HarmonyOS 6数组中的 PostItem 实例在构造时即完成属性赋值,通过 @State 修饰的 postList 变量引用该数组
一、HarmonyOS 技术背景与 ArkTS 开发理念

HarmonyOS 6.1.1 作为华为全场景分布式操作系统的最新演进版本,其核心开发语言 ArkTS 已经从早期的方舟编译器框架发展为一套完整的、类型安全的、声明式 UI 编程体系。基于 HarmonyOS API 24 所提供的系统能力,开发者能够以更接近自然语言的方式描述界面结构与交互逻辑,同时享有 AOT 编译带来的原生级性能表现。ArkTS 在语法层面汲取了 TypeScript 的类型系统优势,同时通过装饰器机制(如 @Entry、@Component、@State、@Builder 等)实现了对组件生命周期、状态管理、UI 构建的精确控制。这种声明式范式不仅降低了开发者的心智负担,更在底层编译阶段实现了渲染树的高效 Diff 与最小化更新。
在城市夜钓这一特定场景中,HarmonyOS ArkTS API 24 的技术优势体现得尤为明显。夜钓作为一种兼具休闲与竞技属性的城市户外活动,其用户群体对移动端应用的视觉沉浸感、实时交互反馈和数据可视化有着极高的要求。浮语·城市夜钓与钓具工坊应用正是基于这一业务需求,采用暗夜蓝与荧光绿的配色方案(#4E9DBA / #4CC97E),在暗色主题(背景色 #0F1620)下营造出与夜钓场景高度匹配的视觉氛围。应用通过涟漪扩散、浮漂信号灯闪烁、夜光漂光点等特效动画,模拟了真实夜钓场景中水面波动与灯光信号的感官体验,使应用不再只是一个信息展示工具,而是一个具有情感共鸣的沉浸式垂钓伴侣。
从架构设计的角度来看,该应用采用了"底部四主 Tab + 首页七内容 Tab"的双层导航结构,这种设计模式在电商、社交、工具类应用中被广泛验证为最优的导航层次。底部四个主 Tab 分别对应"首页"“橱窗”“课堂”"我的"四个核心功能域,而首页中的七个内容子 Tab 则以"鱼漂顶帽式"的视觉风格呈现,即每个 Tab 项由一个顶部短横条和一个下方竖条组合而成,模拟了传统夜钓浮漂的顶帽造型。这种将场景元素融入交互控件设计的做法,体现了 ArkTS 声明式 UI 在自定义视觉表现方面的强大表现力。
在数据驱动层面,应用采用了 @Observed 装饰器对核心数据模型进行响应式包装,包括 PostItem(钓友动态)、RigItem(钓具信息)、NightItem(夜钓场次)、GearItem(装备信息)四个数据实体。当这些数据发生变化时,ArkTS 框架会自动触发关联 UI 组件的重新渲染,无需开发者手动调用 invalidate 或 setState 等命令式 API。这种数据-视图自动绑定的机制,使得开发者可以将精力集中在业务逻辑的编写上,而将 UI 更新的时序与粒度交给框架来优化。此外,应用还通过 setInterval 驱动的 tick 计数器实现了特效动画的帧级更新,配合纯函数计算坐标与透明度,既保证了动画的流畅性,又避免了不必要的组件重建。
二、ColorPalette 接口与 COLORS 常量定义

应用首先通过 interface 定义了一套完整的色彩调色板接口,随后用一个 const 常量实例化了这套调色板。这种设计模式确保了所有颜色值在编译阶段就能得到类型检查,避免了在大型项目中因颜色值拼写错误导致的运行时异常。
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
lure: string;
}
ColorPalette 接口定义了十六个颜色字段,涵盖了主色(primary)、辅色(accent)、背景色(bg)、卡片背景色(cardBg)、文本三级色阶(textPrimary / textSecondary / textHint)、边框色(border)、语义色(success / warning / danger)以及一个特殊的诱饵色(lure)。其中 lure 字段是夜钓场景专属的色彩扩展,用于模拟夜光饵料在水面上的荧光效果。这种将场景语义编码到色彩字段命名中的做法,使得后续的 UI 构建代码具有极强的可读性。
const COLORS: ColorPalette = {
primary: '#4E9DBA',
primaryLight: '#243B52',
primaryDark: '#2E6780',
accent: '#4CC97E',
accentLight: '#1D3A2B',
bg: '#0F1620',
cardBg: '#18202E',
textPrimary: '#E8EDF4',
textSecondary: '#A7B4C4',
textHint: '#5E6D80',
border: '#2A3547',
success: '#6BCB77',
warning: '#E0B14C',
danger: '#E06A5A',
white: '#FFFFFF',
lure: '#8FD8FF'
};
COLORS 常量的实例化值体现了暗夜主题的设计哲学。背景色 #0F1620 是一种极深的蓝黑色,模拟了无光污染的夜钓环境;主色 #4E9DBA 是一种柔和的湖蓝色,呼应了水面反光;辅色 #4CC97E 是荧光绿,模拟了夜光浮漂在水中的光信号;诱饵色 #8FD8FF 则是亮天蓝色,用于点缀夜光饵料光点。文本色阶从 #E8EDF4(近白)到 #5E6D80(暗灰蓝),形成了清晰的信息层次。这套配色方案不仅在视觉上高度统一,更在无障碍对比度方面达到了 WCAG AA 标准。
三、@Observed 数据模型类定义

ArkTS 的 @Observed 装饰器是 V2 状态管理体系中的核心装饰器之一,它将普通类标记为可观察对象,使得类的属性变化能够被框架自动追踪并触发 UI 更新。应用定义了四个 @Observed 类来承载不同业务领域的数据。
@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
PostItem 是钓友动态数据模型,包含了动态 ID、昵称、头像(Emoji)、正文、时间和点赞数六个字段。头像字段使用 Emoji 字符串而非图片 URL,这是一种轻量化的设计决策,既减少了网络请求,又保证了在暗色主题下的良好显示效果。构造函数采用了参数全赋值的方式,使得静态数据初始化时可以在一行代码中完成对象创建。
@Observed
export class RigItem {
id: number = 0
name: string = ''
craft: string = ''
price: string = ''
level: string = ''
hot: number = 0
constructor(id: number, name: string, craft: string, price: string, level: string, hot: number) {
this.id = id; this.name = name; this.craft = craft; this.price = price
this.level = level; this.hot = hot
}
}
RigItem 是钓具数据模型,name 字段存储钓具名称(如"夜光漂 · 芦苇尾"),craft 字段存储工艺参数(如"3 号 · 高可视"),price 字段以字符串形式存储价格(如"¥68/支"),level 字段是等级标签(如"人气"“进阶”"入门"等),hot 字段是关注度数值。这些字段的设计充分考虑了钓具展示场景的信息密度需求。
@Observed
export class NightItem {
id: number = 0
name: string = ''
item: string = ''
status: string = ''
progress: number = 0
constructor(id: number, name: string, item: string, status: string, progress: number) {
this.id = id; this.name = name; this.item = item; this.status = status
this.progress = progress
}
}
NightItem 是夜钓场次数据模型,name 字段包含钓点与时间信息(如"东湖北岸 · 周三夜"),item 字段描述钓法与装备(如"翘嘴窗口 · 22:00-1:00"),status 字段是运行状态(如"已收竿"“守口中”"搏鱼中"等),progress 字段是进度百分比。该模型是"我的夜钓"页面的核心数据载体,其 status 字段通过纯函数映射为不同颜色,实现了状态视觉化的效果。
@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
GearItem 是装备数据模型,结构最为简洁,仅包含名称、描述和图标三个业务字段。icon 字段同样使用 Emoji,与 PostItem 的 avatar 字段保持一致的设计哲学。四个数据模型共同构成了应用的完整数据层,它们通过 @Observed 装饰器与 ArkTS 的渲染引擎建立了响应式绑定关系。
四、接口定义与静态数据数组

