鸿蒙操作系统(HarmonyOS)是华为推出的面向全场景的分布式操作系统,其应用开发框架提供了一套全新的声明式UI开发范式。ArkTS作为鸿蒙生态的核心开发语言,在TypeScript的基础上进行了深度定制与扩展,引入了诸如@Component@Builder@State@Entry等一系列装饰器,使得开发者能够以声明式的方式描述界面结构与状态逻辑。在鸿蒙的开发体系中,UI不再是命令式的"创建-修改-更新"流程,而是通过状态驱动视图自动刷新的响应式模型。当状态数据发生变化时,框架会自动重新执行依赖该状态的UI构建函数,完成界面的局部或整体更新。这种设计思想与React的JSX、Flutter的Widget树有着相似的核心理念,但在语法层面更加贴近TypeScript生态,降低了前端开发者的迁移成本。

ArkTS的组件化开发思想强调"组合优于继承"。每一个@Component装饰的结构体都是一个独立的、可复用的UI单元,它拥有自己的状态管理、构建逻辑和生命周期。组件之间通过参数传递实现数据流动,通过回调函数实现事件冒泡。在本篇分析的代码中,我们可以看到一个完整的黑胶唱片收藏与交易市集应用——它由入口组件统一调度,五个功能页面(仓库、市集、求购、评价、我的)各自独立又相互协作,充分体现了组件化架构的优势。每个页面组件内部又拆分出多个@Builder方法,将卡片渲染、弹窗展示、列表项构建等逻辑封装为可复用的代码块。这种"大组件嵌套小构建器"的分层设计,使得代码既保持了整体的可读性,又实现了细粒度的功能复用。

鸿蒙的布局系统提供了丰富的基础容器组件,其中ColumnRow是最常用的两个线性布局容器。Column使子组件在垂直方向上依次排列,而Row则使子组件在水平方向上依次排列。这两个容器可以通过justifyContentalignItems属性来精确控制子组件在主轴和交叉轴上的对齐方式。Stack布局则允许子组件堆叠排列,后声明的子组件会覆盖在前面的子组件之上,非常适合实现弹窗遮罩层与内容层的叠放效果。Flex布局提供了更灵活的弹性布局能力,支持wrap属性实现自动换行,在本应用中用于实现唱片卡片的网格化排列。

状态管理是ArkTS的核心特性之一。通过@State装饰器声明的变量,一旦其值发生改变,框架会自动触发依赖该变量的UI重新渲染。这种响应式机制使得开发者无需手动调用"刷新"方法,只需关注数据的变更逻辑即可。在本应用中,Tab切换、弹窗的显示与隐藏、列表的筛选过滤、点赞状态的切换等功能,全部依赖于@State变量驱动的响应式更新。此外,@Builder装饰器用于将UI构建逻辑抽取为可复用的方法,它不同于普通方法之处在于其内部可以使用ArkTS的声明式UI语法,且参数传递支持按值传递和按引用传递两种模式,为组件化开发提供了强大的代码组织能力。

ForEach是ArkTS中用于循环渲染列表的核心组件。它接收三个参数:数据源数组、子项构建函数和键值生成函数。键值生成函数(keyGenerator)的返回值用于框架的Diff算法,当数据变化时,框架通过比较键值来决定哪些项需要新增、删除或更新,从而实现高效的列表渲染。在本应用中,唱片列表、评价列表、求购列表等都大量使用了ForEach来渲染动态数据。


一、类型定义体系:接口驱动的数据模型

1.1 GenreMeta 接口——流派元信息

interface GenreMeta {
  label: string;
  icon: string;
  color: string;
  bg: string;
}

在这里插入图片描述

GenreMeta接口定义了音乐流派的元信息结构。这个接口虽然只有四个字段,但承载了流派展示所需的全部视觉信息。label字段存储流派的中文显示名称,如"摇滚"、"爵士"等。icon字段存储对应的Emoji图标,用于在卡片和列表中以视觉化的方式快速识别流派。color字段定义了流派的主题色,用于文本着色。bg字段定义了流派的背景色,用于标签底色填充。

在ArkTS的接口设计中,使用对象字面量类型(object literal type)是一种推荐实践。与class不同,interface仅定义数据结构契约,不包含实现逻辑,这使得它在作为类型约束时更加轻量。在本应用中,所有接口都采用对象字面量形式的属性声明,每个属性都明确标注了类型,这符合ArkTS的强类型约束要求。ArkTS对类型安全有着严格的要求,不允许使用any类型,所有变量和参数都必须有明确的类型声明。

在鸿蒙ArkTS开发规范中,接口(interface)的广泛使用体现了"契约先行"的设计理念。接口定义了数据的形状,而不关心数据的来源和创建方式。这使得数据模型可以在不同的组件之间安全传递,编译器能够在编译阶段捕获类型不匹配的错误,而非等到运行时才暴露问题。

1.2 VinylItem 接口——唱片数据模型

