引言:空竹技艺的历史文化背景

在这里插入图片描述

空竹,古称"胡敲",又名"空钟"“空筝”“地铃”“扯铃”“风葫芦”,是中国传统民间技艺中一颗璀璨的明珠。它以竹木为骨、以丝绳为脉、以旋转为魂,在两根细杆之间上下翻飞、嗡嗡作响,既是市井百姓的消遣玩具,也是杂耍艺人的拿手绝活,更是中华民族非物质文化遗产中极具代表性的手工艺与表演艺术。空竹的历史可以追溯至唐代,彼时已有"弄瓯""弄铃"之类的旋转玩具在民间流行。到了宋代,孟元老在《东京梦华录》中记载了"弄斗"的杂耍表演,这被认为是空竹表演的早期雏形。明清两代是空竹发展的黄金时期,北京天桥、天津三不管、南京夫子庙等民间艺人聚集之地,抖空竹成为最吸引观众围观的表演项目之一。清代《帝京景物略》中详细描写了空竹的形制与玩法:"空钟者,木制其中,竹制其外,中筒贯绳,两竿扯之,旋转如飞,嗡嗡有声。"这段记载生动地还原了数百年前空竹的制作材料与表演方式。

空竹的制作本身就是一门精深的传统手工艺。一枚上品空竹从选竹到成品,需要经过选竹、开片、车碗、掏腔、开音孔、合碗、打磨、上油等八道核心工序,每一道工序都考验匠人的经验与手感。老竹须取三节以上、生长三年以上的毛竹,方能保证竹质坚韧、音色浑厚。车碗时旋床刀具的角度差之毫厘,便会影响空竹旋转时的平衡与风声。掏腔则是空竹能否发出嗡嗡共鸣声的关键,腔体的大小、深浅、弧度决定了音高与音量。可以说,每一枚空竹都是竹木与匠心的结晶,都承载着传统手工艺人对材料、工具与技艺的深刻理解。

在表演层面,空竹技法体系博大精深。基本功"平抖"是所有入门者的第一课,讲究的是匀速旋转、稳而不落;"高抛"要求将高速旋转的空竹抛向空中数米之高再稳稳接住;"盘丝"则是让丝绳缠绕旋转中的空竹做出螺旋上升的运动;"上碗"是将空竹旋转到一定速度后轻轻落入碗中继续旋转;"金鸡上架"更是将空竹停在一根竖直的杆顶上旋转不停。花样方面,“二龙戏珠”“海底捞月”“凤凰展翅”“金蛇狂舞”“青龙出水”“孔雀开屏”"白鹤亮翅"等名目,或取意于神话传说,或取形于飞禽走兽,每一个花样都有严格的动作规范与传承谱系。这些花样不仅是身体的技巧,更是中国传统美学在身体运动中的具象表达——以柔克刚、以静制动、以圆为美、以和为贵。

空竹艺人则是一个有着明确师承关系与技术等级的群体。从学徒到艺人,从艺人到名家,从名家到大师,每一步晋升都需要多年苦练与无数场演出的磨砺。老一辈大师如刘魁、张兆杰等人,一生演出数千场,技艺之精纯已达"人竹合一"的境界。他们的手就是最精密的仪器,抖绳的力度、角度、节奏全凭手感微调,外行看是"随手一抖",内行看则是"力从脚起、经腰到手、贯于绳端、传至竹身"的全身协调运动。

在当代,空竹技艺已被列入国家级非物质文化遗产名录。各地空竹协会、非遗保护中心、民俗博物馆纷纷开展空竹文化的抢救、保护与传承工作。体育院校将空竹纳入民族传统体育课程,中小学校在课后延时服务中引入空竹教学,文创企业将空竹与现代设计结合开发出夜光空竹、彩绘空竹等新品类。从天桥杂耍到非遗国粹,从市井玩具到文化符号,空竹走过了千年岁月,依然在两根竹竿之间旋转出勃勃生机。

本篇技术博文将以一个完整的空竹坊管理应用为载体,运用 HarmonyOS ArkTS 声明式 UI 框架,从色彩体系设计、标签系统构建、数据模型建模、UI 组件渲染、状态管理机制、布局样式编排等多个维度,逐段逐行地深入剖析每一行代码的设计意图与技术细节。通过这篇博文,读者不仅能掌握 ArkTS 声明式开发的核心范式与高级技巧,更能感受到传统空竹文化与现代移动开发技术的跨界碰撞之美。


一、色彩体系:ColorPalette 接口与 COLORS 常量

在这里插入图片描述

1.1 ColorPalette 接口定义

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  walnutA: string;
  walnutB: string;
  stringC: 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 接口,它定义了整个空竹坊应用所使用的全部颜色字段的类型契约。在 ArkTS 声明式 UI 框架中,接口扮演着至关重要的角色:它不产生运行时代码,但为编译器提供了严格的类型检查依据,确保每一个颜色字段在使用时都有明确的类型定义,避免拼写错误或类型不匹配在运行时才暴露为难以追踪的 bug。

这个接口一共定义了二十个颜色字段,每个字段都是 string 类型,用于存放十六进制颜色值或 rgba 表达式。从命名上可以清晰地看到,这些颜色并非随机选取,而是经过精心规划,涵盖了应用中每一个视觉层面的需求。bgcardBg 分别代表页面背景色和卡片背景色,是整个应用底色层次的基座。header1header2 是头部渐变的起始色与终止色,用于制造深色木质质感的视觉纵深。walnutAwalnutBstringC 三个字段直接对应空竹的物理构件——核桃木色用于空竹主体、浅木色用于外圈、丝绳色用于绳线,这种命名方式将抽象的颜色与具象的空竹部件紧密绑定,使得代码可读性大幅提升。

gold 代表金色,在传统工艺中金色常用于点缀与装饰,在此应用中也是火花、旋转粒子的主色调。titlesub 分别是标题文字色与副标题文字色,采用暖黄调以配合整体木质氛围。text1text2text3 三级文字色从深到浅排列,对应正文、说明、辅助三级文字层次,这是现代 UI 设计中常见的文字层级体系。accent 是强调色,hot 是热点色,cool 是冷调色,danger 是危险色,tabBgtabOn 是底部标签栏的背景色与选中色,mask 是模态遮罩色。每一个字段都有明确的语义指向,不存在冗余或模糊的命名。

从软件工程的角度看,将所有颜色集中在一个接口中定义,是一种"单一数据源"的设计思想。这样做的好处是多方面的:首先,当需要切换主题色或进行暗色模式适配时,只需修改一个常量对象,所有引用处自动更新;其次,它强制开发者在新增颜色时先在接口中登记,避免散落在代码各处的临时颜色值造成视觉不一致;最后,接口本身可以作为团队协作的颜色规范文档,设计师与开发者可以对照接口字段进行沟通,减少理解偏差。

1.2 COLORS 常量实例

在这里插入图片描述

const COLORS: ColorPalette = {
  bg: '#FBF3E7',
  cardBg: '#FFFFFF',
  header1: '#7A4A2B',
  header2: '#3A2112',
  walnutA: '#A9713F',
  walnutB: '#D9B38A',
  stringC: '#E8D8C4',
  gold: '#E3B23C',
  title: '#FBEFDC',
  sub: '#DCC09C',
  text1: '#5A351F',
  text2: '#7A5A3E',
  text3: '#A58E74',
  accent: '#7A4A2B',
  hot: '#C0392B',
  cool: '#5D8A5D',
  danger: '#D9534F',
  tabBg: '#7A4A2B',
  tabOn: '#E3B23C',
  mask: 'rgba(0,0,0,0.45)'
};

COLORS 是 ColorPalette 接口的唯一实现实例,被声明为 const 常量,意味着它在整个应用生命周期中不可被重新赋值。这种不可变性是函数式编程理念在 ArkTS 中的体现,确保颜色配置在任何时候都是稳定可预测的。

观察这些具体颜色值,我们可以发现一套精心设计的暖色调色彩体系。#FBF3E7 作为页面背景色是一种极浅的暖米色,它不是纯白也不是纯灰,而是带有微妙的黄色调,模仿的是老竹纸或宣纸的色泽,在视觉上给人温润、古朴的感觉,与空竹的竹木材质形成呼应。#FFFFFF 纯白用于卡片背景,与页面背景形成微妙的明度对比,使卡片"浮"在背景之上,层次分明。

#7A4A2B 是一种深核桃木棕色,在色彩体系中担任多重角色:它既是头部渐变的起始色,又是 accent 强调色,还是 tabBg 标签栏背景色。这种一色多用的策略是色彩体系统一性的体现——同一种棕色出现在不同位置,形成视觉上的"血脉联系",让用户潜意识中感知到这是同一个应用的统一视觉语言。#3A2112 是更深的巧克力棕色,作为头部渐变的终止色,与起始色配合制造出从亮到暗的纵深感。

#A9713F#D9B38A 是两种核桃木色,一深一浅,分别用于空竹主体的不同部位。深色 walnutA 用于主体结构,浅色 walnutB 用于外圈或辅助装饰,这种深浅搭配模仿了真实木器在不同光照下的明暗效果。#E8D8C4 是一种极浅的米色,用于丝绳,与深色空竹主体形成对比,使绳线在视觉上清晰可辨。#E3B23C 是一种暖金色,在应用中用于火花、旋转粒子、标签选中色等需要吸引注意力的元素,金色在中国传统文化中象征着尊贵与品质,与空竹作为非遗工艺品的定位相契合。

三级文字色 #5A351F#7A5A3E#A58E74 从深棕到浅棕渐变,全部采用棕色调而非纯黑或纯灰,这是与整体暖色背景协调的关键设计——如果在暖色背景上使用冷调的灰色文字,会产生色彩冲突,视觉上不和谐。#C0392B 热点红、#5D8A5D 冷调绿、#D9534F 危险红三色用于功能性标识:热点红用于绝活花样和高温警示,冷调绿用于入门级别和正面操作按钮,危险红用于删除操作和警告提示。这三色的使用遵循了"语义色彩"的设计原则——颜色不仅是装饰,更是信息传达的载体。

最后,mask 使用 rgba(0,0,0,0.45) 而非十六进制色值,因为半透明遮罩必须使用 rgba 格式来精确控制透明度。0.45 的透明度是经过权衡的:太透明则无法突出模态弹窗,太不透明则背景完全被遮挡失去上下文。0.45 恰好让背景内容若隐若现,既突出了弹窗主体,又保留了场景的连续性。


二、标签系统:TabMeta 接口与 TAB_LIST 常量

在这里插入图片描述

2.1 TabMeta 接口定义

interface TabMeta {
  label: string;
  icon: string;
}

TabMeta 接口定义了底部导航栏每一个标签的元数据结构。它极其简洁,只包含两个字段:label 是标签的文字名称,icon 是标签的图标符号。这个接口虽然简单,但体现了"元数据驱动 UI"的核心设计思想——将界面配置数据与界面渲染逻辑分离,使得标签的增删改只需要修改数据数组,而不需要改动渲染代码。

在 ArkTS 中,label 字段存放的是中文字符串如"空竹""技法"等,icon 字段存放的则是 emoji 字符。使用 emoji 作为图标是一种轻量化的图标方案,它不需要引入额外的图标库或字体文件,直接使用 Unicode 字符即可在所有设备上渲染显示。虽然 emoji 在不同操作系统上的渲染样式略有差异,但对于这种内部应用而言,其便捷性远大于一致性的微小损失。

2.2 TAB_LIST 标签列表

在这里插入图片描述

const TAB_LIST: TabMeta[] = [
  { label: '空竹', icon: '🪀' },
  { label: '技法', icon: '🤹' },
  { label: '花样', icon: '✨' },
  { label: '艺人', icon: '🧑‍🎨' },
  { label: '工序', icon: '🛠️' },
  { label: '订单', icon: '📦' }
];

TAB_LIST 是 TabMeta 接口的数组实例,定义了六个底部标签,完美对应了空竹坊管理的六大功能模块。这六个标签的排列顺序也经过了精心设计:"空竹"作为产品本身排在首位,是整个应用的核心实体;"技法"和"花样"是空竹的表演技能层面,紧随产品之后;"艺人"是技法与花样的承载者——人,是传承的主体;"工序"是空竹从原材料到成品的制作流程,体现了工艺维度;"订单"则是商业运营层面,放在最后作为运营管理的收尾。

每一个标签的 emoji 图标都与其文字语义高度匹配。🪀 是 Unicode 中专门表示空竹/扯铃的 emoji 字符,这个图标的存在本身就说明空竹作为一种文化符号已经获得了国际标准的认可。🤹 表示杂耍抛接,对应技法中的抛接类技巧。✨ 表示闪光,对应花样的精彩绝伦。🧑‍🎨 表示工匠,对应空竹艺人。🛠️ 表示工具,对应制作工序中的锯刀、旋床等工具。📦 表示包裹箱,对应订单发货。这些 emoji 的选择不是随意的,而是每一项都经过语义匹配的考量。

2.3 索引数组的定义

在这里插入图片描述

const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const SPIN_IDX: number[] = [0, 1, 2, 3, 4, 5];
const STRING_IDX: number[] = [0, 1, 2, 3, 4, 5];
const SPARK_IDX: number[] = [0, 1, 2, 3];

这里定义了五个索引数组,它们在应用中扮演着不同的角色。ROW1_IDXROW2_IDX 将六个标签分成两行三列的布局,ROW1_IDX 包含前三个标签的索引(空竹、技法、花样),ROW2_IDX 包含后三个标签的索引(艺人、工序、订单)。这种分行设计使得底部导航栏在小屏幕设备上也能保持合理的标签宽度,每个标签有足够的空间显示图标和文字。

SPIN_IDXSTRING_IDXSPARK_IDX 三个数组则用于页面头部装饰图中的动画元素。SPIN_IDX 有六个元素,对应六个旋转金色粒子;STRING_IDX 也有六个元素,对应六根丝绳线段;SPARK_IDX 有四个元素,对应四颗火花。这些索引数组将动画元素的数量参数化,如果需要增加或减少粒子数量,只需修改数组长度即可,不需要改动渲染逻辑中的 ForEach 循环。这种设计将"数量"这一配置参数从渲染代码中抽离出来,提高了代码的灵活性与可维护性。


三、辅助函数:位置计算与颜色映射

3.1 旋转粒子位置函数

在这里插入图片描述

function spinX(i: number): number {
  return 120 + i * 26;
}

function spinY(i: number): number {
  return 90 - (i % 2) * 30;
}

function spinSize(i: number): number {
  return 10 + (i % 3) * 5;
}

这三个函数共同计算头部装饰图中六个旋转金色粒子的位置和大小。spinX 计算粒子的横坐标,基准值为 120,每个粒子间距 26 像素,因此六个粒子的横坐标分别为 120、146、172、198、224、250,形成一条从左到右的等距排列。spinY 计算纵坐标,使用 i % 2 取模运算使得奇偶索引的粒子产生高低交替的效果——偶数索引粒子在 y=90 处,奇数索引粒子在 y=60 处,形成上下波动的排列轨迹,模拟空竹旋转时粒子飞溅的不规则感。

