本文以"QQ摄影·光影集"摄影社区App演示页面为实战案例,全面深入解析如何使用HarmonyOS 6.1.1平台的ArkTS语言与HarmonyOS ArkTS API 24构建一个功能完整、视觉精致的移动端社区应用页面。


一、技术引言

HarmonyOS 6.1.1是华为面向全场景智慧化时代推出的分布式操作系统的重要演进版本,其开发框架在ArkTS语言层面持续强化类型安全与声明式UI能力。HarmonyOS ArkTS API 24作为当前版本的核心开发接口集,提供了完善的组件化声明式UI范式,包括状态管理装饰器(@State@Observed@Builder)、布局容器(ColumnRowStackGridScroll)、以及丰富的UI组件(TextTextInputCircle等)和样式系统能力(linearGradientborderRadiusposition等)。

本文以一个摄影社区App的演示页面为载体,深入剖析如何基于HarmonyOS API 24的ArkTS声明式开发框架,从零构建一个包含底部四Tab导航、顶部六功能Tab横向滚动、四种弹框交互、柱状图数据可视化、光斑粒子动画特效的完整社区应用页面。该页面采用"石墨+琥珀"的胶片风格设计语言,融合了作品发现流、灵感广场墙、器材推荐清单、个人主页管理四大功能场景。通过对每一个代码段的逐行解析,读者将系统掌握ArkTS状态驱动模型、@Builder复用函数、生命周期钩子、ForEach列表渲染、定时器动画引擎等核心技术能力在真实业务场景中的工程实践方法。


二、架构总览与Mermaid流程图

2.1 整体架构图

在深入代码细节之前,我们先从宏观架构层面理解这个摄影社区应用的整体结构。整个应用以一个@Entry @Component装饰的主组件为根节点,内部通过状态变量驱动四大内容区域的切换,同时叠加粒子特效层和弹框遮罩层。

状态驱动

状态驱动

@Entry @Component
Index 主组件

状态管理层
@State × 12个状态变量

生命周期层
aboutToAppear / aboutToDisappear

构建层 build()

Stack 根容器

Column 主内容区

particleLayer 粒子层

modalOverlay 弹框层

headerBar 顶部导航

内容区域条件渲染

bottomTabBar 底部Tab

discoverContent 发现流

plazaContent 广场墙

inspireContent 灵感页

mineContent 我的页

publishModal 发布弹框

editProfileModal 编辑弹框

deletePhotoModal 删除弹框

photoDetailModal 详情弹框

如上图所示,整个应用的架构可以分为三大层次。最上层是状态管理层,通过12个@State装饰的状态变量统一管理应用的全部交互状态。中间层是生命周期层,负责在组件挂载时初始化粒子动画定时器、在组件卸载时清理定时器资源。最下层是构建层,通过build()方法组装出Stack根容器,其内部分为主内容区(Column)、粒子特效层和弹框遮罩层三个Z轴层叠区域。

主内容区通过bottomTab状态变量进行条件渲染,当用户切换底部Tab时,对应的内容构建器会被调用。弹框层则通过四个布尔状态变量控制四种弹框的显示与隐藏。这种架构设计使得状态与视图之间保持清晰的单向数据流,符合ArkTS声明式UI的核心设计理念。

2.2 数据流图

组件层

函数层

数据层

COLORS 配色常量

CATEGORY_CONFIG
分类元信息

FEATURE_CONFIG
功能元信息

PHOTO_LIST
15条作品数据

WEEK_LIKE_STATS
周点赞统计

TOPIC_CARDS
话题卡片

GEAR_LIST
器材清单

getCategoryMeta()

getFeatureMeta()

countText()

exifText()

barHeight()

filterByCategory()

headerBar

topTabBar

photoBigCard

discoverContent

plazaContent

inspireContent

mineContent

4个弹框Builder

在这里插入图片描述

数据流图展示了从静态数据层到组件层的完整数据流转路径。静态数据常量(配色、分类配置、作品列表等)定义在组件外部,属于应用级别的不可变数据。纯函数层作为数据与组件之间的桥梁,负责对原始数据进行格式转换、过滤和映射。组件层通过调用这些纯函数获取处理后的数据,并渲染到对应的UI构建器中。

这种设计模式的优势在于数据与视图的解耦。当数据源需要更换时(例如从本地Mock数据切换为网络请求返回的远程数据),只需修改数据层的定义方式,函数层和组件层的代码无需任何改动。同时,纯函数的引入使得数据转换逻辑可独立测试,提升了代码的可维护性和可测试性。

2.3 组件生命周期与粒子动画引擎图

渲染引擎 particles数组 setInterval定时器 Index组件 用户 渲染引擎 particles数组 setInterval定时器 Index组件 用户 loop [每60ms一帧] 资源释放完成 进入页面 aboutToAppear() 生命周期触发 初始化18个粒子对象 随机x/y/size/color/speed/phase setInterval(callback, 60ms) 读取当前粒子数组 计算新y坐标(y - speed) 计算新size(sin波动) 边界判断(y < -5 则重置为105) 生成nextParticles新数组 赋值给this.particles @State变更触发重渲染 粒子层更新显示 离开页面 aboutToDisappear() 生命周期触发 clearInterval(timerId) 清理定时器

组件生命周期序列图揭示了粒子动画引擎的完整运作机制。当用户进入页面时,ArkTS框架自动调用aboutToAppear()生命周期钩子。在该钩子内,系统首先初始化18个粒子对象,每个粒子包含位置坐标(x, y)、尺寸(size)、颜色(color)、运动速度(speed)和相位偏移(phase)五个属性。随后通过setInterval创建一个每60毫秒执行一次的定时器回调。

在定时器的每次回调中,系统遍历当前粒子数组,对每个粒子的y坐标进行递减运算(模拟光斑上浮),通过Math.sin函数计算尺寸的波动变化(模拟光斑闪烁),并在y坐标超出顶部边界时将其重置到底部(实现循环效果)。新生成的粒子数组被赋值给@State装饰的particles变量,ArkTS的状态观察机制检测到变化后自动触发particleLayer构建器的重渲染,从而在屏幕上呈现连续的动画效果。

当用户离开页面时,aboutToDisappear()钩子被调用,系统通过clearInterval清理定时器,避免内存泄漏和无效计算。这是ArkTS生命周期管理中至关重要的一环,确保组件销毁后不会继续占用系统资源。


三、逐段代码深度解析

代码段1:配色配置接口与全局常量

interface ColorPalette {
  graphite: string
  graphiteLight: string
  amber: string
  amberLight: string
  bg: string
  cardBg: string
  ink: string
  gray: string
  hint: string
  border: string
  white: string
  red: string
  green: string
  film: string
}

const COLORS: ColorPalette = {
  graphite: '#2F3542',
  graphiteLight: '#4A5364',
  amber: '#FFB703',
  amberLight: '#FFF4D6',
  bg: '#F5F3F0',
  cardBg: '#FFFFFF',
  ink: '#28242C',
  gray: '#6B675F',
  hint: '#A8A49B',
  border: '#E8E4DD',
  white: '#FFFFFF',
  red: '#E8506E',
  green: '#3BA99C',
  film: '#1E222B'
}

在这里插入图片描述

本段代码定义了整个应用的配色体系,这是视觉设计的基础层。首先通过interface ColorPalette声明了一个包含14个字符串属性的颜色配置接口,每个属性对应一个语义化的色彩角色。接口的使用确保了颜色常量在编译期接受类型检查,任何拼写错误或遗漏属性都会被ArkTS编译器捕获,从而在开发阶段即消除潜在的类型安全隐患。

