#基于HarmonyOS API 24实战解析投资理财记账应用深度技术解析,开闭原则(对扩展开放、对修改关闭)的实践,是构建可维护软件系统的黄金准则
HarmonyOS ArkTS 钓鱼记录管理应用技术深度解析
引言
钓鱼作为一项兼具休闲与竞技属性的户外运动,在国内外拥有庞大的爱好者群体。随着移动设备的普及,越来越多的钓友希望通过一款便捷的应用来记录每一次出钓的细节,包括鱼种、重量、钓点、天气、饵料、钓法等信息,以便长期追踪自己的钓鱼成长轨迹。本文将深入剖析一款基于 HarmonyOS ArkTS 声明式开发框架打造的钓鱼记录管理应用,该应用以"深海蓝 + 海草绿"为核心视觉语言,通过精致的卡片式布局、流畅的水波动画和丰富的数据统计能力,为钓友提供了一站式的钓鱼生活记录体验。无论你是想要回顾某一次精彩的爆护经历,还是希望分析不同季节、不同水域的鱼获规律,这款应用都能提供直观且专业的数据支撑。
从功能架构来看,这款应用采用了经典的底部多 Tab 导航模式,将核心功能拆分为五个相对独立又彼此关联的模块:鱼获记录、钓点管理、装备库、统计分析和个人中心。鱼获记录模块是整个应用的数据核心,支持按鱼种筛选并以精美的卡片形式展示每一条鱼获的完整信息,包括天气、温度、钓法、饵料、放生状态等维度;钓点管理模块则帮助钓友维护自己的"秘密钓点库",记录每个钓点的类型、距离、评分、最佳作钓季节、可钓鱼种以及配套设施;装备库模块让钓友能够清晰地管理自己的钓具资产,跟踪每件装备的品牌、型号、价格、使用次数和当前状态;统计分析模块通过四格统计卡、月度柱状图、鱼种占比进度条和个人记录榜等多种可视化形式,将散落的鱼获数据转化为有价值的洞察;个人中心模块则整合了钓友档案、钓鱼成就、装备概览和系统设置等功能。
在设计理念上,这款应用充分体现了"场景化设计"的思想。配色方案选用深海蓝(#0277BD)作为主色调,搭配海草绿(#2E7D32)作为辅助色,背景采用浅天蓝色(#E1F5FE),整体营造出一种置身水畔、亲近自然的视觉氛围。应用在顶部巧妙地加入了一段水波流动动画,通过两层渐变色带的不停平移,模拟出水面波光粼粼的动态效果,让用户在浏览数据的同时也能感受到钓鱼场景的沉浸感。卡片设计上,每张鱼获卡片顶部都有一段水面渐变头部,配合天气图标、个人最佳标识和放生状态标签,既美观又信息密度高。装备卡片则通过左侧分类色块、右侧价格高亮的布局,让用户能够快速扫读关键信息。
从技术架构层面看,该应用基于 ArkTS 的声明式 UI 范式构建,充分运用了 @Entry、@Component、@State、@Prop、@Observed、@Builder 等核心装饰器,构建出一套层次清晰、职责分明的组件体系。数据层通过 @Observed 修饰的可观察类来承载业务实体,配合静态 mock 数据模拟后端数据源;逻辑层通过一系列纯函数完成统计计算,保证业务逻辑与视图渲染的解耦;视图层则通过多个独立的 @Component 子组件分别承载五个 Tab 页面,每个子组件内部又通过 @Builder 方法拆分出卡片、弹窗等可复用的 UI 片段。这种分层架构既保证了代码的可读性,也为后续接入真实后端、引入分布式数据同步等能力预留了充足的扩展空间。
应用场景方面,这款应用非常适合广大钓鱼爱好者在日常出钓前后使用。出钓前,钓友可以在钓点模块查看历史钓点的最佳作钓时段和可钓鱼种,辅助决策出行计划;作钓过程中,可以通过新增鱼获弹窗快速记录每一条鱼的详细信息;作钓结束后,则可以在统计模块回顾本次出钓的成果,对比历史数据寻找规律。对于追求"路亚精神"的钓友,放生状态标签的设计更是契合了路亚钓法"钓获即放流"的环保理念。此外,装备库的使用次数跟踪功能,也能帮助钓友评估装备的性价比,及时更换老化钓具,提升作钓体验。接下来,我们将从代码层面逐段剖析这款应用的实现细节。
一、interface 类型定义体系
1.1 筛选项与统计项接口
应用首先定义了一批用于配置选项和统计展示的 interface 类型,这些接口虽然结构简单,但贯穿了整个应用的配置与展示逻辑。
interface CatchFilterOption {
label: string
value: string
}
interface SpotTypeOption {
label: string
value: string
}
interface GearCategoryOption {
label: string
value: string
}
interface StatCardItem {
label: string
value: string
icon: string
color: string
}
interface MonthlyStat {
month: string
count: number
}
interface FishRatioItem {
fishType: string
count: number
ratio: number
color: string
}
这里定义了 CatchFilterOption、SpotTypeOption、GearCategoryOption 三个用于筛选的选项接口,它们都遵循 label 与 value 双字段的设计模式。label 用于界面显示中文文案,value 则用于逻辑判断和数据匹配,这种分离设计的好处在于显示与逻辑解耦,未来如果需要做国际化或者多语言适配,只需替换 label 而无需改动业务逻辑。StatCardItem 用于统计卡片的展示,包含标签、数值、图标和颜色四个字段,让卡片样式可以通过数据驱动。MonthlyStat 和 FishRatioItem 则分别服务于月度柱状图和鱼种占比进度条两个统计图表,字段设计精简但足以承载可视化所需的全部信息。
1.2 记录与成就接口
interface PersonalRecord {
fishType: string
weight: string
date: string
location: string
}
interface AchievementItem {
title: string
icon: string
desc: string
unlocked: boolean
}
PersonalRecord 接口用于个人记录榜的数据结构,包含鱼种、重量、日期和地点四个字段,这里值得注意的是 weight 字段被定义为 string 类型而非 number,这是因为记录榜展示时已经将数值与单位"斤"拼接成字符串,避免了在视图层再做格式化处理。AchievementItem 接口则定义了成就项的结构,unlocked 布尔字段用于标识成就是否已解锁,这一字段直接驱动了成就卡片在视觉上的差异——已解锁的成就使用暖色调背景和加粗字体,未解锁的则使用灰色边框和浅色背景,形成鲜明的对比,激励钓友去完成更多成就。
1.3 图标映射接口
interface WeatherIconMap {
[key: string]: string
}
interface BaitIconMap {
[key: string]: string
}
interface MethodIconMap {
[key: string]: string
}
interface FacilityIconMap {
[key: string]: string
}
应用定义了四个索引签名接口 WeatherIconMap、BaitIconMap、MethodIconMap、FacilityIconMap,它们都采用 [key: string]: string 的形式,本质上是字符串到字符串的映射表。这种设计用于将业务字段值(如"晴"“蚯蚓”“手竿”“停车”)映射到对应的 Emoji 图标上,使得视图层可以通过简单的查表操作 WEATHER_ICONS[item.weather] 来获取图标,极大简化了条件渲染的逻辑。使用独立的 interface 而非直接使用 Record<string, string>,是为了让类型语义更加明确,方便后续扩展时增加更严格的约束。
二、@Observed 数据模型类
2.1 CatchItem 鱼获数据模型
@Observed
class CatchItem {
id: number = 0
fishType: string = ''
weight: number = 0
length: number = 0
location: string = ''
date: string = ''
time: string = ''
weather: string = ''
temperature: number = 0
bait: string = ''
method: string = ''
photo: string = ''
notes: string = ''
isPersonalBest: boolean = false
releaseStatus: string = ''
constructor(id: number, fishType: string, weight: number, length: number, location: string,
date: string, time: string, weather: string, temperature: number, bait: string,
method: string, photo: string, notes: string, isPersonalBest: boolean, releaseStatus: string) {
this.id = id
this.fishType = fishType
this.weight = weight
this.length = length
this.location = location
this.date = date
this.time = time
this.weather = weather
this.temperature = temperature
this.bait = bait
this.method = method
this.photo = photo
this.notes = notes
this.isPersonalBest = isPersonalBest
this.releaseStatus = releaseStatus
}
}
CatchItem 是整个应用最核心的数据模型,使用 @Observed 装饰器修饰,这意味着该类的实例属性变化能够被 ArkUI 框架监听并触发相关视图的更新。类中定义了 15 个字段,覆盖了一条鱼获的全部维度信息:id 作为唯一标识;fishType、weight、length 描述鱼的基本属性;location、date、time 记录作钓时空信息;weather、temperature、bait、method 捕获作钓环境与技法;photo、notes 承载多媒体与备注;isPersonalBest、releaseStatus 则是两个状态标记,分别标识是否为个人最佳记录以及放生还是带走。每个字段都给出了默认初始值,这是 ArkTS 中 @Observed 类的良好实践,可以避免在反序列化或部分赋值时出现 undefined 问题。构造函数采用全参数列表的形式,虽然参数较多,但保证了对象创建时所有字段都能被显式赋值,有利于数据的完整性和可追溯性。
2.2 SpotItem 钓点数据模型
@Observed
class SpotItem {
id: number = 0
name: string = ''
type: string = ''
address: string = ''
distance: number = 0
rating: number = 0
bestSeason: string = ''
bestTime: string = ''
fishTypes: string[] = []
facilities: string[] = []
lastVisit: string = ''
visitCount: number = 0
notes: string = ''
imageColor: string = ''
constructor(id: number, name: string, type: string, address: string, distance: number,
rating: number, bestSeason: string, bestTime: string, fishTypes: string[],
facilities: string[], lastVisit: string, visitCount: number, notes: string, imageColor: string) {
// ...字段赋值
}
}
SpotItem 钓点数据模型同样使用 @Observed 修饰,包含 14 个字段。其中 fishTypes 和 facilities 是两个字符串数组,分别记录该钓点可钓鱼种和配套设施,这种数组结构非常适合用 ForEach 在卡片中渲染为标签云。distance 字段记录钓点与用户的距离,rating 是 1-5 的评分,bestSeason 和 bestTime 则给出最佳作钓时段建议,visitCount 和 lastVisit 跟踪访问历史。特别值得一提的是 imageColor 字段,它为每个钓点存储一个主题色,用于卡片头部渐变,让不同钓点在视觉上具有辨识度。这种"数据驱动样式"的设计让卡片列表看起来丰富而不杂乱。
2.3 GearItem 装备数据模型
@Observed
class GearItem {
id: number = 0
name: string = ''
category: string = ''
brand: string = ''
model: string = ''
price: number = 0
purchaseDate: string = ''
status: string = ''
usageCount: number = 0
notes: string = ''
color: string = ''
constructor(id: number, name: string, category: string, brand: string, model: string,
price: number, purchaseDate: string, status: string, usageCount: number, notes: string, color: string) {
// ...字段赋值
}
}
GearItem 装备数据模型包含 11 个字段,专注于钓具资产的管理。category 字段用于装备分类筛选,可选值包括鱼竿、鱼线、鱼钩、饵料、配件、工具等;brand 和 model 记录品牌型号;price 和 purchaseDate 跟踪采购信息;status 字段是装备生命周期管理的关键,取值为"全新"“使用中”"需更换"三种状态;usageCount 则记录使用次数,是评估装备性价比的重要指标。color 字段与 SpotItem 中的 imageColor 类似,用于在卡片左侧渲染分类色块,让装备列表具有清晰的视觉分组效果。整个模型设计紧凑而实用,覆盖了钓友管理装备库的核心需求。
三、静态配置常量体系
3.1 颜色主题常量
const PRIMARY_COLOR: string = '#0277BD'
const SECONDARY_COLOR: string = '#2E7D32'
const BG_COLOR: string = '#E1F5FE'
const CARD_BG: string = '#FFFFFF'
const TEXT_PRIMARY: string = '#263238'
const TEXT_SECONDARY: string = '#546E7A'
const BORDER_COLOR: string = '#B0BEC5'
const WATER_GRADIENT_TOP: string = '#0277BD'
const WATER_GRADIENT_BOTTOM: string = '#01579B'
应用将所有颜色统一抽取为顶层常量,这是前端工程化中非常推荐的设计模式。PRIMARY_COLOR 深海蓝作为主色调贯穿全应用,用于标题强调、按钮主色、选中态高亮等场景;SECONDARY_COLOR 海草绿作为辅助色,主要出现在钓点模块和"放生"标签等场景,呼应自然主题。BG_COLOR 浅天蓝色作为全局背景,营造水畔氛围;CARD_BG 白色用于卡片背景,与浅色背景形成层次。TEXT_PRIMARY 和 TEXT_SECONDARY 分别定义主次文字颜色,保证信息层级清晰;BORDER_COLOR 用于边框和弱化文字。WATER_GRADIENT_TOP 和 WATER_GRADIENT_BOTTOM 是水波渐变的两个端点色,用于顶部水波动画和卡片头部渐变。这种集中式颜色管理的好处是显而易见的:一方面便于全局换肤,另一方面也避免了硬编码颜色值散落各处导致的维护难题。
3.2 筛选项配置常量
const CATCH_FILTERS: CatchFilterOption[] = [
{ label: '全部', value: '全部' },
{ label: '鲈鱼', value: '鲈鱼' },
{ label: '鲫鱼', value: '鲫鱼' },
{ label: '鲤鱼', value: '鲤鱼' },
{ label: '草鱼', value: '草鱼' },
{ label: '黑鱼', value: '黑鱼' },
{ label: '鳜鱼', value: '鳜鱼' }
]
const SPOT_TYPE_OPTIONS: SpotTypeOption[] = [
{ label: '全部', value: '全部' },
{ label: '水库', value: '水库' },
{ label: '河流', value: '河流' },
{ label: '湖泊', value: '湖泊' },
{ label: '海钓', value: '海钓' },
{ label: '池塘', value: '池塘' }
]
const GEAR_CATEGORY_OPTIONS: GearCategoryOption[] = [
{ label: '全部', value: '全部' },
{ label: '鱼竿', value: '鱼竿' },
{ label: '鱼线', value: '鱼线' },
{ label: '鱼钩', value: '鱼钩' },
{ label: '饵料', value: '饵料' },
{ label: '配件', value: '配件' },
{ label: '工具', value: '工具' }
]
这三个常量数组分别定义了鱼获、钓点、装备三个模块的筛选项配置。每个数组都以"全部"作为首项,这是筛选交互的通用模式,允许用户一键查看所有数据。鱼种筛选项覆盖了国内常见的六种淡水鱼,钓点类型则涵盖了水库、河流、湖泊、海钓、池塘五种主要水域类型,装备分类则按钓具的功能属性分为六大类。将这些配置抽取为常量数组,使得筛选栏可以通过 ForEach 动态渲染,新增筛选项时只需在数组中添加一项即可,无需改动视图代码,体现了数据驱动 UI 的设计思想。
3.3 图标映射常量
const WEATHER_ICONS: WeatherIconMap = {
'晴': '☀️',
'阴': '☁️',
'雨': '🌧️',
'雾': '🌫️'
}
const BAIT_ICONS: BaitIconMap = {
'蚯蚓': '🪱',
'玉米': '🌽',
'商品饵': '🧴',
'路亚': '🦎'
}
const METHOD_ICONS: MethodIconMap = {
'手竿': '🎣',
'海竿': '⚓',
'路亚': '🦎',
'矶钓': '🪨'
}
const FACILITY_ICONS: FacilityIconMap = {
'停车': '🅿️',
'餐饮': '🍽️',
'住宿': '🏨',
'厕所': '🚻'
}
这一组图标映射常量是应用视觉表现的一大亮点。通过将业务字段值与 Emoji 图标一一对应,应用在不引入任何图片资源的情况下,就实现了丰富的图标展示效果。天气图标覆盖了晴阴雨雾四种常见天气,饵料图标为蚯蚓、玉米、商品饵、路亚分别匹配了形象的 Emoji,钓法图标则区分手竿、海竿、路亚、矶钓四种主流钓法,设施图标则映射了停车、餐饮、住宿、厕所四类配套。在视图层使用时,通过 WEATHER_ICONS[item.weather] || '☀️' 这种带兜底的查表方式,既保证了已知值的精确匹配,也避免了未知值导致的渲染异常,是一种健壮性很好的写法。
3.4 鱼种颜色调色板
const FISH_COLORS: string[] = ['#0277BD', '#2E7D32', '#FF6F00', '#1565C0', '#6A1B9A', '#00838F', '#C62828']
FISH_COLORS 是一个包含七种颜色的调色板数组,用于在鱼种占比统计图中为不同鱼种分配差异化的颜色。这七种颜色都选自 Material Design 色板,饱和度适中、辨识度高,且与主色调和谐统一。在统计函数中通过 FISH_COLORS[idx % FISH_COLORS.length] 取色,既保证了颜色循环利用,又确保了相邻鱼种颜色差异明显,让占比图表一目了然。这种调色板设计是数据可视化的基础实践,避免了随机取色可能带来的视觉混乱。
四、Mock 数据设计
4.1 鱼获 Mock 数据
const MOCK_CATCHES: CatchItem[] = [
new CatchItem(1, '鲈鱼', 3.5, 42, '千岛湖', '2026-01-15', '06:30', '晴', 22, '路亚', '路亚', '', '大口鲈,非常活跃', true, '放生'),
new CatchItem(2, '鲫鱼', 1.2, 28, '西溪湿地', '2026-01-22', '08:00', '阴', 18, '蚯蚓', '手竿', '', '冬季鲫鱼偏小', false, '带走'),
new CatchItem(3, '鲤鱼', 5.8, 55, '太湖', '2026-02-05', '07:00', '晴', 20, '玉米', '海竿', '', '大鲤鱼!创个人记录', true, '带走'),
// ... 共18条数据
]
MOCK_CATCHES 数组是鱼获模块的数据源,包含 18 条精心设计的鱼获记录,时间跨度从 2026 年 1 月到 7 月,覆盖了春夏两季的作钓场景。每条数据都通过 new CatchItem(...) 构造,字段值真实可信:鱼种涵盖了筛选项中的全部六种;重量从 0.8 斤的小鲫鱼到 6.2 斤的大鲤鱼,分布合理;钓点对应了后面的钓点数据;天气、饵料、钓法也都符合实际作钓场景。特别值得注意的是 isPersonalBest 字段,有 5 条数据被标记为个人最佳,配合 notes 字段中的"创个人记录"“个人最大草鱼"等描述,让数据具有故事性。releaseStatus 字段则交替出现"放生"和"带走”,体现了路亚钓友的环保理念。这种高质量的 mock 数据让应用在演示时就具有真实的使用感,也为后续接入真实数据提供了清晰的字段映射参考。
4.2 钓点 Mock 数据
const MOCK_SPOTS: SpotItem[] = [
new SpotItem(1, '千岛湖', '水库', '淳安县千岛湖镇', 120, 4.8, '春秋', '清晨傍晚', ['鲈鱼', '鳜鱼', '鲫鱼'], ['停车', '餐饮', '住宿', '厕所'], '2026-07-30', 28, '水质好,鱼种丰富', '#0277BD'),
new SpotItem(2, '西溪湿地', '河流', '杭州市西湖区', 8, 4.2, '四季', '全天', ['鲫鱼', '鲤鱼', '草鱼'], ['停车', '厕所'], '2026-06-25', 45, '城市近郊,方便', '#2E7D32'),
// ... 共12条数据
]
MOCK_SPOTS 数组包含 12 个钓点,覆盖了国内多个知名水域,从近郊的西溪湿地(8km)到远途的滇池(1200km),距离跨度很大,能充分展示钓点卡片的距离信息。每个钓点的 type 字段覆盖了水库、河流、湖泊、海钓、池塘全部五种类型;rating 从 3.5 到 4.8 分布合理;fishTypes 和 facilities 数组内容丰富,能在卡片中渲染出饱满的标签云;imageColor 字段为每个钓点分配了不同的主题色,让卡片头部渐变呈现出多样化视觉效果。visitCount 字段从 3 到 45 不等,配合 lastVisit 时间,生动地反映了钓友对不同钓点的偏好频率。这批数据真实地模拟了一个资深钓友的钓点库,为应用的展示提供了扎实的数据基础。
4.3 装备 Mock 数据
const MOCK_GEAR: GearItem[] = [
new GearItem(1, '碳素海竿 3.6m', '鱼竿', '光威', 'GW-H360', 280, '2025-03-15', '使用中', 35, '手感轻,腰力足', '#0277BD'),
new GearItem(2, '溪流手竿 4.5m', '鱼竿', '迪佳', 'DJ-X450', 180, '2025-06-01', '使用中', 28, '细线轻漂适合', '#2E7D32'),
// ... 共20条数据
]
MOCK_GEAR 数组包含 20 件装备,覆盖了鱼竿、鱼线、鱼钩、饵料、配件、工具全部六大类别。装备品牌涵盖了光威、迪佳、阿布、达亿瓦、YGK、东丽、西格、欧娜、化氏、佳钓尼、连球、迪卡侬等国内外知名钓具品牌,型号命名也符合实际产品命名规则,体现了数据的专业性。price 字段从 0 元的自制蚯蚓到 680 元的矶钓竿,价格区间合理;status 字段交替出现"全新"“使用中”“需更换"三种状态,其中"需更换"的尼龙线和折叠椅,配合 notes 中的"老化需换”"坐久了不舒服"等描述,生动地模拟了真实的装备维护场景。usageCount 字段则反映了装备的使用频率,从 0 次的全新装备到 100 次的万能蚯蚓,为后续的装备性价比分析提供了数据支撑。
五、统计函数设计
5.1 基础统计函数
function getTotalCatches(): number {
return MOCK_CATCHES.length
}
function getTotalWeight(): number {
let total: number = 0
for (let i = 0; i < MOCK_CATCHES.length; i++) {
total += MOCK_CATCHES[i].weight
}
return Math.round(total * 10) / 10
}
function getMaxFish(): string {
let maxWeight: number = 0
let maxName: string = ''
for (let i = 0; i < MOCK_CATCHES.length; i++) {
if (MOCK_CATCHES[i].weight > maxWeight) {
maxWeight = MOCK_CATCHES[i].weight
maxName = MOCK_CATCHES[i].fishType + ' ' + maxWeight + '斤'
}
}
return maxName
}
function getSpotCount(): number {
return MOCK_SPOTS.length
}
这一组基础统计函数提供了应用统计模块的核心指标。getTotalCatches 返回鱼获总尾数,逻辑极简但语义明确。getTotalWeight 通过循环累加所有鱼获的重量,并使用 Math.round(total * 10) / 10 的技巧保留一位小数,避免了浮点数累加可能产生的精度问题。getMaxFish 通过一次遍历找到重量最大的鱼获,并将鱼种和重量拼接成字符串返回,这种"业务字符串"直接返回的设计省去了视图层的格式化逻辑。getSpotCount 则简单地返回钓点总数。这些函数都是无副作用的纯函数,接收无参输入、返回确定输出,非常易于测试和复用,体现了函数式编程的思想。
5.2 月度与占比统计函数
function getMonthlyStats(): MonthlyStat[] {
return [
{ month: '1月', count: 2 },
{ month: '2月', count: 2 },
{ month: '3月', count: 2 },
{ month: '4月', count: 2 },
{ month: '5月', count: 2 },
{ month: '6月', count: 2 },
{ month: '7月', count: 4 }
]
}
function getFishRatios(): FishRatioItem[] {
let fishCounts: Record<string, number> = {}
for (let i = 0; i < MOCK_CATCHES.length; i++) {
let ft: string = MOCK_CATCHES[i].fishType
if (fishCounts[ft] === undefined) {
fishCounts[ft] = 0
}
fishCounts[ft] += 1
}
let total: number = MOCK_CATCHES.length
let ratios: FishRatioItem[] = []
let idx: number = 0
let keys: string[] = Object.keys(fishCounts)
for (let i = 0; i < keys.length; i++) {
ratios.push({
fishType: keys[i],
count: fishCounts[keys[i]],
ratio: Math.round(fishCounts[keys[i]] / total * 100),
color: FISH_COLORS[idx % FISH_COLORS.length]
})
idx++
}
return ratios
}
getMonthlyStats 返回月度鱼获数量的统计数据,目前是硬编码的数组,未来可以改为动态从鱼获数据中按月份聚合计算。getFishRatios 则是一个更复杂的统计函数,它首先使用 Record<string, number> 作为计数器,遍历所有鱼获按鱼种累加计数;然后通过 Object.keys 获取所有鱼种名,遍历计算每个鱼种的占比百分比,并从 FISH_COLORS 调色板中循环取色。这个函数展示了 ArkTS 中使用 Record 类型做分组统计的典型模式,Math.round(count / total * 100) 的计算保证了百分比是整数,便于在进度条中直接用作宽度值。整个函数逻辑清晰、无副作用,是数据聚合的优秀实践。
5.3 个人记录函数
function getPersonalRecords(): PersonalRecord[] {
let records: PersonalRecord[] = []
for (let i = 0; i < MOCK_CATCHES.length; i++) {
if (MOCK_CATCHES[i].isPersonalBest) {
records.push({
fishType: MOCK_CATCHES[i].fishType,
weight: MOCK_CATCHES[i].weight + '斤',
date: MOCK_CATCHES[i].date,
location: MOCK_CATCHES[i].location
})
}
}
return records
}
getPersonalRecords 函数遍历所有鱼获,筛选出 isPersonalBest 为 true 的记录,组装成 PersonalRecord 数组返回。这里值得学习的是数据转换的处理方式:将 CatchItem 中的 number 类型 weight 字段拼接上"斤"单位后赋值给 PersonalRecord 的 string 类型 weight 字段,完成了从原始数据到展示数据的格式转换。这种在数据层完成格式化的做法,让视图层可以专注于渲染而无需关心数据格式,提高了代码的内聚性。该函数也体现了"过滤+映射"的经典数据处理模式,是函数式编程中 filter 和 map 操作的命令式实现。
六、入口页面与状态管理
6.1 Tab 枚举定义
enum FishingTab {
Catch = 0,
Spot = 1,
Gear = 2,
Stat = 3,
Mine = 4
}
FishingTab 枚举定义了底部 Tab 的五个选项,分别对应鱼获、钓点、装备、统计、我的五个页面。使用枚举而非魔法数字来标识 Tab,是典型的工程化最佳实践,它让代码 this.currentTab === FishingTab.Catch 相比 this.currentTab === 0 具有更好的可读性和可维护性。枚举值从 0 开始递增,与底部 ForEach 渲染时的索引自然对应,实现了枚举与位置的巧妙映射。这种设计在后续扩展 Tab 时也非常方便,只需在枚举中新增一项并对应增加底部文案即可。
6.2 入口组件状态定义
@Entry
@Component
struct FishingApp {
@State currentTab: number = FishingTab.Catch
@State showAddCatchModal: boolean = false
@State showEditSpotModal: boolean = false
@State showDeleteGearModal: boolean = false
@State editSpotId: number = 0
@State deleteGearId: number = 0
@State waterWaveOffset: number = 0
aboutToAppear(): void {
this.animateWaterWave()
}
}
FishingApp 是应用的入口组件,使用 @Entry 和 @Component 装饰器修饰。组件内部定义了 7 个 @State 状态变量:currentTab 跟踪当前选中的 Tab;三个 show*Modal 布尔变量分别控制三个弹窗的显示;两个 *Id 变量记录当前编辑或删除的对象 ID;waterWaveOffset 则是水波动画的位移量。@State 装饰器保证了这些变量变化时能够触发组件的重新渲染,是 ArkUI 响应式更新的基础。aboutToAppear 生命周期函数在组件即将出现时被调用,这里用于启动水波动画,保证了页面一加载就能看到动态效果。状态管理的设计体现了"单一数据源"的思想,所有跨模块共享的状态(如弹窗显隐、当前操作对象)都集中在入口组件中,通过 @Prop 向下传递给子组件,形成清晰的数据流向。
6.3 水波动画实现
animateWaterWave(): void {
animateTo({ duration: 2000, iterations: -1, curve: Curve.EaseInOut }, () => {
this.waterWaveOffset = 10
})
setTimeout(() => {
animateTo({ duration: 2000, iterations: -1, curve: Curve.EaseInOut }, () => {
this.waterWaveOffset = 0
})
}, 2000)
}
animateWaterWave 方法实现了顶部水波流动的动画效果,是应用视觉体验的一大亮点。它使用 ArkUI 的 animateTo API,配置了 2000 毫秒的持续时间、无限循环(iterations: -1)和 EaseInOut 缓动曲线,先将 waterWaveOffset 从 0 动画到 10,再通过 setTimeout 延迟 2 秒后将其从 10 动画回 0,形成往复的水波摆动效果。EaseInOut 缓动曲线让动画在开始和结束时速度较慢、中间速度较快,模拟了水波的自然运动节奏。这种"双段动画 + 延时"的组合方式,相比单段动画能够产生更丰富的视觉层次,让水波看起来更像是真实的水面波动而非简单的位移。
6.4 build 函数主结构
build() {
Column() {
Stack() {
Column()
.width('100%')
.height(8)
.linearGradient({
direction: GradientDirection.Right,
colors: [[WATER_GRADIENT_TOP, 0.0], [WATER_GRADIENT_BOTTOM, 1.0]]
})
Column()
.width('100%')
.height(8)
.linearGradient({
direction: GradientDirection.Right,
colors: [['#4FC3F7', 0.0], ['#0288D1', 0.5], ['#0277BD', 1.0]]
})
.translate({ x: this.waterWaveOffset })
}
.width('100%')
.height(8)
.clip(true)
Column() {
if (this.currentTab === FishingTab.Catch) {
CatchTabPage({ showAddModal: this.showAddCatchModal })
} else if (this.currentTab === FishingTab.Spot) {
SpotTabPage({ showEditModal: this.showEditSpotModal, editSpotId: this.editSpotId })
} else if (this.currentTab === FishingTab.Gear) {
GearTabPage({ showDeleteModal: this.showDeleteGearModal, deleteGearId: this.deleteGearId })
} else if (this.currentTab === FishingTab.Stat) {
StatTabPage()
} else if (this.currentTab === FishingTab.Mine) {
MineTabPage()
}
}
.layoutWeight(1)
// ...底部Tab栏
}
.width('100%')
.height('100%')
.backgroundColor(BG_COLOR)
}
build 函数是整个应用的骨架,采用 Column 纵向布局,自上而下分为水波动画区、页面内容区和底部 Tab 栏三部分。水波动画区使用 Stack 堆叠两层渐变色带:底层是固定的深海蓝渐变,上层是带浅蓝中间色的三段渐变并通过 translate({ x: this.waterWaveOffset }) 实现水平位移,配合 .clip(true) 裁剪超出部分,营造出两层水波交叠流动的效果。页面内容区通过 if-else if 条件判断根据 currentTab 渲染对应的子页面组件,并向需要弹窗的子组件传递 @Prop 参数。这种条件渲染的方式相比 Tabs 容器更轻量,但代价是页面切换时不会保留状态,每次切换都会重建子组件。.layoutWeight(1) 让内容区占据剩余空间,保证了底部 Tab 栏始终固定在底部。
6.5 底部 Tab 栏实现
Row() {
ForEach(['🐟 鱼获', '📍 钓点', '🎣 装备', '📊 统计', '👤 我的'], (item: string, idx: number) => {
Column() {
Text(item)
.fontSize(12)
.fontColor(this.currentTab === idx ? PRIMARY_COLOR : TEXT_SECONDARY)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
}
.width('20%')
.height(50)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentTab = idx as number
})
}, (item: string, idx: number) => idx.toString())
}
.width('100%')
.height(50)
.backgroundColor(CARD_BG)
.shadow({ radius: 2, color: 'rgba(0,0,0,0.1)', offsetY: -1 })
底部 Tab 栏使用 Row 横向布局,通过 ForEach 遍历五个带 Emoji 的文案数组,每个 Tab 占据 20% 宽度。选中态通过三元运算符动态切换 fontColor(主色蓝 vs 次要灰)和 fontWeight(加粗 vs 常规),实现了简洁明了的视觉反馈。onClick 回调中将索引 idx 赋值给 currentTab,由于 ForEach 的索引是 number 类型,这里通过 idx as number 做了类型断言以保证类型安全。ForEach 的第三个参数是键值生成函数,这里用 idx.toString() 作为 key,保证了列表项的稳定标识。底部还添加了 shadow 阴影效果,offsetY: -1 让阴影向上投射,营造出 Tab 栏浮在内容之上的层次感。
6.6 modalOverlay 遮罩 Builder
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
modalOverlay 是一个可复用的 @Builder 方法,用于生成弹窗的半透明遮罩层。它接收一个 onClose 回调函数作为参数,渲染一个铺满全屏、背景为半透明黑色的 Column,并绑定 onClick 事件触发关闭回调。这种将遮罩抽取为独立 Builder 的设计非常优雅:一方面避免了在每个弹窗中重复编写遮罩代码,另一方面通过参数化回调让遮罩的关闭行为可以灵活定制。rgba(0,0,0,0.5) 的半透明黑色是移动端弹窗遮罩的通用选择,既能让用户感知到下层内容的存在,又能将视觉焦点集中到弹窗本身。这个 Builder 在后续的三个弹窗中都被复用,体现了良好的代码复用意识。
七、鱼获 Tab 页面
7.1 鱼获页面状态与筛选逻辑
@Component
struct CatchTabPage {
@State filterValue: string = '全部'
@State showAddModal: boolean = false
@Prop showAddModalProp: boolean = false
getFilteredCatches(): CatchItem[] {
if (this.filterValue === '全部') {
return MOCK_CATCHES
}
let result: CatchItem[] = []
for (let i = 0; i < MOCK_CATCHES.length; i++) {
if (MOCK_CATCHES[i].fishType === this.filterValue) {
result.push(MOCK_CATCHES[i])
}
}
return result
}
}
CatchTabPage 是鱼获模块的页面组件,内部维护了 filterValue 筛选值和 showAddModal 弹窗显隐两个 @State 状态,同时通过 @Prop 接收父组件传递的 showAddModalProp。@Prop 装饰器表明该变量是父组件状态的只读副本,父组件变化时会同步更新到子组件,但子组件不能反向修改。getFilteredCatches 方法实现了鱼获的筛选逻辑:当筛选值为"全部"时直接返回全部数据,否则遍历 MOCK_CATCHES 收集 fishType 匹配的记录。这种在组件内部提供计算方法的设计,让视图层可以通过 this.getFilteredCatches() 直接获取筛选后的数据,实现了数据与视图的清晰分离。值得注意的是,这里使用了命令式的 for 循环而非函数式的 filter,虽然代码略长,但性能上更优,且符合 ArkTS 的类型推断习惯。
7.2 鱼获页面 build 结构
build() {
Column() {
// 顶部标题
Row() {
Text('🐟 鱼获记录')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Column().layoutWeight(1)
Text('+ 新增')
.fontSize(14)
.fontColor(PRIMARY_COLOR)
.fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor('#E1F5FE')
.onClick(() => {
this.showAddModal = true
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
// 筛选栏
Scroll() {
Row() {
ForEach(CATCH_FILTERS, (item: CatchFilterOption) => {
Text(item.label)
.fontSize(13)
.fontColor(this.filterValue === item.value ? PRIMARY_COLOR : TEXT_SECONDARY)
.fontWeight(this.filterValue === item.value ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.filterValue === item.value ? '#E1F5FE' : '#F5F5F5')
.onClick(() => {
this.filterValue = item.value
})
}, (item: CatchFilterOption) => item.value)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(36)
// ...鱼获列表
}
}
CatchTabPage 的 build 函数分为三个主要部分:顶部标题栏、筛选栏和鱼获列表。顶部标题栏使用 Row 横向布局,左侧是大标题"🐟 鱼获记录",中间用 Column().layoutWeight(1) 占据剩余空间实现右侧按钮的右对齐,右侧是"+ 新增"按钮,点击后设置 showAddModal = true 弹出新增弹窗。筛选栏使用横向 Scroll 包裹 Row,通过 ForEach 渲染 CATCH_FILTERS 中的筛选项,选中态通过背景色(浅蓝 vs 浅灰)、字色(主色 vs 次要)、字重(加粗 vs 常规)三重视觉差异来强调。scrollable(ScrollDirection.Horizontal) 让筛选栏可以横向滚动,scrollBar(BarState.Off) 隐藏了滚动条,保证了视觉的简洁。这种"水平滚动筛选条"是移动端列表页的经典交互模式,能够在有限的屏幕空间内容纳更多筛选项。
7.3 鱼获列表渲染
Scroll() {
Column() {
ForEach(this.getFilteredCatches(), (item: CatchItem) => {
this.catchCard(item)
}, (item: CatchItem) => item.id.toString())
}
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.scrollBar(BarState.Auto)
if (this.showAddModal) { this.addCatchModal() }
鱼获列表使用纵向 Scroll 包裹 Column,通过 ForEach 遍历 getFilteredCatches() 返回的筛选结果,为每条鱼获调用 this.catchCard(item) 渲染卡片。ForEach 的键值生成函数使用 item.id.toString(),保证了卡片的稳定标识,当筛选条件变化时,ArkUI 能够高效地 diff 出增删的卡片,避免不必要的全量重渲染。.layoutWeight(1) 让列表占据标题栏和筛选栏之外的剩余空间,.scrollBar(BarState.Auto) 让滚动条按需显示。最后一行 if (this.showAddModal) { this.addCatchModal() } 是条件渲染弹窗的逻辑,当 showAddModal 为 true 时渲染新增弹窗,否则不渲染,这种"按需挂载"的方式比"常驻隐藏"更节省资源。
7.4 catchCard 鱼获卡片 Builder
@Builder catchCard(item: CatchItem) {
Column() {
// 水面渐变头部
Stack() {
Column()
.width('100%')
.height(40)
.linearGradient({
direction: GradientDirection.Right,
colors: [[item.isPersonalBest ? '#FF6F00' : WATER_GRADIENT_TOP, 0.0],
[item.isPersonalBest ? '#FF8F00' : '#01579B', 1.0]]
})
Row() {
Text(WEATHER_ICONS[item.weather] || '☀️')
.fontSize(18)
Text(item.weather + ' ' + item.temperature + '°C')
.fontSize(12)
.fontColor('#FFFFFF')
.margin({ left: 4 })
Column().layoutWeight(1)
if (item.isPersonalBest) {
Text('⭐ 个人最佳')
.fontSize(12)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
}
Text(item.releaseStatus === '放生' ? '🌊 放生' : '📦 带走')
.fontSize(12)
.fontColor('#FFFFFF')
}
.width('100%')
.height(40)
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
}
.width('100%')
.height(40)
// ...内容区
}
.width('100%')
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 4, color: 'rgba(2,119,189,0.15)', offsetY: 2 })
.margin({ bottom: 12 })
}
catchCard 是鱼获卡片的渲染逻辑,是整个应用视觉设计的精华所在。卡片采用 Column 纵向布局,分为头部渐变区和内容区两部分。头部使用 Stack 堆叠一个渐变背景层和一个内容层:渐变背景通过 linearGradient 实现从左到右的色阶过渡,颜色选择上做了一个巧妙的设计——普通鱼获使用深海蓝渐变(WATER_GRADIENT_TOP 到 #01579B),而个人最佳鱼获则切换为橙色渐变(#FF6F00 到 #FF8F00),通过颜色瞬间突出重要记录。内容层是一个 Row,左侧依次显示天气图标(通过 WEATHER_ICONS 查表获取)、天气文字和温度,中间用 Column().layoutWeight(1) 占位,右侧根据 isPersonalBest 条件显示"⭐ 个人最佳"标签,最右侧显示放生或带走状态。整个卡片还添加了 shadow 阴影效果,color: 'rgba(2,119,189,0.15)' 使用了主色的半透明版本,让阴影带有蓝色调,与整体配色和谐统一。
7.5 catchCard 内容区
Row() {
Column() {
Row() {
Text(item.fishType)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
if (item.isPersonalBest) {
Text('⭐')
.fontSize(14)
.margin({ left: 4 })
}
}
Text(item.weight + '斤 · ' + item.length + 'cm')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(item.isPersonalBest ? '#FF6F00' : PRIMARY_COLOR)
.margin({ top: 4 })
Row() {
Text(METHOD_ICONS[item.method] || '🎣')
.fontSize(14)
Text(item.method)
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ left: 2 })
Text(' | ')
.fontSize(12)
.fontColor(BORDER_COLOR)
Text(BAIT_ICONS[item.bait] || '🧴')
.fontSize(14)
Text(item.bait)
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ left: 2 })
}
.margin({ top: 6 })
Text('📍 ' + item.location)
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text(item.date)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
Text(item.time)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
if (item.notes.length > 0) {
Text(item.notes.substring(0, 8) + (item.notes.length > 8 ? '...' : ''))
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ top: 6 })
.maxLines(1)
}
}
.alignItems(HorizontalAlign.End)
.width(80)
}
.padding(12)
.alignItems(VerticalAlign.Top)
内容区使用 Row 横向布局,分为左侧主信息区和右侧辅助信息区。左侧 Column 通过 layoutWeight(1) 占据主要空间,依次展示鱼种名称(个人最佳时附带⭐)、重量与长度的组合大字(22号字加粗,个人最佳用橙色、普通用主色蓝)、钓法与饵料的图标文字组合(中间用"|“分隔,图标通过 METHOD_ICONS 和 BAIT_ICONS 查表获取)、以及带定位图标的钓点名称。右侧 Column 宽度固定 80 像素,右对齐显示日期、时间和备注。备注的处理特别值得注意:通过 item.notes.substring(0, 8) 截取前 8 个字符,并根据长度判断是否追加”…",配合 .maxLines(1) 限制单行,实现了备注的优雅截断显示。这种"数据驱动样式"的设计贯穿整个卡片——isPersonalBest 字段同时影响了头部渐变颜色、星标显示、重量字色三处视觉表现,让个人最佳记录在卡片中全方位地凸显出来。
7.6 addCatchModal 新增弹窗
@Builder addCatchModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('新增鱼获').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text('x').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
Column() {
Text('鱼种').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
ForEach(['鲈鱼', '鲫鱼', '鲤鱼', '草鱼', '黑鱼', '鳜鱼'], (fishType: string) => {
Text(fishType)
.fontSize(13)
.fontColor('#546E7A')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#F5F5F5')
.margin({ right: 6 })
}, (fishType: string) => fishType)
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
// ...重量、钓点、天气、饵料、处理方式等字段
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('保存').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#0277BD').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.position({ x: '5%', y: '15%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
addCatchModal 是新增鱼获的弹窗 Builder,结构上分为遮罩层和弹窗主体两部分。遮罩层复用了前面定义的 modalOverlay,传入关闭回调。弹窗主体是一个宽度 90%、白色背景、圆角 16 的 Column,通过 position({ x: '5%', y: '15%' }) 定位在屏幕上方 15% 处,模拟了原生弹窗的居中偏上效果。弹窗内部包含标题栏(标题 + 关闭按钮)、分隔线、表单字段区和底部操作按钮区。表单字段区采用了"标签 + 输入控件"的统一模式:鱼种、天气、饵料等枚举字段使用 ForEach 渲染可选标签云;重量、钓点等文本字段使用带边框的占位文本框;处理方式则用绿色"放生"和橙色"带走"两个高亮标签。底部按钮区使用 FlexAlign.Center 居中,"取消"按钮使用浅灰背景,"保存"按钮使用主色蓝背景,形成主次分明的操作引导。整个弹窗通过 zIndex(999) 提升层级,确保覆盖在页面内容之上。虽然当前实现中保存按钮只是关闭弹窗,但结构上已经为接入真实表单逻辑做好了准备。
八、钓点 Tab 页面
8.1 钓点页面筛选逻辑
@Component
struct SpotTabPage {
@State filterValue: string = '全部'
@Prop showEditModal: boolean = false
@Prop editSpotId: number = 0
getFilteredSpots(): SpotItem[] {
if (this.filterValue === '全部') {
return MOCK_SPOTS
}
let result: SpotItem[] = []
for (let i = 0; i < MOCK_SPOTS.length; i++) {
if (MOCK_SPOTS[i].type === this.filterValue) {
result.push(MOCK_SPOTS[i])
}
}
return result
}
}
SpotTabPage 钓点页面的结构与鱼获页面高度一致,体现了应用统一的模块化设计模式。它维护了 filterValue 筛选状态,并通过 @Prop 接收父组件传递的 showEditModal 和 editSpotId,用于控制编辑弹窗的显隐和当前编辑的钓点 ID。getFilteredSpots 方法的实现与 getFilteredCatches 几乎完全相同,只是筛选字段从 fishType 换成了 type,这种高度相似的代码结构提示了后续可以通过泛型或高阶函数进一步抽象优化的空间。@Prop 的使用让钓点页面的编辑弹窗可以由入口组件统一控制,实现了跨组件的状态同步。
8.2 钓点筛选栏的差异化设计
Scroll() {
Row() {
ForEach(SPOT_TYPE_OPTIONS, (item: SpotTypeOption) => {
Text(item.label)
.fontSize(13)
.fontColor(this.filterValue === item.value ? SECONDARY_COLOR : TEXT_SECONDARY)
.fontWeight(this.filterValue === item.value ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.filterValue === item.value ? '#C8E6C9' : '#F5F5F5')
.onClick(() => {
this.filterValue = item.value
})
}, (item: SpotTypeOption) => item.value)
}
.padding({ left: 16, right: 16 })
}
钓点筛选栏与鱼获筛选栏在结构上完全一致,但在配色上做了巧妙的差异化设计。鱼获筛选栏的选中态使用主色蓝(PRIMARY_COLOR)配浅蓝背景(#E1F5FE),而钓点筛选栏的选中态则切换为辅助色海草绿(SECONDARY_COLOR)配浅绿背景(#C8E6C9)。这种"模块色彩标识"的设计让用户在不同 Tab 间切换时,能够通过筛选栏的颜色快速感知当前所处的模块,增强了空间定位感。同时,海草绿的选用也呼应了"钓点 = 自然水域"的语义,让配色具有了符号意义。这种细节处的配色差异化,体现了设计师对用户体验的细致考量。
8.3 spotCard 钓点卡片头部
@Builder spotCard(item: SpotItem) {
Column() {
Stack() {
Column()
.width('100%')
.height(60)
.linearGradient({
direction: GradientDirection.Right,
colors: [[item.imageColor, 0.0], ['#01579B', 1.0]]
})
Column() {
Text(item.name)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(item.type + ' · ' + item.address)
.fontSize(11)
.fontColor('#B0BEC5')
.margin({ top: 4 })
}
.padding({ left: 12, top: 10 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height(60)
// ...内容区
}
.width('100%')
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 4, color: 'rgba(46,125,50,0.15)', offsetY: 2 })
.margin({ bottom: 12 })
}
spotCard 钓点卡片的头部设计与鱼获卡片类似但有所创新。同样使用 Stack 堆叠渐变背景和文字内容,但头部高度从 40 增加到 60,容纳了更大的标题(20号字)。最关键的区别在于渐变色:钓点卡片的起始色使用 item.imageColor,即每个钓点都有自己的主题色(千岛湖蓝、西溪湿地绿、太湖深蓝、洞庭湖紫等),终点色统一为深蓝 #01579B。这种"一钓点一色"的设计让钓点列表呈现出彩虹般的视觉多样性,每张卡片都有独特的色彩标识,用户能够通过颜色快速定位到熟悉的钓点。阴影颜色也做了对应调整,使用 rgba(46,125,50,0.15) 即海草绿的半透明版本,与钓点模块的绿色主题呼应。文字内容上,钓点名称用大号白色加粗字,下方用浅灰色小字显示类型和地址,信息层级清晰。
8.4 spotCard 内容区
Row() {
Column() {
Row() {
Text('⭐ ')
.fontSize(12)
Text(item.rating.toFixed(1))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(SECONDARY_COLOR)
}
Text('距你 ' + item.distance + 'km')
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
Row() {
Text('最佳: ' + item.bestSeason)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
Text(' | ' + item.bestTime)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
}
.margin({ top: 4 })
Row() {
ForEach(item.fishTypes, (fish: string) => {
Text(fish)
.fontSize(10)
.fontColor(PRIMARY_COLOR)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor('#E1F5FE')
.margin({ right: 4 })
}, (fish: string) => fish)
}
.margin({ top: 6 })
Row() {
ForEach(item.facilities, (fac: string) => {
Text(FACILITY_ICONS[fac] || fac)
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ right: 4 })
}, (fac: string) => fac)
}
.margin({ top: 6 })
Text('已访 ' + item.visitCount + '次 · 最近 ' + item.lastVisit)
.fontSize(10)
.fontColor(BORDER_COLOR)
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text('编辑')
.fontSize(12)
.fontColor(PRIMARY_COLOR)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#E1F5FE')
}
.width(50)
.alignItems(HorizontalAlign.End)
}
.padding(12)
.alignItems(VerticalAlign.Top)
钓点卡片的内容区信息密度非常高,左侧 Column 通过 layoutWeight(1) 占据主要空间,自上而下依次展示:评分(⭐ 图标 + toFixed(1) 保留一位小数的评分,海草绿色)、距离(“距你 Xkm”)、最佳作钓时段(季节 + 时间,用"|“分隔)、可钓鱼种标签云(通过 ForEach 渲染,浅蓝背景的小标签)、设施图标(通过 FACILITY_ICONS 查表获取 Emoji)、访问历史(次数 + 最近访问时间)。这里有两处值得学习的细节:一是 item.rating.toFixed(1) 确保评分始终显示一位小数,避免整数评分显示为"4"而非"4.0”;二是鱼种标签云和设施图标都使用 ForEach 动态渲染,能够自适应数组长度,无论数据多少都能正确展示。右侧固定宽度 50 像素的"编辑"按钮,用主色蓝配浅蓝背景,是卡片的操作入口。整个内容区通过字号差异(10/11/12/14)和颜色差异(主色蓝/次要灰/边框灰/海草绿)构建了清晰的信息层级。
8.5 editSpotModal 编辑弹窗
@Builder editSpotModal() {
Column() {
this.modalOverlay(() => { this.showEditModal = false })
Column() {
Row() {
Text('编辑钓点').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text('x').fontSize(18).fontColor('#999999')
.onClick(() => { this.showEditModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
// 钓点名称、类型、地址、评分、最佳季节、备注等字段
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showEditModal = false })
Text('保存').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#0277BD').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showEditModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.position({ x: '5%', y: '15%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
editSpotModal 编辑钓点弹窗在结构上与新增鱼获弹窗保持一致,都采用了"遮罩 + 居中卡片 + 标题栏 + 表单 + 操作按钮"的标准布局。不同之处在于表单字段:编辑钓点弹窗包含钓点名称(文本输入)、类型(水库/河流/湖泊/海钓/池塘标签云)、地址(文本输入)、评分(1-5 星选择)、最佳季节(春/夏/秋/冬/四季标签云)和备注(文本输入)六个字段。评分字段使用了"1 ☆""2 ☆"这样的文字+符号组合来表示星级选择,是一种简洁的星级评分交互方案。虽然当前实现中各字段都是占位文本,并未真正绑定数据,但整体结构已经为完整的编辑功能做好了准备。弹窗的复用模式(遮罩、标题栏、操作按钮)与鱼获弹窗完全一致,体现了应用在交互层面的统一性。
九、装备 Tab 页面
9.1 装备页面筛选逻辑
@Component
struct GearTabPage {
@State filterValue: string = '全部'
@Prop showDeleteModal: boolean = false
@Prop deleteGearId: number = 0
getFilteredGear(): GearItem[] {
if (this.filterValue === '全部') {
return MOCK_GEAR
}
let result: GearItem[] = []
for (let i = 0; i < MOCK_GEAR.length; i++) {
if (MOCK_GEAR[i].category === this.filterValue) {
result.push(MOCK_GEAR[i])
}
}
return result
}
}
GearTabPage 装备页面的结构与前两个 Tab 页面保持一致,维护 filterValue 筛选状态,并通过 @Prop 接收 showDeleteModal 和 deleteGearId 控制删除弹窗。getFilteredGear 方法按 category 字段筛选装备,逻辑与前两个页面的筛选方法同构。这种三个模块高度同构的设计,既是优点也是改进点:优点是降低了维护成本和学习曲线,改进点是存在代码重复,可以通过泛型组件或高阶函数进一步抽象。装备页面的筛选项使用主色蓝(PRIMARY_COLOR)作为选中态颜色,与鱼获页面一致,这是因为装备与鱼获都属于"实物管理"类模块,而钓点作为"地点管理"类模块使用了差异化的海草绿。
9.2 gearCard 装备卡片
@Builder gearCard(item: GearItem) {
Row() {
Column() {
Text(item.category)
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(item.color)
}
.margin({ right: 12 })
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Text(item.brand + ' · ' + item.model)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
Row() {
Text(item.status)
.fontSize(11)
.fontColor(item.status === '全新' ? SECONDARY_COLOR : item.status === '需更换' ? '#C62828' : TEXT_SECONDARY)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(item.status === '全新' ? '#C8E6C9' : item.status === '需更换' ? '#FFCDD2' : '#E0E0E0')
Text('使用 ' + item.usageCount + ' 次')
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text('¥' + item.price)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(PRIMARY_COLOR)
Text(item.purchaseDate)
.fontSize(10)
.fontColor(BORDER_COLOR)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
.width(70)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 3, color: 'rgba(2,119,189,0.1)', offsetY: 1 })
.margin({ bottom: 8 })
}
gearCard 装备卡片采用了与鱼获、钓点卡片不同的横向三栏布局,因为装备信息更适合横向展示。左侧是分类色块,显示 item.category(如"鱼竿"“鱼线”),背景色使用 item.color,白色文字配圆角小标签,让分类一目了然。中间是主信息区,通过 layoutWeight(1) 占据主要空间,依次显示装备名称(15号字加粗)、品牌型号组合(11号字次要色)、状态标签和使用次数。状态标签的设计尤其精妙:通过嵌套的三元运算符,根据 status 值动态切换字色和背景色——"全新"用海草绿配浅绿、"需更换"用红色配浅红、"使用中"用次要灰配浅灰,让装备状态通过颜色瞬间传达。右侧固定 70 像素宽度,右对齐显示价格(16号字主色蓝加粗,带¥符号)和采购日期。整个卡片阴影更轻(radius: 3),间距更小(margin bottom: 8),因为装备列表通常较长,紧凑的卡片能在一屏内展示更多内容。
9.3 deleteGearModal 删除确认弹窗
@Builder deleteGearModal() {
Column() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Column() {
Text('⚠')
.fontSize(36)
Text('确认删除')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#212121')
.margin({ top: 8 })
Text('删除后数据不可恢复,确定要删除该装备吗?')
.fontSize(13)
.fontColor('#999999')
.margin({ top: 6 })
}
.width('100%')
.padding({ top: 24, bottom: 12 })
Divider().color('#F0F0F0')
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showDeleteModal = false })
Text('确认删除').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#C62828').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showDeleteModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 12, bottom: 16 })
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(16)
.position({ x: '10%', y: '35%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
deleteGearModal 是装备删除的确认弹窗,与前两个弹窗的"表单"性质不同,这是一个"警示"性质的对话框。视觉上,它使用了居中的 ⚠ 警示图标(36号大字)、加粗的"确认删除"标题、灰色的风险提示文案,营造出严肃的警示氛围。弹窗宽度收窄到 80%(vs 表单弹窗的 90%),定位下移到 35%(vs 表单弹窗的 15%),让它更接近屏幕中央,符合警示对话框的视觉习惯。底部按钮区,“取消"使用浅灰背景,而"确认删除"使用红色背景(#C62828)——红色在中文语境中天然与"危险”"删除"关联,能够有效提醒用户谨慎操作。这种"颜色传达语义"的设计是移动端危险操作确认的通用模式,能够显著降低误操作概率。
十、统计 Tab 页面
10.1 统计页面结构与四格统计
@Component
struct StatTabPage {
@State selectedMonthIdx: number = 6
build() {
Column() {
Row() {
Text('📊 钓鱼统计')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Column() {
Row() {
this.statMiniCard('总尾数', getTotalCatches().toString(), '🐟', PRIMARY_COLOR)
this.statMiniCard('总重量', getTotalWeight() + '斤', '⚖️', SECONDARY_COLOR)
this.statMiniCard('最大鱼', getMaxFish(), '🏆', '#FF6F00')
this.statMiniCard('钓点数', getSpotCount().toString(), '📍', '#1565C0')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.padding({ left: 8, right: 8 })
// ...图表区
}
.padding(16)
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.scrollBar(BarState.Auto)
}
}
}
StatTabPage 统计页面是应用数据可视化的核心模块,维护了一个 selectedMonthIdx 状态用于跟踪柱状图中选中的月份。整个页面通过纵向 Scroll 包裹,内容自上而下依次为四格统计卡、月度柱状图、鱼种占比进度条和个人记录榜。四格统计卡使用 Row 横向布局,通过 justifyContent(FlexAlign.SpaceAround) 让四个卡片均匀分布,每个卡片调用 this.statMiniCard(...) 渲染,分别展示总尾数(🐟 主色蓝)、总重量(⚖️ 海草绿)、最大鱼(🏆 橙色)、钓点数(📍 深蓝)。四个卡片使用了四种不同的强调色,既保证了视觉多样性,又让每个指标具有独特的色彩标识。这里将统计函数 getTotalCatches() 等直接在视图层调用,体现了"视图即数据投影"的声明式编程思想。
10.2 statMiniCard 统计小卡片
@Builder statMiniCard(label: string, value: string, icon: string, color: string) {
Column() {
Text(icon)
.fontSize(22)
Text(value)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(color)
.margin({ top: 4 })
Text(label)
.fontSize(10)
.fontColor(TEXT_SECONDARY)
}
.width('23%')
.padding(8)
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 3, color: 'rgba(0,0,0,0.08)', offsetY: 1 })
}
statMiniCard 是一个高度参数化的 Builder,接收 label、value、icon、color 四个参数,渲染一个简洁的统计小卡片。卡片采用 Column 纵向布局,自上而下依次是图标(22号字)、数值(16号字加粗,使用传入的 color)、标签(10号字次要色)。宽度设为 23%(四个卡片加间距正好填满一行),圆角 12,白色背景配轻阴影。这种"参数化 Builder"的设计模式非常优雅,通过一个 Builder 定义就能生成四个外观一致但内容不同的卡片,极大地减少了重复代码。参数化的另一个好处是扩展性强——未来如果需要新增统计指标,只需在调用处增加一行 this.statMiniCard(...) 即可,无需修改 Builder 本身。
10.3 月度柱状图
Column() {
Text('月度鱼获数量')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
Row() {
ForEach(getMonthlyStats(), (stat: MonthlyStat, idx: number) => {
Column() {
Column()
.width(24)
.height(stat.count * 12)
.borderRadius({ topLeft: 4, topRight: 4 })
.backgroundColor(this.selectedMonthIdx === idx ? PRIMARY_COLOR : '#B0BEC5')
Text(stat.month)
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
}
.margin({ left: 4, right: 4 })
.onClick(() => {
this.selectedMonthIdx = idx as number
})
}, (stat: MonthlyStat, idx: number) => idx.toString())
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.height(120)
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
月度柱状图是统计页面的第一个图表组件,它使用纯 ArkUI 组件手工绘制柱状图,无需引入任何图表库。实现原理非常巧妙:通过 ForEach 遍历 getMonthlyStats() 返回的月度数据,每个月份渲染一个 Column,内部包含一个作为"柱子"的 Column(宽度 24,高度为 stat.count * 12,即数据值乘以 12 像素)和一个月份标签。柱子的颜色根据 selectedMonthIdx 动态切换:选中月份用主色蓝,其他月份用灰色 #B0BEC5,通过点击柱子可以切换选中状态。整个图表容器高度 120 像素,alignItems(VerticalAlign.Bottom) 让所有柱子底部对齐,形成标准的柱状图效果。柱子顶部还通过 borderRadius({ topLeft: 4, topRight: 4 }) 做了圆角处理,让图表更加精致。这种"用布局组件绘制图表"的方式虽然不如专业图表库功能丰富,但胜在轻量、可控、无依赖,非常适合简单的数据可视化场景。
10.4 鱼种占比进度条
Column() {
Text('鱼种占比')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
ForEach(getFishRatios(), (ratio: FishRatioItem) => {
Row() {
Text(ratio.fishType)
.fontSize(12)
.fontColor(TEXT_PRIMARY)
.width(40)
Row() {
Row()
.width(ratio.ratio + '%')
.height(16)
.borderRadius(8)
.backgroundColor(ratio.color)
}
.width('60%')
.height(16)
.borderRadius(8)
.backgroundColor('#E0E0E0')
Text(ratio.count + '尾 ' + ratio.ratio + '%')
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ left: 8 })
}
.width('100%')
.margin({ bottom: 6 })
}, (ratio: FishRatioItem) => ratio.fishType)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
鱼种占比进度条是统计页面的第二个图表组件,它通过水平进度条的形式展示各鱼种在总鱼获中的占比。每个鱼种渲染为一行 Row,包含三部分:左侧 40 像素宽的鱼种名称、中间 60% 宽度的进度条、右侧的数量和百分比文字。进度条的实现非常精妙:外层 Row 作为轨道,背景色为浅灰 #E0E0E0;内层 Row 作为填充,宽度通过 ratio.ratio + '%' 动态设置(如占比 30% 则宽度为 30%),背景色使用 ratio.color(从 FISH_COLORS 调色板分配)。这种"双层 Row"的进度条实现是 ArkUI 中最常见的进度条模式,简单而有效。进度条高度 16 像素,圆角 8,让进度条看起来圆润友好。通过 getFishRatios() 函数动态获取数据,每个鱼种自动分配不同颜色,整个图表色彩丰富且信息清晰,用户能够直观地看到自己最常钓到的鱼种。
10.5 个人记录榜
Column() {
Text('🏆 个人记录榜')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
ForEach(getPersonalRecords(), (record: PersonalRecord) => {
Row() {
Text('⭐')
.fontSize(16)
.fontColor('#FF6F00')
Column() {
Text(record.fishType)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Text(record.weight + ' · ' + record.location)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
}
.margin({ left: 8 })
.layoutWeight(1)
Text(record.date)
.fontSize(10)
.fontColor(BORDER_COLOR)
}
.width('100%')
.padding(8)
.margin({ bottom: 4 })
.borderRadius(8)
.backgroundColor('#FFF8E1')
}, (record: PersonalRecord) => record.fishType)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
个人记录榜是统计页面的最后一个组件,展示所有标记为"个人最佳"的鱼获记录。每条记录渲染为一行 Row,左侧是橙色的⭐图标,中间是鱼种名称(加粗)和"重量 · 地点"组合,右侧是日期。整个记录项使用浅黄色背景 #FFF8E1,这是一种温暖的米黄色,与"荣誉""成就"的语义契合,也让记录榜在视觉上区别于其他图表。通过 ForEach 遍历 getPersonalRecords() 返回的记录数组,每条记录用 record.fishType 作为 key 保证稳定标识。这种"荣誉墙"式的设计能够激发钓友的成就感,让他们回顾自己钓鱼生涯中的高光时刻,是应用情感化设计的体现。
十一、我的 Tab 页面
11.1 钓友信息卡片
@Component
struct MineTabPage {
@State showSettingsModal: boolean = false
build() {
Column() {
Row() {
Text('👤 钓友主页')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Column() {
Column() {
Row() {
Column() {
Text('钓鱼达人·老王')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Text('浙江杭州 · 钓龄8年')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
Row() {
Text('🐟 ' + getTotalCatches() + '尾')
.fontSize(12)
.fontColor(PRIMARY_COLOR)
.margin({ right: 12 })
Text('📍 ' + getSpotCount() + '个钓点')
.fontSize(12)
.fontColor(SECONDARY_COLOR)
.margin({ right: 12 })
Text('🎣 ' + MOCK_GEAR.length + '件装备')
.fontSize(12)
.fontColor('#FF6F00')
}
.margin({ top: 6 })
}
.layoutWeight(1)
}
}
.width('100%')
.padding(16)
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.1)', offsetY: 2 })
// ...其他模块
}
.padding(16)
}
}
}
}
MineTabPage 个人中心页面是应用的"用户档案"模块,顶部是钓友信息卡片,展示了用户的昵称"钓鱼达人·老王"、地区和钓龄,以及三个关键统计数据:鱼获尾数(主色蓝)、钓点数(海草绿)、装备数(橙色)。这三个数据通过调用 getTotalCatches()、getSpotCount() 和 MOCK_GEAR.length 动态获取,保证了与其他模块的数据一致性。三个统计数字使用了与各自模块对应的主题色,形成了跨模块的色彩呼应,让用户在个人中心就能快速回顾自己的"钓鱼资产"。卡片使用了较重的阴影(radius: 4),让用户信息卡片在视觉上突出,成为页面的焦点。
11.2 钓鱼成就模块
Column() {
Text('🏆 钓鱼成就')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
Row() {
this.achievementCard('首次鲈鱼', '🐟', '钓到第一尾鲈鱼', true)
this.achievementCard('百尾大师', '🎯', '累计100尾鱼获', false)
this.achievementCard('远征者', '🚗', '5个以上钓点打卡', true)
this.achievementCard('装备收藏家', '🎒', '20件以上装备', true)
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
钓鱼成就模块展示了四个成就卡片,通过 this.achievementCard(...) 调用参数化 Builder 渲染。四个成就分别是"首次鲈鱼"(已解锁)、“百尾大师”(未解锁)、“远征者”(已解锁)、“装备收藏家”(已解锁),涵盖了鱼获、钓点、装备三个维度。成就系统是游戏化设计的典型应用,它通过设定目标和解锁状态,激励用户持续使用应用、丰富自己的钓鱼数据。这里有一个有趣的细节:根据 mock 数据,用户有 18 尾鱼获,所以"百尾大师"成就未解锁,这种"数据驱动成就状态"的设计让成就系统具有真实感。未解锁的成就会以灰色边框和浅色背景展示,形成与已解锁成就的视觉对比,激发用户的完成欲望。
11.3 achievementCard 成就卡片
@Builder achievementCard(title: string, icon: string, desc: string, unlocked: boolean) {
Column() {
Text(icon)
.fontSize(24)
.fontColor(unlocked ? '#FF6F00' : BORDER_COLOR)
Text(title)
.fontSize(11)
.fontColor(unlocked ? TEXT_PRIMARY : BORDER_COLOR)
.fontWeight(unlocked ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 4 })
Text(desc)
.fontSize(9)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
}
.width('23%')
.padding(8)
.borderRadius(12)
.backgroundColor(unlocked ? '#FFF8E1' : '#F5F5F5')
.borderWidth(unlocked ? 0 : 1)
.borderColor('#E0E0E0')
}
achievementCard 是成就卡片的参数化 Builder,接收标题、图标、描述和解锁状态四个参数。卡片设计巧妙地利用 unlocked 布尔参数驱动全方位的视觉差异:已解锁成就的图标用橙色(#FF6F00)、标题用主色加粗、背景用米黄色(#FFF8E1,与个人记录榜一致)、无边框;未解锁成就的图标和标题都用灰色(BORDER_COLOR)、标题不加粗、背景用浅灰(#F5F5F5)、有灰色边框。这种"一个布尔参数驱动多重视觉属性"的设计是声明式 UI 的精髓,让状态与视图的映射关系一目了然。卡片宽度 23%,四个卡片均匀分布,图标 24 号字醒目,描述 9 号字紧凑,整体信息密度适中。
11.4 装备概览模块
Column() {
Text('🎣 装备概览')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
Row() {
this.gearOverviewCard('鱼竿', 4, PRIMARY_COLOR)
this.gearOverviewCard('鱼线', 3, '#6A1B9A')
this.gearOverviewCard('鱼钩', 3, SECONDARY_COLOR)
this.gearOverviewCard('饵料', 4, '#FF6F00')
this.gearOverviewCard('配件', 2, '#1565C0')
this.gearOverviewCard('工具', 4, '#00838F')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
装备概览模块以紧凑的数字卡片形式展示各类装备的数量,六个分类(鱼竿、鱼线、鱼钩、饵料、配件、工具)各自带有独立的主题色,与装备卡片中的分类色块颜色一致,形成了跨模块的色彩一致性。通过 this.gearOverviewCard(...) 调用参数化 Builder,每个卡片显示分类名称(彩色加粗)和数量(“X 件”)。这种"概览数字墙"的设计让用户能够快速了解自己装备库的结构分布,是个人中心的经典组件。六个卡片通过 FlexAlign.SpaceAround 均匀分布在一行,虽然空间紧凑,但通过字号差异(12号分类名 vs 16号数字)保证了可读性。
11.5 gearOverviewCard 装备概览卡片
@Builder gearOverviewCard(category: string, count: number, color: string) {
Column() {
Text(category)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(count.toString() + '件')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ top: 2 })
}
.padding({ top: 6, bottom: 6 })
}
gearOverviewCard 是装备概览的参数化 Builder,结构极简:分类名称(彩色加粗)+ 数量(“X 件”,主色加粗)。这个 Builder 没有设置固定宽度,而是依靠父容器的 SpaceAround 布局自动分配空间,六个卡片平均占据一行。值得注意的是数量文字使用了 TEXT_PRIMARY 而非传入的 color,这样让所有数字保持统一的深色,只有分类名称使用彩色,避免了色彩过于杂乱。count.toString() 将数字转为字符串再拼接"件",是 ArkTS 中数字到字符串的常见转换方式。这个 Builder 的设计体现了"最小化参数、最大化复用"的原则,仅用三个参数就完成了六个不同卡片的渲染。
11.6 设置项列表
Column() {
Text('⚙️ 设置')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
ForEach(['通知提醒', '数据备份', '隐私设置', '关于我们'], (item: string) => {
Row() {
Text(item)
.fontSize(14)
.fontColor(TEXT_PRIMARY)
Column().layoutWeight(1)
Text('>')
.fontSize(14)
.fontColor(BORDER_COLOR)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.borderWidth(1)
.borderColor('#EEEEEE')
}, (item: string) => item)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
设置项列表是个人中心的最后一个模块,通过 ForEach 渲染四个设置项:通知提醒、数据备份、隐私设置、关于我们。每个设置项是一个 Row,左侧是设置项名称,中间用 Column().layoutWeight(1) 占位,右侧是">"箭头符号表示可进入。这种"左文右箭头"的列表项是移动端设置页的通用模式,用户一看就知道是可点击进入的入口。每个设置项之间通过 borderWidth(1) 和 borderColor('#EEEEEE') 添加了边框,形成分隔线效果。虽然当前实现中没有绑定 onClick 事件,但结构上已经为后续接入具体设置功能做好了准备。这种"先搭骨架后填逻辑"的开发方式,是快速原型开发的典型做法。
十二、模块与组件特点对比
下面通过一个表格对应用中各个模块和核心组件的特点进行横向对比,帮助读者更清晰地理解它们的设计差异和适用场景。
| 模块/组件 | 核心职责 | 主色调 | 数据模型 | 卡片布局特征 | 弹窗类型 | 筛选维度 | 可视化形式 |
|---|---|---|---|---|---|---|---|
| 鱼获模块 | 记录每次作钓的鱼获详情 | 深海蓝 #0277BD | CatchItem(15字段) | 纵向:渐变头部 + 双栏内容 | 新增表单弹窗 | 鱼种 | 卡片列表 |
| 钓点模块 | 管理钓点信息与作钓建议 | 海草绿 #2E7D32 | SpotItem(14字段) | 纵向:主题色头部 + 标签云内容 | 编辑表单弹窗 | 钓点类型 | 卡片列表 |
| 装备模块 | 管理钓具资产与使用状态 | 深海蓝 #0277BD | GearItem(11字段) | 横向:三栏紧凑布局 | 删除确认弹窗 | 装备类别 | 卡片列表 |
| 统计模块 | 数据聚合与可视化展示 | 多色混合 | MonthlyStat/FishRatioItem | 四格卡 + 图表区 | 无 | 月份选择 | 柱状图/进度条/记录榜 |
| 个人中心 | 用户档案与成就展示 | 多色混合 | AchievementItem | 多区块纵向堆叠 | 设置弹窗 | 无 | 成就墙/概览数字 |
| 入口组件 | Tab 导航与全局状态 | 深海蓝 + 海草绿 | 跨模块状态 | 水波动画 + Tab 栏 | 统一遮罩 | 无 | 水波动画 |
| CatchItem 模型 | 承载鱼获业务实体 | - | @Observed 类 | 15字段全参数构造 | - | - | - |
| SpotItem 模型 | 承载钓点业务实体 | - | @Observed 类 | 14字段含数组 | - | - | - |
从表格可以看出,五个功能模块在职责、配色、数据模型、布局、弹窗等维度都既有共性也有差异。鱼获、钓点、装备三个"列表型"模块采用了相似的"筛选栏 + 卡片列表 + 弹窗"三段式结构,但通过配色差异(蓝/绿/蓝)和卡片布局差异(纵向双栏/纵向标签云/横向三栏)保持了各自的辨识度。统计模块和个人中心则完全不同,前者专注于数据可视化,后者专注于用户档案,两者都不需要筛选和弹窗,但通过丰富的图表和卡片保持了视觉趣味性。这种"求同存异"的设计哲学,既保证了应用的整体一致性,又让每个模块具有独特的个性。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 钓鱼记录管理APP - 深海蓝 #0277BD + 海草绿 #2E7D32, 背景 #E1F5FE
// 底部Tab: 🐟 鱼获 | 📍 钓点 | 🎣 装备 | 📊 统计 | 👤 我的
// ==================== interface类型定义 ====================
interface CatchFilterOption {
label: string
value: string
}
interface SpotTypeOption {
label: string
value: string
}
interface GearCategoryOption {
label: string
value: string
}
interface StatCardItem {
label: string
value: string
icon: string
color: string
}
interface MonthlyStat {
month: string
count: number
}
interface FishRatioItem {
fishType: string
count: number
ratio: number
color: string
}
interface PersonalRecord {
fishType: string
weight: string
date: string
location: string
}
interface AchievementItem {
title: string
icon: string
desc: string
unlocked: boolean
}
interface WeatherIconMap {
[key: string]: string
}
interface BaitIconMap {
[key: string]: string
}
interface MethodIconMap {
[key: string]: string
}
interface FacilityIconMap {
[key: string]: string
}
// ==================== @Observed数据模型类 ====================
@Observed
class CatchItem {
id: number = 0
fishType: string = ''
weight: number = 0
length: number = 0
location: string = ''
date: string = ''
time: string = ''
weather: string = ''
temperature: number = 0
bait: string = ''
method: string = ''
photo: string = ''
notes: string = ''
isPersonalBest: boolean = false
releaseStatus: string = ''
constructor(id: number, fishType: string, weight: number, length: number, location: string,
date: string, time: string, weather: string, temperature: number, bait: string,
method: string, photo: string, notes: string, isPersonalBest: boolean, releaseStatus: string) {
this.id = id
this.fishType = fishType
this.weight = weight
this.length = length
this.location = location
this.date = date
this.time = time
this.weather = weather
this.temperature = temperature
this.bait = bait
this.method = method
this.photo = photo
this.notes = notes
this.isPersonalBest = isPersonalBest
this.releaseStatus = releaseStatus
}
}
@Observed
class SpotItem {
id: number = 0
name: string = ''
type: string = ''
address: string = ''
distance: number = 0
rating: number = 0
bestSeason: string = ''
bestTime: string = ''
fishTypes: string[] = []
facilities: string[] = []
lastVisit: string = ''
visitCount: number = 0
notes: string = ''
imageColor: string = ''
constructor(id: number, name: string, type: string, address: string, distance: number,
rating: number, bestSeason: string, bestTime: string, fishTypes: string[],
facilities: string[], lastVisit: string, visitCount: number, notes: string, imageColor: string) {
this.id = id
this.name = name
this.type = type
this.address = address
this.distance = distance
this.rating = rating
this.bestSeason = bestSeason
this.bestTime = bestTime
this.fishTypes = fishTypes
this.facilities = facilities
this.lastVisit = lastVisit
this.visitCount = visitCount
this.notes = notes
this.imageColor = imageColor
}
}
@Observed
class GearItem {
id: number = 0
name: string = ''
category: string = ''
brand: string = ''
model: string = ''
price: number = 0
purchaseDate: string = ''
status: string = ''
usageCount: number = 0
notes: string = ''
color: string = ''
constructor(id: number, name: string, category: string, brand: string, model: string,
price: number, purchaseDate: string, status: string, usageCount: number, notes: string, color: string) {
this.id = id
this.name = name
this.category = category
this.brand = brand
this.model = model
this.price = price
this.purchaseDate = purchaseDate
this.status = status
this.usageCount = usageCount
this.notes = notes
this.color = color
}
}
// ==================== 静态配置常量 ====================
const PRIMARY_COLOR: string = '#0277BD'
const SECONDARY_COLOR: string = '#2E7D32'
const BG_COLOR: string = '#E1F5FE'
const CARD_BG: string = '#FFFFFF'
const TEXT_PRIMARY: string = '#263238'
const TEXT_SECONDARY: string = '#546E7A'
const BORDER_COLOR: string = '#B0BEC5'
const WATER_GRADIENT_TOP: string = '#0277BD'
const WATER_GRADIENT_BOTTOM: string = '#01579B'
const CATCH_FILTERS: CatchFilterOption[] = [
{ label: '全部', value: '全部' },
{ label: '鲈鱼', value: '鲈鱼' },
{ label: '鲫鱼', value: '鲫鱼' },
{ label: '鲤鱼', value: '鲤鱼' },
{ label: '草鱼', value: '草鱼' },
{ label: '黑鱼', value: '黑鱼' },
{ label: '鳜鱼', value: '鳜鱼' }
]
const SPOT_TYPE_OPTIONS: SpotTypeOption[] = [
{ label: '全部', value: '全部' },
{ label: '水库', value: '水库' },
{ label: '河流', value: '河流' },
{ label: '湖泊', value: '湖泊' },
{ label: '海钓', value: '海钓' },
{ label: '池塘', value: '池塘' }
]
const GEAR_CATEGORY_OPTIONS: GearCategoryOption[] = [
{ label: '全部', value: '全部' },
{ label: '鱼竿', value: '鱼竿' },
{ label: '鱼线', value: '鱼线' },
{ label: '鱼钩', value: '鱼钩' },
{ label: '饵料', value: '饵料' },
{ label: '配件', value: '配件' },
{ label: '工具', value: '工具' }
]
const WEATHER_ICONS: WeatherIconMap = {
'晴': '☀️',
'阴': '☁️',
'雨': '🌧️',
'雾': '🌫️'
}
const BAIT_ICONS: BaitIconMap = {
'蚯蚓': '🪱',
'玉米': '🌽',
'商品饵': '🧴',
'路亚': '🦎'
}
const METHOD_ICONS: MethodIconMap = {
'手竿': '🎣',
'海竿': '⚓',
'路亚': '🦎',
'矶钓': '🪨'
}
const FACILITY_ICONS: FacilityIconMap = {
'停车': '🅿️',
'餐饮': '🍽️',
'住宿': '🏨',
'厕所': '🚻'
}
const FISH_COLORS: string[] = ['#0277BD', '#2E7D32', '#FF6F00', '#1565C0', '#6A1B9A', '#00838F', '#C62828']
// ==================== mock数据 ====================
const MOCK_CATCHES: CatchItem[] = [
new CatchItem(1, '鲈鱼', 3.5, 42, '千岛湖', '2026-01-15', '06:30', '晴', 22, '路亚', '路亚', '', '大口鲈,非常活跃', true, '放生'),
new CatchItem(2, '鲫鱼', 1.2, 28, '西溪湿地', '2026-01-22', '08:00', '阴', 18, '蚯蚓', '手竿', '', '冬季鲫鱼偏小', false, '带走'),
new CatchItem(3, '鲤鱼', 5.8, 55, '太湖', '2026-02-05', '07:00', '晴', 20, '玉米', '海竿', '', '大鲤鱼!创个人记录', true, '带走'),
new CatchItem(4, '草鱼', 4.2, 48, '东钱湖', '2026-02-18', '09:30', '阴', 16, '商品饵', '手竿', '', '草鱼吃口很轻', false, '放生'),
new CatchItem(5, '黑鱼', 2.8, 38, '洞庭湖', '2026-03-02', '10:00', '雨', 15, '路亚', '路亚', '', '黑鱼攻击性很强', false, '带走'),
new CatchItem(6, '鳜鱼', 3.1, 40, '千岛湖', '2026-03-15', '06:00', '晴', 24, '路亚', '路亚', '', '鳜鱼品质极佳', true, '带走'),
new CatchItem(7, '鲈鱼', 2.1, 35, '钱塘江', '2026-03-28', '07:30', '雾', 19, '路亚', '路亚', '', '江鲈体型偏小', false, '放生'),
new CatchItem(8, '鲫鱼', 0.8, 22, '西溪湿地', '2026-04-10', '08:30', '晴', 25, '蚯蚓', '手竿', '', '春鲫活跃期', false, '带走'),
new CatchItem(9, '鲤鱼', 4.5, 52, '太湖', '2026-04-22', '06:30', '阴', 21, '商品饵', '海竿', '', '连竿三尾鲤鱼', false, '放生'),
new CatchItem(10, '草鱼', 6.1, 60, '东钱湖', '2026-05-05', '09:00', '晴', 28, '玉米', '手竿', '', '个人最大草鱼', true, '带走'),
new CatchItem(11, '黑鱼', 3.6, 42, '洞庭湖', '2026-05-18', '10:30', '雨', 22, '路亚', '路亚', '', '雷雨后黑鱼疯狂', false, '带走'),
new CatchItem(12, '鳜鱼', 2.5, 36, '千岛湖', '2026-05-30', '06:00', '晴', 26, '路亚', '路亚', '', '清晨鳜鱼好钓', false, '放生'),
new CatchItem(13, '鲈鱼', 4.0, 44, '钱塘江', '2026-06-12', '07:00', '阴', 23, '路亚', '路亚', '', '仲夏鲈鱼肥美', false, '带走'),
new CatchItem(14, '鲫鱼', 1.5, 30, '西溪湿地', '2026-06-25', '08:00', '晴', 30, '蚯蚓', '手竿', '', '鲫鱼数量多', false, '带走'),
new CatchItem(15, '鲤鱼', 6.2, 58, '太湖', '2026-07-08', '06:30', '雾', 27, '玉米', '海竿', '', '夏季夜钓大鲤', true, '放生'),
new CatchItem(16, '草鱼', 3.0, 44, '东钱湖', '2026-07-20', '09:30', '雨', 25, '商品饵', '手竿', '', '雨中草鱼开口', false, '带走'),
new CatchItem(17, '黑鱼', 1.9, 32, '洞庭湖', '2026-07-28', '11:00', '晴', 31, '路亚', '路亚', '', '伏天黑鱼深水', false, '放生'),
new CatchItem(18, '鲈鱼', 2.6, 38, '千岛湖', '2026-07-30', '05:30', '晴', 29, '路亚', '路亚', '', '早起晨钓收获', false, '带走')
]
const MOCK_SPOTS: SpotItem[] = [
new SpotItem(1, '千岛湖', '水库', '淳安县千岛湖镇', 120, 4.8, '春秋', '清晨傍晚', ['鲈鱼', '鳜鱼', '鲫鱼'], ['停车', '餐饮', '住宿', '厕所'], '2026-07-30', 28, '水质好,鱼种丰富', '#0277BD'),
new SpotItem(2, '西溪湿地', '河流', '杭州市西湖区', 8, 4.2, '四季', '全天', ['鲫鱼', '鲤鱼', '草鱼'], ['停车', '厕所'], '2026-06-25', 45, '城市近郊,方便', '#2E7D32'),
new SpotItem(3, '太湖', '湖泊', '苏州市吴中区', 85, 4.6, '春夏秋', '傍晚', ['鲤鱼', '鲈鱼', '草鱼'], ['停车', '餐饮', '住宿'], '2026-07-08', 22, '大面积湖钓', '#1565C0'),
new SpotItem(4, '东钱湖', '湖泊', '宁波市鄞州区', 35, 4.0, '春夏', '上午', ['草鱼', '鲫鱼', '鲤鱼'], ['停车', '餐饮', '厕所'], '2026-07-20', 18, '宁波本地好去处', '#00838F'),
new SpotItem(5, '洞庭湖', '湖泊', '岳阳市岳阳楼区', 380, 4.5, '春夏秋', '全天', ['黑鱼', '鲤鱼', '草鱼'], ['停车', '餐饮', '住宿', '厕所'], '2026-07-28', 15, '野生鱼种多', '#6A1B9A'),
new SpotItem(6, '钱塘江', '河流', '杭州市萧山区', 12, 3.8, '春秋', '涨潮时', ['鲈鱼', '鲫鱼'], ['停车', '厕所'], '2026-06-12', 30, '潮水钓有趣', '#FF6F00'),
new SpotItem(7, '鄱阳湖', '湖泊', '南昌市新建区', 420, 4.3, '春夏秋', '清晨', ['鲤鱼', '草鱼', '鲫鱼'], ['停车', '餐饮', '住宿'], '2026-05-10', 8, '冬季干湖钓鱼', '#C62828'),
new SpotItem(8, '嵊泗海钓场', '海钓', '舟山市嵊泗县', 180, 4.7, '夏秋', '潮汐变化时', ['鲈鱼', '黑鱼'], ['停车', '餐饮', '住宿', '厕所'], '2026-06-20', 10, '海钓体验一流', '#01579B'),
new SpotItem(9, '大明湖', '池塘', '济南市历下区', 450, 3.5, '春夏', '上午', ['鲫鱼', '鲤鱼'], ['停车', '厕所'], '2026-04-05', 6, '城区休闲钓', '#4CAF50'),
new SpotItem(10, '玄武湖', '池塘', '南京市玄武区', 260, 3.9, '四季', '全天', ['鲫鱼', '草鱼', '鲤鱼'], ['停车', '餐饮', '厕所'], '2026-03-15', 12, '南京市民钓场', '#0097A7'),
new SpotItem(11, '滇池', '湖泊', '昆明市西山区', 1200, 4.1, '四季', '清晨', ['鲤鱼', '鲫鱼', '草鱼'], ['停车', '餐饮', '住宿', '厕所'], '2026-02-20', 5, '高原湖泊钓', '#1B5E20'),
new SpotItem(12, '白马湖', '水库', '淮安市淮安区', 300, 3.7, '春夏秋', '傍晚', ['鲫鱼', '鲤鱼'], ['停车', '厕所'], '2026-01-10', 3, '小型水库休闲钓', '#37474F')
]
const MOCK_GEAR: GearItem[] = [
new GearItem(1, '碳素海竿 3.6m', '鱼竿', '光威', 'GW-H360', 280, '2025-03-15', '使用中', 35, '手感轻,腰力足', '#0277BD'),
new GearItem(2, '溪流手竿 4.5m', '鱼竿', '迪佳', 'DJ-X450', 180, '2025-06-01', '使用中', 28, '细线轻漂适合', '#2E7D32'),
new GearItem(3, '路亚竿 2.1m', '鱼竿', '阿布', 'AB-L210', 450, '2025-08-20', '使用中', 22, '直柄路亚竿', '#FF6F00'),
new GearItem(4, '矶钓竿 5.3m', '鱼竿', '达亿瓦', 'DYW-I530', 680, '2025-10-10', '使用中', 8, '海钓专用', '#1565C0'),
new GearItem(5, 'PE线 1.5号', '鱼线', 'YGK', 'YGK-PE1.5', 120, '2025-04-20', '使用中', 30, '耐磨强', '#6A1B9A'),
new GearItem(6, '尼龙线 3.0号', '鱼线', '东丽', 'TL-N3.0', 45, '2025-07-01', '需更换', 50, '老化需换', '#00838F'),
new GearItem(7, '碳线 2.0号', '鱼线', '西格', 'SG-C2.0', 85, '2025-09-15', '使用中', 20, '子线用', '#C62828'),
new GearItem(8, '伊势尼 6号钩', '鱼钩', '欧娜', 'ON-IS6', 15, '2025-05-10', '使用中', 40, '大鱼利器', '#37474F'),
new GearItem(9, '袖钩 3号', '鱼钩', '鬼牙', 'GY-S3', 12, '2025-08-05', '使用中', 55, '鲫鱼首选', '#0277BD'),
new GearItem(10, '千又 2号钩', '鱼钩', '欧娜', 'ON-QY2', 18, '2025-11-01', '全新', 0, '海钓钩', '#2E7D32'),
new GearItem(11, '商品饵 钓鲫套餐', '饵料', '化氏', 'HS-JM1', 60, '2026-01-05', '使用中', 15, '鲫鱼专用', '#FF6F00'),
new GearItem(12, '活蚯蚓', '饵料', '自制', 'ZZ-EW', 0, '2026-02-01', '使用中', 100, '万能饵', '#1565C0'),
new GearItem(13, '路亚拟饵 系列包', '饵料', '阿布', 'AB-LURE-PKG', 200, '2025-12-15', '使用中', 20, '含软硬饵', '#6A1B9A'),
new GearItem(14, '玉米粒 熟制', '饵料', '自制', 'ZZ-CORN', 0, '2026-03-01', '使用中', 60, '鲤鱼最爱', '#00838F'),
new GearItem(15, '鱼护 大号', '配件', '佳钓尼', 'JDN-FG-L', 80, '2025-04-01', '使用中', 30, '装鱼方便', '#C62828'),
new GearItem(16, '钓箱 36L', '配件', '连球', 'LB-DX36', 350, '2025-06-20', '使用中', 25, '多功能钓箱', '#37474F'),
new GearItem(17, '防晒帽', '工具', '迪卡侬', 'DCN-SH', 35, '2025-07-10', '使用中', 40, '户外必备', '#0277BD'),
new GearItem(18, '偏光镜', '工具', '海伦凯勒', 'HK-PJ', 150, '2025-09-01', '使用中', 35, '看漂更清晰', '#2E7D32'),
new GearItem(19, '电子漂', '工具', '小凤', 'XF-EDB', 45, '2025-11-20', '全新', 0, '夜钓神器', '#FF6F00'),
new GearItem(20, '折叠椅', '工具', '宜家', 'IKEA-FC', 99, '2025-03-25', '需更换', 50, '坐久了不舒服', '#1565C0')
]
// ==================== 统计函数 ====================
function getTotalCatches(): number {
return MOCK_CATCHES.length
}
function getTotalWeight(): number {
let total: number = 0
for (let i = 0; i < MOCK_CATCHES.length; i++) {
total += MOCK_CATCHES[i].weight
}
return Math.round(total * 10) / 10
}
function getMaxFish(): string {
let maxWeight: number = 0
let maxName: string = ''
for (let i = 0; i < MOCK_CATCHES.length; i++) {
if (MOCK_CATCHES[i].weight > maxWeight) {
maxWeight = MOCK_CATCHES[i].weight
maxName = MOCK_CATCHES[i].fishType + ' ' + maxWeight + '斤'
}
}
return maxName
}
function getSpotCount(): number {
return MOCK_SPOTS.length
}
function getMonthlyStats(): MonthlyStat[] {
return [
{ month: '1月', count: 2 },
{ month: '2月', count: 2 },
{ month: '3月', count: 2 },
{ month: '4月', count: 2 },
{ month: '5月', count: 2 },
{ month: '6月', count: 2 },
{ month: '7月', count: 4 }
]
}
function getFishRatios(): FishRatioItem[] {
let fishCounts: Record<string, number> = {}
for (let i = 0; i < MOCK_CATCHES.length; i++) {
let ft: string = MOCK_CATCHES[i].fishType
if (fishCounts[ft] === undefined) {
fishCounts[ft] = 0
}
fishCounts[ft] += 1
}
let total: number = MOCK_CATCHES.length
let ratios: FishRatioItem[] = []
let idx: number = 0
let keys: string[] = Object.keys(fishCounts)
for (let i = 0; i < keys.length; i++) {
ratios.push({
fishType: keys[i],
count: fishCounts[keys[i]],
ratio: Math.round(fishCounts[keys[i]] / total * 100),
color: FISH_COLORS[idx % FISH_COLORS.length]
})
idx++
}
return ratios
}
function getPersonalRecords(): PersonalRecord[] {
let records: PersonalRecord[] = []
for (let i = 0; i < MOCK_CATCHES.length; i++) {
if (MOCK_CATCHES[i].isPersonalBest) {
records.push({
fishType: MOCK_CATCHES[i].fishType,
weight: MOCK_CATCHES[i].weight + '斤',
date: MOCK_CATCHES[i].date,
location: MOCK_CATCHES[i].location
})
}
}
return records
}
// ==================== 底部Tab枚举 ====================
enum FishingTab {
Catch = 0,
Spot = 1,
Gear = 2,
Stat = 3,
Mine = 4
}
// ==================== @Entry @Component 入口页面 ====================
@Entry
@Component
struct FishingApp {
@State currentTab: number = FishingTab.Catch
@State showAddCatchModal: boolean = false
@State showEditSpotModal: boolean = false
@State showDeleteGearModal: boolean = false
@State editSpotId: number = 0
@State deleteGearId: number = 0
@State waterWaveOffset: number = 0
aboutToAppear(): void {
this.animateWaterWave()
}
animateWaterWave(): void {
animateTo({ duration: 2000, iterations: -1, curve: Curve.EaseInOut }, () => {
this.waterWaveOffset = 10
})
setTimeout(() => {
animateTo({ duration: 2000, iterations: -1, curve: Curve.EaseInOut }, () => {
this.waterWaveOffset = 0
})
}, 2000)
}
build() {
Column() {
Stack() {
Column()
.width('100%')
.height(8)
.linearGradient({
direction: GradientDirection.Right,
colors: [[WATER_GRADIENT_TOP, 0.0], [WATER_GRADIENT_BOTTOM, 1.0]]
})
Column()
.width('100%')
.height(8)
.linearGradient({
direction: GradientDirection.Right,
colors: [['#4FC3F7', 0.0], ['#0288D1', 0.5], ['#0277BD', 1.0]]
})
.translate({ x: this.waterWaveOffset })
}
.width('100%')
.height(8)
.clip(true)
Column() {
if (this.currentTab === FishingTab.Catch) {
CatchTabPage({ showAddModal: this.showAddCatchModal })
} else if (this.currentTab === FishingTab.Spot) {
SpotTabPage({ showEditModal: this.showEditSpotModal, editSpotId: this.editSpotId })
} else if (this.currentTab === FishingTab.Gear) {
GearTabPage({ showDeleteModal: this.showDeleteGearModal, deleteGearId: this.deleteGearId })
} else if (this.currentTab === FishingTab.Stat) {
StatTabPage()
} else if (this.currentTab === FishingTab.Mine) {
MineTabPage()
}
}
.layoutWeight(1)
Row() {
ForEach(['🐟 鱼获', '📍 钓点', '🎣 装备', '📊 统计', '👤 我的'], (item: string, idx: number) => {
Column() {
Text(item)
.fontSize(12)
.fontColor(this.currentTab === idx ? PRIMARY_COLOR : TEXT_SECONDARY)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
}
.width('20%')
.height(50)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.currentTab = idx as number
})
}, (item: string, idx: number) => idx.toString())
}
.width('100%')
.height(50)
.backgroundColor(CARD_BG)
.shadow({ radius: 2, color: 'rgba(0,0,0,0.1)', offsetY: -1 })
}
.width('100%')
.height('100%')
.backgroundColor(BG_COLOR)
}
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
}
// ==================== 鱼获Tab页面 ====================
@Component
struct CatchTabPage {
@State filterValue: string = '全部'
@State showAddModal: boolean = false
@Prop showAddModalProp: boolean = false
getFilteredCatches(): CatchItem[] {
if (this.filterValue === '全部') {
return MOCK_CATCHES
}
let result: CatchItem[] = []
for (let i = 0; i < MOCK_CATCHES.length; i++) {
if (MOCK_CATCHES[i].fishType === this.filterValue) {
result.push(MOCK_CATCHES[i])
}
}
return result
}
build() {
Column() {
// 顶部标题
Row() {
Text('🐟 鱼获记录')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Column().layoutWeight(1)
Text('+ 新增')
.fontSize(14)
.fontColor(PRIMARY_COLOR)
.fontWeight(FontWeight.Bold)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor('#E1F5FE')
.onClick(() => {
this.showAddModal = true
})
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
// 筛选栏
Scroll() {
Row() {
ForEach(CATCH_FILTERS, (item: CatchFilterOption) => {
Text(item.label)
.fontSize(13)
.fontColor(this.filterValue === item.value ? PRIMARY_COLOR : TEXT_SECONDARY)
.fontWeight(this.filterValue === item.value ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.filterValue === item.value ? '#E1F5FE' : '#F5F5F5')
.onClick(() => {
this.filterValue = item.value
})
}, (item: CatchFilterOption) => item.value)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(36)
// 鱼获列表
Scroll() {
Column() {
ForEach(this.getFilteredCatches(), (item: CatchItem) => {
this.catchCard(item)
}, (item: CatchItem) => item.id.toString())
}
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.scrollBar(BarState.Auto)
if (this.showAddModal) { this.addCatchModal() }
}
.width('100%')
.height('100%')
}
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder addCatchModal() {
Column() {
this.modalOverlay(() => { this.showAddModal = false })
Column() {
Row() {
Text('新增鱼获').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text('x').fontSize(18).fontColor('#999999')
.onClick(() => { this.showAddModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
Column() {
Text('鱼种').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
ForEach(['鲈鱼', '鲫鱼', '鲤鱼', '草鱼', '黑鱼', '鳜鱼'], (fishType: string) => {
Text(fishType)
.fontSize(13)
.fontColor('#546E7A')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#F5F5F5')
.margin({ right: 6 })
}, (fishType: string) => fishType)
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('重量 (斤)').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 6 })
Row() {
Text('请输入重量')
.fontSize(14).fontColor('#B0B0B0').layoutWeight(1)
Text('斤')
.fontSize(14).fontColor('#999999')
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(8)
.backgroundColor('#F9F9F9')
.borderWidth(1).borderColor('#E0E0E0')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('钓点').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 6 })
Row() {
Text('请输入钓点名称')
.fontSize(14).fontColor('#B0B0B0').layoutWeight(1)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(8)
.backgroundColor('#F9F9F9')
.borderWidth(1).borderColor('#E0E0E0')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('天气').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
ForEach(['晴', '阴', '雨', '雾'], (weather: string) => {
Text(weather)
.fontSize(13)
.fontColor('#546E7A')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#F5F5F5')
.margin({ right: 8 })
}, (weather: string) => weather)
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('饵料').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
ForEach(['蚯蚓', '玉米', '商品饵', '路亚'], (bait: string) => {
Text(bait)
.fontSize(13)
.fontColor('#546E7A')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#F5F5F5')
.margin({ right: 6 })
}, (bait: string) => bait)
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('处理方式').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
Text('放生')
.fontSize(13)
.fontColor('#2E7D32')
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.backgroundColor('#C8E6C9')
.margin({ right: 12 })
Text('带走')
.fontSize(13)
.fontColor('#FF6F00')
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.backgroundColor('#FFF3E0')
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showAddModal = false })
Text('保存').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#0277BD').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showAddModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.position({ x: '5%', y: '15%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder catchCard(item: CatchItem) {
Column() {
// 水面渐变头部
Stack() {
Column()
.width('100%')
.height(40)
.linearGradient({
direction: GradientDirection.Right,
colors: [[item.isPersonalBest ? '#FF6F00' : WATER_GRADIENT_TOP, 0.0],
[item.isPersonalBest ? '#FF8F00' : '#01579B', 1.0]]
})
Row() {
Text(WEATHER_ICONS[item.weather] || '☀️')
.fontSize(18)
Text(item.weather + ' ' + item.temperature + '°C')
.fontSize(12)
.fontColor('#FFFFFF')
.margin({ left: 4 })
Column().layoutWeight(1)
if (item.isPersonalBest) {
Text('⭐ 个人最佳')
.fontSize(12)
.fontColor('#FFFFFF')
.fontWeight(FontWeight.Bold)
}
Text(item.releaseStatus === '放生' ? '🌊 放生' : '📦 带走')
.fontSize(12)
.fontColor('#FFFFFF')
}
.width('100%')
.height(40)
.padding({ left: 12, right: 12 })
.alignItems(VerticalAlign.Center)
}
.width('100%')
.height(40)
// 内容区
Row() {
Column() {
Row() {
Text(item.fishType)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
if (item.isPersonalBest) {
Text('⭐')
.fontSize(14)
.margin({ left: 4 })
}
}
Text(item.weight + '斤 · ' + item.length + 'cm')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(item.isPersonalBest ? '#FF6F00' : PRIMARY_COLOR)
.margin({ top: 4 })
Row() {
Text(METHOD_ICONS[item.method] || '🎣')
.fontSize(14)
Text(item.method)
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ left: 2 })
Text(' | ')
.fontSize(12)
.fontColor(BORDER_COLOR)
Text(BAIT_ICONS[item.bait] || '🧴')
.fontSize(14)
Text(item.bait)
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ left: 2 })
}
.margin({ top: 6 })
Text('📍 ' + item.location)
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text(item.date)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
Text(item.time)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
if (item.notes.length > 0) {
Text(item.notes.substring(0, 8) + (item.notes.length > 8 ? '...' : ''))
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ top: 6 })
.maxLines(1)
}
}
.alignItems(HorizontalAlign.End)
.width(80)
}
.padding(12)
.alignItems(VerticalAlign.Top)
}
.width('100%')
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 4, color: 'rgba(2,119,189,0.15)', offsetY: 2 })
.margin({ bottom: 12 })
}
}
// ==================== 钓点Tab页面 ====================
@Component
struct SpotTabPage {
@State filterValue: string = '全部'
@Prop showEditModal: boolean = false
@Prop editSpotId: number = 0
getFilteredSpots(): SpotItem[] {
if (this.filterValue === '全部') {
return MOCK_SPOTS
}
let result: SpotItem[] = []
for (let i = 0; i < MOCK_SPOTS.length; i++) {
if (MOCK_SPOTS[i].type === this.filterValue) {
result.push(MOCK_SPOTS[i])
}
}
return result
}
build() {
Column() {
Row() {
Text('📍 钓点')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Row() {
ForEach(SPOT_TYPE_OPTIONS, (item: SpotTypeOption) => {
Text(item.label)
.fontSize(13)
.fontColor(this.filterValue === item.value ? SECONDARY_COLOR : TEXT_SECONDARY)
.fontWeight(this.filterValue === item.value ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.filterValue === item.value ? '#C8E6C9' : '#F5F5F5')
.onClick(() => {
this.filterValue = item.value
})
}, (item: SpotTypeOption) => item.value)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(36)
Scroll() {
Column() {
ForEach(this.getFilteredSpots(), (item: SpotItem) => {
this.spotCard(item)
}, (item: SpotItem) => item.id.toString())
}
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.scrollBar(BarState.Auto)
if (this.showEditModal) { this.editSpotModal() }
}
.width('100%')
.height('100%')
}
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder editSpotModal() {
Column() {
this.modalOverlay(() => { this.showEditModal = false })
Column() {
Row() {
Text('编辑钓点').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#212121')
Column().layoutWeight(1)
Text('x').fontSize(18).fontColor('#999999')
.onClick(() => { this.showEditModal = false })
}
.width('100%').padding({ left: 20, right: 20, top: 18, bottom: 12 })
Divider().color('#F0F0F0')
Column() {
Text('钓点名称').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 6 })
Row() {
Text('请输入钓点名称')
.fontSize(14).fontColor('#B0B0B0').layoutWeight(1)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(8)
.backgroundColor('#F9F9F9')
.borderWidth(1).borderColor('#E0E0E0')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('类型').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
ForEach(['水库', '河流', '湖泊', '海钓', '池塘'], (typeItem: string) => {
Text(typeItem)
.fontSize(13)
.fontColor('#546E7A')
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#F5F5F5')
.margin({ right: 6 })
}, (typeItem: string) => typeItem)
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('地址').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 6 })
Row() {
Text('请输入地址')
.fontSize(14).fontColor('#B0B0B0').layoutWeight(1)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(8)
.backgroundColor('#F9F9F9')
.borderWidth(1).borderColor('#E0E0E0')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('评分').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
ForEach(['1', '2', '3', '4', '5'], (star: string) => {
Text(star + ' ☆')
.fontSize(13)
.fontColor('#546E7A')
.padding({ left: 8, right: 8, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#F5F5F5')
.margin({ right: 4 })
}, (star: string) => star)
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('最佳季节').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 8 })
Row() {
ForEach(['春', '夏', '秋', '冬', '四季'], (season: string) => {
Text(season)
.fontSize(13)
.fontColor('#546E7A')
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#F5F5F5')
.margin({ right: 6 })
}, (season: string) => season)
}
.width('100%')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Column() {
Text('备注').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#263238')
.margin({ bottom: 6 })
Row() {
Text('请输入备注信息')
.fontSize(14).fontColor('#B0B0B0').layoutWeight(1)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10, bottom: 10 })
.borderRadius(8)
.backgroundColor('#F9F9F9')
.borderWidth(1).borderColor('#E0E0E0')
}
.width('100%').padding({ left: 20, right: 20, top: 12 })
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showEditModal = false })
Text('保存').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#0277BD').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showEditModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 16, bottom: 16 })
}
.width('90%').backgroundColor('#FFFFFF').borderRadius(16)
.position({ x: '5%', y: '15%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder spotCard(item: SpotItem) {
Column() {
Stack() {
Column()
.width('100%')
.height(60)
.linearGradient({
direction: GradientDirection.Right,
colors: [[item.imageColor, 0.0], ['#01579B', 1.0]]
})
Column() {
Text(item.name)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
Text(item.type + ' · ' + item.address)
.fontSize(11)
.fontColor('#B0BEC5')
.margin({ top: 4 })
}
.padding({ left: 12, top: 10 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height(60)
Row() {
Column() {
Row() {
Text('⭐ ')
.fontSize(12)
Text(item.rating.toFixed(1))
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(SECONDARY_COLOR)
}
Text('距你 ' + item.distance + 'km')
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
Row() {
Text('最佳: ' + item.bestSeason)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
Text(' | ' + item.bestTime)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
}
.margin({ top: 4 })
Row() {
ForEach(item.fishTypes, (fish: string) => {
Text(fish)
.fontSize(10)
.fontColor(PRIMARY_COLOR)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor('#E1F5FE')
.margin({ right: 4 })
}, (fish: string) => fish)
}
.margin({ top: 6 })
Row() {
ForEach(item.facilities, (fac: string) => {
Text(FACILITY_ICONS[fac] || fac)
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ right: 4 })
}, (fac: string) => fac)
}
.margin({ top: 6 })
Text('已访 ' + item.visitCount + '次 · 最近 ' + item.lastVisit)
.fontSize(10)
.fontColor(BORDER_COLOR)
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text('编辑')
.fontSize(12)
.fontColor(PRIMARY_COLOR)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(8)
.backgroundColor('#E1F5FE')
}
.width(50)
.alignItems(HorizontalAlign.End)
}
.padding(12)
.alignItems(VerticalAlign.Top)
}
.width('100%')
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 4, color: 'rgba(46,125,50,0.15)', offsetY: 2 })
.margin({ bottom: 12 })
}
}
// ==================== 装备Tab页面 ====================
@Component
struct GearTabPage {
@State filterValue: string = '全部'
@Prop showDeleteModal: boolean = false
@Prop deleteGearId: number = 0
getFilteredGear(): GearItem[] {
if (this.filterValue === '全部') {
return MOCK_GEAR
}
let result: GearItem[] = []
for (let i = 0; i < MOCK_GEAR.length; i++) {
if (MOCK_GEAR[i].category === this.filterValue) {
result.push(MOCK_GEAR[i])
}
}
return result
}
build() {
Column() {
Row() {
Text('🎣 装备库')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Row() {
ForEach(GEAR_CATEGORY_OPTIONS, (item: GearCategoryOption) => {
Text(item.label)
.fontSize(13)
.fontColor(this.filterValue === item.value ? PRIMARY_COLOR : TEXT_SECONDARY)
.fontWeight(this.filterValue === item.value ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(16)
.backgroundColor(this.filterValue === item.value ? '#E1F5FE' : '#F5F5F5')
.onClick(() => {
this.filterValue = item.value
})
}, (item: GearCategoryOption) => item.value)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
.height(36)
Scroll() {
Column() {
ForEach(this.getFilteredGear(), (item: GearItem) => {
this.gearCard(item)
}, (item: GearItem) => item.id.toString())
}
.padding({ left: 16, right: 16, top: 8, bottom: 16 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.scrollBar(BarState.Auto)
if (this.showDeleteModal) { this.deleteGearModal() }
}
.width('100%')
.height('100%')
}
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%').height('100%')
.backgroundColor('rgba(0,0,0,0.5)')
.onClick(onClose)
}
@Builder deleteGearModal() {
Column() {
this.modalOverlay(() => { this.showDeleteModal = false })
Column() {
Column() {
Text('⚠')
.fontSize(36)
Text('确认删除')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#212121')
.margin({ top: 8 })
Text('删除后数据不可恢复,确定要删除该装备吗?')
.fontSize(13)
.fontColor('#999999')
.margin({ top: 6 })
}
.width('100%')
.padding({ top: 24, bottom: 12 })
Divider().color('#F0F0F0')
Row() {
Text('取消').fontSize(14).fontColor('#888888')
.backgroundColor('#F5F5F5').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.onClick(() => { this.showDeleteModal = false })
Text('确认删除').fontSize(14).fontColor('#FFFFFF')
.backgroundColor('#C62828').borderRadius(20)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.margin({ left: 12 })
.onClick(() => { this.showDeleteModal = false })
}
.width('100%').justifyContent(FlexAlign.Center)
.padding({ left: 20, right: 20, top: 12, bottom: 16 })
}
.width('80%').backgroundColor('#FFFFFF').borderRadius(16)
.position({ x: '10%', y: '35%' })
}
.width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
}
@Builder gearCard(item: GearItem) {
Row() {
Column() {
Text(item.category)
.fontSize(10)
.fontColor('#FFFFFF')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(item.color)
}
.margin({ right: 12 })
Column() {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Text(item.brand + ' · ' + item.model)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
Row() {
Text(item.status)
.fontSize(11)
.fontColor(item.status === '全新' ? SECONDARY_COLOR : item.status === '需更换' ? '#C62828' : TEXT_SECONDARY)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(item.status === '全新' ? '#C8E6C9' : item.status === '需更换' ? '#FFCDD2' : '#E0E0E0')
Text('使用 ' + item.usageCount + ' 次')
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.layoutWeight(1)
Column() {
Text('¥' + item.price)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(PRIMARY_COLOR)
Text(item.purchaseDate)
.fontSize(10)
.fontColor(BORDER_COLOR)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
.width(70)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 3, color: 'rgba(2,119,189,0.1)', offsetY: 1 })
.margin({ bottom: 8 })
}
}
// ==================== 统计Tab页面 ====================
@Component
struct StatTabPage {
@State selectedMonthIdx: number = 6
build() {
Column() {
Row() {
Text('📊 钓鱼统计')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Column() {
// 四格统计
Row() {
this.statMiniCard('总尾数', getTotalCatches().toString(), '🐟', PRIMARY_COLOR)
this.statMiniCard('总重量', getTotalWeight() + '斤', '⚖️', SECONDARY_COLOR)
this.statMiniCard('最大鱼', getMaxFish(), '🏆', '#FF6F00')
this.statMiniCard('钓点数', getSpotCount().toString(), '📍', '#1565C0')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.padding({ left: 8, right: 8 })
// 月度柱状图
Column() {
Text('月度鱼获数量')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
Row() {
ForEach(getMonthlyStats(), (stat: MonthlyStat, idx: number) => {
Column() {
Column()
.width(24)
.height(stat.count * 12)
.borderRadius({ topLeft: 4, topRight: 4 })
.backgroundColor(this.selectedMonthIdx === idx ? PRIMARY_COLOR : '#B0BEC5')
Text(stat.month)
.fontSize(10)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
}
.margin({ left: 4, right: 4 })
.onClick(() => {
this.selectedMonthIdx = idx as number
})
}, (stat: MonthlyStat, idx: number) => idx.toString())
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.height(120)
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
// 鱼种占比进度条
Column() {
Text('鱼种占比')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
ForEach(getFishRatios(), (ratio: FishRatioItem) => {
Row() {
Text(ratio.fishType)
.fontSize(12)
.fontColor(TEXT_PRIMARY)
.width(40)
Row() {
Row()
.width(ratio.ratio + '%')
.height(16)
.borderRadius(8)
.backgroundColor(ratio.color)
}
.width('60%')
.height(16)
.borderRadius(8)
.backgroundColor('#E0E0E0')
Text(ratio.count + '尾 ' + ratio.ratio + '%')
.fontSize(11)
.fontColor(TEXT_SECONDARY)
.margin({ left: 8 })
}
.width('100%')
.margin({ bottom: 6 })
}, (ratio: FishRatioItem) => ratio.fishType)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
// 个人记录榜
Column() {
Text('🏆 个人记录榜')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
ForEach(getPersonalRecords(), (record: PersonalRecord) => {
Row() {
Text('⭐')
.fontSize(16)
.fontColor('#FF6F00')
Column() {
Text(record.fishType)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Text(record.weight + ' · ' + record.location)
.fontSize(11)
.fontColor(TEXT_SECONDARY)
}
.margin({ left: 8 })
.layoutWeight(1)
Text(record.date)
.fontSize(10)
.fontColor(BORDER_COLOR)
}
.width('100%')
.padding(8)
.margin({ bottom: 4 })
.borderRadius(8)
.backgroundColor('#FFF8E1')
}, (record: PersonalRecord) => record.fishType)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
}
.padding(16)
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.scrollBar(BarState.Auto)
}
.width('100%')
.height('100%')
}
@Builder statMiniCard(label: string, value: string, icon: string, color: string) {
Column() {
Text(icon)
.fontSize(22)
Text(value)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(color)
.margin({ top: 4 })
Text(label)
.fontSize(10)
.fontColor(TEXT_SECONDARY)
}
.width('23%')
.padding(8)
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 3, color: 'rgba(0,0,0,0.08)', offsetY: 1 })
}
}
// ==================== 我的Tab页面 ====================
@Component
struct MineTabPage {
@State showSettingsModal: boolean = false
build() {
Column() {
Row() {
Text('👤 钓友主页')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
Scroll() {
Column() {
// 钓友信息
Column() {
Row() {
Column() {
Text('钓鱼达人·老王')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
Text('浙江杭州 · 钓龄8年')
.fontSize(12)
.fontColor(TEXT_SECONDARY)
.margin({ top: 4 })
Row() {
Text('🐟 ' + getTotalCatches() + '尾')
.fontSize(12)
.fontColor(PRIMARY_COLOR)
.margin({ right: 12 })
Text('📍 ' + getSpotCount() + '个钓点')
.fontSize(12)
.fontColor(SECONDARY_COLOR)
.margin({ right: 12 })
Text('🎣 ' + MOCK_GEAR.length + '件装备')
.fontSize(12)
.fontColor('#FF6F00')
}
.margin({ top: 6 })
}
.layoutWeight(1)
}
}
.width('100%')
.padding(16)
.borderRadius(12)
.backgroundColor(CARD_BG)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.1)', offsetY: 2 })
// 钓鱼成就
Column() {
Text('🏆 钓鱼成就')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
Row() {
this.achievementCard('首次鲈鱼', '🐟', '钓到第一尾鲈鱼', true)
this.achievementCard('百尾大师', '🎯', '累计100尾鱼获', false)
this.achievementCard('远征者', '🚗', '5个以上钓点打卡', true)
this.achievementCard('装备收藏家', '🎒', '20件以上装备', true)
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
// 装备概览
Column() {
Text('🎣 装备概览')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
Row() {
this.gearOverviewCard('鱼竿', 4, PRIMARY_COLOR)
this.gearOverviewCard('鱼线', 3, '#6A1B9A')
this.gearOverviewCard('鱼钩', 3, SECONDARY_COLOR)
this.gearOverviewCard('饵料', 4, '#FF6F00')
this.gearOverviewCard('配件', 2, '#1565C0')
this.gearOverviewCard('工具', 4, '#00838F')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
// 设置项
Column() {
Text('⚙️ 设置')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ bottom: 8 })
ForEach(['通知提醒', '数据备份', '隐私设置', '关于我们'], (item: string) => {
Row() {
Text(item)
.fontSize(14)
.fontColor(TEXT_PRIMARY)
Column().layoutWeight(1)
Text('>')
.fontSize(14)
.fontColor(BORDER_COLOR)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.borderWidth(1)
.borderColor('#EEEEEE')
}, (item: string) => item)
}
.width('100%')
.padding(12)
.borderRadius(12)
.backgroundColor(CARD_BG)
.margin({ top: 12 })
}
.padding(16)
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.scrollBar(BarState.Auto)
}
.width('100%')
.height('100%')
}
@Builder achievementCard(title: string, icon: string, desc: string, unlocked: boolean) {
Column() {
Text(icon)
.fontSize(24)
.fontColor(unlocked ? '#FF6F00' : BORDER_COLOR)
Text(title)
.fontSize(11)
.fontColor(unlocked ? TEXT_PRIMARY : BORDER_COLOR)
.fontWeight(unlocked ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 4 })
Text(desc)
.fontSize(9)
.fontColor(TEXT_SECONDARY)
.margin({ top: 2 })
}
.width('23%')
.padding(8)
.borderRadius(12)
.backgroundColor(unlocked ? '#FFF8E1' : '#F5F5F5')
.borderWidth(unlocked ? 0 : 1)
.borderColor('#E0E0E0')
}
@Builder gearOverviewCard(category: string, count: number, color: string) {
Column() {
Text(category)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(count.toString() + '件')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(TEXT_PRIMARY)
.margin({ top: 2 })
}
.padding({ top: 6, bottom: 6 })
}
}
总结
通过对这款 HarmonyOS ArkTS 钓鱼记录管理应用的逐段代码剖析,我们可以总结出几个突出的技术要点。首先是声明式 UI 范式的充分运用,应用通过 @Entry、@Component、@State、@Prop、@Observed、@Builder 等装饰器构建了一套层次分明的组件体系,状态变化能够自动驱动视图更新,开发者只需关注"状态是什么"而非"如何更新视图",极大提升了开发效率。其次是数据与视图的彻底分离,业务实体通过 @Observed 类承载,统计逻辑通过纯函数实现,视图层通过 @Builder 方法拆分复用,三层职责清晰、互不耦合。再次是配置驱动的设计思想,颜色、筛选项、图标映射等全部抽取为常量,通过 ForEach 动态渲染,使得新增配置项无需改动视图代码,具备良好的扩展性。最后是视觉细节的精雕细琢,水波动画、卡片渐变、状态色变、参数化 Builder 等设计,让应用在功能完备的同时具有出色的视觉表现力。
从设计思想层面看,这款应用体现了"场景化设计"和"游戏化设计"两大理念。场景化设计体现在配色方案上——深海蓝与海草绿的组合直接呼应了钓鱼场景的水与草,顶部水波动画强化了沉浸感,让用户在使用过程中始终能感受到钓鱼的氛围。游戏化设计则体现在成就系统和个人记录榜上——通过设定"首次鲈鱼"“百尾大师”"远征者"等成就目标,以及"个人最佳"标记和记录榜展示,激发了钓友的收集欲和成就感,让记录数据这件事变得有趣。此外,应用还体现了"数据驱动样式"的设计思想,如 isPersonalBest 字段同时驱动卡片头部颜色、星标显示、字色变化三处视觉表现,status 字段驱动装备卡片状态标签的三色切换,这种"一处数据多处映射"的设计让 UI 状态管理变得简洁而强大。
在可改进方向上,这款应用还有不少优化空间。首先是数据持久化,当前所有数据都是内存中的 mock 常量,应用重启后数据会丢失,应该引入 @StorageLink、@PersistentStorage 或关系型数据库实现数据持久化,这是走向生产环境的第一步。其次是代码抽象,三个列表型模块(鱼获、钓点、装备)的筛选逻辑、列表渲染、弹窗结构高度相似,可以通过泛型组件或高阶函数进一步抽象,减少重复代码。再次是交互完善,新增/编辑弹窗当前只是占位 UI,应该接入真实的表单输入组件(如 TextInput、Select),并实现数据的增删改查逻辑;装备卡片的删除、钓点卡片的编辑也应该绑定真实的事件处理。此外,统计模块的月度数据目前是硬编码,应该改为从鱼获数据中动态聚合计算,并可以引入更多维度的统计分析,如按钓点统计、按饵料统计、按天气统计等。
更多推荐

所有评论(0)