spinSize 计算粒子大小,使用 i % 3 取模使得粒子大小以 10、15、20 三种尺寸循环出现,增加了视觉上的层次感和随机感。如果所有粒子大小相同,排列会显得机械死板;通过取模运算引入大小变化,使得粒子群看起来更自然、更有动感。这三个函数体现了数学函数驱动的视觉布局思维——用简洁的算术表达式生成丰富多变的视觉效果,是程序化图形设计的精髓。

3.2 丝绳位置函数

在这里插入图片描述

function stringX(i: number): number {
  return 170 + (i - 2.5) * 34;
}

function stringY(i: number): number {
  return 66 - Math.abs(i - 2.5) * 10;
}

丝绳位置函数采用了与粒子不同的计算策略。stringX(i - 2.5) 的含义是以索引 2.5(即第三和第四根绳之间的中点)为中心,向两侧对称分布,间距 34 像素。这意味着六根绳以中心为对称轴,左右各三根,形成对称的绳网布局。stringYMath.abs(i - 2.5) 计算到中心的距离,距离越远 y 值越小(即位置越高),形成一条以中心为最低点的抛物线状排列。这种抛物线排列模拟了真实丝绳在空竹重力作用下的下垂弧线——中间的绳子受力最大、下垂最深,两侧的绳子逐渐升高,完美还原了物理世界中绳索的形态。

3.3 火花位置函数

function sparkX(i: number): number {
  return 228 + i * 18;
}

function sparkY(i: number): number {
  return 30 + (i % 2) * 16;
}

火花位置函数计算四颗火花的坐标。sparkX 基准值 228,间距 18,四颗火花横向排列。sparkY 使用 i % 2 产生高低交替,偶数在 y=30,奇数在 y=46,形成轻微的上下波动。火花位于头部装饰图的右上方区域,模拟空竹高速旋转时摩擦产生的火花飞溅效果,这种视觉细节为静态画面注入了动态感与戏剧性。

3.4 数据条宽度与高度函数

function speedBarW(v: number): number {
  return 18 + v * 0.5;
}

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;
}

这四个函数将数据值映射为可视化条形的尺寸,是数据可视化中的核心映射逻辑。speedBarW 将空竹转速(单位转/分)映射为进度条宽度,基准宽度 18 像素,每增加 1 转/分宽度增加 0.5 像素。当转速为 220 时,条宽为 18 + 110 = 128 像素;当转速为 340 时,条宽为 18 + 170 = 188 像素。这种线性映射使得用户可以直观地通过条宽比较不同空竹的转速差异。

skillBarW 用于技法掌握度和艺人技艺值的进度条,基准 16 像素,系数 1.1。craftBarW 用于工序相关数据,系数 1.6 较大,意味着同样的数值变化在此条上会产生更大的视觉变化,这是根据不同数据类型的数值范围进行的差异化系数调整。orderBarH 用于订单柱状图的高度计算,使用 Math.min(a, 50000) 将金额上限限定在 50000,避免极端大额订单导致柱子过高溢出容器,除以 500 是缩放系数,加上 24 的基准高度确保即使金额为零也有最小可见高度。这种"最大值截断 + 基准值 + 线性缩放"的三段式映射是数据可视化中处理异常值的经典手法。

3.5 颜色映射函数

function gradeColor(g: string): string {
  if (g === '绝活') {
    return COLORS.hot;
  }
  if (g === '上乘') {
    return COLORS.accent;
  }
  if (g === '入门') {
    return COLORS.cool;
  }
  return COLORS.text3;
}

function kindColor(k: string): string {
  if (k === '单轮') {
    return COLORS.accent;
  }
  if (k === '双轮') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

function titleColor(t: string): string {
  if (t === '大师') {
    return COLORS.hot;
  }
  if (t === '名家') {
    return COLORS.accent;
  }
  return COLORS.cool;
}

function stepColor(s: number): string {
  if (s <= 3) {
    return COLORS.walnutA;
  }
  if (s <= 6) {
    return COLORS.gold;
  }
  return COLORS.hot;
}

这四个函数构成了应用的颜色语义映射系统,将业务语义(花样等级、空竹类型、艺人头衔、工序序号)映射为视觉色彩。gradeColor 将花样的三个等级"绝活"“上乘”"入门"分别映射为热点红、强调棕、冷调绿,这种"红-棕-绿"的递进暗合了"高-中-低"的等级表达,红色代表最高等级的绝活花样,如同火光般耀眼;棕色代表中等等级的上乘花样,沉稳厚重;绿色代表入门级别的花样,平和友好。

kindColor 区分单轮和双轮空竹,单轮用棕色、双轮用红色。titleColor 区分大师、名家和其他头衔的艺人,映射策略与花样等级一致。stepColor 按序号分三段着色:前三道工序(选竹、开片、车碗)用核桃木棕,中间三道(掏腔、开音孔、合碗)用金色,最后两道(打磨、上油)用热点红。这种分段着色使得八道工序在视觉上形成"棕-金-红"三段递进,用户一眼就能看出工序的进度阶段。

这四个函数的共同特点是使用简单的 if 条件判断链,最后一个 return 作为默认兜底值。这种写法虽然不如 switch-case 或字典查表优雅,但可读性极强,且在条件数量较少(3-4 个)时性能完全无差异。对于颜色映射这种高频调用的纯函数,清晰可读比形式优美更重要。


四、数据模型:@Observed 观察类体系

4.1 KongzhuItem 空竹品类模型

@Observed
export class KongzhuItem {
  name: string;
  kind: string;
  speed: number;
  size: string;
  price: number;

  constructor(name: string, kind: string, speed: number, size: string, price: number) {
    this.name = name;
    this.kind = kind;
    this.speed = speed;
    this.size = size;
    this.price = price;
  }
}

KongzhuItem 是空竹产品实体的数据模型,使用 @Observed 装饰器修饰。在 ArkTS 的状态管理框架中,@Observed 的作用是将一个普通类转变为"可观察类"——当该类的实例属性发生变化时,所有引用了该实例的 UI 组件都会自动重新渲染。这是 ArkTS 响应式编程模型的核心机制之一,与 Vue 的 reactive 或 React 的 useState 在理念上一脉相承,但实现方式有所不同。

@Observed 类的属性变化之所以能触发 UI 更新,是因为 ArkTS 框架在编译期对 @Observed 类进行了代理增强,在运行时拦截属性的 set 操作,在值真正变更之前通知依赖收集器,由依赖收集器找到所有观察了该属性的 UI 组件并触发重渲染。这种"拦截-通知-重渲染"的机制使得开发者无需手动调用 setStateforceUpdate,只需直接修改属性值即可,代码更加声明式、更加简洁。

KongzhuItem 的五个属性各有明确的语义:name 是空竹的名称如"雕花空竹",kind 是类型如"单轮"或"双轮",speed 是转速以转/分为单位,size 是尺寸描述如"直径 12cm",price 是价格以元为单位。构造函数接收五个参数并依次赋值给属性,这是 TypeScript 中最标准的构造函数写法。使用 export 关键字导出该类,使得它可以在其他文件中被导入使用,虽然在当前单文件结构中 export 并非必需,但它为未来的模块化拆分预留了接口。

4.2 TechItem 技法模型

@Observed
export class TechItem {
  name: string;
  type: string;
  level: number;
  years: number;
  desc: string;

  constructor(name: string, type: string, level: number, years: number, desc: string) {
    this.name = name;
    this.type = type;
    this.level = level;
    this.years = years;
    this.desc = desc;
  }
}

TechItem 是空竹技法的数据模型,同样使用 @Observed 修饰。name 是技法名称如"平抖"“高抛”,type 是技法分类如"基本功"“抛接”“绕线”“平衡”“换手”,level 是掌握度以百分比表示,years 是修炼年数,desc 是技法描述。

值得注意的是 level 属性——它是百分比数值,在修炼弹窗中会被修改(苦练 +5% 或巩固 -3%),这种修改正是通过 @Observed 机制触发 UI 重渲染的典型场景。当用户点击"苦练 +5%"按钮时,代码执行 this.selTech.level = Math.min(100, this.selTech.level + 5)@Observed 拦截器检测到 level 属性从旧值变为新值,通知所有观察了 selTech.level 的 UI 组件重渲染,于是进度条宽度自动更新为新的 skillBarW(level) 计算值。这种"数据驱动 UI"的编程范式是声明式 UI 的精髓所在。

4.3 TrickItem 花样模型

@Observed
export class TrickItem {
  name: string;
  grade: string;
  difficulty: number;
  need: string;
  note: string;

  constructor(name: string, grade: string, difficulty: number, need: string, note: string) {
    this.name = name;
    this.grade = grade;
    this.difficulty = difficulty;
    this.need = need;
    this.note = note;
  }
}

TrickItem 是空竹花样的数据模型。grade 属性存放花样等级(“绝活”“上乘”“入门”),通过 gradeColor 函数映射为不同的标签背景色,实现等级的视觉区分。difficulty 是难度数值(76-96 区间),通过 skillBarW 函数映射为进度条宽度。need 描述所需器材(如"双空竹"“单轮竹”“长线”),note 是简要说明。这五个属性共同构成了一个花样的完整信息画像,使得每一个花样不仅有一个诗意的名字,还有量化的难度指标与操作说明。

4.4 ArtistItem 艺人模型

@Observed
export class ArtistItem {
  name: string;
  title: string;
  age: number;
  shows: number;
  skill: number;

  constructor(name: string, title: string, age: number, shows: number, skill: number) {
    this.name = name;
    this.title = title;
    this.age = age;
    this.shows = shows;
    this.skill = skill;
  }
}

ArtistItem 是空竹艺人的数据模型。title 属性存放头衔(“大师”“名家”“艺人”“学徒”),通过 titleColor 函数映射为不同的标签颜色。shows 是演出场次,skill 是技艺值(0-100 区间),通过 skillBarW 映射为进度条。age 是艺人年龄。这些属性不仅描述了艺人的基本信息,还通过 titleskill 两个维度体现了艺人的技术等级——头衔是定性评价,技艺值是定量评价,两者互相印证。

4.5 KongStepItem 工序模型

@Observed
export class KongStepItem {
  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;
  }
}

KongStepItem 是空竹制作工序的数据模型。seq 是工序序号(1-8),通过 stepColor 函数映射为序号圆圈的背景色,实现"棕-金-红"三段递进。days 是该工序所需天数,tool 是使用的工具(如"锯刀 · 目测"“劈刀”“旋床 · 刀具”),note 是工序要点说明。这个模型将空竹制作的传统工艺流程数据化,每一步的名称、工时、工具、要点都清晰记录,既是工艺文档,也是生产管理的数据基础。

4.6 KongOrderItem 订单模型

@Observed
export class KongOrderItem {
  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;
  }
}

KongOrderItem 是订单的数据模型。name 是空竹产品名称,buyer 是买方(如"体育用品店"“杂技团”“收藏家”),amount 是订单金额,count 是数量,month 是月份(如"2026-08")。amount 通过 orderBarH 函数映射为柱状图高度,在订单标签页中以可视化形式展示月度走势。订单可以被删除——在删除确认弹窗中通过 this.orders.splice(this.orders.indexOf(this.selOrder), 1) 实现,这是 @Observed 类数组操作的另一个典型场景:当数组元素被移除时,ForEach 自动重新渲染列表,被删除的行消失。


五、主页面组件:KongzhuPage 结构体

5.1 组件声明与 @Entry 装饰器

@Entry
@Component
struct KongzhuPage {

@Entry 装饰器标记 KongzhuPage 为应用的入口页面组件——它是整个应用的根组件,HarmonyOS 系统在启动应用时会首先渲染这个组件。一个 ArkTS 页面文件中有且仅能有一个 @Entry 组件。@Component 装饰器则标记该 struct 为一个自定义组件,使其拥有独立的状态管理、生命周期回调和构建函数。这两个装饰器的组合是 ArkTS 页面开发的最基本模板。

struct 关键字定义了一个结构体,在 ArkTS 中结构体是组件的载体。与 TypeScript 的 class 不同,struct 是值类型(虽然在 ArkTS 中的实现细节有所不同),它更强调"数据与行为的内聚"而非"继承与多态"。KongzhuPage 作为整个应用唯一的页面组件,承载了所有的状态、数据、UI 构建逻辑和交互处理,是一个"大组件"的典型代表。在实际工程中,这种将所有逻辑集中在一个组件中的做法适合中小规模应用;对于大型应用,应当将各功能模块拆分为独立的子组件以降低复杂度。

5.2 @State 状态变量——导航与交互状态

  @State curTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State selTech: TechItem | null = null;
  @State selOrder: KongOrderItem | null = null;

这一组 @State 变量管理着应用的所有交互状态。@State 是 ArkTS 中最基础的状态装饰器,被它修饰的变量在值发生变化时会自动触发所在组件的重渲染。与 @Observed 类的属性级响应不同,@State 是组件级的状态——它属于组件自身,当值改变时组件的 build 方法会被重新调用。

curTab 是当前选中的标签索引,初始值为 0(即"空竹"标签)。它是整个页面内容切换的核心驱动力——tabContent 构建器中通过六个 if (this.curTab === N) 条件分支,根据 curTab 的值渲染不同的内容。当用户点击底部标签时,curTab 被更新为对应索引,tabContent 重渲染,显示新的标签内容。这种"状态驱动内容"的模式是声明式 UI 的核心范式。

breath 是一个特殊的布尔状态,它以 470 毫秒为周期在 true 和 false 之间交替切换(由 aboutToAppear 中的 setInterval 实现),驱动所有动画元素的运动。这个变量的命名"breath"(呼吸)非常形象——它如同应用的心跳,每一次跳动都带动头部装饰图中的空竹旋转、粒子闪烁、丝绳摇摆,为静态界面注入生命感。这种单一状态驱动多动画的设计简洁而高效,避免了为每个动画元素维护独立的状态。

showAddshowEditshowDel 三个布尔状态分别控制三个模态弹窗的显示与隐藏。selTechselOrder 是可空引用,分别指向当前选中的技法和订单对象,在编辑和删除弹窗中用于显示选中项的信息。使用 | null 联合类型允许这些变量在未选中时为 null,这是 TypeScript 的空安全设计。

5.3 @State 状态变量——表单状态

  @State formName: string = '';
  @State formKind: string = '';
  @State formPrice: string = '';

这三个 @State 变量用于新增空竹弹窗中的表单输入。formName 存放用户输入的空竹名称,formKind 存放类型,formPrice 存放价格。值得注意的是,虽然价格在数据模型 KongzhuItem 中是 number 类型,但表单状态 formPrice 却是 string 类型——这是因为 TextInput 组件的输入值始终是字符串,将表单状态设为 string 可以直接与 TextInput 双向绑定,避免类型转换的中间环节。在最终提交时,通过 Number(this.formPrice) 将字符串转换为数字再传给 KongzhuItem 的构造函数,这是一种"输入时字符串、提交时转换"的常见表单处理模式。

5.4 @State 状态变量——kongzhus 空竹列表

