基于HarmonyOS ArkTS API 24全栈式非遗文化应用开发深度解析,只需在接口中增加字段即可,所有使用 TabMeta 类型的代码都会获得类型检查的保护
一、兔爷泥塑的历史文化背景

兔爷,又称"兔儿爷",是北京地区最具代表性的民间泥塑艺术品之一,其历史渊源可追溯至明代中后期,距今已有近四百年的传承历史。作为中秋时节祭月、祈福的重要民俗载体,兔爷泥塑不仅仅是一件泥偶玩具,更是老北京民间信仰、节令习俗与手工技艺三者交融的文化结晶。在浩瀚的中华非物质文化遗产谱系中,兔爷以其独特的造型语言、鲜明的色彩体系与深厚的民俗内涵,成为了北方泥塑艺术中一颗璀璨的明珠。
兔爷的起源与月宫玉兔的传说密不可分。中国古代民间流传着"嫦娥奔月"的美丽神话,月宫中的玉兔捣药的形象深入人心。明代北京城中,每逢中秋佳节,百姓便以泥塑兔形之物供奉月神,祈求平安健康。据《帝京景物略》等文献记载,明代嘉靖年间,北京城内已有专门制作兔爷的作坊,当时称之为"兔儿山"或"兔儿摊"。到了清代,兔爷泥塑的发展进入了鼎盛时期,不仅民间百姓家家户户中秋必备兔爷供奉,就连宫廷内院也对此颇为青睐。清代《燕京岁时记》中详细记载了兔爷的制作工艺与市集盛况,描述了东四牌楼、前门大街一带每逢中秋便兔爷摊位林立、热闹非凡的景象。
兔爷的造型并非一成不变,而是在数百年的传承中不断演变与丰富。最初的兔爷造型较为简朴,多为端坐于莲花座上的兔首人身形象,手持玉杵,象征月宫捣药之意。随着时代发展,匠人们开始为兔爷添加各种服饰与道具,逐渐形成了武将兔爷、文官兔爷、骑虎兔爷、骑鹿兔爷、骑麒麟兔爷等多种变体。其中,武将兔爷身着铠甲、背插护背旗、头戴帅盔,威风凛凛,被认为具有镇宅辟邪的神力;骑虎兔爷借虎之威猛,寓意驱邪避灾;骑鹿兔爷取"鹿"与"禄"的谐音,象征福禄双全;骑麒麟兔爷则与"麒麟送子"的古老传说相连,寓意多子多福。每一种造型都承载着特定的文化寓意,反映了不同时期、不同阶层民众的精神寄托与生活愿景。
兔爷泥塑的制作工艺同样是一门精深的传统手艺。从选泥到成品,需经过和泥、塑胎、阴干、素烧、打底、勾线、彩绘、点红唇等数十道工序,每一道工序都有严格的技法要求与经验积累。和泥讲究选取质地细腻、粘性适中的胶泥或粘土,反复揉搓排除气泡以保证胎体密实;塑胎阶段或手工捏塑或以模具翻制,匠人需凭手感与眼力把握兔爷的比例与神态;阴干是极为关键的一步,必须置于阴凉通风处缓慢脱水,急干会导致胎体开裂,前功尽弃;素烧则在窑炉中以约八百度的温度进行低温定型,使泥胎具有一定的硬度和耐久性。此后的打底、勾线、彩绘等装饰工序更是考验匠人的审美与笔功,白粉打底后以细笔勾勒眉眼轮廓,再以彩笔涂甲开脸,最后以朱砂笔点红唇,可谓"画龙点睛"之笔,一尊神采奕奕的兔爷便跃然眼前。
兔爷的色彩体系极具特色,以大红、大黄、大绿为主色调,辅以金色勾勒,形成了浓烈喜庆、富丽堂皇的视觉风格。这种色彩搭配并非随意为之,而是根植于中国传统民间色彩美学——红色象征喜庆吉祥,黄色代表尊贵华美,绿色则寓意生机与平安。金色线条的运用更是点睛之笔,不仅增添了兔爷的华贵气质,也在视觉上强化了造型的层次感与立体感。在老北京人的记忆中,兔爷的色彩与中秋明月的清辉交相辉映,构成了一幅温馨而隽永的民俗画卷。
进入现代社会后,兔爷泥塑面临着工业品冲击、传承人减少、市场萎缩等多重挑战。然而,随着国家对非物质文化遗产保护力度的加大,兔爷泥塑在近年又焕发出了新的生机。2009年,北京泥塑兔儿爷被列入北京市非物质文化遗产名录,多位兔爷制作技艺的传承人如双起翔、张忠强等大师致力于技艺的传承与创新。与此同时,文创产业的发展也为兔爷注入了新的活力,不少设计师将兔爷元素融入现代产品设计之中,让这一古老的民间艺术以全新的面貌走进了年轻一代的视野。
本文所解析的 HarmonyOS ArkTS 应用,正是以兔爷泥塑为主题,运用现代移动端开发技术,将这一非遗文化以数字化、交互化的方式呈现于方寸屏幕之上。应用涵盖了兔爷名品展示、武将盔甲锻造、瑞兽坐骑介绍、彩塑匠人列传、制兔八序工法、订单月度走势六大模块,构建了一个集文化展示、数据管理、交互操作于一体的综合性文化应用。接下来,我们将逐段深入剖析其代码实现,从色彩体系到数据模型,从 UI 组件到状态管理,全面揭示一款高质量 HarmonyOS 应用的技术内核。
二、色彩体系:ColorPalette 接口与 COLORS 常量