应用在数据模型之外,还定义了多个 interface 用于图表与课程等辅助数据的类型约束。这些接口不使用 @Observed,因为它们的内容在运行时不会发生变化,属于只读的展示型数据。
interface WeekChartItem {
label: string;
value: number;
}
interface RigChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface PlayTypeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
WeekChartItem 用于本周渔获柱状图的数据项,包含标签和数值两个字段。RigChartItem 在此基础上增加了 color 字段,用于指定每个柱条的独立颜色。CourseItem 包含课程标题、周次、进度和颜色四个字段,用于鱼情课堂的进度展示。PlayTypeItem 用于玩法类型卡片,其中的 hot 布尔值字段决定了是否显示"热门"标签。这些接口的设计体现了 ArkTS 类型系统在约束数据形状方面的优势。
const POST_LIST: PostItem[] = [
new PostItem(1, '午夜守口人', '🌙', '闸口第二竿,4 斤鲤鱼稳稳入户。', '5分钟前', 108),
new PostItem(2, '路亚夜战派', '🎣', '米诺匀收两圈,翘嘴炸水面了。', '18分钟前', 96),
new PostItem(3, '调漂强迫症', '🪝', '调四钓二找底三次,终于顿口。', '32分钟前', 82),
new PostItem(4, '浮语主理人', '📣', '今夜东湖北岸鱼口指数 78,冲。', '1小时前', 77),
new PostItem(5, '打窝大师', '🌽', '老坛玉米三勺,静等大物进窝。', '2小时前', 68),
new PostItem(6, '防蚊担当', '🦟', '驱蚊液记得补,湖边蚊子成灾。', '3小时前', 54),
new PostItem(7, '渔获记录员', '📖', '本周累计 12.6kg,个人新高。', '5小时前', 47),
new PostItem(8, '夜钓三人组', '👬', '三竿同出三尾鲫鱼,运气爆棚。', '昨天', 42)
];
POST_LIST 是钓友动态的静态初始数据,包含八条预设动态,每条动态都使用了真实的夜钓术语(如"调四钓二"“米诺匀收”"打窝"等),使得应用在首次启动时就具有丰富的内容填充。这种预填充策略不仅提升了首次使用体验,也为后续的用户生成内容(UGC)提供了格式参考。数组中的 PostItem 实例在构造时即完成属性赋值,后续通过 @State 修饰的 postList 变量引用该数组,实现动态的增删操作。
const RIG_LIST: RigItem[] = [
new RigItem(1, '夜光漂 · 芦苇尾', '3 号 · 高可视', '¥68/支', '人气', 92),
new RigItem(2, '碳素竿 · 5.4 米', '轻硬 · 28 调', '¥399/支', '进阶', 88),
new RigItem(3, '腥香饵 · 夜钓款', '500g · 拉饵', '¥22/袋', '入门', 84),
new RigItem(4, '前打轮 · 微物', '泛用 · 顺滑', '¥268/轮', '进阶', 76),
new RigItem(5, '失手绳 · 安全款', '8 米 · 弹力芯', '¥18/条', '安全', 70),
new RigItem(6, '头灯 · 感应变色', '白蓝红 · 防水', '¥79/盏', '人气', 86),
new RigItem(7, '钓箱 · 28L 保温', '可坐 · 升降脚', '¥329/只', '舒适', 72),
new RigItem(8, '挡针 · 卸鱼器', '不锈钢 · 快拆', '¥15/个', '入门', 58)
];
RIG_LIST 是钓具橱窗的静态数据,涵盖了从夜光漂到钓箱的八种钓具。每种钓具的 craft 字段都包含了专业参数(如"28 调""500g · 拉饵"等),体现了应用在垂钓领域的专业深度。hot 字段的数值用于驱动关注度进度条和排序,level 字段则通过 levelColor 纯函数映射为不同的标签颜色。
const NIGHT_LIST: NightItem[] = [
new NightItem(1, '东湖北岸 · 周三夜', '翘嘴窗口 · 22:00-1:00', '已收竿', 100),
new NightItem(2, '月湖闸口 · 今夜', '守大物 · 5.4 米竿', '守口中', 55),
new NightItem(3, '江湾回水湾', '路亚翘嘴 · 米诺', '搏鱼中', 85),
new NightItem(4, '东湖栈桥', '红虫拉饵 · 鲫鱼', '换饵中', 30),
new NightItem(5, '老码头斜坡', '打窝诱钓 · 玉米', '打窝中', 20),
new NightItem(6, '城南湿地', '周六夜 · 3 人同行', '待出发', 10),
new NightItem(7, '月湖闸口 · 上周', '4.2kg 鲤鱼入户', '已收竿', 100),
new NightItem(8, '东湖北岸 · 上周', '鲫鱼 17 尾', '已收竿', 100)
];
NIGHT_LIST 是夜钓场次的静态数据,包含了五种不同的运行状态(已收竿、守口中、搏鱼中、换饵中、待出发),这些状态通过 statusColor 函数映射为不同的颜色值,在 UI 上形成了直观的状态标识。每条数据的 name 字段都包含了真实的地名(如"东湖北岸"“月湖闸口”“江湾回水湾”),增强了应用的真实感与代入感。
五、顶层纯函数与特效计算逻辑

应用在顶层定义了多个纯函数,用于特效动画的坐标计算、颜色映射和数值转换。这些纯函数不依赖任何外部状态,仅根据输入参数返回确定性的输出,是函数式编程思想在 ArkTS 中的具体实践。
function barH(v: number, max: number): number {
return Math.round(118 * v / max);
}
function ncColor(v: number): string {
if (v > 7) {
return '#4CC97E';
} else if (v > 4) {
return '#4E9DBA';
}
return '#8FD8FF';
}
function ringS(i: number): number {
return 26 + i * 16;
}
function ringA(tick: number, i: number): number {
return 0.5 - ((tick + i * 2) % 6) * 0.07;
}
function ringX(i: number): number {
return 110 + i * 150;
}
function ringY(i: number): number {
return 170 + (i % 2) * 240;
}
barH 函数用于计算柱状图的高度,通过将数值 v 按最大值 max 归一化后乘以 118 像素的最大高度,实现了自适应的柱高计算。ncColor 函数根据数值大小返回三档颜色:大于 7 返回荧光绿,大于 4 返回湖蓝,其余返回天蓝。ringS 到 ringY 这一组函数用于涟漪扩散特效的参数计算:ringS 计算涟漪的尺寸(随索引递增),ringA 计算透明度(随 tick 周期性变化),ringX 和 ringY 计算涟漪的中心坐标(按索引排列为交错布局)。
function blinkA(tick: number, i: number): number {
if ((tick + i * 2) % 5 === 0) {
return 0.9;
}
return 0.2;
}
function blinkX(tick: number, i: number): number {
return 40 + ((tick * 3 + i * 227) % 600);
}
function blinkY(tick: number, i: number): number {
return 70 + ((tick * 5 + i * 171) % 590);
}
function lureX(tick: number, i: number): number {
return 30 + ((tick * 4 + i * 251) % 610);
}
function lureY(tick: number, i: number): number {
return 50 + ((tick * 6 + i * 149) % 620);
}
blinkA 函数实现了浮漂信号灯的闪烁逻辑:当 (tick + i * 2) % 5 等于 0 时返回高透明度 0.9(亮),否则返回低透明度 0.2(暗)。这种基于取模运算的周期性闪烁模拟了真实浮漂信号灯的脉冲节奏。blinkX 和 blinkY 函数通过模运算将坐标限制在屏幕范围内,同时 tick 的倍数系数和 i 的质数系数(227、171、251、149)确保了每个光点的运动轨迹互不相同,避免了视觉上的机械重复。lureX 和 lureY 采用类似策略,但使用了不同的系数,使得夜光漂光点与信号灯闪烁的运动模式形成差异化。
function statusColor(s: string): string {
if (s === '已收竿') {
return '#6BCB77';
} else if (s === '搏鱼中') {
return '#E06A5A';
} else if (s === '守口中') {
return '#4CC97E';
} else if (s === '换饵中') {
return '#E0B14C';
} else if (s === '打窝中') {
return '#8FD8FF';
}
return '#5E6D80';
}
function statusProgress(s: string): number {
if (s === '已收竿') {
return 100;
} else if (s === '搏鱼中') {
return 85;
} else if (s === '守口中') {
return 55;
} else if (s === '换饵中') {
return 30;
}
return 10;
}
statusColor 和 statusProgress 是一对配套函数,分别将夜钓状态字符串映射为颜色值和进度值。"已收竿"映射为成功绿和 100% 进度,"搏鱼中"映射为警示红和 85% 进度,"守口中"映射为荧光绿和 55% 进度,"换饵中"映射为警告黄和 30% 进度,默认映射为暗灰蓝和 10% 进度。这种将业务状态编码为视觉参数的做法,使得 UI 组件能够通过纯函数调用获得一致的视觉表现。
function rigTagColor(g: string): string {
if (g.indexOf('夜光') >= 0 || g.indexOf('头灯') >= 0) {
return '#4CC97E';
} else if (g.indexOf('竿') >= 0 || g.indexOf('轮') >= 0) {
return '#4E9DBA';
} else if (g.indexOf('饵') >= 0 || g.indexOf('漂') >= 0) {
return '#8FD8FF';
}
return '#E0B14C';
}
function levelColor(s: string): string {
if (s === '入门') {
return '#6BCB77';
} else if (s === '人气') {
return '#4CC97E';
} else if (s === '进阶') {
return '#4E9DBA';
} else if (s === '安全') {
return '#8FD8FF';
} else if (s === '舒适') {
return '#E0B14C';
}
return '#E06A5A';
}
rigTagColor 函数通过字符串匹配(indexOf)为钓具名称分配标签颜色:含"夜光"或"头灯"的钓具标记为荧光绿,含"竿"或"轮"的标记为湖蓝,含"饵"或"漂"的标记为天蓝,其余标记为警告黄。levelColor 函数则为五种类别(入门、人气、进阶、安全、舒适)分别映射不同的颜色。这些纯函数的设计模式使得颜色管理集中化,后续修改主题时只需调整函数内部的映射逻辑即可。
六、@Entry 组件状态声明与生命周期