  @State kongzhus: KongzhuItem[] = [
    new KongzhuItem('雕花空竹', '双轮', 220, '直径 12cm', 128),
    new KongzhuItem('响铃空竹', '双轮', 260, '直径 14cm', 168),
    new KongzhuItem('单轮长杆竹', '单轮', 300, '直径 10cm', 156),
    new KongzhuItem('楠木空竹', '双轮', 240, '直径 13cm', 258),
    new KongzhuItem('红木小空竹', '单轮', 320, '直径 8cm', 298),
    new KongzhuItem('竹根空竹', '双轮', 210, '直径 15cm', 188),
    new KongzhuItem('彩绘空竹', '双轮', 230, '直径 11cm', 98),
    new KongzhuItem('夜光空竹', '双轮', 280, '直径 12cm', 138),
    new KongzhuItem('象牙白空竹', '单轮', 310, '直径 9cm', 388),
    new KongzhuItem('三响空竹', '双轮', 250, '直径 16cm', 218),
    new KongzhuItem('掌心小空竹', '单轮', 340, '直径 6cm', 88),
    new KongzhuItem('龙凤纹空竹', '双轮', 270, '直径 13cm', 268)
  ];

这是空竹产品的初始数据列表,包含十二款空竹。每款空竹都通过 new KongzhuItem(...) 构造实例,传入名称、类型、转速、尺寸和价格。观察这些数据,可以发现它们覆盖了空竹的多种品类:从材质看有楠木、红木、竹根等;从类型看有单轮和双轮;从大小看从直径 6cm 的掌心小空竹到直径 16cm 的三响空竹;从价格看从 88 元到 388 元覆盖了入门到高端的不同价位。

这十二个实例被 @State 修饰,意味着当数组内容变化(如 push 新增元素)时,引用该数组的 ForEach 会自动重新渲染。数据中每一项的名称都极具传统文化韵味——“雕花”“响铃”“龙凤纹”"象牙白"等名称不仅是产品标识,更是工艺美学的文字表达。转速数据从 210 到 340 转/分不等,反映了不同大小、不同材质空竹的物理特性:小空竹转速高(掌心小空竹 340 转/分),大空竹转速低(竹根空竹 210 转/分),这符合物理学中"大质量、低转速"的规律。

5.5 @State 状态变量——techs 技法列表

  @State techs: TechItem[] = [
    new TechItem('平抖', '基本功', 90, 2, '匀速旋转'),
    new TechItem('高抛', '抛接', 85, 3, '抛高接住'),
    new TechItem('盘丝', '绕线', 92, 4, '线缠旋转'),
    new TechItem('上碗', '平衡', 78, 3, '落入碗中'),
    new TechItem('金鸡上架', '平衡', 88, 4, '单线站立'),
    new TechItem('背后换手', '换手', 82, 3, '背身换线'),
    new TechItem('流星赶月', '绕线', 95, 5, '高速环绕'),
    new TechItem('空中飞人', '抛接', 90, 5, '双竹互抛')
  ];

技法列表包含八项技法,涵盖了空竹表演的基本功、抛接、绕线、平衡、换手五大类别。每一项技法的 level 掌握度从 78% 到 95% 不等,years 修炼年数从 2 年到 5 年。技法名称既有朴实的"平抖"“高抛”“盘丝”,也有意境优美的"金鸡上架"“流星赶月”“空中飞人”,体现了民间技艺在命名上的文学性。

这八个技法实例的 level 属性是可变的——用户可以通过修炼弹窗进行"苦练 +5%"或"巩固 -3%"操作,每次操作后 level 值更新,@Observed 机制触发进度条重渲染。Math.min(100, ...)Math.max(50, ...) 的边界保护确保 level 始终在 50-100 的合理区间内,不会出现超过 100% 或低于 50% 的不合理数值。

5.6 @State 状态变量——tricks 花样列表

  @State tricks: TrickItem[] = [
    new TrickItem('二龙戏珠', '绝活', 96, '双空竹', '双竹同舞'),
    new TrickItem('海底捞月', '绝活', 92, '单轮竹', '低处捞起'),
    new TrickItem('凤凰展翅', '上乘', 88, '双轮竹', '展开旋转'),
    new TrickItem('金蛇狂舞', '上乘', 84, '长线', '蛇形缠绕'),
    new TrickItem('旭日东升', '上乘', 86, '双轮竹', '直线抛升'),
    new TrickItem('青龙出水', '绝活', 94, '双轮竹', '从线底穿出'),
    new TrickItem('孔雀开屏', '上乘', 82, '双轮竹', '多圈环绕'),
    new TrickItem('白鹤亮翅', '入门', 76, '单轮竹', '单侧展开')
  ];

花样列表包含八项花样,等级覆盖了"绝活"(3 项)、“上乘”(4 项)和"入门"(1 项)。每一项花样的名称都取自中国传统文化中的意象——"二龙戏珠"源自民间传说,"凤凰展翅"取形于神鸟,"金蛇狂舞"化用古曲名,"旭日东升"取意于日出,"青龙出水"暗合风水术语,“孔雀开屏”"白鹤亮翅"则取形于飞禽。这些名称不仅是花样的标识,更是中国传统身体美学与自然意象的融合表达。

5.7 @State 状态变量——artists 艺人列表

  @State artists: ArtistItem[] = [
    new ArtistItem('刘魁', '大师', 82, 3000, 99),
    new ArtistItem('张兆杰', '大师', 76, 2600, 97),
    new ArtistItem('赵景春', '名家', 58, 1800, 94),
    new ArtistItem('王铁环', '名家', 52, 1500, 92),
    new ArtistItem('李金斗', '名家', 46, 1200, 90),
    new ArtistItem('孙竹空', '艺人', 38, 800, 86),
    new ArtistItem('周响铃', '艺人', 30, 500, 81),
    new ArtistItem('吴转轮', '学徒', 22, 200, 74)
  ];

艺人列表包含八位艺人,头衔从"大师"到"学徒"形成完整的师承梯度。大师 2 位(刘魁 82 岁、张兆杰 76 岁),名家 3 位(赵景春 58 岁、王铁环 52 岁、李金斗 46 岁),艺人 2 位(孙竹空 38 岁、周响铃 30 岁),学徒 1 位(吴转轮 22 岁)。年龄梯度从 22 岁到 82 岁,演出场次从 200 到 3000,技艺值从 74 到 99,完整展现了空竹艺人从学徒到大师的成长路径。艺人姓名也带有行业特色——“魁”“兆杰”“景春”“铁环”“金斗”“竹空”“响铃”“转轮”,其中"竹空"“响铃”"转轮"直接化用空竹的元素,既是艺名也是行业符号。

5.8 @State 状态变量——steps 工序列表

  @State steps: KongStepItem[] = [
    new KongStepItem('选竹', 2, '锯刀 · 目测', '老竹三节', 1),
    new KongStepItem('开片', 1, '劈刀', '竹片均匀', 2),
    new KongStepItem('车碗', 2, '旋床 · 刀具', '旋出碗形', 3),
    new KongStepItem('掏腔', 1, '刻刀', '内腔出音', 4),
    new KongStepItem('开音孔', 1, '钻头', '对穿孔', 5),
    new KongStepItem('合碗', 1, '胶 · 夹具', '双碗相连', 6),
    new KongStepItem('打磨', 1, '砂纸', '光滑圆润', 7),
    new KongStepItem('上油', 1, '桐油', '防裂增亮', 8)
  ];

工序列表包含八道工序,从"选竹"到"上油"完整覆盖了空竹制作的全流程。seq 序号 1-8 决定了显示顺序和序号圆圈的颜色(前三道棕、中三道金、后两道红)。days 工时总计 10 天,其中"选竹"和"车碗"各需 2 天,是最耗时的工序。tool 字段记录了每道工序使用的工具,从最原始的"锯刀"“劈刀"到半机械化的"旋床”,再到精细的"刻刀"“钻头”“砂纸”,最后是"桐油"上油,工具的演进也反映了工艺从粗到精的过程。note 字段简明扼要地描述了每道工序的技术要点——“老竹三节”“竹片均匀”“旋出碗形”“内腔出音”“对穿孔”“双碗相连”“光滑圆润”“防裂增亮”,八个要点浓缩了空竹制作的全部技术精髓。

5.9 @State 状态变量——orders 订单列表

  @State orders: KongOrderItem[] = [
    new KongOrderItem('雕花空竹', '体育用品店', 12800, 100, '2026-08'),
    new KongOrderItem('响铃空竹', '杂技团', 16800, 100, '2026-07'),
    new KongOrderItem('红木小空竹', '收藏家', 8940, 30, '2026-08'),
    new KongOrderItem('彩绘空竹', '文创店', 9800, 100, '2026-06'),
    new KongOrderItem('夜光空竹', '演出公司', 13800, 100, '2026-07'),
    new KongOrderItem('三响空竹', '庙会主办', 10900, 50, '2026-05'),
    new KongOrderItem('楠木空竹', '研学基地', 12900, 50, '2026-08'),
    new KongOrderItem('掌心小空竹', '玩具品牌', 13200, 150, '2026-06'),
    new KongOrderItem('龙凤纹空竹', '婚庆公司', 8040, 30, '2026-04'),
    new KongOrderItem('竹根空竹', '非遗馆', 9400, 50, '2026-05')
  ];

订单列表包含十个订单,涵盖了从 2026 年 4 月到 8 月的五个月份数据。买方类型多样——体育用品店、杂技团、收藏家、文创店、演出公司、庙会主办、研学基地、玩具品牌、婚庆公司、非遗馆,反映了空竹产品在不同市场渠道的流通情况。金额从 8040 元到 16800 元不等,数量从 30 到 150 个不等。这些数据在 orderChart 构建器中被可视化为柱状图,柱高与金额成正比,并伴有呼吸动画效果——柱子颜色在金色和核桃木色之间交替变化,为数据图表增添了动态感。


六、生命周期:aboutToAppear

  aboutToAppear(): void {
    setInterval(() => {
      this.breath = !this.breath;
    }, 470);
  }

aboutToAppear 是 ArkTS 组件生命周期回调之一,在组件实例创建后、build 方法首次执行前被调用。它通常用于初始化数据、启动定时器、注册事件监听等需要在组件创建时执行的准备性操作。

在此应用中,aboutToAppear 启动了一个 470 毫秒周期的 setInterval 定时器,每隔 470 毫秒将 breath 状态取反。这个定时器是整个应用所有动画的"心脏"——breath 状态的每一次切换都会触发组件重渲染,而重渲染时所有依赖 breath 的动画属性(opacity、rotate、backgroundColor 等)都会更新到新的值,配合各元素自身的 animation 配置(duration、iterations、playMode),产生连续不断的动画效果。

470 毫秒的周期选择是经过考量的。如果周期太短(如 100 毫秒),动画会过于频繁,消耗大量 CPU 资源,可能导致界面卡顿;如果周期太长(如 1000 毫秒),动画会显得迟缓呆滞。470 毫秒约为每秒两次切换,配合各元素的 animation duration(600-800 毫秒),形成了一种"不完全同步"的呼吸节奏——不同元素的动画周期略有差异,使得整体效果不至于整齐划一地机械运动,而是呈现出自然的、此起彼伏的动态感。

需要指出的是,setInterval 返回的定时器 ID 在此代码中未被保存,也未在 aboutToDisappear 中清除。在当前单页面应用中这不是问题,但在多页面或组件可能被销毁的场景中,未清除的定时器会导致内存泄漏。这是一个在生产环境中应当注意的改进点。


七、模态遮罩构建器:modalOverlay

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.mask)
      .onClick(() => {
        onClose();
      })
  }

modalOverlay 是一个可复用的 @Builder 构建器,用于生成模态弹窗的半透明遮罩层。@Builder 装饰器修饰的方法是一个 UI 片段的构建函数,它不产生组件实例,而是在被调用的位置内联展开。这使得 @Builder 适合封装重复使用的 UI 片段,类似于其他框架中的"渲染函数"或"插槽"。

这个构建器接收一个 onClose: () => void 回调函数作为参数。遮罩层是一个全屏的 Column,背景色为 COLORS.mask(即 rgba(0,0,0,0.45) 半透明黑色),点击遮罩层时调用 onClose 回调关闭弹窗。这种"点击外部关闭"是模态弹窗的标准交互模式——用户可以点击遮罩层的任意位置来关闭弹窗,无需精确点击关闭按钮,提升了操作的便捷性。

modalOverlay 在三个模态弹窗(新增弹窗、修炼弹窗、删除弹窗)中被复用,每次调用传入不同的 onClose 回调(分别设置 showAdd、showEdit、showDel 为 false)。这种参数化的构建器复用模式,避免了在每个弹窗中重复编写遮罩层代码,体现了 DRY(Don’t Repeat Yourself)原则。如果未来需要修改遮罩层的样式(如调整透明度或添加模糊效果),只需修改一处即可同步生效。


八、页面头部构建器:pageHeader

