鸿蒙开发背景与 ArkUI 声明式范式概述

鸿蒙操作系统(HarmonyOS)是华为推出的一款面向万物互联时代的分布式操作系统,其核心设计理念是"一次开发,多端部署"。在鸿蒙的应用开发体系中,ArkUI 框架承担着构建用户界面的核心职责。ArkUI 采用了声明式 UI 编程范式,开发者只需描述界面在任意状态下的样子,框架会自动处理状态变化到界面更新的映射关系,这从根本上改变了传统命令式编程中手动操作 DOM 节点、手动刷新界面的繁琐模式。声明式 UI 的最大优势在于:代码结构清晰、可读性强、易于维护,同时框架层面可以做更激进的渲染优化,因为开发者声明的是"结果"而非"过程",框架可以自行决定最优的更新策略。

ArkTS 是鸿蒙生态中专门为应用开发设计的编程语言,它在 TypeScript 的基础上进行了扩展和约束。ArkTS 保留了 TypeScript 的类型系统优势,同时针对声明式 UI 编程做了大量语法层面的增强。在 ArkTS 中,我们可以使用 interface 定义严格类型的数据结构,使用 const 声明不可变常量数据,使用函数封装可复用的业务逻辑。更重要的是,ArkTS 引入了一批装饰器(Decorator)语法,如 @Entry@Component@State@Prop@Builder 等,这些装饰器是 ArkUI 声明式范式的核心基石。通过装饰器,开发者可以用最简洁的语法标记组件的入口、状态变量、构建器函数等关键概念,让编译器在编译阶段就能进行充分的静态分析和优化。

ArkUI 的组件体系是其声明式 UI 范式的具体落地载体。ArkUI 提供了丰富的基础组件和容器组件,基础组件如 Text(文本)、Image(图片)、TextInput(输入框)、Button(按钮)、Progress(进度条)、Toggle(开关)等;容器组件如 Column(纵向布局)、Row(横向布局)、Stack(层叠布局)、Flex(弹性布局)、Scroll(滚动容器)、List(列表)等。这些组件通过链式调用的方式配置属性和事件,形成一种极具表达力的 DSL(领域特定语言)风格。每一个组件都可以通过点语法串联多个属性方法,如 .width().height().fontSize().fontColor().backgroundColor().borderRadius().padding().margin() 等,这种链式调用方式让 UI 代码既紧凑又直观。

在状态管理方面,ArkUI 提供了一套精细化的状态驱动机制。@State 装饰器用于声明组件内部的可变状态变量,当这些变量的值发生变化时,框架会自动触发依赖该变量的 UI 片段重新渲染。@Prop 装饰器用于父子组件之间的单向数据传递,父组件数据变化会自动同步到子组件。此外,ArkUI 还提供了 @Builder 装饰器用于定义可复用的 UI 构建函数,@Entry 装饰器标记应用的根组件入口,@Component 装饰器声明一个自定义组件结构体。这些装饰器协同工作,构成了鸿蒙声明式 UI 开发的完整工具链。本文将以一个功能完整的钓鱼直播站应用为例,逐段拆解其代码实现,深入剖析 ArkUI 的各项技术要点。

一、数据建模层:接口定义与类型约束

在任何一款结构良好的应用中,数据建模都是最基础也是最关键的一环。ArkTS 的 interface 语法为我们提供了定义数据结构的强类型手段。下面是本应用中定义的六组核心数据接口。

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

interface SpotItem {
  id: number
  name: string
  kind: string
  depth: number
  fish: number
  quota: number
  state: number
}

interface LiveItem {
  id: number
  name: string
  title: string
  fans: number
  watch: number
  state: number
}

interface WaterItem {
  id: number
  name: string
  area: number
  quota: number
  price: number
  state: number
}

interface MasterItem {
  id: number
  name: string
  skill: string
  honor: number
  quota: number
  state: number
}

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

在这里插入图片描述

这六组接口分别对应应用中的六大业务实体:渔获动态(FeedItem)、钓点信息(SpotItem)、直播信息(LiveItem)、水域资源(WaterItem)、大师信息(MasterItem)、我的收藏(MyItem)。每一个接口都明确定义了该实体所包含的字段名称和字段类型,这种严格的类型约束是 ArkTS 区别于普通 JavaScript 的核心特征之一。

技术概念强调: 在 ArkTS 中,interface 定义的类型会在编译阶段进行严格检查。如果在代码中试图给一个 FeedItem 对象赋值时遗漏了某个字段,或者给字段赋了错误类型的值,编译器会立即报错并阻止构建。这种"编译期类型安全"机制能在开发早期就暴露大量潜在 Bug,极大降低了运行时出错的风险。

观察这些接口的设计,我们可以发现几个共同的模式。首先,每一个实体都有一个 id 字段作为唯一标识,这在列表渲染时作为 ForEach 的键值(key)非常重要,能帮助框架高效地进行列表差分更新。其次,state 字段在多个接口中反复出现,它是一个数值类型,用于表示实体的当前状态(如 0 表示可用、1 表示已满),这种用数值编码状态的方式在数据存储和传输上非常经济。

技术概念强调: ForEach 是 ArkUI 中用于列表渲染的核心组件。它接受三个参数:数据数组、子项生成函数、键值生成函数。键值函数返回的字符串用于框架内部的 Diff 算法,当数据变化时,框架通过对比键值来决定哪些列表项需要创建、更新或销毁。因此,为每条数据提供稳定且唯一的 id 是确保列表高效更新的关键实践。

此外,不同接口根据业务需求定义了不同的字段。FeedItem 包含 likes(点赞数)和 comments(评论数),体现了社交动态的互动属性;SpotItem 包含 depth(水深)和 fish(渔获指数),体现了钓点资源的专业属性;LiveItem 包含 fans(粉丝数)和 watch(观看数),体现了直播的热度指标。这些字段的精心设计,使得每个接口都能精准表达其业务实体的核心信息。

二、静态数据初始化:常量数组的定义

在定义完数据接口之后,应用通过 const 关键字声明了一批静态数据数组。这些数组作为应用的初始数据源,为界面渲染提供内容。下面展示其中几组核心数据。

const FEEDS: FeedItem[] = [
  { id: 1, name: '野钓阿强', avatar: '🎣', time: '12分钟前', content: '昨晚夜钓青龙湖,18 斤大鲤鱼上岸!主线切了三次,换 0.8 子线硬扛,这手感绝了!', likes: 628, comments: 93 },
  { id: 2, name: '路亚小北', avatar: '🐟', time: '35分钟前', content: '白鹭湾路亚翘嘴连竿,亮片搜边三竿中两尾,傍晚窗口期到了,钓友们冲!', likes: 451, comments: 67 },
  { id: 3, name: '矶钓老周', avatar: '🪨', time: '1小时前', content: '双月湾矶钓爆箱,黄鳍鲷排着队上钩,虾仁打窝加南极虾,今天收成十五斤!', likes: 389, comments: 54 },
  { id: 4, name: '筏钓阿斌', avatar: '⛵', time: '2小时前', content: '雾山水库筏钓鲫鱼连竿,半天一百多条,水深 22 米钓中层,串钩挂红虫效果好。', likes: 342, comments: 49 },
  { id: 5, name: '台钓小陈', avatar: '🎯', time: '4小时前', content: '黑坑正钓鲤鱼口好到爆,饵料配方分享:颗粒粉加果酸,散炮带软粘,抢鱼阶段稳拿前三!', likes: 517, comments: 86 },
  { id: 6, name: '冰钓老刘', avatar: '🧊', time: '6小时前', content: '冰窟窿下面全是大鲫鱼,穿得厚就行,冰钓竿加七星漂,一提一个准。', likes: 276, comments: 38 },
  { id: 7, name: '溪流阿泽', avatar: '🏞️', time: '昨天', content: '樱花溪上游马口成群,2.7 米溪流竿配 0.6 主线,羽毛钩慢收,一下午拉到手软。', likes: 305, comments: 42 },
  { id: 8, name: '海钓大鹏', avatar: '🛥️', time: '昨天', content: '出海船钓金枪鱼,搏斗整整两小时终于拿下,深海 60 米铁板抽到没脾气,值了!', likes: 742, comments: 128 }
]

在这里插入图片描述

这段代码定义了 FEEDS 常量数组,类型标注为 FeedItem[],表示这是一个元素类型为 FeedItem 的数组。数组中每个对象字面量都严格遵循 FeedItem 接口定义的字段结构,包含了 idnameavatartimecontentlikescomments 七个字段。使用 const 关键字声明意味着这个变量绑定不可重新赋值,但要注意数组内部的对象属性在运行时仍然可以被修改。

技术概念强调: ArkTS 中 const 声明的数组,其"不可变"仅限于变量绑定层面——不能将 FEEDS 重新指向另一个数组对象,但数组内部的元素是可以被增删改的(如 pushspliceunshift 等操作)。这种语义与 TypeScript/JavaScript 一致。在应用运行时,这些初始数据会被赋值给 @State 状态变量,之后用户操作(如发布新动态、删除收藏)会通过 unshiftsplice 等方法修改数组内容,从而触发界面更新。

数据中值得注意的设计细节是使用了 Emoji 表情符号作为 avatar(头像)字段值,如 '🎣''🐟''🪨' 等。这是一种轻量化的视觉表达方案,在不需要引入图片资源文件的情况下就能实现直观的图形化展示。在 ArkUI 的 Text 组件中,Emoji 可以直接作为文本内容渲染,无需额外的图片加载逻辑,这对应用的包体积和渲染性能都是友好的。

除了实体数据数组外,应用还定义了两个辅助常量数组:

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

const CATS: string[] = ['全部', '湖库', '河湾', '海钓', '溪流', '黑坑']

在这里插入图片描述

HEAT 数组存储了本周水域的热度数值,用于在首页绘制柱状图。CATS 数组存储了钓法分类标签,用于在界面顶部展示可横向滑动的分类筛选条。这两个数组虽然简单,但它们体现了"数据驱动 UI"的设计思想——界面的内容由数据决定,而非硬编码在 UI 结构中。

三、工具函数层:状态映射与数据构建

在数据层和 UI 层之间,应用定义了一批工具函数,承担着"状态映射"和"数据构建"两类职责。先来看状态映射类函数。

function spotStateText(s: number): string {
  if (s === 0) {
    return '可约钓'
  }
  return '约满'
}

function spotStateColor(s: number): string {
  if (s === 0) {
    return '#4ADE80'
  }
  return '#FB923C'
}

function liveStateText(s: number): string {
  if (s === 0) {
    return '直播中'
  }
  return '已结束'
}

function liveStateColor(s: number): string {
  if (s === 0) {
    return '#FB923C'
  }
  return '#64748B'
}

function waterStateText(s: number): string {
  if (s === 0) {
    return '可认领'
  }
  return '已认满'
}

function waterStateColor(s: number): string {
  if (s === 0) {
    return '#38BDF8'
  }
  return '#FB923C'
}

function masterStateText(s: number): string {
  if (s === 0) {
    return '收徒中'
  }
  return '已满额'
}

function masterStateColor(s: number): string {
  if (s === 0) {
    return '#4ADE80'
  }
  return '#FB923C'
}

在这里插入图片描述

这一组函数遵循了统一的命名模式:xxxStateText 返回状态对应的中文描述文本,xxxStateColor 返回状态对应的十六进制颜色值。这些函数将数值类型的状态码(0 或 1)映射为用户可读的文本和视觉上可区分的颜色,实现了"数据状态"到"界面呈现"的转换逻辑。

技术概念强调: 在 ArkUI 开发中,将状态到文本/颜色的映射逻辑提取为独立函数是一种优秀的工程实践。这样做的好处有三:一是避免了 UI 代码中充斥大量三元运算符或 if-else 分支,保持了 UI 代码的简洁性;二是映射逻辑集中管理,修改状态文案或配色时只需改一处;三是函数可以被多个组件复用,保证了同一状态在应用各处的一致性表达。

颜色编码方面,应用采用了一套统一的色彩体系。从文件开头的注释可以看出,整体设计风格为"晨雾水岸风",定义了六个核心色值:背景色 #0B1F2A(深墨蓝)、面板色 #132B38(暗青绿)、水蓝色 #38BDF8、雾白色 #E0F2FE、草绿色 #4ADE80、霞橙色 #FB923C。这些颜色在状态映射函数中被精确使用:绿色系(#4ADE80)表示"可用/积极"状态,橙色系(#FB923C)表示"已满/警示"状态,蓝色系(#38BDF8)表示"信息/中性"状态,灰色系(#64748B)表示"结束/禁用"状态。

接下来看数据格式化和趋势显示函数:

function heatBar(v: number): string {
  return (14 + 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 '#38BDF8'
  }
  return '#FB923C'
}

在这里插入图片描述

heatBar 函数将热度数值转换为柱状图的高度字符串,使用 vp(虚拟像素)作为单位。这是一种动态计算尺寸的技巧——柱子高度不是固定的,而是根据数据值线性映射到一个合理范围内(14vp 到 54vp 之间),让不同数据值的柱子在视觉上有明显的高低差异。

playsText 函数实现了观看人数的格式化显示:当数字超过一万时,自动转换为"万"为单位并保留一位小数,如 680000 显示为 "68.0万"。这种"大数据友好显示"在中文应用中非常常见,能让用户更直观地感知数值规模。

技术概念强调: vp(virtual pixel)是 ArkUI 中的长度单位之一,它会根据屏幕密度自动缩放,确保在不同分辨率设备上呈现相近的物理尺寸。与之对应的还有 fp(font pixel,用于字体大小,会受系统字体缩放影响)、px(物理像素,不缩放)等。在 heatBar 函数中拼接 'vp' 后缀,是 ArkUI 中以字符串形式表达带单位尺寸的常见写法。

trendTexttrendColor 函数处理趋势数据的显示:正趋势前加 + 号并用蓝色显示,负趋势用橙色显示。这种"正蓝负橙"的配色策略与股票等数据可视化应用的惯例类似,能让用户一眼判断趋势方向。

再看数据构建类函数:

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 buildSpot(id: number, name: string, kind: string, depth: number, fish: number, quota: number, state: number): SpotItem {
  return { id: id, name: name, kind: kind, depth: depth, fish: fish, quota: quota, state: state }
}

