基于HarmonyOS API 24主组件定义多个private修饰的数据数组,在组件实例化时初始化,在整个生命周期中保持不变(除了 watchRecords 被 @State 修饰,可以动态修改)
一、引言:为什么我们需要一个"影视追踪"应用
在当下这个内容爆炸的时代,影视娱乐已经成为人们日常生活中不可或缺的一部分。从院线大片到流媒体独播剧集,从现象级综艺到小众纪录片,每天诞生的优质内容数量远超个人的消费能力。与此同时,观众面临的痛点也愈发明显——看过的电影记不住名字,追了一半的剧集忘了更新到第几集,朋友推荐的片子转头就忘,自己写过的观后感散落在各种聊天记录里无处可寻。

传统的视频平台应用虽然提供了海量的内容资源,但它们的核心目标是"让你看更多内容",而非"帮你管理观影生活"。用户需要一个独立于任何单一平台之上的、属于自己的影视管理工具。这个工具应该能够跨越平台边界,将电影、剧集、综艺等不同形态的内容统一管理;应该能够记录用户的观影足迹,沉淀观影数据;更应该能够通过数据分析帮助用户发现自己偏好的内容类型,从而做出更好的观影决策。
本文将深度剖析一个名为"影视娱乐大全"的完整应用。该应用基于鸿蒙生态的 ArkUI 声明式开发范式构建,采用了 TypeScript 语言(ArkTS 方言),是一个功能完备、界面精致、架构清晰的移动端应用。它不仅涵盖了电影、剧集、综艺三大核心内容板块,还提供了个性化推荐、观影记录管理、数据统计分析等高级功能,堪称一个"迷你版豆瓣+猫眼"的集成体。
通过对这个应用的逐行代码分析,读者将深入理解 ArkUI 框架的核心设计理念,掌握声明式 UI 的组件化开发方法,学习状态管理的最佳实践,了解复杂交互场景下的弹窗系统设计,以及如何在移动端实现数据可视化展示。无论你是鸿蒙开发的初学者,还是希望提升架构设计能力的进阶开发者,都能从这篇解析中获得有价值的知识。
二、技术栈与架构总览
2.1 技术选型背景
该应用基于鸿蒙操作系统(HarmonyOS)的 ArkUI 框架开发。ArkUI 是华为推出的一套声明式 UI 开发范式,其核心思想是:开发者只需要描述界面的当前状态,框架会自动负责将界面从旧状态更新到新状态,无需手动操作 DOM 或调用命令式 API。这种范式大幅减少了界面更新的样板代码,让开发者能够将注意力集中在业务逻辑上。
ArkTS 是 TypeScript 的超集,在保留 TypeScript 类型系统优势的同时,扩展了针对 UI 开发的装饰器语法(如 @Entry、@Component、@State、@Prop、@Builder 等)。这些装饰器将"声明式"的理念具象化为代码层面的语法糖,使得 UI 组件的定义、状态的管理、组件间的通信都有了标准化的表达方式。
2.2 应用整体架构
从架构层面来看,该应用采用了经典的"主组件 + 子组件"分层模式。整个应用由一个入口主组件(MovieEntertainment)和五个功能子组件(RecommendContent、MovieContent、TvContent、VarietyContent、ProfileContent)构成。主组件负责全局状态的维护、Tab 切换的调度、弹窗的管理,以及将数据通过 @Prop 向下传递给子组件。子组件则专注于自身领域的界面渲染和局部交互,通过回调函数将用户操作事件向上汇报给主组件处理。
这种"数据向下流动、事件向上冒泡"的单向数据流模式,确保了状态的可追溯性和可预测性,是声明式 UI 架构的精髓所在。
三、数据模型设计:用接口定义业务语言
3.1 电影数据模型
任何一个应用的根基都是数据。在编写界面之前,首先需要定义清楚业务领域中的数据结构。该应用使用 TypeScript 的 interface 来定义所有数据模型,这种方式既保证了类型安全,又不会引入运行时的额外开销。
interface MovieItem {
id: number
title: string
genre: string
year: number
director: string
actors: string[]
rating: number
duration: number
boxOffice: string
tags: string[]
poster: string
description: string
isWatched: boolean
isFavorite: boolean
watchCount: number
}

让我们逐字段分析这个接口的设计意图:
id: number——唯一标识符,用于ForEach列表渲染时的 key 生成和数据操作时的精确定位。在 ArkUI 中,列表渲染必须提供唯一的 key 函数,id字段承担了这个职责。title: string——影片标题,作为最核心的展示信息。genre: string——类型分类(如"动作"“科幻”“悬疑”),用于分类筛选功能。这里用字符串而非枚举,是为了保持数据来源的灵活性。year: number——上映年份,使用数值类型便于排序和比较。director: string——导演姓名,在详情页展示。actors: string[]——演员列表,使用字符串数组是因为一部电影通常有多位主演,需要横向滚动展示。rating: number——评分,采用 0-10 的十分制(与豆瓣一致),数值类型便于计算和比较。duration: number——片长(分钟),数值类型,在详情页格式化为"173分钟"。boxOffice: string——票房,使用字符串而非数值,因为票房数据的单位不统一(有的用"亿",有的用"全球XX亿$"),用字符串可以保留原始格式。tags: string[]——标签数组(如"硬核"“特效”“续作”),在详情页以胶囊标签形式展示。poster: string——海报,这里巧妙地使用了 Emoji 字符(如"🌌")作为海报占位符,既避免了图片资源的依赖,又保证了视觉效果。description: string——剧情简介。isWatched: boolean——是否已观看,布尔状态标记。isFavorite: boolean——是否已收藏。watchCount: number——观看次数,用于统计用户对某部影片的重温频率。
3.2 剧集数据模型
剧集与电影虽然同属影视内容,但其业务特征有显著差异。剧集有"集数"概念,有"更新状态"概念,有"播出平台"概念。因此需要独立的数据模型。
interface TvItem {
id: number
title: string
genre: string
year: number
director: string
actors: string[]
rating: number
episodes: number
currentEpisode: number
platform: string
tags: string[]
poster: string
description: string
isFollowing: boolean
updateDay: string
}

与 MovieItem 相比,关键差异字段如下:
episodes: number——总集数。currentEpisode: number——当前已观看集数。这两个字段组合后,可以在界面上展示"20/30集"的进度信息,帮助用户追踪追剧进度。platform: string——播出平台(如"爱奇艺"“腾讯”),这在多平台并行的国内市场尤为重要。isFollowing: boolean——是否在追,替代了电影中的isFavorite,用"追"这个动词更符合剧集的场景语境。updateDay: string——更新状态,取值为"更新中"或"已完结"。这个字段在 UI 中会动态控制颜色——更新中的显示为绿色,已完结的显示为灰色,给予用户直观的状态感知。
3.3 综艺数据模型
综艺节目又是一个独特的品类。它有"季"的概念,有"主持人"和"嘉宾"的区分,有节目类型(真人秀、脱口秀、音乐等)的分类。
interface VarietyItem {
id: number
title: string
type: string
host: string
guests: string[]
rating: number
season: number
episodes: number
platform: string
tags: string[]
poster: string
description: string
isFollowing: boolean
}

这里的设计要点:
type: string——节目类型(如"真人秀"“脱口秀”),替代了电影和剧集中的genre字段。使用不同字段名是为了语义上的精确性。host: string——主持人,单一字符串而非数组,因为一档综艺通常有一位核心主持人。guests: string[]——嘉宾阵容,使用数组,因为嘉宾通常有多位。season: number——第几季,综艺节目经常有多季,这个字段在界面上会以"S3"的形式展示。episodes: number——总期数,综艺节目用"期"而非"集"。
3.4 观影记录与辅助数据模型
除了三大核心内容模型外,应用还定义了若干辅助数据模型来支撑"我的"页面的功能。
interface WatchRecord {
id: number
title: string
genre: string
rating: number
comment: string
date: string
poster: string
}

WatchRecord 是用户观影记录的精简模型。它不需要包含导演、演员等完整信息,只需要标题、类型、评分、观后感、日期和海报。这是一种"视图模型"(View Model)的设计思路——根据使用场景裁剪数据,避免冗余字段的传递。
interface GenreTag {
name: string
color: string
}

GenreTag 是一个通用的标签模型,包含名称和颜色两个字段。它在应用中被复用于电影类型、剧集类型、综艺类型、类型偏好统计等多个场景。这种"小而通用"的模型设计体现了良好的抽象能力。
interface RatingBar {
star: number
count: number
percent: number
}

RatingBar 用于评分分布的可视化展示。star 表示星级(1-5),count 表示该星级的人数,percent 表示该星级占总人数的百分比。这三个字段共同驱动一个进度条式的可视化组件。
interface StatItem {
label: string
value: string
color: string
}