COLORS常量对象将每个语义角色映射到具体的十六进制颜色值。这里采用了"石墨+琥珀"的胶片风格设计语言:graphite(石墨黑#2F3542)作为深色主题底色,amber(琥珀金#FFB703)作为主强调色,两者搭配营造出类似复古胶片相机的温暖质感。bg(暖灰#F5F3F0)作为页面背景色,cardBg(纯白#FFFFFF)用于卡片容器,ink(墨色#28242C)用于标题文字,grayhint分别用于次要文字和辅助提示文字,形成了清晰的文字层级。

从工程实践角度看,将所有颜色集中定义为单一常量对象具有三重优势。第一是可维护性——当设计稿调整某个色值时,只需修改一处即可全局生效,避免了散落在各处的硬编码颜色值带来的维护噩梦。第二是语义化——通过COLORS.amber而非'#FFB703'引用颜色,代码的可读性大幅提升,开发者一眼就能理解该颜色的设计意图。第三是一致性——统一的颜色入口确保整个应用的视觉风格保持高度统一,杜绝了不同页面使用近似但不完全相同的色值导致的视觉不协调问题。

此外,film属性(#1E222B)是一个极深的墨黑色,专门用于胶片风格的按钮文字和深色头部区域,与graphite形成了微妙的深色层次区分。red#E8506E)和green#3BA99C)分别用于点赞操作和生态标签,这些功能性颜色同样被纳入统一管理体系。

代码段2:元信息接口定义

interface CategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface FeatureMeta {
  label: string
  color: string
  bg: string
  icon: string
}

interface NavEntry {
  label: string
  icon: string
  color: string
}

interface ParticleDot {
  x: number
  y: number
  size: number
  color: string
  speed: number
  phase: number
}

interface WeekStat {
  day: string
  likes: number
}

interface TopicCard {
  title: string
  desc: string
  icon: string
  color: string
  count: string
}

interface GearItem {
  name: string
  icon: string
  type: string
  price: string
}

在这里插入图片描述

本段代码定义了七个元信息接口,它们是应用数据模型的骨架。在ArkTS的严格类型体系中,接口(interface)不仅用于约束对象字面量的结构,还作为ForEach泛型参数、@State泛型参数和函数参数类型的类型注解,贯穿整个应用的类型安全链路。

CategoryMeta接口定义了摄影分类的元信息结构,包含标签(label)、图标(icon)、主色(color)和背景色(bg)四个属性。这个接口将在分类Tab、作品卡片的分类标签等处使用,确保每个分类拥有一致的视觉表达——例如"人像"分类使用粉红色调,"风光"分类使用蓝色调,"星空"分类使用紫色调等。

FeatureMeta接口与CategoryMeta结构相似,但语义不同,它定义的是功能推荐标签(如"编辑精选"、"热门作品"等)的元信息。在摄影社区中,作品会被打上不同的推荐标签,每个标签拥有专属的颜色和图标,这些视觉差异帮助用户快速识别作品的推荐等级。

ParticleDot接口是粒子动画系统的核心数据结构,包含六个属性:x和y是百分比坐标(0-100),size是粒子直径,color是粒子颜色,speed是上浮速度,phase是正弦波相位。这六个属性共同决定了一个光斑粒子在每一帧的位置、大小和外观。WeekStat接口定义了周统计数据结构,用于柱状图展示。TopicCardGearItem分别定义了话题卡片和器材清单的数据结构。

从类型设计角度看,这些接口都遵循了"最小必要字段"原则——每个接口只包含其业务场景所需的最少属性集合,避免了过度设计。同时,所有属性都使用基本类型(string和number),没有嵌套复杂对象,使得数据序列化、反序列化和Diff比较都保持了高效性。

代码段3:@Observed可观察数据模型

@Observed
class PhotoItem {
  id: number = 0
  title: string = ''
  author: string = ''
  avatar: string = ''
  category: string = ''
  coverColor: string = ''
  icon: string = ''
  likes: number = 0
  views: number = 0
  camera: string = ''
  lens: string = ''
  aperture: string = ''
  shutter: string = ''
  iso: string = ''
  location: string = ''
  tags: string[] = []
  desc: string = ''
  isLiked: boolean = false

  constructor(id: number, title: string, author: string, avatar: string, category: string,
    coverColor: string, icon: string, likes: number, views: number, camera: string,
    lens: string, aperture: string, shutter: string, iso: string, location: string,
    tags: string[], desc: string, isLiked: boolean) {
    this.id = id
    this.title = title
    this.author = author
    this.avatar = avatar
    this.category = category
    this.coverColor = coverColor
    this.icon = icon
    this.likes = likes
    this.views = views
    this.camera = camera
    this.lens = lens
    this.aperture = aperture
    this.shutter = shutter
    this.iso = iso
    this.location = location
    this.tags = tags
    this.desc = desc
    this.isLiked = isLiked
  }
}

在这里插入图片描述

PhotoItem类是整个应用最核心的数据模型,它使用@Observed装饰器进行标记。在HarmonyOS ArkTS API 24中,@Observed装饰器使类的实例成为可观察对象——当该对象的属性发生变化时,依赖于该对象的UI组件会自动触发重渲染。这是ArkTS状态管理系统中实现数据驱动视图的关键机制。

该类包含18个属性,覆盖了摄影作品的所有信息维度。基本信息包括id(唯一标识)、title(作品标题)、author(作者名)、avatar(作者头像emoji)和desc(作品描述)。分类与视觉信息包括category(摄影分类)、coverColor(封面色块颜色)和icon(封面图标emoji)。社交数据包括likes(点赞数)、views(浏览量)和isLiked(当前用户是否已点赞)。

最值得关注的是EXIF(可交换图像文件格式)拍摄参数属性组:camera(机身型号如"Sony A7M4")、lens(镜头规格如"FE 24-70")、aperture(光圈值如"f/8")、shutter(快门速度如"1/250s")和iso(感光度如"ISO100")。这些属性使得每条作品数据不仅是一个社交内容单元,更是一个完整的摄影技术档案。

构造函数采用全参数注入模式,要求在实例化时提供所有18个属性的值。每个属性在声明时都有默认初始值(如id: number = 0title: string = ''),这是ArkTS的语法要求——类的实例属性必须有明确的类型注解和初始值。构造函数内部逐一将参数赋值给this的对应属性,虽然写法较为冗长,但这种显式赋值方式保证了类型安全和赋值逻辑的清晰可读。

tags属性是一个字符串数组,用于存储作品的标签(如"日出"、“云海”、"长焦"等),在详情弹框中以#标签的形式展示。isLiked布尔值在"我的作品"列表中被用作过滤条件——只有被当前用户标记为喜欢的作品才会出现在个人主页列表中。

代码段4:分类与功能配置Record

const CATEGORY_CONFIG: Record<string, CategoryMeta> = {
  '人像': { label: '人像', icon: '👤', color: '#E8506E', bg: '#FCE8EC' },
  '风光': { label: '风光', icon: '🏔', color: '#3D9BE9', bg: '#E5F2FC' },
  '街拍': { label: '街拍', icon: '🚶', color: '#6B675F', bg: '#EFEBE4' },
  '星空': { label: '星空', icon: '🌌', color: '#6C5CA5', bg: '#ECE8F7' },
  '微距': { label: '微距', icon: '🐝', color: '#3BA99C', bg: '#E2F5F2' },
  '胶片': { label: '胶片', icon: '🎞', color: '#C77B3F', bg: '#FAEBDB' }
}

const FEATURE_CONFIG: Record<string, FeatureMeta> = {
  '编辑精选': { label: '编辑精选', color: '#FFB703', bg: '#FFF4D6', icon: '🏅' },
  '首页推荐': { label: '首页推荐', color: '#3D9BE9', bg: '#E5F2FC', icon: '⭐' },
  '热门作品': { label: '热门作品', color: '#E8506E', bg: '#FCE8EC', icon: '🔥' },
  '新锐佳作': { label: '新锐佳作', color: '#3BA99C', bg: '#E2F5F2', icon: '🌱' }
}

在这里插入图片描述

本段代码使用Record<string, T>泛型类型定义了两个查找表配置。在ArkTS中,Record<string, T>是TypeScript的映射类型,表示一个以字符串为键、以泛型T为值的字典结构。这种数据结构非常适合用于"根据分类名称查找对应元信息"的场景。

CATEGORY_CONFIG定义了六个摄影分类的完整元信息:人像(粉红色系,人物图标)、风光(蓝色系,山峰图标)、街拍(灰色系,步行图标)、星空(紫色系,银河图标)、微距(绿色系,蜜蜂图标)、胶片(棕色系,胶卷图标)。每个分类都有专属的前景色(color)和背景色(bg),这组颜色对经过精心搭配,确保在浅色背景上的文字具有良好的可读性。

FEATURE_CONFIG定义了四个功能推荐标签的元信息:编辑精选(琥珀金色,奖章图标)、首页推荐(蓝色,星星图标)、热门作品(红色,火焰图标)、新锐佳作(绿色,幼苗图标)。这些标签会在作品大卡片上根据点赞数动态显示——当作品点赞数超过3000时显示"编辑精选"标签,否则显示"新锐佳作"标签,实现了数据驱动的动态标签逻辑。

使用Record类型而非普通对象字面量的关键优势在于类型安全。当代码通过CATEGORY_CONFIG['人像']访问时,ArkTS编译器知道返回值类型是CategoryMeta,可以享受完整的类型检查和智能提示。而使用普通对象时,索引访问的返回类型是any,会失去类型保护。此外,Record类型的另一个优势是在后续代码中通过可选链操作符或undefined判断来安全处理键不存在的情况,这在处理用户自定义分类或动态数据源时尤为重要。

这两个配置对象在整个应用中作为"数据字典"被多个@Builder函数引用,实现了配置的集中管理和全局复用。当需要新增一个摄影分类时,只需在CATEGORY_CONFIG中添加一个键值对,所有引用该配置的UI组件都会自动适配新的分类,无需修改任何组件代码。

代码段5:Tab与静态数据配置数组

const BOTTOM_TABS: NavEntry[] = [
  { label: '发现', icon: '📷', color: '#2F3542' },
  { label: '广场', icon: '🖼', color: '#FFB703' },
  { label: '灵感', icon: '💡', color: '#6C5CA5' },
  { label: '我的', icon: '🎥', color: '#6B675F' }
]

const TOP_TABS: string[] = ['推荐', '人像', '风光', '街拍', '星空', '胶片']

const WEEK_LIKE_STATS: WeekStat[] = [
  { day: '周一', likes: 120 },
  { day: '周二', likes: 89 },
  { day: '周三', likes: 210 },
  { day: '周四', likes: 156 },
  { day: '周五', likes: 320 },
  { day: '周六', likes: 486 },
  { day: '周日', likes: 402 }
]

const TOPIC_CARDS: TopicCard[] = [
  { title: '黄金时刻', desc: '日出日落的温柔光线', icon: '🌅', color: '#FF9F43', count: '12.4k作品' },
  { title: '城市倒影', desc: '雨后街面的镜像世界', icon: '💧', color: '#3D9BE9', count: '8.9k作品' },
  { title: '极简主义', desc: '少即是多的构图美学', icon: '⬜', color: '#6B675F', count: '6.7k作品' },
  { title: '光影手账', desc: '记录生活中的光', icon: '📓', color: '#C77B3F', count: '5.2k作品' }
]

const GEAR_LIST: GearItem[] = [
  { name: 'Sony A7M4', icon: '📷', type: '全画幅微单', price: '¥15999' },
  { name: 'FE 35mm F1.4', icon: '🔭', type: '定焦镜头', price: '¥8999' },
  { name: '富士 X100VI', icon: '🎞', type: '旁轴胶片机', price: '¥11390' },
  { name: '大疆 Mini 4', icon: '🛸', type: '航拍无人机', price: '¥4788' },
  { name: '捷信旅行者', icon: '🦵', type: '碳纤维三脚架', price: '¥2680' },
  { name: '神牛V1', icon: '💡', type: '圆头闪光灯', price: '¥1380' }
]

const STYLE_OPTIONS: string[] = ['自然光', '人造光', '胶片色', '黑白', '赛博', '日系']

在这里插入图片描述

本段代码集中定义了应用所需的全部静态配置数据,共七个数组/常量。这些数据在应用运行期间保持不变,作为UI渲染的数据源被各内容区域的@Builder函数消费。

BOTTOM_TABS定义了底部导航栏的四个入口,每个入口包含标签文字、图标emoji和激活态颜色。四个Tab分别对应"发现"(作品流)、“广场”(作品墙+数据图)、“灵感”(话题+器材)和"我的"(个人主页)四个功能场景。每个Tab的激活色不同,帮助用户通过颜色快速识别当前所处页面。

TOP_TABS是一个简单的字符串数组,定义了顶部Tab的六个分类标签。其中"推荐"是特殊分类,代表不做过滤的全部作品;其余五个对应CATEGORY_CONFIG中的摄影分类。当用户点击某个顶部Tab时,应用会通过filterByCategory函数对PHOTO_LIST进行过滤,只展示对应分类的作品。

WEEK_LIKE_STATS定义了一周的点赞统计数据,用于广场页面的柱状图展示。数据呈现明显的周末高峰特征——周六(486)和周日(402)的点赞数远高于工作日,这符合摄影社区用户在周末有更多时间浏览和互动的行为模式。柱状图组件根据每个数据点的likes值计算柱子高度,超过300的柱子使用琥珀金色高亮,其余使用石墨灰色,形成了视觉上的数据对比。

TOPIC_CARDS定义了四个创作灵感话题,每个话题卡片拥有专属的主题色、图标、描述和作品数量统计。GEAR_LIST定义了六款热门摄影器材,涵盖机身、镜头、胶片机、无人机、三脚架和闪光灯六大品类,每项器材展示名称、类型标签和价格。STYLE_OPTIONS定义了六种色调风格选项,在发布作品弹框中作为可选择的标签按钮组。

代码段6:十五条摄影作品数据

const PHOTO_LIST: PhotoItem[] = [
  new PhotoItem(1, '晨雾中的塔尖', '追光者·阿岚', '🏔', '风光', '#3D9BE9', '🌄', 3284, 12800, 'Sony A7M4', 'FE 24-70', 'f/8', '1/250s', 'ISO100', '黄山·光明顶', ['日出', '云海', '长焦'], '凌晨四点爬起来等的第一缕光,值了', true),
  new PhotoItem(2, '地铁阅读者', '街角快门手', '🚶', '街拍', '#6B675F', '🚇', 2156, 9800, '富士 X100VI', '等效35mm', 'f/2.0', '1/125s', 'ISO800', '上海·地铁2号线', ['街头', '决定性瞬间', '人文'], '车厢里安静读书的人,是这个城市温柔的证据', false),
  new PhotoItem(3, '银河拱桥', '星野小柯', '🌌', '星空', '#6C5CA5', '🌠', 4820, 21000, 'Sony A7M4', 'Sigma 14mm', 'f/1.8', '15s', 'ISO3200', '内蒙·明安图', ['银河', '赤道仪', '堆栈'], '300张堆栈而成的银河拱桥,肉眼看不见的宇宙', true),
  new PhotoItem(4, '回眸', '鹿岛写真馆', '👤', '人像', '#E8506E', '👁', 3567, 15400, 'Canon R6II', 'RF 85mm', 'f/1.2', '1/500s', 'ISO200', '杭州·西湖', ['人像', '大光圈', '情绪'], 'f/1.2下睫毛都数得清的锐利回眸', false),
  new PhotoItem(5, '蜂鸟振翅', '微距阿哲', '🐝', '微距', '#3BA99C', '🦜', 2890, 11300, 'Nikon Z8', '105mm微距', 'f/5.6', '1/2000s', 'ISO400', '云南·西双版纳', ['微距', '高速快门', '生态'], '1/2000s定格的0.1秒,翅膀的纹理纤毫毕现', false),
  new PhotoItem(6, '绿皮火车', '胶片老赵', '🎞', '胶片', '#C77B3F', '🚂', 1985, 8600, 'Nikon FM2', '50mm', 'f/2.8', '1/250s', 'ISO400', '黔东南·凯里', ['胶片', '柯达200', '怀旧'], '柯达200的颗粒感,是数码给不了的温度', true),
  new PhotoItem(7, '霓虹雨夜', '夜色捕手', '🌃', '街拍', '#2F3542', '🌧', 4132, 18700, 'Sony A7S3', 'FE 35mm', 'f/1.4', '1/80s', 'ISO6400', '香港·旺角', ['夜景', '霓虹', '雨'], '高感夜拍之王,夜色就是我的画布', false),
  new PhotoItem(8, '雪山倒影', '风光狗老王', '🏔', '风光', '#4A90D9', '⛰', 3675, 14200, 'Sony A7R5', 'FE 16-35', 'f/11', '1/60s', 'ISO100', '川西·冷嘎措', ['倒影', '雪山', 'GND'], '等了三小时的风停瞬间,湖面如镜', true),
  new PhotoItem(9, 'old phone booth', 'CityWalker', '☎', '街拍', '#7A8B99', '📞', 1543, 6900, '理光GR3', '28mm', 'f/2.8', '1/160s', 'ISO400', '伦敦·东区', ['snap', '理光GR', '街头'], '理光GR的snap快拍,抬手就是一张', false),
  new PhotoItem(10, '逆光少女', '小森林写真', '🌿', '人像', '#E8608A', '💇', 3021, 12800, 'Canon R8', 'RF 50mm', 'f/1.8', '1/1000s', 'ISO100', '大理·洱海', ['逆光', '发丝光', '日系'], '下午五点的逆光,给头发镀了层金边', false),
  new PhotoItem(11, '星轨同心圆', '夜空守望', '⭐', '星空', '#55518A', '🌀', 3980, 16900, 'Sony A7M4', 'Sigma 20mm', 'f/2.0', '20s', 'ISO1600', '青海·茶卡', ['星轨', '延时', '北辰'], '对准北极星的300张合成,时间的同心圆', true),
  new PhotoItem(12, '晨露蜘蛛网', '微距小花', '🕸', '微距', '#2FA36B', '💧', 2534, 9700, 'OM-1', '60mm微距', 'f/4', '1/160s', 'ISO200', '成都·青城山', ['晨露', '手持', '焦点堆栈'], '清晨五点的蛛网,缀满了一整夜的露水', false),
  new PhotoItem(13, '京都小巷', '和风胶片', '⛩', '胶片', '#B0704C', '🏮', 2210, 9100, 'Contax T2', '38mm', 'f/2.8', '1/250s', 'ISO200', '京都·东山', ['胶片', 'CCD色', '旅拍'], '宾得67的色调,一秒穿越到昭和时代', false),
  new PhotoItem(14, '月升金山', '风光狗老王', '🌕', '风光', '#C77B3F', '🌠', 4468, 19800, 'Sony A7R5', 'FE 200-600', 'f/8', '1/15s', 'ISO400', '川西·子梅垭口', ['悬月', '长焦', '月照金山'], '用巧摄算好的机位,月亮刚好落在贡嘎尖上', true),
  new PhotoItem(15, '黑与白', '影调诗人', '🖤', '人像', '#28242C', '🎭', 1876, 8200, 'Leica M11', '35mm', 'f/2.0', '1/125s', 'ISO800', '北京·798', ['黑白', '高对比', '徕卡'], '去掉颜色后,只剩下最纯粹的情绪', false)
]

PHOTO_LIST是应用的核心数据集,包含15条精心构造的摄影作品数据。每条数据通过PhotoItem构造函数实例化,18个参数覆盖了从基本信息到EXIF拍摄参数的完整作品档案。

从数据分布来看,15条作品覆盖了全部六个摄影分类。风光类4条(第1、8、14条及第8条),街拍类4条(第2、7、9条),人像类3条(第4、10、15条),星空类2条(第3、11条),微距类2条(第5、12条),胶片类2条(第6、13条)。这种分布确保了用户在切换不同分类Tab时都能看到对应分类的作品,提供了完整的分类浏览体验。

每条作品的EXIF参数都是真实且有教学意义的。例如第3条"银河拱桥"使用f/1.8大光圈、15s长曝光、ISO3200高感光度,这是典型的星空摄影参数组合;第5条"蜂鸟振翅"使用1/2000s超高速快门,用于定格蜂鸟振翅的瞬间;第7条"霓虹雨夜"使用ISO6400超高感光度,展现了弱光夜景拍摄的技术挑战。这些参数不仅丰富了数据内容,更使应用具有了摄影知识科普的价值。

isLiked属性的分布遵循"约半数已赞"的模式——15条中有7条为true。这个属性在"我的"页面中被用作过滤条件,只有isLikedtrue的作品才出现在"我的作品"列表中,模拟了用户收藏/关注的作品集合。

每条作品的desc描述字段都是一句富有诗意和故事感的文案,如"凌晨四点爬起来等的第一缕光,值了"、"柯达200的颗粒感,是数码给不了的温度"等,这些文案增强了应用的情感化设计,使数据不再冰冷,而是充满人文温度。

代码段7:全局纯函数工具集

function getCategoryMeta(category: string): CategoryMeta {
  const meta: CategoryMeta | undefined = CATEGORY_CONFIG[category]
  if (meta) {
    return meta
  }
  return { label: category, icon: '📷', color: '#6B675F', bg: '#EFEBE4' }
}

function getFeatureMeta(feature: string): FeatureMeta {
  const meta: FeatureMeta | undefined = FEATURE_CONFIG[feature]
  if (meta) {
    return meta
  }
  return { label: feature, color: '#6B675F', bg: '#EFEBE4', icon: '⭐' }
}

function barHeight(likes: number): string {
  return (likes / 20).toString() + 'vp'
}

function countText(count: number): string {
  if (count >= 10000) {
    return (count / 10000).toFixed(1) + 'w'
  }
  if (count >= 1000) {
    return (count / 1000).toFixed(1) + 'k'
  }
  return count.toString()
}

function exifText(item: PhotoItem): string {
  return item.aperture + ' · ' + item.shutter + ' · ' + item.iso
}

function filterByCategory(category: string): PhotoItem[] {
  if (category === '推荐') {
    return PHOTO_LIST
  }
  const result: PhotoItem[] = []
  for (let i = 0; i < PHOTO_LIST.length; i++) {
    if (PHOTO_LIST[i].category === category) {
      result.push(PHOTO_LIST[i])
    }
  }
  return result
}

在这里插入图片描述

本段代码定义了六个全局纯函数,它们是连接静态数据与UI组件的关键桥梁。所谓"纯函数"是指不依赖外部可变状态、不产生副作用、给定输入总是返回相同输出的函数。在ArkTS的声明式UI范式中,纯函数扮演着数据转换器的角色,将原始数据转换为UI可直接消费的格式。

getCategoryMetagetFeatureMeta是两个查找函数,它们接收分类名称或功能名称字符串,在对应的Record配置中查找元信息。两个函数都实现了优雅的降级逻辑——当传入的键在配置表中不存在时(metaundefined),返回一个默认的灰色通用配置,而不是抛出异常。这种防御性编程策略确保了即使数据源中出现了未预定义的分类或功能名称,应用也不会崩溃,而是以统一的灰色通用样式展示,保证了用户体验的稳定性。

barHeight函数将点赞数值转换为柱状图的柱子高度字符串。它将likes值除以20并拼接'vp'单位,例如486点赞对应24.3vp高度。vp(virtual pixel)是HarmonyOS的虚拟像素单位,会根据屏幕密度自动缩放,保证了不同设备上柱状图高度的一致性。

countText函数实现了数值的智能格式化。当数值达到万级(>=10000)时以w(万)为单位显示一位小数,达到千级(>=1000)时以k(千)为单位显示一位小数,否则显示原始数字。例如3284显示为3.3k,21000显示为2.1w。这种格式化策略在社交媒体应用中非常常见,它使大数字更加简洁易读,同时保留了足够的精度。

exifText函数将光圈、快门和ISO三个EXIF参数拼接为一个字符串,如f/8 · 1/250s · ISO100,用于作品卡片上的参数标签展示。filterByCategory函数实现了按分类过滤作品列表的逻辑——当分类为"推荐"时返回全部作品,否则遍历PHOTO_LIST并收集匹配分类的作品。该函数使用传统的for循环加push方式构建结果数组,虽然不如函数式编程中的filter方法简洁,但在ArkTS中这种命令式写法具有更好的性能表现和更明确的类型推导。

代码段8:主组件状态声明

@Entry
@Component
struct Index {
  @State bottomTab: string = '发现'
  @State topTab: string = '推荐'
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedPhoto: PhotoItem = PHOTO_LIST[0]
  @State deleteIndex: number = 0
  @State inputTitle: string = ''
  @State selectStyle: string = '自然光'
  @State inputBio: string = ''
  @State likeCount: number = 3
  @State particles: ParticleDot[] = []
  private timerId: number = -1

本段代码定义了应用的入口组件Index。通过@Entry装饰器标记该组件为页面入口,@Component装饰器声明它为一个ArkTS组件。在HarmonyOS ArkTS API 24中,@Entry组件是应用路由的起点,框架会在页面加载时自动实例化并渲染该组件。

组件内部声明了13个成员变量,其中12个使用@State装饰器标记,1个使用private关键字标记。@State是ArkTS最核心的状态管理装饰器,被它标记的变量具有"可观察"特性——当变量值发生变化时,所有引用该变量的UI部分会自动重渲染。这种响应式机制是声明式UI的核心,开发者只需修改状态数据,视图更新由框架自动完成。

12个@State变量可以按功能分为四组。第一组是导航状态:bottomTab(当前底部Tab,默认"发现")和topTab(当前顶部Tab,默认"推荐"),这两个变量驱动着整个页面内容的切换。第二组是弹框状态:showAddModalshowEditModalshowDeleteModalshowDetailModal四个布尔值分别控制四种弹框的显示隐藏,初始值均为false。第三组是表单状态:inputTitle(发布表单标题输入)、selectStyle(风格选择,默认"自然光")、inputBio(个人简介输入)、likeCount(点赞计数器)。第四组是数据状态:selectedPhoto(当前选中的作品对象,默认PHOTO_LIST[0])、deleteIndex(待删除作品的索引)、particles(粒子数组,初始为空)。

selectedPhoto的状态类型是PhotoItem——一个被@Observed装饰的类实例。当selectedPhoto被赋值为一个新的PhotoItem对象时,ArkTS框架会检测到引用变化并触发依赖该状态的UI重渲染(如详情弹框)。particles是一个ParticleDot[]数组,每次定时器回调生成新数组并赋值给该变量时,粒子层@Builder会自动重渲染。

timerId使用private而非@State,因为定时器ID不需要驱动UI更新——它只在aboutToDisappear时被读取用于清理定时器。将其标记为private避免了不必要的状态观察开销。这个设计细节体现了ArkTS状态管理的精度控制:只有需要驱动视图的数据才用@State,纯逻辑数据用普通变量。

代码段9:aboutToAppear生命周期与粒子初始化

  aboutToAppear(): void {
    const initParticles: ParticleDot[] = []
    for (let i = 0; i < 18; i++) {
      initParticles.push({
        x: Math.random() * 100,
        y: Math.random() * 100,
        size: 3 + Math.random() * 7,
        color: i % 3 === 0 ? '#FFB703' : (i % 3 === 1 ? '#FFD166' : '#FFE8A3'),
        speed: 0.2 + Math.random() * 0.5,
        phase: Math.random() * 6.28
      })
    }
    this.particles = initParticles
    this.timerId = setInterval(() => {
      const nextParticles: ParticleDot[] = []
      for (let i = 0; i < this.particles.length; i++) {
        const p: ParticleDot = this.particles[i]
        let newY: number = p.y - p.speed
        let newSize: number = p.size + Math.sin(newY / 10 + p.phase) * 1.5
        if (newY < -5) {
          newY = 105
        }
        nextParticles.push({ x: p.x + Math.sin(newY / 25 + p.phase) * 0.3, y: newY, size: newSize, color: p.color, speed: p.speed, phase: p.phase })
      }
      this.particles = nextParticles
    }, 60)
  }

在这里插入图片描述

aboutToAppear()是ArkTS组件生命周期的关键钩子函数,它在组件实例创建后、build()方法执行前被框架自动调用。这个时机非常适合进行数据初始化、资源加载和定时器注册等准备工作,因为此时组件的状态变量已经分配了初始值,但UI尚未渲染,在此处修改状态不会触发额外的重渲染开销。

本函数的核心任务是初始化粒子动画系统。首先创建一个空数组initParticles,然后通过for循环生成18个粒子对象。每个粒子的属性通过Math.random()函数随机生成:xy坐标在0到100之间随机分布(对应屏幕百分比坐标),size在3到10之间随机,speed在0.2到0.7之间随机,phase在0到6.28(约2π)之间随机。

粒子的颜色采用了三色轮换策略:索引i % 3 === 0时使用琥珀金#FFB703i % 3 === 1时使用暖黄#FFD166i % 3 === 2时使用浅金#FFE8A3。三种金色系颜色形成了微妙的色调层次,模拟了胶片光斑的暖色质感。

初始化完成后,粒子数组被赋值给this.particles,触发首次粒子层渲染。随后通过setInterval注册了一个每60毫秒(约16.67帧/秒)执行一次的定时器回调。在回调内部,系统遍历当前粒子数组,对每个粒子计算新的状态:newY通过减去speed实现上浮运动;newSize通过Math.sin函数实现尺寸的周期性波动(模拟光斑闪烁);当newY低于-5时重置为105(从底部重新出现),实现循环效果。

x坐标也通过Math.sin函数加入了轻微的水平摆动(p.x + Math.sin(newY / 25 + p.phase) * 0.3),使粒子运动轨迹呈现微妙的S形而非纯垂直上升。最终生成的新粒子数组被赋值给this.particles,ArkTS状态系统检测到数组变化后自动触发粒子层的重渲染,实现了连续的动画效果。

代码段10:aboutToDisappear生命周期与资源清理

  aboutToDisappear(): void {
    if (this.timerId >= 0) {
      clearInterval(this.timerId)
    }
  }

aboutToDisappear()是与aboutToAppear()配对的生命周期钩子,它在组件即将从组件树中移除时被框架调用。这个时机是执行资源清理工作的最后机会——如果在此处不清理定时器、事件监听器等资源,这些资源会在组件销毁后继续占用系统内存和CPU,最终导致内存泄漏和性能下降。

本函数的逻辑非常简洁:检查timerId是否为有效值(>= 0,因为初始值为-1),如果是则调用clearInterval清理定时器。这种"先检查后清理"的防御性编程模式确保了即使aboutToAppear中定时器创建失败(虽然在此场景下不太可能),aboutToDisappear也不会因尝试清理不存在的定时器而抛出异常。

在ArkTS应用开发中,定时器清理是资源管理的高频场景。除了setInterval/clearInterval外,还有setTimeout/clearTimeout、事件监听的注册与注销、网络请求的取消等都需要在aboutToDisappear中进行对称的清理。这个生命周期钩子的重要性经常被开发者忽视,但它对于应用的长期稳定运行至关重要——尤其是在用户频繁切换页面或Tab的场景下,未清理的定时器会不断累积,最终导致应用卡顿甚至崩溃。

从工程规范角度看,aboutToDisappearaboutToAppear之间应该保持"对称性"——在aboutToAppear中创建/注册了多少资源,在aboutToDisappear中就应该清理/注销多少资源。本例中aboutToAppear只创建了一个定时器,因此aboutToDisappear只需清理一个定时器,保持了完美的对称性。

代码段11:粒子层构建器

  @Builder
  particleLayer() {
    ForEach(this.particles, (p: ParticleDot) => {
      Circle()
        .width(p.size)
        .height(p.size)
        .fill(p.color)
        .opacity(0.4)
        .position({ x: p.x + '%', y: p.y + '%' })
    }, (p: ParticleDot) => p.phase.toString() + p.size.toString())
  }

particleLayer是一个被@Builder装饰器标记的UI构建函数。在ArkTS中,@Builder用于定义可复用的UI片段,它本质上是一个返回UI组件树的函数。@Builder函数与普通函数的区别在于:@Builder函数内部使用声明式语法(如Circle()Text()等组件构造器)描述UI结构,框架会将其编译为高效的渲染指令。

本构建器通过ForEach遍历this.particles数组,为每个粒子生成一个Circle圆形组件。Circle是ArkTS的基本图形组件,通过.width().height()设置直径,.fill()设置填充颜色,.opacity()设置透明度为0.4(使光斑呈现半透明效果),.position()设置绝对定位位置。

.position()方法接收一个坐标对象,xy的值通过粒子对象的百分比坐标拼接'%'符号生成(如p.x + '%')。百分比定位使得粒子在不同屏幕尺寸上都能正确分布,实现了响应式布局。

ForEach的第三个参数是键值生成函数(keyGenerator),它为每个列表项生成唯一键值。这里使用p.phase.toString() + p.size.toString()的组合作为键值。由于phasesize都是随机生成的,它们的组合在大多数情况下能保证唯一性。键值的作用是帮助ArkTS的Diff算法高效识别列表项的增删改——当数组更新时,框架通过对比新旧键值来确定哪些项需要新增、哪些需要删除、哪些需要更新,从而最小化DOM操作开销。

粒子层的透明度为0.4,叠加在主内容区上方,营造出一种"光斑漂浮在页面上方"的视觉效果。由于粒子层的Z轴层级高于主内容区(在build()方法中通过Stack的子元素顺序控制),粒子会覆盖在内容上方,但因为透明度较低且尺寸较小,不会影响内容的可读性和交互。

代码段12:顶部导航栏构建器

  @Builder
  headerBar() {
    Row({ space: 10 }) {
      Text('📷')
        .fontSize(24)
      Column({ space: 2 }) {
        Text('光影集')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('ISO 100 · f/1.8 · 快乐摄影')
          .fontSize(10)
          .fontColor('#C9C4BB')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('💬')
        .fontSize(18)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .backgroundColor('rgba(255,255,255,0.12)')
        .borderRadius(12)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .backgroundColor(COLORS.graphite)
  }

headerBar构建器定义了应用的顶部导航栏。整体采用Row横向布局容器,内部从左到右依次排列:相机emoji图标、应用名称与副标题的纵向组合、消息按钮。这种"图标+标题+操作按钮"的三段式结构是移动端导航栏的经典布局模式。

Row通过{ space: 10 }参数设置了子元素之间的10vp间距。第一个Text('📷')作为应用图标,字号24。接着是一个Column纵向容器,包含应用名"光影集"(字号19,加粗,白色)和副标题"ISO 100 · f/1.8 · 快乐摄影"(字号10,浅灰色#C9C4BB)。副标题使用摄影EXIF参数的格式化文本,巧妙地将品牌口号融入摄影元素中,体现了应用的主题一致性。

Column通过.alignItems(HorizontalAlign.Start)设置子元素左对齐,.layoutWeight(1)使其占据剩余空间,将消息按钮推向右侧。layoutWeight是ArkTS弹性布局的关键属性,它让元素按权重分配剩余空间——这里权重为1意味着该列会吃掉图标和按钮之外的全部宽度,实现了"图标左、标题中、按钮右"的布局效果。

最右侧的Text('💬')是消息按钮,使用36x36vp的圆角方块背景(rgba(255,255,255,0.12)半透明白),通过.borderRadius(12)实现了圆角效果,.textAlign(TextAlign.Center)使emoji在按钮区域内居中。

整个导航栏的背景色为COLORS.graphite(石墨黑#2F3542),与浅色页面背景形成鲜明对比。这种深色头部+浅色内容区的设计模式在摄影类应用中非常流行,因为深色头部模拟了相机机身的暗色区域,增强了应用的"摄影工具"属性。

代码段13:顶部Tab栏构建器

  @Builder
  topTabBar() {
    Scroll() {
      Row({ space: 6 }) {
        ForEach(TOP_TABS, (tab: string) => {
          Column({ space: 4 }) {
            Text(getCategoryMeta(tab).icon + ' ' + tab)
              .fontSize(12)
              .fontColor(this.topTab === tab ? COLORS.white : COLORS.gray)
            if (this.topTab === tab) {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor(COLORS.amber)
            }
          }
          .padding({ left: 12, right: 12, top: 9, bottom: 7 })
          .backgroundColor(this.topTab === tab ? COLORS.graphite : COLORS.cardBg)
          .borderRadius(10)
          .onClick(() => {
            this.topTab = tab
          })
        }, (tab: string) => tab)
      }
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .backgroundColor(COLORS.bg)
  }

topTabBar构建器实现了可横向滚动的分类Tab栏。外层是一个Scroll滚动容器,设置了.scrollable(ScrollDirection.Horizontal)启用横向滚动、.scrollBar(BarState.Off)隐藏滚动条,使六个分类Tab可以在不显示滚动条的情况下横向滑动浏览。

内部是一个Row容器,通过ForEach遍历TOP_TABS数组生成六个Tab按钮。每个Tab按钮是一个Column容器,内部包含一个带图标和文字的Text标签,以及一个条件渲染的指示器。Text的内容通过getCategoryMeta(tab).icon + ' ' + tab拼接生成,即调用查找函数获取分类对应的emoji图标并拼接分类名称,例如"👤 人像"、"🏔 风光"等。

Tab的高亮逻辑通过状态比较实现:this.topTab === tab判断当前Tab是否为激活状态。激活Tab的文字颜色为白色(COLORS.white),背景色为石墨黑(COLORS.graphite);非激活Tab的文字颜色为灰色(COLORS.gray),背景色为白色(COLORS.cardBg)。这种颜色对比使激活Tab在视觉上"凸出"于其他Tab。

激活Tab下方还有一个3vp高的琥珀金色圆角条作为指示器,通过if (this.topTab === tab)条件渲染控制——只有激活Tab才会渲染该指示器,非激活Tab不渲染。条件渲染是ArkTS中控制元素显示隐藏的推荐方式,相比通过.visibility()控制,条件渲染不会在组件树中保留隐藏元素,节省了内存和渲染开销。

每个Tab的.onClick()回调将this.topTab赋值为被点击的Tab名称。由于topTab@State变量,赋值后ArkTS自动触发topTabBardiscoverContent(使用filterByCategory(this.topTab))的重渲染,实现了Tab切换时的高亮状态更新和内容列表过滤的联动效果。

代码段14:大图作品卡片构建器

  @Builder
  photoBigCard(item: PhotoItem) {
    Column({ space: 0 }) {
      Stack({ alignContent: Alignment.TopStart }) {
        Column({ space: 4 }) {
          Text(item.icon)
            .fontSize(56)
        }
        .width('100%')
        .height(170)
        .justifyContent(FlexAlign.Center)

        Text(getFeatureMeta(item.likes > 3000 ? '编辑精选' : '新锐佳作').icon + ' ' + getFeatureMeta(item.likes > 3000 ? '编辑精选' : '新锐佳作').label)
          .fontSize(9)
          .fontColor(COLORS.film)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(COLORS.amber)
          .borderRadius(8)
          .margin(8)
      }
      .width('100%')
      .borderRadius({ topLeft: 14, topRight: 14 })

      Column({ space: 8 }) {
        Text(item.title)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')
          .maxLines(1)

        Text(item.desc)
          .fontSize(11)
          .fontColor(COLORS.gray)
          .width('100%')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Row({ space: 10 }) {
          Text(item.avatar)
            .fontSize(16)
            .width(30)
            .height(30)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.amberLight)
            .borderRadius(15)
          Text(item.author)
            .fontSize(11)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('👁 ' + countText(item.views))
            .fontSize(10)
            .fontColor(COLORS.hint)
          Text(item.isLiked ? '❤️ ' : '🤍 ')
            .fontSize(13)
          Text(countText(item.likes))
            .fontSize(11)
            .fontColor(COLORS.red)
        }
        .width('100%')

        Row({ space: 8 }) {
          Text('📷 ' + item.camera)
            .fontSize(9)
            .fontColor(COLORS.graphiteLight)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.bg)
            .borderRadius(6)
          Text(exifText(item))
            .fontSize(9)
            .fontColor(COLORS.gray)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.bg)
            .borderRadius(6)
          Column().layoutWeight(1)
          Text('📍 ' + item.location)
            .fontSize(9)
            .fontColor(COLORS.hint)
        }
        .width('100%')
      }
      .alignItems(HorizontalAlign.Start)
      .padding(12)
    }
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .onClick(() => {
      this.selectedPhoto = item
      this.showDetailModal = true
    })
  }

photoBigCard是发现页面使用的单列沉浸式大图作品卡片构建器,它接收一个PhotoItem参数并渲染出一张完整的信息卡片。这张卡片是应用中信息密度最高的UI组件,融合了封面区、推荐标签、标题、描述、作者信息、社交数据、EXIF参数和拍摄地点等多个信息层。

卡片整体是一个Column容器,分为上下两部分。上半部分是封面区域,使用Stack层叠布局,内容对齐方式为Alignment.TopStart(左上角对齐)。底层是一个170vp高的Column容器,居中显示作品封面emoji图标(字号56)。叠加层是一个推荐标签——通过getFeatureMeta函数根据点赞数动态选择:点赞超过3000显示"编辑精选"标签,否则显示"新锐佳作"标签。标签使用琥珀金色背景、深色文字、8vp圆角,通过.margin(8)定位在封面区域左上角。

下半部分是信息区域,使用Column容器,设置了8vp的子元素间距和12vp的内边距。信息区包含四行内容:第一行是作品标题(字号15,加粗,墨色,单行显示);第二行是描述文字(字号11,灰色,最多两行,溢出省略——通过.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })实现);第三行是作者信息行,包含头像emoji(30x30vp圆形背景)、作者名、浏览量(调用countText格式化)、点赞状态图标和点赞数;第四行是参数行,包含相机型号标签、EXIF参数标签(调用exifText拼接光圈/快门/ISO)和拍摄地点。

卡片的点击事件设置了this.selectedPhoto = itemthis.showDetailModal = true两个操作。前者将当前点击的作品对象保存到状态变量中供详情弹框使用,后者触发详情弹框的显示。这种"先保存数据再触发显示"的模式确保了弹框打开时能够立即获取到正确的作品数据,避免了数据与视图不同步的问题。

代码段15:发现页内容流构建器

  @Builder
  discoverContent() {
    Scroll() {
      Column({ space: 14 }) {
        ForEach(filterByCategory(this.topTab), (item: PhotoItem) => {
          Column() {
            this.photoBigCard(item)
          }
        }, (item: PhotoItem) => this.topTab + item.id.toString())
      }
      .padding({ left: 14, right: 14, top: 12, bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

discoverContent构建器定义了"发现"页面的主体内容区域,它是一个可纵向滚动的作品流。核心逻辑非常精炼:通过Scroll容器包裹一个ColumnColumn内部通过ForEach渲染作品列表。

这里最关键的细节是ForEach的数据源:filterByCategory(this.topTab)。该函数调用接收当前顶部Tab状态作为参数,返回过滤后的作品数组。当topTab为"推荐"时返回全部15条作品,当topTab为"人像"时只返回分类为"人像"的作品。由于topTab@State变量,当用户切换顶部Tab时,ArkTS自动重新调用filterByCategory获取新的数据数组,并触发ForEach的Diff更新——新增的列表项被渲染,移除的列表项被销毁,保留的列表项保持不变。

ForEach的键值生成函数使用this.topTab + item.id.toString()的组合键。这里将当前Tab名称拼接到作品ID前面是一个精妙的设计——当用户从"推荐"切换到"人像"时,虽然某些作品ID在两个Tab中都存在,但由于Tab前缀不同,键值完全不同,ArkTS会将它们视为全新的列表项进行全量渲染,避免了跨Tab复用同一组件实例可能导致的视觉状态残留问题。

每个作品卡片通过this.photoBigCard(item)调用前面定义的大卡片构建器进行渲染,外层包裹了一个空的Column容器作为列表项容器。Column设置了14vp的子元素间距和左右14vp、上下12/20vp的内边距,为卡片之间和卡片与屏幕边缘之间留出了适当的呼吸空间。

Scroll容器设置了.scrollBar(BarState.Off)隐藏滚动条,.layoutWeight(1)使其占据顶部Tab栏和底部Tab栏之间的全部剩余空间。这种弹性布局确保了内容区在不同屏幕高度的设备上都能正确填充可用空间,同时保证顶部和底部导航栏始终可见。

代码段16:广场页内容构建器(含柱状图)

  @Builder
  plazaContent() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          Text('🖼 全站作品墙')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('按热度')
            .fontSize(11)
            .fontColor(COLORS.amber)
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        Grid() {
          ForEach(PHOTO_LIST, (item: PhotoItem) => {
            GridItem() {
              Column({ space: 0 }) {
                Stack({ alignContent: Alignment.TopStart }) {
                  Column() {
                    Text(item.icon)
                      .fontSize(26)
                  }
                  .width('100%')
                  .height(92)
                  .justifyContent(FlexAlign.Center)
                  .backgroundColor(item.coverColor)

                  Row({ space: 3 }) {
                    Text('❤')
                      .fontSize(9)
                    Text(countText(item.likes))
                      .fontSize(9)
                  }
                  .padding(5)
                  .backgroundColor('rgba(0,0,0,0.35)')
                  .borderRadius(6)
                  .margin(4)
                }
                .width('100%')
                .borderRadius(10)
              }
              .onClick(() => {
                this.selectedPhoto = item
                this.showDetailModal = true
              })
            }
          }, (item: PhotoItem) => 'plaza' + item.id.toString())
        }
        .columnsTemplate('1fr 1fr 1fr')
        .columnsGap(6)
        .rowsGap(6)
        .padding({ left: 14, right: 14 })

        Text('📊 本周获赞趋势')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')
          .padding({ left: 14, right: 14 })

        Column({ space: 10 }) {
          Row({ space: 10 }) {
            ForEach(WEEK_LIKE_STATS, (stat: WeekStat) => {
              Column({ space: 5 }) {
                Text(countText(stat.likes))
                  .fontSize(9)
                  .fontColor(COLORS.gray)
                Column()
                  .width(16)
                  .height(barHeight(stat.likes))
                  .backgroundColor(stat.likes >= 300 ? COLORS.amber : COLORS.graphiteLight)
                  .borderRadius(8)
                Text(stat.day.replace('周', ''))
                  .fontSize(9)
                  .fontColor(COLORS.gray)
              }
            }, (stat: WeekStat) => stat.day)
          }
          .alignItems(VerticalAlign.Bottom)
          Text('周末出片率最高,点赞是更新的动力 ❤')
            .fontSize(11)
            .fontColor(COLORS.hint)
        }
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .margin({ left: 14, right: 14 })
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

plazaContent构建器定义了"广场"页面,包含全站作品墙(三列网格)和本周获赞趋势柱状图两大功能模块。

作品墙使用Grid网格容器,通过.columnsTemplate('1fr 1fr 1fr')设置三列等宽布局,.columnsGap(6).rowsGap(6)设置行列间距。每个GridItem内部是一个Stack层叠容器:底层是92vp高的彩色封面区(背景色为item.coverColor),上层是点赞数标签(半透明黑色背景rgba(0,0,0,0.35))。相比发现页的单列大卡片,广场页采用三列紧凑网格布局,在有限的屏幕空间内展示更多作品,适合"浏览发现"的使用场景。

柱状图是本段代码的技术亮点。它完全使用ArkTS的基础布局组件手工构建,没有依赖任何图表库。七根柱子通过ForEach遍历WEEK_LIKE_STATS数组生成,每根柱子是一个Column容器,内部从上到下排列:数值标签(调用countText格式化)、柱子主体(Column组件,宽度固定16vp,高度通过barHeight函数计算)、星期标签(通过.replace('周', '')去掉"周"字只保留"一"到"日")。

柱子高度通过barHeight(stat.likes)函数计算——该函数将点赞数除以20并拼接vp单位。例如周日402点赞对应20.1vp高度,周六486点赞对应24.3vp高度。柱子颜色通过stat.likes >= 300进行条件判断:超过300的柱子使用琥珀金色(COLORS.amber)高亮,其余使用石墨灰色(COLORS.graphiteLight),形成了数据热力对比的视觉效果。

外层Row设置了.alignItems(VerticalAlign.Bottom)使所有柱子底部对齐,模拟了标准柱状图的"基线对齐"效果。这种纯ArkTS原生组件实现数据可视化的方式虽然代码量较大,但优势在于零依赖、高度可定制、性能优秀,适合简单的数据展示场景。

代码段17:灵感页内容构建器(话题横滑+器材清单)

  @Builder
  inspireContent() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          Text('💡 创作灵感')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('+ 发布作品')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(COLORS.graphite)
            .borderRadius(14)
            .onClick(() => {
              this.showAddModal = true
            })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        Scroll() {
          Row({ space: 10 }) {
            ForEach(TOPIC_CARDS, (topic: TopicCard) => {
              Column({ space: 6 }) {
                Text(topic.icon)
                  .fontSize(26)
                Text(topic.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.white)
                Text(topic.desc)
                  .fontSize(10)
                  .fontColor('rgba(255,255,255,0.75)')
                Text(topic.count)
                  .fontSize(9)
                  .fontColor(COLORS.amber)
              }
              .width(130)
              .padding({ top: 16, bottom: 16 })
              .borderRadius(14)
              .backgroundColor(topic.color)
            }, (topic: TopicCard) => topic.title)
          }
          .padding({ left: 14, right: 14 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        Text('🎒 热门器材清单')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')
          .padding({ left: 14, right: 14 })

        ForEach(GEAR_LIST, (gear: GearItem, index: number) => {
          Row({ space: 12 }) {
            Text(gear.icon)
              .fontSize(22)
              .width(46)
              .height(46)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS.amberLight)
              .borderRadius(12)

            Column({ space: 4 }) {
              Row({ space: 8 }) {
                Text(gear.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.ink)
                Text(gear.type)
                  .fontSize(9)
                  .fontColor(COLORS.gray)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.bg)
                  .borderRadius(6)
              }
              Text(index % 2 === 0 ? '本周 1.2k 位摄影师新增此器材' : '社区讨论热帖 486 条')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text(gear.price)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.amber)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
          .margin({ left: 14, right: 14 })
        }, (gear: GearItem) => gear.name)
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

inspireContent构建器定义了"灵感"页面,由创作灵感标题栏、话题卡片横滑区、热门器材清单三部分组成。这是应用中布局复杂度较高的页面,融合了横向滚动、列表渲染和动态文案等多种技术。

页面顶部是标题行,左侧"💡 创作灵感"标题,右侧"+ 发布作品"按钮——点击后设置this.showAddModal = true触发发布弹框。按钮使用石墨黑色背景、白色文字、14vp圆角的药丸形设计,视觉上明确区别于内容区域。

话题卡片区域使用嵌套的Scroll容器(外层纵向滚动,内层横向滚动)实现横滑卡片墙。四张话题卡片各有专属主题色(黄金时刻橙色、城市倒影蓝色、极简主义灰色、光影手账棕色),卡片内部从上到下排列图标(字号26)、标题(白色加粗)、描述(半透明白色)和作品数量(琥珀金色)。这种"彩色卡片横滑"的设计模式在内容发现类应用中非常流行,如小红书、抖音等。

器材清单使用ForEach遍历GEAR_LIST数组渲染六款器材卡片。每张卡片是一个Row容器,左侧是46x46vp的琥珀浅色圆角图标背景,中间是器材名称、类型标签和动态文案,右侧是琥珀金色价格。动态文案使用index % 2 === 0进行奇偶判断:偶数索引显示"本周 1.2k 位摄影师新增此器材",奇数索引显示"社区讨论热帖 486 条"。这种基于索引的交替文案虽然简单,但有效地为每张卡片提供了差异化的社交数据展示,避免了重复感。

代码段18:我的页内容构建器

  @Builder
  mineContent() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 12 }) {
            Text('🎥')
              .fontSize(30)
              .width(56)
              .height(56)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS.amberLight)
              .borderRadius(28)
            Column({ space: 4 }) {
              Text('快门里的光')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('快门 12.8 万次 · 快门寿命的 1/4')
                .fontSize(11)
                .fontColor('#C9C4BB')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('编辑')
              .fontSize(11)
              .fontColor(COLORS.film)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor(COLORS.amber)
              .borderRadius(12)
              .onClick(() => {
                this.showEditModal = true
              })
          }
          .width('100%')

          Row({ space: 0 }) {
            Column({ space: 3 }) {
              Text('86')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.amber)
              Text('作品')
                .fontSize(10)
                .fontColor('#C9C4BB')
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text('2.4w')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.amber)
              Text('获赞')
                .fontSize(10)
                .fontColor('#C9C4BB')
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text('3210')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.amber)
              Text('粉丝')
                .fontSize(10)
                .fontColor('#C9C4BB')
            }
            .layoutWeight(1)
          }
          .width('100%')
        }
        .width('100%')
        .padding(16)
        .linearGradient({
          direction: GradientDirection.RightBottom,
          colors: [[COLORS.graphite, 0], [COLORS.graphiteLight, 1]]
        })
        .borderRadius(16)

        Text('🗂 我的作品')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')

        ForEach(PHOTO_LIST, (item: PhotoItem, index: number) => {
          if (item.isLiked) {
            Row({ space: 12 }) {
              Text(item.icon)
                .fontSize(22)
                .width(48)
                .height(48)
                .textAlign(TextAlign.Center)
                .backgroundColor(item.coverColor + '22')
                .borderRadius(10)

              Column({ space: 3 }) {
                Text(item.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.ink)
                  .maxLines(1)
                Text('❤ ' + countText(item.likes) + ' · 👁 ' + countText(item.views) + ' · ' + item.category)
                  .fontSize(10)
                  .fontColor(COLORS.gray)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('编辑')
                .fontSize(11)
                .fontColor(COLORS.graphiteLight)
                .onClick(() => {
                  this.selectedPhoto = item
                  this.showEditModal = true
                })
              Text('✕')
                .fontSize(13)
                .fontColor(COLORS.hint)
                .width(24)
                .height(24)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.bg)
                .borderRadius(12)
                .onClick(() => {
                  this.deleteIndex = index
                  this.showDeleteModal = true
                })
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
          }
        }, (item: PhotoItem) => 'mywork' + item.id.toString())
      }
      .padding({ left: 14, right: 14, top: 12, bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

mineContent构建器定义了"我的"页面,包含个人主页头部卡片(渐变背景)和我的作品列表两大部分。

个人主页头部卡片使用了linearGradient线性渐变背景,这是ArkTS样式系统中实现渐变效果的核心API。.linearGradient()方法接收一个配置对象:direction指定渐变方向(GradientDirection.RightBottom表示从左上到右下),colors数组定义渐变色标——[COLORS.graphite, 0]表示起点为石墨黑,[COLORS.graphiteLight, 1]表示终点为浅石墨色。这种对角线渐变营造了类似相机肩带皮革的质感,与整体胶片风格高度契合。

头部卡片内部分为上下两行。上行是头像(56x56vp圆形琥珀浅色背景的emoji)、用户名和简介、编辑按钮(点击触发编辑弹框)。下行是三列统计数据:作品数(86)、获赞数(2.4w)、粉丝数(3210),三个数字使用琥珀金色加粗显示,标签使用浅灰色。三列通过.layoutWeight(1)等宽分布,形成了清晰的数据展示布局。

我的作品列表使用ForEach遍历PHOTO_LIST,但通过if (item.isLiked)条件渲染进行过滤——只有isLikedtrue的作品才渲染为列表项。这模拟了用户收藏/关注的逻辑。每行作品是一个Row容器,包含封面图标(48x48vp,背景色使用item.coverColor + '22'——在原始色值后追加22后缀形成低透明度效果)、标题和统计信息、编辑按钮(点击触发编辑弹框并保存当前作品到selectedPhoto)和删除按钮(点击触发删除弹框并保存当前索引到deleteIndex)。

编辑和删除按钮的onClick回调分别设置不同的状态变量,这种"一个操作设置多个状态"的模式是ArkTS中触发复杂交互的常用方式。例如删除操作同时设置了this.deleteIndex = index(记录待删除索引)和this.showDeleteModal = true(显示弹框),两个状态变更会同步触发,使弹框打开时能够通过PHOTO_LIST[this.deleteIndex]获取到正确的作品标题。

代码段19:底部Tab栏构建器

  @Builder
  bottomTabBar() {
    Row() {
      ForEach(BOTTOM_TABS, (tab: NavEntry) => {
        Column({ space: 3 }) {
          Text(tab.icon)
            .fontSize(22)
          Text(tab.label)
            .fontSize(10)
            .fontColor(this.bottomTab === tab.label ? tab.color : COLORS.hint)
        }
        .layoutWeight(1)
        .onClick(() => {
          this.bottomTab = tab.label
        })
      }, (tab: NavEntry) => tab.label)
    }
    .width('100%')
    .padding({ top: 8, bottom: 10 })
    .backgroundColor(COLORS.cardBg)
  }

bottomTabBar构建器定义了应用的底部导航栏。整体采用Row容器,通过ForEach遍历BOTTOM_TABS数组生成四个Tab入口。每个Tab是一个Column容器,内部包含图标(字号22)和文字标签(字号10),两元素间距为3vp。

Tab的高亮逻辑通过文字颜色实现:激活Tab的文字颜色为tab.color(每个Tab有专属颜色——发现为石墨色、广场为琥珀色、灵感为紫色、我的为灰色),非激活Tab的文字颜色为统一的灰色(COLORS.hint)。这种"每个Tab有专属激活色"的设计使得用户即使不阅读文字,仅通过颜色就能识别当前所处页面。

四个Tab通过.layoutWeight(1)等宽分布,每个占据底部栏四分之一的宽度。.onClick()回调将this.bottomTab赋值为被点击Tab的label值,触发ArkTS状态更新,进而驱动build()方法中内容区域的条件渲染切换。

底部栏整体使用白色背景(COLORS.cardBg),与深色顶部导航栏形成对比,这种"深头浅尾"的配色策略使应用在视觉上保持了摄影主题的层次感——深色头部模拟相机取景器,浅色底部模拟相机操作面板。

代码段20:发布作品弹框构建器

  @Builder
  publishModal() {
    Column() {
      Column({ space: 6 }) {
        Text('📸 发布新作品')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('让好照片被更多人看见')
          .fontSize(11)
          .fontColor('#C9C4BB')
      }
      .width('100%')
      .padding({ top: 18, bottom: 16 })
      .linearGradient({
        direction: GradientDirection.Right,
        colors: [[COLORS.graphite, 0], [COLORS.graphiteLight, 1]]
      })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Column({ space: 14 }) {
        Column({ space: 6 }) {
          Text('作品标题')
            .fontSize(12)
            .fontColor(COLORS.gray)
          TextInput({ placeholder: '给照片起个名字...', text: this.inputTitle })
            .fontSize(14)
            .height(42)
            .backgroundColor(COLORS.bg)
            .borderRadius(10)
            .onChange((value: string) => {
              this.inputTitle = value
            })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Column({ space: 8 }) {
          Text('色调风格')
            .fontSize(12)
            .fontColor(COLORS.gray)
          Row({ space: 8 }) {
            ForEach(STYLE_OPTIONS, (s: string) => {
              Text(s)
                .fontSize(11)
                .fontColor(this.selectStyle === s ? COLORS.film : COLORS.gray)
                .padding({ left: 10, right: 10, top: 7, bottom: 7 })
                .backgroundColor(this.selectStyle === s ? COLORS.amber : COLORS.bg)
                .borderRadius(14)
                .onClick(() => {
                  this.selectStyle = s
                })
            }, (s: string) => s)
          }
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('f/1.8')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text('光圈')
              .fontSize(9)
              .fontColor(COLORS.hint)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          Column({ space: 3 }) {
            Text('1/250s')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text('快门')
              .fontSize(9)
              .fontColor(COLORS.hint)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          Column({ space: 3 }) {
            Text('ISO 100')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text('感光度')
              .fontSize(9)
              .fontColor(COLORS.hint)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.gray)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.bg)
            .borderRadius(20)
            .onClick(() => {
              this.showAddModal = false
            })
          Column().layoutWeight(1)
          Text('发布作品 ✨')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.film)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.amber)
            .borderRadius(20)
            .onClick(() => {
              this.likeCount = this.likeCount + 1
              this.showAddModal = false
            })
        }
        .width('100%')
      }
      .padding(16)
    }
    .width('88%')
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

publishModal构建器定义了发布作品弹框,是应用中表单交互最密集的组件。弹框整体宽度为屏幕的88%,背景白色,16vp圆角,通过.constraintSize({ maxHeight: '80%' })限制最大高度为屏幕的80%,防止在小屏设备上超出可视区域。

弹框头部使用石墨色横向线性渐变背景(从graphitegraphiteLight),顶部圆角16vp,标题"📸 发布新作品"为白色加粗字号17,副标题"让好照片被更多人看见"为浅灰色字号11。头部的深色渐变与下方白色表单区形成了鲜明的视觉分层。

表单区域包含三个表单组件。第一个是作品标题输入框,使用TextInput组件,通过text: this.inputTitle实现双向绑定——输入框的初始值为this.inputTitle状态变量,.onChange()回调将用户输入的值同步回this.inputTitle。这种"初始值+onChange回写"的模式是ArkTS中实现受控表单的标准方式。

第二个是色调风格选择器,通过ForEach渲染STYLE_OPTIONS数组的六个选项(自然光、人造光、胶片色、黑白、赛博、日系)作为可点击的药丸标签。选中状态通过this.selectStyle === s判断,选中项使用琥珀金色背景和深色文字,未选中项使用灰色背景和灰色文字。点击某个选项时将其值赋给this.selectStyle,触发高亮状态切换。

第三个是EXIF参数展示区,三个等宽列分别展示光圈(f/1.8)、快门(1/250s)和感光度(ISO 100),每列通过.layoutWeight(1)等宽分布,灰色背景圆角卡片。这里展示的是默认参数值,实际应用中可以通过选择器或输入框让用户自定义。

底部操作区包含"取消"和"发布作品"两个按钮。取消按钮点击设置this.showAddModal = false关闭弹框。发布按钮点击时执行两个操作:this.likeCount = this.likeCount + 1(模拟发布后点赞数增加)和this.showAddModal = false(关闭弹框)。两个按钮之间通过Column().layoutWeight(1)填充弹性空间,使取消按钮靠左、发布按钮靠右。

代码段21:编辑个人主页弹框构建器

  @Builder
  editProfileModal() {
    Column({ space: 14 }) {
      Row({ space: 8 }) {
        Text('✍️')
          .fontSize(20)
        Text('编辑个人主页')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.hint)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')

      Column({ space: 8 }) {
        Text('昵称')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.graphiteLight)
        TextInput({ placeholder: '快门里的光', text: this.inputBio })
          .fontSize(14)
          .height(42)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .onChange((value: string) => {
            this.inputBio = value
          })
      }
      .alignItems(HorizontalAlign.Start)
      .width('100%')
      .padding(14)
      .border({ width: 1, color: COLORS.amber, radius: 12 })

      Column({ space: 8 }) {
        Text('常用风格')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.amber)
        Row({ space: 8 }) {
          ForEach(['风光', '人像', '街拍', '胶片'], (s: string) => {
            Text(s)
              .fontSize(12)
              .fontColor(this.selectStyle === s ? COLORS.white : COLORS.gray)
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .backgroundColor(this.selectStyle === s ? COLORS.graphite : COLORS.bg)
              .borderRadius(14)
              .onClick(() => {
                this.selectStyle = s
              })
          }, (s: string) => 'p' + s)
        }
      }
      .alignItems(HorizontalAlign.Start)
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.amberLight)
      .borderRadius(12)

      Text('主页头图与水印设置可在网页端管理')
        .fontSize(10)
        .fontColor(COLORS.hint)
        .width('100%')

      Row({ space: 10 }) {
        Column().layoutWeight(1)
        Text('保存资料 ✅')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 22, right: 22, top: 10, bottom: 10 })
          .backgroundColor(COLORS.graphite)
          .borderRadius(18)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')
    }
    .width('88%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

editProfileModal构建器定义了编辑个人主页弹框。与发布弹框的深色头部不同,编辑弹框采用了更简洁的设计风格——标题行使用白色背景,通过左侧emoji图标和右侧关闭按钮形成对称布局。

弹框的核心区域是昵称输入框,被包裹在一个带有琥珀金色描边(.border({ width: 1, color: COLORS.amber, radius: 12 }))的容器中。这种"描边强调"的设计使核心表单区域在视觉上突出,引导用户聚焦于最重要的输入项。TextInput通过text: this.inputBio绑定状态变量,placeholder为"快门里的光"(当前用户名),.onChange()回写输入值到this.inputBio

常用风格选择区使用了琥珀浅色背景(COLORS.amberLight),与描边输入框形成同色系呼应。四个风格选项(风光、人像、街拍、胶片)以药丸标签形式排列,选中项使用石墨黑色背景白色文字,未选中项使用灰色背景灰色文字。这里复用了this.selectStyle状态变量,意味着发布弹框和编辑弹框共享同一个风格选择状态——如果用户在发布弹框中选择了"胶片色",打开编辑弹框时"胶片"选项可能处于高亮状态(虽然值不完全相同,因为一个使用"胶片色"另一个使用"胶片",但状态变量是共享的)。

底部辅助文字"主页头图与水印设置可在网页端管理"使用灰色字号10,提供了功能边界提示——告诉用户某些设置需要在其他端完成,避免用户在本弹框中找不到对应功能而产生困惑。

保存按钮使用石墨黑色背景白色文字,与取消按钮形成对比。按钮点击后设置this.showEditModal = false关闭弹框。ForEach的键值函数使用'p' + s前缀,避免与其他使用相同字符串数组的ForEach键值冲突。

代码段22:删除作品确认弹框构建器

  @Builder
  deletePhotoModal() {
    Column({ space: 14 }) {
      Text('🗑')
        .fontSize(34)
      Text('删除这幅作品?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
      Text('「' + PHOTO_LIST[this.deleteIndex].title + '」将从你的主页下架,互动数据同步清空')
        .fontSize(12)
        .fontColor(COLORS.gray)
        .textAlign(TextAlign.Center)
      Row({ space: 12 }) {
        Text('保留')
          .fontSize(13)
          .fontColor(COLORS.gray)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.bg)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
        Text('删除')
          .fontSize(13)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.red)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
      }
    }
    .width('72%')
    .padding({ top: 24, bottom: 22, left: 18, right: 18 })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
  }

deletePhotoModal构建器定义了删除作品确认弹框。相比发布弹框和编辑弹框的宽表单布局,删除弹框采用了更窄的72%宽度和小巧的居中布局,这种尺寸差异在视觉上传递了"这是一个需要快速决策的警示弹框"的信息。

弹框内容从上到下依次为:垃圾桶emoji图标(字号34,作为视觉警示)、确认标题"删除这幅作品?“(字号16加粗)、风险说明文案(引用PHOTO_LIST[this.deleteIndex].title动态显示待删除作品标题,并说明"将从你的主页下架,互动数据同步清空”)、操作按钮组。

风险说明文案通过PHOTO_LIST[this.deleteIndex].title动态获取待删除作品的标题。this.deleteIndex是在"我的"页面点击删除按钮时设置的索引值。这种通过索引访问数组元素的方式确保了弹框展示的信息与用户要删除的作品完全一致,避免了"确认删除A但实际删除了B"的严重错误。

按钮组包含"保留"(灰色背景灰色文字)和"删除"(红色背景白色文字,COLORS.red#E8506E)两个操作。红色删除按钮在视觉上形成了警示效果,符合通用UI设计规范中"危险操作使用红色"的原则。两个按钮的点击回调目前都是关闭弹框(this.showDeleteModal = false),在实际应用中删除按钮还应包含从数据列表中移除对应作品的逻辑。

从交互设计角度看,删除操作使用"二次确认弹框"是防止误操作的标准模式。用户在"我的"页面点击删除按钮后,不会直接删除作品,而是弹出此确认框要求用户再次确认,有效降低了误删风险。弹框的窄幅设计和居中布局也符合"打断用户当前操作流、要求专注决策"的交互意图。

代码段23:作品详情弹框构建器

  @Builder
  photoDetailModal() {
    Column() {
      Column({ space: 8 }) {
        Text(this.selectedPhoto.icon)
          .fontSize(52)
        Text(this.selectedPhoto.title)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('📍 ' + this.selectedPhoto.location)
          .fontSize(12)
          .fontColor('#C9C4BB')
      }
      .width('100%')
      .padding({ top: 22, bottom: 20 })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Scroll() {
        Column({ space: 12 }) {
          Row({ space: 10 }) {
            Text(this.selectedPhoto.avatar)
              .fontSize(18)
              .width(36)
              .height(36)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS.amberLight)
              .borderRadius(18)
            Column({ space: 3 }) {
              Text(this.selectedPhoto.author)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.ink)
              Text(getCategoryMeta(this.selectedPhoto.category).icon + ' ' + this.selectedPhoto.category + '领域创作者')
                .fontSize(10)
                .fontColor(COLORS.gray)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('+ 关注')
              .fontSize(11)
              .fontColor(COLORS.film)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(COLORS.amber)
              .borderRadius(14)
          }
          .width('100%')

          Text(this.selectedPhoto.desc)
            .fontSize(12)
            .fontColor(COLORS.gray)
            .lineHeight(19)
            .width('100%')

          Column({ space: 10 }) {
            Text('🔧 拍摄参数')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              Column({ space: 3 }) {
                Text(this.selectedPhoto.camera)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('机身')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              Column({ space: 3 }) {
                Text(this.selectedPhoto.lens)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('镜头')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
            }
            .width('100%')
            Row({ space: 8 }) {
              Column({ space: 3 }) {
                Text(this.selectedPhoto.aperture)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('光圈')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              Column({ space: 3 }) {
                Text(this.selectedPhoto.shutter)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('快门')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              Column({ space: 3 }) {
                Text(this.selectedPhoto.iso)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('ISO')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)

          Column({ space: 8 }) {
            Text('🏷 标签')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              ForEach(this.selectedPhoto.tags, (t: string) => {
                Text('#' + t)
                  .fontSize(11)
                  .fontColor(COLORS.amber)
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .backgroundColor(COLORS.amberLight)
                  .borderRadius(10)
              }, (t: string) => this.selectedPhoto.id.toString() + t)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 0 }) {
            Column({ space: 3 }) {
              Text('❤️ ' + countText(this.selectedPhoto.likes))
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.red)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text('👁 ' + countText(this.selectedPhoto.views))
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.graphiteLight)
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.bg)
          .borderRadius(12)

          Row({ space: 10 }) {
            Text('✕ 关闭')
              .fontSize(13)
              .fontColor(COLORS.gray)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.bg)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
            Column().layoutWeight(1)
            Text('点赞支持 ❤')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.red)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
          }
          .width('100%')
        }
        .padding(14)
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('88%')
    .height('78%')
    .backgroundColor(COLORS.bg)
    .borderRadius(16)
  }

photoDetailModal构建器定义了作品详情弹框,这是应用中信息量最大、结构最复杂的弹框组件。弹框宽度88%、高度78%,几乎占据整个屏幕,提供了沉浸式的作品详情浏览体验。

弹框分为头部区域和可滚动内容区域两大部分。头部区域展示作品图标(字号52)、标题(字号18白色加粗)和拍摄地点,顶部圆角16vp与弹框外框衔接。注意头部区域没有设置背景色,它依赖于外层容器的背景色(COLORS.bg暖灰色),形成了一种柔和的过渡效果。

可滚动内容区域使用Scroll容器包裹一个Column,内部依次排列:作者信息行(头像、作者名、分类标签、关注按钮)、作品描述文字、拍摄参数卡片、标签列表、社交数据行、操作按钮组。

拍摄参数卡片是详情弹框的技术核心。它使用白色卡片背景(COLORS.cardBg),内部通过两行Row容器排列五个EXIF参数块:第一行是机身和镜头(两个等宽列),第二行是光圈、快门和ISO(三个等宽列)。每个参数块包含参数值(加粗字号11,石墨浅色)和参数名称(字号9,提示色),灰色背景圆角卡片。这种网格化参数展示使复杂的EXIF数据变得清晰易读。

标签列表通过ForEach遍历this.selectedPhoto.tags数组,为每个标签生成一个#标签格式的药丸标签(琥珀金色文字、琥珀浅色背景、10vp圆角)。ForEach的键值函数使用this.selectedPhoto.id.toString() + t,将作品ID与标签文本组合作为键值,确保不同作品的同名标签不会产生键值冲突。

操作按钮组包含"关闭"和"点赞支持"两个按钮。关闭按钮使用灰色背景,点击后设置this.showDetailModal = false关闭弹框。点赞按钮使用红色背景(COLORS.red),点击后同样关闭弹框(在实际应用中应包含点赞逻辑)。两个按钮通过Column().layoutWeight(1)分隔,关闭靠左、点赞靠右。

整个详情弹框通过this.selectedPhoto状态变量驱动所有数据展示。该变量在用户点击作品卡片(发现页或广场页)或编辑按钮(我的页)时被设置为对应的PhotoItem对象。由于PhotoItem@Observed装饰,当selectedPhoto被赋值为新对象时,ArkTS自动触发详情弹框的重渲染,所有依赖this.selectedPhoto的UI元素都会更新为最新数据。

代码段24:弹框遮罩层构建器

  @Builder
  modalOverlay() {
    Column() {
      Column().layoutWeight(1)
      if (this.showAddModal) {
        Column() {
          this.publishModal()
        }
      }
      if (this.showEditModal) {
        Column() {
          this.editProfileModal()
        }
      }
      if (this.showDeleteModal) {
        Column() {
          this.deletePhotoModal()
        }
      }
      if (this.showDetailModal) {
        Column() {
          this.photoDetailModal()
        }
      }
      Column().layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(20,18,25,0.6)')
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.showAddModal = false
      this.showEditModal = false
      this.showDeleteModal = false
      this.showDetailModal = false
    })
  }

modalOverlay构建器定义了弹框遮罩层,它是所有弹框的统一容器和背景遮罩。该构建器巧妙地使用了一个全屏Column容器,内部通过两个Column().layoutWeight(1)弹性占位元素在顶部和底部各撑起等量空间,将弹框内容"挤"到屏幕中央,实现了垂直居中效果。

遮罩层背景色为rgba(20,18,25,0.6)——一个60%透明度的深色覆层,模拟了"暗化背景以突出弹框"的模态对话框效果。.justifyContent(FlexAlign.Center)进一步确保弹框在容器内居中显示。

四种弹框通过四个独立的if条件渲染语句控制:if (this.showAddModal)渲染发布弹框、if (this.showEditModal)渲染编辑弹框、if (this.showDeleteModal)渲染删除弹框、if (this.showDetailModal)渲染详情弹框。四个条件相互独立,理论上可以同时为true(虽然实际交互中不会出现),每个条件为true时对应的弹框构建器被调用并渲染到遮罩层中。

遮罩层自身的.onClick()回调将所有四个弹框状态设置为false,实现了"点击遮罩层关闭弹框"的交互模式。这是模态对话框的标准交互——用户可以通过点击弹框外的遮罩区域来关闭弹框,无需精确点击关闭按钮。这种交互模式提升了用户操作的便捷性。

值得注意的是,虽然每个弹框内部都有自己的关闭按钮(点击设置对应状态为false),但遮罩层的点击事件会在点击弹框内部时被触发吗?在ArkTS的事件系统中,子元素可以通过.hitTestBehavior()控制事件冒泡。如果弹框内部没有阻止事件冒泡,点击弹框内部会同时触发弹框自身的事件和遮罩层的点击事件。但在本例中,弹框内部有交互元素(如按钮)设置了自身的onClick,这些事件会优先处理,不会导致弹框意外关闭。

代码段25:主构建方法build()

  build() {
    Stack() {
      Column() {
        this.headerBar()
        if (this.bottomTab === '发现') {
          this.topTabBar()
          this.discoverContent()
        }
        if (this.bottomTab === '广场') {
          this.plazaContent()
        }
        if (this.bottomTab === '灵感') {
          this.inspireContent()
        }
        if (this.bottomTab === '我的') {
          this.mineContent()
        }
        this.bottomTabBar()
      }
      .width('100%')
      .height('100%')

      this.particleLayer()

      if (this.showAddModal || this.showEditModal || this.showDeleteModal || this.showDetailModal) {
        Column() {
          this.modalOverlay()
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

build()方法是ArkTS组件的核心入口,框架在渲染组件时调用此方法构建UI组件树。本应用的build()方法使用Stack作为根容器,内部层叠了三个Z轴层级的子元素:主内容区(Column)、粒子特效层和弹框遮罩层。

Stack是ArkTS的层叠布局容器,子元素按照声明顺序从底到上层叠。第一层是主内容区Column,包含顶部导航栏(headerBar)、条件渲染的内容区域和底部Tab栏(bottomTabBar)。内容区域通过四个if条件语句根据this.bottomTab的值进行切换——当bottomTab为"发现"时渲染顶部Tab栏和发现内容流;为"广场"时渲染广场内容;为"灵感"时渲染灵感内容;为"我的"时渲染我的内容。四个条件互斥,同一时间只有一个内容区域被渲染。

这种条件渲染的内容切换方式是ArkTS中实现Tab导航的标准模式。相比使用Visibility属性控制显示隐藏,条件渲染的优势在于:当切换Tab时,非当前Tab的内容组件会被完全销毁并从组件树中移除,释放内存和渲染资源;当切回该Tab时,组件会被重新创建。这种"用时创建、不用销毁"的策略在Tab数量较多时能有效控制内存占用,但也意味着切换Tab时组件状态会丢失(如果组件有局部状态的话)。

第二层是粒子特效层this.particleLayer(),它覆盖在主内容区上方。由于粒子使用了0.4的透明度,不会完全遮挡下方内容,同时为页面增添了动态的光斑漂浮效果,增强了视觉氛围感。

第三层是弹框遮罩层,通过if (this.showAddModal || this.showEditModal || this.showDeleteModal || this.showDetailModal)条件渲染——只要四个弹框状态中任何一个为true,遮罩层就会被渲染。这种"任一为真则显示"的复合条件使得弹框管理变得简洁——无需额外的状态变量来统一控制遮罩层,四个独立的布尔状态通过逻辑或运算自然组合。

Stack根容器设置了100%的宽高和暖灰色背景(COLORS.bg),确保整个屏幕被覆盖,不会出现底部露出系统背景色的问题。整个build()方法的代码结构清晰,三层内容自下而上排列,体现了"内容层 -> 装饰层 -> 交互层"的设计层次。


四、对比表格

表格1:四种内容布局方式对比

对比维度 发现页(单列大图流) 广场页(三列网格墙) 灵感页(横滑+列表) 我的页(列表管理)
布局容器 Scroll + Column + ForEach Grid(3列等宽) 嵌套Scroll(纵+横) Scroll + Column + ForEach
每屏展示数 1-2条作品 6-9条作品 4张话题+6款器材 7条已赞作品
信息密度 高(完整EXIF+描述) 低(仅封面+点赞数) 中(话题描述+器材价格) 中(标题+统计+操作)
卡片尺寸 全宽,约300vp高 三分之一宽,约92vp高 固定130vp宽话题卡 全宽,约72vp高
主要交互 点击查看详情 点击查看详情 横滑浏览+发布 编辑/删除操作
数据过滤 filterByCategory 全部PHOTO_LIST 静态TOPIC/GEAR isLiked为true
滚动方向 纵向 纵向 纵向+横向 纵向
视觉重点 沉浸式单图浏览 快速浏览大量作品 发现灵感与器材 个人内容管理
键值策略 topTab+id ‘plaza’+id topic.title / gear.name ‘mywork’+id

从上表可以看出,四种内容布局方式各有侧重。发现页采用单列大图流,强调每条作品的沉浸式浏览体验,适合用户深度阅读作品信息;广场页使用三列网格墙,在有限空间内展示更多作品,适合快速浏览和发现;灵感页融合横向滚动话题卡片和纵向列表器材清单,提供了多维度的创作灵感来源;我的页使用列表布局,强调作品的管理操作(编辑、删除)而非视觉展示。

四种布局的键值策略也各有不同。发现页使用topTab+id作为键值,确保切换分类Tab时列表项被正确识别为全新项;广场页和我的页分别使用'plaza'+id'mywork'+id前缀,避免与其他页面中相同ID的列表项产生键值冲突;灵感页由于数据源是不同的静态数组,直接使用数组元素的天然唯一属性作为键值即可。

表格2:四种弹框交互对比

对比维度 发布弹框 编辑弹框 删除弹框 详情弹框
宽度比例 88% 88% 72% 88%
高度限制 maxHeight 80% maxHeight 80% 78%固定
头部样式 石墨渐变 白色标题行 无头部 无背景头部
表单组件 TextInput+标签选择 TextInput+标签选择 无表单 无表单
触发来源 灵感页发布按钮 我的页编辑按钮 我的页删除按钮 作品卡片点击
状态变量 showAddModal showEditModal showDeleteModal showDetailModal
关闭方式 取消/发布按钮+遮罩 ✕按钮+保存+遮罩 保留/删除+遮罩 ✕关闭+遮罩
数据来源 inputTitle/selectStyle inputBio/selectStyle PHOTO_LIST[deleteIndex] selectedPhoto
视觉重点 表单输入与风格选择 昵称修改与风格偏好 删除确认与风险提示 完整作品信息展示

四种弹框在尺寸、样式和交互上各有差异,体现了"弹框类型决定设计风格"的原则。发布弹框和编辑弹框是88%宽度的宽表单弹框,适合需要用户输入信息的场景;删除弹框是72%宽度的窄警示弹框,适合需要快速决策的确认场景;详情弹框是88%宽×78%高的近全屏弹框,适合需要展示大量信息的沉浸式浏览场景。

表格3:ArkTS状态管理装饰器对比

装饰器 作用范围 响应式级别 典型用例 本应用使用
@State 组件内部 组件级响应 当前Tab、弹框开关、表单输入 12个状态变量
@Observed 类定义 对象级响应 可变数据模型 PhotoItem类
@Builder 方法 UI片段复用 可复用的卡片/弹框构建器 11个构建器
@Entry 组件 页面入口 应用根页面 Index组件
@Component 组件 组件声明 自定义组件 Index组件
@Prop 父到子 单向同步 子组件接收父组件数据 未使用
@Link 父到子 双向同步 子组件修改父组件状态 未使用
@Provide 祖先组件 跨层级向下 全局主题/语言 未使用
@Consume 后代组件 跨层级接收 消费@Provide数据 未使用

本应用主要使用了@State@Observed@Builder@Entry@Component五种装饰器。由于所有UI都构建在单一的Index组件中,没有子组件拆分,因此不需要使用@Prop/@Link进行父子通信,也不需要使用@Provide/@Consume进行跨层级数据传递。这种"单组件全包"的架构在中小型应用页面中是合理的,但随着功能增长,建议将各内容区域拆分为独立的子组件,通过@Prop/@Link进行状态传递,以提升代码的可维护性。


安装DevEco Studio程序

在这里插入图片描述
选择目标安装目录:

在这里插入图片描述
设置环境变量,但是需要重启一下:

在这里插入图片描述
新建一个空白模板:

在这里插入图片描述
设置API为24的模板项目:
在这里插入图片描述
初始化项目,自动下载相关依赖:

在这里插入图片描述


完整代码:

// ============================================================
// QQ摄影·光影集 —— 摄影社区App演示页面 (ArkTS / HarmonyOS)
// 场景:作品发现 / 灵感广场 / 器材灵感 / 我的
// 风格:石墨+琥珀 胶片风(石墨黑 + 琥珀金 + 暖灰)
// 结构:底部4Tab + 顶部6功能Tab + 4种弹框 + 柱状图 + 光斑粒子特效
// ============================================================

// ==================== 配色配置 ====================
interface ColorPalette {
  graphite: string
  graphiteLight: string
  amber: string
  amberLight: string
  bg: string
  cardBg: string
  ink: string
  gray: string
  hint: string
  border: string
  white: string
  red: string
  green: string
  film: string
}

const COLORS: ColorPalette = {
  graphite: '#2F3542',
  graphiteLight: '#4A5364',
  amber: '#FFB703',
  amberLight: '#FFF4D6',
  bg: '#F5F3F0',
  cardBg: '#FFFFFF',
  ink: '#28242C',
  gray: '#6B675F',
  hint: '#A8A49B',
  border: '#E8E4DD',
  white: '#FFFFFF',
  red: '#E8506E',
  green: '#3BA99C',
  film: '#1E222B'
}

// ==================== 元信息接口 ====================
interface CategoryMeta {
  label: string
  icon: string
  color: string
  bg: string
}

interface FeatureMeta {
  label: string
  color: string
  bg: string
  icon: string
}

interface NavEntry {
  label: string
  icon: string
  color: string
}

interface ParticleDot {
  x: number
  y: number
  size: number
  color: string
  speed: number
  phase: number
}

interface WeekStat {
  day: string
  likes: number
}

interface TopicCard {
  title: string
  desc: string
  icon: string
  color: string
  count: string
}

interface GearItem {
  name: string
  icon: string
  type: string
  price: string
}

// ==================== 摄影作品数据模型 ====================
@Observed
class PhotoItem {
  id: number = 0
  title: string = ''
  author: string = ''
  avatar: string = ''
  category: string = ''
  coverColor: string = ''
  icon: string = ''
  likes: number = 0
  views: number = 0
  camera: string = ''
  lens: string = ''
  aperture: string = ''
  shutter: string = ''
  iso: string = ''
  location: string = ''
  tags: string[] = []
  desc: string = ''
  isLiked: boolean = false

  constructor(id: number, title: string, author: string, avatar: string, category: string,
    coverColor: string, icon: string, likes: number, views: number, camera: string,
    lens: string, aperture: string, shutter: string, iso: string, location: string,
    tags: string[], desc: string, isLiked: boolean) {
    this.id = id
    this.title = title
    this.author = author
    this.avatar = avatar
    this.category = category
    this.coverColor = coverColor
    this.icon = icon
    this.likes = likes
    this.views = views
    this.camera = camera
    this.lens = lens
    this.aperture = aperture
    this.shutter = shutter
    this.iso = iso
    this.location = location
    this.tags = tags
    this.desc = desc
    this.isLiked = isLiked
  }
}

// ==================== 配置 Record ====================
const CATEGORY_CONFIG: Record<string, CategoryMeta> = {
  '人像': { label: '人像', icon: '👤', color: '#E8506E', bg: '#FCE8EC' },
  '风光': { label: '风光', icon: '🏔', color: '#3D9BE9', bg: '#E5F2FC' },
  '街拍': { label: '街拍', icon: '🚶', color: '#6B675F', bg: '#EFEBE4' },
  '星空': { label: '星空', icon: '🌌', color: '#6C5CA5', bg: '#ECE8F7' },
  '微距': { label: '微距', icon: '🐝', color: '#3BA99C', bg: '#E2F5F2' },
  '胶片': { label: '胶片', icon: '🎞', color: '#C77B3F', bg: '#FAEBDB' }
}

const FEATURE_CONFIG: Record<string, FeatureMeta> = {
  '编辑精选': { label: '编辑精选', color: '#FFB703', bg: '#FFF4D6', icon: '🏅' },
  '首页推荐': { label: '首页推荐', color: '#3D9BE9', bg: '#E5F2FC', icon: '⭐' },
  '热门作品': { label: '热门作品', color: '#E8506E', bg: '#FCE8EC', icon: '🔥' },
  '新锐佳作': { label: '新锐佳作', color: '#3BA99C', bg: '#E2F5F2', icon: '🌱' }
}

const BOTTOM_TABS: NavEntry[] = [
  { label: '发现', icon: '📷', color: '#2F3542' },
  { label: '广场', icon: '🖼', color: '#FFB703' },
  { label: '灵感', icon: '💡', color: '#6C5CA5' },
  { label: '我的', icon: '🎥', color: '#6B675F' }
]

const TOP_TABS: string[] = ['推荐', '人像', '风光', '街拍', '星空', '胶片']

const WEEK_LIKE_STATS: WeekStat[] = [
  { day: '周一', likes: 120 },
  { day: '周二', likes: 89 },
  { day: '周三', likes: 210 },
  { day: '周四', likes: 156 },
  { day: '周五', likes: 320 },
  { day: '周六', likes: 486 },
  { day: '周日', likes: 402 }
]

const TOPIC_CARDS: TopicCard[] = [
  { title: '黄金时刻', desc: '日出日落的温柔光线', icon: '🌅', color: '#FF9F43', count: '12.4k作品' },
  { title: '城市倒影', desc: '雨后街面的镜像世界', icon: '💧', color: '#3D9BE9', count: '8.9k作品' },
  { title: '极简主义', desc: '少即是多的构图美学', icon: '⬜', color: '#6B675F', count: '6.7k作品' },
  { title: '光影手账', desc: '记录生活中的光', icon: '📓', color: '#C77B3F', count: '5.2k作品' }
]

const GEAR_LIST: GearItem[] = [
  { name: 'Sony A7M4', icon: '📷', type: '全画幅微单', price: '¥15999' },
  { name: 'FE 35mm F1.4', icon: '🔭', type: '定焦镜头', price: '¥8999' },
  { name: '富士 X100VI', icon: '🎞', type: '旁轴胶片机', price: '¥11390' },
  { name: '大疆 Mini 4', icon: '🛸', type: '航拍无人机', price: '¥4788' },
  { name: '捷信旅行者', icon: '🦵', type: '碳纤维三脚架', price: '¥2680' },
  { name: '神牛V1', icon: '💡', type: '圆头闪光灯', price: '¥1380' }
]

const STYLE_OPTIONS: string[] = ['自然光', '人造光', '胶片色', '黑白', '赛博', '日系']

// ==================== 15条摄影作品数据 ====================
const PHOTO_LIST: PhotoItem[] = [
  new PhotoItem(1, '晨雾中的塔尖', '追光者·阿岚', '🏔', '风光', '#3D9BE9', '🌄', 3284, 12800, 'Sony A7M4', 'FE 24-70', 'f/8', '1/250s', 'ISO100', '黄山·光明顶', ['日出', '云海', '长焦'], '凌晨四点爬起来等的第一缕光,值了', true),
  new PhotoItem(2, '地铁阅读者', '街角快门手', '🚶', '街拍', '#6B675F', '🚇', 2156, 9800, '富士 X100VI', '等效35mm', 'f/2.0', '1/125s', 'ISO800', '上海·地铁2号线', ['街头', '决定性瞬间', '人文'], '车厢里安静读书的人,是这个城市温柔的证据', false),
  new PhotoItem(3, '银河拱桥', '星野小柯', '🌌', '星空', '#6C5CA5', '🌠', 4820, 21000, 'Sony A7M4', 'Sigma 14mm', 'f/1.8', '15s', 'ISO3200', '内蒙·明安图', ['银河', '赤道仪', '堆栈'], '300张堆栈而成的银河拱桥,肉眼看不见的宇宙', true),
  new PhotoItem(4, '回眸', '鹿岛写真馆', '👤', '人像', '#E8506E', '👁', 3567, 15400, 'Canon R6II', 'RF 85mm', 'f/1.2', '1/500s', 'ISO200', '杭州·西湖', ['人像', '大光圈', '情绪'], 'f/1.2下睫毛都数得清的锐利回眸', false),
  new PhotoItem(5, '蜂鸟振翅', '微距阿哲', '🐝', '微距', '#3BA99C', '🦜', 2890, 11300, 'Nikon Z8', '105mm微距', 'f/5.6', '1/2000s', 'ISO400', '云南·西双版纳', ['微距', '高速快门', '生态'], '1/2000s定格的0.1秒,翅膀的纹理纤毫毕现', false),
  new PhotoItem(6, '绿皮火车', '胶片老赵', '🎞', '胶片', '#C77B3F', '🚂', 1985, 8600, 'Nikon FM2', '50mm', 'f/2.8', '1/250s', 'ISO400', '黔东南·凯里', ['胶片', '柯达200', '怀旧'], '柯达200的颗粒感,是数码给不了的温度', true),
  new PhotoItem(7, '霓虹雨夜', '夜色捕手', '🌃', '街拍', '#2F3542', '🌧', 4132, 18700, 'Sony A7S3', 'FE 35mm', 'f/1.4', '1/80s', 'ISO6400', '香港·旺角', ['夜景', '霓虹', '雨'], '高感夜拍之王,夜色就是我的画布', false),
  new PhotoItem(8, '雪山倒影', '风光狗老王', '🏔', '风光', '#4A90D9', '⛰', 3675, 14200, 'Sony A7R5', 'FE 16-35', 'f/11', '1/60s', 'ISO100', '川西·冷嘎措', ['倒影', '雪山', 'GND'], '等了三小时的风停瞬间,湖面如镜', true),
  new PhotoItem(9, 'old phone booth', 'CityWalker', '☎', '街拍', '#7A8B99', '📞', 1543, 6900, '理光GR3', '28mm', 'f/2.8', '1/160s', 'ISO400', '伦敦·东区', ['snap', '理光GR', '街头'], '理光GR的snap快拍,抬手就是一张', false),
  new PhotoItem(10, '逆光少女', '小森林写真', '🌿', '人像', '#E8608A', '💇', 3021, 12800, 'Canon R8', 'RF 50mm', 'f/1.8', '1/1000s', 'ISO100', '大理·洱海', ['逆光', '发丝光', '日系'], '下午五点的逆光,给头发镀了层金边', false),
  new PhotoItem(11, '星轨同心圆', '夜空守望', '⭐', '星空', '#55518A', '🌀', 3980, 16900, 'Sony A7M4', 'Sigma 20mm', 'f/2.0', '20s', 'ISO1600', '青海·茶卡', ['星轨', '延时', '北辰'], '对准北极星的300张合成,时间的同心圆', true),
  new PhotoItem(12, '晨露蜘蛛网', '微距小花', '🕸', '微距', '#2FA36B', '💧', 2534, 9700, 'OM-1', '60mm微距', 'f/4', '1/160s', 'ISO200', '成都·青城山', ['晨露', '手持', '焦点堆栈'], '清晨五点的蛛网,缀满了一整夜的露水', false),
  new PhotoItem(13, '京都小巷', '和风胶片', '⛩', '胶片', '#B0704C', '🏮', 2210, 9100, 'Contax T2', '38mm', 'f/2.8', '1/250s', 'ISO200', '京都·东山', ['胶片', 'CCD色', '旅拍'], '宾得67的色调,一秒穿越到昭和时代', false),
  new PhotoItem(14, '月升金山', '风光狗老王', '🌕', '风光', '#C77B3F', '🌠', 4468, 19800, 'Sony A7R5', 'FE 200-600', 'f/8', '1/15s', 'ISO400', '川西·子梅垭口', ['悬月', '长焦', '月照金山'], '用巧摄算好的机位,月亮刚好落在贡嘎尖上', true),
  new PhotoItem(15, '黑与白', '影调诗人', '🖤', '人像', '#28242C', '🎭', 1876, 8200, 'Leica M11', '35mm', 'f/2.0', '1/125s', 'ISO800', '北京·798', ['黑白', '高对比', '徕卡'], '去掉颜色后,只剩下最纯粹的情绪', false)
]

// ==================== 全局纯函数 ====================
function getCategoryMeta(category: string): CategoryMeta {
  const meta: CategoryMeta | undefined = CATEGORY_CONFIG[category]
  if (meta) {
    return meta
  }
  return { label: category, icon: '📷', color: '#6B675F', bg: '#EFEBE4' }
}

function getFeatureMeta(feature: string): FeatureMeta {
  const meta: FeatureMeta | undefined = FEATURE_CONFIG[feature]
  if (meta) {
    return meta
  }
  return { label: feature, color: '#6B675F', bg: '#EFEBE4', icon: '⭐' }
}

function barHeight(likes: number): string {
  return (likes / 20).toString() + 'vp'
}

function countText(count: number): string {
  if (count >= 10000) {
    return (count / 10000).toFixed(1) + 'w'
  }
  if (count >= 1000) {
    return (count / 1000).toFixed(1) + 'k'
  }
  return count.toString()
}

function exifText(item: PhotoItem): string {
  return item.aperture + ' · ' + item.shutter + ' · ' + item.iso
}

function filterByCategory(category: string): PhotoItem[] {
  if (category === '推荐') {
    return PHOTO_LIST
  }
  const result: PhotoItem[] = []
  for (let i = 0; i < PHOTO_LIST.length; i++) {
    if (PHOTO_LIST[i].category === category) {
      result.push(PHOTO_LIST[i])
    }
  }
  return result
}

@Entry
@Component
struct Index {
  @State bottomTab: string = '发现'
  @State topTab: string = '推荐'
  @State showAddModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedPhoto: PhotoItem = PHOTO_LIST[0]
  @State deleteIndex: number = 0
  @State inputTitle: string = ''
  @State selectStyle: string = '自然光'
  @State inputBio: string = ''
  @State likeCount: number = 3
  @State particles: ParticleDot[] = []
  private timerId: number = -1

  aboutToAppear(): void {
    const initParticles: ParticleDot[] = []
    for (let i = 0; i < 18; i++) {
      initParticles.push({
        x: Math.random() * 100,
        y: Math.random() * 100,
        size: 3 + Math.random() * 7,
        color: i % 3 === 0 ? '#FFB703' : (i % 3 === 1 ? '#FFD166' : '#FFE8A3'),
        speed: 0.2 + Math.random() * 0.5,
        phase: Math.random() * 6.28
      })
    }
    this.particles = initParticles
    this.timerId = setInterval(() => {
      const nextParticles: ParticleDot[] = []
      for (let i = 0; i < this.particles.length; i++) {
        const p: ParticleDot = this.particles[i]
        let newY: number = p.y - p.speed
        let newSize: number = p.size + Math.sin(newY / 10 + p.phase) * 1.5
        if (newY < -5) {
          newY = 105
        }
        nextParticles.push({ x: p.x + Math.sin(newY / 25 + p.phase) * 0.3, y: newY, size: newSize, color: p.color, speed: p.speed, phase: p.phase })
      }
      this.particles = nextParticles
    }, 60)
  }

  aboutToDisappear(): void {
    if (this.timerId >= 0) {
      clearInterval(this.timerId)
    }
  }

  // ==================== 粒子层(漂浮光斑) ====================
  @Builder
  particleLayer() {
    ForEach(this.particles, (p: ParticleDot) => {
      Circle()
        .width(p.size)
        .height(p.size)
        .fill(p.color)
        .opacity(0.4)
        .position({ x: p.x + '%', y: p.y + '%' })
    }, (p: ParticleDot) => p.phase.toString() + p.size.toString())
  }

  // ==================== 顶部导航(相机肩带风格·石墨底) ====================
  @Builder
  headerBar() {
    Row({ space: 10 }) {
      Text('📷')
        .fontSize(24)
      Column({ space: 2 }) {
        Text('光影集')
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('ISO 100 · f/1.8 · 快乐摄影')
          .fontSize(10)
          .fontColor('#C9C4BB')
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Text('💬')
        .fontSize(18)
        .width(36)
        .height(36)
        .textAlign(TextAlign.Center)
        .backgroundColor('rgba(255,255,255,0.12)')
        .borderRadius(12)
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 14, bottom: 14 })
    .backgroundColor(COLORS.graphite)
  }

  // ==================== 顶部Tab ====================
  @Builder
  topTabBar() {
    Scroll() {
      Row({ space: 6 }) {
        ForEach(TOP_TABS, (tab: string) => {
          Column({ space: 4 }) {
            Text(getCategoryMeta(tab).icon + ' ' + tab)
              .fontSize(12)
              .fontColor(this.topTab === tab ? COLORS.white : COLORS.gray)
            if (this.topTab === tab) {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor(COLORS.amber)
            }
          }
          .padding({ left: 12, right: 12, top: 9, bottom: 7 })
          .backgroundColor(this.topTab === tab ? COLORS.graphite : COLORS.cardBg)
          .borderRadius(10)
          .onClick(() => {
            this.topTab = tab
          })
        }, (tab: string) => tab)
      }
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .backgroundColor(COLORS.bg)
  }

  // ==================== 大图作品卡(样式A:单列沉浸大卡) ====================
  @Builder
  photoBigCard(item: PhotoItem) {
    Column({ space: 0 }) {
      Stack({ alignContent: Alignment.TopStart }) {
        Column({ space: 4 }) {
          Text(item.icon)
            .fontSize(56)
        }
        .width('100%')
        .height(170)
        .justifyContent(FlexAlign.Center)

        Text(getFeatureMeta(item.likes > 3000 ? '编辑精选' : '新锐佳作').icon + ' ' + getFeatureMeta(item.likes > 3000 ? '编辑精选' : '新锐佳作').label)
          .fontSize(9)
          .fontColor(COLORS.film)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(COLORS.amber)
          .borderRadius(8)
          .margin(8)
      }
      .width('100%')
      .borderRadius({ topLeft: 14, topRight: 14 })

      Column({ space: 8 }) {
        Text(item.title)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')
          .maxLines(1)

        Text(item.desc)
          .fontSize(11)
          .fontColor(COLORS.gray)
          .width('100%')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Row({ space: 10 }) {
          Text(item.avatar)
            .fontSize(16)
            .width(30)
            .height(30)
            .textAlign(TextAlign.Center)
            .backgroundColor(COLORS.amberLight)
            .borderRadius(15)
          Text(item.author)
            .fontSize(11)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('👁 ' + countText(item.views))
            .fontSize(10)
            .fontColor(COLORS.hint)
          Text(item.isLiked ? '❤️ ' : '🤍 ')
            .fontSize(13)
          Text(countText(item.likes))
            .fontSize(11)
            .fontColor(COLORS.red)
        }
        .width('100%')

        Row({ space: 8 }) {
          Text('📷 ' + item.camera)
            .fontSize(9)
            .fontColor(COLORS.graphiteLight)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.bg)
            .borderRadius(6)
          Text(exifText(item))
            .fontSize(9)
            .fontColor(COLORS.gray)
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .backgroundColor(COLORS.bg)
            .borderRadius(6)
          Column().layoutWeight(1)
          Text('📍 ' + item.location)
            .fontSize(9)
            .fontColor(COLORS.hint)
        }
        .width('100%')
      }
      .alignItems(HorizontalAlign.Start)
      .padding(12)
    }
    .backgroundColor(COLORS.cardBg)
    .borderRadius(14)
    .onClick(() => {
      this.selectedPhoto = item
      this.showDetailModal = true
    })
  }

  // ==================== 发现内容(单列大图流) ====================
  @Builder
  discoverContent() {
    Scroll() {
      Column({ space: 14 }) {
        ForEach(filterByCategory(this.topTab), (item: PhotoItem) => {
          Column() {
            this.photoBigCard(item)
          }
        }, (item: PhotoItem) => this.topTab + item.id.toString())
      }
      .padding({ left: 14, right: 14, top: 12, bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

  // ==================== 广场内容(样式B:三列小格子墙) ====================
  @Builder
  plazaContent() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          Text('🖼 全站作品墙')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('按热度')
            .fontSize(11)
            .fontColor(COLORS.amber)
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        Grid() {
          ForEach(PHOTO_LIST, (item: PhotoItem) => {
            GridItem() {
              Column({ space: 0 }) {
                Stack({ alignContent: Alignment.TopStart }) {
                  Column() {
                    Text(item.icon)
                      .fontSize(26)
                  }
                  .width('100%')
                  .height(92)
                  .justifyContent(FlexAlign.Center)
                  .backgroundColor(item.coverColor)

                  Row({ space: 3 }) {
                    Text('❤')
                      .fontSize(9)
                    Text(countText(item.likes))
                      .fontSize(9)
                  }
                  .padding(5)
                  .backgroundColor('rgba(0,0,0,0.35)')
                  .borderRadius(6)
                  .margin(4)
                }
                .width('100%')
                .borderRadius(10)
              }
              .onClick(() => {
                this.selectedPhoto = item
                this.showDetailModal = true
              })
            }
          }, (item: PhotoItem) => 'plaza' + item.id.toString())
        }
        .columnsTemplate('1fr 1fr 1fr')
        .columnsGap(6)
        .rowsGap(6)
        .padding({ left: 14, right: 14 })

        Text('📊 本周获赞趋势')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')
          .padding({ left: 14, right: 14 })

        Column({ space: 10 }) {
          Row({ space: 10 }) {
            ForEach(WEEK_LIKE_STATS, (stat: WeekStat) => {
              Column({ space: 5 }) {
                Text(countText(stat.likes))
                  .fontSize(9)
                  .fontColor(COLORS.gray)
                Column()
                  .width(16)
                  .height(barHeight(stat.likes))
                  .backgroundColor(stat.likes >= 300 ? COLORS.amber : COLORS.graphiteLight)
                  .borderRadius(8)
                Text(stat.day.replace('周', ''))
                  .fontSize(9)
                  .fontColor(COLORS.gray)
              }
            }, (stat: WeekStat) => stat.day)
          }
          .alignItems(VerticalAlign.Bottom)
          Text('周末出片率最高,点赞是更新的动力 ❤')
            .fontSize(11)
            .fontColor(COLORS.hint)
        }
        .width('100%')
        .padding(16)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .margin({ left: 14, right: 14 })
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

  // ==================== 灵感内容(样式C:话题横滑+器材清单) ====================
  @Builder
  inspireContent() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          Text('💡 创作灵感')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.ink)
            .layoutWeight(1)
          Text('+ 发布作品')
            .fontSize(12)
            .fontColor(COLORS.white)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor(COLORS.graphite)
            .borderRadius(14)
            .onClick(() => {
              this.showAddModal = true
            })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10 })

        Scroll() {
          Row({ space: 10 }) {
            ForEach(TOPIC_CARDS, (topic: TopicCard) => {
              Column({ space: 6 }) {
                Text(topic.icon)
                  .fontSize(26)
                Text(topic.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.white)
                Text(topic.desc)
                  .fontSize(10)
                  .fontColor('rgba(255,255,255,0.75)')
                Text(topic.count)
                  .fontSize(9)
                  .fontColor(COLORS.amber)
              }
              .width(130)
              .padding({ top: 16, bottom: 16 })
              .borderRadius(14)
              .backgroundColor(topic.color)
            }, (topic: TopicCard) => topic.title)
          }
          .padding({ left: 14, right: 14 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        Text('🎒 热门器材清单')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')
          .padding({ left: 14, right: 14 })

        ForEach(GEAR_LIST, (gear: GearItem, index: number) => {
          Row({ space: 12 }) {
            Text(gear.icon)
              .fontSize(22)
              .width(46)
              .height(46)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS.amberLight)
              .borderRadius(12)

            Column({ space: 4 }) {
              Row({ space: 8 }) {
                Text(gear.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.ink)
                Text(gear.type)
                  .fontSize(9)
                  .fontColor(COLORS.gray)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.bg)
                  .borderRadius(6)
              }
              Text(index % 2 === 0 ? '本周 1.2k 位摄影师新增此器材' : '社区讨论热帖 486 条')
                .fontSize(10)
                .fontColor(COLORS.hint)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text(gear.price)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.amber)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
          .margin({ left: 14, right: 14 })
        }, (gear: GearItem) => gear.name)
      }
      .padding({ bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

  // ==================== 我的内容(样式D:作品数据+列表管理) ====================
  @Builder
  mineContent() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row({ space: 12 }) {
            Text('🎥')
              .fontSize(30)
              .width(56)
              .height(56)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS.amberLight)
              .borderRadius(28)
            Column({ space: 4 }) {
              Text('快门里的光')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('快门 12.8 万次 · 快门寿命的 1/4')
                .fontSize(11)
                .fontColor('#C9C4BB')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('编辑')
              .fontSize(11)
              .fontColor(COLORS.film)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .backgroundColor(COLORS.amber)
              .borderRadius(12)
              .onClick(() => {
                this.showEditModal = true
              })
          }
          .width('100%')

          Row({ space: 0 }) {
            Column({ space: 3 }) {
              Text('86')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.amber)
              Text('作品')
                .fontSize(10)
                .fontColor('#C9C4BB')
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text('2.4w')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.amber)
              Text('获赞')
                .fontSize(10)
                .fontColor('#C9C4BB')
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text('3210')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.amber)
              Text('粉丝')
                .fontSize(10)
                .fontColor('#C9C4BB')
            }
            .layoutWeight(1)
          }
          .width('100%')
        }
        .width('100%')
        .padding(16)
        .linearGradient({
          direction: GradientDirection.RightBottom,
          colors: [[COLORS.graphite, 0], [COLORS.graphiteLight, 1]]
        })
        .borderRadius(16)

        Text('🗂 我的作品')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .width('100%')

        ForEach(PHOTO_LIST, (item: PhotoItem, index: number) => {
          if (item.isLiked) {
            Row({ space: 12 }) {
              Text(item.icon)
                .fontSize(22)
                .width(48)
                .height(48)
                .textAlign(TextAlign.Center)
                .backgroundColor(item.coverColor + '22')
                .borderRadius(10)

              Column({ space: 3 }) {
                Text(item.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.ink)
                  .maxLines(1)
                Text('❤ ' + countText(item.likes) + ' · 👁 ' + countText(item.views) + ' · ' + item.category)
                  .fontSize(10)
                  .fontColor(COLORS.gray)
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text('编辑')
                .fontSize(11)
                .fontColor(COLORS.graphiteLight)
                .onClick(() => {
                  this.selectedPhoto = item
                  this.showEditModal = true
                })
              Text('✕')
                .fontSize(13)
                .fontColor(COLORS.hint)
                .width(24)
                .height(24)
                .textAlign(TextAlign.Center)
                .backgroundColor(COLORS.bg)
                .borderRadius(12)
                .onClick(() => {
                  this.deleteIndex = index
                  this.showDeleteModal = true
                })
            }
            .width('100%')
            .padding(12)
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
          }
        }, (item: PhotoItem) => 'mywork' + item.id.toString())
      }
      .padding({ left: 14, right: 14, top: 12, bottom: 20 })
    }
    .scrollBar(BarState.Off)
    .layoutWeight(1)
  }

  // ==================== 底部Tab栏 ====================
  @Builder
  bottomTabBar() {
    Row() {
      ForEach(BOTTOM_TABS, (tab: NavEntry) => {
        Column({ space: 3 }) {
          Text(tab.icon)
            .fontSize(22)
          Text(tab.label)
            .fontSize(10)
            .fontColor(this.bottomTab === tab.label ? tab.color : COLORS.hint)
        }
        .layoutWeight(1)
        .onClick(() => {
          this.bottomTab = tab.label
        })
      }, (tab: NavEntry) => tab.label)
    }
    .width('100%')
    .padding({ top: 8, bottom: 10 })
    .backgroundColor(COLORS.cardBg)
  }

  // ==================== 弹框1:发布作品(石墨渐变头+EXIF表单) ====================
  @Builder
  publishModal() {
    Column() {
      Column({ space: 6 }) {
        Text('📸 发布新作品')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('让好照片被更多人看见')
          .fontSize(11)
          .fontColor('#C9C4BB')
      }
      .width('100%')
      .padding({ top: 18, bottom: 16 })
      .linearGradient({
        direction: GradientDirection.Right,
        colors: [[COLORS.graphite, 0], [COLORS.graphiteLight, 1]]
      })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Column({ space: 14 }) {
        Column({ space: 6 }) {
          Text('作品标题')
            .fontSize(12)
            .fontColor(COLORS.gray)
          TextInput({ placeholder: '给照片起个名字...', text: this.inputTitle })
            .fontSize(14)
            .height(42)
            .backgroundColor(COLORS.bg)
            .borderRadius(10)
            .onChange((value: string) => {
              this.inputTitle = value
            })
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Column({ space: 8 }) {
          Text('色调风格')
            .fontSize(12)
            .fontColor(COLORS.gray)
          Row({ space: 8 }) {
            ForEach(STYLE_OPTIONS, (s: string) => {
              Text(s)
                .fontSize(11)
                .fontColor(this.selectStyle === s ? COLORS.film : COLORS.gray)
                .padding({ left: 10, right: 10, top: 7, bottom: 7 })
                .backgroundColor(this.selectStyle === s ? COLORS.amber : COLORS.bg)
                .borderRadius(14)
                .onClick(() => {
                  this.selectStyle = s
                })
            }, (s: string) => s)
          }
        }
        .alignItems(HorizontalAlign.Start)
        .width('100%')

        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('f/1.8')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text('光圈')
              .fontSize(9)
              .fontColor(COLORS.hint)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          Column({ space: 3 }) {
            Text('1/250s')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text('快门')
              .fontSize(9)
              .fontColor(COLORS.hint)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          Column({ space: 3 }) {
            Text('ISO 100')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Text('感光度')
              .fontSize(9)
              .fontColor(COLORS.hint)
          }
          .layoutWeight(1)
          .padding({ top: 8, bottom: 8 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
        }
        .width('100%')

        Row({ space: 10 }) {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.gray)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.bg)
            .borderRadius(20)
            .onClick(() => {
              this.showAddModal = false
            })
          Column().layoutWeight(1)
          Text('发布作品 ✨')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.film)
            .padding({ left: 22, right: 22, top: 10, bottom: 10 })
            .backgroundColor(COLORS.amber)
            .borderRadius(20)
            .onClick(() => {
              this.likeCount = this.likeCount + 1
              this.showAddModal = false
            })
        }
        .width('100%')
      }
      .padding(16)
    }
    .width('88%')
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

  // ==================== 弹框2:编辑主页(琥珀描边表单) ====================
  @Builder
  editProfileModal() {
    Column({ space: 14 }) {
      Row({ space: 8 }) {
        Text('✍️')
          .fontSize(20)
        Text('编辑个人主页')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.ink)
          .layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor(COLORS.hint)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')

      Column({ space: 8 }) {
        Text('昵称')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.graphiteLight)
        TextInput({ placeholder: '快门里的光', text: this.inputBio })
          .fontSize(14)
          .height(42)
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .onChange((value: string) => {
            this.inputBio = value
          })
      }
      .alignItems(HorizontalAlign.Start)
      .width('100%')
      .padding(14)
      .border({ width: 1, color: COLORS.amber, radius: 12 })

      Column({ space: 8 }) {
        Text('常用风格')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.amber)
        Row({ space: 8 }) {
          ForEach(['风光', '人像', '街拍', '胶片'], (s: string) => {
            Text(s)
              .fontSize(12)
              .fontColor(this.selectStyle === s ? COLORS.white : COLORS.gray)
              .padding({ left: 12, right: 12, top: 7, bottom: 7 })
              .backgroundColor(this.selectStyle === s ? COLORS.graphite : COLORS.bg)
              .borderRadius(14)
              .onClick(() => {
                this.selectStyle = s
              })
          }, (s: string) => 'p' + s)
        }
      }
      .alignItems(HorizontalAlign.Start)
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.amberLight)
      .borderRadius(12)

      Text('主页头图与水印设置可在网页端管理')
        .fontSize(10)
        .fontColor(COLORS.hint)
        .width('100%')

      Row({ space: 10 }) {
        Column().layoutWeight(1)
        Text('保存资料 ✅')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 22, right: 22, top: 10, bottom: 10 })
          .backgroundColor(COLORS.graphite)
          .borderRadius(18)
          .onClick(() => {
            this.showEditModal = false
          })
      }
      .width('100%')
    }
    .width('88%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .constraintSize({ maxHeight: '80%' })
  }

  // ==================== 弹框3:删除作品(小警示卡) ====================
  @Builder
  deletePhotoModal() {
    Column({ space: 14 }) {
      Text('🗑')
        .fontSize(34)
      Text('删除这幅作品?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.ink)
      Text('「' + PHOTO_LIST[this.deleteIndex].title + '」将从你的主页下架,互动数据同步清空')
        .fontSize(12)
        .fontColor(COLORS.gray)
        .textAlign(TextAlign.Center)
      Row({ space: 12 }) {
        Text('保留')
          .fontSize(13)
          .fontColor(COLORS.gray)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.bg)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
        Text('删除')
          .fontSize(13)
          .fontColor(COLORS.white)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.red)
          .borderRadius(18)
          .onClick(() => {
            this.showDeleteModal = false
          })
      }
    }
    .width('72%')
    .padding({ top: 24, bottom: 22, left: 18, right: 18 })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
  }

  // ==================== 弹框4:作品详情(大图+完整EXIF+参数) ====================
  @Builder
  photoDetailModal() {
    Column() {
      Column({ space: 8 }) {
        Text(this.selectedPhoto.icon)
          .fontSize(52)
        Text(this.selectedPhoto.title)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text('📍 ' + this.selectedPhoto.location)
          .fontSize(12)
          .fontColor('#C9C4BB')
      }
      .width('100%')
      .padding({ top: 22, bottom: 20 })
      .borderRadius({ topLeft: 16, topRight: 16 })

      Scroll() {
        Column({ space: 12 }) {
          Row({ space: 10 }) {
            Text(this.selectedPhoto.avatar)
              .fontSize(18)
              .width(36)
              .height(36)
              .textAlign(TextAlign.Center)
              .backgroundColor(COLORS.amberLight)
              .borderRadius(18)
            Column({ space: 3 }) {
              Text(this.selectedPhoto.author)
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.ink)
              Text(getCategoryMeta(this.selectedPhoto.category).icon + ' ' + this.selectedPhoto.category + '领域创作者')
                .fontSize(10)
                .fontColor(COLORS.gray)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Text('+ 关注')
              .fontSize(11)
              .fontColor(COLORS.film)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(COLORS.amber)
              .borderRadius(14)
          }
          .width('100%')

          Text(this.selectedPhoto.desc)
            .fontSize(12)
            .fontColor(COLORS.gray)
            .lineHeight(19)
            .width('100%')

          Column({ space: 10 }) {
            Text('🔧 拍摄参数')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              Column({ space: 3 }) {
                Text(this.selectedPhoto.camera)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('机身')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              Column({ space: 3 }) {
                Text(this.selectedPhoto.lens)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('镜头')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
            }
            .width('100%')
            Row({ space: 8 }) {
              Column({ space: 3 }) {
                Text(this.selectedPhoto.aperture)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('光圈')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              Column({ space: 3 }) {
                Text(this.selectedPhoto.shutter)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('快门')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
              Column({ space: 3 }) {
                Text(this.selectedPhoto.iso)
                  .fontSize(11)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.graphiteLight)
                Text('ISO')
                  .fontSize(9)
                  .fontColor(COLORS.hint)
              }
              .layoutWeight(1)
              .padding({ top: 8, bottom: 8 })
              .backgroundColor(COLORS.bg)
              .borderRadius(10)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)

          Column({ space: 8 }) {
            Text('🏷 标签')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.ink)
            Row({ space: 8 }) {
              ForEach(this.selectedPhoto.tags, (t: string) => {
                Text('#' + t)
                  .fontSize(11)
                  .fontColor(COLORS.amber)
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .backgroundColor(COLORS.amberLight)
                  .borderRadius(10)
              }, (t: string) => this.selectedPhoto.id.toString() + t)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 0 }) {
            Column({ space: 3 }) {
              Text('❤️ ' + countText(this.selectedPhoto.likes))
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.red)
            }
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text('👁 ' + countText(this.selectedPhoto.views))
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.graphiteLight)
            }
            .layoutWeight(1)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.bg)
          .borderRadius(12)

          Row({ space: 10 }) {
            Text('✕ 关闭')
              .fontSize(13)
              .fontColor(COLORS.gray)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.bg)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
            Column().layoutWeight(1)
            Text('点赞支持 ❤')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 18, right: 18, top: 10, bottom: 10 })
              .backgroundColor(COLORS.red)
              .borderRadius(18)
              .onClick(() => {
                this.showDetailModal = false
              })
          }
          .width('100%')
        }
        .padding(14)
      }
      .scrollBar(BarState.Off)
      .layoutWeight(1)
    }
    .width('88%')
    .height('78%')
    .backgroundColor(COLORS.bg)
    .borderRadius(16)
  }

  // ==================== 弹框遮罩 ====================
  @Builder
  modalOverlay() {
    Column() {
      Column().layoutWeight(1)
      if (this.showAddModal) {
        Column() {
          this.publishModal()
        }
      }
      if (this.showEditModal) {
        Column() {
          this.editProfileModal()
        }
      }
      if (this.showDeleteModal) {
        Column() {
          this.deletePhotoModal()
        }
      }
      if (this.showDetailModal) {
        Column() {
          this.photoDetailModal()
        }
      }
      Column().layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(20,18,25,0.6)')
    .justifyContent(FlexAlign.Center)
    .onClick(() => {
      this.showAddModal = false
      this.showEditModal = false
      this.showDeleteModal = false
      this.showDetailModal = false
    })
  }

  // ==================== 主构建 ====================
  build() {
    Stack() {
      Column() {
        this.headerBar()
        if (this.bottomTab === '发现') {
          this.topTabBar()
          this.discoverContent()
        }
        if (this.bottomTab === '广场') {
          this.plazaContent()
        }
        if (this.bottomTab === '灵感') {
          this.inspireContent()
        }
        if (this.bottomTab === '我的') {
          this.mineContent()
        }
        this.bottomTabBar()
      }
      .width('100%')
      .height('100%')

      this.particleLayer()

      if (this.showAddModal || this.showEditModal || this.showDeleteModal || this.showDetailModal) {
        Column() {
          this.modalOverlay()
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


在这里插入图片描述

五、总结

本文以"QQ摄影·光影集"摄影社区App演示页面为实战案例,全面深入地解析了如何基于HarmonyOS 6.1.1平台的ArkTS声明式UI开发框架和HarmonyOS ArkTS API 24构建一个功能完整、视觉精致的移动端社区应用页面。

从技术架构层面看,整个应用构建在"状态驱动视图"的核心范式之上。12个@State状态变量构成了应用的状态管理层,统一管理导航切换、弹框开关、表单输入和粒子动画等全部交互状态。状态变更通过ArkTS的响应式机制自动触发UI重渲染,开发者只需修改状态数据,无需手动操作DOM节点,大幅降低了UI同步的复杂度和出错概率。@Observed装饰的PhotoItem类使作品数据对象成为可观察实体,对象属性的变化能够被框架精确追踪并触发精确的局部更新。

从UI构建层面看,应用通过11个@Builder构建器函数实现了UI的模块化复用。每个构建器负责一个独立的UI区域——从顶部导航栏、顶部Tab栏到四种内容布局、四种弹框,每个模块都有清晰的职责边界和输入输出接口。ForEach列表渲染配合键值生成函数实现了高效的列表Diff更新,当数据变化时只更新必要的列表项,避免了全量重渲染的性能开销。

从动效实现层面看,粒子动画引擎展示了ArkTS中基于定时器实现帧动画的完整方案。aboutToAppear生命周期中初始化18个粒子和60ms定时器,每次回调通过数学运算更新粒子位置和尺寸,@State的响应式特性使新粒子数组自动触发重渲染。aboutToDisappear中的定时器清理保证了资源的正确释放,避免了内存泄漏。

从数据工程层面看,七个接口定义和七个静态数据常量构成了应用的类型安全数据层。Record类型的查找表实现了分类和功能元信息的集中配置,六个纯函数作为数据与UI之间的转换桥梁,将原始数据转换为UI可直接消费的格式。这种"接口约束+集中配置+纯函数转换"的三层数据架构使得数据源更换(如从Mock数据切换为网络数据)时只需修改数据层定义,组件层代码完全不变。

从视觉设计层面看,"石墨+琥珀"的胶片风格配色体系通过单一COLORS常量对象统一管理,确保了全应用视觉风格的一致性。linearGradient线性渐变用于头部和卡片背景营造质感层次,条件渲染的指示器和标签实现了数据驱动的高亮效果,纯ArkTS组件手工构建的柱状图展示了零依赖数据可视化的能力。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