开篇:骑行文化与移动应用的交汇点

城市骑行,正在从一种单纯的通勤方式演变为一种生活态度和文化现象。清晨的滨江绿道、周末的山间爬坡、夜晚的环线刷街——骑行者们用两个轮子丈量着城市的每一寸土地。而一款优秀的骑行社区应用,不仅仅是路线记录工具,更是连接骑友、分享体验、推动成长的精神家园。

本文将深入剖析一款以城市骑行为核心场景的跨平台应用源码,探索如何用 ArkUI 声明式开发框架,将骑行文化的活力与动感融入每一行代码之中。我们将从浅色主题的色彩体系讲起,一路深入到流线光带动画的数学原理、训练计划的数据模型、骑行团的排行榜设计、装备管理的分类逻辑……最终呈现一幅完整的"骑行应用技术全景图"。

与深海主题的沉浸式深色界面不同,骑行应用采用了明快的浅色主题——蓝天白云般的主色调、橙色的活力强调色、干净的白色卡片。这种视觉风格的转变不仅仅是颜色的替换,更涉及到对比度的调整、阴影的运用、层级的表达方式等一系列设计决策。我们将对比两种主题的实现差异,探讨主题化设计的工程方法论。

无论你是骑行爱好者、移动应用开发者,还是对声明式 UI 架构感兴趣的技术人,相信这篇深度解析都能为你带来新的视角和启发。

—在这里插入图片描述

一、色彩系统:明快基调下的浅色主题设计

1.1 色彩接口的延续与扩展

与深海主题一样,骑行应用同样定义了 ColorPalette 接口,但在字段上有一个微妙的差异:

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;
  lane: string;
}

深度解析:

注意最后一个字段——从深海主题的 foam(泡沫色)变成了 lane(车道色)。这是一个非常精妙的设计:

  • 主题专属色字段:接口保留了一个"主题专属色"的位置,不同主题可以根据自身特点定义一个独特的颜色名称。
  • 语义化而非通用化:没有使用 themeColor 或 specialColor 这种通用名称,而是用了 foam、lane 等与主题强相关的词汇。这使得阅读代码时能够立刻联想到颜色的用途和意象。
  • 接口的可扩展性:通用字段(主色、辅助色、文本色等)保持不变,确保了跨主题的代码复用性;专属字段提供了灵活性,让每个主题都有自己的个性。
    在这里插入图片描述

1.2 浅色主题的色彩实例

色彩常量 COLORS 展示了一套完整的浅色主题配色方案:

const COLORS: ColorPalette = {
  primary: '#3A7BD5',
  primaryLight: '#7BA6E8',
  primaryDark: '#27528F',
  accent: '#FF8A3D',
  accentLight: '#E8EFF9',
  bg: '#F0F2F5',
  cardBg: '#FFFFFF',
  textPrimary: '#2A3038',
  textSecondary: '#5C6672',
  textHint: '#98A2AE',
  border: '#DFE4EA',
  success: '#4CAF7D',
  warning: '#E9A93D',
  danger: '#E05D5D',
  white: '#FFFFFF',
  lane: '#B9CCE8'
};

深度解析:

让我们从设计角度逐一分析这套配色的特点:

背景与卡片:

  • bg: '#F0F2F5':浅灰色背景,不是纯白。这是浅色模式设计的一个经典技巧——用极浅的灰色作为页面背景,白色作为卡片背景,通过微妙的色差来区分层级。
  • cardBg: '#FFFFFF':纯白色卡片,在浅灰背景上自然浮现,形成卡片式设计的视觉效果。
  • border: '#DFE4EA':浅灰色边框,在白色卡片上提供了清晰的边界,但又不会过于突兀。

文本三级灰度:

  • textPrimary: '#2A3038':深灰偏蓝的主文本色,不是纯黑。纯黑(#000000)在浅色背景上对比度太高,容易造成视觉疲劳。深灰色更加柔和易读。
  • textSecondary: '#5C6672':中等灰度的次要文本。
  • textHint: '#98A2AE':浅灰色的提示文本。
  • 三级文本的对比度依次递减,形成清晰的信息层级。

主色调与强调色:

  • primary: '#3A7BD5':明亮的天空蓝,给人开阔、自由的感觉,非常契合骑行的主题意象。
  • accent: '#FF8A3D':活力橙色,类似于自行车码表的指示灯颜色,在蓝白主色调中形成强烈的视觉焦点。
  • accentLight: '#E8EFF9':非常浅的蓝紫色,作为标签、分类等元素的背景色,既提供了视觉区分,又不会过于抢眼。

功能色:

  • success: '#4CAF7D':清新的绿色,代表安全、完成、健康。
  • warning: '#E9A93D':温暖的金黄色,代表警告、注意。
  • danger: '#E05D5D':柔和的红色,代表危险、错误、删除。

主题专属色:

  • lane: '#B9CCE8':淡蓝紫色,命名为"车道色"。这个颜色用于流线光带动画,模拟城市道路上的光影流动效果。

1.3 深色主题 vs 浅色主题的对比

在这里插入图片描述

维度深海深色主题骑行浅色主题设计考量
背景色#0A1826 深藏青#F0F2F5 浅灰白深色更沉浸,浅色更清爽
卡片背景#122435 深蓝灰#FFFFFF 纯白深色用明度差分层,浅色用色差分层
主文本色#E4F1F8 浅蓝白#2A3038 深灰蓝都避免使用纯黑/纯白,减少视觉疲劳
主色调#2E9BD6 海洋蓝#3A7BD5 天空蓝同属蓝色系但饱和度/明度不同
强调色#FF9E5E 暖橙#FF8A3D 亮橙深色主题中橙色需要更亮,浅色中可以更深
边框色#1E364A 深青灰#DFE4EA 浅灰深色边框比背景亮,浅色边框比背景暗
专属色名foam 泡沫lane 车道各自主题化命名,增强代入感
氛围倾向沉浸、神秘、深邃活力、清爽、阳光匹配不同的产品调性

二、数据模型层:骑行领域的业务抽象

在这里插入图片描述

2.1 骑友动态模型

社区动态是骑行应用的核心功能之一,PostItem 类定义了动态内容的数据结构:

@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 完全相同(都是社区动态模型),但数据内容却截然不同——从潜水见闻变成了骑行故事:

  • 昵称从"沉船猎手"、“中性浮力大师"变成了"破风手阿凯”、“夜骑灯侠”
  • 内容从"狮子鱼"、“耗气量"变成了"均速27”、“补胎技巧”

这体现了一个重要的架构思想:数据模型的结构是通用的,但数据的内容是领域化的。社区动态的六个字段(id、昵称、头像、正文、时间、点赞数)几乎适用于所有社区类应用,这是一种可复用的模式。

2.2 路线数据模型

在这里插入图片描述

路线是骑行应用的核心实体,RouteItem 类完整描述了一条骑行路线的关键属性:

@Observed
export class RouteItem {
  id: number = 0
  name: string = ''
  distance: string = ''
  terrain: string = ''
  status: string = ''
  hot: number = 0
  constructor(id: number, name: string, distance: string, terrain: string, status: string, hot: number) {
    this.id = id; this.name = name; this.distance = distance; this.terrain = terrain
    this.status = status; this.hot = hot
  }
}

深度解析:

这个数据模型涵盖了骑行路线的关键维度:

  • name:路线名称,如"滨江夜光环线"、“南山爬坡挑战”
  • distance:距离,字符串类型(因为包含单位和描述,如"21.6 km")
  • terrain:地形类型,如"平路"、“爬坡”、“混合”、“越野”、“起伏”、“长途”
  • status:路线状态,如"已骑行"、“待出发”、“常驻”、“规划中”
  • hot:热度值,数值类型,用于排序和热门标识

地形分类的业务含义:
六种地形分类覆盖了城市骑行的主要场景:

  • 平路:最基础的骑行类型,适合大多数骑友
  • 爬坡:有一定海拔提升,需要体力和技巧
  • 混合:平路和坡道都有,综合训练
  • 越野:非铺装路面,需要专门的车辆和技术
  • 起伏:连续的小起伏路,节奏感强
  • 长途:60km 以上的长距离骑行,需要补给策略

这种细致的分类体现了对骑行领域的深入理解,不是简单地用"简单/中等/困难"来划分,而是用骑友们真正关心的地形特征来描述。

2.3 训练计划模型

骑行不仅是娱乐,更是一种有计划的训练。PlanItem 模型定义了训练计划的数据结构:

@Observed
export class PlanItem {
  id: number = 0
  name: string = ''
  stage: string = ''
  level: string = ''
  progress: number = 0
  constructor(id: number, name: string, stage: string, level: string, progress: number) {
    this.id = id; this.name = name; this.stage = stage; this.level = level
    this.progress = progress
  }
}

深度解析:

训练计划模型体现了成长体系的设计思路:

  • name:训练项目名称,如"踏频稳定90"、“弯道压弯过弯”
  • stage:训练阶段/类别,如"平路巡航"、“控车技巧”、“坡道配速”
  • level:难度等级,分为"基础"、“进阶”、"高级"三档
  • progress:掌握度百分比,0-100

三级难度体系的设计哲学:

  • 基础:入门必学,人人都应该掌握的技能(踏频、过弯、安全检查)
  • 进阶:有一定挑战,需要积累一定里程后才能掌握(爬坡、编队、长距离)
  • 高级:专业级技巧,需要专门训练(越野、功率训练)

这种三级体系在运动类应用中非常常见,它既给了新手明确的成长路径,又给了高手足够的挑战空间。

2.4 装备数据模型

在这里插入图片描述

装备是骑行文化的重要组成部分,从整车到配件,从穿戴到工具:

@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
  }
}

