开发一款"夜市摊主连麦代逛代买直播间"应用,听起来像是把直播电商和跑腿外卖揉在一起。但真正动手写的时候才发现,难点不在于功能多,而在于信息密度——一个页面要同时呈现四个机位画面、摊位速报、弹幕、工具条,还要让用户能点单、能管理好物清单、能看客流战报。如何在ArkTS声明式UI里把这些塞进去而不显得拥挤,是整个开发过程中最反复纠结的事。

这篇日志记录了从数据建模到组件拆分、从弹框交互到图表绘制的完整开发过程。不写虚的,每一步都是实际敲代码时遇到的决策点:为什么用bindSheet而不是自定义弹框、为什么灯串Tab的挂绳要用2vp宽度、为什么柱图高度要用数据值乘以固定系数而不是百分比。这些看似琐碎的决策,最终决定了应用跑起来后的手感。

技术栈是HarmonyOS 6.1.1的ArkTS API 24,纯声明式UI,没有引入任何第三方图表库。所有柱图、横条、堆叠条都是用原生Column和Row的layoutWeight拼出来的。这个决定一开始有点犹豫,但写完之后发现,对于这种量级的可视化需求,原生布局完全够用,还省去了库的体积和兼容性麻烦。

Day 1:数据建模——给夜市画一张ER图

在这里插入图片描述

今天开始动工。第一步永远是数据模型,因为后面所有页面的结构都由数据结构决定。我把夜市场景的实体梳理了一遍,定义了9个interface。

// 摊位——这个应用最核心的实体
interface Stall199 {
  name: string
  emoji: string
  cat: string          // 烧烤/糖水/面点/饮品/甜品/小吃/夜宵
  sales: number        // 月销量
  rating: number       // 评分
  queue: number        // 当前排队人数
  state: string        // 营业中/排队中/售罄补货
  tags: string[]       // 招牌亮点
  owner: string        // 摊主名
}

// 好物清单——用户的"想吃"列表
interface Wish199 {
  id: number
  item: string
  stall: string
  emoji: string
  budget: number
  status: string       // 待代买/跑腿中/已送达/已取消
  time: string
  top: boolean         // 置顶
}

// 跑腿订单——从点单到送达的全链路追踪
interface Run199 {
  no: string           // 订单号
  items: string
  runner: string      // 骑手
  state: string       // 取餐中/配送中/已送达/已取消
  fee: number
  mins: number        // 已用分钟
}

// 弹幕——直播间的灵魂
interface Barrage199 {
  user: string
  text: string
  color: string
}

// 每晚客流柱图数据
interface NightFlow199 {
  day: string
  visitors: number
}

// 美食分类占比
interface CatMix199 {
  label: string
  count: number
  color: string
}

// 摊位人气横条
interface StallPop199 {
  name: string
  sales: number
  emoji: string
}

// 月销走势——摊位详情弹框里用
interface MonthSale199 {
  week: string
  sales: number
}

// 消费记录——我的Tab里展示
interface BuyLog199 {
  item: string
  emoji: string
  date: string
  result: string
  price: number
}

写到Stall199的时候纠结了一阵state字段的设计。最初想用一个枚举类型,但ArkTS的枚举在ForEach的key生成里不如字符串方便,最后还是决定用string。三个状态值"营业中/排队中/售罄补货"看起来够用了,但后来在筛选逻辑里发现,“售罄补货"这个状态需要在列表里用不同的颜色和文案表达,如果当初拆成"售罄”+"补货中"两个状态,筛选会更灵活。不过现在改成本太高,先这样吧。

一个教训:状态字段的粒度在设计阶段就要想清楚。多一个状态值意味着多一种筛选维度和视觉表达,后期加状态远比初期多定义一个字段麻烦。

Day 2:静态数据和工具函数——先把假数据填满

在这里插入图片描述

今天的工作是把所有页面的假数据填好,这样后面写UI的时候能直接看到效果。同时把工具函数也一起写了,这些函数后面会在多个组件里复用。

const stallData199: Stall199[] = [
  { name: '炭火烤生蚝', emoji: '🦪', cat: '烧烤', sales: 486, rating: 4.9, queue: 12, state: '营业中', tags: ['蒜蓉天花板', '现开现烤'], owner: '蚝哥' },
  { name: '奶奶糖水铺', emoji: '🍮', cat: '糖水', sales: 352, rating: 4.8, queue: 8, state: '营业中', tags: ['手作龟苓膏', '低糖可选'], owner: '陈奶奶' },
  { name: '爆汁豆腐包', emoji: '🥟', cat: '面点', sales: 421, rating: 4.7, queue: 15, state: '排队中', tags: ['一口爆汁', '限购两笼'], owner: '包嫂' },
  { name: '手打柠檬茶', emoji: '🍋', cat: '饮品', sales: 512, rating: 4.9, queue: 18, state: '排队中', tags: ['真的手打', '鸭子都快不够用'], owner: '柠妹' },
  { name: '小龙虾大排档', emoji: '🦞', cat: '夜宵', sales: 598, rating: 4.9, queue: 22, state: '排队中', tags: ['十三香绝了', '送手套'], owner: '虾姐' },
  // ...更多摊位
]

const barrageData199: Barrage199[] = [
  { user: '深夜干饭人', text: '生蚝再不来我要晕过去了', color: '#FF7043' },
  { user: '柠檬酸了', text: '排队 18 杯起步,手打真的累', color: '#FFCA28' },
  { user: '折耳根勇士', text: '烙锅蘸水加满,谢谢', color: '#FFCA28' },
  { user: '小龙虾脑袋', text: '十三香yyds,已经第三单了', color: '#FF7043' },
  // ...
]

填数据的时候特意让内容有"烟火气"——摊主叫"蚝哥"“包嫂”“虾姐”,弹幕写"生蚝再不来我要晕过去了"“鸭子都快不够用了”。这不是随便编的,是参考了真实夜市直播间的弹幕风格。假数据写得越真实,后面调UI的时候越能发现布局问题。

工具函数部分写了状态颜色映射和统计计算:

// 清单状态颜色
function wishStateColor199(s: string): string {
  if (s === '待代买') {
    return '#FF7043'    // 烟火橙,表示待处理
  }
  if (s === '跑腿中') {
    return '#311B92'    // 夜幕紫,表示进行中
  }
  if (s === '已送达') {
    return '#2E7D32'    // 绿色,表示完成
  }
  return '#9FA8DA'      // 淡紫灰,已取消
}

// 营业中摊位数
function openStallCount199(): number {
  let n: number = 0
  for (let i = 0; i < stallData199.length; i++) {
    if (stallData199[i].state === '营业中') {
      n++
    }
  }
  return n
}

// 清单总预算(排除已取消的)
function wishBudget199(): number {
  let n: number = 0
  for (let i = 0; i < wishData199.length; i++) {
    if (wishData199[i].status !== '已取消') {
      n += wishData199[i].budget
    }
  }
  return n
}

// 今晚总排队数
function queueTotal199(): number {
  let n: number = 0
  for (let i = 0; i < stallData199.length; i++) {
    n += stallData199[i].queue
  }
  return n
}

wishBudget199这个函数有个细节:排除"已取消"的清单项。因为取消的订单不会产生费用,如果算进总预算会误导用户。这种"排除异常态"的统计逻辑在写的时候差点漏掉,后来在调试UI时看到"预算合计"数字偏大才发现问题。

Day 3:五个弹框——今天最重要的一天

在这里插入图片描述

今天把五个弹框全写了。这五个弹框是整个应用交互的核心:点单代买(抽屉)、新增好物清单(抽屉)、编辑清单(抽屉)、删除清单(居中)、摊位详情(居中)。

3.1 点单代买抽屉

这个抽屉是最复杂的,因为它承载了从选摊位到选商品到选数量到算价格的完整下单流程。

@Builder
orderSheet199() {
  Column() {
    Row() {
      Column() {
        Text('🏮 点单代买').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#311B92')
        Text('跑腿小哥全程直播取餐,进度实时可见').fontSize(11)
          .fontColor('#9FA8DA').margin({ top: 4 })
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)
    }
    .width('100%')
    .padding({ left: 20, right: 20, top: 18, bottom: 14 })

    Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

    Scroll() {
      Column() {
        // 摊位选择
        Column() {
          Text('选择摊位').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
          Row() {
            ForEach(stallTags199, (s: string, i: number) => {
              Text(s)
                .fontSize(12)
                .fontColor(this.orderStall === i ? '#FFFFFF' : '#5C4DA8')
                .padding({ left: 12, right: 12, top: 7, bottom: 7 })
                .borderRadius(16)
                .backgroundColor(this.orderStall === i ? '#311B92' : '#EDE9F9')
                .onClick(() => {
                  this.orderStall = i
                })
            }, (s: string) => s)
          }
          .width('100%')
          .margin({ top: 10 })
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        .padding({ left: 20, right: 20, top: 16 })

        // 商品选择——黄色高亮选中态
        Column() {
          Text('选择商品').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
          Row() {
            ForEach(itemTags199, (t: string, i: number) => {
              Text(t)
                .fontSize(12)
                .fontColor(this.orderItem === i ? '#311B92' : '#E08A00')
                .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                .borderRadius(16)
                .backgroundColor(this.orderItem === i ? '#FFCA28' : '#FFF8E1')
                .onClick(() => {
                  this.orderItem = i
                })
            }, (t: string) => t)
          }
        }

        // 开关组——代排队和到货拍照
        Column() {
          Row() {
            Column() {
              Text('接受代排队').fontSize(13).fontColor('#311B92')
              Text('热门摊位由小哥替你排队').fontSize(10)
                .fontColor('#9FA8DA').margin({ top: 2 })
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.orderQueue })
              .selectedColor('#FF7043')
              .onChange((v: boolean) => {
                this.orderQueue = v
              })
          }

          Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

          Row() {
            Column() {
              Text('到货拍照确认').fontSize(13).fontColor('#311B92')
              Text('送达前拍照核验餐品完整').fontSize(10)
                .fontColor('#9FA8DA').margin({ top: 2 })
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.orderTip > 0 })
              .selectedColor('#FFCA28')
              .onChange((v: boolean) => {
                if (v) {
                  this.orderTip = 3
                } else {
                  this.orderTip = 0
                }
              })
          }
        }

        // 预估合计——实时计算
        Text('预估合计 ¥ ' + (this.orderQty * 24 + this.orderTip))
          .fontSize(13)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF7043')
          .margin({ top: 14 })

        Text('下单代买')
          .fontSize(15)
          .fontWeight(FontWeight.Bold)
          .fontColor('#311B92')
          .width('90%')
          .height(46)
          .borderRadius(23)
          .textAlign(TextAlign.Center)
          .linearGradient({
            angle: 90,
            colors: [['#FFCA28', 0], ['#FF7043', 1]]
          })
          .margin({ top: 12, bottom: 24 })
          .onClick(() => {
            this.showOrderSheet = false
          })
      }
      .width('100%')
    }
    .constraintSize({ maxHeight: '85%' })
    .layoutWeight(1)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#FFFFFF')
  .borderRadius({ topLeft: 22, topRight: 22 })
}

写这个抽屉的时候踩了一个坑:this.orderTip > 0作为Toggle的isOn初始值。这里Toggle的isOn不是直接绑定一个boolean状态,而是通过"小费是否大于0"来推导。当用户关闭Toggle时,onChange里把orderTip设为0;打开时设为3。这种"状态推导"的模式在简单场景下没问题,但如果小费步进器也修改orderTip,Toggle和步进器之间就会有状态竞争。目前因为步进器还没实现,暂时没有冲突,但这是后面要小心的地方。

开发中最容易出bug的不是复杂逻辑,而是多个UI控件共享同一个状态时的竞争条件。ArkTS的@State是单向数据流,但当一个状态被多个控件读取和写入时,一定要理清谁是"真值源"。

3.2 编辑清单抽屉——map回写

在这里插入图片描述

编辑抽屉的核心是保存时的map操作。这和后面删除的filter是两种最常用的不可变数据更新模式。

Text('保存修改')
  .onClick(() => {
    this.wishes = this.wishes.map((w: Wish199, i: number) => {
      if (i === this.editIndex) {
        return {
          id: w.id,
          item: w.item,
          stall: w.stall,
          emoji: w.emoji,
          budget: this.editBudget,
          status: w.status,
          time: w.time,
          top: this.editTop      // 只有预算和置顶可编辑
        }
      }
      return w
    })
    this.showEditSheet = false
  })

