1.1 鸿蒙操作系统的技术演进

在这里插入图片描述

HarmonyOS 作为华为自研的分布式操作系统,自诞生之日起便以"面向万物互联时代"为核心定位。与传统移动端操作系统不同,HarmonyOS 采用了微内核分布式架构设计,通过软总线技术实现了跨设备的能力共享与协同。这种架构理念使得开发者可以用一套代码库同时适配手机、平板、手表、车机、智慧屏等多种终端形态,极大降低了多端应用的开发与维护成本。

在 HarmonyOS 6.1.1 版本中,系统层面进一步优化了分布式软总线的传输效率,提升了跨设备流转的响应速度。同时,ArkUI 框架在声明式开发范式上持续演进,新增了更多高性能组件和动画能力,为开发者提供了更丰富的 UI 构建工具。基于 HarmonyOS API 24,开发者可以充分利用系统提供的最新能力,打造体验更加流畅的原生应用。

1.2 ArkTS 语言的核心特性

在这里插入图片描述

ArkTS 是 HarmonyOS 应用开发的首选语言,它在 TypeScript 的基础上进行了扩展,融合了声明式 UI、状态管理、自定义组件等核心能力。与传统的命令式 UI 开发方式相比,ArkTS 的声明式范式让开发者可以更专注于"描述界面应该是什么样子",而不是"如何一步步构建界面"。

ArkTS 的核心特性包括:基于装饰器的组件声明机制、响应式状态管理系统、内置的 UI 组件库、以及强大的自定义组件能力。其中,@Component 装饰器用于标记自定义组件,@Entry 装饰器标记页面入口,@State 装饰器声明组件内部状态。这些装饰器共同构成了 ArkTS 声明式开发的基础语法体系。

在类型系统层面,ArkTS 完全兼容 TypeScript 的类型标注能力,支持接口(interface)、枚举(enum)、泛型等高级类型特性。这意味着开发者可以在享受声明式 UI 便捷性的同时,获得强类型语言带来的代码安全性和可维护性保障。

1.3 声明式 UI 范式的深度理解

在这里插入图片描述

声明式 UI 范式的核心思想是"数据驱动视图"。当状态数据发生变化时,框架会自动重新计算并更新相关的 UI 节点,而无需开发者手动操作 DOM 或 View 对象。这种开发模式极大简化了 UI 状态同步的复杂度,降低了因状态不一致导致的 Bug 概率。

在 ArkUI 框架中,每一个用 @Component 装饰的结构体都是一个独立的 UI 组件单元。组件的 build() 方法描述了组件的 UI 结构,它返回一组声明式的组件树。当组件的 @State 变量发生变化时,框架会触发组件的重新渲染,即重新执行 build() 方法,生成新的组件树,并与旧的组件树进行 Diff 比较,只更新发生变化的部分。

这种机制下,开发者需要特别注意 build() 方法的幂等性和纯净性。也就是说,build() 方法不应该产生副作用,相同的状态输入应该产生相同的 UI 输出。违反这一原则可能导致不可预期的渲染行为和性能问题。

1.4 HarmonyOS 6.1.1 新特性概览

在这里插入图片描述

HarmonyOS 6.1.1 版本在多个维度进行了能力升级。在图形渲染方面,系统优化了 GPU 渲染管线,提升了复杂场景下的帧率稳定性。在动画系统方面,新增了更细粒度的动画控制能力,支持更丰富的插值曲线和物理模拟效果。在状态管理方面,提供了更高效的状态观测机制,减少了不必要的重绘开销。

此外,HarmonyOS 6.1.1 还强化了 ArkTS 的类型推断能力,提升了编译期错误检测的精准度。对于组件化开发而言,新版本的 @Builder 装饰器功能得到了增强,支持更灵活的 UI 片段复用模式。这些改进共同构成了一个更成熟、更高效的应用开发生态。

基于 HarmonyOS API 24 的应用可以充分利用这些新特性,在保证代码质量的同时,为用户带来更流畅、更精致的交互体验。接下来,我们将以一个手工银饰锻造馆电商应用为例,深入剖析如何在实际项目中运用这些技术能力。

1.5 项目业务背景引入

在这里插入图片描述

本文将要剖析的应用是一款名为 “SILVER FORGE 手工银饰锻造馆” 的电商类应用。应用以工业复古风格为视觉基调,采用银灰、暖金、做旧米三色为主色系,营造出一种匠心独运的手工艺品质感。应用包含首页、银戒、银链、吊坠、工具、我的六大功能模块,覆盖了商品展示、筛选定制、数据可视化、会员管理等典型电商场景。

值得关注的是,该应用采用单文件架构设计,所有组件、数据、逻辑都集中在一个源码文件中。这种架构方式虽然在大型项目中不常见,但对于学习研究和快速原型开发而言,具有独特的优势——它消除了文件跳转的心智负担,让开发者可以更连贯地理解整个应用的逻辑脉络。

接下来,我们将从类型定义、数据层、辅助函数、主架构、各功能模块等维度,逐行逐段地对这款应用的源码进行深度剖析,挖掘其中蕴含的 ArkTS 开发技巧和设计思想。


二、类型系统与数据模型设计

在这里插入图片描述

2.1 接口定义的艺术:类型即文档

在 ArkTS 开发中,良好的类型定义不仅能够提供编译期的类型安全保障,更是一种活的文档。通过阅读接口定义,开发者可以快速理解数据结构的含义和约束,而无需翻阅额外的文档。

interface SFColorPalette {
  bg: string
  card: string
  primary: string
  gold: string
  cream: string
  text: string
  sub: string
  line: string
  zebra: string
  danger: string
}

这段代码定义了应用的配色板接口 SFColorPalette。它包含了 10 个颜色属性,每个属性都是字符串类型(存储十六进制颜色值)。从命名上可以清晰地看出每个颜色的用途:bg 用于背景色,card 用于卡片底色,primary 是主色调,gold 是强调金色,cream 是奶油色,text 是正文文字色,sub 是次要文字色,line 是分割线色,zebra 是斑马纹底色,danger 是危险警示色。

这种命名方式体现了语义化配色的设计思想。开发者不需要记忆具体的十六进制颜色值,只需要通过语义化的变量名就能准确使用颜色。同时,当需要调整整体配色方案时,只需要修改这一处定义,整个应用的颜色就会同步更新,极大提升了主题定制的效率。

2.2 银戒数据模型:多维度属性描述

在这里插入图片描述

interface SFRing {
  id: number
  name: string
  material: string
  size: number
  weight: number
  price: number
  sales: number
  tag: string
}

SFRing 接口定义了银戒商品的数据模型。它包含 8 个属性,从不同维度描述了一枚银戒的特征:id 是唯一标识符,name 是商品名称,material 是材质类型(如 925银、999足银、泰银),size 是圈口尺寸,weight 是重量(克),price 是价格,sales 是销量,tag 是标签(如经典、热销、新品等)。

这个数据模型的设计非常讲究。它不仅包含了商品展示所需的基本信息(名称、价格、标签),还包含了产品规格层面的属性(材质、尺寸、重量),以及运营层面的数据(销量)。这种多维度的属性设计为后续的筛选、排序、对比等功能提供了数据基础。

特别值得注意的是 tag 字段的设计。在电商应用中,商品标签是一种常见的营销手段,用于快速传达商品的定位和特色。通过将标签作为数据模型的一个字段,开发者可以灵活地为每个商品配置不同的标签,而不需要修改 UI 代码。

2.3 银链与吊坠:差异化数据模型

interface SFChain {
  id: number
  name: string
  chainType: string
  length: number
  weight: number
  price: number
  sales: number
}

SFChain 接口定义了银链商品的数据模型。与银戒不同,银链用 chainType(链型)替代了 material(材质),用 length(长度)替代了 size(圈口)。这种差异化设计反映了不同品类商品的属性差异——银戒关注圈口尺寸和材质,银链关注链型和长度。

interface SFPendant {
  id: number
  name: string
  colorHex: string
  craft: string
  weight: number
  price: number
}

SFPendant 接口定义了吊坠商品的数据模型。吊坠的特色属性包括 colorHex(颜色十六进制值)和 craft(工艺)。colorHex 字段的设计尤其巧妙——它直接存储颜色的十六进制值,使得 UI 层可以直接用这个值来渲染色卡,无需额外的映射转换。

craft 字段则描述了吊坠的制作工艺,如錾刻、浮雕、珐琅、镂空、做旧、镶嵌等。这些工艺信息不仅是产品的重要卖点,也是用户选择商品时的关键决策因素。

2.4 工具与订单:业务对象建模

interface SFTool {
  id: number
  name: string
  power: number
  weight: number
  precision: number
  price: number
}

SFTool 接口定义了工坊设备工具的数据模型。工具的属性更偏向技术参数:power 是功率(瓦),weight 是净重(千克),precision 是精度(毫米)。这些参数化的属性为后续的参数表格展示和功率对比图表提供了数据支撑。

interface SFOrder {
  id: number
  name: string
  status: string
  price: number
  date: string
}

SFOrder 接口定义了订单的数据模型。订单状态 status 是一个关键字段,它决定了订单在 UI 上的展示样式——已完成订单显示绿色,进行中订单显示金色。这种基于状态的样式差异化是电商应用中常见的设计模式。

2.5 收藏、月度数据与匠人:辅助模型

interface SFFavorite {
  id: number
  name: string
  price: number
  oldPrice: number
}

SFFavorite 接口定义了收藏商品的数据模型。与普通商品不同,收藏商品额外包含了 oldPrice(原价)字段,用于展示价格对比和优惠力度。划线价 + 收藏价的组合是电商应用中常用的促销视觉手法。

interface SFMonthly {
  month: string
  amount: number
}

SFMonthly 接口定义了月度消费数据模型。这个简单的数据结构只有两个字段——月份和金额,但它是柱状图可视化的核心数据源。数据模型的简洁性往往意味着更高的复用性和更低的维护成本。

interface SFArtisan {
  id: number
  name: string
  title: string
  years: number
  works: number
}

SFArtisan 接口定义了匠人数据模型。匠人的属性包括姓名、头衔、从业年限、作品数量。这些信息共同塑造了匠人的专业形象,是手工类产品建立信任的重要元素。

2.6 功能特性与枚举设计

interface SFFeature {
  icon: string
  label: string
}

SFFeature 接口定义了功能宫格项的数据模型。每个功能项由图标(Unicode 字符)和标签文字组成。这种极简的数据结构非常适合宫格导航这类场景——数据结构越简单,渲染效率越高,代码维护成本越低。

enum SFTab {
  Home,
  Ring,
  Chain,
  Pendant,
  Tool,
  Mine
}

SFTab 枚举定义了底部导航栏的六个标签页。使用枚举而不是字符串或数字常量来表示 Tab 状态,具有多重优势:首先,它提供了编译期的类型安全,防止拼写错误;其次,它让代码更具可读性,SFTab.Home 比数字 0 或字符串 "home" 更直观;最后,IDE 可以提供更好的自动补全支持,提升开发效率。


三、全局配色系统:工业复古美学的代码实现

3.1 配色板常量定义

const SF_COLORS: SFColorPalette = {
  bg: '#F0E8D8',
  card: '#FFFFFF',
  primary: '#4A4A4A',
  gold: '#D4A017',
  cream: '#FAF5EA',
  text: '#3A3630',
  sub: '#9A917F',
  line: '#E5DCC8',
  zebra: '#F7F1E3',
  danger: '#C0392B'
}

这段代码创建了应用的全局配色板常量 SF_COLORS。它的类型是前面定义的 SFColorPalette 接口,确保了配色板的结构完整性。

让我们逐一分析每种颜色的设计考量。背景色 bg 采用 #F0E8D8,这是一种偏暖的米黄色调,类似于做旧的羊皮纸质感。这种颜色作为背景,既能提供足够的对比度保证文字可读性,又能营造出温暖复古的氛围。

卡片底色 card 采用纯白色 #FFFFFF,在暖色调的背景上形成清晰的层次区分。白色卡片是现代 UI 设计中最常用的容器底色,它能够很好地承托各种内容元素,同时保持界面的简洁感。

主色调 primary 采用 #4A4A4A,一种中等深度的灰色。这种颜色既不像纯黑那样生硬,又能提供足够的视觉权重。在工业复古风格中,灰色代表着金属的冷峻和工坊的厚重感。

金色 gold 采用 #D4A017,这是一种偏暗的暖金色,类似于经过岁月沉淀的古金色,而非刺眼的亮金。这种金色完美契合了手工银饰的匠心主题——它暗示着贵金属的价值感,同时又不失沉稳内敛。

奶油色 cream 采用 #FAF5EA,比背景色更浅更亮,常用于输入框、按钮等交互元素的底色。它与背景色形成微妙的层次差异,既保持了整体色调的统一性,又提供了必要的视觉区分。

3.2 文字颜色的层级设计

文字颜色系统分为三个层级:主文字色 text、次要文字色 sub、以及强调色(通过金色实现)。

主文字色 text#3A3630,这是一种深棕灰色,比纯黑更柔和,在暖色调背景上具有优秀的可读性。这种颜色避免了纯黑与暖调背景的强烈冲突,让文字阅读更加舒适。

次要文字色 sub#9A917F,这是一种中等深度的灰棕色,与主文字色形成明显的层级差异。它用于辅助信息、标签说明、时间日期等次要内容,帮助用户快速识别信息的重要性层级。

金色 gold 则用于价格、高亮标签、选中状态等需要强调的元素。在整体偏灰棕的色调中,金色自然成为视觉焦点,引导用户的注意力流向关键信息。

3.3 功能色与分割色

分割线色 line#E5DCC8,这是一种非常浅的暖灰色。它的明度接近背景色,能够在不破坏整体视觉连贯性的前提下,提供微妙的空间分隔。分割线是 UI 设计中最容易被忽视但又至关重要的元素——好的分割线应该"隐形"地存在,用户感知不到它的存在,但能清晰地感知到界面的结构层次。

斑马纹色 zebra#F7F1E3,比卡片底色略深一点,用于表格的交替行底色。斑马纹是数据表格中提升可读性的经典手法,它帮助用户的视线在横向移动时不串行。在浅色主题中,斑马纹的色值差异应该控制在较小的范围内,避免过于刺眼。

危险色 danger#C0392B,一种偏暗的砖红色。它用于删除操作、警告提示等负面语义的场景。选择偏暗的红色而非鲜艳的正红,是为了与整体复古暖调风格保持协调,同时仍然能够清晰地传达警示含义。

3.4 配色系统的工程价值

这套配色系统的设计体现了几个重要的工程原则。首先是语义化命名——所有颜色变量都以用途命名而非以色值命名,这使得颜色的调整不会导致命名失效。其次是集中管理——所有颜色定义在同一个常量对象中,修改主题时只需改动一处。第三是层级分明——文字、背景、边框、功能色各有明确的层级划分,确保了界面信息架构的清晰度。

在 ArkTS 开发中,将配色方案抽象为全局常量是一种最佳实践。它不仅提升了代码的可维护性,也为后续的深色模式适配、多主题切换等功能预留了扩展空间。当应用需要支持多主题时,只需要定义多套配色板常量,然后根据当前主题动态切换即可。


四、数据层设计:Mock 数据的结构化艺术

4.1 银戒商品数据集

const SF_RINGS: SFRing[] = [
  { id: 1, name: '素圈银戒', material: '925银', size: 14, weight: 3.2, price: 129, sales: 328, tag: '经典' },
  { id: 2, name: '锤纹银戒', material: '泰银', size: 16, weight: 4.5, price: 199, sales: 512, tag: '热销' },
  // ... 共 10 条数据
]

SF_RINGS 常量存储了 10 款银戒商品的模拟数据。每一条数据都严格遵循 SFRing 接口的结构,包含 id、名称、材质、尺寸、重量、价格、销量、标签八个字段。

从数据内容来看,这些银戒涵盖了不同的材质(925银、泰银、999足银)、不同的风格(经典、复古、轻奢、灵动)、不同的价位段(129元到399元)。这种数据多样性对于演示筛选、排序等功能至关重要。如果所有商品的材质都一样,那么材质筛选功能就无从展示。

特别值得注意的是数据的真实性和合理性。例如,999足银的戒指普遍比925银的更重(因为纯度更高,质地更软,需要更多材料保证强度),泰银款式通常带有复古标签,这些细节都符合真实的产品知识。好的模拟数据不仅要格式正确,还要符合业务逻辑,这样才能让演示效果更具说服力。

4.2 银链商品数据集

const SF_CHAINS: SFChain[] = [
  { id: 1, name: '古巴银链', chainType: '古巴链', length: 550, weight: 32, price: 459, sales: 380 },
  { id: 2, name: '麻绳银链', chainType: '绳链', length: 500, weight: 21, price: 289, sales: 452 },
  // ... 共 8 条数据
]

SF_CHAINS 常量存储了 8 款银链商品的数据。银链数据的特点是长度值较大(450mm-600mm),这为后续的长度对比进度条提供了足够的数值跨度。链型字段 chainType 包含了古巴链、绳链、盒链、瓦片链、水波链、做旧链、珠链、骑士链等多种类型,覆盖了市场上常见的银链款式。

重量数据也体现了合理性——骑士链和做旧链通常更厚重,重量在 35g-40g 之间;而珠链和盒链相对轻盈,重量在 12g-15g 左右。这种数据上的细节处理使得后续的数据分析和可视化更有实际意义。

4.3 吊坠商品数据集

const SF_PENDANTS: SFPendant[] = [
  { id: 1, name: '银月吊坠', colorHex: '#C9CDD4', craft: '錾刻', weight: 6.2, price: 219 },
  { id: 2, name: '暖金叶吊坠', colorHex: '#D4A017', craft: '浮雕', weight: 4.8, price: 259 },
  // ... 共 8 条数据
]

SF_PENDANTS 常量存储了 8 款吊坠商品的数据。吊坠数据最有特色的字段是 colorHex,每款吊坠都有自己独特的颜色值。这些颜色值从冷色调的银月灰、珐琅蓝,到暖色调的暖金叶、红玛瑙,再到复古调的古币棕、银杏黄,覆盖了丰富的色彩谱系。

这种设计使得吊坠页面可以采用色卡展示的形式——每款吊坠用它自己的颜色渲染一个色块,用户通过颜色就能直观识别不同的吊坠款式。这是一种将数据属性直接转化为视觉元素的优秀设计手法。

4.4 工具设备数据集

const SF_TOOLS: SFTool[] = [
  { id: 1, name: '高频锤打机', power: 850, weight: 12.5, precision: 0.02, price: 3699 },
  { id: 2, name: '精密錾刻刀', power: 60, weight: 0.8, precision: 0.01, price: 289 },
  // ... 共 6 条数据
]