应用的主组件 PageNightAnglers 使用 @Entry 和 @Component 装饰器声明为页面入口组件。在 ArkTS 中,@Entry 标记的组件是页面的根组件,每个页面只能有一个 @Entry 组件。@Component 则将一个类标记为可复用的 UI 组件,使其能够参与 ArkTS 的渲染树构建。
@Entry
@Component
struct PageNightAnglers {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State charterOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'night'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State charterTime: string = ''
@State charterSpot: string = ''
@State postList: PostItem[] = POST_LIST
@State nightList: NightItem[] = NIGHT_LIST
@State fxTimer: number = -1
@State 装饰器声明了组件的响应式状态变量。mainTab 和 subTab 分别控制底部主导航和首页子导航的当前选中索引。tick 和 glow 是特效动画驱动变量,通过定时器周期性递增来驱动特效层的帧更新。addOpen、editOpen、delOpen、charterOpen 四个布尔值分别控制四个弹框的显示状态。editIdx、delTarget、editName、editStyle、editNote 是编辑和删除操作的临时数据载体。addTitle 和 addContent 是发布浮语弹框的输入缓存。charterTime 和 charterSpot 是包船预约弹框的输入缓存。postList 和 nightList 分别引用静态数据数组,由于使用了 @Observed 类,这些数组的内容变化会自动触发 UI 更新。fxTimer 存储定时器 ID,用于在组件销毁时清理资源。
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear 是 ArkTS 组件的生命周期钩子,在组件实例创建后、UI 渲染前被调用。此处使用 setInterval 创建了一个每 90 毫秒执行一次的定时器,在回调中将 tick 递增 1 并将 glow 在 0 和 1 之间切换。90 毫秒的间隔约对应 11 FPS 的动画帧率,这种低于屏幕刷新率的帧率设计是有意为之——夜钓场景中的涟漪和灯光闪烁不需要 60 FPS 的丝滑动画,反而需要一种"缓慢、有节奏"的脉冲感来匹配夜钓的宁静氛围。
aboutToDisappear 是组件销毁前的生命周期钩子,此处负责清理定时器资源。通过检查 fxTimer 是否大于 0 来判断定时器是否仍在运行,如果是则调用 clearInterval 清除并重置为 -1。这种防御性的资源清理模式避免了组件销毁后定时器继续执行导致的内存泄漏和空指针异常。
七、弹框操作方法与业务逻辑

应用定义了四个 open 方法和四个 do 方法,分别对应四个弹框的打开和提交操作。这种"open + do"的命名约定清晰地表达了每个方法的职责边界。
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.nightList.length) {
this.editIdx = index;
this.editName = this.nightList[index].name;
this.editStyle = this.nightList[index].status;
this.editNote = this.nightList[index].item;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'night';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
openCharter(): void {
this.charterTime = '';
this.charterSpot = '';
this.charterOpen = true;
}
openAdd 方法清空发布浮语弹框的输入缓存并打开弹框。openEdit 方法接收一个索引参数,从 nightList 中读取对应场次的数据填充到编辑表单中,这种"先填充后打开"的模式确保了用户看到的编辑表单已经包含了当前场次的完整信息。openDelA 和 openDelB 方法分别设置删除目标为夜钓场次和课程,然后打开删除确认弹框。openCharter 方法清空包船预约的输入缓存并打开弹框。
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的浮语', '🎣', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.nightList.length) {
this.nightList.splice(this.editIdx, 1, new NightItem(this.nightList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'night' && this.nightList.length > 0) {
this.nightList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
doCharter(): void {
if (this.charterTime.length > 0) {
this.postList.unshift(new PostItem(998, '我的浮语', '🚤', `已预约包船夜钓:${this.charterTime}`, '刚刚', 0));
}
this.charterOpen = false;
}
doAdd 方法在标题非空时使用 unshift 将新动态插入到 postList 数组的头部,使得新发布的动态能够立即出现在列表顶部。doEdit 方法使用 splice 的替换模式(第二个参数为 1)将指定索引的夜钓场次替换为编辑后的新对象,同时将进度重置为 40%。doDel 方法根据 delTarget 的值决定删除 nightList 或 postList 的首元素。doCharter 方法在时间非空时生成一条包船预约动态并插入到 postList 头部。所有 do 方法在执行完业务逻辑后都会将对应的弹框状态置为 false,实现"操作即关闭"的交互模式。
八、fxLayer 特效动画层构建器

fxLayer 是应用中最具视觉特色的 @Builder 方法,它构建了一个包含涟漪扩散、浮漂信号灯闪烁和夜光漂光点三种特效的全屏动画层。
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3], (i: number) => {
Column()
.width(ringS(i))
.height(ringS(i))
.borderRadius(ringS(i) / 2)
.border({ width: 1, color: COLORS.accent, radius: ringS(i) / 2 })
.opacity(ringA(this.tick, i))
.translate({ x: ringX(i), y: ringY(i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Column()
.width(4)
.height(4)
.borderRadius(2)
.backgroundColor(COLORS.accent)
.opacity(blinkA(this.tick, i))
.translate({ x: blinkX(this.tick, i), y: blinkY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4], (i: number) => {
Column()
.width(3)
.height(3)
.borderRadius(2)
.backgroundColor(COLORS.lure)
.opacity(0.4)
.translate({ x: lureX(this.tick, i), y: lureY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
fxLayer 使用 Stack 作为容器,内含三组 ForEach 渲染的特效元素。第一组是四个涟漪扩散圆环,每个圆环通过 Column 组件设置宽高相等的值(由 ringS 函数计算),borderRadius 设为尺寸的一半形成圆形,border 属性设置 1 像素的荧光绿边框,opacity 由 ringA 函数根据 tick 值计算实现周期性淡入淡出,translate 属性通过 ringX 和 ringY 函数设置固定位置。第二组是六个浮漂信号灯闪烁点,每个点为 4x4 像素的荧光绿圆点,opacity 由 blinkA 函数实现脉冲式闪烁,位置由 blinkX 和 blinkY 函数根据 tick 值动态计算,实现了在屏幕上缓慢游移的效果。第三组是五个夜光漂光点,每个点为 3x3 像素的天蓝色(lure 色)圆点,固定透明度 0.4,位置由 lureX 和 lureY 函数计算。
整个特效层的最后一个属性 hitTestBehavior(HitTestMode.None) 是关键设计——它将特效层设置为不响应任何触摸事件,使得所有触摸事件能够穿透特效层传递到下方的功能层。这种设计确保了特效动画不会干扰用户的正常交互操作。
九、header 头部区域构建器
header 构建器负责渲染页面顶部的品牌信息、鱼情数据和安全横幅等内容,是信息密度最高的 UI 区域之一。
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('🎣 浮语夜钓')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('📍东湖北岸 · 水温 18℃ · 湿度 76%')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text('鱼口指数')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('78')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('今夜在钓')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('34 人')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
头部区域的第一行使用 Row 布局分为三个区域:左侧是品牌名与位置信息,使用 layoutWeight(1) 占据剩余空间;中间是"鱼口指数"数据卡,显示当前鱼口活跃度为 78;右侧是"今夜在钓"数据卡,显示当前在线钓友数为 34 人。三个区域的字号从 17 到 9 递减,形成了清晰的信息层级。鱼口指数使用荧光绿强调,在线人数使用湖蓝色,两种颜色在暗色背景下具有较高的辨识度。
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('翘嘴开口期 · 午夜窗口')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('22:00-01:00 · 米诺匀收 · 建议路亚')
.fontSize(10)
.fontColor('#EAF4F9')
Row({ space: 6 }) {
Text('高爆口')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accent)
.borderRadius(6)
Text('闸口下泄流 · 抢先到')
.fontSize(9)
.fontColor('#EAF4F9')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('包船夜钓')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.accent)
.borderRadius(14)
.onClick(() => {
this.openCharter();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.primary)
.borderRadius(16)
鱼情卡是头部区域的视觉焦点,使用主色(#4E9DBA)作为背景,内部文字使用白色和浅蓝色(#EAF4F9)以确保在蓝色背景上的可读性。卡片左侧展示了翘嘴开口期的时间窗口和推荐钓法,右侧的"包船夜钓"按钮使用辅色(#4CC97E)背景,点击后调用 openCharter 方法打开包船预约弹框。这种"信息卡 + 行动按钮"的组合模式是移动端常见的转化设计。
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('本周大物')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('4.2 kg')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('人均渔获')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('2.6 kg')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('今夜场次')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('6 场')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('渔币')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('¥60')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
数据四格区域使用 Row 等分四列,每列展示一个关键指标:本周大物(4.2 kg,荧光绿)、人均渔获(2.6 kg,湖蓝)、今夜场次(6 场,成功绿)、渔币(¥60,警告黄)。四个数据卡使用相同的布局结构(layoutWeight(1) + padding(8) + cardBg + borderRadius(10)),通过不同的强调色区分数据类型。这种四宫格布局是仪表板设计的经典模式,能够在有限的空间内传达丰富的关键信息。
十、subNav 鱼漂顶帽式子导航构建器
subNav 构建器是应用最具特色的 UI 组件之一,它通过 Column 嵌套实现了"鱼漂顶帽式"的子导航视觉风格。
@Builder
subNav() {
Scroll() {
Row({ space: 13 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Column({ space: 2 }) {
Column()
.width(10)
.height(3)
.borderRadius(2)
.backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.border)
Column()
.width(3)
.height(12)
.borderRadius(2)
.backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.border)
Text(item)
.fontSize(this.subTab === idx ? 13 : 11)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.accent : COLORS.textSecondary)
}
.padding({ top: 2, bottom: 6 })
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 6, bottom: 10 })
}
subNav 使用 Scroll 包裹 Row 实现水平滚动导航。每个导航项由一个 Column 容器包裹三个子元素:顶部是一个 10x3 像素的短横条(模拟浮漂顶帽),中间是一个 3x12 像素的竖条(模拟浮漂漂身),底部是文本标签。当选中时(this.subTab === idx),短横条和竖条均使用荧光绿(accent 色),文本变为加粗的 13 号字;未选中时使用边框色(border),文本为常规的 11 号字。这种通过两个简单几何图形组合模拟浮漂造型的设计,体现了 ArkTS 声明式 UI 在自定义视觉元素方面的灵活性。外层 Scroll 组件设置了 scrollable(ScrollDirection.Horizontal) 和 scrollBar(BarState.Off),使得七个导航项能够水平滚动且不显示滚动条。
十一、pageFeatured 精选页面构建器
pageFeatured 是首页默认展示的精选内容页面,集成了玩法四格、柱状图、钓法构成、钓具橱窗和钓友动态五个功能模块。
@Builder
pageFeatured() {
Column({ space: 12 }) {
Column() {
Text('在浮语能玩什么')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row({ space: 8 }) {
ForEach(TYPE_LIST, (it: PlayTypeItem) => {
Column({ space: 4 }) {
Text(it.icon)
.fontSize(24)
Text(it.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(9)
.fontColor(COLORS.textHint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(it.hot ? '热门' : ' ')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(it.hot ? COLORS.danger : COLORS.border)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 8, left: 4, right: 4 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}, (it: PlayTypeItem) => it.name)
}
.width('100%')
}
.width('100%')
玩法四格模块使用 ForEach 遍历 TYPE_LIST 数组,每个玩法卡片包含图标(24 号字)、名称(11 号加粗)、描述(9 号,单行省略)和热门标签(条件渲染为红色或空)。maxLines(1) 和 textOverflow({ overflow: TextOverflow.Ellipsis }) 的组合确保了描述文本不会换行溢出,而是以省略号截断。四个卡片使用 layoutWeight(1) 等分宽度,在卡片背景下形成均匀的四列布局。
Column() {
Row() {
Text('本周每日渔获重量')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('单位 kg')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row({ space: 10 }) {
ForEach(WEEK_CHART, (it: WeekChartItem) => {
Column({ space: 4 }) {
Column()
.width(20)
.height(barH(it.value, 12))
.borderRadius(5)
.backgroundColor(ncColor(it.value))
Text(`${it.value}`)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(it.label)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (it: WeekChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(140)
.margin({ top: 12 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
柱状图模块使用 ForEach 遍历 WEEK_CHART 数组,每个柱条是一个 Column 组件,宽度固定为 20 像素,高度由 barH 函数根据数值和最大值 12 计算得出。柱条颜色由 ncColor 函数根据数值大小返回三档不同的颜色。柱条下方依次显示数值和星期标签。整个柱状图区域的 Row 设置了 alignItems(VerticalAlign.Bottom),使得所有柱条底部对齐,形成标准的柱状图视觉效果。这种纯 ArkTS 声明式方式绘制的柱状图无需引入任何第三方图表库,体现了框架本身在数据可视化方面的原生能力。
十二、pageCrafts 我的夜钓页面与进度条实现
pageCrafts 页面展示用户的夜钓场次列表,每条场次包含状态标签、进度条和编辑入口。
@Builder
pageCrafts() {
Column({ space: 10 }) {
Text('我的夜钓')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.nightList, (it: NightItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🌙')
.fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(`🧾 ${it.item}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${statusProgress(it.status)}%`)
.height(6)
.borderRadius(3)
.backgroundColor(statusColor(it.status))
}
.width('100%')
Row() {
Text(`钓况进度 ${statusProgress(it.status)}%`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('编辑钓况单')
.fontSize(11)
.fontColor(COLORS.accent)
.onClick(() => {
this.openEdit(idx);
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: NightItem) => it.id.toString())
}
.width('100%')
}
每条夜钓场次卡片使用 Column 垂直布局,包含三个主要区域:顶部是 Row 展示月亮图标和场次信息(名称、状态标签、钓法描述),中间是 Stack 实现的进度条,底部是 Row 展示进度文字和编辑入口。进度条使用 Stack 容器嵌套两个 Row 实现:底层 Row 宽度 100% 作为轨道(border 色),上层 Row 宽度通过模板字符串 ${statusProgress(it.status)}% 动态设置百分比作为填充条(statusColor 映射的颜色)。这种 Stack 嵌套双 Row 的模式是 ArkTS 中实现进度条的标准做法,无需引入 ProgressBar 组件即可实现高度自定义的进度条视觉。编辑入口通过 onClick 回调调用 openEdit(idx) 方法,将当前卡片的索引传递给编辑弹框。
十三、弹框组件构建器群
应用定义了 modalOverlay 和四个 *ModalBody 构建器,分别实现遮罩层和四个弹框的内容区域。
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.7)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
this.charterOpen = false;
})
}
.width('100%')
.height('100%')
}
modalOverlay 构建器渲染了一个全屏的半透明黑色遮罩层,透明度为 0.7(暗色主题下需要更高的遮罩透明度才能形成足够的视觉层次)。遮罩层的 onClick 回调将所有四个弹框状态同时置为 false,实现了点击遮罩关闭任意弹框的统一行为。
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布浮语')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '一句话夜钓日常…', text: this.addTitle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addTitle = v;
})
TextArea({ placeholder: '补充细节:钓点、钓组、渔获…', text: this.addContent })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(90)
.onChange((v: string) => {
this.addContent = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.addOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doAdd();
})
Text('发布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
addModalBody 构建器渲染了发布浮语弹框的内容区域,包含标题文本、TextInput 输入框(标题)、TextArea 文本域(详情)和底部按钮行。TextInput 和 TextArea 的 onChange 回调将输入值同步到 @State 变量中,实现了双向数据绑定。底部按钮行使用了 Button + Text 叠加的模式:Button 组件提供点击区域和背景色,Text 组件通过负 margin 叠加在 Button 上方提供文字标签。这种模式是 ArkTS 中实现自定义样式按钮的常见技巧。
@Builder
charterModalBody() {
Column({ space: 12 }) {
Text('夜钓包船预约')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '出钓时间(如 周六 20:00)', text: this.charterTime })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.charterTime = v;
})
TextInput({ placeholder: '钓点(如 东湖北岸闸口)', text: this.charterSpot })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.charterSpot = v;
})
Text('船长确认鱼情与天气后短信通知 · 含窝料与头灯租借')
.fontSize(11)
.fontColor(COLORS.textHint)
.width('100%')
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.charterOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.accent)
.borderRadius(8)
.onClick(() => {
this.doCharter();
})
Text('提交预约')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -56 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
charterModalBody 构建器渲染了夜钓包船预约弹框,包含出钓时间和钓点两个输入框,以及一条说明文本(“船长确认鱼情与天气后短信通知”)。提交按钮使用辅色(accent 绿色)背景,与发布浮语弹框的主色背景形成视觉区分,帮助用户快速识别不同操作的功能属性。
十四、build 方法主架构与弹框叠加渲染
build 方法是 ArkTS 组件的渲染入口,它通过 Stack 的层级叠加实现了背景层、特效层、内容层和弹框层的分层渲染。
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() {
this.editModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() {
this.delModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.charterOpen) {
Stack() {
this.modalOverlay()
Column() {
this.charterModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
}
build 方法的 Stack 容器中,第一个子元素是全屏背景色(暗色 #0F1620),第二个是 fxLayer 特效层(hitTestBehavior 为 None 不阻挡点击),第三个是主内容层(包含 mainContent 和 bottomBar),第四到七个是四个条件渲染的弹框层。每个弹框层都是一个 Stack,内含 modalOverlay 遮罩和一个居中的弹框内容容器(宽度 88%,最大高度 80%,圆角 16,zIndex 999)。条件渲染使用 if 语句检查对应的 @State 布尔值,当值为 true 时才渲染对应的弹框 Stack。这种基于条件渲染的弹框管理方式,使得弹框的显示与隐藏完全由状态变量驱动,符合声明式 UI 的设计原则。
十五、Mermaid 架构流程图
以下是浮语·城市夜钓应用的组件架构与数据流图:
十六、数据模型与组件对比表格
| 维度 | PostItem | RigItem | NightItem | GearItem |
|---|---|---|---|---|
| 装饰器 | @Observed | @Observed | @Observed | @Observed |
| 业务领域 | 钓友社区动态 | 钓具橱窗展示 | 夜钓场次管理 | 装备库管理 |
| 字段数量 | 6 | 6 | 5 | 4 |
| 核心字段 | text, likes | name, price, hot | status, progress | name, desc |
| 静态数组 | POST_LIST (8条) | RIG_LIST (8条) | NIGHT_LIST (8条) | GEAR_LIST (8条) |
| 可变操作 | unshift/splice | 只读 | splice替换/splice删除 | 只读 |
| 颜色映射函数 | - | rigTagColor, levelColor | statusColor, statusProgress | - |
| 关联弹框 | addModalBody | - | editModalBody, delModalBody | - |
| 关联页面 | pageFeatured, pageCircle, pageMine | pageMarket, pageWorkshop | pageCrafts, pageMine | pageGear, pageWorkshop |
| 特殊字段 | avatar (Emoji) | craft, level | item (钓法描述) | icon (Emoji) |
| 维度 | fxLayer 特效层 | header 头部 | subNav 子导航 | modalOverlay 遮罩 |
|---|---|---|---|---|
| 构建器类型 | @Builder | @Builder | @Builder | @Builder |
| 容器组件 | Stack | Column | Scroll > Row | Stack |
| 渲染元素 | 涟漪+信号灯+光点 | 品牌行+鱼情卡+数据格 | 7个鱼漂顶帽式Tab | 全屏半透明黑色 |
| 动画驱动 | tick (90ms) | 无 | 无 | 无 |
| 点击行为 | HitTestMode.None | 包船预约跳转 | subTab切换 | 关闭所有弹框 |
| 视觉特色 | 荧光绿+天蓝光点 | 主色背景鱼情卡 | 短横条+竖条模拟浮漂 | opacity 0.7 |
| 数据依赖 | tick, glow | 静态文本 | SUB_NAV_LIST | 无 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
// 场景:城市夜钓 / 钓组搭配 / 鱼情播报 / 钓友社区
// 底部4主tab(首页/橱窗/课堂/我的)+ 首页7内容tab(鱼漂顶帽式)
// 特效:涟漪扩散 + 浮漂信号灯闪 + 夜光漂光点(特效层位于底层,不遮挡点击)
// 弹框:新增(发浮语) / 编辑(编辑钓况单) / 删除(收竿撤单·退课) / 包船(夜钓包船预约)
interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
lure: string;
}
const COLORS: ColorPalette = {
primary: '#4E9DBA',
primaryLight: '#243B52',
primaryDark: '#2E6780',
accent: '#4CC97E',
accentLight: '#1D3A2B',
bg: '#0F1620',
cardBg: '#18202E',
textPrimary: '#E8EDF4',
textSecondary: '#A7B4C4',
textHint: '#5E6D80',
border: '#2A3547',
success: '#6BCB77',
warning: '#E0B14C',
danger: '#E06A5A',
white: '#FFFFFF',
lure: '#8FD8FF'
};
// ============ 钓友动态数据模型 ============
@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
// ============ 钓具数据模型 ============
@Observed
export class RigItem {
id: number = 0
name: string = ''
craft: string = ''
price: string = ''
level: string = ''
hot: number = 0
constructor(id: number, name: string, craft: string, price: string, level: string, hot: number) {
this.id = id; this.name = name; this.craft = craft; this.price = price
this.level = level; this.hot = hot
}
}
// ============ 夜钓场次数据模型 ============
@Observed
export class NightItem {
id: number = 0
name: string = ''
item: string = ''
status: string = ''
progress: number = 0
constructor(id: number, name: string, item: string, status: string, progress: number) {
this.id = id; this.name = name; this.item = item; this.status = status
this.progress = progress
}
}
// ============ 装备数据模型 ============
@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
// ============ 图表与课程接口 ============
interface WeekChartItem {
label: string;
value: number;
}
interface RigChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface PlayTypeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
new PostItem(1, '午夜守口人', '🌙', '闸口第二竿,4 斤鲤鱼稳稳入户。', '5分钟前', 108),
new PostItem(2, '路亚夜战派', '🎣', '米诺匀收两圈,翘嘴炸水面了。', '18分钟前', 96),
new PostItem(3, '调漂强迫症', '🪝', '调四钓二找底三次,终于顿口。', '32分钟前', 82),
new PostItem(4, '浮语主理人', '📣', '今夜东湖北岸鱼口指数 78,冲。', '1小时前', 77),
new PostItem(5, '打窝大师', '🌽', '老坛玉米三勺,静等大物进窝。', '2小时前', 68),
new PostItem(6, '防蚊担当', '🦟', '驱蚊液记得补,湖边蚊子成灾。', '3小时前', 54),
new PostItem(7, '渔获记录员', '📖', '本周累计 12.6kg,个人新高。', '5小时前', 47),
new PostItem(8, '夜钓三人组', '👬', '三竿同出三尾鲫鱼,运气爆棚。', '昨天', 42)
];
const RIG_LIST: RigItem[] = [
new RigItem(1, '夜光漂 · 芦苇尾', '3 号 · 高可视', '¥68/支', '人气', 92),
new RigItem(2, '碳素竿 · 5.4 米', '轻硬 · 28 调', '¥399/支', '进阶', 88),
new RigItem(3, '腥香饵 · 夜钓款', '500g · 拉饵', '¥22/袋', '入门', 84),
new RigItem(4, '前打轮 · 微物', '泛用 · 顺滑', '¥268/轮', '进阶', 76),
new RigItem(5, '失手绳 · 安全款', '8 米 · 弹力芯', '¥18/条', '安全', 70),
new RigItem(6, '头灯 · 感应变色', '白蓝红 · 防水', '¥79/盏', '人气', 86),
new RigItem(7, '钓箱 · 28L 保温', '可坐 · 升降脚', '¥329/只', '舒适', 72),
new RigItem(8, '挡针 · 卸鱼器', '不锈钢 · 快拆', '¥15/个', '入门', 58)
];
const NIGHT_LIST: NightItem[] = [
new NightItem(1, '东湖北岸 · 周三夜', '翘嘴窗口 · 22:00-1:00', '已收竿', 100),
new NightItem(2, '月湖闸口 · 今夜', '守大物 · 5.4 米竿', '守口中', 55),
new NightItem(3, '江湾回水湾', '路亚翘嘴 · 米诺', '搏鱼中', 85),
new NightItem(4, '东湖栈桥', '红虫拉饵 · 鲫鱼', '换饵中', 30),
new NightItem(5, '老码头斜坡', '打窝诱钓 · 玉米', '打窝中', 20),
new NightItem(6, '城南湿地', '周六夜 · 3 人同行', '待出发', 10),
new NightItem(7, '月湖闸口 · 上周', '4.2kg 鲤鱼入户', '已收竿', 100),
new NightItem(8, '东湖北岸 · 上周', '鲫鱼 17 尾', '已收竿', 100)
];
const GEAR_LIST: GearItem[] = [
new GearItem(1, '夜光漂 · 三支装', '3/4/5 号各一', '🎣'),
new GearItem(2, '饵料盘 · 折叠', '双格 · 带盖', '🍽️'),
new GearItem(3, '子线盒 · 防缠', '0.4-1.5 号', '📦'),
new GearItem(4, '驱蚊液 · 长效', '夜钓 8 小时', '🦟'),
new GearItem(5, '保温壶 · 1L', '热茶续命', '🍵'),
new GearItem(6, '鱼护 · 大孔', '3 米 · 折叠', '🧺'),
new GearItem(7, '夜钓椅 · 低背', '可躺 · 便携', '💺'),
new GearItem(8, '抄网 · 伸缩', '3 米 · 大口', '🕸️')
];
const WEEK_CHART: WeekChartItem[] = [
{ label: '周一', value: 2 },
{ label: '周二', value: 4 },
{ label: '周三', value: 3 },
{ label: '周四', value: 5 },
{ label: '周五', value: 7 },
{ label: '周六', value: 12 },
{ label: '周日', value: 9 }
];
const RIG_CHART: RigChartItem[] = [
{ label: '路亚', value: 30, color: '#4E9DBA' },
{ label: '台钓', value: 26, color: '#4CC97E' },
{ label: '前打', value: 16, color: '#8FD8FF' },
{ label: '海竿', value: 14, color: '#E0B14C' },
{ label: '溪流', value: 14, color: '#6BCB77' }
];
const TYPE_LIST: PlayTypeItem[] = [
{ name: '夜钓守口', desc: '大物 · 通宵 · 灯光', icon: '🌙', hot: true },
{ name: '路亚夜战', desc: '翘嘴 · 米诺 · 收线', icon: '🎣', hot: true },
{ name: '溪流野钓', desc: '活水 · 轻装 · 冷水', icon: '🏞️', hot: false },
{ name: '包船夜钓', desc: '包位 · 窝料 · 带飞', icon: '🚤', hot: false }
];
const COURSE_LIST: CourseItem[] = [
{ title: '夜钓安全与水域法规', week: '第 1 周', progress: 100, color: '#4E9DBA' },
{ title: '季节鱼情与窗口期', week: '第 2 周', progress: 82, color: '#4CC97E' },
{ title: '线组搭配与调漂', week: '第 3 周', progress: 60, color: '#8FD8FF' },
{ title: '饵料状态与窝料配比', week: '第 4 周', progress: 33, color: '#6BCB77' },
{ title: '大物搏鱼与卸鱼护鱼', week: '第 5 周', progress: 12, color: '#E0B14C' }
];
// ============ 导航配置 ============
interface NavItem {
icon: string;
label: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🎣', label: '首页' },
{ icon: '🌙', label: '橱窗' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '钓具橱窗', '我的夜钓', '线组工坊', '鱼情课堂', '钓友圈', '装备库'];
// ============ 顶层纯函数 ============
function barH(v: number, max: number): number {
return Math.round(118 * v / max);
}
function ncColor(v: number): string {
if (v > 7) {
return '#4CC97E';
} else if (v > 4) {
return '#4E9DBA';
}
return '#8FD8FF';
}
function ringS(i: number): number {
return 26 + i * 16;
}
function ringA(tick: number, i: number): number {
return 0.5 - ((tick + i * 2) % 6) * 0.07;
}
function ringX(i: number): number {
return 110 + i * 150;
}
function ringY(i: number): number {
return 170 + (i % 2) * 240;
}
function blinkA(tick: number, i: number): number {
if ((tick + i * 2) % 5 === 0) {
return 0.9;
}
return 0.2;
}
function blinkX(tick: number, i: number): number {
return 40 + ((tick * 3 + i * 227) % 600);
}
function blinkY(tick: number, i: number): number {
return 70 + ((tick * 5 + i * 171) % 590);
}
function lureX(tick: number, i: number): number {
return 30 + ((tick * 4 + i * 251) % 610);
}
function lureY(tick: number, i: number): number {
return 50 + ((tick * 6 + i * 149) % 620);
}
function statusColor(s: string): string {
if (s === '已收竿') {
return '#6BCB77';
} else if (s === '搏鱼中') {
return '#E06A5A';
} else if (s === '守口中') {
return '#4CC97E';
} else if (s === '换饵中') {
return '#E0B14C';
} else if (s === '打窝中') {
return '#8FD8FF';
}
return '#5E6D80';
}
function statusProgress(s: string): number {
if (s === '已收竿') {
return 100;
} else if (s === '搏鱼中') {
return 85;
} else if (s === '守口中') {
return 55;
} else if (s === '换饵中') {
return 30;
}
return 10;
}
function rigTagColor(g: string): string {
if (g.indexOf('夜光') >= 0 || g.indexOf('头灯') >= 0) {
return '#4CC97E';
} else if (g.indexOf('竿') >= 0 || g.indexOf('轮') >= 0) {
return '#4E9DBA';
} else if (g.indexOf('饵') >= 0 || g.indexOf('漂') >= 0) {
return '#8FD8FF';
}
return '#E0B14C';
}
function levelColor(s: string): string {
if (s === '入门') {
return '#6BCB77';
} else if (s === '人气') {
return '#4CC97E';
} else if (s === '进阶') {
return '#4E9DBA';
} else if (s === '安全') {
return '#8FD8FF';
} else if (s === '舒适') {
return '#E0B14C';
}
return '#E06A5A';
}
@Entry
@Component
struct PageNightAnglers {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State charterOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'night'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State charterTime: string = ''
@State charterSpot: string = ''
@State postList: PostItem[] = POST_LIST
@State nightList: NightItem[] = NIGHT_LIST
@State fxTimer: number = -1
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.nightList.length) {
this.editIdx = index;
this.editName = this.nightList[index].name;
this.editStyle = this.nightList[index].status;
this.editNote = this.nightList[index].item;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'night';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
openCharter(): void {
this.charterTime = '';
this.charterSpot = '';
this.charterOpen = true;
}
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的浮语', '🎣', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.nightList.length) {
this.nightList.splice(this.editIdx, 1, new NightItem(this.nightList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'night' && this.nightList.length > 0) {
this.nightList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
doCharter(): void {
if (this.charterTime.length > 0) {
this.postList.unshift(new PostItem(998, '我的浮语', '🚤', `已预约包船夜钓:${this.charterTime}`, '刚刚', 0));
}
this.charterOpen = false;
}
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3], (i: number) => {
Column()
.width(ringS(i))
.height(ringS(i))
.borderRadius(ringS(i) / 2)
.border({ width: 1, color: COLORS.accent, radius: ringS(i) / 2 })
.opacity(ringA(this.tick, i))
.translate({ x: ringX(i), y: ringY(i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5], (i: number) => {
Column()
.width(4)
.height(4)
.borderRadius(2)
.backgroundColor(COLORS.accent)
.opacity(blinkA(this.tick, i))
.translate({ x: blinkX(this.tick, i), y: blinkY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4], (i: number) => {
Column()
.width(3)
.height(3)
.borderRadius(2)
.backgroundColor(COLORS.lure)
.opacity(0.4)
.translate({ x: lureX(this.tick, i), y: lureY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
@Builder
header() {
Column({ space: 10 }) {
// 品牌行 + 鱼情
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('🎣 浮语夜钓')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('📍东湖北岸 · 水温 18℃ · 湿度 76%')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Column({ space: 3 }) {
Text('鱼口指数')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('78'
)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.alignItems(HorizontalAlign.Center)
Column({ space: 3 }) {
Text('今夜在钓')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('34 人')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 鱼情卡
Row({ space: 12 }) {
Column({ space: 4 }) {
Text('翘嘴开口期 · 午夜窗口')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('22:00-01:00 · 米诺匀收 · 建议路亚')
.fontSize(10)
.fontColor('#EAF4F9')
Row({ space: 6 }) {
Text('高爆口')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accent)
.borderRadius(6)
Text('闸口下泄流 · 抢先到'
)
.fontSize(9)
.fontColor('#EAF4F9')
}
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('包船夜钓')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 8, bottom: 8 })
.backgroundColor(COLORS.accent)
.borderRadius(14)
.onClick(() => {
this.openCharter();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.primary)
.borderRadius(16)
// 安全横幅
Row({ space: 10 }) {
Text('🦺')
.fontSize(22)
Column({ space: 2 }) {
Text('夜钓安全周 · 结伴同行减 20')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('救生衣免费借 · 离水报告有奖 · 全程护鱼')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('去结伴')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.onClick(() => {
this.openCharter();
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 数据四格
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('本周大物')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('4.2 kg')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('人均渔获')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('2.6 kg')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('今夜场次')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('6 场')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('渔币')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('¥60')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
}
.width('100%')
.padding({ top: 10, bottom: 4 })
}
@Builder
subNav() {
Scroll() {
Row({ space: 13 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Column({ space: 2 }) {
Column()
.width(10)
.height(3)
.borderRadius(2)
.backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.border)
Column()
.width(3)
.height(12)
.borderRadius(2)
.backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.border)
Text(item)
.fontSize(this.subTab === idx ? 13 : 11)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.accent : COLORS.textSecondary)
}
.padding({ top: 2, bottom: 6 })
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
.alignItems(VerticalAlign.Top)
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 6, bottom: 10 })
}
@Builder
pageFeatured() {
Column({ space: 12 }) {
// 玩法四格
Column() {
Text('在浮语能玩什么')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row({ space: 8 }) {
ForEach(TYPE_LIST, (it: PlayTypeItem) => {
Column({ space: 4 }) {
Text(it.icon)
.fontSize(24)
Text(it.name)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(9)
.fontColor(COLORS.textHint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(it.hot ? '热门' : ' ')
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(it.hot ? COLORS.danger : COLORS.border)
}
.layoutWeight(1)
.padding({ top: 10, bottom: 8, left: 4, right: 4 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}, (it: PlayTypeItem) => it.name)
}
.width('100%')
}
.width('100%')
// 本周渔获柱状图
Column() {
Row() {
Text('本周每日渔获重量')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('单位 kg')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row({ space: 10 }) {
ForEach(WEEK_CHART, (it: WeekChartItem) => {
Column({ space: 4 }) {
Column()
.width(20)
.height(barH(it.value, 12))
.borderRadius(5)
.backgroundColor(ncColor(it.value))
Text(`${it.value}`)
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text(it.label)
.fontSize(9)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (it: WeekChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(140)
.margin({ top: 12 })
Row({ space: 6 }) {
Text('周六夜渔获占比')
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('38%')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 钓法构成
Column() {
Text('我的夜钓钓法构成')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(RIG_CHART, (it: RigChartItem) => {
Row({ space: 8 }) {
Text(it.label)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(70)
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.value}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.layoutWeight(1)
Text(`${it.value}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (it: RigChartItem) => it.label)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 人气钓具双列
Column() {
Row() {
Text('人气钓具橱窗')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('查看全部')
.fontSize(11)
.fontColor(COLORS.primary)
}
.width('100%')
.margin({ bottom: 10 })
ForEach(RIG_LIST, (it: RigItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < RIG_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('🎣')
.fontSize(22)
.layoutWeight(1)
Text(RIG_LIST[idx].level)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(RIG_LIST[idx].level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(RIG_LIST[idx].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${RIG_LIST[idx].price} · 关注 ${RIG_LIST[idx].hot}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < RIG_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('🌙')
.fontSize(22)
.layoutWeight(1)
Text(RIG_LIST[idx + 1].level)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(RIG_LIST[idx + 1].level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(RIG_LIST[idx + 1].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${RIG_LIST[idx + 1].price} · 关注 ${RIG_LIST[idx + 1].hot}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: RigItem) => it.id.toString())
}
.width('100%')
// 钓友动态
Column({ space: 10 }) {
Row() {
Text('钓友动态')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发浮语')
.fontSize(12)
.fontColor(COLORS.accent)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(22)
Column({ space: 3 }) {
Row() {
Text(it.nick)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`🎣 ${it.likes}`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
.width('100%')
}
@Builder
pageMarket() {
Column({ space: 10 }) {
Row() {
Text('钓具橱窗')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('上架我的钓具')
.fontSize(12)
.fontColor(COLORS.accent)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(RIG_LIST, (it: RigItem) => {
Column({ space: 6 }) {
Row() {
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(rigTagColor(it.name))
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.level)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(it.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg)
.borderRadius(6)
}
.width('100%')
Row({ space: 8 }) {
Text(`💰 ${it.price}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`🧾 ${it.craft}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${it.hot}%`)
.height(6)
.borderRadius(3)
.backgroundColor(rigTagColor(it.name))
}
.width('100%')
Row({ space: 10 }) {
Text(`关注度 ${it.hot}`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('加入购物车')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.openCharter();
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: RigItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageCrafts() {
Column({ space: 10 }) {
Text('我的夜钓')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.nightList, (it: NightItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🌙')
.fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(`🧾 ${it.item}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${statusProgress(it.status)}%`)
.height(6)
.borderRadius(3)
.backgroundColor(statusColor(it.status))
}
.width('100%')
Row() {
Text(`钓况进度 ${statusProgress(it.status)}%`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('编辑钓况单')
.fontSize(11)
.fontColor(COLORS.accent)
.onClick(() => {
this.openEdit(idx);
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: NightItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageWorkshop() {
Column({ space: 10 }) {
Text('线组工坊')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('主线')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('1.5 号')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('子线')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('0.8 号')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('漂号')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('夜光 3 号')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.warning)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
Column({ space: 2 }) {
Text('调钓')
.fontSize(10)
.fontColor(COLORS.textHint)
Text('调四钓二')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.success)
}
.layoutWeight(1)
.padding(8)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(RIG_LIST.slice(0, 5), (it: RigItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.accent : COLORS.textHint)
.width(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${it.craft} · ${it.price}`)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(`关注 ${it.hot}`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(rigTagColor(it.name))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: RigItem) => it.id.toString())
Text('装备速购')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ top: 4 })
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx].icon)
.fontSize(26)
Text(GEAR_LIST[idx].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.accent)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
if (idx + 1 < GEAR_LIST.length) {
Column({ space: 6 }) {
Text(GEAR_LIST[idx + 1].icon)
.fontSize(26)
Text(GEAR_LIST[idx + 1].name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(GEAR_LIST[idx + 1].desc)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text('加购')
.fontSize(11)
.fontColor(COLORS.accent)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
}
.width('100%')
}
}, (it: GearItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('鱼情课堂')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.week)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.width('100%')
Row() {
Text('已学')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(`${it.progress}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: CourseItem) => it.title)
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelB();
})
Text('退课')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -28, top: -32 })
}
.width('100%')
}
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('钓友圈')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发浮语')
.fontSize(12)
.fontColor(COLORS.accent)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(13)
.fontColor(COLORS.textSecondary)
Text(`🎣 ${it.likes} · 💬 回复`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
@Builder
pageGear() {
Column({ space: 10 }) {
Text('装备库')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🎣')
.fontSize(28)
Text('钓组区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('漂 · 子线 · 钩'
)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('🍵')
.fontSize(28)
Text('补给区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('保温壶 · 驱蚊液')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
Column({ space: 4 }) {
Text('🧺')
.fontSize(28)
Text('护鱼区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('鱼护 · 抄网')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
}
.width('100%')
ForEach(GEAR_LIST, (it: GearItem) => {
Row({ space: 10 }) {
Text(it.icon)
.fontSize(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('加购')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.primary)
.borderRadius(8)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: GearItem) => it.id.toString())
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelA();
})
Text('收竿撤首场')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -70, top: -32 })
}
.width('100%')
}
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🎣')
.fontSize(40)
Column({ space: 4 }) {
Text('夜湖守钓人')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('银漂会员 · 夜钓 58 场 · 累计 76kg · 陪伴 110 天')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的夜钓档案')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.nightList.length} 场`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.nightList.slice(0, 3), (it: NightItem) => {
Row() {
Text(it.name)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.status)
.fontSize(11)
.fontColor(statusColor(it.status))
}
.width('100%')
}, (it: NightItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
Column({ space: 10 }) {
Row() {
Text('我的浮语')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(`${this.postList.length} 条`)
.fontSize(12)
.fontColor(COLORS.primary)
}
.width('100%')
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row() {
Text(it.nick)
.fontSize(13)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(it.time)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
}, (it: PostItem) => it.id.toString())
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}
.width('100%')
}
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.7)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
this.charterOpen = false;
})
}
.width('100%')
.height('100%')
}
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布浮语')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '一句话夜钓日常…', text: this.addTitle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addTitle = v;
})
TextArea({ placeholder: '补充细节:钓点、钓组、渔获…', text: this.addContent })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(90)
.onChange((v: string) => {
this.addContent = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.addOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doAdd();
})
Text('发布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('编辑钓况单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '场次名称', text: this.editName })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editName = v;
})
TextInput({ placeholder: '状态(如 守口中)', text: this.editStyle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.editStyle = v;
})
TextArea({ placeholder: '钓况与备注', text: this.editNote })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(70)
.onChange((v: string) => {
this.editNote = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.editOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doEdit();
})
Text('保存')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
delModalBody() {
Column({ space: 12 }) {
Text(this.delTarget === 'night' ? '收竿撤场' : '退出鱼情课堂')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(this.delTarget === 'night' ? '将撤销首场进行中的夜钓并收拢装备,确认执行?' : '将退出首门进行中的课程,确认执行?')
.fontSize(13)
.fontColor(COLORS.textSecondary)
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.delOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.danger)
.borderRadius(8)
.onClick(() => {
this.doDel();
})
Text('确认')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
charterModalBody() {
Column({ space: 12 }) {
Text('夜钓包船预约')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '出钓时间(如 周六 20:00)', text: this.charterTime })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.charterTime = v;
})
TextInput({ placeholder: '钓点(如 东湖北岸闸口)', text: this.charterSpot })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.charterSpot = v;
})
Text('船长确认鱼情与天气后短信通知 · 含窝料与头灯租借')
.fontSize(11)
.fontColor(COLORS.textHint)
.width('100%')
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.charterOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.accent)
.borderRadius(8)
.onClick(() => {
this.doCharter();
})
Text('提交预约')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -56 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.accent : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
}, (it: NavItem) => it.label)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) {
this.pageFeatured()
} else if (this.subTab === 1) {
this.pageMarket()
} else if (this.subTab === 2) {
this.pageCrafts()
} else if (this.subTab === 3) {
this.pageWorkshop()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageGear()
}
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
Scroll() {
Column() {
this.pageMarket()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 2) {
Scroll() {
Column() {
this.pageCourse()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else {
Scroll() {
Column() {
this.pageMine()
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
}
}
.width('100%')
.height('100%')
}
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() {
this.editModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() {
this.delModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.charterOpen) {
Stack() {
this.modalOverlay()
Column() {
this.charterModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
.width('100%')
.height('100%')
}
}
十七、总结

浮语·城市夜钓与钓具工坊应用基于 HarmonyOS 6.1.1 ArkTS API 24 的完整实践,展示了声明式 UI 在垂直场景应用开发中的强大表现力。从 ColorPalette 接口的类型安全色彩管理,到 @Observed 数据模型的响应式绑定,再到纯函数驱动的特效动画计算,整个应用的架构设计体现了"类型安全、数据驱动、函数纯净"三大现代前端工程原则的深度融合。暗夜蓝与荧光绿的配色方案不仅满足了夜钓场景的视觉沉浸需求,更通过 WCAG AA 级对比度标准保证了信息可读性。
在特效动画层面,fxLayer 构建器通过三组 ForEach 渲染的涟漪扩散、信号灯闪烁和夜光漂光点,实现了与夜钓场景高度匹配的氛围效果。90 毫秒的定时器间隔、质数系数的坐标计算函数、HitTestMode.None 的穿透点击设置,这些细节设计共同构成了一个既美观又不干扰交互的特效层。纯函数的广泛使用(barH、ncColor、ringS、ringA、blinkA、statusColor 等)使得特效计算具有确定性和可测试性,为后续的性能优化和单元测试奠定了基础。
在交互架构层面,"底部四主 Tab + 首页七内容 Tab"的双层导航结构为用户提供了清晰的信息探索路径。鱼漂顶帽式的子导航设计将场景元素融入交互控件,是 ArkTS 声明式 UI 在自定义视觉表现方面的典型案例。四个弹框(发布浮语、编辑钓况、收竿撤单、包船预约)通过统一的 modalOverlay + *ModalBody 模式实现了一致的弹框交互体验,“open + do” 的方法命名约定清晰地表达了每个弹框的打开和提交逻辑。
在数据管理层面,四个 @Observed 数据模型(PostItem、RigItem、NightItem、GearItem)通过 @State 变量引用静态数组,实现了数据的响应式管理。unshift 和 splice 操作的精确使用保证了列表增删的 UI 同步更新。静态数据数组的预填充策略不仅提升了首次使用体验,也为 UGC 内容提供了格式参考。statusColor 和 statusProgress 等配套纯函数将业务状态编码为视觉参数,实现了数据与视觉的一致性映射。
更多推荐

所有评论(0)