HarmonyOS 6.1 实战:当curTab变化时,框架销毁旧条件分支的组件树,创建新条件分支的组件树。每个子页面组件通过参数接收回调函数
在HarmonyOS ArkTS的技术博物馆中,每一个
@Entry入口组件都是一件镇馆之宝,它凝结着开发者对声明式UI范式的深刻理解。当我们站在展柜前凝视这些代码,看到的不仅是语法结构,更是一套完整的响应式编程思想——状态驱动渲染、组件化拆分、单向数据流——这些技术理念如同博物馆中的珍贵馆藏,值得每一位置身其中的参观者驻足品鉴。
本期策展以一款精品咖啡烘焙订阅工坊应用为馆藏核心,带领各位访客沿着"色彩馆藏—数据展品—状态管理—页面布展—弹窗交互"的参观动线,逐一拆解其技术脉络。我们将以博物馆导览员的视角,使用"展品"“馆藏”"溯源"等文博术语,为每一件代码展品撰写详尽的展签说明。
在这条参观路线上,您将看到@State如何像恒温恒湿系统一样维护状态的稳定性,ForEach如何像陈列师一样将数据展品有序排列,bindSheet与bindContentCover如何像展柜玻璃一样隔而不离地呈现交互内容。让我们开启这场代码溯源之旅。

展览前言与技术背景
欢迎各位访客来到HarmonyOS ArkTS移动应用技术专题展。本次展览的馆藏核心,是一款面向精品咖啡爱好者的烘焙订阅工坊应用。该应用集成了生豆直采豆单、烘焙记录管理、冲煮方法库、订阅定制服务、庄园口碑排行和个人咖啡护照六大展品区域,通过双排Tab导航(上排"豆单/烘焙/冲煮",下排"订阅/庄园/我的")组织全部功能模块。作为基于HarmonyOS API 24开发的原生应用,它充分运用了ArkTS声明式UI的全部核心能力。
从技术策展的角度来看,这款应用的核心架构采用了"入口组件集中管理状态+子组件分散渲染视图"的设计模式。入口组件CoffeeMain173被@Entry装饰器标记为页面入口,内部维护了7个@State状态变量,涵盖Tab切换状态(curTab)和6个弹窗控制状态。入口组件通过条件渲染(if/else if/else)在6个子页面之间切换,每个子页面都是独立的@Component,通过回调函数与入口组件进行数据交换。这种模式类似于博物馆的总控中心与各分展馆的关系——总控中心负责调度展览内容和控制灯光(状态管理),各分展馆则独立布置展品(UI渲染)。
在馆藏数据层面,应用定义了7组强类型数据接口(BeanItem173、RoastBatch173、BrewMethod173、GearItem173、EstateItem173、SubHistory173、OrderItem173),每组接口对应一个业务实体。这些接口通过模块级别的常量数组进行初始化,形成了一套完整的静态馆藏体系。此外还定义了FlavorItem173(风味标签)和OriginStat173(产地分布)两组辅助数据,用于数据可视化展示。5个纯函数(roastColor173、scoreC173、scoreBar173、lossH173、processC173)承担了"数据展品修复师"的角色,将原始数值转换为颜色值、百分比等UI可渲染的格式。
第一展区:色彩馆藏与数据建模
展品说明
interface ColorPalette173 {
coffee: string;
coffeeDeep: string;
milk: string;
milkDeep: string;
caramel: string;
bg: string;
cardBg: string;
textMain: string;
textSub: string;
textHint: string;
border: string;
danger: string;
white: string;
green: string;
gold: string;
}
const COLORS173: ColorPalette173 = {
coffee: '#4E342E',
coffeeDeep: '#3E2723',
milk: '#D7CCC8',
milkDeep: '#BCAAA4',
caramel: '#E64A19',
bg: '#FBF7F4',
cardBg: '#FFFFFF',
textMain: '#3E2723',
textSub: '#8D6E63',
textHint: '#BCAAA4',
border: '#EFE5E0',
danger: '#D84315',
white: '#FFFFFF',
green: '#2E7D32',
gold: '#E65100'
};
溯源解读

