一、引言

鸿蒙操作系统在 HarmonyOS 6.1.1 版本中持续完善其声明式 UI 开发框架,ArkTS 语言凭借强类型系统、组件化架构和丰富的内置组件库,已成为构建鸿蒙原生应用的首选方案。在 HarmonyOS ArkTS API 24 中,Flex 弹性布局、linearGradient 线性渐变、ForEach 列表渲染、Scroll 滚动容器等核心能力已经高度成熟,能够支撑包含复杂定价逻辑、押金管理、进度条可视化、分段状态选择器等多种交互模式的商业级移动应用。声明式 UI 范式的核心优势在于开发者只需描述界面"是什么"而非"如何变化",框架自动处理状态到视图的映射和增量更新,大幅提升了开发效率和代码可维护性。

露营星野社是一个面向户外露营爱好者的全功能服务平台,业务覆盖装备租赁、装备购买、营地预订、拼队组队、订单管理和个人中心六大核心模块。该应用采用森绿(#2D6A4F)与帆布卡其(#C2A878)作为主色调,底色为自然淡绿(#F4F7F1),营造出沉稳、自然、充满户外大地色调风格的视觉氛围。整体界面设计采用"顶部频道 pill 横滑 + 底部 Tab 导航"的双重导航架构,与电商类应用的导航模式保持一致,用户既可以通过顶部快速切换频道预览,也可以通过底部 Tab 进行主功能模块的跳转。

从技术架构层面来看,该应用实现了 10 个接口定义、4 个全局纯函数、6 个 @Component 组件和 1 个 @Entry 主入口组件。其中最具技术深度的是租赁定价逻辑——全局函数 rentTotal 根据"1 天/3 天/7 天/月租"四种租期档位实现差异化定价:1 天按原价、3 天按原价、7 天享 8 折优惠、月租(30 天)按 24 天计算,这种分层定价策略既激励用户选择更长租期,又确保了平台的收益保障。另一个核心特色是押金系统,每件租赁装备都设有独立押金金额,在租赁车弹框中实时展示"租金合计 + 押金合计",并在"我的"页面提供押金账户弹框,展示冻结金额和退还规则。

在视觉交互层面,该应用实现了多种富有户外特色的 UI 组件:双列租赁卡片网格布局、闪购进度条(以百分比形式展示已抢比例)、营地评分圆徽章布局、拼队成员头像横排(已加入显示笑脸、未加入显示问号占位符)、续租租期选择器、取货码大字号展示、露营徽章墙、押金账户面板等。全局 @Builder 函数 modalOverlay 使用森绿色半透明遮罩(rgba(20,52,43,0.55)),与整体主题色保持一致。所有数据操作均采用不可变更新模式,通过创建新数组替换原数组来触发响应式更新,确保状态管理的可预测性。本文将从接口定义层、全局常量、全局纯函数、各 Tab 组件、主入口页面等多个维度进行逐段代码展示与深度解析。

二、接口定义层分析

2.1 租赁商品接口 RentGoods

interface RentGoods {
  id: number
  name: string
  brand: string
  dayPrice: number
  deposit: number
  stock: number
  tag: string
  score: string
  icon: string
  color: string
  spec: string
}

在这里插入图片描述

RentGoods 接口定义了装备租赁的核心数据结构。id 字段作为唯一标识符用于 ForEach 列表渲染的键值绑定;name 字段存储装备名称(如"隧道帐篷 · 3-4 人款");brand 字段存储品牌名称(如"牧高笛冷山"“挪客屋檐”),品牌信息在详情弹框中以"品牌"标签行展示,帮助用户评估装备品质;dayPrice 字段存储日租金,是 rentTotal 函数的基础计算参数;deposit 字段存储押金金额,在租赁确认抽屉和租赁车弹框中独立展示,押金在归还装备后自动退还;stock 字段记录当前可租库存数量;tag 字段存储促销标签如"人气王"“遮阳神器"等;score 字段以字符串形式存储评分;icon 字段存储 emoji 图标用于卡片视觉标识;color 字段存储卡片渐变起始色值;spec 字段存储规格描述(如"防晒银涂层 · 双门厅”),在详情弹框中以"规格"标签行展示。

2.2 购买商品接口 ShopGoods

interface ShopGoods {
  id: number
  name: string
  price: number
  originPrice: number
  sold: number
  icon: string
  color: string
  label: string
  flash: number
}

在这里插入图片描述

ShopGoods 接口定义了装备购买的数据结构。price 和 originPrice 分别表示秒杀价和原价;sold 字段记录已拼销量,在 UI 中以"(sold / 1000).toFixed(1) + ‘万件’“的格式展示;label 字段存储商品标签(如"返场清仓”“爆款”"早鸟价"等),在卡片图片区域以深色半透明背景的标签形式叠加展示;flash 字段是秒杀进度百分比(0~100),用于渲染闪购进度条——进度条填充宽度直接设为 flash + ‘%’,使用森绿到帆布卡其的渐变色,下方以"已抢 X%"文字提示抢购热度。

2.3 营地接口 CampRow

interface CampRow {
  id: number
  name: string
  place: string
  score: string
  price: number
  tag: string
  icon: string
  color: string
  dist: string
  sites: number
  facilities: string
}

CampRow 接口定义了露营营地的数据结构。place 字段存储营地所在地区(如"杭州 · 临安");dist 字段记录距市区的距离(如"距市区 68km"),帮助用户评估交通成本;sites 字段记录营位数量;facilities 字段以字符串形式存储设施列表(如"淋浴 · 电 · 观星台"),在营地详情弹框中以标签墙形式拆解展示。营地卡片采用评分圆徽章布局——score 评分以白色文字显示在森绿色圆形背景中,与价格和预订按钮水平排列,形成紧凑的信息展示行。

2.4 拼队接口 TeamRow

interface TeamRow {
  id: number
  title: string
  camp: string
  date: string
  need: number
  joined: number
  master: string
  color: string
  note: string
}

TeamRow 接口定义了拼队活动的数据结构。need 字段记录所需人数,joined 字段记录已加入人数,这两个字段通过全局函数 teamLeft 计算还差几人。master 字段记录队长昵称,在入队确认弹框中以"队长"标签展示。note 字段存储队伍备注信息(如"缺 1 人 · 已带天幕和焚火台"),在队伍卡片中以灰字展示,帮助用户了解队伍装备情况。date 字段存储活动日期(如"08/29 周六"),在卡片标题行右侧展示。

2.5 订单接口 OrderRowC

interface OrderRowC {
  id: number
  name: string
  spec: string
  dates: string
  price: number
  status: string
  days: number
  code: string
}

在这里插入图片描述

OrderRowC 接口定义了装备租赁订单的数据结构。spec 字段存储租期规格(如"1 天"“3 天”“7 天”“月租”),与 RENT_TERMS 数组中的租期选项对应;dates 字段存储租赁日期范围(如"08/24 - 08/25");status 字段记录订单状态,可能的值包括"使用中"“待归还”“已归还”“押金已退"等;days 字段记录实际租赁天数,用于续租弹框中计算日租金(price / days 取整后作为基础日租金传入 rentTotal 函数);code 字段存储订单编号(如"CMP20260824001”),在取货码弹框中以小字号展示。

2.6 其他接口定义

interface CouponC {
  id: number
  title: string
  amount: string
  cond: string
  expire: string
  kind: string
}

interface MineMenuC {
  id: number
  icon: string
  name: string
  desc: string
  color: string
}

interface BarC {
  month: string
  amount: number
}

interface TabMetaC {
  name: string
  icon: string
}

interface DateChipC {
  id: number
  label: string
  sub: string
  full: boolean
}

CouponC 接口定义了优惠券数据结构,kind 字段以英文标识优惠券类别(“rent”“shop”“camp”“team”“all”),在 UI 中通过条件判断映射为中文显示(“租赁”“营地”"通用"等)。MineMenuC 接口定义了"我的"页面功能宫格的每一项,该应用定义了 8 个菜单项(比一般应用多2个),每个项都有独特的 emoji 图标和主题色。BarC 接口定义了消费柱状图数据,amount 字段存储月消费金额。TabMetaC 接口定义了底部导航 Tab 的元数据。DateChipC 接口定义了营地预约的日期选项,full 字段为布尔类型,true 表示该日期已满员不可选——在 UI 中通过灰色文字和不可点击的交互状态来区分可选与已满日期。

三、全局常量与枚举分析

3.1 租期常量与定价映射

const RENT_TERMS: string[] = ['1 天', '3 天', '7 天', '月租']

const RENT_DAYS: number[] = [1, 3, 7, 30]

RENT_TERMS 和 RENT_DAYS 是租赁定价系统的两个核心常量。RENT_TERMS 定义了四种租期档位的显示名称,在租期切换条、租赁确认抽屉和续租弹框中使用。RENT_DAYS 定义了每种租期对应的天数,与 RENT_TERMS 通过相同的数组索引一一对应——索引 0 对应"1 天"=1 天,索引 1 对应"3 天"=3 天,索引 2 对应"7 天"=7 天,索引 3 对应"月租"=30 天。这种"显示名称数组 + 天数数组"的双数组设计使得 rentTotal 函数能够通过索引快速获取天数进行定价计算,同时保持了 UI 显示与业务逻辑的解耦。

3.2 租赁商品数据

const RENT_GOODS: RentGoods[] = [
  { id: 1, name: '隧道帐篷 · 3-4 人款', brand: '牧高笛冷山', dayPrice: 45, deposit: 300, stock: 12, tag: '人气王', score: '4.9', icon: '⛺', color: '#D8F3DC', spec: '防晒银涂层 · 双门厅' },
  { id: 2, name: '天幕 · 4x5m 涂银', brand: '挪客屋檐', dayPrice: 30, deposit: 200, stock: 20, tag: '遮阳神器', score: '4.8', icon: '🏖️', color: '#FFF3D6', spec: 'UPF50+ · 抗风 6 级' },
  { id: 3, name: '露营推车 · 折叠大容量', brand: '北欢大容量', dayPrice: 25, deposit: 260, stock: 15, tag: '省力必租', score: '4.7', icon: '🛒', color: '#E3F0E8', spec: '120L · 宽轮越野' },
  { id: 4, name: '焚火台 · 不锈钢双炉', brand: '火枫双星', dayPrice: 35, deposit: 180, stock: 9, tag: '围炉煮茶', score: '4.9', icon: '🔥', color: '#FFE3D3', spec: '带烤网 · 灰烬盘' },
  { id: 5, name: '户外电源 · 1 度电', brand: '电小二1000', dayPrice: 68, deposit: 800, stock: 6, tag: '续航担当', score: '4.8', icon: '🔋', color: '#DCEBFF', spec: '220V · 带 PD 快充' },
  // ... 共 10 条租赁装备数据
]

