开篇:声明式UI范式下的暗房胶片美学

在这里插入图片描述

在HarmonyOS ArkTS API 24的技术体系中,声明式UI开发范式正在重新定义移动应用的构建方式。与传统的命令式UI框架不同,声明式开发允许开发者以"描述UI应该是什么样子"的方式来构建界面,而不是"一步步告诉系统如何绘制UI"。这种范式的转变带来了代码可读性的极大提升,也使得状态与视图的绑定更加直观自然。本文所要深入剖析的"影语·胶片相机与暗房冲扫工坊"应用,正是基于这一先进范式构建的一款沉浸式胶片摄影社区应用,它将暗房红与金色胶片的色彩美学融入到每一个像素的渲染之中。

基于HarmonyOS API 24的暗色主题开发,是当前HarmonyOS生态中备受关注的设计方向。暗色主题不仅仅是将背景改为黑色这么简单,它涉及到一整套完整的色彩系统设计——从主色调、辅助色到文本层级、边框分割线、状态反馈色,每一个色值都需要经过精心调校,以确保在低光环境下既能提供足够的对比度,又不会产生刺眼的视觉疲劳。"影语"应用选择了暗房红(#B0413E)作为主色调,搭配深棕背景(#1C1917)和金色胶片点缀(#C9A66B),构建出一种仿佛置身于真实暗房之中的沉浸式体验。

@Observed装饰器是HarmonyOS ArkUI状态管理体系中的核心机制之一。通过在类定义上添加@Observed装饰器,开发者可以将普通的数据类转变为可观察的数据模型,使得当模型中的属性发生变化时,依赖这些属性的UI组件能够自动刷新。这种细粒度的状态追踪能力,是声明式UI能够高效运行的关键基石。在"影语"应用中,从影友动态(PostItem)到胶片机身(FilmItem),再到冲扫订单(ScanItem)和耗材配件(GearItem),每一个业务实体都被@Observed所装饰,从而实现了数据驱动视图的全自动响应式更新。

@Builder装饰器则是ArkUI框架中用于封装可复用UI片段的强大工具。通过@Builder,开发者可以将复杂的UI结构抽取为独立的构建函数,在需要的地方直接调用,极大地提升了代码的复用性和可维护性。在"影语"应用中,从特效层(fxLayer)、头部导航(header)、子导航栏(subNav)到七个内容页面的构建器,再到弹框组件和底部导航栏,全部采用@Builder进行封装。这种模块化的组织方式,使得每个UI片段都具有清晰的职责边界,也便于后续的迭代优化和功能扩展。

Stack、Column、Row三大布局容器构成了ArkUI声明式布局系统的基石。Stack采用层叠布局,子组件按照先后顺序堆叠在一起,非常适合用于构建弹框、遮罩层、特效层等叠加式UI;Column采用垂直方向的线性布局,子组件从上到下依次排列;Row采用水平方向的线性布局,子组件从左到右依次排列。这三种布局容器的灵活组合,可以构建出几乎所有常见的界面结构。在"影语"应用中,最外层使用Stack实现背景层、特效层、内容层和弹框层的四层叠加,内容层内部则通过Column与Row的嵌套组合,构建出丰富多变的页面布局。

ArkUI渲染管线是支撑整个声明式UI系统高效运行的底层引擎。当状态发生变化时,ArkUI框架会通过差异更新算法精确计算出需要重新渲染的组件,避免不必要的重绘,从而保证界面的流畅性。配合@Builder的复用机制和@Observed的细粒度观察,ArkUI能够在保持开发效率的同时,提供接近原生的渲染性能。对于"影语"这样一个包含暗房红光呼吸特效和胶片颗粒闪烁特效的应用来说,渲染管线的性能表现直接决定了特效动画的流畅度和用户体验的优劣。

暗房红金色彩设计系统是"影语"应用视觉风格的灵魂所在。主色调暗房红(#B0413E)取自真实暗房中安全灯的暖红色,它既是暗房环境的标志色,也象征着胶片在显影液中缓缓呈现影像的神秘过程。辅助色金色胶片(#C9A66B)则取自胶片本身的暖金色调,代表着胶片摄影的经典与质感。深棕背景(#1C1917)并非纯黑,而是带有微微红棕色调的深褐色,这使得整个界面在暗色模式下依然保有温暖的质感,而非冷冰冰的纯黑。配合从浅到深的三级文本色阶和精心设计的边框分割线色,整个色彩系统既统一又富有层次。

胶片冲扫工坊交互模式是"影语"应用的核心业务场景。应用围绕胶片摄影爱好者的真实需求,构建了从机身市集选购、冲扫订单预约、暗房工坊体验到胶片课堂学习的完整业务闭环。用户可以在市集页面浏览各类胶片机身,在冲扫页面跟踪订单进度,在工坊页面查看暗房工位和耗材库存,在课堂页面学习胶片摄影知识,在影友圈分享摄影心得。这种一站式的胶片摄影社区体验,通过底部四个主Tab和首页七个内容Tab的双层导航结构,清晰地呈现在用户面前。


一、ColorPalette接口设计与暗房红金色彩系统

在这里插入图片描述

1.1 接口定义的架构意义

ColorPalette接口是整个应用色彩系统的类型契约。它以TypeScript接口的形式,定义了一套完整的色彩语义规范,使得应用中所有使用颜色的地方都通过统一的语义名称来引用,而非直接使用硬编码的十六进制色值。这种设计模式带来了多重优势:首先,它实现了主题色的集中管理,当需要调整色彩方案时,只需修改COLORS常量对象中的值即可全局生效;其次,它提供了清晰的语义化命名,开发者在编写UI代码时可以直观地知道当前使用的颜色代表什么含义;最后,它为后续的多主题切换(如日间模式/夜间模式切换)预留了扩展空间,只需提供不同的ColorPalette实例即可实现主题的无缝切换。

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

1.2 十六个颜色字段逐字段深度分析

在这里插入图片描述

primary(主色调):取值为#B0413E,即暗房红。这是整个应用的品牌色和视觉核心,用于按钮背景、重要标签、进度条高亮等关键交互元素。这个红色并非鲜艳的正红,而是带有褐色调的暗房安全灯红,饱和度适中,在深色背景上既醒目又不刺眼。它的RGB值约为(176, 65, 62),亮度中等偏低,非常适合作为暗色主题的主色调。

primaryLight(主色亮色变体):取值为#5A3432。这是主色调的低饱和暗化版本,主要用于主色元素的背景衬托或次要强调区域。它的红棕色调与主色保持一致,但亮度更低,约为主色的一半左右,可以在需要层次感的地方与主色形成微妙的明暗对比。

primaryDark(主色暗色变体):取值为#8A2F2C。这是主色调的深化版本,比primary更暗更沉稳,常用于按下态、阴影色或渐变的暗部区域。在需要体现按钮立体感的场景中,primary与primaryDark的搭配可以构建出自然的渐变效果。

accent(强调色/金色):取值为#C9A66B,即金色胶片色。这是应用的辅助强调色,取自胶片本身的暖金色调,与暗房红形成冷暖对比。金色常用于价格标签、重要数据、等级标识等需要突出但又不宜使用主色的场景。红金搭配是经典的高端配色方案,暗房红的沉稳与金色的华贵相得益彰。

accentLight(强调色亮色变体):取值为#4A3E2C。这是金色的暗化低饱和版本,主要用作金色标签的背景色。当需要显示金色文字的标签时,用accentLight作为标签背景,可以形成柔和的衬托效果,既保持了金色的语义,又不会过于刺眼。

bg(页面背景色):取值为#1C1917,即深棕褐色。这是整个应用的底色,不是纯黑也不是深灰,而是带有微微红棕色调的深褐色。这种颜色选择非常考究——纯黑背景在OLED屏幕上虽然省电,但长时间阅读容易产生视觉疲劳;深灰背景则缺乏个性。深棕褐色的背景既保持了暗色主题的沉浸感,又因为微微的红棕色调而与暗房主题相呼应,营造出温暖而神秘的暗房氛围。

cardBg(卡片背景色):取值为#2A2622。这是卡片、弹框等容器组件的背景色,比bg稍亮一些,形成了页面背景与卡片内容之间的层次感。在暗色主题设计中,通过不同深浅的背景色来区分层级是一种常用手法,cardBg与bg的亮度差约为8-10个百分点,既能区分层级,又不会产生割裂感。

textPrimary(主要文本色):取值为#EDE6DB,即暖白色。这是标题、正文等主要文本的颜色,不是纯白而是带有暖黄色调的米白色。在深棕背景上使用暖白色文本,对比度适中,阅读舒适度高,同时也与整体的暖色调配色方案保持一致。

textSecondary(次要文本色):取值为#B5A897。这是描述文字、辅助信息等次要文本的颜色,比主文本色暗一个层级,用于构建文本的视觉层级。在信息密度较高的界面中,通过textPrimary与textSecondary的搭配,可以引导用户的阅读顺序,突出重点信息。

textHint(提示文本色):取值为#7D7264。这是占位符、时间戳、禁用态文字等最弱层级文本的颜色。它的亮度最低,与背景的对比度刚好满足可读性的下限,适用于那些不需要强调、但又必须存在的辅助信息。

border(边框分割线色):取值为#3A342E。这是边框、分割线、进度条背景等元素的颜色。在暗色主题中,分割线不能使用太亮的颜色,否则会显得突兀;也不能太暗,否则起不到分割作用。#3A342E的亮度刚好在bg与cardBg之间,能够柔和地划分区域边界。

success(成功状态色):取值为#8FAE7E,即暗绿色。这是成功、完成、正常等正向状态的标识色。与传统的鲜亮绿色不同,这个绿色经过了灰化处理,饱和度较低,能够很好地融入暗色主题的整体氛围中,不会显得过于跳脱。

warning(警告状态色):取值为#D9A05B,即暗金色。这是警告、注意、进行中等中性偏警示状态的颜色。它与accent金色属于同一色系但略有区分,亮度稍高,用于需要引起注意但又不是严重问题的状态提示。

danger(危险/错误状态色):取值为#C76B5A。这是错误、删除、危险操作等负向状态的颜色。它比主色更亮更偏橘红,视觉冲击力更强,用于需要用户特别注意的危险操作按钮(如退课、取消冲扫)和错误提示。

white(纯白):取值为#FFFFFF。纯白色在整个应用中使用非常克制,主要用于按钮上的文字和角标数字等需要最高对比度的场景。由于纯白在深色背景上过于刺眼,大部分文本都使用了暖白色textPrimary,纯白只保留给最重要的文字信息。

darkroom(暗房红特效色):取值为#D95C4F。这是一个非常特殊的颜色字段,专门用于暗房红光晕呼吸特效。它比主色primary更亮更鲜活,是暗房安全灯的真实灯光颜色。这个颜色只出现在特效层的光晕元素中,通过透明度的变化来模拟暗房红灯的呼吸效果,是整个应用氛围感的点睛之笔。

1.3 色彩系统的设计哲学

在这里插入图片描述

这套十六色的色彩系统,体现了暗色主题设计的专业水准。它不仅仅是选择了一组好看的颜色,更是构建了一套完整的色彩语义体系:从背景到文本的三级明度递进(bg/cardBg/border → textHint/textSecondary/textPrimary),从主色到辅助色的冷暖搭配(暗房红 + 金色胶片),以及从成功到危险的状态色阶(success/warning/danger),每一种颜色都有其明确的语义定位和使用场景。这种系统化的色彩设计,保证了整个应用视觉风格的统一性和一致性,也为后续的功能扩展提供了清晰的色彩规范。


二、@Observed数据模型深度解析

2.1 @Observed装饰器的工作原理

在这里插入图片描述

在HarmonyOS ArkUI的状态管理体系中,@Observed装饰器扮演着至关重要的角色。它的核心作用是将一个普通的TypeScript类标记为"可观察类",使得ArkUI框架能够追踪这个类实例中属性的变化。当被@Observed装饰的类的某个属性值发生改变时,所有依赖该属性的UI组件都会自动触发重新渲染。这种机制是声明式UI能够实现"数据驱动视图"的核心技术基础。

与@State装饰器不同,@Observed作用于类的定义本身,而@State作用于组件内的变量。换言之,@Observed是从数据模型的角度声明"我是可观察的",而@State是从组件的角度声明"我需要观察这个变量"。两者配合使用,才能构建出完整的响应式数据链路。在"影语"应用中,四个核心业务模型类都使用了@Observed装饰器,确保数据变化能够实时反映到界面上。

2.2 PostItem(影友动态)数据模型

@Observed
export class PostItem {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  text: string = ''
  time: string = ''
  likes: number = 0
  constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
    this.time = time; this.likes = likes
  }
}

PostItem是影友动态的数据模型,代表社区中的一条动态帖子。它包含六个字段:

id字段:数字类型,作为每条动态的唯一标识符。在ForEach循环渲染中,id被用作key函数的返回值,确保ArkUI框架能够正确追踪列表项的身份,实现高效的差异更新。当动态列表发生增删操作时,稳定的id是保证列表滚动位置和动画正确的关键。

nick字段:字符串类型,存储发布者的昵称。在影友圈和精选页面的动态列表中,昵称以粗体主文本色显示,是每条动态最醒目的文字信息之一。

avatar字段:字符串类型,存储用户头像的表情符号。应用使用emoji表情作为头像的简化表示,这既减少了图片资源的加载开销,又保持了视觉上的趣味性和辨识度。

text字段:字符串类型,存储动态的正文内容。这是每条动态的核心信息,在列表中通常限制为最多两行显示,超出部分以省略号截断,点击进入详情后才展示完整内容。

time字段:字符串类型,存储动态的发布时间。时间采用相对时间的表述方式(如"7分钟前"、“2小时前”、“昨天”),而非绝对时间戳,这种人性化的时间表示方式在社交类应用中非常常见。

likes字段:数字类型,存储动态获得的点赞数。在动态卡片的底部显示,配合胶片表情符号(🎞️)形成"胶片点赞"的趣味互动,与应用的胶片主题相呼应。

2.3 FilmItem(胶片机身)数据模型

在这里插入图片描述

@Observed
export class FilmItem {
  id: number = 0
  name: string = ''
  craft: string = ''
  price: string = ''
  level: string = ''
  hot: number = 0
  constructor(id: number, name: string, craft: string, price: string, level: string, hot: number) {
    this.id = id; this.name = name; this.craft = craft; this.price = price
    this.level = level; this.hot = hot
  }
}

FilmItem是胶片机身的数据模型,代表市集中的一台胶片相机。它包含六个字段:

id字段:数字类型,机身的唯一标识符,同样用于ForEach的key函数,确保列表渲染的正确性和效率。

name字段:字符串类型,机身的名称,通常包含品牌、型号和特殊版本信息(如"尼康 FM2 · 黑漆"、“徕卡 M6 TTL”)。名称是机身卡片中最核心的信息,以粗体大字显示。

craft字段:字符串类型,描述机身的工艺类型,如"135旁轴单反"、“120双镜头”、"口袋机35F2.8"等。这个字段帮助用户快速了解相机的类型和基本规格。

price字段:字符串类型,存储机身的价格信息,格式为"¥XXXX/台"或"¥XXXX/套"。价格是用户选购决策的重要参考因素,在卡片中以醒目的方式展示。

level字段:字符串类型,表示机身的成色等级或收藏等级,如"成色A"、“典藏级”、“入门款”、"大师级"等。这个字段配合levelColor函数,会以不同颜色的标签形式显示,为用户提供直观的等级认知。

hot字段:数字类型,表示机身的关注度或热度值,范围从0到100。热度值不仅以数字形式展示,还通过进度条的可视化方式呈现,热度越高,进度条越长,颜色也会根据filmTagColor函数呈现不同的色调。

2.4 ScanItem(冲扫订单)数据模型

@Observed
export class ScanItem {
  id: number = 0
  name: string = ''
  item: string = ''
  status: string = ''
  progress: number = 0
  constructor(id: number, name: string, item: string, status: string, progress: number) {
    this.id = id; this.name = name; this.item = item; this.status = status
    this.progress = progress
  }
}

ScanItem是冲扫订单的数据模型,代表用户的一笔胶片冲扫订单。它包含五个字段:

id字段:数字类型,订单的唯一标识符。

name字段:字符串类型,冲扫订单的名称,通常包含胶卷品牌、型号和数量信息(如"柯达金200 · 36张"、“富士业务100 · 2卷”)。

item字段:字符串类型,描述冲扫的工艺项目,如"C-41彩负"、“D-76冲洗”、"E-6反转"等。这个字段对应用户选择的具体冲扫服务类型。

status字段:字符串类型,表示订单的当前状态。状态值包括"已出片"、“扫描中”、“显影中”、“定影中”、“待入档”、"待付尾款"等,代表了胶片冲扫全流程中的不同阶段。配合statusColor函数,每种状态会以不同颜色的标签显示。

progress字段:数字类型,表示订单的冲扫进度百分比(0-100)。这个字段用于进度条的渲染,直观展示订单的完成程度。进度值与status字段相互关联,不同的状态对应不同的进度区间。

2.5 GearItem(耗材配件)数据模型

@Observed
export class GearItem {
  id: number = 0
  name: string = ''
  desc: string = ''
  icon: string = ''
  constructor(id: number, name: string, desc: string, icon: string) {
    this.id = id; this.name = name; this.desc = desc; this.icon = icon
  }
}

GearItem是耗材配件的数据模型,代表暗房工坊中的一种耗材或配件商品。它包含四个字段:

id字段:数字类型,耗材的唯一标识符。

name字段:字符串类型,耗材的名称,如"柯达金200"、“D76显影粉”、"底片袋·无酸"等。

desc字段:字符串类型,耗材的简短描述,说明规格或用途,如"36张彩负"、“1L袋装”、"100页装"等。

icon字段:字符串类型,使用emoji表情作为耗材的图标表示,如胶卷用🎞️、相机用📷、显影粉用🧪等。这种图标方案简洁有趣,与应用整体的复古胶片风格相得益彰。

2.6 辅助接口定义

除了四个核心的@Observed模型类之外,应用还定义了几个辅助接口,用于描述图表数据和课程数据的结构:

WeekChartItem接口:包含label(星期标签)和value(冲扫数量)两个字段,用于周冲扫量柱状图的数据表示。

FilmChartItem接口:在WeekChartItem的基础上增加了color字段,用于题材构成条形图的数据表示,每个题材条目有自己独立的颜色。

CourseItem接口:包含title(课程标题)、week(学习周次)、progress(学习进度)和color(进度条颜色)四个字段,用于胶片课堂的课程列表展示。

DarkTypeItem接口:包含name(服务名称)、desc(服务描述)、icon(图标emoji)和hot(是否热门)四个字段,用于暗房工坊首页的玩法四格展示。

这些辅助接口虽然没有使用@Observed装饰器(因为它们是只读的静态数据,不需要动态更新),但同样遵循了TypeScript的类型规范,为数据提供了清晰的结构定义。


三、静态数据数组与导航配置

在这里插入图片描述

3.1 静态数据数组总览

"影语"应用中定义了八组顶层静态数据数组,它们构成了应用的初始数据源和模拟数据。这些数据数组在模块加载时就完成了初始化,通过new关键字创建对应的@Observed类实例,然后存入数组中供UI组件引用。使用静态数据作为初始状态有诸多好处:便于开发阶段的UI调试、提供真实的内容预览、支持离线场景下的功能演示等。

3.2 POST_LIST:影友动态列表

在这里插入图片描述

POST_LIST数组包含8条影友动态数据,涵盖了不同类型的胶片摄影爱好者人设:从"胶片不死党"到"暗房常驻民",从"二手淘机身"到"过期卷赌徒",每条动态都有独特的昵称、头像表情和内容文案。这些数据生动地描绘了胶片摄影社区的用户画像,也为首页的"影友动态"模块和"影友圈"页面提供了丰富的内容展示。

动态内容的文案设计颇具巧思,充满了胶片摄影的专业术语和圈内黑话:“一卷400度扫出来,颗粒感刚好是记忆的颜色”、“今晚红灯下放了两张8×10,边缘还在出汗”、“过期十年的反转片,赌出一片紫调”……这些文案不仅让静态数据显得生动真实,也增强了应用的社区氛围和专业感。

3.3 FILM_LIST:胶片机身列表

FILM_LIST数组包含8款经典胶片相机的数据,从入门级的海鸥4B(¥580/台)到典藏级的哈苏500C/M(¥12800/套)和徕卡M6 TTL(¥19800/台),覆盖了不同价位段和不同类型的胶片机身。每款相机都有详细的名称、工艺类型、价格、成色等级和热度值。

相机的选择也很有代表性:尼康FM2是经典的机械单反,禄来2.8F是传奇双反,奥林巴斯μ2是口袋机之王,康泰时T2是自动旁轴的巅峰……这些都是胶片爱好者耳熟能详的经典机型,能够迅速引起目标用户的共鸣和兴趣。

3.4 SCAN_LIST:冲扫订单列表

SCAN_LIST数组包含8笔冲扫订单数据,展示了冲扫全流程中的各种状态:已出片、扫描中、显影中、定影中、待入档、待付尾款等。订单内容涵盖了C-41彩负、D-76黑白、E-6反转等多种冲扫工艺,还有宽幅、增感、加急等特殊服务。

这些数据的设计非常贴近真实的冲扫业务流程。用户可以直观地看到自己的每一卷胶片处于什么阶段、完成了多少进度,这种透明化的进度展示是冲扫服务类应用的核心价值之一。通过statusColor和statusProgress函数,不同状态对应不同的颜色和进度值,形成了完整的状态可视化体系。

3.5 GEAR_LIST:耗材配件列表

GEAR_LIST数组包含8种暗房耗材和配件,从胶卷(柯达金200、富士C200、伊尔福HP5)到暗房设备(显影罐、D76显影粉、暗房红灯、防静电毛刷)再到存储用品(底片袋),覆盖了胶片摄影和暗房工作的主要耗材需求。

耗材列表的分类在pageGear页面中得到了进一步的组织:胶卷区、药剂区、归档区三大分类入口,帮助用户快速定位所需的耗材类型。这种分类浏览 + 列表详情的信息架构,是电商类应用的经典模式。

3.6 WEEK_CHART与FILM_CHART:图表数据

WEEK_CHART数组包含一周七天的冲扫量数据,用于首页的周冲扫量柱状图。数据呈现出明显的工作日低、周末高的规律(周一2卷,周日12卷,周六14卷),周末冲扫占比达到57%,这与胶片爱好者通常在周末集中拍摄和送扫的行为模式相符。

FILM_CHART数组包含五种底片题材的占比数据:街头纪实(32%)、人像写真(26%)、风光宽幅(20%)、静物微距(13%)、夜景长曝(9%)。每个题材都有对应的颜色,题材构成条形图使用Stack + Row的组合实现,底层是灰色的进度条背景,上层是带颜色的实际进度条,通过控制上层Row的宽度百分比来展示占比。

3.7 TYPE_LIST与COURSE_LIST:服务与课程数据

TYPE_LIST数组包含四项暗房服务类型:冲扫服务、暗房体验、机身寄卖、底片修复。其中前两项标记为"热门",在UI上会显示红色的"热门"标签。这四项服务构成了暗房工坊的核心业务矩阵,在首页以"在影语能玩什么"的四格入口形式展示。

COURSE_LIST数组包含五门胶片摄影课程,从入门到进阶依次排列:胶片摄影入门与测光(第1周,已完成)、C-41手冲全流程(第2周,88%)、黑白底片放大实操(第3周,54%)、底片数字化与归档(第4周,26%)、过期卷与增感玩法(第5周,6%)。课程的进度设计呈现出逐步递减的规律,符合在线课程学习的真实情况——越往后的课程完成度越低。

3.8 导航配置

NAV_LIST(底部主导航):包含四个导航项,分别是首页(🎞️)、市集(📷)、课堂(📚)、我的(👤)。这四个Tab构成了应用的主导航框架,对应用户的四类核心使用场景:浏览发现、购物交易、学习成长、个人中心。底部导航栏使用Row + ForEach的方式构建,每个导航项是一个Column,包含图标和文字。选中态的图标不透明(opacity: 1),文字加粗且使用暗房红色;未选中态图标半透明(opacity: 0.55),文字使用提示灰色。

SUB_NAV_LIST(首页子导航):包含七个内容Tab:精选、机身市集、我的冲扫、暗房工坊、胶片课堂、影友圈、耗材库。这七个Tab是首页内部的横向导航,采用左对齐下划线式的设计风格——与常见的居中平分式Tab不同,左对齐式Tab更适合Tab数量较多的情况,用户可以通过横向滚动查看所有选项。

子导航的实现方式非常精巧:使用Scroll包裹Row,设置水平滚动方向并隐藏滚动条。每个Tab项是一个Column,包含文字和下方的指示条。选中态的文字更大(14sp)、加粗、暗房红色,下方的指示条(18px宽、3px高的圆角矩形)完全显示;未选中态的文字更小(11sp)、常规字重、次要文本色,下方的指示条虽然存在但透明度为0。这种通过透明度控制指示条显隐的方式,保证了所有Tab项的高度一致,避免了选中态变化时的布局跳动。


四、纯函数工具集深度解析

4.1 纯函数在声明式UI中的价值

纯函数是指输入相同则输出相同、没有副作用的函数。在声明式UI开发中,纯函数是连接数据与视图的重要桥梁。它们将原始数据转换为UI可用的表现形式(如颜色、尺寸、位置等),同时保持了逻辑的可测试性和可复用性。"影语"应用中定义了十余组纯函数工具,涵盖了尺寸计算、颜色映射、特效计算三大类。

4.2 柱状图高度计算:barH函数

function barH(v: number, max: number): number {
  return Math.round(118 * v / max);
}

barH函数用于计算周冲扫量柱状图中每个柱子的高度。它接收当前值v和最大值max两个参数,返回按比例计算后的像素高度(基准高度为118px)。使用Math.round确保返回的是整数值,避免亚像素渲染带来的模糊问题。这个函数体现了数据可视化的基本原理:将数据值线性映射到视觉属性(这里是高度)。

4.3 热度颜色映射:filmColor函数

function filmColor(v: number): string {
  if (v > 9) {
    return '#B0413E';
  } else if (v > 6) {
    return '#C9A66B';
  }
  return '#D9A05B';
}

filmColor函数根据热度值返回对应的柱状图颜色。它采用三级分档的方式:热度大于9时使用暗房红色(最高热度),大于6时使用金色(中等热度),其余使用暗金色(较低热度)。这种分档着色方式比简单的渐变更具有辨识度,用户可以一眼看出哪些柱子属于高热度区间。

4.4 暗房红光晕呼吸特效函数组

暗房红光晕呼吸特效是应用中最具氛围感的视觉效果之一。它由四个函数协同计算,分别控制光晕的透明度、水平位置、垂直位置和半径大小:

lampA函数(透明度计算):0.05 + ((tick + i) % 6) * 0.03。透明度在0.05到0.20之间循环变化,变化周期为6个tick(约0.54秒),模拟暗房红灯的呼吸效果。每个光晕的偏移量i不同,因此它们的呼吸节奏相互错开,形成自然的随机感。

lampX函数(水平位置计算):60 + ((tick * 3 + i * 191) % 540)。光晕的水平位置在60px到600px之间缓慢漂移。tick乘以3使得水平漂移速度适中,i乘以191(一个质数)确保每个光晕的初始位置和运动轨迹各不相同。

lampY函数(垂直位置计算):80 + ((tick * 5 + i * 167) % 570)。光晕的垂直位置在80px到650px之间漂移。tick乘以5使得垂直方向的漂移速度比水平方向稍快,i乘以167(另一个质数)同样是为了增加随机性。

lampR函数(半径计算):20 + ((tick + i) % 5) * 10。光晕的半径在20px到60px之间周期性变化,周期为5个tick。半径的变化配合透明度的变化,共同营造出灯光呼吸的视觉效果——灯光变亮时半径也变大,变暗时半径缩小。

这四个函数共同作用,使得四个暗房红光晕在屏幕上缓缓漂移,同时呼吸般地明暗变化,营造出仿佛暗房中安全灯在空气中摇曳的神秘氛围。

4.5 胶片颗粒闪烁特效函数组

胶片颗粒闪烁特效是另一个重要的氛围元素,它模拟了胶片扫描后画面上的颗粒噪点效果。同样由四个函数控制:

grainA函数(颗粒透明度):0.1 + ((tick + i * 3) % 4) * 0.06。透明度在0.1到0.28之间变化,变化周期为4个tick。每个颗粒的i乘以3,使得它们的闪烁相位各不相同。

grainX函数(颗粒水平位置):20 + ((tick * 7 + i * 211) % 620)。颗粒在水平方向上快速漂移,tick乘以7使得水平运动速度较快。

grainY函数(颗粒垂直位置):30 + ((tick * 9 + i * 179) % 640)。颗粒在垂直方向上的运动速度更快(tick乘以9),这种水平和垂直方向的速度差异使得颗粒的运动轨迹呈现出斜向的动态感。

grainS函数(颗粒大小):2 + ((tick + i) % 3)。颗粒的大小在2px到4px之间变化,非常微小的尺寸变化增强了颗粒的真实感。

胶片颗粒特效使用6个颗粒元素,它们以不同的速度和轨迹在屏幕上闪烁漂移,模拟了胶片颗粒在扫描过程中的随机噪点效果。配合暗房红光晕的呼吸效果,共同构建了应用独特的暗房视觉氛围。

4.6 状态颜色与进度映射

statusColor函数:根据冲扫订单的状态字符串返回对应的状态颜色。已出片 → 绿色(成功),扫描中 → 金色(进行中),显影中 → 暗房红(核心工序),定影中 → 暗金色(中间工序),其他状态 → 提示灰色(待处理)。这种颜色与状态的对应关系,让用户能够通过颜色快速识别订单状态。

statusProgress函数:根据状态返回对应的进度百分比。已出片 → 100%,扫描中 → 78%,显影中 → 60%,定影中 → 42%,默认 → 12%。这些数值反映了冲扫工艺流程的大致阶段划分,为进度条提供了合理的默认值。

4.7 机身标签颜色函数

filmTagColor函数:根据机身名称中包含的品牌关键词返回对应的标签颜色。徕卡/哈苏 → 暗房红(顶级品牌),禄来/海鸥 → 金色(经典双反),尼康/宾得 → 绿色(日系单反),其他 → 暗金色。这种基于品牌的颜色分类,使得用户在浏览机身列表时能够快速识别品牌阵营。

levelColor函数:根据成色等级返回对应的标签颜色。入门款 → 绿色,成色级 → 金色,典藏级 → 暗金色,大师级 → 橘红色。等级与颜色的对应关系形成了一个从低到高的色彩序列,帮助用户直观判断机身的价值等级。


五、组件状态管理与生命周期

5.1 组件级状态变量

PageFilmDarkroom组件中定义了丰富的@State状态变量,这些变量是驱动整个界面交互的核心状态:

导航状态:mainTab(底部导航当前索引)和subTab(首页子导航当前索引)共同决定了当前显示的页面内容。mainTab的取值范围是0-3,对应首页/市集/课堂/我的四个主Tab;subTab的取值范围是0-6,对应精选/机身市集/我的冲扫/暗房工坊/胶片课堂/影友圈/耗材库七个子Tab。

特效状态:tick和glow是驱动特效动画的状态变量。tick是特效动画的时间计数器,每90毫秒自增1,作为所有特效函数的输入参数。glow是呼吸效果的辅助计数器,在0和1之间循环。通过setInterval定时更新tick的值,触发UI的重新渲染,从而实现动画效果。

弹框状态:addOpen、editOpen、delOpen三个布尔值分别控制新增弹框、编辑弹框和删除确认弹框的显隐。这种每个弹框对应一个布尔状态的模式是声明式UI中弹框管理的常用方式,弹框的显示与否完全由状态值决定,状态为true则显示,为false则隐藏。

弹框数据状态:editIdx(当前编辑的冲扫单索引)、delTarget(删除操作的目标类型,scan或course)、editName/editStyle/editNote(编辑弹框中的三个输入值)、addTitle/addContent(新增弹框中的两个输入值)。这些变量用于在弹框打开时暂存用户输入的数据,待用户确认后再同步到主数据列表中。

列表数据状态:postList和scanList是两个核心数据列表,使用@State装饰器修饰,初始值分别为POST_LIST和SCAN_LIST的副本。将静态数据赋值给@State变量,使得列表数据可以被动态修改(新增、编辑、删除),同时触发UI的自动更新。

定时器引用:fxTimer是特效定时器的ID引用,类型为number,初始值为-1。这个变量用于在组件销毁时清除定时器,防止内存泄漏。

5.2 生命周期方法

aboutToAppear方法:在组件即将出现时调用。在这个方法中,应用启动了特效定时器,使用setInterval每90毫秒执行一次回调,将tick加1,glow在0和1之间循环。90毫秒的间隔意味着特效动画的帧率约为11fps,这个帧率对于营造氛围的背景特效来说足够流畅,同时又不会消耗过多的系统资源。

aboutToDisappear方法:在组件即将消失时调用。在这个方法中,应用检查fxTimer是否有效,如果大于0则调用clearInterval清除定时器,并将fxTimer重置为-1。这是一个非常重要的内存保护措施,确保当用户离开页面时,后台的定时器不会继续运行,避免资源泄漏和性能损耗。

5.3 交互方法

openAdd方法:打开新增(发影语)弹框。在打开之前,先将addTitle和addContent清空,确保每次打开弹框都是空白的初始状态。

openEdit方法:打开编辑冲扫单弹框。它接收一个索引参数,先检查索引的合法性(在数组范围内),然后将对应冲扫单的name、status、item字段分别赋值给editName、editStyle、editNote三个编辑状态变量,最后将editOpen设为true打开弹框。这种先加载数据再打开弹框的模式,实现了编辑功能的数据回填。

openDelA和openDelB方法:分别用于打开取消冲扫和退出课程的删除确认弹框。它们通过设置不同的delTarget值(‘scan’或’course’)来区分删除操作的类型,从而在确认删除时执行不同的逻辑。

doAdd方法:执行新增发布操作。它首先检查addTitle是否有内容(长度大于0),如果有则使用unshift方法在postList数组的开头插入一条新的PostItem记录。新动态的id设为999,昵称设为"我的影语",头像用🎞️表情,时间设为"刚刚",点赞数为0。操作完成后关闭弹框。使用unshift而不是push,确保新发布的动态出现在列表的最顶部。

doEdit方法:执行编辑保存操作。它使用splice方法将editIdx位置的元素替换为一个新的ScanItem实例。新实例保留了原有的id,name/status/item字段使用编辑后的值,progress重置为40。操作完成后关闭弹框。

doDel方法:执行删除操作。根据delTarget的值决定删除哪个列表的首条记录:如果是’scan’则删除scanList的第一条,如果是’course’则删除postList的第一条。删除操作都使用splice(0, 1)方法,即移除数组的第一个元素。操作完成后关闭弹框。


六、特效层与视觉氛围构建

6.1 fxLayer特效层设计

fxLayer是应用中最具特色的@Builder构建器之一,它负责渲染暗房红光晕和胶片颗粒两种氛围特效。特效层使用Stack作为容器,内部包含两个ForEach循环,分别渲染四个暗房红光晕和六个胶片颗粒。

每个暗房红光晕是一个Column组件,通过设置width、height和borderRadius(值等于半径)形成圆形。背景色使用COLORS.darkroom(暗房红特效色),透明度通过lampA函数动态计算。位置则通过translate属性的x和y偏移来控制,由lampX和lampY函数计算得出。

每个胶片颗粒也是一个Column组件,但尺寸要小得多(2-4像素),同样通过borderRadius形成圆形。背景色使用COLORS.textHint(提示灰色),透明度通过grainA函数计算。位置通过grainX和grainY函数计算,大小通过grainS函数计算。

6.2 hitTestBehavior穿透机制

特效层最关键的技术细节是.hitTestBehavior(HitTestMode.None)这个属性。如果没有这个设置,覆盖在整个页面上方的特效层会拦截所有的触摸事件,导致用户无法与下方的内容进行交互。通过设置hitTestBehavior为HitTestMode.None,特效层就变成了"透传"的——它只负责视觉呈现,不参与触摸事件的响应,所有的点击、滑动等操作都会穿透特效层,直接作用于下方的内容层。

这是一个非常精妙的设计:它将视觉氛围与交互逻辑完全解耦。特效层可以自由地进行各种视觉效果的渲染,而完全不用担心影响用户的操作体验。同时,由于特效层位于内容层下方(在Stack的子元素顺序中,fxLayer在mainContent之前),它也不会遮挡内容的显示,而是作为背景氛围存在。

6.3 特效动画的实现原理

特效动画的实现利用了ArkUI声明式渲染的特性:当tick状态变量变化时,所有依赖tick的UI属性(透明度、位置、大小等)都会重新计算,组件也会相应地更新渲染。通过setInterval定时改变tick的值,就形成了连续的动画效果。

这种基于状态驱动的动画实现方式,与传统的逐帧绘制动画有着本质的不同。它不需要手动操作DOM或Canvas,也不需要使用专门的动画API,只需要改变状态变量,框架会自动处理UI的更新。这种方式简洁优雅,与声明式UI的开发范式完全一致。


七、头部与子导航组件

7.1 header头部构建器

header构建器负责渲染页面顶部的头部区域,包含四个主要部分:搜索条与购物车、冲扫进度卡、限时冲扫横幅、数据四格。这四个部分垂直排列在一个Column中,形成了内容丰富的头部区域。

搜索条与购物车:最顶部是一个Row,左侧是应用logo(胶片emoji),中间是搜索框(含搜索图标和提示文字"搜一搜「FM2」「C-41 冲扫」“),右侧是购物车图标(带红色角标显示"5”)。搜索框使用cardBg色作为背景,圆角18px,呈现出胶囊形状的搜索栏样式。购物车使用Stack实现角标效果——底图是购物车emoji,上方叠加一个红色背景的白色数字角标,通过translate偏移到右上角。

冲扫进度卡:这是头部最醒目的区域,使用primary暗房红作为背景色,白色文字。左侧显示冲扫状态概要(“3卷显影中·2卷排队扫描”)和详细说明(“富士业务100·标准流程·明早出片”),以及两个标签("药水新鲜"用暗房红底深棕字,"今晚暗房体验夜"用浅粉字)。右侧是"查看进度"按钮,暗房红底深棕字。点击这个按钮会打开编辑冲扫单的弹框。

限时冲扫横幅:使用cardBg色背景,左侧是试剂瓶emoji图标,中间是活动标题(“首卷冲扫8折·新客专享”)和副标题(“含扫描底片袋·48小时出片”),右侧是金色的"预约"按钮。点击预约按钮会打开发影语弹框。

数据四格:四个等宽的小卡片并排展示,分别显示在途胶卷(5卷,暗房红色)、累计冲扫(212卷,金色)、收藏机身(7台,绿色)、影币(¥64,暗金色)。每个卡片都是一个Column,上方是灰色的标签文字,下方是彩色的数值。四个数据使用不同的颜色,既美观又便于区分。

7.2 subNav子导航构建器

subNav构建器负责渲染首页的横向子导航。它使用Scroll组件包裹Row,实现横向滚动的Tab栏。滚动方向设置为水平(ScrollDirection.Horizontal),滚动条设为隐藏(BarState.Off),使得导航栏看起来更加简洁。

每个Tab项是一个Column,包含文字和下方的指示条。选中态与未选中态的差异体现在三个维度:字号(14sp vs 11sp)、字重(Bold vs Normal)、颜色(暗房红 vs 次要文本色)。指示条是一个18px宽、3px高的圆角矩形,选中态显示为暗房红色,未选中态虽然结构相同但透明度为0。

子导航采用左对齐的布局方式(Row默认从左开始排列),而不是常见的平均分配宽度。这种设计的优势在于:当Tab数量较多时(这里有7个),如果平均分配,每个Tab的宽度会很窄,文字容易显示不全;而左对齐 + 横向滚动的方式,每个Tab可以有充足的空间,用户可以通过滑动查看更多选项。


八、七大内容页面Builder详解

8.1 pageFeatured:精选首页

精选首页是应用的默认页面,内容最为丰富,包含五个主要模块:玩法四格、周冲扫柱状图、本月底片题材构成、人气胶片机身双列、影友动态。

玩法四格:以"在影语能玩什么"为标题,下方是四个等宽的服务入口卡片,分别展示TYPE_LIST中的四项服务(冲扫服务、暗房体验、机身寄卖、底片修复)。每个卡片包含图标、名称、描述和热门标签,垂直居中排列。

周冲扫柱状图:标题为"工坊本周冲扫量",右上角标注"单位 卷"。柱状图使用Row + ForEach实现,七个柱子等宽排列,底部对齐(alignItems: VerticalAlign.Bottom)。每个柱子的高度由barH函数根据数据值计算,颜色由filmColor函数决定。柱子下方显示数值和星期标签。底部还有一行汇总信息:“周末冲扫占比 57%”。

题材构成条形图:标题为"本月底片题材构成"。每个题材是一行,左侧是题材名称(固定70px宽),中间是Stack实现的进度条(底层灰色背景,上层彩色进度),右侧是百分比数值(右对齐,固定44px宽)。颜色使用每个题材自己定义的color字段。

人气胶片机身双列:标题为"人气胶片机身",右侧有"查看全部"链接。机身列表使用双列网格布局,实现方式是遍历FILM_LIST,当索引为偶数时渲染一行(Row),行内包含索引和索引+1两个机身卡片。每个卡片包含图标、等级标签、名称、价格和热度信息。

影友动态:标题为"影友动态",右侧有"发影语"按钮(点击打开发布弹框)。列表显示最新的3条动态(使用slice(0, 3)截取),每条动态是一行,左侧是头像emoji,右侧是昵称、时间、正文和点赞数。

8.2 pageFilms:机身市集

机身市集页面展示完整的胶片机身列表,与精选页的双列布局不同,这里采用单列列表的形式,每个机身卡片的信息更加详细。

每个机身卡片包含:左侧的彩色竖条(颜色由filmTagColor根据品牌决定)、机身名称和等级标签、价格与工艺信息、热度进度条(与题材构成的进度条实现方式相同),以及底部的关注度数值和"加入购物车"按钮。

单列列表的优势在于可以展示更丰富的信息,用户可以在一个页面内浏览更多机身的详细参数。彩色竖条是一个很有设计感的细节,它不仅起到了品牌颜色标识的作用,也为列表增添了视觉层次。

8.3 pageScans:我的冲扫

我的冲扫页面展示用户的冲扫订单列表。每个订单卡片包含:胶卷图标、订单名称和状态标签、冲扫工艺信息、冲扫进度条、进度百分比和"编辑冲扫单"按钮。

状态标签的颜色由statusColor函数根据状态值动态决定,进度条的颜色和宽度也分别由statusColor和statusProgress函数决定。点击"编辑冲扫单"会打开编辑弹框,用户可以修改订单名称、状态和备注信息。

这个页面的核心价值在于让用户实时跟踪自己的冲扫订单进度,了解每一卷胶片处于什么阶段。进度条和状态标签的可视化设计,使得抽象的冲扫流程变得直观可感。

8.4 pageWorkshop:暗房工坊

暗房工坊页面是应用中内容最丰富的页面之一,包含暗房状态概览、热门机身排行榜和耗材速购三个部分。

暗房状态四格:顶部四个等宽的小卡片,分别显示今晚工位(放大台R2,暗房红色)、药水温度(38.0℃,金色)、工序(显影,暗金色)、今晚工时(4.5h,绿色)。这四个数据模拟了暗房实时工作状态,给用户以身临其境的感觉。

热门机身排行榜:显示FILM_LIST中前5个机身的排名列表。每个条目左侧是排名数字(前三名使用暗房红色,其余使用灰色),中间是机身名称和工艺价格信息,右侧是关注度数值。这种排行榜形式能够激发用户的探索欲和收藏欲。

耗材速购双列:标题为"耗材速购",下方是双列布局的耗材卡片,展示GEAR_LIST中的所有耗材。每个耗材卡片包含图标、名称、描述和"加购"链接。双列布局在有限的空间内展示了更多的耗材选项。

8.5 pageCourse:胶片课堂

胶片课堂页面展示课程列表,每门课程是一个卡片,包含课程标题、学习周次、进度条和已学百分比。进度条的颜色使用课程自己定义的color字段,五门课程的进度从100%递减到6%,形成了一个学习进度逐渐降低的视觉序列。

页面底部有一个红色的"退课"按钮,点击会打开删除确认弹框(delTarget设为’course’)。退课按钮使用danger色(橘红色),是整个页面中最醒目的操作入口。

值得注意的是,按钮文字的实现方式比较特殊——使用一个空的Button组件作为背景,然后通过margin负偏移将Text文字叠加在按钮上方。这种实现方式虽然略显hacky,但在ArkUI的某些版本中可以避免Button组件对文字样式的限制。

8.6 pageCircle:影友圈

影友圈页面展示完整的影友动态列表,与精选页只显示3条不同,这里展示全部8条动态。每条动态的布局与精选页类似,但字号稍大,信息更完整,底部还增加了"💬 回复"的互动入口。

页面右上角有"发影语"按钮,点击打开发布影语的弹框。用户可以输入标题和详细内容,发布后新动态会插入到列表顶部。

8.7 pageGear:耗材库

耗材库页面是耗材购物的主页面,包含分类入口和耗材列表两部分。

分类入口:顶部三个等宽的分类卡片,分别是胶卷区(🎞️)、药剂区(🧪)、归档区(🗂️),每个分类包含图标、名称和简短描述。分类入口帮助用户快速定位到自己感兴趣的耗材类别。

耗材列表:完整的耗材列表,使用单列布局。每个耗材条目是一行,左侧是图标,中间是名称和描述,右侧是"加购"按钮(暗房红底白字)。

页面底部同样有一个"取消冲扫"按钮(danger色),点击打开删除确认弹框(delTarget设为’scan’)。这个按钮模拟了取消冲扫订单的操作入口。

8.8 pageMine:我的页面

我的页面是用户的个人中心,包含用户信息卡片、冲扫档案和影语记录三个模块。

用户信息卡:顶部是用户头像(胶片emoji)和用户信息(昵称"影语胶片人",会员等级、冲扫数量、收藏数量、打卡天数)。卡片使用cardBg背景,圆角14px。

我的冲扫档案:显示冲扫订单数量和最近3条订单的简要信息(名称和状态)。状态颜色同样由statusColor函数决定。

我的影语:显示发布的影语数量和最近3条动态的简要信息(昵称和时间)。

我的页面是用户个人数据的汇总展示中心,让用户可以一目了然地看到自己在应用中的活动记录和成就。


九、弹框组件体系

9.1 弹框架构设计

应用设计了三套弹框组件,分别对应新增(发影语)、编辑(编辑冲扫单)和删除(取消冲扫/退课)三种操作场景。每套弹框都由两部分组成:modalOverlay(遮罩层)和对应的弹框主体(addModalBody/editModalBody/delModalBody)。

弹框的整体结构使用Stack实现:底层是半透明的黑色遮罩(modalOverlay),上层是白色(cardBg色)的弹框内容。遮罩层的透明度设为0.7,既能将用户的注意力聚焦到弹框内容上,又能隐约看到背后的页面内容,保持场景的连贯性。

9.2 modalOverlay遮罩层

modalOverlay是一个全屏的Stack,内部是一个黑色的Column,透明度0.7。点击遮罩层会将所有三个弹框的开关状态设为false,从而关闭弹框。这种点击遮罩关闭弹框的交互模式是移动端应用的通用规范,符合用户的操作习惯。

9.3 addModalBody:发影语弹框

发影语弹框的标题是"发布影语",包含两个输入控件:

TextInput(标题输入):单行文本输入框,placeholder为"一句话暗房日常…",输入的值实时同步到addTitle状态变量。

TextArea(内容输入):多行文本输入框,placeholder为"补充细节:机身、胶卷、冲洗心得…",高度90px,输入的值实时同步到addContent状态变量。

底部是两个按钮:"取消"按钮(bg色背景,次要文字色)和"发布"按钮(暗房红底白字)。取消按钮关闭弹框且不保存,发布按钮执行doAdd方法将新动态加入列表并关闭弹框。

9.4 editModalBody:编辑冲扫单弹框

编辑冲扫单弹框的标题是"编辑冲扫单",包含三个输入控件:

冲扫名称输入:TextInput,placeholder为"冲扫名称",绑定editName变量。

状态输入:TextInput,placeholder为"状态(如 显影中)",绑定editStyle变量。这里使用文本输入而非下拉选择,是一种简化的实现方式,在实际产品中可能会替换为选择器。

内容备注输入:TextArea,placeholder为"冲扫内容与备注说明",高度70px,绑定editNote变量。

底部同样是"取消"和"保存"两个按钮,保存按钮执行doEdit方法。

9.5 delModalBody:删除确认弹框

删除确认弹框是一个二次确认对话框,用于取消冲扫订单或退出课程。它的标题和内容会根据delTarget的值动态变化:如果是’scan’,标题为"取消冲扫订单",内容为"将取消首笔冲扫订单并按工序退费,确认执行?“;如果是’course’,标题为"退出胶片课堂”,内容为"将退出首门进行中的课程,确认执行?"。

底部是"取消"和"确认"两个按钮。与其他弹框不同的是,确认按钮使用danger色(橘红色)而非主色,因为删除操作是危险操作,需要用警示性的颜色来提醒用户谨慎操作。


十、底部导航与主内容区

10.1 bottomBar底部导航栏

bottomBar构建器负责渲染底部的主导航栏。它使用Row布局,四个导航项等宽排列(每个的layoutWeight为1)。每个导航项是一个Column,包含图标emoji和文字标签。

选中态(mainTab === idx)的图标不透明(opacity: 1),文字加粗且使用暗房红色;未选中态的图标半透明(opacity: 0.55),文字使用提示灰色。通过opacity和fontWeight + fontColor的双重变化,提供了清晰的选中/未选中视觉反馈。

底部导航栏的高度为56px,背景色为cardBg,顶部左右各16px的圆角(borderRadius设置topLeft和topRight)。这种顶部圆角的设计使得底部导航栏看起来像是悬浮在内容上方的独立面板,增加了界面的层次感。

10.2 mainContent主内容区

mainContent构建器是应用内容的主容器,它根据mainTab和subTab的值来决定显示哪个页面。

当mainTab为0(首页)时:显示header头部、subNav子导航栏,以及根据subTab值选择的七个内容页面之一(使用if-else if-else链判断)。内容区域包裹在Scroll中,支持垂直滚动,隐藏滚动条。内容区域的左右padding为14px,底部padding为20px。

当mainTab为1(市集)时:直接显示pageFilms机身市集页面(全屏列表),没有header和subNav。

当mainTab为2(课堂)时:直接显示pageCourse胶片课堂页面。

当mainTab为3(我的)时:直接显示pageMine我的页面。

这种设计体现了导航架构的层次性:首页内部有更细的子导航(7个内容Tab),而市集、课堂、我的三个主Tab直接展示对应内容。双层导航的结构既保证了信息架构的清晰,又避免了层级过深导致的操作繁琐。


十一、build()主构建函数与页面整体架构

11.1 四层Stack结构

build函数是整个页面的入口,它使用Stack作为最外层容器,构建了一个四层叠加的页面结构:

第一层(最底层):背景层。一个全屏的Column,背景色为COLORS.bg(深棕褐色)。这是整个页面的底色。

第二层:特效层。调用fxLayer()构建器,渲染暗房红光晕和胶片颗粒特效。特效层在背景层之上、内容层之下,因此特效会透过内容层的透明区域显示出来,营造出氛围光效从背景中散发的感觉。

第三层:内容层。一个全屏的Column,包含mainContent(主内容区域)和bottomBar(底部导航栏)。这是用户实际交互的主要界面。

第四层(最顶层):弹框层。根据addOpen、editOpen、delOpen三个状态变量的值,条件渲染对应的弹框。每个弹框也是一个Stack结构,包含遮罩层和弹框主体,弹框主体的zIndex设为999,确保其位于最顶层。

这种四层结构的设计非常清晰地分离了不同层级的职责:背景层负责底色,特效层负责氛围,内容层负责交互,弹框层负责模态操作。每一层都有明确的视觉层级和功能定位。

11.2 条件渲染与弹框管理

弹框的显隐使用条件渲染(if语句)来控制。当弹框状态变量为true时,对应的弹框结构被渲染到Stack中;为false时则不渲染。这种声明式的弹框管理方式,比命令式的show/hide方法调用更加直观和可预测。

每个弹框的宽度为屏幕的88%,最大高度为屏幕的80%(constraintSize设置maxHeight),圆角16px。这些尺寸参数确保了弹框在不同尺寸的屏幕上都有合适的显示比例,不会出现弹框过大或过小的问题。


十二、应用架构流程图

0 首页

0 精选

1 机身市集

2 我的冲扫

3 暗房工坊

4 胶片课堂

5 影友圈

6 耗材库

1 市集

2 课堂

3 我的

发影语

编辑冲扫单

取消/退课

用户交互

mainTab 底部导航

header 头部区域

subNav 子导航栏

pageFeatured

pageFilms

pageScans

pageWorkshop

pageCourse

pageCircle

pageGear

pageMine

数据渲染

弹框触发

addModal 新增弹框

editModal 编辑弹框

delModal 删除弹框

doAdd 新增数据

doEdit 更新数据

doDel 删除数据

postList 更新

scanList 更新

UI 自动刷新

setInterval 90ms

tick++

fxLayer 特效更新

暗房红光晕呼吸

胶片颗粒闪烁

ArkUI 渲染管线

用户看到更新后的界面


十三、数据模型对比分析

13.1 四大核心模型对比表

模型名称装饰器字段数核心标识列表名称可编辑主要页面颜色映射函数
PostItem@Observed6idpostList是(新增/删除)精选/影友圈/我的
FilmItem@Observed6idFILM_LIST精选/市集/工坊filmTagColor + levelColor
ScanItem@Observed5idscanList是(编辑/删除)我的冲扫/我的statusColor + statusProgress
GearItem@Observed4idGEAR_LIST暗房工坊/耗材库

13.2 模型设计特点分析

PostItem模型:代表用户生成内容(UGC),支持新增和删除操作,是社区功能的核心数据载体。它的字段围绕社交动态的基本要素展开——谁(nick/avatar)、说了什么(text)、什么时候(time)、获得了多少互动(likes)。

FilmItem模型:代表商品内容,当前实现中是只读的静态数据,但设计为@Observed为后续的动态更新预留了能力。它包含了电商商品的核心要素——名称、规格、价格、等级、热度。两个颜色映射函数(filmTagColor和levelColor)分别从品牌和等级两个维度提供颜色编码。

ScanItem模型:代表服务订单,支持编辑和删除操作,是冲扫业务流程的核心数据模型。它的特色在于status和progress两个字段的配合,以及statusColor和statusProgress两个函数提供的状态可视化能力。冲扫订单的状态流转是整个服务体验的关键路径。

GearItem模型:代表耗材商品,结构最简单(只有4个字段),当前实现中也是只读的。它使用emoji作为图标,简洁有趣,符合应用的轻量级定位。

13.3 @Observed装饰器的使用策略

四个核心模型都使用了@Observed装饰器,这体现了一种"全部可观察"的设计策略。即使某些模型在当前版本中并没有动态修改的需求,仍然将其设计为可观察的,这样做的好处是:

  1. 一致性:所有模型的设计风格保持一致,降低了开发者的认知负担。
  2. 扩展性:当未来需要增加动态修改功能时,不需要修改模型的定义,直接使用即可。
  3. 性能影响可控:@Observed的开销主要体现在属性访问的代理包装上,对于数据量不大的应用来说,这种性能影响可以忽略不计。

当然,对于大规模应用来说,过度使用@Observed可能会带来不必要的性能开销,这时候需要根据实际情况进行取舍。但对于"影语"这样的中等规模应用,"全部可观察"策略是合理的选择。


十四、技术总结与设计思考

14.1 声明式UI开发的优势体现

通过对"影语"应用的深度剖析,我们可以清晰地看到声明式UI开发范式带来的诸多优势:

代码可读性强:UI结构与数据结构高度对应,阅读代码时可以直观地理解"什么样的数据对应什么样的界面"。嵌套的组件结构清晰地表达了UI的层级关系。

状态管理直观:@State、@Observed等装饰器提供了明确的状态标记,数据流向清晰可见。状态变化驱动UI更新的模式,使得开发者不需要手动操作DOM,大大降低了心智负担。

组件复用便捷:@Builder装饰器使得UI片段的抽取和复用变得非常简单。应用中十几个@Builder构建器各司其职,既保证了代码的模块化,又避免了组件化的过度设计。

动画实现优雅:特效动画通过状态变量驱动的方式实现,与UI渲染机制天然融合。不需要专门的动画库或复杂的动画API,只需要改变状态,框架会自动处理过渡和渲染。

14.2 暗色主题设计的关键要素

"影语"应用的暗房红金暗色主题是一次非常成功的暗色主题设计实践。它的成功之处在于:

色彩系统完整:从背景到文本、从主色到辅助色、从成功到危险,构建了一套完整的十六色语义化色彩系统。每种颜色都有明确的使用场景和语义定位。

色调统一和谐:所有颜色都带有暖色调的倾向——背景是暖棕、文本是暖白、主色是暖红、辅助色是暖金。这种统一的暖色调使得整个界面在暗色模式下依然给人温暖的感觉,避免了冷色调暗色主题常有的冰冷感。

对比度经过调校:三级文本色(textPrimary/textSecondary/textHint)与背景的对比度经过精心设计,既保证了可读性,又形成了清晰的视觉层级。不会出现对比度太高导致刺眼,也不会太低导致看不清。

主题色与业务高度融合:暗房红和金色胶片的配色方案,与"胶片冲扫工坊"的业务主题高度契合,色彩本身就在讲述品牌故事,而不是孤立的视觉装饰。

14.3 特效设计的技术智慧

暗房红光晕和胶片颗粒两种特效是应用的点睛之笔,它们的设计体现了深刻的技术智慧:

透传设计:hitTestBehavior(HitTestMode.None)的使用,使得特效层完全不影响用户的交互操作。视觉效果与交互逻辑的完美解耦,是这个设计最精妙的地方。

低帧率策略:90ms的更新间隔(约11fps)对于背景氛围特效来说足够了,同时又非常节省系统资源。如果追求60fps的高帧率,反而会因为特效层的频繁重绘而消耗大量CPU/GPU资源,影响主界面的流畅度。

数学函数驱动:所有特效参数都通过纯函数计算得出,使用tick作为时间变量,使用质数偏移确保随机性。这种方式比预定义关键帧动画更加灵活,也更节省存储空间。

多层次氛围营造:暗房红光晕(大尺度、低频、暖色)和胶片颗粒(小尺度、高频、灰色)两种特效分别作用于不同的视觉尺度,共同营造出丰富而立体的暗房氛围。单一特效容易显得单调,多层次的组合才能产生沉浸感。

14.4 导航架构的分层设计

应用采用了"底部4主Tab + 首页7子Tab"的双层导航架构,这种设计有其合理性:

信息架构清晰:四个主Tab对应用户的四类核心需求(浏览/购物/学习/个人中心),七个子Tab对应首页内的细分内容。层级关系清晰,用户容易理解和记忆。

操作效率平衡:常用功能(首页各子Tab)只需一次点击即可切换,非常便捷;不常用的功能(市集/课堂/我的)放在底部Tab,也只需一次点击。整体操作效率很高。

可扩展性好:子Tab采用横向滚动的方式,理论上可以支持更多的内容分类,未来增加新的内容模块时,只需要在SUB_NAV_LIST中增加一项即可,不需要改变整体的导航架构。

14.5 弹框设计的规范性

三套弹框(新增/编辑/删除)的设计遵循了一致的设计规范:

结构统一:都是"标题 + 内容区 + 底部操作按钮"的三段式结构,用户在使用过一次后就能快速理解其他弹框的操作方式。

视觉一致:都使用cardBg作为背景色,相同的圆角和间距,按钮的样式也保持一致。视觉上的一致性增强了产品的专业感。

操作可逆:每个弹框都有"取消"按钮,用户可以随时放弃操作,关闭弹框。遮罩层点击关闭也提供了便捷的退出方式。

危险操作警示:删除确认弹框使用danger色的确认按钮,并且有明确的文案说明操作后果,符合危险操作二次确认的设计原则。

14.6 纯函数工具的设计价值

应用中大量使用纯函数工具来处理数据到视图的映射,这种模式的价值在于:

逻辑复用:同一个颜色映射逻辑可以在多个页面中复用,避免重复代码。例如statusColor函数在pageScans和pageMine中都被使用。

可测试性:纯函数的输入输出关系明确,非常容易编写单元测试。对于复杂的颜色映射和计算逻辑,单元测试可以有效保证正确性。

关注点分离:UI代码专注于结构和布局,数据转换逻辑专注于计算。两者通过函数调用连接,各自的职责清晰。

易于维护:当需要修改颜色映射规则时,只需要修改对应的函数,不需要在多个UI组件中逐一查找和修改。

14.7 可改进的方向

尽管"影语"应用的整体设计非常出色,但仍有一些可以改进和优化的方向:

动画流畅度:当前的特效动画使用setInterval + 状态更新的方式实现,每帧都会触发整个组件树的重新渲染。对于更复杂的动画场景,可以考虑使用ArkUI提供的animateTo显式动画API,以获得更好的性能和更流畅的动画效果。

组件化程度:当前所有UI都封装在一个组件的@Builder方法中,对于简单应用来说这种方式足够高效。但如果应用规模继续扩大,可以考虑将一些复用度高的UI片段(如机身卡片、动态卡片、进度条等)抽取为独立的自定义组件,进一步提升复用性和可维护性。

数据持久化:当前所有数据都存储在内存中,应用重启后会恢复为初始数据。在实际产品中,需要接入本地数据库或后端服务,实现数据的持久化存储和云端同步。

表单验证:弹框中的输入框目前缺少输入验证逻辑,用户可以输入空内容或非法内容。在生产环境中,需要增加表单验证,确保数据的合法性和完整性。

列表虚拟化:当数据量很大时,ForEach会渲染所有列表项,可能导致性能问题。对于长列表场景,可以使用List组件配合LazyForEach实现列表的虚拟化渲染,只渲染可视区域内的列表项。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 场景:胶片机身市集 / 冲扫预约 / 暗房工坊 / 胶片摄影社区
// 底部4主tab(首页/市集/课堂/我的)+ 首页7内容tab(左对齐下划线式)
// 特效:暗房红灯光晕呼吸 + 胶片颗粒闪烁(特效层位于底层,不遮挡点击)
// 弹框:新增(发影语) / 编辑(编辑冲扫单) / 删除(取消冲扫·退课)

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

const COLORS: ColorPalette = {
  primary: '#B0413E',
  primaryLight: '#5A3432',
  primaryDark: '#8A2F2C',
  accent: '#C9A66B',
  accentLight: '#4A3E2C',
  bg: '#1C1917',
  cardBg: '#2A2622',
  textPrimary: '#EDE6DB',
  textSecondary: '#B5A897',
  textHint: '#7D7264',
  border: '#3A342E',
  success: '#8FAE7E',
  warning: '#D9A05B',
  danger: '#C76B5A',
  white: '#FFFFFF',
  darkroom: '#D95C4F'
};

// ============ 影友动态数据模型 ============
@Observed
export class PostItem {
  id: number = 0
  nick: string = ''
  avatar: string = ''
  text: string = ''
  time: string = ''
  likes: number = 0
  constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
    this.time = time; this.likes = likes
  }
}

