技术要点:本文深入剖析一个基于鸿蒙HarmonyOS ArkTS语言开发的老房爆改设计工作室应用。该应用采用复古工业风浅色暖调视觉设计语言,涵盖改造案例展示、设计方案报价、建材商城选购、工长施工队管理、阶段验收追踪及个人中心六大功能模块,充分展现了ArkUI声明式UI范式在跨组件通信、复杂弹窗系统和时间轴布局等高级场景下的工程实践能力。

一、技术背景与应用概述

鸿蒙HarmonyOS的应用开发框架ArkUI建立在一套严格的声明式编程范式之上。与传统的命令式UI框架(如Android的View体系)不同,ArkUI要求开发者以声明的方式描述界面在各个状态下的最终形态,由框架的渲染引擎负责计算状态差异并执行最小化的DOM操作。这种设计范式在面临复杂状态管理和跨组件通信时,展现出了比命令式UI更为清晰的数据流和更可预测的渲染行为。

ArkTS作为ArkUI的编程语言载体,在TypeScript的基础上增加了对装饰器语法的深度支持。@Entry标记应用的入口组件——整个页面树的根节点;@Component将一个struct声明为可复用的自定义组件,每个组件拥有独立的状态作用域和生命周期;@State创建可观察的状态变量,当其值发生变化时自动触发所属组件的重新渲染;@Builder定义可复用的UI构建方法,既可以在组件内部封装UI片段,也可以作为跨组件的UI复用单元。

设计理念阐述:声明式UI的精髓在于"数据向下流动,事件向上冒泡"。父组件通过属性将数据传递给子组件,子组件通过回调函数将用户事件上报给父组件。这一原则在本文分析的老房爆改应用中得到了系统性的实践——入口组件集中管理全部弹窗状态和选中索引,子组件通过回调函数触发父组件的弹窗显示,形成了清晰的单向数据流架构。

本文分析的老房爆改设计工作室应用,是一个以复古工业风为视觉基调的单文件ArkTS应用。配色系统采用米白色(#F2EEE8)作为主背景,搭配砖红色(#A34730)作为主色调,辅以焊接橙(#E07B39)、水泥灰(#8C8C88)、木色(#8B5E3C)等工业风色彩,营造出温暖而粗犷的复古工业氛围。与深色科技风应用形成鲜明对比,浅色暖调设计对配色层次和边框线条的运用提出了不同的要求——浅色背景下的视觉层次需要通过微妙的色差和精细的边框来构建,而非深色主题中常用的大色块对比。

应用采用底部Tab导航架构,包含案例、方案、建材、施工队、验收、我的六个标签页。在架构设计上,该应用有一个显著特点:所有七个弹窗都集中定义在入口组件RenovaApp中,而非分散在各个子组件内部。子组件通过回调函数(如onCaseDetail、onMeasure、onBudget等)将用户交互事件上报给入口组件,由入口组件统一控制弹窗的显示与隐藏。这种集中式弹窗管理架构确保了弹窗状态的唯一数据源,避免了多个组件各自管理弹窗时可能出现的竞态条件和状态不一致问题。

在功能层面,案例模块展示12个老房改造案例,包括老破小、筒子楼、家属院、自建房、公寓等多种房型,每个案例卡片包含改造前后面积对比条、工期进度条和流动光效动画。方案模块提供10种设计风格的横向滑动卡片、单位造价对比柱状图和斑马纹报价表。建材模块以双列网格展示16款建材,包含库存水位条和销量排行榜。施工队模块展示10位工长的排行榜和接单量条形图。验收模块通过垂直时间轴展示10个验收节点,配合脉冲缩放动画标识已完成节点。我的模块包含会员卡光泽移动效果、月消费柱状图、收藏列表和订单列表。

二、类型定义与数据模型分析

配色系统

interface RVColorPalette {
  bg: string
  card: string
  deep: string
  line: string
  brick: string
  brickDeep: string
  cement: string
  orange: string
  charcoal: string
  textMain: string
  textSub: string
  textHint: string
  red: string
  green: string
  blue: string
  wood: string
}

const RV_COLORS: RVColorPalette = {
  bg: '#F2EEE8',
  card: '#FBF8F3',
  deep: '#E5DFD5',
  line: '#D8D0C2',
  brick: '#A34730',
  brickDeep: '#7E3423',
  cement: '#8C8C88',
  orange: '#E07B39',
  charcoal: '#3A3633',
  textMain: '#3A3633',
  textSub: '#8C8C88',
  textHint: '#B9B2A6',
  red: '#C0392B',
  green: '#6B8F5E',
  blue: '#3A6E8E',
  wood: '#8B5E3C'
}

在这里插入图片描述

复古工业风配色系统定义了16个颜色属性。主背景色#F2EEE8是一种温暖的米白色,模拟老旧纸张或石灰墙面的质感。卡片背景#FBF8F3是略浅于主背景的暖白色,通过微妙的色差区分卡片与背景。深层背景#E5DFD5用于次级容器和选中态背景。线条色#D8D0C2是偏暖的灰色,用于边框和分隔线。

主色调砖红色#A34730源自老红砖墙的颜色,是整个应用的视觉锚点。深砖红#7E3423用于渐变的起始色和强调元素。焊接橙#E07B39模拟焊接火花的高温色,用于动画效果和交互高亮。水泥灰#8C8C88用于次要文本和工业风质感元素。木色#8B5E3C用于木作相关的建材分类标识。

技术要点:在浅色主题应用中,配色层次的构建比深色主题更加微妙。深色主题可以通过大色块和强对比来建立层次,而浅色主题需要依靠细微的色差(如#F2EEE8与#FBF8F3之间仅差几个色阶)配合边框线条(#D8D0C2)来区分容器层级。这要求开发者对色彩有更精细的控制力,每一个色值的选择都需要考虑与周围元素的对比关系。

Tab枚举与业务数据接口

enum RVTab {
  Case = 0,
  Plan = 1,
  Mat = 2,
  Crew = 3,
  Accept = 4,
  Me = 5
}

interface RVCase {
  id: number
  name: string
  type: string
  area: number
  oldArea: number
  period: number
  progress: number
  price: number
  tag: string
  icon: string
  desc: string
  color: string
}

interface RVPlan {
  id: number
  name: string
  style: string
  price: number
  area: number
  total: number
  discount: number
  days: number
  sales: number
  hot: boolean
  icon: string
  color: string
  desc: string
}

interface RVQuoteLine {
  item: string
  price: string
  note: string
}

interface RVMaterial {
  id: number
  name: string
  cat: string
  price: number
  unit: string
  stock: number
  sales: number
  icon: string
  color: string
  tag: string
}

interface RVCrew {
  id: number
  name: string
  age: number
  years: number
  orders: number
  rate: number
  score: number
  tag: string
  icon: string
}

在这里插入图片描述

以上代码展示了Tab枚举和核心业务数据接口。RVTab定义了6个标签页标识值。RVCase描述改造案例的完整信息,包含改造前面积(oldArea)和改造后面积(area),用于计算面积对比比例。RVPlan描述设计方案,包含单价(price/平方米)、总面积、总造价、折扣率、工期天数和热销标识。RVQuoteLine描述报价表中的一行项目。RVMaterial描述建材商品,包含库存百分比和销量数据。RVCrew描述工长信息,包含工龄、接单量、好评率和评分。

值得注意的是,RVQuoteLine接口中的price字段类型为string而非number,因为报价金额包含了千位分隔符和货币符号(如"¥15,200"),这种设计使得数据可以直接绑定到Text组件显示,无需在渲染时进行格式化转换。

全局纯函数

function rvProgressColor(p: number): string {
  if (p >= 100) {
    return '#6B8F5E'
  } else if (p >= 50) {
    return '#A34730'
  }
  return '#E07B39'
}

function rvOrderStatusColor(s: string): string {
  if (s === '已完工') {
    return '#6B8F5E'
  } else if (s === '施工中') {
    return '#E07B39'
  } else if (s === '设计阶段') {
    return '#A34730'
  } else if (s === '待签约') {
    return '#3A6E8E'
  }
  return '#8C8C88'
}

function rvStockColor(stock: number): string {
  if (stock < 25) {
    return '#C0392B'
  } else if (stock < 50) {
    return '#E07B39'
  }
  return '#6B8F5E'
}

function rvPlanBarH(price: number): number {
  return Math.round((price - 700) / 8)
}

function rvSalesRanked(): RVMaterial[] {
  let r: RVMaterial[] = RV_MATS.slice()
  r.sort((a: RVMaterial, b: RVMaterial) => b.sales - a.sales)
  return r
}

function rvCrewByOrders(): RVCrew[] {
  let r: RVCrew[] = RV_CREWS.slice()
  r.sort((a: RVCrew, b: RVCrew) => b.orders - a.orders)
  return r
}

function rvCaseDay(c: RVCase): string {
  return Math.round(c.progress / 100 * c.period).toString()
}

function rvCaseSplitOld(c: RVCase): string {
  return (c.oldArea / (c.oldArea + c.area) * 100).toFixed(0) + '%'
}

function rvCaseSplitNew(c: RVCase): string {
  return (c.area / (c.oldArea + c.area) * 100).toFixed(0) + '%'
}

在这里插入图片描述

以上代码展示了一组纯函数。rvProgressColor根据进度百分比返回颜色——100%为绿色(已完工)、50%以上为砖红色(施工中)、50%以下为橙色(初期阶段)。rvOrderStatusColorrvStockColor分别根据订单状态和库存百分比返回对应的颜色值。

rvPlanBarH函数将价格值转换为柱状图高度——通过(price - 700) / 8的线性变换,将价格区间700-1500映射到0-100像素的高度范围。这种数据到视觉属性的线性映射是数据可视化的基础模式。

rvSalesRankedrvCrewByOrders是两个排序函数,都采用了先slice()复制再sort()的模式,确保不修改原始数据。rvCaseDay计算案例已施工天数(进度百分比乘以总工期)。rvCaseSplitOldrvCaseSplitNew计算改造前后面积在总面积中的占比,用于渲染面积对比条。

三、入口主组件与弹窗系统分析

入口主组件RenovaApp是该应用最复杂的组件,管理着7个弹窗的开关状态、4个选中索引和8个表单字段,同时承担着焊光特效动画的驱动职责。

@Entry
@Component
struct RenovaApp {
  @State activeTab: RVTab = RVTab.Case
  // ===== 7 个弹框开关 =====
  @State showMeasure: boolean = false
  @State showBudget: boolean = false
  @State showStyle: boolean = false
  @State showBook: boolean = false
  @State showDelPlan: boolean = false
  @State showCaseDetail: boolean = false
  @State showClearFav: boolean = false
  // ===== 选中下标 =====
  @State selCaseIdx: number = 0
  @State delPlanIdx: number = 0
  @State bookCrewIdx: number = 0
  @State selStyleIdx: number = 0
  // ===== 弹框表单状态 =====
  @State measureArea: string = '50-80㎡'
  @State measureAge: string = '10-20年'
  @State measureStyle: string = '复古工业风'
  @State measureName: string = ''
  @State measurePhone: string = ''
  @State budgetVal: number = 160
  @State bookDate: string = '本周六'
  @State bookTime: string = '上午 9:00-12:00'
  @State bookName: string = ''
  @State bookPhone: string = ''
  // ===== 焊光特效状态 =====
  @State weldPulse: number = 0.25
  @State weldScale: number = 1

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 1800,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.weldPulse = 0.9
      this.weldScale = 1.35
    })
  }

在这里插入图片描述

入口组件管理了总计22个@State状态变量,分为四组:Tab切换状态、弹窗开关(7个布尔值)、选中索引(4个数字)、表单输入(8个字符串/数字)和动画状态(2个数值)。这种集中式状态管理是该应用的核心架构决策——所有弹窗的状态都由入口组件持有,子组件只能通过回调函数请求打开弹窗,不能自行控制弹窗的显示。

aboutToAppear中启动的焊光特效动画同时驱动两个状态变量:weldPulse从0.25到0.9控制不透明度,weldScale从1到1.35控制缩放比例。两个变量在同一个动画闭包中修改,确保它们的变化完全同步。焊光特效是一个位于右下角(position: 86%, 84%)的闪烁星标,模拟焊接火花的视觉效果,是复古工业风主题的标志性装饰元素。

  @Builder modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('rgba(58,54,51,0.55)')
      .onClick(onClose)
  }

