第一章:司南指南——千年指南针的历史文化背景

在这里插入图片描述

一、司南溯源:从天然磁石到文明之光

在中国古代科技史的长河中,司南无疑是最为璀璨的发明之一。它不仅是中华民族对世界文明的伟大贡献,更是人类认识自然、利用自然的智慧结晶。司南的起源可以追溯到战国时期,距今已有两千余年的历史。据《论衡》记载:"司南之杓,投之于地,其柢指南。"这短短十二个字,记录了人类最早利用磁石指极性来确定方向的伟大尝试。古人将天然磁石琢磨成勺形,放置在光滑的青铜地盘上,勺柄便会自然指向南方。这便是"司南"之名由来——"司"者,掌管也;“南"者,方向也。司南即为"掌管南方之器”,它以最朴素的方式,实现了人类对方向的精确感知。

司南的诞生并非偶然。中国先民对磁现象的认识可以追溯到更早的时期。在春秋战国时期,人们已经在开采铁矿的过程中发现了磁石(即磁铁矿,Fe3O4)的奇妙性质。磁石能够吸引铁器,这种"慈石引铁"的现象被古人形象地比喻为母子相吸——“慈石"之名由此而来,后逐渐演变为"磁石”。更为神奇的是,人们发现经过特定方式加工的磁石具有稳定的指极性,即无论怎样放置,它总是指向同一方向。这一发现是划时代的,它意味着人类第一次拥有了不依赖天象(日影、星辰)、不受天气影响的定向手段。

二、从司南到罗盘:技术演进的历史脉络

在这里插入图片描述

司南的技术演进经历了一个漫长而辉煌的过程。战国时期的天然磁石勺是最初形态,但由于天然磁石磁性较弱,且琢磨工艺难度极大,其实用性受到一定限制。到了汉代,工匠们开始尝试用人工磁化的方法替代天然磁石,这为后世指南针的发展奠定了基础。汉代司南已经发展出较为成熟的青铜圆盘形制,盘面刻有方位文字和刻度,配合磁勺使用,形成了一套完整的定向工具体系。

唐宋时期是指南针技术发生革命性飞跃的关键阶段。北宋时期,曾公亮在《武经总要》中记载了"指南鱼"的制作方法:将薄铁片剪成鱼形,经高温烧红后蘸水淬火,使其磁化,然后浮于水面指示方向。这是人类历史上最早的人工磁化实践之一。随后,沈括在《梦溪笔谈》中详细记录了四种指南针的使用方法:水浮法、指甲旋定法、碗唇旋定法和缕悬法,其中水浮法即为后来广泛使用的水浮指南针。沈括还首次发现了磁偏角现象——磁针所指并非正南正北,而是存在微小偏差,这一发现比欧洲早了约四百年。

宋代指南针的成熟直接催生了航海事业的黄金时代。指南针应用于航海后,船只不再需要依靠沿岸地标和天象导航,可以深入远洋,开辟新的航线。北宋末期,中国海船已经广泛使用指南针导航,朱彧在《萍洲可谈》中记载:"舟师识地理,夜则观星,昼则观日,阴晦观指南针。"这一技术随后经阿拉伯人传至欧洲,对大航海时代的到来产生了深远影响。南宋时期,罗盘(即有刻度盘的指南针)已经发展成熟,出现了将磁针与方位盘合为一体的旱罗盘,以及磁针浮于水中的水罗盘两种形制。

三、罗盘的多元发展:航海、堪舆与天文

到了元明清时期,罗盘技术进入了多元化发展阶段。在航海领域,铜轴木盘的旱罗盘因其坚固耐用、不受颠簸影响而逐渐成为主流,取代了易受水面波纹干扰的水罗盘。明代郑和七下西洋,其庞大船队所依仗的核心导航工具正是精密的航海罗盘。明代的航海罗盘在盘面设计上更加精细,将三百六十度与天干地支、二十四方位结合,形成了具有中国特色的航海罗盘体系。

在堪舆(风水)领域,罗盘的发展更是达到了登峰造极的程度。清代堪舆罗盘(又称罗经)拥有多层同心圆盘,每层刻有不同体系的内容:天干、地支、八卦、二十四山、二十八宿、六十甲子、分金刻度等,层次多达数十层。这种堪舆罗盘不仅是指向工具,更是一个浓缩了中国古代天文、地理、历法、术数知识的复合信息系统。堪舆师通过罗盘测定方位、判断吉凶,使罗盘承载了远超导航功能的文化意义。

与此同时,二十八宿罗盘将中国古代天文学独特的二十八星宿体系融入盘面设计。二十八宿是中国古代天文学家将天球赤道附近的天空划分为二十八个区域,每个区域以一个星宿命名,用于观测日月五星的运行轨迹。将其刻入罗盘,使得罗盘成为天文观测与大地测量相结合的精密仪器。明代雕漆铜芯的二十八宿罗盘,其工艺之精巧、刻度之繁复,堪称古代精密仪器制造的巅峰之作。

四、匠师精神与制器之道

司南与罗盘的制造,凝聚了无数匠师的心血与智慧。从采石到成器,每一道工序都需要精湛的技艺和严谨的态度。采石是第一步,匠师需深入山中寻觅品质上乘的天然磁石,磁石磁性之强弱、质地之纯杂,全凭匠师多年积累的经验来判断。琢勺是将磁石磨制为勺形的关键工序,需在水中反复研磨,既保证勺形的对称美观,又要保持磁石的磁力不被破坏。铸盘是制作承载磁勺的青铜圆盘,涉及熔炼、浇铸、打磨等金属工艺,盘面的平整度直接影响磁勺转动的灵敏度。

刻纹是在盘面上刻画方位文字和刻度线的工序,需要使用錾刀一丝不苟地刻制"北、东北、东、东南、南、西南、西、西北"等方位字,以及地支、天干、二十四山等刻度标记。磁化是确保磁石具有稳定指极性的核心步骤,古人通过磁石摩擦使磁勺获得或增强磁性。校准是验证磁勺指向准确性的过程,匠师利用日影(白天观测太阳投影)和夜星(夜晚观测北极星)来校准磁勺所指方向是否为正南正北。装轴是安装减少磁勺转动摩擦的铜轴和玉珠,使磁勺能够灵敏旋转。最后的试航则是将制成的司南放置在水池和帆船模型上进行实际验证,确保其在各种条件下都能准确指向。

匠师的称号体系也反映了古代工艺等级的严格划分。"大国匠"是最高称号,授予那些技艺登峰造极、作品流传百世的宗师级匠人;"巧匠"是技艺精熟、作品广受认可的资深匠师;"匠人"是经过多年磨练、能够独立完成作品的中级工匠;"学徒"则是尚在学习的初级人员。从学徒到大国匠,往往需要数十年如一日的磨砺和积累。

五、文化意象与当代价值

司南在中国文化中早已超越了器物层面,成为一个深邃的文化意象。“司南"象征着方向与指引,古人常以"司南"比喻为人处世的原则和准则——如"以德为司南”,意即以道德作为人生的方向指引。司南还象征着智慧与探索精神,它代表着古人不断探索自然规律、利用自然力量的科学精神。

在当代,司南的文化价值和技术精神依然熠熠生辉。作为中国古代"四大发明"之一指南针的源头,司南是中华民族科技自信的重要源泉。复刻古代司南、建设司南主题的文化展示平台,既是对传统工艺的保护和传承,也是向公众传播古代科技文化的有效途径。当代的磁勺复刻作品,使用天然磁石与青铜盘相结合,虽然形制古朴,却承载着两千年的科技积淀。

本项目正是以"司南坊"为主题,通过 HarmonyOS ArkTS 声明式 UI 框架,构建了一个完整的司南器物展示与管理平台。它涵盖了司南器物、八方位体系、刻度盘、匠师名录、制南工序和订单管理六大模块,将千年司南文化以现代移动应用的形式重新呈现。接下来,我们将从代码层面,逐段逐行地深入解析这份实现的全貌,探索 ArkTS 声明式 UI 的精髓。


第二章:色彩体系——ColorPalette 与 COLORS 的设计哲学

在这里插入图片描述

2.1 ColorPalette 接口定义

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  bronzeA: string;
  bronzeB: string;
  patina: 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 的接口定义。这个接口定义了整个应用中使用的所有颜色字段,共计二十一个属性。在 ArkTS(基于 TypeScript 的声明式 UI 编程语言)中,interface 关键字用于声明一个对象的结构类型,它定义了对象必须包含的属性及其类型。这里的每一个属性都是 string 类型,表示用十六进制颜色码或 RGBA 字符串来表示颜色值。

从设计角度来看,这个 ColorPalette 接口并非简单地罗列颜色名称,而是有组织、有层次地构建了一套完整的色彩系统。我们可以将这些字段大致分为几个功能组:背景色组(bgcardBg)、头部装饰色组(header1header2bronzeAbronzeBpatinagold)、文字色阶组(titlesubtext1text2text3)、功能强调色组(accenthotcooldanger)以及交互控件色组(tabBgtabOnmask)。这种分组方式使得开发者在选择颜色时有明确的方向感,而非面对一堆无序的色名不知所措。

将颜色体系统一通过接口来定义,是一种非常值得推崇的工程实践。首先,它提供了编译时类型检查——如果某个组件引用了不存在的颜色字段,TypeScript 编译器会立即报错,避免了运行时因拼写错误导致的样式失效。其次,它使得颜色管理集中化——如果将来需要切换主题或微调某个色值,只需修改一处即可全局生效,而无需在数十处代码中逐一查找替换。再者,接口本身充当了色彩设计的"文档",任何新加入项目的开发者只需阅读这个接口,就能快速了解整个应用所使用的色彩体系全貌。

2.2 COLORS 常量实例

在这里插入图片描述

const COLORS: ColorPalette = {
  bg: '#F1F4EA',
  cardBg: '#FFFFFF',
  header1: '#2E4A38',
  header2: '#14231A',
  bronzeA: '#8A6D3B',
  bronzeB: '#C9A96A',
  patina: '#6F8F6E',
  gold: '#E3C57A',
  title: '#EDF4E6',
  sub: '#B8CDA6',
  text1: '#2C3B26',
  text2: '#52664A',
  text3: '#93A488',
  accent: '#8A6D3B',
  hot: '#C9A96A',
  cool: '#6F8F6E',
  danger: '#D9534F',
  tabBg: '#2E4A38',
  tabOn: '#E3C57A',
  mask: 'rgba(0,0,0,0.45)'
};

紧接着接口定义,代码声明了一个 const 常量 COLORS,并赋值为实现了 ColorPalette 接口的对象。这个常量是整个应用色彩体系的具体实例——所有颜色字段在这里被赋予了具体的色值。const 关键字确保这个引用在声明后不可重新赋值,保证了色彩配置的不可变性,防止在运行时被意外修改。

深入分析这些色值,我们可以清晰地看到一套精心设计的"青铜与翠绿"色彩方案,与司南这一古代器物的气质高度吻合。背景色 bg#F1F4EA,这是一种接近宣纸的浅黄绿色,给人以古朴温润之感。卡片背景 cardBg 为纯白 #FFFFFF,保证了信息承载区域的清晰度和可读性。头部主色 header1#2E4A38(深墨绿)和 header2#14231A(更深的墨绿),这两色通过线性渐变形成厚重的头部背景,暗合青铜器的深沉色调。

青铜系列色是这套色彩方案的核心亮点。bronzeA#8A6D3B(深古铜色),bronzeB#C9A96A(亮古铜色),二者分别用于司南盘面的外圈和内圈、刻度文字、方位标识等关键元素,再现了青铜器物的金属质感。patina#6F8F6E(铜绿/锈色),模拟青铜器表面的氧化铜锈,用于环形装饰、进度条等细节。gold#E3C57A(金色),用于点缀星光、边框、司南勺身等亮点元素,增添华贵感。

文字色阶的设计同样层次分明。title#EDF4E6(浅黄白),用于头部标题等需要突出显示在深色背景上的文字。sub#B8CDA6(灰绿),用于副标题。正文色阶从 text1#2C3B26,深墨绿)到 text2#52664A,中墨绿)再到 text3#93A488,浅灰绿),形成了三级文字层次:主标题用 text1,次要信息用 text2,辅助说明用 text3,保证了信息层级的清晰可辨。

功能强调色方面,accentbronzeA 同值,用于价格、确认按钮等需要强调的内容。hotbronzeB 同值,用于角度数值、天数等"热度"类信息。coolpatina 同值,用于次要标签、取消按钮等"冷调"元素。danger#D9534F(红色),用于删除操作和危险提示。交互色组中,tabBgheader1 同值用于底部导航栏背景,tabOngold 同值用于选中状态的标签文字,mask 为半透明黑色 rgba(0,0,0,0.45) 用于模态遮罩层。

值得注意的一个设计技巧是:accentbronzeA 同值、hotbronzeB 同值、coolpatina 同值、tabBgheader1 同值、tabOngold 同值。这种"别名"设计意味着同一个色值在不同语义上下文中有不同的含义,虽然底层色值相同,但在代码中通过不同的语义名称引用,使得代码的可读性和可维护性大大提高。如果将来需要将 accent 改为另一种颜色而不影响 bronzeA,只需修改一处即可——这正是语义化命名的价值所在。


第三章:标签系统——TabMeta 与 TAB_LIST 的结构化设计

在这里插入图片描述

3.1 TabMeta 接口定义

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

在色彩体系定义完毕之后,代码定义了 TabMeta 接口,用于描述底部导航栏中每一个标签项的元数据。这个接口结构非常简洁,只包含两个字符串属性:labeliconlabel 用于存储标签的文字名称,如"司南"、"方位"等;icon 用于存储标签的图标符号,这里使用的是 Emoji 字符。

虽然结构简单,但这个接口的设计体现了"元数据驱动"的架构理念。所谓元数据驱动,即不将标签的内容硬编码在 UI 组件中,而是通过一个独立的数据结构来描述标签的组成,UI 组件只负责按照元数据渲染。这种解耦带来了极大的灵活性:如果将来需要在标签中增加第三种信息(比如徽标数字),只需在 TabMeta 接口中增加一个字段,修改对应的常量数据,再在渲染逻辑中读取该字段即可,而不需要重构整个标签系统。

