引言

鸿蒙生态在过去数年中经历了从探索期到成熟期的跨越式发展,HarmonyOS 6.1.1 作为当前最新的稳定版本,在 ArkUI 声明式开发框架中引入了大量面向复杂业务场景的增强能力。开发者现在可以利用 @Component@State@Builder@Entryexport 等装饰器语法体系,构建出结构清晰、状态可控、组件可复用的高质量移动应用界面。基于 HarmonyOS API 24 的 ArkTS 语言,不仅继承了 TypeScript 的静态类型检查能力,更在编译期和运行时层面针对声明式 UI 的 diff 算法进行了深度优化,使得界面更新效率大幅提升,即使在包含大量动态弹窗和状态切换的复杂交互场景下,也能保持流畅的用户体验。

ArkTS 声明式 UI 的核心理念是"UI 是状态的函数映射"。开发者只需声明界面结构如何依赖状态数据,当 @State 修饰的变量发生变化时,框架自动追踪依赖关系并触发对应组件的重新渲染。这一机制在汉服国风体验馆这类应用中显得尤为重要——该应用包含了朝代筛选、尺码表查看、日历预约时段选择、摄影师挑选、地址增删改查等多个高度交互性的功能模块,每个模块都涉及大量的状态变量和弹窗交互,声明式状态管理使得这些复杂交互的实现变得简洁而可靠。