// ============ 胶片机身数据模型 ============
@Observed
export class FilmItem {
  id: number = 0
  name: string = ''
  craft: string = ''
  price: string = ''
  level: string = ''
  hot: number = 0
  constructor(id: number, name: string, craft: string, price: string, level: string, hot: number) {
    this.id = id; this.name = name; this.craft = craft; this.price = price
    this.level = level; this.hot = hot
  }
}

// ============ 冲扫订单数据模型 ============
@Observed
export class ScanItem {
  id: number = 0
  name: string = ''
  item: string = ''
  status: string = ''
  progress: number = 0
  constructor(id: number, name: string, item: string, status: string, progress: number) {
    this.id = id; this.name = name; this.item = item; this.status = status
    this.progress = progress
  }
}

// ============ 耗材数据模型 ============
@Observed
export class GearItem {
  id: number = 0
  name: string = ''
  desc: string = ''
  icon: string = ''
  constructor(id: number, name: string, desc: string, icon: string) {
    this.id = id; this.name = name; this.desc = desc; this.icon = icon
  }
}

// ============ 图表与课程接口 ============
interface WeekChartItem {
  label: string;
  value: number;
}

interface FilmChartItem {
  label: string;
  value: number;
  color: string;
}

interface CourseItem {
  title: string;
  week: string;
  progress: number;
  color: string;
}

