HarmonyOS 6.1 实战:长列表 ForEach 性能优化,大数据量列表避免不必要的组件重渲染

一、技术背景与鸿蒙生态全景

1.1 HarmonyOS 6.1.1 技术演进脉络

HarmonyOS 6.1.1 作为华为分布式操作系统的最新里程碑版本,在声明式UI框架、ArkTS语言能力、分布式软总线、原子化服务等多个维度实现了质的飞跃。该版本进一步强化了ArkUI开发框架的性能表现,通过组件懒加载、虚拟列表优化、渲染管线重构等技术手段,将复杂列表的滑动帧率提升了35%以上,内存占用降低了22%。

在HarmonyOS 6.1.1中,ArkUI-X跨平台能力得到了全面增强,开发者可以基于同一套ArkTS代码同时部署到HarmonyOS、Android和iOS三个平台,真正实现了"一次开发,多端部署"的愿景。此外,新版本还引入了更细粒度的状态管理机制,包括@LocalStorageProp、@SharedStorage装饰器的性能优化,以及对组件级状态观察的精细化控制。

1.2 ArkTS 语言特性深度剖析

ArkTS 作为 HarmonyOS 的首选应用开发语言,在 TypeScript 的基础上进行了扩展与优化,提供了声明式UI范式、状态管理、自定义组件等核心能力。基于HarmonyOS API 24,ArkTS 引入了更多的语法糖与运行时优化,包括更强大的类型推导系统、更高效的装饰器机制,以及更完善的泛型支持。

声明式UI是ArkTS最核心的设计理念之一。开发者不再需要命令式地操作DOM节点,而是通过描述UI的状态与结构,由框架自动完成渲染与更新。这种范式大幅降低了UI开发的复杂度,同时也为框架的性能优化提供了更大的空间。在HarmonyOS ArkTS API 24中,声明式UI的表达能力进一步增强,支持更复杂的条件渲染、更灵活的布局组合,以及更强大的自定义组件抽象。

1.3 声明式UI范式的核心优势

声明式UI范式的核心在于"状态驱动视图"。当应用状态发生变化时,框架会自动计算差异并更新UI,开发者无需手动操作界面元素。这种模式带来了三大优势:一是代码可读性更高,UI结构与业务逻辑清晰分离;二是可维护性更强,状态变化的追踪更加直观;三是性能更优,框架可以在底层进行批量更新与渲染优化。

在HarmonyOS 6.1.1中,ArkUI框架引入了增量渲染机制,能够精确追踪每个组件的依赖状态,只更新真正发生变化的部分。这种细粒度的更新策略大幅减少了不必要的重绘与重排,尤其在复杂的列表与网格布局中效果显著。

1.4 项目业务背景概述

