HarmonyOS 6糖画技艺FlexAlign.Center 表示在 Flex 容器的主轴方向上居中对齐,配合 Column 容器默认的垂直主轴方向,使得生肖字符精确地位于圆形的中心位置
一、糖画技艺的历史文化背景
糖画,这一门以糖为墨、以勺为笔的民间绝活,是中国非物质文化遗产中一颗璀璨的明珠。它将饮食与艺术融为一体,在方寸之间凝聚了千百年来劳动人民的智慧与审美。糖画的起源可以追溯至唐代,彼时被称为"糖狮",是以糖浆浇铸成各种瑞兽形状的祭祀供品。据《唐书》记载,宫廷宴饮之中已有"以糖铸狮象"的记载,匠人们将蔗糖加热至融化状态,趁热浇入预制的模具之中,冷却凝固后便成为晶莹剔透的糖塑。这种早期的糖塑技艺,虽然尚不具备后世糖画那般行云流水的绘画特征,却已奠定了以糖为材、以热塑形的基本工艺框架,为糖画艺术的萌芽播下了最初的种子。

到了宋代,随着城市经济的繁荣和市民文化的兴盛,糖画开始从宫廷走向市井。北宋都城汴梁的夜市之中,已有匠人当街表演浇糖成画的绝技。孟元老在《东京梦华录》中多次描绘汴梁夜市的热闹景象,其中便有"浇糖为戏"的描述。宋代糖画最大的突破在于从模具浇铸转向徒手浇画——匠人不再依赖模具,而是用铜勺直接将热糖浆在大理石板上勾画浇铸,一气呵成。这一转变使得糖画从单纯的食品造型升华为即兴的绘画艺术,每一幅作品都是独一无二的创作,凝聚着匠人即时的灵感与功力。勺工、腕功、火候控制等核心技艺在这一时期逐渐成熟,形成了体系化的传承脉络。
明清两代是糖画艺术发展的鼎盛时期。明代《天工开物》中详细记载了制糖工艺的各个环节,为糖画提供了优质的原料基础。清代糖画已遍布大江南北,各地形成了鲜明的地域特色:四川糖画以气势磅礴见长,龙凤造型威武灵动;北京厂甸庙会的糖画以精致小巧取胜,多画十二生肖和吉祥纹样;苏州糖画则融入了吴门画派的雅致气韵,造型清秀、线条流畅。每逢庙会、灯会、赶集之日,糖画摊位前总是围满了大人小孩,孩子们瞪大眼睛看着匠人手腕翻飞,一勺糖浆在石板上流淌出飞龙、彩凤、金鱼、玉兔,那种从液态到固态的神奇转变,那种从无到有的创作过程,构成了几代中国人共同的童年记忆。
糖画的工具极为简朴——一把铜勺、一块大理石板、一口熬糖的小铜锅、一支竹签,便是全部家当。然而正是这些看似简陋的工具,在匠人手中却能变幻出万千气象。铜勺是糖画的"画笔",勺口的形状、勺柄的长度、铜的厚薄都直接影响出糖的速度和线条的粗细。经验丰富的匠人能通过手腕的角度控制糖浆的流量,画细线时微微翘起勺口,画粗线时则放低勺口加大流量。大理石板是糖画的"画布",石板的光洁程度和温度直接影响糖画的成型效果——石板太热则糖浆不凝,太冷则糖浆凝固过快来不及勾画。熬糖的铜锅小火慢炖,糖浆的温度需要始终保持在适宜操作的区间,温度过高则糖色发黑、口感发苦,温度过低则糖浆粘稠、无法流畅浇画。竹签是用来粘接糖画与木棍的辅助工具,匠人趁糖浆未完全凝固时将竹签一端压入糖画之中,待冷却后便可手持竹签举起整幅糖画。
糖画的题材极为丰富,几乎涵盖了中国民间美术的全部经典母题。飞禽走兽中,龙、凤、鱼、虎是最受欢迎的造型,龙象征吉祥尊贵,凤寓意美满姻缘,鱼谐音"余"代表年年有余,虎则驱邪避灾。十二生肖是糖画永恒的主题,每个孩子都渴望得到一只属于自己的生肖糖画。此外还有蝴蝶恋花、螃蟹横行、灯笼高挂、福禄寿喜等吉祥图案,以及书法糖字——匠人以糖浆直接书写汉字,将书法艺术与糖画技艺完美融合。每一幅糖画都是一次即兴创作,匠人需要在数秒之内完成从构思到浇铸的全过程,因为糖浆一旦离开火源便开始凝固,留给创作的时间窗口极为短暂。
糖画艺人的传承方式以口传心授为主,师傅带徒弟,少则三五年,多则十年以上方能出师。学徒要从最基本的化糖学起,掌握不同糖料的配比和火候控制;继而练习勺工,在石板上反复练习直线、曲线、螺旋线等基本笔法;再逐步学习各种造型的画法,从简单的灯笼、蝴蝶到复杂的盘龙、飞凤,每一步都需要大量的练习和体会。糖画技艺的传承不仅仅是技术的传授,更是审美观念和文化记忆的延续。每一位老艺人的手中都承载着几十年甚至上百年的技艺积淀,他们的每一次挥勺都是对传统文化的致敬和延续。
进入现代社会,糖画艺术面临着前所未有的挑战与机遇。一方面,城市化进程加速了传统庙会和市集的衰落,糖画艺人的生存空间日益缩小;年轻一代对这门古老技艺的兴趣逐渐淡漠,传承断层问题日趋严重。另一方面,非物质文化遗产保护运动的兴起为糖画注入了新的活力,各地纷纷将糖画列入非遗名录,举办技艺展演和传承培训。在数字化时代,如何利用新技术、新平台来记录、传播和创新糖画艺术,成为文化传承者需要思考的重要课题。正是基于这样的文化背景和技术需求,本项目以 HarmonyOS ArkTS 声明式 UI 框架为载体,将糖画技艺的丰富内涵转化为一个交互式的移动应用,让更多人通过现代科技手段了解和欣赏这门古老的民间艺术。
二、色彩体系:ColorPalette 接口与 COLORS 常量
2.1 ColorPalette 接口定义
interface ColorPalette {
bg: string;
cardBg: string;
copperA: string;
copperB: string;
syrupA: string;
syrupB: string;
syrupC: string;
cream: string;
wheelA: string;
wheelB: string;
pointer: string;
charA: string;
charB: string;
title: string;
sub: string;
text1: string;
text2: string;
text3: string;
accent: string;
hot: string;
cool: string;
danger: string;
tabBg: string;
tabOn: string;
mask: string;
}

ColorPalette 接口是整个色彩体系的类型基础。它定义了二十五个颜色字段,每一个字段都对应着应用中某个特定的视觉角色。在 ArkTS 的类型系统中,interface 用于声明对象的结构类型,这里利用接口将所有颜色集中管理,既保证了类型安全,又使得色彩体系具有极强的可维护性和可扩展性。当需要切换主题色或调整某个具体颜色时,只需修改一处定义,整个应用的视觉风格便会同步更新,这正是集中式色彩管理的核心优势。
这个接口的设计体现了"语义化命名"的理念。每个颜色字段都不是以红、绿、蓝这样的物理色值命名,而是以其在应用中承担的语义角色命名——bg 代表背景色、cardBg 代表卡片背景色、copperA 和 copperB 代表铜器色调的两种深浅版本、syrupA/B/C 代表糖浆色调的三种渐变层次。这种命名方式使得颜色与业务语义紧密绑定,开发者在阅读代码时能够直观地理解每个颜色的用途,而不需要去猜测一个名为"color1"的字段到底用在何处。
值得特别注意的是 mask 字段,它使用的是 rgba(74,46,20,0.5) 格式而非纯十六进制色值。rgba 格式包含了透明度通道(alpha),这里的 0.5 表示百分之五十的不透明度。这个半透明的遮罩颜色专门用于模态弹窗的背景遮罩层,当弹窗弹出时,遮罩层覆盖在底层内容之上,通过半透明效果既遮挡了底层内容的视线焦点,又保留了底层内容的轮廓可见度,营造出一种层次分明的空间纵深感。将 mask 与其他纯色字段并列定义在同一接口中,体现了色彩体系设计的完整性和统一性。
2.2 COLORS 常量实例
const COLORS: ColorPalette = {
bg: '#FBEFDC',
cardBg: '#FFFDF6',
copperA: '#B8703A',
copperB: '#8A4E24',
syrupA: '#D98E2B',
syrupB: '#B8641E',
syrupC: '#F0B85C',
cream: '#FFF3D9',
wheelA: '#E8A94C',
wheelB: '#C8842E',
pointer: '#A8321E',
charA: '#E05A2B',
charB: '#8A4E24',
title: '#FFF8E8',
sub: '#EED9B0',
text1: '#4A2E14',
text2: '#7A5A34',
text3: '#A8895E',
accent: '#C86A1E',
hot: '#D9532E',
cool: '#5E8C6A',
danger: '#D9534F',
tabBg: '#5C3A16',
tabOn: '#F0B85C',
mask: 'rgba(74,46,20,0.5)'
};