SF_TOOLS 常量存储了 6 款工坊设备的数据。工具数据的特点是参数差异巨大——功率从 15W 到 850W 不等,重量从 0.4kg 到 15.2kg 差异悬殊,精度从 0.001mm 到 0.05mm 各有不同。

这种参数差异是刻意设计的。在工具页面中,功率对比柱状图需要有足够的数值差异才能展示出对比效果。如果所有工具的功率都差不多,柱状图就会变成一排等高的柱子,失去了可视化的意义。

价格数据也体现了设备价值的差异——从几百元的小型工具到几千元的大型设备,覆盖了不同的价格档位,符合真实工坊设备的价格分布。

4.5 订单与收藏数据集

const SF_ORDERS: SFOrder[] = [
  { id: 1, name: '锤纹银戒 × 1', status: '已完成', price: 199, date: '08-24' },
  { id: 2, name: '古巴银链 × 1', status: '锻造中', price: 459, date: '08-22' },
  // ... 共 6 条数据
]

SF_ORDERS 常量存储了 6 条订单数据。订单状态包含"已完成"、“锻造中”、"待收货"三种,覆盖了订单生命周期的不同阶段。日期数据从 08-01 到 08-24,呈现出由远及近的时间分布,符合订单列表的时间排序习惯。

const SF_FAVORITES: SFFavorite[] = [
  { id: 1, name: '錾花复古戒', price: 399, oldPrice: 469 },
  { id: 2, name: '泰银做旧链', price: 489, oldPrice: 559 },
  // ... 共 6 条数据
]

SF_FAVORITES 常量存储了 6 条收藏商品数据。每条数据都包含现价和原价,且现价比原价低 50-100 元不等。这种价格差异营造了"收藏即享优惠"的营销氛围,是电商应用中常用的用户留存手段。

4.6 匠人、功能与辅助数据

const SF_ARTISANS: SFArtisan[] = [
  { id: 1, name: '顾银山', title: '錾刻大师', years: 28, works: 1860 },
  { id: 2, name: '苏晚晴', title: '珐琅工艺', years: 12, works: 640 },
  // ... 共 4 条数据
]

SF_ARTISANS 常量存储了 4 位驻馆匠人的数据。匠人的从业年限从 8 年到 28 年不等,作品数量从 480 件到 1860 件。这些数据共同塑造了一个经验丰富、技艺精湛的匠人团队形象,为手工银饰产品增添了人文价值和信任感。

const SF_FEATURES: SFFeature[] = [
  { icon: '◎', label: '定制银戒' },
  { icon: '≋', label: '链身修复' },
  // ... 共 8 项
]

SF_FEATURES 常量存储了 8 项工坊服务功能。每个功能用一个特殊的 Unicode 符号作为图标,配合简短的文字标签。这种设计在没有图片资源的情况下也能实现不错的视觉效果,体现了纯代码 UI 的创造力。

const SF_MATERIALS: string[] = ['925银', '999足银', '泰银']
const SF_CHAIN_TYPES: string[] = ['古巴链', '绳链', '盒链', '做旧链', '珠链']
const SF_CRAFTS: string[] = ['錾刻', '浮雕', '珐琅', '镂空', '做旧', '镶嵌']
const SF_SPARKS: number[] = [0, 1, 2, 3, 4, 5, 6]

最后是几个辅助数组。SF_MATERIALS 列出了所有银戒材质类型,用于筛选标签的渲染。SF_CHAIN_TYPES 列出了链型分类,用于链型分布图表的 X 轴。SF_CRAFTS 列出了工艺类型,用于工艺选择弹窗的宫格。SF_SPARKS 是一个 0 到 6 的数字数组,作为火花粒子动画的循环索引。

这些辅助数组虽然简单,但它们在数据驱动的 UI 构建中扮演着重要角色。通过将分类选项抽象为数组数据,我们可以用 ForEach 循环动态生成 UI 元素,而不是硬编码每一个选项。这种方式提高了代码的可维护性——当需要新增一个分类时,只需要在数组中添加一项即可。


五、辅助函数层:数据处理与动画计算

5.1 热销排序函数

function sfHotRings(): SFRing[] {
  let r: SFRing[] = SF_RINGS.slice()
  r.sort((a: SFRing, b: SFRing) => b.sales - a.sales)
  return r
}

sfHotRings 函数的作用是返回按销量降序排列的银戒数组。函数的实现虽然简单,但有几个值得注意的技术细节。

首先,函数第一行使用 SF_RINGS.slice() 创建了原数组的浅拷贝。这是一个非常重要的做法——因为 Array.sort() 方法会直接修改原数组(即"原地排序"),如果不先创建拷贝,排序操作会改变全局的 SF_RINGS 数据,导致其他依赖原顺序的功能出现异常。通过 slice() 创建拷贝后,排序操作只影响副本,原数据保持不变。

其次,排序比较函数 (a, b) => b.sales - a.sales 实现了降序排序。如果 b.sales 大于 a.sales,返回值为正,表示 b 应该排在 a 前面,这样销量高的商品就排在了前面。

这个函数在首页的热销榜和银戒页的圈口对比中都被调用,体现了函数复用的价值。

5.2 材质筛选函数

function sfFilterRings(mat: string): SFRing[] {
  let r: SFRing[] = []
  for (let i = 0; i < SF_RINGS.length; i++) {
    if (mat === '全部' || SF_RINGS[i].material === mat) {
      r.push(SF_RINGS[i])
    }
  }
  return r
}

sfFilterRings 函数根据材质筛选银戒商品。它接受一个材质参数 mat,返回匹配该材质的银戒数组。当参数为"全部"时,返回所有银戒。

这个函数使用传统的 for 循环而非 Array.filter() 方法来实现筛选。这种写法在性能上略有优势,因为避免了函数调用的开销。虽然对于只有十几条数据的场景来说性能差异可以忽略不计,但这种偏底层的写法体现了对执行效率的关注。

函数的逻辑很直观:初始化一个空数组,遍历所有银戒,如果材质匹配则加入结果数组,最后返回结果。"全部"选项的处理通过短路求值实现——mat === '全部' 为真时,|| 后面的条件不会执行,直接通过筛选。

5.3 银链排序与统计函数

function sfChainRank(): SFChain[] {
  let r: SFChain[] = SF_CHAINS.slice()
  r.sort((a: SFChain, b: SFChain) => b.sales - a.sales)
  return r
}

sfChainRank 函数与 sfHotRings 结构完全相同,只是操作对象从银戒变成了银链。这种模式上的重复暗示了可以进一步抽象——比如可以写一个通用的按销量排序函数,接受任意商品数组作为参数。但在当前的单文件应用中,保持各自独立的实现也有其优势,即代码的直观性和可读性更高。

function sfMaterialCount(mat: string): number {
  let r: number = 0
  for (let i = 0; i < SF_RINGS.length; i++) {
    if (SF_RINGS[i].material === mat) {
      r = r + 1
    }
  }
  return r
}

sfMaterialCount 函数统计指定材质的银戒数量。它遍历银戒数组,每遇到一个材质匹配的商品就将计数器加一。这个函数为银戒页的"在售材质分布"柱状图提供了数据支撑。

function sfChainTypeCount(ct: string): number {
  let r: number = 0
  for (let i = 0; i < SF_CHAINS.length; i++) {
    if (SF_CHAINS[i].chainType === ct) {
      r = r + 1
    }
  }
  return r
}

sfChainTypeCount 函数统计指定链型的银链数量,是银链页"链型分布"图表的数据来源。它的实现模式与 sfMaterialCount 完全一致。

function sfCraftCount(craft: string): number {
  let r: number = 0
  for (let i = 0; i < SF_PENDANTS.length; i++) {
    if (SF_PENDANTS[i].craft === craft) {
      r = r + 1
    }
  }
  return r
}

sfCraftCount 函数统计指定工艺的吊坠数量,为吊坠页的"工艺分布"图表提供数据。同样的模式,不同的数据源。

这三个统计函数虽然逻辑简单,但它们是数据可视化功能的核心纽带——将原始的商品数据转化为图表所需的统计数据。

5.4 火花动画计算函数

function sfSparkY(i: number, t: number): number {
  let phase: number = ((t * 6 + i * 31) % 70 + 70) % 70
  return 150 - phase * 2
}

sfSparkY 函数计算锤打火花粒子的 Y 坐标。这是一个纯函数——给定相同的输入总是返回相同的输出,没有任何副作用。纯函数是函数式编程的核心概念,在 UI 动画中特别有用,因为它们使得动画状态完全可预测。

函数接受两个参数:i 是粒子索引(0-6),t 是时间帧计数器。函数内部首先计算相位值 phase,然后根据相位计算 Y 坐标。

相位计算的公式 ((t * 6 + i * 31) % 70 + 70) % 70 看起来有点复杂,让我们拆解一下。t * 6 让时间每帧推进 6 个单位,控制动画速度。i * 31 为每个粒子设置不同的偏移量,确保 7 个粒子不会同步运动,而是错落有致。% 70 让相位在 0-69 之间循环。

为什么要写两次取模运算——(x % 70 + 70) % 70?这是为了处理负数的情况。JavaScript/ArkTS 中的取模运算在负数时结果可能为负,通过 +70 再取一次模可以确保结果始终为正。在本应用中,因为 t 从 0 开始递增,t * 6 + i * 31 始终为正,所以这个处理其实不是必需的。但这是一个很好的编程习惯——防御性编程,确保函数在各种输入下都能正确工作。

最后,150 - phase * 2 将相位转换为 Y 坐标。相位从 0 增加到 70 时,Y 坐标从 150 减少到 10,模拟火花向上飞溅的轨迹。

5.5 光泽旋转角度函数

function sfShine(t: number): number {
  let a: number = ((t % 120) + 120) % 120
  return a * 3
}

sfShine 函数计算会员卡光泽扫过效果的旋转角度。与火花函数类似,它也是一个纯函数,基于时间帧 t 计算当前的旋转角度。

相位在 0-119 之间循环(通过 % 120 实现),然后乘以 3 得到 0-357 度的旋转角度。之所以不直接用 360 度循环,是因为 120 * 3 = 360,刚好一圈,但循环取模的结果是 0-119,乘以 3 就是 0-357,差 3 度回到原点。这在视觉上几乎可以忽略不计,因为 357 度到 0 度的跳跃发生在一瞬间,人眼难以察觉。

这个函数为"我的"页面的会员卡光泽动画提供了角度数据。光泽效果是通过一个半透明白色的渐变矩形叠加在会员卡上,并持续旋转实现的,模拟出金属表面反光扫过的视觉效果。


六、主应用架构:入口组件与导航系统

6.1 入口组件的结构