interface VinylItem {
  id: number;
  title: string;
  artist: string;![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/2388f406ca74459f8671e5c42cbdc721.png#pic_center)

  year: number;
  genre: string;
  price: number;
  condition: string;
  cover: string;
  rare: boolean;
  tracks: number;
  seller: string;
  sold: boolean;
}

VinylItem接口是整个应用最核心的数据模型,定义了一张黑胶唱片的完整信息结构。共包含12个字段:id为唯一标识符,title为唱片标题,artist为艺术家或乐队名称,year为发行年份,genre为音乐流派,price为售价,condition为品相等级描述,cover为封面图标,rare标记是否为稀有版本,tracks记录曲目数量,seller为卖家名称,sold标记是否已售出。

这里特别值得注意的是布尔类型字段raresold的使用。在UI渲染中,这两个字段直接驱动条件渲染逻辑——当raretrue时,唱片卡片上会显示"稀有"标签;当soldtrue时,会显示"已出"标签。这种将业务状态字段化、布尔化的设计方式,使得UI层只需简单的条件判断即可完成视觉差异展示,无需额外的枚举或状态机。

1.3 WantedItem 接口——求购数据模型

interface WantedItem {
  id: number;
  title: string;
  artist: string;
  budget: number;
  offers: number;
  deadline: string;
  status: string;
  urgent: boolean;
}

WantedItem接口定义了求购信息的数据结构。budget字段表示买家的心理价位,offers字段记录已收到的卖家报价数量,deadline字段存储截止时间描述(如"还剩3天"),status字段表示求购状态(如"求购中"、“即将截止”、“已收到报价”),urgent字段标记是否为紧急求购。当urgenttrue时,求购卡片上会显示红色"急"标签,并在状态文字上使用红色强调。

1.4 ReviewItem 接口——评价数据模型

interface ReviewItem {
  id: number;
  user: string;
  avatar: string;
  rating: number;
  target: string;
  text: string;
  time: string;
  likes: number;
  liked: boolean;
}

在这里插入图片描述

ReviewItem接口定义了用户评价的数据结构。rating字段为1到5的整数评分,target字段记录评价所针对的唱片名称,text字段存储评价正文,likes字段记录点赞数,liked字段标记当前用户是否已点赞。liked字段的设计特别值得注意——它不仅用于控制点赞按钮的视觉状态(已点赞时放大并变色),还参与了ForEach的键值生成,确保点赞状态变化时列表能够正确重新渲染。

1.5 其余接口定义

interface DecadeEntry {
  decade: string;
  count: number;
  color: string;
}

interface PriceTrend {
  genre: string;
  avg: number;
  delta: string;
}

interface TradeItem {
  id: number;
  title: string;
  artist: string;
  price: number;
  buyer: string;
  date: string;
  state: string;
}

interface BadgeEntry {
  icon: string;
  name: string;
  desc: string;
  got: boolean;
}

在这里插入图片描述

DecadeEntry接口定义了年代收藏统计数据,用于渲染柱状图。decade字段如"60s"、“70s"等,count字段记录该年代的唱片数量,color字段定义柱状图颜色。PriceTrend接口定义了各流派的成交均价趋势,delta字段如”+12%“或”-3%"记录涨跌幅。TradeItem接口定义了交易记录数据,state字段记录交易状态(已发货、待付款、已完成、已取消)。BadgeEntry接口定义了勋章数据,got字段标记是否已解锁。

被引用

被引用

被引用

被引用

被引用

被引用

被引用

被引用

数据模型层

GenreMeta
流派元信息

VinylItem
唱片数据

WantedItem
求购数据

ReviewItem
评价数据

DecadeEntry
年代统计

PriceTrend
价格趋势

TradeItem
交易记录

BadgeEntry
勋章数据

仓库Tab

市集Tab

求购Tab

评价Tab

我的Tab

接口的合理拆分是良好架构的基础。在本应用中,八种接口各自承担单一职责:唱片数据归VinylItem,求购数据归WantedItem,评价数据归ReviewItem。这种"一个业务实体一个接口"的设计方式使得数据流清晰可追踪,当某个功能出现问题时,开发者可以快速定位到对应的数据模型和消费该模型的组件。


二、风格配置与常量数据

2.1 GENRE_CONFIG——流派配置映射表

const GENRE_CONFIG: Record<string, GenreMeta> = {
  '摇滚': { label: '摇滚', icon: '🎸', color: '#B45309', bg: '#F6E7D4' },
  '爵士': { label: '爵士', icon: '🎷', color: '#0F766E', bg: '#DCEEEA' },
  '灵魂': { label: '灵魂', icon: '🎙️', color: '#BE185D', bg: '#F8E3EC' },
  '电子': { label: '电子', icon: '🎛️', color: '#4338CA', bg: '#E4E3FA' },
  '嘻哈': { label: '嘻哈', icon: '🎤', color: '#B91C1C', bg: '#F9E2E2' },
  '华语经典': { label: '华语经典', icon: '🀄', color: '#A16207', bg: '#F8F0D8' },
  '乡村': { label: '乡村', icon: '🤠', color: '#15803D', bg: '#E1F2E5' },
  '古典': { label: '古典', icon: '🎻', color: '#6D28D9', bg: '#ECE6F9' }
};

在这里插入图片描述

GENRE_CONFIG是一个使用Record<string, GenreMeta>类型声明的常量映射表。Record<K, V>是TypeScript/ArkTS中的工具类型(Utility Type),它构造一个属性键为K类型、属性值为V类型的对象类型。在这里,键为流派名称字符串,值为GenreMeta对象。这种设计使得开发者可以通过流派名称快速查找对应的图标、颜色和背景色,而无需编写冗长的switch-caseif-else链。

每个流派都有一套精心搭配的视觉方案。摇滚使用琥珀棕色系(#B45309),爵士使用深青绿色系(#0F766E),灵魂使用深玫红色系(#BE185D),电子使用靛蓝色系(#4338CA)。这些颜色搭配不仅符合各自音乐流派的气质,还确保了视觉上的辨识度。值得注意的是,每个流派的colorbg都是同色系的高低明度搭配——前景色较深,背景色较浅,保证了文字在背景上的可读性。

在鸿蒙应用开发中,颜色配置的集中管理是一种重要的工程实践。将所有颜色定义在统一的配置对象中,而非分散在各个组件的样式属性中,使得主题切换和色彩调整变得极为便捷。如果未来需要支持暗色模式,只需修改配置表中的颜色值,所有引用该配置的组件都会自动更新。

2.2 GENRE_LIST与CONDITION_LIST

const GENRE_LIST: string[] = ['全部', '摇滚', '爵士', '灵魂', '电子', '嘻哈', '华语经典', '乡村', '古典'];

const CONDITION_LIST: string[] = ['Mint 全新', 'VG+ 近新', 'VG 良好', 'G+ 有痕'];

GENRE_LIST是一个字符串数组,定义了所有的流派筛选选项。注意第一个元素是"全部",它不是GENRE_CONFIG中的键,而是一个特殊的筛选条件——当用户选择"全部"时,筛选函数会返回完整的列表而不做任何过滤。这种"全部"选项作为数组首元素的约定在UI开发中非常常见。

CONDITION_LIST定义了黑胶唱片的四种品相等级。Mint(全新)是最高品相,VG+(近新)表示几乎全新,VG(良好)表示有轻微使用痕迹,G+(有痕)表示有明显使用痕迹但可正常播放。这些品相等级在市集列表和出售表单中用作选项标签。

2.3 DECADE_SHARE——年代收藏统计

const DECADE_SHARE: DecadeEntry[] = [
  { decade: '60s', count: 18, color: '#8C5A2B' },
  { decade: '70s', count: 32, color: '#B45309' },
  { decade: '80s', count: 26, color: '#C99B3F' },
  { decade: '90s', count: 21, color: '#0F766E' },
  { decade: '00s', count: 12, color: '#BE185D' },
  { decade: '10s', count: 8, color: '#4338CA' }
];

在这里插入图片描述

DECADE_SHARE数组定义了六个年代的唱片收藏数量统计。每个条目包含年代标签、数量和颜色。70年代以32张唱片高居榜首,这反映了70年代是黑胶唱片的黄金时期——摇滚、灵魂、放克等流派在这一时期达到了创作高峰。柱状图的高度通过(d.count / 32 * 72).toFixed(0) + 'vp'动态计算,其中32是最大值,72是最大高度(单位为vp)。这种将数据按比例缩放到固定像素范围的方式,是ArkTS中实现自定义柱状图的常用技巧。

2.4 PRICE_TREND与TRADE_LIST

const PRICE_TREND: PriceTrend[] = [
  { genre: '爵士', avg: 386, delta: '+12%' },
  { genre: '摇滚', avg: 264, delta: '+6%' },
  { genre: '灵魂', avg: 452, delta: '+18%' },
  { genre: '电子', avg: 198, delta: '-3%' },
  { genre: '嘻哈', avg: 312, delta: '+9%' },
  { genre: '华语经典', avg: 176, delta: '+4%' }
];

在这里插入图片描述

PRICE_TREND数组记录了六个流派近30天的成交均价和涨跌幅。灵魂乐以均价452元居首,涨幅达18%,这与灵魂乐唱片的稀缺性直接相关。电子乐均价为198元且跌幅3%,是唯一下跌的流派。涨跌幅的正负通过p.delta.indexOf('-') >= 0来判断,进而决定柱状图颜色和涨跌幅文字颜色——负值使用灰色或红色,正值使用金色或绿色。

const TRADE_LIST: TradeItem[] = [
  { id: 1, title: '蓝色列车', artist: 'John Coltrane', price: 520, buyer: '雨夜留声机', date: '今天 14:20', state: '已发货' },
  { id: 2, title: '平行线', artist: 'Kraftwerk', price: 340, buyer: '硬核乐迷阿伟', date: '今天 10:05', state: '待付款' },
  // ...更多记录
];

在这里插入图片描述

TRADE_LIST数组定义了8条交易记录。每条记录的state字段在UI渲染时通过三元表达式动态设置颜色:已取消显示红色(#B91C1C),已完成显示绿色(#15803D),其他状态显示棕色(#A16207)。这种基于状态值的条件着色是电商类应用中常见的视觉反馈模式。

2.5 VAULT_BADGES——勋章配置

const VAULT_BADGES: BadgeEntry[] = [
  { icon: '🎖️', name: '初代藏家', desc: '收藏满10张', got: true },
  { icon: '📀', name: '百万转盘', desc: '总播放满100小时', got: true },
  { icon: '🤝', name: '守信卖家', desc: '好评满50单', got: true },
  { icon: '🧭', name: '淘碟达人', desc: '市集淘到绝版', got: true },
  { icon: '💎', name: '稀有大户', desc: '稀有盘满5张', got: false },
  { icon: '🚚', name: '百单卖家', desc: '成交满100单', got: false }
];

VAULT_BADGES数组定义了六个勋章条目。got字段标记是否已解锁。已解锁的勋章以完整不透明度(opacity: 1.0)和原始尺寸(scale: 1.0)展示,未解锁的勋章则以降低的不透明度(opacity: 0.3)和缩小尺寸(scale: 0.9)展示,同时描述文字替换为"未解锁"。这种视觉差异直观地传达了成就的获取状态。

使用场景

配置层

GENRE_CONFIG
流派映射表

GENRE_LIST
流派筛选列表

CONDITION_LIST
品相等级列表

DECADE_SHARE
年代统计

PRICE_TREND
价格趋势

TRADE_LIST
交易记录

VAULT_BADGES
勋章配置

仓库Tab柱状图

市集Tab筛选与趋势图

出售表单品相选择

我的Tab勋章墙

交易记录弹窗


三、Mock数据类设计

3.1 VinylData类——唱片数据实现

class VinylData implements VinylItem {
  id: number = 0;
  title: string = '';
  artist: string = '';
  year: number = 0;
  genre: string = '';
  price: number = 0;
  condition: string = '';
  cover: string = '';
  rare: boolean = false;
  tracks: number = 0;
  seller: string = '';
  sold: boolean = false;

  constructor(id: number, title: string, artist: string, year: number, genre: string, 
              price: number, condition: string, cover: string, rare: boolean, 
              tracks: number, seller: string, sold: boolean) {
    this.id = id;
    this.title = title;
    this.artist = artist;
    this.year = year;
    this.genre = genre;
    this.price = price;
    this.condition = condition;
    this.cover = cover;
    this.rare = rare;
    this.tracks = tracks;
    this.seller = seller;
    this.sold = sold;
  }
}

VinylData类通过implements VinylItem关键字实现了VinylItem接口。这是ArkTS中类与接口关系的标准表达——类负责提供具体实现,接口负责定义类型契约。每个属性在声明时都被赋予了默认值(如0''false),这确保了即使构造函数未被正确调用,对象也不会处于undefined状态。

构造函数接收12个参数,逐一赋值给对应的实例属性。这种全参数构造函数的设计虽然参数较多,但保证了对象创建时数据的完整性。在ArkTS中,类不支持可选参数和默认参数的混合使用(与TypeScript有所不同),因此所有参数都必须在构造函数调用时提供。

implements关键字与extends有着本质区别。implements表示类遵循接口定义的形状契约,但不继承任何实现代码;extends表示类继承父类的所有属性和方法。在本应用中,数据类使用implements而非extends,因为接口不包含任何方法实现,类需要自行声明所有属性并提供构造函数。

3.2 WantedData类与ReviewData类

class WantedData implements WantedItem {
  id: number = 0;
  title: string = '';
  artist: string = '';
  budget: number = 0;
  offers: number = 0;
  deadline: string = '';
  status: string = '';
  urgent: boolean = false;

  constructor(id: number, title: string, artist: string, budget: number, 
              offers: number, deadline: string, status: string, urgent: boolean) {
    this.id = id;
    this.title = title;
    this.artist = artist;
    this.budget = budget;
    this.offers = offers;
    this.deadline = deadline;
    this.status = status;
    this.urgent = urgent;
  }
}

WantedData类与VinylData类的设计模式完全一致:声明属性并赋予默认值,构造函数接收全部参数逐一赋值。这种一致性的设计风格使得代码风格统一,便于维护和理解。

class ReviewData implements ReviewItem {
  id: number = 0;
  user: string = '';
  avatar: string = '';
  rating: number = 0;
  target: string = '';
  text: string = '';
  time: string = '';
  likes: number = 0;
  liked: boolean = false;

  constructor(id: number, user: string, avatar: string, rating: number, 
              target: string, text: string, time: string, likes: number, liked: boolean) {
    this.id = id;
    this.user = user;
    this.avatar = avatar;
    this.rating = rating;
    this.target = target;
    this.text = text;
    this.time = time;
    this.likes = likes;
    this.liked = liked;
  }
}

ReviewData类同样遵循这一模式。值得注意的是liked字段的初始值为false,表示用户初始状态下未点赞任何评价。这个字段后续在ReviewContent组件中通过toggleLike方法进行切换,并驱动UI的响应式更新。

implements

implements

implements

VinylItem

+id: number

+title: string

+artist: string

+year: number

+genre: string

+price: number

+condition: string

+cover: string

+rare: boolean

+tracks: number

+seller: string

+sold: boolean

VinylData

+constructor()

WantedItem

+id: number

+title: string

+artist: string

+budget: number

+offers: number

+deadline: string

+status: string

+urgent: boolean

WantedData

+constructor()

ReviewItem

+id: number

+user: string

+avatar: string

+rating: number

+target: string

+text: string

+time: string

+likes: number

+liked: boolean

ReviewData

+constructor()


四、Mock数据数组

4.1 mockVinyls——唱片数据集

const mockVinyls: VinylData[] = [
  new VinylData(1, '蓝色列车', 'John Coltrane', 1957, '爵士', 520, 'VG+ 近新', '🎷', true, 5, '雨夜留声机', false),
  new VinylData(2, '平行线', 'Kraftwerk', 1977, '电子', 340, 'VG 良好', '🎛️', true, 8, '硬核乐迷阿伟', false),
  new VinylData(3, '午夜电台', 'The Midnight', 2016, '电子', 158, 'Mint 全新', '🌙', false, 10, '复古收藏家K', true),
  // ...更多数据
];

mockVinyls数组包含24条唱片数据,涵盖了从1947年到2016年的各年代作品。每条数据通过new VinylData(...)构造函数创建,传入完整的12个参数。数据设计上涵盖了八大流派(爵士、电子、华语经典、古典、嘻哈、摇滚、灵魂、乡村),价格区间从96元到820元,品相从Mint到G+全分布。

特别值得注意的是第3条数据(午夜电台),其sold字段为true,这是24条数据中唯一已售出的唱片。在仓库页面的卡片渲染中,该唱片会显示灰色的"已出"标签,与其他唱片的视觉表现形成区分。同时,第22条数据(电台之星,David Bowie)也标记为sold: true,进一步丰富了数据场景。

4.2 mockWanteds——求购数据集

const mockWanteds: WantedData[] = [
  new WantedData(1, 'In Rainbows 初版', 'Radiohead', 450, 6, '还剩3天', '求购中', true),
  new WantedData(2, 'Sgt. Pepper 首版', 'The Beatles', 1200, 3, '还剩12天', '求购中', true),
  new WantedData(5, 'Kind of Blue', 'Miles Davis', 560, 8, '还剩2天', '即将截止', true),
  // ...更多数据
];

mockWanteds数组包含12条求购数据。其中多条标记为urgent: true(紧急求购),这些求购项在卡片上会显示红色"急"标签。第5条和第12条的status为"即将截止",其状态文字会以红色显示,提醒用户时间紧迫。第10条的status为"已收到报价",表示已有卖家响应。

4.3 mockReviews——评价数据集

const mockReviews: ReviewData[] = [
  new ReviewData(1, '雨夜留声机', '☔️', 5, '蓝色列车', '成色比描述还好...', '2小时前', 46, false),
  new ReviewData(2, '硬核乐迷阿伟', '🎛️', 4, '平行线', '德国原版,音质扎实...', '5小时前', 32, false),
  // ...更多数据
];

mockReviews数组包含14条评价数据。评价评分从3星到5星不等,第7条评价(氛围电子粉对玻璃花园的评价)给出了3星评价,评价文本中提到了版本出入的问题,体现了评价体系的真实性。每条评价的likes字段记录了点赞数,最高的是第10条(上海老克勒对雨巷的评价)的84个赞。

Mock数据的设计质量直接影响开发体验和测试效果。在本应用中,Mock数据不仅数量充足(24条唱片、12条求购、14条评价),而且在数据分布上刻意制造了差异性——不同的流派、不同的年代、不同的品相、不同的状态——确保了各种UI分支都能被触发和展示。这种"覆盖所有渲染路径"的Mock数据设计思路,是前端开发中的最佳实践。


五、工具函数设计

5.1 流派颜色获取函数

function getGenreColor(g: string): string {
  if (GENRE_CONFIG[g]) {
    return GENRE_CONFIG[g].color;
  }
  return '#8C5A2B';
}

function getGenreBg(g: string): string {
  if (GENRE_CONFIG[g]) {
    return GENRE_CONFIG[g].bg;
  }
  return '#F6E7D4';
}

function getGenreIcon(g: string): string {
  if (GENRE_CONFIG[g]) {
    return GENRE_CONFIG[g].icon;
  }
  return '📀';
}

这三个工具函数分别用于获取流派的主题色、背景色和图标。它们的实现逻辑完全一致:首先检查GENRE_CONFIG中是否存在该流派的配置,如果存在则返回对应的值,否则返回一个默认值。

这种"查询-默认值"模式是处理映射表查找的标准做法。GENRE_CONFIG[g]在ArkTS中通过方括号语法访问对象属性,当键不存在时返回undefined,在if条件判断中被视为假值(falsy),从而走到默认值分支。三个默认值分别为棕色(#8C5A2B)、浅棕色(#F6E7D4)和唱片图标(📀),保证了即使流派未在配置表中注册,UI也能正常渲染。

5.2 流派筛选函数

function filterByGenre(list: VinylData[], g: string): VinylData[] {
  if (g === '全部') {
    return list;
  }
  const r: VinylData[] = [];
  for (let i = 0; i < list.length; i++) {
    if (list[i].genre === g) {
      r.push(list[i]);
    }
  }
  return r;
}

filterByGenre函数实现了按流派筛选唱片列表的逻辑。当筛选条件为"全部"时,直接返回原数组,不做任何过滤。否则,遍历数组,将流派匹配的项推入新数组r中返回。

这里使用的是传统的for循环而非数组的filter方法。在ArkTS中,虽然支持数组的高阶函数,但使用基础for循环更加直观且性能可靠。函数返回的是一个新数组而非原数组的引用,这保证了原始数据不会被修改,符合函数式编程中的不可变性原则。

5.3 星级文本转换函数

function starText(rating: number): string {
  if (rating >= 5) {
    return '★★★★★';
  }
  if (rating >= 4) {
    return '★★★★☆';
  }
  if (rating >= 3) {
    return '★★★☆☆';
  }
  return '★★☆☆☆';
}

starText函数将数值评分转换为视觉化的星级文本。5分及以上返回五颗实心星,4分返回四实一虚,3分返回三实两虚,2分及以下返回两实三虚。使用Unicode星号字符(★和☆)而非图片来实现星级展示,是一种轻量且跨平台兼容的方案。

getGenreColor

getGenreBg

getGenreIcon

filterByGenre

starText

工具函数调用

函数类型?

查询GENRE_CONFIG

查询GENRE_CONFIG

查询GENRE_CONFIG

g === '全部'?

rating >= 5?

找到配置?

返回配置值

返回默认值

返回完整列表

遍历过滤匹配项

返回 ★★★★★

rating >= 4?

返回 ★★★★☆

rating >= 3?

返回 ★★★☆☆

返回 ★★☆☆☆


六、底部Tab枚举与入口组件

6.1 VaultTab枚举定义

enum VaultTab {
  VAULT = 0,
  MARKET = 1,
  WANTED = 2,
  REVIEW = 3,
  ME = 4
}

VaultTab枚举定义了应用底部的五个功能标签。使用枚举而非魔法数字(magic number)来表示Tab索引,使得代码更加可读且类型安全。在ArkTS中,枚举成员的值可以是数字或字符串,这里使用的是数字枚举,从0到4依次对应仓库、市集、求购、评价、我的五个页面。

枚举(enum)在ArkTS中是一种特殊的数据类型,它为一组有限的命名常量集合提供类型安全。使用枚举而非裸数字,可以避免因手误输入错误数字索引而导致的Bug,同时使得IDE能够提供自动补全和类型检查。当需要新增或调整Tab顺序时,只需修改枚举定义,所有引用处自动更新。

6.2 VinylApp入口组件

@Entry
@Component
struct VinylApp {
  @State activeTab: VaultTab = VaultTab.VAULT

VinylApp是整个应用的入口组件。@Entry装饰器标记该组件为页面入口,一个页面有且仅有一个@Entry组件。@Component装饰器声明该结构体为一个自定义组件,使其可以在其他组件的build方法中被引用。

@State activeTab是组件的核心状态变量,初始值为VaultTab.VAULT(即0),表示应用启动时默认显示仓库页面。当用户点击底部Tab时,activeTab的值会被更新,进而触发依赖该状态的UI重新渲染——contentArea构建器会根据新的activeTab值渲染对应的内容组件。

6.3 contentArea内容区域构建器

@Builder contentArea() {
  Column() {
    if (this.activeTab === VaultTab.VAULT) {
      VaultContent()
    } else if (this.activeTab === VaultTab.MARKET) {
      MarketContent()
    } else if (this.activeTab === VaultTab.WANTED) {
      WantedContent()
    } else if (this.activeTab === VaultTab.REVIEW) {
      ReviewContent()
    } else {
      MeContent()
    }
  }
  .layoutWeight(1)
}

contentArea是一个@Builder方法,负责根据activeTab的当前值渲染对应的内容组件。它使用if-else if-else条件链来判断应该显示哪个Tab的内容。Column容器包裹内容组件,并通过.layoutWeight(1)占据底部Tab栏之外的全部剩余空间。

@Builder装饰器的方法与build方法的区别在于:build是组件的入口构建方法,每个@Component必须有且仅有一个build方法;而@Builder方法是可选的辅助构建方法,用于将复杂的UI逻辑拆分为可复用的代码块。@Builder方法可以在build方法中通过this.methodName()调用,也可以在其他@Builder方法中嵌套调用。

6.4 bottomTabItem底部Tab项构建器

@Builder bottomTabItem(icon: string, label: string, tab: VaultTab) {
  Column() {
    Text(icon)
      .fontSize(20)
      .opacity(this.activeTab === tab ? 1.0 : 0.42)
      .scale({ x: this.activeTab === tab ? 1.16 : 1.0, y: this.activeTab === tab ? 1.16 : 1.0 })
      .animation({ duration: 200, curve: Curve.EaseOut })
    Text(label)
      .fontSize(9)
      .fontColor(this.activeTab === tab ? '#8C5A2B' : '#A89878')
      .fontWeight(this.activeTab === tab ? FontWeight.Bold : FontWeight.Normal)
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  .padding({ top: 6, bottom: 7 })
  .onClick(() => { this.activeTab = tab })
}

bottomTabItem构建器封装了单个底部Tab项的渲染逻辑。它接收三个参数:icon(Emoji图标)、label(文字标签)和tab(对应的枚举值)。通过将Tab项的构建逻辑抽取为@Builder方法,避免了在build方法中重复编写五次几乎相同的代码。

这里的核心交互逻辑是:当this.activeTab === tabtrue时(即当前Tab处于激活状态),图标不透明度为1.0、缩放比例为1.16、文字颜色为棕色(#8C5A2B)且加粗;否则图标不透明度降至0.42、缩放比例为1.0、文字颜色为浅棕色(#A89878)且不加粗。

.animation({ duration: 200, curve: Curve.EaseOut })为这些属性变化添加了200毫秒的缓出动画效果。Curve.EaseOut是ArkTS预定义的动画曲线之一,其特点是动画开始时变化较快、结束时变化较慢,营造出一种"快速响应、平稳落地"的视觉感受。当用户点击Tab切换时,图标会平滑地放大并变得清晰,而非突兀地跳变。

.onClick(() => { this.activeTab = tab })是Tab切换的核心逻辑——点击后更新activeTab状态,触发整个页面的响应式刷新。

6.5 入口组件build方法

build() {
  Column() {
    this.contentArea()
    Row() {
      this.bottomTabItem('🗄️', '仓库', VaultTab.VAULT)
      this.bottomTabItem('🛍️', '市集', VaultTab.MARKET)
      this.bottomTabItem('🙋', '求购', VaultTab.WANTED)
      this.bottomTabItem('⭐', '评价', VaultTab.REVIEW)
      this.bottomTabItem('🎩', '我的', VaultTab.ME)
    }
    .width('100%')
    .backgroundColor('#FFFDF7')
    .padding({ top: 5, bottom: 7 })
    .shadow({ radius: 16, color: '#163E2B18', offsetY: -4 })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5EFE4')
}

build方法构建了应用的整体框架:一个Column容器内包含内容区域和底部Tab栏。内容区域通过this.contentArea()调用@Builder方法渲染,底部Tab栏是一个Row容器,包含五个通过this.bottomTabItem(...)调用的Tab项。

底部Tab栏的.shadow({ radius: 16, color: '#163E2B18', offsetY: -4 })创建了一个向上偏移的阴影效果。offsetY: -4表示阴影在垂直方向上向上偏移4vp,使得阴影出现在Tab栏的上方边缘,营造出Tab栏悬浮于内容区域之上的视觉层次感。阴影颜色#163E2B18中的18是Alpha通道值(十六进制),表示约9.4%的不透明度,确保阴影柔和而不突兀。

整个页面的背景色为#F5EFE4(一种温暖的奶油色调),与底部Tab栏的#FFFDF7(接近纯白的暖色)形成微妙的层次区分。

VinylApp入口组件

状态驱动

状态驱动

VAULT

MARKET

WANTED

REVIEW

ME

activeTab: VaultTab

contentArea构建器

bottomTabItem构建器 x5

activeTab值?

VaultContent

MarketContent

WantedContent

ReviewContent

MeContent

底部Tab栏 Row

Column 内容容器

页面根 Column

width: 100%
height: 100%
backgroundColor: #F5EFE4


七、电商式公共头部组件

7.1 MallHeader组件定义

@Component
struct MallHeader {
  subtitle: string = '';
  onSearchTap: () => void = () => {};
  onCartTap: () => void = () => {};

  build() {
    Column() {

MallHeader是一个可复用的公共头部组件,被仓库页面引用。它定义了三个成员变量:subtitle用于显示副标题文本,onSearchTaponCartTap是两个回调函数,分别用于处理搜索按钮和购物车按钮的点击事件。

这两个回调函数的默认值都是空箭头函数() => {},这是一种防御性编程的实践——即使父组件未传入回调函数,调用时也不会报错。在ArkTS的组件参数传递中,函数类型参数的默认值通常设为空函数,以避免undefined调用异常。

组件间的通信机制是ArkTS组件化架构的核心。在本例中,MallHeader通过常规成员变量接收父组件传入的数据和回调,子组件不直接修改父组件的状态,而是通过回调函数通知父组件发生了什么事件,由父组件决定如何响应。这种"数据向下流动,事件向上冒泡"的单向数据流模式,保证了组件间的松耦合关系。

7.2 头部标题与搜索栏

Row() {
  Column() {
    Text('🗄️ 黑胶仓库')
      .fontSize(15)
      .fontColor('#FFF9EC')
      .fontWeight(FontWeight.Bold)
    Text('VINYL VAULT · 复古唱片市集')
      .fontSize(7)
      .fontColor('#D9BE8C')
      .margin({ top: 2 })
  }
  .alignItems(HorizontalAlign.Start)

  Row() {
    Text('🔍')
      .fontSize(13)
    Text('搜索唱片 / 艺人 / 首版')
      .fontSize(11)
      .fontColor('#B9A57E')
      .margin({ left: 6 })
  }
  .layoutWeight(1)
  .height(34)
  .backgroundColor('#4A3611')
  .borderRadius(17)
  .margin({ left: 14 })
  .onClick(() => { this.onSearchTap() })

  Text('🛒')
    .fontSize(19)
    .margin({ left: 12 })
    .onClick(() => { this.onCartTap() })
}
.width('100%')
.padding({ left: 14, right: 14, top: 12 })

头部第一行使用Row容器水平排列三个元素:标题列、搜索栏和购物车图标。标题列是一个嵌套的Column,包含主标题"🗄️ 黑胶仓库"和副标题"VINYL VAULT · 复古唱片市集"。主标题字号15、加粗、颜色为接近白色的暖色(#FFF9EC),副标题字号仅7、颜色为柔和的金棕色(#D9BE8C),形成清晰的主次层级。

搜索栏是一个Row容器,通过.layoutWeight(1)占据标题和购物车之间的全部剩余空间。其内部包含搜索图标和占位提示文字,背景色为深棕色(#4A3611),圆角为17vp(高度34vp的一半,形成胶囊形状)。点击搜索栏会触发onSearchTap回调。

购物车图标使用Text组件渲染Emoji,点击触发onCartTap回调。这种使用Emoji作为图标的方式虽然简单,但在跨平台兼容性和渲染性能上有一定优势——无需加载图片资源,直接使用系统字体渲染。

7.3 流派标签行

Row() {
  ForEach(GENRE_LIST, (g: string) => {
    Text(g)
      .fontSize(9)
      .fontColor(g === '全部' ? '#3E2B18' : '#EBD9B4')
      .backgroundColor(g === '全部' ? '#C99B3F' : '#5C4620')
      .borderRadius(9)
      .padding({ left: 9, right: 9, top: 4, bottom: 4 })
      .margin({ right: 5 })
  }, (g: string) => 'mh' + g)
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 13 })

头部第二行是流派标签行,使用ForEach遍历GENRE_LIST数组渲染所有流派标签。"全部"标签使用金色背景(#C99B3F)和深色文字(#3E2B18),其他流派标签使用深棕色背景(#5C4620)和浅色文字(#EBD9B4),使得"全部"标签在视觉上突出于其他标签。

键值生成函数(g: string) => 'mh' + g为每个标签生成唯一键值,前缀'mh'(MallHeader的缩写)确保了不同组件中相同流派名称的键值不会冲突。这是ArkTS中ForEach键值命名的常见约定——使用组件缩写作为前缀,避免全局键值冲突。

7.4 头部渐变背景

    .width('100%')
    .linearGradient({
      angle: 135,
      colors: [['#6B4823', 0], ['#3E2B18', 1]]
    })
    .alignItems(HorizontalAlign.Start)
  }

头部容器使用.linearGradient()方法设置线性渐变背景。angle: 135表示渐变方向为从左上角到右下角(135度)。colors数组定义了渐变色标:起始位置(偏移0)为较浅的棕色#6B4823,结束位置(偏移1)为较深的棕色#3E2B18。这种从浅到深的渐变效果为头部增添了立体感,使其不至于显得过于平淡。

.alignItems(HorizontalAlign.Start)将头部内所有子元素在水平方向上左对齐,确保标题、搜索栏等元素从左侧开始排列。


八、Tab1仓库组件——VaultContent

8.1 状态声明与弹窗管理

@Component
struct VaultContent {
  @State showDetailModal: boolean = false
  @State showTradeModal: boolean = false
  @State showAboutModal: boolean = false
  @State selectedVinyl: VinylData | null = null
  @State discAngle: number = 0

VaultContent组件声明了五个状态变量。前三个布尔变量分别控制三个弹窗的显示与隐藏:showDetailModal控制唱片详情弹窗、showTradeModal控制交易记录弹窗、showAboutModal控制仓库说明弹窗。

selectedVinyl的类型是VinylData | null——联合类型,表示它可以是一个VinylData对象或null。初始值为null,表示未选中任何唱片。当用户点击某张唱片卡片时,该变量被赋值为对应的VinylData对象,详情弹窗通过可选链操作符(?.)安全地访问其属性。

discAngle记录唱片图标的旋转角度,初始值为0。每次点击唱片图标时,角度增加60度,配合.rotate({ angle: this.discAngle }).animation()实现旋转动画效果。

在ArkTS中,联合类型Type | null是表示"可能为空值"的标准方式。与undefined不同,null是一个明确的"无值"标记。使用可选链操作符?.可以安全地访问可能为null的对象的属性——当对象为null时,表达式返回undefined而非抛出异常。这在ArkTS中尤其重要,因为UI构建函数在每次状态变化时都会重新执行,必须确保所有属性访问都是安全的。

8.2 modalOverlay遮罩层构建器

@Builder modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(40,26,10,0.72)')
    .onClick(onClose)
}

modalOverlay是一个高度复用的@Builder方法,用于创建弹窗的半透明遮罩层。它接收一个onClose回调函数作为参数,当用户点击遮罩层时触发——这是"点击外部关闭弹窗"的常见交互模式。

遮罩层是一个空的Column组件(没有子组件),通过width('100%')height('100%')占据全屏。背景色使用RGBA格式rgba(40,26,10,0.72),前三个值是深棕色的RGB分量,最后一个值0.72是Alpha透明度,使得遮罩层半透明地覆盖在底层内容之上。

这个构建器在仓库组件中被三个弹窗(详情、交易、说明)共用,体现了@Builder方法的代码复用价值。如果需要在所有页面中使用遮罩层,这个模式会在每个页面组件中重复声明——ArkTS目前不支持跨组件共享@Builder方法(除非通过全局@Builder声明)。

8.3 唱片详情弹窗——vinylDetailModal

@Builder vinylDetailModal() {
  Column() {
    this.modalOverlay(() => { this.showDetailModal = false })
    Column() {
      Row() {
        Column() {
          Text('💿')
            .fontSize(38)
            .rotate({ angle: this.discAngle })
            .animation({ duration: 800, curve: Curve.Linear })
            .onClick(() => { this.discAngle += 60 })
        }
        .width(88)
        .height(88)
        .backgroundColor('#2E2012')
        .borderRadius(12)
        .alignItems(HorizontalAlign.Center)
        .justifyContent(FlexAlign.Center)

唱片详情弹窗是仓库页面最复杂的弹窗,包含旋转唱片、详情信息、价格历史柱状图和操作按钮。弹窗结构为一个外层Column包含遮罩层和内容卡片。

唱片图标被放置在一个88x88vp的深色容器中,背景色为#2E2012(深棕色)。.rotate({ angle: this.discAngle })使图标按照discAngle的值进行旋转。每次点击图标时,this.discAngle += 60使角度增加60度,配合.animation({ duration: 800, curve: Curve.Linear })的800毫秒线性动画,唱片图标会平滑地旋转60度。Curve.Linear表示匀速动画,适合模拟唱片旋转的物理效果。

.justifyContent(FlexAlign.Center).alignItems(HorizontalAlign.Center)共同将唱片图标在容器中居中。FlexAlign.Center是Flex布局的主轴对齐方式之一,表示子组件在主轴方向上居中排列。在这里,由于容器是Column(主轴为垂直方向),justifyContent控制垂直居中,alignItems控制水平居中。

        Column() {
          Text(this.selectedVinyl?.title ?? '')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3E2B18')
            .maxLines(1)
          Text(this.selectedVinyl?.artist ?? '')
            .fontSize(11)
            .fontColor('#8A7A62')
            .margin({ top: 4 })
          Row() {
            Text('¥' + (this.selectedVinyl?.price ?? 0))
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#B45309')
            Text(this.selectedVinyl?.condition ?? '')
              .fontSize(9)
              .fontColor('#0F766E')
              .backgroundColor('#DCEEEA')
              .borderRadius(8)
              .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              .margin({ left: 10 })
          }
          .margin({ top: 8 })
          .alignItems(VerticalAlign.Center)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 14 })

详情信息列通过this.selectedVinyl?.title ?? ''安全访问选中唱片的属性。??是空值合并操作符(Nullish Coalescing Operator),当左侧表达式为nullundefined时返回右侧的默认值。这里使用空字符串作为默认值,确保即使selectedVinyl为null,Text组件也不会渲染"null"或"undefined"字样。

价格使用棕色加粗字体(#B45309),品相标签使用青绿色文字配浅青绿背景(#0F766E/#DCEEEA),形成醒目的视觉对比。.maxLines(1)限制标题为单行显示,超出部分自动截断。

8.4 详情弹窗的统计信息行

Row() {
  Column() {
    Text('' + (this.selectedVinyl?.year ?? 0))
      .fontSize(15)
      .fontWeight(FontWeight.Bold)
      .fontColor('#8C5A2B')
    Text('发行年份')
      .fontSize(9)
      .fontColor('#8A7A62')
      .margin({ top: 2 })
  }
  .layoutWeight(1)
  .alignItems(HorizontalAlign.Center)
  // ...另外两个Column(曲目数、版本评级)
}
.width('100%')
.margin({ top: 16 })
.padding({ top: 12, bottom: 12 })
.backgroundColor('#F8F2E4')
.borderRadius(12)

详情弹窗的第二部分是三列统计信息行,分别显示发行年份、曲目数和版本评级。每个统计项是一个Column,包含数值和标签。三个Column通过.layoutWeight(1)等分水平空间。

值得注意的是年份的显示方式:'' + (this.selectedVinyl?.year ?? 0)。由于yearnumber类型,而Text组件需要string类型的内容,这里通过空字符串拼接的方式将数字转换为字符串。这是JavaScript/TypeScript中常见的类型转换技巧。

版本评级的显示使用了条件表达式:(this.selectedVinyl?.rare ?? false) ? '稀有' : '常规',颜色也随之变化——稀有时为玫红色(#BE185D),常规时为灰色(#8A7A62)。这种基于布尔字段的条件渲染是ArkTS中动态样式的典型应用。

8.5 价格历史迷你柱状图

Row() {
  ForEach([320, 356, 341, 398, 372, 431], (v: number, i: number) => {
    Column() {
      Column()
        .width(18)
        .height((v / 431 * 52).toFixed(0) + 'vp')
        .borderRadius(6)
        .backgroundColor(i === 5 ? '#B45309' : '#E3D2B6')
      Text((i + 1) + '月')
        .fontSize(8)
        .fontColor('#A89878')
        .margin({ top: 4 })
    }
    .alignItems(HorizontalAlign.Center)
    .margin({ left: 12, right: 12 })
  }, (v: number, i: number) => 'ph' + i)
}
.justifyContent(FlexAlign.Center)
.width('100%')
.margin({ top: 10 })

价格历史柱状图使用ForEach遍历一个内联数组[320, 356, 341, 398, 372, 431](6个月的成交均价数据)。每根柱子是一个空的Column组件,宽度固定为18vp,高度通过(v / 431 * 52).toFixed(0) + 'vp'动态计算。其中431是数组中的最大值,52是最大柱高,通过比例计算将数据值映射到像素高度。

.toFixed(0)将计算结果取整为字符串,再拼接'vp'后缀形成有效的尺寸字符串。最后一个柱子(当前月)使用棕色(#B45309)突出显示,其余使用浅棕色(#E3D2B6),帮助用户快速识别最新数据。

在ArkTS中,动态尺寸的计算是一个需要特别注意的技术点。由于布局属性值通常需要字符串类型(如'52vp'),而计算结果是数字类型,必须通过类型转换将两者连接。toFixed(0)方法将数字转为字符串并保留0位小数,再通过+ 'vp'拼接单位后缀,这是在ArkTS中实现动态高度/宽度的标准做法。

8.6 详情弹窗的操作按钮

Row() {
  Text('加入购物车')
    .fontSize(13)
    .fontWeight(FontWeight.Bold)
    .fontColor('#8C5A2B')
    .backgroundColor('#F1E2C4')
    .borderRadius(20)
    .padding({ left: 20, right: 20, top: 10, bottom: 10 })
    .onClick(() => { this.showDetailModal = false })

  Text('立即购买')
    .fontSize(13)
    .fontWeight(FontWeight.Bold)
    .fontColor('#FFF9EC')
    .backgroundColor('#8C5A2B')
    .borderRadius(20)
    .padding({ left: 24, right: 24, top: 10, bottom: 10 })
    .margin({ left: 12 })
    .onClick(() => { this.showDetailModal = false })

  Text('找相似')
    .fontSize(12)
    .fontColor('#8A7A62')
    .backgroundColor('#F8F2E4')
    .borderRadius(20)
    .padding({ left: 18, right: 18, top: 10, bottom: 10 })
    .margin({ left: 12 })
    .onClick(() => { this.showDetailModal = false })
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 18, bottom: 20 })

操作按钮行包含三个按钮:加入购物车、立即购买和找相似。三个按钮通过不同的颜色方案区分主次——"立即购买"使用棕色实底背景(#8C5A2B)和白色文字作为主按钮,"加入购物车"使用浅棕色背景(#F1E2C4)和棕色文字作为次按钮,"找相似"使用更浅的背景(#F8F2E4)和灰色文字作为辅助按钮。

所有按钮的点击事件都将showDetailModal设为false以关闭弹窗。在实际应用中,这里应该触发各自的业务逻辑(如加入购物车、跳转结算页等),但在本静态原型中仅做关闭处理。

.justifyContent(FlexAlign.Center)使三个按钮在水平方向上居中排列。按钮之间的间距通过.margin({ left: 12 })控制,第一个按钮不需要左间距。

8.7 交易记录弹窗——tradeModal

@Builder tradeModal() {
  Column() {
    this.modalOverlay(() => { this.showTradeModal = false })
    Column() {
      Row() {
        Text('📦 近期交易记录')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3E2B18')
          .layoutWeight(1)
        Text('✕')
          .fontSize(15)
          .fontColor('#A89878')
          .onClick(() => { this.showTradeModal = false })
      }
      .width('100%')
      .padding({ left: 18, right: 18, top: 18 })

交易记录弹窗采用底部抽屉式布局。头部行使用Row水平排列标题和关闭按钮,标题通过.layoutWeight(1)占据左侧全部空间,关闭按钮(“✕”)固定在右侧。这种"标题+关闭按钮"的弹窗头部模式在移动端应用中非常普遍。

      Scroll() {
        Column() {
          ForEach(TRADE_LIST, (t: TradeItem) => {
            Row() {
              Column() {
                Text(t.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Medium)
                  .fontColor('#3E2B18')
                Text(t.artist + ' · ' + t.buyer + ' · ' + t.date)
                  .fontSize(9)
                  .fontColor('#8A7A62')
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Column() {
                Text('¥' + t.price)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#B45309')
                Text(t.state)
                  .fontSize(8)
                  .fontColor(t.state === '已取消' ? '#B91C1C' : (t.state === '已完成' ? '#15803D' : '#A16207'))
                  .backgroundColor('#F8F2E4')
                  .borderRadius(8)
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
            .backgroundColor('#FDF8EC')
            .borderRadius(12)
            .margin({ top: 8 })
          }, (t: TradeItem) => 'tr' + t.id)
        }
        .width('100%')
        .padding({ left: 18, right: 18 })
      }
      .constraintSize({ maxHeight: '46%' })
      .margin({ top: 12 })

交易记录列表使用Scroll容器包裹ForEach渲染的交易项。.constraintSize({ maxHeight: '46%' })限制滚动区域的最大高度为屏幕的46%,当交易记录超出此高度时,用户可以滚动查看。constraintSizeheight的区别在于:height是固定高度,而constraintSize是约束范围(可以设置maxHeightminHeight),允许内容在约束范围内自适应。

每条交易记录的状态标签颜色通过嵌套三元表达式动态设置:已取消为红色(#B91C1C),已完成为绿色(#15803D),其他状态(已发货、待付款)为棕色(#A16207)。这种基于状态值的条件着色使得用户可以一眼区分交易的不同状态。

8.8 仓库说明弹窗——aboutModal

@Builder aboutModal() {
  Column() {
    this.modalOverlay(() => { this.showAboutModal = false })
    Column() {
      Text('🗄️')
        .fontSize(38)
        .margin({ top: 22 })
      Text('我的黑胶仓库')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .margin({ top: 12 })
      Text('共收藏 117 张黑胶唱片,其中稀有版本 9 张。\n总估值 ¥28,640,本月新增 3 张。')
        .fontSize(11)
        .fontColor('#8A7A62')
        .textAlign(TextAlign.Center)
        .lineHeight(18)
        .margin({ top: 12 })
      Text('知道了')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFF9EC')
        .backgroundColor('#8C5A2B')
        .borderRadius(20)
        .padding({ left: 34, right: 34, top: 10, bottom: 10 })
        .margin({ top: 20, bottom: 22 })
        .onClick(() => { this.showAboutModal = false })
    }
    .width('78%')
    .backgroundColor('#FFFDF7')
    .borderRadius(20)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '11%', y: '28%' })
  }
  .width('100%')
  .height('100%')
  .position({ x: 0, y: 0 })
  .zIndex(999)
}

仓库说明弹窗采用居中小卡片布局。.position({ x: '11%', y: '28%' })将内容卡片定位到屏幕水平11%、垂直28%的位置,配合78%的宽度实现水平居中效果。文本中的\n是换行符,配合.lineHeight(18)设置行高为18vp,确保多行文本的可读性。.textAlign(TextAlign.Center)使文本居中对齐。

所有弹窗的外层Column都设置了.position({ x: 0, y: 0 }).zIndex(999),确保弹窗覆盖在页面内容之上。zIndex属性控制组件的层叠顺序,值越大越靠上。999是一个足够大的值,确保弹窗不会被其他元素覆盖。

8.9 vaultCard——唱片卡片构建器

@Builder vaultCard(v: VinylData) {
  Column() {
    Row() {
      Text(v.cover)
        .fontSize(26)
      if (v.rare) {
        Text('稀有')
          .fontSize(7)
          .fontColor('#FFF9EC')
          .backgroundColor('#BE185D')
          .borderRadius(6)
          .padding({ left: 5, right: 5, top: 1, bottom: 1 })
          .margin({ left: 5 })
      }
      if (v.sold) {
        Text('已出')
          .fontSize(7)
          .fontColor('#FFF9EC')
          .backgroundColor('#8A7A62')
          .borderRadius(6)
          .padding({ left: 5, right: 5, top: 1, bottom: 1 })
          .margin({ left: 5 })
      }
    }
    .width('100%')

vaultCard是唱片卡片的构建器,接收一个VinylData参数。卡片顶部使用Row容器水平排列封面图标和状态标签。if (v.rare)if (v.sold)是ArkTS中的条件渲染语法——当条件为true时,对应的Text组件才会被渲染到UI中。这是ArkTS声明式UI的核心特性之一:UI结构可以根据数据动态变化。

    Text(v.title)
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor('#3E2B18')
      .margin({ top: 8 })
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })

    Text(v.artist + ' · ' + v.year)
      .fontSize(9)
      .fontColor('#8A7A62')
      .margin({ top: 3 })
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })

唱片标题和艺术家信息都设置了.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })maxLines(1)限制文本最多显示1行,TextOverflow.Ellipsis指定当文本溢出时显示省略号(…)。这种组合确保了长标题不会破坏卡片布局,而是优雅地截断并显示省略号提示。

    Row() {
      Text('¥' + v.price)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#B45309')
      Text(v.condition)
        .fontSize(8)
        .fontColor(getGenreColor(v.genre))
        .backgroundColor(getGenreBg(v.genre))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
    }
    .width('100%')
    .margin({ top: 8 })
    .alignItems(VerticalAlign.Center)
  }
  .width('46%')
  .padding(12)
  .backgroundColor('#FFFDF7')
  .borderRadius(16)
  .alignItems(HorizontalAlign.Start)
  .margin({ left: '2.5%', top: 10 })
  .onClick(() => {
    this.selectedVinyl = v
    this.showDetailModal = true
  })

卡片底部行显示价格和品相标签。品相标签的颜色通过工具函数getGenreColor(v.genre)getGenreBg(v.genre)动态获取——根据唱片的流派返回对应的主题色和背景色,使得不同流派的品相标签具有不同的色彩风格。

卡片宽度设为46%,配合.margin({ left: '2.5%' })实现两列网格布局——每张卡片占屏幕宽度的46%,左侧留2.5%的间距,两张卡片之间的间距为5%(2.5% + 2.5%),两侧边距各2.5%。点击卡片时,将selectedVinyl赋值为当前唱片数据,并打开详情弹窗。

8.10 仓库页面build方法

build() {
  Stack() {
    Column() {
      MallHeader({
        subtitle: '仓库收藏 117 张 · 本月新增 3 张',
        onSearchTap: () => { this.showTradeModal = true },
        onCartTap: () => { this.showAboutModal = true }
      })

仓库页面的build方法使用Stack作为根容器。Stack是层叠布局容器,允许子组件堆叠排列。在这里,Stack用于将主内容层和弹窗层堆叠在一起——弹窗通过zIndex(999)覆盖在主内容之上。

MallHeader组件被传入两个回调函数:onSearchTap点击搜索栏时打开交易记录弹窗,onCartTap点击购物车图标时打开仓库说明弹窗。这种通过回调函数连接子组件事件与父组件状态的方式,是ArkTS组件间通信的标准模式。

      Scroll() {
        Column() {
          Row() {
            Column() {
              Text('117')
                .fontSize(19)
                .fontWeight(FontWeight.Bold)
                .fontColor('#8C5A2B')
              Text('唱片总数')
                .fontSize(9)
                .fontColor('#8A7A62')
                .margin({ top: 3 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            // ...另外三个统计列
          }
          .width('100%')
          .padding({ top: 14, bottom: 14 })
          .backgroundColor('#FFFDF7')
          .borderRadius(16)
          .margin({ top: 12, left: 14, right: 14 })

仓库总览条使用Row水平排列四个统计项:唱片总数(117)、收藏总值(¥28,640)、稀有版本(9)和近期交易(8单)。每个统计项是一个Column,包含数值和标签。四个Column通过.layoutWeight(1)等分宽度。第四个统计项(交易)可点击,点击后打开交易记录弹窗。

8.11 年代收藏构成柱状图

          Column() {
            Text('📊 年代收藏构成')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3E2B18')
              .width('100%')

            Row() {
              ForEach(DECADE_SHARE, (d: DecadeEntry) => {
                Column() {
                  Text('' + d.count)
                    .fontSize(8)
                    .fontColor(d.color)
                  Column()
                    .width(20)
                    .height((d.count / 32 * 72).toFixed(0) + 'vp')
                    .borderRadius(6)
                    .backgroundColor(d.color)
                    .margin({ top: 3 })
                  Text(d.decade)
                    .fontSize(8)
                    .fontColor('#8A7A62')
                    .margin({ top: 5 })
                }
                .alignItems(HorizontalAlign.Center)
                .margin({ left: 10, right: 10 })
              }, (d: DecadeEntry) => 'dc' + d.decade)
            }
            .justifyContent(FlexAlign.Center)
            .width('100%')
            .margin({ top: 12 })

            Text('70年代唱片最多(32张)· 60年代稀有盘占比最高')
              .fontSize(9)
              .fontColor('#A89878')
              .margin({ top: 10 })
          }
          .width('100%')
          .padding(16)
          .backgroundColor('#FFFDF7')
          .borderRadius(16)
          .margin({ top: 12, left: 14, right: 14 })
          .alignItems(HorizontalAlign.Start)

年代收藏构成柱状图使用ForEach遍历DECADE_SHARE数组渲染六根柱子。每根柱子的颜色直接取自数据对象的color字段,高度通过(d.count / 32 * 72).toFixed(0) + 'vp'按比例计算。柱子顶部显示数量值,底部显示年代标签,形成完整的柱状图结构。

8.12 Flex布局与卡片网格

            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(mockVinyls, (v: VinylData, i: number) => {
                if (i < 8) {
                  this.vaultCard(v)
                }
              }, (v: VinylData) => 'vc' + v.id)
            }
            .width('100%')

本月上新区域使用Flex容器配合FlexWrap.Wrap实现自动换行布局。FlexWrap.Wrap是Flex布局的换行模式,当子组件的总宽度超过容器宽度时,自动换到下一行。每张卡片宽度为46%(在vaultCard中设置),因此每行容纳两张卡片,多余的自动换行。

ForEach中使用了条件渲染if (i < 8)——仅渲染前8张唱片作为"本月上新"的展示。这种在ForEach内部使用条件过滤的方式,既保持了数据源的完整性,又实现了数量的限制。

Flex布局是ArkTS中最灵活的布局容器之一。与RowColumn不同,Flex支持wrap属性实现自动换行,适合实现网格、瀑布流等不规则排列场景。在本应用中,Flex({ wrap: FlexWrap.Wrap })配合卡片宽度和左间距的百分比设置,实现了简洁的两列网格布局,无需使用Grid容器。

8.13 弹窗的条件渲染

      if (this.showDetailModal) {
        this.vinylDetailModal()
      }
      if (this.showTradeModal) {
        this.tradeModal()
      }
      if (this.showAboutModal) {
        this.aboutModal()
      }
    }
    .width('100%')
    .height('100%')
  }

Stack容器的末尾,三个弹窗通过条件渲染控制显示。当showDetailModaltrue时,调用this.vinylDetailModal()构建详情弹窗;同理控制交易弹窗和说明弹窗。由于这些弹窗位于Stack内部且设置了zIndex(999),它们会覆盖在主内容层之上。

这种"条件渲染弹窗"的模式是ArkTS中实现模态对话框的标准方式。与某些框架提供的Modal组件不同,ArkTS通过状态变量驱动的条件渲染来控制弹窗的显示与隐藏,使得弹窗的状态管理与UI渲染保持统一的响应式模型。

true

false

true

false

true

false

VaultContent build

Stack 根容器

Column 主内容层

showDetailModal?

showTradeModal?

showAboutModal?

MallHeader 公共头部

Scroll 滚动区域

仓库总览条 - 4列统计

年代收藏柱状图

本月上新 - Flex 8张卡片

全部收藏 - Flex 全部卡片

vinylDetailModal 详情弹窗

不渲染

tradeModal 交易弹窗

aboutModal 说明弹窗

zIndex: 999 覆盖层


九、Tab2市集组件——MarketContent

9.1 状态声明

@Component
struct MarketContent {
  @State genreFilter: string = '全部'
  @State showBuyModal: boolean = false
  @State showSellModal: boolean = false
  @State showPriceModal: boolean = false
  @State selectedVinyl: VinylData | null = null
  @State sellTitle: string = ''
  @State sellArtist: string = ''
  @State sellPrice: string = ''
  @State sellCondition: number = 1
  @State sellGenre: number = 0
  @State offerPrice: number = 380

MarketContent组件声明了11个状态变量,是所有页面组件中状态最多的。genreFilter记录当前选中的流派筛选条件,初始值为"全部"。sellTitlesellArtistsellPrice分别存储出售表单中用户输入的唱片名称、艺人和售价。sellConditionsellGenre以数字索引的形式记录用户在表单中选择的品相和流派。offerPrice存储出价弹窗中的当前出价金额。

值得注意的是sellTitle等表单字段使用string类型而非number类型,即使sellPrice在语义上是一个数字。这是因为TextInput组件的onChange回调返回的是字符串类型,直接存储字符串可以避免不必要的类型转换。在实际提交表单时,再通过parseInt()parseFloat()将字符串转为数字。

9.2 确认购买弹窗——buyModal

@Builder buyModal() {
  Column() {
    this.modalOverlay(() => { this.showBuyModal = false })
    Column() {
      Row() {
        Text('🧾 确认购买')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3E2B18')
          .layoutWeight(1)
        Text('✕')
          .fontSize(15)
          .fontColor('#A89878')
          .onClick(() => { this.showBuyModal = false })
      }
      .width('100%')
      .padding({ left: 18, right: 18, top: 18 })

确认购买弹窗采用底部抽屉式布局,与交易记录弹窗类似。头部包含标题和关闭按钮,标题通过.layoutWeight(1)占据左侧空间。

      Row() {
        Text(this.selectedVinyl?.cover ?? '💿')
          .fontSize(30)
          .width(56)
          .height(56)
          .backgroundColor('#2E2012')
          .borderRadius(12)
          .textAlign(TextAlign.Center)

        Column() {
          Text(this.selectedVinyl?.title ?? '')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3E2B18')
          Text((this.selectedVinyl?.artist ?? '') + ' · ' + (this.selectedVinyl?.condition ?? ''))
            .fontSize(9)
            .fontColor('#8A7A62')
            .margin({ top: 4 })
          Text('卖家:' + (this.selectedVinyl?.seller ?? '') + ' · 好评率 98%')
            .fontSize(9)
            .fontColor('#8A7A62')
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
      .padding({ left: 18, right: 18, top: 16 })

商品信息行使用Row水平排列封面图标和信息列。封面图标被放置在56x56vp的深色容器中,通过.textAlign(TextAlign.Center)使Emoji在容器中居中。信息列包含标题、艺人/品相和卖家信息,通过.layoutWeight(1)占据图标右侧的全部剩余空间。

9.3 购买弹窗的费用明细

      Column() {
        Row() {
          Text('唱片单价')
            .fontSize(11)
            .fontColor('#8A7A62')
          Text('¥' + (this.selectedVinyl?.price ?? 0))
            .fontSize(11)
            .fontColor('#3E2B18')
            .margin({ left: 12 })
        }
        .width('100%')
        .margin({ top: 10 })

        Row() {
          Text('顺丰保价运费')
            .fontSize(11)
            .fontColor('#8A7A62')
          Text('¥18')
            .fontSize(11)
            .fontColor('#3E2B18')
            .margin({ left: 12 })
        }
        .width('100%')
        .margin({ top: 8 })

        Row() {
          Text('老友优惠')
            .fontSize(11)
            .fontColor('#8A7A62')
          Text('-¥20')
            .fontSize(11)
            .fontColor('#B45309')
            .margin({ left: 12 })
        }
        .width('100%')
        .margin({ top: 8 })

        Divider()
          .color('#EFE3C8')
          .margin({ top: 10 })

        Row() {
          Text('合计')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3E2B18')
          Text('¥' + ((this.selectedVinyl?.price ?? 0) + 18 - 20))
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#B45309')
            .margin({ left: 12 })
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .width('100%')
      .padding(14)
      .backgroundColor('#F8F2E4')
      .borderRadius(14)
      .margin({ top: 16 })

费用明细区域是一个嵌套的Column容器,包含四行费用明细和一个合计行。每行使用Row水平排列费用名称和金额,金额通过.margin({ left: 12 })与名称保持间距。Divider组件在费用明细和合计之间添加了一条分隔线。

合计金额通过算术表达式((this.selectedVinyl?.price ?? 0) + 18 - 20)动态计算——唱片单价加上18元运费减去20元老友优惠。这种在UI模板中直接进行计算的方式在ArkTS中是合法的,但对于复杂的计算逻辑,建议抽取为独立的方法以提高可读性。

9.4 购买弹窗的底部按钮

      Row() {
        Text('再想想')
          .fontSize(13)
          .fontColor('#8A7A62')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#F1E2C4')
          .borderRadius(22)
          .onClick(() => { this.showBuyModal = false })

        Text('提交订单')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFF9EC')
          .layoutWeight(1.6)
          .textAlign(TextAlign.Center)
          .padding({ top: 12, bottom: 12 })
          .backgroundColor('#8C5A2B')
          .borderRadius(22)
          .margin({ left: 12 })
          .scale({ x: 1.0, y: 1.0 })
          .animation({ duration: 150, curve: Curve.EaseOut })
          .onClick(() => { this.showBuyModal = false })
      }
      .width('100%')
      .padding({ left: 18, right: 18, top: 20, bottom: 24 })

底部按钮行使用layoutWeight的非等分分配——"再想想"按钮权重为1,"提交订单"按钮权重为1.6。这意味着"提交订单"按钮占据约61.5%的宽度(1.6 / (1 + 1.6)),"再想想"按钮占据约38.5%的宽度。这种非等分分配使得主操作按钮更加突出,符合电商应用中"主操作优先"的设计原则。

9.5 上架出售弹窗——sellModal

@Builder sellModal() {
  Column() {
    this.modalOverlay(() => { this.showSellModal = false })
    Column() {
      Text('🏷️ 上架出售唱片')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .width('100%')
        .padding({ top: 18, left: 18 })

      Row() {
        Text('唱片名称')
          .fontSize(10)
          .fontColor('#8A7A62')
        Text('必填')
          .fontSize(8)
          .fontColor('#B91C1C')
          .margin({ left: 8 })
      }
      .width('100%')
      .padding({ left: 18, top: 16 })

      TextInput({ placeholder: '例如:蓝色列车' })
        .fontSize(12)
        .height(38)
        .backgroundColor('#F8F2E4')
        .borderRadius(12)
        .padding({ left: 12 })
        .margin({ left: 18, right: 18, top: 6 })
        .onChange((v: string) => { this.sellTitle = v })

上架出售弹窗是一个多字段表单,包含唱片名称、艺人、售价、品相分级和流派选择等字段。每个字段由标签和输入控件组成。标签行使用Row水平排列字段名称和必填提示("必填"以红色小字显示)。

TextInput组件是ArkTS提供的文本输入控件,通过placeholder参数设置占位提示文字。.onChange((v: string) => { this.sellTitle = v })监听输入变化,将输入值同步到状态变量。这种"输入即更新状态"的模式是ArkTS表单处理的标准方式——状态变量作为表单数据的唯一数据源,UI输入直接驱动状态更新。

9.6 出售表单的品相选择器

      Row() {
        ForEach(CONDITION_LIST, (c: string, i: number) => {
          Text(c)
            .fontSize(9)
            .fontColor(this.sellCondition === i ? '#FFF9EC' : '#8A7A62')
            .backgroundColor(this.sellCondition === i ? '#8C5A2B' : '#F1E2C4')
            .borderRadius(10)
            .padding({ left: 8, right: 8, top: 5, bottom: 5 })
            .margin({ right: 6 })
            .onClick(() => { this.sellCondition = i })
        }, (c: string, i: number) => 'mc' + i)
      }
      .width('100%')
      .padding({ left: 18, top: 6 })

品相选择器使用ForEach遍历CONDITION_LIST数组渲染四个品相选项。选中的品相通过this.sellCondition === i判断,选中时使用棕色背景(#8C5A2B)和白色文字,未选中时使用浅棕色背景(#F1E2C4)和灰色文字。点击选项时更新sellCondition为对应的索引值。

这种"标签式单选"的交互模式在移动端表单中非常常见——相比下拉选择器(Select),标签式选择器将所有选项平铺展示,用户一目了然,点击即选,操作效率更高。

9.7 流派选择器的水平滚动

      Scroll() {
        Row() {
          ForEach(GENRE_LIST, (g: string, i: number) => {
            Text(g)
              .fontSize(9)
              .fontColor(this.sellGenre === i ? '#FFF9EC' : '#8A7A62')
              .backgroundColor(this.sellGenre === i ? '#8C5A2B' : '#F1E2C4')
              .borderRadius(10)
              .padding({ left: 9, right: 9, top: 5, bottom: 5 })
              .margin({ right: 6 })
              .onClick(() => { this.sellGenre = i })
          }, (g: string, i: number) => 'mg' + i)
        }
        .padding({ left: 18 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ top: 6 })

流派选择器由于选项较多(9个流派),无法在一行内全部展示,因此使用Scroll容器包裹Row,并通过.scrollable(ScrollDirection.Horizontal)启用水平滚动。.scrollBar(BarState.Off)隐藏滚动条,保持界面的整洁。用户可以左右滑动浏览所有流派选项,选中的流派通过sellGenre索引判断并高亮显示。

ScrollDirection.Horizontal是ArkTS中Scroll组件的滚动方向枚举值之一。默认情况下Scroll是垂直滚动的,设置scrollable(ScrollDirection.Horizontal)后变为水平滚动。配合scrollBar(BarState.Off)隐藏滚动条,可以实现流畅的水平滑动选择器效果。

9.8 出价议价弹窗——offerModal

@Builder offerModal() {
  Column() {
    this.modalOverlay(() => { this.showPriceModal = false })
    Column() {
      Text('💰 向卖家出价')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .width('100%')
        .padding({ top: 20, left: 18 })

      Row() {
        Text('−')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#8C5A2B')
          .width(40)
          .height(40)
          .backgroundColor('#F1E2C4')
          .borderRadius(20)
          .textAlign(TextAlign.Center)
          .onClick(() => {
            if (this.offerPrice > 50) {
              this.offerPrice -= 20
            }
          })

        Text('¥' + this.offerPrice)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
          .fontColor('#B45309')
          .layoutWeight(1)
          .textAlign(TextAlign.Center)

        Text('+')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#8C5A2B')
          .width(40)
          .height(40)
          .backgroundColor('#F1E2C4')
          .borderRadius(20)
          .textAlign(TextAlign.Center)
          .onClick(() => { this.offerPrice += 20 })
      }
      .width('100%')
      .margin({ top: 20 })
      .padding({ left: 18, right: 18 })
      .alignItems(VerticalAlign.Center)

出价弹窗的核心是一个价格步进器(Stepper),由减号按钮、当前价格和加号按钮三部分组成。减号按钮的点击逻辑包含边界检查:if (this.offerPrice > 50)确保出价不会低于50元。加号按钮无上限检查,每次点击增加20元。

步进器的设计模式在电商应用中广泛使用——用户无需手动输入价格,通过点击加/减按钮即可快速调整。每次步进20元的设计兼顾了操作效率和价格精度。当前出价以24号大字加粗棕色字体居中显示,视觉上非常突出。

9.9 marketRow——市集列表项构建器

@Builder marketRow(v: VinylData) {
  Row() {
    Column() {
      Text(v.cover)
        .fontSize(26)
    }
    .width(52)
    .height(52)
    .backgroundColor(getGenreBg(v.genre))
    .borderRadius(12)
    .alignItems(HorizontalAlign.Center)
    .justifyContent(FlexAlign.Center)

    Column() {
      Row() {
        Text(v.title)
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3E2B18')
          .maxLines(1)
        if (v.rare) {
          Text('稀有')
            .fontSize(7)
            .fontColor('#FFF9EC')
            .backgroundColor('#BE185D')
            .borderRadius(6)
            .padding({ left: 5, right: 5, top: 1, bottom: 1 })
            .margin({ left: 5 })
        }
      }
      .alignItems(VerticalAlign.Center)

marketRow是市集列表项的构建器,采用横向布局——左侧封面图标、中间信息区、右侧操作按钮。封面图标容器使用流派背景色getGenreBg(v.genre)动态着色,使得不同流派的唱片在列表中有不同的视觉标识。

信息区的标题行使用嵌套Row,当v.raretrue时在标题右侧追加"稀有"标签。.maxLines(1)确保标题单行显示。.alignItems(VerticalAlign.Center)使标题和稀有标签在垂直方向上居中对齐。

      Column() {
        Text('购买')
          .fontSize(11)
          .fontColor('#FFF9EC')
          .backgroundColor('#8C5A2B')
          .borderRadius(14)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .onClick(() => {
            this.selectedVinyl = v
            this.showBuyModal = true
          })
        Text('出价')
          .fontSize(11)
          .fontColor('#B45309')
          .backgroundColor('#F6E7D4')
          .borderRadius(14)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .margin({ top: 6 })
          .onClick(() => {
            this.selectedVinyl = v
            this.showPriceModal = true
          })
        }
        .alignItems(HorizontalAlign.Center)

操作按钮区包含"购买"和"出价"两个按钮。点击购买按钮时,先将当前唱片数据赋值给selectedVinyl,再打开购买弹窗。点击出价按钮时,同样先赋值selectedVinyl,再打开出价弹窗。这种"先保存选中数据再打开弹窗"的模式确保了弹窗能够访问到正确的唱片信息。

9.10 市集页面的流派筛选与列表渲染

build() {
  Stack() {
    Column() {
      Row() {
        Text('🛍️ 黑胶市集')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3E2B18')
          .layoutWeight(1)
        Text('+ 上架出售')
          .fontSize(11)
          .fontColor('#FFF9EC')
          .backgroundColor('#8C5A2B')
          .borderRadius(16)
          .padding({ left: 14, right: 14, top: 7, bottom: 7 })
          .onClick(() => { this.showSellModal = true })
      }
      .width('100%')
      .padding({ left: 14, right: 14, top: 14 })

市集页面的头部与仓库页面不同——它不使用MallHeader公共组件,而是自建了一个包含标题和"上架出售"按钮的头部行。这种设计差异体现了不同页面的功能侧重:仓库页面侧重收藏查看,市集页面侧重交易操作。

      Scroll() {
        Row() {
          ForEach(GENRE_LIST, (g: string) => {
            Text(g)
              .fontSize(10)
              .fontColor(this.genreFilter === g ? '#FFF9EC' : '#3E2B18')
              .backgroundColor(this.genreFilter === g ? '#8C5A2B' : '#F1E2C4')
              .borderRadius(15)
              .padding({ left: 13, right: 13, top: 7, bottom: 7 })
              .margin({ left: 6 })
              .onClick(() => { this.genreFilter = g })
          }, (g: string) => 'mf' + g)
        }
        .padding({ left: 8, right: 8 })
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .margin({ top: 8 })

流派筛选条使用水平滚动的Scroll容器,与出售表单中的流派选择器类似。区别在于这里的选中状态通过this.genreFilter === g判断(字符串比较),而表单中通过索引比较。点击标签时更新genreFilter,触发下方列表的重新筛选和渲染。

            Text('今日上架 ' + filterByGenre(mockVinyls, this.genreFilter).length + ' 张 · 全部顺丰保价')
              .fontSize(10)
              .fontColor('#A89878')
              .width('100%')
              .padding({ left: 14, top: 14 })

            Column() {
              ForEach(filterByGenre(mockVinyls, this.genreFilter), (v: VinylData) => {
                this.marketRow(v)
              }, (v: VinylData) => 'mk' + v.id)
            }
            .width('100%')
            .padding({ left: 14, right: 14 })

列表区域通过filterByGenre(mockVinyls, this.genreFilter)获取筛选后的数据,再使用ForEach渲染。当genreFilter变化时(如从"全部"切换到"爵士"),filterByGenre函数返回不同的数据集,ForEach通过键值比较('mk' + v.id)判断哪些项需要新增或删除,完成列表的响应式更新。

筛选结果的计数通过filterByGenre(...).length动态显示在列表上方的提示文字中,用户可以实时了解当前筛选结果的数量。

genreFilter状态

触发重渲染

genreFilter=全部

genreFilter=爵士

true

true

true

MarketContent build

Stack 根容器

Column 主内容层

showBuyModal?

showSellModal?

showPriceModal?

头部行: 标题+上架按钮

流派筛选条 - 水平Scroll

内容Scroll

点击切换流派

流派均价柱状图

筛选结果计数文字

市集列表 ForEach

filterByGenre函数

返回全部24张

返回爵士流派唱片

marketRow构建器 x N

点击购买 → buyModal

点击出价 → offerModal

buyModal 购买弹窗

sellModal 出售弹窗

offerModal 出价弹窗


十、Tab3求购组件——WantedContent

10.1 状态声明与弹窗体系

@Component
struct WantedContent {
  @State showPublishModal: boolean = false
  @State showEditModal: boolean = false
  @State showDeleteModal: boolean = false
  @State showOfferListModal: boolean = false
  @State selectedWanted: WantedData | null = null
  @State pubTitle: string = ''
  @State pubArtist: string = ''
  @State pubBudget: string = ''
  @State editBudget: number = 450

WantedContent组件管理着4个弹窗的显示状态和表单数据。selectedWanted存储当前操作的求购项数据,类型为WantedData | nullpubTitlepubArtistpubBudget分别存储发布求购表单的输入值。editBudget存储编辑预算弹窗中的当前预算值,初始值为450,步进单位为50。

与市集页面的出价步进器(步进20)不同,求购预算的步进单位为50——因为求购预算通常金额更大,50元的步进幅度更加合理。减号按钮的边界检查为if (this.editBudget > 100),确保预算不低于100元。

10.2 发布求购弹窗——publishModal

@Builder publishModal() {
  Column() {
    this.modalOverlay(() => { this.showPublishModal = false })
    Column() {
      Text('🙋 发布求购')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .width('100%')
        .padding({ top: 18, left: 18 })

      Text('想找的唱片')
        .fontSize(10)
        .fontColor('#8A7A62')
        .width('100%')
        .padding({ left: 18, top: 16 })

      TextInput({ placeholder: '例如:Kind of Blue 首版' })
        .fontSize(12)
        .height(38)
        .backgroundColor('#F8F2E4')
        .borderRadius(12)
        .padding({ left: 12 })
        .margin({ left: 18, right: 18, top: 6 })
        .onChange((v: string) => { this.pubTitle = v })

发布求购弹窗的表单结构与出售弹窗类似,包含唱片名称、艺人和心理价位三个输入字段。每个字段由标签和TextInput组成,通过.onChange回调将输入值同步到状态变量。

表单底部有一个有效期提示文本:“有效期默认 30 天,可随时手动结束求购”。这种静态提示信息为用户提供了操作预期,减少了不确定性。

10.3 编辑预算弹窗——editModal

@Builder editModal() {
  Column() {
    this.modalOverlay(() => { this.showEditModal = false })
    Column() {
      Text('✏️ 调整求购预算')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .width('100%')
        .padding({ top: 20, left: 18 })

      Text(this.selectedWanted?.title ?? '')
        .fontSize(11)
        .fontColor('#8A7A62')
        .width('100%')
        .padding({ left: 18, top: 6 })

      Row() {
        Text('−')
          .onClick(() => {
            if (this.editBudget > 100) {
              this.editBudget -= 50
            }
          })
        // ...中间价格显示
        Text('+')
          .onClick(() => { this.editBudget += 50 })
      }

编辑预算弹窗的核心是步进器,与市集出价弹窗的步进器结构一致。区别在于步进幅度(50 vs 20)和下限检查(100 vs 50)。弹窗底部有一段提示文字,告知用户"调整预算后会重新通知匹配卖家",这种信息透明的设计增强了用户信任感。

10.4 删除求购确认弹窗——deleteModal

@Builder deleteModal() {
  Column() {
    this.modalOverlay(() => { this.showDeleteModal = false })
    Column() {
      Text('🗑️')
        .fontSize(38)
        .margin({ top: 24 })

      Text('结束这条求购?')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .margin({ top: 14 })

      Text('《' + (this.selectedWanted?.title ?? '') + '》的求购将被撤销,已收到的报价也会一并清除,此操作不可恢复。')
        .fontSize(11)
        .fontColor('#8A7A62')
        .textAlign(TextAlign.Center)
        .lineHeight(18)
        .margin({ top: 12, left: 24, right: 24 })

删除求购确认弹窗采用了"危险操作确认"的设计模式。弹窗以一个大的垃圾桶图标开头,配合"结束这条求购?"的标题和详细的风险说明文本,明确告知用户操作的后果。确认按钮使用红色背景(#B91C1C),与取消按钮的浅色背景形成强烈对比,使用户在点击前三思。

在交互设计中,"危险操作确认"是一个重要的设计模式。对于不可逆的操作(如删除数据、撤销请求),应用应当提供明确的视觉警示和操作确认,防止用户误操作导致数据丢失。在本例中,红色确认按钮、风险说明文本和垃圾桶图标共同构成了一个完整的危险操作确认流程。

10.5 报价列表弹窗——offerListModal

@Builder offerListModal() {
  Column() {
    this.modalOverlay(() => { this.showOfferListModal = false })
    Column() {
      Row() {
        Text('📨 收到的报价')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3E2B18')
          .layoutWeight(1)
        Text('✕')
          .fontSize(15)
          .fontColor('#A89878')
          .onClick(() => { this.showOfferListModal = false })
      }
      .width('100%')
      .padding({ left: 18, right: 18, top: 18 })

      Scroll() {
        Column() {
          ForEach(mockVinyls, (v: VinylData, i: number) => {
            if (i < 6) {
              Row() {
                Text(v.cover)
                  .fontSize(22)
                  .width(40)
                  .height(40)
                  .backgroundColor(getGenreBg(v.genre))
                  .borderRadius(10)
                  .textAlign(TextAlign.Center)

                Column() {
                  Text(v.seller)
                    .fontSize(12)
                    .fontWeight(FontWeight.Medium)
                    .fontColor('#3E2B18')
                  Text(v.condition + ' · ' + v.genre)
                    .fontSize(9)
                    .fontColor('#8A7A62')
                    .margin({ top: 3 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 10 })

                Column() {
                  Text('¥' + (v.price - 30))
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#B45309')
                  Text('接受')
                    .fontSize(10)
                    .fontColor('#FFF9EC')
                    .backgroundColor('#8C5A2B')
                    .borderRadius(12)
                    .padding({ left: 12, right: 12, top: 4, bottom: 4 })
                    .margin({ top: 6 })
                    .onClick(() => { this.showOfferListModal = false })
                }
                .alignItems(HorizontalAlign.End)
              }
              .width('100%')
              .padding(12)
              .backgroundColor('#FDF8EC')
              .borderRadius(12)
              .margin({ top: 8 })
            }
          }, (v: VinylData) => 'of' + v.id)
        }
        .width('100%')
        .padding({ left: 18, right: 18, bottom: 12 })
      }
      .constraintSize({ maxHeight: '44%' })
      .margin({ top: 10 })

报价列表弹窗复用了mockVinyls数据来模拟卖家报价。每条报价显示卖家头像(使用唱片的cover图标)、卖家名称、品相和流派信息,以及出价金额(比原价低30元,模拟议价场景)。用户可以点击"接受"按钮接受报价。

10.6 wantedCard——求购卡片构建器

@Builder wantedCard(w: WantedData) {
  Column() {
    Row() {
      Column() {
        Text('🙋')
          .fontSize(20)
      }
      .width(44)
      .height(44)
      .backgroundColor('#F6E7D4')
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center)
      .justifyContent(FlexAlign.Center)

      Column() {
        Row() {
          Text(w.title)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3E2B18')
            .maxLines(1)
          if (w.urgent) {
            Text('急')
              .fontSize(7)
              .fontColor('#FFF9EC')
              .backgroundColor('#B91C1C')
              .borderRadius(6)
              .padding({ left: 5, right: 5, top: 1, bottom: 1 })
              .margin({ left: 6 })
          }
        }
        .alignItems(VerticalAlign.Center)

        Text(w.artist)
          .fontSize(9)
          .fontColor('#8A7A62')
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 12 })

      Column() {
        Text('¥' + w.budget)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#B45309')
        Text('心理价位')
          .fontSize(8)
          .fontColor('#A89878')
          .margin({ top: 2 })
      }
      .alignItems(HorizontalAlign.End)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

求购卡片采用三栏布局:左侧图标、中间信息、右侧预算。左侧图标容器使用浅棕色背景(#F6E7D4),内含一个"🙋"Emoji。中间信息列包含标题行(带可选的"急"标签)和艺人名称。右侧显示心理价位。

if (w.urgent)条件渲染紧急标签——当urgenttrue时,标题右侧显示红色"急"标签。标签的margin({ left: 6 })使其与标题保持6vp的间距。

    Row() {
      Text(w.status + ' · ' + w.deadline)
        .fontSize(9)
        .fontColor(w.status === '即将截止' ? '#B91C1C' : '#8A7A62')
      Text('📨 ' + w.offers + ' 个报价')
        .fontSize(9)
        .fontColor('#0F766E')
        .margin({ left: 12 })
    }
    .width('100%')
    .margin({ top: 10 })

    Row() {
      Text('查看报价')
        .onClick(() => {
          this.selectedWanted = w
          this.showOfferListModal = true
        })

      Text('调整预算')
        .margin({ left: 10 })
        .onClick(() => {
          this.selectedWanted = w
          this.editBudget = w.budget
          this.showEditModal = true
        })

      Text('结束')
        .margin({ left: 10 })
        .onClick(() => {
          this.selectedWanted = w
          this.showDeleteModal = true
        })
    }
    .width('100%')
    .margin({ top: 12 })
  }

卡片第二行显示状态和报价数。状态文字的颜色通过w.status === '即将截止' ? '#B91C1C' : '#8A7A62'动态设置——即将截止时为红色,其他状态为灰色。报价数使用青绿色(#0F766E)显示,与状态文字形成色彩区分。

卡片底部是三个操作按钮:查看报价、调整预算和结束。每个按钮点击时都会先将当前求购数据保存到selectedWanted,然后打开对应的弹窗。调整预算按钮还额外执行了this.editBudget = w.budget,将当前预算值同步到编辑弹窗的步进器初始值。

10.7 求购统计条

        Row() {
          Column() {
            Text('12')
              .fontSize(17)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8C5A2B')
            Text('进行中')
              .fontSize(9)
              .fontColor('#8A7A62')
              .margin({ top: 3 })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          // ...另外三个统计列:收到报价(45)、已完成(8)、成交率(62%)
        }
        .width('100%')
        .padding({ top: 12, bottom: 12 })
        .backgroundColor('#FFFDF7')
        .borderRadius(16)
        .margin({ top: 12, left: 14, right: 14 })

求购统计条与仓库统计条的布局结构完全一致——四列等分统计项。四个统计项分别是:进行中(12)、收到报价(45)、已完成(8)和成交率(62%)。每个数值使用不同的颜色(棕色、深棕色、青绿色、玫红色)以便快速区分。


十一、Tab4评价组件——ReviewContent

11.1 状态声明与点赞逻辑

@Component
struct ReviewContent {
  @State reviews: ReviewData[] = mockReviews
  @State showWriteModal: boolean = false
  @State showDetailModal: boolean = false
  @State selectedReview: ReviewData | null = null
  @State writeRating: number = 5
  @State writeText: string = ''

  toggleLike(id: number) {
    for (let i = 0; i < this.reviews.length; i++) {
      if (this.reviews[i].id === id) {
        if (this.reviews[i].liked) {
          this.reviews[i].likes -= 1;
          this.reviews[i].liked = false;
        } else {
          this.reviews[i].likes += 1;
          this.reviews[i].liked = true;
        }
      }
    }
    this.reviews = this.reviews.slice();
  }

ReviewContent组件的状态变量中,reviews直接初始化为mockReviews数组——这意味着评价列表的数据源是组件内部的@State变量,可以在运行时被修改。

toggleLike方法是评价组件的核心业务逻辑。它接收一个评价ID参数,遍历reviews数组找到对应评价,切换其liked状态并增减likes计数。方法的最后一行this.reviews = this.reviews.slice()是关键——slice()方法创建数组的一个浅拷贝,将其重新赋值给this.reviews。这个操作虽然看似多余(因为数组内容并未改变),但它触发了ArkTS的响应式更新机制:当@State变量被重新赋值时(即使值的内容相同),框架会检测到引用变化并触发UI重新渲染。

在ArkTS中,直接修改数组元素的属性(如this.reviews[i].likes += 1)不会触发UI更新,因为@State监听的是变量引用的变化,而非对象内部属性的变化。通过this.reviews = this.reviews.slice()创建新数组引用并重新赋值,可以强制触发响应式更新。这是ArkTS状态管理中的一个重要技巧,类似于React中"不可变更新"的概念。

11.2 写评价弹窗——writeModal

@Builder writeModal() {
  Column() {
    this.modalOverlay(() => { this.showWriteModal = false })
    Column() {
      Row() {
        Text('⭐ 写一条评价')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3E2B18')
          .layoutWeight(1)
        Text('✕')
          .fontSize(15)
          .fontColor('#A89878')
          .onClick(() => { this.showWriteModal = false })
      }
      .width('100%')
      .padding({ left: 18, right: 18, top: 18 })

      Text('给「蓝色列车」打分')
        .fontSize(11)
        .fontColor('#8A7A62')
        .width('100%')
        .padding({ left: 18, top: 16 })

      Row() {
        ForEach([1, 2, 3, 4, 5], (n: number) => {
          Text(n <= this.writeRating ? '⭐' : '☆')
            .fontSize(26)
            .margin({ left: 6, right: 6 })
            .scale({ x: n === this.writeRating ? 1.2 : 1.0, y: n === this.writeRating ? 1.2 : 1.0 })
            .animation({ duration: 160, curve: Curve.EaseOut })
            .onClick(() => { this.writeRating = n })
        }, (n: number) => 'wr' + n)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .margin({ top: 12 })

写评价弹窗的星级评分组件使用ForEach遍历[1, 2, 3, 4, 5]数组渲染五颗星。每颗星的显示通过n <= this.writeRating ? '⭐' : '☆'判断——当星星的序号小于等于当前评分时显示实心星,否则显示空心星。当前选中的星星(n === this.writeRating)会有1.2倍的放大效果,配合160毫秒的缓出动画,形成了"点击放大"的交互反馈。

点击任意星星时,writeRating被更新为对应的序号,触发所有星星的重新渲染——序号小于等于新评分的变为实心星,大于新评分的变为空心星。这种"评分即状态"的设计使得星级评分组件的交互极为流畅。

11.3 评价输入区

      Text('聊聊唱片成色、包装与听感')
        .fontSize(10)
        .fontColor('#8A7A62')
        .width('100%')
        .padding({ left: 18, top: 16 })

      TextArea({ placeholder: '至少写 10 个字,帮助更多藏家做决定……' })
        .fontSize(12)
        .height(90)
        .backgroundColor('#F8F2E4')
        .borderRadius(12)
        .padding(10)
        .margin({ left: 18, right: 18, top: 8 })
        .onChange((v: string) => { this.writeText = v })

评价正文使用TextArea组件而非TextInputTextArea是多行文本输入控件,支持换行和较大的输入区域。高度设为90vp,配合padding(10)提供舒适的输入空间。onChange回调将输入内容同步到writeText状态变量。

弹窗底部还有"拍照上传"和"匿名发布"两个辅助选项按钮,分别使用不同的背景色标识——拍照上传使用浅棕色(#F1E2C4),匿名发布使用浅青绿(#DCEEEA),为评价提供附加功能选项。

11.4 评价详情弹窗——detailModal

@Builder detailModal() {
  Column() {
    this.modalOverlay(() => { this.showDetailModal = false })
    Column() {
      Row() {
        Text(this.selectedReview?.avatar ?? '🎧')
          .fontSize(30)
          .width(52)
          .height(52)
          .backgroundColor('#F6E7D4')
          .borderRadius(26)
          .textAlign(TextAlign.Center)

        Column() {
          Text(this.selectedReview?.user ?? '')
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3E2B18')
          Text('累计成交 86 单 · 好评率 99%')
            .fontSize(9)
            .fontColor('#8A7A62')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        .margin({ left: 12 })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

评价详情弹窗展示了评价的完整信息。头部行使用Row水平排列用户头像和用户信息列。头像使用borderRadius(26)将52x52vp的容器变为圆形——当borderRadius等于宽高的一半时,方形容器变为正圆。

      Row() {
        Text(starText(this.selectedReview?.rating ?? 5))
          .fontSize(14)
          .fontColor('#C99B3F')
        Text(this.selectedReview?.time ?? '')
          .fontSize(9)
          .fontColor('#A89878')
          .margin({ left: 12 })
      }
      .margin({ top: 14 })

      Text('评价对象:《' + (this.selectedReview?.target ?? '') + '》')
        .fontSize(11)
        .fontColor('#8C5A2B')
        .width('100%')
        .margin({ top: 10 })

      Text(this.selectedReview?.text ?? '')
        .fontSize(12)
        .fontColor('#3E2B18')
        .width('100%')
        .lineHeight(19)
        .margin({ top: 10 })

详情弹窗使用starText工具函数将评分转换为星级文本,以金色(#C99B3F)显示。评价正文通过.lineHeight(19)设置行高为19vp,确保多行文本的行间距适中,提升可读性。

11.5 reviewCard——评价卡片构建器

@Builder reviewCard(r: ReviewData) {
  Column() {
    Row() {
      Text(r.avatar)
        .fontSize(24)
        .width(40)
        .height(40)
        .backgroundColor('#F6E7D4')
        .borderRadius(20)
        .textAlign(TextAlign.Center)

      Column() {
        Text(r.user)
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor('#3E2B18')
        Row() {
          Text(starText(r.rating))
            .fontSize(11)
            .fontColor('#C99B3F')
          Text(r.time)
            .fontSize(8)
            .fontColor('#A89878')
            .margin({ left: 10 })
        }
        .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      .margin({ left: 10 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Text('《' + r.target + '》')
      .fontSize(10)
      .fontColor('#8C5A2B')
      .width('100%')
      .margin({ top: 8 })

    Text(r.text)
      .fontSize(12)
      .fontColor('#3E2B18')
      .width('100%')
      .lineHeight(19)
      .margin({ top: 6 })

    Row() {
      Text('👍 ' + r.likes)
        .fontSize(11)
        .fontColor(r.liked ? '#B45309' : '#8A7A62')
        .scale({ x: r.liked ? 1.15 : 1.0, y: r.liked ? 1.15 : 1.0 })
        .animation({ duration: 200, curve: Curve.EaseOut })
        .onClick(() => { this.toggleLike(r.id) })

      Text('查看详情 >')
        .fontSize(10)
        .fontColor('#8A7A62')
        .margin({ left: 24 })
        .onClick(() => {
          this.selectedReview = r
          this.showDetailModal = true
        })
    }
    .width('100%')
    .margin({ top: 12 })
  }
  .width('100%')
  .padding(14)
  .backgroundColor('#FFFDF7')
  .borderRadius(16)
  .margin({ top: 10 })
  .alignItems(HorizontalAlign.Start)
}

评价卡片是评价列表的核心渲染单元。卡片包含用户头像、用户名、星级评分、评价时间、评价对象、评价正文和操作按钮。

点赞按钮的视觉状态通过r.liked动态控制:已点赞时文字颜色为棕色(#B45309)且放大1.15倍,未点赞时颜色为灰色(#8A7A62)且保持原始尺寸。200毫秒的缓出动画使得点赞/取消点赞的状态切换平滑自然。

点击点赞按钮调用this.toggleLike(r.id),该方法在reviews数组中找到对应评价,切换liked状态和likes计数,然后通过slice()触发响应式更新。

11.6 评价总览与星级分布

            Column() {
              Row() {
                Column() {
                  Text('4.8')
                    .fontSize(34)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#B45309')
                  Text('★★★★★')
                    .fontSize(12)
                    .fontColor('#C99B3F')
                  Text('累计 2,468 条')
                    .fontSize(9)
                    .fontColor('#8A7A62')
                    .margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Center)

评价总览区域左侧显示大号的综合评分"4.8"(34号字体、加粗、棕色),下方是五颗星和累计评价数。右侧是星级分布条,每行显示一个星级(5星到3星)的占比比例条。

                Column() {
                  Row() {
                    Text('5星')
                      .fontSize(9)
                      .fontColor('#8A7A62')
                      .width(26)
                    Column() {
                      Column()
                        .height(5)
                        .borderRadius(3)
                        .backgroundColor('#C99B3F')
                        .width('92%')
                    }
                    .layoutWeight(1)
                    .height(5)
                    .backgroundColor('#F1E2C4')
                    .borderRadius(3)
                    .margin({ left: 6, right: 6 })
                    Text('92%')
                      .fontSize(9)
                      .fontColor('#8A7A62')
                      .width(30)
                  }
                  .width('100%')

星级分布条的实现使用了嵌套Column的技巧——外层Column作为轨道(背景色#F1E2C4),内层Column作为进度条(背景色#C99B3F),内层的宽度通过百分比设置(如92%表示5星占比92%)。轨道的高度为5vp,圆角为3vp,形成了一个细长的进度条。

            Column() {
              ForEach(this.reviews, (r: ReviewData) => {
                this.reviewCard(r)
              }, (r: ReviewData) => 'rv' + r.id + (r.liked ? 'y' : 'n'))
            }
            .width('100%')
            .padding({ left: 14, right: 14 })

评价列表的ForEach键值生成函数特别值得注意:'rv' + r.id + (r.liked ? 'y' : 'n')。键值不仅包含了评价ID,还附加了liked状态的后缀(‘y’或’n’)。这意味着当用户点赞或取消点赞时,该评价项的键值会发生变化,ForEach会将其视为一个新项进行重新渲染。这种通过键值变化强制重新渲染的技巧,确保了点赞状态变化时卡片的视觉更新(颜色变化、缩放动画)能够被正确执行。

ForEach的键值生成函数(keyGenerator)是ArkTS列表渲染性能优化的关键。合理的键值设计可以帮助框架高效地进行Diff比较,只更新真正变化的列表项。在本例中,将liked状态编入键值是一种以性能换正确性的策略——虽然会导致整个卡片重新创建而非局部更新,但确保了视觉状态的一致性。

渲染错误: Mermaid 渲染失败: Parse error on line 4: ...edReview: ReviewData|null] B -- -----------------------^ Expecting 'SQE', 'TAGEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PIPE'

十二、Tab5我的组件——MeContent

12.1 状态声明

@Component
struct MeContent {
  @State showLevelModal: boolean = false
  @State showAboutModal: boolean = false
  @State signed: boolean = false

MeContent组件的状态变量相对简洁——两个弹窗状态和一个签到状态。signed记录用户今日是否已签到,初始值为false(未签到)。点击签到按钮后变为true,签到按钮的文字从"签到"变为"✓",背景色从金色变为灰色,并通过.scale({ x: 0.95, y: 0.95 })缩小,配合180毫秒的缓出动画形成"按下缩小"的交互反馈。

12.2 藏家等级弹窗——levelModal

@Builder levelModal() {
  Column() {
    this.modalOverlay(() => { this.showLevelModal = false })
    Column() {
      Text('🎩')
        .fontSize(42)
        .margin({ top: 22 })

      Text('三级藏家')
        .fontSize(19)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .margin({ top: 12 })

      Text('再成交 14 单即可升级「四级藏家」,解锁求购置顶特权')
        .fontSize(10)
        .fontColor('#8A7A62')
        .textAlign(TextAlign.Center)
        .lineHeight(16)
        .margin({ top: 8, left: 24, right: 24 })

      Column() {
        Column()
          .height(8)
          .borderRadius(4)
          .backgroundColor('#C99B3F')
          .width('72%')
      }
      .width('100%')
      .height(8)
      .backgroundColor('#F1E2C4')
      .borderRadius(4)
      .margin({ top: 18, left: 24, right: 24 })

      Text('当前 86 单 / 100 单')
        .fontSize(9)
        .fontColor('#A89878')
        .margin({ top: 8 })

藏家等级弹窗的核心是一个进度条,使用了与评价星级分布条相同的嵌套Column技巧——外层Column为轨道(浅棕色#F1E2C4),内层Column为进度条(金色#C99B3F),宽度为72%表示当前已完成86/100单的进度。

进度条下方显示"当前 86 单 / 100 单"的数值,再下方是三列统计信息(好评率98%、收藏数117、累计成交¥4.2万),使用与仓库统计条相同的layoutWeight等分布局。

12.3 关于弹窗——aboutModal

@Builder aboutModal() {
  Column() {
    this.modalOverlay(() => { this.showAboutModal = false })
    Column() {
      Text('🗄️')
        .fontSize(40)
        .margin({ top: 22 })
      Text('黑胶仓库 VINYL VAULT')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#3E2B18')
        .margin({ top: 12 })
      Text('v2.4.0 · 每一张唱片都值得被好好收藏')
        .fontSize(10)
        .fontColor('#8A7A62')
        .margin({ top: 8 })

      Row() {
        Text('在售唱片')
          .fontSize(10)
          .fontColor('#8A7A62')
        Text('8,642 张')
          .fontSize(10)
          .fontColor('#8C5A2B')
          .margin({ left: 12 })
      }
      .margin({ top: 18 })
      .padding({ left: 20, right: 20, top: 10, bottom: 10 })
      .backgroundColor('#F8F2E4')
      .borderRadius(12)

关于弹窗展示了应用的版本信息和统计数据。"在售唱片"信息行使用浅棕色背景(#F8F2E4)和圆角,形成了一个信息卡片的效果。弹窗底部有"给个好评"和"知道了"两个按钮,分别使用不同的样式——给个好评使用浅棕色背景,知道了使用灰色文字无背景。

12.4 settingRow——设置项构建器

@Builder settingRow(icon: string, label: string, extra: string, extraColor: string) {
  Row() {
    Text(icon)
      .fontSize(16)
    Text(label)
      .fontSize(13)
      .fontColor('#3E2B18')
      .margin({ left: 12 })
      .layoutWeight(1)
    Text(extra)
      .fontSize(11)
      .fontColor(extraColor)
  }
  .width('100%')
  .padding({ top: 14, bottom: 14 })
  .padding({ left: 14, right: 14 })
  .width('100%')
  .backgroundColor('#FFFDF7')
  .borderRadius(14)
  .margin({ top: 8 })
}

settingRow是一个通用的设置项构建器,接收四个参数:图标、标签、附加信息和附加信息颜色。通过这个构建器,设置列表的五个选项(收货地址、收款账户、降价提醒、发货模板、关于黑胶仓库)可以统一渲染,仅需传入不同的参数值。

值得注意的是这里调用了两次.padding()方法——第一次设置垂直内边距(top: 14, bottom: 14),第二次设置水平内边距(left: 14, right: 14)。在ArkTS中,连续调用相同的样式方法时,后一次调用会覆盖前一次的对应属性。但由于这两次.padding()设置的是不同的属性维度(垂直和水平),两次调用实际上是合并生效的。不过,更规范的写法应该是使用一次.padding({ top: 14, bottom: 14, left: 14, right: 14 })或简写为.padding(14)

12.5 个人资料卡

            Column() {
              Row() {
                Text('🎩')
                  .fontSize(34)
                  .width(62)
                  .height(62)
                  .backgroundColor('#F1E2C4')
                  .borderRadius(31)
                  .textAlign(TextAlign.Center)

                Column() {
                  Text('雨夜留声机')
                    .fontSize(17)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFF9EC')
                  Text('三级藏家 · 好评率 98% · 已成交 86 单')
                    .fontSize(9)
                    .fontColor('#D9BE8C')
                    .margin({ top: 5 })
                  Text(this.signed ? '今日已签到 ✓' : '今日未签到')
                    .fontSize(9)
                    .fontColor(this.signed ? '#D9BE8C' : '#3E2B18')
                    .backgroundColor(this.signed ? '#5C4620' : '#C99B3F')
                    .borderRadius(10)
                    .padding({ left: 10, right: 10, top: 3, bottom: 3 })
                    .margin({ top: 6 })
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
                .margin({ left: 14 })

                Text(this.signed ? '✓' : '签到')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3E2B18')
                  .backgroundColor(this.signed ? '#8A7A62' : '#C99B3F')
                  .borderRadius(18)
                  .padding({ left: 18, right: 18, top: 9, bottom: 9 })
                  .scale({ x: this.signed ? 0.95 : 1.0, y: this.signed ? 0.95 : 1.0 })
                  .animation({ duration: 180, curve: Curve.EaseOut })
                  .onClick(() => { this.signed = true })
              }
              .width('100%')
              .alignItems(VerticalAlign.Center)

个人资料卡是"我的"页面最核心的视觉区域。卡片使用渐变背景(从#8C5A2B#3E2B18的135度线性渐变),与公共头部的渐变风格保持一致。头像使用62x62vp的圆形容器(borderRadius(31等于宽高一半),内含"🎩"Emoji。

签到状态标签和签到按钮都通过this.signed动态控制样式。未签到时,状态标签为金色背景深色文字"今日未签到",按钮为金色背景"签到";已签到后,状态标签变为深色背景浅色文字"今日已签到 ✓",按钮变为灰色背景"✓"且缩小至0.95倍。签到按钮点击后this.signed = true,触发所有依赖该状态的UI元素重新渲染。

12.6 资料卡统计行

              Row() {
                Column() {
                  Text('117')
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#FFF9EC')
                  Text('收藏')
                    .fontSize(9)
                    .fontColor('#D9BE8C')
                    .margin({ top: 2 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)

                Column() {
                  Text('9')
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#F9E2E2')
                  Text('稀有盘')
                    .fontSize(9)
                    .fontColor('#D9BE8C')
                    .margin({ top: 2 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)

                Column() {
                  Text('12')
                    .fontSize(16)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#DCEEEA')
                  Text('求购中')
                    .fontSize(9)
                    .fontColor('#D9BE8C')
                    .margin({ top: 2 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)

                Column() {
                  Text('等级')
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#C99B3F')
                  Text('详情 >')
                    .fontSize(9)
                    .fontColor('#D9BE8C')
                    .margin({ top: 2 })
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Center)
                .onClick(() => { this.showLevelModal = true })
              }
              .width('100%')
              .margin({ top: 16 })
              .padding({ top: 12, bottom: 12 })
              .backgroundColor('rgba(255,255,255,0.10)')
              .borderRadius(14)
            }
            .width('100%')
            .padding(16)
            .linearGradient({
              angle: 135,
              colors: [['#8C5A2B', 0], ['#3E2B18', 1]]
            })
            .borderRadius(20)
            .margin({ top: 14, left: 14, right: 14 })

资料卡的统计行使用半透明白色背景rgba(255,255,255,0.10)(10%不透明度),叠放在渐变背景之上形成磨砂玻璃效果。四个统计项(收藏117、稀有盘9、求购中12、等级详情)等分排列,文字颜色使用浅色系以适配深色渐变背景。第四个统计项可点击,打开等级弹窗。

资料卡整体使用与公共头部相同的渐变方案(#8C5A2B#3E2B18),保持了视觉风格的统一性。

12.7 收藏估值走势柱状图

            Column() {
              Text('💰 收藏估值走势(近半年,千元)')
                .fontSize(13)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3E2B18')
                .width('100%')

              Row() {
                ForEach([21, 23, 22, 25, 27, 29], (v: number, i: number) => {
                  Column() {
                    Text('' + v)
                      .fontSize(8)
                      .fontColor(i === 5 ? '#B45309' : '#8A7A62')
                    Column()
                      .width(20)
                      .height((v / 29 * 76).toFixed(0) + 'vp')
                      .borderRadius(6)
                      .backgroundColor(i === 5 ? '#B45309' : '#C99B3F')
                      .margin({ top: 3 })
                    Text((i + 3) + '月')
                      .fontSize(8)
                      .fontColor('#8A7A62')
                      .margin({ top: 5 })
                  }
                  .alignItems(HorizontalAlign.Center)
                  .margin({ left: 11, right: 11 })
                }, (v: number, i: number) => 'gv' + i)
              }
              .justifyContent(FlexAlign.Center)
              .width('100%')
              .margin({ top: 12 })

              Text('本月估值 ¥28,640 · 半年增值 38%')
                .fontSize(9)
                .fontColor('#A89878')
                .margin({ top: 10 })
            }

收藏估值走势柱状图与仓库页面的年代柱状图结构一致,但数据来源不同——这里使用内联数组[21, 23, 22, 25, 27, 29]表示近6个月的估值(千元单位)。最大值29对应76vp的最大柱高,通过比例缩放计算各柱高度。最新月份(索引5)使用棕色(#B45309)突出显示,其他月份使用金色(#C99B3F)。

12.8 勋章墙

            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(VAULT_BADGES, (b: BadgeEntry) => {
                Column() {
                  Text(b.icon)
                    .fontSize(26)
                    .opacity(b.got ? 1.0 : 0.3)
                    .scale({ x: b.got ? 1.0 : 0.9, y: b.got ? 1.0 : 0.9 })
                  Text(b.name)
                    .fontSize(10)
                    .fontWeight(FontWeight.Medium)
                    .fontColor(b.got ? '#3E2B18' : '#C0B090')
                    .margin({ top: 6 })
                  Text(b.got ? b.desc : '未解锁')
                    .fontSize(8)
                    .fontColor('#A89878')
                    .margin({ top: 3 })
                }
                .width('30%')
                .padding(10)
                .backgroundColor('#FFFDF7')
                .borderRadius(14)
                .alignItems(HorizontalAlign.Center)
                .margin({ left: '1.6%', top: 10 })
              }, (b: BadgeEntry) => 'vb' + b.name)
            }
            .width('100%')
            .padding({ bottom: 4 })

勋章墙使用Flex({ wrap: FlexWrap.Wrap })实现三列网格布局。每个勋章宽度为30%,配合margin({ left: '1.6%' })实现三列等间距排列——三个勋章占90%,三个间距占4.8%,剩余约5.2%分布在两侧。

已解锁和未解锁的勋章通过b.got状态控制视觉表现。已解锁勋章不透明度为1.0、尺寸为1.0倍、名称颜色为深色(#3E2B18)、显示描述文字。未解锁勋章不透明度降为0.3、尺寸缩小至0.9倍、名称颜色为浅灰色(#C0B090)、描述替换为"未解锁"。这种视觉差异清晰地区分了已获取和未获取的成就。

opacity属性是控制组件可见性的重要手段。与完全隐藏组件(visibility: Visibility.None)不同,opacity: 0.3使组件仍然占据布局空间但视觉上半透明。在勋章展示场景中,这种"灰色不可用"的视觉效果比完全隐藏更加合理——它告知用户这些成就的存在,激发用户的获取动机。

12.9 设置列表与页面尾部

            Text('⚙️ 通用设置')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3E2B18')
              .width('100%')
              .padding({ left: 14, top: 20 })

            Column() {
              this.settingRow('📮', '收货地址', '上海 · 3个', '#8A7A62')
              this.settingRow('💳', '收款账户', '已绑定', '#8C5A2B')
              this.settingRow('🔔', '降价提醒', '已开启', '#8C5A2B')
              this.settingRow('🚚', '发货模板', '顺丰保价', '#8A7A62')
              this.settingRow('ℹ️', '关于黑胶仓库', 'v2.4.0', '#A89878')
            }
            .width('100%')
            .padding({ left: 14, right: 14, top: 8 })

            Text('🗄️ 黑胶仓库 · 每一张唱片都值得被好好收藏')
              .fontSize(9)
              .fontColor('#C0B090')
              .margin({ top: 20, bottom: 90 })
              .onClick(() => { this.showAboutModal = true })

设置列表通过连续调用this.settingRow(...)渲染五个设置项。每个设置项的附加信息颜色不同——"已绑定"和"已开启"使用棕色(#8C5A2B)表示已激活状态,其他使用灰色(#8A7A62)表示中性信息。页面尾部是一段应用口号文字,点击可打开关于弹窗。

设置列表的Column容器没有设置backgroundColor,每个settingRow调用各自设置了白色背景和圆角,形成了一系列独立的设置卡片,卡片之间通过margin({ top: 8 })保持间距。这种"独立卡片式设置项"的设计比传统的"分组列表"更加现代化。

12.10 我的页面build方法与弹窗渲染

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            // ...资料卡、柱状图、勋章墙、设置列表、尾部文字
          }
          .width('100%')
        }
        .scrollBar(BarState.Off)
        .layoutWeight(1)
      }
      .width('100%')
      .height('100%')

      if (this.showLevelModal) {
        this.levelModal()
      }
      if (this.showAboutModal) {
        this.aboutModal()
      }
    }
    .width('100%')
    .height('100%')
  }

"我的"页面与仓库页面不同——它没有使用MallHeader公共头部,而是直接从资料卡开始。整个页面内容包裹在一个Scroll容器中,用户可以上下滚动查看所有内容。两个弹窗(等级弹窗和关于弹窗)通过条件渲染控制显示。

值得注意的是Scroll容器没有传入参数——默认为垂直滚动。.scrollBar(BarState.Off)隐藏滚动条,保持界面的整洁。.layoutWeight(1)使Scroll容器占据Column中的全部剩余空间(减去底部Tab栏的高度,虽然底部Tab栏在入口组件中定义,不在本组件内)。

点击等级列

got=true

got=false

点击

true

true

MeContent build

Stack 根容器

Column 主内容层

showLevelModal?

showAboutModal?

Scroll 滚动区域

资料卡 - 渐变背景

收藏估值柱状图

勋章墙 - Flex三列

设置列表 - 5项

页面尾部口号

头像+用户信息

签到状态 signed

4列统计

showLevelModal = true

ForEach VAULT_BADGES

正常显示

半透明缩小

settingRow x5

showAboutModal = true

levelModal 等级弹窗

aboutModal 关于弹窗


十三、架构与数据流总览

13.1 组件层级关系

整个应用采用了"入口组件-页面组件-构建器方法"的三层架构。入口组件VinylApp负责Tab切换和底部Tab栏渲染,五个页面组件(VaultContentMarketContentWantedContentReviewContentMeContent)各自管理独立的页面逻辑和弹窗体系。每个页面组件内部通过多个@Builder方法拆分出卡片构建器、弹窗构建器和列表项构建器。

MallHeader作为公共头部组件,仅在仓库页面中使用。其他页面各自实现了自己的头部区域,体现了不同页面的差异化设计。这种"公共组件+自定义区域"的混合模式,在保持核心视觉统一的同时允许各页面有灵活的功能定制。

13.2 状态管理架构

应用的状态管理完全基于@State装饰器的组件内状态。每个页面组件独立管理自己的状态变量,页面之间不共享状态。这种设计在当前规模下是合理的——五个页面的数据相互独立,无需跨组件状态共享。如果未来需要实现页面间的数据联动(如在市集购买后自动更新仓库的收藏数据),可以考虑使用@Provide/@ConsumeAppStorage进行跨组件状态管理。

13.3 弹窗管理统一模式

所有页面组件都采用了相同的弹窗管理模式:每个弹窗由一个布尔@State变量控制显示/隐藏,弹窗内容由@Builder方法构建,弹窗外层使用modalOverlay遮罩层。弹窗通过条件渲染(if (this.showXxxModal) { this.xxxModal() })在Stack容器中叠加显示。这种统一的模式使得弹窗的添加、修改和删除都有章可循,降低了维护成本。


十四、核心技术点对比与总结

14.1 布局容器与装饰器对比

组件/属性 类型 主要用途 是否支持换行 主轴方向 典型使用场景
Column 容器组件 垂直排列子组件 垂直 页面主体结构、统计列
Row 容器组件 水平排列子组件 水平 按钮行、信息行
Stack 容器组件 层叠排列子组件 Z轴 弹窗叠加、遮罩层
Flex 容器组件 弹性布局 是(wrap) 可配置 卡片网格、勋章墙
Scroll 容器组件 滚动区域 可配置 长列表、水平筛选条
@Entry 装饰器 标记页面入口 - - VinylApp入口组件
@Component 装饰器 声明自定义组件 - - 所有struct组件
@State 装饰器 声明响应式状态 - - activeTab, showXxxModal
@Builder 装饰器 声明可复用构建方法 - - vaultCard, modalOverlay
ForEach 渲染控制 循环渲染列表 - - 唱片列表、评价列表
if 渲染控制 条件渲染 - - 弹窗显隐、稀有标签
layoutWeight 属性 权重分配空间 - - 等分布局、非等分按钮
constraintSize 属性 约束尺寸范围 - - 弹窗最大高度限制
position 属性 绝对定位 - - 弹窗位置控制
zIndex 属性 层叠顺序 - - 弹窗覆盖(999)
linearGradient 方法 线性渐变背景 - - 头部、资料卡背景
rotate 方法 旋转变换 - - 唱片图标旋转
scale 方法 缩放变换 - - Tab图标放大、点赞缩放
animation 方法 动画效果 - - Tab切换、点赞动画
opacity 方法 透明度控制 - - 未解锁勋章半透明
textAlign 属性 文本对齐 - - 居中文字、按钮文字
maxLines 属性 最大行数 - - 标题单行限制
textOverflow 属性 溢出处理 - - 省略号截断
onClick 事件 点击事件 - - Tab切换、弹窗打开
onChange 事件 输入变化 - - 表单输入同步

14.2 状态变量管理对比

组件 状态变量数量 弹窗数量 @Builder数量 核心交互
VinylApp 1 (activeTab) 0 2 (contentArea, bottomTabItem) Tab切换
VaultContent 5 3 5 (modalOverlay, vinylDetailModal, tradeModal, aboutModal, vaultCard) 唱片详情查看
MarketContent 11 3 5 (modalOverlay, buyModal, sellModal, offerModal, marketRow) 流派筛选+购买/出售/出价
WantedContent 9 4 6 (modalOverlay, publishModal, editModal, deleteModal, offerListModal, wantedCard) 发布/编辑/删除求购
ReviewContent 6 2 4 (modalOverlay, writeModal, detailModal, reviewCard) 写评价+点赞
MeContent 3 2 4 (modalOverlay, levelModal, aboutModal, settingRow) 签到+等级查看

14.3 弹窗类型与布局对比

弹窗名称 所属页面 定位方式 宽度 布局类型 关闭方式
唱片详情弹窗 仓库 position 6%/12% 88% 居中大卡 遮罩点击/按钮
交易记录弹窗 仓库 position 0/16% 100% 底部抽屉 关闭按钮/遮罩
仓库说明弹窗 仓库 position 11%/28% 78% 居中小卡 按钮/遮罩
确认购买弹窗 市集 position 0/24% 100% 底部抽屉 关闭按钮/按钮
上架出售弹窗 市集 position 0/8% 100% 底部抽屉(表单) 取消按钮/按钮
出价议价弹窗 市集 position 9%/26% 82% 居中卡(步进器) 按钮/遮罩
发布求购弹窗 求购 position 0/18% 100% 底部抽屉(表单) 取消按钮/按钮
编辑预算弹窗 求购 position 8%/24% 84% 居中卡(步进器) 按钮/遮罩
删除确认弹窗 求购 position 9%/30% 82% 居中危险提示 按钮/遮罩
报价列表弹窗 求购 position 0/20% 100% 底部抽屉(列表) 关闭按钮/遮罩
写评价弹窗 评价 position 0/18% 100% 底部抽屉(表单) 关闭按钮/按钮
评价详情弹窗 评价 position 8%/16% 84% 居中卡 关闭按钮/遮罩
藏家等级弹窗 我的 position 8%/18% 84% 居中卡(进度条) 按钮/遮罩
关于弹窗 我的 position 10%/22% 80% 居中信息卡 按钮/遮罩

14.4 数据可视化手段对比

图表类型 所属页面 数据来源 实现方式 颜色策略
年代收藏柱状图 仓库 DECADE_SHARE数组 ForEach+动态height Column 每条独立颜色
价格历史柱状图 仓库详情弹窗 内联数组[320,356,341,398,372,431] ForEach+动态height Column 最新项高亮
流派均价柱状图 市集 PRICE_TREND数组 ForEach+动态height Column 涨跌区分色
收藏估值柱状图 我的 内联数组[21,23,22,25,27,29] ForEach+动态height Column 最新项高亮
星级分布条 评价 硬编码百分比 嵌套Column进度条 单色进度条
等级进度条 我的等级弹窗 硬编码72% 嵌套Column进度条 单色进度条
星级评分 评价 rating数值 starText函数转换 金色文字

十五、总结

本篇技术博文对一个完整的鸿蒙HarmonyOS ArkTS黑胶唱片市集应用进行了逐段、逐组件的深度技术解析。该应用以黑胶唱片收藏与交易为核心业务场景,构建了仓库、市集、求购、评价、我的五大功能页面,涵盖了电商类应用的核心交互模式。

从架构层面来看,应用采用了"入口组件统一调度、页面组件独立运作、构建器方法细粒度复用"的三层组件化架构。入口组件VinylApp通过@State activeTab状态变量驱动contentArea构建器的条件渲染,实现了五个页面的动态切换。底部Tab栏通过bottomTabItem构建器封装了统一的渲染逻辑,配合.animation()动画和.scale()缩放实现了流畅的Tab切换交互效果。每个页面组件内部又将卡片渲染、弹窗展示等逻辑拆分为多个@Builder方法,如vaultCardmarketRowwantedCardreviewCard等,实现了代码的高度复用。

从状态管理层面来看,应用全面采用了@State装饰器的响应式状态管理模式。布尔状态变量控制弹窗的显示与隐藏,字符串状态变量记录表单输入和筛选条件,对象状态变量保存当前选中的数据项。特别值得注意的是评价组件中的toggleLike方法——通过this.reviews = this.reviews.slice()创建新数组引用来触发响应式更新,这是ArkTS中处理数组状态更新的重要技巧。同时,ForEach的键值生成函数中编入了liked状态('rv' + r.id + (r.liked ? 'y' : 'n')),确保了点赞状态变化时卡片能够正确重新渲染。

从UI布局层面来看,应用综合运用了ColumnRowStackFlexScroll等多种布局容器。ColumnRow用于线性的垂直和水平排列,layoutWeight实现空间分配。Stack用于弹窗层的叠加显示,配合zIndex(999)确保弹窗覆盖在内容之上。Flex({ wrap: FlexWrap.Wrap })实现了唱片卡片和勋章的自动换行网格布局。Scroll容器配合scrollable(ScrollDirection.Horizontal)constraintSize实现了水平滚动筛选条和限高的滚动列表。

从数据可视化层面来看,应用通过纯ArkTS组件实现了多种图表——柱状图使用ForEach遍历数据数组,通过动态计算height属性值((v / maxValue * maxHeight).toFixed(0) + 'vp')渲染柱子高度。进度条使用嵌套Column结构——外层作为轨道、内层作为进度填充,通过百分比width控制填充宽度。星级评分通过starText工具函数将数值转换为Unicode星号字符字符串。这些实现方式无需引入第三方图表库,完全基于ArkTS基础组件构建,保证了应用的轻量性。

从交互设计层面来看,应用实现了丰富的交互反馈。Tab切换时的图标缩放和不透明度动画、唱片图标的旋转动画、点赞按钮的缩放和颜色变化、签到按钮的按下缩小效果,都通过.animation()方法和.scale()变换实现。弹窗的打开和关闭通过条件渲染控制,遮罩层的点击关闭、关闭按钮的显式关闭提供了双重关闭路径。表单输入通过TextInputTextAreaonChange回调实时同步到状态变量,步进器通过加减按钮的onClick更新数值状态。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 复古暖调风:琥珀棕主色 + 奶油纸底 + 电商式静态头部
// 遵循要求.md:无 Blank、无 UI 内变量声明、constraintSize 限高、单子组件 Scroll、接口约束全部对象字面量

// ============ 类型定义 ============
interface GenreMeta {
  label: string;
  icon: string;
  color: string;
  bg: string;
}

interface VinylItem {
  id: number;
  title: string;
  artist: string;
  year: number;
  genre: string;
  price: number;
  condition: string;
  cover: string;
  rare: boolean;
  tracks: number;
  seller: string;
  sold: boolean;
}

interface WantedItem {
  id: number;
  title: string;
  artist: string;
  budget: number;
  offers: number;
  deadline: string;
  status: string;
  urgent: boolean;
}

interface ReviewItem {
  id: number;
  user: string;
  avatar: string;
  rating: number;
  target: string;
  text: string;
  time: string;
  likes: number;
  liked: boolean;
}

interface DecadeEntry {
  decade: string;
  count: number;
  color: string;
}

interface PriceTrend {
  genre: string;
  avg: number;
  delta: string;
}

interface TradeItem {
  id: number;
  title: string;
  artist: string;
  price: number;
  buyer: string;
  date: string;
  state: string;
}

interface BadgeEntry {
  icon: string;
  name: string;
  desc: string;
  got: boolean;
}

// ============ 风格配置 ============
const GENRE_CONFIG: Record<string, GenreMeta> = {
  '摇滚': { label: '摇滚', icon: '🎸', color: '#B45309', bg: '#F6E7D4' },
  '爵士': { label: '爵士', icon: '🎷', color: '#0F766E', bg: '#DCEEEA' },
  '灵魂': { label: '灵魂', icon: '🎙️', color: '#BE185D', bg: '#F8E3EC' },
  '电子': { label: '电子', icon: '🎛️', color: '#4338CA', bg: '#E4E3FA' },
  '嘻哈': { label: '嘻哈', icon: '🎤', color: '#B91C1C', bg: '#F9E2E2' },
  '华语经典': { label: '华语经典', icon: '🀄', color: '#A16207', bg: '#F8F0D8' },
  '乡村': { label: '乡村', icon: '🤠', color: '#15803D', bg: '#E1F2E5' },
  '古典': { label: '古典', icon: '🎻', color: '#6D28D9', bg: '#ECE6F9' }
};

const GENRE_LIST: string[] = ['全部', '摇滚', '爵士', '灵魂', '电子', '嘻哈', '华语经典', '乡村', '古典'];

const CONDITION_LIST: string[] = ['Mint 全新', 'VG+ 近新', 'VG 良好', 'G+ 有痕'];

const DECADE_SHARE: DecadeEntry[] = [
  { decade: '60s', count: 18, color: '#8C5A2B' },
  { decade: '70s', count: 32, color: '#B45309' },
  { decade: '80s', count: 26, color: '#C99B3F' },
  { decade: '90s', count: 21, color: '#0F766E' },
  { decade: '00s', count: 12, color: '#BE185D' },
  { decade: '10s', count: 8, color: '#4338CA' }
];

const PRICE_TREND: PriceTrend[] = [
  { genre: '爵士', avg: 386, delta: '+12%' },
  { genre: '摇滚', avg: 264, delta: '+6%' },
  { genre: '灵魂', avg: 452, delta: '+18%' },
  { genre: '电子', avg: 198, delta: '-3%' },
  { genre: '嘻哈', avg: 312, delta: '+9%' },
  { genre: '华语经典', avg: 176, delta: '+4%' }
];

const TRADE_LIST: TradeItem[] = [
  { id: 1, title: '蓝色列车', artist: 'John Coltrane', price: 520, buyer: '雨夜留声机', date: '今天 14:20', state: '已发货' },
  { id: 2, title: '平行线', artist: 'Kraftwerk', price: 340, buyer: '硬核乐迷阿伟', date: '今天 10:05', state: '待付款' },
  { id: 3, title: '午夜电台', artist: 'The Midnight', price: 158, buyer: '复古收藏家K', date: '昨天 21:40', state: '已完成' },
  { id: 4, title: '南方站牌', artist: '五条人', price: 96, buyer: '南方以南', date: '昨天 16:12', state: '已完成' },
  { id: 5, title: '金色梦乡', artist: 'Ballake Sissoko', price: 268, buyer: '世界音乐迷', date: '3天前', state: '已发货' },
  { id: 6, title: '街头诗人', artist: 'NAS', price: 410, buyer: '东岸老炮', date: '4天前', state: '已取消' },
  { id: 7, title: '时光旅馆', artist: 'Shigeru Suzuki', price: 680, buyer: 'City Pop猎人', date: '5天前', state: '已完成' },
  { id: 8, title: '玻璃花园', artist: 'Suzanne Ciani', price: 295, buyer: '氛围电子粉', date: '6天前', state: '已完成' }
];

const VAULT_BADGES: BadgeEntry[] = [
  { icon: '🎖️', name: '初代藏家', desc: '收藏满10张', got: true },
  { icon: '📀', name: '百万转盘', desc: '总播放满100小时', got: true },
  { icon: '🤝', name: '守信卖家', desc: '好评满50单', got: true },
  { icon: '🧭', name: '淘碟达人', desc: '市集淘到绝版', got: true },
  { icon: '💎', name: '稀有大户', desc: '稀有盘满5张', got: false },
  { icon: '🚚', name: '百单卖家', desc: '成交满100单', got: false }
];

// ============ Mock 数据类 ============
class VinylData implements VinylItem {
  id: number = 0;
  title: string = '';
  artist: string = '';
  year: number = 0;
  genre: string = '';
  price: number = 0;
  condition: string = '';
  cover: string = '';
  rare: boolean = false;
  tracks: number = 0;
  seller: string = '';
  sold: boolean = false;

  constructor(id: number, title: string, artist: string, year: number, genre: string, price: number, condition: string, cover: string, rare: boolean, tracks: number, seller: string, sold: boolean) {
    this.id = id;
    this.title = title;
    this.artist = artist;
    this.year = year;
    this.genre = genre;
    this.price = price;
    this.condition = condition;
    this.cover = cover;
    this.rare = rare;
    this.tracks = tracks;
    this.seller = seller;
    this.sold = sold;
  }
}

class WantedData implements WantedItem {
  id: number = 0;
  title: string = '';
  artist: string = '';
  budget: number = 0;
  offers: number = 0;
  deadline: string = '';
  status: string = '';
  urgent: boolean = false;

  constructor(id: number, title: string, artist: string, budget: number, offers: number, deadline: string, status: string, urgent: boolean) {
    this.id = id;
    this.title = title;
    this.artist = artist;
    this.budget = budget;
    this.offers = offers;
    this.deadline = deadline;
    this.status = status;
    this.urgent = urgent;
  }
}

class ReviewData implements ReviewItem {
  id: number = 0;
  user: string = '';
  avatar: string = '';
  rating: number = 0;
  target: string = '';
  text: string = '';
  time: string = '';
  likes: number = 0;
  liked: boolean = false;

  constructor(id: number, user: string, avatar: string, rating: number, target: string, text: string, time: string, likes: number, liked: boolean) {
    this.id = id;
    this.user = user;
    this.avatar = avatar;
    this.rating = rating;
    this.target = target;
    this.text = text;
    this.time = time;
    this.likes = likes;
    this.liked = liked;
  }
}

// ============ Mock 数据 ============
const mockVinyls: VinylData[] = [
  new VinylData(1, '蓝色列车', 'John Coltrane', 1957, '爵士', 520, 'VG+ 近新', '🎷', true, 5, '雨夜留声机', false),
  new VinylData(2, '平行线', 'Kraftwerk', 1977, '电子', 340, 'VG 良好', '🎛️', true, 8, '硬核乐迷阿伟', false),
  new VinylData(3, '午夜电台', 'The Midnight', 2016, '电子', 158, 'Mint 全新', '🌙', false, 10, '复古收藏家K', true),
  new V
      }
      .width('100%')
      .height('100%')

      if (this.showLevelModal) {
        this.levelModal()
      }
      if (this.showAboutModal) {
        this.aboutModal()
      }
    }
    .width('100%')
    .height('100%')
  }
}


在这里插入图片描述

从样式设计层面来看,应用采用了"复古暖调"的视觉风格——以琥珀棕(#8C5A2B)为主色,奶油白(#FFFDF7)为卡片底色,深棕色(#3E2B18)为文字主色。渐变背景(linearGradient)用于头部和资料卡,营造层次感。阴影效果(shadow)用于底部Tab栏的浮起感。流派标签通过GENRE_CONFIG配置表统一管理颜色方案,确保了色彩的一致性和可维护性。Emoji图标作为视觉元素贯穿全局,既降低了图片资源的依赖,又增添了趣味性。

从工程规范层面来看,代码遵循了一系列良好的实践。接口定义采用对象字面量形式,所有属性都有明确类型声明。常量配置集中管理(GENRE_CONFIGGENRE_LIST等),便于统一维护。工具函数(getGenreColorfilterByGenrestarText)将通用逻辑抽取为独立函数,提高了代码复用率。ForEach的键值生成函数使用组件缩写前缀(如'mh''vc''mk')避免全局冲突。可选链操作符(?.)和空值合并操作符(??)确保了对null值的安全访问。

Logo

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

更多推荐