3.2 TAB_LIST 常量数组

在这里插入图片描述

const TAB_LIST: TabMeta[] = [
  { label: '司南', icon: '🧭' },
  { label: '方位', icon: '🧿' },
  { label: '刻度', icon: '📐' },
  { label: '匠师', icon: '🔨' },
  { label: '工序', icon: '⚙️' },
  { label: '订单', icon: '📦' }
];

TAB_LIST 是一个 TabMeta 类型的数组常量,包含六个标签项。从内容来看,这六个标签分别对应应用的六个核心功能模块:司南器物展示、方位体系管理、刻度盘展示、匠师名录管理、制南工序展示和订单管理。每个标签配有一个 Emoji 图标,图标的选择颇具匠心:指南针图标直接点明司南主题,辟邪符暗示方位的神秘属性,直角三角尺对应刻度的精密测量,锤子代表匠师的铸器工具,齿轮象征工序的有序运转,包裹箱表示订单的物流配送。

使用 Emoji 作为图标而非传统矢量图标或位图,是一种务实的选择。Emoji 是 Unicode 标准字符,在所有平台上都能显示,无需额外引入图片资源,不增加应用包体积,且在不同设备上自动适配系统字体。当然,Emoji 的显示效果在不同操作系统上可能有细微差异,但对于一个展示型应用而言,这种差异是可以接受的。

将标签配置声明为 const 常量数组,意味着它在整个应用生命周期中是固定的,不可动态增减。这符合本应用的设计定位——六个功能模块是预先确定的,用户无法自定义增减标签。如果将来需要支持动态标签(如根据用户角色显示不同标签),可以将 const 改为组件内部的 @State 变量。但在当前场景下,使用常量是最简洁高效的选择。

3.3 索引数组常量

在这里插入图片描述

const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const DIR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7];
const RING_IDX: number[] = [0, 1, 2];
const STAR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6];
const TICK_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];

在标签列表之后,代码声明了一组索引数组常量。这些常量虽然看似简单,但在后续的 ForEach 渲染中扮演着关键角色。ROW1_IDXROW2_IDX 分别包含 0 到 2 和 3 到 5,用于将六个底部标签分为两行三列布局——第一行显示前三个标签(司南、方位、刻度),第二行显示后三个标签(匠师、工序、订单)。

DIR_IDX 包含 0 到 7 共八个索引,对应八方方位(北、东北、东、东南、南、西南、西、西北)。RING_IDX 包含三个索引,对应司南盘面的三层装饰环。STAR_IDX 包含七个索引,对应头部区域的七颗装饰星点。TICK_IDX 包含 0 到 11 共十二个索引,对应罗盘的十二刻度线。

将索引提取为独立常量而非在 ForEach 中直接书写数字数组,有几个好处。首先是可读性:ForEach(DIR_IDX, ...) 比直接书写数字数组更易理解意图。其次是可维护性:如果将来八方扩展为十六方位,只需修改 DIR_IDX 一处。最后是复用性:如果多个地方需要遍历同一组索引,引用同一常量即可保证一致性。

3.4 方位文字数组

const DIR_TEXT: string[] = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];

DIR_TEXT 是一个字符串数组,按顺时针顺序存储了八个方位的中文名称。索引 0 对应"北",索引 1 对应"东北",依此类推直到索引 7 对应"西北"。这个数组的顺序与 DIR_IDX 的索引严格对应,在渲染司南盘面方位文字时,通过 DIR_TEXT[i] 来获取第 i 个方位的文字。

值得注意的是,这里的方位排列遵循了"从北开始顺时针"的传统罗盘约定,且每 45 度一个方位(360 度除以 8 等于 45 度)。这与后续 dirXdirY 函数中的角度计算公式形成了精确的对应关系。将方位文字作为数据数组而非在组件中硬编码,同样是元数据驱动理念的体现——数据与表现分离,修改文字内容无需改动渲染逻辑。


第四章:辅助函数——数学计算与颜色映射的精密逻辑

4.1 方位坐标计算函数

function dirX(i: number): number {
  return 170 + 44 * Math.cos((i * 45 - 90) * Math.PI / 180);
}

function dirY(i: number): number {
  return 66 + 44 * Math.sin((i * 45 - 90) * Math.PI / 180);
}

dirXdirY 是一对配合使用的坐标计算函数,用于确定八方方位文字在司南盘面上的精确位置。这两个函数的核心是一个极坐标转直角坐标的数学变换。以 dirX 为例:170 是圆心的 X 坐标(司南盘面在容器中的水平中心位置),44 是方位文字所在的半径距离,余弦函数计算的是角度的水平分量。

角度公式 (i * 45 - 90) 的含义如下:i 是方位索引(0 到 7),乘以 45 度得到该方位在世界坐标系中的角度(0 度等于正北,90 度等于正东,180 度等于正南,270 度等于正西)。减去 90 度的偏移量是因为在屏幕坐标系中,0 度(正右方)对应数学上的 0 度,而我们需要将 0 度对应到正上方(正北)。通过减去 90 度,我们将"正北在上方"的罗盘约定转换为"正东在右方"的屏幕坐标系约定。最后乘以 Math.PI / 180 将角度从度转换为弧度,因为 JavaScript 的三角函数接受弧度参数。

dirY 函数与 dirX 完全对称,使用正弦函数替代余弦函数,圆心 Y 坐标为 66。通过这两个函数,八方方位文字被均匀地分布在以 (170, 66) 为圆心、44 为半径的圆周上。这种参数化的位置计算方式非常优雅——如果将来需要调整圆心位置或半径,只需修改两个常量数字即可,所有方位文字会自动重新定位。

4.2 刻度坐标计算函数

function tickX(i: number): number {
  return 170 + 56 * Math.cos((i * 30 - 90) * Math.PI / 180);
}

function tickY(i: number): number {
  return 66 + 56 * Math.sin((i * 30 - 90) * Math.PI / 180);
}

tickXtickY 与方位坐标函数结构完全相同,但有两个关键差异:半径从 44 变为 56,角度间隔从 45 度变为 30 度。这意味着十二个刻度线分布在比方位文字更外一圈的圆周上(半径 56 对应外圈,44 对应内圈),且每 30 度一根刻度线(360 度除以 12 等于 30 度)。这与 TICK_IDX 的十二个索引精确对应。

刻度线在传统罗盘中是用于更精细的角度划分的。八方仅能提供 45 度精度的方位判断,而十二刻度则将精度提升到 30 度。更细的分格还可见于后续的刻度盘数据中——六十甲子(60 分格)、分金刻度(120 分格)等。将刻度线的位置通过参数化函数计算,使得司南盘面的几何结构可以精确控制,且修改分格数量只需调整角度参数。

4.3 星点与环形装饰计算函数

function starX(i: number): number {
  return 248 + i * 15;
}

function starY(i: number): number {
  return 24 + (i % 3) * 14;
}

starXstarY 用于计算头部装饰星点的位置。与方位和刻度的圆形分布不同,星点采用的是直线排列加循环偏移的模式。starX 以 248 为起点,每个星点向右偏移 15 像素——七颗星点分布在 X 坐标 248 到 338 之间。starY 使用了取模运算 (i % 3),使得星点的 Y 坐标在 24、38、52 三个值之间循环,形成三行交错排列的效果。

这种排列方式模拟了夜空中星斗散布的效果——虽然整体在一条水平带上,但有高低错落,比严格一条直线更有自然感。通过取模运算实现循环偏移是一种非常巧妙的技巧,它用一行代码就实现了"三行循环"的效果,无需条件判断或嵌套循环。

function ringSize(i: number): number {
  return 74 + i * 22;
}

ringSize 用于计算三层装饰环的尺寸。i 为 0 时环大小为 74,i 为 1 时为 96,i 为 2 时为 118。三层环依次增大 22 像素,形成同心圆扩散的视觉效果。这三层环叠加在司南盘面上,配合透明度渐变,营造出磁场扩散的动态感——后续我们会看到,这些环还配合缩放动画,进一步增强了动态效果。

4.4 进度条尺寸计算函数

function precBarW(p: number): number {
  return 18 + p * 2.2;
}

function degBarW(d: number): number {
  return 16 + d * 0.9;
}

function skillBarW(s: number): number {
  return 16 + s * 1.1;
}

function orderBarH(a: number): number {
  return 24 + Math.min(a, 50000) / 500;
}

这四个函数分别用于计算不同场景下进度条或条形图的宽度与高度。precBarW 根据指向精度(0 到 100 的百分比值)计算宽度,基础宽度 18 加上精度值乘以 2.2,当精度为 100 时宽度为 240。degBarW 根据角度(0 到 360)计算宽度,基础 16 加上角度乘以 0.9,当角度为 360 时宽度为 340。skillBarW 根据技艺评分计算宽度,基础 16 加上评分乘以 1.1。

orderBarH 的逻辑稍有不同,它计算的是订单金额柱状图的高度而非宽度。使用 Math.min(a, 50000) 对金额做上限截断——超过 50000 的部分不再增加高度,防止极高金额的柱子破坏图表比例。截断后的值除以 500 再加 24 作为基础高度。这种设计保证了柱状图在数据差异悬殊时仍能保持合理的视觉效果。

将进度条的尺寸计算抽象为独立函数,是关注点分离原则的体现。UI 组件只负责调用函数获取尺寸并应用,而尺寸的数学逻辑集中在函数中,便于单独测试和调整。如果产品需求变更——例如进度条的最大宽度需要调整——只需修改函数中的常量参数,所有使用该函数的地方会自动适配。

4.5 颜色映射函数

