一、技术前言
在当下沉浸式娱乐产业的版图中,剧本杀 已经从最初的小众桌游演变为一个覆盖剧本创作、线下拼车、DM 控场、攻略心得、剧友社交的完整产业链。从古风权谋到恐怖推理,从情感沉浸到硬核阵营,剧本杀社区应用的核心诉求是"氛围先行"------用户打开应用的第一秒,就需要被一种暗夜悬疑的气息所包裹,仿佛已经走进了一座灯火昏黄的密室。本文将以一款名为"谜局"的沉浸式剧本杀组局社区为例,基于 HarmonyOS ArkUI 框架,从暗夜紫调色彩体系、迷雾粒子与烛光闪烁双层特效、双层 Tab 导航架构、四套差异化弹框系统等多个维度,深入剖析其完整的技术实现方案。
1.1 ArkUI 声明式 UI 范式解析

ArkUI 是华为 HarmonyOS 生态下的声明式 UI 框架,其核心设计理念与 React 的 JSX 声明式范式和 Flutter 的 Widget 树模型有诸多相似之处,但同时也具备自身独特的装饰器体系和组件化机制。在 ArkUI 中,开发者通过 @Entry 标记入口组件,通过 @Component 声明自定义组件,通过 @Builder 定义可复用的 UI 构建块,通过 @State 实现响应式状态绑定,通过 @Observed 标记可观察的数据模型类。这套装饰器体系构成了 ArkUI 组件化开发的核心基石。
声明式 UI 的本质在于"状态驱动视图"------开发者只需描述界面在任何给定状态下的样子,框架自动负责状态变化时的 UI 更新。在 ArkUI 中,当 @State 修饰的变量发生改变时,所有引用该变量的 UI 片段都会被自动重新渲染。这种机制让开发者无需手动操作 DOM 或调用 setState,只需修改状态变量即可触发界面刷新。同时,@Observed 装饰器配合 @State 使用时,可以实现对象属性的细粒度观察------当被 @Observed 标记的类的实例属性变化时,引用该实例的组件会自动更新。组件化方面,ArkUI 通过 @Component 将 UI 拆分为独立可复用的单元,通过 @Builder 进一步提取可复用的 UI 片段,形成"组件-构建器"两级复用体系。
与 React 的函数组件相比,ArkUI 的 @Component struct 模式更接近于类的形式------状态变量直接声明在结构体内部,而非通过 useState Hook 管理。这种设计让状态声明更加直观,但也意味着状态的生命周期与组件实例绑定,不存在 Hook 的闭包陷阱问题。与 Flutter 的 StatefulWidget 相比,ArkUI 的 @State 不需要显式调用 setState 方法触发重建,而是通过赋值操作自动触发依赖追踪和差分更新。这种"赋值即触发"的设计降低了开发者的心智负担,但也要求开发者对状态变更的粒度有清晰认知------频繁更新大型状态对象可能导致性能问题。
1.2 装饰器工作原理深入探讨

ArkUI 的装饰器体系是其区别于其他声明式框架的核心特征。从编译原理的角度来看,@Component 装饰器在编译阶段会将 struct 结构体转换为一个具备渲染能力、生命周期管理和状态追踪的组件类。@State 装饰器则会为被修饰的变量生成一个 Proxy 包装器,在赋值时自动通知框架的渲染调度器进行差分更新。@Builder 装饰器将一个方法标记为"UI 构建函数",这类函数在调用时不会执行普通的方法调用流程,而是将其内部的 UI 描述注入到调用位置的组件树中------这与 React 中渲染函数返回 JSX 的机制类似,但 ArkUI 的 @Builder 是通过编译期变换实现的,而非运行时的虚拟 DOM diff。
@Observed 装饰器的作用对象是类而非组件内的变量。被 @Observed 标记的类,其实例在赋值给 @State 变量后,属性的变更会被框架劫持并触发引用该实例的 UI 片段更新。这种"类级别观察"机制比 React 的 useState + useEffect 组合更加简洁------开发者不需要手动编写依赖数组,框架自动追踪属性级别的变更。但需要注意的是,@Observed 只对 @State 变量持有的实例生效,如果直接修改非 @State 变量持有的 @Observed 实例,不会触发任何更新。
在本应用中,四个数据模型类(ScriptItem、SessionItem、GuideItem、FeedItem)都被标记为 @Observed,而 feedList 和 sessionList 两个 @State 变量分别持有 FeedItem[] 和 SessionItem[] 数组。当通过 unshift 或 splice 修改数组内容时,由于数组本身是 @State 变量,数组引用的变化会触发列表重新渲染。同时,由于数组元素是 @Observed 实例,元素内部属性的变化也会被追踪------这种双层响应机制保证了数据变化的全面感知。
1.3 与 React/Flutter 的深度对比

从状态管理的角度对比,ArkUI 的 @State + @Observed 组合提供了一种介于 React 的 useState/useReducer 和 Flutter 的 ChangeNotifier/Riverpod 之间的方案。React 的 Hooks 模式强调函数式纯度和不可变数据,每次状态更新都返回新对象;ArkUI 则允许直接修改对象属性,框架通过 Proxy 拦截变更。Flutter 的 StatefulWidget 需要显式调用 setState 并手动实现 didUpdateWidget 等生命周期方法;ArkUI 的 @State 赋值即触发,无需显式调用。在大型应用中,ArkUI 还提供了 @Prop(父到子单向传递)、@Link(父子双向同步)、@Provide/@Consume(跨层级传递)等更细粒度的状态管理装饰器,形成了一套完整的状态管理阶梯。
从渲染性能的角度对比,React 使用虚拟 DOM diff 算法进行差分更新,Flutter 使用 Widget 树 diff + Element 树复用,而 ArkUI 使用的是基于状态依赖图的精准更新机制------框架在编译期就建立了每个 @State 变量与其引用的 UI 片段之间的依赖关系图,运行时只更新受影响的 UI 片段,避免了全树 diff 的开销。这种"编译期依赖分析 + 运行时精准更新"的策略在理论上比 React 的运行时 diff 更高效,但也要求开发者在编写代码时注意状态的粒度------过粗的状态(如一个大对象包含所有数据)会导致不必要的重渲染,过细的状态(如每个字段都是独立的 @State)则增加了管理负担。
从组件复用的角度对比,React 通过函数组件 + 自定义 Hook 实现复用,Flutter 通过 Widget 组合 + Mixin 实现复用,而 ArkUI 通过 @Component + @Builder 两级体系实现复用。@Component 定义完整的可复用组件(有自己的状态和生命周期),@Builder 定义无状态的 UI 片段(类似于 React 的渲染函数或 Flutter 的 build 方法片段)。在本应用中,fxLayer、header、subNav、pageFeatured 等都是 @Builder 方法,它们没有独立状态,直接引用宿主组件的 @State 变量------这种设计让 UI 片段的复用更加轻量,不需要创建新的组件实例。
1.4 剧本杀组局场景的技术挑战

