极光观测与旅行平台基于HarmonyOS ArkTS API 24通过弹窗表单新增旅友动态时,buddies.push() 触发列表刷新;当删除极光笔记时,notes.splice() 触发列表项移除

技术背景

在现代移动端应用开发领域,深色主题(Dark Mode)已经从可选的视觉风格进化为用户强烈需求的核心体验特性。深色主题不仅能在 OLED 屏幕上显著降低功耗、延长续航,还能在暗光环境下减少眼睛疲劳,更重要的是——对于极光观测这一特殊场景,深色界面本身就是场景延伸:用户在暗夜中等待极光时,一个深色界面的手机不会破坏视觉暗适应,让用户可以在查看极光预报和肉眼观测之间无缝切换。这种"界面即场景"的设计哲学,使得深色主题从单纯的视觉选择升华为用户体验的必要组成。

ArkTS 作为鸿蒙生态的核心编程语言,提供了完整的声明式 UI 开发能力。其装饰器体系——@Entry@Component@Builder@State@Observed——构成了从组件声明到状态管理到 UI 构建的全链路工具集。在极光平台这类数据密集型应用中,@Observed 装饰器尤为关键:它使数据模型类实例的属性变化能够自动触发视图刷新,无需开发者手动调用更新方法。当用户发布新的旅友动态时,信息流列表会自动追加新条目;当编辑行程信息时,时间轴上的对应条目会立即更新——这种"数据驱动视图"的响应式机制是声明式 UI 的核心价值所在。

鸿蒙操作系统的渲染引擎对动画和特效提供了丰富的原生支持。position 属性可以实现精确的元素定位,rotate 属性可以实现旋转变换,opacity 属性可以实现透明度渐变,clip 属性可以实现内容裁剪。这些属性的组合使用可以在不依赖第三方动画库的情况下构建出复杂的视觉效果。本应用中的"极光飘带+星光闪烁"特效,就是通过 setInterval 定时器驱动一个递增的 tick 计数器,再通过纯函数将 tick 值映射为元素的位置和透明度,实现了动态飘动的极光飘带和不断位移闪烁的星光——整个过程完全由 ArkTS 原生组件和数学函数实现,零外部依赖。

@Observed 装饰器配合 @State 使用时,形成了一条从用户操作到数据变更再到视图刷新的完整闭环。在极光平台中,五个数据模型类(AuroraSpotTripItemGearItemBuddyItemNoteItem)全部被 @Observed 标记,三个可变列表(行程团、旅友圈、极光笔记)作为 @State 变量绑定到 ForEach 渲染。当用户通过弹窗表单新增旅友动态时,buddies.push() 触发列表刷新;当删除极光笔记时,notes.splice() 触发列表项移除。这种"操作→状态变更→自动渲染"的模式,让开发者无需关心 DOM 操作的细节,只需专注于业务逻辑的正确性。

从行业视角来看,极光观测旅行应用具有独特的 UI 设计挑战。首先,极光数据维度丰富——KP 指数、观测概率、最佳时段、云层预报等指标需要以可视化方式呈现;其次,行程管理涉及多维度信息(日期、天数、价格、人数、领队、路线);再者,社区互动(旅友圈)和信息沉淀(极光笔记)需要不同的列表布局来适配各自的内容特征。本应用通过 4 个底部主 Tab + 7 个首页内嵌子 Tab 的两层导航架构,将这些功能模块有序组织,既保证了导航的简洁性,又通过子 Tab 的细分实现了功能的精准定位。
在这里插入图片描述

项目概述

极光·极光观测与旅行平台是一个面向极光追逐者和北极旅行爱好者的综合性移动应用,涵盖从极光预报到行程预订到社区分享的全链路需求。平台采用底部 4 主 Tab 架构(首页、营地图鉴、装备铺、我的),首页进一步嵌套 7 个内容子 Tab(精选、观测点、极光指数、行程团、装备、旅友圈、极光笔记),形成两层导航体系。

具体功能模块包括:

  • 精选首页:大卡横滚展示极光季活动 + 双列瀑布流展示观测点推荐
  • 观测点列表:编号式列表展示 8 个全球认证观测点(芬兰、挪威、瑞典、格陵兰、阿拉斯加、加拿大、中国漠河、冰岛)
  • 极光指数:近 7 日 KP 指数柱状图 + 4 级概率看板(微弱/可见/丰富/大爆发)+ 观测建议卡片
  • 行程团管理:时间轴布局展示 6 条极光行程,支持编辑行程名称和日期、删除行程
  • 装备展示:三列宫格展示 12 件极地装备(保暖/摄影/安全/露营),含租赁价格和库存
  • 旅友圈:信息流展示旅友动态,支持发布新动态(昵称+内容),含点赞和回复数
  • 极光笔记:图文横条展示 10 篇极光攻略笔记,支持删除操作
  • 营地图鉴:展示各观测点的营地设施和服务(玻璃屋/观景台/暖炉/驻场摄影)
  • 装备铺:双列瀑布展示装备详情,含购买价格和租赁价格
  • 个人中心:用户档案 + 统计数据(观测记录/收藏笔记/点赞数)+ 本季观测统计柱状图 + 功能菜单

整体采用极地深色主题:背景色 #0B1030(近黑深蓝)、卡片色 #151C4A/#1D2760(深蓝紫)、极光绿 #7CF7D4、极光紫 #B388FF、星光黄 #FFF3B0,营造出北极夜空的视觉氛围。
在这里插入图片描述

架构设计与整体技术选型

本应用在架构上采用了"数据模型层→常量数据层→纯函数层→构建器层→根装配层"的五层分离设计。数据模型层通过 @Observed 标记的五个类定义业务实体结构;常量数据层通过 const 数组预填充模拟数据;纯函数层提供了 KP 指数到颜色/高度/等级的映射、星光位置计算、芯片颜色生成等工具函数;构建器层通过十几个 @Builder 方法封装各功能区域的 UI 逻辑;根装配层通过 build() 方法进行条件路由和层级堆叠。

在状态管理上,应用使用了 mainTab(主 Tab 索引)、subTab(子 Tab 索引)、tick(动画计数器)、glow(闪烁开关)四个核心状态变量控制导航和动画,另设三个弹窗状态变量(addModal/editModal/delModal)和三个表单数据组(addNick/addTexteditTitle/editDateeditIdx/delTarget)。弹窗管理采用了方法封装模式——openAdd()openEdit(idx)openDelNote(idx)openDelTrip(idx) 四个方法统一处理弹窗的打开逻辑,在打开目标弹窗的同时关闭其他弹窗,确保任意时刻只有一个弹窗显示。

特效系统是本应用的亮点。fxLayer() 构建器在 Stack 容器中渲染极光飘带和星光闪烁:3 条飘带通过 rotate 旋转和 position 位移实现飘动效果,8 颗星光通过 dotX/dotY 纯函数计算位置。tick 计数器每 700ms 递增 1,所有特效元素的位置都依赖 tick 值,因此每次定时器触发都会重新计算所有特效元素的位置——实现了持续动态变化的视觉效果。fxLayer 被放置在内容区的 Stack 中(而非整个页面的 Stack 中),确保特效只覆盖内容区不遮挡头部。

逐段代码深度分析

在这里插入图片描述

第一段:ColorPalette 接口与深色极地色彩体系

interface ColorPalette {
  bg: string;
  card: string;
  card2: string;
  title: string;
  sub: string;
  line: string;
  aurora: string;
  aurora2: string;
  aurora3: string;
  violet: string;
  star: string;
  gold: string;
  red: string;
  green: string;
  blue: string;
  mask: string;
  white: string;
}

ColorPalette 接口定义了极光平台的 17 个语义化色彩变量。与浅色主题应用不同,这里特别增加了 card2(二级卡片色)用于在深色背景中创建更深的卡片层次,以及三个极光色变量 aurora/aurora2/aurora3 分别对应极光的不同色相——绿色、浅绿和紫色,模拟真实极光的光谱特征。

star(星光黄)和 gold(金色)用于评分和等级标识,mask 使用了 #000000CC(80% 不透明度的纯黑),比浅色主题中常用的半透明深色遮罩更加深邃,与极地暗夜的氛围一致。white 作为纯白色被单独定义而非直接使用字符串 '#FFFFFF',保持了色彩引用的一致性。

第二段:COLORS 常量实例化

在这里插入图片描述

const COLORS: ColorPalette = {
  bg: '#0B1030',
  card: '#151C4A',
  card2: '#1D2760',
  title: '#F0F4FF',
  sub: '#9AA7D8',
  line: '#2A356B',
  aurora: '#7CF7D4',
  aurora2: '#4DD0A8',
  aurora3: '#B388FF',
  violet: '#C9B6FF',
  star: '#FFF3B0',
  gold: '#FFD97A',
  red: '#FF7B7B',
  green: '#7CF7D4',
  blue: '#7FB5FF',
  mask: '#000000CC',
  white: '#FFFFFF'
};

这套色值营造了完整的极地夜空视觉体系。背景色 #0B1030 是近乎纯黑的深蓝紫色,模拟无光害的极地夜空;卡片色 #151C4A#1D2760 分别提供两级深蓝紫色卡片背景,在深色背景上形成微妙的层次。

极光三色是整个配色的灵魂:aurora#7CF7D4)是明亮的薄荷绿,模拟极光最常见的绿色光谱;aurora2#4DD0A8)是较深的翡翠绿,用于较低 KP 指数的标识;aurora3#B388FF)是紫罗兰色,模拟高 KP 指数时极光出现的紫色边缘。这三色在柱状图、标签、按钮中被反复使用,形成了统一的极光视觉语言。

文字色体系在深色主题下反转:title#F0F4FF)是近白色,确保在深色背景上的高对比度可读性;sub#9AA7D8)是柔和的蓝灰色,用于次要文字。star#FFF3B0)是温暖的星光黄,用于评分和最佳时段标识,在冷色调的深色背景中形成温暖的视觉焦点。

第三段:AuroraSpot 观测点数据模型

在这里插入图片描述

@Observed
export class AuroraSpot {
  id: number = 0;
  name: string = '';
  region: string = '';
  kp: number = 0;
  best: string = '';
  score: number = 0;
  level: string = '';
  tags: string[] = [];
  constructor(id: number, name: string, region: string, kp: number, best: string, score: number, level: string, tags: string[]) {
    this.id = id; this.name = name; this.region = region; this.kp = kp;
    this.best = best; this.score = score; this.level = level; this.tags = tags;
  }
}

AuroraSpot 类定义了极光观测点的数据模型。tags 属性使用了 string[] 数组类型——这是本系列应用中首次出现数组类型的模型属性,用于存储观测点的特色标签(如"玻璃屋"、“驯鹿场”、“天空站”、"无光害"等)。数组类型属性在 ForEach 渲染时需要提供 key 生成函数,这一细节在后续的 tabPick() 构建器中体现。

kp 属性是地磁活动指数(Kp Index),范围 0-9,是极光预报的核心指标。level 属性是极光等级的文字描述(“大爆发”/“活跃”/“偶发"等),通过 kpLevel 函数从 kp 值推导。best 属性是最佳观测时段(如"22:00-02:00”),score 是综合评分(0-10)。

第四段:spotList 观测点初始数据

在这里插入图片描述

const spotList: AuroraSpot[] = [
  new AuroraSpot(1, '罗瓦涅米极光村', '芬兰·拉普兰', 8, '22:00-02:00', 9.2, '大爆发', ['玻璃屋', '驯鹿场']),
  new AuroraSpot(2, '特罗姆瑟峡湾', '挪威·北极圈', 7, '21:30-01:30', 9.0, '活跃', ['峡湾', '缆车']),
  new AuroraSpot(3, '阿比斯库天空站', '瑞典·拉普兰', 9, '20:30-03:00', 9.5, '大爆发', ['天空站', '无光害']),
  new AuroraSpot(4, '伊卢利萨特冰湾', '格陵兰', 6, '22:30-01:00', 8.4, '活跃', ['冰山', '冰川']),
  new AuroraSpot(5, '费尔班克斯', '美国·阿拉斯加', 7, '21:00-02:30', 8.8, '活跃', ['温泉', '苔原']),
  new AuroraSpot(6, '黄刀镇', '加拿大·西北地区', 9, '20:00-03:30', 9.3, '大爆发', ['帐篷营地', '冰湖']),
  new AuroraSpot(7, '漠河北极村', '中国·黑龙江', 5, '22:00-01:00', 7.2, '偶发', ['中国最北', '雪屋']),
  new AuroraSpot(8, '雷克雅未克郊野', '冰岛·黄金圈', 6, '21:00-01:30', 8.1, '活跃', ['黑沙滩', '瀑布'])
];

spotList 预填充了 8 个全球顶级极光观测点,覆盖了北半球主要极光观测区域。数据设计体现了专业的极光旅游知识:

阿比斯库天空站(瑞典)以 KP 9 和评分 9.5 位居榜首,这里是全球公认的最佳极光观测地——其独特的微气候使得云量极少,"无光害"的标签强调了其原始自然环境。黄刀镇(加拿大)同样 KP 9,以 20:00-03:30 的超长观测窗口著称。罗瓦涅米(芬兰)以"玻璃屋"闻名,游客可以在温暖的玻璃穹顶下仰望极光。

漠河北极村作为中国唯一的极光观测点被列入列表,KP 5 和"偶发"等级反映了其较低但存在的极光概率——这一设计为中国用户提供了亲切感。"中国最北"和"雪屋"标签突出了其地理意义和体验特色。每个观测点的 tags 数组在后续的精选 Tab 中通过 ForEach 渲染为标签行,颜色编码为深绿底+极光绿文字。

第五段:TripItem 行程团数据模型与数据

在这里插入图片描述

@Observed
export class TripItem {
  id: number = 0;
  title: string = '';
  date: string = '';
  days: number = 0;
  price: string = '';
  people: string = '';
  leader: string = '';
  spots: string = '';
  status: string = '';
  constructor(id: number, title: string, date: string, days: number, price: string, people: string, leader: string, spots: string, status: string) {
    this.id = id; this.title = title; this.date = title; this.days = days;
    this.price = price; this.people = people; this.leader = leader; this.spots = spots; this.status = status;
  }
}

