一、开头引言

在这里插入图片描述

在移动互联网时代,摄影爱好者需要一个专门的平台来展示作品、交流技术、记录成长。本文将深度剖析一款精心设计的摄影社区应用——它以"镜头灰"与"胶片橙"为主色调,构建了一个集作品展示、摄影师排行、器材管理、相册分类于一体的完整生态。这款应用不仅仅是一个图片展示工具,更是摄影师的数字工作室和社交家园。

从技术架构的角度来看,这款应用采用了典型的声明式UI开发范式,基于ArkTS语言和ArkUI框架构建。整体采用Tab导航的五页结构:精选首页、作品画廊、摄影师排行、器材库、个人中心。每个页面都经过精心设计,从颜色系统到数据模型,从状态管理到组件复用,都体现了成熟的移动应用开发思维。

技术亮点方面,这款应用有几个值得称道的设计。首先是颜色系统的规范化设计,通过接口定义颜色调色板,实现了主题一致性和可维护性。其次是数据模型层的清晰分层,每个业务实体都有对应的接口定义,为后续接入真实API打下了良好基础。第三是弹窗组件的复用设计,通过modalOverlay构建器统一实现遮罩层,各种业务弹窗在此基础上进行扩展。第四是柱状图的纯代码实现,不依赖第三方图表库,用基础组件组合出了精美的数据可视化效果。

这款应用的整体设计思路体现了"以摄影师为中心"的产品理念——首页的精选大图头卡配合曝光参数浮层,让专业摄影信息一目了然;周拍摄柱状图帮助用户追踪自己的创作频率;摄影师排行榜激发创作动力;器材库管理让设备信息井井有条;加密相册则保护隐私作品。每一个功能模块都围绕着摄影爱好者的真实需求展开。

二、整体架构流程图

在这里插入图片描述

下面通过Mermaid流程图展示这款摄影社区APP的整体架构,从Tab导航到各页面组件,再到数据模型和弹窗交互的完整链路。

HOME

GALLERY

RANK

GEAR

MINE

PhotoApp 主入口

Tab导航栏

内容区域 contentArea

精选 Tab

作品 Tab

排行 Tab

器材 Tab

我的 Tab

状态管理 @State activeTab

PhotoHomeContent 首页

PhotoGalleryContent 作品页

PhotoRankContent 排行页

PhotoGearContent 器材页

PhotoMineContent 我的页

精选大图头卡

周拍摄柱状图

热门标签

摄影大赛

精选作品列表

上传新作品按钮

新增作品弹窗

瀑布流双列布局

作品卡片组件

前三名展示区

摄影师排行榜

器材列表

器材统计

编辑相机弹窗

个人资料头卡

我的相册列表

删除相册弹窗

数据层

COLORS 颜色常量

TAB_CONFIG 导航配置

PHOTO_LIST 作品数据

WEEK_SHOT 周数据

PHOTOGRAPHER_LIST 摄影师数据

CAMERA_LIST 相机数据

ALBUM_LIST 相册数据

TAG_LIST 标签数据

CONTEST_LIST 比赛数据

工具函数层

getHotColor 热门色

getFollowColor 关注色

getLockBg 锁背景色

三、颜色与主题系统详解

在这里插入图片描述

3.1 颜色调色板接口设计

颜色系统是整个应用视觉风格的基石。这款应用采用了接口+常量的设计模式,先通过接口定义颜色调色板的结构,再用常量对象提供具体的色值实现。

interface PhotoColorPalette {
  primary: string
  primaryLight: string
  primaryDark: string
  accent: string
  accentLight: string
  warm: string
  bg: string
  cardBg: string
  textPrimary: string
  textSecondary: string
  textHint: string
  border: string
  success: string
  warning: string
  danger: string
  white: string
}

这段接口定义了16种颜色角色,覆盖了移动应用UI设计的完整色板需求。主色系(primary系列)提供了基础、浅色、深色三个层级,用于构建应用的主视觉基调。强调色系(accent系列)提供了强调色和浅色两个层级,用于突出关键操作和重要信息。warm色作为暖色调背景,用于输入框、标签等需要温和视觉的区域。

文字颜色分为三级:textPrimary用于主要文字,textSecondary用于次要说明文字,textHint用于提示文字和占位符。这种三级文字色体系是移动应用的标准做法,通过颜色明度差异建立清晰的信息层级。

功能色方面,success(成功绿)、warning(警告橙)、danger(危险红)三色分别对应不同的业务状态反馈。边框色border用于分割线和卡片边框,背景色bg和cardBg分别对应页面背景和卡片背景,建立了明确的层次关系。

3.2 配色方案的设计理念

const COLORS: PhotoColorPalette = {
  primary: '#455A64',
  primaryLight: '#78909C',
  primaryDark: '#263238',
  accent: '#FF6F00',
  accentLight: '#FFB74D',
  warm: '#FFF3E0',
  bg: '#FAFAFA',
  cardBg: '#FFFFFF',
  textPrimary: '#263238',
  textSecondary: '#607D8B',
  textHint: '#B0BEC5',
  border: '#ECEFF1',
  success: '#43A047',
  warning: '#FB8C00',
  danger: '#E53935',
  white: '#FFFFFF'
}