@Entry
@Component
struct SilverForgeApp {
  @State activeTab: SFTab = SFTab.Home

SilverForgeApp 是整个应用的入口组件。它被 @Entry 装饰器标记,表示这是页面的根组件。@Component 装饰器表示这是一个自定义组件。结构体 SilverForgeApp 内部定义了组件的状态和 UI 结构。

组件只有一个状态变量 activeTab,类型为 SFTab 枚举,初始值为 SFTab.Home。这个状态变量记录了当前选中的底部标签页,是整个应用导航状态的唯一真相来源(Single Source of Truth)。

使用枚举类型而非字符串或数字来表示 Tab 状态,是一种优秀的工程实践。它提供了编译期的类型安全——如果你不小心写错了 Tab 名称,编译器会立即报错。同时,IDE 的自动补全功能也能更好地工作,输入 SFTab. 后会自动列出所有可用的 Tab 选项。

6.2 顶部导航栏的构建

Row() {
  Column() {
    Text('SILVER FORGE')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor(SF_COLORS.gold)
    Text('手工银饰锻造馆')
      .fontSize(10)
      .fontColor('#C9C1B0')
      .margin({ top: 2 })
  }
  .alignItems(HorizontalAlign.Start)

顶部导航栏的左侧是品牌 Logo 区域。它由两行文字组成:主标题 “SILVER FORGE” 使用金色粗体,副标题 “手工银饰锻造馆” 使用较小的浅灰色字体。两行文字垂直排列,左对齐。

这种品牌呈现方式简洁而有效。主标题用英文大写 + 金色,传达出高端品质感;副标题用中文小字,说明应用的业务性质。深浅对比和大小对比形成了清晰的视觉层级。

  Row() {
    Text('⌕')
      .fontSize(15)
      .fontColor('#8A8A8A')
    Text('搜索银戒 / 银链 / 吊坠')
      .fontSize(11)
      .fontColor('#8A8A8A')
      .margin({ left: 6 })
      .layoutWeight(1)
  }
  .layoutWeight(1)
  .height(30)
  .backgroundColor('#FFFFFF')
  .borderRadius(15)
  .padding({ left: 10, right: 10 })
  .margin({ left: 12, right: 12 })

导航栏中间是搜索框。这是一个纯展示性质的搜索入口,没有实际的搜索功能,但它在视觉上完善了电商应用的界面元素。搜索框由一个放大镜图标和占位文字组成,放在一个白色圆角矩形背景上。

.layoutWeight(1) 属性让搜索框占据剩余的全部空间,实现了弹性布局。.borderRadius(15) 将高度为 30 的矩形变成两端半圆的胶囊形状,这是移动端搜索框的经典样式。

  Text('◔')
    .fontSize(18)
    .fontColor('#EDE7DA')
    .margin({ right: 10 })
  Text('☰')
    .fontSize(16)
    .fontColor('#EDE7DA')
}
.width('100%')
.height(54)
.backgroundColor(SF_COLORS.primary)
.padding({ left: 14, right: 14 })
.alignItems(VerticalAlign.Center)

导航栏右侧有两个功能图标:消息图标(◔)和菜单图标(☰)。它们使用浅米色(#EDE7DA),在深灰色背景上保持良好的可见性,同时不会过于抢眼。

整个导航栏高度为 54px,背景色为主色调深灰色。.alignItems(VerticalAlign.Center) 确保所有子元素在垂直方向上居中对齐。

6.3 内容区域的条件渲染

Stack() {
  if (this.activeTab === SFTab.Home) {
    SFHomeTab()
  } else if (this.activeTab === SFTab.Ring) {
    SFRingTab()
  } else if (this.activeTab === SFTab.Chain) {
    SFChainTab()
  } else if (this.activeTab === SFTab.Pendant) {
    SFPendantTab()
  } else if (this.activeTab === SFTab.Tool) {
    SFToolTab()
  } else {
    SFMineTab()
  }
}
.layoutWeight(1)
.width('100%')

内容区域使用 Stack 组件作为容器,内部通过条件判断渲染当前选中的 Tab 组件。当 activeTab 状态变化时,对应的 Tab 组件会被渲染,其他 Tab 组件则被移除。

这里使用 Stack 而不是 Column 是有讲究的。Stack 是堆叠布局,子组件会堆叠在一起。虽然当前逻辑下只有一个子组件会被渲染,但使用 Stack 为将来的页面切换动画预留了可能性——如果需要实现 Tab 切换时的淡入淡出或滑动效果,Stack 布局可以让两个页面同时存在并执行过渡动画。

.layoutWeight(1) 让内容区域占据除了顶部导航栏和底部 Tab 栏之外的所有剩余空间。这是 ArkTS 中实现"撑满剩余空间"的标准做法。

6.4 底部 Tab 栏的实现

Row() {
  Column() {
    Text('⌂').fontSize(18).fontColor(this.activeTab === SFTab.Home ? SF_COLORS.gold : SF_COLORS.sub)
    Text('首页').fontSize(10).fontColor(this.activeTab === SFTab.Home ? SF_COLORS.gold : SF_COLORS.sub).margin({ top: 2 })
  }
  .layoutWeight(1)
  .onClick(() => { this.activeTab = SFTab.Home })
  // ... 其他 5 个 Tab 项
}
.width('100%')
.height(58)
.backgroundColor('#FFFFFF')
.alignItems(VerticalAlign.Center)
.border({ width: 1, color: SF_COLORS.line })

底部 Tab 栏使用 Row 布局,包含 6 个 Tab 项。每个 Tab 项是一个 Column,包含图标文字和标签文字,垂直排列。

每个 Tab 项的颜色通过三元表达式动态计算:如果当前是选中状态,则使用金色;否则使用次要灰色。这种基于状态的样式切换是声明式 UI 最典型的应用模式之一。状态一变,UI 自动更新,无需手动操作样式。

.layoutWeight(1) 均匀分配每个 Tab 项的宽度,确保 6 个 Tab 在各种屏幕尺寸下都能等宽排列。

.onClick() 事件处理函数修改 activeTab 状态,触发 UI 的重新渲染。由于声明式 UI 的特性,我们只需要关心"状态应该变成什么",而不需要关心"界面应该如何更新"——框架会自动处理状态变化到 UI 更新的映射。

整个底部 Tab 栏高度 58px,白色背景,顶部有一条细边框。这是移动端应用底部导航的标准设计规格。

6.5 整体布局结构

Column() {
  // 顶部导航栏
  Row() { ... }
  
  // 内容区域
  Stack() { ... }
    .layoutWeight(1)
  
  // 底部 Tab 栏
  Row() { ... }
}
.width('100%')
.height('100%')
.backgroundColor(SF_COLORS.bg)

从整体结构来看,应用采用经典的"顶部导航 + 中间内容 + 底部 Tab"三段式布局。外层是 Column,三个子元素垂直排列。中间的内容区域使用 .layoutWeight(1) 占据弹性空间,确保顶部和底部固定高度,中间自适应。

这种布局结构在移动端应用中极为常见,因为它符合用户的使用习惯——顶部放置品牌和全局操作,中间展示主要内容,底部放置导航切换。用户的拇指在屏幕底部可以轻松触及各个 Tab,符合移动端的交互热区设计原则。

最外层的 Column 设置了宽高为 100%,确保应用充满整个屏幕。背景色为暖米色,奠定了整体的视觉基调。


七、首页模块深度解析:锻造温度仪表盘与匠人工坊

7.1 组件状态与生命周期

@Component
struct SFHomeTab {
  @State sparkT: number = 0
  @State tempPct: number = 62
  animTimer: number = -1

  aboutToAppear(): void {
    this.animTimer = setInterval(() => {
      this.sparkT = this.sparkT + 1
      this.tempPct = 55 + Math.round(35 * Math.sin(this.sparkT / 9))
      if (this.tempPct < 18) {
        this.tempPct = 18
      }
    }, 60)
  }

首页组件 SFHomeTab 定义了两个状态变量和一个普通成员变量。sparkT 是火花动画的时间帧计数器,tempPct 是温度百分比(用于温度进度条),animTimer 是定时器的 ID,用于在组件销毁时清理定时器。

aboutToAppear() 是组件的生命周期回调函数,在组件即将出现时被调用。在这里,我们启动了一个 setInterval 定时器,每 60 毫秒执行一次回调。回调函数做两件事:一是将 sparkT 加一,推进动画帧;二是根据正弦函数计算新的温度百分比。

温度计算的公式 55 + Math.round(35 * Math.sin(this.sparkT / 9)) 值得深入分析。Math.sin() 返回 -1 到 1 之间的值,乘以 35 后范围是 -35 到 35,加上 55 后范围是 20 到 90。this.sparkT / 9 控制了正弦波动的频率——除数越大,波动越慢。

但代码中还有一个额外的判断:if (this.tempPct < 18) { this.tempPct = 18 }。这个下限保护看起来有些奇怪,因为根据正弦函数的计算结果,温度最低应该是 20(55-35),不会低于 18。这可能是一个防御性编程的处理,或者是早期版本遗留下来的代码。无论如何,它不会造成问题,只是多了一层保险。

定时器的间隔设为 60 毫秒,大约相当于每秒 16-17 帧。这个帧率对于简单的粒子动画来说已经足够流畅,同时不会消耗过多的系统资源。

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

aboutToDisappear() 是另一个生命周期回调,在组件即将销毁时被调用。在这里,我们清理定时器,防止内存泄漏。这是一个非常重要的最佳实践——凡是在 aboutToAppear 中启动的定时器、事件监听等资源,都应该在 aboutToDisappear 中对应地清理。

如果忘记清理定时器,会发生什么?当用户切换到其他 Tab 时,首页组件虽然不再渲染,但定时器仍然在后台运行,持续修改已经不存在的组件的状态。这不仅浪费 CPU 资源,还可能导致难以追踪的 Bug。因此,资源的成对管理(创建/销毁)是组件开发中必须遵守的纪律。

7.2 锻造温度仪表盘

Stack() {
  Column() {
    Row() {
      Text('🔥 锻造温度')
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.text)
      Column() {
        Text(`${960 + this.tempPct * 3} °C`)
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.gold)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')

锻造温度仪表盘是首页最具特色的模块之一。它将手工银饰的锻造工艺以可视化的方式呈现出来,增强了应用的主题氛围。

仪表盘的标题行使用 Row 布局,左侧是"🔥 锻造温度"标题,右侧是当前温度数值。温度数值通过模板字符串计算:960 + this.tempPct * 3。当 tempPct 从 20 波动到 90 时,温度从 960+60=1020°C 波动到 960+270=1230°C,这个范围正好覆盖了银的锻造温度区间(银的熔点约 961°C,锻造温度通常在熔点以上)。

温度数值使用金色大字号,成为视觉焦点,配合火焰 emoji 图标,直观传达了"正在锻造"的动态感。

    Column() {
      Column()
        .width(`${this.tempPct}%`)
        .height(12)
        .borderRadius(6)
        .linearGradient({
          angle: 90,
          colors: [['#C0392B', 0], ['#D4A017', 0.6], ['#F2D98D', 1]]
        })
        .opacity(0.55 + 0.45 * Math.abs(Math.sin(this.sparkT / 6)))
    }
    .width('100%')
    .height(12)
    .borderRadius(6)
    .backgroundColor('#E5DCC8')
    .margin({ top: 14 })

温度进度条是仪表盘的核心视觉元素。外层 Column 是进度条的轨道,底色为浅褐色。内层 Column 是进度条的填充部分,宽度随 tempPct 动态变化,模拟温度的波动。

填充部分使用了三色线性渐变:从红色(#C0392B)到金色(#D4A017)再到浅金色(#F2D98D),角度为 90 度(即从左到右)。这种渐变色模拟了金属加热时的颜色变化——温度较低时呈暗红色,温度升高后变成亮金色。

更巧妙的是,填充部分还添加了脉动透明度效果:.opacity(0.55 + 0.45 * Math.abs(Math.sin(this.sparkT / 6)))。透明度在 0.55 到 1.0 之间正弦波动,模拟了火焰的跳动感。Math.abs() 确保透明度始终为正,且波动更有节奏感(从 0.55 升到 1.0 再降回 0.55,而不是降到负值再回来)。

    Row() {
      Text('低温 960°C')
        .fontSize(10)
        .fontColor(SF_COLORS.sub)
      Column() {
        Text('锤打中 · 火花飞溅')
          .fontSize(10)
          .fontColor(SF_COLORS.gold)
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.End)
      .opacity(0.5 + 0.5 * Math.abs(Math.cos(this.sparkT / 7)))
      Text('高温 1250°C')
        .fontSize(10)
        .fontColor(SF_COLORS.sub)
        .margin({ left: 8 })
    }
    .width('100%')
    .margin({ top: 8 })

进度条下方是标注文字行。左侧是"低温 960°C",右侧是"高温 1250°C",标明了温度范围的上下限。中间靠右的位置是"锤打中 · 火花飞溅"的状态文字,使用金色。

这个状态文字也有动画效果——透明度通过余弦函数波动:0.5 + 0.5 * Math.abs(Math.cos(this.sparkT / 7))。与进度条使用正弦函数不同,这里使用余弦函数,使得两者的脉动节奏略有差异,增加了视觉的层次感。

7.3 锤打火花粒子动画

  ForEach(SF_SPARKS, (s: number) => {
    Circle({ width: 5, height: 5 })
      .fill(s % 2 === 0 ? SF_COLORS.gold : '#C0392B')
      .position({
        x: 30 + s * 42 + Math.sin(this.sparkT / 4 + s) * 12,
        y: sfSparkY(s, this.sparkT)
      })
      .opacity(0.9)
  }, (s: number) => `spark${s}-${this.sparkT}`)

火花粒子动画是首页最具动态效果的部分。它使用 ForEach 循环渲染 7 个圆形粒子,每个粒子的位置随时间变化,模拟锤打时火花飞溅的效果。

SF_SPARKS 是一个包含 0 到 6 的数字数组,作为循环的数据源。每个粒子用 Circle 组件绘制,直径 5px。

粒子的填充色交替使用金色和红色:偶数索引的粒子用金色,奇数索引的用红色。这种颜色交替让火花看起来更丰富自然——真实的火花本来就有金黄和红橙等不同颜色。

位置计算是动画的核心。X 坐标的公式是 30 + s * 42 + Math.sin(this.sparkT / 4 + s) * 1230 + s * 42 是每个粒子的基准 X 位置,粒子之间间距 42px。Math.sin(this.sparkT / 4 + s) * 12 是水平方向的正弦摆动,幅度 12px。每个粒子因为索引 s 不同,正弦相位也不同,所以 7 个粒子的水平运动是错位的,看起来更加自然。

Y 坐标通过 sfSparkY 函数计算,我们在前文已经分析过——粒子从下往上运动,到达顶部后循环回到底部,模拟火花飞溅上升然后消失的效果。

最后一个值得注意的细节是 ForEach 的 key 函数:(s: number) => \sparks−{s}-s{this.sparkT}`。key 中包含了 this.sparkT`,这意味着每一帧动画,所有粒子的 key 都会变化。在 ArkUI 框架中,key 变化会导致组件被完全销毁重建,而不是复用更新。

为什么要这样设计?因为粒子的位置变化幅度很大,如果复用组件,框架需要计算位置差异并应用过渡动画,可能导致性能开销更大。而直接重建组件,虽然听起来开销更大,但对于只有 7 个简单圆形粒子的场景来说,重建的成本其实很低,而且可以避免不必要的过渡动画干扰,让粒子运动更加干脆利落。

7.4 匠人横滑列表

Row() {
  Text('驻馆匠人')
    .fontSize(15)
    .fontWeight(FontWeight.Bold)
    .fontColor(SF_COLORS.text)
  Column() {
    Text('查看全部 ›')
      .fontSize(11)
      .fontColor(SF_COLORS.sub)
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.End)
}
.width('100%')
.margin({ top: 16, bottom: 10 })
.alignItems(VerticalAlign.Center)

匠人模块的标题行采用了经典的"主标题 + 更多链接"模式。左侧是加粗的"驻馆匠人"主标题,右侧是次要的"查看全部 ›"链接。这种布局在信息架构上非常清晰——用户一眼就能识别模块主题,同时知道可以查看更多内容。

Scroll() {
  Row() {
    ForEach(SF_ARTISANS, (a: SFArtisan) => {
      Column() {
        Column() {
          Text('⚒')
            .fontSize(24)
            .fontColor(SF_COLORS.gold)
        }
        .width(52)
        .height(52)
        .borderRadius(26)
        .backgroundColor(SF_COLORS.cream)
        .justifyContent(FlexAlign.Center)

        Text(a.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.text)
          .margin({ top: 8 })
        Text(a.title)
          .fontSize(11)
          .fontColor(SF_COLORS.gold)
          .margin({ top: 2 })
        Text(`${a.years} 年 · ${a.works} 件作品`)
          .fontSize(10)
          .fontColor(SF_COLORS.sub)
          .margin({ top: 4 })
      }
      .width(108)
      .padding({ top: 12, bottom: 12 })
      .backgroundColor(SF_COLORS.card)
      .borderRadius(12)
      .margin({ right: 10 })
      .alignItems(HorizontalAlign.Center)
    }, (a: SFArtisan) => a.id.toString())
  }
  .padding({ left: 2, right: 2 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')

匠人列表使用横向滚动的 Scroll 组件实现。内部是一个 Row,包含 4 个匠人卡片。每个卡片是一个 Column,包含头像、姓名、头衔、从业年限和作品数量。

头像部分是一个圆形(宽高 52px,圆角 26px)的奶油色背景,中间放一个金色的锤子图标(⚒)。由于没有真实的匠人照片,用图标代替头像是一种常见的设计处理。圆形的头像容器也是移动端设计的标准模式。

匠人卡片的信息分为三层:姓名用加粗深色字体(主要信息),头衔用金色字体(特色信息),年限和作品数量用浅灰色字体(次要信息)。三层信息通过字号、字重、颜色的差异形成清晰的视觉层级,用户可以快速扫描获取关键信息。

.scrollable(ScrollDirection.Horizontal) 设置滚动方向为水平。.scrollBar(BarState.Off) 隐藏滚动条,让界面更加干净。在移动端,用户自然知道可以左右滑动,隐藏滚动条不会影响可用性,反而提升了视觉整洁度。

7.5 银戒热销榜

Column() {
  ForEach(sfHotRings().slice(0, 5), (r: SFRing, idx: number) => {
    Row() {
      Text(`${idx + 1}`)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(idx < 3 ? '#FFFFFF' : SF_COLORS.sub)
        .width(24)
        .height(24)
        .borderRadius(12)
        .backgroundColor(idx < 3 ? SF_COLORS.gold : SF_COLORS.line)
        .textAlign(TextAlign.Center)

热销榜展示销量排名前 5 的银戒。sfHotRings().slice(0, 5) 先调用排序函数得到按销量降序的数组,然后取前 5 个。这是函数式编程中常用的链式调用写法——数据经过一系列变换后得到最终结果。

每个排行榜条目左侧是排名数字。前三名使用金色圆形背景 + 白色数字,从第四名开始使用浅灰圆形背景 + 灰色数字。这种差异化处理突出了前三名的荣誉地位,符合用户对排行榜的心理预期。

排名数字的容器是一个 24x24 的正方形,圆角设为 12(边长的一半),变成圆形。.textAlign(TextAlign.Center) 确保文字在水平方向居中。配合外层的 Row + alignItems(VerticalAlign.Center),实现了文字的完全居中。

      Column() {
        Text(r.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.text)
        Text(`${r.material} · ${r.size}#圈口 · ${r.weight}g`)
          .fontSize(10)
          .fontColor(SF_COLORS.sub)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

中间是商品信息区域,占满剩余空间(.layoutWeight(1))。商品名称是主要信息,用粗体深色。下方是材质、圈口、重量的规格说明,用小号灰色字体,中间用点号分隔。

这种"主标题 + 副标题"的信息展示模式在列表类 UI 中非常普遍。它的优势在于信息层次分明——用户先看到商品名产生兴趣,然后可以进一步查看规格细节。如果所有信息用同样的样式展示,用户反而需要花费更多精力来分辨信息的重要性。

      Column() {
        Text(`¥${r.price}`)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.gold)
        Text(`已售 ${r.sales}`)
          .margin({ top: 3 })
          .fontSize(10)
          .fontColor(SF_COLORS.sub)
      }
      .alignItems(HorizontalAlign.End)

右侧是价格和销量信息,右对齐。价格用金色加粗突出显示,这是电商应用的通用设计——价格永远是用户最关心的信息之一,需要第一时间抓住眼球。销量用灰色小字显示,作为辅助信息,增加商品的可信度(销量高的商品暗示品质有保障)。

7.6 工坊服务宫格

Grid() {
  ForEach(SF_FEATURES, (f: SFFeature) => {
    GridItem() {
      Column() {
        Text(f.icon)
          .fontSize(20)
          .fontColor(SF_COLORS.g)
        Text(f.label)
          .fontSize(11)
          .fontColor(SF_COLORS.text)
          .margin({ top: 6 })
      }
      .width('100%')
      .padding({ top: 12, bottom: 12 })
      .backgroundColor(SF_COLORS.card)
      .borderRadius(12)
      .justifyContent(FlexAlign.Center)
      .alignItems(HorizontalAlign.Center)
    }
  }, (f: SFFeature) => f.label)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsTemplate('1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.height(180)
.width('100%')
.margin({ bottom: 16 })

工坊服务功能宫格使用 Grid 组件实现。Grid 是 ArkUI 中用于网格布局的强大组件,相比手动用 Row + Column 嵌套实现网格,Grid 更加简洁高效。

.columnsTemplate('1fr 1fr 1fr 1fr') 定义了 4 列,每列宽度相等(1fr 表示一份弹性空间)。.rowsTemplate('1fr 1fr') 定义了 2 行,每行高度相等。8 个功能项正好填满 4x2 的网格。

.columnsGap(10).rowsGap(10) 设置列间距和行间距为 10px,与整体设计的间距系统保持一致。

每个网格项(GridItem)内部是一个 Column,包含图标和文字标签,垂直居中排列。图标使用金色(与主题强调色一致),文字使用深灰色。白色圆角卡片背景让每个功能项看起来独立而清晰。

使用 Grid 组件的优势不仅在于代码简洁,更在于它的性能优化。Grid 组件内置了虚拟滚动能力(虽然当前场景数据量小不需要),并且可以更高效地计算子组件的布局位置。对于更复杂的网格布局场景,Grid 的优势会更加明显。

7.7 首页滚动容器

Scroll() {
  Column() {
    // 锻造温度仪表盘
    // 匠人横滑
    // 热销榜
    // 功能宫格
  }
  .width('100%')
  .padding({ left: 14, right: 14, top: 14 })
  .alignItems(HorizontalAlign.Start)
}
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
.backgroundColor(SF_COLORS.bg)

首页的所有内容都包裹在一个 Scroll 组件中,确保内容超出屏幕高度时可以滚动查看。滚动条被隐藏(.scrollBar(BarState.Off)),保持界面的简洁。

内容容器 Column 设置了左右 14px、顶部 14px 的内边距。这是移动端内容区域的标准边距设置——既不会让内容太靠边显得拥挤,也不会浪费太多屏幕空间。

.alignItems(HorizontalAlign.Start) 确保内容左对齐,这是阅读类内容的标准对齐方式。


八、架构层次与页面导航流程图

8.1 整体架构层次图

为了更清晰地理解应用的架构设计,我们通过 Mermaid 流程图来可视化应用的层次结构和数据流向。

入口组件 SilverForgeApp

顶部导航栏

内容区域 Stack

底部 Tab 栏 Row

SFHomeTab 首页

SFRingTab 银戒

SFChainTab 银链

SFPendantTab 吊坠

SFToolTab 工具

SFMineTab 我的

锻造温度仪表盘

匠人横滑列表

银戒热销榜

工坊服务宫格

材质筛选栏

银戒双列 Grid

圈口对比条

材质柱状图

定制弹窗

银链排行榜

链型分布图

删除确认弹窗

吊坠色卡 Grid

工艺分布图

工艺选择弹窗

工具参数表

功率对比条

规格编辑弹窗

会员卡光泽动画

月消费柱状图

订单/收藏切换

银料档案弹窗

这张架构层次图展示了应用从入口组件到各子模块的完整层级关系。入口组件 SilverForgeApp 是根节点,下分顶部导航栏、内容区域、底部 Tab 栏三部分。内容区域根据当前选中的 Tab 动态渲染对应的子组件。每个 Tab 组件内部又包含若干功能子模块,形成清晰的树状结构。

这种层次化的组件划分遵循了单一职责原则——每个组件只负责一块特定的功能区域。当需要修改某个功能时,只需要找到对应的组件进行修改,不会影响其他部分的代码。这正是组件化开发的核心价值所在。

8.2 页面导航流程图

应用启动

点击银戒 Tab

点击银链 Tab

点击吊坠 Tab

点击工具 Tab

点击我的 Tab

点击首页 Tab

点击银链 Tab

点击吊坠 Tab

点击工具 Tab

点击我的 Tab

Home

向下滚动

向下滚动

向下滚动

温度仪表盘

匠人列表

热销榜

功能宫格

Ring

向下滚动

向下滚动

向下滚动

点击定制按钮

筛选栏

商品Grid

圈口对比

材质分布

定制弹窗

Chain

Pendant

Tool

Mine

向下滚动

向下滚动

切换标签

切换标签

点击会员卡

会员卡

月消费图

订单列表

收藏列表

档案弹窗

这张页面导航状态图展示了应用内部的页面流转关系。六个 Tab 页面之间可以通过底部导航栏自由切换,每个 Tab 内部又有自己的子状态和子页面。例如,银戒 Tab 内可以打开定制弹窗,我的 Tab 内可以在订单和收藏之间切换,也可以打开银料档案弹窗。

这种导航设计符合移动端应用的最佳实践——底部 Tab 提供一级导航,每个 Tab 内部通过滚动或弹窗提供二级内容。用户始终知道自己在哪个 Tab 下,也能快速切换到其他 Tab,导航的心智负担很低。

8.3 数据流向图

组件层

辅助函数层

数据层

SF_RINGS 银戒数据

SF_CHAINS 银链数据

SF_PENDANTS 吊坠数据

SF_TOOLS 工具数据

SF_ORDERS 订单数据

SF_FAVORITES 收藏数据

SF_ARTISANS 匠人数据

SF_MONTHLY 月度数据

SF_FEATURES 功能数据

sfHotRings 热销排序

sfFilterRings 材质筛选

sfChainRank 链销量排序

sfMaterialCount 材质统计

sfChainTypeCount 链型统计

sfCraftCount 工艺统计

sfSparkY 火花Y坐标

sfShine 光泽角度

SFHomeTab

SFRingTab

SFChainTab

SFPendantTab

SFToolTab

SFMineTab

这张数据流向图清晰地展示了数据在应用中的流动路径。原始数据(9 个数据常量)位于最底层,部分数据直接流入组件层用于渲染,部分数据先经过辅助函数的处理(排序、筛选、统计、动画计算)后再流入组件层。

这种分层设计的好处是显而易见的。数据层只负责存储原始数据,不涉及业务逻辑。辅助函数层负责数据的转换和计算,是纯函数,易于测试和复用。组件层负责 UI 渲染和用户交互,调用数据层和辅助函数层获取所需的数据。

这种架构模式与 MVC、MVVM 等经典架构思想一脉相承。数据层对应 Model,组件层对应 View,辅助函数则承担了部分 Controller/ViewModel 的职责。虽然在单文件应用中这些层没有物理上的分隔,但通过代码组织的规范,仍然实现了逻辑上的分层。


九、银戒模块深度解析:筛选、双列网格与定制弹窗

9.1 组件状态设计

@Component
struct SFRingTab {
  @State ringMat: string = '全部'
  @State showAdd: boolean = false
  @State addName: string = ''
  @State addMat: string = '925银'
  @State addWeight: string = ''

银戒页组件定义了 5 个状态变量。ringMat 记录当前选中的材质筛选条件,初始值为"全部"。showAdd 控制定制弹窗的显示/隐藏。addNameaddMataddWeight 分别记录定制弹窗中的名称、材质、克重输入值。

这些状态变量可以分为两类:一类是页面级状态(ringMatshowAdd),控制页面的整体显示逻辑;另一类是表单状态(addNameaddMataddWeight),只在弹窗打开时有意义。

在更复杂的应用中,可能会将表单状态抽取为独立的数据类或子组件。但在当前的场景下,由于表单字段较少(只有 3 个),直接放在组件中也是可以接受的。这是一个代码组织粒度的权衡问题——拆分过多会增加复杂度,不拆分又会让组件状态过多。开发者需要根据实际情况做出判断。

9.2 材质筛选栏

Row() {
  Text('全部')
    .fontSize(12)
    .fontColor(this.ringMat === '全部' ? '#FFFFFF' : SF_COLORS.text)
    .backgroundColor(this.ringMat === '全部' ? SF_COLORS.gold : SF_COLORS.card)
    .borderRadius(14)
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .onClick(() => { this.ringMat = '全部' })

  Text('925银')
    .fontSize(12)
    .fontColor(this.ringMat === '925银' ? '#FFFFFF' : SF_COLORS.text)
    .backgroundColor(this.ringMat === '925银' ? SF_COLORS.gold : SF_COLORS.card)
    .borderRadius(14)
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .margin({ left: 8 })
    .onClick(() => { this.ringMat = '925银' })
  // ... 999足银、泰银 两个选项

材质筛选栏由 4 个可选标签组成:全部、925银、999足银、泰银。每个标签都是一个 Text 组件,通过点击切换选中状态。

选中状态和未选中状态的样式差异明显:选中时金色背景 + 白色文字,未选中时白色背景 + 深色文字。这种高对比度的选中状态让用户一眼就能看出当前的筛选条件。

标签使用胶囊形状(圆角 14px,高度约 24px),这是筛选标签的经典样式。标签之间的间距为 8px,既不会太挤也不会太松。

不过,这段代码存在一定的重复——4 个标签的结构几乎完全一样,只有文字和点击处理函数不同。更好的做法是使用 SF_MATERIALS 数组配合 ForEach 循环来动态生成筛选标签,同时在数组开头加入"全部"选项。这样可以减少重复代码,当需要新增材质选项时也只需要修改数据。

为什么代码中没有这样做呢?可能是出于教学演示的考虑——硬编码的方式更直观,初学者更容易理解。也可能是因为"全部"选项的逻辑比较特殊,需要单独处理。无论如何,理解两种写法的优劣比简单判断对错更重要。

  Column() {
    Text('+ 定制')
      .fontSize(12)
      .fontWeight(FontWeight.Bold)
      .fontColor(SF_COLORS.primary)
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.End)
}
.width('100%')
.margin({ bottom: 12 })
.alignItems(VerticalAlign.Center)

筛选栏的最右侧是"+ 定制"按钮,使用深灰色粗体字。它占据了筛选栏右侧的剩余空间(.layoutWeight(1)),右对齐。这个按钮用于打开定制银戒的弹窗,我们将在后续章节详细分析。

9.3 双列商品网格

Grid() {
  ForEach(sfFilterRings(this.ringMat), (r: SFRing) => {
    GridItem() {
      Column() {
        Stack() {
          Column()
            .width('100%')
            .height(84)
            .borderRadius(10)
            .linearGradient({
              angle: 120,
              colors: [['#EDE7DA', 0], ['#C9CDD4', 1]]
            })
          Text(r.tag)
            .fontSize(10)
            .fontColor('#FFFFFF')
            .backgroundColor(SF_COLORS.gold)
            .borderRadius(8)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .position({ x: 8, y: 8 })
        }
        .width('100%')
        .height(84)

银戒商品列表使用双列 Grid 布局展示。sfFilterRings(this.ringMat) 根据当前选中的材质筛选商品,筛选结果作为 ForEach 的数据源。

每个商品卡片顶部是图片区域。由于没有真实图片,代码使用渐变色块来模拟商品图片。渐变色从米白色(#EDE7DA)过渡到银灰色(#C9CDD4),角度 120 度,营造出一种金属质感的视觉效果。

图片区域使用 Stack 布局,因为需要在图片的左上角叠加标签。标签使用金色背景 + 白色文字,通过 .position({ x: 8, y: 8 }) 定位在图片的左上角。这种绝对定位的方式在 Stack 布局中非常常见——父容器设置相对定位(Stack 默认为相对定位),子组件通过 position 属性设置相对于父容器的位置。

标签展示的是商品的 tag 字段,如"经典"、“热销”、"新品"等。这些标签是重要的营销元素,能够快速吸引用户的注意力,传递商品的特色定位。

        Text(r.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.text)
          .margin({ top: 8 })
          .width('100%')

        Text(`${r.material} · ${r.size}# · ${r.weight}g`)
          .fontSize(10)
          .fontColor(SF_COLORS.sub)
          .margin({ top: 3 })
          .width('100%')

        Row() {
          Text(`¥${r.price}`)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(SF_COLORS.gold)
          Text(`${r.sales}人购买`)
            .fontSize(10)
            .fontColor(SF_COLORS.sub)
        }
        .width('100%')
        .margin({ top: 6, bottom: 8 })
        .alignItems(VerticalAlign.Center)

图片下方是商品信息区域,分为三行:商品名称、规格信息、价格和销量。

商品名称使用 13px 粗体深色,是卡片中最主要的文字信息。.width('100%') 确保文字占满卡片宽度,当文字较长时会自动换行。

规格信息使用 10px 浅灰色,格式为"材质 · 圈口 · 重量",中间用点号分隔。这行信息提供了商品的关键参数,帮助用户快速判断是否符合需求。

底部是价格和销量的横向排列。价格使用 14px 金色粗体,是最醒目的元素。销量使用 10px 浅灰色,作为辅助信息。两者垂直居中对齐,保持视觉平衡。

      }
      .width('100%')
      .backgroundColor(SF_COLORS.card)
      .borderRadius(12)
      .padding(10)
      .alignItems(HorizontalAlign.Start)
    }
  }, (r: SFRing) => `${this.ringMat}-${r.id}`)
}
.columnsTemplate('1fr 1fr')
.rowsTemplate('1fr 1fr 1fr 1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.height(1060)
.width('100%')

Grid 的配置定义了双列布局(.columnsTemplate('1fr 1fr'))和 5 行布局(.rowsTemplate('1fr 1fr 1fr 1fr 1fr'))。列间距和行间距都是 10px。

这里有一个值得讨论的设计:Grid 的高度被硬编码为 1060px。这是因为当筛选条件为"全部"时,有 10 个商品,正好填满 5 行。但如果筛选后商品数量减少(比如"泰银"只有 3 款),Grid 的高度仍然是 1060px,底部就会出现大片空白。

更好的做法是让 Grid 的高度自适应内容,或者在外层使用 Scroll 时不限制 Grid 的高度。但在当前实现中,由于整个页面内容都在 Scroll 内部,Grid 的固定高度可能是为了确保页面布局的稳定性。这是一个设计权衡——固定高度换来布局可预测性,代价是在数据量少时会有空白。

ForEach 的 key 函数使用了 ${this.ringMat}-${r.id} 的组合。将筛选条件加入 key 中,意味着当筛选条件变化时,所有商品项的 key 都会改变,导致 Grid 完全重建。这可以确保筛选切换时不会出现旧数据残留的问题,但代价是失去了过渡动画的可能性。

9.4 圈口对比条

Column() {
  ForEach(sfHotRings().slice(0, 4), (r: SFRing) => {
    Row() {
      Text(r.name)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .width(80)
      Column() {
        Column()
          .width(`${Math.round(r.size / 22 * 100)}%`)
          .height(8)
          .borderRadius(4)
          .backgroundColor(SF_COLORS.gold)
      }
      .layoutWeight(1)
      .height(8)
      .borderRadius(4)
      .backgroundColor(SF_COLORS.line)

      Text(`${r.size}#`)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.gold)
        .width(34)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .margin({ bottom: 10 })
    .alignItems(VerticalAlign.Center)
  }, (r: SFRing) => `size-${r.id}`)
}
.width('100%')
.padding(12)
.backgroundColor(SF_COLORS.card)
.borderRadius(12)

圈口对比模块以水平进度条的形式,直观展示了热销前 4 名银戒的圈口大小差异。数据来源是 sfHotRings().slice(0, 4)——取销量最高的 4 款银戒。

每一行的结构是"商品名称 + 进度条 + 圈口数值"。名称固定宽度 80px,右对齐的圈口数值固定宽度 34px,中间的进度条占据剩余空间。

进度条的填充宽度通过 Math.round(r.size / 22 * 100) 计算。这里假设最大圈口为 22 号(港度圈口的常见范围约为 8-24 号),将实际圈口换算为百分比。例如,14 号圈口的进度为 14/22 ≈ 64%。

这种可视化方式让用户一眼就能比较不同戒指的圈口大小,比单纯看数字更加直观。对于选购戒指的用户来说,圈口是一个非常关键的参数,这种对比展示方式很有实用价值。

9.5 材质分布柱状图

Row() {
  ForEach(SF_MATERIALS, (m: string) => {
    Column() {
      Text(`${sfMaterialCount(m)}`)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.gold)
        .margin({ bottom: 6 })
      Column()
        .width(34)
        .height(sfMaterialCount(m) * 16)
        .borderRadius({ topLeft: 6, topRight: 6 })
        .linearGradient({
          angle: 180,
          colors: [['#D4A017', 0], ['#8C7A5B', 1]]
        })
      Text(m)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .margin({ top: 6 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.End)
  }, (m: string) => m)
}
.width('100%')
.height(150)
.padding(12)
.backgroundColor(SF_COLORS.card)
.borderRadius(12)
.alignItems(VerticalAlign.Bottom)
.margin({ bottom: 16 })

材质分布柱状图用垂直柱状图展示了不同材质的银戒款式数量。这是一个典型的数据可视化组件,用纯代码实现了柱状图的基本功能。

每根柱子由三部分组成:顶部的数量文字、中间的柱体、底部的材质标签。柱体宽度固定为 34px,高度为 sfMaterialCount(m) * 16——每一款对应 16px 的高度。例如,925银有 5 款,柱子高度就是 80px。

柱体使用从上到下的金色渐变(从 #D4A017#8C7A5B),顶部圆角(.borderRadius({ topLeft: 6, topRight: 6 })),模拟了真实柱状图的视觉效果。

外层 Row.alignItems(VerticalAlign.Bottom) 确保所有柱子从底部对齐,这是柱状图的标准对齐方式。每根柱子所在的 Column 设置 .justifyContent(FlexAlign.End),配合外层的底部对齐,让柱子从下往上"生长"。

这种纯代码实现的柱状图虽然功能简单,但对于数据量不大的场景来说已经足够。它的优势在于无需引入额外的图表库,减少了应用体积,同时可以完全自定义样式,与整体设计风格保持一致。

9.6 定制弹窗的构建

@Builder modalOverlay() {
  Column() {
    Column() {
      Text('新增定制银戒')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.text)
        .width('100%')

      Text('定制名称')
        .fontSize(12)
        .fontColor(SF_COLORS.sub)
        .width('100%')
        .margin({ top: 14 })
      TextInput({ placeholder: '如: forged 素圈戒', text: this.addName })
        .fontSize(13)
        .height(38)
        .backgroundColor(SF_COLORS.cream)
        .borderRadius(8)
        .margin({ top: 6 })
        .onChange((v: string) => { this.addName = v })

定制弹窗使用 @Builder 装饰器定义为一个构建函数。@Builder 是 ArkTS 中的一个重要特性,它允许你将可复用的 UI 片段封装为函数,在 build() 方法中调用。这是一种轻量级的 UI 复用机制,相比抽取为独立组件更加简单直接。

弹窗的标题是"新增定制银戒",使用 16px 加粗深色字体。下方是表单内容,第一项是定制名称的输入框。

TextInput 是 ArkUI 中的文本输入组件。placeholder 参数设置占位提示文字,text 参数绑定输入框的当前值(即 this.addName 状态变量)。.onChange() 回调在输入内容变化时触发,将新值更新到状态变量中。

输入框的样式设计:高度 38px,奶油色背景,圆角 8px,字体大小 13px。这种设计简洁干净,与整体的复古暖调风格保持一致。

      Text('选择材质')
        .fontSize(12)
        .fontColor(SF_COLORS.sub)
        .width('100%')
        .margin({ top: 14 })
      Row() {
        Text('925银')
          .fontSize(12)
          .fontColor(this.addMat === '925银' ? '#FFFFFF' : SF_COLORS.text)
          .backgroundColor(this.addMat === '925银' ? SF_COLORS.gold : SF_COLORS.cream)
          .borderRadius(8)
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .onClick(() => { this.addMat = '925银' })
        // ... 999足银、泰银 两个选项
      }
      .width('100%')
      .margin({ top: 6 })

材质选择使用横向排列的三个选项按钮。选中状态为金色背景 + 白色文字,未选中状态为奶油色背景 + 深色文字。点击时切换选中状态。

这种选项按钮(也叫 segmented control 或 chip)是表单中常见的选择控件。它比下拉选择更加直观——所有选项都可见,用户一眼就能看到有哪些可选值,点击即可切换,操作步骤更少。

      Text('预估克重(g)')
        .fontSize(12)
        .fontColor(SF_COLORS.sub)
        .width('100%')
        .margin({ top: 14 })
      TextInput({ placeholder: '如: 5.5', text: this.addWeight })
        .fontSize(13)
        .height(38)
        .backgroundColor(SF_COLORS.cream)
        .borderRadius(8)
        .margin({ top: 6 })
        .type(InputType.Number)
        .onChange((v: string) => { this.addWeight = v })

克重输入框与名称输入框类似,但额外设置了 .type(InputType.Number),表示这是数字输入框。系统会根据输入类型自动调整弹出的键盘类型——数字输入会弹出数字键盘而非全键盘,提升输入效率。

      Text(`预估工费: ¥${(Number(this.addWeight) * 12 + 80).toFixed(0)}(银料另计)`)
        .fontSize(12)
        .fontColor(SF_COLORS.gold)
        .width('100%')
        .margin({ top: 12 })

这是一个实时计算的工费预估显示。计算公式是 克重 × 12 + 80Number(this.addWeight) 将字符串转换为数字进行计算,.toFixed(0) 将结果格式化为整数(不保留小数)。

这种实时计算反馈是优秀的用户体验设计。用户在输入克重的同时就能看到预估的工费,不需要提交后才知道价格,减少了用户的心理负担和操作往返。

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(SF_COLORS.text)
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.zebra)
          .borderRadius(20)
          .onClick(() => { this.showAdd = false })
        Text('提交定制')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.gold)
          .borderRadius(20)
          .margin({ left: 12 })
          .onClick(() => { this.showAdd = false })
      }
      .width('100%')
      .margin({ top: 18 })
      .alignItems(VerticalAlign.Center)

弹窗底部是两个操作按钮:取消和提交定制。按钮高度 40px,圆角 20px(胶囊形状),两个按钮平分宽度(各占 layoutWeight(1))。

"取消"按钮使用斑马纹色(浅米色)背景 + 深色文字,"提交定制"按钮使用金色背景 + 白色粗体文字。两个按钮在视觉权重上有明显差异——提交按钮更醒目,引导用户点击。

不过,当前的"提交定制"按钮只是关闭弹窗,并没有真正提交数据。在真实应用中,这里应该添加表单验证和数据提交逻辑。但作为演示应用,关闭弹窗的简单实现已经足够展示交互流程。

9.7 弹窗遮罩层的实现

    }
    .width('86%')
    .padding(20)
    .backgroundColor(SF_COLORS.card)
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .alignItems(HorizontalAlign.Center)
  .backgroundColor('rgba(58,54,48,0.55)')
  .position({ x: 0, y: 0 })
  .zIndex(999)
  .onClick(() => { this.showAdd = false })

弹窗的遮罩层(半透明黑色背景)是弹窗体验的重要组成部分。外层 Column 占满整个屏幕,背景色为半透明深棕色(rgba(58,54,48,0.55)),透明度 55%。

.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center) 让内部的弹窗卡片在屏幕正居中显示。

.position({ x: 0, y: 0 }).zIndex(999) 确保遮罩层覆盖在所有其他内容之上。.zIndex(999) 设置了一个足够大的层级值,保证弹窗不会被其他 UI 元素遮挡。

遮罩层也绑定了 .onClick() 事件,点击遮罩层时关闭弹窗。这是弹窗交互的标准设计——用户可以通过点击弹窗外的区域来关闭弹窗,提供了额外的操作便捷性。

弹窗卡片的宽度设为屏幕宽度的 86%,左右各留 7% 的边距。这种宽度比例在移动端弹窗设计中比较常见——既不会太宽显得压抑,也不会太窄显得局促。


十、银链模块深度解析:排行榜与删除确认

10.1 组件状态设计

@Component
struct SFChainTab {
  @State showDel: boolean = false
  @State selChain: SFChain | null = null

银链页组件只有两个状态变量:showDel 控制删除确认弹窗的显示,selChain 记录当前选中的银链(用于删除确认弹窗中显示商品名称)。

selChain 的类型是 SFChain | null,这是 TypeScript/ArkTS 中常见的可空类型用法。初始值为 null,表示没有选中任何银链。当用户点击某条银链时,selChain 被赋值为对应的银链对象,同时弹窗显示。

使用可空类型而非单独的布尔状态来控制弹窗,是一种更简洁的状态设计思路——如果 selChain 不为空,说明有选中项,弹窗应该显示;如果为空,弹窗隐藏。这样可以用一个状态变量同时表达"是否显示"和"显示什么"两个信息。不过当前代码中仍然保留了单独的 showDel 变量,可能是出于代码可读性的考虑。

10.2 银链热销排行榜

Column() {
  ForEach(sfChainRank(), (c: SFChain, idx: number) => {
    Row() {
      Text(`${idx + 1}`)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(idx < 3 ? '#FFFFFF' : SF_COLORS.sub)
        .width(22)
        .height(22)
        .borderRadius(11)
        .backgroundColor(idx === 0 ? SF_COLORS.danger : (idx === 1 ? SF_COLORS.gold : (idx === 2 ? '#8C7A5B' : SF_COLORS.line)))
        .textAlign(TextAlign.Center)

银链排行榜与首页的银戒热销榜类似,但有几个明显的差异。首先是排名徽章的颜色设计——第一名使用危险红(SF_COLORS.danger),第二名使用金色,第三名使用古铜色(#8C7A5B),第四名及以后使用浅灰色。

这种金银铜三色的排名设计借鉴了体育比赛的奖牌概念,让排名的荣誉感更强。第一名使用红色而非金色,可能是为了让第一名更加醒目突出——红色在视觉上的冲击力比金色更强。

排名徽章的尺寸是 22x22px,比银戒热销榜的 24x24px 略小一些。这是因为银链排行榜的信息密度更高,需要在有限的空间内容纳更多元素。

      Column() {
        Text(c.name)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.text)
        Row() {
          Column() {
            Column()
              .width(`${Math.round(c.length / 7)}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(SF_COLORS.gold)
          }
          .layoutWeight(1)
          .height(6)
          .borderRadius(3)
          .backgroundColor(SF_COLORS.line)

          Text(`${c.length}mm`)
            .fontSize(10)
            .fontColor(SF_COLORS.sub)
            .margin({ left: 6 })
        }
        .width('100%')
        .margin({ top: 5 })
        .alignItems(VerticalAlign.Center)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })

中间的商品信息区域包含商品名称和长度进度条。长度进度条是银链排行榜的特色元素——它用可视化的方式展示了每条银链的长度差异。

进度条的填充宽度计算公式是 Math.round(c.length / 7)。为什么除以 7?因为进度条是百分比宽度,而银链长度在 450-600mm 之间。如果直接用长度作为百分比,会超出 100%。除以 7 后,600mm 的长度约为 85.7%,450mm 约为 64.3%,都在合理范围内。

长度进度条下方右侧显示具体的长度数值(单位 mm),用小号灰色文字。这样的设计既提供了直观的视觉对比(进度条),又提供了精确的数值信息(文字),两者互为补充。

      Column() {
        Text(`¥${c.price}`)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.gold)
        Text(`已售 ${c.sales}`)
          .fontSize(10)
          .fontColor(SF_COLORS.sub)
          .margin({ top: 3 })
        Text('删除')
          .fontSize(10)
          .fontColor(SF_COLORS.danger)
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.End)

右侧的价格区域包含价格、销量和删除按钮。"删除"文字使用危险红色,明确传达了操作的危险性。点击整行会触发删除确认弹窗。

与银戒热销榜相比,银链排行榜增加了删除操作,这意味着银链页不仅是展示页面,还包含了数据管理的功能。这种展示 + 管理的混合模式在后台管理类应用中比较常见。

    }
    .width('100%')
    .padding(12)
    .backgroundColor(SF_COLORS.card)
    .borderRadius(12)
    .margin({ bottom: 8 })
    .alignItems(VerticalAlign.Center)
    .onClick(() => {
      this.selChain = c
      this.showDel = true
    })

整行的点击事件设置了当前选中的银链并显示删除确认弹窗。这里的交互设计有一个细节值得思考——点击整行触发删除操作,而不是专门的删除按钮。这种设计虽然操作方便(点击区域大),但也存在误触的风险——用户可能只是想查看详情,结果触发了删除操作。

在真实应用中,更安全的做法可能是使用左滑删除、长按删除,或者只让删除按钮响应删除操作。但在演示应用中,点击整行的简化实现可以接受。

10.3 链型分布图表

Column() {
  ForEach(SF_CHAIN_TYPES, (ct: string) => {
    Row() {
      Text(ct)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .width(56)
      Column() {
        Column()
          .width(`${sfChainTypeCount(ct) * 20}%`)
          .height(8)
          .borderRadius(4)
          .backgroundColor(SF_COLORS.primary)
      }
      .layoutWeight(1)
      .height(8)
      .borderRadius(4)
      .backgroundColor(SF_COLORS.line)
      Text(`${sfChainTypeCount(ct)}`)
        .fontSize(10)
        .fontColor(SF_COLORS.sub)
        .width(34)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .margin({ bottom: 10 })
    .alignItems(VerticalAlign.Center)
  }, (ct: string) => ct)
}
.width('100%')
.padding(12)
.backgroundColor(SF_COLORS.card)
.borderRadius(12)
.margin({ bottom: 16 })

链型分布图与银戒页的材质分布图类似,但使用的是水平进度条而非垂直柱状图。每种链型占一行,左侧是链型名称,中间是进度条,右侧是款式数量。

进度条的填充宽度为 sfChainTypeCount(ct) * 20 + '%'。每种款式对应 20% 的进度宽度。例如,有 1 款的链型进度为 20%,有 2 款的链型进度为 40%。由于总共有 5 种链型,最多的链型也只有 2 款,所以最大进度是 40%,不会占满整个进度条。

与材质柱状图使用金色不同,链型分布图的进度条使用主色调深灰色(SF_COLORS.primary)。这种颜色差异让不同页面的图表各有特色,增加了视觉的丰富性。

10.4 删除确认弹窗

@Builder modalOverlay() {
  Column() {
    Column() {
      Text('!')
        .fontSize(26)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .width(52)
        .height(52)
        .borderRadius(26)
        .backgroundColor(SF_COLORS.danger)
        .textAlign(TextAlign.Center)

      Text('确认删除该银链?')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.danger)
        .margin({ top: 14 })

删除确认弹窗的设计与定制弹窗有明显的风格差异。定制弹窗是中性的表单风格,而删除确认弹窗则带有强烈的警示风格——红色圆形感叹号图标、红色标题文字。

顶部的红色圆形图标是视觉焦点。直径 52px 的圆形,纯白色感叹号,红色背景。这个图标立即传达了"危险操作"的信号,让用户在继续操作前产生警觉。

标题文字"确认删除该银链?"使用红色加粗字体,进一步强化了警示意味。这种颜色编码(红色 = 危险/删除)是 UI 设计中的通用语言,用户几乎不需要思考就能理解操作的性质。

      Text(`${this.selChain!.name}」将从在售列表中移除,已下单订单不受影响。此操作不可撤销,请谨慎确认。`)
        .fontSize(12)
        .fontColor(SF_COLORS.sub)
        .textAlign(TextAlign.Center)
        .margin({ top: 10 })
        .lineHeight(18)

提示文字详细说明了删除操作的后果:商品从在售列表移除,但已下单的订单不受影响。同时特别强调"此操作不可撤销,请谨慎确认",提醒用户慎重操作。

这段文字使用了 .lineHeight(18) 设置行高,增加了多行文字的可读性。行高与字号的比例约为 1.5(18/12),这是阅读体验最佳的行高比例范围。

this.selChain!.name 中的 ! 是非空断言操作符。它告诉编译器"我确定 selChain 此时不为空,请不要报错"。这是因为弹窗只在 showDel 为真时显示,而 showDel 为真时 selChain 一定已经被赋值。虽然 TypeScript/ArkTS 的类型系统无法自动推断这种逻辑关系,但开发者可以通过非空断言来绕过类型检查。当然,使用时需要谨慎,确保运行时确实不会为空。

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(SF_COLORS.text)
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.zebra)
          .borderRadius(20)
          .onClick(() => { this.showDel = false })
        Text('确认删除')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.danger)
          .borderRadius(20)
          .margin({ left: 12 })
          .onClick(() => { this.showDel = false })
      }

底部的两个操作按钮风格也与定制弹窗不同。确认按钮使用红色背景(SF_COLORS.danger)而非金色,与删除操作的危险语义保持一致。

在交互设计中,破坏性操作的确认按钮通常使用红色或橙色,这已经成为一种行业惯例。用户看到红色按钮时会自然地更加谨慎,从而减少误操作的概率。

弹窗卡片还额外设置了红色边框(.border({ width: 1, color: '#F0C4B8' })),进一步强化了警告的视觉暗示。这是一个细节之处的设计——边框的颜色比背景红色更浅更柔和,既增加了层次感,又不会过于刺眼。


十一、吊坠模块深度解析:色卡展示与工艺选择

11.1 组件状态设计

@Component
struct SFPendantTab {
  @State showCraft: boolean = false
  @State selCraft: string = '錾刻'
  @State selPendant: SFPendant | null = null

吊坠页组件有三个状态变量:showCraft 控制工艺选择弹窗的显示,selCraft 记录当前选中的工艺,selPendant 记录当前选中的吊坠。

与银链页的删除确认类似,吊坠页的弹窗也涉及"选中哪个商品"和"选择什么选项"两个维度的状态。selPendant 记录用户点击的是哪个吊坠,用于弹窗标题中显示商品名称;selCraft 记录用户在弹窗中选择的工艺,初始值为"錾刻"。

这种状态拆分是合理的——商品选择和工艺选择是两个独立的维度,分开管理让状态变更更加清晰。如果用一个复合对象来同时存储商品和工艺,反而会增加状态更新的复杂度。

11.2 吊坠色卡三列网格

Grid() {
  ForEach(SF_PENDANTS, (p: SFPendant) => {
    GridItem() {
      Column() {
        Column()
          .width('100%')
          .height(62)
          .borderRadius(10)
          .backgroundColor(p.colorHex)

        Text(p.name)
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.text)
          .margin({ top: 6 })
          .maxLines(1)

        Text(`¥${p.price} · ${p.weight}g`)
          .fontSize(10)
          .fontColor(SF_COLORS.gold)
          .margin({ top: 3, bottom: 6 })
      }
      .width('100%')
      .backgroundColor(SF_COLORS.card)
      .borderRadius(12)
      .padding(8)
      .alignItems(HorizontalAlign.Start)
      .onClick(() => {
        this.selPendant = p
        this.selCraft = p.craft
        this.showCraft = true
      })
    }
  }, (p: SFPendant) => p.id.toString())
}
.columnsTemplate('1fr 1fr 1fr')
.rowsTemplate('1fr 1fr 1fr')
.columnsGap(10)
.rowsGap(10)
.height(470)
.width('100%')

吊坠页最具特色的是色卡展示方式。每个吊坠卡片顶部不是商品图片,而是一个用吊坠自身颜色填充的色块。色块的颜色直接来自数据中的 colorHex 字段,这是一种非常巧妙的数据驱动设计。

色卡高度为 62px,比银戒卡片的图片区域(84px)更矮一些,因为三列布局的单卡宽度更窄,保持宽高比例协调。圆角 10px,与整体设计语言一致。

色卡下方是吊坠名称和价格信息。名称使用 11px 粗体,设置了 .maxLines(1) 限制只显示一行,防止名称过长时撑破布局。价格和重量信息使用金色 10px 字体,一行展示。

.columnsTemplate('1fr 1fr 1fr') 设置为三列布局。8 款吊坠正好排 3 列,前两排各 3 个,第三排 2 个(虽然模板定义了 3 行,但实际数据只有 8 个,第三排只有前两列有内容)。

点击任意吊坠卡片会触发三个动作:设置选中的吊坠、将选中的工艺初始化为该吊坠的默认工艺、显示工艺选择弹窗。这种交互方式——点击商品后弹出工艺选择——相当于将"选择工艺"作为商品详情的一个操作步骤,而不是直接在列表中展示所有工艺选项。

11.3 工艺分布图表

Column() {
  ForEach(SF_CRAFTS, (c: string) => {
    Row() {
      Text(c)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .width(44)
      Column() {
        Column()
          .width(`${sfCraftCount(c) * 25}%`)
          .height(8)
          .borderRadius(4)
          .backgroundColor(SF_COLORS.gold)
      }
      .layoutWeight(1)
      .height(8)
      .borderRadius(4)
      .backgroundColor(SF_COLORS.line)
      Text(`${sfCraftCount(c)}`)
        .fontSize(10)
        .fontColor(SF_COLORS.sub)
        .width(34)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .margin({ bottom: 10 })
    .alignItems(VerticalAlign.Center)
  }, (c: string) => c)
}
.width('100%')
.padding(12)
.backgroundColor(SF_COLORS.card)
.borderRadius(12)
.margin({ bottom: 16 })

工艺分布图与链型分布图的结构完全相同,都是水平进度条列表。不同之处在于:工艺名称的标签宽度更窄(44px vs 56px),因为工艺名称都是两个字,比较短;进度条的填充宽度计算系数不同(* 25 vs * 20)。

为什么系数不同?因为数据分布不同。链型最多只有 2 款,而工艺最多有 2 款(数据中各工艺的数量都是 1 或 2)。系数设为 25 意味着 2 款对应 50% 的进度宽度,这样进度条不会太长也不会太短,视觉上比较平衡。

进度条使用金色填充,与吊坠页的整体色调更加协调。不同页面的图表使用不同颜色的进度条,既保持了各自的特色,又不会造成混乱——因为每个图表都有明确的标题说明其含义。

11.4 工艺选择弹窗

@Builder modalOverlay() {
  Column() {
    Column() {
      Text('选择工艺')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.text)
        .width('100%')

      Text(`为「${this.selPendant!.name}」更换工艺方案`)
        .fontSize(11)
        .fontColor(SF_COLORS.sub)
        .width('100%')
        .margin({ top: 6 })

工艺选择弹窗的标题为"选择工艺",副标题说明是为某个具体吊坠更换工艺方案。副标题使用了模板字符串,动态插入选中吊坠的名称,让用户清楚知道当前操作的对象。

这种"为 X 做 Y"的文案写法是一种好的 UX 实践。它通过明确操作对象,减少用户的认知负担,避免用户在多步操作中忘记上下文。

      Grid() {
        ForEach(SF_CRAFTS, (c: string) => {
          GridItem() {
            Column() {
              Text('⚒')
                .fontSize(20)
                .fontColor(this.selCraft === c ? SF_COLORS.gold : SF_COLORS.sub)
              Text(c)
                .fontSize(12)
                .fontColor(this.selCraft === c ? SF_COLORS.gold : SF_COLORS.text)
                .margin({ top: 6 })
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .borderRadius(10)
            .backgroundColor(this.selCraft === c ? '#FBF3DC' : SF_COLORS.cream)
            .border({
              width: this.selCraft === c ? 2 : 1,
              color: this.selCraft === c ? SF_COLORS.gold : SF_COLORS.line
            })
            .justifyContent(FlexAlign.Center)
            .alignItems(HorizontalAlign.Center)
            .onClick(() => { this.selCraft = c })
          }
        }, (c: string) => `${c}-${this.selCraft}`)
      }
      .columnsTemplate('1fr 1fr 1fr')
      .rowsTemplate('1fr 1fr')
      .columnsGap(10)
      .rowsGap(10)
      .height(150)
      .width('100%')
      .margin({ top: 14 })

工艺选择的核心是一个 3x2 的宫格布局,展示 6 种工艺选项。每个工艺选项由锤子图标和工艺名称组成,垂直居中排列。

选中状态与未选中状态的差异体现在三个维度:图标颜色(金色 vs 灰色)、文字颜色(金色 vs 深色)、背景色(浅金色 vs 奶油色)、边框宽度(2px vs 1px)、边框颜色(金色 vs 浅灰)。多重维度的差异让选中状态非常醒目,用户一眼就能看出当前选择。

未选中状态的边框只有 1px,颜色为浅灰色,几乎与背景融为一体。选中状态的边框加粗到 2px,颜色变为金色,同时背景色变成更浅的金色,形成"凸起来"的视觉效果。

.columnsTemplate('1fr 1fr 1fr').rowsTemplate('1fr 1fr') 定义了三列两行的网格。6 种工艺正好填满 6 个格子。

ForEach 的 key 函数使用 ${c}-${this.selCraft},将工艺名称和当前选中值组合在一起。这意味着当 selCraft 变化时,所有工艺项的 key 都会变化,导致整个 Grid 重建。这种做法确保了选中状态切换时的视觉一致性,但同样牺牲了过渡动画的可能性。

      Text(`当前选择: ${this.selCraft}(工时 +${(this.selCraft === '錾刻' ? 3 : 2)} 天)`)
        .fontSize(12)
        .fontColor(SF_COLORS.gold)
        .width('100%')
        .margin({ top: 14 })

宫格下方是当前选择的提示文字,显示当前选中的工艺以及对应的工时期。錾刻工艺需要额外 3 天工时,其他工艺需要额外 2 天。

这种实时反馈让用户知道选择不同工艺对交付时间的影响,帮助用户做出更明智的决策。在定制类产品中,工时和价格是两个最重要的决策因素,明确展示这些信息可以减少后续的沟通成本。

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(SF_COLORS.text)
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.zebra)
          .borderRadius(20)
          .onClick(() => { this.showCraft = false })
        Text('确认工艺')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.gold)
          .borderRadius(20)
          .margin({ left: 12 })
          .onClick(() => { this.showCraft = false })
      }

底部按钮与定制弹窗的按钮风格一致——取消按钮是浅底深色,确认按钮是金色底白色。这是应用中正向操作按钮的标准样式,保持了交互语言的一致性。

交互一致性是 UI 设计中的重要原则。用户在一个地方学会了"金色按钮 = 确认/提交",在其他地方遇到同样的按钮时就会自然地产生相同的预期。这种一致性降低了用户的学习成本,提升了使用体验。


十二、工具模块深度解析:参数表格与规格编辑

12.1 组件状态设计

@Component
struct SFToolTab {
  @State showEdit: boolean = false
  @State selTool: SFTool | null = null
  @State editPower: number = 300
  @State editWeight: number = 10

工具页组件有四个状态变量:showEdit 控制编辑弹窗的显示,selTool 记录当前选中的工具,editPowereditWeight 分别记录编辑中的功率和重量值。

与之前的弹窗不同,工具编辑弹窗有两个数值型的编辑字段(功率和重量),而且这两个字段是通过滑块(Slider)而非输入框来编辑的。这意味着状态变量的类型是 number 而非 string

初始值 editPower: 300editWeight: 10 只是占位值,当用户点击某一行工具时,这两个值会被更新为对应工具的实际参数。

12.2 参数表格的构建

Column() {
  // 表头
  Row() {
    Text('设备名称')
      .fontSize(11)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .layoutWeight(2.2)
    Text('功率W')
      .fontSize(11)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .layoutWeight(1)
      .textAlign(TextAlign.Center)
    Text('净重kg')
      .fontSize(11)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .layoutWeight(1)
      .textAlign(TextAlign.Center)
    Text('精度mm')
      .fontSize(11)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .layoutWeight(1)
      .textAlign(TextAlign.Center)
    Text('价格')
      .fontSize(11)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .layoutWeight(1.2)
      .textAlign(TextAlign.End)
  }
  .width('100%')
  .padding({ left: 10, right: 10, top: 10, bottom: 10 })
  .backgroundColor(SF_COLORS.primary)
  .borderRadius({ topLeft: 10, topRight: 10 })
  .alignItems(VerticalAlign.Center)

参数表格的表头使用深灰色背景 + 白色粗体文字。五列内容分别是:设备名称、功率、净重、精度、价格。每列通过 .layoutWeight() 设置不同的宽度比例:设备名称占 2.2 份(最宽,因为名称文字较长),功率、净重、精度各占 1 份,价格占 1.2 份。

文本对齐方式也各有不同:设备名称左对齐(阅读习惯),功率、净重、精度居中对齐(数字列居中更整齐),价格右对齐(金额列右对齐是财务表格的惯例)。这种差异化的对齐方式是表格设计的专业细节。

表头顶部圆角(.borderRadius({ topLeft: 10, topRight: 10 })),与下方的表格行组成一个整体圆角矩形。

  ForEach(SF_TOOLS, (t: SFTool, i: number) => {
    Row() {
      Text(t.name)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .layoutWeight(2.2)
      Text(`${t.power}`)
        .fontSize(11)
        .fontColor(SF_COLORS.gold)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      Text(`${t.weight}`)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      Text(`${t.precision}`)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .layoutWeight(1)
        .textAlign(TextAlign.Center)
      Text(`¥${t.price}`)
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.text)
        .layoutWeight(1.2)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .padding({ left: 10, right: 10, top: 11, bottom: 11 })
    .backgroundColor(i % 2 === 0 ? SF_COLORS.zebra : SF_COLORS.card)
    .alignItems(VerticalAlign.Center)
    .onClick(() => {
      this.selTool = t
      this.editPower = t.power
      this.editWeight = Math.round(t.weight)
      this.showEdit = true
    })
  }, (t: SFTool, i: number) => `${i}-${t.id}`)

表格数据行使用 ForEach 循环渲染。每行的列宽比例、对齐方式与表头严格对应,确保列与列之间对齐整齐。

斑马纹效果通过 i % 2 === 0 判断实现——偶数行(第 0、2、4 行)使用斑马纹底色(浅米色),奇数行使用白色背景。斑马纹是数据表格中提升可读性的经典手法,它帮助用户的视线在横向移动时保持在同一行,减少串行的概率。

功率列使用金色文字,突出显示。这可能是因为功率是工具最重要的参数指标,或者是为了与功率对比图表中的金色进度条保持视觉呼应。

点击任意一行会触发三个状态更新:设置当前选中的工具对象、将编辑功率初始化为该工具的功率、将编辑重量初始化为该工具的重量(取整),然后显示编辑弹窗。

重量取整(Math.round(t.weight))是因为滑块的步长设为 1,只能取整数值。而原始数据中的重量可能有小数(如 12.5kg、0.8kg),所以需要四舍五入。这是一个数据适配的细节——UI 控件的输入能力与原始数据格式不完全匹配时,需要做适当的转换。

  Column() {
    Text('点击任意行可编辑设备规格参数')
      .fontSize(10)
      .fontColor(SF_COLORS.sub)
  }
  .width('100%')
  .padding({ top: 10, bottom: 10 })
  .backgroundColor(SF_COLORS.card)
  .borderRadius({ bottomLeft: 10, bottomRight: 10 })
  .alignItems(HorizontalAlign.Center)
}

表格底部有一行提示文字:“点击任意行可编辑设备规格参数”。这行提示非常重要——因为"可点击编辑"的交互不是显而易见的,如果没有提示,用户可能不会意识到表格是可交互的。

在 UX 设计中,可发现性(Discoverability)是一个重要原则——用户应该能够通过界面本身发现可以进行哪些操作,而不需要依赖外部说明。对于不那么直观的交互,提供明确的文字提示是一种简单有效的解决方案。

12.3 功率对比图表

Column() {
  ForEach(SF_TOOLS, (t: SFTool) => {
    Row() {
      Text(t.name)
        .fontSize(11)
        .fontColor(SF_COLORS.text)
        .width(80)
      Column() {
        Column()
          .width(`${Math.round(t.power / 9)}%`)
          .height(8)
          .borderRadius(4)
          .backgroundColor(t.power > 400 ? SF_COLORS.danger : SF_COLORS.gold)
      }
      .layoutWeight(1)
      .height(8)
      .borderRadius(4)
      .backgroundColor(SF_COLORS.line)
      Text(`${t.power}W`)
        .fontSize(10)
        .fontColor(SF_COLORS.sub)
        .width(44)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .margin({ bottom: 10 })
    .alignItems(VerticalAlign.Center)
  }, (t: SFTool) => `p-${t.id}`)
}

功率对比图与之前的长度对比图、材质分布图形式类似,都是水平进度条。但功率对比图有一个独特的设计:进度条的颜色根据功率大小变化——功率大于 400W 的用红色,否则用金色。

.backgroundColor(t.power > 400 ? SF_COLORS.danger : SF_COLORS.gold) 这行代码实现了颜色的条件渲染。这种基于数据阈值的颜色变化是数据可视化中的常用技巧——用颜色编码额外的信息维度。在这里,红色表示大功率设备(可能更危险或更耗能),金色表示常规功率设备。

进度条的宽度计算公式是 Math.round(t.power / 9)。最大的功率是 850W,除以 9 约为 94%,接近满格但不会超出。最小的功率是 15W,除以 9 约为 1.7%,取整后为 2%,虽然很短但仍然可见。

设备名称列的宽度固定为 80px,功率数值列固定为 44px,中间进度条占据剩余空间。这种三栏布局(名称 + 进度条 + 数值)是数据对比图表的经典结构。

12.4 规格编辑弹窗

@Builder modalOverlay() {
  Column() {
    Column() {
      Text('编辑设备规格')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(SF_COLORS.text)
        .width('100%')

      Text(`设备: ${this.selTool!.name}`)
        .fontSize(12)
        .fontColor(SF_COLORS.sub)
        .width('100%')
        .margin({ top: 6 })

编辑弹窗的标题是"编辑设备规格",副标题显示当前编辑的设备名称。与其他弹窗一样,明确显示操作对象,减少用户的困惑。

      Row() {
        Text('输出功率')
          .fontSize(12)
          .fontColor(SF_COLORS.text)
        Column() {
          Text(`${this.editPower} W`)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(SF_COLORS.gold)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(VerticalAlign.Center)

      Slider({
        value: this.editPower,
        min: 15,
        min: 15,
        max: 900,
        step: 5,
        style: SliderStyle.OutSet
      })
        .blockColor(SF_COLORS.gold)
        .selectedColor(SF_COLORS.gold)
        .trackColor(SF_COLORS.line)
        .width('100%')
        .margin({ top: 4 })
        .onChange((v: number) => { this.editPower = Math.round(v) })

功率编辑使用 Slider 滑块组件。滑块的参数包括:当前值 value、最小值 min(15W)、最大值 max(900W)、步长 step(5W)、样式 style(OutSet 突出式滑块)。

滑块的样式定制包括三个部分:.blockColor() 设置滑块按钮的颜色(金色),.selectedColor() 设置已选中部分轨道的颜色(金色),.trackColor() 设置未选中部分轨道的颜色(浅灰色)。

滑块上方显示当前的功率数值,右对齐,使用金色粗体。这种"数值 + 滑块"的组合让用户既能精确看到数值,又能通过滑动快速调整。

.onChange() 回调在滑块值变化时触发,将新值四舍五入后更新到 editPower 状态。虽然滑块的 step 已经设为 5,但回调中再次取整是一种双重保险,确保状态值始终是整数。

      Row() {
        Text('整机净重')
          .fontSize(12)
          .fontColor(SF_COLORS.text)
        Column() {
          Text(`${this.editWeight} kg`)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(SF_COLORS.primary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .margin({ top: 12 })
      .alignItems(VerticalAlign.Center)

      Slider({
        value: this.editWeight,
        min: 1,
        max: 40,
        step: 1,
        style: SliderStyle.OutSet
      })
        .blockColor(SF_COLORS.primary)
        .selectedColor(SF_COLORS.primary)
        .trackColor(SF_COLORS.line)
        .width('100%')
        .margin({ top: 4 })
        .onChange((v: number) => { this.editWeight = Math.round(v) })

重量滑块与功率滑块结构相同,但颜色使用深灰色(主色调)而非金色。这种颜色差异让两个滑块在视觉上更容易区分——金色对应功率,深灰色对应重量。

重量的取值范围是 1-40kg,步长为 1kg。这个范围覆盖了从小型工具(不到 1kg)到大型设备(15kg 以上)的各种重量。不过,最小值设为 1kg 可能不太准确——数据中有 0.4kg 和 0.8kg 的工具。但作为演示应用,这个小的 inaccuracy 可以接受。

      Text(`当前精度: ${this.selTool!.precision} mm · 维持不变`)
        .fontSize(11)
        .fontColor(SF_COLORS.sub)
        .width('100%')
        .margin({ top: 10 })

精度参数显示为"维持不变",说明精度不是当前编辑的对象。为什么精度不可编辑?可能是因为精度是设备的固有属性,由机械结构决定,不能简单地通过滑块调整。也可能是演示应用为了简化,只编辑两个参数。

在 UI 设计中,明确告知用户"什么不能改"和"什么可以改"同样重要。如果用户以为精度也能改,但找不到编辑入口,就会产生困惑。用一行文字明确说明"维持不变",可以消除这种困惑。

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(SF_COLORS.text)
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.zebra)
          .borderRadius(20)
          .onClick(() => { this.showEdit = false })
        Text('保存规格')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .height(40)
          .textAlign(TextAlign.Center)
          .backgroundColor(SF_COLORS.primary)
          .borderRadius(20)
          .margin({ left: 12 })
          .onClick(() => { this.showEdit = false })
      }

底部按钮与其他弹窗类似,但确认按钮使用了主色调深灰色而非金色。这可能是因为工具页的主题色更偏向工业灰,用深灰色确认按钮更符合页面的整体调性。

不同页面的确认按钮颜色略有差异,这是一种微妙的设计选择。它既保持了"深色按钮 = 确认操作"的一致性(金色和深灰色都是深色/强调色),又让每个页面有自己的特色。当然,如果过度差异化可能会破坏一致性,需要把握好度。


十三、状态管理与组件依赖图

13.1 状态管理状态图

初始状态

首页状态

每60ms触发

页面切换

页面切回

动画运行

动画停止

银戒状态

点击925银

点击999足银

点击泰银

点击全部

点击全部

点击全部

点击定制

点击定制

取消/提交

显示全部

筛选925银

筛选999足银

筛选泰银

定制弹窗

银链状态

点击某行

取消/确认删除

排行展示

删除弹窗

吊坠状态

点击某吊坠

切换工艺

切换工艺

取消/确认

取消/确认

色卡展示

工艺弹窗

工艺选择中

工具状态

点击某行

拖动滑块

拖动滑块

取消/保存

表格展示

编辑弹窗

编辑中

我的状态

默认

切换标签

切换标签

点击会员卡

关闭

光泽动画运行

订单视图

收藏视图

档案弹窗

这张状态管理图详细展示了每个 Tab 页面内部的状态流转。每个页面都有自己的状态机——首页有动画运行/停止的状态,银戒页有不同的筛选状态,银链页有删除确认状态,吊坠页有工艺选择状态,工具页有编辑状态,我的页面有订单/收藏切换状态和档案弹窗状态。

这种细粒度的状态划分是声明式 UI 开发的核心思维方式。开发者需要清楚地定义组件可能处于的所有状态,以及状态之间的转换条件和触发动作。当状态定义清晰后,UI 的渲染逻辑就变得非常直接——每个状态对应一个确定的 UI 表现。

13.2 弹窗交互流程图

工艺选择流程

删除确认流程

定制弹窗流程

输入名称

选择材质

输入克重

点击取消

点击提交

点击遮罩

点击取消

点击确认删除

点击遮罩

点击工艺选项

点击取消

点击确认

点击遮罩

规格编辑流程

拖动功率滑块

拖动重量滑块

点击取消

点击保存

点击遮罩

点击工具行

selTool = 当前行
editPower = 当前功率
editWeight = 当前重量
showEdit = true

渲染滑块弹窗

用户操作

editPower 更新

editWeight 更新

showEdit = false

点击定制按钮

showAdd = true

渲染遮罩层 + 表单

用户操作

addName 更新

showDel = false

selCraft 更新
选中样式变化

实时计算工费

showAdd = false

点击银链行

selChain = 当前行
showDel = true

渲染红色警告弹窗

用户操作

点击吊坠色卡

selPendant = 当前吊坠
selCraft = 默认工艺
showCraft = true

渲染宫格弹窗

用户操作

更新工时提示

showCraft = false

这张弹窗交互流程图详细展示了四种弹窗的完整交互流程。每种弹窗都遵循相似的模式:用户触发 → 状态变更 → 弹窗渲染 → 用户交互 → 状态更新 → 弹窗关闭。

虽然模式相似,但每种弹窗的具体交互细节各有不同。定制弹窗涉及表单输入和实时计算,删除确认弹窗是警示性的二次确认,工艺选择弹窗涉及多选项切换,规格编辑弹窗涉及滑块拖动调整。

这些弹窗都遵循了一致的设计规范:半透明遮罩层、居中卡片、底部两个操作按钮、点击遮罩可关闭。这种一致性让用户在不同弹窗间切换时,不需要重新学习交互方式,降低了认知负担。

13.3 组件依赖关系图

函数依赖

数据依赖

SilverForgeApp 入口组件

SFHomeTab

SFRingTab

SFChainTab

SFPendantTab

SFToolTab

SFMineTab

温度仪表盘

火花粒子动画

匠人横滑

热销榜

功能宫格

筛选栏

双列商品Grid

圈口对比条

材质柱状图

定制弹窗 Builder

排行榜

链型分布

删除弹窗 Builder

色卡Grid

工艺分布

工艺弹窗 Builder

参数表格

功率对比图

编辑弹窗 Builder

会员卡 + 光泽动画

月消费柱状图

订单/收藏切换

档案弹窗 Builder

SF_RINGS

SF_CHAINS

SF_PENDANTS

SF_TOOLS

SF_ARTISANS

SF_FEATURES

SF_ORDERS

SF_FAVORITES

SF_MONTHLY

sfHotRings

sfFilterRings

sfMaterialCount

sfChainRank

sfChainTypeCount

sfCraftCount

sfSparkY

sfShine

这张组件依赖关系图从三个维度展示了应用的结构:组件层级、数据依赖、函数依赖。上层是组件树结构,左下是数据常量到组件的依赖关系,右下是辅助函数到组件的依赖关系。

从图中可以清晰地看到哪些组件依赖哪些数据和函数。例如,银戒热销榜依赖 SF_RINGS 数据和 sfHotRings 函数;材质柱状图依赖 SF_RINGS 数据和 sfMaterialCount 函数。

理解组件的依赖关系对于代码维护和重构非常重要。当你需要修改某个数据结构时,可以通过依赖关系快速找到所有受影响的组件。当你需要优化性能时,可以分析哪些数据变化会触发哪些组件的重渲染,从而有针对性地进行优化。


十四、我的模块深度解析:会员卡动画与个人中心

14.1 组件状态与生命周期

@Component
struct SFMineTab {
  @State showDetail: boolean = false
  @State mineView: number = 0
  @State shineT: number = 0
  @State shineX: number = -80
  shineTimer: number = -1

  aboutToAppear(): void {
    this.shineTimer = setInterval(() => {
      this.shineT = this.shineT + 1
      this.shineX = ((this.shineT * 11) % 460) - 90
    }, 50)
  }

"我的"页面组件定义了四个状态变量和一个定时器变量。showDetail 控制银料档案弹窗的显示,mineView 控制订单/收藏视图的切换(0 表示订单,1 表示收藏),shineT 是光泽动画的时间帧,shineX 是光泽效果的 X 坐标。

aboutToAppear() 生命周期函数中启动了一个定时器,每 50 毫秒执行一次。这个频率比首页的火花动画(60ms)略快,因为光泽扫过的效果需要更流畅的动画才能显得自然。

光泽 X 坐标的计算公式是 ((this.shineT * 11) % 460) - 90。让我们拆解一下:this.shineT * 11 让每帧推进 11 个像素,控制光泽移动的速度。% 460 让 X 坐标在 0-459 之间循环。- 90 让坐标范围变成 -90 到 369,这样光泽可以从会员卡的左侧外进入,从右侧滑出后再从左侧重新进入。

为什么范围是 460?这大约是会员卡的宽度(屏幕宽度的 92% 左右,减去左右内边距)。光泽扫过整个会员卡宽度后循环,形成连续不断的反光效果。

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

与首页一样,aboutToDisappear() 中清理定时器,防止内存泄漏。这是所有包含定时器动画的组件都必须遵守的规范。

14.2 会员卡与光泽动画

Stack() {
  Column() {
    Row() {
      Text('⚒ SILVER FORGE')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor('#F2D98D')
      Column() {
        Text('GOLD')
          .fontSize(10)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3A3630')
          .backgroundColor('#F2D98D')
          .borderRadius(6)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

会员卡是"我的"页面最醒目的元素。它使用深色渐变背景,配合金色文字,营造出高端会员卡的质感。

会员卡顶部是品牌标识和会员等级。左侧是"⚒ SILVER FORGE"的品牌名,使用浅金色。右侧是"GOLD"等级徽章,使用金色背景 + 深色文字,圆角矩形,非常像真实的会员等级标签。

    Text('银匠世家 · 金卡会员')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .width('100%')
      .margin({ top: 12 })

会员称号"银匠世家 · 金卡会员"使用 18px 白色粗体,是会员卡上最醒目的文字。它明确告知用户的会员身份和等级,给用户一种荣誉感。

    Row() {
      Column() {
        Text('2680')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F2D98D')
        Text('匠造积分')
          .fontSize(10)
          .fontColor('#D9D2C2')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)

      Column() {
        Text('6')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F2D98D')
        Text('定制订单')
          .fontSize(10)
          .fontColor('#D9D2C2')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)
      .margin({ left: 30 })

      Column() {
        Text('6')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#F2D98D')
        Text('心仪藏品')
          .fontSize(10)
          .fontColor('#D9D2C2')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.Center)
      .margin({ left: 30 })

      Column() {
        Text('档案 ›')
          .fontSize(11)
          .fontColor('#F2D98D')
          .backgroundColor('rgba(255,255,255,0.15)')
          .borderRadius(12)
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .margin({ top: 16 })
    .alignItems(VerticalAlign.Center)

会员卡的数据区域展示了三项核心数据:匠造积分(2680)、定制订单(6)、心仪藏品(6)。每项数据都是"大数字 + 小标签"的结构,数字用金色粗体,标签用浅灰色小字。

三项数据之间用 30px 的左边距分隔,排列在会员卡的左侧。最右侧是"档案 ›"按钮,占据剩余空间,右对齐。按钮使用半透明白色背景 + 金色文字,在深色卡面上形成层次感。

这种数据展示方式在会员中心类应用中非常典型。它将用户最关心的几个数据指标一目了然地展示出来,同时提供了进入详情的入口。

  }
  .width('100%')
  .padding(18)
  .borderRadius(16)
  .linearGradient({
    angle: 135,
    colors: [['#5A5A5A', 0], ['#3A3630', 0.55], ['#8C7A5B', 1]]
  })
  .alignItems(HorizontalAlign.Start)

会员卡的背景使用三色线性渐变:从银灰色(#5A5A5A)到深棕灰(#3A3630)再到古铜金(#8C7A5B),角度 135 度(从左上到右下)。

这种渐变设计非常有质感——银灰代表银饰的金属本色,深棕灰代表工业复古的厚重感,古铜金代表金的贵气。三色渐变让会员卡不再是单调的纯色块,而是有了光影变化和材质感。

  // 光泽扫过(旋转)
  Column()
    .width(44)
    .height(300)
    .linearGradient({
      angle: 100,
      colors: [['#00FFFFFF', 0], ['#80FFFFFF', 0.5], ['#00FFFFFF', 1]]
    })
    .rotate({ angle: sfShine(this.shineT) })
    .position({ x: this.shineX, y: -40 })
    .opacity(0.5)
}
.width('100%')
.onClick(() => { this.showDetail = true })

光泽效果是会员卡的点睛之笔。它是一个细长的矩形条(宽 44px,高 300px),使用从透明到半透明白色再到透明的渐变。这个矩形条叠加在会员卡上,通过位置和旋转的持续变化,模拟出金属表面反光扫过的效果。

.rotate({ angle: sfShine(this.shineT) }) 设置光泽条的旋转角度,角度由 sfShine 函数计算,随时间在 0-357 度之间循环。.position({ x: this.shineX, y: -40 }) 设置光泽条的位置,X 坐标随时间从左向右移动。

透明度设为 0.5,让光泽效果不会太刺眼,而是若隐若现,增加了高级感。.opacity(0.5) 与渐变本身的透明度(中间 50% 不透明,两端透明)叠加后,实际的最大不透明度是 25% 左右,非常柔和。

整个会员卡可以点击,点击后打开银料档案弹窗。这是一个隐藏的交互入口——用户可能不会立刻发现会员卡可以点击,但一旦发现,会有一种"彩蛋"般的惊喜感。

14.3 月消费柱状图

Row() {
  ForEach(SF_MONTHLY, (m: SFMonthly) => {
    Column() {
      Text(`${m.amount}`)
        .fontSize(9)
        .fontColor(SF_COLORS.gold)
        .margin({ bottom: 4 })
      Column()
        .width(26)
        .height(Math.round(m.amount / 26))
        .borderRadius({ topLeft: 5, topRight: 5 })
        .linearGradient({
          angle: 180,
          colors: [['#D4A017', 0], ['#8C7A5B', 1]]
        })
      Text(m.month)
        .fontSize(10)
        .fontColor(SF_COLORS.sub)
        .margin({ top: 5 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.End)
  }, (m: SFMonthly) => m.month)
}
.width('100%')
.height(150)
.padding(12)
.backgroundColor(SF_COLORS.card)
.borderRadius(12)
.alignItems(VerticalAlign.Bottom)

月消费趋势柱状图与银戒页的材质分布柱状图结构类似,但有几个细节差异。

首先,柱子顶部显示具体金额数字(9px 金色),而材质柱状图的数量显示在柱子顶部。其次,柱子更窄(26px vs 34px),因为有 6 根柱子而不是 3 根,需要更窄的宽度才能在一行内容纳。第三,底部标签是月份而非材质名称。

柱子高度的计算公式是 Math.round(m.amount / 26)。金额范围从 980 到 2560,除以 26 后高度从约 38px 到约 98px。整个图表区域高度 150px,减去顶部数字和底部标签的空间,柱子的高度范围正好合适。

柱状图的整体高度固定为 150px,底部对齐(.alignItems(VerticalAlign.Bottom))。每根柱子所在的 Column 使用 .justifyContent(FlexAlign.End),确保柱子从底部向上生长。

14.4 订单/收藏切换视图

Row() {
  Text('我的订单')
    .fontSize(14)
    .fontWeight(FontWeight.Bold)
    .fontColor(this.mineView === 0 ? SF_COLORS.gold : SF_COLORS.sub)
    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
    .backgroundColor(this.mineView === 0 ? '#FBF3DC' : SF_COLORS.card)
    .borderRadius(16)
    .onClick(() => { this.mineView = 0 })
  Text('心仪收藏')
    .fontSize(14)
    .fontWeight(FontWeight.Bold)
    .fontColor(this.mineView === 1 ? SF_COLORS.gold : SF_COLORS.sub)
    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
    .backgroundColor(this.mineView === 1 ? '#FBF3DC' : SF_COLORS.card)
    .borderRadius(16)
    .margin({ left: 10 })
    .onClick(() => { this.mineView = 1 })
}
.width('100%')
.margin({ top: 16, bottom: 10 })

订单/收藏切换使用分段控件(Segmented Control)的形式。两个选项按钮横向排列,选中状态为浅金色背景 + 金色文字,未选中状态为白色背景 + 灰色文字。

这种切换方式是移动端应用中非常经典的视图切换模式。它的优势在于:所有选项都可见,用户清楚知道有哪些视图可以切换;切换操作只需一次点击,非常便捷;选中状态的视觉反馈明确,用户始终知道自己当前在哪个视图下。

按钮的圆角设为 16px,高度约为 32px(上下各 8px padding + 文字高度约 16px),形成胶囊形状。选中状态的背景色 #FBF3DC 是非常浅的金色,几乎接近白色,但与纯白色的未选中状态相比仍有微妙的差异。

if (this.mineView === 0) {
  Column() {
    ForEach(SF_ORDERS, (o: SFOrder) => {
      Row() {
        Column() {
          Text(o.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(SF_COLORS.text)
          Text(`${o.date} 下单`)
            .fontSize(10)
            .fontColor(SF_COLORS.sub)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Column() {
          Text(o.status)
            .fontSize(10)
            .fontColor(o.status === '已完成' ? '#6B8E5A' : SF_COLORS.gold)
            .backgroundColor(o.status === '已完成' ? '#EAF2E4' : '#FBF3DC')
            .borderRadius(8)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        }
        .alignItems(HorizontalAlign.End)
        .margin({ right: 10 })

        Text(`¥${o.price}`)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.text)
      }
      .width('100%')
      .padding({ top: 10, bottom: 10, left: 12, right: 12 })
      .backgroundColor(SF_COLORS.card)
      .borderRadius(10)
      .margin({ bottom: 8 })
      .alignItems(VerticalAlign.Center)
    }, (o: SFOrder) => o.id.toString())
  }
  .width('100%')
}

订单视图展示用户的订单列表。每个订单项从左到右依次是:商品名称 + 下单日期、订单状态标签、订单金额。

订单状态标签的颜色根据状态动态变化:"已完成"状态使用绿色(#6B8E5A 文字 + #EAF2E4 背景),其他状态(锻造中、待收货)使用金色(金色文字 + 浅金背景)。

颜色编码是状态标签的核心设计。不同的颜色代表不同的状态含义:绿色表示完成/成功,金色表示进行中/待处理。用户通过颜色就能快速识别订单状态,而不需要仔细阅读文字。

} else {
  Column() {
    ForEach(SF_FAVORITES, (f: SFFavorite) => {
      Row() {
        Column() {
          Text(f.name)
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(SF_COLORS.text)
          Row() {
            Text(`¥${f.oldPrice}`)
              .fontSize(10)
              .fontColor(SF_COLORS.sub)
              .decoration({ type: TextDecorationType.LineThrough })
            Text('收藏价')
              .fontSize(10)
              .fontColor(SF_COLORS.gold)
              .margin({ left: 8 })
          }
          .margin({ top: 3 })
          .alignItems(VerticalAlign.Center)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Column() {
          Text('♥')
            .fontSize(16)
            .fontColor(SF_COLORS.danger)
        }
        .alignItems(HorizontalAlign.End)
        .margin({ right: 10 })

        Text(`¥${f.price}`)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.gold)
      }
      // ... 样式与订单列表一致
    }, (f: SFFavorite) => f.id.toString())
  }
  .width('100%')
}

收藏视图展示用户收藏的商品列表。与订单列表不同的是,收藏列表中每个商品显示原价(划线价)、收藏价标签、爱心图标和收藏价格。

原价使用划线样式(.decoration({ type: TextDecorationType.LineThrough })),这是电商应用中表示"原价/降价"的标准视觉手法。划线价旁边的"收藏价"标签用金色标注,明确告知用户这是收藏专享的优惠价格。

右侧的红色爱心图标是收藏功能的通用符号,用户一眼就能理解其含义。收藏价格使用金色粗体,与原价的灰色划线形成鲜明对比,突出优惠力度。

14.5 银料档案弹窗

@Builder modalOverlay() {
  Column() {
    Column() {
      Row() {
        Text('银料档案 · 925银')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(SF_COLORS.text)
        Column() {
          Text('✕')
            .fontSize(14)
            .fontColor(SF_COLORS.sub)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.End)
        .onClick(() => { this.showDetail = false })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

银料档案弹窗与其他弹窗有一个明显的不同:它的关闭按钮在弹窗内部的右上角(✕ 符号),而不是只有底部的"取消"按钮和点击遮罩关闭。

这种设计更接近传统的对话框模式——弹窗有自己的标题栏,标题栏右侧有关闭按钮。对于内容较多的弹窗(如参数表格),提供一个明显的关闭按钮是很有必要的,因为用户可能看不到底部的取消按钮(如果内容超出屏幕高度),或者不习惯点击遮罩关闭。

      Text('国际标准纯银 · 工坊主用银料')
        .fontSize(11)
        .fontColor(SF_COLORS.sub)
        .width('100%')
        .margin({ top: 6, bottom: 12 })

副标题说明这是"国际标准纯银",是工坊的主用银料。这句描述增加了产品的专业感和可信度——不是随便什么银料,而是国际标准的纯银,而且是工坊主要使用的。

      Row() {
        Text('参数项')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
        Text('数值')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 9, bottom: 9 })
      .backgroundColor(SF_COLORS.primary)
      .borderRadius({ topLeft: 10, topRight: 10 })
      .alignItems(VerticalAlign.Center)

参数表格的表头使用深灰色背景 + 白色粗体文字。两列布局:左侧是"参数项",右对齐;右侧是"数值",右对齐。这种两列表格结构清晰地展示了参数名称和对应的值。

      Row() {
        Text('成色')
          .fontSize(11)
          .fontColor(SF_COLORS.text)
          .layoutWeight(1)
        Text('925‰ 含银量')
          .fontSize(11)
          .fontColor(SF_COLORS.text)
          .layoutWeight(1)
          .textAlign(TextAlign.End)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 11, bottom: 11 })
      .backgroundColor(SF_COLORS.zebra)
      .alignItems(VerticalAlign.Center)
      // ... 密度、熔点、退火温度、锻打硬度、抛光转速 共6行

表格内容包含 6 个参数:成色(925‰ 含银量)、密度(10.36 g/cm³)、熔点(962 °C)、退火温度(650 °C)、锻打硬度(HV 65)、抛光转速(2800 rpm)。

这些参数都是 925 银的真实物理属性数据。数据的准确性很重要——如果这些参数是随意编造的,懂行的用户看到后会对产品产生不信任感。而真实准确的数据则能增强产品的专业形象,让用户相信这是一家真正懂银饰工艺的店铺。

表格行使用斑马纹交替底色(SF_COLORS.zebraSF_COLORS.card 交替),提升可读性。每行的上下内边距为 11px,确保内容不拥挤。

    }
    .width('86%')
    .padding(18)
    .backgroundColor(SF_COLORS.card)
    .borderRadius(16)
    .alignItems(HorizontalAlign.Start)
    .constraintSize({ maxHeight: '80%' })
  }

弹窗卡片设置了 .constraintSize({ maxHeight: '80%' }),即最大高度不超过屏幕的 80%。这是一个重要的细节——如果弹窗内容很多,在小屏幕上可能会超出屏幕高度。设置最大高度限制可以确保弹窗不会撑满整个屏幕,始终保留一些边距空间。当内容超出最大高度时,用户可以在弹窗内滚动查看(如果内部有 Scroll 组件的话)。

不过当前弹窗的内容量还达不到 80% 的高度限制,所以这个属性更多是一种防御性设计——为将来内容增加预留空间,防止内容过多时弹窗溢出屏幕。


十五、渲染流程与性能优化分析

15.1 组件渲染流程图

用户交互

定时器触发

页面切换

组件初始化

状态变量初始化

aboutToAppear 生命周期

执行 build 方法

构建组件树

与旧组件树 Diff 比较

更新差异节点

渲染到屏幕

事件触发 onClick 等

状态变量更新

标记组件为脏

setInterval 回调

aboutToDisappear 生命周期

清理定时器/事件监听

组件销毁

这张渲染流程图展示了 ArkTS 声明式组件的完整渲染生命周期。从组件初始化开始,经过状态初始化、生命周期回调、构建组件树、Diff 比较、更新渲染,到用户交互或定时器触发状态变更后重新渲染,最终在页面切换时销毁组件并清理资源。

理解这个渲染流程对于优化应用性能至关重要。开发者需要知道:哪些操作会触发重渲染(状态变更)、重渲染的成本有多高(构建组件树 + Diff)、如何减少不必要的重渲染(合理划分组件、使用合适的状态管理方式)。

在本应用中,动画效果(火花、光泽、温度脉动)都是通过定时器持续修改状态变量来触发重渲染实现的。由于动画帧频较高(每秒 15-20 帧),每帧都需要重新执行 build 方法并更新 UI。对于简单的动画元素来说,这种方式是可以接受的,但对于更复杂的动画场景,可能需要考虑使用更底层的动画 API 来获得更好的性能。

15.2 状态驱动的 UI 更新机制

声明式 UI 的核心在于"状态驱动视图"。状态是因,UI 是果。开发者只需要维护状态的正确性,UI 的更新由框架自动完成。

在本应用中,所有的交互反馈都是通过状态变更实现的:

  • 点击 Tab → activeTab 变化 → 内容区域切换
  • 点击筛选 → ringMat 变化 → 商品列表重新筛选
  • 点击定制 → showAdd 变为 true → 弹窗显示
  • 拖动滑块 → editPower 变化 → 滑块位置和数值更新

这种单向数据流(状态 → UI → 用户操作 → 新状态 → 新 UI)让应用的逻辑变得清晰可预测。每个状态对应一个确定的 UI 外观,开发者可以像解数学题一样推理应用的行为。

当然,状态管理也有其挑战。当应用规模增大时,状态的数量会膨胀,状态之间的依赖关系会变得复杂。这时候就需要引入更系统的状态管理方案,如全局状态管理、状态分层、状态机等。但对于当前这个规模的应用来说,组件内的 @State 变量已经足够。

15.3 ForEach 与 Key 的性能考量

ForEach 组件的 key 函数是影响列表渲染性能的重要因素。key 的作用是帮助框架识别列表中的每一项,当列表数据变化时,框架可以通过 key 来判断哪些项是新增的、哪些是删除的、哪些是移动的,从而以最小的代价更新 UI。

在本应用中,不同列表的 key 策略各不相同:

  • 匠人列表:使用 a.id.toString(),即数据的唯一 ID。这是最标准的做法,key 稳定且唯一。
  • 热销榜:使用 ${idx}-${r.id},即索引 + ID 的组合。
  • 火花粒子:使用 spark${s}-${this.sparkT},包含时间帧,每帧都变化。
  • 银戒商品 Grid:使用 ${this.ringMat}-${r.id},包含筛选条件,筛选变化时全部重建。
  • 工艺宫格:使用 ${c}-${this.selCraft},包含选中值,选择变化时全部重建。

不同的 key 策略反映了不同的性能考量和设计意图。对于静态列表,使用稳定的 ID 作为 key 可以获得最好的复用性能。对于动画元素,每帧改变 key 可以强制重建,避免过渡动画的干扰。对于筛选/切换场景,在状态变化时改变所有 key,可以确保 UI 的完全重置。

理解 key 的作用机制,根据不同场景选择合适的 key 策略,是 ArkTS 开发者必备的技能之一。


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

为了更直观地对比六个功能模块的设计特点和技术实现,我们整理了以下详细对比表格。

对比维度 首页 银戒 银链 吊坠 工具 我的
核心功能 品牌展示 + 匠人推荐 + 热销榜单 商品浏览 + 材质筛选 + 定制入口 销量排行 + 链型分布 + 删除管理 色卡展示 + 工艺选择 参数表格 + 功率对比 + 规格编辑 会员中心 + 消费统计 + 订单/收藏
布局方式 单列垂直滚动 双列 Grid + 图表 单列列表 + 图表 三列 Grid + 图表 表格 + 图表 卡片 + 图表 + 切换列表
弹窗类型 表单输入弹窗 删除确认弹窗 宫格选择弹窗 滑块编辑弹窗 参数表格弹窗
弹窗数量 0 1 1 1 1 1
动画效果 温度脉动 + 火花粒子 光泽扫过 + 卡片渐变
定时器 1个(60ms间隔) 1个(50ms间隔)
状态变量数 2 + 1定时器 5 2 3 4 4 + 1定时器
图表类型 水平进度条(温度) 水平进度条 + 垂直柱状图 水平进度条 水平进度条 水平进度条 垂直柱状图
数据可视化数量 1种 2种 1种 1种 1种 1种
主要交互 滚动浏览 筛选 + 定制提交 删除确认 工艺选择 滑块编辑 切换视图 + 查看档案
ForEach 循环数 4个 3个 2个 2个 2个 3个
特殊控件 Circle 粒子 Grid 双列 - Grid 三列 Slider 滑块 光泽渐变叠加
确认按钮颜色 - 金色 红色 金色 深灰色 -
列表行数/列数 5行热销 + 4匠人 + 8功能 5行x2列 Grid 8行排行榜 3行x3列 Grid 6行表格 + 6行对比 6行订单/收藏
卡片圆角 14/12/12/12px 12px 12px 12px 10px 16px
数据来源 匠人 + 银戒 + 功能 银戒 + 材质 银链 + 链型 吊坠 + 工艺 工具 订单 + 收藏 + 月度 + 会员
Builder 函数 0 1(modalOverlay) 1(modalOverlay) 1(modalOverlay) 1(modalOverlay) 1(modalOverlay)
生命周期钩子 aboutToAppear/Disappear aboutToAppear/Disappear
斑马纹使用 是(表格) 是(档案表格)
渐变使用 温度条渐变 图片模拟渐变 会员卡三色渐变

从上表可以看出,六个模块在功能定位、交互方式、视觉表现上各有侧重,但又共享统一的设计语言和组件模式。这种"和而不同"的设计是优秀应用的共同特征——整体风格统一,每个页面又有自己的特色。


十七、全面技术总结

17.1 架构设计维度

从架构设计的角度来看,这款应用采用了经典的单入口组件 + 多 Tab 子组件的分层架构。入口组件 SilverForgeApp 负责全局导航和布局,六个 Tab 组件各自负责一个功能模块。每个 Tab 组件内部又包含若干子模块,形成清晰的树状结构。

这种架构的优势在于:职责清晰——每个组件只负责一块功能,代码易于理解和维护;复用性好——数据和辅助函数可以被多个组件共享;扩展性强——新增 Tab 或修改某个模块不会影响其他部分。

同时,应用采用了数据驱动的开发模式。所有 UI 元素都基于数据生成,通过 ForEach 循环将数据映射为 UI 组件。这种模式使得修改数据就能改变 UI,无需手动调整布局代码,大大提升了开发效率。

在状态管理方面,应用采用组件内局部状态的策略。每个组件的状态变量都定义在组件内部,通过 @State 装饰器声明。这种方式简单直接,适合中小型应用。对于更大规模的应用,可能需要引入全局状态管理来处理跨组件的状态共享。

17.2 声明式 UI 范式维度

这款应用充分展示了 ArkTS 声明式 UI 的核心优势。开发者通过描述 UI 的"样子"而非"构建步骤"来编写界面代码。状态变化时,框架自动更新 UI,开发者无需手动操作视图对象。

具体体现在以下几个方面:

条件渲染:通过 if/else 语句控制组件的显示与隐藏,如弹窗的显示控制、订单/收藏视图的切换。声明式的条件渲染让 UI 的可见性逻辑变得非常直观。

循环渲染:通过 ForEach 将数组数据映射为列表 UI,如商品列表、排行榜、功能宫格等。数据与 UI 的一一对应关系清晰明了。

样式绑定:通过三元表达式将状态绑定到样式属性上,如选中态的颜色变化、进度条的宽度、排名徽章的背景色等。样式随状态自动变化,无需手动添加/移除 CSS 类。

事件处理:通过 .onClick() 等事件回调处理用户交互,在回调中修改状态,状态变化自动触发 UI 更新。整个流程形成闭环,逻辑清晰。

这些特性共同构成了声明式 UI 的开发体验——关注点分离、逻辑清晰、代码简洁。开发者可以将更多精力放在业务逻辑和用户体验上,而不是繁琐的 UI 操作代码。

17.3 动画与交互设计维度

在动画与交互方面,这款应用展现了纯代码实现丰富动效的能力。应用中包含三种动画效果:温度脉动动画、锤打火花粒子动画、会员卡光泽扫过动画。

温度脉动动画通过正弦函数控制温度值的波动,同时配合透明度的正弦变化,模拟出火焰跳动的视觉效果。这种数学函数驱动的动画是程序化动画的基础手法——用三角函数、指数函数、缓动函数等数学公式来描述动画曲线,可以获得非常平滑自然的运动效果。

火花粒子动画通过 ForEach 循环渲染多个粒子,每个粒子的位置由时间函数计算得出。粒子的水平运动使用正弦函数,垂直运动使用线性循环,两者叠加产生自然的飞溅效果。多粒子的错位相位设计(每个粒子索引不同,相位不同)让整体效果更加丰富。

会员卡光泽动画通过一个半透明渐变矩形的位移和旋转,模拟金属表面反光扫过的效果。这是一种非常经典的"拟真"动效手法——虽然只是简单的矩形移动,但因为模拟了真实世界中的光学现象,用户会下意识地感知到金属的质感。

在交互设计方面,应用遵循了移动端的通用设计规范:底部 Tab 导航、点击反馈、弹窗确认、列表滚动等。四个弹窗虽然功能各异,但都遵循一致的交互模式——遮罩层 + 居中卡片 + 底部按钮 + 点击遮罩关闭,保持了交互语言的一致性。

17.4 数据可视化维度

应用中包含了多种纯代码实现的数据可视化效果,包括水平进度条对比图、垂直柱状图、排行榜等。这些可视化组件虽然功能相对简单,但展示了用声明式 UI 构建数据图表的可能性。

水平进度条对比图是应用中使用最多的图表形式——银戒圈口对比、银链长度对比、银链链型分布、吊坠工艺分布、工具功率对比,都是水平进度条的形式。这种图表形式的优势是实现简单、结构清晰,适合展示少量数据项之间的对比。

垂直柱状图出现在银戒材质分布和我的月消费趋势两个地方。柱状图比水平进度条更有"图表感",适合展示时间序列或分类数据的趋势变化。纯代码实现柱状图的关键是用数据值计算柱子高度,用渐变增加视觉层次。

排行榜是一种特殊的数据可视化形式——它不仅展示数值对比,还展示排名次序。应用中的排行榜通过排名徽章(金银铜/数字)+ 进度条 + 数值的组合,提供了丰富的信息量。

这些纯代码实现的可视化组件有几个共同优点:零依赖——不需要引入额外的图表库;完全可控——样式、交互可以完全自定义;轻量高效——代码量小,渲染性能好。当然,缺点是功能有限,对于复杂的图表需求(如折线图、饼图、散点图等),还是需要使用专业的图表库。

17.5 设计系统与视觉规范维度

从视觉设计的角度来看,这款应用建立了一套完整的设计系统,包括颜色系统、间距系统、圆角系统、字体系统等。

颜色系统是设计系统的核心。应用定义了 10 种语义化颜色(bg、card、primary、gold、cream、text、sub、line、zebra、danger),覆盖了背景、卡片、主色、强调色、文字、边框、斑马纹、警告等所有常用场景。所有颜色集中管理,确保了全应用的色彩一致性。

间距系统虽然没有明确定义常量,但从代码中可以看出规律:模块间距约 16px,卡片内边距约 12-14px,元素间距约 8-10px。这种有规律的间距层级让界面看起来整齐有序。

圆角系统也有明显的层级:大卡片圆角 14-16px,小卡片圆角 10-12px,按钮圆角 20px(胶囊形),标签圆角 6-8px。不同大小的元素使用不同的圆角半径,保持了视觉比例的协调性。

字体系统分为几个层级:大标题 18px、模块标题 15px、卡片标题 13px、正文 12-13px、辅助文字 10-11px。字重也有明确的使用规范:标题用粗体,正文用常规,价格用粗体金色。

建立完善的设计系统是 UI 开发规范化的必由之路。它不仅能提升开发效率(复用设计决策),还能确保产品体验的一致性,提升品牌形象。

17.6 代码质量与工程实践维度

从代码质量的角度来看,这款应用体现了许多良好的编程实践。

命名规范:所有的变量、函数、组件都采用了语义化的命名方式。前缀 SF 作为命名空间,避免与其他代码冲突。接口用 I 前缀风格(SF 前缀 + 大驼峰),常量用全大写 + 下划线,函数用小驼峰,组件用大驼峰。命名规范统一,易于阅读。

类型安全:大量使用接口定义数据结构,使用枚举表示状态和分类。TypeScript/ArkTS 的类型系统在编译期就能捕获许多潜在的错误,提升了代码的可靠性。

函数复用:排序、筛选、统计等通用逻辑被抽取为独立的函数,可以在多个地方复用。函数是纯函数,输入输出关系明确,易于测试和理解。

组件化:每个 Tab 是一个独立组件,弹窗通过 @Builder 封装为 UI 片段。组件化让代码结构更加清晰,每个组件的职责明确。

生命周期管理:定时器等资源在 aboutToAppear 中创建,在 aboutToDisappear 中清理,遵循了资源成对管理的原则。这是避免内存泄漏的重要保障。

数据与视图分离:数据定义、数据处理、UI 渲染三个层次分明。数据层不依赖 UI,UI 层调用数据层。这种分离使得修改数据不会影响 UI 代码,修改 UI 也不会影响数据结构。

当然,代码中也有一些可以改进的地方,比如筛选标签的重复代码可以用 ForEach 优化、弹窗的公共部分可以抽取为更通用的组件、状态管理可以更系统化等。但作为一个单文件的演示应用,它的代码质量已经相当不错。

17.7 学习价值与实践意义

对于鸿蒙开发者而言,这款应用具有多方面的学习价值。

首先,它展示了如何用 ArkTS 声明式语法构建一个完整的多页面应用。从入口组件到各子组件,从数据定义到 UI 渲染,从用户交互到状态管理,覆盖了应用开发的方方面面。初学者可以通过阅读这份代码,建立对鸿蒙应用开发的整体认知。

其次,它展示了许多实用的 UI 开发技巧。如何实现双列/三列网格布局、如何制作水平进度条、如何用纯代码画柱状图、如何实现弹窗交互、如何制作粒子动画效果——这些都是实际开发中经常遇到的需求。

第三,它展示了如何建立统一的设计系统。从配色到间距,从圆角到字体,一套好的设计系统可以让应用的视觉品质提升一个档次。对于独立开发者或小团队来说,学习如何设计和实现一套完整的设计系统尤为重要。

第四,它展示了数据驱动 UI 的开发思想。所有的 UI 元素都基于数据生成,数据的变化自动反映到 UI 上。这种思想是现代前端开发的核心,也是鸿蒙声明式 UI 的核心。掌握了这种思想,就能更高效地开发复杂应用。

最后,它展示了单文件架构的可能性和适用场景。对于小型应用、原型验证、教学演示等场景,单文件架构具有简单、直观、易于分享的优势。了解这种架构的特点和局限,可以帮助开发者在面对不同项目时做出更合适的架构选择。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// SILVER FORGE 手工银饰锻造馆 - 鸿蒙 ArkTS 单文件应用
// 主题:银灰 #4A4A4A + 暖金 #D4A017 + 做旧米 #F0E8D8(工业复古·浅色暖调)
// ============================================================

// ============ 类型定义 ============
interface SFColorPalette {
  bg: string
  card: string
  primary: string
  gold: string
  cream: string
  text: string
  sub: string
  line: string
  zebra: string
  danger: string
}

interface SFRing {
  id: number
  name: string
  material: string
  size: number
  weight: number
  price: number
  sales: number
  tag: string
}

interface SFChain {
  id: number
  name: string
  chainType: string
  length: number
  weight: number
  price: number
  sales: number
}

interface SFPendant {
  id: number
  name: string
  colorHex: string
  craft: string
  weight: number
  price: number
}

interface SFTool {
  id: number
  name: string
  power: number
  weight: number
  precision: number
  price: number
}

interface SFOrder {
  id: number
  name: string
  status: string
  price: number
  date: string
}

interface SFFavorite {
  id: number
  name: string
  price: number
  oldPrice: number
}

interface SFMonthly {
  month: string
  amount: number
}

interface SFArtisan {
  id: number
  name: string
  title: string
  years: number
  works: number
}

interface SFFeature {
  icon: string
  label: string
}

// ============ 全局配色 ============
const SF_COLORS: SFColorPalette = {
  bg: '#F0E8D8',
  card: '#FFFFFF',
  primary: '#4A4A4A',
  gold: '#D4A017',
  cream: '#FAF5EA',
  text: '#3A3630',
  sub: '#9A917F',
  line: '#E5DCC8',
  zebra: '#F7F1E3',
  danger: '#C0392B'
}

// ============ 数据 ============
const SF_RINGS: SFRing[] = [
  { id: 1, name: '素圈银戒', material: '925银', size: 14, weight: 3.2, price: 129, sales: 328, tag: '经典' },
  { id: 2, name: '锤纹银戒', material: '泰银', size: 16, weight: 4.5, price: 199, sales: 512, tag: '热销' },
  { id: 3, name: '扭臂银戒', material: '999足银', size: 15, weight: 3.8, price: 239, sales: 268, tag: '新品' },
  { id: 4, name: '嵌金丝戒', material: '925银', size: 17, weight: 4.1, price: 359, sales: 189, tag: '轻奢' },
  { id: 5, name: '麻花银戒', material: '925银', size: 13, weight: 2.9, price: 159, sales: 421, tag: '热销' },
  { id: 6, name: '做旧唐草戒', material: '泰银', size: 18, weight: 5.2, price: 289, sales: 156, tag: '复古' },
  { id: 7, name: '光面宽版戒', material: '999足银', size: 19, weight: 6.8, price: 329, sales: 98, tag: '大气' },
  { id: 8, name: '月牙开口戒', material: '925银', size: 12, weight: 2.6, price: 139, sales: 365, tag: '灵动' },
  { id: 9, name: '珐琅点银戒', material: '925银', size: 14, weight: 3.5, price: 269, sales: 210, tag: '彩绘' },
  { id: 10, name: '錾花复古戒', material: '泰银', size: 16, weight: 5.6, price: 399, sales: 143, tag: '匠心' }
]

const SF_CHAINS: SFChain[] = [
  { id: 1, name: '古巴银链', chainType: '古巴链', length: 550, weight: 32, price: 459, sales: 380 },
  { id: 2, name: '麻绳银链', chainType: '绳链', length: 500, weight: 21, price: 289, sales: 452 },
  { id: 3, name: '盒式银链', chainType: '盒链', length: 450, weight: 15, price: 219, sales: 388 },
  { id: 4, name: '瓦片银链', chainType: '瓦片链', length: 600, weight: 28, price: 399, sales: 265 },
  { id: 5, name: '水波银链', chainType: '水波链', length: 500, weight: 19, price: 259, sales: 312 },
  { id: 6, name: '泰银做旧链', chainType: '做旧链', length: 550, weight: 35, price: 489, sales: 198 },
  { id: 7, name: '珠形银链', chainType: '珠链', length: 450, weight: 12, price: 189, sales: 335 },
  { id: 8, name: '双扣骑士链', chainType: '骑士链', length: 600, weight: 40, price: 559, sales: 122 }
]

const SF_PENDANTS: SFPendant[] = [
  { id: 1, name: '银月吊坠', colorHex: '#C9CDD4', craft: '錾刻', weight: 6.2, price: 219 },
  { id: 2, name: '暖金叶吊坠', colorHex: '#D4A017', craft: '浮雕', weight: 4.8, price: 259 },
  { id: 3, name: '珐琅蓝滴坠', colorHex: '#3B6FA0', craft: '珐琅', weight: 5.5, price: 299 },
  { id: 4, name: '做旧古币坠', colorHex: '#8C7A5B', craft: '做旧', weight: 7.0, price: 239 },
  { id: 5, name: '镂空蝶影坠', colorHex: '#B8BCC2', craft: '镂空', weight: 3.9, price: 279 },
  { id: 6, name: '红玛瑙银坠', colorHex: '#9A4A3C', craft: '錾刻', weight: 6.8, price: 319 },
  { id: 7, name: '银杏叶银坠', colorHex: '#C8A96A', craft: '浮雕', weight: 4.2, price: 199 },
  { id: 8, name: '星轨微镶坠', colorHex: '#DDE1E6', craft: '镶嵌', weight: 5.1, price: 349 }
]

const SF_TOOLS: SFTool[] = [
  { id: 1, name: '高频锤打机', power: 850, weight: 12.5, precision: 0.02, price: 3699 },
  { id: 2, name: '精密錾刻刀', power: 60, weight: 0.8, precision: 0.01, price: 289 },
  { id: 3, name: '退火喷火枪', power: 320, weight: 1.6, precision: 0.05, price: 899 },
  { id: 4, name: '拉丝抛光机', power: 420, weight: 9.8, precision: 0.03, price: 1580 },
  { id: 5, name: '电子测温仪', power: 15, weight: 0.4, precision: 0.001, price: 459 },
  { id: 6, name: '戒圈刻字机', power: 260, weight: 15.2, precision: 0.005, price: 2380 }
]

const SF_ORDERS: SFOrder[] = [
  { id: 1, name: '锤纹银戒 × 1', status: '已完成', price: 199, date: '08-24' },
  { id: 2, name: '古巴银链 × 1', status: '锻造中', price: 459, date: '08-22' },
  { id: 3, name: '珐琅蓝滴坠 × 2', status: '待收货', price: 598, date: '08-18' },
  { id: 4, name: '麻花银戒 × 1', status: '已完成', price: 159, date: '08-12' },
  { id: 5, name: '水波银链 × 1', status: '已完成', price: 259, date: '08-05' },
  { id: 6, name: '银月吊坠 × 1', status: '锻造中', price: 219, date: '08-01' }
]

const SF_FAVORITES: SFFavorite[] = [
  { id: 1, name: '錾花复古戒', price: 399, oldPrice: 469 },
  { id: 2, name: '泰银做旧链', price: 489, oldPrice: 559 },
  { id: 3, name: '星轨微镶坠', price: 349, oldPrice: 429 },
  { id: 4, name: '嵌金丝戒', price: 359, oldPrice: 399 },
  { id: 5, name: '做旧古币坠', price: 239, oldPrice: 299 },
  { id: 6, name: '双扣骑士链', price: 559, oldPrice: 659 }
]



17.8 总结:

在这里插入图片描述

基于 HarmonyOS API 24 的能力,这款应用还有许多可以进一步拓展的方向。

在动画方面,可以引入更高级的显式动画 API(animateTo)来实现更流畅的页面切换和状态过渡。当前的弹窗出现/消失都是瞬时的,如果加上淡入淡出或缩放动画,体验会更加精致。

在状态管理方面,可以利用 @Provide/@ConsumeAppStorage 实现跨组件的状态共享。例如,将购物车数据、用户信息等全局状态提升到应用级,让各个 Tab 都能访问。

在组件化方面,可以将弹窗、商品卡片等可复用元素抽取为独立组件,进一步提升代码的复用性和可维护性。对于更复杂的项目,还可以考虑使用自定义组件的 slot(插槽)机制,实现更灵活的组件组合。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