装备清单(8件):

  1. 公路车(铝合金车架)🚴
  2. 骑行头盔(MIPS 防护)⛑️
  3. 前后车灯(400 流明)💡
  4. 骑行裤(硅胶坐垫)🩳
  5. 码表(GPS 记录)📟
  6. 补胎工具(撬棒补片)🔧
  7. 打气筒(高压便携)💨
  8. 骑行手套(防滑减震)🧤

这些装备涵盖了骑行的方方面面——车辆、防护、照明、服装、电子设备、维修工具。选择这些装备作为示例,说明设计者对骑行文化有深入的了解,而不是随便找几个物品凑数。


三、图表与课程:数据可视化的实现

在这里插入图片描述

3.1 图表数据接口

应用中定义了两种图表数据结构:周里程图表和骑行构成图表:

interface WeekChartItem {
  label: string;
  value: number;
}

interface RideChartItem {
  label: string;
  value: number;
  color: string;
}

深度解析:

这两个接口的区别在于 RideChartItem 多了一个 color 字段。为什么?

  • WeekChartItem:用于周里程柱状图,柱子的颜色由数值决定(通过 rideColor 函数计算),是动态的,所以不需要存颜色。
  • RideChartItem:用于骑行构成横向条形图,每个分类有自己固定的颜色(通勤是蓝色、周末长距是橙色、夜骑是浅蓝……),是静态的,所以直接存在数据里。

这是两种不同的颜色策略:数值映射色 vs 分类固有色。前者用于展示"程度",后者用于区分"类别"。

3.2 周里程数据与骑行构成数据

在这里插入图片描述

const WEEK_CHART: WeekChartItem[] = [
  { label: '周一', value: 12 },
  { label: '周二', value: 21 },
  { label: '周三', value: 16 },
  { label: '周四', value: 25 },
  { label: '周五', value: 18 },
  { label: '周六', value: 42 },
  { label: '周日', value: 36 }
];

const RIDE_CHART: RideChartItem[] = [
  { label: '通勤骑行', value: 62, color: '#3A7BD5' },
  { label: '周末长距', value: 48, color: '#FF8A3D' },
  { label: '夜骑', value: 30, color: '#7BA6E8' },
  { label: '爬坡训练', value: 22, color: '#4CAF7D' },
  { label: '越野', value: 10, color: '#E9A93D' }
];

深度解析:

这些数据非常真实,符合城市骑行者的典型行为模式:

周里程分布:

  • 工作日(周一到周五)里程较低,大约 12-25km,主要是通勤
  • 周六最高(42km),周末是骑行的主要时间
  • 周日次之(36km),通常会安排长距离骑行
  • 这种"工作日低、周末高"的模式非常符合实际情况

骑行构成:

  • 通勤骑行占比最高(62%),这是大多数城市骑行者的主要用途
  • 周末长距离其次(48%),体现了骑行的运动属性
  • 夜骑(30%)也是重要组成部分,城市夜骑是一种独特的文化
  • 爬坡训练(22%)和越野(10%)占比较低,属于进阶玩法

3.3 课程数据与成长体系

const COURSE_LIST: CourseItem[] = [
  { title: '车辆调校与安全检查', week: '第 1 周', progress: 100, color: '#3A7BD5' },
  { title: '骑行姿态与踏频', week: '第 2 周', progress: 92, color: '#FF8A3D' },
  { title: '城市路况预判', week: '第 3 周', progress: 70, color: '#4CAF7D' },
  { title: '编队与手势信号', week: '第 4 周', progress: 38, color: '#7BA6E8' },
  { title: '长距离补给与恢复', week: '第 5 周', progress: 16, color: '#E9A93D' }
];

深度解析:

课程体系的设计遵循了循序渐进的原则:

  • 第1周:基础中的基础——车辆检查和安全。任何运动都从安全开始。
  • 第2周:骑行姿态和踏频——骑行的基本功,决定了效率和健康。
  • 第3周:路况预判——城市骑行的核心技能,关系到安全。
  • 第4周:编队骑行——团队骑行的技巧,社交属性。
  • 第5周:长距离补给——耐力骑行的进阶知识。

每门课程都有对应的颜色,从蓝色(基础)到橙色到绿色到浅蓝到黄色,颜色的变化也暗示了学习的进阶路径。


四、导航系统:粗块底部指示式 Tab

4.1 导航配置

const NAV_LIST: NavItem[] = [
  { icon: '🚴', label: '首页' },
  { icon: '🗺️', label: '路线' },
  { icon: '📚', label: '课堂' },
  { icon: '👤', label: '我的' }
];

const SUB_NAV_LIST: string[] = ['精选', '路线图鉴', '训练计划', '骑行团', '骑行课堂', '骑友圈', '装备架'];

深度解析:

