引言:从社交场景看鸿蒙生态的UI表达力

在移动应用开发领域,社区类应用始终是检验UI框架表达能力的试金石。一个优秀的社区应用不仅需要流畅的列表渲染、丰富的交互动效,还需要在有限的屏幕空间内呈现多样化的内容形态。本文将以一个剧本杀探本社区应用为研究对象,深入剖析基于HarmonyOS API 24构建的全功能社交应用的技术实现细节。该应用采用小红书风格的视觉设计,融合了票券式卡片、分段面板、动态筛选、进度条拼车等多种复杂UI模式。

HarmonyOS 6.1.1作为鸿蒙操作系统的重要版本迭代,在ArkUI声明式开发范式上持续演进,为开发者提供了更加灵活高效的UI构建能力。而HarmonyOS ArkTS API 24则带来了一系列重要的API增强,包括更精细的状态管理、更丰富的组件库支持以及更优的渲染性能。本文所分析的应用正是建立在这些技术基石之上,通过单一文件完整实现了六大内容板块与四种弹窗交互,充分展示了ArkTS语言的表达力与ArkUI框架的灵活性。

从技术选型的角度来看,该应用采用了一种高度内聚的单文件架构模式——将数据模型、业务逻辑、UI组件全部集成在一个ETS文件中。这种模式在原型验证阶段和中小型应用场景下具有显著优势:代码结构清晰、依赖关系直观、调试效率高。同时,这种架构也对开发者的代码组织能力提出了更高要求,需要合理划分功能区块、设计可复用的@Builder方法、以及维护一致的状态管理策略。

本文将从颜色配置系统、数据模型设计、纯函数工具层、入口组件架构、头部导航组件、票券式卡片实现、剧本库网格布局、组局拼车卡片、测评卡片设计、店铺列表组件、榜单柱状图、弹窗交互系统、底部导航栏等十余个维度进行逐段代码分析,力求为读者呈现一个完整而深入的ArkTS开发技术全景。无论你是鸿蒙开发的初学者还是有一定经验的从业者,相信都能从中获得有价值的技术参考和设计灵感。


一、颜色配置系统:Interface与const的配套设计

1.1 颜色调色板接口定义

应用的颜色系统采用了TypeScript风格的接口定义与常量实现相结合的模式。首先定义了ColorPalette接口,用于规范颜色配置的结构:

interface ColorPalette {
  primary: string
  bg: string
  card: string
  cardDeep: string
  textPrimary: string
  textSecondary: string
  textMuted: string
  border: string
  accent: string
  accentDim: string
  danger: string
  green: string
  blue: string
  pink: string
  orange: string
}

在这里插入图片描述

这段接口定义体现了典型的"语义化颜色"设计思想——每个颜色字段都具有明确的语义含义,而非直接使用颜色名称。这种设计的优势在于:当需要更换主题色时,只需修改常量值而无需改动所有引用位置,极大地提升了主题切换的可维护性。

在字段命名上,开发者采用了分层递进的策略:primary代表品牌主色,bg是背景色,cardcardDeep构成了卡片的深浅两级,文字颜色分为textPrimary(主文本)、textSecondary(次级文本)、textMuted(弱化文本)三个层级,accentaccentDim构成强调色的明暗对。此外,还定义了dangergreenbluepinkorange等功能色,用于表达不同的业务语义和情感色彩。

1.2 颜色常量实现与色彩体系分析

const COLORS: ColorPalette = {
  primary: '#8B2635',
  bg: '#141021',
  card: '#221A3E',
  cardDeep: '#1A1430',
  textPrimary: '#EDE7F6',
  textSecondary: '#9C8FB8',
  textMuted: '#6B5E8A',
  border: '#3A2E5C',
  accent: '#D4AF37',
  accentDim: '#8A7326',
  danger: '#E14B5A',
  green: '#4CAF7D',
  blue: '#5B7BD5',
  pink: '#D56BA5',
  orange: '#E0913F'
}

