扎染之美,在于水与色的交融、绿与蓝的转化、人与缸的默契。云染坊将千年草木染工艺搬上屏幕,让每一匝布的浸染过程都可被围观、可被共创、可被收藏。声明式UI并非冷冰冰的布局语法,而是承载匠心温度的容器——靛蓝、茜草红、布白三色之间,折叠着一套完整的状态流转与交互逻辑。

连麦共创的本质,是直播能力与工艺流程的深度耦合。四机位同屏、弹幕互动、步骤跟踪、抽屉表单、不可变数据更新,这些技术要素在染缸边汇聚成一套非遗数字化的完整方案。HarmonyOS ArkTS以@State/@Prop双向数据流、@Builder复用、bindSheet/bindContentCover弹层体系为骨架,撑起了染坊的全部业务场景。

本篇将逐段拆解这套约2400行的ArkTS源码,从数据建模到六Tab架构,从直播工位到花色册增删改查,从染材库图表到坯布架选择,覆盖声明式UI在非遗直播电商场景下的全部关键实践。

引言

在这里插入图片描述

扎染(Tie-dye)作为中国非物质文化遗产的重要分支,其核心工艺包含设计捆扎、养缸调液、下缸浸染、氧化显色、拆线漂洗、晾晒定色六大步骤。传统模式下,这些步骤只能在染坊现场完成,学徒与爱好者难以参与其中。云染坊的出现,正是要解决"看得见但染不着"的痛点:通过四机位直播还原染缸浸布的全过程,通过连麦共创让用户与匠人同缸操作,通过花色册沉淀作品资产,通过染材库与坯布架构建选材闭环。

HarmonyOS ArkTS声明式UI范式为这类复杂交互场景提供了天然的建模能力。@State装饰器实现组件内可变状态,当数据变更时框架自动触发UI刷新;@Prop装饰器实现父子组件单向数据传递,保证子组件拿到的是只读快照,避免意外的反向修改;@Builder装饰器将复杂UI结构封装为可复用函数,降低build方法的嵌套深度。三者协同,构成了染坊从数据到视图的完整响应链路。

从架构层面看,整个应用采用单页面多Tab + 多弹层的组织方式。顶部"晾布夹"Tab导航以晾杆、衣夹、错落悬挂布条为视觉隐喻,将六个业务模块串联起来。主入口组件持有全部业务数据(布匹、染材、订单、匠人、弹幕、步骤、坯布等)作为@State,通过回调函数将增删改操作收归到单一数据源,子Tab仅负责展示与事件上报。这种"数据上浮、事件下沉"的设计模式,让状态管理在中等复杂度应用中保持了清晰可维护的边界。弹层体系则借助bindSheet(底部抽屉)与bindContentCover(全屏遮罩)两类原生容器,分别承载表单录入与确认对话两类交互,避免在主滚动区堆积过多条件分支。

一、数据建模:接口驱动的领域对象

在这里插入图片描述

扎染业务的全部实体都以interface定义,这是ArkTS类型系统的标准做法,既保证了编译期类型安全,又不会引入类的运行时开销。

interface Cloth208 {
  id: number
  name: string
  dye: string
  dips: number
  fastness: number
  state: string
  watchers: number
  starred: boolean
}

interface Dye208 {
  id: number
  name: string
  color: string
  origin: string
  output: number
  heat: number
}

interface Order208 {
  id: number
  name: string
  dye: string
  dayNum: number
  bundles: number
  meters: number
  state: string
  top: boolean
}

interface Artisan208 {
  id: number
  name: string
  city: string
  years: number
  online: boolean
  heat: number
}

interface Barrage208 {
  id: number
  text: string
}

interface DipStep208 {
  id: number
  title: string
  tip: string
  done: boolean
}

interface DyeCount208 {
  label: string
  count: number
  color: string
}

interface FastLog208 {
  day: string
  score: number
}

interface Fabric208 {
  id: number
  name: string
  icon: string
  stock: number
  used: boolean
  times: number
}

这套接口覆盖了染坊业务的十个核心实体。Cloth208是花色册的主实体,dye字段记录主染材、dips记录复染次数、fastness记录色牢度评分、state记录当前工艺状态(浸染中/氧化中/已晾干)、starred标记是否精选。Dye208描述草木染材本身,包含产地origin、年产缸数output与人气热度heatOrder208是共创订单,dayNum表示第几日开染、bundles记录扎花匝数、meters记录布量。Artisan208是匠人档案,online标记是否在线、heat记录人气值。Barrage208是直播弹幕,DipStep208是染布六步曲,DyeCount208是染材占比统计的聚合视图,FastLog208是色牢度走势日志,Fabric208是坯布库存。

值得注意的是DyeCount208是一个典型的"投影型"接口——它并非持久化数据,而是由dyeCounts208函数对Cloth208[]实时聚合而成。这种派生数据用同构interface建模,既复用了ForEach的渲染能力,又避免在状态中维护冗余副本。

接口设计的一个细节是所有字段都使用基础类型(number/string/boolean),没有嵌套对象。这并非偷懒,而是深思熟虑后的选择——ArkTS的状态观察基于浅比较,扁平结构能确保字段级变更被精准捕获,而嵌套对象则需要深拷贝对比,性能开销更大。在后续的map/filter更新中可以看到,每次修改都生成全新的对象数组,正是配合扁平接口的最佳实践。

二、配色与状态映射函数集

在这里插入图片描述

染坊的视觉语言高度依赖色彩语义,靛蓝(#283593)代表浸染、茜草红(#E53935)代表已晾干与警告、青绿(#00897B)代表氧化中、黄(#FBC02D)代表栀子染材。这些映射被封装为纯函数,保证可测试性与可复用。

function clothStateColor208(state: string): string {
  if (state === '浸染中') {
    return '#283593'
  }
  if (state === '氧化中') {
    return '#00897B'
  }
  if (state === '已晾干') {
    return '#E53935'
  }
  return '#90A4AE'
}

function orderStateColor208(state: string): string {
  if (state === '今日开缸') {
    return '#E53935'
  }
  if (state === '氧化等待') {
    return '#00897B'
  }
  if (state === '已交付') {
    return '#7CB342'
  }
  return '#90A4AE'
}

function dyeColor208(dye: string): string {
  if (dye === '板蓝根') {
    return '#283593'
  }
  if (dye === '茜草') {
    return '#E53935'
  }
  if (dye === '栀子') {
    return '#FBC02D'
  }
  if (dye === '苏木') {
    return '#8D6E63'
  }
  return '#C62828'
}

clothStateColor208将布匹的三种工艺状态映射为对应色相,让状态标签在列表中一眼可辨——浸染中的靛蓝、氧化中的青绿、已晾干的茜草红,与真实染缸中布匹的颜色演变完全对应。orderStateColor208为订单状态着色,今日开缸用红色突出紧迫感、已交付用绿色传达完成信号。dyeColor208则是染材到标准色的查表函数,板蓝根出靛蓝、茜草出红、栀子出黄、苏木出褐、薯莨出深红。这些函数都是无副作用的纯函数,输入相同则输出相同,在任何组件中调用都安全可靠。

将颜色语义集中到函数层而非散落在UI内联,是维护视觉一致性的关键。当设计需要全局调色时,只需改一处函数即可,而非在数百处内联值中逐一搜索替换。

三、聚合统计函数:不可变数据处理

在这里插入图片描述

列表场景常需要从原始数组派生统计指标,染坊用一组函数封装了这些计算。

function starredClothCount208(cloths: Cloth208[]): number {
  let n: number = 0
  for (let i = 0; i < cloths.length; i++) {
    if (cloths[i].starred) {
      n++
    }
  }
  return n
}

function dippingClothCount208(cloths: Cloth208[]): number {
  let n: number = 0
  for (let i = 0; i < cloths.length; i++) {
    if (cloths[i].state === '浸染中' || cloths[i].state === '氧化中') {
      n++
    }
  }
  return n
}

function onlineArtisanCount208(artisans: Artisan208[]): number {
  let n: number = 0
  for (let i = 0; i < artisans.length; i++) {
    if (artisans[i].online) {
      n++
    }
  }
  return n
}

function maxArtisanHeat208(artisans: Artisan208[]): number {
  let m: number = 1
  for (let i = 0; i < artisans.length; i++) {
    if (artisans[i].heat > m) {
      m = artisans[i].heat
    }
  }
  return m
}

function dyeCounts208(cloths: Cloth208[]): DyeCount208[] {
  const counts: DyeCount208[] = []
  for (let i = 0; i < dyeTags208.length; i++) {
    let n: number = 0
    for (let j = 0; j < cloths.length; j++) {
      if (cloths[j].dye === dyeTags208[i]) {
        n++
      }
    }
    counts.push({ label: dyeTags208[i], count: n, color: ['#283593', '#E53935', '#FBC02D', '#8D6E63', '#C62828'][i] })
  }
  return counts
}

starredClothCount208统计精选标记数,dippingClothCount208统计在缸/氧化中的布匹数,这两个指标在花色册头部和直播工位都有使用。onlineArtisanCount208maxArtisanHeat208服务于匠人榜:前者计算在线人数,后者取人气最大值作为进度条归一化基准——a.heat / maxArtisanHeat208(this.artisans) * 100就是某位匠人相对最高人气的百分比。dyeCounts208是最复杂的聚合,它遍历染材标签数组,对每个染材统计布匹中使用该染材的数量,返回带颜色的DyeCount208[],直接驱动堆叠条形图的渲染。

这些函数的共性是:纯输入输出、无副作用、可直接在build中调用而不会引发额外刷新。ArkTS在每次状态变更后会重新执行build,纯函数的重算是幂等的,结果稳定。但要注意,如果聚合函数内部依赖了外部可变状态,就会破坏幂等性,染坊通过把染材标签作为模块级常量dyeTags208来规避——常量不参与状态观察,重算结果必然一致。

四、主入口组件:状态中枢与Tab路由

在这里插入图片描述

Index208是整个应用的入口组件,持有全部业务数据,并通过顶部"晾布夹"导航在六个Tab之间切换。

@Entry
@Component
struct Index208 {
  @State tabIndex1: number = 0

  // 弹层开关
  @State showJoinSheet: boolean = false
  @State showPatternSheet: boolean = false
  @State showEditSheet: boolean = false
  @State showDelDialog: boolean = false
  @State showDetailDialog: boolean = false

  // 预约连麦共创表单
  @State joinPattern: number = 0
  @State joinFabric: number = 0
  @State joinDips: number = 3
  @State joinLive: boolean = true
  @State joinKeep: boolean = false

  // 上传花色表单
  @State patternName: string = ''
  @State patternDye: number = 0
  @State patternDips: number = 3
  @State patternFast: number = 4
  @State patternPublic: boolean = true

  // 编辑表单
  @State editIndex: number = -1
  @State editName: string = ''
  @State editDips: number = 3
  @State editFast: number = 4
  @State editTop: boolean = false

  // 删除
  @State delIndex: number = -1
  @State delKeepAlbum: boolean = true

  // 详情
  @State detailIndex: number = 0

  // 业务数据
  @State cloths: Cloth208[] = [ /* 十条花色数据 */ ]
  @State dyes: Dye208[] = [ /* 五种染材 */ ]
  @State orders: Order208[] = [ /* 八条订单 */ ]
  @State artisans: Artisan208[] = [ /* 六位匠人 */ ]
  @State dipDays: DipDay208[] = [ /* 周浸染批次 */ ]
  @State fastLogs: FastLog208[] = [ /* 色牢度日志 */ ]
  @State fabrics: Fabric208[] = [ /* 八种坯布 */ ]
  @State barrages: Barrage208[] = [ /* 八条弹幕 */ ]
  @State dipSteps: DipStep208[] = [ /* 染布六步 */ ]

  tabs208: string[] = ['染坊', '花色册', '染材库', '坯布架', '匠人团', '我的']
  tabIcons208: string[] = ['🪣', '🎨', '🌿', '🧵', '🧕', '👤']
  tabDrops208: number[] = [0, 6, 2, 8, 4, 10]
  ...
}

状态设计是这里最值得品味的部分。主入口持有三类状态:第一类是路由与弹层开关(tabIndex1及五个show*布尔),控制页面可见性;第二类是各表单的临时录入值(joinPatternpatternNameeditName等),承载用户在弹层中的操作;第三类是业务数据(clothsdyesorders等),是应用的持久资产。三类状态泾渭分明,弹层开关与表单值是易变的临时态,业务数据是需被回调修改的核心态。

将表单临时态放在主入口而非弹层组件内部,是为了"打开即回填"的体验——编辑花色时主入口先把目标行数据拷贝到edit*字段,再打开弹层,弹层读取这些字段渲染初始值,保存时再把edit*写回cloths。这种"数据上浮"避免了子组件维护镜像副本的同步难题。

tabs208tabIcons208tabDrops208三个数组没有@State装饰,因为它们是常量,不参与状态观察。其中tabDrops208是晾布夹导航的错落偏移量,每个Tab向下悬挂不同距离,模拟真实晾杆上布条长短不一的效果。这种细节让导航从功能性控件升级为有场景感的视觉元素。

五、头部Builder:电商非遗季风横幅

在这里插入图片描述

头部是一个独立的@Builder,承担品牌标识、席位统计与开染入口三个职能。

@Builder
header208() {
  Column({ space: 12 }) {
    Row({ space: 10 }) {
      Column({ space: 4 }) {
        Text('织彩 · 云染坊').fontSize(19).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        Text('扎染匠人连麦共创 · 染缸浸布直播围观').fontSize(11).fontColor('#C5CAE9')
      }
      .alignItems(HorizontalAlign.Start)
      Text('').layoutWeight(1)
      Column({ space: 2 }) {
        Text('🪣').fontSize(20)
        Text('1,526').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        Text('围染席位').fontSize(9).fontColor('#C5CAE9')
      }
      .alignItems(HorizontalAlign.Center)
    }
    .width('100%')

    Row({ space: 10 }) {
      Column().width(4).height(34).borderRadius(2).backgroundColor('#E53935')
      Column({ space: 3 }) {
        Text('板蓝根老缸今晚开染').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        Text('连麦共创抽留样布 · 围观送扎染方巾').fontSize(9).fontColor('#E8EAF6')
      }
      .alignItems(HorizontalAlign.Start)
      Text('').layoutWeight(1)
      Column() {
        Text('去连染 →').fontSize(11).fontColor('#283593').fontWeight(FontWeight.Bold)
      }
      .padding({ left: 12, right: 12, top: 7, bottom: 7 })
      .borderRadius(14)
      .backgroundColor('#E8EAF6')
      .onClick(() => {
        this.showJoinSheet = true
      })
    }
    .width('100%')
    .padding(12)
    .borderRadius(12)
    .backgroundColor('#1A237E')
  }
  .alignItems(HorizontalAlign.Start)
  .padding(14)
  .linearGradient({ angle: 140, colors: [['#3949AB', 0], ['#283593', 1]] })
}

@Builder装饰器让这段复杂UI可以被当作方法调用(this.header208()),在build中与其他Builder并列,降低嵌套深度。头部第一行是品牌区,左侧标题副标题纵向排列、右侧围染席位数字纵向堆叠,中间用Text('').layoutWeight(1)做弹性占位实现两端对齐。第二行是开染横幅,左侧红色竖条做视觉锚点,文案之后是浅底胶囊按钮"去连染",点击触发showJoinSheet = true打开预约抽屉。整个头部用linearGradient铺设靛蓝渐变背景,angle: 140让光从左上斜入,营造非遗季风的厚重感。

layoutWeight(1)配合空Text('')是ArkTS中实现"剩余空间分配"的惯用手法。空文本本身不占视觉,但它的layoutWeight会吃掉父容器剩余宽度,把后续元素推到右侧。这种写法比Flex的justifyContent更细粒度,因为可以在任意位置插入多个弹性占位实现复杂的三段布局。

六、晾布夹Tab导航:场景化导航的视觉建模

在这里插入图片描述

顶部Tab是整个应用的视觉灵魂,用晾杆、衣夹、错落布条重构了传统Tab的呈现方式。

@Builder
tabBar208() {
  Column({ space: 0 }) {
    // 晾杆
    Row() {
      Text('').layoutWeight(1)
    }
    .width('100%')
    .height(2)
    .backgroundColor('#B0BEC5')
    .margin({ left: 10, right: 10 })

    Row({ space: 4 }) {
      ForEach(this.tabs208, (t: string, i: number) => {
        Column({ space: 0 }) {
          // 衣夹
          Row() {
            Column().width(3).height(7).borderRadius(1)
              .backgroundColor(this.tabIndex1 === i ? '#E53935' : '#90A4AE')
          }
          .height(8)
          .justifyContent(FlexAlign.Center)
          // 悬挂布条
          Column({ space: 2 }) {
            Text(this.tabIcons208[i]).fontSize(13)
            Text(t).fontSize(9).maxLines(1)
              .fontColor(this.tabIndex1 === i ? '#FFFFFF' : '#5C6BC0')
          }
          .width(46)
          .padding({ top: 5, bottom: 5 })
          .alignItems(HorizontalAlign.Center)
          .borderRadius({ bottomLeft: 8, bottomRight: 8 })
          .backgroundColor(this.tabIndex1 === i ? '#283593' : '#FFFFFF')
          .shadow(this.tabIndex1 === i
            ? { radius: 8, color: 'rgba(40,53,147,0.4)', offsetY: 3 }
            : { radius: 4, color: 'rgba(0,0,0,0.08)', offsetY: 2 })
        }
        .layoutWeight(1)
        .padding({ top: 0, bottom: 4 })
        .margin({ top: this.tabDrops208[i] })
        .scale({ x: this.tabIndex1 === i ? 1.08 : 1, y: this.tabIndex1 === i ? 1.08 : 1 })
        .animation({ duration: 180 })
        .onClick(() => {
          this.tabIndex1 = i
        })
      }, (t: string) => t)
    }
    .width('100%')
    .alignItems(VerticalAlign.Top)
    .padding({ top: 2, left: 6, right: 6 })
  }
  .width('100%')
  .padding({ top: 4, bottom: 4 })
  .backgroundColor('#FFFFFF')
  .shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: 3 })
}

