引言:鸿蒙开发背景与ArkTS语言范式

华为HarmonyOS(鸿蒙操作系统)自诞生以来,就以"万物互联"为核心愿景,致力于打破设备之间的壁垒,实现跨设备、跨场景的 seamless 协同体验。在HarmonyOS的应用开发体系中,ArkUI框架是构建用户界面的核心引擎,而ArkTS则是驱动这一框架的编程语言。ArkTS在TypeScript的基础上进行了深度扩展与约束裁剪,既保留了TypeScript的静态类型检查能力,又针对声明式UI编程范式做了大量优化。它引入了诸如@Component@Entry@State@Prop@Builder等一系列装饰器,使开发者能够以极简的语法描述界面结构与状态流转,从而大幅提升开发效率与代码可维护性。在鸿蒙生态中,ArkTS不仅仅是一门语言,更是一种完整的开发方法论,它将UI声明、状态管理、组件化设计融为一体,形成了独具特色的声明式开发范式。

声明式UI范式是ArkUI的核心设计理念。与传统的命令式UI(如Android的View体系或iOS的UIKit)不同,声明式UI要求开发者"描述界面应该长什么样",而不是"一步步操作界面去变化"。开发者只需要定义界面在不同状态下的最终形态,框架的差分渲染引擎会自动计算出从旧状态到新状态的最小变更集,并高效地执行DOM树的更新。这种范式极大地简化了状态管理的心智负担——你不需要手动调用setText()setColor()等方法,只需改变@State修饰的变量,界面就会自动刷新。ArkUI的渲染管线基于Yoga布局引擎和自研的C++后端,在性能上可以媲美原生渲染,同时保留了跨平台的一致性体验。

ArkUI组件体系是构建鸿蒙应用界面的基石。该体系提供了丰富的基础组件(如TextImageButtonTextInput等)和容器组件(如ColumnRowStackFlexScrollList等)。容器组件负责子元素的布局排布与尺寸分配,基础组件则负责具体的内容呈现与交互。通过组件的嵌套组合,开发者可以搭建出任意复杂的界面结构。此外,ArkUI还支持linearGradient线性渐变背景、border边框、borderRadius圆角、shadow阴影等丰富的样式属性,让界面视觉表现力不输原生开发。在本文即将剖析的代码中,我们将看到一个完整的非遗手艺坊应用,它综合运用了上述所有ArkUI核心能力,构建出一个涵盖首页信息流、手艺百科、大师名录、课程预约、集市摊位和个人中心六大模块的全功能场景应用。

一、数据模型层:接口定义与类型系统

1.1 核心数据接口定义

在ArkTS中,interface关键字用于定义对象的形状(Shape),类似于TypeScript的接口。在本应用中,所有的业务实体都通过接口进行了严格的类型约束,这确保了在整个应用的数据流转过程中,每个字段都有明确的类型定义,编译器能够在编译阶段捕获类型错误。

interface FeedItem {
  id: number
  name: string
  avatar: string
  time: string
  content: string
  likes: number
  comments: number
}

interface CraftItem {
  id: number
  name: string
  cat: string
  region: string
  score: number
  heirs: number
  state: number
}

interface MasterItem {
  id: number
  name: string
  title: string
  craft: string
  fans: number
  score: number
  quota: number
  state: number
}

interface LessonItem {
  id: number
  name: string
  teacher: string
  time: string
  quota: number
  total: number
  price: number
  state: number
}

interface StallItem {
  id: number
  name: string
  owner: string
  craft: string
  rent: number
  state: number
}

interface MyItem {
  id: number
  name: string
  tag: string
  time: string
}

在这里插入图片描述

上述代码定义了六个核心数据接口:FeedItem描述社区动态信息,包含发布者昵称、头像(emoji表示)、发布时间、动态正文、点赞数和评论数;CraftItem描述非遗手艺项目,包含名称、品类、地区、传承指数、传承人数量和收徒状态;MasterItem描述非遗大师信息,包含姓名、头衔、擅长手艺、粉丝数、评分、剩余名额和拜师状态;LessonItem描述课程信息,包含课程名称、讲师、开课时间、剩余名额、总名额、价格和状态;StallItem描述集市摊位信息,包含摊位名称、摊主、手艺品类、日租金和经营状态;MyItem描述个人收藏项,包含名称、标签和收藏时间。

技术要点:ArkTS接口与类型安全
在ArkTS中,interface定义的接口不仅用于约束对象结构,还广泛应用于组件的@Prop@State类型标注。通过接口约束,编译器能在编译期检查数据结构一致性,避免运行时类型错误。这是ArkTS区别于普通JavaScript的关键特性之一——强类型保障了大型应用的数据安全。

每个接口中的state字段采用数值编码(0表示正常/可用状态,1表示暂停/已满状态),这种设计模式在鸿蒙应用开发中非常常见。使用数值而非字符串来表示状态,一方面减少了内存占用,另一方面在条件判断时性能更优(数值比较快于字符串比较)。后续我们会看到,代码中定义了一系列辅助函数来将这些数值状态转换为人类可读的文本和颜色值。

1.2 静态数据源定义

定义完接口后,代码使用const关键字声明了多组静态数据数组,作为应用的初始数据源。这些数组分别对应六大功能模块的列表数据。

const FEEDS: FeedItem[] = [
  { id: 1, name: '剪花阿婆', avatar: '✂️', time: '22分钟前',
    content: '今天剪了一幅「喜上眉梢」,喜鹊的羽毛用锯齿纹表现,老花镜戴了三层才完工。',
    likes: 342, comments: 56 },
  { id: 2, name: '绣娘小禾', avatar: '🧵', time: '45分钟前',
    content: '蜀绣的鲤鱼绣到第七天,鳞片用了套针,阳光下一照,像是真的游起来了。',
    likes: 287, comments: 44 },
  // ... 更多动态数据
]

const CRAFTS: CraftItem[] = [
  { id: 1, name: '蔚县剪纸', cat: '剪纸', region: '河北蔚县', score: 98, heirs: 126, state: 0 },
  { id: 2, name: '苏绣双面绣', cat: '织绣', region: '江苏苏州', score: 97, heirs: 98, state: 0 },
  { id: 3, name: '东阳木雕', cat: '雕刻', region: '浙江东阳', score: 96, heirs: 112, state: 0 },
  // ... 更多手艺数据
]

const HEAT: number[] = [4, 6, 5, 8, 7, 9, 8, 10]
const CATS: string[] = ['全部', '剪纸', '织绣', '雕刻', '陶瓷', '竹编']

这里FEEDS数组包含了8条社区动态数据,每条都生动地描绘了手艺人的日常创作场景——从蔚县剪纸的锯齿纹技法,到蜀绣的套针鳞片,再到黄杨木雕的精细打磨,内容既丰富又真实。CRAFTS数组收录了10项国家级非物质文化遗产,涵盖了剪纸、织绣、雕刻、漆器、竹编、陶瓷、银饰、风筝、印染、皮影等多种品类。HEAT数组存储了8天的社区活跃度数据,用于渲染热度柱状图。CATS数组定义了品类筛选标签。

技术要点:const数组与不可变性
在ArkTS中,const声明的数组虽然其引用不可变,但数组内容仍可通过spliceunshift等方法修改。在本应用中,这些const数组被赋值给@State变量后,通过spliceunshift操作实现了数据的增删改——这是ArkTS状态驱动UI更新的典型用法。

二、辅助函数体系:状态映射与数据构造

2.1 状态文本与颜色映射函数

应用中定义了一组工具函数,负责将数值状态编码转换为界面可展示的文本和颜色值。这种"状态映射函数"模式在鸿蒙开发中极为实用,它将业务逻辑与视图表现解耦。

function craftStateText(s: number): string {
  if (s === 0) {
    return '收徒中'
  }
  return '暂停收徒'
}

