导演阐述一:每一部优秀的影片都始于一份精密的分镜脚本。在HarmonyOS ArkTS的数字片场中,我们以接口为剧本大纲、以组件为演员阵容、以装饰器为镜头调度指令,搭建起一座从桌游展示到剧本杀开台的全场景桌游娱乐综合体。七个场景段落如同七幕话剧,五组弹框如同五段插叙,共同编织出一幅沉浸式的社交娱乐画卷。

导演阐述二:底部七枚徽章式导航正如场记板上的场景编号,每一个Tab对应一幕独立的戏份——桌游展厅、剧本图书馆、角色化妆间、开台前台、荣誉殿堂、社区广场与个人后台。而深紫与橙的双色灯光方案,则为整个片场定下了神秘悬疑与温暖社交交织的情绪基调。

导演阐述三:在ArkTS的片场中,每一个装饰器都是一条导演指令——@Entry标注的是主场景入口,@Component为每位演员签发角色卡,@State是场记板上随时更新的拍摄状态,@Builder是可反复调用的预制分镜模板,而ForEach便如同群演调度的循环走位指令,将数据列表逐一安排到各自的走位标记上。

引言:开机前的制片准备

在这里插入图片描述

在鸿蒙生态的数字片场中,桌游与剧本杀是一个充满悬念推理与社交欢笑的特殊题材。这部"影片"不仅承载着桌游爱好者的策略对决与剧本杀玩家的沉浸演绎,更串联起从商品浏览、角色管理、桌台预约到社区互动的完整娱乐体验链条。作为这部影片的导演,我们需要在开机前仔细研读剧本,理解每一场戏的调度逻辑、每一段对白的叙事意图、每一组镜头的视觉语言。