遮罩层Builder与前一应用的结构相同,但使用了不同的背景色——rgba(58,54,51,0.55)是一种基于charcoal色(#3A3633)的半透明遮罩,与复古工业风的暖色调保持一致。55%的透明度比深色主题的75%更低,这是因为浅色主题下较高的遮罩透明度会导致背景内容过于不可见,降低上下文感知。

  // 弹框1:新增量房申请(底部抽屉式)
  @Builder measureModal() {
    Column() {
      this.modalOverlay(() => { this.showMeasure = false })
      Column() {
        Row() {
          Text('📏 新增量房申请').fontSize(17).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(RV_COLORS.textSub)
            .onClick(() => { this.showMeasure = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 16, bottom: 8 })

        Scroll() {
          Column() {
            Text('房屋面积').fontSize(12).fontColor(RV_COLORS.textSub).width('100%')
            Row() {
              ForEach(RV_AREA_CHIPS, (a: string) => {
                if (this.measureArea === a) {
                  Text(a).fontSize(11).fontColor('#FFFFFF').backgroundColor(RV_COLORS.brick)
                    .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                    .margin({ right: 8 })
                } else {
                  Text(a).fontSize(11).fontColor(RV_COLORS.textSub).backgroundColor(RV_COLORS.deep)
                    .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                    .margin({ right: 8 })
                    .onClick(() => { this.measureArea = a })
                }
              })
            }
            .width('100%').margin({ top: 8 })

            Text('房龄').fontSize(12).fontColor(RV_COLORS.textSub).width('100%').margin({ top: 14 })
            Row() {
              ForEach(RV_AGE_CHIPS, (a: string) => {
                if (this.measureAge === a) {
                  Text(a).fontSize(11).fontColor('#FFFFFF').backgroundColor(RV_COLORS.brick)
                    .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                    .margin({ right: 8 })
                } else {
                  Text(a).fontSize(11).fontColor(RV_COLORS.textSub).backgroundColor(RV_COLORS.deep)
                    .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                    .margin({ right: 8 })
                    .onClick(() => { this.measureAge = a })
                }
              })
            }
            .width('100%').margin({ top: 8 })

            Text('心仪风格(可多选参考)').fontSize(12).fontColor(RV_COLORS.textSub).width('100%').margin({ top: 14 })
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(RV_STYLE_CHIPS, (s: string) => {
                if (this.measureStyle === s) {
                  Text(s).fontSize(11).fontColor('#FFFFFF').backgroundColor(RV_COLORS.orange)
                    .padding({ left: 11, right: 11, top: 7, bottom: 7 }).borderRadius(15)
                    .margin({ right: 8, bottom: 8 })
                } else {
                  Text(s).fontSize(11).fontColor(RV_COLORS.textSub).backgroundColor(RV_COLORS.deep)
                    .padding({ left: 11, right: 11, top: 7, bottom: 7 }).borderRadius(15)
                    .margin({ right: 8, bottom: 8 })
                    .onClick(() => { this.measureStyle = s })
                }
              })
            }
            .width('100%').margin({ top: 8 })

            Text('联系人').fontSize(12).fontColor(RV_COLORS.textSub).width('100%').margin({ top: 10 })
            TextInput({ placeholder: '如何称呼您' })
              .placeholderColor(RV_COLORS.textHint).fontSize(13).width('100%').height(38)
              .backgroundColor(RV_COLORS.deep).borderRadius(10).margin({ top: 6 })
              .onChange((v: string) => { this.measureName = v })

            Text('联系电话').fontSize(12).fontColor(RV_COLORS.textSub).width('100%').margin({ top: 12 })
            TextInput({ placeholder: '量房师将与您电话确认' })
              .placeholderColor(RV_COLORS.textHint).fontSize(13).width('100%').height(38)
              .backgroundColor(RV_COLORS.deep).borderRadius(10).margin({ top: 6 })
              .onChange((v: string) => { this.measurePhone = v })
          }
          .padding({ left: 20, right: 20, bottom: 10 })
        }
        .scrollable(ScrollDirection.Vertical)
        .scrollBar(BarState.Off)
        .constraintSize({ maxHeight: '34%' })

        Row() {
          Text('取消').fontSize(13).fontColor(RV_COLORS.textSub)
            .backgroundColor(RV_COLORS.deep).borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .onClick(() => { this.showMeasure = false })
          Text('提交量房申请').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor(RV_COLORS.brick).borderRadius(18)
            .padding({ left: 22, right: 22, top: 9, bottom: 9 })
            .margin({ left: 12 })
            .onClick(() => {
              this.showMeasure = false
              this.activeTab = RVTab.Plan
            })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ top: 12, bottom: 18 })
      }
      .width('100%')
      .backgroundColor(RV_COLORS.card)
      .borderRadius({ topLeft: 20, topRight: 20 })
      .position({ x: 0, y: '58%' })
    }
    .width('100%').height('100%')
    .position({ x: 0, y: 0 }).zIndex(999)
  }

在这里插入图片描述

量房申请弹窗采用底部抽屉式形态,内容面板通过position({ x: 0, y: '58%' })定位在屏幕下方42%的位置。弹窗包含房屋面积chips选择器、房龄chips选择器、心仪风格chips选择器(使用FlexWrap.Wrap支持自动换行)、联系人输入框和联系电话输入框。

每个chips选择器都通过ForEach遍历预定义的字符串数组,使用if-else条件渲染实现选中态和未选中态的视觉区分。选中时为白字砖红背景,未选中时为灰字浅色背景。点击未选中chips时更新对应的状态变量,触发选中态切换。这种chips选择器模式在表单交互中比下拉选择器更直观,适合选项数量有限的场景。

弹窗中使用了Scroll容器包裹表单内容,并通过constraintSize({ maxHeight: '34%' })限制滚动区域的最大高度——这是一个关键的布局技术。当表单内容较多时,限制滚动区域高度可以确保底部操作按钮始终可见,避免按钮被表单内容推到屏幕之外。constraintSize是ArkUI中设置尺寸约束的方法,通过maxHeight/minHeight/maxWidth/minWidth四个属性限制组件的尺寸范围。

技术要点constraintSize是ArkUI中控制组件尺寸范围的重要方法。它接受maxHeight、minHeight、maxWidth、minWidth四个可选参数,用于在弹性布局中约束组件的最终尺寸。在弹窗设计中,constraintSize({ maxHeight: '34%' })常用于限制表单滚动区域的高度,确保底部操作按钮始终可见。与固定height不同,constraintSize允许组件在小内容时收缩(如果minHeight允许),在大内容时不超过maxHeight。

  // 弹框2:编辑装修预算(带滑条)
  @Builder budgetModal() {
    Column() {
      this.modalOverlay(() => { this.showBudget = false })
      Column() {
        Row() {
          Text('💰 编辑装修预算').fontSize(17).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Column().layoutWeight(1)
          Text('✕').fontSize(18).fontColor(RV_COLORS.textSub)
            .onClick(() => { this.showBudget = false })
        }
        .width('100%').padding({ left: 20, right: 20, top: 16, bottom: 10 })

        Row() {
          Column() {
            Text('当前预算').fontSize(10).fontColor(RV_COLORS.textSub)
            Text(rvBudgetWan(this.budgetVal) + ' 万').fontSize(30).fontWeight(FontWeight.Bold)
              .fontColor(RV_COLORS.brick).margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Center)
          Column().layoutWeight(1)
          Column() {
            Text('按 88㎡ 折算').fontSize(10).fontColor(RV_COLORS.textSub)
            Text('≈ ' + rvBudgetPerArea(this.budgetVal) + ' 元/㎡')
              .fontSize(14).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.orange).margin({ top: 4 })
          }
          .alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding({ left: 16, right: 16 }).margin({ top: 8 })

        Slider({ value: this.budgetVal, min: 60, max: 400, step: 5, style: SliderStyle.OutSet })
          .blockColor(RV_COLORS.brick).trackColor(RV_COLORS.deep).selectedColor(RV_COLORS.orange)
          .width('86%').margin({ top: 18 })
          .onChange((v: number) => { this.budgetVal = v })

        Row() {
          Text('6万').fontSize(9).fontColor(RV_COLORS.textHint)
          Column().layoutWeight(1)
          Text('23万').fontSize(9).fontColor(RV_COLORS.textSub).textAlign(TextAlign.Center)
          Column().layoutWeight(1)
          Text('40万').fontSize(9).fontColor(RV_COLORS.textHint)
        }
        .width('86%').margin({ top: 4 })

        Row() {
          Text('主材 58%').fontSize(10).fontColor('#FFFFFF').backgroundColor(RV_COLORS.brick)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(12)
          Text('人工 30%').fontSize(10).fontColor(RV_COLORS.textMain).backgroundColor(RV_COLORS.deep)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(12).margin({ left: 8 })
          Text('设计 12%').fontSize(10).fontColor(RV_COLORS.textMain).backgroundColor(RV_COLORS.deep)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 }).borderRadius(12).margin({ left: 8 })
        }
        .width('100%').justifyContent(FlexAlign.Center).margin({ top: 16 })

        Row() {
          Text('取消').fontSize(13).fontColor(RV_COLORS.textSub)
            .backgroundColor(RV_COLORS.deep).borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .onClick(() => { this.showBudget = false })
          Text('保存预算').fontSize(13).fontColor('#FFFFFF')
            .backgroundColor(RV_COLORS.orange).borderRadius(18)
            .padding({ left: 24, right: 24, top: 9, bottom: 9 })
            .margin({ left: 12 })
            .onClick(() => { this.showBudget = false })
        }
        .width('100%').justifyContent(FlexAlign.Center)
        .padding({ top: 18, bottom: 20 })
      }
      .width('90%')
      .backgroundColor(RV_COLORS.card).borderRadius(16)
      .position({ x: '5%', y: '30%' })
    }
    .width('100%').height('100%')
    .position({ x: 0, y: 0 }).zIndex(999)
  }

在这里插入图片描述

预算编辑弹窗采用居中卡片式形态,核心交互是Slider滑块组件。Slider通过style: SliderStyle.OutSet指定为外置式样式——滑块(block)在轨道(track)外部可见,比内置式(InSet)更易于触摸操作。滑块颜色为砖红色,已选轨道为焊接橙色,未选轨道为浅灰色,形成三色分明的视觉层次。

预算值通过rvBudgetWan函数从原始数值(60-400)转换为万元单位(6.0-40.0),通过rvBudgetPerArea函数折算为每平方米单价。这两个纯函数确保了数据展示的一致性,无论在哪个组件中引用预算值,格式化逻辑都是统一的。

回调

回调

回调

回调

回调

回调

onCaseDetail

onMeasure

onBudget

onStyleSelect

onDelPlan

onQuote

onBookCrew

onClearFav

RenovaApp 入口组件

7个弹窗状态 开关

4个选中索引

8个表单字段

焊光特效 weldPulse/weldScale

CaseContent

PlanContent

MaterialContent

CrewContent

AcceptContent

MeContent

caseDetailModal

measureModal

budgetModal

styleModal

delPlanModal

bookModal

clearFavModal

contentArea 内容区域

