HarmonyOS 6效果实现:在ArkTS的Row布局里百分比高度的表现不如固定vp值稳定
开发一款"夜市摊主连麦代逛代买直播间"应用,听起来像是把直播电商和跑腿外卖揉在一起。但真正动手写的时候才发现,难点不在于功能多,而在于信息密度——一个页面要同时呈现四个机位画面、摊位速报、弹幕、工具条,还要让用户能点单、能管理好物清单、能看客流战报。如何在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分别用了三种颜色(烟火橙/灯串黄/夜幕紫),和三个开关的功能含义对应:出摊提醒是"行动"类用橙、清单提醒是"注意"类用黄、深夜模式是"限制"类用紫。这种颜色语义化虽然用户不一定能感知到,但作为开发者的审美自觉,值得坚持。
完整数据流架构
这张图展示了整个应用的状态流。核心数据wishes数组在父组件Index199中维护,通过@Prop单向传递给WishTab199子组件。子组件通过回调函数(onNew/onEdit/onDelete)通知父组件打开对应弹框,弹框中的保存/删除操作通过map/filter更新wishes数组,@Prop会自动将新数据传递给子组件,触发列表刷新。这个"数据下传+回调上传"的模式是ArkTS组件间通信的标准范式。
技术决策对比
| 决策点 | 选择方案 | 放弃的方案 | 选择理由 |
|---|---|---|---|
| 弹框模式 | bindSheet+bindContentCover | 自定义绝对定位弹框 | 原生支持手势关闭,无需手动管理z-index |
| Tab导航 | 灯串挂牌式自定义Row | TabBar组件 | 需要挂绳+灯牌+阴影的视觉效果,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)是堆叠条圆角裁切的必需品。一旦掌握了这几个属性的组合,大部分移动端信息图表都能用原生方式实现,而且性能和兼容性都优于第三方方案。当然如果需要折线图、饼图等复杂图形,原生方式就不合适了,那是另一个话题。
更多推荐

所有评论(0)