COLORS 常量是 ColorPalette 接口的具体实现,它为接口中定义的每一个语义颜色字段赋予了精确的色值。这些色值经过精心调配,整体呈现出温暖、醇厚的琥珀色调,完美契合糖画艺术的传统审美特征。背景色 #FBEFDC 是一种柔和的米黄色,如同旧时庙会上铺着的老粗布桌布,给人温暖亲切之感;卡片背景色 #FFFDF6 则是一种近乎纯白但略带暖意的象牙色,使得卡片内容在米黄背景上能够清晰浮现,同时保持整体色调的和谐统一。
铜器色调 copperA(#B8703A)和 copperB(#8A4E24)模拟了传统糖画工具中铜勺和铜锅的金属色泽。copperA 是较浅的铜色,用于模拟铜器在光照下的高光面;copperB 则是较深的铜色,用于表现铜器的阴影和底色。这两种铜色的搭配使用,赋予了界面元素一种厚重的金属质感,让人联想到老艺人手中那把被岁月磨亮的铜勺。糖浆色调则分为三个层次:syrupA(#D98E2B)是标准糖浆色,呈现浓郁的琥珀金;syrupB(#B8641E)是深糖浆色,接近焦糖;syrupC(#F0B85C)是浅糖浆色,如同刚融化的白糖与黄糖混合后的色泽。这三个层次的糖浆色在转盘渐变、按钮背景、进度条等处交替使用,营造出糖浆流动和光影变化的视觉效果。
文字颜色分为三个层级:text1(#4A2E14)是最深的褐色,用于主要标题和关键信息;text2(#7A5A34)是中等深度的棕褐色,用于次要说明文字;text3(#A8895E)是最浅的灰褐色,用于辅助信息和时间戳等。这种三级文字色阶的设计确保了信息层级的清晰可读——用户一眼便能区分哪些是核心信息、哪些是补充说明。accent(#C86A1E)是强调色,一种偏暖的橙铜色,用于价格、数值、统计等需要突出的关键数据。hot(#D9532E)和 cool(#5E8C6A)是一对功能色:hot 用于高甜度或高温度的警示,色调热烈如灶火;cool 用于低甜度或"已画好"等正面状态,色调沉稳如青瓷。danger(#D9534F)则是危险色,专门用于删除按钮和确认弹窗中的警示操作。
三、标签系统:TabMeta 接口与 TAB_LIST 常量
3.1 TabMeta 接口
interface TabMeta {
label: string;
icon: string;
}

TabMeta 接口定义了底部导航栏每个标签的元数据结构。它仅包含两个字段:label 是标签的文字描述,icon 是标签的图标。这个接口虽然结构简单,却是整个应用导航架构的数据基础。在实际开发中,导航标签是用户与应用交互的入口,每个标签代表着应用的一个功能模块。通过将标签信息抽象为接口类型,开发者可以方便地在代码中统一管理标签数据,新增或修改标签时只需调整数据源而无需改动 UI 逻辑。
icon 字段使用 string 类型来存储 Emoji 字符。这是一种巧妙的设计选择——相比于引入图标库或自定义图标资源,使用 Emoji 具有零依赖、跨平台一致、色彩丰富等优势。每个 Emoji 自身就携带了丰富的视觉语义,例如蜜罐代表糖样、火焰代表熬糖、摩天轮代表转盘、灯笼代表民俗、勺子代表技艺、包裹代表订单,用户一看便能理解每个标签的功能含义。
3.2 TAB_LIST 常量
const TAB_LIST: TabMeta[] = [
{ label: '糖样', icon: '🍯' },
{ label: '熬糖', icon: '🔥' },
{ label: '转盘', icon: '🎡' },
{ label: '民俗', icon: '🏮' },
{ label: '技艺', icon: '🥄' },
{ label: '订单', icon: '📦' }
];

TAB_LIST 是 TabMeta 接口的实例化数组,定义了六个导航标签的完整数据。这六个标签涵盖了糖画技艺应用的全部功能模块:糖样图谱展示各种糖画造型的名称、难度、价格和甜度;熬糖工序展示从化糖到收锅的完整流程;转盘游戏模拟传统糖画摊位上的转盘抽奖;民俗分布展示全国各地糖画摊点的分布和人气;技艺传承展示糖画核心技法的详情和传人信息;订单管理展示糖画的销售订单和出摊数据。
将六个标签分为两行展示(每行三个)是移动端导航设计的常见策略。在手机屏幕上,单行排列六个标签会导致每个标签的宽度过于狭窄,文字和图标会被压缩到难以辨认。分两行排列后,每个标签拥有更充裕的展示空间,用户点击的误触概率也大幅降低。ROW1_IDX 和 ROW2_IDX 两个常量数组分别定义了两行标签的索引,ROW1_IDX 为 [0, 1, 2] 对应前三个标签,ROW2_IDX 为 [3, 4, 5] 对应后三个标签,这种通过索引数组而非直接硬编码的方式来分行,使得标签数量和排列方式的调整变得非常灵活。
3.3 索引与数据常量数组
const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const SLICE_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7];
const DRIP_IDX: number[] = [0, 1, 2, 3];
const SPOON_IDX: number[] = [0, 1, 2];
const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_SALES: number[] = [5200, 6400, 4600, 7800, 5900, 8600];
const MONTH_NAMES: string[] = ['三月', '四月', '五月', '六月', '七月', '八月'];
const ZODIAC_CHARS: string[] = ['龙', '凤', '鱼', '猴', '虎', '兔', '蛇', '马'];
const STRIP_NAMES: string[] = ['糖样', '技法', '学徒'];

这一组常量数组承担着多种不同的数据职责。ROW1_IDX 和 ROW2_IDX 用于导航标签的分行渲染,已在上一节中详细说明。SLICE_IDX 定义了转盘上八个生肖字符的索引序列,对应 ZODIAC_CHARS 数组中的八个汉字——龙、凤、鱼、猴、虎、兔、蛇、马。这些生肖字符不仅出现在转盘上,还用作糖样卡片的装饰图标,通过取模运算 index % 8 实现循环复用,当糖样数量超过八个时,生肖图标会从头循环显示。
DRIP_IDX 定义了四个糖浆滴落效果的索引,用于转盘顶部模拟糖浆滴落的动画——四个小柱体在转盘上方以不同的延迟时间交替变化透明度,营造出糖浆持续滴落的视觉效果。SPOON_IDX 定义了统计条三个分区的索引,对应 STRIP_NAMES 中的"糖样"、“技法”、"学徒"三个标签。MONTH_IDX、MONTH_SALES 和 MONTH_NAMES 三个数组共同构成了订单页面的月度柱状图数据——三月到八月的销售额分别为五千二百、六千四百、四千六百、七千八百、五千九百和八千六百,从中可以直观地看出夏季(六月到八月)是糖画销售的旺季,这与夏季夜市和庙会活动频繁的现实情况高度吻合。
3.4 工具函数群
function sliceAngle(i: number): number {
return i * 45;
}
function sliceX(i: number): number {
return 130 + 52 * Math.sin(i * 0.7854);
}
function sliceY(i: number): number {
return 60 + 52 * (1 - Math.cos(i * 0.7854)) / 2;
}
function dripY(i: number): number {
return 118 + i * 10;
}

这四个函数是转盘布局的核心计算工具。sliceAngle 函数根据索引返回转盘扇区的角度,每个扇区占据四十五度(三百六十度除以八个扇区)。sliceX 和 sliceY 两个函数则利用三角函数计算生肖字符在转盘圆周上的精确坐标位置。其中 0.7854 是四十五度对应的弧度值(π/4 ≈ 0.785398),52 是圆周半径的像素值,130 和 60 是圆心的基准坐标。通过 Math.sin 和 Math.cos 函数,将极坐标(角度)转换为笛卡尔坐标(x, y),实现了八个生肖字符均匀分布在转盘圆周上的效果。
dripY 函数计算糖浆滴落效果的纵向位置,第 i 滴糖浆的 y 坐标为 118 加上 i 乘以 10,即每滴糖浆之间相隔十个像素。这种等间距排列使得四滴糖浆从上到下整齐排列,配合各自的动画延迟时间,形成了一种连续滴落的视觉效果——当第一滴糖浆的透明度达到最高时,第二滴才开始变化,依此类推,形成一种波浪式的流动感。
function sweetColor(s: number): string {
if (s >= 92) {
return COLORS.hot;
}
if (s >= 80) {
return COLORS.syrupA;
}
if (s >= 65) {
return COLORS.cool;
}
return COLORS.text3;
}
function sweetBarW(s: number): number {
return 16 + Math.min(s, 100) / 1.3;
}

sweetColor 函数根据甜度值返回对应的颜色——甜度九十二以上使用热烈的 hot 色,八十到九十一使用标准的糖浆色 syrupA,六十五到七十九使用沉稳的 cool 色,六十五以下则使用淡灰色 text3。这种分级着色机制使得用户能够通过颜色直观判断甜度的高低,红色代表高甜度、橙色代表中高甜度、绿色代表适中甜度、灰色代表低甜度,符合人们对于甜度的色彩直觉认知。
sweetBarW 函数根据甜度值计算甜度进度条的宽度。基准宽度为十六像素,在此基础上加上甜度值除以一点三的结果(甜度值上限为一百,故最大附加宽度约为七十七像素)。使用 Math.min(s, 100) 确保即使传入大于一百的异常值,宽度也不会溢出。这个函数将一个零到一百的数值映射为十六到九十三像素的视觉宽度,实现了数据的可视化表达。
function heatColor(h: number): string {
if (h >= 150) {
return COLORS.danger;
}
if (h >= 120) {
return COLORS.hot;
}
return COLORS.syrupB;
}
function orderBarH(amount: number): number {
return 22 + Math.min(amount, 9000) / 90;
}
function priceTag(p: number): string {
if (p >= 30) {
return '精工';
}
if (p >= 15) {
return '常见';
}
return '亲民';
}

heatColor 函数与 sweetColor 函数类似,但针对的是熬糖温度。温度一百五十度以上使用 danger 红色表示过热警示,一百二十到一百四十九度使用 hot 橙色表示高温状态,一百二十度以下使用 syrupB 深糖浆色表示正常温度。熬糖是糖画制作中最考验火候的环节,温度的精确控制直接关系到糖浆的流动性和成品的色泽口感,这个函数将温度数值转化为直观的颜色信号,帮助用户理解每个熬糖工序的温度状态。
orderBarH 函数根据订单金额计算柱状图的高度。基准高度二十二像素,加上金额值除以九十的结果(金额上限九千,故最大附加高度约一百像素)。priceTag 函数则根据价格返回一个中文标签——三十元以上为"精工",十五到二十九元为"常见",十五元以下为"亲民"。这种分级标签设计既传达了价格信息,又融入了糖画行业的行话术语,增强了应用的文化沉浸感。
四、数据模型:@Observed 可观察类群
4.1 SugarItem 糖样数据模型
@Observed
export class SugarItem {
name: string;
figure: string;
difficulty: number;
price: number;
sweetness: number;
constructor(name: string, figure: string, difficulty: number, price: number, sweetness: number) {
this.name = name;
this.figure = figure;
this.difficulty = difficulty;
this.price = price;
this.sweetness = sweetness;
}
}

SugarItem 是糖样数据的核心模型类,使用了 ArkTS 的 @Observed 装饰器进行标记。@Observed 装饰器的核心作用是使类的实例成为可观察对象——当实例的属性值发生变化时,绑定了该实例的 UI 组件会自动重新渲染,无需手动触发刷新。这是 ArkUI 声明式 UI 范式的响应式数据流基础:数据变化驱动 UI 更新,开发者只需关注数据状态的管理,框架负责将状态变化精确地同步到视图层。
SugarItem 类包含五个属性:name 是糖样的名称(如"游龙戏珠"),figure 是造型描述(如"立体盘龙"),difficulty 是制作难度评分(零到一百的数值),price 是售价(以元为单位),sweetness 是甜度值(零到一百的数值)。构造函数接收五个参数并依次赋值给实例属性,这种传统的构造函数模式在 ArkTS 中是 @Observed 类的标准写法。值得注意的是,这些属性都是普通类型而非 @State 或 @Prop 装饰的类型——@Observed 作用于类级别,使得所有实例属性都具备可观察性,而 @State 等装饰器作用于组件内部的状态变量,两者是不同层级的响应式机制。
糖样数据是整个应用最核心的数据模型,因为糖样图谱是应用第一个标签页的内容,也是用户最常浏览的功能模块。SugarItem 的五个属性完整地描述了一个糖画作品的关键特征:从名称可以了解作品的文化寓意,从造型描述可以想象其外观形态,从难度可以判断制作门槛,从价格可以做出消费决策,从甜度可以预判口感体验。这种多维度的数据建模使得每一个糖样卡片都能呈现丰富的信息。
4.2 BoilItem 熬糖工序数据模型
@Observed
export class BoilItem {
stage: string;
detail: string;
temp: number;
time: string;
master: string;
constructor(stage: string, detail: string, temp: number, time: string, master: string) {
this.stage = stage;
this.detail = detail;
this.temp = temp;
this.time = time;
this.master = master;
}
}

BoilItem 类描述了熬糖工序中的每一个阶段。stage 是工序名称(如"化糖"、“起泡”、“拉丝"等),detail 是该工序的详细操作说明,temp 是该工序的推荐温度(以摄氏度为单位),time 是该工序的持续时间(以古风计时方式表达,如"一炷香”、"一盏茶"等),master 是负责该工序的师傅姓名。
这个数据模型的设计体现了传统文化与现代数据的融合。time 字段没有使用现代的时间单位(分钟、秒),而是采用了古代民间的计时方式——"一炷香"约等于三十分钟,"一盏茶"约等于十五分钟,"半炷香"约十五分钟,"片刻"约五分钟。这种设计不仅增加了应用的文化氛围,也还原了糖画艺人的真实工作场景:在庙会摊位上,老师傅们确实是以燃烧的香或冲泡的茶来估算熬糖时间的,而不是看手表或计时器。master 字段则引入了"糖师傅"和"陈火头"两位虚拟角色,暗示了糖画摊位上通常由两人协作——一位擅长勺工的"糖师傅"负责画糖,一位擅长火候的"陈火头"负责熬糖控温。
temp 字段的数值范围在一百到一百四十二度之间,这与实际熬糖的温度区间高度吻合。白糖的熔点约为一百八十五度,但在实际操作中,糖浆通常被加热到一百一十到一百五十度之间——这个温度区间内,糖浆具有最佳的流动性和成型性。BoilItem 中的温度数据配合 heatColor 函数使用,在 UI 中以颜色直观呈现每道工序的温度高低,用户一眼便能看出"观色"和"拉丝"两道工序的温度最高,这也是整个熬糖流程中最关键的两个火候节点。
4.3 WheelItem 转盘数据模型
@Observed
export class WheelItem {
sector: string;
odds: string;
reward: string;
hot: number;
constructor(sector: string, odds: string, reward: number, hot: number) {
this.sector = sector;
this.odds = odds;
this.reward = reward;
this.hot = hot;
}
}

WheelItem 类描述了糖画转盘上每个扇区的信息。sector 是扇区对应的糖画造型名称(如"大龙"、“凤凰"等),odds 是中签概率的中文描述(如"十二分之一”、“八分之一"等),reward 是中奖后的奖品说明(如"免费重转”、"加赠糖片"等),hot 是人气值(零到一百的数值)。
传统糖画摊位上常设有一个转盘,顾客转动转盘,指针停在哪个扇区就得到哪种糖画。不同造型的糖画制作难度不同,因此中签概率也不同——"大龙"最为复杂,概率最低仅为十二分之一;“谢谢"则是最简单的"空手而归”,概率高达二分之一。这种概率设计既保证了摊主的利润空间,又增加了游戏的趣味性和刺激性。WheelItem 的数据结构完整地复现了这一传统游戏的规则信息。
hot 字段的人气值是一个相对概念,反映了各糖画造型在顾客中的受欢迎程度。从数据来看,"大龙"人气最高为九十六,"谢谢"人气最低为五十二,这与概率成反比——越是难得的造型越受人追捧。这个数值在 UI 中被可视化为一个进度条,宽度随人气值线性变化,配合 syrupA 糖浆色的填充,直观展现了各造型的受欢迎程度排名。
4.4 FolkItem 民俗分布数据模型
@Observed
export class FolkItem {
city: string;
scene: string;
occasion: string;
note: string;
crowds: number;
constructor(city: string, scene: string, occasion: string, note: string, crowds: number) {
this.city = city;
this.scene = scene;
this.occasion = occasion;
this.note = note;
this.crowds = crowds;
}
}
FolkItem 类描述了全国各地糖画摊点的民俗分布信息。city 是城市名称,scene 是具体的街区或景点名称,occasion 是出摊的场合(如"庙会赶集"、"新春灯会"等),note 是该地的糖画文化特色简介,crowds 是日均游客人流量。
这个数据模型涵盖了九个城市的糖画分布信息:成都锦里古街以巴蜀糖画之乡著称,日均游客八千六百人;北京厂甸庙会是京城吹糖人旧俗的传承地;西安回民街以夜市常态形式经营糖画;苏州观前街在评弹书场旁摆摊,融入吴门精致小吃风格;天津古文化街在皇会踩街时最为热闹;重庆磁器口以周末集市形式经营;杭州河坊街延续临安城遗风;南京夫子庙的秦淮灯会配糖画是最经典的组合;广州永庆坊则保留了岭南糖兜传统。
crowds 字段的数据不仅用于展示,还在 UI 中成为信息层级排序的依据。日均游客量越高的摊点,说明该地糖画文化的受众越广、市场越活跃。从数据中可以看出,成都锦里古街的游客量最高(八千六百人),这与四川作为糖画重要发源地之一的地位相吻合;苏州观前街的游客量最低(四千八百人),但苏州糖画以精致著称,走的是精品路线而非走量路线。这些数据为用户了解全国糖画分布格局提供了量化参考。
4.5 SkillItem 技艺传承数据模型
@Observed
export class SkillItem {
name: string;
tool: string;
yearsToLearn: number;
master: string;
heirs: number;
constructor(name: string, tool: string, yearsToLearn: number, master: string, heirs: number) {
this.name = name;
this.tool = tool;
this.yearsToLearn = yearsToLearn;
this.master = master;
this.heirs = heirs;
}
}
SkillItem 类描述了糖画技艺中的每一项核心技法。name 是技法名称(如"勺工"、“腕功”、“吹糖"等),tool 是所需工具(如"铜勺一把”、“悬腕运勺”、"芦管吹泡"等),yearsToLearn 是学成所需的年数,master 是该技法的传承宗师,heirs 是目前掌握该技法的传人数量。
糖画技艺的传承是一个漫长而艰辛的过程。从数据中可以看出,"立体糖"需要十二年才能学成,是所有技法中学习周期最长的——立体糖画需要将平面的糖浆浇铸转化为三维的立体造型,涉及竹签捆扎、多角度浇铸等复杂工艺,对匠人的空间想象力和手部控制力都有极高要求。"吹糖"需要十年学成,匠人需要用芦管将糖浆吹成中空的泡状造型,气息的力度和节奏全凭口耳相传的经验积累。相比之下,"配色"仅需四年,"勺工"需要五年,但这些基础技法同样是不可或缺的根基。
heirs 字段的传人数量反映了各技法的传承现状。"勺工"有十二位传人,是传承最广的技法;“立体糖"仅有四位传人,面临着严重的传承断层风险。这些数据在 UI 中以文字形式呈现,用户可以直观地了解到每项糖画技艺的传承状况,唤起对非物质文化遗产保护的关注。master 字段引入了"糖师傅”、"陈火头"和"赵秀才"三位虚拟宗师角色,暗示了糖画传承中的不同流派——"糖师傅"擅长勺工和腕功等核心画糖技法,"陈火头"精通吹糖、片糖和配色等辅助工艺,"赵秀才"则传承了糖字和糖塑等文雅技法。
4.6 OrderItem 订单数据模型
@Observed
export class OrderItem {
buyer: string;
sugar: string;
count: number;
amount: number;
status: string;
constructor(buyer: string, sugar: string, count: number, amount: number, status: string) {
this.buyer = buyer;
this.sugar = sugar;
this.count = count;
this.amount = amount;
this.status = status;
}
}
OrderItem 类描述了糖画的销售订单信息。buyer 是买方信息(通常包含场所名称,如"锦里·蜀韵阁"),sugar 是订购的糖画名称,count 是订购数量,amount 是订单总金额,status 是订单状态(如"已画好"、“画制中”、“已定糖”、"待确认"等)。
订单状态使用了糖画行业特有的术语:"已画好"表示作品已完成可以交付,"画制中"表示匠人正在制作,"已定糖"表示客户已预付定金等待制作,"待确认"则表示订单细节尚未最终确认。这种行业化的状态命名增强了应用的专业感和文化沉浸感。在 UI 中,"已画好"状态使用 cool 绿色显示,传达积极正向的信号;其他状态则使用 charA 橙色显示,提示用户订单尚在处理中。
OrderItem 的数据涵盖了十二个订单,总金额超过一万五千元,涉及游龙戏珠、凤凰朝阳、连年有鱼等多种糖画造型。配合月度销售柱状图(MONTH_SALES 数据),用户可以全面了解糖画摊位的经营状况:夏季(六到八月)是销售旺季,八月销售额达到八千六百支的最高峰,这与夏季夜市和暑期旅游高峰的现实情况高度吻合。
五、主页面组件:SugarPaintingPage
5.1 组件声明与状态变量
@Entry
@Component
struct SugarPaintingPage {
@State selTab: number = 0;
@State spin: boolean = false;
@State drip: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State editIdx: number = -1;
@State delIdx: number = -1;
@Entry 装饰器标记 SugarPaintingPage 为应用的入口组件——它是整个应用的根 UI 节点,应用启动时首先渲染此组件。@Component 装饰器则声明这是一个自定义组件,可以被其他组件引用或在路由中导航。这两个装饰器的组合是 HarmonyOS ArkTS 应用的标准入口写法。
组件内部声明了八个布尔型和数值型的 @State 变量。@State 是 ArkTS 中最基础的状态装饰器,它使得被装饰的变量成为组件内部的响应式状态——当变量值发生变化时,引用了该变量的 UI 部分会自动重新渲染。selTab 初始值为零,表示应用启动时默认显示第一个标签页(糖样图谱);spin 和 drip 是两个布尔翻转标志,分别控制转盘旋转动画和糖浆滴落动画的交替切换;showAdd、showEdit、showDel 三个布尔变量分别控制三个模态弹窗的显示与隐藏;editIdx 和 delIdx 记录当前正在编辑或删除的数据项索引,初始值 -1 表示尚未选中任何项。
将动画状态(spin 和 drip)设计为布尔翻转标志是一种巧妙的实现方式。ArkUI 的动画系统通过属性值的变化来驱动动画效果——当 spin 从 false 变为 true 时,绑定了 rotate({ angle: this.spin ? 180 : 0 }) 的元素会从零度旋转到一百八十度;当 spin 再次从 true 变为 false 时,元素又从一百八十度旋转回零度。配合 animation({ iterations: -1, playMode: PlayMode.Alternate }) 配置,动画会无限循环、正反交替播放,呈现出持续旋转的视觉效果。drip 标志的工作原理类似,但切换频率更高(每六百毫秒翻转一次),用于驱动甜度进度条的透明度交替变化和糖浆滴落的闪烁效果。
5.2 输入状态变量
@State inpName: string = '';
@State inpFigure: string = '';
@State inpDiff: string = '';
@State inpPrice: string = '';
@State inpSweet: string = '';
@State inpSector: string = '';
@State inpOdds: string = '';
这七个 @State 变量专门用于模态弹窗中的表单输入。inpName 到 inpSweet 五个变量对应糖样数据的五个字段,inpSector 和 inpOdds 对应转盘数据的两个字段。所有变量都使用 string 类型而非 number 类型,这是因为 ArkTS 的 TextInput 组件的 onChange 回调返回的是字符串类型,使用 string 可以避免类型转换的繁琐操作。
在提交表单时,代码会使用 Number() 函数将字符串转换为数值,并配合空值判断确保数据的完整性。例如 Number(this.inpDiff) > 0 ? Number(this.inpDiff) : 60 的逻辑是:如果用户输入了有效的难度值(大于零),则使用输入值;否则使用默认值六十。这种防御性编程模式确保了即使用户留空某些字段,应用也不会因为类型转换错误而崩溃,而是以合理的默认值继续运行。
5.3 数据列表状态
@State sugarList: SugarItem[] = [
new SugarItem('游龙戏珠', '立体盘龙', 95, 38, 88),
new SugarItem('凤凰朝阳', '振翅凤凰', 90, 35, 86),
new SugarItem('连年有鱼', '双鱼戏莲', 78, 25, 90),
new SugarItem('齐天小圣', '猴子摘桃', 82, 28, 84),
new SugarItem('猛虎下山', '卧虎啸风', 80, 30, 82),
new SugarItem('玉兔捣药', '月宫玉兔', 72, 22, 86),
new SugarItem('灵蛇吐信', '蜿蜒游蛇', 76, 24, 80),
new SugarItem('骏马奔腾', '四蹄腾空', 84, 32, 83),
new SugarItem('蝴蝶恋花', '双翅彩蝶', 70, 18, 88),
new SugarItem('螃蟹横行', '八足蟹将', 74, 20, 85),
new SugarItem('灯笼高挂', '宫灯糖片', 55, 12, 92),
new SugarItem('糖字福禄', '书法糖字', 68, 15, 90)
];
sugarList 是糖样数据的核心数组,包含了十二个 SugarItem 实例。这十二个糖样涵盖了龙、凤、鱼、猴、虎、兔、蛇、马等传统生肖和吉祥造型,以及蝴蝶、螃蟹、灯笼、糖字等常见题材。每个糖样的数据都经过精心设计:难度值从五十五到九十五不等,价格从十二元到三十八元呈梯度分布,甜度值集中在八十到九十二之间——这反映了糖画作为甜食的基本属性,几乎所有糖画都具有较高的甜度。
使用 @State 装饰数组类型时,ArkUI 框架会对数组进行深度观察。当数组通过 unshift、splice 等方法进行增删操作时,ForEach 循环会自动更新渲染对应的列表项。这种数组级别的响应式机制是声明式 UI 的核心优势之一——开发者只需操作数据数组,框架负责将数据变化精确地同步到视图层,无需手动操作 DOM 或调用刷新方法。
@State boilList: BoilItem[] = [
new BoilItem('化糖', '白糖黄糖入锅,文火慢化', 110, '一炷香', '糖师傅'),
new BoilItem('起泡', '糖浆冒鱼眼泡,竹筷搅动', 125, '一盏茶', '陈火头'),
new BoilItem('拉丝', '挑糖观丝,挂旗为度', 138, '半炷香', '糖师傅'),
new BoilItem('观色', '琥珀透亮,焦香初起', 142, '片刻', '陈火头'),
new BoilItem('保温', '移小火盅,恒温存糖', 125, '随时', '陈火头'),
new BoilItem('试勺', '勺起糖落,拉线细匀', 130, '随手', '糖师傅'),
new BoilItem('收锅', '收工倒模,余糖成板', 100, '收摊', '陈火头')
];
boilList 数组包含七个 BoilItem 实例,描述了从化糖到收锅的完整熬糖流程。这七道工序的温度数据呈现出一个先升后降的曲线:化糖时温度最低(一百一十度),随着熬制推进温度逐步升高,在"观色"阶段达到峰值(一百四十二度),然后通过"保温"和"试勺"阶段适当降低温度以维持操作适宜性,最后在"收锅"时降至最低(一百度)。这条温度曲线真实地反映了熬糖工艺的科学原理:先低温融化,再高温熬制以达到理想的糖浆浓度和色泽,最后降温保温以延长操作窗口。
@State wheelList: WheelItem[] = [
new WheelItem('大龙', '十二分之一', '免费重转', 96),
new WheelItem('凤凰', '八分之一', '加赠糖片', 88),
new WheelItem('金鱼', '六分之一', '九折优惠', 82),
new WheelItem('猴子', '六分之一', '九折优惠', 78),
new WheelItem('蝴蝶', '五分之一', '小糖一枚', 70),
new WheelItem('灯笼', '四分之一', '小糖一枚', 66),
new WheelItem('福字', '三分之一', '再来一次', 60),
new WheelItem('谢谢', '二分之一', '空手而归', 52)
];
wheelList 数组包含八个 WheelItem 实例,对应转盘上的八个扇区。从概率分布来看,各扇区的概率之和为十二分之一加八分之一加六分之二加五分之一加四分之一加三分之一加二分之一,约等于百分之百,确保了转盘的概率合理性。"大龙"概率最低(十二分之一约百分之八点三),"谢谢"概率最高(二分之一即百分之五十),这种设计保证了摊主在大量顾客参与时的总体盈利空间。
@State folkList: FolkItem[] = [
new FolkItem('成都', '锦里古街', '庙会赶集', '巴蜀糖画之乡', 8600),
new FolkItem('北京', '厂甸庙会', '新春灯会', '京城吹糖人旧俗', 6200),
new FolkItem('西安', '回民街', '夜市常态', '八百里秦川味道', 7400),
new FolkItem('苏州', '观前街', '评弹书场', '吴门精致小吃', 4800),
new FolkItem('天津', '古文化街', '皇会踩街', '卫嘴子好甜口', 5600),
new FolkItem('重庆', '磁器口', '周末集市', '山城巷子糖香', 6800),
new FolkItem('杭州', '河坊街', '宋城游园', '临安城遗风', 5900),
new FolkItem('南京', '夫子庙', '秦淮灯会', '金陵灯彩配糖画', 7100),
new FolkItem('广州', '永庆坊', '醒狮巡游', '岭南糖兜传统', 4300)
];
folkList 数组包含九个 FolkItem 实例,覆盖了华东、华北、西北、西南、华南等主要区域。成都以八千六百的日均游客量位居榜首,这与四川糖画作为国家级非物质文化遗产的地位相称。南京夫子庙以七千一百的游客量紧随其后,秦淮灯会与糖画的组合是金陵古城最具代表性的民俗活动之一。广州永庆坊以四千三百的游客量垫底,但岭南糖兜传统有其独特的文化价值。
@State skillList: SkillItem[] = [
new SkillItem('勺工', '铜勺一把', 5, '糖师傅', 12),
new SkillItem('腕功', '悬腕运勺', 8, '糖师傅', 8),
new SkillItem('吹糖', '芦管吹泡', 10, '陈火头', 5),
new SkillItem('片糖', '大理石板', 6, '陈火头', 9),
new SkillItem('立体糖', '竹签捆扎', 12, '糖师傅', 4),
new SkillItem('糖字', '书法底子', 7, '赵秀才', 6),
new SkillItem('糖塑', '捏塑成形', 9, '赵秀才', 5),
new SkillItem('配色', '食色调糖', 4, '陈火头', 11)
];
skillList 数组包含八个 SkillItem 实例,描述了糖画的八项核心技法。"立体糖"需要十二年学成、仅有四位传人,是传承最艰难的技法;"配色"只需四年学成、有十一位传人,是相对容易普及的技法。这种数据设计真实地反映了非遗技艺传承的现状:越是复杂精深的技法,学习周期越长,传人越少,传承断层的风险也越大。
@State orderList: OrderItem[] = [
new OrderItem('锦里·蜀韵阁', '游龙戏珠', 50, 1900, '已画好'),
new OrderItem('庙会·厂甸摊位', '凤凰朝阳', 45, 1575, '画制中'),
new OrderItem('灯会·秦淮人家', '连年有鱼', 80, 2000, '已画好'),
new OrderItem('茶馆·鹤鸣社', '糖字福禄', 120, 1800, '已画好'),
new OrderItem('书院·草堂学舍', '玉兔捣药', 60, 1320, '画制中'),
new OrderItem('酒楼·醉长安', '猛虎下山', 30, 900, '已定糖'),
new OrderItem('戏园·易俗大剧院', '齐天小圣', 36, 1008, '画制中'),
new OrderItem('婚庆·花好月圆', '蝴蝶恋花', 90, 1620, '已画好'),
new OrderItem('商号·瑞蚨祥', '骏马奔腾', 24, 768, '已定糖'),
new OrderItem('夜市·磁器口摊', '螃蟹横行', 55, 1100, '已画好'),
new OrderItem('灯彩·秦淮灯铺', '灯笼高挂', 150, 1800, '画制中'),
new OrderItem('文創·宽窄伴手', '灵蛇吐信', 42, 1008, '待确认')
];
orderList 数组包含十二个 OrderItem 实例,模拟了糖画摊位的真实订单场景。买方信息中包含了茶馆、酒楼、戏园、婚庆、商号、书院等多种业态,反映了糖画在不同社交场合中的应用需求。订单状态分布为四个"已画好"、三个"画制中"、两个"已定糖"、一个"待确认",呈现出一个正在进行中的业务流水状态。
六、生命周期:aboutToAppear
aboutToAppear(): void {
setInterval(() => {
this.spin = !this.spin;
}, 1600);
setInterval(() => {
this.drip = !this.drip;
}, 600);
}
aboutToAppear 是 ArkUI 组件的生命周期回调函数,在组件创建后、页面渲染前被调用。它通常用于初始化数据、启动定时器或发起网络请求。在这个实现中,aboutToAppear 启动了两个 setInterval 定时器,分别用于驱动转盘旋转动画和糖浆滴落动画。
第一个定时器每一千六百毫秒(一点六秒)翻转一次 spin 变量的布尔值。这个翻转会触发所有引用了 this.spin 的 UI 元素重新渲染——转盘的外圈和内圈会交替旋转一百八十度,生肖字符也会同步旋转,指针则在正负二十六度之间摆动。一千六百毫秒的间隔与动画配置中的 duration: 1600 完全匹配,确保了每次翻转恰好对应一个完整的动画周期,视觉上呈现出无缝衔接的持续旋转效果。
第二个定时器每六百毫秒翻转一次 drip 变量。drip 变量驱动的动画更多、更细腻:糖浆滴落效果的透明度在零点一五和零点九之间交替,甜度进度条的透明度在零点七和一点零之间交替,熬糖火焰图标的透明度在零点六和一点零之间交替,月度柱状图的透明度也在零点七和一点零之间交替。六百毫秒的高频翻转使得这些动画呈现出一种持续的"呼吸感",仿佛整个界面都在轻微地脉动,充满了生命力。
这种通过定时器翻转布尔标志来驱动动画的设计模式,是 ArkUI 中实现持续循环动画的常见方案。相比于纯 CSS 动画或属性动画,这种方式的优势在于可以通过 JavaScript 逻辑精确控制动画的启动、停止和切换时机。例如,如果未来需要在特定条件下暂停动画,只需清除对应的定时器即可,灵活性很高。
七、UI 构建:@Builder 方法群
7.1 modalOverlay 遮罩层构建器
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
modalOverlay 是一个通用的模态遮罩层构建器,它接收一个 onClose 回调函数作为参数。@Builder 装饰器是 ArkUI 中用于定义可复用 UI 片段的装饰器——被 @Builder 修饰的方法返回一个 UI 组件树,可以在其他位置通过 this.methodName() 的方式调用,实现 UI 的模块化复用。
这个构建器创建了一个铺满整个屏幕的 Column 组件,设置了百分之百的宽度和高度,背景色为 COLORS.mask(即 rgba(74,46,20,0.5) 半透明褐色)。当用户点击遮罩层时,会触发 onClick 回调,进而调用传入的 onClose 函数关闭当前弹窗。这种"点击遮罩关闭弹窗"的交互模式是移动端模态弹窗的标准设计——用户无需寻找关闭按钮,点击弹窗外的任意区域即可关闭,操作极为便捷。
将遮罩层抽离为独立的 @Builder 方法是优秀的代码复用实践。应用中有三个模态弹窗(添加、编辑、删除),它们都需要遮罩层,如果在每个弹窗中重复编写遮罩层代码,不仅冗余且难以维护。通过 modalOverlay 构建器,遮罩层的实现只需一处定义,三处调用,任何遮罩层样式的调整只需修改一处代码即可全局生效。onClose 参数的回调设计也保证了灵活性——不同弹窗的关闭逻辑各不相同(showAdd、showEdit、showDel 分别置为 false),通过传入不同的回调函数即可实现差异化处理。
7.2 wheelHeader 转盘头部构建器
@Builder
wheelHeader() {
Column({ space: 0 }) {
Stack() {
Column()
.width(150)
.height(40)
.borderRadius({ topLeft: 20, topRight: 20 })
.backgroundColor(COLORS.copperA)
.position({ x: 55, y: 18 })
Column()
.width(110)
.height(14)
.borderRadius(7)
.backgroundColor(COLORS.copperB)
.position({ x: 75, y: 56 })
wheelHeader 构建器是整个应用最具视觉冲击力的组件——它模拟了一个传统糖画摊位上的铜锅和转盘。构建器使用 Column 作为外层容器,内部嵌套一个 Stack 堆叠容器。Stack 容器的特点是所有子元素默认居中堆叠,后声明的元素覆盖在先声明的元素之上,非常适合用来构建多层叠加的复杂视觉效果。
Stack 内部的第一个子元素是一个宽一百五十、高四十的圆角矩形,模拟铜锅的顶部边缘。它使用了非对称的圆角设置 borderRadius({ topLeft: 20, topRight: 20 }),只有左上和右上有圆角,底部则是直角,这种形状模拟了铜锅锅沿的弧形轮廓。背景色 copperA 是较浅的铜色,模拟铜器在光照下的高光面。position 属性将其精确定位在坐标 (55, 18) 处,这个坐标是经过计算的——使铜锅沿在 Stack 容器中水平居中。
第二个子元素是铜锅的颈部连接段,宽一百一十、高十四的圆角矩形,使用较深的 copperB 色调。它位于铜锅沿下方(y 坐标为五十六),模拟铜锅从锅沿到锅身的过渡部分。两个圆角矩形的垂直堆叠形成了一个类似花瓶轮廓的铜锅造型,既有传统铜器的厚实质感,又有优美的曲线比例。
Column()
.width(130)
.height(130)
.borderRadius(65)
.backgroundColor(COLORS.wheelA)
.position({ x: 65, y: 66 })
.rotate({ angle: this.spin ? 180 : 0 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
Column()
.width(96)
.height(96)
.borderRadius(48)
.backgroundColor(COLORS.wheelB)
.position({ x: 82, y: 83 })
.rotate({ angle: this.spin ? -180 : 0 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
转盘由两个同心圆形组成。外圈直径一百三十像素(半径六十五),使用较浅的 wheelA 金色;内圈直径九十六像素(半径四十八),使用较深的 wheelB 铜金色。两个圆形通过 position 属性精确定位,使它们在视觉上同心对齐。外圈和内圈的旋转方向相反——外圈在 spin 为 true 时旋转一百八十度,内圈则旋转负一百八十度。这种反向旋转的设计产生了强烈的视觉层次感,仿佛转盘有内外两层独立运转的齿轮,增强了动画的动态美感。
两个圆形的动画配置完全相同:持续一千六百毫秒、无限循环、交替播放模式、缓动曲线为 EaseInOut。PlayMode.Alternate 是动画播放模式的关键设置——它使动画在正向播放完毕后自动反向播放,形成一个完整的来回循环。Curve.EaseInOut 缓动曲线使得动画在开始和结束时减速、中间加速,模拟了物理世界中转盘因惯性而缓慢启停的效果,比线性动画更加自然流畅。
ForEach(SLICE_IDX, (i: number) => {
Column() {
Text(ZODIAC_CHARS[i])
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.pointer)
}
.width(22)
.height(22)
.borderRadius(11)
.backgroundColor(COLORS.cream)
.position({ x: sliceX(i), y: sliceY(i) + 66 })
.rotate({ angle: this.spin ? 180 : 0 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
}, (i: number) => 'zc' + i)
转盘上的八个生肖字符通过 ForEach 循环渲染。每个字符被包裹在一个直径二十二像素的圆形容器中,背景色为 cream 奶白色,文字使用 pointer 深红色加粗显示。每个圆形容器的位置由 sliceX 和 sliceY 两个工具函数计算得出,使得八个字符均匀分布在转盘外圈的圆周上。y 坐标额外加上六十六,是为了将字符的坐标系从转盘顶部为基准调整为以 Stack 容器顶部为基准。
八个生肖字符也绑定了与转盘相同的旋转动画,使得它们随转盘同步旋转。ForEach 的第三个参数是键值生成函数 'zc' + i,它为每个渲染项生成唯一的键——zc0、zc1、zc2…以此类推。这个键值是 ArkUI 列表渲染的性能优化机制:当列表数据发生变化时,框架通过键值来判断哪些项目需要更新、哪些可以复用,避免了不必要的全量重渲染,显著提升了列表的渲染性能。
Column()
.width(4)
.height(34)
.borderRadius(2)
.backgroundColor(COLORS.pointer)
.position({ x: 128, y: 62 })
.rotate({ angle: this.spin ? 26 : -26 })
.animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
Column()
.width(16)
.height(16)
.borderRadius(8)
.backgroundColor(COLORS.pointer)
.position({ x: 122, y: 123 })
转盘的指针是一个宽四像素、高三十四像素的细长矩形,使用 pointer 深红色。指针的旋转角度在正负二十六度之间交替,动画持续时间为八百毫秒(是转盘旋转周期的一半),这意味着指针在一个转盘旋转周期内完成两次完整的摆动。这种设计模拟了真实转盘指针的物理行为——当转盘旋转时,指针因弹簧或重力的作用而来回摆动,最终停在某个扇区上。指针的动画频率是转盘的两倍,形成了视觉上的"嵌套"效果,增加了动画的层次感和趣味性。
指针下方是一个直径十六像素的圆点,同样使用 pointer 深红色,模拟指针的轴心或转盘的中心钉。这个圆点没有绑定动画,是转盘中唯一的静态元素,为旋转的转盘提供了一个视觉锚点,使得用户在观看旋转动画时有一个稳定的参照物。
ForEach(DRIP_IDX, (i: number) => {
Column()
.width(5)
.height(8)
.borderRadius({ bottomLeft: 4, bottomRight: 4 })
.backgroundColor(COLORS.syrupB)
.position({ x: 96 + i * 14, y: dripY(i) })
.opacity(this.drip ? 0.9 : 0.15)
.animation({ duration: 600, delay: i * 150, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseIn })
}, (i: number) => 'dp' + i)
}
.width('100%')
.height(210)
.linearGradient({
angle: 180,
colors: [[COLORS.syrupA, 0], [COLORS.syrupB, 1]]
})
四滴糖浆通过 ForEach 循环渲染在转盘上方。每滴糖浆是一个宽五像素、高八像素的小柱体,底部有四像素的圆角,模拟水滴的圆润底部。背景色 syrupB 是深糖浆色。四滴糖浆的横向位置以十四像素的间距均匀排列(96 + i * 14),纵向位置由 dripY 函数计算,从一百一十八像素开始每滴增加十像素。
每滴糖浆的透明度在零点一五和零点九之间交替变化,动画持续六百毫秒。关键在于 delay: i * 150 的延迟设置——第一滴糖浆没有延迟,第二滴延迟一百五十毫秒,第三滴延迟三百毫秒,第四滴延迟四百五十毫秒。这种递增延迟使得四滴糖浆的透明度变化不是同步的,而是呈现出一种从左到右、从上到下的波浪式传播效果,仿佛糖浆正在连续滴落。Curve.EaseIn 缓动曲线使得每滴糖浆在变为透明时先慢后快,模拟了水滴下落时加速的物理效果。
Stack 容器的高度被设置为二百一十像素,并应用了一个一百八十度的线性渐变背景——从顶部的 syrupA 浅糖浆色渐变到底部的 syrupB 深糖浆色。这个渐变背景模拟了铜锅中糖浆从上到下逐渐变深的色泽层次,营造出糖浆正在锅中熬制的沉浸感。整个转盘头部组件通过铜锅造型、双层旋转圆盘、八个生肖字符、摆动指针、滴落糖浆和渐变背景六个视觉层次的叠加,构建出了一个生动逼真的糖画摊位场景。
Row({ space: 10 }) {
Column()
.width(4)
.height(26)
.borderRadius(2)
.backgroundColor(COLORS.title)
Column({ space: 2 }) {
Text('糖画坊 · 勺走龙蛇')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('以糖为墨 · 以勺为笔')
.fontSize(10)
.fontColor(COLORS.sub)
.letterSpacing(2)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 18, top: 8, bottom: 8 })
}
.width('100%')
}
转盘头部下方是应用标题区域。使用 Row 容器水平排列三个元素:一个竖条装饰(宽四、高二十六的圆角矩形,使用 title 浅色)、标题文字"糖画坊 · 勺走龙蛇"(二十号粗体)和副标题"以糖为墨 · 以勺为笔"(十号,字间距为二)。竖条装饰是 UI 设计中常见的视觉手法——在文字左侧放置一条彩色竖线,既能起到引导视线的作用,又能增加标题区域的视觉重量,使其与下方内容形成明确的层级区分。
副标题"以糖为墨 · 以勺为笔"是对糖画艺术最精炼的概括——糖浆替代了墨汁,铜勺替代了画笔,大理石板替代了宣纸,匠人以烹饪工具完成绘画创作。letterSpacing(2) 属性设置了两个字间距的额外间距,使得副标题文字之间有更舒展的呼吸空间,增强了文案的雅致感和仪式感。标题和副标题都使用了浅色系(title 浅米色和 sub 暖灰色),在渐变背景上形成了柔和但清晰的文字层次。
7.3 sweetStrip 统计条构建器
@Builder
sweetStrip() {
Row({ space: 8 }) {
ForEach(SPOON_IDX, (i: number) => {
Column({ space: 2 }) {
Text(STRIP_NAMES[i])
.fontSize(12)
.fontColor(COLORS.text2)
Text(this.stripCount(i).toString() + ' 项')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding(8)
.backgroundColor(COLORS.cream)
.borderRadius(14)
}, (i: number) => 'sp' + i)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10 })
}
sweetStrip 构建器渲染了转盘下方的三个统计区块。使用 Row 容器水平排列三个等宽的 Column 区块,每个区块通过 layoutWeight(1) 实现等分宽度。每个区块包含两行文字:上方是分类名称(“糖样”、“技法”、“学徒”),使用十二号 text2 棕色;下方是数量统计(如"12 项"),使用十五号加粗 accent 橙色。
layoutWeight 是 ArkUI 中 Flex 布局的核心属性,它指定了子元素在主轴方向上的权重。当三个 Column 都设置 layoutWeight(1) 时,它们会平分 Row 容器的可用宽度,无论屏幕尺寸如何变化,三个区块始终保持等宽。这种弹性布局方式确保了在不同屏幕尺寸的设备上,统计条都能正确适配,不会出现某个区块过宽或过窄的问题。
每个区块使用 cream 奶白色作为背景色,与主背景色 bg 形成了柔和的层次区分。十四的圆角值使得区块外观圆润亲切,与糖画温暖的主题调性一致。区块内部的 alignItems(HorizontalAlign.Center) 确保文字水平居中对齐,padding(8) 提供了适度的内边距,使文字与区块边缘保持舒适的间距。
stripCount(i: number): number {
if (i === 0) {
return this.sugarList.length;
}
if (i === 1) {
return this.skillList.length;
}
return this.skillList.length + this.boilList.length;
}
stripCount 是一个辅助方法,根据索引返回对应统计区块的数量。第一个区块(索引零)显示糖样数量,即 sugarList 数组的长度(十二);第二个区块(索引一)显示技法数量,即 skillList 数组的长度(八);第三个区块(索引二)显示"学徒"数量,即技法数量加上熬糖工序数量(八加七等于十五),这个计算逻辑暗示了"学徒需要同时学习技法和熬糖工序"的业务含义。
这个方法虽然简单,但体现了 ArkTS 组件中方法与状态的良好配合。当 sugarList 或 skillList 发生增删变化时,stripCount 方法的返回值会自动更新(因为它直接读取数组的 length 属性),进而触发 sweetStrip 构建器重新渲染,统计数字实时同步。这种数据驱动的响应式更新是声明式 UI 的核心优势。
7.4 sugarCard 糖样卡片构建器
@Builder
sugarCard(item: SugarItem, index: number) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 0 }) {
Text(ZODIAC_CHARS[index % 8])
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.copperB)
}
.width(46)
.height(46)
.borderRadius(23)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.rotate({ angle: this.spin ? 8 : -8 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate })
sugarCard 构建器是糖样列表中每个糖画作品的展示卡片。卡片采用 Column 作为外层容器,内部分为两个主要区域:上方的信息行和下方的甜度进度条。
信息行的左侧是一个直径四十六像素的圆形头像区域,显示 ZODIAC_CHARS 数组中的生肖字符。通过 index % 8 的取模运算,十二个糖样循环复用八个生肖字符——索引零对应"龙",索引八也对应"龙",以此类推。这个圆形头像绑定了一个轻微的旋转动画,角度在正负八度之间交替,持续一千六百毫秒。这个微小的摆动效果为原本静态的卡片注入了一丝动感,使得整个列表在浏览时不会显得过于呆板,同时与转盘的旋转动画形成了呼应——卡片头像的摆动频率与转盘旋转频率完全一致,营造出整个页面元素协调律动的视觉效果。
圆形头像使用了 justifyContent(FlexAlign.Center) 来实现文字的垂直水平居中。FlexAlign.Center 表示在 Flex 容器的主轴方向上居中对齐,配合 Column 容器默认的垂直主轴方向,使得生肖字符精确地位于圆形的中心位置。cream 奶白色背景与 copperB 深铜色文字的搭配,模拟了传统铜币的视觉效果——铜色文字印在奶白色的底面上,既有质感又保持了良好的可读性。
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(priceTag(item.price))
.fontSize(10)
.fontColor(COLORS.title)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.charA)
.borderRadius(8)
}
Text(item.figure + ' · 难度 ' + item.difficulty)
.fontSize(11)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
信息行的中间区域是糖样的核心信息展示。使用 layoutWeight(1) 使其占据头像和价格之间的全部可用空间。该区域包含两行:第一行是糖样名称和价格标签的并排排列,名称使用十五号 text1 深褐色加粗,价格标签由 priceTag 函数生成(“精工”、“常见"或"亲民”),使用十号 title 浅色文字配 charA 橙色背景的胶囊形状标签;第二行是造型描述和难度值的组合文字,使用十一号 text3 浅灰色。
价格标签的设计值得注意。它不是一个纯文字,而是一个带有背景色和内边距的小型胶囊标签。padding({ left: 7, right: 7, top: 2, bottom: 2 }) 提供了水平七像素、垂直二像素的内边距,使得标签文字与背景边缘之间有适当的间距。borderRadius(8) 使得标签的四角圆润,呈现出胶囊形状。charA 橙色背景与 title 浅色文字的高对比度使得标签在名称旁边格外醒目,用户一眼便能判断该糖画的价格档次。
第二行文字将造型描述和难度值通过" · “连接符组合在一起,如"立体盘龙 · 难度 95”。这种信息合并方式有效节省了垂直空间——如果将造型和难度分两行显示,卡片的高度会增加,在有限屏幕内可显示的卡片数量就会减少。通过" · "分隔符将两个相关但不同维度的信息合并为一行,既保证了信息的完整性,又控制了卡片的紧凑度。
Column({ space: 2 }) {
Text('¥' + item.price)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Row({ space: 6 }) {
Text('✎')
.fontSize(13)
.fontColor(COLORS.cool)
.onClick(() => {
this.editIdx = index;
this.inpName = item.name;
this.inpFigure = item.figure;
this.inpDiff = item.difficulty.toString();
this.inpPrice = item.price.toString();
this.inpShow = item.sweetness.toString();
this.showEdit = true;
})
Text('删')
.fontSize(12)
.fontColor(COLORS.danger)
.onClick(() => {
this.delIdx = index;
this.showDel = true;
})
}
}
.alignItems(HorizontalAlign.End)
信息行的右侧区域是价格显示和操作按钮。价格以"¥"符号开头,使用十六号 accent 橙色加粗,是卡片中字号最大、颜色最醒目的文字,确保用户在浏览列表时能第一时间注意到价格信息。价格下方是两个操作按钮:编辑("✎"图标,cool 绿色)和删除("删"字,danger 红色)。
编辑按钮的 onClick 回调执行了一系列状态赋值操作:首先将 editIdx 设为当前卡片的索引,然后将该糖样的五个属性值分别赋给对应的输入状态变量(inpName 到 inpSweet),最后将 showEdit 设为 true 以显示编辑弹窗。这种"先填充数据再显示弹窗"的模式确保了编辑弹窗打开时,输入框中已经预填了当前糖样的数据,用户只需修改需要变更的字段即可,无需重新输入全部信息。
删除按钮的 onClick 回调更为简洁——只需将 delIdx 设为当前索引,然后显示删除确认弹窗。删除操作需要二次确认是良好的 UX 实践——防止用户误触导致数据丢失。删除确认弹窗(delModal)会展示"这一勺不画了?"的提示文字,用户确认后才执行实际的 splice 删除操作。
Row({ space: 6 }) {
Text('甜度 ' + item.sweetness)
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.height(6)
.layoutWeight(1)
.borderRadius(3)
.backgroundColor(COLORS.cream)
Column()
.height(6)
.width(sweetBarW(item.sweetness))
.borderRadius(3)
.backgroundColor(sweetColor(item.sweetness))
.opacity(this.drip ? 1 : 0.7)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.shadow({ radius: 5, color: 'rgba(184,100,30,0.14)', offsetX: 0, offsetY: 2 })
}
卡片下方的甜度进度条是 sugarCard 的视觉亮点。进度条由三层元素组成:最底层是"甜度 XX"的文字标签,中间层是一个 layoutWeight(1) 的灰色轨道(cream 色),最上层是一个宽度由 sweetBarW 函数计算的彩色填充条。填充条的颜色由 sweetColor 函数根据甜度值动态决定——高甜度显示 hot 红色,中高甜度显示 syrupA 橙色,适中甜度显示 cool 绿色,低甜度显示 text3 灰色。
填充条还绑定了一个透明度交替动画,在零点七和一点零之间变化,持续六百毫秒。这个动画与 drip 变量同步,使得甜度进度条呈现出一种"脉动"效果——仿佛糖浆在进度条中流动,甜度值在"呼吸"。这种将数据可视化与动画效果相结合的设计手法,使得原本静态的数据展示变得生动有趣,增强了用户的视觉体验。
卡片的外层容器设置了十二像素的内边距、cardBg 白色背景、十八像素的圆角和一层柔和的阴影。阴影参数 shadow({ radius: 5, color: 'rgba(184,100,30,0.14)', offsetX: 0, offsetY: 2 }) 定义了一个向下偏移两像素、模糊半径五、颜色为半透明铜色的阴影效果。这个阴影模拟了卡片在纸面上的投影,增加了卡片的立体悬浮感。使用铜色调而非纯黑色的阴影是色彩设计的精细之处——铜色阴影与整体琥珀色调和谐统一,不会像黑色阴影那样显得突兀生硬。
7.5 addModal 添加弹窗构建器
@Builder
addModal() {
Stack({ alignContent: Alignment.TopStart }) {
this.modalOverlay(() => {
this.showAdd = false;
})
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🍯')
.fontSize(20)
Text('新熬一勺')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.syrupC)
}
.width('100%')
addModal 构建器是"新增糖样"的模态弹窗。它使用 Stack 作为外层容器,设置了 alignContent: Alignment.TopStart 使弹窗内容从左上角对齐。Stack 内部首先调用 modalOverlay 构建器渲染半透明遮罩层,然后在遮罩层之上叠加弹窗主体内容。
弹窗顶部的标题栏使用 Row 水平排列三个元素:蜜罐 Emoji、标题文字"新熬一勺"和一个填充剩余空间的分隔线(layoutWeight(1) 的一像素高 syrupC 色横线)。这种标题栏设计在应用中多次出现——图标加文字加分隔线的组合既传达了弹窗的功能主题,又在视觉上将标题与下方内容做了明确的分割。“新熬一勺"这个标题用语巧妙地借用了糖画行话——将"添加新糖样"这个技术操作表述为"新熬一勺”,既准确又富有文化韵味。
TextInput({ placeholder: '糖样名(如:麒麟送子)', text: this.inpName })
.height(40)
.fontSize(13)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpName = v;
})
TextInput({ placeholder: '造型(立体/平面…)', text: this.inpFigure })
.height(40)
.fontSize(13)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpFigure = v;
})
弹窗主体包含五个 TextInput 输入框,分别对应糖样的五个属性。前两个输入框(名称和造型)各占一行完整宽度,后三个输入框(难度、价格、甜度)通过 Row 容器水平排列,每个使用 layoutWeight(1) 等分宽度。这种"两行单列加一行三列"的布局方式既考虑了不同属性的输入特点(名称和造型通常较长,需要完整一行;难度、价格和甜度通常只是几个数字,可以紧凑排列),又有效控制了弹窗的高度,避免在手机屏幕上超出可视范围。
每个 TextInput 都使用了双向绑定模式:text: this.inpXxx 参数将输入框的初始文本绑定到对应的状态变量,onChange 回调则在用户输入时将新值同步回状态变量。这种双向绑定确保了输入框显示内容与组件状态之间的实时同步——用户在输入框中打字时,状态变量会即时更新;当代码修改状态变量时(如预填数据或清空输入),输入框显示也会相应变化。placeholder 参数提供了输入提示文字,帮助用户理解每个字段应输入什么内容。
Row({ space: 12 }) {
Column() {
Text('起 勺 画 糖')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(22)
.onClick(() => {
this.sugarList.unshift(new SugarItem(
this.inpName.length > 0 ? this.inpName : '新糖一样',
this.inpFigure.length > 0 ? this.inpFigure : '平面糖片',
Number(this.inpDiff) > 0 ? Number(this.inpDiff) : 60,
Number(this.inpPrice) > 0 ? Number(this.inpPrice) : 18,
Number(this.inpSweet) > 0 ? Number(this.inpSweet) : 85
));
this.inpName = '';
this.inpFigure = '';
this.inpDiff = '';
this.inpPrice = '';
this.inpSweet = '';
this.showAdd = false;
})
弹窗底部的按钮栏使用 Row 居中排列两个按钮。"起勺画糖"是确认按钮,使用 accent 橙色背景和 title 白色文字,点击后执行新增逻辑。onClick 回调中的核心操作是调用 this.sugarList.unshift() 方法,将一个新的 SugarItem 实例插入到数组的最前面。unshift 方法的选择是有意义的——新添加的糖样应该出现在列表的最顶部,这样用户添加后无需滚动就能立即看到新创建的卡片,获得即时的操作反馈。
新增 SugarItem 时使用了三元运算符进行空值处理:如果用户输入了名称则使用输入值,否则使用默认值"新糖一样";如果输入了造型则使用输入值,否则使用"平面糖片";难度、价格和甜度的数值如果输入有效(大于零)则使用输入值,否则分别使用六十、十八和八十五的默认值。这种防御性的默认值设计确保了即使用户完全不填写任何字段直接点击确认,也能创建一个合理的糖样数据项,不会因为空值导致显示异常或类型错误。
确认操作完成后,代码将五个输入状态变量全部清空(重置为空字符串),并将 showAdd 设为 false 关闭弹窗。清空输入变量的操作很重要——如果不清空,下次打开添加弹窗时,输入框中会残留上次输入的内容,用户需要手动清除后才能输入新数据,体验不佳。通过在每次提交后重置输入状态,确保了每次打开添加弹窗时都是干净的初始状态。
Column() {
Text('糖 未 化')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text2)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cream)
.borderRadius(22)
.onClick(() => {
this.showAdd = false;
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 4 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.copperB)
.borderRadius(20)
.position({ x: '7%', y: '12%' })
.zIndex(999)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(998)
}
"糖未化"是取消按钮,使用 cream 奶白色背景和 text2 棕色文字,点击后仅将 showAdd 设为 false 关闭弹窗。两个按钮的视觉设计形成了明确的层级对比——确认按钮使用醒目的橙色背景,取消按钮使用低调的奶白色背景,引导用户优先关注确认操作。按钮文字"起勺画糖"和"糖未化"都使用了带空格的排版方式(“起 勺 画 糖”),这种字间距加宽的处理使得按钮文字更有仪式感,符合糖画这一传统文化主题的调性。
弹窗主体容器设置了百分之八十六的宽度(居中显示,左右各留百分之七的边距)、十六像素的内边距、copperB 深铜色背景和二十像素圆角。position({ x: '7%', y: '12%' }) 将弹窗定位在屏幕上方偏上的位置,zIndex(999) 确保弹窗显示在遮罩层(zIndex 998)之上。constraintSize({ maxHeight: '80%' }) 限制了弹窗的最大高度不超过屏幕高度的百分之八十,防止在输入框过多或屏幕较小时弹窗溢出可视区域。这个约束在高分屏和小屏设备上尤其重要,保证了弹窗内容始终在用户可触及的范围内。
7.6 editModal 编辑弹窗构建器
@Builder
editModal() {
Stack({ alignContent: Alignment.TopStart }) {
this.modalOverlay(() => {
this.showEdit = false;
})
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🥄')
.fontSize(20)
Text('回勺重画')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.syrupC)
}
.width('100%')
editModal 构建器的结构与 addModal 高度相似,但功能定位不同——它是"编辑现有糖样"的弹窗。标题栏使用勺子 Emoji 和"回勺重画"文字。"回勺重画"是糖画行话——"回勺"意味着将铜勺收回重新蘸糖,"重画"意味着在石板上重新浇铸,合在一起暗示了对现有作品的修改和重新定型。
editModal 和 addModal 的 TextInput 配置几乎完全相同,唯一的区别在于初始数据的来源。addModal 打开时输入框是空的(或上次清空后的状态),而 editModal 打开时输入框已经预填了当前编辑项的数据——这是因为在 sugarCard 的编辑按钮 onClick 中,已经将 item 的属性值赋给了 inpName 等状态变量。这种"数据预填"机制使得用户可以基于现有数据进行修改,而非从零开始输入,大幅提升了编辑操作的效率。
Row({ space: 12 }) {
Column() {
Text('定 型 收 勺')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(22)
.onClick(() => {
if (this.editIdx >= 0 && this.editIdx < this.sugarList.length) {
this.sugarList[this.editIdx] = new SugarItem(
this.inpName.length > 0 ? this.inpName : this.sugarList[this.editIdx].name,
this.inpFigure.length > 0 ? this.inpFigure : this.sugarList[this.editIdx].figure,
Number(this.inpDiff) > 0 ? Number(this.inpDiff) : this.sugarList[this.editIdx].difficulty,
Number(this.inpPrice) > 0 ? Number(this.inpPrice) : this.sugarList[this.editIdx].price,
Number(this.inpSweet) > 0 ? Number(this.inpSweet) : this.sugarList[this.editIdx].sweetness
);
}
this.showEdit = false;
})
编辑弹窗的确认按钮文字是"定型收勺",使用 cool 绿色背景(区别于添加弹窗的 accent 橙色)。"定型"是糖画制作的最后一步——糖浆在石板上凝固定型后,作品才算完成;"收勺"则表示匠人收起铜勺,结束这一幅作品的创作。这两个词组合在一起,恰当地表达了"确认修改、保存数据"的操作含义。
onClick 回调中的核心逻辑是替换指定索引处的 SugarItem 实例。首先通过 this.editIdx >= 0 && this.editIdx < this.sugarList.length 进行边界检查,确保索引有效——这防止了在极端情况下(如数据列表已变化但 editIdx 未更新)访问不存在的数组元素而导致的运行时错误。替换时同样使用了三元运算符进行空值处理,但与 addModal 不同的是,这里的默认值不是固定的常量,而是当前编辑项的原始值。这意味着用户如果只修改了名称而未修改其他字段,未修改的字段会保留原值,而非被默认值覆盖。这种"局部更新"的设计确保了编辑操作只改变用户明确修改的字段,不会意外丢失原有数据。
替换操作通过直接赋值 this.sugarList[this.editIdx] = new SugarItem(...) 实现——用一个新的 SugarItem 实例替换数组中指定位置的旧实例。ArkUI 框架会检测到数组元素的变化,并自动更新 ForEach 列表中对应位置的渲染。这种"替换整个对象"而非"修改对象属性"的方式,确保了 @Observed 的可观察性能够正确触发——直接修改对象属性可能不会触发数组级别的响应式更新,而替换整个对象则能保证响应式机制正常工作。
Column() {
Text('倒 回 糖 锅')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text2)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cream)
.borderRadius(22)
.onClick(() => {
this.showEdit = false;
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 4 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.copperB)
.borderRadius(20)
.position({ x: '7%', y: '12%' })
.zIndex(999)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(998)
}
取消按钮文字是"倒回糖锅",使用 cream 奶白色背景。这个比喻极为生动——"倒回糖锅"意味着将已经画好的糖画融化回收,放弃本次创作,回到熬糖锅中重新开始。在编辑场景中,这表示用户放弃本次修改,不保存任何变更,恢复到编辑前的状态。点击后仅将 showEdit 设为 false 关闭弹窗,不执行任何数据操作,原有数据保持不变。
7.7 delModal 删除确认弹窗构建器
@Builder
delModal() {
Stack({ alignContent: Alignment.TopStart }) {
this.modalOverlay(() => {
this.showDel = false;
})
Column({ space: 14 }) {
Column() {
Text('🍬')
.fontSize(26)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.margin({ top: 6 })
Text('这一勺不画了?')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('撤下后糖样名录除名')
.fontSize(12)
.fontColor(COLORS.sub)
delModal 是删除操作的确认弹窗,设计风格与 addModal 和 editModal 有所不同——它更加简洁紧凑,没有输入框,只有图标、提示文字和两个按钮。弹窗顶部是一个直径五十六像素的圆形图标容器,内含糖果 Emoji,使用 cream 奶白色背景。这个大尺寸的图标是弹窗的视觉焦点,糖果 Emoji 直观地传达了"删除糖样"的操作主题。
图标下方是两行提示文字:主提示"这一勺不画了?"使用十七号 title 浅色加粗,是弹窗的核心信息;副提示"撤下后糖样名录除名"使用十二号 sub 暖灰色,解释了删除操作的后果。这种"主提示加副提示"的双行文案设计,既传达了操作意图,又告知了操作影响,帮助用户做出知情的决策。"这一勺不画了?"的问句形式增加了互动感,比直接显示"确认删除"更加人性化,符合糖画主题的文化调性。
Row({ space: 12 }) {
Column() {
Text('倒 掉 这 勺')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(22)
.onClick(() => {
if (this.selTab === 0) {
this.sugarList.splice(this.delIdx, 1);
} else if (this.selTab === 1) {
this.boilList.splice(this.delIdx, 1);
} else if (this.selTab === 2) {
this.wheelList.splice(this.delIdx, 1);
} else if (this.selTab === 3) {
this.folkList.splice(this.delIdx, 1);
} else if (this.selTab === 4) {
this.skillList.splice(this.delIdx, 1);
} else if (this.selTab === 5) {
this.orderList.splice(this.delIdx, 1);
}
this.showDel = false;
})
确认删除按钮"倒掉这勺"使用 danger 红色背景——在 UI 设计中,红色是表示危险或不可逆操作的标准颜色,能够有效警示用户操作的严重性。onClick 回调中的删除逻辑使用了 selTab 作为分支条件,根据当前激活的标签页选择对应的数据列表执行 splice 删除操作。这种设计使得 delModal 成为了一个通用的删除确认弹窗——无论用户在哪个标签页触发删除操作,都会调用同一个弹窗,只是删除的目标列表不同。
splice(this.delIdx, 1) 方法从数组的 delIdx 索引处删除一个元素。splice 是 JavaScript 数组的原生方法,它会直接修改原数组并返回被删除的元素。由于数组是 @State 装饰的响应式状态,splice 操作会触发 ArkUI 的响应式机制,自动更新 ForEach 列表的渲染——被删除的卡片会从列表中消失,后续卡片自动上移填补空缺。
Column() {
Text('再 熬 一 锅')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text2)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cream)
.borderRadius(22)
.onClick(() => {
this.showDel = false;
})
}
.justifyContent(FlexAlign.Center)
}
.width('76%')
.padding(20)
.backgroundColor(COLORS.copperB)
.borderRadius(20)
.position({ x: '12%', y: '30%' })
.zIndex(999)
.constraintSize({ maxHeight: '80%' })
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(998)
}
取消按钮"再熬一锅"使用 cream 奶白色背景,点击后仅关闭弹窗不执行删除。这个文案巧妙地化解了删除操作的紧张氛围——"再熬一锅"暗示糖还在锅里,随时可以重新制作,不必急于删除。这种用轻松幽默的行话替代冷冰冰的"取消"按钮文案的设计,是整个应用文化沉浸感的重要组成部分。
delModal 的弹窗主体宽度为百分之七十六(比 addModal 和 editModal 的百分之八十六更窄),定位在 y 百分之三的位置(比前两个弹窗的 y 百分之十二更低),这是因为删除确认弹窗内容较少,更窄的宽度和更居中的垂直位置使得弹窗在视觉上更加聚焦和紧凑。弹窗使用了 alignItems(HorizontalAlign.Center) 使所有子元素水平居中对齐——图标、提示文字和按钮栏都沿中轴线排列,形成了一种简洁对称的视觉布局。
八、build 方法:主页面渲染入口
8.1 整体结构
build() {
Stack() {
Column() {
Scroll() {
Column() {
this.wheelHeader()
this.sweetStrip()
if (this.selTab === 0) {
// 糖样标签页内容
}
if (this.selTab === 1) {
// 熬糖标签页内容
}
if (this.selTab === 2) {
// 转盘标签页内容
}
if (this.selTab === 3) {
// 民俗标签页内容
}
if (this.selTab === 4) {
// 技艺标签页内容
}
if (this.selTab === 5) {
// 订单标签页内容
}
}
.width('100%')
.padding({ bottom: 150 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
build 方法是 ArkUI 组件的核心入口,它返回组件的 UI 结构。整个页面的结构层次为:Stack(最外层) -> Column(主体容器) -> Scroll(滚动区域) -> Column(内容容器) -> 各标签页内容。这种多层嵌套的布局结构是移动端页面的常见模式——Stack 用于叠加弹窗遮罩层,Column 用于垂直排列滚动区域和底部导航栏,Scroll 提供内容滚动能力,内部的 Column 承载实际的页面内容。
Scroll 组件设置了 layoutWeight(1) 使其占据底部导航栏以上的全部可用高度。scrollBar(BarState.Off) 隐藏了滚动条,使界面更加简洁——在现代移动应用设计中,滚动条通常被隐藏,因为用户已经习惯了触摸滑动操作,不需要滚动条来提示滚动位置。edgeEffect(EdgeEffect.Spring) 设置了滚动到边缘时的回弹效果为弹性模式——当用户滑动到内容顶部或底部时,内容会像弹簧一样回弹,提供了自然的物理反馈。
内容容器的 padding({ bottom: 150 }) 在底部预留了一百五十像素的内边距,这是为了防止底部导航栏遮挡内容——底部导航栏有两行标签,高度约一百五十像素,如果不在内容底部预留空间,最后几条数据会被导航栏覆盖,用户需要滑动多次才能看到。这个底部内边距的大小需要根据导航栏的实际高度来调整,确保所有内容都在可滚动范围内。
8.2 糖样标签页(selTab === 0)
if (this.selTab === 0) {
Row({ space: 10 }) {
Text('糖样图谱 · ' + this.sugarList.length + ' 样')
.fontSize(13)
.fontColor(COLORS.text2)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.syrupC)
Column() {
Text('+ 新 熬 一 勺')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(COLORS.accent)
.borderRadius(18)
.onClick(() => {
this.showAdd = true;
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 8 })
糖样标签页的顶部是一个分区标题栏,使用 Row 水平排列三个元素:左侧的"糖样图谱 · XX 样"统计文字、中间的分隔线(layoutWeight(1) 的一像素高 syrupC 色横线)和右侧的"新熬一勺"添加按钮。统计文字中的 this.sugarList.length 会动态显示当前糖样的数量——当用户添加或删除糖样后,这个数字会自动更新。
"新熬一勺"按钮使用 accent 橙色背景和 title 白色文字,圆角十八像素,点击后设置 showAdd 为 true 触发添加弹窗。按钮的视觉设计与卡片的操作按钮形成统一——同样的橙色基调,但尺寸更大、内边距更宽,使其在标题栏中足够醒目,引导用户进行新增操作。
ForEach(this.sugarList, (item: SugarItem, index: number) => {
Column() {
this.sugarCard(item, index)
}
.margin({ left: 12, right: 12, bottom: 10 })
}, (item: SugarItem) => item.name + item.price.toString())
}
糖样列表通过 ForEach 循环渲染,遍历 sugarList 数组中的每个 SugarItem 实例。ForEach 的第二个参数是渲染函数,接收 item 和 index 两个参数,调用 sugarCard 构建器渲染每个卡片。每个卡片被包裹在一个设置了左右和底部 margin 的 Column 中,使卡片之间保持十像素的垂直间距和十二像素的水平边距。
ForEach 的第三个参数是键值生成函数 (item: SugarItem) => item.name + item.price.toString(),它将每个糖样的名称和价格拼接为唯一键。这个键值的选择是有讲究的——名称加价格的组合在大多数情况下能唯一标识一个糖样项。当列表中的某个糖样被修改(名称或价格变化)时,其键值也会变化,ArkUI 会据此判断该项目需要重新渲染;而其他未被修改的项目键值不变,会被复用,避免不必要的重绘。这种基于键值的差分更新机制是 ForEach 高性能渲染的关键。
8.3 熬糖标签页(selTab === 1)
if (this.selTab === 1) {
Column({ space: 10 }) {
ForEach(this.boilList, (item: BoilItem, index: number) => {
Row({ space: 10 }) {
Column({ space: 0 }) {
Text('🔥')
.fontSize(16)
}
.width(36)
.height(36)
.borderRadius(12)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.opacity(this.drip ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
熬糖标签页的每条工序卡片以 Row 水平布局展开。左侧是一个三十六像素的方形火焰图标,使用 cream 奶白色背景和十二像素圆角。火焰图标绑定了一个透明度交替动画——在零点六和一点零之间变化,持续六百毫秒。这个闪烁效果模拟了火焰跳动的视觉效果,使得每条熬糖工序卡片都像是在灶火上烹饪一样,充满了动态的生命力。
Column({ space: 2 }) {
Text(item.stage + ' · ' + item.time)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.detail)
.fontSize(11)
.fontColor(COLORS.text2)
Row({ space: 6 }) {
Text(item.temp + ' 度')
.fontSize(10)
.fontColor(heatColor(item.temp))
Column()
.width(item.temp / 2)
.height(5)
.borderRadius(3)
.backgroundColor(heatColor(item.temp))
}
Text('掌火 ' + item.master)
.fontSize(10)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
中间区域是工序的核心信息,包含四行内容:第一行是工序名称和持续时间(如"化糖 · 一炷香"),使用十四号 text1 深色加粗;第二行是详细操作说明,使用十一号 text2 棕色;第三行是温度显示和温度进度条——温度数值使用 heatColor 函数动态着色,进度条宽度为温度值除以二(如一百四十二度对应七十一像素宽);第四行是掌火师傅姓名,使用十号 text3 浅灰色。
温度进度条的设计值得注意。它的宽度直接与温度值成正比(width(item.temp / 2)),使得不同工序的温度差异通过进度条长度直观可见——"观色"工序的温度最高(一百四十二度),进度条最长;"收锅"工序的温度最低(一百度),进度条最短。配合 heatColor 函数的颜色分级(高温红色、中温橙色、正常棕色),用户可以通过长度和颜色两个维度快速判断每道工序的温度状态。
Text('删')
.fontSize(13)
.fontColor(COLORS.danger)
.onClick(() => {
this.delIdx = index;
this.showDel = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
每条工序卡片右侧是一个"删"字删除按钮,使用 danger 红色,点击后设置 delIdx 和 showDel 触发删除确认弹窗。由于 delModal 会根据 selTab 的值决定删除哪个列表的数据,而此时 selTab 等于一(熬糖标签页),删除操作会作用于 boilList 数组。
8.4 转盘标签页(selTab === 2)
if (this.selTab === 2) {
Column({ space: 10 }) {
ForEach(this.wheelList, (item: WheelItem, index: number) => {
Row({ space: 10 }) {
Column() {
Text(ZODIAC_CHARS[index % 8])
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.copperB)
}
.width(34)
.height(34)
.borderRadius(17)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.rotate({ angle: this.spin ? 12 : -12 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate })
转盘标签页的每条记录卡片同样以 Row 水平布局展开。左侧是一个直径三十四像素的圆形生肖字符图标,与糖样卡片类似但尺寸更小。这个图标也绑定了一个旋转动画——在正负十二度之间交替,持续一千六百毫秒。这个摆动动画与糖样卡片的头像摆动动画(正负八度)角度更大、更明显,因为转盘标签页的内容本身就是关于"转动"的主题,更大幅度的摆动增强了主题的动态感。
Column({ space: 2 }) {
Text(item.sector + ' 格')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('中签 ' + item.odds + ' · ' + item.reward)
.fontSize(11)
.fontColor(COLORS.text2)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('人气 ' + item.hot)
.fontSize(11)
.fontColor(COLORS.accent)
Column()
.width(14 + item.hot / 1.6)
.height(5)
.borderRadius(3)
.backgroundColor(COLORS.syrupA)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
转盘卡片的中间区域显示扇区名称和中签信息(如"大龙 格"和"中签 十二分之一 · 免费重转"),右侧显示人气值和人气进度条。人气进度条的宽度计算公式为 14 + item.hot / 1.6——基准宽度十四像素加上人气值除以一点六的结果。以"大龙"的人气值九十六为例,进度条宽度为十四加六十等于七十四像素;以"谢谢"的人气值五十二为例,进度条宽度为十四加三十二点五等于四十六点五像素。这种线性映射使得人气值的差异通过进度条长度直观呈现。
8.5 民俗标签页(selTab === 3)
if (this.selTab === 3) {
Column({ space: 10 }) {
ForEach(this.folkList, (item: FolkItem, index: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(item.city)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.occasion)
.fontSize(10)
.fontColor(COLORS.title)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.charB)
.borderRadius(8)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.cream)
Text('游人 ' + item.crowds + '/日')
.fontSize(11)
.fontColor(COLORS.accent)
}
.width('100%')
Text(item.scene + ' · ' + item.note)
.fontSize(11)
.fontColor(COLORS.text2)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
民俗标签页的卡片结构与前几个标签页有所不同——它使用 Column 而非 Row 作为主容器,因为每条民俗信息包含两行内容。第一行是城市名称、场合标签、分隔线和游客量;第二行是景点名称和文化特色说明。
场合标签(如"庙会赶集"、"新春灯会"等)使用了与糖样卡片价格标签相似的胶囊设计,但背景色使用 charB 深铜色而非 charA 橙色,视觉上更加沉稳低调。这种颜色区分是有意为之的——糖样的价格标签是商业信息,使用亮色突出;民俗的场合标签是背景信息,使用暗色弱化,使得城市名称和游客量成为卡片的视觉焦点。游客量文字"游人 8600/日"使用 accent 橙色,数字的突出使得用户能够快速比较各城市的游客规模。
8.6 技艺标签页(selTab === 4)
if (this.selTab === 4) {
Column({ space: 10 }) {
ForEach(this.skillList, (item: SkillItem, index: number) => {
Row({ space: 10 }) {
Text('🥄')
.fontSize(17)
.rotate({ angle: this.spin ? 14 : -14 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate })
Column({ space: 2 }) {
Text(item.name + ' · ' + item.tool)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('需学 ' + item.yearsToLearn + ' 年 · 传人 ' + item.heirs + ' 位')
.fontSize(11)
.fontColor(COLORS.text2)
Text('宗师 ' + item.master)
.fontSize(10)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
技艺标签页的卡片左侧不是圆形图标容器,而是一个直接渲染的勺子 Emoji,绑定了一个正负十四度的旋转动画。这个摆动幅度是所有卡片中最大的,呼应了"技艺"这一主题——勺子是糖画匠人最重要的工具,大幅度的摆动暗示着匠人挥勺作画时的灵活手腕动作。
每条技艺卡片包含三行文字:第一行是技法名称和所需工具(如"勺工 · 铜勺一把"),第二行是学习年限和传人数量(如"需学 5 年 · 传人 12 位"),第三行是宗师姓名。这三行信息从不同维度描述了一项糖画技法——工具说明了物质基础,年限反映了学习难度,传人数量揭示了传承现状,宗师姓名则指向了具体的传承人。这种多维度信息的组合使得用户能够全面了解每项糖画技法的全貌。
8.7 订单标签页(selTab === 5)
if (this.selTab === 5) {
Column({ space: 6 }) {
Text('近六月糖画出摊(支)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.alignSelf(ItemAlign.Start)
Row({ space: 10 }) {
ForEach(MONTH_IDX, (i: number) => {
Column({ space: 4 }) {
Column()
.width(18)
.height(orderBarH(MONTH_SALES[i]))
.borderRadius(6)
.backgroundColor(COLORS.syrupA)
.opacity(this.drip ? 1 : 0.7)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Text(MONTH_NAMES[i])
.fontSize(10)
.fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (i: number) => 'ob' + i)
}
.width('100%')
.padding({ top: 8, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
}
.padding({ left: 12, right: 12, top: 12 })
.alignItems(HorizontalAlign.Start)
订单标签页的第一部分是一个月度销售柱状图。标题"近六月糖画出摊(支)"使用十三号 text1 深色加粗,alignSelf(ItemAlign.Start) 使其左对齐。柱状图使用 Row 水平排列六个柱子,每个柱子通过 ForEach 循环渲染。每个柱子是一个宽度十八像素的 Column,高度由 orderBarH 函数根据月度销售额计算——销售额越高,柱子越高。
柱状图的颜色统一使用 syrupA 糖浆色,并绑定了透明度交替动画(在零点七和一点零之间变化)。这个"呼吸"效果使得柱状图看起来像是有生命力的活体数据,而非冰冷的静态图表。每个柱子下方是对应的月份名称(“三月"到"八月”),使用十号 text3 浅灰色。从柱状图中可以直观地看出:八月柱子最高(八千六百支),五月柱子最矮(四千六百支),整体呈波动上升趋势,反映了夏季旅游旺季对糖画销售的拉动效应。
Column({ space: 10 }) {
ForEach(this.orderList, (item: OrderItem, index: number) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(item.buyer)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.sugar + ' × ' + item.count)
.fontSize(11)
.fontColor(COLORS.text2)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('¥' + item.amount)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.status)
.fontSize(10)
.fontColor(item.status === '已画好' ? COLORS.cool : COLORS.charA)
}
.alignItems(HorizontalAlign.End)
Text('删')
.fontSize(13)
.fontColor(COLORS.danger)
.onClick(() => {
this.delIdx = index;
this.showDel = true;
})
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 10 })
}
订单标签页的第二部分是订单列表。每条订单卡片使用 Row 水平布局,分为三个区域:左侧是买方信息和糖画名称及数量,中间是金额和状态,右侧是删除按钮。买方信息使用十四号 text1 深色加粗,糖画名称和数量组合显示(如"游龙戏珠 × 50"),使用十一号 text2 棕色。
订单状态的文字颜色是动态的——通过三元运算符 item.status === '已画好' ? COLORS.cool : COLORS.charA 判断,如果状态是"已画好"则使用 cool 绿色,否则使用 charA 橙色。这种条件着色机制使得用户能够通过颜色快速区分已完成和未完成的订单——绿色代表可以交付的成品,橙色代表尚在处理中的订单。在十二个订单中,四个"已画好"的订单会显示为绿色,其余八个非"已画好"状态的订单显示为橙色,绿色和橙色的视觉对比使得订单状态的分布一目了然。
8.8 底部导航栏
Column({ space: 6 }) {
Row({ space: 8 }) {
ForEach(ROW1_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon)
.fontSize(17)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.selTab === i ? COLORS.syrupA : COLORS.cream)
.borderRadius(14)
.onClick(() => {
this.selTab = i;
})
}, (i: number) => 't1' + i)
}
.width('100%')
.padding({ left: 10, right: 10 })
Row({ space: 8 }) {
ForEach(ROW2_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon)
.fontSize(17)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.selTab === i ? COLORS.syrupA : COLORS.cream)
.borderRadius(14)
.onClick(() => {
this.selTab = i;
})
}, (i: number) => 't2' + i)
}
.width('100%')
.padding({ left: 10, right: 10, bottom: 8 })
}
.width('100%')
.backgroundColor(COLORS.tabBg)
}
.width('100%')
.height('100%')
底部导航栏分为两行,每行三个标签。每行使用 Row 容器配合 ForEach 循环渲染,遍历 ROW1_IDX 或 ROW2_IDX 索引数组。每个标签是一个 Column 容器,内含 Emoji 图标和中文标签文字,通过 layoutWeight(1) 等分宽度。
标签的选中状态通过两个维度的视觉变化来表现:文字颜色在选中时变为 tabOn 亮金色(COLORS.tabOn),未选中时为 sub 暖灰色(COLORS.sub);背景色在选中时变为 syrupA 糖浆橙色(COLORS.syrupA),未选中时为 cream 奶白色(COLORS.cream)。这种双维度的状态反馈确保了选中标签在视觉上的突出性——用户通过颜色和背景的双重变化可以毫无歧义地识别当前所在的标签页。
标签的 onClick 回调极为简洁——只需将 selTab 设为对应的索引值 i。由于 selTab 是 @State 装饰的响应式状态,其值变化会触发整个 build 方法的条件分支重新评估:之前选中的标签页内容会被移除,新选中的标签页内容会被渲染。这种通过状态变量驱动条件渲染的设计模式是声明式 UI 的精髓——开发者只需管理状态,框架负责将状态映射到正确的视图。
导航栏的外层 Column 设置了 tabBg 深褐色背景(#5C3A16),与页面的 bg 米黄色背景形成强烈的明暗对比,使导航栏在视觉上与内容区域明确分离。这种底部深色导航栏的设计在移动应用中非常常见——深色背景使得导航栏在内容滚动时不会干扰用户的阅读焦点,同时浅色的标签文字在深色背景上具有极高的可读性。
8.9 模态弹窗的条件渲染
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
三个模态弹窗通过条件渲染放置在 Stack 容器的最上层。当 showAdd、showEdit 或 showDel 为 true 时,对应的弹窗构建器会被调用并渲染到 Stack 的顶层。由于 Stack 容器的堆叠特性,后渲染的元素会覆盖在先渲染的元素之上,因此弹窗会覆盖在主内容之上。三个弹窗的 zIndex 分别为 998(外层 Stack)和 999(弹窗主体),确保了弹窗在所有内容之上。
这种条件渲染弹窗的设计模式比使用路由跳转更加轻量——弹窗不会创建新的页面实例,不涉及路由栈的管理,显示和隐藏只需修改一个布尔状态变量即可。对于添加、编辑、删除这类简短的交互操作,模态弹窗是比页面跳转更合适的交互方式,它保持了用户当前的操作上下文,用户完成操作后可以立即返回列表浏览,无需经历页面加载的等待。
九、状态管理深度分析
9.1 @State 状态装饰器
在本项目中,@State 是使用最频繁的状态装饰器,共装饰了约二十个状态变量。@State 装饰的变量具有以下核心特性:第一,变量值的变化会自动触发引用了该变量的 UI 组件重新渲染;第二,对于对象和数组类型,@State 会进行一层观察,当通过赋值替换整个对象或数组时触发更新;第三,@State 变量是组件私有的,不能从外部直接访问或修改。
项目中 @State 变量的使用场景可以分为三类。第一类是 UI 交互状态,如 selTab(当前标签页)、showAdd/showEdit/showDel(弹窗显隐)和 editIdx/delIdx(操作索引),这些变量直接控制 UI 的显示逻辑。第二类是动画驱动状态,如 spin 和 drip 两个布尔翻转标志,它们通过定时器周期性翻转来驱动动画效果。第三类是业务数据状态,如 sugarList、boilList 等六个数据数组,它们是应用的核心数据模型,所有的增删改查操作都作用于这些数组。
@State 对数组的观察机制需要特别注意。当使用 unshift(在数组头部插入元素)或 splice(在指定位置删除元素)等数组变异方法时,ArkUI 框架能够检测到数组的变化并触发 ForEach 列表的增量更新。但是,如果直接修改数组元素的属性(如 this.sugarList[0].name = '新名称'),框架可能不会检测到变化——因为 @State 只观察数组本身的变化(长度、元素引用),不深入观察元素内部的属性变化。这就是为什么编辑操作中使用了"替换整个对象"的方式(this.sugarList[this.editIdx] = new SugarItem(...))而非直接修改属性——替换整个对象会改变数组中该位置的元素引用,从而触发 @State 的观察机制。
9.2 @Observed 与 @ObjectLink 的配合
@Observed 装饰器作用于类级别,使类的实例成为可观察对象。当 @Observed 类的实例属性发生变化时,绑定了该实例的 UI 组件会自动更新。然而,@Observed 单独使用时有一个限制——它只能检测到第一层属性的变化,如果属性本身也是对象,嵌套对象的变化不会被检测到。
在本项目中,六个数据模型类(SugarItem、BoilItem、WheelItem、FolkItem、SkillItem、OrderItem)都使用了 @Observed 装饰器。这些类的属性都是基本类型(string 和 number),没有嵌套对象,因此 @Observed 的一层观察已经足够。如果未来需要扩展某个类的属性为复杂对象(例如在 SugarItem 中添加一个"评价列表"数组属性),则需要配合 @ObjectLink 装饰器来实现多层观察。
@ObjectLink 装饰器用于子组件中接收父组件传递的 @Observed 对象。当父组件中的 @Observed 对象属性发生变化时,子组件中通过 @ObjectLink 接收的对象也会同步更新。需要注意的是,本项目目前没有使用 @ObjectLink——所有数据都在主组件 SugarPaintingPage 中管理和渲染,没有将数据传递给子组件。这是因为项目结构相对扁平,所有卡片都在 @Builder 方法中渲染,而非独立的子组件。如果项目规模扩大,将卡片拆分为独立的子组件,则需要使用 @ObjectLink 来传递和观察 @Observed 对象。
9.3 @Prop 与 @Builder 的数据传递
@Prop 装饰器用于子组件接收父组件传递的简单类型数据(string、number、boolean等)。与 @ObjectLink 不同,@Prop 是单向数据流——父组件的数据变化会同步到子组件,但子组件不能修改 @Prop 变量的值。本项目没有使用 @Prop,因为所有 @Builder 方法都定义在主组件内部,可以直接访问主组件的 @State 变量,无需通过参数传递。
@Builder 方法的数据传递通过方法参数实现。例如 sugarCard(item: SugarItem, index: number) 接收一个 SugarItem 对象和一个索引值作为参数。这些参数在方法内部被视为只读值——@Builder 方法不会修改参数的值,只负责根据参数渲染 UI。这种通过方法参数传递数据的方式比 @Prop 更轻量,适合在同一个组件内部复用 UI 片段的场景。
十、布局样式深度分析
10.1 Flex 弹性布局
本项目大量使用了 ArkUI 的 Flex 弹性布局系统。Row 和 Column 组件本质上都是 Flex 容器——Row 的主轴为水平方向,Column 的主轴为垂直方向。layoutWeight 属性是 Flex 布局中控制子元素在主轴方向上弹性伸缩的核心属性,它指定了子元素占据剩余空间的比例。
在底部导航栏中,六个标签通过 layoutWeight(1) 等分容器宽度。在统计条中,三个区块同样通过 layoutWeight(1) 等分宽度。在卡片的信息行中,中间区域使用 layoutWeight(1) 占据头像和价格之间的全部可用空间,使得不同长度的糖样名称都能正确显示而不会挤压其他元素。这种弹性布局方式确保了 UI 在不同屏幕尺寸下的自适应能力。
alignItems 属性控制子元素在交叉轴方向上的对齐方式。HorizontalAlign.Start 使子元素左对齐,HorizontalAlign.Center 使子元素居中对齐,HorizontalAlign.End 使子元素右对齐。在卡片设计中,左侧头像区域通常使用居中对齐,中间文字区域使用左对齐(文字从左侧开始排列),右侧价格区域使用右对齐(数字靠右排列),这种三段式对齐布局是卡片设计的经典模式。
10.2 position 绝对定位
转盘头部组件大量使用了 position 属性进行绝对定位。position 属性接收 { x, y } 坐标对象,将元素精确定位在父容器(Stack)内的指定位置。转盘的铜锅沿、铜锅颈、外圈、内圈、八个生肖字符、指针和四滴糖浆都使用了 position 进行绝对定位,通过精确的像素坐标实现了多层元素的空间叠加。
绝对定位的使用场景与 Stack 容器密切相关。Stack 的默认行为是将所有子元素居中堆叠,但当需要让元素出现在非中心位置时,就需要使用 position 来覆盖默认的居中行为。转盘组件中的元素位置都是经过精心计算的——外圈和内圈通过 position 使其同心对齐,八个生肖字符通过 sliceX 和 sliceY 函数计算位置使其均匀分布在圆周上,指针通过 position 定位在转盘顶部正中央。
position 的坐标值可以使用像素值或百分比。在本项目中,弹窗主体使用了百分比定位(如 position({ x: '7%', y: '12%' })),这使得弹窗在不同屏幕尺寸下都能保持相对位置的一致性。而转盘内部的元素则使用了像素值定位(如 position({ x: 55, y: 18 })),因为转盘的内部结构是固定的像素尺寸,不需要适配不同屏幕。
10.3 动画系统
本项目使用了 ArkUI 的属性动画系统,通过 animation 属性配置动画效果。animation 属性的配置参数包括:duration(动画持续时间,毫秒)、iterations(迭代次数,-1表示无限循环)、playMode(播放模式)、curve(缓动曲线)和 delay(延迟时间)。
PlayMode.Alternate 是项目中使用最频繁的播放模式,它使动画在正向播放完毕后自动反向播放,形成来回循环的效果。这种模式非常适合需要持续来回变化的动画——转盘旋转一百八十度再回零、指针左右摆动、糖浆透明度高低交替等。与之相对的是 PlayMode.Normal(始终正向播放,动画结束后回到初始状态),适用于不需要来回循环的场景。
缓动曲线的选择对动画的视觉效果有显著影响。Curve.EaseInOut 使动画在开始和结束时减速、中间加速,是最常用的缓动曲线,模拟了自然界中物体的运动规律。Curve.EaseIn 使动画在开始时慢、结束时快,适合模拟下落或加速的效果——糖浆滴落动画使用了这个曲线,使得糖浆在变为透明时有一个"加速消失"的效果,增强了水滴下落的真实感。
delay 参数是动画的时间偏移控制。在糖浆滴落效果中,四滴糖浆分别设置了零、一百五十、三百、四百五十毫秒的延迟,使得它们的透明度变化错开时间,形成波浪式传播的视觉效果。这种利用延迟创建错落感的手法在动画设计中非常实用——通过简单的延迟参数就能将同步变化转化为层次分明的波浪效果。
10.4 渐变与阴影
转盘头部使用了 linearGradient 属性创建线性渐变背景。linearGradient 的配置包括 angle(渐变角度,一百八十度表示从上到下)和 colors(颜色断点数组,每个断点包含颜色值和位置比例)。[[COLORS.syrupA, 0], [COLORS.syrupB, 1]] 表示从零位置(顶部)的 syrupA 浅糖浆色渐变到一位置(底部)的 syrupB 深糖浆色,模拟了铜锅中糖浆从上到下色泽渐深的效果。
糖样卡片使用了 shadow 属性创建投影效果。shadow 的配置包括 radius(模糊半径)、color(阴影颜色)、offsetX(水平偏移)和 offsetY(垂直偏移)。卡片阴影的 offsetY 为二表示阴影向下偏移两像素,模拟了光从上方照射时卡片在下方产生的投影。阴影颜色使用了 rgba(184,100,30,0.14) 即半透明的铜色,与整体琥珀色调和谐统一。这种使用主题色调而非纯黑色的阴影设计是高级 UI 设计的细节体现——黑色阴影在暖色背景上会显得生硬突兀,而同色系的铜色阴影则能自然融入背景,营造出柔和的立体感。
十一、Mermaid 流程图
11.1 应用整体架构流程图
11.2 数据流与状态管理流程图
11.3 糖画制作工序与数据映射流程图
十二、技术对比表格
| 技术维度 | ArkTS 声明式 UI | 传统命令式 UI | React JSX | Vue SFC |
|---|---|---|---|---|
| 装饰器体系 | @State/@Prop/@Observed/@ObjectLink/@Builder/@Component/@Entry | 无装饰器,手动操作 DOM | useState/useEffect/hooks | ref/reactive/computed |
| 状态管理 | @State 组件内 + @Observed 跨组件 | 手动管理状态对象 | useState/Context/Redux | Pinia/Vuex/Composition API |
| UI 复用 | @Builder 方法复用 UI 片段 | 函数封装 DOM 操作 | 组件复用/Render Props | 组件复用/插槽 |
| 列表渲染 | ForEach + 键值生成函数 | 手动循环创建 DOM | map + key | v-for + key |
| 条件渲染 | if/else 嵌入 build 方法 | if 判断操作 DOM | 三元运算符/&& | v-if/v-show |
| 动画系统 | animation 属性 + PlayMode + Curve | requestAnimationFrame/CSS | CSS/Framer Motion | transition/CSS |
| 布局方式 | Row/Column/Flex/Stack/Scroll | flexbox/grid/float | flexbox/grid | flexbox/grid |
| 生命周期 | aboutToAppear/aboutToDisappear/onPageShow | onLoad/onShow/onReady | useEffect/类组件生命周期 | mounted/created/onMounted |
| 类型系统 | ArkTS 静态类型 + interface | JavaScript 动态类型 | TypeScript 可选 | TypeScript 可选 |
| 数据绑定 | @State 双向 + onChange 回调 | 手动事件监听 | useState + onChange | v-model 双向绑定 |
| 样式管理 | 链式属性调用 .width().height() | CSS 样式表/内联样式 | CSS-in-JS/className | scoped |
十三、结尾总结:技术要点回顾与设计哲学
回顾整个糖画技艺应用的源码实现,我们可以清晰地看到 HarmonyOS ArkTS 声明式 UI 范式在移动端应用开发中的核心优势与技术特征。这个应用以糖画这一中国非物质文化遗产为题材,将传统文化内容与现代前端技术深度融合,构建出了一个功能完整、视觉精美、交互流畅的移动应用。从代码架构到视觉设计,从状态管理到动画系统,每一个技术细节都经过了精心打磨,值得我们逐一总结和深入反思。
首先是色彩体系的集中化管理设计。ColorPalette 接口定义了二十五个语义化颜色字段,COLORS 常量为其赋予了精确的色值。这种集中式色彩管理的优势在于三个方面:其一,主题色的一致性得到了根本保障——所有组件引用同一组颜色常量,不会出现颜色值不一致的问题;其二,主题切换的可行性——如果未来需要支持暗色模式或其他主题,只需新增一套 COLORS 常量并通过条件切换即可实现全局换肤;其三,语义化命名使得代码自文档化——开发者看到 COLORS.syrupA 就知道这是糖浆色,看到 COLORS.danger 就知道这是危险色,无需额外注释。这种设计模式在大型项目中尤为重要,它将视觉设计规范直接编码为类型约束,使得 UI 的一致性从制度层面落到了代码层面。
其次是数据模型的 @Observed 响应式设计。六个数据模型类(SugarItem、BoilItem、WheelItem、FolkItem、SkillItem、OrderItem)全部使用 @Observed 装饰器,使得每个数据实例都成为可观察对象。当数据属性变化时,绑定了该数据的 UI 组件会自动重新渲染,无需手动调用刷新方法。这种数据驱动 UI 的设计范式是声明式 UI 的核心——开发者只需管理数据状态,框架负责将状态映射到视图。在实际开发中,这意味着我们不需要编写任何 DOM 操作代码,不需要手动管理视图更新逻辑,只需要在 onClick 等回调中修改数据,UI 就会自动响应。这种模式大幅减少了样板代码,使开发者能够将精力集中在业务逻辑而非视图操作上。
第三是 @Builder 方法实现的 UI 模块化复用。应用中的 wheelHeader、sweetStrip、sugarCard、modalOverlay、addModal、editModal、delModal 等七个 @Builder 方法各自封装了一段可复用的 UI 逻辑。@Builder 的价值在于它使得 UI 片段可以像函数一样被调用和组合——modalOverlay 被三个弹窗复用,sugarCard 被糖样列表中的 ForEach 循环调用。这种复用方式比子组件更轻量——@Builder 方法直接在当前组件的上下文中执行,可以访问组件的 @State 变量和 this 引用,不需要通过 @Prop 或 @ObjectLink 传递数据。在中小规模的应用中,@Builder 是 UI 复用的首选方案;当组件复杂度增加到需要独立状态管理时,才需要拆分为子组件。
第四是动画系统的布尔翻转驱动模式。应用中的两个核心动画——转盘旋转和糖浆滴落——都是通过 setInterval 定时器周期性翻转布尔状态变量(spin 和 drip)来驱动的。这种设计模式的优势在于简洁性和可控性:一个布尔变量的翻转可以同时驱动多个动画效果——spin 驱动了转盘外圈旋转、内圈反向旋转、生肖字符同步旋转、指针摆动、卡片头像摆动等多个动画;drip 驱动了糖浆滴落透明度、甜度进度条脉动、火焰图标闪烁、柱状图呼吸等多个动画。所有动画通过统一的定时器驱动,形成了协调的视觉节奏。PlayMode.Alternate 播放模式使得动画在正反方向之间来回切换,模拟了自然界的周期性运动。缓动曲线的选择(EaseInOut 用于旋转、EaseIn 用于滴落)则根据动画的物理特性进行了差异化配置,使得每个动画都具有独特的运动质感。
第五是条件渲染驱动的标签页切换。六个标签页的内容通过 selTab 状态变量的条件判断来渲染——当 selTab 为零时渲染糖样图谱,为一时渲染熬糖工序,以此类推。这种设计模式的优点是简洁直接——一个状态变量控制全部标签页的显示逻辑,无需路由栈管理或页面实例创建。缺点是所有标签页的 UI 代码都在同一个 build 方法中,当标签页数量增多或内容复杂度增加时,build 方法会变得冗长。在实际项目中,如果标签页超过十个或单个标签页的内容非常复杂,应该考虑使用路由跳转或子组件拆分来优化代码结构。
第六是模态弹窗的 Stack 叠加设计。三个弹窗(添加、编辑、删除)通过条件渲染放置在 Stack 容器的最上层,使用 zIndex 层级控制和半透明遮罩层实现模态效果。这种设计避免了路由跳转的复杂性——弹窗的显示和隐藏只需修改一个布尔状态变量,不涉及路由栈的 push/pop 操作。对于添加、编辑、删除这类简短的交互操作,模态弹窗是比页面跳转更合适的交互方式,它保持了用户的操作上下文,用户完成操作后可以立即返回列表浏览。delModal 的通用设计尤其值得借鉴——一个弹窗通过 selTab 判断删除目标列表,实现了六种不同数据的删除确认,代码复用率极高。
第七是 ForEach 列表渲染的键值优化。应用中的所有列表渲染都使用了 ForEach 循环,并为每个渲染项提供了键值生成函数。键值的作用是帮助框架进行差分更新——当列表数据变化时,框架通过键值判断哪些项需要更新、哪些可以复用,避免了全量重渲染。在糖样列表中,键值为 item.name + item.price.toString(),即名称加价格的组合;在转盘生肖字符中,键值为 'zc' + i,即固定前缀加索引。键值的选择需要保证唯一性和稳定性——如果两个项的键值相同,会导致渲染错误;如果键值频繁变化,会导致不必要的重渲染。
第八是防御性编程的默认值设计。在添加和编辑弹窗中,所有用户输入都经过了空值检查和默认值处理。例如 this.inpName.length > 0 ? this.inpName : '新糖一样' 确保了即使输入框为空,也能使用合理的默认值创建数据。数值类型的转换使用了 Number(this.inpDiff) > 0 ? Number(this.inpDiff) : 60 的模式,既处理了空字符串的情况,又处理了零值和负值的情况。这种防御性编程确保了应用的健壮性——即使用户输入了异常数据或不输入任何数据直接提交,应用也不会崩溃或产生异常状态。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

初始化项目,自动下载相关依赖:

完整代码:
interface ColorPalette {
bg: string;
cardBg: string;
copperA: string;
copperB: string;
syrupA: string;
syrupB: string;
syrupC: string;
cream: string;
wheelA: string;
wheelB: string;
pointer: string;
charA: string;
charB: string;
title: string;
sub: string;
text1: string;
text2: string;
text3: string;
accent: string;
hot: string;
cool: string;
danger: string;
tabBg: string;
tabOn: string;
mask: string;
}
const COLORS: ColorPalette = {
bg: '#FBEFDC',
cardBg: '#FFFDF6',
copperA: '#B8703A',
copperB: '#8A4E24',
syrupA: '#D98E2B',
syrupB: '#B8641E',
syrupC: '#F0B85C',
cream: '#FFF3D9',
wheelA: '#E8A94C',
wheelB: '#C8842E',
pointer: '#A8321E',
charA: '#E05A2B',
charB: '#8A4E24',
title: '#FFF8E8',
sub: '#EED9B0',
text1: '#4A2E14',
text2: '#7A5A34',
text3: '#A8895E',
accent: '#C86A1E',
hot: '#D9532E',
cool: '#5E8C6A',
danger: '#D9534F',
tabBg: '#5C3A16',
tabOn: '#F0B85C',
mask: 'rgba(74,46,20,0.5)'
};
interface TabMeta {
label: string;
icon: string;
}
const TAB_LIST: TabMeta[] = [
{ label: '糖样', icon: '🍯' },
{ label: '熬糖', icon: '🔥' },
{ label: '转盘', icon: '🎡' },
{ label: '民俗', icon: '🏮' },
{ label: '技艺', icon: '🥄' },
{ label: '订单', icon: '📦' }
];
const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const SLICE_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7];
const DRIP_IDX: number[] = [0, 1, 2, 3];
const SPOON_IDX: number[] = [0, 1, 2];
const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_SALES: number[] = [5200, 6400, 4600, 7800, 5900, 8600];
const MONTH_NAMES: string[] = ['三月', '四月', '五月', '六月', '七月', '八月'];
const ZODIAC_CHARS: string[] = ['龙', '凤', '鱼', '猴', '虎', '兔', '蛇', '马'];
const STRIP_NAMES: string[] = ['糖样', '技法', '学徒'];
function sliceAngle(i: number): number {
return i * 45;
}
function sliceX(i: number): number {
return 130 + 52 * Math.sin(i * 0.7854);
}
function sliceY(i: number): number {
return 60 + 52 * (1 - Math.cos(i * 0.7854)) / 2;
}
function dripY(i: number): number {
return 118 + i * 10;
}
function sweetColor(s: number): string {
if (s >= 92) {
return COLORS.hot;
}
if (s >= 80) {
return COLORS.syrupA;
}
if (s >= 65) {
return COLORS.cool;
}
return COLORS.text3;
}
function sweetBarW(s: number): number {
return 16 + Math.min(s, 100) / 1.3;
}
function heatColor(h: number): string {
if (h >= 150) {
return COLORS.danger;
}
if (h >= 120) {
return COLORS.hot;
}
return COLORS.syrupB;
}
function orderBarH(amount: number): number {
return 22 + Math.min(amount, 9000) / 90;
}
function priceTag(p: number): string {
if (p >= 30) {
return '精工';
}
if (p >= 15) {
return '常见';
}
return '亲民';
}
@Observed
export class SugarItem {
name: string;
figure: string;
difficulty: number;
price: number;
sweetness: number;
constructor(name: string, figure: string, difficulty: number, price: number, sweetness: number) {
this.name = name;
this.figure = figure;
this.difficulty = difficulty;
this.price = price;
this.sweetness = sweetness;
}
}
@Observed
export class BoilItem {
stage: string;
detail: string;
temp: number;
time: string;
master: string;
constructor(stage: string, detail: string, temp: number, time: string, master: string) {
this.stage = stage;
this.detail = detail;
this.temp = temp;
this.time = time;
this.master = master;
}
}
@Observed
export class WheelItem {
sector: string;
odds: string;
reward: string;
hot: number;
constructor(sector: string, odds: string, reward: string, hot: number) {
this.sector = sector;
this.odds = odds;
this.reward = reward;
this.hot = hot;
}
}
@Observed
export class FolkItem {
city: string;
scene: string;
occasion: string;
note: string;
crowds: number;
constructor(city: string, scene: string, occasion: string, note: string, crowds: number) {
this.city = city;
this.scene = scene;
this.occasion = occasion;
this.note = note;
this.crowds = crowds;
}
}
@Observed
export class SkillItem {
name: string;
tool: string;
yearsToLearn: number;
master: string;
heirs: number;
constructor(name: string, tool: string, yearsToLearn: number, master: string, heirs: number) {
this.name = name;
this.tool = tool;
this.yearsToLearn = yearsToLearn;
this.master = master;
this.heirs = heirs;
}
}
@Observed
export class OrderItem {
buyer: string;
sugar: string;
count: number;
amount: number;
status: string;
constructor(buyer: string, sugar: string, count: number, amount: number, status: string) {
this.buyer = buyer;
this.sugar = sugar;
this.count = count;
this.amount = amount;
this.status = status;
}
}
@Entry
@Component
struct SugarPaintingPage {
@State selTab: number = 0;
@State spin: boolean = false;
@State drip: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State editIdx: number = -1;
@State delIdx: number = -1;
@State inpName: string = '';
@State inpFigure: string = '';
@State inpDiff: string = '';
@State inpPrice: string = '';
@State inpSweet: string = '';
@State inpSector: string = '';
@State inpOdds: string = '';
@State sugarList: SugarItem[] = [
new SugarItem('游龙戏珠', '立体盘龙', 95, 38, 88),
new SugarItem('凤凰朝阳', '振翅凤凰', 90, 35, 86),
new SugarItem('连年有鱼', '双鱼戏莲', 78, 25, 90),
new SugarItem('齐天小圣', '猴子摘桃', 82, 28, 84),
new SugarItem('猛虎下山', '卧虎啸风', 80, 30, 82),
new SugarItem('玉兔捣药', '月宫玉兔', 72, 22, 86),
new SugarItem('灵蛇吐信', '蜿蜒游蛇', 76, 24, 80),
new SugarItem('骏马奔腾', '四蹄腾空', 84, 32, 83),
new SugarItem('蝴蝶恋花', '双翅彩蝶', 70, 18, 88),
new SugarItem('螃蟹横行', '八足蟹将', 74, 20, 85),
new SugarItem('灯笼高挂', '宫灯糖片', 55, 12, 92),
new SugarItem('糖字福禄', '书法糖字', 68, 15, 90)
];
@State boilList: BoilItem[] = [
new BoilItem('化糖', '白糖黄糖入锅,文火慢化', 110, '一炷香', '糖师傅'),
new BoilItem('起泡', '糖浆冒鱼眼泡,竹筷搅动', 125, '一盏茶', '陈火头'),
new BoilItem('拉丝', '挑糖观丝,挂旗为度', 138, '半炷香', '糖师傅'),
new BoilItem('观色', '琥珀透亮,焦香初起', 142, '片刻', '陈火头'),
new BoilItem('保温', '移小火盅,恒温存糖', 125, '随时', '陈火头'),
new BoilItem('试勺', '勺起糖落,拉线细匀', 130, '随手', '糖师傅'),
new BoilItem('收锅', '收工倒模,余糖成板', 100, '收摊', '陈火头')
];
@State wheelList: WheelItem[] = [
new WheelItem('大龙', '十二分之一', '免费重转', 96),
new WheelItem('凤凰', '八分之一', '加赠糖片', 88),
new WheelItem('金鱼', '六分之一', '九折优惠', 82),
new WheelItem('猴子', '六分之一', '九折优惠', 78),
new WheelItem('蝴蝶', '五分之一', '小糖一枚', 70),
new WheelItem('灯笼', '四分之一', '小糖一枚', 66),
new WheelItem('福字', '三分之一', '再来一次', 60),
new WheelItem('谢谢', '二分之一', '空手而归', 52)
];
@State folkList: FolkItem[] = [
new FolkItem('成都', '锦里古街', '庙会赶集', '巴蜀糖画之乡', 8600),
new FolkItem('北京', '厂甸庙会', '新春灯会', '京城吹糖人旧俗', 6200),
new FolkItem('西安', '回民街', '夜市常态', '八百里秦川味道', 7400),
new FolkItem('苏州', '观前街', '评弹书场', '吴门精致小吃', 4800),
new FolkItem('天津', '古文化街', '皇会踩街', '卫嘴子好甜口', 5600),
new FolkItem('重庆', '磁器口', '周末集市', '山城巷子糖香', 6800),
new FolkItem('杭州', '河坊街', '宋城游园', '临安城遗风', 5900),
new FolkItem('南京', '夫子庙', '秦淮灯会', '金陵灯彩配糖画', 7100),
new FolkItem('广州', '永庆坊', '醒狮巡游', '岭南糖兜传统', 4300)
];
@State skillList: SkillItem[] = [
new SkillItem('勺工', '铜勺一把', 5, '糖师傅', 12),
new SkillItem('腕功', '悬腕运勺', 8, '糖师傅', 8),
new SkillItem('吹糖', '芦管吹泡', 10, '陈火头', 5),
new SkillItem('片糖', '大理石板', 6, '陈火头', 9),
new SkillItem('立体糖', '竹签捆扎', 12, '糖师傅', 4),
new SkillItem('糖字', '书法底子', 7, '赵秀才', 6),
new SkillItem('糖塑', '捏塑成形', 9, '赵秀才', 5),
new SkillItem('配色', '食色调糖', 4, '陈火头', 11)
];
@State orderList: OrderItem[] = [
new OrderItem('锦里·蜀韵阁', '游龙戏珠', 50, 1900, '已画好'),
new OrderItem('庙会·厂甸摊位', '凤凰朝阳', 45, 1575, '画制中'),
new OrderItem('灯会·秦淮人家', '连年有鱼', 80, 2000, '已画好'),
new OrderItem('茶馆·鹤鸣社', '糖字福禄', 120, 1800, '已画好'),
new OrderItem('书院·草堂学舍', '玉兔捣药', 60, 1320, '画制中'),
new OrderItem('酒楼·醉长安', '猛虎下山', 30, 900, '已定糖'),
new OrderItem('戏园·易俗大剧院', '齐天小圣', 36, 1008, '画制中'),
new OrderItem('婚庆·花好月圆', '蝴蝶恋花', 90, 1620, '已画好'),
new OrderItem('商号·瑞蚨祥', '骏马奔腾', 24, 768, '已定糖'),
new OrderItem('夜市·磁器口摊', '螃蟹横行', 55, 1100, '已画好'),
new OrderItem('灯彩·秦淮灯铺', '灯笼高挂', 150, 1800, '画制中'),
new OrderItem('文創·宽窄伴手', '灵蛇吐信', 42, 1008, '待确认')
];
aboutToAppear(): void {
setInterval(() => {
this.spin = !this.spin;
}, 1600);
setInterval(() => {
this.drip = !this.drip;
}, 600);
}
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
@Builder
wheelHeader() {
Column({ space: 0 }) {
Stack() {
Column()
.width(150)
.height(40)
.borderRadius({ topLeft: 20, topRight: 20 })
.backgroundColor(COLORS.copperA)
.position({ x: 55, y: 18 })
Column()
.width(110)
.height(14)
.borderRadius(7)
.backgroundColor(COLORS.copperB)
.position({ x: 75, y: 56 })
Column()
.width(130)
.height(130)
.borderRadius(65)
.backgroundColor(COLORS.wheelA)
.position({ x: 65, y: 66 })
.rotate({ angle: this.spin ? 180 : 0 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
Column()
.width(96)
.height(96)
.borderRadius(48)
.backgroundColor(COLORS.wheelB)
.position({ x: 82, y: 83 })
.rotate({ angle: this.spin ? -180 : 0 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
ForEach(SLICE_IDX, (i: number) => {
Column() {
Text(ZODIAC_CHARS[i])
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.pointer)
}
.width(22)
.height(22)
.borderRadius(11)
.backgroundColor(COLORS.cream)
.position({ x: sliceX(i), y: sliceY(i) + 66 })
.rotate({ angle: this.spin ? 180 : 0 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
}, (i: number) => 'zc' + i)
Column()
.width(4)
.height(34)
.borderRadius(2)
.backgroundColor(COLORS.pointer)
.position({ x: 128, y: 62 })
.rotate({ angle: this.spin ? 26 : -26 })
.animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseInOut })
Column()
.width(16)
.height(16)
.borderRadius(8)
.backgroundColor(COLORS.pointer)
.position({ x: 122, y: 123 })
ForEach(DRIP_IDX, (i: number) => {
Column()
.width(5)
.height(8)
.borderRadius({ bottomLeft: 4, bottomRight: 4 })
.backgroundColor(COLORS.syrupB)
.position({ x: 96 + i * 14, y: dripY(i) })
.opacity(this.drip ? 0.9 : 0.15)
.animation({ duration: 600, delay: i * 150, iterations: -1, playMode: PlayMode.Alternate, curve: Curve.EaseIn })
}, (i: number) => 'dp' + i)
}
.width('100%')
.height(210)
.linearGradient({
angle: 180,
colors: [[COLORS.syrupA, 0], [COLORS.syrupB, 1]]
})
Row({ space: 10 }) {
Column()
.width(4)
.height(26)
.borderRadius(2)
.backgroundColor(COLORS.title)
Column({ space: 2 }) {
Text('糖画坊 · 勺走龙蛇')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('以糖为墨 · 以勺为笔')
.fontSize(10)
.fontColor(COLORS.sub)
.letterSpacing(2)
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding({ left: 18, top: 8, bottom: 8 })
}
.width('100%')
}
@Builder
sweetStrip() {
Row({ space: 8 }) {
ForEach(SPOON_IDX, (i: number) => {
Column({ space: 2 }) {
Text(STRIP_NAMES[i])
.fontSize(12)
.fontColor(COLORS.text2)
Text(this.stripCount(i).toString() + ' 项')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.padding(8)
.backgroundColor(COLORS.cream)
.borderRadius(14)
}, (i: number) => 'sp' + i)
}
.width('100%')
.padding({ left: 12, right: 12, top: 10 })
}
stripCount(i: number): number {
if (i === 0) {
return this.sugarList.length;
}
if (i === 1) {
return this.skillList.length;
}
return this.skillList.length + this.boilList.length;
}
@Builder
sugarCard(item: SugarItem, index: number) {
Column({ space: 8 }) {
Row({ space: 10 }) {
Column({ space: 0 }) {
Text(ZODIAC_CHARS[index % 8])
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.copperB)
}
.width(46)
.height(46)
.borderRadius(23)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.rotate({ angle: this.spin ? 8 : -8 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate })
Column({ space: 3 }) {
Row({ space: 6 }) {
Text(item.name)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(priceTag(item.price))
.fontSize(10)
.fontColor(COLORS.title)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.charA)
.borderRadius(8)
}
Text(item.figure + ' · 难度 ' + item.difficulty)
.fontSize(11)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('¥' + item.price)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Row({ space: 6 }) {
Text('✎')
.fontSize(13)
.fontColor(COLORS.cool)
.onClick(() => {
this.editIdx = index;
this.inpName = item.name;
this.inpFigure = item.figure;
this.inpDiff = item.difficulty.toString();
this.inpPrice = item.price.toString();
this.inpSweet = item.sweetness.toString();
this.showEdit = true;
})
Text('删')
.fontSize(12)
.fontColor(COLORS.danger)
.onClick(() => {
this.delIdx = index;
this.showDel = true;
})
}
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
Row({ space: 6 }) {
Text('甜度 ' + item.sweetness)
.fontSize(10)
.fontColor(COLORS.text2)
Column()
.height(6)
.layoutWeight(1)
.borderRadius(3)
.backgroundColor(COLORS.cream)
Column()
.height(6)
.width(sweetBarW(item.sweetness))
.borderRadius(3)
.backgroundColor(sweetColor(item.sweetness))
.opacity(this.drip ? 1 : 0.7)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.shadow({ radius: 5, color: 'rgba(184,100,30,0.14)', offsetX: 0, offsetY: 2 })
}
@Builder
addModal() {
Stack({ alignContent: Alignment.TopStart }) {
this.modalOverlay(() => {
this.showAdd = false;
})
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🍯')
.fontSize(20)
Text('新熬一勺')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.syrupC)
}
.width('100%')
TextInput({ placeholder: '糖样名(如:麒麟送子)', text: this.inpName })
.height(40)
.fontSize(13)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpName = v;
})
TextInput({ placeholder: '造型(立体/平面…)', text: this.inpFigure })
.height(40)
.fontSize(13)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpFigure = v;
})
Row({ space: 8 }) {
TextInput({ placeholder: '难度', text: this.inpDiff })
.height(40)
.fontSize(13)
.layoutWeight(1)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpDiff = v;
})
TextInput({ placeholder: '价格', text: this.inpPrice })
.height(40)
.fontSize(13)
.layoutWeight(1)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpPrice = v;
})
TextInput({ placeholder: '甜度', text: this.inpSweet })
.height(40)
.fontSize(13)
.layoutWeight(1)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpSweet = v;
})
}
.width('100%')
Row({ space: 12 }) {
Column() {
Text('起 勺 画 糖')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.accent)
.borderRadius(22)
.onClick(() => {
this.sugarList.unshift(new SugarItem(
this.inpName.length > 0 ? this.inpName : '新糖一样',
this.inpFigure.length > 0 ? this.inpFigure : '平面糖片',
Number(this.inpDiff) > 0 ? Number(this.inpDiff) : 60,
Number(this.inpPrice) > 0 ? Number(this.inpPrice) : 18,
Number(this.inpSweet) > 0 ? Number(this.inpSweet) : 85
));
this.inpName = '';
this.inpFigure = '';
this.inpDiff = '';
this.inpPrice = '';
this.inpSweet = '';
this.showAdd = false;
})
Column() {
Text('糖 未 化')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text2)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cream)
.borderRadius(22)
.onClick(() => {
this.showAdd = false;
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 4 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.copperB)
.borderRadius(20)
.position({ x: '7%', y: '12%' })
.zIndex(999)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(998)
}
@Builder
editModal() {
Stack({ alignContent: Alignment.TopStart }) {
this.modalOverlay(() => {
this.showEdit = false;
})
Column({ space: 10 }) {
Row({ space: 8 }) {
Text('🥄')
.fontSize(20)
Text('回勺重画')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.syrupC)
}
.width('100%')
TextInput({ placeholder: '糖样名', text: this.inpName })
.height(40)
.fontSize(13)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpName = v;
})
TextInput({ placeholder: '造型', text: this.inpFigure })
.height(40)
.fontSize(13)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpFigure = v;
})
Row({ space: 8 }) {
TextInput({ placeholder: '难度', text: this.inpDiff })
.height(40)
.fontSize(13)
.layoutWeight(1)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpDiff = v;
})
TextInput({ placeholder: '价格', text: this.inpPrice })
.height(40)
.fontSize(13)
.layoutWeight(1)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpPrice = v;
})
TextInput({ placeholder: '甜度', text: this.inpSweet })
.height(40)
.fontSize(13)
.layoutWeight(1)
.backgroundColor(COLORS.cream)
.borderRadius(10)
.onChange((v: string) => {
this.inpSweet = v;
})
}
.width('100%')
Row({ space: 12 }) {
Column() {
Text('定 型 收 勺')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(22)
.onClick(() => {
if (this.editIdx >= 0 && this.editIdx < this.sugarList.length) {
this.sugarList[this.editIdx] = new SugarItem(
this.inpName.length > 0 ? this.inpName : this.sugarList[this.editIdx].name,
this.inpFigure.length > 0 ? this.inpFigure : this.sugarList[this.editIdx].figure,
Number(this.inpDiff) > 0 ? Number(this.inpDiff) : this.sugarList[this.editIdx].difficulty,
Number(this.inpPrice) > 0 ? Number(this.inpPrice) : this.sugarList[this.editIdx].price,
Number(this.inpSweet) > 0 ? Number(this.inpSweet) : this.sugarList[this.editIdx].sweetness
);
}
this.showEdit = false;
})
Column() {
Text('倒 回 糖 锅')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text2)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cream)
.borderRadius(22)
.onClick(() => {
this.showEdit = false;
})
}
.width('100%')
.justifyContent(FlexAlign.Center)
.margin({ top: 4 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.copperB)
.borderRadius(20)
.position({ x: '7%', y: '12%' })
.zIndex(999)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(998)
}
@Builder
delModal() {
Stack({ alignContent: Alignment.TopStart }) {
this.modalOverlay(() => {
this.showDel = false;
})
Column({ space: 14 }) {
Column() {
Text('🍬')
.fontSize(26)
}
.width(56)
.height(56)
.borderRadius(28)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.margin({ top: 6 })
Text('这一勺不画了?')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('撤下后糖样名录除名')
.fontSize(12)
.fontColor(COLORS.sub)
Row({ space: 12 }) {
Column() {
Text('倒 掉 这 勺')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(22)
.onClick(() => {
if (this.selTab === 0) {
this.sugarList.splice(this.delIdx, 1);
} else if (this.selTab === 1) {
this.boilList.splice(this.delIdx, 1);
} else if (this.selTab === 2) {
this.wheelList.splice(this.delIdx, 1);
} else if (this.selTab === 3) {
this.folkList.splice(this.delIdx, 1);
} else if (this.selTab === 4) {
this.skillList.splice(this.delIdx, 1);
} else if (this.selTab === 5) {
this.orderList.splice(this.delIdx, 1);
}
this.showDel = false;
})
Column() {
Text('再 熬 一 锅')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text2)
}
.padding({ left: 22, right: 22, top: 10, bottom: 10 })
.backgroundColor(COLORS.cream)
.borderRadius(22)
.onClick(() => {
this.showDel = false;
})
}
.justifyContent(FlexAlign.Center)
}
.width('76%')
.padding(20)
.backgroundColor(COLORS.copperB)
.borderRadius(20)
.position({ x: '12%', y: '30%' })
.zIndex(999)
.constraintSize({ maxHeight: '80%' })
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.zIndex(998)
}
build() {
Stack() {
Column() {
Scroll() {
Column() {
this.wheelHeader()
this.sweetStrip()
if (this.selTab === 0) {
Row({ space: 10 }) {
Text('糖样图谱 · ' + this.sugarList.length + ' 样')
.fontSize(13)
.fontColor(COLORS.text2)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.syrupC)
Column() {
Text('+ 新 熬 一 勺')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
}
.padding({ left: 16, right: 16, top: 8, bottom: 8 })
.backgroundColor(COLORS.accent)
.borderRadius(18)
.onClick(() => {
this.showAdd = true;
})
}
.width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 8 })
ForEach(this.sugarList, (item: SugarItem, index: number) => {
Column() {
this.sugarCard(item, index)
}
.margin({ left: 12, right: 12, bottom: 10 })
}, (item: SugarItem) => item.name + item.price.toString())
}
if (this.selTab === 1) {
Column({ space: 10 }) {
ForEach(this.boilList, (item: BoilItem, index: number) => {
Row({ space: 10 }) {
Column({ space: 0 }) {
Text('🔥')
.fontSize(16)
}
.width(36)
.height(36)
.borderRadius(12)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.opacity(this.drip ? 1 : 0.6)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Column({ space: 2 }) {
Text(item.stage + ' · ' + item.time)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.detail)
.fontSize(11)
.fontColor(COLORS.text2)
Row({ space: 6 }) {
Text(item.temp + ' 度')
.fontSize(10)
.fontColor(heatColor(item.temp))
Column()
.width(item.temp / 2)
.height(5)
.borderRadius(3)
.backgroundColor(heatColor(item.temp))
}
Text('掌火 ' + item.master)
.fontSize(10)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('删')
.fontSize(13)
.fontColor(COLORS.danger)
.onClick(() => {
this.delIdx = index;
this.showDel = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
if (this.selTab === 2) {
Column({ space: 10 }) {
ForEach(this.wheelList, (item: WheelItem, index: number) => {
Row({ space: 10 }) {
Column() {
Text(ZODIAC_CHARS[index % 8])
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.copperB)
}
.width(34)
.height(34)
.borderRadius(17)
.backgroundColor(COLORS.cream)
.justifyContent(FlexAlign.Center)
.rotate({ angle: this.spin ? 12 : -12 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate })
Column({ space: 2 }) {
Text(item.sector + ' 格')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('中签 ' + item.odds + ' · ' + item.reward)
.fontSize(11)
.fontColor(COLORS.text2)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('人气 ' + item.hot)
.fontSize(11)
.fontColor(COLORS.accent)
Column()
.width(14 + item.hot / 1.6)
.height(5)
.borderRadius(3)
.backgroundColor(COLORS.syrupA)
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
if (this.selTab === 3) {
Column({ space: 10 }) {
ForEach(this.folkList, (item: FolkItem, index: number) => {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(item.city)
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.occasion)
.fontSize(10)
.fontColor(COLORS.title)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(COLORS.charB)
.borderRadius(8)
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.cream)
Text('游人 ' + item.crowds + '/日')
.fontSize(11)
.fontColor(COLORS.accent)
}
.width('100%')
Text(item.scene + ' · ' + item.note)
.fontSize(11)
.fontColor(COLORS.text2)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
if (this.selTab === 4) {
Column({ space: 10 }) {
ForEach(this.skillList, (item: SkillItem, index: number) => {
Row({ space: 10 }) {
Text('🥄')
.fontSize(17)
.rotate({ angle: this.spin ? 14 : -14 })
.animation({ duration: 1600, iterations: -1, playMode: PlayMode.Alternate })
Column({ space: 2 }) {
Text(item.name + ' · ' + item.tool)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('需学 ' + item.yearsToLearn + ' 年 · 传人 ' + item.heirs + ' 位')
.fontSize(11)
.fontColor(COLORS.text2)
Text('宗师 ' + item.master)
.fontSize(10)
.fontColor(COLORS.text3)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 12 })
}
if (this.selTab === 5) {
Column({ space: 6 }) {
Text('近六月糖画出摊(支)')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.alignSelf(ItemAlign.Start)
Row({ space: 10 }) {
ForEach(MONTH_IDX, (i: number) => {
Column({ space: 4 }) {
Column()
.width(18)
.height(orderBarH(MONTH_SALES[i]))
.borderRadius(6)
.backgroundColor(COLORS.syrupA)
.opacity(this.drip ? 1 : 0.7)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Text(MONTH_NAMES[i])
.fontSize(10)
.fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}, (i: number) => 'ob' + i)
}
.width('100%')
.padding({ top: 8, bottom: 12 })
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
}
.padding({ left: 12, right: 12, top: 12 })
.alignItems(HorizontalAlign.Start)
Column({ space: 10 }) {
ForEach(this.orderList, (item: OrderItem, index: number) => {
Row({ space: 10 }) {
Column({ space: 2 }) {
Text(item.buyer)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.sugar + ' × ' + item.count)
.fontSize(11)
.fontColor(COLORS.text2)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text('¥' + item.amount)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.status)
.fontSize(10)
.fontColor(item.status === '已画好' ? COLORS.cool : COLORS.charA)
}
.alignItems(HorizontalAlign.End)
Text('删')
.fontSize(13)
.fontColor(COLORS.danger)
.onClick(() => {
this.delIdx = index;
this.showDel = true;
})
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
})
}
.padding({ left: 12, right: 12, top: 10 })
}
}
.width('100%')
.padding({ bottom: 150 })
}
.layoutWeight(1)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
Column({ space: 6 }) {
Row({ space: 8 }) {
ForEach(ROW1_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon)
.fontSize(17)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.selTab === i ? COLORS.syrupA : COLORS.cream)
.borderRadius(14)
.onClick(() => {
this.selTab = i;
})
}, (i: number) => 't1' + i)
}
.width('100%')
.padding({ left: 10, right: 10 })
Row({ space: 8 }) {
ForEach(ROW2_IDX, (i: number) => {
Column({ space: 2 }) {
Text(TAB_LIST[i].icon)
.fontSize(17)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontColor(this.selTab === i ? COLORS.tabOn : COLORS.sub)
}
.layoutWeight(1)
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.selTab === i ? COLORS.syrupA : COLORS.cream)
.borderRadius(14)
.onClick(() => {
this.selTab = i;
})
}, (i: number) => 't2' + i)
}
.width('100%')
.padding({ left: 10, right: 10, bottom: 8 })
}
.width('100%')
.backgroundColor(COLORS.tabBg)
}
.width('100%')
.height('100%')
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
第九是文化沉浸感的文案设计。整个应用的文案都融入了糖画行话和文化元素——“新熬一勺"替代"添加”、“回勺重画"替代"编辑”、“倒掉这勺"替代"确认删除”、“定型收勺"替代"保存”、“糖未化"替代"取消”、“再熬一锅"替代"放弃”。这种行话化的文案设计不仅增强了应用的文化沉浸感,还使得每个操作都具有了故事性和仪式感。用户在使用应用的过程中,不仅仅是在操作一个数据管理工具,更是在体验糖画匠人的日常工作流程——选糖样、熬糖浆、转转盘、画糖画、收糖勺。这种将业务操作映射为文化体验的设计理念,是非遗主题应用区别于普通工具应用的核心特征。

第十是布局系统的弹性适配能力。应用使用了 Flex 弹性布局系统,通过 layoutWeight 属性实现等分宽度,通过百分比宽度实现屏幕适配,通过 constraintSize 限制最大高度防止溢出。Row 和 Column 容器的组合使用构建出了各种复杂的布局结构——卡片的三段式布局(头像、信息、操作)、导航栏的两行三列布局、弹窗的居中浮动布局等。position 绝对定位在转盘组件中发挥了关键作用,通过精确的像素坐标实现了多层元素的空间叠加。linearGradient 渐变和 shadow 阴影则为界面增添了质感和层次感,使得每个元素都具有立体的视觉效果。
从技术架构的角度来看,这个应用虽然功能丰富但代码结构清晰。所有数据模型集中在文件头部定义,所有工具函数紧随其后,所有 @Builder 方法在组件中部声明,build 方法在最后统一编排。这种自上而下的代码组织方式使得阅读者能够从数据层到表现层逐步理解应用的全貌。如果项目进一步扩大,可以考虑将数据模型、工具函数、@Builder 方法分别拆分到不同的文件中,通过 import 引入,实现更细粒度的模块化管理。
更多推荐
所有评论(0)