焊光火花 全局装饰

  @Builder contentArea() {
    Stack() {
      if (this.activeTab === RVTab.Case) {
        CaseContent({
          onCaseDetail: (idx: number) => {
            this.selCaseIdx = idx
            this.showCaseDetail = true
          },
          onMeasure: () => { this.showMeasure = true }
        })
      } else if (this.activeTab === RVTab.Plan) {
        PlanContent({
          onBudget: () => { this.showBudget = true },
          onStyleSelect: () => { this.showStyle = true },
          onDelPlan: (idx: number) => {
            this.delPlanIdx = idx
            this.showDelPlan = true
          }
        })
      } else if (this.activeTab === RVTab.Mat) {
        MaterialContent({
          onQuote: () => { this.showBudget = true }
        })
      } else if (this.activeTab === RVTab.Crew) {
        CrewContent({
          onBookCrew: (idx: number) => {
            this.bookCrewIdx = idx
            this.showBook = true
          }
        })
      } else if (this.activeTab === RVTab.Accept) {
        AcceptContent()
      } else {
        MeContent({
          onClearFav: () => { this.showClearFav = true }
        })
      }

      // 右下角焊光火花
      Text('✨').fontSize(16)
        .opacity(this.weldPulse)
        .scale({ x: this.weldScale, y: this.weldScale })
        .position({ x: '86%', y: '84% })

      // 弹框层
      if (this.showMeasure) { this.measureModal() }
      if (this.showBudget) { this.budgetModal() }
      if (this.showStyle) { this.styleModal() }
      if (this.showBook) { this.bookModal() }
      if (this.showDelPlan) { this.delPlanModal() }
      if (this.showCaseDetail) { this.caseDetailModal() }
      if (this.showClearFav) { this.clearFavModal() }
    }
    .width('100%').height('100%')
  }

在这里插入图片描述

contentArea()是内容区域的核心构建方法,展示了ArkUI中跨组件通信的标准模式。子组件通过参数传递接收回调函数——CaseContent接收onCaseDetailonMeasure两个回调,PlanContent接收onBudgetonStyleSelectonDelPlan三个回调,以此类推。当子组件内部的用户交互需要打开弹窗时,调用对应的回调函数,由入口组件修改弹窗状态变量来控制显示。

contentArea()使用Stack容器将三层内容叠放:最底层是条件渲染的子组件页面,中间层是焊光火花装饰元素(通过position绝对定位在右下角),最上层是7个条件渲染的弹窗。这种三层Stack结构确保了弹窗始终在最上层显示,焊光装饰元素在弹窗和页面之间。

技术要点:ArkUI中子组件向父组件通信的标准方式是通过回调函数参数。父组件在创建子组件时传入箭头函数作为参数,子组件在特定交互发生时调用该函数。这种模式被称为"属性向下,事件向上"(props down, events up),是声明式UI框架中标准的组件通信范式。与事件总线(EventBus)或状态管理库(如Redux)相比,回调函数方式更轻量、类型安全,且数据流方向清晰可追踪。

  build() {
    Column() {
      Row() {
        Row() {
          Text('🔨').fontSize(16)
          Column() {
            Text('RENOVA').fontSize(14).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.brick)
            Text('焕新工场').fontSize(8).fontColor(RV_COLORS.textSub).margin({ top: 1 })
          }
          .alignItems(HorizontalAlign.Start).padding({ left: 6 })
        }
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('🔍').fontSize(12).margin({ left: 10 })
          TextInput({ placeholder: '搜案例 / 方案 / 建材 / 工长' })
            .placeholderColor(RV_COLORS.textHint).fontSize(12)
            .backgroundColor(RV_COLORS.card).borderRadius(16)
            .layoutWeight(1).height(32)
            .margin({ left: 6, right: 6 })
            .padding({ left: 10, right: 10 })
        }
        .layoutWeight(1)
        .margin({ left: 12, right: 8 })
        .backgroundColor(RV_COLORS.card)
        .borderRadius(16)
        .height(32)

        Text('🧾 报价').fontSize(11).fontColor('#FFFFFF')
          .backgroundColor(RV_COLORS.brick)
          .padding({ left: 12, right: 12, top: 7, bottom: 7 })
          .borderRadius(14)
          .onClick(() => { this.showBudget = true })
      }
      .width('100%').height(56)
      .backgroundColor(RV_COLORS.deep)
      .alignItems(VerticalAlign.Center)
      .padding({ left: 10, right: 10 })

      Column() {
        this.contentArea()
      }
      .layoutWeight(1)
      .backgroundColor(RV_COLORS.bg)

      Row() {
        ForEach(RV_TABS, (item: RVTabItem) => {
          Column() {
            Text(item.icon).fontSize(17)
              .opacity(this.activeTab === item.tab ? 1.0 : 0.45)
            Text(item.label).fontSize(9)
              .fontColor(this.activeTab === item.tab ? RV_COLORS.brick : RV_COLORS.textSub)
              .fontWeight(this.activeTab === item.tab ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 1 })
            if (this.activeTab === item.tab) {
              Column().width(16).height(3)
                .backgroundColor(RV_COLORS.brick).borderRadius(2).margin({ top: 2 })
            } else {
              Column().width(16).height(3)
                .backgroundColor('rgba(163,71,48,0.12)').borderRadius(2).margin({ top: 2 })
            }
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 5, bottom: 5 })
          .onClick(() => { this.activeTab = item.tab })
        })
      }
      .width('100%')
      .backgroundColor(RV_COLORS.deep)
      .padding({ top: 3, bottom: 4 })
      .border({ width: 1, color: RV_COLORS.line })
    }
    .width('100%').height('100%')
    .backgroundColor(RV_COLORS.bg)
  }
}

build()方法构建了应用的整体框架:顶部导航栏(含品牌Logo、搜索框和报价入口按钮)、内容区域和底部Tab栏。底部Tab栏使用ForEach遍历RV_TABS数组,与前一应用的参数化Builder方式不同,这里直接在ForEach内部生成每个Tab项。选中Tab下方显示砖红色指示条,未选中Tab下方显示淡砖红色(rgba透明度0.12)的占位条——这种设计确保了Tab栏底部始终有指示条存在,只是颜色和透明度不同,避免了Tab切换时指示条出现/消失导致的布局跳动。

四、案例模块组件分析

案例模块展示老房改造案例,包含横幅、分类筛选和案例大卡列表。

@Component
struct CaseContent {
  @State catFilter: string = '全部'
  @State bannerGlow: number = 0.2
  @State flowX: number = 0
  onCaseDetail: (idx: number) => void = (idx: number) => {}
  onMeasure: () => void = () => {}

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 1800,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.bannerGlow = 0.95
    })
    this.getUIContext().animateTo({
      duration: 2000,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.flowX = 140
    })
  }

CaseContent组件接收两个回调函数参数:onCaseDetail用于请求查看案例详情,onMeasure用于请求量房。这两个参数都提供了默认的空函数实现(() => {}),确保即使不传参也不会在调用时报错。这是ArkTS中可选回调参数的标准写法。

