引言

剧本杀作为近年来深受年轻人喜爱的社交推理游戏,其线下拼场组队的痛点一直困扰着大量玩家。传统模式下,玩家需要通过微信群、朋友圈等非结构化渠道寻找队友,信息分散且效率低下,经常出现"差一人成局却找不到人"的尴尬局面。本应用以 HarmonyOS 6.1.1 声明式 UI 范式为核心,构建了一套完整的剧本杀拼场平台,涵盖了从组队拼车、本子库检索、车友社交、战绩复盘到订单管理的全链路业务流程。

从技术架构层面来看,本应用采用了 ArkTS 声明式组件体系,通过 @Entry@Component 装饰器构建了多层级的组件树结构。主页面 ScriptKillPage 作为入口组件,负责整体布局编排与底部导航栏的状态管理,下设六个独立的 Tab 组件(PlayTabScriptTabCrewTabRecordTabJOrderTabJMineTab),每个 Tab 组件内部维护各自的 @State 状态变量,实现了高度解耦的模块化设计。这种架构模式使得每个功能模块可以独立开发、测试和维护,同时通过统一的主题色系和交互范式保证了用户体验的一致性。

在业务设计层面,应用围绕"深夜推理局"的品牌定位,精心设计了深夜黑 #0F1222 搭配荧光紫 #A855F7 的视觉主题,底色采用 #F4F2FF 的淡紫色营造沉浸感。应用不仅提供了基本的拼场功能,还创新性地引入了"角色预选"机制——玩家可以在上车主前提前锁定心仪角色,避免到场后角色争抢。同时,"信用分"体系的引入为车友社交提供了信任基础,"未成局自动退款"的承诺降低了用户的参与门槛。应用还内置了题材热度榜、评分分布图、对局时间线等数据可视化组件,将原本枯燥的游戏数据转化为直观的视觉呈现。
在这里插入图片描述

逐段代码分析

一、数据模型与类型定义层

在这里插入图片描述

应用首先定义了一系列接口类型来规范整个数据层的结构。这些接口覆盖了剧本杀业务领域的核心实体,包括题材标签、拼场房间、题材热度、剧本条目、车友成员、战绩记录、评分分布、订单行、徽章、兑换物品等。

interface GenreChip {
  name: string
  count: number
}

interface PlayRoom {
  id: number
  script: string
  genre: string
  store: string
  district: string
  time: string
  need: number
  total: number
  price: number
  dm: string
  hot: boolean
  urgent: boolean
}

interface ScriptItem {
  id: number
  name: string
  genre: string
  players: string
  duration: string
  difficulty: number
  score: number
  played: number
  dmCount: number
  tag: string
  color: string
}

interface CrewMember {
  id: number
  name: string
  level: string
  plays: number
  tags: string[]
  credit: number
  online: boolean
}

上述代码中,PlayRoom 接口是拼场功能的核心数据结构,其中 needtotal 字段分别表示当前缺口人数和总人数,hoturgent 两个布尔字段用于标识热门本和急缺状态。ScriptItem 接口则定义了剧本库中每条剧本的完整元信息,包括难度等级(1-5)、评分、已玩人数和 DM 数量等关键运营指标。CrewMember 接口中的 tags 数组字段用于存储玩家的专长标签(如"恐怖专精"“推理担当”),credit 字段表示信用分,是车友社交体系中的核心信任指标。通过这些强类型的接口定义,整个应用的数据流在编译期就能得到类型安全保障,有效避免了运行时类型错误。

二、静态数据与常量配置

在这里插入图片描述

应用通过 const 声明了大量静态数据数组,作为模拟数据源驱动 UI 渲染。这些数据涵盖了题材标签、拼场房间列表、题材热度、剧本库、车友列表、战绩记录、评分分布、订单列表、徽章墙、积分兑换商品等。

const GENRE_CHIPS: GenreChip[] = [
  { name: '全部题材', count: 486 },
  { name: '恐怖', count: 132 },
  { name: '推理硬核', count: 118 },
  { name: '情感沉浸', count: 96 },
  { name: '欢乐撕逼', count: 74 },
  { name: '机制阵营', count: 66 }
]

const PLAY_ROOMS: PlayRoom[] = [
  { id: 1, script: '雾山夜行', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区',
    time: '今晚 19:30', need: 2, total: 6, price: 88, dm: '阿鬼', hot: true, urgent: true },
  { id: 2, script: '年轮1997', genre: '情感', store: '拾光推理社', district: '武侯区',
    time: '今晚 20:00', need: 3, total: 7, price: 78, dm: '青禾', hot: false, urgent: true },
  { id: 3, script: '无人医院', genre: '恐怖', store: '夜幕剧本馆', district: '成华区',
    time: '明天 14:00', need: 1, total: 5, price: 98, dm: '老猫', hot: true, urgent: false },
  { id: 4, script: '第七封信', genre: '推理', store: '谜案馆·太古里', district: '锦江区',
    time: '明天 18:30', need: 2, total: 6, price: 108, dm: '墨白', hot: false, urgent: false },
  { id: 5, script: '拆迁办风云', genre: '欢乐', store: '笑场剧本杀', district: '金牛区',
    time: '周六 15:00', need: 4, total: 8, price: 68, dm: '二喜', hot: true, urgent: false },
  { id: 6, script: '长安十二辰', genre: '机制', store: '凌云阁', district: '高新区',
    time: '周六 19:00', need: 2, total: 7, price: 128, dm: '谢必安', hot: false, urgent: false },
  { id: 7, script: '雾都孤影', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区',
    time: '周日 20:30', need: 1, total: 6, price: 92, dm: '阿鬼', hot: false, urgent: true },
  { id: 8, script: '月光奏鸣曲', genre: '情感', store: '拾光推理社', district: '武侯区',
    time: '周日 14:00', need: 3, total: 6, price: 82, dm: '青禾', hot: false, urgent: false }
]

GENRE_CHIPS 数组定义了题材分类标签及其对应的剧本数量,用于拼场页顶部的横向滚动筛选条。PLAY_ROOMS 数组是拼场功能的数据核心,每条记录包含了剧本名称、题材类型、门店名称、所在区域、开场时间、缺口人数、总人数、人均价格和 DM 信息等完整信息。特别值得注意的是 hoturgent 两个字段的组合使用——当 urgenttrue 时会在卡片上显示红色"急缺"标签,当 hottrue 时显示紫色"热门本"标签,这两个标签的协同呈现能够快速吸引用户注意力,提升急缺场次的成局率。

三、工具函数与公共 Builder

在这里插入图片描述

应用定义了多个工具函数用于动态计算 UI 尺寸,以及一个公共的模态遮罩 @Builder 函数。

function heatBarH(h: number): number {
  if (h >= 90) {
    return 88
  }
  if (h >= 70) {
    return 70
  }
  if (h >= 55) {
    return 52
  }
  return 36
}

function heatBarColor(h: number): string {
  if (h >= 90) {
    return '#A855F7'
  }
  if (h >= 70) {
    return '#7C3AED'
  }
  return '#4C1D95'
}

function scoreBarW(c: number): number {
  if (c >= 60) {
    return 150
  }
  if (c >= 20) {
    return 80
  }
  if (c >= 8) {
    return 36
  }
  return 14
}

@Builder
function modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(12,10,24,0.6)')
    .onClick(() => {
      onClose()
    })
}

heatBarHheatBarColor 函数分别根据热度值返回柱状图的高度和颜色,热度越高柱子越高、颜色越亮。scoreBarW 函数根据评分样本数量返回进度条宽度,实现了视觉上的自适应缩放。modalOverlay 是一个 @Builder 修饰的全局公共函数,它生成一个半透明遮罩层,接受一个 onClose 回调参数——当用户点击遮罩区域时触发关闭弹窗。这种设计模式将遮罩逻辑抽取为可复用的公共组件,所有弹窗只需调用该函数即可获得统一的遮罩行为,避免了大量重复代码。

四、拼场 Tab——PlayTab 组件

在这里插入图片描述

PlayTab 是应用的核心功能组件,负责展示拼场房间列表、题材筛选、上车确认弹窗、角色预选弹窗和场馆切换弹窗。