各位访客,现在我们面前的是色彩馆藏展柜。interface是TypeScript的核心语法,用于定义对象的类型契约。在HarmonyOS ArkTS中,接口被广泛用于定义数据模型,确保所有数据实例遵循统一的字段规范。ColorPalette173接口定义了15个字符串类型的色彩属性,从主色调coffee(咖啡棕#4E342E)到辅色调caramel(焦糖橙#E64A19),构成了整套应用视觉体系的基础色板。
const COLORS173: ColorPalette173 = {...}这行代码创建了一个实现该接口的常量对象。类型注解: ColorPalette173确保了编译器在编译阶段检查所有15个属性是否齐全。如果遗漏任何一个,TypeScript编译器会立即报错,这就像博物馆的藏品入馆登记——每一件藏品必须登记完整信息方可入藏。色彩值使用6位十六进制RGB格式(如#4E342E),这是Web端和移动端通用的颜色编码方式,ArkTS完全兼容。
继续参观数据模型展区,我们将看到多组业务实体的接口定义:
interface BeanItem173 {
name: string;
origin: string;
icon: string;
process: string;
price: number;
notes: string;
score: number;
tag: string;
}
interface RoastBatch173 {
batch: string;
bean: string;
level: string;
loss: number;
date: string;
score: number;
}
interface BrewMethod173 {
name: string;
icon: string;
ratio: string;
mins: number;
grind: string;
temp: string;
}
interface EstateItem173 {
name: string;
country: string;
icon: string;
score: number;
alt: string;
var: string;
}
interface FlavorItem173 {
name: string;
pct: number;
color: string;
}
interface OriginStat173 {
region: string;
pct: number;
color: string;
}
馆藏分析

上述六个接口分别定义了咖啡生豆、烘焙批次、冲煮方法、庄园信息、风味标签和产地分布的数据结构。BeanItem173是最丰富的展品,包含名称、产地、处理法、价格、风味描述、杯测评分和标签7个字段,每一个字段都承载着咖啡文化的信息。RoastBatch173记录了烘焙批次的编号、豆名、烘焙度、失重比、日期和评分,构成了烘焙档案的核心。BrewMethod173定义了冲煮方法的粉水比、时长、研磨度和水温,是冲煮参数的标准化记录。
在数据建模中,字段类型的选择体现了业务理解。
score使用number类型而非string,是为了后续的数值比较(如score >= 90判断高分);notes使用string类型存储风味描述,因为它是展示文本而非计算数据。这种"计算用数字、展示用文本"的设计原则,是数据建模的基本素养。
FlavorItem173和OriginStat173是两组特殊的数据接口,它们专门服务于数据可视化。FlavorItem173包含名称、百分比和颜色三个字段,用于风味雷达图或风味条的渲染。OriginStat173包含产区名称、百分比和颜色,用于产地分布的横向比例条。这种"数据+颜色"的接口设计,使得可视化组件可以直接使用接口数据渲染,无需额外的颜色计算逻辑。
第二展区:辅助函数——数据展品修复室

展品说明
function roastColor173(lv: string): string {
if (lv === '浅烘') {
return '#C58A5A';
}
if (lv === '中烘') {
return '#8D6E63';
}
if (lv === '中深烘') {
return '#5D4037';
}
return '#3E2723';
}
function scoreC173(s: number): string {
if (s >= 90) {
return COLORS173.gold;
}
if (s >= 85) {
return COLORS173.caramel;
}
return COLORS173.textSub;
}
function scoreBar173(s: number): string {
return (s - 70) + '%';
}
function lossH173(l: number): string {
return (l * 5) + '%';
}
function processC173(p: string): string {
if (p === '水洗') {
return '#0277BD';
}
if (p === '日晒') {
return '#E65100';
}
if (p === '厌氧日晒') {
return '#8E24AA';
}
return '#2E7D32';
}
溯源解读
各位访客,现在我们进入了"数据展品修复室"。这里陈列着5个纯函数,它们的作用是将原始数值或文本转换为UI可以使用的颜色值或百分比字符串。在博物馆的语境中,这些函数就像是"展品修复师"——它们不创造新的展品(不产生副作用),而是对已有的展品进行加工处理,使其适合在展柜中展示。
roastColor173函数接收烘焙度字符串,返回对应的颜色值。浅烘返回浅棕色(#C58A5A),中烘返回中棕色(#8D6E63),中深烘返回深棕色(#5D4037),深烘返回最深棕色(#3E2723)。这套颜色梯度模拟了咖啡豆在不同烘焙度下的实际颜色变化,使色彩本身就成为了信息传递的载体。在展柜中,用户只需看一眼色块的颜色深浅,就能直观感知烘焙度的高低。
纯函数的核心特征是"相同输入永远产生相同输出",不依赖外部状态、不修改全局变量。在ArkTS声明式UI中,纯函数被广泛用于数据格式转换,因为其输出可预测、可缓存,框架可以安全地在渲染过程中调用而无需担心副作用。
scoreC173和scoreBar173两个函数共同服务于杯测评分的展示。scoreC173将评分数值映射为颜色:90分以上使用金色(gold),85分以上使用焦糖橙(caramel),85分以下使用次级文本色(textSub)。scoreBar173将评分转换为进度条宽度百分比:(s - 70) + '%',即减去70分基数后取百分比。这种"减基数"的设计使得70分的评分对应0%的进度条,90分对应20%,92分对应22%,在视觉上更合理。
processC173函数将处理法映射为颜色:水洗返回蓝色(#0277BD,代表清洁),日晒返回橙色(#E65100,代表阳光),厌氧日晒返回紫色(#8E24AA,代表发酵),其他返回绿色(#2E7D32,代表自然)。这种"处理法→颜色"的映射,使每种处理法都有独特的视觉标识,在展柜中一目了然。
第三展区:入口组件——镇馆之宝

展品说明
@Entry
@Component
struct CoffeeMain173 {
@State curTab: string = 'beans';
@State showBuy: boolean = false;
@State showSub: boolean = false;
@State showRoastEdit: boolean = false;
@State showCancelSub: boolean = false;
@State showDetail: boolean = false;
@State buyName: string = '耶加雪菲·科契尔';
@State roastBatch: string = 'R-0825-01';
@State detailName: string = '耶加雪菲·科契尔';
@State detailIcon: string = '🫘';
@Builder
pageHeader() {
Column() {
Row() {
Text('☕')
.fontSize(22)
Text('多多精品咖啡')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
.margin({ left: 6 })
Text('烘焙坊直发')
.fontSize(10)
.fontColor(COLORS173.coffeeDeep)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(9)
.backgroundColor('#EFB76A')
.margin({ left: 10 })
Text('')
.layoutWeight(1)
Text('🔍')
.fontSize(17)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.borderRadius(17)
.backgroundColor('#33FFFFFF')
.margin({ right: 8 })
Text('🛒')
.fontSize(17)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.borderRadius(17)
.backgroundColor('#33FFFFFF')
}
.width('100%')
.margin({ top: 8 })
Row() {
Text('🔥 今日鲜烘 · 下单后 48h 内出炉')
.fontSize(12)
.fontColor('#FFFFFFD9')
Text('')
.layoutWeight(1)
Text('满 199 减 30')
.fontSize(11)
.fontColor(COLORS173.caramel)
.fontWeight(FontWeight.Bold)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(11)
.backgroundColor('#FFFFFF')
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 14 })
.linearGradient({
angle: 135,
colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]
})
}
溯源解读

各位访客,现在我们来到的是镇馆之宝展柜——入口组件CoffeeMain173。首先映入眼帘的是@Entry装饰器。@Entry是ArkTS的入口装饰器,标记该组件为页面入口组件。在整个源文件中,只能有一个被@Entry装饰的struct,它代表整个页面的根节点,会被注册到路由系统中。@Entry与@Component通常成对出现——@Component装饰器用于声明一个自定义组件,它告诉编译器这个struct是一个参与声明式UI渲染的UI组件。
进入组件内部,我们看到了10个@State状态变量。@State是ArkTS的状态管理装饰器,当被@State修饰的变量值发生变化时,框架会自动触发UI重新渲染,将最新数据反映到界面上。这10个变量可以分为三类:curTab管理当前选中的Tab页;showBuy、showSub、showRoastEdit、showCancelSub、showDetail是5个弹窗显隐控制布尔值;buyName、roastBatch、detailName、detailIcon是4个弹窗上下文数据变量。这种将所有状态集中在根组件管理的模式,就像博物馆总控中心统一管理各展馆的灯光开关——当任何一个开关(@State变量)变化时,对应的展馆(UI组件)会自动调整展示内容。
在ArkTS的响应式系统中,@State的更新是细粒度的——框架会精确追踪哪些UI部分依赖该变量,只更新受影响的部分,而非全量重渲染。这种"精准灯光调控"机制是ArkTS性能优势的核心来源之一。
@Builder装饰器出现在pageHeader方法上方。@Builder用于定义可复用的UI构建函数。被@Builder装饰的方法返回一段声明式UI代码,可以在组件的build方法中通过this.xxx()的方式调用。在这里,pageHeader方法构建了应用顶部头部区域,包含咖啡杯图标、品牌名称、"烘焙坊直发"标签、搜索和购物车图标。
在UI组件层面,Column是ArkUI提供的纵向线性布局容器,子元素按照垂直方向从上到下排列。Row是横向线性布局容器,子元素按照水平方向从左到右排列。Text组件用于显示文本内容,通过链式调用设置fontSize(字体大小)、fontColor(字体颜色)、fontWeight(字体粗细)、padding(内边距)、borderRadius(圆角)等样式。
linearGradient属性设置了组件的线性渐变背景,angle: 135指定135度对角渐变方向,colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]从咖啡棕渐变到深咖啡棕,营造出浓郁沉稳的视觉效果。这与农场应用的绿色渐变形成了鲜明对比——色彩馆藏的选择直接决定了应用的主题氛围。
第四展区:双排Tab导航——展厅导览系统
展品说明
@Builder
tabRows() {
Column() {
Row() {
ForEach(TABS173, (t: TabItem173) => {
if (t.row === 1) {
Column() {
Text(t.icon)
.fontSize(16)
Text(t.label)
.fontSize(12)
.fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
.onClick(() => {
this.curTab = t.key;
})
}
}, (t: TabItem173) => t.key)
}
Row() {
ForEach(TABS173, (t: TabItem173) => {
if (t.row === 2) {
Column() {
Text(t.icon)
.fontSize(16)
Text(t.label)
.fontSize(12)
.fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
.onClick(() => {
this.curTab = t.key;
})
}
}, (t: TabItem173) => t.key)
}
}
.width('100%')
.padding({ left: 12, right: 12 })
.margin({ top: 10 })
}
@Builder
contentArea() {
Column() {
if (this.curTab === 'beans') {
BeansTab173({
onBuy: (n: string): void => {
this.buyName = n;
this.showBuy = true;
},
onDetail: (n: string, ic: string): void => {
this.detailName = n;
this.detailIcon = ic;
this.showDetail = true;
}
})
} else if (this.curTab === 'roast') {
RoastTab173({
onEdit: (n: string): void => {
this.roastBatch = n;
this.showRoastEdit = true;
}
})
} else if (this.curTab === 'brew') {
BrewTab173({
onBuy: (n: string): void => {
this.buyName = n;
this.showBuy = true;
}
})
} else if (this.curTab === 'sub') {
SubTab173({
onCustom: (): void => {
this.showSub = true;
},
onCancelSub: (): void => {
this.showCancelSub = true;
}
})
} else if (this.curTab === 'estate') {
EstateTab173({
onDetail: (n: string, ic: string): void => {
this.detailName = n;
this.detailIcon = ic;
this.showDetail = true;
}
})
} else {
MineTab173({
onDetail: (n: string, ic: string): void => {
this.detailName = n;
this.detailIcon = ic;
this.showDetail = true;
}
})
}
}
.width('100%')
.layoutWeight(1)
}
溯源解读
各位访客,这是本次展览中最具特色的"双排Tab导航"展品。与常规单排Tab不同,这款应用将6个Tab项分为上下两排:上排展示"豆单/烘焙/冲煮"三个核心功能,下排展示"订阅/庄园/我的"三个辅助功能。这种双排设计通过TabItem173接口中的row字段实现——row: 1的Tab在上排渲染,row: 2的Tab在下排渲染。
ForEach是ArkUI提供的循环渲染组件,用于根据数据列表生成UI组件。它接收三个参数:数据源数组、子项生成函数和键值生成函数。在这里,ForEach遍历TABS173数组,内部使用if (t.row === 1)和if (t.row === 2)进行条件过滤,分别在上下两排渲染对应的Tab项。ForEach的键值生成函数返回t.key作为唯一标识,框架通过键值进行增量更新——当Tab选中状态变化时,只需更新相关Tab项的样式,而非重建整个Tab栏。
ForEach的键值生成函数(keyGenerator)是其性能优化的关键。就像博物馆的藏品编号系统,每件展品都有唯一编号,当展品调整位置时,工作人员只需对编号进行增删,而非全部重新编号。ForEach的键值机制同样如此——框架通过键值判断哪些项需要新增、删除或更新。
Tab项的选中态通过三元运算符实现:this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal。被选中的Tab文字加粗且使用焦糖橙色(COLORS173.caramel),背景色为浅橙色(#FBE9E7);未选中的Tab使用普通字重和次级文本色,背景为白色。当用户点击Tab时,onClick回调将curTab设置为该Tab的key值,由于curTab是@State变量,框架自动触发UI重新渲染。
在contentArea构建方法中,if/else if/else条件渲染根据curTab的值切换不同的页面组件。在ArkTS中,if/else用于在build方法或@Builder方法内部根据条件渲染不同的UI组件树。当curTab变化时,框架销毁旧条件分支的组件树,创建新条件分支的组件树。每个子页面组件通过参数接收回调函数:例如BeansTab173接收onBuy和onDetail两个回调,当子组件内部的"下单烘焙"按钮被点击时,调用onBuy(b.name),触发父组件更新buyName和showBuy状态,弹出购买弹窗。
第五展区:bindSheet与bindContentCover——交互展柜系统
展品说明
@Builder
buySheet() {
BeanBuySheet173({
beanName: this.buyName,
onClose: (): void => {
this.showBuy = false;
}
})
}
@Builder
subSheet() {
SubSheet173({
onClose: (): void => {
this.showSub = false;
}
})
}
@Builder
roastEditSheet() {
RoastEditSheet173({
batchName: this.roastBatch,
onClose: (): void => {
this.showRoastEdit = false;
}
})
}
@Builder
cancelSubDialog() {
CancelSubDialog173({
onCancel: (): void => {
this.showCancelSub = false;
},
onConfirm: (): void => {
this.showCancelSub = false;
}
})
}
@Builder
detailDialog() {
BeanDetailDialog173({
beanName: this.detailName,
beanIcon: this.detailIcon,
onClose: (): void => {
this.showDetail = false;
}
})
}
build() {
Column() {
this.pageHeader()
this.tabRows()
this.contentArea()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS173.bg)
.bindSheet($$this.showBuy, this.buySheet(), {
height: 620,
dragBar: true,
showClose: true,
backgroundColor: COLORS173.cardBg
})
.bindSheet($$this.showSub, this.subSheet(), {
height: 560,
dragBar: true,
showClose: true,
backgroundColor: COLORS173.cardBg
})
.bindSheet($$this.showRoastEdit, this.roastEditSheet(), {
height: 500,
dragBar: true,
showClose: true,
backgroundColor: COLORS173.cardBg
})
.bindContentCover($$this.showCancelSub, this.cancelSubDialog(), {
})
.bindContentCover($$this.showDetail, this.detailDialog(), {
})
}
溯源解读
各位访客,这里展示的是应用的"交互展柜系统"——两种弹窗绑定机制。bindSheet是ArkUI提供的方法,用于将一个半模态底部弹窗绑定到组件上。它使用$$双向绑定语法将布尔类型的@State变量与弹窗的显隐状态绑定。$$语法是ArkTS特有的双向绑定标记,确保状态变量与UI状态之间保持同步。当绑定的变量为true时弹窗弹出,为false时弹窗收起。
bindSheet的配置参数中,height指定弹窗高度(如620像素),dragBar: true显示顶部拖拽条,showClose: true显示关闭按钮,backgroundColor设置弹窗背景色。本应用绑定了3个bindSheet弹窗:购买弹窗(620高度)、订阅弹窗(560高度)和烘焙编辑弹窗(500高度),分别用于不同的表单输入场景。半模态弹窗就像博物馆中可以拉开的抽屉式展柜——用户可以同时看到展柜下方的背景内容,交互压力较低。
bindSheet和bindContentCover的展柜隐喻:bindSheet是"抽屉式展柜",从底部拉出,覆盖部分屏幕;bindContentCover是"全幅展柜",从中央展开,覆盖全屏。选择哪种展柜取决于展品的重要程度和用户注意力的聚焦需求。
bindContentCover是另一种弹窗绑定方法,用于全模态覆盖弹窗。与bindSheet的半模态不同,bindContentCover覆盖整个屏幕。本应用绑定了2个bindContentCover弹窗:退订确认弹窗和商品详情弹窗。退订确认属于需要用户明确决策的危险操作,全屏覆盖确保用户充分关注;商品详情包含大量信息(风味谱、冲煮建议、评价等),全屏展示提供更大的信息容量。
每个弹窗构建方法返回一个自定义组件实例,通过参数传递将上下文数据(如buyName、detailName)和回调函数(如onClose)注入弹窗组件。这种设计使得弹窗组件完全独立于父组件,只需接收数据并通过回调通知结果,实现了组件的解耦。
第六展区:豆单页面——产地分布与生豆图鉴
展品说明
@Component
struct BeansTab173 {
onBuy: (n: string) => void = () => {};
onDetail: (n: string, ic: string) => void = () => {};
@Builder
originCard() {
Column() {
Row() {
Text('🌍 在售豆仓产地分布')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('共 42 款生豆')
.fontSize(10)
.fontColor(COLORS173.textHint)
}
.width('100%')
Row() {
ForEach(ORIGINS173, (o: OriginStat173) => {
Text('')
.width(o.pct + '%')
.height(16)
.backgroundColor(o.color)
}, (o: OriginStat173) => o.region)
}
.width('100%')
.height(16)
.borderRadius(8)
.clip(true)
.margin({ top: 10 })
Row() {
ForEach(ORIGINS173, (o: OriginStat173) => {
Row() {
Text('')
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(o.color)
Text(o.region + ' ' + o.pct + '%')
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ left: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Start)
}, (o: OriginStat173) => 'lg' + o.region)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(16)
.margin({ top: 12 })
}
build() {
Scroll() {
Column() {
this.originCard()
Row() {
Text('☕ 生豆直采豆单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('按杯测分排序')
.fontSize(11)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(BEANS173, (b: BeanItem173) => {
Row() {
Text(b.icon)
.fontSize(26)
.width(60)
.height(60)
.textAlign(TextAlign.Center)
.borderRadius(14)
.backgroundColor('#F5EDE7')
Column() {
Row() {
Text(b.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text(b.process)
.fontSize(9)
.fontColor(COLORS173.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(7)
.backgroundColor(processC173(b.process))
.margin({ left: 6 })
}
Text(b.origin + ' · ' + b.notes)
.fontSize(11)
.fontColor(COLORS173.textSub)
.margin({ top: 4 })
Row() {
Text('杯测 ' + b.score)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(scoreC173(b.score))
Text(b.tag)
.fontSize(9)
.fontColor(COLORS173.caramel)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(7)
.backgroundColor('#FBE9E7')
.margin({ left: 8 })
Text('')
.layoutWeight(1)
Text('¥' + b.price + '/200g')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
Text('下单烘焙')
.fontSize(11)
.fontColor(COLORS173.white)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(11)
.backgroundColor(COLORS173.coffee)
.margin({ left: 10 })
.onClick(() => {
this.onBuy(b.name);
})
}
.width('100%')
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS173.cardBg)
.borderRadius(14)
.margin({ top: 10 })
.onClick(() => {
this.onDetail(b.name, b.icon);
})
}, (b: BeanItem173) => b.name)
}
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
溯源解读
各位访客,现在我们进入的是豆单展区——这里陈列着12款精选生豆,每款都有完整的产地、处理法、风味描述和杯测评分。BeansTab173是一个被@Component装饰的自定义组件,定义了两个成员变量onBuy和onDetail作为回调函数接口。父组件在引用该子组件时注入具体的回调实现,子组件在按钮点击时调用这些回调,实现数据向父组件的回传。
产地分布卡片originCard是本展区最具技术特色的展品。横向比例条通过ForEach渲染ORIGINS173数组的4个产地数据,每个Text('')空文本组件的宽度设置为o.pct + '%'(如"38%"),背景色为该产地对应的颜色。4个色块紧密排列在同一Row容器中,通过clip(true)裁剪为圆角,形成完整的比例条。下方的图例区域同样使用ForEach渲染,每个图例项包含一个小色块和产地名称百分比文本。这种"比例条+图例"的数据可视化模式,是移动端展示分布数据的经典方案。
在ArkTS中,
Text('')空文本组件是最轻量的"色块"实现方式——它不渲染任何文字内容,只保留尺寸和背景属性,可用于构建进度条、比例条、色块标记等视觉元素。这种"以Text代色块"的技巧在声明式UI中被广泛使用。
生豆列表中,每个豆子卡片的处理法标签通过processC173(b.process)函数获取颜色——水洗为蓝色、日晒为橙色、厌氧日晒为紫色等。这种"处理法→颜色"的映射使每张卡片在视觉上都能快速区分处理工艺。杯测评分的文字颜色通过scoreC173(b.score)函数获取——90分以上金色、85分以上焦糖橙、85分以下次级灰色。两种颜色函数的组合使用,使每张豆子卡片的信息层次分明。
maxLines和textOverflow属性在豆子卡片中没有直接使用,但b.origin + ' · ' + b.notes这种字符串拼接展示了ArkTS中处理复合文本的方式。通过·分隔符连接产地和风味描述,在单个Text组件中展示多段信息,减少了组件嵌套层级。
第七展区:烘焙记录与失重比柱状图
展品说明
@Component
struct RoastTab173 {
onEdit: (n: string) => void = () => {};
@Builder
levelCard() {
Column() {
Text('🔥 烘焙度谱系')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
Row() {
ForEach(['浅烘', '中烘', '中深烘', '深烘'], (lv: string) => {
Column() {
Text('')
.width('100%')
.height(34)
.borderRadius(10)
.backgroundColor(roastColor173(lv))
Text(lv)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.margin({ top: 5 })
Text('花果酸香')
.fontSize(9)
.fontColor(COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.margin({ left: 4, right: 4 })
}, (lv: string) => lv)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(16)
.margin({ top: 12 })
}
@Builder
lossCard() {
Column() {
Row() {
Text('📉 近期批次失重比')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('失重越高越深烘')
.fontSize(10)
.fontColor(COLORS173.textHint)
}
.width('100%')
Row() {
ForEach(ROASTS173, (r: RoastBatch173, idx: number) => {
if (idx < 6) {
Column() {
Column() {
Text('')
.width('100%')
.height(lossH173(r.loss))
.borderRadius({ topLeft: 5, topRight: 5 })
.backgroundColor(roastColor173(r.level))
}
.width('70%')
.height(100)
.justifyContent(FlexAlign.End)
.backgroundColor('#F5EDE7')
.borderRadius({ topLeft: 5, topRight: 5 })
.clip(true)
Text(r.loss + '%')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.margin({ top: 4 })
Text(r.batch.slice(3, 8))
.fontSize(9)
.fontColor(COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
}, (r: RoastBatch173) => 'loss' + r.batch)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(16)
.margin({ top: 12 })
}
}
溯源解读
各位访客,这是烘焙展区中最引人注目的两件数据可视化展品。烘焙度谱系卡片levelCard通过ForEach渲染4个烘焙度色块,每个色块是一个Text('')空文本组件,宽度100%高度34像素,背景色通过roastColor173(lv)函数获取。4个色块从浅到深排列,配合下方的烘焙度名称和风味描述,形成了一套完整的"烘焙度色谱"。
失重比柱状图卡片lossCard是更复杂的可视化展品。每个柱子由外层Column(宽70%高100像素,作为柱状图容器,背景色为浅咖啡色#F5EDE7)和内层Column(包含一个Text('')空文本,高度通过lossH173(r.loss)计算)组成。justifyContent(FlexAlign.End)使内层柱子从底部向上生长,模拟柱状图的视觉效果。borderRadius({ topLeft: 5, topRight: 5 })只设置顶部圆角,clip(true)裁剪超出外层容器圆角的部分。
lossH173函数将失重比数值乘以5转换为百分比高度:12%失重 → 60%高度,19%失重 → 95%高度。这种"线性映射"的函数设计简洁有效,将数值差异直接转化为视觉高度差异。柱子的颜色通过roastColor173(r.level)获取,使每个柱子不仅高度不同,颜色也不同——深烘的柱子颜色更深、高度更高,双重信息传达。
r.batch.slice(3, 8)使用JavaScript的slice方法截取批次号的一部分作为标签。批次号"R-0825-01"截取后得到"0825-0",这是一种简化标签的方式。在ArkTS中,所有标准的JavaScript字符串方法都可以使用,这体现了ArkTS与TypeScript/JavaScript的完全兼容性。
第八展区:冲煮方法横滑卡片
展品说明
@Component
struct BrewTab173 {
onBuy: (n: string) => void = () => {};
@Builder
methodCard() {
Scroll() {
Row() {
ForEach(BREWS173, (m: BrewMethod173) => {
Column() {
Text(m.icon)
.fontSize(32)
Text(m.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
.margin({ top: 6 })
Text('粉水 ' + m.ratio + ' · ' + m.grind)
.fontSize(10)
.fontColor('#FFFFFFB3')
.margin({ top: 4 })
Text(m.temp + ' · ' + (m.mins >= 60 ? (Math.round(m.mins / 60) + '小时') : (m.mins + '分钟')))
.fontSize(10)
.fontColor('#EFB76A')
.margin({ top: 3 })
}
.width(170)
.height(130)
.padding(12)
.alignItems(HorizontalAlign.Start)
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
})
.margin({ right: 10 })
}, (m: BrewMethod173) => m.name)
}
.width('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
}
溯源解读
各位访客,这里展示的是冲煮方法库的横向滚动卡片。Scroll是ArkUI提供的可滚动容器组件,通过scrollable(ScrollDirection.Horizontal)设置水平滚动方向,scrollBar(BarState.Off)隐藏滚动条。ForEach渲染6种冲煮方法(手冲V60、法压壶、冷萃、摩卡壶、爱乐压、虹吸壶),每张卡片宽度固定170像素高度130像素,通过margin({ right: 10 })设置卡片间距。
每张卡片使用了linearGradient从焦糖橙到深咖啡棕的对角线渐变背景,与白色和金黄色的文字形成高对比度。在时间显示上,代码使用了条件表达式和数学运算:m.mins >= 60 ? (Math.round(m.mins / 60) + '小时') : (m.mins + '分钟')。冷萃的mins值为720(12小时),通过Math.round(720 / 60)计算得到12,显示为"12小时";手冲的mins值为3,直接显示"3分钟"。
Math.round是JavaScript内置的数学函数,在ArkTS中完全可用。这种"数值→格式化文本"的转换逻辑在声明式UI中非常常见,它使得数据可以以更友好的方式呈现给用户。在ArkTS中,可以在Text的内容表达式中使用任意JavaScript表达式,包括条件运算符、数学函数和字符串拼接。
alignItems(HorizontalAlign.Start)使卡片内的子元素从左侧对齐,这对于固定宽度的卡片来说更符合阅读习惯。HorizontalAlign是ArkUI定义的水平对齐枚举,包含Start(左对齐)、Center(居中对齐)、End(右对齐)三个值。
展品流程图
第九展区:庄园口碑榜——排名与评分条
展品说明
@Component
struct EstateTab173 {
onDetail: (n: string, ic: string) => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('🏆 庄园口碑榜')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('COE 榜单同步')
.fontSize(11)
.fontColor(COLORS173.caramel)
}
.width('100%')
.margin({ top: 12 })
Column() {
ForEach(ESTATES173, (e: EstateItem173, idx: number) => {
if (idx < 5) {
Row() {
Text((idx + 1) + '')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(idx === 0 ? '#EFB76A' : COLORS173.textHint)
.width(28)
Text(e.icon)
.fontSize(20)
Column() {
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text(e.country + ' · ' + e.var)
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(e.score + '分')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(scoreC173(e.score))
Row() {
Text('')
.width(scoreBar173(e.score))
.height(5)
.borderRadius(3)
.backgroundColor(scoreC173(e.score))
}
.width(52)
.height(5)
.borderRadius(3)
.backgroundColor('#F5EDE7')
.justifyContent(FlexAlign.Start)
.clip(true)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS173.cardBg)
.borderRadius(12)
.margin({ top: 8 })
.onClick(() => {
this.onDetail(e.name, e.icon);
})
}
}, (e: EstateItem173) => 'rank' + e.name)
}
.width('100%')
Column() {
ForEach(ESTATES173, (e: EstateItem173) => {
Column() {
Row() {
Text(e.icon)
.fontSize(26)
Text('')
.layoutWeight(1)
Text(e.alt)
.fontSize(9)
.fontColor(COLORS173.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(COLORS173.coffee)
}
.width('100%')
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 6 })
Text(e.country + ' · ' + e.var)
.fontSize(10)
.fontColor(COLORS173.textSub)
.width('100%')
.margin({ top: 3 })
Text('杯测 ' + e.score)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(scoreC173(e.score))
.width('100%')
.margin({ top: 5 })
}
.padding(12)
.backgroundColor(COLORS173.cardBg)
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
.onClick(() => {
this.onDetail(e.name, e.icon);
})
}, (e: EstateItem173) => 'grid' + e.name)
}
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
溯源解读
各位访客,庄园口碑榜展区由两部分组成:上方的Top5排行榜和下方的全部庄园网格。排行榜使用ForEach渲染前5名庄园(if (idx < 5)条件过滤),每行包含排名编号、庄园图标、庄园名称/产地/品种信息、评分和进度条。排名第一的编号使用金黄色(#EFB76A),其他排名使用次级文本色,这种"冠军金色"的视觉处理增强了排名的荣誉感。
评分进度条使用了与豆单页相同的"空Text色块"技术:外层Row(宽52高5背景色为浅咖啡色)内嵌一个Text('')空文本,宽度通过scoreBar173(e.score)计算(如92分对应22%),背景色通过scoreC173(e.score)获取(90分以上金色)。这种进度条与前文的失重比柱状图虽然都是"外层容器+内层色块"的结构,但方向不同——进度条是水平向右填充,柱状图是垂直向上生长。
在ArkTS中,同一个技术模式(空Text色块)可以应用于不同的可视化场景。进度条是"左对齐水平填充",柱状图是"底对齐垂直增长",它们的区别仅在于外层容器的
justifyContent属性值——FlexAlign.Start(左侧/顶部对齐)vsFlexAlign.End(右侧/底部对齐)。这种"一个模式多种用途"的设计体现了声明式UI的灵活性。
ForEach的键值生成函数在排行榜和网格中分别使用了'rank' + e.name和'grid' + e.name前缀。虽然两个ForEach的数据源相同(都是ESTATES173),但通过添加不同的前缀,确保了键值的唯一性,避免了框架在diff计算时的冲突。这种"同数据不同前缀"的键值设计,是处理同一数据源多次渲染的最佳实践。
第十展区:订阅页面——历史豆单与退订
展品说明
@Component
struct SubTab173 {
onCustom: () => void = () => {};
onCancelSub: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Column() {
Text('☕ 探索盒 · 第 15 期')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
Text('每月 2 款庄园微批次 · 100g×2')
.fontSize(11)
.fontColor('#EFB76A')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥89/期')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#EFB76A')
}
.width('100%')
Row() {
Text('下次发货:09-01 · 顺丰冷链')
.fontSize(11)
.fontColor('#FFFFFFB3')
Text('')
.layoutWeight(1)
Text('定制口味')
.fontSize(12)
.fontColor(COLORS173.coffeeDeep)
.fontWeight(FontWeight.Bold)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#EFB76A')
.onClick(() => {
this.onCustom();
})
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding(16)
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [[COLORS173.coffee, 0.0], [COLORS173.caramel, 1.0]]
})
.margin({ top: 12 })
Column() {
ForEach(SUB_HISTORY173, (h: SubHistory173) => {
Row() {
Text('📦')
.fontSize(20)
Column() {
Text(h.box)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text(h.beans)
.fontSize(11)
.fontColor(COLORS173.textSub)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(h.status)
.fontSize(11)
.fontColor(COLORS173.green)
.fontWeight(FontWeight.Medium)
Text(h.date)
.fontSize(9)
.fontColor(COLORS173.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS173.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}, (h: SubHistory173) => h.box)
}
.width('100%')
.margin({ top: 2 })
Row() {
Column() {
Text('暂停或终止订阅')
.fontSize(13)
.fontColor(COLORS173.textSub)
Text('已购期次不受影响')
.fontSize(10)
.fontColor(COLORS173.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('退订')
.fontSize(12)
.fontColor(COLORS173.danger)
.padding({ left: 18, right: 18, top: 8, bottom: 8 })
.borderRadius(14)
.border({ width: 1, color: COLORS173.danger })
.onClick(() => {
this.onCancelSub();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(14)
.margin({ top: 14 })
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
溯源解读
各位访客,订阅展区展示了一个完整的咖啡订阅服务体验。顶部卡片使用了从咖啡棕到焦糖橙的渐变背景,包含了订阅期号、内容描述、价格和下次发货信息。"定制口味"按钮使用金黄色背景(#EFB76A),与渐变背景形成对比,吸引用户点击。点击后调用this.onCustom()回调,触发父组件弹出SubSheet173订阅定制弹窗。
历史豆单列表使用ForEach渲染5期订阅记录,每条记录包含盒子信息、豆子内容、状态和送达日期。h.status使用绿色文字显示"已冲煮"状态,h.date使用次级灰色显示日期。每条记录的左右信息分别通过.alignItems(HorizontalAlign.Start)和.alignItems(HorizontalAlign.End)实现左右对齐——左侧是主信息(盒子名和豆子内容),右侧是状态信息(状态和日期)。这种"左右分栏"的信息布局在列表项中非常常见。
在信息密集的列表项中,使用
.alignItems(HorizontalAlign.Start)和.alignItems(HorizontalAlign.End)创建左右分栏是ArkTS的常用布局模式。左侧承载主要信息,右侧承载状态或时间信息,通过layoutWeight(1)在中间创建弹性间隔,实现信息的自然分流。
退订区域的设计体现了"弱化危险操作"的交互原则。"退订"按钮使用轮廓样式(border边框+透明背景)而非实色填充,视觉上弱于页面上的其他操作按钮。这种设计减少了用户误退订的概率,同时仍然提供了退订入口。点击后调用this.onCancelSub()回调,触发父组件弹出CancelSubDialog173全屏确认弹窗。
第十一展区:购买弹窗与订阅定制弹窗
展品说明
@Component
struct BeanBuySheet173 {
beanName: string = '';
onClose: () => void = () => {};
@State selWeight: string = '200g';
@State selRoast: string = '中烘';
@State grind: boolean = false;
@State count: number = 1;
build() {
Column() {
Row() {
Text('🛒 下单烘焙')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('出炉即发')
.fontSize(10)
.fontColor(COLORS173.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor(COLORS173.caramel)
}
.width('100%')
Row() {
Text('☕')
.fontSize(30)
Column() {
Text(this.beanName)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('下单后 48h 内烘焙 · 顺丰包邮')
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.padding(12)
.backgroundColor('#F5EDE7')
.borderRadius(14)
.margin({ top: 12 })
Text('克重规格')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 14 })
Row() {
ForEach(['100g', '200g', '454g', '1kg'], (w: string) => {
Text(w)
.fontSize(12)
.fontColor(this.selWeight === w ? COLORS173.white : COLORS173.textSub)
.fontWeight(this.selWeight === w ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selWeight === w ? COLORS173.coffee : '#F5EDE7')
.margin({ right: 8 })
.onClick(() => {
this.selWeight = w;
})
}, (w: string) => w)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Text('烘焙度')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 14 })
Row() {
ForEach(['浅烘', '中烘', '中深烘', '深烘'], (r: string) => {
Text(r)
.fontSize(12)
.fontColor(this.selRoast === r ? COLORS173.white : COLORS173.textMain)
.fontWeight(this.selRoast === r ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selRoast === r ? roastColor173(r) : '#F5EDE7')
.margin({ right: 8 })
.onClick(() => {
this.selRoast = r;
})
}, (r: string) => r)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Row() {
Column() {
Text(this.grind ? '磨粉发货' : '整豆发货')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS173.textMain)
Text(this.grind ? '按所选器具研磨' : '到手自磨风味更佳')
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(this.grind ? '已开启' : '未开启')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(this.grind ? COLORS173.caramel : COLORS173.textHint)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor(this.grind ? '#FBE9E7' : '#F5EDE7')
.onClick(() => {
this.grind = !this.grind;
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFBF7')
.borderRadius(12)
.margin({ top: 14 })
Row() {
Column() {
Text('合计')
.fontSize(10)
.fontColor(COLORS173.textSub)
Text('¥' + (this.count * 68))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Text('')
.layoutWeight(1)
Text('去结算')
.fontSize(15)
.fontColor(COLORS173.white)
.fontWeight(FontWeight.Bold)
.padding({ left: 34, right: 34, top: 11, bottom: 11 })
.borderRadius(22)
.backgroundColor(COLORS173.caramel)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 18 })
.backgroundColor(COLORS173.cardBg)
}
}
溯源解读
各位访客,BeanBuySheet173是购买烘焙的底部弹窗组件,通过bindSheet绑定显示。它接收beanName参数和onClose回调,内部维护4个@State状态变量:selWeight(克重)、selRoast(烘焙度)、grind(是否磨粉)和count(数量)。这些状态变量的变化只影响弹窗内部的UI更新,不会影响外部页面。
烘焙度选项的选择器使用了roastColor173(r)函数动态设置选中项的背景色。这意味着选中"浅烘"时按钮背景为浅棕色(#C58A5A),选中"深烘"时为最深棕色(#3E2723)。这种"选中色=烘焙色"的设计非常巧妙——用户选择烘焙度时,按钮本身的颜色就直观展示了该烘焙度的视觉效果,形成了一种"所见即所得"的交互体验。
这种"选项颜色映射业务含义"的设计在ArkTS中非常容易实现——只需在三元运算符中调用颜色函数即可。关键在于颜色函数的设计要有明确的业务语义,使得每种颜色都能承载信息。在本例中,烘焙度颜色直接来自
roastColor173函数,该函数也用于烘焙度谱系卡片和失重比柱状图,确保了全局颜色一致性。
磨粉开关使用了"按钮式开关"设计:开启时显示"已开启"和焦糖橙背景,关闭时显示"未开启"和浅灰色背景。与滑块式开关不同,按钮式开关更节省垂直空间,适合在弹窗等紧凑布局中使用。两种开关的本质都是通过@State布尔变量控制样式和文字,点击时执行取反操作this.grind = !this.grind。
合计金额的计算使用了表达式this.count * 68,直接在Text组件的内容中嵌入JavaScript表达式。ArkTS的Text组件支持在花括号{}中使用任意返回字符串的表达式,包括数学运算、条件运算和函数调用。这种"表达式嵌入文本"的能力使得动态数值的展示非常简洁。
第十二展区:商品详情全屏弹窗——风味谱与冲煮建议
展品说明
@Component
struct BeanDetailDialog173 {
beanName: string = '';
beanIcon: string = '';
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text(this.beanIcon)
.fontSize(44)
Text('')
.layoutWeight(1)
Text('杯测 86 分')
.fontSize(12)
.fontColor(COLORS173.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor('#33FFFFFF')
}
.width('100%')
.padding({ left: 16, right: 16, top: 20, bottom: 20 })
.linearGradient({
angle: 135,
colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
})
Column() {
Text(this.beanName)
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
Row() {
Text('水洗处理')
.fontSize(11)
.fontColor('#0277BD')
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor('#E1F5FE')
Text('海拔 1900m')
.fontSize(11)
.fontColor(COLORS173.textSub)
.margin({ left: 10 })
Text('')
.layoutWeight(1)
Text('¥68/200g')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
}
.width('100%')
.margin({ top: 8 })
Text('风味谱')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 12 })
Column() {
ForEach(BEAN_FLAVORS173, (f: FlavorItem173) => {
Row() {
Text(f.name)
.fontSize(12)
.fontColor(COLORS173.textMain)
.width(74)
Row() {
Text('')
.width(f.pct + '%')
.height(10)
.borderRadius(5)
.backgroundColor(f.color)
}
.width('100%')
.layoutWeight(1)
.height(10)
.borderRadius(5)
.backgroundColor('#F5EDE7')
.justifyContent(FlexAlign.Start)
.clip(true)
Text(f.pct + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width(28)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6, bottom: 6 })
}, (f: FlavorItem173) => 'det' + f.name)
}
.width('100%')
.margin({ top: 6 })
Text('冲煮建议')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 12 })
Text('• 研磨:中细(白砂糖)\n• 水温:92℃ · 粉水比 1:15\n• 两段式注水,总时长 2分30秒')
.fontSize(12)
.fontColor(COLORS173.textSub)
.lineHeight(20)
.width('100%')
.margin({ top: 6 })
Text('豆友评价')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 12 })
Column() {
ForEach(['手冲党小k', '奶咖星人', '浅烘控阿橙'], (u: string) => {
Row() {
Text(u)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('★★★★★')
.fontSize(10)
.fontColor(COLORS173.gold)
}
.width('100%')
.margin({ top: 6 })
}, (u: string) => u)
}
.width('100%')
.margin({ top: 4 })
Row() {
Text('收藏')
.fontSize(13)
.fontColor(COLORS173.coffeeDeep)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.borderRadius(20)
.border({ width: 1, color: COLORS173.coffee })
Text('')
.layoutWeight(1)
Text('立即下单烘焙')
.fontSize(14)
.fontColor(COLORS173.white)
.fontWeight(FontWeight.Bold)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.borderRadius(20)
.backgroundColor(COLORS173.caramel)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 })
}
.width('92%')
.constraintSize({ maxHeight: '85%' })
.backgroundColor(COLORS173.cardBg)
.borderRadius(20)
.clip(true)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#00000000')
}
}
溯源解读
各位访客,这是本次展览中最丰富的展品——咖啡豆详情全屏弹窗。BeanDetailDialog173通过bindContentCover绑定显示,其结构分为三层:最外层Column占满全屏并设置透明背景(#00000000),通过justifyContent(FlexAlign.Center)将内容居中;中间层是卡片容器(宽92%、最大高度85%、圆角20);最内层是具体的展示内容。
弹窗顶部的渐变头图区域使用了从焦糖橙到深咖啡棕的135度对角渐变,内含咖啡豆图标和杯测分数标签。backgroundColor('#33FFFFFF')中的"33"表示约20%透明度的白色,创建了半透明的分数标签背景。在ArkTS中,颜色值可以使用8位十六进制(ARGB)格式,前两位是Alpha通道("00"完全透明,"FF"完全不透明)。
8位十六进制颜色在ArkTS中广泛使用,特别是在渐变背景上叠加半透明元素时。常用的透明度值包括:“33”(约20%)、“80”(约50%)、“B3”(约70%)、“CC”(约80%)、“D9”(约85%)。选择哪种透明度取决于底层背景的复杂度和上层文字的可读性需求。
风味谱是详情弹窗中最具数据可视化价值的部分。ForEach渲染4个风味维度(茉莉花香、柠檬酸质、蜂蜜甜感、红茶尾韵),每个维度显示名称、进度条和百分比数值。进度条的背景色直接来自FlavorItem173接口中的color字段——这种"数据自带颜色"的设计使得可视化组件无需额外的颜色计算函数,直接使用接口数据渲染即可。keyGenerator使用了'det' + f.name前缀,与豆单页面的风味条区分开来,避免键值冲突。
冲煮建议使用了\n换行符在单个Text中实现多行文本,配合lineHeight(20)设置行高。这种"单Text多行"的方式比使用多个Text组件更简洁,适用于固定格式的参数说明文本。文本使用•符号作为列表标记,模拟了Markdown的列表效果。
豆友评价列表使用静态字符串数组['手冲党小k', '奶咖星人', '浅烘控阿橙']作为ForEach的数据源,每条评价显示用户名和五星评分。Text('★★★★★')使用Unicode星号字符显示五星评价,颜色为金色(COLORS173.gold)。这种"静态数据+ForEach"的方式在展示少量固定内容时比手动编写多个Row更为简洁。
底部操作区包含"收藏"和"立即下单烘焙"两个按钮。"收藏"使用轮廓样式(咖啡棕边框),"立即下单烘焙"使用焦糖橙实色背景。这种"弱化收藏、强化下单"的视觉设计引导用户进行转化操作。constraintSize({ maxHeight: '85%' })限制卡片最大高度为85%,配合clip(true)裁剪超出部分,确保弹窗在内容过多时不会超出屏幕。
馆藏技术点对比
| 序号 | 技术点 | 类别 | 馆藏位置 | 作用说明 | 策展评价 |
|---|---|---|---|---|---|
| 1 | @Entry | 装饰器 | CoffeeMain173 | 标记页面入口组件 | 镇馆之宝级核心展品 |
| 2 | @Component | 装饰器 | 全部struct | 声明自定义组件 | 共12件馆藏组件 |
| 3 | @State | 装饰器 | 入口和弹窗组件 | 响应式状态管理 | 共10+4+3+3+3+1个状态变量 |
| 4 | @Builder | 装饰器 | 入口组件内 | 定义可复用UI构建方法 | pageHeader/tabRows/contentArea等 |
| 5 | interface | TypeScript | 模块级别 | 定义数据模型接口 | 共9个接口定义 |
| 6 | ForEach | ArkUI组件 | 列表渲染 | 根据数据列表循环渲染 | 配合keyGenerator增量更新 |
| 7 | if/else | 条件渲染 | contentArea/Tab内 | 条件分支渲染 | 页面切换和列表过滤 |
| 8 | Scroll | ArkUI组件 | 各页面容器 | 可滚动容器 | 支持水平和垂直滚动 |
| 9 | bindSheet | 弹窗绑定 | 入口组件build | 半模态底部弹窗 | 3个sheet用于表单输入 |
| 10 | bindContentCover | 弹窗绑定 | 入口组件build | 全模态覆盖弹窗 | 2个cover用于确认和详情 |
| 11 | $$ | 双向绑定 | bindSheet参数 | 状态与弹窗显隐同步 | $$this.showXxx语法 |
| 12 | linearGradient | 样式属性 | 头部、卡片 | 线性渐变背景 | 135度对角渐变为主 |
| 13 | layoutWeight | 布局属性 | 全局 | 按比例分配剩余空间 | 弹性间隔和等宽分布 |
| 14 | aboutToAppear | 生命周期 | RoastEditSheet173 | 组件创建后初始化 | 同步外部参数到内部状态 |
| 15 | TextInput | 交互组件 | 编辑弹窗 | 文本输入 | 配合onChange同步状态 |
| 16 | textAlign | 样式属性 | 全局 | 文本对齐方式 | Start/Center/End三种对齐 |
| 17 | clip | 样式属性 | 进度条、弹窗 | 裁剪超出边界的内容 | 配合borderRadius实现圆角裁剪 |
| 18 | constraintSize | 样式属性 | 详情弹窗 | 约束组件最大尺寸 | maxHeight限制弹窗高度 |
| 19 | justifyContent | 布局属性 | 全局 | 主轴对齐方式 | Start/Center/End控制内容位置 |
| 20 | maxLines | 样式属性 | 文本组件 | 限制最大行数 | 配合textOverflow截断显示 |
安装DevEco Studio程序

选择目标安装目录:

设置环境变量,但是需要重启一下:

新建一个空白模板:

设置API为24的模板项目:
初始化项目,自动下载相关依赖:

完整代码:
// 多多精品咖啡 · 烘焙订阅工坊
// 拼多多风格:咖啡棕 × 奶泡米,双排 Tab(上排豆仓/下排工具)
interface ColorPalette173 {
coffee: string;
coffeeDeep: string;
milk: string;
milkDeep: string;
caramel: string;
bg: string;
cardBg: string;
textMain: string;
textSub: string;
textHint: string;
border: string;
danger: string;
white: string;
green: string;
gold: string;
}
const COLORS173: ColorPalette173 = {
coffee: '#4E342E',
coffeeDeep: '#3E2723',
milk: '#D7CCC8',
milkDeep: '#BCAAA4',
caramel: '#E64A19',
bg: '#FBF7F4',
cardBg: '#FFFFFF',
textMain: '#3E2723',
textSub: '#8D6E63',
textHint: '#BCAAA4',
border: '#EFE5E0',
danger: '#D84315',
white: '#FFFFFF',
green: '#2E7D32',
gold: '#E65100'
};
interface TabItem173 {
key: string;
icon: string;
label: string;
row: number;
}
const TABS173: TabItem173[] = [
{ key: 'beans', icon: '☕', label: '豆单', row: 1 },
{ key: 'roast', icon: '🔥', label: '烘焙', row: 1 },
{ key: 'brew', icon: '🧋', label: '冲煮', row: 1 },
{ key: 'sub', icon: '📅', label: '订阅', row: 2 },
{ key: 'estate', icon: '🏆', label: '庄园', row: 2 },
{ key: 'mine', icon: '👤', label: '我的', row: 2 }
];
interface BeanItem173 {
name: string;
origin: string;
icon: string;
process: string;
price: number;
notes: string;
score: number;
tag: string;
}
const BEANS173: BeanItem173[] = [
{ name: '耶加雪菲·科契尔', origin: '埃塞俄比亚', icon: '🫘', process: '水洗', price: 68, notes: '茉莉·柠檬·蜂蜜', score: 86, tag: '花果炸裂' },
{ name: '西达摩·花魁', origin: '埃塞俄比亚', icon: '🌸', process: '日晒', price: 88, notes: '玫瑰·莓果·可可', score: 88, tag: '限量到货' },
{ name: '瑰夏·翡翠庄园', origin: '巴拿马', icon: '💚', process: '水洗', price: 268, notes: '柑橘·白花·蜜瓜', score: 92, tag: '竞标级' },
{ name: '曼特宁·苏门答腊', origin: '印度尼西亚', icon: '🌿', process: '湿刨', price: 45, notes: '草本·黑巧·香料', score: 82, tag: '醇厚派' },
{ name: '哥伦比亚·蕙兰', origin: '哥伦比亚', icon: '⛰', process: '水洗', price: 52, notes: '焦糖·坚果·苹果', score: 84, tag: '口粮首选' },
{ name: '云南·保山小粒', origin: '中国云南', icon: '🌾', process: '日晒', price: 38, notes: '红糖·梅子·糯香', score: 83, tag: '国产之光' },
{ name: '肯尼亚·AA 涅里', origin: '肯尼亚', icon: '🔴', process: '水洗', price: 72, notes: '黑醋栗·番茄·乌梅', score: 87, tag: '酸质担当' },
{ name: '危地马拉·安提瓜', origin: '危地马拉', icon: '🌋', process: '水洗', price: 58, notes: '烟熏·可可·橙皮', score: 85, tag: '火山土壤' },
{ name: '巴西·喜拉多', origin: '巴西', icon: '🌰', process: '半日晒', price: 42, notes: '花生·牛奶巧克力', score: 80, tag: '奶咖基底' },
{ name: '哥斯达黎加·蜜处理', origin: '哥斯达黎加', icon: '🍯', process: '红蜜', price: 65, notes: '枫糖·红酒·杏仁', score: 86, tag: '甜感突出' },
{ name: '洪都拉斯·雪莉', origin: '洪都拉斯', icon: '🥃', process: '酒桶发酵', price: 78, notes: '威士忌·香草·奶油', score: 87, tag: '微醺风味' },
{ name: '埃塞·74158 微批次', origin: '埃塞俄比亚', icon: '🫐', process: '厌氧日晒', price: 96, notes: '蓝莓·荔枝·发酵香', score: 89, tag: '实验批' }
];
interface OriginStat173 {
region: string;
pct: number;
color: string;
}
const ORIGINS173: OriginStat173[] = [
{ region: '非洲', pct: 38, color: '#E64A19' },
{ region: '中南美洲', pct: 34, color: '#4E342E' },
{ region: '亚洲·海岛', pct: 20, color: '#8D6E63' },
{ region: '中国云南', pct: 8, color: '#2E7D32' }
];
interface RoastBatch173 {
batch: string;
bean: string;
level: string;
loss: number;
date: string;
score: number;
}
const ROASTS173: RoastBatch173[] = [
{ batch: 'R-0825-01', bean: '耶加雪菲', level: '浅烘', loss: 12, date: '08-25', score: 86 },
{ batch: 'R-0824-03', bean: '云南保山', level: '中烘', loss: 15, date: '08-24', score: 84 },
{ batch: 'R-0824-02', bean: '哥伦比亚', level: '中深烘', loss: 17, date: '08-24', score: 83 },
{ batch: 'R-0823-05', bean: '曼特宁', level: '深烘', loss: 19, date: '08-23', score: 82 },
{ batch: 'R-0823-04', bean: '西达摩', level: '浅烘', loss: 11, date: '08-23', score: 88 },
{ batch: 'R-0822-02', bean: '巴西喜拉多', level: '中烘', loss: 14, date: '08-22', score: 80 },
{ batch: 'R-0822-01', bean: '洪都拉斯雪莉', level: '中深烘', loss: 16, date: '08-22', score: 87 },
{ batch: 'R-0821-06', bean: '肯尼亚AA', level: '浅烘', loss: 12, date: '08-21', score: 87 }
];
interface BrewMethod173 {
name: string;
icon: string;
ratio: string;
mins: number;
grind: string;
temp: string;
}
const BREWS173: BrewMethod173[] = [
{ name: '手冲 V60', icon: '🫗', ratio: '1:15', mins: 3, grind: '白砂糖粗', temp: '92℃' },
{ name: '法压壶', icon: '🫖', ratio: '1:12', mins: 4, grind: '海盐粗', temp: '93℃' },
{ name: '冷萃', icon: '🧊', ratio: '1:10', mins: 720, grind: '特粗', temp: '常温' },
{ name: '摩卡壶', icon: '♨️', ratio: '1:8', mins: 5, grind: '细盐', temp: '蒸汽' },
{ name: '爱乐压', icon: '🥤', ratio: '1:14', mins: 2, grind: '细砂糖', temp: '85℃' },
{ name: '虹吸壶', icon: '⚗️', ratio: '1:13', mins: 6, grind: '中细', temp: '90℃' }
];
interface GearItem173 {
name: string;
icon: string;
price: number;
sold: number;
cat: string;
}
const GEARS173: GearItem173[] = [
{ name: 'Hario V60 树脂 02', icon: '🫗', price: 68, sold: 5400, cat: '滤杯' },
{ name: 'Kalita 蛋糕杯 155', icon: '🍰', price: 88, sold: 2100, cat: '滤杯' },
{ name: 'Origami 折纸滤杯', icon: '🪷', price: 108, sold: 1600, cat: '滤杯' },
{ name: 'Fellow Stagg 手冲壶', icon: '🫖', price: 899, sold: 860, cat: '温控' },
{ name: '泰摩 鱼Smart壶', icon: '🐟', price: 399, sold: 3200, cat: '温控' },
{ name: '1Zpresso K系列', icon: '⚙️', price: 1280, sold: 1900, cat: '磨豆' },
{ name: '泰摩 栗子C3', icon: '🌰', price: 368, sold: 8900, cat: '磨豆' },
{ name: 'Comandante C40', icon: '🇩🇪', price: 2680, sold: 620, cat: '磨豆' },
{ name: 'Fellow 恒温杯', icon: '🥛', price: 259, sold: 1400, cat: '温控' },
{ name: 'Brewista 智能秤', icon: '⚖️', price: 459, sold: 2600, cat: '称量' },
{ name: 'Hario 云朵分享壶', icon: '☁️', price: 96, sold: 4100, cat: '滤杯' },
{ name: 'Kinto 冷萃瓶', icon: '🧊', price: 179, sold: 2300, cat: '冷萃' }
];
interface EstateItem173 {
name: string;
country: string;
icon: string;
score: number;
alt: string;
var: string;
}
const ESTATES173: EstateItem173[] = [
{ name: '翡翠庄园', country: '巴拿马', icon: '💎', score: 95, alt: '1650m', var: '瑰夏' },
{ name: '九十+ 烛芒', country: '埃塞俄比亚', icon: '🕯', score: 92, alt: '1950m', var: '原生种' },
{ name: '鲁瓦卡庄园', country: '埃塞俄比亚', icon: '🦝', score: 90, alt: '1850m', var: '74110' },
{ name: '蓝山一号园', country: '牙买加', icon: '⛰', score: 91, alt: '1600m', var: '蓝山' },
{ name: '柯契尔合作社', country: '埃塞俄比亚', icon: '🌺', score: 89, alt: '1900m', var: ' heirloom' },
{ name: '慧兰产区联庄', country: '哥伦比亚', icon: '🚜', score: 87, alt: '1750m', var: '卡杜拉' },
{ name: '涅槃庄园', country: '危地马拉', icon: '🌋', score: 88, alt: '1580m', var: '波旁' },
{ name: '铃木处理厂', country: '巴西', icon: '🌰', score: 84, alt: '1150m', var: '黄波旁' },
{ name: '阿萨庄园', country: '肯尼亚', icon: '🦁', score: 89, alt: '1800m', var: 'SL28' },
{ name: '芒廷庄园', country: '巴拿马', icon: '🌄', score: 90, alt: '1550m', var: '卡杜艾' },
{ name: '云雾庄园', country: '中国云南', icon: '🌫', score: 86, alt: '1680m', var: '卡蒂姆' },
{ name: '金鹰庄园', country: '洪都拉斯', icon: '🦅', score: 87, alt: '1450m', var: '帕卡斯' }
];
interface SubHistory173 {
box: string;
beans: string;
date: string;
status: string;
}
const SUB_HISTORY173: SubHistory173[] = [
{ box: '第 14 期 · 探索盒', beans: '耶加雪菲+云南保山', date: '08-20 送达', status: '已冲煮' },
{ box: '第 13 期 · 探索盒', beans: '肯尼亚AA+巴西', date: '07-20 送达', status: '已冲煮' },
{ box: '第 12 期 · 大师盒', beans: '翡翠瑰夏 50g', date: '06-20 送达', status: '已冲煮' },
{ box: '第 11 期 · 探索盒', beans: '洪都拉斯+危地马拉', date: '05-20 送达', status: '已冲煮' },
{ box: '第 10 期 · 口粮盒', beans: '哥伦比亚 454g', date: '04-20 送达', status: '已冲煮' }
];
interface OrderItem173 {
name: string;
icon: string;
price: number;
status: string;
date: string;
}
const ORDERS173: OrderItem173[] = [
{ name: '西达摩花魁 200g', icon: '🌸', price: 88, status: '烘焙中', date: '08-25' },
{ name: '栗子C3 磨豆机', icon: '🌰', price: 368, status: '已发货', date: '08-16' },
{ name: 'Origami 滤杯+滤纸', icon: '🪷', price: 128, status: '已完成', date: '08-02' },
{ name: '冷萃套装', icon: '🧊', price: 179, status: '已完成', date: '07-21' },
{ name: '洪都拉斯雪莉 250g', icon: '🥃', price: 78, status: '待评价', date: '07-08' }
];
function roastColor173(lv: string): string {
if (lv === '浅烘') {
return '#C58A5A';
}
if (lv === '中烘') {
return '#8D6E63';
}
if (lv === '中深烘') {
return '#5D4037';
}
return '#3E2723';
}
interface FlavorItem173 {
name: string;
pct: number;
color: string;
}
const MY_FLAVORS173: FlavorItem173[] = [
{ name: '花香酸质', pct: 88, color: '#E64A19' },
{ name: '莓果发酵', pct: 76, color: '#8E24AA' },
{ name: '坚果可可', pct: 64, color: '#8D6E63' },
{ name: '奶油酒香', pct: 52, color: '#E65100' }
];
const BEAN_FLAVORS173: FlavorItem173[] = [
{ name: '茉莉花香', pct: 90, color: '#E64A19' },
{ name: '柠檬酸质', pct: 84, color: '#F9A825' },
{ name: '蜂蜜甜感', pct: 78, color: '#E65100' },
{ name: '红茶尾韵', pct: 62, color: '#8D6E63' }
];
function scoreC173(s: number): string {
if (s >= 90) {
return COLORS173.gold;
}
if (s >= 85) {
return COLORS173.caramel;
}
return COLORS173.textSub;
}
function scoreBar173(s: number): string {
return (s - 70) + '%';
}
function lossH173(l: number): string {
return (l * 5) + '%';
}
function processC173(p: string): string {
if (p === '水洗') {
return '#0277BD';
}
if (p === '日晒') {
return '#E65100';
}
if (p === '厌氧日晒') {
return '#8E24AA';
}
return '#2E7D32';
}
@Entry
@Component
struct CoffeeMain173 {
@State curTab: string = 'beans';
@State showBuy: boolean = false;
@State showSub: boolean = false;
@State showRoastEdit: boolean = false;
@State showCancelSub: boolean = false;
@State showDetail: boolean = false;
@State buyName: string = '耶加雪菲·科契尔';
@State roastBatch: string = 'R-0825-01';
@State detailName: string = '耶加雪菲·科契尔';
@State detailIcon: string = '🫘';
@Builder
pageHeader() {
Column() {
Row() {
Text('☕')
.fontSize(22)
Text('多多精品咖啡')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
.margin({ left: 6 })
Text('烘焙坊直发')
.fontSize(10)
.fontColor(COLORS173.coffeeDeep)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(9)
.backgroundColor('#EFB76A')
.margin({ left: 10 })
Text('')
.layoutWeight(1)
Text('🔍')
.fontSize(17)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.borderRadius(17)
.backgroundColor('#33FFFFFF')
.margin({ right: 8 })
Text('🛒')
.fontSize(17)
.width(34)
.height(34)
.textAlign(TextAlign.Center)
.borderRadius(17)
.backgroundColor('#33FFFFFF')
}
.width('100%')
.margin({ top: 8 })
Row() {
Text('🔥 今日鲜烘 · 下单后 48h 内出炉')
.fontSize(12)
.fontColor('#FFFFFFD9')
Text('')
.layoutWeight(1)
Text('满 199 减 30')
.fontSize(11)
.fontColor(COLORS173.caramel)
.fontWeight(FontWeight.Bold)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(11)
.backgroundColor('#FFFFFF')
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding({ left: 14, right: 14, top: 10, bottom: 14 })
.linearGradient({
angle: 135,
colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]
})
}
@Builder
tabRows() {
Column() {
Row() {
ForEach(TABS173, (t: TabItem173) => {
if (t.row === 1) {
Column() {
Text(t.icon)
.fontSize(16)
Text(t.label)
.fontSize(12)
.fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
.onClick(() => {
this.curTab = t.key;
})
}
}, (t: TabItem173) => t.key)
}
Row() {
ForEach(TABS173, (t: TabItem173) => {
if (t.row === 2) {
Column() {
Text(t.icon)
.fontSize(16)
Text(t.label)
.fontSize(12)
.fontWeight(this.curTab === t.key ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === t.key ? COLORS173.caramel : COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding({ top: 8, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.curTab === t.key ? '#FBE9E7' : '#FFFFFF')
.onClick(() => {
this.curTab = t.key;
})
}
}, (t: TabItem173) => t.key)
}
}
.width('100%')
.padding({ left: 12, right: 12 })
.margin({ top: 10 })
}
@Builder
buySheet() {
BeanBuySheet173({
beanName: this.buyName,
onClose: (): void => {
this.showBuy = false;
}
})
}
@Builder
subSheet() {
SubSheet173({
onClose: (): void => {
this.showSub = false;
}
})
}
@Builder
roastEditSheet() {
RoastEditSheet173({
batchName: this.roastBatch,
onClose: (): void => {
this.showRoastEdit = false;
}
})
}
@Builder
cancelSubDialog() {
CancelSubDialog173({
onCancel: (): void => {
this.showCancelSub = false;
},
onConfirm: (): void => {
this.showCancelSub = false;
}
})
}
@Builder
detailDialog() {
BeanDetailDialog173({
beanName: this.detailName,
beanIcon: this.detailIcon,
onClose: (): void => {
this.showDetail = false;
}
})
}
@Builder
contentArea() {
Column() {
if (this.curTab === 'beans') {
BeansTab173({
onBuy: (n: string): void => {
this.buyName = n;
this.showBuy = true;
},
onDetail: (n: string, ic: string): void => {
this.detailName = n;
this.detailIcon = ic;
this.showDetail = true;
}
})
} else if (this.curTab === 'roast') {
RoastTab173({
onEdit: (n: string): void => {
this.roastBatch = n;
this.showRoastEdit = true;
}
})
} else if (this.curTab === 'brew') {
BrewTab173({
onBuy: (n: string): void => {
this.buyName = n;
this.showBuy = true;
}
})
} else if (this.curTab === 'sub') {
SubTab173({
onCustom: (): void => {
this.showSub = true;
},
onCancelSub: (): void => {
this.showCancelSub = true;
}
})
} else if (this.curTab === 'estate') {
EstateTab173({
onDetail: (n: string, ic: string): void => {
this.detailName = n;
this.detailIcon = ic;
this.showDetail = true;
}
})
} else {
MineTab173({
onDetail: (n: string, ic: string): void => {
this.detailName = n;
this.detailIcon = ic;
this.showDetail = true;
}
})
}
}
.width('100%')
.layoutWeight(1)
}
build() {
Column() {
this.pageHeader()
this.tabRows()
this.contentArea()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS173.bg)
.bindSheet($$this.showBuy, this.buySheet(), {
height: 620,
dragBar: true,
showClose: true,
backgroundColor: COLORS173.cardBg
})
.bindSheet($$this.showSub, this.subSheet(), {
height: 560,
dragBar: true,
showClose: true,
backgroundColor: COLORS173.cardBg
})
.bindSheet($$this.showRoastEdit, this.roastEditSheet(), {
height: 500,
dragBar: true,
showClose: true,
backgroundColor: COLORS173.cardBg
})
.bindContentCover($$this.showCancelSub, this.cancelSubDialog(), {
})
.bindContentCover($$this.showDetail, this.detailDialog(), {
})
}
}
@Component
struct BeansTab173 {
onBuy: (n: string) => void = () => {};
onDetail: (n: string, ic: string) => void = () => {};
@Builder
originCard() {
Column() {
Row() {
Text('🌍 在售豆仓产地分布')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('共 42 款生豆')
.fontSize(10)
.fontColor(COLORS173.textHint)
}
.width('100%')
Row() {
ForEach(ORIGINS173, (o: OriginStat173) => {
Text('')
.width(o.pct + '%')
.height(16)
.backgroundColor(o.color)
}, (o: OriginStat173) => o.region)
}
.width('100%')
.height(16)
.borderRadius(8)
.clip(true)
.margin({ top: 10 })
Row() {
ForEach(ORIGINS173, (o: OriginStat173) => {
Row() {
Text('')
.width(8)
.height(8)
.borderRadius(4)
.backgroundColor(o.color)
Text(o.region + ' ' + o.pct + '%')
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ left: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Start)
}, (o: OriginStat173) => 'lg' + o.region)
}
.width('100%')
.margin({ top: 8 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(16)
.margin({ top: 12 })
}
build() {
Scroll() {
Column() {
this.originCard()
Row() {
Text('☕ 生豆直采豆单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('按杯测分排序')
.fontSize(11)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(BEANS173, (b: BeanItem173) => {
Row() {
Text(b.icon)
.fontSize(26)
.width(60)
.height(60)
.textAlign(TextAlign.Center)
.borderRadius(14)
.backgroundColor('#F5EDE7')
Column() {
Row() {
Text(b.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text(b.process)
.fontSize(9)
.fontColor(COLORS173.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(7)
.backgroundColor(processC173(b.process))
.margin({ left: 6 })
}
Text(b.origin + ' · ' + b.notes)
.fontSize(11)
.fontColor(COLORS173.textSub)
.margin({ top: 4 })
Row() {
Text('杯测 ' + b.score)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(scoreC173(b.score))
Text(b.tag)
.fontSize(9)
.fontColor(COLORS173.caramel)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(7)
.backgroundColor('#FBE9E7')
.margin({ left: 8 })
Text('')
.layoutWeight(1)
Text('¥' + b.price + '/200g')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
Text('下单烘焙')
.fontSize(11)
.fontColor(COLORS173.white)
.padding({ left: 10, right: 10, top: 5, bottom: 5 })
.borderRadius(11)
.backgroundColor(COLORS173.coffee)
.margin({ left: 10 })
.onClick(() => {
this.onBuy(b.name);
})
}
.width('100%')
.margin({ top: 6 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.padding(12)
.backgroundColor(COLORS173.cardBg)
.borderRadius(14)
.margin({ top: 10 })
.onClick(() => {
this.onDetail(b.name, b.icon);
})
}, (b: BeanItem173) => b.name)
}
.width('100%')
Text('')
.fontSize(1)
.height(14)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
@Component
struct RoastTab173 {
onEdit: (n: string) => void = () => {};
@Builder
levelCard() {
Column() {
Text('🔥 烘焙度谱系')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
Row() {
ForEach(['浅烘', '中烘', '中深烘', '深烘'], (lv: string) => {
Column() {
Text('')
.width('100%')
.height(34)
.borderRadius(10)
.backgroundColor(roastColor173(lv))
Text(lv)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.margin({ top: 5 })
Text('花果酸香')
.fontSize(9)
.fontColor(COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.margin({ left: 4, right: 4 })
}, (lv: string) => lv)
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(16)
.margin({ top: 12 })
}
@Builder
lossCard() {
Column() {
Row() {
Text('📉 近期批次失重比')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('失重越高越深烘')
.fontSize(10)
.fontColor(COLORS173.textHint)
}
.width('100%')
Row() {
ForEach(ROASTS173, (r: RoastBatch173, idx: number) => {
if (idx < 6) {
Column() {
Column() {
Text('')
.width('100%')
.height(lossH173(r.loss))
.borderRadius({ topLeft: 5, topRight: 5 })
.backgroundColor(roastColor173(r.level))
}
.width('70%')
.height(100)
.justifyContent(FlexAlign.End)
.backgroundColor('#F5EDE7')
.borderRadius({ topLeft: 5, topRight: 5 })
.clip(true)
Text(r.loss + '%')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.margin({ top: 4 })
Text(r.batch.slice(3, 8))
.fontSize(9)
.fontColor(COLORS173.textSub)
.margin({ top: 2 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}
}, (r: RoastBatch173) => 'loss' + r.batch)
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(16)
.margin({ top: 12 })
}
build() {
Scroll() {
Column() {
Row() {
Text('🔥 我的烘焙坊')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('本月 28 锅')
.fontSize(11)
.fontColor(COLORS173.caramel)
}
.width('100%')
.margin({ top: 12 })
this.levelCard()
this.lossCard()
Row() {
Text('📋 烘焙批次记录')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('共 8 锅')
.fontSize(11)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(ROASTS173, (r: RoastBatch173) => {
Row() {
Column() {
Text(r.level)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.borderRadius(9)
.backgroundColor(roastColor173(r.level))
Text(r.date)
.fontSize(9)
.fontColor(COLORS173.textHint)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
Column() {
Text(r.batch + ' · ' + r.bean)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('失重 ' + r.loss + '% · 杯测 ' + r.score + ' 分')
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('编辑')
.fontSize(11)
.fontColor(COLORS173.coffee)
.padding({ left: 12, right: 12, top: 5, bottom: 5 })
.borderRadius(11)
.border({ width: 1, color: COLORS173.coffee })
.onClick(() => {
this.onEdit(r.batch);
})
}
.width('100%')
.padding(11)
.backgroundColor(COLORS173.cardBg)
.borderRadius(13)
.margin({ top: 8 })
}, (r: RoastBatch173) => r.batch)
}
.width('100%')
.margin({ top: 2 })
Text('')
.fontSize(1)
.height(14)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
@Component
struct BrewTab173 {
onBuy: (n: string) => void = () => {};
@Builder
methodCard() {
Scroll() {
Row() {
ForEach(BREWS173, (m: BrewMethod173) => {
Column() {
Text(m.icon)
.fontSize(32)
Text(m.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
.margin({ top: 6 })
Text('粉水 ' + m.ratio + ' · ' + m.grind)
.fontSize(10)
.fontColor('#FFFFFFB3')
.margin({ top: 4 })
Text(m.temp + ' · ' + (m.mins >= 60 ? (Math.round(m.mins / 60) + '小时') : (m.mins + '分钟')))
.fontSize(10)
.fontColor('#EFB76A')
.margin({ top: 3 })
}
.width(170)
.height(130)
.padding(12)
.alignItems(HorizontalAlign.Start)
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
})
.margin({ right: 10 })
}, (m: BrewMethod173) => m.name)
}
.width('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')
}
build() {
Scroll() {
Column() {
Row() {
Text('🧋 冲煮方法库')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('6 种主流方案')
.fontSize(11)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 12 })
this.methodCard()
Row() {
Text('🛠 器具商城')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('满 2 件 9 折')
.fontSize(11)
.fontColor(COLORS173.caramel)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(GEARS173, (g: GearItem173) => {
Column() {
Row() {
Text(g.icon)
.fontSize(30)
Text('')
.layoutWeight(1)
Text(g.cat)
.fontSize(9)
.fontColor(COLORS173.caramel)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor('#FBE9E7')
}
.width('100%')
Text(g.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width('100%')
.margin({ top: 6 })
Row() {
Text('¥' + g.price)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
Text('')
.layoutWeight(1)
Text('已售 ' + g.sold)
.fontSize(10)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 5 })
Text('加入清单')
.fontSize(11)
.fontColor(COLORS173.white)
.padding({ left: 14, right: 14, top: 5, bottom: 5 })
.borderRadius(11)
.backgroundColor(COLORS173.coffee)
.margin({ top: 7 })
.onClick(() => {
this.onBuy(g.name);
})
}
.padding(12)
.backgroundColor(COLORS173.cardBg)
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
}, (g: GearItem173) => g.name)
}
Text('')
.fontSize(1)
.height(14)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
@Component
struct SubTab173 {
onCustom: () => void = () => {};
onCancelSub: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Column() {
Text('☕ 探索盒 · 第 15 期')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
Text('每月 2 款庄园微批次 · 100g×2')
.fontSize(11)
.fontColor('#EFB76A')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('¥89/期')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#EFB76A')
}
.width('100%')
Row() {
Text('下次发货:09-01 · 顺丰冷链')
.fontSize(11)
.fontColor('#FFFFFFB3')
Text('')
.layoutWeight(1)
Text('定制口味')
.fontSize(12)
.fontColor(COLORS173.coffeeDeep)
.fontWeight(FontWeight.Bold)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor('#EFB76A')
.onClick(() => {
this.onCustom();
})
}
.width('100%')
.margin({ top: 12 })
}
.width('100%')
.padding(16)
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [[COLORS173.coffee, 0.0], [COLORS173.caramel, 1.0]]
})
.margin({ top: 12 })
Row() {
Text('📦 历史豆单')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('已订阅 14 期')
.fontSize(11)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(SUB_HISTORY173, (h: SubHistory173) => {
Row() {
Text('📦')
.fontSize(20)
Column() {
Text(h.box)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text(h.beans)
.fontSize(11)
.fontColor(COLORS173.textSub)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(h.status)
.fontSize(11)
.fontColor(COLORS173.green)
.fontWeight(FontWeight.Medium)
Text(h.date)
.fontSize(9)
.fontColor(COLORS173.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS173.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}, (h: SubHistory173) => h.box)
}
.width('100%')
.margin({ top: 2 })
Row() {
Column() {
Text('暂停或终止订阅')
.fontSize(13)
.fontColor(COLORS173.textSub)
Text('已购期次不受影响')
.fontSize(10)
.fontColor(COLORS173.textHint)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('退订')
.fontSize(12)
.fontColor(COLORS173.danger)
.padding({ left: 18, right: 18, top: 8, bottom: 8 })
.borderRadius(14)
.border({ width: 1, color: COLORS173.danger })
.onClick(() => {
this.onCancelSub();
})
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(14)
.margin({ top: 14 })
Text('')
.fontSize(1)
.height(14)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
@Component
struct EstateTab173 {
onDetail: (n: string, ic: string) => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('🏆 庄园口碑榜')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('COE 榜单同步')
.fontSize(11)
.fontColor(COLORS173.caramel)
}
.width('100%')
.margin({ top: 12 })
Column() {
ForEach(ESTATES173, (e: EstateItem173, idx: number) => {
if (idx < 5) {
Row() {
Text((idx + 1) + '')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(idx === 0 ? '#EFB76A' : COLORS173.textHint)
.width(28)
Text(e.icon)
.fontSize(20)
Column() {
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text(e.country + ' · ' + e.var)
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Column() {
Text(e.score + '分')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(scoreC173(e.score))
Row() {
Text('')
.width(scoreBar173(e.score))
.height(5)
.borderRadius(3)
.backgroundColor(scoreC173(e.score))
}
.width(52)
.height(5)
.borderRadius(3)
.backgroundColor('#F5EDE7')
.justifyContent(FlexAlign.Start)
.clip(true)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ top: 11, bottom: 11 })
.backgroundColor(COLORS173.cardBg)
.borderRadius(12)
.margin({ top: 8 })
.onClick(() => {
this.onDetail(e.name, e.icon);
})
}
}, (e: EstateItem173) => 'rank' + e.name)
}
.width('100%')
Row() {
Text('🗺 全部庄园')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('共 12 庄')
.fontSize(11)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(ESTATES173, (e: EstateItem173) => {
Column() {
Row() {
Text(e.icon)
.fontSize(26)
Text('')
.layoutWeight(1)
Text(e.alt)
.fontSize(9)
.fontColor(COLORS173.white)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(8)
.backgroundColor(COLORS173.coffee)
}
.width('100%')
Text(e.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 6 })
Text(e.country + ' · ' + e.var)
.fontSize(10)
.fontColor(COLORS173.textSub)
.width('100%')
.margin({ top: 3 })
Text('杯测 ' + e.score)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(scoreC173(e.score))
.width('100%')
.margin({ top: 5 })
}
.padding(12)
.backgroundColor(COLORS173.cardBg)
.borderRadius(14)
.alignItems(HorizontalAlign.Start)
.onClick(() => {
this.onDetail(e.name, e.icon);
})
}, (e: EstateItem173) => 'grid' + e.name)
}
Text('')
.fontSize(1)
.height(14)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
@Component
struct MineTab173 {
onDetail: (n: string, ic: string) => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('👤 咖啡护照')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('⚙️')
.fontSize(16)
}
.width('100%')
.margin({ top: 12 })
Column() {
Row() {
Text('🧑🍳')
.fontSize(36)
.width(58)
.height(58)
.textAlign(TextAlign.Center)
.borderRadius(29)
.backgroundColor('#33FFFFFF')
Column() {
Text('手冲学徒豆仔')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
Text('打卡 128 杯 · 解锁 22 款豆')
.fontSize(11)
.fontColor('#EFB76A')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('Lv.12')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.coffeeDeep)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor('#EFB76A')
}
.width('100%')
Row() {
ForEach(['喝过豆子', '打卡杯数', '风味标签'], (s: string) => {
Column() {
if (s === '喝过豆子') {
Text('22 款')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
} else if (s === '打卡杯数') {
Text('128 杯')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor('#EFB76A')
} else {
Text('14 个')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.white)
}
Text(s)
.fontSize(10)
.fontColor('#FFFFFFB3')
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (s: string) => s)
}
.width('100%')
.margin({ top: 14 })
}
.width('100%')
.padding(16)
.borderRadius(16)
.linearGradient({
angle: 135,
colors: [[COLORS173.coffee, 0.0], [COLORS173.coffeeDeep, 1.0]]
})
.margin({ top: 12 })
Row() {
Text('👅 风味偏好画像')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('根据打卡生成')
.fontSize(10)
.fontColor(COLORS173.textHint)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(MY_FLAVORS173, (f: FlavorItem173) => {
Row() {
Text(f.name)
.fontSize(12)
.fontColor(COLORS173.textMain)
.width(74)
Row() {
Text('')
.width(f.pct + '%')
.height(10)
.borderRadius(5)
.backgroundColor(f.color)
}
.width('100%')
.layoutWeight(1)
.height(10)
.borderRadius(5)
.backgroundColor('#F5EDE7')
.justifyContent(FlexAlign.Start)
.clip(true)
Text(f.pct + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width(28)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 7, bottom: 7 })
}, (f: FlavorItem173) => f.name)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS173.cardBg)
.borderRadius(16)
.margin({ top: 10 })
Row() {
Text('📦 咖啡订单')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('全部 ›')
.fontSize(12)
.fontColor(COLORS173.caramel)
}
.width('100%')
.margin({ top: 14 })
Column() {
ForEach(ORDERS173, (o: OrderItem173) => {
Row() {
Text(o.icon)
.fontSize(21)
.width(42)
.height(42)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor('#F5EDE7')
Column() {
Text(o.name)
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS173.textMain)
Text(o.date + ' · ¥' + o.price)
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(o.status)
.fontSize(11)
.fontColor(o.status === '烘焙中' ? COLORS173.caramel : COLORS173.green)
.fontWeight(FontWeight.Medium)
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.borderRadius(12)
.backgroundColor(COLORS173.cardBg)
.margin({ top: 8 })
.onClick(() => {
this.onDetail(o.name, o.icon);
})
}, (o: OrderItem173) => o.name)
}
.width('100%')
.margin({ top: 2 })
Text('')
.fontSize(1)
.height(14)
}
.width('100%')
.padding({ left: 12, right: 12 })
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.width('100%')
.height('100%')
}
}
@Component
struct BeanBuySheet173 {
beanName: string = '';
onClose: () => void = () => {};
@State selWeight: string = '200g';
@State selRoast: string = '中烘';
@State grind: boolean = false;
@State count: number = 1;
build() {
Column() {
Row() {
Text('🛒 下单烘焙')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('出炉即发')
.fontSize(10)
.fontColor(COLORS173.white)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor(COLORS173.caramel)
}
.width('100%')
Row() {
Text('☕')
.fontSize(30)
Column() {
Text(this.beanName)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('下单后 48h 内烘焙 · 顺丰包邮')
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
}
.width('100%')
.padding(12)
.backgroundColor('#F5EDE7')
.borderRadius(14)
.margin({ top: 12 })
Text('克重规格')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 14 })
Row() {
ForEach(['100g', '200g', '454g', '1kg'], (w: string) => {
Text(w)
.fontSize(12)
.fontColor(this.selWeight === w ? COLORS173.white : COLORS173.textSub)
.fontWeight(this.selWeight === w ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selWeight === w ? COLORS173.coffee : '#F5EDE7')
.margin({ right: 8 })
.onClick(() => {
this.selWeight = w;
})
}, (w: string) => w)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Text('烘焙度')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 14 })
Row() {
ForEach(['浅烘', '中烘', '中深烘', '深烘'], (r: string) => {
Text(r)
.fontSize(12)
.fontColor(this.selRoast === r ? COLORS173.white : COLORS173.textMain)
.fontWeight(this.selRoast === r ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selRoast === r ? roastColor173(r) : '#F5EDE7')
.margin({ right: 8 })
.onClick(() => {
this.selRoast = r;
})
}, (r: string) => r)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Row() {
Column() {
Text(this.grind ? '磨粉发货' : '整豆发货')
.fontSize(13)
.fontWeight(FontWeight.Medium)
.fontColor(COLORS173.textMain)
Text(this.grind ? '按所选器具研磨' : '到手自磨风味更佳')
.fontSize(10)
.fontColor(COLORS173.textSub)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text(this.grind ? '已开启' : '未开启')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(this.grind ? COLORS173.caramel : COLORS173.textHint)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(12)
.backgroundColor(this.grind ? '#FBE9E7' : '#F5EDE7')
.onClick(() => {
this.grind = !this.grind;
})
}
.width('100%')
.padding(12)
.backgroundColor('#FFFBF7')
.borderRadius(12)
.margin({ top: 14 })
Text('数量')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 14 })
Row() {
Text('-')
.fontSize(18)
.fontColor(this.count > 1 ? COLORS173.textMain : COLORS173.textHint)
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#F5EDE7')
.onClick(() => {
if (this.count > 1) {
this.count = this.count - 1;
}
})
Text(this.count + '')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width(48)
.textAlign(TextAlign.Center)
Text('+')
.fontSize(18)
.fontColor(COLORS173.caramel)
.width(32)
.height(32)
.textAlign(TextAlign.Center)
.borderRadius(8)
.backgroundColor('#FBE9E7')
.onClick(() => {
this.count = this.count + 1;
})
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 6 })
Text('')
.layoutWeight(1)
Row() {
Column() {
Text('合计')
.fontSize(10)
.fontColor(COLORS173.textSub)
Text('¥' + (this.count * 68))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.Start)
Text('')
.layoutWeight(1)
Text('去结算')
.fontSize(15)
.fontColor(COLORS173.white)
.fontWeight(FontWeight.Bold)
.padding({ left: 34, right: 34, top: 11, bottom: 11 })
.borderRadius(22)
.backgroundColor(COLORS173.caramel)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 14 })
Text('')
.fontSize(1)
.height(8)
}
.width('100%')
.padding({ left: 16, right: 16, top: 18 })
.backgroundColor(COLORS173.cardBg)
}
}
@Component
struct SubSheet173 {
onClose: () => void = () => {};
@State selFreq: string = '每月一期';
@State selTaste: string = '花果酸香';
@State selSize: string = '100g×2';
build() {
Column() {
Row() {
Text('📅 定制订阅')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('随时可退')
.fontSize(10)
.fontColor(COLORS173.green)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor('#E8F5E9')
}
.width('100%')
Text('配送频率')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 16 })
Row() {
ForEach(['每月一期', '半月一期', '双月一期'], (f: string) => {
Text(f)
.fontSize(12)
.fontColor(this.selFreq === f ? COLORS173.white : COLORS173.textSub)
.fontWeight(this.selFreq === f ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selFreq === f ? COLORS173.coffee : '#F5EDE7')
.margin({ right: 8 })
.onClick(() => {
this.selFreq = f;
})
}, (f: string) => f)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Text('口味偏好')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 14 })
Row() {
ForEach(['花果酸香', '均衡醇厚', '发酵酒香'], (t: string) => {
Text(t)
.fontSize(12)
.fontColor(this.selTaste === t ? COLORS173.white : COLORS173.textSub)
.fontWeight(this.selTaste === t ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selTaste === t ? COLORS173.caramel : '#FBE9E7')
.margin({ right: 8 })
.onClick(() => {
this.selTaste = t;
})
}, (t: string) => t)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Text('每期规格')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 14 })
Row() {
ForEach(['100g×2', '200g×1', '454g×1'], (s: string) => {
Text(s)
.fontSize(12)
.fontColor(this.selSize === s ? COLORS173.white : COLORS173.textSub)
.fontWeight(this.selSize === s ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 14, right: 14, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selSize === s ? COLORS173.coffeeDeep : '#F5EDE7')
.margin({ right: 8 })
.onClick(() => {
this.selSize = s;
})
}, (s: string) => s)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Row() {
Text('订阅价 ¥89/期')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
Text('')
.layoutWeight(1)
Text('立省 ¥20/期')
.fontSize(11)
.fontColor(COLORS173.green)
}
.width('100%')
.padding(12)
.backgroundColor('#FFFBF7')
.borderRadius(12)
.margin({ top: 14 })
Text('')
.layoutWeight(1)
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLORS173.textSub)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.borderRadius(20)
.border({ width: 1, color: COLORS173.border })
.onClick(() => {
this.onClose();
})
Text('')
.layoutWeight(1)
Text('确认定制')
.fontSize(14)
.fontColor(COLORS173.white)
.fontWeight(FontWeight.Bold)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.borderRadius(20)
.backgroundColor(COLORS173.coffeeDeep)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 14 })
Text('')
.fontSize(1)
.height(8)
}
.width('100%')
.padding({ left: 16, right: 16, top: 18 })
.backgroundColor(COLORS173.cardBg)
}
}
@Component
struct RoastEditSheet173 {
batchName: string = '';
onClose: () => void = () => {};
@State batchNo: string = '';
@State selLevel: string = '浅烘';
@State lossInput: string = '12';
aboutToAppear(): void {
this.batchNo = this.batchName;
}
build() {
Column() {
Row() {
Text('🔥 编辑烘焙记录')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('保存')
.fontSize(13)
.fontColor(COLORS173.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(16)
.backgroundColor(COLORS173.caramel)
.onClick(() => {
this.onClose();
})
}
.width('100%')
Text('批次号')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 16 })
TextInput({ placeholder: '如 R-0825-01', text: this.batchNo })
.fontSize(14)
.height(44)
.padding({ left: 12 })
.borderRadius(10)
.backgroundColor('#F5EDE7')
.onChange((v: string) => {
this.batchNo = v;
})
.margin({ top: 8 })
Text('烘焙度')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 16 })
Row() {
ForEach(['浅烘', '中烘', '中深烘', '深烘'], (l: string) => {
Text(l)
.fontSize(12)
.fontColor(this.selLevel === l ? COLORS173.white : COLORS173.textMain)
.fontWeight(this.selLevel === l ? FontWeight.Bold : FontWeight.Normal)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(14)
.backgroundColor(this.selLevel === l ? roastColor173(l) : '#F5EDE7')
.margin({ right: 8 })
.onClick(() => {
this.selLevel = l;
})
}, (l: string) => l)
}
.width('100%')
.justifyContent(FlexAlign.Start)
.margin({ top: 8 })
Text('失重比(%)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 16 })
TextInput({ placeholder: '输入 10-20', text: this.lossInput })
.fontSize(14)
.height(44)
.padding({ left: 12 })
.borderRadius(10)
.backgroundColor('#F5EDE7')
.type(InputType.Number)
.onChange((v: string) => {
this.lossInput = v;
})
.margin({ top: 8 })
Text('')
.layoutWeight(1)
Row() {
Text('取消')
.fontSize(14)
.fontColor(COLORS173.textSub)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.borderRadius(20)
.border({ width: 1, color: COLORS173.border })
.onClick(() => {
this.onClose();
})
Text('')
.layoutWeight(1)
Text('保存记录')
.fontSize(14)
.fontColor(COLORS173.white)
.fontWeight(FontWeight.Bold)
.padding({ left: 28, right: 28, top: 10, bottom: 10 })
.borderRadius(20)
.backgroundColor(COLORS173.coffeeDeep)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 14 })
Text('')
.fontSize(1)
.height(8)
}
.width('100%')
.padding({ left: 16, right: 16, top: 18 })
.backgroundColor(COLORS173.cardBg)
}
}
@Component
struct CancelSubDialog173 {
onCancel: () => void = () => {};
onConfirm: () => void = () => {};
@State pauseOnly: boolean = true;
build() {
Column() {
Column() {
Text('☕')
.fontSize(38)
Text('退订咖啡订阅')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.margin({ top: 8 })
Text('第 15 期探索盒将不再发货')
.fontSize(13)
.fontColor(COLORS173.textSub)
.margin({ top: 8 })
Row() {
Text(this.pauseOnly ? '☑️' : '⬜')
.fontSize(15)
Text('仅暂停一期,下月自动恢复')
.fontSize(12)
.fontColor(COLORS173.textMain)
.margin({ left: 6 })
}
.width('100%')
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F5EDE7')
.borderRadius(10)
.justifyContent(FlexAlign.Start)
.margin({ top: 14 })
.onClick(() => {
this.pauseOnly = !this.pauseOnly;
})
Text(this.pauseOnly ? '已购期次与积分保留' : '确认后不可恢复,积分清零')
.fontSize(10)
.fontColor(this.pauseOnly ? COLORS173.green : COLORS173.danger)
.margin({ top: 8 })
Row() {
Text('再想想')
.fontSize(14)
.fontColor(COLORS173.textSub)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.borderRadius(20)
.border({ width: 1, color: COLORS173.border })
.onClick(() => {
this.onCancel();
})
Text('')
.layoutWeight(1)
Text('确认退订')
.fontSize(14)
.fontColor(COLORS173.white)
.fontWeight(FontWeight.Bold)
.padding({ left: 26, right: 26, top: 10, bottom: 10 })
.borderRadius(20)
.backgroundColor(COLORS173.danger)
.onClick(() => {
this.onConfirm();
})
}
.width('100%')
.margin({ top: 16 })
}
.width('86%')
.padding({ left: 18, right: 18, top: 22, bottom: 18 })
.backgroundColor(COLORS173.cardBg)
.borderRadius(18)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#00000000')
}
}
@Component
struct BeanDetailDialog173 {
beanName: string = '';
beanIcon: string = '';
onClose: () => void = () => {};
build() {
Column() {
Column() {
Row() {
Text(this.beanIcon)
.fontSize(44)
Text('')
.layoutWeight(1)
Text('杯测 86 分')
.fontSize(12)
.fontColor(COLORS173.white)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.borderRadius(12)
.backgroundColor('#33FFFFFF')
}
.width('100%')
.padding({ left: 16, right: 16, top: 20, bottom: 20 })
.linearGradient({
angle: 135,
colors: [[COLORS173.caramel, 0.0], [COLORS173.coffeeDeep, 1.0]]
})
Column() {
Text(this.beanName)
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
Row() {
Text('水洗处理')
.fontSize(11)
.fontColor('#0277BD')
.padding({ left: 7, right: 7, top: 3, bottom: 3 })
.borderRadius(8)
.backgroundColor('#E1F5FE')
Text('海拔 1900m')
.fontSize(11)
.fontColor(COLORS173.textSub)
.margin({ left: 10 })
Text('')
.layoutWeight(1)
Text('¥68/200g')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.caramel)
}
.width('100%')
.margin({ top: 8 })
Text('风味谱')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 12 })
Column() {
ForEach(BEAN_FLAVORS173, (f: FlavorItem173) => {
Row() {
Text(f.name)
.fontSize(12)
.fontColor(COLORS173.textMain)
.width(74)
Row() {
Text('')
.width(f.pct + '%')
.height(10)
.borderRadius(5)
.backgroundColor(f.color)
}
.width('100%')
.layoutWeight(1)
.height(10)
.borderRadius(5)
.backgroundColor('#F5EDE7')
.justifyContent(FlexAlign.Start)
.clip(true)
Text(f.pct + '')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width(28)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 6, bottom: 6 })
}, (f: FlavorItem173) => 'det' + f.name)
}
.width('100%')
.margin({ top: 6 })
Text('冲煮建议')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 12 })
Text('• 研磨:中细(白砂糖)\n• 水温:92℃ · 粉水比 1:15\n• 两段式注水,总时长 2分30秒')
.fontSize(12)
.fontColor(COLORS173.textSub)
.lineHeight(20)
.width('100%')
.margin({ top: 6 })
Text('豆友评价')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
.width('100%')
.margin({ top: 12 })
Column() {
ForEach(['手冲党小k', '奶咖星人', '浅烘控阿橙'], (u: string) => {
Row() {
Text(u)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS173.textMain)
Text('')
.layoutWeight(1)
Text('★★★★★')
.fontSize(10)
.fontColor(COLORS173.gold)
}
.width('100%')
.margin({ top: 6 })
}, (u: string) => u)
}
.width('100%')
.margin({ top: 4 })
Text('')
.layoutWeight(1)
Row() {
Text('收藏')
.fontSize(13)
.fontColor(COLORS173.coffeeDeep)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.borderRadius(20)
.border({ width: 1, color: COLORS173.coffee })
Text('')
.layoutWeight(1)
Text('立即下单烘焙')
.fontSize(14)
.fontColor(COLORS173.white)
.fontWeight(FontWeight.Bold)
.padding({ left: 24, right: 24, top: 10, bottom: 10 })
.borderRadius(20)
.backgroundColor(COLORS173.caramel)
.onClick(() => {
this.onClose();
})
}
.width('100%')
.margin({ top: 14 })
Text('')
.fontSize(1)
.height(8)
}
.width('100%')
.padding({ left: 16, right: 16, top: 14 })
}
.width('92%')
.constraintSize({ maxHeight: '85%' })
.backgroundColor(COLORS173.cardBg)
.borderRadius(20)
.clip(true)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor('#00000000')
}
}
总结

本次HarmonyOS ArkTS咖啡烘焙订阅工坊技术策展圆满完成。从色彩馆藏(ColorPalette173)到数据建模(9个interface定义),从辅助函数(5个纯函数)到状态管理(10个@State变量),从双排Tab导航到双模态弹窗系统,每一件馆藏展品都体现了ArkTS声明式UI的核心理念——开发者声明数据与视图的映射关系,框架自动处理渲染、更新、动画等底层逻辑。
在状态管理层面,应用展现了"集中管理与局部隔离"的平衡艺术。入口组件的10个@State变量集中管理了Tab切换和弹窗控制的全局状态,而各弹窗组件内部的@State变量(如selWeight、selRoast、count等)则实现了组件内部的局部状态隔离。这种设计既保证了全局状态的统一管理,又避免了局部状态变化对其他组件的不必要影响。回调函数作为子→父通信的唯一通道,确保了数据流的单向性和可追踪性。
在数据可视化层面,应用展示了"纯函数+空Text色块"的轻量级可视化方案。通过roastColor173、scoreC173、processC173等纯函数将业务数据映射为颜色值,通过lossH173、scoreBar173等纯函数将数值映射为百分比,再配合Text('')空文本组件作为色块、Row/Column容器作为轨道,构建了比例条、柱状图、进度条等多种可视化组件。这种方案无需引入第三方图表库,纯ArkTS原生代码即可实现,体现了声明式UI的灵活性。
在交互设计层面,应用的双排Tab导航是最大的结构特色。通过TabItem173接口中的row字段区分上下两排,配合ForEach的条件过滤渲染,实现了6个Tab项的双排布局。这种设计在Tab项较多且可以按功能层级分组时非常实用——上排放核心功能(豆单/烘焙/冲煮),下排放辅助功能(订阅/庄园/我的),信息层级一目了然。bindSheet的3个半模态弹窗用于表单输入场景(购买、订阅、烘焙编辑),bindContentCover的2个全模态弹窗用于详情展示和危险操作确认,两种弹窗的分工明确,交互层级清晰。
在色彩策展层面,整个应用围绕"咖啡棕×奶泡米"的主色调展开,辅以焦糖橙(caramel)作为强调色和金色(gold)作为高光色。roastColor173函数构建了从浅棕到深棕的烘焙度色谱,processC173函数为不同处理法赋予了独特的色彩标识,scoreC173函数通过金色/橙色/灰色的梯度区分评分等级。这些色彩函数不仅服务于视觉美化,更承载了信息传递的功能——用户通过颜色就能快速感知烘焙度、处理法和评分等级。这种"色彩即信息"的设计哲学,是本次策展最核心的馆藏价值所在。
更多推荐



所有评论(0)