TripItem 定义了极光行程团的数据模型,9 个属性覆盖了行程的全部决策维度:title(行程标题)、date(出发日期)、days(天数)、price(价格)、people(成团情况如"已成团 9/12")、leader(领队昵称)、spots(覆盖观测点路线)、status(状态:已满/可报名/即将满员)。status 属性通过颜色编码在时间轴中区分:已满显示红色、可报名显示极光绿。

第六段:GearItem 装备数据模型与数据

@Observed
export class GearItem {
  id: number = 0;
  name: string = '';
  cat: string = '';
  price: string = '';
  rent: string = '';
  stock: number = 0;
  hot: string = '';
  icon: string = '';
  constructor(id: number, name: string, cat: string, price: string, rent: string, stock: number, hot: string, icon: string) {
    this.id = id; this.name = name; this.cat = cat; this.price = price;
    this.rent = rent; this.stock = stock; this.hot = hot; this.icon = icon;
  }
}

GearItem 定义了极地装备的数据模型,包含 cat(分类:保暖/耗材/摄影/安全/露营)、price(购买价格)、rent(租赁价格如"¥60/天")、stock(库存数量)、hot(热门标记,"🔥"或空字符串)。12 件装备涵盖了极光旅行的完整装备需求:防风羽绒服、加厚冲锋裤、雪地靴(保暖);暖宝宝贴(耗材);三脚架、广角镜头、快门线、备用电池(摄影);头灯、保温水壶(安全);雪地帐篷、睡袋(露营)。其中 5 件标记为热门(“🔥”),引导用户关注核心装备。

第七段:BuddyItem 旅友数据模型与数据

@Observed
export class BuddyItem {
  id: number = 0;
  nick: string = '';
  avatar: string = '';
  text: string = '';
  likes: number = 0;
  rep: number = 0;
  tag: string = '';
  time: string = '';
  constructor(id: number, nick: string, avatar: string, text: string, likes: number, rep: number, tag: string, time: string) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text;
    this.likes = likes; this.rep = rep; this.tag = tag; this.time = time;
  }
}

BuddyItem 定义了旅友圈动态的数据模型。text 属性存储动态正文,长度较长(50-100字),在界面中以 11px 字号和 17px 行高显示,确保多行文本的可读性。tag 属性是动态标签(“拍到了”、“求拼团”、"经验分享"等),在界面中以紫罗兰色显示,为每条动态提供了快速分类标签。8 条初始动态内容丰富多样,涵盖了极光拍摄经验分享、拼团邀约、装备转让、直播预告等多种社区互动形式。

第八段:NoteItem 极光笔记数据模型与数据

@Observed
export class NoteItem {
  id: number = 0;
  title: string = '';
  spot: string = '';
  date: string = '';
  author: string = '';
  views: number = 0;
  fav: number = 0;
  icon: string = '';
  constructor(id: number, title: string, spot: string, date: string, author: string, views: number, fav: number, icon: string) {
    this.id = id; this.title = title; this.spot = spot; this.date = date;
    this.author = author; this.views = views; this.fav = fav; this.icon = icon;
  }
}

NoteItem 定义了极光攻略笔记的数据模型。views(浏览数)和 fav(收藏数)是笔记的 popularity 指标,在界面中分别以"👁️"和"⭐"前缀显示。icon 属性使用 Emoji 标识笔记类型(📘教程、🧥保暖、🗺️路线、📱手机摄影、📊评测、🚙自驾、🏠住宿、🎨后期等),这些图标在笔记列表的左侧以 52x52 的彩色背景方块呈现,颜色通过 noteIconBg 函数根据笔记 ID 选择 4 种深蓝紫色之一。

第九段:KpChartItem 与 LevelItem 图表数据

interface KpChartItem {
  day: string;
  kp: number;
  color: string;
}

const kpCharts: KpChartItem[] = [
  { day: '周一', kp: 4, color: '#4DD0A8' },
  { day: '周二', kp: 6, color: '#7CF7D4' },
  { day: '周三', kp: 3, color: '#3E9D88' },
  { day: '周四', kp: 5, color: '#7CF7D4' },
  { day: '周五', kp: 8, color: '#B388FF' },
  { day: '周六', kp: 7, color: '#C9B6FF' },
  { day: '周日', kp: 9, color: '#B388FF' }
];

interface LevelItem {
  label: string;
  pct: number;
  color: string;
}

const levelList: LevelItem[] = [
  { label: 'KP 1-2 极光微弱', pct: 18, color: '#3E9D88' },
  { label: 'KP 3-4 肉眼可见', pct: 45, color: '#4DD0A8' },
  { label: 'KP 5-6 色彩丰富', pct: 70, color: '#7CF7D4' },
  { label: 'KP 7-8 极光大爆发', pct: 92, color: '#B388FF' }
];

KpChartItem 定义了近 7 日 KP 指数柱状图的数据,每天包含日期标签、KP 值和颜色。颜色编码遵循极光三色体系:KP 3-4 使用深翡翠绿(#4DD0A8)、KP 5-6 使用明亮薄荷绿(#7CF7D4)、KP 7-9 使用紫罗兰色(#B388FF)。柱状图中周一到周日的 KP 值从 4 上升到 9,模拟了一个逐渐增强的极光活动周——周三是最低点(KP 3),周日是峰值(KP 9),与"今晚观测建议"中提到的 KP8 形成了数据呼应。

LevelItem 定义了 4 级极光概率看板数据,每级包含标签、概率百分比和颜色。从 KP 1-2 的 18%(微弱)到 KP 7-8 的 92%(大爆发),颜色从深绿渐变到紫色,直观传达了"KP 越高、极光越强、概率越大"的科学关系。

第十段:纯函数层 - KP 映射与星光位置计算

function kpBarH(kp: number): string {
  return (kp * 10).toString() + '%';
}

function kpLevel(kp: number): string {
  if (kp >= 8) { return '大爆发'; }
  if (kp >= 6) { return '活跃'; }
  if (kp >= 4) { return '可见'; }
  return '微弱';
}

function kpColor(kp: number): string {
  if (kp >= 8) { return COLORS.aurora3; }
  if (kp >= 6) { return COLORS.aurora; }
  return COLORS.aurora2;
}

三个 KP 相关函数构成了极光指数的映射体系。kpBarH 将 KP 值映射为柱状图高度百分比(KP 9 → 90%),kpLevel 将 KP 值映射为等级文字,kpColor 将 KP 值映射为颜色。三个函数的阈值判断一致(8/6/4),确保了同一 KP 值在柱状图高度、等级标签和颜色三个维度上保持语义一致。

function dotX(seed: number, tick: number): number {
  return ((seed * 61 + tick * 3) % 90) + 5;
}

function dotY(seed: number, tick: number): number {
  return ((seed * 37 + tick * 2) % 80) + 5;
}

dotXdotY 是星光位置计算函数,它们接收两个参数:seed(星光编号,0-7)和 tick(动画计数器)。通过 (seed * 61 + tick * 3) % 90 + 5 的计算,将 seed 和 tick 映射到 5-95 的位置范围内。61 和 3 是质数系数,确保了不同 seed 值的星光在不同 tick 值下产生不同的位移轨迹,避免了所有星光同步运动的呆板效果。+5 的偏移量确保星光不会出现在边缘 5% 的范围内。

这种基于取模运算的伪随机位置计算方式简单而高效——每次 tick 递增时,所有星光的位置都会重新计算并产生位移,配合 opacity 的交替变化,实现了星光闪烁飘移的效果。相比使用随机数生成器,这种确定性计算方式确保了相同的 tick 值始终产生相同的位置,避免了渲染抖动。

第十一段:辅助纯函数

function barW(v: number): string {
  return (v * 0.7).toString() + '%';
}

function noteIconBg(seed: number): string {
  const arr: string[] = ['#1D2760', '#253070', '#20305F', '#1A2A55'];
  return arr[seed % 4];
}

barW 函数将概率百分比(0-100)映射为进度条宽度(0-70%),0.7 的系数确保进度条不会占满容器宽度,留出右侧空间显示百分比文字。noteIconBg 函数根据笔记 ID 返回 4 种深蓝紫色之一,通过 seed % 4 的取模运算实现了循环取色——笔记 1 和 5 使用同一种颜色,笔记 2 和 6 使用同一种颜色,依此类推。这种"ID 取模取色"的模式在装备卡片中也通过 chipColor 函数使用,是本应用实现色彩多样性的通用手段。

第十二段:Page 组件状态定义

@Entry
@Component
struct Page {
  @State mainTab: number = 0;
  @State subTab: number = 0;
  @State tick: number = 0;
  @State glow: boolean = false;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delTarget: string = 'note';
  @State editTitle: string = '';
  @State editDate: string = '';
  @State addNick: string = '';
  @State addText: string = '';
  @State trips: TripItem[] = [...];
  @State notes: NoteItem[] = [...];
  @State buddies: BuddyItem[] = [...];
  fxTimer: number = -1;

状态变量集合分为五组:导航状态(mainTab/subTab)、动画状态(tick/glow)、弹窗状态(addModal/editModal/delModal)、表单数据(editIdx/delTarget/editTitle/editDate/addNick/addText)、可变列表(trips/notes/buddies)。

tick 变量是本应用独有的设计——它是一个持续递增的整数计数器,不同于 glow 的布尔切换。tick 被传入 dotX/dotY 函数参与星光位置计算,因此每次 tick 变化都会导致所有星光重新定位。同时 glow 的切换通过 opacity 变化控制飘带和星光的透明度,两者配合实现了"位移+闪烁"的双重动效。delTarget 变量用于区分删除操作的目标类型(“note"或"trip”),使同一个删除确认弹窗可以服务于笔记和行程两种删除场景。

第十三段:生命周期与弹窗管理方法

  aboutToAppear(): void {
    this.fxTimer = setInterval(() => {
      this.tick = this.tick + 1;
      this.glow = !this.glow;
    }, 700);
  }

  aboutToDisappear(): void {
    clearInterval(this.fxTimer);
  }

  openAdd(): void {
    this.addNick = '';
    this.addText = '';
    this.addModal = true;
    this.editModal = false;
    this.delModal = false;
  }

  openEdit(idx: number): void {
    this.editIdx = idx;
    this.editTitle = this.trips[idx].title;
    this.editDate = this.trips[idx].date;
    this.addModal = false;
    this.editModal = true;
    this.delModal = false;
  }

  openDelNote(idx: number): void {
    this.delTarget = 'note';
    this.editIdx = idx;
    this.addModal = false;
    this.editModal = false;
    this.delModal = true;
  }

aboutToAppear 中启动 700ms 定时器,每次回调同时递增 tick 和切换 glow。700ms 的间隔比 827 应用的 750ms 略短,使极光飘带和星光的动态节奏更加活跃。

四个 open* 方法是弹窗管理的核心——每个方法在打开目标弹窗的同时关闭其他两个弹窗(设置对应状态为 false),并初始化表单数据。这种"互斥弹窗"管理模式确保了任意时刻只有一个弹窗显示,避免了多个弹窗层叠的混乱。openEdit 方法还负责从 trips 数组中读取当前行程的标题和日期作为表单预填值,实现了"编辑上下文传递"。

第十四段:CRUD 操作方法

  doAdd(): void {
    if (this.addText.length > 0) {
      const n = this.buddies.length + 1;
      this.buddies.push(new BuddyItem(n, this.addNick.length > 0 ? this.addNick : '星野旅人', '🪐', this.addText, 0, 0, '新动态', '刚刚'));
      this.addModal = false;
    }
  }

  doEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.trips.length) {
      this.trips[this.editIdx].title = this.editTitle;
      this.trips[this.editIdx].date = this.editDate;
      this.editModal = false;
    }
  }

  doDel(): void {
    if (this.delTarget === 'trip') {
      if (this.editIdx >= 0 && this.editIdx < this.trips.length) {
        this.trips.splice(this.editIdx, 1);
        this.delModal = false;
      }
    } else {
      if (this.editIdx >= 0 && this.editIdx < this.notes.length) {
        this.notes.splice(this.editIdx, 1);
        this.delModal = false;
      }
    }
  }

三个 do* 方法封装了创建、更新、删除的核心逻辑:

doAdd 使用 push 方法向 buddies 数组末尾添加新动态。新动态的头像固定为"🪐"(星球),标签为"新动态",时间为"刚刚",点赞和回复数初始为 0。昵称在用户未输入时使用默认值"星野旅人"。只有在 addText 非空时才执行添加——这是最基本的内容校验。

doEdit 通过索引直接修改 trips 数组中对应项的 titledate 属性。由于 TripItem@Observed 标记,属性修改会自动触发时间轴列表中对应条目的刷新。

doDel 根据 delTarget 的值决定删除行程还是笔记。这种"单方法多目标"的设计减少了代码重复——同一个确认弹窗和同一个删除方法服务于两种不同的删除场景,只需通过 delTarget 字段区分目标列表。

第十五段:特效层构建器 fxLayer()

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2], (i: number) => {
        Row()
          .width('130%')
          .height(14 + (i * 8))
          .borderRadius(20)
          .backgroundColor(i % 3 === 0 ? COLORS.aurora : (i % 3 === 1 ? COLORS.aurora3 : COLORS.aurora2))
          .opacity(0.12 + (this.glow ? 0.06 : 0))
          .rotate({ angle: -12 + (i * 6) })
          .position({ x: -40 + (this.tick % 20) * (i + 1), y: 30 + (i * 46) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5, 6, 7], (s: number) => {
        Text('✦')
          .fontSize(7 + (s % 3) * 3)
          .fontColor(COLORS.star)
          .opacity(s % 2 === 0 ? 0.9 : 0.25)
          .position({ x: dotX(s, this.tick), y: dotY(s, this.tick) })
      }, (s: number) => s.toString())
    }
    .width('100%')
    .height('100%')
    .clip(true)
  }

fxLayer() 是本应用最精彩的视觉构建器。它在一个 Stack 容器中渲染两组特效元素:

极光飘带(3 条):每条飘带是一个空 Row,宽度 130%(超出容器以实现边缘溢出效果),高度 14-30px(递增),圆角 20px。三条飘带分别使用极光绿(aurora)、极光紫(aurora3)、极光深绿(aurora2)三种颜色。透明度在 0.12-0.18 之间随 glow 状态切换——这种极低的透明度使飘带看起来像远处的极光光带,不会遮挡内容。rotate({ angle: -12 + (i * 6) }) 使三条飘带以不同角度倾斜(-12°、-6°、0°),模拟极光飘带的自然弧度。position 中 x 坐标使用 -40 + (this.tick % 20) * (i + 1) 计算,tick % 20 使得 x 坐标在 0-19 之间循环(取模运算确保飘带不会无限位移),乘以 (i + 1) 使三条飘带的位移速度不同(第 0 条最慢、第 2 条最快),营造出层叠飘动的效果。