@Component
export struct PlayTab {
  @State genreIdx: number = 0
  @State showJoin: boolean = false
  @State showVenue: boolean = false
  @State showRole: boolean = false
  @State selRoom: PlayRoom = PLAY_ROOMS[0]
  @State seatNum: number = 1
  @State roleIdx: number = -1
  @State joined: number = 0

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text('今夜 23 个局在等你')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('拼场免拼卡 · 未成局自动退款')
                .fontSize(11)
                .fontColor('#C4B5FD')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('切场馆')
              .fontSize(12)
              .fontColor('#1E1B33')
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .borderRadius(14)
              .backgroundColor('#A855F7')
              .onClick(() => {
                this.showVenue = true
              })
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('已拼 ' + (this.joined + 31) + ' 场')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('本周热度')
                .fontSize(9)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column({ space: 2 }) {
              Text('97%')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('成局率')
                .fontSize(9)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          .borderRadius(10)
          .backgroundColor('rgba(168,85,247,0.25)')
        }
        .padding(14)
        .borderRadius(14)
        .linearGradient({
          angle: 135,
          colors: [['#1E1B33', 0.0], ['#312E81', 1.0]]
        })
        .width('100%')

PlayTab 组件通过 @State 管理了七个状态变量:genreIdx 控制当前选中的题材索引,showJoinshowVenueshowRole 分别控制三个弹窗的显示状态,selRoom 存储当前选中的房间对象,seatNum 管理占座人数,roleIdx 管理角色选择索引,joined 记录已拼场次。头部区域使用了 linearGradient#1E1B33#312E81 的 135 度渐变背景,营造出深夜推理的沉浸氛围。统计区域采用四列等宽布局(通过 layoutWeight(1) 实现),展示已拼场次、成局率、人均均价和在线车友等核心运营数据。

五、拼场房间列表渲染与上车弹窗

在这里插入图片描述

ForEach(PLAY_ROOMS, (room: PlayRoom) => {
  Column({ space: 10 }) {
    Row({ space: 10 }) {
      Column()
        .width(74)
        .height(92)
        .borderRadius(10)
        .linearGradient({
          angle: 135,
          colors: [['#312E81', 0.0], ['#A855F7', 1.0]]
        })

      Column({ space: 6 }) {
        Row({ space: 6 }) {
          Text(room.genre)
            .fontSize(10)
            .fontColor('#FFFFFF')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(4)
            .backgroundColor('#7C3AED')
          if (room.urgent) {
            Text('急缺')
              .fontSize(10)
              .fontColor('#FFFFFF')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('#EF4444')
          }
          if (room.hot) {
            Text('热门本')
              .fontSize(10)
              .fontColor('#7C3AED')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('#F3E8FF')
          }
        }

        Text(room.script)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1E1B33')

        Text(room.store + ' · ' + room.district)
          .fontSize(11)
          .fontColor('#6B6787')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')

    Row() {
      Row({ space: 6 }) {
        Text('缺 ' + room.need + ' 人')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#EF4444')
        Text('/ ' + room.total + ' 人本')
          .fontSize(10)
          .fontColor('#9B96B8')
        Row({ space: 3 }) {
          ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
            Column()
              .width(10)
              .height(10)
              .borderRadius(5)
              .backgroundColor(i < (room.total - room.need) ? '#A855F7' : '#E9E5F8')
          }, (i: number) => ('s' + i).toString())
        }
      }
      Row()
        .layoutWeight(1)
      Text('¥' + room.price + '/人')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1E1B33')
      Text('上车')
        .fontSize(12)
        .fontColor('#FFFFFF')
        .padding({ left: 14, right: 14, top: 7, bottom: 7 })
        .borderRadius(15)
        .linearGradient({
          angle: 90,
          colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
        })
        .onClick(() => {
          this.selRoom = room
          this.showJoin = true
        })
    }
    .width('100%')
  }
  .padding(12)
  .borderRadius(14)
  .backgroundColor('#FFFFFF')
  .shadow({ radius: 8, color: 'rgba(49,46,129,0.08)', offsetY: 2 })
}, (room: PlayRoom) => room.id.toString())

房间卡片采用了左侧渐变色块加右侧信息区的双栏布局。信息区顶部的标签组通过条件渲染(if (room.urgent)if (room.hot))动态显示状态标签。底部的座位进度可视化是一个巧妙的设计——通过 ForEach 渲染 7 个圆形指示器,已确认的座位显示紫色(#A855F7),空缺的座位显示浅紫色(#E9E5F8),用户可以一目了然地看到当前的拼场进度。点击"上车"按钮后,会将当前房间数据赋值给 selRoom 并触发 showJoin 弹窗。卡片整体添加了 shadow 阴影效果,通过 offsetY: 2 实现微妙的悬浮感。

六、上车确认弹窗与角色预选

在这里插入图片描述

if (this.showJoin) {
  modalOverlay(() => {
    this.showJoin = false
  })
  Column({ space: 14 }) {
    Row() {
      Text('上车确认')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1E1B33')
      Row()
        .layoutWeight(1)
      Text('×')
        .fontSize(18)
        .fontColor('#9B96B8')
        .onClick(() => {
          this.showJoin = false
        })
    }
    .width('100%')

    Column({ space: 6 }) {
      Text(this.selRoom.script + '(' + this.selRoom.genre + ')')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1E1B33')
        .width('100%')
      Text(this.selRoom.store + ' · ' + this.selRoom.time + ' · DM ' + this.selRoom.dm)
        .fontSize(11)
        .fontColor('#6B6787')
        .width('100%')
      Text('还缺 ' + this.selRoom.need + ' 人成局')
        .fontSize(11)
        .fontColor('#EF4444')
        .width('100%')
    }
    .padding(12)
    .borderRadius(10)
    .backgroundColor('#F6F3FE')
    .width('100%')

    Row() {
      Text('占座人数')
        .fontSize(13)
        .fontColor('#4C4670')
      Row()
        .layoutWeight(1)
      Row({ space: 0 }) {
        Text('−')
          .fontSize(16)
          .fontColor(this.seatNum <= 1 ? '#D6D2EA' : '#7C3AED')
          .padding(8)
          .onClick(() => {
            if (this.seatNum > 1) {
              this.seatNum--
            }
          })
        Text(this.seatNum + ' 人')
          .fontSize(13)
          .fontColor('#1E1B33')
          .padding({ left: 10, right: 10 })
        Text('+')
          .fontSize(16)
          .fontColor(this.seatNum >= this.selRoom.need ? '#D6D2EA' : '#7C3AED')
          .padding(8)
          .onClick(() => {
            if (this.seatNum < this.selRoom.need) {
              this.seatNum++
            }
          })
      }
      .borderRadius(8)
      .backgroundColor('#F6F3FE')
    }
    .width('100%')

    Row() {
      Text('提前选角色')
        .fontSize(13)
        .fontColor('#4C4670')
      Row()
        .layoutWeight(1)
      Text('去挑选 >')
        .fontSize(12)
        .fontColor('#7C3AED')
        .onClick(() => {
          this.showRole = true
        })
    }
    .width('100%')

    Row() {
      Column({ space: 1 }) {
        Text('合计')
          .fontSize(11)
          .fontColor('#9B96B8')
        Text('¥' + (this.selRoom.price * this.seatNum))
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#A855F7')
      }
      .alignItems(HorizontalAlign.Start)
      Row()
        .layoutWeight(1)
      Text('确认上车')
        .fontSize(13)
        .fontColor('#FFFFFF')
        .padding({ left: 22, right: 22, top: 9, bottom: 9 })
        .borderRadius(18)
        .linearGradient({
          angle: 90,
          colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
        })
        .onClick(() => {
          this.joined++
          this.showJoin = false
        })
    }
    .width('100%')
  }
  .padding(16)
  .borderRadius(16)
  .backgroundColor('#FFFFFF')
  .constraintSize({ maxHeight: '80%' })
  .width('92%')
}

上车确认弹窗展示了该应用的交互设计精髓。占座人数的加减按钮通过条件表达式动态改变颜色——当达到下限(1 人)时减号变灰,当达到上限(缺口人数)时加号变灰,这种视觉反馈有效防止了无效操作。合计金额通过 this.selRoom.price * this.seatNum 实时计算,体现了声明式 UI 数据驱动的核心特性。点击"确认上车"后,joined 状态递增并关闭弹窗,由于 joined 参与了头部统计区域"已拼 X 场"的计算,UI 会自动更新。弹窗使用 constraintSize({ maxHeight: '80%' }) 限制最大高度,防止内容过多时溢出屏幕。

七、角色预选弹窗

if (this.showRole) {
  modalOverlay(() => {
    this.showRole = false
  })
  Column({ space: 12 }) {
    Text('选择角色(仅本方可见)')
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1E1B33')
      .width('100%')
    Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
      ForEach(['守夜人', '医师', '记者', '警察', '神秘的第七人'], (r: string) => {
        Text(r)
          .fontSize(12)
          .fontColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#FFFFFF' : '#4C4670')
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .borderRadius(10)
          .backgroundColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#A855F7' : '#F6F3FE')
          .margin({ right: 8, bottom: 8 })
          .onClick(() => {
            this.roleIdx = ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r)
          })
      }, (r: string) => r)
    }
    .width('100%')
    Text('锁定')
      .fontSize(13)
      .fontColor('#FFFFFF')
      .padding({ left: 0, right: 0, top: 9, bottom: 9 })
      .borderRadius(16)
      .backgroundColor('#7C3AED')
      .width('100%')
      .textAlign(TextAlign.Center)
      .onClick(() => {
        this.showRole = false
      })
  }
  .padding(16)
  .borderRadius(16)
  .backgroundColor('#FFFFFF')
  .width('86%')
}

角色预选弹窗使用了 Flex 布局配合 FlexWrap.Wrap 实现自动换行的标签流。标题中"仅本方可见"的提示体现了剧本杀游戏中防剧透的设计考量。角色列表通过 ForEach 渲染,选中状态通过对比 roleIdx 与当前角色在数组中的索引来决定,选中时显示紫色背景白色文字,未选中时显示浅紫色背景深紫色文字。这种通过 indexOf 进行选中判断的模式在该应用中被广泛使用,简化了状态管理逻辑。

八、本子库 Tab——ScriptTab 组件

本子库 Tab 提供了剧本检索功能,包含题材热度榜、分类筛选和剧本列表。

@Component
export struct ScriptTab {
  @State catIdx: number = 0
  @State showDetail: boolean = false
  @State showWant: boolean = false
  @State selScript: ScriptItem = SCRIPT_LIST[0]

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 12 }) {
          Row() {
            Text('题材热度榜')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Row()
              .layoutWeight(1)
            Text('本周')
              .fontSize(10)
              .fontColor('#7C3AED')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('#F3E8FF')
          }
          .width('100%')

          Row({ space: 14 }) {
            ForEach(GENRE_HEAT, (g: GenreHeat) => {
              Column({ space: 5 }) {
                Text(g.heat.toString())
                  .fontSize(10)
                  .fontColor(g.heat >= 90 ? '#EF4444' : '#6B6787')
                Column()
                  .width(22)
                  .height(heatBarH(g.heat))
                  .borderRadius({ topLeft: 5, topRight: 5 })
                  .linearGradient({
                    angle: 180,
                    colors: [[heatBarColor(g.heat), 0.0], ['#1E1B33', 1.0]]
                  })
                Text(g.genre)
                  .fontSize(10)
                  .fontColor('#4C4670')
              }
              .alignItems(HorizontalAlign.Center)
            }, (g: GenreHeat) => g.genre)
          }
          .width('100%')
          .padding({ top: 4, bottom: 2 })
        }
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .width('100%')

题材热度榜是该组件的亮点功能。它通过 ForEach 遍历 GENRE_HEAT 数据,为每个题材渲染一个柱状图。柱子高度由 heatBarH(g.heat) 函数动态计算,颜色由 heatBarColor(g.heat) 函数返回,热度值超过 90 的柱子使用最亮的 #A855F7,70-89 使用 #7C3AED,低于 70 使用 #4C1D95。同时,热度数值的文字颜色也通过 g.heat >= 90 ? '#EF4444' : '#6B6787' 条件判断进行动态着色——超过 90 的显示红色以吸引注意。柱状图使用 180 度的线性渐变,从顶部的主色渐变到底部的深色 #1E1B33,营造出层次感。

九、车队 Tab——CrewTab 组件

车队 Tab 展示推荐车友列表,支持评价、搭子邀请和发布招募。