本篇导演手记所拆解的,正是一部基于HarmonyOS ArkTS API 24构建的多功能桌游剧本社区应用。该应用采用深紫色(#4527A0)与橙色(#FF8F00)双主色方案——深紫如同悬疑片中的暗夜基调,营造出推理与沉浸的神秘氛围;橙色如同喜剧片中的暖色光源,传递着社交与欢乐的温度。两种色调在七幕场景中交替出现,如同电影中冷调与暖调的切换,引导观众(用户)在不同情绪间自然过渡。

从制片架构的视角来看,该应用的数据层定义了十一个接口(interface),如同剧本中的十一个角色设定表——配色方案、标签配置、桌游信息、题材分布、剧本信息、角色信息、阵营分布、桌型信息、排行项、战报信息和组局会话。静态数据层以常量数组预置了全部业务数据,相当于美术组提前布好的场景道具。工具函数层提取了五个纯函数用于颜色映射和格式化,它们如同后期制作中的色彩校正滤镜,将原始数据转换为画面所需的可读信息。

在拍摄层,应用划分了七个场景段落(Tab Component),每个场景都是一幕独立的戏——桌游展厅用横滑大卡加网格陈列展示热门桌游、剧本图书馆用题材分布图加评分列表展示热门剧本、角色化妆间用角色卡Grid加阵营分布展示卡组、开台前台用桌型选择加拼车列表展示预约流程、荣誉殿堂用双榜展示剧本和桌游排名、社区广场用战报流展示玩家互动、个人后台用组局记录加收藏展示个人数据。五组弹框分别对应五个插叙段落——预约开台(居中弹框)、购买剧本(抽屉弹框)、编辑角色(抽屉弹框)、删除战报(居中弹框)和剧本详情(居中弹框)。

第一幕:剧本大纲——数据接口定义与配色方案

在这里插入图片描述

1.1 美术指导的色彩方案

在影片开拍前,美术指导需要确定全片的色彩方案。本片的色彩方案通过一个接口定义:

interface ColorPalette115 {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  orange: string;
  orangeLight: string;
  orangeBg: string;
  bg: string;
  card: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

const COLORS115: ColorPalette115 = {
  primary: '#4527A0',
  primaryLight: '#7E57C2',
  primaryDark: '#1A237E',
  orange: '#FF8F00',
  orangeLight: '#FFC107',
  orangeBg: '#FFF7E0',
  bg: '#F5F2FB',
  card: '#FFFFFF',
  textMain: '#2A1B4A',
  textSub: '#8B7BA6',
  textHint: '#BCB1D1',
  border: '#E8E0F5',
  success: '#66BB6A',
  warning: '#FFA726',
  danger: '#EF5350',
  white: '#FFFFFF'
};

interface在ArkTS中用于定义类型契约。ColorPalette115接口定义了十六个颜色字段——三档紫色(primary深紫、primaryLight浅紫、primaryDark暗紫)构成悬疑基调,三档橙色(orange标准橙、orangeLight亮橙、orangeBg淡橙背景)构成社交暖调,加上背景色、卡片色、三档文字色(textMain主文字、textSub次文字、textHint提示文字)、边框色和三种语义色(success成功绿、warning警告橙、danger危险红),构成了一套完整的色彩系统。

const COLORS115是配色方案的具体实现实例。将所有颜色集中在一个常量对象中管理,是ArkTS开发中推荐的做法——它确保了全片色彩的一致性,如同一份标准的色彩对照表贴在每个部门的办公桌上。当需要调整配色时,只需修改这一处定义,全片所有引用COLORS115.xxx的组件都会同步更新。

导演笔记:在ArkTS中,将配色方案提取为接口+常量的模式有三大优势:一是统一管理避免散落的魔法数字;二是语义化命名(如COLORS115.danger'#EF5350'更易读);三是便于主题切换——未来如需支持暗色模式,只需替换常量值而无需改动组件代码。

1.2 角色设定表:数据接口与静态数据

在这里插入图片描述

interface BoardGame115 {
  id: string;
  name: string;
  price: number;
  oldPrice: number;
  sales: string;
  players: string;
  time: string;
  difficulty: number;
  emoji: string;
  hot: boolean;
}

interface Script115 {
  id: string;
  name: string;
  type: string;
  players: string;
  time: string;
  rating: number;
  price: number;
  sales: string;
  emoji: string;
  difficulty: string;
}

interface Role115 {
  id: string;
  name: string;
  camp: string;
  desc: string;
  emoji: string;
}

const BOARD_GAMES115: BoardGame115[] = [
  { id: 'g1', name: '星际殖民 基础版', price: 199, oldPrice: 269, sales: '2.3w', players: '2-4人', time: '60-90min', difficulty: 3, emoji: '🚀', hot: true },
  { id: 'g2', name: '王国保卫战 中文版', price: 149, oldPrice: 199, sales: '4.8w', players: '2-5人', time: '45-60min', difficulty: 2, emoji: '🏰', hot: true },
  { id: 'g3', name: '推理大师 桌游版', price: 89, oldPrice: 119, sales: '6.6w', players: '3-8人', time: '30-45min', difficulty: 1, emoji: '🕵️', hot: true },
  { id: 'g4', name: '星球贸易 豪华版', price: 299, oldPrice: 399, sales: '9200', players: '3-6人', time: '90-120min', difficulty: 4, emoji: '🌍', hot: false }
];

const SCRIPTS115: Script115[] = [
  { id: 's1', name: '雾都孤儿院', type: '恐怖推理', players: '6人', time: '4-5h', rating: 9.2, price: 88, sales: '3.2w', emoji: '🏚️', difficulty: '进阶' },
  { id: 's2', name: '山海列车', type: '情感沉浸', players: '7人', time: '5h', rating: 9.5, price: 108, sales: '5.6w', emoji: '🚂', difficulty: '进阶' },
  { id: 's3', name: '迷雾侦探社', type: '硬核推理', players: '6人', time: '6h', rating: 9.0, price: 128, sales: '1.8w', emoji: '🕵️', difficulty: '硬核' },
  { id: 's4', name: '月圆之夜', type: '机制阵营', players: '8人', time: '4h', rating: 8.7, price: 98, sales: '2.9w', emoji: '🌕', difficulty: '新手' }
];

const ROLES115: Role115[] = [
  { id: 'r1', name: '侦探长·柯岚', camp: '好人', desc: '逻辑担当 · 每轮可验一人', emoji: '🔍' },
  { id: 'r2', name: '女巫·莉莉', camp: '好人', desc: '拥有解药与毒药各一瓶', emoji: '🧪' },
  { id: 'r5', name: '狼王·影牙', camp: '狼人', desc: '出局时可带走一名玩家', emoji: '🐺' },
  { id: 'r7', name: '盗贼·快手', camp: '中立', desc: '开局可交换两张身份牌', emoji: '🎩' }
];

BoardGame115接口定义了桌游的数据结构——id(唯一标识)、name(名称)、price/oldPrice(现价/原价)、sales(销量)、players(人数范围)、time(时长)、difficulty(难度等级1-5)、emoji(图标)、hot(是否热门)。Script115接口定义了剧本杀的数据结构,多了type(题材类型)、rating(评分)和difficulty(文本难度描述而非数字)字段。Role115接口定义了角色信息——name(角色名)、camp(阵营)、desc(技能描述)、emoji(角色图标)。

导演笔记:const声明的常量数组在ArkTS中用于存储不可变的业务数据。这些数据在应用编译期就确定,运行期不变。在实际项目中,这类数据通常来自后端API接口,但在Demo或原型阶段,使用静态常量数组模拟数据是高效的做法——它允许前端独立于后端进行开发和调试。

1.3 色彩校正滤镜:工具函数

在这里插入图片描述

function diffStars115(n: number): string {
  if (n <= 1) { return '★☆☆☆☆ 简单'; }
  if (n === 2) { return '★★☆☆☆ 入门'; }
  if (n === 3) { return '★★★☆☆ 进阶'; }
  if (n === 4) { return '★★★★☆ 困难'; }
  return '★★★★★ 硬核';
}

function barW115(v: number): string {
  return Math.floor(v).toString() + '%';
}

function likeFmt115(v: number): string {
  if (v >= 10000) {
    return (v / 10000).toFixed(1) + 'w';
  }
  return v.toString();
}

function rankColor115(i: number): string {
  if (i === 0) { return COLORS115.orange; }
  if (i === 1) { return COLORS115.textSub; }
  if (i === 2) { return COLORS115.primaryLight; }
  return COLORS115.textHint;
}

function campColor115(c: string): string {
  if (c === '好人') { return COLORS115.primary; }
  if (c === '狼人') { return COLORS115.danger; }
  return COLORS115.orange;
}

五个工具函数如同后期制作中的色彩校正滤镜——diffStars115将数字难度转换为星号+文字描述的复合字符串;barW115将数值拼接为百分比字符串(Math.floor确保整数);likeFmt115将大于1万的数字转换为"x.xw"格式(如12800变为"1.3w");rankColor115根据排名索引返回颜色(第1名橙色、第2名灰色、第3名浅紫、其余提示色);campColor115根据阵营名称返回颜色(好人紫色、狼人红色、中立橙色)。

关键概念:在ArkTS的UI构建函数(build方法)内部,不允许直接声明变量。所有数据预处理逻辑必须抽取到组件外部的函数中。这一约束的设计意图是保持UI构建函数的纯粹性——它只负责"描述UI长什么样",不负责"计算数据"。工具函数层正是这一约束下的产物。

第二幕:主场景调度——@Entry入口与场景切换

在这里插入图片描述

2.1 主场景入口与状态管理

@Entry
@Component
struct DuoDuoBoardApp {
  @State currentTab: number = 0;
  @State showBookDialog: boolean = false;
  @State showBuySheet: boolean = false;
  @State showEditSheet: boolean = false;
  @State showDeleteDialog: boolean = false;
  @State showDetailDialog: boolean = false;
  @State selectedItem: Script115 | null = null;
  // 预约开台
  @State selTable: number = 0;
  @State playerCount: number = 6;
  @State selRoom: boolean = true;
  // 购买
  @State selQty: number = 1;
  @State selVersion: number = 0;
  @State selExpand: boolean = false;
  @State selMembership: boolean = true;
  // 编辑角色
  @State roleName: string = '';
  @State selCamp: number = 0;
  @State selPublic: boolean = true;
  // 删除战报
  @State selClearComments: boolean = false;

  build() {
    Column() {
      // ===== 桌游吧头部(桌游俱乐部参考样式) =====
      Row() {
        Column() {
          Row() {
            Text('多多桌游')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.white)
            Text('GAME')
              .fontSize(9)
              .fontColor(COLORS115.orangeLight)
              .border({ width: 1, color: COLORS115.orangeLight })
              .borderRadius(3)
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .margin({ left: 6 })
          }
          Text('🎲 桌游剧本 · 组局开台 · 角色扮演')
            .fontSize(9)
            .fontColor('rgba(255,255,255,0.8)')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Text('🔍')
          .fontSize(19)
          .fontColor(COLORS115.white)
          .margin({ right: 12 })
        Text('🛒')
          .fontSize(19)
          .fontColor(COLORS115.white)
      }
      .width('100%')
      .height(54)
      .padding({ left: 16, right: 16 })
      .linearGradient({ angle: 120, colors: [[COLORS115.primaryDark, 0], [COLORS115.primary, 0.7], [COLORS115.primaryLight, 1]] })

@Entry是ArkTS的入口装饰器,标记该组件为页面入口组件。在一个ArkTS页面文件中,有且仅有一个组件可以被@Entry标记。被标记的组件会被框架自动识别为页面的根节点,负责整个页面的渲染和生命周期管理。@Entry如同电影的开场镜头——它是观众看到的第一个画面,决定了整部影片的第一印象。

@Component装饰器用于声明一个自定义组件。struct DuoDuoBoardApp结合@Component定义了应用的主组件。该组件声明了十八个@State状态变量——currentTab(当前场景索引)控制七幕场景的切换,五个弹框显隐状态控制五段插叙的进出,selectedItem(选中的剧本对象)为剧本详情弹框提供数据,其余状态变量分别服务于四个弹框的交互表单(预约开台的桌型/人数/主题房、购买的版本/数量/扩展包/会员、编辑角色的名称/阵营/公开、删除战报的清空评论选项)。

关键概念:@State是ArkTS的状态管理装饰器。被@State标记的变量具有响应式特性——当变量值发生变化时,所有引用该变量的UI组件会自动重新渲染。@State如同场记板上的场景编号——一旦编号变化,整个剧组立即切换到对应场景的拍摄状态。这种"状态驱动UI"的模式是声明式UI的核心思想。

build()方法内部使用Column()作为根容器,包含三部分:头部顶栏(Row)、内容区(场景切换)、底部导航栏(Row)。头部顶栏使用linearGradient设置了从暗紫到浅紫的三段渐变——angle: 120指定渐变角度为120度,colors数组定义三个色站,每个色站是[颜色值, 位置偏移]的元组。Text('GAME')使用.border()方法添加了亮橙色边框,作为品牌标识的装饰元素。

关键概念:linearGradient是ArkUI的样式属性方法,用于设置线性渐变背景。angle参数以度数指定渐变方向(0度为从下到上,90度为从左到右,以此类推),colors参数是色站数组,每个色站包含颜色值和0.0-1.0的位置偏移。渐变背景如同电影中的灯光设计——通过色彩的明暗变化营造空间深度和情绪氛围。

2.2 场景切换与条件渲染

在这里插入图片描述

// ===== Tab 内容区 =====
if (this.currentTab === 0) {
  BoardGameTab115({
    onBook: () => { this.showBookDialog = true; }
  })
} else if (this.currentTab === 1) {
  ScriptTab115({
    onDetail: (s: Script115) => {
      this.selectedItem = s;
      this.showDetailDialog = true;
    },
    onBuy: (s: Script115) => { this.showBuySheet = true; }
  })
} else if (this.currentTab === 2) {
  RoleTab115({
    onEdit: () => { this.showEditSheet = true; }
  })
} else if (this.currentTab === 3) {
  TableTab115({
    onBook: () => { this.showBookDialog = true; }
  })
} else if (this.currentTab === 4) {
  RankTab115()
} else if (this.currentTab === 5) {
  CommunityTab115({
    onDelete: () => { this.showDeleteDialog = true; }
  })
} else {
  MineTab115()
}

if/else if/else是ArkTS中的条件渲染语法。在build()方法内,if语句不仅控制逻辑流,还控制UI结构的生成与销毁——当条件为true时,对应的组件被创建并渲染;当条件变为false时,该组件被销毁。这如同电影中的场景切换——当导演喊"换场"时,当前场景的布景被拆除,新场景的布景被搭建。

关键概念:if/else条件渲染与@State配合使用,实现了"状态驱动场景切换"的核心机制。当用户点击底部导航栏的某个标签时,this.currentTab被修改,ArkUI框架检测到@State变化,重新执行build()方法,if/else分支根据新的currentTab值渲染对应的场景组件。旧场景组件被自动销毁回收,新场景组件被创建挂载。这种机制确保了同一时间只有一幕场景在"拍摄",节省了系统资源。

每个场景组件在创建时通过参数传递接收回调函数——BoardGameTab115接收onBook(预约开台回调)、ScriptTab115接收onDetail(查看详情回调)和onBuy(购买回调)、RoleTab115接收onEdit(编辑角色回调)等。这些回调函数如同演员与导演之间的对讲机——当演员在场景中触发某个动作时,通过对讲机通知导演(主组件)进行下一步调度。

2.3 底部七Tab徽章导航

在这里插入图片描述

// ===== 底部7 Tab(徽章圆角单排) =====
Row() {
  TabBtn115({icon:'🎲', label:'桌游', active: this.currentTab === 0, onTap: () => { this.currentTab = 0; }})
  TabBtn115({icon:'📜', label:'剧本', active: this.currentTab === 0, onTap: () => { this.currentTab = 1; }})
  TabBtn115({icon:'🎭', label:'角色', active: this.currentTab === 0, onTap: () => { this.currentTab = 2; }})
  TabBtn115({icon:'🪑', label:'开台', active: this.currentTab === 0, onTap: () => { this.currentTab = 3; }})
  TabBtn115({icon:'🏆', label:'排行', active: this.currentTab === 0, onTap: () => { this.currentTab = 4; }})
  TabBtn115({icon:'💬', label:'社区', active: this.currentTab === 0, onTap: () => { this.currentTab = 5; }})
  TabBtn115({icon:'👤', label:'我的', active: this.currentTab === 0, onTap: () => { this.currentTab = 6; }})
}
.width('100%')
.height(50)
.padding({ left: 2, right: 2, top: 3, bottom: 3 })
.backgroundColor(COLORS115.card)
.border({ width: 1, color: COLORS115.border })

底部导航使用七个TabBtn115自定义组件,每个组件接收四个参数——icon(emoji图标)、label(文字标签)、active(是否选中)和onTap(点击回调)。TabBtn115是一个独立的@Component组件:

@Component
struct TabBtn115 {
  icon: string = '🎲';
  label: string = '';
  active: boolean = false;
  onTap: () => void = () => {};
  build() {
    Column() {
      Text(this.icon)
        .fontSize(16)
        .fontColor(this.active ? COLORS115.white : COLORS115.textSub)
      Text(this.label)
        .fontSize(9)
        .fontWeight(this.active ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.active ? COLORS115.orangeLight : COLORS115.textSub)
        .margin({ top: 1 })
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .height(40)
    .borderRadius(10)
    .backgroundColor(this.active ? COLORS115.primary : COLORS115.card)
    .border({ width: 1, color: this.active ? COLORS115.primary : COLORS115.border })
    .animation({ duration: 200 })
    .onClick(() => { this.onTap(); })
  }
}

TabBtn115组件的成员变量iconlabelactiveonTap通过参数传递接收父组件的值。这种"父向子传参"的模式在ArkTS中是组件间通信的基础方式。@Component声明的组件成员变量(未使用@State标记的)是只读属性——子组件可以读取但不能直接修改,修改必须通过回调函数通知父组件。

关键概念:@Component组件的普通成员变量(非@State标记)相当于函数参数——父组件创建子组件实例时传入值,子组件内部读取使用。这种模式类似于导演给演员派发角色卡——演员按角色卡表演,但不能自行修改角色设定。如果子组件需要修改父组件状态,必须通过回调函数(如onTap)向上传递。

.animation({ duration: 200 })方法为组件添加了动画效果——animation是ArkUI的通用属性方法,duration参数指定动画时长(毫秒)。当active状态切换导致backgroundColorborder等属性变化时,变化过程会在200毫秒内平滑过渡,如同电影中的转场效果——场景切换不是生硬的跳切,而是平滑的淡入淡出。

2.4 弹框绑定与bindSheet/bindContentCover

.bindContentCover($$this.showBookDialog, () => {
}, {
})
.bindSheet($$this.showBuySheet, () => {
}, {
  height: 540,
  dragBar: true,
  showClose: true
})
.bindSheet($$this.showEditSheet, () => {
}, {
  height: 500,
  dragBar: true,
  showClose: true
})
.bindContentCover($$this.showDeleteDialog, () => {
}, {
})
.bindContentCover($$this.showDetailDialog, () => {
}, {
})

主页面通过五个绑定方法管理五组弹框。bindContentCover用于绑定居中式弹框(预约开台、删除战报、剧本详情),bindSheet用于绑定底部抽屉式弹框(购买剧本、编辑角色)。$$this.showBookDialog$$双向绑定语法——$$@State变量与弹框的显示状态双向绑定,当变量为true时弹出弹框,为false时关闭。

关键概念:bindSheetbindContentCover是ArkUI提供的两种弹框绑定方式。bindSheet创建从底部滑入的抽屉式弹框,适合表单交互(如购买、编辑);bindContentCover创建全屏蒙层覆盖的居中弹框,适合信息展示或确认操作(如详情、删除确认)。两者的第一个参数都使用$$双向绑定,第二个参数是Builder函数返回弹框内容,第三个参数是配置对象。

bindSheet的配置对象中,height指定抽屉高度,dragBar控制是否显示顶部拖拽条,showClose控制是否显示关闭按钮。bindContentCover的配置对象为空,使用默认配置。

第三幕:桌游展厅——横滑大卡与网格陈列

3.1 横滑大卡:Scroll水平滚动

@Component
struct BoardGameTab115 {
  onBook: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 横滑大卡
        Row() {
          Text('🎲 本周热门桌游')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('横向滑动 ›')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Scroll() {
          Row() {
            Row() {
              Column() {
                Text('龙与地下城')
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.white)
                Text('入门套 · 长线战役 · 新手友好')
                  .fontSize(9)
                  .fontColor('rgba(255,255,255,0.8)')
                  .margin({ top: 4 })
                Text('¥349 ›')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.orangeLight)
                  .margin({ top: 6 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('🐉')
                .fontSize(36)
            }
            .width(240)
            .height(82)
            .padding(12)
            .borderRadius(12)
            .linearGradient({ angle: 90, colors: [['#1A237E', 0], ['#4527A0', 1]] })
            .margin({ right: 10 })
          }
          .padding({ left: 12, right: 12 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .height(96)
        .margin({ top: 8 })

Scroll是ArkUI提供的滚动容器组件。外层Scroll为垂直滚动,包裹整个页面的所有内容;内层Scroll通过.scrollable(ScrollDirection.Horizontal)设置为水平滚动,用于展示横滑大卡。ScrollDirection枚举定义了Horizontal(水平)和Vertical(垂直)两种滚动方向。

关键概念:Scroll组件可以嵌套使用——外层垂直滚动包裹整个页面,内层水平滚动展示横向卡片列表。这是移动端常见的布局模式,如同电影中的推轨镜头——摄像机沿轨道水平移动,逐个展示排列在轨道旁的场景。.scrollBar(BarState.Off)隐藏了滚动条,BarState枚举提供OnOffAuto三种状态。

横滑大卡使用linearGradient设置了从暗紫到标准紫的横向渐变(angle: 90即从左到右),如同展厅中的聚光灯从一侧打来,为卡片营造出戏剧性的光影效果。卡片内的文字使用白色和半透明白色(rgba(255,255,255,0.8)),确保在深色背景上的可读性。

3.2 桌游网格:ForEach列表渲染

Column() {
  ForEach(BOARD_GAMES115, (g: BoardGame115) => {
    Column() {
      Column() {
        Text(g.emoji)
          .fontSize(32)
        if (g.hot) {
          Text('🔥 热卖')
            .fontSize(8)
            .fontColor(COLORS115.white)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS115.danger)
            .borderRadius(4)
            .margin({ top: 3 })
        }
      }
      .width('100%')
      .height(84)
      .justifyContent(FlexAlign.Center)
      .backgroundColor(COLORS115.bg)
      .borderRadius(10)
      Text(g.name)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS115.textMain)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .width('100%')
        .margin({ top: 6 })
      Text(g.players + ' · ' + g.time)
        .fontSize(8)
        .fontColor(COLORS115.textSub)
        .width('100%')
        .margin({ top: 2 })
      Text(diffStars115(g.difficulty))
        .fontSize(8)
        .fontColor(COLORS115.orange)
        .width('100%')
        .margin({ top: 2 })
      Row() {
        Text('¥' + g.price.toString())
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.primary)
        Text('¥' + g.oldPrice.toString())
          .fontSize(9)
          .fontColor(COLORS115.textHint)
          .decoration({ type: TextDecorationType.LineThrough })
          .margin({ left: 4 })
      }
      .width('100%')
      .margin({ top: 3 })
      Text('已售' + g.sales)
        .fontSize(8)
        .fontColor(COLORS115.textHint)
        .width('100%')
        .margin({ top: 2 })
    }
    .padding(10)
    .backgroundColor(COLORS115.card)
    .borderRadius(12)
    .border({ width: 1, color: COLORS115.border })
    .margin({ bottom: 10 })
  }, (g: BoardGame115) => g.id)
}

ForEach是ArkUI提供的循环渲染组件,用于根据数据列表生成UI组件。ForEach的第一个参数是数据源数组(BOARD_GAMES115),第二个参数是渲染函数(接收每个数据项并返回UI结构),第三个参数是键值生成函数(g: BoardGame115) => g.id——使用每条数据的id字段作为唯一键值。

关键概念:ForEach的键值生成函数(第三个参数)在ArkTS中非常重要。它类似于电影中每个镜头的编号——当数据列表发生变化(增删改)时,框架通过键值判断哪些项需要重新渲染、哪些可以复用。使用数据的唯一标识字段(如g.id)作为键值是最佳实践,它确保了即使数据顺序变化,框架也能精确匹配到对应的UI组件,避免不必要的重新渲染。

if (g.hot)条件渲染控制"热卖"标签的显示——只有g.hottrue的桌游才显示红色"🔥 热卖"标签。这如同电影中的特写镜头——只有重要角色才给特写,配角只出现在远景中。.maxLines(1)限制文本最多一行,.textOverflow({ overflow: TextOverflow.Ellipsis })设置溢出时显示省略号——TextOverflow枚举提供NoneClipEllipsis三种处理方式。

.decoration({ type: TextDecorationType.LineThrough })为原价文字添加了删除线效果——TextDecorationType枚举提供None(无装饰)、LineThrough(删除线)、Underline(下划线)三种文字装饰类型。原价加删除线是电商场景的标准设计语言,通过视觉对比突出折扣力度。

第四幕:剧本图书馆——题材分布与评分列表

4.1 题材分布横条图

@Component
struct ScriptTab115 {
  onDetail: (s: Script115) => void = () => {};
  onBuy: (s: Script115) => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 题材分布图
        Row() {
          Text('📊 剧本题材热度')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('本月')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(GENRE_BARS115, (b: GenreBar115) => {
            Row() {
              Text(b.name)
                .fontSize(10)
                .fontColor(COLORS115.textMain)
                .width(52)
              Column() {
                Row() {
                  Column()
                    .width(barW115(b.value))
                    .height(9)
                    .borderRadius(4)
                    .backgroundColor(b.color)
                }
                .width('100%')
                .height(9)
                .borderRadius(4)
                .backgroundColor(COLORS115.border)
              }
              .layoutWeight(1)
              .margin({ left: 6, right: 6 })
              Text(b.value.toString() + '%')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(b.color)
                .width(34)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (b: GenreBar115) => b.id)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 8 })

ScriptTab115组件是剧本图书馆场景。开篇用题材分布横条图展示本月各类剧本的热度占比。ForEach遍历GENRE_BARS115数组,每个题材生成一个横条——左侧是题材名称(固定宽度52vp),中间是进度条(Column包裹一个带颜色的Column色块),右侧是百分比数值。

横条的实现使用了Column嵌套Row嵌套Column的结构——最外层Column作为轨道(背景色为COLORS115.border浅灰色),内层Row中的Column使用barW115(b.value)计算出的百分比宽度作为色块。这种"容器+色块"的进度条实现方式是ArkTS中的常见手法,如同电影中的画中画特效——外层容器是画框,内层色块是实际内容。

导演笔记:Column().width(barW115(b.value))这种写法中,Column组件不包含任何子元素,纯粹作为色块使用。在ArkTS中,ColumnRowText('')等组件常被用作纯色块或间距占位符,因为ArkTS不允许使用divView等HTML标签,所有视觉元素都必须通过ArkUI组件实现。

4.2 剧本列表卡片

ForEach(SCRIPTS115, (s: Script115) => {
  Row() {
    Text(s.emoji)
      .fontSize(30)
      .width(50)
      .height(50)
      .textAlign(TextAlign.Center)
      .backgroundColor(COLORS115.bg)
      .borderRadius(12)
    Column() {
      Row() {
        Text(s.name)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(s.rating.toString())
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.orange)
          .padding({ left: 4, right: 4, top: 1, bottom: 1 })
          .backgroundColor(COLORS115.orangeBg)
          .borderRadius(4)
          .margin({ left: 5 })
      }
      .width('100%')
      Row() {
        Text(s.type)
          .fontSize(8)
          .fontColor(COLORS115.textSub)
          .padding({ left: 5, right: 5, top: 1, bottom: 1 })
          .border({ width: 1, color: COLORS115.border })
          .borderRadius(3)
        Text(' ' + s.players + '人')
          .fontSize(8)
          .fontColor(COLORS115.primary)
        Text(' ' + s.time)
          .fontSize(8)
          .fontColor(COLORS115.textSub)
        Text(' ' + s.difficulty)
          .fontSize(8)
          .fontColor(COLORS115.danger)
      }
      .width('100%')
      .margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Start)
    .margin({ left: 10 })
    Column() {
      Text('¥' + s.price.toString())
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS115.primary)
      Text('详情')
        .fontSize(9)
        .fontColor(COLORS115.primary)
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        .border({ width: 1, color: COLORS115.primary })
        .borderRadius(6)
        .margin({ top: 4 })
        .onClick(() => { this.onDetail(s); })
      Text('购买')
        .fontSize(9)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS115.white)
        .padding({ left: 10, right: 10, top: 2, bottom: 2 })
        .backgroundColor(COLORS115.primary)
        .borderRadius(6)
        .margin({ top: 5 })
        .onClick(() => { this.onBuy(s); })
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(10)
  .backgroundColor(COLORS115.card)
  .borderRadius(12)
  .border({ width: 1, color: COLORS115.border })
  .margin({ left: 12, right: 12, top: 8 })
}, (s: Script115) => s.id)

剧本列表卡片采用"左图标-中信息-右操作"的三栏布局。左侧Text(s.emoji)使用30vp字号展示剧本emoji图标,背景为浅色圆角块。中间Column包含两行信息——第一行是剧本名称(加粗)和评分徽章(橙色背景),第二行是题材类型(带边框标签)、人数、时长和难度(红色文字)。

右侧操作区包含三个元素——价格(紫色加粗)、"详情"按钮(紫色描边按钮)和"购买"按钮(紫色实心按钮)。.onClick(() => { this.onDetail(s); }).onClick(() => { this.onBuy(s); })分别为两个按钮绑定了点击回调,将当前剧本对象s传递给父组件。

关键概念:.onClick()是ArkUI组件的通用事件方法,接收一个箭头函数作为回调。注意箭头函数() => { this.onDetail(s); }中的this指向当前组件实例(ScriptTab115),这得益于箭头函数不创建自己的this绑定。如果使用普通函数function() { this.onDetail(s); }this将指向函数调用上下文而非组件实例,导致运行时错误。

第五幕:角色化妆间——阵营分布与角色卡组

5.1 阵营分布横条图

@Component
struct RoleTab115 {
  onEdit: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 阵营分布
        Row() {
          Text('⚖️ 阵营分布')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('当前卡组12人')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(CAMP_BARS115, (b: CampBar115) => {
            Row() {
              Text(b.name)
                .fontSize(10)
                .fontColor(COLORS115.textMain)
                .width(64)
              Column() {
                Row() {
                  Column()
                    .width(barW115(b.value))
                    .height(10)
                    .borderRadius(5)
                    .backgroundColor(b.color)
                }
                .width('100%')
                .height(10)
                .borderRadius(5)
                .backgroundColor(COLORS115.border)
              }
              .layoutWeight(1)
              .margin({ left: 6, right: 6 })
              Text(b.value.toString() + '%')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(b.color)
                .width(34)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (b: CampBar115) => b.id)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 8 })

RoleTab115组件是角色化妆间场景。开篇用阵营分布横条图展示当前卡组中好人、狼人、中立三个阵营的占比。campColor115函数为不同阵营返回不同的颜色——好人紫色、狼人红色、中立橙色,这三种颜色与阵营的"正邪中立"定位高度契合,如同电影中角色服装的配色方案——正面角色穿暖色、反派穿冷色、中立角色穿中性色。

5.2 角色卡组列表

ForEach(ROLES115, (r: Role115) => {
  Column() {
    Text(r.emoji)
      .fontSize(28)
      .textAlign(TextAlign.Center)
      .width('100%')
      .height(58)
      .padding({ top: 10 })
      .backgroundColor(COLORS115.bg)
      .borderRadius(10)
    Text(r.name)
      .fontSize(10)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS115.textMain)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')
      .margin({ top: 4 })
    Text(r.camp + ' · ' + r.desc)
      .fontSize(8)
      .fontColor(campColor115(r.camp))
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')
      .margin({ top: 2 })
    Text(r.desc)
      .fontSize(8)
      .fontColor(COLORS115.textSub)
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width('100%')
      .margin({ top: 1 })
  }
  .padding(8)
  .backgroundColor(COLORS115.card)
  .borderRadius(12)
  .border({ width: 1, color: COLORS115.border })
  .margin({ bottom: 8 })
  .onClick(() => { this.onEdit(); })
}, (r: Role115) => r.id)

角色卡使用ForEach遍历ROLES115数组,每个角色生成一张卡片。卡片顶部是emoji图标(居中在58vp高的浅色区域),下方是角色名称(加粗)和阵营+技能描述(阵营颜色)。.onClick(() => { this.onEdit(); })为整张卡片绑定了点击事件——点击任意角色卡都会触发编辑角色弹框。

导演笔记:角色卡的campColor115(r.camp)调用为阵营文字赋予了语义化颜色——好人紫色、狼人红色、中立橙色。这种"数据驱动颜色"的设计模式使得颜色不再是硬编码的魔法值,而是通过函数从业务数据中推导得出。当阵营体系扩展时(如增加"第三方"阵营),只需在campColor115函数中添加一个分支,所有角色卡的颜色自动更新。

第六幕:开台前台——桌型选择与拼车列表

6.1 桌型选择与拼车

@Component
struct TableTab115 {
  onBook: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 桌型选择
        Row() {
          Text('🪑 选择桌型')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('支持到店/预约')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(TABLE_TYPES115, (t: TableType115) => {
            Column() {
              Text(t.emoji)
                .fontSize(26)
              Text(t.name)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .margin({ top: 4 })
              Text(t.desc)
                .fontSize(8)
                .fontColor(COLORS115.textSub)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .margin({ top: 2 })
              Text(t.price)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.orange)
                .margin({ top: 4 })
            }
            .padding(10)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ bottom: 10 })
            .onClick(() => { this.onBook(); })
          }, (t: TableType115) => t.id)
        }

        // 拼车列表
        Row() {
          Text('🧩 正在拼车')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('发起拼车 ›')
            .fontSize(10)
            .fontColor(COLORS115.orange)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        Column() {
          ForEach(SESSIONS115, (s: Session115) => {
            Row() {
              Text(s.emoji)
                .fontSize(26)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS115.bg)
                .borderRadius(10)
              Column() {
                Text(s.name)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.textMain)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(s.date + ' · ' + s.players + '人')
                  .fontSize(9)
                  .fontColor(COLORS115.textSub)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text(s.status === '已满员' ? '已满' : '加入')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(s.status === '已满员' ? COLORS115.textHint : COLORS115.white)
                .padding({ left: 12, right: 12, top: 4, bottom: 4 })
                .backgroundColor(s.status === '已满员' ? COLORS115.border : COLORS115.orange)
                .borderRadius(8)
            }
            .width('100%')
            .padding(10)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ left: 12, right: 12, top: 8 })
          }, (s: Session115) => s.id)
        }

TableTab115组件是开台前台场景。包含两个区域——桌型选择和拼车列表。桌型选择使用ForEach遍历TABLE_TYPES115数组,每个桌型生成一张卡片,点击触发this.onBook()回调弹出预约弹框。

拼车列表中的"加入/已满"按钮使用了三元表达式:s.status === '已满员' ? '已满' : '加入'根据状态选择按钮文字,s.status === '已满员' ? COLORS115.textHint : COLORS115.white选择文字颜色,s.status === '已满员' ? COLORS115.border : COLORS115.orange选择背景色。三个三元表达式配合使用,实现了状态驱动的按钮样式切换——可加入时显示橙色"加入"按钮,已满时显示灰色"已满"按钮。

导演笔记:三元表达式在ArkTS的UI构建中广泛使用——它允许在属性值层面做条件判断,无需使用if/else条件渲染。当需要根据数据值选择颜色、文字、字号等属性时,三元表达式是最简洁的写法。但对于需要渲染完全不同UI结构的场景,if/else条件渲染更为合适。

第七幕:荣誉殿堂与社区广场

7.1 双榜排名

@Component
struct RankTab115 {
  build() {
    Scroll() {
      Column() {
        // 剧本榜
        Row() {
          Text('📜 剧本口碑榜')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('周榜')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(RANK_SCRIPTS115, (r: RankItem115, i: number) => {
            Row() {
              Text((i + 1).toString())
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(rankColor115(i))
                .width(24)
              Text(r.emoji)
                .fontSize(18)
                .width(28)
              Text(r.name)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .layoutWeight(1)
              Column() {
                Row() {
                  Column()
                    .width(barW115(r.score * 10))
                    .height(8)
                    .borderRadius(4)
                    .backgroundColor(i < 3 ? COLORS115.orange : COLORS115.primaryLight)
                }
                .width(80)
                .height(8)
                .borderRadius(4)
                .backgroundColor(COLORS115.border)
              }
              .margin({ right: 8 })
              Text(r.score.toString())
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.orange)
                .width(30)
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }, (r: RankItem115) => r.id)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 4 })
        .backgroundColor(COLORS115.card)
        .borderRadius(12)
        .border({ width: 1, color: COLORS115.border })
        .margin({ left: 12, right: 12, top: 8 })

RankTab115组件是荣誉殿堂场景,展示剧本口碑榜和桌游畅销榜双榜。ForEach遍历RANK_SCRIPTS115数组时,渲染函数的第二个参数i: number是当前项的索引(从0开始)。rankColor115(i)函数根据排名索引返回颜色——第1名橙色、第2名灰色、第3名浅紫、其余提示色,如同颁奖典礼中的金银铜奖配色。

排名条使用Column().width(barW115(r.score * 10))实现——将评分(如9.5)乘以10得到95,再通过barW115函数拼接为"95%"字符串作为宽度。前三名使用橙色进度条(i < 3 ? COLORS115.orange : COLORS115.primaryLight),其余使用浅紫色,视觉上区分头部与尾部排名。

7.2 社区战报流

@Component
struct CommunityTab115 {
  onDelete: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 话题横条
        Scroll() {
          Row() {
            Text('💬 今日话题')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.primaryDark)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS115.orangeBg)
              .borderRadius(10)
            Text('#剧本测评')
              .fontSize(10)
              .fontColor(COLORS115.textSub)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS115.card)
              .border({ width: 1, color: COLORS115.border })
              .borderRadius(10)
              .margin({ left: 8 })
            Text('#拼车开台')
              .fontSize(10)
              .fontColor(COLORS115.textSub)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS115.card)
              .border({ width: 1, color: COLORS115.border })
              .borderRadius(10)
              .margin({ left: 8 })
          }
          .padding({ left: 12, right: 12 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .height(42)
        .margin({ top: 12 })

        // 战报流
        ForEach(REPORTS115, (r: Report115, i: number) => {
          Row() {
            Text(r.emoji)
              .fontSize(34)
              .width(56)
              .height(56)
              .textAlign(TextAlign.Center)
              .backgroundColor(i % 2 === 0 ? COLORS115.bg : COLORS115.orangeBg)
              .borderRadius(12)
            Column() {
              Text(r.name)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Row() {
                Text('剧本《' + r.script + '》')
                  .fontSize(9)
                  .fontColor(COLORS115.primary)
                Text(' · ')
                  .fontSize(9)
                  .fontColor(COLORS115.textHint)
                Text(r.result)
                  .fontSize(9)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.orange)
                Text('').layoutWeight(1)
                Text('❤️ ' + likeFmt115(r.likes))
                  .fontSize(10)
                  .fontColor(COLORS115.danger)
              }
              .width('100%')
              .margin({ top: 4 })
              Row() {
                Text('@' + r.author)
                  .fontSize(9)
                  .fontColor(COLORS115.textSub)
                Text('').layoutWeight(1)
                Text('🗑️ 删除')
                  .fontSize(9)
                  .fontColor(COLORS115.danger)
                  .onClick(() => { this.onDelete(); })
              }
              .width('100%')
              .margin({ top: 5 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS115.card)
          .borderRadius(12)
          .border({ width: 1, color: COLORS115.border })
          .margin({ left: 12, right: 12, top: 8 })
        }, (r: Report115) => r.id)
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

CommunityTab115组件是社区广场场景。顶部话题横条使用水平Scroll展示可滑动的话题标签。战报流使用ForEach遍历REPORTS115数组,每条战报生成一个卡片。

导演笔记:i % 2 === 0 ? COLORS115.bg : COLORS115.orangeBg使用取模运算实现交替背景色——偶数索引用浅紫背景,奇数索引用淡橙背景。这种"斑马纹"设计在列表场景中常见,帮助用户在视觉上区分相邻项,如同电影中的场景切换标记。

likeFmt115(r.likes)函数将点赞数格式化——超过1万的转换为"x.xw"格式(如8600显示为"8600",12800显示为"1.3w")。.onClick(() => { this.onDelete(); })为删除按钮绑定了点击回调,触发删除战报弹框。

第八幕:五段插叙——弹框组件的构建

8.1 预约开台弹框(居中)

@Component
struct BookTableDialog115 {
  selTable: number = 0;
  playerCount: number = 6;
  selRoom: boolean = true;
  onTable: (t: number) => void = () => {};
  onCount: (c: number) => void = () => {};
  onRoom: (r: boolean) => void = () => {};
  onConfirm: () => void = () => {};
  onCancel: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('🪑 预约开台')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.textMain)
            Text('周六 19:00 · 剩余桌位 4')
              .fontSize(9)
              .fontColor(COLORS115.textSub)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor(COLORS115.textHint)
            .onClick(() => { this.onCancel(); })
        }
        .width('100%')

        // 桌型
        Text('选择桌型')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .width('100%')
          .margin({ top: 12 })
        Row() {
          ForEach(TABLE_TYPES115, (t: TableType115, i: number) => {
            Column() {
              Text(t.emoji)
                .fontSize(20)
              Text(t.name)
                .fontSize(9)
                .fontColor(this.selTable === i ? COLORS115.white : COLORS115.textMain)
                .margin({ top: 3 })
              Text(t.price)
                .fontSize(8)
                .fontColor(this.selTable === i ? COLORS115.orangeLight : COLORS115.orange)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .padding({ top: 9, bottom: 9 })
            .backgroundColor(this.selTable === i ? COLORS115.primary : COLORS115.card)
            .border({ width: 1, color: this.selTable === i ? COLORS115.primary : COLORS115.border })
            .borderRadius(10)
            .margin({ right: 8 })
            .onClick(() => { this.onTable(i); })
          }, (t: TableType115) => t.id)
        }
        .width('100%')
        .margin({ top: 8 })

        // 人数步进
        Text('参与人数')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .width('100%')
          .margin({ top: 14 })
        Row() {
          Text('−')
            .fontSize(16)
            .fontColor(COLORS115.primary)
            .padding({ left: 16, right: 16 })
            .onClick(() => { if (this.playerCount > 2) { this.onCount(this.playerCount - 1); } })
          Text(this.playerCount.toString() + ' 人')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
            .layoutWeight(1)
            .textAlign(TextAlign.Center)
          Text('+')
            .fontSize(16)
            .fontColor(COLORS115.primary)
            .padding({ left: 16, right: 16 })
            .onClick(() => { if (this.playerCount < 16) { this.onCount(this.playerCount + 1); } })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS115.bg)
        .borderRadius(10)
        .margin({ top: 8 })

BookTableDialog115是预约开台的居中弹框。它声明了三个数据成员(selTable桌型索引、playerCount参与人数、selRoom是否升级主题房)和五个回调函数(onTable桌型变化、onCount人数变化、onRoom主题房变化、onConfirm确认、onCancel取消)。

关键概念:弹框组件与Tab组件的数据传递模式不同——Tab组件的交互状态由主组件的@State管理,子组件通过回调通知主组件修改状态;弹框组件由于需要绑定到bindContentCoverbindSheet上,通常作为"无状态"组件使用,所有交互状态由主组件通过参数传入和回调管理。这种模式确保了弹框的"即时状态"与主页面的"持久状态"保持同步。

人数步进器的"−"和"+"按钮使用Text组件实现,绑定了.onClick()事件。if (this.playerCount > 2)if (this.playerCount < 16)做了边界检查——最少2人,最多16人,防止数值越界。

8.2 购买剧本弹框(抽屉)

@Component
struct BuySheet115 {
  item: Script115 | null = null;
  selQty: number = 1;
  selVersion: number = 0;
  selExpand: boolean = false;
  selMembership: boolean = true;
  onQty: (q: number) => void = () => {};
  onVersion: (v: number) => void = () => {};
  onExpand: (e: boolean) => void = () => {};
  onMembership: (m: boolean) => void = () => {};
  onConfirm: () => void = () => {};
  onCancel: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        Row() {
          Text(this.item?.emoji ?? '📜')
            .fontSize(34)
            .width(54)
            .height(54)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS115.bg)
            .borderRadius(12)
          Column() {
            Text(this.item?.name ?? '')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.textMain)
            Row() {
              Text('¥' + (this.item?.price ?? 0))
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.primary)
              Text('评分' + (this.item?.rating ?? 0))
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.orange)
                .margin({ left: 6 })
            }
            .margin({ top: 4 })
            Text('已售' + (this.item?.sales ?? '') + '份 · 电子授权')
              .fontSize(9)
              .fontColor(COLORS115.textSub)
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 12 })
          Text('✕')
            .fontSize(18)
            .fontColor(COLORS115.textHint)
            .onClick(() => { this.onCancel(); })
        }
        .width('100%')
        .padding(16)

        Divider().color(COLORS115.border)

        // 版本
        Text('购买版本')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .width('100%')
          .padding({ left: 16, top: 14 })
        Row() {
          ForEach(['电子版', '实体盒装', '电子+实体'], (v: string, i: number) => {
            Column() {
              Text(v)
                .fontSize(10)
                .fontColor(this.selVersion === i ? COLORS115.white : COLORS115.textSub)
              Text(i === 0 ? '¥' + (this.item?.price ?? 0).toString() : (i === 1 ? '¥' + ((this.item?.price ?? 0) + 40).toString() : '¥' + ((this.item?.price ?? 0) + 60).toString()))
                .fontSize(8)
                .fontColor(this.selVersion === i ? COLORS115.orangeLight : COLORS115.textHint)
                .margin({ top: 2 })
            }
            .padding({ left: 14, right: 14, top: 7, bottom: 7 })
            .backgroundColor(this.selVersion === i ? COLORS115.primary : COLORS115.card)
            .border({ width: 1, color: this.selVersion === i ? COLORS115.primary : COLORS115.border })
            .borderRadius(10)
            .margin({ right: 10 })
            .onClick(() => { this.onVersion(i); })
          }, (v: string) => v)
        }
        .width('100%')
        .padding({ left: 16, top: 10 })

BuySheet115是购买剧本的抽屉弹框。item: Script115 | null = null声明了一个可空类型的成员变量——Script115 | null表示该变量可以是Script115类型或nullthis.item?.emoji ?? '📜'使用了可选链操作符?.和空值合并操作符??——如果this.item不为null则取其emoji属性,否则使用默认值'📜'

关键概念:可选链操作符?.和空值合并操作符??是TypeScript/ArkTS中处理可空类型的安全访问方式。this.item?.name等价于this.item !== null ? this.item.name : undefinedthis.item?.name ?? ''进一步将undefined替换为空字符串。这种链式安全访问避免了运行时Cannot read property of null的错误。

Divider().color(COLORS115.border)创建了一个分隔线组件——Divider是ArkUI提供的分隔线组件,用于在列表项之间添加视觉分隔。.color()方法设置分隔线颜色。Divider如同电影中的幕间休息标记——在两段内容之间插入视觉间隔,帮助观众理解内容的分段。

版本选择使用ForEach遍历三个版本选项(电子版、实体盒装、电子+实体),每个版本的价格通过嵌套三元表达式计算——电子版为原价、实体盒装加40元、电子+实体加60元。

8.3 编辑角色弹框(抽屉)与TextInput

@Component
struct EditRoleSheet115 {
  roleName: string = '';
  selCamp: number = 0;
  selPublic: boolean = true;
  onText: (t: string) => void = () => {};
  onCamp: (c: number) => void = () => {};
  onPublic: (p: boolean) => void = () => {};
  onConfirm: () => void = () => {};
  onCancel: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        Row() {
          Text('🎭 编辑角色卡')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor(COLORS115.textHint)
            .onClick(() => { this.onCancel(); })
        }
        .width('100%')
        .padding(16)

        Divider().color(COLORS115.border)

        // 角色名
        Text('角色昵称')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .width('100%')
          .padding({ left: 16, top: 14 })
        TextInput({ placeholder: '如:暗夜之刃', text: this.roleName })
          .width('92%')
          .margin({ left: 16, top: 8 })
          .backgroundColor(COLORS115.card)
          .border({ width: 1, color: COLORS115.border })
          .borderRadius(8)
          .onChange((v: string) => { this.onText(v); })

        // 阵营
        Text('所属阵营')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .width('100%')
          .padding({ left: 16, top: 14 })
        Row() {
          ForEach(['好人', '狼人', '中立'], (c: string, i: number) => {
            Text(c)
              .fontSize(10)
              .fontColor(this.selCamp === i ? COLORS115.white : COLORS115.textSub)
              .padding({ left: 16, right: 16, top: 6, bottom: 6 })
              .backgroundColor(this.selCamp === i ? campColor115(c) : COLORS115.card)
              .border({ width: 1, color: this.selCamp === i ? campColor115(c) : COLORS115.border })
              .borderRadius(10)
              .margin({ right: 10 })
              .onClick(() => { this.onCamp(i); })
          }, (c: string) => c)
        }
        .width('100%')
        .padding({ left: 16, top: 10 })

        // 公开
        Row() {
          Column() {
            Text('🌐 加入公开角色池')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.textMain)
            Text('其他玩家开台时可选用')
              .fontSize(9)
              .fontColor(COLORS115.textSub)
              .margin({ top: 2 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text(this.selPublic ? '☑' : '☐')
            .fontSize(20)
            .fontColor(this.selPublic ? COLORS115.primary : COLORS115.textHint)
            .onClick(() => { this.onPublic(!this.selPublic); })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 14 })

        Text('保存角色')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.white)
          .width('92%')
          .textAlign(TextAlign.Center)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS115.primary)
          .borderRadius(22)
          .margin({ left: 16, top: 18 })
          .onClick(() => { this.onConfirm(); })
      }
      .width('100%')
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
  }
}

EditRoleSheet115是编辑角色的抽屉弹框。TextInput是ArkUI提供的文本输入组件——placeholder参数设置占位提示文字,text参数设置当前文本值。.onChange((v: string) => { this.onText(v); })绑定了文本变化事件——当用户在输入框中键入或删除文字时,回调函数被触发,参数v为最新的文本值,通过this.onText(v)回调传递给主组件。

关键概念:TextInput是ArkUI的文本输入组件,支持text(当前值)、placeholder(占位提示)、type(输入类型,如普通文本、密码、数字)等参数。.onChange()事件在文本变化时触发。在实际开发中,TextInput常配合@State使用实现双向数据绑定——但在弹框组件中,由于状态由主组件管理,通过回调函数向上传递文本值。

阵营选择使用ForEach遍历三个选项(好人、狼人、中立),选中时使用campColor115(c)函数返回的阵营专属颜色作为背景和边框色。this.selPublic ? '☑' : '☐'使用Unicode字符模拟复选框——选中时显示(带勾选),未选中时显示(空框),点击切换状态。

8.4 删除战报弹框(居中)

@Component
struct DeleteReportDialog115 {
  selClearComments: boolean = false;
  onClear: (b: boolean) => void = () => {};
  onConfirm: () => void = () => {};
  onCancel: () => void = () => {};
  build() {
    Column() {
      Text('💣')
        .fontSize(40)
        .margin({ top: 6 })
      Text('确定删除这条战报?')
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS115.textMain)
        .margin({ top: 8 })
      Text('「欢乐本天花板名场面」删除后不可恢复')
        .fontSize(10)
        .fontColor(COLORS115.textSub)
        .margin({ top: 6 })
      Row() {
        Column() {
          Text('🗑️ 同时清空该战报的全部评论')
            .fontSize(11)
            .fontColor(COLORS115.textMain)
          Text('包含326条评论与点赞 · 无法找回')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Text(this.selClearComments ? '☑' : '☐')
          .fontSize(20)
          .fontColor(this.selClearComments ? COLORS115.danger : COLORS115.textHint)
          .onClick(() => { this.onClear(!this.selClearComments); })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS115.bg)
      .borderRadius(10)
      .margin({ top: 14 })
      Row() {
        Text('再想想')
          .fontSize(13)
          .fontColor(COLORS115.textSub)
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS115.card)
          .border({ width: 1, color: COLORS115.border })
          .borderRadius(22)
          .onClick(() => { this.onCancel(); })
        Text('确认删除')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.white)
          .textAlign(TextAlign.Center)
          .layoutWeight(1)
          .padding({ top: 11, bottom: 11 })
          .backgroundColor(COLORS115.danger)
          .borderRadius(22)
          .margin({ left: 12 })
          .onClick(() => { this.onConfirm(); })
      }
      .width('100%')
      .margin({ top: 16 })
    }
    .width('86%')
    .padding(18)
    .backgroundColor(COLORS115.card)
    .borderRadius(16)
  }
}

DeleteReportDialog115是删除战报的居中确认弹框。它使用了一个"同时清空评论"的复选选项——Text(this.selClearComments ? '☑' : '☐')模拟复选框,this.selClearComments ? COLORS115.danger : COLORS115.textHint根据状态选择颜色。确认按钮使用COLORS115.danger红色背景,符合"危险操作"的视觉语义。

导演笔记:居中确认弹框的设计原则是"警示明确、操作可逆"。顶部使用💣炸弹emoji作为视觉警示,中间用红色"不可恢复"文字强调后果,底部提供"再想想"和"确认删除"两个按钮——前者是安全的退出路径,后者是确认操作。按钮使用.layoutWeight(1)均分宽度,确保两个按钮等宽,符合对称布局的美学原则。

8.5 剧本详情弹框(居中)

@Component
struct ScriptDetailDialog115 {
  item: Script115 | null = null;
  onBuy: () => void = () => {};
  onCancel: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text(this.item?.name ?? '')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.white)
            Text((this.item?.type ?? '') + ' · ' + (this.item?.players ?? '') + '人 · ' + (this.item?.time ?? ''))
              .fontSize(10)
              .fontColor('rgba(255,255,255,0.8)')
              .margin({ top: 4 })
            Text('评分 ' + (this.item?.rating ?? 0) + ' · 已售' + (this.item?.sales ?? '') + '份')
              .fontSize(9)
              .fontColor('rgba(255,255,255,0.7)')
              .margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text(this.item?.emoji ?? '📜')
            .fontSize(40)
        }
        .width('100%')
        .padding(16)
        .linearGradient({ angle: 90, colors: [['#1A237E', 0], ['#4527A0', 1]] })
        .borderRadius(12)

        // 简介
        Text('📖 剧情简介')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .width('100%')
          .margin({ top: 14 })
        Text('一列驶向远方的列车,载着七个各怀心事的旅客……当真相逐渐浮出水面,每个微笑背后都藏着不可告人的秘密。')
          .fontSize(10)
          .fontColor(COLORS115.textSub)
          .width('100%')
          .margin({ top: 6 })

        // 基本信息
        Text('📋 剧本信息')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS115.textMain)
          .width('100%')
          .margin({ top: 14 })
        Row() {
          Text('题材类型')
            .fontSize(10)
            .fontColor(COLORS115.textSub)
            .width(70)
          Text(this.item?.type ?? '-')
            .fontSize(10)
            .fontColor(COLORS115.textMain)
            .layoutWeight(1)
          Text('难度')
            .fontSize(10)
            .fontColor(COLORS115.textSub)
            .width(70)
          Text(this.item?.difficulty ?? '-')
            .fontSize(10)
            .fontColor(COLORS115.danger)
            .layoutWeight(1)
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        .border({ width: 1, color: COLORS115.border })
        .borderRadius(8)
        .padding({ left: 10, right: 10 })
        .margin({ top: 8 })

ScriptDetailDialog115是剧本详情的居中弹框。头部使用深紫渐变背景展示剧本名称、类型、人数、时长、评分和销量。(this.item?.type ?? '') + ' · ' + (this.item?.players ?? '') + '人 · ' + (this.item?.time ?? '')使用可选链和空值合并操作符拼接多段信息,确保在itemnull时不报错。

关键概念:Scroll().constraintSize({ maxHeight: '85%' })限制了弹框内容的最大高度为屏幕的85%。constraintSize是ArkUI的约束尺寸方法,可以设置minWidthmaxWidthminHeightmaxHeight四个约束值。在弹框场景中,限制最大高度确保弹框不会超出屏幕边界,超出部分通过Scroll的滚动能力浏览。

叙事流程图

0

1

2

3

4

5

6

onBook

onDetail

onBuy

onEdit

onBook

onDelete

应用启动 @Entry

DuoDuoBoardApp 初始化

build 方法执行

currentTab 场景判断

BoardGameTab115 桌游展厅

ScriptTab115 剧本图书馆

RoleTab115 角色化妆间

TableTab115 开台前台

RankTab115 荣誉殿堂

CommunityTab115 社区广场

MineTab115 个人后台

BookTableDialog115 居中弹框

ScriptDetailDialog115 居中弹框

BuySheet115 抽屉弹框

EditRoleSheet115 抽屉弹框

DeleteReportDialog115 居中弹框

bindContentCover 绑定

bindSheet 绑定

关闭弹框 状态归零

技术对比表:ArkTS关键组件与装饰器一览

序号装饰器/组件中文名称作用描述使用场景导演比喻
1@Entry入口装饰器标记页面入口组件每个页面唯一入口开场镜头
2@Component组件装饰器声明自定义组件可复用UI模块演员角色卡
3@State状态装饰器响应式状态管理需要触发UI更新的变量场记板状态
4@Builder构建函数定义可复用UI片段提取重复UI结构预制分镜模板
5ForEach循环渲染根据数组生成列表数据列表展示群演走位调度
6if/else条件渲染条件控制UI结构场景切换、状态显示场景切换指令
7Scroll滚动容器内容超出时滚动长页面、横滑列表推轨镜头
8Column垂直布局子元素垂直排列纵向列表垂直构图
9Row水平布局子元素水平排列横向并排水平构图
10Text文本组件显示文字内容标题、正文、标签字幕与台词
11TextInput输入组件接收用户文本表单输入框演员即兴台词
12Divider分隔线组件视觉分隔元素列表项间分隔幕间休息标记
13bindSheet抽屉绑定底部滑出弹框表单类交互弹框侧幕滑出布景
14bindContentCover蒙层绑定居中覆盖弹框确认/详情类弹框聚焦特写镜头
15linearGradient线性渐变渐变背景色头部、卡片美化灯光渐变效果
16animation动画属性属性变化平滑过渡按钮选中、状态切换转场特效
17layoutWeight布局权重弹性分配空间按比例分配宽度构图比例分配
18constraintSize约束尺寸最大最小尺寸限制弹框高度限制画面安全框
19maxLines最大行数限制文字行数长文本截断台词长度限制
20textOverflow文字溢出溢出处理方式省略号、裁剪画面裁切方式

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 7 tabs单排(桌游吧风): 桌游/剧本/角色/开台/排行/社区/我的 · 5弹框: 预约开台(居中)/购买(抽屉)/编辑角色(抽屉)/删除战报(居中)/剧本详情(居中)
// 布局差异化:桌游Tab=横滑大卡+上手难度星级+桌游网格、剧本Tab=剧本列表(评分/时长/人数)+题材分布图、角色Tab=角色卡Grid+阵营分布、开台Tab=桌型选择+玩家数步进+时间预约、排行Tab=剧本榜/桌游榜双榜、社区Tab=战报流(点赞可交互)、我的Tab=组局记录+收藏
// 合规:无Blank、Button无文字、constraintSize、ModalTransition.SLIDE、interface全覆盖、UI区无变量声明、this.前缀

// ============ 配色 ============
interface ColorPalette115 {
  primary: string;
  primaryLight: string;
  primaryDark: string;
  orange: string;
  orangeLight: string;
  orangeBg: string;
  bg: string;
  card: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

const COLORS115: ColorPalette115 = {
  primary: '#4527A0',
  primaryLight: '#7E57C2',
  primaryDark: '#1A237E',
  orange: '#FF8F00',
  orangeLight: '#FFC107',
  orangeBg: '#FFF7E0',
  bg: '#F5F2FB',
  card: '#FFFFFF',
  textMain: '#2A1B4A',
  textSub: '#8B7BA6',
  textHint: '#BCB1D1',
  border: '#E8E0F5',
  success: '#66BB6A',
  warning: '#FFA726',
  danger: '#EF5350',
  white: '#FFFFFF'
};

// ============ 数据接口 ============
interface TabCfg115 {
  id: string;
  icon: string;
  label: string;
}

interface BoardGame115 {
  id: string;
  name: string;
  price: number;
  oldPrice: number;
  sales: string;
  players: string;
  time: string;
  difficulty: number;
  emoji: string;
  hot: boolean;
}

interface GenreBar115 {
  id: string;
  name: string;
  value: number;
  color: string;
}

interface Script115 {
  id: string;
  name: string;
  type: string;
  players: string;
  time: string;
  rating: number;
  price: number;
  sales: string;
  emoji: string;
  difficulty: string;
}

interface Role115 {
  id: string;
  name: string;
  camp: string;
  desc: string;
  emoji: string;
}

interface CampBar115 {
  id: string;
  name: string;
  value: number;
  color: string;
}

interface TableType115 {
  id: string;
  name: string;
  price: string;
  desc: string;
  emoji: string;
}

interface RankItem115 {
  id: string;
  name: string;
  score: number;
  emoji: string;
}

interface Report115 {
  id: string;
  name: string;
  author: string;
  likes: number;
  script: string;
  result: string;
  emoji: string;
}

interface Session115 {
  id: string;
  name: string;
  date: string;
  players: string;
  status: string;
  emoji: string;
}

interface Fav115 {
  id: string;
  name: string;
  price: number;
  emoji: string;
}

// ============ 静态数据 ============
const TABS115: TabCfg115[] = [
  { id: 'bg', icon: '🎲', label: '桌游' },
  { id: 'sc', icon: '📜', label: '剧本' },
  { id: 'ro', icon: '🎭', label: '角色' },
  { id: 'tb', icon: '🪑', label: '开台' },
  { id: 'rk', icon: '🏆', label: '排行' },
  { id: 'cm', icon: '💬', label: '社区' },
  { id: 'me', icon: '👤', label: '我的' }
];

const BOARD_GAMES115: BoardGame115[] = [
  { id: 'g1', name: '星际殖民 基础版', price: 199, oldPrice: 269, sales: '2.3w', players: '2-4人', time: '60-90min', difficulty: 3, emoji: '🚀', hot: true },
  { id: 'g2', name: '王国保卫战 中文版', price: 149, oldPrice: 199, sales: '4.8w', players: '2-5人', time: '45-60min', difficulty: 2, emoji: '🏰', hot: true },
  { id: 'g3', name: '推理大师 桌游版', price: 89, oldPrice: 119, sales: '6.6w', players: '3-8人', time: '30-45min', difficulty: 1, emoji: '🕵️', hot: true },
  { id: 'g4', name: '星球贸易 豪华版', price: 299, oldPrice: 399, sales: '9200', players: '3-6人', time: '90-120min', difficulty: 4, emoji: '🌍', hot: false },
  { id: 'g5', name: '魔法学院 对决', price: 129, oldPrice: 169, sales: '3.1w', players: '2人', time: '20-30min', difficulty: 2, emoji: '🧙', hot: false },
  { id: 'g6', name: '暗夜狼人 进阶包', price: 69, oldPrice: 89, sales: '8.2w', players: '8-16人', time: '20-40min', difficulty: 1, emoji: '🐺', hot: true },
  { id: 'g7', name: '碳素纪元 生态策略', price: 249, oldPrice: 329, sales: '5600', players: '2-4人', time: '100-140min', difficulty: 4, emoji: '🌿', hot: false },
  { id: 'g8', name: '疯狂叠叠乐 家庭版', price: 59, oldPrice: 79, sales: '12w', players: '2-8人', time: '15-20min', difficulty: 1, emoji: '🎯', hot: true },
  { id: 'g9', name: '海盗寻宝 骰子游戏', price: 79, oldPrice: 109, sales: '5.4w', players: '2-6人', time: '25-35min', difficulty: 1, emoji: '🏴‍☠️', hot: false },
  { id: 'g10', name: '未来都市 卡牌构筑', price: 219, oldPrice: 289, sales: '1.2w', players: '2-4人', time: '60-90min', difficulty: 3, emoji: '🏙️', hot: false },
  { id: 'g11', name: '龙与地下城 入门套', price: 349, oldPrice: 459, sales: '7800', players: '3-7人', time: '长线战役', difficulty: 5, emoji: '🐉', hot: true },
  { id: 'g12', name: '表情猜猜 聚会派对', price: 49, oldPrice: 69, sales: '15w', players: '4-10人', time: '15-25min', difficulty: 1, emoji: '😜', hot: true }
];

const GENRE_BARS115: GenreBar115[] = [
  { id: 'gb1', name: '推理本', value: 88, color: '#4527A0' },
  { id: 'gb2', name: '情感本', value: 76, color: '#FF8F00' },
  { id: 'gb3', name: '恐怖本', value: 64, color: '#8E0000' },
  { id: 'gb4', name: '机制本', value: 82, color: '#7E57C2' },
  { id: 'gb5', name: '欢乐本', value: 71, color: '#FFC107' }
];

const SCRIPTS115: Script115[] = [
  { id: 's1', name: '雾都孤儿院', type: '恐怖推理', players: '6人', time: '4-5h', rating: 9.2, price: 88, sales: '3.2w', emoji: '🏚️', difficulty: '进阶' },
  { id: 's2', name: '山海列车', type: '情感沉浸', players: '7人', time: '5h', rating: 9.5, price: 108, sales: '5.6w', emoji: '🚂', difficulty: '进阶' },
  { id: 's3', name: '迷雾侦探社', type: '硬核推理', players: '6人', time: '6h', rating: 9.0, price: 128, sales: '1.8w', emoji: '🕵️', difficulty: '硬核' },
  { id: 's4', name: '月圆之夜', type: '机制阵营', players: '8人', time: '4h', rating: 8.7, price: 98, sales: '2.9w', emoji: '🌕', difficulty: '新手' },
  { id: 's5', name: '欢乐大逃杀', type: '欢乐机制', players: '9人', time: '3h', rating: 8.5, price: 68, sales: '7.1w', emoji: '🏃', difficulty: '新手' },
  { id: 's6', name: '午夜画廊', type: '恐怖沉浸', players: '6人', time: '4.5h', rating: 8.9, price: 98, sales: '1.4w', emoji: '🖼️', difficulty: '进阶' },
  { id: 's7', name: '沙漠商队', type: '机制博弈', players: '7人', time: '5h', rating: 9.3, price: 118, sales: '9600', emoji: '🐪', difficulty: '进阶' },
  { id: 's8', name: '那年盛夏', type: '情感还原', players: '6人', time: '4h', rating: 9.1, price: 78, sales: '4.3w', emoji: '☀️', difficulty: '新手' }
];

const ROLES115: Role115[] = [
  { id: 'r1', name: '侦探长·柯岚', camp: '好人', desc: '逻辑担当 · 每轮可验一人', emoji: '🔍' },
  { id: 'r2', name: '女巫·莉莉', camp: '好人', desc: '拥有解药与毒药各一瓶', emoji: '🧪' },
  { id: 'r3', name: '预言家·墨白', camp: '好人', desc: '每晚可以查验一名玩家', emoji: '🔮' },
  { id: 'r4', name: '守卫·铁壁', camp: '好人', desc: '每晚守护一名玩家', emoji: '🛡️' },
  { id: 'r5', name: '狼王·影牙', camp: '狼人', desc: '出局时可带走一名玩家', emoji: '🐺' },
  { id: 'r6', name: '魅惑师·苏菲', camp: '狼人', desc: '可迷惑一名玩家身份', emoji: '💋' },
  { id: 'r7', name: '盗贼·快手', camp: '中立', desc: '开局可交换两张身份牌', emoji: '🎩' },
  { id: 'r8', name: '猎人·雷鸣', camp: '好人', desc: '出局时可开枪带走一人', emoji: '🔫' },
  { id: 'r9', name: '学徒·小满', camp: '中立', desc: '投票权可转让一次', emoji: '📖' },
  { id: 'r10', name: '白狼·霜华', camp: '狼人', desc: '每晚可单独猎杀一名玩家', emoji: '❄️' },
  { id: 'r11', name: '骑士·罗兰', camp: '好人', desc: '可在白天挑战一名玩家', emoji: '⚔️' },
  { id: 'r12', name: '酒鬼·阿豪', camp: '中立', desc: '技能随机 · 趣味担当', emoji: '🍺' }
];

const CAMP_BARS115: CampBar115[] = [
  { id: 'cb1', name: '好人阵营', value: 55, color: '#4527A0' },
  { id: 'cb2', name: '狼人阵营', value: 30, color: '#8E0000' },
  { id: 'cb3', name: '中立阵营', value: 15, color: '#FF8F00' }
];

const TABLE_TYPES115: TableType115[] = [
  { id: 't1', name: '标准桌', price: '¥38/人', desc: '含基础饮品', emoji: '🪑' },
  { id: 't2', name: '豪华包间', price: '¥68/人', desc: '含果盘+饮品', emoji: '🛋️' },
  { id: 't3', name: '沉浸主题房', price: '¥98/人', desc: '换装+场景道具', emoji: '🏰' },
  { id: 't4', name: '大通铺派对桌', price: '¥25/人', desc: '8-16人聚会', emoji: '🎉' }
];

const RANK_SCRIPTS115: RankItem115[] = [
  { id: 'rs1', name: '山海列车', score: 9.5, emoji: '🚂' },
  { id: 'rs2', name: '沙漠商队', score: 9.3, emoji: '🐪' },
  { id: 'rs3', name: '雾都孤儿院', score: 9.2, emoji: '🏚️' },
  { id: 'rs4', name: '那年盛夏', score: 9.1, emoji: '☀️' },
  { id: 'rs5', name: '迷雾侦探社', score: 9.0, emoji: '🕵️' },
  { id: 'rs6', name: '午夜画廊', score: 8.9, emoji: '🖼️' },
  { id: 'rs7', name: '月圆之夜', score: 8.7, emoji: '🌕' },
  { id: 'rs8', name: '欢乐大逃杀', score: 8.5, emoji: '🏃' }
];

const RANK_GAMES115: RankItem115[] = [
  { id: 'rg1', name: '疯狂叠叠乐', score: 9.6, emoji: '🎯' },
  { id: 'rg2', name: '表情猜猜', score: 9.4, emoji: '😜' },
  { id: 'rg3', name: '暗夜狼人', score: 9.3, emoji: '🐺' },
  { id: 'rg4', name: '推理大师', score: 9.2, emoji: '🕵️' },
  { id: 'rg5', name: '王国保卫战', score: 9.1, emoji: '🏰' },
  { id: 'rg6', name: '魔法学院', score: 8.9, emoji: '🧙' }
];

const REPORTS115: Report115[] = [
  { id: 're1', name: '山海列车 全车哭了', author: '情感本玩家', likes: 8600, script: '山海列车', result: '胜利', emoji: '🚂' },
  { id: 're2', name: '硬核推土机5小时通关迷雾', author: '推理狂人', likes: 5200, script: '迷雾侦探社', result: '首车MVP', emoji: '🕵️' },
  { id: 're3', name: '欢乐本天花板名场面', author: '聚会常客', likes: 12800, script: '欢乐大逃杀', result: '欢乐局', emoji: '🏃' },
  { id: 're4', name: '新本《沙漠商队》测评', author: '剧本测评君', likes: 3900, script: '沙漠商队', result: '推荐', emoji: '🐪' },
  { id: 're5', name: '狼人杀12人局复盘', author: '狼人杀导师', likes: 6700, script: '暗夜狼人', result: '狼人胜利', emoji: '🐺' },
  { id: 're6', name: '沉浸主题房换装体验', author: '剧本爱好者', likes: 7400, script: '月圆之夜', result: '阵营胜', emoji: '🏰' }
];

const SESSIONS115: Session115[] = [
  { id: 'se1', name: '山海列车 · 拼车局', date: '08-30 19:00', players: '7/7', status: '已满员', emoji: '🚂' },
  { id: 'se2', name: '欢乐大逃杀 · 公司团建', date: '08-31 14:00', players: '6/9', status: '组局中', emoji: '🏃' },
  { id: 'se3', name: '狼人杀 · 12人标准局', date: '09-01 20:00', players: '9/12', status: '组局中', emoji: '🐺' },
  { id: 'se4', name: '那年盛夏 · 情感专场', date: '09-02 18:30', players: '6/6', status: '已满员', emoji: '☀️' }
];

const FAVS115: Fav115[] = [
  { id: 'f1', name: '星际殖民 基础版', price: 199, emoji: '🚀' },
  { id: 'f2', name: '龙与地下城 入门套', price: 349, emoji: '🐉' },
  { id: 'f3', name: '沙漠商队 剧本', price: 118, emoji: '🐪' },
  { id: 'f4', name: '暗夜狼人 进阶包', price: 69, emoji: '🐺' },
  { id: 'f5', name: '碳素纪元 生态策略', price: 249, emoji: '🌿' },
  { id: 'f6', name: '未来都市 卡牌构筑', price: 219, emoji: '🏙️' }
];

// ============ 工具函数 ============
function diffStars115(n: number): string {
  if (n <= 1) { return '★☆☆☆☆ 简单'; }
  if (n === 2) { return '★★☆☆☆ 入门'; }
  if (n === 3) { return '★★★☆☆ 进阶'; }
  if (n === 4) { return '★★★★☆ 困难'; }
  return '★★★★★ 硬核';
}

function barW115(v: number): string {
  return Math.floor(v).toString() + '%';
}

function likeFmt115(v: number): string {
  if (v >= 10000) {
    return (v / 10000).toFixed(1) + 'w';
  }
  return v.toString();
}

function rankColor115(i: number): string {
  if (i === 0) { return COLORS115.orange; }
  if (i === 1) { return COLORS115.textSub; }
  if (i === 2) { return COLORS115.primaryLight; }
  return COLORS115.textHint;
}

function campColor115(c: string): string {
  if (c === '好人') { return COLORS115.primary; }
  if (c === '狼人') { return COLORS115.danger; }
  return COLORS115.orange;
}

// ============ 主页面 ============
@Entry
@Component
struct DuoDuoBoardApp {
  @State currentTab: number = 0;
  @State showBookDialog: boolean = false;
  @State showBuySheet: boolean = false;
  @State showEditSheet: boolean = false;
  @State showDeleteDialog: boolean = false;
  @State showDetailDialog: boolean = false;
  @State selectedItem: Script115 | null = null;
  // 预约开台
  @State selTable: number = 0;
  @State playerCount: number = 6;
  @State selRoom: boolean = true;
  // 购买
  @State selQty: number = 1;
  @State selVersion: number = 0;
  @State selExpand: boolean = false;
  @State selMembership: boolean = true;
  // 编辑角色
  @State roleName: string = '';
  @State selCamp: number = 0;
  @State selPublic: boolean = true;
  // 删除战报
  @State selClearComments: boolean = false;

  build() {
    Column() {
      // ===== 桌游吧头部(桌游俱乐部参考样式) =====
      Row() {
        Column() {
          Row() {
            Text('多多桌游')
              .fontSize(18)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.white)
            Text('GAME')
              .fontSize(9)
              .fontColor(COLORS115.orangeLight)
              .border({ width: 1, color: COLORS115.orangeLight })
              .borderRadius(3)
              .padding({ left: 4, right: 4, top: 1, bottom: 1 })
              .margin({ left: 6 })
          }
          Text('🎲 桌游剧本 · 组局开台 · 角色扮演')
            .fontSize(9)
            .fontColor('rgba(255,255,255,0.8)')
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Text('🔍')
          .fontSize(19)
          .fontColor(COLORS115.white)
          .margin({ right: 12 })
        Text('🛒')
          .fontSize(19)
          .fontColor(COLORS115.white)
      }
      .width('100%')
      .height(54)
      .padding({ left: 16, right: 16 })
      .linearGradient({ angle: 120, colors: [[COLORS115.primaryDark, 0], [COLORS115.primary, 0.7], [COLORS115.primaryLight, 1]] })

      Row() {
        Text('🎫')
          .fontSize(11)
        Text('新客首单立减 · 组局拼车中 · 剧本上新《沙漠商队》')
          .fontSize(10)
          .fontColor(COLORS115.primaryDark)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ left: 4 })
        Text('').layoutWeight(1)
        Text('拼车 ›')
          .fontSize(10)
          .fontColor(COLORS115.orange)
      }
      .width('100%')
      .height(26)
      .padding({ left: 12, right: 12 })
      .backgroundColor(COLORS115.orangeBg)

      // ===== Tab 内容区 =====
      if (this.currentTab === 0) {
        BoardGameTab115({
          onBook: () => { this.showBookDialog = true; }
        })
      } else if (this.currentTab === 1) {
        ScriptTab115({
          onDetail: (s: Script115) => {
            this.selectedItem = s;
            this.showDetailDialog = true;
          },
          onBuy: (s: Script115) => { this.showBuySheet = true; }
        })
      } else if (this.currentTab === 2) {
        RoleTab115({
          onEdit: () => { this.showEditSheet = true; }
        })
      } else if (this.currentTab === 3) {
        TableTab115({
          onBook: () => { this.showBookDialog = true; }
        })
      } else if (this.currentTab === 4) {
        RankTab115()
      } else if (this.currentTab === 5) {
        CommunityTab115({
          onDelete: () => { this.showDeleteDialog = true; }
        })
      } else {
        MineTab115()
      }

      // ===== 底部7 Tab(徽章圆角单排) =====
      Row() {
        TabBtn115({icon:'🎲', label:'桌游', active: this.currentTab === 0, onTap: () => { this.currentTab = 0; }})
        TabBtn115({icon:'📜', label:'剧本', active: this.currentTab === 0, onTap: () => { this.currentTab = 1; }})
        TabBtn115({icon:'🎭', label:'角色', active: this.currentTab === 0, onTap: () => { this.currentTab = 2; }})
        TabBtn115({icon:'🪑', label:'开台', active: this.currentTab === 0, onTap: () => { this.currentTab = 3; }})
        TabBtn115({icon:'🏆', label:'排行', active: this.currentTab === 0, onTap: () => { this.currentTab = 4; }})
        TabBtn115({icon:'💬', label:'社区', active: this.currentTab === 0, onTap: () => { this.currentTab = 5; }})
        TabBtn115({icon:'👤', label:'我的', active: this.currentTab === 0, onTap: () => { this.currentTab = 6; }})

      }
      .width('100%')
      .height(50)
      .padding({ left: 2, right: 2, top: 3, bottom: 3 })
      .backgroundColor(COLORS115.card)
      .border({ width: 1, color: COLORS115.border })
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS115.bg)
    .bindContentCover($$this.showBookDialog, () => {
    }, {
    })
    .bindSheet($$this.showBuySheet, () => {
    }, {
      height: 540,
      dragBar: true,
      showClose: true
    })
    .bindSheet($$this.showEditSheet, () => {
    }, {
      height: 500,
      dragBar: true,
      showClose: true
    })
    .bindContentCover($$this.showDeleteDialog, () => {
    }, {
    })
    .bindContentCover($$this.showDetailDialog, () => {
    }, {
    })
  }
}

// ============ 底部Tab(圆角徽章风) ============
@Component
struct TabBtn115 {
  icon: string = '🎲';
  label: string = '';
  active: boolean = false;
  onTap: () => void = () => {};
  build() {
    Column() {
      Text(this.icon)
        .fontSize(16)
        .fontColor(this.active ? COLORS115.white : COLORS115.textSub)
      Text(this.label)
        .fontSize(9)
        .fontWeight(this.active ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.active ? COLORS115.orangeLight : COLORS115.textSub)
        .margin({ top: 1 })
    }
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .height(40)
    .borderRadius(10)
    .backgroundColor(this.active ? COLORS115.primary : COLORS115.card)
    .border({ width: 1, color: this.active ? COLORS115.primary : COLORS115.border })
    .animation({ duration: 200 })
    .onClick(() => { this.onTap(); })
  }
}

// ============ 桌游Tab ============
@Component
struct BoardGameTab115 {
  onBook: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 横滑大卡
        Row() {
          Text('🎲 本周热门桌游')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('横向滑动 ›')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Scroll() {
          Row() {
            Row() {
              Column() {
                Text('龙与地下城')
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.white)
                Text('入门套 · 长线战役 · 新手友好')
                  .fontSize(9)
                  .fontColor('rgba(255,255,255,0.8)')
                  .margin({ top: 4 })
                Text('¥349 ›')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.orangeLight)
                  .margin({ top: 6 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('🐉')
                .fontSize(36)
            }
            .width(240)
            .height(82)
            .padding(12)
            .borderRadius(12)
            .linearGradient({ angle: 90, colors: [['#1A237E', 0], ['#4527A0', 1]] })
            .margin({ right: 10 })
            Row() {
              Column() {
                Text('推理大师')
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.white)
                Text('3-8人 · 30分钟快速开局')
                  .fontSize(9)
                  .fontColor('rgba(255,255,255,0.8)')
                  .margin({ top: 4 })
                Text('¥89 ›')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.orangeLight)
                  .margin({ top: 6 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('🕵️')
                .fontSize(36)
            }
            .width(240)
            .height(82)
            .padding(12)
            .borderRadius(12)
            .linearGradient({ angle: 90, colors: [['#B26A00', 0], ['#FF8F00', 1]] })
            .margin({ right: 10 })
            Row() {
              Column() {
                Text('星际殖民')
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.white)
                Text('策略神作 · 收藏级配件')
                  .fontSize(9)
                  .fontColor('rgba(255,255,255,0.8)')
                  .margin({ top: 4 })
                Text('¥199 ›')
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.orangeLight)
                  .margin({ top: 6 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              Text('🚀')
                .fontSize(36)
            }
            .width(240)
            .height(82)
            .padding(12)
            .borderRadius(12)
            .linearGradient({ angle: 90, colors: [['#004D40', 0], ['#26A69A', 1]] })
          }
          .padding({ left: 12, right: 12 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .height(96)
        .margin({ top: 8 })

        // 开台横幅
        Row() {
          Column() {
            Text('🪑 周末组局 · 拼车开台中')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.primaryDark)
            Text('豪华包间 · 沉浸主题房 · 新人免费教学')
              .fontSize(9)
              .fontColor(COLORS115.textSub)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('立即开台 ›')
            .fontSize(10)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.white)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(COLORS115.orange)
            .borderRadius(10)
            .onClick(() => { this.onBook(); })
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS115.orangeBg)
        .borderRadius(12)
        .margin({ left: 12, right: 12, top: 12 })

        // 桌游网格
        Row() {
          Text('🎮 精选桌游')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('全部 ›')
            .fontSize(10)
            .fontColor(COLORS115.primary)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        Column() {
          ForEach(BOARD_GAMES115, (g: BoardGame115) => {
            Column() {
              Column() {
                Text(g.emoji)
                  .fontSize(32)
                if (g.hot) {
                  Text('🔥 热卖')
                    .fontSize(8)
                    .fontColor(COLORS115.white)
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .backgroundColor(COLORS115.danger)
                    .borderRadius(4)
                    .margin({ top: 3 })
                }
              }
              .width('100%')
              .height(84)
              .justifyContent(FlexAlign.Center)
              .backgroundColor(COLORS115.bg)
              .borderRadius(10)
              Text(g.name)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
                .margin({ top: 6 })
              Text(g.players + ' · ' + g.time)
                .fontSize(8)
                .fontColor(COLORS115.textSub)
                .width('100%')
                .margin({ top: 2 })
              Text(diffStars115(g.difficulty))
                .fontSize(8)
                .fontColor(COLORS115.orange)
                .width('100%')
                .margin({ top: 2 })
              Row() {
                Text('¥' + g.price.toString())
                  .fontSize(15)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.primary)
                Text('¥' + g.oldPrice.toString())
                  .fontSize(9)
                  .fontColor(COLORS115.textHint)
                  .decoration({ type: TextDecorationType.LineThrough })
                  .margin({ left: 4 })
              }
              .width('100%')
              .margin({ top: 3 })
              Text('已售' + g.sales)
                .fontSize(8)
                .fontColor(COLORS115.textHint)
                .width('100%')
                .margin({ top: 2 })
            }
            .padding(10)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ bottom: 10 })
          }, (g: BoardGame115) => g.id)
        }
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 剧本Tab ============
@Component
struct ScriptTab115 {
  onDetail: (s: Script115) => void = () => {};
  onBuy: (s: Script115) => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 题材分布图
        Row() {
          Text('📊 剧本题材热度')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('本月')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(GENRE_BARS115, (b: GenreBar115) => {
            Row() {
              Text(b.name)
                .fontSize(10)
                .fontColor(COLORS115.textMain)
                .width(52)
              Column() {
                Row() {
                  Column()
                    .width(barW115(b.value))
                    .height(9)
                    .borderRadius(4)
                    .backgroundColor(b.color)
                }
                .width('100%')
                .height(9)
                .borderRadius(4)
                .backgroundColor(COLORS115.border)
              }
              .layoutWeight(1)
              .margin({ left: 6, right: 6 })
              Text(b.value.toString() + '%')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(b.color)
                .width(34)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (b: GenreBar115) => b.id)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 8 })

        // 剧本列表
        Row() {
          Text('📜 热门剧本')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('共' + SCRIPTS115.length + '本')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        ForEach(SCRIPTS115, (s: Script115) => {
          Row() {
            Text(s.emoji)
              .fontSize(30)
              .width(50)
              .height(50)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS115.bg)
              .borderRadius(12)
            Column() {
              Row() {
                Text(s.name)
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.textMain)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(s.rating.toString())
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.orange)
                  .padding({ left: 4, right: 4, top: 1, bottom: 1 })
                  .backgroundColor(COLORS115.orangeBg)
                  .borderRadius(4)
                  .margin({ left: 5 })
              }
              .width('100%')
              Row() {
                Text(s.type)
                  .fontSize(8)
                  .fontColor(COLORS115.textSub)
                  .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                  .border({ width: 1, color: COLORS115.border })
                  .borderRadius(3)
                Text(' ' + s.players + '人')
                  .fontSize(8)
                  .fontColor(COLORS115.primary)
                Text(' ' + s.time)
                  .fontSize(8)
                  .fontColor(COLORS115.textSub)
                Text(' ' + s.difficulty)
                  .fontSize(8)
                  .fontColor(COLORS115.danger)
              }
              .width('100%')
              .margin({ top: 4 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
            Column() {
              Text('¥' + s.price.toString())
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.primary)
              Text('详情')
                .fontSize(9)
                .fontColor(COLORS115.primary)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .border({ width: 1, color: COLORS115.primary })
                .borderRadius(6)
                .margin({ top: 4 })
                .onClick(() => { this.onDetail(s); })
              Text('购买')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.white)
                .padding({ left: 10, right: 10, top: 2, bottom: 2 })
                .backgroundColor(COLORS115.primary)
                .borderRadius(6)
                .margin({ top: 5 })
                .onClick(() => { this.onBuy(s); })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS115.card)
          .borderRadius(12)
          .border({ width: 1, color: COLORS115.border })
          .margin({ left: 12, right: 12, top: 8 })
        }, (s: Script115) => s.id)
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 角色Tab ============
@Component
struct RoleTab115 {
  onEdit: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 阵营分布
        Row() {
          Text('⚖️ 阵营分布')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('当前卡组12人')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(CAMP_BARS115, (b: CampBar115) => {
            Row() {
              Text(b.name)
                .fontSize(10)
                .fontColor(COLORS115.textMain)
                .width(64)
              Column() {
                Row() {
                  Column()
                    .width(barW115(b.value))
                    .height(10)
                    .borderRadius(5)
                    .backgroundColor(b.color)
                }
                .width('100%')
                .height(10)
                .borderRadius(5)
                .backgroundColor(COLORS115.border)
              }
              .layoutWeight(1)
              .margin({ left: 6, right: 6 })
              Text(b.value.toString() + '%')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(b.color)
                .width(34)
            }
            .width('100%')
            .margin({ top: 8 })
          }, (b: CampBar115) => b.id)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 8 })

        // 角色卡 Grid
        Row() {
          Text('🎭 角色卡组')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('✏️ 自定义 ›')
            .fontSize(10)
            .fontColor(COLORS115.primary)
            .onClick(() => { this.onEdit(); })
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        Column() {
          ForEach(ROLES115, (r: Role115) => {
            Column() {
              Text(r.emoji)
                .fontSize(28)
                .textAlign(TextAlign.Center)
                .width('100%')
                .height(58)
                .padding({ top: 10 })
                .backgroundColor(COLORS115.bg)
                .borderRadius(10)
              Text(r.name)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
                .margin({ top: 4 })
              Text(r.camp + ' · ' + r.desc)
                .fontSize(8)
                .fontColor(campColor115(r.camp))
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
                .margin({ top: 2 })
              Text(r.desc)
                .fontSize(8)
                .fontColor(COLORS115.textSub)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
                .margin({ top: 1 })
            }
            .padding(8)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ bottom: 8 })
            .onClick(() => { this.onEdit(); })
          }, (r: Role115) => r.id)
        }
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 开台Tab ============
@Component
struct TableTab115 {
  onBook: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 桌型选择
        Row() {
          Text('🪑 选择桌型')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('支持到店/预约')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(TABLE_TYPES115, (t: TableType115) => {
            Column() {
              Text(t.emoji)
                .fontSize(26)
              Text(t.name)
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .margin({ top: 4 })
              Text(t.desc)
                .fontSize(8)
                .fontColor(COLORS115.textSub)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .margin({ top: 2 })
              Text(t.price)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.orange)
                .margin({ top: 4 })
            }
            .padding(10)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ bottom: 10 })
            .onClick(() => { this.onBook(); })
          }, (t: TableType115) => t.id)
        }

        // 拼车列表
        Row() {
          Text('🧩 正在拼车')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('发起拼车 ›')
            .fontSize(10)
            .fontColor(COLORS115.orange)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        Column() {
          ForEach(SESSIONS115, (s: Session115) => {
            Row() {
              Text(s.emoji)
                .fontSize(26)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS115.bg)
                .borderRadius(10)
              Column() {
                Text(s.name)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.textMain)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(s.date + ' · ' + s.players + '人')
                  .fontSize(9)
                  .fontColor(COLORS115.textSub)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text(s.status === '已满员' ? '已满' : '加入')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(s.status === '已满员' ? COLORS115.textHint : COLORS115.white)
                .padding({ left: 12, right: 12, top: 4, bottom: 4 })
                .backgroundColor(s.status === '已满员' ? COLORS115.border : COLORS115.orange)
                .borderRadius(8)
            }
            .width('100%')
            .padding(10)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ left: 12, right: 12, top: 8 })
          }, (s: Session115) => s.id)
        }
        .width('100%')
        .padding({ bottom: 16 })
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 排行Tab ============
@Component
struct RankTab115 {
  build() {
    Scroll() {
      Column() {
        // 剧本榜
        Row() {
          Text('📜 剧本口碑榜')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('周榜')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 12 })
        Column() {
          ForEach(RANK_SCRIPTS115, (r: RankItem115, i: number) => {
            Row() {
              Text((i + 1).toString())
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(rankColor115(i))
                .width(24)
              Text(r.emoji)
                .fontSize(18)
                .width(28)
              Text(r.name)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .layoutWeight(1)
              Column() {
                Row() {
                  Column()
                    .width(barW115(r.score * 10))
                    .height(8)
                    .borderRadius(4)
                    .backgroundColor(i < 3 ? COLORS115.orange : COLORS115.primaryLight)
                }
                .width(80)
                .height(8)
                .borderRadius(4)
                .backgroundColor(COLORS115.border)
              }
              .margin({ right: 8 })
              Text(r.score.toString())
                .fontSize(10)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.orange)
                .width(30)
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }, (r: RankItem115) => r.id)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 4 })
        .backgroundColor(COLORS115.card)
        .borderRadius(12)
        .border({ width: 1, color: COLORS115.border })
        .margin({ left: 12, right: 12, top: 8 })

        // 桌游榜
        Row() {
          Text('🎲 桌游畅销榜')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('月榜')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        Column() {
          ForEach(RANK_GAMES115, (r: RankItem115, i: number) => {
            Row() {
              Text((i + 1).toString())
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(rankColor115(i))
                .width(24)
              Text(r.emoji)
                .fontSize(18)
                .width(28)
              Text(r.name)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .layoutWeight(1)
              Text('评分 ' + r.score.toString())
                .fontSize(9)
                .fontColor(COLORS115.textSub)
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
            .border({ width: 0 })
          }, (r: RankItem115) => r.id)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 4 })
        .backgroundColor(COLORS115.card)
        .borderRadius(12)
        .border({ width: 1, color: COLORS115.border })
        .margin({ left: 12, right: 12, top: 8 })
        .padding({ bottom: 4 })
        .margin({ bottom: 16 })
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 社区Tab ============
@Component
struct CommunityTab115 {
  onDelete: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        // 话题横条
        Scroll() {
          Row() {
            Text('💬 今日话题')
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.primaryDark)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS115.orangeBg)
              .borderRadius(10)
            Text('#剧本测评')
              .fontSize(10)
              .fontColor(COLORS115.textSub)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS115.card)
              .border({ width: 1, color: COLORS115.border })
              .borderRadius(10)
              .margin({ left: 8 })
            Text('#拼车开台')
              .fontSize(10)
              .fontColor(COLORS115.textSub)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS115.card)
              .border({ width: 1, color: COLORS115.border })
              .borderRadius(10)
              .margin({ left: 8 })
            Text('#桌游新手村')
              .fontSize(10)
              .fontColor(COLORS115.textSub)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .backgroundColor(COLORS115.card)
              .border({ width: 1, color: COLORS115.border })
              .borderRadius(10)
              .margin({ left: 8 })
          }
          .padding({ left: 12, right: 12 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .height(42)
        .margin({ top: 12 })

        // 战报流
        ForEach(REPORTS115, (r: Report115, i: number) => {
          Row() {
            Text(r.emoji)
              .fontSize(34)
              .width(56)
              .height(56)
              .textAlign(TextAlign.Center)
              .backgroundColor(i % 2 === 0 ? COLORS115.bg : COLORS115.orangeBg)
              .borderRadius(12)
            Column() {
              Text(r.name)
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS115.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Row() {
                Text('剧本《' + r.script + '》')
                  .fontSize(9)
                  .fontColor(COLORS115.primary)
                Text(' · ')
                  .fontSize(9)
                  .fontColor(COLORS115.textHint)
                Text(r.result)
                  .fontSize(9)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.orange)
                Text('').layoutWeight(1)
                Text('❤️ ' + likeFmt115(r.likes))
                  .fontSize(10)
                  .fontColor(COLORS115.danger)
              }
              .width('100%')
              .margin({ top: 4 })
              Row() {
                Text('@' + r.author)
                  .fontSize(9)
                  .fontColor(COLORS115.textSub)
                Text('').layoutWeight(1)
                Text('🗑️ 删除')
                  .fontSize(9)
                  .fontColor(COLORS115.danger)
                  .onClick(() => { this.onDelete(); })
              }
              .width('100%')
              .margin({ top: 5 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 10 })
          }
          .width('100%')
          .padding(10)
          .backgroundColor(COLORS115.card)
          .borderRadius(12)
          .border({ width: 1, color: COLORS115.border })
          .margin({ left: 12, right: 12, top: 8 })
        }, (r: Report115) => r.id)
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 我的Tab ============
@Component
struct MineTab115 {
  build() {
    Scroll() {
      Column() {
        // 玩家头卡
        Row() {
          Column() {
            Text('🎲 桌游玩家 · 阿哲')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.white)
            Text('游戏场次 156 · 胜率 58% · 解锁成就 23')
              .fontSize(9)
              .fontColor('rgba(255,255,255,0.8)')
              .margin({ top: 4 })
            Row() {
              Text('🏅 剧本杀大师')
                .fontSize(9)
                .fontColor(COLORS115.primaryDark)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor(COLORS115.orangeLight)
                .borderRadius(6)
              Text('🎖️ 组局达人')
                .fontSize(9)
                .fontColor(COLORS115.white)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .backgroundColor(COLORS115.primaryLight)
                .borderRadius(6)
                .margin({ left: 6 })
            }
            .margin({ top: 8 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('🧙')
            .fontSize(38)
        }
        .width('100%')
        .height(100)
        .padding(14)
        .borderRadius(14)
        .linearGradient({ angle: 90, colors: [['#1A237E', 0], ['#4527A0', 1]] })
        .margin({ left: 12, right: 12, top: 12 })

        // 组局记录
        Row() {
          Text('🗓️ 我的组局')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('全部 ›')
            .fontSize(10)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        Column() {
          ForEach(SESSIONS115, (s: Session115) => {
            Row() {
              Text(s.emoji)
                .fontSize(26)
                .width(42)
                .height(42)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS115.bg)
                .borderRadius(10)
              Column() {
                Text(s.name)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.textMain)
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(s.date)
                  .fontSize(9)
                  .fontColor(COLORS115.textSub)
                  .margin({ top: 3 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text(s.status)
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(s.status === '已满员' ? COLORS115.success : COLORS115.orange)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                .border({ width: 1, color: s.status === '已满员' ? COLORS115.success : COLORS115.orange })
                .borderRadius(6)
            }
            .width('100%')
            .padding(10)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ left: 12, right: 12, top: 8 })
          }, (s: Session115) => s.id)
        }
        .width('100%')

        // 收藏
        Row() {
          Text('⭐ 我的收藏')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS115.textMain)
          Text('').layoutWeight(1)
          Text('共' + FAVS115.length + '件')
            .fontSize(9)
            .fontColor(COLORS115.textHint)
        }
        .width('100%')
        .padding({ left: 12, right: 12, top: 14 })
        Column() {
          ForEach(FAVS115, (f: Fav115) => {
            Column() {
              Text(f.emoji)
                .fontSize(28)
                .textAlign(TextAlign.Center)
                .width('100%')
                .height(58)
                .padding({ top: 10 })
                .backgroundColor(COLORS115.bg)
                .borderRadius(10)
              Text(f.name)
                .fontSize(9)
                .fontColor(COLORS115.textMain)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
                .margin({ top: 4 })
              Row() {
                Text('¥' + f.price.toString())
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS115.primary)
                Text('').layoutWeight(1)
                Text('🔍')
                  .fontSize(12)
              }
              .width('100%')
              .margin({ top: 3 })
            }
            .padding(8)
            .backgroundColor(COLORS115.card)
            .borderRadius(12)
            .border({ width: 1, color: COLORS115.border })
            .margin({ bottom: 8 })
          }, (f: Fav115) => f.id)
        }
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
  }
}

// ============ 预约开台弹框(居中) ============
@Component
struct BookTableDialog115 {
  selTable: number = 0;
  playerCount: number = 6;
  selRoom: boolean = true;
  onTable: (t: number) => void = () => {};
  onCount: (c: number) => void = () => {};
  onRoom: (r: boolean) => void = () => {};
  onConfirm: () => void = () => {};
  onCancel: () => void = () => {};
  build() {
    Scroll() {
      Column() {
        Row() {
          Column() {
            Text('🪑 预约开台')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS115.textMain)
            Text('周六 19:00 · 剩余桌位 4')
              .fontSize(9)
              .fontColor(COLORS115.textSub)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Start)
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor(COLORS115.textHint)
            .onClick(() => { this.onCancel(); })
        }
        .width('100%')

        // 桌型
        Text('选择桌型')

  }
}


总结:

在这里插入图片描述

第一,从叙事结构的视角来看,该桌游剧本社区应用采用了"七幕主叙事+五段插叙"的复合叙事结构。七幕主叙事分别对应桌游展厅、剧本图书馆、角色化妆间、开台前台、荣誉殿堂、社区广场和个人后台七个场景,每幕场景都是一条独立的叙事线,通过底部七Tab徽章导航实现场景间的切换。五段插叙分别是预约开台、购买剧本、编辑角色、删除战报和剧本详情五个弹框,它们在主叙事推进过程中适时插入,为当前场景提供更深入的交互能力。这种"主线+插叙"的叙事结构在移动应用设计中极为常见,它确保了主叙事的流畅性(一屏一场景,快速切换),同时通过弹框插叙承载了需要更多交互空间的操作流程。

第二,从视觉语言的视角来看,该应用建立了深紫与橙的双色视觉体系。深紫色(#4527A0及其变体)作为主色调,出现在头部顶栏渐变、按钮选中态、价格文字、弹框头部等关键位置,承载着品牌识别和重点强调的功能。橙色(#FF8F00及其变体)作为辅助色,出现在"拼车"标签、"开台"按钮、评分徽章、成就标签等社交互动元素上,传递温暖与活力的情绪信号。两种色调的交替使用如同电影中的冷暖色调切换——深紫承载悬疑推理的冷调氛围,橙色传递社交欢乐的暖调温度。此外,COLORS115常量对象的集中管理确保了全片色彩的一致性和可维护性。

第三,从演员调度的视角来看,该应用的组件架构体现了清晰的层级分工。@Entry DuoDuoBoardApp作为总导演,统管十八个@State状态变量和七幕场景的切换调度。七个Tab组件(BoardGameTab115ScriptTab115RoleTab115TableTab115RankTab115CommunityTab115MineTab115)作为各幕场景的主演,通过回调函数与总导演保持通讯。五个弹框组件(BookTableDialog115BuySheet115EditRoleSheet115DeleteReportDialog115ScriptDetailDialog115)作为插叙演员,在需要时通过bindContentCoverbindSheet绑定入场。TabBtn115作为群演(底部导航按钮),通过参数接收iconlabelactiveonTap四个属性,实现了可复用的按钮组件。这种分层调度模式确保了每个组件的职责单一、接口清晰。

第四,从技术实现的视角来看,该应用充分运用了ArkTS的声明式UI特性。ForEach循环渲染处理了所有列表场景(桌游列表、剧本列表、角色列表、桌型列表、拼车列表、排行列表、战报流等),且每个ForEach都提供了键值生成函数(如(g: BoardGame115) => g.id)以优化渲染性能。if/else条件渲染处理了场景切换和条件显示(热卖标签、已满状态等)。Scroll组件的嵌套使用(外层垂直、内层水平)实现了横滑大卡+纵向列表的复合布局。linearGradient渐变背景和.animation()动画属性为UI增添了视觉层次和交互反馈。可选链操作符?.和空值合并操作符??确保了可空类型的安全访问。

第五,从制片管理的视角来看,该应用的工程规范体现了ArkTS开发的最佳实践。所有工具函数(diffStars115barW115likeFmt115rankColor115campColor115)都抽取到了组件外部,符合"UI构建函数内不声明变量"的编译约束。所有事件回调使用箭头函数确保this绑定正确。所有颜色值通过COLORS115常量对象集中管理,避免散落的魔法数字。所有组件成员变量都提供了默认值,确保在父组件未传参时不会崩溃。ForEach的键值函数全部使用数据的唯一标识字段(g.ids.idr.id等),确保了列表渲染的正确性和性能。这些工程规范确保了代码的可读性、可维护性和运行时稳定性。

Logo

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

更多推荐