PAINT LAB 手办涂装工作室是一款面向模型爱好者与专业涂装师的垂直领域应用,涵盖了涂料管理、喷笔选购、素体浏览、教程学习等核心功能模块。应用采用工业灰蓝(#3A4A5A)为主色调,搭配喷涂橙(#FF7F00)作为强调色,整体视觉风格专业而富有工业质感。

应用包含六个核心Tab页:首页仪表盘、涂料仓、喷笔馆、素体库、涂装学堂、个人中心。每个页面都设计了丰富的交互动效与数据可视化元素,包括环形进度仪表盘、水平色卡墙、热销排行榜、功能宫格、品牌分布柱状图、360度旋转展示台、难度进度条、月度消费趋势图等。

本文将从架构设计、类型系统、数据模型、组件实现、动画特效、状态管理等多个维度,对这款基于HarmonyOS ArkTS API 24开发的应用进行全方位的深度技术解析。


二、整体架构设计

2.1 架构层次图

渲染错误: Mermaid 渲染失败: Parse error on line 15: ... F组件装饰器层 --> G1[@Entry @Component ----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

2.2 架构设计理念解析

本应用采用了典型的单文件组件化架构,将所有页面与组件定义在同一个源文件中,通过清晰的代码分区实现模块化管理。这种架构适合中小型应用的快速开发,减少了文件间的导入导出开销,同时保持了代码的内聚性。

架构的核心是入口组件 PlApp,它负责管理全局的Tab切换状态,并通过条件渲染展示不同的页面内容。每个Tab页都是一个独立的 @Component 结构体,拥有自己的状态管理与UI构建逻辑。这种设计遵循了单一职责原则,每个页面组件只负责自己的业务逻辑与界面呈现。

数据层采用常量定义的方式,将所有业务数据以 const 常量的形式声明在文件顶部,包括类型接口、设计令牌、色系配置、涂料数据、喷笔数据、素体数据、教程数据、订单数据等。这种数据驱动的设计使得UI层与数据层清晰分离,便于后续的数据替换与接口对接。

2.3 组件通信模式

在本应用中,组件之间的通信主要采用两种模式:一是父子组件通过状态传递进行通信,二是通过全局常量数据实现数据共享。由于所有页面都定义在同一个文件中,它们可以直接访问顶层的常量数据与纯函数,无需通过props逐层传递。

这种架构模式的优势在于数据访问的便捷性,任何组件都可以直接读取全局数据。而状态的变更则被限制在各个组件内部,通过 @State 装饰器管理组件级别的状态。这种设计在保证数据一致性的同时,也避免了状态管理的复杂性。


三、类型系统与设计令牌

3.1 接口类型定义体系

应用的类型系统由9个核心接口组成,每个接口都精确描述了对应业务实体的数据结构。这些接口不仅为开发提供了类型安全保障,也作为业务领域模型的文档存在。

typescript 复制代码
interface PlSeriesMeta {
  label: string
  colorHex: string
  desc: string
  count: number
  pct: number
}

PlSeriesMeta 接口描述了涂料色系的元数据信息。其中 label 字段表示色系名称,如"基础"、"金属"、"荧光"等;colorHex 字段存储色系的代表色值,用于UI渲染中的色卡展示;desc 字段提供色系的描述文字,说明该色系的特点与适用场景;count 字段记录该色系下的涂料数量;pct 字段则表示该色系在整体中的占比百分比。

这个接口的设计体现了领域驱动设计的思想,将色系这一业务概念抽象为完整的数据结构,包含了展示所需的全部信息。在UI层,无论是色系列表、筛选标签还是分布统计图,都可以基于这个统一的数据结构进行渲染。

typescript 复制代码
interface PlFuncMeta {
  label: string
  icon: string
  bg: string
  color: string
}

PlFuncMeta 接口定义了功能宫格中每个功能项的数据结构。label 为功能名称,icon 使用emoji字符作为图标,bg 是图标背景色,color 是图标前景色。这种设计将视觉样式数据与业务数据整合在一起,使得功能宫格的渲染完全数据驱动。

typescript 复制代码
interface PlStatMeta {
  label: string
  count: number
  pct: number
  colorHex: string
}

PlStatMeta 是一个通用的统计数据接口,被广泛应用于品牌分布、比例分布、材质分布等多个统计图表中。其设计体现了接口复用的思想,不同维度的统计数据都可以用相同的结构来表示,从而实现统计图表组件的通用化。

3.2 业务实体接口详解

typescript 复制代码
interface PlPaintItem {
  id: number
  name: string
  series: string
  colorHex: string
  capacity: number
  price: number
  sales: number
  stock: number
  brand: string
  effect: string
}

PlPaintItem 是涂料实体的核心接口,包含10个字段,完整描述了一瓶涂料的全部属性。id 作为唯一标识符,name 是涂料名称,series 表示所属色系,colorHex 存储颜色值,capacity 记录剩余容量百分比,price 为单价,sales 是累计销量,stock 是库存数量,brand 表示品牌,effect 描述漆面效果(如消光、光泽、金属、荧光等)。

这个接口的字段设计非常考究,既包含了商品基本信息(名称、品牌、价格),也包含了库存与销售数据(库存、销量),还包含了涂装专业属性(色系、容量、漆面效果)。这种全面的数据结构为UI层的多样化展示提供了数据基础。

typescript 复制代码
interface PlAirbrushItem {
  id: number
  brand: string
  model: string
  caliber: number
  price: number
  origPrice: number
  sales: number
  cupMl: number
  score: number
  tag: string
}

PlAirbrushItem 接口描述了喷笔产品的数据结构。与涂料不同,喷笔作为工具类产品,其属性更侧重于技术参数:caliber 表示喷嘴口径(单位mm),cupMl 表示料杯容量(单位ml),score 是综合评分,tag 是产品标签(如"口碑之王"、"极细线稿"等)。origPrice 字段记录原价,用于展示折扣信息。

3.3 设计令牌系统

设计令牌(Design Tokens)是现代前端架构中的重要概念,它将设计系统中的颜色、间距、字体等基础样式抽象为可复用的变量,确保整个应用视觉风格的一致性。

typescript 复制代码
const PL_PRIMARY: string = '#3A4A5A'
const PL_ACCENT: string = '#FF7F00'
const PL_BG: string = '#FAFAFA'
const PL_CARD: string = '#FFFFFF'
const PL_TEXT: string = '#2A3440'
const PL_SUB: string = '#8A94A0'
const PL_LINE: string = '#EDF0F3'

本应用定义了7个核心颜色令牌,构建了完整的色彩系统。PL_PRIMARY 工业灰蓝是主色调,用于头部横幅、重要按钮、文字强调等场景;PL_ACCENT 喷涂橙是强调色,用于价格、标签、选中状态等需要突出显示的元素;PL_BG 是页面背景色,采用浅灰白色调;PL_CARD 是卡片背景色,使用纯白色;PL_TEXT 是主要文字色,深灰色调保证可读性;PL_SUB 是次要文字色,用于辅助说明文字;PL_LINE 是分割线颜色,浅灰色调。

这种色彩系统的设计遵循了60-30-10的配色原则:主色占据60%的视觉空间,辅助色占据30%,强调色占据10%。工业灰蓝的稳重与喷涂橙的活力形成了鲜明对比,既体现了专业工具类应用的严谨性,又不失创意工作的活力感。

3.4 色系配置数据

typescript 复制代码
const PL_SERIES_LIST: PlSeriesMeta[] = [
  { label: '基础', colorHex: '#3A4A5A', desc: '高遮盖底色 · 战锤骨架首选', count: 3, pct: 30 },
  { label: '金属', colorHex: '#A8B2BD', desc: '金属粒子光泽 · 机甲装甲', count: 2, pct: 20 },
  { label: '荧光', colorHex: '#B8FF3E', desc: '高饱和发光色 · 灯效件', count: 2, pct: 20 },
  { label: '珠光', colorHex: '#E8C77B', desc: '柔润珠光光泽 · 礼装涂装', count: 2, pct: 20 },
  { label: '透明', colorHex: '#1B6D9E', desc: '透明渐变 · 光刃与镜面', count: 1, pct: 10 }
]

色系配置数组包含5个色系,每个色系都有详细的描述信息。基础系占比最高(30%),是涂装工作中最常用的底色;金属、荧光、珠光各占20%,对应不同的特效涂装需求;透明系占比10%,用于特殊效果制作。

这种数据结构的设计非常巧妙,它不仅是UI渲染的数据源,本身也构成了一个完整的业务领域模型。在色系筛选、色系分布统计、新增涂料表单等多个场景中,都复用了这同一份数据。


四、全局工具函数与数据处理

4.1 数据流向图

#mermaid-svg-JIKOJXqWasd8pGDD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JIKOJXqWasd8pGDD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JIKOJXqWasd8pGDD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JIKOJXqWasd8pGDD .error-icon{fill:#552222;}#mermaid-svg-JIKOJXqWasd8pGDD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JIKOJXqWasd8pGDD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JIKOJXqWasd8pGDD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JIKOJXqWasd8pGDD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JIKOJXqWasd8pGDD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JIKOJXqWasd8pGDD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JIKOJXqWasd8pGDD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JIKOJXqWasd8pGDD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JIKOJXqWasd8pGDD .marker.cross{stroke:#333333;}#mermaid-svg-JIKOJXqWasd8pGDD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JIKOJXqWasd8pGDD p{margin:0;}#mermaid-svg-JIKOJXqWasd8pGDD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JIKOJXqWasd8pGDD .cluster-label text{fill:#333;}#mermaid-svg-JIKOJXqWasd8pGDD .cluster-label span{color:#333;}#mermaid-svg-JIKOJXqWasd8pGDD .cluster-label span p{background-color:transparent;}#mermaid-svg-JIKOJXqWasd8pGDD .label text,#mermaid-svg-JIKOJXqWasd8pGDD span{fill:#333;color:#333;}#mermaid-svg-JIKOJXqWasd8pGDD .node rect,#mermaid-svg-JIKOJXqWasd8pGDD .node circle,#mermaid-svg-JIKOJXqWasd8pGDD .node ellipse,#mermaid-svg-JIKOJXqWasd8pGDD .node polygon,#mermaid-svg-JIKOJXqWasd8pGDD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JIKOJXqWasd8pGDD .rough-node .label text,#mermaid-svg-JIKOJXqWasd8pGDD .node .label text,#mermaid-svg-JIKOJXqWasd8pGDD .image-shape .label,#mermaid-svg-JIKOJXqWasd8pGDD .icon-shape .label{text-anchor:middle;}#mermaid-svg-JIKOJXqWasd8pGDD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JIKOJXqWasd8pGDD .rough-node .label,#mermaid-svg-JIKOJXqWasd8pGDD .node .label,#mermaid-svg-JIKOJXqWasd8pGDD .image-shape .label,#mermaid-svg-JIKOJXqWasd8pGDD .icon-shape .label{text-align:center;}#mermaid-svg-JIKOJXqWasd8pGDD .node.clickable{cursor:pointer;}#mermaid-svg-JIKOJXqWasd8pGDD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JIKOJXqWasd8pGDD .arrowheadPath{fill:#333333;}#mermaid-svg-JIKOJXqWasd8pGDD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JIKOJXqWasd8pGDD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JIKOJXqWasd8pGDD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JIKOJXqWasd8pGDD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JIKOJXqWasd8pGDD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JIKOJXqWasd8pGDD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JIKOJXqWasd8pGDD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JIKOJXqWasd8pGDD .cluster text{fill:#333;}#mermaid-svg-JIKOJXqWasd8pGDD .cluster span{color:#333;}#mermaid-svg-JIKOJXqWasd8pGDD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JIKOJXqWasd8pGDD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JIKOJXqWasd8pGDD rect.text{fill:none;stroke-width:0;}#mermaid-svg-JIKOJXqWasd8pGDD .icon-shape,#mermaid-svg-JIKOJXqWasd8pGDD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JIKOJXqWasd8pGDD .icon-shape p,#mermaid-svg-JIKOJXqWasd8pGDD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JIKOJXqWasd8pGDD .icon-shape .label rect,#mermaid-svg-JIKOJXqWasd8pGDD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JIKOJXqWasd8pGDD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JIKOJXqWasd8pGDD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JIKOJXqWasd8pGDD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原始数据常量
纯函数处理
组件状态
UI渲染
getHotPaints 销量排序
getPaintsBySeries 色系过滤
getLowCapacityPaints 容量预警筛选
getRankedAirbrushes 综合评分排序
getPaintTotalSales 销量汇总
getMonthSpendTotal 消费汇总
getShownTutorials 难度筛选
用户交互
状态变更

4.2 热销涂料排序函数

typescript 复制代码
function getHotPaints(): PlPaintItem[] {
  let r: PlPaintItem[] = []
  for (let i = 0; i < PL_PAINTS.length; i++) {
    r.push(PL_PAINTS[i])
  }
  r.sort((a: PlPaintItem, b: PlPaintItem) => b.sales - a.sales)
  return r
}

getHotPaints 函数的功能是获取按销量降序排列的涂料列表。函数内部首先创建一个新数组 r,然后通过 for 循环将原始数据 PL_PAINTS 中的每个元素推入新数组,最后调用数组的 sort 方法进行排序。

这里有一个值得注意的设计细节:函数没有直接对 PL_PAINTS 数组调用 sort,而是先创建了一个副本。这是因为 sort 方法会修改原数组(原地排序),而 PL_PAINTS 作为全局常量数据,不应该被修改。通过创建副本进行排序,保证了原始数据的不可变性,这是函数式编程中的重要原则。

排序的比较函数 (a, b) => b.sales - a.sales 实现了降序排列:当 b 的销量大于 a 时,返回正值,b 会排在 a 前面。这种写法简洁高效,是JavaScript/TypeScript中数值排序的经典模式。

4.3 色系过滤函数

typescript 复制代码
function getPaintsBySeries(series: string): PlPaintItem[] {
  let r: PlPaintItem[] = []
  for (let i = 0; i < PL_PAINTS.length; i++) {
    if (series === '全部' || PL_PAINTS[i].series === series) {
      r.push(PL_PAINTS[i])
    }
  }
  return r
}

getPaintsBySeries 函数根据色系名称筛选涂料。参数 series 指定要筛选的色系,如果值为"全部",则返回所有涂料。函数使用传统的 for 循环遍历数组,通过 if 条件判断进行过滤。

这里的条件判断 series === '全部' || PL_PAINTS[i].series === series 利用了逻辑或的短路特性:当 series 为"全部"时,第一个条件已经为真,不会再执行第二个条件的判断,直接进入分支体。这是一种常见的编程技巧,用一行代码处理了"全部"和"指定色系"两种情况。

4.4 容量预警筛选函数

typescript 复制代码
function getLowCapacityPaints(): PlPaintItem[] {
  let r: PlPaintItem[] = []
  for (let i = 0; i < PL_PAINTS.length; i++) {
    if (PL_PAINTS[i].capacity < 50) {
      r.push(PL_PAINTS[i])
    }
  }
  return r
}

getLowCapacityPaints 函数筛选出剩余容量低于50%的涂料,用于容量预警清单的展示。阈值50%是一个业务决策值,低于这个值的涂料被认为需要补货提醒。

这个函数虽然简单,但体现了业务规则的封装思想。将"低容量"的判断逻辑封装在独立函数中,而不是散落在UI代码的各处,使得业务规则的变更更加容易。如果将来需要将预警阈值从50%调整为40%,只需要修改这一个函数即可。

4.5 喷笔综合排名函数

typescript 复制代码
function getRankedAirbrushes(): PlAirbrushItem[] {
  let r: PlAirbrushItem[] = []
  for (let i = 0; i < PL_AIRBRUSHES.length; i++) {
    r.push(PL_AIRBRUSHES[i])
  }
  r.sort((a: PlAirbrushItem, b: PlAirbrushItem) => b.score * 1000 + b.sales - a.score * 1000 - a.sales)
  return r
}

getRankedAirbrushes 函数是所有工具函数中算法最复杂的一个,它实现了喷笔的综合排名逻辑。排序的依据是评分与销量的加权组合,计算公式为 score * 1000 + sales

这个公式的设计很有意思:评分乘以1000后再加销量,意味着评分的权重远高于销量。例如,评分4.9的喷笔,score * 1000 = 4900,而销量通常只有几百的量级。因此,评分是排序的首要因素,只有当评分相同时,才会比较销量。

这种设计符合"综合排名"的业务语义:质量(评分)比 popularity(销量)更重要。使用乘法而非加权系数的方式,既简洁又高效,避免了浮点数运算的开销。

4.6 汇总统计函数

typescript 复制代码
function getPaintTotalSales(): number {
  let r: number = 0
  for (let i = 0; i < PL_PAINTS.length; i++) {
    r += PL_PAINTS[i].sales
  }
  return r
}

getPaintTotalSales 函数计算所有涂料的总销量。函数通过累加器模式遍历数组,将每款涂料的销量累加到变量 r 中,最终返回总和。这种累加器模式是计算总和的经典实现,简单而高效。

typescript 复制代码
function getMonthSpendTotal(): number {
  let r: number = 0
  for (let i = 0; i < PL_SPENDS.length; i++) {
    r += PL_SPENDS[i].amount
  }
  return r
}

getMonthSpendTotal 函数计算月度消费的总金额,实现方式与总销量函数类似,都是累加器模式的应用。这两个函数虽然逻辑简单,但在UI中被多次使用,如首页的"月销X瓶"、个人中心的"半年合计¥X"等。

4.7 教程筛选函数

typescript 复制代码
function getShownTutorials(onlyFree: boolean): PlTutorialItem[] {
  let r: PlTutorialItem[] = []
  for (let i = 0; i < PL_TUTORIALS.length; i++) {
    if (!onlyFree || PL_TUTORIALS[i].level <= 2) {
      r.push(PL_TUTORIALS[i])
    }
  }
  return r
}

getShownTutorials 函数根据 onlyFree 参数筛选教程。当 onlyFreetrue 时,只返回难度等级小于等于2的入门级教程(免费内容);当 onlyFreefalse 时,返回全部教程。

条件 !onlyFree || PL_TUTORIALS[i].level <= 2 的逻辑是:如果不限制免费(onlyFree 为假),则全部包含;如果限制免费,则只包含等级<=2的内容。这种写法利用了逻辑或的短路特性,代码简洁而表达力强。


五、入口主组件与导航系统

5.1 页面导航流程图

#mermaid-svg-86XNBPhRS5LvEdBi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-86XNBPhRS5LvEdBi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-86XNBPhRS5LvEdBi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-86XNBPhRS5LvEdBi .error-icon{fill:#552222;}#mermaid-svg-86XNBPhRS5LvEdBi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-86XNBPhRS5LvEdBi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-86XNBPhRS5LvEdBi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-86XNBPhRS5LvEdBi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-86XNBPhRS5LvEdBi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-86XNBPhRS5LvEdBi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-86XNBPhRS5LvEdBi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-86XNBPhRS5LvEdBi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-86XNBPhRS5LvEdBi .marker.cross{stroke:#333333;}#mermaid-svg-86XNBPhRS5LvEdBi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-86XNBPhRS5LvEdBi p{margin:0;}#mermaid-svg-86XNBPhRS5LvEdBi defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-86XNBPhRS5LvEdBi g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-86XNBPhRS5LvEdBi g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-86XNBPhRS5LvEdBi g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-86XNBPhRS5LvEdBi g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-86XNBPhRS5LvEdBi g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-86XNBPhRS5LvEdBi .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-86XNBPhRS5LvEdBi .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-86XNBPhRS5LvEdBi .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-86XNBPhRS5LvEdBi .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-86XNBPhRS5LvEdBi .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-86XNBPhRS5LvEdBi .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-86XNBPhRS5LvEdBi .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-86XNBPhRS5LvEdBi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-86XNBPhRS5LvEdBi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-86XNBPhRS5LvEdBi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-86XNBPhRS5LvEdBi .edgeLabel .label text{fill:#333;}#mermaid-svg-86XNBPhRS5LvEdBi .label div .edgeLabel{color:#333;}#mermaid-svg-86XNBPhRS5LvEdBi .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-86XNBPhRS5LvEdBi .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-86XNBPhRS5LvEdBi .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-86XNBPhRS5LvEdBi .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-86XNBPhRS5LvEdBi .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-86XNBPhRS5LvEdBi .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-86XNBPhRS5LvEdBi .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-86XNBPhRS5LvEdBi #statediagram-barbEnd{fill:#333333;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-86XNBPhRS5LvEdBi .cluster-label,#mermaid-svg-86XNBPhRS5LvEdBi .nodeLabel{color:#131300;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-86XNBPhRS5LvEdBi .note-edge{stroke-dasharray:5;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-note text{fill:black;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram-note .nodeLabel{color:black;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagram .edgeLabel{color:red;}#mermaid-svg-86XNBPhRS5LvEdBi #dependencyStart,#mermaid-svg-86XNBPhRS5LvEdBi #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-86XNBPhRS5LvEdBi .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-86XNBPhRS5LvEdBi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 应用启动
点击涂料Tab
点击喷笔Tab
点击素体Tab
点击教程Tab
点击我的Tab
返回首页
切换Tab
切换Tab
切换Tab
切换Tab
切换Tab
切换Tab
切换Tab
切换Tab
HOME
滑动
滑动
滑动
点击色卡
点击商品
关闭弹框
Dashboard
ColorWall
HotRank
FuncGrid
DetailModal
PAINTS
滑动
滑动
点击新增
点击色板
点击删除
点击色系分布
Filter
Grid
Warning
Series
AddModal
EditModal
DeleteModal
SeriesModal
AIRBRUSH
BODIES
TUTORIALS
PROFILE

5.2 Tab枚举定义

typescript 复制代码
enum PlTab {
  HOME = 0,
  PAINTS = 1,
  AIRBRUSH = 2,
  BODIES = 3,
  TUTORIALS = 4,
  PROFILE = 5
}

PlTab 枚举定义了六个Tab页的标识符,每个成员都对应一个数字值。使用枚举而非魔法数字(直接写0、1、2等)是良好的编程实践,它提高了代码的可读性与可维护性。当需要调整Tab顺序时,只需要修改枚举的值即可,不需要到处查找硬编码的数字。

枚举成员采用全大写下划线分隔的命名规范,这是TypeScript/ArkTS中枚举的标准命名方式。每个成员名都清晰地表达了对应页面的含义。

5.3 入口组件结构

typescript 复制代码
@Entry
@Component
struct PlApp {
  @State activeTab: PlTab = PlTab.HOME

PlApp 是应用的入口组件,使用 @Entry 装饰器标记,表示这是页面的根组件。@Component 装饰器表明这是一个自定义组件结构体。

@State activeTab: PlTab = PlTab.HOME 定义了组件的状态变量 activeTab,初始值为 PlTab.HOME(即首页)。@State 装饰器标记的变量是响应式的,当它的值发生变化时,框架会自动重新渲染依赖该状态的UI部分。

这是整个应用中最核心的状态变量,它控制着当前显示哪个Tab页。所有底部Tab按钮的点击事件最终都会修改这个状态,从而触发页面切换。

5.4 内容区域构建器

typescript 复制代码
@Builder contentArea() {
  Column() {
    if (this.activeTab === PlTab.HOME) {
      PlHomeContent()
    } else if (this.activeTab === PlTab.PAINTS) {
      PlPaintsContent()
    } else if (this.activeTab === PlTab.AIRBRUSH) {
      PlAirbrushContent()
    } else if (this.activeTab === PlTab.BODIES) {
      PlBodiesContent()
    } else if (this.activeTab === PlTab.TUTORIALS) {
      PlTutorialsContent()
    } else {
      PlProfileContent()
    }
  }
  .layoutWeight(1)
}

contentArea 是一个 @Builder 装饰的自定义构建函数,用于构建页面的内容区域。它使用 if-else 条件渲染,根据 activeTab 的值决定显示哪个页面组件。

@Builder 是ArkTS中的重要特性,它允许将可复用的UI片段抽取为独立的构建函数,提高代码的复用性与可读性。@Builder 函数遵循声明式UI语法,可以包含任何UI组件与布局结构。

外层的 Column 组件使用 .layoutWeight(1) 修饰,表示该列在父容器中占据剩余的全部空间。这是ArkUI弹性布局的重要属性,类似于Web开发中的 flex: 1

条件渲染的实现方式非常直观:通过 this.activeTab 与枚举值的比较,选择对应的页面组件进行渲染。当 activeTab 变化时,框架会自动diff并更新UI,实现页面切换的效果。

5.5 底部Tab项构建器

typescript 复制代码
@Builder bottomTabItem(icon: string, label: string, tab: PlTab) {
  Column() {
    Text(icon)
      .fontSize(20)
      .opacity(this.activeTab === tab ? 1.0 : 0.4)
    Text(label)
      .fontSize(9)
      .fontColor(this.activeTab === tab ? PL_ACCENT : PL_SUB)
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
    if (this.activeTab === tab) {
      Column()
        .width(16).height(3)
        .backgroundColor(PL_ACCENT)
        .borderRadius(2)
        .margin({ top: 3 })
    }
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .justifyContent(FlexAlign.Center)
  .padding({ top: 6, bottom: 6 })
  .onClick(() => { this.activeTab = tab })
}

bottomTabItem 是底部导航栏中单个Tab项的构建器函数,接收三个参数:icon 图标字符(emoji)、label 文字标签、tab 对应的Tab枚举值。

函数内部构建了一个纵向排列的 Column,包含图标、文字和选中指示器三个元素。选中状态的视觉反馈通过三种方式实现:

一是图标的透明度变化:选中时不透明度为1.0,未选中时为0.4。这种淡化效果既保持了图标的可识别性,又清晰地区分了选中与未选中状态。

二是文字的颜色与字重变化:选中时使用强调色 PL_ACCENT(喷涂橙)并加粗,未选中时使用次要文字色 PL_SUB。颜色变化是最直观的选中反馈,配合字重变化进一步增强视觉层次。

三是底部指示器条:只有选中状态下才会显示一个宽16px、高3px的圆角矩形条,颜色为强调色。这是移动端Tab导航的经典设计模式,为用户提供明确的当前位置指示。

Tab项的点击事件 .onClick(() => { this.activeTab = tab }) 非常简洁,直接修改 activeTab 状态即可。由于状态是响应式的,框架会自动更新所有依赖此状态的UI元素,包括内容区域的切换和所有Tab项的选中状态。

5.6 主构建函数

typescript 复制代码
build() {
  Column() {
    this.contentArea()
    Row() {
      this.bottomTabItem('🏠', '首页', PlTab.HOME)
      this.bottomTabItem('🎨', '涂料', PlTab.PAINTS)
      this.bottomTabItem('💨', '喷笔', PlTab.AIRBRUSH)
      this.bottomTabItem('🤖', '素体', PlTab.BODIES)
      this.bottomTabItem('📖', '教程', PlTab.TUTORIALS)
      this.bottomTabItem('👤', '我的', PlTab.PROFILE)
    }
    .width('100%')
    .backgroundColor(PL_CARD)
    .padding({ top: 4, bottom: 4 })
    .shadow({ radius: 10, color: '#143A4A5A', offsetY: -3 })
  }
  .width('100%').height('100%')
  .backgroundColor(PL_BG)
}

build 函数构建了应用的整体布局:外层是一个 Column,上方是内容区域 contentArea,下方是底部导航栏。

底部导航栏使用 Row 水平排列六个Tab项,每个Tab项通过 .layoutWeight(1) 平均分配宽度。导航栏背景为白色卡片色,上下各有4px的内边距,并设置了向上的阴影效果 offsetY: -3,营造出导航栏悬浮在内容上方的视觉层次感。

阴影颜色 #143A4A5A 是一个8位的十六进制颜色值,前两位 14 表示透明度(约8%),后六位 3A4A5A 是主色调。使用主色调的半透明阴影比纯黑阴影更加和谐,与整体配色风格统一。

最外层的 Column 设置了 width('100%')height('100%'),使其充满整个屏幕,背景色为页面背景色 PL_BG


六、首页仪表盘深度解析

6.1 首页组件结构总览

首页是用户进入应用后看到的第一个页面,承担着信息聚合与功能导航的双重角色。PlHomeContent 组件通过 @State 管理三个状态变量:plSprayY 用于喷雾粒子动画,showDetail 控制详情弹框的显示,selectedPaint 存储当前选中的涂料对象。

typescript 复制代码
@Component
struct PlHomeContent {
  @State plSprayY: number = 0
  @State showDetail: boolean = false
  @State selectedPaint: PlPaintItem | null = null

三个状态变量各司其职。plSprayY 是一个数值型状态,驱动喷雾粒子的上下浮动动画,类型为 number,初始值为0。showDetail 是布尔型状态,控制详情弹框的显示与隐藏,初始值为 false(隐藏)。selectedPaint 是联合类型,可以是 PlPaintItem 对象或 null,初始值为 null,表示当前没有选中的涂料。

这种状态划分体现了关注点分离的原则:动画状态、UI显隐状态、业务数据状态各自独立,互不干扰。每个状态变量只负责一个职责,使得状态管理更加清晰可控。

6.2 喷雾动画初始化

typescript 复制代码
aboutToAppear(): void {
  this.getUIContext().animateTo({
    duration: 1500,
    iterations: -1,
    curve: Curve.EaseOut,
    playMode: PlayMode.Alternate
  }, () => {
    this.plSprayY = -22
  })
}

aboutToAppear 是组件的生命周期函数,在组件即将出现时被调用。这里用来启动喷雾粒子的循环动画。

动画通过 this.getUIContext().animateTo() 方法创建,这是ArkUI中显式动画的标准写法。第一个参数是动画配置对象,第二个参数是动画结束时的状态闭包。

动画配置包含四个关键参数:

  • duration: 1500:动画持续时间为1500毫秒(1.5秒)
  • iterations: -1:迭代次数为-1,表示无限循环
  • curve: Curve.EaseOut:缓动曲线为EaseOut,即动画开始时快,结束时慢
  • playMode: PlayMode.Alternate:播放模式为交替模式,即动画正向播放后反向播放,形成往返效果

动画的目标状态是 this.plSprayY = -22,即将 plSprayY 的值从0变为-22。结合交替播放模式,实际效果是:plSprayY 从0平滑过渡到-22(1.5秒),再从-22平滑过渡回0(1.5秒),如此循环往复。

6.3 涂装仪表盘模块

涂装仪表盘是首页最上方的核心视觉模块,采用深色背景(工业灰蓝),展示用户的涂装进度与工作统计。

typescript 复制代码
Column() {
  Row() {
    Text('涂装仪表盘').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
    Column().layoutWeight(1)
    Text('本周更新').fontSize(9).fontColor('#B8C4D0')
      .backgroundColor('#54677A').borderRadius(8)
      .padding({ left: 8, right: 8, top: 3, bottom: 3 })
  }
  .width('100%')

仪表盘标题栏使用 Row 水平排列,左侧是标题文字"涂装仪表盘",右侧是一个"本周更新"标签。标签使用了较深的灰蓝色背景和浅色文字,形成内嵌式的视觉效果。

typescript 复制代码
Row() {
  Stack() {
    Progress({ value: 76, total: 100, type: ProgressType.Ring })
      .style({ strokeWidth: 9 })
      .color(PL_ACCENT)
      .backgroundColor('#54677A')
      .width(108).height(108)
    Column() {
      Text('76%').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text('总完成度').fontSize(10).fontColor('#B8C4D0').margin({ top: 2 })
    }
  }

左侧是一个环形进度条,使用 Progress 组件的 Ring 类型实现。进度值为76%,使用喷涂橙作为进度色,较深的灰蓝色作为背景色。环形的线宽(strokeWidth)为9px,整体尺寸为108x108px。

Stack 堆叠布局用于在环形进度条的中心叠加显示百分比文字。内部的 Column 垂直排列"76%"的大字号数值和"总完成度"的小字说明。这种堆叠布局是实现居中叠加效果的常用手法。

typescript 复制代码
Column() {
  Row() {
    Column().width(8).height(8).borderRadius(4).backgroundColor(PL_ACCENT)
    Text('进行中项目').fontSize(12).fontColor('#B8C4D0').margin({ left: 6 })
    Column().layoutWeight(1)
    Text('6 件').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
  }.width('100%').margin({ bottom: 9 })
  // ... 本月完工、喷涂工时类似
}
.layoutWeight(1).padding({ left: 18, right: 6 })

右侧是三项统计数据:进行中项目(6件)、本月完工(13件)、喷涂工时(86.5h)。每项都使用 Row 水平排列,包含一个彩色小圆点、标签文字和数值。

每项数据的圆点颜色不同:进行中项目用喷涂橙,本月完工用荧光绿,喷涂工时用珠金色。这种用颜色区分不同维度数据的设计,既美观又能帮助用户快速识别信息类型。

6.4 喷雾粒子特效

typescript 复制代码
Row() {
  Text('💨').fontSize(16)
  Circle().width(5).height(5).fill(PL_ACCENT)
    .translate({ x: -4, y: this.plSprayY * 1.3 }).opacity(0.85)
  Circle().width(4).height(4).fill('#B8C4D0')
    .translate({ x: 6, y: this.plSprayY * 0.7 }).opacity(0.6)
  // ... 更多粒子
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ top: 12, bottom: 4 })

喷雾粒子特效是一个非常有创意的UI细节。它使用8个 Circle 圆形组件模拟喷漆时飘散的颜料颗粒,配合 plSprayY 动画变量实现上下浮动的效果。

每个粒子有不同的大小、颜色、偏移量和不透明度,形成错落有致的视觉效果。translate 变换中的 y 值都与 this.plSprayY 相乘,但乘以不同的系数(0.6到1.6不等),使得各个粒子的浮动幅度不同,产生层次感和自然的飘动效果。

这种多粒子差异化运动的设计手法,在Web开发中通常需要复杂的JavaScript动画才能实现,而在ArkUI中,只需要通过声明式的属性绑定与动画系统配合即可轻松完成。

6.5 色卡横滑模块

typescript 复制代码
Scroll() {
  Row() {
    ForEach(PL_PAINTS, (p: PlPaintItem) => {
      Column() {
        Column() {
          Text(p.name.substring(0, 1)).fontSize(26).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        }
        .width(82).height(82).borderRadius(14)
        .backgroundColor(p.colorHex)
        .shadow({ radius: 8, color: p.colorHex })
        Text(p.name).fontSize(11).fontColor(PL_TEXT).margin({ top: 6 })
        Text(p.series + '系 · ' + p.capacity + '%')
          .fontSize(9).fontColor(PL_SUB).margin({ top: 1 })
      }
      .alignItems(HorizontalAlign.Center)
      .margin({ left: 6, right: 6 })
      .onClick(() => {
        this.selectedPaint = p
        this.showDetail = true
      })
    })
  }
  .padding({ left: 10, right: 10 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.width('100%')

色卡横滑模块使用水平滚动的 Scroll 组件包裹一个横向排列的 Row,通过 ForEach 遍历涂料数据数组,生成一排可滑动的色卡。

每个色卡是一个纵向排列的 Column,上方是一个82x82px的圆角正方形色块,使用涂料的实际颜色作为背景色。色块内部显示涂料名称的第一个字(p.name.substring(0, 1)),使用大号白色粗体字。色块还设置了同色系的阴影,产生发光的视觉效果。

色块下方显示涂料的全名和"色系·容量"的说明文字。整个色卡可以点击,点击后将当前涂料赋值给 selectedPaint 状态,并将 showDetail 设为 true,弹出详情弹框。

Scroll 组件设置了 .scrollable(ScrollDirection.Horizontal) 表示水平滚动,.scrollBar(BarState.Off) 隐藏滚动条,提供更简洁的视觉效果。

6.6 热销排行榜

typescript 复制代码
Column() {
  ForEach(getHotPaints().slice(0, 5), (p: PlPaintItem, idx: number) => {
    Row() {
      Text((idx + 1).toString())
        .fontSize(idx < 3 ? 18 : 14).fontWeight(FontWeight.Bold)
        .fontColor(idx === 0 ? PL_ACCENT : (idx === 1 ? PL_PRIMARY : (idx === 2 ? '#B0713C' : PL_SUB)))
        .width(30).textAlign(TextAlign.Center)

热销排行榜展示销量最高的5款涂料,使用 getHotPaints().slice(0, 5) 获取排序后的前5项数据。

排名数字的样式根据名次不同而变化:

  • 第1名(idx===0):喷涂橙色,字号18px
  • 第2名(idx===1):工业灰蓝色,字号18px
  • 第3名(idx===2):铜棕色,字号18px
  • 第4、5名:次要灰色,字号14px

这种前三名突出显示的设计是排行榜的经典模式,金银铜牌的配色方案(橙、蓝、铜)既直观又富有仪式感。

排名右侧是涂料名称与系列信息,再右侧是价格信息。整行可以点击,点击后同样弹出详情弹框,与色卡墙的交互保持一致。

6.7 功能宫格

typescript 复制代码
Grid() {
  ForEach(PL_FUNCS, (f: PlFuncMeta) => {
    GridItem() {
      Column() {
        Text(f.icon).fontSize(22)
          .width(44).height(44).borderRadius(22)
          .backgroundColor(f.bg).textAlign(TextAlign.Center)
        Text(f.label).fontSize(11).fontColor(PL_TEXT).margin({ top: 7 })
      }
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)
      .padding({ top: 10, bottom: 10 })
    }
  })
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')
.columnsGap(6).rowsGap(6)
.height(190)
.width('100%')

功能宫格使用 Grid 网格布局组件实现,8个功能项排列为4列2行。.columnsTemplate('1fr 1fr 1fr 1fr') 定义了4列等宽的网格,.rowsTemplate('1fr 1fr') 定义了2行等高的网格。

每个网格项 GridItem 内部是一个纵向排列的 Column,上方是圆形背景的图标(emoji字符),下方是功能名称文字。图标的背景色和文字色由数据中的 bgcolor 字段决定,8个功能项各有不同的配色方案。

Grid 组件提供了比手动组合Row/Column更专业的网格布局能力,支持列模板、行模板、列间距、行间距等精细控制。这种声明式的网格定义方式,比传统CSS Grid的写法更加直观。


七、弹窗交互系统深度解析

7.1 弹窗交互流程图

弹窗层 状态变量 界面组件 用户 弹窗层 状态变量 界面组件 用户 #mermaid-svg-sqEgfrObSwCz5mjY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sqEgfrObSwCz5mjY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sqEgfrObSwCz5mjY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sqEgfrObSwCz5mjY .error-icon{fill:#552222;}#mermaid-svg-sqEgfrObSwCz5mjY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sqEgfrObSwCz5mjY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sqEgfrObSwCz5mjY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sqEgfrObSwCz5mjY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sqEgfrObSwCz5mjY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sqEgfrObSwCz5mjY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sqEgfrObSwCz5mjY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sqEgfrObSwCz5mjY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sqEgfrObSwCz5mjY .marker.cross{stroke:#333333;}#mermaid-svg-sqEgfrObSwCz5mjY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sqEgfrObSwCz5mjY p{margin:0;}#mermaid-svg-sqEgfrObSwCz5mjY .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sqEgfrObSwCz5mjY text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-sqEgfrObSwCz5mjY .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sqEgfrObSwCz5mjY .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-sqEgfrObSwCz5mjY .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-sqEgfrObSwCz5mjY .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-sqEgfrObSwCz5mjY #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-sqEgfrObSwCz5mjY .sequenceNumber{fill:white;}#mermaid-svg-sqEgfrObSwCz5mjY #sequencenumber{fill:#333;}#mermaid-svg-sqEgfrObSwCz5mjY #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-sqEgfrObSwCz5mjY .messageText{fill:#333;stroke:none;}#mermaid-svg-sqEgfrObSwCz5mjY .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sqEgfrObSwCz5mjY .labelText,#mermaid-svg-sqEgfrObSwCz5mjY .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-sqEgfrObSwCz5mjY .loopText,#mermaid-svg-sqEgfrObSwCz5mjY .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-sqEgfrObSwCz5mjY .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sqEgfrObSwCz5mjY .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-sqEgfrObSwCz5mjY .noteText,#mermaid-svg-sqEgfrObSwCz5mjY .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-sqEgfrObSwCz5mjY .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sqEgfrObSwCz5mjY .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sqEgfrObSwCz5mjY .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sqEgfrObSwCz5mjY .actorPopupMenu{position:absolute;}#mermaid-svg-sqEgfrObSwCz5mjY .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-sqEgfrObSwCz5mjY .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sqEgfrObSwCz5mjY .actor-man circle,#mermaid-svg-sqEgfrObSwCz5mjY line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-sqEgfrObSwCz5mjY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 弹框类型包括:详情、新增、删除确认、编辑配方、色系选择 点击色卡/商品selectedPaint = 涂料对象showDetail = true触发响应式更新渲染modalOverlay + 内容卡显示详情弹框点击遮罩层showDetail = false触发响应式更新移除弹框弹框消失点击删除按钮selectedPaint = 涂料对象showDeleteConfirm = true渲染删除确认弹框

7.2 遮罩层构建器

typescript 复制代码
@Builder modalOverlay(onClose: () => void) {
  Column()
    .width('100%').height('100%')
    .backgroundColor('rgba(20,26,34,0.55)')
    .onClick(onClose)
}

modalOverlay 是一个通用的遮罩层构建器,接收一个 onClose 回调函数作为参数。遮罩层是一个全屏的半透明黑色层,点击时触发关闭回调。

背景色使用 rgba(20,26,34,0.55),其中RGB值为(20,26,34)的深色调,透明度为55%。这种深青色的半透明遮罩比纯黑遮罩更有质感,与应用的工业灰蓝色调更加协调。

将遮罩层抽象为独立的 @Builder 函数是一个很好的实践,它在多个弹框中被复用,避免了重复代码。

7.3 涂料详情弹框

涂料详情弹框是首页中最复杂的弹框组件,采用底部抽屉样式,展示选中涂料的详细信息。

typescript 复制代码
@Builder paintDetailModal() {
  Column() {
    this.modalOverlay(() => { this.showDetail = false })
    Column() {
      Column()
        .width(44).height(5).borderRadius(3)
        .backgroundColor('#DDE3E9').margin({ top: 10 })

弹框的整体结构是:外层 Column 包含全屏遮罩和内容卡片。内容卡片顶部有一个小的拖拽指示条(宽44px,高5px的圆角矩形),这是底部抽屉样式的标志性设计元素,暗示用户可以上下拖拽。

typescript 复制代码
Row() {
  Column() {
    Text('🎨').fontSize(34).fontColor('#FFFFFF')
  }
  .width(92).height(92).borderRadius(18)
  .backgroundColor(this.selectedPaint!.colorHex)
  .shadow({ radius: 18, color: this.selectedPaint!.colorHex })

内容区域顶部是色卡展示区。左侧是一个92x92px的大圆角正方形色块,背景色为选中涂料的颜色,带有同色系的发光阴影。色块中央显示一个调色板emoji图标。

注意这里使用了 this.selectedPaint!.colorHex 的非空断言操作符 !。这是因为 selectedPaint 的类型是 PlPaintItem | null,而弹框只在 showDetailtrue 时才会渲染,此时 selectedPaint 一定有值。使用 ! 告诉编译器"我确定它不为空",避免类型错误。

typescript 复制代码
Column() {
  Text(this.selectedPaint!.name)
    .fontSize(19).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
  Text(this.selectedPaint!.series + '系 · ' + this.selectedPaint!.effect + '漆面')
    .fontSize(12).fontColor(PL_SUB).margin({ top: 4 })
  Text('编号 PL-P' + this.selectedPaint!.id + ' · ' + this.selectedPaint!.brand)
    .fontSize(11).fontColor(PL_SUB).margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1).padding({ left: 14 })

右侧是涂料的文字信息,包括名称、系列+漆面效果、编号+品牌三行文字,字号逐级减小,形成清晰的信息层级。

typescript 复制代码
Row() {
  Text('剩余容量').fontSize(12).fontColor(PL_SUB)
  Column().layoutWeight(1)
  Text(this.selectedPaint!.capacity + '%')
    .fontSize(12).fontWeight(FontWeight.Bold)
    .fontColor(this.selectedPaint!.capacity < 30 ? '#C0392B' : (this.selectedPaint!.capacity < 60 ? PL_ACCENT : '#2E7D5B'))
}

容量进度条区域上方是一行说明文字,左侧是"剩余容量"标签,右侧是容量百分比数值。数值的颜色根据容量等级动态变化:

  • 低于30%:红色(警告色)
  • 30%-60%:喷涂橙(注意色)
  • 高于60%:绿色(正常色)

这种三级颜色编码系统让用户一眼就能判断涂料的余量状态,是数据可视化中"语义化颜色"的典型应用。

typescript 复制代码
Progress({ value: this.selectedPaint!.capacity, total: 100, type: ProgressType.Linear })
  .color(this.selectedPaint!.capacity < 30 ? '#C0392B' : PL_ACCENT)
  .style({ strokeWidth: 8 })
  .width('88%').margin({ top: 6 })

进度条组件的颜色也有动态变化,但只有两级:低于30%时为红色,其余为橙色。这是因为进度条作为进度指示器,主要区分"危险"和"正常"两种状态即可。

typescript 复制代码
Row() {
  Column() {
    Text(this.selectedPaint!.sales.toString())
      .fontSize(16).fontWeight(FontWeight.Bold).fontColor(PL_PRIMARY)
    Text('累计销量').fontSize(10).fontColor(PL_SUB).margin({ top: 2 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  Column().width(1).height(26).backgroundColor(PL_LINE)
  // ... 现货库存、推荐漆面
}
.width('100%').padding({ top: 14, bottom: 14 })
.backgroundColor('#F5F7F9').borderRadius(12)
.margin({ top: 16 })

三栏统计区使用浅灰色背景卡片,内部用竖线分割为三列:累计销量、现货库存、推荐漆面。每列上方是数值,下方是标签。这种等分三栏的布局方式在移动端应用中非常常见,能够高效展示多维度的汇总数据。

typescript 复制代码
Row() {
  Column() {
    Row() {
      Text('¥').fontSize(12).fontColor(PL_ACCENT)
      Text(this.selectedPaint!.price.toFixed(1))
        .fontSize(22).fontWeight(FontWeight.Bold).fontColor(PL_ACCENT)
    }.alignItems(VerticalAlign.Bottom)
    Text('原价 ¥' + (this.selectedPaint!.price * 1.25).toFixed(1))
      .fontSize(10).fontColor(PL_SUB).margin({ top: 2 })
      .decoration({ type: TextDecorationType.LineThrough })
  }.alignItems(HorizontalAlign.Start)

价格展示区的设计很有电商特色:当前价格用大号喷涂橙粗体字,前面的"¥"符号字号较小但底部对齐;原价用灰色小字并添加删除线。原价通过 price * 1.25 动态计算,即当前价格是原价的8折。

typescript 复制代码
Text('加入收藏')
  .fontSize(13).fontColor(PL_PRIMARY)
  .borderRadius(20).border({ width: 1, color: PL_PRIMARY })
  .padding({ left: 16, right: 16, top: 9, bottom: 9 })
  .onClick(() => { this.showDetail = false })
Text('立即下单')
  .fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
  .backgroundColor(PL_ACCENT).borderRadius(20)
  .padding({ left: 18, right: 18, top: 9, bottom: 9 })
  .margin({ left: 10 })
  .onClick(() => { this.showDetail = false })

底部操作区有两个按钮:"加入收藏"是描边按钮(空心),"立即下单"是实心填充按钮。"立即下单"作为主要操作,使用强调色填充,视觉权重更高;"加入收藏"作为次要操作,使用描边样式,视觉权重较低。这种主次要按钮的视觉区分是交互设计的基本原则。


八、涂料仓页面深度解析

8.1 涂料仓状态管理

涂料仓是应用中功能最丰富的页面,包含4种弹框和多个交互状态。PlPaintsContent 组件管理着丰富的状态变量。

typescript 复制代码
@State filterSeries: string = '全部'
@State showAddModal: boolean = false
@State showDeleteConfirm: boolean = false
@State showEditModal: boolean = false
@State showSeriesModal: boolean = false
@State selectedPaint: PlPaintItem | null = null
@State formName: string = ''
@State formColor: string = ''
@State formCapacity: string = ''
@State formPrice: string = ''
@State formSeries: string = '基础'
@State recipeRatio: string = '1:1 标准喷涂'
@State plDropY: number = 0
@State plPulse: number = 0

12个状态变量可以分为几类:

  • 筛选状态filterSeries 记录当前选中的色系筛选条件
  • 弹框显隐状态showAddModalshowDeleteConfirmshowEditModalshowSeriesModal 四个布尔值控制四个不同弹框的显示
  • 选中数据状态selectedPaint 存储当前操作的涂料对象
  • 表单状态formNameformColorformCapacityformPriceformSeries 存储新增涂料表单中的输入值
  • 配方状态recipeRatio 存储编辑配方时选中的稀释比例
  • 动画状态plDropYplPulse 控制颜料滴落和脉冲发光动画

虽然状态变量较多,但每个状态都有明确的单一职责,整体管理是清晰的。这种"一个状态控制一件事"的设计哲学,使得状态变更的追踪和调试都相对容易。

8.2 双动画并行机制

typescript 复制代码
aboutToAppear(): void {
  this.getUIContext().animateTo({
    duration: 1300,
    iterations: -1,
    curve: Curve.EaseIn,
    playMode: PlayMode.Alternate
  }, () => {
    this.plDropY = 10
  })
  this.getUIContext().animateTo({
    duration: 900,
    iterations: -1,
    curve: Curve.EaseInOut,
    playMode: PlayMode.Alternate
  }, () => {
    this.plPulse = 1
  })
}

涂料仓页面有两个并行的动画循环,分别驱动不同的视觉效果:

第一个动画控制颜料滴落效果 plDropY:持续时间1300ms,缓动曲线为EaseIn(开始慢、结束快,模拟重力加速),交替播放。目标值为10,即滴落位移量为10px。

第二个动画控制脉冲发光效果 plPulse:持续时间900ms,缓动曲线为EaseInOut(慢-快-慢,呼吸感),交替播放。目标值为1,即脉冲强度从0变到1。

两个动画使用不同的时长和缓动曲线,使得它们的节奏不同步,产生更自然、更丰富的视觉效果。如果两个动画完全同步,反而会显得机械呆板。

8.3 深色头部横幅

typescript 复制代码
Column() {
  Row() {
    Column() {
      Text('涂料仓').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
      Text('10 款在库 · 总销量 ' + getPaintTotalSales() + ' 瓶')
        .fontSize(10).fontColor('#B8C4D0').margin({ top: 3 })
    }
    .alignItems(HorizontalAlign.Start)
    Column().layoutWeight(1)
    Text('+ 新增涂料')
      .fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
      .backgroundColor(PL_ACCENT).borderRadius(16)
      .padding({ left: 14, right: 14, top: 8, bottom: 8 })
      .onClick(() => { this.showAddModal = true })
  }

涂料仓的头部使用工业灰蓝深色背景,与首页仪表盘的风格一致。左侧是标题和副标题,右侧是一个"+ 新增涂料"按钮,点击后弹出新增涂料的表单弹框。

标题下方的副标题动态展示了涂料数量和总销量,其中总销量通过调用 getPaintTotalSales() 函数实时计算。这种在UI构建中直接调用函数的写法,体现了声明式UI中"UI是状态的函数"的思想。

8.4 色系筛选 Chips

typescript 复制代码
Scroll() {
  Row() {
    ForEach(PL_SERIES_FILTER, (s: string) => {
      if (this.filterSeries === s) {
        Text(s === '全部' ? '🎛 全部' : s)
          .fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          .backgroundColor(PL_PRIMARY).borderRadius(14)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .margin({ left: 4, right: 4 })
      } else {
        Text(s === '全部' ? '🎛 全部' : s)
          .fontSize(11).fontColor(PL_PRIMARY)
          .backgroundColor(PL_CARD).borderRadius(14)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .margin({ left: 4, right: 4 })
          .onClick(() => { this.filterSeries = s })
      }
    })
  }
  .padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
.width('100%').margin({ top: 12 })

色系筛选栏使用横向滚动的Chips(标签)布局。每个色系对应一个可点击的标签,选中状态和未选中状态有不同的视觉样式。

选中状态:白色文字、加粗、主色背景

未选中状态:主色文字、白色背景、可点击

Chips布局是移动端筛选功能的经典交互模式,比下拉选择更直观、操作步骤更少。通过 ForEach 遍历 PL_SERIES_FILTER 数组生成标签,数据驱动的方式使得筛选选项的增改变得非常容易。

8.5 色板 Grid 与动画特效

typescript 复制代码
Grid() {
  ForEach(getPaintsBySeries(this.filterSeries), (p: PlPaintItem) => {
    GridItem() {
      Column() {
        Column() {
          Text(p.name.substring(0, 1))
            .fontSize(30).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            .opacity(0.9)
        }
        .width('100%').height(86).borderRadius(12)
        .backgroundColor(p.colorHex)
        .shadow({ radius: 4 + this.plPulse * 16, color: p.colorHex })
        .scale({ x: 1 + this.plPulse * 0.04, y: 1 + this.plPulse * 0.04 })
        .onClick(() => {
          this.selectedPaint = p
          this.showEditModal = true
        })

色板区域使用三列网格布局展示涂料。每个涂料卡片的核心是上方的色块,色块有两个动态特效:

脉冲发光效果:.shadow({ radius: 4 + this.plPulse * 16, color: p.colorHex }) 阴影的半径随 plPulse 值变化。当 plPulse 为0时,阴影半径为4px(基础阴影);当 plPulse 为1时,阴影半径为20px(最大发光效果)。阴影颜色与涂料颜色相同,产生同色发光的视觉效果。

脉冲缩放效果:.scale({ x: 1 + this.plPulse * 0.04, y: 1 + this.plPulse * 0.04 }) 色块的缩放比例也随 plPulse 变化,最大放大4%。缩放与发光同步进行,模拟呼吸般的脉动效果。

typescript 复制代码
Circle().width(5).height(5).fill(p.colorHex)
  .translate({ y: this.plDropY })
  .opacity(1 - this.plDropY / 11)
  .margin({ top: 1 })

色块下方还有一个颜料滴落特效:一个直径5px的小圆点,颜色与涂料相同。它的Y轴位移随 plDropY 变化,同时不透明度随位移增加而降低(1 - plDropY / 11),模拟颜料滴下并逐渐消失的效果。

这种微交互动效虽然不影响功能,但极大地提升了应用的品质感和趣味性,让静态的色板"活"了起来。

8.6 容量预警清单

typescript 复制代码
Column() {
  ForEach(getLowCapacityPaints(), (p: PlPaintItem) => {
    Row() {
      Column().width(14).height(14).borderRadius(4).backgroundColor(p.colorHex)
      Column() {
        Text(p.name + ' · ' + p.brand).fontSize(12).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
        Text('库存 ' + p.stock + ' 瓶 · ' + p.effect + '漆面')
          .fontSize(9).fontColor(PL_SUB).margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1).padding({ left: 10 })
      Column() {
        Progress({ value: p.capacity, total: 100, type: ProgressType.Linear })
          .color(p.capacity < 30 ? '#C0392B' : PL_ACCENT)
          .style({ strokeWidth: 6 })
          .width(90)
        Text(p.capacity + '%')
          .fontSize(10).fontWeight(FontWeight.Bold)
          .fontColor(p.capacity < 30 ? '#C0392B' : PL_ACCENT)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      Text('补货')
        .fontSize(10).fontColor('#FFFFFF')
        .backgroundColor(PL_ACCENT).borderRadius(10)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .margin({ left: 10 })
        .onClick(() => {
          this.selectedPaint = p
          this.showEditModal = true
        })
    }
    .width('100%').alignItems(VerticalAlign.Center)
    .padding({ left: 14, right: 14, top: 11, bottom: 11 })
    .backgroundColor(PL_CARD).borderRadius(12)
    .margin({ bottom: 8 })
  })
}

容量预警清单展示所有剩余容量低于50%的涂料,提醒用户及时补货。每项预警包含:颜色标识方块、涂料名称品牌、库存与漆面效果、容量进度条+百分比、补货按钮。

进度条和百分比数值的颜色同样使用了三级颜色编码(红/橙/绿),但这里的进度条颜色只用了两级(红/橙),因为预警清单中的涂料本身就已经低于50%了。

"补货"按钮点击后会弹出编辑配方弹框 showEditModal = true,用户可以在弹框中编辑喷涂配方并调整容量。这是一个合理的交互设计,补货操作通常伴随着配方的调整。

8.7 色系分布统计

typescript 复制代码
Column() {
  ForEach(PL_SERIES_LIST, (s: PlSeriesMeta) => {
    Column() {
      Row() {
        Column().width(12).height(12).borderRadius(6).backgroundColor(s.colorHex)
        Text(s.label + '系').fontSize(12).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          .margin({ left: 8 })
        Column().layoutWeight(1)
        Text(s.count + ' 瓶 · ' + s.pct + '%')
          .fontSize(10).fontColor(PL_SUB)
      }
      .width('100%')
      Text(s.desc).fontSize(9).fontColor(PL_SUB)
        .margin({ top: 3 })
      Row() {
        Column().height(7).borderRadius(4)
          .backgroundColor(s.colorHex)
          .width(s.pct + '%')
        Column().layoutWeight(1)
      }
      .width('100%').margin({ top: 6 })
    }
    .width('100%').backgroundColor(PL_CARD).borderRadius(12)
    .padding({ left: 14, right: 14, top: 11, bottom: 11 })
    .margin({ bottom: 8 })
    .onClick(() => {
      this.filterSeries = s.label
      this.showSeriesModal = false
    })
  })
}

色系分布区域以卡片列表的形式展示每个色系的详细信息,包括色系名称、颜色圆点、涂料数量和占比、色系描述文字,以及可视化的进度条。

进度条的宽度通过 .width(s.pct + '%') 动态设置,直接使用百分比数值作为宽度百分比。这种将数据值直接映射为UI属性的写法,是声明式UI的典型特征,非常直观。

每个色系卡片都可以点击,点击后设置 filterSeries 为对应色系并关闭色系选择弹框。这意味着色系分布图不仅是统计展示,还可以作为筛选器使用,实现了"展示即操作"的交互理念。


九、弹框组件体系详解

9.1 组件依赖图

#mermaid-svg-30SJdjIYwVUUG32n{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-30SJdjIYwVUUG32n .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-30SJdjIYwVUUG32n .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-30SJdjIYwVUUG32n .error-icon{fill:#552222;}#mermaid-svg-30SJdjIYwVUUG32n .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-30SJdjIYwVUUG32n .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-30SJdjIYwVUUG32n .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-30SJdjIYwVUUG32n .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-30SJdjIYwVUUG32n .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-30SJdjIYwVUUG32n .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-30SJdjIYwVUUG32n .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-30SJdjIYwVUUG32n .marker{fill:#333333;stroke:#333333;}#mermaid-svg-30SJdjIYwVUUG32n .marker.cross{stroke:#333333;}#mermaid-svg-30SJdjIYwVUUG32n svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-30SJdjIYwVUUG32n p{margin:0;}#mermaid-svg-30SJdjIYwVUUG32n .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-30SJdjIYwVUUG32n .cluster-label text{fill:#333;}#mermaid-svg-30SJdjIYwVUUG32n .cluster-label span{color:#333;}#mermaid-svg-30SJdjIYwVUUG32n .cluster-label span p{background-color:transparent;}#mermaid-svg-30SJdjIYwVUUG32n .label text,#mermaid-svg-30SJdjIYwVUUG32n span{fill:#333;color:#333;}#mermaid-svg-30SJdjIYwVUUG32n .node rect,#mermaid-svg-30SJdjIYwVUUG32n .node circle,#mermaid-svg-30SJdjIYwVUUG32n .node ellipse,#mermaid-svg-30SJdjIYwVUUG32n .node polygon,#mermaid-svg-30SJdjIYwVUUG32n .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-30SJdjIYwVUUG32n .rough-node .label text,#mermaid-svg-30SJdjIYwVUUG32n .node .label text,#mermaid-svg-30SJdjIYwVUUG32n .image-shape .label,#mermaid-svg-30SJdjIYwVUUG32n .icon-shape .label{text-anchor:middle;}#mermaid-svg-30SJdjIYwVUUG32n .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-30SJdjIYwVUUG32n .rough-node .label,#mermaid-svg-30SJdjIYwVUUG32n .node .label,#mermaid-svg-30SJdjIYwVUUG32n .image-shape .label,#mermaid-svg-30SJdjIYwVUUG32n .icon-shape .label{text-align:center;}#mermaid-svg-30SJdjIYwVUUG32n .node.clickable{cursor:pointer;}#mermaid-svg-30SJdjIYwVUUG32n .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-30SJdjIYwVUUG32n .arrowheadPath{fill:#333333;}#mermaid-svg-30SJdjIYwVUUG32n .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-30SJdjIYwVUUG32n .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-30SJdjIYwVUUG32n .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-30SJdjIYwVUUG32n .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-30SJdjIYwVUUG32n .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-30SJdjIYwVUUG32n .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-30SJdjIYwVUUG32n .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-30SJdjIYwVUUG32n .cluster text{fill:#333;}#mermaid-svg-30SJdjIYwVUUG32n .cluster span{color:#333;}#mermaid-svg-30SJdjIYwVUUG32n div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-30SJdjIYwVUUG32n .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-30SJdjIYwVUUG32n rect.text{fill:none;stroke-width:0;}#mermaid-svg-30SJdjIYwVUUG32n .icon-shape,#mermaid-svg-30SJdjIYwVUUG32n .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-30SJdjIYwVUUG32n .icon-shape p,#mermaid-svg-30SJdjIYwVUUG32n .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-30SJdjIYwVUUG32n .icon-shape .label rect,#mermaid-svg-30SJdjIYwVUUG32n .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-30SJdjIYwVUUG32n .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-30SJdjIYwVUUG32n .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-30SJdjIYwVUUG32n :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 弹框系统
modalOverlay 遮罩层
addPaintModal 新增涂料
deleteConfirmModal 删除确认
editRecipeModal 编辑配方
seriesSelectModal 色系选择
paintDetailModal 涂料详情
PlHomeContent
PlPaintsContent

9.2 新增涂料弹框

新增涂料弹框是一个居中的大卡片表单,用于录入新涂料的信息。

typescript 复制代码
@Builder addPaintModal() {
  Column() {
    this.modalOverlay(() => { this.showAddModal = false })
    Column() {
      Row() {
        Column().width(4).height(18).borderRadius(2).backgroundColor(PL_ACCENT)
        Text('新增涂料入库').fontSize(17).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          .margin({ left: 8 })
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor(PL_SUB)
          .onClick(() => { this.showAddModal = false })
      }

弹框标题栏左侧有一个橙色的竖条装饰,这是一种常见的标题视觉设计手法,通过色块强调标题区域。右侧是关闭按钮,点击后关闭弹框。

typescript 复制代码
Scroll() {
  Column() {
    Text('涂料名称').fontSize(11).fontColor(PL_SUB)
      .margin({ top: 10 }).width('100%')
    TextInput({ placeholder: '如:星夜蓝' })
      .placeholderColor('#BFC7D0').fontSize(13)
      .backgroundColor('#F5F7F9').borderRadius(8)
      .width('100%').margin({ top: 4 })
      .onChange((v: string) => { this.formName = v })

表单区域使用 Scroll 包裹,防止内容过多时超出屏幕。每个表单项由标签文字和输入框组成,输入框使用浅灰色背景和圆角设计,风格简洁。

TextInputonChange 回调将输入值同步到对应的 @State 变量中。这是受控组件的标准模式:输入框的值由状态控制,用户输入通过事件回调更新状态。

typescript 复制代码
Row() {
  Column() {
    Text('剩余容量 %').fontSize(11).fontColor(PL_SUB).width('100%')
    TextInput({ placeholder: '0-100' })
      .placeholderColor('#BFC7D0').fontSize(13)
      .backgroundColor('#F5F7F9').borderRadius(8)
      .width('100%').margin({ top: 4 })
      .onChange((v: string) => { this.formCapacity = v })
  }.layoutWeight(1)
  Column() {
    Text('单价 ¥').fontSize(11).fontColor(PL_SUB).width('100%')
    TextInput({ placeholder: '如:28.5' })
      .placeholderColor('#BFC7D0').fontSize(13)
      .backgroundColor('#F5F7F9').borderRadius(8)
      .width('100%').margin({ top: 4 })
      .onChange((v: string) => { this.formPrice = v })
  }.layoutWeight(1).padding({ left: 10 })
}

容量和单价两个输入项使用两列并排布局,通过两个 Column 各设置 .layoutWeight(1) 实现等分宽度。这种并排布局节省了垂直空间,也让相关的输入项在视觉上更紧凑。

typescript 复制代码
Text('所属色系').fontSize(11).fontColor(PL_SUB)
  .margin({ top: 12 }).width('100%')
Row() {
  ForEach(PL_SERIES_LIST, (s: PlSeriesMeta) => {
    if (this.formSeries === s.label) {
      Text(s.label)
        .fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        .backgroundColor(PL_PRIMARY).borderRadius(12)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .margin({ right: 6 })
    } else {
      Text(s.label)
        .fontSize(11).fontColor(PL_PRIMARY)
        .backgroundColor('#EDF1F5').borderRadius(12)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .margin({ right: 6 })
        .onClick(() => { this.formSeries = s.label })
    }
  })
}

色系选择使用横向排列的Chips标签,与筛选栏的交互模式一致。选中状态使用主色填充白字,未选中状态使用浅灰背景主色文字。点击未选中的标签即可切换选中的色系。

typescript 复制代码
Row() {
  Text('取消')
    .fontSize(13).fontColor(PL_SUB)
    .backgroundColor('#F0F2F5').borderRadius(20)
    .padding({ left: 26, right: 26, top: 10, bottom: 10 })
    .onClick(() => { this.showAddModal = false })
  Text('确认入库')
    .fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
    .backgroundColor(PL_ACCENT).borderRadius(20)
    .padding({ left: 26, right: 26, top: 10, bottom: 10 })
    .margin({ left: 12 })
    .onClick(() => { this.showAddModal = false })
}

底部操作按钮使用了典型的"取消/确认"双按钮布局。取消按钮为灰色次要样式,确认按钮为橙色主要样式。按钮使用胶囊形圆角(borderRadius: 20),视觉上更加圆润友好。

9.3 删除确认弹框

删除确认弹框是一个小型警示卡片,用于在用户执行删除操作前进行二次确认。

typescript 复制代码
@Builder deleteConfirmModal() {
  Column() {
    this.modalOverlay(() => { this.showDeleteConfirm = false })
    Column() {
      Text('🗑').fontSize(42).margin({ top: 22 })
      Text('确认下架该涂料?')
        .fontSize(17).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
        .margin({ top: 8 })
      Text('下架后配方记录将保留 30 天')
        .fontSize(11).fontColor('#C0392B').margin({ top: 4 })

弹框顶部是一个大号的垃圾桶emoji图标,下方是确认标题和提示文字。提示文字使用红色,强调删除操作的风险性。"下架后配方记录将保留30天"的说明文字可以减轻用户的焦虑,让他们知道删除不是永久性的。

typescript 复制代码
Row() {
  Column().width(26).height(26).borderRadius(8)
    .backgroundColor(this.selectedPaint!.colorHex)
  Text(this.selectedPaint!.name)
    .fontSize(13).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
    .margin({ left: 8 })
  Column().layoutWeight(1)
  Text('剩 ' + this.selectedPaint!.capacity + '%')
    .fontSize(11).fontColor(PL_SUB)
}
.width('86%').alignItems(VerticalAlign.Center)
.backgroundColor('#FBF1F0').borderRadius(10)
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.margin({ top: 14 })

中间区域展示了即将删除的涂料信息,包括颜色方块、名称和剩余容量。背景使用淡红色(#FBF1F0),与删除操作的警示主题相呼应。这种在确认弹框中回显操作对象信息的设计,可以有效防止用户误操作。

typescript 复制代码
Row() {
  Text('再想想')
    .fontSize(13).fontColor(PL_SUB)
    .backgroundColor('#F0F2F5').borderRadius(20)
    .padding({ left: 26, right: 26, top: 10, bottom: 10 })
    .onClick(() => { this.showDeleteConfirm = false })
  Text('确认下架')
    .fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
    .backgroundColor('#C0392B').borderRadius(20)
    .padding({ left: 26, right: 26, top: 10, bottom: 10 })
    .margin({ left: 12 })
    .onClick(() => { this.showDeleteConfirm = false })
}

底部按钮的设计也体现了对删除操作的重视:取消按钮文案为"再想想"(而非简单的"取消"),更具人性化;确认按钮使用红色背景(#C0392B),与其他弹框的橙色确认按钮不同,红色强烈暗示危险操作。

9.4 编辑配方弹框

编辑配方弹框采用橙色头带的卡片设计,用于编辑涂料的喷涂配方参数。

typescript 复制代码
@Builder editRecipeModal() {
  Column() {
    this.modalOverlay(() => { this.showEditModal = false })
    Column() {
      Column() {
        Row() {
          Text('✏️ 编辑喷涂配方').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          Column().layoutWeight(1)
          Text('✕').fontSize(15).fontColor('#FFE3C7')
            .onClick(() => { this.showEditModal = false })
        }
        .width('100%').alignItems(VerticalAlign.Center)
        .padding({ left: 16, right: 16, top: 13, bottom: 13 })
      }
      .width('100%').backgroundColor(PL_ACCENT)
      .borderRadius({ topLeft: 16, topRight: 16 })

弹框的头部使用喷涂橙填充,白色文字,右上角的关闭按钮使用浅橙色(#FFE3C7)。顶部两个角使用圆角,底部为直角,与下方的白色卡片自然衔接。

typescript 复制代码
TextArea({ placeholder: '例如:水补土1200目一遍,面漆 1:2 稀释薄喷两遍,最后消光统一' })
  .placeholderColor('#BFC7D0').fontSize(12)
  .backgroundColor('#F5F7F9').borderRadius(8)
  .width('91%').height(80).margin({ top: 4 })

配方备注使用 TextArea 多行文本输入框,高度80px,可以输入多行文字。占位符文本给出了配方备注的示例格式,引导用户正确填写内容。

typescript 复制代码
Text('稀释比例').fontSize(11).fontColor(PL_SUB)
  .margin({ top: 12, left: 16 }).width('100%')
Row() {
  ForEach(PL_MIX_RATIOS, (m: string) => {
    if (this.recipeRatio === m) {
      Text(m)
        .fontSize(10).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        .backgroundColor(PL_ACCENT).borderRadius(12)
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .margin({ right: 6 })
    } else {
      Text(m)
        .fontSize(10).fontColor(PL_PRIMARY)
        .backgroundColor('#EDF1F5').borderRadius(12)
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .margin({ right: 6 })
        .onClick(() => { this.recipeRatio = m })
    }
  })
}

稀释比例选择使用Chips标签组,有四个选项:"1:1 标准喷涂"、"1:2 水彩轻薄"、"2:1 厚涂覆盖"、"1:3 渐变透明"。选中状态为橙色填充白字,未选中为灰底蓝字。

稀释比例是涂装专业中的重要参数,不同的比例适用于不同的涂装效果。将专业知识内置于选项中,既方便了用户选择,也体现了应用的专业性。

9.5 色系选择弹框

色系选择弹框采用工业深色卡片设计,提供沉浸式的色系选择体验。

typescript 复制代码
@Builder seriesSelectModal() {
  Column() {
    this.modalOverlay(() => { this.showSeriesModal = false })
    Column() {
      Row() {
        Text('🏭 选择色系').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Column().layoutWeight(1)
        Text('✕').fontSize(15).fontColor('#8A99A9')
          .onClick(() => { this.showSeriesModal = false })
      }

弹框整体使用深灰蓝色(#2E3B48)背景,白色文字,营造出工业感的视觉氛围。这种深色主题的弹框与应用整体的工业风格非常契合。

typescript 复制代码
Column() {
  ForEach(PL_SERIES_LIST, (s: PlSeriesMeta) => {
    Row() {
      Column().width(16).height(16).borderRadius(8).backgroundColor(s.colorHex)
      Column() {
        Text(s.label + '系').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text(s.desc).fontSize(9).fontColor('#8A99A9').margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1).padding({ left: 12 })
      if (this.filterSeries === s.label) {
        Text('✓ 已选').fontSize(10).fontColor(PL_ACCENT)
          .fontWeight(FontWeight.Bold)
      } else {
        Text(s.count + ' 瓶').fontSize(10).fontColor('#8A99A9')
      }
    }
    .width('100%').alignItems(VerticalAlign.Center)
    .backgroundColor('#38485A')
    .borderRadius(10)
    .padding({ left: 14, right: 14, top: 11, bottom: 11 })
    .margin({ bottom: 8 })
    .onClick(() => {
      this.filterSeries = s.label
      this.showSeriesModal = false
    })
  })
}

每个色系列表项包含:颜色圆点、色系名称和描述、右侧状态。右侧状态根据是否选中显示不同内容:已选中时显示橙色的"✓ 已选",未选中时显示灰色的"X 瓶"数量。

列表项的背景色是比弹框背景稍浅的灰蓝色(#38485A),形成微妙的层次关系。这种在深色背景上使用稍浅色块的设计,比浅色背景下的卡片更具沉浸感。

typescript 复制代码
Text('查看全部涂料')
  .fontSize(12).fontColor('#FFFFFF')
  .borderRadius(18).border({ width: 1, color: '#5A6C80' })
  .padding({ left: 28, right: 28, top: 9, bottom: 9 })
  .margin({ top: 8, bottom: 18 })
  .onClick(() => {
    this.filterSeries = '全部'
    this.showSeriesModal = false
  })

底部有一个"查看全部涂料"的描边按钮,点击后将筛选条件重置为"全部"并关闭弹框。这是一个便捷的"重置"操作,让用户在浏览完单个色系后可以快速回到全部视图。


十、喷笔馆页面深度解析

10.1 喷笔馆页面结构

喷笔馆页面的设计风格与其他页面形成鲜明对比,它以喷涂橙为主色调,突出喷笔作为核心工具的重要地位。

typescript 复制代码
Column() {
  // ===== 橙色头部横幅 =====
  Column() {
    Row() {
      Column() {
        Text('喷笔馆').fontSize(20).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
        Text('8 款在售 · 按综合评分实时排名')
          .fontSize(10).fontColor('#FFE3C7').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      Column().layoutWeight(1)
      Text('💨 喷涂压力 0.2MPa')
        .fontSize(10).fontColor('#FFFFFF')
        .backgroundColor('rgba(255,255,255,0.25)').borderRadius(12)
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
    }
    .width('100%').alignItems(VerticalAlign.Center)
  }
  .width('100%').backgroundColor(PL_ACCENT)
  .borderRadius({ bottomLeft: 18, bottomRight: 18 })
  .padding({ left: 16, right: 16, top: 12, bottom: 16 })

头部横幅使用喷涂橙作为背景色,白色文字。副标题使用浅橙色(#FFE3C7),在橙色背景上保持良好的可读性。右侧有一个"喷涂压力 0.2MPa"的状态标签,使用半透明白色背景,营造出玻璃态的视觉效果。

横幅的底部两个角使用大圆角(18px),形成"横幅"的视觉形态。这种顶部平直、底部圆角的设计,在移动端应用中非常流行,既保证了状态栏区域的完整填充,又为内容区域提供了柔和的过渡。

10.2 综合排行榜

typescript 复制代码
Column() {
  ForEach(getRankedAirbrushes(), (a: PlAirbrushItem, idx: number) => {
    Column() {
      Row() {
        Text((idx + 1).toString())
          .fontSize(idx < 3 ? 22 : 16).fontWeight(FontWeight.Bold)
          .fontColor(idx === 0 ? PL_ACCENT : (idx === 1 ? PL_PRIMARY : (idx === 2 ? '#B0713C' : PL_SUB)))
          .width(34).textAlign(TextAlign.Center)

喷笔排行榜是页面的主体内容,使用 getRankedAirbrushes() 函数获取按综合评分排序的喷笔列表。排名数字的样式设计与涂料热销榜类似,前三名使用不同的颜色和更大的字号突出显示。

typescript 复制代码
Column() {
  Row() {
    Text(a.model).fontSize(14).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
    Text(a.tag)
      .fontSize(8).fontColor(PL_ACCENT)
      .backgroundColor('#FFF1E3').borderRadius(8)
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .margin({ left: 6 })
  }.alignItems(VerticalAlign.Center)
  Text(a.brand + ' · 料杯 ' + a.cupMl + 'ml · ' + '★ ' + a.score.toFixed(1))
    .fontSize(10).fontColor(PL_SUB).margin({ top: 3 })

喷笔信息的第一行是型号名称和标签。标签使用橙色文字配浅橙背景,突出产品的特色定位(如"口碑之王"、"极细线稿"、"镜面专用"等)。第二行是品牌、料杯容量和评分信息,使用灰色小字。

typescript 复制代码
Row() {
  Text('口径 ' + a.caliber.toFixed(1) + 'mm')
    .fontSize(9).fontColor(PL_SUB)
    .width(66)
  Progress({ value: a.caliber * 200, total: 100, type: ProgressType.Linear })
    .color(idx < 3 ? PL_ACCENT : PL_PRIMARY)
    .style({ strokeWidth: 5 })
    .layoutWeight(1)
  Text('已售 ' + a.sales)
    .fontSize(9).fontColor(PL_SUB)
    .width(58).textAlign(TextAlign.End)
}
.width('100%').alignItems(VerticalAlign.Center)
.margin({ top: 6 })

口径进度条是一个很有特色的设计。它用进度条的长度可视化地表示喷笔口径的大小:口径越大,进度条越长。计算公式为 a.caliber * 200,即0.5mm的口径对应100%的进度条长度。

这种将技术参数可视化的设计手法,让用户可以直观地比较不同喷笔的口径差异,比单纯看数字更有冲击力。进度条的颜色也有区分:前三名使用橙色,其余使用主色,与排名的视觉重点保持一致。

typescript 复制代码
Column() {
  Text('¥' + a.price.toString())
    .fontSize(15).fontWeight(FontWeight.Bold).fontColor(PL_ACCENT)
  Text('¥' + a.origPrice.toString())
    .fontSize(10).fontColor(PL_SUB)
    .decoration({ type: TextDecorationType.LineThrough })
    .margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)

价格区域右对齐显示,当前价格用橙色粗体大字,原价用灰色小字带删除线。价格是电商类列表中最重要的信息之一,右对齐的布局符合用户浏览列表时的视觉习惯。

10.3 品牌分布柱状图

typescript 复制代码
Column() {
  Row() {
    ForEach(PL_BRAND_STATS, (b: PlStatMeta) => {
      Column() {
        Text(b.count.toString())
          .fontSize(10).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          .margin({ bottom: 4 })
        Column()
          .width(24).height(24 + b.pct * 0.92)
          .borderRadius({ topLeft: 5, topRight: 5 })
          .backgroundColor(b.colorHex)
      }
      .alignItems(HorizontalAlign.Center)
      .layoutWeight(1)
    })
  }
  .width('100%').alignItems(VerticalAlign.Bottom)
  .padding({ top: 10 })

品牌分布柱状图完全使用基础组件构建,没有依赖图表库。每个柱子是一个 Column 组件,通过 .height(24 + b.pct * 0.92) 动态设置高度,其中 b.pct 是百分比值,乘以系数0.92得到像素高度,再加上24px的基础高度。

柱子的顶部两个角使用圆角(borderRadius: topLeft, topRight),底部为直角,这是柱状图的经典样式。每个柱子顶部显示数量数值,底部显示品牌名称。

外层的 Row 设置了 .alignItems(VerticalAlign.Bottom),使得所有柱子底部对齐,高度向上延伸,这是柱状图布局的关键。

typescript 复制代码
Divider().color(PL_LINE).margin({ top: 12 })
Row() {
  Text('口径 0.2mm 适合刻线与镜面,0.3mm 通用主力量产,0.4-0.5mm 适合大面积底色')
    .fontSize(9).fontColor(PL_SUB)
    .layoutWeight(1)
}
.width('100%').margin({ top: 10 })

柱状图下方有一条分割线和一段科普文字,介绍不同口径喷笔的适用场景。这种在数据展示旁附带知识说明的设计,体现了应用的教育价值,帮助用户理解参数含义,做出更合适的选择。


十一、素体库页面深度解析

11.1 360度旋转展示台

素体库页面最引人注目的是顶部的360度旋转展示台,这是一个非常有创意的视觉设计。

typescript 复制代码
@Component
struct PlBodiesContent {
  @State plTurntable: number = 0

  aboutToAppear(): void {
    this.getUIContext().animateTo({
      duration: 7000,
      iterations: -1,
      curve: Curve.Linear,
      playMode: PlayMode.Normal
    }, () => {
      this.plTurntable = 360
    })
  }

旋转动画使用 plTurntable 状态变量驱动,初始值为0,动画目标值为360度。动画配置为:持续时间7000ms(7秒一圈),无限循环,线性匀速,单向播放(Normal)。

线性匀速的旋转模拟了真实转台的运动效果,7秒一圈的速度既不会太快导致眩晕,也不会太慢显得拖沓。单向循环(而非交替)保证了旋转方向的一致性,更符合物理世界中转台的旋转方式。

typescript 复制代码
Column() {
  Stack() {
    Column()
      .width(170).height(22).borderRadius(11)
      .backgroundColor('#243040')
      .offset({ y: 44 })
    Column()
      .width(120).height(8).borderRadius(4)
      .backgroundColor('#FF7F00')
      .opacity(0.35)
      .offset({ y: 50 })
    Row() {
      Column() {
        Text('🤖').fontSize(26)
        Text('1/100').fontSize(8).fontColor('#B8C4D0').margin({ top: 2 })
      }
      .width(52).height(64).borderRadius(8)
      .backgroundColor('#3A4A5A')
      .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
      // ... 另外两个素体展示块
    }
    .rotate({ angle: this.plTurntable })
    .offset({ y: -6 })
  }

展示台的结构很有层次感:

  • 最底层是一个深灰色的椭圆形底座(用宽170高22的圆角矩形模拟)
  • 中间是一层橙色的半透明光晕,增加立体感
  • 最上层是三个旋转的素体展示块,围绕中心旋转

三个展示块组成的 Row 设置了 .rotate({ angle: this.plTurntable }),随动画变量旋转。由于Row是水平排列的三个方块,旋转时它们会围绕中心点做圆周运动,形成转盘效果。

这种用简单几何形状组合出3D视觉效果的手法非常巧妙,不需要复杂的3D引擎,仅用2D变换就实现了转盘的视觉隐喻。

11.2 素体双列 Grid

typescript 复制代码
Grid() {
  ForEach(PL_BODIES, (b: PlBodyItem) => {
    GridItem() {
      Column() {
        Stack() {
          Column()
            .width('100%').height(76)
            .backgroundColor(b.material === 'ABS' ? '#E8EDF2' : (b.material === 'PVC' ? '#FFF1E3' : '#F3EDFA'))
            .borderRadius({ topLeft: 12, topRight: 12 })
          Text(b.material === '树脂' ? '🗿' : (b.material === 'PVC' ? '🎽' : '🤖'))
            .fontSize(34)

素体列表使用双列网格布局,每个素体卡片顶部有一个图片展示区。展示区的背景色根据材质不同而变化:

  • ABS材质:灰蓝色(#E8EDF2)
  • PVC材质:橙色(#FFF1E3)
  • 树脂材质:紫色(#F3EDFA)

不同材质使用不同的图标和背景色,让用户可以通过颜色快速识别素体的材质类型。这种颜色编码系统是信息设计中的有效手段。

typescript 复制代码
Text(b.scale)
  .fontSize(9).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
  .backgroundColor(PL_PRIMARY).borderRadius(8)
  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
  .position({ x: 8, y: 8 })

素体的比例标签(如1/100、1/144等)使用 .position() 绝对定位在图片区的左上角,深底白字的设计确保了在不同背景色上的可读性。

typescript 复制代码
Column() {
  Text(b.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
    .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  Text(b.pose + ' · ' + b.material)
    .fontSize(9).fontColor(PL_SUB).margin({ top: 3 })
  Row() {
    Text('¥' + b.price.toString())
      .fontSize(13).fontWeight(FontWeight.Bold).fontColor(PL_ACCENT)
    Column().layoutWeight(1)
    Text('库' + b.stock)
      .fontSize(9).fontColor(PL_SUB)
  }
  .width('100%').margin({ top: 6 })
  Row() {
    Text('月销 ' + b.sales)
      .fontSize(9).fontColor(PL_SUB)
    Column().layoutWeight(1)
    Text('去喷涂 ›')
      .fontSize(9).fontColor(PL_PRIMARY).fontWeight(FontWeight.Bold)
  }
  .width('100%').margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.padding({ left: 10, right: 10, top: 8, bottom: 10 })

卡片下方的信息区域包含四行内容:

  1. 素体名称(单行省略)
  2. 姿势 · 材质
  3. 价格 · 库存
  4. 月销量 · 去喷涂入口

信息量丰富但布局紧凑,右下角的"去喷涂 ›"文字链接暗示了下一步操作,引导用户从浏览素体进入涂装流程。

11.3 比例分布与材质分布

素体库页面底部有两个统计图表:比例分布图和材质分布图,分别从尺寸比例和制作材料两个维度对素体进行分类统计。

比例分布图使用横向进度条的形式,展示6种常见比例(1/144、1/100、1/60、1/12、1/8、1/7)的分布情况。每种比例有对应的颜色,进度条宽度直观反映数量占比。

材质分布图使用垂直柱状图的形式,展示ABS、PVC、树脂三种材质的分布对比。ABS材质占比最高(4款,100%),PVC和树脂各占一半。柱状图下方还标注了百分比占比,数据信息更加完整。

这两个图表为用户提供了不同维度的数据视角,帮助用户快速了解素体库的整体构成,也体现了应用的数据可视化能力。


十二、涂装学堂页面深度解析

12.1 状态管理状态图

#mermaid-svg-wsVMEGQzS2BbZu0s{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wsVMEGQzS2BbZu0s .error-icon{fill:#552222;}#mermaid-svg-wsVMEGQzS2BbZu0s .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wsVMEGQzS2BbZu0s .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wsVMEGQzS2BbZu0s .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wsVMEGQzS2BbZu0s .marker.cross{stroke:#333333;}#mermaid-svg-wsVMEGQzS2BbZu0s svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wsVMEGQzS2BbZu0s p{margin:0;}#mermaid-svg-wsVMEGQzS2BbZu0s defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-wsVMEGQzS2BbZu0s g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-wsVMEGQzS2BbZu0s g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-wsVMEGQzS2BbZu0s g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-wsVMEGQzS2BbZu0s g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-wsVMEGQzS2BbZu0s g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-wsVMEGQzS2BbZu0s .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-wsVMEGQzS2BbZu0s .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-wsVMEGQzS2BbZu0s .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-wsVMEGQzS2BbZu0s .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-wsVMEGQzS2BbZu0s .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-wsVMEGQzS2BbZu0s .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-wsVMEGQzS2BbZu0s .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-wsVMEGQzS2BbZu0s .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wsVMEGQzS2BbZu0s .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wsVMEGQzS2BbZu0s .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wsVMEGQzS2BbZu0s .edgeLabel .label text{fill:#333;}#mermaid-svg-wsVMEGQzS2BbZu0s .label div .edgeLabel{color:#333;}#mermaid-svg-wsVMEGQzS2BbZu0s .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-wsVMEGQzS2BbZu0s .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-wsVMEGQzS2BbZu0s .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-wsVMEGQzS2BbZu0s .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-wsVMEGQzS2BbZu0s .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-wsVMEGQzS2BbZu0s .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wsVMEGQzS2BbZu0s .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wsVMEGQzS2BbZu0s #statediagram-barbEnd{fill:#333333;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wsVMEGQzS2BbZu0s .cluster-label,#mermaid-svg-wsVMEGQzS2BbZu0s .nodeLabel{color:#131300;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-wsVMEGQzS2BbZu0s .note-edge{stroke-dasharray:5;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-note text{fill:black;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram-note .nodeLabel{color:black;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagram .edgeLabel{color:red;}#mermaid-svg-wsVMEGQzS2BbZu0s #dependencyStart,#mermaid-svg-wsVMEGQzS2BbZu0s #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-wsVMEGQzS2BbZu0s .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wsVMEGQzS2BbZu0s :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 点击"仅看入门"按钮
再次点击按钮
全部课程
向下滑动
向下滑动
向下滑动
向下滑动
向下滑动
课程1
课程2
课程3
课程4
课程5
课程6
仅看入门
向下滑动
入门课1
入门课2
课程详情
最后一步高亮
标题区
难度条
评分区
步骤列表
STEP1
STEP2
STEP3
STEP4
STEP5

12.2 难度筛选功能

typescript 复制代码
@Component
struct PlTutorialsContent {
  @State onlyFree: boolean = false

涂装学堂页面只有一个状态变量 onlyFree,控制是否只显示入门级(免费)课程。这种极简的状态设计使得页面逻辑非常清晰。

typescript 复制代码
if (this.onlyFree) {
  Text('🆓 仅看入门')
    .fontSize(10).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
    .backgroundColor(PL_ACCENT).borderRadius(12)
    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
    .onClick(() => { this.onlyFree = false })
} else {
  Text('🆓 仅看入门')
    .fontSize(10).fontColor(PL_ACCENT)
    .backgroundColor('#FFF1E3').borderRadius(12)
    .padding({ left: 10, right: 10, top: 5, bottom: 5 })
    .onClick(() => { this.onlyFree = true })
}

筛选按钮有两种状态:激活状态(橙色填充白字)和未激活状态(浅橙背景橙字)。点击按钮在两种状态间切换,同时切换列表的显示内容。

按钮文案"🆓 仅看入门"使用了免费emoji,直观地传达了"免费内容"的含义。这种图标+文字的组合比纯文字更容易理解和记忆。

12.3 课程卡片设计

typescript 复制代码
ForEach(getShownTutorials(this.onlyFree), (t: PlTutorialItem) => {
  Column() {
    Row() {
      Column() {
        Text(t.title).fontSize(14).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(t.author + ' · ' + t.duration + ' 分钟 · ' + (t.views / 10000).toFixed(1) + 'w 人学过')
          .fontSize(9).fontColor(PL_SUB).margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1).padding({ right: 8 })

每个课程卡片顶部是标题和元信息行。标题使用粗体大字,最多显示一行,超出部分用省略号截断。元信息包括作者、时长、学习人数,使用灰色小字。

学习人数的显示做了单位换算:(t.views / 10000).toFixed(1) + 'w 人学过',将原始的浏览量转换为"万"为单位的显示格式。例如12800次浏览显示为"1.3w 人学过"。这种格式化处理提升了大数字的可读性。

typescript 复制代码
Text(t.level >= 5 ? '地狱级' : (t.level === 4 ? '进阶级' : (t.level === 3 ? '熟练级' : '入门级')))
  .fontSize(9).fontColor(t.level >= 5 ? '#C0392B' : (t.level >= 4 ? PL_ACCENT : '#2E7D5B'))
  .fontWeight(FontWeight.Bold)
  .borderRadius(8)
  .border({ width: 1, color: t.level >= 5 ? '#C0392B' : (t.level >= 4 ? PL_ACCENT : '#2E7D5B') })
  .padding({ left: 8, right: 8, top: 3, bottom: 3 })

右侧是难度等级标签,使用文字描述而非数字等级:

  • level >= 5:地狱级,红色
  • level === 4:进阶级,橙色
  • level === 3:熟练级,绿色
  • level <= 2:入门级,绿色

这种将抽象数字等级转化为具象文字描述的设计,降低了用户的认知负担。标签使用描边样式,边框颜色与文字颜色一致,视觉上更加精致。

12.4 难度进度条

typescript 复制代码
Row() {
  Text('难度').fontSize(9).fontColor(PL_SUB)
  Row() {
    ForEach(PL_LEVEL_SEGMENTS, (n: number) => {
      if (n <= t.level) {
        Column()
          .layoutWeight(1).height(6).borderRadius(3)
          .backgroundColor(n <= 2 ? '#8FBF9F' : (n <= 4 ? PL_ACCENT : '#C0392B'))
          .margin({ right: 4 })
      } else {
        Column()
          .layoutWeight(1).height(6).borderRadius(3)
          .backgroundColor('#E4E9EE')
          .margin({ right: 4 })
      }
    })
  }
  .layoutWeight(1).margin({ left: 8 })
  Text('LV.' + t.level).fontSize(9).fontColor(PL_SUB)
    .width(30).textAlign(TextAlign.End)
}

难度进度条是一个非常有设计感的可视化元素。它由5个分段组成,每个分段代表一个难度等级。已达到的分段填充颜色,未达到的为灰色。

每个分段的颜色随等级变化:

  • 1-2级(入门):绿色(#8FBF9F)
  • 3-4级(进阶):橙色(PL_ACCENT)
  • 5级(地狱):红色(#C0392B)

这种渐变色的难度条直观地传达了"难度越高、颜色越红"的语义,与交通灯的颜色隐喻一致,用户可以快速理解。左侧是"难度"标签,右侧是"LV.X"的数值标注,整体布局均衡美观。

12.5 评分与操作区

typescript 复制代码
Row() {
  Text('★ ' + t.rating.toFixed(1))
    .fontSize(12).fontWeight(FontWeight.Bold).fontColor(PL_ACCENT)
  Text('学员评分')
    .fontSize(9).fontColor(PL_SUB).margin({ left: 8 })
  Column().layoutWeight(1)
  Text('▶ 立即学习')
    .fontSize(10).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
    .backgroundColor(PL_PRIMARY).borderRadius(12)
    .padding({ left: 12, right: 12, top: 5, bottom: 5 })
}

评分区域左侧显示星级评分,使用橙色粗体字突出显示。右侧是"立即学习"按钮,使用主色填充白字,是课程卡片的主要操作入口。

按钮使用播放图标"▶"强化"开始学习"的动作暗示,这种图标+文字的按钮设计比纯文字按钮更有吸引力,也更符合视频学习类产品的交互习惯。

12.6 步骤流程组件

typescript 复制代码
Column() {
  ForEach(t.steps, (s: string, i: number) => {
    Row() {
      Column() {
        Text('STEP ' + (i + 1))
          .fontSize(8).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          .width(40).height(18).borderRadius(9)
          .backgroundColor(i === t.steps.length - 1 ? PL_ACCENT : PL_PRIMARY)
          .textAlign(TextAlign.Center)
        if (i < t.steps.length - 1) {
          Column()
            .width(2).layoutWeight(1)
            .backgroundColor('#D8DEE5')
            .margin({ top: 3, bottom: 3 })
        }
      }
      .alignItems(HorizontalAlign.Center)
      .margin({ top: 2 })
      Text(s)
        .fontSize(11).fontColor(PL_TEXT)
        .layoutWeight(1)
        .padding({ left: 12, top: 1, bottom: 10 })
    }
    .alignItems(VerticalAlign.Top)
    .width('100%')
  })
}

步骤流程是教程卡片中最具特色的部分。它使用纵向时间线的形式展示教程的各个步骤,每个步骤由左侧的STEP标签和连接线、右侧的步骤文字组成。

STEP标签是一个胶囊形状的圆角矩形,显示步骤序号。最后一个步骤使用橙色高亮,其余使用主色,这种设计暗示了"最后一步是关键/终点"的语义。

相邻步骤之间通过竖线连接,竖线的高度由 .layoutWeight(1) 自动填充,形成连续的时间线效果。连接线的存在使得多个步骤在视觉上形成一个整体,清晰地表达了步骤之间的顺序关系。

这种纵向步骤条的设计在教程类、流程类应用中非常实用,它比简单的列表更能表达流程的递进关系,视觉上也更加生动有趣。


十三、个人中心页面深度解析

13.1 个人中心结构概览

个人中心页面集成了用户信息展示、会员权益、消费统计、订单管理、收藏管理等多个功能模块,是信息密度最高的页面之一。

typescript 复制代码
@Component
struct PlProfileContent {
  @State listTab: number = 0

页面只有一个状态变量 listTab,用于控制订单/收藏列表的切换。0表示显示订单列表,1表示显示收藏列表。这种用数字表示Tab索引的模式在多Tab切换中很常见。

13.2 用户头像栏

typescript 复制代码
Row() {
  Text('🧑‍🎨').fontSize(26)
    .width(52).height(52).borderRadius(26)
    .backgroundColor('#EAF0F6').textAlign(TextAlign.Center)
  Column() {
    Text('涂装大卫').fontSize(17).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
    Text('工龄 3 年 · 完工 58 件').fontSize(10).fontColor(PL_SUB).margin({ top: 3 })
  }
  .alignItems(HorizontalAlign.Start).padding({ left: 12 })
  Column().layoutWeight(1)
  Text('⚙️').fontSize(18)
    .width(36).height(36).borderRadius(18)
    .backgroundColor(PL_CARD).textAlign(TextAlign.Center)
    .shadow({ radius: 6, color: '#143A4A5A' })
}

头像栏是个人中心的标志性组件。左侧是用户头像(使用emoji代替),圆形设计,浅蓝灰色背景。中间是用户名和用户简介,右侧是设置按钮。

设置按钮使用圆形白底带阴影的设计,有一种悬浮的视觉效果。阴影使用主色调的半透明色(#143A4A5A),与整体配色协调统一。

用户简介"工龄3年 · 完工58件"的设计很有特色,它用"工龄"和"完工数"这两个涂装爱好者关心的指标来描述用户身份,比传统的"个性签名"更有社群认同感。

13.3 会员卡模块

typescript 复制代码
Column() {
  Row() {
    Column() {
      Row() {
        Text('PL').fontSize(13).fontWeight(FontWeight.Bold).fontColor(PL_PRIMARY)
          .backgroundColor(PL_ACCENT).borderRadius(5)
          .padding({ left: 5, right: 5, top: 2, bottom: 2 })
        Text('PRO 会员').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          .margin({ left: 8 })
      }.alignItems(VerticalAlign.Center)
      Text('NO. PL-2026-0826 · 2027-08-26 到期')
        .fontSize(9).fontColor('#B8C4D0').margin({ top: 8 })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    Text('🎁')
      .fontSize(20)
      .width(44).height(44).borderRadius(22)
      .backgroundColor('#54677A').textAlign(TextAlign.Center)
  }

会员卡模块使用深色背景(工业灰蓝),是页面中的视觉重点。卡片顶部左侧是会员标识:PL logo + "PRO 会员"文字,右侧是一个礼物图标按钮。

PL logo的设计很有品牌感:橙底深蓝字的小方块,简洁醒目。会员编号和到期日期使用浅灰色小字,提供详细的会员信息。

typescript 复制代码
Row() {
  Column() {
    Text('4820').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFC999')
    Text('积分').fontSize(9).fontColor('#B8C4D0').margin({ top: 2 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  Column().width(1).height(24).backgroundColor('#54677A')
  Column() {
    Text('8').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFC999')
    Text('优惠券').fontSize(9).fontColor('#B8C4D0').margin({ top: 2 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  Column().width(1).height(24).backgroundColor('#54677A')
  Column() {
    Text('6').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFC999')
    Text('收藏').fontSize(9).fontColor('#B8C4D0').margin({ top: 2 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
  Column().width(1).height(24).backgroundColor('#54677A')
  Column() {
    Text('13').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FFC999')
    Text('本月完工').fontSize(9).fontColor('#B8C4D0').margin({ top: 2 })
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
}
.width('100%').margin({ top: 16 })

会员卡下方是四栏数据统计,使用竖线分隔:积分、优惠券、收藏、本月完工。数值使用暖橙色(#FFC999),标签使用浅灰色。四等分的布局在有限的空间内展示了四个核心数据指标,信息效率很高。

13.4 月度消费柱状图

typescript 复制代码
Column() {
  Row() {
    Text('💰 月度消费').fontSize(14).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
    Column().layoutWeight(1)
    Text('半年合计 ¥' + getMonthSpendTotal().toString())
      .fontSize(10).fontColor(PL_SUB)
  }
  .width('100%')
  Row() {
    ForEach(PL_SPENDS, (s: PlSpendItem) => {
      Column() {
        Text((s.amount / 1000).toFixed(1) + 'k')
          .fontSize(8).fontColor(PL_SUB)
          .margin({ bottom: 3 })
        Column()
          .width(26).height(20 + s.pct * 1.1)
          .borderRadius({ topLeft: 5, topRight: 5 })
          .backgroundColor(s.pct === 100 ? PL_ACCENT : '#7C8EA3')
        Text(s.month).fontSize(9)
          .fontColor(s.pct === 100 ? PL_ACCENT : PL_SUB)
          .fontWeight(s.pct === 100 ? FontWeight.Bold : FontWeight.Normal)
          .margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.Center)
      .layoutWeight(1)
    })
  }

月度消费图是个人中心的重要数据可视化组件,展示近6个月的消费趋势。柱状图的高度由消费百分比决定,最高消费月(pct=100)使用橙色高亮,其余月份使用灰蓝色。

消费金额的显示做了单位转换:(s.amount / 1000).toFixed(1) + 'k',将元转换为千元单位,如1890元显示为"1.9k"。这种简洁的数字表达方式适合空间有限的图表场景。

最高消费月的柱子、月份文字都使用橙色并加粗,形成视觉焦点,让用户一眼就能识别出消费峰值。这是数据可视化中"突出关键数据点"的典型手法。

typescript 复制代码
Row() {
  Column().width(8).height(8).borderRadius(2).backgroundColor(PL_ACCENT)
  Text('消费峰值月').fontSize(9).fontColor(PL_SUB).margin({ left: 4 })
  Column().layoutWeight(1)
  Text('涂料支出占 62%').fontSize(9).fontColor(PL_SUB)
}
.width('100%').margin({ top: 12 })

图表下方有图例和说明文字。左侧是橙色方块图例和"消费峰值月"说明,右侧是"涂料支出占62%"的额外数据点。这些补充信息帮助用户更好地理解图表数据,提供更丰富的洞察。

13.5 订单/收藏切换 Tab

typescript 复制代码
Row() {
  Column() {
    Text('📦 我的订单')
      .fontSize(13).fontWeight(FontWeight.Bold)
      .fontColor(this.listTab === 0 ? PL_TEXT : PL_SUB)
    if (this.listTab === 0) {
      Column().width(22).height(3).borderRadius(2)
        .backgroundColor(PL_ACCENT).margin({ top: 4 })
    } else {
      Column().width(22).height(3).margin({ top: 4 })
    }
  }
  .alignItems(HorizontalAlign.Center)
  .layoutWeight(1)
  .onClick(() => { this.listTab = 0 })

订单/收藏切换使用经典的Tab设计,两个Tab平均分配宽度。选中的Tab文字为深色,下方有橙色指示条;未选中的Tab文字为灰色,下方没有指示条。

指示条的实现方式很巧妙:通过 if 条件判断,选中时渲染一个有背景色的小矩形,未选中时渲染一个没有背景色的占位矩形(高度和margin保持一致)。这样无论选中与否,Tab项的整体高度都是一致的,不会出现跳动。

13.6 订单列表

typescript 复制代码
Column() {
  ForEach(PL_ORDERS, (o: PlOrderItem) => {
    Row() {
      Column() {
        Text(o.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(o.id + ' · ' + o.date + ' · 共' + o.count + '件')
          .fontSize(9).fontColor(PL_SUB).margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1).padding({ right: 8 })
      Column() {
        Text(o.status)
          .fontSize(10).fontWeight(FontWeight.Bold)
          .fontColor(o.status === '已完成' ? '#2E7D5B' : (o.status === '配送中' ? PL_ACCENT : PL_SUB))
        Text('¥' + o.amount.toFixed(2))
          .fontSize(12).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%').alignItems(VerticalAlign.Center)
    .padding({ left: 14, right: 14, top: 12, bottom: 12 })
    .border({ width: 1, color: PL_LINE })
    .borderRadius(12)
    .margin({ bottom: 8 })
  })
}

订单列表中每个订单项包含左侧的订单信息和右侧的状态与金额。订单信息包括订单标题、订单号、日期和商品件数。右侧上方是订单状态,使用不同颜色区分:

  • 已完成:绿色(#2E7D5B)
  • 配送中:橙色(PL_ACCENT)
  • 已取消:灰色(PL_SUB)

状态颜色使用语义化配色,绿色表示成功/完成,橙色表示进行中,灰色表示已取消。这种颜色编码系统让用户可以通过颜色快速识别订单状态,无需仔细阅读文字。

金额使用深色粗体字显示,保留两位小数,符合金额展示的规范。订单卡片使用边框样式而非阴影,视觉上更加轻盈简洁。

13.7 收藏列表

typescript 复制代码
Column() {
  ForEach(PL_FAVS, (f: PlFavItem) => {
    Row() {
      Column()
        .width(38).height(38).borderRadius(10)
        .backgroundColor(f.colorHex)
      Column() {
        Text(f.title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(f.type + ' · 收藏于 ' + f.date)
          .fontSize(9).fontColor(PL_SUB).margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1).padding({ left: 12 })
      Text('›').fontSize(16).fontColor(PL_SUB)
    }
    .width('100%').alignItems(VerticalAlign.Center)
    .padding({ left: 14, right: 14, top: 11, bottom: 11 })
    .border({ width: 1, color: PL_LINE })
    .borderRadius(12)
    .margin({ bottom: 8 })
  })
}

收藏列表的设计更加简洁。左侧是一个38x38px的圆角方块,使用收藏项的颜色填充,作为视觉标识。中间是收藏标题和类型+日期信息。右侧是一个右箭头"›",暗示可以点击进入详情。

收藏列表支持多种类型的收藏(涂料、教程、素体、喷笔),用统一的列表样式展示不同类型的内容。颜色方块起到了视觉锚点的作用,即使不看文字,用户也能通过颜色大致识别出收藏的内容。


十四、动画系统深度解析

14.1 渲染流程图

#mermaid-svg-Mq65VST5xzpvLNEu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Mq65VST5xzpvLNEu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Mq65VST5xzpvLNEu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Mq65VST5xzpvLNEu .error-icon{fill:#552222;}#mermaid-svg-Mq65VST5xzpvLNEu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Mq65VST5xzpvLNEu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Mq65VST5xzpvLNEu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Mq65VST5xzpvLNEu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Mq65VST5xzpvLNEu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Mq65VST5xzpvLNEu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Mq65VST5xzpvLNEu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Mq65VST5xzpvLNEu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Mq65VST5xzpvLNEu .marker.cross{stroke:#333333;}#mermaid-svg-Mq65VST5xzpvLNEu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Mq65VST5xzpvLNEu p{margin:0;}#mermaid-svg-Mq65VST5xzpvLNEu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Mq65VST5xzpvLNEu .cluster-label text{fill:#333;}#mermaid-svg-Mq65VST5xzpvLNEu .cluster-label span{color:#333;}#mermaid-svg-Mq65VST5xzpvLNEu .cluster-label span p{background-color:transparent;}#mermaid-svg-Mq65VST5xzpvLNEu .label text,#mermaid-svg-Mq65VST5xzpvLNEu span{fill:#333;color:#333;}#mermaid-svg-Mq65VST5xzpvLNEu .node rect,#mermaid-svg-Mq65VST5xzpvLNEu .node circle,#mermaid-svg-Mq65VST5xzpvLNEu .node ellipse,#mermaid-svg-Mq65VST5xzpvLNEu .node polygon,#mermaid-svg-Mq65VST5xzpvLNEu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Mq65VST5xzpvLNEu .rough-node .label text,#mermaid-svg-Mq65VST5xzpvLNEu .node .label text,#mermaid-svg-Mq65VST5xzpvLNEu .image-shape .label,#mermaid-svg-Mq65VST5xzpvLNEu .icon-shape .label{text-anchor:middle;}#mermaid-svg-Mq65VST5xzpvLNEu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Mq65VST5xzpvLNEu .rough-node .label,#mermaid-svg-Mq65VST5xzpvLNEu .node .label,#mermaid-svg-Mq65VST5xzpvLNEu .image-shape .label,#mermaid-svg-Mq65VST5xzpvLNEu .icon-shape .label{text-align:center;}#mermaid-svg-Mq65VST5xzpvLNEu .node.clickable{cursor:pointer;}#mermaid-svg-Mq65VST5xzpvLNEu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Mq65VST5xzpvLNEu .arrowheadPath{fill:#333333;}#mermaid-svg-Mq65VST5xzpvLNEu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Mq65VST5xzpvLNEu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Mq65VST5xzpvLNEu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Mq65VST5xzpvLNEu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Mq65VST5xzpvLNEu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Mq65VST5xzpvLNEu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Mq65VST5xzpvLNEu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Mq65VST5xzpvLNEu .cluster text{fill:#333;}#mermaid-svg-Mq65VST5xzpvLNEu .cluster span{color:#333;}#mermaid-svg-Mq65VST5xzpvLNEu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Mq65VST5xzpvLNEu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Mq65VST5xzpvLNEu rect.text{fill:none;stroke-width:0;}#mermaid-svg-Mq65VST5xzpvLNEu .icon-shape,#mermaid-svg-Mq65VST5xzpvLNEu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Mq65VST5xzpvLNEu .icon-shape p,#mermaid-svg-Mq65VST5xzpvLNEu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Mq65VST5xzpvLNEu .icon-shape .label rect,#mermaid-svg-Mq65VST5xzpvLNEu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Mq65VST5xzpvLNEu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Mq65VST5xzpvLNEu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Mq65VST5xzpvLNEu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否



组件初始化
aboutToAppear 生命周期
启动 animateTo 动画
状态变量开始过渡
每一帧重新计算UI
计算 translate 位移
计算 opacity 透明度
计算 scale 缩放比例
计算 shadow 阴影半径
计算 rotate 旋转角度
ArkUI 渲染引擎
GPU 合成与绘制
屏幕显示
动画是否结束?
是否有下一次迭代?
切换方向 / 重置状态
动画完成

14.2 显式动画机制

本应用中大量使用了 animateTo 显式动画来创建各种视觉效果。显式动画是ArkUI中的核心动画机制,它通过指定动画参数和目标状态,由框架自动计算中间帧并平滑过渡。

应用中的动画可以分为几类:

喷雾粒子动画(首页) :使用 plSprayY 变量驱动8个粒子的垂直浮动,配合不同的位移系数产生层次感。动画时长1500ms,EaseOut缓动,交替播放。

颜料滴落动画(涂料仓) :使用 plDropY 变量驱动颜料液滴的下落与淡出效果,EaseIn缓动模拟重力加速,时长1300ms。

脉冲发光动画(涂料仓) :使用 plPulse 变量驱动色板的发光阴影与缩放呼吸效果,EaseInOut缓动,时长900ms。

转盘旋转动画(素体库) :使用 plTurntable 变量驱动360度旋转展示台,Linear线性缓动,单向循环,时长7000ms。

14.3 多粒子差异化运动

首页喷雾粒子动画是动画设计中的精彩案例。它使用8个不同参数的粒子组合出自然的喷雾效果:

复制代码
粒子1: 系数1.3, 不透明度0.85, 喷涂橙色
粒子2: 系数0.7, 不透明度0.6,  浅灰色
粒子3: 系数1.6, 不透明度0.75, 喷涂橙色
粒子4: 系数0.9, 不透明度0.65, 珠金色
粒子5: 系数1.2, 不透明度0.8,  荧光绿
粒子6: 系数0.6, 不透明度0.5,  白色
粒子7: 系数1.4, 不透明度0.7,  喷涂橙色

每个粒子的运动系数不同(0.6~1.6),意味着它们的浮动幅度各不相同;颜色和不透明度也各不相同。这种差异化设计避免了"整齐划一"的机械感,模拟了真实世界中粒子运动的随机性和自然感。

这种技术的核心思想是:用同一个状态变量驱动多个元素,但每个元素应用不同的变换系数,从而产生丰富的层次变化。这比为每个粒子创建独立的动画要高效得多,也更容易保持整体的协调。

14.4 动画性能优化

HarmonyOS 6.1.1 的ArkUI框架在动画性能方面做了大量优化。本应用的动画实现也遵循了最佳实践:

使用属性动画而非帧动画:所有动画都是通过改变组件的属性(translate、scale、opacity、rotate等)实现的,这些属性可以由GPU直接合成,不需要重新布局和绘制,性能很高。

合理控制动画数量:虽然有多个动画元素,但它们都由少数几个状态变量驱动(每个页面1-2个动画变量),减少了状态追踪的开销。

使用无限循环而非重复触发 :通过 iterations: -1 设置无限循环,动画由框架统一管理,避免了频繁的动画启动/停止带来的开销。

交替播放模式减少状态切换 :使用 PlayMode.Alternate 交替模式,动画可以在两个状态之间平滑往返,不需要重置和重新启动。


十五、状态管理模式分析

15.1 @State 装饰器的使用模式

本应用全部使用 @State 装饰器管理组件内部状态,这是ArkUI中最基础也是最常用的状态管理方式。每个组件定义自己需要的状态变量,状态变更触发组件重新渲染。

从状态的用途来看,可以分为以下几类:

UI显隐状态 :布尔型,控制弹框、菜单等的显示与隐藏,如 showDetailshowAddModalshowDeleteConfirmshowEditModalshowSeriesModalonlyFree 等。

选中数据状态 :对象型,存储当前选中的业务数据对象,如 selectedPaint。通常与UI显隐状态配合使用,先设置选中数据,再显示对应的弹框。

筛选/分类状态 :字符串或数字型,控制列表的筛选条件或当前选中的分类,如 filterSeriesformSeriesrecipeRatioactiveTablistTab 等。

表单输入状态 :字符串型,存储表单中的输入值,如 formNameformColorformCapacityformPrice 等。

动画驱动状态 :数值型,作为动画的驱动变量,如 plSprayYplDropYplPulseplTurntable 等。

15.2 状态变更的单向数据流

应用遵循严格的单向数据流模式:

  1. 用户交互(点击、滑动等)触发事件回调
  2. 事件回调修改 @State 状态变量
  3. 状态变更触发框架的响应式更新机制
  4. 框架重新计算依赖该状态的UI部分
  5. UI更新后呈现给用户

这种单向数据流的模式使得状态变化的追踪非常清晰。任何UI变化都可以追溯到某个状态的变更,而任何状态的变更都可以追溯到某个用户交互事件。调试时只需要关注状态的变化路径即可。

15.3 状态粒度的设计考量

本应用的状态设计采用了"细粒度"策略,每个状态变量只负责一个功能。例如涂料仓页面有4个独立的弹框显隐状态,而不是用一个枚举值表示当前显示哪个弹框。

细粒度状态的优势:

  • 逻辑清晰,每个状态的含义明确
  • 状态变更简单,只需要修改一个布尔值
  • 可以支持多个弹框同时显示(虽然本应用中没有这种场景)

粗粒度状态(如用枚举值)的优势:

  • 状态变量更少
  • 互斥关系有类型保障(枚举值确保同一时间只有一个状态)

对于小型应用,细粒度的布尔状态通常更加直观易用。而对于复杂应用,可能需要引入更结构化的状态管理方案。

15.4 全局数据与组件状态的分离

应用将所有业务数据定义为顶层 const 常量,组件只管理交互状态。这种数据与状态分离的设计有诸多好处:

数据单一来源 :所有组件共享同一份数据源,确保数据一致性。

只读数据安全 :const常量不可修改,避免意外的数据变更。

组件职责单一 :组件只负责UI和交互状态,不管理业务数据。

便于接入后端:将来接入真实API时,只需要替换常量数据为网络请求,组件层不需要修改。

这种模式类似于Flutter中的Provider或React中的Context,将数据层与UI层分离,是良好的架构实践。


十六、各模块特性对比总结

16.1 模块特性对比表

维度 首页仪表盘 涂料仓 喷笔馆 素体库 涂装学堂 个人中心
主色调 工业灰蓝 工业灰蓝 喷涂橙 浅灰工作台风 浅灰蓝 工业灰蓝
Tab图标 🏠 🎨 💨 🤖 📖 👤
核心功能 数据概览 + 导航 涂料管理 喷笔选购 素体浏览 教程学习 用户中心
数据项数 10款涂料展示 10款全量管理 8款喷笔 8款素体 6门课程 6订单 + 6收藏
弹框数量 1个(详情) 4个(新增/删除/编辑/色系) 0个 0个 0个 0个
状态变量数 3个 12个 0个 1个 1个 1个
动画效果 喷雾粒子浮动 颜料滴落 + 脉冲发光 360°旋转转盘
图表类型 环形进度 + 列表 进度条 + 横向柱状图 排行榜 + 柱状图 横向条 + 柱状图 难度分段条 月度消费柱状图
筛选功能 色系筛选(6种) 难度筛选(免费/全部) 订单/收藏切换
列表布局 水平色卡 + 垂直榜单 三列Grid + 预警列表 垂直列表 双列Grid 垂直卡片列表 垂直列表
交互复杂度 中等 中等
数据可视化 仪表盘 色系分布 + 容量预警 品牌分布 + 口径条 比例分布 + 材质分布 难度进度条 消费趋势图
表单输入 名称/色号/容量/价格/色系
特色设计 喷雾粒子特效 脉冲发光 + 颜料滴落 口径可视化进度条 360°旋转展示台 纵向步骤流程 会员卡 + 消费统计
代码行数 约350行 约560行 约150行 约210行 约150行 约220行

16.2 设计一致性分析

纵观六个页面,虽然每个页面都有自己的特色和功能重点,但整体设计语言保持了高度的一致性:

色彩系统统一:所有页面都遵循工业灰蓝为主色、喷涂橙为强调色的配色方案,即使是喷笔馆的橙色头部页面,也只是将强调色提升为主色使用,色彩体系仍然是统一的。

圆角规范统一:卡片圆角统一使用12px,大按钮圆角统一使用20px(胶囊形),小标签圆角统一使用8-10px,弹框圆角统一使用16px。统一的圆角规格营造了协调的视觉感受。

间距系统统一:各页面的内边距、外边距都遵循相似的规范,卡片内边距约12-14px,模块间距约16px,整体视觉节奏一致。

文字层级统一:标题16-20px粗体、副标题12-14px常规、辅助文字9-11px灰色,三级文字层级在各页面中保持一致。

交互模式统一:Chips筛选、列表点击、弹框交互等在不同页面中使用相同的交互模式和视觉反馈,降低了用户的学习成本。


十七、技术总结与展望

17.1 架构设计维度总结

本应用采用单文件组件化架构,将数据层、工具层、组件层清晰分离。数据层使用const常量定义,保证了数据的不可变性和全局一致性。工具层使用纯函数封装业务逻辑,提高了代码的复用性和可测试性。组件层使用声明式UI构建,每个组件管理自己的状态和UI。

这种架构的优势在于:

  • 简单直接:没有复杂的状态管理库和数据流框架,学习成本低
  • 性能优秀:基于ArkUI的响应式系统,状态变更的粒度精确到组件级
  • 易于维护:数据与UI分离,业务逻辑集中在纯函数中
  • 可扩展性强:新增页面或功能不需要改动现有结构

对于中小型应用来说,这种架构是高效而实用的选择。随着应用规模的增长,可以逐步引入更复杂的状态管理方案,如 @Observed/@ObjectLink@Provide/@Consume 等更高级的状态装饰器,或者引入全局状态管理框架。

17.2 声明式UI实践总结

本应用充分展示了声明式UI范式的强大表达力。从简单的文本显示,到复杂的网格布局、条件渲染、循环渲染,再到动画效果和交互反馈,全部通过声明式的语法来描述。

声明式UI的核心优势在本应用中得到了充分体现:

描述而非命令:开发者只需要描述"UI在什么状态下应该是什么样子",而不需要关心"如何从状态A变成状态B"。框架自动处理了中间的过渡和更新。

状态驱动视图:所有UI变化都源于状态变化。追踪状态就能理解UI变化,调试变得更加直观。

组件化复用 :通过 @Component@Builder,UI片段可以被轻松抽象和复用,提高了开发效率和代码质量。

动画天然支持:ArkUI的动画系统与声明式UI深度集成,只需要改变状态值,框架就会自动产生动画过渡,无需手动计算中间帧。

17.3 动效设计维度总结

应用中的动效设计虽然不复杂,但非常精致且恰到好处。四种不同风格的动画(喷雾粒子、颜料滴落、脉冲发光、转盘旋转)分别应用于不同页面,每个动画都与页面的主题高度契合:

  • 首页的喷雾粒子呼应"喷涂"主题
  • 涂料仓的颜料滴落和脉冲发光呼应"涂料"主题
  • 素体库的转盘旋转呼应"展示"主题

这种"动效服务于主题"的设计理念,比为了动效而动效的做法更加高级。动效不是炫技的手段,而是增强主题表达、提升用户体验的工具。

动画的实现方式也值得称道:通过一个简单的数值变量驱动多个视觉元素,配合不同的系数产生丰富的变化。这种方法性能开销小、实现简单、效果出色,是移动端动效设计的典范。

17.4 数据可视化维度总结

应用在数据可视化方面展现了很强的创造力。不依赖任何第三方图表库,完全使用基础组件构建了多种数据可视化效果:

  • 环形进度仪表盘:使用Progress组件的Ring类型,配合Stack堆叠布局实现中心文字叠加,打造专业的仪表盘视觉效果。
  • 水平进度条:使用Progress组件的Linear类型,广泛应用于容量显示、口径展示、难度等级等场景,通过颜色变化传递语义信息。
  • 垂直柱状图:使用Column组件的动态高度模拟柱状图,配合Row的底部对齐实现图表布局,用于品牌分布、材质分布、消费趋势等统计展示。
  • 横向分布条:使用Column的宽度百分比实现水平进度条,用于色系分布、比例分布等场景,直观展示各分类的占比关系。
  • 分段难度条:使用多个小Column组合成分段式进度条,每段颜色随等级变化,实现难度可视化的渐变效果。
  • 纵向步骤流程:使用Step标签+连接线的组合,构建纵向时间线/流程图,清晰展示教程的步骤递进关系。

这些可视化效果全部基于ArkUI的基础布局组件实现,没有引入额外的图表库依赖。这种做法的优势在于:包体积更小、性能更可控、定制化程度更高。开发者可以完全掌控图表的每一个细节,实现与应用风格高度统一的视觉效果。

对于中小规模的数据展示需求,这种"原生组件拼装"的方式往往比引入重型图表库更加高效和灵活。当然,对于更复杂的图表需求(如折线图、饼图、雷达图等),HarmonyOS生态中也有相应的图表库可供选择。

17.5 代码质量与可维护性

从代码质量的角度来看,本应用有很多值得称道的地方:

命名规范统一 :所有变量、函数、组件都使用统一的前缀 PLPl,形成了清晰的命名空间。常量使用全大写下划线分隔,接口使用PascalCase,函数使用camelCase,组件使用PascalCase,命名规范严谨而专业。

代码分区清晰:文件内部使用注释分隔线将不同模块的代码明确分开,包括类型定义区、设计令牌区、色系配置区、功能宫格配置区、各业务数据区、全局函数区、各页面组件区等。即使是单文件架构,代码组织也非常有条理。

注释精简到位:代码中的注释不多但都很有用,主要用于标记代码分区和关键模块的说明。这种"适度注释"的风格比过度注释更加清爽,也降低了注释与代码不同步的风险。

纯函数与UI分离:业务逻辑被封装在纯函数中,与UI代码完全分离。纯函数没有副作用,输入输出明确,易于测试和复用。UI层只负责渲染和交互,职责单一。

响应式状态管理 :所有状态都通过 @State 装饰器管理,状态变更自动触发UI更新。开发者不需要手动操作UI元素,减少了出错的可能性。

17.6 用户体验设计亮点

从用户体验的角度来看,应用在细节处理上展现了很高的水准:

语义化颜色编码:红色表示危险/警告(删除、低容量),橙色表示注意/进行中(中等容量、配送中),绿色表示正常/完成(高容量、已完成)。这种与交通灯一致的颜色隐喻,用户无需学习就能理解。

三级信息层级:每个信息模块通常都有三级文字层级------主标题(大号粗体深色)、副标题/元信息(中号常规色)、辅助说明(小号灰色)。清晰的层级帮助用户快速识别信息的重要性。

操作反馈明确:每个可点击的元素都有明确的视觉暗示(按钮样式、箭头符号、悬停态等)。弹框有遮罩层点击关闭、右上角关闭按钮、操作按钮三重关闭途径,符合用户的操作习惯。

数据格式化友好:大数字转换为"万"或"千"单位显示,金额保留合适的小数位,日期格式简洁统一。这些细节处理虽然不显眼,但却实实在在地提升了信息的可读性。

空状态与边界处理:筛选后的数据、低容量涂料列表等可能为空的场景都有合理的处理。虽然当前数据量下不会出现空列表,但代码结构已经为这种情况做好了准备。

17.7 基于HarmonyOS API 24的技术亮点

本应用基于HarmonyOS ArkTS API 24开发,充分利用了新版本API的特性:

声明式UI语法增强 :ArkTS的声明式语法在API 24中更加成熟,支持更丰富的链式调用和属性设置。本应用中大量使用的属性链式调用(如 .fontSize().fontColor().margin())正是声明式UI语法的典型写法。

ForEach迭代渲染ForEach 组件是ArkUI声明式列表渲染的核心,它可以根据数组数据动态生成UI元素。本应用在色卡墙、热销榜、功能宫格、排行榜、教程列表等多处使用了 ForEach,展现了其强大的列表渲染能力。

Grid网格布局Grid 组件提供了专业的网格布局能力,支持列模板、行模板、列间距、行间距等精细控制。本应用在功能宫格(4x2)、涂料列表(3列)、素体列表(2列)等场景中使用了Grid布局,替代了传统的Row+Column组合方式,代码更加简洁直观。

显式动画系统animateTo 显式动画API在HarmonyOS 6.1.1中得到了进一步优化,支持更丰富的缓动曲线和播放模式。本应用中的喷雾粒子、脉冲发光、转盘旋转等动画效果都基于这一API实现,动画流畅自然。

Progress组件多样化:Progress组件支持Ring和Linear两种类型,还可以通过style属性自定义线宽等样式。本应用创造性地将Progress组件用于容量显示、口径可视化、难度进度等多种场景,拓展了进度条组件的使用边界。

Stack堆叠布局:Stack组件支持子元素堆叠排列,是实现居中叠加、悬浮效果的利器。本应用在环形仪表盘、色卡文字叠加、转盘展示台等多处使用了Stack布局,实现了丰富的视觉层次。

17.8 可优化与拓展方向

尽管本应用已经相当完善,但从工程化和产品化的角度,仍然有一些可以优化和拓展的方向:

状态管理升级 :当前全部使用 @State 组件内状态,当应用规模扩大后,可以考虑引入 @Observed/@ObjectLink 实现对象级状态观察,或使用 @Provide/@Consume 实现跨层级状态共享。对于更复杂的全局状态,可以引入AppStorage或PersistentStorage进行持久化存储。

组件拆分细化 :当前所有组件定义在同一个文件中,当代码量增长到一定程度后,可以按页面或功能模块拆分为多个文件,使用 import/export 进行模块间引用。这样更利于团队协作和代码维护。

数据持久化:当前所有数据都是硬编码的常量,应用重启后数据不会保存。可以引入轻量持久化方案(如Preferences),将用户的涂料数据、收藏记录、订单信息等保存到本地,实现真正可用的应用。

网络请求接入:将硬编码的常量数据替换为真实的网络请求,接入后端API服务。可以使用HarmonyOS提供的http能力,结合异步编程(async/await)实现数据的动态加载。

单元测试覆盖:当前的纯函数非常适合编写单元测试,可以引入测试框架对工具函数进行测试覆盖,确保业务逻辑的正确性。

无障碍适配:为视障用户添加无障碍支持,包括内容描述(contentDescription)、焦点顺序、屏幕阅读器支持等,提升应用的包容性。

深色模式适配:当前只支持浅色模式,可以增加深色模式的适配,根据系统设置自动切换配色方案,提升夜间使用体验。

国际化支持:将硬编码的中文字符串提取为资源文件,支持多语言切换,为应用的国际化拓展做好准备。


十八、HarmonyOS 6.1.1 开发实践感悟

18.1 开发效率与体验

基于HarmonyOS ArkTS API 24进行开发,整体的开发体验是非常流畅的。声明式UI的语法简洁直观,上手门槛不高,尤其是有前端开发经验的开发者,可以快速掌握ArkTS的核心概念。

声明式UI最大的优势在于"所见即所得"的开发体验。你描述UI的结构和状态,框架负责渲染和更新。这种模式减少了大量的样板代码,让开发者可以更专注于业务逻辑和用户体验本身。

ArkUI提供的组件库非常丰富,从基础的Text、Image、Button,到布局组件Column、Row、Stack、Grid,再到功能组件Scroll、List、Tabs、Progress、Swiper等,基本覆盖了移动端UI开发的所有常见需求。配合链式调用的属性设置方式,UI代码的可读性非常好。

18.2 性能表现

HarmonyOS 6.1.1 在UI渲染性能方面表现出色。ArkUI框架采用了差异化渲染机制,只更新真正发生变化的组件,避免了不必要的重绘。对于本应用中的列表滚动、动画效果等场景,都能保持流畅的帧率表现。

动画系统的性能尤其值得称道。无论是喷雾粒子的多元素动画,还是转盘旋转的持续动画,都可以在保持流畅的同时,不影响主线程的响应。这得益于ArkUI将动画计算与UI渲染分离的架构设计。

对于复杂的列表页面,ArkUI的ForEach组件也提供了良好的渲染性能。虽然本应用中的数据量不大(最多10项),但框架的虚拟化渲染能力足以支撑更大量级的列表场景。

18.3 生态与工具链

HarmonyOS的开发生态正在快速完善中。DevEco Studio作为官方IDE,提供了代码补全、实时预览、调试器、性能分析等完整的开发工具链。尤其是预览器功能,可以实时查看UI效果,大大提高了UI开发的效率。

ArkTS语言本身也在持续演进中。基于TypeScript的基础,加上声明式UI的扩展,既保留了类型安全的优势,又提供了强大的UI开发能力。对于有TypeScript或JavaScript背景的开发者来说,转型ArkTS的学习曲线是比较平缓的。

随着HarmonyOS生态的不断壮大,越来越多的第三方库和工具正在涌现。从UI组件库到状态管理框架,从网络请求库到数据持久化方案,开发者的选择越来越多,生态的繁荣也为HarmonyOS应用开发提供了更坚实的基础。


安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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


完整代码:

js 复制代码
// ================================================================
// PAINT LAB 手办涂装工作室 - ArkTS 单文件实现
// 前缀 PL | 工业灰蓝 #3A4A5A + 喷涂橙 #FF7F00 + 纯白 #FAFAFA
// 6 Tab:首页 / 涂料 / 喷笔 / 素体 / 教程 / 我的
// ================================================================

// ============ 类型定义 ============
interface PlSeriesMeta {
  label: string
  colorHex: string
  desc: string
  count: number
  pct: number
}

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

interface PlStatMeta {
  label: string
  count: number
  pct: number
  colorHex: string
}

interface PlPaintItem {
  id: number
  name: string
  series: string
  colorHex: string
  capacity: number
  price: number
  sales: number
  stock: number
  brand: string
  effect: string
}

interface PlAirbrushItem {
  id: number
  brand: string
  model: string
  caliber: number
  price: number
  origPrice: number
  sales: number
  cupMl: number
  score: number
  tag: string
}

interface PlBodyItem {
  id: number
  name: string
  scale: string
  material: string
  price: number
  stock: number
  sales: number
  pose: string
}

interface PlTutorialItem {
  id: number
  title: string
  author: string
  level: number
  duration: number
  rating: number
  views: number
  steps: string[]
}

interface PlOrderItem {
  id: string
  title: string
  count: number
  amount: number
  date: string
  status: string
}

interface PlFavItem {
  id: number
  title: string
  type: string
  colorHex: string
  date: string
}

interface PlSpendItem {
  month: string
  amount: number
  pct: number
}

// ============ 设计令牌 ============
const PL_PRIMARY: string = '#3A4A5A'
const PL_ACCENT: string = '#FF7F00'
const PL_BG: string = '#FAFAFA'
const PL_CARD: string = '#FFFFFF'
const PL_TEXT: string = '#2A3440'
const PL_SUB: string = '#8A94A0'
const PL_LINE: string = '#EDF0F3'

// ============ 色系配置 ============
const PL_SERIES_LIST: PlSeriesMeta[] = [
  { label: '基础', colorHex: '#3A4A5A', desc: '高遮盖底色 · 战锤骨架首选', count: 3, pct: 30 },
  { label: '金属', colorHex: '#A8B2BD', desc: '金属粒子光泽 · 机甲装甲', count: 2, pct: 20 },
  { label: '荧光', colorHex: '#B8FF3E', desc: '高饱和发光色 · 灯效件', count: 2, pct: 20 },
  { label: '珠光', colorHex: '#E8C77B', desc: '柔润珠光光泽 · 礼装涂装', count: 2, pct: 20 },
  { label: '透明', colorHex: '#1B6D9E', desc: '透明渐变 · 光刃与镜面', count: 1, pct: 10 }
]

const PL_SERIES_FILTER: string[] = ['全部', '基础', '金属', '荧光', '珠光', '透明']

// ============ 功能宫格配置 ============
const PL_FUNCS: PlFuncMeta[] = [
  { label: '我的配方', icon: '🎨', bg: '#EAF0F6', color: '#3A4A5A' },
  { label: '调漆计算', icon: '🧮', bg: '#FFF1E3', color: '#E06A00' },
  { label: '喷涂参数', icon: '💨', bg: '#EAF6EF', color: '#2E7D5B' },
  { label: '专属调色', icon: '🎯', bg: '#F3EDFA', color: '#6B4FA8' },
  { label: '工坊打卡', icon: '📍', bg: '#FDEBEE', color: '#C0392B' },
  { label: '素材库', icon: '📦', bg: '#E8F4FA', color: '#1B6D9E' },
  { label: '社区分享', icon: '🌐', bg: '#EAF6EF', color: '#00695C' },
  { label: '活动报名', icon: '🎫', bg: '#FFF1E3', color: '#D35400' }
]

// ============ 稀释比例配置 ============
const PL_MIX_RATIOS: string[] = ['1:1 标准喷涂', '1:2 水彩轻薄', '2:1 厚涂覆盖', '1:3 渐变透明']

// ============ 喷笔品牌分布(柱状图) ============
const PL_BRAND_STATS: PlStatMeta[] = [
  { label: '岩田', count: 2, pct: 100, colorHex: '#3A4A5A' },
  { label: '郡士', count: 2, pct: 100, colorHex: '#FF7F00' },
  { label: 'Sparmax', count: 1, pct: 50, colorHex: '#1B6D9E' },
  { label: 'H&S', count: 1, pct: 50, colorHex: '#2E7D5B' },
  { label: '峰华', count: 1, pct: 50, colorHex: '#6B4FA8' },
  { label: 'Badger', count: 1, pct: 50, colorHex: '#C0392B' }
]

// ============ 素体比例分布 ============
const PL_SCALE_STATS: PlStatMeta[] = [
  { label: '1/144', count: 2, pct: 100, colorHex: '#3A4A5A' },
  { label: '1/100', count: 2, pct: 100, colorHex: '#FF7F00' },
  { label: '1/60', count: 1, pct: 50, colorHex: '#1B6D9E' },
  { label: '1/12', count: 1, pct: 50, colorHex: '#2E7D5B' },
  { label: '1/8', count: 1, pct: 50, colorHex: '#6B4FA8' },
  { label: '1/7', count: 1, pct: 50, colorHex: '#C0392B' }
]

// ============ 素体材质分布(柱状图) ============
const PL_MATERIAL_STATS: PlStatMeta[] = [
  { label: 'ABS', count: 4, pct: 100, colorHex: '#3A4A5A' },
  { label: 'PVC', count: 2, pct: 50, colorHex: '#FF7F00' },
  { label: '树脂', count: 2, pct: 50, colorHex: '#1B6D9E' }
]

// ============ 涂料数据(10款) ============
const PL_PAINTS: PlPaintItem[] = [
  { id: 1, name: '星夜蓝', series: '基础', colorHex: '#2C5F8D', capacity: 78, price: 28.5, sales: 326, stock: 42, brand: '郡士', effect: '消光' },
  { id: 2, name: '喷涂橙焰', series: '荧光', colorHex: '#FF7F00', capacity: 45, price: 32.0, sales: 512, stock: 35, brand: 'PL自研', effect: '荧光' },
  { id: 3, name: '枪铁灰', series: '金属', colorHex: '#4A5560', capacity: 62, price: 35.8, sales: 289, stock: 28, brand: 'AV', effect: '金属' },
  { id: 4, name: '樱花粉', series: '基础', colorHex: '#F4A7B9', capacity: 30, price: 26.5, sales: 441, stock: 19, brand: '郡士', effect: '光泽' },
  { id: 5, name: '荧光绿光', series: '荧光', colorHex: '#B8FF3E', capacity: 88, price: 30.0, sales: 198, stock: 51, brand: 'PL自研', effect: '荧光' },
  { id: 6, name: '金砂珠光', series: '珠光', colorHex: '#E8C77B', capacity: 15, price: 48.0, sales: 376, stock: 8, brand: 'AV', effect: '珠光' },
  { id: 7, name: '深海透明蓝', series: '透明', colorHex: '#1B6D9E', capacity: 55, price: 24.0, sales: 233, stock: 33, brand: '盖亚', effect: '透明' },
  { id: 8, name: '曜石黑', series: '基础', colorHex: '#1A1A22', capacity: 92, price: 22.0, sales: 605, stock: 60, brand: '郡士', effect: '消光' },
  { id: 9, name: '霜白珠光', series: '珠光', colorHex: '#F5F2E9', capacity: 40, price: 27.5, sales: 318, stock: 24, brand: 'AV', effect: '珠光' },
  { id: 10, name: '战损银', series: '金属', colorHex: '#A8B2BD', capacity: 68, price: 39.0, sales: 175, stock: 30, brand: '盖亚', effect: '金属' }
]

// ============ 喷笔数据(8款) ============
const PL_AIRBRUSHES: PlAirbrushItem[] = [
  { id: 1, brand: '岩田', model: 'HP-CS·匠', caliber: 0.3, price: 1280, origPrice: 1480, sales: 462, cupMl: 9, score: 4.9, tag: '口碑之王' },
  { id: 2, brand: '岩田', model: 'HP-BP·纤', caliber: 0.2, price: 1580, origPrice: 1780, sales: 245, cupMl: 7, score: 4.8, tag: '极细线稿' },
  { id: 3, brand: '郡士', model: 'PS-289 镜面', caliber: 0.2, price: 799, origPrice: 899, sales: 341, cupMl: 10, score: 4.8, tag: '镜面专用' },
  { id: 4, brand: '郡士', model: 'PS-299 战队', caliber: 0.3, price: 688, origPrice: 758, sales: 312, cupMl: 15, score: 4.7, tag: '性价比' },
  { id: 5, brand: 'Sparmax', model: 'MAX-4 双动', caliber: 0.4, price: 899, origPrice: 999, sales: 275, cupMl: 22, score: 4.7, tag: '大面积' },
  { id: 6, brand: 'H&S', model: 'Evolution 2024', caliber: 0.4, price: 1099, origPrice: 1299, sales: 198, cupMl: 20, score: 4.6, tag: '德系精工' },
  { id: 7, brand: '峰华', model: 'HD-130 入门', caliber: 0.5, price: 258, origPrice: 328, sales: 520, cupMl: 9, score: 4.5, tag: '新人首支' },
  { id: 8, brand: 'Badger', model: 'Patriot 105', caliber: 0.5, price: 728, origPrice: 828, sales: 187, cupMl: 8, score: 4.4, tag: '美系扎实' }
]

// ============ 素体数据(8款) ============
const PL_BODIES: PlBodyItem[] = [
  { id: 1, name: '剑圣 Ver.Ka 素体', scale: '1/100', material: 'ABS', price: 299, stock: 45, sales: 389, pose: '拔刀立姿' },
  { id: 2, name: '暗天使改修素体', scale: '1/144', material: 'ABS', price: 199, stock: 88, sales: 512, pose: '悬浮展翼' },
  { id: 3, name: '哥特萝莉素体', scale: '1/8', material: 'PVC', price: 428, stock: 12, sales: 156, pose: '提裙回眸' },
  { id: 4, name: '机娘空白素体', scale: '1/7', material: '树脂', price: 588, stock: 8, sales: 98, pose: '武装待机' },
  { id: 5, name: 'SD圆桌骑士', scale: '1/144', material: 'ABS', price: 129, stock: 120, sales: 605, pose: '举盾冲锋' },
  { id: 6, name: '战术装甲素体', scale: '1/100', material: 'ABS', price: 259, stock: 66, sales: 341, pose: '持枪跪姿' },
  { id: 7, name: '武者白坯素体', scale: '1/60', material: 'ABS', price: 359, stock: 30, sales: 223, pose: '持刀劈斩' },
  { id: 8, name: '主教雕像素体', scale: '1/12', material: '树脂', price: 368, stock: 15, sales: 187, pose: '祈祷静立' }
]

// ============ 教程数据(6条) ============
const PL_TUTORIALS: PlTutorialItem[] = [
  { id: 1, title: '高达渗线笔进阶全解', author: '金田大师', level: 4, duration: 25, rating: 4.9, views: 12800, steps: ['整理水口与飞边', '稀释渗线液点入刻线', '棉签擦除溢出部分', '边缘高光提亮', '消光统一保护'] },
  { id: 2, title: '零基础渐变喷涂教程', author: '喷绘小阿琳', level: 2, duration: 18, rating: 4.7, views: 25600, steps: ['底色满喷两遍', '距面20cm扫喷', '逐层收窄喷涂区', '光油定色'] },
  { id: 3, title: '金属质感AB补土 sculpt', author: '老张工坊', level: 5, duration: 42, rating: 4.8, views: 8900, steps: ['AB补土1:1混合', '压出装甲纹理', '刀刮出棱线', '水补土检验'] },
  { id: 4, title: '水贴浸泡定位技巧', author: 'D Modell社', level: 3, duration: 15, rating: 4.6, views: 31200, steps: ['温水浸泡30秒', '软化剂打底', '棉棒定位排水', '压平吸干'] },
  { id: 5, title: '旧化战损流锈法', author: '锈迹阿梁', level: 5, duration: 38, rating: 4.9, views: 10400, steps: ['海绵点掉漆', '深色渍洗阴影', '流锈液自上而下', '钛棕色干扫', '粉彩定妆'] },
  { id: 6, title: '荧光分层涂装顺序', author: '夜光实验室', level: 4, duration: 22, rating: 4.5, views: 9800, steps: ['白色打底提亮', '透明荧光中层', '边缘留白过渡', 'UV漆点涂灯位'] }
]

// ============ 订单数据(6条) ============
const PL_ORDERS: PlOrderItem[] = [
  { id: 'PL-20260812', title: '星夜蓝 ×2 + 渗线液套装', count: 4, amount: 128.0, date: '08-12', status: '已完成' },
  { id: 'PL-20260809', title: '岩田 HP-CS·匠', count: 1, amount: 1280.0, date: '08-09', status: '配送中' },
  { id: 'PL-20260803', title: '机娘空白素体(树脂)', count: 1, amount: 588.0, date: '08-03', status: '已完成' },
  { id: 'PL-20260728', title: '荧光色三件套', count: 3, amount: 94.5, date: '07-28', status: '已完成' },
  { id: 'PL-20260721', title: 'SD圆桌骑士 ×2', count: 2, amount: 258.0, date: '07-21', status: '已取消' },
  { id: 'PL-20260715', title: '喷涂橙焰 + 消光保护漆', count: 2, amount: 66.5, date: '07-15', status: '已完成' }
]

// ============ 收藏数据(6条) ============
const PL_FAVS: PlFavItem[] = [
  { id: 1, title: '金砂珠光', type: '涂料', colorHex: '#E8C77B', date: '08-18' },
  { id: 2, title: '高达渗线笔进阶全解', type: '教程', colorHex: '#3A4A5A', date: '08-16' },
  { id: 3, title: '剑圣 Ver.Ka 素体', type: '素体', colorHex: '#2C5F8D', date: '08-14' },
  { id: 4, title: '岩田 HP-CS·匠', type: '喷笔', colorHex: '#FF7F00', date: '08-10' },
  { id: 5, title: '旧化战损流锈法', type: '教程', colorHex: '#4A5560', date: '08-05' },
  { id: 6, title: '深海透明蓝', type: '涂料', colorHex: '#1B6D9E', date: '07-30' }
]

// ============ 月消费数据(6条) ============
const PL_SPENDS: PlSpendItem[] = [
  { month: '2月', amount: 1260, pct: 67 },
  { month: '3月', amount: 980, pct: 52 },
  { month: '4月', amount: 1520, pct: 80 },
  { month: '5月', amount: 640, pct: 34 },
  { month: '6月', amount: 1890, pct: 100 },
  { month: '7月', amount: 1120, pct: 59 }
]

// ============ 全局纯函数 ============
function getHotPaints(): PlPaintItem[] {
  let r: PlPaintItem[] = []
  for (let i = 0; i < PL_PAINTS.length; i++) {
    r.push(PL_PAINTS[i])
  }
  r.sort((a: PlPaintItem, b: PlPaintItem) => b.sales - a.sales)
  return r
}

function getPaintsBySeries(series: string): PlPaintItem[] {
  let r: PlPaintItem[] = []
  for (let i = 0; i < PL_PAINTS.length; i++) {
    if (series === '全部' || PL_PAINTS[i].series === series) {
      r.push(PL_PAINTS[i])
    }
  }
  return r
}

function getLowCapacityPaints(): PlPaintItem[] {
  let r: PlPaintItem[] = []
  for (let i = 0; i < PL_PAINTS.length; i++) {
    if (PL_PAINTS[i].capacity < 50) {
      r.push(PL_PAINTS[i])
    }
  }
  return r
}

function getRankedAirbrushes(): PlAirbrushItem[] {
  let r: PlAirbrushItem[] = []
  for (let i = 0; i < PL_AIRBRUSHES.length; i++) {
    r.push(PL_AIRBRUSHES[i])
  }
  r.sort((a: PlAirbrushItem, b: PlAirbrushItem) => b.score * 1000 + b.sales - a.score * 1000 - a.sales)
  return r
}

function getPaintTotalSales(): number {
  let r: number = 0
  for (let i = 0; i < PL_PAINTS.length; i++) {
    r += PL_PAINTS[i].sales
  }
  return r
}

function getMonthSpendTotal(): number {
  let r: number = 0
  for (let i = 0; i < PL_SPENDS.length; i++) {
    r += PL_SPENDS[i].amount
  }
  return r
}

function getShownTutorials(onlyFree: boolean): PlTutorialItem[] {
  let r: PlTutorialItem[] = []
  for (let i = 0; i < PL_TUTORIALS.length; i++) {
    if (!onlyFree || PL_TUTORIALS[i].level <= 2) {
      r.push(PL_TUTORIALS[i])
    }
  }
  return r
}

// ============ 底部 Tab 枚举 ============
enum PlTab {
  HOME = 0,
  PAINTS = 1,
  AIRBRUSH = 2,
  BODIES = 3,
  TUTORIALS = 4,
  PROFILE = 5
}

// ============ 入口主组件 ============
@Entry
@Component
struct PlApp {
  @State activeTab: PlTab = PlTab.HOME

  @Builder contentArea() {
    Column() {
      if (this.activeTab === PlTab.HOME) {
        PlHomeContent()
      } else if (this.activeTab === PlTab.PAINTS) {
        PlPaintsContent()
      } else if (this.activeTab === PlTab.AIRBRUSH) {
        PlAirbrushContent()
      } else if (this.activeTab === PlTab.BODIES) {
        PlBodiesContent()
      } else if (this.activeTab === PlTab.TUTORIALS) {
        PlTutorialsContent()
      } else {
        PlProfileContent()
      }
    }
    .layoutWeight(1)
  }

  @Builder bottomTabItem(icon: string, label: string, tab: PlTab) {
    Column() {
      Text(icon)
        .fontSize(20)
        .opacity(this.activeTab === tab ? 1.0 : 0.4)
      Text(label)
        .fontSize(9)
        .fontColor(this.activeTab === tab ? PL_ACCENT : PL_SUB)
        .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 2 })
      if (this.activeTab === tab) {
        Column()
          .width(16).height(3)
          .backgroundColor(PL_ACCENT)
          .borderRadius(2)
          .margin({ top: 3 })
      }
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)
    .padding({ top: 6, bottom: 6 })
    .onClick(() => { this.activeTab = tab })
  }

  build() {
    Column() {
      this.contentArea()
      Row() {
        this.bottomTabItem('🏠', '首页', PlTab.HOME)
        this.bottomTabItem('🎨', '涂料', PlTab.PAINTS)
        this.bottomTabItem('💨', '喷笔', PlTab.AIRBRUSH)
        this.bottomTabItem('🤖', '素体', PlTab.BODIES)
        this.bottomTabItem('📖', '教程', PlTab.TUTORIALS)
        this.bottomTabItem('👤', '我的', PlTab.PROFILE)
      }
      .width('100%')
      .backgroundColor(PL_CARD)
      .padding({ top: 4, bottom: 4 })
      .shadow({ radius: 10, color: '#143A4A5A', offsetY: -3 })
    }
    .width('100%').height('100%')
    .backgroundColor(PL_BG)
  }
}

// ============ Tab1:首页 ============
@Component
struct PlHomeContent {
  @State plSprayY: number = 0
  @State showDetail: boolean = false
  @State selectedPaint: PlPaintItem | null = null

  aboutToAppear(): void {
    this.getUIContext().animateTo({
      duration: 1500,
      iterations: -1,
      curve: Curve.EaseOut,
      playMode: PlayMode.Alternate
    }, () => {
      this.plSprayY = -22
    })
  }

  // ========== 弹框遮罩 ==========
  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(20,26,34,0.55)')
      .onClick(onClose)
  }

  // ========== 弹框5:涂装详情(底部抽屉样式) ==========
  @Builder paintDetailModal() {
    Column() {
      this.modalOverlay(() => { this.showDetail = false })
      Column() {
        Column()
          .width(44).height(5).borderRadius(3)
          .backgroundColor('#DDE3E9').margin({ top: 10 })
        Row() {
          Column() {
            Text('🎨').fontSize(34).fontColor('#FFFFFF')
          }
          .width(92).height(92).borderRadius(18)
          .backgroundColor(this.selectedPaint!.colorHex)
          .shadow({ radius: 18, color: this.selectedPaint!.colorHex })
          Column() {
            Text(this.selectedPaint!.name)
              .fontSize(19).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
            Text(this.selectedPaint!.series + '系 · ' + this.selectedPaint!.effect + '漆面')
              .fontSize(12).fontColor(PL_SUB).margin({ top: 4 })
            Text('编号 PL-P' + this.selectedPaint!.id + ' · ' + this.selectedPaint!.brand)
              .fontSize(11).fontColor(PL_SUB).margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1).padding({ left: 14 })
        }
        .width('100%').alignItems(VerticalAlign.Center)
        .padding({ left: 20, right: 20, top: 16 })

        Row() {
          Text('剩余容量').fontSize(12).fontColor(PL_SUB)
          Column().layoutWeight(1)
          Text(this.selectedPaint!.capacity + '%')
            .fontSize(12).fontWeight(FontWeight.Bold)
            .fontColor(this.selectedPaint!.capacity < 30 ? '#C0392B' : (this.selectedPaint!.capacity < 60 ? PL_ACCENT : '#2E7D5B'))
        }
        .width('100%').padding({ left: 20, right: 20, top: 16 })
        Progress({ value: this.selectedPaint!.capacity, total: 100, type: ProgressType.Linear })
          .color(this.selectedPaint!.capacity < 30 ? '#C0392B' : PL_ACCENT)
          .style({ strokeWidth: 8 })
          .width('88%').margin({ top: 6 })

        Row() {
          Column() {
            Text(this.selectedPaint!.sales.toString())
              .fontSize(16).fontWeight(FontWeight.Bold).fontColor(PL_PRIMARY)
            Text('累计销量').fontSize(10).fontColor(PL_SUB).margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column().width(1).height(26).backgroundColor(PL_LINE)
          Column() {
            Text(this.selectedPaint!.stock.toString() + ' 瓶')
              .fontSize(16).fontWeight(FontWeight.Bold).fontColor(PL_PRIMARY)
            Text('现货库存').fontSize(10).fontColor(PL_SUB).margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column().width(1).height(26).backgroundColor(PL_LINE)
          Column() {
            Text(this.selectedPaint!.effect)
              .fontSize(16).fontWeight(FontWeight.Bold).fontColor(PL_PRIMARY)
            Text('推荐漆面').fontSize(10).fontColor(PL_SUB).margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding({ top: 14, bottom: 14 })
        .backgroundColor('#F5F7F9').borderRadius(12)
        .margin({ top: 16 })

        Row() {
          Column() {
            Row() {
              Text('¥').fontSize(12).fontColor(PL_ACCENT)
              Text(this.selectedPaint!.price.toFixed(1))
                .fontSize(22).fontWeight(FontWeight.Bold).fontColor(PL_ACCENT)
            }.alignItems(VerticalAlign.Bottom)
            Text('原价 ¥' + (this.selectedPaint!.price * 1.25).toFixed(1))
              .fontSize(10).fontColor(PL_SUB).margin({ top: 2 })
              .decoration({ type: TextDecorationType.LineThrough })
          }.alignItems(HorizontalAlign.Start)
          Column().layoutWeight(1)
          Text('加入收藏')
            .fontSize(13).fontColor(PL_PRIMARY)
            .borderRadius(20).border({ width: 1, color: PL_PRIMARY })
            .padding({ left: 16, right: 16, top: 9, bottom: 9 })
            .onClick(() => { this.showDetail = false })
          Text('立即下单')
            .fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            .backgroundColor(PL_ACCENT).borderRadius(20)
            .padding({ left: 18, right: 18, top: 9, bottom: 9 })
            .margin({ left: 10 })
            .onClick(() => { this.showDetail = false })
        }
        .width('100%').alignItems(VerticalAlign.Center)
        .padding({ left: 20, right: 20, top: 16, bottom: 20 })
      }
      .width('94%').backgroundColor(PL_CARD).borderRadius(20)
      .alignItems(HorizontalAlign.Center)
      .position({ x: '3%', y: '46%' })
    }
    .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
  }

  // ========== 首页头部 ==========
  @Builder homeHeader() {
    Row() {
      Column() {
        Text('PL')
          .fontSize(15).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
          .backgroundColor(PL_ACCENT).borderRadius(8)
          .padding({ left: 7, right: 7, top: 4, bottom: 4 })
      }
      Column() {
        Row() {
          Text('PAINT').fontSize(18).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
          Text(' LAB').fontSize(18).fontWeight(FontWeight.Bold).fontColor(PL_ACCENT)
        }
        Text('手办涂装工作室 · 喷涂体验中心')
          .fontSize(10).fontColor(PL_SUB).margin({ top: 1 })
      }
      .alignItems(HorizontalAlign.Start).padding({ left: 8 })
      Column().layoutWeight(1)
      Text('🔍').fontSize(15)
        .width(34).height(34).borderRadius(17).backgroundColor(PL_CARD)
        .textAlign(TextAlign.Center)
        .shadow({ radius: 6, color: '#143A4A5A' })
      Text('🔔').fontSize(15)
        .width(34).height(34).borderRadius(17).backgroundColor(PL_CARD)
        .textAlign(TextAlign.Center)
        .shadow({ radius: 6, color: '#143A4A5A' })
        .margin({ left: 8 })
    }
    .width('100%').alignItems(VerticalAlign.Center)
    .padding({ left: 16, right: 16, top: 10, bottom: 10 })
    .backgroundColor(PL_BG)
  }

  build() {
    Stack() {
      Column() {
        this.homeHeader()
        Scroll() {
          Column() {
            // ===== 涂装仪表盘 =====
            Column() {
              Row() {
                Text('涂装仪表盘').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Column().layoutWeight(1)
                Text('本周更新').fontSize(9).fontColor('#B8C4D0')
                  .backgroundColor('#54677A').borderRadius(8)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              }
              .width('100%')
              Row() {
                Stack() {
                  Progress({ value: 76, total: 100, type: ProgressType.Ring })
                    .style({ strokeWidth: 9 })
                    .color(PL_ACCENT)
                    .backgroundColor('#54677A')
                    .width(108).height(108)
                  Column() {
                    Text('76%').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                    Text('总完成度').fontSize(10).fontColor('#B8C4D0').margin({ top: 2 })
                  }
                }
                Column() {
                  Row() {
                    Column().width(8).height(8).borderRadius(4).backgroundColor(PL_ACCENT)
                    Text('进行中项目').fontSize(12).fontColor('#B8C4D0').margin({ left: 6 })
                    Column().layoutWeight(1)
                    Text('6 件').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                  }.width('100%').margin({ bottom: 9 })
                  Row() {
                    Column().width(8).height(8).borderRadius(4).backgroundColor('#B8FF3E')
                    Text('本月完工').fontSize(12).fontColor('#B8C4D0').margin({ left: 6 })
                    Column().layoutWeight(1)
                    Text('13 件').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                  }.width('100%').margin({ bottom: 9 })
                  Row() {
                    Column().width(8).height(8).borderRadius(4).backgroundColor('#E8C77B')
                    Text('喷涂工时').fontSize(12).fontColor('#B8C4D0').margin({ left: 6 })
                    Column().layoutWeight(1)
                    Text('86.5h').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                  }.width('100%')
                }
                .layoutWeight(1).padding({ left: 18, right: 6 })
              }
              .width('100%').alignItems(VerticalAlign.Center)
              .margin({ top: 14 })

              // ===== 喷雾粒子特效 plSprayY =====
              Row() {
                Text('💨').fontSize(16)
                Circle().width(5).height(5).fill(PL_ACCENT)
                  .translate({ x: -4, y: this.plSprayY * 1.3 }).opacity(0.85)
                Circle().width(4).height(4).fill('#B8C4D0')
                  .translate({ x: 6, y: this.plSprayY * 0.7 }).opacity(0.6)
                Circle().width(3).height(3).fill(PL_ACCENT)
                  .translate({ x: 14, y: this.plSprayY * 1.6 }).opacity(0.75)
                Circle().width(5).height(5).fill('#E8C77B')
                  .translate({ x: 22, y: this.plSprayY * 0.9 }).opacity(0.65)
                Circle().width(4).height(4).fill('#B8FF3E')
                  .translate({ x: 30, y: this.plSprayY * 1.2 }).opacity(0.8)
                Circle().width(3).height(3).fill('#FFFFFF')
                  .translate({ x: 38, y: this.plSprayY * 0.6 }).opacity(0.5)
                Circle().width(4).height(4).fill(PL_ACCENT)
                  .translate({ x: 46, y: this.plSprayY * 1.4 }).opacity(0.7)
              }
              .width('100%').justifyContent(FlexAlign.Center)
              .padding({ top: 12, bottom: 4 })
            }
            .width('100%').borderRadius(18).backgroundColor(PL_PRIMARY)
            .padding({ left: 16, right: 16, top: 14, bottom: 12 })
            .margin({ top: 4 })

            // ===== 色卡横滑 =====
            Row() {
              Text('🎛 色卡墙').fontSize(15).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
              Column().layoutWeight(1)
              Text('左右滑动 ›').fontSize(10).fontColor(PL_SUB)
            }
            .width('100%').padding({ left: 16, right: 16, top: 16, bottom: 10 })
            Scroll() {
              Row() {
                ForEach(PL_PAINTS, (p: PlPaintItem) => {
                  Column() {
                    Column() {
                      Text(p.name.substring(0, 1)).fontSize(26).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                    }
                    .width(82).height(82).borderRadius(14)
                    .backgroundColor(p.colorHex)
                    .shadow({ radius: 8, color: p.colorHex })
                    Text(p.name).fontSize(11).fontColor(PL_TEXT).margin({ top: 6 })
                    Text(p.series + '系 · ' + p.capacity + '%')
                      .fontSize(9).fontColor(PL_SUB).margin({ top: 1 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .margin({ left: 6, right: 6 })
                  .onClick(() => {
                    this.selectedPaint = p
                    this.showDetail = true
                  })
                })
              }
              .padding({ left: 10, right: 10 })
            }
            .scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
            .width('100%')

            // ===== 热销榜 =====
            Row() {
              Text('🔥 热销榜 TOP5').fontSize(15).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
              Column().layoutWeight(1)
              Text('月销 ' + getPaintTotalSales() + ' 瓶').fontSize(10).fontColor(PL_SUB)
            }
            .width('100%').padding({ left: 16, right: 16, top: 18, bottom: 10 })
            Column() {
              ForEach(getHotPaints().slice(0, 5), (p: PlPaintItem, idx: number) => {
                Row() {
                  Text((idx + 1).toString())
                    .fontSize(idx < 3 ? 18 : 14).fontWeight(FontWeight.Bold)
                    .fontColor(idx === 0 ? PL_ACCENT : (idx === 1 ? PL_PRIMARY : (idx === 2 ? '#B0713C' : PL_SUB)))
                    .width(30).textAlign(TextAlign.Center)
                  Column() {
                    Text(p.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
                    Text(p.series + '系 · ' + p.effect + ' · 已售' + p.sales + '瓶')
                      .fontSize(10).fontColor(PL_SUB).margin({ top: 2 })
                  }
                  .alignItems(HorizontalAlign.Start)
                  .layoutWeight(1).padding({ left: 8 })
                  Column() {
                    Text('¥' + p.price.toFixed(1))
                      .fontSize(14).fontWeight(FontWeight.Bold).fontColor(PL_ACCENT)
                    Text('会员95折').fontSize(9).fontColor(PL_SUB).margin({ top: 2 })
                  }.alignItems(HorizontalAlign.End)
                }
                .width('100%').alignItems(VerticalAlign.Center)
                .padding({ left: 14, right: 14, top: 11, bottom: 11 })
                .backgroundColor(PL_CARD).borderRadius(12)
                .margin({ bottom: 8 })
                .onClick(() => {
                  this.selectedPaint = p
                  this.showDetail = true
                })
              })
            }
            .width('100%').padding({ left: 12, right: 12 })

            // ===== 功能宫格 =====
            Row() {
              Text('🧰 工作台').fontSize(15).fontWeight(FontWeight.Bold).fontColor(PL_TEXT)
              Column().layoutWeight(1)
              Text('全部工具 ›').fontSize(10).fontColor(PL_SUB)
            }
            .width('100%').padding({ left: 16, right: 16, top: 12, bottom: 10 })
            Grid() {
              ForEach(PL_FUNCS, (f: PlFuncMeta) => {
                GridItem() {
                  Column() {
                    Text(f.icon).fontSize(22)
                      .width(44).height(44).borderRadius(22)
                      .backgroundColor(f.bg).textAlign(TextAlign.Center)
                    Text(f.label).fontSize(11).fontColor(PL_TEXT).margin({ top: 7 })
              
}

十九、结语

PAINT LAB 手办涂装工作室应用,作为一款基于HarmonyOS 6.1.1与ArkTS API 24开发的垂直领域应用,展现了声明式UI范式下移动应用开发的全新可能。从架构设计到动效实现,从数据可视化到用户体验,每一个细节都体现了对技术品质和用户价值的追求。

通过深入分析这款应用的源代码,我们不仅可以学习到ArkTS声明式UI的具体用法,更能体会到"状态驱动视图"这一核心思想在实际项目中的落地方式。从类型定义到数据模型,从纯函数工具到组件实现,从状态管理到动画特效,整个代码展现了清晰的架构思路和扎实的编码功底。

相关推荐
lilian23323 分钟前
HarmonyOS 7 新特性(七)|JsLeakWatcher 与 HWASan 内存治理
华为·harmonyos
2501_9197490334 分钟前
华为鸿蒙免费养猫APP—小羊养猫
华为·harmonyos·鸿蒙
2501_9197490340 分钟前
华为鸿蒙免费养鱼APP—小羊养鱼
华为·harmonyos·鸿蒙
lilian2331 小时前
HarmonyOS 7 新特性(二)|ArkAF:把 App 业务能力安全开放为 Skill
华为·harmonyos
OH_TPC1 小时前
async 和 await
华为·harmonyos·鸿蒙
贾伟康1 小时前
【天体运行模拟|03】HarmonyOS ArkTS 场景选择实战:组织行星、卫星与天文现象入口
list·harmonyos·arkts·arkui·页面路由
加农炮手Jinx11 小时前
Flutter for OpenHarmony 实战:flutter_animate 声明式动画让 UI 灵动如原生
flutter·ui·华为·harmonyos·鸿蒙
里欧跑得慢11 小时前
Flutter 三方库 function_tree — 鸿蒙应用开发中的动态数学公式解析与计算神器,实现鸿蒙深度适配下的复杂函数逻辑运行时求值实战(适配鸿蒙 HarmonyOS Next ohos)
android·前端·安全·flutter·华为·harmonyos
2501_9197490313 小时前
华为鸿蒙免费制定规则软件—小羊规则
华为·harmonyos·鸿蒙