基于HarmonyOS ArkTS API 24工具函数与公共 Builder定义了多个工具函数用于动态计算 UI 尺寸,以及一个公共的模态遮罩 @Builder 函数
引言
剧本杀作为近年来深受年轻人喜爱的社交推理游戏,其线下拼场组队的痛点一直困扰着大量玩家。传统模式下,玩家需要通过微信群、朋友圈等非结构化渠道寻找队友,信息分散且效率低下,经常出现"差一人成局却找不到人"的尴尬局面。本应用以 HarmonyOS 6.1.1 声明式 UI 范式为核心,构建了一套完整的剧本杀拼场平台,涵盖了从组队拼车、本子库检索、车友社交、战绩复盘到订单管理的全链路业务流程。
从技术架构层面来看,本应用采用了 ArkTS 声明式组件体系,通过 @Entry 和 @Component 装饰器构建了多层级的组件树结构。主页面 ScriptKillPage 作为入口组件,负责整体布局编排与底部导航栏的状态管理,下设六个独立的 Tab 组件(PlayTab、ScriptTab、CrewTab、RecordTab、JOrderTab、JMineTab),每个 Tab 组件内部维护各自的 @State 状态变量,实现了高度解耦的模块化设计。这种架构模式使得每个功能模块可以独立开发、测试和维护,同时通过统一的主题色系和交互范式保证了用户体验的一致性。
在业务设计层面,应用围绕"深夜推理局"的品牌定位,精心设计了深夜黑 #0F1222 搭配荧光紫 #A855F7 的视觉主题,底色采用 #F4F2FF 的淡紫色营造沉浸感。应用不仅提供了基本的拼场功能,还创新性地引入了"角色预选"机制——玩家可以在上车主前提前锁定心仪角色,避免到场后角色争抢。同时,"信用分"体系的引入为车友社交提供了信任基础,"未成局自动退款"的承诺降低了用户的参与门槛。应用还内置了题材热度榜、评分分布图、对局时间线等数据可视化组件,将原本枯燥的游戏数据转化为直观的视觉呈现。

逐段代码分析
一、数据模型与类型定义层

应用首先定义了一系列接口类型来规范整个数据层的结构。这些接口覆盖了剧本杀业务领域的核心实体,包括题材标签、拼场房间、题材热度、剧本条目、车友成员、战绩记录、评分分布、订单行、徽章、兑换物品等。
interface GenreChip {
name: string
count: number
}
interface PlayRoom {
id: number
script: string
genre: string
store: string
district: string
time: string
need: number
total: number
price: number
dm: string
hot: boolean
urgent: boolean
}
interface ScriptItem {
id: number
name: string
genre: string
players: string
duration: string
difficulty: number
score: number
played: number
dmCount: number
tag: string
color: string
}
interface CrewMember {
id: number
name: string
level: string
plays: number
tags: string[]
credit: number
online: boolean
}
上述代码中,PlayRoom 接口是拼场功能的核心数据结构,其中 need 和 total 字段分别表示当前缺口人数和总人数,hot 和 urgent 两个布尔字段用于标识热门本和急缺状态。ScriptItem 接口则定义了剧本库中每条剧本的完整元信息,包括难度等级(1-5)、评分、已玩人数和 DM 数量等关键运营指标。CrewMember 接口中的 tags 数组字段用于存储玩家的专长标签(如"恐怖专精"“推理担当”),credit 字段表示信用分,是车友社交体系中的核心信任指标。通过这些强类型的接口定义,整个应用的数据流在编译期就能得到类型安全保障,有效避免了运行时类型错误。
二、静态数据与常量配置

应用通过 const 声明了大量静态数据数组,作为模拟数据源驱动 UI 渲染。这些数据涵盖了题材标签、拼场房间列表、题材热度、剧本库、车友列表、战绩记录、评分分布、订单列表、徽章墙、积分兑换商品等。
const GENRE_CHIPS: GenreChip[] = [
{ name: '全部题材', count: 486 },
{ name: '恐怖', count: 132 },
{ name: '推理硬核', count: 118 },
{ name: '情感沉浸', count: 96 },
{ name: '欢乐撕逼', count: 74 },
{ name: '机制阵营', count: 66 }
]
const PLAY_ROOMS: PlayRoom[] = [
{ id: 1, script: '雾山夜行', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区',
time: '今晚 19:30', need: 2, total: 6, price: 88, dm: '阿鬼', hot: true, urgent: true },
{ id: 2, script: '年轮1997', genre: '情感', store: '拾光推理社', district: '武侯区',
time: '今晚 20:00', need: 3, total: 7, price: 78, dm: '青禾', hot: false, urgent: true },
{ id: 3, script: '无人医院', genre: '恐怖', store: '夜幕剧本馆', district: '成华区',
time: '明天 14:00', need: 1, total: 5, price: 98, dm: '老猫', hot: true, urgent: false },
{ id: 4, script: '第七封信', genre: '推理', store: '谜案馆·太古里', district: '锦江区',
time: '明天 18:30', need: 2, total: 6, price: 108, dm: '墨白', hot: false, urgent: false },
{ id: 5, script: '拆迁办风云', genre: '欢乐', store: '笑场剧本杀', district: '金牛区',
time: '周六 15:00', need: 4, total: 8, price: 68, dm: '二喜', hot: true, urgent: false },
{ id: 6, script: '长安十二辰', genre: '机制', store: '凌云阁', district: '高新区',
time: '周六 19:00', need: 2, total: 7, price: 128, dm: '谢必安', hot: false, urgent: false },
{ id: 7, script: '雾都孤影', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区',
time: '周日 20:30', need: 1, total: 6, price: 92, dm: '阿鬼', hot: false, urgent: true },
{ id: 8, script: '月光奏鸣曲', genre: '情感', store: '拾光推理社', district: '武侯区',
time: '周日 14:00', need: 3, total: 6, price: 82, dm: '青禾', hot: false, urgent: false }
]
GENRE_CHIPS 数组定义了题材分类标签及其对应的剧本数量,用于拼场页顶部的横向滚动筛选条。PLAY_ROOMS 数组是拼场功能的数据核心,每条记录包含了剧本名称、题材类型、门店名称、所在区域、开场时间、缺口人数、总人数、人均价格和 DM 信息等完整信息。特别值得注意的是 hot 和 urgent 两个字段的组合使用——当 urgent 为 true 时会在卡片上显示红色"急缺"标签,当 hot 为 true 时显示紫色"热门本"标签,这两个标签的协同呈现能够快速吸引用户注意力,提升急缺场次的成局率。
三、工具函数与公共 Builder

应用定义了多个工具函数用于动态计算 UI 尺寸,以及一个公共的模态遮罩 @Builder 函数。
function heatBarH(h: number): number {
if (h >= 90) {
return 88
}
if (h >= 70) {
return 70
}
if (h >= 55) {
return 52
}
return 36
}
function heatBarColor(h: number): string {
if (h >= 90) {
return '#A855F7'
}
if (h >= 70) {
return '#7C3AED'
}
return '#4C1D95'
}
function scoreBarW(c: number): number {
if (c >= 60) {
return 150
}
if (c >= 20) {
return 80
}
if (c >= 8) {
return 36
}
return 14
}
@Builder
function modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(12,10,24,0.6)')
.onClick(() => {
onClose()
})
}
heatBarH 和 heatBarColor 函数分别根据热度值返回柱状图的高度和颜色,热度越高柱子越高、颜色越亮。scoreBarW 函数根据评分样本数量返回进度条宽度,实现了视觉上的自适应缩放。modalOverlay 是一个 @Builder 修饰的全局公共函数,它生成一个半透明遮罩层,接受一个 onClose 回调参数——当用户点击遮罩区域时触发关闭弹窗。这种设计模式将遮罩逻辑抽取为可复用的公共组件,所有弹窗只需调用该函数即可获得统一的遮罩行为,避免了大量重复代码。
四、拼场 Tab——PlayTab 组件

