HarmonyOS 6 ArkUI - setInterval 粒子动画实现:@State 粒子数组 x 坐标漂移、Math.random 初始化、越界循环重置及 slice () 引用更新触发重绘
每一场现场,都值得被记进日记。票根会旧,记忆不会。
引言:把一张票根,变成一本会呼吸的巡演日记
2026 年的 Live 音乐市场,正在经历一场从"抢票即终点"到"全链路体验"的深刻转变。过去,乐迷的观演旅程被割裂在无数个 App 之间——在大麦抢票、在高德查路线、在小红书找攻略、在微信约搭子、在朋友圈发打卡。每一次切换,都是一次体验的断裂和记忆的散落。
巡演日记 TOURLOG 正是为解决这一痛点而生。它不是又一个票务平台,而是一本围绕 Live 音乐现场展开的"全链路观演日记"——从巡演日程的发现、场馆信息的查询、电子票夹的管理,到乐迷打卡的社区、拼车拼房的搭子匹配,再到个人观演足迹的沉淀,TOURLOG 将一场 Live 从"官宣"到"散场后回忆"的完整旅程收拢在一个 App 之内。

打开 TOURLOG,第一眼看到的不是冰冷的商品列表,而是一条流淌的时间轴——16 场演出正在路上,从陈粒「悬日」到万能青年旅店,从五条人到草东没有派对,每一场都像一张被钉在墙上的演出海报,等着你去揭下。深酒红的底色如同剧场里熄灭灯光前的最后一抹暮色,香槟金的文字如同舞台上追光灯打出的剧名,玫瑰红的"立即抢票"按钮如同开场铃——一切都在告诉你:这不是一个购物 App,这是一本关于现场的日记。
本文将从设计理念、色彩系统、项目架构、数据模型、六大 Tab 页面、弹窗交互、动画效果、五大业务系统、开发环境等多个维度,对巡演日记 TOURLOG 进行一次完整的深度解析。无论你是 HarmonyOS 开发者、产品经理、UI 设计师,还是一个热爱 Live 音乐的乐迷,都能在本文中找到有价值的参考。
第一章 项目概述与设计理念
1.1 项目定位
巡演日记 TOURLOG 是一款基于 HarmonyOS ArkTS 声明式 UI 框架开发的 Live 音乐全链路体验 App,单文件约 2900 行代码,无外部依赖,可直接在 DevEco Studio 中运行预览。项目聚焦于独立音乐/摇滚/民谣/后摇等 Live 场景,覆盖从"演出发现"到"观演回忆"的完整用户旅程。
与传统票务 App 不同,TOURLOG 的核心定位不是"卖票",而是"记录"——它将每一场 Live 视为乐迷人生中的一个重要节点,用电子票夹留存入场凭证,用打卡社区记录现场瞬间,用观演足迹串联城市地图,用搭子匹配连接同好之人。票根会旧,记忆不会——这是 TOURLOG 的产品哲学,也是它区别于所有竞品的灵魂所在。
1.2 目标用户
TOURLOG 的目标用户画像清晰而聚焦:
- 独立音乐爱好者:频繁观看 Livehouse、体育馆级别的独立乐队演出,年观演 5 场以上,对陈粒、万青、五条人、草东、新裤子等乐队有强烈认同感
- 巡演追演族:愿意跨城市追巡演,有"集邮"式观演习惯,重视每一场的票根和打卡记录
- 社交型乐迷:喜欢在观演前后寻找同好搭子,拼车拼房、排队买周边、散场吃夜宵,观演不仅是音乐体验更是社交体验
- 现场记录者:习惯用文字和照片记录每一场 Live 的感动瞬间,有"写观演日记"的习惯或意愿
这些用户的共同特征是:对 Live 音乐有强烈的情感投入,观演不仅是消费行为更是身份认同和生活方式。TOURLOG 的每一个设计决策,都围绕这一核心用户群展开。
1.3 设计理念
TOURLOG 的设计理念可以概括为三个关键词:剧场感、日记感、同好感。
剧场感:深酒红的底色如同剧场幕布,香槟金的文字如同舞台追光,玫瑰红的按钮如同开场铃。每一次打开 App,都像走进一座剧场——灯光渐暗,幕布拉开,一场属于你的 Live 即将开始。这种剧场感不是装饰性的,而是贯穿整个交互体验的——演出详情弹窗像一张节目单,电子票像一张入场券,抢票倒计时像开场前的最后一分钟。
日记感:TOURLOG 的本质是一本日记。时间轴式的日程排布如同日记本上的日期格,电子票夹如同夹在日记本里的票根,打卡动态如同写在日记里的文字,个人中心的"观演 23 场·足迹 9 城"如同日记扉页上的总结。每一场 Live 都在这本日记里留下一页,随着时间推移,这本日记会越来越厚,越来越珍贵。
同好感:Live 音乐的魅力不仅在于舞台上的乐队,更在于台下那群和你一起合唱、一起蹦跳、一起流泪的陌生人。TOURLOG 的搭子系统、打卡社区、评论互动,都是为了让这些"陌生人"变成"同好"。拼房拼车、排队买周边、散场吃夜宵——这些观演中的"周边体验",恰恰是 Live 记忆中最温暖的部分。
1.4 核心功能矩阵
TOURLOG 的功能体系围绕"一场 Live 的全链路"展开,形成六大核心模块:
| 模块 | 核心功能 | 用户价值 |
|---|---|---|
| 巡演日程 | 时间轴演出列表、演出详情、抢票入口、开售提醒 | 发现演出,不错过任何一场想看的 Live |
| 场馆指南 | 城市热度排行、场馆信息、座位图、交通攻略 | 观演前了解场馆,规划行程 |
| 电子票夹 | 票根管理、电子票展示、转赠、退票 | 一站式管理所有演出门票 |
| 乐迷打卡 | 观演动态发布、现场评分、评论互动、点赞分享 | 记录每一场 Live 的感动瞬间 |
| 搭子匹配 | 拼房拼车列表、发起搭子、加入申请、安全须知 | 找到同好,让观演不再孤单 |
| 个人中心 | 观演足迹、想看列表、等级体系、设置 | 沉淀观演记忆,见证成长轨迹 |
这六大模块不是孤立的,而是通过"一场 Live"这个核心线索串联在一起——在日程里发现演出,在场馆里了解场地,在票夹里管理门票,在打卡里记录现场,在搭子里找到同好,在个人中心里回顾足迹。每一个模块都是这本巡演日记的一页,合在一起就是一本完整的 Live 人生。
第二章 色彩系统与视觉风格
2.1 票根酒红风:一场关于剧场与回忆的色彩叙事
TOURLOG 的视觉风格被定义为"票根酒红风"——这是一种以深酒红为底色、玫瑰红为强调、香槟金为点缀、奶白为文字的色彩体系,灵感来源于三个意象:剧场幕布、复古票根、威士忌酒杯。
深酒红(#1A0E12 / #180C10)如同剧场里熄灭灯光前的最后一抹暮色,是整个 App 的底色。它不是纯黑,而是带着一丝红调的深酒红——在暗光下像天鹅绒幕布,在亮光下像陈年红酒。这种底色为整个 App 奠定了"剧场感"的基调,让每一个 UI 元素都像舞台上被聚光灯照亮的道具。
玫瑰红(#E11D48)是 TOURLOG 的品牌色和主操作色。它如同舞台上的开场铃、票根上的印章、乐迷手腕上的红色入场带——醒目、热烈、不可忽视。所有主操作按钮(立即抢票、提交订单、发布打卡、发送申请)都使用玫瑰红或香槟金,在深酒红底色上形成强烈的视觉焦点。
香槟金(#F59E0B)是 TOURLOG 的点缀色和次级操作色。它如同舞台上的追光灯、票根上的烫金字、剧场里的黄铜扶手——温暖、精致、有质感。香槟金用于标签、图标、次级按钮、数据高亮等场景,与玫瑰红形成"金红配"的经典组合,如同一张复古票根上的红底金字。
奶白(#FDF2F4 / #E7D5D9)是 TOURLOG 的文字色。它不是纯白,而是带着一丝粉调的奶白——在深酒红底色上柔和而不刺眼,如同剧场里柔和的面光。主标题使用 #FDF2F4,正文使用 #E7D5D9,辅助文字使用 #9A8F94(灰调酒红),形成清晰的文字层级。
2.2 色彩语义体系
TOURLOG 的色彩使用遵循严格的语义体系,每一种颜色都有明确的含义:
| 色彩 | 色值 | 语义 | 使用场景 |
|---|---|---|---|
| 深酒红底 | #1A0E12 | 页面底色 | 所有页面背景 |
| 卡片酒红 | #241318 | 卡片背景 | 演出卡片、票根、场馆卡、动态卡 |
| 玫瑰红 | #E11D48 | 主操作/品牌 | 抢票按钮、退票、退出、点赞红心 |
| 香槟金 | #F59E0B | 次级操作/高亮 | Tab选中、提交订单、发布、加入搭子 |
| 天空蓝 | #7DD3FC | 信息/安全 | 实名标签、安全须知、待观演状态 |
| 灰酒红 | #9A8F94 | 辅助文字 | 次要信息、时间、地点、占位文字 |
| 售罄灰 | #9A8F94 | 不可用 | 售罄状态、已核销票根 |
特别值得一提的是演出状态的色彩编码:预售中使用香槟金(#F59E0B),如同"正在热卖"的标签;售罄使用灰酒红(#9A8F94),如同"已售完"的印章;未开售使用天空蓝(#7DD3FC),如同"即将开始"的预告。三种状态三种颜色,乐迷扫一眼就能知道哪些演出可以抢、哪些已经错过、哪些值得等待。
2.3 视觉元素语言
TOURLOG 的视觉元素语言围绕"剧场与票根"展开,形成一套独特的设计语言:
大圆角卡片:所有卡片使用 14-16px 的大圆角,如同剧场里的节目单和票根的圆角切割。卡片底色使用比页面底色稍浅的 #241318,形成"卡片浮在底色上"的层次感。
Emoji 图标系统:TOURLOG 大量使用 Emoji 作为图标——🎫(票)、🏟(场馆)、📅(日程)、📸(打卡)、🤝(搭子)、🎤(演出)、🎸(乐队)、🛶(五条人)、⛰(草东)。Emoji 图标不仅降低了设计成本,更赋予了每个乐队和功能独特的个性标识——陈粒是🌞(悬日),万青是🎸,五条人是🛶,草东是⛰,新裤子是👖。这些 Emoji 如同乐队的"视觉签名",乐迷一眼就能认出。
时间轴布局:日程 Tab 采用时间轴式布局——左侧是日期(月/日/星期),中间是一条竖线(预售中为香槟金实线,其他为半透明线),右侧是演出信息。这种布局如同日记本上的时间线,让"巡演"这个概念有了视觉化的表达——一场场演出沿着时间线排列,如同人生路上的一个个站点。
票根式设计:电子票夹的票根卡片采用"票根式"设计——左侧是耳机图标(如同票根上的打孔),中间是演出信息和座位,右侧是状态标签和箭头,底部是票号和"点击查看电子票"。票根卡片的设计灵感来源于真实的纸质票根,每一张都像一张可以撕下来收藏的入场券。
2.4 深色模式的沉浸式体验
TOURLOG 全程使用深色模式(深酒红底色),这不是一个随意的选择,而是基于产品定位的深思熟虑:
剧场沉浸感:深色模式如同剧场里熄灭的灯光,让用户的注意力集中在"舞台"上——演出信息、票根、打卡内容。每一个 UI 元素都像被聚光灯照亮,形成强烈的剧场沉浸感。
Live 场景适配:乐迷在观演前后使用 App 的场景往往是暗光环境(剧场内、散场后的夜晚、通勤的地铁),深色模式在这些场景下更护眼、更省电、更不突兀。
复古质感:深酒红+香槟金的深色配色如同复古票根和老照片,与"日记"和"回忆"的产品调性高度契合。浅色模式会让这种复古质感大打折扣。
品牌差异化:在票务 App 普遍使用浅色模式的市场中,TOURLOG 的深色模式形成了强烈的品牌差异化——打开 App 的一瞬间,用户就知道"这不是大麦,这是巡演日记"。
第三章 项目架构与文件组织
3.1 单文件组件化架构
TOURLOG 采用单文件组件化架构——整个应用的所有代码(类型定义、静态数据、工具函数、枚举、8 个 @Component 组件)都写在一个 2131.ets 文件中,约 2900 行。这种架构选择基于以下考量:
演示与学习友好:作为一个演示项目,单文件架构让学习者可以一次性看到完整的代码结构,无需在多个文件之间跳转。从类型定义到组件实现,从数据到 UI,一切都在一个文件里,阅读体验如同读一本完整的书。
无构建依赖:单文件架构不需要复杂的模块导入导出,减少了构建配置和依赖管理的复杂度。在 DevEco Studio 中直接打开文件即可预览运行,无需额外配置。
组件化内聚:虽然是单文件,但代码内部通过 @Component 装饰器划分为 8 个独立组件,每个组件封装自己的状态、UI 和逻辑。组件之间通过静态数据和 props 传递信息,保持了良好的解耦性。
3.2 组件层级结构
TOURLOG 的 8 个 @Component 组件形成清晰的层级结构:
TourlogApp(@Entry 根组件)
├── TourHeader(头部组件:Logo + 搜索 + 横向筛选标签)
└── contentArea(内容区:根据 activeTab 切换 6 个 Tab 组件)
├── ScheduleContent(日程Tab:时间轴演出列表 + 详情/抢票弹窗)
├── VenueContent(场馆Tab:城市热度图 + 场馆网格 + 详情弹窗)
├── TicketContent(票夹Tab:待观演/已核销票根 + 电子票/转赠/退票弹窗)
├── CheckinContent(打卡Tab:动态流 + 发布/详情弹窗 + 点赞)
├── BuddyContent(搭子Tab:拼车拼房列表 + 详情/发起/加入/安全弹窗)
└── MeContent(我的Tab:个人信息 + 观演数据 + 想看/关于/退出弹窗)
TourlogApp 是 @Entry 根组件,负责管理全局状态 activeTab(当前选中的 Tab),通过 @Builder contentArea() 根据 activeTab 渲染对应的 Tab 组件,通过 @Builder bottomTabItem() 渲染底部导航栏的每一个 Tab 项。
TourHeader 是头部组件,包含 Logo(🎫 票根图标 + "巡演日记"标题 + "TOURLOG · Live 音乐现场助手"副标题)、搜索框(搜演出/场馆/音乐人)、通知铃铛,以及一行横向滚动的筛选标签(全部/即将开售/我要看/同城/拼搭子/转票区)。头部是静态的,不随 Tab 切换而变化。
6 个 Tab 组件 各自封装自己的 @State 状态、@Builder 弹窗和 build() 方法。每个 Tab 组件都是一个独立的"页面",包含自己的列表、卡片、弹窗和交互逻辑。
3.3 状态管理策略
TOURLOG 的状态管理遵循"状态最小化、局部化"的原则:
全局状态:只有 activeTab(当前选中的 Tab)是全局状态,由根组件 TourlogApp 管理,通过 @State 装饰。这个状态决定了内容区渲染哪个 Tab 组件。
局部状态:每个 Tab 组件内部的状态(如日程 Tab 的 showDetail/selectedTour/showBuyModal/zoneIndex,票夹 Tab 的 tabIdx/showTicket/selectedTicket,打卡 Tab 的 showPostModal/postText/postStars 等)都由各自组件通过 @State 管理,不对外暴露。这种局部化的状态管理避免了状态污染和不必要的重渲染。
静态数据:所有业务数据(TOUR_DATES、VENUE_LIST、TICKET_STUBS、CHECKIN_POSTS、BUDDY_LIST、CITY_HEAT、WANT_LIST 等)都定义为顶层 const 常量,不使用 @State 管理。这些数据是"只读"的,组件通过引用访问,不需要响应式更新。唯一的例外是打卡动态的 liked 状态和想看列表的 notify 状态——这些通过直接修改对象属性实现(p.liked = !p.liked),配合 ForEach 的 keyGenerator 触发局部更新。
弹窗状态:所有弹窗都通过布尔型 @State(showDetail/showBuyModal/showTicket 等)控制显隐,弹窗内容通过 @Builder 封装。弹窗的显示/隐藏本质上是 Stack 叠加层中 if 条件的切换——当 showDetail 为 true 时,渲染一个全屏半透明遮罩+弹窗内容;为 false 时不渲染。
3.4 弹窗实现模式
TOURLOG 中有约 15 套弹窗(演出详情、抢票确认、场馆详情、电子票、转赠、退票、发布打卡、打卡详情、搭子详情、发起搭子、加入搭子、安全须知、想看列表、关于、退出登录),全部采用统一的实现模式:
Stack({ alignContent: Alignment.Center }) {
Scroll() { /* 主内容 */ }
if (this.showXxx) {
Column() {
Column() // 半透明遮罩(点击关闭)
.layoutWeight(1)
.onClick(() => { this.showXxx = false; })
this.xxxModal() // 弹窗内容
}
.backgroundColor('rgba(10,4,7,0.8)') // 深酒红半透明遮罩
}
}
这种模式的核心是 Stack 叠加 + if 条件渲染 + 半透明遮罩:
- Stack 让弹窗可以叠加在主内容之上,alignContent: Alignment.Center 让弹窗居中显示
- if (this.showXxx) 控制弹窗的显隐,为 true 时渲染弹窗层,为 false 时不渲染
- 半透明遮罩 使用 rgba(10,4,7,0.8)——深酒红的 80% 不透明,既遮挡了背后的主内容,又透出一丝酒红色调,与整体视觉风格统一
- 遮罩点击关闭:遮罩层(Column .layoutWeight(1))占据弹窗上方的全部空间,点击后设置 showXxx = false 关闭弹窗
- 弹窗内容 通过 @Builder xxxModal() 封装,是一个 Column 组件,包含标题、内容、操作按钮等
这种弹窗实现模式简洁、统一、易于维护,是 ArkTS 中实现自定义弹窗的最佳实践之一。所有弹窗的遮罩色、圆角、边框风格保持一致,形成统一的弹窗视觉语言。
3.5 代码组织结构
2131.ets 文件内部按照清晰的逻辑顺序组织:
- 文件头注释:项目名称、功能描述、视觉风格、遵循规范
- 类型定义(7 个 interface):TourDate、VenueInfo、TicketStub、CheckinPost、BuddyItem、CityHeat、WantItem
- 静态数据(10 个 const 数组):TOUR_DATES、VENUE_LIST、TICKET_STUBS、CHECKIN_POSTS、BUDDY_LIST、CITY_HEAT、WANT_LIST、TICKET_ZONES
- 工具函数(4 个 function):tourStatusColor、tourStatusBg、heatBarHeight、starText
- 枚举(1 个 enum):TourTab(SCHEDULE/VENUE/TICKET/CHECKIN/BUDDY/ME)
- 根组件:TourlogApp(@Entry)
- 头部组件:TourHeader
- 6 个 Tab 组件:ScheduleContent、VenueContent、TicketContent、CheckinContent、BuddyContent、MeContent
这种"类型→数据→工具→枚举→组件"的组织顺序符合开发者的阅读习惯——先了解数据结构,再看数据内容,然后是工具函数和枚举,最后是 UI 组件的实现。每个部分之间用 // ============ xxx ============ 注释分隔,形成清晰的代码分区。
第四章 数据模型与全局状态管理
4.1 核心数据模型
TOURLOG 定义了 7 个核心 interface,覆盖演出、场馆、票根、打卡、搭子、城市热度、想看列表七大业务实体:
TourDate(巡演场次):一场具体的演出,包含 id、artist(演出名称,如"陈粒「悬日」")、city(城市)、venue(场馆)、date(日期,MM-DD 格式)、weekday(星期)、status(状态:预售中/售罄/未开售)、priceFrom(起售价)、want(想看人数)、emoji(乐队图标)。TourDate 是整个 App 的核心实体——日程 Tab 展示它,演出详情弹窗展示它,抢票弹窗关联它,打卡动态引用它,搭子列表关联它。
VenueInfo(场馆信息):一个 Live 场馆,包含 id、name、city、capacity(容量,如"18000座")、metro(地铁交通)、score(评分)、tags(标签,如"地铁直达/存包便利/声场佳")。VenueInfo 为乐迷提供观演前的场馆攻略——怎么去、多大、评价如何、有什么注意事项。
TicketStub(电子票根):一张已购买的电子票,包含 id、artist、seat(座位,如"看台 B区 12排 08座")、zone(区域,如"看台B")、date(日期时间)、code(票号,如"TOUR-8821-SH")、used(是否已核销)、color(票根颜色,用于电子票二维码区域的背景色)。TicketStub 是电子票夹的核心实体,每一张票都像一张可以收藏的入场券。
CheckinPost(打卡动态):一条乐迷发布的观演打卡,包含 id、user(用户名)、avatar(头像 Emoji)、show(关联演出)、city、text(打卡文字)、likes(点赞数)、liked(当前用户是否点赞)、time(发布时间)、stars(现场评分,1-5 星)。CheckinPost 是打卡社区的核心实体,每一条动态都是乐迷对一场 Live 的记忆记录。
BuddyItem(搭子信息):一条拼车拼房搭子招募信息,包含 id、user、avatar、show(关联演出)、need(需求,如"拼房 · 场馆附近2km")、gender(性别偏好,如"女生优先")、verified(是否实名认证)、joined(已加入人数)。BuddyItem 是搭子匹配系统的核心实体,每一条都是乐迷寻找同好的邀请。
CityHeat(城市热度):一个城市的演出场次统计,包含 city、shows(场次)。CityHeat 用于场馆 Tab 顶部的城市热度柱状图,直观展示哪些城市是 Live 重镇。
WantItem(想看演出):一条"我想看"的演出记录,包含 id、artist、city、date(待官宣)、notify(是否开启开售提醒)。WantItem 用于个人中心的"我想看的演出"列表,帮助乐迷追踪尚未官宣或未开售的演出。
4.2 静态数据设计
TOURLOG 的所有业务数据都以顶层 const 数组的形式定义,共 10 组静态数据:
| 数据数组 | 数量 | 内容 |
|---|---|---|
| TOUR_DATES | 16 | 巡演场次,从 09-06 陈粒到 12-20 梅卡德尔,覆盖 16 个独立乐队 |
| VENUE_LIST | 12 | 全国热门 Live 场馆,从上海梅奔到大连体育中心 |
| TICKET_STUBS | 10 | 电子票根,6 张待观演 + 4 张已核销 |
| CHECKIN_POSTS | 12 | 乐迷打卡动态,覆盖陈粒、万青、五条人、新裤子等演出 |
| BUDDY_LIST | 10 | 搭子招募信息,拼房/拼车/排队/看演出 |
| CITY_HEAT | 8 | 城市演出热度,上海 42 场居首 |
| WANT_LIST | 4 | 想看演出,万青石家庄、Joyside 北京等 |
| TICKET_ZONES | 4 | 票档选项,内场¥880/看台A¥580/看台B¥380/看台C¥280 |
这些静态数据的设计有几个值得注意的特点:
真实感:所有乐队、场馆、城市都是真实存在的——陈粒「悬日」巡演、万能青年旅店、五条人、草东没有派对、梅赛德斯奔驰文化中心、凯迪拉克中心。这种真实感让演示数据不像是"假数据",而像是一个真实运营中的 App 的数据快照。
情感化:打卡动态的文字内容充满了 Live 现场的情感——“安可连唱三首《易燃易爆炸》,全场大合唱的时候我哭了”、“《杀死那个石家庄人》前奏响起的瞬间,起鸡皮疙瘩”、“《这是我一生中最勇敢的瞬间》求婚环节真的有人求婚成功了”。这些文字不是冷冰冰的"测试数据",而是有温度、有故事、有共鸣的真实观演感受。
多样性:16 场演出覆盖了独立音乐的多个子流派——民谣(陈粒)、摇滚(万青、新裤子、刺猬)、方言摇滚(五条人)、后摇(惘闻)、后朋(重塑、海朋森)、都市流行(棱镜、夏日入侵)、迷幻摇滚(梅卡德尔)。12 个场馆覆盖了从 12000 座到 21000 座的不同规模。这种多样性让 App 看起来内容丰富、生态完整。
状态分布:16 场演出的状态分布合理——预售中 10 场、售罄 1 场(万青)、未开售 5 场。这种分布让乐迷既有"可以马上抢"的演出,也有"需要等待"的演出,还有"已经错过"的遗憾,模拟了真实的票务市场状态。
4.3 工具函数
TOURLOG 定义了 4 个工具函数,用于处理 UI 展示中的格式化和样式计算:
tourStatusColor(status):根据演出状态返回文字颜色——预售中返回香槟金 #F59E0B,售罄返回灰酒红 #9A8F94,未开售返回天空蓝 #7DD3FC。这个函数让演出状态标签的颜色与语义一致,乐迷扫一眼颜色就知道状态。
tourStatusBg(status):根据演出状态返回背景色——对应 tourStatusColor 的 12-16% 透明度版本,用于状态标签的背景。预售中是 rgba(245,158,11,0.14),售罄是 rgba(154,143,148,0.16),未开售是 rgba(125,211,252,0.14)。
heatBarHeight(shows):根据城市演出场次计算柱状图高度——shows * 3.2 vp。上海 42 场对应 134vp,武汉 16 场对应 51vp,形成高低错落的柱状图。系数 3.2 是经过调试的最佳值——既让最高的柱子不会超出容器,又让最低的柱子有足够的可见高度。
starText(n):根据评分数返回星级文字——5 星返回"★★★★★",4 星返回"★★★★☆"。用于打卡动态的现场评分展示,实心星和空心星的组合直观展示评分。
4.4 枚举定义
TOURLOG 定义了 1 个枚举 TourTab,包含 6 个值:SCHEDULE(日程)、VENUE(场馆)、TICKET(票夹)、CHECKIN(打卡)、BUDDY(搭子)、ME(我的)。这个枚举用于根组件的 activeTab 状态,通过数字索引标识当前选中的 Tab。使用枚举而非字符串或数字字面量,提高了代码的可读性和可维护性——this.activeTab === TourTab.SCHEDULE 比 this.activeTab === 0 更清晰易懂。
4.5 状态管理最佳实践
TOURLOG 的状态管理体现了几个 ArkTS 开发的最佳实践:
状态最小化:只在真正需要响应式更新的地方使用 @State。全局只有 activeTab 一个状态,每个 Tab 组件内部只管理自己需要的弹窗显隐和表单输入状态。静态数据全部使用 const,不使用 @State,避免不必要的响应式开销。
状态局部化:每个组件的状态只在组件内部使用,不跨组件传递。Tab 组件之间不共享状态——日程 Tab 的 selectedTour 不会传递给票夹 Tab,打卡 Tab 的 postText 不会传递给搭子 Tab。这种局部化的状态管理让组件之间解耦,修改一个组件不会影响其他组件。
弹窗状态布尔化:所有弹窗都用布尔型 @State 控制显隐,弹窗内容用 @Builder 封装。这种模式比"用一个字符串状态标识当前显示哪个弹窗"更清晰——每个弹窗有自己的状态变量,显隐逻辑独立,不会出现"切换弹窗时状态混乱"的问题。
表单状态即时绑定:TextInput 和 TextArea 的输入通过 onChange 回调即时更新 @State 变量(如 transferPhone、postText),不需要额外的"提交时读取"逻辑。这种即时绑定让表单数据始终与 UI 同步,提交时直接读取 @State 变量即可。
对象属性直接修改:对于需要修改对象属性的场景(如打卡动态的 liked 状态、想看列表的 notify 状态),直接修改对象属性(p.liked = !p.liked),配合 ForEach 的 keyGenerator 触发局部更新。这种方式比"替换整个数组"更高效,也更符合直觉。
第五章 六大 Tab 页面深度解析
TOURLOG 的底部导航栏包含 6 个 Tab:日程、场馆、票夹、打卡、搭子、我的。每个 Tab 都是一个独立的 @Component 组件,封装自己的状态、UI 和交互逻辑。本章将逐一解析每个 Tab 的设计思路、核心交互和技术实现。
5.1 日程 Tab:一条流淌的巡演时间轴

日程 Tab 是 TOURLOG 的首页,也是整个 App 的"入口页"。它的核心设计是一条时间轴式的演出列表——左侧是日期(月/日/星期),中间是一条竖线(预售中为香槟金实线,其他为半透明线),右侧是演出信息(乐队名+Emoji、城市+场馆、价格+想看人数、状态标签)。
这种时间轴布局的设计灵感来源于"巡演日记"的概念——每一场演出都是日记上的一个日期格,沿着时间线排列,如同人生路上的一个个站点。乐迷从上往下滑动,就像在翻阅一本日记,从 9 月的陈粒翻到 12 月的梅卡德尔,每一场都是一个值得标记的日子。
日程 Tab 的核心交互有三个:
演出详情弹窗:点击任意一场演出卡片,弹出演出详情弹窗(详见 5.7 节截图),包含演出信息、时间线流程(检票/开场/散场)、想去人数、场馆攻略入口、找搭子入口、立即抢票按钮。这个弹窗是从"发现演出"到"决定购票"的关键转化节点。
抢票确认弹窗:在演出详情中点击"立即抢票",弹出抢票确认弹窗(详见 5.8 节截图),包含票档选择(内场/看台A/B/C)、实名观演提示、余票进度条、倒计时、提交订单按钮。这个弹窗模拟了真实的抢票流程,让乐迷在演示中也能感受到抢票的紧张感。
状态色彩编码:每一场演出的状态标签使用不同颜色——预售中是香槟金,售罄是灰酒红,未开售是天空蓝。这种色彩编码让乐迷扫一眼就能知道哪些演出可以抢、哪些已经错过、哪些值得等待。售罄的万青(2.1万想去)和未开售的重塑(0.8万想去)在列表中形成鲜明对比,也暗示了独立音乐市场的供需关系。
日程 Tab 的顶部还有一个"江浙沪"地区筛选标签和"16 场演出正在路上"的标题,这些细节让页面更有"现场感"——不是泛泛的"全部演出",而是"你所在地区的演出"。
5.2 场馆 Tab:城市热度与场馆百科

场馆 Tab 是 TOURLOG 的"场馆百科",分为上下两个部分:顶部是城市热度柱状图,底部是热门场馆网格。
城市热度柱状图展示了 8 个城市的下半年演出场次——上海 42 场、北京 38 场、广州 30 场、深圳 28 场、成都 26 场、杭州 22 场、南京 18 场、武汉 16 场。柱状图使用玫瑰红(超过 30 场的城市)和香槟金(30 场以下)两种颜色,上海和北京以超过 35 场的"红色柱子"遥遥领先,直观展示了 Live 音乐市场的城市分布格局——北上广深是第一梯队,成杭宁汉是第二梯队。
这个柱状图的实现使用了 heatBarHeight 工具函数(shows * 3.2 vp),每个柱子的高度根据场次动态计算。柱子上方显示场次数字,下方显示城市名,形成完整的数据可视化。
热门场馆网格使用 Flex 双列布局,展示 12 个全国热门 Live 场馆——梅赛德斯奔驰文化中心(上海,18000座,4.8分)、凯迪拉克中心(北京,18000座,4.7分)、宝能观致文化中心(广州,13000座,4.6分)等。每个场馆卡片包含场馆图标、名称、城市、评分、容量,点击后弹出场馆详情弹窗,包含地铁交通、场馆标签、座位区示意图、近期演出入口。
场馆 Tab 的设计价值在于"观演前的攻略准备"——乐迷在购票前需要了解场馆有多大、怎么去、声场好不好、周边有什么。TOURLOG 把这些信息收拢在一个 Tab 里,让乐迷不需要在高德、大众点评、小红书之间来回切换。
5.3 票夹 Tab:会呼吸的电子票根

票夹 Tab 是 TOURLOG 的"电子钱包",管理用户所有的演出门票。顶部有两个 Tab 切换:待观演(6) 和 已核销(4),分别展示未使用和已使用的票根。
待观演列表展示了 6 张电子票根——陈粒「悬日」(看台B区12排08座,09-06)、五条人(内场F区03排21座,09-20)、草东没有派对(看台A区05排17座,10-11)、棱镜乐队(看台C区09排02座,10-17)、回春丹(内场D区06排11座,11-01)、惘闻(看台B区02排19座,11-22)。每一张票根都像一张真实的电子票——左侧是耳机图标(票根打孔),中间是演出名+日期时间+座位,右侧是"待观演"状态标签和箭头,底部是票号和"点击查看电子票 ›"。
点击票根后弹出电子票详情弹窗——顶部是票号,中间是一个"票根式"的电子票卡片(演出名、日期、座位、二维码),底部是操作按钮。待观演的票有"转赠好友"和"申请退票"两个按钮,已核销的票显示"该票已核销·感谢观看"。
转赠弹窗包含手机号输入框和"每张票仅可转赠一次"的提示,退票弹窗包含退票规则(7天以上退100%、3-7天退70%、3天内不可退)、票面价、手续费、预计到账的计算明细。这些细节让电子票夹不只是一个"展示门票"的地方,而是一个可以真正"管理门票"的工具——转赠给朋友、临时有事退票、查看二维码入场。
票夹 Tab 的设计哲学是"让每一张票根都有纪念价值"——已核销的票不会被删除,而是留在"已核销"标签里,变成一本"票根收藏册"。多年后翻回来,看到一张张已核销的票根,就能回忆起每一场 Live 的感动。这正是"票根会旧,记忆不会"的产品哲学在票夹 Tab 中的具体体现。
5.4 打卡 Tab:乐迷的现场日记

打卡 Tab 是 TOURLOG 的"乐迷社区",展示乐迷发布的观演打卡动态。每一条动态包含:用户头像(Emoji)、用户名、发布时间、关联演出、城市、打卡文字、现场评分(星级)、点赞数、评论数、分享按钮。
动态流的内容充满了 Live 现场的真实情感——“安可连唱三首《易燃易爆炸》,全场大合唱的时候我哭了”(悬日收藏家,陈粒上海)、“《杀死那个石家庄人》前奏响起的瞬间,起鸡皮疙瘩”(冀A青年,万青北京,5210赞)、“仁科又说错了城市名,全场喊「广州」他喊「深圳」哈哈哈”(海边阿茂,五条人广州)、“《这是我一生中最勇敢的瞬间》求婚环节真的有人求婚成功了!”(光斑记录者,棱镜南京,8930赞)。
这些文字不是冷冰冰的"测试数据",而是有温度、有故事、有共鸣的真实观演感受。每一条动态都像一篇简短的现场日记,记录着那一刻的感动、震撼、欢乐甚至遗憾。乐迷刷动态流,就像在翻阅一本"集体现场日记"——在别人的文字里重温自己看过的演出,在别人的感动里找到共鸣。
打卡 Tab 的核心交互有四个:
点赞:点击心形图标切换点赞状态,点赞后心形变红(玫瑰红)并有缩放动画,同时弹出"❤️ 已点赞"的 Toast 提示。点赞数实时更新(p.likes += 1)。
发布打卡:点击右上角"✍️ 发打卡"按钮,弹出发布打卡弹窗,包含图片/视频/票根三种媒体选择、关联演出、现场评分(5星可点击)、文字输入框、发布按钮。这个弹窗让乐迷可以快速记录观演感受——选几张照片、打个分、写几句话、发布。
打卡详情:点击动态卡片,弹出打卡详情弹窗,包含用户信息、演出关联、评分、文字、图片占位、评论列表、评论输入框。详情弹窗让乐迷可以深入阅读一条打卡的完整内容,查看评论,参与讨论。
评论互动:打卡详情弹窗底部有评论输入框和发送按钮,让乐迷可以在动态下评论互动。预置的三条评论(“鼓手solo太炸了”、“羡慕哭了,求返场”、“我也在场!B区举灯牌那个是我”)展示了社区互动的氛围。
打卡 Tab 的设计价值在于"让观演记忆可被记录和分享"——Live 现场的感动是短暂的,散场后很快就会被日常琐事淹没。打卡 Tab 让乐迷可以在感动还没消退的时候,用文字和照片把它记录下来,变成永恒的记忆。而社区的点赞和评论,则让这份记忆得到同好的回应和共鸣。
5.5 搭子 Tab:找到一起去现场的人

搭子 Tab 是 TOURLOG 的"社交匹配"模块,帮助乐迷找到一起观演的搭子。列表展示 10 条搭子招募信息,每条包含:用户头像、用户名、实名认证标签、关联演出、需求(拼房/拼车/排队/看演出)、已加入人数、性别偏好、加入按钮。
搭子的需求类型丰富多样——“拼房·场馆附近2km”(摇滚青椒,陈粒上海,女生优先)、“拼车·五棵松回燕郊”(石门夜车,万青北京)、“搭子·一起排队买周边”(阿珍爱上了阿强,五条人广州)、“拼房·双床房一晚”(蓉城小喇叭,新裤子成都,女生优先,4人已加入)、“搭子·散场一起吃夜宵”(辣椒炒肉,回春丹长沙,5人已加入)。
这些需求覆盖了观演的全链路——出发前拼房、到场后拼车、入场前排队买周边、散场后吃夜宵。每一种需求都对应着观演中的一个"孤独时刻"——一个人住酒店太贵、一个人打车不安全、一个人排队太无聊、一个人吃夜宵没意思。搭子 Tab 就是为了解决这些"孤独时刻"而存在的——让一个人的观演变成一群人的狂欢。
搭子 Tab 的核心交互有四个:
加入搭子:点击"加入"按钮,弹出加入确认弹窗,包含搭子信息、加入说明、安全提示、发送申请按钮。申请发送后,搭子发起人可以在会话里同意或拒绝。
发起搭子:点击右上角"+ 发起"按钮,弹出发起搭子弹窗,包含需求类型选择(拼房/拼车/排队/看演出)、关联演出选择、需求描述输入框、发布按钮。乐迷可以根据自己的需求发起新的搭子招募。
搭子详情:点击搭子卡片,弹出搭子详情弹窗,包含用户信息(观演场次、信用分)、演出关联、需求描述、已加入人数、性别偏好、聊一聊和申请加入按钮。
安全须知:底部有"🛡 查看搭子安全须知"按钮,点击弹出安全须知弹窗,包含四条安全提示(优先实名认证、平台担保交易、公共场所见面、一键举报)。安全是搭子匹配的核心顾虑,TOURLOG 通过实名认证标签、信用分展示、安全须知弹窗等设计,让乐迷在找搭子时更有安全感。
搭子 Tab 的设计价值在于"让 Live 音乐从一个人的享受变成一群人的狂欢"——Live 的魅力不仅在于舞台上的乐队,更在于台下那群和你一起合唱、一起蹦跳、一起流泪的陌生人。搭子 Tab 让这些"陌生人"在观演前就可以连接起来,从拼房拼车到排队夜宵,从陌生到熟悉,最后在现场一起合唱——这正是 Live 音乐最动人的部分。
5.6 我的 Tab:一本会成长的观演日记

我的 Tab 是 TOURLOG 的"个人中心",也是整本"巡演日记"的"扉页总结"。页面顶部是用户信息区——头像(耳机Emoji)、用户名(前排常驻)、观演数据(Live 观演 23 场·足迹 9 城)、等级徽章(Lv.8 现场老炮)。
用户信息下方是三个数据卡片:待观演 6(香槟金)、已看过 23(玫瑰红)、想看 4(天空蓝)。这三个数字是用户观演旅程的"仪表盘"——待观演是即将到来的期待,已看过是过去积累的回忆,想看是未来追逐的目标。点击"想看"卡片,弹出想看列表弹窗,包含 4 场待官宣的演出(万青石家庄、Joyside 北京、后海大鲨鱼上海、木马乐队长沙),每场可以单独开关开售提醒。
再往下是两个功能列表:
观演记录列表:我想看的演出(4场)、电子票夹(10张)、我的搭子(3组)、我的打卡(获赞2,340)。这四个入口分别对应用户观演旅程的四个维度——期待(想看)、凭证(票夹)、社交(搭子)、记忆(打卡)。
设置列表:开售提醒(已开启)、关于巡演日记(v1.8.2)、退出登录。关于弹窗包含版本号、产品标语(“每一场现场,都值得被记进日记。票根会旧,记忆不会。”)、用户协议、隐私政策。
我的 Tab 的设计哲学是"让用户看到自己的成长"——观演 23 场、足迹 9 城、Lv.8 现场老炮、获赞 2,340,这些数字不是冰冷的统计,而是用户 Live 人生的"勋章"。每多一场观演,"已看过"的数字就加 1;每多一个城市,"足迹"就加 1;每多一个点赞,"获赞"就加 1。这些数字的增长,就是用户在 Live 世界里的成长轨迹。
等级体系(Lv.8 现场老炮)是这种"成长感"的集中体现——从 Lv.1 新手到 Lv.8 现场老炮,每一个等级都对应着观演场次的积累。等级徽章不仅是身份的象征,更是对用户"热爱 Live"的肯定和鼓励。
第六章 弹窗交互体系深度解析
TOURLOG 中有约 15 套弹窗组件,覆盖演出详情、抢票确认、场馆详情、电子票、转赠、退票、发布打卡、打卡详情、搭子详情、发起搭子、加入搭子、安全须知、想看列表、关于、退出登录。这些弹窗全部采用统一的"Stack 叠加 + if 条件渲染 + 半透明遮罩"实现模式,形成了完整的弹窗交互体系。本章重点解析两个最核心的弹窗:演出详情弹窗和抢票确认弹窗。
6.1 演出详情弹窗:一张会说话的节目单

演出详情弹窗是从"发现演出"到"决定购票"的关键转化节点,也是 TOURLOG 中信息密度最高的弹窗之一。弹窗的内容结构如同一张剧场节目单:
顶部演出信息区:Emoji 图标(🌞)、演出名称(陈粒「悬日」)、日期时间(09-06 周六 19:30)、城市场馆(上海·梅赛德斯奔驰文化中心)、起售价(¥380 起)。这些信息是乐迷决定"要不要看"的核心依据——谁的演出、什么时候、在哪里、多少钱。
时间线流程区:以时间轴的形式展示演出当天的流程——19:30 检票入场(记得带身份证原件)、20:00 开场(暖场嘉宾 30 分钟)、22:30 预计散场(地铁末班车 23:15)。这个时间线是 TOURLOG 的贴心设计——很多乐迷(尤其是第一次去大型场馆的)不知道演出当天的时间安排,不知道几点到、几点散场、末班车赶不赶得上。时间线流程区把这些信息一目了然地展示出来,让乐迷可以提前规划行程。
快捷入口区:三个快捷入口按钮——❤ 想去 1.2万、📍 场馆攻略、🤝 找搭子。这三个入口分别对应"表达期待"、“了解场地”、"找同好"三个需求,让乐迷在详情页就可以完成这些操作,不需要跳转到其他 Tab。
底部主操作按钮:“🎫 立即抢票"按钮(香槟金,大圆角,缩放动效)。这是整个弹窗的"转化按钮”——点击后弹出抢票确认弹窗,进入购票流程。按钮的文字根据演出状态动态变化——预售中显示"立即抢票",售罄显示"缺货登记",未开售显示"开售提醒我"。
演出详情弹窗的设计价值在于"让乐迷在一个页面里完成从了解到决策的全部流程"——看演出信息、了解时间流程、查场馆攻略、找搭子、表达想去、最后抢票。不需要跳转到其他页面,所有操作都在弹窗内完成,减少了决策过程中的摩擦和流失。
6.2 抢票确认弹窗:模拟真实的抢票紧张感

抢票确认弹窗是 TOURLOG 中最"紧张刺激"的弹窗,它模拟了真实抢票场景中的票档选择、实名提示、余票进度、倒计时等元素:
票档选择区:四个票档选项——内场 ¥880(选中,香槟金圆点)、看台A ¥580、看台B ¥380、看台C ¥280。每个选项左侧是票档名称和价格,右侧是单选圆点。点击任意选项切换选中状态,选中的票档文字变香槟金,圆点变实心。票档从高到低排列,内场在前(价格最高、离舞台最近),看台C在后(价格最低、离舞台最远)。
实名观演提示:“实名观演 每证件限购 4 张,入场需刷身份证”——这是大型演唱会的标准规则,TOURLOG 把它放在票档选择下方,提醒乐迷购票前准备好身份证。
余票进度条:一个香槟金的进度条,显示"余票 32%“,旁边是"15:14:22 后关闭"的倒计时。这个设计是抢票确认弹窗的"灵魂”——余票百分比制造了"票不多了"的紧迫感,倒计时制造了"时间不多了"的紧迫感。两种紧迫感叠加,模拟了真实抢票时"手慢无"的紧张感,促使乐迷尽快完成购票。
底部操作按钮:“再想想”(取消,深色背景)和"提交订单"(确认,香槟金背景)。两个按钮并排,"提交订单"是主操作,使用香槟金背景和深色文字,在深酒红底色上形成强烈的视觉焦点。
抢票确认弹窗的设计价值在于"让演示也能体验到真实抢票的紧张感"——虽然这是一个演示项目,没有真实的支付和票务系统,但通过票档选择、实名提示、余票进度、倒计时等元素的设计,乐迷在点击"提交订单"的那一刻,也能感受到一丝真实抢票时的心跳加速。这种"沉浸式模拟"是 TOURLOG 弹窗设计的精髓——不只是展示信息,更是创造体验。
6.3 其他弹窗概览
除了演出详情和抢票确认,TOURLOG 还有约 13 套弹窗,每一套都针对特定的业务场景:
| 弹窗 | 触发场景 | 核心内容 |
|---|---|---|
| 场馆详情 | 点击场馆卡片 | 场馆信息、地铁交通、标签、座位区示意图、近期演出 |
| 电子票详情 | 点击票根 | 票号、票根式电子票(含二维码)、转赠/退票按钮 |
| 转赠确认 | 点击转赠好友 | 手机号输入、转赠规则提示、确认转赠 |
| 退票确认 | 点击申请退票 | 退票规则、票面价/手续费/到账计算、确认退票 |
| 发布打卡 | 点击发打卡 | 媒体选择、关联演出、5星评分、文字输入、发布 |
| 打卡详情 | 点击动态卡片 | 用户信息、演出关联、评分文字、图片、评论列表、评论输入 |
| 搭子详情 | 点击搭子卡片 | 用户信息、演出关联、需求描述、已加入人数、聊一聊/申请加入 |
| 发起搭子 | 点击+发起 | 需求类型选择、关联演出、需求描述、发布 |
| 加入确认 | 点击加入 | 搭子信息、加入说明、安全提示、发送申请 |
| 安全须知 | 点击安全须知 | 四条安全提示(实名/担保/公共场所/举报) |
| 想看列表 | 点击想看 | 4场待官宣演出、开售提醒开关 |
| 关于 | 点击关于 | 版本号、产品标语、用户协议、隐私政策 |
| 退出登录 | 点击退出 | 确认退出、取消/退出按钮 |
这 15 套弹窗形成了 TOURLOG 完整的交互闭环——从发现演出(详情)到购票(抢票确认),从管理门票(电子票/转赠/退票)到记录现场(发布打卡/打卡详情),从找搭子(搭子详情/发起/加入/安全)到个人设置(想看/关于/退出)。每一个业务场景都有对应的弹窗交互,让乐迷在 App 内就可以完成从"发现"到"回忆"的完整观演旅程。
第七章 动画与过渡效果
TOURLOG 的动画设计遵循"克制而有温度"的原则——不追求炫技,而是在关键交互节点用微妙的动画增强反馈感和沉浸感。本章解析 TOURLOG 中几处典型的动画设计。
7.1 Tab 切换缩放动画
底部导航栏的 Tab 图标在选中时会有一个微妙的缩放动画——scale(this.activeTab === tab ? { x: 1.16, y: 1.16 } : { x: 1, y: 1 }),配合 animation({ duration: 220, curve: Curve.EaseOut })。选中的图标放大到 1.16 倍,未选中的保持 1 倍,切换时有 220ms 的缓出动画。
这个动画的设计意图是"让 Tab 切换有触感"——点击 Tab 的瞬间,图标微微放大,像是被"按下"了一样,给乐迷一个明确的视觉反馈。1.16 倍的缩放比例经过精心调试——太小了不明显,太大了显得浮夸,1.16 倍刚好是"能感觉到但不突兀"的程度。220ms 的时长也是经过调试的——太快了像闪一下,太慢了显得拖沓,220ms 刚好是"流畅而不拖沓"的程度。
7.2 点赞心跳动画
打卡动态的点赞按钮在点赞时会有一个"心跳"动画——scale(p.liked ? { x: 1.15, y: 1.15 } : { x: 1, y: 1 }),配合 animation({ duration: 200, curve: Curve.EaseOut })。点赞后心形图标放大到 1.15 倍,颜色从灰酒红变为玫瑰红,同时弹出"❤️ 已点赞"的 Toast 提示(Toast 本身也有一个 1.3 倍的缩放动画)。
这个动画的设计灵感来源于"心跳"——点赞是一种情感表达,心跳动画让这个表达有了"生命感"。1.15 倍的缩放像是心脏跳动了一下,200ms 的时长刚好是一次心跳的时间。配合颜色变化(灰→红)和 Toast 提示,点赞这个简单的操作变成了一个有温度、有反馈的情感交互。
7.3 抢票按钮呼吸动效
演出详情弹窗的"立即抢票"按钮有一个微妙的"呼吸"动效——scale({ x: 1.04, y: 1.04 }),配合 animation({ duration: 300, curve: Curve.EaseOut })。按钮持续保持 1.04 倍的微放大状态,像是在"呼吸"一样,吸引乐迷的注意力。
这个动画的设计意图是"让主操作按钮有吸引力"——抢票按钮是演出详情弹窗的核心转化按钮,需要在视觉上突出。1.04 倍的微放大不会像闪烁那样刺眼,但会在潜意识里吸引乐迷的目光,让他们更倾向于点击。300ms 的缓出动画让放大过程平滑自然,不会有"跳动"的不适感。
7.4 弹窗淡入淡出
所有弹窗的显示/隐藏都通过 if 条件渲染实现,配合 Stack 叠加和半透明遮罩。虽然没有显式的过渡动画(ArkTS 的 if 条件渲染默认是即时切换),但半透明遮罩(rgba(10,4,7,0.8))的渐隐效果和弹窗内容的居中布局,让弹窗的出现/消失有了自然的"淡入淡出"感。
这种"无动画胜有动画"的设计体现了 TOURLOG 动画设计的克制原则——不是所有交互都需要动画,关键节点的动画(Tab 切换、点赞、抢票按钮)已经足够提供反馈感,弹窗的显隐用最简单的条件渲染反而更干净利落,不会因为动画过多而显得花哨。
7.5 星级评分点击动画
发布打卡弹窗的星级评分使用了点击动画——scale(s === this.postStars ? { x: 1.2, y: 1.2 } : { x: 1, y: 1 }),配合 animation({ duration: 150, curve: Curve.EaseOut })。点击某颗星时,该星放大到 1.2 倍,150ms 后恢复。这个动画让星级评分这个操作有了"按键反馈"——点击的瞬间星星微微放大,像是被"按下"了一样,给乐迷明确的操作反馈。
7.6 动画设计总结
TOURLOG 的动画设计可以总结为三个原则:
克制原则:只在关键交互节点使用动画(Tab 切换、点赞、抢票按钮、星级评分),不追求"处处有动画"。过多的动画会让 App 显得花哨和拖沓,克制的动画反而更有质感。
微交互原则:所有动画都是"微动画"——缩放比例在 1.04-1.3 倍之间,时长在 150-300ms 之间。微动画的好处是"有反馈但不干扰"——乐迷能感觉到操作被响应,但不会因为动画太大而分散注意力。
情感原则:动画不仅是视觉反馈,更是情感表达——点赞的心跳动画表达了"喜欢"的情感,抢票按钮的呼吸动效表达了"期待"的情感,星级评分的点击动画表达了"评价"的情感。每一个动画都有对应的情感含义,让冷冰冰的 UI 有了温度。
第八章 巡演日程系统
巡演日程系统是 TOURLOG 的核心功能之一,也是乐迷"发现演出"的主要入口。本章从数据模型、列表布局、详情弹窗、抢票流程四个维度解析巡演日程系统的设计。
8.1 数据模型:一场演出的完整画像
TourDate 接口定义了一场演出的完整画像,包含 10 个字段:id(唯一标识)、artist(演出名称,含乐队名和巡演名,如"陈粒「悬日」")、city(城市)、venue(场馆)、date(日期,MM-DD 格式)、weekday(星期)、status(状态:预售中/售罄/未开售)、priceFrom(起售价)、want(想看人数)、emoji(乐队图标 Emoji)。
这个数据模型的设计有几个值得注意的特点:
artist 字段的复合设计:artist 不是简单的"乐队名",而是"乐队名+巡演名"的复合字段(如"陈粒「悬日」"、“万能青年旅店”)。这种设计让演出名称更有辨识度——同一乐队的不同巡演可以通过巡演名区分,乐迷看到"陈粒「悬日」"就知道是哪一轮巡演。
emoji 字段的个性标识:每支乐队都有一个独特的 Emoji 图标——陈粒是🌞(悬日)、万青是🎸、五条人是🛶、草东是⛰、新裤子是👖、重塑是🗿、棱镜是🔻、刺猬是🦔、回春丹是💊、夏日入侵是☀、惘闻是🌊、海朋森是⚓。这些 Emoji 如同乐队的"视觉签名",乐迷扫一眼图标就能认出是哪支乐队,也让演出列表更有个性和趣味。
status 字段的三态设计:演出状态只有三种——预售中、售罄、未开售。这种三态设计覆盖了演出从"未官宣"到"售罄"的完整生命周期,配合三种颜色编码(香槟金/灰酒红/天空蓝),让乐迷一目了然。
want 字段的社交证明:想看人数是一种"社交证明"——想看的人越多,说明这场演出越热门,越值得关注。万青 2.1 万想看、草东 1.9 万想看、陈粒 1.2 万想看,这些数字本身就是一种"热度排行",引导乐迷关注热门演出。
8.2 列表布局:时间轴的叙事力量
日程 Tab 的列表采用时间轴布局,每一行包含三个部分:左侧日期区、中间竖线、右侧演出信息区。
左侧日期区:显示月份(09)、日期(06)、星期(周六),垂直排列。日期使用大号粗体(19vp),月份和星期使用小号(11vp/9vp)。这种排版让日期成为时间轴上的"锚点",乐迷的目光从日期移动到竖线再到演出信息,形成自然的阅读流。
中间竖线:一条 2vp 宽的竖线,连接上下两场演出。预售中的演出竖线是香槟金实线(#F59E0B),其他状态是半透明线(rgba(245,158,11,0.25))。这条竖线是时间轴的"脊梁"——它把分散的演出卡片串联成一条连续的时间线,让"巡演"这个概念有了视觉化的表达。
右侧演出信息区:包含乐队名+Emoji+状态标签、城市+场馆、价格+想看人数。乐队名使用大号粗体(14vp),状态标签使用小号带背景色的胶囊标签,城市场馆使用小号灰酒红文字,价格使用香槟金粗体,想看人数使用玫瑰红。这种信息层级让乐迷可以快速扫描——先看乐队名(是谁),再看价格和想看(值不值得),最后看城市场馆(在哪里)。
时间轴布局的叙事力量在于"让演出列表变成一个故事"——从上往下滑动,就像在时间的河流上漂流,从 9 月的陈粒到 12 月的梅卡德尔,每一场都是河流上的一个港口。乐迷不是在"浏览商品列表",而是在"翻阅一本巡演日记"——这正是 TOURLOG 产品哲学的体现。
8.3 详情弹窗:从节目单到抢票的转化
演出详情弹窗是从"发现"到"购票"的转化节点,设计如同一张剧场节目单(详见 6.1 节)。弹窗的信息架构遵循"从宏观到微观,从信息到行动"的逻辑:
- 宏观信息(演出名、时间、地点、价格)——让乐迷快速了解"这是什么演出"
- 流程信息(时间线:检票/开场/散场)——让乐迷了解"演出当天怎么安排"
- 快捷入口(想去/场馆攻略/找搭子)——让乐迷可以"表达期待、了解场地、找同好"
- 行动按钮(立即抢票)——引导乐迷"立即购票"
这种信息架构的设计让乐迷在一个弹窗内完成从"了解"到"决策"的全部流程,减少了页面跳转和决策摩擦。时间线流程区是 TOURLOG 的差异化设计——大多数票务 App 的演出详情只包含基本信息,TOURLOG 额外提供了演出当天的时间流程,这对第一次去大型场馆的乐迷特别有价值。
8.4 抢票流程:模拟真实的购票体验
抢票确认弹窗模拟了真实的购票流程(详见 6.2 节),包含票档选择、实名提示、余票进度、倒计时、提交订单五个环节。这个流程的设计有几个关键点:
票档从高到低排列:内场 ¥880 在前,看台C ¥280 在后。这种排列利用了"锚定效应"——乐迷先看到高价的内场,再看到低价的看台,会觉得看台"很划算",从而更倾向于购买。
余票和倒计时制造紧迫感:余票 32% 和倒计时 15:14:22 制造了"票不多了、时间不多了"的紧迫感,这是电商和票务领域经典的"稀缺性营销"手法。虽然是演示项目,但这种设计让乐迷在点击"提交订单"时也能感受到一丝真实抢票的紧张感。
实名提示降低预期:"每证件限购 4 张,入场需刷身份证"的提示让乐迷在购票前就了解规则,避免购票后才发现不能转让或需要身份证的问题。
第九章 电子票夹系统
电子票夹系统是 TOURLOG 的"门票管理中心",也是乐迷观演旅程中"凭证"的载体。本章从票根设计、电子票展示、转赠退票三个维度解析电子票夹系统。
9.1 票根设计:每一张票都是收藏品
TOURLOG 的票根卡片设计灵感来源于真实的纸质票根——左侧是"打孔区"(耳机图标,如同票根上的打孔),中间是演出信息和座位,右侧是状态标签和箭头,底部是票号和"点击查看电子票"。
票根卡片的设计有几个值得注意的细节:
票号格式:每张票都有一个唯一的票号,格式为"TOUR-XXXX-城市缩写"(如 TOUR-8821-SH、TOUR-5106-GZ、TOUR-3392-HZ)。这种格式让票号有了"序列号+城市"的双重标识,既唯一又有地域特色。
状态标签:待观演的票显示"待观演"标签(天空蓝),已核销的票显示"已核销"标签(灰酒红)。状态标签让乐迷一眼就能区分"还没看的"和"已经看过的"票。
座位信息:每张票都显示详细的座位信息(如"看台 B区 12排 08座"、“内场 F区 03排 21座”),包含区域、排号、座号。这种详细的座位信息让乐迷在入场前就知道自己坐在哪里,不需要在现场翻找。
已核销票的纪念价值:已核销的票不会被删除,而是留在"已核销"标签里,变成一本"票根收藏册"。已核销票的座位文字变灰(#6B5B60),状态标签显示"已核销",底部显示"留念存根"。这些细节让已核销的票有了"纪念品"的质感——它不再是一张可以入场的凭证,而是一场 Live 的纪念物。
9.2 电子票展示:二维码与票根式设计
点击票根后弹出电子票详情弹窗,核心是一个"票根式"的电子票卡片:
票根式布局:电子票卡片使用票根的颜色(每张票有自己的 color 字段,如陈粒是 #E11D48 玫瑰红、五条人是 #F59E0B 香槟金、草东是 #7C3AED 紫色、棱镜是 #0EA5E9 天空蓝)作为背景色,顶部是演出名和日期时间,中间是分割线,下方是座位信息,底部是二维码。这种布局模拟了真实电子票的样式,让乐弥有"拿着真票"的感觉。
二维码占位:二维码区域使用文字模拟(“▓▓▒▒▓▓▓▒▓▒▒▓\n…”),在演示项目中不需要真实的二维码生成。二维码区域下方有"入场时向工作人员出示"的提示文字。
票号显示:弹窗顶部显示完整票号(如"电子票 · TOUR-8821-SH"),让乐迷可以核对票号。
操作按钮:待观演的票有"转赠好友"和"申请退票"两个按钮,已核销的票显示"该票已核销·感谢观看"。操作按钮根据票的状态动态变化,符合业务逻辑。
9.3 转赠与退票:门票的生命周期管理
电子票夹不仅是"展示门票"的地方,更是"管理门票"的工具。转赠和退票是门票生命周期中的两个关键操作:
转赠流程:点击"转赠好友"→ 弹出转赠弹窗 → 输入对方手机号 → 确认转赠。转赠弹窗包含手机号输入框(InputType.PhoneNumber)和"每张票仅可转赠一次,转赠后不可撤回"的红色警告提示。这种设计模拟了真实票务平台的转赠规则——一次转赠、不可撤回,防止票被多次流转。
退票流程:点击"申请退票"→ 弹出退票弹窗 → 查看退票规则和退款计算 → 确认退票。退票弹窗包含:
- 退票规则(开场前7天以上退100%、3-7天退70%、3天内不可退)
- 票面价(¥580)
- 手续费(30%,-¥174,玫瑰红)
- 预计到账(¥406,香槟金粗体)
这种"规则+计算明细"的设计让乐迷在退票前就清楚地知道能退多少钱,避免退票后才发现手续费太高的纠纷。手续费使用玫瑰红("扣钱"的颜色),预计到账使用香槟金("到账"的颜色),色彩语义清晰。
9.4 票夹系统的设计哲学
电子票夹系统的设计哲学可以概括为"让每一张票都有始有终":
- 购票时:票进入"待观演",乐弥可以查看电子票、转赠、退票
- 观演时:出示电子票二维码入场,票被核销
- 观演后:票进入"已核销",变成纪念品,永远留在票夹里
这个生命周期让每一张票都有了"故事"——从期待(待观演)到体验(观演)到回忆(已核销),票根不只是一张入场凭证,更是一场 Live 的完整记录。多年后翻回票夹,看到一张张已核销的票根,就能回忆起每一场 Live 的感动——这正是"票根会旧,记忆不会"的产品哲学在电子票夹中的具体体现。
第十章 乐迷打卡社区
乐迷打卡社区是 TOURLOG 的"内容生态",也是乐弥"记录现场"和"分享感动"的平台。本章从动态流、发布流程、互动机制三个维度解析打卡社区。
10.1 动态流:集体现场日记
打卡 Tab 的动态流是 TOURLOG 的"内容主界面",每一条动态都是一位乐弥对一场 Live 的记录。动态流的设计有几个特点:
真实感的内容:如前文所述,打卡动态的文字内容充满了 Live 现场的真实情感和细节——“安可连唱三首《易燃易爆炸》”、“《杀死那个石家庄人》前奏响起的瞬间”、“仁科又说错了城市名”、“《这是我一生中最勇敢的瞬间》求婚环节真的有人求婚成功了”。这些文字不是泛泛的"很好看"“很感动”,而是有具体歌曲、具体瞬间、具体故事的"现场记录"。这种真实感让动态流不像是"测试数据",而像是一个真实运营中的社区。
情感化的用户名:每个用户名都与 Live 文化相关——“悬日收藏家”(陈粒粉丝)、“冀A青年”(万青粉丝,冀A是石家庄车牌)、“海边阿茂”(五条人粉丝,阿茂是五条人成员)、“牛仔裤批发”(新裤子粉丝)、“雕塑爱好者”(重塑粉丝)、“山下望草”(草东粉丝)、“光斑记录者”(棱镜粉丝)。这些用户名本身就是一种"身份标识",乐弥看到用户名就知道对方是哪支乐队的粉丝,增加了社区的归属感和认同感。
星级评分:每条动态都有现场评分(★★★★★ 或 ★★★★☆),让乐弥可以量化表达对一场 Live 的满意度。5 星的动态占大多数(Live 现场的体验通常都很好),4 星的动态偶尔出现(如五条人"仁科说错城市名"的趣味现场)。
互动数据:每条动态显示点赞数、评论数(128)、分享按钮。万青的动态有 5210 赞,棱镜的求婚动态有 8930 赞,这些数据本身就是一种"热门内容"的标识,引导乐弥关注高赞动态。
10.2 发布流程:三步记录现场感动
发布打卡的流程设计为"三步完成",让乐弥在散场后的感动还没消退时就能快速记录:
第一步:选择媒体:发布弹窗顶部有三个媒体选择按钮——🎤(文字/演出)、📷(照片)、🎟(票根)。乐弥可以选择发布纯文字、带照片、或带票根的打卡。
第二步:关联演出和评分:媒体选择下方显示"关联演出:陈粒「悬日」· 上海 · 09-06"(自动关联最近观演的演出),然后是 5 星评分(可点击选择,选中的星有 1.2 倍缩放动画)。
第三步:写文字并发布:底部是文字输入框(TextArea,90vp 高)和"发布打卡"按钮。乐弥写下几句话,点击发布,一条打卡就完成了。
这个"三步发布"流程的设计意图是"降低记录门槛,让感动不被遗忘"——Live 散场后,乐弥的感动是强烈但短暂的,如果发布流程太复杂(需要选照片、写标题、选标签、写长文),很多乐弥会因为"太麻烦"而放弃记录。TOURLOG 的三步流程让乐弥可以在 30 秒内完成一条打卡,把感动及时记录下来。
10.3 互动机制:点赞、评论、分享
打卡社区的互动机制包含三个维度:
点赞:点击心形图标切换点赞状态,点赞后心形变红(玫瑰红)并有 1.15 倍缩放动画,同时弹出"❤️ 已点赞"的 Toast。点赞是最轻量的互动方式——不需要写文字,点一下就可以表达"我也有同感"。
评论:点击动态卡片进入详情弹窗,底部有评论输入框和发送按钮。详情弹窗中预置了三条评论(“鼓手solo太炸了”、“羡慕哭了,求返场”、“我也在场!B区举灯牌那个是我”),展示了社区互动的氛围。评论是比点赞更深层的互动——乐弥可以在评论区讨论演出细节、分享感受、甚至找到同场的乐弥。
分享:每条动态右侧有"↗ 分享"按钮,让乐弥可以把打卡分享到外部平台(微信、微博等)。分享不仅是传播,更是"炫耀"——把自己看过的 Live 分享给朋友,是一种"我在现场"的身份表达。
10.4 打卡社区的设计价值
打卡社区的设计价值在于"让 Live 记忆从个人体验变成集体共鸣":
- 个人层面:打卡让乐弥可以记录每一场 Live 的感动,变成个人的"现场日记"
- 社区层面:动态流让乐弥可以看到别人的现场记录,在别人的文字里重温自己看过的演出,找到共鸣
- 发现层面:高赞的打卡动态本身就是一种"演出推荐"——看到别人说"《杀死那个石家庄人》前奏响起的瞬间起鸡皮疙瘩",没看过万青的乐弥可能就会想去看一场
打卡社区是 TOURLOG 的"内容引擎"——乐弥生产内容(打卡),内容吸引更多乐弥,更多乐弥生产更多内容,形成正向循环。一个有活跃内容的社区,才是有生命力的 App。
第十一章 搭子匹配系统
搭子匹配系统是 TOURLOG 的"社交引擎",帮助乐弥找到一起观演的同好。本章从需求场景、匹配机制、安全设计三个维度解析搭子系统。
11.1 需求场景:观演全链路的孤独时刻
搭子系统的设计源于对"观演全链路孤独时刻"的洞察。一个人去看 Live,会在多个时刻感到"如果有人一起就好了":
- 出发前:一个人住酒店太贵(拼房需求)、一个人打车不安全/不划算(拼车需求)
- 到场后:一个人排队买周边太无聊(排队搭子)、一个人吃饭没意思(饭搭子)
- 入场时:一个人占座不方便(看演出搭子)
- 散场后:一个人吃夜宵太冷清(夜宵搭子)、一个人打车回酒店不安全(返程拼车)
TOURLOG 的搭子系统覆盖了这些全链路需求——BUDDY_LIST 中的 10 条搭子信息包含拼房(4条)、拼车(3条)、排队(1条)、看演出(1条)、夜宵(1条),需求类型丰富多样。
11.2 匹配机制:基于演出的精准匹配
搭子系统的匹配机制是"基于演出的精准匹配"——每一条搭子信息都关联一场具体的演出(如"陈粒·上海"、“万青·北京”、“五条人·广州”),乐弥在看某场演出的搭子时,看到的都是同一场演出的搭子招募。这种基于演出的匹配比基于兴趣或地理位置的匹配更精准——同一场演出的乐弥不仅有相同的音乐喜好,更有相同的时间和地点,匹配成功率更高。
搭子信息的关键字段:
- need(需求):具体描述需求,如"拼房·场馆附近2km"、“拼车·五棵松回燕郊”、“搭子·散场一起吃夜宵”
- gender(性别偏好):“女生优先”、“男生优先”、“不限”,满足不同乐弥的性别偏好
- verified(实名认证):是否完成实名认证,实名的搭子更可信
- joined(已加入人数):已经加入的人数,让乐弥知道这个搭子还缺不缺人
11.3 安全设计:让找搭子更安心
安全是搭子匹配的核心顾虑——和陌生人拼房拼车,安全是第一位的。TOURLOG 通过多层安全设计让乐弥在找搭子时更安心:
实名认证标签:完成实名认证的用户显示"实名"标签(天空蓝),在搭子列表和详情中都有展示。实名标签是一种"信任信号"——实名的用户更可靠,乐弥更倾向于加入实名搭子。
信用分展示:搭子详情弹窗显示用户的"观演 12 场·信用分 96",观演场次和信用分是用户在 TOURLOG 中的"历史记录"——观演场次多说明是资深乐弥,信用分高说明没有不良记录。
安全须知弹窗:底部有"🛡 查看搭子安全须知"入口,点击弹出四条安全提示:
- 优先选择已完成实名认证的搭子
- 费用走平台担保交易,勿私下转账
- 首次见面选择人多的公共场所
- 遇到骚扰可一键举报,平台 24h 处理
这四条安全提示覆盖了搭子匹配中最常见的安全风险——身份验证、费用安全、见面安全、骚扰处理,让乐弥在找搭子前就了解安全注意事项。
加入确认流程:点击"加入"按钮后不是直接加入,而是弹出加入确认弹窗,包含搭子信息、加入说明、安全提示、发送申请按钮。申请发送后,搭子发起人可以在会话里同意或拒绝——这种"申请-同意"的双向确认机制让双方都有选择权,避免被不喜欢的人强行加入。
11.4 搭子系统的设计价值
搭子系统的设计价值在于"让 Live 音乐从一个人的享受变成一群人的狂欢":
Live 的魅力不仅在于舞台上的乐队,更在于台下那群和你一起合唱、一起蹦跳、一起流泪的陌生人。搭子系统让这些"陌生人"在观演前就可以连接起来——从拼房拼车到排队夜宵,从陌生到熟悉,最后在现场一起合唱。
这种"观演前社交"的设计是 TOURLOG 区别于传统票务 App 的核心差异化——传统票务 App 只解决"买票"的问题,TOURLOG 解决"和谁一起去"的问题。对于很多乐弥(尤其是第一次去大型场馆的、跨城市追巡演的、身边没有同好的)来说,"和谁一起去"比"买不买得到票"更重要。搭子系统让这些乐弥不再孤单,让每一场 Live 都有同好相伴。
第十二章 个人中心与观演数据
个人中心是 TOURLOG 的"观演人生仪表盘",也是整本"巡演日记"的"扉页总结"。本章从用户画像、数据统计、功能入口、等级体系四个维度解析个人中心。
12.1 用户画像:前排常驻的 Live 人生
个人中心顶部的用户信息区展示了用户的"Live 画像":
- 头像:🎧 耳机 Emoji(香槟金圆形背景)
- 用户名:前排常驻(暗示用户总是买前排票的资深乐弥)
- 观演数据:Live 观演 23 场·足迹 9 城
- 等级徽章:Lv.8 现场老炮
这个用户画像的设计意图是"让用户看到自己的 Live 身份"——"前排常驻"这个用户名本身就是一种身份表达(总是买前排票的资深乐弥),"观演 23 场·足迹 9 城"是用户 Live 人生的量化总结,"Lv.8 现场老炮"是用户在 TOURLOG 社区中的等级身份。
这些信息组合在一起,形成了一个完整的"Live 人设"——一个热爱 Live 音乐、总是买前排票、看过 23 场演出、去过 9 个城市、等级 8 级的资深乐弥。这种人设不仅是展示给别人看的,更是展示给自己看的——每次打开个人中心,看到这些数字和徽章,用户都会感受到"我是一个热爱 Live 的人"的身份认同。
12.2 数据统计:观演旅程的仪表盘
用户信息下方是三个数据卡片:待观演 6(香槟金)、已看过 23(玫瑰红)、想看 4(天空蓝)。
这三个数字是用户观演旅程的"仪表盘":
- 待观演 6:即将到来的期待——6 场演出已经买好票,等待观演日的到来
- 已看过 23:过去积累的回忆——23 场 Live 已经看过,每一场都是一段记忆
- 想看 4:未来追逐的目标——4 场演出还没官宣或没开售,等待开票的通知
三个数字使用三种颜色——香槟金(期待)、玫瑰红(回忆)、天空蓝(目标),色彩语义清晰。点击"想看"卡片弹出想看列表弹窗,包含 4 场待官宣演出(万青石家庄、Joyside 北京、后海大鲨鱼上海、木马乐队长沙),每场可以单独开关开售提醒。
这个"三数据仪表盘"的设计让用户一眼就能看到自己观演旅程的全貌——过去看了多少、未来要看多少、还在等多少。数字的增长(已看过 +1、足迹 +1)就是用户在 Live 世界里的成长轨迹。
12.3 功能入口:观演记录的四个维度
数据卡片下方是两个功能列表,第一个是"观演记录列表",包含四个入口:
- 我想看的演出(4场):对应"想看"数据,点击弹出想看列表弹窗
- 电子票夹(10张):跳转到票夹 Tab,管理所有演出门票
- 我的搭子(3组):查看已加入的搭子群组
- 我的打卡(获赞2,340):查看自己发布的打卡动态和获赞数
这四个入口分别对应用户观演旅程的四个维度——期待(想看)、凭证(票夹)、社交(搭子)、记忆(打卡)。从期待到凭证到社交到记忆,覆盖了一场 Live 从"想看"到"回忆"的完整旅程。
第二个是"设置列表",包含三个入口:
- 开售提醒(已开启):管理演出开售提醒设置
- 关于巡演日记(v1.8.2):查看 App 版本信息、产品标语、用户协议、隐私政策
- 退出登录:退出当前账号
12.4 等级体系:Live 人生的成长勋章
等级体系(Lv.8 现场老炮)是个人中心最有"游戏感"的设计。虽然 TOURLOG 没有明确展示等级规则,但从"Lv.8 现场老炮"这个徽章可以推测出等级体系的设计思路:
等级与观演场次挂钩:等级 likely 基于观演场次累计——看的演出越多,等级越高。Lv.8 对应 23 场观演,平均每级约 3 场,这种"多看升级"的机制激励用户多看 Live。
等级名称有 Live 特色:"现场老炮"这个等级名称充满了 Live 文化特色——"老炮"是北京方言,指在某个领域经验丰富的人,“现场老炮"就是"看现场经验丰富的人”。这种有文化特色的等级名称比"VIP1/VIP2"或"黄金/钻石"更有归属感和趣味性。
等级徽章是身份象征:等级徽章(Lv.8 现场老炮)展示在用户信息区,是用户在 TOURLOG 社区中的"身份名片"。高等级用户在社区中更有话语权和影响力,也更有动力继续看 Live 升级。
等级体系的设计价值在于"让看 Live 这件事有了游戏化的成长感"——每多看一场 Live,等级就可能提升,徽章就可能变化,这种"成长反馈"让看 Live 从单纯的消费行为变成了一种"升级打怪"的游戏体验。对于热爱 Live 的乐弥来说,等级不仅是一个数字,更是对自己"热爱 Live"的肯定和鼓励。
12.5 个人中心的设计哲学
个人中心的设计哲学可以概括为"让用户看到自己的成长,感受到自己的热爱":
- 数据统计让用户看到自己观演旅程的全貌(待观演/已看过/想看)
- 功能入口让用户可以快速管理自己的观演记录(票夹/搭子/打卡)
- 等级体系让用户在看 Live 的过程中有"升级成长"的游戏感
- 用户画像让用户感受到"我是一个热爱 Live 的人"的身份认同
个人中心不是一个冷冰冰的"设置页面",而是一本"观演日记的扉页"——每次打开,用户都能看到自己的 Live 人生总结,感受到自己对 Live 音乐的热爱。这正是"巡演日记"这个产品名称的终极含义——TOURLOG 不只是一个工具 App,更是一本记录用户 Live 人生的日记。
十三、开发环境搭建
13.1 环境要求
DevEco Studio 5.0+(支持HarmonyOS 6.1.1)、HarmonyOS SDK API 24、Node.js 18+、至少8GB内存。
13.2 安装DevEco Studio

从华为开发者官网下载最新版本,运行安装包,选择安装路径,勾选创建桌面快捷方式,点击安装。
13.3 配置SDK

首次启动后进入File > Settings > HarmonyOS SDK,勾选API 24及以上版本,点击Apply等待下载完成。
13.4 创建项目

选择Create Project > Empty Ability,配置项目名称、包名、保存路径,选择Compile SDK为API 24,点击Finish。
13.5 运行应用

将1316.ets代码替换到entry/src/main/ets/pages/Index.ets,连接HarmonyOS设备或启动模拟器,点击运行按钮预览。
13.6 预览调试

DevEco Studio提供实时预览功能,编辑器右侧打开Previewer面板,支持多设备尺寸预览、深色模式切换、组件树inspect等调试能力。
总结与展望
项目总结
巡演日记 TOURLOG 是一款以"Live 音乐全链路体验"为核心的观演助手 App,基于 HarmonyOS ArkTS 声明式 UI 框架开发,单文件约 2900 行代码,无外部依赖。项目实现了 6 大 Tab 页面(日程/场馆/票夹/打卡/搭子/我的)和约 15 套弹窗组件,完整覆盖演出发现、场馆查询、门票管理、现场记录、搭子匹配、个人足迹等核心观演业务场景。
视觉设计上采用"票根酒红风"——深酒红底(#1A0E12)如同剧场幕布,玫瑰红(#E11D48)如同开场铃和票根印章,香槟金(#F59E0B)如同舞台追光和烫金字,奶白(#FDF2F4)如同柔和面光。四种色彩的组合营造出强烈的"剧场沉浸感"——每一次打开 App,都像走进一座剧场,灯光渐暗,幕布拉开,一场属于你的 Live 即将开始。色彩语义明确——玫瑰红代表主操作/退票/点赞,香槟金代表次级操作/抢票/数据高亮,天空蓝代表信息/安全/待观演状态。
技术实现上严格遵循 ArkTS 规范:无 Blank 组件、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量。采用单文件组件化架构,通过 @Component 划分为 8 个独立组件,@State 管理局部状态,@Builder 封装可复用 UI 和弹窗,Stack 叠加实现半透明遮罩弹窗,属性动画实现交互动效。
技术亮点
- 票根酒红风视觉体系:深酒红底+玫瑰红+香槟金+奶白的四色体系,大圆角卡片+Emoji 乐队图标+时间轴布局,整体视觉如同剧场节目单和复古票根,营造出强烈的 Live 现场沉浸感
- 时间轴式巡演日程:16 场演出沿时间轴排列,左侧日期+中间竖线+右侧演出信息,如同日记本上的日期格,让"巡演"这个概念有了视觉化的叙事力量
- 12 城热门场馆百科:从上海梅奔到大连体育中心,12 个场馆覆盖 12000-21000 座不同规模,每个场馆含地铁交通、评分、标签、座位区示意图,是乐弥观演前的"攻略手册"
- 会呼吸的电子票夹:10 张电子票根(6 待观演+4 已核销),票根式设计+唯一票号+二维码,支持转赠和退票,已核销票变成"留念存根"永远留存——票根会旧,记忆不会
- 有温度的乐弥打卡社区:12 条充满真实情感的观演打卡,从"《杀死那个石家庄人》前奏响起起鸡皮疙瘩"到"求婚环节真的有人求婚成功了",每一条都是一篇简短的现场日记
- 全链路搭子匹配系统:10 条搭子招募覆盖拼房/拼车/排队/夜宵全链路,基于演出的精准匹配+实名认证+信用分+安全须知+申请确认机制,让找搭子更安心
- 城市热度数据可视化:8 城演出场次柱状图,上海 42 场北京 38 场遥遥领先,玫瑰红(超30场)和香槟金(30场以下)双色编码,直观展示 Live 市场的城市格局
- 抢票流程沉浸式模拟:票档选择+实名提示+余票32%进度条+倒计时,模拟真实抢票的紧张感,让演示也能体验到"手慢无"的心跳加速
- 约 15 套完整弹窗交互体系:从演出详情、抢票确认、电子票、转赠、退票、发布打卡、打卡详情、搭子详情、发起搭子、加入确认、安全须知到想看列表、关于、退出,每个业务场景都有对应弹窗
- 观演人生仪表盘:待观演6/已看过23/想看4三数据仪表盘+Lv.8现场老炮等级+观演23场足迹9城,让用户看到自己的 Live 成长轨迹,感受到"我是一个热爱 Live 的人"的身份认同
扩展方向
巡演日记 TOURLOG 作为演示项目,仍有多个可扩展方向:
- 真实票务接入:当前票务为静态演示,可接入大麦/猫眼等票务平台 API 实现真实的演出查询和购票,支持实时余票、选座、支付等完整票务流程
- 电子票二维码生成:当前电子票二维码为文字模拟,可接入二维码生成库实现真实的电子票二维码,支持扫码入场、票券验证等
- LBS 场馆导航:可接入地图 SDK 实现场馆导航功能,支持从当前位置到场馆的路线规划、地铁换乘、停车指引、周边餐饮推荐等
- AI 观演推荐:可集成 AI 算法根据用户的观演历史、想看列表、打卡内容,推荐可能喜欢的演出和乐队,实现"千人千面"的个性化推荐
- 现场社交增强:可扩展"同场乐弥"功能——在演出现场通过蓝牙/WiFi 直连发现同场乐弥,支持现场聊天、拼车、交换联系方式等
- 观演数据深度分析:可扩展个人观演数据分析——年度观演报告(看了多少场、花了多少钱、去了多少城市、最喜欢的乐队)、观演足迹地图、消费趋势分析等
- AR 现场体验:可结合 HarmonyOS 的 AR 能力,实现 AR 观演——在场馆内通过 AR 查看座位视角、舞台距离、周边设施,甚至 AR 应援效果
- 多端协同:可扩展为手机/手表/车机多端协同,手机用于购票和打卡,手表用于入场二维码和震动反馈,车机用于场馆导航和散场路线规划
巡演日记 TOURLOG 展示了 HarmonyOS ArkTS 在生活服务类 App 开发中的强大能力——声明式 UI 的简洁语法、@State 的高效状态管理、Stack 叠加弹窗的灵活实现、属性动画的流畅动效、Previewer 的实时预览,让开发者可以用较少的代码实现丰富的生活服务体验。希望本文能为 HarmonyOS 开发者和 Live 音乐产品从业者提供有价值的参考。
每一场现场,都值得被记进日记。票根会旧,记忆不会。
本文基于 HarmonyOS ArkTS 声明式 UI 框架实战项目撰写,所有代码和图片均来自巡演日记 TOURLOG 项目。
附录:完整源码(2131.ets)
以下为巡演日记 TOURLOG 应用的完整 ArkTS 源码,单文件无外部依赖,可直接复制到 DevEco Studio 的 Index.ets 中运行。
// 2131.ets 巡演日记 TOURLOG —— 演唱会巡演日程 / 电子票夹 / 乐迷打卡社区
// 票根酒红风:深酒红底 + 玫瑰红 + 香槟金 + 奶白
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量
// ============ 类型定义 ============
interface TourDate {
id: number;
artist: string;
city: string;
venue: string;
date: string;
weekday: string;
status: string;
priceFrom: number;
want: number;
emoji: string;
}
interface VenueInfo {
id: number;
name: string;
city: string;
capacity: string;
metro: string;
score: string;
tags: string;
}
interface TicketStub {
id: number;
artist: string;
seat: string;
zone: string;
date: string;
code: string;
used: boolean;
color: string;
}
interface CheckinPost {
id: number;
user: string;
avatar: string;
show: string;
city: string;
text: string;
likes: number;
liked: boolean;
time: string;
stars: number;
}
interface BuddyItem {
id: number;
user: string;
avatar: string;
show: string;
need: string;
gender: string;
verified: boolean;
joined: number;
}
interface CityHeat {
city: string;
shows: number;
}
interface WantItem {
id: number;
artist: string;
city: string;
date: string;
notify: boolean;
}
// ============ 静态数据 ============
const TOUR_DATES: TourDate[] = [
{ id: 1, artist: '陈粒「悬日」', city: '上海', venue: '梅赛德斯奔驰文化中心', date: '09-06', weekday: '周六', status: '预售中', priceFrom: 380, want: 12480, emoji: '🌞' },
{ id: 2, artist: '万能青年旅店', city: '北京', venue: '凯迪拉克中心', date: '09-13', weekday: '周六', status: '售罄', priceFrom: 480, want: 20931, emoji: '🎸' },
{ id: 3, artist: '五条人', city: '广州', venue: '宝能观致文化中心', date: '09-20', weekday: '周六', status: '预售中', priceFrom: 330, want: 15322, emoji: '🛶' },
{ id: 4, artist: '新裤子', city: '成都', venue: '五粮液成都演艺中心', date: '09-27', weekday: '周六', status: '预售中', priceFrom: 380, want: 9760, emoji: '👖' },
{ id: 5, artist: '重塑雕像的权利', city: '深圳', venue: '深圳湾体育中心', date: '10-04', weekday: '周日', status: '未开售', priceFrom: 480, want: 8421, emoji: '🗿' },
{ id: 6, artist: '草东没有派对', city: '杭州', venue: '杭州奥体中心体育馆', date: '10-11', weekday: '周六', status: '预售中', priceFrom: 480, want: 18760, emoji: '⛰' },
{ id: 7, artist: '棱镜乐队', city: '南京', venue: '南京青奥体育公园体育馆', date: '10-17', weekday: '周五', status: '预售中', priceFrom: 280, want: 7321, emoji: '🔻' },
{ id: 8, artist: '刺猬乐队', city: '武汉', venue: '武汉体育中心体育馆', date: '10-25', weekday: '周六', status: '未开售', priceFrom: 380, want: 6210, emoji: '🦔' },
{ id: 9, artist: '回春丹', city: '长沙', venue: '湖南国际会展中心', date: '11-01', weekday: '周六', status: '预售中', priceFrom: 280, want: 5890, emoji: '💊' },
{ id: 10, artist: '夏日入侵企画', city: '西安', venue: '西安奥体中心体育馆', date: '11-08', weekday: '周六', status: '预售中', priceFrom: 280, want: 5120, emoji: '☀' },
{ id: 11, artist: '声音玩具', city: '重庆', venue: '重庆国际博览中心', date: '11-15', weekday: '周六', status: '未开售', priceFrom: 380, want: 7640, emoji: '🧸' },
{ id: 12, artist: '惘闻', city: '大连', venue: '大连体育中心体育馆', date: '11-22', weekday: '周六', status: '预售中', priceFrom: 330, want: 4210, emoji: '🌊' },
{ id: 13, artist: '海朋森', city: '厦门', venue: '厦门嘉庚体育馆', date: '11-29', weekday: '周六', status: '预售中', priceFrom: 280, want: 3980, emoji: '⚓' },
{ id: 14, artist: '秘密行动', city: '昆明', venue: '滇池国际会展中心', date: '12-06', weekday: '周六', status: '未开售', priceFrom: 280, want: 3210, emoji: '🎭' },
{ id: 15, artist: '法兹乐队', city: '郑州', venue: '河南省体育馆', date: '12-13', weekday: '周六', status: '预售中', priceFrom: 280, want: 2870, emoji: '🚦' },
{ id: 16, artist: '梅卡德尔', city: '天津', venue: '天津人民体育馆', date: '12-20', weekday: '周六', status: '预售中', priceFrom: 330, want: 3410, emoji: '🎭' }
];
const VENUE_LIST: VenueInfo[] = [
{ id: 1, name: '梅赛德斯奔驰文化中心', city: '上海', capacity: '18000座', metro: '8号线 中华艺术宫站', score: '4.8', tags: '地铁直达/存包便利/声场佳' },
{ id: 2, name: '凯迪拉克中心', city: '北京', capacity: '18000座', metro: '1号线 五棵松站', score: '4.7', tags: '地铁直达/餐饮多/散场快' },
{ id: 3, name: '宝能观致文化中心', city: '广州', capacity: '13000座', metro: '6号线 潦口站', score: '4.6', tags: '地铁直达/冷气足/停车难' },
{ id: 4, name: '五粮液成都演艺中心', city: '成都', capacity: '12000座', metro: '1号线 世纪城站', score: '4.7', tags: '地铁直达/周边热闹/存包贵' },
{ id: 5, name: '深圳湾体育中心', city: '深圳', capacity: '13000座', metro: '2号线 后海站', score: '4.8', tags: '地铁直达/海景/散场有序' },
{ id: 6, name: '杭州奥体中心体育馆', city: '杭州', capacity: '19000座', metro: '6/7号线 奥体中心站', score: '4.6', tags: '地铁直达/场地新/周边偏' },
{ id: 7, name: '南京青奥体育公园体育馆', city: '南京', capacity: '21000座', metro: '10号线 临江站', score: '4.5', tags: '接驳车/场地大/步行远' },
{ id: 8, name: '武汉体育中心体育馆', city: '武汉', capacity: '13000座', metro: '3号线 体育中心站', score: '4.6', tags: '地铁直达/夜市近/存包便利' },
{ id: 9, name: '湖南国际会展中心', city: '长沙', capacity: '15000座', metro: '2号线 万家丽广场站', score: '4.5', tags: '打车难/应援多/场馆暖' },
{ id: 10, name: '西安奥体中心体育馆', city: '西安', capacity: '17000座', metro: '14号线 奥体中心站', score: '4.7', tags: '地铁直达/场地新/远市区' },
{ id: 11, name: '重庆国际博览中心', city: '重庆', capacity: '16000座', metro: '6号线 礼嘉站', score: '4.4', tags: '接驳车/坡多/火锅近' },
{ id: 12, name: '大连体育中心体育馆', city: '大连', capacity: '18000座', metro: '2号线 体育中心站', score: '4.6', tags: '地铁直达/海风大/散场快' }
];
const TICKET_STUBS: TicketStub[] = [
{ id: 1, artist: '陈粒「悬日」', seat: '看台 B区 12排 08座', zone: '看台B', date: '09-06 19:30', code: 'TOUR-8821-SH', used: false, color: '#E11D48' },
{ id: 2, artist: '五条人', seat: '内场 F区 03排 21座', zone: '内场F', date: '09-20 19:00', code: 'TOUR-5106-GZ', used: false, color: '#F59E0B' },
{ id: 3, artist: '草东没有派对', seat: '看台 A区 05排 17座', zone: '看台A', date: '10-11 19:30', code: 'TOUR-3392-HZ', used: false, color: '#7C3AED' },
{ id: 4, artist: '棱镜乐队', seat: '看台 C区 09排 02座', zone: '看台C', date: '10-17 20:00', code: 'TOUR-7741-NJ', used: false, color: '#0EA5E9' },
{ id: 5, artist: '回春丹', seat: '内场 D区 06排 11座', zone: '内场D', date: '11-01 19:30', code: 'TOUR-6608-CS', used: false, color: '#10B981' },
{ id: 6, artist: '惘闻', seat: '看台 B区 02排 19座', zone: '看台B', date: '11-22 19:30', code: 'TOUR-9025-DL', used: false, color: '#6366F1' },
{ id: 7, artist: '万能青年旅店', seat: '内场 E区 08排 05座', zone: '内场E', date: '09-13 19:30', code: 'TOUR-1187-BJ', used: true, color: '#94A3B8' },
{ id: 8, artist: '新裤子', seat: '看台 A区 11排 23座', zone: '看台A', date: '09-27 19:30', code: 'TOUR-4472-CD', used: true, color: '#94A3B8' },
{ id: 9, artist: '夏日入侵企画', seat: '看台 D区 04排 09座', zone: '看台D', date: '11-08 19:30', code: 'TOUR-5533-XA', used: true, color: '#94A3B8' },
{ id: 10, artist: '海朋森', seat: '内场 B区 02排 14座', zone: '内场B', date: '11-29 20:00', code: 'TOUR-2210-XM', used: true, color: '#94A3B8' }
];
const CHECKIN_POSTS: CheckinPost[] = [
{ id: 1, user: '悬日收藏家', avatar: '🌞', show: '陈粒「悬日」', city: '上海', text: '安可连唱三首《易燃易爆炸》,全场大合唱的时候我哭了', likes: 2341, liked: false, time: '2小时前', stars: 5 },
{ id: 2, user: '冀A青年', avatar: '🎸', show: '万能青年旅店', city: '北京', text: '《杀死那个石家庄人》前奏响起的瞬间,起鸡皮疙瘩', likes: 5210, liked: true, time: '5小时前', stars: 5 },
{ id: 3, user: '海边阿茂', avatar: '🛶', show: '五条人', city: '广州', text: '仁科又说错了城市名,全场喊「广州」他喊「深圳」哈哈哈', likes: 3120, liked: false, time: '8小时前', stars: 4 },
{ id: 4, user: '牛仔裤批发', avatar: '👖', show: '新裤子', city: '成都', text: '蹦到《你要跳舞吗》的时候鞋都被踩掉了,值!', likes: 1870, liked: false, time: '昨天', stars: 5 },
{ id: 5, user: '雕塑爱好者', avatar: '🗿', show: '重塑雕像的权利', city: '深圳', text: '华东的台风眼一开嗓,整个人被钉在原地', likes: 2650, liked: false, time: '昨天', stars: 5 },
{ id: 6, user: '山下望草', avatar: '⛰', show: '草东没有派对', city: '杭州', text: '《大风吹》全场跳,保安大哥都在抖腿', likes: 4320, liked: false, time: '2天前', stars: 5 },
{ id: 7, user: '光斑记录者', avatar: '🔻', show: '棱镜乐队', city: '南京', text: '《这是我一生中最勇敢的瞬间》求婚环节真的有人求婚成功了!', likes: 8930, liked: true, time: '2天前', stars: 5 },
{ id: 8, user: '刺猬抱抱', avatar: '🦔', show: '刺猬乐队', city: '武汉', text: '火车驶向云外带来的鼓点,把我的耳朵留在了场馆', likes: 1560, liked: false, time: '3天前', stars: 4 },
{ id: 9, user: '丹田有力', avatar: '💊', show: '回春丹', city: '长沙', text: '《鲜花》前奏一出,全场都变成了花海', likes: 2210, liked: false, time: '3天前', stars: 5 },
{ id: 10, user: '噪海航行', avatar: '🌊', show: '惘闻', city: '大连', text: '后摇现场不需要歌词,眼泪自己会流', likes: 3320, liked: false, time: '4天前', stars: 5 },
{ id: 11, user: '白鹭洲主', avatar: '⚓', show: '海朋森', city: '厦门', text: '场地小反而更近,歌手汗都甩到第一排了', likes: 980, liked: false, time: '5天前', stars: 4 },
{ id: 12, user: '鼓楼夜行', avatar: '🚦', show: '法兹乐队', city: '郑州', text: '站了三个小时腿麻了,但《立交桥》响起时又活过来了', likes: 1120, liked: false, time: '6天前', stars: 4 }
];
const BUDDY_LIST: BuddyItem[] = [
{ id: 1, user: '摇滚青椒', avatar: '🫑', show: '陈粒·上海', need: '拼房 · 场馆附近2km', gender: '女生优先', verified: true, joined: 3 },
{ id: 2, user: '石门夜车', avatar: '🚂', show: '万青·北京', need: '拼车 · 五棵松回燕郊', gender: '不限', verified: true, joined: 2 },
{ id: 3, user: '阿珍爱上了阿强', avatar: '🦆', show: '五条人·广州', need: '搭子 · 一起排队买周边', gender: '不限', verified: false, joined: 1 },
{ id: 4, user: '蓉城小喇叭', avatar: '📣', show: '新裤子·成都', need: '拼房 · 双床房一晚', gender: '女生优先', verified: true, joined: 4 },
{ id: 5, user: '湾湾观演团', avatar: '🌉', show: '重塑·深圳', need: '拼车 · 后海回南山', gender: '不限', verified: false, joined: 2 },
{ id: 6, user: '西湖雨声', avatar: '☔', show: '草东·杭州', need: '搭子 · 内场F区同行', gender: '男生优先', verified: true, joined: 1 },
{ id: 7, user: '金陵夜泊', avatar: '🏮', show: '棱镜·南京', need: '拼房 · 演唱会当晚', gender: '女生优先', verified: false, joined: 3 },
{ id: 8, user: '热干面加醋', avatar: '🍜', show: '刺猬·武汉', need: '拼车 · 光谷回汉口', gender: '不限', verified: true, joined: 2 },
{ id: 9, user: '辣椒炒肉', avatar: '🌶', show: '回春丹·长沙', need: '搭子 · 散场一起吃夜宵', gender: '不限', verified: true, joined: 5 },
{ id: 10, user: '城墙根下', avatar: '🏯', show: '夏日入侵·西安', need: '拼房 · 钟楼附近', gender: '男生优先', verified: false, joined: 1 }
];
const CITY_HEAT: CityHeat[] = [
{ city: '上海', shows: 42 },
{ city: '北京', shows: 38 },
{ city: '广州', shows: 30 },
{ city: '深圳', shows: 28 },
{ city: '成都', shows: 26 },
{ city: '杭州', shows: 22 },
{ city: '南京', shows: 18 },
{ city: '武汉', shows: 16 }
];
const WANT_LIST: WantItem[] = [
{ id: 1, artist: '万能青年旅店', city: '石家庄', date: '待官宣', notify: true },
{ id: 2, artist: 'Joyside', city: '北京', date: '待官宣', notify: false },
{ id: 3, artist: '后海大鲨鱼', city: '上海', date: '待官宣', notify: true },
{ id: 4, artist: '木马乐队', city: '长沙', date: '待官宣', notify: false }
];
const TICKET_ZONES: string[] = ['内场 ¥880', '看台A ¥580', '看台B ¥380', '看台C ¥280'];
// ============ 工具函数 ============
function tourStatusColor(s: string): string {
if (s === '预售中') {
return '#F59E0B';
}
if (s === '售罄') {
return '#9A8F94';
}
return '#7DD3FC';
}
function tourStatusBg(s: string): string {
if (s === '预售中') {
return 'rgba(245,158,11,0.14)';
}
if (s === '售罄') {
return 'rgba(154,143,148,0.16)';
}
return 'rgba(125,211,252,0.14)';
}
function heatBarHeight(shows: number): string {
return (shows * 3.2).toFixed(0) + 'vp';
}
function starText(n: number): string {
if (n >= 5) {
return '★★★★★';
}
return '★★★★☆';
}
enum TourTab {
SCHEDULE,
VENUE,
TICKET,
CHECKIN,
BUDDY,
ME
}
// ============ 主入口 ============
@Entry
@Component
struct TourlogApp {
@State activeTab: TourTab = TourTab.SCHEDULE
@Builder contentArea() {
if (this.activeTab === TourTab.SCHEDULE) {
ScheduleContent()
} else if (this.activeTab === TourTab.VENUE) {
VenueContent()
} else if (this.activeTab === TourTab.TICKET) {
TicketContent()
} else if (this.activeTab === TourTab.CHECKIN) {
CheckinContent()
} else if (this.activeTab === TourTab.BUDDY) {
BuddyContent()
} else {
MeContent()
}
}
@Builder bottomTabItem(icon: string, label: string, tab: TourTab) {
Column({ space: 3 }) {
Text(icon)
.fontSize(21)
.scale(this.activeTab === tab ? { x: 1.16, y: 1.16 } : { x: 1, y: 1 })
.animation({ duration: 220, curve: Curve.EaseOut })
Text(label)
.fontSize(10)
.fontColor(this.activeTab === tab ? '#F59E0B' : '#9A8F94')
}
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.width('16.6%')
.onClick(() => {
this.activeTab = tab;
})
}
build() {
Column() {
TourHeader()
Column() {
this.contentArea()
}
.layoutWeight(1)
.width('100%')
Divider()
.color('rgba(245,158,11,0.2)')
.strokeWidth(1)
Row() {
this.bottomTabItem('📅', '日程', TourTab.SCHEDULE)
this.bottomTabItem('🏟', '场馆', TourTab.VENUE)
this.bottomTabItem('🎫', '票夹', TourTab.TICKET)
this.bottomTabItem('📸', '打卡', TourTab.CHECKIN)
this.bottomTabItem('🤝', '搭子', TourTab.BUDDY)
this.bottomTabItem('👤', '我的', TourTab.ME)
}
.width('100%')
.backgroundColor('#180C10')
.padding({ bottom: 6 })
}
.width('100%')
.height('100%')
.backgroundColor('#1A0E12')
}
}
// ============ 头部(电商风·静态) ============
@Component
struct TourHeader {
onSearchTap: () => void = () => {};
build() {
Column() {
Row({ space: 10 }) {
Column() {
Text('🎫')
.fontSize(20)
}
.width(42)
.height(42)
.borderRadius(8)
.backgroundColor('rgba(225,29,72,0.15)')
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text('巡演日记')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
Text('TOURLOG · Live 音乐现场助手')
.fontSize(8)
.fontColor('#9A8F94')
.letterSpacing(1)
}
.alignItems(HorizontalAlign.Start)
Row({ space: 6 }) {
Text('🔎')
.fontSize(13)
Text('搜演出 / 场馆 / 音乐人')
.fontSize(11)
.fontColor('#9A8F94')
}
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#241318')
.borderRadius(22)
.border({ width: 1, color: 'rgba(245,158,11,0.2)' })
.layoutWeight(1)
.onClick(() => {
this.onSearchTap();
})
Text('🔔')
.fontSize(18)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
Scroll() {
Row({ space: 8 }) {
Text('全部')
.fontSize(11)
.fontColor('#1A0E12')
.fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#F59E0B')
.borderRadius(14)
Text('即将开售')
.fontSize(11)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#241318')
.borderRadius(14)
Text('我要看')
.fontSize(11)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#241318')
.borderRadius(14)
Text('同城')
.fontSize(11)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#241318')
.borderRadius(14)
Text('拼搭子')
.fontSize(11)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#241318')
.borderRadius(14)
Text('转票区')
.fontSize(11)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#241318')
.borderRadius(14)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ bottom: 12 })
}
.width('100%')
.backgroundColor('#180C10')
}
}
// ============ 日程 Tab(时间轴 + 演出详情/抢票) ============
@Component
struct ScheduleContent {
@State showDetail: boolean = false
@State selectedTour: TourDate | null = null
@State showBuyModal: boolean = false
@State zoneIndex: number = 0
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('📅')
.fontSize(30)
.margin({ bottom: 8 })
Text('日程演示')
.fontSize(12)
.fontColor('#9A8F94')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder tourDetailModal() {
Column({ space: 12 }) {
Column({ space: 8 }) {
Text(this.selectedTour!.emoji)
.fontSize(40)
Text(this.selectedTour!.artist)
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
}
.width('100%')
.padding({ top: 22 })
.alignItems(HorizontalAlign.Center)
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('📅')
.fontSize(14)
Text(this.selectedTour!.date + ' ' + this.selectedTour!.weekday + ' 19:30')
.fontSize(12)
.fontColor('#E7D5D9')
}
.width('100%')
Row({ space: 10 }) {
Text('🏟')
.fontSize(14)
Text(this.selectedTour!.city + ' · ' + this.selectedTour!.venue)
.fontSize(12)
.fontColor('#E7D5D9')
}
.width('100%')
Row({ space: 10 }) {
Text('💰')
.fontSize(14)
Text('¥' + this.selectedTour!.priceFrom + ' 起')
.fontSize(12)
.fontColor('#F59E0B')
}
.width('100%')
}
.width('86%')
.padding(14)
.backgroundColor('#241318')
.borderRadius(14)
Divider()
.color('rgba(245,158,11,0.18)')
.margin({ left: 24, right: 24 })
Column({ space: 0 }) {
Row() {
Text('19:30')
.fontSize(10)
.fontColor('#F59E0B')
.width(50)
Text('检票入场,记得带身份证原件')
.fontSize(11)
.fontColor('#C9B8BC')
}
.width('84%')
.padding({ top: 8, bottom: 8 })
Row() {
Text('20:00')
.fontSize(10)
.fontColor('#F59E0B')
.width(50)
Text('开场 · 暖场嘉宾 30 分钟')
.fontSize(11)
.fontColor('#C9B8BC')
}
.width('84%')
.padding({ top: 8, bottom: 8 })
Row() {
Text('22:30')
.fontSize(10)
.fontColor('#F59E0B')
.width(50)
Text('预计散场 · 地铁末班车 23:15')
.fontSize(11)
.fontColor('#C9B8BC')
}
.width('84%')
.padding({ top: 8, bottom: 8 })
}
Row({ space: 10 }) {
Text('❤ 想去 ' + (this.selectedTour!.want / 10000).toFixed(1) + '万')
.fontSize(11)
.fontColor('#E11D48')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('rgba(225,29,72,0.12)')
.borderRadius(14)
Text('📍 场馆攻略')
.fontSize(11)
.fontColor('#7DD3FC')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('rgba(125,211,252,0.12)')
.borderRadius(14)
Text('🤝 找搭子')
.fontSize(11)
.fontColor('#F59E0B')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor('rgba(245,158,11,0.12)')
.borderRadius(14)
}
Button() {
Text(this.selectedTour!.status === '预售中' ? '🎫 立即抢票' : this.selectedTour!.status === '售罄' ? '缺货登记' : '开售提醒我')
.fontSize(15)
.fontColor('#1A0E12')
}
.padding({ left: 44, right: 44, top: 12, bottom: 12 })
.backgroundColor('#F59E0B')
.borderRadius(26)
.scale({ x: 1.04, y: 1.04 })
.animation({ duration: 300, curve: Curve.EaseOut })
.margin({ top: 6, bottom: 22 })
.onClick(() => {
if (this.selectedTour!.status === '预售中') {
this.showDetail = false;
this.showBuyModal = true;
} else {
this.showDetail = false;
}
})
}
.width('90%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(245,158,11,0.28)' })
}
@Builder buyModal() {
Column({ space: 14 }) {
Text('🎫 确认抢票')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.margin({ top: 20 })
Text(this.selectedTour!.artist + ' · ' + this.selectedTour!.city)
.fontSize(11)
.fontColor('#9A8F94')
Column({ space: 0 }) {
ForEach(TICKET_ZONES, (z: string, i: number) => {
Row() {
Text(z)
.fontSize(13)
.fontColor(this.zoneIndex === i ? '#F59E0B' : '#C9B8BC')
Text(this.zoneIndex === i ? '●' : '○')
.fontSize(13)
.fontColor('#F59E0B')
}
.width('84%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ top: 11, bottom: 11 })
.onClick(() => {
this.zoneIndex = i;
})
}, (z: string) => z)
}
.width('100%')
.backgroundColor('#180C10')
.borderRadius(14)
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('实名观演')
.fontSize(10)
.fontColor('#7DD3FC')
Text('每证件限购 4 张,入场需刷身份证')
.fontSize(10)
.fontColor('#9A8F94')
}
.width('84%')
Column() {
Column()
.width('68%')
.height(6)
.borderRadius(3)
.backgroundColor('#F59E0B')
}
.width('84%')
.height(6)
.borderRadius(3)
.backgroundColor('#3D2229')
Row({ space: 8 }) {
Text('余票 32%')
.fontSize(9)
.fontColor('#F59E0B')
Text('15:14:22 后关闭')
.fontSize(9)
.fontColor('#9A8F94')
}
.width('84%')
}
Row({ space: 12 }) {
Button() {
Text('再想想')
.fontSize(13)
.fontColor('#9A8F94')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#180C10')
.borderRadius(20)
.onClick(() => {
this.showBuyModal = false;
})
Button() {
Text('提交订单')
.fontSize(13)
.fontColor('#1A0E12')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#F59E0B')
.borderRadius(20)
.onClick(() => {
this.showBuyModal = false;
})
}
.margin({ bottom: 20 })
}
.width('88%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder tourRow(t: TourDate) {
Row({ space: 12 }) {
Column({ space: 2 }) {
Text(t.date.split('-')[0])
.fontSize(11)
.fontColor('#9A8F94')
Text(t.date.split('-')[1])
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
Text(t.weekday)
.fontSize(9)
.fontColor('#9A8F94')
}
.width(52)
.alignItems(HorizontalAlign.Center)
Column()
.width(2)
.height(58)
.backgroundColor(t.status === '预售中' ? '#F59E0B' : 'rgba(245,158,11,0.25)')
Column({ space: 5 }) {
Row({ space: 8 }) {
Text(t.emoji + ' ' + t.artist)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.maxLines(1)
Text(t.status)
.fontSize(8)
.fontColor(tourStatusColor(t.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(tourStatusBg(t.status))
.borderRadius(4)
}
Text(t.city + ' · ' + t.venue)
.fontSize(10)
.fontColor('#9A8F94')
.maxLines(1)
Row({ space: 10 }) {
Text('¥' + t.priceFrom + '起')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor('#F59E0B')
Text('❤ ' + (t.want / 10000).toFixed(1) + '万想去')
.fontSize(10)
.fontColor('#E11D48')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor('#241318')
.borderRadius(16)
.margin({ bottom: 10 })
.onClick(() => {
this.selectedTour = t;
this.zoneIndex = 0;
this.showDetail = true;
})
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Row({ space: 10 }) {
Text('🎟 ' + TOUR_DATES.length + ' 场演出正在路上')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.layoutWeight(1)
Text('江浙沪')
.fontSize(10)
.fontColor('#F59E0B')
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('rgba(245,158,11,0.12)')
.borderRadius(12)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 10 })
Column() {
ForEach(TOUR_DATES, (t: TourDate) => {
this.tourRow(t)
}, (t: TourDate) => t.id.toString())
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showDetail) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showDetail = false;
})
this.tourDetailModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
if (this.showBuyModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showBuyModal = false;
})
this.buyModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 场馆 Tab(网格 + 场馆详情) ============
@Component
struct VenueContent {
@State showVenue: boolean = false
@State selectedVenue: VenueInfo | null = null
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('🏟')
.fontSize(30)
.margin({ bottom: 8 })
Text('场馆演示')
.fontSize(12)
.fontColor('#9A8F94')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder venueModal() {
Column({ space: 12 }) {
Text('🏟 ' + this.selectedVenue!.name)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.margin({ top: 20 })
.padding({ left: 20, right: 20 })
.textAlign(TextAlign.Center)
Row({ space: 10 }) {
Text(this.selectedVenue!.city)
.fontSize(10)
.fontColor('#F59E0B')
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor('rgba(245,158,11,0.12)')
.borderRadius(4)
Text(this.selectedVenue!.capacity)
.fontSize(10)
.fontColor('#9A8F94')
Text('★ ' + this.selectedVenue!.score)
.fontSize(10)
.fontColor('#F59E0B')
}
Column({ space: 6 }) {
Row({ space: 8 }) {
Text('🚇')
.fontSize(13)
Text(this.selectedVenue!.metro)
.fontSize(12)
.fontColor('#E7D5D9')
}
.width('100%')
Row({ space: 8 }) {
Text('🏷')
.fontSize(13)
Text(this.selectedVenue!.tags)
.fontSize(12)
.fontColor('#9A8F94')
}
.width('100%')
}
.width('84%')
.padding(12)
.backgroundColor('#180C10')
.borderRadius(12)
Text('座位区示意')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#E7D5D9')
.margin({ top: 4 })
Column({ space: 4 }) {
Row({ space: 4 }) {
Text('看台A')
.fontSize(9)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#3D2229')
.borderRadius(6)
Text('看台B')
.fontSize(9)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#3D2229')
.borderRadius(6)
Text('看台C')
.fontSize(9)
.fontColor('#9A8F94')
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor('#3D2229')
.borderRadius(6)
}
Text('内场 FLOOR')
.fontSize(10)
.fontColor('#F59E0B')
.padding({ left: 60, right: 60, top: 14, bottom: 14 })
.backgroundColor('rgba(245,158,11,0.14)')
.borderRadius(10)
.border({ width: 1, color: 'rgba(245,158,11,0.4)' })
Text('舞台 STAGE')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor('#1A0E12')
.padding({ left: 40, right: 40, top: 8, bottom: 8 })
.backgroundColor('#E11D48')
.borderRadius(6)
}
.width('84%')
.alignItems(HorizontalAlign.Center)
Button() {
Text('查看近期演出')
.fontSize(13)
.fontColor('#1A0E12')
}
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.backgroundColor('#F59E0B')
.borderRadius(22)
.margin({ top: 6, bottom: 20 })
.onClick(() => {
this.showVenue = false;
})
}
.width('90%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(245,158,11,0.28)' })
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Text('🏟 全国热门 Live 场馆')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.width('100%')
.padding({ left: 16, top: 14, bottom: 10 })
Row({ space: 10 }) {
ForEach(CITY_HEAT, (c: CityHeat) => {
Column({ space: 5 }) {
Text(c.shows.toString())
.fontSize(9)
.fontColor('#F59E0B')
Column()
.width(16)
.height(heatBarHeight(c.shows))
.borderRadius(4)
.backgroundColor(c.shows > 30 ? '#E11D48' : '#F59E0B')
Text(c.city)
.fontSize(9)
.fontColor('#9A8F94')
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.End)
}, (c: CityHeat) => c.city)
}
.height(150)
.alignItems(VerticalAlign.Bottom)
.width('100%')
.padding({ left: 16, right: 16 })
.margin({ bottom: 6 })
Text('(下半年场馆演出场次)')
.fontSize(9)
.fontColor('#9A8F94')
.width('100%')
.textAlign(TextAlign.Center)
Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
ForEach(VENUE_LIST, (v: VenueInfo) => {
Column({ space: 8 }) {
Column() {
Text('🏟')
.fontSize(26)
}
.width('100%')
.height(60)
.borderRadius(12)
.backgroundColor('rgba(225,29,72,0.1)')
.justifyContent(FlexAlign.Center)
Text(v.name)
.fontSize(12)
.fontWeight(FontWeight.Medium)
.fontColor('#FDF2F4')
.maxLines(2)
.minLines(2)
Row({ space: 6 }) {
Text(v.city)
.fontSize(9)
.fontColor('#F59E0B')
Text('★' + v.score)
.fontSize(9)
.fontColor('#F59E0B')
Text(v.capacity)
.fontSize(9)
.fontColor('#9A8F94')
}
}
.width('48%')
.padding(10)
.backgroundColor('#241318')
.borderRadius(16)
.margin({ bottom: 10 })
.onClick(() => {
this.selectedVenue = v;
this.showVenue = true;
})
}, (v: VenueInfo) => v.id.toString())
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 20 })
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showVenue) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showVenue = false;
})
this.venueModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 票夹 Tab(票根卡片 + 转赠/退票) ============
@Component
struct TicketContent {
@State tabIdx: number = 0
@State showTicket: boolean = false
@State selectedTicket: TicketStub | null = null
@State showTransferModal: boolean = false
@State showRefundModal: boolean = false
@State transferPhone: string = ''
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('🎫')
.fontSize(30)
.margin({ bottom: 8 })
Text('票夹演示')
.fontSize(12)
.fontColor('#9A8F94')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder ticketModal() {
Column({ space: 12 }) {
Text('电子票 · ' + this.selectedTicket!.code)
.fontSize(11)
.fontColor('#9A8F94')
.margin({ top: 18 })
Column({ space: 10 }) {
Text(this.selectedTicket!.artist)
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor('#1A0E12')
Text(this.selectedTicket!.date)
.fontSize(11)
.fontColor('#3D2229')
Divider()
.color('rgba(26,14,18,0.15)')
Text(this.selectedTicket!.seat)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#1A0E12')
Column() {
Text('▓▓▒▒▓▓▓▒▓▒▒▓\n▒▓▒▓▓▒▒▓▓▒▓▓\n▓▒▒▓▓▓▒▓▒▓▒▒▓\n▒▓▓▒▒▓▒▓▓▓▒▓▒')
.fontSize(13)
.fontColor('#1A0E12')
.textAlign(TextAlign.Center)
.lineHeight(16)
}
.width(150)
.height(150)
.backgroundColor('#FDF2F4')
.borderRadius(8)
.justifyContent(FlexAlign.Center)
.margin({ top: 4 })
Text('入场时向工作人员出示')
.fontSize(9)
.fontColor('#3D2229')
}
.width('84%')
.padding({ top: 18, bottom: 18 })
.backgroundColor(this.selectedTicket!.used ? '#9A8F94' : '#F59E0B')
.borderRadius(6)
if (this.selectedTicket!.used) {
Text('该票已核销 · 感谢观看')
.fontSize(12)
.fontColor('#9A8F94')
.margin({ bottom: 18 })
} else {
Row({ space: 12 }) {
Button() {
Text('转赠好友')
.fontSize(13)
.fontColor('#F59E0B')
}
.padding({ left: 18, right: 18, top: 9, bottom: 9 })
.backgroundColor('rgba(245,158,11,0.12)')
.borderRadius(20)
.onClick(() => {
this.showTicket = false;
this.showTransferModal = true;
})
Button() {
Text('申请退票')
.fontSize(13)
.fontColor('#E11D48')
}
.padding({ left: 18, right: 18, top: 9, bottom: 9 })
.backgroundColor('rgba(225,29,72,0.12)')
.borderRadius(20)
.onClick(() => {
this.showTicket = false;
this.showRefundModal = true;
})
}
.margin({ bottom: 20 })
}
}
.width('88%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(245,158,11,0.28)' })
}
@Builder transferModal() {
Column({ space: 14 }) {
Text('转赠票券')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.margin({ top: 20 })
Text('「' + this.selectedTicket!.artist + '」')
.fontSize(12)
.fontColor('#F59E0B')
TextInput({ placeholder: '输入对方巡演日记绑定手机号', text: this.transferPhone })
.fontSize(13)
.fontColor('#FDF2F4')
.placeholderColor('#9A8F94')
.backgroundColor('#180C10')
.borderRadius(10)
.width('82%')
.type(InputType.PhoneNumber)
.onChange((v: string) => {
this.transferPhone = v;
})
Text('⚠️ 每张票仅可转赠一次,转赠后不可撤回')
.fontSize(10)
.fontColor('#E11D48')
Row({ space: 12 }) {
Button() {
Text('取消')
.fontSize(13)
.fontColor('#9A8F94')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#180C10')
.borderRadius(20)
.onClick(() => {
this.showTransferModal = false;
})
Button() {
Text('确认转赠')
.fontSize(13)
.fontColor('#1A0E12')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#F59E0B')
.borderRadius(20)
.onClick(() => {
this.showTransferModal = false;
this.transferPhone = '';
})
}
.margin({ bottom: 20 })
}
.width('86%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder refundModal() {
Column({ space: 12 }) {
Text('💸')
.fontSize(38)
.margin({ top: 20 })
Text('申请退票')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#E11D48')
Text('开场前 7 天以上:退 100%\n开场前 3-7 天:退 70%\n开场前 3 天内:不可退')
.fontSize(11)
.fontColor('#C9B8BC')
.textAlign(TextAlign.Center)
.lineHeight(18)
Column({ space: 5 }) {
Row({ space: 8 }) {
Text('票面价')
.fontSize(11)
.fontColor('#9A8F94')
.layoutWeight(1)
Text('¥580')
.fontSize(11)
.fontColor('#FDF2F4')
}
.width('84%')
Row({ space: 8 }) {
Text('手续费(30%)')
.fontSize(11)
.fontColor('#9A8F94')
.layoutWeight(1)
Text('-¥174')
.fontSize(11)
.fontColor('#E11D48')
}
.width('84%')
Divider()
.color('rgba(245,158,11,0.15)')
Row({ space: 8 }) {
Text('预计到账')
.fontSize(11)
.fontColor('#9A8F94')
.layoutWeight(1)
Text('¥406')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#F59E0B')
}
.width('84%')
}
Row({ space: 12 }) {
Button() {
Text('不退了')
.fontSize(13)
.fontColor('#9A8F94')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#180C10')
.borderRadius(20)
.onClick(() => {
this.showRefundModal = false;
})
Button() {
Text('确认退票')
.fontSize(13)
.fontColor('#FDF2F4')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#E11D48')
.borderRadius(20)
.onClick(() => {
this.showRefundModal = false;
})
}
.margin({ bottom: 20 })
}
.width('86%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(225,29,72,0.3)' })
}
@Builder ticketStub(t: TicketStub) {
Column({ space: 10 }) {
Row({ space: 12 }) {
Column() {
Text('🎵')
.fontSize(24)
}
.width(52)
.height(52)
.borderRadius(10)
.backgroundColor('rgba(245,158,11,0.12)')
.justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Text(t.artist)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
Text(t.date)
.fontSize(10)
.fontColor('#9A8F94')
Text(t.seat)
.fontSize(10)
.fontColor(t.used ? '#6B5B60' : '#F59E0B')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 4 }) {
Text(t.used ? '已核销' : '待观演')
.fontSize(9)
.fontColor(t.used ? '#9A8F94' : '#7DD3FC')
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(t.used ? 'rgba(154,143,148,0.16)' : 'rgba(125,211,252,0.12)')
.borderRadius(4)
Text('›')
.fontSize(16)
.fontColor('#9A8F94')
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Divider()
.color('rgba(245,158,11,0.15)')
Row() {
Text('票号 ' + t.code)
.fontSize(9)
.fontColor('#9A8F94')
.layoutWeight(1)
Text(t.used ? '留念存根' : '点击查看电子票 ›')
.fontSize(9)
.fontColor('#F59E0B')
}
.width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#241318')
.borderRadius(16)
.margin({ bottom: 10 })
.scale(this.showTicket && this.selectedTicket !== null && this.selectedTicket!.id === t.id ? { x: 1.02, y: 1.02 } : { x: 1, y: 1 })
.animation({ duration: 200, curve: Curve.EaseOut })
.onClick(() => {
this.selectedTicket = t;
this.showTicket = true;
})
}
build() {
Stack({ alignContent: Alignment.Center }) {
Column() {
Row({ space: 8 }) {
Text('待观演 (' + (TICKET_STUBS.length - 4) + ')')
.fontSize(12)
.fontColor(this.tabIdx === 0 ? '#1A0E12' : '#9A8F94')
.fontWeight(FontWeight.Bold)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(this.tabIdx === 0 ? '#F59E0B' : '#241318')
.borderRadius(18)
.onClick(() => {
this.tabIdx = 0;
})
Text('已核销 (4)')
.fontSize(12)
.fontColor(this.tabIdx === 1 ? '#1A0E12' : '#9A8F94')
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(this.tabIdx === 1 ? '#F59E0B' : '#241318')
.borderRadius(18)
.onClick(() => {
this.tabIdx = 1;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12 })
Scroll() {
Column() {
ForEach(TICKET_STUBS, (t: TicketStub) => {
if (this.tabIdx === 0 && !t.used) {
this.ticketStub(t)
}
if (this.tabIdx === 1 && t.used) {
this.ticketStub(t)
}
}, (t: TicketStub) => t.id.toString() + this.tabIdx.toString())
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 20 })
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
}
.width('100%')
.height('100%')
if (this.showTicket) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showTicket = false;
})
this.ticketModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.82)')
}
if (this.showTransferModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showTransferModal = false;
})
this.transferModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
if (this.showRefundModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showRefundModal = false;
})
this.refundModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 打卡 Tab(动态流 + 发布/详情) ============
@Component
struct CheckinContent {
@State showPostModal: boolean = false
@State showDetail: boolean = false
@State selectedPost: CheckinPost | null = null
@State showLikeToast: boolean = false
@State postText: string = ''
@State postStars: number = 5
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('📸')
.fontSize(30)
.margin({ bottom: 8 })
Text('打卡演示')
.fontSize(12)
.fontColor('#9A8F94')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder postModal() {
Column({ space: 14 }) {
Text('发布观演打卡')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.margin({ top: 20 })
Row({ space: 10 }) {
Column() {
Text('🎤')
.fontSize(20)
}
.padding(10)
.backgroundColor('rgba(225,29,72,0.12)')
.borderRadius(10)
Column() {
Text('📷')
.fontSize(20)
}
.padding(10)
.backgroundColor('rgba(245,158,11,0.12)')
.borderRadius(10)
Column() {
Text('🎟')
.fontSize(20)
}
.padding(10)
.backgroundColor('rgba(125,211,252,0.12)')
.borderRadius(10)
}
Text('关联演出:陈粒「悬日」· 上海 · 09-06')
.fontSize(11)
.fontColor('#F59E0B')
Row({ space: 8 }) {
Text('现场评分')
.fontSize(11)
.fontColor('#9A8F94')
ForEach([1, 2, 3, 4, 5], (s: number) => {
Text(s <= this.postStars ? '★' : '☆')
.fontSize(20)
.fontColor('#F59E0B')
.scale(s === this.postStars ? { x: 1.2, y: 1.2 } : { x: 1, y: 1 })
.animation({ duration: 150, curve: Curve.EaseOut })
.onClick(() => {
this.postStars = s;
})
}, (s: number) => s.toString())
}
TextArea({ placeholder: '这场演出最打动你的瞬间…', text: this.postText })
.fontSize(12)
.fontColor('#FDF2F4')
.placeholderColor('#9A8F94')
.backgroundColor('#180C10')
.borderRadius(12)
.width('84%')
.height(90)
.onChange((v: string) => {
this.postText = v;
})
Row({ space: 12 }) {
Button() {
Text('取消')
.fontSize(13)
.fontColor('#9A8F94')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#180C10')
.borderRadius(20)
.onClick(() => {
this.showPostModal = false;
})
Button() {
Text('发布打卡')
.fontSize(13)
.fontColor('#1A0E12')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#F59E0B')
.borderRadius(20)
.onClick(() => {
this.showPostModal = false;
this.postText = '';
})
}
.margin({ bottom: 20 })
}
.width('90%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(245,158,11,0.28)' })
}
@Builder postDetailModal() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Column() {
Text(this.selectedPost!.avatar)
.fontSize(22)
}
.width(44)
.height(44)
.borderRadius(22)
.backgroundColor('rgba(245,158,11,0.12)')
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text(this.selectedPost!.user)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
Text(this.selectedPost!.time + ' · ' + this.selectedPost!.city)
.fontSize(10)
.fontColor('#9A8F94')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 18 })
Column({ space: 6 }) {
Text(this.selectedPost!.show + ' 现场打卡')
.fontSize(11)
.fontColor('#F59E0B')
Text(starText(this.selectedPost!.stars))
.fontSize(13)
.fontColor('#F59E0B')
Text(this.selectedPost!.text)
.fontSize(12)
.fontColor('#E7D5D9')
.lineHeight(19)
.padding({ left: 12, right: 12 })
}
.width('100%')
.padding({ top: 6 })
.alignItems(HorizontalAlign.Center)
Column() {
Text('🎤🎸🥁')
.fontSize(30)
.padding({ left: 50, right: 50, top: 30, bottom: 30 })
.backgroundColor('rgba(225,29,72,0.1)')
.borderRadius(12)
}
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('鼓手solo太炸了')
.fontSize(10)
.fontColor('#C9B8BC')
Text('2分钟前')
.fontSize(9)
.fontColor('#9A8F94')
}
.width('100%')
Row({ space: 10 }) {
Text('羡慕哭了,求返场')
.fontSize(10)
.fontColor('#C9B8BC')
Text('18分钟前')
.fontSize(9)
.fontColor('#9A8F94')
}
.width('100%')
Row({ space: 10 }) {
Text('我也在场!B区举灯牌那个是我')
.fontSize(10)
.fontColor('#C9B8BC')
Text('1小时前')
.fontSize(9)
.fontColor('#9A8F94')
}
.width('100%')
}
.width('86%')
.padding(12)
.backgroundColor('#180C10')
.borderRadius(12)
Row({ space: 10 }) {
TextInput({ placeholder: '说点什么…' })
.fontSize(11)
.fontColor('#FDF2F4')
.placeholderColor('#9A8F94')
.backgroundColor('#180C10')
.borderRadius(18)
.layoutWeight(1)
Text('发送')
.fontSize(11)
.fontColor('#1A0E12')
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor('#F59E0B')
.borderRadius(16)
}
.width('86%')
.margin({ bottom: 20 })
}
.width('94%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(245,158,11,0.28)' })
}
@Builder likeToast() {
Column({ space: 8 }) {
Text('❤️')
.fontSize(32)
.scale({ x: 1.3, y: 1.3 })
.animation({ duration: 400, curve: Curve.EaseOut })
Text('已点赞')
.fontSize(12)
.fontColor('#FDF2F4')
}
.width('42%')
.padding({ top: 20, bottom: 20 })
.backgroundColor('#241318')
.borderRadius(16)
.alignItems(HorizontalAlign.Center)
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Row({ space: 10 }) {
Text('🔥 最新打卡')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.layoutWeight(1)
Text('✍️ 发打卡')
.fontSize(11)
.fontColor('#1A0E12')
.fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#F59E0B')
.borderRadius(14)
.onClick(() => {
this.showPostModal = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 14, bottom: 10 })
Column() {
ForEach(CHECKIN_POSTS, (p: CheckinPost) => {
Column({ space: 10 }) {
Row({ space: 12 }) {
Column() {
Text(p.avatar)
.fontSize(22)
}
.width(44)
.height(44)
.borderRadius(22)
.backgroundColor('rgba(245,158,11,0.1)')
.justifyContent(FlexAlign.Center)
Column({ space: 2 }) {
Text(p.user)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
Text(p.time + ' · ' + p.show + ' · ' + p.city)
.fontSize(9)
.fontColor('#9A8F94')
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
Column({ space: 6 }) {
Text(p.text)
.fontSize(12)
.fontColor('#E7D5D9')
.lineHeight(19)
Text(starText(p.stars))
.fontSize(11)
.fontColor('#F59E0B')
}
.width('100%')
.alignItems(HorizontalAlign.Start)
.padding({ left: 56 })
Row({ space: 16 }) {
Text(p.liked ? '❤ ' + p.likes : '♡ ' + p.likes)
.fontSize(11)
.fontColor(p.liked ? '#E11D48' : '#9A8F94')
.scale(p.liked ? { x: 1.15, y: 1.15 } : { x: 1, y: 1 })
.animation({ duration: 200, curve: Curve.EaseOut })
.onClick(() => {
p.liked = !p.liked;
if (p.liked) {
p.likes += 1;
this.showLikeToast = true;
} else {
p.likes -= 1;
}
})
Text('💬 128')
.fontSize(11)
.fontColor('#9A8F94')
.onClick(() => {
this.selectedPost = p;
this.showDetail = true;
})
Text('↗ 分享')
.fontSize(11)
.fontColor('#9A8F94')
}
.width('100%')
.padding({ left: 56 })
}
.width('100%')
.padding(14)
.backgroundColor('#241318')
.borderRadius(16)
.margin({ bottom: 10 })
.onClick(() => {
this.selectedPost = p;
this.showDetail = true;
})
}, (p: CheckinPost) => p.id.toString() + p.liked.toString())
}
.width('100%')
.padding({ left: 16, right: 16, bottom: 20 })
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showPostModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showPostModal = false;
})
this.postModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
if (this.showDetail) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showDetail = false;
})
this.postDetailModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
if (this.showLikeToast) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showLikeToast = false;
})
this.likeToast()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.5)')
}
}
.width('100%')
.height('100%')
}
}
// ============ 搭子 Tab(拼车拼房列表 + 发起/加入) ============
@Component
struct BuddyContent {
@State showBuddy: boolean = false
@State selectedBuddy: BuddyItem | null = null
@State showCreateModal: boolean = false
@State showJoinModal: boolean = false
@State showSafetyModal: boolean = false
@State createNeed: string = '拼房'
@Builder modalOverlay(onClose: () => void) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
onClose();
})
Column() {
Text('🤝')
.fontSize(30)
.margin({ bottom: 8 })
Text('搭子演示')
.fontSize(12)
.fontColor('#9A8F94')
}
.width('100%')
}
.width('100%')
.height('100%')
}
@Builder buddyModal() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Column() {
Text(this.selectedBuddy!.avatar)
.fontSize(24)
}
.width(52)
.height(52)
.borderRadius(26)
.backgroundColor('rgba(245,158,11,0.12)')
.justifyContent(FlexAlign.Center)
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(this.selectedBuddy!.user)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
if (this.selectedBuddy!.verified) {
Text('已实名')
.fontSize(8)
.fontColor('#7DD3FC')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('rgba(125,211,252,0.12)')
.borderRadius(4)
}
}
Text('观演 12 场 · 信用分 96')
.fontSize(10)
.fontColor('#9A8F94')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 20 })
Column({ space: 6 }) {
Text('🎫 ' + this.selectedBuddy!.show)
.fontSize(12)
.fontColor('#F59E0B')
Text('📌 ' + this.selectedBuddy!.need)
.fontSize(12)
.fontColor('#E7D5D9')
Text('👥 已加入 ' + this.selectedBuddy!.joined + ' 人 · ' + this.selectedBuddy!.gender)
.fontSize(10)
.fontColor('#9A8F94')
}
.width('86%')
.padding(12)
.backgroundColor('#180C10')
.borderRadius(12)
Text('「场内前排圈,散场一起吃火锅,AA 制,男女不限~」')
.fontSize(11)
.fontColor('#C9B8BC')
.padding({ left: 24, right: 24 })
.textAlign(TextAlign.Center)
Row({ space: 12 }) {
Button() {
Text('聊一聊')
.fontSize(13)
.fontColor('#F59E0B')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('rgba(245,158,11,0.12)')
.borderRadius(20)
Button() {
Text('申请加入')
.fontSize(13)
.fontColor('#1A0E12')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#F59E0B')
.borderRadius(20)
.onClick(() => {
this.showBuddy = false;
this.showJoinModal = true;
})
}
.margin({ bottom: 20 })
}
.width('90%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(245,158,11,0.28)' })
}
@Builder createModal() {
Column({ space: 12 }) {
Text('发起新搭子')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.margin({ top: 20 })
Row({ space: 8 }) {
ForEach(['拼房', '拼车', '排队', '看演出'], (n: string) => {
Text(n)
.fontSize(11)
.fontColor(this.createNeed === n ? '#1A0E12' : '#C9B8BC')
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.backgroundColor(this.createNeed === n ? '#F59E0B' : '#180C10')
.borderRadius(14)
.onClick(() => {
this.createNeed = n;
})
}, (n: string) => n)
}
Row({ space: 8 }) {
ForEach(['陈粒·上海', '万青·北京', '五条人·广州', '草东·杭州'], (s: string) => {
Text(s)
.fontSize(9)
.fontColor('#F59E0B')
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('rgba(245,158,11,0.1)')
.borderRadius(10)
}, (s: string) => s)
}
TextArea({ placeholder: '描述你的需求:时间地点、AA方式、期望搭子…' })
.fontSize(12)
.fontColor('#FDF2F4')
.placeholderColor('#9A8F94')
.backgroundColor('#180C10')
.borderRadius(12)
.width('84%')
.height(80)
Row({ space: 10 }) {
Button() {
Text('取消')
.fontSize(13)
.fontColor('#9A8F94')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#180C10')
.borderRadius(20)
.onClick(() => {
this.showCreateModal = false;
})
Button() {
Text('发布')
.fontSize(13)
.fontColor('#1A0E12')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#F59E0B')
.borderRadius(20)
.onClick(() => {
this.showCreateModal = false;
})
}
.margin({ bottom: 20 })
}
.width('88%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder joinModal() {
Column({ space: 12 }) {
Text('🤝')
.fontSize(36)
.margin({ top: 20 })
Text('申请加入「摇滚青椒」的搭子?')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
Text('加入后你们可以在会话里沟通行程与费用分摊')
.fontSize(11)
.fontColor('#9A8F94')
.padding({ left: 28, right: 28 })
.textAlign(TextAlign.Center)
Text('🛡 建议先查看对方实名认证与历史评价')
.fontSize(10)
.fontColor('#7DD3FC')
.onClick(() => {
this.showJoinModal = false;
this.showSafetyModal = true;
})
Row({ space: 12 }) {
Button() {
Text('再看看')
.fontSize(13)
.fontColor('#9A8F94')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#180C10')
.borderRadius(20)
.onClick(() => {
this.showJoinModal = false;
})
Button() {
Text('发送申请')
.fontSize(13)
.fontColor('#1A0E12')
}
.padding({ left: 20, right: 20, top: 9, bottom: 9 })
.backgroundColor('#F59E0B')
.borderRadius(20)
.onClick(() => {
this.showJoinModal = false;
})
}
.margin({ bottom: 20 })
}
.width('82%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
}
@Builder safetyModal() {
Column({ space: 12 }) {
Text('🛡')
.fontSize(36)
.margin({ top: 20 })
Text('搭子安全须知')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
Column({ space: 8 }) {
Row({ space: 8 }) {
Text('1.')
.fontSize(11)
.fontColor('#F59E0B')
Text('优先选择已完成实名认证的搭子')
.fontSize(11)
.fontColor('#C9B8BC')
}
.width('84%')
Row({ space: 8 }) {
Text('2.')
.fontSize(11)
.fontColor('#F59E0B')
Text('费用走平台担保交易,勿私下转账')
.fontSize(11)
.fontColor('#C9B8BC')
}
.width('84%')
Row({ space: 8 }) {
Text('3.')
.fontSize(11)
.fontColor('#F59E0B')
Text('首次见面选择人多的公共场所')
.fontSize(11)
.fontColor('#C9B8BC')
}
.width('84%')
Row({ space: 8 }) {
Text('4.')
.fontSize(11)
.fontColor('#F59E0B')
Text('遇到骚扰可一键举报,平台 24h 处理')
.fontSize(11)
.fontColor('#C9B8BC')
}
.width('84%')
}
Button() {
Text('我知道了')
.fontSize(14)
.fontColor('#1A0E12')
}
.padding({ left: 30, right: 30, top: 10, bottom: 10 })
.backgroundColor('#F59E0B')
.borderRadius(22)
.margin({ bottom: 20 })
.onClick(() => {
this.showSafetyModal = false;
})
}
.width('86%')
.backgroundColor('#241318')
.borderRadius(20)
.alignItems(HorizontalAlign.Center)
.border({ width: 1, color: 'rgba(125,211,252,0.3)' })
}
build() {
Stack({ alignContent: Alignment.Center }) {
Scroll() {
Column() {
Row({ space: 10 }) {
Text('🤝 本周搭子 ' + BUDDY_LIST.length + ' 条')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#FDF2F4')
.layoutWeight(1)
Text('+ 发起')
.fontSize(11)
.fontColor('#1A0E12')
.fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#F59E0B')
.borderRadius(14)
.onClick(() => {
this.showCreateModal = true;
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 })
Column({ space: 8 }) {
ForEach(BUDDY_LIST, (b: BuddyItem) => {
Row({ space: 12 }) {
Column() {
Text(b.avatar)
.fontSize(20)
}
.width(46)
.height(46)
.borderRadius(23)
.backgroundColor('rgba(225,29,72,0.1)')
.justifyContent(FlexAlign.Center)
Column({ space: 4 }) {
Row({ space: 6 }) {
Text(b.user)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor('#FDF2F4')
if (b.verified) {
Text('实名')
.fontSize(7)
.fontColor('#7DD3FC')
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('rgba(125,211,252,0.12)')
.borderRadius(3)
}
}
Text(b.show + ' · ' + b.need)
.fontSize(10)
.fontColor('#9A8F94')
.maxLines(1)
Row({ space: 8 }) {
Text('👥 ' + b.joined + '人已加入')
.fontSize(9)
.fontColor('#F59E0B')
Text(b.gender)
.fontSize(9)
.fontColor('#9A8F94')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('加入')
.fontSize(10)
.fontColor('#1A0E12')
.fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.backgroundColor('#F59E0B')
.borderRadius(12)
.onClick(() => {
this.selectedBuddy = b;
this.showJoinModal = true;
})
}
.width('100%')
.padding(12)
.backgroundColor('#241318')
.borderRadius(14)
}, (b: BuddyItem) => b.id.toString())
}
.width('100%')
.padding({ left: 16, right: 16, top: 10 })
Button() {
Text('🛡 查看搭子安全须知')
.fontSize(12)
.fontColor('#7DD3FC')
}
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.backgroundColor('rgba(125,211,252,0.1)')
.borderRadius(20)
.margin({ top: 10, bottom: 24 })
.onClick(() => {
this.showSafetyModal = true;
})
}
.width('100%')
}
.scrollBar(BarState.Off)
.layoutWeight(1)
.width('100%')
if (this.showBuddy) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showBuddy = false;
})
this.buddyModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
if (this.showCreateModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showCreateModal = false;
})
this.createModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
if (this.showJoinModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showJoinModal = false;
})
this.joinModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
if (this.showSafetyModal) {
Column() {
Column()
.width('100%')
.layoutWeight(1)
.onClick(() => {
this.showSafetyModal = false;
})
this.safetyModal()
}
.width('100%')
.height('100%')
.backgroundColor('rgba(10,4,7,0.8)')
}
}
更多推荐



所有评论(0)