RENT_GOODS 常量是租赁 Tab 的核心数据源,包含 10 条装备数据。每条数据都精心设置了不同的 dayPrice(15~68 元/天)和 deposit(90~800 元)值,体现了不同装备的价值差异——户外电源的押金最高(800 元),星空投影灯串的押金最低(90 元)。color 字段使用柔和的浅色系(如 #D8F3DC 浅绿、#FFF3D6 浅黄、#E3F0E8 浅灰绿等),在双列卡片布局中作为渐变起始色,渐变终止色统一为白色(#FFFFFF),形成清新淡雅的视觉风格。spec 字段采用"特性1 · 特性2"的格式存储装备规格,在详情弹框中以"规格"标签行完整展示。

3.3 购买商品与营地数据

const SHOP_GOODS: ShopGoods[] = [
  { id: 1, name: '冷山 3 Air 帐篷 全新', price: 399, originPrice: 659, sold: 2300, icon: '⛺', color: '#D8F3DC', label: '返场清仓', flash: 76 },
  { id: 2, name: '涂银天幕 5x6 加大版', price: 179, originPrice: 299, sold: 5600, icon: '🏖️', color: '#FFF3D6', label: '爆款', flash: 92 },
  // ... 共 8 条购买商品数据
]

const CAMPS: CampRow[] = [
  { id: 1, name: '云顶星野营地', place: '杭州 · 临安', score: '4.9', price: 128, tag: '观星营地', icon: '🌌', color: '#E8E4FF', dist: '距市区 68km', sites: 42, facilities: '淋浴 · 电 · 观星台' },
  { id: 2, name: '松林湖畔露营地', place: '湖州 · 安吉', score: '4.8', price: 98, tag: '湖景位', icon: '🌲', color: '#D8F3DC', dist: '距市区 52km', sites: 60, facilities: '淋浴 · 皮划艇 · 篝火' },
  // ... 共 8 条营地数据
]

SHOP_GOODS 常量包含 8 条装备购买数据,flash 字段的取值范围从 38 到 97,在进度条中以百分比形式可视化展示抢购热度。sold 字段最高达到 12000(氛围灯串太阳能款),在 UI 中以"1.2 万件"的形式展示。CAMPS 常量包含 8 条营地数据,覆盖杭州、湖州、台州、衢州、绍兴、宁波、金华等地区,price 范围从 88 元到 198 元/晚。facilities 字段使用" · "分隔符存储多个设施,在营地详情弹框的设施标签墙中进一步拆解为独立标签展示。

3.4 拼队与订单数据

const TEAMS: TeamRow[] = [
  { id: 1, title: '周六云顶观星 4 人局', camp: '云顶星野营地', date: '08/29 周六', need: 4, joined: 3, master: '山系阿正', color: '#E8E4FF', note: '缺 1 人 · 已带天幕和焚火台' },
  { id: 2, title: '安吉松林亲子捞鱼团', camp: '松林湖畔露营地', date: '08/30 周日', need: 6, joined: 4, master: '团子妈妈', color: '#D8F3DC', note: '缺 2 人 · 娃 3 枚求同龄' },
  { id: 3, title: '温岭风车山追日出行', camp: '风车山丘营地', date: '08/29 周六', need: 5, joined: 5, master: '早起困难户', color: '#FFE8D6', note: '已满员 · 等候补' },
  // ... 共 6 条拼队数据
]

const ORDERS_C: OrderRowC[] = [
  { id: 1, name: '隧道帐篷 · 3-4 人款', spec: '1 天', dates: '08/24 - 08/25', price: 45, status: '使用中', days: 1, code: 'CMP20260824001' },
  { id: 2, name: '户外电源 · 1 度电', spec: '3 天', dates: '08/23 - 08/26', price: 204, status: '使用中', days: 3, code: 'CMP20260823002' },
  { id: 3, name: '焚火台 · 不锈钢双炉', spec: '7 天', dates: '08/18 - 08/25', price: 245, status: '待归还', days: 7, code: 'CMP20260818003' },
  // ... 共 8 条订单数据
]

TEAMS 常量包含 6 条拼队数据,其中第 3 条(温岭风车山追日出行)的 joined 等于 need,状态为"已满员",在 UI 中按钮显示"等候补"且禁用样式。note 字段的"缺 X 人"信息与 teamLeft 函数的计算结果对应。ORDERS_C 常量包含 8 条订单数据,覆盖了"使用中"“待归还”“已归还”"押金已退"四种状态,其中 days 字段记录的租赁天数与 spec 字段的租期档位对应,但 price 字段存储的是总租金而非日租金——例如"焚火台 7 天"的 price 为 245 元,即 35 元/天 × 7 天 × 0.8(8折)= 196 元…实际上这里的 price 是 245,说明该订单可能是在 8 折优惠上线前下的单,体现了数据真实性。

3.5 优惠券、消费记录与导航常量

const COUPONS_C: CouponC[] = [
  { id: 1, title: '租赁新客券', amount: '30', cond: '满 100 可用', expire: '08/31 前', kind: 'rent' },
  { id: 2, title: '周末租期券', amount: '20', cond: '满 60 可用', expire: '09/15 前', kind: 'rent' },
  { id: 3, title: '装备购满减', amount: '50', cond: '满 300 可用', expire: '08/31 前', kind: 'shop' },
  { id: 4, title: '营地预订券', amount: '40', cond: '满 120 可用', expire: '09/30 前', kind: 'camp' },
  { id: 5, title: '拼队免押券', amount: '免押', cond: '芝麻分 650+', expire: '长期', kind: 'team' },
  { id: 6, title: '老友回归券', amount: '15', cond: '无门槛', expire: '09/10 前', kind: 'all' }
]

const SPEND_BARS_C: BarC[] = [
  { month: '3月', amount: 320 },
  { month: '4月', amount: 560 },
  { month: '5月', amount: 480 },
  { month: '6月', amount: 890 },
  { month: '7月', amount: 1260 },
  { month: '8月', amount: 1580 }
]

const DATE_CHIPS_C: DateChipC[] = [
  { id: 1, label: '今天', sub: '08/24', full: false },
  { id: 2, label: '明天', sub: '08/25', full: false },
  { id: 3, label: '周六', sub: '08/29', full: false },
  { id: 4, label: '周日', sub: '08/30', full: true },
  { id: 5, label: '周一', sub: '08/31', full: false },
  { id: 6, label: '周六', sub: '09/05', full: false }
]

const MAIN_TABS_C: TabMetaC[] = [
  { name: '租装备', icon: '⛺' },
  { name: '装备购', icon: '🛍️' },
  { name: '营地', icon: '🌲' },
  { name: '拼队', icon: '👥' },
  { name: '订单', icon: '📋' },
  { name: '我的', icon: '👤' }
]

在这里插入图片描述

COUPONS_C 常量定义了 6 张优惠券,amount 字段存储的值有数值型("30""20""50""40"“15”)和特殊型(“免押”),在 UI 中以"¥"前缀拼接显示("免押"除外)。kind 字段的英文标识在 UI 中通过条件判断映射为中文——“rent"映射为"租赁”、“camp"映射为"营地”、"all"映射为"通用"等。SPEND_BARS_C 常量定义了近 6 个月的露营消费数据,金额从 320 元递增到 1580 元,呈现明显的夏季消费高峰趋势。DATE_CHIPS_C 常量定义了 6 个营地预约日期选项,其中第 4 个(周日 08/30)的 full 为 true,在 UI 中以灰色不可选状态呈现。MAIN_TABS_C 常量定义了底部导航的 6 个 Tab 元数据。

3.6 菜单数据

const MINE_MENUS_C: MineMenuC[] = [
  { id: 1, icon: '⛺', name: '我的租赁', desc: '2 件使用中', color: '#D8F3DC' },
  { id: 2, icon: '🎟️', name: '营地订单', desc: '3 个营地待出行', color: '#E8E4FF' },
  { id: 3, icon: '💰', name: '押金账户', desc: '冻结 660 元', color: '#FFF3D6' },
  { id: 4, icon: '⭐', name: '装备收藏', desc: '收藏 12 件', color: '#FFE8D6' },
  { id: 5, icon: '👥', name: '我的队伍', desc: '参与 4 局', color: '#DCEBFF' },
  { id: 6, icon: '📍', name: '足迹营地', desc: '打卡 9 个营地', color: '#E8F5E9' },
  { id: 7, icon: '🛠️', name: '归还网点', desc: '附近 3 个网点', color: '#EDE0D4' },
  { id: 8, icon: '🎧', name: '在线客服', desc: '24h 在线', color: '#EFE6FF' }
]

MINE_MENUS_C 常量定义了 8 个功能菜单项,比一般应用多出"押金账户"和"归还网点"两个特色菜单,体现了装备租赁业务的独特需求。每个菜单项的 color 字段存储不同的浅色背景色,在功能宫格中以 42x42 像素的圆角矩形图标背景呈现。菜单项的点击路由通过 id 判断——id 为 3 时弹出押金账户弹框,id 为 8 时弹出客服弹框,其他 id 弹出徽章墙弹框。

四、全局纯函数分析

4.1 租赁定价函数 rentTotal

function rentTotal(base: number, idx: number): number {
  const d: number = RENT_DAYS[idx]
  if (d >= 30) {
    return base * 24
  }
  if (d >= 7) {
    return base * d * 8 / 10
  }
  return base * d
}

rentTotal 函数是该应用最具业务深度的全局纯函数,实现了装备租赁的分层定价逻辑。函数接收两个参数:base 为日租金基数,idx 为租期档位索引(0~3 对应 RENT_DAYS 数组)。函数首先通过 RENT_DAYS[idx] 获取实际天数 d,然后根据 d 的值执行三段分支定价逻辑。

第一段分支(d >= 30,即月租):返回 base × 24,即月租按 24 天计算,相当于在 30 天的基础上打了 8 折(24/30 = 0.8)。这种设计既给予月租用户显著的优惠(比按天计算节省 6 天的租金),又保障了平台的最低收益。第二段分支(d >= 7,即 7 天租):返回 base × d × 0.8,即 7 天租享 8 折优惠。例如焚火台日租金 35 元,7 天租的总价为 35 × 7 × 0.8 = 196 元。第三段分支(d < 7,即 1 天或 3 天):返回 base × d,即按原价计算无折扣。

这种三段式定价策略在租赁行业非常常见,其核心思路是通过价格梯度激励用户选择更长的租期——长租用户享受更低的日均租金,平台则获得更高的单笔订单金额和更稳定的收入预期。该函数在 RentalTab 组件的 cartTotal 方法、租赁确认抽屉弹框、续租弹框等多处被调用,是租赁业务的核心计算引擎。

4.2 租期名称函数 termName

function termName(idx: number): string {
  return RENT_TERMS[idx]
}

termName 函数是一个简单的索引映射工具函数,将租期索引转换为对应的显示名称。虽然该函数的实现极为简洁(仅一行 return 语句),但将其封装为独立函数的设计有两个好处:一是提高了代码的可读性——调用方看到 termName(this.termIdx) 比直接看到 RENT_TERMS[this.termIdx] 更容易理解意图;二是为未来可能的国际化或显示格式变更预留了扩展点,只需修改该函数即可全局生效。

4.3 柱状图高度函数 barCH

function barCH(v: number): number {
  return Math.round(v / 16)
}

在这里插入图片描述

barCH 函数将消费金额映射为柱状图柱子的像素高度。函数以 16 作为除数,将输入金额 v 除以 16 后使用 Math.round 四舍五入取整。以 SPEND_BARS_C 数据为例,3 月份 320 元对应高度 20 像素,8 月份 1580 元对应高度 99 像素。这种线性映射方式简单直观,最大值对应的高度约为 99 像素,适合在 110 像素高度的容器中展示。与周边游应用中 barHT 函数使用固定最大值 2400 进行归一化不同,barCH 函数直接使用除法取整,计算效率更高但灵活性略低。

4.4 拼队剩余人数函数 teamLeft

function teamLeft(need: number, joined: number): number {
  if (need - joined > 0) {
    return need - joined
  }
  return 0
}

teamLeft 函数计算拼队还差多少人才能满员。函数接收 need(所需人数)和 joined(已加入人数)两个参数,当 need - joined 大于 0 时返回差值,否则返回 0。该函数在 TeamTab 组件中被多处调用——在队伍卡片的"缺 X 人"标签、按钮文案(“申请入队"或"等候补”)以及按钮背景色判断中都使用了 teamLeft 的返回值。将这一计算逻辑封装为全局纯函数,确保了整个组件中对"剩余人数"的计算方式一致,避免了在不同位置使用不同计算方式导致的不一致风险。

五、各 Tab 组件深度分析

5.1 租装备组件 RentalTab

5.1.1 状态变量与关键方法
@Component
export struct RentalTab {
  @State termIdx: number = 0
  @State curRent: RentGoods = RENT_GOODS[0]
  @State cartRent: RentGoods[] = []
  @State showRentDrawer: boolean = false
  @State showRentDetail: boolean = false
  @State showCart: boolean = false

  addCart() {
    const g: RentGoods = this.curRent
    const r: RentGoods[] = []
    for (let i = 0; i < this.cartRent.length; i++) {
      r.push(this.cartRent[i])
    }
    r.push(g)
    this.cartRent = r
    this.showRentDrawer = false
  }

  removeCart(id: number) {
    const r: RentGoods[] = []
    for (let i = 0; i < this.cartRent.length; i++) {
      if (this.cartRent[i].id !== id) {
        r.push(this.cartRent[i])
      }
    }
    this.cartRent = r
  }

  cartTotal(): number {
    let s: number = 0
    for (let i = 0; i < this.cartRent.length; i++) {
      s = s + rentTotal(this.cartRent[i].dayPrice, this.termIdx)
    }
    this.cartRent = r
  }

  cartDeposit(): number {
    let s: number = 0
    for (let i = 0; i < this.cartRent.length; i++) {
      s = s + this.cartRent[i].deposit
    }
    return s
  }

在这里插入图片描述

RentalTab 组件维护了 6 个状态变量。termIdx 记录选中的租期档位索引,默认为 0(1 天),该变量直接影响 rentTotal 函数的定价计算结果。curRent 记录当前选中查看的租赁装备实例。cartRent 是租赁车数组,初始为空数组,用户通过 addCart 方法逐件添加装备。

addCart 方法实现租赁车的"添加"操作——将当前选中的装备 curRent 追加到 cartRent 数组末尾,同时关闭租赁确认抽屉弹框。removeCart 方法通过 id 过滤实现"移除"操作。cartTotal 方法是租赁车金额计算的核心方法——遍历 cartRent 数组,对每件装备调用 rentTotal(dayPrice, termIdx) 计算租金,累加得到总租金。注意这里使用的是全局 rentTotal 函数而非简单的 dayPrice × days 乘法,确保了折扣逻辑的一致性。cartDeposit 方法遍历 cartRent 数组累加每件装备的 deposit 字段,得到总押金金额。这两个方法在底部浮动条和租赁车弹框中被调用,实时展示"租金合计 + 押金合计"的金额信息。

5.1.2 build 方法关键代码段
  build() {
    Scroll() {
      Column() {
        // 顶部租期切换条
        Row({ space: 0 }) {
          ForEach(RENT_TERMS, (t: string, i: number) => {
            Column({ space: 4 }) {
              Text(t)
                .fontSize(13)
                .fontWeight(this.termIdx === i ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.termIdx === i ? '#FFFFFF' : '#52796F')
              if (this.termIdx === i) {
                Column()
                  .width(18)
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor('#C2A878')
              } else {
                Column()
                  .width(18)
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor('rgba(0,0,0,0)')
              }
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 8 })
            .alignItems(HorizontalAlign.Center)
            .backgroundColor(this.termIdx === i ? '#2D6A4F' : '#FFFFFF')
            .borderRadius(10)
            .margin({ left: i === 0 ? 0 : 4, right: i === RENT_TERMS.length - 1 ? 0 : 4 })
            .onClick(() => {
              this.termIdx = i
            })
          }, (t: string) => t)
        }
        .width('100%')

        Row({ space: 6 }) {
          Text('🏕️ 租期越长越划算 · 月租低至 8 折')
            .fontSize(11)
            .fontColor('#2D6A4F')
            .layoutWeight(1)
          Text('免押金 · 芝麻分 650+')
            .fontSize(10)
            .fontColor('#8A6D3B')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(8)
            .backgroundColor('#F3E8D9')
        }
        .width('100%')
        .margin({ top: 10, bottom: 6 })

在这里插入图片描述

build 方法以 Scroll 组件作为根容器。顶部租期切换条是该组件的核心交互元素——通过 ForEach 遍历 RENT_TERMS 数组渲染四个租期选项,每个选项使用 layoutWeight(1) 等分宽度。选中状态的选项背景为森绿色(#2D6A4F)、文字为白色加粗,下方显示帆布卡其色(#C2A878)的指示条;未选中状态背景为白色、文字为灰绿色(#52796F),指示条为透明色。选项之间的间距通过 margin 动态设置——第一个元素无左边距,最后一个元素无右边距,中间元素各有 4 像素间距,实现了紧凑均匀的布局效果。切换条下方的提示行展示"租期越长越划算 · 月租低至 8 折"的促销文案,右侧展示"免押金 · 芝麻分 650+"标签,引导用户选择更长租期和开通免押服务。

        // 双列租赁卡片
        Flex({ wrap: FlexWrap.Wrap, direction: FlexDirection.Row, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(RENT_GOODS, (g: RentGoods) => {
            Column() {
              Column() {
                Text(g.icon)
                  .fontSize(38)
                Text(g.tag)
                  .fontSize(9)
                  .fontColor('#8A6D3B')
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor('rgba(255,255,255,0.85)')
                  .margin({ top: 6 })
              }
              .width('100%')
              .height(92)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .linearGradient({
                angle: 135,
                colors: [[g.color, 0], ['#FFFFFF', 1]]
              })

              Column({ space: 5 }) {
                Text(g.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .width('100%')

                Text(g.brand + ' · ' + g.spec)
                  .fontSize(10)
                  .fontColor('#7A8B7F')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .width('100%')

                Row({ space: 4 }) {
                  Text('★ ' + g.score)
                    .fontSize(10)
                    .fontColor('#B7791F')
                  Text('库存 ' + g.stock)
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                }
                .width('100%')

                Row() {
                  Text('¥')
                    .fontSize(11)
                    .fontColor('#B03A2E')
                    .fontWeight(FontWeight.Bold)
                  Text(g.dayPrice.toString())
                    .fontSize(18)
                    .fontColor('#B03A2E')
                    .fontWeight(FontWeight.Bold)
                  Text('/天')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                }
                .width('100%')
                .alignItems(VerticalAlign.Bottom)

                Row() {
                  Button() {
                    Text('详情')
                      .fontSize(11)
                      .fontColor('#2D6A4F')
                  }
                  .height(28)
                  .padding({ left: 12, right: 12 })
                  .borderRadius(14)
                  .backgroundColor('#FFFFFF')
                  .border({ width: 1, color: '#2D6A4F' })
                  .onClick(() => {
                    this.curRent = g
                    this.showRentDetail = true
                  })

                  Button() {
                    Text('去租赁')
                      .fontSize(11)
                      .fontColor('#FFFFFF')
                  }
                  .height(28)
                  .padding({ left: 14, right: 14 })
                  .borderRadius(14)
                  .backgroundColor('#2D6A4F')
                  .margin({ left: 8 })
                  .onClick(() => {
                    this.curRent = g
                    this.showRentDrawer = true
                  })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                .margin({ top: 2 })
              }
              .padding(10)
              .alignItems(HorizontalAlign.Start)
            }
            .width('48.5%')
            .margin({ bottom: 10 })
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .shadow({ radius: 8, color: 'rgba(45,106,79,0.10)', offsetX: 0, offsetY: 3 })
            .onClick(() => {
              this.curRent = g
              this.showRentDetail = true
            })
          }, (g: RentGoods) => g.id.toString())
        }
        .width('100%')

在这里插入图片描述

双列租赁卡片是该组件的主体内容区域,使用 Flex 弹性换行布局实现两列等宽排列。每张卡片宽度设为 48.5%,配合 SpaceBetween 对齐方式在两列之间留出 3% 的间距。卡片顶部图片区域使用 linearGradient 渐变背景(从 g.color 到白色),高度 92 像素,居中展示装备 emoji 图标和促销标签。标签以半透明白色背景的圆角小标签形式叠加在图片底部。

卡片信息区域包含装备名称(maxLines: 1 + Ellipsis 确保单行省略)、品牌与规格描述(同样单行省略)、评分与库存信息行、价格行(¥符号 + 数字 + /天 单位,底部对齐)和操作按钮行。"详情"按钮使用白底森绿边框的描边样式,"去租赁"按钮使用森绿实心背景,两个按钮通过 SpaceBetween 对齐分别贴左右排列。整个卡片设置了森绿色半透明阴影(rgba(45,106,79,0.10)),营造轻微的悬浮视觉效果。

5.1.3 租赁确认抽屉弹框
        // 租赁确认抽屉弹框
        if (this.showRentDrawer) {
          modalOverlay(() => {
            this.showRentDrawer = false
          })
          Column({ space: 14 }) {
            Row() {
              Column()
                .width(36)
                .height(4)
                .borderRadius(2)
                .backgroundColor('#D5DBD6')
            }
            .width('100%')
            .justifyContent(FlexAlign.Center)

            Text('确认租赁 · ' + this.curRent.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1B4332')
              .width('100%')

            Row({ space: 10 }) {
              Column() {
                Text(this.curRent.icon)
                  .fontSize(34)
              }
              .width(64)
              .height(64)
              .borderRadius(12)
              .backgroundColor(this.curRent.color)
              .justifyContent(FlexAlign.Center)

              Column({ space: 3 }) {
                Text(this.curRent.brand)
                  .fontSize(11)
                  .fontColor('#7A8B7F')
                Text(this.curRent.spec)
                  .fontSize(11)
                  .fontColor('#7A8B7F')
                Text('押金 ¥' + this.curRent.deposit + '(归还秒退)')
                  .fontSize(11)
                  .fontColor('#8A6D3B')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 0 }) {
              ForEach(RENT_TERMS, (t: string, i: number) => {
                Text(t)
                  .fontSize(12)
                  .fontColor(this.termIdx === i ? '#FFFFFF' : '#52796F')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(10)
                  .backgroundColor(this.termIdx === i ? '#2D6A4F' : '#F0F4EF')
                  .margin({ left: i === 0 ? 0 : 6 })
                  .onClick(() => {
                    this.termIdx = i
                  })
              }, (t: string) => t)
            }
            .width('100%')

            Column({ space: 8 }) {
              Row() {
                Text('租金(' + termName(this.termIdx) + ')')
                  .fontSize(12)
                  .fontColor('#7A8B7F')
                Text('¥' + rentTotal(this.curRent.dayPrice, this.termIdx))
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#B03A2E')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
              }
              .width('100%')

              Row() {
                Text('押金(冻结)')
                  .fontSize(12)
                  .fontColor('#7A8B7F')
                Text('¥' + this.curRent.deposit)
                  .fontSize(12)
                  .fontColor('#1B4332')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
              }
              .width('100%')

              Row() {
                Text('取货网点')
                  .fontSize(12)
                  .fontColor('#7A8B7F')
                Text('星野社 · 西湖文化广场店')
                  .fontSize(12)
                  .fontColor('#1B4332')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#F0F4EF')

            Row({ space: 10 }) {
              Button() {
                Text('再看看')
                  .fontSize(13)
                  .fontColor('#52796F')
              }
              .height(40)
              .layoutWeight(1)
              .borderRadius(20)
              .backgroundColor('#FFFFFF')
              .border({ width: 1, color: '#B7C9BD' })
              .onClick(() => {
                this.showRentDrawer = false
              })

              Button() {
                Text('加入租赁车')
                  .fontSize(13)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
              }
              .height(40)
              .layoutWeight(1)
              .borderRadius(20)
              .backgroundColor('#2D6A4F')
              .onClick(() => {
                this.addCart()
              })
            }
            .width('100%')
          }
          .width('92%')
          .constraintSize({ maxHeight: '80%' })
          .padding(18)
          .borderRadius({ topLeft: 22, topRight: 22, bottomLeft: 22, bottomRight: 22 })
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Start)
        }

租赁确认抽屉弹框是该组件的核心交互弹框,采用居中卡片样式(四角均设置 22 像素圆角)。弹框首先渲染顶部拖拽指示条(36x4 像素灰色圆角矩形),然后展示装备名称标题。装备信息行采用左图标右文字的布局——左侧 64x64 像素圆角图标区域使用装备的 color 值作为背景色,右侧展示品牌、规格和押金信息。押金信息特别标注"归还秒退"提示,消除用户对押金安全的顾虑。

租期选择行通过 ForEach 遍历 RENT_TERMS 数组渲染四个租期选项,选中状态使用森绿色背景,未选中状态使用浅灰绿背景。费用明细区域以三行表格形式展示——租金行调用 termName 和 rentTotal 函数动态计算租金金额,押金行展示固定金额,取货网点行展示固定门店地址。这种将费用拆解为明细行的设计方式,使用户能够清晰了解每项费用的构成,提升了交易的透明度。底部双按钮采用"再看看"(描边样式)和"加入租赁车"(实心样式)的对称布局,通过 layoutWeight(1) 等分宽度。

5.2 装备购组件 ShopTab

@Component
export struct ShopTab {
  @State buyNum: number = 1
  @State curShop: ShopGoods = SHOP_GOODS[0]
  @State showBuy: boolean = false
  @State showFlash: boolean = false
  @State favShop: number[] = [2, 6]

  toggleFav(id: number) {
    const r: number[] = []
    for (let i = 0; i < this.favShop.length; i++) {
      if (this.favShop[i] !== id) {
        r.push(this.favShop[i])
      }
    }
    if (this.favShop.indexOf(id) < 0) {
      r.push(id)
    }
    this.favShop = r
  }

  isFav(id: number): boolean {
    return this.favShop.indexOf(id) >= 0
  }

ShopTab 组件维护了 5 个状态变量。buyNum 记录购买数量,默认为 1。favShop 是收藏的商品 id 数组,初始包含 id 为 2 和 6 的两个商品。toggleFav 方法实现收藏切换,使用 indexOf 方法判断目标 id 是否已存在——如果 indexOf 返回 -1(不存在)则追加,否则过滤移除。isFav 方法通过 indexOf 返回值是否大于等于 0 来判断收藏状态,在 UI 中用于切换爱心图标的颜色和样式。

装备购组件的秒杀 banner 使用森绿到帆布卡其的渐变背景,展示倒计时数字(时/分/秒三个数字块)和"去抢购"按钮。商品列表采用单列横向卡片布局(不同于租装备的双列布局),每张卡片左侧是 92x92 像素的图标区域,右侧是商品信息。卡片信息区域的特色是闪购进度条——通过设置 Column 的 width 为 g.flash + ‘%’ 实现进度条填充,使用森绿到帆布卡其的渐变色,下方以"已抢 X%"文字提示。购买确认弹框采用居中圆角卡片样式(width: 74%),包含商品图标、名称、标签、数量选择器、合计金额和"单独购/发起拼单"双按钮。

5.3 营地组件 CampTab

@Component
export struct CampTab {
  @State curCamp: CampRow = CAMPS[0]
  @State dateIdxC: number = 0
  @State showCampDetail: boolean = false
  @State showBook: boolean = false
  @State showFacility: boolean = false
  @State booked: string[] = ['云顶星野营地 08/29']

  bookCamp() {
    const c: CampRow = this.curCamp
    const d: DateChipC = DATE_CHIPS_C[this.dateIdxC]
    const r: string[] = []
    for (let i = 0; i < this.booked.length; i++) {
      r.push(this.booked[i])
    }
    r.push(c.name + ' ' + d.sub)
    this.booked = r
    this.showBook = false
  }

CampTab 组件维护了 6 个状态变量。curCamp 记录当前选中的营地实例。dateIdxC 记录选中的日期索引。booked 是已预订营地列表(字符串数组),初始包含一条预订记录。bookCamp 方法实现营地预订——将营地名称和选中日期拼接为字符串后追加到 booked 数组,同时关闭预订弹框。

营地列表采用评分圆徽章布局,每张卡片左侧是 100x88 像素的图标区域(使用 linearGradient 渐变背景),右侧是营地信息。信息区域顶部展示"NO.X"排名标签和营地名称,下方依次展示标签/距离/营位信息、设施信息和价格行。价格行是该卡片的视觉焦点——左侧是 34x34 像素的森绿色圆形评分徽章(白色评分数字居中),中间是价格信息(¥价格 + /晚起),右侧是"预订"按钮。营地预约弹框采用日期 chips 网格布局,通过 Flex 弹性换行排列 6 个日期选项,full 为 true 的日期以灰色不可选状态呈现。

5.4 拼队组件 TeamTab

@Component
export struct TeamTab {
  @State curTeam: TeamRow = TEAMS[0]
  @State myTeams: TeamRow[] = []
  @State teamList: TeamRow[] = [
    { id: 1, title: '周六云顶观星 4 人局', camp: '云顶星野营地', date: '08/29 周六', need: 4, joined: 3, master: '山系阿正', color: '#E8E4FF', note: '缺 1 人 · 已带天幕和焚火台' },
    // ... 共 6 条队伍数据(与 TEAMS 相同但为组件内部状态)
  ]
  @State showJoin: boolean = false
  @State showCreate: boolean = false
  @State showRule: boolean = false
  @State newTitle: string = ''
  @State newNeed: number = 4

  joinTeam() {
    const t: TeamRow = this.curTeam
    if (t.joined < t.need) {
      const r: TeamRow[] = []
      for (let i = 0; i < this.teamList.length; i++) {
        if (this.teamList[i].id === t.id) {
          r.push({ id: this.teamList[i].id, title: this.teamList[i].title, camp: this.teamList[i].camp, date: this.teamList[i].date, need: this.teamList[i].need, joined: this.teamList[i].joined + 1, master: this.teamList[i].master, color: this.teamList[i].color, note: this.teamList[i].note })
        } else {
          r.push(this.teamList[i])
        }
      }
      this.teamList = r
      const m: TeamRow[] = []
      for (let i = 0; i < this.myTeams.length; i++) {
        m.push(this.myTeams[i])
      }
      m.push(t)
      this.myTeams = m
    }
    this.showJoin = false
  }

  createTeam() {
    if (this.newTitle.length > 0) {
      const m: TeamRow[] = []
      for (let i = 0; i < this.myTeams.length; i++) {
        m.push(this.myTeams[i])
      }
      m.push({ id: this.myTeams.length + 100, title: this.newTitle, camp: '自定义营地', date: '待定', need: this.newNeed, joined: 1, master: '我', color: '#F3E8D9', note: '队长就是我 · 招募中' })
      this.myTeams = m
      const r2: TeamRow[] = []
      r2.push({ id: this.myTeams.length + 100, title: this.newTitle, camp: '自定义营地', date: '待定', need: this.newNeed, joined: 1, master: '我', color: '#F3E8D9', note: '队长就是我 · 招募中' })
      for (let i = 0; i < this.teamList.length; i++) {
        r2.push(this.teamList[i])
      }
      this.teamList = r2
    }
    this.showCreate = false
  }

TeamTab 组件维护了 8 个状态变量,是该应用状态管理最复杂的组件。curTeam 记录当前选中的队伍实例。myTeams 是用户已参与的队伍列表,初始为空数组。teamList 是拼队大厅的队伍列表,初始从 TEAMS 常量复制。newTitle 和 newNeed 是发起拼队弹框中的表单数据。

joinTeam 方法实现入队操作,包含两层数据更新——首先更新 teamList 中目标队伍的 joined 计数(加 1),然后将该队伍添加到 myTeams 数组中记录用户参与历史。入队前通过 t.joined < t.need 条件检查确保队伍未满员。

createTeam 方法实现发起拼队操作,包含表单验证(newTitle.length > 0)和两层数据更新——首先将新队伍添加到 myTeams 数组,然后将新队伍插入到 teamList 数组头部(使用 unshift 等价逻辑:先创建只包含新队伍的数组 r2,再遍历原 teamList 逐个推入 r2)。新队伍的 id 使用 myTeams.length + 100 避免与现有队伍 id 冲突,master 设为"我",color 设为帆布卡其色(#F3E8D9)作为视觉标识。

队伍卡片的成员头像横排是该组件的特色 UI 元素——通过 ForEach 遍历 [0,1,2,3,4,5,6,7] 数组,当 k < joined 时显示"🙂"笑脸头像(森绿色圆形背景),当 joined <= k < need 时显示"?"占位符(灰色虚线边框),当 k >= need 时不渲染。这种设计直观地展示了队伍的成员填充状态。进度条使用 Math.round(t.joined * 100 / t.need) + ‘%’ 计算填充宽度,配合森绿到帆布卡其的渐变色。

5.5 订单组件 ROrderTab

@Component
export struct ROrderTab {
  @State statusIdx: number = 0
  @State curOrder: OrderRowC = ORDERS_C[0]
  @State showRenew: boolean = false
  @State showCode: boolean = false
  @State showCancel: boolean = false
  @State renewDays: number = 1

  renewOrder() {
    this.showRenew = false
  }

ROrderTab 组件维护了 6 个状态变量。statusIdx 记录选中的状态筛选索引,默认为 0(全部)。renewDays 记录续租弹框中选中的租期索引,默认为 1(即 3 天租)。该组件的状态筛选器采用分段器样式——四个选项(全部/使用中/待归还/已归还)通过 ForEach 渲染为水平排列的分段按钮,选中项背景为森绿色、文字白色加粗,下方显示帆布卡其色指示条。筛选逻辑通过复合条件判断实现:statusIdx 为 0 时展示全部,为 1 时展示"使用中"状态,为 2 时展示"待归还"状态,为 3 时展示"已归还"或"押金已退"两种状态。

订单卡片根据状态显示不同的操作按钮——“使用中"订单显示"续租”(描边按钮)和"取货码"(实心按钮),"待归还"订单显示"预约归还"按钮,"已归还/押金已退"订单显示"再来一单"按钮。续租弹框的特色是租期选择器——通过 ForEach 遍历 RENT_TERMS 渲染四个租期选项,每个选项下方显示通过 rentTotal(Math.round(this.curOrder.price / this.curOrder.days), i) 计算的续租金额,即将原订单总租金除以原租赁天数取整得到日租金基数,再传入 rentTotal 函数计算新租期的租金。取货码弹框以 42 像素大字号、8 像素字间距的方式展示"8 6 2 4"四位数字取货码,配合网点地址和营业时间信息。预约归还弹框通过 Flex 弹性换行布局排列 6 个时间段选项,用户选择后点击"提交预约"完成归还预约。

5.6 我的组件 RMineTab

@Component
export struct RMineTab {
  @State showCoupon: boolean = false
  @State showBadge: boolean = false
  @State showService: boolean = false
  @State showDeposit: boolean = false

  build() {
    Scroll() {
      Column() {
        // 渐变头部
        Column({ space: 10 }) {
          Row({ space: 12 }) {
            Column() {
              Text('🧗')
                .fontSize(32)
            }
            .width(58)
            .height(58)
            .borderRadius(29)
            .justifyContent(FlexAlign.Center)
            .backgroundColor('rgba(255,255,255,0.25)')
            .border({ width: 2, color: '#C2A878' })

            Column({ space: 3 }) {
              Text('山系小野')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('Lv.6 荒野玩家 · 露营 32 次')
                .fontSize(10)
                .fontColor('#D8F3DC')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Text('编辑')
              .fontSize(11)
              .fontColor('#E9F5EC')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor('rgba(255,255,255,0.18)')
          }
          .width('100%')

          Row({ space: 0 }) {
            Column({ space: 2 }) {
              Text('32')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('露营次数')
                .fontSize(9)
                .fontColor('#D8F3DC')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column()
              .width(1)
              .height(26)
              .backgroundColor('rgba(255,255,255,0.3)')

            Column({ space: 2 }) {
              Text('9')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('打卡营地')
                .fontSize(9)
                .fontColor('#D8F3DC')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column()
              .width(1)
              .height(26)
              .backgroundColor('rgba(255,255,255,0.3)')

            Column({ space: 2 }) {
              Text('4')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('拼队局数')
                .fontSize(9)
                .fontColor('#D8F3DC')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)

            Column()
              .width(1)
              .height(26)
              .backgroundColor('rgba(255,255,255,0.3)')

            Column({ space: 2 }) {
              Text('660')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('冻结押金')
                .fontSize(9)
                .fontColor('#D8F3DC')
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .onClick(() => {
              this.showDeposit = true
            })
          }
          .width('100%')
          .margin({ top: 4 })
        }
        .width('100%')
        .padding(16)
        .borderRadius({ topLeft: 0, topRight: 0, bottomLeft: 18, bottomRight: 18 })
        .linearGradient({
          angle: 135,
          colors: [['#14342B', 0], ['#2D6A4F', 0.6], ['#52796F', 1]]
        })

RMineTab 组件维护了 4 个弹框显隐状态变量。渐变头部采用 135 度对角渐变背景,从深森绿(#14342B)过渡到标准森绿(#2D6A4F)再到浅森绿(#52796F),仅设置底部两个圆角(18 像素)。头部包含用户头像(🧗 emoji + 帆布卡其色边框圆形背景)、昵称、等级标签和编辑按钮。数据四格区域是头部的重要信息展示区——分别展示露营次数(32)、打卡营地(9)、拼队局数(4)和冻结押金(660 元),每格之间用 1 像素半透明分隔线分隔。第四格"冻结押金"可点击进入押金账户弹框,这是该应用特有的功能入口。

消费月报柱状图区域通过 ForEach 遍历 SPEND_BARS_C 数组渲染 6 个柱子,柱子高度通过 barCH 函数计算。柱子颜色根据金额是否超过 1000 动态选择——超过 1000 使用红色渐变(#B03A2E → #E74C3C),否则使用森绿渐变(#2D6A4F → #52796F),红色柱子用于强调高消费月份。

押金账户弹框是该组件的特色功能——展示冻结中的押金总额(¥660),下方列出每件装备的押金明细和退还规则,如"帐篷 300 元 · 归还验收后 2h 内解冻"“户外电源 360 元 · 待归还后自动退回"等,并提示"芝麻分 650+ 可开通免押租赁”。徽章墙弹框通过 Flex 弹性换行布局排列 6 个露营徽章标签(首次露营、观星达人、焚火料理人、带宠出逃、房车初体验、冬 camp 勇士),每个标签使用不同的颜色主题,底部提示"再露营 3 次解锁’百次老炮’徽章"。客服弹框采用对话气泡样式,左侧为机器人客服(🤖 + 森绿色头像背景),右侧为用户消息(🧗 + 帆布卡其色头像背景),通过不同的 borderRadius 圆角设置区分气泡方向。

六、主入口页面分析

@Entry
@Component
struct CPage {
  @State activeTab: number = 0
  @State showSearch: boolean = false
  @State searchKw: string = ''
  @State showMsg: boolean = false

  build() {
    Column() {
      // 电商风格头部(森绿渐变,无动画)
      Column() {
        Row({ space: 10 }) {
          Text('露营星野社')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')

          Row({ space: 6 }) {
            Text('🔍')
              .fontSize(13)
            Text('搜帐篷 / 营地 / 拼队')
              .fontSize(11)
              .fontColor('#D8F3DC')
              .layoutWeight(1)
          }
          .layoutWeight(1)
          .height(32)
          .padding({ left: 12, right: 12 })
          .borderRadius(16)
          .backgroundColor('rgba(255,255,255,0.22)')
          .onClick(() => {
            this.showSearch = true
          })

          Text('🔔')
            .fontSize(17)
            .onClick(() => {
              this.showMsg = true
            })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10, bottom: 10 })

        // 频道 pill 横滑
        Scroll() {
          Row({ space: 8 }) {
            ForEach(MAIN_TABS_C, (t: TabMetaC, i: number) => {
              Text(t.name)
                .fontSize(11)
                .fontWeight(this.activeTab === i ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.activeTab === i ? '#14342B' : '#D8F3DC')
                .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                .borderRadius(13)
                .backgroundColor(this.activeTab === i ? '#C2A878' : 'rgba(255,255,255,0.14)')
                .onClick(() => {
                  this.activeTab = i
                })
            }, (t: TabMetaC) => t.name)
          }
          .padding({ left: 14, right: 14 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')
        .margin({ bottom: 8 })
      }
      .width('100%')
      .linearGradient({
        angle: 120,
        colors: [['#14342B', 0], ['#2D6A4F', 0.7], ['#52796F', 1]]
      })

      // tab 内容
      Column() {
        if (this.activeTab === 0) {
          RentalTab()
        } else if (this.activeTab === 1) {
          ShopTab()
        } else if (this.activeTab === 2) {
          CampTab()
        } else if (this.activeTab === 3) {
          TeamTab()
        } else if (this.activeTab === 4) {
          ROrderTab()
        } else {
          RMineTab()
        }
      }
      .layoutWeight(1)
      .width('100%')

      Column()
        .height(1)
        .width('100%')
        .backgroundColor('#DCE5DD')

      // 底部导航(6 tab,一排)
      Row({ space: 0 }) {
        ForEach(MAIN_TABS_C, (t: TabMetaC, i: number) => {
          Column({ space: 3 }) {
            Text(t.icon)
              .fontSize(16)
            Text(t.name)
              .fontSize(9)
              .fontWeight(this.activeTab === i ? FontWeight.Bold : FontWeight.Normal)
              .fontColor(this.activeTab === i ? '#2D6A4F' : '#9AA8A0')
            if (this.activeTab === i) {
              Column()
                .width(14)
                .height(3)
                .borderRadius(2)
                .backgroundColor('#C2A878')
            } else {
              Column()
                .width(14)
                .height(3)
                .borderRadius(2)
                .backgroundColor('rgba(0,0,0,0)')
            }
          }
          .layoutWeight(1)
          .padding({ top: 7, bottom: 7 })
          .alignItems(HorizontalAlign.Center)
          .onClick(() => {
            this.activeTab = i
          })
        }, (t: TabMetaC) => t.name)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')

CPage 是整个应用的 @Entry 主入口组件,维护了 4 个状态变量。页面结构与周边游特卖应用高度一致,但主题色和细节处理有显著差异。头部渐变区域使用 120 度线性渐变,从深森绿(#14342B)过渡到标准森绿(#2D6A4F)再到浅森绿(#52796F),营造出沉稳的户外大地色氛围。搜索框内的提示文字颜色为浅绿(#D8F3DC),而非周边游应用的浅蓝色。

顶部频道 pill 的选中状态使用帆布卡其色(#C2A878)作为背景——这是与周边游应用白色选中背景的显著区别,帆布卡其色与森绿头部形成温暖的大地色调对比。底部导航栏的选中 Tab 文字颜色为森绿(#2D6A4F),指示条颜色为帆布卡其色(#C2A878),与头部频道 pill 的色彩方案保持一致,形成了统一的双色导航视觉语言。

搜索弹框包含 TextInput 输入框和热门搜索标签墙,标签以"隧道帐篷"“观星营地”“户外电源”“焚火台”“亲子营地”"周末拼队"等露营相关关键词为主,每个标签使用不同的浅色背景。消息通知弹框以列表样式展示租赁提醒、拼队通知和营地优惠三类消息,每条消息包含圆形图标背景、标题、内容和时间戳。

七、流程图

7.1 应用架构流程图

activeTab=0

activeTab=1

activeTab=2

activeTab=3

activeTab=4

activeTab=5

onClick

CPage 主入口

头部森绿渐变区域

顶部频道 Pill 横滑

搜索弹框 showSearch

消息弹框 showMsg

activeTab 条件渲染

RentalTab 租装备

ShopTab 装备购

CampTab 营地

TeamTab 拼队

ROrderTab 订单

RMineTab 我的

租期切换条 termIdx

双列租赁卡片网格

租赁确认抽屉 showRentDrawer

装备详情弹框 showRentDetail

租赁车弹框 showCart

底部浮动条

秒杀 Banner

分类横滑

单列商品卡片

秒杀规则弹框 showFlash

购买确认弹框 showBuy

营地大 Banner

营地评分圆徽章列表

营地详情弹框 showCampDetail

营地预约弹框 showBook

设施筛选弹框 showFacility

拼队概览

队伍卡片+头像横排

入队弹框 showJoin

发起拼队弹框 showCreate

拼队规则弹框 showRule

状态分段器

订单卡片列表

续租弹框 showRenew

取货码弹框 showCode

预约归还弹框 showCancel

渐变头部四格

消费月报柱状图

券包横滑

功能宫格 8 项

押金账户弹框 showDeposit

徽章墙弹框 showBadge

客服弹框 showService

底部导航 6 Tab

7.2 弹框交互流程图

渲染错误: Mermaid 渲染失败: Parse error on line 28: ...->|清空| Y[cartRent = []] W -->|提交租赁| -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'SQS'

八、技术对比表格

技术维度 租装备 Tab 装备购 Tab 营地 Tab 拼队 Tab 订单 Tab 我的 Tab
核心交互 租期切换+定价计算 闪购进度条+拼单 日期选择+评分徽章 头像横排+进度条 分段器+续租 四格数据+宫格
弹框数量 3 个 2 个 3 个 3 个 3 个 3 个
弹框样式 抽屉+居中卡 居中圆角卡 居中卡+网格 边框卡+表单 居中卡+大字码 面板+标签墙
CRUD 操作 增删+合计 收藏切换 预订新增 入队+发起 续租+预约
定价逻辑 rentTotal 三段定价 rentTotal 续租计算
押金系统 有 deposit 字段 有 deposit 展示 有押金账户
列表布局 Flex 双列换行 单列横向卡 单列大卡 单列卡+头像排 单列卡 Flex 宫格
渐变色使用 卡片 g.color→白 进度条森绿→卡其 头部三色渐变 进度条森绿→卡其 头部三色渐变
状态变量数量 6 个 5 个 6 个 8 个 6 个 4 个
特色组件 租期切换条+租赁车 闪购进度条 评分圆徽章 成员头像横排 取货码大字号 押金账户+徽章墙

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 主题:森绿 #2D6A4F × 帆布卡其 #C2A878,底色 #F4F7F1,户外大地色风
// 6 个底部 Tab:租装备 / 装备购 / 营地 / 拼队 / 订单 / 我的

interface RentGoods {
  id: number
  name: string
  brand: string
  dayPrice: number
  deposit: number
  stock: number
  tag: string
  score: string
  icon: string
  color: string
  spec: string
}

interface ShopGoods {
  id: number
  name: string
  price: number
  originPrice: number
  sold: number
  icon: string
  color: string
  label: string
  flash: number
}

interface CampRow {
  id: number
  name: string
  place: string
  score: string
  price: number
  tag: string
  icon: string
  color: string
  dist: string
  sites: number
  facilities: string
}

interface TeamRow {
  id: number
  title: string
  camp: string
  date: string
  need: number
  joined: number
  master: string
  color: string
  note: string
}

interface OrderRowC {
  id: number
  name: string
  spec: string
  dates: string
  price: number
  status: string
  days: number
  code: string
}

interface CouponC {
  id: number
  title: string
  amount: string
  cond: string
  expire: string
  kind: string
}

interface MineMenuC {
  id: number
  icon: string
  name: string
  desc: string
  color: string
}

interface BarC {
  month: string
  amount: number
}

interface TabMetaC {
  name: string
  icon: string
}

interface DateChipC {
  id: number
  label: string
  sub: string
  full: boolean
}

const RENT_TERMS: string[] = ['1 天', '3 天', '7 天', '月租']

const RENT_DAYS: number[] = [1, 3, 7, 30]

const RENT_GOODS: RentGoods[] = [
  { id: 1, name: '隧道帐篷 · 3-4 人款', brand: '牧高笛冷山', dayPrice: 45, deposit: 300, stock: 12, tag: '人气王', score: '4.9', icon: '⛺', color: '#D8F3DC', spec: '防晒银涂层 · 双门厅' },
  { id: 2, name: '天幕 · 4x5m 涂银', brand: '挪客屋檐', dayPrice: 30, deposit: 200, stock: 20, tag: '遮阳神器', score: '4.8', icon: '🏖️', color: '#FFF3D6', spec: 'UPF50+ · 抗风 6 级' },
  { id: 3, name: '露营推车 · 折叠大容量', brand: '北欢大容量', dayPrice: 25, deposit: 260, stock: 15, tag: '省力必租', score: '4.7', icon: '🛒', color: '#E3F0E8', spec: '120L · 宽轮越野' },
  { id: 4, name: '焚火台 · 不锈钢双炉', brand: '火枫双星', dayPrice: 35, deposit: 180, stock: 9, tag: '围炉煮茶', score: '4.9', icon: '🔥', color: '#FFE3D3', spec: '带烤网 · 灰烬盘' },
  { id: 5, name: '户外电源 · 1 度电', brand: '电小二1000', dayPrice: 68, deposit: 800, stock: 6, tag: '续航担当', score: '4.8', icon: '🔋', color: '#DCEBFF', spec: '220V · 带 PD 快充' },
  { id: 6, name: '蛋卷桌 + 月亮椅套装', brand: '京东京造', dayPrice: 28, deposit: 150, stock: 22, tag: '套餐划算', score: '4.6', icon: '🪑', color: '#F3E8D9', spec: '一桌四椅 · 免安装' },
  { id: 7, name: '卡式炉 + 烧水壶', brand: '岩谷便携', dayPrice: 20, deposit: 120, stock: 18, tag: '煮咖自由', score: '4.7', icon: '🍳', color: '#E8F5E9', spec: '防风 · 气罐 2 罐' },
  { id: 8, name: '星空投影灯串 20 米', brand: '山力士氛围', dayPrice: 15, deposit: 90, stock: 25, tag: '氛围拉满', score: '4.5', icon: '💡', color: '#EFE6FF', spec: '暖光 · 遥控调档' },
  { id: 9, name: '充气床垫 · 双人加厚', brand: 'Therm-A', dayPrice: 32, deposit: 200, stock: 11, tag: '睡眠救星', score: '4.8', icon: '🛏️', color: '#FFF0F0', spec: '自充气 · R 值 4.1' },
  { id: 10, name: '便携咖啡手冲套装', brand: 'Hario 旅人', dayPrice: 22, deposit: 160, stock: 8, tag: '小众精品', score: '4.9', icon: '☕', color: '#EDE0D4', spec: '磨豆机 · 滤杯分享壶' }
]

const SHOP_GOODS: ShopGoods[] = [
  { id: 1, name: '冷山 3 Air 帐篷 全新', price: 399, originPrice: 659, sold: 2300, icon: '⛺', color: '#D8F3DC', label: '返场清仓', flash: 76 },
  { id: 2, name: '涂银天幕 5x6 加大版', price: 179, originPrice: 299, sold: 5600, icon: '🏖️', color: '#FFF3D6', label: '爆款', flash: 92 },
  { id: 3, name: '户外折叠克米特椅', price: 89, originPrice: 149, sold: 8900, icon: '🪑', color: '#F3E8D9', label: '带杯架', flash: 64 },
  { id: 4, name: '钛合金焚火台轻量款', price: 268, originPrice: 399, sold: 1200, icon: '🔥', color: '#FFE3D3', label: '早鸟价', flash: 45 },
  { id: 5, name: '露营拖挂保温箱 40L', price: 349, originPrice: 529, sold: 780, icon: '🧊', color: '#DCEBFF', label: '车载必备', flash: 58 },
  { id: 6, name: '氛围灯串太阳能款', price: 49, originPrice: 99, sold: 12000, icon: '💡', color: '#EFE6FF', label: '9.9 券后', flash: 97 },
  { id: 7, name: '户外电源 600W 便携', price: 1299, originPrice: 1799, sold: 640, icon: '🔋', color: '#E8F5E9', label: '分期免息', flash: 38 },
  { id: 8, name: '铸铁珐琅炖锅 22cm', price: 199, originPrice: 359, sold: 2100, icon: '🍲', color: '#EDE0D4', label: '围炉神器', flash: 71 }
]

const CAMPS: CampRow[] = [
  { id: 1, name: '云顶星野营地', place: '杭州 · 临安', score: '4.9', price: 128, tag: '观星营地', icon: '🌌', color: '#E8E4FF', dist: '距市区 68km', sites: 42, facilities: '淋浴 · 电 · 观星台' },
  { id: 2, name: '松林湖畔露营地', place: '湖州 · 安吉', score: '4.8', price: 98, tag: '湖景位', icon: '🌲', color: '#D8F3DC', dist: '距市区 52km', sites: 60, facilities: '淋浴 · 皮划艇 · 篝火' },
  { id: 3, name: '风车山丘营地', place: '台州 · 温岭', score: '4.7', price: 88, tag: '日出位', icon: '🌄', color: '#FFE8D6', dist: '距市区 45km', sites: 35, facilities: '电 · 咖啡车 · 卫浴' },
  { id: 4, name: '溪谷亲子营地', place: '衢州 · 开化', score: '4.8', price: 118, tag: '亲子友好', icon: '👨‍👩‍👧', color: '#E3F0E8', dist: '距市区 73km', sites: 48, facilities: '泳池 · 捞鱼 · 滑梯' },
  { id: 5, name: '竹林秘境营地', place: '莫干山 · 德清', score: '4.9', price: 158, tag: '网红打卡', icon: '🎋', color: '#E8F5E9', dist: '距市区 40km', sites: 26, facilities: '私汤 · 下午茶 · 篝火' },
  { id: 6, name: '海岸线房车营地', place: '宁波 · 象山', score: '4.6', price: 198, tag: '海景房车', icon: '🚐', color: '#DCEBFF', dist: '距市区 95km', sites: 30, facilities: '房车桩 · 海钓 · 烧烤' },
  { id: 7, name: '草原星空牧场', place: '金华 · 武义', score: '4.7', price: 108, tag: '可带宠物', icon: '🐑', color: '#FFF3D6', dist: '距市区 60km', sites: 55, facilities: '牧草区 · 萌宠 · 淋浴' },
  { id: 8, name: '古树茶田营地', place: '绍兴 · 新昌', score: '4.8', price: 138, tag: '茶山云海', icon: '🍃', color: '#EDE0D4', dist: '距市区 50km', sites: 38, facilities: '茶室 · 电 · 早餐' }
]

const TEAMS: TeamRow[] = [
  { id: 1, title: '周六云顶观星 4 人局', camp: '云顶星野营地', date: '08/29 周六', need: 4, joined: 3, master: '山系阿正', color: '#E8E4FF', note: '缺 1 人 · 已带天幕和焚火台' },
  { id: 2, title: '安吉松林亲子捞鱼团', camp: '松林湖畔露营地', date: '08/30 周日', need: 6, joined: 4, master: '团子妈妈', color: '#D8F3DC', note: '缺 2 人 · 娃 3 枚求同龄' },
  { id: 3, title: '温岭风车山追日出行', camp: '风车山丘营地', date: '08/29 周六', need: 5, joined: 5, master: '早起困难户', color: '#FFE8D6', note: '已满员 · 等候补' },
  { id: 4, title: '莫干山竹林下午茶局', camp: '竹林秘境营地', date: '08/31 周一', need: 3, joined: 1, master: '胶片小鹿', color: '#E8F5E9', note: '缺 2 人 · 摄影搭子优先' },
  { id: 5, title: '象山海岸房车海钓团', camp: '海岸线房车营地', date: '09/05 周六', need: 4, joined: 2, master: '老船长Ken', color: '#DCEBFF', note: '缺 2 人 · 有渔具 3 套' },
  { id: 6, title: '武义牧场萌宠野餐会', camp: '草原星空牧场', date: '08/30 周日', need: 8, joined: 5, master: '柴犬旺财', color: '#FFF3D6', note: '缺 3 人 · 可带毛孩子' }
]

const ORDERS_C: OrderRowC[] = [
  { id: 1, name: '隧道帐篷 · 3-4 人款', spec: '1 天', dates: '08/24 - 08/25', price: 45, status: '使用中', days: 1, code: 'CMP20260824001' },
  { id: 2, name: '户外电源 · 1 度电', spec: '3 天', dates: '08/23 - 08/26', price: 204, status: '使用中', days: 3, code: 'CMP20260823002' },
  { id: 3, name: '焚火台 · 不锈钢双炉', spec: '7 天', dates: '08/18 - 08/25', price: 245, status: '待归还', days: 7, code: 'CMP20260818003' },
  { id: 4, name: '卡式炉 + 烧水壶', spec: '3 天', dates: '08/15 - 08/18', price: 60, status: '已归还', days: 3, code: 'CMP20260815004' },
  { id: 5, name: '充气床垫 · 双人加厚', spec: '1 天', dates: '08/12 - 08/13', price: 32, status: '已归还', days: 1, code: 'CMP20260812005' },
  { id: 6, name: '便携咖啡手冲套装', spec: '月租', dates: '07/20 - 08/20', price: 528, status: '押金已退', days: 30, code: 'CMP20260720006' },
  { id: 7, name: '天幕 · 4x5m 涂银', spec: '3 天', dates: '08/08 - 08/11', price: 90, status: '已归还', days: 3, code: 'CMP20260808007' },
  { id: 8, name: '蛋卷桌 + 月亮椅套装', spec: '7 天', dates: '07/28 - 08/04', price: 196, status: '押金已退', days: 7, code: 'CMP20260728008' }
]

const COUPONS_C: CouponC[] = [
  { id: 1, title: '租赁新客券', amount: '30', cond: '满 100 可用', expire: '08/31 前', kind: 'rent' },
  { id: 2, title: '周末租期券', amount: '20', cond: '满 60 可用', expire: '09/15 前', kind: 'rent' },
  { id: 3, title: '装备购满减', amount: '50', cond: '满 300 可用', expire: '08/31 前', kind: 'shop' },
  { id: 4, title: '营地预订券', amount: '40', cond: '满 120 可用', expire: '09/30 前', kind: 'camp' },
  { id: 5, title: '拼队免押券', amount: '免押', cond: '芝麻分 650+', expire: '长期', kind: 'team' },
  { id: 6, title: '老友回归券', amount: '15', cond: '无门槛', expire: '09/10 前', kind: 'all' }
]

const MINE_MENUS_C: MineMenuC[] = [
  { id: 1, icon: '⛺', name: '我的租赁', desc: '2 件使用中', color: '#D8F3DC' },
  { id: 2, icon: '🎟️', name: '营地订单', desc: '3 个营地待出行', color: '#E8E4FF' },
  { id: 3, icon: '💰', name: '押金账户', desc: '冻结 660 元', color: '#FFF3D6' },
  { id: 4, icon: '⭐', name: '装备收藏', desc: '收藏 12 件', color: '#FFE8D6' },
  { id: 5, icon: '👥', name: '我的队伍', desc: '参与 4 局', color: '#DCEBFF' },
  { id: 6, icon: '📍', name: '足迹营地', desc: '打卡 9 个营地', color: '#E8F5E9' },
  { id: 7, icon: '🛠️', name: '归还网点', desc: '附近 3 个网点', color: '#EDE0D4' },
  { id: 8, icon: '🎧', name: '在线客服', desc: '24h 在线', color: '#EFE6FF' }
]

const SPEND_BARS_C: BarC[] = [
  { month: '3月', amount: 320 },
  { month: '4月', amount: 560 },
  { month: '5月', amount: 480 },
  { month: '6月', amount: 890 },
  { month: '7月', amount: 1260 },
  { month: '8月', amount: 1580 }
]

const DATE_CHIPS_C: DateChipC[] = [
  { id: 1, label: '今天', sub: '08/24', full: false },
  { id: 2, label: '明天', sub: '08/25', full: false },
  { id: 3, label: '周六', sub: '08/29', full: false },
  { id: 4, label: '周日', sub: '08/30', full: true },
  { id: 5, label: '周一', sub: '08/31', full: false },
  { id: 6, label: '周六', sub: '09/05', full: false }
]

const MAIN_TABS_C: TabMetaC[] = [
  { name: '租装备', icon: '⛺' },
  { name: '装备购', icon: '🛍️' },
  { name: '营地', icon: '🌲' },
  { name: '拼队', icon: '👥' },
  { name: '订单', icon: '📋' },
  { name: '我的', icon: '👤' }
]

function rentTotal(base: number, idx: number): number {
  const d: number = RENT_DAYS[idx]
  if (d >= 30) {
    return base * 24
  }
  if (d >= 7) {
    return base * d * 8 / 10
  }
  return base * d
}

function termName(idx: number): string {
  return RENT_TERMS[idx]
}

function barCH(v: number): number {
  return Math.round(v / 16)
}

function teamLeft(need: number, joined: number): number {
  if (need - joined > 0) {
    return need - joined
  }
  return 0
}

@Builder
function modalOverlay(onClose: () => void) {
  Column()
    .width('100%')
    .height('100%')
    .backgroundColor('rgba(20,52,43,0.55)')
    .onClick(() => {
      onClose()
    })
}

// ==================== Tab 1 租装备 ====================
@Component
export struct RentalTab {
  @State termIdx: number = 0
  @State curRent: RentGoods = RENT_GOODS[0]
  @State cartRent: RentGoods[] = []
  @State showRentDrawer: boolean = false
  @State showRentDetail: boolean = false
  @State showCart: boolean = false

  addCart() {
    const g: RentGoods = this.curRent
    const r: RentGoods[] = []
    for (let i = 0; i < this.cartRent.length; i++) {
      r.push(this.cartRent[i])
    }
    r.push(g)
    this.cartRent = r
    this.showRentDrawer = false
  }

  removeCart(id: number) {
    const r: RentGoods[] = []
    for (let i = 0; i < this.cartRent.length; i++) {
      if (this.cartRent[i].id !== id) {
        r.push(this.cartRent[i])
      }
    }
    this.cartRent = r
  }

  cartTotal(): number {
    let s: number = 0
    for (let i = 0; i < this.cartRent.length; i++) {
      s = s + rentTotal(this.cartRent[i].dayPrice, this.termIdx)
    }
    return s
  }

  cartDeposit(): number {
    let s: number = 0
    for (let i = 0; i < this.cartRent.length; i++) {
      s = s + this.cartRent[i].deposit
    }
    return s
  }

  build() {
    Scroll() {
      Column() {
        // 顶部租期切换条
        Row({ space: 0 }) {
          ForEach(RENT_TERMS, (t: string, i: number) => {
            Column({ space: 4 }) {
              Text(t)
                .fontSize(13)
                .fontWeight(this.termIdx === i ? FontWeight.Bold : FontWeight.Normal)
                .fontColor(this.termIdx === i ? '#FFFFFF' : '#52796F')
              if (this.termIdx === i) {
                Column()
                  .width(18)
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor('#C2A878')
              } else {
                Column()
                  .width(18)
                  .height(3)
                  .borderRadius(2)
                  .backgroundColor('rgba(0,0,0,0)')
              }
            }
            .layoutWeight(1)
            .padding({ top: 10, bottom: 8 })
            .alignItems(HorizontalAlign.Center)
            .backgroundColor(this.termIdx === i ? '#2D6A4F' : '#FFFFFF')
            .borderRadius(10)
            .margin({ left: i === 0 ? 0 : 4, right: i === RENT_TERMS.length - 1 ? 0 : 4 })
            .onClick(() => {
              this.termIdx = i
            })
          }, (t: string) => t)
        }
        .width('100%')

        Row({ space: 6 }) {
          Text('🏕️ 租期越长越划算 · 月租低至 8 折')
            .fontSize(11)
            .fontColor('#2D6A4F')
            .layoutWeight(1)
          Text('免押金 · 芝麻分 650+')
            .fontSize(10)
            .fontColor('#8A6D3B')
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .borderRadius(8)
            .backgroundColor('#F3E8D9')
        }
        .width('100%')
        .margin({ top: 10, bottom: 6 })

        // 双列租赁卡片
        Flex({ wrap: FlexWrap.Wrap, direction: FlexDirection.Row, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(RENT_GOODS, (g: RentGoods) => {
            Column() {
              Column() {
                Text(g.icon)
                  .fontSize(38)
                Text(g.tag)
                  .fontSize(9)
                  .fontColor('#8A6D3B')
                  .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                  .borderRadius(8)
                  .backgroundColor('rgba(255,255,255,0.85)')
                  .margin({ top: 6 })
              }
              .width('100%')
              .height(92)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .linearGradient({
                angle: 135,
                colors: [[g.color, 0], ['#FFFFFF', 1]]
              })

              Column({ space: 5 }) {
                Text(g.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .width('100%')

                Text(g.brand + ' · ' + g.spec)
                  .fontSize(10)
                  .fontColor('#7A8B7F')
                  .maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                  .width('100%')

                Row({ space: 4 }) {
                  Text('★ ' + g.score)
                    .fontSize(10)
                    .fontColor('#B7791F')
                  Text('库存 ' + g.stock)
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                }
                .width('100%')

                Row() {
                  Text('¥')
                    .fontSize(11)
                    .fontColor('#B03A2E')
                    .fontWeight(FontWeight.Bold)
                  Text(g.dayPrice.toString())
                    .fontSize(18)
                    .fontColor('#B03A2E')
                    .fontWeight(FontWeight.Bold)
                  Text('/天')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                }
                .width('100%')
                .alignItems(VerticalAlign.Bottom)

                Row() {
                  Button() {
                    Text('详情')
                      .fontSize(11)
                      .fontColor('#2D6A4F')
                  }
                  .height(28)
                  .padding({ left: 12, right: 12 })
                  .borderRadius(14)
                  .backgroundColor('#FFFFFF')
                  .border({ width: 1, color: '#2D6A4F' })
                  .onClick(() => {
                    this.curRent = g
                    this.showRentDetail = true
                  })

                  Button() {
                    Text('去租赁')
                      .fontSize(11)
                      .fontColor('#FFFFFF')
                  }
                  .height(28)
                  .padding({ left: 14, right: 14 })
                  .borderRadius(14)
                  .backgroundColor('#2D6A4F')
                  .margin({ left: 8 })
                  .onClick(() => {
                    this.curRent = g
                    this.showRentDrawer = true
                  })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                .margin({ top: 2 })
              }
              .padding(10)
              .alignItems(HorizontalAlign.Start)
            }
            .width('48.5%')
            .margin({ bottom: 10 })
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .shadow({ radius: 8, color: 'rgba(45,106,79,0.10)', offsetX: 0, offsetY: 3 })
            .onClick(() => {
              this.curRent = g
              this.showRentDetail = true
            })
          }, (g: RentGoods) => g.id.toString())
        }
        .width('100%')

        // 底部租赁车浮动条
        if (this.cartRent.length > 0) {
          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('已选 ' + this.cartRent.length + ' 件')
                .fontSize(12)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1B4332')
              Text('押金 ¥' + this.cartDeposit() + ' · 归还即退')
                .fontSize(10)
                .fontColor('#7A8B7F')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Button() {
              Text('查看租赁车 ¥' + this.cartTotal())
                .fontSize(12)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
            }
            .height(38)
            .padding({ left: 18, right: 18 })
            .borderRadius(19)
            .linearGradient({
              angle: 90,
              colors: [['#2D6A4F', 0], ['#52796F', 1]]
            })
            .onClick(() => {
              this.showCart = true
            })
          }
          .width('100%')
          .padding(12)
          .borderRadius(14)
          .backgroundColor('#FFFFFF')
          .shadow({ radius: 12, color: 'rgba(27,67,50,0.18)', offsetX: 0, offsetY: 4 })
        }
        // 租赁确认抽屉弹框
        if (this.showRentDrawer) {
          modalOverlay(() => {
            this.showRentDrawer = false
          })
          Column({ space: 14 }) {
            Row() {
              Column()
                .width(36)
                .height(4)
                .borderRadius(2)
                .backgroundColor('#D5DBD6')
            }
            .width('100%')
            .justifyContent(FlexAlign.Center)

            Text('确认租赁 · ' + this.curRent.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1B4332')
              .width('100%')

            Row({ space: 10 }) {
              Column() {
                Text(this.curRent.icon)
                  .fontSize(34)
              }
              .width(64)
              .height(64)
              .borderRadius(12)
              .backgroundColor(this.curRent.color)
              .justifyContent(FlexAlign.Center)

              Column({ space: 3 }) {
                Text(this.curRent.brand)
                  .fontSize(11)
                  .fontColor('#7A8B7F')
                Text(this.curRent.spec)
                  .fontSize(11)
                  .fontColor('#7A8B7F')
                Text('押金 ¥' + this.curRent.deposit + '(归还秒退)')
                  .fontSize(11)
                  .fontColor('#8A6D3B')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 0 }) {
              ForEach(RENT_TERMS, (t: string, i: number) => {
                Text(t)
                  .fontSize(12)
                  .fontColor(this.termIdx === i ? '#FFFFFF' : '#52796F')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(10)
                  .backgroundColor(this.termIdx === i ? '#2D6A4F' : '#F0F4EF')
                  .margin({ left: i === 0 ? 0 : 6 })
                  .onClick(() => {
                    this.termIdx = i
                  })
              }, (t: string) => t)
            }
            .width('100%')

            Column({ space: 8 }) {
              Row() {
                Text('租金(' + termName(this.termIdx) + ')')
                  .fontSize(12)
                  .fontColor('#7A8B7F')
                Text('¥' + rentTotal(this.curRent.dayPrice, this.termIdx))
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#B03A2E')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
              }
              .width('100%')

              Row() {
                Text('押金(冻结)')
                  .fontSize(12)
                  .fontColor('#7A8B7F')
                Text('¥' + this.curRent.deposit)
                  .fontSize(12)
                  .fontColor('#1B4332')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
              }
              .width('100%')

              Row() {
                Text('取货网点')
                  .fontSize(12)
                  .fontColor('#7A8B7F')
                Text('星野社 · 西湖文化广场店')
                  .fontSize(12)
                  .fontColor('#1B4332')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#F0F4EF')

            Row({ space: 10 }) {
              Button() {
                Text('再看看')
                  .fontSize(13)
                  .fontColor('#52796F')
              }
              .height(40)
              .layoutWeight(1)
              .borderRadius(20)
              .backgroundColor('#FFFFFF')
              .border({ width: 1, color: '#B7C9BD' })
              .onClick(() => {
                this.showRentDrawer = false
              })

              Button() {
                Text('加入租赁车')
                  .fontSize(13)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
              }
              .height(40)
              .layoutWeight(1)
              .borderRadius(20)
              .backgroundColor('#2D6A4F')
              .onClick(() => {
                this.addCart()
              })
            }
            .width('100%')
          }
          .width('92%')
          .constraintSize({ maxHeight: '80%' })
          .padding(18)
          .borderRadius({ topLeft: 22, topRight: 22, bottomLeft: 22, bottomRight: 22 })
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Start)
        }
        // 装备详情弹框
        if (this.showRentDetail) {
          modalOverlay(() => {
            this.showRentDetail = false
          })
          Column({ space: 12 }) {
            Column() {
              Text(this.curRent.icon)
                .fontSize(52)
            }
            .width('100%')
            .height(120)
            .justifyContent(FlexAlign.Center)
            .linearGradient({
              angle: 135,
              colors: [[this.curRent.color, 0], ['#FFFFFF', 1]]
            })
            .borderRadius({ topLeft: 16, topRight: 16, bottomLeft: 0, bottomRight: 0 })

            Scroll() {
              Column({ space: 10 }) {
                Text(this.curRent.name)
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .width('100%')

                Row({ space: 8 }) {
                  Text(this.curRent.tag)
                    .fontSize(10)
                    .fontColor('#2D6A4F')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(8)
                    .backgroundColor('#D8F3DC')
                  Text('★ ' + this.curRent.score + ' 分')
                    .fontSize(10)
                    .fontColor('#B7791F')
                  Text('已租 ' + (this.curRent.stock * 3).toString() + ' 次')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                }
                .width('100%')

                Column({ space: 6 }) {
                  Row() {
                    Text('品牌')
                      .fontSize(11)
                      .fontColor('#7A8B7F')
                      .width(64)
                    Text(this.curRent.brand)
                      .fontSize(11)
                      .fontColor('#1B4332')
                  }
                  .width('100%')

                  Row() {
                    Text('规格')
                      .fontSize(11)
                      .fontColor('#7A8B7F')
                      .width(64)
                    Text(this.curRent.spec)
                      .fontSize(11)
                      .fontColor('#1B4332')
                  }
                  .width('100%')

                  Row() {
                    Text('日租金')
                      .fontSize(11)
                      .fontColor('#7A8B7F')
                      .width(64)
                    Text('¥' + this.curRent.dayPrice + ' / 天,周租 8 折')
                      .fontSize(11)
                      .fontColor('#1B4332')
                  }
                  .width('100%')

                  Row() {
                    Text('押金')
                      .fontSize(11)
                      .fontColor('#7A8B7F')
                      .width(64)
                    Text('¥' + this.curRent.deposit + ' · 支持免押')
                      .fontSize(11)
                      .fontColor('#1B4332')
                  }
                  .width('100%')

                  Row() {
                    Text('库存')
                      .fontSize(11)
                      .fontColor('#7A8B7F')
                      .width(64)
                    Text(this.curRent.stock + ' 件可租')
                      .fontSize(11)
                      .fontColor('#1B4332')
                  }
                  .width('100%')
                }
                .width('100%')
                .padding(12)
                .borderRadius(12)
                .backgroundColor('#F0F4EF')

                Text('租赁须知')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .width('100%')

                Column({ space: 5 }) {
                  Text('· 取还货时间 09:00 - 21:00,逾期按日加收 50%')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                  Text('· 人为损坏按配件价 7 折赔付,自然损耗免赔')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                  Text('· 归还时请清理泥沙,严重污渍收取 30 元清洁费')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                }
                .alignItems(HorizontalAlign.Start)
                .width('100%')

                Row({ space: 10 }) {
                  Button() {
                    Text('关闭')
                      .fontSize(13)
                      .fontColor('#52796F')
                  }
                  .height(38)
                  .layoutWeight(1)
                  .borderRadius(19)
                  .backgroundColor('#F0F4EF')
                  .onClick(() => {
                    this.showRentDetail = false
                  })

                  Button() {
                    Text('¥' + this.curRent.dayPrice + '/天 去租')
                      .fontSize(13)
                      .fontColor('#FFFFFF')
                      .fontWeight(FontWeight.Bold)
                  }
                  .height(38)
                  .layoutWeight(1)
                  .borderRadius(19)
                  .backgroundColor('#2D6A4F')
                  .onClick(() => {
                    this.showRentDetail = false
                    this.showRentDrawer = true
                  })
                }
                .width('100%')
                .margin({ top: 4 })
              }
              .width('100%')
            }
            .constraintSize({ maxHeight: '46%' })
            .padding({ left: 16, right: 16 })

          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Start)
        }
        // 租赁车弹框
        if (this.showCart) {
          modalOverlay(() => {
            this.showCart = false
          })
          Column({ space: 12 }) {
            Row() {
              Text('租赁车 · ' + this.cartRent.length + ' 件')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1B4332')
                .layoutWeight(1)
              Text('清空')
                .fontSize(12)
                .fontColor('#B03A2E')
                .onClick(() => {
                  this.cartRent = []
                })
            }
            .width('100%')

            Scroll() {
              Column({ space: 10 }) {
                ForEach(this.cartRent, (g: RentGoods) => {
                  Row({ space: 10 }) {
                    Column() {
                      Text(g.icon)
                        .fontSize(26)
                    }
                    .width(46)
                    .height(46)
                    .borderRadius(10)
                    .backgroundColor(g.color)
                    .justifyContent(FlexAlign.Center)

                    Column({ space: 2 }) {
                      Text(g.name)
                        .fontSize(12)
                        .fontColor('#1B4332')
                        .maxLines(1)
                      Text('押金 ¥' + g.deposit + ' · ' + termName(this.termIdx) + ' ¥' + rentTotal(g.dayPrice, this.termIdx))
                        .fontSize(10)
                        .fontColor('#7A8B7F')
                    }
                    .alignItems(HorizontalAlign.Start)
                    .layoutWeight(1)

                    Text('移除')
                      .fontSize(11)
                      .fontColor('#B03A2E')
                      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                      .borderRadius(8)
                      .backgroundColor('#FDEDEC')
                      .onClick(() => {
                        this.removeCart(g.id)
                      })
                  }
                  .width('100%')
                  .padding(10)
                  .borderRadius(12)
                  .backgroundColor('#F7FAF6')
                }, (g: RentGoods) => g.id.toString())
              }
              .width('100%')
            }
            .constraintSize({ maxHeight: '42%' })

            Row() {
              Column({ space: 2 }) {
                Text('租金合计 ¥' + this.cartTotal() + ' + 押金 ¥' + this.cartDeposit())
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#B03A2E')
                Text('取还网点:西湖文化广场店')
                  .fontSize(10)
                  .fontColor('#7A8B7F')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Button() {
                Text('提交租赁')
                  .fontSize(13)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
              }
              .height(38)
              .padding({ left: 20, right: 20 })
              .borderRadius(19)
              .backgroundColor('#2D6A4F')
              .onClick(() => {
                this.cartRent = []
                this.showCart = false
              })
            }
            .width('100%')
          }
          .width('92%')
          .constraintSize({ maxHeight: '80%' })
          .padding(16)
          .borderRadius(18)
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Start)
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 16 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F7F1')
  }
}

// ==================== Tab 2 装备购 ====================
@Component
export struct ShopTab {
  @State buyNum: number = 1
  @State curShop: ShopGoods = SHOP_GOODS[0]
  @State showBuy: boolean = false
  @State showFlash: boolean = false
  @State favShop: number[] = [2, 6]

  toggleFav(id: number) {
    const r: number[] = []
    for (let i = 0; i < this.favShop.length; i++) {
      if (this.favShop[i] !== id) {
        r.push(this.favShop[i])
      }
    }
    if (this.favShop.indexOf(id) < 0) {
      r.push(id)
    }
    this.favShop = r
  }

  isFav(id: number): boolean {
    return this.favShop.indexOf(id) >= 0
  }

  build() {
    Scroll() {
      Column() {
        // 秒杀 banner
        Column() {
          Row({ space: 10 }) {
            Column({ space: 3 }) {
              Text('户外装备返场秒杀')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('今晚 20 点场 · 每满 300 减 50')
                .fontSize(10)
                .fontColor('#E9F5EC')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Button() {
              Text('去抢购')
                .fontSize(11)
                .fontColor('#2D6A4F')
                .fontWeight(FontWeight.Bold)
            }
            .height(30)
            .padding({ left: 14, right: 14 })
            .borderRadius(15)
            .backgroundColor('#C2A878')
            .onClick(() => {
              this.showFlash = true
            })
          }
          .width('100%')

          Row({ space: 8 }) {
            Text('距结束')
              .fontSize(10)
              .fontColor('#E9F5EC')
            Text('04')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('rgba(0,0,0,0.25)')
            Text('12')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('rgba(0,0,0,0.25)')
            Text('36')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
              .backgroundColor('rgba(0,0,0,0.25)')
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .padding(14)
        .borderRadius(16)
        .linearGradient({
          angle: 120,
          colors: [['#14342B', 0], ['#2D6A4F', 0.65], ['#52796F', 1]]
        })

        // 分类横滑
        Scroll() {
          Row({ space: 8 }) {
            Text('🔥 热销榜')
              .fontSize(12)
              .fontColor('#8A6D3B')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(16)
              .backgroundColor('#F3E8D9')
            Text('⛺ 帐篷')
              .fontSize(12)
              .fontColor('#2D6A4F')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(16)
              .backgroundColor('#D8F3DC')
            Text('🪑 桌椅')
              .fontSize(12)
              .fontColor('#5C4033')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(16)
              .backgroundColor('#F3E8D9')
            Text('🔥 焚火')
              .fontSize(12)
              .fontColor('#B03A2E')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(16)
              .backgroundColor('#FFE3D3')
            Text('💡 灯具')
              .fontSize(12)
              .fontColor('#6D28D9')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(16)
              .backgroundColor('#EFE6FF')
            Text('🔋 电源')
              .fontSize(12)
              .fontColor('#1D4ED8')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(16)
              .backgroundColor('#DCEBFF')
            Text('🍳 炊具')
              .fontSize(12)
              .fontColor('#166534')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(16)
              .backgroundColor('#E8F5E9')
          }
          .padding({ top: 12 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        // 单列横向商品卡
        Column({ space: 10 }) {
          ForEach(SHOP_GOODS, (g: ShopGoods) => {
            Row({ space: 12 }) {
              Column() {
                Text(g.icon)
                  .fontSize(36)
                Text(g.label)
                  .fontSize(9)
                  .fontColor('#FFFFFF')
                  .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                  .borderRadius(7)
                  .backgroundColor('rgba(20,52,43,0.7)')
                  .margin({ top: 5 })
              }
              .width(92)
              .height(92)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .borderRadius(12)
              .linearGradient({
                angle: 135,
                colors: [[g.color, 0], ['#FFFFFF', 1]]
              })

              Column({ space: 5 }) {
                Text(g.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .maxLines(1)
                  .width('100%')

                Row({ space: 6 }) {
                  Text('已拼 ' + (g.sold / 1000).toFixed(1) + '万件')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                  Text('包邮')
                    .fontSize(9)
                    .fontColor('#2D6A4F')
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    .borderRadius(4)
                    .backgroundColor('#D8F3DC')
                  Text('运费险')
                    .fontSize(9)
                    .fontColor('#8A6D3B')
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                    .borderRadius(4)
                    .backgroundColor('#F3E8D9')
                }
                .width('100%')

                // 秒杀进度条
                Column({ space: 3 }) {
                  Row() {
                    Column()
                      .width(g.flash + '%')
                      .height(6)
                      .borderRadius(3)
                      .linearGradient({
                        angle: 90,
                        colors: [['#2D6A4F', 0], ['#C2A878', 1]]
                      })

                    Column()
                      .layoutWeight(1)
                      .height(6)
                      .borderRadius(3)
                      .backgroundColor('#EAEFE8')
                  }
                  .width('100%')

                  Text('已抢 ' + g.flash + '%')
                    .fontSize(9)
                    .fontColor('#B03A2E')
                    .width('100%')
                }
                .width('100%')

                Row() {
                  Row() {
                    Text('¥')
                      .fontSize(10)
                      .fontColor('#B03A2E')
                      .fontWeight(FontWeight.Bold)
                    Text(g.price.toString())
                      .fontSize(17)
                      .fontColor('#B03A2E')
                      .fontWeight(FontWeight.Bold)
                    Text('¥' + g.originPrice)
                      .fontSize(10)
                      .fontColor('#A9B5AC')
                      .decoration({ type: TextDecorationType.LineThrough })
                      .margin({ left: 5 })
                  }
                  .alignItems(VerticalAlign.Bottom)
                  .layoutWeight(1)

                  Text(this.isFav(g.id) ? '❤' : '♡')
                    .fontSize(15)
                    .fontColor(this.isFav(g.id) ? '#B03A2E' : '#A9B5AC')
                    .margin({ right: 10 })
                    .onClick(() => {
                      this.toggleFav(g.id)
                    })

                  Button() {
                    Text('拼单')
                      .fontSize(11)
                      .fontColor('#FFFFFF')
                      .fontWeight(FontWeight.Bold)
                  }
                  .height(28)
                  .padding({ left: 16, right: 16 })
                  .borderRadius(14)
                  .backgroundColor('#B03A2E')
                  .onClick(() => {
                    this.curShop = g
                    this.buyNum = 1
                    this.showBuy = true
                  })
                }
                .width('100%')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')
            .padding(11)
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .shadow({ radius: 6, color: 'rgba(45,106,79,0.08)', offsetX: 0, offsetY: 2 })
            .onClick(() => {
              this.curShop = g
              this.buyNum = 1
              this.showBuy = true
            })
          }, (g: ShopGoods) => g.id.toString())
        }
        .width('100%')
        .margin({ top: 12 })

        // 秒杀规则弹框
        if (this.showFlash) {
          modalOverlay(() => {
            this.showFlash = false
          })
          Column({ space: 12 }) {
            Text('⚡ 返场秒杀玩法')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1B4332')

            Column({ space: 8 }) {
              Text('① 每晚 20 点开抢,大牌装备 5 折起')
                .fontSize(11)
                .fontColor('#52796F')
                .width('100%')
              Text('② 每满 300 减 50,可与店铺券叠加')
                .fontSize(11)
                .fontColor('#52796F')
                .width('100%')
              Text('③ 前 100 名下单送防潮垫一块')
                .fontSize(11)
                .fontColor('#52796F')
                .width('100%')
              Text('④ 秒杀价商品支持 7 天无理由退换')
                .fontSize(11)
                .fontColor('#52796F')
                .width('100%')
            }
            .alignItems(HorizontalAlign.Start)
            .width('100%')
            .padding(14)
            .borderRadius(12)
            .backgroundColor('#F7FAF6')

            Scroll() {
              Row({ space: 8 }) {
                ForEach(SHOP_GOODS, (g: ShopGoods) => {
                  Column({ space: 4 }) {
                    Text(g.icon)
                      .fontSize(24)
                    Text('¥' + Math.round(g.price / 2))
                      .fontSize(11)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#B03A2E')
                  }
                  .width(64)
                  .padding({ top: 8, bottom: 8 })
                  .borderRadius(10)
                  .backgroundColor(g.color)
                }, (g: ShopGoods) => g.id.toString())
              }
            }
            .scrollable(ScrollDirection.Horizontal)
            .scrollBar(BarState.Off)
            .width('100%')

            Button() {
              Text('我知道了')
                .fontSize(13)
                .fontColor('#FFFFFF')
            }
            .height(38)
            .width('100%')
            .borderRadius(19)
            .backgroundColor('#2D6A4F')
            .onClick(() => {
              this.showFlash = false
            })
          }
          .width('80%')
          .constraintSize({ maxHeight: '80%' })
          .padding(18)
          .borderRadius(18)
          .backgroundColor('#FFFFFF')
        }
        // 购买确认弹框(居中小卡样式,区别于抽屉)
        if (this.showBuy) {
          modalOverlay(() => {
            this.showBuy = false
          })
          Column({ space: 12 }) {
            Column() {
              Text(this.curShop.icon)
                .fontSize(44)
            }
            .width(72)
            .height(72)
            .borderRadius(36)
            .justifyContent(FlexAlign.Center)
            .linearGradient({
              angle: 135,
              colors: [[this.curShop.color, 0], ['#FFFFFF', 1]]
            })

            Text(this.curShop.name)
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1B4332')
              .maxLines(1)

            Row({ space: 6 }) {
              Text(this.curShop.label)
                .fontSize(10)
                .fontColor('#8A6D3B')
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(8)
                .backgroundColor('#F3E8D9')
              Text('7 天无理由')
                .fontSize(10)
                .fontColor('#2D6A4F')
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(8)
                .backgroundColor('#D8F3DC')
            }

            Row({ space: 0 }) {
              Button() {
                Text('-')
                  .fontSize(16)
                  .fontColor('#52796F')
              }
              .width(34)
              .height(34)
              .borderRadius(8)
              .backgroundColor('#F0F4EF')
              .onClick(() => {
                if (this.buyNum > 1) {
                  this.buyNum = this.buyNum - 1
                }
              })

              Text(this.buyNum.toString())
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#1B4332')
                .width(44)
                .textAlign(TextAlign.Center)

              Button() {
                Text('+')
                  .fontSize(16)
                  .fontColor('#52796F')
              }
              .width(34)
              .height(34)
              .borderRadius(8)
              .backgroundColor('#F0F4EF')
              .onClick(() => {
                this.buyNum = this.buyNum + 1
              })
            }

            Row() {
              Text('合计')
                .fontSize(12)
                .fontColor('#7A8B7F')
              Text('¥' + this.curShop.price * this.buyNum)
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .fontColor('#B03A2E')
                .margin({ left: 6 })
            }

            Row({ space: 10 }) {
              Button() {
                Text('单独购 ¥' + this.curShop.price)
                  .fontSize(11)
                  .fontColor('#52796F')
              }
              .height(36)
              .layoutWeight(1)
              .borderRadius(18)
              .backgroundColor('#FFFFFF')
              .border({ width: 1, color: '#B7C9BD' })
              .onClick(() => {
                this.showBuy = false
              })

              Button() {
                Text('发起拼单更省')
                  .fontSize(11)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
              }
              .height(36)
              .layoutWeight(1)
              .borderRadius(18)
              .linearGradient({
                angle: 90,
                colors: [['#B03A2E', 0], ['#E74C3C', 1]]
              })
              .onClick(() => {
                this.showBuy = false
              })
            }
            .width('100%')
          }
          .width('74%')
          .constraintSize({ maxHeight: '80%' })
          .padding(18)
          .borderRadius(22)
          .backgroundColor('#FFFFFF')
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 16 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F7F1')
  }
}

// ==================== Tab 3 营地 ====================
@Component
export struct CampTab {
  @State curCamp: CampRow = CAMPS[0]
  @State dateIdxC: number = 0
  @State showCampDetail: boolean = false
  @State showBook: boolean = false
  @State showFacility: boolean = false
  @State booked: string[] = ['云顶星野营地 08/29']

  bookCamp() {
    const c: CampRow = this.curCamp
    const d: DateChipC = DATE_CHIPS_C[this.dateIdxC]
    const r: string[] = []
    for (let i = 0; i < this.booked.length; i++) {
      r.push(this.booked[i])
    }
    r.push(c.name + ' ' + d.sub)
    this.booked = r
    this.showBook = false
  }

  build() {
    Scroll() {
      Column() {
        // 营地大 banner
        Column({ space: 10 }) {
          Row({ space: 10 }) {
            Text('🌲')
              .fontSize(40)
            Column({ space: 3 }) {
              Text('本周营地热榜')
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('486 个营地 · 今晚剩余 1242 个营位')
                .fontSize(10)
                .fontColor('#E9F5EC')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Button() {
              Text('筛选')
                .fontSize(11)
                .fontColor('#2D6A4F')
                .fontWeight(FontWeight.Bold)
            }
            .height(28)
            .padding({ left: 14, right: 14 })
            .borderRadius(14)
            .backgroundColor('#C2A878')
            .onClick(() => {
              this.showFacility = true
            })
          }
          .width('100%')

          Row({ space: 8 }) {
            Text('已订 ' + this.booked.length + ' 单')
              .fontSize(11)
              .fontColor('#E9F5EC')
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor('rgba(255,255,255,0.18)')
            Text('宠物友好 32 家')
              .fontSize(11)
              .fontColor('#E9F5EC')
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor('rgba(255,255,255,0.18)')
            Text('可篝火 56 家')
              .fontSize(11)
              .fontColor('#E9F5EC')
              .padding({ left: 10, right: 10, top: 4, bottom: 4 })
              .borderRadius(10)
              .backgroundColor('rgba(255,255,255,0.18)')
          }
          .width('100%')
        }
        .width('100%')
        .padding(14)
        .borderRadius(16)
        .linearGradient({
          angle: 135,
          colors: [['#14342B', 0], ['#2D6A4F', 0.6], ['#C2A878', 1]]
        })

        Text('🔥 热门营地')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1B4332')
          .width('100%')
          .margin({ top: 14, bottom: 4 })

        // 营地卡片列表(评分圆徽章布局)
        Column({ space: 12 }) {
          ForEach(CAMPS, (c: CampRow, i: number) => {
            Column() {
              Row({ space: 12 }) {
                Column() {
                  Text(c.icon)
                    .fontSize(40)
                  Text(c.place)
                    .fontSize(9)
                    .fontColor('#FFFFFF')
                    .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                    .borderRadius(8)
                    .backgroundColor('rgba(20,52,43,0.55)')
                    .margin({ top: 6 })
                }
                .width(100)
                .height(88)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .borderRadius(12)
                .linearGradient({
                  angle: 135,
                  colors: [[c.color, 0], ['#FFFFFF', 1]]
                })

                Column({ space: 5 }) {
                  Row({ space: 6 }) {
                    Text('NO.' + (i + 1))
                      .fontSize(9)
                      .fontColor('#B03A2E')
                      .fontWeight(FontWeight.Bold)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .backgroundColor('#FDEDEC')
                    Text(c.name)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#1B4332')
                      .maxLines(1)
                      .layoutWeight(1)
                  }
                  .width('100%')

                  Text(c.tag + ' · ' + c.dist + ' · ' + c.sites + ' 个营位')
                    .fontSize(10)
                    .fontColor('#7A8B7F')
                    .width('100%')

                  Text('设施:' + c.facilities)
                    .fontSize(10)
                    .fontColor('#52796F')
                    .width('100%')
                    .maxLines(1)

                  Row() {
                    Column() {
                      Text(c.score)
                        .fontSize(13)
                        .fontWeight(FontWeight.Bold)
                        .fontColor('#FFFFFF')
                    }
                    .width(34)
                    .height(34)
                    .borderRadius(17)
                    .justifyContent(FlexAlign.Center)
                    .backgroundColor('#2D6A4F')

                    Column({ space: 1 }) {
                      Text('¥' + c.price)
                        .fontSize(16)
                        .fontWeight(FontWeight.Bold)
                        .fontColor('#B03A2E')
                      Text('/晚 起')
                        .fontSize(9)
                        .fontColor('#7A8B7F')
                    }
                    .margin({ left: 10 })

                    Button() {
                      Text('预订')
                        .fontSize(11)
                        .fontColor('#FFFFFF')
                        .fontWeight(FontWeight.Bold)
                    }
                    .height(30)
                    .padding({ left: 18, right: 18 })
                    .borderRadius(15)
                    .backgroundColor('#2D6A4F')
                    .margin({ left: 10 })
                    .onClick(() => {
                      this.curCamp = c
                      this.dateIdxC = 0
                      this.showBook = true
                    })
                  }
                  .width('100%')
                  .alignItems(VerticalAlign.Center)
                }
                .alignItems(HorizontalAlign.Start)
                .layoutWeight(1)
              }
              .width('100%')
              .padding(11)
            }
            .width('100%')
            .borderRadius(14)
            .backgroundColor('#FFFFFF')
            .shadow({ radius: 6, color: 'rgba(45,106,79,0.08)', offsetX: 0, offsetY: 2 })
            .onClick(() => {
              this.curCamp = c
              this.showCampDetail = true
            })
          }, (c: CampRow) => c.id.toString())
        }
        .width('100%')

        // 营地详情弹框(左上圆角大卡 + 底部双按钮)
        if (this.showCampDetail) {
          modalOverlay(() => {
            this.showCampDetail = false
          })
          Column() {
            Column() {
              Text(this.curCamp.icon)
                .fontSize(48)
            }
            .width('100%')
            .height(110)
            .justifyContent(FlexAlign.Center)
            .linearGradient({
              angle: 160,
              colors: [[this.curCamp.color, 0], ['#E9F5EC', 1]]
            })

            Column({ space: 10 }) {
              Row({ space: 8 }) {
                Text(this.curCamp.name)
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .layoutWeight(1)
                Text('★ ' + this.curCamp.score)
                  .fontSize(12)
                  .fontColor('#B7791F')
              }
              .width('100%')

              Row({ space: 6 }) {
                Text(this.curCamp.tag)
                  .fontSize(10)
                  .fontColor('#2D6A4F')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#D8F3DC')
                Text(this.curCamp.dist)
                  .fontSize(10)
                  .fontColor('#52796F')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#F0F4EF')
                Text(this.curCamp.sites + ' 营位')
                  .fontSize(10)
                  .fontColor('#8A6D3B')
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
                  .backgroundColor('#F3E8D9')
              }
              .width('100%')

              Column({ space: 6 }) {
                Text('营地设施')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .width('100%')
                Row({ space: 6 }) {
                  Text('🚿 淋浴')
                    .fontSize(10)
                    .fontColor('#52796F')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(8)
                    .backgroundColor('#F7FAF6')
                  Text('⚡ 市电')
                    .fontSize(10)
                    .fontColor('#52796F')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(8)
                    .backgroundColor('#F7FAF6')
                  Text('🔥 篝火区')
                    .fontSize(10)
                    .fontColor('#52796F')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(8)
                    .backgroundColor('#F7FAF6')
                  Text('🐕 宠物友好')
                    .fontSize(10)
                    .fontColor('#52796F')
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(8)
                    .backgroundColor('#F7FAF6')
                }
                .width('100%')
              }
              .width('100%')
              .padding(12)
              .borderRadius(12)
              .backgroundColor('#F7FAF6')

              Row() {
                Text('入场费')
                  .fontSize(11)
                  .fontColor('#7A8B7F')
                Text('¥' + this.curCamp.price + ' /人 /晚(含营位)')
                  .fontSize(11)
                  .fontColor('#1B4332')
                  .layoutWeight(1)
                  .textAlign(TextAlign.End)
              }
              .width('100%')

              Row({ space: 10 }) {
                Button() {
                  Text('加入心愿单')
                    .fontSize(12)
                    .fontColor('#52796F')
                }
                .height(38)
                .layoutWeight(1)
                .borderRadius(19)
                .backgroundColor('#FFFFFF')
                .border({ width: 1, color: '#B7C9BD' })
                .onClick(() => {
                  this.showCampDetail = false
                })

                Button() {
                  Text('¥' + this.curCamp.price + ' 立即预订')
                    .fontSize(12)
                    .fontColor('#FFFFFF')
                    .fontWeight(FontWeight.Bold)
                }
                .height(38)
                .layoutWeight(1)
                .borderRadius(19)
                .backgroundColor('#2D6A4F')
                .onClick(() => {
                  this.showCampDetail = false
                  this.showBook = true
                })
              }
              .width('100%')
            }
            .padding(16)
            .alignItems(HorizontalAlign.Start)
          }
          .width('86%')
          .constraintSize({ maxHeight: '80%' })
          .borderRadius(18)
          .backgroundColor('#FFFFFF')
          .clip(true)
        }
        // 营地预约弹框(日期 chips 网格样式)
        if (this.showBook) {
          modalOverlay(() => {
            this.showBook = false
          })
          Column({ space: 14 }) {
            Text('预订 · ' + this.curCamp.name)
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1B4332')
              .width('100%')

            Text('选择入营日期')
              .fontSize(12)
              .fontColor('#52796F')
              .width('100%')

            Flex({ wrap: FlexWrap.Wrap, direction: FlexDirection.Row }) {
              ForEach(DATE_CHIPS_C, (d: DateChipC, i: number) => {
                Column({ space: 2 }) {
                  Text(d.label)
                    .fontSize(12)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(this.dateIdxC === i ? '#FFFFFF' : (d.full ? '#A9B5AC' : '#1B4332'))
                  Text(d.sub)
                    .fontSize(9)
                    .fontColor(this.dateIdxC === i ? '#E9F5EC' : (d.full ? '#A9B5AC' : '#7A8B7F'))
                }
                .width(76)
                .padding({ top: 8, bottom: 8 })
                .borderRadius(12)
                .alignItems(HorizontalAlign.Center)
                .backgroundColor(this.dateIdxC === i ? '#2D6A4F' : '#F0F4EF')
                .margin(4)
                .onClick(() => {
                  if (!d.full) {
                    this.dateIdxC = i
                  }
                })
              }, (d: DateChipC) => d.id.toString())
            }
            .width('100%')

            Column({ space: 8 }) {
              Row() {
                Text('入场费 ¥' + this.curCamp.price + ' × 2 人')
                  .fontSize(11)
                  .fontColor('#52796F')
                  .layoutWeight(1)
                Text('¥' + this.curCamp.price * 2)
                  .fontSize(11)
                  .fontColor('#1B4332')
              }
              .width('100%')

              Row() {
                Text('营位预留(19:00 前)')
                  .fontSize(11)
                  .fontColor('#52796F')
                  .layoutWeight(1)
                Text('免费')
                  .fontSize(11)
                  .fontColor('#2D6A4F')
              }
              .width('100%')

              Row() {
                Text('合计')
                  .fontSize(12)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#1B4332')
                  .layoutWeight(1)
                Text('¥' + this.curCamp.price * 2)
                  .fontSize(16)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#B03A2E')
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#F0F4EF')

            Row({ space: 10 }) {
              Button() {
                Text('取消')
                  .fontSize(13)
                  .fontColor('#52796F')
              }
              .height(40)
              .layoutWeight(1)
              .borderRadius(20)
              .backgroundColor('#F0F4EF')
              .onClick(() => {
                this.showBook = false
              })

              Button() {
                Text('确认预订')
                  .fontSize(13)
                  .fontColor('#FFFFFF')
                  .fontWeight(FontWeight.Bold)
              }
              .height(40)
              .layoutWeight(1)
              .borderRadius(20)
              .linearGradient({
                angle: 90,
                colors: [['#2D6A4F', 0], ['#52796F', 1]]
              })
              .onClick(() => {
                this.bookCamp()
              })
            }
            .width('100%')
          }
          .width('88%')
          .constraintSize({ maxHeight: '80%' })
          .padding(18)
          .borderRadius(16)
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Start)
        }
        // 设施筛选弹框(标签墙样式)
        if (this.showFacility) {
          modalOverlay(() => {
            this.showFacility = false
          })
          Column({ space: 14 }) {
            Text('按设施筛选营地')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#1B4332')
              .width('100%')

            Flex({ wrap: FlexWrap.Wrap, direction: FlexDirection.Row }) {
              ForEach(CAMPS, (c: CampRow) => {
                Text(c.facilities)
                  .fontSize(10)
                  .fontColor('#2D6A4F')
                  .padding({ left: 10, right: 10, top: 6, bottom: 6 })
                  .borderRadius(12)
                  .backgroundColor('#D8F3DC')
                  .margin(4)
              }, (c: CampRow) => c.id.toString())
            }
            .width('100%')

            Flex({ wrap: FlexWrap.Wrap, direction: FlexDirection.Row }) {
              Text('🚿 独立卫浴')
                .fontSize(11)
                .fontColor('#52796F')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#F0F4EF')
                .margin(4)
              Text('🔥 可明火')
                .fontSize(11)
                .fontColor('#B03A2E')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#FFE3D3')
                .margin(4)
              Text('🐕 可带宠物')
                .fontSize(11)
                .fontColor('#8A6D3B')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#F3E8D9')
                .margin(4)
              Text('🌊 湖景位')
                .fontSize(11)
                .fontColor('#1D4ED8')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#DCEBFF')
                .margin(4)
              Text('🌌 观星台')
                .fontSize(11)
                .fontColor('#6D28D9')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#EFE6FF')
                .margin(4)
              Text('☕ 咖啡车')
                .fontSize(11)
                .fontColor('#5C4033')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#EDE0D4')
                .margin(4)
            }
            .width('100%')

            Button() {
              Text('完成筛选')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .fontWeight(FontWeight.Bold)
            }
            .height(40)
            .width('100%')
            .borderRadius(20)
            .backgroundColor('#2D6A4F')
            .onClick(() => {
              this.showFacility = false
            })
          }
          .width('84%')
          .constraintSize({ maxHeight: '80%' })
          .padding(18)
          .borderRadius(20)
          .backgroundColor('#FFFFFF')
          .alignItems(HorizontalAlign.Start)
        }
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 16 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F7F1')
  }
}

// ==================== Tab 4 拼队 ====================
@Component
export struct TeamTab {
  @State curTeam: TeamRow = TEAMS[0]
  @State myTeams: TeamRow[] = []
  @State teamList: TeamRow[] = [
    { id: 1, title: '周六云顶观星 4 人局', camp: '云顶星野营地', date: '08/29 周六', need: 4, joined: 3, master: '山系阿正', color: '#E8E4FF', note: '缺 1 人 · 已带天幕和焚火台' },
    { id: 2, title: '安吉松林亲子捞鱼团', camp: '松林湖畔露营地', date: '08/30 周日', need: 6, joined: 4, master: '团子妈妈', color: '#D8F3DC', note: '缺 2 人 · 娃 3 枚求同龄' },
    { id: 3, title: '温岭风车山追日出行', camp: '风车山丘营地', date: '08/29 周六', need: 5, joined: 5, master: '早起困难户', color: '#FFE8D6', note: '已满员 · 等候补' },
    { id: 4, title: '莫干山竹林下午茶局', camp: '竹林秘境营地', date: '08/31 周一', need: 3, joined: 1, master: '胶片小鹿', color: '#E8F5E9', note: '缺 2 人 · 摄影搭子优先' },
    { id: 5, title: '象山海岸房车海钓团', camp: '海岸线房车营地', date: '09/05 周六', need: 4, joined: 2, master: '老船长Ken', color: '#DCEBFF', note: '缺 2 人 · 有渔具 3 套' },
    { id: 6, title: '武义牧场萌宠野餐会', camp: '草原星空牧场', date: '08/30 周日', need: 8, joined: 5, master: '柴犬旺财', color: '#FFF3D6', note: '缺 3 人 · 可带毛孩子' }
  ]
  @State showJoin: boolean = false
  @State showCreate: boolean = false
  @State showRule: boolean = false
  @State newTitle: string = ''
  @State newNeed: number = 4

  joinTeam() {
    const t: TeamRow = this.curTeam
    if (t.joined < t.need) {
      const r: TeamRow[] = []
      for (let i = 0; i < this.teamList.length; i++) {
        if (this.teamList[i].id === t.id) {
          r.push({ id: this.teamList[i].id, title: this.teamList[i].title, camp: this.teamList[i].camp, date: this.teamList[i].date, need: this.teamList[i].need, joined: this.teamList[i].joined + 1, master: this.teamList[i].master, color: this.teamList[i].color, note: this.teamList[i].note })
        } else {
          r.push(this.teamList[i])
        }
      }
      this.teamList = r
      const m: TeamRow[] = []
      for (let i = 0; i < this.myTeams.length; i++) {
        m.push(this.myTeams[i])
      }
      m.push(t)
      this.myTeams = m
    }
    this.showJoin = false
  }

  createTeam() {
    if (this.newTitle.length > 0) {
      const m: TeamRow[] = []
      for (let i = 0; i < this.myTeams.length; i++) {
        m.push(this.myTeams[i])
      }
      m.push({ id: this.myTeams.length + 100, title: this.newTitle, camp: '自定义营地', date: '待定', need: this.newNeed, joined: 1, master: '我', color: '#F3E8D9', note: '队长就是我 · 招募中' })
      this.myTeams = m
      const r2: TeamRow[] = []
      r2.push({ id: this.myTeams.length + 100, title: this.newTitle, camp: '自定义营地', date: '待定', need: this.newNeed, joined: 1, master: '我', color: '#F3E8D9', note: '队长就是我 · 招募中' })
      for (let i = 0; i < this.teamList.length; i++) {
        r2.push(this.teamList[i])
      }
      this.teamList = r2
    }
    this.showCreate = false
  }

  build() {
    Scroll() {
      Column() {
        // 我发起的拼队概览
        Row({ space: 10 }) {
          Column() {
            Text('👥')
              .fontSize(30)
          }
          .width(54)
          .height(54)
          .borderRadius(27)
          .justifyContent(FlexAlign.Center)
          .backgroundColor('#D8F3DC')


     
          })
        }
        .width('86%')
        .constraintSize({ maxHeight: '80%' })
        .padding(18)
        .borderRadius(16)
        .backgroundColor('#FFFFFF')
        .alignItems(HorizontalAlign.Start)
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F4F7F1')
  }
}

在这里插入图片描述

九、总结

本文对基于 HarmonyOS 6.1.1 构建的露营星野社应用进行了全维度的深度技术解析。该应用以森绿与帆布卡其为主色调,采用"顶部频道 pill 横滑 + 底部 Tab 导航"的双重导航架构,覆盖了装备租赁、装备购买、营地预订、拼队组队、订单管理和个人中心六大核心业务模块。从接口定义层到组件实现层,从全局常量配置到全局纯函数封装,本文逐段展示了代码并详细解释了每个设计决策背后的技术考量和业务逻辑。

在定价逻辑层面,该应用的核心技术亮点是 rentTotal 函数实现的三段式分层定价策略——1 天和 3 天按原价计算、7 天享 8 折优惠、月租按 24 天计算(相当于 8 折)。这种定价策略通过简单的 if-else 分支实现,却在业务层面实现了精妙的用户激励效果:长租用户的日均租金更低(如焚火台 7 天租的日均租金为 28 元,比 1 天租的 35 元低 20%),激励用户选择更长租期;而平台则通过更高的单笔订单金额和更稳定的装备利用率获得收益保障。该函数在租赁确认抽屉、租赁车合计、续租弹框等多个场景中被调用,确保了定价逻辑的一致性和可维护性。

在数据架构层面,该应用定义了 10 个接口类型覆盖全部业务数据模型,采用不可变更新模式进行所有数据操作。拼队组件的 joinTeam 和 createTeam 方法尤其值得深入分析——joinTeam 方法同时更新了 teamList(大厅列表中的队伍计数)和 myTeams(用户参与历史)两个数组,createTeam 方法通过创建新数组并头部插入的方式实现队伍发布。所有数据操作都遵循"创建新数组 → 逐个复制元素 → 追加/修改目标元素 → 整体赋值"的模式,确保了 ArkTS 响应式系统能够正确检测到数组引用的变化并触发 UI 重渲染。

双列租赁卡片网格使用 Flex 弹性换行布局和 SpaceBetween 对齐实现等宽双列排列,每张卡片以 g.color 为渐变起始色配合白色终止色形成柔和的图标区域背景。拼队成员头像横排通过 ForEach 遍历固定数组 [0,1,2,3,4,5,6,7] 并根据 k 与 joined/need 的关系渲染不同状态的头像——已加入为笑脸、未加入为问号占位符,直观展示了队伍填充状态。押金账户弹框和徽章墙弹框是装备租赁业务的特色功能,前者展示冻结押金明细和退还规则,后者通过标签墙展示用户的露营成就,两者共同增强了用户的信任感和参与感。整体来看,该应用的代码结构清晰、定价逻辑严谨、状态管理可控,充分展现了 HarmonyOS ArkTS API 24 在构建垂直领域服务平台时的成熟开发能力和精细化用户体验设计水平。

Logo

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

更多推荐