底部主导航的四个 Tab 与深海主题结构相同,但图标和标签不同:

  • 🚴 首页(原 🌊)
  • 🗺️ 路线(原 📍 潜点)
  • 📚 课堂(保持不变)
  • 👤 我的(保持不变)

子导航的七个板块也有对应关系:

  • 精选 → 精选
  • 路线图鉴 → 潜点图鉴
  • 训练计划 → 潜程技能
  • 骑行团 → 船潜预约
  • 骑行课堂 → 潜水课堂
  • 骑友圈 → 潜伴圈
  • 装备架 → 装备舱

可以看出,虽然业务领域不同,但信息架构的模式是高度一致的——首页聚合 + 核心内容 + 成长体系 + 社区互动 + 工具/装备。这种模式的一致性降低了用户的学习成本,也说明了产品设计的方法论是可迁移的。

4.2 粗块底部指示式导航的实现

骑行主题的子导航样式与深海主题不同——深海是下划线式,骑行是粗块底部指示式:

@Builder
subNav() {
  Scroll() {
    Row({ space: 6 }) {
      ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
        Column({ space: 0 }) {
          Text(item)
            .fontSize(12)
            .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
            .padding({ left: 12, right: 12, top: 8, bottom: 8 })
          Column()
            .width(this.subTab === idx ? 100 : 0)
            .height(3)
            .backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.cardBg)
        }
        .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
        .borderRadius(10)
        .onClick(() => {
          this.subTab = idx;
        })
      }, (item: string) => item)
    }
    .width('100%')
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .padding({ top: 4, bottom: 10 })
}

深度解析:

让我们对比两种导航样式的差异:

特点深海下划线式骑行粗块底部指示式
选中背景透明(无背景)主色填充(蓝色药丸形状)
选中文字颜色泡沫色(浅青色)白色
指示条位置文字下方,独立存在标签底部边缘,与背景融合
指示条宽度20px(窄下划线)100%(与文字同宽)
整体形状扁平文字圆角矩形"药丸"
视觉风格轻盈、简约饱满、有按钮感

这两种样式各有适用场景:

  • 下划线式适合内容型、阅读型的应用,视觉干扰小
  • 粗块底部指示式适合工具型、操作型的应用,选中感更强

实现上的关键差异在于:

  • 骑行主题的选中项有背景色(COLORS.primary)和圆角(borderRadius(10)),形成"药丸"形状
  • 文字颜色在选中时变为白色,因为背景是深色的主色
  • 底部指示条与背景色对比不明显(橙色指示条在蓝色背景上),更多是装饰作用

五、动画系统:流线光带与车灯闪烁

5.1 动画函数的数学原理

骑行主题的背景动画是"流线光带 + 车灯闪烁",模拟城市道路上的光影流动效果:

function laneX(tick: number, i: number): number {
  return -40 + ((tick * 9 + i * 131) % 700);
}

function laneY(tick: number, i: number): number {
  return 90 + i * 150;
}

function laneA(tick: number, i: number): number {
  return 0.08 + ((tick + i) % 4) * 0.05;
}

深度解析:

流线光带(lane)的动画分析:

  • X 方向运动:laneX 函数计算光带的水平位置。起始点 -40(屏幕左侧外),每帧前进 tick * 9 个单位,每根光带有不同的相位偏移 i * 131。% 700 使得光带在移动 700px 后重新开始,形成循环。
  • Y 方向固定:laneY 是固定的,90 + i * 150 表示 5 根光带垂直分布,间距 150px。这模拟了道路上的车道线,水平流动但垂直位置固定。
  • 透明度变化:laneA 函数控制透明度,在 0.08、0.13、0.18、0.23 四个值之间循环。很低的透明度确保光带只是背景装饰,不会干扰内容阅读。
function lightA(tick: number, i: number): number {
  return (tick + i) % 3 === 0 ? 0.6 : 0.15;
}

function lightX(tick: number, i: number): number {
  return 40 + ((tick * 6 + i * 113) % 600);
}

function lightY(tick: number, i: number): number {
  return 70 + ((tick * 7 + i * 59) % 630);
}

车灯闪烁(light)的动画分析:

  • 闪烁效果:lightA 函数实现了车灯的闪烁。当 (tick + i) % 3 === 0 时,透明度为 0.6(亮),其他时候为 0.15(暗)。大约每 3 帧(270ms)闪一次,模拟自行车灯的闪烁节奏。
  • 位置移动:车灯在 X 和 Y 方向都在移动,模拟骑行中的车灯在城市中穿梭的感觉。
  • X 速度 6,Y 速度 7:两个方向速度不同,形成斜向运动轨迹,更有动感。

5.2 动画层的构建