8.1 头部容器与渐变背景

  @Builder
  pageHeader() {
    Column() {
      Stack() {
        Column()
          .width('100%')
          .height('100%')
          .borderRadius(22)
          .linearGradient({
            angle: 135,
            colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
          })

pageHeader 是整个应用视觉设计的核心,它用纯代码绘制了一幅空竹旋转的动态画面。外层是一个 Column 容器,内层是一个 Stack 堆叠布局。Stack 的特性是所有子元素在同一坐标系中堆叠,后声明的元素覆盖先声明的元素,这使得它可以像画布一样逐层绘制。

第一层是渐变背景——一个全尺寸的 Column,设置了 22 像素的圆角和 135 度角的线性渐变。linearGradientangle: 135 表示渐变方向从左上角到右下角,colors 数组中 [COLORS.header1, 0] 表示起始位置(0%)使用深核桃木棕色 #7A4A2B[COLORS.header2, 1] 表示终止位置(100%)使用更深的巧克力棕色 #3A2112。这种从浅棕到深棕的对角渐变制造了一种类似木质表面的光泽感与纵深感,为整个头部画面提供了深沉厚重的背景基调。

8.2 火花粒子动画

        ForEach(SPARK_IDX, (i: number) => {
          Column()
            .width(3)
            .height(3)
            .borderRadius(1.5)
            .backgroundColor(COLORS.gold)
            .opacity(this.breath ? 0.25 : 1)
            .position({ x: sparkX(i), y: sparkY(i) })
            .animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'k' + i)

在渐变背景之上,首先绘制的是四颗火花粒子。ForEach 遍历 SPARK_IDX 数组([0,1,2,3]),为每个索引创建一个 3x3 像素的金色小圆点。opacity 属性绑定到 this.breath 状态——当 breath 为 true 时透明度 0.25(暗淡),为 false 时透明度 1(明亮),配合 650 毫秒持续时间、无限循环、Alternate 播放模式的动画,形成了"明-暗-明-暗"的闪烁效果。PlayMode.Alternate 表示动画在正向播放完毕后反向播放,形成来回往复的效果,比默认的 Normal 模式(每次从头开始)更自然流畅。

position 属性使用绝对定位,sparkXsparkY 函数分别计算每颗火花的坐标。由于火花的位置是固定的,动画只改变透明度,位置不变,所以四颗火花在固定位置闪烁,模拟空竹高速旋转摩擦时飞溅的火花。ForEach 的第三个参数 (i: number) => 'k' + i 是键值生成函数,为每个粒子生成唯一键 'k0''k1''k2''k3',用于框架的虚拟 DOM diff 算法高效识别元素。

8.3 空竹主体结构——抖杆

        Column()
          .width(30)
          .height(4)
          .backgroundColor(COLORS.walnutB)
          .borderRadius(2)
          .position({ x: 130, y: 62 })
        Column()
          .width(30)
          .height(4)
          .backgroundColor(COLORS.walnutB)
          .borderRadius(2)
          .position({ x: 210, y: 62 })
        Column()
          .width(8)
          .height(46)
          .backgroundColor(COLORS.walnutA)
          .borderRadius(4)
          .position({ x: 122, y: 40 })
        Column()
          .width(8)
          .height(46)
          .backgroundColor(COLORS.walnutA)
          .borderRadius(4)
          .position({ x: 240, y: 40 })

这四个 Column 分别绘制了两根抖杆的杆头和杆身。前两个 Column(width 30, height 4)是左右两根杆的杆头横条,位于 y=62 处,浅核桃木色。后两个 Column(width 8, height 46)是左右两根杆的杆身竖条,位于 y=40 处,深核桃木色。左杆杆头在 x=130、杆身在 x=122;右杆杆头在 x=210、杆身在 x=240。这种"横条+竖条"的组合用最简单的矩形模拟了抖杆的形态——杆身是竖直握持的木杆,杆头是顶端系绳的横头。

四个 Column 都没有动画属性,它们是静态的——抖杆在空竹旋转时本身不会移动,只有空竹和绳子会运动。这种动静结合的画面构图使得视觉焦点自然集中到旋转的空竹主体上,符合"动静相衬"的视觉设计原则。

8.4 空竹主体结构——旋转体

        Stack() {
          Column()
            .width(52)
            .height(52)
            .borderRadius(26)
            .backgroundColor(COLORS.walnutA)
            .border({ width: 2, color: COLORS.gold })
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor(COLORS.walnutB)
          Column()
            .width(10)
            .height(10)
            .borderRadius(5)
            .backgroundColor(COLORS.stringC)
            .position({ x: 21, y: 21 })
        }
        .rotate({ angle: this.breath ? 40 : 0 })
        .animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })
        .position({ x: 158, y: 36 })

空竹旋转体是头部画面的视觉中心,使用嵌套的 Stack 绘制了三层同心圆结构。最外层是 52x52 的深核桃木色大圆,带 2 像素金色边框;中间层是 30x30 的浅核桃木色中圆;最内层是 10x10 的丝绳色小圆,用 position 偏移到 (21,21) 的位置模拟轴心。三层圆的 borderRadius 都设为宽度的一半,确保形成正圆。

整个 Stack 通过 rotate 属性旋转,旋转角度绑定到 breath 状态——breath 为 true 时旋转 40 度,为 false 时旋转 0 度。配合 800 毫秒、Alternate 模式的动画,空竹在 0 度和 40 度之间往复旋转,模拟了空竹在绳上左右摇摆旋转的动态效果。Stack 整体通过 position({ x: 158, y: 36 }) 定位在画面中央偏上的位置,正好处于两根抖杆之间,符合真实抖空竹时的物理位置关系。

8.5 旋转金色粒子

        ForEach(SPIN_IDX, (i: number) => {
          Column()
            .width(spinSize(i))
            .height(spinSize(i))
            .borderRadius(spinSize(i) / 2)
            .backgroundColor(COLORS.gold)
            .opacity(this.breath ? 0.25 : 0.8)
            .position({ x: spinX(i), y: spinY(i) })
            .animation({ duration: 750, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'p' + i)

在空竹旋转体之上叠加了六个金色旋转粒子,通过 ForEach(SPIN_IDX, ...) 遍历生成。每个粒子的大小由 spinSize(i) 计算(10/15/20 三种尺寸循环),透明度在 0.25 和 0.8 之间切换,位置由 spinXspinY 计算(横向等距排列、纵向高低交替)。这些粒子模拟空竹高速旋转时周围飞溅的金色光点,与火花的区别在于:火花位于上方且固定闪烁,旋转粒子位于下方且与空竹旋转体同处一个视觉层次,仿佛是从旋转体上甩出的能量碎片。

六个粒子的动画周期为 750 毫秒,与火花的 650 毫秒和旋转体的 800 毫秒都不同,这种刻意的周期差异使得各动画元素不会同步运动,形成"此起彼伏"的错落感。如果所有动画的 duration 相同,所有元素会同时变亮同时变暗,视觉效果会非常机械;而不同周期的组合产生了类似"随机"的自然动态感。

8.6 丝绳线段动画

        ForEach(STRING_IDX, (i: number) => {
          Column()
            .width(2)
            .height(10 + (i === 0 || i === 5 ? 4 : 0))
            .backgroundColor(COLORS.stringC)
            .borderRadius(1)
            .position({ x: stringX(i), y: stringY(i) })
            .rotate({ angle: this.breath ? 10 : -10 })
            .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'g' + i)

六根丝绳线段通过 ForEach(STRING_IDX, ...) 生成。每根线段是一个 2 像素宽、10 像素高的细长矩形(首尾两根 height 14 像素,略长一些以模拟绳端下垂)。位置由 stringXstringY 计算——以中心对称分布,纵向呈抛物线排列,模拟绳子在重力下的下垂弧度。

每根线段通过 rotate 属性在 10 度和 -10 度之间摆动,配合 600 毫秒的 Alternate 动画,形成了绳子左右摇摆的效果。这种摇摆模拟了抖空竹时手腕的微小抖动传到绳端产生的振动——绳子的摆动频率(600 毫秒)快于空竹旋转体(800 毫秒),符合物理上"轻质物体振动频率高于重质物体"的规律。首尾两根线段略长(i === 0 || i === 5 时 height 加 4)的设计,模拟了绳端因不受中间约束而自然下垂更长的物理特性。

8.7 标题与统计数字

        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 })

在装饰图层的上方,通过 position 绝对定位放置了标题区域。"空竹坊"三个大字以 20 号粗体暖黄色(COLORS.title)显示,下方副标题"抖出乾坤 · 一绳牵出万转风"以 11 号浅木色(COLORS.sub)显示,上下间距 4 像素。alignItems(HorizontalAlign.Start) 使文字左对齐,与中文从左到右的阅读习惯一致。

副标题"抖出乾坤 · 一绳牵出万转风"是一句精心撰写的文案——"抖出乾坤"化用"扭转乾坤"之意,将抖空竹的动作升华为掌控天地的气魄;"一绳牵出万转风"则用"一"与"万"的数量对比,凸显以小博大、以柔克刚的哲学意蕴。这种文案设计将技术产品与传统文化深度融合,使应用不仅是一个管理工具,更是一个文化载体。

        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 组件在头部底部区域以 2x2 网格排列了四组统计数据:12 款空竹、8 项技法、8 位艺人、8 道工序。每组数据由一个大号粗体数字和一个小号标签文字组成,数字使用 COLORS.title 暖黄色突出显示,标签使用 COLORS.sub 浅木色作为辅助说明。这种"数字+标签"的设计模式是仪表盘和统计面板中的经典布局,让用户一目了然地获取关键数据。

四组数据的位置经过精确计算——左列 x=18,右列 x=118;上行 y=58,下行 y=80。行间距 22 像素,列间距 100 像素,使得四组数据在 128 像素高的头部底部区域排列整齐且不显拥挤。这些数字是硬编码的静态值,在实际工程中应当从数据数组的 length 属性动态计算,但在当前应用中数据量固定,硬编码可以减少运行时计算。

8.8 头部容器尺寸

      }
      .width('100%')
      .height(128)
    }
    .width('100%')
  }

Stack 的宽度为 100%(撑满父容器),高度固定为 128 像素。这个高度值是经过精心设计的——它需要容纳顶部标题(y=12 起,约 35 像素高)、底部统计数字(y=80 起,约 20 像素高),以及中间的装饰图案(y=30 到 y=90),128 像素恰好能够容纳所有元素而不留过多空白。外层 Column 宽度也为 100%,使得头部撑满屏幕宽度。


九、空竹行构建器:kongzhuRow

  @Builder
  kongzhuRow(item: KongzhuItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.kind)
        .fontSize(9)
        .fontColor(COLORS.cardBg)
        .backgroundColor(kindColor(item.kind))
        .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(speedBarW(item.speed))
            .height(6)
            .borderRadius(3)
            .backgroundColor(COLORS.walnutA)
            .margin({ left: 6 })
          Text(item.speed + '转/分')
            .fontSize(9)
            .fontColor(COLORS.accent)
            .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 })
  }

kongzhuRow 是空竹列表中每一行卡片的构建器,接收一个 KongzhuItem 参数。整个卡片是一个 Row 横向布局,从左到右依次排列:空竹名称、类型标签、尺寸与转速信息、价格。

名称使用 14 号粗体深棕色文字,是卡片的主标题。类型标签是一个 9 号白底彩色背景的圆角小标签,背景色由 kindColor 函数根据类型(单轮/双轮)返回,白字配彩色底形成高对比度的标签效果,8 像素圆角和紧凑 padding 使标签看起来精致小巧。标签与名称之间有 8 像素左间距,形成"主标题+副标签"的经典信息排列。

中间区域是一个 Column,通过 layoutWeight(1) 占据剩余空间。Column 内部上方是尺寸文字(10 号浅棕色),下方是一个 Row 包含"转速"标签、进度条和转速数值。进度条是一个 Column 矩形,宽度由 speedBarW(item.speed) 动态计算,6 像素高,核桃木色,3 像素圆角。进度条左侧是"转速"文字标签,右侧是"220转/分"的数值,三者在同一行内紧凑排列,形成"标签-图形-数值"的数据可视化微型组件。

最右侧是价格,12 号粗体强调棕色,前缀人民币符号。价格放在最右侧是因为它是用户最关心的信息,放在行尾便于快速扫描。整个卡片使用 12 像素内边距、白色背景、12 像素圆角,卡片之间有 8 像素顶部间距,形成清晰的信息卡片列表。白底卡片的圆角设计和适中的间距,使得内容层次分明,视觉上不会因为信息密集而显得拥挤。


十、技法行构建器:techRow

  @Builder
  techRow(item: TechItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.type)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(COLORS.cool)
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text('掌握 ' + item.level + '% · ' + item.desc)
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.level))
          .height(6)
          .borderRadius(3)
          .backgroundColor(item.level >= 90 ? COLORS.hot : COLORS.gold)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text(item.years + '年')
        .fontSize(10)
        .fontColor(COLORS.text3)
        .onClick(() => {
          this.selTech = item;
          this.showEdit = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

techRow 是技法列表的行构建器,与 kongzhuRow 结构相似但有关键差异。类型标签使用统一的 COLORS.cool 冷调绿色背景,不再像 kongzhuRow 那样根据类型动态着色,这是因为技法类型的视觉区分不需要那么强烈,统一颜色更简洁。

进度条的颜色使用了三元表达式 item.level >= 90 ? COLORS.hot : COLORS.gold——掌握度 90% 以上的技法进度条用热点红,90% 以下用金色。这种条件着色使得高掌握度技法在视觉上更突出,用户一眼就能识别出哪些技法已经修炼到精纯境界。

最右侧的"年"数文字不仅有显示功能,还绑定了 onClick 事件——点击后设置 this.selTech = itemthis.showEdit = true,打开修炼弹窗。这种"点击文字打开弹窗"的交互设计比较隐蔽,用户不一定能发现年数是可点击的。在实际工程中,可以考虑给可点击元素添加视觉提示(如下划线、箭头图标或按压效果),提升交互的可发现性。但从设计意图看,将修炼入口隐藏在年数文字上,可能是为了保持列表的简洁外观,只在用户真正需要修炼时才弹出操作面板。


十一、花样行构建器:trickRow

  @Builder
  trickRow(item: TrickItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.grade)
        .fontSize(9)
        .fontColor(COLORS.cardBg)
        .backgroundColor(gradeColor(item.grade))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text(item.need + ' · ' + item.note)
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.difficulty))
          .height(5)
          .borderRadius(2)
          .backgroundColor(COLORS.walnutB)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text('难度 ' + item.difficulty)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.hot)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

trickRow 是花样列表的行构建器。花样等级标签使用 gradeColor 函数着色——"绝活"红色、"上乘"棕色、"入门"绿色,等级越高颜色越热烈,形成视觉上的等级梯度。进度条使用 COLORS.walnutB 浅核桃木色,高度 5 像素(比技法进度条矮 1 像素),圆角 2 像素,视觉上更纤细,暗示花样难度是一个不可控的客观指标,而非像技法掌握度那样可以主动提升。

难度数值使用粗体热点红色,前缀"难度"二字使语义明确。与技法的修炼年数不同,花样行没有点击事件——花样是展示性的数据,不像技法那样可以"修炼"改变掌握度。这种设计上的差异反映了业务逻辑的不同:技法是可以精进的技能,花样是不可变的客观难度,交互设计应当与业务语义一致。

中间 Column 显示 item.need + ' · ' + item.note,将所需器材和说明用中圆点分隔合为一行,这种简洁的信息压缩方式在空间有限的列表行中非常实用。alignItems(HorizontalAlign.End) 使 Column 内的内容右对齐,因为 Column 在 layoutWeight(1) 区域中,右侧对齐使得进度条和文字与右侧的难度数值视觉上对齐,形成整齐的右边缘。


十二、艺人行构建器:artistRow

  @Builder
  artistRow(item: ArtistItem) {
    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.shows + ' 场')
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.skill))
          .height(6)
          .borderRadius(3)
          .backgroundColor(COLORS.walnutA)
          .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 })
  }

artistRow 是艺人列表的行构建器。头衔标签使用 titleColor 函数着色——"大师"红色、“名家"棕色、其他绿色,等级递进与花样等级一致。信息行显示"技艺 99 · 3000 场”,将技艺值和演出场次合并为一行,两个数字以中圆点分隔。技艺进度条使用 COLORS.walnutA 深核桃木色,6 像素高,3 像素圆角,与技法进度条样式一致,保持了"能力进度条"的视觉统一性。

最右侧显示年龄,10 号浅棕色文字。年龄是一个辅助信息,放在行尾便于参考但不抢夺注意力。与花样行类似,艺人行也没有点击事件,艺人是展示性数据。但从功能扩展的角度看,未来可以为艺人行添加点击查看详情的交互,展示艺人的师承关系、代表花样等更详细信息。