PlayTab 是应用的核心功能组件,负责展示拼场房间列表、题材筛选、上车确认弹窗、角色预选弹窗和场馆切换弹窗。
@Component
export struct PlayTab {
@State genreIdx: number = 0
@State showJoin: boolean = false
@State showVenue: boolean = false
@State showRole: boolean = false
@State selRoom: PlayRoom = PLAY_ROOMS[0]
@State seatNum: number = 1
@State roleIdx: number = -1
@State joined: number = 0
build() {
Scroll() {
Column({ space: 12 }) {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('今夜 23 个局在等你')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('拼场免拼卡 · 未成局自动退款')
.fontSize(11)
.fontColor('#C4B5FD')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('切场馆')
.fontSize(12)
.fontColor('#1E1B33')
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.borderRadius(14)
.backgroundColor('#A855F7')
.onClick(() => {
this.showVenue = true
})
}
.width('100%')
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('已拼 ' + (this.joined + 31) + ' 场')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本周热度')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('97%')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('成局率')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor('rgba(168,85,247,0.25)')
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [['#1E1B33', 0.0], ['#312E81', 1.0]]
})
.width('100%')
PlayTab 组件通过 @State 管理了七个状态变量:genreIdx 控制当前选中的题材索引,showJoin、showVenue、showRole 分别控制三个弹窗的显示状态,selRoom 存储当前选中的房间对象,seatNum 管理占座人数,roleIdx 管理角色选择索引,joined 记录已拼场次。头部区域使用了 linearGradient 从 #1E1B33 到 #312E81 的 135 度渐变背景,营造出深夜推理的沉浸氛围。统计区域采用四列等宽布局(通过 layoutWeight(1) 实现),展示已拼场次、成局率、人均均价和在线车友等核心运营数据。
五、拼场房间列表渲染与上车弹窗

ForEach(PLAY_ROOMS, (room: PlayRoom) => {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column()
.width(74)
.height(92)
.borderRadius(10)
.linearGradient({
angle: 135,
colors: [['#312E81', 0.0], ['#A855F7', 1.0]]
})
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(room.genre)
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#7C3AED')
if (room.urgent) {
Text('急缺')
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#EF4444')
}
if (room.hot) {
Text('热门本')
.fontSize(10)
.fontColor('#7C3AED')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F3E8FF')
}
}
Text(room.script)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(room.store + ' · ' + room.district)
.fontSize(11)
.fontColor('#6B6787')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Row() {
Row({ space: 6 }) {
Text('缺 ' + room.need + ' 人')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#EF4444')
Text('/ ' + room.total + ' 人本')
.fontSize(10)
.fontColor('#9B96B8')
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor(i < (room.total - room.need) ? '#A855F7' : '#E9E5F8')
}, (i: number) => ('s' + i).toString())
}
}
Row()
.layoutWeight(1)
Text('¥' + room.price + '/人')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text('上车')
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.borderRadius(15)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.onClick(() => {
this.selRoom = room
this.showJoin = true
})
}
.width('100%')
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: 'rgba(49,46,129,0.08)', offsetY: 2 })
}, (room: PlayRoom) => room.id.toString())
房间卡片采用了左侧渐变色块加右侧信息区的双栏布局。信息区顶部的标签组通过条件渲染(if (room.urgent) 和 if (room.hot))动态显示状态标签。底部的座位进度可视化是一个巧妙的设计——通过 ForEach 渲染 7 个圆形指示器,已确认的座位显示紫色(#A855F7),空缺的座位显示浅紫色(#E9E5F8),用户可以一目了然地看到当前的拼场进度。点击"上车"按钮后,会将当前房间数据赋值给 selRoom 并触发 showJoin 弹窗。卡片整体添加了 shadow 阴影效果,通过 offsetY: 2 实现微妙的悬浮感。
六、上车确认弹窗与角色预选

if (this.showJoin) {
modalOverlay(() => {
this.showJoin = false
})
Column({ space: 14 }) {
Row() {
Text('上车确认')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('×')
.fontSize(18)
.fontColor('#9B96B8')
.onClick(() => {
this.showJoin = false
})
}
.width('100%')
Column({ space: 6 }) {
Text(this.selRoom.script + '(' + this.selRoom.genre + ')')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Text(this.selRoom.store + ' · ' + this.selRoom.time + ' · DM ' + this.selRoom.dm)
.fontSize(11)
.fontColor('#6B6787')
.width('100%')
Text('还缺 ' + this.selRoom.need + ' 人成局')
.fontSize(11)
.fontColor('#EF4444')
.width('100%')
}
.padding(12)
.borderRadius(10)
.backgroundColor('#F6F3FE')
.width('100%')
Row() {
Text('占座人数')
.fontSize(13)
.fontColor('#4C4670')
Row()
.layoutWeight(1)
Row({ space: 0 }) {
Text('−')
.fontSize(16)
.fontColor(this.seatNum <= 1 ? '#D6D2EA' : '#7C3AED')
.padding(8)
.onClick(() => {
if (this.seatNum > 1) {
this.seatNum--
}
})
Text(this.seatNum + ' 人')
.fontSize(13)
.fontColor('#1E1B33')
.padding({ left: 10, right: 10 })
Text('+')
.fontSize(16)
.fontColor(this.seatNum >= this.selRoom.need ? '#D6D2EA' : '#7C3AED')
.padding(8)
.onClick(() => {
if (this.seatNum < this.selRoom.need) {
this.seatNum++
}
})
}
.borderRadius(8)
.backgroundColor('#F6F3FE')
}
.width('100%')
Row() {
Text('提前选角色')
.fontSize(13)
.fontColor('#4C4670')
Row()
.layoutWeight(1)
Text('去挑选 >')
.fontSize(12)
.fontColor('#7C3AED')
.onClick(() => {
this.showRole = true
})
}
.width('100%')
Row() {
Column({ space: 1 }) {
Text('合计')
.fontSize(11)
.fontColor('#9B96B8')
Text('¥' + (this.selRoom.price * this.seatNum))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#A855F7')
}
.alignItems(HorizontalAlign.Start)
Row()
.layoutWeight(1)
Text('确认上车')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.borderRadius(18)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.onClick(() => {
this.joined++
this.showJoin = false
})
}
.width('100%')
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '80%' })
.width('92%')
}
上车确认弹窗展示了该应用的交互设计精髓。占座人数的加减按钮通过条件表达式动态改变颜色——当达到下限(1 人)时减号变灰,当达到上限(缺口人数)时加号变灰,这种视觉反馈有效防止了无效操作。合计金额通过 this.selRoom.price * this.seatNum 实时计算,体现了声明式 UI 数据驱动的核心特性。点击"确认上车"后,joined 状态递增并关闭弹窗,由于 joined 参与了头部统计区域"已拼 X 场"的计算,UI 会自动更新。弹窗使用 constraintSize({ maxHeight: '80%' }) 限制最大高度,防止内容过多时溢出屏幕。
七、角色预选弹窗
if (this.showRole) {
modalOverlay(() => {
this.showRole = false
})
Column({ space: 12 }) {
Text('选择角色(仅本方可见)')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(['守夜人', '医师', '记者', '警察', '神秘的第七人'], (r: string) => {
Text(r)
.fontSize(12)
.fontColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#FFFFFF' : '#4C4670')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(10)
.backgroundColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#A855F7' : '#F6F3FE')
.margin({ right: 8, bottom: 8 })
.onClick(() => {
this.roleIdx = ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r)
})
}, (r: string) => r)
}
.width('100%')
Text('锁定')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 0, right: 0, top: 9, bottom: 9 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.width('100%')
.textAlign(TextAlign.Center)
.onClick(() => {
this.showRole = false
})
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.width('86%')
}
角色预选弹窗使用了 Flex 布局配合 FlexWrap.Wrap 实现自动换行的标签流。标题中"仅本方可见"的提示体现了剧本杀游戏中防剧透的设计考量。角色列表通过 ForEach 渲染,选中状态通过对比 roleIdx 与当前角色在数组中的索引来决定,选中时显示紫色背景白色文字,未选中时显示浅紫色背景深紫色文字。这种通过 indexOf 进行选中判断的模式在该应用中被广泛使用,简化了状态管理逻辑。
八、本子库 Tab——ScriptTab 组件
本子库 Tab 提供了剧本检索功能,包含题材热度榜、分类筛选和剧本列表。
@Component
export struct ScriptTab {
@State catIdx: number = 0
@State showDetail: boolean = false
@State showWant: boolean = false
@State selScript: ScriptItem = SCRIPT_LIST[0]
build() {
Scroll() {
Column({ space: 12 }) {
Column({ space: 12 }) {
Row() {
Text('题材热度榜')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('本周')
.fontSize(10)
.fontColor('#7C3AED')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F3E8FF')
}
.width('100%')
Row({ space: 14 }) {
ForEach(GENRE_HEAT, (g: GenreHeat) => {
Column({ space: 5 }) {
Text(g.heat.toString())
.fontSize(10)
.fontColor(g.heat >= 90 ? '#EF4444' : '#6B6787')
Column()
.width(22)
.height(heatBarH(g.heat))
.borderRadius({ topLeft: 5, topRight: 5 })
.linearGradient({
angle: 180,
colors: [[heatBarColor(g.heat), 0.0], ['#1E1B33', 1.0]]
})
Text(g.genre)
.fontSize(10)
.fontColor('#4C4670')
}
.alignItems(HorizontalAlign.Center)
}, (g: GenreHeat) => g.genre)
}
.width('100%')
.padding({ top: 4, bottom: 2 })
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.width('100%')
题材热度榜是该组件的亮点功能。它通过 ForEach 遍历 GENRE_HEAT 数据,为每个题材渲染一个柱状图。柱子高度由 heatBarH(g.heat) 函数动态计算,颜色由 heatBarColor(g.heat) 函数返回,热度值超过 90 的柱子使用最亮的 #A855F7,70-89 使用 #7C3AED,低于 70 使用 #4C1D95。同时,热度数值的文字颜色也通过 g.heat >= 90 ? '#EF4444' : '#6B6787' 条件判断进行动态着色——超过 90 的显示红色以吸引注意。柱状图使用 180 度的线性渐变,从顶部的主色渐变到底部的深色 #1E1B33,营造出层次感。
九、车队 Tab——CrewTab 组件
车队 Tab 展示推荐车友列表,支持评价、搭子邀请和发布招募。
ForEach(CREW_MEMBERS, (m: CrewMember) => {
Column({ space: 10 }) {
Row({ space: 12 }) {
Stack({ }) {
Column()
.width(52)
.height(52)
.borderRadius(26)
.linearGradient({
angle: 135,
colors: [['#A855F7', 0.0], ['#312E81', 1.0]]
})
if (m.online) {
Column()
.width(12)
.height(12)
.borderRadius(6)
.backgroundColor('#22C55E')
.border({ width: 2, color: '#FFFFFF' })
}
}
.width(52)
.height(52)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(m.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(m.level)
.fontSize(9)
.fontColor('#7C3AED')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F3E8FF')
}
Text('拼过 ' + m.plays + ' 本 · 信用分 ' + m.credit)
.fontSize(11)
.fontColor('#6B6787')
Row({ space: 6 }) {
ForEach(m.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4C4670')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F6F3FE')
}, (t: string) => t)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Row({ space: 8 }) {
Row()
.layoutWeight(1)
Text('评价')
.fontSize(11)
.fontColor('#7C3AED')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.border({ width: 1, color: '#7C3AED' })
.onClick(() => {
this.selCrew = m
this.showRate = true
})
Text('搭子邀请')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#7C3AED')
.onClick(() => {
this.selCrew = m
this.showChat = true
})
}
.width('100%')
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
}, (m: CrewMember) => m.id.toString())
车友卡片使用 Stack 组件实现了头像与在线状态指示器的叠层效果。当 m.online 为 true 时,在头像右下角渲染一个绿色小圆点(#22C55E)并带有白色边框,清晰指示在线状态。车友的专长标签通过 ForEach(m.tags, ...) 遍历渲染为浅紫色的小标签。底部操作区提供了"评价"和"搭子邀请"两个按钮,分别打开评价弹窗和聊天弹窗。评价弹窗中实现了五星评分功能,通过 ForEach([1, 2, 3, 4, 5], ...) 渲染五颗星星,点击时更新 starNum,并根据评分值显示不同的评语文案。
十、战绩 Tab——RecordTab 组件
战绩 Tab 以时间线形式展示用户的历史对局记录,并支持复盘笔记功能。
ForEach(RECORD_LIST, (r: RecordItem) => {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(r.date)
.fontSize(11)
.fontColor('#7C3AED')
.fontWeight(FontWeight.Bold)
Column()
.width(2)
.height(64)
.backgroundColor('#E5E0F5')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(r.script)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(r.result)
.fontSize(10)
.fontColor(r.resultColor)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F6F3FE')
}
Text('角色:' + r.role)
.fontSize(11)
.fontColor('#6B6787')
Row({ space: 4 }) {
ForEach([1, 2, 3, 4, 5], (i: number) => {
Text(i <= r.mood ? '★' : '☆')
.fontSize(11)
.fontColor(i <= r.mood ? '#F59E0B' : '#D6D2EA')
}, (i: number) => (r.id.toString() + '-' + i).toString())
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('复盘')
.fontSize(11)
.fontColor('#7C3AED')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10)
.border({ width: 1, color: '#7C3AED' })
.onClick(() => {
this.selRecord = r
this.showNote = true
})
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor('#FFFFFF')
}, (r: RecordItem) => r.id.toString())
对局时间线采用了左侧竖线加右侧内容卡片的经典时间线布局。左侧的竖线(width(2).height(64).backgroundColor('#E5E0F5'))作为时间轴的视觉引导线,顶部显示日期标签。右侧内容区展示了剧本名称、对局结果(使用不同颜色的标签区分"凶手获胜"“沉浸通关”"投凶正确"等结果)、扮演角色和心情评分(五星制)。心情评分通过 ForEach([1, 2, 3, 4, 5], ...) 渲染,满足条件 i <= r.mood 的星星显示金色(#F59E0B),否则显示灰色空星。点击"复盘"按钮打开复盘笔记弹窗,其中包含 DM 点评和用户自填笔记区域。
十一、订单 Tab——JOrderTab 组件
订单 Tab 提供了订单筛选、核销码展示、改期和取消等功能。
if (this.showCode) {
modalOverlay(() => {
this.showCode = false
})
Column({ space: 14 }) {
Text('到场核销码')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(this.selOrder.script + ' · ' + this.selOrder.time)
.fontSize(12)
.fontColor('#6B6787')
Column({ space: 10 }) {
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach([1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], (b: number) => {
Column()
.width(16)
.height(16)
.backgroundColor(b === 1 ? '#1E1B33' : '#FFFFFF')
.border({ width: 1, color: '#E5E0F5' })
.margin(2)
}, (b: number, i: number) => (b.toString() + i).toString())
}
Text('8826 1947')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.letterSpacing(3)
}
.padding(16)
.borderRadius(12)
.backgroundColor('#FBFAFF')
.width('100%')
.alignItems(HorizontalAlign.Center)
}
}
核销码弹窗是该组件最具特色的功能。它通过 ForEach 渲染了一个 5x5 的二维码矩阵——使用一个 25 元素的二值数组(0 和 1),值为 1 的方块显示深色(#1E1B33),值为 0 的显示白色并带有浅色边框。这个伪二维码虽然不是真正的可扫描二维码,但在视觉上高度还原了二维码的外观。下方搭配 8 位数字核销码(使用 letterSpacing(3) 增加字间距),用户可出示给前台 DM 扫码入场。订单列表中的操作按钮根据订单状态动态显示——待开场状态显示"核销码"“改期”“取消"三个按钮,其他状态只显示"再来一单”。
十二、我的 Tab——JMineTab 组件与主页路由
我的 Tab 展示用户信息、等级进度、积分兑换、徽章墙和设置等功能。
@Entry
@Component
struct ScriptKillPage {
@State activeTab: number = 0
@State showSearch: boolean = false
@State showMsg: boolean = false
@State unRead: number = 2
build() {
Column() {
Column() {
Row({ space: 10 }) {
Text('夜拼')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Row({ space: 6 }) {
Text('🔍')
.fontSize(13)
TextInput({ placeholder: '雾山夜行 / 恐怖本 / 迷雾剧场' })
.fontSize(12)
.height(34)
.layoutWeight(1)
.backgroundColor('#EDE9FE')
.borderRadius(17)
}
.layoutWeight(1)
.padding({ left: 6, right: 6 })
.backgroundColor('#FFFFFF')
.borderRadius(17)
Stack({ alignContent: Alignment.TopEnd }) {
Text('✉')
.fontSize(18)
.fontColor('#FFFFFF')
if (this.unRead > 0) {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor('#A855F7')
.margin({ top: -2, right: -2 })
}
}
.width(28)
.height(28)
.onClick(() => {
this.showMsg = true
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
}
.linearGradient({
angle: 135,
colors: [['#0F1222', 0.0], ['#312E81', 1.0]]
})
.width('100%')
if (this.activeTab === 0) {
PlayTab()
} else if (this.activeTab === 1) {
ScriptTab()
} else if (this.activeTab === 2) {
CrewTab()
} else if (this.activeTab === 3) {
RecordTab()
} else if (this.activeTab === 4) {
JOrderTab()
} else {
JMineTab()
}
Row({ space: 0 }) {
ForEach(MAIN_TABS, (tab: TabMeta, idx: number) => {
Column({ space: 3 }) {
Text(tab.icon)
.fontSize(14)
.fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.activeTab === idx ? '#7C3AED' : '#9B96B8')
if (this.activeTab === idx) {
Column()
.width(16)
.height(3)
.borderRadius(2)
.backgroundColor('#A855F7')
} else {
Column()
.width(16)
.height(3)
.borderRadius(2)
.backgroundColor('rgba(0,0,0,0)')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.activeTab = idx
})
}, (tab: TabMeta, idx: number) => (tab.name + idx).toString())
}
.width('100%')
.backgroundColor('#FFFFFF')
}
.width('100%')
.height('100%')
.backgroundColor('#F4F2FF')
}
}
主页面 ScriptKillPage 通过 @Entry 装饰器标记为应用入口。头部导航栏使用 Stack 组件在消息图标上叠加了一个未读消息红点指示器(当 unRead > 0 时显示),并使用负 margin 将红点偏移到右上角。Tab 切换通过 if-else 链式判断实现组件的条件渲染,activeTab 的值决定了当前显示哪个子组件。底部导航栏通过 ForEach(MAIN_TABS, ...) 渲染六个 Tab 按钮,选中状态使用加粗字体和紫色文字,并在下方显示一个紫色指示条(#A855F7),未选中状态的指示条使用透明色保持布局对齐。消息中心弹窗实现了未读消息列表和"全部已读"功能,点击后将 unRead 置为 0,红点自动消失。
整体业务流程图
技术点对比表格
| 技术维度 | 拼场Tab (PlayTab) | 本子库Tab (ScriptTab) | 车队Tab (CrewTab) | 战绩Tab (RecordTab) | 订单Tab (JOrderTab) | 我的Tab (JMineTab) |
|---|---|---|---|---|---|---|
| 核心数据结构 | PlayRoom | ScriptItem | CrewMember | RecordItem | OrderRow | BadgeItem/ExchangeItem |
| 状态变量数量 | 7个 | 4个 | 7个 | 3个 | 5个 | 6个 |
| 弹窗数量 | 3个(上车/角色/场馆) | 2个(详情/想玩) | 3个(评价/聊天/招募) | 1个(复盘) | 3个(核销/改期/取消) | 4个(兑换/徽章/等级/设置) |
| 列表渲染方式 | ForEach房间卡片 | ForEach剧本卡片 | ForEach车友卡片 | ForEach时间线 | ForEach订单卡片 | ForEach兑换/菜单 |
| 数据可视化 | 座位进度圆点 | 热度柱状图 | 在线状态点 | 评分分布条+时间线 | 二维码矩阵 | 等级进度条 |
| 交互特色 | 角色预选机制 | 想玩清单订阅 | 信用分体系 | DM点评+自填笔记 | 伪二维码核销 | 夜间模式开关 |
| 渐变背景 | 135度深紫渐变 | 白色卡片 | 135度紫蓝渐变 | 135度黑紫渐变 | 135度紫蓝渐变 | 135度黑紫渐变 |
| 表单输入 | 无 | 无 | TextInput聊天/招募 | TextInput笔记 | 无 | 无 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// 主题:深夜黑 #0F1222 × 荧光紫 #A855F7,底色 #F4F2FF,深夜推理局风格
// 6 个底部 Tab:拼场 / 本子库 / 车队 / 战绩 / 订单 / 我的
interface GenreChip {
name: string
count: number
}
interface PlayRoom {
id: number
script: string
genre: string
store: string
district: string
time: string
need: number
total: number
price: number
dm: string
hot: boolean
urgent: boolean
}
interface GenreHeat {
genre: string
heat: number
}
interface ScriptItem {
id: number
name: string
genre: string
players: string
duration: string
difficulty: number
score: number
played: number
dmCount: number
tag: string
color: string
}
interface CrewMember {
id: number
name: string
level: string
plays: number
tags: string[]
credit: number
online: boolean
}
interface RecordItem {
id: number
date: string
script: string
role: string
result: string
resultColor: string
mood: number
}
interface ScoreBucket {
star: number
count: number
}
interface OrderRow {
id: string
script: string
store: string
time: string
status: string
statusColor: string
price: number
seats: number
}
interface BadgeItem {
id: number
name: string
desc: string
lit: boolean
}
interface ExchangeItem {
id: number
name: string
cost: number
color: string
}
interface TabMeta {
name: string
icon: string
}
interface MsgMeta {
id: number
title: string
content: string
time: string
read: boolean
}
const GENRE_CHIPS: GenreChip[] = [
{ name: '全部题材', count: 486 },
{ name: '恐怖', count: 132 },
{ name: '推理硬核', count: 118 },
{ name: '情感沉浸', count: 96 },
{ name: '欢乐撕逼', count: 74 },
{ name: '机制阵营', count: 66 }
]
const PLAY_ROOMS: PlayRoom[] = [
{ id: 1, script: '雾山夜行', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区', time: '今晚 19:30', need: 2, total: 6, price: 88, dm: '阿鬼', hot: true, urgent: true },
{ id: 2, script: '年轮1997', genre: '情感', store: '拾光推理社', district: '武侯区', time: '今晚 20:00', need: 3, total: 7, price: 78, dm: '青禾', hot: false, urgent: true },
{ id: 3, script: '无人医院', genre: '恐怖', store: '夜幕剧本馆', district: '成华区', time: '明天 14:00', need: 1, total: 5, price: 98, dm: '老猫', hot: true, urgent: false },
{ id: 4, script: '第七封信', genre: '推理', store: '谜案馆·太古里', district: '锦江区', time: '明天 18:30', need: 2, total: 6, price: 108, dm: '墨白', hot: false, urgent: false },
{ id: 5, script: '拆迁办风云', genre: '欢乐', store: '笑场剧本杀', district: '金牛区', time: '周六 15:00', need: 4, total: 8, price: 68, dm: '二喜', hot: true, urgent: false },
{ id: 6, script: '长安十二辰', genre: '机制', store: '凌云阁', district: '高新区', time: '周六 19:00', need: 2, total: 7, price: 128, dm: '谢必安', hot: false, urgent: false },
{ id: 7, script: '雾都孤影', genre: '恐怖', store: '迷雾剧场·春熙店', district: '锦江区', time: '周日 20:30', need: 1, total: 6, price: 92, dm: '阿鬼', hot: false, urgent: true },
{ id: 8, script: '月光奏鸣曲', genre: '情感', store: '拾光推理社', district: '武侯区', time: '周日 14:00', need: 3, total: 6, price: 82, dm: '青禾', hot: false, urgent: false }
]
const GENRE_HEAT: GenreHeat[] = [
{ genre: '恐怖', heat: 92 },
{ genre: '推理', heat: 78 },
{ genre: '情感', heat: 70 },
{ genre: '欢乐', heat: 58 },
{ genre: '机制', heat: 46 }
]
const SCRIPT_LIST: ScriptItem[] = [
{ id: 1, name: '雾山夜行', genre: '恐怖', players: '6 人', duration: '4.5h', difficulty: 4, score: 9.2, played: 8421, dmCount: 12, tag: '年度十佳', color: '#2E2350' },
{ id: 2, name: '第七封信', genre: '推理', players: '6 人', duration: '5h', difficulty: 5, score: 9.5, played: 6210, dmCount: 9, tag: '硬核闭眼冲', color: '#1F3A5F' },
{ id: 3, name: '年轮1997', genre: '情感', players: '7 人', duration: '4h', difficulty: 2, score: 9.0, played: 9833, dmCount: 21, tag: '哭到缺氧', color: '#5F2A4A' },
{ id: 4, name: '无人医院', genre: '恐怖', players: '5 人', duration: '4h', difficulty: 3, score: 8.8, played: 5320, dmCount: 15, tag: '单排友好', color: '#2F3B2F' },
{ id: 5, name: '拆迁办风云', genre: '欢乐', players: '8 人', duration: '3.5h', difficulty: 1, score: 8.6, played: 11207, dmCount: 18, tag: '团建神器', color: '#6B4A1F' },
{ id: 6, name: '长安十二辰', genre: '机制', players: '7 人', duration: '6h', difficulty: 5, score: 9.4, played: 4108, dmCount: 7, tag: '进阶必玩', color: '#3D2F5F' }
]
const CREW_MEMBERS: CrewMember[] = [
{ id: 1, name: '夜行者K', level: 'Lv.9 老玩家', plays: 187, tags: ['恐怖专精', '不贴脸', '讲礼貌'], credit: 98, online: true },
{ id: 2, name: '陈皮话梅糖', level: 'Lv.7 常驻', plays: 96, tags: ['情感本', '氛围组', '可哭'], credit: 95, online: true },
{ id: 3, name: '阿鬼的猫', level: 'Lv.8 老玩家', plays: 142, tags: ['推理担当', '全类型', '节奏快'], credit: 99, online: false },
{ id: 4, name: '栗子不甜', level: 'Lv.5 进阶', plays: 41, tags: ['欢乐本', '话痨', '能接梗'], credit: 92, online: true },
{ id: 5, name: 'ZERO', level: 'Lv.10 大神', plays: 268, tags: ['机制本', '算牌流', '带飞'], credit: 97, online: false }
]
const RECORD_LIST: RecordItem[] = [
{ id: 1, date: '08/22', script: '雾山夜行', role: '守夜人·陈九', result: '凶手获胜', resultColor: '#A855F7', mood: 5 },
{ id: 2, date: '08/17', script: '年轮1997', role: '二姐·林晚', result: '沉浸通关', resultColor: '#7C3AED', mood: 5 },
{ id: 3, date: '08/10', script: '第七封信', role: '邮差·沈默', result: '投凶正确', resultColor: '#22C55E', mood: 4 },
{ id: 4, date: '08/03', script: '拆迁办风云', role: '钉子户·王哥', result: '阵营胜利', resultColor: '#F59E0B', mood: 5 },
{ id: 5, date: '07/27', script: '无人医院', role: '实习医生·顾一', result: '投凶失误', resultColor: '#94A3B8', mood: 3 },
{ id: 6, date: '07/19', script: '长安十二辰', role: '不良帅·裴远', result: '个人第一', resultColor: '#A855F7', mood: 5 }
]
const SCORE_BUCKETS: ScoreBucket[] = [
{ star: 5, count: 68 },
{ star: 4, count: 21 },
{ star: 3, count: 8 },
{ star: 2, count: 2 },
{ star: 1, count: 1 }
]
const ORDER_ROWS: OrderRow[] = [
{ id: 'JP2026082401', script: '雾山夜行', store: '迷雾剧场·春熙店', time: '08/24 19:30', status: '待开场', statusColor: '#A855F7', price: 88, seats: 2 },
{ id: 'JP2026082002', script: '年轮1997', store: '拾光推理社', time: '08/20 14:00', status: '已完成', statusColor: '#6B6787', price: 78, seats: 1 },
{ id: 'JP2026081503', script: '第七封信', store: '谜案馆·太古里', time: '08/15 18:30', status: '已改期', statusColor: '#F59E0B', price: 108, seats: 3 },
{ id: 'JP2026080904', script: '无人医院', store: '夜幕剧本馆', time: '08/09 20:00', status: '已取消', statusColor: '#94A3B8', price: 98, seats: 1 }
]
const ORDER_FILTERS: string[] = ['全部', '待开场', '已完成', '已改期', '已取消']
const BADGE_LIST: BadgeItem[] = [
{ id: 1, name: '百本斩', desc: '完成 100 本', lit: true },
{ id: 2, name: '恐怖免疫', desc: '恐怖本 20 场', lit: true },
{ id: 3, name: '泪失禁体质', desc: '情感本哭 10 次', lit: true },
{ id: 4, name: '名侦探', desc: '投凶正确率 80%', lit: true },
{ id: 5, name: '社交悍匪', desc: '拼场 50 次', lit: true },
{ id: 6, name: '千本斩', desc: '完成 1000 本', lit: false }
]
const EXCHANGE_LIST: ExchangeItem[] = [
{ id: 1, name: '恐怖本 5 折券', cost: 500, color: '#A855F7' },
{ id: 2, name: '免拼卡(随时退)', cost: 800, color: '#7C3AED' },
{ id: 3, name: '限定徽章·夜枭', cost: 1200, color: '#1E1B33' },
{ id: 4, name: 'DM 点评特权', cost: 300, color: '#F59E0B' }
]
const MAIN_TABS: TabMeta[] = [
{ name: '拼场', icon: '拼场' },
{ name: '本子库', icon: '本子' },
{ name: '车队', icon: '车队' },
{ name: '战绩', icon: '战绩' },
{ name: '订单', icon: '订单' },
{ name: '我的', icon: '我的' }
]
const MSG_LIST: MsgMeta[] = [
{ id: 1, title: '拼场成功', content: '「雾山夜行」今晚 19:30 已成局,请提前 15 分钟到场', time: '10:03', read: false },
{ id: 2, title: 'DM 已确认', content: '阿鬼 DM 已确认带本场,恐怖程度可调节', time: '09:45', read: false },
{ id: 3, title: '车队邀请', content: '夜行者K 邀请你加入周六「长安十二辰」车队', time: '昨天', read: true },
{ id: 4, title: '复盘上线', content: '你 08/22 的「雾山夜行」复盘已生成', time: '昨天', read: true },
{ id: 5, title: '积分到账', content: '拼场返 88 积分,可兑换恐怖本 5 折券', time: '08/22', read: true }
]
function heatBarH(h: number): number {
if (h >= 90) {
return 88
}
if (h >= 70) {
return 70
}
if (h >= 55) {
return 52
}
return 36
}
function heatBarColor(h: number): string {
if (h >= 90) {
return '#A855F7'
}
if (h >= 70) {
return '#7C3AED'
}
return '#4C1D95'
}
function scoreBarW(c: number): number {
if (c >= 60) {
return 150
}
if (c >= 20) {
return 80
}
if (c >= 8) {
return 36
}
return 14
}
@Builder
function modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('rgba(12,10,24,0.6)')
.onClick(() => {
onClose()
})
}
// ==================== Tab 1 拼场 ====================
@Component
export struct PlayTab {
@State genreIdx: number = 0
@State showJoin: boolean = false
@State showVenue: boolean = false
@State showRole: boolean = false
@State selRoom: PlayRoom = PLAY_ROOMS[0]
@State seatNum: number = 1
@State roleIdx: number = -1
@State joined: number = 0
build() {
Scroll() {
Column({ space: 12 }) {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('今夜 23 个局在等你')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('拼场免拼卡 · 未成局自动退款')
.fontSize(11)
.fontColor('#C4B5FD')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('切场馆')
.fontSize(12)
.fontColor('#1E1B33')
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.borderRadius(14)
.backgroundColor('#A855F7')
.onClick(() => {
this.showVenue = true
})
}
.width('100%')
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('已拼 ' + (this.joined + 31) + ' 场')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('本周热度')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('97%')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('成局率')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('¥82')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('人均均价')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('128')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('在线车友')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.borderRadius(10)
.backgroundColor('rgba(168,85,247,0.25)')
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [['#1E1B33', 0.0], ['#312E81', 1.0]]
})
.width('100%')
Scroll() {
Row({ space: 8 }) {
ForEach(GENRE_CHIPS, (chip: GenreChip) => {
Text(chip.name + ' ' + chip.count)
.fontSize(12)
.fontColor(this.genreIdx === GENRE_CHIPS.indexOf(chip) ? '#FFFFFF' : '#4C4670')
.padding({ left: 13, right: 13, top: 7, bottom: 7 })
.borderRadius(15)
.backgroundColor(this.genreIdx === GENRE_CHIPS.indexOf(chip) ? '#A855F7' : '#FFFFFF')
.onClick(() => {
this.genreIdx = GENRE_CHIPS.indexOf(chip)
})
}, (chip: GenreChip) => chip.name)
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
ForEach(PLAY_ROOMS, (room: PlayRoom) => {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column()
.width(74)
.height(92)
.borderRadius(10)
.linearGradient({
angle: 135,
colors: [['#312E81', 0.0], ['#A855F7', 1.0]]
})
Column({ space: 6 }) {
Row({ space: 6 }) {
Text(room.genre)
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#7C3AED')
if (room.urgent) {
Text('急缺')
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#EF4444')
}
if (room.hot) {
Text('热门本')
.fontSize(10)
.fontColor('#7C3AED')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F3E8FF')
}
}
Text(room.script)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(room.store + ' · ' + room.district)
.fontSize(11)
.fontColor('#6B6787')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text('🕐 ' + room.time)
.fontSize(11)
.fontColor('#4C4670')
Text('DM ' + room.dm)
.fontSize(11)
.fontColor('#4C4670')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Row() {
Row({ space: 6 }) {
Text('缺 ' + room.need + ' 人')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#EF4444')
Text('/ ' + room.total + ' 人本')
.fontSize(10)
.fontColor('#9B96B8')
Row({ space: 3 }) {
ForEach([0, 1, 2, 3, 4, 5, 6], (i: number) => {
Column()
.width(10)
.height(10)
.borderRadius(5)
.backgroundColor(i < (room.total - room.need) ? '#A855F7' : '#E9E5F8')
}, (i: number) => ('s' + i).toString())
}
}
Row()
.layoutWeight(1)
Text('¥' + room.price + '/人')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text('上车')
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.borderRadius(15)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.onClick(() => {
this.selRoom = room
this.showJoin = true
})
}
.width('100%')
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.shadow({ radius: 8, color: 'rgba(49,46,129,0.08)', offsetY: 2 })
}, (room: PlayRoom) => room.id.toString())
if (this.showJoin) {
modalOverlay(() => {
this.showJoin = false
})
Column({ space: 14 }) {
Row() {
Text('上车确认')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('×')
.fontSize(18)
.fontColor('#9B96B8')
.onClick(() => {
this.showJoin = false
})
}
.width('100%')
Column({ space: 6 }) {
Text(this.selRoom.script + '(' + this.selRoom.genre + ')')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Text(this.selRoom.store + ' · ' + this.selRoom.time + ' · DM ' + this.selRoom.dm)
.fontSize(11)
.fontColor('#6B6787')
.width('100%')
Text('还缺 ' + this.selRoom.need + ' 人成局')
.fontSize(11)
.fontColor('#EF4444')
.width('100%')
}
.padding(12)
.borderRadius(10)
.backgroundColor('#F6F3FE')
.width('100%')
Row() {
Text('占座人数')
.fontSize(13)
.fontColor('#4C4670')
Row()
.layoutWeight(1)
Row({ space: 0 }) {
Text('−')
.fontSize(16)
.fontColor(this.seatNum <= 1 ? '#D6D2EA' : '#7C3AED')
.padding(8)
.onClick(() => {
if (this.seatNum > 1) {
this.seatNum--
}
})
Text(this.seatNum + ' 人')
.fontSize(13)
.fontColor('#1E1B33')
.padding({ left: 10, right: 10 })
Text('+')
.fontSize(16)
.fontColor(this.seatNum >= this.selRoom.need ? '#D6D2EA' : '#7C3AED')
.padding(8)
.onClick(() => {
if (this.seatNum < this.selRoom.need) {
this.seatNum++
}
})
}
.borderRadius(8)
.backgroundColor('#F6F3FE')
}
.width('100%')
Row() {
Text('提前选角色')
.fontSize(13)
.fontColor('#4C4670')
Row()
.layoutWeight(1)
Text('去挑选 >')
.fontSize(12)
.fontColor('#7C3AED')
.onClick(() => {
this.showRole = true
})
}
.width('100%')
Text('· 未成局自动全额退款\n· 开场前 4 小时可免费退改')
.fontSize(11)
.fontColor('#9B96B8')
.lineHeight(18)
.width('100%')
Row() {
Column({ space: 1 }) {
Text('合计')
.fontSize(11)
.fontColor('#9B96B8')
Text('¥' + (this.selRoom.price * this.seatNum))
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#A855F7')
}
.alignItems(HorizontalAlign.Start)
Row()
.layoutWeight(1)
Text('确认上车')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 22, right: 22, top: 9, bottom: 9 })
.borderRadius(18)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.onClick(() => {
this.joined++
this.showJoin = false
})
}
.width('100%')
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '80%' })
.width('92%')
}
if (this.showRole) {
modalOverlay(() => {
this.showRole = false
})
Column({ space: 12 }) {
Text('选择角色(仅本方可见)')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) {
ForEach(['守夜人', '医师', '记者', '警察', '神秘的第七人'], (r: string) => {
Text(r)
.fontSize(12)
.fontColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#FFFFFF' : '#4C4670')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(10)
.backgroundColor(this.roleIdx === ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r) ? '#A855F7' : '#F6F3FE')
.margin({ right: 8, bottom: 8 })
.onClick(() => {
this.roleIdx = ['守夜人', '医师', '记者', '警察', '神秘的第七人'].indexOf(r)
})
}, (r: string) => r)
}
.width('100%')
Text('锁定')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 0, right: 0, top: 9, bottom: 9 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.width('100%')
.textAlign(TextAlign.Center)
.onClick(() => {
this.showRole = false
})
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.width('86%')
}
if (this.showVenue) {
modalOverlay(() => {
this.showVenue = false
})
Column({ space: 10 }) {
Text('切换场馆')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
ForEach(['迷雾剧场·春熙店', '拾光推理社', '夜幕剧本馆', '谜案馆·太古里', '笑场剧本杀', '凌云阁'], (v: string) => {
Row() {
Column({ space: 2 }) {
Text(v)
.fontSize(13)
.fontColor('#1E1B33')
Text('本周 12 场 · 人均 ¥86')
.fontSize(10)
.fontColor('#9B96B8')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('查看')
.fontSize(11)
.fontColor('#7C3AED')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#F6F3FE')
.onClick(() => {
this.showVenue = false
})
}
.width('100%')
.padding({ top: 10, bottom: 10 })
}, (v: string) => v)
}
.padding(18)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '72%' })
.width('88%')
}
}
.padding(12)
}
.backgroundColor('#F4F2FF')
.layoutWeight(1)
}
}
// ==================== Tab 2 本子库 ====================
@Component
export struct ScriptTab {
@State catIdx: number = 0
@State showDetail: boolean = false
@State showWant: boolean = false
@State selScript: ScriptItem = SCRIPT_LIST[0]
build() {
Scroll() {
Column({ space: 12 }) {
Column({ space: 12 }) {
Row() {
Text('题材热度榜')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('本周')
.fontSize(10)
.fontColor('#7C3AED')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F3E8FF')
}
.width('100%')
Row({ space: 14 }) {
ForEach(GENRE_HEAT, (g: GenreHeat) => {
Column({ space: 5 }) {
Text(g.heat.toString())
.fontSize(10)
.fontColor(g.heat >= 90 ? '#EF4444' : '#6B6787')
Column()
.width(22)
.height(heatBarH(g.heat))
.borderRadius({ topLeft: 5, topRight: 5 })
.linearGradient({
angle: 180,
colors: [[heatBarColor(g.heat), 0.0], ['#1E1B33', 1.0]]
})
Text(g.genre)
.fontSize(10)
.fontColor('#4C4670')
}
.alignItems(HorizontalAlign.Center)
}, (g: GenreHeat) => g.genre)
}
.width('100%')
.padding({ top: 4, bottom: 2 })
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.width('100%')
Row({ space: 8 }) {
ForEach(['全部', '恐怖', '推理', '情感', '欢乐', '机制'], (cat: string) => {
Text(cat)
.fontSize(12)
.fontColor(this.catIdx === ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat) ? '#FFFFFF' : '#4C4670')
.padding({ left: 13, right: 13, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.catIdx === ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat) ? '#7C3AED' : '#FFFFFF')
.border({ width: 1, color: this.catIdx === ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat) ? '#7C3AED' : '#E5E0F5' })
.onClick(() => {
this.catIdx = ['全部', '恐怖', '推理', '情感', '欢乐', '机制'].indexOf(cat)
})
}, (cat: string) => cat)
}
.width('100%')
ForEach(SCRIPT_LIST, (s: ScriptItem) => {
Column({ space: 10 }) {
Row({ space: 12 }) {
Column({ space: 4 }) {
Column()
.width(70)
.height(92)
.borderRadius(10)
.backgroundColor(s.color)
Text(s.tag)
.fontSize(9)
.fontColor('#7C3AED')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F3E8FF')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 5 }) {
Row({ space: 6 }) {
Text(s.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(s.genre)
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#7C3AED')
}
Row({ space: 10 }) {
Text(s.players + '本')
Text(s.duration)
Text('难度 ' + s.difficulty + '/5')
}
Row({ space: 10 }) {
Text('★ ' + s.score.toFixed(1))
.fontSize(11)
.fontColor('#F59E0B')
Text(s.played + ' 人玩过')
.fontSize(11)
.fontColor('#6B6787')
Text(s.dmCount + ' 位 DM 在带')
.fontSize(11)
.fontColor('#6B6787')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Row() {
Text('¥78 起')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#A855F7')
Row()
.layoutWeight(1)
Text('想玩清单')
.fontSize(11)
.fontColor('#4C4670')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.border({ width: 1, color: '#D6D2EA' })
.onClick(() => {
this.selScript = s
this.showWant = true
})
Text('本子详情')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#7C3AED')
.onClick(() => {
this.selScript = s
this.showDetail = true
})
}
.width('100%')
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
}, (s: ScriptItem) => s.id.toString())
if (this.showDetail) {
modalOverlay(() => {
this.showDetail = false
})
Column({ space: 12 }) {
Row() {
Text('本子详情')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('×')
.fontSize(18)
.fontColor('#9B96B8')
.onClick(() => {
this.showDetail = false
})
}
.width('100%')
Row({ space: 12 }) {
Column()
.width(80)
.height(104)
.borderRadius(10)
.backgroundColor(this.selScript.color)
Column({ space: 5 }) {
Text(this.selScript.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(this.selScript.genre + ' · ' + this.selScript.tag)
.fontSize(11)
.fontColor('#7C3AED')
Text('人数 ' + this.selScript.players + ' · 时长 ' + this.selScript.duration + ' · 难度 ' + this.selScript.difficulty + '/5')
.fontSize(11)
.fontColor('#6B6787')
Text('★ ' + this.selScript.score.toFixed(1) + ' · ' + this.selScript.played + ' 人玩过')
.fontSize(11)
.fontColor('#F59E0B')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Column({ space: 6 }) {
Text('角色一览(无剧透)')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(['守夜人', '巡山医倌', '纸扎铺老板', '货车司机', '返乡学生', '护士'], (r: string) => {
Text(r)
.fontSize(11)
.fontColor('#4C4670')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(8)
.backgroundColor('#F6F3FE')
.margin({ right: 8, bottom: 8 })
}, (r: string) => r)
}
.width('100%')
}
.padding(10)
.borderRadius(10)
.backgroundColor('#FBFAFF')
.width('100%')
Row() {
Text('¥78/人 起')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#A855F7')
Row()
.layoutWeight(1)
Text('加入想玩')
.fontSize(12)
.fontColor('#7C3AED')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(14)
.border({ width: 1, color: '#7C3AED' })
.onClick(() => {
this.showWant = true
})
Text('去拼场')
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.borderRadius(14)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.onClick(() => {
this.showDetail = false
})
}
.width('100%')
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '80%' })
.width('92%')
}
if (this.showWant) {
modalOverlay(() => {
this.showWant = false
})
Column({ space: 12 }) {
Text('已加入想玩清单')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text('「' + this.selScript.name + '」开拼时\n第一时间推送提醒给你')
.fontSize(12)
.fontColor('#6B6787')
.textAlign(TextAlign.Center)
.lineHeight(19)
Row({ space: 10 }) {
Text('好的')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 24, right: 24, top: 9, bottom: 9 })
.borderRadius(18)
.backgroundColor('#7C3AED')
.onClick(() => {
this.showWant = false
})
}
}
.padding(22)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Center)
.width('78%')
}
}
.padding(12)
}
.backgroundColor('#F4F2FF')
.layoutWeight(1)
}
}
// ==================== Tab 3 车队 ====================
@Component
export struct CrewTab {
@State showRate: boolean = false
@State showChat: boolean = false
@State showRecruit: boolean = false
@State starNum: number = 5
@State selCrew: CrewMember = CREW_MEMBERS[0]
@State inputMsg: string = ''
@State recruitScript: string = ''
build() {
Scroll() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text('发布招募')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('缺人发这儿,30 秒成局')
.fontSize(10)
.fontColor('#C4B5FD')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('发布')
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.borderRadius(14)
.backgroundColor('#A855F7')
.onClick(() => {
this.showRecruit = true
})
}
.width('100%')
}
.padding(14)
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [['#312E81', 0.0], ['#7C3AED', 1.0]]
})
.width('100%')
Row() {
Text('推荐车友')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('信用分 = 拼场守约率')
.fontSize(10)
.fontColor('#9B96B8')
}
.width('100%')
ForEach(CREW_MEMBERS, (m: CrewMember) => {
Column({ space: 10 }) {
Row({ space: 12 }) {
Stack({ }) {
Column()
.width(52)
.height(52)
.borderRadius(26)
.linearGradient({
angle: 135,
colors: [['#A855F7', 0.0], ['#312E81', 1.0]]
})
if (m.online) {
Column()
.width(12)
.height(12)
.borderRadius(6)
.backgroundColor('#22C55E')
.border({ width: 2, color: '#FFFFFF' })
}
}
.width(52)
.height(52)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(m.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(m.level)
.fontSize(9)
.fontColor('#7C3AED')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F3E8FF')
}
Text('拼过 ' + m.plays + ' 本 · 信用分 ' + m.credit)
.fontSize(11)
.fontColor('#6B6787')
Row({ space: 6 }) {
ForEach(m.tags, (t: string) => {
Text(t)
.fontSize(10)
.fontColor('#4C4670')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F6F3FE')
}, (t: string) => t)
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Row({ space: 8 }) {
Row()
.layoutWeight(1)
Text('评价')
.fontSize(11)
.fontColor('#7C3AED')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.border({ width: 1, color: '#7C3AED' })
.onClick(() => {
this.selCrew = m
this.showRate = true
})
Text('搭子邀请')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#7C3AED')
.onClick(() => {
this.selCrew = m
this.showChat = true
})
}
.width('100%')
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
}, (m: CrewMember) => m.id.toString())
if (this.showRate) {
modalOverlay(() => {
this.showRate = false
})
Column({ space: 14 }) {
Text('评价 ' + this.selCrew.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Row({ space: 10 }) {
ForEach([1, 2, 3, 4, 5], (i: number) => {
Text(i <= this.starNum ? '★' : '☆')
.fontSize(28)
.fontColor(i <= this.starNum ? '#F59E0B' : '#D6D2EA')
.onClick(() => {
this.starNum = i
})
}, (i: number) => i.toString())
}
Text(this.starNum >= 5 ? '神仙搭子,下次还约!' : (this.starNum >= 3 ? '体验不错,可以再约' : '体验一般,不太合适'))
.fontSize(12)
.fontColor('#6B6787')
Text('提交评价')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 0, right: 0, top: 9, bottom: 9 })
.borderRadius(18)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.width('100%')
.textAlign(TextAlign.Center)
.onClick(() => {
this.showRate = false
})
}
.padding(20)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '70%' })
.width('84%')
}
if (this.showChat) {
modalOverlay(() => {
this.showChat = false
})
Column({ space: 12 }) {
Row() {
Text('搭子邀请 · ' + this.selCrew.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('×')
.fontSize(18)
.fontColor('#9B96B8')
.onClick(() => {
this.showChat = false
})
}
.width('100%')
Column({ space: 6 }) {
Text('你好!周六「长安十二辰」缺 2 人,\n看你是机制本大神,一起吗?')
.fontSize(12)
.fontColor('#1E1B33')
.padding(12)
.borderRadius({ topLeft: 2, topRight: 12, bottomLeft: 12, bottomRight: 12 })
.backgroundColor('#F6F3FE')
.width('86%')
}
.alignItems(HorizontalAlign.Start)
.width('100%')
TextInput({ placeholder: '说点什么…', text: this.inputMsg })
.fontSize(12)
.height(38)
.borderRadius(10)
.backgroundColor('#F6F3FE')
.onChange((v: string) => {
this.inputMsg = v
})
.width('100%')
Row({ space: 10 }) {
Text('取消')
.fontSize(13)
.fontColor('#4C4670')
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#F6F3FE')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showChat = false
})
Text('发送')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showChat = false
})
}
.width('100%')
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '70%' })
.width('90%')
}
if (this.showRecruit) {
modalOverlay(() => {
this.showRecruit = false
})
Column({ space: 12 }) {
Text('发布招募')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
TextInput({ placeholder: '想拼的本子名(如:雾山夜行)', text: this.recruitScript })
.fontSize(12)
.height(38)
.borderRadius(10)
.backgroundColor('#F6F3FE')
.onChange((v: string) => {
this.recruitScript = v
})
.width('100%')
Column({ space: 8 }) {
Text('题材')
.fontSize(12)
.fontColor('#6B6787')
.width('100%')
Row({ space: 8 }) {
ForEach(['恐怖', '推理', '情感', '欢乐'], (g: string) => {
Text(g)
.fontSize(12)
.fontColor('#7C3AED')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#F6F3FE')
}, (g: string) => g)
}
}
.width('100%')
Column({ space: 8 }) {
Text('时间')
.fontSize(12)
.fontColor('#6B6787')
.width('100%')
Row({ space: 8 }) {
ForEach(['今晚', '明天', '本周末'], (t: string) => {
Text(t)
.fontSize(12)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(10)
.backgroundColor('#7C3AED')
}, (t: string) => t)
}
}
.width('100%')
Text('发布招募')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 0, right: 0, top: 9, bottom: 9 })
.borderRadius(18)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.width('100%')
.textAlign(TextAlign.Center)
.onClick(() => {
this.showRecruit = false
})
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '80%' })
.width('92%')
}
}
.padding(12)
}
.backgroundColor('#F4F2FF')
.layoutWeight(1)
}
}
// ==================== Tab 4 战绩 ====================
@Component
export struct RecordTab {
@State showNote: boolean = false
@State selRecord: RecordItem = RECORD_LIST[0]
@State noteText: string = ''
build() {
Scroll() {
Column({ space: 12 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('52')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('总场次')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('842')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('拼场时长(h)')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('76%')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('投凶正确率')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('4.8')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('车友评分')
.fontSize(9)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.padding({ top: 16, bottom: 16 })
.borderRadius(14)
.linearGradient({
angle: 135,
colors: [['#0F1222', 0.0], ['#7C3AED', 1.0]]
})
.width('100%')
Column({ space: 10 }) {
Row() {
Text('我的评分分布')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('100 场样本')
.fontSize(10)
.fontColor('#9B96B8')
}
.width('100%')
ForEach(SCORE_BUCKETS, (b: ScoreBucket) => {
Row({ space: 10 }) {
Text(b.star + ' 星')
.fontSize(11)
.fontColor('#4C4670')
.width(34)
Column()
.height(10)
.borderRadius(5)
.width(scoreBarW(b.count))
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
Text(b.count + '%')
.fontSize(11)
.fontColor('#9B96B8')
}
.width('100%')
}, (b: ScoreBucket) => b.star.toString())
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.width('100%')
Row() {
Text('对局时间线')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('全部 52 场')
.fontSize(11)
.fontColor('#9B96B8')
}
.width('100%')
ForEach(RECORD_LIST, (r: RecordItem) => {
Row({ space: 12 }) {
Column({ space: 4 }) {
Text(r.date)
.fontSize(11)
.fontColor('#7C3AED')
.fontWeight(FontWeight.Bold)
Column()
.width(2)
.height(64)
.backgroundColor('#E5E0F5')
}
.alignItems(HorizontalAlign.Center)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(r.script)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(r.result)
.fontSize(10)
.fontColor(r.resultColor)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(4)
.backgroundColor('#F6F3FE')
}
Text('角色:' + r.role)
.fontSize(11)
.fontColor('#6B6787')
Row({ space: 4 }) {
ForEach([1, 2, 3, 4, 5], (i: number) => {
Text(i <= r.mood ? '★' : '☆')
.fontSize(11)
.fontColor(i <= r.mood ? '#F59E0B' : '#D6D2EA')
}, (i: number) => (r.id.toString() + '-' + i).toString())
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('复盘')
.fontSize(11)
.fontColor('#7C3AED')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(10)
.border({ width: 1, color: '#7C3AED' })
.onClick(() => {
this.selRecord = r
this.showNote = true
})
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor('#FFFFFF')
}, (r: RecordItem) => r.id.toString())
if (this.showNote) {
modalOverlay(() => {
this.showNote = false
})
Column({ space: 12 }) {
Row() {
Text('复盘笔记')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('×')
.fontSize(18)
.fontColor('#9B96B8')
.onClick(() => {
this.showNote = false
})
}
.width('100%')
Column({ space: 5 }) {
Text(this.selRecord.script + ' · ' + this.selRecord.role)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Text(this.selRecord.date + ' · ' + this.selRecord.result)
.fontSize(11)
.fontColor(this.selRecord.resultColor)
.width('100%')
}
.padding(10)
.borderRadius(10)
.backgroundColor('#F6F3FE')
.width('100%')
Column({ space: 4 }) {
Text('DM 点评')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Text('阿鬼:状态在线,守夜人身份藏得很好,\n最后一票犹豫了,下次可以更果断。')
.fontSize(11)
.fontColor('#6B6787')
.lineHeight(18)
.width('100%')
}
.padding(10)
.borderRadius(10)
.backgroundColor('#FBFAFF')
.width('100%')
TextInput({ placeholder: '写点什么…(仅自己可见)', text: this.noteText })
.fontSize(12)
.height(64)
.borderRadius(10)
.backgroundColor('#F6F3FE')
.onChange((v: string) => {
this.noteText = v
})
.width('100%')
Text('保存笔记')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 0, right: 0, top: 9, bottom: 9 })
.borderRadius(18)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
.width('100%')
.textAlign(TextAlign.Center)
.onClick(() => {
this.showNote = false
})
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '80%' })
.width('92%')
}
}
.padding(12)
}
.backgroundColor('#F4F2FF')
.layoutWeight(1)
}
}
// ==================== Tab 5 订单 ====================
@Component
export struct JOrderTab {
@State filterIdx: number = 0
@State showCode: boolean = false
@State showChange: boolean = false
@State showCancel: boolean = false
@State selOrder: OrderRow = ORDER_ROWS[0]
build() {
Scroll() {
Column({ space: 12 }) {
Row({ space: 8 }) {
ForEach(ORDER_FILTERS, (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.filterIdx === ORDER_FILTERS.indexOf(f) ? '#FFFFFF' : '#4C4670')
.padding({ left: 13, right: 13, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.filterIdx === ORDER_FILTERS.indexOf(f) ? '#7C3AED' : '#FFFFFF')
.border({ width: 1, color: this.filterIdx === ORDER_FILTERS.indexOf(f) ? '#7C3AED' : '#E5E0F5' })
.onClick(() => {
this.filterIdx = ORDER_FILTERS.indexOf(f)
})
}, (f: string) => f)
}
.width('100%')
ForEach(ORDER_ROWS, (o: OrderRow) => {
Column({ space: 10 }) {
Row() {
Text('订单 ' + o.id)
.fontSize(11)
.fontColor('#9B96B8')
Row()
.layoutWeight(1)
Text(o.status)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(o.statusColor)
}
.width('100%')
Row({ space: 12 }) {
Column()
.width(56)
.height(72)
.borderRadius(10)
.linearGradient({
angle: 135,
colors: [['#312E81', 0.0], ['#A855F7', 1.0]]
})
Column({ space: 4 }) {
Text(o.script)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(o.store)
.fontSize(11)
.fontColor('#6B6787')
Text(o.time + ' · ' + o.seats + ' 座')
.fontSize(11)
.fontColor('#6B6787')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥' + (o.price * o.seats))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#A855F7')
}
.width('100%')
Row({ space: 8 }) {
Row()
.layoutWeight(1)
if (o.status === '待开场') {
Text('核销码')
.fontSize(11)
.fontColor('#7C3AED')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.border({ width: 1, color: '#7C3AED' })
.onClick(() => {
this.selOrder = o
this.showCode = true
})
Text('改期')
.fontSize(11)
.fontColor('#4C4670')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.border({ width: 1, color: '#D6D2EA' })
.onClick(() => {
this.selOrder = o
this.showChange = true
})
Text('取消')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#EF4444')
.onClick(() => {
this.selOrder = o
this.showCancel = true
})
} else {
Text('再来一单')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#7C3AED')
}
}
.width('100%')
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
}, (o: OrderRow) => o.id)
if (this.showCode) {
modalOverlay(() => {
this.showCode = false
})
Column({ space: 14 }) {
Text('到场核销码')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(this.selOrder.script + ' · ' + this.selOrder.time)
.fontSize(12)
.fontColor('#6B6787')
Column({ space: 10 }) {
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Center }) {
ForEach([1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1, 0, 1], (b: number) => {
Column()
.width(16)
.height(16)
.backgroundColor(b === 1 ? '#1E1B33' : '#FFFFFF')
.border({ width: 1, color: '#E5E0F5' })
.margin(2)
}, (b: number, i: number) => (b.toString() + i).toString())
}
Text('8826 1947')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.letterSpacing(3)
}
.padding(16)
.borderRadius(12)
.backgroundColor('#FBFAFF')
.width('100%')
.alignItems(HorizontalAlign.Center)
Text('出示给前台 DM 扫码即可入场')
.fontSize(11)
.fontColor('#9B96B8')
Text('完成')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 26, right: 26, top: 9, bottom: 9 })
.borderRadius(18)
.backgroundColor('#7C3AED')
.onClick(() => {
this.showCode = false
})
}
.padding(20)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.alignItems(HorizontalAlign.Center)
.width('86%')
}
if (this.showChange) {
modalOverlay(() => {
this.showChange = false
})
Column({ space: 12 }) {
Text('改期')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Text('「' + this.selOrder.script + '」每单可免费改期 1 次')
.fontSize(11)
.fontColor('#6B6787')
.width('100%')
Row({ space: 8 }) {
ForEach(['明天 19:30', '周六 14:00', '周日 20:30'], (t: string) => {
Text(t)
.fontSize(12)
.fontColor('#7C3AED')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.borderRadius(10)
.backgroundColor('#F6F3FE')
}, (t: string) => t)
}
.width('100%')
Row({ space: 10 }) {
Text('取消')
.fontSize(13)
.fontColor('#4C4670')
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#F6F3FE')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showChange = false
})
Text('确认改期')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showChange = false
})
}
.width('100%')
}
.padding(18)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.width('88%')
}
if (this.showCancel) {
modalOverlay(() => {
this.showCancel = false
})
Column({ space: 12 }) {
Text('取消订单')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text('开场前 4 小时取消全额退款\n当前订单距开场 5.2 小时,可免费取消')
.fontSize(12)
.fontColor('#6B6787')
.lineHeight(19)
.textAlign(TextAlign.Center)
Row({ space: 10 }) {
Text('再想想')
.fontSize(13)
.fontColor('#4C4670')
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#F6F3FE')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showCancel = false
})
Text('确认取消')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 20, right: 20, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#EF4444')
.layoutWeight(1)
.textAlign(TextAlign.Center)
.onClick(() => {
this.showCancel = false
})
}
.width('100%')
}
.padding(18)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.width('84%')
}
}
.padding(12)
}
.backgroundColor('#F4F2FF')
.layoutWeight(1)
}
}
// ==================== Tab 6 我的 ====================
@Component
export struct JMineTab {
@State showExchange: boolean = false
@State showSetting: boolean = false
@State showBadge: boolean = false
@State showLevel: boolean = false
@State nightMode: boolean = true
build() {
Scroll() {
Column({ space: 12 }) {
Column({ space: 12 }) {
Row({ space: 12 }) {
Column()
.width(58)
.height(58)
.borderRadius(29)
.linearGradient({
angle: 135,
colors: [['#A855F7', 0.0], ['#0F1222', 1.0]]
})
.border({ width: 2, color: '#C4B5FD' })
Column({ space: 4 }) {
Text('夜行者K')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Row({ space: 6 }) {
Text('Lv.9 老玩家')
.fontSize(10)
.fontColor('#1E1B33')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor('#C4B5FD')
Text('再拼 13 场升 Lv.10')
.fontSize(10)
.fontColor('#C4B5FD')
.onClick(() => {
this.showLevel = true
})
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('徽章')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(12)
.border({ width: 1, color: '#A855F7' })
.onClick(() => {
this.showBadge = true
})
}
.width('100%')
Column({ space: 6 }) {
Row() {
Text('Lv.9')
.fontSize(10)
.fontColor('#C4B5FD')
Row()
.layoutWeight(1)
Text('Lv.10')
.fontSize(10)
.fontColor('#C4B5FD')
}
.width('100%')
Column({ space: 0 }) {
Column()
.width('74%')
.height(6)
.borderRadius(3)
.linearGradient({
angle: 90,
colors: [['#7C3AED', 0.0], ['#A855F7', 1.0]]
})
}
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor('rgba(196,181,253,0.3)')
}
.width('100%')
Row({ space: 0 }) {
Column({ space: 3 }) {
Text('2380')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('夜行积分')
.fontSize(10)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('12')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('优惠券')
.fontSize(10)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('5')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('徽章')
.fontSize(10)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.showBadge = true
})
Column({ space: 3 }) {
Text('38')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text('想玩清单')
.fontSize(10)
.fontColor('#C4B5FD')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding({ top: 4, bottom: 2 })
}
.padding(16)
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [['#0F1222', 0.0], ['#7C3AED', 1.0]]
})
.width('100%')
Column({ space: 12 }) {
Row() {
Text('积分兑换')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('全部 >')
.fontSize(11)
.fontColor('#9B96B8')
.onClick(() => {
this.showExchange = true
})
}
.width('100%')
Scroll() {
Row({ space: 10 }) {
ForEach(EXCHANGE_LIST, (e: ExchangeItem) => {
Column({ space: 6 }) {
Column()
.width(88)
.height(64)
.borderRadius(10)
.backgroundColor(e.color)
Text(e.name)
.fontSize(10)
.fontColor('#4C4670')
.maxLines(1)
Text(e.cost + ' 分')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#7C3AED')
}
.padding(8)
.borderRadius(12)
.backgroundColor('#F6F3FE')
.alignItems(HorizontalAlign.Center)
.width(104)
.onClick(() => {
this.showExchange = true
})
}, (e: ExchangeItem) => e.id.toString())
}
}
.scrollable(ScrollDirection.Horizontal)
.width('100%')
}
.padding(12)
.borderRadius(14)
.backgroundColor('#FFFFFF')
.width('100%')
Column({ space: 0 }) {
ForEach(['我的车友', '常去场馆', '收货地址', '拼场提醒设置', '帮助与客服', '关于夜拼'], (t: string) => {
Row() {
Text(t)
.fontSize(13)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('>')
.fontSize(13)
.fontColor('#D6D2EA')
}
.width('100%')
.padding({ top: 13, bottom: 13 })
}, (t: string) => t)
}
.padding({ left: 14, right: 14 })
.borderRadius(12)
.backgroundColor('#FFFFFF')
.width('100%')
Row() {
Text('夜间模式(深夜局专用配色)')
.fontSize(13)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text(this.nightMode ? '已开启' : '已关闭')
.fontSize(12)
.fontColor(this.nightMode ? '#7C3AED' : '#9B96B8')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#F6F3FE')
.onClick(() => {
this.nightMode = !this.nightMode
})
}
.width('100%')
.padding(14)
.borderRadius(12)
.backgroundColor('#FFFFFF')
.onClick(() => {
this.showSetting = true
})
if (this.showExchange) {
modalOverlay(() => {
this.showExchange = false
})
Column({ space: 12 }) {
Text('积分商城')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
ForEach(EXCHANGE_LIST, (e: ExchangeItem) => {
Row({ space: 12 }) {
Column()
.width(52)
.height(52)
.borderRadius(10)
.backgroundColor(e.color)
Column({ space: 3 }) {
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(e.cost + ' 夜行积分')
.fontSize(11)
.fontColor('#7C3AED')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('兑换')
.fontSize(11)
.fontColor('#FFFFFF')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#7C3AED')
.onClick(() => {
this.showExchange = false
})
}
.width('100%')
.padding(10)
.borderRadius(12)
.backgroundColor('#F6F3FE')
}, (e: ExchangeItem) => ('x' + e.id).toString())
Text('当前 2380 分 · 每拼 1 场得 88 分')
.fontSize(11)
.fontColor('#9B96B8')
.width('100%')
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '78%' })
.width('92%')
}
if (this.showBadge) {
modalOverlay(() => {
this.showBadge = false
})
Column({ space: 12 }) {
Text('我的徽章墙')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(BADGE_LIST, (b: BadgeItem) => {
Column({ space: 5 }) {
Column()
.width(52)
.height(52)
.borderRadius(26)
.backgroundColor(b.lit ? '#7C3AED' : '#E5E0F5')
Text(b.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(b.lit ? '#1E1B33' : '#9B96B8')
Text(b.desc)
.fontSize(9)
.fontColor('#9B96B8')
}
.width('31%')
.alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor(b.lit ? '#F6F3FE' : '#FBFAFF')
.margin({ bottom: 8 })
}, (b: BadgeItem) => b.id.toString())
}
.width('100%')
Text('关闭')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 26, right: 26, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.onClick(() => {
this.showBadge = false
})
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '78%' })
.width('92%')
}
if (this.showLevel) {
modalOverlay(() => {
this.showLevel = false
})
Column({ space: 10 }) {
Text('等级特权')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
ForEach(['Lv.8 · 拼场 95 折', 'Lv.9 · 优先锁定角色', 'Lv.10 · 免拼卡 ×3/月', 'Lv.12 · 专属年度报告'], (t: string) => {
Text('· ' + t)
.fontSize(12)
.fontColor('#6B6787')
.width('100%')
}, (t: string) => t)
Text('当前 Lv.9 · 再拼 13 场升级')
.fontSize(11)
.fontColor('#7C3AED')
Text('知道了')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 26, right: 26, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.onClick(() => {
this.showLevel = false
})
}
.padding(20)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.width('82%')
}
if (this.showSetting) {
modalOverlay(() => {
this.showSetting = false
})
Column({ space: 12 }) {
Text('设置')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
ForEach(['消息推送', '免打扰时段', '隐私设置', '账号安全', '退出登录'], (t: string) => {
Row() {
Text(t)
.fontSize(13)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('>')
.fontSize(13)
.fontColor('#D6D2EA')
}
.width('100%')
.padding({ top: 12, bottom: 12 })
}, (t: string) => t)
Text('关闭')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 26, right: 26, top: 8, bottom: 8 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.onClick(() => {
this.showSetting = false
})
}
.padding(18)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '75%' })
.width('86%')
}
}
.padding(12)
}
.backgroundColor('#F4F2FF')
.layoutWeight(1)
}
}
// ==================== 主页面 ====================
@Entry
@Component
struct ScriptKillPage {
@State activeTab: number = 0
@State showSearch: boolean = false
@State showMsg: boolean = false
@State unRead: number = 2
build() {
Column() {
Column() {
Row({ space: 10 }) {
Text('夜拼')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Row({ space: 6 }) {
Text('🔍')
.fontSize(13)
TextInput({ placeholder: '雾山夜行 / 恐怖本 / 迷雾剧场' })
.fontSize(12)
.height(34)
.layoutWeight(1)
.backgroundColor('#EDE9FE')
.borderRadius(17)
}
.layoutWeight(1)
.padding({ left: 6, right: 6 })
.backgroundColor('#FFFFFF')
.borderRadius(17)
Stack({ alignContent: Alignment.TopEnd }) {
Text('✉')
.fontSize(18)
.fontColor('#FFFFFF')
if (this.unRead > 0) {
Column()
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor('#A855F7')
.margin({ top: -2, right: -2 })
}
}
.width(28)
.height(28)
.onClick(() => {
this.showMsg = true
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
}
.linearGradient({
angle: 135,
colors: [['#0F1222', 0.0], ['#312E81', 1.0]]
})
.width('100%')
if (this.activeTab === 0) {
PlayTab()
} else if (this.activeTab === 1) {
ScriptTab()
} else if (this.activeTab === 2) {
CrewTab()
} else if (this.activeTab === 3) {
RecordTab()
} else if (this.activeTab === 4) {
JOrderTab()
} else {
JMineTab()
}
Column()
.width('100%')
.height(1)
.backgroundColor('#E5E0F5')
Row({ space: 0 }) {
ForEach(MAIN_TABS, (tab: TabMeta, idx: number) => {
Column({ space: 3 }) {
Text(tab.icon)
.fontSize(14)
.fontWeight(this.activeTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.activeTab === idx ? '#7C3AED' : '#9B96B8')
if (this.activeTab === idx) {
Column()
.width(16)
.height(3)
.borderRadius(2)
.backgroundColor('#A855F7')
} else {
Column()
.width(16)
.height(3)
.borderRadius(2)
.backgroundColor('rgba(0,0,0,0)')
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.activeTab = idx
})
}, (tab: TabMeta, idx: number) => (tab.name + idx).toString())
}
.width('100%')
.backgroundColor('#FFFFFF')
if (this.showSearch) {
modalOverlay(() => {
this.showSearch = false
})
Column({ space: 12 }) {
Text('搜索本子 / 场馆 / 车友')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
.width('100%')
TextInput({ placeholder: '输入关键词' })
.fontSize(12)
.height(38)
.borderRadius(10)
.backgroundColor('#F6F3FE')
.width('100%')
Row({ space: 8 }) {
ForEach(['恐怖本', '情感本', '拼场', 'DM'], (k: string) => {
Text(k)
.fontSize(12)
.fontColor('#7C3AED')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#F6F3FE')
}, (k: string) => k)
}
.width('100%')
Text('搜索')
.fontSize(13)
.fontColor('#FFFFFF')
.padding({ left: 0, right: 0, top: 9, bottom: 9 })
.borderRadius(16)
.backgroundColor('#7C3AED')
.width('100%')
.textAlign(TextAlign.Center)
.onClick(() => {
this.showSearch = false
})
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.width('90%')
}
if (this.showMsg) {
modalOverlay(() => {
this.showMsg = false
this.unRead = 0
})
Column({ space: 12 }) {
Row() {
Text('消息中心')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Row()
.layoutWeight(1)
Text('全部已读')
.fontSize(11)
.fontColor('#7C3AED')
.onClick(() => {
this.unRead = 0
})
}
.width('100%')
ForEach(MSG_LIST, (m: MsgMeta) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(m.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1E1B33')
Text(m.content)
.fontSize(11)
.fontColor('#6B6787')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text(m.time)
.fontSize(9)
.fontColor('#9B96B8')
if (!m.read) {
Column()
.width(7)
.height(7)
.borderRadius(4)
.backgroundColor('#A855F7')
}
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
}, (m: MsgMeta) => m.id.toString())
}
.padding(16)
.borderRadius(16)
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '75%' })
.width('92%')
}
}
.width('100%')
.height('100%')
.backgroundColor('#F4F2FF')
}
}
总结

本文对基于 HarmonyOS API 24 的夜拼剧本杀拼场应用进行了全面的代码级深度解析。该应用以 ArkTS 声明式 UI 为核心框架,通过 @Entry、@Component、@State 等装饰器构建了层次分明的组件树,实现了拼场、本子库、车队、战绩、订单和个人中心六大功能模块的完整覆盖。应用在数据层采用了强类型接口定义,确保了编译期的类型安全;在视图层大量运用了 ForEach、Flex、Stack 等布局组件和条件渲染机制,实现了丰富的动态 UI 效果。
从架构设计角度来审视,该应用体现了 HarmonyOS 声明式 UI 的几个核心设计理念。首先是状态驱动的 UI 更新机制——所有界面变化都由 @State 变量的修改自动触发,开发者无需手动操作 DOM 节点。其次是组件化的模块拆分策略——每个 Tab 都是独立的 @Component struct,内部状态完全自包含,通过主页面的 activeTab 进行路由切换。最后是公共逻辑的复用设计——modalOverlay 作为 @Builder 函数被所有弹窗共享,工具函数 heatBarH、heatBarColor、scoreBarW 被多个组件调用,有效减少了代码冗余。
更多推荐




所有评论(0)