function buildLive(id: number, name: string, title: string, fans: number, watch: number, state: number): LiveItem {
  return { id: id, name: name, title: title, fans: fans, watch: watch, state: state }
}

function buildWater(id: number, name: string, area: number, quota: number, price: number, state: number): WaterItem {
  return { id: id, name: name, area: area, quota: quota, price: price, state: state }
}

function buildMaster(id: number, name: string, skill: string, honor: number, quota: number, state: number): MasterItem {
  return { id: id, name: name, skill: skill, honor: honor, quota: quota, state: state }
}

在这里插入图片描述

这组 buildXxx 函数是各实体类型的工厂函数(Factory Function),接收所有字段值作为参数,返回一个完整构造的对象。使用工厂函数而非直接写对象字面量的好处在于:一是参数列表明确标注了类型,编译器能在调用点进行类型检查;二是构造逻辑集中,如果将来需要在创建对象时添加默认值或校验逻辑,只需修改函数内部即可。

最后一个工具函数是 buildWaterBook,它实现了水域认领的业务逻辑:

function buildWaterBook(w: WaterItem): WaterItem {
  let q = w.quota - 1
  let st = w.state
  if (q <= 0) {
    q = 0
    st = 1
  }
  return buildWater(w.id, w.name, w.area, q, w.price, st)
}

在这里插入图片描述

这个函数接收一个 WaterItem 对象,将其 quota(剩余名额)减一。当名额减到零或以下时,自动将 state 切换为 1(已认满),并将名额钳制(clamp)在 0 而不允许变为负数。这是一个典型的"业务状态转换"函数,封装了认领操作的完整逻辑:减名额、判断是否售罄、更新状态。通过提取为独立函数,这段逻辑可以在多处被复用,且修改时只需改一处。

用户点击认领水域

调用 buildWaterBook 函数

quota 减 1

quota <= 0?

quota 钳制为 0

state 切换为 1 已认满

保持 state 为 0 可认领

返回新的 WaterItem 对象

splice 替换原数组元素

界面自动刷新

在这里插入图片描述

四、根组件入口:@Entry 与 @Component 装饰器

现在进入应用的核心部分——根组件的定义。在 ArkUI 中,每个页面有且仅有一个被 @Entry 装饰的组件作为入口,而 @Component 装饰器则用于声明一个自定义组件。

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State spots: SpotItem[] = SPOTS
  @State lives: LiveItem[] = LIVES
  @State waters: WaterItem[] = WATERS
  @State masters: MasterItem[] = MASTERS
  @State mys: MyItem[] = MYS
  @State pickedFeed: FeedItem | null = null
  @State pickedSpot: SpotItem | null = null
  @State pickedLive: LiveItem | null = null
  @State pickedWater: WaterItem | null = null
  @State pickedMaster: MasterItem | null = null
  @State pickedMy: MyItem | null = null
  @State showFeedDetail: boolean = false
  @State showFeedAdd: boolean = false
  @State showSpotDetail: boolean = false
  @State showSpotEdit: boolean = false
  @State showLiveRule: boolean = false
  @State showLiveDetail: boolean = false
  @State showLiveAdd: boolean = false
  @State showWaterDetail: boolean = false
  @State showWaterBook: boolean = false
  @State showMasterRule: boolean = false
  @State showMasterDetail: boolean = false
  @State showMasterSign: boolean = false
  @State showMasterQuit: 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 newSpotName: string = ''
  @State newSpotKind: string = ''
  @State newSpotDepth: string = ''
  @State newNick: string = ''
  @State catSel: number = 0

这段代码是整个应用的状态管理中枢。@Entry 标记 Index 为页面入口组件,@Component 声明它是一个自定义组件结构体。结构体内部通过大量 @State 装饰器声明了应用运行期间需要追踪的状态变量。

这些状态变量可以分为四类。第一类是数据列表状态feedsspotsliveswatersmastersmys,它们分别在初始化时赋值为前面定义的常量数组。这些数组在运行时会被用户操作修改(如新增动态、删除收藏、认领水域),因此必须用 @State 标记以确保变化能触发界面刷新。

第二类是选中项状态pickedFeedpickedSpotpickedLivepickedWaterpickedMasterpickedMy,它们的类型是 XxxItem | null(联合类型,可为具体对象或 null)。这些变量用于记录用户当前点击查看的某条数据,当用户点击列表项时被赋值,弹出详情弹窗时读取这些值显示对应内容。初始值为 null 表示未选中任何项。

技术概念强调: @State 装饰器是 ArkUI 状态管理的基础。被 @State 修饰的变量,其值变化会被框架自动观察。当值改变时,框架会重新执行 build() 函数中依赖该变量的 UI 片段,实现"数据变→界面变"的自动更新。注意 @State 仅能观察变量本身的赋值变化,对于对象内部属性的深层变化或数组元素的修改,ArkUI 通过重写数组方法(如 spliceunshift 等)来实现观察。因此使用 this.feeds.unshift(...)this.feeds.splice(...) 也能触发界面刷新。

第三类是弹窗显示状态showFeedDetailshowFeedAddshowSpotDetailshowSpotEdit 等一系列布尔值变量。每个弹窗对应一个布尔开关,true 表示显示、false 表示隐藏。这种"布尔开关控弹窗"的模式在 ArkUI 中非常常见,通过条件渲染(if (this.showXxx))来控制弹窗的显示与隐藏。

第四类是表单输入状态newFeedNamenewFeedTextnewSpotNamenewSpotKindnewSpotDepthnewNick,用于存储用户在弹窗输入框中输入的内容。这些变量通过 TextInput 组件的 onChange 回调实时更新,提交时读取这些值构建新数据对象。

第五类是选择状态catSel 用于记录当前选中的分类标签索引,currentTab 用于记录当前激活的底部标签页索引。

五、@Builder 构建器:底部标签栏的复用单元

@Builder 装饰器是 ArkUI 中用于定义可复用 UI 片段的重要工具。下面是底部标签栏单项的构建器定义。

@Builder
tabItem(icon: string, label: string, tab: number) {
  Column({ space: 3 }) {
    Text(icon)
      .fontSize(22)
      .fontColor(this.currentTab === tab ? '#38BDF8' : '#7E9BA8')
    Text(label)
      .fontSize(11)
      .fontColor(this.currentTab === tab ? '#38BDF8' : '#7E9BA8')
  }
  .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 修饰的 tabItem 方法接收三个参数:icon(图标符号)、label(文字标签)、tab(标签编号)。方法体内部构建了一个 Column 容器,其中纵向排列了一个图标 Text 和一个标签 Text

技术概念强调: @Builder 装饰器定义的方法与普通方法的本质区别在于:@Builder 方法的返回值是一段 UI 描述(虚拟 DOM 节点),可以被直接嵌入到 build() 方法的组件树中。普通方法返回的是数据值,无法作为 UI 节点使用。@Builder 方法支持参数传递,使得同一套 UI 模板可以通过不同参数渲染出不同内容,实现了 UI 的参数化复用。

Column 是 ArkUI 的纵向布局容器组件,子元素按从上到下方向排列。{ space: 3 } 参数设置了子元素之间的间距为 3vp。两个 Text 组件的 fontColor 通过三元运算符根据当前激活的标签决定颜色:如果 this.currentTab === tab(当前标签正是此标签),则显示水蓝色 #38BDF8(高亮态);否则显示灰蓝色 #7E9BA8(普通态)。

技术概念强调: Column 容器组件是 ArkUI 三大基础布局容器之一(另两个是 RowStack)。Column 使子元素纵向排列,Row 使子元素横向排列。两者都支持 justifyContent 属性控制主轴方向的对齐方式(如 FlexAlign.Center 居中、FlexAlign.Start 起始端、FlexAlign.End 末端、FlexAlign.SpaceBetween 两端对齐等),以及 alignItems 属性控制交叉轴方向的对齐方式。

外层 Column.width('16.66%') 将宽度设为父容器的六分之一(因为底部有 6 个标签),.height('100%') 撑满高度。.justifyContent(FlexAlign.Center) 使图标和文字在纵向居中对齐。

.scale() 属性实现了选中标签的放大效果:当前标签的 x、y 缩放比例为 1.12(放大 12%),未选中标签为 1(原始大小)。配合 .animation() 属性,这个缩放变化会有 200 毫秒的过渡动画,使用 Curve.EaseOut(先快后慢的缓出曲线),给用户一种"选中标签弹出"的视觉反馈。

技术概念强调: .animation() 是 ArkUI 的隐式动画属性。它不需要开发者显式调用动画 API,而是自动监听组件属性变化并产生过渡效果。当 .scale() 的值从 1 变为 1.12(或反向)时,.animation() 配置的 duration(时长)和 curve(曲线)决定了过渡的节奏。Curve.EaseOut 是预定义的缓动曲线之一,动画开始快速、结束缓慢,适合"选中放大"这种强调终态的效果。除了 EaseOut,ArkUI 还提供 Linear(匀速)、EaseIn(缓入)、EaseInOut(缓入缓出)、FastOutSlowIn 等曲线。

.onClick() 事件回调简单地将 this.currentTab 设为当前标签编号 tab。由于 currentTab@State 变量,赋值后框架会自动重新渲染所有依赖它的 UI 片段——包括所有 tabItem 的颜色和缩放状态,以及内容区的标签页切换。

六、build() 主入口:Stack 层叠布局与整体结构

build() 方法是每个组件的核心,它返回组件的 UI 描述。根组件的 build() 方法使用了 Stack 作为最外层容器。

build() {
  Stack({ alignContent: Alignment.Bottom }) {
    Column() {
      // ===== 头部 =====
      Column() {
        // 头部内容...
      }
      .width('100%')
      .backgroundColor('#132B38')

      // ===== 内容区 =====
      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) {
          SpotContent({
            spots: this.spots,
            catSel: this.catSel,
            showSpotDetail: (item: SpotItem) => {
              this.pickedSpot = item
              this.showSpotDetail = true
            },
            showSpotEdit: (item: SpotItem) => {
              this.pickedSpot = item
              this.newSpotName = item.name
              this.newSpotKind = item.kind
              this.newSpotDepth = item.depth.toString()
              this.showSpotEdit = true
            }
          })
        }
        // ... 其他标签页
      }
      .layoutWeight(1)
      .width('100%')

      // ===== 底部 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('#132B38')
      .border({ width: { top: 1 }, color: '#1E3A4A' })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#0B1F2A')

    // ================= 弹窗层 =================
    // 各种弹窗的条件渲染...
  }
  .width('100%')
  .height('100%')
}

技术概念强调: Stack 是 ArkUI 的层叠布局容器组件。与 Column(纵向排列)和 Row(横向排列)不同,Stack 将子元素堆叠在一起——后声明的子元素覆盖在先声明的子元素之上,形成"图层"关系。alignContent 参数控制子元素在 Stack 内的对齐方式,Alignment.Bottom 表示子元素底部对齐。Stack 在本应用中扮演着关键角色:最底层是主界面(头部+内容+底栏),上层是各种弹窗,弹窗通过覆盖在主界面上方实现"模态对话框"效果。

build() 方法内部的 Stack 包含两大部分。第一部分是一个 Column,纵向排列了三个区域:头部 Column、内容区 Column、底部标签栏 Row。内容区使用了 .layoutWeight(1),这是 ArkUI 的权重分配属性。

技术概念强调: layoutWeight 是 ArkUI 中进行弹性空间分配的属性。在一个 ColumnRow 容器中,如果某些子元素设置了固定尺寸(如头部和底栏有明确高度),而另一个子元素设置了 layoutWeight(1),那么该子元素会自动占满容器中剩余的所有可用空间。layoutWeight 的值是一个权重数,如果多个子元素都设置了 layoutWeight,则按权重比例分配空间。本例中内容区 layoutWeight(1) 使其占据头部和底栏之间的全部剩余高度,实现了"头部固定+内容滚动+底栏固定"的经典三段式布局。

内容区内部的标签页切换逻辑使用了条件渲染。if (this.currentTab === 0) 判断当前标签编号,为 0 时渲染 HomeContent 组件,为 1 时渲染 SpotContent 组件,依此类推。每次切换标签时,currentTab 变化触发 build() 重新执行,对应的条件分支被激活,渲染对应的子组件。

这里值得特别关注的是父子组件通信机制。以 HomeContent 为例:

HomeContent({
  feeds: this.feeds,
  heat: HEAT,
  catSel: this.catSel,
  showFeedDetail: (item: FeedItem) => {
    this.pickedFeed = item
    this.showFeedDetail = true
  },
  showFeedAdd: () => {
    this.showFeedAdd = true
  }
})

父组件 Index 在创建子组件 HomeContent 时,通过参数传递了两类信息。第一类是数据属性feedsheatcatSel,这些数据从父组件传递到子组件,子组件通过 @Prop 接收。第二类是回调函数showFeedDetailshowFeedAdd,它们是箭头函数,捕获了父组件的 this,在子组件内部被调用时,实际修改的是父组件的状态变量。

技术概念强调: 这种"数据向下、事件向上"的模式是 ArkUI 组件通信的核心范式。父组件通过属性将数据传递给子组件(单向数据流),子组件不能直接修改父组件数据,而是通过调用父组件传入的回调函数来间接通知父组件"有事发生了"。父组件在回调中修改自身状态,状态变化再通过属性传递回流向子组件。这种单向数据流设计保证了数据变化的可追踪性,避免了双向绑定带来的状态混乱问题。

底部标签栏的 Row 容器横向排列了六个 tabItem 构建器调用。.border({ width: { top: 1 }, color: '#1E3A4A' }) 只设置了上边框,实现了底栏与内容区之间的一条分隔线。{ width: { top: 1 } } 这种对象写法允许单独指定某一条边的边框宽度,是 ArkUI 边框属性的精细控制方式。

Stack层叠容器

主界面层

0

1

2

3

4

5

showFeedDetail

showFeedAdd

showSpotDetail

showSpotEdit

其他

弹窗层 条件渲染

头部 Column - 固定高度

内容区 Column - layoutWeight1

底栏 Row - 固定高度 58vp

currentTab?

HomeContent 首页

SpotContent 钓点

LiveContent 直播

WaterContent 水域

MasterContent 大师

MeContent 我的

showXxx弹窗?

动态详情弹窗

发布动态弹窗

钓点详情弹窗

钓点编辑弹窗