汉服国风体验馆是一个面向汉服爱好者的一站式服务平台,集成了今日上新、服饰租赁、簪花铺子、写真预约、订单管理和个人中心六大核心模块。在视觉设计上,该应用采用了朱红(#C8102E)与鎏金(#E8B34B)的双主色搭配方案,底色为暖米色 #FFF7EC——朱红色承袭中国传统漆器与宫墙的经典色调,鎏金色则呼应古代金箔与铜镀金的工艺美感,两者在暖米色底纸上营造出浓郁的新中式国风美学。与游戏点卡充值中心不同,该应用的所有子组件都使用了 export struct 导出修饰,且所有模态弹窗均以内联方式直接写在各组件的 build() 方法中而非抽取为独立的 @Builder 方法,同时定义了一个全局的 @Builder function modalOverlay 作为跨组件共享的遮罩层构建器。

本文将从接口定义层、全局常量与数据配置、全局纯函数与全局构建器、各 Tab 组件的逐段源码解析、主入口页面架构、流程图绘制以及技术对比等维度,对该汉服国风体验馆的完整源码进行深度技术解读。

接口定义层分析

汉服国风体验馆共定义了十四个接口,是三个应用中接口数量最多的。这些接口覆盖了朝代筛选、汉服商品、租金走势、尺码表、配饰商品、写真套餐、摄影师、日历日期、订单记录、优惠券、积分明细、收货地址、Tab 配置和消息推送等全部业务领域。

朝代筛选与汉服商品接口

interface DynastyChip {
  name: string
  count: number
}

interface HanfuItem {
  id: number
  title: string
  dynasty: string
  price: number
  originPrice: number
  rentDay: number
  score: number
  rentCount: number
  tags: string[]
  hot: boolean
  color: string
}

在这里插入图片描述

DynastyChip 接口定义了朝代筛选标签的数据结构,name 字段存储朝代名称(全部朝代、唐制、宋制、明制、魏晋、清汉女),count 字段存储该朝代的在架商品数量,在 UI 上以"唐制 92"的形式展示,让用户在选择前就能了解各朝代的商品丰富度。

HanfuItem 接口是汉服商品的核心数据结构,字段最为丰富。dynasty 字段标识所属朝代,用于筛选逻辑;priceoriginPrice 分别表示租金和原价,通过 decoration({ type: TextDecorationType.LineThrough }) 实现原价划线效果;rentDay 字段表示基础租赁天数(通常为 3 天),在租赁弹窗中用于计算 price * Math.ceil(rentNum / rentDay) 的总价;scorerentCount 分别表示评分和租赁次数,用于商品排序和信任度展示;tags 是字符串数组,存储如"新品首发"、“含发饰”、"飘逸"等标签,通过 ForEach 循环渲染为小标签;color 字段存储商品的代表性颜色十六进制值,作为商品图片区域的纯色背景,在无网络图片的场景下实现了视觉区分。

租金走势与尺码表接口

interface RentTrend {
  day: string
  price: number
}

interface SizeRow {
  size: string
  height: string
  bust: string
  stock: number
}

RentTrend 接口定义了一周七天的租金走势数据,price 字段表示当日租金均价,周末价格显著高于工作日(周五 138、周六 168、周日 152 vs 工作日 72-96),通过 rentBarH() 函数映射为柱状图高度,通过 trendBarColor() 函数映射为柱状图颜色。

SizeRow 接口定义了尺码表的数据结构,size 字段为尺码代号(XS/S/M/L/XL),heightbust 分别表示对应身高和胸围范围,stock 字段表示该尺码的库存数量。当 stock > 0 时显示"可租 X 件",否则显示"已约满"。M 码行会以浅黄色背景高亮,表示推荐尺码。

配饰与写真套餐接口

interface AccessoryItem {
  id: number
  name: string
  material: string
  price: number
  stock: number
  grade: string
  color: string
}

interface PhotoPackage {
  id: number
  name: string
  scene: string
  price: number
  originPrice: number
  duration: string
  photos: string
  retouch: string
  hot: boolean
  color: string
}

在这里插入图片描述

AccessoryItem 接口定义了簪花配饰商品的结构。material 字段存储材质信息(铜镀金·珍珠、非遗绒花、手作缠花等),在商品卡片中以小字展示,传达工艺品质;grade 字段标识等级(匠心工艺、非遗联名、手作限定、孤品等),用于商品的差异化定位。

PhotoPackage 接口定义了写真套餐的结构,字段最为丰富。scene 字段描述拍摄场景(园林实景、花墙+茶室双景、中式庭院烛光等),duration 字段表示拍摄时长,photos 字段描述精修张数和底片政策,retouch 字段标识修图等级(轻奢精修、杂志级修图、电影级调色等),这些字段共同构成了写真套餐的完整营销信息。

摄影师与日历接口

interface Photographer {
  id: number
  name: string
  years: number
  style: string
  price: number
  works: number
  rating: number
}

interface CalendarDay {
  idx: number
  label: string
  date: string
  left: number
}

Photographer 接口定义了签约摄影师的信息结构。years 字段表示从业年限,用于在卡片上以"从业 X 年"的标签形式展示资历;style 字段描述拍摄风格(电影感·情绪光影、清冷宋制·留白美学等),帮助用户匹配自己的审美偏好;works 字段表示作品数量,rating 字段表示评分。

CalendarDay 接口定义了日历日期的数据结构。idx 作为唯一标识,label 字段为日期标签(今天、明天、周四等),date 字段为日期字符串(8/24、8/25等),left 字段表示剩余可约数量。当 left === 0 时,该日期会显示"已约满"并禁用选择。这个接口在试穿预约弹窗和写真预约弹窗中均被使用,是跨 Tab 共享的日历数据源。

订单与优惠接口

interface OrderRecord {
  id: string
  item: string
  cover: string
  date: string
  days: number
  status: string
  statusColor: string
  amount: number
  deposit: number
}

interface CouponMeta {
  id: number
  title: string
  amount: number
  condition: string
  expire: string
  color: string
}

interface PointLog {
  id: number
  title: string
  delta: number
  date: string
}

在这里插入图片描述

OrderRecord 接口定义了订单记录的结构。与游戏充值中心不同,这里的 id 是字符串类型(如"HF2026082301"),更贴近真实订单号格式。cover 字段存储订单封面的颜色值,用于在无图片时以纯色块展示商品视觉。statusColor 字段直接将状态颜色存储在数据中(如租赁中为 #C8102E、已归还为 #5C7B8A),避免了在渲染时进行条件映射。deposit 字段表示押金金额,在订单卡片中根据 deposit > 0 条件决定是否展示押金信息。

CouponMeta 接口定义了优惠券的结构,color 字段存储优惠券的主题色,使得不同类型优惠券可以有不同的视觉风格。PointLog 接口定义了积分变动记录,delta 字段为正数表示获得积分,为负数表示消耗积分,通过 delta > 0 ? '+' : '' 的条件表达式在 UI 上展示带正负号的变动值。

地址与配置接口

interface AddrMeta {
  id: number
  name: string
  phone: string
  region: string
  detail: string
  tag: string
}

interface TabMeta {
  name: string
  icon: string
}

interface MsgMeta {
  id: number
  title: string
  content: string
  time: string
  read: boolean
}

在这里插入图片描述

AddrMeta 接口定义了收货地址的结构。tag 字段标识地址类型(默认、公司、家),以小标签形式展示。region 字段存储省市信息,detail 字段存储详细地址。TabMeta 接口定义了底部导航配置,与游戏充值中心不同的是,icon 字段使用中文文字而非 emoji(“上新”、“租赁”、"簪花"等),更符合国风应用的文字美学。MsgMeta 接口新增了 read 布尔字段,用于标识消息的已读/未读状态,未读消息会在消息中心以红色圆点标记。

全局常量与枚举分析

朝代与汉服数据配置

const DYNASTY_CHIPS: DynastyChip[] = [
  { name: '全部朝代', count: 286 },
  { name: '唐制', count: 92 },
  { name: '宋制', count: 74 },
  { name: '明制', count: 68 },
  { name: '魏晋', count: 31 },
  { name: '清汉女', count: 21 }
]

DYNASTY_CHIPS 常量数组定义了六个朝代筛选标签。从数量分布可以看出,唐制(92 套)和宋制(74 套)是商品最丰富的朝代,魏晋(31 套)和清汉女(21 套)则相对小众。这种数据驱动的筛选标签设计,让用户在选择朝代时就能直观感知商品库的深度,有助于做出快速决策。

const NEW_HANFU: HanfuItem[] = [
  { id: 1, title: '齐胸襦裙·烟霞染', dynasty: '唐制', price: 88, originPrice: 158, rentDay: 3, score: 4.9, rentCount: 2317, tags: ['新品首发', '含发饰', '飘逸'], hot: true, color: '#F5C8D0' },
  { id: 2, title: '马面裙·山河绣', dynasty: '明制', price: 68, originPrice: 128, rentDay: 3, score: 4.8, rentCount: 4521, tags: ['爆款返场', '织金'], hot: true, color: '#D9B382' },
  // ... 其余汉服数据
]

const RENT_LIST: HanfuItem[] = [
  { id: 101, title: '全套唐制·花朝宴', dynasty: '唐制', price: 128, originPrice: 228, rentDay: 3, score: 4.9, rentCount: 3216, tags: ['全套含鞋', '送造型'], hot: true, color: '#F2B8C6' },
  // ... 其余租赁数据
]

NEW_HANFURENT_LIST 分别定义了今日上新 Tab 和服饰租赁 Tab 的商品数据。值得注意的是,两个数组的 id 编号体系不同——上新商品从 1 开始,租赁商品从 101 开始,避免了 ID 冲突。color 字段为每件汉服配置了代表性颜色(如烟霞染的 #F5C8D0 浅粉色、竹影青的 #BFD8C2 淡绿色),在无图片时作为纯色封面展示,同时通过颜色名称传达汉服的意境美感。

走势与尺码数据配置

const RENT_TREND: RentTrend[] = [
  { day: '周一', price: 96 },
  { day: '周二', price: 78 },
  { day: '周三', price: 72 },
  { day: '周四', price: 88 },
  { day: '周五', price: 138 },
  { day: '周六', price: 168 },
  { day: '周日', price: 152 }
]

const SIZE_ROWS: SizeRow[] = [
  { size: 'XS', height: '150-158cm', bust: '80-84cm', stock: 12 },
  { size: 'S', height: '158-163cm', bust: '84-88cm', stock: 26 },
  { size: 'M', height: '163-168cm', bust: '88-92cm', stock: 31 },
  { size: 'L', height: '168-173cm', bust: '92-96cm', stock: 18 },
  { size: 'XL', height: '173-178cm', bust: '96-100cm', stock: 7 }
]

在这里插入图片描述

RENT_TREND 的数据分布清晰地反映了汉服租赁的周末溢价现象——工作日均价在 72-96 元之间,周末则飙升至 138-168 元,涨幅接近一倍。这种数据特征通过柱状图可视化后,能够有效引导用户选择工作日租赁以节省费用。SIZE_ROWS 定义了从 XS 到 XL 的五档尺码,M 码库存最充足(31 件),XL 码库存最紧张(7 件),通过 stock > 0 的条件判断展示可租或约满状态。

配饰与写真数据配置

const ACCESSORY_ITEMS: AccessoryItem[] = [
  { id: 1, name: '鎏金发簪·并蒂莲', material: '铜镀金·珍珠', price: 39, stock: 126, grade: '匠心工艺', color: '#E8B34B' },
  { id: 2, name: '绒花发钗·石榴红', material: '非遗绒花', price: 45, stock: 88, grade: '非遗联名', color: '#D94F5C' },
  // ... 其余配饰数据
]

const ACCESSORY_CATS: string[] = ['全部', '发饰', '璎珞', '道具', '团扇', '鞋履']

const PHOTO_PACKAGES: PhotoPackage[] = [
  { id: 1, name: '单人国风写真', scene: '园林实景 3 选 1', price: 399, originPrice: 699, duration: '120 分钟', photos: '精修 9 张 + 底片全送', retouch: '轻奢精修', hot: true, color: '#F5C8D0' },
  // ... 其余套餐数据
]

const PHOTOGRAPHERS: Photographer[] = [
  { id: 1, name: '阿棠', years: 8, style: '电影感·情绪光影', price: 200, works: 1860, rating: 4.9 },
  // ... 其余摄影师数据
]

配饰数据覆盖了发簪、发钗、步摇、璎珞、玉佩、折扇、油纸伞和披帛八种品类,材质涵盖铜镀金、非遗绒花、手作缠花、岫玉、真丝绡等多种传统工艺。写真套餐从 399 元的单人写真到 1299 元的情侣婚照,覆盖了不同消费层级的需求。摄影师数据展示了四位签约摄影师,从业年限从 4 年到 8 年不等,风格各异,价格从 120 元到 200 元/组。

日历与订单数据配置

const CALENDAR_DAYS: CalendarDay[] = [
  { idx: 0, label: '今天', date: '8/24', left: 6 },
  { idx: 1, label: '明天', date: '8/25', left: 12 },
  { idx: 2, label: '周四', date: '8/26', left: 9 },
  { idx: 3, label: '周五', date: '8/27', left: 3 },
  { idx: 4, label: '周六', date: '8/28', left: 0 },
  { idx: 5, label: '周日', date: '8/29', left: 2 },
  { idx: 6, label: '周一', date: '8/30', left: 14 }
]

const ORDER_LIST: OrderRecord[] = [
  { id: 'HF2026082301', item: '全套唐制·花朝宴', cover: '#F2B8C6', date: '08/23 - 08/25', days: 3, status: '租赁中', statusColor: '#C8102E', amount: 128, deposit: 200 },
  // ... 其余订单数据
]

const ORDER_FILTERS: string[] = ['全部', '租赁中', '待取片', '已归还', '退款']

在这里插入图片描述

CALENDAR_DAYS 定义了未来七天的日历数据,周六(8/28)的 left 为 0,表示当日已约满,在 UI 上会以灰色禁用状态展示。ORDER_LIST 定义了五条订单记录,覆盖了租赁中、已归还、待取片、已完成和退款成功五种状态,每种状态都有对应的 statusColor 颜色值。ORDER_FILTERS 定义了五个筛选标签。

优惠与积分数据配置

const COUPON_LIST: CouponMeta[] = [
  { id: 1, title: '汉服租赁立减券', amount: 30, condition: '满 128 可用', expire: '09/30 前有效', color: '#C8102E' },
  { id: 2, title: '写真定金膨胀券', amount: 100, condition: '满 399 可用', expire: '10/15 前有效', color: '#E8B34B' },
  { id: 3, title: '簪花铺子满赠券', amount: 10, condition: '满 39 可用', expire: '08/31 前有效', color: '#D94F5C' },
  { id: 4, title: '新人首单 5 折券', amount: 20, condition: '无门槛', expire: '09/15 前有效', color: '#8E0E20' }
]

const POINT_LOGS: PointLog[] = [
  { id: 1, title: '归还加分·衣物完好', delta: 20, date: '08/25 10:32' },
  { id: 2, title: '签到得积分', delta: 5, date: '08/24 09:00' },
  { id: 3, title: '写真订单返积分', delta: 120, date: '08/10 18:20' },
  { id: 4, title: '积分兑换发簪', delta: -200, date: '08/02 12:41' },
  { id: 5, title: '评价晒图奖励', delta: 15, date: '08/01 21:05' }
]

在这里插入图片描述

优惠券数据涵盖了租赁、写真和配饰三种业务场景,每张优惠券都有独立的主题色,在优惠券卡片左侧以纯色块展示金额。积分明细记录了五种积分变动场景,其中 delta 为负数(-200)的记录表示积分兑换消耗,在 UI 上以蓝色(#8FA3C4)展示,正数则以朱红色展示。

导航与消息配置

const MAIN_TABS: TabMeta[] = [
  { name: '今日上新', icon: '上新' },
  { name: '服饰租赁', icon: '租赁' },
  { name: '簪花铺子', icon: '簪花' },
  { name: '写真预约', icon: '写真' },
  { name: '订单', icon: '订单' },
  { name: '我的', icon: '我的' }
]

const MSG_LIST: MsgMeta[] = [
  { id: 1, title: '租赁提醒', content: '您的「花朝宴」还有 1 天到期,顺丰上门取件已预约', time: '10:12', read: false },
  // ... 其余消息数据
]

MAIN_TABSicon 字段使用中文文字而非 emoji,这在三个应用中独树一帜——文字图标更符合国风应用的文人气韵,也与朱红鎏金的古典配色相得益彰。MSG_LIST 中前两条消息的 readfalse(未读),对应主入口页面头部的未读红点指示器。

全局纯函数与全局构建器分析

租金柱状图高度函数 rentBarH

function rentBarH(p: number): number {
  if (p >= 160) {
    return 84
  }
  if (p >= 130) {
    return 66
  }
  if (p >= 90) {
    return 46
  }
  return 28
}

rentBarH 函数将租金价格映射为柱状图高度,采用阶梯式映射策略而非线性映射。160 元以上映射为 84 像素最高柱,130-159 元映射为 66 像素,90-129 元映射为 46 像素,90 元以下映射为 28 像素最低柱。阶梯式映射的优势在于视觉区分度更高——工作日和周末的价格差异在视觉上表现为明显的"高低跳跃"效果,比线性映射更能突出周末溢价现象。

趋势柱状图颜色函数 trendBarColor

function trendBarColor(p: number): string {
  if (p >= 130) {
    return '#C8102E'
  }
  return '#E8B34B'
}

在这里插入图片描述

trendBarColor 函数将价格映射为柱状图渐变色。130 元以上(周末档)使用朱红色渐变 ['#E8556D', '#C8102E'],130 元以下(工作日档)使用鎏金色渐变 ['#F2D088', '#E8B34B']。这种双色映射与高度阶梯函数配合,形成了"高柱红色、低柱金色"的视觉模式,用户一眼即可识别周末高峰。

全局遮罩构建器 modalOverlay

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

这是整个应用中最重要的全局构建器。与游戏充值中心在每个 Tab 内部各自定义 modalOverlay 不同,汉服体验馆将遮罩层抽取为全局 @Builder function,所有子组件共享同一个遮罩构建器。遮罩背景色为 rgba(30,10,12,0.55)——暗红黑色调的半透明覆盖,与朱红主色保持视觉协调。这种全局共享构建器的设计,消除了代码重复,是组件复用理念在声明式 UI 中的具体体现。

各 Tab 组件深度分析

今日上新 Tab(NewTab)

今日上新 Tab 是应用的首页,展示本周新上架的汉服商品,支持朝代筛选和租赁确认。

@State 变量定义
@Component
export struct NewTab {
  @State dynastyIdx: number = 0
  @State showRent: boolean = false
  @State showSize: boolean = false
  @State showFav: boolean = false
  @State rentNum: number = 3
  @State favCount: number = 328
  @State isFav: boolean = false
  @State selHanfu: HanfuItem = NEW_HANFU[0]

dynastyIdx 管理朝代筛选的选中索引,showRentshowSizeshowFav 分别控制租赁弹窗、尺码表弹窗和上新提醒弹窗的显隐。rentNum 管理租赁天数,初始值为 3(对应基础租期),范围限制在 1-15 天。selHanfu 存储当前选中的汉服对象引用,作为弹窗数据的来源。

build() 方法关键代码

朝代筛选区域:

Scroll() {
  Row({ space: 8 }) {
    ForEach(DYNASTY_CHIPS, (chip: DynastyChip) => {
      Text(chip.name + ' ' + chip.count)
        .fontSize(12)
        .fontColor(this.dynastyIdx === DYNASTY_CHIPS.indexOf(chip) ? '#FFFFFF' : '#8E0E20')
        .padding({ left: 14, right: 14, top: 7, bottom: 7 })
        .backgroundColor(this.dynastyIdx === DYNASTY_CHIPS.indexOf(chip) ? '#C8102E' : '#FDF1E3')
        .borderRadius(16)
        .onClick(() => {
          this.dynastyIdx = DYNASTY_CHIPS.indexOf(chip)
        })
    }, (chip: DynastyChip) => chip.name)
  }
}
.scrollable(ScrollDirection.Horizontal)

朝代筛选使用横向可滚动的标签条,选中状态为朱红色背景白色文字,未选中为浅米色背景深红色文字。ForEach 的第三个参数提供了 key 生成器 chip.name,确保列表项的高效 diff。注意这里使用了 DYNASTY_CHIPS.indexOf(chip) 而非索引参数来匹配选中状态——这是一种安全的写法,避免了 ForEach 索引与数据索引不一致的问题。

商品列表使用纵向排列的卡片布局,每张卡片包含纯色封面(item.color)、朝代标签、HOT 标签、商品标题、标签云、评分和租赁信息。租赁弹窗以内联方式直接写在 build() 中:

if (this.showRent) {
  modalOverlay(() => { this.showRent = false })
  Column({ space: 14 }) {
    Row() {
      Text('租赁确认')
      Row().layoutWeight(1)
      Text('×').onClick(() => { this.showRent = false })
    }
    Row({ space: 10 }) {
      Column().width(64).height(80).backgroundColor(this.selHanfu.color)
      Column({ space: 4 }) {
        Text(this.selHanfu.title)
        Text(this.selHanfu.dynasty + ' · 含全套配饰')
        Text('押金 ¥200(归还全额退)')
      }
    }
    Row() {
      Text('租赁天数')
      Row().layoutWeight(1)
      Row({ space: 0 }) {
        Text('−').onClick(() => { if (this.rentNum > 1) this.rentNum-- })
        Text(this.rentNum + ' 天')
        Text('+').onClick(() => { if (this.rentNum < 15) this.rentNum++ })
      }
    }
    Row() {
      Text('查看尺码表').onClick(() => { this.showSize = true })
      Row().layoutWeight(1)
      Text('顺丰双向包邮 · 破损险已含')
    }
    Row() {
      Column({ space: 1 }) {
        Text('合计')
        Text('¥' + (this.selHanfu.price * Math.ceil(this.rentNum / 3)))
          .fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C8102E')
      }
      Row().layoutWeight(1)
      Text('确认租赁')
        .linearGradient({ angle: 90, colors: [['#C8102E', 0.0], ['#E8556D', 1.0]] })
    }
  }
  .backgroundColor('#FFF9F0').borderRadius(16).width('92%')
}

租赁弹窗的租赁天数加减器是典型的步进器交互,通过 rentNum > 1rentNum < 15 的条件判断限制操作范围。总价计算使用 this.selHanfu.price * Math.ceil(this.rentNum / 3) 公式——每 3 天为一个计费周期,不足 3 天按 3 天计算。这种"向上取整"的定价策略在租赁行业非常常见。尺码表弹窗通过 showSize 状态嵌套控制,在租赁弹窗内再弹出尺码表,形成了二级弹窗的交互层级。

服饰租赁 Tab(RentTab)

服饰租赁 Tab 展示了全套汉服的租赁信息,包含租金走势图和到店试穿预约。

@Component
export struct RentTab {
  @State filterIdx: number = 0
  @State showTry: boolean = false
  @State showDetail: boolean = false
  @State selItem: HanfuItem = RENT_LIST[0]
  @State tryDate: string = '今天'
  @State trySlot: string = '14:00'

tryDatetrySlot 分别管理试穿预约的日期和时段选择。租金走势柱状图:

Row({ space: 10 }) {
  ForEach(RENT_TREND, (t: RentTrend) => {
    Column({ space: 4 }) {
      Text('¥' + t.price)
        .fontColor(t.price >= 130 ? '#C8102E' : '#A8896A')
      Column()
        .width(18)
        .height(rentBarH(t.price))
        .borderRadius({ topLeft: 4, topRight: 4 })
        .linearGradient({
          angle: 180,
          colors: trendBarColor(t.price) === '#C8102E'
            ? [['#E8556D', 0.0], ['#C8102E', 1.0]]
            : [['#F2D088', 0.0], ['#E8B34B', 1.0]]
        })
      Text(t.day)
        .fontColor('#A8896A')
    }
  }, (t: RentTrend) => t.day)
}

柱状图通过 ForEach 遍历 RENT_TREND 数据渲染,每根柱子使用 rentBarH() 计算高度、trendBarColor() 选择渐变色。柱子顶部圆角(topLeft: 4, topRight: 4)使得视觉更加柔和。价格标签在柱子上方,当价格达到 130 元以上时变为朱红色以突出周末溢价。

试穿预约弹窗包含日期选择和时段选择两部分:

Column({ space: 8 }) {
  Text('选择日期')
  Row({ space: 8 }) {
    ForEach(CALENDAR_DAYS, (d: CalendarDay) => {
      Column({ space: 3 }) {
        Text(d.label)
          .fontColor(this.tryDate === d.date ? '#FFFFFF' : '#5C4A3E')
        Text(d.date)
          .fontColor(this.tryDate === d.date ? '#FFE3C8' : '#A8896A')
      }
      .backgroundColor(this.tryDate === d.date ? '#C8102E' : '#FDF6EA')
      .onClick(() => { this.tryDate = d.date })
    }, (d: CalendarDay) => d.idx.toString())
  }
}

Column({ space: 8 }) {
  Text('选择时段')
  Row({ space: 8 }) {
    ForEach(['10:00', '14:00', '16:00', '19:00'], (slot: string) => {
      Text(slot)
        .fontColor(this.trySlot === slot ? '#FFFFFF' : '#5C4A3E')
        .backgroundColor(this.trySlot === slot ? '#8E0E20' : '#FDF6EA')
        .onClick(() => { this.trySlot = slot })
    }, (slot: string) => slot)
  }
}

日期选择以横排卡片形式展示未来七天,选中日期以朱红色背景高亮。时段选择则使用更深的暗红色(#8E0E20)作为选中背景色,与日期选择形成视觉层次区分。地址信息以纯文字形式展示在弹窗底部。

簪花铺子 Tab(PinTab)

簪花铺子 Tab 展示了传统配饰商品,支持分类筛选和购物车操作。

@Component
export struct PinTab {
  @State catIdx: number = 0
  @State showCart: boolean = false
  @State showCare: boolean = false
  @State buyNum: number = 1
  @State selAcc: AccessoryItem = ACCESSORY_ITEMS[0]

商品布局使用 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现双列网格:

Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
  ForEach(ACCESSORY_ITEMS, (acc: AccessoryItem) => {
    Column({ space: 8 }) {
      Column()
        .width('100%')
        .height(110)
        .backgroundColor(acc.color)
      Column({ space: 4 }) {
        Text(acc.name).maxLines(1)
        Text(acc.material)
        Row() {
          Text('¥' + acc.price).fontColor('#C8102E')
          Row().layoutWeight(1)
          Text('加入购物车')
            .backgroundColor('#8E0E20')
            .onClick(() => {
              this.selAcc = acc
              this.showCart = true
            })
        }
      }
    }
    .width('48.5%')
  }, (acc: AccessoryItem) => acc.id.toString())
}

每张商品卡片宽度为 48.5%,两列排列。购物车弹窗包含商品信息、数量加减器(上限为 selAcc.stock)、定制刻字提示和小计金额。养护贴士弹窗以五条文字列表展示配饰保养知识,是纯信息展示型弹窗的典型实现。

写真预约 Tab(PhotoTab)

写真预约 Tab 是功能最复杂的模块之一,集成了套餐展示、摄影师选择和预约确认。

@Component
export struct PhotoTab {
  @State showBook: boolean = false
  @State showPhoto: boolean = false
  @State showDate: boolean = false
  @State selPkg: PhotoPackage = PHOTO_PACKAGES[0]
  @State selPhotographer: Photographer = PHOTOGRAPHERS[0]
  @State bookDate: string = '8/28'

selPkgselPhotographer 分别管理选中的套餐和摄影师,两者在预约弹窗中联合展示。套餐展示使用横向滚动的卡片列表:

Scroll() {
  Row({ space: 10 }) {
    ForEach(PHOTO_PACKAGES, (pkg: PhotoPackage) => {
      Column({ space: 8 }) {
        Column().width(150).height(96).backgroundColor(pkg.color)
        Column({ space: 4 }) {
          Row({ space: 6 }) {
            Text(pkg.name)
            if (pkg.hot) {
              Text('HOT').backgroundColor('#C8102E')
            }
          }
          Text(pkg.scene)
          Text(pkg.duration + ' · ' + pkg.photos).maxLines(1)
          Row() {
            Text('¥' + pkg.price)
            Text('¥' + pkg.originPrice).decoration({ type: TextDecorationType.LineThrough })
            Row().layoutWeight(1)
            Text('预约')
              .backgroundColor('#C8102E')
              .onClick(() => {
                this.selPkg = pkg
                this.showBook = true
              })
          }
        }
        .width(150)
      }
    }, (pkg: PhotoPackage) => pkg.id.toString())
  }
}
.scrollable(ScrollDirection.Horizontal)

摄影师列表使用纵向排列的卡片,每张卡片包含圆形头像、姓名、从业年限标签、风格描述、评分、作品数量和"选 TA"按钮。预约弹窗综合展示了套餐信息、摄影师信息和拍摄日期选择,日期卡片还展示了剩余可约数量(d.left),当 left === 0 时以灰色禁用样式展示。

订单 Tab(OrderTab)

订单 Tab 展示了租赁和写真订单的历史记录,支持按状态筛选和多种操作。

@Component
export struct OrderTab {
  @State filterIdx: number = 0
  @State showDetail: boolean = false
  @State showRefund: boolean = false
  @State showRenew: boolean = false
  @State selOrder: OrderRecord = ORDER_LIST[0]

订单卡片的操作按钮根据状态动态展示:

Row({ space: 8 }) {
  Row().layoutWeight(1)
  Text('订单详情')
    .border({ width: 1, color: '#E3D5C2' })
    .onClick(() => { this.selOrder = o; this.showDetail = true })
  if (o.status === '租赁中') {
    Text('续租')
      .border({ width: 1, color: '#E8B34B' })
      .onClick(() => { this.selOrder = o; this.showRenew = true })
  }
  if (o.status === '待取片' || o.status === '租赁中') {
    Text('申请退订')
      .backgroundColor('#C8102E')
      .onClick(() => { this.selOrder = o; this.showRefund = true })
  }
}

续租弹窗提供了三种续租方案(+1 天、+3 天、+7 天),价格通过条件表达式动态计算:

ForEach(['+1 天', '+3 天', '+7 天'], (d: string) => {
  Text(d + ' ¥' + (this.selOrder.amount * (d === '+1 天' ? 0.4 : (d === '+3 天' ? 1 : 2))).toFixed(0))
})

退订弹窗展示了退订规则(24 小时前全免、24 小时内收 30% 违约金、已使用不可退),让用户在确认前充分了解退订政策。

我的 Tab(MineTab)

我的 Tab 是个人中心,集成了会员信息、积分明细、优惠券、收货地址 CRUD 和会员特权。

@Component
export struct MineTab {
  @State showCoupon: boolean = false
  @State showPoint: boolean = false
  @State showAddr: boolean = false
  @State showEditAddr: boolean = false
  @State showDelAddr: boolean = false
  @State showVip: boolean = false
  @State inputName: string = ''
  @State inputPhone: string = ''
  @State inputDetail: string = ''
  @State editIdx: number = -1
  @State delIdx: number = -1
  @State addrList: AddrMeta[] = [
    { id: 1, name: '沈知意', phone: '138****6621', region: '四川 成都', detail: '锦江区青羊坊 12 号院 3 栋 502', tag: '默认' },
    { id: 2, name: '沈知意', phone: '138****6621', region: '四川 成都', detail: '高新区天府三街新希望大厦 B 座 21F', tag: '公司' }
  ]

addrList 是一个可变状态数组,支持完整的增删改查操作。三个方法 saveAddr()updateAddr()removeAddr() 实现了地址的 CRUD:

saveAddr() {
  const r: AddrMeta[] = []
  for (let i = 0; i < this.addrList.length; i++) {
    r.push(this.addrList[i])
  }
  r.push({
    id: this.addrList.length + 1,
    name: this.inputName === '' ? '沈知意' : this.inputName,
    phone: this.inputPhone === '' ? '138****6621' : this.inputPhone,
    region: '四川 成都',
    detail: this.inputDetail === '' ? '锦江区春熙路 88 号' : this.inputDetail,
    tag: '家'
  })
  this.addrList = r
  this.showAddr = false
}

updateAddr() {
  const r: AddrMeta[] = []
  for (let i = 0; i < this.addrList.length; i++) {
    if (i === this.editIdx) {
      r.push({
        id: this.addrList[i].id,
        name: this.inputName === '' ? this.addrList[i].name : this.inputName,
        phone: this.inputPhone === '' ? this.addrList[i].phone : this.inputPhone,
        region: this.addrList[i].region,
        detail: this.inputDetail === '' ? this.addrList[i].detail : this.inputDetail,
        tag: this.addrList[i].tag
      })
    } else {
      r.push(this.addrList[i])
    }
  }
  this.addrList = r
  this.showEditAddr = false
}

removeAddr() {
  const r: AddrMeta[] = []
  for (let i = 0; i < this.addrList.length; i++) {
    if (i !== this.delIdx) {
      r.push(this.addrList[i])
    }
  }
  this.addrList = r
  this.showDelAddr = false
}

三个方法都采用了不可变更新模式——创建新数组、遍历过滤或修改、整体赋值给 this.addrListsaveAddr 通过 push 在末尾追加新地址,updateAddr 通过 editIdx 匹配修改项,removeAddr 通过 delIdx 排除删除项。空输入时使用默认值或保留原值的回退策略,提升了用户体验。

会员特权弹窗使用朱红到鎏金的渐变背景,与主入口头部保持视觉一致性:

if (this.showVip) {
  modalOverlay(() => { this.showVip = false })
  Column({ space: 12 }) {
    Text('金穗会员特权').fontColor('#FFFFFF')
    Column({ space: 8 }) {
      Text('· 全场租金 95 折').fontColor('#FFE3C8')
      Text('· 生日当月免 1 次押金').fontColor('#FFE3C8')
      Text('· 新品优先租(提前 24h)').fontColor('#FFE3C8')
      Text('· 写真跟拍 9 折').fontColor('#FFE3C8')
    }
    .backgroundColor('rgba(142,14,32,0.5)')
    Text('知道了').backgroundColor('#F2D088')
  }
  .linearGradient({ angle: 135, colors: [['#C8102E', 0.0], ['#E8B34B', 1.0]] })
}

主入口页面分析

@Entry
@Component
struct HanfuPage {
  @State activeTab: number = 0
  @State showSearch: boolean = false
  @State showMsg: boolean = false
  @State searchKey: string = ''
  @State unRead: number = 2

  build() {
    Column() {
      Column({ space: 0 }) {
        Row({ space: 10 }) {
          Text('青羊坊')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Row({ space: 6 }) {
            Text('🔍').fontSize(13)
            TextInput({ placeholder: '马面裙 / 绒花 / 唐制写真' })
              .fontSize(12)
              .height(34)
              .layoutWeight(1)
              .backgroundColor('#FBE9EA')
              .borderRadius(17)
              .onChange((v: string) => { this.searchKey = v })
          }
          .layoutWeight(1)
          .backgroundColor('#FFFFFF')
          .borderRadius(17)

          Stack({ alignContent: Alignment.TopEnd }) {
            Text('✉').fontColor('#FFFFFF')
            if (this.unRead > 0) {
              Column()
                .width(8)
                .height(8)
                .borderRadius(4)
                .backgroundColor('#E8B34B')
            }
          }
          .onClick(() => { this.showMsg = true })
        }
        .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      }
      .linearGradient({ angle: 135, colors: [['#C8102E', 0.0], ['#8E0E20', 1.0]] })

      if (this.activeTab === 0) {
        NewTab()
      } else if (this.activeTab === 1) {
        RentTab()
      } else if (this.activeTab === 2) {
        PinTab()
      } else if (this.activeTab === 3) {
        PhotoTab()
      } else if (this.activeTab === 4) {
        OrderTab()
      } else {
        MineTab()
      }

      Row({ space: 0 }) {
        ForEach(MAIN_TABS, (tab: TabMeta, idx: number) => {
          Column({ space: 3 }) {
            Text(tab.icon)
              .fontColor(this.activeTab === idx ? '#C8102E' : '#B08A5A')
            Text(tab.name)
              .fontColor(this.activeTab === idx ? '#C8102E' : '#B08A5A')
            if (this.activeTab === idx) {
              Column()
                .width(16).height(3).borderRadius(2)
                .backgroundColor('#C8102E')
            } else {
              Column()
                .width(16).height(3).borderRadius(2)
                .backgroundColor('rgba(0,0,0,0)')
            }
          }
          .layoutWeight(1)
          .onClick(() => { this.activeTab = idx })
        }, (tab: TabMeta, idx: number) => (tab.name + idx).toString())
      }
      .backgroundColor('#FFFFFF')

      if (this.showSearch) { /* 搜索弹窗 */ }
      if (this.showMsg) {
        modalOverlay(() => { this.showMsg = false; this.unRead = 0 })
        Column({ space: 12 }) {
          Row() {
            Text('消息中心')
            Row().layoutWeight(1)
            Text('全部已读').onClick(() => { this.unRead = 0 })
          }
          ForEach(MSG_LIST, (m: MsgMeta) => {
            Row({ space: 10 }) {
              Column({ space: 2 }) {
                Text(m.title)
                Text(m.content).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .layoutWeight(1)
              Column({ space: 4 }) {
                Text(m.time)
                if (!m.read) {
                  Column().width(7).height(7).backgroundColor('#C8102E')
                }
              }
            }
          }, (m: MsgMeta) => m.id.toString())
        }
        .backgroundColor('#FFF9F0').borderRadius(16).width('92%')
      }
    }
    .backgroundColor('#FFF7EC')
  }
}

主入口 HanfuPagebuild() 方法结构与游戏充值中心类似,但有几个关键差异。第一,头部区域直接内嵌了 TextInput 搜索框(而非点击弹出搜索弹窗),用户可以直接在头部输入关键词。第二,消息铃铛使用 unRead 数字状态管理未读数量,点击消息弹窗后自动清零。第三,底部导航栏的选中指示器使用条件渲染(if-else)而非透明色覆盖,选中 Tab 显示朱红色指示条,未选中则完全不渲染指示条。第四,ForEach 的 key 生成器使用 (tab.name + idx).toString(),确保即使两个 Tab 名称相同也能保持唯一 key。

消息弹窗中的未读红点根据 m.read 布尔值条件渲染——未读消息右侧显示一个 7x7 像素的朱红色圆点,已读消息则无此标记。"全部已读"按钮点击后将 unRead 设为 0,同时消息弹窗的遮罩回调也将 unRead 清零。

流程图

应用架构流程图

activeTab=0

activeTab=1

activeTab=2

activeTab=3

activeTab=4

activeTab=5

HanfuPage 主入口

头部朱红渐变区

内容区条件渲染

底部导航栏

青羊坊品牌名

内嵌 TextInput 搜索框

消息信封 + unRead 红点

NewTab 今日上新

RentTab 服饰租赁

PinTab 簪花铺子

PhotoTab 写真预约

OrderTab 订单

MineTab 我的

朝代筛选 DynastyChips

租赁确认弹窗 - 内联

尺码表弹窗 - 二级嵌套

租金走势柱状图

试穿预约弹窗 - 日期+时段

套餐横滑列表

摄影师列表

预约弹窗 - 套餐+摄影师+日期

地址列表 CRUD

优惠券弹窗

积分兑换弹窗

金穗会员特权弹窗

6 Tab 文字图标 ForEach

选中指示条 - 条件渲染

弹框交互流程图

朝代筛选

立即租赁

预约试穿

写真预约

地址管理

新增

编辑

删除

查看消息

用户操作

选择操作类型

点击朝代标签

更新 dynastyIdx

UI 重渲染商品列表

弹出租赁弹窗 - 内联

调整租赁天数 加减器

计算总价 = price * ceil rentNum/3

查看尺码表 - 二级弹窗

关闭尺码表

确认租赁

关闭 showRent

弹出试穿弹窗

选择日期 - 日历卡片

选择时段 - 10/14/16/19点

提交预约

关闭 showTry

弹出预约弹窗

选择拍摄日期

显示剩余可约数

立即预约

关闭 showBook

弹出地址列表

选择操作

调用 saveAddr 方法

弹出编辑弹窗

弹出删除确认弹窗

调用 updateAddr 方法

调用 removeAddr 方法

创建新数组 push 新地址

创建新数组 替换匹配项

创建新数组 过滤删除项

赋值 addrList

关闭弹窗

弹出消息中心

unRead 清零

技术对比表格

技术维度 今日上新 服饰租赁 簪花铺子 写真预约 订单 我的
核心交互 朝代筛选+租赁确认 走势图+试穿预约 分类筛选+购物车 套餐+摄影师+预约 筛选+续租+退订 地址CRUD+特权
状态变量数 8 6 5 6 5 12
弹窗数 3 (租赁/尺码/提醒) 2 (试穿/详情) 2 (购物车/养护) 3 (预约/客片/档期) 3 (详情/退订/续租) 6 (券/积分/地址/编辑/删除/VIP)
弹窗实现方式 内联在 build() 中 内联在 build() 中 内联在 build() 中 内联在 build() 中 内联在 build() 中 内联在 build() 中
二级弹窗 有 (尺码表嵌套租赁) 有 (编辑/删除嵌套地址)
数据更新模式 纯读取 纯读取 纯读取 纯读取 纯读取 不可变数组CRUD
特色组件 朝代筛选横滑 租金柱状图 Flex双列网格 套餐横滑+摄影师列表 续租价格计算 地址CRUD全流程
全局函数调用 rentBarH/trendBarColor
export 修饰
遮罩层来源 全局 modalOverlay 全局 modalOverlay 全局 modalOverlay 全局 modalOverlay 全局 modalOverlay 全局 modalOverlay

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// 主题:朱红 #C8102E × 鎏金 #E8B34B,底色 #FFF7EC,国风新中式
// 6 个底部 Tab:今日上新 / 服饰租赁 / 簪花铺子 / 写真预约 / 订单 / 我的

interface DynastyChip {
  name: string
  count: number
}

interface HanfuItem {
  id: number
  title: string
  dynasty: string
  price: number
  originPrice: number
  rentDay: number
  score: number
  rentCount: number
  tags: string[]
  hot: boolean
  color: string
}

interface RentTrend {
  day: string
  price: number
}

interface SizeRow {
  size: string
  height: string
  bust: string
  stock: number
}

interface AccessoryItem {
  id: number
  name: string
  material: string
  price: number
  stock: number
  grade: string
  color: string
}

interface PhotoPackage {
  id: number
  name: string
  scene: string
  price: number
  originPrice: number
  duration: string
  photos: string
  retouch: string
  hot: boolean
  color: string
}

interface Photographer {
  id: number
  name: string
  years: number
  style: string
  price: number
  works: number
  rating: number
}

interface CalendarDay {
  idx: number
  label: string
  date: string
  left: number
}

interface OrderRecord {
  id: string
  item: string
  cover: string
  date: string
  days: number
  status: string
  statusColor: string
  amount: number
  deposit: number
}

interface CouponMeta {
  id: number
  title: string
  amount: number
  condition: string
  expire: string
  color: string
}

interface PointLog {
  id: number
  title: string
  delta: number
  date: string
}

interface AddrMeta {
  id: number
  name: string
  phone: string
  region: string
  detail: string
  tag: string
}

interface TabMeta {
  name: string
  icon: string
}

interface MsgMeta {
  id: number
  title: string
  content: string
  time: string
  read: boolean
}

const DYNASTY_CHIPS: DynastyChip[] = [
  { name: '全部朝代', count: 286 },
  { name: '唐制', count: 92 },
  { name: '宋制', count: 74 },
  { name: '明制', count: 68 },
  { name: '魏晋', count: 31 },
  { name: '清汉女', count: 21 }
]

const NEW_HANFU: HanfuItem[] = [
  { id: 1, title: '齐胸襦裙·烟霞染', dynasty: '唐制', price: 88, originPrice: 158, rentDay: 3, score: 4.9, rentCount: 2317, tags: ['新品首发', '含发饰', '飘逸'], hot: true, color: '#F5C8D0' },
  { id: 2, title: '马面裙·山河绣', dynasty: '明制', price: 68, originPrice: 128, rentDay: 3, score: 4.8, rentCount: 4521, tags: ['爆款返场', '织金'], hot: true, color: '#D9B382' },
  { id: 3, title: '褙子·竹影青', dynasty: '宋制', price: 56, originPrice: 99, rentDay: 3, score: 4.7, rentCount: 1876, tags: ['清冷感', '含披帛'], hot: false, color: '#BFD8C2' },
  { id: 4, title: '大袖衫·流云白', dynasty: '魏晋', price: 96, originPrice: 176, rentDay: 3, score: 4.9, rentCount: 1203, tags: ['仙气', '写真首选'], hot: true, color: '#EDE7DA' },
  { id: 5, title: '袄裙·海棠红', dynasty: '明制', price: 72, originPrice: 138, rentDay: 3, score: 4.6, rentCount: 986, tags: ['新年款', '拜年服'], hot: false, color: '#E39A94' },
  { id: 6, title: '坦领·葡萄紫', dynasty: '唐制', price: 62, originPrice: 118, rentDay: 3, score: 4.7, rentCount: 1532, tags: ['显白', '含义髻'], hot: false, color: '#C7A6D9' }
]

const RENT_LIST: HanfuItem[] = [
  { id: 101, title: '全套唐制·花朝宴', dynasty: '唐制', price: 128, originPrice: 228, rentDay: 3, score: 4.9, rentCount: 3216, tags: ['全套含鞋', '送造型'], hot: true, color: '#F2B8C6' },
  { id: 102, title: '全套宋制·雨过天青', dynasty: '宋制', price: 108, originPrice: 198, rentDay: 3, score: 4.8, rentCount: 2054, tags: ['全套含冠', '通勤可穿'], hot: false, color: '#AEC8C2' },
  { id: 103, title: '全套明制·鸾凤和鸣', dynasty: '明制', price: 158, originPrice: 288, rentDay: 3, score: 5.0, rentCount: 4102, tags: ['婚礼级', '含凤冠'], hot: true, color: '#D9826C' },
  { id: 104, title: '全套魏晋·谪仙引', dynasty: '魏晋', price: 136, originPrice: 246, rentDay: 3, score: 4.8, rentCount: 1677, tags: ['大袖', '武侠感'], hot: false, color: '#C9CBB8' },
  { id: 105, title: '男士圆领袍·凌云志', dynasty: '唐制', price: 78, originPrice: 148, rentDay: 3, score: 4.7, rentCount: 1390, tags: ['情侣款', '含幞头'], hot: true, color: '#8FA3C4' },
  { id: 106, title: '亲子装·小锦鲤', dynasty: '明制', price: 92, originPrice: 168, rentDay: 3, score: 4.9, rentCount: 2208, tags: ['亲子', '全家福'], hot: false, color: '#F4B26A' }
]

const RENT_TREND: RentTrend[] = [
  { day: '周一', price: 96 },
  { day: '周二', price: 78 },
  { day: '周三', price: 72 },
  { day: '周四', price: 88 },
  { day: '周五', price: 138 },
  { day: '周六', price: 168 },
  { day: '周日', price: 152 }
]

const SIZE_ROWS: SizeRow[] = [
  { size: 'XS', height: '150-158cm', bust: '80-84cm', stock: 12 },
  { size: 'S', height: '158-163cm', bust: '84-88cm', stock: 26 },
  { size: 'M', height: '163-168cm', bust: '88-92cm', stock: 31 },
  { size: 'L', height: '168-173cm', bust: '92-96cm', stock: 18 },
  { size: 'XL', height: '173-178cm', bust: '96-100cm', stock: 7 }
]

const ACCESSORY_ITEMS: AccessoryItem[] = [
  { id: 1, name: '鎏金发簪·并蒂莲', material: '铜镀金·珍珠', price: 39, stock: 126, grade: '匠心工艺', color: '#E8B34B' },
  { id: 2, name: '绒花发钗·石榴红', material: '非遗绒花', price: 45, stock: 88, grade: '非遗联名', color: '#D94F5C' },
  { id: 3, name: '缠花步摇·青竹雪', material: '手作缠花', price: 52, stock: 64, grade: '手作限定', color: '#9CBF9E' },
  { id: 4, name: '璎珞项圈·流苏月', material: '合金·仿玉', price: 36, stock: 152, grade: '人气爆款', color: '#C7A6D9' },
  { id: 5, name: '禁步玉佩·云纹佩', material: '岫玉·流苏', price: 58, stock: 43, grade: '孤品', color: '#A8C8D8' },
  { id: 6, name: '折扇·墨梅图', material: '竹骨·绢面', price: 29, stock: 210, grade: '写真道具', color: '#E5DCC8' },
  { id: 7, name: '油纸伞·烟雨蓝', material: '棉纸·竹柄', price: 48, stock: 97, grade: '道具人气', color: '#8FA8C8' },
  { id: 8, name: '披帛·云雾绡', material: '真丝绡', price: 32, stock: 178, grade: '租赁随赠', color: '#F2C9D4' }
]

const ACCESSORY_CATS: string[] = ['全部', '发饰', '璎珞', '道具', '团扇', '鞋履']

const PHOTO_PACKAGES: PhotoPackage[] = [
  { id: 1, name: '单人国风写真', scene: '园林实景 3 选 1', price: 399, originPrice: 699, duration: '120 分钟', photos: '精修 9 张 + 底片全送', retouch: '轻奢精修', hot: true, color: '#F5C8D0' },
  { id: 2, name: '闺蜜双人写真', scene: '花墙 + 茶室双景', price: 599, originPrice: 1099, duration: '150 分钟', photos: '精修 18 张', retouch: '杂志级修图', hot: true, color: '#E8B34B' },
  { id: 3, name: '情侣汉服婚照', scene: '中式庭院烛光', price: 1299, originPrice: 2399, duration: '240 分钟', photos: '精修 30 张 + 相册', retouch: '电影级调色', hot: false, color: '#D9826C' },
  { id: 4, name: '亲子全家福', scene: '古风街景全家福', price: 699, originPrice: 1299, duration: '180 分钟', photos: '精修 20 张 + 摆台', retouch: '温馨影调', hot: false, color: '#F4B26A' }
]

const PHOTOGRAPHERS: Photographer[] = [
  { id: 1, name: '阿棠', years: 8, style: '电影感·情绪光影', price: 200, works: 1860, rating: 4.9 },
  { id: 2, name: '鹿鸣', years: 6, style: '清冷宋制·留白美学', price: 160, works: 1240, rating: 4.8 },
  { id: 3, name: '陈拾', years: 5, style: '浓颜唐风·华丽构图', price: 180, works: 980, rating: 4.7 },
  { id: 4, name: '小满', years: 4, style: '日系汉服·自然光', price: 120, works: 760, rating: 4.8 }
]

const CALENDAR_DAYS: CalendarDay[] = [
  { idx: 0, label: '今天', date: '8/24', left: 6 },
  { idx: 1, label: '明天', date: '8/25', left: 12 },
  { idx: 2, label: '周四', date: '8/26', left: 9 },
  { idx: 3, label: '周五', date: '8/27', left: 3 },
  { idx: 4, label: '周六', date: '8/28', left: 0 },
  { idx: 5, label: '周日', date: '8/29', left: 2 },
  { idx: 6, label: '周一', date: '8/30', left: 14 }
]

const ORDER_LIST: OrderRecord[] = [
  { id: 'HF2026082301', item: '全套唐制·花朝宴', cover: '#F2B8C6', date: '08/23 - 08/25', days: 3, status: '租赁中', statusColor: '#C8102E', amount: 128, deposit: 200 },
  { id: 'HF2026081802', item: '马面裙·山河绣', cover: '#D9B382', date: '08/18 - 08/20', days: 3, status: '已归还', statusColor: '#5C7B8A', amount: 68, deposit: 100 },
  { id: 'HF2026081003', item: '写真·单人国风', cover: '#F5C8D0', date: '08/10 拍摄', days: 1, status: '待取片', statusColor: '#E8B34B', amount: 399, deposit: 0 },
  { id: 'HF2026080204', item: '簪花发钗·石榴红', cover: '#D94F5C', date: '08/02 购买', days: 1, status: '已完成', statusColor: '#5C7B8A', amount: 45, deposit: 0 },
  { id: 'HF2026072805', item: '全套魏晋·谪仙引', cover: '#C9CBB8', date: '07/28 - 07/30', days: 3, status: '退款成功', statusColor: '#8FA3C4', amount: 136, deposit: 200 }
]

const ORDER_FILTERS: string[] = ['全部', '租赁中', '待取片', '已归还', '退款']

const COUPON_LIST: CouponMeta[] = [
  { id: 1, title: '汉服租赁立减券', amount: 30, condition: '满 128 可用', expire: '09/30 前有效', color: '#C8102E' },
  { id: 2, title: '写真定金膨胀券', amount: 100, condition: '满 399 可用', expire: '10/15 前有效', color: '#E8B34B' },
  { id: 3, title: '簪花铺子满赠券', amount: 10, condition: '满 39 可用', expire: '08/31 前有效', color: '#D94F5C' },
  { id: 4, title: '新人首单 5 折券', amount: 20, condition: '无门槛', expire: '09/15 前有效', color: '#8E0E20' }
]

const POINT_LOGS: PointLog[] = [
  { id: 1, title: '归还加分·衣物完好', delta: 20, date: '08/25 10:32' },
  { id: 2, title: '签到得积分', delta: 5, date: '08/24 09:00' },
  { id: 3, title: '写真订单返积分', delta: 120, date: '08/10 18:20' },
  { id: 4, title: '积分兑换发簪', delta: -200, date: '08/02 12:41' },
  { id: 5, title: '评价晒图奖励', delta: 15, date: '08/01 21:05' }
]

const MAIN_TABS: TabMeta[] = [
  { name: '今日上新', icon: '上新' },
  { name: '服饰租赁', icon: '租赁' },
  { name: '簪花铺子', icon: '簪花' },
  { name: '写真预约', icon: '写真' },
  { name: '订单', icon: '订单' },
  { name: '我的', icon: '我的' }
]

const MSG_LIST: MsgMeta[] = [
  { id: 1, title: '租赁提醒', content: '您的「花朝宴」还有 1 天到期,顺丰上门取件已预约', time: '10:12', read: false },
  { id: 2, title: '写真取片', content: '您的 9 张精修已上传云端,有效期 30 天', time: '昨天', read: false },
  { id: 3, title: '优惠券到账', content: '中秋档租赁 8 折券已发放,先囤后约', time: '昨天', read: true },
  { id: 4, title: '上新预告', content: '「宋制百迭裙·暮山紫」周五 10 点首发', time: '08/22', read: true },
  { id: 5, title: '积分变动', content: '归还加 20 分,当前 1240 分', time: '08/20', read: true },
  { id: 6, title: '会员升级', content: '再租 1 单即升级「金穗会员」,享免押金', time: '08/18', read: true }
]

function rentBarH(p: number): number {
  if (p >= 160) {
    return 84
  }
  if (p >= 130) {
    return 66
  }
  if (p >= 90) {
    return 46
  }
  return 28
}

function trendBarColor(p: number): string {
  if (p >= 130) {
    return '#C8102E'
  }
  return '#E8B34B'
}

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

// ==================== Tab 1 今日上新 ====================
@Component
export struct NewTab {
  @State dynastyIdx: number = 0
  @State showRent: boolean = false
  @State showSize: boolean = false
  @State showFav: boolean = false
  @State rentNum: number = 3
  @State favCount: number = 328
  @State isFav: boolean = false
  @State selHanfu: HanfuItem = NEW_HANFU[0]

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row() {
          Column({ space: 2 }) {
            Text('国风上新 · 每周五 10 点')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8E0E20')
            Text('本周上新 12 套 · 免押金租 3 日起')
              .fontSize(11)
              .fontColor('#A8896A')
          }
          .alignItems(HorizontalAlign.Start)

          Row()
            .layoutWeight(1)

          Text('上新日历')
            .fontSize(12)
            .fontColor('#C8102E')
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .backgroundColor('#FBE9EA')
            .borderRadius(14)
            .onClick(() => {
              this.showFav = true
            })
        }
        .width('100%')

        Scroll() {
          Row({ space: 8 }) {
            ForEach(DYNASTY_CHIPS, (chip: DynastyChip) => {
              Text(chip.name + ' ' + chip.count)
                .fontSize(12)
                .fontColor(this.dynastyIdx === DYNASTY_CHIPS.indexOf(chip) ? '#FFFFFF' : '#8E0E20')
                .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                .backgroundColor(this.dynastyIdx === DYNASTY_CHIPS.indexOf(chip) ? '#C8102E' : '#FDF1E3')
                .borderRadius(16)
                .onClick(() => {
                  this.dynastyIdx = DYNASTY_CHIPS.indexOf(chip)
                })
            }, (chip: DynastyChip) => chip.name)
          }
        }
        .scrollable(ScrollDirection.Horizontal)
        .width('100%')

        Row({ space: 10 }) {
          Column({ space: 6 }) {
            Text('限时 5 折起')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('唐制专场')
              .fontSize(11)
              .fontColor('#FFE3C8')
          }
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .borderRadius(12)
          .linearGradient({
            angle: 135,
            colors: [['#C8102E', 0.0], ['#8E0E20', 1.0]]
          })
          .layoutWeight(1)
          .height(84)
          .onClick(() => {
            this.dynastyIdx = 1
          })

          Column({ space: 6 }) {
            Text('新人 0 元')
              .fontSize(15)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8E0E20')
            Text('簪花随租送')
              .fontSize(11)
              .fontColor('#A8896A')
          }
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#FBF0DB')
          .border({ width: 1, color: '#E8B34B' })
          .layoutWeight(1)
          .height(84)
          .onClick(() => {
            this.showFav = true
          })
        }
        .width('100%')

        Row() {
          Text('本周爆款榜')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2A22')
          Row()
            .layoutWeight(1)
          Text('按租赁次数')
            .fontSize(11)
            .fontColor('#A8896A')
        }
        .width('100%')

        ForEach(NEW_HANFU, (item: HanfuItem) => {
          Row({ space: 12 }) {
            Column()
              .width(96)
              .height(120)
              .borderRadius(10)
              .backgroundColor(item.color)

            Column({ space: 6 }) {
              Row({ space: 6 }) {
                Text(item.dynasty)
                  .fontSize(10)
                  .fontColor('#FFFFFF')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor('#C8102E')
                  .borderRadius(4)
                if (item.hot) {
                  Text('HOT')
                    .fontSize(10)
                    .fontColor('#8E0E20')
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .backgroundColor('#FBF0DB')
                    .borderRadius(4)
                }
              }

              Text(item.title)
                .fontSize(14)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3A2A22')
                .maxLines(1)
                .textOverflow({ overflow: TextOverflow.Ellipsis })

              Row({ space: 6 }) {
                ForEach(item.tags, (tag: string) => {
                  Text(tag)
                    .fontSize(10)
                    .fontColor('#B08A5A')
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .backgroundColor('#FDF6EA')
                    .borderRadius(4)
                }, (tag: string) => tag)
              }

              Row({ space: 8 }) {
                Text('★ ' + item.score.toFixed(1))
                  .fontSize(11)
                  .fontColor('#E8B34B')
                Text(item.rentCount + ' 次租')
                  .fontSize(11)
                  .fontColor('#A8896A')
              }

              Row() {
                Column({ space: 1 }) {
                  Row({ space: 3 }) {
                    Text('¥' + item.price)
                      .fontSize(17)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#C8102E')
                    Text('/' + item.rentDay + '天')
                      .fontSize(10)
                      .fontColor('#A8896A')
                  }
                  Text('¥' + item.originPrice)
                    .fontSize(10)
                    .fontColor('#CBB9A4')
                    .decoration({ type: TextDecorationType.LineThrough })
                }
                .alignItems(HorizontalAlign.Start)

                Row()
                  .layoutWeight(1)

                Text('立即租')
                  .fontSize(12)
                  .fontColor('#FFFFFF')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(15)
                  .linearGradient({
                    angle: 90,
                    colors: [['#C8102E', 0.0], ['#E8556D', 1.0]]
                  })
                  .onClick(() => {
                    this.selHanfu = item
                    this.showRent = true
                  })
              }
              .width('100%')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
          .shadow({
            radius: 8,
            color: 'rgba(200,16,46,0.06)',
            offsetX: 0,
            offsetY: 2
          })
        }, (item: HanfuItem) => item.id.toString())

        if (this.showRent) {
          modalOverlay(() => {
            this.showRent = false
          })
          Column({ space: 14 }) {
            Row() {
              Text('租赁确认')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3A2A22')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#A8896A')
                .onClick(() => {
                  this.showRent = false
                })
            }
            .width('100%')

            Row({ space: 10 }) {
              Column()
                .width(64)
                .height(80)
                .borderRadius(8)
                .backgroundColor(this.selHanfu.color)
              Column({ space: 4 }) {
                Text(this.selHanfu.title)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3A2A22')
                Text(this.selHanfu.dynasty + ' · 含全套配饰')
                  .fontSize(11)
                  .fontColor('#A8896A')
                Text('押金 ¥200(归还全额退)')
                  .fontSize(11)
                  .fontColor('#B08A5A')
              }
              .alignItems(HorizontalAlign.Start)
            }
            .width('100%')

            Row() {
              Text('租赁天数')
                .fontSize(13)
                .fontColor('#5C4A3E')
              Row()
                .layoutWeight(1)
              Row({ space: 0 }) {
                Text('−')
                  .fontSize(16)
                  .fontColor(this.rentNum <= 1 ? '#D8C9B8' : '#C8102E')
                  .padding(8)
                  .onClick(() => {
                    if (this.rentNum > 1) {
                      this.rentNum--
                    }
                  })
                Text(this.rentNum + ' 天')
                  .fontSize(13)
                  .fontColor('#3A2A22')
                  .padding({ left: 10, right: 10 })
                Text('+')
                  .fontSize(16)
                  .fontColor('#C8102E')
                  .padding(8)
                  .onClick(() => {
                    if (this.rentNum < 15) {
                      this.rentNum++
                    }
                  })
              }
              .borderRadius(8)
              .backgroundColor('#FDF6EA')
            }
            .width('100%')

            Row() {
              Text('查看尺码表')
                .fontSize(12)
                .fontColor('#C8102E')
                .onClick(() => {
                  this.showSize = true
                })
              Row()
                .layoutWeight(1)
              Text('顺丰双向包邮 · 破损险已含')
                .fontSize(11)
                .fontColor('#A8896A')
            }
            .width('100%')

            Row() {
              Column({ space: 1 }) {
                Text('合计')
                  .fontSize(11)
                  .fontColor('#A8896A')
                Text('¥' + (this.selHanfu.price * Math.ceil(this.rentNum / 3)))
                  .fontSize(20)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#C8102E')
              }
              .alignItems(HorizontalAlign.Start)

              Row()
                .layoutWeight(1)

              Text('确认租赁')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 22, right: 22, top: 9, bottom: 9 })
                .borderRadius(18)
                .linearGradient({
                  angle: 90,
                  colors: [['#C8102E', 0.0], ['#E8556D', 1.0]]
                })
                .onClick(() => {
                  this.showRent = false
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .constraintSize({ maxHeight: '80%' })
          .width('92%')
        }

        if (this.showSize) {
          modalOverlay(() => {
            this.showSize = false
          })
          Column({ space: 12 }) {
            Row() {
              Text('尺码表(女款)')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3A2A22')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#A8896A')
                .onClick(() => {
                  this.showSize = false
                })
            }
            .width('100%')

            ForEach(SIZE_ROWS, (row: SizeRow) => {
              Row() {
                Text(row.size)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#C8102E')
                  .width(48)
                Text(row.height)
                  .fontSize(12)
                  .fontColor('#5C4A3E')
                  .layoutWeight(1)
                Text(row.bust)
                  .fontSize(12)
                  .fontColor('#5C4A3E')
                  .layoutWeight(1)
                Text(row.stock > 0 ? '可租 ' + row.stock + ' 件' : '已约满')
                  .fontSize(11)
                  .fontColor(row.stock > 0 ? '#B08A5A' : '#D8C9B8')
              }
              .width('100%')
              .padding({ top: 8, bottom: 8 })
              .borderRadius(8)
              .backgroundColor(row.size === 'M' ? '#FDF1E3' : '#FFFFFF')
            }, (row: SizeRow) => row.size)

            Text('腰围超标可加钱定制裙长,联系客服备注即可')
              .fontSize(11)
              .fontColor('#A8896A')
              .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .constraintSize({ maxHeight: '70%' })
          .width('88%')
        }

        if (this.showFav) {
          modalOverlay(() => {
            this.showFav = false
          })
          Column({ space: 12 }) {
            Text('上新提醒已开启')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2A22')
            Text('周五 10 点首发提醒会推送到消息中心\n首发前 30 分钟下单再减 ¥10')
              .fontSize(12)
              .fontColor('#A8896A')
              .textAlign(TextAlign.Center)
            Row({ space: 10 }) {
              Text('知道了')
                .fontSize(13)
                .fontColor('#8E0E20')
                .padding({ left: 22, right: 22, top: 9, bottom: 9 })
                .borderRadius(18)
                .backgroundColor('#FDF1E3')
                .onClick(() => {
                  this.showFav = false
                })
            }
          }
          .padding(22)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .alignItems(HorizontalAlign.Center)
          .width('76%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#FFF7EC')
    .layoutWeight(1)
  }
}

// ==================== Tab 2 服饰租赁 ====================
@Component
export struct RentTab {
  @State filterIdx: number = 0
  @State showTry: boolean = false
  @State showDetail: boolean = false
  @State selItem: HanfuItem = RENT_LIST[0]
  @State tryDate: string = '今天'
  @State trySlot: string = '14:00'

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 10 }) {
          Row() {
            Text('本周租金行情(元/3天)')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2A22')
            Row()
              .layoutWeight(1)
            Text('周末溢价')
              .fontSize(10)
              .fontColor('#C8102E')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor('#FBE9EA')
              .borderRadius(4)
          }
          .width('100%')

          Row({ space: 10 }) {
            ForEach(RENT_TREND, (t: RentTrend) => {
              Column({ space: 4 }) {
                Text('¥' + t.price)
                  .fontSize(9)
                  .fontColor(t.price >= 130 ? '#C8102E' : '#A8896A')
                Column()
                  .width(18)
                  .height(rentBarH(t.price))
                  .borderRadius({ topLeft: 4, topRight: 4 })
                  .linearGradient({
                    angle: 180,
                    colors: trendBarColor(t.price) === '#C8102E'
                      ? [['#E8556D', 0.0], ['#C8102E', 1.0]]
                      : [['#F2D088', 0.0], ['#E8B34B', 1.0]]
                  })
                Text(t.day)
                  .fontSize(10)
                  .fontColor('#A8896A')
              }
              .alignItems(HorizontalAlign.Center)
            }, (t: RentTrend) => t.day)
          }
          .width('100%')
          .padding({ top: 6, bottom: 4 })
        }
        .padding(12)
        .borderRadius(12)
        .backgroundColor('#FFFFFF')

        Row({ space: 8 }) {
          ForEach(DYNASTY_CHIPS, (chip: DynastyChip) => {
            Text(chip.name)
              .fontSize(12)
              .fontColor(this.filterIdx === DYNASTY_CHIPS.indexOf(chip) ? '#8E0E20' : '#A8896A')
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(this.filterIdx === DYNASTY_CHIPS.indexOf(chip) ? '#FDF1E3' : '#FFFFFF')
              .borderRadius(14)
              .border({ width: 1, color: this.filterIdx === DYNASTY_CHIPS.indexOf(chip) ? '#C8102E' : '#EFE3D0' })
              .onClick(() => {
                this.filterIdx = DYNASTY_CHIPS.indexOf(chip)
              })
          }, (chip: DynastyChip) => chip.name)
        }
        .width('100%')

        Row() {
          Text('到店试穿免费 · 免费改裙长')
            .fontSize(11)
            .fontColor('#B08A5A')
          Row()
            .layoutWeight(1)
          Text('预约试穿')
            .fontSize(12)
            .fontColor('#FFFFFF')
            .padding({ left: 14, right: 14, top: 6, bottom: 6 })
            .borderRadius(14)
            .backgroundColor('#C8102E')
            .onClick(() => {
              this.showTry = true
            })
        }
        .width('100%')
        .padding(10)
        .borderRadius(10)
        .backgroundColor('#FBF0DB')

        ForEach(RENT_LIST, (item: HanfuItem) => {
          Row({ space: 12 }) {
            Column()
              .width(110)
              .height(140)
              .borderRadius(10)
              .backgroundColor(item.color)

            Column({ space: 6 }) {
              Row({ space: 6 }) {
                Text(item.dynasty + '全套')
                  .fontSize(10)
                  .fontColor('#8E0E20')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .backgroundColor('#FBF0DB')
                  .borderRadius(4)
                Text('★ ' + item.score.toFixed(1))
                  .fontSize(11)
                  .fontColor('#E8B34B')
              }

              Text(item.title)
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3A2A22')

              Row({ space: 6 }) {
                ForEach(item.tags, (tag: string) => {
                  Text(tag)
                    .fontSize(10)
                    .fontColor('#C8102E')
                    .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                    .backgroundColor('#FBE9EA')
                    .borderRadius(4)
                }, (tag: string) => tag)
              }

              Text('押金 ¥200 · 归还全退 · 含清洗')
                .fontSize(11)
                .fontColor('#A8896A')

              Row() {
                Row({ space: 3 }) {
                  Text('¥' + item.price)
                    .fontSize(18)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#C8102E')
                  Text('/3天')
                    .fontSize(10)
                    .fontColor('#A8896A')
                }

                Row()
                  .layoutWeight(1)

                Text('查看详情')
                  .fontSize(12)
                  .fontColor('#8E0E20')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(13)
                  .border({ width: 1, color: '#C8102E' })
                  .onClick(() => {
                    this.selItem = item
                    this.showDetail = true
                  })
              }
              .width('100%')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('100%')
          .padding(10)
          .borderRadius(12)
          .backgroundColor('#FFFFFF')
          .shadow({ radius: 6, color: 'rgba(58,42,34,0.05)', offsetY: 2 })
        }, (item: HanfuItem) => item.id.toString())

        if (this.showTry) {
          modalOverlay(() => {
            this.showTry = false
          })
          Column({ space: 14 }) {
            Text('预约到店试穿')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2A22')
              .width('100%')

            Column({ space: 8 }) {
              Text('选择日期')
                .fontSize(12)
                .fontColor('#A8896A')
                .width('100%')
              Row({ space: 8 }) {
                ForEach(CALENDAR_DAYS, (d: CalendarDay) => {
                  Column({ space: 3 }) {
                    Text(d.label)
                      .fontSize(12)
                      .fontColor(this.tryDate === d.date ? '#FFFFFF' : '#5C4A3E')
                    Text(d.date)
                      .fontSize(10)
                      .fontColor(this.tryDate === d.date ? '#FFE3C8' : '#A8896A')
                  }
                  .padding({ left: 10, right: 10, top: 7, bottom: 7 })
                  .borderRadius(10)
                  .backgroundColor(this.tryDate === d.date ? '#C8102E' : '#FDF6EA')
                  .onClick(() => {
                    this.tryDate = d.date
                  })
                }, (d: CalendarDay) => d.idx.toString())
              }
            }
            .width('100%')

            Column({ space: 8 }) {
              Text('选择时段')
                .fontSize(12)
                .fontColor('#A8896A')
                .width('100%')
              Row({ space: 8 }) {
                ForEach(['10:00', '14:00', '16:00', '19:00'], (slot: string) => {
                  Text(slot)
                    .fontSize(12)
                    .fontColor(this.trySlot === slot ? '#FFFFFF' : '#5C4A3E')
                    .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                    .borderRadius(10)
                    .backgroundColor(this.trySlot === slot ? '#8E0E20' : '#FDF6EA')
                    .onClick(() => {
                      this.trySlot = slot
                    })
                }, (slot: string) => slot)
              }
            }
            .width('100%')

            Text('地址:锦官城·青羊坊汉服馆 2 楼(地铁 4 号线 B 口)')
              .fontSize(11)
              .fontColor('#A8896A')
              .width('100%')

            Text('提交预约')
              .fontSize(13)
              .fontColor('#FFFFFF')
              .padding({ left: 0, right: 0, top: 10, bottom: 10 })
              .borderRadius(20)
              .linearGradient({
                angle: 90,
                colors: [['#C8102E', 0.0], ['#E8B34B', 1.0]]
              })
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.showTry = false
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .constraintSize({ maxHeight: '80%' })
          .width('92%')
        }

        if (this.showDetail) {
          modalOverlay(() => {
            this.showDetail = false
          })
          Column({ space: 12 }) {
            Row() {
              Text('套装详情')
                .fontSize(16)
                .fontWeight(FontWeight.Bold)
                .fontColor('#3A2A22')
              Row()
                .layoutWeight(1)
              Text('×')
                .fontSize(18)
                .fontColor('#A8896A')
                .onClick(() => {
                  this.showDetail = false
                })
            }
            .width('100%')

            Column()
              .width('100%')
              .height(120)
              .borderRadius(10)
              .backgroundColor(this.selItem.color)

            Row({ space: 10 }) {
              Column({ space: 2 }) {
                Text('上衣裙装')
                  .fontSize(12)
                  .fontColor('#3A2A22')
                Text('含')
                  .fontSize(11)
                  .fontColor('#C8102E')
              }
              .layoutWeight(1)
              Column({ space: 2 }) {
                Text('发冠发饰')
                  .fontSize(12)
                  .fontColor('#3A2A22')
                Text('含')
                  .fontSize(11)
                  .fontColor('#C8102E')
              }
              .layoutWeight(1)
              Column({ space: 2 }) {
                Text('绣鞋')
                  .fontSize(12)
                  .fontColor('#3A2A22')
                Text('含')
                  .fontSize(11)
                  .fontColor('#C8102E')
              }
              .layoutWeight(1)
              Column({ space: 2 }) {
                Text('妆造')
                  .fontSize(12)
                  .fontColor('#3A2A22')
                Text('¥39 加购')
                  .fontSize(11)
                  .fontColor('#B08A5A')
              }
              .layoutWeight(1)
            }
            .width('100%')
            .padding({ top: 10, bottom: 10 })
            .borderRadius(10)
            .backgroundColor('#FDF6EA')

            Text('· 归还时无需清洗,污渍由专业机构处理\n· 熨烫后顺丰上门取件,全程可跟踪\n· 连续租 3 次以上自动升级金穗会员免押金')
              .fontSize(11)
              .fontColor('#A8896A')
              .lineHeight(18)
              .width('100%')

            Row() {
              Text('¥' + this.selItem.price)
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor('#C8102E')
              Text('/3天 · 已租 ' + this.selItem.rentCount + ' 次')
                .fontSize(11)
                .fontColor('#A8896A')
              Row()
                .layoutWeight(1)
              Text('去下单')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#C8102E')
                .onClick(() => {
                  this.showDetail = false
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .constraintSize({ maxHeight: '80%' })
          .width('92%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#FFF7EC')
    .layoutWeight(1)
  }
}

// ==================== Tab 3 簪花铺子 ====================
@Component
export struct PinTab {
  @State catIdx: number = 0
  @State showCart: boolean = false
  @State showCare: boolean = false
  @State buyNum: number = 1
  @State selAcc: AccessoryItem = ACCESSORY_ITEMS[0]

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Row({ space: 8 }) {
          ForEach(ACCESSORY_CATS, (cat: string) => {
            Text(cat)
              .fontSize(12)
              .fontColor(this.catIdx === ACCESSORY_CATS.indexOf(cat) ? '#FFFFFF' : '#8E0E20')
              .padding({ left: 14, right: 14, top: 7, bottom: 7 })
              .borderRadius(15)
              .backgroundColor(this.catIdx === ACCESSORY_CATS.indexOf(cat) ? '#C8102E' : '#FDF1E3')
              .onClick(() => {
                this.catIdx = ACCESSORY_CATS.indexOf(cat)
              })
          }, (cat: string) => cat)
        }
        .width('100%')

        Row({ space: 10 }) {
          Column({ space: 4 }) {
            Text('非遗绒花')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8E0E20')
            Text('匠人手作 限量 88 件')
              .fontSize(10)
              .fontColor('#B08A5A')
          }
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#FBF0DB')
          .layoutWeight(1)
          .height(72)
          .onClick(() => {
            this.showCare = true
          })

          Column({ space: 4 }) {
            Text('簪花随租送')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('租汉服免费借 3 件')
              .fontSize(10)
              .fontColor('#FFE3C8')
          }
          .alignItems(HorizontalAlign.Start)
          .padding(12)
          .borderRadius(12)
          .linearGradient({
            angle: 135,
            colors: [['#C8102E', 0.0], ['#E8B34B', 1.0]]
          })
          .layoutWeight(1)
          .height(72)
          .onClick(() => {
            this.showCare = true
          })
        }
        .width('100%')

        Row() {
          Text('掌柜推荐')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2A22')
          Row()
            .layoutWeight(1)
          Text('养护贴士')
            .fontSize(11)
            .fontColor('#C8102E')
            .onClick(() => {
              this.showCare = true
            })
        }
        .width('100%')

        Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
          ForEach(ACCESSORY_ITEMS, (acc: AccessoryItem) => {
            Column({ space: 8 }) {
              Column()
                .width('100%')
                .height(110)
                .borderRadius(10)
                .backgroundColor(acc.color)

              Column({ space: 4 }) {
                Text(acc.name)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3A2A22')
                  .maxLines(1)
                Text(acc.material)
                  .fontSize(10)
                  .fontColor('#A8896A')
                Row() {
                  Text('¥' + acc.price)
                    .fontSize(15)
                    .fontWeight(FontWeight.Bold)
                    .fontColor('#C8102E')
                  Row()
                    .layoutWeight(1)
                  Text('加入购物车')
                    .fontSize(10)
                    .fontColor('#FFFFFF')
                    .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                    .borderRadius(10)
                    .backgroundColor('#8E0E20')
                    .onClick(() => {
                      this.selAcc = acc
                      this.showCart = true
                    })
                }
                .width('100%')
              }
              .alignItems(HorizontalAlign.Start)
              .width('100%')
            }
            .padding(10)
            .borderRadius(12)
            .backgroundColor('#FFFFFF')
            .width('48.5%')
            .margin({ bottom: 10 })
          }, (acc: AccessoryItem) => acc.id.toString())
        }
        .width('100%')

        Text('· 现货 48 小时内发货 · 七天无理由退换 · 支持定制刻字')
          .fontSize(10)
          .fontColor('#CBB9A4')
          .width('100%')
          .textAlign(TextAlign.Center)

        if (this.showCart) {
          modalOverlay(() => {
            this.showCart = false
          })
          Column({ space: 14 }) {
            Row({ space: 10 }) {
              Column()
                .width(60)
                .height(60)
                .borderRadius(8)
                .backgroundColor(this.selAcc.color)
              Column({ space: 3 }) {
                Text(this.selAcc.name)
                  .fontSize(14)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3A2A22')
                Text(this.selAcc.material + ' · ' + this.selAcc.grade)
                  .fontSize(10)
                  .fontColor('#A8896A')
                Text('剩余 ' + this.selAcc.stock + ' 件')
                  .fontSize(10)
                  .fontColor('#B08A5A')
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row() {
              Text('购买数量')
                .fontSize(13)
                .fontColor('#5C4A3E')
              Row()
                .layoutWeight(1)
              Row({ space: 0 }) {
                Text('−')
                  .fontSize(15)
                  .fontColor(this.buyNum <= 1 ? '#D8C9B8' : '#C8102E')
                  .padding(7)
                  .onClick(() => {
                    if (this.buyNum > 1) {
                      this.buyNum--
                    }
                  })
                Text(this.buyNum.toString())
                  .fontSize(13)
                  .fontColor('#3A2A22')
                  .padding({ left: 12, right: 12 })
                Text('+')
                  .fontSize(15)
                  .fontColor(this.buyNum >= this.selAcc.stock ? '#D8C9B8' : '#C8102E')
                  .padding(7)
                  .onClick(() => {
                    if (this.buyNum < this.selAcc.stock) {
                      this.buyNum++
                    }
                  })
              }
              .borderRadius(8)
              .backgroundColor('#FDF6EA')
            }
            .width('100%')

            Row() {
              Text('定制刻字(免费 8 字)')
                .fontSize(12)
                .fontColor('#5C4A3E')
              Row()
                .layoutWeight(1)
              Text('「长安」')
                .fontSize(11)
                .fontColor('#B08A5A')
            }
            .width('100%')

            Row() {
              Text('小计 ¥' + (this.selAcc.price * this.buyNum))
                .fontSize(17)
                .fontWeight(FontWeight.Bold)
                .fontColor('#C8102E')
              Row()
                .layoutWeight(1)
              Text('加入购物车')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .linearGradient({
                  angle: 90,
                  colors: [['#C8102E', 0.0], ['#E8556D', 1.0]]
                })
                .onClick(() => {
                  this.showCart = false
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .width('92%')
        }

        if (this.showCare) {
          modalOverlay(() => {
            this.showCare = false
          })
          Column({ space: 10 }) {
            Text('配饰养护贴士')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2A22')
            Text('1. 绒花怕水,雨天请收进锦囊\n2. 鎏金簪避免与香水直接接触\n3. 缠花可用软毛刷轻扫浮尘\n4. 玉佩绳结松脱可到店免费更换\n5. 长期不用请平放于原盒避光保存')
              .fontSize(12)
              .fontColor('#5C4A3E')
              .lineHeight(20)
            Text('明白了')
              .fontSize(13)
              .fontColor('#8E0E20')
              .padding({ left: 26, right: 26, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#FDF1E3')
              .onClick(() => {
                this.showCare = false
              })
          }
          .padding(20)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .alignItems(HorizontalAlign.Center)
          .width('80%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#FFF7EC')
    .layoutWeight(1)
  }
}

// ==================== Tab 4 写真预约 ====================
@Component
export struct PhotoTab {
  @State showBook: boolean = false
  @State showPhoto: boolean = false
  @State showDate: boolean = false
  @State selPkg: PhotoPackage = PHOTO_PACKAGES[0]
  @State selPhotographer: Photographer = PHOTOGRAPHERS[0]
  @State bookDate: string = '8/28'

  build() {
    Scroll() {
      Column({ space: 12 }) {
        Column({ space: 8 }) {
          Row({ space: 8 }) {
            Column()
              .width(56)
              .height(56)
              .borderRadius(28)
              .backgroundColor('#E8B34B')
            Column({ space: 3 }) {
              Text('国风写真节 · 第 5 季')
                .fontSize(15)
                .fontWeight(FontWeight.Bold)
                .fontColor('#FFFFFF')
              Text('园林 / 茶室 / 烛光夜拍 三大主题任选')
                .fontSize(10)
                .fontColor('#FFE3C8')
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
          }
          .width('100%')

          Row({ space: 10 }) {
            Text('立减 ¥300')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8E0E20')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor('#FFFFFF')
            Text('赠妆造')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8E0E20')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor('#FFFFFF')
            Text('底片全送')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#8E0E20')
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(12)
              .backgroundColor('#FFFFFF')
          }
        }
        .padding(14)
        .borderRadius(14)
        .linearGradient({
          angle: 135,
          colors: [['#C8102E', 0.0], ['#8E0E20', 1.0]]
        })
        .width('100%')

        Row() {
          Text('热门套餐')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2A22')
          Row()
            .layoutWeight(1)
          Text('档期日历')
            .fontSize(11)
            .fontColor('#C8102E')
            .onClick(() => {
              this.showDate = true
            })
        }
        .width('100%')

        Scroll() {
          Row({ space: 10 }) {
            ForEach(PHOTO_PACKAGES, (pkg: PhotoPackage) => {
              Column({ space: 8 }) {
                Column()
                  .width(150)
                  .height(96)
                  .borderRadius(10)
                  .backgroundColor(pkg.color)

                Column({ space: 4 }) {
                  Row({ space: 6 }) {
                    Text(pkg.name)
                      .fontSize(13)
                      .fontWeight(FontWeight.Bold)
                      .fontColor('#3A2A22')
                    if (pkg.hot) {
                      Text('HOT')
                        .fontSize(9)
                        .fontColor('#FFFFFF')
                        .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                        .borderRadius(3)
                        .backgroundColor('#C8102E')
                    }
                  }

               
                  Text('删除')
                    .fontSize(11)
                    .fontColor('#FFFFFF')
                    .padding({ left: 12, right: 12, top: 5, bottom: 5 })
                    .borderRadius(10)
                    .backgroundColor('#8E0E20')
                    .onClick(() => {
                      this.delIdx = idx
                      this.showDelAddr = true
                    })
                }
                .width('100%')
              }
              .padding(12)
              .borderRadius(12)
              .backgroundColor('#FFFFFF')
              .width('100%')
            }, (a: AddrMeta) => a.id.toString())

            Text('新增地址')
              .fontSize(12)
              .fontColor('#FFFFFF')
              .padding({ left: 0, right: 0, top: 9, bottom: 9 })
              .borderRadius(16)
              .backgroundColor('#C8102E')
              .width('100%')
              .textAlign(TextAlign.Center)
              .onClick(() => {
                this.saveAddr()
              })
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .constraintSize({ maxHeight: '78%' })
          .width('92%')
        }

        if (this.showEditAddr) {
          modalOverlay(() => {
            this.showEditAddr = false
          })
          Column({ space: 12 }) {
            Text('编辑地址')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2A22')
              .width('100%')
            TextInput({ placeholder: '收货人姓名', text: this.inputName })
              .fontSize(12)
              .height(38)
              .borderRadius(8)
              .backgroundColor('#FDF6EA')
              .onChange((v: string) => {
                this.inputName = v
              })
              .width('100%')
            TextInput({ placeholder: '手机号', text: this.inputPhone })
              .fontSize(12)
              .height(38)
              .borderRadius(8)
              .backgroundColor('#FDF6EA')
              .onChange((v: string) => {
                this.inputPhone = v
              })
              .width('100%')
            TextInput({ placeholder: '详细地址', text: this.inputDetail })
              .fontSize(12)
              .height(38)
              .borderRadius(8)
              .backgroundColor('#FDF6EA')
              .onChange((v: string) => {
                this.inputDetail = v
              })
              .width('100%')
            Row({ space: 10 }) {
              Text('取消')
                .fontSize(13)
                .fontColor('#5C4A3E')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#FDF6EA')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showEditAddr = false
                })
              Text('保存')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#C8102E')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.updateAddr()
                })
            }
            .width('100%')
          }
          .padding(16)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .width('88%')
        }

        if (this.showDelAddr) {
          modalOverlay(() => {
            this.showDelAddr = false
          })
          Column({ space: 12 }) {
            Text('删除地址')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2A22')
            Text('删除后无法恢复,确认删除该地址?')
              .fontSize(12)
              .fontColor('#A8896A')
            Row({ space: 10 }) {
              Text('取消')
                .fontSize(13)
                .fontColor('#5C4A3E')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#FDF6EA')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.showDelAddr = false
                })
              Text('删除')
                .fontSize(13)
                .fontColor('#FFFFFF')
                .padding({ left: 20, right: 20, top: 8, bottom: 8 })
                .borderRadius(16)
                .backgroundColor('#8E0E20')
                .layoutWeight(1)
                .textAlign(TextAlign.Center)
                .onClick(() => {
                  this.removeAddr()
                })
            }
            .width('100%')
          }
          .padding(18)
          .borderRadius(16)
          .backgroundColor('#FFF9F0')
          .width('80%')
        }

        if (this.showVip) {
          modalOverlay(() => {
            this.showVip = false
          })
          Column({ space: 12 }) {
            Text('金穗会员特权')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
              .width('100%')
            Column({ space: 8 }) {
              Text('· 全场租金 95 折')
                .fontSize(12)
                .fontColor('#FFE3C8')
                .width('100%')
              Text('· 生日当月免 1 次押金')
                .fontSize(12)
                .fontColor('#FFE3C8')
                .width('100%')
              Text('· 新品优先租(提前 24h)')
                .fontSize(12)
                .fontColor('#FFE3C8')
                .width('100%')
              Text('· 写真跟拍 9 折')
                .fontSize(12)
                .fontColor('#FFE3C8')
                .width('100%')
            }
            .padding(14)
            .borderRadius(10)
            .backgroundColor('rgba(142,14,32,0.5)')
            .width('100%')
            Text('知道了')
              .fontSize(13)
              .fontColor('#8E0E20')
              .padding({ left: 26, right: 26, top: 8, bottom: 8 })
              .borderRadius(16)
              .backgroundColor('#F2D088')
              .onClick(() => {
                this.showVip = false
              })
          }
          .padding(20)
          .borderRadius(16)
          .linearGradient({
            angle: 135,
            colors: [['#C8102E', 0.0], ['#E8B34B', 1.0]]
          })
          .width('84%')
        }
      }
      .padding(12)
    }
    .backgroundColor('#FFF7EC')
    .layoutWeight(1)
  }
}

// ==================== 主页面 ====================
@Entry
@Component
struct HanfuPage {
  @State activeTab: number = 0
  @State showSearch: boolean = false
  @State showMsg: boolean = false
  @State searchKey: string = ''
  @State unRead: number = 2

  build() {
    Column() {
      Column({ space: 0 }) {
        Row({ space: 10 }) {
          Text('青羊坊')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')

          Row({ space: 6 }) {
            Text('🔍')
              .fontSize(13)
            TextInput({ placeholder: '马面裙 / 绒花 / 唐制写真' })
              .fontSize(12)
              .height(34)
              .layoutWeight(1)
              .backgroundColor('#FBE9EA')
              .borderRadius(17)
              .onChange((v: string) => {
                this.searchKey = v
              })
          }
          .layoutWeight(1)
          .padding({ left: 6, right: 6 })
          .backgroundColor('#FFFFFF')
          .borderRadius(17)

          Stack({ alignContent: Alignment.TopEnd }) {
            Text('✉')
              .fontSize(18)
              .fontColor('#FFFFFF')
            if (this.unRead > 0) {
              Column()
                .width(8)
                .height(8)
                .borderRadius(4)
                .backgroundColor('#E8B34B')
                .margin({ top: -2, right: -2 })
            }
          }
          .width(28)
          .height(28)
          .onClick(() => {
            this.showMsg = true
          })
        }
        .width('100%')
        .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      }
      .linearGradient({
        angle: 135,
        colors: [['#C8102E', 0.0], ['#8E0E20', 1.0]]
      })
      .width('100%')

      if (this.activeTab === 0) {
        NewTab()
      } else if (this.activeTab === 1) {
        RentTab()
      } else if (this.activeTab === 2) {
        PinTab()
      } else if (this.activeTab === 3) {
        PhotoTab()
      } else if (this.activeTab === 4) {
        OrderTab()
      } else {
        MineTab()
      }

      Row({ space: 0 }) {
        ForEach(MAIN_TABS, (tab: TabMeta, idx: number) => {
          Column({ space: 3 }) {
            Text(tab.icon)
              .fontSize(15)
              .fontColor(this.activeTab === idx ? '#C8102E' : '#B08A5A')
            Text(tab.name)
              .fontSize(9)
              .fontColor(this.activeTab === idx ? '#C8102E' : '#B08A5A')
            if (this.activeTab === idx) {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('#C8102E')
            } else {
              Column()
                .width(16)
                .height(3)
                .borderRadius(2)
                .backgroundColor('rgba(0,0,0,0)')
            }
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 6, bottom: 6 })
          .onClick(() => {
            this.activeTab = idx
          })
        }, (tab: TabMeta, idx: number) => (tab.name + idx).toString())
      }
      .width('100%')
      .backgroundColor('#FFFFFF')

      if (this.showSearch) {
        modalOverlay(() => {
          this.showSearch = false
        })
        Column({ space: 12 }) {
          Text('搜索')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#3A2A22')
            .width('100%')
          TextInput({ placeholder: '输入汉服 / 配饰关键词' })
            .fontSize(12)
            .height(38)
            .borderRadius(10)
            .backgroundColor('#FDF6EA')
            .width('100%')
          Row({ space: 8 }) {
            ForEach(['马面裙', '唐制', '绒花', '写真'], (k: string) => {
              Text(k)
                .fontSize(12)
                .fontColor('#8E0E20')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .backgroundColor('#FDF1E3')
            }, (k: string) => k)
          }
          .width('100%')
          Text('搜索')
            .fontSize(13)
            .fontColor('#FFFFFF')
            .padding({ left: 0, right: 0, top: 9, bottom: 9 })
            .borderRadius(16)
            .backgroundColor('#C8102E')
            .width('100%')
            .textAlign(TextAlign.Center)
            .onClick(() => {
              this.showSearch = false
            })
        }
        .padding(16)
        .borderRadius(16)
        .backgroundColor('#FFF9F0')
        .width('90%')
      }

      if (this.showMsg) {
        modalOverlay(() => {
          this.showMsg = false
          this.unRead = 0
        })
        Column({ space: 12 }) {
          Row() {
            Text('消息中心')
              .fontSize(16)
              .fontWeight(FontWeight.Bold)
              .fontColor('#3A2A22')
            Row()
              .layoutWeight(1)
            Text('全部已读')
              .fontSize(11)
              .fontColor('#C8102E')
              .onClick(() => {
                this.unRead = 0
              })
          }
          .width('100%')

          ForEach(MSG_LIST, (m: MsgMeta) => {
            Row({ space: 10 }) {
              Column({ space: 2 }) {
                Text(m.title)
                  .fontSize(13)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#3A2A22')
                Text(m.content)
                  .fontSize(11)
                  .fontColor('#A8896A')
                  .maxLines(2)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Column({ space: 4 }) {
                Text(m.time)
                  .fontSize(9)
                  .fontColor('#CBB9A4')
                if (!m.read) {
                  Column()
                    .width(7)
                    .height(7)
                    .borderRadius(4)
                    .backgroundColor('#C8102E')
                }
              }
              .alignItems(HorizontalAlign.End)
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }, (m: MsgMeta) => m.id.toString())
        }
        .padding(16)
        .borderRadius(16)
        .backgroundColor('#FFF9F0')
        .constraintSize({ maxHeight: '75%' })
        .width('92%')
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFF7EC')
  }
}


在这里插入图片描述

总结

本文全面解析了基于 HarmonyOS 6.1.1 ArkTS 声明式 UI 框架开发的汉服国风体验馆应用。从架构设计层面看,该应用采用了"全局共享构建器 + export 导出子组件 + 内联弹窗"的独特架构模式。与游戏充值中心在每个 Tab 内部各自定义 modalOverlay 不同,汉服体验馆将遮罩层抽取为全局 @Builder function modalOverlay,所有子组件共享同一个遮罩构建器实例,消除了大量重复代码。同时,所有六个 Tab 组件都使用 export struct 导出,意味着这些组件可以被其他页面或模块引用,为未来扩展(如独立详情页、分享卡片等)预留了组件复用的可能性。

从数据建模层面看,十四个接口定义是三个应用中最多的,体现了汉服租赁业务的数据复杂度——一个商品(HanfuItem)就包含了十一个字段,涵盖朝代、租金、原价、租期、评分、租赁次数、标签数组、热度、代表色等维度。特别是 OrderRecord 接口中的 statusColor 字段,将状态颜色直接存储在数据中而非在渲染时通过条件映射计算,这是一种"数据冗余换渲染效率"的设计权衡。地址管理模块的 AddrMeta 接口配合 saveAddrupdateAddrremoveAddr 三个方法,实现了完整的不可变数组 CRUD 操作,是 ArkTS 声明式状态管理在数据增删改场景下的标准实践范式。

从交互设计层面看,该应用最具特色的是弹窗的内联实现方式——所有模态弹窗都直接写在各组件的 build() 方法中,通过 if (this.showXxx) 条件判断控制显隐,而非抽取为独立的 @Builder 方法。这种方式的优势是弹窗代码与页面上下文紧密耦合,可以直接访问 this 引用的所有状态变量,无需通过参数传递;同时,二级弹窗(如尺码表嵌套在租赁弹窗中、编辑/删除弹窗嵌套在地址列表中)的实现更加自然。代价是 build() 方法变得较长,但对于单页面单职责的组件来说,这种长度是可接受的。写真预约弹窗展示了套餐选择、摄影师选择和日历日期选择的三维交互,是应用中复杂度最高的弹窗模块。

Logo

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

更多推荐