ForEach(CREW_MEMBERS, (m: CrewMember) => {
  Column({ space: 10 }) {
    Row({ space: 12 }) {
      Stack({  }) {
        Column()
          .width(52)
          .height(52)
          .borderRadius(26)
          .linearGradient({
            angle: 135,
            colors: [['#A855F7', 0.0], ['#312E81', 1.0]]
          })
        if (m.online) {
          Column()
            .width(12)
            .height(12)
            .borderRadius(6)
            .backgroundColor('#22C55E')
            .border({ width: 2, color: '#FFFFFF' })
        }
      }
      .width(52)
      .height(52)

      Column({ space: 4 }) {
        Row({ space: 6 }) {
          Text(m.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1E1B33')
          Text(m.level)
            .fontSize(9)
            .fontColor('#7C3AED')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(4)
            .backgroundColor('#F3E8FF')
        }
        Text('拼过 ' + m.plays + ' 本 · 信用分 ' + m.credit)
          .fontSize(11)
          .fontColor('#6B6787')
        Row({ space: 6 }) {
          ForEach(m.tags, (t: string) => {
            Text(t)
              .fontSize(10)
              .fontColor('#4C4670')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('#F6F3FE')
          }, (t: string) => t)
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
    }
    .width('100%')

    Row({ space: 8 }) {
      Row()
        .layoutWeight(1)
      Text('评价')
        .fontSize(11)
        .fontColor('#7C3AED')
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(12)
        .border({ width: 1, color: '#7C3AED' })
        .onClick(() => {
          this.selCrew = m
          this.showRate = true
        })
      Text('搭子邀请')
        .fontSize(11)
        .fontColor('#FFFFFF')
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(12)
        .backgroundColor('#7C3AED')
        .onClick(() => {
          this.selCrew = m
          this.showChat = true
        })
    }
    .width('100%')
  }
  .padding(12)
  .borderRadius(14)
  .backgroundColor('#FFFFFF')
}, (m: CrewMember) => m.id.toString())

车友卡片使用 Stack 组件实现了头像与在线状态指示器的叠层效果。当 m.onlinetrue 时,在头像右下角渲染一个绿色小圆点(#22C55E)并带有白色边框,清晰指示在线状态。车友的专长标签通过 ForEach(m.tags, ...) 遍历渲染为浅紫色的小标签。底部操作区提供了"评价"和"搭子邀请"两个按钮,分别打开评价弹窗和聊天弹窗。评价弹窗中实现了五星评分功能,通过 ForEach([1, 2, 3, 4, 5], ...) 渲染五颗星星,点击时更新 starNum,并根据评分值显示不同的评语文案。

十、战绩 Tab——RecordTab 组件

战绩 Tab 以时间线形式展示用户的历史对局记录,并支持复盘笔记功能。

ForEach(RECORD_LIST, (r: RecordItem) => {
  Row({ space: 12 }) {
    Column({ space: 4 }) {
      Text(r.date)
        .fontSize(11)
        .fontColor('#7C3AED')
        .fontWeight(FontWeight.Bold)
      Column()
        .width(2)
        .height(64)
        .backgroundColor('#E5E0F5')
    }
    .alignItems(HorizontalAlign.Center)

    Column({ space: 6 }) {
      Row({ space: 8 }) {
        Text(r.script)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1E1B33')
        Text(r.result)
          .fontSize(10)
          .fontColor(r.resultColor)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .borderRadius(4)
          .backgroundColor('#F6F3FE')
      }
      Text('角色:' + r.role)
        .fontSize(11)
        .fontColor('#6B6787')
      Row({ space: 4 }) {
        ForEach([1, 2, 3, 4, 5], (i: number) => {
          Text(i <= r.mood ? '★' : '☆')
            .fontSize(11)
            .fontColor(i <= r.mood ? '#F59E0B' : '#D6D2EA')
        }, (i: number) => (r.id.toString() + '-' + i).toString())
      }
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Text('复盘')
      .fontSize(11)
      .fontColor('#7C3AED')
      .padding({ left: 10, right: 10, top: 5, bottom: 5 })
      .borderRadius(10)
      .border({ width: 1, color: '#7C3AED' })
      .onClick(() => {
        this.selRecord = r
        this.showNote = true
      })
  }
  .width('100%')
  .padding(12)
  .borderRadius(12)
  .backgroundColor('#FFFFFF')
}, (r: RecordItem) => r.id.toString())

对局时间线采用了左侧竖线加右侧内容卡片的经典时间线布局。左侧的竖线(width(2).height(64).backgroundColor('#E5E0F5'))作为时间轴的视觉引导线,顶部显示日期标签。右侧内容区展示了剧本名称、对局结果(使用不同颜色的标签区分"凶手获胜"“沉浸通关”"投凶正确"等结果)、扮演角色和心情评分(五星制)。心情评分通过 ForEach([1, 2, 3, 4, 5], ...) 渲染,满足条件 i <= r.mood 的星星显示金色(#F59E0B),否则显示灰色空星。点击"复盘"按钮打开复盘笔记弹窗,其中包含 DM 点评和用户自填笔记区域。

十一、订单 Tab——JOrderTab 组件

订单 Tab 提供了订单筛选、核销码展示、改期和取消等功能。

if (this.showCode) {
  modalOverlay(() => {
    this.showCode = false
  })
  Column({ space: 14 }) {
    Text('到场核销码')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1E1B33')
    Text(this.selOrder.script + ' · ' + this.selOrder.time)
      .fontSize(12)
      .fontColor('#6B6787')
    Column({ space: 10 }) {
      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
        ForEach([1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], (b: number) => {
          Column()
            .width(16)
            .height(16)
            .backgroundColor(b === 1 ? '#1E1B33' : '#FFFFFF')
            .border({ width: 1, color: '#E5E0F5' })
            .margin(2)
        }, (b: number, i: number) => (b.toString() + i).toString())
      }
      Text('8826  1947')
        .fontSize(20)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1E1B33')
        .letterSpacing(3)
    }
    .padding(16)
    .borderRadius(12)
    .backgroundColor('#FBFAFF')
    .width('100%')
    .alignItems(HorizontalAlign.Center)
  }
}

核销码弹窗是该组件最具特色的功能。它通过 ForEach 渲染了一个 5x5 的二维码矩阵——使用一个 25 元素的二值数组(0 和 1),值为 1 的方块显示深色(#1E1B33),值为 0 的显示白色并带有浅色边框。这个伪二维码虽然不是真正的可扫描二维码,但在视觉上高度还原了二维码的外观。下方搭配 8 位数字核销码(使用 letterSpacing(3) 增加字间距),用户可出示给前台 DM 扫码入场。订单列表中的操作按钮根据订单状态动态显示——待开场状态显示"核销码"“改期”“取消"三个按钮,其他状态只显示"再来一单”。

十二、我的 Tab——JMineTab 组件与主页路由

我的 Tab 展示用户信息、等级进度、积分兑换、徽章墙和设置等功能。

@Entry
@Component
struct ScriptKillPage {
  @State activeTab: number = 0
  @State showSearch: boolean = false
  @State showMsg: boolean = false
  @State unRead: number = 2

  build() {
    Column() {
      Column() {
        Row({ space: 10 }) {
          Text('夜拼')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Row({ space: 6 }) {
            Text('🔍')
              .fontSize(13)
            TextInput({ placeholder: '雾山夜行 / 恐怖本 / 迷雾剧场' })
              .fontSize(12)
              .height(34)
              .layoutWeight(1)
              .backgroundColor('#EDE9FE')
              .borderRadius(17)
          }
          .layoutWeight(1)
          .padding({ left: 6, right: 6 })
          .backgroundColor('#FFFFFF')
          .borderRadius(17)

          Stack({ alignContent: Alignment.TopEnd }) {
            Text('✉')
              .fontSize(18)
              .fontColor('#FFFFFF')
            if (this.unRead > 0) {
              Column()
                .width(8)
                .height(8)
                .borderRadius(4)
                .backgroundColor('#A855F7')
                .margin({ top: -2, right: -2 })
            }
          }
          .width(28)
          .height(28)
          .onClick(() => {
            this.showMsg = true
          })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      }
      .linearGradient({
        angle: 135,
        colors: [['#0F1222', 0.0], ['#312E81', 1.0]]
      })
      .width('100%')

      if (this.activeTab === 0) {
        PlayTab()
      } else if (this.activeTab === 1) {
        ScriptTab()
      } else if (this.activeTab === 2) {
        CrewTab()
      } else if (this.activeTab === 3) {
        RecordTab()
      } else if (this.activeTab === 4) {
        JOrderTab()
      } else {
        JMineTab()
      }

      Row({ space: 0 }) {
        ForEach(MAIN_TABS, (tab: TabMeta, idx: number) => {
          Column({ space: 3 }) {
            Text(tab.icon)
              .fontSize(14)
              .fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.activeTab === idx ? '#7C3AED' : '#9B96B8')
            if (this.activeTab === idx) {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('#A855F7')
            } else {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('rgba(0,0,0,0)')
            }
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 7, bottom: 7 })
          .onClick(() => {
            this.activeTab = idx
          })
        }, (tab: TabMeta, idx: number) => (tab.name + idx).toString())
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F2FF')
  }
}

主页面 ScriptKillPage 通过 @Entry 装饰器标记为应用入口。头部导航栏使用 Stack 组件在消息图标上叠加了一个未读消息红点指示器(当 unRead > 0 时显示),并使用负 margin 将红点偏移到右上角。Tab 切换通过 if-else 链式判断实现组件的条件渲染,activeTab 的值决定了当前显示哪个子组件。底部导航栏通过 ForEach(MAIN_TABS, ...) 渲染六个 Tab 按钮,选中状态使用加粗字体和紫色文字,并在下方显示一个紫色指示条(#A855F7),未选中状态的指示条使用透明色保持布局对齐。消息中心弹窗实现了未读消息列表和"全部已读"功能,点击后将 unRead 置为 0,红点自动消失。

整体业务流程图

0

1

2

3

4

5

本子详情

想玩清单

评价

搭子邀请

核销码

改期

取消

应用启动 ScriptKillPage

头部导航栏

activeTab 选择

PlayTab 拼场

ScriptTab 本子库

CrewTab 车队

RecordTab 战绩

JOrderTab 订单

JMineTab 我的

题材筛选条

房间卡片列表

点击上车

上车确认弹窗

提前选角色?

角色预选弹窗

确认上车 joined++

题材热度柱状图

分类筛选

剧本卡片列表

操作选择

详情弹窗

想玩提醒弹窗

发布招募入口

车友列表

操作选择

星级评价弹窗

聊天弹窗

招募发布弹窗

战绩统计栏

评分分布图

对局时间线

点击复盘

复盘笔记弹窗

订单筛选

订单卡片列表

待开场操作

二维码弹窗

改期弹窗

取消确认弹窗

用户信息卡

积分兑换

徽章墙

设置弹窗

消息中心

未读红点

消息列表弹窗

技术点对比表格

技术维度拼场Tab (PlayTab)本子库Tab (ScriptTab)车队Tab (CrewTab)战绩Tab (RecordTab)订单Tab (JOrderTab)我的Tab (JMineTab)
核心数据结构PlayRoomScriptItemCrewMemberRecordItemOrderRowBadgeItem/ExchangeItem
状态变量数量7个4个7个3个5个6个
弹窗数量3个(上车/角色/场馆)2个(详情/想玩)3个(评价/聊天/招募)1个(复盘)3个(核销/改期/取消)4个(兑换/徽章/等级/设置)
列表渲染方式ForEach房间卡片ForEach剧本卡片ForEach车友卡片ForEach时间线ForEach订单卡片ForEach兑换/菜单
数据可视化座位进度圆点热度柱状图在线状态点评分分布条+时间线二维码矩阵等级进度条
交互特色角色预选机制想玩清单订阅信用分体系DM点评+自填笔记伪二维码核销夜间模式开关
渐变背景135度深紫渐变白色卡片135度紫蓝渐变135度黑紫渐变135度紫蓝渐变135度黑紫渐变
表单输入TextInput聊天/招募TextInput笔记

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 主题:深夜黑 #0F1222 × 荧光紫 #A855F7,底色 #F4F2FF,深夜推理局风格
// 6 个底部 Tab:拼场 / 本子库 / 车队 / 战绩 / 订单 / 我的

interface GenreChip {
  name: string
  count: number
}

interface PlayRoom {
  id: number
  script: string
  genre: string
  store: string
  district: string
  time: string
  need: number
  total: number
  price: number
  dm: string
  hot: boolean
  urgent: boolean
}

interface GenreHeat {
  genre: string
  heat: number
}

interface ScriptItem {
  id: number
  name: string
  genre: string
  players: string
  duration: string
  difficulty: number
  score: number
  played: number
  dmCount: number
  tag: string
  color: string
}

interface CrewMember {
  id: number
  name: string
  level: string
  plays: number
  tags: string[]
  credit: number
  online: boolean
}

interface RecordItem {
  id: number
  date: string
  script: string
  role: string
  result: string
  resultColor: string
  mood: number
}

interface ScoreBucket {
  star: number
  count: number
}

interface OrderRow {
  id: string
  script: string
  store: string
  time: string
  status: string
  statusColor: string
  price: number
  seats: number
}

interface BadgeItem {
  id: number
  name: string
  desc: string
  lit: boolean
}

interface ExchangeItem {
  id: number
  name: string
  cost: number
  color: string
}

interface TabMeta {
  name: string
  icon: string
}

interface MsgMeta {
  id: number
  title: string
  content: string
  time: string
  read: boolean
}

const GENRE_CHIPS: GenreChip[] = [
  { name: '全部题材', count: 486 },
  { name: '恐怖', count: 132 },
  { name: '推理硬核', count: 118 },
  { name: '情感沉浸', count: 96 },
  { name: '欢乐撕逼', count: 74 },
  { name: '机制阵营', count: 66 }
]

const PLAY_ROOMS: PlayRoom[] = [
  { id: 1, script: '雾山夜行', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区', time: '今晚 19:30', need: 2, total: 6, price: 88, dm: '阿鬼', hot: true, urgent: true },
  { id: 2, script: '年轮1997', genre: '情感', store: '拾光推理社', district: '武侯区', time: '今晚 20:00', need: 3, total: 7, price: 78, dm: '青禾', hot: false, urgent: true },
  { id: 3, script: '无人医院', genre: '恐怖', store: '夜幕剧本馆', district: '成华区', time: '明天 14:00', need: 1, total: 5, price: 98, dm: '老猫', hot: true, urgent: false },
  { id: 4, script: '第七封信', genre: '推理', store: '谜案馆·太古里', district: '锦江区', time: '明天 18:30', need: 2, total: 6, price: 108, dm: '墨白', hot: false, urgent: false },
  { id: 5, script: '拆迁办风云', genre: '欢乐', store: '笑场剧本杀', district: '金牛区', time: '周六 15:00', need: 4, total: 8, price: 68, dm: '二喜', hot: true, urgent: false },
  { id: 6, script: '长安十二辰', genre: '机制', store: '凌云阁', district: '高新区', time: '周六 19:00', need: 2, total: 7, price: 128, dm: '谢必安', hot: false, urgent: false },
  { id: 7, script: '雾都孤影', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区', time: '周日 20:30', need: 1, total: 6, price: 92, dm: '阿鬼', hot: false, urgent: true },
  { id: 8, script: '月光奏鸣曲', genre: '情感', store: '拾光推理社', district: '武侯区', time: '周日 14:00', need: 3, total: 6, price: 82, dm: '青禾', hot: false, urgent: false }
]

const GENRE_HEAT: GenreHeat[] = [
  { genre: '恐怖', heat: 92 },
  { genre: '推理', heat: 78 },
  { genre: '情感', heat: 70 },
  { genre: '欢乐', heat: 58 },
  { genre: '机制', heat: 46 }
]

const SCRIPT_LIST: ScriptItem[] = [
  { id: 1, name: '雾山夜行', genre: '恐怖', players: '6 人', duration: '4.5h', difficulty: 4, score: 9.2, played: 8421, dmCount: 12, tag: '年度十佳', color: '#2E2350' },
  { id: 2, name: '第七封信', genre: '推理', players: '6 人', duration: '5h', difficulty: 5, score: 9.5, played: 6210, dmCount: 9, tag: '硬核闭眼冲', color: '#1F3A5F' },
  { id: 3, name: '年轮1997', genre: '情感', players: '7 人', duration: '4h', difficulty: 2, score: 9.0, played: 9833, dmCount: 21, tag: '哭到缺氧', color: '#5F2A4A' },
  { id: 4, name: '无人医院', genre: '恐怖', players: '5 人', duration: '4h', difficulty: 3, score: 8.8, played: 5320, dmCount: 15, tag: '单排友好', color: '#2F3B2F' },
  { id: 5, name: '拆迁办风云', genre: '欢乐', players: '8 人', duration: '3.5h', difficulty: 1, score: 8.6, played: 11207, dmCount: 18, tag: '团建神器', color: '#6B4A1F' },
  { id: 6, name: '长安十二辰', genre: '机制', players: '7 人', duration: '6h', difficulty: 5, score: 9.4, played: 4108, dmCount: 7, tag: '进阶必玩', color: '#3D2F5F' }
]

const CREW_MEMBERS: CrewMember[] = [
  { id: 1, name: '夜行者K', level: 'Lv.9 老玩家', plays: 187, tags: ['恐怖专精', '不贴脸', '讲礼貌'], credit: 98, online: true },
  { id: 2, name: '陈皮话梅糖', level: 'Lv.7 常驻', plays: 96, tags: ['情感本', '氛围组', '可哭'], credit: 95, online: true },
  { id: 3, name: '阿鬼的猫', level: 'Lv.8 老玩家', plays: 142, tags: ['推理担当', '全类型', '节奏快'], credit: 99, online: false },
  { id: 4, name: '栗子不甜', level: 'Lv.5 进阶', plays: 41, tags: ['欢乐本', '话痨', '能接梗'], credit: 92, online: true },
  { id: 5, name: 'ZERO', level: 'Lv.10 大神', plays: 268, tags: ['机制本', '算牌流', '带飞'], credit: 97, online: false }
]

const RECORD_LIST: RecordItem[] = [
  { id: 1, date: '08/22', script: '雾山夜行', role: '守夜人·陈九', result: '凶手获胜', resultColor: '#A855F7', mood: 5 },
  { id: 2, date: '08/17', script: '年轮1997', role: '二姐·林晚', result: '沉浸通关', resultColor: '#7C3AED', mood: 5 },
  { id: 3, date: '08/10', script: '第七封信', role: '邮差·沈默', result: '投凶正确', resultColor: '#22C55E', mood: 4 },
  { id: 4, date: '08/03', script: '拆迁办风云', role: '钉子户·王哥', result: '阵营胜利', resultColor: '#F59E0B', mood: 5 },
  { id: 5, date: '07/27', script: '无人医院', role: '实习医生·顾一', result: '投凶失误', resultColor: '#94A3B8', mood: 3 },
  { id: 6, date: '07/19', script: '长安十二辰', role: '不良帅·裴远', result: '个人第一', resultColor: '#A855F7', mood: 5 }
]

const SCORE_BUCKETS: ScoreBucket[] = [
  { star: 5, count: 68 },
  { star: 4, count: 21 },
  { star: 3, count: 8 },
  { star: 2, count: 2 },
  { star: 1, count: 1 }
]

const ORDER_ROWS: OrderRow[] = [
  { id: 'JP2026082401', script: '雾山夜行', store: '迷雾剧场·春熙店', time: '08/24 19:30', status: '待开场', statusColor: '#A855F7', price: 88, seats: 2 },
  { id: 'JP2026082002', script: '年轮1997', store: '拾光推理社', time: '08/20 14:00', status: '已完成', statusColor: '#6B6787', price: 78, seats: 1 },
  { id: 'JP2026081503', script: '第七封信', store: '谜案馆·太古里', time: '08/15 18:30', status: '已改期', statusColor: '#F59E0B', price: 108, seats: 3 },
  { id: 'JP2026080904', script: '无人医院', store: '夜幕剧本馆', time: '08/09 20:00', status: '已取消', statusColor: '#94A3B8', price: 98, seats: 1 }
]

const ORDER_FILTERS: string[] = ['全部', '待开场', '已完成', '已改期', '已取消']

const BADGE_LIST: BadgeItem[] = [
  { id: 1, name: '百本斩', desc: '完成 100 本', lit: true },
  { id: 2, name: '恐怖免疫', desc: '恐怖本 20 场', lit: true },
  { id: 3, name: '泪失禁体质', desc: '情感本哭 10 次', lit: true },
  { id: 4, name: '名侦探', desc: '投凶正确率 80%', lit: true },
  { id: 5, name: '社交悍匪', desc: '拼场 50 次', lit: true },
  { id: 6, name: '千本斩', desc: '完成 1000 本', lit: false }
]

const EXCHANGE_LIST: ExchangeItem[] = [
  { id: 1, name: '恐怖本 5 折券', cost: 500, color: '#A855F7' },
  { id: 2, name: '免拼卡(随时退)', cost: 800, color: '#7C3AED' },
  { id: 3, name: '限定徽章·夜枭', cost: 1200, color: '#1E1B33' },
  { id: 4, name: 'DM 点评特权', cost: 300, color: '#F59E0B' }
]

const MAIN_TABS: TabMeta[] = [
  { name: '拼场', icon: '拼场' },
  { name: '本子库', icon: '本子' },
  { name: '车队', icon: '车队' },
  { name: '战绩', icon: '战绩' },
  { name: '订单', icon: '订单' },
  { name: '我的', icon: '我的' }
]

const MSG_LIST: MsgMeta[] = [
  { id: 1, title: '拼场成功', content: '「雾山夜行」今晚 19:30 已成局,请提前 15 分钟到场', time: '10:03', read: false },
  { id: 2, title: 'DM 已确认', content: '阿鬼 DM 已确认带本场,恐怖程度可调节', time: '09:45', read: false },
  { id: 3, title: '车队邀请', content: '夜行者K 邀请你加入周六「长安十二辰」车队', time: '昨天', read: true },
  { id: 4, title: '复盘上线', content: '你 08/22 的「雾山夜行」复盘已生成', time: '昨天', read: true },
  { id: 5, title: '积分到账', content: '拼场返 88 积分,可兑换恐怖本 5 折券', time: '08/22', read: true }
]

function heatBarH(h: number): number {
  if (h >= 90) {
    return 88
  }
  if (h >= 70) {
    return 70
  }
  if (h >= 55) {
    return 52
  }
  return 36
}

function heatBarColor(h: number): string {
  if (h >= 90) {
    return '#A855F7'
  }
  if (h >= 70) {
    return '#7C3AED'
  }
  return '#4C1D95'
}

function scoreBarW(c: number): number {
  if (c >= 60) {
    return 150
  }
  if (c >= 20) {
    return 80
  }
  if (c >= 8) {
    return 36
  }
  return 14
}

@Builder
function modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(12,10,24,0.6)')
    .onClick(() => {
      onClose()
    })
}

// ==================== Tab 1 拼场 ====================
@Component
export struct PlayTab {
  @State genreIdx: number = 0
  @State showJoin: boolean = false
  @State showVenue: boolean = false
  @State showRole: boolean = false
  @State selRoom: PlayRoom = PLAY_ROOMS[0]
  @State seatNum: number = 1
  @State roleIdx: number = -1
  @State joined: number = 0

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text('今夜 23 个局在等你')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('拼场免拼卡 · 未成局自动退款')
                .fontSize(11)
                .fontColor('#C4B5FD')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('切场馆')
              .fontSize(12)
              .fontColor('#1E1B33')
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .borderRadius(14)
              .backgroundColor('#A855F7')
              .onClick(() => {
                this.showVenue = true
              })
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('已拼 ' + (this.joined + 31) + ' 场')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('本周热度')
                .fontSize(9)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column({ space: 2 }) {
              Text('97%')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('成局率')
                .fontSize(9)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column({ space: 2 }) {
              Text('¥82')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('人均均价')
                .fontSize(9)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column({ space: 2 }) {
              Text('128')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('在线车友')
                .fontSize(9)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          .borderRadius(10)
          .backgroundColor('rgba(168,85,247,0.25)')
        }
        .padding(14)
        .borderRadius(14)
        .linearGradient({
          angle: 135,
          colors: [['#1E1B33', 0.0], ['#312E81', 1.0]]
        })
        .width('100%')

        Scroll() {
          Row({ space: 8 }) {
            ForEach(GENRE_CHIPS, (chip: GenreChip) => {
              Text(chip.name + ' ' + chip.count)
                .fontSize(12)
                .fontColor(this.genreIdx === GENRE_CHIPS.indexOf(chip) ? '#FFFFFF' : '#4C4670')
                .padding({ left: 13, right: 13, top: 7, bottom: 7 })
                .borderRadius(15)
                .backgroundColor(this.genreIdx === GENRE_CHIPS.indexOf(chip) ? '#A855F7' : '#FFFFFF')
                .onClick(() => {
                  this.genreIdx = GENRE_CHIPS.indexOf(chip)
                })
            }, (chip: GenreChip) => chip.name)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .width('100%')

        ForEach(PLAY_ROOMS, (room: PlayRoom) => {
          Column({ space: 10 }) {
            Row({ space: 10 }) {
              Column()
                .width(74)
                .height(92)
                .borderRadius(10)
                .linearGradient({
                  angle: 135,
                  colors: [['#312E81', 0.0], ['#A855F7', 1.0]]
                })

              Column({ space: 6 }) {
                Row({ space: 6 }) {
                  Text(room.genre)
                    .fontSize(10)
                    .fontColor('#FFFFFF')
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(4)
                    .backgroundColor('#7C3AED')
                  if (room.urgent) {
                    Text('急缺')
                      .fontSize(10)
                      .fontColor('#FFFFFF')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(4)
                      .backgroundColor('#EF4444')
                  }
                  if (room.hot) {
                    Text('热门本')
                      .fontSize(10)
                      .fontColor('#7C3AED')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(4)
                      .backgroundColor('#F3E8FF')
                  }
                }

                Text(room.script)
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E1B33')

                Text(room.store + ' · ' + room.district)
                  .fontSize(11)
                  .fontColor('#6B6787')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })

                Row({ space: 8 }) {
                  Text('🕐 ' + room.time)
                    .fontSize(11)
                    .fontColor('#4C4670')
                  Text('DM ' + room.dm)
                    .fontSize(11)
                    .fontColor('#4C4670')
                }
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row() {
              Row({ space: 6 }) {
                Text('缺 ' + room.need + ' 人')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#EF4444')
                Text('/ ' + room.total + ' 人本')
                  .fontSize(10)
                  .fontColor('#9B96B8')
                Row({ space: 3 }) {
                  ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
                    Column()
                      .width(10)
                      .height(10)
                      .borderRadius(5)
                      .backgroundColor(i < (room.total - room.need) ? '#A855F7' : '#E9E5F8')
                  }, (i: number) => ('s' + i).toString())
                }
              }

              Row()
                .layoutWeight(1)

              Text('¥' + room.price + '/人')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
              Text('上车')
                .fontSize(12)
                .fontColor('#FFFFFF')
                .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                .borderRadius(15)
                .linearGradient({
                  angle: 90,
                  colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
                })
                .onClick(() => {
                  this.selRoom = room
                  this.showJoin = true
                })
            }
            .width('100%')
          }
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
          .shadow({ radius: 8, color: 'rgba(49,46,129,0.08)', offsetY: 2 })
        }, (room: PlayRoom) => room.id.toString())

        if (this.showJoin) {
          modalOverlay(() => {
            this.showJoin = false
          })
          Column({ space: 14 }) {
            Row() {
              Text('上车确认')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#9B96B8')
                .onClick(() => {
                  this.showJoin = false
                })
            }
            .width('100%')

            Column({ space: 6 }) {
              Text(this.selRoom.script + '(' + this.selRoom.genre + ')')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
                .width('100%')
              Text(this.selRoom.store + ' · ' + this.selRoom.time + ' · DM ' + this.selRoom.dm)
                .fontSize(11)
                .fontColor('#6B6787')
                .width('100%')
              Text('还缺 ' + this.selRoom.need + ' 人成局')
                .fontSize(11)
                .fontColor('#EF4444')
                .width('100%')
            }
            .padding(12)
            .borderRadius(10)
            .backgroundColor('#F6F3FE')
            .width('100%')

            Row() {
              Text('占座人数')
                .fontSize(13)
                .fontColor('#4C4670')
              Row()
                .layoutWeight(1)
              Row({ space: 0 }) {
                Text('−')
                  .fontSize(16)
                  .fontColor(this.seatNum <= 1 ? '#D6D2EA' : '#7C3AED')
                  .padding(8)
                  .onClick(() => {
                    if (this.seatNum > 1) {
                      this.seatNum--
                    }
                  })
                Text(this.seatNum + ' 人')
                  .fontSize(13)
                  .fontColor('#1E1B33')
                  .padding({ left: 10, right: 10 })
                Text('+')
                  .fontSize(16)
                  .fontColor(this.seatNum >= this.selRoom.need ? '#D6D2EA' : '#7C3AED')
                  .padding(8)
                  .onClick(() => {
                    if (this.seatNum < this.selRoom.need) {
                      this.seatNum++
                    }
                  })
              }
              .borderRadius(8)
              .backgroundColor('#F6F3FE')
            }
            .width('100%')

            Row() {
              Text('提前选角色')
                .fontSize(13)
                .fontColor('#4C4670')
              Row()
                .layoutWeight(1)
              Text('去挑选 >')
                .fontSize(12)
                .fontColor('#7C3AED')
                .onClick(() => {
                  this.showRole = true
                })
            }
            .width('100%')

            Text('· 未成局自动全额退款\n· 开场前 4 小时可免费退改')
              .fontSize(11)
              .fontColor('#9B96B8')
              .lineHeight(18)
              .width('100%')

            Row() {
              Column({ space: 1 }) {
                Text('合计')
                  .fontSize(11)
                  .fontColor('#9B96B8')
                Text('¥' + (this.selRoom.price * this.seatNum))
                  .fontSize(20)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#A855F7')
              }
              .alignItems(HorizontalAlign.Start)

              Row()
                .layoutWeight(1)

              Text('确认上车')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 22, right: 22, top: 9, bottom: 9 })
                .borderRadius(18)
                .linearGradient({
                  angle: 90,
                  colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
                })
                .onClick(() => {
                  this.joined++
                  this.showJoin = false
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '80%' })
          .width('92%')
        }

        if (this.showRole) {
          modalOverlay(() => {
            this.showRole = false
          })
          Column({ space: 12 }) {
            Text('选择角色(仅本方可见)')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')
            Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
              ForEach(['守夜人', '医师', '记者', '警察', '神秘的第七人'], (r: string) => {
                Text(r)
                  .fontSize(12)
                  .fontColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#FFFFFF' : '#4C4670')
                  .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                  .borderRadius(10)
                  .backgroundColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#A855F7' : '#F6F3FE')
                  .margin({ right: 8, bottom: 8 })
                  .onClick(() => {
                    this.roleIdx = ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r)
                  })
              }, (r: string) => r)
            }
            .width('100%')
            Text('锁定')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 0, right: 0, top: 9, bottom: 9 })
              .borderRadius(16)
              .backgroundColor('#7C3AED')
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showRole = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('86%')
        }

        if (this.showVenue) {
          modalOverlay(() => {
            this.showVenue = false
          })
          Column({ space: 10 }) {
            Text('切换场馆')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')
            ForEach(['迷雾剧场·春熙店', '拾光推理社', '夜幕剧本馆', '谜案馆·太古里', '笑场剧本杀', '凌云阁'], (v: string) => {
              Row() {
                Column({ space: 2 }) {
                  Text(v)
                    .fontSize(13)
                    .fontColor('#1E1B33')
                  Text('本周 12 场 · 人均 ¥86')
                    .fontSize(10)
                    .fontColor('#9B96B8')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                Text('查看')
                  .fontSize(11)
                  .fontColor('#7C3AED')
                  .padding({ left: 10, right: 10, top: 4, bottom: 4 })
                  .borderRadius(8)
                  .backgroundColor('#F6F3FE')
                  .onClick(() => {
                    this.showVenue = false
                  })
              }
              .width('100%')
              .padding({ top: 10, bottom: 10 })
            }, (v: string) => v)
          }
          .padding(18)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '72%' })
          .width('88%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F4F2FF')
    .layoutWeight(1)
  }
}

// ==================== Tab 2 本子库 ====================
@Component
export struct ScriptTab {
  @State catIdx: number = 0
  @State showDetail: boolean = false
  @State showWant: boolean = false
  @State selScript: ScriptItem = SCRIPT_LIST[0]

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 12 }) {
          Row() {
            Text('题材热度榜')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Row()
              .layoutWeight(1)
            Text('本周')
              .fontSize(10)
              .fontColor('#7C3AED')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('#F3E8FF')
          }
          .width('100%')

          Row({ space: 14 }) {
            ForEach(GENRE_HEAT, (g: GenreHeat) => {
              Column({ space: 5 }) {
                Text(g.heat.toString())
                  .fontSize(10)
                  .fontColor(g.heat >= 90 ? '#EF4444' : '#6B6787')
                Column()
                  .width(22)
                  .height(heatBarH(g.heat))
                  .borderRadius({ topLeft: 5, topRight: 5 })
                  .linearGradient({
                    angle: 180,
                    colors: [[heatBarColor(g.heat), 0.0], ['#1E1B33', 1.0]]
                  })
                Text(g.genre)
                  .fontSize(10)
                  .fontColor('#4C4670')
              }
              .alignItems(HorizontalAlign.Center)
            }, (g: GenreHeat) => g.genre)
          }
          .width('100%')
          .padding({ top: 4, bottom: 2 })
        }
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .width('100%')

        Row({ space: 8 }) {
          ForEach(['全部', '恐怖', '推理', '情感', '欢乐', '机制'], (cat: string) => {
            Text(cat)
              .fontSize(12)
              .fontColor(this.catIdx === ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat) ? '#FFFFFF' : '#4C4670')
              .padding({ left: 13, right: 13, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor(this.catIdx === ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat) ? '#7C3AED' : '#FFFFFF')
              .border({ width: 1, color: this.catIdx === ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat) ? '#7C3AED' : '#E5E0F5' })
              .onClick(() => {
                this.catIdx = ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat)
              })
          }, (cat: string) => cat)
        }
        .width('100%')

        ForEach(SCRIPT_LIST, (s: ScriptItem) => {
          Column({ space: 10 }) {
            Row({ space: 12 }) {
              Column({ space: 4 }) {
                Column()
                  .width(70)
                  .height(92)
                  .borderRadius(10)
                  .backgroundColor(s.color)
                Text(s.tag)
                  .fontSize(9)
                  .fontColor('#7C3AED')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(4)
                  .backgroundColor('#F3E8FF')
              }
              .alignItems(HorizontalAlign.Center)

              Column({ space: 5 }) {
                Row({ space: 6 }) {
                  Text(s.name)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1E1B33')
                  Text(s.genre)
                    .fontSize(10)
                    .fontColor('#FFFFFF')
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(4)
                    .backgroundColor('#7C3AED')
                }

                Row({ space: 10 }) {
                  Text(s.players + '本')
                  Text(s.duration)
                  Text('难度 ' + s.difficulty + '/5')
                }

                Row({ space: 10 }) {
                  Text('★ ' + s.score.toFixed(1))
                    .fontSize(11)
                    .fontColor('#F59E0B')
                  Text(s.played + ' 人玩过')
                    .fontSize(11)
                    .fontColor('#6B6787')
                  Text(s.dmCount + ' 位 DM 在带')
                    .fontSize(11)
                    .fontColor('#6B6787')
                }
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row() {
              Text('¥78 起')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#A855F7')
              Row()
                .layoutWeight(1)
              Text('想玩清单')
                .fontSize(11)
                .fontColor('#4C4670')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .border({ width: 1, color: '#D6D2EA' })
                .onClick(() => {
                  this.selScript = s
                  this.showWant = true
                })
              Text('本子详情')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#7C3AED')
                .onClick(() => {
                  this.selScript = s
                  this.showDetail = true
                })
            }
            .width('100%')
          }
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
        }, (s: ScriptItem) => s.id.toString())

        if (this.showDetail) {
          modalOverlay(() => {
            this.showDetail = false
          })
          Column({ space: 12 }) {
            Row() {
              Text('本子详情')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#9B96B8')
                .onClick(() => {
                  this.showDetail = false
                })
            }
            .width('100%')

            Row({ space: 12 }) {
              Column()
                .width(80)
                .height(104)
                .borderRadius(10)
                .backgroundColor(this.selScript.color)
              Column({ space: 5 }) {
                Text(this.selScript.name)
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E1B33')
                Text(this.selScript.genre + ' · ' + this.selScript.tag)
                  .fontSize(11)
                  .fontColor('#7C3AED')
                Text('人数 ' + this.selScript.players + ' · 时长 ' + this.selScript.duration + ' · 难度 ' + this.selScript.difficulty + '/5')
                  .fontSize(11)
                  .fontColor('#6B6787')
                Text('★ ' + this.selScript.score.toFixed(1) + ' · ' + this.selScript.played + ' 人玩过')
                  .fontSize(11)
                  .fontColor('#F59E0B')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Column({ space: 6 }) {
              Text('角色一览(无剧透)')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
                .width('100%')
              Flex({ wrap: FlexWrap.Wrap }) {
                ForEach(['守夜人', '巡山医倌', '纸扎铺老板', '货车司机', '返乡学生', '护士'], (r: string) => {
                  Text(r)
                    .fontSize(11)
                    .fontColor('#4C4670')
                    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                    .borderRadius(8)
                    .backgroundColor('#F6F3FE')
                    .margin({ right: 8, bottom: 8 })
                }, (r: string) => r)
              }
              .width('100%')
            }
            .padding(10)
            .borderRadius(10)
            .backgroundColor('#FBFAFF')
            .width('100%')

            Row() {
              Text('¥78/人 起')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#A855F7')
              Row()
                .layoutWeight(1)
              Text('加入想玩')
                .fontSize(12)
                .fontColor('#7C3AED')
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .borderRadius(14)
                .border({ width: 1, color: '#7C3AED' })
                .onClick(() => {
                  this.showWant = true
                })
              Text('去拼场')
                .fontSize(12)
                .fontColor('#FFFFFF')
                .padding({ left: 14, right: 14, top: 8, bottom: 8 })
                .borderRadius(14)
                .linearGradient({
                  angle: 90,
                  colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
                })
                .onClick(() => {
                  this.showDetail = false
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '80%' })
          .width('92%')
        }

        if (this.showWant) {
          modalOverlay(() => {
            this.showWant = false
          })
          Column({ space: 12 }) {
            Text('已加入想玩清单')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Text('「' + this.selScript.name + '」开拼时\n第一时间推送提醒给你')
              .fontSize(12)
              .fontColor('#6B6787')
              .textAlign(TextAlign.Center)
              .lineHeight(19)
            Row({ space: 10 }) {
              Text('好的')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 24, right: 24, top: 9, bottom: 9 })
                .borderRadius(18)
                .backgroundColor('#7C3AED')
                .onClick(() => {
                  this.showWant = false
                })
            }
          }
          .padding(22)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Center)
          .width('78%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F4F2FF')
    .layoutWeight(1)
  }
}

// ==================== Tab 3 车队 ====================
@Component
export struct CrewTab {
  @State showRate: boolean = false
  @State showChat: boolean = false
  @State showRecruit: boolean = false
  @State starNum: number = 5
  @State selCrew: CrewMember = CREW_MEMBERS[0]
  @State inputMsg: string = ''
  @State recruitScript: string = ''

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('发布招募')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('缺人发这儿,30 秒成局')
                .fontSize(10)
                .fontColor('#C4B5FD')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('发布')
              .fontSize(12)
              .fontColor('#FFFFFF')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(14)
              .backgroundColor('#A855F7')
              .onClick(() => {
                this.showRecruit = true
              })
          }
          .width('100%')
        }
        .padding(14)
        .borderRadius(14)
        .linearGradient({
          angle: 135,
          colors: [['#312E81', 0.0], ['#7C3AED', 1.0]]
        })
        .width('100%')

        Row() {
          Text('推荐车友')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1E1B33')
          Row()
            .layoutWeight(1)
          Text('信用分 = 拼场守约率')
            .fontSize(10)
            .fontColor('#9B96B8')
        }
        .width('100%')

        ForEach(CREW_MEMBERS, (m: CrewMember) => {
          Column({ space: 10 }) {
            Row({ space: 12 }) {
              Stack({  }) {
                Column()
                  .width(52)
                  .height(52)
                  .borderRadius(26)
                  .linearGradient({
                    angle: 135,
                    colors: [['#A855F7', 0.0], ['#312E81', 1.0]]
                  })
                if (m.online) {
                  Column()
                    .width(12)
                    .height(12)
                    .borderRadius(6)
                    .backgroundColor('#22C55E')
                    .border({ width: 2, color: '#FFFFFF' })
                }
              }
              .width(52)
              .height(52)

              Column({ space: 4 }) {
                Row({ space: 6 }) {
                  Text(m.name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1E1B33')
                  Text(m.level)
                    .fontSize(9)
                    .fontColor('#7C3AED')
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .borderRadius(4)
                    .backgroundColor('#F3E8FF')
                }
                Text('拼过 ' + m.plays + ' 本 · 信用分 ' + m.credit)
                  .fontSize(11)
                  .fontColor('#6B6787')
                Row({ space: 6 }) {
                  ForEach(m.tags, (t: string) => {
                    Text(t)
                      .fontSize(10)
                      .fontColor('#4C4670')
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(4)
                      .backgroundColor('#F6F3FE')
                  }, (t: string) => t)
                }
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 8 }) {
              Row()
                .layoutWeight(1)
              Text('评价')
                .fontSize(11)
                .fontColor('#7C3AED')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .border({ width: 1, color: '#7C3AED' })
                .onClick(() => {
                  this.selCrew = m
                  this.showRate = true
                })
              Text('搭子邀请')
                .fontSize(11)
                .fontColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#7C3AED')
                .onClick(() => {
                  this.selCrew = m
                  this.showChat = true
                })
            }
            .width('100%')
          }
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
        }, (m: CrewMember) => m.id.toString())

        if (this.showRate) {
          modalOverlay(() => {
            this.showRate = false
          })
          Column({ space: 14 }) {
            Text('评价 ' + this.selCrew.name)
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')
            Row({ space: 10 }) {
              ForEach([1, 2, 3, 4, 5], (i: number) => {
                Text(i <= this.starNum ? '★' : '☆')
                  .fontSize(28)
                  .fontColor(i <= this.starNum ? '#F59E0B' : '#D6D2EA')
                  .onClick(() => {
                    this.starNum = i
                  })
              }, (i: number) => i.toString())
            }
            Text(this.starNum >= 5 ? '神仙搭子,下次还约!' : (this.starNum >= 3 ? '体验不错,可以再约' : '体验一般,不太合适'))
              .fontSize(12)
              .fontColor('#6B6787')
            Text('提交评价')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 0, right: 0, top: 9, bottom: 9 })
              .borderRadius(18)
              .linearGradient({
                angle: 90,
                colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
              })
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showRate = false
              })
          }
          .padding(20)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '70%' })
          .width('84%')
        }

        if (this.showChat) {
          modalOverlay(() => {
            this.showChat = false
          })
          Column({ space: 12 }) {
            Row() {
              Text('搭子邀请 · ' + this.selCrew.name)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#9B96B8')
                .onClick(() => {
                  this.showChat = false
                })
            }
            .width('100%')

            Column({ space: 6 }) {
              Text('你好!周六「长安十二辰」缺 2 人,\n看你是机制本大神,一起吗?')
                .fontSize(12)
                .fontColor('#1E1B33')
                .padding(12)
                .borderRadius({ topLeft: 2, topRight: 12, bottomLeft: 12, bottomRight: 12 })
                .backgroundColor('#F6F3FE')
                .width('86%')
            }
            .alignItems(HorizontalAlign.Start)
            .width('100%')

            TextInput({ placeholder: '说点什么…', text: this.inputMsg })
              .fontSize(12)
              .height(38)
              .borderRadius(10)
              .backgroundColor('#F6F3FE')
              .onChange((v: string) => {
                this.inputMsg = v
              })
              .width('100%')

            Row({ space: 10 }) {
              Text('取消')
                .fontSize(13)
                .fontColor('#4C4670')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#F6F3FE')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showChat = false
                })
              Text('发送')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#7C3AED')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showChat = false
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '70%' })
          .width('90%')
        }

        if (this.showRecruit) {
          modalOverlay(() => {
            this.showRecruit = false
          })
          Column({ space: 12 }) {
            Text('发布招募')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')

            TextInput({ placeholder: '想拼的本子名(如:雾山夜行)', text: this.recruitScript })
              .fontSize(12)
              .height(38)
              .borderRadius(10)
              .backgroundColor('#F6F3FE')
              .onChange((v: string) => {
                this.recruitScript = v
              })
              .width('100%')

            Column({ space: 8 }) {
              Text('题材')
                .fontSize(12)
                .fontColor('#6B6787')
                .width('100%')
              Row({ space: 8 }) {
                ForEach(['恐怖', '推理', '情感', '欢乐'], (g: string) => {
                  Text(g)
                    .fontSize(12)
                    .fontColor('#7C3AED')
                    .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                    .borderRadius(10)
                    .backgroundColor('#F6F3FE')
                }, (g: string) => g)
              }
            }
            .width('100%')

            Column({ space: 8 }) {
              Text('时间')
                .fontSize(12)
                .fontColor('#6B6787')
                .width('100%')
              Row({ space: 8 }) {
                ForEach(['今晚', '明天', '本周末'], (t: string) => {
                  Text(t)
                    .fontSize(12)
                    .fontColor('#FFFFFF')
                    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                    .borderRadius(10)
                    .backgroundColor('#7C3AED')
                }, (t: string) => t)
              }
            }
            .width('100%')

            Text('发布招募')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 0, right: 0, top: 9, bottom: 9 })
              .borderRadius(18)
              .linearGradient({
                angle: 90,
                colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
              })
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showRecruit = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '80%' })
          .width('92%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F4F2FF')
    .layoutWeight(1)
  }
}