从业务场景的角度来审视,剧本杀组局社区应用面临几个独特的技术挑战:
第一,氛围营造与性能的平衡。 剧本杀应用需要营造"暗夜密室"的沉浸氛围,但过重的特效会影响低端设备的流畅度。本应用通过 120ms 间隔的 setInterval 驱动特效(约 8 FPS),而非使用 requestAnimationFrame(60 FPS),在氛围感和性能之间取得了平衡。对于迷雾粒子这种"缓慢漂浮"的视觉效果,低帧率的跳动反而更有"摇曳"的复古质感。
第二,多维数据的可视化呈现。 剧本杀涉及剧本类型、难度、评分、人数、时长、组局状态、角色热度等多个数据维度。本应用通过柱状图、进度条、排行榜、双色标签等多种可视化形式,将多维数据压缩在有限的屏幕空间内,同时保持暗色主题下的可读性。
第三,组局流程的多步骤引导。 从浏览剧本到加入组局,用户需要经历"选角→确认→入场"的多步骤流程。本应用通过加入组局弹框的三步进度指示器,将多步骤流程可视化,降低用户的认知负担。
第四,差异化弹框的视觉识别。 创建组局、编辑角色、退出组局、加入组局四套弹框分别对应不同的操作语义和风险等级。本应用通过四套完全不同的头卡视觉风格(暗色海报、双色条、危险暗红、角色选择卡),让用户在弹框出现的瞬间就能识别操作类型和风险等级,这是"防误操作设计"的重要实践。
从技术选型的角度来审视,这款沉浸式剧本杀社区应用采用了以下几个关键技术决策:
第一,暗夜紫调色彩体系。 与常见的浅色系应用不同,这款应用采用了以深紫黑(#1A1025)为背景的暗夜主题。主色选择了深紫(#6A1B9A),这是一种与悬疑、神秘、暗夜高度关联的色调,在视觉上营造出一种"走进古老庄园"的氛围感。强调色选择了琥珀橙(#FF6F00),模拟烛光与灯笼的暖色光芒,在深紫背景上形成了"暗夜中一点火光"的视觉对比。金色(#FFD54F)用于评分和高亮元素,暗示剧本的"品质等级"。整个色彩体系营造出一种"古宅夜谈"的氛围,与剧本杀的场景主题高度契合。

第二,迷雾粒子与烛光闪烁双层特效。 特效层包含两套独立的动画系统------迷雾粒子(6 个紫色椭圆在屏幕上漂浮移动)和烛光闪烁(5 个蜡烛 emoji 以不同透明度跳动)。迷雾通过 fogX、fogY、fogA 三个纯函数计算位置和透明度,烛光通过 candleA 函数计算闪烁透明度。整个特效层通过 hitTestBehavior(HitTestMode.None) 属性穿透触摸事件,既保证了视觉层面的动态氛围感,又不会影响用户对上方内容区域的正常交互。特效层的动画驱动通过 setInterval 定时器每 120 毫秒更新 tick 状态变量,触发 ForEach 重新计算每个粒子和烛光的位置与透明度。

第三,双层 Tab 导航架构。 应用采用底部 4 主 Tab(首页/剧本库/组局/我的)与首页 5 内容 Tab(精选/剧本库/组局大厅/攻略心得/剧友圈)的双层导航设计。底部主 Tab 切换大的功能域,首页内容 Tab 切换细分业务场景。子导航栏的选中态采用了竖条指示器------当某个 Tab 被选中时,其左侧出现一条 3px 宽、16px 高的琥珀橙竖条,配合金色文字和深紫底色,形成一种"灯笼吊牌"式的视觉指示。其核心实现依赖于 @State mainTab 和 @State subTab 两个状态变量的组合条件渲染。
第四,四套差异化弹框系统。 应用定义了创建组局、编辑角色、退出组局、加入组局四套弹框,每套弹框都有独立的视觉风格------创建组局弹框采用暗色海报头卡(深紫底+骰子图标+白色标题),编辑角色弹框采用双色条头卡(紫色主体+琥珀色右侧色块),退出组局弹框采用危险暗红卡(danger 红色头+警告条),加入组局弹框采用角色选择卡(三步进度指示器+角色色块标签)。四套弹框通过 @State 布尔变量控制显示隐藏,通过 Stack 容器实现遮罩层与弹框体的叠加。
第五,声明式条件渲染与按需构建。 内容区域共享同一个 Scroll 容器,通过 if/else if 条件链根据 subTab 的值决定当前显示哪个 @Builder 构建函数。每次只渲染当前激活的页面内容,避免了多个页面实例同时存在带来的内存开销,实现了按需渲染的性能优化策略。
下面,我们将从代码的第一行开始,逐段深入分析这个沉浸式剧本杀组局社区的完整实现。
二、整体架构流程图
#mermaid-svg-uoC5BxjeRiLKxJdt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uoC5BxjeRiLKxJdt .error-icon{fill:#552222;}#mermaid-svg-uoC5BxjeRiLKxJdt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uoC5BxjeRiLKxJdt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uoC5BxjeRiLKxJdt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uoC5BxjeRiLKxJdt .marker.cross{stroke:#333333;}#mermaid-svg-uoC5BxjeRiLKxJdt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uoC5BxjeRiLKxJdt p{margin:0;}#mermaid-svg-uoC5BxjeRiLKxJdt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-uoC5BxjeRiLKxJdt .cluster-label text{fill:#333;}#mermaid-svg-uoC5BxjeRiLKxJdt .cluster-label span{color:#333;}#mermaid-svg-uoC5BxjeRiLKxJdt .cluster-label span p{background-color:transparent;}#mermaid-svg-uoC5BxjeRiLKxJdt .label text,#mermaid-svg-uoC5BxjeRiLKxJdt span{fill:#333;color:#333;}#mermaid-svg-uoC5BxjeRiLKxJdt .node rect,#mermaid-svg-uoC5BxjeRiLKxJdt .node circle,#mermaid-svg-uoC5BxjeRiLKxJdt .node ellipse,#mermaid-svg-uoC5BxjeRiLKxJdt .node polygon,#mermaid-svg-uoC5BxjeRiLKxJdt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uoC5BxjeRiLKxJdt .rough-node .label text,#mermaid-svg-uoC5BxjeRiLKxJdt .node .label text,#mermaid-svg-uoC5BxjeRiLKxJdt .image-shape .label,#mermaid-svg-uoC5BxjeRiLKxJdt .icon-shape .label{text-anchor:middle;}#mermaid-svg-uoC5BxjeRiLKxJdt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uoC5BxjeRiLKxJdt .rough-node .label,#mermaid-svg-uoC5BxjeRiLKxJdt .node .label,#mermaid-svg-uoC5BxjeRiLKxJdt .image-shape .label,#mermaid-svg-uoC5BxjeRiLKxJdt .icon-shape .label{text-align:center;}#mermaid-svg-uoC5BxjeRiLKxJdt .node.clickable{cursor:pointer;}#mermaid-svg-uoC5BxjeRiLKxJdt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uoC5BxjeRiLKxJdt .arrowheadPath{fill:#333333;}#mermaid-svg-uoC5BxjeRiLKxJdt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uoC5BxjeRiLKxJdt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uoC5BxjeRiLKxJdt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uoC5BxjeRiLKxJdt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uoC5BxjeRiLKxJdt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uoC5BxjeRiLKxJdt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uoC5BxjeRiLKxJdt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uoC5BxjeRiLKxJdt .cluster text{fill:#333;}#mermaid-svg-uoC5BxjeRiLKxJdt .cluster span{color:#333;}#mermaid-svg-uoC5BxjeRiLKxJdt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uoC5BxjeRiLKxJdt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uoC5BxjeRiLKxJdt rect.text{fill:none;stroke-width:0;}#mermaid-svg-uoC5BxjeRiLKxJdt .icon-shape,#mermaid-svg-uoC5BxjeRiLKxJdt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uoC5BxjeRiLKxJdt .icon-shape p,#mermaid-svg-uoC5BxjeRiLKxJdt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uoC5BxjeRiLKxJdt .icon-shape .label rect,#mermaid-svg-uoC5BxjeRiLKxJdt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uoC5BxjeRiLKxJdt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uoC5BxjeRiLKxJdt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uoC5BxjeRiLKxJdt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 底部导航
弹框层
其他主Tab
首页5内容Tab
头部区域
特效层
数据层
状态管理
入口组件
PageMysteryNight
@Entry @Component
mainTab: number
主Tab索引
subTab: number
内容Tab索引
tick: number
特效动画状态
addOpen / editOpen
delOpen / joinOpen
弹框开关
feedList / sessionList
响应式列表数据
表单临时状态
editNick/Role/Level
addScript/Time/Location
joinScript/Role
ColorPalette
15色暗夜体系
4个@Observed模型
ScriptItem / SessionItem
GuideItem / FeedItem
5个接口
WeekChart/TypeDist
HotRole/FeatureScript/NavItem
10组静态数据
SCRIPT/SESSION/GUIDE/FEED
WEEK/TYPE/HOT/FEATURE/NAV/SUBNAV
7个纯函数
barH/diffColor/statusColor
fogX/fogY/fogA/candleA
fxLayer
迷雾粒子+烛光闪烁
hitTestMode.None
header
搜索栏+榜首剧本卡+四格统计
pageFeatured
精选首页
pageScriptLib
剧本库列表
pageSessions
组局大厅
pageGuide
攻略心得
pageCircle
剧友圈
pageMine
我的页面
addModalBody
创建组局弹框
暗色海报头卡
editModalBody
编辑角色弹框
双色条头卡
delModalBody
退出组局弹框
危险暗红卡
joinModalBody
加入组局弹框
角色选择卡
bottomBar
4主Tab导航
从上述架构流程图可以清晰地看到,整个应用以 PageMysteryNight 组件为核心枢纽,向下连接了状态管理、数据层、特效层、头部区域、内容区域、弹框层和底部导航七个子系统。与其他应用相比,这款沉浸式剧本杀社区在氛围特效和弹框视觉差异化上有独特设计------迷雾粒子与烛光闪烁的双重动画营造了暗夜悬疑的氛围,四套弹框各自采用不同的头卡视觉语言来匹配不同的操作语义。
架构的分层逻辑也值得深入分析。最底层是背景层(纯色 Column),第二层是特效层(fxLayer),第三层是内容层(mainContent + bottomBar),第四层是弹框层(四套 @Builder 弹框体)。这种从底到顶的叠加顺序确保了弹框永远在最顶层,特效永远在内容下方,背景永远在最底层。Stack 容器的"后声明在上"特性让这种层级关系通过代码顺序自然表达------开发者在阅读 build 函数时,从上到下就是从底层到顶层的视觉叠加顺序。
三、数据流图
#mermaid-svg-gj1P2ln04zX8FlKA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gj1P2ln04zX8FlKA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gj1P2ln04zX8FlKA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gj1P2ln04zX8FlKA .error-icon{fill:#552222;}#mermaid-svg-gj1P2ln04zX8FlKA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gj1P2ln04zX8FlKA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gj1P2ln04zX8FlKA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gj1P2ln04zX8FlKA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gj1P2ln04zX8FlKA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gj1P2ln04zX8FlKA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gj1P2ln04zX8FlKA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gj1P2ln04zX8FlKA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gj1P2ln04zX8FlKA .marker.cross{stroke:#333333;}#mermaid-svg-gj1P2ln04zX8FlKA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gj1P2ln04zX8FlKA p{margin:0;}#mermaid-svg-gj1P2ln04zX8FlKA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-gj1P2ln04zX8FlKA .cluster-label text{fill:#333;}#mermaid-svg-gj1P2ln04zX8FlKA .cluster-label span{color:#333;}#mermaid-svg-gj1P2ln04zX8FlKA .cluster-label span p{background-color:transparent;}#mermaid-svg-gj1P2ln04zX8FlKA .label text,#mermaid-svg-gj1P2ln04zX8FlKA span{fill:#333;color:#333;}#mermaid-svg-gj1P2ln04zX8FlKA .node rect,#mermaid-svg-gj1P2ln04zX8FlKA .node circle,#mermaid-svg-gj1P2ln04zX8FlKA .node ellipse,#mermaid-svg-gj1P2ln04zX8FlKA .node polygon,#mermaid-svg-gj1P2ln04zX8FlKA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gj1P2ln04zX8FlKA .rough-node .label text,#mermaid-svg-gj1P2ln04zX8FlKA .node .label text,#mermaid-svg-gj1P2ln04zX8FlKA .image-shape .label,#mermaid-svg-gj1P2ln04zX8FlKA .icon-shape .label{text-anchor:middle;}#mermaid-svg-gj1P2ln04zX8FlKA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gj1P2ln04zX8FlKA .rough-node .label,#mermaid-svg-gj1P2ln04zX8FlKA .node .label,#mermaid-svg-gj1P2ln04zX8FlKA .image-shape .label,#mermaid-svg-gj1P2ln04zX8FlKA .icon-shape .label{text-align:center;}#mermaid-svg-gj1P2ln04zX8FlKA .node.clickable{cursor:pointer;}#mermaid-svg-gj1P2ln04zX8FlKA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gj1P2ln04zX8FlKA .arrowheadPath{fill:#333333;}#mermaid-svg-gj1P2ln04zX8FlKA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gj1P2ln04zX8FlKA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gj1P2ln04zX8FlKA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gj1P2ln04zX8FlKA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gj1P2ln04zX8FlKA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gj1P2ln04zX8FlKA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gj1P2ln04zX8FlKA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gj1P2ln04zX8FlKA .cluster text{fill:#333;}#mermaid-svg-gj1P2ln04zX8FlKA .cluster span{color:#333;}#mermaid-svg-gj1P2ln04zX8FlKA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gj1P2ln04zX8FlKA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gj1P2ln04zX8FlKA rect.text{fill:none;stroke-width:0;}#mermaid-svg-gj1P2ln04zX8FlKA .icon-shape,#mermaid-svg-gj1P2ln04zX8FlKA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gj1P2ln04zX8FlKA .icon-shape p,#mermaid-svg-gj1P2ln04zX8FlKA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gj1P2ln04zX8FlKA .icon-shape .label rect,#mermaid-svg-gj1P2ln04zX8FlKA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gj1P2ln04zX8FlKA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gj1P2ln04zX8FlKA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gj1P2ln04zX8FlKA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} UI响应
状态变更
用户交互
doAdd
doJoin
doDel
自动同步
点击底部Tab
点击子导航Tab
点击剧本卡片
点击发起组局
点击加入拼车
点击编辑角色
点击退出组局
弹框内确认操作
mainTab = idx
subTab = idx
joinScript = name
joinOpen = true
addOpen = true
editOpen = true
delOpen = true
sessionList.unshift
feedList.unshift
sessionList.splice
mainContent条件渲染
subNav选中态更新
joinModalBody显示
addModalBody显示
editModalBody显示
delModalBody显示
组局列表更新
动态列表更新
数据流图揭示了应用中所有用户交互到 UI 响应的完整路径。值得注意的是底部三个操作(doAdd、doJoin、doDel)不仅触发对应列表的更新,还会通过 @State 的响应式机制自动同步到其他引用同一数据源的页面------例如 doJoin 向 feedList 插入新动态后,精选首页的 3 条预览、剧友圈的全部动态、我的页面的 3 条动态预览都会自动刷新。这种"一处修改,多处同步"的响应式数据流是 ArkUI @State 装饰器的核心价值。
四、色彩体系设计
色彩是沉浸式氛围的基石。这款应用首先定义了一个 ColorPalette 接口,将所有颜色常量约束在一个统一的类型体系内,然后通过一个 COLORS 常量对象实例化这套色彩体系。
4.1 ColorPalette 接口
typescript
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
gold: string;
}
这个接口定义了 15 个颜色字段,涵盖了主色体系(primary/primaryLight/primaryDark)、强调色体系(accent/accentLight)、背景体系(bg/cardBg)、文字层次体系(textPrimary/textSecondary/textHint)、功能色体系(success/warning/danger)、以及辅助色(white/gold/border)。通过接口约束,所有颜色字段都有明确的类型定义,确保后续在使用时不会出现拼写错误或遗漏。
在 ArkUI 中使用 interface 定义类型是一种常见的工程化实践。与 TypeScript 的 type 别名相比,interface 支持声明合并和继承扩展,在大型项目中更易于维护。这个 ColorPalette 接口不仅约束了 COLORS 常量的结构,还为后续可能的主题切换功能(如亮色主题、节日主题)提供了统一的类型契约------任何新主题只需实现这个接口即可无缝替换。
4.2 COLORS 常量逐色分析
typescript
const COLORS: ColorPalette = {
primary: '#6A1B9A',
primaryLight: '#F3E5F5',
primaryDark: '#38006B',
accent: '#FF6F00',
accentLight: '#FFF3E0',
bg: '#1A1025',
cardBg: '#261835',
textPrimary: '#F5F0FA',
textSecondary: '#B8A8D0',
textHint: '#7A6B90',
border: '#3A2A4F',
success: '#66BB6A',
warning: '#FFB74D',
danger: '#EF5350',
white: '#FFFFFF',
gold: '#FFD54F'
};
下面逐一分析每个颜色的设计意图:
primary: '#6A1B9A' ------ 深紫色。这是整个应用的主色调,RGB 值为 (106, 27, 154),是一种偏向蓝紫的深色。紫色在色彩心理学中与神秘、高贵、悬疑相关联,是剧本杀这类暗夜推理场景的理想主色。它既不像纯黑那样压抑,也不像亮紫那样轻浮,保持了恰到好处的沉郁感。在 Material Design 的色板体系中,这个色值接近 Purple 800,属于高饱和度深色调。
primaryLight: '#F3E5F5' ------ 极淡紫。RGB (243, 229, 245),几乎是白色带一丝紫意。在暗色主题中,primaryLight 主要用于角色标签的浅色底色(如剧友圈中"角色"标签的背景色),在深色卡片上形成柔和的浅色点缀。这种"暗色主题中的浅色标签"设计是一种视觉层级提升技巧------浅色在暗色背景上天然具有"浮出"效果,适合用于需要强调的标签元素。
primaryDark: '#38006B' ------ 深暗紫。RGB (56, 0, 107),是主色的进一步深化版本。用于弹框头卡背景、剧本图标底色、子导航选中态底色等需要"更深一层"视觉层次的区域。在暗色主题中,primaryDark 不是"更暗"而是"更浓"------它在视觉上比 primary 更深,形成一种"沉入暗夜"的效果。这个色值在 Material Design 色板中接近 Purple 900,饱和度极高但明度极低。
accent: '#FF6F00' ------ 琥珀橙。RGB (255, 111, 0),一种温暖的橙色调。这是应用的强调色,模拟烛光与灯笼的暖色光芒。在深紫背景上,琥珀橙形成了"暗夜中一点火光"的强烈视觉对比,用于按钮、选中态指示器、关键数据高亮等需要突出显示的元素。橙色与紫色的互补关系在色轮上形成 180 度对立------紫色是冷色调,橙色是暖色调,两者的搭配在视觉上具有最高的对比度和冲击力。
accentLight: '#FFF3E0' ------ 极淡橙。RGB (255, 243, 224),用于剧友圈中"角色"标签的浅色底色,与 primaryLight 形成双色标签的视觉对比。在暗色卡片背景上,极淡橙和极淡紫两种浅色标签并列出现,形成了一种"暖冷对比"的微视觉效果。
bg: '#1A1025' ------ 深紫黑。RGB (26, 16, 37),是整个应用的背景色。这不是纯黑,而是一种带有紫色调的深暗色------纯黑过于冰冷,而带紫调的深色更有"古宅夜色"的氛围感。背景色与卡片色之间保持约 8% 的明度差,让卡片浮起但不过于突兀。在 HSL 色彩空间中,这个颜色的色相为 264 度(偏蓝紫),饱和度为 42%,明度仅为 12%------这种"低明度中饱和"的配置确保了背景既不刺眼也不灰暗。
cardBg: '#261835' ------ 卡片深紫。RGB (38, 24, 53),用于所有卡片的背景色。比背景色稍亮,让卡片在视觉上"浮出"背景,形成层次感。明度差约 6%,这个差值在暗色主题中恰到好处------太大会导致卡片过于突兀,太小则无法区分卡片与背景。
textPrimary: '#F5F0FA' ------ 主文字色。RGB (245, 240, 250),接近白色但带有极淡的紫调。在深色背景上提供了最高的文字对比度,用于标题、主要数据等需要突出阅读的内容。之所以不使用纯白 #FFFFFF,是因为纯白在深紫背景上会显得过于刺眼,而带紫调的近白色与整体色彩体系更加和谐。
textSecondary: '#B8A8D0' ------ 次文字色。RGB (184, 168, 208),一种淡紫灰色。用于副标题、描述性文字、标签等辅助信息,与主文字形成层次但不过于黯淡。明度约为 73%,在深色背景上可读性良好但不如主文字突出。
textHint: '#7A6B90' ------ 提示文字色。RGB (122, 107, 144),更暗的紫灰色。用于时间戳、单位说明、提示性文字等最次要的信息,在深色背景上几乎"隐入"背景,形成视觉层次的最底层。明度约为 49%,在深色背景上的对比度较低,适合不需要突出阅读的辅助信息。
border: '#3A2A4F' ------ 边框色。RGB (58, 42, 79),用于分割线、进度条底色、按钮边框等。比卡片色稍深,形成细微的边界感。在暗色主题中,边框色的作用不是"画出一条线",而是通过微妙的明度差异暗示边界------这种"隐性边界"比显性边框更适合暗色主题的柔和氛围。
success: '#66BB6A' ------ 成功绿。RGB (102, 187, 106),用于"已满"状态标签、我的场次统计等正向反馈信息。这是一种中等饱和度的绿色,在深紫背景上既醒目又不刺眼。
warning: '#FFB74D' ------ 警告金。RGB (255, 183, 77),用于"差1人"状态标签,表示"即将满员"的提示。这个颜色与 gold 不同------warning 更偏橙黄,gold 更偏纯黄。这种微妙的差异让"警告"和"品质高亮"在视觉上有所区分。
danger: '#EF5350' ------ 危险红。RGB (239, 83, 80),用于"差2人""差3人""差4人"状态标签、退出组局弹框头卡、难度五星标记等需要警示的元素。这是一种偏向珊瑚红的红色,比纯红 #FF0000 更柔和,在深色主题上不会过于刺眼。
white: '#FFFFFF' ------ 纯白。用于弹框头卡标题文字、按钮文字等需要最高对比度的场景。在弹框头卡中,由于头卡背景是 primaryDark 或 danger 等深色,纯白文字提供了最高可读性。
gold: '#FFD54F' ------ 金色。RGB (255, 213, 79),用于评分数字、榜首标签、热门角色排名等"品质等级"相关的视觉元素,暗示剧本或角色的"含金量"。金色在深色主题中具有天然的"尊贵"暗示,与剧本杀的"古风"气质高度契合。
这套 15 色暗夜紫调体系,通过深紫背景+琥珀强调+金色高亮的三层色阶,构建了一种"古宅夜谈"的沉浸式视觉语言。颜色体系的设计不仅考虑了美学层面(色相搭配、明度对比、饱和度平衡),还考虑了语义层面(成功/警告/危险的功能色映射)和层次层面(主文字/次文字/提示文字的明度梯度),是一个完整的色彩工程体系。
五、数据模型层
数据模型是组件状态与 UI 之间的桥梁。这款应用定义了 4 个 @Observed 类和 5 个普通接口,构建了完整的数据模型层。
5.1 ScriptItem 剧本数据模型
typescript
@Observed
export class ScriptItem {
id: number = 0
name: string = ''
type: string = ''
players: string = ''
duration: string = ''
difficulty: number = 0
rating: number = 0
tag: string = ''
constructor(id: number, name: string, type: string, players: string, duration: string, difficulty: number, rating: number, tag: string) {
this.id = id; this.name = name; this.type = type; this.players = players
this.duration = duration; this.difficulty = difficulty; this.rating = rating; this.tag = tag
}
}
ScriptItem 是剧本的核心数据模型,使用 @Observed 装饰器标记,意味着当其实例属性变化时,引用该实例的组件会自动更新。它包含 8 个字段:id(唯一标识)、name(剧本名称,如"年轮")、type(类型,如"情感沉浸")、players(人数,如"6人")、duration(时长,如"4小时")、difficulty(难度,1-5 的整数)、rating(评分,如 9.2)、tag(标签,如"催泪")。每个字段都有默认初始值,constructor 一次性接收所有参数并赋值。
这种"全字段构造器"的设计虽然参数较多,但保证了数据实例的完整性------创建时必须提供所有字段,避免了部分字段为空的"半成品"数据。在 TypeScript 中,如果使用可选属性(?:),则需要在每个使用点进行空值检查,这会增加代码的防御性开销。而"全必填"设计在编译期就排除了空值风险,让消费端代码更加简洁。
difficulty 字段是 number 类型而非字符串,这是因为难度需要参与数值比较(在 diffColor 函数中 d >= 5 和 d >= 4)。rating 也是 number 类型,用于排序和比较。而 players 和 duration 是字符串类型,因为它们包含"人"和"小时"等中文后缀,直接以字符串存储避免了每次渲染时拼接后缀的开销。
5.2 SessionItem 组局数据模型
typescript
@Observed
export class SessionItem {
id: number = 0
script: string = ''
host: string = ''
time: string = ''
location: string = ''
filled: number = 0
total: number = 0
status: string = ''
constructor(id: number, script: string, host: string, time: string, location: string, filled: number, total: number, status: string) {
this.id = id; this.script = script; this.host = script; this.host = host; this.time = time
this.location = location; this.filled = filled; this.total = total; this.status = status
}
}
SessionItem 是组局的核心数据模型,同样使用 @Observed 标记。它包含 8 个字段:id、script(剧本名称)、host(DM 主持人)、time(开本时间)、location(场地)、filled(已报名人数)、total(总人数)、status(状态文字,如"差2人")。
filled 和 total 两个字段配合使用,用于计算进度条宽度百分比(filled / total * 100)。将这两个数值分开存储而非合并为一个百分比字段,是因为原始数值(已报/总人数)比百分比更具信息量------用户需要知道"4/6"而非"66%"。status 是一个字符串而非枚举,通过 statusColor 函数映射到不同的颜色。这种"状态文字+颜色映射函数"的设计比硬编码枚举更灵活------新增状态只需在数据中添加并在函数中增加映射即可,不需要修改枚举定义和类型约束。
当用户通过 doAdd 函数创建新组局时,会构造一个新的 SessionItem 实例(ID 为 999,主持人为"我",已报 1 人/共 6 人,状态"差5人"),并通过 unshift 插入到 sessionList 头部。由于 sessionList 是 @State 变量,插入操作会触发组局大厅列表、我的页面组局记录预览的自动更新。
5.3 GuideItem 攻略数据模型
typescript
@Observed
export class GuideItem {
id: number = 0
title: string = ''
author: string = ''
script: string = ''
views: number = 0
likes: number = 0
type: string = ''
constructor(id: number, title: string, author: string, script: string, views: number, likes: number, type: string) {
this.id = id; this.title = title; this.author = author; this.script = script
this.views = views; this.likes = likes; this.type = type
}
}
GuideItem 是攻略心得的数据模型,包含 7 个字段:id、title(攻略标题)、author(作者)、script(关联剧本)、views(浏览量)、likes(点赞数)、type(攻略类型,如"攻略""避坑""角色""解析"等)。views 和 likes 两个数值字段为攻略列表提供了热度排序的依据。script 字段建立了攻略与剧本之间的关联------这种"关联字段"是社交化内容平台的基础设计,允许用户从剧本详情页跳转到相关攻略,或从攻略页面跳转到剧本信息。
5.4 FeedItem 动态数据模型
typescript
@Observed
export class FeedItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
script: string = ''
role: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, script: string, role: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.script = script; this.role = role; this.time = time; this.likes = likes
}
}
FeedItem 是剧友圈动态的数据模型,包含 8 个字段:id、nick(昵称)、avatar(头像,使用 emoji 字符如"🎭")、text(动态文字)、script(关联剧本)、role(扮演角色)、time(发布时间)、likes(点赞数)。使用 emoji 作为头像是这类社区应用的常见做法------在缺乏真实用户头像图片资源时,emoji 提供了一种轻量级、富有表现力的视觉替代方案。
FeedItem 是应用中交互最频繁的数据模型------用户加入组局时会通过 doJoin 函数向 feedList 插入新的 FeedItem 实例。新实例的 nick 为"我",avatar 为"🎭",text 为 加入了《${this.joinScript}》组局,role 为用户选择的角色(如"侦探"),time 为"刚刚",likes 为 0。这种"操作即动态"的设计让用户的每个行为都能在社区中留下痕迹,增强了社交反馈感。
5.5 排行与配置接口
除了 4 个 @Observed 数据模型类,应用还定义了 5 个普通接口用于排行和配置:
typescript
interface WeekChartItem { label: string; value: number; }
interface TypeDist { label: string; value: number; color: string; }
interface HotRole { name: string; script: string; icon: string; pick: number; }
interface FeatureScript { icon: string; name: string; type: string; tag: string; }
interface NavItem { icon: string; label: string; }
WeekChartItem 用于本周组局场次柱状图,包含标签和数值两个字段。TypeDist 用于剧本类型分布,比 WeekChartItem 多了一个 color 字段,因为每个类型需要用不同颜色区分。HotRole 用于热门角色排行榜,包含角色名、所属剧本、图标和被选次数。FeatureScript 用于精选剧本卡片,包含图标、名称、类型和标签。NavItem 用于导航栏配置,包含图标和标签。这些接口不需要 @Observed 标记,因为它们是静态数据,不涉及动态变化的场景。
区分 @Observed 类和普通接口的设计哲学是:只有需要"运行时可变"的数据才使用 @Observed 类,纯展示用的静态数据使用接口即可。这种区分避免了不必要的观察开销------@Observed 会在每个属性上设置 Proxy 拦截器,如果数据不会变化,这个拦截器就是纯开销。在工程实践中,这种"按需观察"的设计能显著降低大型应用的内存和 CPU 开销。
六、静态数据与模拟数据
应用通过 10 组静态常量数据模拟了完整的业务场景。这些数据在应用启动时即加载,为 UI 组件提供渲染所需的全部内容。
6.1 剧本列表 SCRIPT_LIST
SCRIPT_LIST 包含 12 部剧本,覆盖了情感沉浸、恐怖推理、古风权谋、硬核推理、情感治愈、恐怖悬疑、古风阵营、欧式推理、日式情感、现代悬疑、欧式恐怖、武侠阵营等多种类型。每部剧本都有完整的 8 个字段数据,包括人数(4-8人)、时长(3-5小时)、难度(2-5星)、评分(8.7-9.8分)和标签。这种多类型覆盖确保了剧本库页面和类型分布图表有足够的数据维度。
剧本名称的设计也颇具匠心------"年轮"暗示情感时光、"古墓迷踪"暗示恐怖探险、"长安夜未央"暗示古风权谋、"第七号嫌疑人"暗示硬核推理。每个名称都与其类型标签高度匹配,让用户能通过名称直觉判断剧本的风格基调。
6.2 组局列表 SESSION_LIST
SESSION_LIST 包含 10 场组局,每场都有 DM 主持人(小夜/阿月/老张三位)、时间(今晚到下周五)、场地(朝阳/海淀/西单/望京四家门店)、已报/总人数比和状态文字。状态文字包括"差1人""差2人""差3人""差4人""已满"五种,覆盖了从即将满员到已满员的完整状态谱系。
三位 DM 的命名也暗含角色设定------"小夜"暗示夜间场次、"阿月"暗示月色氛围、"老张"暗示资深老手。四家门店覆盖了北京的东西南北四个方位,模拟了真实的线下组局网络。
6.3 攻略列表与动态列表
GUIDE_LIST 包含 8 篇攻略,类型涵盖"攻略""避坑""角色""解析""教程""实录""礼仪"等,既有针对特定剧本的攻略(如《长安夜未央》全结局攻略),也有通用指南(如新手 DM 入坑指南、剧本杀社交礼仪)。
FEED_LIST 包含 8 条动态,每条都带有 emoji 头像(🎭👻💔🔍🧟⚔️🎤📋)、关联剧本、扮演角色和点赞数。动态内容模拟了真实剧友圈的交流场景------有人分享打本感受,有人预警恐怖桥段,有人分析角色弧光。
6.4 统计图表数据与导航配置
WEEK_CHART 是本周组局场次柱状图数据,周一到周日的场次从 3 到 15 不等,周末为高峰(周六 15 场)。TYPE_DIST 是剧本类型分布数据,5 种类型各有不同占比和颜色。HOT_ROLES 是热门角色 TOP6 排行,每个角色都有图标、所属剧本和被选次数。FEATURE_SCRIPTS 是精选剧本卡片数据,4 部高分剧本展示在首页精选区。
typescript
const NAV_LIST: NavItem[] = [
{ icon: '🏠', label: '首页' },
{ icon: '📖', label: '剧本库' },
{ icon: '🎲', label: '组局' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '剧本库', '组局大厅', '攻略心得', '剧友圈'];
NAV_LIST 定义了底部 4 主 Tab 的图标和标签,SUB_NAV_LIST 定义了首页 5 个内容 Tab 的标签。这两组配置数据驱动了整个导航系统的渲染------如果需要增减 Tab,只需修改这两组常量即可,无需改动组件代码。这种"配置驱动 UI"的设计模式是组件化开发的核心原则之一,它将"做什么"(配置数据)与"怎么做"(组件逻辑)完全分离,提高了代码的可维护性和可扩展性。
七、工具函数体系
应用定义了 7 个顶层纯函数,分为三类:数据可视化计算函数、状态颜色映射函数、特效动画计算函数。这些函数都是无副作用的纯函数------相同的输入永远产生相同的输出,不依赖也不修改任何外部状态。这种函数式编程风格在 UI 开发中有独特优势:纯函数易于测试、易于组合、易于推理,是构建可靠 UI 系统的基础。
7.1 数据可视化计算函数 barH
typescript
function barH(v: number, max: number): number {
return Math.round(100 * v / max);
}
barH 函数用于计算柱状图的高度百分比。它接收两个参数:当前值 v 和最大值 max,返回 v / max 的百分比取整。例如 barH(15, 15) 返回 100,barH(4, 15) 返回 27。这个函数被用于本周组局场次柱状图的高度计算------所有柱子的高度都是相对于最大值 15 的百分比。使用 Math.round 取整避免了浮点数像素带来的渲染模糊问题。
这种"相对最大值百分比"的计算方式是数据可视化的常见模式。它的优势在于自适应------当数据范围变化时(如最大值从 15 变为 20),所有柱子的高度会自动重新缩放,无需手动调整。如果使用绝对像素值,则需要硬编码每个柱子的高度,维护成本高且不具备自适应性。
7.2 难度颜色映射函数 diffColor
typescript
function diffColor(d: number): string {
if (d >= 5) {
return COLORS.danger;
} else if (d >= 4) {
return COLORS.accent;
}
return COLORS.success;
}
diffColor 函数将剧本难度(1-5 的整数)映射到颜色。难度 5(最高)映射到危险红 COLORS.danger,难度 4 映射到琥珀橙 COLORS.accent,难度 3 及以下映射到成功绿 COLORS.success。这种颜色编码让用户在浏览剧本库时能通过星星的颜色快速判断难度等级------红色代表"硬核烧脑",橙色代表"中等挑战",绿色代表"轻松入门"。
使用 >= 而非 === 的设计使得函数对未来可能的"半星"评分(如 4.5 星)也具有兼容性------如果难度字段从整数变为浮点数,d >= 5 和 d >= 4 的判断仍然有效。这种"防御性编程"思维在纯函数设计中很重要,因为纯函数一旦定义就可能被多个调用点使用,修改参数类型的影响面很大。
7.3 状态颜色映射函数 statusColor
typescript
function statusColor(s: string): string {
if (s === '已满') {
return COLORS.success;
} else if (s === '差1人') {
return COLORS.warning;
} else if (s === '差2人') {
return COLORS.accent;
}
return COLORS.danger;
}
statusColor 函数将组局状态文字映射到颜色。"已满"映射到成功绿(表示组局完成),"差1人"映射到警告金(表示即将满员),"差2人"映射到琥珀橙(表示差一两人),"差3人""差4人"和其他状态映射到危险红(表示严重缺人)。这种映射逻辑形成了一个从"绿色安全"到"红色告急"的颜色梯度,让用户能通过颜色直觉判断组局的紧迫程度。
这个函数同时被组局大厅列表的状态标签、进度条颜色、我的页面组局记录预览三处调用------纯函数的"单一定义多处复用"特性在这里得到了充分体现。如果需要调整颜色映射逻辑(如将"差1人"从警告金改为成功绿),只需修改这一处函数即可,所有调用点自动生效。
7.4 迷雾粒子位置与透明度函数
typescript
function fogX(tick: number, i: number): number {
return 20 + ((tick * 7 + i * 167) % 610);
}
function fogY(tick: number, i: number): number {
return 40 + ((tick * 5 + i * 113) % 590);
}
function fogA(tick: number, i: number): number {
if ((tick + i * 3) % 5 === 0) {
return 0.4;
}
return 0.15;
}
这三个函数共同计算迷雾粒子的位置和透明度。fogX 和 fogY 通过取模运算(% 610 和 % 590)将粒子的位置限制在屏幕范围内,同时通过 tick * 7 + i * 167 和 tick * 5 + i * 113 的不同系数让每个粒子的移动速度和轨迹各不相同。tick 是随时间递增的状态变量,i 是粒子的索引。
使用质数系数(7、167、5、113)可以最大程度地减少不同粒子之间的运动模式重叠,让每个粒子的漂浮轨迹看起来是独立的。如果使用非质数系数(如 2、4、6),不同粒子的运动周期会产生公倍数,导致多个粒子在某些时刻同步移动,破坏"随机漂浮"的自然感。质数之间的最小公倍数极大,使得同步的概率趋近于零------这是数论在动画设计中的一个实际应用。
fogA 函数控制粒子的透明度------当 (tick + i * 3) % 5 === 0 时返回 0.4(较亮),否则返回 0.15(较暗)。这种基于取模的透明度变化让粒子呈现出"时隐时现"的闪烁效果,模拟迷雾在烛光中若隐若现的视觉效果。不同粒子(i * 3)的闪烁周期也不同,避免了所有粒子同时明灭的机械感。
7.5 烛光透明度函数 candleA
typescript
function candleA(tick: number, i: number): number {
if ((tick + i * 2) % 3 === 0) {
return 0.8;
}
return 0.3;
}
candleA 函数计算烛光的闪烁透明度。当 (tick + i * 2) % 3 === 0 时返回 0.8(明亮),否则返回 0.3(暗淡)。与迷雾粒子不同,烛光的闪烁频率更高(每 3 个 tick 闪烁一次,而迷雾是每 5 个 tick),且明暗对比更强烈(0.8 vs 0.3,差距 0.5;而迷雾是 0.4 vs 0.15,差距 0.25)。这种差异化的闪烁参数让迷雾的"缓慢漂浮"和烛光的"快速跳动"在视觉上形成了鲜明的节奏对比------迷雾是背景的底层氛围,烛光是前景的点缀闪烁。
从物理模拟的角度来看,这种设计有一定合理性------真实的烛光确实会因为气流波动而快速闪烁,而迷雾的移动则受温度梯度驱动,变化较为缓慢。虽然在 ArkUI 中通过纯函数模拟物理现象只是一种近似,但通过参数差异化(频率和对比度)传达的"视觉节奏"足以让用户感知到两种特效的不同质感。
八、组件主体结构
typescript
@Entry
@Component
struct PageMysteryNight {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State joinOpen: boolean = false
@State editNick: string = ''
@State editRole: string = ''
@State editLevel: string = ''
@State addScript: string = ''
@State addTime: string = ''
@State addLocation: string = ''
@State joinScript: string = ''
@State joinRole: string = '侦探'
@State feedList: FeedItem[] = FEED_LIST
@State sessionList: SessionItem[] = SESSION_LIST
@State fxTimer: number = -1
PageMysteryNight 是整个应用的入口组件,通过 @Entry 标记为页面入口,通过 @Component 声明为自定义组件。它定义了 18 个 @State 状态变量,可以分为四组:
导航状态组 :mainTab(主 Tab 索引,0-3)和 subTab(内容 Tab 索引,0-4),这两个变量驱动了整个页面的条件渲染逻辑。mainTab 的初始值为 0(首页),subTab 的初始值也为 0(精选),确保应用启动时显示首页精选内容。
特效状态组 :tick(动画时钟,每 120ms 递增)和 fxTimer(定时器 ID,用于生命周期管理),这两个变量驱动了迷雾粒子和烛光的动画。tick 的初始值为 0,fxTimer 的初始值为 -1(表示未启动)。
弹框开关组 :addOpen、editOpen、delOpen、joinOpen 四个布尔变量,分别控制创建组局、编辑角色、退出组局、加入组局四套弹框的显示与隐藏。初始值均为 false,确保应用启动时所有弹框都是隐藏的。
表单临时状态组 :editNick/editRole/editLevel(编辑角色弹框的三个输入字段)、addScript/addTime/addLocation(创建组局弹框的三个输入字段)、joinScript/joinRole(加入组局弹框的两个字段),这些变量临时存储弹框中的表单数据。joinRole 的初始值为 '侦探',因为侦探是最常见的角色选择。
响应式列表组 :feedList(动态列表,初始化为 FEED_LIST)和 sessionList(组局列表,初始化为 SESSION_LIST),这两个列表是可变的------用户创建组局会向 sessionList 插入新条目,加入组局会向 feedList 插入新动态,退出组局会从 sessionList 删除条目。
8.1 生命周期函数
typescript
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
}, 120);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear 是组件即将挂载时的生命周期回调。在这里,它启动了一个 120ms 间隔的定时器,每次递增 tick 状态变量。这个递增的 tick 驱动了所有迷雾粒子和烛光的动画------每次 tick 变化,fogX/fogY/fogA/candleA 函数都会返回新的值,触发 ForEach 重新渲染粒子的位置和透明度。
aboutToDisappear 是组件即将销毁时的生命周期回调。它检查 fxTimer 是否为正数(表示定时器仍在运行),如果是则清除定时器并将 fxTimer 重置为 -1。这种"成对管理"的生命周期模式是 ArkUI 动画的标准范式------在 aboutToAppear 中启动,在 aboutToDisappear 中清理,确保组件销毁后不会产生内存泄漏或空指针异常。
120ms 的间隔在流畅度(约 8 FPS 的动画更新)和性能之间取得了平衡------对于迷雾和烛光这种"缓慢氛围"特效,不需要 60FPS 的丝滑动画,8FPS 的跳动反而更有"摇曳"的质感。如果使用 requestAnimationFrame(约 16.67ms 间隔,60 FPS),虽然动画更流畅,但 CPU 占用会高出 7.5 倍,在低端设备上可能导致卡顿和发热。120ms 的间隔是一个经过权衡的"氛围特效"帧率------足够慢以保证性能,又足够快以让用户感知到动态变化。
8.2 弹框开关函数
typescript
openAdd(): void {
this.addScript = '';
this.addTime = '';
this.addLocation = '';
this.addOpen = true;
}
openEdit(): void {
this.editNick = '谜局玩家';
this.editRole = '推理爱好者';
this.editLevel = '高级';
this.editOpen = true;
}
openDel(): void {
this.delOpen = true;
}
openJoin(script: string): void {
this.joinScript = script;
this.joinRole = '侦探';
this.joinOpen = true;
}
这四个 open 函数是对弹框显示逻辑的封装。每个函数在打开弹框前会初始化对应的表单临时状态------openAdd 清空三个输入字段为空字符串,openEdit 预填默认值(昵称"谜局玩家"、角色"推理爱好者"、等级"高级"),openDel 不需要表单数据直接打开,openJoin 接收一个 script 参数设置目标剧本并重置角色为"侦探"。
这种"打开即初始化"的设计保证了每次打开弹框时表单都是干净的或预填了合理默认值,避免了上次输入的残留数据污染当前操作。特别是 openEdit 的预填值设计------它假设当前用户是"谜局玩家"、偏好"推理爱好者"、等级"高级"------这些预填值在真实应用中应该从用户档案接口获取,在当前原型中用硬编码模拟。
openJoin 是唯一接收参数的函数,它接受 script: string 参数。这个设计使得加入组局弹框可以从多个入口触发------剧本库的剧本卡片、精选首页的热打剧本卡、组局大厅的加入拼车按钮------每个入口传递不同的剧本名称,弹框打开后显示对应剧本的信息。这种"参数化弹框"设计是组件复用的高级模式。
8.3 弹框确认函数
typescript
doAdd(): void {
if (this.addScript.length > 0) {
this.sessionList.unshift(new SessionItem(999, this.addScript, '我', this.addTime, this.addLocation, 1, 6, '差5人'));
}
this.addOpen = false;
}
doEdit(): void {
this.editOpen = false;
}
doDel(): void {
if (this.sessionList.length > 0) {
this.sessionList.splice(0, 1);
}
this.delOpen = false;
}
doJoin(): void {
if (this.joinScript.length > 0) {
this.feedList.unshift(new FeedItem(999, '我', '🎭', `加入了《${this.joinScript}》组局`, this.joinScript, this.joinRole, '刚刚', 0));
}
this.joinOpen = false;
}
这四个 do 函数处理弹框的确认操作。doAdd 在剧本名称非空时向 sessionList 头部插入一条新的组局记录(ID 为 999、主持人为"我"、已报1人/共6人、状态"差5人"),然后关闭弹框。doEdit 直接关闭弹框(当前版本未实现实际的数据保存)。doDel 在列表非空时删除 sessionList 的第一个元素(即最新的组局),然后关闭弹框。doJoin 在剧本名称非空时向 feedList 头部插入一条新动态(昵称"我"、文字"加入了《xxx》组局"),然后关闭弹框。
使用 unshift 在头部插入而非 push 在尾部追加,是因为新动态应该出现在列表最顶部,符合社交媒体"最新内容在最前"的交互习惯。这种"头部插入"模式让用户在执行操作后能立即在列表顶部看到自己的操作结果,形成了即时反馈闭环。doDel 使用 splice(0, 1) 删除第一个元素而非 pop() 删除最后一个元素,是因为最新的组局在列表头部------用户"退出首个组局"的语义是退出列表中的第一条记录。
doAdd 和 doJoin 都有空值保护(length > 0 检查),防止在剧本名称为空时插入无效数据。doDel 有列表非空保护(length > 0 检查),防止在空列表上调用 splice 导致错误。这些防御性检查是健壮代码的基本要求。
九、特效层详解
typescript
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Column()
.width(40 + i * 8)
.height(20)
.borderRadius(10)
.backgroundColor('#6A1B9A')
.opacity(fogA(this.tick, i))
.translate({ x: fogX(this.tick, i), y: fogY(this.tick, i) })
}, (i: number) => 'f' + i.toString())
ForEach([0, 1, 2, 3, 4], (i: number) => {
Text('🕯️')
.fontSize(10 + (i % 3) * 3)
.opacity(candleA(this.tick, i))
.translate({ x: 60 + i * 120, y: 40 + ((this.tick * 3 + i * 80) % 600) })
}, (i: number) => 'c' + i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
fxLayer 是特效层的构建函数,使用 Stack 容器叠加了两套动画系统。这是整个应用氛围感的核心来源------迷雾粒子营造"暗夜密室"的底层氛围,烛光闪烁提供"摇曳灯火"的前景点缀。
特效层动画流程图
#mermaid-svg-ypv7158dLBLulakm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ypv7158dLBLulakm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ypv7158dLBLulakm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ypv7158dLBLulakm .error-icon{fill:#552222;}#mermaid-svg-ypv7158dLBLulakm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ypv7158dLBLulakm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ypv7158dLBLulakm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ypv7158dLBLulakm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ypv7158dLBLulakm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ypv7158dLBLulakm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ypv7158dLBLulakm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ypv7158dLBLulakm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ypv7158dLBLulakm .marker.cross{stroke:#333333;}#mermaid-svg-ypv7158dLBLulakm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ypv7158dLBLulakm p{margin:0;}#mermaid-svg-ypv7158dLBLulakm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ypv7158dLBLulakm .cluster-label text{fill:#333;}#mermaid-svg-ypv7158dLBLulakm .cluster-label span{color:#333;}#mermaid-svg-ypv7158dLBLulakm .cluster-label span p{background-color:transparent;}#mermaid-svg-ypv7158dLBLulakm .label text,#mermaid-svg-ypv7158dLBLulakm span{fill:#333;color:#333;}#mermaid-svg-ypv7158dLBLulakm .node rect,#mermaid-svg-ypv7158dLBLulakm .node circle,#mermaid-svg-ypv7158dLBLulakm .node ellipse,#mermaid-svg-ypv7158dLBLulakm .node polygon,#mermaid-svg-ypv7158dLBLulakm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ypv7158dLBLulakm .rough-node .label text,#mermaid-svg-ypv7158dLBLulakm .node .label text,#mermaid-svg-ypv7158dLBLulakm .image-shape .label,#mermaid-svg-ypv7158dLBLulakm .icon-shape .label{text-anchor:middle;}#mermaid-svg-ypv7158dLBLulakm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ypv7158dLBLulakm .rough-node .label,#mermaid-svg-ypv7158dLBLulakm .node .label,#mermaid-svg-ypv7158dLBLulakm .image-shape .label,#mermaid-svg-ypv7158dLBLulakm .icon-shape .label{text-align:center;}#mermaid-svg-ypv7158dLBLulakm .node.clickable{cursor:pointer;}#mermaid-svg-ypv7158dLBLulakm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ypv7158dLBLulakm .arrowheadPath{fill:#333333;}#mermaid-svg-ypv7158dLBLulakm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ypv7158dLBLulakm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ypv7158dLBLulakm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ypv7158dLBLulakm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ypv7158dLBLulakm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ypv7158dLBLulakm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ypv7158dLBLulakm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ypv7158dLBLulakm .cluster text{fill:#333;}#mermaid-svg-ypv7158dLBLulakm .cluster span{color:#333;}#mermaid-svg-ypv7158dLBLulakm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ypv7158dLBLulakm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ypv7158dLBLulakm rect.text{fill:none;stroke-width:0;}#mermaid-svg-ypv7158dLBLulakm .icon-shape,#mermaid-svg-ypv7158dLBLulakm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ypv7158dLBLulakm .icon-shape p,#mermaid-svg-ypv7158dLBLulakm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ypv7158dLBLulakm .icon-shape .label rect,#mermaid-svg-ypv7158dLBLulakm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ypv7158dLBLulakm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ypv7158dLBLulakm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ypv7158dLBLulakm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 渲染层
烛光闪烁计算
迷雾粒子计算
定时器驱动
组件销毁时
aboutToAppear
setInterval 120ms
tick = tick + 1
aboutToDisappear
clearInterval
fogX tick, i
X坐标 = 20 + tick*7+i*167 % 610
fogY tick, i
Y坐标 = 40 + tick*5+i*113 % 590
fogA tick, i
透明度 = 0.4 或 0.15
6个Column粒子
宽40+i*8 高20 圆角10
candleA tick, i
透明度 = 0.8 或 0.3
X坐标 = 60 + i*120
Y坐标 = 40 + tick*3+i*80 % 600
5个Text蜡烛emoji
字号10+i%3*3
Stack容器
alignContent TopStart
hitTestBehavior None
触摸穿透
迷雾粒子层 :通过 ForEach 遍历 6 个粒子(索引 0-5),每个粒子是一个 Column 组件。粒子的宽度随索引递增(40 + i * 8,从 40px 到 80px),高度固定 20px,圆角 10px,背景色为深紫 #6A1B9A。每个粒子的透明度由 fogA(this.tick, i) 计算决定,位置由 fogX 和 fogY 计算决定。由于 tick 每 120ms 递增一次,粒子的透明度和位置会持续变化,形成迷雾漂浮的效果。粒子的 key 函数返回 'f' + i,确保 ForEach 能正确追踪每个粒子的身份。
粒子的宽度设计 40 + i * 8 是一种"大小差异化"策略------6 个粒子的宽度从 40px 到 80px 不等,让迷雾看起来不是均匀的圆形,而是大小不一的"雾团"。这种大小差异增加了视觉的自然感,避免了所有粒子看起来完全相同的机械感。
烛光闪烁层 :通过 ForEach 遍历 5 个烛光(索引 0-4),每个烛光是一个 Text 组件显示蜡烛 emoji 🕯️。烛光的字体大小随索引变化(10 + (i % 3) * 3,从 10px 到 16px 循环),透明度由 candleA(this.tick, i) 计算决定。烛光的水平位置均匀分布(60 + i * 120,从 60px 到 540px),垂直位置随 tick 变化((this.tick * 3 + i * 80) % 600),形成从下到上的缓慢漂浮效果。
烛光字号使用 i % 3 而非 i 的设计让 5 个烛光的字号在 3 种大小间循环(10、13、16、10、13),而非线性递增。这种"循环大小"避免了最后一个烛光过大,保持了视觉的均匀性。
穿透触摸的关键属性 :整个特效层 Stack 设置了 hitTestBehavior(HitTestMode.None),这意味着特效层不会拦截任何触摸事件------所有触摸操作都会穿透到下方的内容层。这是特效层设计的核心决策:它只负责视觉装饰,不干扰任何用户交互。如果没有这个属性,漂浮的粒子可能会遮挡按钮的点击区域,导致交互失效。
HitTestMode.None 是 ArkUI 提供的三种命中测试模式之一(另外两种是 Default 和 Block)。Default 模式下组件正常参与命中测试,Block 模式下组件拦截所有触摸事件不向下传递,None 模式下组件完全忽略触摸事件让事件穿透。在特效层场景中,None 是唯一正确的选择------特效层需要可见但不可交互。
alignContent: Alignment.TopStart 确保了所有粒子和烛光的初始定位以左上角为基准,配合 translate 的偏移量计算,让定位逻辑更加直观------(0, 0) 是左上角,正 X 向右,正 Y 向下。translate 属性在 ArkUI 中用于平移变换,它不改变组件的布局位置(即不影响其他组件的排列),只在渲染时进行视觉偏移------这种"布局不变、渲染偏移"的特性让粒子可以自由移动而不影响内容区域的布局。
十、头部区域详解
头部区域由搜索栏、榜首剧本横幅卡和四格统计区三个部分组成,是用户进入应用后看到的第一屏。
10.1 搜索栏
typescript
Row({ space: 10 }) {
Row({ space: 6 }) {
Text('🔍')
.fontSize(14)
Text('搜剧本 / DM / 组局')
.fontSize(12)
.fontColor(COLORS.textHint)
.layoutWeight(1)
}
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
Stack({ alignContent: Alignment.TopEnd }) {
Text('📜')
.fontSize(22)
Text('2')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor(COLORS.danger)
.borderRadius(8)
.translate({ x: 6, y: -4 })
}
.width(34)
.height(30)
}
.width('100%')
搜索栏是一个横向 Row,包含一个搜索输入框(左侧,layoutWeight(1) 占据剩余空间)和一个消息通知按钮(右侧,固定 34x30 尺寸)。搜索输入框本身是一个 Row,包含放大镜 emoji 和占位提示文字"搜剧本 / DM / 组局",背景为卡片深紫、圆角 18px。
消息按钮使用 Stack 叠加了一个卷轴 emoji 和一个红色数字角标"2"------角标通过 translate 偏移到右上角外侧(x: 6, y: -4),模拟 iOS 风格的未读消息提示。alignContent: Alignment.TopEnd 让角标的初始定位在右上角,再通过 translate 微调到外侧。这种"Stack 叠加 + translate 微调"是 ArkUI 中实现角标的标准技巧。
搜索栏当前是一个纯展示组件------没有绑定 TextInput,点击不会触发搜索行为。在真实应用中,这里应该替换为可点击的搜索入口,点击后跳转到搜索页面。当前的设计意图是展示搜索栏的视觉样式,而非实现完整的搜索功能。
10.2 榜首剧本横幅卡
typescript
Row({ space: 14 }) {
Column({ space: 6 }) {
Text('🏛️ 长安夜未央')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
Text('7人 · 古风权谋 · 5小时 · 难度★★★★★')
.fontSize(10)
.fontColor('#D0C0E8')
Text('⭐ 9.8分 · 本周热打 326场 · 催泪指数MAX')
.fontSize(10)
.fontColor('#D0C0E8')
Row({ space: 6 }) {
Text('🔥 榜首')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.gold)
.borderRadius(6)
Text('立即拼车')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.accent)
.borderRadius(8)
.onClick(() => {
this.openJoin('长安夜未央');
})
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.linearGradient({
angle: 135,
colors: [[COLORS.primaryDark, 0.0], [COLORS.primary, 1.0]]
})
.borderRadius(16)
榜首剧本横幅卡是头部最醒目的区域。它使用 linearGradient 实现了从左上到右下的 135 度线性渐变------从 primaryDark(深暗紫 #38006B)渐变到 primary(深紫 #6A1B9A),形成了一种"从暗处到亮处"的视觉深度。
linearGradient 是 ArkUI 提供的线性渐变属性,接收 angle(角度)和 colors(颜色停靠点数组)两个参数。angle: 135 表示渐变方向为从左上到右下(0 度为从下到上,90 度为从左到右,135 度为从左上到右下)。colors 数组中每个元素是 [颜色, 位置] 的元组,位置从 0.0 到 1.0。这种渐变设计比纯色背景更有层次感,营造了"深邃空间"的视觉效果。
卡片内展示了本周榜首剧本"长安夜未央"的完整信息:金色标题、古风权谋类型、7人配置、5小时时长、五星难度、9.8 分评分、本周热打 326 场。底部两个标签按钮------"🔥 榜首"金色标签和"立即拼车"琥珀橙按钮,后者点击后调用 this.openJoin('长安夜未央') 打开加入组局弹框。
#D0C0E8 是一个在 COLORS 体系之外的内联颜色,比 textSecondary 更亮,用于横幅卡内的副信息文字。在工程实践中,内联颜色应该尽量避免,但在某些需要"微调"的场景下(如横幅卡内的文字需要比普通次文字更亮一些),内联颜色比在 COLORS 中新增一个字段更灵活。不过从一致性角度来说,更好的做法是将这个颜色纳入 COLORS 体系,如增加一个 textBanner 字段。
10.3 四格统计区
typescript
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('在管剧本')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('128本')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('今日组局')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('42场')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('活跃DM')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('18位')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('我的场次')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('36场')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
四格统计区展示了四个关键数据:在管剧本(128本,紫色数字)、今日组局(42场,橙色数字)、活跃 DM(18位,金色数字)、我的场次(36场,绿色数字)。每格使用 layoutWeight(1) 等分宽度,卡片深紫背景、圆角 10px、居中对齐。
四个数字分别使用 primary、accent、gold、success 四种不同颜色,让用户能通过颜色快速区分不同维度的数据。这种"四色四格"的统计设计在有限的头部空间内传达了应用运营的全貌------从剧本储备(128本)到今日活跃(42场)、从人力配置(18位DM)到个人参与(36场),四个维度覆盖了"供给侧"和"需求侧"的关键指标。
每格的结构完全相同------标签(9px 灰色)在上、数值(13px 加粗彩色)在下------这种"结构统一、颜色区分"的设计模式让四格在视觉上整齐划一,同时通过颜色传达不同的数据维度。在 ArkUI 中,Column({ space: 2 }) 设置了子元素间距为 2px,让标签和数值紧凑排列。
十一、子导航栏详解
typescript
@Builder
subNav() {
Scroll() {
Row({ space: 8 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Row({ space: 6 }) {
if (this.subTab === idx) {
Column()
.width(3)
.height(16)
.borderRadius(2)
.backgroundColor(COLORS.accent)
}
Text(item)
.fontSize(this.subTab === idx ? 13 : 11)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.gold : COLORS.textSecondary)
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.subTab === idx ? COLORS.primaryDark : COLORS.cardBg)
.borderRadius(12)
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
.alignItems(VerticalAlign.Center)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 6, bottom: 10 })
}
子导航栏是首页内容 Tab 的切换器,包含 5 个 Tab 标签:精选、剧本库、组局大厅、攻略心得、剧友圈。整个导航栏包裹在一个横向 Scroll 中(scrollable(ScrollDirection.Horizontal)),隐藏滚动条(scrollBar(BarState.Off)),当 Tab 数量超出屏幕宽度时可以横向滑动。
每个 Tab 是一个 Row,内部通过条件渲染判断是否显示竖条指示器。当 this.subTab === idx 时(当前选中态),会在文字左侧渲染一条 3px 宽、16px 高、琥珀橙色的竖条(backgroundColor(COLORS.accent)),这就是"竖条指示式"的设计------选中态用一条竖线作为视觉标记,类似于灯笼下方悬挂的吊牌。
同时,选中态的文字会变大(13px vs 11px)、加粗、变为金色,背景变为深暗紫(primaryDark)。这三重视觉变化------竖条指示器、文字样式、背景色------构成了"三层强化"的选中态反馈机制,让当前选中的 Tab 在多个维度上脱颖而出。
未选中态的文字为 11px、常规字重、紫灰色(textSecondary),背景为卡片深紫。点击任意 Tab 将 subTab 设置为对应索引,触发条件渲染切换内容区域。这种"点击即切换"的即时响应是移动端导航的基本要求------用户点击后内容区域应该立即更新,无需等待。
scrollBar(BarState.Off) 隐藏滚动条是一个重要的视觉细节------在移动端,横向滚动条会占据宝贵的垂直空间,且在暗色主题中滚动条的视觉效果不佳。隐藏滚动条让导航栏看起来更像"标签"而非"滚动区域",但当 Tab 数量超出屏幕时仍然可以滑动。
十二、精选首页 pageFeatured
精选首页是 subTab === 0 时显示的默认页面,包含本周热打剧本、组局场次柱状图、剧本类型分布、热门角色 TOP6、最新剧友动态五个板块。
12.1 本周热打剧本
typescript
Column() {
Text('本周热打剧本')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row({ space: 8 }) {
ForEach(FEATURE_SCRIPTS, (it: FeatureScript) => {
Column({ space: 4 }) {
Text(it.icon)
.fontSize(28)
Text(it.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.type)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(it.tag)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
}
.layoutWeight(1)
.padding({ top: 12, bottom: 10, left: 4, right: 4 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.openJoin(it.name);
})
}, (it: FeatureScript) => it.name)
}
.width('100%')
}
.width('100%')
这个板块通过 ForEach 渲染 4 部精选剧本卡片,横向排列(Row + layoutWeight(1) 等分)。每张卡片包含大图标(28px emoji)、剧本名称(加粗)、类型(小字)和评分标签(金色加粗)。
每张卡片的视觉层次通过字号递减实现------28px 图标最醒目,11px 名称次之,9px 类型最小,10px 评分标签用金色加粗突出。这种"字号梯度+颜色高亮"的层次设计让用户在扫视时能快速捕捉到关键信息(图标和评分),深入阅读时再看类型细节。
点击任意卡片调用 this.openJoin(it.name) 打开加入组局弹框。使用 it.name 作为 ForEach 的 key 函数返回值,确保每个剧本卡片有唯一标识------如果两个剧本同名,会导致 key 冲突和渲染异常,因此在数据层面保证名称唯一性很重要。
12.2 本周组局场次柱状图
typescript
Column() {
Row() {
Text('本周组局场次')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('单位 场')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row({ space: 10 }) {
ForEach(WEEK_CHART, (it: WeekChartItem) => {
Column({ space: 4 }) {
Column()
.width(20)
.height(barH(it.value, 15))
.borderRadius(5)
.backgroundColor(it.value > 10 ? COLORS.accent : COLORS.primary)
Text(`${it.value}`)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(it.label)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (it: WeekChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(120)
.margin({ top: 12 })
Row({ space: 6 }) {
Text('周末组局高峰')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('15场/日')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
柱状图通过 ForEach 渲染 7 天的数据。每根柱子是一个 Column 组件,宽度 20px,高度由 barH(it.value, 15) 计算(相对最大值 15 的百分比)。柱子颜色根据值的大小动态选择------超过 10 场的用琥珀橙(COLORS.accent,表示高峰),否则用深紫(COLORS.primary)。底部显示数值和星期标签。
alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐,形成标准的柱状图视觉------这是柱状图的核心视觉特征,所有柱子从同一条基线向上生长。柱状图容器高度为 120px,柱子最大高度为 100px(barH(15, 15) 返回 100),为数值和标签预留了 20px 的空间。
底部"周末组局高峰 15场/日"的文字提示,用琥珀橙大字突出峰值数据,帮助用户快速识别周末是组局高峰期。这种"图表+文字注释"的设计模式让数据可视化不仅展示数据,还传达洞察。
12.3 剧本类型分布
typescript
Column() {
Text('剧本类型分布')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(TYPE_DIST, (it: TypeDist) => {
Row({ space: 8 }) {
Text(it.label)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(64)
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.value}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.layoutWeight(1)
Text(`${it.value}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (it: TypeDist) => it.label)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
类型分布使用横向进度条展示 5 种剧本类型的占比。每行包含类型标签(固定 64px 宽)、进度条(Stack 叠加底色条和前景色条)和百分比数值(右对齐 44px 宽)。进度条的宽度直接使用百分比字符串 ${it.value}%,这是 ArkUI 中一个巧妙的技巧------直接将数据值映射为 CSS 宽度百分比。
Stack({ alignContent: Alignment.Start }) 让前景条从左侧开始覆盖底色条------底色条宽度为 100%(占满容器),前景条宽度为 ${it.value}%(根据数据值变化)。这种"底色+前景"的双层进度条是数据可视化的标准模式,底色提供"100%参照",前景显示实际占比。
每种类型使用各自的 color 字段作为前景色,底色统一为 border 色。这种"每类型不同色"的设计让用户能通过颜色快速区分不同类型------情感沉浸用深紫、硬核推理用琥珀橙、恐怖惊悚用危险红、古风阵营用金色、其他用灰紫色。
12.4 热门角色 TOP6
typescript
Column() {
Text('热门角色 TOP6')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 8 })
ForEach(HOT_ROLES, (it: HotRole, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.gold : COLORS.textHint)
.width(20)
Text(it.icon)
.fontSize(20)
Column({ space: 2 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.script)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(`🔥${it.pick}`)
.fontSize(11)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.margin({ top: 4 })
}, (it: HotRole) => it.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
热门角色排行榜展示了 6 个角色的排名、图标、名称、所属剧本和被选次数。排名数字前三名使用金色(COLORS.gold),后三名使用提示灰色(COLORS.textHint),形成了"金银铜"的视觉层次。
idx < 3 的判断将前三个角色(索引 0、1、2)标记为金色,后三个标记为灰色------这种"前三高亮"的设计是排行榜的通用模式,来源于体育竞技的"金银铜牌"传统。被选次数前缀火焰 emoji 并使用琥珀橙色,突出角色的热度。
12.5 最新剧友动态
typescript
Column({ space: 10 }) {
Row() {
Text('最新剧友动态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发动态')
.fontSize(12)
.fontColor(COLORS.accent)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.feedList.slice(0, 3), (it: FeedItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(22)
Column({ space: 3 }) {
Row() {
Text(it.nick)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(`🎭 ${it.role}`)
.fontSize(10)
.fontColor(COLORS.accent)
Text(`📖 ${it.script}`)
.fontSize(10)
.fontColor(COLORS.primary)
Text(`♥ ${it.likes}`)
.fontSize(10)
.fontColor(COLORS.danger)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: FeedItem) => it.id.toString())
}
.width('100%')
精选首页只展示动态列表的前 3 条(this.feedList.slice(0, 3)),作为精选预览。每条动态包含 emoji 头像、昵称、时间、动态文字(最多 2 行,超出省略)、角色标签、剧本标签和点赞数。
maxLines(2) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 的组合是文本截断的标准模式------动态文字最多显示 2 行,超出部分以省略号结尾。这种设计确保每条动态卡片的高度一致,不会因为文字过长而破坏列表的整体布局。
当用户在剧友圈发新动态后,feedList 头部插入新条目,精选首页的预览也会自动更新------这就是 @State 响应式状态的魅力:一处修改,多处同步。slice(0, 3) 每次渲染时都会从最新的 feedList 中取前 3 条,确保预览内容始终是最新的。
"发动态"入口点击调用 this.openAdd() 打开创建组局弹框------在当前实现中,"发动态"和"发起组局"共用同一个弹框,因为创建组局本质上也是一种动态行为。在更完整的应用中,这两个入口应该打开不同的弹框。
十三、剧本库 pageScriptLib
typescript
@Builder
pageScriptLib() {
Column({ space: 10 }) {
Row() {
Text('剧本库 · 全部剧本')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('共12本')
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
ForEach(SCRIPT_LIST, (it: ScriptItem) => {
Row({ space: 12 }) {
Column() {
Text(it.type === '情感沉浸' ? '💔' : (it.type === '恐怖推理' ? '👻' : (it.type === '硬核推理' ? '🔍' : (it.type === '古风权谋' ? '🏛️' : '🎭'))))
.fontSize(28)
}
.width(50)
.height(50)
.backgroundColor(COLORS.primaryDark)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`⭐${it.rating}`)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.gold)
}
.width('100%')
Row({ space: 8 }) {
Text(`🎭 ${it.type}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`👥 ${it.players}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`⏱️ ${it.duration}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
Row({ space: 8 }) {
Text(`难度`)
.fontSize(10)
.fontColor(COLORS.textHint)
Text('★'.repeat(it.difficulty))
.fontSize(10)
.fontColor(diffColor(it.difficulty))
Text(it.tag)
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor(COLORS.accent)
.borderRadius(4)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.onClick(() => {
this.openJoin(it.name);
})
}, (it: ScriptItem) => it.id.toString())
}
.width('100%')
}
剧本库页面是一个垂直列表,展示全部 12 部剧本。每条记录的左侧是一个 50x50 的深暗紫圆角图标区域,内部根据剧本类型动态选择 emoji 图标------通过嵌套三元运算符将"情感沉浸"映射到 💔、"恐怖推理"映射到 👻、"硬核推理"映射到 🔍、"古风权谋"映射到 🏛️,其他类型默认使用 🎭。
这种"类型→emoji"的映射逻辑虽然用三元运算符实现较为冗长,但在不引入额外映射函数的情况下完成了动态图标选择。如果类型种类更多(超过 5-6 种),建议提取为一个独立的映射函数或使用 Record<string, string> 查找表。当前 5 种类型的嵌套三元运算符在可读性上尚可接受。
右侧内容区包含三行信息:第一行是剧本名称(加粗)和评分(金色加粗,前缀星号 emoji);第二行是类型、人数、时长三个标签,用 emoji 前缀增加辨识度;第三行是难度文字、星星重复字符串('★'.repeat(it.difficulty),根据难度生成 1-5 颗星,颜色由 diffColor 函数动态映射)、和标签胶囊。
'★'.repeat() 是 JavaScript 字符串方法在 ArkUI 中的直接使用,通过重复字符生成可视化的星级评分。这种方法比渲染多个独立的星形图标更简洁,且在不同难度下自动调整星星数量。星星的颜色由 diffColor(it.difficulty) 动态计算------5星红色、4星橙色、3星及以下绿色,让用户通过颜色直觉判断难度等级。
点击任意剧本卡片调用 this.openJoin(it.name) 打开加入组局弹框,实现了从剧本浏览到组局参与的直接转化路径。这种"浏览即转化"的设计缩短了用户从发现剧本到参与组局的路径,提高了转化率。
十四、组局大厅 pageSessions
typescript
@Builder
pageSessions() {
Column({ space: 10 }) {
Row() {
Text('组局大厅 · 拼车')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发起组局')
.fontSize(12)
.fontColor(COLORS.accent)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.sessionList, (it: SessionItem) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🎲')
.fontSize(24)
Column({ space: 4 }) {
Text(it.script)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Row({ space: 8 }) {
Text(`🧑⚖️ ${it.host}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`📍 ${it.location}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
Text(`🕒 ${it.time}`)
.fontSize(11)
.fontColor(COLORS.gold)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primaryDark)
.borderRadius(8)
}
.width('100%')
Row({ space: 8 }) {
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${it.filled / it.total * 100}%`)
.height(6)
.borderRadius(3)
.backgroundColor(statusColor(it.status))
}
.layoutWeight(1)
Text(`${it.filled}/${it.total}人`)
.fontSize(10)
.fontColor(COLORS.textSecondary)
.width(48)
.textAlign(TextAlign.End)
}
.width('100%')
Row({ space: 8 }) {
Text('查看详情')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
if (it.status === '已满') {
Text('已满员')
.fontSize(11)
.fontColor(COLORS.textHint)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.border)
.borderRadius(8)
} else {
Text('加入拼车')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.accent)
.borderRadius(8)
.onClick(() => {
this.openJoin(it.script);
})
}
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: SessionItem) => it.id.toString())
}
.width('100%')
}
组局大厅是应用的核心业务页面,展示所有可参与的组局列表。页面顶部有"发起组局"入口(点击调用 this.openAdd() 打开创建组局弹框)。
每场组局是一个 Column 卡片,包含三层信息:
第一层:基本信息行 。左侧是骰子 emoji 图标,右侧是剧本名称(加粗)、DM 主持人和场地(同行展示)、开本时间(金色)。最右侧是状态标签------使用 statusColor(it.status) 动态映射颜色,背景为深暗紫、圆角 8px。状态标签的颜色与进度条颜色保持一致(都通过 statusColor 计算),形成了"状态色贯穿"的视觉一致性。
第二层:进度条行 。使用 Stack 叠加底色条和前景条,前景条宽度通过 ${it.filled / it.total * 100}% 计算已报名人数占比,颜色与状态标签保持一致。右侧显示"已报/总人数"文字。这种"进度条+数值"的双重展示让用户能同时通过视觉长度和精确数字判断组局填充程度。
进度条高度为 6px,圆角 3px------这种"细条圆角"的设计让进度条在视觉上更加精致,不会占用过多垂直空间。前景条和底色条都使用相同的圆角值,确保两端的对齐效果。
第三层:操作行 。左侧"查看详情"文字,右侧根据状态条件渲染------"已满"状态显示灰色的"已满员"标签(不可点击),其他状态显示琥珀橙色的"加入拼车"按钮(点击调用 this.openJoin(it.script) 打开加入组局弹框)。
这种"满员禁用"的设计防止了用户向已满组局发起无效申请。已满员标签使用 COLORS.border 背景色和 COLORS.textHint 文字色------这两种颜色都是色彩体系中最暗的颜色,视觉上"退后",暗示"不可操作"。而"加入拼车"按钮使用 COLORS.accent 背景色和 COLORS.white 文字色------高对比度的暖色按钮在视觉上"前进",吸引用户点击。
十五、攻略心得 pageGuide
typescript
@Builder
pageGuide() {
Column({ space: 10 }) {
Text('攻略心得 · 剧友经验')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(GUIDE_LIST, (it: GuideItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column() {
Text(`#${idx + 1}`)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.gold : COLORS.textHint)
}
.width(36)
Column({ space: 4 }) {
Row() {
Text(it.type)
.fontSize(9)
.fontColor(COLORS.white)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor(COLORS.primary)
.borderRadius(4)
Text(it.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
.margin({ left: 6 })
}
.width('100%')
Row({ space: 8 }) {
Text(`✍️ ${it.author}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`📖 ${it.script}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
Row({ space: 12 }) {
Text(`👁️ ${it.views}`)
.fontSize(10)
.fontColor(COLORS.textHint)
Text(`♥ ${it.likes}`)
.fontSize(10)
.fontColor(COLORS.danger)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: GuideItem) => it.id.toString())
}
.width('100%')
}
攻略心得页面展示 8 篇攻略的列表。每篇攻略左侧是排名编号(#1 到 #8),前三名使用金色,后五名使用灰色------与热门角色排行榜的"金银铜"逻辑一致。
右侧内容区包含三行:
第一行是类型标签(紫色胶囊底+白色小字)和攻略标题(加粗),标签和标题在同一行通过 layoutWeight(1) 分配空间。类型标签使用 COLORS.primary 深紫底色和 COLORS.white 白色文字,形成"深底浅字"的高对比度胶囊。这种"标签+标题"的行内布局是内容列表的常见模式,让用户能通过标签快速筛选感兴趣的类型。
第二行是作者和关联剧本,用 emoji 前缀增加辨识度。✍️ 表示作者,📖 表示剧本------这种 emoji 前缀的设计比纯文字标签更直观,用户在扫视时能通过 emoji 快速识别信息的类别。
第三行是浏览量(眼睛 emoji 前缀,灰色)和点赞数(心形前缀,红色),这两个数据指标反映了攻略的热度和质量。浏览量使用 textHint 灰色(最暗的文字色),点赞数使用 danger 红色------这种"浏览暗、点赞亮"的颜色对比引导用户关注点赞数(质量指标)而非浏览量(曝光指标)。
攻略列表不支持点击操作------这是一个纯展示页面,攻略详情需要在真实应用中通过跳转到详情页实现。
十六、剧友圈 pageCircle
typescript
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('剧友圈 · 交流动态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发动态')
.fontSize(12)
.fontColor(COLORS.accent)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.feedList, (it: FeedItem) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(13)
.fontColor(COLORS.textSecondary)
Row({ space: 6 }) {
Text(`🎭 ${it.role}`)
.fontSize(10)
.fontColor(COLORS.accent)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
Text(`📖 ${it.script}`)
.fontSize(10)
.fontColor(COLORS.primary)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(6)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Row({ space: 16 }) {
Text(`♥ ${it.likes}`)
.fontSize(12)
.fontColor(COLORS.danger)
Text('💬 评论')
.fontSize(12)
.fontColor(COLORS.textHint)
Text('🔄 转发')
.fontSize(12)
.fontColor(COLORS.textHint)
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}, (it: FeedItem) => it.id.toString())
}
.width('100%')
}
剧友圈是社区动态的完整展示页面,与精选首页的 3 条预览不同,这里展示全部动态。页面顶部有"发动态"入口(点击调用 this.openAdd() 打开创建组局弹框------在当前实现中,"发动态"和"发起组局"共用同一个弹框,因为创建组局本质上也是一种动态行为)。
每条动态是一个 Column 卡片,包含内容区和操作区两部分:
内容区 :左侧是 26px 的 emoji 头像,右侧是昵称(加粗)+时间、动态文字、角色标签和剧本标签。角色标签使用琥珀橙文字+极淡橙底色(accentLight),剧本标签使用深紫文字+极淡紫底色(primaryLight),形成双色标签的视觉对比------这种"双色浅底标签"比纯文字标签更有层次感。
操作区 :点赞(红色)、评论(灰色)、转发(灰色)三个操作按钮横向排列,间距 16px。当前版本这些按钮是纯展示性的,没有实现实际的交互逻辑。点赞数使用 danger 红色,评论和转发使用 textHint 灰色------这种"点赞亮、其他暗"的颜色对比模拟了真实社交媒体中用户对点赞行为的偏好。
注意剧友圈使用 this.feedList(全部动态)而非 FEED_LIST(静态数据),当用户通过"加入组局"操作生成新动态后,新动态会出现在列表顶部------这种实时更新是 @State 响应式状态的直接体现。ForEach 的 key 函数使用 it.id.toString(),当 doJoin 插入新动态时(ID 为 999),ForEach 会检测到新 key 并在列表头部插入新项,不会重建已有项。
十七、我的页面 pageMine
typescript
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🎭')
.fontSize(40)
Column({ space: 4 }) {
Text('谜局玩家')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('高级 · 已玩36场 · 隐藏结局解锁12个 · 欧气SSS')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('编辑')
.fontSize(11)
.fontColor(COLORS.accent)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(COLORS.primaryDark)
.borderRadius(8)
.onClick(() => {
this.openEdit();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的组局记录')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.sessionList.length} 场`)
.fontSize(12)
.fontColor(COLORS.accent)
}
.width('100%')
ForEach(this.sessionList.slice(0, 3), (it: SessionItem) => {
Row() {
Text(it.script)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.status)
.fontSize(11)
.fontColor(statusColor(it.status))
}
.width('100%')
}, (it: SessionItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Text('我的动态')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.feedList.slice(0, 3), (it: FeedItem) => {
Row() {
Text(it.script)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.time)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
}, (it: FeedItem) => it.id.toString())
Button()
.width('100%')
.height(40)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.85)
.onClick(() => {
this.openDel();
})
Text('退出首个组局')
.fontSize(13)
.fontColor(COLORS.white)
.margin({ top: -30 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}
.width('100%')
}
我的页面是 mainTab === 3 时显示的页面。页面包含三个卡片:
第一个卡片:用户档案卡 。左侧 40px 的面具 emoji 头像,中间是昵称"谜局玩家"和等级/场次/隐藏结局/欧气等级的详细描述,右侧是"编辑"按钮(点击调用 this.openEdit() 打开编辑角色弹框)。"高级 · 已玩36场 · 隐藏结局解锁12个 · 欧气SSS"这行描述浓缩了用户的核心成就------等级、场次、隐藏内容解锁数和运气评级,四个维度构成了一份"玩家画像"。
第二个卡片:我的组局记录 。展示组局列表的前 3 条。每条记录左侧是剧本名称,右侧是状态文字(颜色由 statusColor 动态映射)。卡片顶部显示总场次数(${this.sessionList.length} 场,琥珀橙色)------这个数字会随用户的组局操作动态变化。当用户创建新组局时,sessionList.length 增加 1,这个数字自动更新。当用户退出首个组局时,sessionList.length 减少 1,数字也自动更新。这种"数据→显示"的自动同步是 @State 响应式机制的核心价值。
第三个卡片:我的动态 。展示动态列表的前 3 条,每条显示剧本名称和时间。卡片底部有一个红色危险按钮"退出首个组局"------点击调用 this.openDel() 打开退出组局弹框。
这里使用了一个有趣的技巧:Button 组件没有内置文字,通过在下方叠加一个 Text 组件并设置 margin({ top: -30 }) 实现负边距上移,让文字"覆盖"在按钮之上。这种"Button+Text 叠加"的模式是 ArkUI 中实现自定义按钮文字的常见技巧------Button 组件的 label 属性虽然可以设置文字,但样式控制不如独立 Text 灵活。负边距的值 -30 是根据按钮高度 40px 和文字高度约 13px+行间距计算得出的经验值------文字需要在按钮垂直居中,所以负边距约为按钮高度的一半减去文字高度的一半。
opacity(0.85) 让危险按钮稍微透明,降低了视觉冲击力------这是一个微妙的设计细节,因为退出组局是危险操作,按钮不应该过于醒目以免诱导误点击。通过降低不透明度,按钮在视觉上"退后"了一步,让用户需要更主动地关注才能点击。
十八、弹框系统详解
应用定义了四套差异化弹框,每套弹框都有独立的视觉风格和交互逻辑。所有弹框共享一个遮罩层 modalOverlay,通过 @State 布尔变量控制显示。
弹框系统状态转换图
#mermaid-svg-6oFP0xiOQCH20nvZ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6oFP0xiOQCH20nvZ .error-icon{fill:#552222;}#mermaid-svg-6oFP0xiOQCH20nvZ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6oFP0xiOQCH20nvZ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6oFP0xiOQCH20nvZ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6oFP0xiOQCH20nvZ .marker.cross{stroke:#333333;}#mermaid-svg-6oFP0xiOQCH20nvZ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6oFP0xiOQCH20nvZ p{margin:0;}#mermaid-svg-6oFP0xiOQCH20nvZ .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6oFP0xiOQCH20nvZ .cluster-label text{fill:#333;}#mermaid-svg-6oFP0xiOQCH20nvZ .cluster-label span{color:#333;}#mermaid-svg-6oFP0xiOQCH20nvZ .cluster-label span p{background-color:transparent;}#mermaid-svg-6oFP0xiOQCH20nvZ .label text,#mermaid-svg-6oFP0xiOQCH20nvZ span{fill:#333;color:#333;}#mermaid-svg-6oFP0xiOQCH20nvZ .node rect,#mermaid-svg-6oFP0xiOQCH20nvZ .node circle,#mermaid-svg-6oFP0xiOQCH20nvZ .node ellipse,#mermaid-svg-6oFP0xiOQCH20nvZ .node polygon,#mermaid-svg-6oFP0xiOQCH20nvZ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6oFP0xiOQCH20nvZ .rough-node .label text,#mermaid-svg-6oFP0xiOQCH20nvZ .node .label text,#mermaid-svg-6oFP0xiOQCH20nvZ .image-shape .label,#mermaid-svg-6oFP0xiOQCH20nvZ .icon-shape .label{text-anchor:middle;}#mermaid-svg-6oFP0xiOQCH20nvZ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6oFP0xiOQCH20nvZ .rough-node .label,#mermaid-svg-6oFP0xiOQCH20nvZ .node .label,#mermaid-svg-6oFP0xiOQCH20nvZ .image-shape .label,#mermaid-svg-6oFP0xiOQCH20nvZ .icon-shape .label{text-align:center;}#mermaid-svg-6oFP0xiOQCH20nvZ .node.clickable{cursor:pointer;}#mermaid-svg-6oFP0xiOQCH20nvZ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6oFP0xiOQCH20nvZ .arrowheadPath{fill:#333333;}#mermaid-svg-6oFP0xiOQCH20nvZ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6oFP0xiOQCH20nvZ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6oFP0xiOQCH20nvZ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6oFP0xiOQCH20nvZ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6oFP0xiOQCH20nvZ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6oFP0xiOQCH20nvZ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6oFP0xiOQCH20nvZ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6oFP0xiOQCH20nvZ .cluster text{fill:#333;}#mermaid-svg-6oFP0xiOQCH20nvZ .cluster span{color:#333;}#mermaid-svg-6oFP0xiOQCH20nvZ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6oFP0xiOQCH20nvZ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6oFP0xiOQCH20nvZ rect.text{fill:none;stroke-width:0;}#mermaid-svg-6oFP0xiOQCH20nvZ .icon-shape,#mermaid-svg-6oFP0xiOQCH20nvZ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6oFP0xiOQCH20nvZ .icon-shape p,#mermaid-svg-6oFP0xiOQCH20nvZ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6oFP0xiOQCH20nvZ .icon-shape .label rect,#mermaid-svg-6oFP0xiOQCH20nvZ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6oFP0xiOQCH20nvZ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6oFP0xiOQCH20nvZ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6oFP0xiOQCH20nvZ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 关闭方式
弹框状态
触发入口
剧本卡片点击
发起组局按钮
编辑角色按钮
退出组局按钮
加入拼车按钮
全部关闭
初始状态
创建组局弹框
addOpen=true
编辑角色弹框
editOpen=true
退出组局弹框
delOpen=false
加入组局弹框
joinOpen=true
点击遮罩层
点击取消按钮
确认操作 doAdd
确认操作 doEdit
确认操作 doDel
确认操作 doJoin
18.1 遮罩层 modalOverlay
typescript
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.7)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
this.joinOpen = false;
})
}
.width('100%')
.height('100%')
}
遮罩层是一个全屏黑色半透明遮罩(#000000 透明度 0.7),点击任意位置关闭所有弹框(同时将四个 @State 布尔变量设为 false)。这种"统一遮罩层+分散弹框体"的设计避免了每个弹框各自实现遮罩逻辑的冗余,保证了关闭行为的一致性------用户点击弹框外部任何区域都会关闭当前弹框。
opacity(0.7) 的设计让背景内容隐约可见------用户能看到弹框背后的页面内容,但焦点集中在弹框上。这种"半透明遮罩"比完全不透明的遮罩更友好,因为它保留了空间上下文感知,用户知道关闭弹框后会回到哪个页面。
遮罩层的 onClick 同时将四个布尔变量设为 false------虽然理论上每次只有一个弹框打开,但"全量重置"比"判断哪个打开了再关闭"更简洁,且避免了因状态不同步导致弹框无法关闭的 bug。这种"宁可多设也不漏设"的防御性编程在状态管理中值得推荐。
18.2 创建组局弹框 addModalBody ------ 暗色海报头卡
typescript
@Builder
addModalBody() {
Column() {
Row({ space: 10 }) {
Text('🎲')
.fontSize(22)
.fontColor(COLORS.white)
Column({ space: 2 }) {
Text('发起一场组局')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('邀请剧友一起拼车开本')
.fontSize(9)
.fontColor('#D0C0E8')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 14 })
.backgroundColor(COLORS.primaryDark)
.borderRadius({ topLeft: 16, topRight: 16 })
Column({ space: 12 }) {
TextInput({ placeholder: '剧本名称(如 长安夜未央)', text: this.addScript })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addScript = v;
})
TextInput({ placeholder: '开本时间(如 今晚 19:00)', text: this.addTime })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addTime = v;
})
TextInput({ placeholder: '场地(如 谜局·朝阳店)', text: this.addLocation })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addLocation = v;
})
Row({ space: 6 }) {
Text('4人本')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.bg)
.borderRadius(8)
Text('6人本')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.bg)
.borderRadius(8)
Text('8人本')
.fontSize(10)
.fontColor(COLORS.textSecondary)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.bg)
.borderRadius(8)
}
.width('100%')
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.accent)
.borderRadius(21)
.onClick(() => {
this.doAdd();
})
Text('发布组局')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: -32 })
Text('取消')
.fontSize(12)
.fontColor(COLORS.textHint)
.onClick(() => {
this.addOpen = false;
})
}
.width('100%')
.padding(16)
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
创建组局弹框的头卡采用"暗色海报"风格------深暗紫底色(primaryDark),左侧骰子图标,右侧白色标题"发起一场组局"和淡紫副标题"邀请剧友一起拼车开本"。头卡只有顶部圆角(borderRadius({ topLeft: 16, topRight: 16 })),与下方表单区域形成无缝衔接。
borderRadius 的对象语法 { topLeft: 16, topRight: 16 } 只设置左上和右上圆角,左下和右下保持直角------这种"部分圆角"设计让头卡与表单区域在视觉上融为一体,整个弹框的外层 borderRadius(16) 提供四角圆角,内层头卡的顶部圆角与外层对齐。
表单区包含三个 TextInput 输入框(剧本名称、开本时间、场地),每个输入框通过 onChange 回调实时更新对应的 @State 变量。TextInput 的 text 参数绑定了 @State 变量(如 text: this.addScript),确保弹框打开时显示初始值,onChange 回调确保用户输入实时同步到状态变量。
输入框背景使用 COLORS.bg(比卡片背景更深的颜色),在卡片内形成"凹陷"的视觉层次------这种"输入框比卡片背景更深"的设计模拟了物理世界中"凹槽"的视觉效果,让用户直觉地知道这里可以输入内容。输入框高度为 40px,圆角 8px------这些尺寸值经过移动端设计规范的验证,适合手指触控操作。
下方有"4人本""6人本""8人本"三个不可选的人数标签(当前为纯展示),以及一个全宽琥珀橙圆形按钮"发布组局"(点击调用 this.doAdd())和"取消"文字按钮。按钮同样使用 Button+Text 叠加模式,通过 margin({ top: -32 }) 让文字覆盖在按钮上。
18.3 编辑角色弹框 editModalBody ------ 双色条头卡
typescript
@Builder
editModalBody() {
Column() {
Row() {
Column({ space: 2 }) {
Text('编辑角色档案')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('修改你的剧友身份卡')
.fontSize(9)
.fontColor('#FFD54F')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 16 })
Text('🎭')
.fontSize(22)
.width(64)
.height(56)
.textAlign(TextAlign.Center)
.backgroundColor(COLORS.accent)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.primary)
.borderRadius({ topLeft: 16, topRight: 16 })
.alignItems(VerticalAlign.Center)
Column({ space: 12 }) {
TextInput({ placeholder: '昵称', text: this.editNick })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editNick = v;
})
TextInput({ placeholder: '角色偏好(如 推理爱好者)', text: this.editRole })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editRole = v;
})
Row({ space: 8 }) {
Text('初级')
.fontSize(11)
.fontColor(this.editLevel === '初级' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.editLevel === '初级' ? COLORS.primary : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.editLevel = '初级';
})
Text('中级')
.fontSize(11)
.fontColor(this.editLevel === '中级' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.editLevel === '中级' ? COLORS.primary : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.editLevel = '中级';
})
Text('高级')
.fontSize(11)
.fontColor(this.editLevel === '高级' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.editLevel === '高级' ? COLORS.accent : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.editLevel = '高级';
})
}
.width('100%')
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(19)
.onClick(() => {
this.editOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.accent)
.borderRadius(19)
.onClick(() => {
this.doEdit();
})
Text('保存')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -40 })
}
.width('100%')
}
.width('100%')
.padding(16)
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
编辑角色弹框的头卡采用"双色条"设计------主体为深紫(primary),右侧有一个 64px 宽的琥珀橙(accent)色块。左侧是白色标题"编辑角色档案"和金色副标题"修改你的剧友身份卡",右侧色块上是面具 emoji。这种"双色拼接"的头卡设计与创建组局的"单色海报"头卡形成视觉对比,让用户能通过头卡风格快速识别当前弹框的操作类型。
双色条头卡的实现技巧在于 Row 内部的两个 Column/Text 分别占据不同空间------左侧 Column 使用 layoutWeight(1) 占据剩余空间,右侧 Text 固定 64px 宽度和 56px 高度。alignItems(VerticalAlign.Center) 让头卡内容垂直居中对齐。
表单区包含昵称和角色偏好两个输入框,以及初级/中级/高级三个等级选择标签。等级标签是可交互的------点击后设置 this.editLevel 为对应值,同时通过条件渲染改变文字颜色和背景色(选中态为白字+主色底/强调色底,未选中态为灰字+深色底)。
高级等级选中时使用琥珀橙底色(accent),初级和中级使用深紫底色(primary),形成"高级更醒目"的视觉层次。这种"不同等级不同色"的设计让用户在选择时能通过颜色感知等级的差异------初级和中级是"常规"等级用主色,高级是"精英"等级用强调色。
底部是"取消"和"保存"双按钮,使用 Button+Text 叠加模式,通过负边距让文字覆盖在按钮上。两个按钮等宽(layoutWeight(1)),高度 38px,圆角 19px(完全圆角,形成药丸形状)。
18.4 退出组局弹框 delModalBody ------ 危险暗红卡
typescript
@Builder
delModalBody() {
Column() {
Row({ space: 10 }) {
Text('🚪')
.fontSize(22)
.fontColor(COLORS.white)
Column({ space: 2 }) {
Text('退出组局')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('退出后位置释放给其他剧友')
.fontSize(9)
.fontColor('#FFCDD2')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.danger)
.borderRadius({ topLeft: 16, topRight: 16 })
Column({ space: 12 }) {
Row({ space: 10 }) {
Column()
.width(4)
.height(36)
.borderRadius(2)
.backgroundColor(COLORS.danger)
Text('退出后你的位置将释放给等待中的剧友。如果开场前12小时内退出,会影响你的信用分。')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.bg)
.borderRadius(10)
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(40)
.backgroundColor(COLORS.cardBg)
.borderRadius(20)
.border({ width: 1, color: COLORS.border, radius: 20 })
.onClick(() => {
this.delOpen = false;
})
Text('再想想')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(40)
.backgroundColor(COLORS.danger)
.borderRadius(20)
.onClick(() => {
this.doDel();
})
Text('确认退出')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -66 })
}
.width('100%')
}
.width('100%')
.padding(16)
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
退出组局弹框的头卡采用"危险暗红"设计------整个头卡背景为危险红(danger),左侧门 emoji,白色标题"退出组局"和淡红副标题"退出后位置释放给其他剧友"(#FFCDD2,一种比 danger 更淡的红色)。红色头卡在视觉上直接传达了"危险操作"的警告信号,与其他三套弹框的紫色/橙色头卡形成鲜明对比。
#FFCDD2 是 Material Design Red 100 色值,比 danger(Red 400)更淡。在红色头卡上使用淡红色副标题,保持了色相统一性但通过明度差异形成层次------这种"同色系明度对比"比跨色系对比更柔和,适合作为警告信息的辅助说明。
表单区包含一个警告条(左侧 4px 宽红色竖条+右侧警告文字)和底部双按钮。警告文字详细说明了退出的后果------"位置释放给等待中的剧友"和"开场前 12 小时内退出会影响信用分"。
警告条的设计通过红色竖条引导视线------4px 宽的红色竖条在深色背景上形成一条"视线引导线",让用户的目光自然从左到右扫过警告文字。这种"竖条引导"的设计源自报纸和杂志的"侧栏引用"排版传统。
底部按钮使用"再想想"(灰色边框按钮)和"确认退出"(红色按钮)的非对称设计------左侧按钮有边框无填充(backgroundColor(COLORS.cardBg) + border({ width: 1, color: COLORS.border })),右侧按钮有填充无边框(backgroundColor(COLORS.danger) 无 border)。通过视觉权重的不平衡引导用户优先选择"再想想"(安全选项)------这是"防误操作设计"的典型模式。左侧按钮的"空心"视觉让它在权重上轻于右侧的"实心"按钮,但"再想想"在文字语义上是安全选项,这种"视觉权重与安全引导的矛盾"实际上是一种心理学技巧------用户倾向于点击视觉权重较轻的按钮(因为感觉"不那么危险"),从而选择了安全选项。
18.5 加入组局弹框 joinModalBody ------ 角色选择卡
typescript
@Builder
joinModalBody() {
Column() {
Row({ space: 4 }) {
Column({ space: 3 }) {
Column()
.width(12)
.height(12)
.borderRadius(6)
.backgroundColor(COLORS.primary)
Text('选角')
.fontSize(9)
.fontColor(COLORS.primaryDark)
}
.alignItems(HorizontalAlign.Center)
Column()
.width(20)
.height(2)
.backgroundColor(COLORS.border)
.margin({ top: -14 })
Column({ space: 3 }) {
Column()
.width(12)
.height(12)
.borderRadius(6)
.backgroundColor(COLORS.accent)
Text('确认')
.fontSize(9)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Center)
Column()
.width(20)
.height(2)
.backgroundColor(COLORS.border)
.margin({ top: -14 })
Column({ space: 3 }) {
Column()
.width(12)
.height(12)
.borderRadius(6)
.backgroundColor(COLORS.cardBg)
.border({ width: 2, color: COLORS.border, radius: 6 })
Text('入场')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 14, bottom: 6 })
Column({ space: 12 }) {
Text(`加入《${this.joinScript}》`)
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Text('选择你想扮演的角色')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width('100%')
Row({ space: 8 }) {
Text('侦探')
.fontSize(11)
.fontColor(this.joinRole === '侦探' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.joinRole === '侦探' ? COLORS.primary : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.joinRole = '侦探';
})
Text('凶手')
.fontSize(11)
.fontColor(this.joinRole === '凶手' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.joinRole === '凶手' ? COLORS.danger : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.joinRole = '凶手';
})
Text('目击者')
.fontSize(11)
.fontColor(this.joinRole === '目击者' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.joinRole === '目击者' ? COLORS.accent : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.joinRole = '目击者';
})
Text('NPC')
.fontSize(11)
.fontColor(this.joinRole === 'NPC' ? COLORS.white : COLORS.textSecondary)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.backgroundColor(this.joinRole === 'NPC' ? COLORS.gold : COLORS.bg)
.borderRadius(10)
.onClick(() => {
this.joinRole = 'NPC';
})
}
.width('100%')
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.accent)
.borderRadius(21)
.onClick(() => {
this.doJoin();
})
Text('确认加入')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ top: -32 })
}
.width('100%')
.padding(16)
}
.width('100%')
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
}
加入组局弹框的独特之处在于顶部有一个三步进度指示器------"选角""确认""入场"三个步骤,通过圆点+连接线的方式展示操作流程。第一步"选角"使用深紫实心圆点,第二步"确认"使用琥珀实心圆点,第三步"入场"使用空心圆点(深色底+边框)。两个步骤之间用 2px 高的连接线相连。
三步进度指示器的实现通过 Row({ space: 4 }) 横向排列三个 Column(步骤节点)和两个 Column(连接线)。连接线使用 margin({ top: -14 }) 负边距上移,让它与圆点的垂直中心对齐------圆点高度 12px,在 Column({ space: 3 }) 中上方,连接线需要上移约 14px 才能与圆点中心对齐。这种"负边距对齐"是 ArkUI 中实现跨元素对齐的常用技巧。
justifyContent(FlexAlign.Center) 让整个进度指示器水平居中,不占据全宽------这种"居中步骤条"比"两端对齐步骤条"更紧凑,适合弹框内的有限空间。
表单区展示目标剧本名称(加入《${this.joinScript}》)和四个角色选择标签:侦探(选中为深紫底)、凶手(选中为危险红底)、目击者(选中为琥珀橙底)、NPC(选中为金色底)。每个角色选中时使用不同的颜色,这不仅是视觉区分,更暗示了不同角色在剧本中的"阵营"------侦探是正面角色用主色、凶手是隐藏反派用危险色、目击者是信息提供者用强调色、NPC 是辅助角色用金色。这种"角色→颜色→阵营"的语义映射让角色选择不仅是功能操作,更是一种角色认知的引导。
底部是琥珀橙"确认加入"按钮,点击调用 this.doJoin() 向动态列表插入加入记录。doJoin 函数会创建一个新的 FeedItem 实例,文字内容为 加入了《${this.joinScript}》组局,角色为用户选择的 this.joinRole 值------这意味着用户选择的角色会体现在动态内容中,让其他剧友能通过动态知道该用户扮演的角色。
十九、底部导航与主内容组装
19.1 底部导航 bottomBar
typescript
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.accent : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
}, (it: NavItem) => it.label)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
底部导航栏通过 ForEach 渲染 4 个主 Tab(首页/剧本库/组局/我的),每个 Tab 包含 emoji 图标和文字标签。选中态通过双重视觉反馈:图标透明度为 1(未选中 0.55)、文字加粗且变为琥珀橙色(未选中为灰色、常规字重)。每个 Tab 使用 layoutWeight(1) 等分宽度,点击设置 this.mainTab 为对应索引。导航栏顶部圆角 16px,高度 56px,背景为卡片深紫。
图标透明度的差异(1 vs 0.55)是一种微妙但有效的选中态反馈------选中 Tab 的图标完全不透明,未选中 Tab 的图标半透明,让选中项在视觉上"浮出"。这种"透明度对比"比颜色变化更柔和,不会因为颜色过多而干扰整体暗色主题的一致性。
导航栏高度 56px 是移动端底部导航的标准高度,适合手指触控。顶部圆角 16px 让导航栏与内容区域之间形成一条优雅的弧线分界,而非生硬的直角。
19.2 主内容组装 mainContent
typescript
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) {
this.pageFeatured()
} else if (this.subTab === 1) {
this.pageScriptLib()
} else if (this.subTab === 2) {
this.pageSessions()
} else if (this.subTab === 3) {
this.pageGuide()
} else {
this.pageCircle()
}
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
Scroll() {
Column() {
this.pageScriptLib()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 2) {
Scroll() {
Column() {
this.pageSessions()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else {
Scroll() {
Column() {
this.pageMine()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
}
}
.width('100%')
.height('100%')
}
mainContent 是内容区域的组装函数,它首先渲染 header(所有 Tab 共享),然后根据 mainTab 的值条件渲染不同内容:
mainTab === 0(首页):渲染subNav子导航栏 +Scroll滚动容器,内部再根据subTab的值条件渲染 5 个内容页面之一。这是双层 Tab 导航的核心逻辑------主 Tab 控制大功能域,子 Tab 控制首页内的细分内容。mainTab === 1(剧本库):直接渲染pageScriptLib页面。mainTab === 2(组局):直接渲染pageSessions页面。mainTab === 3(我的):直接渲染pageMine页面。
每个 Scroll 容器都设置了垂直滚动、隐藏滚动条、左右各 14px 内边距、底部 20px 内边距。layoutWeight(1) 让内容区域占据 header 和 bottomBar 之间的所有剩余空间。scrollBar(BarState.Off) 隐藏滚动条------在暗色主题中,滚动条的视觉效果不佳且占用空间,隐藏后页面更加整洁。
条件渲染的"按需构建"策略是性能优化的关键------每次只渲染当前激活的页面内容,避免了多个页面实例同时存在带来的内存开销。当用户切换 Tab 时,旧页面被销毁、新页面被创建。这种"用完即毁"的策略在移动端有限的内存环境下是合理的,但对于包含大量数据的页面(如剧本库的 12 条记录),频繁的创建销毁可能带来轻微的卡顿。在更高级的实现中,可以使用 @LocalStorageLink 或 AppStorage 缓存页面数据,减少重建时的数据加载开销。
19.3 build 入口函数
typescript
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() {
this.editModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() {
this.delModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.joinOpen) {
Stack() {
this.modalOverlay()
Column() {
this.joinModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
}
build 函数是组件的最终渲染入口。它使用 Stack 容器从底到顶叠加四层:
第一层:背景层 。一个全屏 Column 设置背景色为 COLORS.bg(深紫黑),为整个应用提供底色。
第二层:特效层 。调用 this.fxLayer() 渲染迷雾粒子和烛光闪烁。由于设置了 hitTestBehavior(HitTestMode.None),这层的触摸事件会穿透到下方。
第三层:内容层 。一个全屏 Column 包含 mainContent(头部+内容区)和 bottomBar(底部导航),这是用户实际交互的层级。
第四层:弹框层 。通过四个 if 条件判断分别渲染四套弹框。每套弹框都是一个 Stack 叠加 modalOverlay(遮罩层)和弹框体(88% 宽度、最高 80% 高度、zIndex 999)。弹框体居中显示在遮罩层之上,形成模态对话框效果。
弹框体的尺寸设计值得注意------宽度 88% 让弹框在屏幕两侧留出 6% 的边距,形成"浮出"效果;constraintSize({ maxHeight: '80%' }) 限制弹框最大高度为屏幕的 80%,当表单内容过多时弹框不会超出屏幕范围。zIndex(999) 确保弹框层在所有内容之上------虽然 Stack 的后声明在上特性已经保证了层级,但显式设置 zIndex 是更安全的做法,避免在复杂嵌套场景下层级混乱。
这种"背景→特效→内容→弹框"的四层叠加架构是沉浸式应用的标准模式------背景提供底色,特效提供氛围,内容提供功能,弹框提供交互模态。每层各司其职,通过 Stack 的叠加顺序自然表达层级关系。
二十、功能模块对比表
| 模块 | 布局方式 | 数据模型 | 核心字段数 | 特殊视觉元素 | 动画效果 | 交互入口 |
|---|---|---|---|---|---|---|
| 精选首页 | 四格+柱状图+进度条+排行榜+列表 | FeatureScript/WeekChartItem/TypeDist/HotRole/FeedItem | 3-4 | 竖条指示式Tab+双色浅底标签 | 无 | 立即拼车/发动态 |
| 剧本库 | 垂直列表+左侧图标 | ScriptItem | 8 | 类型emoji映射+星级重复+难度三色 | 无 | 加入拼车 |
| 组局大厅 | 垂直列表+进度条 | SessionItem | 8 | 状态五色映射+满员禁用 | 无 | 发起组局/加入拼车 |
| 攻略心得 | 垂直列表+排名编号 | GuideItem | 7 | Top3金色高亮+类型标签 | 无 | 无 |
| 剧友圈 | 垂直列表+操作行 | FeedItem | 8 | 双色浅底标签+emoji头像 | 无 | 发动态 |
| 我的页面 | 卡片+列表+按钮 | FeedItem/SessionItem | 2 | Button+Text叠加+负边距 | 无 | 编辑/退出首个组局 |
| 创建组局弹框 | 表单输入 | SessionItem | 3 | 暗色海报头卡+人数标签 | 无 | 发布组局/取消 |
| 编辑角色弹框 | 表单输入+等级选择 | 无(临时状态) | 3 | 双色条头卡+等级色块 | 无 | 保存/取消 |
| 退出组局弹框 | 警告条+双按钮 | 无 | 0 | 危险暗红卡+红色竖条警告 | 无 | 确认退出/再想想 |
| 加入组局弹框 | 步骤指示+角色选择 | FeedItem | 2 | 三步进度器+角色四色标签 | 无 | 确认加入 |
| 特效层 | Stack+ForEach | tick | 1 | 迷雾粒子+烛光闪烁 | 120ms定时器 | 无(穿透触摸) |
| 底部导航 | 四等分行 | NavItem | 2 | 透明度+颜色双反馈 | 无 | Tab切换 |
| 子导航栏 | 横向滚动+竖条指示 | SUB_NAV_LIST | 1 | 竖条指示器+金色选中 | 无 | 内容Tab切换 |
色彩体系对比表
| 色彩角色 | 色值 | RGB | 明度 | 用途 | 搭配关系 |
|---|---|---|---|---|---|
| 主色 primary | #6A1B9A | 106,27,154 | 低 | 按钮、标签、图标底 | 与accent互补 |
| 主色暗 primaryDark | #38006B | 56,0,107 | 极低 | 弹框头卡、选中态底 | primary的深化 |
| 主色亮 primaryLight | #F3E5F5 | 243,229,245 | 极高 | 浅色标签底 | 与accentLight对比 |
| 强调色 accent | #FF6F00 | 255,111,0 | 中 | 按钮、指示器、高亮 | 与primary互补 |
| 强调色亮 accentLight | #FFF3E0 | 255,243,224 | 极高 | 浅色标签底 | 与primaryLight对比 |
| 背景 bg | #1A1025 | 26,16,37 | 极低 | 全局背景 | 底层基底 |
| 卡片 cardBg | #261835 | 38,24,53 | 低 | 所有卡片背景 | 浮于bg之上 |
| 金色 gold | #FFD54F | 255,213,79 | 高 | 评分、排名、榜首 | 品质高亮 |
| 危险 danger | #EF5350 | 239,83,80 | 中 | 退出、严重缺人 | 警告色 |
弹框系统对比表
| 弹框 | 头卡风格 | 头卡主色 | 表单类型 | 按钮设计 | 防误操作策略 |
|---|---|---|---|---|---|
| 创建组局 | 暗色海报 | primaryDark | 3个TextInput | 全宽橙色按钮 | 剧本名称空值保护 |
| 编辑角色 | 双色条 | primary+accent | 2个TextInput+等级选择 | 等宽双按钮 | 无特殊防误操作 |
| 退出组局 | 危险暗红 | danger | 警告条 | 非对称双按钮 | 红色头卡+非对称按钮+警告文字 |
| 加入组局 | 角色选择卡 | 无头卡 | 角色四色标签 | 全宽橙色按钮 | 三步进度指示器 |
二十一、总结与展望
本文以"谜局"沉浸式剧本杀组局社区为例,完整剖析了一个基于 HarmonyOS ArkUI 框架开发的暗夜紫调移动端应用的技术实现。从架构设计层面来看,这个应用展示了以下几个值得深入思考的设计模式和技术决策。
第一,暗夜紫调色彩体系的氛围营造能力。 与常见的浅色系或纯黑深色系不同,这款应用采用了以深紫黑(#1A1025)为背景、深紫(#6A1B9A)为主色、琥珀橙(#FF6F00)为强调色的暗夜紫调色彩体系。紫色在色彩心理学中与神秘、悬疑、暗夜相关联,是剧本杀这类推理场景的理想主色。而琥珀橙模拟烛光与灯笼的暖色光芒,在深紫背景上形成了"暗夜中一点火光"的视觉对比。整个 15 色体系通过深紫背景+琥珀强调+金色高亮的三层色阶,构建了一种"古宅夜谈"的沉浸式视觉语言。理解这种"色彩→氛围→主题"的映射逻辑,是从功能型 UI 设计迈向沉浸式 UI 设计的关键。同时,文字层次的三级明度梯度(textPrimary/textSecondary/textHint)确保了信息在暗色背景上的可读性层次,而功能色体系(success/warning/danger)则通过语义映射让用户能通过颜色直觉判断状态。这套色彩体系不仅是一个颜色集合,更是一个完整的"视觉语言系统"------每种颜色都有明确的语义角色和使用场景,通过 ColorPalette 接口约束和 COLORS 常量统一管理,保证了整个应用视觉的一致性。
第二,迷雾粒子与烛光闪烁的双层特效设计及其性能策略。 特效层包含两套独立的动画系统------迷雾粒子的缓慢漂浮和烛光的快速闪烁。迷雾通过 fogX、fogY、fogA 三个纯函数计算位置和透明度,使用质数系数(7、167、5、113)让每个粒子的运动轨迹独立,这种基于数论的动画设计确保了粒子运动的自然感。烛光通过 candleA 函数以更高频率(每 3 个 tick vs 迷雾的每 5 个 tick)和更强对比(0.8 vs 0.3,差距 0.5 vs 迷雾的 0.25)进行闪烁。这种"双系统差异化参数"的设计让两种特效在视觉节奏上形成互补------迷雾是缓慢的底层氛围,烛光是快速的前景点缀。同时,hitTestBehavior(HitTestMode.None) 确保特效层穿透触摸事件,只负责视觉装饰不干扰交互。120ms 的更新间隔(约 8 FPS)对于"氛围特效"来说恰到好处------不需要 60FPS 的丝滑动画,跳动的质感反而更有"摇曳"的复古感。这种"低帧率氛围特效"的策略在低端设备上也能流畅运行,是性能与美学的平衡艺术。特效层通过 Stack 叠加在背景层之上、内容层之下,形成了"背景→特效→内容→弹框"的四层架构,每层各司其职,通过代码顺序自然表达视觉层级。
第三,竖条指示式子导航的视觉隐喻与双层 Tab 导航架构。 子导航栏的选中态指示器不是常见的下划线或圆点,而是一条 3px 宽、16px 高的琥珀橙竖条。这种"竖条指示器"在视觉上类似于灯笼下方悬挂的吊牌------竖条是吊绳,文字标签是吊牌本身。这种设计不仅是一种视觉装饰,更与剧本杀"古宅夜话"的场景主题形成了概念性呼应。同时,选中态的金色文字、加粗字重和深暗紫底色形成了"三层强化"的视觉反馈,让当前选中的 Tab 在多个维度上脱颖而出。双层 Tab 导航架构(底部 4 主 Tab + 首页 5 内容 Tab)通过 mainTab 和 subTab 两个 @State 变量的组合条件渲染实现------主 Tab 切换大功能域,子 Tab 切换首页内的细分内容。这种"两级导航"设计在内容维度较多的应用中是常见的导航模式,它通过层级化降低了用户的认知负担。条件渲染的"按需构建"策略确保每次只渲染当前激活的页面内容,避免了多个页面实例同时存在带来的内存开销。
第四,四套弹框的差异化头卡设计与防误操作策略。 创建组局弹框采用"暗色海报头卡"(深暗紫底+骰子图标+白色标题),编辑角色弹框采用"双色条头卡"(深紫主体+琥珀右侧色块),退出组局弹框采用"危险暗红卡"(红色头+警告条+非对称按钮),加入组局弹框采用"角色选择卡"(三步进度指示器+角色四色标签)。四套弹框的头卡视觉风格完全不同,让用户能通过头卡一眼识别当前操作类型。退出组局弹框的红色头卡和"再想想/确认退出"的非对称按钮设计(左侧按钮有边框无填充、右侧按钮有填充无边框)通过视觉权重的不平衡引导用户优先选择安全选项,这是"防误操作设计"的典型模式。加入组局弹框的三步进度指示器("选角→确认→入场")将多步骤流程可视化,降低了用户的认知负担。角色选择的四色标签(侦探深紫、凶手红色、目击者橙色、NPC 金色)不仅是视觉区分,更暗示了不同角色在剧本中的"阵营"------这种"角色→颜色→阵营"的语义映射让角色选择不仅是功能操作,更是一种角色认知引导。所有弹框共享一个 modalOverlay 遮罩层,通过"统一遮罩+分散弹框体"的设计避免了遮罩逻辑的冗余,保证了关闭行为的一致性。
第五,状态映射函数的认知层次设计与响应式数据流。 statusColor 函数将组局状态文字映射到颜色------"已满"映射到成功绿、"差1人"映射到警告金、"差2人"映射到琥珀橙、"差3人及以上"映射到危险红。这种映射逻辑形成了一个从"绿色安全"到"红色告急"的颜色梯度,让用户能通过颜色直觉判断组局的紧迫程度。同样,diffColor 函数将剧本难度映射到颜色------5星红色、4星橙色、3星及以下绿色,让用户通过颜色快速判断难度等级。candleA 和 fogA 函数则将时间状态(tick)映射到透明度,驱动特效动画。这些纯函数的共同特点是"输入→颜色/数值"的单向映射,无副作用、可测试、可复用,是函数式编程思想在 UI 领域的应用。同时,@State 修饰的 feedList 和 sessionList 是跨页面共享的响应式数据------精选首页预览前 3 条动态、剧友圈展示全部动态、我的页面展示前 3 条动态,三处引用同一个数据源,任何一处修改数据(如加入组局插入新动态),其他两处自动同步更新。这种"一处修改,多处同步"的响应式数据流是 ArkUI @State 装饰器的核心价值,它消除了手动数据同步的繁琐和出错风险。
第六,Button+Text 叠加技巧与 ArkUI 工程化实践。 由于 ArkUI 的 Button 组件在样式控制上不如独立 Text 灵活,应用多处使用了"Button+Text 叠加"模式------先渲染一个无文字的 Button 作为可点击的背景区域,再叠加一个 Text 组件通过 margin({ top: -32 }) 负边距上移覆盖在按钮上。这种技巧虽然略显 hacky,但在当前 ArkUI 版本下是一种实用的自定义按钮文字样式的方法。负边距的值需要根据按钮高度精确调整------42px 高度的按钮用 -32,38px 的用 -40,40px 的用 -52/-66,这些数值是经过调试的经验值。从工程化角度来看,这种"绕过框架限制"的技巧反映了 ArkUI 在自定义组件样式方面仍有改进空间。此外,应用中的"配置驱动 UI"模式(NAV_LIST 和 SUB_NAV_LIST 配置驱动导航渲染)、"类型约束先行"模式(ColorPalette 接口约束色彩体系)、"纯函数分离"模式(7 个顶层纯函数分离业务逻辑)等工程化实践,都体现了从"写代码"到"设计系统"的思维升级。
展望未来,这款应用可以在以下几个方面继续深化:第一,接入真实后端 API 替换静态模拟数据,实现真正的组局创建、加入、退出等业务流程,使用 @StorageLink 或 AppStorage 实现数据的持久化存储;第二,引入 ArkUI 的 animateTo 显式动画 API 为弹框的显示/隐藏添加过渡效果(如淡入淡出、缩放动画),替代当前的硬切换,提升交互的流畅感;第三,增加剧本详情页和攻略详情页,通过 Router 或 Navigation 组件实现从列表到详情的导航跳转,完善应用的内容深度;第四,引入 @Watch 装饰器监听状态变化,实现更精细的数据流向控制,如组局满员时自动通知 DM、用户退出组局时自动释放名额给等待队列;第五,利用 ArkUI 的 @Provide/@Consume 跨层级状态传递机制,将用户登录状态、主题偏好等全局状态提升到顶层组件,减少中间层组件的状态传递负担;第六,增加 Lottie 动画或属性动画为迷雾粒子和烛光闪烁添加更丰富的运动轨迹,如贝塞尔曲线路径、弹簧物理效果等,让特效从"数学函数驱动"升级为"物理引擎驱动"。这些优化方向将进一步推动这款沉浸式剧本杀社区从"技术原型"迈向"生产级应用",最终实现一个真正连接剧本杀爱好者、DM 和线下门店的完整社交生态平台。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 "项目",右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 "新建项目" 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 "应用" 标签后,右侧展示多种模板。对于大多数场景,推荐选择 "Empty Ability" ------ 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 "下一步" 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 ------ 一个居中显示的 "Hello World" 文本。

1.4 完成创建
确认配置无误后,点击右下角 "完成" 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 "退出代码为 0" 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
typescript
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry |
标记为页面入口,可用于路由跳转 |
@Component |
声明为自定义组件 |
@State |
状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer |
相对布局容器,替代传统线性布局 |
.onClick() |
点击事件,此处点击后文本变为 "Welcome" |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK (或快捷键
Ctrl + Alt + S搜索 "HarmonyOS SDK")
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:"HarmonyOS SDK 已经包含在 IDE,无需单独安装",省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。