interface DarkTypeItem {
  name: string;
  desc: string;
  icon: string;
  hot: boolean;
}

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '胶片不死党', '🎞️', '一卷 400 度扫出来,颗粒感刚好是记忆的颜色。', '7分钟前', 128),
  new PostItem(2, '暗房常驻民', '🪔', '今晚红灯下放了两张 8×10,边缘还在出汗。', '21分钟前', 106),
  new PostItem(3, '二手淘机身', '📷', '三百块收的旁轴,快门居然一点不拖。', '39分钟前', 94),
  new PostItem(4, '影语主理人', '🔴', '周四暗房体验夜还有 3 个位,冲卷带走。', '1小时前', 87),
  new PostItem(5, '过期卷赌徒', '🎲', '过期十年的反转片,赌出一片紫调。', '2小时前', 79),
  new PostItem(6, '宽幅爱好者', '🌄', '哈林宽幅第一卷,扫出来像老电影截图。', '3小时前', 71),
  new PostItem(7, '黑白放大师', '⚫', '乐凯黑白配 D76,层次比想象中细腻。', '5小时前', 64),
  new PostItem(8, '胶片收纳控', '🗂️', '第 100 张底片袋编目完成,强迫症满足。', '昨天', 57)
];

const FILM_LIST: FilmItem[] = [
  new FilmItem(1, '尼康 FM2 · 黑漆', '135 旁轴单反', '¥2380/台', '成色 A', 96),
  new FilmItem(2, '禄来 2.8F · 双反', '120 双镜头', '¥9800/台', '典藏级', 92),
  new FilmItem(3, '奥林巴斯 μ2', '口袋机 35F2.8', '¥1180/台', '成色 B+', 88),
  new FilmItem(4, '哈苏 500C/M', '120 中画幅', '¥12800/套', '典藏级', 85),
  new FilmItem(5, '康泰时 T2', '自动旁轴 38F2.8', '¥4680/台', '成色 A', 81),
  new FilmItem(6, '海鸥 4B · 国产', '120 双反', '¥580/台', '入门款', 74),
  new FilmItem(7, '徕卡 M6 TTL', '135 旁轴旗舰', '¥19800/台', '大师级', 68),
  new FilmItem(8, '宾得 67 · 风光机', '120 单反', '¥7600/套', '成色 A', 63)
];