// ==================== Tab 4 战绩 ====================
@Component
export struct RecordTab {
  @State showNote: boolean = false
  @State selRecord: RecordItem = RECORD_LIST[0]
  @State noteText: string = ''

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('52')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('总场次')
              .fontSize(9)
              .fontColor('#C4B5FD')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('842')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('拼场时长(h)')
              .fontSize(9)
              .fontColor('#C4B5FD')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('76%')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('投凶正确率')
              .fontSize(9)
              .fontColor('#C4B5FD')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          Column({ space: 3 }) {
            Text('4.8')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('车友评分')
              .fontSize(9)
              .fontColor('#C4B5FD')
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
        }
        .padding({ top: 16, bottom: 16 })
        .borderRadius(14)
        .linearGradient({
          angle: 135,
          colors: [['#0F1222', 0.0], ['#7C3AED', 1.0]]
        })
        .width('100%')

        Column({ space: 10 }) {
          Row() {
            Text('我的评分分布')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Row()
              .layoutWeight(1)
            Text('100 场样本')
              .fontSize(10)
              .fontColor('#9B96B8')
          }
          .width('100%')

          ForEach(SCORE_BUCKETS, (b: ScoreBucket) => {
            Row({ space: 10 }) {
              Text(b.star + ' 星')
                .fontSize(11)
                .fontColor('#4C4670')
                .width(34)
              Column()
                .height(10)
                .borderRadius(5)
                .width(scoreBarW(b.count))
                .linearGradient({
                  angle: 90,
                  colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
                })
              Text(b.count + '%')
                .fontSize(11)
                .fontColor('#9B96B8')
            }
            .width('100%')
          }, (b: ScoreBucket) => b.star.toString())
        }
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .width('100%')