这款应用的主色调选择了"镜头灰"(#455A64)作为primary色,这是一种偏蓝的深灰色,灵感来源于专业相机的金属机身质感,传递出专业、稳重、高端的品牌调性。primaryLight(#78909C)和primaryDark(#263238)构成了完整的灰色阶梯,用于渐变背景、阴影效果等场景。

强调色选择了"胶片橙"(#FF6F00),这是一种饱和度较高的橙色,灵感来源于柯达等经典胶片品牌的包装色彩。橙色与蓝灰色形成了鲜明的冷暖对比,在灰调为主的界面中起到画龙点睛的作用。橙色天然带有"创意"、“活力”、"温暖"的语义,非常契合摄影社区的产品定位。

warm色(#FFF3E0)是一种非常浅的暖橙色,用作输入框背景、标签背景等,既保持了视觉上的柔和感,又与主强调色系保持统一。背景色选择了接近白色的浅灰(#FAFAFA),既避免了纯白的刺眼感,又能让卡片内容更加突出。

3.3 常量配置方式

颜色常量采用顶层const的方式定义,这意味着在整个应用的任何组件中都可以直接访问COLORS对象。这种设计有几个优点:第一,全局唯一的颜色源,确保了整个应用视觉风格的一致性;第二,修改方便,只需修改一处即可全局生效;第三,配合TypeScript类型检查,使用时可以获得代码提示和类型安全保障。

除了颜色常量,应用还定义了一系列工具函数来根据业务状态返回对应的颜色。这种设计模式将"状态→颜色"的映射逻辑集中管理,避免了在组件中重复编写条件判断代码。

function getHotColor(isHot: boolean): string {
  return isHot ? COLORS.accent : COLORS.primaryLight
}

function getFollowColor(isFollow: boolean): string {
  return isFollow ? COLORS.border : COLORS.accent
}

function getLockBg(isLock: boolean): string {
  return isLock ? COLORS.warm : COLORS.border
}

getHotColor函数根据作品是否热门返回不同的颜色——热门作品用强调色(橙色),非热门用主色浅色。getFollowColor函数根据关注状态返回颜色——已关注返回边框灰(表示"已完成"状态),未关注返回强调色(表示"可操作"状态)。getLockBg函数根据相册是否加密返回背景色——加密相册用暖色调背景,未加密用边框色背景。

这些工具函数的设计体现了"关注点分离"的原则,将颜色计算逻辑从UI组件中抽离出来,让组件代码更加简洁清晰,同时也便于统一维护和修改。

四、数据模型层深度解析

在这里插入图片描述

4.1 PhotoMeta 作品数据模型

interface PhotoMeta {
  title: string
  author: string
  avatar: string
  likes: number
  camera: string
  lens: string
  aperture: string
  iso: string
  isHot: boolean
}

PhotoMeta是摄影作品的数据模型,包含9个字段。title是作品标题,author是作者名称,avatar是作者头像(用emoji表示),likes是点赞数。这四个字段构成了作品的基本信息层。

camera(相机型号)、lens(镜头焦距)、aperture(光圈值)、iso(感光度)这四个字段是摄影作品的专业参数信息,也是这款应用区别于普通图片社交应用的关键所在。对于摄影爱好者来说,这些EXIF信息是作品的重要组成部分,是学习和交流的核心内容。将这些信息作为独立字段存储,而不是混在描述文字中,便于后续进行筛选、排序、统计等高级功能开发。

isHot字段是一个布尔值,标记作品是否为热门作品。这个字段可以用于UI上的视觉差异化展示(如热门标签、特殊颜色等),也可以用于数据排序和筛选。在实际应用中,这个字段可能由后端根据点赞数、评论数、分享数等综合计算得出。

4.2 WeekShotMeta 周拍摄数据模型

interface WeekShotMeta {
  label: string
  value: number
}

WeekShotMeta是周拍摄统计的数据模型,结构非常简洁——label是日期标签(周一到周日),value是当天的拍摄数量(张数)。这个数据模型用于驱动首页的柱状图组件,展示用户一周内的拍摄活跃度。

虽然结构简单,但这个数据模型的设计很有代表性。它遵循了"标签-值"的经典数据结构,是图表类组件的标准数据格式。这种设计的好处是通用性强,同样的数据结构可以复用到月统计、年统计等不同时间维度的图表中,只需替换数据源即可。

4.3 PhotographerMeta 摄影师数据模型

interface PhotographerMeta {
  name: string
  avatar: string
  followers: string
  works: string
  specialty: string
  isFollow: boolean
}

PhotographerMeta是摄影师的数据模型,包含6个字段。name是摄影师名称,avatar是头像,这是用户的基本标识信息。followers是粉丝数,works是作品数,这两个数字是衡量摄影师影响力的核心指标。用string类型存储是因为粉丝数可能很大(如"12.8万"),需要格式化显示。

specialty字段表示摄影师的专长领域,如"风光·城市"、"星空·延时"等。这个字段帮助用户快速了解摄影师的风格定位,也为后续的推荐算法和分类浏览提供了数据基础。

isFollow字段是当前用户是否关注该摄影师的标记。这个布尔字段直接驱动UI上关注按钮的状态显示——已关注和未关注呈现不同的颜色和文字。在实际产品中,这个字段通常需要结合用户登录状态和关注关系表来确定。

4.4 CameraMeta 相机数据模型

interface CameraMeta {
  brand: string
  model: string
  emoji: string
  sensor: string
  pixels: string
  isMain: boolean
}

CameraMeta是相机设备的数据模型,用于器材库页面。brand是品牌(如Sony、Canon、Nikon),model是型号(如A7R5、EOS R5),这两个字段组合起来就是相机的完整名称。emoji字段用表情符号作为相机的图标表示,在没有真实图片资源的情况下提供了良好的视觉占位。

sensor字段表示传感器类型(全画幅、APS-C、M4/3等),这是相机的核心规格参数之一,对画质和镜头适配有决定性影响。pixels字段表示像素数量,是用户最关心的规格参数之一。用string类型存储是因为像素数通常带有"万"这样的单位后缀。

isMain字段标记是否为主力相机。这个字段让用户可以在多台相机中指定常用的主力机型,在UI上会有特殊的视觉标记(如"主力"标签)。这是一个很贴心的产品设计,体现了对多机身摄影师使用场景的理解。

4.5 AlbumMeta 相册数据模型

interface AlbumMeta {
  name: string
  emoji: string
  count: string
  date: string
  isLock: boolean
}

AlbumMeta是相册的数据模型,用于个人中心的相册管理。name是相册名称,emoji是相册封面图标,count是照片数量,date是相册创建或更新日期。这四个字段构成了相册的基本信息。

isLock字段标记相册是否加密。这是一个很重要的功能特性——用户可能有一些私密的照片不希望被随意查看,加密相册功能满足了这一隐私保护需求。在UI上,加密相册会显示锁图标并有不同的背景色,让用户一目了然。

4.6 TagMeta 标签数据模型

interface TagMeta {
  name: string
  count: string
  isHot: boolean
}

TagMeta是标签的数据模型,用于首页的热门标签区域。name是标签名称(带#号前缀),count是该标签下的作品数量,isHot标记是否为热门标签。

标签系统是内容型应用的标配功能,它既是内容分类的方式,也是用户发现感兴趣内容的入口。isHot字段可以用于突出展示热门标签,引导用户浏览。count字段展示作品数量,给用户一个内容丰富度的预期。

4.7 ContestMeta 比赛数据模型

interface ContestMeta {
  title: string
  emoji: string
  deadline: string
  prize: string
  isJoin: boolean
}

ContestMeta是摄影比赛的数据模型,用于首页的摄影大赛区域。title是比赛名称,emoji是比赛主题图标,deadline是截止日期,prize是奖金金额。这四个字段提供了比赛的核心信息。

isJoin字段标记用户是否已参与该比赛。这个字段直接驱动"参与/已参与"按钮的状态显示。比赛活动是社区类应用提升用户活跃度的重要运营手段,通过奖金激励和竞技性激发用户的创作热情。

4.8 TabMeta 导航标签数据模型

interface TabMeta {
  title: string
  icon: string
}

TabMeta是底部导航标签的数据模型,title是标签文字,icon是标签图标(用emoji表示)。这个接口虽然简单,但它将导航配置从组件代码中抽离出来,使得导航栏的配置更加灵活。如果需要修改导航项的文字或图标,只需修改配置数据,不需要改动组件代码。

五、配置常量层分析

在这里插入图片描述

5.1 TAB_CONFIG 导航配置

const TAB_CONFIG: Record<string, TabMeta> = {
  'HOME': { title: '精选', icon: '📷' },
  'GALLERY': { title: '作品', icon: '🖼️' },
  'RANK': { title: '排行', icon: '🏆' },
  'GEAR': { title: '器材', icon: '🎥' },
  'MINE': { title: '我的', icon: '👤' }
}

TAB_CONFIG是底部导航栏的配置常量,采用Record<string, TabMeta>的类型定义,键是枚举值的字符串形式,值是包含标题和图标的对象。这种配置驱动的设计模式有几个显著优点:

第一,集中管理。所有导航项的配置都在一个地方,修改和维护都很方便。如果产品经理要求把"精选"改成"首页",只需改一处配置即可。

第二,类型安全。配合PhotoTab枚举使用,键名与枚举值一一对应,减少了手写字符串可能导致的错误。

第三,易于扩展。如果后续需要增加新的导航项,只需在配置中添加一项,然后在枚举和contentArea中增加对应分支即可。

第四,支持国际化。通过配置的方式,可以方便地接入多语言方案,根据语言环境加载不同的标题文本。

5.2 业务配置常量的设计模式

应用中定义了多个业务数据常量,如PHOTO_LIST、WEEK_SHOT、PHOTOGRAPHER_LIST等。这些常量在开发阶段扮演着Mock数据的角色,同时也定义了各业务模块的数据结构。

从设计模式的角度来看,这些常量遵循了"数据驱动视图"的理念——组件只负责渲染逻辑,数据由外部提供。这种设计使得组件具有很好的复用性和可测试性。当需要接入真实后端API时,只需将数据源从常量替换为网络请求返回的数据即可,组件本身不需要做任何修改。

常量命名采用了"全大写下划线分隔"的风格,这是常量命名的通用约定,让人一眼就能看出这是不可变的数据。数组类型的常量都带有_LIST后缀,对象类型的常量带有_CONFIG后缀,命名清晰且具有自解释性。

5.3 枚举类型的使用

enum PhotoTab {
  HOME,
  GALLERY,
  RANK,
  GEAR,
  MINE
}

PhotoTab枚举定义了五个Tab页的标识,使用数字枚举的方式(默认从0开始递增)。枚举类型在Tab导航场景中非常适用,它带来了以下好处:

一是代码可读性强。用PhotoTab.HOME代替魔法数字0,代码的意图更加清晰。
二是类型安全。TypeScript会对枚举值进行类型检查,防止传入无效的Tab值。
三是重构友好。如果需要调整Tab的顺序或增减Tab项,只需修改枚举定义,编译器会自动检查所有使用处。
四是智能提示。在IDE中输入PhotoTab.时会自动补全所有可用的枚举值,提升开发效率。

六、Mock数据层分析

在这里插入图片描述

6.1 模拟数据的设计思路

这款应用的Mock数据设计非常考究,不是简单的占位数据,而是具有真实业务场景特征的模拟数据。以PHOTO_LIST为例,12条作品数据涵盖了风光、人像、微距、街头、星空、胶片等多种摄影题材,相机品牌包括Sony、Canon、Nikon、Fuji、Olympus、Leica、Pentax等主流品牌,点赞数从980到5230不等,分布非常真实。

这种高质量的Mock数据有几个重要价值。第一,在开发阶段就能看到接近真实的界面效果,便于设计师和产品经理评估UI设计。第二,丰富的数据样例可以暴露更多边界情况,比如长标题、大数字、热门标签等,确保UI在各种数据状态下都能正常显示。第三,为后续的测试工作提供了数据基础,可以直接基于这些样例数据编写测试用例。

6.2 作品数据结构分析

const PHOTO_LIST: PhotoMeta[] = [
  { title: '城市天际线', author: '光影捕手', avatar: '🧑‍🎨', likes: 3280, camera: 'Sony A7R5', lens: '24-70mm', aperture: 'f/8', iso: 'ISO 100', isHot: true },
  { title: '雾中山林', author: '自然之眼', avatar: '🌲', likes: 2510, camera: 'Canon R5', lens: '70-200mm', aperture: 'f/4', iso: 'ISO 200', isHot: true },
  // ... 更多数据
]

PHOTO_LIST包含12条作品数据,每条数据都有完整的9个字段。观察这些数据可以发现几个设计细节:

作者名称与头像emoji是对应的,比如"光影捕手"对应"🧑‍🎨",“星空猎人"对应"🌌”,“微距世界"对应"🌸”。这种对应关系让数据更有真实感,也帮助用户快速记忆和识别不同的摄影师。

同一个作者有多条作品,比如"光影捕手"有3条作品,"行走的镜头"有2条作品。这模拟了真实社区中多产作者的情况,也让摄影师排行页的数据更有依据。

isHot字段的分布也很合理,12条作品中有6条标记为热门,比例约为50%。热门作品通常点赞数较高,但也不是绝对的——这种不完全对应的关系更接近真实业务场景。

6.3 摄影师数据结构分析

const PHOTOGRAPHER_LIST: PhotographerMeta[] = [
  { name: '光影捕手', avatar: '🧑‍🎨', followers: '12.8万', works: '386', specialty: '风光·城市', isFollow: true },
  { name: '星空猎人', avatar: '🌌', followers: '9.6万', works: '215', specialty: '星空·延时', isFollow: true },
  // ... 更多数据
]

PHOTOGRAPHER_LIST包含8位摄影师数据。粉丝数从2.1万到12.8万不等,呈现出合理的梯度分布。作品数从67到512不等,与粉丝数大致正相关但不完全对应——这符合真实情况,有些摄影师虽然作品不多但质量很高,粉丝数也很多。

specialty字段用"·"分隔两个专长领域,这种命名方式简洁而信息密度高。用户可以一眼看出每位摄影师的擅长方向。

isFollow字段有两位摄影师标记为已关注(true),模拟了当前用户已关注部分摄影师的状态。这种混合状态让关注按钮的两种视觉状态都能在页面中展示出来,便于UI验收。

6.4 相机数据结构分析

const CAMERA_LIST: CameraMeta[] = [
  { brand: 'Sony', model: 'A7R5', emoji: '📸', sensor: '全画幅', pixels: '6100万', isMain: true },
  { brand: 'Canon', model: 'EOS R5', emoji: '📸', sensor: '全画幅', pixels: '4500万', isMain: false },
  // ... 更多数据
]

CAMERA_LIST包含8款相机数据,涵盖了当前市场上的主流品牌和型号。传感器类型包括全画幅、APS-C、M4/3三种,像素从2000万到6100万不等。这些数据对于摄影爱好者来说都是耳熟能详的机型,增强了应用的专业感和真实感。

isMain字段只有Sony A7R5标记为true,表示这是用户的主力相机。主力相机会在UI上有特殊的视觉标记,帮助用户快速识别常用设备。

6.5 相册数据结构分析

const ALBUM_LIST: AlbumMeta[] = [
  { name: '城市风光', emoji: '🏙️', count: '86张', date: '2024-08', isLock: false },
  { name: '自然风光', emoji: '🏔️', count: '124张', date: '2024-07', isLock: false },
  { name: '人像作品', emoji: '👤', count: '52张', date: '2024-06', isLock: true },
  // ... 更多数据
]

ALBUM_LIST包含10个相册,涵盖了风光、人像、街头、星空、微距等常见分类。相册数量从28张到215张不等,日期从2024-01到2024-08,模拟了用户长期积累的相册数据。

其中有2个相册标记为加密(isLock: true),分别是"人像作品"和"黑白系列"。加密相册的设置体现了对用户隐私的尊重,也让相册列表的视觉效果更加丰富多样。

七、主入口组件逐段分析

在这里插入图片描述

7.1 状态管理设计

@Entry
@Component
struct PhotoApp {
  @State activeTab: PhotoTab = PhotoTab.HOME

PhotoApp是应用的主入口组件,使用@Entry装饰器标记为页面入口,@Component装饰器标记为组件。@State装饰器是ArkUI框架中最基础也是最常用的状态管理装饰器。

@State装饰的变量activeTab是组件的内部状态,当它的值发生变化时,框架会自动重新渲染依赖这个状态的UI部分。在这个应用中,activeTab控制着当前显示的页面和底部导航栏的选中状态。

初始值设为PhotoTab.HOME,表示应用启动时默认显示首页。这是一个很合理的默认值,首页通常承载着应用的核心内容和入口功能。

使用枚举类型而不是字符串或数字来表示Tab状态,是一个很好的实践。枚举的类型安全性可以防止无效值的传入,同时也让代码更具可读性。

7.2 @Builder自定义构建器的设计

  @Builder contentArea() {
    Column() {
      if (this.activeTab === PhotoTab.HOME) {
        PhotoHomeContent()
      } else if (this.activeTab === PhotoTab.GALLERY) {
        PhotoGalleryContent()
      } else if (this.activeTab === PhotoTab.RANK) {
        PhotoRankContent()
      } else if (this.activeTab === PhotoTab.GEAR) {
        PhotoGearContent()
      } else if (this.activeTab === PhotoTab.MINE) {
        PhotoMineContent()
      }
    }
    .width('100%')
  }

contentArea是一个@Builder装饰的自定义构建函数,用于构建内容区域。@Builder是ArkUI框架提供的一种轻量复用机制,可以将一段UI构建逻辑封装成可复用的函数。

这个构建器的核心逻辑是一个if-else if链式判断,根据activeTab的值来决定渲染哪个页面组件。这种方式实现了Tab页的切换功能——当用户点击底部导航栏时,activeTab的值改变,触发UI重新渲染,contentArea就会显示对应的页面内容。

使用Column作为容器包裹页面组件,设置宽度为100%。虽然每个页面内部也有自己的布局容器,但外层再包一层Column可以确保内容区域的宽度铺满,同时也为将来可能增加的公共头部或公共底部预留了扩展空间。

7.3 tabItem方法的实现原理

  @Builder tabItem(icon: string, label: string, tab: PhotoTab) {
    Column() {
      Text(icon).fontSize(20)
      Text(label).fontSize(10)
        .fontColor(this.activeTab === tab ? COLORS.accent : COLORS.textHint)
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .onClick(() => { this.activeTab = tab })
  }

tabItem构建函数用于构建单个Tab项,接收三个参数:icon(图标emoji)、label(文字标签)、tab(对应的枚举值)。

每个Tab项使用Column垂直布局,上方是图标(20号字体),下方是文字标签(10号字体)。图标使用emoji表示,这是一种简单而有效的图标方案——不需要引入图标库,跨平台一致性好,而且自带颜色和情感表达。

文字标签的颜色和字重会根据选中状态动态变化:选中时使用强调色(橙色)+ 粗体,未选中时使用提示色(浅灰)+ 常规字重。这种视觉差异化是Tab导航的标准交互设计,让用户清晰地知道当前所在的页面。

.layoutWeight(1)是关键的布局属性,它让每个Tab项在Row容器中平均分配宽度。不管有多少个Tab项,它们都会自动平分底部导航栏的宽度,保证布局的均匀和稳定。

.onClick事件处理函数将activeTab设置为当前Tab对应的枚举值。由于activeTab是@State状态变量,修改它会触发UI刷新,从而实现页面切换的效果。整个交互流程就是:点击Tab → 修改状态 → 触发重绘 → 页面切换 + Tab视觉更新。

7.4 contentArea内容区域切换逻辑

内容区域的切换逻辑是整个Tab导航机制的核心。当用户点击底部的某个Tab时,事件处理函数修改activeTab的值。由于activeTab被@State装饰,框架会检测到状态变化,然后重新执行build方法来更新UI。

在重新构建的过程中,contentArea构建器会根据新的activeTab值选择对应的页面组件进行渲染。同时,底部导航栏的tabItem也会根据新的activeTab值更新文字颜色和字重。

这种状态驱动的UI更新模式是声明式UI框架的核心思想。开发者不需要手动操作UI元素,只需要维护好状态,框架会自动根据状态计算出最新的UI并更新。这大大简化了UI编程的复杂度,也减少了因状态和UI不一致导致的bug。

7.5 build方法的整体布局结构

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, PhotoTab.HOME)
        this.tabItem(TAB_CONFIG.GALLERY.icon, TAB_CONFIG.GALLERY.title, PhotoTab.GALLERY)
        this.tabItem(TAB_CONFIG.RANK.icon, TAB_CONFIG.RANK.title, PhotoTab.RANK)
        this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, PhotoTab.GEAR)
        this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, PhotoTab.MINE)
      }
      .width('100%')
      .padding({ top: 8, bottom: 8 })
      .backgroundColor(COLORS.white)
      .shadow({ radius: 10, color: '#455A6420', offsetY: -2 })
    }
    .width('100%').height('100%')
    .backgroundColor(COLORS.bg)
  }