从色彩体系来看,该应用采用了深紫色调的暗色主题设计,主色调为酒红色(#8B2635),背景为深紫色(#141021),强调色为金色(#D4AF37),形成了"暗紫底 + 酒红主 + 金点缀"的高对比度视觉方案。这种配色在剧本杀这一特定场景中非常契合,深色调营造出神秘悬疑的氛围,金色则增添了品质感和仪式感。

值得注意的是卡片颜色的两级设计:card#221A3E)作为卡片主体色,cardDeep#1A1430)作为更深一层的底色,两者之间形成微妙的层次差异。这种设计在嵌套组件中尤为有用——当卡片内部需要再嵌入一个子容器时,使用cardDeep可以自然地表达层级关系,而无需引入新的颜色变量。

文字颜色的三级划分同样值得细品:textPrimary#EDE7F6)接近白色,用于标题和重要信息;textSecondary#9C8FB8)为淡紫色,用于描述性文字;textMuted#6B5E8A)为更深的紫灰色,用于辅助信息和占位文字。这种渐进式的文字透明度设计,既保证了可读性,又通过色彩层次引导用户的视觉焦点。

1.3 标签文案配置的设计考量

interface LabelConfig {
  appTitle: string
  appSubtitle: string
  searchHint: string
  publish: string
  mainTabs: string[]
  mainIcons: string[]
  contentTabs: string[]
  quickTags: string[]
  types: string[]
  cities: string[]
  timeSlots: string[]
}

在这里插入图片描述

标签配置采用了与颜色配置相同的interface + const模式。这种设计模式在ArkTS开发中被广泛采用,其核心价值在于:第一,提供了编译期的类型检查,避免了拼写错误;第二,实现了文案与逻辑的分离,便于国际化和主题定制;第三,通过集中管理,所有文案资源一目了然,降低了维护成本。

LabelConfig接口中,可以看到应用的文案资源被划分为多个维度:应用级文案(标题、副标题、搜索提示)、导航标签(主导航、内容导航)、筛选标签(快捷标签、类型、城市、时段)。这种分类方式与应用的信息架构高度对应,体现了"配置即结构"的设计理念。

const LABELS: LabelConfig = {
  appTitle: '探本社',
  appSubtitle: '剧本杀探本社区',
  searchHint: '搜索剧本 / 店铺 / DM',
  publish: '发布',
  mainTabs: ['首页', '发现', '消息', '我的'],
  mainIcons: ['🏠', '🧭', '💬', '👤'],
  contentTabs: ['推荐', '剧本库', '组局', '测评', '店铺', '榜单'],
  quickTags: ['🔥本周热本', '😱恐怖', '💔情感', '🧩硬核推理', '⚔️阵营', '✨新本速递'],
  types: ['情感', '推理', '恐怖', '机制', '阵营'],
  cities: ['全城', '天河区', '越秀区', '海珠区', '番禺区', '荔湾区'],
  timeSlots: ['今天 19:00', '今天 20:30', '明天 14:00', '明天 19:30', '周六 13:00', '周六 19:00']
}

在这里插入图片描述

从常量实现中可以观察到几个有趣的设计细节:其一,图标采用emoji字符而非图片资源,这种做法在原型开发中极为高效,同时也保证了跨平台的一致性;其二,快捷标签(quickTags)采用了"emoji + 文字"的组合形式,增强了视觉辨识度和情感表达;其三,城市筛选默认包含"全城"选项,时段选项则覆盖了当天到周末的多个典型时间段,贴合剧本杀消费的时间规律。


二、数据模型层:@Observed装饰器与接口实现模式

2.1 数据建模的整体架构

应用的数据模型层采用了interface + @Observed class implements的经典模式。这种模式是HarmonyOS ArkTS API 24中状态管理的核心实践之一,它将数据结构的类型定义与响应式能力的实现分离,既保证了类型安全,又赋予了数据对象UI驱动的响应式特性。

整体来看,应用定义了五大核心数据实体:剧本(Script)、测评(Review)、组局(Group)、店铺(Store)、榜单(Rank)。每个实体都遵循相同的设计模式:先定义interface描述数据结构,再创建被@Observed装饰的class实现该接口,并在构造函数中完成属性赋值。

2.2 Script模型详解

interface Script {
  id: number
  title: string
  type: string
  players: string
  duration: string
  difficulty: number
  rating: number
  price: number
  tags: string
  desc: string
  hot: number
  isNew: boolean
}

在这里插入图片描述

Script接口定义了剧本实体的12个属性,涵盖了剧本的基本信息(id、标题、类型)、规格参数(人数、时长、难度)、商业属性(评分、价格、热度)以及描述信息(标签、简介、是否新品)。其中,playersduration使用字符串类型而非结构化对象,这是UI展示导向的设计选择——在列表展示场景下,直接使用格式化字符串可以避免运行时的拼接计算。

@Observed
class ScriptItem implements Script {
  id: number = 0
  title: string = ''
  type: string = ''
  players: string = ''
  duration: string = ''
  difficulty: number = 1
  rating: number = 0
  price: number = 0
  tags: string = ''
  desc: string = ''
  hot: number = 0
  isNew: boolean = false

  constructor(o: Script) {
    this.id = o.id
    this.title = o.title
    this.type = o.type
    this.players = o.players
    this.duration = o.duration
    this.difficulty = o.difficulty
    this.rating = o.rating
    this.price = o.price
    this.tags = o.tags
    this.desc = o.desc
    this.hot = o.hot
    this.isNew = o.isNew
  }
}

ScriptItem类的实现体现了几个关键的技术要点:

第一,@Observed装饰器的作用。在ArkUI框架中,被@Observed装饰的类的实例可以被@ObjectLink或嵌套在@State装饰的数组中时,其属性变化能够触发UI刷新。这是实现细粒度状态更新的基础。

第二,所有属性都提供了默认值。这是ArkTS语言的要求——类属性必须在声明时或构造函数中初始化。默认值的选择也经过了考量:数值类型默认0或1,字符串类型默认空串,布尔类型默认false,这些都是业务上合理的初始状态。

第三,构造函数接收一个Script接口类型的参数。这种设计模式使得数据对象的创建非常灵活——既可以传入普通的对象字面量,也可以传入其他实现了Script接口的类实例。在数据转换和迁移场景中,这种灵活性尤为重要。

2.3 Review模型与社交属性设计

interface Review {
  id: number
  script: string
  store: string
  author: string
  avatar: string
  score: number
  dmScore: number
  mood: string
  content: string
  likes: number
  liked: boolean
  date: string
}

在这里插入图片描述

Review模型代表了用户的探本测评,是社区互动的核心载体。与传统的评论模型相比,该模型增加了几个剧本杀场景特有的属性:dmScore(DM评分,即主持人评分)用于区分剧本本身的质量与店家的服务质量;mood(心情标签)是一种轻量级的情感表达,类似于"一句话总结";liked状态则记录了当前用户是否点赞,直接驱动UI的点赞状态切换。

@Observed装饰的ReviewItem类实现了相同的模式,这里不再赘述。值得注意的是likesliked这一对属性——它们共同构成了点赞功能的数据基础。在后续的交互分析中我们会看到,点赞操作需要同时更新这两个属性,并通过数组的重新赋值来触发UI刷新。

2.4 Group模型与拼车业务逻辑

interface Group {
  id: number
  script: string
  store: string
  time: string
  needCount: number
  haveCount: number
  host: string
  hostAvatar: string
  city: string
  fee: number
  status: string
}

在这里插入图片描述

Group模型描述了组局拼车的业务实体,这是剧本杀社区中最具特色的功能模块。该模型的核心是needCount(需要人数)和haveCount(已有人数)这一对属性,它们共同决定了拼车的进度状态。status字段则用字符串表示拼车的当前状态(如"招募中"、“已满”、“开局”),驱动UI中的状态标签颜色和按钮的可点击性。

值得注意的是,hosthostAvatar字段记录了发起人的信息,这种设计在社交场景中非常重要——用户在决定是否加入拼车时,往往会先查看发起人的身份信息。同时,fee(人均费用)和city(所在城市)则提供了筛选和决策的关键信息。

2.5 Store与Rank模型

interface Store {
  id: number
  name: string
  city: string
  area: string
  rating: number
  rooms: number
  priceLevel: number
  tags: string
  dmCount: number
  distance: string
  hot: number
}

Store模型描述了剧本杀店铺的完整信息。除了基本的名称、评分、位置信息外,还包含了几个行业特有的属性:rooms(房间数)反映了店铺的规模和接待能力;dmCount(DM数量)则是衡量店家服务能力的重要指标;priceLevel采用等级制(1-3)而非具体价格,符合本地生活类应用的常见做法;distance使用字符串类型,直接展示"1.2km"这样的格式化距离,避免了前端的单位换算。

interface Rank {
  id: number
  name: string
  score: number
  badge: string
  type: string
}

在这里插入图片描述

Rank模型是榜单功能的数据载体。它的设计非常简洁——名称、分数、徽章、类型。其中type字段的设计值得关注:它指明了该条榜单属于"店铺热榜"、“剧本热榜”、“DM评分榜"还是"作者达人榜”。这种设计使得单一的数据结构可以支持多种榜单类型,减少了重复代码。


三、工具函数层:纯函数的设计哲学

3.1 类型颜色映射函数

function typeColor(t: string): string {
  if (t === '情感') {
    return COLORS.pink
  }
  if (t === '推理') {
    return COLORS.blue
  }
  if (t === '恐怖') {
    return COLORS.danger
  }
  if (t === '机制') {
    return COLORS.accent
  }
  return COLORS.orange
}

typeColor函数是一个典型的业务语义到视觉语义的映射函数。它将剧本的类型标签(情感、推理、恐怖、机制、阵营)映射为对应的主题色(粉色、蓝色、红色、金色、橙色)。这种映射并非随意选择,而是基于心理学和设计学的考量:

  • 情感本对应粉色——粉色与温柔、浪漫、感性等情感体验紧密关联
  • 推理本对应蓝色——蓝色代表理性、逻辑、冷静,与推理思维的特质契合
  • 恐怖本对应红色(danger色)——红色具有警示、紧张、刺激的视觉感受
  • 机制本对应金色(accent色)——金色象征游戏化、奖励、成就,与机制本的博弈属性呼应
  • 阵营本默认橙色——橙色代表活力、社交、互动,与阵营对抗的社交属性匹配

从实现方式来看,该函数采用了多分支if语句而非switch或对象映射。在TypeScript/ArkTS中,这几种方式的性能差异微乎其微,但多分支if的写法在需要添加新类型时最为直观,每个条件判断都是独立的,便于维护和扩展。

3.2 星级评分函数家族

应用定义了三个与星级评分相关的函数:diffStars(难度星级)、scoreStars(评分星星)以及稍后会提到的基于评分的进度条计算。

function diffStars(d: number): string {
  if (d >= 5) {
    return '★★★★★'
  }
  if (d === 4) {
    return '★★★★☆'
  }
  if (d === 3) {
    return '★★★☆☆'
  }
  if (d === 2) {
    return '★★☆☆☆'
  }
  return '★☆☆☆☆'
}

diffStars函数将1-5的难度数值转换为对应的实心星/空心星字符串。由于难度值是整数(1-5),该函数采用精确匹配的方式,每个等级对应固定的星星组合。这种设计在UI上的好处是直观明确——用户一眼就能看出难度等级,不会产生歧义。

function scoreStars(s: number): string {
  if (s >= 5) {
    return '★★★★★'
  }
  if (s >= 4.5) {
    return '★★★★✩'
  }
  if (s >= 4) {
    return '★★★★☆'
  }
  return '★★★☆☆'
}

scoreStars函数则针对评分场景进行了细分,引入了半星()的概念。评分通常是小数(如4.8分),使用半星可以更精确地表达评分等级。函数将评分划分为四个区间:满分(5.0)、4.5-4.9、4.0-4.4、以及4.0以下。这种分级方式既保证了视觉上的简洁性(不需要显示小数评分),又提供了足够的区分度。

3.3 进度百分比计算

function pctOf(have: number, need: number): number {
  if (need <= 0) {
    return 0
  }
  if (have >= need) {
    return 100
  }
  return Math.round(have * 100 / need)
}

在这里插入图片描述

pctOf函数计算拼车进度的百分比。虽然逻辑简单,但其中包含了两处重要的边界处理:

第一,当need <= 0时返回0。这是一个防御性编程的措施,防止了除零错误的发生。在正常业务逻辑中,needCount应该总是大于0,但加上这个保护可以在数据异常时避免应用崩溃。

第二,当have >= need时返回100。这确保了进度条不会出现超过100%的情况。如果已有人数超过了需求人数(比如有人临时加入导致超额),进度条仍然显示为满格,符合用户的直觉预期。

函数使用Math.round进行四舍五入,这意味着百分比值是整数。在UI进度条的场景下,整数精度已经足够,无需保留小数。

3.4 状态颜色与价格等级函数

function statusColor(s: string): string {
  if (s === '招募中') {
    return COLORS.green
  }
  if (s === '已满') {
    return COLORS.orange
  }
  return COLORS.accent
}

statusColor函数将组局状态映射为颜色。“招募中"使用绿色(表示可加入、进行中),“已满"使用橙色(表示紧张、即将开始),其他状态(如"开局”)默认使用金色。这种颜色编码与用户的心理预期高度一致——绿色代表"行动”,橙色代表"注意",金色代表"完成/成就"。

function priceLevelText(l: number): string {
  if (l >= 3) {
    return '¥¥¥'
  }
  if (l === 2) {
    return '¥¥'
  }
  return '¥'
}

priceLevelText函数将数字价格等级转换为符号化的人民币表示。这是本地生活类应用的标准做法——使用¥符号的数量来表示价格档次,而非展示具体金额,既简洁又直观。三个等级分别对应平价、中等、高档三档消费水平。

3.5 特效层动画函数

应用定义了一组以fx(effect)为前缀的函数,用于驱动浮动emoji特效层的动画效果。这组函数包括fxYfxRotatefxOpacityfxScalefxX等。

function fxY(i: number, tick: number): number {
  return 40 + ((tick + i * 37) % 100) * 4
}

function fxRotate(i: number, tick: number): number {
  return ((tick * (i + 2)) % 60) - 30
}

function fxOpacity(i: number, tick: number): number {
  return 0.25 + ((tick + i * 11) % 40) / 80
}

function fxScale(i: number, tick: number): number {
  return 0.9 + ((tick + i * 7) % 30) / 60
}

function fxX(i: number): number {
  return 30 + i * 58
}

这组函数的设计非常巧妙,它们都遵循相同的模式:接收元素索引i和时间刻度tick两个参数,通过数学运算生成随时间变化的属性值。关键技术要点包括:

索引偏移:每个函数都使用i作为偏移因子(乘以不同的素数:37、11、7等),确保不同元素的动画节奏不同步,产生自然随机的视觉效果。使用素数可以最大程度地减少周期性重合。

取模运算%取模运算使得动画值在一定范围内循环往复,形成周期性动画。不同的取模基数(100、60、40、30)决定了不同属性的动画周期。

起始偏移:每个函数都有一个基础偏移值(如fxY的40、fxOpacity的0.25、fxScale的0.9),确保动画不会从0开始,避免了极端状态。

无状态设计:这些都是纯函数——相同的输入总是产生相同的输出。动画的驱动完全依赖于外部的tick状态,而函数本身不持有任何状态。这种设计使得动画逻辑高度可测试,且与UI渲染框架解耦。

在入口组件中,通过setInterval每120毫秒递增一次tick值,从而驱动所有特效元素的动画更新。这种"时钟驱动"的动画模式在ArkUI中非常实用,尤其适合大量元素的协调动画。

3.6 数据筛选与排序函数

function topHotScripts(): ScriptItem[] {
  const all: ScriptItem[] = scriptData.slice(0)
  all.sort((a: ScriptItem, b: ScriptItem): number => b.hot - a.hot)
  return all.slice(0, 5)
}

topHotScripts函数返回热度最高的前5个剧本。函数首先通过slice(0)创建数组的浅拷贝,然后在拷贝上进行排序,最后取前5个元素。这种"先拷贝后操作"的模式非常重要——它确保了原始数据不会被排序操作修改,保持了源数据的稳定性。

排序函数使用b.hot - a.hot实现降序排列,热度高的在前。这是JavaScript/TypeScript中数组排序的标准写法,在ArkTS中同样适用。

function barHeight(hot: number, maxHot: number): number {
  if (maxHot <= 0) {
    return 10
  }
  return Math.max(12, Math.round(hot * 110 / maxHot))
}

barHeight函数根据热度值计算柱状图的柱高。它接受两个参数:当前热度值和最大热度值,通过比例计算得出像素高度。Math.max(12, ...)确保了即使热度很低,柱子也至少有12像素高,避免了视觉上过矮的问题。最大高度为110像素(当热度等于最大值时),这个数值是根据UI布局空间精心设定的。

function filterScriptsByType(t: string): ScriptItem[] {
  if (t === '全部') {
    return scriptData
  }
  const res: ScriptItem[] = []
  for (let i = 0; i < scriptData.length; i++) {
    if (scriptData[i].type === t) {
      res.push(scriptData[i])
    }
  }
  return res
}

filterScriptsByType函数根据剧本类型筛选数据。当筛选条件为"全部"时直接返回原数组,否则使用for循环遍历并收集匹配项。这里使用传统的for循环而非数组的filter方法,可能是出于性能考虑——在ArkTS运行环境中,传统循环通常比高阶函数方法有更好的性能表现,尤其是在数据量较大的情况下。

同理,filterStoresByCity函数实现了按城市筛选店铺的功能,模式完全相同。

3.7 数据构建函数

应用定义了一组buildXxxData函数,用于将静态的常量数据转换为@Observed类的实例数组:

function buildScriptData(): ScriptItem[] {
  const arr: ScriptItem[] = []
  for (let i = 0; i < SCRIPTS_DATA.length; i++) {
    arr.push(new ScriptItem(SCRIPTS_DATA[i]))
  }
  return arr
}

这组函数(buildScriptDatabuildReviewDatabuildGroupDatabuildStoreDatabuildRankData)的作用是在应用初始化时,将普通的对象字面量数组转换为具有响应式能力的类实例数组。每个数据项都通过new XxxItem()的方式创建,确保它们都具有@Observed装饰器赋予的响应式特性。

最后,这些构建函数在模块顶层被调用,生成全局可用的数据实例:

const scriptData: ScriptItem[] = buildScriptData()
const reviewData: ReviewItem[] = buildReviewData()
const groupData: GroupItem[] = buildGroupData()
const storeData: StoreItem[] = buildStoreData()
const rankData: RankItem[] = buildRankData()

这种"常量数据 + 构建函数 + 全局实例"的三层结构,实现了数据定义与响应式包装的分离,既便于维护静态数据,又确保了运行时的数据响应能力。


四、入口组件架构:@Entry与@Component的组织方式

4.1 组件的整体结构

@Entry
@Component
struct MurderMysteryApp {
  // 导航状态
  @State curTab: number = 0
  @State mainTab: number = 0
  
  // 弹窗状态
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  
  // 动画时钟
  @State tick: number = 0
  
  // 列表数据
  @State scripts: ScriptItem[] = scriptData
  @State reviews: ReviewItem[] = reviewData
  @State groups: GroupItem[] = groupData
  @State stores: StoreItem[] = storeData
  @State ranks: RankItem[] = rankData
  
  // 筛选状态
  @State libFilter: string = '全部'
  @State cityFilter: string = '全城'
  
  // 搜索
  @State keyword: string = ''
  
  // ... 更多表单状态

入口组件MurderMysteryApp是整个应用的根组件。它使用@Entry装饰器标记为应用入口,使用@Component装饰器标记为ArkUI组件,使用struct关键字定义结构体式组件。

组件的状态变量按照功能进行了分组排列,这种组织方式使得代码可读性大大提高。从上到下依次是:导航状态、弹窗状态、动画时钟、列表数据、筛选状态、搜索关键词、各类表单状态。每个分组都有注释说明,体现了良好的代码组织习惯。

状态变量全部使用@State装饰器,这意味着它们都是组件内部的状态,状态变化会触发当前组件及其子组件的重新渲染。在HarmonyOS ArkTS API 24中,@State装饰的变量支持更丰富的类型检测和更细粒度的更新追踪。

4.2 生命周期管理

private timer: number = -1

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

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

组件使用了两个生命周期函数来管理动画定时器:

  • aboutToAppear():在组件即将出现时调用。这里启动了一个每120毫秒触发一次的定时器,用于递增tick值,从而驱动特效层的动画。
  • aboutToDisappear():在组件即将消失时调用。这里清除定时器,防止内存泄漏。

120毫秒的间隔是一个经过考量的选择——大约8-9帧每秒,对于漂浮emoji这类轻量动画来说已经足够流畅,同时又不会造成过大的性能开销。如果间隔太短(如16毫秒/60fps),会增加不必要的CPU消耗;如果间隔太长(如500毫秒),动画会显得卡顿。

定时器ID存储在私有成员变量timer中,初始值为-1。在aboutToDisappear中通过判断timer >= 0来确保只清除有效的定时器,这是一种常见的防御性编程技巧。

4.3 导航切换方法

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

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

应用有两级导航:内容Tab(curTab,0-5对应6个内容板块)和主导航(mainTab,0-3对应底部4个主Tab)。每个导航切换都被封装为独立的方法,而非直接在UI代码中修改状态。这种封装的好处是:

  1. 可读性:方法名清晰表达了操作意图
  2. 可维护性:如果未来需要在切换时添加额外逻辑(如埋点、动画),只需修改方法内部
  3. 可复用性:多处UI可以调用同一个方法

4.4 弹窗管理方法

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

closeAll方法一次性关闭所有弹窗。由于应用有4种不同的弹窗(新增测评、编辑组局、删除确认、发布组局),且同一时间应该只有一个弹窗处于打开状态,因此提供一个统一的关闭方法非常必要。这种设计也避免了弹窗状态不一致的问题。

每个弹窗都有对应的打开方法(openAddopenEditopenDelopenBiz),这些方法在打开弹窗前会先重置表单字段的状态,确保每次打开都是"干净"的初始状态。

openAdd(): void {
  this.addTitle = ''
  this.addType = '情感'
  this.addDifficulty = 3
  this.addScore = 5
  this.addContent = ''
  this.addOpen = true
}

openAdd为例,打开新增测评弹窗前,会先清空标题、重置类型为"情感"、难度为3星、评分为5星、内容为空,然后才将addOpen设为true。这种"先重置后打开"的模式确保了用户体验的一致性。

4.5 业务操作方法

应用实现了多种业务操作方法,包括提交新增测评、提交编辑组局、确认删除、提交发布组局、点赞测评、加入组局等。这些方法构成了应用的核心业务逻辑层。

submitAdd(): void {
  if (this.addTitle.length === 0) {
    this.addTitle = '未命名剧本'
  }
  const item: ReviewItem = new ReviewItem({
    id: this.reviews.length + 1,
    script: this.addTitle,
    store: '拾光剧本馆',
    author: '我',
    avatar: '🎭',
    score: this.addScore,
    dmScore: this.addDifficulty,
    mood: '刚探完本',
    content: this.addContent.length > 0 ? this.addContent : '值得一刷的新本!',
    likes: 0,
    liked: false,
    date: '刚刚'
  })
  this.reviews.unshift(item)
  this.closeAll()
}

submitAdd方法处理新增测评的提交逻辑。其中有几个值得关注的设计:

默认值策略:当标题为空时,自动填充"未命名剧本";当内容为空时,自动填充"值得一刷的新本!"。这种容错设计避免了空白数据的产生,同时也降低了用户的发布门槛。

数据插入位置:使用unshift方法将新测评插入数组开头,而非push到末尾。这符合社交应用的惯例——最新发布的内容应该显示在最前面。

ID生成:使用this.reviews.length + 1生成新ID。这是一种简单的自增ID策略,在本地数据场景下足够使用。在真实的后端对接场景中,ID通常由服务端生成。

submitEdit(): void {
  if (this.editIdx >= 0 && this.editIdx < this.groups.length) {
    const g: GroupItem = this.groups[this.editIdx]
    g.time = this.editTime
    g.needCount = this.editNeed
    const fee: number = this.editFee.length > 0 ? Number(this.editFee) : g.fee
    g.fee = fee
    if (this.editNote.length > 0) {
      g.script = g.script + ' ·' + this.editNote
    }
    this.groups = this.groups.slice(0)
  }
  this.closeAll()
}

submitEdit方法处理组局信息的编辑提交。这段代码展示了ArkUI中一个重要的状态更新模式——数组元素修改后的重新赋值:

this.groups = this.groups.slice(0)

由于@State装饰的数组在检测变化时主要依赖数组引用的变化(而非内部元素的深比较),当直接修改数组元素的属性时,UI可能不会刷新。因此,通过slice(0)创建一个新的数组并重新赋值,可以触发@State的变化检测,从而驱动UI更新。这是ArkTS开发中一个非常实用的技巧。

toggleLike(i: number): void {
  const r: ReviewItem = this.reviews[i]
  r.liked = !r.liked
  if (r.liked) {
    r.likes = r.likes + 1
  } else {
    r.likes = r.likes - 1
  }
  this.reviews = this.reviews.slice(0)
}

toggleLike方法实现了点赞/取消点赞的切换逻辑。同样使用了数组重新赋值的模式来触发UI刷新。点赞数的增减与liked状态的切换保持同步,确保数据一致性。

joinGroup(i: number): void {
  const g: GroupItem = this.groups[i]
  if (g.haveCount < g.needCount) {
    g.haveCount = g.haveCount + 1
    if (g.haveCount >= g.needCount) {
      g.status = '已满'
    }
    this.groups = this.groups.slice(0)
  }
}

joinGroup方法实现了加入组局的功能。它首先检查是否还有空位(haveCount < needCount),如果有则增加已有人数,当达到满员时自动将状态改为"已满"。这里的边界检查确保了即使用户反复点击"上车"按钮,已有人数也不会超过需求人数。


五、特效层与头部组件设计

5.1 浮动emoji特效层

@Builder
fxLayer() {
  Column() {
    ForEach(FX_EMOJIS, (e: string, i: number) => {
      Text(e)
        .fontSize(18)
        .opacity(fxOpacity(i, this.tick))
        .rotate({ angle: fxRotate(i, this.tick) })
        .scale({ x: fxScale(i, this.tick), y: fxScale(i, this.tick) })
        .position({ x: fxX(i), y: fxY(i, this.tick) })
    }, (e: string, i: number) => 'fx' + i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

特效层是一个非常精巧的设计。它使用ForEach遍历FX_EMOJIS数组(包含8个emoji:🔍🕯️🎭🔪✨📖🩸🎲),为每个emoji应用多种变换效果。

关键技术要点:

@Builder装饰器fxLayer@Builder装饰,意味着它是一个可复用的UI构建函数。@Builder是ArkUI中的重要特性,它允许开发者将UI片段抽象为可复用的函数,支持参数传递和状态引用。

position定位:每个emoji使用.position()进行绝对定位,X坐标由fxX(i)计算(固定位置,随索引递增),Y坐标由fxY(i, this.tick)计算(随时间变化,产生浮动效果)。

多属性联动:每个emoji同时应用了透明度(opacity)、旋转(rotate)、缩放(scale)三种变换,它们都随时间tick变化,但变化节奏各不相同(因为使用了不同的数学公式),从而产生丰富而自然的动画效果。

hitTestBehavior.hitTestBehavior(HitTestMode.None)是一个非常重要的设置——它使得特效层不响应任何触摸事件,点击会穿透到下层的UI元素。如果没有这个设置,浮动的emoji会阻挡用户对下方内容的点击操作。

key生成:ForEach的第三个参数是key生成函数'fx' + i.toString(),确保每个元素有唯一的稳定标识,提升列表渲染的性能和稳定性。

5.2 头部组件的三层结构

@Builder
header() {
  Column() {
    // 第一层:标题栏(应用名称 + 通知按钮)
    Row() {
      Column() {
        Text(LABELS.appTitle)
          .fontSize(22)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
        Text(LABELS.appSubtitle)
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)

      Row() {
        Text('🔔')
          .fontSize(20)
          .onClick(() => {
            this.mainTab = 2
          })
      }
      .padding(8)
      .borderRadius(20)
      .backgroundColor('#33264880')
    }
    
    // 第二层:搜索栏
    Row() {
      Text('🔍')
        .fontSize(16)
        .margin({ left: 12 })
      TextInput({ placeholder: LABELS.searchHint, text: this.keyword })
        .placeholderColor(COLORS.textMuted)
        .placeholderFont({ size: 13 })
        .fontColor(COLORS.textPrimary)
        .fontSize(13)
        .backgroundColor(Color.Transparent)
        .layoutWeight(1)
        .onChange((v: string) => {
          this.keyword = v
        })
    }
    .width('92%')
    .height(38)
    .borderRadius(19)
    .backgroundColor('#2A1F4A')
    .border({ width: 1, color: COLORS.border })
    .alignItems(VerticalAlign.Center)
    
    // 第三层:快捷标签(横向滚动)
    Scroll() {
      Row() {
        ForEach(LABELS.quickTags, (t: string, i: number) => {
          Text(t)
            // ... 样式与交互
            .onClick(() => {
              if (i === 1) {
                this.libFilter = '恐怖'
                this.switchTab(1)
              } else if (i === 2) {
                this.libFilter = '情感'
                this.switchTab(1)
              }
              // ... 更多分支
            })
        }, (t: string) => 'qk' + t)
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
  }
  .width('100%')
  .padding({ bottom: 12 })
  .linearGradient({
    angle: 160,
    colors: [['#241244', 0], ['#1A1033', 0.6], ['#8B263580', 1]]
  })
}

头部组件采用了三层垂直堆叠的结构:标题栏、搜索栏、快捷标签栏。整体背景使用线性渐变(linearGradient),从深紫色过渡到酒红色,角度为160度,营造出神秘而华丽的视觉氛围。

标题栏层:左侧是应用名称和副标题,右侧是通知按钮。通知按钮使用半透明圆形背景(#33264880),其中80是Alpha通道值,表示50%透明度。点击通知按钮会跳转到消息Tab(mainTab = 2)。

搜索栏层:搜索框采用胶囊形设计(高度38,圆角19,即高度的一半),左侧有放大镜图标,右侧是输入框。输入框使用.layoutWeight(1)占据剩余空间。背景色为深紫色(#2A1F4A),边框为紫色系,整体与深色主题协调。

快捷标签层:使用横向滚动的Scroll组件承载快捷标签。每个标签有不同的选中态样式——第一个标签(本周热本)默认高亮,使用金色文字和深色背景。点击标签会触发不同的导航行为:类型相关的标签(恐怖、情感、推理、阵营)会跳转到剧本库Tab并设置对应的筛选条件,其他标签则跳转到推荐Tab。

5.3 内容Tab栏的双排布局

@Builder
tabBar() {
  Column() {
    Row() {
      ForEach(LABELS.contentTabs.slice(0, 3), (t: string, i: number) => {
        Column() {
          Text(t)
            .fontSize(13)
            .fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.curTab === i ? COLORS.accent : COLORS.textSecondary)
          Divider()
            .width(this.curTab === i ? 18 : 0)
            .height(2)
            .color(COLORS.accent)
            .margin({ top: 4 })
        }
        .onClick(() => {
          this.switchTab(i)
        })
      }, (t: string) => 'tb0' + t)
    }
    .justifyContent(FlexAlign.SpaceEvenly)
    
    Row() {
      ForEach(LABELS.contentTabs.slice(3, 6), (t: string, i: number) => {
        // 类似结构,索引为 i + 3
      }, (t: string) => 'tb1' + t)
    }
    .justifyContent(FlexAlign.SpaceEvenly)
  }
}

由于内容Tab有6个之多,一排显示会过于拥挤,因此采用了双排布局的设计。上排显示前3个(推荐、剧本库、组局),下排显示后3个(测评、店铺、榜单)。

每个Tab项的选中态通过三个维度表达:

  1. 字重:选中时加粗(FontWeight.Bold
  2. 颜色:选中时使用金色强调色
  3. 底部指示条:选中时显示18像素宽的金色下划线

这种"三重反馈"的选中态设计确保了用户可以清晰地识别当前所在的Tab位置。值得注意的是,底部指示条的宽度使用了动态设置——未选中时宽度为0,选中时宽度为18。在ArkUI中,Divider的宽度变化会产生自然的过渡效果,增强了交互的流畅感。


六、票券式卡片:推荐页面的核心设计

6.1 票券卡片的整体结构

@Builder
ticketCard(s: ScriptItem) {
  Row() {
    // 左侧色块区(票根)
    Column() {
      Text(s.type)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
      Divider()
        .width(24)
        .height(1)
        .color('#FFFFFF66')
        .margin({ top: 6, bottom: 6 })
      Text(s.players)
        .fontSize(11)
        .fontColor('#FFFFFFCC')
      Text(s.duration)
        .fontSize(10)
        .fontColor('#FFFFFF99')
        .margin({ top: 2 })
      Text('¥' + s.price.toString())
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
        .margin({ top: 8 })
      if (s.isNew) {
        Text('NEW')
          .fontSize(9)
          .fontColor(Color.White)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .borderRadius(6)
          .backgroundColor('#FFFFFF33')
          .margin({ top: 6 })
      }
    }
    .width(92)
    .backgroundColor(typeColor(s.type))
    .borderRadius({ topLeft: 14, bottomLeft: 14 })
    
    // 中间打孔分隔
    Column() {
      Divider()
        .vertical(true)
        .height('100%')
        .width(2)
        .color(COLORS.bg)
    }
    .width(4)
    .backgroundColor(COLORS.bg)
    
    // 右侧内容区
    Column() {
      // 标题行 + 评分
      Row() {
        Text(s.title)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .layoutWeight(1)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(s.rating.toFixed(1))
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
      }
      
      // 描述
      Text(s.desc)
        .fontSize(12)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: 6 })
      
      // 标签
      Row() {
        Text(s.tags)
          .fontSize(10)
          .fontColor(typeColor(s.type))
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(10)
          .backgroundColor('#2A2148')
          .layoutWeight(1)
      }
      .margin({ top: 8 })
      
      // 底部操作行
      Row() {
        Text('难度 ' + diffStars(s.difficulty))
        Text('🔥 热度 ' + s.hot.toString())
        Text('组局')
          .padding({ left: 12, right: 12, top: 4, bottom: 4 })
          .borderRadius(12)
          .backgroundColor(COLORS.primary)
          .onClick(() => {
            this.bizScript = s.title
            this.openBiz()
          })
      }
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 8 })
    }
    .layoutWeight(1)
    .padding(12)
  }
  .height(132)
  .borderRadius(14)
  .backgroundColor(COLORS.card)
  .clip(true)
  .margin({ bottom: 12 })
}

票券式卡片是推荐页面的核心视觉元素,其设计灵感来源于实体票根的形态。整个卡片分为三个横向区域:左侧色块区、中间打孔分隔区、右侧内容区。

左侧色块区:宽度固定为92像素,背景色随剧本类型变化(情感本粉色、推理本蓝色等)。内容从上到下依次为:类型标签、分隔线、人数、时长、价格。如果是新剧本(isNew为true),还会显示一个半透明白色背景的"NEW"标签。文字颜色使用不同透明度的白色来区分层级:纯白用于类型和价格(最重要的信息),80%透明度(CC)用于人数,60%透明度(99)用于时长。

中间打孔分隔区:宽度仅4像素,背景色与页面背景相同(COLORS.bg),中间有一条2像素宽的竖向Divider。这个设计巧妙地模拟了票券的打孔效果——左右两部分通过"孔"分隔,视觉上产生了断裂感,但实际上是连续的。配合外层的.clip(true)确保圆角处不溢出。

右侧内容区:占据剩余空间(.layoutWeight(1)),包含剧本标题、评分、描述、标签、难度星级、热度值和组局按钮。标题行使用.layoutWeight(1)让标题占据剩余空间,同时设置.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })实现单行省略。描述文本最多显示两行,超出则省略。

底部操作行采用两端对齐的布局(SpaceBetween),左侧是难度和热度信息,右侧是"组局"按钮。点击组局按钮会预填剧本名称并打开发布组局弹窗,实现了从浏览到行动的顺畅转化。

6.2 推荐页面结构

@Builder
pageFeed() {
  Column() {
    Row() {
      Text('🎭 今日精选票根 · 每日 10 点更新')
        .fontColor(COLORS.accent)
      Text('换一批')
        .fontColor(COLORS.textSecondary)
        .onClick(() => {
          const first: ScriptItem = this.scripts[0]
          this.scripts = this.scripts.slice(1)
          this.scripts.push(first)
        })
    }
    .justifyContent(FlexAlign.SpaceBetween)

    ForEach(this.scripts, (s: ScriptItem, i: number) => {
      this.ticketCard(s)
    }, (s: ScriptItem) => 'feed' + s.id.toString())
  }
  .padding({ left: 14, right: 14, top: 12 })
}

推荐页面(pageFeed)结构简洁:顶部是提示条,下方是票券卡片列表。提示条左侧说明推荐机制(“今日精选票根 · 每日 10 点更新”),右侧是"换一批"按钮。

"换一批"的实现非常巧妙——它将数组的第一个元素移到末尾,从而实现了循环轮换的效果:

const first: ScriptItem = this.scripts[0]
this.scripts = this.scripts.slice(1)
this.scripts.push(first)

这种方式不需要额外的数据源,仅通过数组操作就实现了内容的"刷新"感。虽然在真实应用中应该从后端获取新数据,但在原型阶段,这种本地轮换的方式既简单又有效。


七、剧本库与组局页面

7.1 剧本库的横向筛选与网格布局

@Builder
pageLibrary() {
  Column() {
    // 横向类型筛选
    Scroll() {
      Row() {
        Text('全部')
          .onClick(() => { this.libFilter = '全部' })
        ForEach(LABELS.types, (t: string) => {
          Text(t)
            .backgroundColor(this.libFilter === t ? typeColor(t) : '#2A2148')
            .onClick(() => { this.libFilter = t })
        }, (t: string) => 'libf' + t)
      }
    }
    .scrollable(ScrollDirection.Horizontal)
    
    // 结果统计
    Text('共 ' + filterScriptsByType(this.libFilter).length.toString() + ' 部 · ' + this.libFilter)
    
    // 三列网格
    Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
      ForEach(filterScriptsByType(this.libFilter), (s: ScriptItem) => {
        Column() {
          // 上半部分:类型色块
          Column() {
            Text(s.type)
            Text(diffStars(s.difficulty))
          }
          .backgroundColor(typeColor(s.type))
          .borderRadius({ topLeft: 10, topRight: 10 })
          
          // 下半部分:剧本信息
          Column() {
            Text(s.title)
            Text(s.players + ' · ' + s.duration)
            Row() {
              Text('★' + s.rating.toFixed(1))
              Text('¥' + s.price.toString())
            }
          }
          .padding(8)
        }
        .width('32%')
        .onClick(() => {
          this.bizScript = s.title
          this.openBiz()
        })
      }, (s: ScriptItem) => 'lib' + s.id.toString())
    }
  }
}

剧本库页面采用了"顶部筛选 + 网格内容"的经典布局。筛选栏使用横向滚动,支持"全部"和五种剧本类型的切换。选中态的背景色随类型变化(使用typeColor(t)函数),形成彩色标签的视觉效果。

网格布局使用Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween })实现三列自适应排列。每个卡片宽度设为32%(width('32%')),三列共占96%,剩余4%作为列间距(由SpaceBetween自动分配)。

每个网格卡片采用上下两段式设计:上半部分是类型色块区(高度58像素),显示类型名称和难度星级;下半部分是剧本信息区,包含标题、人数时长、评分和价格。卡片点击后会预填剧本名并打开发布组局弹窗,缩短了用户从浏览到下单的路径。

7.2 组局拼车卡片设计

@Builder
groupCard(g: GroupItem, i: number) {
  Column() {
    // 顶部:发起人信息 + 状态标签
    Row() {
      Text(g.hostAvatar)
        .fontSize(20)
      Column() {
        Text(g.host)
        Text(g.city + ' · ' + g.time)
          .fontColor(COLORS.textMuted)
      }
      .layoutWeight(1)
      
      Text(g.status)
        .backgroundColor(statusColor(g.status))
    }
    
    // 剧本名称 + 价格
    Row() {
      Text('🎭 ' + g.script)
        .fontColor(COLORS.accent)
        .layoutWeight(1)
      Text('¥' + g.fee.toString() + '/人')
        .fontColor(COLORS.orange)
    }
    
    // 店铺位置
    Text('📍 ' + g.store)
      .fontColor(COLORS.textSecondary)
    
    // 进度条
    Column() {
      Row() {
        Row()
          .width(pctOf(g.haveCount, g.needCount).toString() + '%')
          .height(6)
          .backgroundColor(statusColor(g.status))
      }
      .height(6)
      .backgroundColor('#2A2148')
      
      Row() {
        Text('已拼 ' + g.haveCount.toString() + '/' + g.needCount.toString() + ' 人')
        Text('还差 ' + (g.needCount - g.haveCount > 0 ? ... : '0') + ' 人开局')
          .fontColor(statusColor(g.status))
      }
      .justifyContent(FlexAlign.SpaceBetween)
    }
    
    // 底部操作按钮
    Row() {
      Text('✏️ 编辑')
        .onClick(() => { this.openEdit(i) })
      Text('🗑️ 删除')
        .onClick(() => { this.openDel('组局', g.script) })
      Text(g.haveCount >= g.needCount ? '已满员' : '🚗 我要上车')
        .backgroundColor(g.haveCount >= g.needCount ? COLORS.textMuted : COLORS.primary)
        .onClick(() => { this.joinGroup(i) })
    }
    .justifyContent(FlexAlign.End)
  }
}

组局卡片是应用中信息密度最高的组件之一,包含了发起人信息、状态标签、剧本名称、价格、店铺、进度条和操作按钮等多个信息层级。

进度条的实现值得详细说明。进度条使用嵌套的Row组件实现:外层Row作为背景槽(深紫色),内层Row作为进度填充,其宽度由pctOf(g.haveCount, g.needCount)计算得出的百分比动态设置。进度条的颜色与组局状态联动——招募中显示绿色,已满显示橙色。

进度条下方是文字说明,左侧显示"已拼 X/Y 人",右侧显示"还差 Z 人开局"。右侧文字的颜色同样随状态变化,强化了状态的视觉表达。

底部操作区采用右对齐布局,包含三个按钮:编辑、删除、我要上车。编辑和删除使用描边按钮样式(border + 透明背景),"我要上车"使用实心按钮样式。当组局已满时,按钮变为灰色的"已满员"且不可点击(虽然代码中没有显式禁用点击,但joinGroup方法内部有判断,满员时点击不会有任何效果)。


八、测评、店铺与榜单页面

8.1 测评卡片与雷达式评分

@Builder
reviewCard(r: ReviewItem, i: number) {
  Column() {
    // 顶部:用户头像 + 信息
    Row() {
      Text(r.avatar)
        .width(44)
        .height(44)
        .textAlign(TextAlign.Center)
        .borderRadius(22)
        .backgroundColor('#2A2148')
      Column() {
        Text(r.author)
        Text(r.date + ' · ' + r.store)
          .fontColor(COLORS.textMuted)
      }
      .layoutWeight(1)
      
      Column() {
        Text('🗑️')
          .onClick(() => { this.openDel('测评', r.script) })
      }
    }
    
    // 剧本名 + 评分
    Row() {
      Text('🎭 ' + r.script)
        .fontColor(COLORS.accent)
        .layoutWeight(1)
      Text(scoreStars(r.score))
        .fontColor(COLORS.accent)
    }
    
    // 心情标签
    Text('「' + r.mood + '」')
      .fontColor(COLORS.pink)
    
    // 测评内容
    Text(r.content)
      .fontColor(COLORS.textSecondary)
    
    // 雷达式分区评分
    Column() {
      // 剧情评分条
      Row() {
        Text('剧情')
          .width(30)
        Row() {
          Row()
            .width((r.score * 20).toString() + '%')
            .height(4)
            .backgroundColor(COLORS.pink)
        }
        .layoutWeight(1)
        Text(r.score.toFixed(1))
          .width(22)
      }
      // DM评分条
      Row() {
        Text('DM')
        Row() {
          Row()
            .width((r.dmScore * 20).toString() + '%')
            .height(4)
            .backgroundColor(COLORS.accent)
        }
        .layoutWeight(1)
        Text(r.dmScore.toFixed(1))
      }
      // 氛围评分条
      Row() {
        Text('氛围')
        Row() {
          Row()
            .width((85 + (r.id % 3) * 5).toString() + '%')
            .height(4)
            .backgroundColor(COLORS.blue)
        }
        .layoutWeight(1)
        Text((4.2 + (r.id % 3) * 0.1).toFixed(1))
      }
    }
    .border({ width: 1, color: COLORS.border })
    .borderRadius(10)
    .padding(8)
    
    // 底部:评论数 + 点赞
    Row() {
      Text('💬 ' + (12 + r.id * 3).toString())
      Text(r.liked ? '❤️ ' + r.likes.toString() : '🤍 ' + r.likes.toString())
        .fontColor(r.liked ? COLORS.danger : COLORS.textMuted)
        .backgroundColor(r.liked ? '#3A1B22' : 'transparent')
        .onClick(() => { this.toggleLike(i) })
    }
    .justifyContent(FlexAlign.End)
  }
}

测评卡片是社区内容的核心展示形式,其设计融合了社交应用的多种交互元素。

雷达式分区评分是该卡片最具特色的部分。它通过三个水平进度条分别展示剧情、DM、氛围三个维度的评分,形成了类似雷达图的视觉效果,但在实现上比真正的雷达图简单得多。每个评分条由三部分组成:左侧维度名称(固定宽度30像素)、中间进度条(使用.layoutWeight(1)自适应宽度)、右侧数值(固定宽度22像素)。

三个维度使用了不同的主题色:剧情用粉色、DM用金色、氛围用蓝色,既区分了维度,又增添了视觉丰富度。剧情和DM的进度条使用真实数据(r.score * 20r.dmScore * 20,即5分制转百分比),氛围评分则使用基于ID的模拟数据(85 + (r.id % 3) * 5),这是原型开发中常见的做法。

点赞交互设计得非常细致:点赞前显示白色心形(🤍)和灰色文字,点赞后显示红色心形(❤️)和红色文字,同时背景变为深红色半透明(#3A1B22)。这种多维度的状态反馈大大增强了交互的愉悦感。

8.2 店铺卡片与热度进度条

@Builder
storeCard(s: StoreItem) {
  Column() {
    Row() {
      // 店铺首字母头像
      Column() {
        Text(s.name.substring(0, 1))
          .fontColor(COLORS.accent)
      }
      .width(48)
      .height(48)
      .borderRadius(12)
      .backgroundColor('#2A2148')
      .border({ width: 1, color: COLORS.accentDim })
      
      // 店铺信息
      Column() {
        Text(s.name)
        Text(s.area + ' · ' + s.distance + ' · ' + s.rooms.toString() + '间房 · ' + s.dmCount.toString() + '位DM')
          .fontColor(COLORS.textMuted)
        Text(s.tags)
          .fontColor(COLORS.textSecondary)
      }
      .layoutWeight(1)
      
      // 评分与价格
      Column() {
        Text('★' + s.rating.toFixed(1))
          .fontColor(COLORS.accent)
        Text(priceLevelText(s.priceLevel))
          .fontColor(COLORS.orange)
        Text('🗑️')
          .onClick(() => { this.openDel('收藏店铺', s.name) })
      }
    }
    
    // 底部热度条
    Row() {
      Text('🔥 热度 ' + s.hot.toString())
        .fontColor(COLORS.orange)
      Row() {
        Row()
          .width(s.hot.toString() + '%')
          .height(4)
          .backgroundColor(COLORS.orange)
      }
      .layoutWeight(1)
      .backgroundColor('#2A2148')
      Text('组局')
        .fontColor(Color.White)
        .backgroundColor(COLORS.primary)
        .onClick(() => {
          this.bizStore = s.name
          this.bizCity = s.city
          this.openBiz()
        })
    }
  }
}

店铺卡片采用上下两层结构:上层是店铺的主要信息,下层是热度条和操作按钮。

店铺头像使用店铺名称的首字(s.name.substring(0, 1))代替图片,这是在没有真实图片资源时的常见做法。使用金色文字和深色背景,配合金色边框,保持了整体的视觉品质感。

店铺信息包含三行内容:店铺名称(主要信息)、区域+距离+房间数+DM数(辅助信息)、标签(特色信息)。信息密度适中,用户可以快速了解店铺的基本情况。

底部热度条同时展示了热度数值、热度进度条和组局按钮。热度条使用橙色,与"热度"的语义高度匹配。点击组局按钮会预填店铺名称和所在城市,打开发布组局弹窗——这是典型的"上下文行动"设计,让用户在浏览内容时可以直接发起相关操作。

8.3 榜单页面的柱状图实现

@Builder
pageRank() {
  Column() {
    Text('📊 剧本热度 TOP5')
    
    // 柱状图
    Row() {
      ForEach(topHotScripts(), (s: ScriptItem, i: number) => {
        Column() {
          Text(s.hot.toString())
          Column()
            .width(26)
            .height(barHeight(s.hot, 100))
            .borderRadius({ topLeft: 6, topRight: 6 })
            .backgroundColor(i === 0 ? COLORS.accent : COLORS.primary)
          Text(s.title)
            .width(60)
            .textAlign(TextAlign.Center)
        }
        .width(60)
      }, (s: ScriptItem) => 'bar' + s.id.toString())
    }
    .alignItems(VerticalAlign.Bottom)
    
    // 排行榜列表
    Text('🏅 综合排行榜')
    
    ForEach(this.ranks, (r: RankItem, i: number) => {
      Row() {
        Text((i + 1).toString())
          .fontColor(i < 3 ? COLORS.accent : COLORS.textMuted)
          .width(28)
        Text(r.badge)
          .width(30)
        Column() {
          Text(r.name)
          Text(r.type)
            .fontColor(COLORS.textMuted)
        }
        .layoutWeight(1)
        
        Row() {
          Row()
            .width((r.score).toString() + '%')
            .height(5)
            .backgroundColor(i < 3 ? COLORS.accent : COLORS.primary)
        }
        .width(70)
        
        Text(r.score.toString())
          .fontColor(COLORS.accent)
          .width(28)
      }
      .backgroundColor(i % 2 === 0 ? COLORS.card : COLORS.cardDeep)
    }, (r: RankItem) => 'rk' + r.id.toString())
  }
}

榜单页面由两部分组成:顶部的热度TOP5柱状图和下方的综合排行榜列表。

柱状图的实现非常巧妙——完全使用基础的ColumnRow组件构建,无需引入任何图表库。每个柱子就是一个设置了宽度、高度和圆角的Column组件,高度由barHeight(s.hot, 100)函数根据热度值动态计算。第一名的柱子使用金色(强调色),其余使用酒红色(主色),形成了视觉焦点。

柱状图容器使用.alignItems(VerticalAlign.Bottom),确保所有柱子从底部对齐,这是柱状图的基本要求。每个柱子上方显示热度数值,下方显示剧本名称,整体结构清晰直观。

排行榜列表采用标准的排行条目设计:从左到右依次是排名数字、徽章图标、名称与类型、分数进度条、分数值。前三名(i < 3)的排名数字和进度条使用金色强调,其余使用主色。列表条目使用交替背景色(i % 2 === 0时用card色,否则用cardDeep色),增强了长列表的可读性。


九、弹窗交互系统

9.1 弹窗遮罩层的架构设计

@Builder
modalOverlay() {
  Column() {
    // 顶部弹性遮罩区(点击关闭)
    Column()
      .layoutWeight(1)
      .onClick(() => { this.closeAll() })

    // 居中弹窗:编辑 / 删除确认
    if (this.editOpen) {
      this.modalBodyEdit()
    }
    if (this.delOpen) {
      this.modalBodyDel()
    }

    // 底部弹窗:新增测评 / 发布组局
    if (this.addOpen) {
      this.modalBodyAdd()
    }
    if (this.bizOpen) {
      this.modalBodyBiz()
    }

    // 居中弹窗下方的弹性遮罩区
    if (this.editOpen || this.delOpen) {
      Column()
        .layoutWeight(1)
        .onClick(() => { this.closeAll() })
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#99000000')
}

弹窗系统的遮罩层设计体现了清晰的架构思维。整个遮罩是一个全屏的Column组件,背景为60%透明度的黑色(#99000000,其中99是Alpha值,约60%不透明)。

遮罩层分为三种弹窗类型:

  1. 底部弹窗(新增测评、发布组局):从底部滑出,只有顶部有弹性点击区
  2. 居中弹窗(编辑组局、删除确认):居中显示,上下都有弹性点击区

弹性点击区使用.layoutWeight(1)占据剩余空间,点击任何空白区域都可以关闭弹窗。这是移动端弹窗的标准交互模式,符合用户的操作习惯。

弹窗的显示通过布尔状态变量(addOpeneditOpendelOpenbizOpen)控制,使用if条件渲染。虽然代码中同时检查了多个状态,但业务逻辑保证了同一时间只有一个弹窗处于打开状态(所有打开方法在打开新弹窗前都会重置,但closeAll会在业务方法中被调用确保状态一致性)。

9.2 新增测评弹窗(底部弹出)

@Builder
modalBodyAdd() {
  Column() {
    // 标题栏
    Row() {
      Text('✍️ 新增探本测评')
      Text('✕')
        .onClick(() => { this.closeAll() })
    }
    .justifyContent(FlexAlign.SpaceBetween)
    
    Scroll() {
      Column() {
        // 剧本名称输入
        Text('剧本名称')
        TextInput({ placeholder: '例如:年轮', text: this.addTitle })
          .onChange((v: string) => { this.addTitle = v })
        
        // 剧本类型选择
        Text('剧本类型')
        Row() {
          ForEach(LABELS.types, (t: string) => {
            Text(t)
              .backgroundColor(this.addType === t ? typeColor(t) : COLORS.cardDeep)
              .onClick(() => { this.addType = t })
          }, (t: string) => 'addt' + t)
        }
        
        // 难度选择
        Text('难度星级')
        Row() {
          ForEach([1, 2, 3, 4, 5], (d: number) => {
            Text(d.toString() + '星' + (d === 5 ? '(地狱)' : d === 1 ? '(休闲)' : ''))
              .onClick(() => { this.addDifficulty = d })
          }, (d: number) => 'addd' + d.toString())
        }
        
        // 评分选择
        Text('综合评分')
        Row() {
          ForEach([1, 2, 3, 4, 5], (sc: number) => {
            Text(sc <= this.addScore ? '★' : '☆')
              .fontColor(sc <= this.addScore ? COLORS.accent : COLORS.textMuted)
              .onClick(() => { this.addScore = sc })
          }, (sc: number) => 'adds' + sc.toString())
        }
        
        // 测评内容
        Text('测评内容')
        TextArea({ placeholder: '写下你的探本感受...', text: this.addContent })
          .onChange((v: string) => { this.addContent = v })
        
        // 操作按钮
        Row() {
          Text('发布测评')
            .backgroundColor(COLORS.primary)
            .onClick(() => { this.submitAdd() })
          Text('取消')
            .onClick(() => { this.closeAll() })
        }
      }
    }
    .layoutWeight(1)
  }
  .width('90%')
  .constraintSize({ maxHeight: '75%' })
  .borderRadius({ topLeft: 20, topRight: 20 })
}

新增测评弹窗是一个典型的底部弹出表单,包含剧本名称、类型、难度、评分、内容五个输入项。

评分交互的设计颇具巧思:使用5个星星字符(★/☆)作为评分选择器,点击第N颗星就设置为N分。已选中的星星显示为金色实心,未选中的显示为灰色空心。这种交互方式直观有趣,比滑块或数字输入更具情感化。

难度选择则使用标签按钮形式,每个难度等级都有文字描述(1星为"休闲",5星为"地狱"),帮助用户准确理解难度含义。

表单内容区域使用Scroll + layoutWeight(1)的组合,确保在内容超出屏幕高度时可以滚动查看。弹窗的最大高度限制为屏幕高度的75%(maxHeight: '75%'),避免了弹窗占满全屏的压迫感。

9.3 编辑组局弹窗(居中金边样式)

@Builder
modalBodyEdit() {
  Column() {
    // 标题
    Row() {
      Text('✏️ 编辑组局信息')
        .fontColor(COLORS.accent)
      Text('✕')
        .onClick(() => { this.closeAll() })
    }
    
    Text('🎭 ' + this.editScriptName)
      .fontColor(COLORS.textSecondary)
    
    // 时间选择
    Text('开局时间')
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(LABELS.timeSlots, (t: string) => {
        Text(t)
          .backgroundColor(this.editTime === t ? COLORS.accent : COLORS.cardDeep)
          .fontColor(this.editTime === t ? Color.Black : COLORS.textSecondary)
          .onClick(() => { this.editTime = t })
      }, (t: string) => 'editt' + t)
    }
    
    // 人数选择(加减按钮)
    Text('需要人数')
    Row() {
      Text('−')
        .onClick(() => {
          if (this.editNeed > 3) {
            this.editNeed = this.editNeed - 1
          }
        })
      Text(this.editNeed.toString() + ' 人')
        .layoutWeight(1)
      Text('+')
        .onClick(() => {
          if (this.editNeed < 10) {
            this.editNeed = this.editNeed + 1
          }
        })
    }
    
    // 费用输入
    Text('人均费用(元)')
    TextInput({ placeholder: '128', text: this.editFee })
      .type(InputType.Number)
    
    // 备注
    Text('备注')
    TextArea({ placeholder: '例如:只差一位女生,新手友好', text: this.editNote })
    
    // 按钮
    Row() {
      Text('保存修改')
        .fontColor(Color.Black)
        .backgroundColor(COLORS.accent)
        .onClick(() => { this.submitEdit() })
      Text('取消')
        .onClick(() => { this.closeAll() })
    }
  }
  .width('85%')
  .borderRadius(16)
  .backgroundColor('#241A42')
  .border({ width: 1.5, color: COLORS.accent })
}

编辑组局弹窗采用居中显示的样式,最显著的特点是1.5像素宽的金色边框,营造出"金边"质感,与深色背景形成鲜明对比。弹窗背景色比普通卡片略深(#241A42),进一步强化了弹窗的层次感。

人数选择器使用加减按钮的形式,而非下拉选择或输入框。这种设计在移动端非常友好——点击操作比键盘输入更便捷,同时通过边界检查(最少3人,最多10人)确保了数据的合理性。减号按钮有下限保护(editNeed > 3时才能减),加号按钮有上限保护(editNeed < 10时才能加)。

时间选择使用Flex({ wrap: FlexWrap.Wrap })实现标签的自动换行排列。选中态使用金色背景+黑色文字,形成强烈的视觉对比,与弹窗的金边风格统一。

9.4 删除确认弹窗(红色警示)

@Builder
modalBodyDel() {
  Column() {
    Text('⚠️')
      .fontSize(40)
    
    Text('确认删除?')
      .fontColor(COLORS.danger)
      .fontWeight(FontWeight.Bold)
    
    Text('删除「' + this.delName + '」' + this.delKind + '后,将无法恢复。')
      .fontColor(COLORS.textSecondary)
      .textAlign(TextAlign.Center)
    
    Divider()
    
    Row() {
      Text('取消')
        .layoutWeight(1)
        .height(44)
        .onClick(() => { this.closeAll() })
      Divider()
        .vertical(true)
        .height(44)
      Text('删除')
        .fontColor(COLORS.danger)
        .layoutWeight(1)
        .height(44)
        .onClick(() => { this.confirmDel() })
    }
  }
  .width('70%')
  .borderRadius(16)
  .border({ width: 1, color: COLORS.danger })
}

删除确认弹窗是一个经典的警示对话框设计。它的视觉语言全部围绕"危险"这一主题构建:

  • 红色边框:使用danger色(红色)作为边框,立即传递警示信号
  • 警告图标:大号的⚠️emoji强化了警示意味
  • 红色标题:"确认删除?"使用红色加粗字体
  • 红色删除按钮:删除按钮文字使用红色,与取消按钮的灰色形成对比

按钮区域采用了左右等分的布局,中间用竖线分隔。这种设计常见于iOS风格的对话框,操作区域清晰明确。删除操作是不可逆的,因此使用醒目的红色作为视觉强调,同时弹窗内容明确告知用户"删除后将无法恢复",起到二次确认的作用。

9.5 发布组局弹窗(底部大表单)

@Builder
modalBodyBiz() {
  Column() {
    Row() {
      Text('🚗 发布组局拼车')
      Text('✕')
        .onClick(() => { this.closeAll() })
    }
    
    Scroll() {
      Column() {
        // 剧本名称
        Text('剧本名称')
        TextInput({ placeholder: '例如:雾山夜行', text: this.bizScript })
        
        // 店铺
        Text('店铺')
        TextInput({ placeholder: '例如:迷雾剧场·天河店', text: this.bizStore })
        
        // 所在区域
        Text('所在区域')
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(LABELS.cities.slice(1), (c: string) => {
            Text(c)
              .onClick(() => { this.bizCity = c })
          }, (c: string) => 'bizc' + c)
        }
        
        // 开局时间
        Text('开局时间')
        Flex({ wrap: FlexWrap.Wrap }) {
          ForEach(LABELS.timeSlots, (t: string) => {
            Text(t)
              .onClick(() => { this.bizTime = t })
          }, (t: string) => 'bizt' + t)
        }
        
        // 费用
        Text('人均费用(元)')
        TextInput({ placeholder: '128', text: this.bizFee })
          .type(InputType.Number)
        
        // 联系方式
        Text('联系方式(微信/手机)')
        TextInput({ placeholder: '仅拼车成功后对成员可见', text: this.bizContact })
        
        // 按钮
        Row() {
          Text('🚗 立即发车')
            .backgroundColor(COLORS.primary)
            .onClick(() => { this.submitBiz() })
          Text('取消')
            .onClick(() => { this.closeAll() })
        }
      }
    }
    .layoutWeight(1)
  }
  .width('95%')
  .constraintSize({ maxHeight: '80%' })
}

发布组局弹窗是所有弹窗中表单字段最多的一个,包含剧本名称、店铺、区域、时间、费用、联系方式六个输入项。

表单的宽度设置为95%(比新增测评的90%更宽),因为输入项较多,需要更多的横向空间。最大高度限制为80%,同样比新增测评弹窗更高,以容纳更多内容。

所在区域选项使用了LABELS.cities.slice(1)——即跳过第一个"全城"选项,因为发布组局时必须选择具体的城区,"全城"没有实际意义。这个细节体现了开发者对业务场景的深入理解。

提交按钮使用"🚗 立即发车"这样的文案,比"发布"或"确认"更具场景感和行动号召力,与剧本杀拼车的语境高度契合。


十、底部导航栏与主构建函数

10.1 底部导航栏的凸起按钮设计

@Builder
bottomBar() {
  Row() {
    // 左侧两个Tab
    ForEach(LABELS.mainTabs.slice(0, 2), (t: string, i: number) => {
      Column() {
        Text(LABELS.mainIcons[i])
        Text(t)
          .fontColor(this.mainTab === i ? COLORS.accent : COLORS.textMuted)
      }
      .layoutWeight(1)
      .onClick(() => { this.switchMain(i) })
    }, (t: string) => 'mb' + t)
    
    // 中间凸起发布按钮
    Column() {
      Text('➕')
        .width(52)
        .height(52)
        .borderRadius(26)
        .backgroundColor(COLORS.primary)
        .border({ width: 2, color: COLORS.accent })
        .shadow({ radius: 10, color: '#668B2635', offsetX: 0, offsetY: 4 })
        .onClick(() => { this.openBiz() })
    }
    .layoutWeight(1)
    .margin({ top: -18 })
    
    // 右侧两个Tab
    ForEach(LABELS.mainTabs.slice(2), (t: string, i: number) => {
      Column() {
        Text(LABELS.mainIcons[i + 2])
        Text(t)
          .fontColor(this.mainTab === i + 2 ? COLORS.accent : COLORS.textMuted)
      }
      .layoutWeight(1)
      .onClick(() => { this.switchMain(i + 2) })
    }, (t: string) => 'mb2' + t)
  }
  .height(56)
  .backgroundColor(COLORS.cardDeep)
}

底部导航栏是应用的全局导航入口,采用了小红书、抖音等社交应用常见的"中间凸起按钮"设计模式。

导航栏共有5个元素,但实际的Tab只有4个(首页、发现、消息、我的),中间位置被一个凸起的发布按钮占据。这种设计的核心思想是:将应用最核心的操作(发布内容/发起组局)放在最显眼、最容易点击的位置。

凸起按钮的实现有几个关键技术点:

  1. 负margin.margin({ top: -18 })使按钮向上偏移18像素,从而"突破"导航栏的边界,形成凸起效果。
  2. 圆形按钮:52x52像素的圆形按钮,圆角26像素(正好是宽度的一半)。
  3. 双层边框:2像素宽的金色边框包裹酒红色的按钮主体,形成金边质感。
  4. 阴影效果.shadow({ radius: 10, color: '#668B2635', offsetX: 0, offsetY: 4 })为按钮添加阴影,增强立体感和悬浮感。阴影颜色使用主色调的半透明版本(#668B2635,其中66是Alpha值),使阴影与整体色调协调。

左右两侧的Tab使用ForEach渲染,通过slice(0, 2)slice(2)分别获取前两个和后两个Tab元素。中间用凸起按钮隔开,形成"2 + 1 + 2"的五栏布局。每个Tab的选中态通过文字颜色变化来表达——选中时为金色,未选中时为灰色。

10.2 主构建函数的Stack布局

build() {
  Stack() {
    // 主内容层
    Column() {
      this.header()
      this.tabBar()
      Scroll() {
        Column() {
          if (this.curTab === 0) {
            this.pageFeed()
          }
          if (this.curTab === 1) {
            this.pageLibrary()
          }
          if (this.curTab === 2) {
            this.pageGroup()
          }
          if (this.curTab === 3) {
            this.pageReview()
          }
          if (this.curTab === 4) {
            this.pageStore()
          }
          if (this.curTab === 5) {
            this.pageRank()
          }
        }
      }
      .layoutWeight(1)
      this.bottomBar()
    }
    
    // 特效层
    this.fxLayer()
    
    // 弹窗层
    if (this.addOpen || this.editOpen || this.delOpen || this.bizOpen) {
      this.modalOverlay()
    }
  }
  .width('100%')
  .height('100%')
  .backgroundColor(COLORS.bg)
}

主构建函数使用Stack堆叠布局,将应用界面分为三层,从下到上依次是:

  1. 主内容层:包含头部、Tab栏、内容区、底部导航。这是应用的主体,使用Column垂直排列。内容区使用Scroll + layoutWeight(1)实现可滚动的内容区域,根据curTab的值显示不同的页面内容。

  2. 特效层:浮动emoji特效,位于主内容层之上。由于设置了hitTestBehavior(HitTestMode.None),它不会阻挡用户对下方内容的操作。

  3. 弹窗层:在有弹窗打开时显示,位于最顶层。遮罩层覆盖全屏,阻止用户操作下方内容。

这种"内容 + 装饰 + 弹窗"的三层结构是UI架构中的经典模式。Stack布局使得各层独立管理、互不干扰,同时又能叠加显示。每层的职责清晰明确:内容层承载信息,特效层增强视觉体验,弹窗层处理模态交互。

内容区的页面切换使用多个if条件判断而非switch语句,这是因为ArkTS的声明式UI语法中只能使用if条件渲染。每个页面都是一个独立的@Builder方法,通过curTab状态变量控制显示哪个页面。当curTab变化时,ArkUI框架会自动计算差异并更新UI。


十一、架构流程图与数据流分析

11.1 应用整体架构流程图

为了更直观地理解应用的整体架构,下面使用Mermaid流程图展示组件层级和数据流关系:

渲染错误: Mermaid 渲染失败: Parse error on line 5: ...渲染层] B --> B1[@State 导航状态
c ---------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

从架构图中可以清晰地看到,整个应用采用了单组件多Builder的架构模式。所有的状态和逻辑都集中在MurderMysteryApp这一个根组件中,UI部分则通过多个@Builder方法进行模块化拆分。这种架构的优势在于状态管理简单直接——所有状态都在同一个组件内,不需要跨组件传递;但劣势也很明显——当应用规模增长时,单组件会变得过于庞大,维护成本上升。

11.2 点赞交互的状态流转

下面以点赞功能为例,展示用户交互到UI更新的完整数据流:

状态层 @State reviews 方法层 toggleLike UI层 reviewCard 用户 状态层 @State reviews 方法层 toggleLike UI层 reviewCard 用户 点击❤️点赞按钮 调用 toggleLike(i) 获取 reviews[i] 返回 ReviewItem 引用 切换 liked = !liked 更新 likes +/- 1 reviews = reviews.slice(0) 触发状态更新 UI刷新 心形变色 + 数字变化

这个时序图清晰地展示了点赞操作的完整流程:

  1. 用户触发:用户点击测评卡片上的点赞按钮
  2. 方法调用:按钮的onClick回调调用toggleLike(i)方法
  3. 数据修改:方法内部获取对应索引的ReviewItem对象,切换liked状态并更新likes计数
  4. 状态通知:通过slice(0)创建新数组并重新赋值,触发@State的变化检测
  5. UI更新:ArkUI框架检测到状态变化,重新渲染相关的UI元素

关键点在于第4步——由于ArkUI的@State对数组的变化检测主要基于引用变化,直接修改数组元素的属性可能不会触发UI刷新。因此,开发者使用了"数组重新赋值"的技巧来确保状态变化被正确检测。


十二、技术对比与选型分析

12.1 状态管理方案对比

在HarmonyOS ArkTS开发中,状态管理是架构设计的核心议题。以下对比了几种常见的状态管理方案:

对比维度 @State + @Observed(本方案) @Provide/@Consume AppStorage/LocalStorage Redux-like 三方库
学习成本 低,官方内置,API简洁 中,需理解跨组件传递 中,需理解全局存储 高,需理解action/reducer
适用规模 中小型应用,单组件为主 中等规模,多层嵌套 大型应用,全局状态共享 大型复杂应用
类型安全 高,完全TypeScript化 中,key为字符串
性能表现 优秀,细粒度更新 良好 一般,全局通知 取决于实现
调试难度 低,状态集中 中,需追踪传递链 中,全局搜索 高,时间旅行调试复杂
代码量 少,样板代码极少 较少 中等 多,需要大量样板代码
可测试性 高,纯逻辑易测试 中,依赖组件树 中,全局依赖 高,纯函数reducer
官方支持 完全支持,一等公民 完全支持 完全支持 社区方案

本应用选择了@State + @Observed的组合方案,这是基于以下考量:

  1. 应用规模适中:应用功能虽然丰富,但所有状态可以在单组件内容纳,无需复杂的跨组件状态传递。
  2. 开发效率优先:原型开发阶段,简洁的状态管理方案可以大大加快开发速度。
  3. 性能足够@State提供的细粒度更新对于当前的数据量(每类12条数据)来说绰绰有余。
  4. 维护成本低:状态集中管理,查找和修改都非常方便。

当然,如果应用继续发展壮大(比如增加用户系统、实时聊天、更多数据实体),这种单组件架构可能会遇到瓶颈。届时可以考虑逐步迁移到@Provide/@ConsumeAppStorage方案,将状态分发到不同的组件层级。

12.2 UI构建模式对比

ArkUI提供了多种UI组织方式,以下是对比分析:

对比维度 @Builder 函数(本方案) 自定义@Component 全局自定义组件 Builder 模式链式调用
复用粒度 组件内复用 跨组件复用 全局复用 单组件内
状态访问 直接访问组件状态 通过参数传递 通过参数/状态管理 直接访问
代码组织 同一文件内分组 多文件拆分 全局注册 内嵌在build中
性能开销 极低,内联展开 有组件实例开销 有组件实例开销 极低
适用场景 组件内UI片段复用 通用组件库 全局通用组件 简单UI组合
可维护性 中,单文件易膨胀 高,职责清晰 高,统一管理 低,嵌套过深
测试友好 低,依赖宿主组件 高,可独立测试

本应用大量使用了@Builder函数来组织UI代码,这是一种介于"全部内联"和"完全组件化"之间的折中方案。它的优势在于:

  • 零开销@Builder本质上是代码内联,没有额外的组件实例化开销
  • 状态共享:可以直接访问宿主组件的@State变量,无需参数传递
  • 代码清晰:将复杂的build函数拆分为多个有意义的小函数

但这种模式也有局限性——当UI片段需要在多个组件间复用时,@Builder就无能为力了,此时需要提取为独立的@Component组件。


十三、设计模式与最佳实践总结

13.1 接口驱动的数据建模

应用中反复出现的interface + @Observed class implements模式是一种非常值得推广的最佳实践。这种模式将数据的形状定义(interface)与数据的响应式能力(class)分离,带来了多重好处:

类型安全与自动补全:接口定义为IDE提供了完整的类型信息,开发者在编写代码时可以获得精准的自动补全和类型检查,大大减少了拼写错误和属性遗漏。

关注点分离:接口只关心"数据长什么样",类只关心"数据如何响应变化"。当需要调整数据结构时,修改接口即可;当需要调整响应式行为时,修改类的装饰器即可。

易于测试:接口定义的纯数据对象可以在任何测试环境中轻松创建,不需要依赖ArkUI运行时。这为单元测试提供了便利。

渐进式迁移:如果未来需要将数据层迁移到后端或独立模块,可以直接复用接口定义,降低了重构成本。

13.2 配置与逻辑分离

应用将颜色、文案、静态数据全部抽取为顶层常量和配置对象,与UI逻辑和业务逻辑严格分离。这种分离的价值体现在:

主题切换能力:如果需要支持浅色主题或多种配色方案,只需替换COLORS常量即可,UI代码完全不需要改动。

国际化支持:所有用户可见的文案都集中在LABELS配置中,添加新的语言版本只需提供新的文案配置。

设计系统统一:集中管理的颜色和间距确保了整个应用视觉风格的一致性,避免了"到处都是不同色值"的混乱局面。

非开发者友好:产品经理或设计师可以直接修改配置文件来调整文案和颜色,无需触碰业务逻辑代码。

13.3 纯函数工具层的设计

应用中的工具函数(typeColordiffStarspctOffxY等)全部采用纯函数的形式编写——相同的输入总是产生相同的输出,不依赖外部状态,也不产生副作用。

纯函数的优势包括:

可测试性:纯函数的测试极其简单,只需给定输入、验证输出即可,不需要mock任何依赖。

可组合性:纯函数可以自由组合,形成更复杂的函数,而不用担心相互干扰。

可缓存性:对于相同的输入,纯函数的结果可以被缓存(memoization),在计算密集型场景下可以显著提升性能。

易于调试:纯函数的行为完全可预测,出现bug时只需检查输入输出即可定位问题。

在UI开发中,将"展示逻辑"(如颜色映射、格式化、动画计算)抽取为纯函数是一种非常有益的实践——它使得UI组件更加专注于布局和交互,而将计算逻辑交给纯函数处理。

13.4 数组状态更新模式

应用中反复出现的this.xxx = this.xxx.slice(0)模式值得深入讨论。这是ArkUI开发中应对"数组元素属性变更不触发更新"问题的标准解决方案。

为什么需要这样做?因为@State装饰的数组,其变化检测主要针对数组本身的引用变化和元素的增删操作。当你直接修改数组中某个元素的属性时,数组的引用没有改变,元素的数量也没有变化,因此@State可能检测不到这个变化。

解决方案有几种:

  1. 数组重新赋值(本方案):通过slice(0)创建新数组,改变引用,触发更新
  2. @ObjectLink:在子组件中使用@ObjectLink接收对象,实现细粒度更新
  3. @Observed + 整体替换:替换整个对象实例而非修改属性

本应用选择了第一种方案,因为它实现最简单、代码量最少,对于当前的数据规模来说性能也完全足够。但如果数据量很大(如几百条),频繁的数组拷贝可能会带来性能问题,届时需要考虑更优化的方案。

13.5 弹窗的统一管理

应用通过closeAll()方法统一管理所有弹窗的关闭状态,这种集中式管理有几个好处:

状态一致性:确保任何时候最多只有一个弹窗打开,避免了弹窗叠加的混乱局面。

代码简洁:每个业务操作完成后只需调用一次closeAll(),不需要逐一关闭可能打开的弹窗。

易于扩展:如果未来增加新的弹窗类型,只需在closeAll()中添加一行重置代码即可。

同时,每个弹窗都有对应的openXxx()方法,在打开前重置表单状态。这种"打开即重置"的模式确保了用户每次打开弹窗都看到干净的初始状态,避免了上次输入残留导致的混淆。


十四、性能优化与体验提升建议

14.1 列表渲染优化

当前应用使用ForEach渲染列表,对于12条数据的规模来说完全没有性能问题。但如果数据量增长到几十甚至上百条,可以考虑以下优化措施:

使用LazyForEachLazyForEach是ArkUI提供的懒加载列表组件,它只会渲染当前可见的列表项,当列表很长时可以大幅减少内存占用和初始渲染时间。需要实现IDataSource接口来提供数据。

稳定的key生成:当前代码已经为ForEach提供了key函数(如'feed' + s.id.toString()),这是一个很好的实践。稳定的key可以帮助框架正确识别元素的增删改,减少不必要的重建。

减少列表项的重建:可以将列表项抽取为独立的@Component组件,配合@ObjectLink使用,实现细粒度的更新——只有数据真正变化的列表项才会重新渲染。

14.2 动画性能优化

浮动emoji特效使用setInterval驱动,每120毫秒更新一次tick状态。这种方式虽然实现简单,但存在一些性能隐患:

整组件重绘:tick变化会触发整个组件的重新渲染,即使特效层只占UI的一小部分。可以考虑将特效层抽取为独立组件,只在特效组件内部管理tick状态,减少重绘范围。

requestAnimationFrame:在HarmonyOS中,可以考虑使用更平滑的动画API(如animateTo)来实现动画效果,而非手动驱动tick。animateTo可以利用系统的渲染节拍,动画更流畅,CPU占用更低。

特效数量控制:当前有8个浮动emoji,如果进一步增加数量,性能开销会线性增长。建议根据设备性能动态调整特效的数量和复杂度。

14.3 状态管理优化

随着应用功能的增加,单组件中的状态变量会越来越多,可能导致以下问题:

状态分类管理:可以将相关的状态组织成对象(如将所有表单状态封装为AddFormState对象),减少顶层状态变量的数量。

状态下推:将只在某个子组件中使用的状态下推到子组件内部,减少根组件的状态负担。

使用AppStorage:对于需要全局共享的状态(如用户信息、主题设置),可以使用AppStorage进行全局管理,避免在组件间层层传递。

14.4 代码组织优化

当前所有代码都在一个文件中,对于2600+行的规模来说,已经接近单文件可维护性的上限。建议的拆分策略:

按层拆分:将数据模型(interface + class)抽取到独立的models目录,将工具函数抽取到utils目录,将配置抽取到config目录。

按功能拆分组件:将每个页面(推荐、剧本库、组局等)抽取为独立的组件文件,每个组件负责自己的UI和局部状态。

公共组件库:将可复用的UI元素(如按钮、卡片、弹窗框架)抽取为公共组件。


十五、总结与展望

15.1 整体技术评价

本文深入剖析了一个基于HarmonyOS API 24构建的剧本杀社区应用的完整源码。从代码质量和技术实现的角度来看,这个应用展现了相当高的完成度和专业性:

架构清晰:应用采用单组件多Builder的架构,状态集中管理,UI分层明确,数据流单向清晰。对于中小型应用来说,这种架构既简单又高效。

设计考究:从颜色系统的语义化设计,到票券卡片的视觉创意,再到弹窗的分层设计,处处体现了开发者对UI/UX的深入思考。暗色主题配合金色强调色的配色方案,与剧本杀的场景氛围高度契合。

功能完整:六大内容板块(推荐、剧本库、组局、测评、店铺、榜单)加上四种弹窗交互(新增测评、编辑组局、删除确认、发布组局),覆盖了社区类应用的核心功能场景。每个板块都有独特的UI设计,而非简单的列表重复。

细节到位:从边界检查(如人数加减的上下限),到默认值策略(如空标题的自动填充),再到交互反馈(如点赞的多状态变化),大量细节处理体现了开发者的经验和用心。

15.2 技术选型的得与失

选择基于HarmonyOS ArkTS API 24和单文件架构来构建这个应用,是一个典型的"原型优先"策略。这种选择带来的好处是显而易见的:

开发速度快:所有代码在一个文件中,不需要考虑模块划分和依赖管理,开发者可以专注于功能实现本身。

调试效率高:状态集中、逻辑集中,出问题时可以快速定位。

学习成本低:对于刚接触ArkTS的开发者来说,单文件架构是最容易理解和上手的。

但与此同时,这种架构也存在一些固有的局限性:

可扩展性有限:当功能继续增加时,单文件会变得越来越臃肿,代码导航和维护的成本会上升。

复用性不足:所有UI片段都是组件内的@Builder方法,无法在其他项目或组件中复用。

测试困难:由于UI和业务逻辑高度耦合,难以进行独立的单元测试。

对于一个原型项目或中小型应用来说,这些权衡是完全可以接受的。开发效率的提升远远超过了架构"不完美"带来的损失。

15.3 对鸿蒙开发生态的观察

通过分析这个应用,我们可以对HarmonyOS 6.1.1时代的开发生态做一些观察:

声明式UI范式已经成熟:ArkUI的声明式开发范式(struct + @Component + @State)已经非常完善,开发者可以用简洁的代码构建复杂的UI界面。与传统的命令式UI相比,声明式范式的生产力优势是显而易见的。

状态管理体系完善:从@State@Prop@Link@Observed@ObjectLink@Provide@Consume,再到AppStorageLocalStorage,ArkTS提供了层次丰富的状态管理方案,可以满足不同规模应用的需求。

UI组件库丰富度有待提升:虽然基础组件(Text、Column、Row、Scroll等)非常完善,但在复杂组件(如图表、日历、富文本等)方面,生态还不够丰富。开发者经常需要用基础组件自行构建,这既展示了基础组件的灵活性(如本应用中的柱状图),也反映了生态的不足。

动画能力持续增强:从animateTotransition,再到各种属性动画,ArkUI的动画能力在持续增强。但对于复杂的交互动画,仍然需要开发者手动实现较多逻辑。

15.4 后续演进方向

如果这个应用要从原型走向生产级产品,以下几个方向值得重点投入:

后端对接:当前所有数据都是本地硬编码的,生产环境需要对接后端API,实现数据的实时获取和更新。可以使用ArkTS的网络请求能力(如@ohos.net.http)或封装数据访问层。

用户系统:添加用户注册、登录、个人主页等功能,实现真正的社交互动。用户数据可以使用AppStorage进行全局管理。

组件化重构:将单文件架构拆分为多组件、多模块的架构,提升代码的可维护性和可复用性。每个页面独立为一个组件,公共UI抽取为组件库。

性能优化:针对大数据量场景,引入LazyForEach懒加载、图片缓存、预加载等优化手段,确保列表滚动的流畅性。

工程化建设:引入单元测试、集成测试、CI/CD等工程化实践,提升代码质量和发布效率。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

/**
 * ============================================================================
 * 场景:剧本杀探本社区(小红书风格)
 * ----------------------------------------------------------------------------
 * 这是一个小红书 App 风格的剧本杀爱好者社区应用。
 * 用户可以分享探本测评、组局拼车、查询剧本库、给 DM 评分、查看店铺排行。
 *
 * 布局风格:票券式卡片列表 + 分段面板
 * - 主要列表使用"票券"风格卡片:左右两段式布局,左侧色块区(类型/人数)
 *   + 右侧内容区,中间用竖向 Divider 模拟打孔分隔感
 * - 6 个内容 tab 使用完全不同的布局方案:
 *   推荐(票券卡片)/ 剧本库(横向筛选+3列网格)/ 组局(进度条拼车卡)
 *   / 测评(星星+分区评分条)/ 店铺(城市筛选+横向店铺卡)/ 榜单(柱状图+排行列表)
 * - 4 个弹框:新增测评(底部)、编辑组局(居中金边)、删除确认(居中红色警示)、
 *   发布组局(底部大表单)
 * ============================================================================
 */

// ---------------------------------------------------------------------------
// 颜色配置(Interface + const 配套)
// ---------------------------------------------------------------------------
interface ColorPalette {
  primary: string
  bg: string
  card: string
  cardDeep: string
  textPrimary: string
  textSecondary: string
  textMuted: string
  border: string
  accent: string
  accentDim: string
  danger: string
  green: string
  blue: string
  pink: string
  orange: string
}

const COLORS: ColorPalette = {
  primary: '#8B2635',
  bg: '#141021',
  card: '#221A3E',
  cardDeep: '#1A1430',
  textPrimary: '#EDE7F6',
  textSecondary: '#9C8FB8',
  textMuted: '#6B5E8A',
  border: '#3A2E5C',
  accent: '#D4AF37',
  accentDim: '#8A7326',
  danger: '#E14B5A',
  green: '#4CAF7D',
  blue: '#5B7BD5',
  pink: '#D56BA5',
  orange: '#E0913F'
}

// ---------------------------------------------------------------------------
// 标签/文案配置
// ---------------------------------------------------------------------------
interface LabelConfig {
  appTitle: string
  appSubtitle: string
  searchHint: string
  publish: string
  mainTabs: string[]
  mainIcons: string[]
  contentTabs: string[]
  quickTags: string[]
  types: string[]
  cities: string[]
  timeSlots: string[]
}

const LABELS: LabelConfig = {
  appTitle: '探本社',
  appSubtitle: '剧本杀探本社区',
  searchHint: '搜索剧本 / 店铺 / DM',
  publish: '发布',
  mainTabs: ['首页', '发现', '消息', '我的'],
  mainIcons: ['🏠', '🧭', '💬', '👤'],
  contentTabs: ['推荐', '剧本库', '组局', '测评', '店铺', '榜单'],
  quickTags: ['🔥本周热本', '😱恐怖', '💔情感', '🧩硬核推理', '⚔️阵营', '✨新本速递'],
  types: ['情感', '推理', '恐怖', '机制', '阵营'],
  cities: ['全城', '天河区', '越秀区', '海珠区', '番禺区', '荔湾区'],
  timeSlots: ['今天 19:00', '今天 20:30', '明天 14:00', '明天 19:30', '周六 13:00', '周六 19:00']
}

// ---------------------------------------------------------------------------
// 数据模型:interface + @Observed class implements 模式
// ---------------------------------------------------------------------------

/** 剧本 */
interface ScriptInterface {
  id: number
  title: string
  type: string
  players: string
  duration: string
  difficulty: number
  rating: number
  price: number
  tags: string
  desc: string
  hot: number
  isNew: boolean
}

@Observed
class ScriptItem1 implements ScriptInterface {
  id: number = 0
  title: string = ''
  type: string = ''
  players: string = ''
  duration: string = ''
  difficulty: number = 1
  rating: number = 0
  price: number = 0
  tags: string = ''
  desc: string = ''
  hot: number = 0
  isNew: boolean = false

  constructor(o: ScriptInterface) {
    this.id = o.id
    this.title = o.title
    this.type = o.type
    this.players = o.players
    this.duration = o.duration
    this.difficulty = o.difficulty
    this.rating = o.rating
    this.price = o.price
    this.tags = o.tags
    this.desc = o.desc
    this.hot = o.hot
    this.isNew = o.isNew
  }
}

/** 探本测评 */
interface Review {
  id: number
  script: string
  store: string
  author: string
  avatar: string
  score: number
  dmScore: number
  mood: string
  content: string
  likes: number
  liked: boolean
  date: string
}

@Observed
class ReviewItem implements Review {
  id: number = 0
  script: string = ''
  store: string = ''
  author: string = ''
  avatar: string = ''
  score: number = 0
  dmScore: number = 0
  mood: string = ''
  content: string = ''
  likes: number = 0
  liked: boolean = false
  date: string = ''

  constructor(o: Review) {
    this.id = o.id
    this.script = o.script
    this.store = o.store
    this.author = o.author
    this.avatar = o.avatar
    this.score = o.score
    this.dmScore = o.dmScore
    this.mood = o.mood
    this.content = o.content
    this.likes = o.likes
    this.liked = o.liked
    this.date = o.date
  }
}

/** 组局拼车 */
interface Group {
  id: number
  script: string
  store: string
  time: string
  needCount: number
  haveCount: number
  host: string
  hostAvatar: string
  city: string
  fee: number
  status: string
}

@Observed
class GroupItem implements Group {
  id: number = 0
  script: string = ''
  store: string = ''
  time: string = ''
  needCount: number = 0
  haveCount: number = 0
  host: string = ''
  hostAvatar: string = ''
  city: string = ''
  fee: number = 0
  status: string = ''

  constructor(o: Group) {
    this.id = o.id
    this.script = o.script
    this.store = o.store
    this.time = o.time
    this.needCount = o.needCount
    this.haveCount = o.haveCount
    this.host = o.host
    this.hostAvatar = o.hostAvatar
    this.city = o.city
    this.fee = o.fee
    this.status = o.status
  }
}

/** 店铺 */
interface Store {
  id: number
  name: string
  city: string
  area: string
  rating: number
  rooms: number
  priceLevel: number
  tags: string
  dmCount: number
  distance: string
  hot: number
}

@Observed
class StoreItem implements Store {
  id: number = 0
  name: string = ''
  city: string = ''
  area: string = ''
  rating: number = 0
  rooms: number = 0
  priceLevel: number = 0
  tags: string = ''
  dmCount: number = 0
  distance: string = ''
  hot: number = 0

  constructor(o: Store) {
    this.id = o.id
    this.name = o.name
    this.city = o.city
    this.area = o.area
    this.rating = o.rating
    this.rooms = o.rooms
    this.priceLevel = o.priceLevel
    this.tags = o.tags
    this.dmCount = o.dmCount
    this.distance = o.distance
    this.hot = o.hot
  }
}

/** 榜单条目 */
interface Rank {
  id: number
  name: string
  score: number
  badge: string
  type: string
}

@Observed
class RankItem implements Rank {
  id: number = 0
  name: string = ''
  score: number = 0
  badge: string = ''
  type: string = ''

  constructor(o: Rank) {
    this.id = o.id
    this.name = o.name
    this.score = o.score
    this.badge = o.badge
    this.type = o.type
  }
}

// ---------------------------------------------------------------------------
// 写死的列表数据(每类 12 条)
// ---------------------------------------------------------------------------

const SCRIPTS_DATA: ScriptInterface[] = [
  { id: 1, title: '年轮', type: '情感', players: '4男3女', duration: '5小时', difficulty: 3, rating: 4.9, price: 128, tags: '催泪,古风,沉浸', desc: '跨越三生三世的羁绊,纸巾请备足三包。', hot: 98, isNew: false },
  { id: 2, title: '雾山夜行', type: '推理', players: '6人', duration: '5.5小时', difficulty: 5, rating: 4.8, price: 158, tags: '硬核,本格,密室', desc: '暴风雪山庄模式,三重密室诡计层层反转。', hot: 95, isNew: false },
  { id: 3, title: '纸嫁衣', type: '恐怖', players: '5人', duration: '4小时', difficulty: 2, rating: 4.7, price: 108, tags: '中式恐怖,换装,NPC', desc: '中式冥婚题材,NPC贴脸杀预警。', hot: 93, isNew: false },
  { id: 4, title: '孤城闭', type: '阵营', players: '7人', duration: '6小时', difficulty: 4, rating: 4.6, price: 138, tags: '宫斗,权谋,对抗', desc: '宫廷权谋阵营本,辩环节堪比辩论赛。', hot: 90, isNew: false },
  { id: 5, title: '第七号档案', type: '机制', players: '6人', duration: '5小时', difficulty: 4, rating: 4.5, price: 148, tags: '跑团,搜证,博弈', desc: '机制跑团本,地图探索自由度极高。', hot: 88, isNew: false },
  { id: 6, title: '长夜余晖', type: '情感', players: '3男3女', duration: '4.5小时', difficulty: 3, rating: 4.8, price: 118, tags: '亲情,治愈,现代', desc: '父辈青春故事,散场后想给爸爸打电话。', hot: 87, isNew: true },
  { id: 7, title: '退Crowd', type: '推理', players: '6人', duration: '5小时', difficulty: 5, rating: 4.4, price: 168, tags: '变格,烧脑,高阶', desc: '变格推理天花板,建议进阶玩家挑战。', hot: 82, isNew: false },
  { id: 8, title: '殡仪馆之夜', type: '恐怖', players: '4人', duration: '3.5小时', difficulty: 3, rating: 4.6, price: 98, tags: '追逐,单人任务,黑暗', desc: '单人任务环节不建议心脏不好者体验。', hot: 80, isNew: false },
  { id: 9, title: '海上来客', type: '阵营', players: '8人', duration: '6.5小时', difficulty: 4, rating: 4.3, price: 158, tags: '海盗,谈判,阵营', desc: '八人大型阵营本,谈判与背叛的艺术。', hot: 76, isNew: false },
  { id: 10, title: '默剧之城', type: '机制', players: '5人', duration: '4小时', difficulty: 2, rating: 4.5, price: 88, tags: '新手友好,欢乐,拍卖', desc: '拍卖机制欢乐本,新手入坑首选。', hot: 74, isNew: true },
  { id: 11, title: '四月物语', type: '情感', players: '2男2女', duration: '4小时', difficulty: 2, rating: 4.7, price: 108, tags: '青春,恋爱,微醺', desc: '四月樱花下的双向暗恋,甜到犯规。', hot: 71, isNew: false },
  { id: 12, title: '无人生还之地', type: '恐怖', players: '6人', duration: '5小时', difficulty: 4, rating: 4.5, price: 128, tags: '废土,生存,重恐', desc: '废土生存恐怖本,全程高压氛围。', hot: 68, isNew: true }
]

const REVIEWS_DATA: Review[] = [
  { id: 1, script: '年轮', store: '迷雾剧场·天河店', author: '柚子不吃柚', avatar: '🍊', score: 5, dmScore: 5, mood: '哭到隐形眼镜都掉了', content: 'DM小K代入感绝了,结尾独白直接破防。', likes: 328, liked: false, date: '2小时前' },
  { id: 2, script: '雾山夜行', store: '逻辑之门推理馆', author: '福尔摩苏', avatar: '🔍', score: 5, dmScore: 4, mood: '脑细胞全灭但很爽', content: '三重密室环环相扣,复盘一小时才懂全貌。', likes: 256, liked: false, date: '5小时前' },
  { id: 3, script: '纸嫁衣', store: '夜半惊魂馆', author: '胆小鬼阿宁', avatar: '🕯️', score: 4, dmScore: 5, mood: '全程抱住隔壁小姐姐', content: 'NPC太敬业了,贴脸那下我魂都飞了。', likes: 198, liked: false, date: '8小时前' },
  { id: 4, script: '孤城闭', store: '长安夜话剧本社', author: '宫斗十级学者', avatar: '👑', score: 5, dmScore: 4, mood: '辩到嗓子哑', content: '阵营对抗拉满,最后翻盘那一刻全场沸腾。', likes: 176, liked: false, date: '昨天' },
  { id: 5, script: '第七号档案', store: '迷雾剧场·天河店', author: '跑团老炮儿', avatar: '🎲', score: 4, dmScore: 5, mood: '地图探索太自由', content: '机制本里少见的电影感,DM控场满分。', likes: 154, liked: false, date: '昨天' },
  { id: 6, script: '长夜余晖', store: '拾光剧本馆', author: '爸爸的小棉袄', avatar: '📞', score: 5, dmScore: 4, mood: '散场给爸打了电话', content: '父辈的青春比想象中滚烫,纸巾备三包。', likes: 142, liked: false, date: '昨天' },
  { id: 7, script: '殡仪馆之夜', store: '夜半惊魂馆', author: '铁血真汉子', avatar: '💀', score: 4, dmScore: 5, mood: '单人任务差点报警', content: '黑暗里只有手电和心跳,建议结伴玩。', likes: 133, liked: false, date: '2天前' },
  { id: 8, script: '海上来客', store: '港湾戏剧社', author: '船长不上岸', avatar: '⚓', score: 4, dmScore: 4, mood: '谈判桌上的艺术', content: '八人局节奏不塌,背叛环节笑声不断。', likes: 121, liked: false, date: '2天前' },
  { id: 9, script: '默剧之城', store: '欢乐岛剧本店', author: '萌新探险家', avatar: '🎈', score: 5, dmScore: 4, mood: '新手入坑不踩雷', content: '拍卖环节笑到拍桌,新手友好度满分。', likes: 108, liked: false, date: '3天前' },
  { id: 10, script: '四月物语', store: '拾光剧本馆', author: '樱花汽水', avatar: '🌸', score: 5, dmScore: 5, mood: '甜到原地转圈', content: '双向暗恋太好嗑了,和对象一起玩更甜。', likes: 97, liked: false, date: '3天前' },
  { id: 11, script: '退Crowd', store: '逻辑之门推理馆', author: '变格信徒', avatar: '🌀', score: 4, dmScore: 4, mood: '进阶玩家请就位', content: '变格设定烧脑,新手慎入,老手狂喜。', likes: 86, liked: false, date: '4天前' },
  { id: 12, script: '无人生还之地', store: '废土2049馆', author: '生存狂魔', avatar: '☢️', score: 4, dmScore: 5, mood: '高压氛围喘不过气', content: '废土布景震撼,资源博弈扣人心弦。', likes: 79, liked: false, date: '5天前' }
]

const GROUPS_DATA: Group[] = [
  { id: 1, script: '年轮', store: '迷雾剧场·天河店', time: '今天 19:00', needCount: 7, haveCount: 5, host: '柚子不吃柚', hostAvatar: '🍊', city: '天河区', fee: 128, status: '招募中' },
  { id: 2, script: '雾山夜行', store: '逻辑之门推理馆', time: '今天 20:30', needCount: 6, haveCount: 6, host: '福尔摩苏', hostAvatar: '🔍', city: '越秀区', fee: 158, status: '已满' },
  { id: 3, script: '纸嫁衣', store: '夜半惊魂馆', time: '明天 14:00', needCount: 5, haveCount: 2, host: '胆小鬼阿宁', hostAvatar: '🕯️', city: '海珠区', fee: 108, status: '招募中' },
  { id: 4, script: '孤城闭', store: '长安夜话剧本社', time: '明天 19:30', needCount: 7, haveCount: 4, host: '宫斗十级学者', hostAvatar: '👑', city: '天河区', fee: 138, status: '招募中' },
  { id: 5, script: '第七号档案', store: '迷雾剧场·天河店', time: '周六 13:00', needCount: 6, haveCount: 3, host: '跑团老炮儿', hostAvatar: '🎲', city: '天河区', fee: 148, status: '招募中' },
  { id: 6, script: '长夜余晖', store: '拾光剧本馆', time: '周六 19:00', needCount: 6, haveCount: 6, host: '爸爸的小棉袄', hostAvatar: '📞', city: '番禺区', fee: 118, status: '开局' },
  { id: 7, script: '殡仪馆之夜', store: '夜半惊魂馆', time: '今天 22:00', needCount: 4, haveCount: 1, host: '铁血真汉子', hostAvatar: '💀', city: '海珠区', fee: 98, status: '招募中' },
  { id: 8, script: '海上来客', store: '港湾戏剧社', time: '明天 13:30', needCount: 8, haveCount: 5, host: '船长不上岸', hostAvatar: '⚓', city: '荔湾区', fee: 158, status: '招募中' },
  { id: 9, script: '默剧之城', store: '欢乐岛剧本店', time: '明天 15:00', needCount: 5, haveCount: 4, host: '萌新探险家', hostAvatar: '🎈', city: '越秀区', fee: 88, status: '招募中' },
  { id: 10, script: '四月物语', store: '拾光剧本馆', time: '周六 16:00', needCount: 4, haveCount: 3, host: '樱花汽水', hostAvatar: '🌸', city: '番禺区', fee: 108, status: '招募中' },
  { id: 11, script: '退Crowd', store: '逻辑之门推理馆', time: '周日 14:00', needCount: 6, haveCount: 2, host: '变格信徒', hostAvatar: '🌀', city: '越秀区', fee: 168, status: '招募中' },
  { id: 12, script: '无人生还之地', store: '废土2049馆', time: '周日 19:00', needCount: 6, haveCount: 4, host: '生存狂魔', hostAvatar: '☢️', city: '荔湾区', fee: 128, status: '招募中' }
]

const STORES_DATA: Store[] = [
  { id: 1, name: '迷雾剧场·天河店', city: '天河区', area: '珠江新城', rating: 4.9, rooms: 14, priceLevel: 3, tags: '沉浸演绎,金牌DM', dmCount: 9, distance: '1.2km', hot: 99 },
  { id: 2, name: '逻辑之门推理馆', city: '越秀区', area: '北京路', rating: 4.8, rooms: 10, priceLevel: 3, tags: '硬核推理,安静房间', dmCount: 7, distance: '3.5km', hot: 95 },
  { id: 3, name: '夜半惊魂馆', city: '海珠区', area: '江南西', rating: 4.7, rooms: 11, priceLevel: 2, tags: '重恐,NPC多,机关房', dmCount: 8, distance: '5.8km', hot: 93 },
  { id: 4, name: '长安夜话剧本社', city: '天河区', area: '体育西', rating: 4.6, rooms: 12, priceLevel: 3, tags: '古风换装,汉服体验', dmCount: 6, distance: '2.4km', hot: 90 },
  { id: 5, name: '拾光剧本馆', city: '番禺区', area: '市桥', rating: 4.8, rooms: 9, priceLevel: 2, tags: '情感本专门店,温馨', dmCount: 5, distance: '12.6km', hot: 88 },
  { id: 6, name: '港湾戏剧社', city: '荔湾区', area: '西关', rating: 4.5, rooms: 8, priceLevel: 2, tags: '大阵营本,多人局', dmCount: 6, distance: '8.1km', hot: 84 },
  { id: 7, name: '欢乐岛剧本店', city: '越秀区', area: '淘金', rating: 4.6, rooms: 7, priceLevel: 1, tags: '新手友好,高性价比', dmCount: 4, distance: '4.2km', hot: 80 },
  { id: 8, name: '废土2049馆', city: '荔湾区', area: '芳村', rating: 4.5, rooms: 10, priceLevel: 3, tags: '赛博布景,机关密室', dmCount: 7, distance: '10.3km', hot: 78 },
  { id: 9, name: '月见黑事务所', city: '天河区', area: '五山', rating: 4.4, rooms: 6, priceLevel: 2, tags: '深夜场,恐怖特调', dmCount: 4, distance: '6.7km', hot: 72 },
  { id: 10, name: '纸鸢古风馆', city: '海珠区', area: '客村', rating: 4.6, rooms: 8, priceLevel: 3, tags: '全古风,茶歇赠饮', dmCount: 5, distance: '5.1km', hot: 70 },
  { id: 11, name: '迷雾剧场·番禺店', city: '番禺区', area: '万博', rating: 4.7, rooms: 13, priceLevel: 3, tags: '连锁品质,停车方便', dmCount: 8, distance: '14.2km', hot: 68 },
  { id: 12, name: '第七印象剧本社', city: '天河区', area: '车陂', rating: 4.3, rooms: 5, priceLevel: 1, tags: '平价拼场,学生党', dmCount: 3, distance: '7.9km', hot: 62 }
]

const RANKS_DATA: Rank[] = [
  { id: 1, name: '迷雾剧场·天河店', score: 99, badge: '🏆', type: '店铺热榜' },
  { id: 2, name: '年轮', score: 98, badge: '🥇', type: '剧本热榜' },
  { id: 3, name: 'DM小K', score: 97, badge: '🎖️', type: 'DM评分榜' },
  { id: 4, name: '柚子不吃柚', score: 96, badge: '👑', type: '作者达人榜' },
  { id: 5, name: '雾山夜行', score: 95, badge: '🥈', type: '剧本热榜' },
  { id: 6, name: '逻辑之门推理馆', score: 95, badge: '🏆', type: '店铺热榜' },
  { id: 7, name: 'DM阿鬼', score: 94, badge: '🎖️', type: 'DM评分榜' },
  { id: 8, name: '纸嫁衣', score: 93, badge: '🥉', type: '剧本热榜' },
  { id: 9, name: '福尔摩苏', score: 92, badge: '👑', type: '作者达人榜' },
  { id: 10, name: '夜半惊魂馆', score: 93, badge: '🏆', type: '店铺热榜' },
  { id: 11, name: 'DM月见', score: 91, badge: '🎖️', type: 'DM评分榜' },
  { id: 12, name: '孤城闭', score: 90, badge: '⭐', type: '剧本热榜' }
]

// ---------------------------------------------------------------------------
// 全局纯函数(供 UI 内表达式调用)
// ---------------------------------------------------------------------------

/** 剧本类型对应的主题色 */
function typeColor(t: string): string {
  if (t === '情感') {
    return COLORS.pink
  }
  if (t === '推理') {
    return COLORS.blue
  }
  if (t === '恐怖') {
    return COLORS.danger
  }
  if (t === '机制') {
    return COLORS.accent
  }
  return COLORS.orange
}

/** 难度星级(实心星) */
function diffStars(d: number): string {
  if (d >= 5) {
    return '★★★★★'
  }
  if (d === 4) {
    return '★★★★☆'
  }
  if (d === 3) {
    return '★★★☆☆'
  }
  if (d === 2) {
    return '★★☆☆☆'
  }
  return '★☆☆☆☆'
}

/** 评分星星(5分制,四舍五入成实心) */
function scoreStars(s: number): string {
  if (s >= 5) {
    return '★★★★★'
  }
  if (s >= 4.5) {
    return '★★★★✩'
  }
  if (s >= 4) {
    return '★★★★☆'
  }
  return '★★★☆☆'
}

/** 满员进度百分比 */
function pctOf(have: number, need: number): number {
  if (need <= 0) {
    return 0
  }
  if (have >= need) {
    return 100
  }
  return Math.round(have * 100 / need)
}

/** 组局状态颜色 */
function statusColor(s: string): string {
  if (s === '招募中') {
    return COLORS.green
  }
  if (s === '已满') {
    return COLORS.orange
  }
  return COLORS.accent
}

/** 价格等级显示 */
function priceLevelText(l: number): string {
  if (l >= 3) {
    return '¥¥¥'
  }
  if (l === 2) {
    return '¥¥'
  }
  return '¥'
}

/** 特效层:纵向浮动偏移 */
function fxY(i: number, tick: number): number {
  return 40 + ((tick + i * 37) % 100) * 4
}

/** 特效层:旋转角度 */
function fxRotate(i: number, tick: number): number {
  return ((tick * (i + 2)) % 60) - 30
}

/** 特效层:透明度呼吸 */
function fxOpacity(i: number, tick: number): number {
  return 0.25 + ((tick + i * 11) % 40) / 80
}

/** 特效层:缩放 */
function fxScale(i: number, tick: number): number {
  return 0.9 + ((tick + i * 7) % 30) / 60
}

/** 特效层:横向位置 */
function fxX(i: number): number {
  return 30 + i * 58
}

/** 热度 TOP5 剧本(柱状图数据) */
function topHotScripts(): ScriptItem1[] {
  const all: ScriptItem1[] = scriptData.slice(0)
  all.sort((a: ScriptItem1, b: ScriptItem1): number => b.hot - a.hot)
  return all.slice(0, 5)
}

/** 柱状图最大高度像素 */
function barHeight(hot: number, maxHot: number): number {
  if (maxHot <= 0) {
    return 10
  }
  return Math.max(12, Math.round(hot * 110 / maxHot))
}

/** 根据剧本类型筛选(供剧本库 tab 使用) */
function filterScriptsByType(t: string): ScriptItem1[] {
  if (t === '全部') {
    return scriptData
  }
  const res: ScriptItem1[] = []
  for (let i = 0; i < scriptData.length; i++) {
    if (scriptData[i].type === t) {
      res.push(scriptData[i])
    }
  }
  return res
}

/** 根据城市筛选店铺 */
function filterStoresByCity(c: string): StoreItem[] {
  if (c === '全城') {
    return storeData
  }
  const res: StoreItem[] = []
  for (let i = 0; i < storeData.length; i++) {
    if (storeData[i].city === c) {
      res.push(storeData[i])
    }
  }
  return res
}

/** 构造初始实例数据 */
function buildScriptData(): ScriptItem1[] {
  const arr: ScriptItem1[] = []
  for (let i = 0; i < SCRIPTS_DATA.length; i++) {
    arr.push(new ScriptItem1(SCRIPTS_DATA[i]))
  }
  return arr
}

function buildReviewData(): ReviewItem[] {
  const arr: ReviewItem[] = []
  for (let i = 0; i < REVIEWS_DATA.length; i++) {
    arr.push(new ReviewItem(REVIEWS_DATA[i]))
  }
  return arr
}

function buildGroupData(): GroupItem[] {
  const arr: GroupItem[] = []
  for (let i = 0; i < GROUPS_DATA.length; i++) {
    arr.push(new GroupItem(GROUPS_DATA[i]))
  }
  return arr
}

function buildStoreData(): StoreItem[] {
  const arr: StoreItem[] = []
  for (let i = 0; i < STORES_DATA.length; i++) {
    arr.push(new StoreItem(STORES_DATA[i]))
  }
  return arr
}

function buildRankData(): RankItem[] {
  const arr: RankItem[] = []
  for (let i = 0; i < RANKS_DATA.length; i++) {
    arr.push(new RankItem(RANKS_DATA[i]))
  }
  return arr
}

const scriptData: ScriptItem1[] = buildScriptData()
const reviewData: ReviewItem[] = buildReviewData()
const groupData: GroupItem[] = buildGroupData()
const storeData: StoreItem[] = buildStoreData()
const rankData: RankItem[] = buildRankData()

/** 特效层 emoji 列表 */
const FX_EMOJIS: string[] = ['🔍', '🕯️', '🎭', '🔪', '✨', '📖', '🩸', '🎲']

// ---------------------------------------------------------------------------
// 入口组件
// ---------------------------------------------------------------------------
@Entry
@Component
struct MurderMysteryApp {
  @State curTab: number = 0
  @State mainTab: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State bizOpen: boolean = false
  @State tick: number = 0

  // 列表状态
  @State scripts: ScriptItem1[] = scriptData
  @State reviews: ReviewItem[] = reviewData
  @State groups: GroupItem[] = groupData
  @State stores: StoreItem[] = storeData
  @State ranks: RankItem[] = rankData

  // 剧本库筛选
  @State libFilter: string = '全部'

  // 店铺城市筛选
  @State cityFilter: string = '全城'

  // 搜索
  @State keyword: string = ''

  // 新增测评表单
  @State addTitle: string = ''
  @State addType: string = '情感'
  @State addDifficulty: number = 3
  @State addScore: number = 5
  @State addContent: string = ''

  // 编辑组局表单
  @State editIdx: number = -1
  @State editScriptName: string = ''
  @State editTime: string = '今天 19:00'
  @State editNeed: number = 6
  @State editFee: string = ''
  @State editNote: string = ''

  // 删除确认
  @State delKind: string = '测评'
  @State delName: string = ''

  // 发布组局表单
  @State bizScript: string = ''
  @State bizStore: string = ''
  @State bizCity: string = '天河区'
  @State bizTime: string = '今天 19:00'
  @State bizFee: string = ''
  @State bizContact: string = ''

  private timer: number = -1

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

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

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

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

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

  /** 打开新增测评弹框 */
  openAdd(): void {
    this.addTitle = ''
    this.addType = '情感'
    this.addDifficulty = 3
    this.addScore = 5
    this.addContent = ''
    this.addOpen = true
  }

  /** 打开编辑组局弹框 */
  openEdit(i: number): void {
    this.editIdx = i
    this.editScriptName = this.groups[i].script
    this.editTime = this.groups[i].time
    this.editNeed = this.groups[i].needCount
    this.editFee = this.groups[i].fee.toString()
    this.editNote = ''
    this.editOpen = true
  }

  /** 打开删除确认弹框 */
  openDel(kind: string, name: string): void {
    this.delKind = kind
    this.delName = name
    this.delOpen = true
  }

  /** 打开发布组局弹框 */
  openBiz(): void {
    this.bizScript = ''
    this.bizStore = ''
    this.bizCity = '天河区'
    this.bizTime = '今天 19:00'
    this.bizFee = ''
    this.bizContact = ''
    this.bizOpen = true
  }

  /** 提交新增测评 */
  submitAdd(): void {
    if (this.addTitle.length === 0) {
      this.addTitle = '未命名剧本'
    }
    const item: ReviewItem = new ReviewItem({
      id: this.reviews.length + 1,
      script: this.addTitle,
      store: '拾光剧本馆',
      author: '我',
      avatar: '🎭',
      score: this.addScore,
      dmScore: this.addDifficulty,
      mood: '刚探完本',
      content: this.addContent.length > 0 ? this.addContent : '值得一刷的新本!',
      likes: 0,
      liked: false,
      date: '刚刚'
    })
    this.reviews.unshift(item)
    this.closeAll()
  }

  /** 提交编辑组局 */
  submitEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.groups.length) {
      const g: GroupItem = this.groups[this.editIdx]
      g.time = this.editTime
      g.needCount = this.editNeed
      const fee: number = this.editFee.length > 0 ? Number(this.editFee) : g.fee
      g.fee = fee
      if (this.editNote.length > 0) {
        g.script = g.script + ' ·' + this.editNote
      }
      this.groups = this.groups.slice(0)
    }
    this.closeAll()
  }

  /** 确认删除 */
  confirmDel(): void {
    if (this.delKind === '测评') {
      this.reviews = this.reviews.slice(1)
    } else if (this.delKind === '组局') {
      this.groups = this.groups.slice(1)
    } else {
      this.stores = this.stores.slice(1)
    }
    this.closeAll()
  }

  /** 提交发布组局 */
  submitBiz(): void {
    if (this.bizScript.length === 0) {
      this.bizScript = '未定剧本'
    }
    const item: GroupItem = new GroupItem({
      id: this.groups.length + 1,
      script: this.bizScript,
      store: this.bizStore.length > 0 ? this.bizStore : '待定店铺',
      time: this.bizTime,
      needCount: 6,
      haveCount: 1,
      host: '我',
      hostAvatar: '🎭',
      city: this.bizCity,
      fee: this.bizFee.length > 0 ? Number(this.bizFee) : 128,
      status: '招募中'
    })
    this.groups.unshift(item)
    this.closeAll()
    this.curTab = 2
  }

  /** 点赞测评 */
  toggleLike(i: number): void {
    const r: ReviewItem = this.reviews[i]
    r.liked = !r.liked
    if (r.liked) {
      r.likes = r.likes + 1
    } else {
      r.likes = r.likes - 1
    }
    this.reviews = this.reviews.slice(0)
  }

  /** 加入组局 */
  joinGroup(i: number): void {
    const g: GroupItem = this.groups[i]
    if (g.haveCount < g.needCount) {
      g.haveCount = g.haveCount + 1
      if (g.haveCount >= g.needCount) {
        g.status = '已满'
      }
      this.groups = this.groups.slice(0)
    }
  }

  // -------------------------------------------------------------------------
  // 特效层:浮动 emoji
  // -------------------------------------------------------------------------
  @Builder
  fxLayer() {
    Column() {
      ForEach(FX_EMOJIS, (e: string, i: number) => {
        Text(e)
          .fontSize(18)
          .opacity(fxOpacity(i, this.tick))
          .rotate({ angle: fxRotate(i, this.tick) })
          .scale({ x: fxScale(i, this.tick), y: fxScale(i, this.tick) })
          .position({ x: fxX(i), y: fxY(i, this.tick) })
      }, (e: string, i: number) => 'fx' + i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  // -------------------------------------------------------------------------
  // 头部:静态深紫渐变 + 搜索栏 + 类型快捷标签
  // -------------------------------------------------------------------------
  @Builder
  header() {
    Column() {
      Row() {
        Column() {
          Text(LABELS.appTitle)
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
          Text(LABELS.appSubtitle)
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)

        Row() {
          Text('🔔')
            .fontSize(20)
            .onClick(() => {
              this.mainTab = 2
            })
        }
        .padding(8)
        .borderRadius(20)
        .backgroundColor('#33264880')
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .alignItems(VerticalAlign.Center)
      .padding({ left: 16, right: 16, top: 12, bottom: 8 })

      Row() {
        Text('🔍')
          .fontSize(16)
          .margin({ left: 12 })
        TextInput({ placeholder: LABELS.searchHint, text: this.keyword })
          .placeholderColor(COLORS.textMuted)
          .placeholderFont({ size: 13 })
          .fontColor(COLORS.textPrimary)
          .fontSize(13)
          .backgroundColor(Color.Transparent)
          .layoutWeight(1)
          .onChange((v: string) => {
            this.keyword = v
          })
      }
      .width('92%')
      .height(38)
      .borderRadius(19)
      .backgroundColor('#2A1F4A')
      .border({ width: 1, color: COLORS.border })
      .alignItems(VerticalAlign.Center)

      Scroll() {
        Row() {
          ForEach(LABELS.quickTags, (t: string, i: number) => {
            Text(t)
              .fontSize(11)
              .fontColor(i === 0 ? COLORS.accent : COLORS.textSecondary)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .margin({ right: 8 })
              .borderRadius(14)
              .backgroundColor(i === 0 ? '#3D3212' : '#2A2148')
              .border({ width: 1, color: i === 0 ? COLORS.accentDim : COLORS.border })
              .onClick(() => {
                if (i === 1) {
                  this.libFilter = '恐怖'
                  this.switchTab(1)
                } else if (i === 2) {
                  this.libFilter = '情感'
                  this.switchTab(1)
                } else if (i === 3) {
                  this.libFilter = '推理'
                  this.switchTab(1)
                } else if (i === 4) {
                  this.libFilter = '阵营'
                  this.switchTab(1)
                } else {
                  this.switchTab(0)
                }
              })
          }, (t: string) => 'qk' + t)
        }
        .padding({ left: 16, right: 16 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ top: 10 })
    }
    .width('100%')
    .padding({ bottom: 12 })
    .alignItems(HorizontalAlign.Center)
    .linearGradient({
      angle: 160,
      colors: [['#241244', 0], ['#1A1033', 0.6], ['#8B263580', 1]]
    })
  }

  // -------------------------------------------------------------------------
  // 内容 tab 栏:两排 6 个
  // -------------------------------------------------------------------------
  @Builder
  tabBar() {
    Column() {
      Row() {
        ForEach(LABELS.contentTabs.slice(0, 3), (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(13)
              .fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.curTab === i ? COLORS.accent : COLORS.textSecondary)
            Divider()
              .width(this.curTab === i ? 18 : 0)
              .height(2)
              .color(COLORS.accent)
              .margin({ top: 4 })
          }
          .padding({ left: 10, right: 10, top: 6, bottom: 4 })
          .onClick(() => {
            this.switchTab(i)
          })
        }, (t: string) => 'tb0' + t)
      }
      .justifyContent(FlexAlign.SpaceEvenly)
      .width('100%')

      Row() {
        ForEach(LABELS.contentTabs.slice(3, 6), (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(13)
              .fontWeight(this.curTab === i + 3 ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.curTab === i + 3 ? COLORS.accent : COLORS.textSecondary)
            Divider()
              .width(this.curTab === i + 3 ? 18 : 0)
              .height(2)
              .color(COLORS.accent)
              .margin({ top: 4 })
          }
          .padding({ left: 10, right: 10, top: 4, bottom: 6 })
          .onClick(() => {
            this.switchTab(i + 3)
          })
        }, (t: string) => 'tb1' + t)
      }
      .justifyContent(FlexAlign.SpaceEvenly)
      .width('100%')
    }
    .width('100%')
    .backgroundColor(COLORS.cardDeep)
    .border({ width: 1, color: COLORS.border })
  }

  // -------------------------------------------------------------------------
  // Tab 0 推荐:票券式卡片列表(左色块 + 竖 Divider + 右内容)
  // -------------------------------------------------------------------------
  @Builder
  ticketCard(s: ScriptItem1) {
    Row() {
      // 左侧色块区(票根)
      Column() {
        Text(s.type)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
        Divider()
          .width(24)
          .height(1)
          .color('#FFFFFF66')
          .margin({ top: 6, bottom: 6 })
        Text(s.players)
          .fontSize(11)
          .fontColor('#FFFFFFCC')
        Text(s.duration)
          .fontSize(10)
          .fontColor('#FFFFFF99')
          .margin({ top: 2 })
        Text('¥' + s.price.toString())
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)
          .margin({ top: 8 })
        if (s.isNew) {
          Text('NEW')
            .fontSize(9)
            .fontColor(Color.White)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(6)
            .backgroundColor('#FFFFFF33')
            .margin({ top: 6 })
        }
      }
      .width(92)
      .padding(10)
      .height('100%')
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
      .backgroundColor(typeColor(s.type))
      .borderRadius({ topLeft: 14, bottomLeft: 14 })

      // 打孔分隔感:竖向 Divider(上下留白 + 半透明)
      Column() {
        Divider()
          .vertical(true)
          .height('100%')
          .width(2)
          .color(COLORS.bg)
      }
      .width(4)
      .height('100%')
      .backgroundColor(COLORS.bg)
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)

      // 右侧内容区
      Column() {
        Row() {
          Text(s.title)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(s.rating.toFixed(1))
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)

        Text(s.desc)
          .fontSize(12)
          .fontColor(COLORS.textSecondary)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .margin({ top: 6 })

        Row() {
          Text(s.tags)
            .fontSize(10)
            .fontColor(typeColor(s.type))
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(10)
            .backgroundColor('#2A2148')
            .layoutWeight(1)
            .maxLines(1)
        }
        .width('100%')
        .margin({ top: 8 })

        Row() {
          Text('难度 ' + diffStars(s.difficulty))
            .fontSize(11)
            .fontColor(COLORS.textMuted)
          Text('🔥 热度 ' + s.hot.toString())
            .fontSize(11)
            .fontColor(COLORS.orange)
          Text('组局')
            .fontSize(11)
            .fontColor(Color.White)
            .padding({ left: 12, right: 12, top: 4, bottom: 4 })
            .borderRadius(12)
            .backgroundColor(COLORS.primary)
            .onClick(() => {
              this.bizScript = s.title
              this.openBiz()
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .alignItems(VerticalAlign.Center)
        .margin({ top: 8 })
      }
      .layoutWeight(1)
      .padding(12)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .height(132)
    .borderRadius(14)
    .backgroundColor(COLORS.card)
    .border({ width: 1, color: COLORS.border })
    .clip(true)
    .margin({ bottom: 12 })
  }

  @Builder
  pageFeed() {
    Column() {
      // 顶部提示条
      Row() {
        Text('🎭 今日精选票根 · 每日 10 点更新')
          .fontSize(11)
          .fontColor(COLORS.accent)
        Text('换一批')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .onClick(() => {
            const first: ScriptItem1 = this.scripts[0]
            this.scripts = this.scripts.slice(1)
            this.scripts.push(first)
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ left: 4, right: 4, bottom: 10 })

      ForEach(this.scripts, (s: ScriptItem1, i: number) => {
        this.ticketCard(s)
      }, (s: ScriptItem1) => 'feed' + s.id.toString())
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12 })
  }

  // -------------------------------------------------------------------------
  // Tab 1 剧本库:横向滚动类型筛选 + 3 列网格卡片
  // -------------------------------------------------------------------------
  @Builder
  pageLibrary() {
    Column() {
      Scroll() {
        Row() {
          Text('全部')
            .fontSize(12)
            .fontColor(this.libFilter === '全部' ? Color.White : COLORS.textSecondary)
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .margin({ right: 8 })
            .borderRadius(16)
            .backgroundColor(this.libFilter === '全部' ? COLORS.primary : '#2A2148')
            .onClick(() => {
              this.libFilter = '全部'
            })
          ForEach(LABELS.types, (t: string) => {
            Text(t)
              .fontSize(12)
              .fontColor(this.libFilter === t ? Color.White : COLORS.textSecondary)
              .padding({ left: 14, right: 14, top: 6, bottom: 6 })
              .margin({ right: 8 })
              .borderRadius(16)
              .backgroundColor(this.libFilter === t ? typeColor(t) : '#2A2148')
              .onClick(() => {
                this.libFilter = t
              })
          }, (t: string) => 'libf' + t)
        }
        .padding({ left: 14, right: 14 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ top: 12, bottom: 12 })

      Text('共 ' + filterScriptsByType(this.libFilter).length.toString() + ' 部 · ' + this.libFilter)
        .fontSize(11)
        .fontColor(COLORS.textMuted)
        .width('100%')
        .textAlign(TextAlign.Start)
        .padding({ left: 14, bottom: 8 })

      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(filterScriptsByType(this.libFilter), (s: ScriptItem1) => {
          Column() {
            Column() {
              Text(s.type)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(Color.White)
              Text(diffStars(s.difficulty))
                .fontSize(10)
                .fontColor('#FFFFFFAA')
                .margin({ top: 4 })
            }
            .width('100%')
            .height(58)
            .justifyContent(FlexAlign.Center)
            .backgroundColor(typeColor(s.type))
            .borderRadius({ topLeft: 10, topRight: 10 })

            Column() {
              Text(s.title)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
                .width('100%')
              Text(s.players + ' · ' + s.duration)
                .fontSize(10)
                .fontColor(COLORS.textMuted)
                .margin({ top: 4 })
                .width('100%')
              Row() {
                Text('★' + s.rating.toFixed(1))
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.accent)
                Text('¥' + s.price.toString())
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
              }
              .width('100%')
              .justifyContent(FlexAlign.SpaceBetween)
              .margin({ top: 6 })
            }
            .padding(8)
            .alignItems(HorizontalAlign.Start)
          }
          .width('32%')
          .borderRadius(10)
          .backgroundColor(COLORS.card)
          .border({ width: 1, color: COLORS.border })
          .margin({ bottom: 10 })
          .onClick(() => {
            this.bizScript = s.title
            this.openBiz()
          })
        }, (s: ScriptItem1) => 'lib' + s.id.toString())
      }
      .width('100%')
      .padding({ left: 14, right: 14, bottom: 12 })
    }
    .width('100%')
  }

  // -------------------------------------------------------------------------
  // Tab 2 组局:拼车卡片(进度条显示已满/需要人数)
  // -------------------------------------------------------------------------
  @Builder
  groupCard(g: GroupItem, i: number) {
    Column() {
      Row() {
        Text(g.hostAvatar)
          .fontSize(20)
        Column() {
          Text(g.host)
            .fontSize(12)
            .fontColor(COLORS.textPrimary)
            .fontWeight(FontWeight.Medium)
          Text(g.city + ' · ' + g.time)
            .fontSize(10)
            .fontColor(COLORS.textMuted)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 8 })

        Text(g.status)
          .fontSize(10)
          .fontColor(Color.White)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .borderRadius(10)
          .backgroundColor(statusColor(g.status))
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Row() {
        Text('🎭 ' + g.script)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
          .layoutWeight(1)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('¥' + g.fee.toString() + '/人')
          .fontSize(13)
          .fontColor(COLORS.orange)
      }
      .width('100%')
      .margin({ top: 10 })
      .alignItems(VerticalAlign.Center)

      Text('📍 ' + g.store)
        .fontSize(11)
        .fontColor(COLORS.textSecondary)
        .width('100%')
        .margin({ top: 4 })

      // 进度条
      Column() {
        Row() {
          Row() {
            Row()
              .width(pctOf(g.haveCount, g.needCount).toString() + '%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(statusColor(g.status))
          }
          .width('100%')
          .height(6)
          .borderRadius(3)
          .backgroundColor('#2A2148')
        }
        .width('100%')

        Row() {
          Text('已拼 ' + g.haveCount.toString() + '/' + g.needCount.toString() + ' 人')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('还差 ' + (g.needCount - g.haveCount > 0 ? (g.needCount - g.haveCount).toString() : '0') + ' 人开局')
            .fontSize(11)
            .fontColor(statusColor(g.status))
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 6 })
      }
      .width('100%')
      .margin({ top: 10 })

      Row() {
        Text('✏️ 编辑')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .borderRadius(12)
          .border({ width: 1, color: COLORS.border })
          .onClick(() => {
            this.openEdit(i)
          })
        Text('🗑️ 删除')
          .fontSize(11)
          .fontColor(COLORS.danger)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .borderRadius(12)
          .border({ width: 1, color: COLORS.danger })
          .margin({ left: 8 })
          .onClick(() => {
            this.openDel('组局', g.script)
          })
        Text(g.haveCount >= g.needCount ? '已满员' : '🚗 我要上车')
          .fontSize(11)
          .fontColor(Color.White)
          .padding({ left: 14, right: 14, top: 5, bottom: 5 })
          .borderRadius(12)
          .backgroundColor(g.haveCount >= g.needCount ? COLORS.textMuted : COLORS.primary)
          .margin({ left: 8 })
          .onClick(() => {
            this.joinGroup(i)
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
      .alignItems(VerticalAlign.Center)
      .margin({ top: 10 })
    }
    .width('100%')
    .padding(12)
    .borderRadius(14)
    .backgroundColor(COLORS.card)
    .border({ width: 1, color: COLORS.border })
    .margin({ bottom: 12 })
  }

  @Builder
  pageGroup() {
    Column() {
      Row() {
        Text('🚗 拼车大厅')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('发布组局')
          .fontSize(11)
          .fontColor(Color.White)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .borderRadius(12)
          .backgroundColor(COLORS.primary)
          .onClick(() => {
            this.openBiz()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .padding({ bottom: 12 })

      ForEach(this.groups, (g: GroupItem, i: number) => {
        this.groupCard(g, i)
      }, (g: GroupItem) => 'grp' + g.id.toString() + '_' + g.haveCount.toString())
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12 })
  }

  // -------------------------------------------------------------------------
  // Tab 3 测评:评分星星 + 分区评分条(雷达式分区列表)
  // -------------------------------------------------------------------------
  @Builder
  reviewCard(r: ReviewItem, i: number) {
    Column() {
      Row() {
        Text(r.avatar)
          .fontSize(26)
          .width(44)
          .height(44)
          .textAlign(TextAlign.Center)
          .borderRadius(22)
          .backgroundColor('#2A2148')
        Column() {
          Text(r.author)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text(r.date + ' · ' + r.store)
            .fontSize(10)
            .fontColor(COLORS.textMuted)
            .margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 10 })

        Column() {
          Text('🗑️')
            .fontSize(14)
            .padding(4)
            .onClick(() => {
              this.openDel('测评', r.script)
            })
        }
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Row() {
        Text('🎭 ' + r.script)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.accent)
          .layoutWeight(1)
        Text(scoreStars(r.score))
          .fontSize(13)
          .fontColor(COLORS.accent)
      }
      .width('100%')
      .margin({ top: 8 })
      .alignItems(VerticalAlign.Center)

      Text('「' + r.mood + '」')
        .fontSize(12)
        .fontColor(COLORS.pink)
        .width('100%')
        .margin({ top: 6 })

      Text(r.content)
        .fontSize(12)
        .fontColor(COLORS.textSecondary)
        .width('100%')
        .margin({ top: 6 })

      // 雷达式分区评分(进度条模拟)
      Column() {
        Row() {
          Text('剧情')
            .fontSize(10)
            .fontColor(COLORS.textMuted)
            .width(30)
          Row() {
            Row()
              .width((r.score * 20).toString() + '%')
              .height(4)
              .borderRadius(2)
              .backgroundColor(COLORS.pink)
          }
          .width('100%')
          .height(4)
          .borderRadius(2)
          .backgroundColor('#2A2148')
          .layoutWeight(1)
          .margin({ left: 6, right: 6 })
          Text(r.score.toFixed(1))
            .fontSize(10)
            .fontColor(COLORS.pink)
            .width(22)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 6 })

        Row() {
          Text('DM')
            .fontSize(10)
            .fontColor(COLORS.textMuted)
            .width(30)
          Row() {
            Row()
              .width((r.dmScore * 20).toString() + '%')
              .height(4)
              .borderRadius(2)
              .backgroundColor(COLORS.accent)
          }
          .width('100%')
          .height(4)
          .borderRadius(2)
          .backgroundColor('#2A2148')
          .layoutWeight(1)
          .margin({ left: 6, right: 6 })
          Text(r.dmScore.toFixed(1))
            .fontSize(10)
            .fontColor(COLORS.accent)
            .width(22)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 5 })

        Row() {
          Text('氛围')
            .fontSize(10)
            .fontColor(COLORS.textMuted)
            .width(30)
          Row() {
            Row()
              .width((85 + (r.id % 3) * 5).toString() + '%')
              .height(4)
              .borderRadius(2)
              .backgroundColor(COLORS.blue)
          }
          .width('100%')
          .height(4)
          .borderRadius(2)
          .backgroundColor('#2A2148')
          .layoutWeight(1)
          .margin({ left: 6, right: 6 })
          Text((4.2 + (r.id % 3) * 0.1).toFixed(1))
            .fontSize(10)
            .fontColor(COLORS.blue)
            .width(22)
        }
        .width('100%')
        .alignItems(VerticalAlign.Center)
        .margin({ top: 5 })
      }
      .width('100%')
      .padding({ top: 8 })
      .border({ width: 1, color: COLORS.border })
      .borderRadius(10)
      .padding(8)

      Row() {
        Text('💬 ' + (12 + r.id * 3).toString())
          .fontSize(11)
          .fontColor(COLORS.textMuted)
        Text(r.liked ? '❤️ ' + r.likes.toString() : '🤍 ' + r.likes.toString())
          .fontSize(11)
          .fontColor(r.liked ? COLORS.danger : COLORS.textMuted)
          .padding({ left: 12, right: 12, top: 4, bottom: 4 })
          .borderRadius(12)
          .backgroundColor(r.liked ? '#3A1B22' : 'transparent')
          .onClick(() => {
            this.toggleLike(i)
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.End)
      .margin({ top: 8 })
    }
    .width('100%')
    .padding(12)
    .borderRadius(14)
    .backgroundColor(COLORS.card)
    .border({ width: 1, color: COLORS.border })
    .margin({ bottom: 12 })
  }


  }
}


在这里插入图片描述

15.5 总结:

剧本杀社区应用的源码分析,让我们得以窥见HarmonyOS ArkTS开发的真实面貌。从颜色配置到数据建模,从工具函数到UI组件,从状态管理到交互设计,每一个层面都展现了ArkTS语言的表达力和ArkUI框架的灵活性。

基于HarmonyOS API 24的开发生态正在走向成熟。对于前端开发者来说,ArkTS的声明式UI范式与React、Flutter等框架有很多相似之处,学习曲线相对平缓。而对于原生开发者来说,ArkTS提供的系统级能力(如分布式能力、原子化服务)又是Web框架无法比拟的。

Logo

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

更多推荐