...更多弹窗

七、头部区域:渐变背景与视觉设计

头部区域是用户打开应用后首先看到的部分,其视觉设计直接影响用户的第一印象。下面是头部区域的完整代码。

Column() {
  Row() {
    Row() {
      Text('🎣')
        .fontSize(26)
      Column({ space: 2 }) {
        Text('FISH CAST')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0F2FE')
        Text('钓鱼直播站 · 官方')
          .fontSize(10)
          .fontColor('#7DD3FC')
      }
      .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('#E0F2FE')
        .backgroundColor('rgba(56,189,248,0.25)')
        .borderRadius(20)
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
      Text('').layoutWeight(1)
      Text('🔴 直播中 128 场')
        .fontSize(11)
        .fontColor('#7DD3FC')
    }
    .width('100%')

    Text('钓鱼直播站')
      .fontSize(26)
      .fontWeight(FontWeight.Bold)
      .fontColor('#E0F2FE')
      .letterSpacing(2)
    Text('FISH CAST · 水岸晨雾 一竿入梦')
      .fontSize(11)
      .fontColor('#7DD3FC')
      .letterSpacing(1)

    // 水波符号带
    Row() {
      ForEach([12, 16, 10, 18, 14, 11, 17, 13, 15, 12], (s: number, i: number) => {
        Text(i % 2 === 0 ? '~' : '≈')
          .fontSize(s)
          .fontColor(i % 3 === 0 ? '#38BDF8' : (i % 3 === 1 ? '#4ADE80' : '#FB923C'))
          .opacity(0.8)
      }, (s: number, i: number) => 'wave' + i)
    }
    .width('100%')
    .justifyContent(FlexAlign.SpaceBetween)
    .padding({ left: 20, right: 20 })

    // 搜索胶囊
    Row() {
      Text('🔍')
        .fontSize(14)
      Text('搜索钓点、直播、渔获、大师')
        .fontSize(13)
        .fontColor('#7DD3FC')
        .margin({ left: 6 })
    }
    .width('90%')
    .height(38)
    .backgroundColor('rgba(19,43,56,0.75)')
    .borderRadius(19)
    .padding({ left: 14 })
    .alignItems(VerticalAlign.Center)
  }
  .width('100%')
  .padding({ left: 16, right: 16, top: 12, bottom: 14 })
  .linearGradient({
    angle: 180,
    colors: [['#082F49', 0.0], ['#132B38', 0.55], ['#0B1F2A', 1.0]]
  })
}
.width('100%')
.backgroundColor('#132B38')

头部区域分为两层。第一层是状态栏行 Row,左侧是 Logo 区(图标+应用名称+副标题),右侧是通知和搜索图标。值得注意的是 Text('').layoutWeight(1) 这个看似空白的文本元素——它其实是一个"弹性占位符",利用 layoutWeight(1) 占满中间所有剩余空间,将左右两侧的元素推到两端,实现了经典的"两端对齐"布局效果。

技术概念强调: Text('').layoutWeight(1) 是 ArkUI 中实现弹性布局的一个经典技巧。在一个 Row 中,如果需要将某些子元素推到左端、某些推到右端,可以在它们之间插入一个设置了 layoutWeight(1) 的空 Text。这个空元素会占据所有可用空间,把右边的元素挤到最右边。这个技巧在应用中被大量使用,是替代 Flex 布局的轻量方案。

第二层是晨雾横幅 Column,使用了 .linearGradient() 实现线性渐变背景。linearGradient 接收 angle(角度)和 colors(颜色断点数组)两个参数。angle: 180 表示渐变方向从上到下。colors 数组中每个元素是 [颜色值, 位置比例] 的二元组,定义了渐变过程中的颜色锚点。本例中从 #082F49(顶部深蓝)经 #132B38(中部暗青)渐变到 #0B1F2A(底部墨蓝),营造出水面晨雾的深邃氛围。

技术概念强调: linearGradient 是 ArkUI 的渐变背景属性,支持线性渐变、角度渐变和径向渐变三种模式。通过精确控制颜色断点的位置比例,可以创造出非常细腻的视觉过渡效果。渐变背景比纯色背景更有层次感和氛围感,在应用的头部、横幅、卡片等视觉重点区域被广泛使用。

横幅中的水波符号带是一个值得细读的 ForEach 应用:

