深度剖析基于HarmonyOS API 24的剧本杀社区应用:HarmonyOS 6.1.1与HarmonyOS ArkTS API 24全栈实战
引言:从社交场景看鸿蒙生态的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是背景色,card和cardDeep构成了卡片的深浅两级,文字颜色分为textPrimary(主文本)、textSecondary(次级文本)、textMuted(弱化文本)三个层级,accent与accentDim构成强调色的明暗对。此外,还定义了danger、green、blue、pink、orange等功能色,用于表达不同的业务语义和情感色彩。
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、标题、类型)、规格参数(人数、时长、难度)、商业属性(评分、价格、热度)以及描述信息(标签、简介、是否新品)。其中,players和duration使用字符串类型而非结构化对象,这是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类实现了相同的模式,这里不再赘述。值得注意的是likes和liked这一对属性——它们共同构成了点赞功能的数据基础。在后续的交互分析中我们会看到,点赞操作需要同时更新这两个属性,并通过数组的重新赋值来触发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中的状态标签颜色和按钮的可点击性。
值得注意的是,host和hostAvatar字段记录了发起人的信息,这种设计在社交场景中非常重要——用户在决定是否加入拼车时,往往会先查看发起人的身份信息。同时,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特效层的动画效果。这组函数包括fxY、fxRotate、fxOpacity、fxScale、fxX等。
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
}
这组函数(buildScriptData、buildReviewData、buildGroupData、buildStoreData、buildRankData)的作用是在应用初始化时,将普通的对象字面量数组转换为具有响应式能力的类实例数组。每个数据项都通过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代码中修改状态。这种封装的好处是:
- 可读性:方法名清晰表达了操作意图
- 可维护性:如果未来需要在切换时添加额外逻辑(如埋点、动画),只需修改方法内部
- 可复用性:多处UI可以调用同一个方法
4.4 弹窗管理方法
closeAll(): void {
this.addOpen = false
this.editOpen = false
this.delOpen = false
this.bizOpen = false
}
closeAll方法一次性关闭所有弹窗。由于应用有4种不同的弹窗(新增测评、编辑组局、删除确认、发布组局),且同一时间应该只有一个弹窗处于打开状态,因此提供一个统一的关闭方法非常必要。这种设计也避免了弹窗状态不一致的问题。
每个弹窗都有对应的打开方法(openAdd、openEdit、openDel、openBiz),这些方法在打开弹窗前会先重置表单字段的状态,确保每次打开都是"干净"的初始状态。
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项的选中态通过三个维度表达:
- 字重:选中时加粗(
FontWeight.Bold) - 颜色:选中时使用金色强调色
- 底部指示条:选中时显示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 * 20和r.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柱状图和下方的综合排行榜列表。
柱状图的实现非常巧妙——完全使用基础的Column和Row组件构建,无需引入任何图表库。每个柱子就是一个设置了宽度、高度和圆角的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%不透明)。
遮罩层分为三种弹窗类型:
- 底部弹窗(新增测评、发布组局):从底部滑出,只有顶部有弹性点击区
- 居中弹窗(编辑组局、删除确认):居中显示,上下都有弹性点击区
弹性点击区使用.layoutWeight(1)占据剩余空间,点击任何空白区域都可以关闭弹窗。这是移动端弹窗的标准交互模式,符合用户的操作习惯。
弹窗的显示通过布尔状态变量(addOpen、editOpen、delOpen、bizOpen)控制,使用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个(首页、发现、消息、我的),中间位置被一个凸起的发布按钮占据。这种设计的核心思想是:将应用最核心的操作(发布内容/发起组局)放在最显眼、最容易点击的位置。
凸起按钮的实现有几个关键技术点:
- 负margin:
.margin({ top: -18 })使按钮向上偏移18像素,从而"突破"导航栏的边界,形成凸起效果。 - 圆形按钮:52x52像素的圆形按钮,圆角26像素(正好是宽度的一半)。
- 双层边框:2像素宽的金色边框包裹酒红色的按钮主体,形成金边质感。
- 阴影效果:
.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堆叠布局,将应用界面分为三层,从下到上依次是:
-
主内容层:包含头部、Tab栏、内容区、底部导航。这是应用的主体,使用
Column垂直排列。内容区使用Scroll + layoutWeight(1)实现可滚动的内容区域,根据curTab的值显示不同的页面内容。 -
特效层:浮动emoji特效,位于主内容层之上。由于设置了
hitTestBehavior(HitTestMode.None),它不会阻挡用户对下方内容的操作。 -
弹窗层:在有弹窗打开时显示,位于最顶层。遮罩层覆盖全屏,阻止用户操作下方内容。
这种"内容 + 装饰 + 弹窗"的三层结构是UI架构中的经典模式。Stack布局使得各层独立管理、互不干扰,同时又能叠加显示。每层的职责清晰明确:内容层承载信息,特效层增强视觉体验,弹窗层处理模态交互。
内容区的页面切换使用多个if条件判断而非switch语句,这是因为ArkTS的声明式UI语法中只能使用if条件渲染。每个页面都是一个独立的@Builder方法,通过curTab状态变量控制显示哪个页面。当curTab变化时,ArkUI框架会自动计算差异并更新UI。
十一、架构流程图与数据流分析
11.1 应用整体架构流程图
为了更直观地理解应用的整体架构,下面使用Mermaid流程图展示组件层级和数据流关系:
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更新的完整数据流:
这个时序图清晰地展示了点赞操作的完整流程:
- 用户触发:用户点击测评卡片上的点赞按钮
- 方法调用:按钮的
onClick回调调用toggleLike(i)方法 - 数据修改:方法内部获取对应索引的
ReviewItem对象,切换liked状态并更新likes计数 - 状态通知:通过
slice(0)创建新数组并重新赋值,触发@State的变化检测 - 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的组合方案,这是基于以下考量:
- 应用规模适中:应用功能虽然丰富,但所有状态可以在单组件内容纳,无需复杂的跨组件状态传递。
- 开发效率优先:原型开发阶段,简洁的状态管理方案可以大大加快开发速度。
- 性能足够:
@State提供的细粒度更新对于当前的数据量(每类12条数据)来说绰绰有余。 - 维护成本低:状态集中管理,查找和修改都非常方便。
当然,如果应用继续发展壮大(比如增加用户系统、实时聊天、更多数据实体),这种单组件架构可能会遇到瓶颈。届时可以考虑逐步迁移到@Provide/@Consume或AppStorage方案,将状态分发到不同的组件层级。
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 纯函数工具层的设计
应用中的工具函数(typeColor、diffStars、pctOf、fxY等)全部采用纯函数的形式编写——相同的输入总是产生相同的输出,不依赖外部状态,也不产生副作用。
纯函数的优势包括:
可测试性:纯函数的测试极其简单,只需给定输入、验证输出即可,不需要mock任何依赖。
可组合性:纯函数可以自由组合,形成更复杂的函数,而不用担心相互干扰。
可缓存性:对于相同的输入,纯函数的结果可以被缓存(memoization),在计算密集型场景下可以显著提升性能。
易于调试:纯函数的行为完全可预测,出现bug时只需检查输入输出即可定位问题。
在UI开发中,将"展示逻辑"(如颜色映射、格式化、动画计算)抽取为纯函数是一种非常有益的实践——它使得UI组件更加专注于布局和交互,而将计算逻辑交给纯函数处理。
13.4 数组状态更新模式
应用中反复出现的this.xxx = this.xxx.slice(0)模式值得深入讨论。这是ArkUI开发中应对"数组元素属性变更不触发更新"问题的标准解决方案。
为什么需要这样做?因为@State装饰的数组,其变化检测主要针对数组本身的引用变化和元素的增删操作。当你直接修改数组中某个元素的属性时,数组的引用没有改变,元素的数量也没有变化,因此@State可能检测不到这个变化。
解决方案有几种:
- 数组重新赋值(本方案):通过
slice(0)创建新数组,改变引用,触发更新 - @ObjectLink:在子组件中使用
@ObjectLink接收对象,实现细粒度更新 - @Observed + 整体替换:替换整个对象实例而非修改属性
本应用选择了第一种方案,因为它实现最简单、代码量最少,对于当前的数据规模来说性能也完全足够。但如果数据量很大(如几百条),频繁的数组拷贝可能会带来性能问题,届时需要考虑更优化的方案。
13.5 弹窗的统一管理
应用通过closeAll()方法统一管理所有弹窗的关闭状态,这种集中式管理有几个好处:
状态一致性:确保任何时候最多只有一个弹窗打开,避免了弹窗叠加的混乱局面。
代码简洁:每个业务操作完成后只需调用一次closeAll(),不需要逐一关闭可能打开的弹窗。
易于扩展:如果未来增加新的弹窗类型,只需在closeAll()中添加一行重置代码即可。
同时,每个弹窗都有对应的openXxx()方法,在打开前重置表单状态。这种"打开即重置"的模式确保了用户每次打开弹窗都看到干净的初始状态,避免了上次输入残留导致的混淆。
十四、性能优化与体验提升建议
14.1 列表渲染优化
当前应用使用ForEach渲染列表,对于12条数据的规模来说完全没有性能问题。但如果数据量增长到几十甚至上百条,可以考虑以下优化措施:
使用LazyForEach:LazyForEach是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,再到AppStorage、LocalStorage,ArkTS提供了层次丰富的状态管理方案,可以满足不同规模应用的需求。
UI组件库丰富度有待提升:虽然基础组件(Text、Column、Row、Scroll等)非常完善,但在复杂组件(如图表、日历、富文本等)方面,生态还不够丰富。开发者经常需要用基础组件自行构建,这既展示了基础组件的灵活性(如本应用中的柱状图),也反映了生态的不足。
动画能力持续增强:从animateTo到transition,再到各种属性动画,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框架无法比拟的。
更多推荐


所有评论(0)