星光闪烁(8 颗):每颗星光是 字符,字号 7-13px(根据 s % 3 变化),颜色为星光黄(COLORS.star)。透明度在 0.9 和 0.25 之间交替(根据 s % 2),使一半星光明亮、一半暗淡——这种交替闪烁效果模拟了真实星空中星星亮度变化的现象。位置通过 dotX(s, this.tick)dotY(s, this.tick) 计算,每次 tick 变化都会重新定位所有星光。

外层 Stackclip(true) 确保所有特效元素被裁剪在容器范围内,不会溢出到内容区域之外。fxLayerbuild() 中被放置在内容区的 Stack 内(与主内容同级),确保特效只覆盖内容区不遮挡头部。

第十六段:头部构建器 header()

  @Builder
  header() {
    Column({ space: 10 }) {
      Row({ space: 8 }) {
        Column() { Text('📍').fontSize(15) }
        .width(32).height(32).borderRadius(16).backgroundColor(COLORS.card2)
        .justifyContent(FlexAlign.Center)
        Column({ space: 2 }) {
          Text('北极圈观测站').fontSize(11).fontColor(COLORS.sub)
          Text('罗瓦涅米 · 晴 -18°C').fontSize(9).fontColor(COLORS.aurora2)
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Row({ space: 6 }) {
          Column() { Text('🔍').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
          .justifyContent(FlexAlign.Center)
          Column() { Text('🔔').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
          .justifyContent(FlexAlign.Center)
        }
      }
      .width('100%')

头部第一行是定位+搜索+通知栏。左侧是定位信息(📍图标 + “北极圈观测站” + “罗瓦涅米 · 晴 -18°C”),右侧是搜索和通知按钮。定位信息的第二行使用了 COLORS.aurora2(极光绿)显示天气信息,在深色背景上形成色彩亮点。"-18°C"的温度信息传达了极地严寒的环境特征,增强了应用的真实感。

两个功能按钮(🔍搜索、🔔通知)使用 30x30 的圆形容器(borderRadius(15)),背景为二级卡片色(COLORS.card2),在深色背景上形成微凸的按钮视觉效果。

第十七段:今日极光强度大卡

      Row({ space: 12 }) {
        Column({ space: 3 }) {
          Text('今夜极光').fontSize(10).fontColor(COLORS.sub)
          Text('KP 8').fontSize(28).fontColor(COLORS.aurora).fontWeight(FontWeight.Bold)
          Text('大爆发 · 概率 92%').fontSize(9).fontColor(COLORS.star)
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Column({ space: 6 }) {
          Row({ space: 4 }) {
            ForEach([0, 1, 2, 3, 4, 5, 6, 7], (b: number) => {
              Column()
                .width(6).height(b < 8 ? 12 + b * 3 : 0)
                .borderRadius(3).backgroundColor(b < 5 ? COLORS.aurora2 : COLORS.aurora)
            }, (b: number) => b.toString())
          }
          .alignItems(VerticalAlign.Bottom)
          Text('22:00 后云层将消散').fontSize(8).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.card)
      .border({ width: 1, color: COLORS.aurora3 })

今日极光强度大卡是头部的视觉重心。左侧显示 KP 值(“KP 8”,28px 极光绿色加粗),下方是等级和概率(“大爆发 · 概率 92%”,星光黄色)。右侧是一个 8 柱的迷你柱状图——每柱宽度 6px,高度递增(12+3b 像素),前 4 柱使用深极光绿(aurora2),后 4 柱使用亮极光绿(aurora),通过 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,形成阶梯上升的视觉效果。

整个大卡使用 COLORS.card(深蓝紫色)背景和极光紫(aurora3)边框,边框的紫色光晕效果使卡片看起来像是被极光环绕,这一细节设计极大地增强了极地氛围感。下方的"22:00 后云层将消散"提示为用户提供了实用的观测时间建议。

第十八段:热门观测点横滚

      Scroll() {
        Row({ space: 10 }) {
          ForEach(spotList, (sp: AuroraSpot) => {
            Column({ space: 4 }) {
              Text(sp.name.length > 6 ? sp.name.slice(0, 6) : sp.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
              Text(sp.region).fontSize(8).fontColor(COLORS.sub)
              Text('KP ' + sp.kp.toString()).fontSize(9).fontColor(kpColor(sp.kp)).fontWeight(FontWeight.Bold)
            }
            .width(88).padding({ top: 8, bottom: 8 }).borderRadius(10)
            .backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
          }, (sp: AuroraSpot) => sp.id.toString())
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .height(66)
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(COLORS.bg)
  }

热门观测点横滚使用 Scroll + Row 的横向滚动布局,每个观测点卡片宽度 88px,显示名称(截断至 6 字符)、区域和 KP 值。KP 值通过 kpColor 函数着色——KP 8 以上的观测点(罗瓦涅米、阿比斯库、黄刀镇)显示紫色,KP 6-7 的显示亮绿色,KP 5 以下的显示深绿色。这种颜色编码让用户在横向浏览时能够通过色彩快速识别高 KP 观测点。

第十九段:内容子Tab导航(7个,两排4+3)

  @Builder
  subNav() {
    Column({ space: 6 }) {
      Row({ space: 6 }) {
        ForEach(['精选', '观测点', '极光指数', '行程团'], (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(11)
              .fontColor(this.subTab === i ? COLORS.bg : COLORS.sub)
              .fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1)
          .padding({ top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(this.subTab === i ? COLORS.aurora : COLORS.card2)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.subTab = i; })
        }, (t: string) => t)
      }
      .width('100%')
      Row({ space: 6 }) {
        ForEach(['装备', '旅友圈', '极光笔记'], (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(11)
              .fontColor(this.subTab === i + 4 ? COLORS.bg : COLORS.sub)
              .fontWeight(this.subTab === i + 4 ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1)
          .padding({ top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(this.subTab === i + 4 ? COLORS.violet : COLORS.card2)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.subTab = i + 4; })
        }, (t: string) => t)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 12, right: 12 })
  }

7 个子 Tab 被分为两排:第一排 4 个(精选/观测点/极光指数/行程团),第二排 3 个(装备/旅友圈/极光笔记)。这种"4+3"两排布局是当 Tab 数量超过 5 个时的常见解决方案——单排横滚虽然能容纳更多 Tab,但用户需要滑动才能看到全部 Tab,降低了可发现性;两排布局使所有 Tab 一目了然,点击即可切换。

两排 Tab 的选中态颜色不同:第一排选中时使用极光绿(aurora)背景,第二排选中时使用紫罗兰色(violet)背景。选中态的文字颜色使用 COLORS.bg(深蓝黑色),在亮色背景上形成高对比度。这种双色选中态设计不仅在视觉上区分了两排 Tab,还与极光的绿色/紫色双主色体系呼应。

第二排 Tab 的索引使用 i + 4(0+4=4, 1+4=5, 2+4=6),与第一排的 i(0-3)连续编号,统一在 subTab 变量中管理。

第二十段:精选Tab - 大卡横滚与双列瀑布

  @Builder
  tabPick() {
    Scroll() {
      Column({ space: 10 }) {
        Scroll() {
          Row({ space: 10 }) {
            ForEach([0, 1, 2], (b: number) => {
              Column({ space: 6 }) {
                Text(b === 0 ? '🌌 极光季开启' : (b === 1 ? '⛺ 天空站特惠' : '📷 追光课堂'))
                  .fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
                Text(b === 0 ? '阿比斯库 KP9 持续一周' : (b === 1 ? '住 3 晚送 1 晚向导' : '新手上路 8 折'))
                  .fontSize(9).fontColor('#DCE3FF')
                Row({ space: 6 }) {
                  Column() { Text('立即查看').fontSize(9).fontColor(COLORS.bg) }
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
                  .backgroundColor(COLORS.white)
                }
                .width('100%')
              }
              .layoutWeight(1)
              .height(86).padding(12).borderRadius(14)
              .backgroundColor(b === 0 ? '#2A4BFF' : (b === 1 ? '#7B4DFF' : '#0E8A6D'))
              .alignItems(HorizontalAlign.Start)
              .justifyContent(FlexAlign.SpaceBetween)
            }, (b: number) => b.toString())
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .height(86)

精选 Tab 顶部是 3 张大卡横滚,每张卡片使用不同的品牌色背景:蓝色(#2A4BFF,极光季开启)、紫色(#7B4DFF,天空站特惠)、绿色(#0E8A6D,追光课堂)。justifyContent(FlexAlign.SpaceBetween) 使标题、描述和按钮三部分在卡片高度内均匀分布。

        ForEach([0, 1], (col: number) => {
          Column({ space: 8 }) {
            ForEach(spotList, (sp: AuroraSpot) => {
              if (sp.id % 2 === col) {
                Column({ space: 5 }) {
                  Row() {
                    Text(sp.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                    Column().layoutWeight(1)
                    Text('KP ' + sp.kp.toString()).fontSize(9).fontColor(kpColor(sp.kp)).fontWeight(FontWeight.Bold)
                  }
                  .width('100%')
                  Text(sp.region).fontSize(9).fontColor(COLORS.sub)
                  Row({ space: 4 }) {
                    ForEach(sp.tags, (tg: string) => {
                      Text(tg).fontSize(8).fontColor(COLORS.aurora2)
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                        .backgroundColor('#0E3A2E')
                    }, (tg: string) => tg)
                  }
                  .width('100%')
                  Text('⭐ ' + sp.score.toString() + ' · ' + sp.best).fontSize(8).fontColor(COLORS.star)
                }
                .width('100%')
                .padding(10).borderRadius(12).backgroundColor(COLORS.card)
                .alignItems(HorizontalAlign.Start)
              }
            }, (sp: AuroraSpot) => sp.id.toString())
          }
          .layoutWeight(1)
        })

大卡下方是双列瀑布流,通过 ForEach([0, 1], (col: number) => ...) 遍历两列,每列内部遍历 spotList,通过 if (sp.id % 2 === col) 将奇数 ID 的观测点放入左列,偶数 ID 的放入右列。每个观测点卡片显示名称、KP 值、区域、标签行和评分+最佳时段。标签行通过 ForEach(sp.tags, ...) 渲染 tags 数组中的每个标签,使用深绿背景(#0E3A2E)和极光绿文字——这是应用中唯一使用特定深绿背景的组件,模拟了极光光带在暗夜中的视觉效果。

第二十一段:观测点Tab - 编号列表

  @Builder
  tabSpots() {
    Scroll() {
      Column({ space: 8 }) {
        ForEach(spotList, (sp: AuroraSpot) => {
          Row({ space: 10 }) {
            Column() { Text(sp.id.toString()).fontSize(12).fontColor(COLORS.aurora).fontWeight(FontWeight.Bold) }
            .width(30).height(30).borderRadius(15).backgroundColor('#0E3A2E')
            .justifyContent(FlexAlign.Center)
            Column({ space: 3 }) {
              Text(sp.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(sp.region + ' · 最佳 ' + sp.best).fontSize(9).fontColor(COLORS.sub)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(kpLevel(sp.kp)).fontSize(9).fontColor(kpColor(sp.kp)).fontWeight(FontWeight.Bold)
              Text('⭐ ' + sp.score.toString()).fontSize(8).fontColor(COLORS.star)
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(11).borderRadius(12).backgroundColor(COLORS.card)
          .border({ width: 1, color: sp.kp >= 8 ? COLORS.aurora3 : COLORS.line })
        }, (sp: AuroraSpot) => sp.id.toString())
        Column() { Text('共 8 个已认证观测点 · 持续更新中').fontSize(9).fontColor(COLORS.sub) }
        .width('100%').padding({ top: 8, bottom: 8 }).alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

观测点列表使用编号圆形+信息+指标的三段式布局。每个观测点的编号显示在 30x30 的圆形容器中(深绿背景 #0E3A2E + 极光绿文字),这种编号圆形设计使列表具有"图鉴"的秩序感。卡片的边框颜色根据 KP 值变化——KP ≥ 8 的观测点(阿比斯库、黄刀镇)使用极光紫边框(aurora3),其他使用默认线条色(line),使高 KP 观测点在列表中通过紫色边框脱颖而出。

列表底部有一个统计文字"共 8 个已认证观测点 · 持续更新中",传达了数据的权威性和实时性。

第二十二段:极光指数Tab - 柱状图与等级看板

  @Builder
  tabIndex1() {
    Scroll() {
      Column({ space: 10 }) {
        Column({ space: 8 }) {
          Row() {
            Text('近 7 日 KP 指数').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text('本周峰值 KP9').fontSize(9).fontColor(COLORS.aurora3)
          }
          .width('100%')
          Row({ space: 8 }) {
            ForEach(kpCharts, (c: KpChartItem) => {
              Column({ space: 4 }) {
                Column()
                  .width(16).height(kpBarH(c.kp)).borderRadius(6).backgroundColor(c.color)
                Text(c.day).fontSize(8).fontColor(COLORS.sub)
              }
              .layoutWeight(1)
              .height(120).justifyContent(FlexAlign.End).alignItems(HorizontalAlign.Center)
            }, (c: KpChartItem) => c.day)
          }
          .width('100%')
          .height(132)
          .padding({ top: 8, bottom: 6 })
          .borderRadius(12).backgroundColor(COLORS.card)
        }
        .width('100%')

极光指数 Tab 是数据可视化的核心区域。近 7 日 KP 柱状图通过 ForEach 渲染 7 个柱子,每个柱子宽度 16px,高度通过 kpBarH(c.kp) 计算(KP 9 → “90%”)。柱子的容器高度固定为 120px,使用 justifyContent(FlexAlign.End) 使柱子从底部向上生长——这种"底对齐"的布局使不同高度的柱子形成了直观的高低对比。

每个柱子使用 KpChartItem 中预定义的 color 属性,颜色从深绿(KP 3-4)到亮绿(KP 5-6)到紫色(KP 7-9),用户可以通过柱子颜色直接判断每天的极光强度等级。标题行右侧的"本周峰值 KP9"用紫色文字(aurora3)突出显示最高值。

        Column({ space: 8 }) {
          ForEach(levelList, (lv: LevelItem) => {
            Column({ space: 4 }) {
              Row() {
                Text(lv.label).fontSize(10).fontColor(COLORS.title)
                Column().layoutWeight(1)
                Text(lv.pct.toString() + '%').fontSize(10).fontColor(lv.color).fontWeight(FontWeight.Bold)
              }
              .width('100%')
              Row() {
                Column().width(barW(lv.pct)).height(6).borderRadius(3).backgroundColor(lv.color)
              }
              .width('100%').height(6).borderRadius(3).backgroundColor(COLORS.card2)
            }
            .width('100%')
          }, (lv: LevelItem) => lv.label)
        }
        .width('100%')
        .padding(12).borderRadius(12).backgroundColor(COLORS.card)

等级看板展示 4 级极光概率的进度条。每级包含标签、百分比和进度条。进度条使用"内嵌"设计——外层 Row 设置了 backgroundColor(COLORS.card2) 作为背景轨,内层 Column 的宽度通过 barW(lv.pct) 计算(如 92% → 64.4%),背景色为对应等级颜色。这种"轨+填充"的双层进度条在深色主题中效果尤佳——深色轨与亮色填充的对比使进度一目了然。

第二十三段:行程团Tab - 时间轴布局

  @Builder
  tabTrips() {
    Scroll() {
      Column({ space: 0 }) {
        ForEach(this.trips, (tp: TripItem, idx: number) => {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Column()
                .width(8).height(8).borderRadius(4)
                .backgroundColor(tp.status === '已满' ? COLORS.red : COLORS.aurora)
              Column()
                .width(1).height(96).backgroundColor(COLORS.line)
            }
            Column({ space: 4 }) {
              Row() {
                Text(tp.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Column().layoutWeight(1)
                Text(tp.status).fontSize(8).fontColor(tp.status === '已满' ? COLORS.red : COLORS.aurora)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                  .backgroundColor(tp.status === '已满' ? '#3A1520' : '#0E3A2E')
              }
              .width('100%')
              Text('📅 ' + tp.date + ' · ' + tp.days.toString() + ' 天 · ' + tp.people).fontSize(9).fontColor(COLORS.sub)
              Text('🧭 领队 ' + tp.leader + ' · ' + tp.spots).fontSize(9).fontColor(COLORS.sub)
              Row({ space: 6 }) {
                Text(tp.price).fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
                Column().layoutWeight(1)
                Text('✏️').fontSize(11).onClick(() => { this.openEdit(idx); })
                Text('🗑️').fontSize(11).onClick(() => { this.openDelTrip(idx); })
              }
              .width('100%')
            }
            .layoutWeight(1)
            .padding(10).borderRadius(12).backgroundColor(COLORS.card)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
        }, (tp: TripItem) => tp.id.toString())
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

行程团 Tab 使用时间轴布局——每条行程左侧有一个 8x8 的圆点(时间节点)和一条 1x96 的垂直连接线,形成了从上到下的时间轴视觉。圆点颜色根据状态变化:已满显示红色、可报名显示极光绿。垂直连接线使用 COLORS.line(深蓝紫色),在深色背景上形成微妙的轴线。

每条行程的信息卡片包含四行:标题+状态标签、日期+天数+成团情况、领队+路线、价格+编辑/删除按钮。状态标签的颜色和背景色与圆点保持一致(红色/绿色体系)。价格使用金色(gold)显示,在深色背景中形成温暖的视觉焦点。编辑和删除按钮通过 onClick 调用 openEdit(idx)openDelTrip(idx) 方法——这种通过方法调用而非直接设置状态的写法,使得弹窗管理逻辑集中化,便于维护。

第二十四段:装备Tab - 三列宫格

  @Builder
  tabGear() {
    Scroll() {
      Column({ space: 10 }) {
        ForEach([0, 1, 2, 3], (row: number) => {
          Row({ space: 8 }) {
            ForEach(gearList, (g: GearItem) => {
              if (Math.floor((g.id - 1) / 3) === row) {
                Column({ space: 4 }) {
                  Text(g.icon).fontSize(22)
                  Text(g.name.length > 5 ? g.name.slice(0, 5) : g.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
                  Text(g.rent).fontSize(8).fontColor(COLORS.aurora)
                  Text('库存 ' + g.stock.toString()).fontSize(8).fontColor(COLORS.sub)
                  Text(g.hot).fontSize(8)
                }
                .layoutWeight(1)
                .padding({ top: 10, bottom: 10 }).borderRadius(12).backgroundColor(COLORS.card)
                .alignItems(HorizontalAlign.Center)
              }
            }, (g: GearItem) => g.id.toString())
          }
          .width('100%')
        })
        Column() { Text('装备全部支持极光季租赁 · 满 5 天 8 折').fontSize(9).fontColor(COLORS.sub) }
        .width('100%').alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

装备 Tab 使用"行遍历+条件过滤"的方式实现三列宫格。外层 ForEach 遍历 4 行(0-3),内层 ForEach 遍历 gearList 全部 12 件装备,通过 if (Math.floor((g.id - 1) / 3) === row) 判断当前装备是否属于当前行——ID 1-3 在第 0 行、ID 4-6 在第 1 行、依此类推。这种"行过滤"模式虽然需要遍历全部数据 4 次,但在数据量小(12 件)的情况下性能影响可以忽略。

每个装备卡片显示图标(22px)、名称(截断至 5 字符)、租赁价格(极光绿色)、库存(次要色)和热门标记("🔥"或空)。底部的"满 5 天 8 折"促销信息为用户提供了实用的优惠提示。

第二十五段:旅友圈Tab - 信息流与发布

  @Builder
  tabBuddies() {
    Scroll() {
      Column({ space: 8 }) {
        ForEach(this.buddies, (b: BuddyItem) => {
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Column() { Text(b.avatar).fontSize(18) }
              .width(34).height(34).borderRadius(17).backgroundColor(COLORS.card2)
              .justifyContent(FlexAlign.Center)
              Column({ space: 2 }) {
                Text(b.nick).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(b.time).fontSize(8).fontColor(COLORS.sub)
              }
              .alignItems(HorizontalAlign.Start)
              Column().layoutWeight(1)
              Text(b.tag).fontSize(8).fontColor(COLORS.violet)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
                .backgroundColor('#2A2450')
            }
            .width('100%')
            Text(b.text).fontSize(11).fontColor('#DCE3FF').lineHeight(17)
            Row({ space: 14 }) {
              Text('👍 ' + b.likes.toString()).fontSize(9).fontColor(COLORS.sub)
              Text('💬 ' + b.rep.toString()).fontSize(9).fontColor(COLORS.sub)
              Column().layoutWeight(1)
              Text('展开').fontSize(9).fontColor(COLORS.aurora)
            }
            .width('100%')
          }
          .width('100%')
          .padding(11).borderRadius(12).backgroundColor(COLORS.card)
          .alignItems(HorizontalAlign.Start)
        }, (b: BuddyItem) => b.id.toString())
        Column() { Text('+ 发布动态').fontSize(10).fontColor(COLORS.bg).fontWeight(FontWeight.Bold) }
        .width('100%').padding({ top: 10, bottom: 10 }).borderRadius(12).backgroundColor(COLORS.aurora)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => { this.openAdd(); })
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

旅友圈 Tab 使用信息流布局,每条动态包含头像+昵称+时间+标签的头部行、正文文本和点赞+回复+展开的操作行。正文使用 #DCE3FF(浅蓝白色)显示,在深色背景上保持高可读性。lineHeight(17) 确保多行文本的行间距舒适。标签使用紫罗兰色(violet)文字和深紫色(#2A2450)背景,形成与极光紫色调一致的标签视觉。

列表底部有一个极光绿色(aurora)的"+ 发布动态"按钮,点击调用 this.openAdd() 方法打开新增动态弹窗。按钮使用深色文字(COLORS.bg)在亮色背景上——这种"反转"配色使按钮在深色主题中格外醒目,是设计中常用的 CTA(Call To Action)强调手法。

第二十六段:极光笔记Tab - 图文横条

  @Builder
  tabNotes() {
    Scroll() {
      Column({ space: 8 }) {
        ForEach(this.notes, (n: NoteItem, idx: number) => {
          Row({ space: 10 }) {
            Column() { Text(n.icon).fontSize(20) }
            .width(52).height(52).borderRadius(12).backgroundColor(noteIconBg(n.id))
            .justifyContent(FlexAlign.Center)
            Column({ space: 4 }) {
              Text(n.title.length > 14 ? n.title.slice(0, 14) + '…' : n.title)
                .fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(n.spot + ' · ' + n.date + ' · ' + n.author).fontSize(9).fontColor(COLORS.sub)
              Row({ space: 10 }) {
                Text('👁️ ' + n.views.toString()).fontSize(8).fontColor(COLORS.sub)
                Text('⭐ ' + n.fav.toString()).fontSize(8).fontColor(COLORS.star)
                Column().layoutWeight(1)
                Text('🗑️').fontSize(10).opacity(0.7).onClick(() => { this.openDelNote(idx); })
              }
              .width('100%')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .padding(10).borderRadius(12).backgroundColor(COLORS.card)
        }, (n: NoteItem) => n.id.toString())
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

极光笔记 Tab 使用"左图右文"的横条布局。左侧是 52x52 的圆角方块图标,背景色通过 noteIconBg(n.id) 从 4 种深蓝紫色中循环选择——这种循环取色使笔记列表的图标背景呈现微妙的色彩变化,避免了单调。右侧三行:标题(截断至 14 字符+省略号)、来源+日期+作者、浏览数+收藏数+删除按钮。

删除按钮使用 opacity(0.7) 使其视觉上弱化——删除是一个"危险操作",通过降低不透明度使其不那么醒目,降低误触概率。点击调用 this.openDelNote(idx) 打开删除确认弹窗。

第二十七段:底部导航栏

  @Builder
  bottomBar() {
    Row({ space: 6 }) {
      ForEach(NAV_LIST, (ni: NavItem, bI: number) => {
        Column({ space: 2 }) {
          Text(ni.icon).fontSize(16)
            .opacity(this.mainTab === bI ? 1 : 0.45)
          Text(ni.label).fontSize(9)
            .fontColor(this.mainTab === bI ? COLORS.aurora : COLORS.sub)
            .fontWeight(this.mainTab === bI ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .borderRadius(12)
        .backgroundColor(this.mainTab === bI ? '#0E3A2E' : Color.Transparent)
        .justifyContent(FlexAlign.Center)
        .onClick(() => { this.mainTab = bI; })
      }, (ni: NavItem) => ni.label)
    }
    .width('100%')
    .padding({ left: 8, right: 8, top: 4, bottom: 8 })
    .backgroundColor(COLORS.card)
  }

底部导航栏有 4 个 Tab(首页🏔️/营地🛖/装备铺🎒/我的🧑‍🚀)。选中态使用三重视觉变化:图标不透明度从 0.45 提升至 1、文字颜色从灰色变为极光绿、文字加粗、背景色变为深绿(#0E3A2E)。这种三重变化使选中态在深色主题中极为醒目。

选中态背景使用 #0E3A2E(极暗绿色),与极光主题一致——在深色背景上呈现微妙的绿色光晕,仿佛底部导航栏被极光照射。未选中态使用 Color.Transparent(完全透明),避免视觉干扰。

第二十八段:build() 根装配与弹窗

  build() {
    Column() {
      this.header()
      Stack({ alignContent: Alignment.TopStart }) {
        if (this.mainTab === 0) {
          Column() {
            this.subNav()
            if (this.subTab === 0) {
              this.tabPick()
            } else if (this.subTab === 1) {
              this.tabSpots()
            } else if (this.subTab === 2) {
              this.tabIndex1()
            } else if (this.subTab === 3) {
              this.tabTrips()
            } else if (this.subTab === 4) {
              this.tabGear()
            } else if (this.subTab === 5) {
              this.tabBuddies()
            } else {
              this.tabNotes()
            }
          }
          .width('100%')
          .height('100%')
        } else if (this.mainTab === 1) {
          this.tabCamp()
        } else if (this.mainTab === 2) {
          this.tabShop()
        } else {
          this.tabMine()
        }
        this.fxLayer()
      }
      .width('100%')
      .layoutWeight(1)

      this.bottomBar()

build() 方法的结构是"头部→内容Stack(含特效层)→底部导航→弹窗层"。头部始终显示,内容区使用 Stack 容器包裹主内容和 fxLayer() 特效层——特效层在 Stack 中位于主内容之上(后渲染的元素在上层),但由于特效元素的透明度极低(0.12-0.9 且面积小),不会实质遮挡内容。StackalignContent: Alignment.TopStart 确保特效从左上角开始定位。

主内容的路由是两层嵌套:第一层根据 mainTab 在首页、营地图鉴、装备铺、我的之间切换;首页内部第二层根据 subTab 在 7 个子 Tab 之间切换。这种"两级 if-else 链"的嵌套路由模式,使得首页承载了极其丰富的功能(7 个子 Tab),而其他主 Tab 保持简洁。

三个弹窗(新增动态、编辑行程、删除确认)在 build() 末尾通过 if 条件渲染,使用 zIndex(999) 浮在所有内容之上。弹窗内容结构与其他应用的弹窗类似——遮罩+标题+表单/确认信息+操作按钮——但配色完全遵循深色主题:弹窗背景使用 COLORS.card(深蓝紫),输入框使用 #0E3A2E(深绿)背景,确认按钮使用极光绿(aurora)背景。删除确认弹窗的确认按钮使用红色(red)背景,在深色主题中红色更加醒目,有效传达"危险操作"的警示。

流程图

mainTab=0

0

1

2

3

4

5

6

mainTab=1

mainTab=2

mainTab=3

addModal

editModal

delModal

无弹窗

内容非空

内容为空

索引有效

delTarget=trip

delTarget=note

700ms 周期

组件实例化

aboutToAppear

启动 setInterval 700ms

tick++ / glow 切换

build 根装配

header 头部渲染

定位栏+搜索/通知

今日极光强度大卡 KP8

热门观测点横滚 8个

Stack 内容区

mainTab 判断

首页 subNav 7子Tab

subTab 判断

tabPick 精选大卡+瀑布

tabSpots 观测点编号列表

tabIndex1 KP柱状图+等级看板

tabTrips 行程时间轴

tabGear 装备三列宫格

tabBuddies 旅友圈信息流

tabNotes 极光笔记横条

tabCamp 营地图鉴

tabShop 装备铺瀑布

tabMine 个人中心

fxLayer 特效层

极光飘带 3条 rotate+position

星光闪烁 8颗 dotX/dotY

bottomBar 底部导航

弹窗状态

发布旅友动态弹窗

编辑行程弹窗

删除确认弹窗

渲染完成

doAdd

push 新BuddyItem

不操作

doEdit

修改 trips 属性

doDel

splice trips

splice notes

技术对比表

技术维度实现方式设计特点性能考量
色彩系统17个语义化变量+深色极地主题极光绿/紫/星光黄三色体系,深蓝紫背景深色背景降低OLED功耗,高对比文字
导航架构4主Tab+7子Tab(4+3两排)双层导航两排子Tab双色选中态(绿/紫)双层if-else路由,切换成本低
数据模型@Observed标记5个类tags数组属性,9个属性覆盖全维度属性变更精准触发视图刷新
初始数据const数组预填充6组数据8观测点+6行程+12装备+8旅友+10笔记加载即有数据,无网络请求
纯函数层7个全局函数KP映射+星光位置计算+取色函数确定性计算,无随机数开销
特效系统setInterval 700ms+tick计数器+glow切换3条飘带rotate+position,8颗星光位移单定时器驱动,fxLayer仅Stack内渲染
飘带动画rotate旋转+position位移+opacity切换三条飘带不同角度和速度,tick%20循环Row空元素+CSS变换,无DOM重排
星光闪烁dotX/dotY纯函数计算位置质数系数避免同步,s%2交替透明度8个Text节点,位置重算轻量
KP柱状图ForEach+height=kp*10%底对齐柱子,3色编码KP等级7个Column节点,CSS高度变化
等级看板双层Row进度条(轨+填充)barW函数映射百分比→宽度4个进度条,CSS宽度变化
行程时间轴圆点+垂直线+卡片状态颜色编码(红/绿),编辑/删除入口ForEach+splice局部刷新
装备宫格行遍历+条件过滤(行号=ceil(id/3))三列布局,名称截断5字符12项遍历4次,小数据量可接受
旅友圈信息流+push新增标签紫色系,正文浅蓝白高可读push触发列表末尾追加
笔记列表左图右文横条+noteIconBg取色4色循环图标背景,删除按钮opacity0.7splice删除触发局部刷新
弹窗系统方法封装(openAdd/openEdit/openDel*)互斥弹窗,delTarget区分目标同一弹窗复用两种删除场景
fxLayer定位Stack内层级,clip(true)裁剪仅覆盖内容区不遮挡头部特效与内容同级渲染,低透明度
底部导航4Tab+三重选中态(透明度/颜色/背景)选中背景#0E3A2E极光绿光晕CSS属性变化,无DOM开销
表单交互TextInput+onChange+@State绑定深绿输入框背景,极光绿确认按钮实时同步无防抖
CRUD方法doAdd/doEdit/doDel封装doDel通过delTarget分支处理边界检查+索引校验
数据一致性观测点KP↔柱状图KP↔等级看板概率三处KP数据逻辑自洽无冗余计算

总结与工程实践启示

第一,深色主题在特定场景下不仅是视觉选择更是功能需求。 本应用面向极光观测场景,用户在暗夜中使用手机查看极光预报时,深色界面能够保持视觉暗适应(Dark Adaptation)——人眼在暗环境下需要约 20-30 分钟达到最佳暗适应状态,而一个亮色界面会瞬间破坏这一适应。应用的背景色 #0B1030(近黑深蓝)确保了最低限度的光污染,卡片色 #151C4A#1D2760 在保持层次感的同时也维持了低亮度。这种"场景驱动的主题选择"理念超越了单纯的审美偏好,体现了对用户使用场景的深度理解。

第二,基于数学函数的特效系统展示了"零依赖动画"的可能性。 fxLayer 中的极光飘带和星光闪烁完全由 ArkTS 原生组件和纯数学函数实现——dotX/dotY 使用质数系数和取模运算生成确定性位置序列,rotateposition 属性实现旋转变换和位移。整个特效系统没有引入任何第三方动画库,仅使用了一个 setInterval 定时器和一个递增的 tick 计数器。这种极简方案在性能上几乎零开销——每次 tick 变化只触发特效层的 11 个元素(3 飘带+8 星光)重新计算位置,不会导致整个页面的重绘。在实际项目中,当动画需求在可控范围内时,自建数学驱动的特效往往比引入 Lottie 等动画库更加轻量和可控。

第三,两层导航架构(4主Tab+7子Tab)在功能密度与导航简洁之间取得了优秀平衡。 4 个底部 Tab 保证了主要功能的快速切换,7 个子 Tab 以"4+3"两排布局在首页内承载了丰富的细分功能。两排子 Tab 的双色选中态(第一排极光绿、第二排紫罗兰)不仅视觉上区分了两排,还与极光的绿/紫双色主调呼应。这种设计使所有子 Tab 一目了然,无需横向滑动即可触达,提高了功能的可发现性。相比之下,单排横滚的子 Tab 虽然节省垂直空间,但隐藏在滚动区域的 Tab 可发现性较低。

第四,方法封装的弹窗管理模式提升了代码的可维护性。 openAdd()/openEdit(idx)/openDelNote(idx)/openDelTrip(idx) 四个方法统一处理弹窗的打开逻辑——设置表单预填值、打开目标弹窗、关闭其他弹窗。这种集中管理模式使得弹窗的修改和维护只需在一处进行,而非散落在各个 onClick 回调中。doDel 方法通过 delTarget 字段分支处理笔记和行程两种删除场景,使同一个确认弹窗和删除方法服务于两种目标——这种"单方法多目标"的设计减少了代码重复,但也要求开发者在使用时正确设置 delTarget

第五,数据可视化在深色主题中需要特殊的配色策略。 KP 柱状图使用极光三色体系(深绿→亮绿→紫色)编码 KP 等级,在深色背景上这些颜色都具有较高的亮度和饱和度,确保了可辨识性。等级看板的进度条使用"深色轨+亮色填充"的双层设计,通过对比使进度一目了然。这种深色主题下的数据可视化配色策略——使用高饱和度色彩而非传统的浅色主题中的低饱和度色彩——是深色 UI 设计的重要原则。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 头部样式:极地影视头部(搜索栏 + 定位 + 今日极光强度大卡 + 热门观测点横滚)无动画
// 底部主tab:首页 / 营地图鉴 / 装备铺 / 我的(4个)
// 首页内容tab:精选 / 观测点 / 极光指数 / 行程团 / 装备 / 旅友圈 / 极光笔记(7个)
// 特效:极光飘带 + 星光闪烁(setInterval 驱动,仅覆盖内容区不遮挡头部)
// 弹框:新增旅友动态 / 编辑行程 / 删除笔记确认(if条件渲染 + constraintSize + zIndex)

interface ColorPalette {
  bg: string;
  card: string;
  card2: string;
  title: string;
  sub: string;
  line: string;
  aurora: string;
  aurora2: string;
  aurora3: string;
  violet: string;
  star: string;
  gold: string;
  red: string;
  green: string;
  blue: string;
  mask: string;
  white: string;
}

const COLORS: ColorPalette = {
  bg: '#0B1030',
  card: '#151C4A',
  card2: '#1D2760',
  title: '#F0F4FF',
  sub: '#9AA7D8',
  line: '#2A356B',
  aurora: '#7CF7D4',
  aurora2: '#4DD0A8',
  aurora3: '#B388FF',
  violet: '#C9B6FF',
  star: '#FFF3B0',
  gold: '#FFD97A',
  red: '#FF7B7B',
  green: '#7CF7D4',
  blue: '#7FB5FF',
  mask: '#000000CC',
  white: '#FFFFFF'
};

// ============ 观测点 ============
@Observed
export class AuroraSpot {
  id: number = 0;
  name: string = '';
  region: string = '';
  kp: number = 0;
  best: string = '';
  score: number = 0;
  level: string = '';
  tags: string[] = [];
  constructor(id: number, name: string, region: string, kp: number, best: string, score: number, level: string, tags: string[]) {
    this.id = id; this.name = name; this.region = region; this.kp = kp;
    this.best = best; this.score = score; this.level = level; this.tags = tags;
  }
}

// ============ 行程团 ============
@Observed
export class TripItem {
  id: number = 0;
  title: string = '';
  date: string = '';
  days: number = 0;
  price: string = '';
  people: string = '';
  leader: string = '';
  spots: string = '';
  status: string = '';
  constructor(id: number, title: string, date: string, days: number, price: string, people: string, leader: string, spots: string, status: string) {
    this.id = id; this.title = title; this.date = date; this.days = days;
    this.price = price; this.people = people; this.leader = leader; this.spots = spots; this.status = status;
  }
}

// ============ 装备 ============
@Observed
export class GearItem {
  id: number = 0;
  name: string = '';
  cat: string = '';
  price: string = '';
  rent: string = '';
  stock: number = 0;
  hot: string = '';
  icon: string = '';
  constructor(id: number, name: string, cat: string, price: string, rent: string, stock: number, hot: string, icon: string) {
    this.id = id; this.name = name; this.cat = cat; this.price = price;
    this.rent = rent; this.stock = stock; this.hot = hot; this.icon = icon;
  }
}

// ============ 旅友 ============
@Observed
export class BuddyItem {
  id: number = 0;
  nick: string = '';
  avatar: string = '';
  text: string = '';
  likes: number = 0;
  rep: number = 0;
  tag: string = '';
  time: string = '';
  constructor(id: number, nick: string, avatar: string, text: string, likes: number, rep: number, tag: string, time: string) {
    this.id = id; this.nick = nick; this.avatar = avatar; this.text = text;
    this.likes = likes; this.rep = rep; this.tag = tag; this.time = time;
  }
}

// ============ 极光笔记 ============
@Observed
export class NoteItem {
  id: number = 0;
  title: string = '';
  spot: string = '';
  date: string = '';
  author: string = '';
  views: number = 0;
  fav: number = 0;
  icon: string = '';
  constructor(id: number, title: string, spot: string, date: string, author: string, views: number, fav: number, icon: string) {
    this.id = id; this.title = title; this.spot = spot; this.date = date;
    this.author = author; this.views = views; this.fav = fav; this.icon = icon;
  }
}

// ============ 图表 ============
interface KpChartItem {
  day: string;
  kp: number;
  color: string;
}

const kpCharts: KpChartItem[] = [
  { day: '周一', kp: 4, color: '#4DD0A8' },
  { day: '周二', kp: 6, color: '#7CF7D4' },
  { day: '周三', kp: 3, color: '#3E9D88' },
  { day: '周四', kp: 5, color: '#7CF7D4' },
  { day: '周五', kp: 8, color: '#B388FF' },
  { day: '周六', kp: 7, color: '#C9B6FF' },
  { day: '周日', kp: 9, color: '#B388FF' }
];

interface LevelItem {
  label: string;
  pct: number;
  color: string;
}

const levelList: LevelItem[] = [
  { label: 'KP 1-2 极光微弱', pct: 18, color: '#3E9D88' },
  { label: 'KP 3-4 肉眼可见', pct: 45, color: '#4DD0A8' },
  { label: 'KP 5-6 色彩丰富', pct: 70, color: '#7CF7D4' },
  { label: 'KP 7-8 极光大爆发', pct: 92, color: '#B388FF' }
];

// ============ 全局写死数据 ============
const spotList: AuroraSpot[] = [
  new AuroraSpot(1, '罗瓦涅米极光村', '芬兰·拉普兰', 8, '22:00-02:00', 9.2, '大爆发', ['玻璃屋', '驯鹿场']),
  new AuroraSpot(2, '特罗姆瑟峡湾', '挪威·北极圈', 7, '21:30-01:30', 9.0, '活跃', ['峡湾', '缆车']),
  new AuroraSpot(3, '阿比斯库天空站', '瑞典·拉普兰', 9, '20:30-03:00', 9.5, '大爆发', ['天空站', '无光害']),
  new AuroraSpot(4, '伊卢利萨特冰湾', '格陵兰', 6, '22:30-01:00', 8.4, '活跃', ['冰山', '冰川']),
  new AuroraSpot(5, '费尔班克斯', '美国·阿拉斯加', 7, '21:00-02:30', 8.8, '活跃', ['温泉', '苔原']),
  new AuroraSpot(6, '黄刀镇', '加拿大·西北地区', 9, '20:00-03:30', 9.3, '大爆发', ['帐篷营地', '冰湖']),
  new AuroraSpot(7, '漠河北极村', '中国·黑龙江', 5, '22:00-01:00', 7.2, '偶发', ['中国最北', '雪屋']),
  new AuroraSpot(8, '雷克雅未克郊野', '冰岛·黄金圈', 6, '21:00-01:30', 8.1, '活跃', ['黑沙滩', '瀑布'])
];

const tripList: TripItem[] = [
  new TripItem(1, '冰岛极光环岛 8 日', '2026-09-18', 8, '¥16800', '已成团 16/16', '老狼', '黄金圈/黑沙滩/冰河湖', '已满'),
  new TripItem(2, '芬兰玻璃屋追光 5 日', '2026-10-02', 5, '¥12800', '已成团 9/12', '苏苏', '罗瓦涅米/伊纳里', '可报名'),
  new TripItem(3, '挪威峡湾极光自驾 7 日', '2026-10-20', 7, '¥15800', '已成团 6/10', '阿哲', '特罗姆瑟/罗弗敦群岛', '可报名'),
  new TripItem(4, '阿拉斯加温泉极光 6 日', '2026-11-05', 6, '¥14500', '已成团 11/14', '大熊', '费尔班克斯/迪纳利', '可报名'),
  new TripItem(5, '漠河雪乡极光初体验 4 日', '2026-12-10', 4, '¥3980', '已成团 18/20', '雪狐', '北极村/龙江第一湾', '即将满员'),
  new TripItem(6, '格陵兰极夜探险 9 日', '2026-12-22', 9, '¥21800', '已成团 4/8', '老狼', '伊卢利萨特/努克', '可报名')
];

const gearList: GearItem[] = [
  new GearItem(1, '防风羽绒服', '保暖', '¥1200', '¥60/天', 24, '🔥', '🧥'),
  new GearItem(2, '加厚冲锋裤', '保暖', '¥680', '¥35/天', 31, '', '👖'),
  new GearItem(3, '雪地靴', '保暖', '¥520', '¥30/天', 28, '', '🥾'),
  new GearItem(4, '暖宝宝贴(50片)', '耗材', '¥45', '¥5/包', 66, '', '🔥'),
  new GearItem(5, '三脚架', '摄影', '¥880', '¥40/天', 17, '🔥', '📷'),
  new GearItem(6, '广角镜头(14mm)', '摄影', '¥4200', '¥120/天', 9, '🔥', '🔭'),
  new GearItem(7, '快门线', '摄影', '¥120', '¥8/天', 40, '', '🎛️'),
  new GearItem(8, '备用电池套装', '摄影', '¥360', '¥15/天', 22, '', '🔋'),
  new GearItem(9, '头灯', '安全', '¥150', '¥10/天', 35, '', '💡'),
  new GearItem(10, '保温水壶', '安全', '¥180', '¥10/天', 30, '', '🫖'),
  new GearItem(11, '雪地帐篷', '露营', '¥1600', '¥80/天', 12, '🔥', '⛺'),
  new GearItem(12, '睡袋(-30°)', '露营', '¥980', '¥55/天', 15, '', '🛏️')
];

const buddyList: BuddyItem[] = [
  new BuddyItem(1, '追光者阿哲', '🦊', '昨晚在特罗姆瑟拍到了极光大爆发,M档 15s ISO1600 曝光刚刚好,附一张成片!', 328, 12, '拍到了', '2小时前'),
  new BuddyItem(2, '苏苏看星星', '🐧', '黄刀镇的极光预报更新了,今晚 KP8,帐篷营地还有 5 个位置,求拼!', 156, 8, '求拼团', '3小时前'),
  new BuddyItem(3, '北极圈常驻民', '🦌', '给新手一个忠告:别只盯着极光,拉普兰的银河同样震撼,人少的时候抬头就有。', 89, 5, '经验分享', '5小时前'),
  new BuddyItem(4, '拍极光的猫', '🐈', '出 14mm 广角镜头租期 3 天,冰岛环岛刚用完,成色全新,可面交。', 45, 3, '转装备', '8小时前'),
  new BuddyItem(5, '雪原漫游者', '🐻', '阿比斯库天空站本周极光指数 9,我已经连续蹲了 3 晚,今晚有望出片!', 203, 10, '直播中', '10小时前'),
  new BuddyItem(6, '冻成冰棍也值', '🧊', '漠河北极村偶发级极光,虽然只有淡绿色,但作为国内能看到极光的地方已经知足了。', 77, 4, '打卡', '昨天'),
  new BuddyItem(7, '北方来信', '🕊️', '求同行!12 月格陵兰极夜探险,目前 4 人,想找两个摄影搭子,分摊向导费。', 66, 6, '求同行', '昨天'),
  new BuddyItem(8, '极光摄影日记', '📘', '整理了 2026 全年的极光摄影教程合集,从参数设置到后期调色,放在笔记里自取。', 512, 21, '干货', '2天前')
];

interface NavItem {
  icon: string;
  label: string;
}

const NAV_LIST: NavItem[] = [
  { icon: '🏔️', label: '首页' },
  { icon: '🛖', label: '营地' },
  { icon: '🎒', label: '装备铺' },
  { icon: '🧑‍🚀', label: '我的' }
];

const noteList: NoteItem[] = [
  new NoteItem(1, '追光新手必读:参数设置与机位选择', '特罗姆瑟', '2026-08-12', '追光者阿哲', 3210, 482, '📘'),
  new NoteItem(2, '零下 30 度拍摄 3 小时的保暖攻略', '费尔班克斯', '2026-08-08', '北极圈常驻民', 2890, 410, '🧥'),
  new NoteItem(3, '黄刀镇冰湖机位踩点实录', '黄刀镇', '2026-08-05', '苏苏看星星', 1980, 356, '🗺️'),
  new NoteItem(4, '手机拍极光:长曝光也能出大片', '罗瓦涅米', '2026-07-28', '手机也能拍', 2650, 391, '📱'),
  new NoteItem(5, '极光预测 App 横向评测:哪个最准', '阿比斯库', '2026-07-20', '数据控小王', 1540, 289, '📊'),
  new NoteItem(6, '冰岛环岛极光路线 Day1-8 全记录', '冰岛', '2026-07-15', '雪原漫游者', 4520, 668, '🚙'),
  new NoteItem(7, '玻璃屋住宿避坑指南:保暖与观景兼得', '芬兰', '2026-07-10', '冻成冰棍也值', 1210, 234, '🏠'),
  new NoteItem(8, '后期调色:把绿光调出紫粉渐变', '后期技巧', '2026-07-02', '极光摄影日记', 3860, 521, '🎨'),
  new NoteItem(9, '漠河追光全攻略:预算与交通', '漠河', '2026-06-25', '雪狐', 980, 176, '🧭'),
  new NoteItem(10, '极光团 vs 自由行:成本真实对比', '综合', '2026-06-18', '账房先生', 2340, 342, '💰')
];

// ============ 顶层纯函数 ============
function kpBarH(kp: number): string {
  return (kp * 10).toString() + '%';
}

function kpLevel(kp: number): string {
  if (kp >= 8) { return '大爆发'; }
  if (kp >= 6) { return '活跃'; }
  if (kp >= 4) { return '可见'; }
  return '微弱';
}

function kpColor(kp: number): string {
  if (kp >= 8) { return COLORS.aurora3; }
  if (kp >= 6) { return COLORS.aurora; }
  return COLORS.aurora2;
}

function dotX(seed: number, tick: number): number {
  return ((seed * 61 + tick * 3) % 90) + 5;
}

function dotY(seed: number, tick: number): number {
  return ((seed * 37 + tick * 2) % 80) + 5;
}

function barW(v: number): string {
  return (v * 0.7).toString() + '%';
}

function noteIconBg(seed: number): string {
  const arr: string[] = ['#1D2760', '#253070', '#20305F', '#1A2A55'];
  return arr[seed % 4];
}

@Entry
@Component
struct Page {
  @State mainTab: number = 0;
  @State subTab: number = 0;
  @State tick: number = 0;
  @State glow: boolean = false;
  // 弹框状态
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = -1;
  @State delTarget: string = 'note';
  @State editTitle: string = '';
  @State editDate: string = '';
  @State addNick: string = '';
  @State addText: string = '';
  // 列表
  @State trips: TripItem[] = [
    new TripItem(1, '冰岛极光环岛 8 日', '2026-09-18', 8, '¥16800', '已成团 16/16', '老狼', '黄金圈/黑沙滩/冰河湖', '已满'),
    new TripItem(2, '芬兰玻璃屋追光 5 日', '2026-10-02', 5, '¥12800', '已成团 9/12', '苏苏', '罗瓦涅米/伊纳里', '可报名'),
    new TripItem(3, '挪威峡湾极光自驾 7 日', '2026-10-20', 7, '¥15800', '已成团 6/10', '阿哲', '特罗姆瑟/罗弗敦群岛', '可报名'),
    new TripItem(4, '阿拉斯加温泉极光 6 日', '2026-11-05', 6, '¥14500', '已成团 11/14', '大熊', '费尔班克斯/迪纳利', '可报名'),
    new TripItem(5, '漠河雪乡极光初体验 4 日', '2026-12-10', 4, '¥3980', '已成团 18/20', '雪狐', '北极村/龙江第一湾', '即将满员'),
    new TripItem(6, '格陵兰极夜探险 9 日', '2026-12-22', 9, '¥21800', '已成团 4/8', '老狼', '伊卢利萨特/努克', '可报名')
  ];
  @State notes: NoteItem[] = [
    new NoteItem(1, '追光新手必读:参数设置与机位选择', '特罗姆瑟', '2026-08-12', '追光者阿哲', 3210, 482, '📘'),
    new NoteItem(2, '零下 30 度拍摄 3 小时的保暖攻略', '费尔班克斯', '2026-08-08', '北极圈常驻民', 2890, 410, '🧥'),
    new NoteItem(3, '黄刀镇冰湖机位踩点实录', '黄刀镇', '2026-08-05', '苏苏看星星', 1980, 356, '🗺️'),
    new NoteItem(4, '手机拍极光:长曝光也能出大片', '罗瓦涅米', '2026-07-28', '手机也能拍', 2650, 391, '📱'),
    new NoteItem(5, '极光预测 App 横向评测:哪个最准', '阿比斯库', '2026-07-20', '数据控小王', 1540, 289, '📊'),
    new NoteItem(6, '冰岛环岛极光路线 Day1-8 全记录', '冰岛', '2026-07-15', '雪原漫游者', 4520, 668, '🚙'),
    new NoteItem(7, '玻璃屋住宿避坑指南:保暖与观景兼得', '芬兰', '2026-07-10', '冻成冰棍也值', 1210, 234, '🏠'),
    new NoteItem(8, '后期调色:把绿光调出紫粉渐变', '后期技巧', '2026-07-02', '极光摄影日记', 3860, 521, '🎨')
  ];
  @State buddies: BuddyItem[] = [
    new BuddyItem(1, '追光者阿哲', '🦊', '昨晚在特罗姆瑟拍到了极光大爆发,M档 15s ISO1600 曝光刚刚好,附一张成片!', 328, 12, '拍到了', '2小时前'),
    new BuddyItem(2, '苏苏看星星', '🐧', '黄刀镇的极光预报更新了,今晚 KP8,帐篷营地还有 5 个位置,求拼!', 156, 8, '求拼团', '3小时前'),
    new BuddyItem(3, '北极圈常驻民', '🦌', '给新手一个忠告:别只盯着极光,拉普兰的银河同样震撼,人少的时候抬头就有。', 89, 5, '经验分享', '5小时前'),
    new BuddyItem(4, '拍极光的猫', '🐈', '出 14mm 广角镜头租期 3 天,冰岛环岛刚用完,成色全新,可面交。', 45, 3, '转装备', '8小时前'),
    new BuddyItem(5, '雪原漫游者', '🐻', '阿比斯库天空站本周极光指数 9,我已经连续蹲了 3 晚,今晚有望出片!', 203, 10, '直播中', '10小时前')
  ];
  fxTimer: number = -1;

  aboutToAppear(): void {
    this.fxTimer = setInterval(() => {
      this.tick = this.tick + 1;
      this.glow = !this.glow;
    }, 700);
  }

  aboutToDisappear(): void {
    clearInterval(this.fxTimer);
  }

  // ============ 弹框工具 ============
  openAdd(): void {
    this.addNick = '';
    this.addText = '';
    this.addModal = true;
    this.editModal = false;
    this.delModal = false;
  }

  openEdit(idx: number): void {
    this.editIdx = idx;
    this.editTitle = this.trips[idx].title;
    this.editDate = this.trips[idx].date;
    this.addModal = false;
    this.editModal = true;
    this.delModal = false;
  }

  openDelNote(idx: number): void {
    this.delTarget = 'note';
    this.editIdx = idx;
    this.addModal = false;
    this.editModal = false;
    this.delModal = true;
  }

  openDelTrip(idx: number): void {
    this.delTarget = 'trip';
    this.editIdx = idx;
    this.addModal = false;
    this.editModal = false;
    this.delModal = true;
  }

  doAdd(): void {
    if (this.addText.length > 0) {
      const n = this.buddies.length + 1;
      this.buddies.push(new BuddyItem(n, this.addNick.length > 0 ? this.addNick : '星野旅人', '🪐', this.addText, 0, 0, '新动态', '刚刚'));
      this.addModal = false;
    }
  }

  doEdit(): void {
    if (this.editIdx >= 0 && this.editIdx < this.trips.length) {
      this.trips[this.editIdx].title = this.editTitle;
      this.trips[this.editIdx].date = this.editDate;
      this.editModal = false;
    }
  }

  doDel(): void {
    if (this.delTarget === 'trip') {
      if (this.editIdx >= 0 && this.editIdx < this.trips.length) {
        this.trips.splice(this.editIdx, 1);
        this.delModal = false;
      }
    } else {
      if (this.editIdx >= 0 && this.editIdx < this.notes.length) {
        this.notes.splice(this.editIdx, 1);
        this.delModal = false;
      }
    }
  }

  // ============ 特效层 ============
  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      // 极光飘带(3条)
      ForEach([0, 1, 2], (i: number) => {
        Row()
          .width('130%')
          .height(14 + (i * 8))
          .borderRadius(20)
          .backgroundColor(i % 3 === 0 ? COLORS.aurora : (i % 3 === 1 ? COLORS.aurora3 : COLORS.aurora2))
          .opacity(0.12 + (this.glow ? 0.06 : 0))
          .rotate({ angle: -12 + (i * 6) })
          .position({ x: -40 + (this.tick % 20) * (i + 1), y: 30 + (i * 46) })
      }, (i: number) => i.toString())
      // 星光闪烁
      ForEach([0, 1, 2, 3, 4, 5, 6, 7], (s: number) => {
        Text('✦')
          .fontSize(7 + (s % 3) * 3)
          .fontColor(COLORS.star)
          .opacity(s % 2 === 0 ? 0.9 : 0.25)
          .position({ x: dotX(s, this.tick), y: dotY(s, this.tick) })
      }, (s: number) => s.toString())
    }
    .width('100%')
    .height('100%')
    .clip(true)
  }

  // ============ 头部(电商极地风,无动画) ============
  @Builder
  header() {
    Column({ space: 10 }) {
      Row({ space: 8 }) {
        Column() { Text('📍').fontSize(15) }
        .width(32).height(32).borderRadius(16).backgroundColor(COLORS.card2)
        .justifyContent(FlexAlign.Center)
        Column({ space: 2 }) {
          Text('北极圈观测站').fontSize(11).fontColor(COLORS.sub)
          Text('罗瓦涅米 · 晴 -18°C').fontSize(9).fontColor(COLORS.aurora2)
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Row({ space: 6 }) {
          Column() { Text('🔍').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
          .justifyContent(FlexAlign.Center)
          Column() { Text('🔔').fontSize(14) }
          .width(30).height(30).borderRadius(15).backgroundColor(COLORS.card2)
          .justifyContent(FlexAlign.Center)
        }
      }
      .width('100%')

      // 今日极光强度大卡
      Row({ space: 12 }) {
        Column({ space: 3 }) {
          Text('今夜极光').fontSize(10).fontColor(COLORS.sub)
          Text('KP 8').fontSize(28).fontColor(COLORS.aurora).fontWeight(FontWeight.Bold)
          Text('大爆发 · 概率 92%').fontSize(9).fontColor(COLORS.star)
        }
        .alignItems(HorizontalAlign.Start)
        Column().layoutWeight(1)
        Column({ space: 6 }) {
          Row({ space: 4 }) {
            ForEach([0, 1, 2, 3, 4, 5, 6, 7], (b: number) => {
              Column()
                .width(6).height(b < 8 ? 12 + b * 3 : 0)
                .borderRadius(3).backgroundColor(b < 5 ? COLORS.aurora2 : COLORS.aurora)
            }, (b: number) => b.toString())
          }
          .alignItems(VerticalAlign.Bottom)
          Text('22:00 后云层将消散').fontSize(8).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(14)
      .borderRadius(16)
      .backgroundColor(COLORS.card)
      .border({ width: 1, color: COLORS.aurora3 })

      // 热门观测点横滚
      Scroll() {
        Row({ space: 10 }) {
          ForEach(spotList, (sp: AuroraSpot) => {
            Column({ space: 4 }) {
              Text(sp.name.length > 6 ? sp.name.slice(0, 6) : sp.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
              Text(sp.region).fontSize(8).fontColor(COLORS.sub)
              Text('KP ' + sp.kp.toString()).fontSize(9).fontColor(kpColor(sp.kp)).fontWeight(FontWeight.Bold)
            }
            .width(88).padding({ top: 8, bottom: 8 }).borderRadius(10)
            .backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
          }, (sp: AuroraSpot) => sp.id.toString())
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
      .height(66)
    }
    .width('100%')
    .padding({ left: 12, right: 12, top: 8, bottom: 8 })
    .backgroundColor(COLORS.bg)
  }

  // ============ 内容tab导航(7个,两排 4+3) ============
  @Builder
  subNav() {
    Column({ space: 6 }) {
      Row({ space: 6 }) {
        ForEach(['精选', '观测点', '极光指数', '行程团'], (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(11)
              .fontColor(this.subTab === i ? COLORS.bg : COLORS.sub)
              .fontWeight(this.subTab === i ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1)
          .padding({ top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(this.subTab === i ? COLORS.aurora : COLORS.card2)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.subTab = i; })
        }, (t: string) => t)
      }
      .width('100%')
      Row({ space: 6 }) {
        ForEach(['装备', '旅友圈', '极光笔记'], (t: string, i: number) => {
          Column() {
            Text(t)
              .fontSize(11)
              .fontColor(this.subTab === i + 4 ? COLORS.bg : COLORS.sub)
              .fontWeight(this.subTab === i + 4 ? FontWeight.Bold : FontWeight.Normal)
          }
          .layoutWeight(1)
          .padding({ top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(this.subTab === i + 4 ? COLORS.violet : COLORS.card2)
          .alignItems(HorizontalAlign.Center)
          .onClick(() => { this.subTab = i + 4; })
        }, (t: string) => t)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 12, right: 12 })
  }

  // ============ Tab0 精选:大卡横滚 + 双列瀑布 ============
  @Builder
  tabPick() {
    Scroll() {
      Column({ space: 10 }) {
        // 大卡横滚
        Scroll() {
          Row({ space: 10 }) {
            ForEach([0, 1, 2], (b: number) => {
              Column({ space: 6 }) {
                Text(b === 0 ? '🌌 极光季开启' : (b === 1 ? '⛺ 天空站特惠' : '📷 追光课堂'))
                  .fontSize(16).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
                Text(b === 0 ? '阿比斯库 KP9 持续一周' : (b === 1 ? '住 3 晚送 1 晚向导' : '新手上路 8 折'))
                  .fontSize(9).fontColor('#DCE3FF')
                Row({ space: 6 }) {
                  Column() { Text('立即查看').fontSize(9).fontColor(COLORS.bg) }
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
                  .backgroundColor(COLORS.white)
                }
                .width('100%')
              }
              .layoutWeight(1)
              .height(86).padding(12).borderRadius(14)
              .backgroundColor(b === 0 ? '#2A4BFF' : (b === 1 ? '#7B4DFF' : '#0E8A6D'))
              .alignItems(HorizontalAlign.Start)
              .justifyContent(FlexAlign.SpaceBetween)
            }, (b: number) => b.toString())
          }
          .width('100%')
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .height(86)

        // 观测点推荐双列瀑布
        ForEach([0, 1], (col: number) => {
          Column({ space: 8 }) {
            ForEach(spotList, (sp: AuroraSpot) => {
              if (sp.id % 2 === col) {
                Column({ space: 5 }) {
                  Row() {
                    Text(sp.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                    Column().layoutWeight(1)
                    Text('KP ' + sp.kp.toString()).fontSize(9).fontColor(kpColor(sp.kp)).fontWeight(FontWeight.Bold)
                  }
                  .width('100%')
                  Text(sp.region).fontSize(9).fontColor(COLORS.sub)
                  Row({ space: 4 }) {
                    ForEach(sp.tags, (tg: string) => {
                      Text(tg).fontSize(8).fontColor(COLORS.aurora2)
                        .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                        .backgroundColor('#0E3A2E')
                    }, (tg: string) => tg)
                  }
                  .width('100%')
                  Text('⭐ ' + sp.score.toString() + ' · ' + sp.best).fontSize(8).fontColor(COLORS.star)
                }
                .width('100%')
                .padding(10).borderRadius(12).backgroundColor(COLORS.card)
                .alignItems(HorizontalAlign.Start)
              }
            }, (sp: AuroraSpot) => sp.id.toString())
          }
          .layoutWeight(1)
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ Tab1 观测点:编号列表 ============
  @Builder
  tabSpots() {
    Scroll() {
      Column({ space: 8 }) {
        ForEach(spotList, (sp: AuroraSpot) => {
          Row({ space: 10 }) {
            Column() { Text(sp.id.toString()).fontSize(12).fontColor(COLORS.aurora).fontWeight(FontWeight.Bold) }
            .width(30).height(30).borderRadius(15).backgroundColor('#0E3A2E')
            .justifyContent(FlexAlign.Center)
            Column({ space: 3 }) {
              Text(sp.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(sp.region + ' · 最佳 ' + sp.best).fontSize(9).fontColor(COLORS.sub)
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Column({ space: 3 }) {
              Text(kpLevel(sp.kp)).fontSize(9).fontColor(kpColor(sp.kp)).fontWeight(FontWeight.Bold)
              Text('⭐ ' + sp.score.toString()).fontSize(8).fontColor(COLORS.star)
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
          .padding(11).borderRadius(12).backgroundColor(COLORS.card)
          .border({ width: 1, color: sp.kp >= 8 ? COLORS.aurora3 : COLORS.line })
        }, (sp: AuroraSpot) => sp.id.toString())
        Column() { Text('共 8 个已认证观测点 · 持续更新中').fontSize(9).fontColor(COLORS.sub) }
        .width('100%').padding({ top: 8, bottom: 8 }).alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ Tab2 极光指数:柱状图 + 等级看板 ============
  @Builder
  tabIndex1() {
    Scroll() {
      Column({ space: 10 }) {
        // KP柱状图
        Column({ space: 8 }) {
          Row() {
            Text('近 7 日 KP 指数').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Column().layoutWeight(1)
            Text('本周峰值 KP9').fontSize(9).fontColor(COLORS.aurora3)
          }
          .width('100%')
          Row({ space: 8 }) {
            ForEach(kpCharts, (c: KpChartItem) => {
              Column({ space: 4 }) {
                Column()
                  .width(16).height(kpBarH(c.kp)).borderRadius(6).backgroundColor(c.color)
                Text(c.day).fontSize(8).fontColor(COLORS.sub)
              }
              .layoutWeight(1)
              .height(120).justifyContent(FlexAlign.End).alignItems(HorizontalAlign.Center)
            }, (c: KpChartItem) => c.day)
          }
          .width('100%')
          .height(132)
          .padding({ top: 8, bottom: 6 })
          .borderRadius(12).backgroundColor(COLORS.card)
        }
        .width('100%')

        // 等级看板
        Column({ space: 8 }) {
          ForEach(levelList, (lv: LevelItem) => {
            Column({ space: 4 }) {
              Row() {
                Text(lv.label).fontSize(10).fontColor(COLORS.title)
                Column().layoutWeight(1)
                Text(lv.pct.toString() + '%').fontSize(10).fontColor(lv.color).fontWeight(FontWeight.Bold)
              }
              .width('100%')
              Row() {
                Column().width(barW(lv.pct)).height(6).borderRadius(3).backgroundColor(lv.color)
              }
              .width('100%').height(6).borderRadius(3).backgroundColor(COLORS.card2)
            }
            .width('100%')
          }, (lv: LevelItem) => lv.label)
        }
        .width('100%')
        .padding(12).borderRadius(12).backgroundColor(COLORS.card)

        // 观测建议
        Column({ space: 6 }) {
          Text('🌌 今晚观测建议').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text('· 22:00 后云层消散,KP8 持续至凌晨 2 点\n· 推荐机位:阿比斯库天空站观景平台\n· 记得带上三脚架与备用电池\n· 拍摄参数建议:M档 / 15-20s / ISO 1600-3200').fontSize(10).fontColor(COLORS.sub).lineHeight(18)
        }
        .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)
        .alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ Tab3 行程团:时间轴 ============
  @Builder
  tabTrips() {
    Scroll() {
      Column({ space: 0 }) {
        ForEach(this.trips, (tp: TripItem, idx: number) => {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Column()
                .width(8).height(8).borderRadius(4)
                .backgroundColor(tp.status === '已满' ? COLORS.red : COLORS.aurora)
              Column()
                .width(1).height(96).backgroundColor(COLORS.line)
            }
            Column({ space: 4 }) {
              Row() {
                Text(tp.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Column().layoutWeight(1)
                Text(tp.status).fontSize(8).fontColor(tp.status === '已满' ? COLORS.red : COLORS.aurora)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8)
                  .backgroundColor(tp.status === '已满' ? '#3A1520' : '#0E3A2E')
              }
              .width('100%')
              Text('📅 ' + tp.date + ' · ' + tp.days.toString() + ' 天 · ' + tp.people).fontSize(9).fontColor(COLORS.sub)
              Text('🧭 领队 ' + tp.leader + ' · ' + tp.spots).fontSize(9).fontColor(COLORS.sub)
              Row({ space: 6 }) {
                Text(tp.price).fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
                Column().layoutWeight(1)
                Text('✏️').fontSize(11).onClick(() => { this.openEdit(idx); })
                Text('🗑️').fontSize(11).onClick(() => { this.openDelTrip(idx); })
              }
              .width('100%')
            }
            .layoutWeight(1)
            .padding(10).borderRadius(12).backgroundColor(COLORS.card)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
        }, (tp: TripItem) => tp.id.toString())
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ Tab4 装备:三列宫格 ============
  @Builder
  tabGear() {
    Scroll() {
      Column({ space: 10 }) {
        ForEach([0, 1, 2, 3], (row: number) => {
          Row({ space: 8 }) {
            ForEach(gearList, (g: GearItem) => {
              if (Math.floor((g.id - 1) / 3) === row) {
                Column({ space: 4 }) {
                  Text(g.icon).fontSize(22)
                  Text(g.name.length > 5 ? g.name.slice(0, 5) : g.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
                  Text(g.rent).fontSize(8).fontColor(COLORS.aurora)
                  Text('库存 ' + g.stock.toString()).fontSize(8).fontColor(COLORS.sub)
                  Text(g.hot).fontSize(8)
                }
                .layoutWeight(1)
                .padding({ top: 10, bottom: 10 }).borderRadius(12).backgroundColor(COLORS.card)
                .alignItems(HorizontalAlign.Center)
              }
            }, (g: GearItem) => g.id.toString())
          }
          .width('100%')
        })
        Column() { Text('装备全部支持极光季租赁 · 满 5 天 8 折').fontSize(9).fontColor(COLORS.sub) }
        .width('100%').alignItems(HorizontalAlign.Center)
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ Tab5 旅友圈:信息流 ============
  @Builder
  tabBuddies() {
    Scroll() {
      Column({ space: 8 }) {
        ForEach(this.buddies, (b: BuddyItem) => {
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Column() { Text(b.avatar).fontSize(18) }
              .width(34).height(34).borderRadius(17).backgroundColor(COLORS.card2)
              .justifyContent(FlexAlign.Center)
              Column({ space: 2 }) {
                Text(b.nick).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                Text(b.time).fontSize(8).fontColor(COLORS.sub)
              }
              .alignItems(HorizontalAlign.Start)
              Column().layoutWeight(1)
              Text(b.tag).fontSize(8).fontColor(COLORS.violet)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10)
                .backgroundColor('#2A2450')
            }
            .width('100%')
            Text(b.text).fontSize(11).fontColor('#DCE3FF').lineHeight(17)
            Row({ space: 14 }) {
              Text('👍 ' + b.likes.toString()).fontSize(9).fontColor(COLORS.sub)
              Text('💬 ' + b.rep.toString()).fontSize(9).fontColor(COLORS.sub)
              Column().layoutWeight(1)
              Text('展开').fontSize(9).fontColor(COLORS.aurora)
            }
            .width('100%')
          }
          .width('100%')
          .padding(11).borderRadius(12).backgroundColor(COLORS.card)
          .alignItems(HorizontalAlign.Start)
        }, (b: BuddyItem) => b.id.toString())
        Column() { Text('+ 发布动态').fontSize(10).fontColor(COLORS.bg).fontWeight(FontWeight.Bold) }
        .width('100%').padding({ top: 10, bottom: 10 }).borderRadius(12).backgroundColor(COLORS.aurora)
        .alignItems(HorizontalAlign.Center)
        .onClick(() => { this.openAdd(); })
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ Tab6 极光笔记:图文横条 ============
  @Builder
  tabNotes() {
    Scroll() {
      Column({ space: 8 }) {
        ForEach(this.notes, (n: NoteItem, idx: number) => {
          Row({ space: 10 }) {
            Column() { Text(n.icon).fontSize(20) }
            .width(52).height(52).borderRadius(12).backgroundColor(noteIconBg(n.id))
            .justifyContent(FlexAlign.Center)
            Column({ space: 4 }) {
              Text(n.title.length > 14 ? n.title.slice(0, 14) + '…' : n.title)
                .fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(n.spot + ' · ' + n.date + ' · ' + n.author).fontSize(9).fontColor(COLORS.sub)
              Row({ space: 10 }) {
                Text('👁️ ' + n.views.toString()).fontSize(8).fontColor(COLORS.sub)
                Text('⭐ ' + n.fav.toString()).fontSize(8).fontColor(COLORS.star)
                Column().layoutWeight(1)
                Text('🗑️').fontSize(10).opacity(0.7).onClick(() => { this.openDelNote(idx); })
              }
              .width('100%')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .padding(10).borderRadius(12).backgroundColor(COLORS.card)
        }, (n: NoteItem) => n.id.toString())
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ 主tab2 营地图鉴:图鉴列表 ============
  @Builder
  tabCamp() {
    Scroll() {
      Column({ space: 10 }) {
        ForEach(spotList, (sp: AuroraSpot) => {
          Column({ space: 6 }) {
            Row() {
              Text('🛖 ' + sp.name).fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text(kpLevel(sp.kp) + ' · ' + sp.kp.toString()).fontSize(9).fontColor(kpColor(sp.kp))
            }
            .width('100%')
            Text('设施:玻璃屋 8 间 / 观景平台 / 暖炉小屋 / 摄影师驻场\n服务:接送机 · 极光叫醒 · 热饮供应 · 修图指导').fontSize(9).fontColor(COLORS.sub).lineHeight(16)
            Row({ space: 4 }) {
              ForEach(['玻璃屋', '观景台', '暖炉', '驻场'], (ft: string, fi: number) => {
                Text(ft).fontSize(8).fontColor(COLORS.violet)
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).backgroundColor('#2A2450')
              }, (ft: string, fi: number) => fi.toString())
            }
            .width('100%')
          }
          .width('100%')
          .padding(12).borderRadius(12).backgroundColor(COLORS.card)
          .alignItems(HorizontalAlign.Start)
          .border({ width: 1, color: sp.kp >= 8 ? COLORS.aurora3 : COLORS.line })
        }, (sp: AuroraSpot) => sp.id.toString())
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ 主tab3 装备铺:装备宫格 ============
  @Builder
  tabShop() {
    Scroll() {
      Column({ space: 10 }) {
        Row({ space: 8 }) {
          Column({ space: 3 }) {
            Text('🧥 保暖专区').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('12 件在租').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
          .alignItems(HorizontalAlign.Start)
          Column({ space: 3 }) {
            Text('📷 摄影专区').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('8 件在租').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding(10).borderRadius(12).backgroundColor(COLORS.card)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        ForEach([0, 1, 2, 3, 4, 5], (row: number) => {
          Row({ space: 8 }) {
            ForEach(gearList, (g: GearItem) => {
              if (Math.floor((g.id - 1) / 2) === row) {
                Column({ space: 4 }) {
                  Row() {
                    Text(g.icon).fontSize(20)
                    Column().layoutWeight(1)
                    Text(g.hot).fontSize(8)
                  }
                  .width('100%')
                  Text(g.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Medium)
                  Text(g.cat + ' · 库存 ' + g.stock.toString()).fontSize(8).fontColor(COLORS.sub)
                  Row() {
                    Text(g.price).fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
                    Column().layoutWeight(1)
                    Text('租').fontSize(9).fontColor(COLORS.bg)
                      .padding({ left: 10, right: 10, top: 3, bottom: 3 }).borderRadius(10).backgroundColor(COLORS.aurora)
                  }
                  .width('100%')
                }
                .layoutWeight(1)
                .padding(10).borderRadius(12).backgroundColor(COLORS.card)
                .alignItems(HorizontalAlign.Start)
              }
            }, (g: GearItem) => g.id.toString())
          }
          .width('100%')
        })
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ 主tab4 我的:个人中心 ============
  @Builder
  tabMine() {
    Scroll() {
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column() { Text('🦊').fontSize(30) }
          .width(56).height(56).borderRadius(28).backgroundColor(COLORS.card2)
          .justifyContent(FlexAlign.Center)
          Column({ space: 4 }) {
            Text('追光者阿哲').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('已打卡 6 个观测点 · 徽章 12 枚').fontSize(9).fontColor(COLORS.sub)
            Row({ space: 6 }) {
              Text('🏅 极光猎人').fontSize(8).fontColor(COLORS.star).padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).backgroundColor('#3A3410')
              Text('📷 摄影家').fontSize(8).fontColor(COLORS.violet).padding({ left: 6, right: 6, top: 2, bottom: 2 }).borderRadius(8).backgroundColor('#2A2450')
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start)
          Column().layoutWeight(1)
        }
        .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card)

        Row({ space: 8 }) {
          Column({ space: 2 }) {
            Text('12').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('观测记录').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(12).backgroundColor(COLORS.card).justifyContent(FlexAlign.Center)
          Column({ space: 2 }) {
            Text('8').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('收藏笔记').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(12).backgroundColor(COLORS.card).justifyContent(FlexAlign.Center)
          Column({ space: 2 }) {
            Text('320').fontSize(16).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('点赞数').fontSize(8).fontColor(COLORS.sub)
          }
          .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(12).backgroundColor(COLORS.card).justifyContent(FlexAlign.Center)
        }
        .width('100%')

        Column({ space: 2 }) {
          Text('📊 本季观测统计').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text('已追极光夜数 23 晚 · 成功率 78%').fontSize(9).fontColor(COLORS.sub)
          Row({ space: 4 }) {
            ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (b: number) => {
              Column()
                .width(8).height(b < 8 ? 14 + b * 2 : 0)
                .borderRadius(3).backgroundColor(b < 6 ? COLORS.aurora2 : COLORS.aurora)
            }, (b: number) => b.toString())
          }
          .width('100%').height(40).justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Bottom)
        }
        .width('100%').padding(12).borderRadius(12).backgroundColor(COLORS.card).alignItems(HorizontalAlign.Start)

        ForEach(['我的预约', '我的订单', '装备租赁记录', '追光日记', '成就徽章', '设置'], (mi: string, miI: number) => {
          Row() {
            Text(mi === '我的预约' ? '📅' : (mi === '我的订单' ? '🧾' : (mi === '装备租赁记录' ? '🎒' : (mi === '追光日记' ? '📔' : (mi === '成就徽章' ? '🏅' : '⚙️')))))
              .fontSize(13)
            Text(mi).fontSize(11).fontColor(COLORS.title).margin({ left: 8 })
            Column().layoutWeight(1)
            Text('›').fontSize(14).fontColor(COLORS.sub)
          }
          .width('100%').padding(11).borderRadius(12).backgroundColor(COLORS.card)
        }, (mi: string) => mi)
      }
      .width('100%')
      .padding({ left: 12, right: 12, bottom: 16 })
    }
    .width('100%')
    .layoutWeight(1)
  }

  // ============ 底部导航(4个) ============
  @Builder
  bottomBar() {
    Row({ space: 6 }) {
      ForEach(NAV_LIST, (ni: NavItem, bI: number) => {
        Column({ space: 2 }) {
          Text(ni.icon).fontSize(16)
            .opacity(this.mainTab === bI ? 1 : 0.45)
          Text(ni.label).fontSize(9)
            .fontColor(this.mainTab === bI ? COLORS.aurora : COLORS.sub)
            .fontWeight(this.mainTab === bI ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1)
        .padding({ top: 8, bottom: 8 })
        .borderRadius(12)
        .backgroundColor(this.mainTab === bI ? '#0E3A2E' : Color.Transparent)
        .justifyContent(FlexAlign.Center)
        .onClick(() => { this.mainTab = bI; })
      }, (ni: NavItem) => ni.label)
    }
    .width('100%')
    .padding({ left: 8, right: 8, top: 4, bottom: 8 })
    .backgroundColor(COLORS.card)
  }

  // ============ 弹窗遮罩 ============
  @Builder
  modalOverlay(onClose: () => void) {
    Column() {
    }
    .width('100%').height('100%').backgroundColor(COLORS.mask)
    .onClick(() => { onClose(); })
  }

  build() {
    Column() {
      this.header()
      // 内容区(特效层覆盖)
      Stack({ alignContent: Alignment.TopStart }) {
        if (this.mainTab === 0) {
          Column() {
            this.subNav()
            if (this.subTab === 0) {
              this.tabPick()
            } else if (this.subTab === 1) {
              this.tabSpots()
            } else if (this.subTab === 2) {
              this.tabIndex1()
            } else if (this.subTab === 3) {
              this.tabTrips()
            } else if (this.subTab === 4) {
              this.tabGear()
            } else if (this.subTab === 5) {
              this.tabBuddies()
            } else {
              this.tabNotes()
            }
          }
          .width('100%')
          .height('100%')
        } else if (this.mainTab === 1) {
          this.tabCamp()
        } else if (this.mainTab === 2) {
          this.tabShop()
        } else {
          this.tabMine()
        }
        // 特效层(不遮挡头部,仅内容区)
        this.fxLayer()
      }
      .width('100%')
      .layoutWeight(1)

      this.bottomBar()

      // ============ 弹框 ============
      if (this.addModal) {
        Column() {
          this.modalOverlay(() => { this.addModal = false; })
          Column({ space: 10 }) {
            Row({ space: 8 }) {
              Text('✅ 发布旅友动态').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text('✕').fontSize(16).fontColor(COLORS.sub)
                .onClick(() => { this.addModal = false; })
            }
            .width('100%')
            Text('昵称').fontSize(10).fontColor(COLORS.sub)
            TextInput({ placeholder: '请输入昵称(可空)', text: this.addNick })
              .height(34).fontSize(11).backgroundColor('#0E3A2E')
              .onChange((v: string) => { this.addNick = v; })
            Text('动态内容').fontSize(10).fontColor(COLORS.sub)
            TextInput({ placeholder: '分享你的追光经历…', text: this.addText })
              .height(64).fontSize(11).backgroundColor('#0E3A2E')
              .onChange((v: string) => { this.addText = v; })
            Row({ space: 10 }) {
              Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
              .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
              .backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
              .onClick(() => { this.addModal = false; })
              Column() { Text('发布').fontSize(11).fontColor(COLORS.bg).fontWeight(FontWeight.Bold) }
              .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
              .backgroundColor(COLORS.aurora).alignItems(HorizontalAlign.Center)
              .onClick(() => { this.doAdd(); })
            }
            .width('100%')
          }
          .width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
          .borderRadius(16).backgroundColor(COLORS.card)
        }
        .width('100%').height('100%').zIndex(999)
        .justifyContent(FlexAlign.Center)
      }

      if (this.editModal) {
        Column() {
          this.modalOverlay(() => { this.editModal = false; })
          Column({ space: 10 }) {
            Row({ space: 8 }) {
              Text('✏️ 编辑行程').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text('✕').fontSize(16).fontColor(COLORS.sub)
                .onClick(() => { this.editModal = false; })
            }
            .width('100%')
            Text('行程名称').fontSize(10).fontColor(COLORS.sub)
            TextInput({ placeholder: '行程名称', text: this.editTitle })
              .height(34).fontSize(11).backgroundColor('#0E3A2E')
              .onChange((v: string) => { this.editTitle = v; })
            Text('出发日期').fontSize(10).fontColor(COLORS.sub)
            TextInput({ placeholder: '如 2026-10-02', text: this.editDate })
              .height(34).fontSize(11).backgroundColor('#0E3A2E')
              .onChange((v: string) => { this.editDate = v; })
            Row({ space: 10 }) {
              Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
              .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
              .backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
              .onClick(() => { this.editModal = false; })
              Column() { Text('保存').fontSize(11).fontColor(COLORS.bg).fontWeight(FontWeight.Bold) }
              .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
              .backgroundColor(COLORS.aurora).alignItems(HorizontalAlign.Center)
              .onClick(() => { this.doEdit(); })
            }
            .width('100%')
          }
          .width('82%').constraintSize({ maxHeight: '80%' }).padding(16)
          .borderRadius(16).backgroundColor(COLORS.card)
        }
        .width('100%').height('100%').zIndex(999)
        .justifyContent(FlexAlign.Center)
      }

      if (this.delModal) {
        Column() {
          this.modalOverlay(() => { this.delModal = false; })
          Column({ space: 10 }) {
            Text('🗑️').fontSize(34)
            Text('删除这条内容?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text('删除后将无法恢复').fontSize(10).fontColor(COLORS.red)
            Row({ space: 10 }) {
              Column() { Text('取消').fontSize(11).fontColor(COLORS.sub) }
              .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
              .backgroundColor(COLORS.card2).alignItems(HorizontalAlign.Center)
              .onClick(() => { this.delModal = false; })
              Column() { Text('确认删除').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
              .layoutWeight(1).padding({ top: 10, bottom: 10 }).borderRadius(10)
              .backgroundColor(COLORS.red).alignItems(HorizontalAlign.Center)
              .onClick(() => { this.doDel(); })
            }
            .width('100%')
          }
          .width('76%').constraintSize({ maxHeight: '60%' }).padding(16)
          .borderRadius(16).backgroundColor(COLORS.card)
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%').height('100%').zIndex(999)
        .justifyContent(FlexAlign.Center)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

在这里插入图片描述

第六,数据的交叉引用与一致性设计增强了应用的专业感。 头部大卡中的"KP 8"与柱状图中周五的 KP 8、等级看板中"KP 7-8 极光大爆发 92%"形成了三处数据呼应;8 个观测点的 KP 值与 kpColor 函数的颜色编码、kpLevel 函数的等级标签、kpBarH 函数的高度映射保持一致。这种多维度的数据自洽性使得应用在浏览时呈现出科学严谨的专业感——对于极光观测这一科学性较强的领域,数据的一致性是建立用户信任的基础。

第七,clip(true) 在特效控制中发挥了关键作用。 fxLayerStack 容器设置了 clip(true),确保所有特效元素被裁剪在容器范围内。极光飘带的宽度为 130%(超出容器宽度),position 定位可能导致星光出现在容器边缘——clip(true) 确保这些溢出的部分不会显示在内容区域之外,避免了特效对其他 UI 区域的视觉干扰。这种"裁剪控制"在特效开发中是必不可少的,它使得特效可以在"无限"的虚拟空间中运动,同时只在指定区域内可见。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值