晾杆是一根2像素高的灰色横线,横跨整个宽度。每个Tab由"衣夹+布条"两部分构成:衣夹是顶部一个3×7的小竖条(选中时变红、未选中时灰),布条是下方的圆角胶囊,只对底边做圆角(borderRadius({ bottomLeft: 8, bottomRight: 8 })),模拟布条从夹子下垂的形态。tabDrops208[i]作为margin({ top })让每个布条下沉不同距离,制造错落悬挂感。选中态用1.08倍缩放+靛蓝填充+强阴影三重视觉强化,配合animation({ duration: 180 })实现180毫秒的弹性过渡。

这种场景化导航的精髓在于:视觉元素不仅是装饰,更要与业务语义同构。晾杆对应"挂布"、衣夹对应"固定"、错落布条对应"不同花色"——每个像素都在讲故事,用户在点击Tab时,潜意识里完成的是"取下一匹布查看"的动作映射。

ForEach的第三个参数是键生成器(t: string) => t,用Tab文本本身作为唯一键。键的稳定性决定Diff效率,这里文本不变则键不变,框架能精准识别哪一项被选中,只重渲染变化的项,而非整列重建。

七、build主方法:Tab分发与弹层挂载

在这里插入图片描述

build方法是组件的渲染入口,这里通过if分支分发六个Tab,并在根容器挂载五个弹层。