build方法是组件的UI构建函数,它定义了组件的整体布局结构。最外层是一个Column,包含两个子元素:上方的内容区域(contentArea)和下方的底部导航栏(Row包裹的tabItem列表)。

内容区域占据了大部分空间,底部导航栏固定在底部。底部导航栏使用Row水平排列五个Tab项,每个Tab项通过layoutWeight(1)平均分配宽度。

底部导航栏的样式设计也很讲究:白色背景、上下各8dp的内边距、顶部带有淡淡的阴影效果。阴影使用了主色调的透明版(#455A6420,即20%不透明度),offsetY设为-2让阴影向上投射,radius设为10让阴影边缘柔和。这种设计让导航栏与内容区域之间有清晰的视觉分隔,同时又不会显得突兀。

最外层的Column设置了宽高都是100%,背景色为bg色(#FAFAFA)。这确保了应用铺满整个屏幕,背景色在内容不足的区域也能正确显示。

八、各页面组件逐段代码分析

在这里插入图片描述

8.1 首页组件 PhotoHomeContent

8.1.1 状态与弹窗构建器
@Component
struct PhotoHomeContent {
  @State showAddPhotoModal: boolean = false
  @State photoTitle: string = ''
  @State photoCamera: string = ''

首页组件定义了三个@State状态变量。showAddPhotoModal控制新增作品弹窗的显示与隐藏,初始值为false(不显示)。photoTitle和photoCamera分别存储弹窗中输入的作品标题和拍摄器材信息。

将弹窗的输入数据定义为组件的状态变量,是因为弹窗是组件的一部分,输入的数据需要在弹窗关闭后保留(或用于后续的提交操作)。虽然当前版本中点击上传后只是关闭弹窗,没有实际保存数据,但这种设计为后续功能扩展预留了空间。

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

modalOverlay构建函数创建了一个全屏的半透明黑色遮罩层。背景色使用rgba格式,黑色(0,0,0)+ 55%不透明度,这样既能让背景内容变暗以突出弹窗,又不会完全遮挡背景,保持了视觉上的层次感。

onClick事件绑定了传入的onClose回调函数,这意味着点击遮罩层可以关闭弹窗。这是弹窗交互的标准设计——除了点击关闭按钮外,点击弹窗外部区域也能关闭,提供了更便捷的操作方式。

遮罩层作为@Builder函数,并且接收一个回调函数作为参数,这种设计使得遮罩层可以被各种弹窗复用,而关闭逻辑由调用方决定。这是一种典型的"行为参数化"设计模式。

8.1.2 新增作品弹窗
  @Builder addPhotoModal() {
    Stack() {
      this.modalOverlay(() => { this.showAddPhotoModal = false })
      Column() {
        Row() {
          Text('📷 新增作品').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddPhotoModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)

addPhotoModal构建函数创建了新增作品的弹窗内容。最外层使用Stack布局,第一层是半透明遮罩(modalOverlay),第二层是弹窗主体(Column)。Stack的堆叠特性让遮罩层铺满整个屏幕,弹窗居中浮在遮罩之上。

弹窗主体是一个Column,顶部是标题栏。标题栏使用Row布局,左侧是带相机emoji的标题文字,中间用Column().layoutWeight(1)撑开空间,右侧是关闭按钮(✕)。这种"左中右"的标题栏布局是移动端弹窗的标准模式。

标题栏下方是一条分割线(Divider),颜色为边框色,视觉上将标题区和内容区分隔开。

        Column() {
          Text('作品标题').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 城市天际线' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.photoTitle = v })
          Text('拍摄器材').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 Sony A7R5' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.photoCamera = v })

表单内容区域包含多个输入项,每个输入项由标签文字(Text)和输入框(TextInput)组成。标签文字使用次要文字色,字号12号,提示用户这个输入框需要填写什么内容。

输入框的设计很有特色:背景色使用warm色(浅橙黄),而不是常见的白色或灰色。这种暖色调的输入框与应用的整体配色风格保持一致,同时也让输入区域更加柔和友好。圆角8dp,宽度90%,左右各有20dp的边距。

placeholder使用提示色(textHint),给出具体的填写示例(如"如 城市天际线"),而不是笼统的"请输入"。这种示例式的占位符比通用提示更有引导性,用户一看就知道该填什么样的内容。

onChange事件将输入的值同步到对应的@State变量中。这是声明式UI中处理用户输入的标准方式——输入框的值由状态驱动,用户输入改变状态,状态变化又会反映到UI上(虽然TextInput本身可能不显示状态值,但状态数据已经准备好供后续使用)。

          Text('拍摄地点').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 上海外滩' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.warm).borderRadius(8)
              .onClick(() => { this.showAddPhotoModal = false })
            Column().width(12)
            Text('上传').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.accent).borderRadius(8)
              .onClick(() => { this.showAddPhotoModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })

底部是操作按钮区域,使用Row水平排列"取消"和"上传"两个按钮。两个按钮各占一半宽度(layoutWeight(1)),中间用Column().width(12)留出12dp的间距。

"取消"按钮使用暖色调背景+次要文字色,视觉权重较低,作为次要操作。"上传"按钮使用强调色背景+白色文字,视觉权重高,作为主要操作。这种主次按钮的视觉差异化设计是表单交互的标准做法,引导用户的注意力到主要操作上。

.constraintSize({ maxHeight: ‘70%’ })限制了内容区域的最大高度为屏幕的70%。这是一个重要的细节——当表单内容很多时,弹窗不会撑满整个屏幕,而是在内部滚动,保持弹窗的整体比例和美感。

8.1.3 首页头卡——精选大图+曝光参数浮层
  build() {
    Stack() {
      Scroll() {
        Column() {
          // 作品大图+曝光参数浮层头卡
          Stack({ alignContent: Alignment.BottomStart }) {
            Column()
              .width('100%').height(220)
              .linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
              .borderRadius({ bottomLeft: 28, bottomRight: 28 })

首页的build方法最外层是Stack,里面包含可滚动的内容区域和弹窗。内容区域最上方是一个头卡组件,使用Stack布局,对齐方式为底部起始(BottomStart)。

头卡的背景是一个220dp高的渐变色块,使用135度角的线性渐变,从主色(primary)渐变到深主色(primaryDark)。底部左右两个角有28dp的大圆角,形成一个"上平下圆"的形状,视觉上很有特色。

在真实应用中,这里应该是一张精选的大图,但在当前实现中用渐变色块替代。用渐变作为图片占位是一种常见的设计手法,既保持了视觉的美观,又避免了图片加载失败时的空白感。

            Column() {
              Row() {
                Column() {
                  Text('📷 今日精选').fontSize(11).fontColor('#B0BEC5')
                  Text('城市天际线').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
                    .margin({ top: 4 })
                  Text('by 光影捕手').fontSize(11).fontColor('#B0BEC5').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Column() {
                  Text('3,280').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
                  Text('❤️ 点赞').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Center)
              }
              .padding({ left: 20, right: 20, top: 40 })

头卡的内容分为上下两部分。上部分是一个Row,左侧是作品信息,右侧是点赞数。左侧从上到下依次是:分类标签(今日精选)、作品标题(城市天际线)、作者信息(by 光影捕手)。文字颜色使用白色和浅灰色(#B0BEC5),在深色背景上保证了良好的可读性。

右侧是点赞数的突出展示,3,280的数字使用22号粗体强调色(橙色),在深色背景上非常醒目。下方是"❤️ 点赞"的小字说明。这种大数字+小标签的信息展示方式能有效吸引用户注意力,突出作品的受欢迎程度。

              // 曝光参数浮层
              Row() {
                Column() {
                  Text('Sony A7R5').fontSize(10).fontColor(COLORS.white)
                  Text('24-70mm f/8').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Row() {
                  Text('ISO 100').fontSize(9).fontColor(COLORS.white)
                    .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                    .backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
                  Column().width(6)
                  Text('1/250s').fontSize(9).fontColor(COLORS.white)
                    .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                    .backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
                }
              }
              .width('100%')
              .padding({ left: 20, right: 20, top: 30, bottom: 16 })
            }
          }
          .width('100%').height(220)

曝光参数浮层是头卡的下半部分,也是这款应用最具特色的设计之一。左侧显示相机型号和镜头光圈信息,右侧是两个胶囊状的参数标签——ISO和快门速度。

参数标签使用半透明白色背景(rgba(255,255,255,0.15)),白色文字,8dp圆角。这种设计在深色背景上既保持了良好的可读性,又有一种通透感和科技感。15%的不透明度让背景渐变隐约可见,视觉层次非常丰富。

将曝光参数(相机、镜头、光圈、ISO、快门速度)以如此醒目的方式展示在首页头卡中,充分体现了这款应用的专业性定位——它不是普通的图片分享应用,而是面向摄影爱好者的专业社区,EXIF信息是作品不可或缺的组成部分。

8.1.4 周拍摄柱状图
          // 周拍摄柱状图
          Column() {
            Text('📊 本周拍摄(张)').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
            Row() {
              ForEach(WEEK_SHOT, (w: WeekShotMeta) => {
                Column() {
                  Stack({ alignContent: Alignment.Bottom }) {
                    Column().width(22).height(90)
                      .backgroundColor(COLORS.border)
                      .borderRadius(4)
                    Column().width(22).height(Number(w.value) * 6)
                      .linearGradient({ angle: 0, colors: [[COLORS.accentLight, 0.0], [COLORS.accent, 1.0]] })
                      .borderRadius(4)
                  }
                  .width(22).height(90)
                  Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
                    .margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Center)
                .layoutWeight(1)
              })
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 12, bottom: 8 })
          }
          .width('100%')
          .backgroundColor(COLORS.cardBg)
          .borderRadius(16)
          .margin({ left: 12, right: 12, top: 12 })

周拍摄柱状图是一个纯代码实现的数据可视化组件,没有依赖任何第三方图表库。整个图表放在一个卡片容器中,白色背景、16dp圆角、左右各12dp外边距。

图表的标题是"📊 本周拍摄(张)",左对齐,14号粗体字。下方是柱状图主体,使用Row水平排列七天的数据。每一列数据用Column垂直布局,上方是柱子,下方是日期标签。

柱子的实现非常巧妙:使用Stack堆叠两个Column,底层是高度90dp的灰色背景柱(border色),上层是高度随数据变化的渐变色柱(从accentLight到accent的垂直渐变)。Stack的对齐方式设为Bottom,让数据柱从底部向上生长,这正是柱状图的标准呈现方式。

柱子的高度计算使用Number(w.value) * 6,即将拍摄张数乘以6倍作为柱子高度(单位dp)。这种简单的比例缩放虽然不是精确的百分比计算,但对于展示相对趋势来说已经足够。从数据来看,最大值是周六的14张,乘以6等于84dp,接近90dp的总高度,比例控制得很合理。

ForEach是ArkUI框架提供的列表渲染组件,用于遍历数组并为每个元素生成对应的UI。这里遍历WEEK_SHOT数组,为每一天的数据生成一根柱子和一个标签。.layoutWeight(1)让每一列平均分配Row的宽度,保证7根柱子均匀分布。

8.1.5 热门标签
          // 热门标签
          Column() {
            Text('🔥 热门标签').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(TAG_LIST, (t: TagMeta) => {
                Row() {
                  Text(t.name).fontSize(12).fontColor(getHotColor(t.isHot))
                  Text(' ' + t.count).fontSize(9).fontColor(COLORS.textHint)
                }
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(COLORS.warm).borderRadius(20)
                .margin({ right: 8, bottom: 8 })
              })
            }
            .width('100%').padding({ left: 16, right: 16 })
          }

热门标签区域使用Flex布局,wrap属性设为FlexWrap.Wrap,实现了标签的自动换行效果。当一行放不下所有标签时,多余的标签会自动换到下一行,这是标签云/热门标签的标准布局方式。

每个标签是一个Row,包含标签名称和作品数量。标签名称使用12号字体,颜色根据是否热门动态变化(热门用橙色,非热门用灰蓝色)。作品数量使用9号小字体,提示色,作为补充信息。

标签的背景色使用warm色(浅橙黄),圆角20dp(接近胶囊形状),左右各12dp、上下各6dp的内边距。这种圆润的标签样式在视觉上非常友好,点击热区也足够大。标签之间右边距8dp、下边距8dp,保持适当的间距。

标签右下方的间距(margin)而不是整个Flex容器的padding,是因为FlexWrap换行后,每一行的第一个标签左边不需要间距,最后一个右边也不需要间距,而每行之间需要垂直间距。用标签自身的margin来控制间距可以自动处理这些边界情况。

8.1.6 摄影大赛列表
          // 比赛活动
          Column() {
            Text('🏆 摄影大赛').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            ForEach(CONTEST_LIST, (c: ContestMeta) => {
              Row() {
                Text(c.emoji).fontSize(28).margin({ right: 12 })
                Column() {
                  Text(c.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                  Text('截止 ' + c.deadline + ' · 奖金 ' + c.prize).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text(c.isJoin ? '已参与' : '参与').fontSize(11)
                  .fontColor(c.isJoin ? COLORS.textHint : COLORS.white)
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .backgroundColor(c.isJoin ? COLORS.border : COLORS.accent).borderRadius(12)
              }
              .width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
            })
          }

摄影大赛列表也是一个卡片式设计,白色背景、圆角、外边距。列表中每一行是一个比赛项,使用水平布局:左侧是emoji图标(28号大字体),中间是比赛信息,右侧是参与按钮。

中间信息区包含两行:第一行是比赛标题(13号中等字重),第二行是截止日期和奖金信息(10号次要文字色)。第二行用"·"分隔两个信息点,简洁而信息密度高。

右侧的"参与/已参与"按钮根据isJoin状态呈现不同的视觉样式:未参与时是橙色填充按钮(强调色背景+白色文字),已参与时是灰色边框样式(边框色背景+提示色文字)。这种状态切换让用户一眼就能看出哪些比赛已经参加了,哪些还可以参加。

按钮的圆角是12dp,比标签的圆角略小,内边距左右各12dp、上下各6dp。这种尺寸设计让按钮既醒目又不显得笨重。

8.1.7 精选作品列表
          // 精选作品列表
          Column() {
            Text('✨ 精选作品').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            ForEach(PHOTO_LIST, (p: PhotoMeta) => {
              Row() {
                Stack() {
                  Column().width(52).height(52).borderRadius(12)
                    .linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
                  Text(p.avatar).fontSize(28)
                }
                .width(52).height(52).margin({ right: 12 })

精选作品列表是首页最长的一个列表,展示了12条精选作品。每条作品项的左侧是一个52x52dp的头像区域。头像的实现方式是:一个渐变色块作为背景(从primaryLight到primaryDark的135度渐变),上面居中显示emoji头像。

使用渐变色块作为头像背景,而不是纯色背景,增加了视觉的丰富度和品质感。12dp的圆角让头像看起来圆润友好,28号的emoji大小适中。

                Column() {
                  Text(p.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                  Text(p.author + ' · ' + p.camera).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
                  Text(p.lens + ' ' + p.aperture + ' ' + p.iso).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)

中间信息区有三行文字,形成清晰的信息层级。第一行是作品标题,最重要,字号最大(13号),主要文字色,中等字重。第二行是作者和相机信息,字号10号,次要文字色。第三行是镜头、光圈、ISO等专业参数,字号最小(9号),提示色文字。

这种三级信息结构是列表设计的经典模式——最重要的信息用最大最醒目的样式,次要信息逐级减弱。用户在快速浏览时能迅速抓住重点,需要详细信息时也能找到。

                Column() {
                  Text('❤️ ' + String(p.likes)).fontSize(10).fontColor(getHotColor(p.isHot))
                  if (p.isHot) {
                    Text('🔥 热门').fontSize(8).fontColor(COLORS.white)
                      .padding({ left: 4, right: 4, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accent).borderRadius(4)
                      .margin({ top: 4 })
                  }
                }
                .alignItems(HorizontalAlign.End)

右侧是点赞数和热门标签区域。点赞数的颜色根据是否热门动态变化——热门作品用橙色,非热门用灰蓝色。这样热门作品在视觉上更加突出,引导用户关注高质量内容。

如果作品是热门的(isHot为true),还会显示一个"🔥 热门"的小标签。这个标签使用橙色背景+白色文字,4dp圆角,非常小巧精致。8号字体虽然小,但配合橙色背景仍然很醒目。

条件渲染(if语句)是声明式UI的常用模式,根据状态决定是否渲染某个UI元素。这里用来控制热门标签的显示与隐藏。

每条作品项之间用底部分割线分隔(border底部1dp边框),这是列表项的标准分隔方式。

8.1.8 上传按钮与弹窗控制
          // 新增按钮
          Row() {
            Text('+ 上传新作品').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 12, bottom: 12 })
              .backgroundColor(COLORS.accent).borderRadius(12)
              .onClick(() => { this.showAddPhotoModal = true })
          }
          .width('92%')
          .margin({ bottom: 20 })

首页底部是一个"上传新作品"的大按钮,橙色填充,白色文字,12dp圆角,上下各12dp内边距。按钮宽度为92%,底部外边距20dp。

这个按钮是首页的主要行动号召(Call to Action),引导用户上传自己的作品。醒目的橙色和较大的尺寸确保用户不会错过这个入口。

点击按钮会将showAddPhotoModal设为true,从而显示新增作品弹窗。

      if (this.showAddPhotoModal) {
        this.addPhotoModal()
      }
    }
    .width('100%').height('100%')
  }
}

弹窗的显示控制通过条件渲染实现——当showAddPhotoModal为true时,渲染addPhotoModal的内容。由于最外层是Stack布局,弹窗会浮在内容区域之上。

这种弹窗实现方式虽然简单,但非常有效。核心原理就是利用Stack的堆叠特性+条件渲染,控制弹窗的显示与隐藏。遮罩层提供背景变暗的效果,同时点击遮罩可以关闭弹窗,形成完整的交互闭环。

8.2 作品页组件 PhotoGalleryContent

@Component
struct PhotoGalleryContent {
  build() {
    Scroll() {
      Column() {
        // 瀑布流双列
        Text('🖼️ 全部作品').fontSize(16).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(PHOTO_LIST, (p: PhotoMeta) => {
            Column() {
              Stack() {
                Column().width('100%').height(120)
                  .linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
                  .borderRadius({ topLeft: 12, topRight: 12 })
                Text(p.avatar).fontSize(40)
              }
              .width('100%').height(120)

作品页采用瀑布流双列布局展示全部作品。页面标题是"🖼️ 全部作品",16号粗体字。

作品网格使用Flex布局,wrap属性为Wrap(自动换行),justifyContent为SpaceBetween(两端对齐,项目之间间隔相等)。这种布局方式会自动计算每行能放几个卡片,然后均匀分布间距,实现整齐的网格效果。

每个作品卡片是一个Column,宽度为48%(两列布局,每列略小于50%,留出中间间距)。卡片上方是图片区域,下方是文字信息区域。

图片区域高度120dp,使用渐变背景+emoji图标的方式模拟作品图片。顶部左右两个角有12dp圆角,与卡片整体圆角一致。40号的emoji在120dp高的区域中居中显示,大小适中。

              Column() {
                Text(p.title).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                Text(p.author).fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
                Row() {
                  Text('❤️ ' + String(p.likes)).fontSize(9).fontColor(COLORS.accent)
                  Column().layoutWeight(1)
                  if (p.isHot) {
                    Text('🔥').fontSize(10)
                  }
                }
                .width('100%').margin({ top: 4 })
              }
              .width('100%').padding({ left: 8, right: 8, top: 6, bottom: 8 })
            }
            .width('48%')
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
            .margin({ bottom: 10 })

卡片下方的文字信息区域有三行:作品标题、作者、点赞数+热门标记。信息层级与首页列表类似,但字号更小一些,因为卡片空间有限。

底部的Row布局中,左边是点赞数(橙色),右边是热门火焰图标(如果有的话)。中间用Column().layoutWeight(1)撑开,让点赞数和热门图标分别靠左右两侧。

卡片整体有白色背景、12dp圆角、底部10dp外边距。卡片之间的间距由Flex的SpaceBetween对齐方式(水平方向)和卡片的margin-bottom(垂直方向)共同控制。

这种双列瀑布流布局非常适合图片类内容的展示,在有限的屏幕空间内展示更多的作品缩略图,提高浏览效率。

8.3 排行页组件 PhotoRankContent

8.3.1 前三名展示区
@Component
struct PhotoRankContent {
  build() {
    Scroll() {
      Column() {
        Text('🏆 摄影师排行').fontSize(16).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })

        // 前三名
        Row() {
          ForEach(PHOTOGRAPHER_LIST.slice(0, 3), (pg: PhotographerMeta, idx: number) => {
            Column() {
              Stack() {
                Column().width(56).height(56).borderRadius(28)
                  .backgroundColor(idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32')
                Text(pg.avatar).fontSize(32)
              }
              .width(56).height(56).margin({ bottom: 6 })

排行页的顶部是一个特殊设计的前三名展示区。使用PHOTOGRAPHER_LIST.slice(0, 3)获取前三位摄影师的数据。

三位摄影师水平排列在一个Row中,每位摄影师是一个Column,包含头像、名字、粉丝数、奖牌emoji。

头像的背景色根据排名不同而变化:第一名金色(#FFD700)、第二名银色(#C0C0C0)、第三名铜色(#CD7F32)。这种金/银/铜的配色方案是排行榜的经典设计,用户一眼就能识别出前三名的名次。

头像大小为56x56dp,圆形(borderRadius: 28),比普通列表的头像更大,突出前三名的特殊地位。

              Text(pg.name).fontSize(11).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
              Text(pg.followers + '粉丝').fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
              Text(idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉').fontSize(14).margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
            .margin({ top: idx === 0 ? 0 : 20 })
          })
        }
        .width('100%')
        .padding({ top: 16, bottom: 16 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .margin({ left: 12, right: 12 })

名字使用粗体字,粉丝数使用次要文字色。底部显示奖牌emoji:🥇金牌、🥈银牌、🥉铜牌,进一步强化名次的视觉识别。

一个巧妙的设计是margin的top值:第一名的top是0,第二、三名的top是20。这意味着第一名的头像位置比第二、三名高20dp,形成一个"冠军居中更高"的领奖台效果。虽然只是一个小小的margin差异,但视觉效果却非常生动,让排行榜更有仪式感。

8.3.2 排行榜列表
        // 排行榜
        Column() {
          ForEach(PHOTOGRAPHER_LIST, (pg: PhotographerMeta, idx: number) => {
            Row() {
              Text(String(idx + 1)).fontSize(18).fontWeight(FontWeight.Bold)
                .fontColor(idx < 3 ? COLORS.accent : COLORS.textHint)
                .width(32).textAlign(TextAlign.Center)
              Text(pg.avatar).fontSize(28).margin({ right: 10 })

排行榜列表展示全部8位摄影师,每位一行。每行的左侧是排名数字,18号粗体字,宽度32dp,居中对齐。排名数字的颜色根据名次变化:前三名用强调色(橙色),其余用提示色(浅灰)。这种设计让前三名在列表中仍然保持视觉上的突出地位。

排名数字右边是头像emoji,28号字体。再往右是摄影师的详细信息。

              Column() {
                Text(pg.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                Text(pg.specialty + ' · ' + pg.works + '作品').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1)

中间信息区有两行:摄影师姓名和专长+作品数。专长字段用"·"与作品数分隔,信息密度高但不杂乱。

              Column() {
                Text(pg.followers).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
                Text('粉丝').fontSize(8).fontColor(COLORS.textHint).margin({ top: 1 })
              }
              .alignItems(HorizontalAlign.End).margin({ right: 10 })

粉丝数区域也是两行结构:上面是粉丝数(粗体),下面是"粉丝"标签(小字提示色)。这种大数字+小标签的设计让粉丝数这个关键指标更加突出。

              Text(pg.isFollow ? '已关注' : '关注').fontSize(11)
                .fontColor(pg.isFollow ? COLORS.textHint : COLORS.white)
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(getFollowColor(pg.isFollow)).borderRadius(12)
            }
            .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
            .border({ width: { bottom: 1 }, color: COLORS.border })
          })
        }

最右侧是关注按钮。按钮的文字和背景色根据关注状态动态变化:已关注时是灰色背景+灰色文字(视觉上较弱),未关注时是橙色背景+白色文字(视觉上较强)。

这种设计的交互意图很明显:对于未关注的人,用醒目的橙色按钮引导用户点击关注;对于已关注的人,用弱化的样式表示"已完成"状态,减少视觉干扰。

每行之间用底部分割线分隔,保持列表的清晰结构。

8.4 器材页组件 PhotoGearContent

8.4.1 编辑相机弹窗
@Component
struct PhotoGearContent {
  @State showEditCameraModal: boolean = false
  @State editModel: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

器材页的弹窗是编辑相机信息弹窗。与首页的新增作品弹窗类似,也有一个modalOverlay遮罩层构建器。实际上,三个页面(首页、器材页、我的页)都有各自的modalOverlay构建器,代码几乎完全一样。这是一个可以进一步优化的点——可以将modalOverlay抽离成一个公共组件,减少重复代码。

  @Builder editCameraModal() {
    Stack() {
      this.modalOverlay(() => { this.showEditCameraModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑相机信息').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditCameraModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)

编辑相机弹窗的标题是"✏️ 编辑相机信息",与新增作品弹窗的结构基本一致:标题栏+分割线+表单内容+操作按钮。

        Column() {
          Text('型号').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 A7R5' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.editModel = v })
          Text('传感器').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 全画幅' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
          Text('像素').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 6100万' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })

表单包含三个输入项:型号、传感器、像素。每个输入项的样式与新增作品弹窗保持一致——暖色调背景、圆角、占位符示例提示。

只有型号输入框绑定了onChange事件(同步到editModel状态),传感器和像素输入框没有绑定。这可能是因为当前版本只是演示用途,没有完整实现编辑功能。在真实产品中,所有输入项都应该绑定对应的状态变量。

          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.warm).borderRadius(8)
              .onClick(() => { this.showEditCameraModal = false })
            Column().width(12)
            Text('保存').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.primary).borderRadius(8)
              .onClick(() => { this.showEditCameraModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })

底部的操作按钮也是"取消"+"保存"的组合。但注意这里的主按钮(保存)使用的是primary色(深灰蓝),而不是accent色(橙色)。这与新增作品弹窗的主按钮颜色不同——新增用橙色,编辑用主色。

这种颜色区分可能是有意为之的设计:新增操作是"创建",用充满活力的橙色;编辑操作是"修改",用稳重的主色调。虽然只是颜色的细微差别,但体现了对不同操作类型语义的思考。

8.4.2 器材列表与统计
  build() {
    Stack() {
      Scroll() {
        Column() {
          Text('🎥 我的器材库').fontSize(16).fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
          ForEach(CAMERA_LIST, (c: CameraMeta) => {
            Row() {
              Stack() {
                Column().width(48).height(48).borderRadius(12)
                  .backgroundColor(c.isMain ? COLORS.warm : COLORS.border)
                Text(c.emoji).fontSize(28)
              }
              .width(48).height(48).margin({ right: 12 })

器材列表的页面标题是"🎥 我的器材库"。列表项左侧是48x48dp的图标区域,背景色根据是否为主力相机而不同:主力相机用暖色调(warm)背景,非主力用边框色(border)背景。这种视觉区分让用户快速识别出常用的主力相机。

              Column() {
                Text(c.brand + ' ' + c.model).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                Text(c.sensor + ' · ' + c.pixels).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1)
              if (c.isMain) {
                Text('主力').fontSize(9).fontColor(COLORS.white)
                  .padding({ left: 6, right: 6, top: 3, bottom: 3 })
                  .backgroundColor(COLORS.accent).borderRadius(4)
                  .margin({ right: 8 })
              }
              Text('✏️').fontSize(16).onClick(() => { this.showEditCameraModal = true })
            }
            .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
            .border({ width: { bottom: 1 }, color: COLORS.border })

中间信息区显示品牌+型号(第一行)和传感器+像素(第二行)。品牌和型号用空格拼接,传感器和像素用"·"分隔。

如果是主力相机,会显示一个橙色的"主力"小标签,9号白色文字,4dp圆角,非常小巧醒目。

最右侧是编辑按钮(铅笔emoji),点击后弹出编辑相机信息弹窗。每一行都有独立的编辑入口,用户可以直接编辑对应的相机信息。

          // 器材统计
          Column() {
            Text('📈 器材统计').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 12 })
            Row() {
              Column() {
                Text('8').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
                Text('相机').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center).layoutWeight(1)
              Column() {
                Text('12').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
                Text('镜头').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center).layoutWeight(1)
              Column() {
                Text('5').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.success)
                Text('配件').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center).layoutWeight(1)
            }
            .width('100%').padding({ top: 8, bottom: 16 })
          }

器材统计区域用三列展示相机、镜头、配件的数量统计。每个统计项都是大数字+小标签的结构,28号粗体数字非常醒目。

三个数字使用不同的颜色:相机用橙色(accent)、镜头用主色(primary)、配件用绿色(success)。这种颜色区分增加了视觉的丰富度,也让三类器材更容易区分。

不过需要注意的是,这些统计数字是硬编码的(8、12、5),而不是根据CAMERA_LIST等数据动态计算的。在真实产品中,这些数字应该从数据中动态计算得出,确保数据一致性。

8.5 我的页组件 PhotoMineContent

8.5.1 删除相册弹窗
@Component
struct PhotoMineContent {
  @State showDeleteAlbumModal: boolean = false
  @State deleteName: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

我的页面的弹窗是删除相册的确认弹窗。与前两个弹窗不同,这是一个危险操作的确认弹窗,设计上需要特别注意防止误操作。

  @Builder deleteAlbumModal() {
    Stack() {
      this.modalOverlay(() => { this.showDeleteAlbumModal = false })
      Column() {
        Row() {
          Text('🗑️ 删除相册').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showDeleteAlbumModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('确定要删除该相册吗?').fontSize(14).fontColor(COLORS.textPrimary)
            .margin({ top: 20, left: 20 })
          Text('删除后无法恢复,相册中的照片将移至回收站。').fontSize(11).fontColor(COLORS.textSecondary)
            .margin({ top: 8, left: 20, right: 20 })

删除确认弹窗的内容区域包含两段文字。第一段是确认问题:“确定要删除该相册吗?”,使用主要文字色,字号14号,让用户清晰地看到确认信息。

第二段是警告说明:“删除后无法恢复,相册中的照片将移至回收站。”,使用次要文字色,字号11号。这段文字告诉用户删除操作的后果——数据会移至回收站而不是立即永久删除,减轻用户的焦虑感,同时也明确了操作的不可逆性(在回收站清空之前还是可以恢复的)。

这种"确认问题+后果说明"的两段式文案是危险操作确认弹窗的标准做法,既保证了用户知情,又提供了足够的警示。

          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.warm).borderRadius(8)
              .onClick(() => { this.showDeleteAlbumModal = false })
            Column().width(12)
            Text('删除').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.danger).borderRadius(8)
              .onClick(() => { this.showDeleteAlbumModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })

底部按钮的设计也体现了危险操作的特点:"删除"按钮使用danger色(红色),而不是之前的橙色或主色。红色是危险/警告的标准语义色,用户看到红色按钮会本能地更加谨慎,有效防止误删。

"取消"按钮仍然是暖色调背景+次要文字色,作为安全的次要操作。将安全的"取消"放在左边、危险的"删除"放在右边,也是符合用户操作习惯的布局——右手持机时,右边更容易误触,但红色的警告色会起到提醒作用。

8.5.2 个人资料头卡
  build() {
    Stack() {
      Scroll() {
        Column() {
          // 个人资料头卡
          Row() {
            Stack() {
              Column().width(60).height(60).borderRadius(30)
                .linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
              Text('🧑‍🎨').fontSize(36)
            }
            .width(60).height(60).margin({ right: 14 })

个人中心页面的顶部是个人资料头卡,与首页头卡类似,使用渐变背景+底部大圆角的设计。头卡内容使用Row水平布局,左侧是头像,右侧是个人信息。

头像大小为60x60dp,圆形(30dp圆角),渐变背景+emoji的设计风格与其他页面保持一致。36号的emoji在60dp的圆中居中显示,大小适中。

            Column() {
              Text('光影捕手').fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
              Text('Lv.8 资深摄影师').fontSize(10).fontColor('#B0BEC5').margin({ top: 2 })
              Row() {
                Text('386 作品').fontSize(10).fontColor('#B0BEC5')
                Text(' · ').fontSize(10).fontColor('#B0BEC5')
                Text('12.8万 粉丝').fontSize(10).fontColor('#B0BEC5')
              }
              .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
          }
          .width('100%')
          .padding({ left: 20, right: 20, top: 20, bottom: 20 })
          .linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
          .borderRadius({ bottomLeft: 24, bottomRight: 24 })

个人信息区域有三行:用户名(16号白色粗体)、等级称号(10号浅灰色)、作品数+粉丝数(10号浅灰色,用"·"分隔)。

等级系统(Lv.8 资深摄影师)是社区类应用的常见功能,通过等级标识用户的活跃度和贡献度,激励用户持续创作和参与社区互动。

头卡的整体样式与首页头卡保持一致——135度渐变背景、底部大圆角(24dp)、深色背景上的白色/浅色文字。这种设计一致性让整个应用的视觉风格更加统一。

8.5.3 相册列表
          // 我的相册
          Column() {
            Text('📁 我的相册').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            ForEach(ALBUM_LIST, (a: AlbumMeta) => {
              Row() {
                Stack() {
                  Column().width(44).height(44).borderRadius(10)
                    .backgroundColor(getLockBg(a.isLock))
                  Text(a.emoji).fontSize(26)
                }
                .width(44).height(44).margin({ right: 12 })

我的相册列表展示用户创建的所有相册。每个相册项左侧是44x44dp的图标区域,背景色根据是否加密动态变化:加密相册用暖色调(warm)背景,未加密用边框色(border)背景。

这种颜色区分让加密相册在列表中更容易被识别,也暗示了它们的特殊性(需要密码或指纹才能访问)。

                Column() {
                  Text(a.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                  Text(a.count + ' · ' + a.date).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                if (a.isLock) {
                  Text('🔒').fontSize(14).margin({ right: 8 })
                }
                Text('🗑️').fontSize(16).onClick(() => { this.showDeleteAlbumModal = true })
              }
              .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
              .border({ width: { bottom: 1 }, color: COLORS.border })
            })
          }

中间信息区显示相册名称和照片数量+创建日期。如果相册是加密的,还会显示一个锁图标(🔒),进一步强化加密的视觉标识。

最右侧是删除按钮(垃圾桶emoji),点击后弹出删除确认弹窗。每个相册都有独立的删除入口,方便用户管理相册。

删除按钮直接放在每行的最右侧,点击即可触发删除操作。不过从交互安全的角度来看,这种设计可能存在误触的风险——用户可能在滑动列表时不小心点击到删除按钮。更好的做法可能是将删除操作放在左滑菜单或详情页中,减少误触概率。

九、弹窗组件详解

9.1 modalOverlay的实现原理

三个页面的弹窗都使用了相同的modalOverlay模式。虽然每个页面都有自己的modalOverlay构建器(这是代码重复,可以优化),但实现原理是一致的。

modalOverlay的核心是一个全屏的半透明黑色遮罩层,它有两个作用:

  1. 视觉上降低背景内容的亮度,让弹窗更加突出
  2. 交互上捕获点击事件,点击遮罩层可以关闭弹窗

遮罩层使用Column组件,设置宽高为100%铺满全屏,背景色为rgba(0,0,0,0.55)——55%不透明度的黑色。这个透明度经过精心设计:足够暗以突出弹窗,但又不会完全遮挡背景内容,保持了界面的上下文感。

从实现架构来看,弹窗使用Stack布局,包含两层:

  • 底层:modalOverlay遮罩层(全屏)
  • 上层:弹窗内容容器(居中显示)

Stack的堆叠特性自然地实现了弹窗浮在内容之上的效果。遮罩层的onClick事件绑定关闭逻辑,弹窗内容本身不绑定关闭事件,这样点击弹窗内部不会关闭,只有点击外部遮罩才会关闭——这是弹窗交互的标准行为。

9.2 三种弹窗的设计模式对比

这款应用包含三种类型的弹窗:新增作品、编辑相机、删除相册。它们虽然内容不同,但遵循相同的设计模式:

弹窗类型 标题图标 主按钮颜色 主按钮文字 表单字段数
新增作品 📷 橙色(accent) 上传 3
编辑相机 ✏️ 深灰蓝(primary) 保存 3
删除相册 🗑️ 红色(danger) 删除 0(确认文案)

三种弹窗对应三种操作类型:创建、修改、删除。主按钮的颜色分别使用了强调色(创建)、主色(修改)、危险色(删除)。这种用颜色区分操作类型的设计非常专业,用户通过按钮颜色就能直觉地感知操作的性质和风险等级。

新增类操作使用充满活力的橙色,鼓励用户创建新内容;编辑类操作使用稳重的主色调,表示这是一个常规的修改操作;删除类操作使用警示性的红色,提醒用户这是危险操作,需要谨慎确认。

从表单复杂度来看,新增和编辑弹窗都包含表单输入,删除弹窗只有确认文字。这也符合各自的功能定位——创建和修改需要用户输入信息,删除只需要确认意图。

9.3 弹窗交互流程图

弹窗组件 状态变量 页面UI 用户 弹窗组件 状态变量 页面UI 用户 alt [点击遮罩层/关闭按钮/取消按钮] [点击主操作按钮(上传/保存/删除)] 点击触发按钮(上传/编辑/删除) showXxxModal = true 状态更新,触发重绘 渲染弹窗(条件渲染) 弹窗显示 点击关闭区域 showXxxModal = false 状态更新,触发重绘 移除弹窗(条件不满足) 弹窗消失 点击主按钮 处理业务逻辑 + showXxxModal = false 状态更新,触发重绘 移除弹窗 弹窗消失 + 操作生效

十、核心UI组件分析

10.1 头卡组件

头卡组件是这款应用中最具视觉特色的组件之一,在首页和我的页都有使用。它的设计特点包括:

渐变背景:使用135度角的线性渐变,从主色渐变到深主色,营造出深邃而专业的视觉效果。渐变比纯色背景更有层次感和品质感。

底部大圆角:底部左右两个角使用28dp(首页)或24dp(我的页)的大圆角,顶部是直角。这种"上平下圆"的形状像一个倒置的圆角矩形,视觉上很有辨识度。

文字层级:深色背景上使用白色主文字+浅灰辅助文字的配色方案,确保良好的可读性。文字大小和字重的差异建立了清晰的信息层级。

功能差异:首页头卡主要展示精选作品信息,包含作品标题、作者、点赞数、曝光参数等;我的页头卡主要展示个人信息,包含用户名、等级、作品数、粉丝数等。相同的视觉框架承载不同的内容,体现了组件的复用性。

首页头卡的曝光参数浮层是一大亮点——将ISO、快门速度等专业参数以半透明胶囊标签的形式展示,既专业又美观,是摄影类应用的差异化设计。

10.2 柱状图组件

周拍摄柱状图是一个纯代码实现的轻量级图表组件,没有依赖任何第三方库。它的实现原理是:

底层背景柱:一个固定高度(90dp)的灰色Column,作为柱状图的背景槽,代表100%的高度。

上层数据柱:一个高度由数据决定的渐变Column,从底部向上生长。高度 = 数据值 × 比例系数(6)。

Stack堆叠 + Bottom对齐:使用Stack将背景柱和数据柱堆叠在一起,对齐方式设为Bottom,让数据柱从底部开始向上延伸,形成柱状图的视觉效果。

渐变填充:数据柱使用垂直渐变(angle: 0),从浅色(accentLight)渐变到深色(accent),增加了柱子的立体感和视觉品质。

这种纯基础组件实现的图表虽然功能简单(不支持交互、动画、多数据系列等),但对于展示简单的趋势数据来说已经足够。它的优势在于:零依赖、体积小、完全可控、样式可深度定制。

10.3 卡片组件

卡片是这款应用中最常用的UI组件之一,几乎每个模块的内容都放在卡片中。卡片的统一设计风格是:

  • 白色背景(cardBg)
  • 16dp圆角(大卡片)或12dp圆角(小卡片)
  • 左右各12dp外边距
  • 顶部12dp外边距

卡片设计的好处是显而易见的:

  1. 视觉分层:卡片将内容组织成独立的区块,页面结构清晰,用户容易理解
  2. 信息聚焦:每张卡片承载一个主题的内容,用户的注意力自然地被卡片框住
  3. 触摸友好:卡片之间有间距,手指点击时不容易误触相邻卡片
  4. 美观统一:统一的卡片样式让整个应用看起来整洁、专业

从布局角度看,卡片式设计配合Scroll滚动视图,形成了"一屏多卡,滚动浏览"的页面结构。用户通过垂直滚动浏览一张张卡片,每张卡片内的内容相对独立和完整。

10.4 列表项组件

列表项是另一种大量使用的UI模式,在作品列表、摄影师排行、器材列表、相册列表中都有应用。

标准的列表项结构是:左侧图标 + 中间文字信息 + 右侧操作/辅助信息。这种"左中右"的三栏布局是移动端列表的经典设计模式。

各部分的详细设计:

  • 左侧图标:通常是渐变色块+emoji的组合,大小从40dp到52dp不等,圆角8-12dp
  • 中间信息:通常是2-3行文字,字号逐级减小,颜色逐级变浅,形成清晰的信息层级
  • 右侧区域:可能是数字、标签、按钮、状态指示等,根据业务需求灵活变化

列表项之间通常用底部分割线分隔(border底部1dp),或者用卡片背景+间距的方式分隔。分割线的颜色是border色(非常浅的灰),既起到了分隔作用,又不会太突兀。

十一、技术亮点总结表格

功能模块 技术实现方式 设计模式 代码量估计 复用性
颜色系统 interface + const常量 配置驱动 约50行 高,可直接复用于其他应用
数据模型 TypeScript interface 类型安全 约60行 高,前后端联调的契约
Tab导航 @State + @Builder + enum 状态驱动 约60行 高,标准Tab模式
弹窗系统 Stack + 条件渲染 + modalOverlay 组合模式 约150行/页 中,需抽离公共遮罩层
柱状图组件 Stack堆叠 + 渐变Column 纯代码绘制 约30行 中,需适配数据格式
头卡组件 渐变背景 + 底部大圆角 视觉容器 约60行 中,内容需自定义
列表组件 ForEach + Row三栏布局 列表渲染 约40行/个 高,标准列表模式
卡片组件 白色背景 + 圆角 + 外边距 容器模式 约10行/个 高,通用容器
标签组件 圆角背景 + 文字 轻量组件 约5行/个 高,通用标签
状态管理 @State装饰器 局部状态 每个组件2-5个 中,仅组件内使用
Mock数据 顶层const数组 数据驱动 约150行 中,业务相关
工具函数 顶层function 逻辑复用 约15行 高,通用颜色计算

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 534.ets 摄影社区 APP — PhotoApp
// 主色调: 镜头灰 #455A64 + 胶片橙 #FF6F00
// 布局: 作品大图+曝光参数浮层头卡 + 周拍摄柱状图
//       + 热门作品瀑布流 + 摄影师排行 + 相机库 + 我的
// 弹窗: 新增作品 / 编辑相机 / 删除相册 (全部 modalOverlay)
// ============================================================

interface PhotoColorPalette {
  primary: string
  primaryLight: string
  primaryDark: string
  accent: string
  accentLight: string
  warm: string
  bg: string
  cardBg: string
  textPrimary: string
  textSecondary: string
  textHint: string
  border: string
  success: string
  warning: string
  danger: string
  white: string
}

interface TabMeta {
  title: string
  icon: string
}

interface PhotoMeta {
  title: string
  author: string
  avatar: string
  likes: number
  camera: string
  lens: string
  aperture: string
  iso: string
  isHot: boolean
}

interface WeekShotMeta {
  label: string
  value: number
}

interface PhotographerMeta {
  name: string
  avatar: string
  followers: string
  works: string
  specialty: string
  isFollow: boolean
}

interface CameraMeta {
  brand: string
  model: string
  emoji: string
  sensor: string
  pixels: string
  isMain: boolean
}

interface AlbumMeta {
  name: string
  emoji: string
  count: string
  date: string
  isLock: boolean
}

interface TagMeta {
  name: string
  count: string
  isHot: boolean
}

interface ContestMeta {
  title: string
  emoji: string
  deadline: string
  prize: string
  isJoin: boolean
}

const COLORS: PhotoColorPalette = {
  primary: '#455A64',
  primaryLight: '#78909C',
  primaryDark: '#263238',
  accent: '#FF6F00',
  accentLight: '#FFB74D',
  warm: '#FFF3E0',
  bg: '#FAFAFA',
  cardBg: '#FFFFFF',
  textPrimary: '#263238',
  textSecondary: '#607D8B',
  textHint: '#B0BEC5',
  border: '#ECEFF1',
  success: '#43A047',
  warning: '#FB8C00',
  danger: '#E53935',
  white: '#FFFFFF'
}

const TAB_CONFIG: Record<string, TabMeta> = {
  'HOME': { title: '精选', icon: '📷' },
  'GALLERY': { title: '作品', icon: '🖼️' },
  'RANK': { title: '排行', icon: '🏆' },
  'GEAR': { title: '器材', icon: '🎥' },
  'MINE': { title: '我的', icon: '👤' }
}

const PHOTO_LIST: PhotoMeta[] = [
  { title: '城市天际线', author: '光影捕手', avatar: '🧑‍🎨', likes: 3280, camera: 'Sony A7R5', lens: '24-70mm', aperture: 'f/8', iso: 'ISO 100', isHot: true },
  { title: '雾中山林', author: '自然之眼', avatar: '🌲', likes: 2510, camera: 'Canon R5', lens: '70-200mm', aperture: 'f/4', iso: 'ISO 200', isHot: true },
  { title: '街角黄昏', author: '行走的镜头', avatar: '🚶', likes: 1860, camera: 'Fuji X-T5', lens: '35mm', aperture: 'f/2.8', iso: 'ISO 400', isHot: false },
  { title: '极光之夜', author: '星空猎人', avatar: '🌌', likes: 4120, camera: 'Nikon Z9', lens: '14-24mm', aperture: 'f/2.8', iso: 'ISO 3200', isHot: true },
  { title: '微观花蕊', author: '微距世界', avatar: '🌸', likes: 980, camera: 'Olympus OM-1', lens: '90mm Macro', aperture: 'f/5.6', iso: 'ISO 200', isHot: false },
  { title: '逆光人像', author: '光影捕手', avatar: '🧑‍🎨', likes: 2150, camera: 'Sony A7R5', lens: '85mm', aperture: 'f/1.4', iso: 'ISO 100', isHot: false },
  { title: '延时云海', author: '云端漫步', avatar: '☁️', likes: 3680, camera: 'Canon R5', lens: '16-35mm', aperture: 'f/11', iso: 'ISO 100', isHot: true },
  { title: '黑白街头', author: '行走的镜头', avatar: '🚶', likes: 1420, camera: 'Leica Q3', lens: '28mm', aperture: 'f/2', iso: 'ISO 800', isHot: false },
  { title: '日落剪影', author: '光影捕手', avatar: '🧑‍🎨', likes: 2890, camera: 'Sony A7R5', lens: '70-200mm', aperture: 'f/4', iso: 'ISO 100', isHot: true },
  { title: '雨后霓虹', author: '夜色猎人', avatar: '🌃', likes: 1670, camera: 'Fuji X-T5', lens: '23mm', aperture: 'f/1.4', iso: 'ISO 1600', isHot: false },
  { title: '雪山星轨', author: '星空猎人', avatar: '🌌', likes: 5230, camera: 'Nikon Z9', lens: '14-24mm', aperture: 'f/2.8', iso: 'ISO 6400', isHot: true },
  { title: '复古胶片', author: '怀旧派', avatar: '📼', likes: 1230, camera: 'Pentax K-1', lens: '50mm', aperture: 'f/1.8', iso: 'ISO 400', isHot: false }
]

const WEEK_SHOT: WeekShotMeta[] = [
  { label: '周一', value: 4 },
  { label: '周二', value: 7 },
  { label: '周三', value: 5 },
  { label: '周四', value: 8 },
  { label: '周五', value: 11 },
  { label: '周六', value: 14 },
  { label: '周日', value: 9 }
]

const PHOTOGRAPHER_LIST: PhotographerMeta[] = [
  { name: '光影捕手', avatar: '🧑‍🎨', followers: '12.8万', works: '386', specialty: '风光·城市', isFollow: true },
  { name: '星空猎人', avatar: '🌌', followers: '9.6万', works: '215', specialty: '星空·延时', isFollow: true },
  { name: '自然之眼', avatar: '🌲', followers: '8.2万', works: '428', specialty: '自然·生态', isFollow: false },
  { name: '行走的镜头', avatar: '🚶', followers: '6.5万', works: '512', specialty: '街头·人文', isFollow: false },
  { name: '微距世界', avatar: '🌸', followers: '4.1万', works: '189', specialty: '微距·花卉', isFollow: false },
  { name: '云端漫步', avatar: '☁️', followers: '3.8万', works: '156', specialty: '风光·延时', isFollow: false },
  { name: '夜色猎人', avatar: '🌃', followers: '3.2万', works: '98', specialty: '夜景·城市', isFollow: false },
  { name: '怀旧派', avatar: '📼', followers: '2.1万', works: '67', specialty: '胶片·复古', isFollow: false }
]

const CAMERA_LIST: CameraMeta[] = [
  { brand: 'Sony', model: 'A7R5', emoji: '📸', sensor: '全画幅', pixels: '6100万', isMain: true },
  { brand: 'Canon', model: 'EOS R5', emoji: '📸', sensor: '全画幅', pixels: '4500万', isMain: false },
  { brand: 'Nikon', model: 'Z9', emoji: '📸', sensor: '全画幅', pixels: '4570万', isMain: false },
  { brand: 'Fuji', model: 'X-T5', emoji: '📸', sensor: 'APS-C', pixels: '4020万', isMain: false },
  { brand: 'Olympus', model: 'OM-1', emoji: '📸', sensor: 'M4/3', pixels: '2000万', isMain: false },
  { brand: 'Leica', model: 'Q3', emoji: '📸', sensor: '全画幅', pixels: '6000万', isMain: false },
  { brand: 'Pentax', model: 'K-1', emoji: '📸', sensor: '全画幅', pixels: '3600万', isMain: false },
  { brand: 'Sigma', model: 'fp L', emoji: '📸', sensor: '全画幅', pixels: '6100万', isMain: false }
]

const ALBUM_LIST: AlbumMeta[] = [
  { name: '城市风光', emoji: '🏙️', count: '86张', date: '2024-08', isLock: false },
  { name: '自然风光', emoji: '🏔️', count: '124张', date: '2024-07', isLock: false },
  { name: '人像作品', emoji: '👤', count: '52张', date: '2024-06', isLock: true },
  { name: '街头摄影', emoji: '🛣️', count: '98张', date: '2024-06', isLock: false },
  { name: '星空银河', emoji: '🌌', count: '35张', date: '2024-05', isLock: false },
  { name: '微距世界', emoji: '🐝', count: '67张', date: '2024-05', isLock: false },
  { name: '旅行记录', emoji: '✈️', count: '215张', date: '2024-04', isLock: false },
  { name: '黑白系列', emoji: '⚫', count: '43张', date: '2024-03', isLock: true },
  { name: '胶片回忆', emoji: '📼', count: '28张', date: '2024-02', isLock: false },
  { name: '延时合集', emoji: '⏱️', count: '19张', date: '2024-01', isLock: false }
]

const TAG_LIST: TagMeta[] = [
  { name: '#风光', count: '2.3万', isHot: true },
  { name: '#人像', count: '1.8万', isHot: true },
  { name: '#街头', count: '1.2万', isHot: false },
  { name: '#星空', count: '8560', isHot: true },
  { name: '#微距', count: '6230', isHot: false },
  { name: '#黑白', count: '5810', isHot: false },
  { name: '#胶片', count: '4320', isHot: false },
  { name: '#延时', count: '3180', isHot: false }
]

const CONTEST_LIST: ContestMeta[] = [
  { title: '城市光影大赛', emoji: '🏙️', deadline: '08-30', prize: '¥10,000', isJoin: true },
  { title: '夏日风光征稿', emoji: '☀️', deadline: '09-15', prize: '¥5,000', isJoin: false },
  { title: '微距世界挑战', emoji: '🐝', deadline: '09-01', prize: '¥3,000', isJoin: false },
  { title: '星空摄影月赛', emoji: '🌌', deadline: '08-25', prize: '¥8,000', isJoin: true },
  { title: '人文纪实专题', emoji: '📖', deadline: '10-01', prize: '¥6,000', isJoin: false },
  { title: '黑白极简大赛', emoji: '⚫', deadline: '09-20', prize: '¥4,000', isJoin: false }
]

function getHotColor(isHot: boolean): string {
  return isHot ? COLORS.accent : COLORS.primaryLight
}

function getFollowColor(isFollow: boolean): string {
  return isFollow ? COLORS.border : COLORS.accent
}

function getLockBg(isLock: boolean): string {
  return isLock ? COLORS.warm : COLORS.border
}

enum PhotoTab {
  HOME,
  GALLERY,
  RANK,
  GEAR,
  MINE
}

@Entry
@Component
struct PhotoApp {
  @State activeTab: PhotoTab = PhotoTab.HOME

  @Builder contentArea() {
    Column() {
      if (this.activeTab === PhotoTab.HOME) {
        PhotoHomeContent()
      } else if (this.activeTab === PhotoTab.GALLERY) {
        PhotoGalleryContent()
      } else if (this.activeTab === PhotoTab.RANK) {
        PhotoRankContent()
      } else if (this.activeTab === PhotoTab.GEAR) {
        PhotoGearContent()
      } else if (this.activeTab === PhotoTab.MINE) {
        PhotoMineContent()
      }
    }
    .width('100%')
  }

  @Builder tabItem(icon: string, label: string, tab: PhotoTab) {
    Column() {
      Text(icon).fontSize(20)
      Text(label).fontSize(10)
        .fontColor(this.activeTab === tab ? COLORS.accent : COLORS.textHint)
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
    }
    .layoutWeight(1)
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.tabItem(TAB_CONFIG.HOME.icon, TAB_CONFIG.HOME.title, PhotoTab.HOME)
        this.tabItem(TAB_CONFIG.GALLERY.icon, TAB_CONFIG.GALLERY.title, PhotoTab.GALLERY)
        this.tabItem(TAB_CONFIG.RANK.icon, TAB_CONFIG.RANK.title, PhotoTab.RANK)
        this.tabItem(TAB_CONFIG.GEAR.icon, TAB_CONFIG.GEAR.title, PhotoTab.GEAR)
        this.tabItem(TAB_CONFIG.MINE.icon, TAB_CONFIG.MINE.title, PhotoTab.MINE)
      }
      .width('100%')
      .padding({ top: 8, bottom: 8 })
      .backgroundColor(COLORS.white)
      .shadow({ radius: 10, color: '#455A6420', offsetY: -2 })
    }
    .width('100%').height('100%')
    .backgroundColor(COLORS.bg)
  }
}

@Component
struct PhotoHomeContent {
  @State showAddPhotoModal: boolean = false
  @State photoTitle: string = ''
  @State photoCamera: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder addPhotoModal() {
    Stack() {
      this.modalOverlay(() => { this.showAddPhotoModal = false })
      Column() {
        Row() {
          Text('📷 新增作品').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showAddPhotoModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('作品标题').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 城市天际线' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.photoTitle = v })
          Text('拍摄器材').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 Sony A7R5' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.photoCamera = v })
          Text('拍摄地点').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 上海外滩' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.warm).borderRadius(8)
              .onClick(() => { this.showAddPhotoModal = false })
            Column().width(12)
            Text('上传').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.accent).borderRadius(8)
              .onClick(() => { this.showAddPhotoModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })
      }
      .width('88%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 作品大图+曝光参数浮层头卡
          Stack({ alignContent: Alignment.BottomStart }) {
            Column()
              .width('100%').height(220)
              .linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
              .borderRadius({ bottomLeft: 28, bottomRight: 28 })
            Column() {
              Row() {
                Column() {
                  Text('📷 今日精选').fontSize(11).fontColor('#B0BEC5')
                  Text('城市天际线').fontSize(24).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
                    .margin({ top: 4 })
                  Text('by 光影捕手').fontSize(11).fontColor('#B0BEC5').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Column() {
                  Text('3,280').fontSize(22).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
                  Text('❤️ 点赞').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Center)
              }
              .padding({ left: 20, right: 20, top: 40 })
              // 曝光参数浮层
              Row() {
                Column() {
                  Text('Sony A7R5').fontSize(10).fontColor(COLORS.white)
                  Text('24-70mm f/8').fontSize(9).fontColor('#B0BEC5').margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Row() {
                  Text('ISO 100').fontSize(9).fontColor(COLORS.white)
                    .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                    .backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
                  Column().width(6)
                  Text('1/250s').fontSize(9).fontColor(COLORS.white)
                    .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                    .backgroundColor('rgba(255,255,255,0.15)').borderRadius(8)
                }
              }
              .width('100%')
              .padding({ left: 20, right: 20, top: 30, bottom: 16 })
            }
          }
          .width('100%').height(220)

          // 周拍摄柱状图
          Column() {
            Text('📊 本周拍摄(张)').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16 })
            Row() {
              ForEach(WEEK_SHOT, (w: WeekShotMeta) => {
                Column() {
                  Stack({ alignContent: Alignment.Bottom }) {
                    Column().width(22).height(90)
                      .backgroundColor(COLORS.border)
                      .borderRadius(4)
                    Column().width(22).height(Number(w.value) * 6)
                      .linearGradient({ angle: 0, colors: [[COLORS.accentLight, 0.0], [COLORS.accent, 1.0]] })
                      .borderRadius(4)
                  }
                  .width(22).height(90)
                  Text(w.label).fontSize(9).fontColor(COLORS.textSecondary)
                    .margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Center)
                .layoutWeight(1)
              })
            }
            .width('100%')
            .padding({ left: 16, right: 16, top: 12, bottom: 8 })
          }
          .width('100%')
          .backgroundColor(COLORS.cardBg)
          .borderRadius(16)
          .margin({ left: 12, right: 12, top: 12 })

          // 热门标签
          Column() {
            Text('🔥 热门标签').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(TAG_LIST, (t: TagMeta) => {
                Row() {
                  Text(t.name).fontSize(12).fontColor(getHotColor(t.isHot))
                  Text(' ' + t.count).fontSize(9).fontColor(COLORS.textHint)
                }
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(COLORS.warm).borderRadius(20)
                .margin({ right: 8, bottom: 8 })
              })
            }
            .width('100%').padding({ left: 16, right: 16 })
          }
          .width('100%')
          .backgroundColor(COLORS.cardBg)
          .borderRadius(16)
          .margin({ left: 12, right: 12, top: 12 })

          // 比赛活动
          Column() {
            Text('🏆 摄影大赛').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            ForEach(CONTEST_LIST, (c: ContestMeta) => {
              Row() {
                Text(c.emoji).fontSize(28).margin({ right: 12 })
                Column() {
                  Text(c.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                  Text('截止 ' + c.deadline + ' · 奖金 ' + c.prize).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text(c.isJoin ? '已参与' : '参与').fontSize(11)
                  .fontColor(c.isJoin ? COLORS.textHint : COLORS.white)
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .backgroundColor(c.isJoin ? COLORS.border : COLORS.accent).borderRadius(12)
              }
              .width('100%').padding({ left: 16, right: 16, top: 8, bottom: 8 })
            })
          }
          .width('100%')
          .backgroundColor(COLORS.cardBg)
          .borderRadius(16)
          .margin({ left: 12, right: 12, top: 12 })

          // 精选作品列表
          Column() {
            Text('✨ 精选作品').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            ForEach(PHOTO_LIST, (p: PhotoMeta) => {
              Row() {
                Stack() {
                  Column().width(52).height(52).borderRadius(12)
                    .linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
                  Text(p.avatar).fontSize(28)
                }
                .width(52).height(52).margin({ right: 12 })
                Column() {
                  Text(p.title).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                  Text(p.author + ' · ' + p.camera).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
                  Text(p.lens + ' ' + p.aperture + ' ' + p.iso).fontSize(9).fontColor(COLORS.textHint).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Column() {
                  Text('❤️ ' + String(p.likes)).fontSize(10).fontColor(getHotColor(p.isHot))
                  if (p.isHot) {
                    Text('🔥 热门').fontSize(8).fontColor(COLORS.white)
                      .padding({ left: 4, right: 4, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accent).borderRadius(4)
                      .margin({ top: 4 })
                  }
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
              .border({ width: { bottom: 1 }, color: COLORS.border })
            })
          }
          .width('100%')
          .backgroundColor(COLORS.cardBg)
          .borderRadius(16)
          .margin({ left: 12, right: 12, top: 12, bottom: 12 })

          // 新增按钮
          Row() {
            Text('+ 上传新作品').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 12, bottom: 12 })
              .backgroundColor(COLORS.accent).borderRadius(12)
              .onClick(() => { this.showAddPhotoModal = true })
          }
          .width('92%')
          .margin({ bottom: 20 })
        }
        .width('100%')
      }
      .width('100%').height('100%')
      .scrollBar(BarState.Off)

      if (this.showAddPhotoModal) {
        this.addPhotoModal()
      }
    }
    .width('100%').height('100%')
  }
}

@Component
struct PhotoGalleryContent {
  build() {
    Scroll() {
      Column() {
        // 瀑布流双列
        Text('🖼️ 全部作品').fontSize(16).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(PHOTO_LIST, (p: PhotoMeta) => {
            Column() {
              Stack() {
                Column().width('100%').height(120)
                  .linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
                  .borderRadius({ topLeft: 12, topRight: 12 })
                Text(p.avatar).fontSize(40)
              }
              .width('100%').height(120)
              Column() {
                Text(p.title).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                Text(p.author).fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
                Row() {
                  Text('❤️ ' + String(p.likes)).fontSize(9).fontColor(COLORS.accent)
                  Column().layoutWeight(1)
                  if (p.isHot) {
                    Text('🔥').fontSize(10)
                  }
                }
                .width('100%').margin({ top: 4 })
              }
              .width('100%').padding({ left: 8, right: 8, top: 6, bottom: 8 })
            }
            .width('48%')
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
            .margin({ bottom: 10 })
          })
        }
        .width('92%')
        .padding({ top: 4 })
      }
      .width('100%')
    }
    .width('100%').height('100%')
    .scrollBar(BarState.Off)
  }
}

@Component
struct PhotoRankContent {
  build() {
    Scroll() {
      Column() {
        Text('🏆 摄影师排行').fontSize(16).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })

        // 前三名
        Row() {
          ForEach(PHOTOGRAPHER_LIST.slice(0, 3), (pg: PhotographerMeta, idx: number) => {
            Column() {
              Stack() {
                Column().width(56).height(56).borderRadius(28)
                  .backgroundColor(idx === 0 ? '#FFD700' : idx === 1 ? '#C0C0C0' : '#CD7F32')
                Text(pg.avatar).fontSize(32)
              }
              .width(56).height(56).margin({ bottom: 6 })
              Text(pg.name).fontSize(11).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
              Text(pg.followers + '粉丝').fontSize(9).fontColor(COLORS.textSecondary).margin({ top: 2 })
              Text(idx === 0 ? '🥇' : idx === 1 ? '🥈' : '🥉').fontSize(14).margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
            .margin({ top: idx === 0 ? 0 : 20 })
          })
        }
        .width('100%')
        .padding({ top: 16, bottom: 16 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .margin({ left: 12, right: 12 })

        // 排行榜
        Column() {
          ForEach(PHOTOGRAPHER_LIST, (pg: PhotographerMeta, idx: number) => {
            Row() {
              Text(String(idx + 1)).fontSize(18).fontWeight(FontWeight.Bold)
                .fontColor(idx < 3 ? COLORS.accent : COLORS.textHint)
                .width(32).textAlign(TextAlign.Center)
              Text(pg.avatar).fontSize(28).margin({ right: 10 })
              Column() {
                Text(pg.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                Text(pg.specialty + ' · ' + pg.works + '作品').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1)
              Column() {
                Text(pg.followers).fontSize(12).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Bold)
                Text('粉丝').fontSize(8).fontColor(COLORS.textHint).margin({ top: 1 })
              }
              .alignItems(HorizontalAlign.End).margin({ right: 10 })
              Text(pg.isFollow ? '已关注' : '关注').fontSize(11)
                .fontColor(pg.isFollow ? COLORS.textHint : COLORS.white)
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(getFollowColor(pg.isFollow)).borderRadius(12)
            }
            .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
            .border({ width: { bottom: 1 }, color: COLORS.border })
          })
        }
        .width('100%')
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .margin({ left: 12, right: 12, top: 12, bottom: 12 })
      }
      .width('100%')
    }
    .width('100%').height('100%')
    .scrollBar(BarState.Off)
  }
}

@Component
struct PhotoGearContent {
  @State showEditCameraModal: boolean = false
  @State editModel: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder editCameraModal() {
    Stack() {
      this.modalOverlay(() => { this.showEditCameraModal = false })
      Column() {
        Row() {
          Text('✏️ 编辑相机信息').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showEditCameraModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('型号').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 12, left: 20 })
          TextInput({ placeholder: '如 A7R5' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
            .onChange((v: string) => { this.editModel = v })
          Text('传感器').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 全画幅' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
          Text('像素').fontSize(12).fontColor(COLORS.textSecondary).margin({ top: 14, left: 20 })
          TextInput({ placeholder: '如 6100万' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
            .backgroundColor(COLORS.warm).borderRadius(8)
            .margin({ left: 20, right: 20, top: 4 })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.warm).borderRadius(8)
              .onClick(() => { this.showEditCameraModal = false })
            Column().width(12)
            Text('保存').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.primary).borderRadius(8)
              .onClick(() => { this.showEditCameraModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })
      }
      .width('88%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          Text('🎥 我的器材库').fontSize(16).fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 8 })
          ForEach(CAMERA_LIST, (c: CameraMeta) => {
            Row() {
              Stack() {
                Column().width(48).height(48).borderRadius(12)
                  .backgroundColor(c.isMain ? COLORS.warm : COLORS.border)
                Text(c.emoji).fontSize(28)
              }
              .width(48).height(48).margin({ right: 12 })
              Column() {
                Text(c.brand + ' ' + c.model).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                Text(c.sensor + ' · ' + c.pixels).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1)
              if (c.isMain) {
                Text('主力').fontSize(9).fontColor(COLORS.white)
                  .padding({ left: 6, right: 6, top: 3, bottom: 3 })
                  .backgroundColor(COLORS.accent).borderRadius(4)
                  .margin({ right: 8 })
              }
              Text('✏️').fontSize(16).onClick(() => { this.showEditCameraModal = true })
            }
            .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })
            .border({ width: { bottom: 1 }, color: COLORS.border })
          })

          // 器材统计
          Column() {
            Text('📈 器材统计').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 16, bottom: 12 })
            Row() {
              Column() {
                Text('8').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.accent)
                Text('相机').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center).layoutWeight(1)
              Column() {
                Text('12').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.primary)
                Text('镜头').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center).layoutWeight(1)
              Column() {
                Text('5').fontSize(28).fontWeight(FontWeight.Bold).fontColor(COLORS.success)
                Text('配件').fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
              }
              .alignItems(HorizontalAlign.Center).layoutWeight(1)
            }
            .width('100%').padding({ top: 8, bottom: 16 })
          }
          .width('100%')
          .backgroundColor(COLORS.cardBg)
          .borderRadius(16)
          .margin({ left: 12, right: 12, top: 12, bottom: 12 })
        }
        .width('100%')
      }
      .width('100%').height('100%')
      .scrollBar(BarState.Off)

      if (this.showEditCameraModal) {
        this.editCameraModal()
      }
    }
    .width('100%').height('100%')
  }
}

@Component
struct PhotoMineContent {
  @State showDeleteAlbumModal: boolean = false
  @State deleteName: string = ''

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(0,0,0,0.55)')
      .onClick(onClose)
  }

  @Builder deleteAlbumModal() {
    Stack() {
      this.modalOverlay(() => { this.showDeleteAlbumModal = false })
      Column() {
        Row() {
          Text('🗑️ 删除相册').fontSize(18).fontWeight(FontWeight.Bold).fontColor(COLORS.textPrimary)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(COLORS.textHint).onClick(() => { this.showDeleteAlbumModal = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
        Divider().color(COLORS.border)
        Column() {
          Text('确定要删除该相册吗?').fontSize(14).fontColor(COLORS.textPrimary)
            .margin({ top: 20, left: 20 })
          Text('删除后无法恢复,相册中的照片将移至回收站。').fontSize(11).fontColor(COLORS.textSecondary)
            .margin({ top: 8, left: 20, right: 20 })
          Row() {
            Text('取消').fontSize(14).fontColor(COLORS.textSecondary)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.warm).borderRadius(8)
              .onClick(() => { this.showDeleteAlbumModal = false })
            Column().width(12)
            Text('删除').fontSize(14).fontColor(COLORS.white)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.danger).borderRadius(8)
              .onClick(() => { this.showDeleteAlbumModal = false })
          }
          .margin({ left: 20, right: 20, top: 20, bottom: 20 })
        }
        .constraintSize({ maxHeight: '70%' })
      }
      .width('88%')
      .backgroundColor(COLORS.cardBg).borderRadius(16)
    }
    .width('100%').height('100%')
  }

  build() {
    Stack() {
      Scroll() {
        Column() {
          // 个人资料头卡
          Row() {
            Stack() {
              Column().width(60).height(60).borderRadius(30)
                .linearGradient({ angle: 135, colors: [[COLORS.primaryLight, 0.0], [COLORS.primaryDark, 1.0]] })
              Text('🧑‍🎨').fontSize(36)
            }
            .width(60).height(60).margin({ right: 14 })
            Column() {
              Text('光影捕手').fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
              Text('Lv.8 资深摄影师').fontSize(10).fontColor('#B0BEC5').margin({ top: 2 })
              Row() {
                Text('386 作品').fontSize(10).fontColor('#B0BEC5')
                Text(' · ').fontSize(10).fontColor('#B0BEC5')
                Text('12.8万 粉丝').fontSize(10).fontColor('#B0BEC5')
              }
              .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
          }
          .width('100%')
          .padding({ left: 20, right: 20, top: 20, bottom: 20 })
          .linearGradient({ angle: 135, colors: [[COLORS.primary, 0.0], [COLORS.primaryDark, 1.0]] })
          .borderRadius({ bottomLeft: 24, bottomRight: 24 })

          // 我的相册
          Column() {
            Text('📁 我的相册').fontSize(14).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary).margin({ left: 16, top: 12, bottom: 8 })
            ForEach(ALBUM_LIST, (a: AlbumMeta) => {
              Row() {
                Stack() {
                  Column().width(44).height(44).borderRadius(10)
                    .backgroundColor(getLockBg(a.isLock))
                  Text(a.emoji).fontSize(26)
                }
                .width(44).height(44).margin({ right: 12 })
                Column() {
                  Text(a.name).fontSize(13).fontColor(COLORS.textPrimary).fontWeight(FontWeight.Medium)
                  Text(a.count + ' · ' + a.date).fontSize(10).fontColor(COLORS.textSecondary).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                if (a.isLock) {
                  Text('🔒').fontSize(14).margin({ right: 8 })
                }
                Text('🗑️').fontSize(16).onClick(() => { this.showDeleteAlbumModal = true })
              }
              .width('100%').padding({ left: 16, right: 16, top: 10, bottom: 10 })
              .border({ width: { bottom: 1 }, color: COLORS.border })
            })
          }
          .width('100%')
          .backgroundColor(COLORS.cardBg)
          .borderRadius(16)
          .margin({ left: 12, right: 12, top: 12, bottom: 12 })
        }
        .width('100%')
      }
      .width('100%').height('100%')
      .scrollBar(BarState.Off)

      if (this.showDeleteAlbumModal) {
        this.deleteAlbumModal()
      }
    }
    .width('100%').height('100%')
  }
}

十二、结尾总结

在这里插入图片描述

这款摄影社区应用虽然只是一个前端原型,但其中蕴含的架构设计思想和UI组件设计经验值得深入学习。从整体架构来看,它采用了经典的Tab导航+多页面结构,每个页面职责清晰,页面之间通过底部导航切换,交互流程直观自然。

在架构设计方面,这款应用体现了几个值得称道的优点。第一是分层清晰——数据模型层、配置常量层、工具函数层、组件层,各层职责明确,依赖关系简单。第二是类型安全——大量使用interface定义数据结构,用enum表示枚举值,配合TypeScript的类型检查,有效减少了运行时错误。第三是配置驱动——TAB_CONFIG等配置常量将UI配置从组件代码中抽离,提高了代码的可维护性和可扩展性。

在组件设计方面,这款应用也有很多亮点。@Builder自定义构建器的使用,将可复用的UI片段封装成函数,减少了代码重复。虽然目前modalOverlay在每个页面中都有重复定义,但这已经体现了组件化复用的思路,只需再进一步抽离成独立组件即可。头卡、卡片、列表项、标签等基础UI元素的设计风格统一,形成了一套完整的设计语言。

在状态管理方面,目前使用的是最基础的@State装饰器,每个组件维护自己的局部状态。对于当前这个规模的应用来说,局部状态已经足够,不需要引入更复杂的状态管理方案。这是一个很好的实践——选择最简单的能满足需求的方案,而不是盲目追求复杂的架构。

从学习价值的角度来看,这款应用非常适合ArkUI/ArkTS的初学者学习。它涵盖了声明式UI开发的核心概念:@Component组件、@State状态、@Builder构建器、ForEach列表渲染、条件渲染、Stack/Column/Row布局、事件处理等。同时,它也展示了如何从零开始构建一个完整的移动应用界面,从颜色系统到数据模型,从页面布局到交互设计,都有可以借鉴的地方。

未来可以优化的方向也很明确:一是将重复的modalOverlay抽离为公共组件,减少代码冗余;二是引入更完善的状态管理方案,实现跨页面的状态共享;三是将统计数据改为动态计算,确保数据一致性;四是增加更多的交互动效,提升用户体验;五是接入真实的后端API,让应用从原型变成真正可用的产品。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