十三、工序行构建器:stepRow

  @Builder
  stepRow(item: KongStepItem) {
    Row() {
      Text(item.seq + '')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.cardBg)
        .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 是工序列表的行构建器,与其他行构建器有一个显著差异——最左侧不是文字标题,而是一个 24x24 像素的圆形序号徽章。序号通过 item.seq + '' 转为字符串显示在圆心,textAlign(TextAlign.Center) 确保数字居中。背景色由 stepColor 函数根据序号分段着色(1-3 棕、4-6 金、7-8 红),形成工序进度的三段色彩递进。圆角 12 像素(宽度的一半)使其成为正圆。这种序号徽章的设计在步骤列表、流程展示中非常常见,它用颜色和数字双重编码了工序的阶段和顺序。

序号右侧是一个 Column,上方是工序名称(14 号粗体深棕),下方是工具和要点(10 号浅棕),与序号徽章之间有 10 像素左间距。最右侧是工时"2天"等,11 号粗体热点红色。工时使用粗体红色强调,是因为工时是生产管理中最关心的数据——它直接影响产能规划和成本核算。

item.seq + '' 这种将数字转为字符串的写法值得注意。在 ArkTS 中,Text 组件的 content 参数期望接收字符串,直接传入数字虽然也能渲染,但类型上不够严谨。通过空字符串拼接 + '' 是一种轻量的类型转换方式,虽然不如 String(item.seq)item.seq.toString() 规范,但在简短表达中更为紧凑。


十四、订单柱状图构建器:orderChart

  @Builder
  orderChart() {
    Row() {
      ForEach(this.orders, (item: KongOrderItem) => {
        Column() {
          Column()
            .width(12)
            .height(orderBarH(item.amount))
            .borderRadius(3)
            .backgroundColor(this.breath ? COLORS.gold : COLORS.walnutA)
            .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: KongOrderItem) => item.name)
    }
    .width('100%')
    .height(92)
    .alignItems(VerticalAlign.Bottom)
    .padding({ left: 6, right: 6, top: 6, bottom: 6 })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
  }

orderChart 是订单数据的柱状图可视化组件。外层是一个 Row,alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐——这是柱状图的关键布局属性,确保所有柱子从同一底线向上生长,形成可比较的高度差异。Row 的高度固定为 92 像素,包含 6 像素的上下内边距,实际可用高度为 80 像素,恰好容纳最高柱子(16800 元对应高度 24 + 16800/500 = 57.6 像素)加上底部标签文字。

ForEach 遍历 this.orders 数组,为每个订单创建一个 Column。Column 内部上方是柱子(width 12, height 由 orderBarH 计算),下方是金额标签(item.amount / 1000 + 'k',如"12.8k"表示 12800 元)。每个 Column 通过 layoutWeight(1) 等分 Row 的宽度,alignItems(HorizontalAlign.Center) 使柱子和标签在 Column 中水平居中。

柱子的 backgroundColor 绑定到 this.breath 状态——breath 为 true 时金色,为 false 时核桃木色,配合 600 毫秒 Alternate 动画,所有柱子同步在金色和棕色之间交替变色,形成"呼吸"效果。这种全柱同步变色的动画虽然简洁,但视觉上略为机械——如果能为每个柱子设置独立的动画延迟,形成"波浪"式的依次变色效果,会更加生动。不过当前实现的优势是简洁高效,所有柱子共用一个状态驱动,无需为每个柱子维护独立的动画状态。

item.amount / 1000 + 'k' 是一种将金额转换为千单位的显示方式。12800 / 1000 = 12.8,加上 ‘k’ 后缀显示为"12.8k",这种缩写方式在数据可视化中非常常见,它用最短的字符表达数值量级,适合空间有限的柱状图标签。


十五、订单行构建器:orderRow

  @Builder
  orderRow(item: KongOrderItem) {
    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 是订单列表的行构建器。左侧是订单名称(14 号粗体深棕),通过 layoutWeight(1) 占据剩余空间。中间是一个 Column,上方是金额(12 号粗体强调棕色,带人民币前缀),下方是数量和月份(9 号浅棕),alignItems(HorizontalAlign.End) 使两行文字右对齐。最右侧是"删除"按钮——11 号危险红色文字,浅红色背景 #FDE8E8,8 像素圆角,像一个药丸形状的操作按钮。

"删除"按钮的 onClick 设置 this.selOrder = itemthis.showDel = true,打开删除确认弹窗。这里没有直接执行删除操作,而是先弹出确认弹窗,这是一种防误操作的安全设计——删除是不可逆操作,必须让用户二次确认。#FDE8E8 是一种极浅的粉红色,与 COLORS.danger 红色形成"浅底深字"的按钮配色,视觉上醒目但不刺眼,传达"危险操作"的语义暗示。


十六、标签内容构建器:tabContent

  @Builder
  tabContent() {
    if (this.curTab === 0) {
      Column() {
        Text('空竹名品 · 转得越快越值钱')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.kongzhus, (item: KongzhuItem) => {
          this.kongzhuRow(item)
        }, (item: KongzhuItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 1) {
      Column() {
        Text('八大技法 · 点击可修炼精进')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.techs, (item: TechItem) => {
          this.techRow(item)
        }, (item: TechItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 2) {
      Column() {
        Text('拿手花样 · 从入门到绝活')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.tricks, (item: TrickItem) => {
          this.trickRow(item)
        }, (item: TrickItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 3) {
      Column() {
        Text('抖空竹艺人 · 一代一代传')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.artists, (item: ArtistItem) => {
          this.artistRow(item)
        }, (item: ArtistItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 4) {
      Column() {
        Text('制竹八序 · 竹木精工出妙音')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.steps, (item: KongStepItem) => {
          this.stepRow(item)
        }, (item: KongStepItem) => 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: KongOrderItem) => {
          this.orderRow(item)
        }, (item: KongOrderItem) => item.name + item.month)
      }
      .width('100%')
      .margin({ top: 10 })
    }
  }

tabContent 是标签内容的调度中心,它根据 curTab 的值选择渲染对应的内容。这里使用了六个 if 条件块(而非 if-else if 链),这是 ArkTS 声明式 UI 的写法特点——每个 if 块独立判断条件,只有匹配的块会渲染。虽然逻辑上 if-else if 链更高效(匹配后跳过后续判断),但在声明式 UI 中,这种独立 if 块的写法更清晰,因为每个块都是一个独立的 UI 构建单元。

每个标签页的结构高度统一:一个标题文字 + 一个 ForEach 列表。标题文字以 12 号 COLORS.text2 中棕色显示,是当前标签的内容概述。六个标题分别是:“空竹名品 · 转得越快越值钱”“八大技法 · 点击可修炼精进”“拿手花样 · 从入门到绝活”“抖空竹艺人 · 一代一代传”“制竹八序 · 竹木精工出妙音”“订单金额 · 月度走势”。这些标题不仅描述了内容,还通过后半句的修饰语传达了业务理念——"转得越快越值钱"体现了转速与价值的关系,"一代一代传"强调了传承性,"竹木精工出妙音"点明了工艺品质。

ForEach 的键值生成函数使用 item.name(订单使用 item.name + item.month),确保每个元素有唯一键。当数组内容变化(如新增空竹、删除订单)时,ForEach 通过键值进行 diff,只更新变化的元素,而非全量重渲染,这是虚拟 DOM 机制的性能优化。

订单标签页(curTab === 5)的结构比其他标签页更复杂——它包含了两个内容区域:上方的柱状图和下方的订单列表,两者之间还有一个分隔标题。这种"图表+列表"的组合布局是数据分析页面的经典设计:柱状图提供整体趋势的直观概览,列表提供每条数据的详细信息,用户可以在宏观与微观两个层面获取信息。


十七、底部导航项构建器: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 ? '#96582F' : COLORS.tabBg)
    .borderRadius(10)
    .onClick(() => {
      this.curTab = i;
    })
  }

bottomItem 是底部导航栏单个标签项的构建器,接收索引 i 作为参数。每个标签项是一个 Row,包含 emoji 图标(16 号)和文字标签(11 号),图标和文字之间有 4 像素间距。justifyContent(FlexAlign.Center) 使 Row 内的图标和文字在水平方向居中排列。

选中态与未选中态通过多个三元表达式实现差异化:选中项的文字使用 FontWeight.Bold 粗体和 COLORS.tabOn 金色,未选中项使用 FontWeight.Normal 常规和 COLORS.sub 浅木色;选中项的背景色为 #96582F(一种比 tabBg 更亮的棕色),未选中项为 COLORS.tabBg 深核桃棕色。这种"选中亮、未选中暗"的色彩对比配合"选中粗、未选中细"的字重对比,形成了清晰的选中态视觉反馈,用户当前在哪个标签一目了然。

layoutWeight(1) 使每个标签等分父 Row 的宽度,无论标签文字长短,三个标签在一行中各占三分之一。onClick 回调将 this.curTab 设为当前索引 i,触发 tabContent 重渲染切换内容。这种"点击标签→更新状态→重渲染内容"的链式反应是声明式 UI 交互的核心模式。


十八、底部导航栏构建器: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 将六个标签分成两行排列——第一行通过 ForEach(ROW1_IDX, ...) 渲染索引 0、1、2(空竹、技法、花样),第二行通过 ForEach(ROW2_IDX, ...) 渲染索引 3、4、5(艺人、工序、订单)。两行之间有 6 像素间距。整个底部栏是一个 Column,背景色为 COLORS.tabBg 深核桃棕色,内边距 10 像素。

圆角设置 { topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 } 只对上方两个角设置 18 像素圆角,下方两角为 0(直角)。这是因为底部栏通常位于屏幕底部,下方圆角没有意义且会留出空隙,上方圆角则使底部栏与上方内容之间形成柔和的过渡,视觉上像是底部栏"托"住了上方内容。这种"上圆下直"的圆角设计是底部固定栏的标准做法。

这种两行三列的底部导航设计在移动应用中并不常见——大多数应用使用单行底部导航。但对于六个标签的情况,单行会导致每个标签过窄,图标和文字可能被压缩或截断。两行三列的布局为每个标签提供了充足的宽度,同时保持标签在拇指可触及范围内。代价是底部栏占据了更多的垂直空间,但对于内容以列表为主的应用,这种牺牲是可以接受的。


十九、新增空竹弹窗构建器:addModal

19.1 弹窗容器与遮罩

  @Builder
  addModal() {
    if (this.showAdd) {
      Stack() {
        this.modalOverlay(() => {
          this.showAdd = false;
        })
        Column() {

addModal 通过 if (this.showAdd) 条件控制渲染——只有当 showAdd 为 true 时弹窗才存在于 UI 树中,为 false 时弹窗完全不存在。这种"条件渲染"而非"条件显示"的方式意味着弹窗不显示时不占用任何渲染资源,比 visibility 控制显示隐藏更高效。Stack 作为容器,底层是 modalOverlay 遮罩层,上层是弹窗主体 Column。

modalOverlay 接收一个箭头函数 () => { this.showAdd = false; } 作为 onClose 回调。当用户点击遮罩层时,onClose 被调用,showAdd 设为 false,条件渲染使整个弹窗从 UI 树中移除。这种回调函数参数化的设计使得 modalOverlay 可以被多个弹窗复用,每个弹窗传入自己的关闭逻辑。

19.2 弹窗标题

          Text('新增空竹')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          Text('登记一款新制空竹')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })

弹窗主体顶部是标题区域——"新增空竹"以 17 号粗体深棕色显示,下方副标题"登记一款新制空竹"以 11 号浅棕色显示,上下间距 3 像素。标题区域为弹窗提供了明确的操作语境,用户一眼就知道这个弹窗是用于添加新的空竹产品。

19.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.formKind, placeholder: '如:双轮' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formKind = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Column() {
            Text('价格(元)')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formPrice, placeholder: '如:168' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formPrice = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })

竹名输入字段是一个 Column,上方是 12 号中棕色标签"竹名",下方是 TextInput 输入框。TextInput 的 text 参数绑定到 this.formName 状态,placeholder 提供占位提示"如:描金空竹"。输入框高度 38 像素,字体 13 号,与标签间距 5 像素。onChange 回调在输入内容变化时将新值赋给 this.formName,实现数据的双向绑定——虽然不是严格意义上的双向绑定(TextInput 的 text 不会自动随 formName 变化),但在当前场景中足够使用。

竹名输入框的 placeholder "描金空竹"是一个具有传统文化韵味的示例名称——"描金"是中国传统工艺中的装饰技法,在器物表面用金粉描绘纹样。使用这种文化化的 placeholder 不仅提供了输入格式提示,更在细节处传达了空竹的工艺文化。

类型和价格两个输入字段的结构与竹名完全一致,只是标签文字、placeholder 和绑定的状态变量不同。类型输入框的 placeholder "双轮"提示用户输入空竹的类型分类,价格输入框的 placeholder "168"提示用户输入数字价格。三个字段的间距从 14 像素(第一个字段与标题之间)递减为 12 像素(字段之间),这种递减间距使得标题区域与表单区域之间有更明显的分隔,而表单字段之间间距紧凑,视觉上形成一组。

19.4 操作按钮

          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.kongzhus.push(new KongzhuItem(this.formName, this.formKind, 240, '直径 12cm', Number(this.formPrice)));
                this.showAdd = false;
              })
          }
          .width('100%')
          .margin({ top: 16 })

操作按钮区域是一个 Row,包含"取消"和"确认登记"两个按钮。两个按钮都通过 layoutWeight(1) 等分宽度,textAlign(TextAlign.Center) 使文字居中,padding 设置上下 10 像素的内边距,borderRadius(10) 设置圆角。两个按钮之间有 10 像素左间距。

"取消"按钮使用灰色背景 #F2F2F2 和中棕色文字,视觉上低调——它是次要操作,不应当比确认按钮更醒目。"确认登记"按钮使用 COLORS.cool 冷调绿色背景和白色粗体文字,视觉上突出——它是主要操作,应当吸引用户注意力。这种"次要操作灰色低调、主要操作彩色突出"的按钮设计模式是现代 UI 中的通行做法。

“确认登记"的 onClick 执行两步操作:首先调用 this.kongzhus.push(new KongzhuItem(...)) 将新空竹添加到列表,然后设置 this.showAdd = false 关闭弹窗。新空竹的构造使用表单中输入的 formNameformKindformPrice(通过 Number() 转为数字),转速固定为 240,尺寸固定为"直径 12cm”。这两个固定值在实际工程中应当也由用户输入或根据类型自动推荐,但在当前简化版本中使用默认值。

19.5 弹窗定位与尺寸

        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .constraintSize({ maxHeight: '80%' })
        .position({ x: 0, y: 0 })
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
  }

弹窗主体 Column 宽度为父容器的 88%(居中后左右各留 6% 空白),内边距 18 像素,白色背景,16 像素圆角。constraintSize({ maxHeight: '80%' }) 限制弹窗最大高度为父容器的 80%,防止内容过多时弹窗超出屏幕。position({ x: 0, y: 0 }) 将弹窗定位在 Stack 的左上角——由于 Stack 的默认对齐方式是居中,弹窗在 Stack 中会居中显示。zIndex(999) 确保弹窗主体在遮罩层之上(遮罩层没有设置 zIndex,默认为 0)。

外层 Stack 宽高均为 100%,覆盖整个屏幕,与 build 方法中的根 Stack 重叠。这种"全屏 Stack + 条件渲染弹窗"的设计模式在 ArkTS 中是模态弹窗的标准实现方式——弹窗作为根 Stack 的子元素,在条件为 true 时覆盖在所有内容之上,在条件为 false 时从 UI 树中完全移除。


二十、修炼技法弹窗构建器:editModal

20.1 弹窗结构与标题

  @Builder
  editModal() {
    if (this.showEdit) {
      Stack() {
        this.modalOverlay(() => {
          this.showEdit = false;
        })
        Column() {
          Text('修炼技法')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          if (this.selTech) {
            Text(this.selTech.name + ' · 掌握 ' + this.selTech.level + '%')
              .fontSize(12)
              .fontColor(COLORS.text2)
              .margin({ top: 6 })
          }
          Text('每日苦练,技法精进')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })

editModal 的结构与 addModal 一致——Stack + modalOverlay + Column 主体。标题"修炼技法"以 17 号粗体显示,下方使用 if (this.selTech) 条件渲染选中技法的名称和当前掌握度。这里的条件判断是必要的——selTechTechItem | null 类型,在 showEdit 为 true 但 selTech 尚未被赋值的极端情况下,直接访问 selTech.name 会导致空指针异常。通过 if 条件守卫,只有在 selTech 非 null 时才访问其属性,这是 TypeScript 空安全的最佳实践。

副标题"每日苦练,技法精进"以 11 号浅棕色显示,传达了修炼的理念——技法的提升需要日常的持续练习。这种文案将一个数值操作(+5% 或 -3%)包装成了有文化内涵的交互行为,使枯燥的数据修改变得有故事感。

20.2 操作按钮

          Row() {
            Text('苦练 +5%')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.hot)
              .borderRadius(10)
              .onClick(() => {
                if (this.selTech) {
                  this.selTech.level = Math.min(100, this.selTech.level + 5);
                }
                this.showEdit = false;
              })
            Text('巩固 -3%')
              .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.selTech) {
                  this.selTech.level = Math.max(50, this.selTech.level - 3);
                }
                this.showEdit = false;
              })
          }
          .width('100%')
          .margin({ top: 16 })

修炼弹窗提供了两个操作:"苦练 +5%“和"巩固 -3%”。"苦练"使用 COLORS.hot 热点红色背景——红色暗示高强度、高付出,苦练意味着大量练习带来快速提升。"巩固"使用 COLORS.cool 冷调绿色背景——绿色暗示平稳、缓和,巩固意味着回顾复习可能导致暂时的水平波动(-3%)。这种颜色语义与操作语义的匹配是色彩心理学的应用。

"苦练 +5%"的 onClick 执行 this.selTech.level = Math.min(100, this.selTech.level + 5)——在当前 level 基础上加 5,但不超过 100。Math.min(100, ...) 是上界保护,确保掌握度不会超过 100%。同理,"巩固 -3%"使用 Math.max(50, ...) 确保掌握度不会低于 50%。50% 的下界意味着即使巩固有所回落,技法的基础掌握程度也不会低于半数,这在业务逻辑上是合理的——已经学会的技法不会遗忘到一半以下。

修改 selTech.level 后,由于 TechItem 是 @Observed 类,属性变化会通知所有观察了该属性的 UI 组件重渲染。具体来说,techRow 中的 skillBarW(item.level) 进度条宽度和 '掌握 ' + item.level + '%' 文字都会自动更新。关闭弹窗后(this.showEdit = false),用户可以看到列表中对应技法行的进度条和文字已经更新为新值。这就是 @Observed 装饰器的核心价值——数据修改自动驱动 UI 更新,无需手动刷新。

20.3 取消按钮与弹窗尺寸

          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%')
    }
  }