2.1 ColorPalette 接口定义
interface ColorPalette {
bg: string;
cardBg: string;
header1: string;
header2: string;
faceA: string;
faceB: string;
robeA: string;
robeB: string;
gold: 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 是一个 TypeScript 接口,它定义了整个应用所使用的全部色彩属性的名称与类型契约。这个接口本身不产生任何运行时的值,它的作用是纯粹的类型约束——确保后续定义的 COLORS 常量对象严格包含所有预定的颜色字段,且每个字段的值都是字符串类型。通过接口先行约束的方式,开发者可以在编译阶段就捕获色彩字段缺失或类型不匹配的错误,这对于一个拥有二十一个颜色字段的大型应用来说,其安全保障作用不可小觑。
从字段命名来看,ColorPalette 的设计具有清晰的功能语义分层。bg 与 cardBg 分别对应页面整体背景色与卡片背景色,构成视觉层级的基础;header1 与 header2 用于标题区域的渐变色定义,形成深浅渐变的视觉效果;faceA 与 faceB 专门用于兔爷面部色彩的渐变,robeA 与 robeB 用于服饰色彩的深浅搭配,这种 A/B 后缀的命名模式暗示了渐变或对比的使用场景。gold 作为独立的强调色存在,title 与 sub 用于标题与副标题文字色彩,text1 至 text3 形成了三级文字颜色层次,分别对应主要文字、次要文字与辅助文字。accent、hot、cool、danger 则是四个具有语义化的功能色——accent 为品牌主调,hot 表达热度与警示,cool 传达沉稳与生机,danger 用于危险操作提示。最后,tabBg 与 tabOn 服务于底部标签栏的切换状态,mask 用于模态遮罩层。
这种将所有颜色集中管理于一个接口之下的设计模式,在工程实践中具有深远的意义。首先,它实现了色彩定义与色彩使用的物理分离,所有 UI 组件都不直接书写颜色字面量,而是通过引用 COLORS 对象的属性来获取颜色值,这意味着当需要调整某一颜色时,只需修改 COLORS 常量中的对应字段即可全局生效,极大降低了维护成本。其次,接口定义本身也充当了设计文档的角色——通过审视 ColorPalette 的字段列表,设计人员与开发人员可以快速了解整个应用的色彩体系结构,确保设计稿与实现之间的一致性。此外,TypeScript 的接口在 ArkTS 环境中同样适用,这使得该色彩体系能够在静态类型检查的护航下安全运作。
2.2 COLORS 常量定义

const COLORS: ColorPalette = {
bg: '#FDF3EC',
cardBg: '#FFFFFF',
header1: '#8C3B2E',
header2: '#3E1510',
faceA: '#F6D8B8',
faceB: '#F2C9A0',
robeA: '#3E7C54',
robeB: '#2B5C3C',
gold: '#E3B23C',
title: '#FDEEDA',
sub: '#E8C49A',
text1: '#5A2A1E',
text2: '#7A5542',
text3: '#A58E7C',
accent: '#8C3B2E',
hot: '#C0392B',
cool: '#3E7C54',
danger: '#D9534F',
tabBg: '#8C3B2E',
tabOn: '#F2C9A0',
mask: 'rgba(0,0,0,0.45)'
};
COLORS 常量是 ColorPalette 接口的具体实现,它将每一个抽象的色彩属性赋予了具体的色值。这是一份精心调配的色彩方案,其整体基调以暖色系为主,贴合了兔爷泥塑这一民间艺术品的传统色彩特征。背景色 #FDF3EC 是一种极浅的暖米色,仿佛宣纸在暖光下的色泽,为整个应用奠定了温润古朴的视觉基调。卡片背景使用纯白 #FFFFFF,与背景色之间形成了微弱但清晰的层次差异,使内容卡片在视觉上自然浮起。
标题区域的渐变色 header1 #8C3B2E 与 header2 #3E1510 是一组从砖红到深棕的深暖色调,这种色彩组合让人联想到老北京城墙的朱红与泥塑的赭褐,富有传统韵味。兔爷面部色彩 faceA #F6D8B8 与 faceB #F2C9A0 是两组肤色系,前者偏暖偏亮,后者略深略沉,用于面部渐变以营造立体感。服饰色彩 robeA #3E7C54 与 robeB #2B5C3C 是一组深浅绿色,呼应了传统兔爷铠甲中常见的翠绿色彩,同时也作为 cool 语义色使用。金色 #E3B23C 是一种偏暖的古金色调,区别于纯金的耀眼,更接近于泥塑上金箔的温润质感。
文字色彩的三级层次设计也颇为考究。text1 #5A2A1E 是深褐色,用于最重要的标题与名称;text2 #7A5542 是中棕色,用于次级说明文字;text3 #A58E7C 是浅棕灰色,用于最弱的辅助提示信息。这三级灰度变化在暖色基调下自然过渡,既保证了可读性,又避免了纯黑文字对整体色彩氛围的破坏。功能色方面,accent 与 header1 共用 #8C3B2E,确立了品牌主色调的一致性;hot #C0392B 与 danger #D9534F 虽然都属红色系,但前者更偏正红用于热度表达,后者更偏珊瑚红用于危险提示,色彩语义清晰可辨。标签栏的 tabBg #8C3B2E 与 tabOn #F2C9A0 形成了深浅对比,使选中与未选中状态的切换一目了然。最后的 mask 使用 rgba(0,0,0,0.45) 半透明黑色,用于模态弹窗时的背景遮罩,其透明度经过精心调校,既能有效突出前景内容,又不至于完全遮蔽背景信息。
从工程角度审视,将 COLORS 声明为 const 常量意味着它在运行时不可被重新赋值,这保证了色彩体系在整个应用生命周期中的稳定性。同时,由于 COLORS 是模块级别的常量,它在内存中只存在一份实例,所有引用它的组件共享同一份数据,既节省了内存开销,也确保了色彩取值的一致性。这种集中式的色彩管理模式,是大型应用开发中普遍采用的最佳实践之一,它使得主题切换、暗色模式适配等高级功能在未来扩展时变得轻而易举——只需替换 COLORS 对象本身即可实现全局换肤。
三、标签系统:TabMeta 接口与 TAB_LIST 常量

3.1 TabMeta 接口定义
interface TabMeta {
label: string;
icon: string;
}
TabMeta 接口定义了底部标签栏中每一个标签项的元数据结构,它极其简洁——仅包含 label 与 icon 两个字符串字段。label 字段存储标签的中文显示名称,icon 字段则存储对应的 Emoji 表情符号作为图标。这种设计选择值得注意:应用没有使用传统的图片资源或矢量图标作为标签图标,而是直接采用了 Unicode Emoji 字符,这既减少了图片资源的体积开销,又利用了系统字体内置的 Emoji 渲染能力,使得标签图标天然具备了跨平台的一致性与彩色显示能力。
接口虽小,其设计意义却不浅。将标签的文本与图标封装为接口类型,意味着后续如需扩展标签的属性——例如添加标签的颜色、角标数字、路由路径等——只需在接口中增加字段即可,所有使用 TabMeta 类型的代码都会获得类型检查的保护。这是一种面向接口编程的典型实践,在应用规模尚小之时就为未来的扩展预留了结构性的空间。
3.2 TAB_LIST 常量定义

const TAB_LIST: TabMeta[] = [
{ label: '兔爷', icon: '🐰' },
{ label: '盔甲', icon: '🛡️' },
{ label: '坐骑', icon: '🐯' },
{ label: '匠人', icon: '🧑🎨' },
{ label: '工序', icon: '🎨' },
{ label: '订单', icon: '📦' }
];
TAB_LIST 是一个包含六个 TabMeta 元素的数组常量,它定义了应用底部标签栏的全部标签项。六个标签按顺序分别是"兔爷"、“盔甲”、“坐骑”、“匠人”、“工序"和"订单”,这一排列并非随机,而是遵循了从主体到附属、从产品到流程、从静态到动态的逻辑线索。"兔爷"作为应用的核心主体放在首位,是最主要的展示内容;"盔甲"与"坐骑"是兔爷的附属装饰与配套元素,紧随其后;"匠人"介绍了制作兔爷的传承人;"工序"展示了兔爷的制作流程;"订单"则以商业数据动态收尾,形成了从文化展示到商业运营的完整叙事链。
每个标签的 Emoji 图标选择也颇具匠心。兔兔 🐰 直接对应兔爷主题,形象生动;盾牌 🛡️ 代表武将盔甲的防护之意;虎头 🐯 对应骑虎兔爷的坐骑属性;匠人 🧑🎨 是一个正在创作的工匠形象,精准传达了手工艺人的角色定位;调色板 🎨 既代表彩绘工序,也暗示了艺术创作的本质;包裹 📦 则直观地表达了订单与物流的概念。这组 Emoji 图标在视觉上色彩丰富、辨识度高,与标签文本形成了互补的双重信息通道,即使用户快速扫视也能迅速定位到目标标签。
将 TAB_LIST 声明为模块级 const 常量,与 COLORS 的设计理念一脉相承。它保证了标签数据在整个应用中的唯一性与不可变性,所有引用 TAB_LIST 的组件都读取同一份数据源。当需要修改标签名称或图标时,只需在 TAB_LIST 定义处修改一处即可全局生效。此外,由于 TAB_LIST 是一个数组,它的长度直接决定了标签栏的标签数量,这意味着增加或减少标签也只需在数组中增删元素即可,无需修改任何组件的渲染逻辑,充分体现了数据驱动 UI 的设计理念。
四、常量索引数组与辅助函数

4.1 常量索引数组
const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const FLAG_IDX: number[] = [0, 1, 2];
const STAR_IDX: number[] = [0, 1, 2, 3, 4];
const TASSEL_IDX: number[] = [0, 1, 2];
const EAR_IDX: number[] = [0, 1];
这六个常量数组是专门用于 ForEach 循环渲染的索引序列,它们的存在体现了代码设计中对"语义化命名"与"配置集中管理"的双重追求。ROW1_IDX 与 ROW2_IDX 将六个底部标签分为两行三列的布局——第一行展示索引 0、1、2 对应的"兔爷"、“盔甲”、“坐骑”,第二行展示索引 3、4、5 对应的"匠人"、“工序”、“订单”。这种将索引提取为独立常量的做法,使得标签行的划分逻辑一目了然,未来如需调整每行的标签数量(例如改为三行两列),只需修改这两个数组的元素分配即可。
FLAG_IDX、STAR_IDX、TASSEL_IDX 和 EAR_IDX 则服务于页头(pageHeader)中兔爷形象的绘制。FLAG_IDX 对应三面护背旗的渲染索引,STAR_IDX 对应五颗装饰星花的渲染索引,TASSEL_IDX 对应三条金色流苏的渲染索引,EAR_IDX 对应两只兔耳朵的渲染索引。这些索引数组虽然内容简单(仅是连续整数序列),但将它们提取为命名常量后,代码的可读性大幅提升——当读者在 ForEach 中看到 STAR_IDX 时,立刻就能理解这是在渲染星星;若直接写 [0,1,2,3,4],则需要进入循环体才能推断其用途。
从更深层的设计角度来看,这些索引数组实际上扮演了"渲染配置"的角色。每一个索引数组的长度直接决定了对应装饰元素的数量——STAR_IDX 有五个元素意味着渲染五颗星星,EAR_IDX 有两个元素意味着渲染两只耳朵。如果未来需要增加星星的数量或调整耳朵的数量,只需修改对应数组的元素即可,无需触及循环体的逻辑。这种将数量配置外置于常量的做法,使得 UI 的微调变得极为便捷,也降低了因修改循环体而引入 bug 的风险。
4.2 位置计算函数群

function flagX(i: number): number {
return 254 + i * 20;
}
function flagY(i: number): number {
return 26 + i * 12;
}
function flagAngle(i: number): number {
return 8 - i * 8;
}
这三个函数负责计算护背旗的水平位置、垂直位置和旋转角度。flagX 函数以 254 为基准横坐标,每增加一个索引就向右偏移 20 像素,使三面旗帜在水平方向上均匀排列。flagY 函数以 26 为基准纵坐标,每增加一个索引就向下偏移 12 像素,使旗帜在垂直方向上形成阶梯状的错落排布。flagAngle 函数则以 8 度为初始旋转角,每增加一个索引减少 8 度,使三面旗帜的旋转角度分别为 8 度、0 度和 -8 度,形成左右对称的飘扬姿态。
这三个函数虽然各自只有一行返回语句,但它们共同构成了护背旗的空间布局算法。通过将位置与角度的计算封装为独立函数,代码实现了布局逻辑与渲染逻辑的分离——渲染组件只需调用函数获取坐标即可,无需关心坐标是如何计算出来的。这种分离使得布局调整变得集中且安全,例如若需将旗帜间距从 20 调整为 24,只需修改 flagX 中的系数即可,不影响任何渲染代码。
function starX(i: number): number {
return 40 + i * 24;
}
function starY(i: number): number {
return 18 + (i % 2) * 14;
}
starX 与 starY 函数负责计算装饰星花的位置。starX 以 40 为基准,每增加一个索引向右移动 24 像素,使五颗星星在水平方向上等间距排列。starY 的计算则更为巧妙——它使用了取模运算 (i % 2),当索引为偶数(0、2、4)时返回 18,当索引为奇数(1、3)时返回 32(18+14)。这种设计使得星星在垂直方向上交替错落,形成了一种类似夜空中星星散布的自然布局,避免了等高排列的单调感。这种取模技巧在 UI 布局中是一种常见的手法,用于实现交错、锯齿等不规则但有序的排列效果。
function earX(i: number): number {
return 118 + i * 104;
}
function tasselY(i: number): number {
return 96 + i * 10;
}
earX 函数计算两只兔耳朵的水平位置,以 118 为基准,索引 0 对应左耳位于 x=118,索引 1 对应右耳位于 x=222(118+104)。两只耳朵之间相隔 104 像素,这一间距与兔爷面部宽度相匹配,使耳朵恰好位于头顶两侧。tasselY 函数计算三条流苏的垂直位置,以 96 为基准,每条流苏向下偏移 10 像素,形成自上而下的垂坠效果。流苏的垂直递增布局模拟了真实流苏因重力而自然下垂的排列方式,增强了装饰的动效感。
4.3 数据条形宽度计算函数
function auraBarW(a: number): number {
return 18 + a * 2.2;
}
function skillBarW(s: number): number {
return 16 + s * 1.1;
}
function craftBarW(d: number): number {
return 16 + d * 1.6;
}
function orderBarH(a: number): number {
return 24 + Math.min(a, 50000) / 500;
}
这四个函数负责将数据值映射为 UI 中条形图的宽度或高度,它们是数据可视化的核心算法。auraBarW 将兔爷的灵性值(0-100 范围)映射为条形宽度,基础宽度 18 像素加上灵性值乘以 2.2,当灵性值为 95 时宽度为 18+95×2.2=227 像素,当灵性值为 80 时宽度为 18+80×2.2=194 像素,形成了明显的视觉差异。skillBarW 将盔甲品级或坐骑威仪值映射为条形宽度,系数 1.1 比灵性条的 2.2 更小,这是因为品级数值通常在 80-96 之间,较小的系数使条形宽度差异更为精细。
craftBarW 函数用于工序天数到条形宽度的映射,系数 1.6 介于灵性与技能之间,适配了天数通常在 1-3 之间的较小数值范围。orderBarH 函数最为特殊——它将订单金额映射为柱状图的高度,并使用 Math.min(a, 50000) 对输入值进行了上限截断。这一设计非常重要:当某笔订单金额超过 50000 时,柱形高度不再继续增长,而是被限制在 24+50000/500=124 像素的高度上限。这种截断策略防止了异常大值导致的柱形过高破坏图表整体布局的问题,是数据可视化中常见的"值域裁剪"技巧。基准高度 24 像素则保证了即使金额为零,柱形仍有最小的可视高度,避免完全消失。
这组函数共同体现了一个重要的设计原则:数据到视觉的映射必须是可控的、有界的、线性(或分段线性)的。通过将映射逻辑封装为独立函数,不仅提高了代码的复用性(同一映射函数可在多处使用),也使得映射参数的调优变得集中且便捷。若需调整灵性条的视觉灵敏度,只需修改 auraBarW 中的系数 2.2 即可全局生效。
4.4 语义化色彩映射函数
function poseColor(p: string): string {
if (p === '坐像') {
return COLORS.accent;
}
if (p === '骑虎') {
return COLORS.hot;
}
if (p === '骑鹿') {
return COLORS.cool;
}
return COLORS.text3;
}
function mountColor(m: string): string {
if (m === '虎') {
return COLORS.hot;
}
if (m === '鹿') {
return COLORS.cool;
}
if (m === '麒麟') {
return COLORS.gold;
}
return COLORS.accent;
}
function titleColor(t: string): string {
if (t === '大师') {
return COLORS.accent;
}
if (t === '名家') {
return COLORS.hot;
}
return COLORS.cool;
}
function stepColor(s: number): string {
if (s <= 3) {
return COLORS.faceB;
}
if (s <= 6) {
return COLORS.gold;
}
return COLORS.accent;
}
这四个函数构成了应用的语义化色彩映射体系,它们将业务含义(姿态、坐骑类型、匠人级别、工序阶段)映射为视觉色彩。poseColor 函数根据兔爷的姿态返回对应的标签背景色——"坐像"使用品牌主色 accent 的砖红色,"骑虎"使用 hot 的正红色以示威猛,"骑鹿"使用 cool 的绿色以示祥和,其他姿态则使用 text3 的浅灰棕色作为默认色。这种姿态-色彩的映射使用户在浏览兔爷列表时,通过标签颜色就能快速辨识兔爷的类型,形成了一种隐式的分类视觉索引。
mountColor 函数为坐骑类型分配色彩——虎对应 hot 红色,鹿对应 cool 绿色,麒麟对应 gold 金色,其他默认为 accent。这种映射与 poseColor 保持了一致的语义——虎与骑虎都使用 hot 红色,鹿与骑鹿都使用 cool 绿色,使得同一文化概念在不同上下文中保持色彩的一致性,强化了用户的色彩-语义联想。titleColor 函数则根据匠人的级别分配色彩——大师使用 accent 砖红色显得庄重,名家使用 hot 红色彰显声望,匠人与学徒则使用 cool 绿色表达沉稳与成长。stepColor 函数按工序阶段分为三段——前期工序(序号1-3)使用 faceB 的肤色,中期工序(序号4-6)使用 gold 金色,后期工序(序号7-8)使用 accent 砖红色,这种分段着色使用户能够直观感知工序的进展阶段。
这组函数的设计模式有一个共同特征:它们都采用了一系列 if 语句进行条件匹配,最后以默认返回值兜底。这种模式在分支数量较少时简洁清晰,但如果未来分支增多,可以考虑改为 Map 或 switch-case 结构以提升可读性。从 ArkTS 的语法约束来看,函数的参数类型与返回类型都显式标注为 string,这确保了类型安全——传入非字符串参数或忘记返回值都会在编译时被捕获。
五、数据模型:@Observed 类详解
5.1 ToyRabbitItem 兔爷模型
@Observed
export class ToyRabbitItem {
name: string;
pose: string;
aura: number;
size: string;
price: number;
constructor(name: string, pose: string, aura: number, size: string, price: number) {
this.name = name;
this.pose = pose;
this.aura = aura;
this.size = size;
this.price = price;
}
}
ToyRabbitItem 是应用中最核心的数据模型,代表一尊兔爷泥塑作品。它被 @Observed 装饰器修饰,这意味着该类的实例成为了可观察对象——当其属性发生变化时,所有引用该实例的 UI 组件都会自动重新渲染。这一机制是 ArkUI 响应式编程的基石,它使得数据与视图之间的同步无需手动触发,框架在底层自动完成了依赖追踪与差异更新。
该模型包含五个字段:name(兔爷名称,如"镇宅大兔爷")、pose(姿态,如"坐像"、“骑虎”、“骑鹿”、“骑麒麟”、“扮相”)、aura(灵性值,0-100 的数值,代表该兔爷的稀有程度与工艺水平)、size(尺寸描述,如"高 38cm")、price(价格,以元为单位的数字)。这五个字段从名称、造型、品质、规格、价值五个维度完整地描述了一尊兔爷的信息,既是数据存储的结构,也是 UI 渲染的数据源。
构造函数 constructor 接收五个参数并依次赋值给实例属性,这是最基础的类初始化模式。值得注意的是,所有属性都在构造函数中完成了赋值,不存在可选属性或延迟赋值的场景,这保证了每一个 ToyRabbitItem 实例在被创建时就处于完整可用的状态。在应用的数据初始化中,可以看到十二尊兔爷被创建并放入 rabbits 数组——从"镇宅大兔爷"到"月宫兔爷",涵盖了坐像、骑虎、骑鹿、骑麒麟、扮相等多种姿态,灵性值从 80 到 96 不等,价格从 198 元到 980 元,构成了一个丰富多样的兔爷名品库。
@Observed 装饰器的工作原理值得深入理解。当一个 @Observed 类的实例被 @State、@Prop、@ObjectLink 等状态装饰器引用时,框架会在属性被修改时触发回调,进而驱动依赖该属性的 UI 组件进行定向更新。这种机制避免了全量刷新的性能开销——只有真正变化的数据对应的 UI 片段才会被重新渲染。在 ToyRabbitItem 的使用场景中,当用户通过新增弹窗添加一尊兔爷时,rabbits 数组被 push 新实例,ForEach 会自动追加渲染新的兔爷卡片,而无需手动操作 DOM 或调用 invalidate 方法。
5.2 ArmorItem 盔甲模型
@Observed
export class ArmorItem {
name: string;
part: string;
level: number;
material: string;
note: string;
constructor(name: string, part: string, level: number, material: string, note: string) {
this.name = name;
this.part = part;
this.level = level;
this.material = material;
this.note = note;
}
}
ArmorItem 模型代表兔爷的武将盔甲装备,同样使用 @Observed 装饰以支持响应式更新。其五个字段涵盖了盔甲的完整信息:name(盔甲名称,如"黄金锁子甲"、“红缨帅盔”)、part(部位分类,如"胸甲"、“头盔”、“背旗”、“披风"等)、level(品级评分,数值范围大致在 80-96 之间)、material(材质,如"金箔”、“绒球”、“绸缎"等)、note(特征描述,如"护心镜”、“顶缨”、"四杆旗"等)。
ArmorItem 模型的设计特别注重 level 属性的可变性。在应用的锻造弹窗中,用户可以对盔甲进行"精工 +4分"或"旧甲翻新 -2分"的操作,直接修改 selArmor.level 的值。由于 ArmorItem 是 @Observed 类,当 level 属性被修改时,盔甲列表中对应卡片的品级条宽度(由 skillBarW(item.level) 计算)和品级数字会自动更新,无需任何手动刷新。这正是 @Observed 装饰器的价值所在——它使数据的可变性在 UI 层面变得透明且自动。
盔甲数据的初始化包含了八件装备,从"黄金锁子甲"到"七星旗座",覆盖了胸甲、头盔、背旗、披风、护腕、靴、腰带、旗座等部位,材质涵盖金箔、绒球、绸缎、锦缎、银箔、彩绘、白蜡、木胎等多种传统工艺材料。这种丰富的数据初始化使得盔甲标签页的内容充实而有深度,用户在浏览过程中可以全面了解兔爷武将装束的各个组成部分。
5.3 MountItem 坐骑模型
@Observed
export class MountItem {
name: string;
mount: string;
speed: number;
meaning: string;
use: string;
constructor(name: string, mount: string, speed: number, meaning: string, use: string) {
this.name = name;
this.mount = mount;
this.speed = speed;
this.meaning = meaning;
this.use = use;
}
}
MountItem 模型代表兔爷的瑞兽坐骑,五个字段分别是:name(坐骑名称,如"黑虎"、“白象”、“梅花鹿"等)、mount(坐骑类型简写,如"虎”、“象”、“鹿”、“麒麟"等,用于 mountColor 函数的色彩映射)、speed(威仪值,数值代表该坐骑的威严程度)、meaning(文化寓意,如"驱邪避灾”、“吉祥平安”、“禄寿双全"等)、use(适用场景,如"镇宅”、“摆设”、“祝寿”、"求嗣"等)。
MountItem 的字段设计体现了文化深度与实用信息的平衡。meaning 字段传达了每种坐骑在传统文化中的象征意义——虎驱邪、象吉祥、鹿禄寿、麒麟送子、狮威镇、鹤延年、马前程、凤百鸟朝凤,这些寓意深植于中国民俗信仰体系之中。use 字段则将文化寓意转化为具体的适用场景,为用户在选购或定制兔爷时提供了实用的参考指南。这种"文化+实用"的双字段设计,使得坐骑信息既有知识性又有指导性。
八条坐骑数据的初始化从"黑虎"到"凤凰",威仪值从 78 到 94,覆盖了镇宅、摆设、祝寿、求嗣、学业、婚庆等多种用途,构成了一套完整的瑞兽坐骑谱系。@Observed 装饰器在此模型上的作用虽然不如 ArmorItem 那般显著(坐骑数据在应用中不被修改),但它为未来可能的功能扩展——例如坐骑升级、威仪值动态调整等——预留了响应式更新的能力。
5.4 TuhuArtisanItem 匠人模型
@Observed
export class TuhuArtisanItem {
name: string;
title: string;
age: number;
works: number;
skill: number;
constructor(name: string, title: string, age: number, works: number, skill: number) {
this.name = name;
this.title = title;
this.age = age;
this.works = works;
this.skill = skill;
}
}
TuhuArtisanItem 模型代表兔爷泥塑的传承匠人,其五个字段设计精到:name(匠人姓名,如"双起翔"、“张忠强"等,这些名字致敬了现实中兔爷制作技艺的非遗传承人)、title(级别头衔,取值为"大师”、“名家”、“匠人”、“学徒”,用于 titleColor 函数的色彩映射)、age(年龄,反映匠人的资历与世代)、works(传世作品数量,体现匠人的创作产量)、skill(技艺评分,0-100 数值,量化匠人的工艺水平)。
匠人模型的特别之处在于其数据的真实性与层次感。八位匠人从"双起翔"(大师,88岁,500件作品,技艺99分)到"周点彩"(学徒,20岁,50件作品,技艺73分),形成了从大师到学徒的完整梯队结构。这种层次分明的数据设计不仅展示了兔爷制作技艺的传承谱系,也使用户在浏览匠人列表时能够感受到这门古老手艺代代相传的生命力。title 字段的四级分类(大师、名家、匠人、学徒)配合 titleColor 函数的色彩映射,使每条匠人记录的标签颜色直观传达了匠人的级别——砖红色的大师庄重权威,红色的名家声名显赫,绿色的匠人与学徒沉稳务实。
@Observed 在匠人模型上的应用为未来可能的匠人信息管理功能——例如技艺评分更新、作品数量增加、级别晋升等——提供了响应式更新的基础。虽然当前应用中匠人数据是静态展示的,但 @Observed 装饰器的存在使得这些数据天然具备了可观测性,一旦业务需求变化,数据修改即可自动反映到 UI 层。
5.5 TuhuStepItem 工序模型
@Observed
export class TuhuStepItem {
name: string;
days: number;
tool: string;
note: string;
seq: number;
constructor(name: string, days: number, tool: string, note: string, seq: number) {
this.name = name;
this.days = days;
this.tool = tool;
this.note = note;
this.seq = seq;
}
}
TuhuStepItem 模型代表兔爷制作的一道工序,其字段设计精准地描述了每道工序的核心要素:name(工序名称,如"和泥"、“塑胎”、“阴干"等)、days(所需天数,体现工序的时间成本)、tool(使用工具与材料,如"粘土·水”、“模具·刮刀”、“窑炉"等)、note(工序要点,如"揉匀排泡”、“兔爷身胚”、"缓慢脱水"等)、seq(工序序号,1-8,决定在列表中的排列顺序与 stepColor 函数的色彩分段)。
八道工序从"和泥"(序号1,2天)到"点红唇"(序号8,1天),完整地呈现了兔爷泥塑从泥土到成品的全部制作流程。总工期约17天,体现了手工泥塑"慢工出细活"的工艺特征。seq 字段的存在使得工序数据天然具备有序性,即使在数据源中打乱顺序,渲染时也能通过 seq 进行排序。stepColor 函数利用 seq 值将八道工序分为三个色彩阶段——前期(1-3)使用肤色 faceB,中期(4-6)使用金色 gold,后期(7-8)使用砖红 accent——使用户在视觉上能够感知制作流程的进展节奏。
工序数据的 note 字段值得特别关注——“揉匀排泡”、“缓慢脱水”、“800度定型”、“铺白底色”、“眉眼轮廓”、“涂甲开脸”、“画龙点睛”——这些短语精炼地概括了每道工序的技术要领,体现了匠人世代积累的经验智慧。通过数字化呈现这些工序信息,应用不仅是一个展示工具,更是一部兔爷制作技艺的微缩百科。
5.6 TuhuOrderItem 订单模型
@Observed
export class TuhuOrderItem {
name: string;
buyer: string;
amount: number;
count: number;
month: string;
constructor(name: string, buyer: string, amount: number, count: number, month: string) {
this.name = name;
this.buyer = buyer;
this.amount = amount;
this.count = count;
this.month = month;
}
}
TuhuOrderItem 模型代表一笔兔爷订单,其五个字段涵盖了订单的核心信息:name(兔爷名称,关联到 ToyRabbitItem 的 name 字段)、buyer(买家身份,如"庙会"、“民俗馆”、“收藏家”、“婚庆公司"等)、amount(订单金额,以元为单位)、count(订购数量,以尊为单位)、month(订单月份,格式为"YYYY-MM”,用于月度走势分析)。
TuhuOrderItem 是应用中交互性最强的数据模型。在删除弹窗中,用户可以删除选定的订单——通过 this.orders.splice(this.orders.indexOf(this.selOrder), 1) 实现数组的定向删除。由于 orders 数组是 @State 修饰的,且数组元素是 @Observed 类的实例,splice 操作会触发 ForEach 的差异更新,自动移除对应的订单卡片,同时 orderChart 中的柱状图也会自动重新渲染以反映删除后的订单集合。这种数据驱动的自动更新机制,使得应用的交互体验流畅而自然。
十条订单数据的初始化涵盖了 2026 年 4 月至 8 月的五个月份数据,买家类型多样——从庙会、民俗馆到收藏家、婚庆公司,从文创店、旅游商店到戏曲团、祝寿定制——展示了兔爷泥塑在不同场景下的市场需求。金额从 9900 元到 26640 元不等,为 orderChart 的柱状图提供了丰富的数据维度。month 字段的"YYYY-MM"格式使得数据天然具备时间序列属性,为按月汇总和走势分析奠定了数据基础。
六、状态管理与生命周期
6.1 @State 状态变量群
@Entry
@Component
struct TuhuPage {
@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State selArmor: ArmorItem | null = null;
@State selOrder: TuhuOrderItem | null = null;
@State formName: string = '';
@State formPose: string = '';
@State formPrice: string = '';
@State rabbits: ToyRabbitItem[] = [ ... ];
@State armors: ArmorItem[] = [ ... ];
@State mounts: MountItem[] = [ ... ];
@State artisans: TuhuArtisanItem[] = [ ... ];
@State steps: TuhuStepItem[] = [ ... ];
@State orders: TuhuOrderItem[] = [ ... ];
TuhuPage 结构体是整个应用的唯一入口组件,被 @Entry 和 @Component 双重装饰。@Entry 标识其为应用的根组件,@Component 标识其为 ArkUI 声明式组件。结构体内声明了十六个 @State 状态变量,构成了应用全部的状态空间。这些状态变量可以分为四类:交互控制类、表单数据类、选中对象类和数据列表类。
交互控制类状态包括 curTab(当前选中的标签索引,0-5)、breath(呼吸动画的布尔翻转标志)、showAdd/showEdit/showDel(三个模态弹窗的显示控制布尔值)。curTab 控制着 tabContent 中六个条件分支的渲染切换——当 curTab 为 0 时显示兔爷列表,为 1 时显示盔甲列表,依此类推。breath 是一个特殊的动画驱动变量,它在 aboutToAppear 中通过 setInterval 每 490 毫秒翻转一次,驱动页头中星星闪烁、旗帜飘扬、流苏摇摆等动画效果。showAdd、showEdit、showDel 三个布尔值分别控制新增兔爷弹窗、锻造盔甲弹窗和删除订单弹窗的显隐,它们在 build 方法中通过 if 条件渲染来决定是否将对应的模态层加入组件树。
表单数据类状态包括 formName、formPose、formPrice 三个字符串变量,它们服务于新增弹窗中的 TextInput 双向绑定。当用户在输入框中输入内容时,onChange 回调将值写回对应的 @State 变量,这些变量又在确认按钮被点击时被用于构造新的 ToyRabbitItem 实例。这种"状态作为表单数据的中间存储"的模式,使得表单数据在弹窗关闭与重新打开之间可以保持(或通过重置清空),为用户提供了灵活的编辑体验。
选中对象类状态包括 selArmor 和 selOrder,它们的类型是 ArmorItem | null 和 TuhuOrderItem | null——联合类型允许它们为 null(未选中状态)或具体的模型实例(已选中状态)。当用户在盔甲列表中点击某件盔甲的品级分数时,该盔甲实例被赋值给 selArmor,同时 showEdit 被设为 true,锻造弹窗便展示 selArmor 的详细信息。这种"先选中、后操作"的两步交互模式,使得数据的修改有明确的上下文参照,避免了误操作。
数据列表类状态是六个 @Observed 类的数组,分别存储兔爷、盔甲、坐骑、匠人、工序和订单的完整数据集。这些数组在声明时即完成了数据初始化,每个数组都包含了精心准备的示例数据。@State 对数组的修改操作(push、splice 等)会触发 ForEach 的差异更新,这是 ArkUI 响应式系统对数组类型的特殊支持——框架会追踪数组的变化并精确地增删对应的 UI 元素,而非全量重建列表。
6.2 aboutToAppear 生命周期
aboutToAppear(): void {
setInterval(() => {
this.breath = !this.breath;
}, 490);
}
aboutToAppear 是 ArkUI 组件生命周期的重要回调之一,它在组件实例创建后、build 方法执行前被调用。在这个应用中,aboutToAppear 的唯一职责是启动一个周期性定时器,每 490 毫秒翻转 breath 布尔值。这个看似简单的操作实际上是整个应用动画体系的心脏。
breath 状态变量的翻转通过 @State 的响应式机制触发了一系列 UI 更新。在 pageHeader 中,星星的 opacity 在 this.breath ? 0.3 : 0.9 之间交替变化,形成闪烁效果;流苏的 rotate 角度在 this.breath ? 8 : -8 度之间交替,形成摇摆效果;在 orderChart 中,柱状图的颜色在 this.breath ? COLORS.gold : COLORS.robeA 之间交替,形成色彩呼吸效果。这些动画虽然各自独立,但都由同一个 breath 变量驱动,形成了一种协调一致的"呼吸"节奏——整个界面仿佛有了生命,以约半秒为周期规律地律动着。
490 毫秒的周期选择并非随意——它接近于人类自然呼吸的周期(约 4 秒一个完整呼吸循环的八分之一),在视觉上形成一种不急不缓的律动感。如果周期过短(如 100 毫秒),动画会显得急躁闪烁,令人不适;如果周期过长(如 2000 毫秒),动画又会显得迟钝沉闷。490 毫秒的取值恰到好处地平衡了动感与舒适度。
需要特别注意的是,setInterval 返回的定时器 ID 在此代码中未被保存,也没有在 aboutToDisappear 中被清除。这意味着该定时器会一直运行到应用关闭。在当前单页面应用的结构中,这不是问题——TuhuPage 作为 @Entry 根组件,其生命周期与应用一致。但如果未来该组件被拆分为子组件在多个页面间复用,未清除的定时器将成为内存泄漏的隐患。这是一个在更严格的工程质量要求下值得优化的点。
七、UI 组件逐段分析
7.1 modalOverlay 模态遮罩
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
modalOverlay 是一个可复用的 @Builder 函数,它接收一个 onClose 回调函数作为参数,渲染一个覆盖全屏的半透明遮罩层。遮罩使用 COLORS.mask 的 rgba(0,0,0,0.45) 半透明黑色作为背景色,在视觉上将前景弹窗与背景内容分离开来。当用户点击遮罩区域时,onClose 回调被调用,执行关闭弹窗的逻辑。
@Builder 是 ArkUI 中用于声明可复用 UI 片段的装饰器。与 @Component 不同,@Builder 不创建独立的组件实例,而是在调用处内联展开,这使得它的性能开销极低,适合用于抽取重复的 UI 片段。modalOverlay 在应用中被新增弹窗、锻造弹窗、删除弹窗三个弹窗复用,每个弹窗在 Stack 中先铺一层 modalOverlay 作为背景遮罩,再在其上叠加弹窗内容。这种"遮罩+内容"的 Stack 叠层结构是模态弹窗的经典布局模式。
onClose 参数的类型是 () => void——一个无参无返回值的函数类型。这种通过函数参数传递行为的设计,使得 modalOverlay 具有极高的通用性——调用方可以传入任意的关闭逻辑。在新增弹窗中,onClose 是 () => { this.showAdd = false; };在锻造弹窗中,是 () => { this.showEdit = false; };在删除弹窗中,是 () => { this.showDel = false; }。同一个 modalOverlay 通过不同的回调函数实现了三个不同弹窗的关闭逻辑,充分体现了参数化 Builder 的复用价值。
从交互设计的角度来看,点击遮罩关闭弹窗是一种符合用户直觉的交互模式——用户在弹窗外面的任意位置点击,都期望弹窗能够关闭。这种模式在现代移动应用中几乎成为了标准,modalOverlay 的封装使得三个弹窗都一致地获得了这一交互能力,无需各自重复编写遮罩代码。
7.2 pageHeader 页头组件——渐变背景与星花
@Builder
pageHeader() {
Column() {
Stack() {
Column()
.width('100%')
.height('100%')
.borderRadius(22)
.linearGradient({
angle: 135,
colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
})
ForEach(STAR_IDX, (i: number) => {
Text('✦')
.fontSize(9 + (i % 2) * 3)
.fontColor(COLORS.gold)
.opacity(this.breath ? 0.3 : 0.9)
.position({ x: starX(i), y: starY(i) })
.animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => 'z' + i)
pageHeader 是应用中最复杂、最精美的 @Builder 组件,它构建了一个包含兔爷形象、装饰元素、标题文字和统计数据的完整页头区域。整个页头以一个 Stack 为容器,Stack 的第一层是一个铺满整个区域的 Column,设置了 22 的圆角和 135 度角的线性渐变背景。渐变从 header1 的砖红色 #8C3B2E(起始色,偏移量 0)过渡到 header2 的深棕色 #3E1510(终止色,偏移量 1),形成了一种从亮到暗、从暖到沉的深邃视觉效果,仿佛是古老庙宇中朱红漆面在烛光映照下的色泽变化。
在渐变背景之上,第一组 ForEach 使用 STAR_IDX 索引数组渲染五颗装饰星花。每颗星花使用 Unicode 字符 ‘✦’(四角星),字体大小通过 9 + (i % 2) * 3 计算——索引为偶数(0、2、4)时字号为 9,索引为奇数(1、3)时字号为 12,形成了大小交替的节奏感。星花的颜色为 COLORS.gold 的金色,在深色渐变背景上熠熠生辉。opacity 属性绑定到 breath 变量——当 breath 为 true 时透明度 0.3(暗淡),为 false 时透明度 0.9(明亮),配合 700 毫秒时长、无限循环、交替播放模式的 animation,形成了星星闪烁的动画效果。
ForEach 的第三个参数是键值生成函数 (i: number) => 'z' + i,它为每颗星星生成唯一的键值 ‘z0’、‘z1’、‘z2’、‘z3’、‘z4’。键值在 ArkUI 的 ForEach 差异更新中至关重要——框架通过比较新旧键值列表来决定哪些元素需要新增、删除或重排。使用唯一且稳定的键值(如 ‘z’ + 索引)可以确保 ForEach 在更新时精确匹配每个元素,避免不必要的重渲染。
7.3 pageHeader 页头组件——护背旗
ForEach(FLAG_IDX, (i: number) => {
Column() {
Column()
.width(3)
.height(26)
.backgroundColor(COLORS.robeB)
Column()
.width(14)
.height(10)
.backgroundColor(COLORS.gold)
.borderRadius(2)
.rotate({ angle: flagAngle(i) })
.animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })
}
.position({ x: flagX(i), y: flagY(i) })
}, (i: number) => 'f' + i)
在星花之上,第二组 ForEach 渲染三面护背旗。每面旗帜由两个 Column 组合而成——上部的旗杆(width 3, height 26, robeB 深绿色)和下部的旗面(width 14, height 10, gold 金色,borderRadius 2 形成微圆角矩形)。旗面通过 rotate 属性以 flagAngle(i) 计算的角度进行旋转——第一面旗旋转 8 度(向右倾斜),第二面旗旋转 0 度(垂直),第三面旗旋转 -8 度(向左倾斜),三面旗形成左右对称的飘扬姿态。
旗面的 rotate 属性后紧跟了 animation 声明——800 毫秒时长、无限循环、交替播放。这里的 animation 作用于 rotate 属性,配合外层 breath 变化驱动的组件重渲染,animation 的 Alternate 播放模式会使其在两个状态间交替过渡,形成旗帜摇摆的视觉效果。
position 属性通过 flagX(i) 和 flagY(i) 将每面旗帜定位到精确的坐标位置。三面旗帜的水平位置分别为 x=254、274、294,垂直位置分别为 y=26、38、50,形成了从左上到右下的阶梯排列,模拟了旗帜插在兔爷背后自上而下展开的视觉效果。‘f’ + i 的键值前缀确保旗帜的 ForEach 与星花的 ForEach 不会产生键值冲突。
7.4 pageHeader 页头组件——兔爷面部与耳朵
Column()
.width(96)
.height(60)
.borderRadius(48)
.backgroundColor(COLORS.robeA)
.position({ x: 122, y: 52 })
Column()
.width(72)
.height(46)
.borderRadius(36)
.backgroundColor(COLORS.faceA)
.position({ x: 134, y: 42 })
ForEach(EAR_IDX, (i: number) => {
Column()
.width(18)
.height(30)
.borderRadius(9)
.backgroundColor(COLORS.faceB)
.border({ width: 2, color: COLORS.robeB })
.position({ x: earX(i), y: 14 })
}, (i: number) => 'e' + i)
兔爷的面部形象由两个 Column 和一组耳朵的 ForEach 构成。第一个 Column(width 96, height 60, borderRadius 48)使用 robeA 的绿色,位于 (122, 52),构成了兔爷的绿色头饰底座——其 borderRadius 48 接近 width/2,使得矩形顶部形成半圆形,模拟了头盔的弧形轮廓。第二个 Column(width 72, height 46, borderRadius 36)使用 faceA 的肤色 #F6D8B8,位于 (134, 42),叠加在头饰底座之上,构成了兔爷的面部。两个 Column 的位置错位(面部比头饰底座偏右上方 12 像素、偏上 10 像素)形成了一种层次感,面部从头盔中露出,模拟了真实的物理遮挡关系。
耳朵的 ForEach 使用 EAR_IDX [0, 1] 渲染两只耳朵,每只耳朵是一个 Column(width 18, height 30, borderRadius 9),使用 faceB 的肤色,并添加了 robeB 深绿色的 2 像素边框。borderRadius 9 使得耳朵形成圆润的椭圆形,边框则勾勒出耳朵的轮廓线。两只耳朵分别位于 (118, 14) 和 (222, 14),对称地置于头顶两侧。它们在垂直方向上(y=14)高于面部(y=42)和头饰(y=52),在 Stack 的层级上也位于它们之后(后绘制的元素覆盖先绘制的),但由于 position 定位使它们在视觉上位于头顶上方,不与面部和头饰重叠,形成了兔子标志性的长耳形象。
这段代码通过纯 CSS 式的布局——用矩形、圆角、定位和颜色组合出兔爷的面部形象——展现了 ArkUI 声明式 UI 的强大表现力。没有使用任何图片资源,仅凭几何形状与色彩的组合就构建出了一个辨识度极高的兔爷头像,既节省了资源开销,又保证了在任何屏幕分辨率下的清晰渲染。
7.5 pageHeader 页头组件——面部细节
Text('王')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
.position({ x: 163, y: 50 })
Column()
.width(5)
.height(5)
.borderRadius(2.5)
.backgroundColor(COLORS.text1)
.position({ x: 148, y: 58 })
Column()
.width(5)
.height(5)
.borderRadius(2.5)
.backgroundColor(COLORS.text1)
.position({ x: 178, y: 58 })
Column()
.width(3)
.height(3)
.borderRadius(1.5)
.backgroundColor(COLORS.hot)
.position({ x: 165, y: 66 })
在面部基础之上,这段代码为兔爷添加了面部细节——额头"王"字、两只眼睛和嘴巴。Text(‘王’) 以 10 号粗体字、accent 砖红色渲染,定位于 (163, 50),模拟了兔爷额头上书写的"王"字——这一设计灵感来源于传统虎头帽或虎面泥塑上常写的"王"字,象征着兔爷武将形象的威猛与王者之气。
两只眼睛由两个 5x5 像素的 Column 构成,borderRadius 2.5 使其成为圆形,text1 深褐色使眼睛在肤色面部上清晰可见。左眼位于 (148, 58),右眼位于 (178, 58),两眼间距 30 像素,对称分布在面部中线两侧。嘴巴则是一个 3x3 像素的小圆点,使用 hot 正红色,位于 (165, 66),在两眼下方居中。这些微小的细节元素虽然尺寸仅有几像素,但它们赋予了兔爷面部以神态与表情——圆圆的眼睛显得天真灵动,小巧的红嘴似笑非笑,配合额头的"王"字,形成了一种"既威严又萌趣"的独特气质,这正是兔爷泥塑艺术的精髓所在。
这种以几何形状构建复杂图像的手法,在声明式 UI 框架中是一种常见的"无图绘图"技术。它的优势在于:一是无需图片资源,减小应用体积;二是矢量精度,在任何分辨率下都保持清晰;三是颜色可动态改变,便于主题适配。其局限在于只能构建相对简单的图形,复杂图像仍需依赖图片资源。在本应用中,兔爷的面部形象相对简练,几何形状的组合足以胜任,这种选择是恰当的。
7.6 pageHeader 页头组件——流苏装饰
ForEach(TASSEL_IDX, (i: number) => {
Column()
.width(2)
.height(14)
.backgroundColor(COLORS.gold)
.borderRadius(1)
.position({ x: 160 + i * 6, y: tasselY(i) })
.rotate({ angle: this.breath ? 8 : -8 })
.animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => 'a' + i)
在兔爷面部之下,TASSEL_IDX 的 ForEach 渲染三条金色流苏。每条流苏是一个 2x14 像素的细长 Column,使用 gold 金色,borderRadius 1 形成微圆角。三条流苏的 x 坐标分别为 160、166、172(基准 160 + i*6),y 坐标分别为 96、106、116(tasselY 函数计算),自上而下排列在兔爷面部下方,模拟了头盔或帽饰上垂坠的金色流苏穗子。
流苏的 rotate 属性绑定到 breath 变量——this.breath ? 8 : -8,当 breath 为 true 时旋转 8 度,为 false 时旋转 -8 度。配合 650 毫秒时长、无限循环、交替播放的 animation,流苏在 +8 度与 -8 度之间来回摆动,形成了流苏随风轻摇的动画效果。650 毫秒的时长略短于星星的 700 毫秒和旗帜的 800 毫秒,使得不同元素的动画节奏略有差异,避免了所有动画同步运动带来的机械感,形成了一种更自然、更有机的律动感。
三条流苏的 x 坐标间距仅 6 像素,它们紧密排列在兔爷面部中下方的位置,视觉上仿佛是从兔爷的帽冠或头饰上垂坠下来的装饰穗子。这种细节装饰虽然微小,但极大丰富了兔爷形象的精致感与层次感,使整个页头的兔爷形象不显得空泛。‘a’ + i 的键值前缀保证了流苏 ForEach 的元素唯一性。
7.7 pageHeader 页头组件——标题与统计
Column() {
Text('兔儿爷坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('中秋瑞兽 · 一尊彩塑佑平安')
.fontSize(11)
.fontColor(COLORS.sub)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 18, y: 12 })
在装饰元素的右侧偏上区域,这段代码渲染了应用的主标题与副标题。主标题"兔儿爷坊"以 20 号粗体字、title 的暖白色 #FDEEDA 显示,在深色渐变背景上醒目而温润。副标题"中秋瑞兽 · 一尊彩塑佑平安"以 11 号字、sub 的浅棕色 #E8C49A 显示,margin top 4 像素使其与主标题保持适度间距。整个 Column 设置 alignItems 为 HorizontalAlign.Start(左对齐),position 定位于 (18, 12)——位于页头左上角。
主副标题的文案设计颇具匠心。"兔儿爷坊"以"坊"字收尾,传达了一种手工作坊的古朴感与匠人气息,暗示这不是一个普通的商店,而是一个传承手艺的工坊。副标题"中秋瑞兽 · 一尊彩塑佑平安"则用精炼的语言概括了兔爷的文化本质——它是中秋时节的瑞兽象征,一尊彩塑泥像承载着保佑平安的祈愿。这个副标题既交代了兔爷的节令属性(中秋),又点明了其文化功能(佑平安),文字精练而内涵丰富。
Row() {
Text('12')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('兔爷')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 58 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('盔甲')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 80 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('坐骑')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 58 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('工序')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 80 })
在标题下方,四个 Row 组件分别展示了四组统计数据。第一组位于 (18, 58)——"12 兔爷"表示应用中共收录了 12 尊兔爷名品。数字以 15 号粗体字、title 暖白色显示,标签"兔爷"以 9 号字、sub 浅棕色显示,margin left 2 像素与数字紧贴。这种"大数字+小标签"的排版模式是一种常见的信息展示手法——数字以醒目的大字呈现关键信息,标签以小字补充语义,形成了清晰的视觉层次。
第二组统计数据"8 盔甲"位于 (18, 80),表示收录了 8 件盔甲装备。第三组和第四组统计数据"8 坐骑"和"8 工序"分别位于 (118, 58) 和 (118, 80)。四组数据形成了 2x2 的网格布局——左侧两列展示"兔爷"与"盔甲"的数量,右侧两列展示"坐骑"与"工序"的数量。这种布局使得用户在一瞥之间就能获知应用的核心内容规模——12 尊兔爷、8 件盔甲、8 种坐骑、8 道工序,对应用的信息量有了直观的认知。
整个页头区域以 128 像素的高度呈现在页面顶部,Stack 容器使得所有元素——渐变背景、星花、旗帜、兔爷形象、流苏、标题、统计数据——都在同一个布局空间中自由定位,通过 position 坐标精确排列。这种绝对定位的 Stack 布局模式在构建复杂视觉形象时非常有用,它允许设计师以像素级的精度控制每个元素的位置,实现精细的视觉设计。
7.8 rabbitRow 兔爷列表项
@Builder
rabbitRow(item: ToyRabbitItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.pose)
.fontSize(9)
.fontColor(COLORS.cardBg)
.backgroundColor(poseColor(item.pose))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text(item.size)
.fontSize(10)
.fontColor(COLORS.text3)
Row() {
Text('灵性')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(auraBarW(item.aura))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.robeA)
.margin({ left: 6 })
Text(item.aura + '%')
.fontSize(9)
.fontColor(COLORS.cool)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('¥' + item.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
rabbitRow 是兔爷列表中每一行卡片的 @Builder 函数,它接收一个 ToyRabbitItem 参数,将其信息以精心设计的布局呈现为一行卡片。整个卡片以 Row 为容器,从左到右依次排列:兔爷名称、姿态标签、尺寸与灵性条、价格。这种横向排列的信息布局使每张卡片在有限的宽度内展示了丰富的信息维度。
兔爷名称 Text 以 14 号粗体字、text1 深褐色显示,是卡片中最醒目的文字。紧跟其后的姿态标签 Text 以 9 号字、cardBg 白色字显示,背景色由 poseColor 函数根据姿态动态返回——"坐像"为砖红、"骑虎"为正红、"骑鹿"为绿色等。姿态标签通过 borderRadius 8 形成药丸形圆角,padding 6 像素水平、2 像素垂直使文字与边缘保持适度间距,margin left 8 与名称保持分离。这个姿态标签在视觉上形成了一个彩色徽章,使用户在浏览列表时能够通过颜色快速辨识兔爷的类型。
名称与姿态标签之后是一个 Column,设置了 layoutWeight(1) 使其占据 Row 中的剩余宽度。Column 内部包含两部分:上方是尺寸 Text(10 号字,text3 浅棕色),下方是一个 Row 包含灵性标签、灵性条和灵性百分比。灵性条是一个 Column,其宽度由 auraBarW(item.aura) 计算——灵性值越高,条形越宽,形成了一种直观的数据可视化。灵性条使用 robeA 绿色,高度 6 像素,borderRadius 3 形成圆角条形。灵性百分比 Text 以 9 号字、cool 绿色显示在条形右侧,将条形宽度所表达的数值以精确数字的形式补充呈现。这种"条形+数字"的双重表达方式,既提供了视觉直觉(条形长度),又提供了精确数据(百分比数字),兼顾了快速扫描与精确读取两种需求。
最右侧是价格 Text,以 12 号粗体字、accent 砖红色显示,前缀’¥’符号使其一目了然为价格信息。价格使用 accent 品牌主色而非 hot 或 danger,传达了一种正面的、非警示性的商业信息呈现态度。整个卡片以 cardBg 白色为背景,borderRadius 12 形成圆角,padding 12 使内容与边缘保持间距,margin top 8 使卡片之间在垂直方向上保持间距。这种白色圆角卡片在暖色背景上的设计,形成了清爽的内容区域与温暖的背景之间的层次对比。
7.9 armorRow 盔甲列表项
@Builder
armorRow(item: ArmorItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.part)
.fontSize(9)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.accent)
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text(item.material + ' · ' + item.note)
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(skillBarW(item.level))
.height(5)
.borderRadius(2)
.backgroundColor(COLORS.gold)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.level + '分')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
.onClick(() => {
this.selArmor = item;
this.showEdit = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
armorRow 是盔甲列表中每一行卡片的 @Builder 函数,其布局结构与 rabbitRow 高度相似——从左到右依次为名称、部位标签、材质与特征信息、品级条与品级分数。这种结构一致性使得用户在兔爷标签页和盔甲标签页之间切换时,能够以相同的视觉模式理解信息,降低了认知负担。但 armorRow 在细节上与 rabbitRow 有若干差异,这些差异体现了不同数据类型的个性化呈现。
盔甲名称以 14 号粗体字显示,与兔爷名称一致。部位标签 Text 使用 accent 砖红色作为背景色(而非 poseColor 的动态色彩),因为盔甲的部位分类不需要通过颜色区分——每个部位(胸甲、头盔、背旗等)都是唯一的,不像兔爷姿态那样有色彩语义。材质与特征信息以"材质 · 特征"的格式合并为一个 Text(9 号字,text3 浅棕色),这种合并方式比拆分为两个 Text 更紧凑,适合在有限空间内展示复合信息。
品级条使用 skillBarW(item.level) 计算宽度,高度 5 像素(比灵性条的 6 像素略细),使用 gold 金色(而非灵性条的 robeA 绿色)。金色品级条在视觉上传达了"精工锻造"的贵重感,与盔甲的武将属性相得益彰。品级条下方对齐方式为 HorizontalAlign.End(右对齐),与灵性条的左对齐形成对比——盔甲的品级信息在右侧靠近分数显示,形成紧凑的信息簇。
armorRow 最显著的交互特性是品级分数 Text 绑定了 onClick 事件——this.selArmor = item; this.showEdit = true;。当用户点击品级分数时,当前盔甲实例被赋值给 selArmor 状态变量,同时 showEdit 被设为 true 触发锻造弹窗显示。这种"点击查看详情/编辑"的交互模式使用户能够对盔甲进行精工锻造操作。onClick 的存在使品级分数不仅是一个信息展示,更是一个交互入口,用户通过点击它可以进入更深入的盔甲管理流程。品级分数使用 hot 正红色显示,在视觉上也暗示了其可交互性——红色文字在白色卡片上形成了一种"按钮"般的视觉提示。
7.10 mountRow 坐骑列表项
@Builder
mountRow(item: MountItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.mount)
.fontSize(9)
.fontColor(COLORS.cardBg)
.backgroundColor(mountColor(item.mount))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text(item.meaning + ' · ' + item.use)
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(skillBarW(item.speed))
.height(5)
.borderRadius(2)
.backgroundColor(COLORS.faceB)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text('威仪 ' + item.speed)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cool)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
mountRow 是坐骑列表的行卡片 @Builder 函数,结构与 rabbitRow 和 armorRow 保持一致但又有细节差异。坐骑名称以 14 号粗体字显示,坐骑类型标签使用 mountColor 函数动态返回背景色——虎为 hot 红色、鹿为 cool 绿色、麒麟为 gold 金色,这种动态色彩与 poseColor 的设计理念相同,使用户能够通过颜色快速辨识坐骑类型。
寓意与用途信息以"寓意 · 用途"的格式合并显示,例如"驱邪避灾 · 镇宅"、"吉祥平安 · 摆设"等。这种合并方式将坐骑的文化内涵与实用场景紧凑地呈现在一行文字中,使用户在浏览时既能了解坐骑的象征意义,又能获知其适用场景。威仪条使用 skillBarW(item.speed) 计算宽度,高度 5 像素,使用 faceB 肤肤色作为条形颜色。faceB 是一种温暖的肤色 #F2C9A0,与灵性条的绿色和品级条的金色形成了区分,使三种数据条在视觉上各具特色。
威仪数值以 10 号粗体字、cool 绿色显示,前缀"威仪"两字标明了数据的语义。使用 cool 绿色而非 hot 红色或 accent 砖红色,传达了坐骑"祥和"与"生机"的属性——与盔甲品级的 hot 红色形成对比,盔甲是武将的攻击性象征(红色),坐骑是瑞兽的守护性象征(绿色)。这种色彩语义的设计虽然细微,但在整体应用的色彩体系中形成了连贯的语义网络。
mountRow 不包含任何 onClick 交互——坐骑信息是纯展示性的,用户无法修改坐骑数据。这一设计决策反映了坐骑信息的文化属性——坐骑的寓意与用途是传统文化固有的,不应被用户随意修改,与盔甲的可锻造性形成了对比。这种"可变数据"与"不可变数据"的区分,通过交互的有无在 UI 层面得到了明确的表达。
7.11 artisanRow 匠人列表项
@Builder
artisanRow(item: TuhuArtisanItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.title)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(titleColor(item.title))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text('技艺 ' + item.skill + ' · ' + item.works + ' 件')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(skillBarW(item.skill))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.robeA)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.age + '岁')
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
artisanRow 是匠人列表的行卡片 @Builder 函数。匠人姓名以 14 号粗体字显示,级别标签使用 titleColor 函数动态返回背景色——大师为 accent 砖红色、名家为 hot 红色、匠人与学徒为 cool 绿色。级别标签的字号为 10(比姿态、部位、坐骑标签的 9 号略大),因为"大师"、"名家"等头衔在匠人信息中是较为重要的信息,略大的字号使其更为醒目。
技艺信息以"技艺 XX · XX 件"的格式合并显示,例如"技艺 99 · 500 件"、"技艺 97 · 420 件"等。这种格式同时呈现了匠人的技艺评分和传世作品数量,使用户能够全面评估匠人的水平与产出。技艺条使用 skillBarW(item.skill) 计算宽度,高度 6 像素(与灵性条一致,比品级条和威仪条的 5 像素略粗),使用 robeA 绿色。技艺条的高度与灵性条一致,暗示了技艺与灵性在概念上的关联——技艺高超的匠人才能制作出灵性高的兔爷。
匠人年龄以 10 号字、text3 浅棕色显示在卡片最右侧。年龄使用 text3 而非 cool 或 accent,表明年龄在匠人信息中属于辅助信息——虽然年龄反映了匠人的资历,但技艺评分和作品数量才是衡量匠人水平的核心指标。这种色彩层次的设计——核心信息用强调色,辅助信息用弱化色——使得用户在扫描列表时能够自动过滤信息的重要性层级,快速定位到关键数据。
7.12 stepRow 工序列表项
@Builder
stepRow(item: TuhuStepItem) {
Row() {
Text(item.seq + '')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.backgroundColor(stepColor(item.seq))
.borderRadius(12)
Column() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.tool + ' · ' + item.note)
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(item.days + '天')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
stepRow 是工序列表的行卡片 @Builder 函数,其设计与前几种列表项有显著差异。最左侧是一个序号徽章——Text 显示 item.seq 的数字(通过 + '' 转为字符串),以 13 号粗体字、text1 深褐色字色显示,背景色由 stepColor 函数根据序号返回——前期工序(1-3)为 faceB 肤肤色、中期工序(4-6)为 gold 金色、后期工序(7-8)为 accent 砖红色。序号徽章宽高各 24 像素,textAlign 居中使数字在徽章中央,borderRadius 12 使其成为圆形——形成了一个色彩鲜明的圆形序号标签。
序号徽章是 stepRow 区别于其他列表项的核心特征。其他列表项(兔爷、盔甲、坐骑、匠人)的左侧第一元素是名称 Text,而工序列表项的第一元素是序号徽章。这种差异源于工序数据的天然有序性——八道工序有严格的先后顺序,序号徽章以视觉化的方式强调了这一顺序。三段式色彩分段(肤色、金色、砖红)使用户能够直观感知工序的进展阶段:前期是泥土处理的质朴阶段(肤色),中期是窑烧打底的精炼阶段(金色),后期是彩绘开脸的艺术阶段(砖红)。
序号徽章之后是一个 Column,包含工序名称和工具特征信息。工序名称以 14 号粗体字显示,工具与特征以"工具 · 特征"格式合并显示(10 号字,text3 浅棕色,margin top 3 与名称保持间距)。Column 设置 layoutWeight(1) 占据剩余宽度,margin left 10 与序号徽章保持间距。最右侧是工期 Text,以 11 号粗体字、hot 红色显示,"天"作为单位后缀。工期使用 hot 红色而非 cool 绿色或 accent 砖红色,传达了时间成本的"警示"意味——工期越长的工序越需要耐心等待,红色提醒用户关注时间成本。
stepRow 的设计巧妙地将"有序性"(序号徽章)、“内容信息”(名称与工具特征)和"时间成本"(工期天数)三者组织在一个紧凑的卡片中,既有视觉化的序号引导,又有文字化的详细信息,还有色彩化的时间提示,是数据卡片设计的优秀范例。
7.13 orderChart 订单柱状图
@Builder
orderChart() {
Row() {
ForEach(this.orders, (item: TuhuOrderItem) => {
Column() {
Column()
.width(12)
.height(orderBarH(item.amount))
.borderRadius(3)
.backgroundColor(this.breath ? COLORS.gold : COLORS.robeA)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Text(item.amount / 1000 + 'k')
.fontSize(8)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (item: TuhuOrderItem) => item.name)
}
.width('100%')
.height(92)
.alignItems(VerticalAlign.Bottom)
.padding({ left: 6, right: 6, top: 6, bottom: 6 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
orderChart 是订单数据的可视化柱状图 @Builder 函数,它将每笔订单的金额以柱形高度的方式直观呈现。整个图表以 Row 为容器,高度 92 像素,alignItems 设为 VerticalAlign.Bottom 使所有柱形从底部对齐生长——这是柱状图的标准布局模式。Row 内部通过 ForEach 遍历 this.orders 数组,为每笔订单渲染一个 Column。
每个订单的 Column 包含两部分:上方的柱形和下方的金额标签。柱形是一个 Column,宽度固定 12 像素,高度由 orderBarH(item.amount) 计算——金额越大柱形越高,但通过 Math.min 截断在 124 像素上限。柱形使用 borderRadius 3 形成圆角矩形,其 backgroundColor 绑定到 breath 变量——this.breath ? COLORS.gold : COLORS.robeA,当 breath 为 true 时显示金色,为 false 时显示绿色。配合 600 毫秒时长、无限循环、交替播放的 animation,柱形在金色与绿色之间交替变换,形成了一种"呼吸"般的色彩律动效果,使原本静态的柱状图变得生动活泼。
柱形下方的金额标签以 8 号字、text3 浅棕色显示 item.amount / 1000 + 'k'——将金额除以 1000 并附加’k’后缀,例如 26640 显示为"26.64k"、9900 显示为"9.9k"。这种千位简写方式使金额数字更紧凑,适合在柱形下方有限的空间内显示。每个 Column 设置 layoutWeight(1) 和 alignItems(HorizontalAlign.Center),使柱形与标签在每个列内居中对齐,且各列等宽分布在整个 Row 宽度内。
orderChart 是应用中数据可视化最突出的组件。它通过 ForEach + 布局属性的组合,在纯 ArkUI 声明式语法中实现了一个动态柱状图——无需任何第三方图表库,无需 Canvas 绘制,仅凭 Column 的高度差和 Row 的底部对齐就构建出了直观的数据可视化效果。这种"原生声明式图表"的实现方式虽然功能相对简单(不支持复杂交互),但对于柱状图这一基础图表类型来说已经足够,且具有零依赖、高性能、易于维护的优势。
ForEach 的键值生成函数是 (item: TuhuOrderItem) => item.name,使用订单名称作为键值。需要注意的是,如果存在两个同名订单,它们的键值将冲突,可能导致 ForEach 的差异更新出现异常。在当前数据中,每个订单的名称都是唯一的,所以不存在此问题。但在 orderRow 的 ForEach 中,键值生成函数使用了 item.name + item.month(名称+月份的复合键),这表明开发者意识到了名称可能重复的风险,并采取了更安全的键值策略。
7.14 orderRow 订单列表项
@Builder
orderRow(item: TuhuOrderItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.layoutWeight(1)
Column() {
Text('¥' + item.amount)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.count + ' 尊 · ' + item.month)
.fontSize(9)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
Text('删除')
.fontSize(11)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FDE8E8')
.borderRadius(8)
.margin({ left: 10 })
.onClick(() => {
this.selOrder = item;
this.showDel = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
orderRow 是订单列表的行卡片 @Builder 函数,其布局与其他列表项有较大差异。订单名称 Text 设置了 layoutWeight(1),使其占据 Row 的主要宽度,将右侧的金额信息和删除按钮推至右端。这种设计使订单名称作为主要标识占据最宽的空间,而金额和操作按钮作为辅助信息紧凑排列在右侧。
金额信息 Column 包含两行:上方是’¥’前缀的金额数字(12 号粗体字,accent 砖红色),下方是"数量尊 · 月份"格式的辅助信息(9 号字,text3 浅棕色)。金额使用 accent 品牌色而非 hot 红色,与兔爷价格的色彩处理一致,传达了正面的商业信息。Column 的 alignItems 设为 HorizontalAlign.End(右对齐),使两行文字在右侧对齐,形成整齐的信息簇。
最右侧是"删除"按钮——一个 Text 以 11 号字、danger 珊瑚红色显示,背景色为 #FDE8E8 浅粉红(danger 色系的极浅变体),borderRadius 8 形成圆角药丸形,padding 10 像素水平、4 像素垂直使其具有舒适的点击区域。onClick 事件将 item 赋值给 selOrder,同时 showDel 设为 true 触发删除弹窗。这种"先弹窗确认、后执行删除"的两步交互模式是一种安全设计——删除操作不可逆,通过弹窗确认可以防止用户误触删除按钮导致数据丢失。danger 红色与浅粉红背景的组合在视觉上形成了一个"危险操作"的明确信号,提醒用户此操作需要谨慎。
7.15 tabContent 标签内容调度器
@Builder
tabContent() {
if (this.curTab === 0) {
Column() {
Text('兔爷名品 · 灵性越高越难得')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.rabbits, (item: ToyRabbitItem) => {
this.rabbitRow(item)
}, (item: ToyRabbitItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 1) {
Column() {
Text('武将盔甲 · 点击可精工锻造')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.armors, (item: ArmorItem) => {
this.armorRow(item)
}, (item: ArmorItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 2) {
Column() {
Text('瑞兽坐骑 · 各司祥瑞')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.mounts, (item: MountItem) => {
this.mountRow(item)
}, (item: MountItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
tabContent 是标签内容的核心调度 @Builder 函数,它通过六个 if 条件分支根据 curTab 的值决定渲染哪个标签页的内容。每个分支的结构高度一致——一个 Column 包含一个标题 Text 和一个 ForEach 列表渲染。当 curTab 为 0 时渲染兔爷列表,为 1 时渲染盔甲列表,依此类推。这种基于 if 条件的内容切换是 ArkUI 中实现标签页切换的经典模式——当 curTab 变化时,@State 的响应式机制触发 tabContent 的重新执行,新的条件分支被激活,对应的列表内容被渲染,旧的内容被移除。
每个标签页的标题 Text 不仅起到了说明内容的作用,还通过文案传达了该标签页的特色与交互提示。"兔爷名品 · 灵性越高越难得"点明了灵性值的意义;"武将盔甲 · 点击可精工锻造"提示了盔甲可交互锻造的功能;"瑞兽坐骑 · 各司祥瑞"概括了坐骑的文化寓意。这些标题文案既 informative 又 evocative,在功能说明之外增添了文化韵味。
if (this.curTab === 5) {
Column() {
Text('订单金额 · 月度走势')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
this.orderChart()
Text('全部订单 · 点击可删除')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
.margin({ top: 12 })
ForEach(this.orders, (item: TuhuOrderItem) => {
this.orderRow(item)
}, (item: TuhuOrderItem) => item.name + item.month)
}
.width('100%')
.margin({ top: 10 })
}
}
订单标签页(curTab === 5)的内容最为丰富,它包含三个部分:标题"订单金额 · 月度走势"、orderChart 柱状图、子标题"全部订单 · 点击可删除"和 orderRow 列表。这种"图表+列表"的组合呈现方式使用户既能从柱状图中宏观把握订单金额的分布走势,又能在列表中查看每笔订单的详细信息并进行删除操作。orderRow 的 ForEach 键值使用了 item.name + item.month 的复合键,确保即使存在同名订单也能生成唯一键值,这是比 orderChart 中仅用 item.name 更安全的设计。
tabContent 中的六个 if 分支不是 if-else 链,而是六个独立的 if 语句。这意味着理论上多个分支可以同时为 true 时渲染多个内容。但在实际使用中,curTab 是一个单一数值,不可能同时等于多个不同的值,因此只有一个分支会被激活。使用独立 if 而非 if-else 链可能是出于代码简洁性的考虑——每个分支的结构完全一致,使用 if-else 链会增加缩进层级,而独立 if 使每个分支的代码结构保持扁平化。
7.16 bottomItem 底部标签项
@Builder
bottomItem(i: number) {
Row() {
Text(TAB_LIST[i].icon)
.fontSize(16)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
.margin({ left: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === i ? '#A84938' : COLORS.tabBg)
.borderRadius(10)
.onClick(() => {
this.curTab = i;
})
}
bottomItem 是底部标签栏中单个标签的 @Builder 函数,它接收一个索引参数 i,从 TAB_LIST 中取出对应的图标和标签进行渲染。每个标签是一个 Row,包含图标 Text(16 号字,Emoji 字符)和标签 Text(11 号字,中文标签),标签的 fontWeight 和 fontColor 根据 this.curTab === i 的判断结果动态切换——选中时为粗体、tabOn 浅棕色 #F2C9A0,未选中时为正常字重、sub 深棕色 #E8C49A。这种选中/未选中的视觉差异使用户能够清晰地辨认当前所在的标签页。
标签的背景色也根据选中状态切换——选中时为 #A84938(比 tabBg #8C3B2E 略亮的红色变体),未选中时为 COLORS.tabBg。#A84938 是一种在 tabBg 基础上提高亮度的色彩,使得选中标签在视觉上"浮起"于未选中标签,形成了一种凸起的激活效果。borderRadius 10 使标签形成圆角矩形,padding 垂直 8 像素使其具有舒适的点击区域。layoutWeight(1) 使六个标签在两行三列的布局中均分宽度,justifyContent(FlexAlign.Center) 使图标与标签在 Row 中水平居中排列。
onClick 事件将 curTab 设为 i,触发 tabContent 的条件分支切换。这是一个极简但核心的交互——用户点击标签,curTab 变化,tabContent 重新渲染,对应的内容显示在页面上。整个标签切换的响应式链路从 onClick 赋值开始,经 @State 的变化通知,到 tabContent 的重新执行,最终到 ForEach 列表的渲染更新,全部由框架自动驱动,无需任何手动 DOM 操作或 invalidate 调用。
7.17 bottomBar 底部标签栏
@Builder
bottomBar() {
Column() {
Row() {
ForEach(ROW1_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r1' + i)
}
.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r2' + i)
}
.width('100%')
.margin({ top: 6 })
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.tabBg)
.borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
}
bottomBar 将六个标签组织为两行三列的布局。外层 Column 包含两个 Row——第一个 Row 通过 ForEach(ROW1_IDX) 渲染索引 0、1、2 的三个标签(兔爷、盔甲、坐骑),第二个 Row 通过 ForEach(ROW2_IDX) 渲染索引 3、4、5 的三个标签(匠人、工序、订单)。两个 Row 之间通过 margin top 6 保持间距。整个 Column 以 tabBg 砖红色为背景,borderRadius 仅设置 topLeft 和 topRight 为 18,bottomLeft 和 bottomRight 为 0,形成了顶部圆角、底部直角的形状——这是因为底部标签栏位于页面最底部,其底边无需圆角,而顶部圆角使其与上方内容区域之间形成柔和的过渡。
两行三列的标签布局相比于单行六列的传统底部标签栏有若干优势。首先是触控体验——六列单行布局在窄屏设备上每个标签的宽度不足,点击目标过小影响触控精度;两行三列使每个标签的宽度增加了近一倍,提升了触控舒适度。其次是信息密度——两行布局为标签文本留出了更多水平空间,使"兔爷"、"盔甲"等双字标签能够完整显示而不被截断。最后是视觉平衡——两行三列的矩形布局在视觉上比单行六列的扁条形布局更协调,与页头区域的 2x2 统计数据网格形成了上下呼应。
两个 ForEach 的键值分别使用 ‘r1’ + i 和 ‘r2’ + i 前缀,确保两行标签的元素在 ForEach 差异更新中不会冲突。虽然标签栏在应用运行期间不会增减标签数量,但良好的键值习惯有助于未来的功能扩展和性能优化。
7.18 addModal 新增兔爷弹窗
@Builder
addModal() {
if (this.showAdd) {
Stack() {
this.modalOverlay(() => {
this.showAdd = false;
})
Column() {
Text('新增兔爷')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('登记一尊新塑兔爷')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
Column() {
Text('兔爷名')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formName, placeholder: '如:招财兔爷' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formName = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 14 })
addModal 是新增兔爷弹窗的 @Builder 函数,它以 if (this.showAdd) 条件包裹整个弹窗内容——只有当 showAdd 为 true 时,弹窗才会被渲染到组件树中。这种条件渲染模式与 ArkUI 的响应式机制配合,使得弹窗的显隐完全由状态变量驱动,无需手动操作 visibility 属性或调用 show/hide 方法。
弹窗以 Stack 为容器,内含两层:底层是 modalOverlay 遮罩(传入关闭 showAdd 的回调),上层是弹窗内容 Column。弹窗标题"新增兔爷"以 17 号粗体字显示,副标题"登记一尊新塑兔爷"以 11 号字、text3 浅棕色显示。标题与副标题的文案设计传递了弹窗的功能定位——"新增"是操作类型,"登记"暗示了数据录入的正式性,"一尊"是兔爷的量词,"新塑"暗示了新制作的兔爷。
表单包含三个字段:兔爷名、姿态、价格。每个字段由一个 Column 包裹标签 Text 和 TextInput 组成。TextInput 通过 { text: this.formName, placeholder: '如:招财兔爷' } 的参数传入当前值和占位提示。onChange 回调将输入值写回 formName 状态变量,实现了表单数据的双向绑定——用户输入触发状态更新,状态变量保存了最新值供后续使用。placeholder 的文案设计也颇具巧思——“如:招财兔爷”、“如:骑虎”、"如:680"分别为三个字段提供了示例值,这些示例值既说明了输入格式,又暗示了可能的内容范围,降低了用户的认知成本。
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showAdd = false;
})
Text('确认登记')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
this.rabbits.push(new ToyRabbitItem(this.formName, this.formPose, 85, '高 24cm', Number(this.formPrice)));
this.showAdd = false;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
弹窗底部是取消与确认两个按钮,以 Row 横向排列,各占 layoutWeight(1) 即 50% 宽度。取消按钮使用 #F2F2F2 浅灰色背景、text2 棕色字,视觉上弱化为次要操作。确认按钮"确认登记"使用 cool 绿色背景、cardBg 白色粗体字,视觉上强化为主要操作。margin left 10 使两按钮之间保持间距。这种"灰色取消+彩色确认"的按钮设计是现代移动应用中标准的操作模式——通过色彩对比引导用户执行主要操作(确认),同时不阻碍次要操作(取消)。
确认按钮的 onClick 执行了两步操作:首先通过 this.rabbits.push(new ToyRabbitItem(...)) 向 rabbits 数组追加一个新的 ToyRabbitItem 实例。新实例的 name 和 pose 来自表单输入的 formName 和 formPose,灵性值固定为 85,尺寸固定为"高 24cm",价格通过 Number(this.formPrice) 将字符串转为数字。push 操作触发 @State 的数组变化通知,ForEach 自动追加渲染新的兔爷卡片。随后 showAdd 设为 false 关闭弹窗。这两步操作展示了 ArkUI 响应式编程的典型流程——修改状态数据,框架自动更新 UI,无需任何手动刷新。
弹窗内容 Column 设置了 width(‘88%’)(占屏幕宽度的 88%)、padding(18)、cardBg 白色背景、borderRadius(16) 圆角,constraintSize maxHeight ‘80%’ 限制最大高度不超过屏幕的 80%(防止内容过多时溢出屏幕)。position({ x: 0, y: 0 }) 和 zIndex(999) 确保弹窗内容层叠在遮罩之上。这些样式属性共同构建了一个居中显示、白色圆角、层级最高的模态弹窗。
7.19 editModal 锻造盔甲弹窗
@Builder
editModal() {
if (this.showEdit) {
Stack() {
this.modalOverlay(() => {
this.showEdit = false;
})
Column() {
Text('锻造盔甲')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selArmor) {
Text(this.selArmor.name + ' · 品级 ' + this.selArmor.level + ' 分')
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 6 })
}
Text('锤炼甲片,品级精进')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
editModal 是锻造盔甲弹窗的 @Builder 函数,以 if (this.showEdit) 条件控制显隐。弹窗结构与 addModal 类似——Stack 容器、modalOverlay 遮罩、Column 内容。但内容层面有一个关键的差异:弹窗内部使用了 if (this.selArmor) 条件渲染来展示选中盔甲的信息。selArmor 的类型是 ArmorItem | null,当它为 null 时(未选中盔甲),if 条件为假,盔甲信息 Text 不渲染;当它为非 null 时(已选中盔甲),if 条件为真,显示"盔甲名 · 品级 XX 分"的信息。
这种 null 检查的条件渲染是处理联合类型 T | null 的标准模式。在 ArkTS 的类型系统中,ArmorItem | null 类型的变量在被使用前必须经过 null 检查,否则编译器会报错。if (this.selArmor) 不仅是一个运行时的安全检查,也是编译时的类型收窄——在 if 块内,selArmor 的类型被收窄为 ArmorItem(非 null),因此可以安全地访问其 name 和 level 属性。这是 ArkTS(TypeScript)类型系统的一个重要特性,它在编译阶段就防止了空指针访问。
弹窗标题"锻造盔甲"以 17 号粗体字显示,选中盔甲信息以 12 号字、text2 棕色显示,副标题"锤炼甲片,品级精进"以 11 号字、text3 浅棕色显示。三行文字形成了"操作类型 - 操作对象 - 操作说明"的三级信息层次,使用户在操作前对弹窗的功能有清晰的认知。
Row() {
Text('精工 +4分')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.gold)
.borderRadius(10)
.onClick(() => {
if (this.selArmor) {
this.selArmor.level = Math.min(100, this.selArmor.level + 4);
}
this.showEdit = false;
})
Text('旧甲翻新 -2分')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selArmor) {
this.selArmor.level = Math.max(60, this.selArmor.level - 2);
}
this.showEdit = false;
})
}
.width('100%')
.margin({ top: 16 })
弹窗提供了两个操作选项:“精工 +4分"和"旧甲翻新 -2分”。精工按钮使用 gold 金色背景,传达了"提升品质"的正面语义;旧甲翻新按钮使用 cool 绿色背景,虽然也是正面操作(翻新),但语义上不如精工强烈。两个按钮的 onClick 都包含了 if (this.selArmor) 的 null 检查——这是因为 onClick 的回调函数在运行时执行,此时 selArmor 可能在理论上已变为 null,null 检查确保了类型安全。
精工按钮的 onClick 将 selArmor.level 增加 4,但通过 Math.min(100, ...) 截断在 100 的上限——品级满分为 100,超过 100 没有意义。旧甲翻新按钮的 onClick 将 selArmor.level 减少 2,通过 Math.max(60, ...) 截断在 60 的下限——品级最低为 60,低于 60 可能代表盔甲已无法使用。这种上下限截断是数据完整性的保障,防止用户通过反复操作将品级推至不合理的范围。
由于 ArmorItem 是 @Observed 类,当 selArmor.level 被修改时,引用该实例的 armorRow 卡片会自动更新——品级条宽度(由 skillBarW(item.level) 计算)和品级分数(item.level + ‘分’)都会自动刷新。这种"修改一处数据、多处 UI 自动同步"的响应式更新,是 @Observed + @State 组合的核心价值。用户在弹窗中点击"精工 +4分"后,返回盔甲列表就能看到对应卡片的品级条变长、分数增加,无需任何手动刷新。
弹窗还有一个"取消"按钮,以 #F2F2F2 浅灰色背景显示,点击后关闭弹窗但不执行任何操作。三个按钮——精工、翻新、取消——构成了"两种操作+一种退出"的交互结构,使用户有明确的选择路径,同时随时可以无副作用地退出弹窗。
7.20 delModal 删除订单弹窗
@Builder
delModal() {
if (this.showDel) {
Stack() {
this.modalOverlay(() => {
this.showDel = false;
})
Column() {
Text('删除订单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selOrder) {
Text('确认删除「' + this.selOrder.name + '」订单?')
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 8 })
}
Text('删除后不可恢复')
.fontSize(10)
.fontColor(COLORS.danger)
.margin({ top: 4 })
delModal 是删除订单弹窗的 @Builder 函数,以 if (this.showDel) 条件控制显隐。结构与 addModal 和 editModal 一致——Stack + modalOverlay + Column 内容。弹窗标题"删除订单"以 17 号粗体字显示,选中订单信息以 if (this.selOrder) 条件渲染——"确认删除「订单名」订单?"的格式使用了中文书名号「」来包裹订单名,使订单名在句子中清晰可辨。
"删除后不可恢复"以 10 号字、danger 珊瑚红色显示,margin top 4 与上方信息保持间距。这是删除操作的警示提示——使用 danger 红色和"不可恢复"的措辞,明确告知用户此操作的不可逆性。这种警示设计在涉及数据删除的交互中是必要的用户体验保障,防止用户误操作导致数据丢失。
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showDel = false;
})
Text('确认删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selOrder) {
this.orders.splice(this.orders.indexOf(this.selOrder), 1);
}
this.showDel = false;
})
}
.width('100%')
.margin({ top: 16 })
弹窗底部是取消与确认删除两个按钮。取消按钮使用 #F2F2F2 浅灰色背景,与 addModal 的取消按钮一致。确认删除按钮使用 danger 珊瑚红色背景、cardBg 白色粗体字,视觉上与警示提示"删除后不可恢复"形成色彩呼应——两者都使用 danger 色系,强化了"危险操作"的视觉信号。这种"警示文字+警示色按钮"的双重警示设计,使用户在执行删除操作前必须经过明确的视觉确认,极大降低了误删风险。
确认删除按钮的 onClick 执行了 this.orders.splice(this.orders.indexOf(this.selOrder), 1) 操作。这行代码分两步:首先 this.orders.indexOf(this.selOrder) 在 orders 数组中查找 selOrder 的索引位置;然后 splice(index, 1) 从该索引处删除一个元素。splice 操作触发了 @State 数组的变化通知,ForEach 自动移除对应的 orderRow 卡片,同时 orderChart 中的柱状图也会重新渲染以反映删除后的订单集合。这种数组级别的响应式更新是 ArkUI 框架对数组方法的深度拦截——push、splice、pop 等数组变异方法都被框架代理,使得数组操作自动触发 UI 更化。
if (this.selOrder) 的 null 检查同样出现在 onClick 中,确保在 selOrder 为 null 时不执行 splice 操作。虽然在实际流程中,delModal 只在 selOrder 被赋值后才会显示,onClick 触发时 selOrder 必然非 null,但类型系统要求在使用前进行 null 检查,这是类型安全的必要保障。
7.21 build 主入口函数
build() {
Stack() {
Column() {
Scroll() {
Column() {
this.pageHeader()
this.tabContent()
}
.width('100%')
.padding({ left: 14, right: 14, bottom: 12 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.backgroundColor(COLORS.bg)
this.bottomBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
build 函数是 @Component 组件的核心入口,它描述了 TuhuPage 组件的完整 UI 结构。整个 build 以一个 Stack 为根容器,Stack 内部分为两大层:底层是一个 Column 包含 Scroll 内容区和 bottomBar 底部栏,上层是三个条件渲染的模态弹窗。
底层 Column 内部,Scroll 组件包裹了一个 Column,Column 中依次调用了 pageHeader() 和 tabContent() 两个 @Builder。Scroll 设置了 scrollable(ScrollDirection.Vertical) 允许垂直滚动,layoutWeight(1) 使其占据 Column 中除 bottomBar 之外的所有剩余高度。这意味着当 tabContent 的内容超出屏幕可视区域时,用户可以上下滚动浏览,而 bottomBar 始终固定在底部不受滚动影响。这种"可滚动内容区+固定底栏"的布局是移动应用中最常见的页面结构之一。
Column 的 padding 设置了 left: 14, right: 14, bottom: 12,使内容在水平方向保持 14 像素的边距、底部保持 12 像素的间距。水平边距使内容不贴屏幕边缘,在视觉上更为舒适。backgroundColor 设为 COLORS.bg 的暖米色,与整个应用的色彩基调一致。
Stack 的上层是三个条件渲染的模态弹窗——if (this.showAdd) { this.addModal() }、if (this.showEdit) { this.editModal() }、if (this.showDel) { this.delModal() }。当三个 showXxx 状态变量为 false 时,对应的弹窗不渲染;为 true 时,弹窗被添加到 Stack 的上层,覆盖在底层内容之上。由于 Stack 的层叠特性,后添加的元素覆盖在先添加的元素之上,因此弹窗自然地覆盖在主内容之上。三个弹窗使用独立的条件变量控制,理论上可以同时显示多个弹窗(如 showAdd 和 showEdit 同时为 true),但在实际交互流程中,一次只会有一个弹窗显示。
整个 build 函数的结构清晰地体现了应用的页面层次:根 Stack -> 主 Column(Scroll 内容 + 固定底栏)+ 浮层弹窗。这种结构既保证了主内容的可滚动性和底栏的固定性,又通过 Stack 的层叠能力实现了模态弹窗的覆盖显示,是 ArkUI 声明式 UI 在复杂页面布局中的典型应用。
八、Mermaid 流程图
8.1 应用整体架构流程图
这张流程图展示了应用从启动到运行的完整架构链路。应用启动后首先执行 aboutToAppear 生命周期回调,启动 breath 呼吸定时器,随后进入 build 函数渲染整个 UI。build 的根 Stack 分为主内容层和弹窗层两条分支:主内容层包含可滚动的 Scroll(内含页头和标签内容)和固定的底部标签栏;弹窗层通过三个 showXxx 条件变量控制三种模态弹窗的显隐。标签内容通过 curTab 的值在六个标签页之间切换,底部标签栏的点击通过更新 curTab 驱动内容切换。三个弹窗分别对应三种数据操作——新增兔爷、锻造盔甲、删除订单——操作完成后数据变化自动反映回对应的列表。
8.2 响应式数据更新流程图
这张流程图详细展示了应用中四种核心交互的响应式数据更新链路。每条链路都遵循"用户交互 -> 状态变更 -> 弹窗操作 -> 响应式更新 -> 渲染结果"的五步流程。无论是标签切换的 curTab 赋值,还是盔甲锻造的 level 修改,还是兔爷新增的 push 操作,还是订单删除的 splice 操作,都由 @State 和 @Observed 的响应式机制自动驱动 UI 更新,无需任何手动刷新。这种数据驱动 UI 的编程范式是 ArkUI 声明式 UI 的核心精髓。
8.3 兔爷泥塑制作工法流程图
这张流程图展示了应用中工序标签页所呈现的兔爷泥塑八道制作工法。从和泥到点红唇,八道工序按序号顺序串联执行,总工期约 17 天。流程图下方将八道工序按 stepColor 函数的色彩分段分为三组——前期工序(1-3)使用 faceB 肤肤色标注,对应泥土处理的质朴阶段;中期工序(4-6)使用 gold 金色标注,对应窑烧打底的精炼阶段;后期工序(7-8)使用 accent 砖红色标注,对应彩绘开脸的艺术阶段。这种色彩分段与 stepColor 函数的逻辑完全一致,使流程图与代码中的色彩映射形成了图文呼应。
九、技术对比表格
9.1 ArkUI 状态装饰器对比
| 装饰器 | 适用范围 | 数据流向 | 响应式级别 | 典型场景 | 本应用使用情况 |
|---|---|---|---|---|---|
| @State | 当前组件内部 | 组件内单向 | 自身+子组件自动刷新 | 组件私有状态管理 | 16个状态变量全部使用 |
| @Prop | 父到子单向同步 | 父到子(值拷贝) | 子组件自动刷新 | 父组件传递简单数据给子组件 | 未使用(单组件结构无需) |
| @ObjectLink | 父到子双向同步 | 父双向(引用共享) | 对象属性变更自动刷新 | 子组件引用@Observed对象 | 未使用(单组件结构无需) |
| @Observed | 类定义 | 类实例属性可观察 | 属性变更触发依赖刷新 | 可变数据模型的基类 | 6个数据模型类全部使用 |
| @Builder | UI片段复用 | 无数据流(参数传入) | 调用处内联展开 | 提取重复UI结构 | 14个Builder函数 |
| @Entry | 根组件标识 | 无 | 标识应用入口 | 应用根组件 | TuhuPage使用 |
| @Component | 组件声明 | 无 | 声明ArkUI组件 | 所有自定义组件 | TuhuPage使用 |
9.2 数据模型对比
| 模型类 | 字段数 | 可变性 | 交互操作 | 色彩映射函数 | 数据量 | 响应式更新触发点 |
|---|---|---|---|---|---|---|
| ToyRabbitItem | 5 | 可增不可改 | 新增(push) | poseColor | 12条 | rabbits.push触发ForEach追加 |
| ArmorItem | 5 | 可改品级 | 锻造(level加减) | 无(统一accent) | 8条 | level修改触发品级条+分数更新 |
| MountItem | 5 | 不可变 | 无(纯展示) | mountColor | 8条 | 无更新触发 |
| TuhuArtisanItem | 5 | 不可变 | 无(纯展示) | titleColor | 8条 | 无更新触发 |
| TuhuStepItem | 5 | 不可变 | 无(纯展示) | stepColor | 8条 | 无更新触发 |
| TuhuOrderItem | 5 | 可删不可改 | 删除(splice) | 无 | 10条 | splice触发ForEach移除+图表重绘 |
9.3 @Builder 组件功能对比
| Builder名称 | 功能定位 | 复杂度 | 交互性 | 动画 | 参数 | 复用情况 |
|---|---|---|---|---|---|---|
| modalOverlay | 模态遮罩层 | 低 | 点击关闭 | 无 | onClose回调 | 被3个弹窗复用 |
| pageHeader | 页头形象+标题 | 高 | 无 | 星花/旗帜/流苏动画 | 无 | 唯一调用 |
| rabbitRow | 兔爷列表卡片 | 中 | 无 | 无 | ToyRabbitItem | ForEach循环调用 |
| armorRow | 盔甲列表卡片 | 中 | 点击品级打开弹窗 | 无 | ArmorItem | ForEach循环调用 |
| mountRow | 坐骑列表卡片 | 中 | 无 | 无 | MountItem | ForEach循环调用 |
| artisanRow | 匠人列表卡片 | 中 | 无 | 无 | TuhuArtisanItem | ForEach循环调用 |
| stepRow | 工序列表卡片 | 中 | 无 | 无 | TuhuStepItem | ForEach循环调用 |
| orderChart | 订单柱状图 | 中高 | 无 | 柱形色彩呼吸 | 无 | 唯一调用 |
| orderRow | 订单列表卡片 | 中 | 点击删除打开弹窗 | 无 | TuhuOrderItem | ForEach循环调用 |
| tabContent | 标签内容调度 | 中高 | 无 | 无 | 无 | 唯一调用 |
| bottomItem | 底部标签项 | 低 | 点击切换标签 | 无 | 索引i | ForEach循环调用 |
| bottomBar | 底部标签栏 | 中 | 无 | 无 | 无 | 唯一调用 |
| addModal | 新增兔爷弹窗 | 高 | 表单输入+确认/取消 | 无 | 无 | 条件渲染调用 |
| editModal | 锻造盔甲弹窗 | 高 | 精工/翻新/取消 | 无 | 无 | 条件渲染调用 |
| delModal | 删除订单弹窗 | 中 | 确认/取消 | 无 | 无 | 条件渲染调用 |
9.4 布局技术对比
| 布局技术 | 使用场景 | 优势 | 局限 | 本应用典型使用 |
|---|---|---|---|---|
| Stack(层叠) | 元素叠加定位 | 支持绝对定位、层叠覆盖 | 需手动计算坐标 | pageHeader兔爷形象、弹窗Stack |
| Column(纵向) | 垂直排列子元素 | 自动垂直堆叠、支持对齐 | 水平方向需配合Row | 标签页内容、弹窗内容 |
| Row(横向) | 水平排列子元素 | 自动水平排列、支持layoutWeight | 垂直方向需配合Column | 列表卡片、底部标签 |
| ForEach | 列表渲染 | 自动差异更新、键值追踪 | 需提供唯一键值 | 所有列表渲染、装饰元素 |
| layoutWeight | 弹性宽度/高度 | 自动分配剩余空间 | 仅在Flex容器中生效 | 卡片中间区域、标签等宽 |
| position | 绝对定位 | 精确像素级控制 | 不响应容器尺寸变化 | pageHeader所有装饰元素 |
| Scroll | 可滚动区域 | 内容超出屏幕可滚动 | 仅支持单一方向 | 主内容区域 |
| if 条件渲染 | 动态显隐 | 条件为假时不渲染(性能优) | 频繁切换可能有开销 | 标签页切换、弹窗显隐 |
| linearGradient | 渐变背景 | 丰富视觉层次 | 不支持复杂渐变 | pageHeader背景 |
| animation | 属性动画 | 自动过渡、支持循环 | 需配合状态变化 | 星花/旗帜/流苏/柱状图 |
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

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

完整代码:
interface ColorPalette {
bg: string;
cardBg: string;
header1: string;
header2: string;
faceA: string;
faceB: string;
robeA: string;
robeB: string;
gold: 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: '#FDF3EC',
cardBg: '#FFFFFF',
header1: '#8C3B2E',
header2: '#3E1510',
faceA: '#F6D8B8',
faceB: '#F2C9A0',
robeA: '#3E7C54',
robeB: '#2B5C3C',
gold: '#E3B23C',
title: '#FDEEDA',
sub: '#E8C49A',
text1: '#5A2A1E',
text2: '#7A5542',
text3: '#A58E7C',
accent: '#8C3B2E',
hot: '#C0392B',
cool: '#3E7C54',
danger: '#D9534F',
tabBg: '#8C3B2E',
tabOn: '#F2C9A0',
mask: 'rgba(0,0,0,0.45)'
};
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 FLAG_IDX: number[] = [0, 1, 2];
const STAR_IDX: number[] = [0, 1, 2, 3, 4];
const TASSEL_IDX: number[] = [0, 1, 2];
const EAR_IDX: number[] = [0, 1];
function flagX(i: number): number {
return 254 + i * 20;
}
function flagY(i: number): number {
return 26 + i * 12;
}
function flagAngle(i: number): number {
return 8 - i * 8;
}
function starX(i: number): number {
return 40 + i * 24;
}
function starY(i: number): number {
return 18 + (i % 2) * 14;
}
function earX(i: number): number {
return 118 + i * 104;
}
function tasselY(i: number): number {
return 96 + i * 10;
}
function auraBarW(a: number): number {
return 18 + a * 2.2;
}
function skillBarW(s: number): number {
return 16 + s * 1.1;
}
function craftBarW(d: number): number {
return 16 + d * 1.6;
}
function orderBarH(a: number): number {
return 24 + Math.min(a, 50000) / 500;
}
function poseColor(p: string): string {
if (p === '坐像') {
return COLORS.accent;
}
if (p === '骑虎') {
return COLORS.hot;
}
if (p === '骑鹿') {
return COLORS.cool;
}
return COLORS.text3;
}
function mountColor(m: string): string {
if (m === '虎') {
return COLORS.hot;
}
if (m === '鹿') {
return COLORS.cool;
}
if (m === '麒麟') {
return COLORS.gold;
}
return COLORS.accent;
}
function titleColor(t: string): string {
if (t === '大师') {
return COLORS.accent;
}
if (t === '名家') {
return COLORS.hot;
}
return COLORS.cool;
}
function stepColor(s: number): string {
if (s <= 3) {
return COLORS.faceB;
}
if (s <= 6) {
return COLORS.gold;
}
return COLORS.accent;
}
@Observed
export class ToyRabbitItem {
name: string;
pose: string;
aura: number;
size: string;
price: number;
constructor(name: string, pose: string, aura: number, size: string, price: number) {
this.name = name;
this.pose = pose;
this.aura = aura;
this.size = size;
this.price = price;
}
}
@Observed
export class ArmorItem {
name: string;
part: string;
level: number;
material: string;
note: string;
constructor(name: string, part: string, level: number, material: string, note: string) {
this.name = name;
this.part = part;
this.level = level;
this.material = material;
this.note = note;
}
}
@Observed
export class MountItem {
name: string;
mount: string;
speed: number;
meaning: string;
use: string;
constructor(name: string, mount: string, speed: number, meaning: string, use: string) {
this.name = name;
this.mount = mount;
this.speed = speed;
this.meaning = meaning;
this.use = use;
}
}
@Observed
export class TuhuArtisanItem {
name: string;
title: string;
age: number;
works: number;
skill: number;
constructor(name: string, title: string, age: number, works: number, skill: number) {
this.name = name;
this.title = title;
this.age = age;
this.works = works;
this.skill = skill;
}
}
@Observed
export class TuhuStepItem {
name: string;
days: number;
tool: string;
note: string;
seq: number;
constructor(name: string, days: number, tool: string, note: string, seq: number) {
this.name = name;
this.days = days;
this.tool = tool;
this.note = note;
this.seq = seq;
}
}
@Observed
export class TuhuOrderItem {
name: string;
buyer: string;
amount: number;
count: number;
month: string;
constructor(name: string, buyer: string, amount: number, count: number, month: string) {
this.name = name;
this.buyer = buyer;
this.amount = amount;
this.count = count;
this.month = month;
}
}
@Entry
@Component
struct TuhuPage {
@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State selArmor: ArmorItem | null = null;
@State selOrder: TuhuOrderItem | null = null;
@State formName: string = '';
@State formPose: string = '';
@State formPrice: string = '';
@State rabbits: ToyRabbitItem[] = [
new ToyRabbitItem('镇宅大兔爷', '坐像', 95, '高 38cm', 888),
new ToyRabbitItem('骑虎兔爷', '骑虎', 92, '高 30cm', 680),
new ToyRabbitItem('骑鹿兔爷', '骑鹿', 90, '高 30cm', 660),
new ToyRabbitItem('骑麒麟兔爷', '骑麒麟', 94, '高 32cm', 780),
new ToyRabbitItem('荷花兔爷', '坐像', 85, '高 22cm', 328),
new ToyRabbitItem('金盔兔爷', '坐像', 96, '高 35cm', 980),
new ToyRabbitItem('京剧兔爷', '扮相', 88, '高 26cm', 480),
new ToyRabbitItem('送子兔爷', '坐像', 82, '高 20cm', 268),
new ToyRabbitItem('麒麟送子兔爷', '骑麒麟', 93, '高 28cm', 720),
new ToyRabbitItem('关公兔爷', '扮相', 89, '高 28cm', 560),
new ToyRabbitItem('对兔爷', '坐像', 80, '高 16cm', 198),
new ToyRabbitItem('月宫兔爷', '坐像', 86, '高 24cm', 380)
];
@State armors: ArmorItem[] = [
new ArmorItem('黄金锁子甲', '胸甲', 96, '金箔', '护心镜'),
new ArmorItem('红缨帅盔', '头盔', 92, '绒球', '顶缨'),
new ArmorItem('护背旗', '背旗', 90, '绸缎', '四杆旗'),
new ArmorItem('绣金披风', '披风', 94, '锦缎', '金线滚边'),
new ArmorItem('鱼鳞护腕', '护腕', 85, '银箔', '双层鳞甲'),
new ArmorItem('虎头战靴', '靴', 84, '彩绘', '虎首纹'),
new ArmorItem('玉带', '腰带', 88, '白蜡', '镂空如意'),
new ArmorItem('七星旗座', '旗座', 80, '木胎', '七星纹')
];
@State mounts: MountItem[] = [
new MountItem('黑虎', '虎', 90, '驱邪避灾', '镇宅'),
new MountItem('白象', '象', 86, '吉祥平安', '摆设'),
new MountItem('梅花鹿', '鹿', 82, '禄寿双全', '祝寿'),
new MountItem('麒麟', '麒麟', 94, '祥瑞送子', '求嗣'),
new MountItem('狮子', '狮', 88, '威镇四方', '镇宅'),
new MountItem('仙鹤', '鹤', 78, '延年益寿', '祝寿'),
new MountItem('天马', '马', 84, '前程万里', '学业'),
new MountItem('凤凰', '凤', 92, '百鸟朝凤', '婚庆')
];
@State artisans: TuhuArtisanItem[] = [
new TuhuArtisanItem('双起翔', '大师', 88, 500, 99),
new TuhuArtisanItem('张忠强', '大师', 74, 420, 97),
new TuhuArtisanItem('刘月兔', '名家', 56, 300, 94),
new TuhuArtisanItem('王彩绘', '名家', 48, 240, 92),
new TuhuArtisanItem('李泥人', '名家', 42, 200, 89),
new TuhuArtisanItem('赵金盔', '匠人', 34, 140, 85),
new TuhuArtisanItem('孙描金', '匠人', 27, 90, 80),
new TuhuArtisanItem('周点彩', '学徒', 20, 50, 73)
];
@State steps: TuhuStepItem[] = [
new TuhuStepItem('和泥', 2, '粘土 · 水', '揉匀排泡', 1),
new TuhuStepItem('塑胎', 3, '模具 · 刮刀', '兔爷身胚', 2),
new TuhuStepItem('阴干', 3, '阴凉架', '缓慢脱水', 3),
new TuhuStepItem('素烧', 2, '窑炉', '800度定型', 4),
new TuhuStepItem('打底', 1, '白粉 · 刷', '铺白底色', 5),
new TuhuStepItem('勾线', 2, '细笔 · 墨', '眉眼轮廓', 6),
new TuhuStepItem('彩绘', 3, '彩笔 · 金粉', '涂甲开脸', 7),
new TuhuStepItem('点红唇', 1, '朱砂笔', '画龙点睛', 8)
];
@State orders: TuhuOrderItem[] = [
new TuhuOrderItem('镇宅大兔爷', '庙会', 26640, 30, '2026-08'),
new TuhuOrderItem('骑虎兔爷', '民俗馆', 20400, 30, '2026-07'),
new TuhuOrderItem('金盔兔爷', '收藏家', 19600, 20, '2026-08'),
new TuhuOrderItem('麒麟送子兔爷', '婚庆公司', 14400, 20, '2026-06'),
new TuhuOrderItem('京剧兔爷', '剧场', 14400, 30, '2026-07'),
new TuhuOrderItem('荷花兔爷', '文创店', 9840, 30, '2026-05'),
new TuhuOrderItem('骑鹿兔爷', '祝寿定制', 13200, 20, '2026-08'),
new TuhuOrderItem('月宫兔爷', '中秋礼盒', 15200, 40, '2026-06'),
new TuhuOrderItem('对兔爷', '旅游商店', 9900, 50, '2026-04'),
new TuhuOrderItem('关公兔爷', '戏曲团', 11200, 20, '2026-05')
];
aboutToAppear(): void {
setInterval(() => {
this.breath = !this.breath;
}, 490);
}
@Builder
modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.mask)
.onClick(() => {
onClose();
})
}
@Builder
pageHeader() {
Column() {
Stack() {
Column()
.width('100%')
.height('100%')
.borderRadius(22)
.linearGradient({
angle: 135,
colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
})
ForEach(STAR_IDX, (i: number) => {
Text('✦')
.fontSize(9 + (i % 2) * 3)
.fontColor(COLORS.gold)
.opacity(this.breath ? 0.3 : 0.9)
.position({ x: starX(i), y: starY(i) })
.animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => 'z' + i)
ForEach(FLAG_IDX, (i: number) => {
Column() {
Column()
.width(3)
.height(26)
.backgroundColor(COLORS.robeB)
Column()
.width(14)
.height(10)
.backgroundColor(COLORS.gold)
.borderRadius(2)
.rotate({ angle: flagAngle(i) })
.animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })
}
.position({ x: flagX(i), y: flagY(i) })
}, (i: number) => 'f' + i)
Column()
.width(96)
.height(60)
.borderRadius(48)
.backgroundColor(COLORS.robeA)
.position({ x: 122, y: 52 })
Column()
.width(72)
.height(46)
.borderRadius(36)
.backgroundColor(COLORS.faceA)
.position({ x: 134, y: 42 })
ForEach(EAR_IDX, (i: number) => {
Column()
.width(18)
.height(30)
.borderRadius(9)
.backgroundColor(COLORS.faceB)
.border({ width: 2, color: COLORS.robeB })
.position({ x: earX(i), y: 14 })
}, (i: number) => 'e' + i)
Text('王')
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
.position({ x: 163, y: 50 })
Column()
.width(5)
.height(5)
.borderRadius(2.5)
.backgroundColor(COLORS.text1)
.position({ x: 148, y: 58 })
Column()
.width(5)
.height(5)
.borderRadius(2.5)
.backgroundColor(COLORS.text1)
.position({ x: 178, y: 58 })
Column()
.width(3)
.height(3)
.borderRadius(1.5)
.backgroundColor(COLORS.hot)
.position({ x: 165, y: 66 })
ForEach(TASSEL_IDX, (i: number) => {
Column()
.width(2)
.height(14)
.backgroundColor(COLORS.gold)
.borderRadius(1)
.position({ x: 160 + i * 6, y: tasselY(i) })
.rotate({ angle: this.breath ? 8 : -8 })
.animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })
}, (i: number) => 'a' + i)
Column() {
Text('兔儿爷坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('中秋瑞兽 · 一尊彩塑佑平安')
.fontSize(11)
.fontColor(COLORS.sub)
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.position({ x: 18, y: 12 })
Row() {
Text('12')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('兔爷')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 58 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('盔甲')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 18, y: 80 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('坐骑')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 58 })
Row() {
Text('8')
.fontSize(15)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.title)
Text('工序')
.fontSize(9)
.fontColor(COLORS.sub)
.margin({ left: 2 })
}
.position({ x: 118, y: 80 })
}
.width('100%')
.height(128)
}
.width('100%')
}
@Builder
rabbitRow(item: ToyRabbitItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.pose)
.fontSize(9)
.fontColor(COLORS.cardBg)
.backgroundColor(poseColor(item.pose))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text(item.size)
.fontSize(10)
.fontColor(COLORS.text3)
Row() {
Text('灵性')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(auraBarW(item.aura))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.robeA)
.margin({ left: 6 })
Text(item.aura + '%')
.fontSize(9)
.fontColor(COLORS.cool)
.margin({ left: 6 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 12 })
Text('¥' + item.price)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
armorRow(item: ArmorItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.part)
.fontSize(9)
.fontColor(COLORS.cardBg)
.backgroundColor(COLORS.accent)
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text(item.material + ' · ' + item.note)
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(skillBarW(item.level))
.height(5)
.borderRadius(2)
.backgroundColor(COLORS.gold)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.level + '分')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
.onClick(() => {
this.selArmor = item;
this.showEdit = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
mountRow(item: MountItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.mount)
.fontSize(9)
.fontColor(COLORS.cardBg)
.backgroundColor(mountColor(item.mount))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text(item.meaning + ' · ' + item.use)
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(skillBarW(item.speed))
.height(5)
.borderRadius(2)
.backgroundColor(COLORS.faceB)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text('威仪 ' + item.speed)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cool)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
artisanRow(item: TuhuArtisanItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.title)
.fontSize(10)
.fontColor(COLORS.cardBg)
.backgroundColor(titleColor(item.title))
.borderRadius(8)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ left: 8 })
Column() {
Text('技艺 ' + item.skill + ' · ' + item.works + ' 件')
.fontSize(9)
.fontColor(COLORS.text3)
Column()
.width(skillBarW(item.skill))
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.robeA)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.End)
.layoutWeight(1)
Text(item.age + '岁')
.fontSize(10)
.fontColor(COLORS.text3)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
stepRow(item: TuhuStepItem) {
Row() {
Text(item.seq + '')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.width(24)
.height(24)
.textAlign(TextAlign.Center)
.backgroundColor(stepColor(item.seq))
.borderRadius(12)
Column() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text(item.tool + ' · ' + item.note)
.fontSize(10)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 10 })
Text(item.days + '天')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.hot)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
orderChart() {
Row() {
ForEach(this.orders, (item: TuhuOrderItem) => {
Column() {
Column()
.width(12)
.height(orderBarH(item.amount))
.borderRadius(3)
.backgroundColor(this.breath ? COLORS.gold : COLORS.robeA)
.animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
Text(item.amount / 1000 + 'k')
.fontSize(8)
.fontColor(COLORS.text3)
.margin({ top: 3 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (item: TuhuOrderItem) => item.name)
}
.width('100%')
.height(92)
.alignItems(VerticalAlign.Bottom)
.padding({ left: 6, right: 6, top: 6, bottom: 6 })
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}
@Builder
orderRow(item: TuhuOrderItem) {
Row() {
Text(item.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
.layoutWeight(1)
Column() {
Text('¥' + item.amount)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.accent)
Text(item.count + ' 尊 · ' + item.month)
.fontSize(9)
.fontColor(COLORS.text3)
.margin({ top: 2 })
}
.alignItems(HorizontalAlign.End)
Text('删除')
.fontSize(11)
.fontColor(COLORS.danger)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor('#FDE8E8')
.borderRadius(8)
.margin({ left: 10 })
.onClick(() => {
this.selOrder = item;
this.showDel = true;
})
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.margin({ top: 8 })
}
@Builder
tabContent() {
if (this.curTab === 0) {
Column() {
Text('兔爷名品 · 灵性越高越难得')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.rabbits, (item: ToyRabbitItem) => {
this.rabbitRow(item)
}, (item: ToyRabbitItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 1) {
Column() {
Text('武将盔甲 · 点击可精工锻造')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.armors, (item: ArmorItem) => {
this.armorRow(item)
}, (item: ArmorItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 2) {
Column() {
Text('瑞兽坐骑 · 各司祥瑞')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.mounts, (item: MountItem) => {
this.mountRow(item)
}, (item: MountItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 3) {
Column() {
Text('彩塑匠人 · 一抔泥土见匠心')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.artisans, (item: TuhuArtisanItem) => {
this.artisanRow(item)
}, (item: TuhuArtisanItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 4) {
Column() {
Text('制兔八序 · 和泥彩绘点红唇')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
ForEach(this.steps, (item: TuhuStepItem) => {
this.stepRow(item)
}, (item: TuhuStepItem) => item.name)
}
.width('100%')
.margin({ top: 10 })
}
if (this.curTab === 5) {
Column() {
Text('订单金额 · 月度走势')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
this.orderChart()
Text('全部订单 · 点击可删除')
.fontSize(12)
.fontColor(COLORS.text2)
.width('100%')
.margin({ top: 12 })
ForEach(this.orders, (item: TuhuOrderItem) => {
this.orderRow(item)
}, (item: TuhuOrderItem) => item.name + item.month)
}
.width('100%')
.margin({ top: 10 })
}
}
@Builder
bottomItem(i: number) {
Row() {
Text(TAB_LIST[i].icon)
.fontSize(16)
Text(TAB_LIST[i].label)
.fontSize(11)
.fontWeight(this.curTab === i ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
.margin({ left: 4 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 8, bottom: 8 })
.backgroundColor(this.curTab === i ? '#A84938' : COLORS.tabBg)
.borderRadius(10)
.onClick(() => {
this.curTab = i;
})
}
@Builder
bottomBar() {
Column() {
Row() {
ForEach(ROW1_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r1' + i)
}
.width('100%')
Row() {
ForEach(ROW2_IDX, (i: number) => {
this.bottomItem(i)
}, (i: number) => 'r2' + i)
}
.width('100%')
.margin({ top: 6 })
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.tabBg)
.borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
}
@Builder
addModal() {
if (this.showAdd) {
Stack() {
this.modalOverlay(() => {
this.showAdd = false;
})
Column() {
Text('新增兔爷')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
Text('登记一尊新塑兔爷')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
Column() {
Text('兔爷名')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formName, placeholder: '如:招财兔爷' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formName = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 14 })
Column() {
Text('姿态')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formPose, placeholder: '如:骑虎' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formPose = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Column() {
Text('价格(元)')
.fontSize(12)
.fontColor(COLORS.text2)
TextInput({ text: this.formPrice, placeholder: '如:680' })
.height(38)
.fontSize(13)
.margin({ top: 5 })
.onChange((v: string) => {
this.formPrice = v;
})
}
.alignItems(HorizontalAlign.Start)
.width('100%')
.margin({ top: 12 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showAdd = false;
})
Text('确认登记')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
this.rabbits.push(new ToyRabbitItem(this.formName, this.formPose, 85, '高 24cm', Number(this.formPrice)));
this.showAdd = false;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
@Builder
editModal() {
if (this.showEdit) {
Stack() {
this.modalOverlay(() => {
this.showEdit = false;
})
Column() {
Text('锻造盔甲')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selArmor) {
Text(this.selArmor.name + ' · 品级 ' + this.selArmor.level + ' 分')
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 6 })
}
Text('锤炼甲片,品级精进')
.fontSize(11)
.fontColor(COLORS.text3)
.margin({ top: 3 })
Row() {
Text('精工 +4分')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.gold)
.borderRadius(10)
.onClick(() => {
if (this.selArmor) {
this.selArmor.level = Math.min(100, this.selArmor.level + 4);
}
this.showEdit = false;
})
Text('旧甲翻新 -2分')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.cool)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selArmor) {
this.selArmor.level = Math.max(60, this.selArmor.level - 2);
}
this.showEdit = false;
})
}
.width('100%')
.margin({ top: 16 })
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.width('100%')
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.margin({ top: 10 })
.onClick(() => {
this.showEdit = false;
})
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
@Builder
delModal() {
if (this.showDel) {
Stack() {
this.modalOverlay(() => {
this.showDel = false;
})
Column() {
Text('删除订单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.text1)
if (this.selOrder) {
Text('确认删除「' + this.selOrder.name + '」订单?')
.fontSize(12)
.fontColor(COLORS.text2)
.margin({ top: 8 })
}
Text('删除后不可恢复')
.fontSize(10)
.fontColor(COLORS.danger)
.margin({ top: 4 })
Row() {
Text('取消')
.fontSize(13)
.fontColor(COLORS.text2)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor('#F2F2F2')
.borderRadius(10)
.onClick(() => {
this.showDel = false;
})
Text('确认删除')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.cardBg)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 })
.backgroundColor(COLORS.danger)
.borderRadius(10)
.margin({ left: 10 })
.onClick(() => {
if (this.selOrder) {
this.orders.splice(this.orders.indexOf(this.selOrder), 1);
}
this.showDel = false;
})
}
.width('100%')
.margin({ top: 16 })
}
.width('88%')
.padding(18)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.constraintSize({ maxHeight: '80%' })
.position({ x: 0, y: 0 })
.zIndex(999)
}
.width('100%')
.height('100%')
}
}
build() {
Stack() {
Column() {
Scroll() {
Column() {
this.pageHeader()
this.tabContent()
}
.width('100%')
.padding({ left: 14, right: 14, bottom: 12 })
}
.scrollable(ScrollDirection.Vertical)
.layoutWeight(1)
.backgroundColor(COLORS.bg)
this.bottomBar()
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
if (this.showAdd) {
this.addModal()
}
if (this.showEdit) {
this.editModal()
}
if (this.showDel) {
this.delModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
十、技术总结

本文对一款以兔爷泥塑为主题的 HarmonyOS ArkTS 应用进行了从历史文化到代码实现的全面深度解析。作为一款融合非遗文化与现代移动端技术的应用,其在技术架构与工程设计上展现出了诸多值得借鉴的优秀实践,同时也暴露了一些可以进一步优化的空间。以下从八个维度对应用的技术要点进行系统性总结。
第一,响应式状态管理体系的精炼设计。 应用采用了 @Entry + @Component + @State + @Observed 的组合体系,构建了一套完整的响应式数据流。十六个 @State 状态变量覆盖了交互控制(curTab、breath、showXxx)、表单数据(formName、formPose、formPrice)、选中对象(selArmor、selOrder)和数据列表(rabbits、armors、mounts、artisans、steps、orders)四大类别,构成了应用全部的状态空间。六个 @Observed 数据模型类使得对象属性的修改能够自动触发 UI 更新,而数组的 push 和 splice 操作也通过框架的代理机制自动驱动 ForEach 的差异更新。整个应用没有任何手动 DOM 操作或 invalidate 调用,全部依赖响应式系统自动同步数据与视图,这正是声明式 UI 范式的核心优势。对于初学者而言,理解 @State 与 @Observed 的协作机制是掌握 ArkUI 的关键——@State 管理组件级的状态变量,@Observed 管理类实例的属性可观察性,两者结合实现了从基础类型到引用类型再到集合类型的全维度响应式覆盖。
第二,色彩体系的集中化管理与语义化映射。 ColorPalette 接口与 COLORS 常量构成的色彩管理体系,实现了颜色定义与颜色使用的物理分离。二十一个色彩字段覆盖了背景、卡片、渐变、面部、服饰、金色、文字三级层次、四个功能色(accent/hot/cool/danger)、标签栏和遮罩等所有视觉元素。所有 UI 组件都不直接书写颜色字面量,而是通过 COLORS 对象引用取色,这使得全局换肤只需替换一个对象即可实现。在此基础上,poseColor、mountColor、titleColor、stepColor 四个语义化色彩映射函数将业务含义(姿态、坐骑类型、匠人级别、工序阶段)映射为视觉色彩,形成了一套"业务语义-视觉色彩"的关联体系。这种设计使得用户在浏览列表时能够通过标签颜色快速辨识数据类型,形成了一种隐式的视觉分类索引,是数据可视化在列表层面的巧妙应用。
第三,无图绘图的声明式 UI 表现力。 pageHeader 组件通过纯几何形状——矩形、圆角、定位和色彩的组合——构建了一个辨识度极高的兔爷头像形象,包括绿色头饰底座、肤色面部、两只长耳、额头"王"字、双眼、红嘴和金色流苏。这种不依赖任何图片资源、仅凭 ArkUI 声明式属性就构建复杂图像的技术,展现了框架的强大表现力。其优势在于零资源依赖(减小应用体积)、矢量精度(任意分辨率清晰)和颜色可变性(便于主题适配)。配合 breath 呼吸定时器驱动的星花闪烁、旗帜飘扬和流苏摇摆动画,整个页头区域形成了一个生动活泼的视觉焦点,为应用奠定了文化氛围与视觉基调。
第四,数据可视化在纯声明式语法中的实现。 orderChart 组件通过 ForEach + Column 高度差 + Row 底部对齐的组合,在纯 ArkUI 语法中实现了一个动态柱状图。柱形高度由 orderBarH 函数将订单金额映射为像素高度,并通过 Math.min 截断防止异常值破坏布局。柱形色彩绑定到 breath 变量,在金色与绿色之间交替变化,使静态图表获得了动态律动。每个柱形下方的金额标签以千位简写方式显示数据值。这种"原生声明式图表"虽然功能不及专业图表库丰富,但对于柱状图这一基础类型已经足够,且具有零依赖、高性能、易维护的优势,是小型应用中数据可视化的理想选择。
第五,模态弹窗的统一架构与安全交互设计。 三个模态弹窗(addModal、editModal、delModal)共享统一的架构模式——Stack 容器 + modalOverlay 遮罩 + Column 内容——通过复用 modalOverlay @Builder 实现了遮罩逻辑的统一管理。三个弹窗分别通过 showAdd、showEdit、showDel 三个布尔状态变量控制显隐,使用 if 条件渲染决定是否将弹窗加入组件树。在交互安全层面,删除操作采用了"先选中、后弹窗确认、再执行"的三步模式,配合 danger 红色的警示文字和警示色按钮,形成了双重确认保障。锻造操作通过 Math.min 和 Math.max 对品级值进行上下限截断,防止不合理的数据范围。表单输入通过 onChange 回调将值写回状态变量,确认时通过 Number() 转换确保类型正确。这些设计细节共同构建了一个安全、可靠的交互体系。
第六,@Observed 类与联合类型的空安全处理。 selArmor 和 selOrder 两个状态变量使用了 ArmorItem | null 和 TuhuOrderItem | null 的联合类型,允许它们在未选中时为 null。在使用这两个变量时,代码始终通过 if (this.selXxx) 进行 null 检查——这不仅是一个运行时的安全防护,更是 ArkTS(TypeScript)类型系统的编译时保障。在 if 块内,变量的类型被自动收窄为非 null 类型,使得属性访问可以安全进行。这种空安全处理模式在涉及可选对象引用的场景中是必须的,它从编译阶段就防止了空指针异常的发生,是类型安全编程的典范实践。
第七,布局结构的层次化组织与一致性设计。 应用的整体布局遵循"根 Stack -> 主 Column(Scroll + bottomBar)+ 浮层弹窗"的层次结构,主 Column 内的 Scroll 包含 pageHeader 和 tabContent,tabContent 通过 curTab 的条件分支调度六个标签页内容。底部标签栏采用两行三列的布局(通过 ROW1_IDX 和 ROW2_IDX 索引数组配置),比传统的单行六列布局提供了更好的触控体验和信息密度。六种列表卡片(rabbitRow、armorRow、mountRow、artisanRow、stepRow、orderRow)虽然内容各异,但都遵循"Row 容器 + 左侧名称 + 标签徽章 + 中间 layoutWeight(1) 信息区 + 右侧数值"的统一结构模式,使用户在不同标签页之间切换时能够以相同的视觉模式理解信息,降低了认知负担。这种"一致中有变化、变化中有一致"的设计哲学,是优秀 UI 设计的核心原则之一。
第八,可优化空间与工程化建议。 尽管应用在技术实现上整体优秀,但仍有一些可以进一步优化的空间。首先是 aboutToAppear 中的 setInterval 未在 aboutToDisappear 中清除,在单页面应用中不是问题,但如果组件被复用于多页面场景将导致内存泄漏。其次是 orderChart 的 ForEach 键值仅使用 item.name,不如 orderRow 中的 item.name + item.month 复合键安全,存在同名订单键值冲突的风险。第三是六个 @Observed 数据模型类的字段都没有使用 readonly 修饰不可变属性(如 name、seq 等在应用中不被修改的字段),声明为 readonly 可以在编译时防止意外修改,进一步提升数据完整性。第四是 tabContent 的六个 if 分支可以改为 if-else if 链或使用映射表模式,使互斥条件更明确。第五是当前应用为单组件结构(只有 TuhuPage 一个 @Component),如果未来功能扩展,将列表项卡片拆分为独立的 @Component 子组件并使用 @Prop/@ObjectLink 传递数据,将有助于代码的模块化和复用性。这些优化建议并不影响当前应用的正常运行,但在工程化规模扩大时将显著提升代码的可维护性与安全性。
更多推荐



所有评论(0)