const SCAN_LIST: ScanItem[] = [
  new ScanItem(1, '柯达金 200 · 36 张', 'C-41 彩负', '已出片', 100),
  new ScanItem(2, '富士业务 100 · 2 卷', 'C-41 彩负', '扫描中', 82),
  new ScanItem(3, '乐凯 SHD100 · 黑白', 'D-76 冲洗', '显影中', 64),
  new ScanItem(4, '过期反转片 EB-3', 'E-6 反转', '定影中', 48),
  new ScanItem(5, '宽幅哈林卷', '135 双画幅', '扫描中', 36),
  new ScanItem(6, '街拍混卷 · 27 张', 'C-41 增感', '待入档', 20),
  new ScanItem(7, '伊尔福 HP5 · 夜景', '黑白增感 1600', '显影中', 12),
  new ScanItem(8, '婚礼跟拍卷 · 4 卷', 'C-41 加急', '待付尾款', 6)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '柯达金 200', '36 张彩负', '🎞️'),
  new GearItem(2, '富士 C200', '街拍常备', '📷'),
  new GearItem(3, '伊尔福 HP5', '黑白 400', '⚫'),
  new GearItem(4, '显影罐 + 片芯', '不锈钢两联', '🥫'),
  new GearItem(5, 'D76 显影粉', '1L 袋装', '🧪'),
  new GearItem(6, '底片袋 · 无酸', '100 页装', '🗂️'),
  new GearItem(7, '暗房红灯', 'LED 安全灯', '🔴'),
  new GearItem(8, '防静电毛刷', '底片清洁', '🧹')
];