function craftStateColor(s: number): string {
  if (s === 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

function masterStateText(s: number): string {
  if (s === 0) {
    return '可拜师'
  }
  return '名额已满'
}

function masterStateColor(s: number): string {
  if (s === 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

function lessonStateText(s: number): string {
  if (s === 0) {
    return '有名额'
  }
  return '已报满'
}

function stallStateText(s: number): string {
  if (s === 0) {
    return '经营中'
  }
  return '已打烊'
}

在这里插入图片描述

这些函数遵循统一的设计模式:接收一个数值参数(0或1),返回对应的中文状态描述或颜色十六进制值。当状态为0时,返回正常状态文本(如"收徒中"、“可拜师”、“有名额”、“经营中”)和石青色#0E7490;当状态为1时,返回异常状态文本(如"暂停收徒"、“名额已满”、“已报满”、“已打烊”)和朱砂色#C2410C

技术要点:颜色体系设计
本应用采用了精心设计的宣纸水墨配色方案:背景色#F7F3EA(宣纸色)、面板色#FFFFFF(白色)、墨色#1F2937(深灰)、朱砂色#C2410C(主强调色)、靛蓝色#4338CA(次强调色)、石青色#0E7490(辅助色)。这种配色既传达了非遗手艺的文化底蕴,又保证了界面的可读性和层次感。颜色统一通过函数返回,方便全局调整。

2.2 数据展示格式化函数

除了状态映射,应用还定义了数据格式化函数,用于将数值转换为更友好的展示格式。

function heatBar(v: number): string {
  return (16 + v * 4) + 'vp'
}

function playsText(p: number): string {
  if (p >= 10000) {
    return (p / 10000).toFixed(1) + '万'
  }
  return p.toString()
}

function trendText(t: number): string {
  if (t > 0) {
    return '+' + t + '%'
  }
  return t + '%'
}

function trendColor(t: number): string {
  if (t >= 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

heatBar函数将热度数值转换为柱状图高度(单位为vp,即virtual pixel虚拟像素),公式为16 + v * 4,意味着热度值越大柱子越高。playsText函数实现了"万"级别的数字简写——当粉丝数超过一万时,显示为"5.3万"的格式,这在中文社交应用中是非常普遍的做法。trendTexttrendColor分别处理趋势数据的文本(正数加"+"前缀)和颜色(正数用石青,负数用朱砂)。

技术要点:vp单位系统
鸿蒙ArkUI中,vp(virtual pixel)是密度无关像素单位,类似于Android的dp。在不同屏幕密度的设备上,1vp对应的物理像素不同,但视觉大小保持一致。使用vp而非px,可以确保界面在不同分辨率设备上呈现一致的视觉效果。

2.3 数据构造器函数

应用还定义了一组build*工厂函数,用于创建符合接口约束的数据对象。

function buildFeed(id: number, name: string, avatar: string, time: string,
  content: string, likes: number, comments: number): FeedItem {
  return { id: id, name: name, avatar: avatar, time: time,
    content: content, likes: likes, comments: comments }
}

function buildCraft(id: number, name: string, cat: string, region: string,
  score: number, heirs: number, state: number): CraftItem {
  return { id: id, name: name, cat: cat, region: region,
    score: score, heirs: heirs, state: state }
}

function buildMaster(id: number, name: string, title: string, craft: string,
  fans: number, score: number, quota: number, state: number): MasterItem {
  return { id: id, name: name, title: title, craft: craft,
    fans: fans, score: score, quota: quota, state: state }
}

function buildLesson(id: number, name: string, teacher: string, time: string,
  quota: number, total: number, price: number, state: number): LessonItem {
  return { id: id, name: name, teacher: teacher, time: time,
    quota: quota, total: total, price: price, state: state }
}

function buildStall(id: number, name: string, owner: string, craft: string,
  rent: number, state: number): StallItem {
  return { id: id, name: name, owner: owner, craft: craft, rent: rent, state: state }
}

function buildMy(id: number, name: string, tag: string, time: string): MyItem {
  return { id: id, name: name, tag: tag, time: time }
}

在这里插入图片描述

这些工厂函数的作用是在运行时动态构造新的数据对象。例如,当用户发布新动态时,buildFeed函数会构造一个新的FeedItem对象并插入到动态列表的最前面;当大师名额减少时,buildMaster函数会构造一个更新了quota字段的新对象来替换旧对象。这种"构造新对象替换旧对象"的模式在ArkTS中尤为重要——因为ArkTS的@State状态管理是基于引用变化的,通过splice方法替换数组元素可以触发UI的重新渲染。

技术要点:工厂函数与不可变数据更新
在ArkTS的状态管理中,直接修改对象属性(如item.score = 95)有时无法触发UI更新,因为@State监听的是引用变化而非属性变化。通过splice方法用新对象替换旧对象,能够确保引用变化被状态系统捕获,从而正确触发界面刷新。这是鸿蒙声明式UI中一个关键的编程范式。

三、主入口组件:@Entry与@Component装饰器

3.1 组件声明与状态变量定义

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State crafts: CraftItem[] = CRAFTS
  @State masters: MasterItem[] = MASTERS
  @State lessons: LessonItem[] = LESSONS
  @State stalls: StallItem[] = STALLS
  @State mys: MyItem[] = MYS

  @State pickedFeed: FeedItem | null = null
  @State pickedCraft: CraftItem | null = null
  @State pickedMaster: MasterItem | null = null
  @State pickedLesson: LessonItem | null = null
  @State pickedStall: StallItem | null = null
  @State pickedMy: MyItem | null = null

  @State showFeedDetail: boolean = false
  @State showFeedAdd: boolean = false
  @State showCraftDetail: boolean = false
  @State showCraftEdit: boolean = false
  @State showMasterDetail: boolean = false
  @State showMasterBook: boolean = false
  @State showLessonDetail: boolean = false
  @State showLessonSign: boolean = false
  @State showFairRule: boolean = false
  @State showFairDetail: boolean = false
  @State showFairAdd: boolean = false
  @State showFairQuit: boolean = false
  @State showMyRemove: boolean = false
  @State showMyNick: boolean = false
  @State showMyCache: boolean = false
  @State showMyExit: boolean = false

  @State newFeedName: string = ''
  @State newFeedText: string = ''
  @State newCraftName: string = ''
  @State newCraftScore: string = ''
  @State newStallName: string = ''
  @State newStallOwner: string = ''
  @State newNick: string = ''
  @State catSel: number = 0

在这里插入图片描述

这段代码是整个应用的核心组件声明。@Entry装饰器标记Index结构体为应用的入口组件——每个ArkTS页面有且仅有一个@Entry组件,它对应页面的根节点。@Component装饰器标记Index为一个自定义组件,使其可以在build()方法中声明UI结构。

@State装饰器是ArkTS状态管理最核心的装饰器。被@State修饰的变量称为"状态变量",当它们的值发生变化时,ArkUI框架会自动重新执行build()方法中依赖这些变量的部分,实现界面的差分更新。本组件定义了大量状态变量,可分为四类:

第一类:列表数据状态feedscraftsmasterslessonsstallsmys)——这些变量持有六大模块的列表数据,初始值来自静态常量数组,运行时可通过unshiftsplice进行增删改操作。

第二类:选中项状态pickedFeedpickedCraft等)——这些变量类型为FeedItem | null(联合类型),初始值为null,当用户点击某个列表项时被赋值为对应的选中对象,用于在弹窗中展示详情。

第三类:弹窗显隐状态showFeedDetailshowFeedAdd等)——这是一组布尔值,每个对应一个弹窗的显示/隐藏状态。通过条件渲染(if (this.showXxx))控制弹窗的显示与隐藏。

第四类:输入框状态newFeedNamenewFeedText等)——这些变量与TextInput组件双向绑定,存储用户在输入框中输入的内容。

技术要点:@State装饰器详解
@State是ArkTS中最基础的状态装饰器,具有以下特性:(1) 仅在组件内部使用,是组件私有的状态;(2) 支持基本类型(number、string、boolean)、对象类型和数组类型;(3) 当值发生赋值变化时,触发依赖该状态的UI组件重新渲染;(4) 对于数组和对象,需要通过引用变化(如splice返回新数组)才能可靠触发更新。@State变量必须在声明时初始化,不能为undefined

3.2 整体应用架构与组件关系

以下是本应用的整体架构流程图,展示了从入口组件到各子组件、弹窗层的数据流与控制流关系:

0

1

2

3

4

5

回调

回调

回调

回调

回调

回调

回调

回调

回调

回调

回调

回调

回调

回调

回调

回调

@Entry @Component Index

Stack层叠布局

Column主容器

头部区域 Column

内容区域 Column layoutWeight=1

底部Tab栏 Row

Row: Logo + 通知 + 搜索

Column: 宣纸横幅 + 墨点装饰 + 搜索胶囊

currentTab?

HomeContent 首页

CraftContent 手艺

MasterContent 大师

LessonContent 课程

FairContent 市集

MeContent 我的

弹窗层 Overlay

feedDetailOverlay 动态详情

feedAddOverlay 发布动态

craftDetailOverlay 手艺详情

craftEditOverlay 更新指数

masterDetailOverlay 大师详情

masterBookOverlay 拜师预约

lessonDetailOverlay 课程详情

lessonSignOverlay 报名课程

fairRuleOverlay 集市规则

fairDetailOverlay 摊位详情

fairAddOverlay 申请入驻

fairQuitOverlay 撤除摊位

myRemoveOverlay 移除收藏

myNickOverlay 修改昵称

myCacheOverlay 清理缓存

myExitOverlay 退出登录

在这里插入图片描述

上图清晰地展示了应用的分层架构:最外层是Stack层叠布局,它将主内容区域和弹窗层叠放在一起;主内容区域是一个Column纵向容器,从上到下依次排列头部、内容区和底部Tab栏;内容区通过if条件判断,根据currentTab的值渲染不同的子组件;弹窗层位于Stack的最上层,通过一系列布尔状态变量控制显隐,各个子组件通过回调函数触发对应弹窗的显示。

四、Tab栏构建器:@Builder装饰器的运用

4.1 tabItem构建器实现

@Builder
tabItem(icon: string, label: string, tab: number) {
  Column({ space: 3 }) {
    Text(icon)
      .fontSize(22)
      .fontColor(this.currentTab === tab ? '#C2410C' : '#A8A29E')
    Text(label)
      .fontSize(11)
      .fontColor(this.currentTab === tab ? '#C2410C' : '#A8A29E')
  }
  .width('16.66%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
  .animation({ duration: 200, curve: Curve.EaseOut })
  .onClick(() => {
    this.currentTab = tab
  })
}

在这里插入图片描述

@Builder装饰器是ArkTS中用于声明可复用UI片段的装饰器。被@Builder修饰的方法返回一段UI声明,可以在build()方法中多次调用,实现UI结构的复用。与@Component不同,@Builder方法不需要独立的组件实例,它直接在调用处展开,因此性能开销更小,适合用于简单的UI片段复用。

这个tabItem构建器接收三个参数:icon(emoji图标字符串)、label(文字标签)和tab(Tab索引值)。它构建了一个Column纵向容器,内部包含图标Text和标签Text两个子组件。当this.currentTab === tabtrue时(即当前Tab被选中),文字颜色变为朱砂色#C2410C,否则为灰色#A8A29E——这是通过三元运算符实现的动态样式。

技术要点:@Builder装饰器
@Builder方法的主要特点:(1) 用于封装可复用的UI结构片段;(2) 可以接收参数,实现参数化UI;(3) 在编译时展开到调用处,无额外运行时开销;(4) 与@Component相比更轻量,适合简单UI复用;(5) 可以访问宿主组件的this上下文,包括状态变量和方法。

容器使用了scale属性实现选中时的放大效果——选中Tab的x和y方向都放大1.12倍。配合animation属性,这个缩放变化会以200毫秒的EaseOut缓动曲线平滑过渡,给用户一种"弹起"的反馈感。justifyContent(FlexAlign.Center)使子组件在垂直方向居中对齐。宽度设为16.66%(约1/6),因为底部有6个Tab,每个占六分之一的宽度。

技术要点:animation动画属性
animation是ArkUI的隐式动画API,它作用于前一个属性变化。当scaleopacityrotation等可动画属性发生变化时,animation会自动插值过渡。参数duration指定动画时长(毫秒),curve指定缓动曲线(Curve.EaseOut表示先快后慢)。这种声明式动画无需手动管理动画控制器,使用极为简便。

4.2 底部Tab栏布局

Row() {
  this.tabItem('🏠', '首页', 0)
  this.tabItem('🎨', '手艺', 1)
  this.tabItem('👨‍🎨', '大师', 2)
  this.tabItem('📖', '课程', 3)
  this.tabItem('🏮', '市集', 4)
  this.tabItem('👤', '我的', 5)
}
.width('100%')
.height(58)
.backgroundColor('#FFFFFF')
.border({ width: { top: 1 }, color: 'rgba(31,41,55,0.12)' })

在这里插入图片描述

底部Tab栏使用Row横向容器布局,内部通过6次调用this.tabItem()方法生成6个Tab项。Row容器宽度100%,高度58vp,背景白色,顶部有1px的半透明灰色边框线,与上方内容区形成视觉分隔。六个Tab分别对应首页、手艺、大师、课程、市集、我的六个功能模块,使用emoji作为图标,简洁直观。

五、主构建方法:Stack层叠布局与页面骨架

5.1 Stack层叠布局与页面骨架

build() {
  Stack({ alignContent: Alignment.Bottom }) {
    Column() {
      // ===== 头部:宣纸水墨风 =====
      Column() { ... }
      .width('100%')
      .backgroundColor('#DCD2BC')

      // ===== 内容区 =====
      Column() {
        if (this.currentTab === 0) {
          HomeContent({ ... })
        }
        if (this.currentTab === 1) {
          CraftContent({ ... })
        }
        // ... 其他Tab内容
      }
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#F7F3EA')

      // ===== 底部 tab 栏 =====
      Row() { ... }
    }
    .width('100%')
    .height('100%')

    // ===== 弹窗层 =====
    if (this.showFeedDetail && this.pickedFeed !== null) {
      this.feedDetailOverlay(this.pickedFeed, () => {
        this.showFeedDetail = false
      })
    }
    // ... 其他弹窗
  }
  .width('100%')
  .height('100%')
}

在这里插入图片描述

build()方法是每个@Component组件必须实现的方法,它返回组件的UI结构。本组件的build()方法使用Stack作为根容器——Stack是ArkUI的层叠布局容器,子元素按照声明的顺序从下到上层叠排列,后面的子元素覆盖在前面的之上。

StackalignContent参数设为Alignment.Bottom,意味着子元素默认在底部对齐。但实际上,主内容Column设置了width('100%')height('100%'),会撑满整个Stack空间,而弹窗层也设置了width('100%')height('100%'),因此两者完全重叠——弹窗覆盖在主内容之上,形成遮罩效果。

技术要点:Stack层叠布局
Stack是ArkUI三大基础布局容器之一(另外两个是ColumnRow),用于实现层叠效果。子元素按声明顺序从底到顶排列。alignContent参数控制子元素的对齐方式,默认为Alignment.Center。Stack常用于实现弹窗遮罩、悬浮按钮、加载遮罩等覆盖层效果。配合zIndex属性还可以手动调整层级顺序。

主内容Column内部从上到下依次排列三个部分:头部区域、内容区域和底部Tab栏。内容区域使用layoutWeight(1),这是一个非常重要的属性——它表示该组件在父容器中占据剩余的所有空间。头部和底部Tab栏的高度是固定的,内容区域通过layoutWeight(1)自动填充中间的剩余空间,确保在不同屏幕尺寸的设备上都能正确布局。

技术要点:layoutWeight权重分配
layoutWeight是ArkUI中Flex布局和Column/Row容器中的权重分配属性。当容器内有多个子元素时,设置了layoutWeight的子元素会按照权重比例分配剩余空间。layoutWeight(1)表示占用全部剩余空间,这在实现"固定高度头部+自适应高度内容区+固定高度底部"的经典布局时非常关键。在本应用中,一个空Text('').layoutWeight(1)的模式被大量使用,用于在Row中推开两侧元素实现两端对齐。

5.2 头部区域:宣纸水墨风设计

Column() {
  Row() {
    Row() {
      Text('🖌️')
        .fontSize(26)
      Column({ space: 2 }) {
        Text('CRAFT HALL')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
        Text('非遗手艺坊 · 官方')
          .fontSize(10)
          .fontColor('#57534E')
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
    }
    .alignItems(VerticalAlign.Center)
    Text('').layoutWeight(1)
    Text('🔔')
      .fontSize(22)
      .opacity(0.9)
    Text('🔍')
      .fontSize(22)
      .margin({ left: 14 })
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 10 })

  // 宣纸横幅
  Column({ space: 6 }) {
    Row() {
      Text('🖋️ 非遗认证专区')
        .fontSize(11)
        .fontColor('#C2410C')
        .backgroundColor('rgba(194,65,12,0.12)')
        .borderRadius(20)
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
      Text('').layoutWeight(1)
      Text('已收录 86 项非遗手艺')
        .fontSize(11)
        .fontColor('#57534E')
    }
    .width('100%')

    Text('非遗手艺坊')
      .fontSize(26)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1F2937')
      .letterSpacing(2)
    Text('CRAFT HALL · 让老手艺被更多人看见')
      .fontSize(11)
      .fontColor('#57534E')
      .letterSpacing(1)

    // 墨点装饰带
    Row() {
      ForEach([10, 14, 8, 16, 12, 9, 15, 11, 13, 10], (s: number, i: number) => {
        Text(i % 2 === 0 ? '◆' : '▪')
          .fontSize(s)
          .fontColor(i % 3 === 0 ? '#C2410C' : (i % 3 === 1 ? '#4338CA' : '#0E7490'))
          .opacity(0.6)
      }, (s: number, i: number) => 'ink' + i)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .padding({ left: 20, right: 20 })

    // 搜索胶囊
    Row() {
      Text('🔍')
        .fontSize(14)
      Text('搜索手艺、大师、课程、市集摊位')
        .fontSize(13)
        .fontColor('#57534E')
        .margin({ left: 6 })
    }
    .width('90%')
    .height(38)
    .backgroundColor('rgba(255,255,255,0.7)')
    .borderRadius(19)
    .padding({ left: 14 })
    .alignItems(VerticalAlign.Center)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 14 })
  .linearGradient({
    angle: 180,
    colors: [['#F1EBDF', 0.0], ['#E8E0CE', 0.55], ['#DCD2BC', 1.0]]
  })
}
.width('100%')
.backgroundColor('#DCD2BC')

头部区域的代码结构相当丰富,包含两个主要部分:顶部导航栏和宣纸横幅。顶部导航栏使用Row横向布局,左侧是Logo区域(画笔emoji + 标题文字 + 副标题),右侧是通知和搜索图标。这里再次用到了Text('').layoutWeight(1)的技巧——一个空文本占据中间所有剩余空间,将左右两侧的元素推开,实现了左对齐和右对齐共存的效果。

宣纸横幅区域是整个应用视觉设计的精华所在。首先是一个标签行,左侧是朱砂色背景的"非遗认证专区"胶囊标签,右侧是"已收录86项非遗手艺"的统计文字。接着是大标题"非遗手艺坊",字号26,粗体,字间距2——letterSpacing属性增加了文字间的呼吸感,使得中文标题更加优雅。副标题"CRAFT HALL · 让老手艺被更多人看见"使用英文+中文的组合,字间距1。

最精彩的是"墨点装饰带"——使用ForEach渲染了10个不同大小的几何符号(交替),颜色在朱砂、靛蓝、石青三色间循环,透明度0.6,通过justifyContent(FlexAlign.SpaceBetween)均匀分布在整个宽度上。这个装饰带的灵感来自中国传统水墨画中的墨点意象,通过代码精确控制每个墨点的大小、颜色和透明度,营造出一种"墨韵"的视觉氛围。

技术要点:ForEach列表渲染
ForEach是ArkUI中用于列表渲染的核心组件。它接收三个参数:(1) 数据源数组;(2) 项生成函数(item, index) => UI;(3) 键值生成函数(item, index) => string。键值函数返回的唯一标识符用于框架的差分算法——当数据变化时,框架通过键值判断哪些项是新增、删除或移动的,从而高效更新列表。在本例中,墨点装饰带的数据是静态数组[10, 14, 8, 16, 12, 9, 15, 11, 13, 10],键值为'ink' + i

技术要点:linearGradient线性渐变
linearGradient属性为组件添加线性渐变背景。angle指定渐变角度(180表示从上到下),colors数组定义渐变色标——每个色标是一个[颜色, 位置]的元组,位置范围0.0到1.0。本例中渐变从#F1EBDF(浅宣纸色)到#E8E0CE(中间过渡色)再到#DCD2BC(深宣纸色),模拟了宣纸的层次质感。搜索胶囊使用半透明白色背景rgba(255,255,255,0.7),叠在渐变背景上呈现出磨砂玻璃效果。

六、内容区条件渲染:组件化路由

6.1 基于currentTab的条件渲染

Column() {
  if (this.currentTab === 0) {
    HomeContent({
      feeds: this.feeds,
      heat: HEAT,
      catSel: this.catSel,
      showFeedDetail: (item: FeedItem) => {
        this.pickedFeed = item
        this.showFeedDetail = true
      },
      showFeedAdd: () => {
        this.showFeedAdd = true
      }
    })
  }
  if (this.currentTab === 1) {
    CraftContent({
      crafts: this.crafts,
      catSel: this.catSel,
      showCraftDetail: (item: CraftItem) => {
        this.pickedCraft = item
        this.showCraftDetail = true
      },
      showCraftEdit: (item: CraftItem) => {
        this.pickedCraft = item
        this.showCraftEdit = true
      }
    })
  }
  if (this.currentTab === 2) {
    MasterContent({
      masters: this.masters,
      showMasterDetail: (item: MasterItem) => {
        this.pickedMaster = item
        this.showMasterDetail = true
      },
      showMasterBook: (item: MasterItem) => {
        this.pickedMaster = item
        this.showMasterBook = true
      }
    })
  }
  if (this.currentTab === 3) {
    LessonContent({
      lessons: this.lessons,
      showLessonDetail: (item: LessonItem) => {
        this.pickedLesson = item
        this.showLessonDetail = true
      },
      showLessonSign: (item: LessonItem) => {
        this.pickedLesson = item
        this.showLessonSign = true
      }
    })
  }
  if (this.currentTab === 4) {
    FairContent({
      stalls: this.stalls,
      showFairRule: () => {
        this.showFairRule = true
      },
      showFairDetail: (item: StallItem) => {
        this.pickedStall = item
        this.showFairDetail = true
      },
      showFairAdd: () => {
        this.showFairAdd = true
      },
      showFairQuit: (item: StallItem) => {
        this.pickedStall = item
        this.showFairQuit = true
      }
    })
  }
  if (this.currentTab === 5) {
    MeContent({
      mys: this.mys,
      showMyRemove: (item: MyItem) => {
        this.pickedMy = item
        this.showMyRemove = true
      },
      showMyNick: () => {
        this.showMyNick = true
      },
      showMyCache: () => {
        this.showMyCache = true
      },
      showMyExit: () => {
        this.showMyExit = true
      }
    })
  }
}
.layoutWeight(1)
.width('100%')
.backgroundColor('#F7F3EA')

这段代码展示了ArkTS中基于条件渲染实现"组件化路由"的设计模式。通过一系列if (this.currentTab === N)判断,根据当前选中的Tab索引值渲染对应的子组件。当用户点击底部Tab栏时,currentTab状态变量改变,ArkUI框架自动重新执行build()方法,销毁旧Tab的组件树,创建新Tab的组件树,实现了页面切换的效果。

每个子组件在调用时都传递了两类参数:数据属性回调函数。以HomeContent为例,feedsheat是数据属性(从父组件传递到子组件的数据),showFeedDetailshowFeedAdd是回调函数(子组件通过调用这些函数将事件通知给父组件)。这种"数据向下流动,事件向上冒泡"的模式正是Flutter和ArkUI等声明式框架推崇的"单向数据流"架构。

技术要点:ArkTS组件间通信
ArkTS组件间通信遵循单向数据流原则:父组件通过属性向子组件传递数据和回调函数;子组件通过调用回调函数向父组件发送事件。父组件在回调中修改自身的@State状态,状态变化自动触发UI刷新,新数据再通过属性传递给子组件。这种模式避免了组件间的直接状态共享,使数据流清晰可追踪。在本例中,子组件列表项的点击事件通过回调冒泡到父组件,父组件设置pickedXxxshowXxx状态变量,触发弹窗显示。

七、弹窗层架构:@Builder Overlay系统

7.1 弹窗显隐控制机制

// ===== 弹窗层 =====
if (this.showFeedDetail && this.pickedFeed !== null) {
  this.feedDetailOverlay(this.pickedFeed, () => {
    this.showFeedDetail = false
  })
}
if (this.showFeedAdd) {
  this.feedAddOverlay(() => {
    this.showFeedAdd = false
  })
}
if (this.showCraftDetail && this.pickedCraft !== null) {
  this.craftDetailOverlay(this.pickedCraft, () => {
    this.showCraftDetail = false
  })
}
if (this.showCraftEdit && this.pickedCraft !== null) {
  this.craftEditOverlay(this.pickedCraft, () => {
    this.showCraftEdit = false
  })
}
// ... 其余弹窗同理

弹窗层是整个应用架构中最精妙的部分。所有的弹窗都声明在Stack容器的子元素中,位于主内容之上。每个弹窗的显示由两个条件控制:一个布尔状态变量(如showFeedDetail)和一个选中项变量(如pickedFeed)。只有当两个条件同时满足时,弹窗才会渲染。

每个弹窗在调用时都传入一个onClose回调函数,回调函数的内容是this.showXxx = false——即关闭弹窗。当用户在弹窗内部点击关闭按钮或执行操作完成后,调用onClose()函数,父组件的状态变量变为false,条件渲染判定为false,弹窗从组件树中移除,实现了关闭效果。

技术要点:条件渲染弹窗模式
ArkUI中实现弹窗有两种主要方式:(1) 使用内置的DialogControllerCustomDialog装饰器;(2) 使用Stack层叠布局+条件渲染自定义弹窗。本应用选择了第二种方式,因为自定义弹窗的样式控制更加灵活,且与主内容共用同一组件树,数据传递更直接。这种方式的关键是将弹窗放在Stack中,通过布尔状态变量控制显隐,弹窗背景使用半透明颜色实现遮罩效果。

以下是弹窗交互的完整流程图:

用户在子组件中点击列表项

子组件调用回调函数

父组件设置 pickedXxx = item

父组件设置 showXxx = true

条件渲染判定为 true

弹窗 @Builder 渲染显示

用户在弹窗中操作

用户点击关闭/确认/取消

调用 onClose 回调

父组件设置 showXxx = false

条件渲染判定为 false

弹窗从组件树移除

界面恢复主内容

7.2 动态详情弹窗:feedDetailOverlay

@Builder
feedDetailOverlay(item: FeedItem, onClose: () => void) {
  Column() {
    Column({ space: 10 }) {
      Row() {
        Text(item.avatar)
          .fontSize(36)
        Column({ space: 3 }) {
          Text(item.name)
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1F2937')
          Text(item.time)
            .fontSize(11)
            .fontColor('#8A8172')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 10 })
        Text('').layoutWeight(1)
        Text('✕')
          .fontSize(18)
          .fontColor('#57534E')
          .padding(6)
          .onClick(() => {
            onClose()
          })
      }
      .width('100%')

      Text(item.content)
        .fontSize(14)
        .fontColor('#3F3A32')
        .lineHeight(22)

      Row() {
        Text('👍 ' + item.likes)
          .fontSize(12)
          .fontColor('#C2410C')
        Text('💬 ' + item.comments)
          .fontSize(12)
          .fontColor('#C2410C')
          .margin({ left: 16 })
        Text('').layoutWeight(1)
        Text('🖋️ 申请认证')
          .fontSize(12)
          .fontColor('#0E7490')
      }
      .width('100%')

      Row() {
        Text('TA 的手艺铺')
          .fontSize(13)
          .fontColor('#C2410C')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('rgba(194,65,12,0.1)')
          .borderRadius(6)
          .onClick(() => { onClose() })
        Text('去看看')
          .fontSize(13)
          .fontColor('#FFFFFF')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('#C2410C')
          .borderRadius(6)
          .onClick(() => { onClose() })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 6 })
    }
    .width('88%')
    .constraintSize({ maxHeight: '80%' })
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(194,65,12,0.3)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(15,23,42,0.5)')
}

feedDetailOverlay是动态详情弹窗的构建器,接收两个参数:item(选中的动态数据)和onClose(关闭回调)。弹窗的结构分为两层:外层Column作为遮罩层,宽度高度100%,背景半透明深色rgba(15,23,42,0.5),内容居中对齐;内层Column作为弹窗主体,宽度88%,最大高度80%,白色背景,14vp圆角,朱砂色半透明边框。

弹窗主体内部从上到下依次为:头部行(头像+昵称+时间+关闭按钮)、动态正文(lineHeight(22)设置行高增加可读性)、互动数据行(点赞数+评论数+申请认证链接)、底部按钮行("TA的手艺铺"次要按钮+"去看看"主要按钮)。次要按钮使用朱砂色半透明背景rgba(194,65,12,0.1)配合朱砂色文字,主要按钮使用实色朱砂背景配合白色文字——这种"轻量按钮+实色按钮"的组合是移动端常见的操作引导模式。

技术要点:constraintSize约束尺寸
constraintSize属性用于设置组件的尺寸约束,包括minWidthmaxWidthminHeightmaxHeight四个维度。在本例中maxHeight: '80%'确保弹窗内容不超过屏幕高度的80%,防止内容过多时溢出屏幕。这是一种防御性设计,保证弹窗在各种内容长度下都有良好的显示效果。

7.3 发布动态弹窗:feedAddOverlay与TextInput输入

@Builder
feedAddOverlay(onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('发布手艺动态')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
      Text('记录今天的匠心瞬间')
        .fontSize(11)
        .fontColor('#8A8172')

      TextInput({ placeholder: '你的昵称', text: this.newFeedName })
        .fontSize(13)
        .fontColor('#1F2937')
        .placeholderColor('#A8A29E')
        .height(42)
        .backgroundColor('#F7F3EA')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newFeedName = v
        })

      TextInput({ placeholder: '写下动态内容…', text: this.newFeedText })
        .fontSize(13)
        .fontColor('#1F2937')
        .placeholderColor('#A8A29E')
        .height(88)
        .backgroundColor('#F7F3EA')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newFeedText = v
        })

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor('#8A8172')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(42)
          .backgroundColor('#F1EBDF')
          .borderRadius(8)
          .onClick(() => {
            this.newFeedName = ''
            this.newFeedText = ''
            onClose()
          })
        Text('发布')
          .fontSize(14)
          .fontColor('#FFFFFF')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(42)
          .backgroundColor('#C2410C')
          .borderRadius(8)
          .onClick(() => {
            if (this.newFeedName.length > 0 && this.newFeedText.length > 0) {
              this.feeds.unshift(buildFeed(99, this.newFeedName, '🧵', '刚刚', this.newFeedText, 0, 0))
              this.newFeedName = ''
              this.newFeedText = ''
              onClose()
            }
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(67,56,202,0.35)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(15,23,42,0.5)')
}

feedAddOverlay是发布动态的表单弹窗。这里出现了TextInput组件——ArkUI的文本输入框组件。TextInput通过placeholder参数设置占位提示文字,通过text参数绑定当前文本值。onChange回调在用户输入时触发,参数v为输入框的最新内容。在这个弹窗中,两个TextInput分别绑定了this.newFeedNamethis.newFeedText两个状态变量——用户输入的内容实时同步到状态变量中。

"发布"按钮的点击事件包含了完整的表单验证逻辑:通过this.newFeedName.length > 0 && this.newFeedText.length > 0检查两个输入框是否都有内容。验证通过后,调用buildFeed工厂函数构造新的FeedItem对象,使用this.feeds.unshift()将其插入到动态列表的最前面(unshift方法在数组头部插入元素),然后清空输入框状态变量并关闭弹窗。由于feeds@State变量,unshift操作会触发列表的重新渲染,新动态立即出现在首页信息流的顶部。

技术要点:TextInput组件
TextInput是ArkUI的文本输入组件,主要属性包括:placeholder(占位文字)、text(当前文本)、type(输入类型,如InputType.Number数字键盘)、placeholderColor(占位文字颜色)、onChange(文本变化回调)。通过onChange回调将输入内容同步到@State变量,实现"受控输入"模式。注意TextInput本身不支持双向绑定,需要手动在onChange中更新状态。

7.4 手艺详情弹窗:craftDetailOverlay

@Builder
craftDetailOverlay(item: CraftItem, onClose: () => void) {
  Column() {
    Column({ space: 10 }) {
      Row() {
        Text('🏺')
          .fontSize(38)
        Column({ space: 3 }) {
          Text(item.name)
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1F2937')
          Text(item.region + ' · ' + item.cat)
            .fontSize(11)
            .fontColor('#8A8172')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 10 })
        Text('').layoutWeight(1)
        Text('✕')
          .fontSize(18)
          .fontColor('#57534E')
          .padding(6)
          .onClick(() => { onClose() })
      }
      .width('100%')

      Row() {
        Text('⭐ 传承指数 ' + item.score)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#C2410C')
        Text('👥 传承人 ' + item.heirs + ' 位')
          .fontSize(12)
          .fontColor('#57534E')
          .margin({ left: 12 })
        Text('').layoutWeight(1)
        Text(craftStateText(item.state))
          .fontSize(11)
          .fontColor(craftStateColor(item.state))
          .backgroundColor('rgba(14,116,144,0.1)')
          .borderRadius(10)
          .padding({ left: 8, right: 8, top: 2, bottom: 2 })
      }
      .width('100%')

      Text('该技艺入选国家级非物质文化遗产名录,社区内收录了其历史渊源、代表作品与传承人故事,可在线向大师学习。')
        .fontSize(13)
        .fontColor('#3F3A32')
        .lineHeight(21)

      Row() {
        Text('传承人故事')
          .fontSize(13)
          .fontColor('#C2410C')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('rgba(194,65,12,0.1)')
          .borderRadius(6)
          .onClick(() => { onClose() })
        Text('去学手艺')
          .fontSize(13)
          .fontColor('#FFFFFF')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('#C2410C')
          .borderRadius(6)
          .onClick(() => { onClose() })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
      .margin({ top: 6 })
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(194,65,12,0.3)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(15,23,42,0.5)')
}

craftDetailOverlay展示手艺项目的详情信息。弹窗头部使用了一个大号emoji(🏺,字号38)作为视觉焦点,配合手艺名称和地区品类信息。信息行展示了传承指数(星标+分数)、传承人数量(人像图标+位数)和收徒状态标签——状态标签使用了前面定义的craftStateTextcraftStateColor函数,根据item.state的值动态显示"收徒中"或"暂停收徒"以及对应的颜色。

状态标签的样式设计值得注意:使用backgroundColor('rgba(14,116,144,0.1)')作为背景(石青色的10%透明度),配合craftStateColor返回的文字颜色,形成了一种"浅底深字"的标签效果。borderRadius(10)使标签呈现胶囊形状,padding控制内部留白。这种标签样式在整个应用中被大量复用,用于显示各种状态信息。

7.5 更新传承指数弹窗:craftEditOverlay与数据修改

@Builder
craftEditOverlay(item: CraftItem, onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('更新传承指数')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
      Text('「' + item.name + '」当前指数 ' + item.score)
        .fontSize(12)
        .fontColor('#8A8172')

      TextInput({ placeholder: '新传承指数(1-100)', text: this.newCraftScore })
        .type(InputType.Number)
        .fontSize(14)
        .fontColor('#1F2937')
        .placeholderColor('#A8A29E')
        .height(44)
        .backgroundColor('#F7F3EA')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newCraftScore = v
        })

      Text('指数由传承人数量、活跃度与口碑综合评估,更新后同步手艺列表展示')
        .fontSize(11)
        .fontColor('#8A8172')

      Row() {
        Text('取消')
          .onClick(() => {
            this.newCraftScore = ''
            onClose()
          })
        Text('保存更新')
          .onClick(() => {
            const s = parseInt(this.newCraftScore)
            if (s > 0 && s <= 100) {
              const idx = this.crafts.findIndex((c: CraftItem) => c.id === item.id)
              if (idx >= 0) {
                this.crafts.splice(idx, 1, buildCraft(item.id, item.name, item.cat, item.region, s, item.heirs, item.state))
              }
              this.newCraftScore = ''
              onClose()
            }
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(67,56,202,0.35)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(15,23,42,0.5)')
}

craftEditOverlay是一个数据修改弹窗,它展示了ArkTS中"查找-替换"模式的数据更新方法。TextInput使用了type(InputType.Number),这会在用户输入时弹出数字键盘,限制输入内容为数字。

“保存更新"按钮的点击事件逻辑非常典型:首先使用parseInt将输入的字符串转换为整数,然后验证范围(1-100),接着使用findIndex在数组中查找目标项的索引,最后使用splice(idx, 1, newItem)用新构造的对象替换旧对象。splice方法的第一个参数是起始索引,第二个参数是要删除的元素数量(1表示删除一个),后续参数是要插入的新元素——因此splice(idx, 1, newItem)的效果是"用newItem替换索引idx处的元素”。

技术要点:splice方法与状态更新
在ArkTS中,修改@State数组有以下可靠方式:(1) push()尾部追加;(2) unshift()头部追加;(3) splice(idx, 1, newItem)替换元素;(4) splice(idx, 1)删除元素;(5) 直接重新赋值整个数组。findIndex配合splice是最常用的"按ID查找并更新"模式。注意:直接修改数组元素的属性(如this.crafts[0].score = 95)在某些情况下可能不触发UI更新,建议使用splice替换整个对象。

7.6 拜师预约弹窗:masterBookOverlay与名额递减

@Builder
masterBookOverlay(item: MasterItem, onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('拜师预约')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
      Text('向「' + item.name + '」递交拜师学艺申请')
        .fontSize(12)
        .fontColor('#8A8172')

      Row() {
        Text('📜')
          .fontSize(30)
        Column({ space: 3 }) {
          Text('拜师你将获得')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor('#1F2937')
          Text('线上入门课 + 每周直播指点 + 学习进度档案')
            .fontSize(11)
            .fontColor('#8A8172')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 10 })
      }
      .width('100%')
      .padding(12)
      .backgroundColor('rgba(194,65,12,0.06)')
      .borderRadius(10)

      Row() {
        Text('取消')
          .onClick(() => { onClose() })
        Text('确认预约')
          .onClick(() => {
            if (item.quota > 0) {
              const idx = this.masters.findIndex((m: MasterItem) => m.id === item.id)
              if (idx >= 0) {
                const cur = this.masters[idx]
                const nq = cur.quota - 1
                this.masters.splice(idx, 1, buildMaster(cur.id, cur.name, cur.title, cur.craft, cur.fans, cur.score, nq, nq <= 0 ? 1 : 0))
              }
              onClose()
            }
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(67,56,202,0.35)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(15,23,42,0.5)')
}

masterBookOverlay是拜师预约弹窗,它的核心逻辑在于"名额递减"——当用户确认预约时,需要将大师的剩余名额减1,如果名额减到0则自动将状态切换为"名额已满"。

"确认预约"按钮的点击事件逻辑为:首先检查item.quota > 0(还有剩余名额),然后通过findIndex找到大师在数组中的位置,读取当前大师数据cur,计算新名额nq = cur.quota - 1,然后使用buildMaster构造一个新的大师对象——名额更新为nq,状态根据nq <= 0判断为1(名额已满)还是0(可拜师),最后用splice替换。这种"读取-修改-构造-替换"的四步操作是ArkTS中更新数组元素的标准流程。

弹窗中还包含一个信息卡片,展示了拜师后将获得的服务内容(线上入门课+直播指点+进度档案),背景使用极淡的朱砂色rgba(194,65,12,0.06),配合borderRadius(10)圆角,形成一个信息提示区块。

7.7 课程报名弹窗:lessonSignOverlay

@Builder
lessonSignOverlay(item: LessonItem, onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('报名课程')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
      Text('「' + item.name + '」剩余 ' + item.quota + ' 席 · ¥' + item.price + '/人')
        .fontSize(12)
        .fontColor('#8A8172')

      Text('报名后将在开课前 3 天收到材料包与教室地址,支持开课前 24 小时免费取消。')
        .fontSize(11)
        .fontColor('#8A8172')

      Row() {
        Text('再想想')
          .onClick(() => { onClose() })
        Text('确认报名')
          .onClick(() => {
            if (item.quota > 0) {
              const idx = this.lessons.findIndex((l: LessonItem) => l.id === item.id)
              if (idx >= 0) {
                const cur = this.lessons[idx]
                const nq = cur.quota - 1
                this.lessons.splice(idx, 1, buildLesson(cur.id, cur.name, cur.teacher, cur.time, nq, cur.total, cur.price, nq <= 0 ? 1 : 0))
              }
              onClose()
            }
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(67,56,202,0.35)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(15,23,42,0.5)')
}

lessonSignOverlay的课程报名弹窗与拜师预约弹窗的逻辑结构几乎完全相同——都是"名额递减+状态自动切换"模式。不同的是按钮文案的措辞——“再想想"替代"取消”,更加口语化和温和,符合教育类应用的调性。报名说明文字提到了"材料包到家"和"24小时免费取消"等细节信息,提升了用户信任度。

7.8 摊位撤除弹窗:fairQuitOverlay与数组删除

@Builder
fairQuitOverlay(item: StallItem, onClose: () => void) {
  Column() {
    Column({ space: 12 }) {
      Text('⚠️ 撤除摊位')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1F2937')
      Text('确认将「' + item.name + '」撤出集市?')
        .fontSize(13)
        .fontColor('#3F3A32')
      Text('撤摊后摊位将立即下架,已发布的作品与评价记录保留 30 天。')
        .fontSize(11)
        .fontColor('#C2410C')

      Row() {
        Text('再想想')
          .onClick(() => { onClose() })
        Text('确认撤摊')
          .onClick(() => {
            const idx = this.stalls.findIndex((s: StallItem) => s.id === item.id)
            if (idx >= 0) {
              this.stalls.splice(idx, 1)
            }
            onClose()
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(14)
    .border({ width: 1, color: 'rgba(194,65,12,0.4)' })
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor('rgba(15,23,42,0.5)')
}

fairQuitOverlay展示了ArkTS中数组删除的实现方式。与前面的"替换"操作不同,这里使用的是splice(idx, 1)——只传两个参数,表示从索引idx处删除1个元素,不插入新元素。删除后,数组长度减1,后续元素自动前移。

弹窗使用了警示性的设计:标题前缀"⚠️"警告图标,说明文字使用了朱砂色#C2410C而不是普通的灰色,提示用户此操作不可逆。确认按钮的文案是"确认撤摊"而非简单的"确认",使操作语义更加明确。这些细节设计都遵循了移动端"破坏性操作确认"的UX最佳实践。

7.9 其他弹窗概览

除了上述详细分析的弹窗外,应用中还包含以下弹窗,它们的设计模式高度一致:

masterDetailOverlay:大师详情弹窗,展示大师的粉丝数(使用playsText格式化为"万"级显示)、评分和拜师状态,底部有"看作品集"和"去拜师"两个按钮。

lessonDetailOverlay:课程详情弹窗,展示价格(大字号朱砂色突出)、剩余席位和报名状态,底部有"课程大纲"和"立即报名"按钮。

fairRuleOverlay:集市规则弹窗,纯文本展示5条规则,底部一个"知道了"按钮。这是唯一没有"双按钮"的弹窗,因为其性质是信息展示而非操作确认。

fairDetailOverlay:摊位详情弹窗,展示日租金和经营状态,底部有"摊主主页"和"去逛逛"按钮。

fairAddOverlay:申请入驻弹窗,包含摊位名称和摊主昵称两个TextInput输入框,提交后通过unshift将新摊位添加到列表头部。

myRemoveOverlay:移除收藏弹窗,确认后通过splice(idx, 1)从收藏列表中删除对应项。

myNickOverlay:修改昵称弹窗,包含一个TextInput输入框,保存后清空输入并关闭(实际应用中此处应调用后端API保存昵称)。

myCacheOverlay:清理缓存弹窗,展示当前缓存占用86MB,确认后直接关闭。

myExitOverlay:退出登录弹窗,确认后直接关闭(实际应用中此处应执行登出逻辑)。

技术要点:弹窗设计的一致性原则
纵观全部16个弹窗,可以总结出统一的设计规范:(1) 外层Column全屏遮罩,背景rgba(15,23,42,0.5);(2) 内层Column宽度88%,白色背景,14vp圆角;(3) 头部含标题(17vp粗体)+副标题(11-12vp灰色);(4) 底部双按钮行,取消按钮浅色背景,确认按钮朱砂实色背景;(5) 按钮宽度46%,高度40-42vp,6-8vp圆角。这种高度一致的弹窗模板保证了应用视觉的统一性。

八、首页子组件:HomeContent

8.1 组件声明与属性传递

@Component
struct HomeContent {
  @Prop feeds: FeedItem[] = []
  @Prop heat: number[] = []
  @Prop catSel: number = 0
  @State localCat: number = 0
  showFeedDetail: (item: FeedItem) => void = (item: FeedItem) => {}
  showFeedAdd: () => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // ... 内容
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
  }
}

HomeContent是首页内容区的子组件。它使用了@Prop装饰器接收父组件传递的数据——@Prop@State不同,它表示"从父组件单向同步的属性",当父组件中对应数据变化时,@Prop变量会自动更新,但子组件不能修改@Prop变量(修改也不会同步回父组件)。

技术要点:@Prop装饰器
@Prop是ArkTS的子组件属性装饰器,特点:(1) 用于接收父组件传递的数据;(2) 数据同步方向为父到子(单向);(3) 父组件数据变化时自动同步到子组件;(4) 子组件不能反向修改父组件数据;(5) 适用于"展示型"子组件。与之对比,@Link装饰器支持双向同步——子组件修改会同步回父组件。本应用中所有子组件都使用@Prop+回调函数模式,保持了单向数据流。

HomeContent还定义了一个@State localCat局部状态变量,用于管理品类标签的选中状态。这个变量不需要同步到父组件,因此使用@State而非@PropshowFeedDetailshowFeedAdd是普通函数属性(非装饰器修饰),默认值为空函数,实际由父组件在调用时传入具体的回调实现。

build()方法使用Scroll作为根容器——Scroll是ArkUI的滚动容器组件,当内容超出可视区域时允许用户滚动查看。scrollBar(BarState.Off)隐藏了滚动条,使界面更加简洁。内部的Column设置了12vp的间距和四周的padding。

技术要点:Scroll滚动容器
Scroll是ArkUI的滚动容器,特点:(1) 只能包含一个子组件(通常是Column或Row);(2) 当子组件内容超出Scroll尺寸时自动启用滚动;(3) scrollable(ScrollDirection.Horizontal)可设置横向滚动;(4) scrollBar(BarState.Off)隐藏滚动条;(5) 常用于列表内容、长表单等场景。注意:对于长列表(100+项),建议使用List组件而非Scroll+ForEach,因为List支持懒加载。

8.2 非遗头条横幅

Row() {
  Column({ space: 6 }) {
    Text('📜 今日非遗头条')
      .fontSize(11)
      .fontColor('#C2410C')
      .backgroundColor('rgba(194,65,12,0.1)')
      .borderRadius(20)
      .padding({ left: 10, right: 10, top: 3, bottom: 3 })
    Text('「剪花奶奶」全网收徒\n蔚县剪纸线上传承计划启动')
      .fontSize(18)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1F2937')
      .lineHeight(26)
    Text('零基础也能学 · 每周直播指点')
      .fontSize(11)
      .fontColor('#57534E')
  }
  .alignItems(HorizontalAlign.Start)
  .layoutWeight(1)
  Text('✂️')
    .fontSize(52)
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.border({ width: 1, color: 'rgba(194,65,12,0.2)' })
.onClick(() => {
  this.showFeedDetail(this.feeds[0])
})

首页的头条横幅是一个精心设计的卡片组件。左侧是文字内容区(使用layoutWeight(1)占据剩余空间),包含标签("今日非遗头条"胶囊)、标题(两行文字,使用\n换行符分隔)和副标题。右侧是一个52vp的大号剪刀emoji作为装饰。

标题文字使用了\n换行符实现两行显示,配合lineHeight(26)设置行高,使两行文字之间的间距适中。整个横幅可点击——onClick回调调用this.showFeedDetail(this.feeds[0]),点击后展示第一条动态的详情。这里使用的是默认函数参数showFeedDetail,它实际上是由父组件Index传入的回调函数。

8.3 手艺热度柱状图

Column({ space: 8 }) {
  Row() {
    Text('📊 本周手艺热度')
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1F2937')
    Text('').layoutWeight(1)
    Text('近 8 天社区活跃度')
      .fontSize(11)
      .fontColor('#8A8172')
  }
  .width('100%')
  Row() {
    ForEach(this.heat, (v: number, i: number) => {
      Column({ space: 4 }) {
        Text(v.toString())
          .fontSize(9)
          .fontColor('#8A8172')
        Row()
          .width(22)
          .height(heatBar(v))
          .backgroundColor(i % 2 === 0 ? '#C2410C' : '#4338CA')
          .borderRadius(4)
        Text('周' + (i + 1))
          .fontSize(9)
          .fontColor('#A8A29E')
      }
      .layoutWeight(1)
      .height(120)
      .justifyContent(FlexAlign.End)
    }, (v: number, i: number) => 'heat' + i)
  }
  .width('100%')
}
.width('100%')
.padding(14)
.backgroundColor('#FFFFFF')
.borderRadius(14)
.border({ width: 1, color: 'rgba(67,56,202,0.15)' })

这段代码实现了一个自定义的柱状图组件,不使用任何图表库,纯用ArkUI基础组件搭建。数据源是this.heat数组(8个数值),通过ForEach渲染8根柱子。每根柱子是一个Column容器,从上到下依次为:数值标签、柱子本体(一个Row组件设置固定宽度22vp、高度通过heatBar(v)函数动态计算)、周次标签。

柱子的颜色交替使用朱砂色#C2410C和靛蓝色#4338CAi % 2 === 0判断奇偶),形成双色对比效果。Column容器高度固定120vp,justifyContent(FlexAlign.End)使内容底部对齐——这样柱子就会从底部向上生长,符合柱状图的视觉惯例。layoutWeight(1)使8根柱子等宽分布在整行中。

技术要点:自定义图表实现
在ArkUI中实现简单图表无需引入第三方库。柱状图可用ForEach+Row/Column+动态高度实现;折线图可用Stack+Shape+Path实现;饼图可用Stack+多个sector形状实现。关键是通过函数将数据值映射为组件尺寸(如本例的heatBar函数将热度值映射为柱子高度)。这种"数据驱动的尺寸"正是声明式UI的优势所在。

8.4 速览三格与品类标签

Row({ space: 10 }) {
  Column({ space: 4 }) {
    Text('86')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#C2410C')
    Text('收录非遗项目')
      .fontSize(10)
      .fontColor('#8A8172')
    Text('+12 本月')
      .fontSize(10)
      .fontColor('#0E7490')
  }
  .layoutWeight(1)
  .padding({ top: 12, bottom: 12 })
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .alignItems(HorizontalAlign.Center)
  // ... 其余两格结构相同
}
.width('100%')

// 品类标签 + 发布
Row({ space: 8 }) {
  Scroll() {
    Row({ space: 8 }) {
      ForEach(CATS, (c: string, i: number) => {
        Text(c)
          .fontSize(12)
          .fontColor(this.localCat === i ? '#FFFFFF' : '#57534E')
          .backgroundColor(this.localCat === i ? '#C2410C' : '#FFFFFF')
          .borderRadius(16)
          .padding({ left: 14, right: 14, top: 6, bottom: 6 })
          .onClick(() => {
            this.localCat = i
          })
      }, (c: string, i: number) => 'cat' + i)
    }
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .layoutWeight(1)
  Text('发布')
    .fontSize(12)
    .fontColor('#FFFFFF')
    .backgroundColor('#C2410C')
    .borderRadius(16)
    .padding({ left: 14, right: 14, top: 6, bottom: 6 })
    .onClick(() => {
      this.showFeedAdd()
    })
}
.width('100%')

速览三格使用三个等宽的Column(每个layoutWeight(1)),展示收录项目数、认证手艺人、累计教学时长三个关键指标。每个格子内部三行文字:大号数值+指标名称+增量说明。

品类标签区域使用了横向Scroll容器——scrollable(ScrollDirection.Horizontal)设置横向滚动,scrollBar(BarState.Off)隐藏滚动条。内部通过ForEach渲染CATS数组中的6个品类标签。选中标签使用白字朱砂底,未选中使用灰字白底,通过this.localCat === i三元判断切换。点击标签更新localCat状态变量。标签旁边的"发布"按钮点击后调用this.showFeedAdd()触发发布动态弹窗。

技术要点:横向滚动Scroll
Scroll组件默认纵向滚动,通过scrollable(ScrollDirection.Horizontal)可切换为横向滚动。横向滚动常用于标签栏、图片轮播、卡片横滑等场景。配合scrollBar(BarState.Off)隐藏滚动条后,视觉上更加简洁。当标签数量超出可视宽度时,用户可以左右滑动查看更多标签。

8.5 手艺动态列表

ForEach(this.feeds, (item: FeedItem, idx: number) => {
  Row() {
    Text(item.avatar)
      .fontSize(30)
    Column({ space: 4 }) {
      Row() {
        Text(item.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1F2937')
        Text('').layoutWeight(1)
        Text(item.time)
          .fontSize(10)
          .fontColor('#A8A29E')
      }
      .width('100%')
      Text(item.content)
        .fontSize(12)
        .fontColor('#3F3A32')
        .lineHeight(19)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Row({ space: 14 }) {
        Text('👍 ' + item.likes)
          .fontSize(11)
          .fontColor('#C2410C')
        Text('💬 ' + item.comments)
          .fontSize(11)
          .fontColor('#C2410C')
        Text('').layoutWeight(1)
        Text('看手艺 ›')
          .fontSize(11)
          .fontColor('#0E7490')
      }
      .width('100%')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 10 })
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#FFFFFF')
  .borderRadius(12)
  .border({ width: 1, color: 'rgba(31,41,55,0.08)' })
  .onClick(() => {
    this.showFeedDetail(item)
  })
}, (item: FeedItem, idx: number) => 'feed' + item.id)

动态列表使用ForEach渲染this.feeds数组中的每一条动态。每条动态是一个Row卡片,左侧是30vp的emoji头像,右侧是内容区Column。内容区包含三行:头部行(昵称+时间,用layoutWeight推开两端)、正文(maxLines(2)限制两行,textOverflow设置省略号)、互动行(点赞数+评论数+"看手艺"链接)。

技术要点:maxLines与textOverflow
maxLines属性限制Text组件的最大显示行数,超出部分根据textOverflow配置处理。TextOverflow.Ellipsis表示超出部分用省略号(…)替代。这在列表卡片中非常实用——动态内容可能很长,但列表项需要保持统一高度,通过限制行数+省略号可以优雅地处理长文本。lineHeight属性设置行高,19vp的行高使12vp字号的文字阅读舒适。

整个卡片可点击——onClick调用this.showFeedDetail(item),将当前动态数据传递给父组件的回调函数,触发详情弹窗。ForEach的键值函数返回'feed' + item.id,确保每条动态有唯一标识。

九、手艺子组件:CraftContent

9.1 品类横滑标签

@Component
struct CraftContent {
  @Prop crafts: CraftItem[] = []
  @Prop catSel: number = 0
  @State localCat: number = 0
  showCraftDetail: (item: CraftItem) => void = (item: CraftItem) => {}
  showCraftEdit: (item: CraftItem) => void = (item: CraftItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Scroll() {
          Row({ space: 8 }) {
            ForEach(CATS, (c: string, i: number) => {
              Column({ space: 4 }) {
                Text(c)
                  .fontSize(12)
                  .fontColor(this.localCat === i ? '#C2410C' : '#8A8172')
                  .fontWeight(this.localCat === i ? FontWeight.Bold : FontWeight.Normal)
                Row()
                  .width(16)
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor(this.localCat === i ? '#C2410C' : 'transparent')
              }
              .padding({ left: 10, right: 10, top: 6, bottom: 2 })
              .onClick(() => {
                this.localCat = i
              })
            }, (c: string, i: number) => 'cat' + i)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        // ... 双列卡片
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
  }
}

CraftContent的品类标签与首页不同——它使用了一个带下划线的标签设计。每个标签是一个Column,上方是文字,下方是一个3vp高的Row作为下划线指示器。选中时下划线显示朱砂色,未选中时背景为'transparent'(透明)。文字也有选中态变化:选中时朱砂色粗体,未选中时灰色常规体。这种"文字+下划线"的标签风格在内容类应用中非常经典。

9.2 双列卡片网格

ForEach([0, 2, 4, 6, 8], (start: number, row: number) => {
  Row({ space: 10 }) {
    if (start < this.crafts.length) {
      this.craftCard(this.crafts[start])
    }
    if (start + 1 < this.crafts.length) {
      this.craftCard(this.crafts[start + 1])
    } else {
      Column()
        .layoutWeight(1)
        .height(170)
    }
  }
  .width('100%')
}, (start: number, row: number) => 'row' + row)

手艺列表采用了双列网格布局。ForEach遍历[0, 2, 4, 6, 8]这5个起始索引,每个起始索引对应一行——索引0和1是第一行的两个卡片,索引2和3是第二行,以此类推。每行是一个Row,内部调用两次this.craftCard()构建器渲染两个卡片。

当数据项为奇数时,最后一行只有一个卡片,此时用一个空的Column().layoutWeight(1).height(170)占位,保持两侧布局对称。这是一种常见的网格布局"补齐"技巧。

技术要点:ForEach遍历索引数组实现网格
使用ForEach遍历[0, 2, 4, 6, 8]这样的索引数组来实现网格布局,是一种灵活的技巧。它允许每行渲染固定数量的卡片(本例为2列),且能正确处理奇数数据项的最后一行补齐。相比ArkUI的Grid组件,这种方式虽然代码量稍多,但布局灵活性更高——可以轻松调整行间距、列间距、卡片样式等。

9.3 手艺卡片构建器

@Builder
craftCard(item: CraftItem) {
  Column({ space: 8 }) {
    Text(item.cat === '剪纸' ? '✂️' : (item.cat === '织绣' ? '🧵' : (item.cat === '雕刻' ? '🪵' :
      (item.cat === '陶瓷' ? '🏺' : (item.cat === '竹编' ? '🎋' : (item.cat === '漆器' ? '🏺' :
      (item.cat === '银饰' ? '🥈' : (item.cat === '印染' ? '🎨' : '🎭'))))))))
      .fontSize(34)
      .height(54)
    Text(item.name)
      .fontSize(14)
      .fontWeight(FontWeight.Bold)
      .fontColor('#1F2937')
      .maxLines(1)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
    Text(item.region + ' · ' + item.cat)
      .fontSize(10)
      .fontColor('#8A8172')
    Row() {
      Text('⭐ ' + item.score)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor('#C2410C')
      Text('').layoutWeight(1)
      Text(craftStateText(item.state))
        .fontSize(9)
        .fontColor(craftStateColor(item.state))
    }
    .width('100%')
    Row({ space: 6 }) {
      Text('看详情')
        .fontSize(11)
        .fontColor('#C2410C')
        .textAlign(TextAlign.Center)
        .width('48%')
        .height(30)
        .backgroundColor('rgba(194,65,12,0.1)')
        .borderRadius(6)
        .onClick(() => { this.showCraftDetail(item) })
      Text('更新')
        .fontSize(11)
        .fontColor('#FFFFFF')
        .textAlign(TextAlign.Center)
        .width('48%')
        .height(30)
        .backgroundColor('#C2410C')
        .borderRadius(6)
        .onClick(() => { this.showCraftEdit(item) })
    }
    .width('100%')
  }
  .layoutWeight(1)
  .padding(12)
  .backgroundColor('#FFFFFF')
  .borderRadius(14)
  .border({ width: 1, color: 'rgba(67,56,202,0.18)' })
  .alignItems(HorizontalAlign.Start)
}

craftCard是单个手艺卡片的构建器。卡片顶部的emoji图标通过多层嵌套的三元运算符根据品类选择——剪纸用✂️、织绣用🧵、雕刻用🪵等。虽然多层嵌套的三元运算符在可读性上不如switch语句,但在ArkTS声明式UI中,三元运算符是表达式级别的条件判断,可以直接在Text()参数中使用,而switch是语句不能直接嵌入表达式。

卡片底部有两个按钮:“看详情”(浅色)调用showCraftDetail回调,“更新”(实色)调用showCraftEdit回调。两个按钮宽度48%,间距6vp,通过Row({ space: 6 })控制。卡片整体使用layoutWeight(1)使其在双列网格中占据一半宽度,alignItems(HorizontalAlign.Start)使内容左对齐。

十、大师子组件:MasterContent

@Component
struct MasterContent {
  @Prop masters: MasterItem[] = []
  showMasterDetail: (item: MasterItem) => void = (item: MasterItem) => {}
  showMasterBook: (item: MasterItem) => void = (item: MasterItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 3 指标横幅
        Row({ space: 10 }) {
          Column({ space: 4 }) {
            Text('86').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#C2410C')
            Text('认证大师').fontSize(10).fontColor('#8A8172')
          }
          .layoutWeight(1).padding({ top: 14, bottom: 14 })
          .backgroundColor('#FFFFFF').borderRadius(12)
          .alignItems(HorizontalAlign.Center)
          // ... 其余两格
        }
        .width('100%')

        Row() {
          Text('👨‍🎨 非遗大师堂')
            .fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1F2937')
          Text('').layoutWeight(1)
          Text('长按列表项可查看拜师说明')
            .fontSize(11).fontColor('#A8A29E')
        }
        .width('100%')

        ForEach(this.masters, (item: MasterItem, idx: number) => {
          Row() {
            Text(item.craft === '蔚县剪纸' ? '✂️' : (item.craft === '苏绣双面绣' ? '🧵' : 
              (item.craft === '东阳木雕' ? '🪵' : (item.craft === '脱胎漆器' ? '🏺' : 
              (item.craft === '嵊州竹编' ? '🎋' : (item.craft === '景德镇青花' ? '🏺' : 
              (item.craft === '苗银锻造' ? '🥈' : '🎭')))))))
              .fontSize(30)
            Column({ space: 4 }) {
              Text(item.name)
                .fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1F2937')
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(item.title + ' · ' + item.craft)
                .fontSize(11).fontColor('#8A8172')
              Row({ space: 10 }) {
                Text('👥 ' + playsText(item.fans)).fontSize(10).fontColor('#8A8172')
                Text('⭐ ' + item.score).fontSize(10).fontColor('#8A8172')
                Text('剩余名额 ' + item.quota).fontSize(10).fontColor('#C2410C')
              }
              .width('100%')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            .margin({ left: 10 })
            Column({ space: 6 }) {
              Text(masterStateText(item.state))
                .fontSize(10).fontColor(masterStateColor(item.state))
                .backgroundColor('rgba(14,116,144,0.1)')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              Text('拜师')
                .fontSize(11).fontColor('#FFFFFF')
                .textAlign(TextAlign.Center)
                .width(56).height(28)
                .backgroundColor('#C2410C').borderRadius(6)
                .onClick(() => { this.showMasterBook(item) })
            }
          }
          .width('100%').padding(12)
          .backgroundColor('#FFFFFF').borderRadius(12)
          .border({ width: 1, color: 'rgba(31,41,55,0.08)' })
          .onClick(() => { this.showMasterDetail(item) })
        }, (item: MasterItem, idx: number) => 'master' + item.id)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%').height('100%')
    .scrollBar(BarState.Off)
  }
}

MasterContent的大师列表每项采用横向布局:左侧是手艺品类emoji,中间是大师信息区(姓名+头衔+粉丝数/评分/名额),右侧是操作区(状态标签+拜师按钮)。粉丝数使用playsText函数格式化为"万"级显示,评分和剩余名额紧跟其后。

整个列表项可点击触发详情弹窗,右侧的"拜师"按钮单独绑定showMasterBook回调触发拜师弹窗——这里展示了ArkUI事件冒泡的一个关键特性:内部组件的onClick会拦截事件,不会触发外层组件的onClick。因此点击"拜师"按钮只触发拜师弹窗,不会同时触发详情弹窗。

技术要点:事件冒泡与拦截
在ArkUI中,当一个组件嵌套在另一个组件内时,子组件的触摸事件默认会被拦截——即如果子组件绑定了onClick,父组件的onClick不会被触发。这与Web DOM的事件冒泡机制不同。在ArkUI中,如果需要同时触发父子组件的事件,需要使用HitTestMode相关API手动配置事件分发策略。在本例中,"拜师"按钮的onClick独立于列表项的onClick,互不干扰。

十一、课程子组件:LessonContent

@Component
struct LessonContent {
  @Prop lessons: LessonItem[] = []
  showLessonDetail: (item: LessonItem) => void = (item: LessonItem) => {}
  showLessonSign: (item: LessonItem) => void = (item: LessonItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row() {
          Column({ space: 6 }) {
            Text('📖 本期小班课')
              .fontSize(11).fontColor('#C2410C')
              .backgroundColor('rgba(194,65,12,0.1)')
              .borderRadius(20)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            Text('跟大师学手艺\n每周都有新课程')
              .fontSize(18).fontWeight(FontWeight.Bold)
              .fontColor('#1F2937').lineHeight(26)
            Text('小班教学 · 材料包到家 · 作品带回')
              .fontSize(11).fontColor('#57534E')
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
          Text('🎋').fontSize(52)
        }
        .width('100%').padding(14)
        .backgroundColor('#FFFFFF').borderRadius(14)
        .border({ width: 1, color: 'rgba(67,56,202,0.2)' })

        ForEach(this.lessons, (item: LessonItem, idx: number) => {
          Column({ space: 8 }) {
            Row() {
              Text(item.teacher === '剪花奶奶' ? '✂️' : (item.teacher === '顾云娘' ? '🧵' :
                /* ... emoji映射 */ '🎭'))
                .fontSize(30)
              Column({ space: 3 }) {
                Text(item.name)
                  .fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1F2937')
                Text(item.teacher + ' · ' + item.time)
                  .fontSize(11).fontColor('#8A8172')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 }).layoutWeight(1)
              Text(lessonStateText(item.state))
                .fontSize(10).fontColor(lessonStateColor(item.state))
                .backgroundColor('rgba(14,116,144,0.1)')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
            }
            .width('100%')

            Row() {
              Text('剩余 ' + item.quota + '/' + item.total + ' 席')
                .fontSize(10).fontColor('#8A8172')
              Text('').layoutWeight(1)
              Text('¥' + item.price)
                .fontSize(13).fontWeight(FontWeight.Bold).fontColor('#C2410C')
            }
            .width('100%')
            Row() {
              Row()
                .width('100%').height(6)
                .backgroundColor('rgba(194,65,12,0.1)')
                .borderRadius(3)
                .clip(true)
            }
            .width('100%')

            Row({ space: 8 }) {
              Text('课程详情')
                .fontSize(11).fontColor('#C2410C')
                .textAlign(TextAlign.Center)
                .width('46%').height(32)
                .backgroundColor('rgba(194,65,12,0.1)').borderRadius(6)
                .onClick(() => { this.showLessonDetail(item) })
              Text('报名')
                .fontSize(11).fontColor('#FFFFFF')
                .textAlign(TextAlign.Center)
                .width('46%').height(32)
                .backgroundColor('#C2410C').borderRadius(6)
                .onClick(() => { this.showLessonSign(item) })
            }
            .width('100%')
          }
          .width('100%').padding(12)
          .backgroundColor('#FFFFFF').borderRadius(12)
          .border({ width: 1, color: 'rgba(67,56,202,0.15)' })
        }, (item: LessonItem, idx: number) => 'lesson' + item.id)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%').height('100%')
    .scrollBar(BarState.Off)
  }
}

LessonContent的课程列表每项包含三个区域:头部信息行(讲师emoji+课程名+讲师时间+状态标签)、席位价格行(剩余席位+价格)、进度条、操作按钮行。其中最值得注意的是席位进度条——一个6vp高的Row,背景为朱砂色10%透明度,配合borderRadius(3)圆角和clip(true)裁剪。虽然当前代码中进度条的填充比例没有动态计算(固定显示底色),但其结构已经为后续动态填充预留了扩展空间。clip(true)确保子内容不超出圆角边界。

技术要点:clip裁剪属性
clip(true)属性使组件裁剪超出自身边界的内容。当一个组件有圆角(borderRadius)时,子组件如果超出圆角区域会被方形裁剪而非圆角裁剪。设置clip(true)后,子内容会被正确地按圆角形状裁剪。这在实现圆角图片、圆角进度条等效果时非常重要。

十二、市集子组件:FairContent

@Component
struct FairContent {
  @Prop stalls: StallItem[] = []
  showFairRule: () => void = () => {}
  showFairDetail: (item: StallItem) => void = (item: StallItem) => {}
  showFairAdd: () => void = () => {}
  showFairQuit: (item: StallItem) => void = (item: StallItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 8 }) {
          Row() {
            Text('🏮 手艺集市')
              .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#1F2937')
            Text('').layoutWeight(1)
            Text('规则 ›')
              .fontSize(12).fontColor('#C2410C')
              .onClick(() => { this.showFairRule() })
          }
          .width('100%')
          Text('手艺人自己的市集 · 每周上新 · 直播逛摊')
            .fontSize(11).fontColor('#57534E')
          Row() {
            ForEach([1, 2, 3, 4, 5, 6], (s: number, i: number) => {
              Text('🏮')
                .fontSize(16)
                .opacity(0.5 + s * 0.08)
            }, (s: number, i: number) => 'l' + s)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .padding({ left: 20, right: 20 })
        }
        .width('100%').padding(14)
        .backgroundColor('#FFFFFF').borderRadius(14)
        .border({ width: 1, color: 'rgba(194,65,12,0.25)' })

        ForEach(this.stalls, (item: StallItem, idx: number) => {
          Row() {
            Text('🏮').fontSize(26)
            Column({ space: 4 }) {
              Text(item.name)
                .fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1F2937')
              Text(item.owner + ' · ' + item.craft)
                .fontSize(11).fontColor('#8A8172')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1).margin({ left: 10 })
            Column({ space: 6 }) {
              Text(stallStateText(item.state))
                .fontSize(10).fontColor(stallStateColor(item.state))
                .backgroundColor('rgba(14,116,144,0.1)')
                .borderRadius(10)
                .padding({ left: 8, right: 8, top: 2, bottom: 2 })
              Text('租金 ¥' + item.rent)
                .fontSize(10).fontColor('#C2410C')
            }
          }
          .width('100%').padding(12)
          .backgroundColor('#FFFFFF').borderRadius(12)
          .border({ width: 1, color: 'rgba(31,41,55,0.08)' })
          .onClick(() => { this.showFairDetail(item) })
        }, (item: StallItem, idx: number) => 'stall' + item.id)

        Row({ space: 8 }) {
          Text('申请入驻摊位')
            .fontSize(13).fontColor('#FFFFFF')
            .textAlign(TextAlign.Center)
            .width('48%').height(42)
            .backgroundColor('#C2410C').borderRadius(8)
            .onClick(() => { this.showFairAdd() })
          Text('长按列表项撤摊')
            .fontSize(13).fontColor('#C2410C')
            .textAlign(TextAlign.Center)
            .width('48%').height(42)
            .backgroundColor('rgba(194,65,12,0.1)').borderRadius(8)
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 4 })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%').height('100%')
    .scrollBar(BarState.Off)
  }
}

FairContent的市集横幅区域有一个特殊的装饰——6个灯笼emoji通过opacity(0.5 + s * 0.08)实现了透明度递增效果,从0.58到0.98逐渐变亮,配合justifyContent(FlexAlign.SpaceBetween)均匀分布,形成一种灯笼渐次亮起的视觉效果。

摊位列表每项与大师列表类似,采用左中右三段布局:左侧灯笼emoji、中间摊位信息、右侧状态标签和租金。底部有两个按钮:“申请入驻摊位”(实色按钮,触发showFairAdd)和"长按列表项撤摊"(浅色按钮,仅文字提示,实际撤摊通过列表项的长按手势触发)。

十三、个人中心子组件:MeContent

@Component
struct MeContent {
  @Prop mys: MyItem[] = []
  showMyRemove: (item: MyItem) => void = (item: MyItem) => {}
  showMyNick: () => void = () => {}
  showMyCache: () => void = () => {}
  showMyExit: () => void = () => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 档案卡
        Column({ space: 10 }) {
          Row() {
            Text('🖌️').fontSize(44)
            Column({ space: 4 }) {
              Text('墨韵拾遗')
                .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1F2937')
              Text('ID 66 180 23 · 学艺 LV.5')
                .fontSize(11).fontColor('#8A8172')
            }
            .alignItems(HorizontalAlign.Start).margin({ left: 12 })
            Text('').layoutWeight(1)
            Text('编辑 ›')
              .fontSize(12).fontColor('#C2410C')
              .onClick(() => { this.showMyNick() })
          }
          .width('100%')
          Text('已学 4 门手艺 · 完成作品 12 件 · 拜师 1 位')
            .fontSize(11).fontColor('#57534E')
          Row() {
            ForEach([1, 2, 3, 4, 5, 6, 7], (s: number, i: number) => {
              Text('🖋️')
                .fontSize(12)
                .opacity(0.4 + s * 0.08)
            }, (s: number, i: number) => 'seal' + s)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .padding({ left: 16, right: 16 })
        }
        .width('100%').padding(14)
        .backgroundColor('#FFFFFF').borderRadius(14)
        .border({ width: 1, color: 'rgba(194,65,12,0.25)' })

        // 四宫格
        Row({ space: 10 }) {
          Column({ space: 4 }) {
            Text('📖').fontSize(24)
            Text('在学课程').fontSize(11).fontColor('#8A8172')
            Text('2 门').fontSize(11).fontColor('#C2410C')
          }
          .layoutWeight(1).padding(12)
          .backgroundColor('#FFFFFF').borderRadius(12)
          .alignItems(HorizontalAlign.Center)
          // ... 其余三格结构相同
        }
        .width('100%')

        // 我的收藏
        Row() {
          Text('⭐ 我的收藏')
            .fontSize(15).fontWeight(FontWeight.Bold).fontColor('#1F2937')
          Text('').layoutWeight(1)
          Text('共 ' + this.mys.length + ' 条')
            .fontSize(11).fontColor('#A8A29E')
        }
        .width('100%')

        ForEach(this.mys, (item: MyItem, idx: number) => {
          Row() {
            Text('⭐').fontSize(20)
            Column({ space: 3 }) {
              Text(item.name)
                .fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1F2937')
              Text(item.tag + ' · ' + item.time)
                .fontSize(10).fontColor('#A8A29E')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1).margin({ left: 10 })
            Text('移除')
              .fontSize(11).fontColor('#C2410C')
              .backgroundColor('rgba(194,65,12,0.1)')
              .borderRadius(12)
              .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              .onClick(() => { this.showMyRemove(item) })
          }
          .width('100%').padding(12)
          .backgroundColor('#FFFFFF').borderRadius(12)
          .border({ width: 1, color: 'rgba(31,41,55,0.06)' })
        }, (item: MyItem, idx: number) => 'my' + item.id)

        // 设置区
        Row() {
          Text('🧹 清理缓存').fontSize(13).fontColor('#57534E')
          Text('').layoutWeight(1)
          Text('86MB ›')
            .fontSize(12).fontColor('#C2410C')
            .onClick(() => { this.showMyCache() })
        }
        .width('100%').padding(12)
        .backgroundColor('#FFFFFF').borderRadius(12)
        Row() {
          Text('🚪 退出登录').fontSize(13).fontColor('#C2410C')
          Text('').layoutWeight(1)
          Text('›')
            .fontSize(14).fontColor('#C2410C')
            .onClick(() => { this.showMyExit() })
        }
        .width('100%').padding(12)
        .backgroundColor('#FFFFFF').borderRadius(12)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%').height('100%')
    .scrollBar(BarState.Off)
  }
}

MeContent个人中心从上到下依次为:档案卡(用户头像+昵称+ID+学艺等级+学习统计+印章装饰带)、四宫格(在学课程/拜师申请/材料包/徽章)、收藏列表(ForEach渲染收藏项,每项有"移除"按钮)、设置区(清理缓存/退出登录)。

档案卡中的印章装饰带使用了7个🖋️emoji,透明度从0.48递增到0.96(0.4 + s * 0.08),与市集横幅的灯笼渐亮效果异曲同工——都是通过opacity的线性变化营造层次感。收藏列表的数量显示使用了this.mys.length动态获取,当用户移除收藏后,数字会自动更新。

十四、核心概念对比表

以下是本应用中涉及的各类技术要素的系统对比:

技术要素 类型 作用 使用位置 关键特性
@Entry 装饰器 标记入口组件 Index结构体 每个页面仅一个,对应页面根节点
@Component 装饰器 声明自定义组件 Index及6个子组件 可复用,拥有独立build方法
@State 装饰器 组件内部状态变量 currentTab、showXxx等 值变化触发UI刷新,组件私有
@Prop 装饰器 父到子单向数据传递 子组件的feeds、crafts等 父组件变化自动同步到子组件
@Builder 装饰器 声明可复用UI片段 tabItem、craftCard等 编译时展开,无运行时开销
@Link 装饰器 父子双向数据同步 (本应用未使用) 子组件修改同步回父组件
Column 容器组件 纵向布局 几乎所有布局区域 space参数设置子元素间距
Row 容器组件 横向布局 Tab栏、列表项行 space参数设置子元素间距
Stack 容器组件 层叠布局 build根容器 后声明的子元素覆盖在上方
Scroll 容器组件 滚动容器 6个子组件根容器 支持纵向和横向滚动
Flex 容器组件 弹性布局 (本应用通过layoutWeight间接使用) 灵活的权重分配
Text 基础组件 文本显示 全应用 支持fontSize、fontColor等
TextInput 基础组件 文本输入 表单弹窗 支持placeholder、type、onChange
layoutWeight 通用属性 权重分配剩余空间 内容区、空Text占位 1表示占据全部剩余空间
justifyContent 通用属性 主轴对齐方式 容器组件 FlexAlign.Center/SpaceBetween等
alignItems 通用属性 交叉轴对齐方式 Column/Row HorizontalAlign/VerticalAlign
scale 通用属性 缩放变换 Tab选中动画 x、y方向独立缩放
animation 通用属性 隐式动画 Tab选中动画 duration+curve参数
linearGradient 样式属性 线性渐变背景 头部宣纸横幅 angle+colors色标数组
borderRadius 样式属性 圆角 卡片、按钮、弹窗 数值越大圆角越大
ForEach 渲染组件 列表渲染 所有列表和装饰带 数据源+项生成函数+键值函数
if条件渲染 语法 条件渲染 Tab切换、弹窗显隐 条件为true时渲染组件
onClick 事件属性 点击事件 按钮、列表项 接收箭头函数回调
onChange 事件属性 输入变化回调 TextInput 参数v为最新文本值
maxLines 文本属性 最大行数 动态正文、卡片名称 配合textOverflow使用
textOverflow 文本属性 溢出处理 动态正文、卡片名称 Ellipsis表示省略号
opacity 样式属性 透明度 装饰带emoji、遮罩 0.0完全透明到1.0完全不透明
border 样式属性 边框 卡片、弹窗 width+color可分方向设置
clip 通用属性 裁剪超出内容 进度条容器 true时按组件形状裁剪子内容
constraintSize 通用属性 尺寸约束 弹窗主体 maxHeight防止内容溢出
letterSpacing 文本属性 字间距 大标题 增加文字呼吸感

十五、总结与深度回顾

架构设计总结

本文详细剖析了一个完整的鸿蒙ArkTS应用——非遗手艺坊的源代码。从架构层面看,这个应用采用了经典的"单入口+多子组件+弹窗层"三段式架构。@Entry @Component Index作为唯一入口组件,承担了状态管理中枢的角色——所有的业务数据(feeds、crafts、masters等)和UI状态(showXxx布尔值、pickedXxx选中项)都集中在Index组件中管理。六个子组件(HomeContent、CraftContent、MasterContent、LessonContent、FairContent、MeContent)是纯展示型组件,通过@Prop接收数据、通过回调函数上报事件。这种"状态上提、视图下沉"的设计模式使数据流清晰可追踪,是声明式UI框架的最佳实践之一。

状态管理模式总结

应用的状态管理体系是理解其运行机制的关键。Index组件定义了约40个@State状态变量,分为列表数据、选中项、弹窗显隐和输入框文本四类。状态变化的触发路径是:用户操作子组件 -> 子组件调用回调函数 -> Index组件修改@State变量 -> ArkUI框架检测到变化 -> 重新执行build()方法中受影响的部分 -> UI更新。这个闭环是整个应用运转的核心引擎。特别值得强调的是"弹窗显隐+选中项"的双状态控制模式——一个布尔变量控制弹窗是否渲染,一个对象变量存储弹窗要展示的数据,两者配合实现了完整的弹窗交互流程。

视觉设计总结

在视觉设计方面,应用采用了精心策划的"宣纸水墨"配色体系——以宣纸色#F7F3EA为底,白色面板#FFFFFF为卡片,墨色#1F2937为正文,朱砂色#C2410C为主强调色,靛蓝色#4338CA和石青色#0E7490为辅助色。这种配色方案既传达了中国传统非遗文化的古朴气质,又保证了现代移动端界面的清晰度和层次感。应用中的所有卡片都统一使用12-14vp圆角、1px半透明边框、白色背景,形成了高度一致的视觉语言。头部区域的线性渐变背景、墨点装饰带、印章/灯笼渐变透明效果等细节设计,展现了ArkUI强大的样式表达能力。

组件化与复用总结

组件化方面,应用定义了1个入口组件和6个子组件,以及16个@Builder弹窗构建器和2个@BuilderUI片段构建器(tabItem和craftCard)。@Builder的广泛使用是这个应用的一大亮点——它在不引入额外组件实例开销的前提下,实现了UI结构的参数化复用。所有的弹窗都遵循统一的模板(遮罩层+主体卡片+标题+内容+双按钮行),通过参数传入不同的数据和回调,实现了"一套模板渲染16种弹窗"的高复用率。这种设计不仅减少了代码量,更重要的是保证了所有弹窗的视觉和行为一致性。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 宣纸水墨风(浅色):bg #F7F3EA / 面板 #FFFFFF / 墨色 #1F2937 / 朱砂 #C2410C / 靛蓝 #4338CA / 石青 #0E7490

interface FeedItem {
  id: number
  name: string
  avatar: string
  time: string
  content: string
  likes: number
  comments: number
}

interface CraftItem {
  id: number
  name: string
  cat: string
  region: string
  score: number
  heirs: number
  state: number
}

interface MasterItem {
  id: number
  name: string
  title: string
  craft: string
  fans: number
  score: number
  quota: number
  state: number
}

interface LessonItem {
  id: number
  name: string
  teacher: string
  time: string
  quota: number
  total: number
  price: number
  state: number
}

interface StallItem {
  id: number
  name: string
  owner: string
  craft: string
  rent: number
  state: number
}

interface MyItem {
  id: number
  name: string
  tag: string
  time: string
}

const FEEDS: FeedItem[] = [
  { id: 1, name: '剪花阿婆', avatar: '✂️', time: '22分钟前', content: '今天剪了一幅「喜上眉梢」,喜鹊的羽毛用锯齿纹表现,老花镜戴了三层才完工。', likes: 342, comments: 56 },
  { id: 2, name: '绣娘小禾', avatar: '🧵', time: '45分钟前', content: '蜀绣的鲤鱼绣到第七天,鳞片用了套针,阳光下一照,像是真的游起来了。', likes: 287, comments: 44 },
  { id: 3, name: '木雕老周', avatar: '🪵', time: '1小时前', content: '黄杨木雕的关公像开脸了,胡须丝丝分明,打磨到两千目,手感温润如玉。', likes: 256, comments: 38 },
  { id: 4, name: '漆器阿远', avatar: '🏺', time: '2小时前', content: '大漆脱胎的茶则上了第三道朱漆,阴干房湿度 75%,还要等两个月才能起光。', likes: 198, comments: 27 },
  { id: 5, name: '竹编陈伯', avatar: '🎋', time: '4小时前', content: '六角花篮编到第三层,竹篾刮得比纸还薄,指甲缝里全是竹子香。', likes: 312, comments: 49 },
  { id: 6, name: '瓷都小汪', avatar: '🏮', time: '6小时前', content: '青花瓷坯画了「缠枝莲」,钴料要等烧出来才见真色,一千三百度见。', likes: 234, comments: 36 },
  { id: 7, name: '银匠阿花', avatar: '🥈', time: '昨天', content: '苗银的蝴蝶银簪錾好了,翅膀上的细纹用錾子敲了两千多下,颤巍巍像真蝶。', likes: 276, comments: 42 },
  { id: 8, name: '皮影老韩', avatar: '🎭', time: '昨天', content: '新刻的皮影「孙悟空」上色完成,驴皮透亮,灯一打,金箍棒都在闪光。', likes: 265, comments: 40 }
]

const CRAFTS: CraftItem[] = [
  { id: 1, name: '蔚县剪纸', cat: '剪纸', region: '河北蔚县', score: 98, heirs: 126, state: 0 },
  { id: 2, name: '苏绣双面绣', cat: '织绣', region: '江苏苏州', score: 97, heirs: 98, state: 0 },
  { id: 3, name: '东阳木雕', cat: '雕刻', region: '浙江东阳', score: 96, heirs: 112, state: 0 },
  { id: 4, name: '福州脱胎漆器', cat: '漆器', region: '福建福州', score: 95, heirs: 64, state: 0 },
  { id: 5, name: '嵊州竹编', cat: '竹编', region: '浙江嵊州', score: 94, heirs: 87, state: 0 },
  { id: 6, name: '景德镇青花', cat: '陶瓷', region: '江西景德镇', score: 99, heirs: 143, state: 0 },
  { id: 7, name: '苗银锻造', cat: '银饰', region: '贵州凯里', score: 93, heirs: 52, state: 1 },
  { id: 8, name: '潍坊风筝', cat: '风筝', region: '山东潍坊', score: 92, heirs: 76, state: 0 },
  { id: 9, name: '蓝印花布', cat: '印染', region: '江苏南通', score: 91, heirs: 45, state: 1 },
  { id: 10, name: '唐山皮影', cat: '皮影', region: '河北唐山', score: 90, heirs: 58, state: 0 }
]

const MASTERS: MasterItem[] = [
  { id: 1, name: '剪花奶奶·王秀英', title: '剪纸国大师 No.1', craft: '蔚县剪纸', fans: 52800, score: 99, quota: 6, state: 0 },
  { id: 2, name: '绣针·顾云娘', title: '苏绣传承人 No.1', craft: '苏绣双面绣', fans: 43600, score: 98, quota: 12, state: 0 },
  { id: 3, name: '刀客·周木堂', title: '木雕大师 No.2', craft: '东阳木雕', fans: 31800, score: 96, quota: 0, state: 1 },
  { id: 4, name: '漆缘·林远', title: '漆器名家 No.1', craft: '脱胎漆器', fans: 27400, score: 95, quota: 8, state: 0 },
  { id: 5, name: '篾匠·陈守节', title: '竹编匠人 No.1', craft: '嵊州竹编', fans: 25600, score: 94, quota: 15, state: 0 },
  { id: 6, name: '瓷火·汪窑主', title: '青花瓷匠 No.1', craft: '景德镇青花', fans: 38200, score: 97, quota: 10, state: 0 },
  { id: 7, name: '银铃·阿花', title: '苗银匠人 No.2', craft: '苗银锻造', fans: 19300, score: 92, quota: 0, state: 1 },
  { id: 8, name: '光影·韩老生', title: '皮影名家 No.1', craft: '唐山皮影', fans: 22600, score: 93, quota: 9, state: 0 }
]

const LESSONS: LessonItem[] = [
  { id: 1, name: '入门剪纸·对称纹样', teacher: '剪花奶奶', time: '每周六 10:00', quota: 6, total: 15, price: 68, state: 0 },
  { id: 2, name: '苏绣基础·直针套针', teacher: '顾云娘', time: '每周日 14:00', quota: 0, total: 12, price: 128, state: 1 },
  { id: 3, name: '木雕开坯体验', teacher: '周木堂', time: '09-05 13:00', quota: 8, total: 10, price: 158, state: 0 },
  { id: 4, name: '大漆髹涂入门', teacher: '林远', time: '09-08 10:00', quota: 4, total: 8, price: 188, state: 0 },
  { id: 5, name: '竹编六角花篮', teacher: '陈守节', time: '09-12 09:30', quota: 10, total: 20, price: 88, state: 0 },
  { id: 6, name: '青花勾线技法', teacher: '汪窑主', time: '09-15 14:00', quota: 5, total: 12, price: 138, state: 0 },
  { id: 7, name: '苗银錾刻体验', teacher: '阿花', time: '09-19 10:00', quota: 0, total: 6, price: 228, state: 1 },
  { id: 8, name: '皮影雕刻入门', teacher: '韩老生', time: '09-22 15:00', quota: 7, total: 10, price: 108, state: 0 }
]

const STALLS: StallItem[] = [
  { id: 1, name: '剪花小铺', owner: '剪花阿婆', craft: '剪纸窗花', rent: 60, state: 0 },
  { id: 2, name: '绣坊·云锦阁', owner: '绣娘小禾', craft: '苏绣团扇', rent: 80, state: 0 },
  { id: 3, name: '木艺·刀痕堂', owner: '木雕老周', craft: '黄杨木雕', rent: 100, state: 0 },
  { id: 4, name: '漆器·朱砂里', owner: '漆器阿远', craft: '大漆茶则', rent: 90, state: 1 },
  { id: 5, name: '竹编·青篾坊', owner: '竹编陈伯', craft: '竹编器物', rent: 50, state: 0 },
  { id: 6, name: '瓷坊·青花引', owner: '瓷都小汪', craft: '青花茶器', rent: 70, state: 0 },
  { id: 7, name: '银铺·蝴蝶扣', owner: '银匠阿花', craft: '苗银首饰', rent: 110, state: 0 },
  { id: 8, name: '影戏·光与影', owner: '皮影老韩', craft: '皮影摆件', rent: 65, state: 0 }
]

const MYS: MyItem[] = [
  { id: 1, name: '蔚县剪纸·喜上眉梢', tag: '剪纸', time: '收藏于 08-23' },
  { id: 2, name: '苏绣·双面猫扑蝶', tag: '织绣', time: '收藏于 08-21' },
  { id: 3, name: '黄杨木雕·关公像', tag: '雕刻', time: '收藏于 08-18' },
  { id: 4, name: '青花·缠枝莲茶盏', tag: '陶瓷', time: '收藏于 08-15' },
  { id: 5, name: '苗银·蝴蝶银簪', tag: '银饰', time: '收藏于 08-12' },
  { id: 6, name: '竹编·六角花篮', tag: '竹编', time: '收藏于 08-09' },
  { id: 7, name: '皮影·孙悟空', tag: '皮影', time: '收藏于 08-06' },
  { id: 8, name: '蓝印花布·包袱布', tag: '印染', time: '收藏于 08-03' }
]

const HEAT: number[] = [4, 6, 5, 8, 7, 9, 8, 10]

const CATS: string[] = ['全部', '剪纸', '织绣', '雕刻', '陶瓷', '竹编']

function heatBar(v: number): string {
  return (16 + v * 4) + 'vp'
}

function playsText(p: number): string {
  if (p >= 10000) {
    return (p / 10000).toFixed(1) + '万'
  }
  return p.toString()
}

function craftStateText(s: number): string {
  if (s === 0) {
    return '收徒中'
  }
  return '暂停收徒'
}

function craftStateColor(s: number): string {
  if (s === 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

function masterStateText(s: number): string {
  if (s === 0) {
    return '可拜师'
  }
  return '名额已满'
}

function masterStateColor(s: number): string {
  if (s === 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

function lessonStateText(s: number): string {
  if (s === 0) {
    return '有名额'
  }
  return '已报满'
}

function lessonStateColor(s: number): string {
  if (s === 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

function stallStateText(s: number): string {
  if (s === 0) {
    return '经营中'
  }
  return '已打烊'
}

function stallStateColor(s: number): string {
  if (s === 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

function trendText(t: number): string {
  if (t > 0) {
    return '+' + t + '%'
  }
  return t + '%'
}

function trendColor(t: number): string {
  if (t >= 0) {
    return '#0E7490'
  }
  return '#C2410C'
}

function buildFeed(id: number, name: string, avatar: string, time: string, content: string, likes: number, comments: number): FeedItem {
  return { id: id, name: name, avatar: avatar, time: time, content: content, likes: likes, comments: comments }
}

function buildMy(id: number, name: string, tag: string, time: string): MyItem {
  return { id: id, name: name, tag: tag, time: time }
}

function buildCraft(id: number, name: string, cat: string, region: string, score: number, heirs: number, state: number): CraftItem {
  return { id: id, name: name, cat: cat, region: region, score: score, heirs: heirs, state: state }
}

function buildMaster(id: number, name: string, title: string, craft: string, fans: number, score: number, quota: number, state: number): MasterItem {
  return { id: id, name: name, title: title, craft: craft, fans: fans, score: score, quota: quota, state: state }
}

function buildLesson(id: number, name: string, teacher: string, time: string, quota: number, total: number, price: number, state: number): LessonItem {
  return { id: id, name: name, teacher: teacher, time: time, quota: quota, total: total, price: price, state: state }
}

function buildStall(id: number, name: string, owner: string, craft: string, rent: number, state: number): StallItem {
  return { id: id, name: name, owner: owner, craft: craft, rent: rent, state: state }
}

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State crafts: CraftItem[] = CRAFTS
  @State masters: MasterItem[] = MASTERS
  @State lessons: LessonItem[] = LESSONS
  @State stalls: StallItem[] = STALLS
  @State mys: MyItem[] = MYS
  @State pickedFeed: FeedItem | null = null
  @State pickedCraft: CraftItem | null = null
  @State pickedMaster: MasterItem | null = null
  @State pickedLesson: LessonItem | null = null
  @State pickedStall: StallItem | null = null
  @State pickedMy: MyItem | null = null
  @State showFeedDetail: boolean = false
  @State showFeedAdd: boolean = false
  @State showCraftDetail: boolean = false
  @State showCraftEdit: boolean = false
  @State showMasterDetail: boolean = false
  @State showMasterBook: boolean = false
  @State showLessonDetail: boolean = false
  @State showLessonSign: boolean = false
  @State showFairRule: boolean = false
  @State showFairDetail: boolean = false
  @State showFairAdd: boolean = false
  @State showFairQuit: boolean = false
  @State showMyRemove: boolean = false
  @State showMyNick: boolean = false
  @State showMyCache: boolean = false
  @State showMyExit: boolean = false
  @State newFeedName: string = ''
  @State newFeedText: string = ''
  @State newCraftName: string = ''
  @State newCraftScore: string = ''
  @State newStallName: string = ''
  @State newStallOwner: string = ''
  @State newNick: string = ''
  @State catSel: number = 0

  @Builder
  tabItem(icon: string, label: string, tab: number) {
    Column({ space: 3 }) {
      Text(icon)
        .fontSize(22)
        .fontColor(this.currentTab === tab ? '#C2410C' : '#A8A29E')
      Text(label)
        .fontSize(11)
        .fontColor(this.currentTab === tab ? '#C2410C' : '#A8A29E')
    }
    .width('16.66%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .scale({ x: this.currentTab === tab ? 1.12 : 1, y: this.currentTab === tab ? 1.12 : 1 })
    .animation({ duration: 200, curve: Curve.EaseOut })
    .onClick(() => {
      this.currentTab = tab
    })
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Column() {
        // ===== 头部:宣纸水墨风 =====
        Column() {
          Row() {
            Row() {
              Text('🖌️')
                .fontSize(26)
              Column({ space: 2 }) {
                Text('CRAFT HALL')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1F2937')
                Text('非遗手艺坊 · 官方')
                  .fontSize(10)
                  .fontColor('#57534E')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 8 })
            }
            .alignItems(VerticalAlign.Center)
            Text('').layoutWeight(1)
            Text('🔔')
              .fontSize(22)
              .opacity(0.9)
            Text('🔍')
              .fontSize(22)
              .margin({ left: 14 })
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 10 })

          // 宣纸横幅
          Column({ space: 6 }) {
            Row() {
              Text('🖋️ 非遗认证专区')
                .fontSize(11)
                .fontColor('#C2410C')
                .backgroundColor('rgba(194,65,12,0.12)')
                .borderRadius(20)
                .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              Text('').layoutWeight(1)
              Text('已收录 86 项非遗手艺')
                .fontSize(11)
                .fontColor('#57534E')
            }
            .width('100%')

            Text('非遗手艺坊')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1F2937')
              .letterSpacing(2)
            Text('CRAFT HALL · 让老手艺被更多人看见')
              .fontSize(11)
              .fontColor('#57534E')
              .letterSpacing(1)

            // 墨点装饰带
            Row() {
              ForEach([10, 14, 8, 16, 12, 9, 15, 11, 13, 10], (s: number, i: number) => {
                Text(i % 2 === 0 ? '◆' : '▪')
                  .fontSize(s)
                  .fontColor(i % 3 === 0 ? '#C2410C' : (i % 3 === 1 ? '#4338CA' : '#0E7490'))
                  .opacity(0.6)
              }, (s: number, i: number) => 'ink' + i)
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .padding({ left: 20, right: 20 })

            // 搜索胶囊
            Row() {
              Text('🔍')
                .fontSize(14)
              Text('搜索手艺、大师、课程、市集摊位')
                .fontSize(13)
                .fontColor('#57534E')
                .margin({ left: 6 })
            }
            .width('90%')
            .height(38)
            .backgroundColor('rgba(255,255,255,0.7)')
            .borderRadius(19)
            .padding({ left: 14 })
            .alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 14 })
          .linearGradient({
            angle: 180,
            colors: [['#F1EBDF', 0.0], ['#E8E0CE', 0.55], ['#DCD2BC', 1.0]]
          })
        }
        .width('100%')
        .backgroundColor('#DCD2BC')

        // ===== 内容区 =====
        Column() {
          if (this.currentTab === 0) {
            HomeContent({
              feeds: this.feeds,
              heat: HEAT,
              catSel: this.catSel,
              showFeedDetail: (item: FeedItem) => {
                this.pickedFeed = item
                this.showFeedDetail = true
              },
              showFeedAdd: () => {
                this.showFeedAdd = true
              }
            })
          }
          if (this.currentTab === 1) {
            CraftContent({
              crafts: this.crafts,
              catSel: this.catSel,
              showCraftDetail: (item: CraftItem) => {
                this.pickedCraft = item
                this.showCraftDetail = true
              },
              showCraftEdit: (item: CraftItem) => {
                this.pickedCraft = item
                this.showCraftEdit = true
              }
            })
          }
          if (this.currentTab === 2) {
            MasterContent({
              masters: this.masters,
              showMasterDetail: (item: MasterItem) => {
                this.pickedMaster = item
                this.showMasterDetail = true
              },
              showMasterBook: (item: MasterItem) => {
                this.pickedMaster = item
                this.showMasterBook = true
              }
            })
          }
          if (this.currentTab === 3) {
            LessonContent({
              lessons: this.lessons,
              showLessonDetail: (item: LessonItem) => {
                this.pickedLesson = item
                this.showLessonDetail = true
              },
              showLessonSign: (item: LessonItem) => {
                this.pickedLesson = item
                this.showLessonSign = true
              }
            })
          }
          if (this.currentTab === 4) {
            FairContent({
              stalls: this.stalls,
              showFairRule: () => {
                this.showFairRule = true
              },
              showFairDetail: (item: StallItem) => {
                this.pickedStall = item
                this.showFairDetail = true
              },
              showFairAdd: () => {
                this.showFairAdd = true
              },
              showFairQuit: (item: StallItem) => {
                this.pickedStall = item
                this.showFairQuit = true
              }
            })
          }
          if (this.currentTab === 5) {
            MeContent({
              mys: this.mys,
              showMyRemove: (item: MyItem) => {
                this.pickedMy = item
                this.showMyRemove = true
              },
              showMyNick: () => {
                this.showMyNick = true
              },
              showMyCache: () => {
                this.showMyCache = true
              },
              showMyExit: () => {
                this.showMyExit = true
              }
            })
          }
        }
        .layoutWeight(1)
        .width('100%')
        .backgroundColor('#F7F3EA')

        // ===== 底部 tab 栏 =====
        Row() {
          this.tabItem('🏠', '首页', 0)
          this.tabItem('🎨', '手艺', 1)
          this.tabItem('👨‍🎨', '大师', 2)
          this.tabItem('📖', '课程', 3)
          this.tabItem('🏮', '市集', 4)
          this.tabItem('👤', '我的', 5)
        }
        .width('100%')
        .height(58)
        .backgroundColor('#FFFFFF')
        .border({ width: { top: 1 }, color: 'rgba(31,41,55,0.12)' })
      }
      .width('100%')
      .height('100%')

      // ===== 弹窗层 =====
      if (this.showFeedDetail && this.pickedFeed !== null) {
        this.feedDetailOverlay(this.pickedFeed, () => {
          this.showFeedDetail = false
        })
      }
      if (this.showFeedAdd) {
        this.feedAddOverlay(() => {
          this.showFeedAdd = false
        })
      }
      if (this.showCraftDetail && this.pickedCraft !== null) {
        this.craftDetailOverlay(this.pickedCraft, () => {
          this.showCraftDetail = false
        })
      }
      if (this.showCraftEdit && this.pickedCraft !== null) {
        this.craftEditOverlay(this.pickedCraft, () => {
          this.showCraftEdit = false
        })
      }
      if (this.showMasterDetail && this.pickedMaster !== null) {
        this.masterDetailOverlay(this.pickedMaster, () => {
          this.showMasterDetail = false
        })
      }
      if (this.showMasterBook && this.pickedMaster !== null) {
        this.masterBookOverlay(this.pickedMaster, () => {
          this.showMasterBook = false
        })
      }
      if (this.showLessonDetail && this.pickedLesson !== null) {
        this.lessonDetailOverlay(this.pickedLesson, () => {
          this.showLessonDetail = false
        })
      }
      if (this.showLessonSign && this.pickedLesson !== null) {
        this.lessonSignOverlay(this.pickedLesson, () => {
          this.showLessonSign = false
        })
      }
      if (this.showFairRule) {
        this.fairRuleOverlay(() => {
          this.showFairRule = false
        })
      }
      if (this.showFairDetail && this.pickedStall !== null) {
        this.fairDetailOverlay(this.pickedStall, () => {
          this.showFairDetail = false
        })
      }
      if (this.showFairAdd) {
        this.fairAddOverlay(() => {
          this.showFairAdd = false
        })
      }
      if (this.showFairQuit && this.pickedStall !== null) {
        this.fairQuitOverlay(this.pickedStall, () => {
          this.showFairQuit = false
        })
      }
      if (this.showMyRemove && this.pickedMy !== null) {
        this.myRemoveOverlay(this.pickedMy, () => {
          this.showMyRemove = false
        })
      }
      if (this.showMyNick) {
        this.myNickOverlay(() => {
          this.showMyNick = false
        })
      }
      if (this.showMyCache) {
        this.myCacheOverlay(() => {
          this.showMyCache = false
        })
      }
      if (this.showMyExit) {
        this.myExitOverlay(() => {
          this.showMyExit = false
        })
      }
    }
    .width('100%')
    .height('100%')
  }

  @Builder
  feedDetailOverlay(item: FeedItem, onClose: () => void) {
    Column() {
      Column({ space: 10 }) {
        Row() {
          Text(item.avatar)
            .fontSize(36)
          Column({ space: 3 }) {
            Text(item.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1F2937')
            Text(item.time)
              .fontSize(11)
              .fontColor('#8A8172')
          }
          .alignItems(HorizontalAlign.Start)
          .margin({ left: 10 })
          Text('').layoutWeight(1)
          Text('✕')
            .fontSize(18)
            .fontColor('#57534E')
            .padding(6)
            .onClick(() => {
              onClose()
            })
        }
        .width('100%')

        Text(item.content)
          .fontSize(14)
          .fontColor('#3F3A32')
          .lineHeight(22)

        Row() {
          Text('👍 ' + item.likes)
            .fontSize(12)
            .fontColor('#C2410C')
          Text('💬 ' + item.comments)
            .fontSize(12)
            .fontColor('#C2410C')
            .margin({ left: 16 })
          Text('').layoutWeight(1)
          Text('🖋️ 申请认证')
            .fontSize(12)
            .fontColor('#0E7490')
        }
        .width('100%')

        Row() {
          Text('TA 的手艺铺')
            .fontSize(13)
            .fontColor('#C2410C'
        .borderRadius(12)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 16 })
    }
    .width('100%')
    .height('100%')
    .scrollBar(BarState.Off)
  }
}

// ===== 2180.ets END =====


在这里插入图片描述

数据操作模式总结

应用中涉及的数据操作主要有三种:新增(unshift头部插入)、删除(splice(idx, 1))、更新(splice(idx, 1, newItem)替换)。这三种操作都直接作用于@State数组,ArkUI框架会自动检测数组变化并触发UI刷新。应用没有使用额外的状态管理库(如Redux或MobX),所有状态都在组件层级内管理,这对于一个中等复杂度的单页面应用来说是完全足够的。工厂函数(buildFeed、buildCraft等)在数据构造中扮演了重要角色——它们确保新构造的对象符合接口类型约束,配合TypeScript的静态类型检查,在编译阶段就能捕获类型错误。

ArkUI技术能力总结

从ArkUI技术能力的角度看,本应用全面运用了声明式UI的核心特性。布局方面,综合使用了Column纵向布局、Row横向布局、Stack层叠布局和Scroll滚动容器,通过layoutWeight实现弹性空间分配。渲染方面,ForEach列表渲染配合键值函数实现了高效的差分更新,if条件渲染实现了Tab切换和弹窗显隐。样式方面,linearGradient渐变、borderRadius圆角、border边框、opacity透明度、scale缩放、animation动画等属性的组合使用,构建出了丰富的视觉效果。交互方面,onClick点击事件和onChange输入变化回调覆盖了全部用户交互场景。Text组件的maxLines+textOverflow文本溢出处理、clip裁剪、constraintSize尺寸约束等细节属性的运用,展现了开发者对ArkUI API的深入理解。

Logo

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

更多推荐