function rankColor(r: string): string {
  if (r === '上品') {
    return COLORS.accent;
  }
  if (r === '中品') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

function dirTagColor(d: string): string {
  if (d === '正' || d === '真') {
    return COLORS.accent;
  }
  return COLORS.cool;
}

function titleColor(t: string): string {
  if (t === '大国匠') {
    return COLORS.accent;
  }
  if (t === '巧匠') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

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

这四个函数都是颜色映射函数——根据传入的数据值(字符串或数字)返回对应的颜色值。rankColor 将刻度等级(上品、中品、下品)映射为强调色、热色、冷色。dirTagColor 将方位标签(正或真 vs 隅)映射为强调色或冷色。titleColor 将匠师称号(大国匠、巧匠、匠人或学徒)映射为强调色、热色、冷色。stepColor 将工序序号分段映射为不同颜色。

这些函数体现了一种"条件着色"的设计模式——通过数据值决定视觉表现,使颜色具有语义意义。例如在刻度等级中,"上品"使用最深沉的强调色(古铜色),暗示其珍贵和高级;"中品"使用亮一些的热色(亮古铜色),表示中等品质;"下品"使用最淡的冷色(铜绿色),表示普通品质。用户通过颜色就能快速识别品质高低,无需仔细阅读文字。

stepColor 函数使用了数字分段而非字符串匹配,体现了不同的设计策略。序号 1 到 3 的早期工序使用铜绿色(象征初始、起步),4 到 6 的中期工序使用亮古铜色(象征进展、升温),7 到 8 的后期工序使用深古铜色(象征收尾、精华)。这种从冷到暖到深的色彩递进,巧妙地呼应了从采石到成器的工艺进程——前期朴素,中期渐入佳境,后期精华凝聚。

将颜色映射逻辑封装为独立函数而非内联在组件中,使得颜色策略的修改和扩展非常方便。例如,如果将来增加一个新的刻度等级"极品",只需在 rankColor 中增加一个条件分支即可。如果需要改变映射策略——例如将"上品"从强调色改为另一种颜色——也只需修改一处。这种封装是可维护性的重要保障。


第五章:数据模型——@Observed 类的逐个深度解析

5.1 CompassItem:司南器物数据模型

@Observed
export class CompassItem {
  name: string;
  era: string;
  prec: number;
  disk: string;
  price: number;

  constructor(name: string, era: string, prec: number, disk: string, price: number) {
    this.name = name;
    this.era = era;
    this.prec = prec;
    this.disk = disk;
    this.price = price;
  }
}

CompassItem 是第一个数据模型类,代表一件司南器物。类声明前标注了 @Observed 装饰器,这是 ArkUI 框架提供的可观察对象装饰器。@Observed 的作用是使类的实例成为可观察对象——当其实例的属性被修改时,绑定了该实例的 UI 组件会自动刷新。

类定义了五个属性:name(器物名称,如"司南勺")、era(所属年代,如"战国")、prec(指向精度,0 到 100 的数值)、disk(盘面材质描述,如"天然磁石")、price(价格,数值类型)。这些属性覆盖了一件司南器物最核心的信息维度,从名称标识到历史年代、从功能指标到材质和价格,形成了一个完整的数据画像。

export 关键字使得这个类可以被其他文件导入使用。构造函数接收五个参数并逐一赋值给对应属性,这是 TypeScript 中最标准的构造函数写法。虽然看似平淡无奇,但正是通过构造函数,我们可以在创建实例时一次性初始化所有属性,保证了对象创建后即处于完整可用的状态。

@Observed 的深层意义在于它实现了 ArkUI 的细粒度响应式更新机制。在传统的声明式 UI 框架中,数据的变更通常触发整个列表或整个组件的重绘。而通过 @Observed 的机制,ArkUI 实现了精确到对象属性级别的更新——当某件器物的 price 被修改时,只有绑定该器物的 UI 片段会刷新,其他器物的渲染不受影响。这种机制在列表数据量大时尤其重要,它显著减少了不必要的渲染开销,保证了界面的流畅性。

5.2 DirectionItem:方位数据模型

@Observed
export class DirectionItem {
  name: string;
  deg: number;
  type: string;
  use: string;
  tag: string;

  constructor(name: string, deg: number, type: string, use: string, tag: string) {
    this.name = name;
    this.deg = deg;
    this.type = type;
    this.use = use;
    this.tag = tag;
  }
}

DirectionItem 代表一个方位条目,包含五个属性:name(方位名称,如"正北")、deg(角度值,如 0 表示正北,90 表示正东)、type(方位类型描述,如"本命方位")、use(用途描述,如"定极")、tag(方位标签,"正"表示正方位,"隅"表示隅方位)。

deg 属性是这类数据的核心——它是一个可变的数值,用户可以通过交互来"微调"角度。后续我们会看到,编辑模态框提供了"偏东 +2 度"和"偏西 -2 度"两个按钮,点击后直接修改 selDir.deg 的值。由于 DirectionItem@Observed 类,且被 @State 变量 selDir 引用,修改 deg 后,UI 会自动刷新显示新的角度值。这就是响应式编程的威力——开发者只需修改数据,UI 自动跟随。

tag 属性的设计也值得分析。"正"与"隅"的区分是中国古代方位体系的重要概念。正方位(正北、正东、正南、正西)位于罗盘的四个基本方向上,隅方位(东北、东南、西南、西北)位于基本方向之间。在后续的颜色映射函数 dirTagColor 中,"正"和"真"标签会映射为强调色,"隅"映射为冷色,通过颜色差异强化了正隅之分。这种设计将传统文化概念通过 UI 细节自然地呈现出来。

5.3 ScaleItem:刻度盘数据模型

@Observed
export class ScaleItem {
  name: string;
  div: number;
  min: number;
  max: number;
  level: string;

  constructor(name: string, div: number, min: number, max: number, level: string) {
    this.name = name;
    this.div = div;
    this.min = min;
    this.max = max;
    this.level = level;
  }
}

ScaleItem 代表一种刻度盘体系,包含五个属性:name(刻度名称,如"地支刻度")、div(分格数,如 12 表示将圆周分为 12 等份)、minmax(刻度范围的最小值和最大值,通常为 0 和 360)、level(品质等级,“上品”、“中品”、“下品”)。

div 属性反映了古代罗盘刻度的精密程度。从数据中可以看到,八卦方位仅 8 分格(最粗),地支刻度 12 分格,天干刻度 10 分格,二十四山 24 分格,二十八宿 28 分格,六十甲子 60 分格,分金刻度 120 分格(最细),周天三百六十为 360 分格(理论最细)。分格数越多,刻度越精密,能分辨的角度越小。这种从粗到细的刻度发展,正是古代测量技术不断精进的体现。

level 属性与 rankColor 函数配合,为不同品质的刻度盘赋予不同颜色。"上品"刻度盘使用强调色标识,"中品"使用热色,"下品"使用冷色。这种颜色编码使用户一眼即可识别刻度盘的品质等级,无需阅读文字说明。

5.4 SmithItem:匠师数据模型

@Observed
export class SmithItem {
  name: string;
  title: string;
  age: number;
  works: number;
  skill: number;

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

SmithItem 代表一位匠师,包含五个属性:name(匠师姓名,如"郑公铸")、title(称号,“大国匠”、“巧匠”、“匠人”、“学徒”)、age(年龄)、works(作品数量)、skill(技艺评分,0 到 100)。

匠师数据中蕴含着一个有趣的叙事——从"大国匠"到"学徒",技艺评分从 98 递减至 72,年龄从 72 递减至 20,作品数从 86 递减至 12。这描绘了一条清晰的匠人成长路径:年轻学徒从 20 岁入门,随着岁月增长,作品积累,技艺精进,最终在 60 到 70 岁达到"大国匠"的境界。这种数据设计不仅是对古代匠师等级体系的还原,也暗含了"十年磨一剑"的工匠精神——技艺的精进需要数十年的积累和磨砺。

5.5 CompassStepItem:工序数据模型

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

CompassStepItem 代表一道制南工序,包含五个属性:name(工序名称,如"采石")、days(所需天数)、tool(使用工具,如"山锤 · 罗盘")、note(工序说明,如"觅天然磁石")、seq(工序序号,1 到 8)。

seq 属性是排序的关键——八道工序从 1 到 8 严格有序:采石(1) 接着 琢勺(2) 接着 铸盘(3) 接着 刻纹(4) 接着 磁化(5) 接着 校准(6) 接着 装轴(7) 接着 试航(8)。这个序号不仅用于列表排序,还通过 stepColor 函数映射为不同颜色——前期工序为铜绿色,中期为亮古铜色,后期为深古铜色。色彩的变化暗示了工艺进程的推进:从朴素的采石阶段,到渐入佳境的铸刻阶段,到精华为凝聚的收尾阶段。

days 属性揭示了各工序的时间投入。采石需 3 天,琢勺最长需 5 天(因为磨制勺形最为费时),铸盘需 4 天,刻纹 3 天,磁化仅需 1 天,校准 2 天,装轴 1 天,试航 2 天。总计 21 天——约三周时间,从原石到成品。这个时间安排既体现了古代手工制造的节奏,也暗示了各工序的难度差异。

5.6 CompassOrderItem:订单数据模型

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

CompassOrderItem 是最后一个数据模型类,代表一笔订单,包含五个属性:name(器物名称)、buyer(买方,如"博物馆"、“考古院”)、amount(金额)、count(数量)、month(月份,如"2026-08")。

buyer 属性的值域很有趣——包括博物馆、考古院、航海博物馆、文创品牌、收藏家、影视剧组、天文馆、玉器店、教材出版社、研学机构等。这些买方涵盖了文化、教育、影视、收藏等多个领域,反映了司南器物在当代的多元应用场景:博物馆和考古院用于学术研究和展示,文创品牌用于开发文化衍生品,影视剧组用于道具制作,教材出版社用于教学演示,研学机构用于学生体验。

amountcount 是订单的核心数据,month 用于月度走势分析。在后续的订单图表中,amount 被转换为柱状图的高度;在 orderRow 中,amountcountmonth 共同构成订单列表项的信息展示。由于 CompassOrderItem@Observed 类,且订单列表 orders@State 变量,当用户删除订单时(通过 splice 方法),UI 会自动刷新列表,被删除的订单项立即从界面消失。


第六章:UI 组件——@Entry 与 @Component 的深度解析

6.1 CompassPage 结构声明与状态定义

@Entry
@Component
struct CompassPage {
  @State curTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State selDir: DirectionItem | null = null;
  @State selOrder: CompassOrderItem | null = null;
  @State formName: string = '';
  @State formEra: string = '';
  @State formPrice: string = '';

这段代码声明了应用的主组件 CompassPage@Entry 装饰器标记此组件为页面的入口组件——即应用的根 UI 节点。@Component 装饰器声明这是一个自定义组件,可以被其他组件引用或在页面中使用。在 ArkUI 中,struct 关键字用于声明组件结构体(而非 class),组件内部的 UI 通过 build() 方法描述。

接下来是一系列 @State 状态变量声明。@State 是 ArkUI 最核心的状态管理装饰器,它使变量成为响应式状态——当变量值变化时,引用了该变量的 UI 部分会自动重新渲染。curTab 初始值为 0,表示默认选中第一个标签(司南)。breath 初始值为 false,这是一个"呼吸"动画的开关状态,后续会在定时器中不断翻转。

showAddshowEditshowDel 三个布尔变量分别控制三个模态框的显示与隐藏——新增司南模态、编辑方位模态、删除订单模态。这种"布尔开关加条件渲染"是管理模态框最常见的模式。每个模态框的 @Builder 内部都会检查对应的布尔变量,为 true 时渲染模态内容,为 false 时不渲染。

selDirselOrder 是两个"可空"状态变量,类型分别为 DirectionItem | nullCompassOrderItem | null。初始值为 null,当用户点击某个方位的角度数值时,selDir 被赋值为对应的方位对象并打开编辑模态;当用户点击某个订单的"删除"按钮时,selOrder 被赋值为对应的订单对象并打开删除模态。使用联合类型加 null 是 TypeScript 的类型安全特性,它强制开发者在访问对象属性前先检查是否为 null,避免了空指针异常。

formNameformEraformPrice 是新增司南表单的三个输入字段。初始值为空字符串,当用户在模态框的 TextInput 中输入内容时,通过 onChange 回调将输入值同步到这些状态变量。在用户点击"确认登记"时,这些值被用于构造新的 CompassItem 实例并推入列表。值得注意的是 formPrice 使用字符串类型而非数字——这是因为 TextInput 的值本身是字符串,在最终需要数字时通过 Number() 转换。

6.2 司南器物列表数据

  @State compasses: CompassItem[] = [
    new CompassItem('司南勺', '战国', 96, '天然磁石', 88000),
    new CompassItem('汉司南盘', '汉', 90, '青铜圆盘', 52000),
    new CompassItem('唐罗盘仪', '唐', 92, '青铜 · 木', 68000),
    new CompassItem('宋水浮针', '宋', 88, '磁针 · 浮瓢', 36000),
    new CompassItem('元早罗盘', '元', 94, '铜面木框', 46000),
    new CompassItem('明航海罗盘', '明', 97, '铜轴木盘', 78000),
    new CompassItem('清堪舆罗盘', '清', 91, '多层铜盘', 98000),
    new CompassItem('司南佩', '汉', 72, '白玉', 26000),
    new CompassItem('指南鱼', '宋', 78, '薄铁片', 12000),
    new CompassItem('磁勺复刻', '当代', 95, '磁石 · 铜盘', 9800),
    new CompassItem('水罗盘', '宋', 84, '铜碗 · 磁针', 22000),
    new CompassItem('二十八宿罗盘', '明', 89, '雕漆铜芯', 66000)
  ];

这是司南器物列表的初始数据,包含十二件从战国到当代的司南器物。每件器物通过 new CompassItem(...) 构造,五个参数分别对应名称、年代、精度、材质和价格。这十二件器物涵盖了司南发展史的主要阶段和形制:战国天然磁石勺是最早的形态,汉代青铜圆盘是司南盘成熟期,唐代罗盘仪是向罗盘过渡的关键形态,宋代水浮针是人工磁化的里程碑,元代铜面木框罗盘是形制创新,明代铜轴木盘航海罗盘是航海应用巅峰,清代多层铜盘堪舆罗盘是堪舆应用极致。

列表中还包含一些特殊器物:"司南佩"是汉代白玉制的佩饰司南,虽指向精度较低(72%),但作为随身佩戴的装饰品具有文化价值;"指南鱼"是宋代薄铁片制的人工磁化指向器,精度 78%,是水浮指南针的早期形态;"磁勺复刻"是当代按照古法复制的司南勺,精度 95% 且价格仅 9800 元,是最亲民的复刻品;"水罗盘"是宋代铜碗磁针制的水浮式罗盘;"二十八宿罗盘"是明代雕漆铜芯制的高精度天文罗盘。

将数据直接初始化在 @State 声明中,是 ArkUI 应用的常见做法。这些数据在组件创建时即被注入,作为应用的"种子数据"。由于 compasses@State 变量,当用户通过新增模态框添加新器物时,UI 会自动刷新列表,新增的器物立即出现在列表底部。这种响应式数据绑定是 ArkUI 声明式 UI 的核心优势——开发者只需操作数据,界面自动跟随。

6.3 方位、刻度、匠师、工序与订单数据

  @State dirs: DirectionItem[] = [
    new DirectionItem('正北', 0, '本命方位', '定极', '正'),
    new DirectionItem('正东', 90, '日出方位', '向阳', '正'),
    new DirectionItem('正南', 180, '正阳方位', '营建', '正'),
    new DirectionItem('正西', 270, '日落方位', '行旅', '正'),
    new DirectionItem('东北', 45, '艮位', '风水', '隅'),
    new DirectionItem('东南', 135, '巽位', '航海', '隅'),
    new DirectionItem('西南', 225, '坤位', '堪舆', '隅'),
    new DirectionItem('西北', 315, '乾位', '祭祀', '隅')
  ];

方位列表 dirs 包含八个方位条目,按"北、东、南、西、东北、东南、西南、西北"的顺序排列。四个正方位(正北 0 度、正东 90 度、正南 180 度、正西 270 度)标签为"正",四个隅方位(东北 45 度、东南 135 度、西南 225 度、西北 315 度)标签为"隅"。每个方位都关联了类型和用途描述:正北为"本命方位"用于"定极",正东为"日出方位"用于"向阳",正南为"正阳方位"用于"营建",正西为"日落方位"用于"行旅"。

隅方位则对应八卦中的方位:东北为"艮位"用于"风水",东南为"巽位"用于"航海",西南为"坤位"用于"堪舆",西北为"乾位"用于"祭祀"。八卦方位与实际方向的对应关系源于《周易》的后天八卦图(文王八卦):震卦在东、巽卦在东南、离卦在南、坤卦在西南、兑卦在西、乾卦在西北、坎卦在北、艮卦在东北。这套八卦方位体系将中国传统的易学理论与实际方位结合,赋予了每个方位独特的文化含义。

  @State scales: ScaleItem[] = [
    new ScaleItem('地支刻度', 12, 0, 360, '中品'),
    new ScaleItem('天干刻度', 10, 0, 360, '上品'),
    new ScaleItem('二十四山', 24, 0, 360, '上品'),
    new ScaleItem('八卦方位', 8, 0, 360, '中品'),
    new ScaleItem('周天三百六十', 360, 0, 360, '上品'),
    new ScaleItem('二十八宿', 28, 0, 360, '上品'),
    new ScaleItem('六十甲子', 60, 0, 360, '中品'),
    new ScaleItem('分金刻度', 120, 0, 360, '下品')
  ];

刻度盘列表 scales 包含八种刻度体系,分格数从 8(八卦方位)到 360(周天三百六十)不等。"二十四山"是古代堪舆罗盘最核心的刻度系统,将圆周分为 24 等份,每份 15 度,结合天干、地支和八卦形成完整的方位体系。"分金刻度"是更精细的 120 分格系统,每格 3 度,用于精确测定方位的"分金"术。

  @State smiths: SmithItem[] = [
    new SmithItem('郑公铸', '大国匠', 72, 86, 98),
    new SmithItem('沈司南', '大国匠', 64, 78, 96),
    new SmithItem('赵罗盘', '巧匠', 52, 60, 93),
    new SmithItem('钱定极', '巧匠', 46, 52, 91),
    new SmithItem('孙磁针', '巧匠', 40, 44, 88),
    new SmithItem('李浮瓢', '匠人', 34, 30, 84),
    new SmithItem('周刻度', '匠人', 28, 22, 79),
    new SmithItem('吴铜面', '学徒', 20, 12, 72)
  ];

匠师列表 smiths 包含八位匠师,从 72 岁的"大国匠"郑公铸到 20 岁的"学徒"吴铜面。匠师姓名都颇具古意且暗含工艺:"郑公铸"暗示铸造,"沈司南"直指司南,"赵罗盘"指向罗盘制作,"钱定极"暗示定极校准,"孙磁针"指向磁针工艺,"李浮瓢"暗示水浮针,"周刻度"指向刻纹,"吴铜面"暗示铜面制作。这些姓名本身就是工艺分工的缩影。

  @State steps: CompassStepItem[] = [
    new CompassStepItem('采石', 3, '山锤 · 罗盘', '觅天然磁石', 1),
    new CompassStepItem('琢勺', 5, '磨石 · 水', '磨勺形', 2),
    new CompassStepItem('铸盘', 4, '熔炉 · 范', '铸青铜盘', 3),
    new CompassStepItem('刻纹', 3, '錾刀', '刻方位字', 4),
    new CompassStepItem('磁化', 1, '磁石摩擦', '定磁极', 5),
    new CompassStepItem('校准', 2, '日影 · 夜星', '对正北', 6),
    new CompassStepItem('装轴', 1, '铜轴 · 玉珠', '减摩擦', 7),
    new CompassStepItem('试航', 2, '水池 · 帆船', '验证指向', 8)
  ];

工序列表 steps 包含八道制南工序,按序号 1 到 8 有序排列。每道工序包含天数、工具和说明。"山锤 · 罗盘"是采石工具(用罗盘在山中定位磁石矿脉),"磨石 · 水"是琢勺工具(在水中用磨石磨制勺形),"熔炉 · 范"是铸盘工具(用熔炉和模具铸造青铜盘),"錾刀"是刻纹工具,"磁石摩擦"是磁化方法,"日影 · 夜星"是校准参照(白天看日影、夜晚看星),"铜轴 · 玉珠"是装轴材料,"水池 · 帆船"是试航设备。

  @State orders: CompassOrderItem[] = [
    new CompassOrderItem('司南勺', '博物馆', 88000, 1, '2026-08'),
    new CompassOrderItem('汉司南盘', '考古院', 156000, 3, '2026-07'),
    new CompassOrderItem('明航海罗盘', '航海博物馆', 234000, 3, '2026-08'),
    new CompassOrderItem('磁勺复刻', '文创品牌', 98000, 10, '2026-06'),
    new CompassOrderItem('清堪舆罗盘', '收藏家', 98000, 1, '2026-07'),
    new CompassOrderItem('水罗盘', '影视剧组', 66000, 3, '2026-05'),
    new CompassOrderItem('二十八宿罗盘', '天文馆', 132000, 2, '2026-08'),
    new CompassOrderItem('司南佩', '玉器店', 78000, 3, '2026-06'),
    new CompassOrderItem('宋水浮针', '教材出版社', 36000, 1, '2026-04'),
    new CompassOrderItem('指南鱼', '研学机构', 48000, 4, '2026-05')
  ];

订单列表 orders 包含十笔订单,时间跨度从 2026 年 4 月到 8 月。金额从 36000 元到 234000 元不等,数量从 1 件到 10 件不等。买方类型丰富多样,每笔订单的器物名称都与前面 compasses 列表中的器物对应,形成了一个完整的数据生态——器物在"司南"标签页展示其信息,在"订单"标签页展示其销售情况。

6.4 生命周期函数 aboutToAppear

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

aboutToAppear 是 ArkUI 组件的生命周期回调函数,在组件创建后、build() 方法执行前被调用。这个时机非常适合做初始化工作——数据预加载、定时器启动、事件监听注册等。在这里,aboutToAppear 的唯一职责是启动一个定时器,每 520 毫秒翻转一次 breath 布尔值。

setInterval 是 JavaScript 和 TypeScript 标准的全局函数,用于按照指定的时间间隔重复执行回调函数。这里传入的间隔是 520 毫秒——约半秒一次翻转。每次翻转后,breath 从 false 变为 true,再从 true 变为 false,如此循环往复。

this.breath 的翻转会触发什么效果呢?由于 breath@State 变量,它的变化会导致所有引用了 breath 的 UI 部分重新渲染。在后续的代码中,breath 被多处引用:头部星点的透明度交替变化、装饰环的缩放交替、司南勺的旋转角度、订单柱状图的颜色交替。所有这些效果通过同一个 breath 变量驱动,形成了协调一致的"呼吸"节奏——整个界面仿佛有了生命,在呼吸之间微微律动。

需要注意的是,setInterval 返回一个定时器 ID,通常应该在组件销毁时(aboutToDisappear 中)调用 clearInterval 清除。这段代码没有显式清除定时器,这在单页面应用中通常不会造成问题,但如果组件可能被多次创建和销毁,未清除的定时器会导致内存泄漏。这是一个潜在的技术债务点,在更严谨的实现中应当处理。

6.5 模态遮罩层 modalOverlay

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

modalOverlay 是一个 @Builder 函数,用于渲染模态框的遮罩层。@Builder 是 ArkUI 的装饰器,用于声明可复用的 UI 构建函数——它不是一个独立组件,而是一段可被调用的 UI 描述代码。@Builder 函数可以接收参数,这里接收一个 onClose 回调函数。

遮罩层的实现非常简洁:一个 Column 组件,宽度和高度都设为 100%(即占满父容器),背景色为 COLORS.maskrgba(0,0,0,0.45),半透明黑色),并绑定了 onClick 点击事件——点击遮罩层任意位置都会调用 onClose 回调,关闭模态框。

这种"点击遮罩关闭模态"是移动端常见的交互模式。当模态框弹出时,半透明遮罩层覆盖在下层内容之上,使用户的注意力聚焦在模态框内容上。同时,遮罩层接收点击事件来关闭模态框,提供了一种直观的"点击外部取消"操作路径。用户既可以通过模态框内的"取消"按钮关闭,也可以直接点击遮罩层关闭,两种方式都很自然。

将遮罩层抽象为独立的 @Builder 函数是一个良好的复用设计。应用中有三个模态框(新增、编辑、删除),它们都需要遮罩层。如果不抽象,每个模态框都要重复编写遮罩层的代码。通过 modalOverlay(onClose) 的调用,每个模态框只需一行代码即可获得遮罩层及其关闭交互。

6.6 页面头部 pageHeader——司南盘面的视觉中心

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

pageHeader 是整个应用中最为复杂、视觉最为丰富的 @Builder 函数。它由一个 Column 容器包裹一个 Stack 组成。Stack 是 ArkUI 的堆叠容器,其子元素会按照声明顺序层叠排列——后声明的子元素覆盖在先声明的子元素之上。Stack 内的第一个子元素是一个占满整个区域的 Column,设置了 22 的圆角和 135 度角的线性渐变背景。

线性渐变从 COLORS.header1#2E4A38,深墨绿)到 COLORS.header2#14231A,更深墨绿),角度为 135 度——即从左上角向右下角渐变。这种渐变营造了深邃的纵深感,使头部区域如同青铜器的暗面,沉稳而厚重。这个渐变背景是整个司南盘面的"画布"——后续所有的装饰元素(星点、环、刻度、方位文字、勺身、标题、统计数字)都层叠在这块深绿色渐变背景之上。

        ForEach(STAR_IDX, (i: number) => {
          Column()
            .width(3)
            .height(3)
            .borderRadius(1.5)
            .backgroundColor(COLORS.gold)
            .opacity(this.breath ? 0.3 : 1)
            .position({ x: starX(i), y: starY(i) })
            .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 's' + i)

在渐变背景之上,首先渲染的是七颗装饰星点。通过 ForEach 遍历 STAR_IDX(0 到 6),为每个索引创建一个 3x3 像素的小圆点,背景色为金色。每颗星点的位置由 starX(i)starY(i) 计算,呈现出三行交错的排列效果。星点的透明度通过 this.breath 状态控制——当 breath 为 true 时透明度 0.3(暗淡),为 false 时透明度 1(明亮)。配合 700 毫秒的交替动画,星点会从暗到明、再从明到暗地交替闪烁,无限循环。七颗星点的闪烁与 breath 的翻转共同作用,形成了夜空中星辰明灭的效果。

        Column()
          .width(112)
          .height(112)
          .borderRadius(56)
          .backgroundColor(COLORS.bronzeA)
          .border({ width: 3, color: COLORS.gold })
          .position({ x: 114, y: 8 })
        Column()
          .width(96)
          .height(96)
          .borderRadius(48)
          .border({ width: 1.5, color: COLORS.bronzeB })
          .position({ x: 122, y: 16 })

接下来是司南盘面的主体——两个同心圆。第一个 Column 是外圆,宽高 112 像素,圆角 56(等于宽度的一半,形成正圆),背景色为深古铜色,边框宽度 3 像素、金色。第二个 Column 是内圆,宽高 96 像素,圆角 48,有 1.5 像素宽的亮古铜色边框。两个圆的位置关系是精心计算的:外圆中心在 (170, 64),内圆中心也在 (170, 64),两圆同心。外圆的金色粗边框模拟青铜器物的镀金边缘,内圆的细铜色边框模拟盘面的刻线。

        ForEach(RING_IDX, (i: number) => {
          Column()
            .width(ringSize(i))
            .height(ringSize(i))
            .borderRadius(ringSize(i) / 2)
            .border({ width: 1, color: COLORS.patina })
            .opacity(0.35 - i * 0.1)
            .scale({ x: this.breath ? 1.12 : 1, y: this.breath ? 1.12 : 1 })
            .position({ x: 170 - ringSize(i) / 2, y: 66 - ringSize(i) / 2 })
            .animation({ duration: 1300 - i * 300, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'g' + i)

在双圆之上,渲染三层装饰环。每层环的大小由 ringSize(i) 计算(74、96、118),边框为铜绿色,透明度随索引递减(0.35、0.25、0.15)。三层环配合 scale 缩放动画——breath 为 true 时放大到 1.12 倍,为 false 时恢复原大小。动画持续时间随索引递减(1300、1000、700 毫秒),使得三层环的缩放节奏错开,形成"波纹扩散"的视觉效果——像磁场从中心向外扩散的波纹。

        ForEach(TICK_IDX, (i: number) => {
          Column()
            .width(1.5)
            .height(i % 3 === 0 ? 8 : 5)
            .backgroundColor(COLORS.bronzeB)
            .position({ x: tickX(i), y: tickY(i) })
            .rotate({ angle: i * 30 })
        }, (i: number) => 'k' + i)

三层环之上是十二根刻度线。每根刻度线宽 1.5 像素,高度根据 i % 3 === 0 条件决定——索引为 0、3、6、9(即正北、正东、正南、正西四个基本方位)的刻度线高 8 像素,其余高 5 像素。这种"主刻度高、副刻度低"的设计是传统罗盘刻度的标准做法。刻度线的位置由 tickX(i)tickY(i) 计算,分布在半径 56 的圆周上,通过旋转使刻度线呈放射状排列。

        ForEach(DIR_IDX, (i: number) => {
          Text(DIR_TEXT[i])
            .fontSize(9)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.bronzeB)
            .position({ x: dirX(i) - 6, y: dirY(i) - 7 })
        }, (i: number) => 'd' + i)

刻度线之内(半径 44 处)是八方方位文字,通过遍历 DIR_IDX 渲染。每个方位文字通过 Text(DIR_TEXT[i]) 创建,字号 9,粗体,颜色为亮古铜色。八方文字"北、东北、东、东南、南、西南、西、西北"均匀分布在半径 44 的圆周上,与外圈刻度线形成内外两层。文字与刻度线的配合使司南盘面既有精确的刻度标识,又有易读的方位文字。

        Stack() {
          Column()
            .width(34)
            .height(8)
            .borderRadius(4)
            .backgroundColor(COLORS.gold)
          Column()
            .width(7)
            .height(7)
            .borderRadius(3.5)
            .backgroundColor(COLORS.bronzeB)
        }
        .rotate({ angle: this.breath ? 14 : -10 })
        .animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
        .position({ x: 170, y: 66 })

在方位文字和刻度线之上,是司南勺的核心造型——一个 Stack 包含两个 Column。第一个 Column 宽 34 像素、高 8 像素,圆角 4,金色背景——这是勺身。第二个 Column 宽高 7 像素,圆角 3.5,亮古铜色背景——这是勺柄端点。勺身通过旋转在 -10 度到 14 度之间来回摆动,模拟了磁勺在盘面上微微振荡、最终指向南方的动态效果。勺身位置在 (170, 66),正是司南盘面的圆心,再现了古代司南"磁勺置于盘心"的形制。

        Text('南')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .position({ x: 266, y: 14 })
        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 })

在盘面右侧处,一个"南"字以 12 号字、粗体、浅黄白色显示——标注了勺柄所指的方向。在盘面左上角处,是应用标题"司南坊"和副标题"指南天下 · 一勺定乾坤"。主标题字号 20、粗体、浅黄白色,副标题字号 11、灰绿色。副标题是应用的精神宣言——"指南天下"意味着司南惠及天下,"一勺定乾坤"暗合古人"一勺定南北"的诗意。

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

在标题下方,是四组统计数据展示。每组由一个大号数字(15 号字、粗体、浅黄白色)和一个小号标签(9 号字、灰绿色)组成。四组数据分别是:“12 司南”(12 件器物)、“8 方位”(8 个方位)、“8 匠师”(8 位匠师)、“8 工序”(8 道工序)。这种"数字加标签"的统计展示模式在仪表盘类应用中非常常见,四个数字一目了然地告诉用户应用的核心数据规模。这些数字与实际列表数据的长度精确对应,保证了头部统计与列表内容的准确性。

整个 pageHeaderStack 层级中,从底到顶依次是:渐变背景、星点、外圆、内圆、装饰环、刻度线、方位文字、勺身、南字、标题、统计数字。整个头部高度 128 像素,是应用视觉设计的精华所在。

6.7 司南器物列表行 compassRow

  @Builder
  compassRow(item: CompassItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Column() {
        Text(item.era + ' · ' + item.disk)
          .fontSize(10)
          .fontColor(COLORS.text3)
        Row() {
          Text('指向').fontSize(9).fontColor(COLORS.text3)
          Column()
            .width(precBarW(item.prec))
            .height(6)
            .borderRadius(3)
            .backgroundColor(COLORS.bronzeA)
            .margin({ left: 6 })
          Text(item.prec + '%').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 })
  }

compassRow 是司南器物列表的行渲染函数。它接收一个 CompassItem 参数,返回一个 Row 容器作为列表项。整体布局是水平三段式:左侧器物名称、中间详情区(年代材质加精度进度条)、右侧价格。

左侧是器物名称,14 号字、粗体、深墨绿色——作为列表项的主标题,字号最大、颜色最深,确保用户首先看到的就是器物名称。中间详情区是一个 Column(纵向排列),通过 .layoutWeight(1) 占据剩余水平空间,使左侧名称和右侧价格固定宽度、中间自适应。中间区域顶部是"年代 · 材质"的描述文字(10 号字、浅灰绿色),下方是指向精度进度条。

进度条由三部分组成:"指向"标签、Column 柱条(宽度由 precBarW(item.prec) 计算、高 6 像素、圆角 3、深古铜色背景)、精度百分比数值。柱条的宽度随精度值变化——精度越高,柱条越长,视觉上直观地传达了"指向越准越珍贵"的产品理念。右侧价格以"¥"符号开头,12 号字、粗体、强调色——价格是用户关注的核心信息之一,通过粗体和强调色使其醒目。

6.8 方位列表行 dirRow

  @Builder
  dirRow(item: DirectionItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.tag)
        .fontSize(9)
        .fontColor(COLORS.cardBg)
        .backgroundColor(dirTagColor(item.tag))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text(item.type + ' · ' + item.use).fontSize(9).fontColor(COLORS.text3)
        Column()
          .width(degBarW(item.deg))
          .height(5)
          .borderRadius(2)
          .backgroundColor(COLORS.patina)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text(item.deg + '°')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.hot)
        .onClick(() => {
          this.selDir = item;
          this.showEdit = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

dirRow 是方位列表的行渲染函数,布局为水平四段式:方位名称、标签徽章、类型与角度进度条、角度数值。与 compassRow 相比,dirRow 增加了标签徽章和交互事件。标签徽章显示 item.tag(“正"或"隅”),背景色由 dirTagColor 函数决定,通过颜色编码区分正隅方位。

最右侧是角度数值(如"0°"),11 号字、粗体、热色(亮古铜色)。这个数值同时是一个可点击的交互元素——点击后赋值 this.selDir = item 并打开编辑模态框。这种"点击数值进入编辑"的交互模式非常直观——用户看到角度数值,想要调整时直接点击即可。点击后,编辑模态框会显示当前方位名称和角度,提供"偏东 +2 度"和"偏西 -2 度"两个微调选项。

6.9 刻度列表行 scaleRow

  @Builder
  scaleRow(item: ScaleItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.level)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(rankColor(item.level))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Text(item.div + ' 分格 · ' + item.min + '-' + item.max + '°')
        .fontSize(10)
        .fontColor(COLORS.text3)
        .layoutWeight(1)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

scaleRow 是刻度盘列表的行渲染函数,布局为水平三段式:刻度名称、品质徽章、分格信息。这是三个列表行函数中最简洁的一个,因为它不需要进度条,仅通过文字和徽章展示信息。品质徽章显示 item.level(“上品”、“中品”、“下品”),背景色由 rankColor 函数决定——通过颜色编码区分等级。右侧是分格信息文字,格式为"分格数 分格 · 最小值-最大值°",通过 .layoutWeight(1) 占据剩余空间并右对齐。

6.10 匠师列表行 smithRow

  @Builder
  smithRow(item: SmithItem) {
    Row() {
      Text(item.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.text1)
      Text(item.title)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(titleColor(item.title))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text('技艺 ' + item.skill + ' · ' + item.works + ' 件').fontSize(9).fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.skill))
          .height(6)
          .borderRadius(3)
          .backgroundColor(COLORS.bronzeB)
          .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 })
  }

smithRow 是匠师列表的行渲染函数,布局为水平四段式:姓名、称号徽章、技艺进度条、年龄。结构与 dirRow 非常相似。称号徽章显示 item.title,背景色由 titleColor 函数决定——"大国匠"为强调色、"巧匠"为热色、其余为冷色。技艺进度条使用亮古铜色而非深古铜色,与精度进度条形成色彩区分。匠师行不包含交互事件,是纯展示型的列表项。

6.11 工序列表行 stepRow

  @Builder
  stepRow(item: CompassStepItem) {
    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 是工序列表的行渲染函数,布局为水平三段式:序号圆形徽章、工序详情、天数。与前几个行函数不同,stepRow 的左侧不是文字名称,而是一个圆形序号徽章——24x24 像素的圆形,背景色由 stepColor 决定,白色粗体数字居中显示。这个序号徽章既是序号标识,也是颜色编码的载体——用户通过徽章颜色即可判断工序处于前期、中期还是后期。

6.12 订单柱状图 orderChart

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

orderChart 是订单金额柱状图的渲染函数。与前面的行渲染函数不同,它不接收参数,直接遍历 this.orders 状态数组渲染柱状图。每根柱子宽 12 像素,高度由 orderBarH(item.amount) 计算,背景色在金色和深古铜色之间交替——配合 600 毫秒的交替动画,柱子颜色平滑切换,形成"闪烁"效果。柱子下方是金额标签(金额除以 1000 加"k"后缀)。外层 Row 高度 92 像素,底部对齐,十根柱子等分宽度。

6.13 订单列表行 orderRow

  @Builder
  orderRow(item: CompassOrderItem) {
    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 是订单列表的行渲染函数,布局为水平三段式:器物名称、金额与数量信息、删除按钮。这是所有行函数中唯一包含"危险操作"按钮的。删除按钮使用红色文字配浅红色背景,明确传达"危险操作"的语义信号。点击后赋值 this.selOrder = item 并打开删除确认模态框——没有直接删除,而是先弹出确认框,这是良好的用户体验实践,删除是不可逆操作,需要二次确认以防误触。

6.14 标签内容区 tabContent

  @Builder
  tabContent() {
    if (this.curTab === 0) {
      Column() {
        Text('司南器物 · 指向越准越珍贵').fontSize(12).fontColor(COLORS.text2).width('100%')
        ForEach(this.compasses, (item: CompassItem) => {
          this.compassRow(item)
        }, (item: CompassItem) => item.name)
      }.width('100%').margin({ top: 10 })
    }
    if (this.curTab === 1) {
      Column() {
        Text('八方位体系 · 点击角度可微调').fontSize(12).fontColor(COLORS.text2).width('100%')
        ForEach(this.dirs, (item: DirectionItem) => {
          this.dirRow(item)
        }, (item: DirectionItem) => item.name)
      }.width('100%').margin({ top: 10 })
    }
    if (this.curTab === 2) {
      Column() {
        Text('刻度盘 · 分格愈细愈精密').fontSize(12).fontColor(COLORS.text2).width('100%')
        ForEach(this.scales, (item: ScaleItem) => {
          this.scaleRow(item)
        }, (item: ScaleItem) => item.name)
      }.width('100%').margin({ top: 10 })
    }
    if (this.curTab === 3) {
      Column() {
        Text('铸司南匠师 · 一锤一磨定方向').fontSize(12).fontColor(COLORS.text2).width('100%')
        ForEach(this.smiths, (item: SmithItem) => {
          this.smithRow(item)
        }, (item: SmithItem) => item.name)
      }.width('100%').margin({ top: 10 })
    }
    if (this.curTab === 4) {
      Column() {
        Text('制南八序 · 磨石铸盘对星斗').fontSize(12).fontColor(COLORS.text2).width('100%')
        ForEach(this.steps, (item: CompassStepItem) => {
          this.stepRow(item)
        }, (item: CompassStepItem) => 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: CompassOrderItem) => {
          this.orderRow(item)
        }, (item: CompassOrderItem) => item.name + item.month)
      }.width('100%').margin({ top: 10 })
    }
  }

tabContent 是标签内容区的渲染函数,通过一系列 if 条件判断 this.curTab 的值来决定渲染哪个标签的内容。每个 if 分支的结构基本相同:一个 Column 容器,顶部是描述性文字,然后通过 ForEach 遍历对应的数据数组渲染列表行。

每个标签的描述文字都点明了该模块的主题和操作提示:司南标签提示"指向越准越珍贵",方位标签提示"点击角度可微调",刻度标签揭示"分格愈细愈精密",匠师标签强调"一锤一磨定方向",工序标签概括"磨石铸盘对星斗",订单标签介绍"月度走势"和"点击可删除"。

curTab === 5 时,渲染订单管理页面,与前面五个标签不同,订单页面包含两个部分:顶部的柱状图和下方的订单列表。订单行的键值生成器使用 item.name + item.month——因为同一器物可能在不同月份有多个订单,仅用 item.name 可能不唯一,加上 item.month 保证了键值的唯一性。

6.15 底部导航项 bottomItem 与导航栏 bottomBar

  @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 ? '#3D604A' : COLORS.tabBg)
    .borderRadius(10)
    .onClick(() => {
      this.curTab = i;
    })
  }

bottomItem 是底部导航栏单个标签项的渲染函数,接收索引 i 作为参数。它从 TAB_LIST 常量中读取对应索引的 iconlabel,渲染为一个图标加文字的水平组合。标签的文字样式根据是否为当前选中标签动态变化:选中时粗体加金色文字,未选中时常规字重加灰绿色文字。背景色同样动态变化:选中时为稍浅的绿色(模拟高亮效果),未选中时为深墨绿。

每个标签项通过 .layoutWeight(1) 等分水平空间,点击事件将 this.curTab 设置为当前索引——由于 curTab@State 变量,赋值后整个 tabContent 会根据新的 curTab 值重新渲染,显示对应标签的内容。这种"一处状态变更,多处 UI 联动"正是响应式 UI 的核心优势。

  @Builder
  bottomBar() {
    Column() {
      Row() {
        ForEach(ROW1_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r1' + i)
      }.width('100%')
      Row() {
        ForEach(ROW2_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r2' + i)
      }.width('100%').margin({ top: 6 })
    }
    .width('100%')
    .padding(10)
    .backgroundColor(COLORS.tabBg)
    .borderRadius({ topLeft: 18, topRight: 18, bottomLeft: 0, bottomRight: 0 })
  }

bottomBar 是底部导航栏的容器函数。它由一个 Column 包含两个 Row 组成——第一行通过 ForEach(ROW1_IDX, ...) 渲染前三个标签,第二行通过 ForEach(ROW2_IDX, ...) 渲染后三个标签。将六个标签分为两行三列是空间利用的考量。外层 Column 设置了不对称圆角——顶部 18、底部 0,这种"顶部圆角、底部直角"的设计使导航栏顶部呈现柔和的弧线过渡,底部紧贴屏幕底部无圆角,是移动端底部导航的经典样式。

6.16 新增司南模态框 addModal

  @Builder
  addModal() {
    if (this.showAdd) {
      Stack() {
        this.modalOverlay(() => { this.showAdd = false; })
        Column() {
          Text('新增司南').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.text1)
          Text('登记一件新铸司南').fontSize(11).fontColor(COLORS.text3).margin({ top: 3 })
          // 三个输入字段:器名、年代、价格
          Column() {
            Text('器名').fontSize(12).fontColor(COLORS.text2)
            TextInput({ text: this.formName, placeholder: '如:鎏金司南' })
              .height(38).fontSize(13).margin({ top: 5 })
              .onChange((v: string) => { this.formName = v; })
          }.alignItems(HorizontalAlign.Start).width('100%').margin({ top: 14 })
          // ...年代和价格字段结构相同
          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.compasses.push(new CompassItem(this.formName, this.formEra, 88, '青铜圆盘', 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%')
    }
  }

addModal 是新增司南的模态框渲染函数。整体结构是 if (this.showAdd) 条件包裹的 Stack,内含遮罩层和模态卡片。表单包含三个输入字段:器名、年代和价格,每个字段由标签和 TextInput 组成,onChange 回调将输入值同步回状态变量。确认按钮的 onClick 回调执行 this.compasses.push(new CompassItem(...)) 将新器物添加到列表,精度固定为 88,材质固定为"青铜圆盘",价格通过 Number() 转换。模态卡片设置宽度 88%、最大高度 80%、zIndex(999) 确保层叠在遮罩层之上。

6.17 编辑方位模态框 editModal

  @Builder
  editModal() {
    if (this.showEdit) {
      Stack() {
        this.modalOverlay(() => { this.showEdit = false; })
        Column() {
          Text('编辑方位').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.text1)
          if (this.selDir) {
            Text(this.selDir.name + ' · 当前 ' + this.selDir.deg + '°')
              .fontSize(12).fontColor(COLORS.text2).margin({ top: 6 })
          }
          Text('微调方位角度,保证司南精确').fontSize(11).fontColor(COLORS.text3).margin({ top: 3 })
          Row() {
            Text('偏东 +2°').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.cardBg)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.accent).borderRadius(10)
              .onClick(() => {
                if (this.selDir) { this.selDir.deg = (this.selDir.deg + 2) % 360; }
                this.showEdit = false;
              })
            Text('偏西 -2°').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.cardBg)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.cool).borderRadius(10)
              .margin({ left: 10 })
              .onClick(() => {
                if (this.selDir) { this.selDir.deg = (this.selDir.deg - 2 + 360) % 360; }
                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%')
    }
  }

editModal 是编辑方位的模态框。核心是两个微调按钮——"偏东 +2 度"使用强调色背景,点击后执行 this.selDir.deg = (this.selDir.deg + 2) % 360;"偏西 -2 度"使用冷色背景,点击后执行 this.selDir.deg = (this.selDir.deg - 2 + 360) % 360。两个按钮的颜色与功能语义呼应——"偏东"用暖色暗示向东偏转,"偏西"用冷色暗示向西偏转。由于 selDir@State 变量且 DirectionItem@Observed 类,修改 deg 后绑定的 UI 会自动刷新。

6.18 删除订单模态框 delModal

  @Builder
  delModal() {
    if (this.showDel) {
      Stack() {
        this.modalOverlay(() => { this.showDel = false; })
        Column() {
          Text('删除订单').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.text1)
          if (this.selOrder) {
            Text('确认删除「' + this.selOrder.name + '」订单?')
              .fontSize(12).fontColor(COLORS.text2).margin({ top: 8 })
          }
          Text('删除后不可恢复').fontSize(10).fontColor(COLORS.danger).margin({ top: 4 })
          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%')
    }
  }

delModal 是删除订单的确认模态框。红色警告文字"删除后不可恢复"通过颜色和文字双重强调操作的严重性。确认删除按钮使用红色背景,与警告文字呼应,形成统一的危险操作视觉语言。确认删除的 onClick 执行 this.orders.splice(this.orders.indexOf(this.selOrder), 1)——先通过 indexOf 找到选中订单在数组中的索引,然后 splice 删除。由于 orders@State 变量,删除后列表会自动刷新。

6.19 页面主构建函数 build

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            this.pageHeader()
            this.tabContent()
          }
          .width('100%')
          .padding({ left: 14, right: 14, bottom: 12 })
        }
        .scrollable(ScrollDirection.Vertical)
        .layoutWeight(1)
        .backgroundColor(COLORS.bg)
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)
      if (this.showAdd) {
        this.addModal()
      }
      if (this.showEdit) {
        this.editModal()
      }
      if (this.showDel) {
        this.delModal()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}

build() 是组件的核心方法,描述了整个页面的 UI 结构。最外层是一个 Stack——将模态框层叠在主内容之上。Stack 内部首先是主内容区 Column(包含 Scroll 可滚动区域和 bottomBar 底部导航栏),然后是三个条件渲染的模态框。

主内容区 Column 包含两部分:上方是 Scroll 可滚动区域(包含 pageHeadertabContent),下方是 bottomBarScroll 通过 .layoutWeight(1) 占据除底部导航外的所有垂直空间,.scrollable(ScrollDirection.Vertical) 启用垂直滚动。这种"Scroll 加固定底栏"的布局是移动端应用最常见的页面结构——顶部内容可滚动,底部导航固定。

三个模态框的条件渲染使用 if 语句——只有对应状态为 true 时才渲染模态。由于模态在 Stack 中位于主内容之后声明,它们会覆盖在主内容之上。build() 方法的设计体现了 ArkUI 声明式 UI 的核心理念——UI 是状态的函数,所有 UI 变化都由状态变化自动触发,开发者无需手动操作 DOM 或调用重绘方法。


第七章:状态管理——@State 与 @Observed 的协同分析

7.1 @State:组件级响应式状态

在本项目中,@State 是使用最广泛的状态装饰器。CompassPage 组件内部声明了多达十六个 @State 变量,涵盖了从 UI 状态到数据列表的全部响应式数据。@State 的核心机制是:当被装饰的变量值发生变化时,ArkUI 框架会自动触发引用了该变量的 UI 部分重新渲染。

@State 变量分为几个功能类别。第一类是 UI 交互状态:curTab(当前标签索引)、breath(呼吸动画开关)、showAddshowEditshowDel(模态框显示开关)。第二类是选中项状态:selDir(选中的方位对象)、selOrder(选中的订单对象)。第三类是表单状态:formNameformEraformPrice。第四类是数据列表状态:compassesdirsscalessmithsstepsorders

@State 对数组的操作支持是本项目的重要特性。当调用 this.compasses.push(...) 添加新器物时,或调用 this.orders.splice(...) 删除订单时,ArkUI 框架会检测到数组变化,自动触发 ForEach 重新渲染。这种"数组变异自动触发渲染"的机制是 ArkUI 响应式系统的核心能力之一。

7.2 @Observed:可观察对象类

@Observed 装饰器用于标记类为可观察对象。本项目定义了六个 @Observed 类。@Observed 的作用是使类的实例属性变为可观察的——当属性被修改时,绑定该实例的 UI 会自动刷新。在编辑方位模态框中,this.selDir.deg = (this.selDir.deg + 2) % 360 这行代码修改了 DirectionItem 实例的 deg 属性。由于 DirectionItem@Observed 类,且 selDir@State 变量,这个属性修改会被框架捕获,触发绑定了 selDir 的 UI 部分自动更新。

7.3 状态管理的协同工作模式

本项目的状态管理体系可以概括为"@State@Observed 的组合模式"。@State 管理组件级的状态(包括数组列表),@Observed 使数组中的对象实例属性可观察。当数组本身变化(增删项)时,@State 触发 ForEach 重新渲染;当对象属性变化时,@Observed 触发绑定该对象的 UI 片段更新。

在订单管理流程中:用户点击"删除"按钮,selOrder 被赋值,showDel 设为 true,模态框渲染。用户点击"确认删除",splice 从数组中移除该订单,@State 检测到数组变化,ForEach 重新渲染。showDel 设为 false,模态框消失。

在方位编辑流程中:用户点击角度数值,selDir 被赋值,showEdit 设为 true。用户点击"偏东 +2 度",selDir.deg 被修改,@Observed 检测到属性变化。showEdit 设为 false,用户回到列表页,角度数值和进度条已更新。

在新增器物流程中:用户触发 showAdd = true,输入数据通过 onChange 实时更新 formName 等状态。用户点击"确认登记",push 添加新项,@State 检测到数组变化,列表自动刷新。


第八章:布局样式——Flex 布局与 layoutWeight 的深度解析

8.1 layoutWeight:弹性空间分配

layoutWeight 是 ArkUI 中弹性布局的核心属性。在本项目中,layoutWeight 被广泛用于列表行的中间区域,使其占据名称和尾部数值之间的所有剩余空间。以 compassRow 为例:Row 内有三个子元素——器物名称(自然宽度)、中间 ColumnlayoutWeight(1))、价格(自然宽度)。layoutWeight(1) 使中间 Column 占据 Row 总宽度减去名称和价格宽度后的所有剩余空间。

bottomItem 中,每个标签项设置 layoutWeight(1),三个标签项权重相同,等分 Row 的宽度。在 orderChart 中,每根柱子的 Column 也设置 layoutWeight(1),十根柱子等分宽度。layoutWeight 与百分比宽度的区别在于:百分比宽度是相对于父容器总宽度的,而 layoutWeight 是相对于其他兄弟元素的权重。当需要"固定宽度加弹性宽度加固定宽度"的三段式布局时,layoutWeight 是唯一的选择。

8.2 Stack 堆叠布局

Stack 是 ArkUI 的堆叠容器,子元素按照声明顺序层叠排列。本项目在两处关键位置使用了 Stack:头部盘面和页面根布局。头部 pageHeader 中的 Stack 将渐变背景、星点、同心圆、装饰环、刻度线、方位文字、勺身、标题、统计数字等十余层元素堆叠在一起,通过 .position({ x, y }) 精确定位每个元素。页面根 build() 中的 Stack 用于将模态框层叠在主内容之上。

8.3 Scroll 滚动容器与 Column/Row 线性布局

Scroll 是 ArkUI 的滚动容器,使其子内容可以在指定方向上滚动。本项目的 build() 中使用了 Scroll 包裹 pageHeadertabContent,通过 .scrollable(ScrollDirection.Vertical) 启用垂直滚动。Scroll 通过 .layoutWeight(1) 占据 bottomBar 之外的所有垂直空间,保证了滚动区域最大化。这种"Scroll 加固定底栏"的布局是移动端应用最常见的页面结构。

ColumnRow 是 ArkUI 最基础的线性布局容器。Column 使子元素纵向排列,Row 使子元素横向排列。本项目大量使用了这两种容器来构建各种布局结构。在列表行中,外层 Row 实现水平排列,内层 Column 实现垂直排列。ColumnRow 都支持 alignItems 属性控制子元素在交叉轴上的对齐方式,本项目在多处使用了 HorizontalAlign.StartHorizontalAlign.EndVerticalAlign.Bottom 等对齐属性。


第九章:架构流程图——Mermaid 可视化分析

9.1 应用整体架构流程图

UI渲染层

状态管理层

数据层

ColorPalette 色彩体系

TabList 标签系统

CompassItem 司南器物

DirectionItem 方位

ScaleItem 刻度盘

SmithItem 匠师

CompassStepItem 工序

CompassOrderItem 订单

@State curTab 标签索引

@State breath 呼吸开关

@State showAdd/showEdit/showDel 模态开关

@State selDir/selOrder 选中项

@State formName/Era/Price 表单

@State compasses/dirs/scales/smiths/steps/orders 数据列表

pageHeader 头部盘面

tabContent 标签内容

bottomBar 底部导航

addModal 新增模态

editModal 编辑模态

delModal 删除模态

这张流程图展示了应用的三层架构:数据层、状态管理层和 UI 渲染层。数据层包含了色彩体系、标签系统和六个数据模型类,它们是应用的静态配置和类型定义。状态管理层包含了所有的 @State 变量,它们是连接数据与 UI 的桥梁——数据模型的实例通过 @State 变量持有,UI 通过引用这些 @State 变量来渲染。UI 渲染层包含了所有的 @Builder 函数和 build() 方法,它们消费状态变量来生成界面。

从图中可以清晰地看到数据流动的路径:数据模型类实例化后存入 @State 数据列表变量,数据列表变量通过 ForEach 驱动 tabContent 渲染对应的列表行。色彩体系从数据层直接流向 UI 渲染层的各个组件,为它们提供颜色配置。标签系统从数据层流向 bottomBar,为其提供标签内容。模态框的状态变量控制模态的显示与隐藏,选中项变量在模态框中被使用。

9.2 用户交互流程图——删除订单

取消

确认删除

用户点击删除按钮

selOrder = item

showDel = true

delModal 渲染

用户选择

showDel = false

orders.splice 删除

@State 检测数组变化

ForEach 重新渲染

showDel = false

模态框关闭

这张流程图展示了一个完整的用户交互流程——删除订单。从用户点击删除按钮开始,到界面更新结束,整个过程由状态变量驱动,无需手动操作 DOM。用户点击"删除"按钮后,selOrder 被赋值为当前订单对象,showDel 设为 true,这触发 delModal 的条件渲染——模态框出现在屏幕上。模态框展示确认信息和警告文字,用户面临两个选择:取消或确认删除。

如果用户选择取消,showDel 被设为 false,模态框消失,列表不变。如果用户选择确认删除,splice 方法从 orders 数组中移除被选中的订单项。由于 orders@State 变量,数组变化被框架检测到,ForEach 自动重新渲染订单列表——被删除的项从界面消失。最后 showDel 设为 false,模态框关闭,流程结束。整个过程中,开发者只需修改状态变量,UI 更新完全由框架自动处理。

9.3 呼吸动画驱动机制流程图

true

false

true

false

true

false

true

false

aboutToAppear 生命周期

setInterval 520ms

breath 翻转

breath 值

星点透明度 0.3

星点透明度 1.0

装饰环 scale 1.12

装饰环 scale 1.0

勺身旋转 14度

勺身旋转 -10度

柱状图金色

柱状图古铜色

UI 自动刷新

这张流程图展示了"呼吸"动画的驱动机制。aboutToAppear 生命周期函数启动一个 520 毫秒的定时器,定时器每次触发时翻转 breath 布尔值。由于 breath@State 变量,每次翻转都会触发所有引用了 breath 的 UI 部分重新渲染。

breath 的翻转驱动了四组动画效果:头部星点的透明度在 0.3 和 1.0 之间交替,形成闪烁效果;三层装饰环的缩放在 1.0 和 1.12 之间交替,形成扩散效果;司南勺身的旋转角度在 -10 度和 14 度之间交替,形成摆动效果;订单柱状图的颜色在金色和古铜色之间交替,形成闪烁效果。所有这些动画效果共享同一个 breath 变量,因此它们的节奏是协调一致的——整个界面在同一个"呼吸"频率下律动。定时器循环执行,breath 不断翻转,动画永不停止,直到页面销毁。


第十章:技术对比表格

下表对本项目中使用的主要 ArkUI 技术特性进行了对比总结:

技术特性用途说明本项目应用场景优势分析
@Entry标记页面入口组件CompassPage 主组件确保组件作为页面根节点被框架识别和渲染
@Component声明自定义组件CompassPage 结构体使结构体获得组件能力,支持 build 方法描述 UI
@State组件级响应式状态十六个状态变量变量变化自动触发 UI 更新,支持数组变异检测
@Observed可观察对象类装饰器六个数据模型类对象属性变化触发细粒度 UI 更新,避免全量重绘
@Builder可复用 UI 构建函数十九个 Builder 函数封装 UI 片段,支持参数传递,提高代码复用性
ForEach列表渲染组件司南/方位/刻度等六个列表基于键值的 diff 算法,最小化渲染开销
Stack堆叠布局容器头部盘面和页面根布局子元素层叠排列,支持精确定位和模态覆盖
Scroll滚动容器主内容区支持内容超出屏幕时的垂直滚动
layoutWeight弹性空间分配列表行中间区域和导航项按权重分配剩余空间,实现自适应三段式布局
linearGradient线性渐变背景头部盘面背景两色渐变营造青铜器深沉纵深感
animation属性动画星点/环/勺身/柱状图交替播放模式实现呼吸效果,无限循环
position绝对定位头部所有装饰元素精确坐标定位,实现司南盘面构图
rotate旋转变换刻度线和勺身径向排列刻度线,勺身摆动模拟磁勺
scale缩放变换三层装饰环配合 breath 形成磁场扩散效果
opacity透明度控制装饰环和星点递减透明度模拟能量消散,交替模拟闪烁
borderRadius圆角设置卡片/圆形/按钮统一圆角语言,卡片 12、圆 56、按钮 10
TextInput文本输入组件新增模态表单支持占位符和值绑定,onChange 实时同步
PlayMode.Alternate动画交替播放所有动画正反向交替播放,形成平滑过渡而非跳变

下表对比了本项目中使用的六种数据模型类的设计差异:

类名属性数量可变性应用标签交互能力颜色映射函数
CompassItem5价格可改司南通过模态新增无直接映射
DirectionItem5角度可微调方位点击角度进入编辑dirTagColor
ScaleItem5不可变刻度纯展示rankColor
SmithItem5不可变匠师纯展示titleColor
CompassStepItem5不可变工序纯展示stepColor
CompassOrderItem5可删除订单点击删除并确认无直接映射

下表对比了本项目中三个模态框的设计模式:

模态框触发条件操作类型按钮数量主按钮颜色警告提示
新增司南showAdd表单输入加提交2(取消+确认)冷色铜绿
编辑方位showEdit数值微调3(偏东+偏西+取消)强调色古铜
删除订单showDel确认删除2(取消+确认删除)红色danger“删除后不可恢复”

第十一章:技术要点总结

一、声明式 UI 范式的深度实践

本项目通过 ArkTS 声明式 UI 框架,完整地展示了一个文化主题移动应用从数据定义到 UI 渲染的全栈实现。声明式 UI 的核心理念是"UI 是状态的函数"——开发者只需描述状态变量与 UI 的映射关系,框架自动处理状态变化时的 UI 更新。在本项目中,curTab 状态决定了标签内容的切换,breath 状态驱动了全局呼吸动画,showAdd/showEdit/showDel 状态控制了模态框的显隐,compasses/dirs/scales/smiths/steps/orders 状态数组通过 ForEach 渲染为列表 UI。这种模式使得开发者可以专注于业务逻辑和数据操作,而无需关心 UI 更新的时机和方式——框架会在状态变化时自动触发精确的 UI 刷新。

声明式 UI 相比传统的命令式 UI(如 Android View 系统、iOS UIKit)有显著优势。在命令式 UI 中,开发者需要手动调用 setText()setVisibility() 等方法来更新界面,代码冗长且容易遗漏。而在声明式 UI 中,开发者只需修改状态变量,UI 自动跟随——这大大减少了样板代码,降低了 bug 率,提高了开发效率。本项目中,添加一件新司南只需一行 this.compasses.push(...) 代码,列表 UI 会自动刷新显示新项;删除一笔订单只需一行 this.orders.splice(...) 代码,列表 UI 会自动移除对应项。这种简洁性是声明式 UI 的核心价值。

二、响应式状态管理的精细设计

本项目的状态管理体系是 ArkUI 响应式系统的典范案例。通过 @State@Observed 的组合,实现了从组件级状态到对象属性级响应的完整覆盖。@State 管理了十六个状态变量,覆盖了 UI 交互状态、选中项状态、表单状态和数据列表状态四个类别。@Observed 标记了六个数据模型类,使它们的实例属性变化能够触发绑定的 UI 片段更新。

特别值得关注的是 @State 对数组的支持。ArkUI 框架通过代理数组的变异方法(pushsplicepop 等)来检测数组变化。当 this.compasses.push(new CompassItem(...)) 执行时,框架拦截 push 操作,标记数组已变化,然后在下一帧触发 ForEach 重新渲染。这种机制使得数组操作与普通 JavaScript 代码完全一致,开发者无需学习特殊的 API 来触发列表更新——只需使用标准的数组方法即可。

@Observed 的细粒度更新机制则体现在方位编辑流程中。当 this.selDir.deg 被修改时,框架不仅检测到 selDir 变量的变化(引用未变,但属性变了),还通过 Proxy 代理精确识别是 deg 属性发生了变化。于是,只有绑定了 selDir.deg 的 UI 片段(角度数值和进度条宽度)会更新,其他属性(如 nametag)对应的 UI 不受影响。这种精确到属性级别的更新机制,在列表项数据量大时显著提升了渲染性能。

三、视觉设计与文化主题的深度融合

本项目在视觉设计上的最大亮点是将司南这一古代器物的美学特征通过 ArkUI 的视觉 API 完整地呈现出来。色彩体系以"青铜与翠绿"为核心方案,二十一个颜色字段涵盖了从背景到文字、从装饰到功能的全部色彩需求。古铜色、亮古铜色、铜绿色、金色四种主色调再现了青铜器物的金属质感和氧化痕迹,浅黄绿色背景模拟宣纸的古朴温润,深墨绿头部渐变暗合青铜器的深沉色调。

头部盘面是视觉设计的精华,通过 Stack 堆叠十余层元素构建了一幅精密的司南盘面图。从渐变背景到星点闪烁,从同心圆到装饰环扩散,从刻度线放射排列到方位文字均匀分布,从勺身摆动到标题和统计数字,每一个元素都经过精确的坐标计算和样式配置。特别是勺身的摆动动画——通过 breath 状态驱动旋转角度在 -10 度到 14 度之间交替,模拟了磁勺在盘面上微微振荡、最终指向南方的动态效果——这是对古代司南工作原理的生动再现。

四、辅助函数的参数化设计

本项目定义了十二个辅助函数,分为坐标计算(dirX/dirY/tickX/tickY/starX/starY/ringSize)、尺寸计算(precBarW/degBarW/skillBarW/orderBarH)和颜色映射(rankColor/dirTagColor/titleColor/stepColor)三类。这些函数体现了参数化设计的思想——通过函数参数控制输出值,使得修改一个参数即可影响所有使用该函数的地方。

坐标计算函数使用极坐标转直角坐标的数学变换,将方位索引和刻度索引转换为圆周上的精确位置。尺寸计算函数将数据值(精度、角度、评分、金额)映射为进度条或柱状图的视觉尺寸。颜色映射函数将语义值(等级、标签、称号、序号)映射为颜色值,实现了"条件着色"的设计模式。所有这些函数都是纯函数——给定相同的输入,总是返回相同的输出,无副作用,便于测试和推理。

五、模态框系统的复用设计

本项目的三个模态框(新增、编辑、删除)共享了一套统一的设计模式:if 条件渲染 + Stack 堆叠 + 遮罩层 + 内容卡片。遮罩层通过 modalOverlay @Builder 函数复用,只需传入不同的 onClose 回调即可。内容卡片使用统一的样式——88% 宽度、18 像素内边距、白色背景、16 像素圆角、最大高度 80%、zIndex(999)——保证了视觉一致性。

三个模态框的操作按钮也遵循统一的设计语言:取消按钮使用浅灰色背景表示"次要操作",确认按钮使用语义色(冷色/强调色/红色)表示"主要操作"。删除模态额外使用红色警告文字"删除后不可恢复",通过颜色和文字双重强调操作的不可逆性。这种统一的模态设计语言使用户能够快速理解每个模态的操作方式,降低了学习成本。

六、潜在改进方向与技术债务

虽然本项目在架构设计和代码实现上已经相当成熟,但仍有一些潜在的改进方向。首先是定时器清理问题:aboutToAppear 中启动的 setInterval 没有在 aboutToDisappear 中清除,在组件多次创建销毁的场景下可能导致内存泄漏。其次是表单验证的缺失:新增模态中的 TextInput 没有输入验证,用户可以提交空值或非数字价格。第三是数据持久化的缺失:所有数据都是内存中的初始数据,应用关闭后新增和修改的数据会丢失。如果需要持久化,可以考虑使用 @AppStorage@StorageLink 将数据保存到应用级存储中。

此外,ForEach 的键值设计在某些场景下可能不够健壮。例如司南列表使用 item.name 作为键值,如果用户通过新增模态添加了与现有器物同名的器物,就会产生键值冲突。更稳健的做法是使用唯一 ID(如时间戳或 UUID)作为键值。这些改进方向不影响当前应用的功能,但在生产环境中值得考虑。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  bronzeA: string;
  bronzeB: string;
  patina: 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: '#F1F4EA',
  cardBg: '#FFFFFF',
  header1: '#2E4A38',
  header2: '#14231A',
  bronzeA: '#8A6D3B',
  bronzeB: '#C9A96A',
  patina: '#6F8F6E',
  gold: '#E3C57A',
  title: '#EDF4E6',
  sub: '#B8CDA6',
  text1: '#2C3B26',
  text2: '#52664A',
  text3: '#93A488',
  accent: '#8A6D3B',
  hot: '#C9A96A',
  cool: '#6F8F6E',
  danger: '#D9534F',
  tabBg: '#2E4A38',
  tabOn: '#E3C57A',
  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 DIR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7];
const RING_IDX: number[] = [0, 1, 2];
const STAR_IDX: number[] = [0, 1, 2, 3, 4, 5, 6];
const TICK_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];

const DIR_TEXT: string[] = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];