const WEEK_CHART: WeekChartItem[] = [
  { label: '周一', value: 2 },
  { label: '周二', value: 3 },
  { label: '周三', value: 5 },
  { label: '周四', value: 7 },
  { label: '周五', value: 9 },
  { label: '周六', value: 14 },
  { label: '周日', value: 12 }
];

const FILM_CHART: FilmChartItem[] = [
  { label: '街头纪实', value: 32, color: '#B0413E' },
  { label: '人像写珍', value: 26, color: '#C9A66B' },
  { label: '风光宽幅', value: 20, color: '#8FAE7E' },
  { label: '静物微距', value: 13, color: '#D9A05B' },
  { label: '夜景长曝', value: 9, color: '#C76B5A' }
];

const TYPE_LIST: DarkTypeItem[] = [
  { name: '冲扫服务', desc: 'C-41/E-6 · 扫描出片', icon: '🧪', hot: true },
  { name: '暗房体验', desc: '红灯下亲手放大', icon: '🪔', hot: true },
  { name: '机身寄卖', desc: '免费估价 · 寄售', icon: '📷', hot: false },
  { name: '底片修复', desc: '划痕 · 霉斑处理', icon: '🩹', hot: false }
];

const COURSE_LIST: CourseItem[] = [
  { title: '胶片摄影入门与测光', week: '第 1 周', progress: 100, color: '#B0413E' },
  { title: 'C-41 手冲全流程', week: '第 2 周', progress: 88, color: '#C9A66B' },
  { title: '黑白底片放大实操', week: '第 3 周', progress: 54, color: '#8FAE7E' },
  { title: '底片数字化与归档', week: '第 4 周', progress: 26, color: '#D9A05B' },
  { title: '过期卷与增感玩法', week: '第 5 周', progress: 6, color: '#C76B5A' }
];