@Builder
fxLayer() {
  Stack({ alignContent: Alignment.TopStart }) {
    // 流线光带层
    ForEach([0, 1, 2, 3, 4], (i: number) => {
      Column()
        .width(160)
        .height(10)
        .borderRadius(5)
        .backgroundColor(COLORS.lane)
        .opacity(laneA(this.tick, i))
        .translate({ x: laneX(this.tick, i), y: laneY(this.tick, i) })
    }, (i: number) => i.toString())
    // 车灯闪烁层
    ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (i: number) => {
      Column()
        .width(7)
        .height(7)
        .borderRadius(4)
        .backgroundColor(COLORS.accent)
        .opacity(lightA(this.tick, i))
        .translate({ x: lightX(this.tick, i), y: lightY(this.tick, i) })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height('100%')
  .hitTestBehavior(HitTestMode.None)
}

深度解析:

特效层由两部分组成:

流线光带(5条):

  • 宽 160px,高 10px,圆角 5px——细长的胶囊形状,像道路上的光带
  • 颜色是 lane(淡蓝紫色)
  • 透明度很低(0.08-0.23),在浅色背景上几乎是"若隐若现"的感觉
  • 水平流动,垂直位置固定

车灯闪烁(10个):

  • 7x7px 的小方块,圆角 4px——接近圆形的小点
  • 颜色是强调色(橙色),模拟自行车尾灯
  • 透明度在 0.15 和 0.6 之间跳跃,产生闪烁效果
  • 在整个屏幕范围内斜向移动

动画的整体效果:
想象一下夜晚城市的高架桥上,车流的尾灯形成流动的光带——这个动画试图在浅色背景上用更加抽象、艺术化的方式表达这种"流动"的感觉。流线光带是连续的轨迹,车灯闪烁是离散的光点,两者结合营造出城市骑行的动感氛围。


六、工具函数:颜色映射与状态计算

6.1 柱状图颜色函数

function rideColor(v: number): string {
  if (v > 60) {
    return '#3A7BD5';   // 蓝色 - 高里程
  } else if (v > 35) {
    return '#FF8A3D';   // 橙色 - 中里程
  }
  return '#4CAF7D';     // 绿色 - 低里程
}

深度解析:

这个函数的颜色逻辑与深海主题有所不同——深海主题是"数值越高颜色越’好’“(绿→蓝→黄),而骑行主题的颜色逻辑更像是"数值越高越核心”:

  • 低里程(<35):绿色——骑行量较少的日子,轻松休闲
  • 中里程(35-60):橙色——中等骑行量
  • 高里程(>60):蓝色——大骑行量,用主色调强调

阈值的设定也反映了骑行的实际情况:日常通勤大约 10-20km,中等训练 30-50km,周末长距离可能达到 60km 以上。

6.2 状态色与进度函数

function statusColor(s: string): string {
  if (s === '已骑行') {
    return '#4CAF7D';     // 绿色 - 已完成
  } else if (s === '待出发' || s === '常驻') {
    return '#3A7BD5';     // 蓝色 - 进行中/常用
  }
  return '#98A2AE';       // 灰色 - 规划中/未开始
}

function statusProgress(s: string): number {
  if (s === '已骑行') {
    return 100;
  } else if (s === '常驻') {
    return 80;
  } else if (s === '待出发') {
    return 40;
  }
  return 12;
}

深度解析:

这两个函数配合使用,将路线的文字状态转化为可视化的颜色和进度条:

状态颜色进度含义
已骑行绿色100%已经骑过,完成
常驻蓝色80%经常骑的路线,接近完成
待出发蓝色40%计划中,准备骑
规划中灰色12%还在想法阶段,刚开始

这种"状态 → 颜色 + 进度"的转换模式非常实用——用两种视觉编码(颜色 + 长度)同时传递状态信息,比单纯的文字标签更加直观。

6.3 地形标签色与难度等级色

function terrainTagColor(g: string): string {
  if (g === '平路') {
    return '#3A7BD5';     // 蓝色 - 最常见
  } else if (g === '爬坡') {
    return '#FF8A3D';     // 橙色 - 有挑战
  } else if (g === '越野') {
    return '#4CAF7D';     // 绿色 - 自然野趣
  }
  return '#E9A93D';       // 黄色 - 其他
}

function levelColor(s: string): string {
  if (s === '基础') {
    return '#4CAF7D';     // 绿色 - 入门
  } else if (s === '进阶') {
    return '#3A7BD5';     // 蓝色 - 中级
  }
  return '#FF8A3D';       // 橙色 - 高级
}

深度解析:

levelColor 函数的颜色梯度是绿→蓝→橙,这与常见的"绿→蓝→红"三级体系略有不同。为什么高级用橙色而不是红色?

这可能是出于产品调性的考虑:骑行是一项健康、阳光的运动,用红色(危险色)来标记最高级可能不太合适。橙色既表达了"挑战"和"热度"的含义,又保持了积极向上的感觉,更符合骑行文化的氛围。


七、头部组件:数据概览的设计

7.1 头部布局结构

骑行应用的头部组件与深海主题有明显不同——它采用了"横幅 + 四宫格"的双层结构:

@Builder
header() {
  Column({ space: 10 }) {
    // 横幅区域
    Row() {
      Column() {
        Row({ space: 8 }) {
          Text('🚴')
            .fontSize(24)
          Text('骑语驿站')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
          Column()
            .width(6)
            .height(6)
            .borderRadius(3)
            .backgroundColor(COLORS.success)
            .margin({ top: 4 })
        }
        .width('100%')
        Text('本周 170.4 km · 目标完成 85%')
          .fontSize(10)
          .fontColor('#D3E2F7')
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)
      Text('约骑')
        .fontSize(12)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.primaryDark)
        .padding({ left: 14, right: 14, top: 8, bottom: 8 })
        .backgroundColor(COLORS.white)
        .borderRadius(16)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(COLORS.primaryDark)
    .borderRadius(16)
    // 数据四宫格
    Row({ space: 8 }) {
      // 今日里程
      Column({ space: 2 }) {
        Text('今日里程')
          .fontSize(9)
          .fontColor(COLORS.textHint)
        Text('18.6 km')
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primary)
      }
      .layoutWeight(1)
      .padding(8)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
      .alignItems(HorizontalAlign.Center)
      // ... 均速、爬升、消耗
    }
    .width('100%')
  }
  .width('100%')
  .padding({ top: 10, bottom: 4 })
}

深度解析:

横幅区域的设计亮点:

  • 深色横幅 + 白色文字:用 primaryDark 作为背景,白色标题,形成强烈的视觉冲击力
  • 在线状态指示:标题旁边有一个小小的绿色圆点(width: 6, height: 6, borderRadius: 3, backgroundColor: success),这是"在线/活跃"状态的经典表示方式
  • 周进度概览:副标题显示"本周 170.4 km · 目标完成 85%",一句话传递了核心数据和完成度
  • 反色按钮:"约骑"按钮使用白色背景 + 深色文字,在深色横幅上非常突出

数据四宫格的指标选择:

  1. 今日里程(主色)——当天的核心产出指标
  2. 均速(强调色)——骑行强度/效率的体现
  3. 爬升(成功色)——骑行难度的度量
  4. 消耗(警告色)——运动效果的量化

这四个指标分别从"量"、“速”、“强度”、"效果"四个维度描述了骑行数据,构成了完整的数据概览。每个指标用不同的颜色编码,帮助用户快速识别和记忆。


八、页面构建器:骑行场景的内容设计

8.1 精选首页的模块构成

@Builder
pageFeatured() {
  Column({ space: 12 }) {
    // 骑行方式四格
    Column() { ... }
    // 周里程柱状图
    Column() { ... }
    // 骑行构成占比
    Column() { ... }
    // 路线双列
    Column() { ... }
    // 骑友动态
    Column({ space: 10 }) { ... }
  }
  .width('100%')
}

深度解析:

首页的模块布局与深海主题类似,但内容完全骑行化:

  1. 骑行方式四格 → 原海况四格:晨骑通勤、周末长距离、夜骑环线、爬坡训练
  2. 本周每日里程柱状图 → 原月度瓶数柱状图:周一到周日的骑行里程
  3. 骑行时长构成 → 原证书进度占比:通勤/周末长距/夜骑/爬坡/越野的比例
  4. 热门路线双列卡片 → 原潜点双列:推荐骑行路线
  5. 骑友动态 → 原潜伴动态:社区内容流

这种"结构复用、内容替换"的模式非常高效——同一个信息架构模板可以快速适配不同的垂直领域。

8.2 骑行团排行榜

骑行团页面展示了热门路线的排行榜:

@Builder
pageClub() {
  Column({ space: 10 }) {
    Text('骑行团')
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor(COLORS.textPrimary)
      .width('100%')
    ForEach(ROUTE_LIST.slice(0, 5), (it: RouteItem, 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.distance} · ${it.terrain}`)
            .fontSize(10)
            .fontColor(COLORS.textSecondary)
        }
        .layoutWeight(1)
        .alignItems(HorizontalAlign.Start)
        Text(`热度 ${it.hot}`)
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor(terrainTagColor(it.terrain))
      }
      .width('100%')
      .padding(10)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(10)
    }, (it: RouteItem) => it.id.toString())
    // 装备速查 ...
  }
  .width('100%')
}

深度解析:

排行榜设计有几个值得注意的细节:

  • 前三名高亮:idx < 3 ? COLORS.accent : COLORS.textHint——前三名的序号使用强调色(橙色),其他使用提示色。这是排行榜的经典做法,突出头部位置。
  • 序号固定宽度:width(20) 确保所有序号对齐,列表更整齐。
  • 热度在右侧:热度值右对齐显示,使用地形对应的颜色作为热度数字的颜色。
  • 卡片式列表项:每条记录都有独立的白色卡片背景和圆角,在浅灰色页面背景上形成清晰的层级。

8.3 装备架页面

@Builder
pageTools() {
  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)
      // 穿戴区、维修区 ...
    }
    .width('100%')
    // 装备列表 ...
    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%')
}

深度解析:

装备管理页面的设计体现了分类管理的思想:

三分类入口:

  • 🚴 整车区:公路车、瓜车
  • ⛑️ 穿戴区:头盔、手套
  • 🔧 维修区:补胎、打气

这种分类方式符合骑友对装备的认知习惯——先按大类快速定位,再查看具体装备。

取消夜骑按钮:

页面底部有一个红色的"取消夜骑"按钮。这个按钮的位置和样式都暗示了这是一个"危险操作":

  • 红色背景(COLORS.danger)
  • 全宽显示,视觉权重高
  • 点击后会弹出确认对话框(openDelA())

这是一种常见的"页面底部危险操作"模式,将删除/取消等不可逆操作放在页面底部,既保证了可访问性,又通过颜色和确认流程防止了误操作。


九、弹窗系统与交互流程

9.1 三种弹窗的复用与差异

与深海主题一样,骑行应用也有新增(发布骑语)、编辑(编辑骑行计划)、删除(取消夜骑/退课)三种弹窗。它们共享相同的结构模式:

  • 遮罩层:半透明黑色背景,点击关闭
  • 标题:明确告知操作类型
  • 表单区域:输入框和文本域
  • 按钮行:取消 + 确认

但具体内容和业务逻辑不同:

弹窗类型标题表单字段确认按钮目标数据
新增发布骑语标题 + 补充细节发布postList(动态列表)
编辑编辑骑行计划计划名称 + 难度 + 当前阶段保存planList(训练计划)
删除取消夜骑/退课无(确认文案)确认planList / postList

9.2 删除操作的双目标设计

删除弹窗支持两种不同的删除场景,通过 delTarget 变量区分:

openDelA(): void {
  this.delTarget = 'ride';   // 取消夜骑
  this.delOpen = true;
}

openDelB(): void {
  this.delTarget = 'course'; // 退课
  this.delOpen = true;
}

doDel(): void {
  if (this.delTarget === 'ride' && this.planList.length > 0) {
    this.planList.splice(0, 1);
  } else if (this.delTarget === 'course' && this.postList.length > 0) {
    this.postList.splice(0, 1);
  }
  this.delOpen = false;
}

深度解析:

这种"一个弹窗 + 目标变量"的设计模式有以下权衡:

优点:

  • 代码复用:弹窗的 UI 结构只需要写一份
  • 维护方便:修改弹窗样式时只需要改一处

缺点:

  • 逻辑分散:需要在多个地方判断 delTarget 的值
  • 扩展性差:如果增加更多删除场景,doDel 函数会越来越长

对于只有两三种删除场景的简单应用,这种模式完全够用。如果场景更多,可以考虑使用策略模式或封装为独立的删除服务。


十、架构对比:两种主题的异同

整体架构对比

架构层面深海潜水主题城市骑行主题共性/差异
色彩接口ColorPalette + foamColorPalette + lane结构相同,专属色名不同
主色海洋蓝 #2E9BD6天空蓝 #3A7BD5同色系,深浅不同
背景深色 #0A1826浅色 #F0F2F5深浅模式不同
动态模型PostItemPostItem完全相同(社区通用)
核心模型SiteItem/DiveItemRouteItem/PlanItem领域不同,结构类似
装备模型GearItemGearItem完全相同(通用模式)
子导航样式下划线式粗块底部指示式样式不同,逻辑相同
背景动画气泡 + 光束流线光带 + 车灯主题定制,原理相同
弹窗系统新增/编辑/删除新增/编辑/删除模式完全相同
页面结构7 个子页面7 个子页面一一对应,领域替换
动画驱动setInterval 90mssetInterval 90ms完全相同
状态数量17 个 @State17 个 @State完全相同

动画效果对比

动画元素深海潜水城市骑行
粒子 1气泡(foam 色,上浮)流线光带(lane 色,水平流动)
粒子 2光束(primaryLight 色,摇曳)车灯闪烁(accent 色,移动闪烁)
粒子数量12 气泡 + 5 光束 = 1710 车灯 + 5 光带 = 15
运动方式上升 + 摇曳水平流动 + 斜向闪烁
透明度范围气泡 0.15-0.43 / 光束 0.05-0.155光带 0.08-0.23 / 车灯 0.15-0.6
动画原理纯函数 + tick 驱动纯函数 + tick 驱动

主题化设计的启示

通过对比这两个主题,我们可以总结出"一键换肤"的实现思路:

  1. 提取色彩常量:所有颜色都从 COLORS 常量中读取,不硬编码在组件里
  2. 统一色彩接口:确保不同主题的颜色字段名一致(除了专属色)
  3. 复用组件结构:UI 结构和布局逻辑完全相同,只是颜色和内容不同
  4. 动画函数替换:不同主题有不同的动画函数,但调用方式和驱动机制相同
  5. 数据内容替换:静态数据(列表文本、图标等)根据主题替换

在真实项目中,可以进一步将主题配置抽离为独立的主题文件,通过运行时切换或编译时配置来实现换肤。


十一、架构流程图与数据流

页面层级结构图

┌─────────────────────────────────────────────────────────────┐
│                      Stack 根布局                             │
├─────────────────────────────────────────────────────────────┤
│  ┌───────────────────────────────────────────────────────┐  │
│  │  背景层 - 浅灰底色 (#F0F2F5)                            │  │
│  └───────────────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  特效层 - hitTestBehavior: None                        │  │
│  │  ┌──────────────┐  ┌──────────────┐                  │  │
│  │  │  流线光带 5 条 │  │  车灯闪烁 10个│                  │  │
│  │  └──────────────┘  └──────────────┘                  │  │
│  └───────────────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  内容层 - Column                                       │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │  header() 横幅标题 + 数据四宫格                  │  │  │
│  │  ├─────────────────────────────────────────────────┤  │  │
│  │  │  subNav() 粗块底部指示式子导航 (仅首页)          │  │  │
│  │  ├─────────────────────────────────────────────────┤  │  │
│  │  │  Scroll - 可滚动内容区 (layoutWeight: 1)        │  │  │
│  │  │  ┌───────────────────────────────────────────┐  │  │  │
│  │  │  │  pageFeatured/pageRoutes/pagePlans/...    │  │  │  │
│  │  │  └───────────────────────────────────────────┘  │  │  │
│  │  ├─────────────────────────────────────────────────┤  │  │
│  │  │  bottomBar() 底部 4 Tab 导航                     │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
│  ┌───────────────────────────────────────────────────────┐  │
│  │  弹窗层 - 条件渲染 (zIndex: 999)                       │  │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐           │  │
│  │  │ 发布骑语 │  │ 编辑计划 │  │ 确认删除 │           │  │
│  │  └──────────┘  └──────────┘  └──────────┘           │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

交互流程图

用户进入页面
    │
    ▼
aboutToAppear 启动定时器
    │
    ├── tick 每 90ms + 1 ──► 触发特效层动画帧更新
    │
    ▼
用户点击底部 Tab
    │
    ├── mainTab 更新 ──► mainContent 条件渲染切换页面
    │
    ▼
用户点击子导航
    │
    ├── subTab 更新 ──► 首页内容区切换子页面
    │
    ▼
用户点击"发骑语"
    │
    ├── addOpen = true ──► 新增弹窗显示
    ├── addTitle/addContent 实时更新 ──► 表单数据绑定
    └── 点击发布 ──► doAdd() ──► postList.unshift(newItem)
                          │
                          └── addOpen = false ──► 弹窗关闭

十二、总结与反思

12.1 骑行应用的技术亮点

1. 浅色主题的成熟实现

相较于深色主题,浅色主题在对比度控制、阴影表现、层级区分上有不同的挑战。这款骑行应用的浅色实现非常成熟:

  • 背景用浅灰而非纯白,减少视觉疲劳
  • 卡片用白色,通过色差形成自然的浮起感
  • 文本使用深灰而非纯黑,更加柔和易读
  • 边框用浅灰色,清晰但不突兀

2. 动画与主题的深度契合

流线光带 + 车灯闪烁的动画设计完美契合骑行主题:

  • 流线光带模拟道路上的光影流动
  • 车灯闪烁模拟骑行中的尾灯效果
  • 橙色车灯与蓝色光带的色彩搭配和谐

3. 数据模型的领域专业性

从路线的六种地形分类,到训练计划的三级难度体系,再到装备的八大件——数据模型的设计体现了对骑行文化的深入理解,不是泛泛而谈的通用模板。

4. 可复用的架构模式

虽然业务领域完全不同,但整体架构(色彩系统、数据模型、导航结构、弹窗系统、页面组织)与深海主题高度一致。这说明这套架构模式具有良好的通用性和可迁移性。

12.2 工程化思考

组件抽象的边界在哪里?

阅读代码时会发现,很多页面都有相似的结构——列表项、卡片、进度条……但它们并没有被抽取为独立组件,而是直接写在每个 @Builder 方法里。这引发了一个经典的工程问题:什么时候应该抽象组件?

抽象的好处是减少重复代码、统一视觉风格、便于维护。但过早的抽象也有代价:

  • 抽象层需要设计通用的 API,增加复杂度
  • 组件的差异需要通过 props 处理,props 会越来越多
  • 修改组件可能影响所有使用处,回归成本高

在这个 Demo 级别的项目中,"复制粘贴式开发"反而是可以接受的——因为每个页面虽然结构相似,但细节上有很多差异,强行抽象可能会让组件变得臃肿难用。而在真实项目中,当团队规模变大、页面数量增多时,建立组件库就变得必要了。

状态管理的复杂度临界点

17 个 @State 变量,对于单个页面组件来说已经不算少了。随着功能增加,状态数量还会继续增长。什么时候需要引入更高级的状态管理方案?

一个粗略的判断标准:

  • 10 个以内:纯 @State 完全没问题
  • 10-20 个:可以考虑将相关状态封装为 @ObservedObject
  • 20 个以上或跨组件共享:考虑全局状态管理(Redux、MVVM 等)

安装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;
  lane: string;
}

const COLORS: ColorPalette = {
  primary: '#3A7BD5',
  primaryLight: '#7BA6E8',
  primaryDark: '#27528F',
  accent: '#FF8A3D',
  accentLight: '#E8EFF9',
  bg: '#F0F2F5',
  cardBg: '#FFFFFF',
  textPrimary: '#2A3038',
  textSecondary: '#5C6672',
  textHint: '#98A2AE',
  border: '#DFE4EA',
  success: '#4CAF7D',
  warning: '#E9A93D',
  danger: '#E05D5D',
  white: '#FFFFFF',
  lane: '#B9CCE8'
};

// ============ 骑友动态数据模型 ============
@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 RouteItem {
  id: number = 0
  name: string = ''
  distance: string = ''
  terrain: string = ''
  status: string = ''
  hot: number = 0
  constructor(id: number, name: string, distance: string, terrain: string, status: string, hot: number) {
    this.id = id; this.name = name; this.distance = distance; this.terrain = terrain
    this.status = status; this.hot = hot
  }
}

// ============ 骑行计划数据模型 ============
@Observed
export class PlanItem {
  id: number = 0
  name: string = ''
  stage: string = ''
  level: string = ''
  progress: number = 0
  constructor(id: number, name: string, stage: string, level: string, progress: number) {
    this.id = id; this.name = name; this.stage = stage; this.level = level
    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 RideChartItem {
  label: string;
  value: number;
  color: string;
}

interface CourseItem {
  title: string;
  week: string;
  progress: number;
  color: string;
}

interface RideTypeItem {
  name: string;
  desc: string;
  icon: string;
  hot: boolean;
}

// ============ 顶层静态数据 ============
const POST_LIST: PostItem[] = [
  new PostItem(1, '破风手阿凯', '🚴', '今早通勤 18 公里均速 27,红灯少真的能省十分钟。', '3分钟前', 97),
  new PostItem(2, '夜骑灯侠', '💡', '前后灯都换了 400 流明的,夜骑被远光晃的日子结束了。', '18分钟前', 85),
  new PostItem(3, '爬坡粉碎机', '⛰️', '南山的坡配速进 6 分了,齿比换 34T 才是版本答案。', '34分钟前', 78),
  new PostItem(4, '补胎老师傅', '🔧', '路上帮一个通勤妹子补胎,撬棒永远多带一套没错。', '1小时前', 69),
  new PostItem(5, '瓜车漫游者', '🛣️', '碎石路换 40c 外胎之后,井盖都变成按摩不再是坎。', '2小时前', 60),
  new PostItem(6, '驿站店长', '☕', '本周末补给站上新冰美式,凭骑行码打八折。', '3小时前', 53),
  new PostItem(7, '锁鞋初学者', '👟', '零速摔第三次之后终于学会提前解锁,膝盖保住了。', '5小时前', 46),
  new PostItem(8, '铁三预备役', '🏅', '骑车心率区间 3 练了一个月,同样的坡心率降了 8。', '昨天', 66)
];

const ROUTE_LIST: RouteItem[] = [
  new RouteItem(1, '滨江夜光环线', '21.6 km', '平路', '已骑行', 88),
  new RouteItem(2, '南山爬坡挑战', '14.2 km', '爬坡', '已骑行', 92),
  new RouteItem(3, '老城咖啡巡游', '16.8 km', '混合', '待出发', 76),
  new RouteItem(4, '江心洲环岛', '24.5 km', '平路', '已骑行', 84),
  new RouteItem(5, '西山训练绕圈', '8.0 km', '起伏', '常驻', 70),
  new RouteItem(6, '跨江大桥通勤线', '11.3 km', '平路', '常驻', 66),
  new RouteItem(7, '森林公园越野线', '18.9 km', '越野', '待出发', 58),
  new RouteItem(8, '古镇长途拉练', '68.0 km', '长途', '规划中', 44)
];

const PLAN_LIST: PlanItem[] = [
  new PlanItem(1, '踏频稳定 90', '平路巡航', '基础', 100),
  new PlanItem(2, '弯道压弯过弯', '控车技巧', '基础', 84),
  new PlanItem(3, '爬坡节奏呼吸', '坡道配速', '进阶', 62),
  new PlanItem(4, '编队轮换破风', '团队协作', '进阶', 48),
  new PlanItem(5, '长距离补给策略', '能量管理', '进阶', 36),
  new PlanItem(6, '越野重心控制', '林道技术', '高级', 22),
  new PlanItem(7, '功率训练区间', '数据化训练', '高级', 30),
  new PlanItem(8, '夜骑安全演练', '灯光与路线', '基础', 72)
];

const GEAR_LIST: GearItem[] = [
  new GearItem(1, '公路车', '铝合金车架', '🚴'),
  new GearItem(2, '骑行头盔', 'MIPS 防护', '⛑️'),
  new GearItem(3, '前后车灯', '400 流明', '💡'),
  new GearItem(4, '骑行裤', '硅胶坐垫', '🩳'),
  new GearItem(5, '码表', 'GPS 记录', '📟'),
  new GearItem(6, '补胎工具', '撬棒补片', '🔧'),
  new GearItem(7, '打气筒', '高压便携', '💨'),
  new GearItem(8, '骑行手套', '防滑减震', '🧤')
];

const WEEK_CHART: WeekChartItem[] = [
  { label: '周一', value: 12 },
  { label: '周二', value: 21 },
  { label: '周三', value: 16 },
  { label: '周四', value: 25 },
  { label: '周五', value: 18 },
  { label: '周六', value: 42 },
  { label: '周日', value: 36 }
];

const RIDE_CHART: RideChartItem[] = [
  { label: '通勤骑行', value: 62, color: '#3A7BD5' },
  { label: '周末长距', value: 48, color: '#FF8A3D' },
  { label: '夜骑', value: 30, color: '#7BA6E8' },
  { label: '爬坡训练', value: 22, color: '#4CAF7D' },
  { label: '越野', value: 10, color: '#E9A93D' }
];

const TYPE_LIST: RideTypeItem[] = [
  { 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: '#3A7BD5' },
  { title: '骑行姿态与踏频', week: '第 2 周', progress: 92, color: '#FF8A3D' },
  { title: '城市路况预判', week: '第 3 周', progress: 70, color: '#4CAF7D' },
  { title: '编队与手势信号', week: '第 4 周', progress: 38, color: '#7BA6E8' },
  { title: '长距离补给与恢复', week: '第 5 周', progress: 16, color: '#E9A93D' }
];

// ============ 导航配置 ============
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 rideColor(v: number): string {
  if (v > 60) {
    return '#3A7BD5';
  } else if (v > 35) {
    return '#FF8A3D';
  }
  return '#4CAF7D';
}

function laneX(tick: number, i: number): number {
  return -40 + ((tick * 9 + i * 131) % 700);
}

function laneY(tick: number, i: number): number {
  return 90 + i * 150;
}

function laneA(tick: number, i: number): number {
  return 0.08 + ((tick + i) % 4) * 0.05;
}

function lightA(tick: number, i: number): number {
  return (tick + i) % 3 === 0 ? 0.6 : 0.15;
}

function lightX(tick: number, i: number): number {
  return 40 + ((tick * 6 + i * 113) % 600);
}

function lightY(tick: number, i: number): number {
  return 70 + ((tick * 7 + i * 59) % 630);
}

function statusColor(s: string): string {
  if (s === '已骑行') {
    return '#4CAF7D';
  } else if (s === '待出发' || s === '常驻') {
    return '#3A7BD5';
  }
  return '#98A2AE';
}

function statusProgress(s: string): number {
  if (s === '已骑行') {
    return 100;
  } else if (s === '常驻') {
    return 80;
  } else if (s === '待出发') {
    return 40;
  }
  return 12;
}

function terrainTagColor(g: string): string {
  if (g === '平路') {
    return '#3A7BD5';
  } else if (g === '爬坡') {
    return '#FF8A3D';
  } else if (g === '越野') {
    return '#4CAF7D';
  }
  return '#E9A93D';
}

function levelColor(s: string): string {
  if (s === '基础') {
    return '#4CAF7D';
  } else if (s === '进阶') {
    return '#3A7BD5';
  }
  return '#FF8A3D';
}

@Entry
@Component
struct PageCityCyclingHub {
  @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 editIdx: number = 0
  @State delTarget: string = 'ride'
  @State editName: string = ''
  @State editStyle: string = ''
  @State editNote: string = ''
  @State addTitle: string = ''
  @State addContent: string = ''
  @State postList: PostItem[] = POST_LIST
  @State planList: PlanItem[] = PLAN_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.planList.length) {
      this.editIdx = index;
      this.editName = this.planList[index].name;
      this.editStyle = this.planList[index].level;
      this.editNote = this.planList[index].stage;
    }
    this.editOpen = true;
  }

  openDelA(): void {
    this.delTarget = 'ride';
    this.delOpen = true;
  }

  openDelB(): void {
    this.delTarget = 'course';
    this.delOpen = 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.planList.length) {
      this.planList.splice(this.editIdx, 1, new PlanItem(this.planList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
    }
    this.editOpen = false;
  }

  doDel(): void {
    if (this.delTarget === 'ride' && this.planList.length > 0) {
      this.planList.splice(0, 1);
    } else if (this.delTarget === 'course' && this.postList.length > 0) {
      this.postList.splice(0, 1);
    }
    this.delOpen = false;
  }

  @Builder
  fxLayer() {
    Stack({ alignContent: Alignment.TopStart }) {
      ForEach([0, 1, 2, 3, 4], (i: number) => {
        Column()
          .width(160)
          .height(10)
          .borderRadius(5)
          .backgroundColor(COLORS.lane)
          .opacity(laneA(this.tick, i))
          .translate({ x: laneX(this.tick, i), y: laneY(this.tick, i) })
      }, (i: number) => i.toString())
      ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (i: number) => {
        Column()
          .width(7)
          .height(7)
          .borderRadius(4)
          .backgroundColor(COLORS.accent)
          .opacity(lightA(this.tick, i))
          .translate({ x: lightX(this.tick, i), y: lightY(this.tick, i) })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height('100%')
    .hitTestBehavior(HitTestMode.None)
  }

  @Builder
  header() {
    Column({ space: 10 }) {
      Row() {
        Column() {
          Row({ space: 8 }) {
            Text('🚴')
              .fontSize(24)
            Text('骑语驿站')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.white)
            Column()
              .width(6)
              .height(6)
              .borderRadius(3)
              .backgroundColor(COLORS.success)
              .margin({ top: 4 })
          }
          .width('100%')
          Text('本周 170.4 km · 目标完成 85%')
            .fontSize(10)
            .fontColor('#D3E2F7')
            .margin({ top: 4 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('约骑')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.primaryDark)
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .backgroundColor(COLORS.white)
          .borderRadius(16)
      }
      .width('100%')
      .padding(16)
      .backgroundColor(COLORS.primaryDark)
      .borderRadius(16)
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text('今日里程')
            .fontSize(9)
            .fontColor(COLORS.textHint)
          Text('18.6 km')
            .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('24.2')
            .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('320 m')
            .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('512 kcal')
            .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: 6 }) {
        ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
          Column({ space: 0 }) {
            Text(item)
              .fontSize(12)
              .fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
              .padding({ left: 12, right: 12, top: 8, bottom: 8 })
            Column()
              .width(this.subTab === idx ? 100 : 0)
              .height(3)
              .backgroundColor(this.subTab === idx ? COLORS.accent : COLORS.cardBg)
          }
          .backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
          .borderRadius(10)
          .onClick(() => {
            this.subTab = idx;
          })
        }, (item: string) => item)
      }
      .width('100%')
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .padding({ top: 4, 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: RideTypeItem) => {
            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: RideTypeItem) => it.name)
        }
        .width('100%')
      }
      .width('100%')

      // 周里程柱状图
      Column() {
        Row() {
          Text('本周每日里程')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .layoutWeight(1)
          Text('单位 km')
            .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, 42))
                .borderRadius(5)
                .backgroundColor(rideColor(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('46%')
            .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(RIDE_CHART, (it: RideChartItem) => {
          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: RideChartItem) => 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(ROUTE_LIST, (it: RouteItem, idx: number) => {
          if (idx % 2 === 0) {
            Row({ space: 10 }) {
              if (idx < ROUTE_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🚴')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(ROUTE_LIST[idx].terrain)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(terrainTagColor(ROUTE_LIST[idx].terrain))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(ROUTE_LIST[idx].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${ROUTE_LIST[idx].distance} · 热度 ${ROUTE_LIST[idx].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
              if (idx + 1 < ROUTE_LIST.length) {
                Column({ space: 6 }) {
                  Row() {
                    Text('🗺️')
                      .fontSize(22)
                      .layoutWeight(1)
                    Text(ROUTE_LIST[idx + 1].terrain)
                      .fontSize(10)
                      .fontWeight(FontWeight.Bold)
                      .fontColor(terrainTagColor(ROUTE_LIST[idx + 1].terrain))
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .backgroundColor(COLORS.accentLight)
                      .borderRadius(6)
                  }
                  .width('100%')
                  Text(ROUTE_LIST[idx + 1].name)
                    .fontSize(14)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(`${ROUTE_LIST[idx + 1].distance} · 热度 ${ROUTE_LIST[idx + 1].hot}`)
                    .fontSize(11)
                    .fontColor(COLORS.textSecondary)
                }
                .layoutWeight(1)
                .alignItems(HorizontalAlign.Start)
                .padding(12)
                .backgroundColor(COLORS.cardBg)
                .borderRadius(12)
              }
            }
            .width('100%')
          }
        }, (it: RouteItem) => 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.primary)
            .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
  pageRoutes() {
    Column({ space: 10 }) {
      Row() {
        Text('路线图鉴')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
          .layoutWeight(1)
        Text('新增路线')
          .fontSize(12)
          .fontColor(COLORS.primary)
          .onClick(() => {
            this.openAdd();
          })
      }
      .width('100%')
      ForEach(ROUTE_LIST, (it: RouteItem) => {
        Column({ space: 6 }) {
          Row() {
            Column()
              .width(4)
              .height(38)
              .borderRadius(2)
              .backgroundColor(statusColor(it.status))
            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.bg)
                  .borderRadius(6)
              }
              .width('100%')
              Row({ space: 8 }) {
                Text(`📏 ${it.distance}`)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text(`🔥 热度 ${it.hot}`)
                  .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(`${statusProgress(it.status)}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(statusColor(it.status))
          }
          .width('100%')
          Row({ space: 10 }) {
            Text(`完成度 ${statusProgress(it.status)}%`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text(it.terrain)
              .fontSize(10)
              .fontWeight(FontWeight.Bold)
              .fontColor(terrainTagColor(it.terrain))
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(COLORS.accentLight)
              .borderRadius(6)
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: RouteItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pagePlans() {
    Column({ space: 10 }) {
      Text('训练计划')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(this.planList, (it: PlanItem, 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.level)
                  .fontSize(10)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(levelColor(it.level))
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor(COLORS.accentLight)
                  .borderRadius(6)
              }
              .width('100%')
              Text(`🔧 ${it.stage}`)
                .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(`${it.progress}%`)
              .height(6)
              .borderRadius(3)
              .backgroundColor(levelColor(it.level))
          }
          .width('100%')
          Row() {
            Text(`掌握度 ${it.progress}%`)
              .fontSize(10)
              .fontColor(COLORS.textHint)
              .layoutWeight(1)
            Text('编辑计划')
              .fontSize(11)
              .fontColor(COLORS.primary)
              .onClick(() => {
                this.openEdit(idx);
              })
          }
          .width('100%')
        }
        .width('100%')
        .padding(12)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (it: PlanItem) => it.id.toString())
    }
    .width('100%')
  }

  @Builder
  pageClub() {
    Column({ space: 10 }) {
      Text('骑行团')
        .fontSize(16)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
        .width('100%')
      ForEach(ROUTE_LIST.slice(0, 5), (it: RouteItem, 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.distance} · ${it.terrain}`)
              .fontSize(10)
              .fontColor(COLORS.textSecondary)
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
          Text(`热度 ${it.hot}`)
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor(terrainTagColor(it.terrain))
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(10)
      }, (it: RouteItem) => 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(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('去补货')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .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(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textPrimary)
                Text(GEAR_LIST[idx + 1].desc)
                  .fontSize(11)
                  .fontColor(COLORS.textSecondary)
                Text('去补货')
                  .fontSize(11)
                  .fontColor(COLORS.primary)
                  .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.primary)
          .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
  pageTools() {
    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.primary)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.accentLight)
            .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('Lv.6 · 年度 2140 km · 骑行 96 次')
            .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.planList.length} 项`)
            .fontSize(12)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        ForEach(this.planList.slice(0, 3), (it: PlanItem) => {
          Row() {
            Text(it.name)
              .fontSize(13)
              .fontColor(COLORS.textSecondary)
              .layoutWeight(1)
            Text(it.stage)
              .fontSize(11)
              .fontColor(levelColor(it.level))
          }
          .width('100%')
        }, (it: PlanItem) => 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.6)
        .onClick(() => {
          this.addOpen = false;
          this.editOpen = false;
          this.delOpen = 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 === 'ride' ? '取消夜骑' : '退出骑行课堂')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textPrimary)
      Text(this.delTarget === 'ride' ? '将移除首项训练计划并通知同行骑友,确认执行?' : '将退出首门进行中的课程,确认执行?')
        .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
  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.primary : 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.pageRoutes()
            } else if (this.subTab === 2) {
              this.pagePlans()
            } else if (this.subTab === 3) {
              this.pageClub()
            } else if (this.subTab === 4) {
              this.pageCourse()
            } else if (this.subTab === 5) {
              this.pageCircle()
            } else {
              this.pageTools()
            }
          }
          .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.pageRoutes()
          }
          .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%')
      }
    }
    .width('100%')
    .height('100%')
  }
}


12.3 从 Demo 到产品的演进路径

在这里插入图片描述

最后,让我们畅想一下,如果要把这个 Demo 发展成真正的骑行社区产品,还需要增加哪些能力?

数据层演进:

  • 接入真实后端 API,替换静态数据
  • 增加本地缓存和离线能力
  • 实现骑行轨迹记录(GPS 追踪)
  • 接入硬件设备(码表、心率带、功率计)

功能层演进:

  • 路线导航(实时导航、语音提示)
  • 数据统计与分析(周/月/年报、能力评估)
  • 社交功能(关注、私信、约骑)
  • 社区运营(活动、挑战、排行榜)

架构层演进:

  • 多页面路由与状态管理
  • 模块化拆分(数据模块、UI 组件库、业务模块)
  • 性能监控与异常上报
  • 自动化测试体系
Logo

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

更多推荐