function dirX(i: number): number {
  return 170 + 44 * Math.cos((i * 45 - 90) * Math.PI / 180);
}

function dirY(i: number): number {
  return 66 + 44 * Math.sin((i * 45 - 90) * Math.PI / 180);
}

function tickX(i: number): number {
  return 170 + 56 * Math.cos((i * 30 - 90) * Math.PI / 180);
}

function tickY(i: number): number {
  return 66 + 56 * Math.sin((i * 30 - 90) * Math.PI / 180);
}

function starX(i: number): number {
  return 248 + i * 15;
}

function starY(i: number): number {
  return 24 + (i % 3) * 14;
}

function ringSize(i: number): number {
  return 74 + i * 22;
}

function precBarW(p: number): number {
  return 18 + p * 2.2;
}

function degBarW(d: number): number {
  return 16 + d * 0.9;
}

function skillBarW(s: number): number {
  return 16 + s * 1.1;
}

function orderBarH(a: number): number {
  return 24 + Math.min(a, 50000) / 500;
}

function rankColor(r: string): string {
  if (r === '上品') {
    return COLORS.accent;
  }
  if (r === '中品') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

function dirTagColor(d: string): string {
  if (d === '正' || d === '真') {
    return COLORS.accent;
  }
  return COLORS.cool;
}

function titleColor(t: string): string {
  if (t === '大国匠') {
    return COLORS.accent;
  }
  if (t === '巧匠') {
    return COLORS.hot;
  }
  return COLORS.cool;
}

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

@Observed
export class CompassItem {
  name: string;
  era: string;
  prec: number;
  disk: string;
  price: number;

  constructor(name: string, era: string, prec: number, disk: string, price: number) {
    this.name = name;
    this.era = era;
    this.prec = prec;
    this.disk = disk;
    this.price = price;
  }
}

@Observed
export class DirectionItem {
  name: string;
  deg: number;
  type: string;
  use: string;
  tag: string;

  constructor(name: string, deg: number, type: string, use: string, tag: string) {
    this.name = name;
    this.deg = deg;
    this.type = type;
    this.use = use;
    this.tag = tag;
  }
}

@Observed
export class ScaleItem {
  name: string;
  div: number;
  min: number;
  max: number;
  level: string;

  constructor(name: string, div: number, min: number, max: number, level: string) {
    this.name = name;
    this.div = div;
    this.min = min;
    this.max = max;
    this.level = level;
  }
}

@Observed
export class SmithItem {
  name: string;
  title: string;
  age: number;
  works: number;
  skill: number;

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

@Observed
export class CompassStepItem {
  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 CompassOrderItem {
  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 CompassPage {
  @State curTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State selDir: DirectionItem | null = null;
  @State selOrder: CompassOrderItem | null = null;
  @State formName: string = '';
  @State formEra: string = '';
  @State formPrice: string = '';
  @State compasses: CompassItem[] = [
    new CompassItem('司南勺', '战国', 96, '天然磁石', 88000),
    new CompassItem('汉司南盘', '汉', 90, '青铜圆盘', 52000),
    new CompassItem('唐罗盘仪', '唐', 92, '青铜 · 木', 68000),
    new CompassItem('宋水浮针', '宋', 88, '磁针 · 浮瓢', 36000),
    new CompassItem('元早罗盘', '元', 94, '铜面木框', 46000),
    new CompassItem('明航海罗盘', '明', 97, '铜轴木盘', 78000),
    new CompassItem('清堪舆罗盘', '清', 91, '多层铜盘', 98000),
    new CompassItem('司南佩', '汉', 72, '白玉', 26000),
    new CompassItem('指南鱼', '宋', 78, '薄铁片', 12000),
    new CompassItem('磁勺复刻', '当代', 95, '磁石 · 铜盘', 9800),
    new CompassItem('水罗盘', '宋', 84, '铜碗 · 磁针', 22000),
    new CompassItem('二十八宿罗盘', '明', 89, '雕漆铜芯', 66000)
  ];
  @State dirs: DirectionItem[] = [
    new DirectionItem('正北', 0, '本命方位', '定极', '正'),
    new DirectionItem('正东', 90, '日出方位', '向阳', '正'),
    new DirectionItem('正南', 180, '正阳方位', '营建', '正'),
    new DirectionItem('正西', 270, '日落方位', '行旅', '正'),
    new DirectionItem('东北', 45, '艮位', '风水', '隅'),
    new DirectionItem('东南', 135, '巽位', '航海', '隅'),
    new DirectionItem('西南', 225, '坤位', '堪舆', '隅'),
    new DirectionItem('西北', 315, '乾位', '祭祀', '隅')
  ];
  @State scales: ScaleItem[] = [
    new ScaleItem('地支刻度', 12, 0, 360, '中品'),
    new ScaleItem('天干刻度', 10, 0, 360, '上品'),
    new ScaleItem('二十四山', 24, 0, 360, '上品'),
    new ScaleItem('八卦方位', 8, 0, 360, '中品'),
    new ScaleItem('周天三百六十', 360, 0, 360, '上品'),
    new ScaleItem('二十八宿', 28, 0, 360, '上品'),
    new ScaleItem('六十甲子', 60, 0, 360, '中品'),
    new ScaleItem('分金刻度', 120, 0, 360, '下品')
  ];
  @State smiths: SmithItem[] = [
    new SmithItem('郑公铸', '大国匠', 72, 86, 98),
    new SmithItem('沈司南', '大国匠', 64, 78, 96),
    new SmithItem('赵罗盘', '巧匠', 52, 60, 93),
    new SmithItem('钱定极', '巧匠', 46, 52, 91),
    new SmithItem('孙磁针', '巧匠', 40, 44, 88),
    new SmithItem('李浮瓢', '匠人', 34, 30, 84),
    new SmithItem('周刻度', '匠人', 28, 22, 79),
    new SmithItem('吴铜面', '学徒', 20, 12, 72)
  ];
  @State steps: CompassStepItem[] = [
    new CompassStepItem('采石', 3, '山锤 · 罗盘', '觅天然磁石', 1),
    new CompassStepItem('琢勺', 5, '磨石 · 水', '磨勺形', 2),
    new CompassStepItem('铸盘', 4, '熔炉 · 范', '铸青铜盘', 3),
    new CompassStepItem('刻纹', 3, '錾刀', '刻方位字', 4),
    new CompassStepItem('磁化', 1, '磁石摩擦', '定磁极', 5),
    new CompassStepItem('校准', 2, '日影 · 夜星', '对正北', 6),
    new CompassStepItem('装轴', 1, '铜轴 · 玉珠', '减摩擦', 7),
    new CompassStepItem('试航', 2, '水池 · 帆船', '验证指向', 8)
  ];
  @State orders: CompassOrderItem[] = [
    new CompassOrderItem('司南勺', '博物馆', 88000, 1, '2026-08'),
    new CompassOrderItem('汉司南盘', '考古院', 156000, 3, '2026-07'),
    new CompassOrderItem('明航海罗盘', '航海博物馆', 234000, 3, '2026-08'),
    new CompassOrderItem('磁勺复刻', '文创品牌', 98000, 10, '2026-06'),
    new CompassOrderItem('清堪舆罗盘', '收藏家', 98000, 1, '2026-07'),
    new CompassOrderItem('水罗盘', '影视剧组', 66000, 3, '2026-05'),
    new CompassOrderItem('二十八宿罗盘', '天文馆', 132000, 2, '2026-08'),
    new CompassOrderItem('司南佩', '玉器店', 78000, 3, '2026-06'),
    new CompassOrderItem('宋水浮针', '教材出版社', 36000, 1, '2026-04'),
    new CompassOrderItem('指南鱼', '研学机构', 48000, 4, '2026-05')
  ];

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

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

  @Builder
  pageHeader() {
    Column() {
      Stack() {
        Column()
          .width('100%')
          .height('100%')
          .borderRadius(22)
          .linearGradient({
            angle: 135,
            colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
          })
        ForEach(STAR_IDX, (i: number) => {
          Column()
            .width(3)
            .height(3)
            .borderRadius(1.5)
            .backgroundColor(COLORS.gold)
            .opacity(this.breath ? 0.3 : 1)
            .position({ x: starX(i), y: starY(i) })
            .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 's' + i)
        Column()
          .width(112)
          .height(112)
          .borderRadius(56)
          .backgroundColor(COLORS.bronzeA)
          .border({ width: 3, color: COLORS.gold })
          .position({ x: 114, y: 8 })
        Column()
          .width(96)
          .height(96)
          .borderRadius(48)
          .border({ width: 1.5, color: COLORS.bronzeB })
          .position({ x: 122, y: 16 })
        ForEach(RING_IDX, (i: number) => {
          Column()
            .width(ringSize(i))
            .height(ringSize(i))
            .borderRadius(ringSize(i) / 2)
            .border({ width: 1, color: COLORS.patina })
            .opacity(0.35 - i * 0.1)
            .scale({ x: this.breath ? 1.12 : 1, y: this.breath ? 1.12 : 1 })
            .position({ x: 170 - ringSize(i) / 2, y: 66 - ringSize(i) / 2 })
            .animation({ duration: 1300 - i * 300, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'g' + i)
        ForEach(TICK_IDX, (i: number) => {
          Column()
            .width(1.5)
            .height(i % 3 === 0 ? 8 : 5)
            .backgroundColor(COLORS.bronzeB)
            .position({ x: tickX(i), y: tickY(i) })
            .rotate({ angle: i * 30 })
        }, (i: number) => 'k' + i)
        ForEach(DIR_IDX, (i: number) => {
          Text(DIR_TEXT[i])
            .fontSize(9)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.bronzeB)
            .position({ x: dirX(i) - 6, y: dirY(i) - 7 })
        }, (i: number) => 'd' + i)
        Stack() {
          Column()
            .width(34)
            .height(8)
            .borderRadius(4)
            .backgroundColor(COLORS.gold)
          Column()
            .width(7)
            .height(7)
            .borderRadius(3.5)
            .backgroundColor(COLORS.bronzeB)
        }
        .rotate({ angle: this.breath ? 14 : -10 })
        .animation({ duration: 900, iterations: -1, playMode: PlayMode.Alternate })
        .position({ x: 170, y: 66 })
        Text('南')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
          .position({ x: 266, y: 14 })
        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
  compassRow(item: CompassItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Column() {
        Text(item.era + ' · ' + item.disk)
          .fontSize(10)
          .fontColor(COLORS.text3)
        Row() {
          Text('指向')
            .fontSize(9)
            .fontColor(COLORS.text3)
          Column()
            .width(precBarW(item.prec))
            .height(6)
            .borderRadius(3)
            .backgroundColor(COLORS.bronzeA)
            .margin({ left: 6 })
          Text(item.prec + '%')
            .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
  dirRow(item: DirectionItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.tag)
        .fontSize(9)
        .fontColor(COLORS.cardBg)
        .backgroundColor(dirTagColor(item.tag))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text(item.type + ' · ' + item.use)
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(degBarW(item.deg))
          .height(5)
          .borderRadius(2)
          .backgroundColor(COLORS.patina)
          .margin({ top: 3 })
      }
      .alignItems(HorizontalAlign.End)
      .layoutWeight(1)
      Text(item.deg + '°')
        .fontSize(11)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.hot)
        .onClick(() => {
          this.selDir = item;
          this.showEdit = true;
        })
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  scaleRow(item: ScaleItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.level)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(rankColor(item.level))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Text(item.div + ' 分格 · ' + item.min + '-' + item.max + '°')
        .fontSize(10)
        .fontColor(COLORS.text3)
        .layoutWeight(1)
        .textAlign(TextAlign.End)
    }
    .width('100%')
    .padding(12)
    .backgroundColor(COLORS.cardBg)
    .borderRadius(12)
    .margin({ top: 8 })
  }

  @Builder
  smithRow(item: SmithItem) {
    Row() {
      Text(item.name)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(COLORS.text1)
      Text(item.title)
        .fontSize(10)
        .fontColor(COLORS.cardBg)
        .backgroundColor(titleColor(item.title))
        .borderRadius(8)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .margin({ left: 8 })
      Column() {
        Text('技艺 ' + item.skill + ' · ' + item.works + ' 件')
          .fontSize(9)
          .fontColor(COLORS.text3)
        Column()
          .width(skillBarW(item.skill))
          .height(6)
          .borderRadius(3)
          .backgroundColor(COLORS.bronzeB)
          .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: CompassStepItem) {
    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: CompassOrderItem) => {
        Column() {
          Column()
            .width(12)
            .height(orderBarH(item.amount))
            .borderRadius(3)
            .backgroundColor(this.breath ? COLORS.gold : COLORS.bronzeA)
            .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: CompassOrderItem) => 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: CompassOrderItem) {
    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.compasses, (item: CompassItem) => {
          this.compassRow(item)
        }, (item: CompassItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 1) {
      Column() {
        Text('八方位体系 · 点击角度可微调')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.dirs, (item: DirectionItem) => {
          this.dirRow(item)
        }, (item: DirectionItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 2) {
      Column() {
        Text('刻度盘 · 分格愈细愈精密')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.scales, (item: ScaleItem) => {
          this.scaleRow(item)
        }, (item: ScaleItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 3) {
      Column() {
        Text('铸司南匠师 · 一锤一磨定方向')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.smiths, (item: SmithItem) => {
          this.smithRow(item)
        }, (item: SmithItem) => item.name)
      }
      .width('100%')
      .margin({ top: 10 })
    }
    if (this.curTab === 4) {
      Column() {
        Text('制南八序 · 磨石铸盘对星斗')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .width('100%')
        ForEach(this.steps, (item: CompassStepItem) => {
          this.stepRow(item)
        }, (item: CompassStepItem) => 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: CompassOrderItem) => {
          this.orderRow(item)
        }, (item: CompassOrderItem) => 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 ? '#3D604A' : 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.formEra, placeholder: '如:唐' })
              .height(38)
              .fontSize(13)
              .margin({ top: 5 })
              .onChange((v: string) => {
                this.formEra = v;
              })
          }
          .alignItems(HorizontalAlign.Start)
          .width('100%')
          .margin({ top: 12 })
          Column() {
            Text('价格(元)')
              .fontSize(12)
              .fontColor(COLORS.text2)
            TextInput({ text: this.formPrice, placeholder: '如:36000' })
              .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.compasses.push(new CompassItem(this.formName, this.formEra, 88, '青铜圆盘', 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.selDir) {
            Text(this.selDir.name + ' · 当前 ' + this.selDir.deg + '°')
              .fontSize(12)
              .fontColor(COLORS.text2)
              .margin({ top: 6 })
          }
          Text('微调方位角度,保证司南精确')
            .fontSize(11)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
          Row() {
            Text('偏东 +2°')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.accent)
              .borderRadius(10)
              .onClick(() => {
                if (this.selDir) {
                  this.selDir.deg = (this.selDir.deg + 2) % 360;
                }
                this.showEdit = false;
              })
            Text('偏西 -2°')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.cool)
              .borderRadius(10)
              .margin({ left: 10 })
              .onClick(() => {
                if (this.selDir) {
                  this.selDir.deg = (this.selDir.deg - 2 + 360) % 360;
                }
                this.showEdit = false;
              })
          }
          .width('100%')
          .margin({ top: 16 })
          Text('取消')
            .fontSize(13)
            .fontColor(COLORS.text2)
            .width('100%')
            .textAlign(TextAlign.Center)
            .padding({ top: 10, bottom: 10 })
            .backgroundColor('#F2F2F2')
            .borderRadius(10)
            .margin({ top: 10 })
            .onClick(() => {
              this.showEdit = false;
            })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .constraintSize({ maxHeight: '80%' })
        .position({ x: 0, y: 0 })
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
  }

  @Builder
  delModal() {
    if (this.showDel) {
      Stack() {
        this.modalOverlay(() => {
          this.showDel = false;
        })
        Column() {
          Text('删除订单')
            .fontSize(17)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.text1)
          if (this.selOrder) {
            Text('确认删除「' + this.selOrder.name + '」订单?')
              .fontSize(12)
              .fontColor(COLORS.text2)
              .margin({ top: 8 })
          }
          Text('删除后不可恢复')
            .fontSize(10)
            .fontColor(COLORS.danger)
            .margin({ top: 4 })
          Row() {
            Text('取消')
              .fontSize(13)
              .fontColor(COLORS.text2)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor('#F2F2F2')
              .borderRadius(10)
              .onClick(() => {
                this.showDel = false;
              })
            Text('确认删除')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.cardBg)
              .layoutWeight(1)
              .textAlign(TextAlign.Center)
              .padding({ top: 10, bottom: 10 })
              .backgroundColor(COLORS.danger)
              .borderRadius(10)
              .margin({ left: 10 })
              .onClick(() => {
                if (this.selOrder) {
                  this.orders.splice(this.orders.indexOf(this.selOrder), 1);
                }
                this.showDel = false;
              })
          }
          .width('100%')
          .margin({ top: 16 })
        }
        .width('88%')
        .padding(18)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(16)
        .constraintSize({ maxHeight: '80%' })
        .position({ x: 0, y: 0 })
        .zIndex(999)
      }
      .width('100%')
      .height('100%')
    }
  }

  build() {
    Stack() {
      Column() {
        Scroll() {
          Column() {
            this.pageHeader()
            this.tabContent()
          }
          .width('100%')
          .padding({ left: 14, right: 14, bottom: 12 })
        }
        .scrollable(ScrollDirection.Vertical)
        .layoutWeight(1)
        .backgroundColor(COLORS.bg)
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)
      if (this.showAdd) {
        this.addModal()
      }
      if (this.showEdit) {
        this.editModal()
      }
      if (this.showDel) {
        this.delModal()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }
}


七、总结

在这里插入图片描述

本项目以"司南坊"为主题,通过 HarmonyOS ArkTS 声明式 UI 框架,构建了一个完整的司南器物展示与管理平台。从千年司南的历史文化背景出发,通过精心设计的色彩体系、标签系统、数据模型和 UI 组件,将古代司南文化以现代移动应用的形式重新呈现。技术实现上,项目充分运用了 @Entry@Component@State@Observed@BuilderForEachStackScrolllayoutWeightlinearGradientanimationpositionrotatescaleopacityTextInput 等 ArkUI 核心特性,覆盖了从页面结构到列表渲染、从状态管理到动画效果、从模态交互到数据操作的完整技术栈。

项目的架构设计体现了清晰的分层思想:数据层(接口和常量)、逻辑层(辅助函数和数据模型)、状态层(@State@Observed)、渲染层(@Builderbuild)各司其职,通过响应式数据流自动连接。这种架构使得代码结构清晰、可维护性强、扩展性好——添加新功能模块只需新增一个标签、一个数据模型类、一个行渲染函数和一个 tabContent 分支即可,不影响现有代码。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