Row() {
  ForEach([12, 16, 10, 18, 14, 11, 17, 13, 15, 12], (s: number, i: number) => {
    Text(i % 2 === 0 ? '~' : '≈')
      .fontSize(s)
      .fontColor(i % 3 === 0 ? '#38BDF8' : (i % 3 === 1 ? '#4ADE80' : '#FB923C'))
      .opacity(0.8)
  }, (s: number, i: number) => 'wave' + i)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.padding({ left: 20, right: 20 })

ForEach 遍历一个包含 10 个数字的内联数组 [12, 16, 10, 18, 14, 11, 17, 13, 15, 12]。对于每个元素,生成一个 Text 显示波浪符号——偶数索引显示 '~'、奇数索引显示 '≈'。字体大小直接取数组中的数值 s,因此每个波浪符号的大小都不同,形成高低错落的视觉效果。颜色则通过 i % 3 取模循环使用蓝、绿、橙三色。

技术概念强调: ForEach 的第三个参数是键值生成函数 (s: number, i: number) => 'wave' + i,它返回字符串 'wave0''wave1'… 作为每个列表项的唯一标识。虽然这里的数组是静态的(不会增删),但 ArkUI 仍然要求提供键值函数以保证 Diff 算法的正确性。键值函数的返回值必须满足"同一数据项始终返回同一键值"的稳定性要求。

搜索胶囊是一个 Row 容器,.borderRadius(19) 将其圆角半径设为高度的一半(38/2=19),形成完全圆角的胶囊形状。.backgroundColor('rgba(19,43,56,0.75)') 使用了带透明度的 rgba 颜色,0.75 的透明度让背景能透出底层渐变,增加视觉层次。

八、弹窗系统:条件渲染与模态覆盖

应用的弹窗系统是整个代码中最具特色的架构设计之一。所有弹窗都定义在 Stack 的第二层(主界面之上),通过布尔状态变量控制显示。下面以动态详情弹窗为例进行详细拆解。

if (this.showFeedDetail) {
  Column() {
    Column({ space: 10 }) {
      Row() {
        Text(this.pickedFeed !== null ? this.pickedFeed.avatar : '')
          .fontSize(30)
        Column({ space: 3 }) {
          Text(this.pickedFeed !== null ? this.pickedFeed.name : '')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0F2FE')
          Text(this.pickedFeed !== null ? this.pickedFeed.time : '')
            .fontSize(11)
            .fontColor('#7DD3FC')
        }
        .alignItems(HorizontalAlign.Start)
        .margin({ left: 10 })
        Text('').layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#E0F2FE')
          .padding(6)
          .onClick(() => {
            this.showFeedDetail = false
          })
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)

      Text(this.pickedFeed !== null ? this.pickedFeed.content : '')
        .fontSize(15)
        .fontColor('#BAE6FD')
        .lineHeight(24)

      Row() {
        Text('👍 渔获点赞 ' + (this.pickedFeed !== null ? this.pickedFeed.likes : 0))
          .fontSize(13)
          .fontColor('#4ADE80')
          .backgroundColor('rgba(74,222,128,0.15)')
          .borderRadius(14)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        Text('💬 评论 ' + (this.pickedFeed !== null ? this.pickedFeed.comments : 0))
          .fontSize(13)
          .fontColor('#38BDF8')
          .backgroundColor('rgba(56,189,248,0.15)')
          .borderRadius(14)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
      }
      .width('100%')

      Text('🐟 鱼获 18.6 斤 · 主线 0.8 · 夜光漂')
        .fontSize(12)
        .fontColor('#FB923C')
        .backgroundColor('rgba(251,146,60,0.12)')
        .borderRadius(10)
        .padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .width('100%')
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#132B38')
    .borderRadius(14)
    .border({ width: 1, color: '#38BDF8' })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(11,31,42,0.88)')
  .justifyContent(FlexAlign.Center)
}

弹窗的结构分为两层。外层 Column 覆盖整个屏幕(width('100%') + height('100%')),背景色为半透明深色 rgba(11,31,42,0.88),这层半透明遮罩(mask)使背后的主界面变暗,视觉上"退后",让用户的注意力集中到弹窗内容上。.justifyContent(FlexAlign.Center) 使内层内容在纵向居中显示。

内层 Column 是实际的弹窗卡片,宽度为屏幕的 88%,留出两侧的边距让弹窗有"浮于"屏幕之上的视觉感。.borderRadius(14) 设为圆角卡片,.border({ width: 1, color: '#38BDF8' }) 加上一圈水蓝色边框,与主题色呼应。

技术概念强调: 条件渲染 if (this.showXxx) 是 ArkUI 中实现弹窗显示/隐藏的核心机制。当布尔变量从 false 变为 true 时,条件分支内的 UI 被创建并插入组件树;从 true 变为 false 时,UI 被销毁移除。这种方式与"始终渲染但通过 visibility 属性控制显隐"的方式不同——条件渲染在隐藏时不占用任何渲染资源,但显隐切换时会有创建/销毁的开销。对于弹窗这种"大部分时间隐藏"的场景,条件渲染是更优的选择。

弹窗内容中反复出现的 this.pickedFeed !== null ? this.pickedFeed.xxx : '' 模式值得特别说明。由于 pickedFeed 的类型是 FeedItem | null(可能为 null),直接访问 this.pickedFeed.name 会在编译期报错(不能保证不为 null)。因此每次访问都需要进行 null 检查。这里使用了三元运算符:如果不为 null 则取实际值,为 null 则返回空字符串。这是一种防御性编程实践,确保即使状态异常也不会导致运行时崩溃。

技术概念强调: ArkTS 的空安全(Null Safety)机制要求开发者显式处理可能为 null 的情况。对于 XxxItem | null 联合类型,访问属性前必须先判断不为 null。这虽然在代码层面增加了一些样板代码,但从根本上消除了空指针异常——这类 Bug 在传统 JavaScript 中是最常见的运行时错误之一。ArkTS 还提供了非空断言操作符 !(如 this.pickedFeed!.name),在开发者确信变量不为 null 时可跳过检查,但需谨慎使用。

关闭按钮 Text('✕').onClick() 回调将 this.showFeedDetail 设为 false,触发条件渲染移除整个弹窗。这种"点击关闭按钮→翻转布尔开关→条件渲染销毁弹窗"的模式在本应用的所有弹窗中反复使用,形成了一致的交互范式。

弹窗中的内容展示也有值得学习的地方。点赞数和评论数使用带半透明背景色和圆角的 Text 呈现为"标签胶囊"样式,不同数据用不同颜色区分(点赞绿色、评论蓝色),视觉上一目了然。最后的鱼获信息行使用了橙色系,与前两者形成色彩层次区分。

九、表单弹窗:TextInput 输入框与数据提交

发布渔获动态弹窗展示了表单交互的完整流程,包括输入框、标签选择和提交逻辑。

if (this.showFeedAdd) {
  Column() {
    Column({ space: 12 }) {
      Row() {
        Text('🎣 发布渔获动态')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0F2FE')
        Text('').layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#E0F2FE')
          .padding(6)
          .onClick(() => {
            this.showFeedAdd = false
          })
      }
      .width('100%')

      TextInput({ placeholder: '你的昵称', text: this.newFeedName })
        .width('100%')
        .height(42)
        .fontSize(14)
        .fontColor('#E0F2FE')
        .backgroundColor('#0B1F2A')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newFeedName = v
        })

      TextInput({ placeholder: '分享今天的渔获...', text: this.newFeedText })
        .width('100%')
        .height(88)
        .fontSize(14)
        .fontColor('#E0F2FE')
        .backgroundColor('#0B1F2A')
        .borderRadius(8)
        .onChange((v: string) => {
          this.newFeedText = v
        })

      Row() {
        Text('🌊 野钓')
          .fontSize(12)
          .fontColor('#38BDF8')
          .backgroundColor('rgba(56,189,248,0.15)')
          .borderRadius(12)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        Text('🎣 路亚')
          .fontSize(12)
          .fontColor('#4ADE80')
          .backgroundColor('rgba(74,222,128,0.15)')
          .borderRadius(12)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
        Text('🐟 台钓')
          .fontSize(12)
          .fontColor('#FB923C')
          .backgroundColor('rgba(251,146,60,0.15)')
          .borderRadius(12)
          .padding({ left: 10, right: 10, top: 4, bottom: 4 })
      }
      .width('100%')

      Text('发布')
        .textAlign(TextAlign.Center)
        .width('100%')
        .height(42)
        .backgroundColor('#38BDF8')
        .borderRadius(8)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#082F49')
        .onClick(() => {
          if (this.newFeedName !== '' && this.newFeedText !== '') {
            this.feeds.unshift(buildFeed(this.feeds.length + 1, this.newFeedName, '🎣', '刚刚', this.newFeedText, 0, 0))
            this.newFeedName = ''
            this.newFeedText = ''
          }
          this.showFeedAdd = false
        })
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#132B38')
    .borderRadius(14)
    .border({ width: 1, color: '#4ADE80' })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(11,31,42,0.88)')
  .justifyContent(FlexAlign.Center)
}

技术概念强调: TextInput 是 ArkUI 的文本输入框组件,支持用户输入文本内容。它通过 { placeholder: '...', text: '...' } 参数配置占位提示文本和当前值。placeholder 在输入框为空时显示灰色提示文字,text 用于绑定当前输入值。.onChange((v: string) => { ... }) 回调在用户输入时触发,参数 v 是当前输入框的完整文本内容。在回调中将 v 赋值给 @State 变量,实现了"输入框内容→状态变量"的同步。

两个 TextInput 分别用于输入昵称(高度 42vp,单行)和渔获内容(高度 88vp,多行区域)。它们的 .onChange() 回调分别更新 this.newFeedNamethis.newFeedText 两个状态变量。这种"输入框 onChange 实时更新状态"的模式是 ArkUI 表单处理的标准做法,状态变量始终持有最新的输入值。

标签选择行展示了三个可选标签(野钓、路亚、台钓),用不同颜色的胶囊样式呈现。虽然这里标签目前是静态展示(未绑定选中逻辑),但从设计意图可以看出,这是为用户选择动态分类预留的 UI 位置。

提交按钮 Text('发布').onClick() 回调包含了核心的业务逻辑:

.onClick(() => {
  if (this.newFeedName !== '' && this.newFeedText !== '') {
    this.feeds.unshift(buildFeed(this.feeds.length + 1, this.newFeedName, '🎣', '刚刚', this.newFeedText, 0, 0))
    this.newFeedName = ''
    this.newFeedText = ''
  }
  this.showFeedAdd = false
})

技术概念强调: unshift 是 JavaScript/ArkTS 数组的原生方法,在数组头部插入一个新元素。在本应用中,新发布的动态通过 unshift 插入到 feeds 数组的最前面,使其在列表渲染时显示在最顶部——这符合"最新内容优先展示"的社交应用惯例。由于 feeds@State 变量,unshift 操作会被 ArkUI 的数组观察机制捕获,自动触发 ForEach 列表的增量更新,在列表头部插入一个新的卡片 UI。

这段逻辑首先检查昵称和内容是否都非空(简单的表单校验),满足条件时调用 buildFeed 工厂函数构造一个新的 FeedItem 对象。新对象的 id 使用 this.feeds.length + 1 生成,头像固定为 '🎣',时间显示为"刚刚",点赞和评论初始为 0。插入后清空输入状态变量,最后无论是否提交都关闭弹窗。

十、编辑功能:数组查找与 splice 替换

钓点编辑弹窗展示了"查找→修改→替换"的数据更新模式,这是一种比单纯插入更复杂的数组操作。

Text('保存修改')
  .textAlign(TextAlign.Center)
  .width('100%')
  .height(42)
  .backgroundColor('#FB923C')
  .borderRadius(8)
  .fontSize(15)
  .fontWeight(FontWeight.Bold)
  .fontColor('#0B1F2A')
  .onClick(() => {
    if (this.pickedSpot !== null) {
      let idx = this.spots.findIndex((it: SpotItem) => it.id === this.pickedSpot!.id)
      if (idx >= 0) {
        let d = parseInt(this.newSpotDepth)
        if (isNaN(d)) {
          d = this.pickedSpot.depth
        }
        this.spots.splice(idx, 1, buildSpot(this.pickedSpot.id, this.newSpotName, this.newSpotKind, d, this.pickedSpot.fish, this.pickedSpot.quota, this.pickedSpot.state))
      }
    }
    this.showSpotEdit = false
  })

这段保存逻辑的流程是:首先确认 pickedSpot 不为 null,然后通过 findIndexspots 数组中找到与当前选中项 ID 匹配的元素索引。findIndex 接收一个箭头函数作为谓词(predicate),遍历数组返回第一个满足条件的元素索引,找不到返回 -1。

技术概念强调: splice 是 JavaScript/ArkTS 数组的原生方法,功能强大——可以删除指定位置的元素、在指定位置插入新元素,或同时完成删除和插入。splice(idx, 1, newItem) 表示从索引 idx 处删除 1 个元素,并在同一位置插入 newItem,实现"原地替换"效果。ArkUI 的状态观察机制能识别 splice@State 数组的修改,触发对应列表项的 UI 更新。

水深字段的处理展示了数据健壮性的考量。parseInt(this.newSpotDepth) 尝试将输入的字符串转为整数。如果用户输入了非数字内容(如字母或留空),parseInt 返回 NaN(Not a Number)。isNaN(d) 检查捕获这种情况,回退使用原始的 this.pickedSpot.depth 值,确保数据不会被无效输入污染。

技术概念强调: parseInt 是将字符串解析为整数的全局函数。如果字符串不是合法数字,返回 NaNisNaN 函数用于检测一个值是否为 NaN。在表单输入场景中,用户可能输入任何内容,对数字型字段进行 parseInt + isNaN 的双重处理是防御性编程的常见模式,确保即使输入无效也不会导致后续计算崩溃。

这里使用了非空断言操作符 !this.pickedSpot!.id。虽然外层已经检查了 this.pickedSpot !== null,但 ArkTS 编译器在闭包内部不会自动延续外层的 null 检查结果(因为在闭包执行时变量可能已被其他代码修改),因此需要在访问属性时使用 ! 断言告知编译器"此处确定不为 null"。

十一、首页内容组件:Scroll 滚动与 ForEach 列表

HomeContent 是第一个子内容组件,展示了滚动容器和列表渲染的综合应用。

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

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 头条横幅
        Column({ space: 8 }) {
          Row() {
            Text('🔥 头条 · 渔获风云榜')
              .fontSize(11)
              .fontColor('#E0F2FE')
              .backgroundColor('rgba(251,146,60,0.3)')
              .borderRadius(20)
              .padding({ left: 10, right: 10, top: 3, bottom: 3 })
            Text('').layoutWeight(1)
            Text('昨日 2.4 万条渔获')
              .fontSize(11)
              .fontColor('#7DD3FC')
          }
          .width('100%')

          Text('青龙湖夜钓爆护:18 斤大鲤登顶本周渔获榜')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor('#E0F2FE')
            .lineHeight(24)

          Row() {
            Text('🎣 钓友阿强')
              .fontSize(12)
              .fontColor('#7DD3FC')
            Text('').layoutWeight(1)
            Text('🚀 热度 ' + trendText(26))
              .fontSize(12)
              .fontColor(trendColor(26))
          }
          .width('100%')
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#132B38')
        .borderRadius(12)
        .border({ width: 1, color: '#38BDF8' })
        // ... 更多内容
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12, bottom: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
    .backgroundColor('#0B1F2A')
  }
}

技术概念强调: @Prop 装饰器用于父子组件之间的单向数据传递。父组件通过属性将数据传递给子组件的 @Prop 变量,当父组件数据变化时,变化会自动同步到子组件。但子组件不能修改 @Prop 变量(单向约束),这保证了数据流的单向性和可预测性。@Prop 适合传递值类型数据或只读的数组引用。与 @Prop 对应的还有 @Link(双向同步)和 @ObjectLink(嵌套对象同步),分别用于不同的数据同步场景。

HomeContent 组件通过 @Prop 接收三个数据属性:feedsheatcatSel。同时声明了两个普通方法属性 showFeedDetailshowFeedAdd 作为回调函数(注意它们没有装饰器,而是直接赋值为默认的空函数)。这种写法是 ArkUI 子组件接收父组件回调的标准模式。

技术概念强调: Scroll 是 ArkUI 的滚动容器组件,使其子内容可以滚动浏览。.scrollable(ScrollDirection.Vertical) 设置为纵向滚动,.scrollBar(BarState.Off) 隐藏滚动条(但滚动功能仍然可用)。Scroll 是处理"内容超出屏幕"场景的核心组件,几乎所有信息流类界面都需要 Scroll 包裹。Scroll 内部通常只放一个 ColumnRow 作为内容容器。

头条横幅卡片展示了信息层次的设计。顶部是一个小标签(橙色背景圆角胶囊),中间是大标题(17vp 粗体),底部是作者和热度信息。.border({ width: 1, color: '#38BDF8' }) 给卡片加了水蓝色边框,使其在深色背景中更突出。.lineHeight(24) 设置文本行高为 24vp,让多行文本有舒适的呼吸感。

首页中还有一个柱状图区域,用纯组件方式实现了数据可视化:

Row() {
  ForEach(this.heat, (v: number, i: number) => {
    Column({ space: 4 }) {
      Column()
        .width(24)
        .height(heatBar(v))
        .backgroundColor(i % 2 === 0 ? '#38BDF8' : '#4ADE80')
        .borderRadius(4)
      Text('周' + (i + 1))
        .fontSize(9)
        .fontColor('#7E9BA8')
    }
    .width('12.5%')
    .justifyContent(FlexAlign.End)
  }, (v: number, i: number) => 'h' + i)
}
.width('100%')
.height(90)
.alignItems(VerticalAlign.Bottom)

这个柱状图的实现思路非常巧妙。外层 Row 横向排列多个柱子,每个柱子占宽度 '12.5%'(1/8,共 8 根柱子正好填满)。每根柱子是一个 Column,内部包含一个空 Column 作为柱体本体和一个 Text 作为标签。

柱体的高度通过 heatBar(v) 函数动态计算,该函数返回 (14 + v * 4) + 'vp' 字符串——热度值越大,柱子越高。背景色通过索引取模交替使用蓝色和绿色,形成双色柱状图。.alignItems(VerticalAlign.Bottom) 使外层 Row 的子元素底部对齐,所有柱子从底部生长向上,符合柱状图的视觉惯例。

技术概念强调: ArkUI 没有内置的图表组件,但通过 ColumnRow 等基础容器和动态计算高度,可以实现柱状图、进度条等数据可视化效果。这种"用基础组件拼装图表"的方式灵活性极高,开发者可以完全控制图表的视觉细节。对于更复杂的图表需求,可以使用第三方图表库或 XComponent 组件嵌入自定义渲染。

渔获动态列表是首页的核心内容,通过 ForEach 渲染:

ForEach(this.feeds, (item: FeedItem) => {
  Column({ space: 8 }) {
    Row() {
      Text(item.avatar)
        .fontSize(26)
      Column({ space: 2 }) {
        Text(item.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0F2FE')
        Text(item.time)
          .fontSize(10)
          .fontColor('#7E9BA8')
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
      Text('').layoutWeight(1)
      Text('🐟')
        .fontSize(18)
        .opacity(0.8)
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

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

    Row() {
      Text('👍 ' + item.likes)
        .fontSize(12)
        .fontColor('#7DD3FC')
      Text('💬 ' + item.comments)
        .fontSize(12)
        .fontColor('#7DD3FC')
        .margin({ left: 16 })
      Text('').layoutWeight(1)
      Text('查看详情 ›')
        .fontSize(12)
        .fontColor('#FB923C')
    }
    .width('100%')
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#132B38')
  .borderRadius(12)
  .onClick(() => {
    this.showFeedDetail(item)
  })
}, (item: FeedItem) => 'f' + item.id)

每条动态卡片是一个 Column,内部纵向排列三部分:头部行(头像+名称+时间+鱼图标)、正文内容、底部统计行(点赞数+评论数+查看详情链接)。整个卡片的 .onClick() 调用 this.showFeedDetail(item) 回调,将当前项传递给父组件。

技术概念强调: ForEach 的键值函数 (item: FeedItem) => 'f' + item.id 返回 'f1''f2'… 等唯一字符串。当用户发布新动态时,unshift 在数组头部插入新元素,ForEach 的 Diff 算法通过键值发现新增了一个 'f9'(假设新 id 为 9),于是只创建一个新卡片插入到列表头部,已有的 8 个卡片不会重新创建。这种增量更新机制是 ForEach 高性能列表渲染的关键。

卡片设计中的细节值得注意。.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) 这对属性组合用于文本截断——当文本超出容器宽度时,限制为一行显示并在末尾添加省略号。这在列表项中很常用,防止单条内容过长破坏整体布局。

十二、钓点内容组件:双列网格与分类筛选

SpotContent 组件展示了一个双列网格布局的钓点列表,以及横向滑动的分类筛选条。

@Component
struct SpotContent {
  @Prop spots: SpotItem[]
  @Prop catSel: number
  showSpotDetail: (item: SpotItem) => void = (item: SpotItem) => {}
  showSpotEdit: (item: SpotItem) => void = (item: SpotItem) => {}

  build() {
    Scroll() {
      Column({ space: 12 }) {
        // 分类横滑
        Scroll() {
          Row({ space: 8 }) {
            ForEach(CATS, (c: string, i: number) => {
              Text(c)
                .fontSize(12)
                .fontColor(this.catSel === i ? '#0B1F2A' : '#7DD3FC')
                .backgroundColor(this.catSel === i ? '#38BDF8' : 'rgba(56,189,248,0.12)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            }, (c: string, i: number) => 'sc' + i)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        // ... 双列钓点卡片
      }
    }
  }
}

分类筛选条使用了嵌套的 Scroll 组件——外层是纵向滚动(钓点列表),内层是横向滚动(分类标签)。.scrollable(ScrollDirection.Horizontal) 设置为横向滚动模式。标签的选中态通过颜色反转实现:选中时文字深色、背景亮蓝;未选中时文字浅蓝、背景淡蓝。

技术概念强调: ScrollDirection 枚举定义了滚动方向:Horizontal(横向)、Vertical(纵向)。一个 Scroll 组件只能设置一个方向的滚动。如果需要同时支持横纵滚动(如可左右滑动的分类条+可上下滚动的列表),需要嵌套使用两个 Scroll,分别设置不同方向。外层纵向 Scroll 包裹整个内容,内层横向 Scroll 包裹分类条。

双列钓点卡片的实现方式比较特殊——不是用 Grid 网格组件,而是用 ForEach 遍历索引数组 [0, 2, 4, 6, 8],每次迭代渲染一行 Row 包含两个 Column 卡片:

ForEach([0, 2, 4, 6, 8], (i: number) => {
  Row({ space: 10 }) {
    Column({ space: 6 }) {
      // 第 i 个钓点卡片内容
      Text(this.spots[i].name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E0F2FE')
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .width('100%')
      // ... 更多字段
    }
    .layoutWeight(1)
    .padding(10)
    .backgroundColor('#132B38')
    .borderRadius(12)
    .alignItems(HorizontalAlign.Start)

    Column({ space: 6 }) {
      // 第 i+1 个钓点卡片内容
      Text(this.spots[i + 1].name)
      // ... 更多字段
    }
    .layoutWeight(1)
    .padding(10)
    .backgroundColor('#132B38')
    .borderRadius(12)
    .alignItems(HorizontalAlign.Start)
  }
  .width('100%')
}, (i: number) => 'srow' + i)

技术概念强调: 这种"遍历步进索引+每行两个卡片"的方式是实现双列网格的技巧之一。每个 Column 设置 .layoutWeight(1) 使两个卡片平分 Row 的宽度,{ space: 10 } 设置卡片之间的间距。相比 Grid 组件,这种方式的好处是可以在两个卡片中放置完全不同的布局结构,灵活性更高。但缺点是代码量较大(每个卡片的内容需要写两遍),且如果数据项数为奇数需要特殊处理最后一行的单个卡片。

每个钓点卡片内部包含了丰富的信息:状态标签(可约钓/约满)、钓点名称、类型和水深、渔获指数和名额数、详情和编辑按钮。这些信息通过 ColumnRow 的组合实现了层次清晰的卡片布局。

技术概念强调: .alignItems(HorizontalAlign.Start)Column 容器中设置子元素的横向对齐方式为起始端(左对齐)。Column 的默认 alignItemsHorizontalAlign.Center(居中),当需要左对齐时需显式设置。与之对应,RowalignItems 默认是 VerticalAlign.Center,可通过 VerticalAlign.TopVerticalAlign.Bottom 改为顶部或底部对齐。

十三、直播内容组件:状态驱动的视觉变化

LiveContent 组件展示了如何根据数据状态动态改变 UI 视觉表现。

ForEach(this.lives, (item: LiveItem) => {
  Column({ space: 8 }) {
    Row() {
      Text(item.state === 0 ? '🔴' : '⚫')
        .fontSize(18)
      Column({ space: 2 }) {
        Text(item.name)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0F2FE')
        Text(item.title)
          .fontSize(11)
          .fontColor('#7DD3FC')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
      Text('').layoutWeight(1)
      Text(liveStateText(item.state))
        .fontSize(10)
        .fontColor(liveStateColor(item.state))
        .backgroundColor(liveStateColor(item.state) === '#64748B' ? 'rgba(100,116,139,0.2)' : 'rgba(251,146,60,0.15)')
        .borderRadius(10)
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    Row() {
      Text('👥 ' + item.fans + ' 钓友')
        .fontSize(11)
        .fontColor('#7E9BA8')
      Text('').layoutWeight(1)
      Text('👀 ' + playsText(item.watch))
        .fontSize(11)
        .fontColor('#FB923C')
    }
    .width('100%')

    Row() {
      Text('进入直播间')
        .textAlign(TextAlign.Center)
        .width('100%')
        .height(32)
        .backgroundColor(item.state === 0 ? '#38BDF8' : 'rgba(100,116,139,0.3)')
        .borderRadius(8)
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(item.state === 0 ? '#082F49' : '#7E9BA8')
        .onClick(() => {
          this.showLiveDetail(item)
        })
    }
    .width('100%')
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#132B38')
  .borderRadius(12)
  .onClick(() => {
    this.showLiveDetail(item)
  })
}, (item: LiveItem) => 'l' + item.id)

这个直播列表卡片最值得关注的是多层状态驱动的视觉变化。首先是图标:正在直播的显示红色圆点 '🔴',已结束的显示黑色圆点 '⚫'。其次是状态标签:通过 liveStateText()liveStateColor() 函数动态生成文本和颜色。更复杂的是背景色的判断:

.backgroundColor(liveStateColor(item.state) === '#64748B' ? 'rgba(100,116,139,0.2)' : 'rgba(251,146,60,0.15)')

这行代码先调用 liveStateColor 获取状态颜色值,再判断该颜色是否为灰色 #64748B(已结束),据此选择不同的半透明背景色。这种"根据状态颜色推断背景色"的写法虽然有些绕,但实现了视觉语义的一致性:正在直播的标签用橙色系背景,已结束的用灰色系背景。

技术概念强调: 三元运算符 ? : 是 ArkUI 中实现条件视觉的最常用工具。在链式属性调用中,三元运算符可以根据条件返回不同的属性值,如 .backgroundColor(condition ? colorA : colorB)。这种写法让 UI 的视觉表现直接由数据驱动,无需手动操作 DOM 样式。当状态变化时,@State 变量触发重新渲染,三元运算符重新求值,组件属性自动更新。

"进入直播间"按钮的样式也根据状态变化:正在直播的按钮为亮蓝色背景+深色文字(可操作的视觉暗示),已结束的为灰色半透明背景+灰色文字(不可操作的视觉暗示)。这种通过颜色明暗传达可操作性的设计是移动端交互的重要原则。

十四、水域内容组件:进度条与条件交互

WaterContent 组件引入了进度条这种新的可视化元素,展示认领名额的消耗情况。

ForEach(this.waters, (item: WaterItem) => {
  Column({ space: 8 }) {
    Row() {
      Text('🌊')
        .fontSize(22)
      Column({ space: 2 }) {
        Text(item.name)
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0F2FE')
        Text('面积 ' + item.area + ' 亩 · ¥' + item.price + '/月')
          .fontSize(11)
          .fontColor('#7DD3FC')
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 8 })
      Text('').layoutWeight(1)
      Text(waterStateText(item.state))
        .fontSize(10)
        .fontColor(waterStateColor(item.state))
        .backgroundColor('rgba(56,189,248,0.15)')
        .borderRadius(10)
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
    }
    .width('100%')
    .alignItems(VerticalAlign.Center)

    // 认领进度条
    Row() {
      Row()
        .width((item.quota * 6) + '%')
        .height(8)
        .backgroundColor(item.state === 0 ? '#38BDF8' : '#FB923C')
        .borderRadius(4)
    }
    .width('100%')
    .height(8)
    .backgroundColor('rgba(224,242,254,0.15)')
    .borderRadius(4)

    Row() {
      Text('剩余名额 ' + item.quota + ' 席')
        .fontSize(11)
        .fontColor('#7E9BA8')
      Text('').layoutWeight(1)
      Text('详情 ›')
        .fontSize(12)
        .fontColor('#38BDF8')
        .onClick(() => {
          this.showWaterDetail(item)
        })
      Text('认领')
        .fontSize(12)
        .fontColor(item.state === 0 ? '#4ADE80' : '#7E9BA8')
        .margin({ left: 14 })
        .onClick(() => {
          if (item.state === 0) {
            this.showWaterBook(item)
          }
        })
    }
    .width('100%')
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#132B38')
  .borderRadius(12)
  .onClick(() => {
    this.showWaterDetail(item)
  })
}, (item: WaterItem) => 'w' + item.id)

进度条的实现采用了嵌套 Row 的方式:外层 Row 是轨道(track),内层 Row 是填充条(fill)。外层宽度 100%、高度 8vp,背景为半透明白色作为轨道底色。内层宽度通过 (item.quota * 6) + '%' 动态计算——名额越多,填充比例越大。背景色根据状态选择蓝色或橙色。

技术概念强调: ArkUI 内置了 Progress 进度组件,可以直接使用 Progress({ value: 50, total: 100 }) 创建进度条。但本例使用嵌套 Row 手动实现进度条,这是因为需要更精细的视觉控制——自定义的圆角、颜色、渐变等效果可能超出 Progress 组件的属性范围。这种"用基础容器拼装自定义控件"的方式在 ArkUI 开发中非常常见。

认领按钮的 .onClick() 回调包含了条件判断:

.onClick(() => {
  if (item.state === 0) {
    this.showWaterBook(item)
  }
})

只有当水域状态为 0(可认领)时,点击认领才会触发认领弹窗。已认满的水域(state 为 1)点击认领不会有任何反应。这种"状态守卫"逻辑防止了用户对不可操作项的误操作。同时,按钮文字颜色也根据状态变化——可认领时为绿色,已满时为灰色,从视觉上告知用户当前是否可操作。

十五、大师内容组件:排行榜与排名标识

MasterContent 组件展示了排行榜的设计模式,包括排名编号和前三名特殊高亮。

ForEach(this.masters, (item: MasterItem, index: number) => {
  Row() {
    Text('№' + (index + 1))
      .fontSize(13)
      .fontWeight(FontWeight.Bold)
      .fontColor(index === 0 ? '#FB923C' : (index < 3 ? '#38BDF8' : '#7E9BA8'))
      .width(36)

    Column({ space: 4 }) {
      Text(item.name)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#E0F2FE')
      Text('擅长 ' + item.skill + ' · 荣誉 ' + item.honor)
        .fontSize(11)
        .fontColor('#7DD3FC')
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)

    Column({ space: 4 }) {
      Text(masterStateText(item.state))
        .fontSize(10)
        .fontColor(masterStateColor(item.state))
        .backgroundColor('rgba(74,222,128,0.15)')
        .borderRadius(10)
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
      Text('剩余 ' + item.quota + ' 席')
        .fontSize(10)
        .fontColor('#7E9BA8')
    }
    .alignItems(HorizontalAlign.End)
  }
  .width('100%')
  .padding(12)
  .backgroundColor('#132B38')
  .borderRadius(12)
  .alignItems(VerticalAlign.Center)
  .onClick(() => {
    this.showMasterDetail(item)
  })
}, (item: MasterItem, index: number) => 'm' + item.id)

技术概念强调: ForEach 的子项生成函数可以接收第二个参数 index(索引),在遍历时从 0 开始递增。利用 index 可以实现排名编号、隔行变色、前三名特殊标识等与位置相关的逻辑。注意 index 是数组中的位置索引,如果列表项被重新排序,index 会随之变化,因此不应将 index 用作数据的唯一标识。

排名编号 №1№2… 的颜色使用了三层嵌套的三元运算符:

.fontColor(index === 0 ? '#FB923C' : (index < 3 ? '#38BDF8' : '#7E9BA8'))

第一名用橙色 #FB923C(金银铜牌中的"金"色系)、第二三名用蓝色 #38BDF8(银牌色系)、第四名及以后用灰色 #7E9BA8。这种通过索引位置区分视觉层级的设计在排行榜类界面中非常经典。

排行榜卡片的布局采用了"三段式"横向排列:左侧排名编号(固定宽度 36vp)、中间名称和擅长领域(layoutWeight(1) 占据剩余空间)、右侧状态和名额。.alignItems(HorizontalAlign.End) 使右侧信息列的子元素右对齐,与左侧的左对齐形成对称布局。

十六、我的内容组件:用户档案与设置项

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: 8 }) {
          Row() {
            Text('🎣')
              .fontSize(40)
            Column({ space: 3 }) {
              Text('钓友阿强')
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E0F2FE')
              Text('🎖️ Lv.18 夜钓达人')
                .fontSize(12)
                .fontColor('#FB923C')
            }
            .alignItems(HorizontalAlign.Start)
            .margin({ left: 12 })
            Text('').layoutWeight(1)
            Text('✏️')
              .fontSize(18)
              .fontColor('#38BDF8')
              .onClick(() => {
                this.showMyNick()
              })
          }
          .width('100%')
          .alignItems(VerticalAlign.Center)

          Row() {
            Text('渔获 328 条')
              .fontSize(12)
              .fontColor('#7DD3FC')
            Text('')
              .fontSize(12)
              .layoutWeight(1)
            Text('足迹 46 处')
              .fontSize(12)
              .fontColor('#7DD3FC')
          }
          .width('100%')
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#132B38')
        .borderRadius(12)
        .border({ width: 1, color: '#38BDF8' })
        // ... 四宫格、收藏列表、设置项
      }
    }
  }
}

用户档案卡的设计是一个标准的"头像+信息+操作"三段横向布局。左侧是 Emoji 头像(40vp 大字体),中间是用户名和等级标签,右侧是编辑按钮。底部的统计行(渔获数、足迹数)使用了 Text('').layoutWeight(1) 作为分隔占位符。

四宫格快捷入口使用了 Row 横向排列四个 Column,每个 Column 设置 .layoutWeight(1) 平分宽度。这种"四等分横向网格"是个人页面的经典布局模式。

收藏列表的每项卡片中有一个删除按钮,点击触发移除确认弹窗:

Text('✕')
  .fontSize(14)
  .fontColor('#FB923C')
  .margin({ left: 12 })
  .onClick(() => {
    this.showMyRemove(item)
  })

技术概念强调: 在列表项中嵌入操作按钮时,需要注意事件冒泡(Event Bubbling)问题。如果整个卡片有 .onClick() 且内部按钮也有 .onClick(),点击按钮时会同时触发两个事件。ArkUI 的事件系统会处理这种情况,内部元素的点击事件会被优先处理。但如果需要阻止事件冒泡,可以使用 stopPropagation 或调整事件绑定策略。在本应用中,收藏列表项本身没有绑定点击事件(不同于直播列表),因此不存在冒泡冲突。

设置项部分展示了两个设置行(清理缓存、退出登录),每行都是 Row 布局:左侧图标和文字、右侧状态值和箭头。.onClick() 绑定对应的弹窗触发回调。

十七、水域认领弹窗:数据更新与状态流转

水域认领确认弹窗展示了完整的"确认→更新数据→状态流转"业务流程。

if (this.showWaterBook) {
  Column() {
    Column({ space: 12 }) {
      Row() {
        Text('✅ 确认认领')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0F2FE')
        Text('').layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#E0F2FE')
          .padding(6)
          .onClick(() => {
            this.showWaterBook = false
          })
      }
      .width('100%')

      Text('你即将认领「' + (this.pickedWater !== null ? this.pickedWater.name : '') + '」\n剩余名额 ' + (this.pickedWater !== null ? this.pickedWater.quota : 0) + ' 席')
        .fontSize(14)
        .fontColor('#BAE6FD')
        .lineHeight(24)

      Text('确认认领')
        .textAlign(TextAlign.Center)
        .width('100%')
        .height(42)
        .backgroundColor('#4ADE80')
        .borderRadius(8)
        .fontSize(15)
        .fontWeight(FontWeight.Bold)
        .fontColor('#0B1F2A')
        .onClick(() => {
          if (this.pickedWater !== null) {
            let idx = this.waters.findIndex((it: WaterItem) => it.id === this.pickedWater!.id)
            if (idx >= 0) {
              this.waters.splice(idx, 1, buildWaterBook(this.waters[idx]))
            }
          }
          this.showWaterBook = false
        })
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#132B38')
    .borderRadius(14)
    .border({ width: 1, color: '#4ADE80' })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(11,31,42,0.88)')
  .justifyContent(FlexAlign.Center)
}

确认按钮的 .onClick() 回调执行了完整的认领流程:先通过 findIndex 定位水域在数组中的位置,然后调用 buildWaterBook 函数生成更新后的 WaterItem 对象(名额减一、可能状态翻转),最后用 splice 原地替换数组中的旧对象。

技术概念强调: 不可变数据(Immutable Data)更新模式在声明式 UI 中至关重要。与其直接修改对象属性 this.waters[idx].quota -= 1,不如用 buildWaterBook 构造一个全新的对象再 splice 替换。虽然 ArkUI 的 @State 能观察 splice 操作,但创建新对象的方式更安全——它保证了旧对象的引用不会意外被其他代码修改,且在调试时更容易追踪状态变化历史。

十八、退赛/移除弹窗:确认对话与数组删除

退出拜师弹窗和收藏移除弹窗展示了两步确认的交互模式——用户需要点击"取消"或"确认"来决定是否执行破坏性操作。

if (this.showMasterQuit) {
  Column() {
    Column({ space: 12 }) {
      Row() {
        Text('⚠️ 退出拜师')
          .fontSize(17)
          .fontWeight(FontWeight.Bold)
          .fontColor('#E0F2FE')
        Text('').layoutWeight(1)
        Text('✕')
          .fontSize(16)
          .fontColor('#E0F2FE')
          .padding(6)
          .onClick(() => {
            this.showMasterQuit = false
          })
      }
      .width('100%')

      Text('确认退出「' + (this.pickedMaster !== null ? this.pickedMaster.name : '') + '」的拜师?\n退出后学徒积分清零。')
        .fontSize(14)
        .fontColor('#BAE6FD')
        .lineHeight(24)

      Row() {
        Text('取消')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('rgba(100,116,139,0.3)')
          .borderRadius(8)
          .fontSize(14)
          .fontColor('#BAE6FD')
          .onClick(() => {
            this.showMasterQuit = false
          })
        Text('确认退出')
          .textAlign(TextAlign.Center)
          .width('46%')
          .height(40)
          .backgroundColor('#FB923C')
          .borderRadius(8)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#0B1F2A')
          .onClick(() => {
            if (this.pickedMaster !== null) {
              let idx = this.masters.findIndex((it: MasterItem) => it.id === this.pickedMaster!.id)
              if (idx >= 0) {
                this.masters.splice(idx, 1)
              }
            }
            this.showMasterQuit = false
          })
      }
      .width('100%')
      .justifyContent(FlexAlign.SpaceBetween)
    }
    .width('88%')
    .padding(18)
    .backgroundColor('#132B38')
    .borderRadius(14)
    .border({ width: 1, color: '#FB923C' })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('rgba(11,31,42,0.88)')
  .justifyContent(FlexAlign.Center)
}

这个弹窗包含了两个按钮:取消和确认退出。按钮布局使用了 .justifyContent(FlexAlign.SpaceBetween) 使两个按钮分别贴向左右两端。两个按钮的宽度都是 '46%',中间留出约 8% 的间距。

技术概念强调: FlexAlign.SpaceBetween 是弹性布局的对齐模式之一,使子元素在主轴方向两端对齐,元素之间的间距均匀分配。与之类似的还有 FlexAlign.SpaceAround(每个元素两侧间距相等,包括首尾)和 FlexAlign.SpaceEvenly(所有间距完全相等)。在两按钮确认弹窗中,SpaceBetween 是最常用的对齐方式。

确认退出的逻辑使用了 splice(idx, 1)——与认领弹窗的 splice(idx, 1, newItem) 不同,这里只传两个参数,表示在索引 idx 处删除 1 个元素,不插入新元素。删除后 ForEach 列表会自动移除对应的卡片 UI。

技术概念强调: 破坏性操作(删除、退出、移除)在交互设计中通常需要两步确认——第一步触发确认弹窗,第二步在弹窗中再次确认。这种设计能有效防止用户误操作导致的数据丢失。在视觉上,破坏性操作的按钮通常使用橙色或红色系(如本例的 #FB923C),与安全操作(绿色系 #4ADE80)形成对比,让用户在点击前有所警觉。

十九、改昵称弹窗:数组元素属性更新

修改昵称弹窗展示了另一种数据更新模式——替换数组中特定位置的元素。

Text('保存')
  .textAlign(TextAlign.Center)
  .width('100%')
  .height(42)
  .backgroundColor('#38BDF8')
  .borderRadius(8)
  .fontSize(15)
  .fontWeight(FontWeight.Bold)
  .fontColor('#082F49')
  .onClick(() => {
    if (this.newNick !== '') {
      this.mys[0] = buildMy(this.mys[0].id, this.newNick, this.mys[0].tag, this.mys[0].time)
      this.newNick = ''
    }
    this.showMyNick = false
  })

这段代码通过 this.mys[0] = buildMy(...) 直接赋值替换数组第一个元素。与 splice 不同,直接索引赋值是一种更直观的元素替换方式。ArkUI 的状态观察机制能捕获通过索引直接赋值的操作,触发对应列表项的 UI 更新。

技术概念强调: ArkUI 的 @State 数组观察机制不仅支持 pushunshiftsplicefill 等数组方法的观察,还支持通过索引直接赋值(如 this.arr[0] = newValue)的观察。这得益于 ArkTS 编译器对数组访问的重写(Proxy 模式)。开发者无需手动调用 setState 或类似 API,数据变化会自动反映到 UI 上。

二十、缓存清理与退出登录:确认弹窗的变体

清理缓存弹窗和退出登录弹窗在结构上与退赛弹窗类似,都是两按钮确认模式。但它们的业务逻辑不同——这两个弹窗实际上不修改任何数据,纯粹是 UI 状态的翻转。

// 清理缓存弹窗的确认按钮
.onClick(() => {
  this.showMyCache = false
})

// 退出登录弹窗的确认按钮
.onClick(() => {
  this.showMyExit = false
})

这两个弹窗的确认按钮只做了一件事:关闭弹窗本身(将对应的 showXxx 布尔值设为 false)。在实际应用中,这里应该添加真正的业务逻辑(如调用清理缓存的系统 API、执行登出请求等),但在本示例中作为 UI 框架演示,仅实现了交互层面的弹窗关闭。

技术概念强调: 即使某些操作暂时没有真实业务逻辑,先搭建完整的交互流程(弹窗→确认→反馈)也是一种良好的开发习惯。这种"UI 先行、逻辑后补"的模式让产品的交互体验在开发早期就可以被验证,避免后期因交互设计不合理而大规模返工。确认弹窗的框架一旦搭好,后续只需在 .onClick() 中填充真实逻辑即可。

二十一、整体架构回顾与流程总结

经过以上对全部代码的逐段拆解,我们已经完整理解了这个钓鱼直播站应用从数据建模到 UI 呈现的全流程。让我们用流程图来总结整个应用的运行架构。

渲染层

组件层

状态层

数据层

interface 类型定义

const 常量数据数组

工具函数 状态映射/数据构建

currentTab 当前标签

xxx 数据列表状态

pickedXxx 选中项状态

showXxx 弹窗开关状态

newXxx 表单输入状态

Index 根组件 Entry

HomeContent 首页

SpotContent 钓点

LiveContent 直播

WaterContent 水域

MasterContent 大师

MeContent 我的

Stack 层叠布局

Column/Row 容器组合

ForEach 列表渲染

条件渲染 弹窗

animation 隐式动画

二十二、核心要素对比汇总表

下面将本应用中涉及的各类数据结构、组件、状态变量、装饰器等核心要素进行系统化对比。

类别名称类型/作用域核心特征典型用途状态观察备注
接口FeedItem数据结构7字段:id/name/avatar/time/content/likes/comments渔获动态数据建模编译期类型安全
接口SpotItem数据结构7字段:id/name/kind/depth/fish/quota/state钓点信息建模state编码0/1
接口LiveItem数据结构6字段:id/name/title/fans/watch/state直播信息建模watch需格式化
接口WaterItem数据结构6字段:id/name/area/quota/price/state水域资源建模含价格字段
接口MasterItem数据结构6字段:id/name/skill/honor/quota/state大师信息建模honor为荣誉值
接口MyItem数据结构4字段:id/name/tag/time收藏记录建模最精简的接口
常量FEEDSFeedItem[]8条初始动态数据首页列表数据源const声明
常量SPOTSSpotItem[]10条钓点数据钓点列表数据源双列渲染
常量LIVESLiveItem[]8条直播数据直播列表数据源含状态驱动UI
常量WATERSWaterItem[]8条水域数据水域列表数据源含进度条
常量MASTERSMasterItem[]8条大师数据排行榜数据源含排名高亮
常量MYSMyItem[]8条收藏数据收藏列表数据源可删除
常量HEATnumber[]8个热度数值柱状图数据动态计算柱高
常量CATSstring[]6个分类标签分类筛选条横向滚动
函数heatBar工具函数返回(14+v*4)+‘vp’柱状图高度计算vp单位
函数playsText工具函数万以上转"万"单位观看数格式化toFixed(1)
函数spotStateText状态映射0→可约钓/1→约满钓点状态文本配套color函数
函数liveStateColor状态映射0→橙/1→灰直播状态颜色区分直播/结束
函数trendText状态映射正→+x%/负→x%趋势百分比正负前缀
函数buildFeed工厂函数构造FeedItem对象新建动态参数化构造
函数buildWaterBook业务函数名额减1+状态翻转水域认领含钳制逻辑
装饰器@Entry组件标记标记页面入口组件Index根组件-每页仅一个
装饰器@Component组件标记声明自定义组件所有struct-struct前修饰
装饰器@State状态管理组件内部可变状态currentTab/feeds等值变化触发重渲染
装饰器@Prop状态管理父→子单向传递子组件数据接收是(父变化同步)子不可修改
装饰器@BuilderUI复用定义可复用UI片段tabItem方法-参数化UI模板
组件Stack容器布局子元素层叠堆叠弹窗覆盖主界面-alignContent对齐
组件Column容器布局子元素纵向排列头部/卡片内容-space设间距
组件Row容器布局子元素横向排列统计行/按钮行-space设间距
组件Scroll容器布局内容滚动浏览各内容区外层-支持横纵方向
组件Text基础组件显示文本标题/正文/标签-支持Emoji
组件TextInput基础组件文本输入框表单弹窗-onChange回调
属性layoutWeight布局属性弹性权重分配占位符/等分布局-值为权重数
属性linearGradient视觉属性线性渐变背景头部横幅-angle+colors
属性animation动画属性隐式过渡动画标签选中放大-duration+curve
属性scale变换属性缩放变换标签选中放大-x/y轴比例
属性borderRadius视觉属性圆角半径卡片/胶囊-数值或百分比
属性opacity视觉属性透明度图标/遮罩-0~1
属性letterSpacing文本属性字间距大标题-vp单位
属性maxLines文本属性最大行数长文本截断-配合textOverflow
APIForEach列表渲染遍历数组生成UI所有列表-需键值函数
APIfindIndex数组方法查找满足条件的索引定位编辑/删除项-返回-1表示未找到
APIsplice数组方法删除/插入/替换数据更新原地修改
APIunshift数组方法头部插入元素新增动态/直播原地修改
APIparseInt全局函数字符串转整数水深输入解析返回NaN容错
枚举FlexAlign对齐枚举主轴对齐方式Center/SpaceBetween等-7种模式
枚举ScrollDirection方向枚举滚动方向Horizontal/Vertical-2种方向
枚举Curve缓动枚举动画曲线EaseOut等-预定义曲线

二十三、全文总结

通过对这款钓鱼直播站应用的完整代码拆解,我们可以清晰地看到鸿蒙 ArkUI 声明式 UI 开发范式的全貌。从最底层的 interface 类型定义,到 const 常量数据初始化,再到工具函数层的状态映射和数据构建,整个数据层的设计严谨而富有层次。每一个业务实体都有明确的接口定义和初始数据,工具函数将状态码到视觉表现的映射逻辑集中管理,工厂函数统一了对象的构造方式。这种"数据层→函数层→状态层→组件层→渲染层"的分层架构,使得代码各司其职、职责清晰,为后续的功能扩展和维护奠定了良好基础。

状态管理是本应用最核心的技术亮点。根组件 Index 通过近 40 个 @State 变量构建了一个完整的状态中枢,涵盖了数据列表、选中项、弹窗开关、表单输入、选择索引五大类状态。这些状态变量通过 @Prop 单向传递到六个子内容组件,子组件通过回调函数向父组件上报用户操作,形成了"数据向下、事件向上"的清晰数据流。弹窗系统通过布尔开关变量配合条件渲染实现,每个弹窗都是独立的条件分支,互不干扰。这种设计虽然状态变量数量较多,但每个变量职责单一、语义明确,整体可维护性反而优于少数复杂状态对象的方案。

UI 布局方面,应用充分运用了 ArkUI 的容器组件体系。Stack 层叠布局实现了弹窗覆盖主界面的效果,ColumnRow 的组合构建了从页面骨架到卡片内容的完整布局层级。layoutWeight 弹性权重分配解决了"固定高度+自适应内容+固定高度"的三段式布局问题,也在列表内部实现了等分网格和两端对齐。Scroll 滚动容器包裹所有内容区,配合 scrollBar(BarState.Off) 隐藏滚动条,提供了流畅的信息浏览体验。ForEach 列表渲染配合键值函数实现了高效的增量更新,无论是头部插入新动态还是原地替换编辑后的数据,都能精确更新到对应的 UI 片段。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 晨雾水岸风:bg #0B1F2A / 面板 #132B38 / 水蓝 #38BDF8 / 雾白 #E0F2FE / 草绿 #4ADE80 / 霞橙 #FB923C

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

interface SpotItem {
  id: number
  name: string
  kind: string
  depth: number
  fish: number
  quota: number
  state: number
}

interface LiveItem {
  id: number
  name: string
  title: string
  fans: number
  watch: number
  state: number
}

interface WaterItem {
  id: number
  name: string
  area: number
  quota: number
  price: number
  state: number
}

interface MasterItem {
  id: number
  name: string
  skill: string
  honor: number
  quota: number
  state: number
}

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

const FEEDS: FeedItem[] = [
  { id: 1, name: '野钓阿强', avatar: '🎣', time: '12分钟前', content: '昨晚夜钓青龙湖,18 斤大鲤鱼上岸!主线切了三次,换 0.8 子线硬扛,这手感绝了!', likes: 628, comments: 93 },
  { id: 2, name: '路亚小北', avatar: '🐟', time: '35分钟前', content: '白鹭湾路亚翘嘴连竿,亮片搜边三竿中两尾,傍晚窗口期到了,钓友们冲!', likes: 451, comments: 67 },
  { id: 3, name: '矶钓老周', avatar: '🪨', time: '1小时前', content: '双月湾矶钓爆箱,黄鳍鲷排着队上钩,虾仁打窝加南极虾,今天收成十五斤!', likes: 389, comments: 54 },
  { id: 4, name: '筏钓阿斌', avatar: '⛵', time: '2小时前', content: '雾山水库筏钓鲫鱼连竿,半天一百多条,水深 22 米钓中层,串钩挂红虫效果好。', likes: 342, comments: 49 },
  { id: 5, name: '台钓小陈', avatar: '🎯', time: '4小时前', content: '黑坑正钓鲤鱼口好到爆,饵料配方分享:颗粒粉加果酸,散炮带软粘,抢鱼阶段稳拿前三!', likes: 517, comments: 86 },
  { id: 6, name: '冰钓老刘', avatar: '🧊', time: '6小时前', content: '冰窟窿下面全是大鲫鱼,穿得厚就行,冰钓竿加七星漂,一提一个准。', likes: 276, comments: 38 },
  { id: 7, name: '溪流阿泽', avatar: '🏞️', time: '昨天', content: '樱花溪上游马口成群,2.7 米溪流竿配 0.6 主线,羽毛钩慢收,一下午拉到手软。', likes: 305, comments: 42 },
  { id: 8, name: '海钓大鹏', avatar: '🛥️', time: '昨天', content: '出海船钓金枪鱼,搏斗整整两小时终于拿下,深海 60 米铁板抽到没脾气,值了!', likes: 742, comments: 128 }
]

const SPOTS: SpotItem[] = [
  { id: 1, name: '青龙湖大坝', kind: '湖库', depth: 18, fish: 96, quota: 8, state: 0 },
  { id: 2, name: '白鹭湾湿地', kind: '河湾', depth: 5, fish: 92, quota: 0, state: 1 },
  { id: 3, name: '双月湾矶石', kind: '海钓', depth: 25, fish: 95, quota: 10, state: 0 },
  { id: 4, name: '柳溪河下游', kind: '溪流', depth: 3, fish: 88, quota: 12, state: 0 },
  { id: 5, name: '雾山水库', kind: '湖库', depth: 22, fish: 91, quota: 6, state: 0 },
  { id: 6, name: '金沙湾排口', kind: '海钓', depth: 12, fish: 89, quota: 0, state: 1 },
  { id: 7, name: '老桥洞钓位', kind: '河湾', depth: 6, fish: 87, quota: 9, state: 0 },
  { id: 8, name: '野猪湖芦苇荡', kind: '湖库', depth: 4, fish: 90, quota: 14, state: 0 },
  { id: 9, name: '断崖深水区', kind: '湖库', depth: 30, fish: 93, quota: 4, state: 0 },
  { id: 10, name: '樱花溪上游', kind: '溪流', depth: 2, fish: 85, quota: 7, state: 0 }
]

const LIVES: LiveItem[] = [
  { id: 1, name: '阿强夜钓', title: '青龙湖夜钓 主线0.8 夜光漂', fans: 2354, watch: 680000, state: 0 },
  { id: 2, name: '小北路亚', title: '白鹭湾路亚 亮片搜边', fans: 1567, watch: 450000, state: 0 },
  { id: 3, name: '老周矶钓', title: '双月湾矶钓 礁石打窝', fans: 982, watch: 320000, state: 0 },
  { id: 4, name: '阿斌筏钓', title: '雾山水库筏钓鲫鱼', fans: 1230, watch: 410000, state: 0 },
  { id: 5, name: '小陈黑坑', title: '黑坑正钓抢鱼 配方分享', fans: 2056, watch: 530000, state: 0 },
  { id: 6, name: '老刘冰钓', title: '冰钓大鲫鱼 七星漂教学', fans: 645, watch: 210000, state: 1 },
  { id: 7, name: '阿泽溪流', title: '溪流马口 羽毛钩慢收', fans: 721, watch: 260000, state: 0 },
  { id: 8, name: '大鹏船钓', title: '深海拖钓 铁板抽金枪', fans: 1890, watch: 740000, state: 1 }
]

const WATERS: WaterItem[] = [
  { id: 1, name: '青龙湖水域', area: 2800, quota: 12, price: 500, state: 0 },
  { id: 2, name: '白鹭湾水域', area: 1600, quota: 8, price: 400, state: 0 },
  { id: 3, name: '雾山水域', area: 3500, quota: 15, price: 600, state: 0 },
  { id: 4, name: '野猪湖水域', area: 1200, quota: 0, price: 350, state: 1 },
  { id: 5, name: '柳溪河水域', area: 900, quota: 5, price: 300, state: 0 },
  { id: 6, name: '金沙湾水域', area: 2100, quota: 10, price: 550, state: 0 },
  { id: 7, name: '断崖深水区', area: 800, quota: 3, price: 450, state: 0 },
  { id: 8, name: '樱花溪水域', area: 600, quota: 4, price: 280, state: 0 }
]

const MASTERS: MasterItem[] = [
  { id: 1, name: '钓王老郑', skill: '湖库巨物', honor: 98, quota: 6, state: 0 },
  { id: 2, name: '路亚一姐阿兰', skill: '路亚鲈鱼', honor: 96, quota: 8, state: 0 },
  { id: 3, name: '矶钓高手老顾', skill: '海矶钓', honor: 95, quota: 0, state: 1 },
  { id: 4, name: '筏钓宗师老韩', skill: '筏钓', honor: 93, quota: 5, state: 0 },
  { id: 5, name: '黑坑达人小马', skill: '竞技黑坑', honor: 92, quota: 7, state: 0 },
  { id: 6, name: '冰钓活字典老孙', skill: '冰钓', honor: 90, quota: 0, state: 1 },
  { id: 7, name: '溪流精灵阿康', skill: '溪流钓', honor: 89, quota: 9, state: 0 },
  { id: 8, name: '船钓船长老吴', skill: '深海船钓', honor: 94, quota: 4, state: 0 }
]

const MYS: MyItem[] = [
  { id: 1, name: '18斤大鲤上岸实录', tag: '野钓', time: '收藏于 08-25' },
  { id: 2, name: '翘嘴连竿路亚教学', tag: '路亚', time: '收藏于 08-23' },
  { id: 3, name: '黄鳍鲷爆箱矶钓', tag: '矶钓', time: '收藏于 08-20' },
  { id: 4, name: '鲫鱼连竿饵料配方', tag: '台钓', time: '收藏于 08-17' },
  { id: 5, name: '冰钓鲫鱼七星漂技巧', tag: '冰钓', time: '收藏于 08-14' },
  { id: 6, name: '金枪鱼搏斗全程', tag: '海钓', time: '收藏于 08-11' },
  { id: 7, name: '马口溪流钓法', tag: '溪流', time: '收藏于 08-08' },
  { id: 8, name: '夜钓调漂心得', tag: '野钓', time: '收藏于 08-05' }
]

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

const CATS: string[] = ['全部', '湖库', '河湾', '海钓', '溪流', '黑坑']

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

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

function spotStateText(s: number): string {
  if (s === 0) {
    return '可约钓'
  }
  return '约满'
}

function spotStateColor(s: number): string {
  if (s === 0) {
    return '#4ADE80'
  }
  return '#FB923C'
}

function liveStateText(s: number): string {
  if (s === 0) {
    return '直播中'
  }
  return '已结束'
}

function liveStateColor(s: number): string {
  if (s === 0) {
    return '#FB923C'
  }
  return '#64748B'
}

function waterStateText(s: number): string {
  if (s === 0) {
    return '可认领'
  }
  return '已认满'
}

function waterStateColor(s: number): string {
  if (s === 0) {
    return '#38BDF8'
  }
  return '#FB923C'
}

function masterStateText(s: number): string {
  if (s === 0) {
    return '收徒中'
  }
  return '已满额'
}

function masterStateColor(s: number): string {
  if (s === 0) {
    return '#4ADE80'
  }
  return '#FB923C'
}

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

function trendColor(t: number): string {
  if (t >= 0) {
    return '#38BDF8'
  }
  return '#FB923C'
}

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 buildSpot(id: number, name: string, kind: string, depth: number, fish: number, quota: number, state: number): SpotItem {
  return { id: id, name: name, kind: kind, depth: depth, fish: fish, quota: quota, state: state }
}

function buildLive(id: number, name: string, title: string, fans: number, watch: number, state: number): LiveItem {
  return { id: id, name: name, title: title, fans: fans, watch: watch, state: state }
}

function buildWater(id: number, name: string, area: number, quota: number, price: number, state: number): WaterItem {
  return { id: id, name: name, area: area, quota: quota, price: price, state: state }
}

function buildMaster(id: number, name: string, skill: string, honor: number, quota: number, state: number): MasterItem {
  return { id: id, name: name, skill: skill, honor: honor, quota: quota, state: state }
}

function buildWaterBook(w: WaterItem): WaterItem {
  let q = w.quota - 1
  let st = w.state
  if (q <= 0) {
    q = 0
    st = 1
  }
  return buildWater(w.id, w.name, w.area, q, w.price, st)
}

@Entry
@Component
struct Index {
  @State currentTab: number = 0
  @State feeds: FeedItem[] = FEEDS
  @State spots: SpotItem[] = SPOTS
  @State lives: LiveItem[] = LIVES
  @State waters: WaterItem[] = WATERS
  @State masters: MasterItem[] = MASTERS
  @State mys: MyItem[] = MYS
  @State pickedFeed: FeedItem | null = null
  @State pickedSpot: SpotItem | null = null
  @State pickedLive: LiveItem | null = null
  @State pickedWater: WaterItem | null = null
  @State pickedMaster: MasterItem | null = null
  @State pickedMy: MyItem | null = null
  @State showFeedDetail: boolean = false
  @State showFeedAdd: boolean = false
  @State showSpotDetail: boolean = false
  @State showSpotEdit: boolean = false
  @State showLiveRule: boolean = false
  @State showLiveDetail: boolean = false
  @State showLiveAdd: boolean = false
  @State showWaterDetail: boolean = false
  @State showWaterBook: boolean = false
  @State showMasterRule: boolean = false
  @State showMasterDetail: boolean = false
  @State showMasterSign: boolean = false
  @State showMasterQuit: 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 newSpotName: string = ''
  @State newSpotKind: string = ''
  @State newSpotDepth: 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 ? '#38BDF8' : '#7E9BA8')
      Text(label)
        .fontSize(11)
        .fontColor(this.currentTab === tab ? '#38BDF8' : '#7E9BA8')
    }
    .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('FISH CAST')
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#E0F2FE')
                Text('钓鱼直播站 · 官方')
                  .fontSize(10)
                  .fontColor('#7DD3FC')
              }
              .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('#E0F2FE')
                .backgroundColor('rgba(56,189,248,0.25)')
                .borderRadius(20)
                .padding({ left: 10, right: 10, top: 3, bottom: 3 })
              Text('').layoutWeight(1)
              Text('🔴 直播中 128 场')
                .fontSize(11)
                .fontColor('#7DD3FC')
            }
            .width('100%')

            Text('钓鱼直播站')
              .fontSize(26)
              .fontWeight(FontWeight.Bold)
              .fontColor('#E0F2FE')
              .letterSpacing(2)
            Text('FISH CAST · 水岸晨雾 一竿入梦')
              .fontSize(11)
              .fontColor('#7DD3FC')
              .letterSpacing(1)

            // 水波符号带
            Row() {
              ForEach([12, 16, 10, 18, 14, 11, 17, 13, 15, 12], (s: number, i: number) => {
                Text(i % 2 === 0 ? '~' : '≈')
                  .fontSize(s)
                  .fontColor(i % 3 === 0 ? '#38BDF8' : (i % 3 === 1 ? '#4ADE80' : '#FB923C'))
                  .opacity(0.8)
              }, (s: number, i: number) => 'wave' + i)
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
            .padding({ left: 20, right: 20 })

            // 搜索胶囊
            Row() {
              Text('🔍')
                .fontSize(14)
              Text('搜索钓点、直播、渔获、大师')
                .fontSize(13)
                .fontColor('#7DD3FC')
                .margin({ left: 6 })
            }
            .width('90%')
            .height(38)
            .backgroundColor('rgba(19,43,56,0.75)')
            .borderRadius(19)
            .padding({ left: 14 })
            .alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .padding({ left: 16, right: 16, top: 12, bottom: 14 })
          .linearGradient({
            angle: 180,
            colors: [['#082F49', 0.0], ['#132B38', 0.55], ['#0B1F2A', 1.0]]
          })
        }
        .width('100%')
        .backgroundColor('#132B38')

        // ===== 内容区 =====
        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) {
            SpotContent({
              spots: this.spots,
              catSel: this.catSel,
              showSpotDetail: (item: SpotItem) => {
                this.pickedSpot = item
                this.showSpotDetail = true
              },
              showSpotEdit: (item: SpotItem) => {
                this.pickedSpot = item
                this.newSpotName = item.name
                this.newSpotKind = item.kind
                this.newSpotDepth = item.depth.toString()
                this.showSpotEdit = true
              }
            })
          }
          if (this.currentTab === 2) {
            LiveContent({
              lives: this.lives,
              showLiveRule: () => {
                this.showLiveRule = true
              },
              showLiveDetail: (item: LiveItem) => {
                this.pickedLive = item
                this.showLiveDetail = true
              },
              showLiveAdd: () => {
                this.showLiveAdd = true
              }
            })
          }
          if (this.currentTab === 3) {
            WaterContent({
              waters: this.waters,
              showWaterDetail: (item: WaterItem) => {
                this.pickedWater = item
                this.showWaterDetail = true
              },
              showWaterBook: (item: WaterItem) => {
                this.pickedWater = item
                this.showWaterBook = true
              }
            })
          }
          if (this.currentTab === 4) {
            MasterContent({
              masters: this.masters,
              showMasterRule: () => {
                this.showMasterRule = true
              },
              showMasterDetail: (item: MasterItem) => {
                this.pickedMaster = item
                this.showMasterDetail = true
              },
              showMasterSign: (item: MasterItem) => {
                this.pickedMaster = item
                this.showMasterSign = true
              },
              showMasterQuit: (item: MasterItem) => {
                this.pickedMaster = item
                this.showMasterQuit = 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%')

        // ===== 底部 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('#132B38')
        .border({ width: { top: 1 }, color: '#1E3A4A' })
      }
      .width('100%')
      .height('100%')
      .backgroundColor('#0B1F2A')

      // ================= 弹窗层 =================

      // 动态详情
      if (this.showFeedDetail) {
        Column() {
          Column({ space: 10 }) {
            Row() {
              Text(this.pickedFeed !== null ? this.pickedFeed.avatar : '')
                .fontSize(30)
              Column({ space: 3 }) {
                Text(this.pickedFeed !== null ? this.pickedFeed.name : '')
                  .fontSize(17)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#E0F2FE')
                Text(this.pickedFeed !== null ? this.pickedFeed.time : '')
                  .fontSize(11)
                  .fontColor('#7DD3FC')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#E0F2FE')
                .padding(6)
                .onClick(() => {
                  this.showFeedDetail = false
                })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Text(this.pickedFeed !== null ? this.pickedFeed.content : '')
              .fontSize(15)
              .fontColor('#BAE6FD')
              .lineHeight(24)

            Row() {
              Text('👍 渔获点赞 ' + (this.pickedFeed !== null ? this.pickedFeed.likes : 0))
                .fontSize(13)
                .fontColor('#4ADE80')
                .backgroundColor('rgba(74,222,128,0.15)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
              Text('💬 评论 ' + (this.pickedFeed !== null ? this.pickedFeed.comments : 0))
                .fontSize(13)
                .fontColor('#38BDF8')
                .backgroundColor('rgba(56,189,248,0.15)')
                .borderRadius(14)
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
            }
            .width('100%')

            Text('🐟 鱼获 18.6 斤 · 主线 0.8 · 夜光漂')
              .fontSize(12)
              .fontColor('#FB923C')
              .backgroundColor('rgba(251,146,60,0.12)')
              .borderRadius(10)
              .padding({ left: 10, right: 10, top: 6, bottom: 6 })
              .width('100%')
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#132B38')
          .borderRadius(14)
          .border({ width: 1, color: '#38BDF8' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(11,31,42,0.88)')
        .justifyContent(FlexAlign.Center)
      }

      // 发布动态新增
      if (this.showFeedAdd) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('🎣 发布渔获动态')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E0F2FE')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#E0F2FE')
                .padding(6)
                .onClick(() => {
                  this.showFeedAdd = false
                })
            }
            .width('100%')

            TextInput({ placeholder: '你的昵称', text: this.newFeedName })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#E0F2FE')
              .backgroundColor('#0B1F2A')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedName = v
              })

            TextInput({ placeholder: '分享今天的渔获...', text: this.newFeedText })
              .width('100%')
              .height(88)
              .fontSize(14)
              .fontColor('#E0F2FE')
              .backgroundColor('#0B1F2A')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedText = v
              })

            Row() {
              Text('🌊 野钓')
                .fontSize(12)
                .fontColor('#38BDF8')
                .backgroundColor('rgba(56,189,248,0.15)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('🎣 路亚')
                .fontSize(12)
                .fontColor('#4ADE80')
                .backgroundColor('rgba(74,222,128,0.15)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('🐟 台钓')
                .fontSize(12)
                .fontColor('#FB923C')
                .backgroundColor('rgba(251,146,60,0.15)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
            }
            .width('100%')

            Text('发布')
              .textAlign(TextAlign.Center)
              .width('100%')
              .height(42)
              .backgroundColor('#38BDF8')
              .borderRadius(8)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#082F49')
              .onClick(() => {
                if (this.newFeedName !== '' && this.newFeedText !== '') {
                  this.feeds.unshift(buildFeed(this.feeds.length + 1, this.newFeedName, '🎣', '刚刚', this.newFeedText, 0, 0))
                  this.newFeedName = ''
                  this.newFeedText = ''
                }
                this.showFeedAdd = false
              })
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#132B38')
          .borderRadius(14)
          .border({ width: 1, color: '#4ADE80' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(11,31,42,0.88)')
        .justifyContent(FlexAlign.Center)
      }

      // 钓点详情
      if (this.showSpotDetail) {
        Column() {
          Column({ space: 10 }) {
            Row() {
              Text('📍')
                .fontSize(30)
              Column({ space: 3 }) {
                Text(this.pickedSpot !== null ? this.pickedSpot.name : '')
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#E0F2FE')
                Text(this.pickedSpot !== null ? this.pickedSpot.kind + ' · 水深 ' + this.pickedSpot.depth + ' 米' : '')
                  .fontSize(12)
                  .fontColor('#7DD3FC')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#E0F2FE')
                .padding(6)
                .onClick(() => {
                  this.showSpotDetail = false
                })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Row() {
              Text('渔获指数 ' + (this.pickedSpot !== null ? this.pickedSpot.fish : 0))
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FB923C')
              Text('').layoutWeight(1)
              Text(this.pickedSpot !== null ? spotStateText(this.pickedSpot.state) : '')
                .fontSize(12)
                .fontColor(this.pickedSpot !== null ? spotStateColor(this.pickedSpot.state) : '#4ADE80')
            }
            .width('100%')

            Text('主钓鱼种:鲤鱼 / 草鱼 / 翘嘴\n最佳时段:清晨 05:00 - 08:00、傍晚 17:00 - 19:00\n饵料建议:玉米打窝 + 腥香饵')
              .fontSize(13)
              .fontColor('#BAE6FD')
              .lineHeight(22)

            Row() {
              Text('✏️ 编辑记录')
                .textAlign(TextAlign.Center)
                .width('46%')
                .height(40)
                .backgroundColor('rgba(56,189,248,0.2)')
                .borderRadius(8)
                .fontSize(14)
                .fontColor('#38BDF8')
                .onClick(() => {
                  this.showSpotDetail = false
                  this.showSpotEdit = true
                })
              Text('🗺️ 导航去钓')
                .textAlign(TextAlign.Center)
                .width('46%')
                .height(40)
                .backgroundColor('#38BDF8')
                .borderRadius(8)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#082F49')
                .onClick(() => {
                  this.showSpotDetail = false
                })
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#132B38')
          .borderRadius(14)
          .border({ width: 1, color: '#38BDF8' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(11,31,42,0.88)')
        .justifyContent(FlexAlign.Center)
      }

      // 钓点记录编辑
      if (this.showSpotEdit) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('✏️ 编辑钓点记录')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E0F2FE')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#E0F2FE')
                .padding(6)
                .onClick(() => {
                  this.showSpotEdit = false
                })
            }
            .width('100%')

            TextInput({ placeholder: '钓点名称', text: this.newSpotName })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#E0F2FE')
              .backgroundColor('#0B1F2A')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newSpotName = v
              })

            TextInput({ placeholder: '水域类型', text: this.newSpotKind })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#E0F2FE')
              .backgroundColor('#0B1F2A')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newSpotKind = v
              })

            TextInput({ placeholder: '水深(米)', text: this.newSpotDepth })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#E0F2FE')
              .backgroundColor('#0B1F2A')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newSpotDepth = v
              })

            Text('保存修改')
              .textAlign(TextAlign.Center)
              .width('100%')
              .height(42)
              .backgroundColor('#FB923C')
              .borderRadius(8)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#0B1F2A')
              .onClick(() => {
                if (this.pickedSpot !== null) {
                  let idx = this.spots.findIndex((it: SpotItem) => it.id === this.pickedSpot!.id)
                  if (idx >= 0) {
                    let d = parseInt(this.newSpotDepth)
                    if (isNaN(d)) {
                      d = this.pickedSpot.depth
                    }
                    this.spots.splice(idx, 1, buildSpot(this.pickedSpot.id, this.newSpotName, this.newSpotKind, d, this.pickedSpot.fish, this.pickedSpot.quota, this.pickedSpot.state))
                  }
                }
                this.showSpotEdit = false
              })
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#132B38')
          .borderRadius(14)
          .border({ width: 1, color: '#FB923C' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(11,31,42,0.88)')
        .justifyContent(FlexAlign.Center)
      }

      // 直播规则
      if (this.showLiveRule) {
        Column() {
          Column({ space: 10 }) {
            Row() {
              Text('📜 开播规则')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E0F2FE')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#E0F2FE')
                .padding(6)
                .onClick(() => {
                  this.showLiveRule = false
                })
            }
            .width('100%')

            Text('1. 直播必须在水域边进行,画面清晰\n2. 禁止虚假渔获、禁止电鱼毒鱼\n3. 开播前需预约时段,每天最多 2 场\n4. 单场观看满 10 万可上热门榜单')
              .fontSize(14)
              .fontColor('#BAE6FD')
              .lineHeight(26)

            Text('知道了')
              .textAlign(TextAlign.Center)
              .width('100%')
              .height(40)
              .backgroundColor('#38BDF8')
              .borderRadius(8)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#082F49')
              .onClick(() => {
                this.showLiveRule = false
              })
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#132B38')
          .borderRadius(14)
          .border({ width: 1, color: '#7DD3FC' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(11,31,42,0.88)')
        .justifyContent(FlexAlign.Center)
      }

      // 直播详情
      if (this.showLiveDetail) {
        Column() {
          Column({ space: 10 }) {
            Row() {
              Text('🔴')
                .fontSize(24)
              Column({ space: 3 }) {
                Text(this.pickedLive !== null ? this.pickedLive.name : '')
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#E0F2FE')
                Text(this.pickedLive !== null ? liveStateText(this.pickedLive.state) : '')
                  .fontSize(12)
                  .fontColor(this.pickedLive !== null ? liveStateColor(this.pickedLive.state) : '#FB923C')
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#E0F2FE')
                .padding(6)
                .onClick(() => {
                  this.showLiveDetail = false
                })
            }
            .width('100%')
            .alignItems(VerticalAlign.Center)

            Text(this.pickedLive !== null ? this.pickedLive.title : '')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#F0FDFA')

            Row() {
              Text('👥 钓友 ' + (this.pickedLive !== null ? this.pickedLive.fans : 0))
                .fontSize(13)
                .fontColor('#7DD3FC')
              Text('').layoutWeight(1)
              Text('👀 观看 ' + (this.pickedLive !== null ? playsText(this.pickedLive.watch) : '0'))
                .fontSize(13)
                .fontColor('#FB923C')
            }
            .width('100%')

            Text('直播间正在水边实况,当前鱼口不错,主播会在抛竿间隙回复弹幕问题。')
              .fontSize(13)
              .fontColor('#BAE6FD')
              .lineHeight(22)

            Row() {
              Text('🎁 送礼')
                .textAlign(TextAlign.Center)
                .width('46%')
                .height(40)
                .backgroundColor('rgba(251,146,60,0.2)')
                .borderRadius(8)
                .fontSize(14)
                .fontColor('#FB923C')
                .onClick(() => {
                  this.showLiveDetail = false
                })
              Text('💬 发弹幕')
                .textAlign(TextAlign.Center)
                .width('46%')
                .height(40)
                .backgroundColor('#38BDF8')
                .borderRadius(8)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#082F49')
                .onClick(() => {
                  this.showLiveDetail = false
                })
            }
            .width('100%')
            .justifyContent(FlexAlign.SpaceBetween)
          }
          .width('88%')
          .padding(18)
          .backgroundColor('#132B38')
          .borderRadius(14)
          .border({ width: 1, color: '#FB923C' })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('rgba(11,31,42,0.88)')
        .justifyContent(FlexAlign.Center)
      }

      // 预约开播新增
      if (this.showLiveAdd) {
        Column() {
          Column({ space: 12 }) {
            Row() {
              Text('🎥 预约开播')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#E0F2FE')
              Text('').layoutWeight(1)
              Text('✕')
                .fontSize(16)
                .fontColor('#E0F2FE')
                .padding(6)
                .onClick(() => {
                  this.showLiveAdd = false
                })
            }
            .width('100%')

            TextInput({ placeholder: '主播昵称', text: this.newFeedName })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#E0F2FE')
              .backgroundColor('#0B1F2A')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedName = v
              })

            TextInput({ placeholder: '直播标题,如:青龙湖夜钓', text: this.newFeedText })
              .width('100%')
              .height(42)
              .fontSize(14)
              .fontColor('#E0F2FE')
              .backgroundColor('#0B1F2A')
              .borderRadius(8)
              .onChange((v: string) => {
                this.newFeedText = v
              })

            Row() {
              Text('⏰ 06:00 - 09:00')
                .fontSize(12)
                .fontColor('#38BDF8')
                .backgroundColor('rgba(56,189,248,0.15)')
                .borderRadius(12)
                .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              Text('🌙 19:00 - 22:00')
                .fontSize(12)
                .fontColor('#4ADE80')
  }
}

// ===== 2182.ets END =====


在这里插入图片描述

视觉设计方面,应用采用了一套精心设计的"晨雾水岸风"色彩体系,六个核心色值(深墨蓝/暗青绿/水蓝/雾白/草绿/霞橙)贯穿全局。通过 linearGradient 线性渐变营造头部氛围,通过半透明 rgba 色实现遮罩和背景层次,通过状态映射函数确保同一业务状态在应用各处的视觉表达一致。柱状图、进度条等数据可视化元素完全使用基础组件拼装实现,展现了 ArkUI 在无内置图表组件情况下的灵活性。animation 隐式动画为标签切换提供了平滑的缩放过渡,scale 变换和 letterSpacing 字间距等细节优化了视觉精致度。

交互设计方面,应用实现了完整的增删改查流程。新增(发布动态、预约开播)、编辑(钓点记录修改、昵称修改)、删除(移除收藏、退出拜师)、状态流转(水域认领名额递减、大师拜师名额递减)等操作都有对应的弹窗和确认流程。破坏性操作采用两步确认模式,通过橙色按钮和警示文案降低误操作风险。状态守卫逻辑(如已认满的水域不可再认领)从交互层面防止了非法操作。每个弹窗都遵循统一的布局模式——半透明遮罩+居中卡片+关闭按钮+内容+操作按钮,形成了一致的用户体验。

在工程实践层面,这个应用展示了 ArkUI 开发的多个最佳实践:防御性编程(null 检查、isNaN 容错)、不可变数据更新(构造新对象再 splice 替换而非直接修改属性)、关注点分离(状态映射逻辑提取为函数、UI 构建提取为 @Builder)、参数化复用(@Builder 通过参数渲染不同内容)、以及一致的代码风格(统一的命名模式、统一的弹窗结构、统一的卡片布局)。这些实践使得代码虽然体量较大(近 2900 行),但结构清晰、可读性强、易于维护,充分体现了声明式 UI 范式在构建复杂应用方面的优势。

Logo

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

更多推荐