build() {
  Column() {
    this.header208()
    this.tabBar208()
    Scroll() {
      Column({ space: 12 }) {
        if (this.tabIndex1 === 0) {
          LiveTab208({
            cloths: this.cloths,
            artisans: this.artisans,
            dipSteps: this.dipSteps,
            barrages: this.barrages,
            onStep: (i: number) => {
              this.dipSteps = this.dipSteps.map((s: DipStep208, si: number) => {
                if (si === i) {
                  return { id: s.id, title: s.title, tip: s.tip, done: !s.done }
                }
                return s
              })
            },
            onJoin: () => {
              this.showJoinSheet = true
            }
          })
        }
        if (this.tabIndex1 === 1) {
          PatternTab208({
            cloths: this.cloths,
            onAdd: () => { this.showPatternSheet = true },
            onDetail: (i: number) => {
              this.detailIndex = i
              this.showDetailDialog = true
            },
            onEdit: (i: number) => {
              this.editIndex = i
              this.editName = this.cloths[i].name
              this.editDips = this.cloths[i].dips
              this.editFast = this.cloths[i].fastness
              this.editTop = this.cloths[i].starred
              this.showEditSheet = true
            },
            onDel: (i: number) => {
              this.delIndex = i
              this.showDelDialog = true
            },
            onStar: (i: number) => {
              this.cloths = this.cloths.map((c: Cloth208, ci: number) => {
                if (ci === i) {
                  return { id: c.id, name: c.name, dye: c.dye, dips: c.dips,
                    fastness: c.fastness, state: c.state, watchers: c.watchers,
                    starred: !c.starred }
                }
                return c
              })
            }
          })
        }
        if (this.tabIndex1 === 2) {
          DyeTab208({ dyes: this.dyes, cloths: this.cloths, dipDays: this.dipDays })
        }
        if (this.tabIndex1 === 3) {
          FabricTab208({ fabrics: this.fabrics, fastLogs: this.fastLogs })
        }
        if (this.tabIndex1 === 4) {
          ArtisanTab208({
            artisans: this.artisans,
            onJoin: () => { this.showJoinSheet = true }
          })
        }
        if (this.tabIndex1 === 5) {
          MineTab208({ cloths: this.cloths, orders: this.orders, dipDays: this.dipDays })
        }
      }
      .width('100%')
      .padding(14)
    }
    .layoutWeight(1)
    .align(Alignment.Top)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F5F0E8')
  .bindSheet($$this.showJoinSheet, this.joinSheet208(), {
    height: 620, dragBar: true, showClose: false, backgroundColor: '#FFFFFF'
  })
  .bindSheet($$this.showPatternSheet, this.patternSheet208(), {
    height: 600, dragBar: true, showClose: false, backgroundColor: '#FFFFFF'
  })
  .bindSheet($$this.showEditSheet, this.editSheet208(), {
    height: 560, dragBar: true, showClose: false, backgroundColor: '#FFFFFF'
  })
  .bindContentCover($$this.showDelDialog, this.delDialog208(), {})
  .bindContentCover($$this.showDetailDialog, this.detailDialog208(), {})
}

这里体现了"数据下传、事件上传"的经典模式。子Tab通过@Prop接收数据快照,通过回调函数(onSteponJoinonAddonDetailonEditonDelonStar)将用户意图回传主入口。主入口在回调中执行真正的状态修改,保证数据源唯一。以onStar为例,点击星标时子组件只上报索引i,主入口用map生成全新数组:命中索引的项starred取反,其余项原样返回。这种不可变更新让ArkTS的状态观察能精准识别"引用变更",触发刷新。

onEdit的回填逻辑尤其值得学习。打开编辑弹层前,主入口先把目标行的namedipsfastnessstarred拷贝到edit*临时态,再置showEditSheet = true。这样弹层首次渲染就读到正确初始值,避免"先开空表单再异步填充"的闪烁。onStep则用map翻转指定步骤的done,让染布六步曲可点击切换完成态,进度可视化。

$$this.showJoinSheet是双向绑定语法,bindSheet内部修改该布尔(如下拉关闭)会反向写回@State,无需手动同步。这是HarmonyOS对弹层状态管理的原生支持,比手动监听关闭事件再置false简洁得多。

弹层分两类:bindSheet挂载底部抽屉(预约、上传、编辑三个表单),可设高度、拖拽条、背景色;bindContentCover挂载全屏遮罩(删除确认、花色详情两个对话框),居中展示。两类容器都不占用主滚动区位置,而是浮于其上,开关由布尔状态驱动,ArkTS自动管理显隐动画。

八、预约连麦共创抽屉:表单交互全流程

预约抽屉是业务最重的弹层,包含花色选择、坯布选择、浸染次数、直播开关、留样开关、费用计算六个表单元素。

@Builder
joinSheet208() {
  Column({ space: 16 }) {
    Row({ space: 10 }) {
      Column().width(4).height(30).borderRadius(2).backgroundColor('#283593')
      Column({ space: 2 }) {
        Text('预约连麦共创').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        Text('和靛缸阿婆同缸染布 · 出布可留样').fontSize(10).fontColor('#5C6BC0')
      }
      .alignItems(HorizontalAlign.Start)
      Text('').layoutWeight(1)
      Column() {
        Text('✕').fontSize(14).fontColor('#5C6BC0')
      }
      .width(30).height(30).borderRadius(15)
      .backgroundColor('#E8EAF6').justifyContent(FlexAlign.Center)
      .onClick(() => { this.showJoinSheet = false })
    }
    .width('100%')

    Scroll() {
      Column({ space: 16 }) {
        // 目标花色 - 标签选择
        Column({ space: 8 }) {
          Text('目标花色').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#283593')
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(dyeTags208, (tag: string, i: number) => {
              Text(tag)
                .fontSize(11)
                .fontColor(this.joinPattern === i ? '#FFFFFF' : '#5C6BC0')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(16)
                .backgroundColor(this.joinPattern === i ? dyeColor208(tag) : '#E8EAF6')
                .margin(4)
                .onClick(() => { this.joinPattern = i })
            }, (tag: string) => tag)
          }
        }
        .alignItems(HorizontalAlign.Start).width('100%')

        // 坯布选择
        Column({ space: 8 }) {
          Text('坯布选择').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#283593')
          Flex({ wrap: FlexWrap.Wrap }) {
            ForEach(fabricTags208, (tag: string, i: number) => {
              Text(tag)
                .fontSize(11)
                .fontColor(this.joinFabric === i ? '#FFFFFF' : '#5C6BC0')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .borderRadius(16)
                .backgroundColor(this.joinFabric === i ? '#E53935' : '#E8EAF6')
                .margin(4)
                .onClick(() => { this.joinFabric = i })
            }, (tag: string) => tag)
          }
        }
        .alignItems(HorizontalAlign.Start).width('100%')

        // 浸染次数步进器
        Row({ space: 14 }) {
          Column() { Text('-').fontSize(16).fontColor('#283593') }
          .width(34).height(34).borderRadius(17)
          .backgroundColor('#E8EAF6').justifyContent(FlexAlign.Center)
          .onClick(() => { if (this.joinDips > 1) { this.joinDips -= 1 } })
          Column({ space: 2 }) {
            Text('浸染 ' + this.joinDips + ' 次').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#283593')
            Text('次数越多颜色越深').fontSize(9).fontColor('#90A4AE')
          }
          .alignItems(HorizontalAlign.Start)
          Column() { Text('+').fontSize(16).fontColor('#283593') }
          .width(34).height(34).borderRadius(17)
          .backgroundColor('#E8EAF6').justifyContent(FlexAlign.Center)
          .onClick(() => { if (this.joinDips < 6) { this.joinDips += 1 } })
          Text('').layoutWeight(1)
        }
        .width('100%')

        // 费用计算
        Row({ space: 10 }) {
          Column({ space: 2 }) {
            Text('预计花费').fontSize(13).fontColor('#283593')
            Text('含坯布与染材费').fontSize(9).fontColor('#90A4AE')
          }
          .alignItems(HorizontalAlign.Start)
          Text('').layoutWeight(1)
          Text('¥ ' + (this.joinDips * 22 + (this.joinKeep ? 15 : 0)))
            .fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C62828')
        }
        .width('100%').padding(12).borderRadius(12).backgroundColor('#E8EAF6')

        Button() {
          Text('确认预约连染').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .width('100%').height(48).borderRadius(24).backgroundColor('#283593')
        .onClick(() => { this.showJoinSheet = false })
      }
      .width('100%').padding({ left: 18, right: 18, top: 4, bottom: 24 })
    }
    .constraintSize({ maxHeight: 460 })
  }
  .width('100%').height('100%').padding({ top: 14 }).backgroundColor('#FFFFFF')
}

标签选择用Flex({ wrap: FlexWrap.Wrap })实现自动换行的胶囊群,选中态用染材对应色填充(dyeColor208(tag)),未选中用浅靛底。这种"选中即着色"的反馈让用户直观感知所选染材的色相。浸染次数是自定义步进器:左右两个圆形按钮加减,中间显示当前值与提示文案,加减都有边界保护(1到6次),防止越界。

费用计算是响应式的:this.joinDips * 22 + (this.joinKeep ? 15 : 0),浸染次数每加一次加22元,勾选留样布再加15元。由于joinDipsjoinKeep都是@State,任一变化都触发这段Text重算,实现价格实时联动。整个抽屉用Scroll包裹内容区,constraintSize({ maxHeight: 460 })限制最大高度,超出则内部滚动,避免抽屉过高遮挡主页面。

自定义开关(直播共创、留样布纪念)用Row + Circle模拟iOS风格拨动开关:外层Row是轨道、内层Circle是滑块,backgroundColor随状态在主题色与灰色间切换。这是HarmonyOS中未用Toggle组件时的标准实现范式。

九、编辑与删除弹层:不可变更新实践

编辑抽屉保存时用map回写,删除对话框确认时用filter移除,二者体现了ArkTS中数组操作的核心范式。

// 编辑保存 - map回写
Button() {
  Text('保存修改').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.width('100%').height(48).borderRadius(24).backgroundColor('#00897B')
.onClick(() => {
  this.cloths = this.cloths.map((c: Cloth208, ci: number) => {
    if (ci === this.editIndex) {
      return {
        id: c.id,
        name: this.editName === '' ? c.name : this.editName,
        dye: c.dye,
        dips: this.editDips,
        fastness: this.editFast * 18,
        state: c.state,
        watchers: c.watchers,
        starred: this.editTop
      }
    }
    return c
  })
  this.showEditSheet = false
})

// 删除确认 - filter移除
Button() {
  Text('确认下架').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
}
.layoutWeight(1).height(44).borderRadius(22).backgroundColor('#C62828')
.onClick(() => {
  this.cloths = this.cloths.filter((c: Cloth208, ci: number) => ci !== this.delIndex)
  this.showDelDialog = false
})

map回写时,命中索引的项返回全新对象(editName为空则保留原名、fastnesseditFast * 18换算为百分制),未命中项原样返回。这种"部分替换"模式保证只有被编辑行获得新引用,其余行引用不变,ArkTS的Diff能精准识别最小变更范围。filter移除则生成排除目标索引的新数组,长度减一。

注意编辑保存时fastness: this.editFast * 18这一行——弹层里色牢度是1到5级的整数,但花色册存储的是百分制,这里做了×18的近似换算。这种"录入态与存储态分离"的设计,让用户用熟悉的1-5级操作,底层用百分制存储精度,是表单设计中的常见妥协。

删除对话框还提供"保留染样册"选项(delKeepAlbum开关),即使下架花色卡,染样册仍可保留历史记录。这种"软删除"语义在非遗场景中尤其重要——作品可以下架但工艺档案不能丢失,体现了对非遗资产的保护态度。

十、花色详情对话框:图表与跳转联动

详情对话框是信息密度最高的弹层,包含指标卡、七日柱图、工艺档案、跳转预约四个区块。

@Builder
detailDialog208() {
  Column({ space: 0 }) {
    Scroll() {
      Column({ space: 0 }) {
        // 头部渐变区
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Column() {
              Text('🎨').fontSize(30)
            }
            .width(56).height(56).borderRadius(28)
            .backgroundColor('#FFFFFF').justifyContent(FlexAlign.Center)
            Column({ space: 3 }) {
              Text(this.cloths[this.detailIndex].name)
                .fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
              Text(this.cloths[this.detailIndex].dye + ' · 复染 '
                + this.cloths[this.detailIndex].dips + ' 次')
                .fontSize(11).fontColor('#E8EAF6')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Column() { Text('✕').fontSize(13).fontColor('#FFFFFF') }
            .width(28).height(28).borderRadius(14)
            .backgroundColor('rgba(255,255,255,0.2)')
            .justifyContent(FlexAlign.Center)
            .onClick(() => { this.showDetailDialog = false })
          }
          .width('100%')
        }
        .width('100%').padding(18)
        .linearGradient({ angle: 135, colors: [['#3949AB', 0], ['#C62828', 1]] })

        // 三指标卡
        Row({ space: 8 }) {
          Column({ space: 2 }) {
            Text(this.cloths[this.detailIndex].fastness + '%')
              .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#283593')
            Text('色牢度评分').fontSize(9).fontColor('#5C6BC0')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
          .padding({ top: 10, bottom: 10 }).borderRadius(10).backgroundColor('#E8EAF6')
          // ... 观看人次、当前状态
        }
        .width('100%')

        // 近7日跟染人数柱图
        Column({ space: 8 }) {
          Text('近 7 日跟染人数').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
          Row({ space: 6 }) {
            ForEach(this.dipDays, (d: DipDay208) => {
              Column({ space: 4 }) {
                Column()
                  .width(16)
                  .height(d.dips * 5)
                  .borderRadius({ topLeft: 4, topRight: 4 })
                  .linearGradient({ angle: 180, colors: [['#5C6BC0', 0], ['#283593', 1]] })
                Text(d.day).fontSize(8).fontColor('#5C6BC0')
              }
              .alignItems(HorizontalAlign.Center).layoutWeight(1)
            }, (d: DipDay208) => ('d' + d.day))
          }
          .width('100%').alignItems(VerticalAlign.Bottom).height(90)
        }
        .width('100%').padding(12).borderRadius(12).backgroundColor('#F5F5F5')

        // 跳转预约
        Button() {
          Text('预约连染同款花色').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .width('100%').height(44).borderRadius(22).backgroundColor('#C62828')
        .onClick(() => {
          this.showDetailDialog = false
          this.showJoinSheet = true
        })
      }
      .width('100%')
    }
    .constraintSize({ maxHeight: 480 })
  }
  .width('86%').borderRadius(20).backgroundColor('#FFFFFF').clip(true)
}

柱图的实现是纯ArkTS声明式:每根柱子是一个Column,高度由d.dips * 5动态计算(浸染批次乘5像素),底色用linearGradient从浅靛到深靛渐变模拟布条下垂。Row({ space: 6 })水平排列七根柱子,alignItems(VerticalAlign.Bottom)让所有柱子底对齐,形成标准柱状图效果。这种"用Column高度做数据可视化"的手法在HarmonyOS中无需引入图表库即可实现轻量图表。

详情对话框最巧妙的是跳转联动:点击"预约连染同款花色"时,先showDetailDialog = false关闭详情,再showJoinSheet = true打开预约抽屉。两个弹层状态的串行切换实现了"看中某个花色→立即预约同款"的业务闭环。.clip(true)配合borderRadius(20)让内部渐变头部被圆角裁剪,避免方角溢出。

十一、Tab1染坊直播:四机位与工艺步骤

LiveTab208是直播Tab,用Grid布局呈现四机位,配合染布六步曲与弹幕互动。

@Component
struct LiveTab208 {
  @State localMic: boolean = true
  @State localCam: boolean = true
  @State localStir: boolean = true
  @Prop cloths: Cloth208[] = []
  @Prop artisans: Artisan208[] = []
  @Prop dipSteps: DipStep208[] = []
  @Prop barrages: Barrage208[] = []
  onStep: (i: number) => void = () => {}
  onJoin: () => void = () => {}

  build() {
    Column({ space: 12 }) {
      // 四机位网格
      Grid() {
        GridItem() {
          Column({ space: 4 }) {
            Row({ space: 6 }) {
              Column() { Text('🪣').fontSize(18) }
              .width(34).height(34).borderRadius(17)
              .backgroundColor('rgba(255,255,255,0.25)')
              .justifyContent(FlexAlign.Center)
              Column({ space: 2 }) {
                Text('主镜 · 靛缸浸染').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
                Text('靛缸阿婆 · 老缸').fontSize(9).fontColor('#C5CAE9')
              }
              .alignItems(HorizontalAlign.Start)
            }
            .width('100%').padding(10)
            Text('').layoutWeight(1)
            Row({ space: 6 }) {
              Text('● LIVE').fontSize(9).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
              Text(dippingClothCount208(this.cloths) + ' 匝布在缸').fontSize(9).fontColor('#C5CAE9')
            }
            .width('100%').padding(8)
          }
          .width('100%').height('100%').borderRadius(12).padding(6)
          .linearGradient({ angle: 150, colors: [['#283593', 0], ['#1A237E', 1]] })
          .onClick(() => { this.onJoin() })
        }
        // 氧化晾晒位、拆线特写位、我的工位...
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr')
      .height(240).width('100%')

      // 工具条
      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text(this.localMic ? '🎙️' : '🔇').fontSize(18)
          Text('对讲').fontSize(9).fontColor('#283593')
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 }).borderRadius(12)
        .backgroundColor(this.localMic ? '#C5CAE9' : '#E8EAF6')
        .onClick(() => { this.localMic = !this.localMic })
        // 镜头、搅缸、连染...
      }
      .width('100%')

      // 染布六步曲
      Column({ space: 10 }) {
        Text('染布六步曲').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        ForEach(this.dipSteps, (s: DipStep208, i: number) => {
          Row({ space: 10 }) {
            Column() {
              Text(s.done ? '✓' : (i + 1) + '').fontSize(12)
                .fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            }
            .width(24).height(24).borderRadius(12)
            .backgroundColor(s.done ? '#7CB342' : '#9FA8DA')
            .justifyContent(FlexAlign.Center)
            Column({ space: 2 }) {
              Text(s.title).fontSize(12)
                .fontColor(s.done ? '#1A237E' : '#5C6BC0').fontWeight(FontWeight.Bold)
              Text(s.tip).fontSize(9).fontColor('#BDBDBD')
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Text(s.done ? '已完成' : '进行中').fontSize(9)
              .fontColor(s.done ? '#7CB342' : '#C62828')
          }
          .width('100%').padding(10).borderRadius(10)
          .backgroundColor(s.done ? '#F1F8E9' : '#FFFFFF')
          .onClick(() => { this.onStep(i) })
        }, (s: DipStep208) => ('s' + s.id))
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')

      // 弹幕
      Column({ space: 6 }) {
        Text('围染弹幕').fontSize(12).fontWeight(FontWeight.Bold).fontColor('#283593')
        ForEach(this.barrages, (b: Barrage208, i: number) => {
          Row({ space: 6 }) {
            Text('#' + (i + 1)).fontSize(8).fontColor('#C62828')
            Text(b.text).fontSize(10).fontColor('#37474F')
          }
          .width('100%').padding(6).borderRadius(8)
          .backgroundColor(i % 2 === 0 ? '#E8EAF6' : '#FBE9E7')
        }, (b: Barrage208) => ('b' + b.id))
      }
      .width('100%').padding(10).borderRadius(12).backgroundColor('#E8EAF6')
    }
    .width('100%')
  }
}

四机位用GridcolumnsTemplate('1fr 1fr')rowsTemplate('1fr 1fr')切成2×2网格,分别是主镜(靛缸浸染,靛蓝渐变)、氧化晾晒位(青绿渐变)、拆线特写位(红色渐变,“开盲盒时刻”)、我的工位(摄像头开关)。每个机位卡片用linearGradient铺底,左上角圆形图标+机位名称,右下角状态标签(LIVE、氧化中、围观数等)。主镜卡片点击触发onJoin预约连染。

四机位的色彩分配并非随意:主镜靛蓝对应浸染、氧化位青绿对应氧化显色、拆线位红色对应成品的茜草红、我的工位靛紫是用户色。四块卡片的渐变色就是扎染全流程的色相演变图,用户看一眼就理解了工艺脉络。

染布六步曲用ForEach渲染步骤列表,每步左侧圆形序号(完成显示✓绿底、未完成显示序号灰底),中间标题与提示,右侧状态标签。点击步骤触发onStep(i)回调,主入口翻转该步done状态,实现工艺进度可交互标记。弹幕列表用奇偶交替背景色(靛底/红底)增强可读性,每条弹幕前缀红色序号,模拟直播平台的弹幕流。

localMiclocalCamlocalStir三个@State是LiveTab的本地状态,不涉及主入口数据,因此放在子组件内自治。麦克风、摄像头、搅缸三个工具按钮根据这些状态切换图标与背景色,点击翻转布尔值。这种"本地交互态留在子组件"的分层,避免把所有状态都堆到主入口,保持各组件的职责清晰。

十二、Tab2花色册:增删改查与堆叠条

PatternTab208是花色册的管理中枢,提供统计卡、上传入口、染材占比堆叠条与花色列表的四联操作。

@Component
struct PatternTab208 {
  @Prop cloths: Cloth208[] = []
  onAdd: () => void = () => {}
  onDetail: (i: number) => void = () => {}
  onEdit: (i: number) => void = () => {}
  onDel: (i: number) => void = () => {}
  onStar: (i: number) => void = () => {}

  build() {
    Column({ space: 12 }) {
      // 统计卡
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text(this.cloths.length + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#C62828')
          Text('馆藏花色').fontSize(9).fontColor('#5C6BC0')
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 }).borderRadius(10).backgroundColor('#FBE9E7')
        Column({ space: 2 }) {
          Text(starredClothCount208(this.cloths) + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#283593')
          Text('精选标记').fontSize(9).fontColor('#5C6BC0')
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 }).borderRadius(10).backgroundColor('#E8EAF6')
        Column({ space: 2 }) {
          Text(dippingClothCount208(this.cloths) + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#00897B')
          Text('在缸/氧化').fontSize(9).fontColor('#5C6BC0')
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 10, bottom: 10 }).borderRadius(10).backgroundColor('#E0F2F1')
      }
      .width('100%')

      // 染材占比堆叠条
      Column({ space: 8 }) {
        Text('馆藏染材构成').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        Row() {
          ForEach(dyeCounts208(this.cloths), (c: DyeCount208) => {
            Row() {
              Text(c.label + ' ' + c.count).fontSize(8).fontColor('#FFFFFF')
            }
            .width('100%').justifyContent(FlexAlign.Center).backgroundColor(c.color)
          }, (c: DyeCount208) => (c.label + c.count))
        }
        .width('100%').height(22).borderRadius(11).clip(true)
        Row({ space: 10 }) {
          ForEach(dyeCounts208(this.cloths), (c: DyeCount208) => {
            Row({ space: 4 }) {
              Column().width(8).height(8).borderRadius(4).backgroundColor(c.color)
              Text(c.label).fontSize(9).fontColor('#5C6BC0')
            }
          }, (c: DyeCount208) => ('lg' + c.label))
        }
        .width('100%')
      }
      .width('100%').padding(12).borderRadius(12).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)

      // 花色列表
      ForEach(this.cloths, (c: Cloth208, i: number) => {
        Column({ space: 8 }) {
          Row({ space: 10 }) {
            Column() { Text('🎨').fontSize(20) }
            .width(44).height(44).borderRadius(12)
            .backgroundColor('#E8EAF6').justifyContent(FlexAlign.Center)
            Column({ space: 3 }) {
              Text(c.name).fontSize(13).fontColor('#1A237E').fontWeight(FontWeight.Bold)
              Row({ space: 6 }) {
                Text(c.dye).fontSize(8).fontColor('#FFFFFF')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(6).backgroundColor(dyeColor208(c.dye))
                Text('复染 ' + c.dips + ' 次').fontSize(9).fontColor('#5C6BC0')
                Text('围观 ' + c.watchers).fontSize(9).fontColor('#BDBDBD')
              }
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Column() { Text(c.starred ? '⭐' : '☆').fontSize(18) }
            .width(32).height(32).justifyContent(FlexAlign.Center)
            .onClick(() => { this.onStar(i) })
          }
          .width('100%')

          Row() {
            Row({ space: 4 }) {
              Text('色牢度').fontSize(9).fontColor('#5C6BC0')
              Text(c.fastness + '%').fontSize(10).fontColor('#C62828').fontWeight(FontWeight.Bold)
            }
            Row({ space: 4 }) {
              Text('状态').fontSize(9).fontColor('#5C6BC0')
              Text(c.state).fontSize(10).fontColor(clothStateColor208(c.state)).fontWeight(FontWeight.Bold)
            }
            .margin({ left: 14 })
            Text('').layoutWeight(1)
            Column() { Text('详情').fontSize(10).fontColor('#FFFFFF') }
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(10)
            .backgroundColor('#283593')
            .onClick(() => { this.onDetail(i) })
            Column() { Text('编辑').fontSize(10).fontColor('#283593') }
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(10)
            .backgroundColor('#E8EAF6').margin({ left: 6 })
            .onClick(() => { this.onEdit(i) })
            Column() { Text('下架').fontSize(10).fontColor('#C62828') }
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(10)
            .backgroundColor('#FBE9E7').margin({ left: 6 })
            .onClick(() => { this.onDel(i) })
          }
          .width('100%')
        }
        .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      }, (c: Cloth208) => ('c' + c.id + c.state))
    }
    .width('100%')
  }
}

统计卡用三个layoutWeight(1)等分的色块呈现馆藏总数、精选标记数、在缸/氧化数,数字颜色与背景色语义对应(红/蓝/绿)。堆叠条是亮点:dyeCounts208(this.cloths)返回各染材的计数,外层Row水平排列每个染材的色块,每个色块width('100%')但被Flex按比例分配实际宽度(因为多个子项平分),实现按占比的堆叠效果。.clip(true)配合borderRadius(11)让两端色块被圆角裁剪。下方图例用小圆点+标签对应色块。

花色列表每行包含图标、名称、染材标签(用dyeColor208着色)、复染次数、围观数、星标按钮、色牢度、状态标签,以及详情/编辑/下架三个操作按钮。星标点击触发onStar(i),三个操作按钮分别触发onDetailonEditonDel回调。ForEach的键是'c' + c.id + c.state,包含id与状态,当某条花色状态变更时键也变更,框架识别为新项重新渲染,确保状态色标签即时更新。

三个操作按钮的颜色编码遵循固定语义:详情用靛蓝(信息查看)、编辑用浅靛底深靛字(中性修改)、下架用红底红字(危险操作)。这种"色彩即语义"的按钮体系让用户在密集列表中也能凭色辨操作。

十三、Tab3染材库:热度条与柱图

DyeTab208展示草木染材档案,包含染材卡、人气热度横条、本周浸染批次柱图、染材用量占比堆叠条。

@Component
struct DyeTab208 {
  @Prop dyes: Dye208[] = []
  @Prop cloths: Cloth208[] = []
  @Prop dipDays: DipDay208[] = []

  build() {
    Column({ space: 12 }) {
      // 染材卡
      Column({ space: 8 }) {
        Text('草木染材架').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        ForEach(this.dyes, (d: Dye208) => {
          Row({ space: 10 }) {
            Column() { Text('🌿').fontSize(18) }
            .width(40).height(40).borderRadius(10)
            .backgroundColor(d.color + '22').justifyContent(FlexAlign.Center)
            Column({ space: 2 }) {
              Row({ space: 6 }) {
                Text(d.name).fontSize(12).fontColor('#1A237E').fontWeight(FontWeight.Bold)
                Column().width(10).height(10).borderRadius(5).backgroundColor(d.color)
              }
              Text('主产地 ' + d.origin + ' · 年产 ' + d.output + ' 缸')
                .fontSize(9).fontColor('#5C6BC0')
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Text(d.heat + '°').fontSize(13).fontColor(d.color).fontWeight(FontWeight.Bold)
          }
          .width('100%').padding(10).borderRadius(10).backgroundColor('#F5F5F5')
        }, (d: Dye208) => ('d' + d.id))
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)

      // 染材热度横条
      Column({ space: 10 }) {
        Text('染材人气热度').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        ForEach(this.dyes, (d: Dye208) => {
          Column({ space: 4 }) {
            Row({ space: 6 }) {
              Text(d.name).fontSize(10).fontColor('#283593').width(52)
              Row() {
                Row()
                  .width(d.heat + '%')
                  .height(10).borderRadius(5)
                  .linearGradient({ angle: 0, colors: [[d.color, 0], ['#C62828', 1]] })
              }
              .layoutWeight(1).height(10).borderRadius(5).backgroundColor('#E8EAF6')
              Text(d.heat + '').fontSize(9).fontColor('#C62828').fontWeight(FontWeight.Bold)
            }
            .width('100%')
          }
          .width('100%')
        }, (d: Dye208) => ('heat' + d.id))
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)

      // 本周浸染批次柱图
      Column({ space: 8 }) {
        Text('本周每日浸染批次').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        Row({ space: 6 }) {
          ForEach(this.dipDays, (d: DipDay208) => {
            Column({ space: 4 }) {
              Text(d.dips + '').fontSize(8).fontColor('#283593')
              Column()
                .width(18)
                .height(d.dips * 5)
                .borderRadius({ topLeft: 4, topRight: 4 })
                .linearGradient({ angle: 180, colors: [['#5C6BC0', 0], ['#1A237E', 1]] })
              Text(d.day).fontSize(8).fontColor('#5C6BC0')
            }
            .alignItems(HorizontalAlign.Center).layoutWeight(1)
          }, (d: DipDay208) => ('dd' + d.day))
        }
        .width('100%').alignItems(VerticalAlign.Bottom).height(100)
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
  }
}

染材卡用d.color + '22'生成带透明度的色块背景(22是十六进制透明度,约13%),让染材图标底色与染材本色对应但更柔和。人气热度横条是进度条变体:外层Row是灰色轨道,内层Row宽度为d.heat + '%',用linearGradient从染材色渐变到红色,表示热度越高越偏红。柱图与详情对话框中的柱图实现一致,d.dips * 5为像素高度,底对齐排列。

d.color + '22'这种字符串拼接透明度的写法是HarmonyOS中16进制色值+Alpha的惯用法。#28359322表示靛蓝带13%透明度,比rgba(40,53,147,0.13)更简洁,且与d.color同源便于复用。

十四、Tab4坯布架与Tab5匠人团

坯布架提供库存列表与色牢度走势柱图,匠人团提供在线统计与人气榜。

@Component
struct FabricTab208 {
  @Prop fabrics: Fabric208[] = []
  @Prop fastLogs: FastLog208[] = []
  @State pickIndex: number = -1

  build() {
    Column({ space: 12 }) {
      // 统计卡:在架坯布、本周耗用、平均色牢度
      Row({ space: 8 }) { /* ... */ }
      .width('100%')

      // 坯布列表
      Column({ space: 8 }) {
        Text('坯布货架').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        ForEach(this.fabrics, (f: Fabric208, i: number) => {
          Row({ space: 10 }) {
            Column() { Text(f.icon).fontSize(18) }
            .width(40).height(40).borderRadius(10).backgroundColor('#E8EAF6')
            .justifyContent(FlexAlign.Center)
            Column({ space: 2 }) {
              Text(f.name).fontSize(12).fontColor('#1A237E').fontWeight(FontWeight.Bold)
              Text('库存 ' + f.stock + ' 匹 · 累计用量 ' + f.times + ' 匹')
                .fontSize(9).fontColor('#5C6BC0')
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Column() {
              Text(this.pickIndex === i ? '已选定' : (f.used ? '常用' : '备选')).fontSize(10)
                .fontColor(this.pickIndex === i ? '#FFFFFF' : (f.used ? '#283593' : '#BDBDBD'))
            }
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(10)
            .backgroundColor(this.pickIndex === i ? '#C62828' : (f.used ? '#E8EAF6' : '#F5F5F5'))
            .onClick(() => { this.pickIndex = i })
          }
          .width('100%').padding(10).borderRadius(10)
          .backgroundColor(this.pickIndex === i ? '#FBE9E7' : '#FAFAFA')
        }, (f: Fabric208) => ('t' + f.id + this.pickIndex))
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)

      // 色牢度走势柱图
      Column({ space: 8 }) {
        Text('近 7 日成品色牢度').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        Row({ space: 6 }) {
          ForEach(this.fastLogs, (l: FastLog208) => {
            Column({ space: 4 }) {
              Text(l.score + '').fontSize(8).fontColor('#283593')
              Column()
                .width(16)
                .height(l.score)
                .borderRadius({ topLeft: 4, topRight: 4 })
                .linearGradient({ angle: 180, colors: [['#80CBC4', 0], ['#00695C', 1]] })
              Text(l.day).fontSize(8).fontColor('#5C6BC0')
            }
            .alignItems(HorizontalAlign.Center).layoutWeight(1)
          }, (l: FastLog208) => ('u' + l.day))
        }
        .width('100%').alignItems(VerticalAlign.Bottom).height(110)
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
  }
}

@Component
struct ArtisanTab208 {
  @Prop artisans: Artisan208[] = []
  @State followIndex: number = -1
  onJoin: () => void = () => {}

  build() {
    Column({ space: 12 }) {
      // 在线匠人统计 + 去连染
      Column({ space: 8 }) {
        Row({ space: 10 }) {
          Column() { Text('🧕').fontSize(24) }
          .width(48).height(48).borderRadius(24).backgroundColor('#FBE9E7')
          .justifyContent(FlexAlign.Center)
          Column({ space: 3 }) {
            Text('在线匠人 ' + onlineArtisanCount208(this.artisans) + ' / ' + this.artisans.length)
              .fontSize(14).fontColor('#1A237E').fontWeight(FontWeight.Bold)
            Text('连麦共创 · 同缸染布').fontSize(10).fontColor('#5C6BC0')
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
          Column() { Text('去连染').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
          .padding({ left: 12, right: 12, top: 7, bottom: 7 }).borderRadius(14)
          .backgroundColor('#C62828')
          .onClick(() => { this.onJoin() })
        }
        .width('100%')
      }
      .width('100%').padding(14).borderRadius(14).backgroundColor('#FFFFFF')

      // 匠人人气榜
      Column({ space: 10 }) {
        Text('匠人人气榜').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        ForEach(this.artisans, (a: Artisan208, i: number) => {
          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text((i + 1) + '').fontSize(13)
                .fontColor(i < 3 ? '#C62828' : '#BDBDBD').fontWeight(FontWeight.Bold)
            }
            .width(22).justifyContent(FlexAlign.Center)
            Column() { Text(a.name.slice(0, 1)).fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
            .width(40).height(40).borderRadius(20)
            .backgroundColor(dyeColor208(dyeTags208[i % dyeTags208.length]))
            .justifyContent(FlexAlign.Center)
            Column({ space: 2 }) {
              Row({ space: 6 }) {
                Text(a.name).fontSize(12).fontColor('#1A237E').fontWeight(FontWeight.Bold)
                if (a.online) {
                  Column() { Text('在线').fontSize(8).fontColor('#7CB342') }
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(6).backgroundColor('#F1F8E9')
                }
              }
              Text(a.city + ' · 染龄 ' + a.years + ' 年').fontSize(9).fontColor('#5C6BC0')
              Row({ space: 6 }) {
                Row() {
                  Row()
                    .width((a.heat / maxArtisanHeat208(this.artisans) * 100) + '%')
                    .height(8).borderRadius(4)
                    .linearGradient({ angle: 0, colors: [['#EF9A9A', 0], ['#C62828', 1]] })
                }
                .width(90).height(8).borderRadius(4).backgroundColor('#E8EAF6').clip(true)
                Text('人气 ' + a.heat).fontSize(8).fontColor('#C62828')
              }
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Column() {
              Text(this.followIndex === i ? '已关注' : '关注').fontSize(10)
                .fontColor(this.followIndex === i ? '#FFFFFF' : '#C62828')
            }
            .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
            .backgroundColor(this.followIndex === i ? '#C62828' : '#FBE9E7')
            .onClick(() => { this.followIndex = i })
          }
          .width('100%').padding(10).borderRadius(10).backgroundColor('#FAFAFA')
        }, (a: Artisan208) => ('a' + a.id + this.followIndex))
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
  }
}

坯布架的pickIndex是子组件本地状态,点击坯布行切换选中态,选中行背景变红、标签变"已选定"。ForEach的键't' + f.id + this.pickIndex包含pickIndex,确保选中态变化时对应行重新渲染。色牢度走势柱图用青绿色渐变(#80CBC4#00695C),与染材库的靛蓝柱图色彩区分,让不同Tab的图表有各自的色相身份。

匠人榜的排名前三序号用红色突出,其余灰色。头像用a.name.slice(0, 1)取姓名首字,背景色用dyeColor208(dyeTags208[i % dyeTags208.length])循环取染材色,让六位匠人的头像底色恰好对应六种染材,形成"一位匠人一种染材"的视觉隐喻。人气进度条宽度是a.heat / maxArtisanHeat208(this.artisans) * 100 + '%',相对最高人气归一化,最高者满格。

十五、Tab6我的:个人卡与设置开关

MineTab208是用户中心,包含个人渐变卡、本周浸染柱图、共创单列表、三个设置开关。

@Component
struct MineTab208 {
  @Prop cloths: Cloth208[] = []
  @Prop orders: Order208[] = []
  @Prop dipDays: DipDay208[] = []
  @State nightDip: boolean = false
  @State notifyOpen: boolean = true
  @State autoAlbum: boolean = false

  build() {
    Column({ space: 12 }) {
      // 个人卡 - 渐变
      Column({ space: 10 }) {
        Row({ space: 12 }) {
          Column() { Text('染').fontSize(20).fontColor('#FFFFFF').fontWeight(FontWeight.Bold) }
          .width(54).height(54).borderRadius(27)
          .backgroundColor('rgba(255,255,255,0.25)').justifyContent(FlexAlign.Center)
          Column({ space: 3 }) {
            Text('染友 · 靛蓝学徒').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('共创 26 次 · 出作品 18 件 · 上榜 3 次').fontSize(10).fontColor('#C5CAE9')
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
          Text('🪣').fontSize(22)
        }
        .width('100%')

        Row({ space: 8 }) {
          Column({ space: 2 }) {
            Text(starredClothCount208(this.cloths) + '').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#E8EAF6')
            Text('精选作品').fontSize(9).fontColor('#C5CAE9')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column({ space: 2 }) {
            Text('980').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#E8EAF6')
            Text('染币余额').fontSize(9).fontColor('#C5CAE9')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
          Column({ space: 2 }) {
            Text('Lv.4').fontSize(15).fontWeight(FontWeight.Bold).fontColor('#E8EAF6')
            Text('染友等级').fontSize(9).fontColor('#C5CAE9')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').padding({ top: 10 })
      }
      .width('100%').padding(16).borderRadius(16)
      .linearGradient({ angle: 135, colors: [['#3949AB', 0], ['#C62828', 1]] })

      // 我的共创单
      Column({ space: 8 }) {
        Text('我的共创单').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
        ForEach(this.orders, (o: Order208, i: number) => {
          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('D' + o.dayNum).fontSize(11).fontColor('#C62828').fontWeight(FontWeight.Bold)
              Text(o.dye).fontSize(8).fontColor('#BDBDBD')
            }
            .width(38).alignItems(HorizontalAlign.Center)
            Column({ space: 2 }) {
              Text(o.name).fontSize(11).fontColor('#1A237E').fontWeight(FontWeight.Bold)
              Text('扎花 ' + o.bundles + ' 匝 · 布量 ' + o.meters + ' 米').fontSize(9).fontColor('#5C6BC0')
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            if (o.top) {
              Column() { Text('置顶').fontSize(8).fontColor('#C62828') }
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(6).backgroundColor('#FBE9E7')
            }
            Text(o.state).fontSize(9).fontColor(orderStateColor208(o.state)).fontWeight(FontWeight.Bold)
          }
          .width('100%').padding(8).borderRadius(8)
          .backgroundColor(i % 2 === 0 ? '#FAFAFA' : '#E8EAF6')
        }, (o: Order208) => ('mo' + o.id))
      }
      .width('100%').padding(12).borderRadius(14).backgroundColor('#FFFFFF')
      .alignItems(HorizontalAlign.Start)

      // 设置开关
      Column({ space: 0 }) {
        Row({ space: 10 }) {
          Text('🌙').fontSize(16)
          Column({ space: 2 }) {
            Text('夜染模式').fontSize(12).fontColor('#1A237E')
            Text('夜间连染自动补光').fontSize(9).fontColor('#90A4AE')
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
          Row() { Circle().width(16).height(16).fill('#FFFFFF') }
          .width(44).height(24).borderRadius(12).justifyContent(FlexAlign.Center)
          .backgroundColor(this.nightDip ? '#C62828' : '#BDBDBD')
          .onClick(() => { this.nightDip = !this.nightDip })
        }
        .width('100%').padding(12)
        // 开缸开播提醒、自动存染样册...
      }
      .width('100%').borderRadius(14).backgroundColor('#FFFFFF')
    }
    .width('100%')
  }
}

个人卡用linearGradient从靛蓝渐变到红色(#3949AB#C62828),与详情对话框头部渐变一致,形成"品牌色"统一识别。卡片内三指标(精选作品、染币余额、染友等级)等分排列,白色文字在渐变底上高对比可读。共创单列表每行左侧D + dayNum表示第几日开染,右侧状态标签用orderStateColor208着色,置顶订单额外显示红色"置顶"标签。三个设置开关(夜染模式、开缸提醒、自动存样册)都是本地@State布尔,点击翻转,轨道色随状态在红色与灰色间切换。

业务流程图

下面用流程图梳理从用户进入应用到完成一次连麦共创的完整链路。

染坊

花色册

染材库

坯布架

匠人团

我的

预约同款

确认

用户进入云染坊

顶部晾布夹Tab导航

选择Tab

四机位直播网格

花色列表与统计

染材卡与热度图

坯布选择与色牢度

匠人榜与关注

个人卡与订单

点击主镜/连染

点击详情

点击编辑

点击下架

点击星标

点击去连染

预约连麦共创抽屉

花色详情对话框

选目标花色

选坯布

调浸染次数

开关直播/留样

实时计算费用

确认预约

编辑抽屉

改名称/次数/色牢度/置顶

map回写cloths

删除对话框

保留染样册?

filter移除cloths

map翻转starred

关闭抽屉回到Tab

技术点对比表

技术点 实现方式 适用场景 优势 注意事项
@State 组件内可变状态 主入口数据、本地开关 自动触发UI刷新 引用变更才触发观察,需不可变更新
@Prop 父到子单向传递 子Tab接收数据快照 隔离子组件误改 只读,修改需回调上报
@Builder UI结构复用函数 头部、导航、弹层 降低build嵌套深度 内部this指向宿主组件
bindSheet 底部抽屉容器 表单录入(预约/上传/编辑) 原生拖拽、高度可设 需$$双向绑定开关
bindContentCover 全屏遮罩容器 对话框(删除/详情) 居中展示、不占位 内容需自带圆角裁剪
ForEach键生成 第三参数key 列表渲染 精准Diff、最小重渲染 键需稳定唯一,含易变字段
map不可变更新 生成新数组 编辑/星标/步骤切换 引用变更触发观察 返回新对象,勿原地修改
filter不可变更新 排除目标项 删除操作 长度自动减一 索引基准可能偏移,用ci比较
linearGradient 渐变背景 头部、机位、柱图 视觉层次丰富 angle控制光线方向
layoutWeight(1)空Text 弹性占位 两端对齐布局 细粒度空间分配 空文本不占视觉但占布局
Column高度做图表 数据可视化 柱图、进度条 无需图表库 像素值需手动换算
Circle+Row模拟开关 自定义Toggle 设置项、表单开关 视觉风格统一 非原生Toggle,无障碍支持弱
纯函数聚合 无副作用计算 统计指标、占比 幂等可复用 避免依赖外部可变状态

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// ============================================================
// 场景:扎染匠人连麦共创 / 染缸浸布直播围观
// 配色:靛蓝 #283593 × 茜草红 #E53935 × 布白 #F5F0E8
// Tab 布局:顶部「晾布夹」导航(横向晾杆 + 衣夹 + 错落悬挂布条)
// ============================================================

interface DipDay208 {
  day: string
  dips: number
}

interface Cloth208 {
  id: number
  name: string
  dye: string
  dips: number
  fastness: number
  state: string
  watchers: number
  starred: boolean
}

interface Dye208 {
  id: number
  name: string
  color: string
  origin: string
  output: number
  heat: number
}

interface Order208 {
  id: number
  name: string
  dye: string
  dayNum: number
  bundles: number
  meters: number
  state: string
  top: boolean
}

interface Artisan208 {
  id: number
  name: string
  city: string
  years: number
  online: boolean
  heat: number
}

interface Barrage208 {
  id: number
  text: string
}

interface DipStep208 {
  id: number
  title: string
  tip: string
  done: boolean
}

interface DyeCount208 {
  label: string
  count: number
  color: string
}

interface FastLog208 {
  day: string
  score: number
}

interface Fabric208 {
  id: number
  name: string
  icon: string
  stock: number
  used: boolean
  times: number
}

const dyeTags208: string[] = ['板蓝根', '茜草', '栀子', '苏木', '薯莨']
const fabricTags208: string[] = ['棉麻方巾', '真丝长巾', '帆布袋']

function clothStateColor208(state: string): string {
  if (state === '浸染中') {
    return '#283593'
  }
  if (state === '氧化中') {
    return '#00897B'
  }
  if (state === '已晾干') {
    return '#E53935'
  }
  return '#90A4AE'
}

function orderStateColor208(state: string): string {
  if (state === '今日开缸') {
    return '#E53935'
  }
  if (state === '氧化等待') {
    return '#00897B'
  }
  if (state === '已交付') {
    return '#7CB342'
  }
  return '#90A4AE'
}

function dyeColor208(dye: string): string {
  if (dye === '板蓝根') {
    return '#283593'
  }
  if (dye === '茜草') {
    return '#E53935'
  }
  if (dye === '栀子') {
    return '#FBC02D'
  }
  if (dye === '苏木') {
    return '#8D6E63'
  }
  return '#C62828'
}

function starredClothCount208(cloths: Cloth208[]): number {
  let n: number = 0
  for (let i = 0; i < cloths.length; i++) {
    if (cloths[i].starred) {
      n++
    }
  }
  return n
}

function dippingClothCount208(cloths: Cloth208[]): number {
  let n: number = 0
  for (let i = 0; i < cloths.length; i++) {
    if (cloths[i].state === '浸染中' || cloths[i].state === '氧化中') {
      n++
    }
  }
  return n
}

function onlineArtisanCount208(artisans: Artisan208[]): number {
  let n: number = 0
  for (let i = 0; i < artisans.length; i++) {
    if (artisans[i].online) {
      n++
    }
  }
  return n
}

function maxArtisanHeat208(artisans: Artisan208[]): number {
  let m: number = 1
  for (let i = 0; i < artisans.length; i++) {
    if (artisans[i].heat > m) {
      m = artisans[i].heat
    }
  }
  return m
}

function dyeCounts208(cloths: Cloth208[]): DyeCount208[] {
  const counts: DyeCount208[] = []
  for (let i = 0; i < dyeTags208.length; i++) {
    let n: number = 0
    for (let j = 0; j < cloths.length; j++) {
      if (cloths[j].dye === dyeTags208[i]) {
        n++
      }
    }
    counts.push({ label: dyeTags208[i], count: n, color: ['#283593', '#E53935', '#FBC02D', '#8D6E63', '#C62828'][i] })
  }
  return counts
}

// ================= 主页面 =================

@Entry
@Component
struct Index208 {
  @State tabIndex1: number = 0

  // 弹框开关
  @State showJoinSheet: boolean = false
  @State showPatternSheet: boolean = false
  @State showEditSheet: boolean = false
  @State showDelDialog: boolean = false
  @State showDetailDialog: boolean = false

  // 预约连麦共创表单
  @State joinPattern: number = 0
  @State joinFabric: number = 0
  @State joinDips: number = 3
  @State joinLive: boolean = true
  @State joinKeep: boolean = false

  // 上传花色表单
  @State patternName: string = ''
  @State patternDye: number = 0
  @State patternDips: number = 3
  @State patternFast: number = 4
  @State patternPublic: boolean = true

  // 编辑表单
  @State editIndex: number = -1
  @State editName: string = ''
  @State editDips: number = 3
  @State editFast: number = 4
  @State editTop: boolean = false

  // 删除
  @State delIndex: number = -1
  @State delKeepAlbum: boolean = true

  // 详情
  @State detailIndex: number = 0

  // 数据
  @State cloths: Cloth208[] = [
    { id: 1, name: '星空纹方巾', dye: '板蓝根', dips: 5, fastness: 92, state: '浸染中', watchers: 3120, starred: true },
    { id: 2, name: '鱼子缬手帕', dye: '茜草', dips: 3, fastness: 86, state: '氧化中', watchers: 1680, starred: false },
    { id: 3, name: '鹿胎缬桌旗', dye: '苏木', dips: 4, fastness: 78, state: '已晾干', watchers: 2240, starred: true },
    { id: 4, name: '蝴蝶花长巾', dye: '板蓝根', dips: 6, fastness: 90, state: '浸染中', watchers: 3560, starred: true },
    { id: 5, name: '栀子金茶席', dye: '栀子', dips: 2, fastness: 82, state: '氧化中', watchers: 1120, starred: false },
    { id: 6, name: '玛瑙纹帆布袋', dye: '茜草', dips: 4, fastness: 88, state: '已晾干', watchers: 2050, starred: false },
    { id: 7, name: '苍山雪围巾', dye: '板蓝根', dips: 3, fastness: 85, state: '浸染中', watchers: 1890, starred: true },
    { id: 8, name: '薯莨赤披肩', dye: '薯莨', dips: 6, fastness: 94, state: '已晾干', watchers: 4380, starred: true },
    { id: 9, name: '叠染云肩', dye: '苏木', dips: 5, fastness: 80, state: '氧化中', watchers: 1350, starred: false },
    { id: 10, name: '青花瓷纹餐垫', dye: '板蓝根', dips: 4, fastness: 87, state: '已晾干', watchers: 960, starred: false }
  ]

  @State dyes: Dye208[] = [
    { id: 1, name: '板蓝根', color: '#283593', origin: '大理周城', output: 320, heat: 96 },
    { id: 2, name: '茜草', color: '#E53935', origin: '渭南', output: 210, heat: 88 },
    { id: 3, name: '栀子', color: '#FBC02D', origin: '岳阳', output: 160, heat: 82 },
    { id: 4, name: '苏木', color: '#8D6E63', origin: '凭祥', output: 140, heat: 77 },
    { id: 5, name: '薯莨', color: '#C62828', origin: '顺德', output: 95, heat: 90 }
  ]

  @State orders: Order208[] = [
    { id: 1, name: '星空纹方巾共创单', dye: '板蓝根', dayNum: 2, bundles: 6, meters: 12, state: '今日开缸', top: true },
    { id: 2, name: '鱼子缬手帕拼单', dye: '茜草', dayNum: 1, bundles: 4, meters: 6, state: '氧化等待', top: false },
    { id: 3, name: '蝴蝶花长巾跟染', dye: '板蓝根', dayNum: 3, bundles: 8, meters: 16, state: '今日开缸', top: false },
    { id: 4, name: '栀子金茶席团染', dye: '栀子', dayNum: 1, bundles: 5, meters: 9, state: '氧化等待', top: false },
    { id: 5, name: '薯莨赤披肩定制', dye: '薯莨', dayNum: 4, bundles: 3, meters: 15, state: '已交付', top: false },
    { id: 6, name: '鹿胎缬桌旗跟染', dye: '苏木', dayNum: 2, bundles: 4, meters: 8, state: '已交付', top: false },
    { id: 7, name: '叠染云肩共创', dye: '苏木', dayNum: 5, bundles: 7, meters: 14, state: '氧化等待', top: false },
    { id: 8, name: '青花瓷纹餐垫拼单', dye: '板蓝根', dayNum: 1, bundles: 9, meters: 11, state: '已交付', top: false }
  ]

  @State artisans: Artisan208[] = [
    { id: 1, name: '靛缸阿婆', city: '大理', years: 32, online: true, heat: 98 },
    { id: 2, name: '扎花阿姐', city: '自贡', years: 15, online: true, heat: 91 },
    { id: 3, name: '薯莨传人', city: '顺德', years: 21, online: false, heat: 89 },
    { id: 4, name: '茜草姑娘', city: '渭南', years: 8, online: true, heat: 80 },
    { id: 5, name: '缬蓝师傅', city: '南通', years: 18, online: false, heat: 86 },
    { id: 6, name: '栀子小哥', city: '岳阳', years: 6, online: true, heat: 70 }
  ]

  @State dipDays: DipDay208[] = [
    { day: '周一', dips: 6 },
    { day: '周二', dips: 9 },
    { day: '周三', dips: 7 },
    { day: '周四', dips: 12 },
    { day: '周五', dips: 14 },
    { day: '周六', dips: 18 },
    { day: '周日', dips: 15 }
  ]

  @State fastLogs: FastLog208[] = [
    { day: 'D1', score: 78 },
    { day: 'D2', score: 82 },
    { day: 'D3', score: 80 },
    { day: 'D4', score: 86 },
    { day: 'D5', score: 88 },
    { day: 'D6', score: 91 },
    { day: 'D7', score: 90 }
  ]

  @State fabrics: Fabric208[] = [
    { id: 1, name: '棉麻方巾', icon: '🧵', stock: 24, used: true, times: 132 },
    { id: 2, name: '真丝长巾', icon: '丝绸', stock: 12, used: false, times: 76 },
    { id: 3, name: '帆布袋', icon: '👜', stock: 18, used: true, times: 58 },
    { id: 4, name: '棉布茶席', icon: '🍵', stock: 15, used: false, times: 64 },
    { id: 5, name: '亚麻围巾', icon: '🧣', stock: 9, used: false, times: 41 },
    { id: 6, name: '手帕坯', icon: '🪡', stock: 30, used: true, times: 118 },
    { id: 7, name: '束口袋', icon: '🎒', stock: 21, used: false, times: 37 },
    { id: 8, name: 'T 恤坯', icon: '👕', stock: 11, used: false, times: 29 }
  ]

  @State barrages: Barrage208[] = [
    { id: 1, text: '出缸那一瞬间太惊艳了' },
    { id: 2, text: '靛蓝从绿变蓝的过程百看不厌' },
    { id: 3, text: '星空纹的留白好绝' },
    { id: 4, text: '第一次云扎染,拆线像开奖' },
    { id: 5, text: '阿婆的手法行云流水' },
    { id: 6, text: '这缸养了八年了吧' },
    { id: 7, text: '浸染 +1,记第五次' },
    { id: 8, text: '茜草红越晒越亮' }
  ]

  @State dipSteps: DipStep208[] = [
    { id: 1, title: '设计捆扎', tip: '皮筋弹珠防染 · 想好留白', done: true },
    { id: 2, title: '养缸调液', tip: '看靛花 · 试染布条定浓淡', done: true },
    { id: 3, title: '下缸浸染', tip: '浸透 3-5 分钟 · 轻柔翻动', done: true },
    { id: 4, title: '氧化显色', tip: '出缸透氧 · 绿转蓝再复染', done: false },
    { id: 5, title: '拆线漂洗', tip: '剪皮筋 · 清水漂至无浮色', done: false },
    { id: 6, title: '晾晒定色', tip: '阴干固色 · 避暴晒褪色', done: false }
  ]

  tabs208: string[] = ['染坊', '花色册', '染材库', '坯布架', '匠人团', '我的']
  tabIcons208: string[] = ['🪣', '🎨', '🌿', '🧵', '🧕', '👤']
  tabDrops208: number[] = [0, 6, 2, 8, 4, 10]

  // ---------- 头部(电商非遗季风,无动画) ----------
  @Builder
  header208() {
    Column({ space: 12 }) {
      Row({ space: 10 }) {
        Column({ space: 4 }) {
          Text('织彩 · 云染坊').fontSize(19).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Text('扎染匠人连麦共创 · 染缸浸布直播围观').fontSize(11).fontColor('#C5CAE9')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Column({ space: 2 }) {
          Text('🪣').fontSize(20)
          Text('1,526').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Text('围染席位').fontSize(9).fontColor('#C5CAE9')
        }
        .alignItems(HorizontalAlign.Center)
      }
      .width('100%')

      Row({ space: 10 }) {
        Column().width(4).height(34).borderRadius(2).backgroundColor('#E53935')
        Column({ space: 3 }) {
          Text('板蓝根老缸今晚开染').fontSize(13).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Text('连麦共创抽留样布 · 围观送扎染方巾').fontSize(10).fontColor('#E8EAF6')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Column() {
          Text('去连染 →').fontSize(11).fontColor('#283593').fontWeight(FontWeight.Bold)
        }
        .padding({ left: 12, right: 12, top: 7, bottom: 7 })
        .borderRadius(14)
        .backgroundColor('#E8EAF6')
        .onClick(() => {
          this.showJoinSheet = true
        })
      }
      .width('100%')
      .padding(12)
      .borderRadius(12)
      .backgroundColor('#1A237E')
    }
    .alignItems(HorizontalAlign.Start)
    .padding(14)
    .linearGradient({ angle: 140, colors: [['#3949AB', 0], ['#283593', 1]] })
  }

  // ---------- 顶部「晾布夹」tab ----------
  @Builder
  tabBar208() {
    Column({ space: 0 }) {
      // 晾杆
      Row() {
        Text('').layoutWeight(1)
      }
      .width('100%')
      .height(2)
      .backgroundColor('#B0BEC5')
      .margin({ left: 10, right: 10 })

      Row({ space: 4 }) {
        ForEach(this.tabs208, (t: string, i: number) => {
          Column({ space: 0 }) {
            // 衣夹
            Row() {
              Column().width(3).height(7).borderRadius(1).backgroundColor(this.tabIndex1 === i ? '#E53935' : '#90A4AE')
            }
            .height(8)
            .justifyContent(FlexAlign.Center)
            // 悬挂布条
            Column({ space: 2 }) {
              Text(this.tabIcons208[i]).fontSize(13)
              Text(t).fontSize(9).fontColor(this.tabIndex1 === i ? '#FFFFFF' : '#5C6BC0').maxLines(1)
            }
            .width(46)
            .padding({ top: 5, bottom: 5 })
            .alignItems(HorizontalAlign.Center)
            .borderRadius({ bottomLeft: 8, bottomRight: 8 })
            .backgroundColor(this.tabIndex1 === i ? '#283593' : '#FFFFFF')
            .shadow(this.tabIndex1 === i ? { radius: 8, color: 'rgba(40,53,147,0.4)', offsetY: 3 } : { radius: 4, color: 'rgba(0,0,0,0.08)', offsetY: 2 })
          }
          .layoutWeight(1)
          .padding({ top: 0, bottom: 4 })
          .margin({ top: this.tabDrops208[i] })
          .scale({ x: this.tabIndex1 === i ? 1.08 : 1, y: this.tabIndex1 === i ? 1.08 : 1 })
          .animation({ duration: 180 })
          .onClick(() => {
            this.tabIndex1 = i
          })
        }, (t: string) => t)
      }
      .width('100%')
      .alignItems(VerticalAlign.Top)
      .padding({ top: 2, left: 6, right: 6 })
    }
    .width('100%')
    .padding({ top: 4, bottom: 4 })
    .backgroundColor('#FFFFFF')
    .shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: 3 })
  }

  build() {
    Column() {
      this.header208()
      this.tabBar208()
      Scroll() {
        Column({ space: 12 }) {
          if (this.tabIndex1 === 0) {
            LiveTab208({
              cloths: this.cloths,
              artisans: this.artisans,
              dipSteps: this.dipSteps,
              barrages: this.barrages,
              onStep: (i: number) => {
                this.dipSteps = this.dipSteps.map((s: DipStep208, si: number) => {
                  if (si === i) {
                    return { id: s.id, title: s.title, tip: s.tip, done: !s.done }
                  }
                  return s
                })
              },
              onJoin: () => {
                this.showJoinSheet = true
              }
            })
          }
          if (this.tabIndex1 === 1) {
            PatternTab208({
              cloths: this.cloths,
              onAdd: () => {
                this.showPatternSheet = true
              },
              onDetail: (i: number) => {
                this.detailIndex = i
                this.showDetailDialog = true
              },
              onEdit: (i: number) => {
                this.editIndex = i
                this.editName = this.cloths[i].name
                this.editDips = this.cloths[i].dips
                this.editFast = this.cloths[i].fastness
                this.editTop = this.cloths[i].starred
                this.showEditSheet = true
              },
              onDel: (i: number) => {
                this.delIndex = i
                this.showDelDialog = true
              },
              onStar: (i: number) => {
                this.cloths = this.cloths.map((c: Cloth208, ci: number) => {
                  if (ci === i) {
                    return { id: c.id, name: c.name, dye: c.dye, dips: c.dips, fastness: c.fastness, state: c.state, watchers: c.watchers, starred: !c.starred }
                  }
                  return c
                })
              }
            })
          }
          if (this.tabIndex1 === 2) {
            DyeTab208({ dyes: this.dyes, cloths: this.cloths, dipDays: this.dipDays })
          }
          if (this.tabIndex1 === 3) {
            FabricTab208({ fabrics: this.fabrics, fastLogs: this.fastLogs })
          }
          if (this.tabIndex1 === 4) {
            ArtisanTab208({
              artisans: this.artisans,
              onJoin: () => {
                this.showJoinSheet = true
              }
            })
          }
          if (this.tabIndex1 === 5) {
            MineTab208({ cloths: this.cloths, orders: this.orders, dipDays: this.dipDays })
          }
        }
        .width('100%')
        .padding(14)
      }
      .layoutWeight(1)
      .align(Alignment.Top)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F0E8')
    .bindSheet($$this.showJoinSheet, this.joinSheet208(), {
      height: 620,
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindSheet($$this.showPatternSheet, this.patternSheet208(), {
      height: 600,
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindSheet($$this.showEditSheet, this.editSheet208(), {
      height: 560,
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindContentCover($$this.showDelDialog, this.delDialog208(), {
    })
    .bindContentCover($$this.showDetailDialog, this.detailDialog208(), {
    })
  }

  // ---------- 弹框1:预约连麦共创(抽屉) ----------
  @Builder
  joinSheet208() {
    Column({ space: 16 }) {
      Row({ space: 10 }) {
        Column().width(4).height(30).borderRadius(2).backgroundColor('#283593')
        Column({ space: 2 }) {
          Text('预约连麦共创').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A237E')
          Text('和靛缸阿婆同缸染布 · 出布可留样').fontSize(10).fontColor('#5C6BC0')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Column() {
          Text('✕').fontSize(14).fontColor('#5C6BC0')
        }
        .width(30)
        .height(30)
        .borderRadius(15)
        .backgroundColor('#E8EAF6')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showJoinSheet = false
        })
      }
      .width('100%')

      Scroll() {
        Column({ space: 16 }) {
          Column({ space: 8 }) {
            Text('目标花色').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#283593')
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(dyeTags208, (tag: string, i: number) => {
                Text(tag)
                  .fontSize(11)
                  .fontColor(this.joinPattern === i ? '#FFFFFF' : '#5C6BC0')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(16)
                  .backgroundColor(this.joinPattern === i ? dyeColor208(tag) : '#E8EAF6')
                  .margin(4)
                  .onClick(() => {
                    this.joinPattern = i
                  })
              }, (tag: string) => tag)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Column({ space: 8 }) {
            Text('坯布选择').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#283593')
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(fabricTags208, (tag: string, i: number) => {
                Text(tag)
                  .fontSize(11)
                  .fontColor(this.joinFabric === i ? '#FFFFFF' : '#5C6BC0')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(16)
                  .backgroundColor(this.joinFabric === i ? '#E53935' : '#E8EAF6')
                  .margin(4)
                  .onClick(() => {
                    this.joinFabric = i
                  })
              }, (tag: string) => tag)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#283593')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8EAF6')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.joinDips > 1) {
                this.joinDips -= 1
              }
            })
            Column({ space: 2 }) {
              Text('浸染 ' + this.joinDips + ' 次').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#283593')
              Text('次数越多颜色越深').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#283593')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8EAF6')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.joinDips < 6) {
                this.joinDips += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('直播共创').fontSize(13).fontColor('#283593')
              Text('出缸瞬间同步给你看').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Row() {
              Circle().width(18).height(18).fill('#FFFFFF')
            }
            .width(48)
            .height(26)
            .borderRadius(13)
            .justifyContent(FlexAlign.Center)
            .backgroundColor(this.joinLive ? '#283593' : '#BDBDBD')
            .onClick(() => {
              this.joinLive = !this.joinLive
            })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#E8EAF6')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('留样布纪念').fontSize(13).fontColor('#283593')
              Text('成品裁角样包邮到家').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Row() {
              Circle().width(18).height(18).fill('#FFFFFF')
            }
            .width(48)
            .height(26)
            .borderRadius(13)
            .justifyContent(FlexAlign.Center)
            .backgroundColor(this.joinKeep ? '#E53935' : '#BDBDBD')
            .onClick(() => {
              this.joinKeep = !this.joinKeep
            })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#FBE9E7')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('预计花费').fontSize(13).fontColor('#283593')
              Text('含坯布与染材费').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Text('¥ ' + (this.joinDips * 22 + (this.joinKeep ? 15 : 0))).fontSize(20).fontWeight(FontWeight.Bold).fontColor('#C62828')
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#E8EAF6')

          Button() {
            Text('确认预约连染').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          }
          .width('100%')
          .height(48)
          .borderRadius(24)
          .backgroundColor('#283593')
          .onClick(() => {
            this.showJoinSheet = false
          })

          Text('开染前 1 小时可免费改期 · 成品归共创者所有').fontSize(9).fontColor('#BDBDBD')
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 4, bottom: 24 })
      }
      .constraintSize({ maxHeight: 460 })
    }
    .width('100%')
    .height('100%')
    .padding({ top: 14 })
    .backgroundColor('#FFFFFF')
  }

  // ---------- 弹框2:上传花色(抽屉,concat 前插) ----------
  @Builder
  patternSheet208() {
    Column({ space: 16 }) {
      Row({ space: 10 }) {
        Column().width(4).height(30).borderRadius(2).backgroundColor('#E53935')
        Column({ space: 2 }) {
          Text('上传新花色').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A237E')
          Text('晒出你的扎染作品 · 收进花色册').fontSize(10).fontColor('#5C6BC0')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Column() {
          Text('✕').fontSize(14).fontColor('#5C6BC0')
        }
        .width(30)
        .height(30)
        .borderRadius(15)
        .backgroundColor('#E8EAF6')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showPatternSheet = false
        })
      }
      .width('100%')

      Scroll() {
        Column({ space: 16 }) {
          Column({ space: 8 }) {
            Text('花色名称').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#283593')
            TextInput({ placeholder: '例如:洱海月纹方巾', text: this.patternName })
              .fontSize(13)
              .padding(12)
              .borderRadius(12)
              .backgroundColor('#E8EAF6')
              .onChange((v: string) => {
                this.patternName = v
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Column({ space: 8 }) {
            Text('主染材').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#283593')
            Flex({ wrap: FlexWrap.Wrap }) {
              ForEach(dyeTags208, (tag: string, i: number) => {
                Text(tag)
                  .fontSize(11)
                  .fontColor(this.patternDye === i ? '#FFFFFF' : '#5C6BC0')
                  .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                  .borderRadius(16)
                  .backgroundColor(this.patternDye === i ? dyeColor208(tag) : '#E8EAF6')
                  .margin(4)
                  .onClick(() => {
                    this.patternDye = i
                  })
              }, (tag: string) => tag)
            }
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#E53935')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#FBE9E7')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.patternDips > 1) {
                this.patternDips -= 1
              }
            })
            Column({ space: 2 }) {
              Text('浸染 ' + this.patternDips + ' 次').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#E53935')
              Text('记下你的复染次数').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#E53935')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#FBE9E7')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.patternDips < 6) {
                this.patternDips += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#283593')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8EAF6')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.patternFast > 1) {
                this.patternFast -= 1
              }
            })
            Column({ space: 2 }) {
              Text('自评色牢度 ' + this.patternFast + ' 级').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#283593')
              Text('水洗不掉色 1-5 级').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#283593')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8EAF6')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.patternFast < 5) {
                this.patternFast += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('公开到花色册').fontSize(13).fontColor('#283593')
              Text('其他染友可见你的作品').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Row() {
              Circle().width(18).height(18).fill('#FFFFFF')
            }
            .width(48)
            .height(26)
            .borderRadius(13)
            .justifyContent(FlexAlign.Center)
            .backgroundColor(this.patternPublic ? '#283593' : '#BDBDBD')
            .onClick(() => {
              this.patternPublic = !this.patternPublic
            })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#E8EAF6')

          Button() {
            Text('上传花色').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          }
          .width('100%')
          .height(48)
          .borderRadius(24)
          .backgroundColor('#E53935')
          .onClick(() => {
            this.patternName = ''
          })
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 4, bottom: 24 })
      }
      .constraintSize({ maxHeight: 440 })
    }
    .width('100%')
    .height('100%')
    .padding({ top: 14 })
    .backgroundColor('#FFFFFF')
  }

  // ---------- 弹框3:编辑花色(抽屉,map 回写) ----------
  @Builder
  editSheet208() {
    Column({ space: 16 }) {
      Row({ space: 10 }) {
        Column().width(4).height(30).borderRadius(2).backgroundColor('#00897B')
        Column({ space: 2 }) {
          Text('编辑花色档案').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A237E')
          Text('修正复染次数与色牢度').fontSize(10).fontColor('#5C6BC0')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Column() {
          Text('✕').fontSize(14).fontColor('#5C6BC0')
        }
        .width(30)
        .height(30)
        .borderRadius(15)
        .backgroundColor('#E8EAF6')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.showEditSheet = false
        })
      }
      .width('100%')

      Scroll() {
        Column({ space: 16 }) {
          Column({ space: 8 }) {
            Text('花色名称').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#283593')
            TextInput({ placeholder: '输入新名称', text: this.editName })
              .fontSize(13)
              .padding(12)
              .borderRadius(12)
              .backgroundColor('#E8EAF6')
              .onChange((v: string) => {
                this.editName = v
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#283593')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8EAF6')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.editDips > 1) {
                this.editDips -= 1
              }
            })
            Column({ space: 2 }) {
              Text('浸染 ' + this.editDips + ' 次').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#283593')
              Text('补记复染次数').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#283593')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E8EAF6')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.editDips < 6) {
                this.editDips += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 14 }) {
            Column() {
              Text('-').fontSize(16).fontColor('#00897B')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E0F2F1')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.editFast > 1) {
                this.editFast -= 1
              }
            })
            Column({ space: 2 }) {
              Text('色牢度 ' + this.editFast + ' 级').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#00897B')
              Text('复测后可修正').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Column() {
              Text('+').fontSize(16).fontColor('#00897B')
            }
            .width(34)
            .height(34)
            .borderRadius(17)
            .backgroundColor('#E0F2F1')
            .justifyContent(FlexAlign.Center)
            .onClick(() => {
              if (this.editFast < 5) {
                this.editFast += 1
              }
            })
            Text('').layoutWeight(1)
          }
          .width('100%')

          Row({ space: 10 }) {
            Column({ space: 2 }) {
              Text('置顶到花色册').fontSize(13).fontColor('#283593')
              Text('最满意的作品放最上面').fontSize(9).fontColor('#90A4AE')
            }
            .alignItems(HorizontalAlign.Start)
            Text('').layoutWeight(1)
            Row() {
              Circle().width(18).height(18).fill('#FFFFFF')
            }
            .width(48)
            .height(26)
            .borderRadius(13)
            .justifyContent(FlexAlign.Center)
            .backgroundColor(this.editTop ? '#E53935' : '#BDBDBD')
            .onClick(() => {
              this.editTop = !this.editTop
            })
          }
          .width('100%')
          .padding(12)
          .borderRadius(12)
          .backgroundColor('#FBE9E7')

          Button() {
            Text('保存修改').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          }
          .width('100%')
          .height(48)
          .borderRadius(24)
          .backgroundColor('#00897B')
          .onClick(() => {
            this.cloths = this.cloths.map((c: Cloth208, ci: number) => {
              if (ci === this.editIndex) {
                return {
                  id: c.id,
                  name: this.editName === '' ? c.name : this.editName,
                  dye: c.dye,
                  dips: this.editDips,
                  fastness: this.editFast * 18,
                  state: c.state,
                  watchers: c.watchers,
                  starred: this.editTop
                }
              }
              return c
            })
            this.showEditSheet = false
          })
        }
        .width('100%')
        .padding({ left: 18, right: 18, top: 4, bottom: 24 })
      }
      .constraintSize({ maxHeight: 420 })
    }
    .width('100%')
    .height('100%')
    .padding({ top: 14 })
    .backgroundColor('#FFFFFF')
  }

  // ---------- 弹框4:删除花色(居中,filter) ----------
  @Builder
  delDialog208() {
    Column({ space: 14 }) {
      Column() {
        Text('🧵').fontSize(34)
      }
      .width(64)
      .height(64)
      .borderRadius(32)
      .backgroundColor('#E8EAF6')
      .justifyContent(FlexAlign.Center)

      Text('下架这个花色?').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#1A237E')
      Text('「' + (this.delIndex >= 0 && this.delIndex < this.cloths.length ? this.cloths[this.delIndex].name : '') + '」将从花色册下架,跟染入口同步关闭').fontSize(11).fontColor('#5C6BC0').textAlign(TextAlign.Center)

      Row({ space: 10 }) {
        Column({ space: 2 }) {
          Text('保留染样册').fontSize(12).fontColor('#283593')
          Text('仅下架花色卡').fontSize(9).fontColor('#90A4AE')
        }
        .alignItems(HorizontalAlign.Start)
        Text('').layoutWeight(1)
        Row() {
          Circle().width(16).height(16).fill('#FFFFFF')
        }
        .width(44)
        .height(24)
        .borderRadius(12)
        .justifyContent(FlexAlign.Center)
        .backgroundColor(this.delKeepAlbum ? '#283593' : '#BDBDBD')
        .onClick(() => {
          this.delKeepAlbum = !this.delKeepAlbum
        })
      }
      .width('100%')
      .padding(10)
      .borderRadius(10)
      .backgroundColor('#E8EAF6')

      Row({ space: 10 }) {
        Button() {
          Text('再想想').fontSize(14).fontColor('#283593').fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .height(44)
        .borderRadius(22)
        .backgroundColor('#E8EAF6')
        .onClick(() => {
          this.showDelDialog = false
        })

        Button() {
          Text('确认下架').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1)
        .height(44)
        .borderRadius(22)
        .backgroundColor('#C62828')
        .onClick(() => {
          this.cloths = this.cloths.filter((c: Cloth208, ci: number) => ci !== this.delIndex)
          this.showDelDialog = false
        })
      }
      .width('100%')
    }
    .width('82%')
    .padding(20)
    .borderRadius(20)
    .backgroundColor('#FFFFFF')
    .alignItems(HorizontalAlign.Center)
  }

  // ---------- 弹框5:花色详情(居中,图表+跳转联动) ----------
  @Builder
  detailDialog208() {
    Column({ space: 0 }) {
      Scroll() {
        Column({ space: 0 }) {
          Column({ space: 8 }) {
            Row({ space: 10 }) {
              Column() {
                Text('🎨').fontSize(30)
              }
              .width(56)
              .height(56)
              .borderRadius(28)
              .backgroundColor('#FFFFFF')
              .justifyContent(FlexAlign.Center)
              Column({ space: 3 }) {
                Text(this.detailIndex >= 0 && this.detailIndex < this.cloths.length ? this.cloths[this.detailIndex].name : '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
                Text((this.detailIndex >= 0 && this.detailIndex < this.cloths.length ? this.cloths[this.detailIndex].dye : '') + ' · 复染 ' + (this.detailIndex >= 0 && this.detailIndex < this.cloths.length ? this.cloths[this.detailIndex].dips : 0) + ' 次').fontSize(11).fontColor('#E8EAF6')
              }
              .alignItems(HorizontalAlign.Start)
              Text('').layoutWeight(1)
              Column() {
                Text('✕').fontSize(13).fontColor('#FFFFFF')
              }
              .width(28)
              .height(28)
              .borderRadius(14)
              .backgroundColor('rgba(255,255,255,0.2)')
              .justifyContent(FlexAlign.Center)
              .onClick(() => {
                this.showDetailDialog = false
              })
            }
            .width('100%')
          }
          .width('100%')
          .padding(18)
          .linearGradient({ angle: 135, colors: [['#3949AB', 0], ['#C62828', 1]] })

          Column({ space: 14 }) {
            Row({ space: 8 }) {
              Column({ space: 2 }) {
                Text((this.detailIndex >= 0 && this.detailIndex < this.cloths.length ? this.cloths[this.detailIndex].fastness + '%' : '')).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#283593')
                Text('色牢度评分').fontSize(9).fontColor('#5C6BC0')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .borderRadius(10)
              .backgroundColor('#E8EAF6')
              Column({ space: 2 }) {
                Text((this.detailIndex >= 0 && this.detailIndex < this.cloths.length ? this.cloths[this.detailIndex].watchers : 0) + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#C62828')
                Text('累计跟染人次').fontSize(9).fontColor('#5C6BC0')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .borderRadius(10)
              .backgroundColor('#FBE9E7')
              Column({ space: 2 }) {
                Text(this.detailIndex >= 0 && this.detailIndex < this.cloths.length ? this.cloths[this.detailIndex].state : '').fontSize(14).fontWeight(FontWeight.Bold).fontColor(clothStateColor208(this.detailIndex >= 0 && this.detailIndex < this.cloths.length ? this.cloths[this.detailIndex].state : ''))
                Text('当前状态').fontSize(9).fontColor('#5C6BC0')
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .borderRadius(10)
              .backgroundColor('#E8EAF6')
            }
            .width('100%')

            Column({ space: 8 }) {
              Text('近 7 日跟染人数').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
              Row({ space: 6 }) {
                ForEach(this.dipDays, (d: DipDay208) => {
                  Column({ space: 4 }) {
                    Column()
                      .width(16)
                      .height(d.dips * 5)
                      .borderRadius({ topLeft: 4, topRight: 4 })
                      .linearGradient({ angle: 180, colors: [['#5C6BC0', 0], ['#283593', 1]] })
                    Text(d.day).fontSize(8).fontColor('#5C6BC0')
                  }
                  .alignItems(HorizontalAlign.Center)
                  .layoutWeight(1)
                }, (d: DipDay208) => ('d' + d.day))
              }
              .width('100%')
              .alignItems(VerticalAlign.Bottom)
              .height(90)
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#F5F5F5')

            Column({ space: 8 }) {
              Text('工艺档案').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#1A237E')
              Row({ space: 8 }) {
                Text('捆扎').fontSize(11).fontColor('#5C6BC0')
                Text('弹珠 + 梯子捆').fontSize(11).fontColor('#283593').fontWeight(FontWeight.Bold)
                Text('').layoutWeight(1)
                Text('留白 32%').fontSize(10).fontColor('#C62828')
              }
              .width('100%')
              Row({ space: 8 }) {
                Text('养缸').fontSize(11).fontColor('#5C6BC0')
                Text('八年老靛缸 · pH 11').fontSize(11).fontColor('#283593').fontWeight(FontWeight.Bold)
                Text('').layoutWeight(1)
                Text('靛花饱满').fontSize(10).fontColor('#00897B')
              }
              .width('100%')
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor('#F5F5F5')
            .alignItems(HorizontalAlign.Start)

            Button() {
              Text('预约连染同款花色').fontSize(14).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            }
            .width('100%')
            .height(44)
            .borderRadius(22)
            .backgroundColor('#C62828')
            .onClick(() => {
              this.showDetailDialog = false
              this.showJoinSheet = true
            })
          }
          .width('100%')
          .padding(16)
        }
        .width('100%')
      }
      .constraintSize({ maxHeight: 480 })
    }
    .width('86%')
    .borderRadius(20)
    .backgroundColor('#FFFFFF')
    .clip(true)
  }
}

// ================= Tab1:染坊(直播) =================

@Component
struct LiveTab208 {
  @State localMic: boolean = true
  @State localCam: boolean = true
  @State localStir: boolean = true
  @Prop cloths: Cloth208[] = []
  @Prop artisans: Artisan208[] = []
  @Prop dipSteps: DipStep208[] = []
  @Prop barrages: Barrage208[] = []
  onStep: (i: number) => void = () => {
  }
  onJoin: () => void = () => {
  }

  build() {
    Column({ space: 12 }) {
      // 四机位
      Grid() {
        GridItem() {
          Column({ space: 4 }) {
            Row({ space: 6 }) {
              Column() {
                Text('🪣').fontSize(18)
              }
              .width(34)
              .height(34)
              .borderRadius(17)
              .backgroundColor('rgba(255,255,255,0.25)')
              .justifyContent(FlexAlign.Center)
              Column({ space: 2 }) {
                Text('主镜 · 靛缸浸染').fontSize(11).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
                Text('靛缸阿婆 · 老缸').fontSize(9).fontColor('#C5CAE9')
              }
              .alignItems(HorizontalAlign.Start)
            }
            .width('100%')
            .padding(10)
            Text('').layoutWeight(1)
            Row({ space: 6 }) {
              Text('● LIVE').fontSize(9).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
              Text(dippingClothCount208(this.cloths) + ' 匝布在缸').fontSize(9).fontColor('#C5CAE9')
            }
            .width('100%')
            .padding(8)
          }
          .width('100%')
          .height('100%')
          .borderRadius(12)
          .padding(6)
          .linearGradient({ angle: 150, colors: [['#283593', 0], ['#1A237E', 1]] })
          .onClick(() => {
        
  }
}


总结

在这里插入图片描述

云染坊这套源码的完整度,体现在它不是Demo片段的堆砌,而是一个从数据建模到交互闭环的自洽系统。十个interface定义了全部领域实体,八个纯函数封装了配色映射与统计聚合,一个主入口组件统管六个Tab与五个弹层的状态中枢,六个子Tab各司其职地承载直播、花色册、染材库、坯布架、匠人团、我的六类业务场景。每一处状态变更都走"回调上报→主入口不可变更新→@State触发刷新→@Prop快照下传→子Tab重渲染"的完整链路,没有捷径也没有旁路,保证了数据流的单向可追踪。

从工程实践角度,这套代码示范了HarmonyOS ArkTS在中等复杂度应用中的几项关键范式:@Builder拆分复杂UI降低build嵌套、bindSheet/bindContentCover分层弹层管理、map/filter不可变数组更新配合状态观察、ForEach键生成器精准Diff、linearGradientlayoutWeight的视觉建模能力。特别值得注意的是"数据上浮、事件下沉"的架构——主入口持有全部业务数据与表单临时态,子组件只接收快照与回调,这种分层让状态边界清晰,在应用规模增长时仍能保持可维护性。晾布夹Tab导航的错落布条、四机位的色相演变、堆叠条的占比可视化、匠人头像的染材色循环,这些细节则展示了声明式UI在场景化设计中的表达力——每一个像素都在为非遗工艺的数字化体验服务。

扎染的核心是"留白",染坊代码的核心是"分层"。捆扎防染留出布的白,状态分层留出维护的白;浸染复染叠出色的深,不可变更新叠出引用的深。千年工艺与现代框架,在留白与叠加的哲学上殊途同归。

云染坊提供了一个可直接参考的骨架:把Tab导航做成场景化隐喻、把直播工位做成色相演变图、把表单录入做成抽屉式交互、把列表操作做成回调式CRUD、把数据可视化做成纯声明式柱图。这套范式不依赖任何第三方图表库或复杂状态管理框架,纯用ArkTS原生能力即实现了完整的业务闭环,是声明式UI"够用且好用"的最佳注脚。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