StatItem 用于统计卡片的展示,包含标签名、数值和主题色。在"我的"页面顶部,四个统计卡片(本月观看、平均评分、想看收藏、观影时长)就是由这个模型驱动的。
四、主组件架构与状态管理
4.1 组件声明与入口标记
@Entry
@Component
struct MovieEntertainment {

这三行代码是整个应用的入口。@Entry 装饰器标记此组件为页面入口组件,意味着它是路由导航的根节点。@Component 装饰器声明这是一个自定义组件,可以被其他组件引用(虽然作为入口组件,它本身不会被引用,但这个装饰器是必须的)。struct 关键字定义了一个结构体——在 ArkTS 中,所有 UI 组件都以 struct 的形式定义。
4.2 状态变量体系
ArkUI 的状态管理是其核心特性之一。通过不同的装饰器,可以精确控制状态的响应范围和传递方向。
@State currentTab: number = 0
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedMovie: MovieItem | null = null
@State selectedTv: TvItem | null = null
@State selectedVariety: VarietyItem | null = null
@State selectedRecord: WatchRecord | null = null
@State selectedGenre: string = '全部'
@State detailType: string = 'movie'
@State newTitle: string = ''
@State newGenre: string = '动作'
@State newRating: number = 5
@State newComment: string = ''
@State editComment: string = ''
@State movieGenreIndex: number = 0
@State tvGenreIndex: number = 0
@State varietyTypeIndex: number = 0

@State 装饰器标记的变量是组件的内部状态。当这些变量的值发生变化时,ArkUI 框架会自动触发组件的重新渲染,将界面更新到最新状态。让我们逐一理解这些状态的设计意图。
Tab 导航状态:
currentTab: number = 0——当前激活的 Tab 索引。初始值为 0,即默认显示"推荐"页。取值范围 0-4,分别对应推荐、电影、剧集、综艺、我的五个页面。这个变量驱动了整个主内容区域的条件渲染逻辑。
弹窗显隐状态:
showAddModal、showEditModal、showDeleteConfirm、showDetailModal——四个布尔值分别控制新增记录弹窗、编辑评论弹窗、删除确认弹窗和详情弹窗的显隐。这种"一个弹窗一个布尔值"的设计简单直观,易于理解和维护。
选中数据状态:
selectedMovie、selectedTv、selectedVariety、selectedRecord——四个可空类型变量,用于存储当前被用户点击选中的数据项。当用户点击某部电影时,该电影数据会被赋值给selectedMovie,同时showDetailModal被置为true,详情弹窗便可以读取selectedMovie的内容进行展示。使用| null联合类型,是为了在弹窗未打开时表示"无选中项"的状态。
表单状态:
newTitle、newGenre、newRating、newComment——这四个变量构成了新增观影记录的表单状态。newGenre默认值为"动作",newRating默认值为 5,为用户提供了合理的初始选项,减少了操作成本。editComment——编辑评论时的文本状态,独立于newComment,避免互相干扰。
筛选状态:
movieGenreIndex、tvGenreIndex、varietyTypeIndex——三个数值索引,分别记录电影、剧集、综艺三个页面的当前筛选标签位置。索引 0 始终代表"全部",因此初始值 0 表示不筛选。使用索引而非名称,是因为标签列表中的名称可能重复,而索引是唯一的。
4.3 静态数据源
主组件中定义了多个 private 修饰的数据数组,这些数据在组件实例化时初始化,在整个生命周期中保持不变(除了 watchRecords 被 @State 修饰,可以动态修改)。
private movieGenres: GenreTag[] = [
{ name: '全部', color: '#1A237E' },
{ name: '动作', color: '#E91E63' },
{ name: '喜剧', color: '#FFD600' },
{ name: '科幻', color: '#00E676' },
{ name: '悬疑', color: '#7C4DFF' },
{ name: '爱情', color: '#FF4081' },
{ name: '动画', color: '#00BCD4' },
{ name: '恐怖', color: '#5D4037' },
{ name: '纪录片', color: '#4CAF50' }
]
这是电影类型的标签配置数组。每个类型都关联了一个主题色,这个颜色会在筛选标签的选中态、统计图表的进度条等多个地方复用。颜色选择遵循了 Material Design 的色彩体系——"动作"用热烈的粉红色,"喜剧"用明亮的黄色,"科幻"用科技感的绿色,"悬疑"用神秘的紫色,每个颜色都与其类型名称有语义上的关联。
private tvGenres: GenreTag[] = [
{ name: '全部', color: '#1A237E' },
{ name: '悬疑', color: '#7C4DFF' },
{ name: '古装', color: '#8D6E63' },
{ name: '都市', color: '#3949AB' },
{ name: '科幻', color: '#00E676' },
{ name: '喜剧', color: '#FFD600' },
{ name: '爱情', color: '#FF4081' }
]
剧集类型标签。与电影类型相比,去掉了"恐怖"“纪录片”“动画”,增加了"古装"和"都市"。这反映了国内剧集市场的类型分布特征——古装剧和都市剧是两大主力类型。
private varietyTypes: GenreTag[] = [
{ name: '全部', color: '#1A237E' },
{ name: '真人秀', color: '#E91E63' },
{ name: '脱口秀', color: '#FFD600' },
{ name: '音乐', color: '#00E676' },
{ name: '竞技', color: '#FF6D00' }
]
综艺类型标签。综艺的分类维度与电影、剧集完全不同,按节目形态分为真人秀、脱口秀、音乐、竞技四类。
4.4 统计数据配置
private stats: StatItem[] = [
{ label: '本月观看', value: '23', color: '#1A237E' },
{ label: '平均评分', value: '8.2', color: '#FFD600' },
{ label: '想看收藏', value: '47', color: '#E91E63' },
{ label: '观影时长', value: '68h', color: '#00E676' }
]
这是"我的"页面顶部统计卡片的配置数据。四个指标分别从"量"(本月观看次数)、“质”(平均评分)、“意愿”(想看收藏数)、“时”(累计观影时长)四个维度量化用户的观影行为。每个指标使用不同的主题色,形成视觉上的区分。
private ratingBars: RatingBar[] = [
{ star: 5, count: 1280, percent: 100 },
{ star: 4, count: 860, percent: 67 },
{ star: 3, count: 420, percent: 33 },
{ star: 2, count: 180, percent: 14 },
{ star: 1, count: 60, percent: 5 }
]
评分分布数据。五星最多(1280人),一星最少(60人),呈现出健康的"右偏分布",说明用户整体评价偏正面。percent 字段是相对于五星人数的百分比,用于驱动进度条的宽度。
private genreStats: GenreTag[] = [
{ name: '动作', color: '#E91E63' },
{ name: '喜剧', color: '#FFD600' },
{ name: '科幻', color: '#00E676' },
{ name: '悬疑', color: '#7C4DFF' },
{ name: '爱情', color: '#FF4081' },
{ name: '动画', color: '#00BCD4' }
]
private genrePercents: number[] = [28, 22, 18, 15, 10, 7]
类型偏好统计数据。genreStats 定义了类型名称和颜色,genrePercents 定义了对应的百分比。两个数组通过索引关联——动作 28%、喜剧 22%、科幻 18%、悬疑 15%、爱情 10%、动画 7%。在界面中,进度条宽度通过 genrePercents[index] * 3 计算得出,乘以 3 是为了将百分比值放大到合适的视觉宽度。
4.5 观影记录数据
@State watchRecords: WatchRecord[] = [
{ id: 1, title: '流浪地球2', genre: '科幻', rating: 9, comment: '硬核科幻,中国电影的骄傲', date: '2026-08-01', poster: '🌌' },
{ id: 2, title: '满江红', genre: '悬疑', rating: 8, comment: '层层反转,节奏紧凑', date: '2026-07-28', poster: '⚔️' },
{ id: 3, title: '长安三万里', genre: '动画', rating: 9, comment: '诗画交融,文化盛宴', date: '2026-07-25', poster: '📜' },
{ id: 4, title: '封神第一部', genre: '动作', rating: 8, comment: '视觉震撼,史诗气质', date: '2026-07-20', poster: '🗡️' },
{ id: 5, title: '孤注一掷', genre: '悬疑', rating: 7, comment: '反诈题材,发人深省', date: '2026-07-15', poster: '🎰' }
]
watchRecords 使用 @State 修饰,这意味着它是可变状态。用户可以通过新增、编辑、删除操作来修改这个数组,每次修改都会自动触发"我的"页面的重新渲染。初始数据包含了五条观影记录,每条都有标题、类型、评分(满分10分)、观后感、日期和海报 Emoji。
4.6 内容数据源
应用内置了丰富的电影、剧集、综艺数据。电影数据包含 26 部影片,涵盖 2023 年的国内外热门大片以及部分经典作品。剧集数据包含 21 部剧集,覆盖了悬疑、古装、都市、科幻、爱情、喜剧等多种类型。综艺数据包含 16 档节目,涵盖真人秀、脱口秀、音乐、竞技等形态。
private movies: MovieItem[] = [
{ id: 1, title: '流浪地球2', genre: '科幻', year: 2023, director: '郭帆',
actors: ['吴京', '刘德华', '李雪健'], rating: 8.3, duration: 173,
boxOffice: '40.2亿', tags: ['硬核', '特效', '续作'], poster: '🌌',
description: '太阳即将毁灭,人类开启流浪地球计划,寻找新的家园。',
isWatched: true, isFavorite: true, watchCount: 3 },
// ... 更多电影数据
]
以上是电影数据的示例结构。每部电影都包含了完整的元信息。值得注意的是 isWatched、isFavorite、watchCount 三个字段记录了用户对该电影的交互状态——已观看、已收藏、观看次数。这些状态字段为未来的个性化推荐和数据分析提供了数据基础。
五、生命周期与工具方法
5.1 生命周期回调
aboutToAppear() {
this.recommendMovies = this.movies.filter((m: MovieItem) => m.rating >= 8.0).slice(0, 8)
}
aboutToAppear 是 ArkUI 组件的生命周期回调方法,在组件创建后、build 方法执行前被调用。这里是进行数据初始化的理想位置。
这段代码的逻辑是:从全部电影数据中筛选出评分大于等于 8.0 的高分电影,然后取前 8 部,赋值给 recommendMovies 数组。这个数组将在推荐页面的"热门推荐"横滚区域展示。通过在生命周期中预计算推荐数据,避免了在 build 方法中重复计算,提升了渲染性能。
5.2 评分星级生成方法
getRatingStars(rating: number): string {
let stars: string = ''
let full: number = Math.floor(rating / 2)
let i: number = 0
for (i = 0; i < full; i++) {
stars += '⭐'
}
if (rating / 2 - full >= 0.5) {
stars += '🌟'
}
return stars
}
这是一个将数值评分转换为星级 Emoji 字符串的工具方法。由于应用的评分采用 10 分制,而星级展示通常采用 5 星制,因此需要除以 2 进行转换。
逐行解析:
let stars: string = ''——初始化空字符串,用于累积星星字符。let full: number = Math.floor(rating / 2)——计算满星数量。例如评分 8.3 除以 2 得 4.15,向下取整为 4 颗满星。for (i = 0; i < full; i++) { stars += '⭐' }——循环添加满星 Emoji。if (rating / 2 - full >= 0.5)——判断是否有半星。rating / 2 - full得到小数部分,如果大于等于 0.5 则添加一颗半星(用 🌟 表示)。return stars——返回最终的星级字符串。
例如评分 8.3 会生成"⭐⭐⭐⭐🌟"(四颗满星加一颗半星),评分 7.6 会生成"⭐⭐⭐🌟"(三颗满星加一颗半星)。
5.3 筛选过滤方法
应用为三种内容类型各提供了一个筛选方法,逻辑结构一致。
getFilteredMovies(): MovieItem[] {
if (this.selectedGenre === '全部' || this.movieGenreIndex === 0) {
return this.movies
}
return this.movies.filter((m: MovieItem) => m.genre === this.movieGenres[this.movieGenreIndex].name)
}
getFilteredMovies 方法的逻辑:
- 首先判断
selectedGenre是否为"全部"或者movieGenreIndex是否为 0(索引 0 对应"全部"标签)。如果是,直接返回完整的电影列表。 - 否则,根据
movieGenreIndex从movieGenres数组中取出对应的类型名称,然后用filter方法筛选出该类型的所有电影。
getFilteredTvShows(): TvItem[] {
if (this.tvGenreIndex === 0) {
return this.tvShows
}
return this.tvShows.filter((t: TvItem) => t.genre === this.tvGenres[this.tvGenreIndex].name)
}
getFilteredVariety(): VarietyItem[] {
if (this.varietyTypeIndex === 0) {
return this.varietyShows
}
return this.varietyShows.filter((v: VarietyItem) => v.type === this.varietyTypes[this.varietyTypeIndex].name)
}
剧集和综艺的筛选方法结构完全一致。唯一的区别是综艺使用 v.type 而非 v.genre 进行匹配,因为综艺数据模型中使用 type 字段表示节目类型。
六、主界面布局解析
6.1 整体布局结构
build() {
Column() {
// 顶部深色渐变区域
Stack({ alignContent: Alignment.TopStart }) {
// ...
}
.width('100%')
.height(120)
// Tab内容区
Column() {
// ...
}
.layoutWeight(1)
.width('100%')
.backgroundColor('#E8EAF6')
// 底部Tab栏
Row() {
// ...
}
.width('100%')
.height(60)
.backgroundColor('#FFFFFF')
}
.width('100%')
.height('100%')
.backgroundColor('#E8EAF6')
.bindContentCover($$this.showDetailModal, this.detailModalBuilder)
.bindContentCover($$this.showAddModal, this.addModalBuilder)
.bindContentCover($$this.showEditModal, this.editModalBuilder)
.bindContentCover($$this.showDeleteConfirm, this.deleteConfirmBuilder)
}
整个界面的骨架是一个纵向排列的 Column,从上到下依次是:顶部标题区(固定高度 120)、内容区(layoutWeight(1) 占据剩余空间)、底部 Tab 栏(固定高度 60)。
在 Column 的最外层,通过四个 bindContentCover 绑定了四个全屏覆盖弹窗。$$ 语法表示双向绑定——当弹窗内部修改了对应的布尔状态时,外层会感知到变化并关闭弹窗。这种声明式的弹窗管理方式是 ArkUI 的特色,无需手动调用 show/hide 方法。
6.2 顶部渐变标题区
Stack({ alignContent: Alignment.TopStart }) {
Column() {
Text('🎬 影视娱乐大全')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 48, left: 20, bottom: 4 })
Text('发现好电影 · 追剧不迷路')
.fontSize(13)
.fontColor('#B3B3CC')
.margin({ left: 20, bottom: 16 })
}
.width('100%')
.height(120)
.linearGradient({
angle: 180,
colors: [['#1A237E', 0.0], ['#3949AB', 1.0]]
})
}
.width('100%')
.height(120)
顶部区域使用 Stack 容器叠加了一个 Column,Column 内部包含标题和副标题两行文字。最关键的视觉效果是 linearGradient 线性渐变背景——从顶部的深蓝色 #1A237E(靛蓝 900)渐变到底部的稍浅蓝色 #3949AB(靛蓝 400),角度 180 度表示从上到下。
标题"🎬 影视娱乐大全"使用 22 号粗体白色字体,top: 48 的上边距为状态栏留出了空间。副标题"发现好电影 · 追剧不迷路"使用 13 号字、浅紫色 #B3B3CC,在深色背景上形成柔和的对比。
6.3 Tab 内容区条件渲染
Column() {
if (this.currentTab === 0) {
RecommendContent({
recommendMovies: this.recommendMovies,
movies: this.movies,
tvShows: this.tvShows,
varietyShows: this.varietyShows,
getRatingStars: (r: number) => this.getRatingStars(r),
onMovieClick: (m: MovieItem) => {
this.selectedMovie = m
this.detailType = 'movie'
this.showDetailModal = true
},
onTvClick: (t: TvItem) => {
this.selectedTv = t
this.detailType = 'tv'
this.showDetailModal = true
},
onVarietyClick: (v: VarietyItem) => {
this.selectedVariety = v
this.detailType = 'variety'
this.showDetailModal = true
}
})
} else if (this.currentTab === 1) {
MovieContent({
movies: this.getFilteredMovies(),
movieGenres: this.movieGenres,
movieGenreIndex: this.movieGenreIndex,
getRatingStars: (r: number) => this.getRatingStars(r),
onGenreChange: (index: number) => {
this.movieGenreIndex = index
},
onMovieClick: (m: MovieItem) => {
this.selectedMovie = m
this.detailType = 'movie'
this.showDetailModal = true
}
})
} else if (this.currentTab === 2) {
// 剧集 Tab - 结构类似
} else if (this.currentTab === 3) {
// 综艺 Tab - 结构类似
} else {
// 我的 Tab
ProfileContent({
watchRecords: this.watchRecords,
stats: this.stats,
ratingBars: this.ratingBars,
genreStats: this.genreStats,
genrePercents: this.genrePercents,
getRatingStars: (r: number) => this.getRatingStars(r),
onAddRecord: () => {
this.showAddModal = true
},
onEditRecord: (r: WatchRecord) => {
this.selectedRecord = r
this.editComment = r.comment
this.showEditModal = true
},
onDeleteRecord: (r: WatchRecord) => {
this.selectedRecord = r
this.showDeleteConfirm = true
}
})
}
}
这是条件渲染的核心逻辑。通过 if-else if-else 链式判断 currentTab 的值,动态渲染对应的子组件。每次 currentTab 变化时,ArkUI 会自动销毁旧组件、创建新组件,这个过程对开发者是透明的。
在子组件的构造参数中,可以看到两种类型的数据传递:
- 数据向下传递:如
movies、tvShows、stats等,将主组件的数据传递给子组件进行展示。 - 回调向上传递:如
onMovieClick、onGenreChange、onAddRecord等,将事件处理函数传递给子组件。当子组件内部发生用户交互时,调用这些回调函数,由主组件统一处理状态变更。
以 onMovieClick 回调为例,当用户在推荐页或电影页点击某部电影时,子组件调用 this.onMovieClick(movie),主组件接收后执行三步操作:将电影数据赋值给 selectedMovie、设置 detailType 为"movie"、将 showDetailModal 置为 true。这三步操作完成后,详情弹窗会自动弹出并展示该电影的完整信息。
6.4 底部 Tab 导航栏
Row() {
ForEach([0, 1, 2, 3, 4], (tabIndex: number) => {
Column() {
Text(['🔥', '🎬', '📺', '🎭', '👤'][tabIndex])
.fontSize(24)
Text(['推荐', '电影', '剧集', '综艺', '我的'][tabIndex])
.fontSize(11)
.fontColor(this.currentTab === tabIndex ? '#1A237E' : '#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.currentTab = tabIndex
})
}, (tabIndex: number) => tabIndex.toString())
}
.width('100%')
.height(60)
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: '#E0E0E0' })
底部 Tab 栏使用 Row 容器横向排列五个 Tab 项,每个项通过 layoutWeight(1) 等分宽度。每个 Tab 项包含一个 Emoji 图标(24号字)和一行文字标签(11号字)。
关键设计在于颜色的动态切换:fontColor(this.currentTab === tabIndex ? '#1A237E' : '#999999')。当前选中的 Tab 文字显示为深蓝色(与整体主题色一致),未选中的显示为灰色。这种高亮反馈让用户清楚地知道自己在哪个页面。
顶部边框 border({ width: { top: 1 }, color: '#E0E0E0' }) 在 Tab 栏上方添加了一条细分隔线,使其与内容区在视觉上分离。onClick 回调中只需一行 this.currentTab = tabIndex 即可完成页面切换——声明式 UI 的威力在这里体现得淋漓尽致,开发者只需修改状态变量,框架自动完成界面的更新。
七、弹窗系统设计
弹窗是移动端应用中处理"临时性、模态化"交互的重要手段。该应用设计了四个弹窗:详情弹窗、新增记录弹窗、编辑评论弹窗和删除确认弹窗。它们通过 @Builder 装饰器定义为构建器函数,然后通过 bindContentCover 绑定到主组件上。
7.1 详情弹窗
详情弹窗是应用中最复杂的弹窗,因为它需要根据 detailType 的值动态展示电影、剧集或综艺三种不同的详情内容。
@Builder
detailModalBuilder() {
Column() {
// 顶部拖拽条
Row() {
Column().width(40).height(4).backgroundColor('#CCCCCC').borderRadius(2)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 8 })
Scroll() {
Column() {
if (this.detailType === 'movie' && this.selectedMovie !== null) {
// 电影详情内容
} else if (this.detailType === 'tv' && this.selectedTv !== null) {
// 剧集详情内容
} else if (this.detailType === 'variety' && this.selectedVariety !== null) {
// 综艺详情内容
}
}
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
// 关闭按钮
Button('关闭')
.width('90%')
.height(44)
.backgroundColor('#1A237E')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ bottom: 24, top: 10 })
.onClick(() => {
this.showDetailModal = false
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '85%' })
}
弹窗的整体结构分为三层:顶部拖拽条(视觉指示器,告诉用户这是一个可滑动的底部弹窗)、中间可滚动内容区(Scroll 包裹的 Column)、底部关闭按钮。
constraintSize({ maxHeight: '85%' }) 限制了弹窗的最大高度为屏幕的 85%,这样即使在内容很多的情况下,弹窗也不会完全遮挡主界面,保持了上下文的可见性。
7.1.1 电影详情内容
Row() {
Text(this.selectedMovie!.poster)
.fontSize(56)
.width(100).height(140)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Text(this.selectedMovie!.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`${this.selectedMovie!.year} · ${this.selectedMovie!.genre} · ${this.selectedMovie!.duration}分钟`)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
Text(this.selectedMovie!.boxOffice)
.fontSize(13)
.fontColor('#E91E63')
.margin({ top: 6 })
Row() {
Text(this.getRatingStars(this.selectedMovie!.rating))
.fontSize(12)
Text(this.selectedMovie!.rating.toFixed(1))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD600')
.margin({ left: 8 })
}
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
.layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 10 })
这是电影详情的头部区域。左侧是海报(100x140 的 Emoji 展示区,灰色背景圆角),右侧是标题、年份/类型/片长、票房和评分的纵向排列。
! 非空断言操作符的使用值得注意。由于 selectedMovie 被声明为 MovieItem | null 可空类型,在条件判断 this.selectedMovie !== null 之后,TypeScript 理论上应该自动收窄类型。但 ArkTS 在模板表达式中的类型收窄能力有限,因此需要使用 ! 显式告诉编译器"此处一定非空"。
票房使用粉红色 #E91E63 突出显示,评分数值使用黄色 #FFD600 突出显示,星级 Emoji 旁边跟着数值,形成"⭐⭐⭐⭐🌟 8.3"的视觉组合。
接下来是标签展示区域:
Row() {
ForEach(this.selectedMovie!.tags, (tag: string) => {
Text(tag)
.fontSize(11)
.fontColor('#3949AB')
.backgroundColor('#E8EAF6')
.borderRadius(10)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.margin({ right: 8 })
}, (tag: string) => tag)
}
.width('100%')
.padding({ left: 20, top: 12 })
标签以胶囊形态展示,深蓝色文字配浅蓝色背景,每个标签右侧留 8 的间距。ForEach 的 key 函数直接使用 tag 字符串本身。
剧情简介区域:
Text('剧情简介')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Text(this.selectedMovie!.description)
.fontSize(13)
.fontColor('#555555')
.lineHeight(22)
.margin({ top: 8, left: 20, right: 20 })
简介区域采用"标题 + 正文"的经典排版。标题 15 号粗体深蓝色,正文 13 号深灰色,行高 22 提供了舒适的阅读间距。
演职人员区域:
Text('演职人员')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Text(`导演:${this.selectedMovie!.director}`)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6, left: 20 })
Scroll() {
Row() {
ForEach(this.selectedMovie!.actors, (actor: string) => {
Column() {
Text('🎭')
.fontSize(32)
.width(56).height(56)
.backgroundColor('#E8EAF6')
.borderRadius(28)
.textAlign(TextAlign.Center)
Text(actor)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 4 })
.maxLines(1)
}
.margin({ right: 14 })
}, (actor: string) => actor)
}
.padding({ left: 20, right: 20, top: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
演员列表使用横向滚动的 Scroll + Row 组合实现。每个演员是一个 56x56 的圆形头像(Emoji 占位)加下方姓名的组合。scrollBar(BarState.Off) 隐藏了滚动条,保持界面的简洁。
评分分布区域:
Text('评分分布')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Column() {
ForEach(this.ratingBars, (bar: RatingBar) => {
Row() {
Text(`${bar.star}星`)
.fontSize(12)
.fontColor('#666666')
.width(36)
Column() {
Column()
.width(`${bar.percent}%`)
.height(12)
.backgroundColor(['#E91E63', '#FF6D00', '#FFD600', '#8BC34A', '#00BCD4'][5 - bar.star])
.borderRadius(6)
}
.layoutWeight(1)
.height(12)
.backgroundColor('#F0F0F0')
.borderRadius(6)
.margin({ left: 8, right: 8 })
Text(`${bar.count}`)
.fontSize(11)
.fontColor('#999999')
.width(50)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6 })
.padding({ left: 20, right: 20 })
}, (bar: RatingBar) => bar.star.toString())
}
.margin({ top: 8, bottom: 20 })
评分分布使用水平进度条可视化。每一行包含:星级标签(“5星”)、进度条(内层 Column 的宽度由 percent 驱动,外层 Column 作为灰色轨道)、人数。
进度条颜色通过数组索引动态选择:['#E91E63', '#FF6D00', '#FFD600', '#8BC34A', '#00BCD4'][5 - bar.star]。当 star 为 5 时,索引为 0(粉红色);当 star 为 1 时,索引为 4(青色)。这样高评分用暖色,低评分用冷色,形成了直观的颜色梯度。
7.1.2 剧集与综艺详情
剧集和综艺的详情内容结构与电影类似,但根据各自的数据模型做了适配。剧集详情会展示"当前集数/总集数"和"更新状态",综艺详情会展示"第几季"和"主持人"。这里不再逐一展开,核心的布局模式和设计理念是一致的。
7.2 新增观影记录弹窗
@Builder
addModalBuilder() {
Column() {
Text('➕ 新增观影记录')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 24, bottom: 20 })
Column() {
Text('影片名称')
.fontSize(14)
.fontColor('#333333')
.alignSelf(ItemAlign.Start)
TextInput({ placeholder: '请输入影片名称', text: this.newTitle })
.width('100%')
.height(44)
.backgroundColor('#F5F5F5')
.borderRadius(10)
.margin({ top: 8 })
.onChange((val: string) => {
this.newTitle = val
})
新增弹窗是一个表单界面,包含影片名称输入框、类型选择器、评分选择器和观后感文本域。
影片名称使用 TextInput 组件,placeholder 提供输入提示,text 绑定到 newTitle 状态变量,onChange 回调实时更新状态。背景色 #F5F5F5 和圆角 10 提供了柔和的输入区域视觉。
类型选择器:
Text('类型')
.fontSize(14)
.fontColor('#333333')
.margin({ top: 16 })
.alignSelf(ItemAlign.Start)
Row() {
ForEach(['动作', '喜剧', '科幻', '悬疑', '爱情', '动画'], (g: string) => {
Text(g)
.fontSize(12)
.fontColor(this.newGenre === g ? '#FFFFFF' : '#3949AB')
.backgroundColor(this.newGenre === g ? '#1A237E' : '#E8EAF6')
.borderRadius(14)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.newGenre = g
})
}, (g: string) => g)
}
.width('100%')
.margin({ top: 8 })
类型选择器使用一排可点击的胶囊标签实现。选中的标签显示为深蓝色背景白色文字,未选中的显示为浅蓝色背景深蓝色文字。点击时更新 newGenre 状态,界面自动刷新选中态。这是一种比下拉选择器更直观、更适合移动端的选择交互模式。
评分选择器:
Text('评分')
.fontSize(14)
.fontColor('#333333')
.margin({ top: 16 })
.alignSelf(ItemAlign.Start)
Row() {
ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], (s: number) => {
Text(`${s}`)
.fontSize(14)
.fontColor(this.newRating === s ? '#FFFFFF' : '#FFD600')
.backgroundColor(this.newRating === s ? '#FFD600' : '#FFF8E1')
.borderRadius(16)
.width(32).height(32)
.textAlign(TextAlign.Center)
.margin({ right: 6 })
.onClick(() => {
this.newRating = s
})
}, (s: number) => s.toString())
}
.width('100%')
.margin({ top: 8 })
评分选择器提供了 1-10 的十个圆形数字按钮。选中的显示为黄色背景白色文字,未选中的显示为浅黄色背景黄色文字。黄色系的选择呼应了"星级"的视觉语义。
保存逻辑:
Button('保存')
.layoutWeight(1)
.height(44)
.backgroundColor('#1A237E')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ left: 10 })
.onClick(() => {
let newId: number = this.watchRecords.length + 1
this.watchRecords.unshift({
id: newId,
title: this.newTitle || '未命名',
genre: this.newGenre,
rating: this.newRating,
comment: this.newComment || '暂无评论',
date: '2026-08-06',
poster: '🎬'
})
this.showAddModal = false
this.newTitle = ''
this.newComment = ''
this.newRating = 5
})
保存按钮的 onClick 回调执行以下操作:
- 生成新 ID(基于当前记录数 +1)。
- 使用
unshift方法将新记录插入数组头部,使其在列表中显示在最前面。 - 对
title和comment做了空值保护——如果用户未输入,则使用"未命名"和"暂无评论"作为默认值。 - 关闭弹窗并重置表单状态,为下次新增做好准备。
7.3 编辑评论弹窗
@Builder
editModalBuilder() {
Column() {
Text('✏️ 编辑评论')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 24, bottom: 8 })
if (this.selectedRecord !== null) {
Text(this.selectedRecord!.title)
.fontSize(15)
.fontColor('#666666')
.margin({ bottom: 16 })
Text('观后感')
.fontSize(14)
.fontColor('#333333')
.margin({ bottom: 8 })
.alignSelf(ItemAlign.Start)
TextArea({ text: this.editComment })
.width('90%')
.height(100)
.backgroundColor('#F5F5F5')
.borderRadius(10)
.onChange((val: string) => {
this.editComment = val
})
}
编辑弹窗的结构比新增弹窗简单——只需要一个文本域来编辑观后感。顶部显示被编辑记录的标题作为上下文提示,TextArea 绑定到 editComment 状态。
保存逻辑:
Button('保存')
.layoutWeight(1)
.height(44)
.backgroundColor('#00E676')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ left: 10 })
.onClick(() => {
if (this.selectedRecord !== null) {
let idx: number = this.watchRecords.findIndex((r: WatchRecord) => r.id === this.selectedRecord!.id)
if (idx >= 0) {
this.watchRecords[idx].comment = this.editComment
}
}
this.showEditModal = false
})
保存时通过 findIndex 根据记录 ID 查找在数组中的位置,然后直接修改该位置的 comment 字段。这种"查找-修改"的模式确保了即使记录顺序发生变化,也能精确定位到正确的记录。注意保存按钮使用了绿色 #00E676 而非主题深蓝色,通过颜色差异暗示"编辑"与"新增"的不同操作语义。
7.4 删除确认弹窗
@Builder
deleteConfirmBuilder() {
Column() {
Text('🗑️ 确认删除')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E91E63')
.margin({ top: 28, bottom: 12 })
if (this.selectedRecord !== null) {
Text(`确定要删除「${this.selectedRecord!.title}」的观影记录吗?`)
.fontSize(14)
.fontColor('#666666')
.textAlign(TextAlign.Center)
.lineHeight(22)
.margin({ bottom: 20, left: 30, right: 30 })
}
Row() {
Button('取消')
.layoutWeight(1)
.height(44)
.backgroundColor('#F5F5F5')
.fontColor('#666666')
.fontSize(15)
.borderRadius(22)
.margin({ right: 10 })
.onClick(() => {
this.showDeleteConfirm = false
})
Button('删除')
.layoutWeight(1)
.height(44)
.backgroundColor('#E91E63')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ left: 10 })
.onClick(() => {
if (this.selectedRecord !== null) {
this.watchRecords = this.watchRecords.filter((r: WatchRecord) => r.id !== this.selectedRecord!.id)
}
this.showDeleteConfirm = false
})
}
.width('85%')
.margin({ bottom: 24 })
}
.width('85%')
.height('auto')
.backgroundColor('#FFFFFF')
.borderRadius(20)
}
删除确认弹窗是所有弹窗中最简洁的。标题使用粉红色 #E91E63(与"删除"按钮同色),警示用户这是一个破坏性操作。提示文本中嵌入了被删除记录的标题,让用户明确知道将要删除的内容。
删除逻辑使用 filter 方法创建一个不包含目标记录的新数组,然后赋值给 watchRecords。这种不可变更新的方式确保了 ArkUI 能够正确检测到状态变化并触发重新渲染。与直接 splice 相比,filter + 赋值的方式在声明式框架中更加安全可靠。
八、推荐页组件:内容发现的第一站
8.1 组件声明与属性定义
@Component
struct RecommendContent {
@Prop recommendMovies: MovieItem[]
@Prop movies: MovieItem[]
@Prop tvShows: TvItem[]
@Prop varietyShows: VarietyItem[]
getRatingStars: (r: number) => string = (r: number) => ''
onMovieClick: (m: MovieItem) => void = (m: MovieItem) => {}
onTvClick: (t: TvItem) => void = (t: TvItem) => {}
onVarietyClick: (v: VarietyItem) => void = (v: VarietyItem) => {}
推荐页组件使用 @Component 装饰器声明。它接收四组数据:推荐电影列表、全部电影列表、全部剧集列表和全部综艺列表。前三组通过 @Prop 修饰,意味着它们是父组件传递下来的只读数据——当父组件的数据变化时,子组件会自动更新,但子组件不能直接修改这些数据。最后三个回调函数则使用普通的属性声明方式(默认值为空函数),这是 ArkUI 中传递事件处理器的标准模式。
@Prop 与 @State 的核心区别在于:@State 是组件自有的可变状态,@Prop 是从父组件单向同步的只读数据。这种区分构成了 ArkUI 单向数据流的基础——数据只能从父到子流动,事件只能从子到父冒泡。
8.2 热门推荐横滚卡片
Scroll() {
Row() {
ForEach(this.recommendMovies, (movie: MovieItem) => {
Stack({ alignContent: Alignment.TopEnd }) {
Column() {
Text(movie.poster)
.fontSize(48)
.width('100%').height(120)
.backgroundColor('#3949AB')
.borderRadius({ topLeft: 12, topRight: 12 })
.textAlign(TextAlign.Center)
Column() {
Text(movie.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.maxLines(1)
Row() {
Text('⭐')
.fontSize(10)
Text(movie.rating.toFixed(1))
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
Text(`${movie.year}`)
.fontSize(10)
.fontColor('#999999')
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.width('100%')
.padding(8)
.alignItems(HorizontalAlign.Start)
}
.width(140)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.onClick(() => {
this.onMovieClick(movie)
})
Text('HOT')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor('#E91E63')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 6, right: 6 })
}
.margin({ right: 12 })
}, (movie: MovieItem) => movie.id.toString())
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
这是推荐页最引人注目的区域——横向滚动的热门电影卡片。每张卡片宽度固定为 140,使用 Stack 容器实现内容与角标的叠加。
卡片内部结构分为两层:上半部分是 120 高的海报区域(深蓝色背景,Emoji 居中显示),下半部分是标题和评分信息。Stack 的 alignContent: Alignment.TopEnd 设置使得右上角的"HOT"角标能够叠加在海报区域的上方。角标使用粉红色背景白色文字,9 号字体,小巧而醒目。
整个卡片设置了 onClick 点击事件,触发 onMovieClick 回调,将电影数据传递给父组件打开详情弹窗。maxLines(1) 确保标题过长时只显示一行,避免破坏卡片布局。
8.3 分类导航入口
Row() {
ForEach([
{ icon: '🎬', label: '电影', color: '#E91E63' },
{ icon: '📺', label: '剧集', color: '#3949AB' },
{ icon: '🎭', label: '综艺', color: '#00E676' },
{ icon: '⭐', label: '收藏', color: '#FFD600' }
], (cat: Record<string, string>) => {
Column() {
Text(cat.icon)
.fontSize(28)
Text(cat.label)
.fontSize(12)
.fontColor('#333333')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 14, bottom: 14 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 4, right: 4 })
}, (cat: Record<string, string>) => cat.label)
}
.width('92%')
.margin({ bottom: 12 })
分类导航是一个四等分的按钮行,每个入口包含一个 28 号 Emoji 图标和一行 12 号标签文字。四个入口分别用粉红、靛蓝、绿色和黄色作为主题色暗示(虽然代码中 color 字段未被直接使用在样式中,但为后续扩展预留了空间)。整体使用白色卡片背景和圆角,悬浮在浅蓝色页面背景上。
8.4 本周热门电影排行
Column() {
ForEach(this.movies.slice(0, 5), (movie: MovieItem, index: number) => {
Row() {
Text(`${index + 1}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(index < 3 ? '#E91E63' : '#999999')
.width(30)
.textAlign(TextAlign.Center)
Text(movie.poster)
.fontSize(32)
.width(50).height(60)
.backgroundColor('#E8EAF6')
.borderRadius(8)
.textAlign(TextAlign.Center)
Column() {
Text(movie.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`${movie.genre} · ${movie.year} · ${movie.director}`)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
.maxLines(1)
Row() {
Text(this.getRatingStars(movie.rating))
.fontSize(9)
Text(movie.rating.toFixed(1))
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('›')
.fontSize(24)
.fontColor('#CCCCCC')
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(12)
.margin({ bottom: 8 })
.onClick(() => {
this.onMovieClick(movie)
})
}, (movie: MovieItem) => movie.id.toString())
}
.padding({ left: 16, right: 16 })
本周热门电影取前 5 部进行排行展示。每行包含:排名序号、海报缩略图、标题/类型/年份/导演信息、星级评分、右侧箭头。
排名序号的配色是一个细节亮点:前三名使用粉红色 #E91E63,第四、五名使用灰色 #999999。这种"前三名高亮"的设计在各类排行榜中非常常见,能够快速引导用户关注头部内容。
ForEach 的第二个参数 (movie: MovieItem, index: number) 中,index 是当前项的索引值,从 0 开始。通过 index + 1 将其转换为从 1 开始的排名序号。index < 3 的判断(即索引 0、1、2,对应排名 1、2、3)实现了前三名高亮逻辑。
8.5 热门剧集推荐
热门剧集的展示结构与热门电影类似,取前 4 部展示。每行额外显示了集数进度和更新状态:
Text(`${tv.genre} · ${tv.currentEpisode}/${tv.episodes}集`)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
Text(tv.updateDay === '更新中' ? '🟢 更新中' : '✅ 已完结')
.fontSize(11)
.fontColor(tv.updateDay === '更新中' ? '#00E676' : '#999999')
.margin({ top: 2 })
"更新中"状态前缀绿色圆点 Emoji,文字也是绿色;"已完结"状态前缀勾选 Emoji,文字为灰色。这种颜色编码让用户一眼就能分辨剧集的更新状态,无需仔细阅读文字。
九、电影页组件:网格化内容浏览
9.1 类型筛选标签栏
Scroll() {
Row() {
ForEach(this.movieGenres, (genre: GenreTag, index: number) => {
Text(genre.name)
.fontSize(13)
.fontColor(this.movieGenreIndex === index ? '#FFFFFF' : genre.color)
.backgroundColor(this.movieGenreIndex === index ? genre.color : '#FFFFFF')
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.onGenreChange(index)
})
}, (genre: GenreTag) => genre.name)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ top: 12, bottom: 12 })
类型筛选标签栏使用横向滚动的 Scroll + Row 实现,确保在标签数量较多时可以滑动浏览。每个标签的样式根据选中状态动态切换:选中时背景填充该类型的主题色、文字变白色;未选中时背景为白色、文字为该类型的主题色。
onClick 回调调用 this.onGenreChange(index),将选中索引传递给父组件。父组件更新 movieGenreIndex 状态后,getFilteredMovies() 方法会重新计算过滤结果,新的电影列表通过 @Prop 传递回子组件,触发界面更新。这个"子组件触发事件 -> 父组件更新状态 -> 数据重新过滤 -> 子组件接收新数据 -> 界面刷新"的完整闭环,是 ArkUI 单向数据流的典型应用场景。
9.2 电影网格列表
Scroll() {
Grid() {
ForEach(this.movies, (movie: MovieItem) => {
GridItem() {
Column() {
Stack({ alignContent: Alignment.TopEnd }) {
Text(movie.poster)
.fontSize(48)
.width('100%').height(130)
.backgroundColor('#E8EAF6')
.borderRadius({ topLeft: 10, topRight: 10 })
.textAlign(TextAlign.Center)
Text(movie.rating.toFixed(1))
.fontSize(11)
.fontColor('#1A237E')
.backgroundColor('#FFD600')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 6, right: 6 })
}
.width('100%')
Column() {
Text(movie.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.maxLines(1)
Row() {
Text(this.getRatingStars(movie.rating))
.fontSize(8)
Text(`${movie.year}`)
.fontSize(10)
.fontColor('#999999')
.margin({ left: 6 })
}
.margin({ top: 4 })
Text(movie.genre)
.fontSize(10)
.fontColor('#3949AB')
.backgroundColor('#E8EAF6')
.borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
.width('100%')
.padding(8)
.alignItems(HorizontalAlign.Start)
}
.backgroundColor('#FFFFFF')
.borderRadius(10)
.onClick(() => {
this.onMovieClick(movie)
})
}
}, (movie: MovieItem) => movie.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding({ left: 16, right: 16, bottom: 20 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
电影列表使用 Grid 网格组件实现两列布局。columnsTemplate('1fr 1fr') 定义了两列等宽的网格模板,columnsGap(12) 和 rowsGap(12) 设置了列间距和行间距。
每个网格项(GridItem)内部是一个 Column 卡片,包含:海报区域(Stack 叠加了 Emoji 海报和右上角的评分角标)、标题、星级+年份、类型标签。评分角标使用黄色背景深蓝色文字,与海报的浅蓝色背景形成鲜明对比,让评分一目了然。
类型标签使用 alignSelf(ItemAlign.Start) 左对齐,确保标签不会因为 Column 的默认居中而偏移。这种细节处理体现了对视觉一致性的追求。
十、剧集页组件:追剧进度可视化
剧集页的整体结构与电影页类似——顶部类型筛选标签栏 + 下方网格列表。但网格项的内容有所不同,增加了追剧进度和更新状态的展示。
Row() {
Text(this.getRatingStars(tv.rating))
.fontSize(8)
Text(`${tv.episodes}集`)
.fontSize(10)
.fontColor('#999999')
.margin({ left: 6 })
}
.margin({ top: 4 })
Row() {
Text(tv.updateDay === '更新中' ? '🟢' : '✅')
.fontSize(10)
Text(tv.updateDay)
.fontSize(10)
.fontColor(tv.updateDay === '更新中' ? '#00E676' : '#999999')
.margin({ left: 2 })
}
.margin({ top: 4 })
与电影网格项相比,剧集网格项用"集数"替代了"年份",并额外增加了一行更新状态指示器。更新状态使用了条件表达式实现动态样式:Emoji 图标和文字颜色都根据 updateDay 的值动态切换。
十一、综艺页组件:列表式内容展示
综艺页采用了与电影、剧集不同的列表布局——使用纵向排列的 Column + Row 而非 Grid 网格。这是因为综艺节目的信息量较大(包含季数、期数、平台、主持人、嘉宾等),需要更宽的展示空间。
ForEach(this.varietyShows, (variety: VarietyItem) => {
Row() {
Text(variety.poster)
.fontSize(36)
.width(70).height(90)
.backgroundColor('#E8EAF6')
.borderRadius(10)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(variety.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`S${variety.season}`)
.fontSize(10)
.fontColor('#E91E63')
.backgroundColor('#FCE4EC')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
Text(`${variety.type} · ${variety.episodes}期 · ${variety.platform}`)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 6 })
Row() {
Text(this.getRatingStars(variety.rating))
.fontSize(9)
Text(variety.rating.toFixed(1))
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
}
.margin({ top: 6 })
Text(`主持人:${variety.host}`)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 4 })
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
Column() {
Text(variety.isFollowing ? '❤️' : '🤍')
.fontSize(20)
Text('›')
.fontSize(24)
.fontColor('#CCCCCC')
.margin({ top: 8 })
}
.justifyContent(FlexAlign.Center)
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(12)
.margin({ bottom: 10 })
.onClick(() => {
this.onVarietyClick(variety)
})
}, (variety: VarietyItem) => variety.id.toString())
每张综艺卡片是一个横向 Row,分为三部分:左侧海报(70x90 的 Emoji 展示区)、中间内容区(标题+季数标签、类型/期数/平台、评分、主持人)、右侧操作区(关注状态心形图标+箭头)。
季数标签 S${variety.season} 使用粉红色文字配浅粉红色背景(#FCE4EC),紧跟在标题右侧,是综艺列表中特有的视觉元素。关注状态通过 variety.isFollowing ? '❤️' : '🤍' 动态切换——已关注的显示红心,未关注的显示白心,提供了直观的视觉反馈。
十二、个人中心页组件:数据可视化与记录管理
个人中心是整个应用功能最密集的页面,集成了用户信息展示、统计卡片、评分分布图、类型偏好图和观影记录管理五大模块。
12.1 用户信息卡片
Row() {
Text('👤')
.fontSize(40)
.width(60).height(60)
.backgroundColor('#3949AB')
.borderRadius(30)
.textAlign(TextAlign.Center)
Column() {
Text('影迷达人')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text('Lv.8 · 观影238部')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.margin({ left: 14 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Button('➕ 记录')
.height(32)
.backgroundColor('#1A237E')
.fontColor('#FFFFFF')
.fontSize(12)
.borderRadius(16)
.onClick(() => {
this.onAddRecord()
})
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(14)
.margin({ top: 16 })
用户信息卡片采用"头像+信息+操作按钮"的三段式布局。头像是 60x60 的圆形区域(borderRadius(30)),深蓝色背景,中间放置 Emoji。用户名"影迷达人"使用 17 号粗体深蓝色,下方"Lv.8 · 观影238部"使用 12 号灰色,展示了用户等级和累计观影量。右侧的"➕ 记录"按钮触发新增观影记录弹窗。
12.2 统计数据卡片
Row() {
ForEach(this.stats, (stat: StatItem) => {
Column() {
Text(stat.value)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(stat.color)
Text(stat.label)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
}, (stat: StatItem) => stat.label)
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding({ top: 16, bottom: 16 })
.margin({ top: 12 })
四个统计指标横向等分排列,每个指标的数值使用 22 号粗体字,颜色取自 stat.color(深蓝、黄色、粉红、绿色),标签使用 11 号灰色。四个不同颜色的数值在白色卡片上形成了丰富的视觉层次。
12.3 评分分布可视化
Column() {
ForEach(this.ratingBars, (bar: RatingBar) => {
Row() {
Text(`${bar.star}⭐`)
.fontSize(12)
.fontColor('#666666')
.width(40)
Column() {
Column()
.width(`${bar.percent}%`)
.height(14)
.backgroundColor(['#E91E63', '#FF6D00', '#FFD600', '#8BC34A', '#00BCD4'][5 - bar.star])
.borderRadius(7)
}
.layoutWeight(1)
.height(14)
.backgroundColor('#F0F0F0')
.borderRadius(7)
.margin({ left: 8, right: 8 })
Text(`${bar.count}人`)
.fontSize(11)
.fontColor('#999999')
.width(50)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6 })
}, (bar: RatingBar) => bar.star.toString())
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(14)
.margin({ bottom: 12 })
评分分布图使用水平进度条实现数据可视化。每一行由三部分组成:左侧星级标签(“5⭐”)、中间进度条(内层彩色条 + 外层灰色轨道)、右侧人数。进度条的宽度由 bar.percent 驱动,通过模板字符串 ${bar.percent}% 动态设置。
进度条颜色使用数组索引 [5 - bar.star] 实现星级到颜色的映射。这是一个巧妙的数学技巧——5 星对应索引 0(粉红色),4 星对应索引 1(橙色),3 星对应索引 2(黄色),2 星对应索引 3(浅绿色),1 星对应索引 4(青色),形成了从暖到冷的颜色渐变,高评分暖色、低评分冷色,直觉上非常自然。
12.4 类型偏好可视化
Column() {
ForEach(this.genreStats, (genre: GenreTag, index: number) => {
Row() {
Text(genre.name)
.fontSize(12)
.fontColor('#333333')
.width(40)
Column() {
Column()
.width(`${this.genrePercents[index] * 3}%`)
.height(12)
.backgroundColor(genre.color)
.borderRadius(6)
}
.layoutWeight(1)
.height(12)
.backgroundColor('#F0F0F0')
.borderRadius(6)
.margin({ left: 8, right: 8 })
Text(`${this.genrePercents[index]}%`)
.fontSize(11)
.fontColor('#999999')
.width(36)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 5 })
}, (genre: GenreTag) => genre.name)
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(14)
.margin({ bottom: 12 })
类型偏好图的结构与评分分布图类似,但有一个关键差异:进度条宽度计算使用了 ${this.genrePercents[index] * 3}%。这里乘以 3 是因为百分比值本身较小(最大 28%),如果直接用百分比作为宽度,进度条会太短不够直观。乘以 3 后,28% 变成 84% 的宽度,视觉上更加饱满。同时右侧的数值标签仍然显示原始百分比值 ${this.genrePercents[index]}%,确保数据展示的准确性。
进度条颜色直接取自 genre.color,每种类型使用各自的专属颜色,与筛选标签中的颜色保持一致,形成了跨页面的视觉统一。
12.5 观影记录列表
Column() {
ForEach(this.watchRecords, (record: WatchRecord) => {
Column() {
Row() {
Text(record.poster)
.fontSize(32)
.width(50).height(60)
.backgroundColor('#E8EAF6')
.borderRadius(8)
.textAlign(TextAlign.Center)
Column() {
Text(record.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Row() {
Text(record.genre)
.fontSize(10)
.fontColor('#3949AB')
.backgroundColor('#E8EAF6')
.borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(this.getRatingStars(record.rating))
.fontSize(9)
.margin({ left: 8 })
Text(`${record.rating}`)
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
}
.margin({ top: 6 })
.alignItems(VerticalAlign.Center)
Text(record.comment)
.fontSize(12)
.fontColor('#666666')
.margin({ top: 6 })
.maxLines(2)
.lineHeight(18)
Text(record.date)
.fontSize(10)
.fontColor('#CCCCCC')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Column() {
Text('✏️')
.fontSize(18)
.padding(4)
.onClick(() => {
this.onEditRecord(record)
})
Text('🗑️')
.fontSize(18)
.margin({ top: 8 })
.padding(4)
.onClick(() => {
this.onDeleteRecord(record)
})
}
}
.width('100%')
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(12)
.margin({ bottom: 8 })
}, (record: WatchRecord) => record.id.toString())
}
.width('92%')
.padding({ bottom: 20 })
观影记录列表是个人中心的核心功能区域。每条记录卡片包含:左侧海报缩略图、中间内容区(标题、类型标签+星级+评分、观后感、日期)、右侧操作区(编辑和删除按钮)。
观后感文本使用 maxLines(2) 限制最多显示两行,lineHeight(18) 设置行高,超出部分自动截断。这确保了长评论不会撑破卡片布局。日期使用最浅的灰色 #CCCCCC,作为次要信息弱化展示。
编辑和删除按钮使用 Emoji 图标(✏️ 和 🗑️),分别触发 onEditRecord 和 onDeleteRecord 回调。这两个回调将对应的记录数据传递给父组件,由父组件打开相应的弹窗进行处理。
十三、组件通信机制深度解析
13.1 数据流向图
整个应用的数据流向可以用以下路径来描述:
主组件(MovieEntertainment)维护所有核心状态和数据源,包括内容数据、筛选索引、选中项、弹窗状态和表单状态。这些数据通过两种方式向下传递给子组件:
- 通过
@Prop传递只读数据(如movies、tvShows、stats等)。 - 通过普通属性传递回调函数(如
onMovieClick、onGenreChange等)。
子组件在接收到数据后进行渲染展示,当用户与界面交互时(如点击卡片、切换筛选标签、编辑记录),子组件调用对应的回调函数,将交互事件和数据传递回主组件。主组件在回调中修改相应的 @State 状态变量,触发界面的自动更新。
13.2 回调函数的设计模式
// 父组件中定义回调
getRatingStars: (r: number) => this.getRatingStars(r),
onMovieClick: (m: MovieItem) => {
this.selectedMovie = m
this.detailType = 'movie'
this.showDetailModal = true
},
onGenreChange: (index: number) => {
this.movieGenreIndex = index
},
// 子组件中声明回调
getRatingStars: (r: number) => string = (r: number) => ''
onMovieClick: (m: MovieItem) => void = (m: MovieItem) => {}
onGenreChange: (index: number) => void = (index: number) => {}
回调函数在子组件中声明时都提供了默认值(空函数或空字符串返回),这是一个重要的防御性编程实践。它确保了即使父组件没有传递某个回调,子组件调用时也不会报错,而是静默返回默认值。这种模式使得子组件可以独立测试和复用。
getRatingStars 回调的传递方式值得特别关注。它被定义为 (r: number) => this.getRatingStars(r) 而非直接传递 this.getRatingStars,这是因为箭头函数能够正确绑定 this 上下文。如果直接传递方法引用,在子组件中调用时 this 会指向子组件而非主组件,导致方法内部访问 this 相关属性时出错。
13.3 bindContentCover 的双向绑定
.bindContentCover($$this.showDetailModal, this.detailModalBuilder)
.bindContentCover($$this.showAddModal, this.addModalBuilder)
.bindContentCover($$this.showEditModal, this.editModalBuilder)
.bindContentCover($$this.showDeleteConfirm, this.deleteConfirmBuilder)
bindContentCover 是 ArkUI 提供的全屏覆盖组件绑定方法。它接收两个参数:一个布尔值控制显隐(使用 $$ 双向绑定语法),一个 @Builder 构建器函数定义覆盖内容。
$$ 语法表示双向绑定——不仅父组件的状态变化会影响弹窗的显隐,弹窗内部修改了该状态(如点击关闭按钮设置 this.showDetailModal = false)也会同步回父组件。这种双向绑定简化了弹窗关闭的逻辑,无需额外的回调函数。
十四、关键特性对比总结
下面通过一个表格对应用中各核心子组件的关键特性进行对比总结:
| 特性维度 | 推荐页 | 电影页 | 剧集页 | 综艺页 | 个人中心 |
|---|---|---|---|---|---|
| 布局方式 | 纵向滚动 | 网格两列 | 网格两列 | 纵向列表 | 纵向滚动 |
| 内容来源 | 多数据源聚合 | 电影列表 | 剧集列表 | 综艺列表 | 观影记录+统计数据 |
| 筛选功能 | 无 | 类型筛选 | 类型筛选 | 类型筛选 | 无 |
| 点击交互 | 打开详情弹窗 | 打开详情弹窗 | 打开详情弹窗 | 打开详情弹窗 | 编辑/删除记录 |
| 数据可视化 | 排行榜序号 | 评分角标 | 更新状态指示 | 关注状态心形 | 评分分布+类型偏好 |
| 横向滚动 | 热门推荐卡片 | 筛选标签栏 | 筛选标签栏 | 筛选标签栏 | 无 |
| 卡片形态 | 大卡片+列表卡 | 网格卡 | 网格卡 | 宽幅列表卡 | 记录卡+统计卡 |
| 操作按钮 | 无 | 无 | 无 | 无 | 新增/编辑/删除 |
| 数据可变性 | 只读 | 只读 | 只读 | 只读 | 可增删改 |
| 状态管理 | @Prop 接收 | @Prop 接收 | @Prop 接收 | @Prop 接收 | @Prop + 回调联动 |
| 视觉重点 | 内容发现 | 内容浏览 | 追剧进度 | 节目信息 | 数据分析 |
十五、全文总结
15.1 架构设计回顾
通过对这个影视娱乐大全应用的完整代码解析,我们可以清晰地看到一套成熟的 ArkUI 声明式应用架构。整个应用以一个入口主组件为中枢,统一管理所有状态和数据,通过 @Prop 向下传递数据、通过回调函数向上接收事件,形成了清晰的单向数据流。五个功能子组件各自独立负责自己的界面渲染和局部交互,通过明确的接口(属性和回调)与主组件通信,实现了高内聚低耦合的组件化设计。
这种架构的优势在于可预测性强——任何时候界面的状态都可以通过追溯状态变量来确定,不需要关心命令式的 DOM 操作序列。当出现 Bug 时,只需检查状态变量的变化链路即可定位问题,大大降低了调试难度。
15.2 技术亮点提炼
第一,数据模型的精细化设计。应用为电影、剧集、综艺三种内容类型分别定义了独立的数据接口,每个接口都精确反映了该品类的业务特征。电影有"票房"和"片长",剧集有"集数"和"更新状态",综艺有"季数"和"主持人"。这种领域驱动的建模方式使得数据与业务高度匹配。
第二,状态管理的分层策略。@State 用于组件内部可变状态,@Prop 用于父到子的只读数据同步,普通属性用于回调函数传递。三种数据传递方式各司其职,构成了完整的状态管理体系。
第三,弹窗系统的统一设计。四个弹窗通过 @Builder 定义为构建器函数,通过 bindContentCover 统一绑定到主组件,通过 $$ 双向绑定实现显隐控制。这种模式将弹窗的声明、绑定和控制三个关注点清晰地分离,可维护性极高。
第四,数据可视化的轻量实现。评分分布和类型偏好两个图表没有使用任何第三方图表库,而是通过 Column 的宽度百分比驱动 + 颜色数组索引的组合,用纯 ArkUI 组件实现了进度条式的可视化效果。颜色梯度的设计(高评分暖色、低评分冷色)更是体现了对数据可视化最佳实践的深刻理解。
第五,交互反馈的即时性。无论是筛选标签的选中态切换、评分按钮的高亮、关注心形的变色,还是 Tab 导航的颜色变化,所有交互都有即时的视觉反馈。这种"所见即所得"的交互体验是声明式 UI 的天然优势——开发者只需描述状态与界面的映射关系,框架自动处理界面更新的细节。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
// 影视娱乐大全 Movie Entertainment
interface MovieItem {
id: number
title: string
genre: string
year: number
director: string
actors: string[]
rating: number
duration: number
boxOffice: string
tags: string[]
poster: string
description: string
isWatched: boolean
isFavorite: boolean
watchCount: number
}
interface TvItem {
id: number
title: string
genre: string
year: number
director: string
actors: string[]
rating: number
episodes: number
currentEpisode: number
platform: string
tags: string[]
poster: string
description: string
isFollowing: boolean
updateDay: string
}
interface VarietyItem {
id: number
title: string
type: string
host: string
guests: string[]
rating: number
season: number
episodes: number
platform: string
tags: string[]
poster: string
description: string
isFollowing: boolean
}
interface WatchRecord {
id: number
title: string
genre: string
rating: number
comment: string
date: string
poster: string
}
interface GenreTag {
name: string
color: string
}
interface RatingBar {
star: number
count: number
percent: number
}
interface StatItem {
label: string
value: string
color: string
}
@Entry
@Component
struct MovieEntertainment {
@State currentTab: number = 0
@State showAddModal: boolean = false
@State showEditModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showDetailModal: boolean = false
@State selectedMovie: MovieItem | null = null
@State selectedTv: TvItem | null = null
@State selectedVariety: VarietyItem | null = null
@State selectedRecord: WatchRecord | null = null
@State selectedGenre: string = '全部'
@State detailType: string = 'movie'
@State newTitle: string = ''
@State newGenre: string = '动作'
@State newRating: number = 5
@State newComment: string = ''
@State editComment: string = ''
@State movieGenreIndex: number = 0
@State tvGenreIndex: number = 0
@State varietyTypeIndex: number = 0
private movieGenres: GenreTag[] = [
{ name: '全部', color: '#1A237E' },
{ name: '动作', color: '#E91E63' },
{ name: '喜剧', color: '#FFD600' },
{ name: '科幻', color: '#00E676' },
{ name: '悬疑', color: '#7C4DFF' },
{ name: '爱情', color: '#FF4081' },
{ name: '动画', color: '#00BCD4' },
{ name: '恐怖', color: '#5D4037' },
{ name: '纪录片', color: '#4CAF50' }
]
private tvGenres: GenreTag[] = [
{ name: '全部', color: '#1A237E' },
{ name: '悬疑', color: '#7C4DFF' },
{ name: '古装', color: '#8D6E63' },
{ name: '都市', color: '#3949AB' },
{ name: '科幻', color: '#00E676' },
{ name: '喜剧', color: '#FFD600' },
{ name: '爱情', color: '#FF4081' }
]
private varietyTypes: GenreTag[] = [
{ name: '全部', color: '#1A237E' },
{ name: '真人秀', color: '#E91E63' },
{ name: '脱口秀', color: '#FFD600' },
{ name: '音乐', color: '#00E676' },
{ name: '竞技', color: '#FF6D00' }
]
private stats: StatItem[] = [
{ label: '本月观看', value: '23', color: '#1A237E' },
{ label: '平均评分', value: '8.2', color: '#FFD600' },
{ label: '想看收藏', value: '47', color: '#E91E63' },
{ label: '观影时长', value: '68h', color: '#00E676' }
]
private ratingBars: RatingBar[] = [
{ star: 5, count: 1280, percent: 100 },
{ star: 4, count: 860, percent: 67 },
{ star: 3, count: 420, percent: 33 },
{ star: 2, count: 180, percent: 14 },
{ star: 1, count: 60, percent: 5 }
]
private genreStats: GenreTag[] = [
{ name: '动作', color: '#E91E63' },
{ name: '喜剧', color: '#FFD600' },
{ name: '科幻', color: '#00E676' },
{ name: '悬疑', color: '#7C4DFF' },
{ name: '爱情', color: '#FF4081' },
{ name: '动画', color: '#00BCD4' }
]
private genrePercents: number[] = [28, 22, 18, 15, 10, 7]
@State watchRecords: WatchRecord[] = [
{ id: 1, title: '流浪地球2', genre: '科幻', rating: 9, comment: '硬核科幻,中国电影的骄傲', date: '2026-08-01', poster: '🌌' },
{ id: 2, title: '满江红', genre: '悬疑', rating: 8, comment: '层层反转,节奏紧凑', date: '2026-07-28', poster: '⚔️' },
{ id: 3, title: '长安三万里', genre: '动画', rating: 9, comment: '诗画交融,文化盛宴', date: '2026-07-25', poster: '📜' },
{ id: 4, title: '封神第一部', genre: '动作', rating: 8, comment: '视觉震撼,史诗气质', date: '2026-07-20', poster: '🗡️' },
{ id: 5, title: '孤注一掷', genre: '悬疑', rating: 7, comment: '反诈题材,发人深省', date: '2026-07-15', poster: '🎰' }
]
private movies: MovieItem[] = [
{ id: 1, title: '流浪地球2', genre: '科幻', year: 2023, director: '郭帆', actors: ['吴京', '刘德华', '李雪健'], rating: 8.3, duration: 173, boxOffice: '40.2亿', tags: ['硬核', '特效', '续作'], poster: '🌌', description: '太阳即将毁灭,人类开启流浪地球计划,寻找新的家园。', isWatched: true, isFavorite: true, watchCount: 3 },
{ id: 2, title: '满江红', genre: '悬疑', year: 2023, director: '张艺谋', actors: ['沈腾', '易烊千玺', '张译'], rating: 7.8, duration: 159, boxOffice: '45.4亿', tags: ['反转', '古装', '喜剧'], poster: '⚔️', description: '南宋绍兴年间,岳飞死后四年,秦桧率兵与金国会谈。', isWatched: true, isFavorite: false, watchCount: 1 },
{ id: 3, title: '长安三万里', genre: '动画', year: 2023, director: '谢君伟', actors: ['配音阵容'], rating: 8.0, duration: 168, boxOffice: '18.2亿', tags: ['诗词', '历史', '国漫'], poster: '📜', description: '安史之乱后,整个大唐因战而致江山颓败。', isWatched: true, isFavorite: true, watchCount: 2 },
{ id: 4, title: '封神第一部', genre: '动作', year: 2023, director: '乌尔善', actors: ['费翔', '李雪健', '黄渤'], rating: 7.7, duration: 148, boxOffice: '26.3亿', tags: ['神话', '史诗', '特效'], poster: '🗡️', description: '商王殷寿与狐妖妲己暴虐无道,引发天谴。', isWatched: true, isFavorite: false, watchCount: 1 },
{ id: 5, title: '孤注一掷', genre: '悬疑', year: 2023, director: '申奥', actors: ['张艺兴', '金晨', '咏梅'], rating: 7.6, duration: 120, boxOffice: '38.4亿', tags: ['反诈', '现实', '犯罪'], poster: '🎰', description: '程序员潘生被骗至境外诈骗工厂的故事。', isWatched: true, isFavorite: false, watchCount: 1 },
{ id: 6, title: '消失的她', genre: '悬疑', year: 2023, director: '崔睿', actors: ['朱一龙', '倪妮', '文咏珊'], rating: 7.5, duration: 121, boxOffice: '35.2亿', tags: ['悬疑', '反转', '犯罪'], poster: '🌊', description: '何非的妻子在东南亚旅游时离奇消失。', isWatched: false, isFavorite: true, watchCount: 0 },
{ id: 7, title: '八角笼中', genre: '动作', year: 2023, director: '王宝强', actors: ['王宝强', '陈永胜'], rating: 7.4, duration: 125, boxOffice: '21.8亿', tags: ['现实', '励志', '格斗'], poster: '🥊', description: '向腾辉带领大山里的孩子们格斗改变命运。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 8, title: '碟中谍7', genre: '动作', year: 2023, director: '克里斯托弗', actors: ['汤姆·克鲁斯', '海莉·阿特维尔'], rating: 8.1, duration: 163, boxOffice: '全球5.6亿$', tags: ['特工', '惊险', '系列'], poster: '🕶️', description: '伊森·亨特面对全新威胁,AI控制的终极武器。', isWatched: true, isFavorite: true, watchCount: 2 },
{ id: 9, title: '芭比', genre: '喜剧', year: 2023, director: '格蕾塔', actors: ['玛格特·罗比', '瑞恩·高斯林'], rating: 8.0, duration: 114, boxOffice: '全球14.4亿$', tags: ['奇幻', '喜剧', '女性'], poster: '🎀', description: '芭比在完美世界开始出现存在危机。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 10, title: '奥本海默', genre: '纪录片', year: 2023, director: '诺兰', actors: ['基里安·墨菲', '艾米莉·布朗特'], rating: 8.9, duration: 180, boxOffice: '全球9.5亿$', tags: ['传记', '历史', '震撼'], poster: '💥', description: '原子弹之父奥本海默的传奇人生。', isWatched: true, isFavorite: true, watchCount: 2 },
{ id: 11, title: '蜘蛛侠:纵横宇宙', genre: '动画', year: 2023, director: '华金', actors: ['配音阵容'], rating: 8.5, duration: 140, boxOffice: '全球6.9亿$', tags: ['动画', '超级英雄', '视觉'], poster: '🕷️', description: '迈尔斯探索多元宇宙的冒险故事。', isWatched: true, isFavorite: true, watchCount: 3 },
{ id: 12, title: '银河护卫队3', genre: '科幻', year: 2023, director: '詹姆斯·古恩', actors: ['克里斯·帕拉特'], rating: 8.0, duration: 150, boxOffice: '全球8.4亿$', tags: ['漫威', '搞笑', '告别'], poster: '🚀', description: '护卫队成员为了拯救火箭浣熊的终极冒险。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 13, title: '灌篮高手', genre: '动画', year: 2023, director: '井上雄彦', actors: ['配音阵容'], rating: 8.7, duration: 124, boxOffice: '6.6亿', tags: ['青春', '热血', '回忆'], poster: '🏀', description: '湘北 vs 山王工业的终极对决。', isWatched: true, isFavorite: true, watchCount: 2 },
{ id: 14, title: '无名', genre: '悬疑', year: 2023, director: '程耳', actors: ['梁朝伟', '王一博'], rating: 7.3, duration: 129, boxOffice: '9.3亿', tags: ['谍战', '文艺', '民国'], poster: '🎭', description: '抗战时期地下工作者的隐秘战线。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 15, title: '深海', genre: '动画', year: 2023, director: '田晓鹏', actors: ['配音阵容'], rating: 7.6, duration: 112, boxOffice: '9.1亿', tags: ['奇幻', '治愈', '水墨'], poster: '🌊', description: '女孩误入深海世界的奇幻冒险。', isWatched: false, isFavorite: true, watchCount: 0 },
{ id: 16, title: '变形金刚7', genre: '动作', year: 2023, director: '史蒂文', actors: ['安东尼·拉莫斯'], rating: 6.8, duration: 127, boxOffice: '全球4.3亿$', tags: ['科幻', '机器人', '系列'], poster: '🤖', description: '汽车人面对新敌人的远古之战。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 17, title: '闪电侠', genre: '科幻', year: 2023, director: '安德烈斯', actors: ['埃兹拉·米勒'], rating: 7.2, duration: 144, boxOffice: '全球2.7亿$', tags: ['DC', '超级英雄', '时间'], poster: '⚡', description: '闪电侠穿越时空试图改变过去。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 18, title: '速度与激情10', genre: '动作', year: 2023, director: '路易斯', actors: ['范·迪塞尔', '杰森·莫玛'], rating: 6.5, duration: 142, boxOffice: '全球7.1亿$', tags: ['飙车', '系列', '动作'], poster: '🏎️', description: '唐老面对史上最强反派的终极之战。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 19, title: '铃芽之旅', genre: '爱情', year: 2023, director: '新海诚', actors: ['配音阵容'], rating: 8.0, duration: 121, boxOffice: '8.1亿', tags: ['日本', '奇幻', '治愈'], poster: '🚪', description: '少女铃芽的关门之旅与命运邂逅。', isWatched: true, isFavorite: true, watchCount: 2 },
{ id: 20, title: '铃芽之旅2', genre: '爱情', year: 2024, director: '新海诚', actors: ['配音阵容'], rating: 8.2, duration: 118, boxOffice: '待定', tags: ['日本', '奇幻', '续作'], poster: '🌸', description: '铃芽的全新冒险故事。', isWatched: false, isFavorite: true, watchCount: 0 },
{ id: 21, title: '熊出没·伴我熊芯', genre: '喜剧', year: 2023, director: '林永长', actors: ['配音阵容'], rating: 7.0, duration: 96, boxOffice: '14.9亿', tags: ['亲子', '动画', '系列'], poster: '🐻', description: '熊大熊二寻找妈妈的温暖故事。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 22, title: '长空之王', genre: '动作', year: 2023, director: '刘晓世', actors: ['王一博', '胡军'], rating: 7.0, duration: 128, boxOffice: '8.5亿', tags: ['军旅', '试飞', '热血'], poster: '✈️', description: '试飞员挑战战机极限的壮志故事。', isWatched: false, isFavorite: false, watchCount: 0 },
{ id: 23, title: '流浪地球1', genre: '科幻', year: 2019, director: '郭帆', actors: ['吴京', '屈楚萧'], rating: 7.9, duration: 125, boxOffice: '46.8亿', tags: ['硬核', '开山', '经典'], poster: '🌍', description: '人类带着地球逃离太阳系的壮举。', isWatched: true, isFavorite: true, watchCount: 5 },
{ id: 24, title: '哪吒之魔童降世', genre: '动画', year: 2019, director: '饺子', actors: ['配音阵容'], rating: 8.4, duration: 110, boxOffice: '50.3亿', tags: ['国漫', '颠覆', '燃'], poster: '🔥', description: '哪吒逆天改命的热血传奇。', isWatched: true, isFavorite: true, watchCount: 4 },
{ id: 25, title: '你好,李焕英', genre: '喜剧', year: 2021, director: '贾玲', actors: ['贾玲', '张小斐', '沈腾'], rating: 7.7, duration: 128, boxOffice: '54.1亿', tags: ['亲情', '穿越', '催泪'], poster: '❤️', description: '穿越时空与年轻时的母亲相遇。', isWatched: true, isFavorite: false, watchCount: 2 },
{ id: 26, title: '流浪地球3', genre: '科幻', year: 2027, director: '郭帆', actors: ['吴京', '刘德华'], rating: 9.0, duration: 180, boxOffice: '待上映', tags: ['期待', '续作', '硬核'], poster: '🪐', description: '流浪地球计划的最终篇章。', isWatched: false, isFavorite: true, watchCount: 0 }
]
private tvShows: TvItem[] = [
{ id: 1, title: '狂飙', genre: '悬疑', year: 2023, director: '徐纪周', actors: ['张译', '张颂文', '李一桐'], rating: 8.5, episodes: 39, currentEpisode: 39, platform: '爱奇艺', tags: ['扫黑', '犯罪', '现实'], poster: '🔫', description: '安欣与高启强二十年的正邪较量。', isFollowing: true, updateDay: '已完结' },
{ id: 2, title: '三体', genre: '科幻', year: 2023, director: '杨磊', actors: ['张鲁一', '于和伟', '陈瑾'], rating: 8.3, episodes: 30, currentEpisode: 30, platform: '腾讯', tags: ['科幻', '硬核', '经典'], poster: '🌠', description: '人类面对三体文明的降临危机。', isFollowing: true, updateDay: '已完结' },
{ id: 3, title: '繁花', genre: '都市', year: 2023, director: '王家卫', actors: ['胡歌', '马伊琍', '唐嫣'], rating: 8.4, episodes: 30, currentEpisode: 20, platform: '腾讯', tags: ['文艺', '上海', '商战'], poster: '🌸', description: '九十年代上海的商海沉浮故事。', isFollowing: true, updateDay: '更新中' },
{ id: 4, title: '漫长的季节', genre: '悬疑', year: 2023, director: '辛爽', actors: ['范伟', '秦昊', '陈明昊'], rating: 9.4, episodes: 12, currentEpisode: 12, platform: '腾讯', tags: ['神剧', '东北', '命运'], poster: '🍂', description: '东北老工业区的碎尸案与时代变迁。', isFollowing: true, updateDay: '已完结' },
{ id: 5, title: '莲花楼', genre: '古装', year: 2023, director: '郭虎', actors: ['成毅', '曾舜晞'], rating: 8.2, episodes: 40, currentEpisode: 40, platform: '爱奇艺', tags: ['武侠', '探案', '古风'], poster: '🪷', description: '李相夷退隐江湖后的探案传奇。', isFollowing: false, updateDay: '已完结' },
{ id: 6, title: '长相思', genre: '古装', year: 2023, director: '秦榛', actors: ['杨紫', '邓为', '张晚意'], rating: 7.8, episodes: 39, currentEpisode: 39, platform: '腾讯', tags: ['仙侠', '爱情', '虐恋'], poster: '🌙', description: '小夭与四个男人的纠葛情缘。', isFollowing: true, updateDay: '已完结' },
{ id: 7, title: '鹊刀门传奇', genre: '喜剧', year: 2023, director: '赵本山', actors: ['赵本山', '宋小宝'], rating: 8.1, episodes: 40, currentEpisode: 40, platform: '腾讯', tags: ['武侠', '搞笑', '东北'], poster: '🗡️', description: '鹊刀门掌门的江湖趣事。', isFollowing: false, updateDay: '已完结' },
{ id: 8, title: '我的人间烟火', genre: '爱情', year: 2023, director: '李木戈', actors: ['杨洋', '王楚然'], rating: 5.8, episodes: 40, currentEpisode: 40, platform: '湖南卫视', tags: ['都市', '消防', '争议'], poster: '🚒', description: '消防员与医生的都市爱情故事。', isFollowing: false, updateDay: '已完结' },
{ id: 9, title: '偷偷藏不住', genre: '爱情', year: 2023, director: '李青蓉', actors: ['赵露思', '陈哲远'], rating: 7.5, episodes: 25, currentEpisode: 25, platform: '优酷', tags: ['甜宠', '青春', '暗恋'], poster: '🍬', description: '少女暗恋邻居哥哥的甜蜜故事。', isFollowing: true, updateDay: '已完结' },
{ id: 10, title: '梦中的那片海', genre: '都市', year: 2023, director: '付宁', actors: ['肖战', '李沁'], rating: 7.2, episodes: 38, currentEpisode: 38, platform: '腾讯', tags: ['青春', '年代', '北京'], poster: '⛸️', description: '七十年代北京青年的奋斗与爱情。', isFollowing: false, updateDay: '已完结' },
{ id: 11, title: '安乐传', genre: '古装', year: 2023, director: '成志超', actors: ['迪丽热巴', '龚俊'], rating: 6.5, episodes: 40, currentEpisode: 40, platform: '优酷', tags: ['古装', '言情', '权谋'], poster: '👑', description: '帝梓元与韩烨的虐恋纠葛。', isFollowing: false, updateDay: '已完结' },
{ id: 12, title: '斗罗大陆2', genre: '科幻', year: 2023, director: '卫立洲', actors: ['周翊然', '张予曦'], rating: 7.0, episodes: 38, currentEpisode: 38, platform: '腾讯', tags: ['玄幻', '热血', '少年'], poster: '⚡', description: '唐三在斗罗大陆的修炼冒险。', isFollowing: true, updateDay: '已完结' },
{ id: 13, title: '显微镜下的大明', genre: '古装', year: 2023, director: '潘安子', actors: ['张若昀', '王阳'], rating: 7.8, episodes: 14, currentEpisode: 14, platform: '爱奇艺', tags: ['历史', '悬疑', '短剧'], poster: '🔭', description: '明代丝绢案的官场众生相。', isFollowing: false, updateDay: '已完结' },
{ id: 14, title: '尘封十三载', genre: '悬疑', year: 2023, director: '刘海滨', actors: ['陈建斌', '陈晓'], rating: 7.7, episodes: 24, currentEpisode: 24, platform: '爱奇艺', tags: ['刑侦', '双男主', '时间'], poster: '🔪', description: '十三年悬案的追踪与真相。', isFollowing: false, updateDay: '已完结' },
{ id: 15, title: '他是谁', genre: '悬疑', year: 2023, director: '鲍成志', actors: ['张译', '丁勇岱'], rating: 6.8, episodes: 24, currentEpisode: 24, platform: '优酷', tags: ['刑侦', '年代', '连环案'], poster: '🕵️', description: '卫国平追查八年悬案的故事。', isFollowing: false, updateDay: '已完结' },
{ id: 16, title: '无间', genre: '悬疑', year: 2023, director: '奇道', actors: ['靳东', '王丽坤'], rating: 6.5, episodes: 40, currentEpisode: 40, platform: '江苏卫视', tags: ['谍战', '民国', '烧脑'], poster: '🎭', description: '抗战时期的特工暗战。', isFollowing: false, updateDay: '已完结' },
{ id: 17, title: '薄冰', genre: '悬疑', year: 2023, director: '金琛', actors: ['彭冠英', '陈钰琪'], rating: 6.0, episodes: 40, currentEpisode: 40, platform: '湖南卫视', tags: ['谍战', '民国', '爱情'], poster: '🧊', description: '军统特工的潜伏与抉择。', isFollowing: false, updateDay: '已完结' },
{ id: 18, title: '护心', genre: '爱情', year: 2023, director: '何澍培', actors: ['侯明昊', '周也'], rating: 7.2, episodes: 40, currentEpisode: 40, platform: '优酷', tags: ['仙侠', '搞笑', '甜虐'], poster: '🐉', description: '天曜与雁回的修仙情缘。', isFollowing: true, updateDay: '已完结' },
{ id: 19, title: '后浪', genre: '都市', year: 2023, director: '韩晓军', actors: ['吴刚', '赵露思'], rating: 6.3, episodes: 40, currentEpisode: 40, platform: '优酷', tags: ['中医', '传承', '争议'], poster: '🌿', description: '中医传承人的成长故事。', isFollowing: false, updateDay: '已完结' },
{ id: 20, title: '追光者', genre: '都市', year: 2023, director: '毛卫宁', actors: ['罗云熙', '吴倩'], rating: 7.0, episodes: 40, currentEpisode: 40, platform: '腾讯', tags: ['公益', '救援', '温暖'], poster: '🔦', description: '民间救援队的温暖故事。', isFollowing: false, updateDay: '已完结' },
{ id: 21, title: '问心', genre: '都市', year: 2023, director: '黎志', actors: ['毛晓彤', '赵又廷'], rating: 8.0, episodes: 38, currentEpisode: 38, platform: '腾讯', tags: ['医疗', '现实', '温情'], poster: '🫀', description: '心脏科医生的医者仁心。', isFollowing: true, updateDay: '已完结' }
]
private varietyShows: VarietyItem[] = [
{ id: 1, title: '奔跑吧·黄河篇', type: '真人秀', host: '李晨', guests: ['Angelababy', '郑恺', '沙溢'], rating: 7.5, season: 11, episodes: 12, platform: '浙江卫视', tags: ['游戏', '户外', '搞笑'], poster: '🏃', description: '跑男团黄河沿岸公益之旅。', isFollowing: true },
{ id: 2, title: '脱口秀大会6', type: '脱口秀', host: '李诞', guests: ['王建国', '呼兰', '何广智'], rating: 8.2, season: 6, episodes: 10, platform: '腾讯', tags: ['搞笑', '语言', '竞技'], poster: '🎤', description: '脱口秀演员的巅峰对决。', isFollowing: true },
{ id: 3, title: '披荆斩棘3', type: '真人秀', host: '谢娜', guests: ['陈小春', '林志颖', '胡彦斌'], rating: 7.8, season: 3, episodes: 12, platform: '芒果TV', tags: ['音乐', '哥哥', '舞台'], poster: '🎸', description: '哥哥们的音乐舞台竞演。', isFollowing: true },
{ id: 4, title: '乘风4', type: '音乐', host: '谢娜', guests: ['谢娜', 'Ella', '王心凌'], rating: 8.0, season: 4, episodes: 12, platform: '芒果TV', tags: ['姐姐', '音乐', '舞台'], poster: '🌊', description: '姐姐们的音乐梦想之旅。', isFollowing: true },
{ id: 5, title: '向往的生活7', type: '真人秀', host: '何炅', guests: ['黄磊', '张艺兴', '彭昱畅'], rating: 8.3, season: 7, episodes: 14, platform: '湖南卫视', tags: ['慢综艺', '田园', '治愈'], poster: '🏡', description: '蘑菇屋的田园慢生活。', isFollowing: true },
{ id: 6, title: '中国好声音2023', type: '音乐', host: '华少', guests: ['薛之谦', '刘宪华', '周华健'], rating: 7.2, season: 12, episodes: 13, platform: '浙江卫视', tags: ['音乐', '选秀', '导师'], poster: '🎵', description: '草根歌手的音乐梦想舞台。', isFollowing: false },
{ id: 7, title: '王牌对王牌8', type: '竞技', host: '沈腾', guests: ['贾玲', '华晨宇', '关晓彤'], rating: 7.0, season: 8, episodes: 12, platform: '浙江卫视', tags: ['游戏', '搞笑', '怀旧'], poster: '🃏', description: '王牌家族的游戏竞技之夜。', isFollowing: false },
{ id: 8, title: '声生不息·宝岛季', type: '音乐', host: '何炅', guests: ['那英', '张信哲', '张韶涵'], rating: 8.5, season: 2, episodes: 10, platform: '芒果TV', tags: ['音乐', '怀旧', '华语'], poster: '🏝️', description: '台湾流行音乐经典重现。', isFollowing: true },
{ id: 9, title: '种地吧', type: '真人秀', host: '无', guests: ['十个勤天'], rating: 9.0, season: 1, episodes: 50, platform: '爱奇艺', tags: ['农业', '青春', '纪实'], poster: '🌾', description: '十个少年190天种地纪实。', isFollowing: true },
{ id: 10, title: '极限挑战9', type: '真人秀', host: '雷佳音', guests: ['岳云鹏', '王迅', '贾乃亮'], rating: 6.8, season: 9, episodes: 12, platform: '东方卫视', tags: ['游戏', '户外', '男人帮'], poster: '⚡', description: '极限男人帮的挑战之旅。', isFollowing: false },
{ id: 11, title: '我们的歌5', type: '音乐', host: '林海', guests: ['马嘉祺', '杨千嬅', '大张伟'], rating: 7.5, season: 5, episodes: 12, platform: '东方卫视', tags: ['音乐', '代际', '合作'], poster: '🎶', description: '前辈与新人的音乐对话。', isFollowing: false },
{ id: 12, title: '一年一度喜剧大赛2', type: '竞技', host: '马东', guests: ['黄渤', '李诞', '于和伟'], rating: 8.8, season: 2, episodes: 10, platform: '爱奇艺', tags: ['喜剧', '竞技', '原创'], poster: '😂', description: '原创喜剧人的巅峰对决。', isFollowing: true },
{ id: 13, title: '乐队的夏天3', type: '音乐', host: '马东', guests: ['张亚东', '大张伟', '彭磊'], rating: 8.6, season: 3, episodes: 10, platform: '爱奇艺', tags: ['摇滚', '乐队', 'live'], poster: '🤘', description: '中国乐队的夏日狂欢。', isFollowing: true },
{ id: 14, title: '这就是街舞6', type: '竞技', host: '王嘉尔', guests: ['韩庚', '刘宪华', '张艺兴'], rating: 8.3, season: 6, episodes: 12, platform: '优酷', tags: ['街舞', '竞技', '热血'], poster: '💃', description: '顶尖舞者的街舞对决。', isFollowing: true },
{ id: 15, title: '吐槽大会7', type: '脱口秀', host: '李诞', guests: ['张绍刚', '池子', '王建国'], rating: 6.5, season: 7, episodes: 10, platform: '腾讯', tags: ['吐槽', '搞笑', '明星'], poster: '🗯️', description: '明星主咖被吐槽大会。', isFollowing: false },
{ id: 16, title: '快乐再出发2', type: '真人秀', host: '无', guests: ['0713男团'], rating: 9.2, season: 2, episodes: 12, platform: '芒果TV', tags: ['音乐', '友情', '治愈'], poster: '🎸', description: '0713男团的快乐旅行。', isFollowing: true }
]
private recommendMovies: MovieItem[] = []
aboutToAppear() {
this.recommendMovies = this.movies.filter((m: MovieItem) => m.rating >= 8.0).slice(0, 8)
}
getRatingStars(rating: number): string {
let stars: string = ''
let full: number = Math.floor(rating / 2)
let i: number = 0
for (i = 0; i < full; i++) {
stars += '⭐'
}
if (rating / 2 - full >= 0.5) {
stars += '🌟'
}
return stars
}
getFilteredMovies(): MovieItem[] {
if (this.selectedGenre === '全部' || this.movieGenreIndex === 0) {
return this.movies
}
return this.movies.filter((m: MovieItem) => m.genre === this.movieGenres[this.movieGenreIndex].name)
}
getFilteredTvShows(): TvItem[] {
if (this.tvGenreIndex === 0) {
return this.tvShows
}
return this.tvShows.filter((t: TvItem) => t.genre === this.tvGenres[this.tvGenreIndex].name)
}
getFilteredVariety(): VarietyItem[] {
if (this.varietyTypeIndex === 0) {
return this.varietyShows
}
return this.varietyShows.filter((v: VarietyItem) => v.type === this.varietyTypes[this.varietyTypeIndex].name)
}
build() {
Column() {
// 顶部深色渐变区域
Stack({ alignContent: Alignment.TopStart }) {
Column() {
Text('🎬 影视娱乐大全')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
.margin({ top: 48, left: 20, bottom: 4 })
Text('发现好电影 · 追剧不迷路')
.fontSize(13)
.fontColor('#B3B3CC')
.margin({ left: 20, bottom: 16 })
}
.width('100%')
.height(120)
.linearGradient({
angle: 180,
colors: [['#1A237E', 0.0], ['#3949AB', 1.0]]
})
}
.width('100%')
.height(120)
// Tab内容区
Column() {
if (this.currentTab === 0) {
RecommendContent({
recommendMovies: this.recommendMovies,
movies: this.movies,
tvShows: this.tvShows,
varietyShows: this.varietyShows,
getRatingStars: (r: number) => this.getRatingStars(r),
onMovieClick: (m: MovieItem) => {
this.selectedMovie = m
this.detailType = 'movie'
this.showDetailModal = true
},
onTvClick: (t: TvItem) => {
this.selectedTv = t
this.detailType = 'tv'
this.showDetailModal = true
},
onVarietyClick: (v: VarietyItem) => {
this.selectedVariety = v
this.detailType = 'variety'
this.showDetailModal = true
}
})
} else if (this.currentTab === 1) {
MovieContent({
movies: this.getFilteredMovies(),
movieGenres: this.movieGenres,
movieGenreIndex: this.movieGenreIndex,
getRatingStars: (r: number) => this.getRatingStars(r),
onGenreChange: (index: number) => {
this.movieGenreIndex = index
},
onMovieClick: (m: MovieItem) => {
this.selectedMovie = m
this.detailType = 'movie'
this.showDetailModal = true
}
})
} else if (this.currentTab === 2) {
TvContent({
tvShows: this.getFilteredTvShows(),
tvGenres: this.tvGenres,
tvGenreIndex: this.tvGenreIndex,
getRatingStars: (r: number) => this.getRatingStars(r),
onGenreChange: (index: number) => {
this.tvGenreIndex = index
},
onTvClick: (t: TvItem) => {
this.selectedTv = t
this.detailType = 'tv'
this.showDetailModal = true
}
})
} else if (this.currentTab === 3) {
VarietyContent({
varietyShows: this.getFilteredVariety(),
varietyTypes: this.varietyTypes,
varietyTypeIndex: this.varietyTypeIndex,
getRatingStars: (r: number) => this.getRatingStars(r),
onTypeChange: (index: number) => {
this.varietyTypeIndex = index
},
onVarietyClick: (v: VarietyItem) => {
this.selectedVariety = v
this.detailType = 'variety'
this.showDetailModal = true
}
})
} else {
ProfileContent({
watchRecords: this.watchRecords,
stats: this.stats,
ratingBars: this.ratingBars,
genreStats: this.genreStats,
genrePercents: this.genrePercents,
getRatingStars: (r: number) => this.getRatingStars(r),
onAddRecord: () => {
this.showAddModal = true
},
onEditRecord: (r: WatchRecord) => {
this.selectedRecord = r
this.editComment = r.comment
this.showEditModal = true
},
onDeleteRecord: (r: WatchRecord) => {
this.selectedRecord = r
this.showDeleteConfirm = true
}
})
}
}
.layoutWeight(1)
.width('100%')
.backgroundColor('#E8EAF6')
// 底部Tab栏
Row() {
ForEach([0, 1, 2, 3, 4], (tabIndex: number) => {
Column() {
Text(['🔥', '🎬', '📺', '🎭', '👤'][tabIndex])
.fontSize(24)
Text(['推荐', '电影', '剧集', '综艺', '我的'][tabIndex])
.fontSize(11)
.fontColor(this.currentTab === tabIndex ? '#1A237E' : '#999999')
.margin({ top: 2 })
}
.layoutWeight(1)
.padding({ top: 8, bottom: 8 })
.onClick(() => {
this.currentTab = tabIndex
})
}, (tabIndex: number) => tabIndex.toString())
}
.width('100%')
.height(60)
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: '#E0E0E0' })
}
.width('100%')
.height('100%')
.backgroundColor('#E8EAF6')
.bindContentCover($$this.showDetailModal, this.detailModalBuilder)
.bindContentCover($$this.showAddModal, this.addModalBuilder)
.bindContentCover($$this.showEditModal, this.editModalBuilder)
.bindContentCover($$this.showDeleteConfirm, this.deleteConfirmBuilder)
}
@Builder
detailModalBuilder() {
Column() {
// 顶部拖拽条
Row() {
Column().width(40).height(4).backgroundColor('#CCCCCC').borderRadius(2)
}
.width('100%')
.justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 8 })
Scroll() {
Column() {
if (this.detailType === 'movie' && this.selectedMovie !== null) {
// 电影详情
Row() {
Text(this.selectedMovie!.poster)
.fontSize(56)
.width(100).height(140)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Text(this.selectedMovie!.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`${this.selectedMovie!.year} · ${this.selectedMovie!.genre} · ${this.selectedMovie!.duration}分钟`)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
Text(this.selectedMovie!.boxOffice)
.fontSize(13)
.fontColor('#E91E63')
.margin({ top: 6 })
Row() {
Text(this.getRatingStars(this.selectedMovie!.rating))
.fontSize(12)
Text(this.selectedMovie!.rating.toFixed(1))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD600')
.margin({ left: 8 })
}
.margin({ top: 8 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
.layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 10 })
// 标签
Row() {
ForEach(this.selectedMovie!.tags, (tag: string) => {
Text(tag)
.fontSize(11)
.fontColor('#3949AB')
.backgroundColor('#E8EAF6')
.borderRadius(10)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.margin({ right: 8 })
}, (tag: string) => tag)
}
.width('100%')
.padding({ left: 20, top: 12 })
// 简介
Text('剧情简介')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Text(this.selectedMovie!.description)
.fontSize(13)
.fontColor('#555555')
.lineHeight(22)
.margin({ top: 8, left: 20, right: 20 })
// 演职人员
Text('演职人员')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Text(`导演:${this.selectedMovie!.director}`)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6, left: 20 })
Scroll() {
Row() {
ForEach(this.selectedMovie!.actors, (actor: string) => {
Column() {
Text('🎭')
.fontSize(32)
.width(56).height(56)
.backgroundColor('#E8EAF6')
.borderRadius(28)
.textAlign(TextAlign.Center)
Text(actor)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 4 })
.maxLines(1)
}
.margin({ right: 14 })
}, (actor: string) => actor)
}
.padding({ left: 20, right: 20, top: 10 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
// 评分分布
Text('评分分布')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Column() {
ForEach(this.ratingBars, (bar: RatingBar) => {
Row() {
Text(`${bar.star}星`)
.fontSize(12)
.fontColor('#666666')
.width(36)
Column() {
Column()
.width(`${bar.percent}%`)
.height(12)
.backgroundColor(['#E91E63', '#FF6D00', '#FFD600', '#8BC34A', '#00BCD4'][5 - bar.star])
.borderRadius(6)
}
.layoutWeight(1)
.height(12)
.backgroundColor('#F0F0F0')
.borderRadius(6)
.margin({ left: 8, right: 8 })
Text(`${bar.count}`)
.fontSize(11)
.fontColor('#999999')
.width(50)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6 })
.padding({ left: 20, right: 20 })
}, (bar: RatingBar) => bar.star.toString())
}
.margin({ top: 8, bottom: 20 })
} else if (this.detailType === 'tv' && this.selectedTv !== null) {
// 剧集详情
Row() {
Text(this.selectedTv!.poster)
.fontSize(56)
.width(100).height(140)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Text(this.selectedTv!.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`${this.selectedTv!.year} · ${this.selectedTv!.genre}`)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
Text(`${this.selectedTv!.currentEpisode}/${this.selectedTv!.episodes}集 · ${this.selectedTv!.platform}`)
.fontSize(13)
.fontColor('#3949AB')
.margin({ top: 6 })
Row() {
Text(this.getRatingStars(this.selectedTv!.rating))
.fontSize(12)
Text(this.selectedTv!.rating.toFixed(1))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD600')
.margin({ left: 8 })
}
.margin({ top: 8 })
Text(`更新:${this.selectedTv!.updateDay}`)
.fontSize(12)
.fontColor(this.selectedTv!.updateDay === '更新中' ? '#00E676' : '#999999')
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
.layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 10 })
Text('剧情简介')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Text(this.selectedTv!.description)
.fontSize(13)
.fontColor('#555555')
.lineHeight(22)
.margin({ top: 8, left: 20, right: 20, bottom: 20 })
Text('演职人员')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 4, left: 20 })
Text(`导演:${this.selectedTv!.director}`)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6, left: 20 })
Scroll() {
Row() {
ForEach(this.selectedTv!.actors, (actor: string) => {
Column() {
Text('🎭')
.fontSize(32)
.width(56).height(56)
.backgroundColor('#E8EAF6')
.borderRadius(28)
.textAlign(TextAlign.Center)
Text(actor)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 4 })
.maxLines(1)
}
.margin({ right: 14 })
}, (actor: string) => actor)
}
.padding({ left: 20, right: 20, top: 10, bottom: 20 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
} else if (this.detailType === 'variety' && this.selectedVariety !== null) {
// 综艺详情
Row() {
Text(this.selectedVariety!.poster)
.fontSize(56)
.width(100).height(140)
.backgroundColor('#F5F5F5')
.borderRadius(12)
.textAlign(TextAlign.Center)
Column() {
Text(this.selectedVariety!.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`${this.selectedVariety!.type} · 第${this.selectedVariety!.season}季`)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 6 })
Text(`${this.selectedVariety!.episodes}期 · ${this.selectedVariety!.platform}`)
.fontSize(13)
.fontColor('#3949AB')
.margin({ top: 6 })
Row() {
Text(this.getRatingStars(this.selectedVariety!.rating))
.fontSize(12)
Text(this.selectedVariety!.rating.toFixed(1))
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#FFD600')
.margin({ left: 8 })
}
.margin({ top: 8 })
Text(`主持人:${this.selectedVariety!.host}`)
.fontSize(12)
.fontColor('#666666')
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 14 })
.layoutWeight(1)
}
.width('100%')
.padding({ left: 20, right: 20, top: 10 })
Text('节目简介')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Text(this.selectedVariety!.description)
.fontSize(13)
.fontColor('#555555')
.lineHeight(22)
.margin({ top: 8, left: 20, right: 20 })
Text('嘉宾阵容')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 16, left: 20 })
Scroll() {
Row() {
ForEach(this.selectedVariety!.guests, (guest: string) => {
Column() {
Text('🌟')
.fontSize(32)
.width(56).height(56)
.backgroundColor('#E8EAF6')
.borderRadius(28)
.textAlign(TextAlign.Center)
Text(guest)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 4 })
.maxLines(1)
}
.margin({ right: 14 })
}, (guest: string) => guest)
}
.padding({ left: 20, right: 20, top: 10, bottom: 20 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
}
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
// 关闭按钮
Button('关闭')
.width('90%')
.height(44)
.backgroundColor('#1A237E')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ bottom: 24, top: 10 })
.onClick(() => {
this.showDetailModal = false
})
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
.constraintSize({ maxHeight: '85%' })
}
@Builder
addModalBuilder() {
Column() {
Text('➕ 新增观影记录')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 24, bottom: 20 })
Column() {
Text('影片名称')
.fontSize(14)
.fontColor('#333333')
.alignSelf(ItemAlign.Start)
TextInput({ placeholder: '请输入影片名称', text: this.newTitle })
.width('100%')
.height(44)
.backgroundColor('#F5F5F5')
.borderRadius(10)
.margin({ top: 8 })
.onChange((val: string) => {
this.newTitle = val
})
Text('类型')
.fontSize(14)
.fontColor('#333333')
.margin({ top: 16 })
.alignSelf(ItemAlign.Start)
Row() {
ForEach(['动作', '喜剧', '科幻', '悬疑', '爱情', '动画'], (g: string) => {
Text(g)
.fontSize(12)
.fontColor(this.newGenre === g ? '#FFFFFF' : '#3949AB')
.backgroundColor(this.newGenre === g ? '#1A237E' : '#E8EAF6')
.borderRadius(14)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.newGenre = g
})
}, (g: string) => g)
}
.width('100%')
.margin({ top: 8 })
Text('评分')
.fontSize(14)
.fontColor('#333333')
.margin({ top: 16 })
.alignSelf(ItemAlign.Start)
Row() {
ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], (s: number) => {
Text(`${s}`)
.fontSize(14)
.fontColor(this.newRating === s ? '#FFFFFF' : '#FFD600')
.backgroundColor(this.newRating === s ? '#FFD600' : '#FFF8E1')
.borderRadius(16)
.width(32).height(32)
.textAlign(TextAlign.Center)
.margin({ right: 6 })
.onClick(() => {
this.newRating = s
})
}, (s: number) => s.toString())
}
.width('100%')
.margin({ top: 8 })
Text('观后感')
.fontSize(14)
.fontColor('#333333')
.margin({ top: 16 })
.alignSelf(ItemAlign.Start)
TextArea({ placeholder: '写下你的观后感...', text: this.newComment })
.width('100%')
.height(80)
.backgroundColor('#F5F5F5')
.borderRadius(10)
.margin({ top: 8 })
.onChange((val: string) => {
this.newComment = val
})
}
.width('90%')
Row() {
Button('取消')
.layoutWeight(1)
.height(44)
.backgroundColor('#F5F5F5')
.fontColor('#666666')
.fontSize(15)
.borderRadius(22)
.margin({ right: 10 })
.onClick(() => {
this.showAddModal = false
this.newTitle = ''
this.newComment = ''
this.newRating = 5
})
Button('保存')
.layoutWeight(1)
.height(44)
.backgroundColor('#1A237E')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ left: 10 })
.onClick(() => {
let newId: number = this.watchRecords.length + 1
this.watchRecords.unshift({
id: newId,
title: this.newTitle || '未命名',
genre: this.newGenre,
rating: this.newRating,
comment: this.newComment || '暂无评论',
date: '2026-08-06',
poster: '🎬'
})
this.showAddModal = false
this.newTitle = ''
this.newComment = ''
this.newRating = 5
})
}
.width('90%')
.margin({ top: 20, bottom: 24 })
}
.width('100%')
.height('auto')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.constraintSize({ maxHeight: '80%' })
}
@Builder
editModalBuilder() {
Column() {
Text('✏️ 编辑评论')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ top: 24, bottom: 8 })
if (this.selectedRecord !== null) {
Text(this.selectedRecord!.title)
.fontSize(15)
.fontColor('#666666')
.margin({ bottom: 16 })
Text('观后感')
.fontSize(14)
.fontColor('#333333')
.margin({ bottom: 8 })
.alignSelf(ItemAlign.Start)
TextArea({ text: this.editComment })
.width('90%')
.height(100)
.backgroundColor('#F5F5F5')
.borderRadius(10)
.onChange((val: string) => {
this.editComment = val
})
}
Row() {
Button('取消')
.layoutWeight(1)
.height(44)
.backgroundColor('#F5F5F5')
.fontColor('#666666')
.fontSize(15)
.borderRadius(22)
.margin({ right: 10 })
.onClick(() => {
this.showEditModal = false
})
Button('保存')
.layoutWeight(1)
.height(44)
.backgroundColor('#00E676')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ left: 10 })
.onClick(() => {
if (this.selectedRecord !== null) {
let idx: number = this.watchRecords.findIndex((r: WatchRecord) => r.id === this.selectedRecord!.id)
if (idx >= 0) {
this.watchRecords[idx].comment = this.editComment
}
}
this.showEditModal = false
})
}
.width('90%')
.margin({ top: 20, bottom: 24 })
}
.width('100%')
.height('auto')
.backgroundColor('#FFFFFF')
.borderRadius(20)
.constraintSize({ maxHeight: '60%' })
}
@Builder
deleteConfirmBuilder() {
Column() {
Text('🗑️ 确认删除')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#E91E63')
.margin({ top: 28, bottom: 12 })
if (this.selectedRecord !== null) {
Text(`确定要删除「${this.selectedRecord!.title}」的观影记录吗?`)
.fontSize(14)
.fontColor('#666666')
.textAlign(TextAlign.Center)
.lineHeight(22)
.margin({ bottom: 20, left: 30, right: 30 })
}
Row() {
Button('取消')
.layoutWeight(1)
.height(44)
.backgroundColor('#F5F5F5')
.fontColor('#666666')
.fontSize(15)
.borderRadius(22)
.margin({ right: 10 })
.onClick(() => {
this.showDeleteConfirm = false
})
Button('删除')
.layoutWeight(1)
.height(44)
.backgroundColor('#E91E63')
.fontColor('#FFFFFF')
.fontSize(15)
.borderRadius(22)
.margin({ left: 10 })
.onClick(() => {
if (this.selectedRecord !== null) {
this.watchRecords = this.watchRecords.filter((r: WatchRecord) => r.id !== this.selectedRecord!.id)
}
this.showDeleteConfirm = false
})
}
.width('85%')
.margin({ bottom: 24 })
}
.width('85%')
.height('auto')
.backgroundColor('#FFFFFF')
.borderRadius(20)
}
}
// ========== 推荐Tab ==========
@Component
struct RecommendContent {
@Prop recommendMovies: MovieItem[]
@Prop movies: MovieItem[]
@Prop tvShows: TvItem[]
@Prop varietyShows: VarietyItem[]
getRatingStars: (r: number) => string = (r: number) => ''
onMovieClick: (m: MovieItem) => void = (m: MovieItem) => {}
onTvClick: (t: TvItem) => void = (t: TvItem) => {}
onVarietyClick: (v: VarietyItem) => void = (v: VarietyItem) => {}
build() {
Scroll() {
Column() {
// 热门推荐横滚大卡片
Text('🔥 热门推荐')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ left: 16, top: 16, bottom: 12 })
.alignSelf(ItemAlign.Start)
Scroll() {
Row() {
ForEach(this.recommendMovies, (movie: MovieItem) => {
Stack({ alignContent: Alignment.TopEnd }) {
Column() {
Text(movie.poster)
.fontSize(48)
.width('100%').height(120)
.backgroundColor('#3949AB')
.borderRadius({ topLeft: 12, topRight: 12 })
.textAlign(TextAlign.Center)
Column() {
Text(movie.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.maxLines(1)
Row() {
Text('⭐')
.fontSize(10)
Text(movie.rating.toFixed(1))
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
Text(`${movie.year}`)
.fontSize(10)
.fontColor('#999999')
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.width('100%')
.padding(8)
.alignItems(HorizontalAlign.Start)
}
.width(140)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.onClick(() => {
this.onMovieClick(movie)
})
Text('HOT')
.fontSize(9)
.fontColor('#FFFFFF')
.backgroundColor('#E91E63')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 6, right: 6 })
}
.margin({ right: 12 })
}, (movie: MovieItem) => movie.id.toString())
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
// 分类入口
Text('📂 分类导航')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ left: 16, top: 20, bottom: 12 })
.alignSelf(ItemAlign.Start)
Row() {
ForEach([
{ icon: '🎬', label: '电影', color: '#E91E63' },
{ icon: '📺', label: '剧集', color: '#3949AB' },
{ icon: '🎭', label: '综艺', color: '#00E676' },
{ icon: '⭐', label: '收藏', color: '#FFD600' }
], (cat: Record<string, string>) => {
Column() {
Text(cat.icon)
.fontSize(28)
Text(cat.label)
.fontSize(12)
.fontColor('#333333')
.margin({ top: 4 })
}
.layoutWeight(1)
.padding({ top: 14, bottom: 14 })
.backgroundColor('#FFFFFF')
.borderRadius(12)
.margin({ left: 4, right: 4 })
}, (cat: Record<string, string>) => cat.label)
}
.width('92%')
.margin({ bottom: 12 })
// 本周热门电影
Text('📈 本周热门电影')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ left: 16, top: 8, bottom: 12 })
.alignSelf(ItemAlign.Start)
Column() {
ForEach(this.movies.slice(0, 5), (movie: MovieItem, index: number) => {
Row() {
Text(`${index + 1}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(index < 3 ? '#E91E63' : '#999999')
.width(30)
.textAlign(TextAlign.Center)
Text(movie.poster)
.fontSize(32)
.width(50).height(60)
.backgroundColor('#E8EAF6')
.borderRadius(8)
.textAlign(TextAlign.Center)
Column() {
Text(movie.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`${movie.genre} · ${movie.year} · ${movie.director}`)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
.maxLines(1)
Row() {
Text(this.getRatingStars(movie.rating))
.fontSize(9)
Text(movie.rating.toFixed(1))
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('›')
.fontSize(24)
.fontColor('#CCCCCC')
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(12)
.margin({ bottom: 8 })
.onClick(() => {
this.onMovieClick(movie)
})
}, (movie: MovieItem) => movie.id.toString())
}
.padding({ left: 16, right: 16 })
// 热门剧集
Text('🔥 热门剧集')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ left: 16, top: 12, bottom: 12 })
.alignSelf(ItemAlign.Start)
Column() {
ForEach(this.tvShows.slice(0, 4), (tv: TvItem, index: number) => {
Row() {
Text(`${index + 1}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(index < 3 ? '#E91E63' : '#999999')
.width(30)
.textAlign(TextAlign.Center)
Text(tv.poster)
.fontSize(32)
.width(50).height(60)
.backgroundColor('#E8EAF6')
.borderRadius(8)
.textAlign(TextAlign.Center)
Column() {
Text(tv.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`${tv.genre} · ${tv.currentEpisode}/${tv.episodes}集`)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
Text(tv.updateDay === '更新中' ? '🟢 更新中' : '✅ 已完结')
.fontSize(11)
.fontColor(tv.updateDay === '更新中' ? '#00E676' : '#999999')
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Text('›')
.fontSize(24)
.fontColor('#CCCCCC')
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(12)
.margin({ bottom: 8 })
.onClick(() => {
this.onTvClick(tv)
})
}, (tv: TvItem) => tv.id.toString())
}
.padding({ left: 16, right: 16, bottom: 20 })
}
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
// ========== 电影Tab ==========
@Component
struct MovieContent {
@Prop movies: MovieItem[]
@Prop movieGenres: GenreTag[]
@Prop movieGenreIndex: number
getRatingStars: (r: number) => string = (r: number) => ''
onGenreChange: (index: number) => void = (index: number) => {}
onMovieClick: (m: MovieItem) => void = (m: MovieItem) => {}
build() {
Column() {
// 类型筛选标签
Scroll() {
Row() {
ForEach(this.movieGenres, (genre: GenreTag, index: number) => {
Text(genre.name)
.fontSize(13)
.fontColor(this.movieGenreIndex === index ? '#FFFFFF' : genre.color)
.backgroundColor(this.movieGenreIndex === index ? genre.color : '#FFFFFF')
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.onGenreChange(index)
})
}, (genre: GenreTag) => genre.name)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ top: 12, bottom: 12 })
// 电影网格
Scroll() {
Grid() {
ForEach(this.movies, (movie: MovieItem) => {
GridItem() {
Column() {
Stack({ alignContent: Alignment.TopEnd }) {
Text(movie.poster)
.fontSize(48)
.width('100%').height(130)
.backgroundColor('#E8EAF6')
.borderRadius({ topLeft: 10, topRight: 10 })
.textAlign(TextAlign.Center)
Text(movie.rating.toFixed(1))
.fontSize(11)
.fontColor('#1A237E')
.backgroundColor('#FFD600')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 6, right: 6 })
}
.width('100%')
Column() {
Text(movie.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.maxLines(1)
Row() {
Text(this.getRatingStars(movie.rating))
.fontSize(8)
Text(`${movie.year}`)
.fontSize(10)
.fontColor('#999999')
.margin({ left: 6 })
}
.margin({ top: 4 })
Text(movie.genre)
.fontSize(10)
.fontColor('#3949AB')
.backgroundColor('#E8EAF6')
.borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 4 })
.alignSelf(ItemAlign.Start)
}
.width('100%')
.padding(8)
.alignItems(HorizontalAlign.Start)
}
.backgroundColor('#FFFFFF')
.borderRadius(10)
.onClick(() => {
this.onMovieClick(movie)
})
}
}, (movie: MovieItem) => movie.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding({ left: 16, right: 16, bottom: 20 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}
// ========== 剧集Tab ==========
@Component
struct TvContent {
@Prop tvShows: TvItem[]
@Prop tvGenres: GenreTag[]
@Prop tvGenreIndex: number
getRatingStars: (r: number) => string = (r: number) => ''
onGenreChange: (index: number) => void = (index: number) => {}
onTvClick: (t: TvItem) => void = (t: TvItem) => {}
build() {
Column() {
Scroll() {
Row() {
ForEach(this.tvGenres, (genre: GenreTag, index: number) => {
Text(genre.name)
.fontSize(13)
.fontColor(this.tvGenreIndex === index ? '#FFFFFF' : genre.color)
.backgroundColor(this.tvGenreIndex === index ? genre.color : '#FFFFFF')
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.onGenreChange(index)
})
}, (genre: GenreTag) => genre.name)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ top: 12, bottom: 12 })
Scroll() {
Grid() {
ForEach(this.tvShows, (tv: TvItem) => {
GridItem() {
Column() {
Stack({ alignContent: Alignment.TopEnd }) {
Text(tv.poster)
.fontSize(48)
.width('100%').height(130)
.backgroundColor('#E8EAF6')
.borderRadius({ topLeft: 10, topRight: 10 })
.textAlign(TextAlign.Center)
Text(tv.rating.toFixed(1))
.fontSize(11)
.fontColor('#1A237E')
.backgroundColor('#FFD600')
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ top: 6, right: 6 })
}
.width('100%')
Column() {
Text(tv.title)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.maxLines(1)
Row() {
Text(this.getRatingStars(tv.rating))
.fontSize(8)
Text(`${tv.episodes}集`)
.fontSize(10)
.fontColor('#999999')
.margin({ left: 6 })
}
.margin({ top: 4 })
Row() {
Text(tv.updateDay === '更新中' ? '🟢' : '✅')
.fontSize(10)
Text(tv.updateDay)
.fontSize(10)
.fontColor(tv.updateDay === '更新中' ? '#00E676' : '#999999')
.margin({ left: 2 })
}
.margin({ top: 4 })
}
.width('100%')
.padding(8)
.alignItems(HorizontalAlign.Start)
}
.backgroundColor('#FFFFFF')
.borderRadius(10)
.onClick(() => {
this.onTvClick(tv)
})
}
}, (tv: TvItem) => tv.id.toString())
}
.columnsTemplate('1fr 1fr')
.columnsGap(12)
.rowsGap(12)
.padding({ left: 16, right: 16, bottom: 20 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}
// ========== 综艺Tab ==========
@Component
struct VarietyContent {
@Prop varietyShows: VarietyItem[]
@Prop varietyTypes: GenreTag[]
@Prop varietyTypeIndex: number
getRatingStars: (r: number) => string = (r: number) => ''
onTypeChange: (index: number) => void = (index: number) => {}
onVarietyClick: (v: VarietyItem) => void = (v: VarietyItem) => {}
build() {
Column() {
Scroll() {
Row() {
ForEach(this.varietyTypes, (type: GenreTag, index: number) => {
Text(type.name)
.fontSize(13)
.fontColor(this.varietyTypeIndex === index ? '#FFFFFF' : type.color)
.backgroundColor(this.varietyTypeIndex === index ? type.color : '#FFFFFF')
.borderRadius(16)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => {
this.onTypeChange(index)
})
}, (type: GenreTag) => type.name)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.padding({ top: 12, bottom: 12 })
Scroll() {
Column() {
ForEach(this.varietyShows, (variety: VarietyItem) => {
Row() {
Text(variety.poster)
.fontSize(36)
.width(70).height(90)
.backgroundColor('#E8EAF6')
.borderRadius(10)
.textAlign(TextAlign.Center)
Column() {
Row() {
Text(variety.title)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`S${variety.season}`)
.fontSize(10)
.fontColor('#E91E63')
.backgroundColor('#FCE4EC')
.borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.margin({ left: 8 })
}
.alignItems(VerticalAlign.Center)
Text(`${variety.type} · ${variety.episodes}期 · ${variety.platform}`)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 6 })
Row() {
Text(this.getRatingStars(variety.rating))
.fontSize(9)
Text(variety.rating.toFixed(1))
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
}
.margin({ top: 6 })
Text(`主持人:${variety.host}`)
.fontSize(11)
.fontColor('#666666')
.margin({ top: 4 })
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
Column() {
Text(variety.isFollowing ? '❤️' : '🤍')
.fontSize(20)
Text('›')
.fontSize(24)
.fontColor('#CCCCCC')
.margin({ top: 8 })
}
.justifyContent(FlexAlign.Center)
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(12)
.margin({ bottom: 10 })
.onClick(() => {
this.onVarietyClick(variety)
})
}, (variety: VarietyItem) => variety.id.toString())
}
.padding({ left: 16, right: 16, bottom: 20 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}
// ========== 我的Tab ==========
@Component
struct ProfileContent {
@Prop watchRecords: WatchRecord[]
@Prop stats: StatItem[]
@Prop ratingBars: RatingBar[]
@Prop genreStats: GenreTag[]
@Prop genrePercents: number[]
getRatingStars: (r: number) => string = (r: number) => ''
onAddRecord: () => void = () => {}
onEditRecord: (r: WatchRecord) => void = (r: WatchRecord) => {}
onDeleteRecord: (r: WatchRecord) => void = (r: WatchRecord) => {}
build() {
Scroll() {
Column() {
// 用户信息
Row() {
Text('👤')
.fontSize(40)
.width(60).height(60)
.backgroundColor('#3949AB')
.borderRadius(30)
.textAlign(TextAlign.Center)
Column() {
Text('影迷达人')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text('Lv.8 · 观影238部')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.margin({ left: 14 })
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Button('➕ 记录')
.height(32)
.backgroundColor('#1A237E')
.fontColor('#FFFFFF')
.fontSize(12)
.borderRadius(16)
.onClick(() => {
this.onAddRecord()
})
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(14)
.margin({ top: 16 })
// 统计卡片
Row() {
ForEach(this.stats, (stat: StatItem) => {
Column() {
Text(stat.value)
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(stat.color)
Text(stat.label)
.fontSize(11)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
}, (stat: StatItem) => stat.label)
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding({ top: 16, bottom: 16 })
.margin({ top: 12 })
// 评分分布
Text('📊 评分分布')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ left: 16, top: 16, bottom: 10 })
.alignSelf(ItemAlign.Start)
Column() {
ForEach(this.ratingBars, (bar: RatingBar) => {
Row() {
Text(`${bar.star}⭐`)
.fontSize(12)
.fontColor('#666666')
.width(40)
Column() {
Column()
.width(`${bar.percent}%`)
.height(14)
.backgroundColor(['#E91E63', '#FF6D00', '#FFD600', '#8BC34A', '#00BCD4'][5 - bar.star])
.borderRadius(7)
}
.layoutWeight(1)
.height(14)
.backgroundColor('#F0F0F0')
.borderRadius(7)
.margin({ left: 8, right: 8 })
Text(`${bar.count}人`)
.fontSize(11)
.fontColor('#999999')
.width(50)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6 })
}, (bar: RatingBar) => bar.star.toString())
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(14)
.margin({ bottom: 12 })
// 类型占比
Text('🎭 类型偏好')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
.margin({ left: 16, top: 8, bottom: 10 })
.alignSelf(ItemAlign.Start)
Column() {
ForEach(this.genreStats, (genre: GenreTag, index: number) => {
Row() {
Text(genre.name)
.fontSize(12)
.fontColor('#333333')
.width(40)
Column() {
Column()
.width(`${this.genrePercents[index] * 3}%`)
.height(12)
.backgroundColor(genre.color)
.borderRadius(6)
}
.layoutWeight(1)
.height(12)
.backgroundColor('#F0F0F0')
.borderRadius(6)
.margin({ left: 8, right: 8 })
Text(`${this.genrePercents[index]}%`)
.fontSize(11)
.fontColor('#999999')
.width(36)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 5 })
}, (genre: GenreTag) => genre.name)
}
.width('92%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.padding(14)
.margin({ bottom: 12 })
// 观影记录列表
Row() {
Text('📝 观影记录')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Text(`(${this.watchRecords.length})`)
.fontSize(13)
.fontColor('#999999')
.margin({ left: 6 })
}
.width('92%')
.margin({ top: 8, bottom: 10 })
Column() {
ForEach(this.watchRecords, (record: WatchRecord) => {
Column() {
Row() {
Text(record.poster)
.fontSize(32)
.width(50).height(60)
.backgroundColor('#E8EAF6')
.borderRadius(8)
.textAlign(TextAlign.Center)
Column() {
Text(record.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor('#1A237E')
Row() {
Text(record.genre)
.fontSize(10)
.fontColor('#3949AB')
.backgroundColor('#E8EAF6')
.borderRadius(6)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Text(this.getRatingStars(record.rating))
.fontSize(9)
.margin({ left: 8 })
Text(`${record.rating}`)
.fontSize(12)
.fontColor('#FFD600')
.fontWeight(FontWeight.Bold)
.margin({ left: 4 })
}
.margin({ top: 6 })
.alignItems(VerticalAlign.Center)
Text(record.comment)
.fontSize(12)
.fontColor('#666666')
.margin({ top: 6 })
.maxLines(2)
.lineHeight(18)
Text(record.date)
.fontSize(10)
.fontColor('#CCCCCC')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
.layoutWeight(1)
Column() {
Text('✏️')
.fontSize(18)
.padding(4)
.onClick(() => {
this.onEditRecord(record)
})
Text('🗑️')
.fontSize(18)
.margin({ top: 8 })
.padding(4)
.onClick(() => {
this.onDeleteRecord(record)
})
}
}
.width('100%')
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(12)
.padding(12)
.margin({ bottom: 8 })
}, (record: WatchRecord) => record.id.toString())
}
.width('92%')
.padding({ bottom: 20 })
}
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
15.3 可扩展性展望
当前的实现已经具备了良好的扩展基础。如果要进一步增强应用功能,可以考虑以下方向:

在数据持久化方面,当前的数据都是内存中的硬编码数据,应用关闭后即丢失。可以引入鸿蒙的轻量级数据存储(如 Preferences)或关系型数据库来持久化观影记录和用户偏好。
在推荐算法方面,当前的推荐逻辑仅基于评分阈值筛选,较为简单。可以结合用户的观影记录、评分历史、类型偏好等数据,实现协同过滤或内容推荐算法,提供更加个性化的推荐结果。
在网络请求方面,可以接入真实的影视数据 API(如豆瓣 API、TMDB 等),实现实时数据获取。当前的 aboutToAppear 生命周期方法已经为异步数据加载预留了入口。
更多推荐
所有评论(0)