这里有个设计决策:编辑清单时只允许修改预算和置顶状态,不允许改商品名和摊位。因为如果改了商品名,对应的摊位信息就可能不匹配,需要做关联校验,太复杂。产品层面也合理——用户想改商品可以直接删了重新加,编辑主要用于调整预算和置顶优先级。

3.3 删除确认框——filter删除

在这里插入图片描述

Text('确认移出')
  .onClick(() => {
    this.wishes = this.wishes.filter((w: Wish199, i: number) => {
      return i !== this.deleteIndex
    })
    this.showDeleteDialog = false
  })

filter删除比map简单,但有个细节:deleteIndex用的是数组索引而不是id。这在数据不变化的场景下没问题,但如果在打开删除确认框期间数据发生了变化(比如另一个操作删除了前面的项),索引就会错位。目前应用是单页面的,不存在并发操作,所以用索引没问题。但如果将来加了WebSocket推送导致数据变化,就需要改成用id匹配。

3.4 摊位详情——居中弹框

在这里插入图片描述

详情弹框用了bindContentCover,和抽屉的bindSheet形成对比。居中弹框的视觉重点在头部的渐变和内部的柱图。

@Builder
detailDialog199() {
  Column() {
    // 渐变头——夜幕紫到烟火橙
    Column() {
      Text(stallData199[this.detailIndex].emoji)
        .fontSize(34)
        .width(64)
        .height(64)
        .borderRadius(32)
        .backgroundColor('#FFFFFF')
        .textAlign(TextAlign.Center)
      Text(stallData199[this.detailIndex].name)
        .fontSize(19)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')
        .margin({ top: 8 })
      Text('摊主 ' + stallData199[this.detailIndex].owner + ' · ' + stallData199[this.detailIndex].cat)
        .fontSize(12)
        .fontColor('#FFF3D6')
        .margin({ top: 4 })
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
    .padding({ top: 26, bottom: 18 })
    .borderRadius({ topLeft: 22, topRight: 22 })
    .linearGradient({
      angle: 135,
      colors: [['#311B92', 0], ['#FF7043', 1]]
    })

    Scroll() {
      Column() {
        // 统计行——月销/评分/排队
        Row() {
          Column({ space: 2 }) {
            Text(stallData199[this.detailIndex].sales + '')
              .fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FF7043')
            Text(stallStatLabel199(0)).fontSize(10).fontColor('#9FA8DA')
          }.layoutWeight(1)
          Column({ space: 2 }) {
            Text('⭐ ' + stallData199[this.detailIndex].rating)
              .fontSize(17).fontWeight(FontWeight.Bold).fontColor('#E08A00')
            Text(stallStatLabel199(1)).fontSize(10).fontColor('#9FA8DA')
          }.layoutWeight(1)
          Column({ space: 2 }) {
            Text(stallData199[this.detailIndex].queue + ' 人')
              .fontSize(17).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Text(stallStatLabel199(2)).fontSize(10).fontColor('#9FA8DA')
          }.layoutWeight(1)
        }

        // 月销走势柱图
        Column() {
          Text('近期销量走势(单)').fontSize(13)
            .fontWeight(FontWeight.Bold).fontColor('#311B92')
          Row() {
            ForEach(monthSale199, (m: MonthSale199) => {
              Column() {
                Text(m.sales + '').fontSize(9).fontColor('#FF7043').margin({ bottom: 3 })
                Column()
                  .width(30)
                  .height(Math.floor(m.sales / 4))
                  .borderRadius(5)
                  .linearGradient({
                    angle: 180,
                    colors: [['#FFCA28', 0], ['#FF7043', 1]]
                  })
                Text(m.week).fontSize(9).fontColor('#9FA8DA').margin({ top: 4 })
              }
            }, (m: MonthSale199) => m.week)
          }
          .height(140)
          .alignItems(VerticalAlign.Bottom)
          .justifyContent(FlexAlign.SpaceBetween)
        }
      }
    }
    .constraintSize({ maxHeight: '60%' })
  }
  .width('86%')
  .borderRadius(22)
  .backgroundColor('#FFFFFF')
}

柱图的高度计算用了Math.floor(m.sales / 4)——把销量除以4作为高度值。这个系数4是反复调试出来的:月销最大值486,486/4≈121,加上Text和间距刚好填满140的高度。如果用百分比(height: m.sales/max*100 + ‘%’),在ArkTS的Row布局里百分比高度的表现不如固定vp值稳定,这是实际踩坑后的结论。

Day 4:灯串Tab导航——今天最有成就感的部分

在这里插入图片描述

今天写了顶部Tab导航。设计要求是"灯串挂牌"风格——一条横向缆线贯穿,6块灯牌挂牌各带短挂绳,选中灯牌亮黄发光+阴影浮起。

// ---------- 顶部「灯串挂牌」tab ----------
Column() {
  // 横向缆线
  Row() {
    Text('').layoutWeight(1).height(2).backgroundColor('#D9CFEE')
  }
  .width('100%')
  .padding({ left: 14, right: 14 })

  Row() {
    ForEach(tabItems199, (t: TabItem199, i: number) => {
      Column() {
        // 挂绳
        Text('')
          .width(2)
          .height(6)
          .backgroundColor(this.currentTab === i ? '#E08A00' : '#D9CFEE')
        // 灯牌
        Column({ space: 2 }) {
          Text(t.icon).fontSize(15)
          Text(t.name)
            .fontSize(10)
            .fontWeight(this.currentTab === i ? FontWeight.Bold : FontWeight.Normal)
            .fontColor(this.currentTab === i ? '#311B92' : '#8E85B0')
        }
        .padding({ top: 5, bottom: 6, left: 4, right: 4 })
        .borderRadius(10)
        .backgroundColor(this.currentTab === i ? '#FFCA28' : '#FFFFFF')
        .shadow({
          radius: this.currentTab === i ? 8 : 2,
          color: this.currentTab === i ? '#66FF9800' : '#1A000000',
          offsetX: 0,
          offsetY: this.currentTab === i ? 3 : 1
        })
        .scale(this.currentTab === i ? { x: 1.08, y: 1.08 } : { x: 1, y: 1 })
        .animation({ duration: 180 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Center)
      .margin({ top: 2 })
      .onClick(() => {
        this.currentTab = i
      })
    }, (t: TabItem199) => t.name)
  }
  .width('100%')
  .padding({ left: 8, right: 8, top: 2, bottom: 8 })
}
.width('100%')
.backgroundColor('#F6F2FB')

几个关键决策点记录一下:

挂绳宽度为什么是2vp? 试过1vp太细看不清,3vp太粗像柱子,2vp刚好有"绳子"的感觉。挂绳颜色在选中时变成E08A00(深黄),和灯牌的亮黄形成色阶。

shadow的写法:ArkTS的shadow属性接受一个对象,radius是模糊半径,color用#AA + 色值的格式(前两位是透明度)。选中态用66FF9800(半透明橙黄),未选中用1A000000(极淡黑)。offsetY让阴影向下偏移,制造"浮起"的视觉。

scale 1.08:放大8%是反复试出来的。5%太小看不出来,10%太大显得突兀,8%刚好有"灯牌被点亮鼓起来"的感觉。配合180ms的animation,切换时的手感很丝滑。

写UI就是这样,很多参数没法靠理论推导,只能反复调。但调的时候要有审美框架:你的目标是"夜市灯串被点亮"的感觉,那放大比例、阴影方向、颜色变化都应该服务于这个目标。无目的的参数调整是最大的时间浪费。

Day 5:主页面build——把所有零件拼起来

今天写build()方法,把头部、Tab导航、内容区和五个弹框绑定拼到一起。这天的代码不长但很关键,因为它是整个应用的骨架。

build() {
  Column() {
    // ---------- 头部:夜幕紫电商大促风 ----------
    Column() {
      Row() {
        Column() {
          Text('拾贝 · 云夜市')
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
          Text('今晚 10 个摊位出摊,云逛街不吃灰')
            .fontSize(11).fontColor('#FFE08C').margin({ top: 3 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 14 })

      // 电商大促横幅条
      Row({ space: 8 }) {
        Column()
          .width(4)
          .height(30)
          .borderRadius(2)
          .backgroundColor('#FFCA28')
        Column() {
          Text('夜宵狂欢 · 满 49 减 8').fontSize(14)
            .fontWeight(FontWeight.Bold).fontColor('#FFCA28')
          Text('21:00-23:00 全场摊位同步').fontSize(10)
            .fontColor('#FFD98E').margin({ top: 2 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text('去凑单 →')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor('#311B92')
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .borderRadius(13)
          .backgroundColor('#FFCA28')
      }
      .width('100%')
      .margin({ left: 20, right: 20, top: 10 })
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
      .borderRadius(14)
      .backgroundColor('#3D25A8')

      // 统计胶囊行
      Row({ space: 8 }) {
        Row({ space: 5 }) {
          Text('🏮').fontSize(11)
          Text(openStallCount199() + ' 摊营业').fontSize(11).fontColor('#FFFFFF')
        }
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .borderRadius(13)
        .backgroundColor('#59FF7043')

        Row({ space: 5 }) {
          Text('👥').fontSize(11)
          Text('排队 ' + queueTotal199() + ' 人').fontSize(11).fontColor('#FFFFFF')
        }
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .borderRadius(13)
        .backgroundColor('#59FFCA28')

        Row({ space: 5 }) {
          Text('🛵').fontSize(11)
          Text('跑腿单 3 单进行中').fontSize(11).fontColor('#FFFFFF')
        }
        .padding({ left: 10, right: 10, top: 5, bottom: 5 })
        .borderRadius(13)
        .backgroundColor('#59556BD7')
      }
      .width('100%')
      .padding({ left: 20, top: 12, bottom: 14 })
    }
    .width('100%')
    .linearGradient({
      angle: 160,
      colors: [['#311B92', 0], ['#4A2BB8', 1]]
    })

    // ...灯串Tab和内容区
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#F6F2FB')
  // 五个弹框绑定
  .bindSheet($$this.showOrderSheet, this.orderSheet199(), {
    height: '80%',
    dragBar: true,
    showClose: false,
    backgroundColor: '#FFFFFF'
  })
  .bindSheet($$this.showNewSheet, this.newSheet199(), {
    height: '68%',
    dragBar: true,
    showClose: false,
    backgroundColor: '#FFFFFF'
  })
  .bindSheet($$this.showEditSheet, this.editSheet199(), {
    height: '72%',
    dragBar: true,
    showClose: false,
    backgroundColor: '#FFFFFF'
  })
  .bindContentCover($$this.showDeleteDialog, this.deleteDialog199(), {
  })
  .bindContentCover($$this.showDetailDialog, this.detailDialog199(), {
  })
}

统计胶囊行的背景色有个技巧:#59FF7043这种写法,前两位59是十六进制的透明度(约35%),后面是色值。这样半透明的胶囊在夜幕紫背景上不会太抢眼,但又能用颜色区分不同信息维度——橙红色表示营业数、灯串黄表示排队、淡紫色表示跑腿单。这个透明度编码方式是从Material Design的color opacity规范学的。

bindSheet和bindContentCover的双向绑定语法是ArkTS特有的。‘双向绑定语法是ArkTS特有的。`双向绑定语法是ArkTS特有的。this.showOrderSheet`表示这个布尔状态和弹框的显示/隐藏双向绑定——状态变true弹框出现,用户下滑关闭弹框状态自动变false。这种双向绑定省去了手动管理弹框生命周期的麻烦。

Day 6:逛市集直播Tab——四宫格+工具条+弹幕

今天写第一个Tab页:逛市集(直播)。这个页面的信息密度最高,需要同时呈现四机位视频、工具条、摊位速报列表和弹幕。

@Component
struct LiveTab199 {
  @State micOn: boolean = true
  @State lampOn: boolean = true
  @State liked: boolean[] = [true, false, true, false, false, false, false, false]
  onOrder: () => void = () => {}

  build() {
    Column() {
      // 视频宫格 2×2
      Grid() {
        GridItem() {
          Column() {
            Text('🎥').fontSize(24).margin({ top: 6 })
            Text('主镜头 · 夜市大街').fontSize(11).fontColor('#FFFFFF').margin({ top: 6 })
            Text('主播小拾正在逛烧烤区').fontSize(9).fontColor('#FFE08C').margin({ top: 2 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .borderRadius(12)
          .linearGradient({
            angle: 135,
            colors: [['#311B92', 0], ['#5E35B1', 1]]
          })
        }
        GridItem() {
          Column() {
            Text('🦪').fontSize(24).margin({ top: 6 })
            Text('机位 · 炭火烤生蚝').fontSize(11).fontColor('#FFFFFF').margin({ top: 6 })
            Text('蚝哥现开第 38 打').fontSize(9).fontColor('#FFE08C').margin({ top: 2 })
          }
          .linearGradient({
            angle: 135,
            colors: [['#FF7043', 0], ['#E64A19', 1]]
          })
        }
        // 跑腿视角和柠檬茶摊机位...
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr')
      .columnsGap(8)
      .rowsGap(8)
      .height(226)

      // 工具条——连麦/灯牌/点单/打赏
      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text(this.micOn ? '🎙️' : '🔇').fontSize(17)
          Text(this.micOn ? '连麦开' : '已静音').fontSize(9)
            .fontColor(this.micOn ? '#311B92' : '#9FA8DA')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.micOn = !this.micOn
        })

        Column({ space: 3 }) {
          Text(this.lampOn ? '🏮' : '💡').fontSize(17)
          Text(this.lampOn ? '灯牌亮' : '已熄灭').fontSize(9)
            .fontColor(this.lampOn ? '#E08A00' : '#9FA8DA')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor(this.lampOn ? '#FFF8E1' : '#FFFFFF')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.lampOn = !this.lampOn
        })

        Column({ space: 3 }) {
          Text('🏮').fontSize(17)
          Text('点单代买').fontSize(9).fontColor('#FF7043')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.onOrder()
        })

        Column({ space: 3 }) {
          Text('🎁').fontSize(17)
          Text('打赏摊主').fontSize(9).fontColor('#311B92')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor('#EDE9F9')
        .justifyContent(FlexAlign.Center)
      }

      // 摊位速报列表——前6个摊位
      Column() {
        Text('摊位速报').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(stallData199, (s: Stall199, i: number) => {
          if (i < 6) {
            Row({ space: 12 }) {
              Column() {
                Row({ space: 6 }) {
                  Text(s.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
                  Text(s.state)
                    .fontSize(9)
                    .fontColor(stallStateColor199(s.state))
                    .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                    .borderRadius(9)
                    .backgroundColor('#FBFAFE')
                }
                Text('⭐ ' + s.rating + ' · 排队 ' + s.queue + ' 人 · 月销 ' + s.sales + ' 单')
                  .fontSize(10).fontColor('#8E85B0').margin({ top: 3 })
                Row({ space: 6 }) {
                  ForEach(s.tags, (t: string) => {
                    Text('# ' + t).fontSize(9).fontColor('#E08A00')
                      .padding({ left: 7, right: 7, top: 3, bottom: 3 })
                      .borderRadius(9).backgroundColor('#FFF8E1')
                  }, (t: string) => t)
                }
                .margin({ top: 5 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text(this.liked[i] ? '🔥' : '🤍')
                .fontSize(16)
                .onClick(() => {
                  this.liked = this.liked.map((v: boolean, j: number) => {
                    return j === i ? !v : v
                  })
                })
            }
            .width('100%')
            .backgroundColor('#FFFFFF')
            .borderRadius(12)
            .margin({ top: 7 })
          }
        }, (s: Stall199) => s.name)
      }

      // 弹幕
      Column() {
        Text('夜市弹幕').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(barrageData199, (b: Barrage199, i: number) => {
          Row({ space: 6 }) {
            Text(b.user).fontSize(10).fontColor('#8E85B0')
            Text(b.text).fontSize(11).fontColor(b.color).layoutWeight(1)
          }
          .width('100%')
          .margin({ top: 7 })
          .margin({ left: (i % 3) * 14 })
          .opacity(i % 2 === 0 ? 1 : 0.75)
        }, (b: Barrage199) => b.user)
      }
    }
    .width('100%')
  }
}

弹幕的实现有个小技巧:margin({ left: (i % 3) * 14 })——用索引对3取余再乘以14,让弹幕产生三种缩进级别(0、14、28vp),模拟真实弹幕从不同位置飘过的错落感。opacity(i % 2 === 0 ? 1 : 0.75)让偶数条弹幕全不透明、奇数条75%透明度,增加层次感。这些小细节是让假数据"看起来像真的"的关键。

liked数组的map回写和编辑清单的模式一样,但这里有一个区别:liked是组件内部的@State,不通过@Prop从父组件传入,所以更新后直接触发本组件的UI刷新。而摊位列表的liked状态如果不提升到父组件,在切换Tab回来后会丢失——这是ArkTS组件状态的生命周期限制,目前因为只是点赞状态,丢失影响不大,但如果要持久化就需要用AppStorage。

Day 7:图表绘制——摊主团Tab和跑腿单Tab

今天把剩下的Tab页全写了。摊主团Tab里有三种图表:每晚客流柱图、美食分类堆叠条、摊主口碑榜。跑腿单Tab有进度条和汇总行。

// 每晚客流柱图
Column() {
  Text('本周每晚客流(百人次)').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
  Row() {
    ForEach(nightFlow199, (d: NightFlow199) => {
      Column() {
        Text(d.visitors + '').fontSize(9).fontColor('#FF7043').margin({ bottom: 3 })
        Column()
          .width(20)
          .height(d.visitors * 7)
          .borderRadius(5)
          .linearGradient({
            angle: 180,
            colors: [['#FFCA28', 0], ['#FF7043', 1]]
          })
        Text(d.day).fontSize(9).fontColor('#9FA8DA').margin({ top: 4 })
      }
    }, (d: NightFlow199) => d.day)
  }
  .height(260)
  .alignItems(VerticalAlign.Bottom)
  .justifyContent(FlexAlign.SpaceBetween)
}

// 美食分类占比堆叠条
Column() {
  Text('全场摊位分类构成').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
  Row() {
    ForEach(catMix199, (c: CatMix199) => {
      Column().layoutWeight(c.count).height(14).backgroundColor(c.color)
    }, (c: CatMix199) => c.label)
  }
  .width('100%')
  .borderRadius(7)
  .clip(true)
  .margin({ top: 12 })
}

// 摊主口碑榜
Column() {
  Text('摊主口碑榜').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
  ForEach(stallData199, (s: Stall199, i: number) => {
    if (i < 6) {
      Row({ space: 10 }) {
        Text((i + 1) + '')
          .fontSize(11)
          .fontWeight(FontWeight.Bold)
          .fontColor(i < 3 ? '#FFFFFF' : '#8E85B0')
          .width(20)
          .height(20)
          .borderRadius(10)
          .textAlign(TextAlign.Center)
          .backgroundColor(i < 3 ? '#FF7043' : '#EDE9F9')
        Text(s.emoji + ' ' + s.owner).fontSize(12).fontColor('#311B92').layoutWeight(1)
        Text('⭐ ' + s.rating).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#E08A00')
        Text(s.cat).fontSize(10).fontColor('#8E85B0')
      }
    }
  }, (s: Stall199) => s.owner)
}

堆叠条的layoutWeight(c.count)是整段代码最精妙的一行。layoutWeight本意是按权重分配剩余空间,这里把每个分类的count值作为权重,烧烤夜宵5份、甜品糖水4份、饮品3份、面点小吃2份,堆叠条就会按5:4:3:2的比例分配宽度,完美呈现占比关系。加上.clip(true)让圆角裁切生效,一条分类占比堆叠条就完成了。比引入Chart.js之类的库轻量太多了。

客流柱图的高度系数用了d.visitors * 7——最大值32,32*7=224,加上文字标签约260的高度。这个7也是反复调出来的。后面加了个alignItems(VerticalAlign.Bottom)让柱子从底部对齐,这是柱图的关键——如果不设Bottom对齐,柱子会从顶部往下长,视觉效果完全不对。

图表开发的核心不是"画出来",而是"对齐正确"。柱图的底部对齐、横条的左右对齐、堆叠条的宽度比例——每一个对齐都关系到数据是否被正确感知。ArkTS原生布局能力虽然不如D3.js灵活,但对于移动端的信息图表,layoutWeight+Column/Row的组合已经足够。

Day 8:跑腿单Tab——进度条的两种表达

跑腿单Tab的设计重点在进度条:用宽度百分比表达"已用时间/预计总时间"的进度。

ForEach(runData199, (r: Run199) => {
  Column() {
    Row() {
      Text('# ' + r.no).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#311B92')
      Text(r.state)
        .fontSize(9)
        .fontColor(runStateColor199(r.state))
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .borderRadius(9)
        .backgroundColor('#FBFAFE')
        .layoutWeight(1)
        .textAlign(TextAlign.End)
    }

    Row({ space: 12 }) {
      Column() {
        Text(r.items).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#311B92')
        Text('骑手 ' + r.runner + ' · 已用 ' + r.mins + ' 分钟')
          .fontSize(10).fontColor('#8E85B0').margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Column({ space: 2 }) {
        Text('¥ ' + r.fee).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FF7043')
        Text('跑腿费').fontSize(9).fontColor('#9FA8DA')
      }
    }
    .margin({ top: 10 })

    // 进度条——已用时间占比
    Row() {
      Text('')
        .width((r.mins / 35 * 100) + '%')
        .height(5)
        .borderRadius(3)
        .backgroundColor('#FF7043')
      Text('').layoutWeight(1).height(5).borderRadius(3).backgroundColor('#EAE4F5')
    }
    .margin({ top: 8 })
  }
  .backgroundColor('#FFFFFF')
  .borderRadius(16)
  .padding(14)
}, (r: Run199) => r.no)

进度条的宽度计算r.mins / 35 * 100 + '%'——用已用分钟除以35(预计总送达时间)再乘以100得到百分比。这里35是个魔数(magic number),理想情况下应该从配置或后端获取。但因为是假数据阶段,直接写死也无所谓。如果r.mins为0(已取消的订单),进度条宽度就是0%,视觉上只显示灰色底条,合理。

Day 9:我的Tab——个人卡和设置开关

最后写"我的"Tab。这页比较常规:渐变个人卡、消费记录列表、三个设置开关。

@Component
struct MineTab199 {
  @State notifyStall: boolean = true
  @State notifyWish: boolean = true
  @State nightMode: boolean = false

  build() {
    Column() {
      // 个人卡片——夜幕紫到烟火橙渐变
      Row({ space: 14 }) {
        Column() {
          Row({ space: 6 }) {
            Text('夜宵课代表').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('金胃王').fontSize(9).fontColor('#311B92')
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .borderRadius(10).backgroundColor('#FFCA28')
          }
          Text('云逛 32 晚 · 钟情烧烤与糖水').fontSize(11)
            .fontColor('#FFE08C').margin({ top: 5 })
          Row({ space: 10 }) {
            Text('下单 58 单').fontSize(10).fontColor('#FFE08C')
            Text('好评率 100%').fontSize(10).fontColor('#FFE08C')
          }
          .margin({ top: 5 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(16)
      .borderRadius(18)
      .linearGradient({
        angle: 120,
        colors: [['#311B92', 0], ['#FF7043', 1]]
      })
      .margin({ left: 12, right: 12, top: 10 })

      // 消费记录
      Column() {
        Text('我的消费记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(buyLogData199, (b: BuyLog199) => {
          Row({ space: 12 }) {
            Column() {
              Text(b.item).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
              Text(b.result).fontSize(10)
                .fontColor(b.result.indexOf('取消') >= 0 ? '#9E9E9E' : '#2E7D32')
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column({ space: 2 }) {
              Text('¥ ' + b.price).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FF7043')
              Text(b.date).fontSize(9).fontColor('#9FA8DA')
            }
          }
          .backgroundColor('#FBFAFE')
          .borderRadius(12)
          .margin({ top: 7 })
        }, (b: BuyLog199) => b.item)
      }

      // 设置开关
      Column() {
        Row() {
          Column() {
            Text('常逛摊位出摊提醒').fontSize(13).fontColor('#311B92')
            Text('收藏摊位开火即推送').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.notifyStall })
            .selectedColor('#FF7043')
            .onChange((v: boolean) => { this.notifyStall = v })
        }

        Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

        Row() {
          Column() {
            Text('清单逛到提醒').fontSize(13).fontColor('#311B92')
            Text('镜头切到想吃分类时通知').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.notifyWish })
            .selectedColor('#FFCA28')
            .onChange((v: boolean) => { this.notifyWish = v })
        }

        Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

        Row() {
          Column() {
            Text('深夜模式(23:00 后禁下单)').fontSize(13).fontColor('#311B92')
            Text('管住嘴,从源头做起').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.nightMode })
            .selectedColor('#311B92')
            .onChange((v: boolean) => { this.nightMode = v })
        }
      }

      Text('拾贝 · 云夜市 v2.0.1 · 人间烟火气')
        .fontSize(10).fontColor('#B4ABD6').margin({ top: 16, bottom: 24 })
    }
    .width('100%')
  }
}

"深夜模式"这个设置开关让我笑了半天——“管住嘴,从源头做起”,这种文案风格是故意往"接地气"方向写的,和夜市场景的氛围匹配。Toggle的selectedColor分别用了三种颜色(烟火橙/灯串黄/夜幕紫),和三个开关的功能含义对应:出摊提醒是"行动"类用橙、清单提醒是"注意"类用黄、深夜模式是"限制"类用紫。这种颜色语义化虽然用户不一定能感知到,但作为开发者的审美自觉,值得坚持。

完整数据流架构

渲染错误: Mermaid 渲染失败: Parse error on line 8: ... W[wishes数组] -->|@Prop传递| Tab3[WishT -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'

这张图展示了整个应用的状态流。核心数据wishes数组在父组件Index199中维护,通过@Prop单向传递给WishTab199子组件。子组件通过回调函数(onNew/onEdit/onDelete)通知父组件打开对应弹框,弹框中的保存/删除操作通过map/filter更新wishes数组,@Prop会自动将新数据传递给子组件,触发列表刷新。这个"数据下传+回调上传"的模式是ArkTS组件间通信的标准范式。

技术决策对比

决策点选择方案放弃的方案选择理由
弹框模式bindSheet+bindContentCover自定义绝对定位弹框原生支持手势关闭,无需手动管理z-index
Tab导航灯串挂牌式自定义RowTabBar组件需要挂绳+灯牌+阴影的视觉效果,TabBar不够灵活
图表实现Column/Row+layoutWeight引入图表库数据量小,原生布局足够,省体积
数据更新map/filter不可变模式直接索引赋值ArkTS的@State需要引用变化才触发刷新
状态传递@Prop单向+回调@Link双向列表数据由父组件统一管理,子组件只读+回调
弹幕错落索引取余做margin随机数假数据阶段需要确定性,索引取余可复现
柱图高度数据值×固定系数百分比ArkTS Row中百分比高度不如vp值稳定
Toggle状态布尔推导(orderTip>0)直接绑定一个状态控制多个UI表现时推导更灵活
进度条宽度百分比Slider组件只需展示不需交互,Text+Row更轻量
透明度色#AA+色值格式rgba()函数ArkTS原生支持#AARRGGBB格式更简洁

安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

// =====================================================================
// 场景:代逛主播举着云台逛夜市,四个摊位机位同屏,观众连线点单,
//       跑腿小哥直播取餐,可建好物清单、围观摊位、看每晚客流战报。
// 配色:夜幕紫 #311B92 × 灯串黄 #FFCA28 × 烟火橙 #FF7043
// Tab 布局:顶部「灯串挂牌」tab(一条横向缆线贯穿,6 块灯牌挂牌
//           各带短挂绳,选中灯牌亮黄发光+阴影浮起)
// 弹框:点单代买(抽屉) / 新增好物清单(抽屉) / 编辑清单(抽屉-map回写) /
//       删除清单(居中-filter) / 摊位详情(居中)
// 图表:每晚客流柱图 / 美食分类占比堆叠条 / 摊位人气横条 / 月销走势柱图(详情)
// =====================================================================

// ----------------------------- 数据模型 -----------------------------

// 摊位
interface Stall199 {
  name: string
  emoji: string
  cat: string
  sales: number
  rating: number
  queue: number
  state: string
  tags: string[]
  owner: string
}

// 好物清单
interface Wish199 {
  id: number
  item: string
  stall: string
  emoji: string
  budget: number
  status: string
  time: string
  top: boolean
}

// 跑腿订单
interface Run199 {
  no: string
  items: string
  runner: string
  state: string
  fee: number
  mins: number
}

// 弹幕
interface Barrage199 {
  user: string
  text: string
  color: string
}

// 每晚客流柱图
interface NightFlow199 {
  day: string
  visitors: number
}

// 美食分类占比
interface CatMix199 {
  label: string
  count: number
  color: string
}

// 摊位人气横条
interface StallPop199 {
  name: string
  sales: number
  emoji: string
}

// 月销走势(详情弹框)
interface MonthSale199 {
  week: string
  sales: number
}

// 消费记录
interface BuyLog199 {
  item: string
  emoji: string
  date: string
  result: string
  price: number
}

// tab 项
interface TabItem199 {
  icon: string
  name: string
}

// ----------------------------- 全局数据 -----------------------------

const tabItems199: TabItem199[] = [
  { icon: '🏮', name: '逛市集' },
  { icon: '🛖', name: '摊位墙' },
  { icon: '📝', name: '好物单' },
  { icon: '🛵', name: '跑腿单' },
  { icon: '🧑‍🍳', name: '摊主团' },
  { icon: '🧺', name: '我的' }
]

const stallData199: Stall199[] = [
  { name: '炭火烤生蚝', emoji: '🦪', cat: '烧烤', sales: 486, rating: 4.9, queue: 12, state: '营业中', tags: ['蒜蓉天花板', '现开现烤'], owner: '蚝哥' },
  { name: '奶奶糖水铺', emoji: '🍮', cat: '糖水', sales: 352, rating: 4.8, queue: 8, state: '营业中', tags: ['手作龟苓膏', '低糖可选'], owner: '陈奶奶' },
  { name: '爆汁豆腐包', emoji: '🥟', cat: '面点', sales: 421, rating: 4.7, queue: 15, state: '排队中', tags: ['一口爆汁', '限购两笼'], owner: '包嫂' },
  { name: '铁板鱿鱼王', emoji: '🦑', cat: '烧烤', sales: 298, rating: 4.6, queue: 9, state: '营业中', tags: ['秘制酱料', '可加辣'], owner: '鱿叔' },
  { name: '手打柠檬茶', emoji: '🍋', cat: '饮品', sales: 512, rating: 4.9, queue: 18, state: '排队中', tags: ['真的手打', '鸭子都快不够用'], owner: '柠妹' },
  { name: '古法鸡蛋仔', emoji: '🧇', cat: '甜品', sales: 267, rating: 4.8, queue: 6, state: '营业中', tags: ['外脆内软', '混酱自由'], owner: '华仔' },
  { name: '贵州烙锅', emoji: '🍳', cat: '小吃', sales: 234, rating: 4.7, queue: 7, state: '营业中', tags: ['折耳根警告', '蘸水灵魂'], owner: '黔妹' },
  { name: '现烤榴莲饼', emoji: '🍕', cat: '甜品', sales: 189, rating: 4.5, queue: 4, state: '售罄补货', tags: ['真材实料', '果肉超多'], owner: '榴叔' },
  { name: '小龙虾大排档', emoji: '🦞', cat: '夜宵', sales: 598, rating: 4.9, queue: 22, state: '排队中', tags: ['十三香绝了', '送手套'], owner: '虾姐' },
  { name: '竹筒奶茶', emoji: '🥤', cat: '饮品', sales: 203, rating: 4.4, queue: 5, state: '营业中', tags: ['拍照神器', '竹香回味'], owner: '筒哥' }
]

const wishData199: Wish199[] = [
  { id: 1, item: '蒜蓉烤生蚝 × 6', stall: '炭火烤生蚝', emoji: '🦪', budget: 48, status: '待代买', time: '刚刚', top: true },
  { id: 2, item: '龟苓膏少糖', stall: '奶奶糖水铺', emoji: '🍮', budget: 15, status: '待代买', time: '今天 20:31', top: false },
  { id: 3, item: '爆汁豆腐包 × 2 笼', stall: '爆汁豆腐包', emoji: '🥟', budget: 24, status: '跑腿中', time: '今天 20:12', top: false },
  { id: 4, item: '手打鸭屎香柠檬茶', stall: '手打柠檬茶', emoji: '🍋', budget: 18, status: '跑腿中', time: '今天 20:02', top: false },
  { id: 5, item: '古法鸡蛋仔混酱', stall: '古法鸡蛋仔', emoji: '🧇', budget: 16, status: '已送达', time: '昨天 21:44', top: false },
  { id: 6, item: '十三香小龙虾 2 斤', stall: '小龙虾大排档', emoji: '🦞', budget: 118, status: '已送达', time: '昨天 21:20', top: false },
  { id: 7, item: '贵州烙锅双人餐', stall: '贵州烙锅', emoji: '🍳', budget: 66, status: '已取消', time: '08-23', top: false },
  { id: 8, item: '竹筒奶茶少冰', stall: '竹筒奶茶', emoji: '🥤', budget: 22, status: '已送达', time: '08-22', top: false }
]

const runData199: Run199[] = [
  { no: 'NB-8827', items: '豆腐包+柠檬茶', runner: '骑手小飞', state: '取餐中', fee: 6, mins: 8 },
  { no: 'NB-8826', items: '烤生蚝 6 只', runner: '骑手阿力', state: '配送中', fee: 5, mins: 12 },
  { no: 'NB-8825', items: '小龙虾 2 斤', runner: '骑手大壮', state: '配送中', fee: 9, mins: 18 },
  { no: 'NB-8824', items: '鸡蛋仔+糖水', runner: '骑手小美', state: '已送达', fee: 5, mins: 26 },
  { no: 'NB-8823', items: '铁板鱿鱼 2 串', runner: '骑手阿力', state: '已送达', fee: 5, mins: 31 },
  { no: 'NB-8822', items: '榴莲饼 1 份', runner: '骑手老周', state: '已取消', fee: 0, mins: 0 }
]

const barrageData199: Barrage199[] = [
  { user: '深夜干饭人', text: '生蚝再不来我要晕过去了', color: '#FF7043' },
  { user: '柠檬酸了', text: '排队 18 杯起步,手打真的累', color: '#FFCA28' },
  { user: '折耳根勇士', text: '烙锅蘸水加满,谢谢', color: '#FFCA28' },
  { user: '小龙虾脑袋', text: '十三香yyds,已经第三单了', color: '#FF7043' },
  { user: '糖水铺常客', text: '奶奶的龟苓膏是童年的味道', color: '#FFD54F' },
  { user: '代逛观众', text: '主播帮我看下鱿鱼摊队多长', color: '#FFF176' },
  { user: '碳水狂魔', text: '豆腐包限购两笼是拦不住我的', color: '#FF7043' },
  { user: '省钱小能手', text: '榴莲饼售罄了,含泪改鸡蛋仔', color: '#FFD54F' }
]

const nightFlow199: NightFlow199[] = [
  { day: '周一', visitors: 9 },
  { day: '周二', visitors: 11 },
  { day: '周三', visitors: 14 },
  { day: '周四', visitors: 16 },
  { day: '周五', visitors: 24 },
  { day: '周六', visitors: 32 },
  { day: '周日', visitors: 27 }
]

const catMix199: CatMix199[] = [
  { label: '烧烤夜宵', count: 5, color: '#FF7043' },
  { label: '甜品糖水', count: 4, color: '#FFCA28' },
  { label: '饮品', count: 3, color: '#26A69A' },
  { label: '面点小吃', count: 2, color: '#7E57C2' }
]

const stallPop199: StallPop199[] = [
  { name: '小龙虾大排档', sales: 598, emoji: '🦞' },
  { name: '手打柠檬茶', sales: 512, emoji: '🍋' },
  { name: '炭火烤生蚝', sales: 486, emoji: '🦪' },
  { name: '爆汁豆腐包', sales: 421, emoji: '🥟' },
  { name: '奶奶糖水铺', sales: 352, emoji: '🍮' },
  { name: '铁板鱿鱼王', sales: 298, emoji: '🦑' }
]

const monthSale199: MonthSale199[] = [
  { week: '第1周', sales: 180 },
  { week: '第2周', sales: 240 },
  { week: '第3周', sales: 210 },
  { week: '第4周', sales: 300 },
  { week: '本周', sales: 486 }
]

const buyLogData199: BuyLog199[] = [
  { item: '十三香小龙虾 2 斤', emoji: '🦞', date: '昨天', result: '已送达 · 好评', price: 118 },
  { item: '古法鸡蛋仔混酱', emoji: '🧇', date: '昨天', result: '已送达 · 返单预定', price: 16 },
  { item: '蒜蓉烤生蚝 × 6', emoji: '🦪', date: '08-23', result: '已送达 · 好评', price: 48 },
  { item: '龟苓膏少糖', emoji: '🍮', date: '08-21', result: '已送达 · 好评', price: 15 },
  { item: '贵州烙锅双人餐', emoji: '🍳', date: '08-20', result: '已取消 · 全额退款', price: 66 }
]

const stallTags199: string[] = ['炭火烤生蚝', '奶奶糖水铺', '爆汁豆腐包', '手打柠檬茶', '小龙虾大排档']
const itemTags199: string[] = ['蒜蓉生蚝', '龟苓膏', '豆腐包', '柠檬茶', '小龙虾']
const catTags199: string[] = ['烧烤夜宵', '甜品糖水', '饮品', '面点小吃']
const wishFilters199: string[] = ['全部', '待代买', '跑腿中', '已送达']
const stallFilters199: string[] = ['全部', '营业中', '排队中', '售罄补货']

// ----------------------------- 全局工具函数 -----------------------------

// 清单状态颜色
function wishStateColor199(s: string): string {
  if (s === '待代买') {
    return '#FF7043'
  }
  if (s === '跑腿中') {
    return '#311B92'
  }
  if (s === '已送达') {
    return '#2E7D32'
  }
  return '#9FA8DA'
}

// 摊位状态颜色
function stallStateColor199(s: string): string {
  if (s === '营业中') {
    return '#2E7D32'
  }
  if (s === '排队中') {
    return '#FF7043'
  }
  return '#9E9E9E'
}

// 跑腿单状态颜色
function runStateColor199(s: string): string {
  if (s === '取餐中' || s === '配送中') {
    return '#FF7043'
  }
  if (s === '已送达') {
    return '#2E7D32'
  }
  return '#9E9E9E'
}

// 营业中摊位数
function openStallCount199(): number {
  let n: number = 0
  for (let i = 0; i < stallData199.length; i++) {
    if (stallData199[i].state === '营业中') {
      n++
    }
  }
  return n
}

// 待代买清单数
function pendingWishCount199(): number {
  let n: number = 0
  for (let i = 0; i < wishData199.length; i++) {
    if (wishData199[i].status === '待代买') {
      n++
    }
  }
  return n
}

// 清单总预算
function wishBudget199(): number {
  let n: number = 0
  for (let i = 0; i < wishData199.length; i++) {
    if (wishData199[i].status !== '已取消') {
      n += wishData199[i].budget
    }
  }
  return n
}

// 今晚总排队数
function queueTotal199(): number {
  let n: number = 0
  for (let i = 0; i < stallData199.length; i++) {
    n += stallData199[i].queue
  }
  return n
}

// 摊位销量最大值
function maxStallSale199(): number {
  let m: number = 0
  for (let i = 0; i < stallPop199.length; i++) {
    if (stallPop199[i].sales > m) {
      m = stallPop199[i].sales
    }
  }
  return m
}

// 详情统计行标签
function stallStatLabel199(idx: number): string {
  if (idx === 0) {
    return '月销量'
  }
  if (idx === 1) {
    return '评分'
  }
  return '当前排队'
}

// ============================ 页面入口 ============================

@Entry
@Component
struct Index199 {
  // tab
  @State currentTab: number = 0
  // 弹框开关
  @State showOrderSheet: boolean = false
  @State showNewSheet: boolean = false
  @State showEditSheet: boolean = false
  @State showDeleteDialog: boolean = false
  @State showDetailDialog: boolean = false
  // 清单数据(可增删改)
  @State wishes: Wish199[] = wishData199
  // 点单表单
  @State orderStall: number = 0
  @State orderItem: number = 0
  @State orderQty: number = 2
  @State orderQueue: boolean = true
  @State orderTip: number = 3
  // 新建清单表单
  @State newCat: number = 0
  @State newBudget: number = 30
  @State newNotify: boolean = true
  // 编辑清单表单
  @State editIndex: number = -1
  @State editCat: number = 0
  @State editBudget: number = 30
  @State editTop: boolean = false
  // 删除
  @State deleteIndex: number = -1
  @State deleteKeepLog: boolean = true
  // 详情
  @State detailIndex: number = 0

  // ---------- 弹框一:点单代买(底部抽屉) ----------
  @Builder
  orderSheet199() {
    Column() {
      Row() {
        Column() {
          Text('🏮 点单代买').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#311B92')
          Text('跑腿小哥全程直播取餐,进度实时可见').fontSize(11).fontColor('#9FA8DA').margin({ top: 4 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 18, bottom: 14 })

      Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

      Scroll() {
        Column() {
          // 摊位
          Column() {
            Text('选择摊位').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
              ForEach(stallTags199, (s: string, i: number) => {
                Text(s)
                  .fontSize(12)
                  .fontColor(this.orderStall === i ? '#FFFFFF' : '#5C4DA8')
                  .padding({ left: 12, right: 12, top: 7, bottom: 7 })
                  .borderRadius(16)
                  .backgroundColor(this.orderStall === i ? '#311B92' : '#EDE9F9')
                  .onClick(() => {
                    this.orderStall = i
                  })
              }, (s: string) => s)
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 商品
          Column() {
            Text('选择商品').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
              ForEach(itemTags199, (t: string, i: number) => {
                Text(t)
                  .fontSize(12)
                  .fontColor(this.orderItem === i ? '#311B92' : '#E08A00')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(16)
                  .backgroundColor(this.orderItem === i ? '#FFCA28' : '#FFF8E1')
                  .onClick(() => {
                    this.orderItem = i
                  })
              }, (t: string) => t)
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 数量步进
          Column() {
            Text('数量(份)').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 跑腿小费步进
          Column() {
            Text('跑腿小费(元)').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 开关组
          Column() {
            Row() {
              Column() {
                Text('接受代排队').fontSize(13).fontColor('#311B92')
                Text('热门摊位由小哥替你排队').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
              }.alignItems(HorizontalAlign.Start).layoutWeight(1)
              Toggle({ type: ToggleType.Switch, isOn: this.orderQueue })
                .selectedColor('#FF7043')
                .onChange((v: boolean) => {
                  this.orderQueue = v
                })
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })

            Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

            Row() {
              Column() {
                Text('到货拍照确认').fontSize(13).fontColor('#311B92')
                Text('送达前拍照核验餐品完整').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
              }.alignItems(HorizontalAlign.Start).layoutWeight(1)
              Toggle({ type: ToggleType.Switch, isOn: this.orderTip > 0 })
                .selectedColor('#FFCA28')
                .onChange((v: boolean) => {
                  if (v) {
                    this.orderTip = 3
                  } else {
                    this.orderTip = 0
                  }
                })
            }
            .width('100%')
            .padding({ top: 12, bottom: 12 })
          }
          .width('100%')
          .borderRadius(14)
          .backgroundColor('#FBFAFE')
          .margin({ top: 18 })
          .padding({ left: 14, right: 14 })

          Text('预估合计 ¥ ' + (this.orderQty * 24 + this.orderTip))
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FF7043')
            .margin({ top: 14 })

          Text('下单代买')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#311B92')
            .width('90%')
            .height(46)
            .borderRadius(23)
            .textAlign(TextAlign.Center)
            .linearGradient({
              angle: 90,
              colors: [['#FFCA28', 0], ['#FF7043', 1]]
            })
            .margin({ top: 12, bottom: 24 })
            .onClick(() => {
              this.showOrderSheet = false
            })
        }
        .width('100%')
      }
      .constraintSize({ maxHeight: '85%' })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 22, topRight: 22 })
  }

  // ---------- 弹框二:新增好物清单(底部抽屉) ----------
  @Builder
  newSheet199() {
    Column() {
      Row() {
        Column() {
          Text('📝 新增好物清单').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#311B92')
          Text('先把想吃的记下来,逛到摊位一键下单').fontSize(11).fontColor('#9FA8DA').margin({ top: 4 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 18, bottom: 14 })

      Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

      Scroll() {
        Column() {
          // 分类
          Column() {
            Text('想吃分类').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
              ForEach(catTags199, (c: string, i: number) => {
                Text(c)
                  .fontSize(12)
                  .fontColor(this.newCat === i ? '#FFFFFF' : '#5C4DA8')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(16)
                  .backgroundColor(this.newCat === i ? '#311B92' : '#EDE9F9')
                  .onClick(() => {
                    this.newCat = i
                  })
              }, (c: string) => c)
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 预算步进
          Column() {
            Text('单类预算上限(元)').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 提醒开关
          Row() {
            Column() {
              Text('逛到对应摊位时提醒我').fontSize(13).fontColor('#311B92')
              Text('主播镜头切到该分类时推送').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.newNotify })
              .selectedColor('#FFCA28')
              .onChange((v: boolean) => {
                this.newNotify = v
              })
          }
          .width('100%')
          .borderRadius(14)
          .backgroundColor('#FBFAFE')
          .margin({ top: 18 })
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })

          Text('加入清单')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#311B92')
            .width('90%')
            .height(46)
            .borderRadius(23)
            .textAlign(TextAlign.Center)
            .linearGradient({
              angle: 90,
              colors: [['#FFCA28', 0], ['#FF9800', 1]]
            })
            .margin({ top: 24, bottom: 24 })
            .onClick(() => {
              this.showNewSheet = false
            })
        }
        .width('100%')
      }
      .constraintSize({ maxHeight: '85%' })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 22, topRight: 22 })
  }

  // ---------- 弹框三:编辑清单(底部抽屉,map 回写) ----------
  @Builder
  editSheet199() {
    Column() {
      Row() {
        Column() {
          Text('✏️ 编辑好物清单').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#311B92')
          Text('调整分类与预算后立即生效').fontSize(11).fontColor('#9FA8DA').margin({ top: 4 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%')
      .padding({ left: 20, right: 20, top: 18, bottom: 14 })

      Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

      Scroll() {
        Column() {
          // 分类
          Column() {
            Text('想吃分类').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
              ForEach(catTags199, (c: string, i: number) => {
                Text(c)
                  .fontSize(12)
                  .fontColor(this.editCat === i ? '#FFFFFF' : '#5C4DA8')
                  .padding({ left: 14, right: 14, top: 7, bottom: 7 })
                  .borderRadius(16)
                  .backgroundColor(this.editCat === i ? '#311B92' : '#EDE9F9')
                  .onClick(() => {
                    this.editCat = i
                  })
              }, (c: string) => c)
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 预算步进
          Column() {
            Text('单类预算上限(元)').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .padding({ left: 20, right: 20, top: 16 })

          // 置顶开关
          Row() {
            Column() {
              Text('置顶这条清单').fontSize(13).fontColor('#311B92')
              Text('逛市集时优先展示在清单顶部').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
            Toggle({ type: ToggleType.Switch, isOn: this.editTop })
              .selectedColor('#FF7043')
              .onChange((v: boolean) => {
                this.editTop = v
              })
          }
          .width('100%')
          .borderRadius(14)
          .backgroundColor('#FBFAFE')
          .margin({ top: 18 })
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })

          Text('保存修改')
            .fontSize(15)
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .width('90%')
            .height(46)
            .borderRadius(23)
            .textAlign(TextAlign.Center)
            .linearGradient({
              angle: 90,
              colors: [['#311B92', 0], ['#5C4DA8', 1]]
            })
            .margin({ top: 24, bottom: 24 })
            .onClick(() => {
              this.wishes = this.wishes.map((w: Wish199, i: number) => {
                if (i === this.editIndex) {
                  return {
                    id: w.id,
                    item: w.item,
                    stall: w.stall,
                    emoji: w.emoji,
                    budget: this.editBudget,
                    status: w.status,
                    time: w.time,
                    top: this.editTop
                  }
                }
                return w
              })
              this.showEditSheet = false
            })
        }
        .width('100%')
      }
      .constraintSize({ maxHeight: '85%' })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius({ topLeft: 22, topRight: 22 })
  }

  // ---------- 弹框四:删除清单(居中确认框) ----------
  @Builder
  deleteDialog199() {
    Column() {

      Text('移出这条清单?').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#311B92').margin({ top: 14 })
      Text('清单移出后,已下单的跑腿订单不受影响,未下单项将不再提醒。')
        .fontSize(12)
        .fontColor('#9FA8DA')
        .textAlign(TextAlign.Center)
        .lineHeight(18)
        .margin({ top: 8 })
        .padding({ left: 24, right: 24 })

      Row() {
        Column() {
          Text('保留消费记录').fontSize(13).fontColor('#311B92')
          Text('历史订单仍可在「我的」查看').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Toggle({ type: ToggleType.Switch, isOn: this.deleteKeepLog })
          .selectedColor('#FF7043')
          .onChange((v: boolean) => {
            this.deleteKeepLog = v
          })
      }
      .width('86%')
      .borderRadius(14)
      .backgroundColor('#FBFAFE')
      .margin({ top: 14 })
      .padding({ left: 14, right: 14, top: 12, bottom: 12 })

      Row({ space: 12 }) {
        Text('再想想')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#5C4DA8')
          .layoutWeight(1)
          .height(44)
          .borderRadius(22)
          .textAlign(TextAlign.Center)
          .backgroundColor('#EDE9F9')
          .onClick(() => {
            this.showDeleteDialog = false
          })
        Text('确认移出')
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .layoutWeight(1)
          .height(44)
          .borderRadius(22)
          .textAlign(TextAlign.Center)
          .backgroundColor('#FF7043')
          .onClick(() => {
            this.wishes = this.wishes.filter((w: Wish199, i: number) => {
              return i !== this.deleteIndex
            })
            this.showDeleteDialog = false
          })
      }
      .width('86%')
      .margin({ top: 22, bottom: 26 })
    }
    .width('86%')
    .borderRadius(22)
    .backgroundColor('#FFFFFF')
    .alignItems(HorizontalAlign.Center)
  }

  // ---------- 弹框五:摊位详情(居中展示框) ----------
  @Builder
  detailDialog199() {
    Column() {
      // 渐变头
      Column() {
        Text(stallData199[this.detailIndex].emoji)
          .fontSize(34)
          .width(64)
          .height(64)
          .borderRadius(32)
          .backgroundColor('#FFFFFF')
          .textAlign(TextAlign.Center)
        Text(stallData199[this.detailIndex].name)
          .fontSize(19)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FFFFFF')
          .margin({ top: 8 })
        Text('摊主 ' + stallData199[this.detailIndex].owner + ' · ' + stallData199[this.detailIndex].cat)
          .fontSize(12)
          .fontColor('#FFF3D6')
          .margin({ top: 4 })
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      .padding({ top: 26, bottom: 18 })
      .borderRadius({ topLeft: 22, topRight: 22 })
      .linearGradient({
        angle: 135,
        colors: [['#311B92', 0], ['#FF7043', 1]]
      })

      Scroll() {
        Column() {
          // 统计行
          Row() {
            Column({ space: 2 }) {
              Text(stallData199[this.detailIndex].sales + '').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#FF7043')
              Text(stallStatLabel199(0)).fontSize(10).fontColor('#9FA8DA')
            }.layoutWeight(1)
            Column({ space: 2 }) {
              Text('⭐ ' + stallData199[this.detailIndex].rating).fontSize(17).fontWeight(FontWeight.Bold).fontColor('#E08A00')
              Text(stallStatLabel199(1)).fontSize(10).fontColor('#9FA8DA')
            }.layoutWeight(1)
            Column({ space: 2 }) {
              Text(stallData199[this.detailIndex].queue + ' 人').fontSize(17).fontWeight(FontWeight.Bold).fontColor('#311B92')
              Text(stallStatLabel199(2)).fontSize(10).fontColor('#9FA8DA')
            }.layoutWeight(1)
          }
          .width('100%')
          .padding({ top: 16, bottom: 14 })

          // 月销走势柱图
          Column() {
            Text('近期销量走势(单)').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
              ForEach(monthSale199, (m: MonthSale199) => {
                Column() {
                  Text(m.sales + '').fontSize(9).fontColor('#FF7043').margin({ bottom: 3 })
                  Column()
                    .width(30)
                    .height(Math.floor(m.sales / 4))
                    .borderRadius(5)
                    .linearGradient({
                      angle: 180,
                      colors: [['#FFCA28', 0], ['#FF7043', 1]]
                    })
                  Text(m.week).fontSize(9).fontColor('#9FA8DA').margin({ top: 4 })
                }
              }, (m: MonthSale199) => m.week)
            }
            .width('100%')
            .height(140)
            .alignItems(VerticalAlign.Bottom)
            .justifyContent(FlexAlign.SpaceBetween)
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .borderRadius(14)
          .backgroundColor('#FBFAFE')
          .padding(14)
          .margin({ top: 4 })

          // 招牌标签
          Column() {
            Text('招牌亮点').fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Row() {
              ForEach(stallData199[this.detailIndex].tags, (t: string) => {
                Text('# ' + t)
                  .fontSize(11)
                  .fontColor('#E08A00')
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 })
                  .borderRadius(12)
                  .backgroundColor('#FFF8E1')
                  .margin({ right: 8 })
              }, (t: string) => t)
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .alignItems(HorizontalAlign.Start)
          .margin({ top: 16 })

          // 操作按钮
          Row({ space: 12 }) {
            Text('🏮 去下单')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#311B92')
              .layoutWeight(1)
              .height(44)
              .borderRadius(22)
              .textAlign(TextAlign.Center)
              .linearGradient({
                angle: 90,
                colors: [['#FFCA28', 0], ['#FF7043', 1]]
              })
              .onClick(() => {
                this.showDetailDialog = false
                this.showOrderSheet = true
              })
            Text('关闭')
              .fontSize(14)
              .fontWeight(FontWeight.Bold)
              .fontColor('#5C4DA8')
              .width(84)
              .height(44)
              .borderRadius(22)
              .textAlign(TextAlign.Center)
              .backgroundColor('#EDE9F9')
              .onClick(() => {
                this.showDetailDialog = false
              })
          }
          .width('100%')
          .padding({ left: 20, right: 20, top: 20, bottom: 24 })
        }
        .width('100%')
      }
      .constraintSize({ maxHeight: '60%' })
    }
    .width('86%')
    .borderRadius(22)
    .backgroundColor('#FFFFFF')
  }

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

  build() {
    Column() {
      // ---------- 头部:夜幕紫电商大促风(无动画) ----------
      Column() {
        // 顶部标题行
        Row() {
          Column() {
            Text('拾贝 · 云夜市')
              .fontSize(20)
              .fontWeight(FontWeight.Bold)
              .fontColor('#FFFFFF')
            Text('今晚 10 个摊位出摊,云逛街不吃灰').fontSize(11).fontColor('#FFE08C').margin({ top: 3 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)

          Row({ space: 8 }) {
          }
        }
        .width('100%')
        .padding({ left: 20, right: 20, top: 14 })

        // 电商大促横幅条
        Row({ space: 8 }) {
          Column()
            .width(4)
            .height(30)
            .borderRadius(2)
            .backgroundColor('#FFCA28')
          Column() {
            Text('夜宵狂欢 · 满 49 减 8').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#FFCA28')
            Text('21:00-23:00 全场摊位同步').fontSize(10).fontColor('#FFD98E').margin({ top: 2 })
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text('去凑单 →')
            .fontSize(11)
            .fontWeight(FontWeight.Bold)
            .fontColor('#311B92')
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(13)
            .backgroundColor('#FFCA28')
        }
        .width('100%')
        .margin({ left: 20, right: 20, top: 10 })
        .padding({ left: 12, right: 12, top: 10, bottom: 10 })
        .borderRadius(14)
        .backgroundColor('#3D25A8')

        // 统计胶囊行
        Row({ space: 8 }) {
          Row({ space: 5 }) {
            Text('🏮').fontSize(11)
            Text(openStallCount199() + ' 摊营业').fontSize(11).fontColor('#FFFFFF')
          }
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .borderRadius(13)
          .backgroundColor('#59FF7043')

          Row({ space: 5 }) {
            Text('👥').fontSize(11)
            Text('排队 ' + queueTotal199() + ' 人').fontSize(11).fontColor('#FFFFFF')
          }
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .borderRadius(13)
          .backgroundColor('#59FFCA28')

          Row({ space: 5 }) {
            Text('🛵').fontSize(11)
            Text('跑腿单 3 单进行中').fontSize(11).fontColor('#FFFFFF')
          }
          .padding({ left: 10, right: 10, top: 5, bottom: 5 })
          .borderRadius(13)
          .backgroundColor('#59556BD7')
        }
        .width('100%')
        .padding({ left: 20, top: 12, bottom: 14 })
      }
      .width('100%')
      .linearGradient({
        angle: 160,
        colors: [['#311B92', 0], ['#4A2BB8', 1]]
      })

      // ---------- 顶部「灯串挂牌」tab ----------
      Column() {
        // 横向缆线
        Row() {
          Text('').layoutWeight(1).height(2).backgroundColor('#D9CFEE')
        }
        .width('100%')
        .padding({ left: 14, right: 14 })

        Row() {
          ForEach(tabItems199, (t: TabItem199, i: number) => {
            Column() {
              // 挂绳
              Text('')
                .width(2)
                .height(6)
                .backgroundColor(this.currentTab === i ? '#E08A00' : '#D9CFEE')
              // 灯牌
              Column({ space: 2 }) {
                Text(t.icon).fontSize(15)
                Text(t.name)
                  .fontSize(10)
                  .fontWeight(this.currentTab === i ? FontWeight.Bold : FontWeight.Normal)
                  .fontColor(this.currentTab === i ? '#311B92' : '#8E85B0')
              }
              .padding({ top: 5, bottom: 6, left: 4, right: 4 })
              .borderRadius(10)
              .backgroundColor(this.currentTab === i ? '#FFCA28' : '#FFFFFF')
              .shadow({
                radius: this.currentTab === i ? 8 : 2,
                color: this.currentTab === i ? '#66FF9800' : '#1A000000',
                offsetX: 0,
                offsetY: this.currentTab === i ? 3 : 1
              })
              .scale(this.currentTab === i ? { x: 1.08, y: 1.08 } : { x: 1, y: 1 })
              .animation({ duration: 180 })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.Center)
            .margin({ top: 2 })
            .onClick(() => {
              this.currentTab = i
            })
          }, (t: TabItem199) => t.name)
        }
        .width('100%')
        .padding({ left: 8, right: 8, top: 2, bottom: 8 })

        Column().width('100%').height(0.5).backgroundColor('#E5DEF3')
      }
      .width('100%')
      .backgroundColor('#F6F2FB')

      // ---------- 内容区 ----------
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            LiveTab199({
              onOrder: () => {
                this.showOrderSheet = true
              }
            })
          } else if (this.currentTab === 1) {
            StallTab199({
              onDetail: (i: number) => {
                this.detailIndex = i
                this.showDetailDialog = true
              }
            })
          } else if (this.currentTab === 2) {
            WishTab199({
              wishes: this.wishes,
              onNew: () => {
                this.showNewSheet = true
              },
              onEdit: (i: number) => {
                this.editIndex = i
                this.editCat = 0
                this.editBudget = this.wishes[i].budget
                this.editTop = this.wishes[i].top
                this.showEditSheet = true
              },
              onDelete: (i: number) => {
                this.deleteIndex = i
                this.showDeleteDialog = true
              }
            })
          } else if (this.currentTab === 3) {
            RunTab199()
          } else if (this.currentTab === 4) {
            OwnerTab199()
          } else {
            MineTab199()
          }
        }
        .width('100%')
        .padding({ bottom: 12 })
      }
      .layoutWeight(1)
      .scrollBar(BarState.Off)
      .width('100%')
      .backgroundColor('#F6F2FB')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F6F2FB')
    // 五个弹框绑定
    .bindSheet($$this.showOrderSheet, this.orderSheet199(), {
      height: '80%',
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindSheet($$this.showNewSheet, this.newSheet199(), {
      height: '68%',
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindSheet($$this.showEditSheet, this.editSheet199(), {
      height: '72%',
      dragBar: true,
      showClose: false,
      backgroundColor: '#FFFFFF'
    })
    .bindContentCover($$this.showDeleteDialog, this.deleteDialog199(), {
    })
    .bindContentCover($$this.showDetailDialog, this.detailDialog199(), {
    })
  }
}

// ============================ Tab 1:逛市集(直播) ============================

@Component
struct LiveTab199 {
  @State micOn: boolean = true
  @State lampOn: boolean = true
  @State liked: boolean[] = [true, false, true, false, false, false, false, false]
  onOrder: () => void = () => {}

  build() {
    Column() {
      // 视频宫格 2×2
      Grid() {
        GridItem() {
          Column() {
            Text('🎥').fontSize(24).margin({ top: 6 })
            Text('主镜头 · 夜市大街').fontSize(11).fontColor('#FFFFFF').margin({ top: 6 })
            Text('主播小拾正在逛烧烤区').fontSize(9).fontColor('#FFE08C').margin({ top: 2 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .borderRadius(12)
          .linearGradient({
            angle: 135,
            colors: [['#311B92', 0], ['#5E35B1', 1]]
          })
        }
        GridItem() {
          Column() {
            Text('🦪').fontSize(24).margin({ top: 6 })
            Text('机位 · 炭火烤生蚝').fontSize(11).fontColor('#FFFFFF').margin({ top: 6 })
            Text('蚝哥现开第 38 打').fontSize(9).fontColor('#FFE08C').margin({ top: 2 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .borderRadius(12)
          .linearGradient({
            angle: 135,
            colors: [['#FF7043', 0], ['#E64A19', 1]]
          })
        }
        GridItem() {
          Column() {
            Text('🛵').fontSize(24).margin({ top: 6 })
            Text('跑腿视角 · 小飞').fontSize(11).fontColor('#FFFFFF').margin({ top: 6 })
            Text('正在豆腐包摊位排队').fontSize(9).fontColor('#FFE08C').margin({ top: 2 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .borderRadius(12)
          .linearGradient({
            angle: 135,
            colors: [['#00897B', 0], ['#311B92', 1]]
          })
        }
        GridItem() {
          Column() {
            Text('🧋').fontSize(24).margin({ top: 6 })
            Text('机位 · 柠檬茶摊').fontSize(11).fontColor('#FFFFFF').margin({ top: 6 })
            Text('柠妹手速残影出现').fontSize(9).fontColor('#FFE08C').margin({ top: 2 })
          }
          .width('100%')
          .height('100%')
          .justifyContent(FlexAlign.Center)
          .borderRadius(12)
          .linearGradient({
            angle: 135,
            colors: [['#F57F17', 0], ['#FBC02D', 1]]
          })
        }
      }
      .columnsTemplate('1fr 1fr')
      .rowsTemplate('1fr 1fr')
      .columnsGap(8)
      .rowsGap(8)
      .height(226)
      .width('100%')
      .margin({ left: 12, right: 12, top: 10 })

      // 工具条
      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text(this.micOn ? '🎙️' : '🔇').fontSize(17)
          Text(this.micOn ? '连麦开' : '已静音').fontSize(9).fontColor(this.micOn ? '#311B92' : '#9FA8DA')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.micOn = !this.micOn
        })

        Column({ space: 3 }) {
          Text(this.lampOn ? '🏮' : '💡').fontSize(17)
          Text(this.lampOn ? '灯牌亮' : '已熄灭').fontSize(9).fontColor(this.lampOn ? '#E08A00' : '#9FA8DA')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor(this.lampOn ? '#FFF8E1' : '#FFFFFF')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.lampOn = !this.lampOn
        })

        Column({ space: 3 }) {
          Text('🏮').fontSize(17)
          Text('点单代买').fontSize(9).fontColor('#FF7043')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor('#FFFFFF')
        .justifyContent(FlexAlign.Center)
        .onClick(() => {
          this.onOrder()
        })

        Column({ space: 3 }) {
          Text('🎁').fontSize(17)
          Text('打赏摊主').fontSize(9).fontColor('#311B92')
        }
        .layoutWeight(1)
        .height(52)
        .borderRadius(14)
        .backgroundColor('#EDE9F9')
        .justifyContent(FlexAlign.Center)
      }
      .width('100%')
      .margin({ left: 12, right: 12, top: 10 })

      // 摊位速报列表
      Column() {
        Text('摊位速报').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(stallData199, (s: Stall199, i: number) => {
          if (i < 6) {
            Row({ space: 12 }) {
              Column() {
                Row({ space: 6 }) {
                  Text(s.name).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
                  Text(s.state)
                    .fontSize(9)
                    .fontColor(stallStateColor199(s.state))
                    .padding({ left: 7, right: 7, top: 2, bottom: 2 })
                    .borderRadius(9)
                    .backgroundColor('#FBFAFE')
                }
                Text('⭐ ' + s.rating + ' · 排队 ' + s.queue + ' 人 · 月销 ' + s.sales + ' 单').fontSize(10).fontColor('#8E85B0').margin({ top: 3 })
                Row({ space: 6 }) {
                  ForEach(s.tags, (t: string) => {
                    Text('# ' + t).fontSize(9).fontColor('#E08A00').padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(9).backgroundColor('#FFF8E1')
                  }, (t: string) => t)
                }
                .margin({ top: 5 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)

              Text(this.liked[i] ? '🔥' : '🤍')
                .fontSize(16)
                .onClick(() => {
                  this.liked = this.liked.map((v: boolean, j: number) => {
                    return j === i ? !v : v
                  })
                })
            }
            .width('100%')
            .padding({ top: 9, bottom: 9 })
            .borderRadius(12)
            .backgroundColor('#FFFFFF')
            .margin({ top: 7 })
          }
        }, (s: Stall199) => s.name)
      }
      .width('100%')
      .borderRadius(16)
      .backgroundColor('#F6F2FB')
      .padding(14)
      .margin({ left: 12, right: 12, top: 12 })

      // 弹幕
      Column() {
        Text('夜市弹幕').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(barrageData199, (b: Barrage199, i: number) => {
          Row({ space: 6 }) {
            Text(b.user).fontSize(10).fontColor('#8E85B0')
            Text(b.text).fontSize(11).fontColor(b.color).layoutWeight(1)
          }
          .width('100%')
          .margin({ top: 7 })
          .margin({ left: (i % 3) * 14 })
          .opacity(i % 2 === 0 ? 1 : 0.75)
        }, (b: Barrage199) => b.user)
      }
      .width('100%')
      .borderRadius(16)
      .backgroundColor('#FFFFFF')
      .padding(14)
      .margin({ left: 12, right: 12, top: 12, bottom: 20 })
    }
    .width('100%')
  }
}

// ============================ Tab 2:摊位墙 ============================

@Component
struct StallTab199 {
  @State filter: number = 0
  onDetail: (i: number) => void = () => {}

  build() {
    Column() {
      // 筛选 chips
      Row() {
        ForEach(stallFilters199, (f: string, i: number) => {
          Text(f)
            .fontSize(12)
            .fontColor(this.filter === i ? '#FFFFFF' : '#5C4DA8')
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(16)
            .backgroundColor(this.filter === i ? '#311B92' : '#FFFFFF')
            .onClick(() => {
              this.filter = i
            })
        }, (f: string) => f)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10 })

      // 摊位人气横条
      Column() {
        Text('本月摊位人气榜(单)').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(stallPop199, (p: StallPop199) => {
          Row({ space: 8 }) {
            Text(p.emoji + ' ' + p.name).fontSize(11).fontColor('#311B92').width(96)
            Row() {
              Text('')
                .width((p.sales / maxStallSale199() * 100) + '%')
                .height(10)
                .borderRadius(5)
                .linearGradient({
                  angle: 90,
                  colors: [['#FFCA28', 0], ['#FF7043', 1]]
                })
              Text('').layoutWeight(1).height(10).borderRadius(5).backgroundColor('#EAE4F5')
            }
            .layoutWeight(1)
            Text(p.sales + '').fontSize(10).fontColor('#FF7043').width(28).textAlign(TextAlign.End)
          }
          .width('100%')
          .margin({ bottom: 9 })
        }, (p: StallPop199) => p.name)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(14)
      .margin({ left: 12, right: 12, top: 10 })

      // 摊位卡片列表
      ForEach(stallData199, (s: Stall199, i: number) => {
        if (this.filter === 0 || stallFilters199[this.filter] === s.state) {
          Column() {
            Row({ space: 12 }) {
              Column() {
                Row({ space: 6 }) {
                  Text(s.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
                  Text(s.state)
                    .fontSize(9)
                    .fontColor(stallStateColor199(s.state))
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(9)
                    .backgroundColor('#FBFAFE')
                }
                Text('摊主 ' + s.owner + ' · ' + s.cat + ' · ⭐ ' + s.rating).fontSize(10).fontColor('#8E85B0').margin({ top: 4 })
                Row({ space: 6 }) {
                  ForEach(s.tags, (t: string) => {
                    Text('# ' + t).fontSize(9).fontColor('#E08A00').padding({ left: 7, right: 7, top: 3, bottom: 3 }).borderRadius(9).backgroundColor('#FFF8E1')
                  }, (t: string) => t)
                }
                .margin({ top: 6 })
                Row({ space: 10 }) {
                  Text('🔥 月销 ' + s.sales).fontSize(10).fontColor('#FF7043')
                  Text('👥 排队 ' + s.queue).fontSize(10).fontColor('#5C4DA8')
                }
                .margin({ top: 6 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')
            .onClick(() => {
              this.onDetail(i)
            })
          }
          .width('100%')
          .backgroundColor('#FFFFFF')
          .borderRadius(16)
          .padding(14)
          .margin({ left: 12, right: 12, top: 8 })
        }
      }, (s: Stall199) => s.name + this.filter)

      Text('— 点击摊位卡片查看详情与月销走势 —').fontSize(10).fontColor('#B4ABD6').margin({ top: 16, bottom: 20 })
    }
    .width('100%')
  }
}

// ============================ Tab 3:好物单 ============================

@Component
struct WishTab199 {
  @Prop wishes: Wish199[]
  @State filter: number = 0
  onNew: () => void = () => {}
  onEdit: (i: number) => void = () => {}
  onDelete: (i: number) => void = () => {}

  build() {
    Column() {
      // 顶部操作行
      Row({ space: 10 }) {
        Column() {
          Column({ space: 2 }) {
            Text(this.wishes.length + '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Text('清单条目').fontSize(9).fontColor('#9FA8DA')
          }.layoutWeight(1)
          Column({ space: 2 }) {
            Text(pendingWishCount199() + '').fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF7043')
            Text('待代买').fontSize(9).fontColor('#9FA8DA')
          }.layoutWeight(1)
          Column({ space: 2 }) {
            Text('¥ ' + wishBudget199()).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#E08A00')
            Text('预算合计').fontSize(9).fontColor('#9FA8DA')
          }.layoutWeight(1)
        }
        .layoutWeight(1)
        .height(64)
        .justifyContent(FlexAlign.Center)

        Text('📝\n加清单')
          .fontSize(12)
          .fontColor('#311B92')
          .textAlign(TextAlign.Center)
          .lineHeight(16)
          .width(72)
          .height(64)
          .borderRadius(16)
          .linearGradient({
            angle: 135,
            colors: [['#FFCA28', 0], ['#FF9800', 1]]
          })
          .onClick(() => {
            this.onNew()
          })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding({ left: 14, right: 14, top: 10, bottom: 10 })
      .margin({ left: 12, right: 12, top: 10 })

      // 筛选 chips
      Row() {
        ForEach(wishFilters199, (f: string, i: number) => {
          Text(f)
            .fontSize(12)
            .fontColor(this.filter === i ? '#FFFFFF' : '#5C4DA8')
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(16)
            .backgroundColor(this.filter === i ? '#FF7043' : '#FFFFFF')
            .onClick(() => {
              this.filter = i
            })
        }, (f: string) => f)
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 12 })

      // 清单列表
      ForEach(this.wishes, (w: Wish199, i: number) => {
        if (this.filter === 0 || wishFilters199[this.filter] === w.status) {
          Column() {
            Row({ space: 12 }) {
              Column() {
                Row({ space: 6 }) {
                  if (w.top) {
                    Text('📌').fontSize(11)
                  }
                  Text(w.item).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
                  Text(w.status)
                    .fontSize(9)
                    .fontColor(wishStateColor199(w.status))
                    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                    .borderRadius(9)
                    .backgroundColor('#FBFAFE')
                }
                Text('🛖 ' + w.stall + ' · 预算 ¥ ' + w.budget).fontSize(10).fontColor('#8E85B0').margin({ top: 4 })
                Text(w.time).fontSize(9).fontColor('#B4ABD6').margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
            }
            .width('100%')

            Row({ space: 8 }) {
              Text('✏️ 编辑')
                .fontSize(11)
                .fontColor('#5C4DA8')
                .layoutWeight(1)
                .height(32)
                .borderRadius(16)
                .textAlign(TextAlign.Center)
                .backgroundColor('#EDE9F9')
                .onClick(() => {
                  this.onEdit(i)
                })
              Text('🗑️ 移出')
                .fontSize(11)
                .fontColor('#FF7043')
                .layoutWeight(1)
                .height(32)
                .borderRadius(16)
                .textAlign(TextAlign.Center)
                .backgroundColor('#FFF8E1')
                .onClick(() => {
                  this.onDelete(i)
                })
            }
            .width('100%')
            .margin({ top: 10 })
          }
          .width('100%')
          .backgroundColor('#FFFFFF')
          .borderRadius(16)
          .padding(14)
          .margin({ left: 12, right: 12, top: 8 })
        }
      }, (w: Wish199) => w.id.toString() + w.status + this.filter)

      Text('— 清单条目保留 7 天,下单后自动归档 —').fontSize(10).fontColor('#B4ABD6').margin({ top: 16, bottom: 20 })
    }
    .width('100%')
  }
}

// ============================ Tab 4:跑腿单 ============================

@Component
struct RunTab199 {
  build() {
    Column() {
      // 汇总行
      Row({ space: 8 }) {
        Column({ space: 2 }) {
          Text(runData199.length + '').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#311B92')
          Text('今日订单').fontSize(9).fontColor('#9FA8DA')
        }.layoutWeight(1)
        Column().width(0.5).height(26).backgroundColor('#EAE4F5')
        Column({ space: 2 }) {
          Text('3').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FF7043')
          Text('进行中').fontSize(9).fontColor('#9FA8DA')
        }.layoutWeight(1)
        Column().width(0.5).height(26).backgroundColor('#EAE4F5')
        Column({ space: 2 }) {
          Text('31 分钟').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#E08A00')
          Text('平均送达').fontSize(9).fontColor('#9FA8DA')
        }.layoutWeight(1)
        Column().width(0.5).height(26).backgroundColor('#EAE4F5')
        Column({ space: 2 }) {
          Text('¥ 25').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#2E7D32')
          Text('跑腿费合计').fontSize(9).fontColor('#9FA8DA')
        }.layoutWeight(1)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding({ top: 12, bottom: 12 })
      .margin({ left: 12, right: 12, top: 10 })

      // 跑腿单列表
      ForEach(runData199, (r: Run199) => {
        Column() {
          Row() {
            Text('# ' + r.no).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#311B92')
            Text(r.state)
              .fontSize(9)
              .fontColor(runStateColor199(r.state))
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
              .borderRadius(9)
              .backgroundColor('#FBFAFE')
              .layoutWeight(1)
              .textAlign(TextAlign.End)
          }
          .width('100%')

          Row({ space: 12 }) {
            Column() {
              Text(r.items).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#311B92')
              Text('骑手 ' + r.runner + ' · 已用 ' + r.mins + ' 分钟').fontSize(10).fontColor('#8E85B0').margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column({ space: 2 }) {
              Text('¥ ' + r.fee).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#FF7043')
              Text('跑腿费').fontSize(9).fontColor('#9FA8DA')
            }
          }
          .width('100%')
          .margin({ top: 10 })

          // 进度条
          Row() {
            Text('')
              .width((r.mins / 35 * 100) + '%')
              .height(5)
              .borderRadius(3)
              .backgroundColor('#FF7043')
            Text('').layoutWeight(1).height(5).borderRadius(3).backgroundColor('#EAE4F5')
          }
          .width('100%')
          .margin({ top: 8 })
        }
        .width('100%')
        .backgroundColor('#FFFFFF')
        .borderRadius(16)
        .padding(14)
        .margin({ left: 12, right: 12, top: 8 })
      }, (r: Run199) => r.no)

      Text('— 全程直播取餐,眼见为实 —').fontSize(10).fontColor('#B4ABD6').margin({ top: 16, bottom: 20 })
    }
    .width('100%')
  }
}

// ============================ Tab 5:摊主团 ============================

@Component
struct OwnerTab199 {
  build() {
    Column() {
      // 每晚客流柱图
      Column() {
        Text('本周每晚客流(百人次)').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        Row() {
          ForEach(nightFlow199, (d: NightFlow199) => {
            Column() {
              Text(d.visitors + '').fontSize(9).fontColor('#FF7043').margin({ bottom: 3 })
              Column()
                .width(20)
                .height(d.visitors * 7)
                .borderRadius(5)
                .linearGradient({
                  angle: 180,
                  colors: [['#FFCA28', 0], ['#FF7043', 1]]
                })
              Text(d.day).fontSize(9).fontColor('#9FA8DA').margin({ top: 4 })
            }
          }, (d: NightFlow199) => d.day)
        }
        .width('100%')
        .height(260)
        .alignItems(VerticalAlign.Bottom)
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ top: 12 })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(14)
      .margin({ left: 12, right: 12, top: 10 })

      // 美食分类占比堆叠条
      Column() {
        Text('全场摊位分类构成').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        Row() {
          ForEach(catMix199, (c: CatMix199) => {
            Column().layoutWeight(c.count).height(14).backgroundColor(c.color)
          }, (c: CatMix199) => c.label)
        }
        .width('100%')
        .borderRadius(7)
        .clip(true)
        .margin({ top: 12 })

        Row() {
          ForEach(catMix199, (c: CatMix199) => {
            Row({ space: 4 }) {
              Column().width(7).height(7).borderRadius(4).backgroundColor(c.color).margin({ top: 2 })
              Text(c.label + ' ' + c.count + '摊').fontSize(9).fontColor('#8E85B0')
            }
            .margin({ right: 10 })
            .alignItems(VerticalAlign.Top)
          }, (c: CatMix199) => c.label)
        }
        .width('100%')
        .margin({ top: 10 })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(14)
      .margin({ left: 12, right: 12, top: 10 })

      // 摊主口碑榜
      Column() {
        Text('摊主口碑榜').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(stallData199, (s: Stall199, i: number) => {
          if (i < 6) {
            Row({ space: 10 }) {
              Text((i + 1) + '')
                .fontSize(11)
                .fontWeight(FontWeight.Bold)
                .fontColor(i < 3 ? '#FFFFFF' : '#8E85B0')
                .width(20)
                .height(20)
                .borderRadius(10)
                .textAlign(TextAlign.Center)
                .backgroundColor(i < 3 ? '#FF7043' : '#EDE9F9')
              Text(s.emoji + ' ' + s.owner).fontSize(12).fontColor('#311B92').layoutWeight(1)
              Text('⭐ ' + s.rating).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#E08A00')
              Text(s.cat).fontSize(10).fontColor('#8E85B0')
            }
            .width('100%')
            .padding({ top: 8, bottom: 8 })
          }
        }, (s: Stall199) => s.owner)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(14)
      .margin({ left: 12, right: 12, top: 12, bottom: 20 })
    }
    .width('100%')
  }
}

// ============================ Tab 6:我的 ============================

@Component
struct MineTab199 {
  @State notifyStall: boolean = true
  @State notifyWish: boolean = true
  @State nightMode: boolean = false

  build() {
    Column() {
      // 个人卡片
      Row({ space: 14 }) {
        Column() {
          Row({ space: 6 }) {
            Text('夜宵课代表').fontSize(16).fontWeight(FontWeight.Bold).fontColor('#FFFFFF')
            Text('金胃王').fontSize(9).fontColor('#311B92').padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(10).backgroundColor('#FFCA28')
          }
          Text('云逛 32 晚 · 钟情烧烤与糖水').fontSize(11).fontColor('#FFE08C').margin({ top: 5 })
          Row({ space: 10 }) {
            Text('下单 58 单').fontSize(10).fontColor('#FFE08C')
            Text('好评率 100%').fontSize(10).fontColor('#FFE08C')
          }
          .margin({ top: 5 })
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .padding(16)
      .borderRadius(18)
      .linearGradient({
        angle: 120,
        colors: [['#311B92', 0], ['#FF7043', 1]]
      })
      .margin({ left: 12, right: 12, top: 10 })

      // 消费记录
      Column() {
        Text('我的消费记录').fontSize(14).fontWeight(FontWeight.Bold).fontColor('#311B92')
        ForEach(buyLogData199, (b: BuyLog199) => {
          Row({ space: 12 }) {
            Column() {
              Text(b.item).fontSize(13).fontWeight(FontWeight.Bold).fontColor('#311B92')
              Text(b.result).fontSize(10).fontColor(b.result.indexOf('取消') >= 0 ? '#9E9E9E' : '#2E7D32').margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column({ space: 2 }) {
              Text('¥ ' + b.price).fontSize(11).fontWeight(FontWeight.Bold).fontColor('#FF7043')
              Text(b.date).fontSize(9).fontColor('#9FA8DA')
            }
          }
          .width('100%')
          .padding({ top: 9, bottom: 9 })
          .borderRadius(12)
          .backgroundColor('#FBFAFE')
          .margin({ top: 7 })
        }, (b: BuyLog199) => b.item)
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding(14)
      .margin({ left: 12, right: 12, top: 12 })

      // 设置开关
      Column() {
        Row() {
          Column() {
            Text('常逛摊位出摊提醒').fontSize(13).fontColor('#311B92')
            Text('收藏摊位开火即推送').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.notifyStall })
            .selectedColor('#FF7043')
            .onChange((v: boolean) => {
              this.notifyStall = v
            })
        }
        .width('100%')
        .padding({ top: 13, bottom: 13 })

        Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

        Row() {
          Column() {
            Text('清单逛到提醒').fontSize(13).fontColor('#311B92')
            Text('镜头切到想吃分类时通知').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.notifyWish })
            .selectedColor('#FFCA28')
            .onChange((v: boolean) => {
              this.notifyWish = v
            })
        }
        .width('100%')
        .padding({ top: 13, bottom: 13 })

        Column().width('100%').height(0.5).backgroundColor('#EAE4F5')

        Row() {
          Column() {
            Text('深夜模式(23:00 后禁下单)').fontSize(13).fontColor('#311B92')
            Text('管住嘴,从源头做起').fontSize(10).fontColor('#9FA8DA').margin({ top: 2 })
          }.alignItems(HorizontalAlign.Start).layoutWeight(1)
          Toggle({ type: ToggleType.Switch, isOn: this.nightMode })
            .selectedColor('#311B92')
            .onChange((v: boolean) => {
              this.nightMode = v
            })
        }
        .width('100%')
        .padding({ top: 13, bottom: 13 })
      }
      .width('100%')
      .backgroundColor('#FFFFFF')
      .borderRadius(16)
      .padding({ left: 14, right: 14 })
      .margin({ left: 12, right: 12, top: 12 })

      Text('拾贝 · 云夜市 v2.0.1 · 人间烟火气').fontSize(10).fontColor('#B4ABD6').margin({ top: 16, bottom: 24 })
    }
    .width('100%')
  }
}


总结

在这里插入图片描述

回头看这9天的开发过程,最深刻的体会是:ArkTS声明式UI的开发效率确实比命令式高很多。bindSheet/bindContentCover省去了弹框生命周期管理的大量样板代码,@State+@Prop+回调的模式让数据流清晰可追踪,layoutWeight让图表绘制变得异常简洁。但效率的提升有一个前提——你必须在动手写代码前把数据模型和组件层级想清楚。声明式UI的好处在于"描述即结果",但如果描述本身就是混乱的,效率提升就无从谈起。

第二个体会是关于"假数据"的价值。这次开发我花了整整一天填静态数据,当时觉得浪费时间,但后面写UI时假数据的"真实感"反复帮我发现了布局问题——比如弹幕太短时Row的layoutWeight会把文字拉到右边、摊位标签太多时会溢出卡片边界。如果用"test1"“test2"这种数据,这些问题要到联调时才会暴露。所以假数据不是"占位”,是开发过程中最早的"UI测试用例"。

第三个体会是图表不必依赖第三方库。这个应用里有柱图、横条、堆叠条、进度条四种可视化形态,全部用Column和Row的原生布局属性实现。关键在于理解ArkTS布局引擎的特性:layoutWeight是按权重分配空间的利器,VerticalAlign.Bottom是柱图对齐的关键,clip(true)是堆叠条圆角裁切的必需品。一旦掌握了这几个属性的组合,大部分移动端信息图表都能用原生方式实现,而且性能和兼容性都优于第三方方案。当然如果需要折线图、饼图等复杂图形,原生方式就不合适了,那是另一个话题。

Logo

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

更多推荐