        Row() {
          Text('对局时间线')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1E1B33')
          Row()
            .layoutWeight(1)
          Text('全部 52 场')
            .fontSize(11)
            .fontColor('#9B96B8')
        }
        .width('100%')

        ForEach(RECORD_LIST, (r: RecordItem) => {
          Row({ space: 12 }) {
            Column({ space: 4 }) {
              Text(r.date)
                .fontSize(11)
                .fontColor('#7C3AED')
                .fontWeight(FontWeight.Bold)
              Column()
                .width(2)
                .height(64)
                .backgroundColor('#E5E0F5')
            }
            .alignItems(HorizontalAlign.Center)

            Column({ space: 6 }) {
              Row({ space: 8 }) {
                Text(r.script)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E1B33')
                Text(r.result)
                  .fontSize(10)
                  .fontColor(r.resultColor)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(4)
                  .backgroundColor('#F6F3FE')
              }
              Text('角色:' + r.role)
                .fontSize(11)
                .fontColor('#6B6787')
              Row({ space: 4 }) {
                ForEach([1, 2, 3, 4, 5], (i: number) => {
                  Text(i <= r.mood ? '★' : '☆')
                    .fontSize(11)
                    .fontColor(i <= r.mood ? '#F59E0B' : '#D6D2EA')
                }, (i: number) => (r.id.toString() + '-' + i).toString())
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('复盘')
              .fontSize(11)
              .fontColor('#7C3AED')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10)
              .border({ width: 1, color: '#7C3AED' })
              .onClick(() => {
                this.selRecord = r
                this.showNote = true
              })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
        }, (r: RecordItem) => r.id.toString())

        if (this.showNote) {
          modalOverlay(() => {
            this.showNote = false
          })
          Column({ space: 12 }) {
            Row() {
              Text('复盘笔记')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#9B96B8')
                .onClick(() => {
                  this.showNote = false
                })
            }
            .width('100%')

            Column({ space: 5 }) {
              Text(this.selRecord.script + ' · ' + this.selRecord.role)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
                .width('100%')
              Text(this.selRecord.date + ' · ' + this.selRecord.result)
                .fontSize(11)
                .fontColor(this.selRecord.resultColor)
                .width('100%')
            }
            .padding(10)
            .borderRadius(10)
            .backgroundColor('#F6F3FE')
            .width('100%')

            Column({ space: 4 }) {
              Text('DM 点评')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
                .width('100%')
              Text('阿鬼:状态在线,守夜人身份藏得很好,\n最后一票犹豫了,下次可以更果断。')
                .fontSize(11)
                .fontColor('#6B6787')
                .lineHeight(18)
                .width('100%')
            }
            .padding(10)
            .borderRadius(10)
            .backgroundColor('#FBFAFF')
            .width('100%')

            TextInput({ placeholder: '写点什么…(仅自己可见)', text: this.noteText })
              .fontSize(12)
              .height(64)
              .borderRadius(10)
              .backgroundColor('#F6F3FE')
              .onChange((v: string) => {
                this.noteText = v
              })
              .width('100%')

            Text('保存笔记')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 0, right: 0, top: 9, bottom: 9 })
              .borderRadius(18)
              .linearGradient({
                angle: 90,
                colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
              })
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showNote = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '80%' })
          .width('92%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F4F2FF')
    .layoutWeight(1)
  }
}