取消按钮是一个全宽文字按钮,灰色背景,与 addModal 中的取消按钮样式一致。点击取消只关闭弹窗,不修改任何数据。弹窗的尺寸和定位参数与 addModal 完全一致——88% 宽度、18 像素内边距、白色背景、16 像素圆角、80% 最大高度、zIndex 999。这种参数的一致性确保了所有弹窗在视觉上统一,用户在不同弹窗之间切换时不会感到视觉跳跃。


二十一、删除订单弹窗构建器:delModal

21.1 弹窗结构与确认信息

  @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 的结构与前两个弹窗一致。标题"删除订单"以 17 号粗体显示。下方使用 if (this.selOrder) 条件渲染确认信息"确认删除「雕花空竹」订单?",将选中订单的名称嵌入确认文案中,使提示更加具体和明确。再下方是一行 10 号 COLORS.danger 危险红色的"删除后不可恢复"警告——这行文字用危险红色和较小字号传达了严肃的警告语义,提醒用户此操作不可逆。

21.2 操作按钮

          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%')
    }
  }

删除弹窗的操作按钮与新增弹窗的布局一致——"取消"灰色低调、"确认删除"彩色突出。但"确认删除"按钮使用 COLORS.danger 危险红色背景而非冷调绿色,因为删除是一个破坏性操作,应当使用红色传达警示语义。这种"操作性质决定按钮颜色"的设计原则使得用户在看到红色按钮时下意识提高警觉,减少误操作。

"确认删除"的 onClick 执行 this.orders.splice(this.orders.indexOf(this.selOrder), 1)——首先通过 indexOf 找到 selOrder 在 orders 数组中的索引,然后通过 splice(index, 1) 移除该元素。splice 方法会修改原数组,由于 orders 是 @State 数组,数组内容的变化会触发 ForEach 的 diff 机制,被删除的订单行自动从列表中消失,柱状图也会同步更新。删除完成后关闭弹窗。

indexOf + splice 是 JavaScript/TypeScript 中删除数组元素的经典组合。需要注意的是,indexOf 使用严格相等(===)比较,而 selOrder 是对象引用,所以只有当数组中的元素与 selOrder 是同一个对象引用时才能匹配。在当前代码中,selOrder 是从 orderRow 的 onClick 中直接赋值的数组元素引用,所以引用相等是成立的。但如果数组中存在"同名不同对象"的情况,这种基于引用的删除可能失败。不过由于 ForEach 的键值是 item.name + item.month,理论上不存在同名同月的重复订单,所以不会出现匹配歧义。


二十二、build 构建函数:页面总装配

22.1 根 Stack 与内容区

  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)

build 方法是 KongzhuPage 组件的构建入口,所有 UI 结构从这里开始组装。根容器是一个 Stack——选择 Stack 而非 Column 作为根容器,是因为 Stack 需要容纳三个模态弹窗作为覆盖层。模态弹窗需要覆盖在整个页面之上,只有 Stack 的堆叠特性才能实现这种覆盖效果。

Stack 的第一个子元素是一个 Column,这是页面的主体结构。Column 内部从上到下依次是:Scroll 滚动区域(包含 pageHeader 和 tabContent)和 bottomBar 底部导航栏。Scroll 通过 layoutWeight(1) 占据 Column 中 bottomBar 之外的所有剩余空间,scrollable(ScrollDirection.Vertical) 允许垂直滚动——当 tabContent 的内容超出 Scroll 的可视高度时,用户可以上下滑动查看全部内容。Scroll 的背景色为 COLORS.bg 暖米色,与页面整体色调一致。

Scroll 内部的 Column 包含 pageHeader 和 tabContent 两个构建器的调用结果,宽度 100%,左右内边距 14 像素,底部内边距 12 像素。左右 14 像素的内边距使得内容不贴屏幕边缘,视觉上更有呼吸感。底部 12 像素内边距使最后一个列表项与底部导航栏之间有间距。