// ============ 导航配置 ============
interface NavItem {
  icon: string;
  label: string;
}

const NAV_LIST: NavItem[] = [
  { icon: '🎞️', label: '首页' },
  { icon: '📷', label: '市集' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '机身市集', '我的冲扫', '暗房工坊', '胶片课堂', '影友圈', '耗材库'];

// ============ 顶层纯函数 ============
function barH(v: number, max: number): number {
  return Math.round(118 * v / max);
}

function filmColor(v: number): string {
  if (v > 9) {
    return '#B0413E';
  } else if (v > 6) {
    return '#C9A66B';
  }
  return '#D9A05B';
}

function lampA(tick: number, i: number): number {
  return 0.05 + ((tick + i) % 6) * 0.03;
}

function lampX(tick: number, i: number): number {
  return 60 + ((tick * 3 + i * 191) % 540);
}

function lampY(tick: number, i: number): number {
  return 80 + ((tick * 5 + i * 167) % 570);
}

function lampR(tick: number, i: number): number {
  return 20 + ((tick + i) % 5) * 10;
}

function grainA(tick: number, i: number): number {
  return 0.1 + ((tick + i * 3) % 4) * 0.06;
}

function grainX(tick: number, i: number): number {
  return 20 + ((tick * 7 + i * 211) % 620);
}

function grainY(tick: number, i: number): number {
  return 30 + ((tick * 9 + i * 179) % 640);
}

function grainS(tick: number, i: number): number {
  return 2 + ((tick + i) % 3);
}

function statusColor(s: string): string {
  if (s === '已出片') {
    return '#8FAE7E';
  } else if (s === '扫描中') {
    return '#C9A66B';
  } else if (s === '显影中') {
    return '#B0413E';
  } else if (s === '定影中') {
    return '#D9A05B';
  }
  return '#7D7264';
}

function statusProgress(s: string): number {
  if (s === '已出片') {
    return 100;
  } else if (s === '扫描中') {
    return 78;
  } else if (s === '显影中') {
    return 60;
  } else if (s === '定影中') {
    return 42;
  }
  return 12;
}

function filmTagColor(g: string): string {
  if (g.indexOf('徕卡') >= 0 || g.indexOf('哈苏') >= 0) {
    return '#B0413E';
  } else if (g.indexOf('禄来') >= 0 || g.indexOf('海鸥') >= 0) {
    return '#C9A66B';
  } else if (g.indexOf('尼康') >= 0 || g.indexOf('宾得') >= 0) {
    return '#8FAE7E';
  }
  return '#D9A05B';
}

function levelColor(s: string): string {
  if (s === '入门款') {
    return '#8FAE7E';
  } else if (s.indexOf('成色') >= 0) {
    return '#C9A66B';
  } else if (s === '典藏级') {
    return '#D9A05B';
  }
  return '#C76B5A';
}

@Entry
@Component
struct PageFilmDarkroom {
  @State mainTab: number = 0
  @State subTab: number = 0
  @State tick: number = 0
  @State glow: number = 0
  @State addOpen: boolean = false
  @State editOpen: boolean = false
  @State delOpen: boolean = false
  @State editIdx: number = 0
  @State delTarget: string = 'scan'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State scanList: ScanItem[] = SCAN_LIST
  @State fxTimer: number = -1

  aboutToAppear(): void {
    this.fxTimer = setInterval(() => {
      this.tick = this.tick + 1;
      this.glow = (this.glow + 1) % 2;
    }, 90);
  }

  aboutToDisappear(): void {
    if (this.fxTimer > 0) {
      clearInterval(this.fxTimer);
      this.fxTimer = -1;
    }
  }

  openAdd(): void {
    this.addTitle = '';
    this.addContent = '';
    this.addOpen = true;
  }

  openEdit(index: number): void {
    if (index >= 0 && index < this.scanList.length) {
      this.editIdx = index;
      this.editName = this.scanList[index].name;
      this.editStyle = this.scanList[index].status;
      this.editNote = this.scanList[index].item;
    }
    this.editOpen = true;
  }

  openDelA(): void {
    this.delTarget = 'scan';
    this.delOpen = true;
  }

  openDelB(): void {
    this.delTarget = 'course';
    this.delOpen = true;
  }

  doAdd(): void {
    if (this.addTitle.length > 0) {
      this.postList.unshift(new PostItem(999, '我的影语', '🎞️', this.addTitle, '刚刚', 0));
    }
    this.addOpen = false;
  }

  doEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.scanList.length) {
      this.scanList.splice(this.editIdx, 1, new ScanItem(this.scanList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
    }
    this.editOpen = false;
  }

  doDel(): void {
    if (this.delTarget === 'scan' && this.scanList.length > 0) {
      this.scanList.splice(0, 1);
    } else if (this.delTarget === 'course' && this.postList.length > 0) {
      this.postList.splice(0, 1);
    }
    this.delOpen = false;
  }

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3], (i: number) => {
        Column()
          .width(lampR(this.tick, i) * 2)
          .height(lampR(this.tick, i) * 2)
          .borderRadius(lampR(this.tick, i))
          .backgroundColor(COLORS.darkroom)
          .opacity(lampA(this.tick, i))
          .translate({ x: lampX(this.tick, i), y: lampY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
        Column()
          .width(grainS(this.tick, i))
          .height(grainS(this.tick, i))
          .borderRadius(grainS(this.tick, i) / 2)
          .backgroundColor(COLORS.textHint)
          .opacity(grainA(this.tick, i))
          .translate({ x: grainX(this.tick, i), y: grainY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column({ space: 10 }) {
      // 搜索条 + 购物车
      Row({ space: 10 }) {
        Text('🎞️')
          .fontSize(24)
        Row({ space: 6 }) {
          Text('🔍')
            .fontSize(12)
          Text('搜一搜「FM2」「C-41 冲扫」')
            .fontSize(11)
            .fontColor(COLORS.textHint)
            .layoutWeight(1)
        }
        .layoutWeight(1)
        .padding({ left: 12, right: 12, top: 8, bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        Stack() {
          Text('🧺')
            .fontSize(20)
          Text('5')
            .fontSize(8)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 4, right: 4, top: 1, bottom: 1 })
            .backgroundColor(COLORS.danger)
            .borderRadius(8)
            .translate({ x: 10, y: -10 })
        }
        .width(30)
        .height(30)
      }
      .width('100%')

      // 冲扫进度卡
      Row({ space: 12 }) {
        Column({ space: 4 }) {
          Text('3 卷显影中 · 2 卷排队扫描')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Text('富士业务 100 · 标准流程 · 明早出片')
            .fontSize(10)
            .fontColor('#EBD3CE')
          Row({ space: 6 }) {
            Text('药水新鲜')
              .fontSize(9)
              .fontWeight(FontWeight.Bold)
              .fontColor('#5A201D')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.darkroom)
              .borderRadius(6)
            Text('今晚暗房体验夜')
              .fontSize(9)
              .fontColor('#EBD3CE')
          }
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('查看进度')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5A201D')
          .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          .backgroundColor(COLORS.darkroom)
          .borderRadius(14)
          .onClick(() => {
            this.openEdit(1);
          })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.primary)
      .borderRadius(16)

      // 限时冲扫横幅
      Row({ space: 10 }) {
        Text('🧪')
          .fontSize(22)
        Column({ space: 2 }) {
          Text('首卷冲扫 8 折 · 新客专享')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('含扫描底片袋 · 48 小时出片')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('预约')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .backgroundColor(COLORS.accent)
          .borderRadius(14)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      .padding(12)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 数据四格
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('在途胶卷')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('5 卷')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.darkroom)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('累计冲扫')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('212 卷')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('收藏机身')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('7 台')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('影币')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('¥64')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ top: 10, bottom: 4 })
  }

  @Builder
  subNav() {
    Scroll() {
      Row({ space: 14 }) {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Column({ space: 4 }) {
            Text(item)
              .fontSize(this.subTab === idx ? 14 : 11)
              .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.subTab === idx ? COLORS.darkroom : COLORS.textSecondary)
            if (this.subTab === idx) {
              Row()
                .width(18)
                .height(3)
                .borderRadius(2)
                .backgroundColor(COLORS.darkroom)
            } else {
              Row()
                .width(18)
                .height(3)
                .opacity(0)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .padding({ top: 6, bottom: 4 })
          .onClick(() => {
            this.subTab = idx;
          })
        }, (item: string) => item)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .padding({ top: 6, bottom: 10 })
  }

  @Builder
  pageFeatured() {
    Column({ space: 12 }) {
      // 玩法四格
      Column() {
        Text('在影语能玩什么')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
          .margin({ bottom: 10 })
        Row({ space: 8 }) {
          ForEach(TYPE_LIST, (it: DarkTypeItem) => {
            Column({ space: 4 }) {
              Text(it.icon)
                .fontSize(24)
              Text(it.name)
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
              Text(it.desc)
                .fontSize(9)
                .fontColor(COLORS.textHint)
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(it.hot ? '热门' : ' ')
                .fontSize(9)
                .fontWeight(FontWeight.Bold)
                .fontColor(it.hot ? COLORS.danger : COLORS.border)
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 8, left: 4, right: 4 })
            .backgroundColor(COLORS.cardBg)
            .borderRadius(12)
            .alignItems(HorizontalAlign.Center)
          }, (it: DarkTypeItem) => it.name)
        }
        .width('100%')
      }
      .width('100%')

      // 周冲扫柱状图
      Column() {
        Row() {
          Text('工坊本周冲扫量')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('单位 卷')
            .fontSize(10)
            .fontColor(COLORS.textHint)
        }
        .width('100%')
        Row({ space: 10 }) {
          ForEach(WEEK_CHART, (it: WeekChartItem) => {
            Column({ space: 4 }) {
              Column()
                .width(20)
                .height(barH(it.value, 14))
                .borderRadius(5)
                .backgroundColor(filmColor(it.value))
              Text(`${it.value}`)
                .fontSize(9)
                .fontColor(COLORS.textSecondary)
              Text(it.label)
                .fontSize(9)
                .fontColor(COLORS.textHint)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
          }, (it: WeekChartItem) => it.label + it.value.toString())
        }
        .width('100%')
        .alignItems(VerticalAlign.Bottom)
        .height(140)
        .margin({ top: 12 })
        Row({ space: 6 }) {
          Text('周末冲扫占比')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
          Text('57%')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 题材构成
      Column() {
        Text('本月底片题材构成')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .width('100%')
        ForEach(FILM_CHART, (it: FilmChartItem) => {
          Row({ space: 8 }) {
            Text(it.label)
              .fontSize(12)
              .fontColor(COLORS.textSecondary)
              .width(70)
            Stack({ alignContent: Alignment.Start }) {
              Row()
                .width('100%')
                .height(8)
                .borderRadius(4)
                .backgroundColor(COLORS.border)
              Row()
                .width(`${it.value}%`)
                .height(8)
                .borderRadius(4)
                .backgroundColor(it.color)
            }
            .layoutWeight(1)
            Text(`${it.value}%`)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .width(44)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ top: 8 })
        }, (it: FilmChartItem) => it.label)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)

      // 人气机身双列
      Column() {
        Row() {
          Text('人气胶片机身')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('查看全部')
            .fontSize(11)
            .fontColor(COLORS.darkroom)
        }
        .width('100%')
        .margin({ bottom: 10 })
        ForEach(FILM_LIST, (it: FilmItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < FILM_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('📷')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(FILM_LIST[idx].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(FILM_LIST[idx].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(FILM_LIST[idx].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${FILM_LIST[idx].price} · 关注 ${FILM_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < FILM_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🎥')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(FILM_LIST[idx + 1].level)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(levelColor(FILM_LIST[idx + 1].level))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(FILM_LIST[idx + 1].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${FILM_LIST[idx + 1].price} · 关注 ${FILM_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: FilmItem) => it.id.toString())
      }
      .width('100%')

      // 影友动态
      Column({ space: 10 }) {
        Row() {
          Text('影友动态')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('发影语')
            .fontSize(12)
            .fontColor(COLORS.darkroom)
            .onClick(() => {
              this.openAdd();
            })
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row({ space: 10 }) {
            Text(it.avatar)
              .fontSize(22)
            Column({ space: 3 }) {
              Row() {
                Text(it.nick)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.time)
                  .fontSize(10)
                  .fontColor(COLORS.textHint)
              }
              .width('100%')
              Text(it.text)
                .fontSize(12)
                .fontColor(COLORS.textSecondary)
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(`🎞️ ${it.likes}`)
                .fontSize(11)
                .fontColor(COLORS.textHint)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .padding(12)
          .backgroundColor(COLORS.cardBg)
          .borderRadius(12)
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
    }
    .width('100%')
  }

  @Builder
  pageFilms() {
    Column({ space: 10 }) {
      Row() {
        Text('机身市集')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('我的机身寄卖')
          .fontSize(12)
          .fontColor(COLORS.darkroom)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      ForEach(FILM_LIST, (it: FilmItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(filmTagColor(it.name))
            Column({ space: 4 }) {
              Row() {
                Text(it.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.level)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(levelColor(it.level))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.bg)
                  .borderRadius(6)
              }
              .width('100%')
              Row({ space: 8 }) {
                Text(`💰 ${it.price}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text(`🔧 ${it.craft}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
              }
              .width('100%')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${it.hot}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(filmTagColor(it.name))
          }
          .width('100%')
          Row({ space: 10 }) {
            Text(`关注度 ${it.hot}`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('加入购物车')
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .backgroundColor(COLORS.primary)
              .borderRadius(8)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: FilmItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageScans() {
    Column({ space: 10 }) {
      Text('我的冲扫')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(this.scanList, (it: ScanItem, idx: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Text('🎞️')
              .fontSize(24)
            Column({ space: 4 }) {
              Row() {
                Text(it.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                  .layoutWeight(1)
                Text(it.status)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(statusColor(it.status))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.accentLight)
                  .borderRadius(6)
              }
              .width('100%')
              Text(`🧾 ${it.item}`)
                .fontSize(11)
                .fontColor(COLORS.textSecondary)
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${statusProgress(it.status)}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(statusColor(it.status))
          }
          .width('100%')
          Row() {
            Text(`冲扫进度 ${statusProgress(it.status)}%`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('编辑冲扫单')
              .fontSize(11)
              .fontColor(COLORS.darkroom)
              .onClick(() => {
                this.openEdit(idx);
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: ScanItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageWorkshop() {
    Column({ space: 10 }) {
      Text('暗房工坊')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('今晚工位')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('放大台 R2')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.darkroom)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('药水温度')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('38.0℃')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.accent)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('工序')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('显影')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.warning)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 2 }) {
          Text('今晚工时')
            .fontSize(10)
            .fontColor(COLORS.textHint)
          Text('4.5 h')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.success)
        }
        .layoutWeight(1)
        .padding(8)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(FILM_LIST.slice(0, 5), (it: FilmItem, idx: number) => {
        Row({ space: 10 }) {
          Text(`${idx + 1}`)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(idx < 3 ? COLORS.darkroom : COLORS.textHint)
            .width(20)
          Column({ space: 3 }) {
            Text(it.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(`${it.craft} · ${it.price}`)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text(`关注 ${it.hot}`)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(filmTagColor(it.name))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: FilmItem) => it.id.toString())
      Text('耗材速购')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
        .margin({ top: 4 })
      ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
        if (idx % 2 === 0) {
          Row({ space: 10 }) {
            if (idx < GEAR_LIST.length) {
              Column({ space: 6 }) {
                Text(GEAR_LIST[idx].icon)
                  .fontSize(26)
                Text(GEAR_LIST[idx].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('加购')
                  .fontSize(11)
                  .fontColor(COLORS.darkroom)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding(12)
              .backgroundColor(COLORS.cardBg)
              .borderRadius(12)
            }
            if (idx + 1 < GEAR_LIST.length) {
              Column({ space: 6 }) {
                Text(GEAR_LIST[idx + 1].icon)
                  .fontSize(26)
                Text(GEAR_LIST[idx + 1].name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx + 1].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('加购')
                  .fontSize(11)
                  .fontColor(COLORS.darkroom)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .padding(12)
              .backgroundColor(COLORS.cardBg)
              .borderRadius(12)
            }
          }
          .width('100%')
        }
      }, (it: GearItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageCourse() {
    Column({ space: 10 }) {
      Text('胶片课堂')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(COURSE_LIST, (it: CourseItem) => {
        Column({ space: 8 }) {
          Row() {
            Text(it.title)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
              .layoutWeight(1)
            Text(it.week)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
          Stack({ alignContent: Alignment.Start }) {
            Row()
              .width('100%')
              .height(8)
              .borderRadius(4)
              .backgroundColor(COLORS.border)
            Row()
              .width(`${it.progress}%`)
              .height(8)
              .borderRadius(4)
              .backgroundColor(it.color)
          }
          .width('100%')
          Row() {
            Text('已学')
              .fontSize(11)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(`${it.progress}%`)
              .fontSize(11)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.darkroom)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: CourseItem) => it.title)
      Button()
        .width('100%')
        .height(42)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .opacity(0.9)
        .onClick(() => {
          this.openDelB();
        })
      Text('退课')
        .fontSize(14)
        .fontColor(COLORS.white)
        .margin({ left: -28, top: -32 })
    }
    .width('100%')
  }

  @Builder
  pageCircle() {
    Column({ space: 10 }) {
      Row() {
        Text('影友圈')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('发影语')
          .fontSize(12)
          .fontColor(COLORS.darkroom)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      ForEach(this.postList, (it: PostItem) => {
        Row({ space: 10 }) {
          Text(it.avatar)
            .fontSize(26)
          Column({ space: 4 }) {
            Row() {
              Text(it.nick)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.textPrimary)
                .layoutWeight(1)
              Text(it.time)
                .fontSize(10)
                .fontColor(COLORS.textHint)
            }
            .width('100%')
            Text(it.text)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
            Text(`🎞️ ${it.likes} · 💬 回复`)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: PostItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageGear() {
    Column({ space: 10 }) {
      Text('耗材库')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      Row({ space: 8 }) {
        Column({ space: 4 }) {
          Text('🎞️')
            .fontSize(28)
          Text('胶卷区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('彩负 · 黑白')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('🧪')
            .fontSize(28)
          Text('药剂区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('显影 · 定影')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        Column({ space: 4 }) {
          Text('🗂️')
            .fontSize(28)
          Text('归档区')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('底片袋 · 清洁')
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      ForEach(GEAR_LIST, (it: GearItem) => {
        Row({ space: 10 }) {
          Text(it.icon)
            .fontSize(20)
          Column({ space: 3 }) {
            Text(it.name)
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text(it.desc)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text('加购')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.primary)
            .borderRadius(8)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: GearItem) => it.id.toString())
      Button()
        .width('100%')
        .height(42)
        .backgroundColor(COLORS.danger)
        .borderRadius(10)
        .opacity(0.9)
        .onClick(() => {
          this.openDelA();
        })
      Text('取消冲扫')
        .fontSize(14)
        .fontColor(COLORS.white)
        .margin({ left: -56, top: -32 })
    }
    .width('100%')
  }

  @Builder
  pageMine() {
    Column({ space: 12 }) {
      Row({ space: 12 }) {
        Text('🎞️')
          .fontSize(40)
        Column({ space: 4 }) {
          Text('影语胶片人')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('银盐会员 · 冲扫 212 卷 · 收藏 7 台 · 打卡 88 天')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的冲扫档案')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.scanList.length}`)
            .fontSize(12)
            .fontColor(COLORS.darkroom)
        }
        .width('100%')
        ForEach(this.scanList.slice(0, 3), (it: ScanItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.status)
              .fontSize(11)
              .fontColor(statusColor(it.status))
          }
          .width('100%')
        }, (it: ScanItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
      Column({ space: 10 }) {
        Row() {
          Text('我的影语')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text(`${this.postList.length}`)
            .fontSize(12)
            .fontColor(COLORS.darkroom)
        }
        .width('100%')
        ForEach(this.postList.slice(0, 3), (it: PostItem) => {
          Row() {
            Text(it.nick)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.time)
              .fontSize(11)
              .fontColor(COLORS.textHint)
          }
          .width('100%')
        }, (it: PostItem) => it.id.toString())
      }
      .width('100%')
      .padding(14)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(14)
    }
    .width('100%')
  }

  @Builder
  modalOverlay() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor('#000000')
        .opacity(0.7)
        .onClick(() => {
          this.addOpen = false;
          this.editOpen = false;
          this.delOpen = false;
        })
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  addModalBody() {
    Column({ space: 12 }) {
      Text('发布影语')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '一句话暗房日常…', text: this.addTitle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.addTitle = v;
        })
      TextArea({ placeholder: '补充细节:机身、胶卷、冲洗心得…', text: this.addContent })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(90)
        .onChange((v: string) => {
          this.addContent = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.addOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doAdd();
          })
        Text('发布')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  editModalBody() {
    Column({ space: 12 }) {
      Text('编辑冲扫单')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      TextInput({ placeholder: '冲扫名称', text: this.editName })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editName = v;
        })
      TextInput({ placeholder: '状态(如 显影中)', text: this.editStyle })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(40)
        .onChange((v: string) => {
          this.editStyle = v;
        })
      TextArea({ placeholder: '冲扫内容与备注说明', text: this.editNote })
        .fontSize(13)
        .fontColor(COLORS.textPrimary)
        .backgroundColor(COLORS.bg)
        .borderRadius(8)
        .height(70)
        .onChange((v: string) => {
          this.editNote = v;
        })
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.editOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.primary)
          .borderRadius(8)
          .onClick(() => {
            this.doEdit();
          })
        Text('保存')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  delModalBody() {
    Column({ space: 12 }) {
      Text(this.delTarget === 'scan' ? '取消冲扫订单' : '退出胶片课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'scan' ? '将取消首笔冲扫订单并按工序退费,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .fontSize(13)
        .fontColor(COLORS.textSecondary)
      Row({ space: 10 }) {
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .onClick(() => {
            this.delOpen = false;
          })
        Text('取消')
          .fontSize(13)
          .fontColor(COLORS.textSecondary)
          .margin({ left: -52 })
        Button()
          .layoutWeight(1)
          .height(38)
          .backgroundColor(COLORS.danger)
          .borderRadius(8)
          .onClick(() => {
            this.doDel();
          })
        Text('确认')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .margin({ left: -44 })
      }
      .width('100%')
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.cardBg)
  }

  @Builder
  bottomBar() {
    Row() {
      ForEach(NAV_LIST, (it: NavItem, idx: number) => {
        Column({ space: 2 }) {
          Text(it.icon)
            .fontSize(20)
            .opacity(this.mainTab === idx ? 1 : 0.55)
          Text(it.label)
            .fontSize(10)
            .fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.mainTab === idx ? COLORS.darkroom : COLORS.textHint)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => {
          this.mainTab = idx;
        })
      }, (it: NavItem) => it.label)
    }
    .width('100%')
    .height(56)
    .backgroundColor(COLORS.cardBg)
    .borderRadius({ topLeft: 16, topRight: 16 })
  }

  @Builder
  mainContent() {
    Column() {
      this.header()
      if (this.mainTab === 0) {
        this.subNav()
        Scroll() {
          Column() {
            if (this.subTab === 0) {
              this.pageFeatured()
            } else if (this.subTab === 1) {
              this.pageFilms()
            } else if (this.subTab === 2) {
              this.pageScans()
            } else if (this.subTab === 3) {
              this.pageWorkshop()
            } else if (this.subTab === 4) {
              this.pageCourse()
            } else if (this.subTab === 5) {
              this.pageCircle()
            } else {
              this.pageGear()
            }
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 1) {
        Scroll() {
          Column() {
            this.pageFilms()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else if (this.mainTab === 2) {
        Scroll() {
          Column() {
            this.pageCourse()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      } else {
        Scroll() {
          Column() {
            this.pageMine()
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .layoutWeight(1)
        .padding({ left: 14, right: 14, bottom: 20 })
      }
    }
    .width('100%')
    .height('100%')
  }

  build() {
    Stack() {
      Column()
        .width('100%')
        .height('100%')
        .backgroundColor(COLORS.bg)
      this.fxLayer()
      Column() {
        this.mainContent()
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      if (this.addOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.addModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
      if (this.editOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.editModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
      if (this.delOpen) {
        Stack() {
          this.modalOverlay()
          Column() {
            this.delModalBody()
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .zIndex(999)
        }
        .width('100%')
        .height('100%')
      }
    }
    .width('100%')
    .height('100%')
  }
}


结语

在这里插入图片描述

"影语·胶片相机与暗房冲扫工坊"应用是一个基于HarmonyOS ArkTS API 24声明式UI范式构建的优秀案例。它以暗房红金暗色主题为视觉特色,通过@Observed状态管理、@Builder组件封装、Stack/Column/Row布局系统等核心技术,构建了一个功能完整、体验流畅的胶片摄影社区应用。从色彩系统的精心设计,到数据模型的合理抽象;从特效层的氛围营造,到导航架构的清晰分层;从弹框组件的规范统一,到纯函数工具的灵活运用——应用的每一个技术细节都体现了对声明式UI开发范式的深刻理解和熟练运用。

Logo

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

更多推荐