// ==================== Tab 5 订单 ====================
@Component
export struct JOrderTab {
  @State filterIdx: number = 0
  @State showCode: boolean = false
  @State showChange: boolean = false
  @State showCancel: boolean = false
  @State selOrder: OrderRow = ORDER_ROWS[0]

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          ForEach(ORDER_FILTERS, (f: string) => {
            Text(f)
              .fontSize(12)
              .fontColor(this.filterIdx === ORDER_FILTERS.indexOf(f) ? '#FFFFFF' : '#4C4670')
              .padding({ left: 13, right: 13, top: 6, bottom: 6 })
              .borderRadius(14)
              .backgroundColor(this.filterIdx === ORDER_FILTERS.indexOf(f) ? '#7C3AED' : '#FFFFFF')
              .border({ width: 1, color: this.filterIdx === ORDER_FILTERS.indexOf(f) ? '#7C3AED' : '#E5E0F5' })
              .onClick(() => {
                this.filterIdx = ORDER_FILTERS.indexOf(f)
              })
          }, (f: string) => f)
        }
        .width('100%')

        ForEach(ORDER_ROWS, (o: OrderRow) => {
          Column({ space: 10 }) {
            Row() {
              Text('订单 ' + o.id)
                .fontSize(11)
                .fontColor('#9B96B8')
              Row()
                .layoutWeight(1)
              Text(o.status)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(o.statusColor)
            }
            .width('100%')

            Row({ space: 12 }) {
              Column()
                .width(56)
                .height(72)
                .borderRadius(10)
                .linearGradient({
                  angle: 135,
                  colors: [['#312E81', 0.0], ['#A855F7', 1.0]]
                })
              Column({ space: 4 }) {
                Text(o.script)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E1B33')
                Text(o.store)
                  .fontSize(11)
                  .fontColor('#6B6787')
                Text(o.time + ' · ' + o.seats + ' 座')
                  .fontSize(11)
                  .fontColor('#6B6787')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('¥' + (o.price * o.seats))
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#A855F7')
            }
            .width('100%')

            Row({ space: 8 }) {
              Row()
                .layoutWeight(1)
              if (o.status === '待开场') {
                Text('核销码')
                  .fontSize(11)
                  .fontColor('#7C3AED')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .border({ width: 1, color: '#7C3AED' })
                  .onClick(() => {
                    this.selOrder = o
                    this.showCode = true
                  })
                Text('改期')
                  .fontSize(11)
                  .fontColor('#4C4670')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .border({ width: 1, color: '#D6D2EA' })
                  .onClick(() => {
                    this.selOrder = o
                    this.showChange = true
                  })
                Text('取消')
                  .fontSize(11)
                  .fontColor('#FFFFFF')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .backgroundColor('#EF4444')
                  .onClick(() => {
                    this.selOrder = o
                    this.showCancel = true
                  })
              } else {
                Text('再来一单')
                  .fontSize(11)
                  .fontColor('#FFFFFF')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .backgroundColor('#7C3AED')
              }
            }
            .width('100%')
          }
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
        }, (o: OrderRow) => o.id)

        if (this.showCode) {
          modalOverlay(() => {
            this.showCode = false
          })
          Column({ space: 14 }) {
            Text('到场核销码')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Text(this.selOrder.script + ' · ' + this.selOrder.time)
              .fontSize(12)
              .fontColor('#6B6787')
            Column({ space: 10 }) {
              Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
                ForEach([1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], (b: number) => {
                  Column()
                    .width(16)
                    .height(16)
                    .backgroundColor(b === 1 ? '#1E1B33' : '#FFFFFF')
                    .border({ width: 1, color: '#E5E0F5' })
                    .margin(2)
                }, (b: number, i: number) => (b.toString() + i).toString())
              }
              Text('8826  1947')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1E1B33')
                .letterSpacing(3)
            }
            .padding(16)
            .borderRadius(12)
            .backgroundColor('#FBFAFF')
            .width('100%')
            .alignItems(HorizontalAlign.Center)

            Text('出示给前台 DM 扫码即可入场')
              .fontSize(11)
              .fontColor('#9B96B8')
            Text('完成')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 26, right: 26, top: 9, bottom: 9 })
              .borderRadius(18)
              .backgroundColor('#7C3AED')
              .onClick(() => {
                this.showCode = false
              })
          }
          .padding(20)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Center)
          .width('86%')
        }

        if (this.showChange) {
          modalOverlay(() => {
            this.showChange = false
          })
          Column({ space: 12 }) {
            Text('改期')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')
            Text('「' + this.selOrder.script + '」每单可免费改期 1 次')
              .fontSize(11)
              .fontColor('#6B6787')
              .width('100%')
            Row({ space: 8 }) {
              ForEach(['明天 19:30', '周六 14:00', '周日 20:30'], (t: string) => {
                Text(t)
                  .fontSize(12)
                  .fontColor('#7C3AED')
                  .padding({ left: 12, right: 12, top: 8, bottom: 8 })
                  .borderRadius(10)
                  .backgroundColor('#F6F3FE')
              }, (t: string) => t)
            }
            .width('100%')
            Row({ space: 10 }) {
              Text('取消')
                .fontSize(13)
                .fontColor('#4C4670')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#F6F3FE')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showChange = false
                })
              Text('确认改期')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#7C3AED')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showChange = false
                })
            }
            .width('100%')
          }
          .padding(18)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('88%')
        }

        if (this.showCancel) {
          modalOverlay(() => {
            this.showCancel = false
          })
          Column({ space: 12 }) {
            Text('取消订单')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Text('开场前 4 小时取消全额退款\n当前订单距开场 5.2 小时,可免费取消')
              .fontSize(12)
              .fontColor('#6B6787')
              .lineHeight(19)
              .textAlign(TextAlign.Center)
            Row({ space: 10 }) {
              Text('再想想')
                .fontSize(13)
                .fontColor('#4C4670')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#F6F3FE')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showCancel = false
                })
              Text('确认取消')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#EF4444')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showCancel = false
                })
            }
            .width('100%')
          }
          .padding(18)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('84%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F4F2FF')
    .layoutWeight(1)
  }
}