22.2 模态弹窗的条件渲染

      if (this.showAdd) {
        this.addModal()
      }
      if (this.showEdit) {
        this.editModal()
      }
      if (this.showDel) {
        this.delModal()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

在 Stack 的 Column 主体之后,是三个模态弹窗的条件渲染。每个弹窗通过对应的布尔状态控制——showAdd 为 true 时渲染 addModal,showEdit 为 true 时渲染 editModal,showDel 为 true 时渲染 delModal。由于它们是 Stack 的子元素,且每个弹窗内部都是全屏的 Stack + 遮罩层 + 弹窗主体,所以当任一弹窗显示时,它会覆盖在 Column 主体之上,遮罩层遮挡背景内容,弹窗主体浮在遮罩层之上。

三个弹窗使用独立的 if 条件而非 if-else if 链,这意味着理论上可以有多个弹窗同时显示(如 showAdd 和 showEdit 同时为 true)。但在实际交互中,每次只有一个弹窗被触发(用户不可能同时点击新增和修炼),所以不会出现多个弹窗同时显示的情况。如果万一出现,后声明的弹窗会覆盖先声明的弹窗。

根 Stack 的宽高均为 100%,背景色为 COLORS.bg,确保整个屏幕都被暖米色背景覆盖,不会出现任何白色或黑色默认背景的闪烁。build 方法返回这个 Stack,作为组件的渲染输出。每次任何 @State 变量变化时,build 方法会被重新调用,UI 树根据新的状态值进行增量更新。


二十三、状态管理深度分析

23.1 @State 状态装饰器

本应用中大量使用了 @State 装饰器,它是 ArkTS 状态管理体系中最基础也最核心的装饰器。@State 修饰的变量具备以下特性:第一,变量值变化时自动触发所在组件的 build 方法重新执行;第二,变量可以是基本类型(number、string、boolean)或引用类型(对象、数组);第三,对于数组类型,@State 能感知数组的长变化(push、splice、pop 等)并触发重渲染;第四,对于对象类型,@State 能感知对象属性的直接赋值变化并触发重渲染。

本应用中的 @State 变量可以分为三类:交互控制状态(curTab、breath、showAdd、showEdit、showDel)、选中项状态(selTech、selOrder)、表单状态(formName、formKind、formPrice)和数据列表状态(kongzhus、techs、tricks、artists、steps、orders)。交互控制状态是最简单的布尔/数值状态,值变化时直接控制 UI 的显示隐藏或内容切换。选中项状态使用联合类型 T | null,允许空值表示未选中。表单状态为字符串类型,与 TextInput 的 onChange 回调配合实现数据输入。数据列表状态是最复杂的——它们是 @Observed 类的实例数组,既有数组层面的增删(push 新增空竹、splice 删除订单),又有元素层面的属性修改(selTech.level 变化)。

23.2 @Observed 与 @State 的协作

@Observed@State 是 ArkTS 响应式体系的两大支柱,它们的协作机制值得深入理解。@State 负责组件级别的状态响应——当 @State 变量本身变化时(如 curTab 从 0 变为 1),组件重渲染。@Observed 负责对象级别的属性响应——当 @Observed 类的实例属性变化时(如 selTech.level 从 85 变为 90),所有引用了该实例的 UI 组件重渲染。

在本应用中,techRow 构建器引用了 item.level,其中 item 来自 this.techs 数组。当用户在修炼弹窗中修改 this.selTech.level 时,由于 selTech 和 techs 中的某个元素是同一个对象引用(selTech 是从 techRow 的 onClick 中赋值的 this.selTech = item),所以修改 selTech.level 就是修改 techs 数组中对应元素的 level 属性。@Observed 机制检测到这一属性变化,通知 techRow 重渲染,进度条宽度从 skillBarW(85) 更新为 skillBarW(90)。整个流程无需手动操作 DOM 或调用刷新方法,完全由框架的响应式系统自动完成。

23.3 @Prop 与 @ObjectLink 的缺失

值得注意的是,本应用没有使用 @Prop@ObjectLink 装饰器。在 ArkTS 的完整状态管理体系中,@Prop 用于父组件向子组件单向传递值类型数据,@ObjectLink 用于子组件引用父组件传递的 @Observed 对象。这两个装饰器主要用于多组件架构中的状态传递。

本应用没有使用它们,是因为所有 UI 逻辑都集中在 KongzhuPage 一个组件中,所有 @Builder 方法都是该组件的内部方法,直接访问组件的 this 状态,不需要跨组件传递数据。这是一个"单组件应用"的架构特征——简单直接,但随着功能增长会导致组件过于庞大。在工程实践中,当组件超过 500 行时应当考虑拆分,将每个标签页拆为独立的子组件,使用 @Prop@ObjectLink 进行状态传递。


二十四、布局样式深度分析

24.1 Flex 布局体系

ArkTS 的布局系统基于 Flex 弹性盒子模型。本应用中大量使用了 Row(水平 Flex)和 Column(垂直 Flex)两种容器。Row 的子元素默认从左到右排列,Column 的子元素默认从上到下排列。layoutWeight 是 Flex 布局中的弹性权重——设置 layoutWeight(1) 的元素会占据父容器中其他非弹性元素剩余的所有空间。

在 kongzhuRow 中,名称和类型标签是固定宽度的非弹性元素,中间的 Column 设置 layoutWeight(1) 占据剩余空间,最右侧的价格也是固定宽度。这种"固定-弹性-固定"的布局模式使得中间内容区域可以自适应屏幕宽度,而左右两侧的关键信息始终保持固定位置。在 orderChart 中,每个柱子的 Column 都设置了 layoutWeight(1),使所有柱子等分宽度,无论柱子数量多少都能均匀排列。

justifyContentalignItems 是 Flex 布局的两个对齐属性。justifyContent 控制主轴方向的对齐——Row 的主轴是水平方向,justifyContent(FlexAlign.Center) 使子元素水平居中(如 bottomItem 中的图标和文字居中)。alignItems 控制交叉轴方向的对齐——Row 的交叉轴是垂直方向,alignItems(VerticalAlign.Bottom) 使子元素底部对齐(如 orderChart 中的柱子底部对齐)。

24.2 绝对定位与 Stack 堆叠

position 属性提供绝对定位能力,使元素脱离 Flex 流的约束,在父容器中以指定坐标渲染。本应用在 pageHeader 中大量使用 position——头部装饰图中的所有元素(火花、抖杆、旋转体、粒子、绳线、标题、统计数字)都使用绝对定位精确放置在 128 像素高的 Stack 画布上。

Stack 是绝对定位的天然容器——Stack 中所有子元素默认从同一位置(左上角或居中)开始堆叠,配合 position 可以将每个元素精确放置到指定坐标。这种"Stack + position"的模式本质上是一种"画布式"的 UI 绘制方式,与 Flex 的"流式布局"互补——Flex 适合结构化的列表、表单等有规律的布局,position 适合自由的、需要精确控制的图形化布局。

24.3 间距与圆角体系

本应用的间距体系遵循"4 像素网格"原则——所有间距值(padding、margin)都是 4 的倍数或接近 4 的倍数。常见的间距值有 4、8、10、12、14、16、18 像素。这种网格化的间距体系使得不同组件之间的间距比例关系一致,视觉上和谐统一。

圆角体系同样有规律可循——小元素(标签、按钮)使用 8-10 像素圆角,中元素(列表卡片、图表)使用 12 像素圆角,大元素(头部、弹窗)使用 16-22 像素圆角。圆角大小与元素尺寸成正比,符合"大元素大圆角、小元素小圆角"的设计原则。底部栏的"上圆下直"圆角是一种特殊情况——上方圆角是为了与内容区域形成柔和过渡,下方直角是因为底部紧贴屏幕边缘无需圆角。


二十五、Mermaid 流程图

25.1 应用状态流转图

下图展示了用户在应用中的主要交互流程与状态变化:

点击底部标签

点击技法年数

苦练+5%

巩固-3%

取消

点击订单删除

确认删除

取消

点击遮罩或确认

应用启动

aboutToAppear 启动breath定时器

渲染pageHeader + tabContent + bottomBar

用户操作

更新curTab

tabContent重渲染 切换标签内容

设置selTech 设置showEdit=true

渲染修炼弹窗

用户选择

level = min100, level+5

level = max50, level-3

showEdit=false

showEdit=false Observed触发techRow重渲染

设置selOrder 设置showDel=true

渲染删除弹窗

用户选择

orders.splice 删除订单

showDel=false

showDel=false ForEach更新列表和图表

关闭对应弹窗

25.2 数据模型与UI渲染关系图

下图展示了六个 @Observed 数据模型与对应 UI 组件之间的渲染关系:

渲染错误: Mermaid 渲染失败: Parse error on line 12: ...graph 状态管理层 SK[@State kongzhus] ----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

25.3 页面构建层次图

下图展示了 build 方法的组件层次与渲染层级:

0

1

2

3

4

5

Stack 根容器 width100% height100%

Column 主体

if showAdd 新增弹窗

if showEdit 修炼弹窗

if showDel 删除弹窗

Scroll 可滚动区域 layoutWeight1

bottomBar 底部导航栏

Column 内容区 padding 14px

pageHeader 头部图 height 128px

tabContent 标签内容

渐变背景层

火花粒子层 ForEach SPARK_IDX

抖杆层 4个Column

空竹旋转体 Stack 3层圆

旋转粒子层 ForEach SPIN_IDX

丝绳层 ForEach STRING_IDX

标题与统计 4组Row

curTab判断

空竹列表 ForEach kongzhus

技法列表 ForEach techs

花样列表 ForEach tricks

艺人列表 ForEach artists

工序列表 ForEach steps

柱状图+订单列表 orderChart+ForEach orders

Row 第一行 ForEach ROW1_IDX

Row 第二行 ForEach ROW2_IDX

bottomItem 0/1/2

bottomItem 3/4/5


二十六、技术对比表格

26.1 状态装饰器对比

装饰器作用范围触发条件数据流向本应用使用场景适用场景
@State组件内部变量值变化单组件内curTab, breath, showAdd, kongzhus 等组件私有的响应式状态
@Observed类级别实例属性变化通知所有观察者KongzhuItem, TechItem 等 6 个类需要跨组件响应的对象类型
@Prop父到子单向父组件传递值变化父到子单向未使用子组件接收父组件的值类型数据
@ObjectLink父到子双向@Observed 对象属性变化父到子双向未使用子组件接收父组件的对象引用
@Provide祖先到后代祖先组件值变化跨层级向下未使用全局主题、用户信息等
@Consume后代到祖先祖先 @Provide 变化跨层级向上未使用接收祖先 @Provide 的数据
@StorageLink持久化存储本地存储值变化双向持久化未使用需要跨页面持久化的状态

26.2 UI 构建方式对比

构建方式语法形式复用性参数传递性能特征本应用使用情况
@Builder@Builder funcName(params) { ... }组件内复用支持参数内联展开,无组件实例全部 UI 片段(13个Builder)
@Component@Component struct Xxx { ... }跨文件复用通过属性传递独立组件实例,有生命周期仅 KongzhuPage 1个
@BuilderParam@BuilderParam content: () => void插槽式复用接收Builder函数延迟执行未使用
全局Builder@Builder function globalFunc()全局复用支持参数内联展开未使用

26.3 布局容器对比

容器排列方向对齐控制弹性分配本应用使用场景
Row水平justifyContent + alignItemslayoutWeight卡片行、按钮组、统计数字
Column垂直alignItems + justifyContentlayoutWeight页面主体、卡片内容、弹窗
Stack堆叠position绝对定位不适用头部画布、根容器、弹窗容器
Scroll可滚动不直接控制对齐layoutWeight内容滚动区域
Flex可配置方向justifyContent + alignItemsflexWeight未直接使用(Row/Column为其语法糖)
Grid网格rowStart/columnStart不适用未使用(用ForEach+Row模拟网格)
List列表不直接控制对齐不适用未使用(用Scroll+ForEach替代)

26.4 动画机制对比

动画方式触发机制控制粒度代码复杂度本应用使用情况
属性动画 .animation()属性值变化时触发单属性级别全部动画(透明度、旋转、颜色)
显式动画 animateTo()代码显式调用闭包内多属性未使用
@AnimatableExtend自定义可动画属性自定义属性级别未使用
转场动画组件插入/删除时组件级别未使用
setInterval驱动定时器周期触发全局状态驱动breath布尔切换驱动所有动画

26.5 颜色映射函数对比

函数名输入类型映射逻辑输出颜色应用场景
gradeColorstring (花样等级)绝活到hot红, 上乘到accent棕, 入门到cool绿3级颜色trickRow 花样等级标签
kindColorstring (空竹类型)单轮到accent棕, 双轮到hot红2级颜色kongzhuRow 类型标签
titleColorstring (艺人头衔)大师到hot红, 名家到accent棕, 其他到cool绿3级颜色artistRow 头衔标签
stepColornumber (工序序号)小于等于3到walnutA棕, 小于等于6到gold金, 大于6到hot红3段颜色stepRow 序号徽章
speedBarWnumber (转速)线性映射 18+v乘0.5宽度像素kongzhuRow 转速进度条
skillBarWnumber (掌握度/技艺/难度)线性映射 16+s乘1.1宽度像素多个进度条
craftBarWnumber (工序数据)线性映射 16+d乘1.6宽度像素未实际使用(预留)
orderBarHnumber (订单金额)截断加线性 24加min(a,50000)/500高度像素orderChart 柱状图

二十七、关键技术要点总结

27.1 声明式 UI 范式的核心价值

本应用完整地展示了 ArkTS 声明式 UI 范式的核心价值——“状态驱动 UI”。整个应用的交互逻辑可以归纳为一个简单而强大的循环:用户操作→修改状态→框架自动重渲染。用户点击底部标签,curTab 从 0 变为 1,tabContent 自动切换显示技法列表;用户点击"苦练 +5%“,selTech.level 从 85 变为 90,techRow 的进度条自动更新宽度。开发者无需手动操作 DOM、无需调用刷新方法、无需管理渲染时序,只需声明"状态是什么"和"UI 应该长什么样”,框架自动完成状态到 UI 的映射。

这种范式带来的最大好处是代码的可预测性和可维护性。在传统的命令式 UI 中,开发者需要手动追踪"哪个 UI 元素需要更新"“什么时候更新”“按什么顺序更新”,随着交互复杂度的增长,这些追踪会变得极其困难。而在声明式 UI 中,开发者只需关心"状态正确与否"——只要状态正确,UI 就一定正确,因为 UI 是状态的函数,状态变则 UI 变,状态不变则 UI 不变。这种"状态即真理"的设计哲学极大地降低了复杂交互应用的开发与调试成本。

27.2 @Observed 与 @State 的双层响应体系

本应用的响应式体系由 @State 和 @Observed 两层构成,各自负责不同层级的响应。@State 负责组件级状态——当 curTab、breath、showAdd 等组件私有状态变化时,触发组件的 build 方法重新执行。@Observed 负责对象级状态——当 KongzhuItem、TechItem 等 @Observed 类的实例属性变化时,通知所有引用了该属性的 UI 片段重渲染。

这两层的协作在本应用中有一个经典场景:修炼技法。用户点击技法行的年数文字,设置 selTech = item(item 来自 techs 数组,是同一个对象引用)和 showEdit = true。showEdit 是 @State,触发修炼弹窗渲染。用户点击"苦练 +5%",执行 selTech.level = Math.min(100, selTech.level + 5)。由于 selTech 是 @Observed 类 TechItem 的实例,level 属性变化触发 @Observed 机制,通知 techRow 中引用了 item.level 的进度条和文字重渲染。同时,showEdit 设为 false(@State 触发),关闭弹窗。最终效果:弹窗关闭,列表中对应技法的进度条和掌握度文字已自动更新为新值。整个过程涉及 @State 和 @Observed 两个层面的响应,但开发者只需修改两个变量(selTech.level 和 showEdit),框架自动协调所有 UI 更新。

27.3 @Builder 构建器的复用策略

本应用使用了十三个 @Builder 方法,涵盖了从头部装饰图到列表行、从底部导航到模态弹窗的所有 UI 片段。@Builder 的复用策略有两种:无参构建器(如 pageHeader、bottomBar)用于封装固定的 UI 片段,有参构建器(如 kongzhuRow(item)、modalOverlay(onClose))用于参数化复用。

有参构建器的参数化复用是减少代码重复的关键手段。六个列表行构建器(kongzhuRow、techRow、trickRow、artistRow、stepRow、orderRow)虽然接收不同类型的数据项,但它们的整体结构高度一致——都是 Row 容器 + 左侧标题 + 类型标签 + 中间数据 + 右侧数值 + 白底圆角卡片。如果使用 @Component 子组件,可以定义一个通用的 ListRow 组件接收这些参数;但使用 @Builder,可以在不增加组件层级的前提下实现类似的复用效果。modalOverlay 的参数化复用更为典型——三个弹窗共用同一个遮罩层构建器,只需传入不同的 onClose 回调。这种"构建器+回调参数"的模式在 ArkTS 中是封装可复用 UI 片段的标准做法,它介于"完全内联"和"独立组件"之间,在代码简洁性和复用性之间取得了良好的平衡。

27.4 动画驱动的单状态模式

本应用的所有动画都由一个 @State 布尔变量 breath 驱动,这是一个非常简洁高效的动画驱动模式。breath 以 470 毫秒为周期在 true 和 false 之间交替,所有动画元素的 opacity、rotate、backgroundColor 等属性都绑定到 breath 的二元状态,配合各元素自身的 animation 配置(不同的 duration、Alternate 模式),产生丰富多变却协调统一的动画效果。

这种单状态驱动多动画的模式有三个优势:第一,状态管理简单——只需维护一个布尔变量,无需为每个动画元素独立管理状态;第二,性能开销低——每 470 毫秒一次的状态切换只触发一次组件重渲染,所有动画元素的重渲染在一次 build 中完成,而非分散在多个独立的时间节点;第三,视觉效果协调——所有动画共享同一个"呼吸"节奏,虽然各元素的 animation duration 不同(600ms、650ms、750ms、800ms),但它们都在同一个 breath 驱动下运动,形成统一的韵律感。

这种模式也有其局限:所有动画严格同步于 breath 的切换时刻,无法实现"依次延迟启动"的波浪式效果。在订单柱状图中,所有柱子同步变色就是这个局限的体现——如果希望柱子从左到右依次变色,需要为每个柱子维护独立的延迟状态,这会增加复杂度。在简洁性与效果丰富度之间的权衡是动画设计中的永恒主题,本应用选择了简洁性优先的策略。

27.5 色彩语义体系的设计哲学

本应用的色彩体系不是随意搭配的,而是建立在一套完整的语义映射体系之上。从宏观层面看,整个应用采用暖色调为主色系——页面背景是暖米色、头部是深棕色渐变、卡片是白色与暖色文字的组合,这种暖色调与空竹的竹木材质形成视觉呼应,传达出传统手工艺的温度感与厚重感。

从微观层面看,每一组颜色都有明确的语义指向。花样等级的"红-棕-绿"对应"绝活-上乘-入门"的高中低三档;艺人头衔使用相同的映射策略保持一致性;工序序号的"棕-金-红"三段递进暗示了从原材料处理到成品精修的工艺深化过程。空竹类型的"棕-红"区分和技法掌握度 90% 阈值的"红-金"切换,都是将业务语义编码为视觉色彩的具体实践。

这种"色彩即语义"的设计哲学使得用户在浏览列表时,无需阅读文字就能通过颜色快速获取关键信息——红色标签意味着高等级或重要警示,绿色标签意味着入门或正面操作,金色标签意味着中等或装饰性强调。颜色从纯粹的装饰元素升华为信息传达的载体,这是从"好看"到"好用"的设计跃迁。

27.6 数据可视化在移动端的轻量实现

本应用在订单标签页中实现了一个轻量级的柱状图组件 orderChart,它完全用 ArkTS 声明式 UI 的基础组件(Row、Column、ForEach)构建,没有引入任何图表库。柱状图的核心实现逻辑是:ForEach 遍历数据数组,为每个数据项创建一个 Column 容器,容器内包含一个高度与数据值成正比的矩形 Column 和一个标签 Text。所有柱子通过 layoutWeight(1) 等分宽度,通过 alignItems(VerticalAlign.Bottom) 底部对齐。

这种轻量级数据可视化的实现方式有几个优点:第一,零依赖——不需要引入第三方图表库,减少包体积和依赖管理复杂度;第二,完全可定制——柱子的颜色、圆角、标签样式都可以直接用 ArkTS 样式属性控制,与图表库的配置 API 相比更加灵活;第三,响应式集成——柱状图的数据来自 @State 数组,当数组变化(如删除订单)时柱状图自动更新,无需手动调用图表刷新方法。缺点是对于复杂的图表类型(如折线图、饼图、散点图)实现难度较大,此时引入专业图表库更为合理。

27.7 模态弹窗的架构模式

本应用的三个模态弹窗(新增空竹、修炼技法、删除订单)共享一套统一的架构模式:条件渲染控制显示隐藏、Stack+遮罩层+主体Column的三层结构、modalOverlay复用遮罩层、统一的尺寸和定位参数。这种模式的一致性使得三个弹窗在视觉上统一、在代码结构上对称、在维护上便捷。

三个弹窗虽然结构一致,但交互逻辑各有特色。新增弹窗使用三个 TextInput 进行表单输入,提交时通过 push 添加数据;修炼弹窗使用两个操作按钮直接修改 @Observed 对象属性,利用 Math.min/max 进行边界保护;删除弹窗使用 indexOf+splice 删除数组元素,通过引用匹配定位目标。这三种交互模式分别代表了"新增"“修改”"删除"三种最常见的数据操作,它们的实现方式可以作为 ArkTS 声明式 UI 中 CRUD 操作的参考范式。

27.8 传统文化与现代技术的融合表达

本应用不仅是一个技术实现,更是一次传统文化与现代技术融合表达的尝试。从空竹的历史文化背景到十二款空竹的命名,从八大技法的文学化描述到八种花样的诗意名称,从八位艺人的师承梯度到八道工序的精工要点,每一个数据元素都承载着空竹文化的内涵。色彩体系中的核桃木色、金色、暖米色呼应了竹木材质与传统工艺的视觉调性;头部装饰图用纯代码绘制了一幅空竹旋转的动态画面,将物理世界的抖空竹动作抽象为数学函数驱动的动画效果。

这种融合表达的核心在于:技术不仅是实现功能的工具,更是传达文化的载体。当用户在这个应用中浏览空竹名品、修炼技法、查看花样、了解艺人、学习工序、管理订单时,他们不仅在使用一个管理工具,更是在与空竹文化进行一次数字化的对话。声明式 UI 的"状态驱动"范式在这里找到了一个恰切的隐喻——正如空竹在绳上的旋转由手腕的微小抖动驱动,UI 的更新由状态的微小变化驱动;正如空竹艺人的技艺在日复一日的苦练中精进,应用的交互在每一次状态变化中完善。技术与文化在底层逻辑上找到了共鸣,这正是本应用设计的深层意图。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  walnutA: string;
  walnutB: string;
  stringC: 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: '#FBF3E7',
  cardBg: '#FFFFFF',
  header1: '#7A4A2B',
  header2: '#3A2112',
  walnutA: '#A9713F',
  walnutB: '#D9B38A',
  stringC: '#E8D8C4',
  gold: '#E3B23C',
  title: '#FBEFDC',
  sub: '#DCC09C',
  text1: '#5A351F',
  text2: '#7A5A3E',
  text3: '#A58E74',
  accent: '#7A4A2B',
  hot: '#C0392B',
  cool: '#5D8A5D',
  danger: '#D9534F',
  tabBg: '#7A4A2B',
  tabOn: '#E3B23C',
  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 SPIN_IDX: number[] = [0, 1, 2, 3, 4, 5];
const STRING_IDX: number[] = [0, 1, 2, 3, 4, 5];
const SPARK_IDX: number[] = [0, 1, 2, 3];

function spinX(i: number): number {
  return 120 + i * 26;
}

function spinY(i: number): number {
  return 90 - (i % 2) * 30;
}

function spinSize(i: number): number {
  return 10 + (i % 3) * 5;
}

function stringX(i: number): number {
  return 170 + (i - 2.5) * 34;
}

function stringY(i: number): number {
  return 66 - Math.abs(i - 2.5) * 10;
}

function sparkX(i: number): number {
  return 228 + i * 18;
}

function sparkY(i: number): number {
  return 30 + (i % 2) * 16;
}

function speedBarW(v: number): number {
  return 18 + v * 0.5;
}

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 gradeColor(g: string): string {
  if (g === '绝活') {
    return COLORS.hot;
  }
  if (g === '上乘') {
    return COLORS.accent;
  }
  if (g === '入门') {
    return COLORS.cool;
  }
  return COLORS.text3;
}

function kindColor(k: string): string {
  if (k === '单轮') {
    return COLORS.accent;
  }
  if (k === '双轮') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

function titleColor(t: string): string {
  if (t === '大师') {
    return COLORS.hot;
  }
  if (t === '名家') {
    return COLORS.accent;
  }
  return COLORS.cool;
}

function stepColor(s: number): string {
  if (s <= 3) {
    return COLORS.walnutA;
  }
  if (s <= 6) {
    return COLORS.gold;
  }
  return COLORS.hot;
}

@Observed
export class KongzhuItem {
  name: string;
  kind: string;
  speed: number;
  size: string;
  price: number;

  constructor(name: string, kind: string, speed: number, size: string, price: number) {
    this.name = name;
    this.kind = kind;
    this.speed = speed;
    this.size = size;
    this.price = price;
  }
}

@Observed
export class TechItem {
  name: string;
  type: string;
  level: number;
  years: number;
  desc: string;

  constructor(name: string, type: string, level: number, years: number, desc: string) {
    this.name = name;
    this.type = type;
    this.level = level;
    this.years = years;
    this.desc = desc;
  }
}

@Observed
export class TrickItem {
  name: string;
  grade: string;
  difficulty: number;
  need: string;
  note: string;

  constructor(name: string, grade: string, difficulty: number, need: string, note: string) {
    this.name = name;
    this.grade = grade;
    this.difficulty = difficulty;
    this.need = need;
    this.note = note;
  }
}

@Observed
export class ArtistItem {
  name: string;
  title: string;
  age: number;
  shows: number;
  skill: number;

  constructor(name: string, title: string, age: number, shows: number, skill: number) {
    this.name = name;
    this.title = title;
    this.age = age;
    this.shows = shows;
    this.skill = skill;
  }
}

@Observed
export class KongStepItem {
  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 KongOrderItem {
  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 KongzhuPage {
  @State curTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State selTech: TechItem | null = null;
  @State selOrder: KongOrderItem | null = null;
  @State formName: string = '';
  @State formKind: string = '';
  @State formPrice: string = '';
  @State kongzhus: KongzhuItem[] = [
    new KongzhuItem('雕花空竹', '双轮', 220, '直径 12cm', 128),
    new KongzhuItem('响铃空竹', '双轮', 260, '直径 14cm', 168),
    new KongzhuItem('单轮长杆竹', '单轮', 300, '直径 10cm', 156),
    new KongzhuItem('楠木空竹', '双轮', 240, '直径 13cm', 258),
    new KongzhuItem('红木小空竹', '单轮', 320, '直径 8cm', 298),
    new KongzhuItem('竹根空竹', '双轮', 210, '直径 15cm', 188),
    new KongzhuItem('彩绘空竹', '双轮', 230, '直径 11cm', 98),
    new KongzhuItem('夜光空竹', '双轮', 280, '直径 12cm', 138),
    new KongzhuItem('象牙白空竹', '单轮', 310, '直径 9cm', 388),
    new KongzhuItem('三响空竹', '双轮', 250, '直径 16cm', 218),
    new KongzhuItem('掌心小空竹', '单轮', 340, '直径 6cm', 88),
    new KongzhuItem('龙凤纹空竹', '双轮', 270, '直径 13cm', 268)
  ];
  @State techs: TechItem[] = [
    new TechItem('平抖', '基本功', 90, 2, '匀速旋转'),
    new TechItem('高抛', '抛接', 85, 3, '抛高接住'),
    new TechItem('盘丝', '绕线', 92, 4, '线缠旋转'),
    new TechItem('上碗', '平衡', 78, 3, '落入碗中'),
    new TechItem('金鸡上架', '平衡', 88, 4, '单线站立'),
    new TechItem('背后换手', '换手', 82, 3, '背身换线'),
    new TechItem('流星赶月', '绕线', 95, 5, '高速环绕'),
    new TechItem('空中飞人', '抛接', 90, 5, '双竹互抛')
  ];
  @State tricks: TrickItem[] = [
    new TrickItem('二龙戏珠', '绝活', 96, '双空竹', '双竹同舞'),
    new TrickItem('海底捞月', '绝活', 92, '单轮竹', '低处捞起'),
    new TrickItem('凤凰展翅', '上乘', 88, '双轮竹', '展开旋转'),
    new TrickItem('金蛇狂舞', '上乘', 84, '长线', '蛇形缠绕'),
    new TrickItem('旭日东升', '上乘', 86, '双轮竹', '直线抛升'),
    new TrickItem('青龙出水', '绝活', 94, '双轮竹', '从线底穿出'),
    new TrickItem('孔雀开屏', '上乘', 82, '双轮竹', '多圈环绕'),
    new TrickItem('白鹤亮翅', '入门', 76, '单轮竹', '单侧展开')
  ];
  @State artists: ArtistItem[] = [
    new ArtistItem('刘魁', '大师', 82, 3000, 99),
    new ArtistItem('张兆杰', '大师', 76, 2600, 97),
    new ArtistItem('赵景春', '名家', 58, 1800, 94),
    new ArtistItem('王铁环', '名家', 52, 1500, 92),
    new ArtistItem('李金斗', '名家', 46, 1200, 90),
    new ArtistItem('孙竹空', '艺人', 38, 800, 86),
    new ArtistItem('周响铃', '艺人', 30, 500, 81),
    new ArtistItem('吴转轮', '学徒', 22, 200, 74)
  ];
  @State steps: KongStepItem[] = [
    new KongStepItem('选竹', 2, '锯刀 · 目测', '老竹三节', 1),
    new KongStepItem('开片', 1, '劈刀', '竹片均匀', 2),
    new KongStepItem('车碗', 2, '旋床 · 刀具', '旋出碗形', 3),
    new KongStepItem('掏腔', 1, '刻刀', '内腔出音', 4),
    new KongStepItem('开音孔', 1, '钻头', '对穿孔', 5),
    new KongStepItem('合碗', 1, '胶 · 夹具', '双碗相连', 6),
    new KongStepItem('打磨', 1, '砂纸', '光滑圆润', 7),
    new KongStepItem('上油', 1, '桐油', '防裂增亮', 8)
  ];
  @State orders: KongOrderItem[] = [
    new KongOrderItem('雕花空竹', '体育用品店', 12800, 100, '2026-08'),
    new KongOrderItem('响铃空竹', '杂技团', 16800, 100, '2026-07'),
    new KongOrderItem('红木小空竹', '收藏家', 8940, 30, '2026-08'),
    new KongOrderItem('彩绘空竹', '文创店', 9800, 100, '2026-06'),
    new KongOrderItem('夜光空竹', '演出公司', 13800, 100, '2026-07'),
    new KongOrderItem('三响空竹', '庙会主办', 10900, 50, '2026-05'),
    new KongOrderItem('楠木空竹', '研学基地', 12900, 50, '2026-08'),
    new KongOrderItem('掌心小空竹', '玩具品牌', 13200, 150, '2026-06'),
    new KongOrderItem('龙凤纹空竹', '婚庆公司', 8040, 30, '2026-04'),
    new KongOrderItem('竹根空竹', '非遗馆', 9400, 50, '2026-05')
  ];

  aboutToAppear(): void {
    setInterval(() => {
      this.breath = !this.breath;
    }, 470);
  }

  @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(SPARK_IDX, (i: number) => {
          Column()
            .width(3)
            .height(3)
            .borderRadius(1.5)
            .backgroundColor(COLORS.gold)
            .opacity(this.breath ? 0.25 : 1)
            .position({ x: sparkX(i), y: sparkY(i) })
            .animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'k' + i)
        Column()
          .width(30)
          .height(4)
          .backgroundColor(COLORS.walnutB)
          .borderRadius(2)
          .position({ x: 130, y: 62 })
        Column()
          .width(30)
          .height(4)
          .backgroundColor(COLORS.walnutB)
          .borderRadius(2)
          .position({ x: 210, y: 62 })
        Column()
          .width(8)
          .height(46)
          .backgroundColor(COLORS.walnutA)
          .borderRadius(4)
          .position({ x: 122, y: 40 })
        Column()
          .width(8)
          .height(46)
          .backgroundColor(COLORS.walnutA)
          .borderRadius(4)
          .position({ x: 240, y: 40 })
        Stack() {
          Column()
            .width(52)
            .height(52)
            .borderRadius(26)
            .backgroundColor(COLORS.walnutA)
            .border({ width: 2, color: COLORS.gold })
          Column()
            .width(30)
            .height(30)
            .borderRadius(15)
            .backgroundColor(COLORS.walnutB)
          Column()
            .width(10)
            .height(10)
            .borderRadius(5)
            .backgroundColor(COLORS.stringC)
            .position({ x: 21, y: 21 })
        }
        .rotate({ angle: this.breath ? 40 : 0 })
        .animation({ duration: 800, iterations: -1, playMode: PlayMode.Alternate })
        .position({ x: 158, y: 36 })
        ForEach(SPIN_IDX, (i: number) => {
          Column()
            .width(spinSize(i))
            .height(spinSize(i))
            .borderRadius(spinSize(i) / 2)
            .backgroundColor(COLORS.gold)
            .opacity(this.breath ? 0.25 : 0.8)
            .position({ x: spinX(i), y: spinY(i) })
            .animation({ duration: 750, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'p' + i)
        ForEach(STRING_IDX, (i: number) => {
          Column()
            .width(2)
            .height(10 + (i === 0 || i === 5 ? 4 : 0))
            .backgroundColor(COLORS.stringC)
            .borderRadius(1)
            .position({ x: stringX(i), y: stringY(i) })
            .rotate({ angle: this.breath ? 10 : -10 })
            .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'g' + 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
  kongzhuRow(item: KongzhuItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.kind)
        .fontSize(9)
        .fontColor(COLORS.cardBg)
        .backgroundColor(kindColor(item.kind))
        .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(speedBarW(item.speed))
            .height(6)
            .borderRadius(3)
            .backgroundColor(COLORS.walnutA)
            .margin({ left: 6 })
          Text(item.speed + '转/分')
            .fontSize(9)
            .fontColor(COLORS.accent)
            .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
  techRow(item: TechItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.type)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(COLORS.cool)
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text('掌握 ' + item.level + '% · ' + item.desc)
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.level))
          .height(6)
          .borderRadius(3)
          .backgroundColor(item.level >= 90 ? COLORS.hot : COLORS.gold)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text(item.years + '年')
        .fontSize(10)
        .fontColor(COLORS.text3)
        .onClick(() => {
          this.selTech = item;
          this.showEdit = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  trickRow(item: TrickItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.grade)
        .fontSize(9)
        .fontColor(COLORS.cardBg)
        .backgroundColor(gradeColor(item.grade))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text(item.need + ' · ' + item.note)
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.difficulty))
          .height(5)
          .borderRadius(2)
          .backgroundColor(COLORS.walnutB)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text('难度 ' + item.difficulty)
        .fontSize(10)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.hot)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  artistRow(item: ArtistItem) {
    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.shows + ' 场')
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.skill))
          .height(6)
          .borderRadius(3)
          .backgroundColor(COLORS.walnutA)
          .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: KongStepItem) {
    Row() {
      Text(item.seq + '')
        .fontSize(13)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.cardBg)
        .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: KongOrderItem) => {
        Column() {
          Column()
            .width(12)
            .height(orderBarH(item.amount))
            .borderRadius(3)
            .backgroundColor(this.breath ? COLORS.gold : COLORS.walnutA)
            .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: KongOrderItem) => 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: KongOrderItem) {
    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.kongzhus, (item: KongzhuItem) => {
          this.kongzhuRow(item)
        }, (item: KongzhuItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 1) {
      Column() {
        Text('八大技法 · 点击可修炼精进')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.techs, (item: TechItem) => {
          this.techRow(item)
        }, (item: TechItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 2) {
      Column() {
        Text('拿手花样 · 从入门到绝活')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.tricks, (item: TrickItem) => {
          this.trickRow(item)
        }, (item: TrickItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 3) {
      Column() {
        Text('抖空竹艺人 · 一代一代传')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.artists, (item: ArtistItem) => {
          this.artistRow(item)
        }, (item: ArtistItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 4) {
      Column() {
        Text('制竹八序 · 竹木精工出妙音')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.steps, (item: KongStepItem) => {
          this.stepRow(item)
        }, (item: KongStepItem) => 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: KongOrderItem) => {
          this.orderRow(item)
        }, (item: KongOrderItem) => 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 ? '#96582F' : 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.formKind, placeholder: '如:双轮' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formKind = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Column() {
            Text('价格(元)')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formPrice, placeholder: '如:168' })
              .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.kongzhus.push(new KongzhuItem(this.formName, this.formKind, 240, '直径 12cm', 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.selTech) {
            Text(this.selTech.name + ' · 掌握 ' + this.selTech.level + '%')
              .fontSize(12)
              .fontColor(COLORS.text2)
              .margin({ top: 6 })
          }
          Text('每日苦练,技法精进')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
          Row() {
            Text('苦练 +5%')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.hot)
              .borderRadius(10)
              .onClick(() => {
                if (this.selTech) {
                  this.selTech.level = Math.min(100, this.selTech.level + 5);
                }
                this.showEdit = false;
              })
            Text('巩固 -3%')
              .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.selTech) {
                  this.selTech.level = Math.max(50, this.selTech.level - 3);
                }
                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)
  }
}


27.9 总结与展望

在这里插入图片描述

通过对这个空竹坊管理应用的逐段逐行深度解析,我们完整地审视了 ArkTS 声明式 UI 开发的核心技术与设计理念。从 ColorPalette 接口的类型契约到 COLORS 常量的二十色暖调体系,从 TabMeta 的元数据驱动到六个标签的功能模块划分,从六个 @Observed 数据模型到十二个 @State 状态变量的分层管理,从十三个 @Builder 构建器的参数化复用到 build 方法的页面总装配,从 breath 单状态驱动的多元素动画到 modalOverlay 的遮罩层复用,每一个技术决策都体现了声明式 UI 范式的核心原则——状态是唯一的真相来源,UI 是状态的函数映射。

在架构层面,可以将 KongzhuPage 拆分为六个独立的标签页子组件,使用 @Prop 和 @ObjectLink 进行状态传递,降低单组件复杂度;在数据层面,可以将初始数据从硬编码迁移到本地存储或远程接口,实现数据的持久化和同步;在交互层面,可以为列表项添加滑动删除、长按菜单等更丰富的手势交互;在可视化层面,可以引入更多图表类型(如折线图展示月度趋势、饼图展示类型占比);在动画层面,可以为每个动画元素添加独立的延迟参数,实现更自然的波浪式动画效果;在无障碍层面,可以添加语义化标签、字体大小适配和色彩对比度优化,确保应用对所有用户友好。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