组件管理3个状态变量:catFilter记录当前分类筛选值,bannerGlow控制横幅火花的不透明度动画,flowX控制工期进度条上流动光效的横向位移。两个动画分别使用1800毫秒和2000毫秒的时长,不同的节奏避免了视觉上的机械感。

  build() {
    Scroll() {
      Column() {
        // 横幅
        Stack() {
          Column()
            .width('100%').height(112)
            .linearGradient({
              direction: GradientDirection.Right,
              colors: [[RV_COLORS.brickDeep, 0], [RV_COLORS.orange, 1]]
            })
            .borderRadius(14)
          Text('⚡').fontSize(22)
            .opacity(this.bannerGlow)
            .position({ x: '84%', y: 14 })
          Text('🛠️').fontSize(16)
            .position({ x: '70%', y: 52 })
            .opacity(this.bannerGlow)
          Column() {
            Text('RENOVA 焕新工场 · 老房爆改实验室').fontSize(15).fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('累计爆改 386 套 · 平均节省 32% 工期 · 0 返工质保')
              .fontSize(9).fontColor('#F2EEE8').margin({ top: 4 })
            Row() {
              Text('🏗️ 看案例').fontSize(10).fontColor(RV_COLORS.brickDeep)
                .backgroundColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .margin({ top: 10 })
              Text('📏 免费约量房').fontSize(10).fontColor('#FFFFFF')
                .backgroundColor('rgba(255,255,255,0.25)')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .margin({ top: 10, left: 8 })
                .onClick(() => { this.onMeasure() })
            }
          }
          .alignItems(HorizontalAlign.Start)
          .position({ x: 16, y: 16 })
        }
        .width('100%').height(112).margin({ top: 10 })

横幅使用Stack三层叠构建:底层是渐变背景(从深砖红到焊接橙的横向渐变),中层是两个闪烁图标(闪电和工具,通过opacity(this.bannerGlow)绑定呼吸动画),顶层是标题文字和操作按钮。横幅上的"免费约量房"按钮通过onClick(() => { this.onMeasure() })调用父组件传入的回调函数,触发量房弹窗显示。

        // 分类 chips
        Scroll() {
          Row() {
            ForEach(RV_CASE_TYPES, (t: string) => {
              if (this.catFilter === t) {
                Text(t).fontSize(11).fontColor('#FFFFFF').backgroundColor(RV_COLORS.brick)
                  .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                  .margin({ right: 8 })
              } else {
                Text(t).fontSize(11).fontColor(RV_COLORS.textSub).backgroundColor(RV_COLORS.card)
                  .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                  .margin({ right: 8 })
                  .border({ width: 1, color: RV_COLORS.line })
                  .onClick(() => { this.catFilter = t })
              }
            })
          }
          .padding({ left: 2, right: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%').margin({ top: 12 })

分类chips使用横向滚动Scroll容器包裹。选中态为白字砖红背景无边框,未选中态为灰字浅色背景带边框——浅色主题下通过border({ width: 1, color: RV_COLORS.line })为未选中chips添加细边框,增强了可点击区域的可感知性。

        // 案例大卡列表
        ForEach(RV_CASES, (c: RVCase, idx: number) => {
          if (this.catFilter === '全部' || this.catFilter === c.type) {
            Column() {
              Row() {
                Text(c.icon).fontSize(20)
                  .width(42).height(42).borderRadius(12)
                  .backgroundColor(c.color).textAlign(TextAlign.Center)
                Column() {
                  Text(c.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
                  Row() {
                    Text(c.type).fontSize(9).fontColor('#FFFFFF')
                      .backgroundColor(RV_COLORS.brick)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                    Text(c.tag).fontSize(9).fontColor(RV_COLORS.orange)
                      .backgroundColor(RV_COLORS.deep)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .margin({ left: 6 })
                  }
                  .margin({ top: 5 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
                Column() {
                  Text('¥' + c.price.toFixed(1) + '万').fontSize(14).fontWeight(FontWeight.Bold)
                    .fontColor(RV_COLORS.brick)
                  Text(c.progress >= 100 ? '已完工' : '施工中').fontSize(9)
                    .fontColor(rvProgressColor(c.progress)).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Center)
              }
              .width('100%')

              // 改造前后对比条
              Stack() {
                Row() {
                  Column().width(rvCaseSplitOld(c)).height(30).backgroundColor('#CBBFB0')
                  Column().layoutWeight(1).height(30).backgroundColor(c.color)
                }
                .width('100%').borderRadius(6)
                Text('老房 ' + c.oldArea + '㎡  →  新家 ' + c.area + '㎡')
                  .fontSize(9).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              }
              .width('100%').height(30).margin({ top: 10 })

              Row() {
                Text('📐 面积 ' + c.area.toString() + '㎡').fontSize(9).fontColor(RV_COLORS.textSub)
                Text('📅 第 ' + rvCaseDay(c) + ' / ' + c.period.toString() + ' 天')
                  .fontSize(9).fontColor(RV_COLORS.textSub).margin({ left: 12 })
                Column().layoutWeight(1)
                Text(c.progress.toString() + '%').fontSize(10).fontWeight(FontWeight.Bold)
                  .fontColor(rvProgressColor(c.progress))
              }
              .width('100%').margin({ top: 8 })

              // 工期进度条(流动动画)
              Stack() {
                Row() {
                  Column().width(c.progress + '%').height(8)
                    .backgroundColor(rvProgressColor(c.progress)).borderRadius(4)
                  Column().layoutWeight(1).height(8)
                    .backgroundColor(RV_COLORS.deep).borderRadius(4)
                }
                .width('100%')
                Column().width(22).height(8).backgroundColor('#FFFFFF')
                  .opacity(0.5).borderRadius(4)
                  .position({ x: 0, y: 0 })
                  .translate({ x: this.flowX })
              }
              .width('100%').height(8).margin({ top: 6 })

              Row() {
                Text('查看方案报价').fontSize(11).fontColor('#FFFFFF')
                  .backgroundColor(RV_COLORS.brick)
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(13)
                  .margin({ top: 10 })
                  .onClick(() => { this.onCaseDetail(idx) })
                Text('免费量房').fontSize(11).fontColor(RV_COLORS.brick)
                  .backgroundColor(RV_COLORS.deep)
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(13)
                  .margin({ top: 10, left: 8 })
                  .onClick(() => { this.onMeasure() })
                Column().layoutWeight(1)
                Text('›').fontSize(18).fontColor(RV_COLORS.textHint)
                  .margin({ top: 6 })
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .backgroundColor(RV_COLORS.card)
            .borderRadius(14)
            .border({ width: 1, color: RV_COLORS.line })
            .margin({ top: 10 })
          }
        })

案例大卡列表通过ForEach遍历RV_CASES数组,使用if条件筛选匹配当前分类的案例。每个案例卡片包含:案例图标与名称标签、改造前后面积对比条、工期信息行和工期进度条。

改造前后面积对比条是案例卡片的特色视觉元素。它通过Stack层叠实现:底层是Row容器,内含两个Column——左侧Column宽度为rvCaseSplitOld(c)(老房面积占比),使用浅灰色#CBBFB0;右侧Column通过layoutWeight(1)占据剩余空间,使用案例主题色。上层是一个居中的白色文字,展示"老房XX㎡ → 新家XX㎡"的对比信息。这种双色对比条直观地展示了改造前后面积的变化关系。

工期进度条包含流动光效——在进度条之上叠加了一个白色半透明小条(22像素宽),通过translate({ x: this.flowX })绑定横向位移动画,在进度条上来回滑动,模拟工期内动的流动感。这是该应用中最具工业风特色的动画效果之一,模拟了工厂流水线上产品移动的视觉效果。

技术要点translate是ArkUI的平移变换方法,通过xy参数指定位移量。与布局方法position不同,translate不改变元素在布局树中的位置,只在渲染层面进行视觉位移。translate常用于在固定容器内创建移动效果——如本例中的进度条光效,进度条本身的布局位置不变,但光效通过translate在进度条上方移动。

五、方案模块组件分析

方案模块提供设计风格选择、造价对比和报价表。

@Component
struct PlanContent {
  @State chartGlow: number = 1
  onBudget: () => void = () => {}
  onStyleSelect: () => void = () => {}
  onDelPlan: (idx: number) => void = (idx: number) => {}

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 1600,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.chartGlow = 1.18
    })
  }

PlanContent接收三个回调函数参数,管理1个动画状态chartGlow。造价对比图中最高柱子(第4根,索引3)通过scale({ x: this.chartGlow, y: this.chartGlow })绑定缩放动画,在1到1.18之间往复变化,形成柱子轻微"呼吸"的视觉效果。

  build() {
    Scroll() {
      Column() {
        // 横向滑动风格卡
        Scroll() {
          Row() {
            ForEach(RV_PLANS, (p: RVPlan, idx: number) => {
              Column() {
                Row() {
                  Text(p.hot ? '🔥' : '📌').fontSize(10)
                  Column().layoutWeight(1)
                  Text('🗑️').fontSize(10).fontColor(RV_COLORS.textHint)
                    .onClick(() => { this.onDelPlan(idx) })
                }
                .width('100%')
                Text(p.icon).fontSize(30).margin({ top: 6 })
                Text(p.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
                  .maxLines(1).margin({ top: 6 })
                Text('¥' + p.price.toString() + '/㎡').fontSize(13).fontWeight(FontWeight.Bold)
                  .fontColor(RV_COLORS.brick).margin({ top: 4 })
                Text('已售 ' + p.sales.toString() + ' 套 · ' + p.days.toString() + ' 天')
                  .fontSize(8).fontColor(RV_COLORS.textSub).margin({ top: 3 })
              }
              .width(148)
              .padding(12)
              .backgroundColor(RV_COLORS.card)
              .borderRadius(12)
              .border({ width: 1, color: RV_COLORS.line })
              .margin({ right: 10 })
              .alignItems(HorizontalAlign.Center)
            })
          }
          .padding({ left: 2, right: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%').margin({ top: 10 })

风格卡使用横向滚动Scroll容器,每张卡片宽度固定148像素。卡片顶部行展示热销标签(🔥或📌)和删除按钮,删除按钮通过onClick(() => { this.onDelPlan(idx) })调用父组件回调触发删除弹窗。卡片主体展示风格图标、名称、单价、销量和工期。

        // 斑马纹报价表
        Row() {
          Text('项目').fontSize(10).fontColor(RV_COLORS.textSub).layoutWeight(1)
          Text('金额').fontSize(10).fontColor(RV_COLORS.textSub).width(72).textAlign(TextAlign.End)
          Text('说明').fontSize(10).fontColor(RV_COLORS.textSub).width(70).textAlign(TextAlign.End)
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        .backgroundColor(RV_COLORS.charcoal)

        ForEach(RV_QUOTE_LINES, (q: RVQuoteLine, idx: number) => {
          Row() {
            Text(q.item).fontSize(10).fontColor(RV_COLORS.textMain).layoutWeight(1)
            Text(q.price).fontSize(10).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.brick)
              .width(72).textAlign(TextAlign.End)
            Text(q.note).fontSize(8).fontColor(RV_COLORS.textSub).width(70).textAlign(TextAlign.End)
          }
          .width('100%')
          .padding({ top: 9, bottom: 9 })
          .backgroundColor(idx % 2 === 0 ? RV_COLORS.card : '#F0EAE0')
          .margin({ top: 2 })
        })

        Row() {
          Text('合计(约)').fontSize(11).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Column().layoutWeight(1)
          Text('¥131,300').fontSize(15).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.brick)
        }
        .width('100%')
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(RV_COLORS.deep)
        .borderRadius(8)
        .margin({ top: 4 })

斑马纹报价表由表头行、数据行和合计行三部分组成。表头行使用RV_COLORS.charcoal(#3A3633深色)背景配浅灰文字,与数据行形成鲜明的视觉分隔。数据行通过idx % 2 === 0 ? RV_COLORS.card : '#F0EAE0'实现斑马纹交替背景色——偶数行使用#FBF8F3,奇数行使用略深的#F0EAE0,两者之间的色差比深色主题中更微妙,需要精心调校才能在浅色背景上形成可辨识的斑马纹效果。

报价表使用三列布局:项目名称通过layoutWeight(1)占据弹性宽度,金额列固定72像素宽右对齐,说明列固定70像素宽右对齐。数字右对齐是财务报表的标准排版方式,便于纵向比较金额大小。

六、建材模块组件分析

建材模块以双列网格展示建材商品,包含库存水位条和销量排行榜。

@Component
struct MaterialContent {
  @State catFilter: string = '全部'
  @State flashOp: number = 0.15
  onQuote: () => void = () => {}

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 1700,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.flashOp = 0.6
    })
  }

MaterialContent管理2个状态变量。flashOp控制库存水位条上闪光效果的透明度动画——从0.15到0.6的往复变化,模拟库存水位的波动感。这种闪光效果叠加在进度条之上,通过position({ x: 0, y: 0 })定位在进度条起始位置,配合opacity(this.flashOp)绑定动画。

                // 库存水位条
                Row() {
                  Text('库存').fontSize(8).fontColor(RV_COLORS.textSub).width(24)
                  Stack() {
                    Row() {
                      Column().width(m.stock + '%').height(6)
                        .backgroundColor(rvStockColor(m.stock)).borderRadius(3)
                      Column().layoutWeight(1).height(6)
                        .backgroundColor(RV_COLORS.deep).borderRadius(3)
                    }
                    .width('100%')
                    Column().width(16).height(6).backgroundColor('#FFFFFF')
                      .opacity(this.flashOp).borderRadius(3)
                      .position({ x: 0, y: 0 })
                  }
                  .layoutWeight(1).height(6).margin({ left: 4 })
                  Text(m.stock.toString() + '%').fontSize(8)
                    .fontColor(rvStockColor(m.stock)).width(28).textAlign(TextAlign.End)
                }
                .width('100%').margin({ top: 6 }).alignItems(VerticalAlign.Center)

库存水位条是建材卡片的特色视觉元素。它使用Stack三层叠:底层是进度条(Row内含填充Column和剩余Column),中层是闪光条(白色半透明小条通过position和opacity绑定动画),上层无。库存百分比直接映射为进度条宽度——m.stock + '%',库存颜色通过rvStockColor(m.stock)函数返回(低于25%红色、低于50%橙色、其余绿色)。

        // 建材销量榜
        Column() {
          ForEach(rvSalesRanked(), (m: RVMaterial, idx: number) => {
            if (idx < 6) {
              Row() {
                Text(rvMedalIcon(idx + 1)).fontSize(12).width(24)
                Text(m.icon).fontSize(12).width(22).textAlign(TextAlign.Center)
                Text(m.name).fontSize(10).fontColor(RV_COLORS.textMain).layoutWeight(1).maxLines(1)
                  .padding({ left: 4 })
                Column() {
                  Column().width(rvSalesBarW(m.sales)).height(8)
                    .backgroundColor(idx === 0 ? RV_COLORS.orange : RV_COLORS.brick)
                    .borderRadius(4)
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).margin({ left: 6, right: 6 })
                Text(m.sales.toString()).fontSize(10).fontWeight(FontWeight.Bold)
                  .fontColor(RV_COLORS.brick).width(30).textAlign(TextAlign.End)
              }
              .width('100%')
              .padding({ top: 8, bottom: 8 })
              .alignItems(VerticalAlign.Center)
            }
          })
        }

建材销量榜通过rvSalesRanked()函数获取按销量降序排列的建材列表,仅展示前6名。排名图标通过rvMedalIcon函数返回——前三名返回金银铜牌emoji(🥇🥈🥉),其余返回数字字符串。销量进度条宽度通过rvSalesBarW函数计算(按最大值520的比例换算为百分比),第一名使用焊接橙色,其余使用砖红色。

七、施工队模块组件分析

施工队模块展示工长排行榜和接单量条形图。

@Component
struct CrewContent {
  @State medalRot: number = 0
  onBookCrew: (idx: number) => void = (idx: number) => {}

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 2200,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.medalRot = 6
    })
  }

CrewContent管理1个动画状态medalRot,控制排行榜第一名奖牌的微摆动画——在0到6度之间往复旋转,模拟奖牌在微风中轻轻摇摆的效果。2200毫秒的动画时长是最慢的,营造出沉缓的摇摆节奏。

        // 工长排行榜
        ForEach(rvCrewByOrders(), (c: RVCrew, idx: number) => {
          Row() {
            Text(rvMedalIcon(idx + 1)).fontSize(15)
              .fontColor(rvMedalColor(idx + 1))
              .width(30).textAlign(TextAlign.Center)
              .rotate({ angle: idx === 0 ? this.medalRot : 0 })
            Column() {
              Text(c.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
              Text(c.tag + ' · ' + c.years + ' 年').fontSize(9).fontColor(RV_COLORS.textSub).margin({ top: 3 })
            }
            .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 6 })
            Column() {
              Text(c.orders.toString() + ' 单').fontSize(11).fontWeight(FontWeight.Bold)
                .fontColor(RV_COLORS.brick)
              Text('好评 ' + c.rate.toString() + '%').fontSize(8).fontColor(RV_COLORS.textSub).margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
            Text('⭐ ' + c.score.toFixed(1)).fontSize(10).fontWeight(FontWeight.Bold)
              .fontColor(rvScoreColor(c.score)).width(44).textAlign(TextAlign.Center)
            Text('预约').fontSize(10).fontColor('#FFFFFF')
              .backgroundColor(RV_COLORS.brick)
              .padding({ left: 10, right: 10, top: 6, bottom: 6 })
              .borderRadius(11)
              .onClick(() => { this.onBookCrew(rvCrewIdxByRank(idx)) })
          }
          .width('100%')
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(idx % 2 === 0 ? RV_COLORS.card : '#F0EAE0')
          .borderRadius(10)
          .margin({ top: 6 })
          .alignItems(VerticalAlign.Center)
        })

工长排行榜通过rvCrewByOrders()函数获取按接单量降序排列的工长列表。每行包含奖牌图标(第一名通过rotate({ angle: this.medalRot })绑定微摆动画)、工长信息、接单量、评分和预约按钮。预约按钮通过this.onBookCrew(rvCrewIdxByRank(idx))调用回调——rvCrewIdxByRank函数将排行榜中的索引转换为原始数据数组中的索引,确保预约的是正确的工长。

八、验收模块组件分析

验收模块通过垂直时间轴展示10个验收节点,是整个应用中最具空间特色的布局设计。

@Component
struct AcceptContent {
  @State checkScale: number = 1

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 1500,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.checkScale = 1.25
    })
  }

AcceptContent管理1个动画状态checkScale,控制已验收节点图标的脉冲缩放——在1到1.25之间往复变化,模拟验收通过的视觉反馈。

        // 验收时间轴
        ForEach(RV_ACCEPTS, (a: RVAccept) => {
          Row() {
            // 左侧日期块 + 轴线
            Column() {
              Text(a.date).fontSize(9).fontColor(RV_COLORS.textSub)
              Text(a.icon).fontSize(15)
                .width(30).height(30).borderRadius(15)
                .backgroundColor(a.status === '已验收' ? '#E7EFE3' : RV_COLORS.deep)
                .textAlign(TextAlign.Center)
                .margin({ top: 3 })
                .scale({ x: a.status === '已验收' ? this.checkScale : 1, y: a.status === '已验收' ? this.checkScale : 1 })
              Column()
                .width(2).height(52)
                .backgroundColor(a.status === '已验收' ? RV_COLORS.green : RV_COLORS.line)
                .margin({ top: 4 })
            }
            .width(52)
            .alignItems(HorizontalAlign.Center)

            // 右侧节点卡
            Column() {
              Row() {
                Text(a.phase).fontSize(13).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
                  .layoutWeight(1)
                Text(a.status).fontSize(9).fontColor('#FFFFFF')
                  .backgroundColor(rvAcceptStatusColor(a.status))
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                  .borderRadius(8)
              }
              .width('100%')
              Text(a.desc).fontSize(10).fontColor(RV_COLORS.textSub).width('100%').margin({ top: 5 })
              if (a.score > 0) {
                Row() {
                  Text('⭐ 验收评分 ' + a.score.toString()).fontSize(10).fontWeight(FontWeight.Bold)
                    .fontColor(RV_COLORS.orange).margin({ top: 6 })
                  Column().layoutWeight(1)
                  Text('查看报告 ›').fontSize(9).fontColor(RV_COLORS.brick).margin({ top: 6 })
                }
                .width('100%')
              }
            }
            .layoutWeight(1)
            .padding(11)
            .backgroundColor(RV_COLORS.card)
            .borderRadius(12)
            .border({ width: 1, color: RV_COLORS.line })
            .margin({ top: 6, bottom: 2 })
            .alignItems(HorizontalAlign.Start)
          }
          .width('100%')
          .alignItems(VerticalAlign.Top)
        })

验收时间轴采用左右分栏布局:左侧52像素宽的Column包含日期、图标和连接线,右侧通过layoutWeight(1)占据剩余空间的Column包含节点详情卡片。左侧的连接线是一个宽2像素、高52像素的Column,已验收节点使用绿色线,未验收节点使用灰色线,形成时间轴的视觉轴线。

已验收节点的图标通过scale({ x: this.checkScale, y: this.checkScale })绑定脉冲缩放动画——从1到1.25的往复变化使得已验收节点在视觉上"呼吸",与未验收节点的静止状态形成对比。节点详情卡片中,当a.score > 0时显示评分行,否则隐藏——这种条件渲染避免了未验收节点显示无意义的0分评分。

技术要点scale是ArkUI的缩放变换方法,通过xy参数指定横纵向的缩放比例。值为1表示原始大小,大于1放大,小于1缩小。scale是图形变换而非布局变换——缩放后元素在布局中占用的空间不变,只在渲染层面进行视觉缩放。这使得scale非常适合用于创建不影响布局的脉冲、弹跳等微交互动画。

验收时间轴布局

左侧 52px固定宽

右侧 layoutWeight弹性宽

日期文字

图标圆形背景

连接线 2x52px

status === 已验收?

绿色背景 脉冲缩放

灰色背景 静止

status === 已验收?

绿色连线

灰色连线

节点标题 + 状态标签

节点描述

score > 0?

评分行 + 查看报告

隐藏评分行

九、个人中心模块分析

个人中心模块包含会员卡光泽移动效果、月消费柱状图、收藏列表、订单列表和设置项。

@Component
struct MeContent {
  @State shineX: number = -70
  @State shineOp: number = 0.2
  onClearFav: () => void = () => {}

  aboutToAppear() {
    this.getUIContext().animateTo({
      duration: 2200,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.shineX = 70
      this.shineOp = 0.5
    })
  }

MeContent管理2个动画状态:shineX控制会员卡光泽条的横向位移(从-70到70),shineOp控制光泽条的不透明度(从0.2到0.5)。两个状态同步动画,模拟光泽从左到右滑过会员卡表面的视觉效果。

        // 会员卡
        Stack() {
          Column()
            .width('100%').height(120)
            .linearGradient({
              direction: GradientDirection.RightBottom,
              colors: [[RV_COLORS.brickDeep, 0], [RV_COLORS.brick, 0.6], [RV_COLORS.orange, 1]]
            })
            .borderRadius(14)
          Column().width(120).height(7)
            .backgroundColor('#FFFFFF')
            .opacity(this.shineOp)
            .rotate({ angle: -16 })
            .translate({ x: this.shineX })
            .position({ x: 40, y: 46 })
          Row() {
            Column() {
              Text('老韩工友').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('铂金会员 · 工龄 6 年').fontSize(10).fontColor('#F2EEE8').margin({ top: 5 })
              Row() {
                Text('编号 RV-20260827').fontSize(8).fontColor('#F2EEE8')
                  .backgroundColor('rgba(255,255,255,0.2)')
                  .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                  .borderRadius(6)
                  .margin({ top: 8 })
              }
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Column() {
              Text('8,880').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text('可用积分').fontSize(9).fontColor('#F2EEE8').margin({ top: 2 })
              Text('3 张优惠券').fontSize(8).fontColor('#F2EEE8')
                .backgroundColor('rgba(255,255,255,0.2)')
                .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                .borderRadius(6)
                .margin({ top: 8 })
            }
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .padding({ left: 16, right: 16 })
          .position({ x: 0, y: 22 })
          .alignItems(VerticalAlign.Center)
        }
        .width('100%').height(120).margin({ top: 10 })

会员卡使用Stack三层叠构建:底层是渐变背景(从深砖红到砖红到焊接橙的对角线渐变,使用GradientDirection.RightBottom方向),中层是光泽条(一个120x7像素的白色条,通过rotate({ angle: -16 })旋转-16度形成斜角,通过translate({ x: this.shineX })绑定横向位移动画,通过opacity(this.shineOp)绑定不透明度动画),顶层是会员信息内容。

光泽条动画是会员卡的核心视觉特色。三个变换方法——rotate(旋转)、translate(平移)、opacity(透明度)——组合使用,模拟了光线从卡片表面划过的效果。旋转-16度使光泽条呈斜角,与卡片的横向渐变方向形成视觉呼应。光泽条从左侧(x=-70)移动到右侧(x=70),同时透明度从0.2增加到0.5再返回,形成光泽渐强渐弱的扫光效果。

技术要点:在ArkUI中,rotatetranslatescale三种图形变换方法可以组合使用,实现复杂的视觉效果。它们的执行顺序为:先旋转,再缩放,最后平移。这三个变换都是渲染层面的操作,不影响元素的布局空间——这意味着变换后的元素不会推开周围的元素,也不会被周围元素的位置变化影响。这种特性使得它们非常适合用于装饰性的动画效果。

        // 订单列表
        ForEach(RV_ORDERS, (o: RVOrder) => {
          Column() {
            Row() {
              Text(o.no).fontSize(9).fontColor(RV_COLORS.textHint)
              Column().layoutWeight(1)
              Text(o.status).fontSize(9).fontColor('#FFFFFF')
                .backgroundColor(rvOrderStatusColor(o.status))
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .borderRadius(8)
            }
            .width('100%')
            Row() {
              Text(o.caseName).fontSize(11).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
                .layoutWeight(1).maxLines(1)
              Text(o.amount).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.brick)
            }
            .width('100%').margin({ top: 6 })
            Row() {
              Text('下单 ' + o.date).fontSize(8).fontColor(RV_COLORS.textSub).width(60)
              Stack() {
                Row() {
                  Column().width(o.progress + '%').height(5)
                    .backgroundColor(rvProgressColor(o.progress)).borderRadius(3)
                  Column().layoutWeight(1).height(5)
                    .backgroundColor(RV_COLORS.deep).borderRadius(3)
                }
                .width('100%')
              }
              .layoutWeight(1).height(5).margin({ left: 6 })
              Text(o.progress.toString() + '%').fontSize(8)
                .fontColor(rvProgressColor(o.progress)).width(28).textAlign(TextAlign.End)
            }
            .width('100%').margin({ top: 7 }).alignItems(VerticalAlign.Center)
          }
          .width('100%')
          .padding(10)
          .backgroundColor(RV_COLORS.card)
          .borderRadius(12)
          .border({ width: 1, color: RV_COLORS.line })
          .margin({ top: 6 })
          .alignItems(HorizontalAlign.Start)
        })

订单列表通过ForEach遍历RV_ORDERS数组,每个订单卡片包含订单编号与状态标签行、案例名称与金额行、下单日期与进度条行。进度条使用Stack+Row嵌套实现——外层Stack固定高度5像素,内层Row包含填充Column(宽度为o.progress + '%')和剩余Column,通过rvProgressColor(o.progress)返回进度对应的颜色。这种迷你进度条在订单列表中直观地展示了每个订单的施工进度。

十、弹窗系统设计模式分析

该应用定义了7个弹窗,全部集中在入口组件RenovaApp中。通过对这些弹窗的分析,可以总结出该应用的弹窗设计体系。

三种弹窗形态

  1. 底部抽屉式(measureModal、bookModal):内容面板通过position({ x: 0, y: '58%' })或类似值定位在屏幕下方,适合包含较多表单字段的输入类操作。使用constraintSize({ maxHeight: '34%' })限制表单滚动区域高度,确保底部按钮始终可见。
  2. 居中卡片式(budgetModal、styleModal、delPlanModal、caseDetailModal、clearFavModal):内容面板通过position({ x: '5%', y: '30%' })或类似值居中显示,适合确认类、选择类和详情查看类操作。
  3. 近全屏式(bookModal、caseDetailModal):内容面板宽度和高度较大,通过position({ x: '4%', y: '10%' })定位在屏幕上方,适合内容较多的详情展示。

统一的回调通信机制:所有弹窗的打开操作都由子组件通过回调函数触发。入口组件在创建子组件时传入回调函数,子组件在用户交互时调用回调。回调函数的实现通常包含两步:设置选中索引和设置弹窗开关为true。例如:

onCaseDetail: (idx: number) => {
  this.selCaseIdx = idx
  this.showCaseDetail = true
}

这种模式确保了弹窗的打开逻辑完全由入口组件控制,子组件只需要"报告"用户想要做什么,而不需要关心弹窗如何显示。

十一、组件与功能对比总表

组件名称 功能职责 核心状态变量 动画效果 回调函数 弹窗数量
RenovaApp 入口与弹窗集中管理 22个(7开关+4索引+8表单+2动画+1Tab) 焊光脉冲 无(接收所有回调) 7
CaseContent 改造案例展示与筛选 catFilter, bannerGlow, flowX 横幅火花+进度条流动 onCaseDetail, onMeasure 0
PlanContent 设计方案与报价表 chartGlow 造价柱呼吸 onBudget, onStyleSelect, onDelPlan 0
MaterialContent 建材商城与销量榜 catFilter, flashOp 库存条闪光 onQuote 0
CrewContent 工长排行与预约 medalRot 奖牌微摆 onBookCrew 0
AcceptContent 验收时间轴 checkScale 节点脉冲缩放 0
MeContent 个人中心 shineX, shineOp 会员卡光泽扫动 onClearFav 0
技术要点 使用场景 核心参数/说明
回调函数通信 子组件向父组件上报事件 箭头函数作为组件参数传入
constraintSize 限制弹窗滚动区域高度 maxHeight: ‘34%’
linearGradient 横幅、会员卡渐变背景 direction, colors[[色值, 位置]]
translate 光泽移动、进度条流动 x/y位移量
rotate 奖牌微摆、光泽条旋转 angle旋转角度
scale 节点脉冲、柱状图呼吸 x/y缩放比例
Slider 预算调节 value, min, max, step, style
Flex+Wrap 风格chips自动换行 wrap: FlexWrap.Wrap
border 浅色主题卡片边框 width: 1, color: line
ForEach+条件筛选 案例分类、建材分类 if条件过滤数据项

十二、全文技术总结

本文对基于鸿蒙HarmonyOS ArkTS语言开发的老房爆改设计工作室应用进行了全面的代码级技术剖析。该应用以复古工业风浅色暖调为视觉基调,通过六个功能模块构建了一个完整的旧房改造服务生态,涵盖案例展示、方案报价、建材选购、施工队管理、验收追踪和个人中心。

在架构层面,该应用最显著的设计决策是将全部7个弹窗集中定义在入口组件RenovaApp中。这种集中式弹窗管理架构带来了几个重要的优势:弹窗状态的唯一数据源确保了不会出现多个组件各自管理同一弹窗时的竞态条件;弹窗的打开和关闭逻辑完全由入口组件控制,子组件通过回调函数上报用户意图;弹窗的z-index层级由Stack容器的声明顺序天然保证,无需手动管理。子组件通过回调函数参数与父组件通信——CaseContent通过onCaseDetail和onMeasure回调上报案例详情查看和量房请求,PlanContent通过onBudget、onStyleSelect和onDelPlan三个回调上报预算编辑、风格选择和方案删除,CrewContent通过onBookCrew回调上报工长预约,MeContent通过onClearFav回调上报清空收藏。这种"属性向下,事件向上"的通信模式是ArkUI中跨组件交互的标准范式。

在数据模型层面,应用定义了12个interface接口,涵盖了改造案例、设计方案、报价行、建材、工长、验收、订单、收藏、消费、统计、设置项和风格宫格等业务实体。30余个纯函数封装了状态映射、数据计算、排序和格式化等业务逻辑。值得注意的是,部分接口字段(如RVQuoteLine.price)使用string类型而非number,直接存储格式化后的文本,简化了渲染逻辑。排序函数统一采用slice()复制再sort()的模式,确保不修改原始数据。

在视觉设计层面,复古工业风浅色主题对配色和边框的运用提出了与深色主题不同的要求。浅色背景下的容器层次通过微妙的色差(#F2EEE8主背景、#FBF8F3卡片背景、#E5DFD5深层背景)配合#D8D0C2线条色来构建,每个卡片都通过border({ width: 1, color: RV_COLORS.line })添加细边框,增强了浅色背景下的容器边界感。斑马纹交替背景色的色差更微妙(#FBF8F3与#F0EAE0之间仅差几个色阶),需要精心调校才能达到既可辨识又不刺眼的效果。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// RENOVA 焕新工场 —— 老房爆改设计工作室(我爱我家类·居家场景)
// 改造案例 + 设计方案报价 + 建材商城 + 工长施工队 + 阶段验收 + 我的
// 复古工业风浅色暖调 · ArkTS 单文件
// ============================================================

// ============ 配色(复古工业风:米白×砖红×焊接橙×水泥灰) ============
interface RVColorPalette {
  bg: string
  card: string
  deep: string
  line: string
  brick: string
  brickDeep: string
  cement: string
  orange: string
  charcoal: string
  textMain: string
  textSub: string
  textHint: string
  red: string
  green: string
  blue: string
  wood: string
}

const RV_COLORS: RVColorPalette = {
  bg: '#F2EEE8',
  card: '#FBF8F3',
  deep: '#E5DFD5',
  line: '#D8D0C2',
  brick: '#A34730',
  brickDeep: '#7E3423',
  cement: '#8C8C88',
  orange: '#E07B39',
  charcoal: '#3A3633',
  textMain: '#3A3633',
  textSub: '#8C8C88',
  textHint: '#B9B2A6',
  red: '#C0392B',
  green: '#6B8F5E',
  blue: '#3A6E8E',
  wood: '#8B5E3C'
}

// ============ Tab 定义 ============
enum RVTab {
  Case = 0,
  Plan = 1,
  Mat = 2,
  Crew = 3,
  Accept = 4,
  Me = 5
}

interface RVTabItem {
  tab: RVTab
  icon: string
  label: string
}

const RV_TABS: RVTabItem[] = [
  { tab: RVTab.Case, icon: '🏠', label: '案例' },
  { tab: RVTab.Plan, icon: '📐', label: '方案' },
  { tab: RVTab.Mat, icon: '🧱', label: '建材' },
  { tab: RVTab.Crew, icon: '👷', label: '施工队' },
  { tab: RVTab.Accept, icon: '✅', label: '验收' },
  { tab: RVTab.Me, icon: '👤', label: '我的' }
]

// ============ 数据接口 ============
interface RVCase {
  id: number
  name: string
  type: string
  area: number
  oldArea: number
  period: number
  progress: number
  price: number
  tag: string
  icon: string
  desc: string
  color: string
}

interface RVPlan {
  id: number
  name: string
  style: string
  price: number
  area: number
  total: number
  discount: number
  days: number
  sales: number
  hot: boolean
  icon: string
  color: string
  desc: string
}

interface RVQuoteLine {
  item: string
  price: string
  note: string
}

interface RVMaterial {
  id: number
  name: string
  cat: string
  price: number
  unit: string
  stock: number
  sales: number
  icon: string
  color: string
  tag: string
}

interface RVCrew {
  id: number
  name: string
  age: number
  years: number
  orders: number
  rate: number
  score: number
  tag: string
  icon: string
}

interface RVAccept {
  id: number
  phase: string
  date: string
  desc: string
  status: string
  icon: string
  score: number
}

interface RVOrder {
  id: number
  no: string
  caseName: string
  amount: string
  date: string
  status: string
  progress: number
}

interface RVFav {
  id: number
  name: string
  type: string
  price: string
  icon: string
  color: string
}

interface RVSpend {
  month: string
  amount: number
}

interface RVStat {
  label: string
  value: string
  unit: string
  icon: string
  color: string
}

interface RVSettingItem {
  icon: string
  label: string
  value: string
}

interface RVStyleGrid {
  name: string
  icon: string
  color: string
}

// ============ 全局硬编码数据 ============

// 12 个改造案例(老破小 / 学区房 / 筒子楼 / 家属院 / 自建房 / 公寓)
const RV_CASES: RVCase[] = [
  {
    id: 1, name: '55㎡学区老破小·三室焕新', type: '老破小', area: 55, oldArea: 42, period: 45,
    progress: 100, price: 12.8, tag: '学区房', icon: '🏫', color: '#A34730',
    desc: '三代同堂,两居改三居,全屋水电重排,儿童房做到顶收纳'
  },
  {
    id: 2, name: '筒子楼·35㎡一居爆改', type: '筒子楼', area: 35, oldArea: 28, period: 38,
    progress: 100, price: 8.6, tag: '极限空间', icon: '🏢', color: '#E07B39',
    desc: '5.2米层高做夹层,双层动线,楼梯下嵌入洗衣机与书桌'
  },
  {
    id: 3, name: '老家属院·88㎡全屋整装', type: '家属院', area: 88, oldArea: 72, period: 60,
    progress: 100, price: 19.9, tag: '整装', icon: '🏘️', color: '#6B8F5E',
    desc: '三十年老房,墙体全面加固,双层断桥窗隔音降噪'
  },
  {
    id: 4, name: '城中村自建房·一层半改造', type: '自建房', area: 120, oldArea: 96, period: 75,
    progress: 100, price: 26.5, tag: '大平层', icon: '🏠', color: '#8C8C88',
    desc: '挑高5.8米现浇夹层,一楼会客二楼起居,庭院打通'
  },
  {
    id: 5, name: '老房客厅扩容·阳台打通', type: '老破小', area: 62, oldArea: 50, period: 42,
    progress: 100, price: 13.6, tag: '扩容', icon: '🪟', color: '#A34730',
    desc: '阳台并入客厅,地台抬高,采光面积翻倍'
  },
  {
    id: 6, name: '二手房翻新·清水变精装', type: '二手房', area: 76, oldArea: 64, period: 52,
    progress: 88, price: 16.8, tag: '精装', icon: '🛋️', color: '#E07B39',
    desc: '原租户搬离后全屋重新设计,硬装软装一体交付'
  },
  {
    id: 7, name: '老公寓·复古工业风', type: '公寓', area: 70, oldArea: 58, period: 48,
    progress: 100, price: 17.2, tag: '工业风', icon: '🏭', color: '#3A3633',
    desc: '保留红砖肌理,铁艺轨道灯加原木家具,复古到骨子里'
  },
  {
    id: 8, name: '老食堂改造·Loft工作室', type: '商用', area: 150, oldArea: 120, period: 90,
    progress: 100, price: 35.0, tag: 'Loft', icon: '🍽️', color: '#A34730',
    desc: '旧食堂变身复式设计工作室,钢结构夹层荷载加固'
  },
  {
    id: 9, name: '筒子楼·一居变两居', type: '筒子楼', area: 48, oldArea: 38, period: 40,
    progress: 100, price: 10.5, tag: '学区', icon: '🏫', color: '#6B8F5E',
    desc: '玻璃隔断加折叠门,孩子学习区与卧室互不打扰'
  },
  {
    id: 10, name: '老小区顶楼·隔热防水', type: '老破小', area: 68, oldArea: 54, period: 35,
    progress: 100, price: 12.2, tag: '防水', icon: '🌧️', color: '#8C8C88',
    desc: '屋面重做三层防水,室内吊顶加保温层,夏天降温5度'
  },
  {
    id: 11, name: '单位分配房·水电全换', type: '家属院', area: 80, oldArea: 66, period: 55,
    progress: 72, price: 15.4, tag: '水电改造', icon: '⚡', color: '#E07B39',
    desc: '全屋水电重排,智能配电箱,弱电桥架隐藏走线'
  },
  {
    id: 12, name: '年代房·全屋智能化', type: '公寓', area: 90, oldArea: 74, period: 58,
    progress: 100, price: 21.0, tag: '智能家居', icon: '🤖', color: '#3A3633',
    desc: '全屋智能灯光窗帘安防,语音中控,老人模式一键切换'
  }
]

// 10 个设计方案(横向风格卡 + 斑马纹报价表 + 造价对比图)
const RV_PLANS: RVPlan[] = [
  {
    id: 1, name: '轻法式奶油风', style: '法式', price: 899, area: 88, total: 7.9,
    discount: 0.90, days: 45, sales: 126, hot: true, icon: '🕊️', color: '#D9C6B0',
    desc: '奶油白护墙板 + 石膏线,柔光砖通铺,法式拱门元素'
  },
  {
    id: 2, name: '日式原木风', style: '原木', price: 1099, area: 76, total: 8.4,
    discount: 0.92, days: 50, sales: 98, hot: false, icon: '🪵', color: '#B08968',
    desc: '橡木地板 + 纸灯 + 榻榻米茶室,收纳到顶不落灰'
  },
  {
    id: 3, name: '现代极简黑白灰', style: '极简', price: 799, area: 96, total: 7.7,
    discount: 0.88, days: 42, sales: 156, hot: true, icon: '⬛', color: '#3A3633',
    desc: '无主灯设计,悬浮电视墙,柜体全部隐形拉手'
  },
  {
    id: 4, name: '复古工业风', style: '工业', price: 1199, area: 70, total: 8.4,
    discount: 0.90, days: 55, sales: 64, hot: false, icon: '🏭', color: '#A34730',
    desc: '红砖墙 + 水泥地坪 + 黑铁管道灯,轨道射灯打亮'
  },
  {
    id: 5, name: '北欧浅色系', style: '北欧', price: 949, area: 82, total: 7.8,
    discount: 0.95, days: 40, sales: 132, hot: true, icon: '❄️', color: '#B8C9D4',
    desc: '大白墙 + 浅木色家具 + 绿植点缀,小户型显大'
  },
  {
    id: 6, name: '新中式', style: '中式', price: 1299, area: 110, total: 14.3,
    discount: 0.90, days: 65, sales: 45, hot: false, icon: '🏯', color: '#6B8F5E',
    desc: '胡桃木格栅 + 水墨背景墙,禅意茶室与书房结合'
  },
  {
    id: 7, name: '美式轻奢', style: '美式', price: 1499, area: 105, total: 15.7,
    discount: 0.92, days: 70, sales: 38, hot: false, icon: '🦅', color: '#3A6E8E',
    desc: '护墙板线条 + 黄铜五金 + 大沙发,客厅显阔气'
  },
  {
    id: 8, name: '侘寂风', style: '侘寂', price: 1350, area: 68, total: 9.2,
    discount: 0.90, days: 60, sales: 52, hot: false, icon: '🏺', color: '#A9A29A',
    desc: '微水泥墙面 + 亚麻布艺,自然肌理,岁月静好'
  },
  {
    id: 9, name: '波西米亚混搭', style: '混搭', price: 899, area: 58, total: 5.2,
    discount: 0.95, days: 45, sales: 87, hot: false, icon: '🌵', color: '#C46B3D',
    desc: '藤编家具 + 撞色软装 + 满墙绿植,度假感拉满'
  },
  {
    id: 10, name: '意式极简', style: '极简', price: 1399, area: 92, total: 12.9,
    discount: 0.90, days: 62, sales: 73, hot: false, icon: '🏛️', color: '#8C8C88',
    desc: '岩板台面 + 深色木饰面,高级感与收纳兼顾'
  }
]

// 斑马纹报价表 10 行
const RV_QUOTE_LINES: RVQuoteLine[] = [
  { item: '方案设计费', price: '¥8,800', note: '含3D效果图2版' },
  { item: '拆改工程', price: '¥6,500', note: '拆墙+渣土清运' },
  { item: '水电改造', price: '¥15,200', note: '全屋强弱电重排' },
  { item: '泥瓦防水', price: '¥12,800', note: '双层防水闭水试验' },
  { item: '木作吊顶', price: '¥9,600', note: '石膏板+护墙板' },
  { item: '油漆墙纸', price: '¥7,900', note: '环保乳胶漆三底两面' },
  { item: '主材代购', price: '¥48,000', note: '瓷砖地板卫浴五金' },
  { item: '软装搭配', price: '¥16,500', note: '家具窗帘灯具饰品' },
  { item: '管理费', price: '¥4,800', note: '工长驻场代管' },
  { item: '开荒保洁', price: '¥1,200', note: '深度保洁×2轮' }
]

// 16 款建材(双列 Grid + 库存水位条 + 销量榜)
const RV_MATS: RVMaterial[] = [
  { id: 1, name: '通体大理石瓷砖 750×1500', cat: '地砖', price: 128, unit: '片', stock: 72, sales: 420, icon: '🧱', color: '#A34730', tag: '热销' },
  { id: 2, name: '复合实木地板', cat: '地板', price: 168, unit: '㎡', stock: 45, sales: 315, icon: '🪵', color: '#8B5E3C', tag: '环保' },
  { id: 3, name: '抗甲醛净味乳胶漆', cat: '墙面', price: 298, unit: '桶', stock: 58, sales: 268, icon: '🛢️', color: '#6B8F5E', tag: '环保' },
  { id: 4, name: '无缝热烫墙布', cat: '墙面', price: 88, unit: '㎡', stock: 30, sales: 186, icon: '🧵', color: '#A9A29A', tag: '新品' },
  { id: 5, name: '智能马桶一体机', cat: '卫浴', price: 2999, unit: '台', stock: 22, sales: 142, icon: '🚽', color: '#3A6E8E', tag: '缺货' },
  { id: 6, name: '恒温花洒套装', cat: '卫浴', price: 1299, unit: '套', stock: 64, sales: 231, icon: '🚿', color: '#3A6E8E', tag: '热销' },
  { id: 7, name: '岩板浴室柜组合', cat: '卫浴', price: 2599, unit: '套', stock: 18, sales: 97, icon: '🪞', color: '#3A6E8E', tag: '缺货' },
  { id: 8, name: '实木颗粒橱柜', cat: '橱柜', price: 1880, unit: '延米', stock: 55, sales: 173, icon: '🍳', color: '#8C5A2B', tag: '定制' },
  { id: 9, name: '手工大单槽水槽', cat: '橱柜', price: 699, unit: '个', stock: 71, sales: 205, icon: '🚰', color: '#8C5A2B', tag: '爆款' },
  { id: 10, name: '无主灯磁吸轨道灯', cat: '灯具', price: 359, unit: '米', stock: 49, sales: 254, icon: '💡', color: '#E0A84C', tag: '新品' },
  { id: 11, name: '智能开关面板', cat: '灯具', price: 89, unit: '个', stock: 66, sales: 389, icon: '🎛️', color: '#E0A84C', tag: '爆款' },
  { id: 12, name: '实木复合木门', cat: '门窗', price: 1580, unit: '樘', stock: 27, sales: 118, icon: '🚪', color: '#7A6A5A', tag: '定制' },
  { id: 13, name: '断桥铝系统窗', cat: '门窗', price: 1280, unit: '㎡', stock: 15, sales: 84, icon: '🪟', color: '#7A6A5A', tag: '缺货' },
  { id: 14, name: '防水抗裂水泥', cat: '辅料', price: 42, unit: '袋', stock: 88, sales: 520, icon: '🏗️', color: '#8C8C88', tag: '刚需' },
  { id: 15, name: '国标铜芯电线', cat: '辅料', price: 356, unit: '卷', stock: 34, sales: 156, icon: '🔌', color: '#8C8C88', tag: '安全' },
  { id: 16, name: 'PPR热水管套装', cat: '辅料', price: 468, unit: '套', stock: 60, sales: 132, icon: '💧', color: '#8C8C88', tag: '热销' }
]

// 10 位工长(大序号排行榜 + 接单量条形图)
const RV_CREWS: RVCrew[] = [
  { id: 1, name: '王建国', age: 52, years: 32, orders: 286, rate: 98, score: 9.8, tag: '水电王牌', icon: '👷' },
  { id: 2, name: '李海生', age: 47, years: 26, orders: 241, rate: 97, score: 9.7, tag: '木工大家', icon: '🪚' },
  { id: 3, name: '张铁军', age: 49, years: 28, orders: 228, rate: 96, score: 9.6, tag: '砌筑老将', icon: '🧱' },
  { id: 4, name: '陈国栋', age: 44, years: 22, orders: 198, rate: 98, score: 9.8, tag: '整装交付', icon: '🏗️' },
  { id: 5, name: '刘卫东', age: 51, years: 30, orders: 265, rate: 95, score: 9.5, tag: '旧改专家', icon: '🔧' },
  { id: 6, name: '赵文杰', age: 40, years: 18, orders: 176, rate: 97, score: 9.7, tag: '水电快刀', icon: '⚡' },
  { id: 7, name: '孙志强', age: 46, years: 24, orders: 208, rate: 96, score: 9.6, tag: '防水卫士', icon: '💧' },
  { id: 8, name: '周立新', age: 43, years: 20, orders: 189, rate: 95, score: 9.5, tag: '油漆高手', icon: '🎨' },
  { id: 9, name: '吴国平', age: 50, years: 27, orders: 219, rate: 97, score: 9.7, tag: '全案总监', icon: '📋' },
  { id: 10, name: '郑光荣', age: 55, years: 35, orders: 302, rate: 98, score: 9.9, tag: '金牌老工', icon: '🏆' }
]

// 10 条验收节点(垂直时间轴)
const RV_ACCEPTS: RVAccept[] = [
  { id: 1, phase: '开工交底', date: '08-12', desc: '拆除交底 + 水电点位定位 + 图纸核对', status: '已验收', icon: '📋', score: 96 },
  { id: 2, phase: '水电隐蔽工程', date: '08-20', desc: '强弱电分离 / 打压测试 / 拍全留底', status: '已验收', icon: '⚡', score: 98 },
  { id: 3, phase: '泥瓦防水', date: '08-28', desc: '卫生间厨房双层防水 + 闭水试验48h', status: '已验收', icon: '🧱', score: 97 },
  { id: 4, phase: '木作基层', date: '09-05', desc: '吊顶龙骨 / 护墙板基层 / 柜体安装', status: '待验收', icon: '🪚', score: 0 },
  { id: 5, phase: '油漆墙纸', date: '09-12', desc: '乳胶漆三底两面 / 墙布热烫上墙', status: '待验收', icon: '🎨', score: 0 },
  { id: 6, phase: '主材安装', date: '09-18', desc: '瓷砖收口 / 橱柜 / 卫浴洁具进场', status: '待验收', icon: '🚪', score: 0 },
  { id: 7, phase: '台面五金安装', date: '09-22', desc: '岩板台面 / 拉篮五金 / 龙头角阀', status: '待验收', icon: '🍳', score: 0 },
  { id: 8, phase: '开荒保洁', date: '09-26', desc: '深度保洁×2轮 / 全屋除甲醛检测', status: '待验收', icon: '🧹', score: 0 },
  { id: 9, phase: '竣工验收', date: '09-30', desc: '全屋复检 / 水电图移交 / 钥匙交付', status: '待验收', icon: '🏁', score: 0 },
  { id: 10, phase: '质保回访', date: '10-30', desc: '入住一个月后回访 / 遗留问题整改', status: '待验收', icon: '💌', score: 0 }
]

// 12 条订单
const RV_ORDERS: RVOrder[] = [
  { id: 1, no: 'RV-2601', caseName: '55㎡学区老破小整装', amount: '¥12.8万', date: '07-10', status: '已完工', progress: 100 },
  { id: 2, no: 'RV-2602', caseName: '筒子楼一居爆改', amount: '¥8.6万', date: '07-18', status: '施工中', progress: 68 },
  { id: 3, no: 'RV-2603', caseName: '老家属院全屋整装', amount: '¥19.9万', date: '07-26', status: '设计阶段', progress: 25 },
  { id: 4, no: 'RV-2604', caseName: '城中村自建房一层半', amount: '¥26.5万', date: '08-02', status: '施工中', progress: 45 },
  { id: 5, no: 'RV-2605', caseName: '老房客厅扩容阳台打通', amount: '¥13.6万', date: '08-08', status: '已完工', progress: 100 },
  { id: 6, no: 'RV-2606', caseName: '二手房清水变精装', amount: '¥16.8万', date: '08-15', status: '施工中', progress: 58 },
  { id: 7, no: 'RV-2607', caseName: '老公寓复古工业风', amount: '¥17.2万', date: '08-20', status: '待开工', progress: 10 },
  { id: 8, no: 'RV-2608', caseName: '老食堂Loft工作室', amount: '¥35.0万', date: '08-25', status: '设计阶段', progress: 30 },

        })
      } else if (this.activeTab === RVTab.Mat) {
        MaterialContent({
          onQuote: () => { this.showBudget = true }
        })
      } else if (this.activeTab === RVTab.Crew) {
        CrewContent({
          onBookCrew: (idx: number) => {
            this.bookCrewIdx = idx
            this.showBook = true
          }
        })
      } else if (this.activeTab === RVTab.Accept) {
        AcceptContent()
      } else {
        MeContent({
          onClearFav: () => { this.showClearFav = true }
        })
      }

      // 右下角焊光火花(属性级动画)
      Text('✨').fontSize(16)
        .opacity(this.weldPulse)
        .scale({ x: this.weldScale, y: this.weldScale })
        .position({ x: '86%', y: '84%' })

      // ===== 弹框层 =====
      if (this.showMeasure) { this.measureModal() }
      if (this.showBudget) { this.budgetModal() }
      if (this.showStyle) { this.styleModal() }
      if (this.showBook) { this.bookModal() }
      if (this.showDelPlan) { this.delPlanModal() }
      if (this.showCaseDetail) { this.caseDetailModal() }
      if (this.showClearFav) { this.clearFavModal() }
    }
    .width('100%').height('100%')
  }

  build() {
    Column() {
      // ===== 头部(电商风:搜索框 + 报价入口,无动画) =====
      Row() {
        Row() {
          Text('🔨').fontSize(16)
          Column() {
            Text('RENOVA').fontSize(14).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.brick)
            Text('焕新工场').fontSize(8).fontColor(RV_COLORS.textSub).margin({ top: 1 })
          }
          .alignItems(HorizontalAlign.Start).padding({ left: 6 })
        }
        .alignItems(VerticalAlign.Center)

        Row() {
          Text('🔍').fontSize(12).margin({ left: 10 })
          TextInput({ placeholder: '搜案例 / 方案 / 建材 / 工长' })
            .placeholderColor(RV_COLORS.textHint).fontSize(12)
            .backgroundColor(RV_COLORS.card).borderRadius(16)
            .layoutWeight(1).height(32)
            .margin({ left: 6, right: 6 })
            .padding({ left: 10, right: 10 })
        }
        .layoutWeight(1)
        .margin({ left: 12, right: 8 })
        .backgroundColor(RV_COLORS.card)
        .borderRadius(16)
        .height(32)

        Text('🧾 报价').fontSize(11).fontColor('#FFFFFF')
          .backgroundColor(RV_COLORS.brick)
          .padding({ left: 12, right: 12, top: 7, bottom: 7 })
          .borderRadius(14)
          .onClick(() => { this.showBudget = true })
      }
      .width('100%').height(56)
      .backgroundColor(RV_COLORS.deep)
      .alignItems(VerticalAlign.Center)
      .padding({ left: 10, right: 10 })

      // ===== 内容区 =====
      Column() {
        this.contentArea()
      }
      .layoutWeight(1)
      .backgroundColor(RV_COLORS.bg)

      // ===== 底部 Tab(6个,可点击切换) =====
      Row() {
        ForEach(RV_TABS, (item: RVTabItem) => {
          Column() {
            Text(item.icon).fontSize(17)
              .opacity(this.activeTab === item.tab ? 1.0 : 0.45)
            Text(item.label).fontSize(9)
              .fontColor(this.activeTab === item.tab ? RV_COLORS.brick : RV_COLORS.textSub)
              .fontWeight(this.activeTab === item.tab ? FontWeight.Bold : FontWeight.Normal)
              .margin({ top: 1 })
            if (this.activeTab === item.tab) {
              Column().width(16).height(3)
                .backgroundColor(RV_COLORS.brick).borderRadius(2).margin({ top: 2 })
            } else {
              Column().width(16).height(3)
                .backgroundColor('rgba(163,71,48,0.12)').borderRadius(2).margin({ top: 2 })
            }
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Center)
          .padding({ top: 5, bottom: 5 })
          .onClick(() => { this.activeTab = item.tab })
        })
      }
      .width('100%')
      .backgroundColor(RV_COLORS.deep)
      .padding({ top: 3, bottom: 4 })
      .border({ width: 1, color: RV_COLORS.line })
    }
    .width('100%').height('100%')
    .backgroundColor(RV_COLORS.bg)
  }
}

// ============ Tab1:案例 ============
@Component
struct CaseContent {
  @State catFilter: string = '全部'
  @State bannerGlow: number = 0.2
  @State flowX: number = 0
  onCaseDetail: (idx: number) => void = (idx: number) => {}
  onMeasure: () => void = () => {}

  aboutToAppear() {
    // 焊光脉冲(横幅火花)
    this.getUIContext().animateTo({
      duration: 1800,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.bannerGlow = 0.95
    })
    // 工期进度条流动
    this.getUIContext().animateTo({
      duration: 2000,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.flowX = 140
    })
  }

  build() {
    Scroll() {
      Column() {
        // 横幅
        Stack() {
          Column()
            .width('100%').height(112)
            .linearGradient({
              direction: GradientDirection.Right,
              colors: [[RV_COLORS.brickDeep, 0], [RV_COLORS.orange, 1]]
            })
            .borderRadius(14)
          Text('⚡').fontSize(22)
            .opacity(this.bannerGlow)
            .position({ x: '84%', y: 14 })
          Text('🛠️').fontSize(16)
            .position({ x: '70%', y: 52 })
            .opacity(this.bannerGlow)
          Column() {
            Text('RENOVA 焕新工场 · 老房爆改实验室').fontSize(15).fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('累计爆改 386 套 · 平均节省 32% 工期 · 0 返工质保')
              .fontSize(9).fontColor('#F2EEE8').margin({ top: 4 })
            Row() {
              Text('🏗️ 看案例').fontSize(10).fontColor(RV_COLORS.brickDeep)
                .backgroundColor('#FFFFFF')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .margin({ top: 10 })
              Text('📏 免费约量房').fontSize(10).fontColor('#FFFFFF')
                .backgroundColor('rgba(255,255,255,0.25)')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(12)
                .margin({ top: 10, left: 8 })
                .onClick(() => { this.onMeasure() })
            }
          }
          .alignItems(HorizontalAlign.Start)
          .position({ x: 16, y: 16 })
        }
        .width('100%').height(112).margin({ top: 10 })

        // 分类 chips
        Scroll() {
          Row() {
            ForEach(RV_CASE_TYPES, (t: string) => {
              if (this.catFilter === t) {
                Text(t).fontSize(11).fontColor('#FFFFFF').backgroundColor(RV_COLORS.brick)
                  .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                  .margin({ right: 8 })
              } else {
                Text(t).fontSize(11).fontColor(RV_COLORS.textSub).backgroundColor(RV_COLORS.card)
                  .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(15)
                  .margin({ right: 8 })
                  .border({ width: 1, color: RV_COLORS.line })
                  .onClick(() => { this.catFilter = t })
              }
            })
          }
          .padding({ left: 2, right: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%').margin({ top: 12 })

        // 案例大卡列表
        ForEach(RV_CASES, (c: RVCase, idx: number) => {
          if (this.catFilter === '全部' || this.catFilter === c.type) {
            Column() {
              Row() {
                Text(c.icon).fontSize(20)
                  .width(42).height(42).borderRadius(12)
                  .backgroundColor(c.color).textAlign(TextAlign.Center)
                Column() {
                  Text(c.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
                  Row() {
                    Text(c.type).fontSize(9).fontColor('#FFFFFF')
                      .backgroundColor(RV_COLORS.brick)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                    Text(c.tag).fontSize(9).fontColor(RV_COLORS.orange)
                      .backgroundColor(RV_COLORS.deep)
                      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                      .borderRadius(6)
                      .margin({ left: 6 })
                  }
                  .margin({ top: 5 })
                }
                .layoutWeight(1).alignItems(HorizontalAlign.Start).padding({ left: 10 })
                Column() {
                  Text('¥' + c.price.toFixed(1) + '万').fontSize(14).fontWeight(FontWeight.Bold)
                    .fontColor(RV_COLORS.brick)
                  Text(c.progress >= 100 ? '已完工' : '施工中').fontSize(9)
                    .fontColor(rvProgressColor(c.progress)).margin({ top: 2 })
                }
                .alignItems(HorizontalAlign.Center)
              }
              .width('100%')

              // 改造前后对比条
              Stack() {
                Row() {
                  Column().width(rvCaseSplitOld(c)).height(30).backgroundColor('#CBBFB0')
                  Column().layoutWeight(1).height(30).backgroundColor(c.color)
                }
                .width('100%').borderRadius(6)
                Text('老房 ' + c.oldArea + '㎡  →  新家 ' + c.area + '㎡')
                  .fontSize(9).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              }
              .width('100%').height(30).margin({ top: 10 })

              Row() {
                Text('📐 面积 ' + c.area.toString() + '㎡').fontSize(9).fontColor(RV_COLORS.textSub)
                Text('📅 第 ' + rvCaseDay(c) + ' / ' + c.period.toString() + ' 天')
                  .fontSize(9).fontColor(RV_COLORS.textSub).margin({ left: 12 })
                Column().layoutWeight(1)
                Text(c.progress.toString() + '%').fontSize(10).fontWeight(FontWeight.Bold)
                  .fontColor(rvProgressColor(c.progress))
              }
              .width('100%').margin({ top: 8 })

              // 工期进度条(流动动画)
              Stack() {
                Row() {
                  Column().width(c.progress + '%').height(8)
                    .backgroundColor(rvProgressColor(c.progress)).borderRadius(4)
                  Column().layoutWeight(1).height(8)
                    .backgroundColor(RV_COLORS.deep).borderRadius(4)
                }
                .width('100%')
                Column().width(22).height(8).backgroundColor('#FFFFFF')
                  .opacity(0.5).borderRadius(4)
                  .position({ x: 0, y: 0 })
                  .translate({ x: this.flowX })
              }
              .width('100%').height(8).margin({ top: 6 })

              Row() {
                Text('查看方案报价').fontSize(11).fontColor('#FFFFFF')
                  .backgroundColor(RV_COLORS.brick)
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(13)
                  .margin({ top: 10 })
                  .onClick(() => { this.onCaseDetail(idx) })
                Text('免费量房').fontSize(11).fontColor(RV_COLORS.brick)
                  .backgroundColor(RV_COLORS.deep)
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(13)
                  .margin({ top: 10, left: 8 })
                  .onClick(() => { this.onMeasure() })
                Column().layoutWeight(1)
                Text('›').fontSize(18).fontColor(RV_COLORS.textHint)
                  .margin({ top: 6 })
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .backgroundColor(RV_COLORS.card)
            .borderRadius(14)
            .border({ width: 1, color: RV_COLORS.line })
            .margin({ top: 10 })
          }
        })

        // 底部品牌条
        Column() {
          Text('🔨 RENOVA 焕新工场').fontSize(12).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Text('老房爆改 · 0 返工 · 质保 5 年').fontSize(9).fontColor(RV_COLORS.textSub).margin({ top: 3 })
          Text('营业执照 · 建筑施工资质 · 装修保险').fontSize(8).fontColor(RV_COLORS.textHint).margin({ top: 2 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Center)
        .padding({ top: 14, bottom: 14 })
        .margin({ top: 12 })

        Column().height(10)
      }
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%').height('100%')
    .backgroundColor(RV_COLORS.bg)
  }
}

// ============ Tab2:方案 ============
@Component
struct PlanContent {
  @State chartGlow: number = 1
  onBudget: () => void = () => {}
  onStyleSelect: () => void = () => {}
  onDelPlan: (idx: number) => void = (idx: number) => {}

  aboutToAppear() {
    // 造价图高亮柱呼吸
    this.getUIContext().animateTo({
      duration: 1600,
      iterations: -1,
      playMode: PlayMode.Alternate,
      curve: Curve.EaseInOut
    }, () => {
      this.chartGlow = 1.18
    })
  }

  build() {
    Scroll() {
      Column() {
        // 横向滑动风格卡
        Row() {
          Text('🎨 设计方案').fontSize(15).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Column().layoutWeight(1)
          Text('横滑浏览').fontSize(9).fontColor(RV_COLORS.textSub)
        }
        .width('100%').margin({ top: 10 })

        Scroll() {
          Row() {
            ForEach(RV_PLANS, (p: RVPlan, idx: number) => {
              Column() {
                Row() {
                  Text(p.hot ? '🔥' : '📌').fontSize(10)
                  Column().layoutWeight(1)
                  Text('🗑️').fontSize(10).fontColor(RV_COLORS.textHint)
                    .onClick(() => { this.onDelPlan(idx) })
                }
                .width('100%')
                Text(p.icon).fontSize(30).margin({ top: 6 })
                Text(p.name).fontSize(12).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
                  .maxLines(1).margin({ top: 6 })
                Text('¥' + p.price.toString() + '/㎡').fontSize(13).fontWeight(FontWeight.Bold)
                  .fontColor(RV_COLORS.brick).margin({ top: 4 })
                Text('已售 ' + p.sales.toString() + ' 套 · ' + p.days.toString() + ' 天')
                  .fontSize(8).fontColor(RV_COLORS.textSub).margin({ top: 3 })
              }
              .width(148)
              .padding(12)
              .backgroundColor(RV_COLORS.card)
              .borderRadius(12)
              .border({ width: 1, color: RV_COLORS.line })
              .margin({ right: 10 })
              .alignItems(HorizontalAlign.Center)
            })
          }
          .padding({ left: 2, right: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%').margin({ top: 10 })

        // 造价对比柱状图
        Row() {
          Text('📊 单位造价对比').fontSize(15).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Column().layoutWeight(1)
          Text('元/㎡').fontSize(9).fontColor(RV_COLORS.textSub)
        }
        .width('100%').margin({ top: 16 })

        Column() {
          Row() {
            ForEach(RV_PLAN_BAR_IDXS, (i: number) => {
              Column() {
                Stack() {
                  Column()
                    .width(24)
                    .height(rvPlanBarH(RV_PLANS[i].price))
                    .backgroundColor(i === 3 ? RV_COLORS.orange : RV_COLORS.brick)
                    .borderRadius({ topLeft: 4, topRight: 4 })
                    .scale({ x: i === 3 ? this.chartGlow : 1, y: i === 3 ? this.chartGlow : 1 })
                }
                .width(24)
                .height(100)
                Text(RV_PLANS[i].price.toString()).fontSize(8).fontColor(RV_COLORS.textSub)
                  .margin({ top: 4 })
                Text(RV_PLANS[i].icon).fontSize(12).margin({ top: 3 })
                Text(RV_PLANS[i].style).fontSize(8).fontColor(RV_COLORS.textHint).margin({ top: 1 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            })
          }
          .width('100%')
        }
        .width('100%')
        .padding({ top: 12, bottom: 12 })
        .backgroundColor(RV_COLORS.card)
        .borderRadius(12)
        .margin({ top: 8 })

        // 斑马纹报价表
        Row() {
          Text('🧾 当前方案报价表').fontSize(15).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Column().layoutWeight(1)
          Text('复古工业风 · 70㎡').fontSize(9).fontColor(RV_COLORS.orange)
        }
        .width('100%').margin({ top: 16 })

        Row() {
          Text('项目').fontSize(10).fontColor(RV_COLORS.textSub).layoutWeight(1)
          Text('金额').fontSize(10).fontColor(RV_COLORS.textSub).width(72).textAlign(TextAlign.End)
          Text('说明').fontSize(10).fontColor(RV_COLORS.textSub).width(70).textAlign(TextAlign.End)
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        .backgroundColor(RV_COLORS.charcoal)

        ForEach(RV_QUOTE_LINES, (q: RVQuoteLine, idx: number) => {
          Row() {
            Text(q.item).fontSize(10).fontColor(RV_COLORS.textMain).layoutWeight(1)
            Text(q.price).fontSize(10).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.brick)
              .width(72).textAlign(TextAlign.End)
            Text(q.note).fontSize(8).fontColor(RV_COLORS.textSub).width(70).textAlign(TextAlign.End)
          }
          .width('100%')
          .padding({ top: 9, bottom: 9 })
          .backgroundColor(idx % 2 === 0 ? RV_COLORS.card : '#F0EAE0')
          .margin({ top: 2 })
        })

        Row() {
          Text('合计(约)').fontSize(11).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.textMain)
          Column().layoutWeight(1)
          Text('¥131,300').fontSize(15).fontWeight(FontWeight.Bold).fontColor(RV_COLORS.brick)
        }
        .width('100%')
        .padding({ top: 10, bottom: 10 })
        .backgroundColor(RV_COLORS.deep)
        .borderRadius(8)
        .margin({ top: 4 })

        // 底部操作
        Row() {
          Text('✏️ 编辑预算').fontSize(11).fontColor('#FFFFFF')
            .backgroundColor(RV_COLORS.orange)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(13)
            .onClick(() => { this.onBudget() })
          Text('🎨 换风格').fontSize(11).fontColor('#FFFFFF')
            .backgroundColor(RV_COLORS.brick)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(13)
            .margin({ left: 8 })
            .onClick(() => { this.onStyleSelect() })
          Column().layoutWeight(1)
          Text('一键下单').fontSize(11).fontColor(RV_COLORS.brick)
            .backgroundColor(RV_COLORS.deep)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .borderRadius(13)
            .onClick(() => { this.onBudget() })
        }
        .width('100%').margin({ top: 14 })

        Column().height(10)
      }
      .padding({ left: 12, right: 12 })
    }
  
      .padding({ left: 12, right: 12 })
    }
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
    .width('100%').height('100%')
    .backgroundColor(RV_COLORS.bg)
  }
}


在这里插入图片描述

在动画体系层面,应用为每个功能模块设计了独立的特色动画:案例模块的横幅火花闪烁和进度条流动光效、方案模块的造价柱呼吸缩放、建材模块的库存水位闪光、施工队模块的奖牌微摆旋转、验收模块的节点脉冲缩放、个人中心的会员卡光泽扫动。这些动画通过animateTo API驱动,使用PlayMode.Alternate交替播放模式和Curve.EaseInOut缓动曲线,营造出各具特色又统一和谐的动态体验。入口组件的焊光特效作为全局装饰元素,通过opacity和scale双状态同步动画,在右下角持续闪烁,强化了复古工业风的主题氛围。

在图形变换层面,应用系统性地运用了ArkUI的三种变换方法。rotate用于奖牌微摆(0-6度)和光泽条旋转(-16度),translate用于进度条光效流动(0-140位移)和会员卡光泽扫动(-70到70位移),scale用于已验收节点脉冲(1-1.25缩放)和造价柱呼吸(1-1.18缩放)。三种变换方法的组合使用——会员卡光泽条同时使用rotate+translate+opacity——实现了复杂的扫光视觉效果。这些变换都是渲染层面的操作,不影响布局空间,使得它们可以在不影响周围元素位置的前提下创建丰富的动态效果。

在布局技术层面,应用使用了constraintSize限制弹窗滚动区域的最大高度,确保底部操作按钮始终可见。Flex+FlexWrap.Wrap实现了风格chips的自动换行布局。Stack层叠容器被广泛用于构建复合视觉元素——会员卡的三层叠(渐变背景+光泽条+信息内容)、进度条的两层叠(进度填充+流动光效)、面积对比条的两层叠(双色对比+文字标签)等。ForEach配合if条件筛选实现了案例分类和建材分类的动态列表过滤。

Logo

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

更多推荐