// ==================== Tab 6 我的 ====================
@Component
export struct JMineTab {
  @State showExchange: boolean = false
  @State showSetting: boolean = false
  @State showBadge: boolean = false
  @State showLevel: boolean = false
  @State nightMode: boolean = true

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 12 }) {
          Row({ space: 12 }) {
            Column()
              .width(58)
              .height(58)
              .borderRadius(29)
              .linearGradient({
                angle: 135,
                colors: [['#A855F7', 0.0], ['#0F1222', 1.0]]
              })
              .border({ width: 2, color: '#C4B5FD' })

            Column({ space: 4 }) {
              Text('夜行者K')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Row({ space: 6 }) {
                Text('Lv.9 老玩家')
                  .fontSize(10)
                  .fontColor('#1E1B33')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(10)
                  .backgroundColor('#C4B5FD')
                Text('再拼 13 场升 Lv.10')
                  .fontSize(10)
                  .fontColor('#C4B5FD')
                  .onClick(() => {
                    this.showLevel = true
                  })
              }
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('徽章')
              .fontSize(11)
              .fontColor('#FFFFFF')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(12)
              .border({ width: 1, color: '#A855F7' })
              .onClick(() => {
                this.showBadge = true
              })
          }
          .width('100%')

          Column({ space: 6 }) {
            Row() {
              Text('Lv.9')
                .fontSize(10)
                .fontColor('#C4B5FD')
              Row()
                .layoutWeight(1)
              Text('Lv.10')
                .fontSize(10)
                .fontColor('#C4B5FD')
            }
            .width('100%')
            Column({ space: 0 }) {
              Column()
                .width('74%')
                .height(6)
                .borderRadius(3)
                .linearGradient({
                  angle: 90,
                  colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
                })
            }
            .width('100%')
            .height(6)
            .borderRadius(3)
            .backgroundColor('rgba(196,181,253,0.3)')
          }
          .width('100%')

          Row({ space: 0 }) {
            Column({ space: 3 }) {
              Text('2380')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('夜行积分')
                .fontSize(10)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column({ space: 3 }) {
              Text('12')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('优惠券')
                .fontSize(10)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            Column({ space: 3 }) {
              Text('5')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('徽章')
                .fontSize(10)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .onClick(() => {
              this.showBadge = true
            })
            Column({ space: 3 }) {
              Text('38')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('想玩清单')
                .fontSize(10)
                .fontColor('#C4B5FD')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding({ top: 4, bottom: 2 })
        }
        .padding(16)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [['#0F1222', 0.0], ['#7C3AED', 1.0]]
        })
        .width('100%')

        Column({ space: 12 }) {
          Row() {
            Text('积分兑换')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Row()
              .layoutWeight(1)
            Text('全部 >')
              .fontSize(11)
              .fontColor('#9B96B8')
              .onClick(() => {
                this.showExchange = true
              })
          }
          .width('100%')

          Scroll() {
            Row({ space: 10 }) {
              ForEach(EXCHANGE_LIST, (e: ExchangeItem) => {
                Column({ space: 6 }) {
                  Column()
                    .width(88)
                    .height(64)
                    .borderRadius(10)
                    .backgroundColor(e.color)
                  Text(e.name)
                    .fontSize(10)
                    .fontColor('#4C4670')
                    .maxLines(1)
                  Text(e.cost + ' 分')
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#7C3AED')
                }
                .padding(8)
                .borderRadius(12)
                .backgroundColor('#F6F3FE')
                .alignItems(HorizontalAlign.Center)
                .width(104)
                .onClick(() => {
                  this.showExchange = true
                })
              }, (e: ExchangeItem) => e.id.toString())
            }
          }
          .scrollable(ScrollDirection.Horizontal)
          .width('100%')
        }
        .padding(12)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .width('100%')

        Column({ space: 0 }) {
          ForEach(['我的车友', '常去场馆', '收货地址', '拼场提醒设置', '帮助与客服', '关于夜拼'], (t: string) => {
            Row() {
              Text(t)
                .fontSize(13)
                .fontColor('#1E1B33')
              Row()
                .layoutWeight(1)
              Text('>')
                .fontSize(13)
                .fontColor('#D6D2EA')
            }
            .width('100%')
            .padding({ top: 13, bottom: 13 })
          }, (t: string) => t)
        }
        .padding({ left: 14, right: 14 })
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .width('100%')

        Row() {
          Text('夜间模式(深夜局专用配色)')
            .fontSize(13)
            .fontColor('#1E1B33')
          Row()
            .layoutWeight(1)
          Text(this.nightMode ? '已开启' : '已关闭')
            .fontSize(12)
            .fontColor(this.nightMode ? '#7C3AED' : '#9B96B8')
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12)
            .backgroundColor('#F6F3FE')
            .onClick(() => {
              this.nightMode = !this.nightMode
            })
        }
        .width('100%')
        .padding(14)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')
        .onClick(() => {
          this.showSetting = true
        })

        if (this.showExchange) {
          modalOverlay(() => {
            this.showExchange = false
          })
          Column({ space: 12 }) {
            Text('积分商城')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')
            ForEach(EXCHANGE_LIST, (e: ExchangeItem) => {
              Row({ space: 12 }) {
                Column()
                  .width(52)
                  .height(52)
                  .borderRadius(10)
                  .backgroundColor(e.color)
                Column({ space: 3 }) {
                  Text(e.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#1E1B33')
                  Text(e.cost + ' 夜行积分')
                    .fontSize(11)
                    .fontColor('#7C3AED')
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                Text('兑换')
                  .fontSize(11)
                  .fontColor('#FFFFFF')
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .backgroundColor('#7C3AED')
                  .onClick(() => {
                    this.showExchange = false
                  })
              }
              .width('100%')
              .padding(10)
              .borderRadius(12)
              .backgroundColor('#F6F3FE')
            }, (e: ExchangeItem) => ('x' + e.id).toString())
            Text('当前 2380 分 · 每拼 1 场得 88 分')
              .fontSize(11)
              .fontColor('#9B96B8')
              .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '78%' })
          .width('92%')
        }

        if (this.showBadge) {
          modalOverlay(() => {
            this.showBadge = false
          })
          Column({ space: 12 }) {
            Text('我的徽章墙')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')
            Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
              ForEach(BADGE_LIST, (b: BadgeItem) => {
                Column({ space: 5 }) {
                  Column()
                    .width(52)
                    .height(52)
                    .borderRadius(26)
                    .backgroundColor(b.lit ? '#7C3AED' : '#E5E0F5')
                  Text(b.name)
                    .fontSize(11)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(b.lit ? '#1E1B33' : '#9B96B8')
                  Text(b.desc)
                    .fontSize(9)
                    .fontColor('#9B96B8')
                }
                .width('31%')
                .alignItems(HorizontalAlign.Center)
                .padding({ top: 10, bottom: 10 })
                .borderRadius(12)
                .backgroundColor(b.lit ? '#F6F3FE' : '#FBFAFF')
                .margin({ bottom: 8 })
              }, (b: BadgeItem) => b.id.toString())
            }
            .width('100%')
            Text('关闭')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 26, right: 26, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#7C3AED')
              .onClick(() => {
                this.showBadge = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '78%' })
          .width('92%')
        }

        if (this.showLevel) {
          modalOverlay(() => {
            this.showLevel = false
          })
          Column({ space: 10 }) {
            Text('等级特权')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            ForEach(['Lv.8 · 拼场 95 折', 'Lv.9 · 优先锁定角色', 'Lv.10 · 免拼卡 ×3/月', 'Lv.12 · 专属年度报告'], (t: string) => {
              Text('· ' + t)
                .fontSize(12)
                .fontColor('#6B6787')
                .width('100%')
            }, (t: string) => t)
            Text('当前 Lv.9 · 再拼 13 场升级')
              .fontSize(11)
              .fontColor('#7C3AED')
            Text('知道了')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 26, right: 26, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#7C3AED')
              .onClick(() => {
                this.showLevel = false
              })
          }
          .padding(20)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .width('82%')
        }

        if (this.showSetting) {
          modalOverlay(() => {
            this.showSetting = false
          })
          Column({ space: 12 }) {
            Text('设置')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
              .width('100%')
            ForEach(['消息推送', '免打扰时段', '隐私设置', '账号安全', '退出登录'], (t: string) => {
              Row() {
                Text(t)
                  .fontSize(13)
                  .fontColor('#1E1B33')
                Row()
                  .layoutWeight(1)
                Text('>')
                  .fontSize(13)
                  .fontColor('#D6D2EA')
              }
              .width('100%')
              .padding({ top: 12, bottom: 12 })
            }, (t: string) => t)
            Text('关闭')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 26, right: 26, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#7C3AED')
              .onClick(() => {
                this.showSetting = false
              })
          }
          .padding(18)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .constraintSize({ maxHeight: '75%' })
          .width('86%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#F4F2FF')
    .layoutWeight(1)
  }
}

// ==================== 主页面 ====================
@Entry
@Component
struct ScriptKillPage {
  @State activeTab: number = 0
  @State showSearch: boolean = false
  @State showMsg: boolean = false
  @State unRead: number = 2

  build() {
    Column() {
      Column() {
        Row({ space: 10 }) {
          Text('夜拼')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')

          Row({ space: 6 }) {
            Text('🔍')
              .fontSize(13)
            TextInput({ placeholder: '雾山夜行 / 恐怖本 / 迷雾剧场' })
              .fontSize(12)
              .height(34)
              .layoutWeight(1)
              .backgroundColor('#EDE9FE')
              .borderRadius(17)
          }
          .layoutWeight(1)
          .padding({ left: 6, right: 6 })
          .backgroundColor('#FFFFFF')
          .borderRadius(17)

          Stack({ alignContent: Alignment.TopEnd }) {
            Text('✉')
              .fontSize(18)
              .fontColor('#FFFFFF')
            if (this.unRead > 0) {
              Column()
                .width(8)
                .height(8)
                .borderRadius(4)
                .backgroundColor('#A855F7')
                .margin({ top: -2, right: -2 })
            }
          }
          .width(28)
          .height(28)
          .onClick(() => {
            this.showMsg = true
          })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      }
      .linearGradient({
        angle: 135,
        colors: [['#0F1222', 0.0], ['#312E81', 1.0]]
      })
      .width('100%')

      if (this.activeTab === 0) {
        PlayTab()
      } else if (this.activeTab === 1) {
        ScriptTab()
      } else if (this.activeTab === 2) {
        CrewTab()
      } else if (this.activeTab === 3) {
        RecordTab()
      } else if (this.activeTab === 4) {
        JOrderTab()
      } else {
        JMineTab()
      }

      Column()
        .width('100%')
        .height(1)
        .backgroundColor('#E5E0F5')

      Row({ space: 0 }) {
        ForEach(MAIN_TABS, (tab: TabMeta, idx: number) => {
          Column({ space: 3 }) {
            Text(tab.icon)
              .fontSize(14)
              .fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.activeTab === idx ? '#7C3AED' : '#9B96B8')
            if (this.activeTab === idx) {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('#A855F7')
            } else {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('rgba(0,0,0,0)')
            }
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 7, bottom: 7 })
          .onClick(() => {
            this.activeTab = idx
          })
        }, (tab: TabMeta, idx: number) => (tab.name + idx).toString())
      }
      .width('100%')
      .backgroundColor('#FFFFFF')

      if (this.showSearch) {
        modalOverlay(() => {
          this.showSearch = false
        })
        Column({ space: 12 }) {
          Text('搜索本子 / 场馆 / 车友')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1E1B33')
            .width('100%')
          TextInput({ placeholder: '输入关键词' })
            .fontSize(12)
            .height(38)
            .borderRadius(10)
            .backgroundColor('#F6F3FE')
            .width('100%')
          Row({ space: 8 }) {
            ForEach(['恐怖本', '情感本', '拼场', 'DM'], (k: string) => {
              Text(k)
                .fontSize(12)
                .fontColor('#7C3AED')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#F6F3FE')
            }, (k: string) => k)
          }
          .width('100%')
          Text('搜索')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .padding({ left: 0, right: 0, top: 9, bottom: 9 })
            .borderRadius(16)
            .backgroundColor('#7C3AED')
            .width('100%')
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.showSearch = false
            })
        }
        .padding(16)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .width('90%')
      }

      if (this.showMsg) {
        modalOverlay(() => {
          this.showMsg = false
          this.unRead = 0
        })
        Column({ space: 12 }) {
          Row() {
            Text('消息中心')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1E1B33')
            Row()
              .layoutWeight(1)
            Text('全部已读')
              .fontSize(11)
              .fontColor('#7C3AED')
              .onClick(() => {
                this.unRead = 0
              })
          }
          .width('100%')

          ForEach(MSG_LIST, (m: MsgMeta) => {
            Row({ space: 10 }) {
              Column({ space: 2 }) {
                Text(m.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1E1B33')
                Text(m.content)
                  .fontSize(11)
                  .fontColor('#6B6787')
                  .maxLines(2)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Column({ space: 4 }) {
                Text(m.time)
                  .fontSize(9)
                  .fontColor('#9B96B8')
                if (!m.read) {
                  Column()
                    .width(7)
                    .height(7)
                    .borderRadius(4)
                    .backgroundColor('#A855F7')
                }
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }, (m: MsgMeta) => m.id.toString())
        }
        .padding(16)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .constraintSize({ maxHeight: '75%' })
        .width('92%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F2FF')
  }
}


总结

在这里插入图片描述

本文对基于 HarmonyOS API 24 的夜拼剧本杀拼场应用进行了全面的代码级深度解析。该应用以 ArkTS 声明式 UI 为核心框架,通过 @Entry@Component@State 等装饰器构建了层次分明的组件树,实现了拼场、本子库、车队、战绩、订单和个人中心六大功能模块的完整覆盖。应用在数据层采用了强类型接口定义,确保了编译期的类型安全;在视图层大量运用了 ForEachFlexStack 等布局组件和条件渲染机制,实现了丰富的动态 UI 效果。

从架构设计角度来审视,该应用体现了 HarmonyOS 声明式 UI 的几个核心设计理念。首先是状态驱动的 UI 更新机制——所有界面变化都由 @State 变量的修改自动触发,开发者无需手动操作 DOM 节点。其次是组件化的模块拆分策略——每个 Tab 都是独立的 @Component struct,内部状态完全自包含,通过主页面的 activeTab 进行路由切换。最后是公共逻辑的复用设计——modalOverlay 作为 @Builder 函数被所有弹窗共享,工具函数 heatBarHheatBarColorscoreBarW 被多个组件调用,有效减少了代码冗余。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