一、罗盘堪舆的历史文化背景

罗盘,古称罗经、罗镜、罗盘针、指南针盘,是中国古代堪舆学(风水学)中最核心的测量工具,也是中华文明对世界科技史最为卓著的贡献之一。其起源可追溯至战国时期的司南,距今已有两千余年的历史。据《韩非子·有度》记载:“先王立司南以端朝夕”,可见早在先秦时期,先民便已利用磁性指向的特性来辨别方位、确定朝向。司南以天然磁石雕琢成勺形,置于光滑的地盘上,勺柄指向南方,这便是罗盘最原始的雏形。

在这里插入图片描述

汉代以降,随着阴阳五行学说的成熟与天文学的发展,司南逐渐演化为更为精密的磁针式仪器。宋代沈括在《梦溪笔谈》中对指南针的制造工艺有详细记载,并首次发现了磁偏角的存在,比欧洲早了四百余年。沈括描述了四种指南针的安装方式:水浮法、指甲旋定法、碗唇旋定法和缕悬法,其中缕悬法以蚕丝悬吊磁针,灵敏度极高,堪称当时世界上最先进的磁指向技术。到了南宋时期,罗盘已从单纯的指南工具发展为集方位测量、天文观测、堪舆定盘于一体的综合性仪器,其盘面上开始刻有天干、地支、八卦、二十四山等复杂的方位标识。

二十四山是罗盘堪舆体系中最基础也最重要的方位划分方法。它将周天三百六十度均分为二十四等份,每份十五度,以十二地支(子丑寅卯辰巳午未申酉戌亥)、八干(甲乙丙丁庚辛壬癸)和四维(乾坤艮巽)共同组成。其中子为正北、午为正南、卯为正东、酉为正西,其余各山按顺时针方向排列。二十四山不仅用于方位标定,更与五行生克、八卦方位、纳甲纳甲等术数体系紧密关联,是堪舆实践中判断龙脉走向、水口位置、坐向吉凶的根本依据。每一山都有其特定的五行属性、卦象对应和吉凶宜忌,堪舆师通过罗盘测定某一地点的坐山朝向,再结合元运飞星、山水形势等因素进行综合分析,最终给出风水布局的建议。

天池是罗盘正中央的圆形区域,因其形如池、内置磁针而得名。天池内注水或装有透明玻璃罩,磁针浮于其上或以顶针支撑,可以自由旋转以指示南北方向。天池的名称寓意深远——"天"代表天道运行、天体运转,“池"则象征着聚水藏风、涵养气脉。在堪舆理论中,天池被视为罗盘的"心脏”,是整个仪器的灵气所在。天池的材质和工艺直接影响罗盘的精度和品质,古人常用天然水晶、琉璃、玉髓等珍贵材料制作天池底面,既美观大方,又能减少磁针旋转时的摩擦阻力,提高指向的准确性。磁针的磁化强度也至关重要,强磁针能够快速稳定地指向南北,而弱磁针则可能因外界干扰而产生偏差。

八宅理气是堪舆学中重要的流派之一,起源于唐代,盛行于明清。其核心理论是将住宅按坐向分为东四宅和西四宅两大类,再将人的命卦与之匹配,以判断住宅与居住者之间的吉凶关系。东四宅包括震宅(坐东向西)、巽宅(坐东南向西北)、坎宅(坐北向南)、离宅(坐南向北);西四宅包括乾宅(坐西北向东南)、坤宅(坐西南向东北)、艮宅(坐东北向西南)、兑宅(坐西向东)。每一宅又有四吉方和四凶方,吉方宜作卧室、客厅、门路,凶方则宜作厨房、厕所、储藏室等。八宅理论将方位与人的命卦相结合,体现了古人"天人合一"的哲学思想。

玄空飞星是堪舆学中另一重要流派,以洛书九宫为基础,结合三元九运的时间周期,通过九颗飞星的流转组合来判断风水吉凶。九星分别是一白贪狼、二黑巨门、三碧禄存、四绿文曲、五黄廉贞、六白武曲、七赤破军、八白左辅、九紫右弼。每颗星都有其特定的五行属性和吉凶含义,在不同元运中发挥不同的作用。玄空飞星强调"时间"与"空间"的动态结合,认为风水的吉凶并非固定不变,而是随着时间推移而流转变化,这一理念比静态的八宅理论更为精密和灵活。

奇门遁甲是中国古代最高层次的预测术数之一,有"帝王之学"的美誉。它以洛书九宫为框架,结合天干、地支、八门、九星、八神等要素,通过排盘推演来预测吉凶、选择方位、制定策略。奇门遁甲的盘面结构极为复杂,包含天盘、地盘、人盘、神盘等多层信息,其排盘和解读需要深厚的术数功底。在古代,奇门遁甲主要用于军事决策和帝王治国,传说中诸葛亮借东风、刘伯温辅佐朱元璋定天下,都与奇门遁甲的运用有关。在罗盘堪舆中,奇门遁甲盘是一种特殊的高层罗盘,专用于择吉选方、趋吉避凶。

大六壬是与奇门遁甲、太乙神数并称"三式"的古代高级术数体系。大六壬以天地盘为基础,通过四课三传的推演来预测事物的发展趋势。在堪舆实践中,大六壬可用于择日、选方、断事,是堪舆师必备的高级技能之一。大六壬盘的结构同样复杂,包含天盘地支、天将、神煞等多层信息,对罗盘的层数和精度有较高要求。

堪舆师是堪舆学的实践者和传承者,在古代社会中享有崇高的地位。堪舆师不仅要精通罗盘的使用方法,还要熟悉天文地理、阴阳五行、八卦易经等广博的知识体系。堪舆师的等级划分由来已久,从"学徒"到"名师",从"大师"到"国师",每一级别都代表着不同的学识水平和实践经验。"国师"是堪舆师的最高荣誉,通常由朝廷册封,负责皇家陵寝、宫殿等重要建筑的堪舆选址。"大师"则是民间公认的堪舆高手,学养深厚、经验丰富。"名师"是在某一领域有所专长的堪舆从业者,而"学徒"则是初入门径、尚在学习阶段的堪舆新人。

罗盘的制作工艺也是一门精湛的传统手艺。一把上好的罗盘,从选材到成品需要经过数十道工序。盘体材料多选用黄檀木、黑檀木、紫檀木、红木等硬质木材,这些木材质地细密、纹理美观、不易变形,能够保证罗盘盘面的平整度和稳定性。盘面的刻度文字由工匠以极细的毛笔或刻刀逐一刻写,要求字迹清晰、排列精准、无一错漏。天池的安装、磁针的磁化、盘层的叠加,每一环节都考验着制作者的经验和耐心。正因如此,优质罗盘往往价格不菲,是堪舆师视若珍宝的传家之物。

时至今日,罗盘堪舆之学并未因现代科技的发展而式微,反而借助数字化手段焕发出新的生机。将古老的罗盘堪舆知识以移动应用的形式呈现,不仅能够让更多人了解和接触这一传统文化瑰宝,更能在交互体验上实现传统罗盘所不具备的功能——如动态指针动画、数据可视化的方位精度展示、堪舆师名录管理、订单追踪等。本文所解析的HarmonyOS ArkTS应用,正是这一理念的具体实践。它以罗盘堪舆为主题,运用ArkTS声明式UI框架,构建了一个集罗盘展示、天池管理、方位分析、二十四山查询、堪舆师名录、订单管理于一体的综合性应用,在传承传统文化的同时,展现了现代移动开发技术的精湛工艺。

二、色彩体系深度解析

2.1 ColorPalette 接口定义

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  discA: string;
  discB: string;
  ringA: string;
  ringB: string;
  needleA: string;
  needleB: 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 接口定义了整个应用所使用的全部色彩规范,共包含二十三个颜色字段。这种以接口形式统一定义色彩体系的做法,是现代前端开发中最佳实践之一。通过将所有颜色值集中管理,开发者可以在一处修改、全局生效,避免了颜色值散落在各处导致的维护困难和风格不一致问题。

接口中每个字段都有明确的语义命名,使代码具有极强的自解释性。bg 代表页面背景色,cardBg 代表卡片背景色,header1 和 header2 用于头部渐变效果的起始和终止颜色。discA 和 discB 对应罗盘盘面的内外层颜色,ringA 和 ringB 对应罗盘环线的颜色,needleA 和 needleB 则是磁针指针的渐变色。这种命名方式直接反映了颜色在UI中的用途,使后续维护者无需查阅文档即可理解每个颜色的作用。

从架构设计的角度看,ColorPalette 接口的定义体现了"抽象先行"的编程理念。先定义接口(即色彩的抽象规范),再提供具体实现(即COLORS常量),这种模式使得未来如果需要支持暗色模式或多主题切换,只需新增一个实现ColorPalette接口的常量对象即可,而无需修改任何使用色彩的组件代码。这是开闭原则(对扩展开放、对修改封闭)在实际开发中的典型应用。

在HarmonyOS ArkTS开发中,接口的使用方式与TypeScript一致,但ArkTS在此基础上增加了更严格的类型检查机制。ColorPalette 接口中的所有字段都被声明为string类型,这是因为ArkTS中的颜色值无论是十六进制格式(如’#F2EFE8’)还是rgba格式(如’rgba(0,0,0,0.45)'),都以字符串形式传递给UI组件的backgroundColor、fontColor等属性。统一使用string类型简化了类型系统,同时也与ArkTS的属性设置API保持一致。

2.2 COLORS 常量实现

const COLORS: ColorPalette = {
  bg: '#F2EFE8',
  cardBg: '#FFFFFF',
  header1: '#4E3A28',
  header2: '#221812',
  discA: '#C9A87C',
  discB: '#8A6A4E',
  ringA: '#5A4632',
  ringB: '#3A2C20',
  needleA: '#B8432E',
  needleB: '#E3B23C',
  gold: '#E3B23C',
  title: '#FAF2E6',
  sub: '#D8C0A0',
  text1: '#33261A',
  text2: '#6B5844',
  text3: '#9C8A72',
  accent: '#8A5A3A',
  hot: '#B8432E',
  cool: '#3E6B5E',
  danger: '#D9534F',
  tabBg: '#4E3A28',
  tabOn: '#F2C14E',
  mask: 'rgba(0,0,0,0.45)'
};

在这里插入图片描述

COLORS 常量是 ColorPalette 接口的具体实现,为每一个颜色字段赋予了精确的色值。从色彩搭配的角度审视,这套配色方案呈现出浓郁的古典中式美学风格——以暖色调为基底,融合了檀木色、铜金色、朱红色等传统色彩元素,完美契合罗盘堪舆这一主题的文化气质。

背景色 bg 使用 ‘#F2EFE8’,这是一种极淡的暖灰色,接近宣纸的色彩,给人一种古朴雅致的视觉感受。卡片背景 cardBg 使用纯白色 ‘#FFFFFF’,与背景色形成微妙的层次对比,使卡片内容在视觉上自然浮起。头部区域采用从 ‘#4E3A28’(深檀色)到 ‘#221812’(近黑色)的渐变,模拟古木盘体的厚重质感,营造出庄严肃穆的视觉氛围。

罗盘盘面色彩的设计尤为考究。discA ‘#C9A87C’ 是一种柔和的浅铜色,用于盘面外层;discB ‘#8A6A4E’ 是较深的木褐色,用于盘面内层。这两种颜色的搭配模拟了真实罗盘盘面的木质纹理和铜质装饰效果。环线颜色 ringA ‘#5A4632’ 和 ringB ‘#3A2C20’ 均为深棕色系,在盘面上形成层次分明的同心圆效果,如同真实罗盘上的多层刻度环。

磁针指针的色彩设计体现了巧思。needleA ‘#B8432E’ 是朱红色,代表指针的南端(在传统罗盘中,南端通常染红以区分南北);needleB ‘#E3B23C’ 是金色,代表指针的北端。这种红金渐变不仅在视觉上醒目突出,更暗合了中国传统文化中"南朱北玄"的方位色彩约定。金色 gold 与 needleB 共享同一色值 ‘#E3B23C’,在应用中广泛用于装饰性元素,如星点、刻度标记、标题强调等,为整体界面增添一抹华贵气息。

文字色彩的层级设计遵循了从深到浅的渐进原则。text1 ‘#33261A’ 是最深的文字色,用于主要标题和重要信息;text2 ‘#6B5844’ 是中等深度的文字色,用于辅助说明和描述文字;text3 ‘#9C8A72’ 是较浅的文字色,用于次要信息和标签。这种三级文字色阶确保了信息层级的清晰传达——用户一眼即可区分哪些是核心内容、哪些是辅助说明、哪些是补充信息。

功能色系的设计同样精到。accent ‘#8A5A3A’ 是棕橙色,用作操作按钮和强调元素的背景色;hot ‘#B8432E’ 是朱红色,用于表示"逆转"等需要警示的操作;cool ‘#3E6B5E’ 是青绿色,用于表示"顺转"等正向操作,也与五行中"木"的属性色相呼应;danger ‘#D9534F’ 是标准的危险红,用于删除操作的警示。这套功能色既有文化内涵,又符合现代UI设计中语义化色彩的使用规范。

底部导航栏的色彩采用 tabBg ‘#4E3A28’(深檀色)作为背景色,tabOn ‘#F2C14E’(亮金色)作为选中态颜色。选中态的亮金色在深色背景上形成强烈的视觉对比,使用户能够一眼识别当前所处的页面位置。遮罩层 mask 使用 ‘rgba(0,0,0,0.45)’ 半透明黑色,在弹窗出现时遮蔽底层内容,引导用户专注于当前交互。

2.3 色彩体系的文化映射

深入分析 COLORS 中的每一个色值,可以发现它们并非随意选取,而是经过了精心设计,以在视觉层面传达罗盘堪舆的文化内涵。整体色彩基调以暖色为主,这与中国传统美学中追求温润、内敛、含蓄的审美取向一致。檀木色系的大量使用,让人联想到古代堪舆师手持木质罗盘在山川间寻龙点穴的场景,增强了应用的文化沉浸感。

金色在应用中的高频使用也值得注意。在五行学说中,金代表西方、秋季、收敛,象征着坚固、珍贵和永恒。金色作为装饰色出现在罗盘的星点、刻度、指针北端、标题强调等多个位置,既增加了界面的精致感,又暗合了"金"在堪舆学中的特殊地位。同时,金色与檀木色的搭配是传统中式设计中经典的配色组合,在视觉上形成温暖而不失沉稳的风格。

红色系在应用中承担了双重角色。一方面,朱红色用于磁针南端,延续了传统罗盘将南端染红的古老惯例;另一方面,朱红色也用于"逆转"按钮和删除操作的警示色,利用了红色在人类视觉心理中天然的警示和紧迫感。这种一色两用的设计既保持了色彩体系的一致性,又赋予了同一色彩在不同语境下的差异化语义。

青绿色 cool ‘#3E6B5E’ 的使用则体现了五行色彩在应用中的渗透。在五行学说中,东方属木,其色青绿。应用将青绿色用于"顺转"按钮——顺转即顺时针旋转,在堪舆学中代表顺应天道、顺时而动,这与木行的"生长、升发"属性相吻合。这种将五行色彩融入交互设计的做法,是传统文化元素在现代UI设计中的创造性运用,体现了开发者对堪舆文化的深入理解。

三、标签系统深度解析

3.1 TabMeta 接口定义

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

TabMeta 接口定义了底部导航标签的元数据结构,包含两个字符串字段:label 和 icon。label 存储标签的文字名称,icon 存储标签的Emoji图标。这种将标签信息抽象为接口的设计,使得标签数据的维护变得异常简洁——新增、修改或删除标签只需操作数据数组,而无需修改组件的渲染逻辑。

在HarmonyOS ArkTS中,interface的定义方式与TypeScript保持一致,但ArkTS的编译器会对接口进行更严格的类型检查。TabMeta 接口虽然结构简单,但它为整个底部导航系统提供了类型安全保障——任何使用TabMeta类型的地方,编译器都会确保label和icon字段都已正确赋值,避免因字段缺失而导致的运行时错误。

icon字段使用Emoji而非图标资源路径,这是一个有趣的设计选择。Emoji作为Unicode标准的一部分,可以在所有平台上一致显示,无需额外加载图片资源,既减小了应用体积,又简化了资源管理流程。不过,Emoji的渲染效果在不同设备和系统版本上可能存在细微差异,这是开发者在使用Emoji作为图标时需要注意的问题。

3.2 TAB_LIST 常量实现

const TAB_LIST: TabMeta[] = [
  { label: '罗盘', icon: '🧭' },
  { label: '天池', icon: '⚪' },
  { label: '方位', icon: '🧱' },
  { label: '二十四山', icon: '🗺️' },
  { label: '堪舆师', icon: '🧙' },
  { label: '订单', icon: '📦' }
];

在这里插入图片描述

TAB_LIST 数组定义了应用底部导航栏的六个标签,每个标签对应应用的一个功能模块。从标签的排列顺序可以看出,应用的功能组织遵循了从"器"到"人"再到"事"的逻辑脉络:罗盘和天池是堪舆工具本身(器),方位和二十四山是堪舆理论体系(理),堪舆师是堪舆实践者(人),订单则是商业往来记录(事)。这种组织方式既有逻辑层次,又符合用户从了解工具到理解理论再到接触从业者和商业信息的认知路径。

第一个标签"罗盘"使用指南针Emoji ‘🧭’ 作为图标,这是整个应用的核心入口,展示各类罗盘产品的详细信息,包括名称、层数、盘环数、价格和材质。第二个标签"天池"使用白色圆圈Emoji ‘⚪’ 作为图标,对应罗盘天池的管理功能,展示不同材质和磁针类型的天池组件。第三个标签"方位"使用砖块Emoji ‘🧱’ 作为图标,展示八宫方位信息,包含五行属性和角度数据。第四个标签"二十四山"使用地图Emoji ‘🗺️’ 作为图标,展示二十四山的分组、度和用途信息。第五个标签"堪舆师"使用法师Emoji ‘🧙’ 作为图标,展示堪舆师的名录信息,包含等级、年限、案牍数和专长。第六个标签"订单"使用包裹Emoji ‘📦’ 作为图标,展示罗盘订单的往来记录和金额统计。

值得注意的是,六个标签被分为两组,每组三个,分别通过 ROW1_IDX [0,1,2] 和 ROW2_IDX [3,4,5] 两个索引数组来管理。这种分组方式使得底部导航栏以两行三列的网格形式排列,充分利用了横向空间,使每个标签拥有更宽的点击区域,提升了触摸操作的友好性。在移动设备上,底部导航栏如果横向排列过多标签,每个标签的可用宽度会被压缩,导致文字截断或图标过小,影响用户体验。两行三列的布局巧妙地解决了这一问题。

3.3 索引数组与方位字符

const ROW1_IDX: number[] = [0, 1, 2];
const ROW2_IDX: number[] = [3, 4, 5];
const RING_IDX: number[] = [0, 1, 2, 3];
const DIR_CHARS: string[] = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
const TICK_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const STAR_IDX: number[] = [0, 1, 2, 3];

在这里插入图片描述

这一组常量数组定义了应用中多处循环渲染所需的索引序列。ROW1_IDX 和 ROW2_IDX 分别索引底部导航栏第一行和第二行的标签,前面已经分析过。RING_IDX [0,1,2,3] 用于头部罗盘图案中同心圆环的渲染,四个索引对应四个不同大小的同心圆,模拟罗盘的多层盘面结构。

DIR_CHARS 数组是整个应用中最具文化内涵的常量之一。它包含了十二地支的完整序列:子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥。在罗盘堪舆中,十二地支是二十四山的重要组成部分,分布在罗盘盘面的十二个方位上。子位于正北,午位于正南,卯位于正东,酉位于正西,其余各地支按顺时针方向均匀分布。这个数组在头部罗盘图案的渲染中被使用,通过 ForEach 循环将十二地支字符按三角函数计算的位置放置在罗盘盘面的边缘,营造出真实罗盘的方位标识效果。

TICK_IDX 数组包含十二个从0到11的整数,用于头部罗盘图案中刻度标记的渲染。十二个刻度对应十二地支的十二个方位,每个刻度以小竖条的形式呈现在罗盘盘面边缘。STAR_IDX [0,1,2,3] 用于头部装饰星点的渲染,四个星点以不同大小和位置分布在头部区域,增添了一份神秘的天文氛围。

这些索引数组看似简单,但它们在代码中扮演着"渲染蓝图"的角色——ForEach 循环通过遍历这些数组来生成对应的UI元素,数组中的每一个值都对应一个具体的视觉元素。将索引序列提取为常量而非在循环中直接使用字面量,提升了代码的可读性和可维护性。如果未来需要增加罗盘环数或刻度数,只需修改对应的常量数组即可,无需改动渲染逻辑。

四、辅助函数深度解析

4.1 方位坐标计算函数

function dirX(i: number): number {
  return 150 + Math.round(78 * Math.sin(i * 3.14159 / 6));
}

function dirY(i: number): number {
  return 88 + Math.round(78 * Math.cos(i * 3.14159 / 6));
}

在这里插入图片描述

dirX 和 dirY 是一对用于计算十二地支字符在罗盘盘面上位置的函数。它们基于三角函数将线性索引(0到11)映射为罗盘圆周上的二维坐标。其中,150 和 88 分别是罗盘盘面的中心X和Y坐标,78 是地支字符距圆心的半径距离。

函数的核心数学原理是参数方程。对于一个圆心在 (cx, cy)、半径为 r 的圆,圆上任意一点的坐标可以表示为 (cx + r * sin(θ), cy + r * cos(θ)),其中 θ 是该点相对于正北方向(上方)的弧度。在这里,θ = i * π / 6,即每个地支之间相隔30度(360度 / 12 = 30度),换算为弧度即 π/6。当 i=0 时,θ=0,对应正北方位(子),坐标为 (150, 88-78) = (150, 10),即罗盘正上方。当 i=3 时,θ=π/2,对应正东方位(卯),坐标为 (150+78, 88) = (228, 88),即罗盘正右方。这种计算方式确保了十二地支字符精确地分布在罗盘盘面的边缘。

使用 Math.round 对计算结果进行四舍五入是一个细节处理。由于屏幕坐标必须是整数像素值,浮点数计算结果需要取整。Math.round 相比 Math.floor 或 Math.ceil 能产生更均匀的像素分布,减少视觉上的偏移感。此外,函数中使用 3.14159 作为圆周率的近似值而非 Math.PI,这是一个微小的精度取舍——在实际渲染中,这种近似不会产生肉眼可见的差异,但略微减少了计算量。

这两个函数体现了将数学原理应用于UI布局的设计思路。在声明式UI框架中,复杂的几何布局往往需要通过函数计算来确定元素位置,而非依赖布局容器的自动排列。dirX 和 dirY 函数封装了这种计算逻辑,使 ForEach 循环中的元素定位代码保持简洁。函数的纯函数特性(给定相同的输入总是产生相同的输出)也使得它们易于测试和调试。

4.2 罗盘环大小函数

function ringSize(i: number): number {
  return 176 - i * 36;
}

在这里插入图片描述

ringSize 函数根据索引 i 计算罗盘同心圆环的大小。函数返回 176 - i * 36,即第一个环(i=0)大小为176,第二个环(i=1)大小为140,第三个环(i=2)大小为104,第四个环(i=3)大小为68。每个环之间相差36个像素,形成由外向内逐层缩小的同心圆效果。

这种线性递减的大小设计模拟了真实罗盘的多层盘面结构。传统罗盘通常包含多层同心圆环,每层刻有不同的内容——最外层可能是二十四山,向内依次是八卦、天干、地支、二十八宿等。虽然这里的同心圆仅作为装饰元素(fill 设为 Transparent),但它们在视觉上还原了罗盘盘面的层次感,使头部图案具有更强的文化辨识度。

176 作为最大环的尺寸,与头部区域的高度(176像素)一致,确保最大的环刚好填满头部高度。68 作为最小环的尺寸,接近天池区域的直径(34像素的两倍),使最内层环与天池之间留有适当的间距。这种从外到内逐层缩小的视觉效果,配合呼吸动画(opacity 在 0.55 和 1 之间交替),营造出罗盘盘面"活"的感觉,仿佛磁针正在盘上缓缓转动。

4.3 刻度坐标计算函数

function tickX(i: number): number {
  return 150 + Math.round(92 * Math.sin(i * 3.14159 / 6));
}

function tickY(i: number): number {
  return 88 + Math.round(92 * Math.cos(i * 3.14159 / 6));
}

在这里插入图片描述

tickX 和 tickY 函数与 dirX、dirY 函数的数学原理完全相同,唯一的区别在于半径值——这里使用92而非78,意味着刻度标记位于地支字符的外圈。在罗盘盘面上,刻度标记通常位于最外层,地支字符则位于内侧一层,这种由外到内的排列顺序符合传统罗盘的盘面布局规范。

92 这个半径值经过了精心选择。头部区域的高度为176像素,中心Y坐标为88,因此从中心到顶部边缘的距离为88。92的半径意味着刻度标记的中心位于距中心92像素处,超出了头部高度的一半(88)。但由于刻度本身高度为6像素,其顶部位于 92 - 3 = 89 处,仍在头部范围内。这种精确的像素级计算确保了所有视觉元素都在可见区域内正确显示,不出现裁剪或溢出。

4.4 星点位置计算函数

function starX(i: number): number {
  return 40 + i * 60;
}

function starY(i: number): number {
  return 14 + (i % 2) * 10;
}

在这里插入图片描述

starX 和 starY 函数计算头部装饰星点的位置。starX 使用线性公式 40 + i * 60,使四个星点沿水平方向均匀分布——i=0 时在 x=40,i=1 时在 x=100,i=2 时在 x=160,i=3 时在 x=220。星点之间的间距为60像素,整个星点序列横向跨越了从40到220的范围,覆盖了头部区域的左半部分。

starY 使用 14 + (i % 2) * 10 的公式,使星点的Y坐标在14和24之间交替变化。当 i 为偶数时,Y坐标为14;当 i 为奇数时,Y坐标为24。这种锯齿状的高低交替排列,使星点不会排列在一条直线上,而是呈现出自然散落的分布效果,更接近真实星空的随机感。

星点本身使用 ‘✦’ 字符渲染,字号在8到11之间变化(8 + (i % 2) * 3),配合呼吸动画的透明度变化(在0.3和0.9之间交替),营造出星光闪烁的动态效果。这些装饰元素虽然不承载功能信息,但它们极大地提升了头部区域的视觉吸引力和文化氛围,是"形式追随功能"之外"形式本身即是体验"这一设计理念的具体体现。

4.5 等级颜色映射函数

function gradeColor(g: string): string {
  if (g === '国师') {
    return COLORS.needleA;
  }
  if (g === '大师') {
    return COLORS.gold;
  }
  if (g === '名师') {
    return COLORS.cool;
  }
  return COLORS.text3;
}

gradeColor 函数将堪舆师的等级文本映射为对应的颜色值。国师映射为 needleA(朱红色 ‘#B8432E’),大师映射为 gold(金色 ‘#E3B23C’),名师映射为 cool(青绿色 ‘#3E6B5E’),其他等级(如学徒)映射为 text3(浅灰棕色 ‘#9C8A72’)。

这种等级到颜色的映射设计体现了鲜明的视觉层级。国师使用朱红色——在传统文化中,朱红是最尊贵的色彩之一,常用于皇家和高等官员的服饰,将其用于最高等级的"国师"恰如其分。大师使用金色,金色在中国文化中象征着珍贵和尊荣,与"大师"这一高等级称谓相匹配。名师使用青绿色,既有别于国师的朱红和大師的金色,又暗合五行中"木"的属性(名师如树木般根深叶茂)。学徒使用低调的浅灰棕色,表示其尚在起步阶段,这一色彩不会喧宾夺主。

函数的实现采用了简洁的 if-return 链式判断,而非 switch 语句或对象映射。这种写法在条件数量较少时具有良好的可读性,每个条件分支的意图一目了然。最后的 return COLORS.text3 作为默认返回值,处理了所有未明确匹配的情况(如"学徒"等级),确保函数在任何输入下都返回有效的颜色值,避免因遗漏分支而导致的空值问题。

在应用中,gradeColor 函数被多处调用——masterList 中的堪舆师等级标签、poolList 中的磁针类型标签、mountainList 中的分金评分标签都使用了该函数来动态计算颜色。这种将"等级-颜色"映射逻辑集中在一个函数中的做法,确保了色彩语义在整个应用中的一致性,也使得未来的色彩调整只需修改一处。

4.6 进度条宽度计算函数

function accBarW(a: number): number {
  return 16 + Math.min(a, 100) / 1.2;
}

function craftBarW(c: number): number {
  return 16 + Math.min(c, 30) / 0.375;
}

function orderBarH(amount: number): number {
  return 24 + Math.min(amount, 60000) / 600;
}

这三个函数分别计算方位进度条宽度、工艺进度条宽度和订单柱状图高度。它们的共同特点是使用 Math.min 对输入值进行上限约束,然后进行线性缩放,再加上一个基础偏移量。

accBarW 函数将角度值(0到100)映射为进度条宽度(16到约99.3)。Math.min(a, 100) 确保即使输入超过100也不会导致进度条溢出,除以1.2是将0-100的范围缩放到0-83.3,加上基础偏移16,最终宽度范围为16到99.3。这个函数在 dirList 组件中被使用,将方位角度转换为可视化的进度条长度,使用户能够直观地感知不同方位的角度大小。

craftBarW 函数将工艺值(0到30)映射为进度条宽度(16到96)。Math.min(c, 30) 约束上限为30,除以0.375将0-30的范围缩放到0-80,加上基础偏移16。该函数的缩放因子 0.375 = 30 / 80,确保满值30恰好对应80像素的进度条增长量。这种精确的缩放计算使得不同范围的数值都能被映射到合适的视觉宽度。

orderBarH 函数将订单金额(0到60000)映射为柱状图高度(24到124)。Math.min(amount, 60000) 约束上限为60000,除以600将0-60000的范围缩放到0-100,加上基础偏移24,最终高度范围为24到124。该函数在 orderList 组件中被使用,将订单金额转换为柱状图的高度,使金额的相对大小通过视觉高度直观呈现。60000元对应100像素的增长量,意味着每600元对应1像素的高度变化,这种缩放比例在视觉上既有足够的区分度,又不会因极端值而导致柱状图过高或过低。

这三个函数共同体现了数据可视化中的一个核心原则:将不同量纲、不同范围的数值统一映射到有限的视觉空间中。通过 Math.min 的上限约束和除法缩放,函数确保了无论输入值如何变化,输出的视觉尺寸始终在合理范围内。基础偏移量(16或24)的设置则保证了即使输入为0,进度条或柱状图也有一个最小可见尺寸,避免出现零高度或零宽度的"消失"元素。

五、数据模型深度解析

5.1 CompassItem 罗盘模型

@Observed
export class CompassItem {
  name: string;
  layers: number;
  rings: number;
  price: number;
  wood: string;
  constructor(name: string, layers: number, rings: number, price: number, wood: string) {
    this.name = name;
    this.layers = layers;
    this.rings = rings;
    this.price = price;
    this.wood = wood;
  }
}

CompassItem 类是罗盘产品的数据模型,使用 @Observed 装饰器标记为可观察对象。@Observed 是 HarmonyOS ArkTS 中的一个关键装饰器,它使得类的实例在属性值发生变化时能够自动通知绑定的UI组件进行重新渲染,实现了数据驱动的响应式更新机制。

CompassItem 包含五个属性:name(罗盘名称)、layers(层数)、rings(盘环数)、price(价格)、wood(材质)。这些属性全面描述了一把罗盘的核心特征——层数和盘环数反映了罗盘的信息容量和复杂程度,层数越多、盘环越多,意味着罗盘上刻载的堪舆信息越丰富;材质则直接关系到罗盘的品质和耐久性,从常见的黄檀木、黑檀木到珍贵的紫檀木、老黄檀,不同材质在质地、纹理和价格上差异显著。

constructor 构造函数接收五个参数并依次赋值给实例属性。这种在构造函数中直接赋值的写法简洁明了,但在大型项目中可以考虑使用参数属性简写(如 constructor(public name: string, ...))来进一步减少样板代码。不过,ArkTS 对类的语法支持可能与标准 TypeScript 存在细微差异,显式的属性声明和赋值方式在兼容性上更为稳妥。

@Observed 装饰器的工作原理值得深入理解。当一个类被 @Observed 标记后,ArkTS 框架会在运行时对该类的实例进行代理(Proxy),拦截属性的赋值操作。当某个属性被赋予新值时,代理会触发通知机制,告知所有依赖该属性的UI组件进行更新。这种机制使得开发者只需修改数据模型的属性值,UI就会自动反映变化,无需手动调用 setState 或类似方法。在 CompassItem 的使用场景中,如果罗盘的价格或名称发生变更,绑定了这些属性的列表项会自动更新显示,实现了真正的数据-视图同步。

5.2 PoolItem 天池模型

@Observed
export class PoolItem {
  name: string;
  glass: string;
  depth: number;
  magnet: string;
  constructor(name: string, glass: string, depth: number, magnet: string) {
    this.name = name;
    this.glass = glass;
    this.depth = depth;
    this.magnet = magnet;
  }
}

PoolItem 类是天池组件的数据模型,包含四个属性:name(天池名称)、glass(玻璃/材质描述)、depth(深度)、magnet(磁针类型)。天池作为罗盘的核心组件,其品质直接决定了罗盘的测量精度和使用体验。

glass 属性描述天池的材质类型,如"天然水晶"“古法琉璃”"天然玉髓"等。不同的材质在透光性、硬度和美观度上各有特点——水晶天池晶莹剔透,能够清晰看到磁针的指向;琉璃天池色彩斑斓,具有独特的艺术美感;玉髓天池温润如脂,触感细腻。depth 属性以数值表示天池的深度等级,数值越大表示天池越深,磁针的稳定性越好。

magnet 属性描述磁针的类型,如"强磁针"“中磁针”"悬浮针"等。磁针的磁化强度直接影响罗盘的指向速度和稳定性——强磁针能够快速响应并稳定指向南北,而普通磁针可能需要更长时间才能稳定。在UI中,磁针类型的标签颜色通过 gradeColor 函数动态计算:"强磁针"映射为大师级别的金色,其他类型映射为名师级别的青绿色,使强磁针在视觉上更为突出。

5.3 DirItem 方位模型

@Observed
export class DirItem {
  name: string;
  element: string;
  angle: number;
  color: string;
  constructor(name: string, element: string, angle: number, color: string) {
    this.name = name;
    this.element = element;
    this.angle = angle;
    this.color = color;
  }
}

DirItem 类是方位的数据模型,包含四个属性:name(方位名称)、element(五行属性)、angle(角度)、color(方位颜色)。这四个属性共同描述了八卦方位的堪舆特征。

name 属性存储方位的中文名称,如"正东"“正南”“正西”“正北”“东南”“西南”“西北”“东北”——对应八卦中的震、离、兑、坎、巽、坤、乾、艮。element 属性以"卦名·五行"的格式描述方位的五行属性,如"震·木"“离·火”“兑·金”"坎·水"等。五行属性是堪舆学中判断方位吉凶的重要依据——木主生长升发,火主炎热向上,金主收敛肃杀,水主寒冷下行,土主承载化育。不同五行属性的方位适用于不同的堪舆用途。

angle 属性存储方位的角度值,正北为0度,正东为90度,正南为180度,正西为270度。这个角度值在UI中通过 accBarW 函数转换为进度条宽度,使用户能够直观比较不同方位的角度大小。color 属性存储方位的专属颜色,每个方位都有对应的五行色——东方木为青绿,南方火为朱红,西方金为铜色,北方水为深蓝等。这些颜色在UI中用于进度条的渐变起始色,使每个方位的进度条都具有独特的视觉标识。

DirItem 的 angle 属性是可变的,在 editModal 弹窗中用户可以通过"顺转15度"或"逆转15度"按钮来修改角度值。由于 DirItem 被 @Observed 标记,当 angle 属性被修改时,绑定了该属性的UI组件(进度条和角度文本)会自动更新,实现了即时的交互反馈。

5.4 MountainItem 二十四山模型

@Observed
export class MountainItem {
  name: string;
  group: string;
  degree: number;
  use: string;
  constructor(name: string, group: string, degree: number, use: string) {
    this.name = name;
    this.group = group;
    this.degree = degree;
    this.use = string;
  }
}

MountainItem 类是二十四山的数据模型,包含四个属性:name(山名)、group(分组)、degree(分金度数)、use(用途)。这些属性描述了二十四山在堪舆实践中的具体应用信息。

name 属性存储山的名称或组合名称,如"子·癸"“丑·艮·寅”“甲·卯·乙"等。在二十四山体系中,每三山组成一组,对应一个卦宫——北方三山(壬子癸)属坎卦,东北三山(丑艮寅)属艮卦,东方三山(甲卯乙)属震卦,以此类推。group 属性以"方位+三山"的格式描述山的分组信息,如"北方三山”"东北三山"等,帮助用户理解每座山在整体体系中的位置。

degree 属性存储分金度数,这是一个0到100的评分值,反映了该山在堪舆实践中的重要程度或精度等级。这个数值在UI中通过 gradeColor 函数映射为颜色——度数大于94的映射为国师级朱红色,其余映射为大师级金色,使高分项目在视觉上更为醒目。use 属性描述该山在堪舆实践中的具体用途,如"宅运立向"“山形断吉”“龙脉寻踪”"水口定局"等,这些用途术语反映了二十四山在不同堪舆场景下的应用方法。

MountainItem 数据的设计体现了堪舆学的系统性和层次性——从单座山到三山组合,从方位分组到具体用途,从分金度数到实践方法,构成了一个完整的知识体系。将这一体系以结构化数据的形式呈现,使用户能够在移动设备上便捷地查阅和学习二十四山的相关知识。

5.5 MasterItem 堪舆师模型

@Observed
export class MasterItem {
  name: string;
  grade: string;
  years: number;
  cases: number;
  specialty: string;
  constructor(name: string, grade: string, years: number, cases: number, specialty: string) {
    this.name = name;
    this.grade = grade;
    this.years = years;
    this.cases = cases;
    this.specialty = specialty;
  }
}

MasterItem 类是堪舆师的数据模型,包含五个属性:name(姓名)、grade(等级)、years(从业年限)、cases(案牍数)、specialty(专长)。这些属性全面描述了一位堪舆师的专业背景和实力水平。

name 属性采用"传承·姓氏"的格式,如"杨公传人·陈""赖公传人·罗"等,这种命名方式体现了堪舆学的师承传统——在古代,堪舆学问主要通过师徒口传心授的方式传承,弟子以师父的姓氏加上"公"字作为尊称来标识自己的师承渊源。“杨公"指杨筠松,唐代著名堪舆大师,被后世尊为"杨公”,是三合派堪舆的创始人。"赖公"指赖布衣,宋代堪舆名师。"蒋公"指蒋大鸿,明末清初玄空派堪舆的重要传人。

grade 属性存储堪舆师的等级,分为"国师"“大师”“名师”"学徒"四个级别。在应用的数据中,国师有两人,大师有两人,名师有两人,学徒有两人,呈金字塔形分布,符合现实中高水平堪舆师稀缺、初学者众多的实际情况。等级通过 gradeColor 函数映射为不同的颜色标签,使用户能够一眼识别堪舆师的级别。

years 属性存储从业年限,cases 属性存储案牍数(即处理过的堪舆案例数量)。这两个数值是衡量堪舆师经验的重要指标——国师级堪舆师的从业年限在38年以上,案牍数超过500;学徒级堪舆师的从业年限在6年以下,案牍数不足50。这些数值在UI中以"专长·年限"和"案牍数"的形式展示,使用户能够量化评估堪舆师的经验水平。

specialty 属性存储堪舆师的专长领域,如"三合定盘"“天星催官”“玄空飞星”"峦头点穴"等。这些专长领域反映了堪舆学内部的不同流派和技法——三合派注重方位配合,天星派注重星象对应,玄空派注重飞星流转,峦头派注重山形水势。每位堪舆师通常精通某一两个流派的技法,这种专长分化使得用户可以根据自身需求选择合适的堪舆师。

5.6 CompassOrderItem 订单模型

@Observed
export class CompassOrderItem {
  buyer: string;
  item: string;
  amount: number;
  qty: number;
  date: string;
  constructor(buyer: string, item: string, amount: number, qty: number, date: string) {
    this.buyer = buyer;
    this.item = item;
    this.amount = amount;
    this.qty = qty;
    this.date = date;
  }
}

CompassOrderItem 类是订单的数据模型,包含五个属性:buyer(买家名称)、item(订购商品)、amount(订单金额)、qty(数量)、date(日期)。这些属性记录了罗盘交易的完整信息。

buyer 属性存储买家名称,如"北京易学馆"“上海风水阁"等,这些名称反映了罗盘的主要客户群体——传统文化研究机构、风水咨询公司和古籍收藏店铺。item 属性存储订购的罗盘名称,与 CompassItem 的 name 属性对应,如"综合罗盘”“玄空飞星盘”"奇门遁甲盘"等。

amount 属性存储订单总金额,qty 属性存储订购数量。在数据中,大多数订单的订购数量为10件,少数为5件,金额则根据罗盘的单价和数量计算得出。date 属性存储订单日期,格式为"YYYY-MM-DD"。这些日期数据从2026年1月到4月,覆盖了近半年的交易记录。

在UI中,CompassOrderItem 数据以两种形式呈现——上半部分是柱状图,通过 orderBarH 函数将金额映射为柱状图高度,使用户能够直观比较不同订单的金额大小;下半部分是列表,逐条展示订单的详细信息。这种"图表+列表"的数据展示方式兼顾了宏观概览和微观细节,是数据可视化中常用的设计模式。

CompassOrderItem 作为 @Observed 类,当新增订单被添加到 orders 数组时(通过 addModal 弹窗的"确认新增"按钮),UI会自动更新柱状图和列表,实现了数据的即时刷新。这种响应式更新机制是 ArkTS 声明式UI框架的核心优势之一,大大简化了数据变更后的UI同步逻辑。

六、状态管理深度解析

6.1 @State 状态变量

@State curTab: number = 0;
@State breath: boolean = false;
@State showAdd: boolean = false;
@State showEdit: boolean = false;
@State showDel: boolean = false;
@State selDir: DirItem | null = null;
@State selMaster: MasterItem | null = null;
@State newName: string = '';
@State newItem: string = '';
@State newPrice: string = '';

@State 装饰器是 ArkTS 中最基础的状态管理装饰器,它标记的变量是组件的内部状态。当 @State 变量的值发生变化时,ArkTS 框架会自动重新渲染依赖该变量的UI部分,实现数据驱动的界面更新。

curTab 变量存储当前选中的标签索引,初始值为0(罗盘标签)。这个变量控制着 tabContent 组件中显示的内容——当 curTab 为0时显示罗盘列表,为1时显示天池列表,以此类推。每次用户点击底部导航栏的标签时,curTab 的值被更新,tabContent 中的条件判断会切换显示对应的内容区块。

breath 变量是一个布尔值,在 aboutToAppear 生命周期中被设置为每440毫秒翻转一次。这个变量驱动着头部区域的多种呼吸动画效果——星点的透明度交替、同心圆环的透明度交替、磁针的旋转角度、装饰线条的旋转角度等。breath 变量的设计体现了"单一状态源"的思想——所有呼吸动画都由同一个布尔值驱动,确保了它们之间的同步性,避免了多个独立定时器可能导致的动画不同步问题。

showAdd、showEdit、showDel 三个布尔变量分别控制新增弹窗、编辑弹窗和删除弹窗的显示与隐藏。这三个变量在 build 方法的 Stack 容器中通过条件判断来决定是否渲染对应的弹窗组件。当用户点击"新增"按钮时 showAdd 被设为 true,addModal 组件被渲染并覆盖在主内容上方;当用户点击弹窗的"取消"按钮或遮罩层时 showAdd 被设为 false,弹窗消失。这种通过布尔状态控制弹窗显隐的模式是 ArkTS 中实现模态框的标准做法。

selDir 和 selMaster 变量分别存储当前选中的方位对象和堪舆师对象,类型为 DirItem | null 和 MasterItem | null。这两个变量在编辑弹窗和删除弹窗中使用——当用户点击某个方位的"编辑"按钮时,selDir 被赋值为该方位对象,editModal 弹窗中显示该对象的详细信息并提供修改功能;当用户点击某个堪舆师的"删除"按钮时,selMaster 被赋值为该堪舆师对象,delModal 弹窗中显示该对象的名称并请求确认。使用联合类型(DirItem | null)允许变量在未选中任何对象时为 null,在 ArkTS 中需要在使用前进行空值检查。

newName、newItem、newPrice 三个字符串变量存储新增订单弹窗中用户输入的买家名称、商品名称和金额。这三个变量通过 TextInput 的 onChange 回调实时更新,在用户点击"确认新增"按钮时被用于构造新的 CompassOrderItem 实例并添加到 orders 数组中。使用字符串类型而非数值类型来存储金额(newPrice)是为了简化 TextInput 的双向绑定——TextInput 的 text 属性只接受字符串,如果使用数值类型需要进行额外的类型转换。

6.2 @State 数据数组

@State compasses: CompassItem[] = [
  new CompassItem('三合罗盘', 22, 3, 2600, '黄檀木'),
  new CompassItem('三元罗盘', 24, 4, 3200, '黑檀木'),
  new CompassItem('综合罗盘', 30, 5, 4600, '红木'),
  new CompassItem('玄空飞星盘', 26, 4, 3800, '黄檀木'),
  new CompassItem('八宅罗盘', 20, 3, 2200, '楠木'),
  new CompassItem('大六壬盘', 28, 5, 4200, '紫檀木'),
  new CompassItem('奇门遁甲盘', 32, 6, 5800, '黑檀木'),
  new CompassItem('小玄空盘', 18, 2, 1600, '花梨木'),
  new CompassItem('风水铜盘', 25, 4, 3400, '青铜'),
  new CompassItem('堪舆精装盘', 36, 7, 7600, '紫檀木'),
  new CompassItem('祖传三合盘', 22, 3, 6800, '老黄檀'),
  new CompassItem('学徒练习盘', 16, 2, 980, '白蜡木')
];

compasses 数组是罗盘列表的数据源,包含十二种不同类型的罗盘。每一种罗盘都有独特的名称、层数、盘环数、价格和材质。从数据中可以看出,罗盘的层数从最少的16层(学徒练习盘)到最多的36层(堪舆精装盘),盘环数从最少的2盘到最多的7盘,价格从最低的980元(学徒练习盘)到最高的7600元(堪舆精装盘)。

罗盘的命名反映了不同堪舆流派的需求。三合罗盘是三合派堪舆的基础工具,三元罗盘是三元派堪舆的专用工具,综合罗盘融合了多个流派的信息层次,玄空飞星盘专用于玄空飞星派的分析,八宅罗盘用于八宅理气的方位判断,大六壬盘用于大六壬术数的推演,奇门遁甲盘用于奇门遁甲的排盘演局。这些不同类型的罗盘体现了堪舆学内部流派的多样性和专业性。

材质的选择同样考究。黄檀木、黑檀木、紫檀木都是硬度高、纹理美的珍贵木材,适合制作精密罗盘;楠木质地较软但防腐性好,适合制作入门级罗盘;花梨木色彩鲜艳但硬度一般,适合制作轻便型罗盘;青铜材质的罗盘则具有特殊的金属质感和耐久性。“老黄檀"材质用于"祖传三合盘”,暗示这是一把年代久远、材质稀缺的古董罗盘,其价格(6800元)远高于同规格的新制三合罗盘(2600元),反映了古董罗盘的收藏价值。

@State pools: PoolItem[] = [
  new PoolItem('水晶天池', '天然水晶', 4, '强磁针'),
  new PoolItem('琉璃天池', '古法琉璃', 3, '中磁针'),
  new PoolItem('玉髓天池', '天然玉髓', 5, '强磁针'),
  new PoolItem('玻璃天池', '光学玻璃', 2, '普通针'),
  new PoolItem('玛瑙天池', '天然玛瑙', 4, '强磁针'),
  new PoolItem('蓝宝天池', '蓝宝石面', 5, '特强针'),
  new PoolItem('水晶浮针池', '水晶+浮针', 6, '悬浮针'),
  new PoolItem('古法铜池', '黄铜底', 3, '老磁针')
];

pools 数组是天池列表的数据源,包含八种不同材质和磁针类型的天池。水晶天池使用天然水晶制作,深度为4,配备强磁针,是品质优良的标准天池。琉璃天池使用古法琉璃制作,深度为3,配备中磁针,具有独特的艺术美感。玉髓天池使用天然玉髓制作,深度为5,配备强磁针,温润细腻。蓝宝天池使用蓝宝石面制作,深度为5,配备特强针,是天池中的顶级产品。

水晶浮针池的深度为6,是所有天池中最深的,配备悬浮针——悬浮针是一种特殊的磁针安装方式,磁针悬浮在天池液体中,几乎无摩擦阻力,指向精度极高。古法铜池使用黄铜底制作,深度为3,配备老磁针——老磁针可能是经过特殊退火处理的传统磁针,磁化强度可能不如新型强磁针,但具有怀旧的文化韵味。

@State dirs: DirItem[] = [
  new DirItem('正东', '震·木', 90, '#3E6B5E'),
  new DirItem('正南', '离·火', 180, '#B8432E'),
  new DirItem('正西', '兑·金', 270, '#C9A87C'),
  new DirItem('正北', '坎·水', 0, '#3A5A8A'),
  new DirItem('东南', '巽·木', 135, '#4E7A5A'),
  new DirItem('西南', '坤·土', 225, '#8A6A4E'),
  new DirItem('西北', '乾·金', 315, '#5A6B8A'),
  new DirItem('东北', '艮·土', 45, '#7A5A4A')
];

dirs 数组是方位列表的数据源,包含八个方位的详细信息。每个方位的五行属性和颜色都经过精心设计——正东震木使用青绿色 ‘#3E6B5E’,正南离火使用朱红色 ‘#B8432E’,正西兑金使用铜色 ‘#C9A87C’,正北坎水使用深蓝色 ‘#3A5A8A’。这些颜色与五行学说中的方位色彩约定高度一致,使数据本身就具有文化辨识度。

角度值的设计也符合堪舆学的方位体系——正北0度、正东90度、正南180度、正西270度,四维方位分别为东南135度、西南225度、西北315度、东北45度。这些角度值在UI中通过 accBarW 函数转换为进度条宽度(取 angle % 90 的值),使正方位(90、180、270)的进度条满格,四维方位的进度条为半格,呈现出有规律的视觉排列。

@State mountains: MountainItem[] = [
  new MountainItem('子·癸', '北方三山', 96, '宅运立向'),
  new MountainItem('丑·艮·寅', '东北三山', 90, '山形断吉'),
  new MountainItem('甲·卯·乙', '东方三山', 94, '龙脉寻踪'),
  new MountainItem('辰·巽·巳', '东南三山', 92, '水口定局'),
  new MountainItem('丙·午·丁', '南方三山', 97, '朝向择吉'),
  new MountainItem('未·坤·申', '西南三山', 89, '砂法观形'),
  new MountainItem('庚·酉·辛', '西方三山', 93, '水法立向'),
  new MountainItem('戌·乾·亥', '西北三山', 95, '天门合局'),
  new MountainItem('二十四山全盘', '综合', 98, '堪舆定盘'),
  new MountainItem('七十二龙分金', '细层', 96, '分金坐度')
];

mountains 数组是二十四山列表的数据源,包含十组二十四山信息。前八组分别对应八个方位的三山组合,后两组"二十四山全盘"和"七十二龙分金"则是综合性的高层信息。每组数据的 degree(分金度数)都在89到98之间,通过 gradeColor 函数映射——度数大于94的显示为国师级朱红色(如南方三山97、全盘98、北方三山96、西北三山95、东方三山94),其余显示为大师级金色。这种颜色分级使用户能够快速识别出在堪舆实践中最为重要的山向。

@State masters: MasterItem[] = [
  new MasterItem('杨公传人·陈', '国师', 42, 680, '三合定盘'),
  new MasterItem('赖公传人·罗', '国师', 38, 520, '天星催官'),
  new MasterItem('蒋公传人·吴', '大师', 30, 410, '玄空飞星'),
  new MasterItem('郭公传人·郑', '大师', 28, 360, '峦头点穴'),
  new MasterItem('何公传人·马', '名师', 20, 240, '八宅安宅'),
  new MasterItem('曾公传人·许', '名师', 18, 210, '大六壬断'),
  new MasterItem('新派堪舆·顾', '学徒', 6, 48, '罗盘入门'),
  new MasterItem('新派堪舆·韩', '学徒', 4, 32, '立向基础')
];

masters 数组是堪舆师列表的数据源,包含八位堪舆师的完整信息。数据的排列遵循等级从高到低的顺序——国师在前,大师次之,名师再次,学徒最后。在每位等级内部,又按从业年限从长到短排列。这种排序方式使用户在浏览列表时,首先看到的是最资深的堪舆师,符合"重要信息优先展示"的UI设计原则。

杨公传人陈姓堪舆师以42年从业经验和680件案牍位居榜首,其专长"三合定盘"是三合派堪舆的核心技法。赖公传人罗姓堪舆师的专长"天星催官"是天星派的独特技法,用于通过星象分析来催旺官运。蒋公传人吴姓堪舆师精通"玄空飞星",郭公传人郑姓堪舆师擅长"峦头点穴"——峦头派注重山形水势的实地勘察,点穴是峦头派最核心的技法之一,指在龙脉上找到最适宜安葬或建宅的精确位置。

@State orders: CompassOrderItem[] = [
  new CompassOrderItem('北京易学馆', '综合罗盘', 46000, 10, '2026-01-06'),
  new CompassOrderItem('上海风水阁', '玄空飞星盘', 38000, 10, '2026-01-18'),
  new CompassOrderItem('广州堪舆社', '奇门遁甲盘', 58000, 10, '2026-02-02'),
  new CompassOrderItem('成都古籍店', '三合罗盘', 26000, 10, '2026-02-15'),
  new CompassOrderItem('杭州国学馆', '堪舆精装盘', 38000, 5, '2026-03-04'),
  new CompassOrderItem('西安文化街', '大六壬盘', 42000, 10, '2026-03-17'),
  new CompassOrderItem('南京典籍铺', '三元罗盘', 32000, 10, '2026-03-28'),
  new CompassOrderItem('武汉易学会', '风水铜盘', 34000, 10, '2026-04-05'),
  new CompassOrderItem('苏州文房阁', '祖传三合盘', 34000, 5, '2026-04-12'),
  new CompassOrderItem('天津罗盘坊', '学徒练习盘', 9800, 10, '2026-04-18')
];

orders 数组是订单列表的数据源,包含十条订单记录,日期从2026年1月到4月。订单金额从最低的9800元(学徒练习盘10件)到最高的58000元(奇门遁甲盘10件),通过 orderBarH 函数映射为不同高度的柱状图。买家名称涵盖了北京、上海、广州、成都、杭州、西安、南京、武汉、苏州、天津等城市的文化机构,反映了罗盘市场在全国范围的分布。

6.3 生命周期与定时器

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

aboutToAppear 是 ArkTS 组件的生命周期方法,在组件即将出现在屏幕上之前被调用。这个方法常用于初始化数据、启动定时器或发起网络请求。在这里,aboutToAppear 方法中启动了一个 setInterval 定时器,每440毫秒将 breath 布尔值取反一次。

440毫秒的间隔选择并非随意。这个时间长度介于快速闪烁(如200毫秒,会让人感到不适)和缓慢变化(如1000毫秒,动画效果不明显)之间,能够产生自然、舒适的呼吸感。440毫秒的周期意味着每秒大约进行2.27次状态翻转,配合各处动画的 duration(520、600、650、700毫秒)和 PlayMode.Alternate(交替播放模式),形成了一种温和的脉动节奏。

需要注意的是,setInterval 启动的定时器在组件销毁时不会被自动清除。在更完善的实现中,应该在 aboutToDisappear 生命周期中调用 clearInterval 来清除定时器,避免内存泄漏。不过,在这个应用中,由于 LuoPanPage 是 @Entry 级别的组件,其生命周期与应用窗口一致,定时器会随窗口销毁而被回收,因此在实践中不会造成问题。

breath 变量的取反操作 this.breath = !this.breath 触发了 ArkTS 的响应式更新机制。每次 breath 值变化时,所有依赖 breath 的UI属性(opacity、rotate angle 等)都会被重新计算,驱动动画效果的产生。这种"状态翻转驱动动画"的模式比直接使用动画API更为灵活——它允许同一状态变量同时控制多个不相关位置的动画效果,实现了全局同步的呼吸节奏。

七、UI组件深度解析

7.1 modalOverlay 遮罩层构建器

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

modalOverlay 是一个通用的遮罩层构建器,接受一个 onClose 回调函数作为参数。它创建一个覆盖整个屏幕的 Column 容器,背景色为半透明黑色(COLORS.mask = ‘rgba(0,0,0,0.45)’),点击时调用 onClose 回调关闭弹窗。

@Builder 装饰器是 ArkTS 中用于定义可复用UI片段的装饰器。与 @Component 不同,@Builder 方法不需要独立的状态管理,它更类似于一个返回UI结构的函数。@Builder 方法可以接收参数,使得同一构建器能够根据不同的参数生成不同的UI内容——modalOverlay 就是通过 onClose 参数实现了不同弹窗关闭逻辑的复用。

遮罩层的设计是模态弹窗交互中的标准做法。当弹窗出现时,遮罩层覆盖在底层内容上方,降低背景内容的视觉显著性,引导用户将注意力集中在弹窗内容上。45%透明度的黑色遮罩既能有效遮蔽背景,又不会完全遮挡——用户仍然可以隐约看到底层内容,保持了上下文连续性。点击遮罩层关闭弹窗是一种符合用户直觉的交互模式——用户在弹窗外点击时通常期望关闭弹窗,这一设计满足了这一预期。

modalOverlay 被三个弹窗组件(addModal、editModal、delModal)复用,每个弹窗传入不同的 onClose 回调函数——addModal 传入的是将 showAdd 设为 false 的函数,editModal 传入的是将 showEdit 设为 false 的函数,delModal 传入的是将 showDel 设为 false 的函数。这种通过参数化实现复用的设计避免了为每个弹窗单独编写遮罩层代码,减少了代码冗余。

7.2 pageHeader 页面头部构建器

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

pageHeader 构建器是应用头部区域的渲染逻辑,它是整个应用中视觉最丰富、动画最密集的组件。头部区域使用 Stack 容器作为根节点,Stack 的特性是所有子元素以堆叠方式排列,后添加的元素覆盖在先添加的元素之上。这种堆叠布局方式非常适合头部区域的设计——多层视觉元素(背景渐变、星点、圆环、刻度、地支字符、磁针、标题文字)需要叠加在一起,共同构成一幅完整的罗盘图案。

Stack 的第一个子元素是一个铺满整个区域的 Column 容器,设置了22像素的圆角和135度角度的线性渐变。渐变从 header1 ‘#4E3A28’(深檀色)到 header2 ‘#221812’(近黑色),模拟了深色木质盘面的渐变效果。135度的渐变角度意味着颜色从左上方向右下方过渡,这种斜向渐变比水平或垂直渐变更具动感和层次感。

      ForEach(STAR_IDX, (i: number) => {
        Text('✦')
          .fontSize(8 + (i % 2) * 3)
          .fontColor(COLORS.gold)
          .opacity(this.breath ? 0.3 : 0.9)
          .position({ x: starX(i), y: starY(i) })
          .animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })
      }, (i: number) => 's' + i)

头部区域的第二个视觉层是四个装饰星点,通过 ForEach 遍历 STAR_IDX [0,1,2,3] 数组生成。每个星点使用 ‘✦’ 字符渲染,字号在8到11之间交替变化(8 + (i % 2) * 3),金色着色。星点位置通过 starX 和 starY 函数计算,呈锯齿状分布在头部区域的左半部分。

每个星点的 opacity 属性绑定到 breath 状态——当 breath 为 false 时透明度为0.9(几乎完全可见),当 breath 为 true 时透明度为0.3(较为暗淡)。配合 animation 动画属性(duration 650毫秒、无限循环、交替播放模式),星点的透明度在0.3和0.9之间平滑过渡,形成闪烁效果。iterations 设为 -1 表示无限循环,PlayMode.Alternate 表示每次循环结束后反向播放(从亮到暗再到亮),使动画过渡更加自然。

ForEach 的第三个参数是键值生成函数 (i: number) => 's' + i,它为每个星点生成唯一的键值 ‘s0’、‘s1’、‘s2’、‘s3’。键值在 ArkTS 的 ForEach 中用于高效的 diff 算法——当数组发生变化时,框架通过键值判断哪些元素是新增的、哪些是移除的、哪些是更新的,从而最小化DOM操作。即使数组内容不变(如本例中的常量数组),提供键值生成函数仍然是良好的编程习惯,有助于框架的性能优化。

      ForEach(RING_IDX, (i: number) => {
        Circle()
          .width(ringSize(i))
          .height(ringSize(i))
          .fill(Color.Transparent)
          .stroke(COLORS.ringA)
          .strokeWidth(2)
          .position({ x: 150 - ringSize(i) / 2, y: 88 - ringSize(i) / 2 })
          .opacity(this.breath ? 0.55 : 1)
          .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
      }, (i: number) => 'r' + i)

第三个视觉层是四个同心圆环,通过 ForEach 遍历 RING_IDX [0,1,2,3] 数组生成。每个圆环使用 Circle 组件渲染,大小通过 ringSize(i) 函数计算——从176到68递减。圆环的填充色设为透明(Color.Transparent),仅通过 stroke(描边)和 strokeWidth(描边宽度2像素)来显示圆环轮廓。

圆环的位置通过 position 属性精确设置。由于 Circle 组件的默认定位方式是以左上角为锚点,需要通过 150 - ringSize(i) / 288 - ringSize(i) / 2 来将圆心对齐到 (150, 88)——即头部区域的中心点。这种手动计算位置的写法虽然不如居中对齐布局简洁,但在 Stack 容器中是定位元素的标准方式。

圆环的 opacity 同样绑定到 breath 状态,在0.55和1之间交替变化。动画持续时间为700毫秒,与星点的650毫秒略有差异,使得圆环和星点的呼吸节奏不完全同步,产生更为丰富的视觉层次感。多个动画以略微不同的频率运行,避免了所有元素同时明暗变化可能带来的机械感,营造出更接近自然 rhythms 的视觉效果。

      ForEach(TICK_IDX, (i: number) => {
        Column()
          .width(2)
          .height(6)
          .backgroundColor(COLORS.gold)
          .position({ x: tickX(i) - 1, y: tickY(i) - 3 })
          .rotate({ angle: i * 30 })
      }, (i: number) => 't' + i)

第四个视觉层是十二个刻度标记,通过 ForEach 遍历 TICK_IDX [0,1,…,11] 数组生成。每个刻度标记是一个2像素宽、6像素高的金色小竖条,位置通过 tickX 和 tickY 函数计算。tickX(i) - 1 和 tickY(i) - 3 的减法操作是将刻度的中心对齐到计算位置——刻度宽度为2,减去1使其水平居中;高度为6,减去3使其垂直居中。

每个刻度标记通过 rotate 属性旋转 i * 30 度——第一个刻度(i=0)不旋转,指向正北;第二个刻度(i=1)旋转30度,指向北偏东30度方向;以此类推,十二个刻度均匀分布在罗盘盘面边缘,每个间隔30度。旋转操作以刻度的中心为旋转中心,使得刻度在视觉上"指向"罗盘中心方向之外,如同真实罗盘上的刻度线。

刻度标记不绑定 breath 动画,保持稳定的静态显示。这种动中有静、静中有动的设计使头部区域既有活力又不显杂乱——呼吸动画的元素(星点、圆环、磁针、文字)与静态元素(刻度、地支字符)交替分布,形成节奏感。

      ForEach(DIR_CHARS, (ch: string, i: number) => {
        Text(ch)
          .fontSize(12)
          .fontColor(COLORS.sub)
          .position({ x: dirX(i) - 6, y: dirY(i) - 8 })
      }, (ch: string) => ch)

第五个视觉层是十二地支字符,通过 ForEach 遍历 DIR_CHARS 数组生成。每个字符使用12号字体、sub 颜色(‘#D8C0A0’ 暖棕色)渲染,位置通过 dirX 和 dirY 函数计算。dirX(i) - 6 和 dirY(i) - 8 的减法操作是字符居中调整——字符宽度约为12像素(fontSize 12),减去6使其水平居中;字符高度约为16像素,减去8使其垂直居中。

ForEach 的键值生成函数 (ch: string) => ch 直接使用字符本身作为键值。由于十二地支字符各不相同,这种键值方式保证了每个字符的唯一性。当 DIR_CHARS 数组发生变化时(虽然在实际应用中不会变化),框架能够通过字符值精确识别变化的元素。

十二地支字符在罗盘盘面上的分布遵循堪舆学的方位约定——子位于正北(盘面顶部),卯位于正东(盘面右侧),午位于正南(盘面底部),酉位于正西(盘面左侧),其余各地支按顺时针方向均匀分布。这种分布方式与真实罗盘的盘面布局完全一致,使头部图案具有专业的文化辨识度。

      Circle()
        .width(34)
        .height(34)
        .fill(COLORS.discB)
        .position({ x: 133, y: 71 })
      Circle()
        .width(18)
        .height(18)
        .fill(COLORS.gold)
        .position({ x: 141, y: 79 })

第六和第七个视觉层是罗盘天池的两个同心圆。外层圆直径34像素,填充 discB(‘#8A6A4E’ 木褐色),位于 (133, 71)——即圆心在 (133+17, 71+17) = (150, 88),与头部中心对齐。内层圆直径18像素,填充 gold(‘#E3B23C’ 金色),位于 (141, 79)——圆心同样在 (141+9, 79+9) = (150, 88)。

两层圆的圆心都在 (150, 88),确保它们是真正的同心圆。外层木褐色圆代表天池的外框,内层金色圆代表天池的中心点——在真实罗盘中,天池中心通常有一个顶针或标识,用于支撑磁针或标记盘面中心。金色中心圆的存在使天池区域在视觉上有一个明确的焦点,增强了罗盘图案的辨识度。

      Column()
        .width(8)
        .height(86)
        .borderRadius(4)
        .linearGradient({
          angle: 90,
          colors: [[COLORS.needleA, 0], [COLORS.needleB, 1]]
        })
        .position({ x: 146, y: 45 })
        .rotate({ angle: this.breath ? -18 : 18 })
        .animation({ duration: 520, iterations: -1, playMode: PlayMode.Alternate })

第八个视觉层是磁针指针。磁针使用一个8像素宽、86像素高的 Column 组件渲染,设置了4像素的圆角。磁针的填充使用90度角度的线性渐变——从 needleA ‘#B8432E’(朱红色)到 needleB ‘#E3B23C’(金色),模拟了传统罗盘磁针南红北金的双色设计。

磁针的位置设在 (146, 45),即磁针的水平中心在 146+4=150(与罗盘中心对齐),垂直从45延伸到45+86=131,中心在 45+43=88(与罗盘中心对齐)。这种精确的定位确保磁针穿过天池中心,在视觉上呈现为一个以天池为支点旋转的指针。

磁针的 rotate 属性绑定到 breath 状态——当 breath 为 false 时旋转18度,当 breath 为 true 时旋转-18度。配合520毫秒的交替动画,磁针在-18度和18度之间来回摆动,模拟了磁针在真实罗盘中寻找南北方向时的摆动效果。520毫秒的摆动周期比圆环(700ms)和星点(650ms)更快,使磁针成为头部区域动态感最强的元素,符合磁针作为罗盘核心活动部件的定位。

      Text('罗盘坊')
        .fontSize(24)
        .fontColor(COLORS.title)
        .fontWeight(FontWeight.Bold)
        .position({ x: 210, y: 58 })
      Text('天池定针 · 二十四山')
        .fontSize(12)
        .fontColor(COLORS.sub)
        .position({ x: 210, y: 92 })
      Text('堪舆神工 · 指北定南')
        .fontSize(10)
        .fontColor(COLORS.gold)
        .position({ x: 210, y: 118 })
        .opacity(this.breath ? 0.55 : 1)
        .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })

第九至十一个视觉层是头部区域的文字标识。“罗盘坊” 以24号粗体字、title 颜色(‘#FAF2E6’ 暖白色)显示在 (210, 58),是应用的品牌名称。“天池定针 · 二十四山” 以12号字、sub 颜色显示在 (210, 92),是应用的功能描述。“堪舆神工 · 指北定南” 以10号金色字显示在 (210, 118),并绑定了 breath 动画——透明度在0.55和1之间交替,配合600毫秒的交替动画,形成闪烁效果。

三行文字的Y坐标分别为58、92、118,间距分别为34和26,呈现出上疏下密的排列节奏。品牌名称(24号字)最大最醒目,功能描述(12号字)次之,标语(10号字)最小——这种字号递减的信息层级设计使每一行文字的视觉权重与其信息重要性相匹配。

“堪舆神工 · 指北定南” 这一标语使用了"指北定南"而非更常见的"指南针"表述,这是因为传统罗盘的磁针南端(红色端)指向南方,而指针本身(包括天池中的浮针)的"针尖"通常指向北方——"指北"是磁针物理指向,"定南"是通过指北来确定南方方位。这种表述体现了对罗盘堪舆文化的深入理解。

      Column()
        .width(1)
        .height(30)
        .backgroundColor(COLORS.gold)
        .position({ x: 36, y: 150 })
        .rotate({ angle: this.breath ? -8 : 8 })
        .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
      Column()
        .width(1)
        .height(30)
        .backgroundColor(COLORS.gold)
        .position({ x: 286, y: 150 })
        .rotate({ angle: this.breath ? 8 : -8 })
        .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })

最后两个视觉层是头部区域左右两侧的装饰线条。左侧线条位于 (36, 150),右侧线条位于 (286, 150),均为1像素宽、30像素高的金色竖线。两条线条的旋转角度互为镜像——当 breath 为 false 时,左侧旋转-8度(逆时针),右侧旋转8度(顺时针);当 breath 为 true 时,左侧旋转8度,右侧旋转-8度。这种对称的旋转运动使两条线条如同两扇缓缓开合的门扉,增添了动态美感。

两条线条的旋转方向与 breath 状态的对应关系是镜像的——左侧 this.breath ? -8 : 8,右侧 this.breath ? 8 : -8。这意味着当 breath 为 false 时,左侧向左倾斜、右侧向右倾斜(外八字);当 breath 为 true 时,左侧向右倾斜、右侧向左倾斜(内八字)。这种"呼吸式"的开合运动与罗盘盘面的呼吸动画形成了呼应,使整个头部区域的动态效果协调一致。

7.3 bottomBar 底部导航栏构建器

@Builder
bottomBar() {
  Column() {
    Row() {
      ForEach(ROW1_IDX, (i: number) => {
        this.bottomItem(i)
      }, (i: number) => 'r1_' + i)
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
    Row() {
      ForEach(ROW2_IDX, (i: number) => {
        this.bottomItem(i)
      }, (i: number) => 'r2_' + i)
    }
    .width('100%')
    .justifyContent(FlexAlign.Center)
  }
  .width('100%')
  .padding({ top: 8, bottom: 10 })
  .backgroundColor(COLORS.tabBg)
}

bottomBar 构建器渲染应用底部的导航栏。导航栏使用 Column 容器作为根节点,内含两行 Row——第一行通过 ForEach 遍历 ROW1_IDX [0,1,2] 渲染前三个标签,第二行通过 ForEach 遍历 ROW2_IDX [3,4,5] 渲染后三个标签。两行 Row 都设置了 justifyContent(FlexAlign.Center),使标签在水平方向居中排列。

底部导航栏的背景色为 tabBg(‘#4E3A28’ 深檀色),与头部区域的渐变色系一致,在视觉上形成上下呼应。padding 设置了顶部8像素、底部10像素的内边距,使标签内容与导航栏边缘保持适当间距。

两行三列的布局设计是处理六个标签的合理方案。如果将六个标签横向排列在一行中,每个标签的可用宽度约为屏幕宽度的六分之一(在标准手机屏幕上约60-65像素),这对于显示Emoji图标和两字标签来说勉强够用,但对于"二十四山"这样四字的标签则会导致文字截断。两行三列的布局使每个标签的可用宽度增加到约三分之一屏幕宽度(约120-130像素),确保所有标签文字都能完整显示。

7.4 bottomItem 底部标签项构建器

@Builder
bottomItem(i: number) {
  Column() {
    Text(TAB_LIST[i].icon)
      .fontSize(17)
    Text(TAB_LIST[i].label)
      .fontSize(10)
      .fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
  }
  .width(92)
  .padding({ top: 6, bottom: 6 })
  .borderRadius(10)
  .backgroundColor(this.curTab === i ? COLORS.header2 : Color.Transparent)
  .onClick(() => {
    this.curTab = i;
  })
}

bottomItem 构建器渲染单个底部标签项。每个标签项是一个 Column 容器,宽度固定为92像素,内含两行文本——第一行是Emoji图标(17号字),第二行是标签文字(10号字)。标签文字的颜色根据 curTab 是否等于当前索引 i 来决定——选中时使用 tabOn(‘#F2C14E’ 亮金色),未选中时使用 sub(‘#D8C0A0’ 暖棕色)。

标签项的背景色同样根据选中状态变化——选中时为 header2(‘#221812’ 近黑色),形成深色背景上高亮选中项的效果;未选中时为透明。10像素的圆角使背景色呈现为圆角矩形,比直角矩形更为柔和。padding 设置了上下各6像素的内边距,使图标和文字与背景边缘保持间距。

onClick 回调将 curTab 设为当前索引 i,触发 tabContent 的条件判断切换显示对应的内容。这种通过修改状态变量来驱动内容切换的模式是 ArkTS 声明式UI的核心——开发者只需声明"当 curTab 等于某个值时显示什么内容",框架会自动处理UI的更新和过渡。

92像素的固定宽度是一个经过权衡的选择。在标准手机屏幕上(宽度约360-414像素),三个92像素的标签总宽度为276像素,剩余空间由 FlexAlign.Center 分配为两侧的等距间距,使标签组居中显示。如果标签宽度过小(如70像素),Emoji和文字会显得拥挤;如果过大(如120像素),三个标签的总宽度可能超出屏幕,导致换行或溢出。

7.5 compassList 罗盘列表构建器

@Builder
compassList() {
  Column() {
    ForEach(this.compasses, (item: CompassItem) => {
      Row() {
        Column() {
          Text(item.name)
            .fontSize(14)
            .fontColor(COLORS.text1)
            .fontWeight(FontWeight.Medium)
          Text(item.wood + ' · ' + item.layers + '层 ' + item.rings + '盘')
            .fontSize(11)
            .fontColor(COLORS.text2)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Column() {
          Text('¥' + item.price)
            .fontSize(13)
            .fontColor(COLORS.accent)
            .fontWeight(FontWeight.Medium)
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(10)
      .margin({ bottom: 8 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (item: CompassItem) => item.name)
  }
}

compassList 构建器渲染罗盘产品列表。列表使用 Column 容器作为根节点,通过 ForEach 遍历 compasses 数组生成列表项。每个列表项是一个 Row 容器,内部分为左右两个 Column——左侧 Column 使用 layoutWeight(1) 占据剩余空间,显示罗盘名称(14号字、text1 深色、中等字重)和材质层数信息(11号字、text2 中等色);右侧 Column 显示价格(13号字、accent 棕橙色、中等字重),右对齐排列。

左侧 Column 的 alignItems 设为 HorizontalAlign.Start,使两行文字左对齐——名称在上,描述在下,形成清晰的标题-副标题结构。描述文字 item.wood + ' · ' + item.layers + '层 ' + item.rings + '盘' 将材质、层数和盘环数以分隔符连接为一句简洁的描述,如"黄檀木 · 22层 3盘",使用户一眼即可获取罗盘的核心参数。

右侧 Column 的 alignItems 设为 HorizontalAlign.End,使价格文本右对齐。价格前加了"¥"符号以明确单位。使用 accent 棕橙色而非普通文字色,使价格信息在视觉上更为突出,符合电商类应用中价格信息高亮展示的惯例。

每个列表项的样式设置为白色背景(cardBg)、12像素圆角、四周10像素内边距、底部8像素外边距。白色背景在淡灰色页面背景上形成卡片浮起效果,圆角使卡片边缘柔和,外边距在卡片之间形成间距,使列表项之间有明确的视觉分隔。ForEach 的键值生成函数使用 item.name(罗盘名称),由于每个罗盘名称唯一,保证了列表项的高效 diff 更新。

7.6 poolList 天池列表构建器

@Builder
poolList() {
  Column() {
    ForEach(this.pools, (item: PoolItem) => {
      Row() {
        Text(item.name)
          .fontSize(14)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Medium)
        Text(' · ' + item.glass)
          .fontSize(11)
          .fontColor(COLORS.text2)
        Column()
          .layoutWeight(1)
        Text(item.magnet)
          .fontSize(11)
          .fontColor(gradeColor(item.magnet === '强磁针' ? '大师' : '名师'))
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      }
      .width('100%')
      .padding(10)
      .margin({ bottom: 8 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (item: PoolItem) => item.name)
  }
}

poolList 构建器渲染天池列表。每个列表项的布局与 compassList 有所不同——采用单行水平排列的方式,将天池名称、材质描述、弹性间距和磁针类型标签从左到右依次排列。

天池名称使用14号字、text1 深色、中等字重,是列表项的主标题。材质描述以" · "分隔符跟在名称后面,使用11号字、text2 中等色,作为名称的补充说明。这种将主标题和副信息放在同一行的设计比上下分行的设计更为紧凑,适合信息量适中的列表项。

中间的空 Column 使用 layoutWeight(1) 占据剩余空间,将右侧的磁针类型标签推到行末。磁针类型标签使用11号字,颜色通过 gradeColor 函数动态计算——如果磁针类型是"强磁针",则映射为大师级金色,否则映射为名师级青绿色。标签使用淡灰色背景(COLORS.bg)和8像素圆角,形成胶囊状的标签样式。左右8像素、上下3像素的 padding 使标签内容与边缘保持适当间距。

这种将业务逻辑(磁针类型到颜色的映射)通过函数动态计算的写法,使UI组件不需要硬编码颜色值,而是根据数据特征自动选择合适的视觉样式。如果未来需要调整磁针类型的颜色映射规则,只需修改 gradeColor 函数,所有使用该函数的UI组件都会自动更新。

7.7 dirList 方位列表构建器

@Builder
dirList() {
  Column() {
    Column() {
      Row() {
        Text('方位精度')
          .fontSize(12)
          .fontColor(COLORS.text2)
        Column()
          .layoutWeight(1)
        Text('满刻可读 · 误差±0.5°')
          .fontSize(10)
          .fontColor(COLORS.cool)
      }
      .width('100%')

dirList 构建器渲染方位列表,其结构比前两个列表更为复杂。列表外层是一个白色卡片容器,卡片顶部是一个标题行——左侧显示"方位精度"标题(12号字、text2 中等色),右侧显示"满刻可读 · 误差±0.5°"说明(10号字、cool 青绿色)。中间的空 Column 使用 layoutWeight(1) 将两侧内容推开,形成左标题右说明的布局。

"满刻可读 · 误差±0.5°"这一说明文字使用了 cool 青绿色,与前面分析过的五行色彩约定一致——青绿色对应"木"行,象征着精确、生长和升发。将精度信息以青绿色高亮,暗示了罗盘方位测量的专业性和可靠性,增强了用户对产品的信任感。

      ForEach(this.dirs, (item: DirItem) => {
        Row() {
          Text(item.name)
            .fontSize(13)
            .fontColor(COLORS.text1)
            .width(44)
          Text(item.element)
            .fontSize(10)
            .fontColor(COLORS.text3)
            .width(56)
          Column() {
            Column()
              .width(accBarW(item.angle % 90))
              .height(8)
              .borderRadius(4)
              .linearGradient({
                angle: 90,
                colors: [[item.color, 0], [COLORS.gold, 1]]
              })
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.End)
          Text(item.angle + '°')
            .fontSize(10)
            .fontColor(COLORS.accent)
            .width(36)
            .textAlign(TextAlign.End)
        }
        .width('100%')
        .padding({ top: 7, bottom: 7 })
      }, (item: DirItem) => item.name)

卡片内容区通过 ForEach 遍历 dirs 数组,每个方位渲染为一行。每行包含四列:方位名称(固定44像素宽,13号字、text1 深色)、五行属性(固定56像素宽,10号字、text3 浅色)、进度条(layoutWeight(1) 自适应宽度)、角度值(固定36像素宽,10号字、accent 棕橙色、右对齐)。

进度条是 dirList 组件中最具特色的视觉元素。进度条的宽度通过 accBarW(item.angle % 90) 函数计算——取角度值除以90的余数,使正方位(90、180、270度)的余数为0,进度条宽度为最小值16;四维方位(45、135、225、315度)的余数为45,进度条宽度为 16 + 45/1.2 = 53.5。进度条高度为8像素,4像素圆角,填充使用90度线性渐变——从方位专属颜色到金色。

进度条容器使用 layoutWeight(1) 占据剩余空间,并设置 alignItems(HorizontalAlign.End) 使进度条右对齐。这种设计使进度条从右侧向左生长,最长的进度条(四维方位)比最短的(正方位)长出约37.5像素,在视觉上形成长短不一的条形图效果,使用户能够直观比较各方位的角度差异。

角度值以 “角度°” 的格式显示在最右侧,使用 accent 棕橙色以区别于其他文本。textAlign(TextAlign.End) 确保角度值在36像素的固定宽度内右对齐,使所有角度值的末位数字对齐,便于用户快速浏览和比较。

7.8 mountainList 二十四山列表构建器

@Builder
mountainList() {
  Column() {
    ForEach(this.mountains, (item: MountainItem) => {
      Row() {
        Column() {
          Text(item.name)
            .fontSize(14)
            .fontColor(COLORS.text1)
            .fontWeight(FontWeight.Medium)
          Text(item.group + ' · ' + item.use)
            .fontSize(11)
            .fontColor(COLORS.text2)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Text(item.degree + '分')
          .fontSize(11)
          .fontColor(gradeColor(item.degree > 94 ? '国师' : '大师'))
          .backgroundColor(COLORS.bg)
          .borderRadius(8)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      }
      .width('100%')
      .padding(10)
      .margin({ bottom: 8 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (item: MountainItem) => item.name)
  }
}

mountainList 构建器渲染二十四山列表。每个列表项的布局采用左右结构——左侧 Column 使用 layoutWeight(1) 占据主要空间,显示山名(14号字、text1 深色、中等字重)和分组用途信息(11号字、text2 中等色);右侧显示分金度数标签。

山名如"子·癸"“丑·艮·寅”“甲·卯·乙"等,以中圆点”·“分隔各个山的代号。对于三山组合(如"丑·艮·寅”),中间的山名(艮)是该组的核心卦山,两侧的山名(丑、寅)是辅助山。这种命名方式使用户能够一眼识别每组的山组合和核心卦象。

分组用途信息 item.group + ' · ' + item.use 将分组名称和用途以分隔符连接,如"北方三山 · 宅运立向"“东北三山 · 山形断吉”。这种简洁的描述使用户无需查阅专业书籍即可了解每座山在堪舆实践中的用途。

分金度数标签显示"度数分"(如"96分"“90分”),颜色通过 gradeColor 函数动态计算——度数大于94的映射为国师级朱红色,其余映射为大师级金色。标签使用淡灰色背景和圆角,与 poolList 中的磁针类型标签样式一致,保持了应用内标签视觉风格的统一。

7.9 masterList 堪舆师列表构建器

@Builder
masterList() {
  Column() {
    ForEach(this.masters, (item: MasterItem) => {
      Row() {
        Column() {
          Text(item.name)
            .fontSize(14)
            .fontColor(COLORS.text1)
            .fontWeight(FontWeight.Medium)
          Text(item.specialty + ' · ' + item.years + '年')
            .fontSize(11)
            .fontColor(COLORS.text2)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Column() {
          Text(item.grade)
            .fontSize(11)
            .fontColor(Color.White)
            .backgroundColor(gradeColor(item.grade))
            .borderRadius(8)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          Text('案牍 ' + item.cases)
            .fontSize(10)
            .fontColor(COLORS.text3)
            .margin({ top: 3 })
        }
        .alignItems(HorizontalAlign.End)
        Text('删除')
          .fontSize(11)
          .fontColor(COLORS.danger)
          .padding({ left: 8, right: 4 })
          .onClick(() => {
            this.selMaster = item;
            this.showDel = true;
          })
      }
      .width('100%')
      .padding(10)
      .margin({ bottom: 8 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (item: MasterItem) => item.name)
  }
}

masterList 构建器渲染堪舆师列表,是所有列表构建器中信息最为丰富的一个。每个列表项的布局分为三部分——左侧 Column 显示堪舆师姓名和专长信息(layoutWeight(1) 占据主要空间),中间 Column 显示等级标签和案牍数,右侧是删除操作按钮。

左侧的姓名使用14号字、text1 深色、中等字重,是列表项的主标题。专长信息 item.specialty + ' · ' + item.years + '年' 将专长领域和从业年限连接为一句描述,如"三合定盘 · 42年"“天星催官 · 38年”。这种描述方式使用户能够同时了解堪舆师的专长方向和经验深度。

中间的 Column 包含两行内容——等级标签和案牍数。等级标签使用白色文字、背景色通过 gradeColor 函数根据等级动态计算(国师朱红、大师金色、名师青绿、学徒灰棕),8像素圆角,形成彩色的等级徽章。案牍数以"案牍 数值"的格式显示在等级标签下方,使用10号字、text3 浅色,作为等级标签的补充信息。案牍数反映了堪舆师的实践经验——数值越大表示处理过的案例越多,经验越丰富。

右侧的"删除"文字使用 danger 红色(‘#D9534F’),是列表中唯一的危险操作按钮。点击删除按钮不会直接删除堪舆师,而是将 selMaster 设为当前堪舆师对象、将 showDel 设为 true,触发 delModal 删除确认弹窗的显示。这种"先选择、再确认"的两步操作模式可以有效防止用户误删数据,是危险操作交互设计的最佳实践。

masterList 的 ForEach 键值生成函数使用 item.name(堪舆师姓名),由于每位堪舆师的姓名唯一,保证了列表项的正确识别。不过需要注意的是,如果两位堪舆师恰好同名(虽然在实际数据中不会出现),键值冲突可能导致 diff 算法行为异常。在更严谨的实现中,可以为数据模型添加唯一标识符(如 id 字段)作为键值。

7.10 orderList 订单列表构建器

@Builder
orderList() {
  Column() {
    Column() {
      Row() {
        Text('订单金额')
          .fontSize(12)
          .fontColor(COLORS.text2)
        Column()
          .layoutWeight(1)
        Text('单位:元')
          .fontSize(10)
          .fontColor(COLORS.text3)
      }
      .width('100%')

orderList 构建器渲染订单列表,是所有列表构建器中结构最复杂的一个。列表分为上下两部分——上半部分是柱状图卡片,下半部分是订单明细列表。

柱状图卡片的顶部是标题行——左侧显示"订单金额"(12号字、text2 中等色),右侧显示"单位:元"(10号字、text3 浅色),中间用 layoutWeight(1) 的空 Column 分开。这种标题-单位的布局使用户在浏览柱状图时能够明确了解图表的含义和数据的单位。

      Row() {
        ForEach(this.orders, (item: CompassOrderItem) => {
          Column() {
            Column()
              .width(18)
              .height(orderBarH(item.amount))
              .borderRadius({ topLeft: 5, topRight: 5 })
              .linearGradient({
                angle: 90,
                colors: [[COLORS.needleA, 0], [COLORS.gold, 1]]
              })
            Text(item.qty + '件')
              .fontSize(9)
              .fontColor(COLORS.text3)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.Center)
          .layoutWeight(1)
        }, (item: CompassOrderItem) => item.buyer)
      }
      .width('100%')
      .height(120)
      .alignItems(VerticalAlign.Bottom)
      .margin({ top: 6 })

柱状图区域使用一个高度为120像素的 Row 容器,通过 ForEach 遍历 orders 数组生成十个柱子。每个柱子是一个 Column 容器,使用 layoutWeight(1) 均分宽度,内部包含柱体和数量标签两部分。

柱体使用 Column 组件渲染,宽度固定为18像素,高度通过 orderBarH(item.amount) 函数动态计算。柱体顶部设置了5像素的圆角(topLeft 和 topRight),使柱顶呈圆角矩形。柱体的填充使用90度线性渐变——从 needleA 朱红色到 gold 金色,与头部磁针的渐变色一致,在视觉上形成与应用主题的呼应。

柱体下方显示"数量件"标签(9号字、text3 浅色),作为柱体的X轴标注。每个Column使用 alignItems(HorizontalAlign.Center) 使柱体和标签水平居中对齐。Row 容器的 alignItems 设为 VerticalAlign.Bottom,使所有柱子底部对齐——这是柱状图的标准对齐方式,确保柱子的高度差异能够准确反映数据的大小差异。

柱状图的Y轴最大高度由 orderBarH 函数的上限决定——最大金额60000对应的高度为 24 + 60000/600 = 124像素,但 Row 容器高度限制为120像素。由于最高金额为58000(奇门遁甲盘),对应高度为 24 + 58000/600 = 120.67像素,接近但不超出容器高度。这种数据范围与容器高度的精确匹配确保了柱状图的视觉效果最佳——最高的柱子几乎填满容器高度,最矮的柱子(9800元,对应高度40.33像素)也有足够的可见高度。

    ForEach(this.orders, (item: CompassOrderItem) => {
      Row() {
        Column() {
          Text(item.item)
            .fontSize(14)
            .fontColor(COLORS.text1)
            .fontWeight(FontWeight.Medium)
          Text(item.buyer + ' · ' + item.date)
            .fontSize(11)
            .fontColor(COLORS.text2)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
        Column() {
          Text('¥' + item.amount)
            .fontSize(13)
            .fontColor(COLORS.accent)
            .fontWeight(FontWeight.Medium)
        }
        .alignItems(HorizontalAlign.End)
      }
      .width('100%')
      .padding(10)
      .margin({ bottom: 8 })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
    }, (item: CompassOrderItem) => item.buyer + '_o')

柱状图下方是订单明细列表,通过 ForEach 遍历 orders 数组生成列表项。每个列表项采用左右结构——左侧 Column 显示商品名称(14号字、text1 深色、中等字重)和买家日期信息(11号字、text2 中等色),右侧 Column 显示金额(13号字、accent 棕橙色、中等字重)。

值得注意的是,明细列表的 ForEach 键值生成函数使用 item.buyer + '_o'(买家名称加后缀’_o’),而柱状图的键值生成函数使用 item.buyer(买家名称)。这是因为两个 ForEach 都遍历同一个 orders 数组,如果使用相同的键值,ArkTS 的 diff 算法可能会混淆两个 ForEach 中的元素。通过在键值中添加后缀 ‘_o’,确保了两个 ForEach 的键值空间不会冲突。

7.11 tabContent 标签内容构建器

@Builder
tabContent() {
  if (this.curTab === 0) {
    Column() {
      Row() {
        Text('罗盘 · 堪舆定盘之器')
          .fontSize(15)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 4, bottom: 8 })
        Column()
          .layoutWeight(1)
        Text('+新增')
          .fontSize(12)
          .fontColor(COLORS.accent)
          .margin({ top: 4, bottom: 8 })
          .onClick(() => {
            this.showAdd = true;
          })
      }
      .width('100%')
      this.compassList()
    }
  } else if (this.curTab === 1) {

tabContent 构建器是整个应用的内容调度中心,根据 curTab 的值通过 if-else 条件判断来渲染不同的内容区块。这种基于状态变量的条件渲染是 ArkTS 中实现页面切换的标准模式——无需路由跳转,通过修改 curTab 值即可在原地切换内容。

当 curTab 为0时,显示罗盘列表区块。区块顶部是一个标题行——左侧显示"罗盘 · 堪舆定盘之器"标题(15号字、text1 深色、粗体),右侧显示"+新增"操作按钮(12号字、accent 棕橙色)。点击"+新增"按钮将 showAdd 设为 true,触发 addModal 新增弹窗的显示。标题行的设计使用户在浏览罗盘列表的同时,能够便捷地发起新增订单的操作。

  } else if (this.curTab === 2) {
    Column() {
      Row() {
        Text('方位 · 八宫指路')
          .fontSize(15)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 4, bottom: 8 })
        Column()
          .layoutWeight(1)
        Text('编辑')
          .fontSize(12)
          .fontColor(COLORS.cool)
          .margin({ top: 4, bottom: 8 })
          .onClick(() => {
            this.selDir = this.dirs[0];
            this.showEdit = true;
          })
      }
      .width('100%')
      this.dirList()
    }

当 curTab 为2时,显示方位列表区块。与罗盘列表类似,区块顶部有标题行和操作按钮,但操作按钮变为"编辑"(使用 cool 青绿色而非 accent 棕橙色)。点击"编辑"按钮将 selDir 设为 dirs 数组的第一个元素(正东方位),并将 showEdit 设为 true,触发 editModal 编辑弹窗的显示。

"编辑"按钮使用青绿色而非棕橙色,是一个微妙的视觉区分——新增操作使用常规的 accent 色,编辑操作使用 cool 色,删除操作使用 danger 红色。这种通过颜色区分操作类型的做法使用户能够通过颜色快速识别操作的属性,降低了误操作的风险。

其他标签的内容区块结构类似——curTab 为1时显示天池列表,为3时显示二十四山列表,为4时显示堪舆师列表,为5时显示订单列表。每个区块都有对应的标题文字,如"天池 · 磁针定南北"“二十四山 · 堪舆分金”“堪舆师 · 相地明师”“订单 · 罗盘往来”。这些标题以"主题 · 副题"的格式呈现,既概括了区块的内容主题,又以副题形式补充了文化内涵。

7.12 addModal 新增弹窗构建器

@Builder
addModal() {
  Stack() {
    this.modalOverlay(() => {
      this.showAdd = false;
    })
    Column() {
      Text('新增罗盘订单')
        .fontSize(16)
        .fontColor(COLORS.text1)
        .fontWeight(FontWeight.Bold)
      TextInput({ placeholder: '买家名称', text: this.newName })
        .fontSize(13)
        .margin({ top: 14 })
        .onChange((v: string) => {
          this.newName = v;
        })
      TextInput({ placeholder: '订购罗盘', text: this.newItem })
        .fontSize(13)
        .margin({ top: 10 })
        .onChange((v: string) => {
          this.newItem = v;
        })
      TextInput({ placeholder: '订单金额', text: this.newPrice })
        .fontSize(13)
        .margin({ top: 10 })
        .onChange((v: string) => {
          this.newPrice = v;
        })

addModal 构建器渲染新增订单弹窗。弹窗使用 Stack 容器作为根节点,内含两层——底层是 modalOverlay 遮罩层(点击关闭弹窗),上层是弹窗内容区。

弹窗内容区是一个 Column 容器,宽度为屏幕的86%,内边距18像素,最大高度限制为80%(constraintSize maxHeight ‘80%’),白色背景,16像素圆角。内容区从上到下依次包含:标题"新增罗盘订单"(16号字、text1 深色、粗体)、三个 TextInput 输入框(买家名称、订购罗盘、订单金额)和操作按钮行。

三个 TextInput 输入框通过 text 属性绑定到 newName、newItem、newPrice 状态变量,通过 onChange 回调实时更新这些变量的值。每个输入框设置了 placeholder 占位文字,引导用户输入正确的信息。输入框之间通过 margin({ top: 10 }) 保持10像素的垂直间距,第一个输入框与标题之间的间距为14像素(margin({ top: 14 })),略大于输入框之间的间距,使标题与内容区之间有更明显的分隔。

      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS.text2)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .onClick(() => {
            this.showAdd = false;
          })
        Column()
          .width(12)
        Text('确认新增')
          .fontSize(14)
          .fontColor(Color.White)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.accent)
          .borderRadius(10)
          .onClick(() => {
            this.orders.push(new CompassOrderItem(this.newName, this.newItem,
              parseInt(this.newPrice) || 1000, 1, '2026-05-01'));
            this.newName = '';
            this.newItem = '';
            this.newPrice = '';
            this.showAdd = false;
          })
      }
      .margin({ top: 18 })

操作按钮行包含"取消"和"确认新增"两个按钮,中间用12像素宽的空 Column 间隔。"取消"按钮使用 text2 中等色文字和 bg 淡灰色背景,是低视觉权重的次要操作;"确认新增"按钮使用白色文字和 accent 棕橙色背景,是高视觉权重的主要操作。这种主次按钮的视觉区分使用户能够一眼识别推荐的操作方向。

"确认新增"按钮的 onClick 回调执行以下操作:使用 newName、newItem 和 newPrice 构造一个新的 CompassOrderItem 实例(数量固定为1,日期固定为’2026-05-01’),通过 this.orders.push() 将其添加到 orders 数组末尾;然后清空三个输入框的状态变量;最后将 showAdd 设为 false 关闭弹窗。

parseInt(this.newPrice) || 1000 这行代码值得注意。parseInt 将字符串转换为整数,但如果用户输入的不是有效数字(如空字符串或字母),parseInt 返回 NaN,而 NaN || 1000 的逻辑或运算会返回默认值1000。这种防御性编程确保了即使用户输入无效数据,订单金额也不会是 NaN 或0,而是有一个合理的默认值。不过,1000作为默认值可能偏低(远低于大多数罗盘的实际价格),在实际应用中可能需要更合理的默认值或输入验证提示。

this.orders.push() 操作触发 ArkTS 的响应式更新——orders 数组被修改后,orderList 组件中的柱状图和明细列表会自动重新渲染,新增的订单数据立即出现在界面上。这种数据变更到UI更新的自动化流程是 ArkTS @State 数组管理的核心能力。

7.13 editModal 编辑弹窗构建器

@Builder
editModal() {
  Stack() {
    this.modalOverlay(() => {
      this.showEdit = false;
    })
    Column() {
      Text('编辑方位 · ' + (this.selDir !== null ? this.selDir.name : ''))
        .fontSize(16)
        .fontColor(COLORS.text1)
        .fontWeight(FontWeight.Bold)
      Text('当前角度 ' + (this.selDir !== null ? this.selDir.angle : 0) + '°,五行 '
        + (this.selDir !== null ? this.selDir.element : ''))
        .fontSize(12)
        .fontColor(COLORS.text2)
        .margin({ top: 8 })

editModal 构建器渲染方位编辑弹窗。弹窗的结构与 addModal 类似——Stack 容器内含遮罩层和内容区。内容区的标题动态显示当前选中的方位名称:"编辑方位 · " + selDir.name。副标题显示当前角度和五行属性:"当前角度 " + selDir.angle + "°,五行 " + selDir.element。

由于 selDir 的类型是 DirItem | null,在访问其属性前必须进行空值检查。代码使用了 (this.selDir !== null ? this.selDir.name : '') 的三元运算符来安全访问属性——如果 selDir 为 null,则显示空字符串而非抛出空指针异常。这种防御性写法在 ArkTS 中是处理可空类型的推荐方式。

      Row() {
        Text('顺转15°')
          .fontSize(13)
          .fontColor(Color.White)
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .backgroundColor(COLORS.cool)
          .borderRadius(10)
          .onClick(() => {
            if (this.selDir !== null) {
              this.selDir.angle = (this.selDir.angle + 15) % 360;
            }
          })
        Column()
          .width(10)
        Text('逆转15°')
          .fontSize(13)
          .fontColor(Color.White)
          .padding({ left: 14, right: 14, top: 8, bottom: 8 })
          .backgroundColor(COLORS.hot)
          .borderRadius(10)
          .onClick(() => {
            if (this.selDir !== null) {
              this.selDir.angle = (this.selDir.angle + 345) % 360;
            }
          })
      }
      .margin({ top: 14 })

弹窗的核心交互是两个旋转按钮——“顺转15°”(青绿色背景)和"逆转15°"(朱红色背景)。顺转按钮将角度增加15度并取360的模:(angle + 15) % 360。逆转按钮将角度减少15度,但为了避免负数取模的问题,使用了 (angle + 345) % 360 的技巧——345 = 360 - 15,加上345等同于减去15,但确保了结果始终为正数。

这种"加替代减"的取模技巧在编程中很常见。在 JavaScript/TypeScript 中,负数取模的结果可能为负数(如 -15 % 360 = -15),这会导致角度值出现负数,不符合0-360度的范围约定。通过加上345(即360-15),将减法转换为加法,确保取模结果始终在0-359范围内。

由于 DirItem 被 @Observed 标记,当 selDir.angle 被修改时,dirList 组件中绑定该属性的进度条宽度和角度文本会自动更新。这种即时的数据-视图同步使用户在点击旋转按钮时能够立即看到进度条和角度值的变化,提供了流畅的交互反馈。

      Text('完成')
        .fontSize(14)
        .fontColor(COLORS.text1)
        .padding({ left: 22, right: 22, top: 9, bottom: 9 })
        .backgroundColor(COLORS.bg)
        .borderRadius(10)
        .margin({ top: 16 })
        .onClick(() => {
          this.showEdit = false;
        })

"完成"按钮用于关闭编辑弹窗。按钮使用 text1 深色文字和 bg 淡灰色背景,视觉权重低于两个旋转按钮,符合"操作按钮权重高于关闭按钮"的设计原则。margin({ top: 16 }) 使完成按钮与上方的旋转按钮行保持16像素的间距,形成清晰的视觉分隔。

7.14 delModal 删除确认弹窗构建器

@Builder
delModal() {
  Stack() {
    this.modalOverlay(() => {
      this.showDel = false;
    })
    Column() {
      Text('删除堪舆师')
        .fontSize(16)
        .fontColor(COLORS.text1)
        .fontWeight(FontWeight.Bold)
      Text('确认移除 ' + (this.selMaster !== null ? this.selMaster.name : '') + ' 及其名录?')
        .fontSize(12)
        .fontColor(COLORS.text2)
        .margin({ top: 10 })
      Row() {
        Text('取消')
          .fontSize(14)
          .fontColor(COLORS.text2)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .onClick(() => {
            this.showDel = false;
          })
        Column()
          .width(12)
        Text('确认删除')
          .fontSize(14)
          .fontColor(Color.White)
          .padding({ left: 20, right: 20, top: 9, bottom: 9 })
          .backgroundColor(COLORS.danger)
          .borderRadius(10)
          .onClick(() => {
            this.showDel = false;
          })
      }
      .margin({ top: 18 })
    }
    .width('86%')
    .padding(18)
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(COLORS.cardBg)
    .borderRadius(16)
    .position({ x: 0, y: 0 })
  }
  .width('100%')
  .height('100%')
  .zIndex(999)
}

delModal 构建器渲染删除确认弹窗。弹窗的结构与前两个弹窗一致——Stack 容器内含遮罩层和内容区。内容区显示标题"删除堪舆师"和确认信息"确认移除 [堪舆师姓名] 及其名录?"。

确认信息中使用了 (this.selMaster !== null ? this.selMaster.name : '') 的空值检查三元运算符,确保在 selMaster 为 null 时不会抛出异常。这种防御性写法在 ArkTS 中处理可空类型时尤为重要,因为 ArkTS 的类型系统虽然提供了联合类型(DirItem | null),但在运行时仍可能因异步操作或状态竞争导致变量为 null。

操作按钮行包含"取消"和"确认删除"两个按钮。"取消"按钮使用 text2 中等色文字和 bg 淡灰色背景,"确认删除"按钮使用白色文字和 danger 红色背景。红色背景明确警示了删除操作的不可逆性和风险性,是危险操作按钮的标准配色。

值得注意的是,"确认删除"按钮的 onClick 回调仅将 showDel 设为 false(关闭弹窗),并未实际执行删除操作(即从 masters 数组中移除 selMaster)。这可能是一个有意为之的设计——在实际应用中,删除操作可能需要调用后端API进行数据持久化,或者需要用户进行二次确认。也可能是一个待完善的功能——在更完整的实现中,应该添加 this.masters.splice(this.masters.indexOf(this.selMaster), 1) 来实际移除选中的堪舆师。

7.15 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 方法是 ArkTS 组件的入口方法,定义了组件的最终UI结构。LuoPanPage 组件的 build 方法使用 Stack 作为根容器,内含两层——底层是主内容区的 Column,上层是三个条件渲染的弹窗。

主内容区的 Column 从上到下包含两部分:Scroll 滚动容器和 bottomBar 底部导航栏。Scroll 容器使用 layoutWeight(1) 占据剩余空间(即除底部导航栏之外的全部高度),内部包含 pageHeader 头部区域和 tabContent 内容区域。Scroll 容器的 scrollable 属性设为 ScrollDirection.Vertical,使内容可以纵向滚动——当内容超出可视区域时,用户可以通过上下滑动查看全部内容。

内容区的 padding 设置为左右14像素、底部12像素,使内容与屏幕边缘保持间距。左右14像素的间距确保内容不会紧贴屏幕边缘,在视觉上形成适当的留白。底部12像素的间距使最后一个列表项与底部导航栏之间有间距,避免内容被导航栏遮挡。

三个弹窗通过 if 条件判断决定是否渲染——当 showAdd 为 true 时渲染 addModal,当 showEdit 为 true 时渲染 editModal,当 showDel 为 true 时渲染 delModal。这种条件渲染的方式使弹窗组件在不需要时不占用渲染资源,只在需要时才被创建和渲染。弹窗组件由于设置了 zIndex(999),会覆盖在主内容区上方。

build 方法的结构体现了"内容层-弹窗层"的分层设计思路——主内容区(pageHeader + tabContent + bottomBar)构成应用的基础界面,弹窗层(addModal + editModal + delModal)作为交互操作的浮层叠加在主内容之上。这种分层设计使主内容区和弹窗区的逻辑相互独立,降低了代码的耦合度。

八、布局样式深度解析

8.1 Flex 布局与 layoutWeight

在整个应用的布局中,Flex 布局(通过 Row 和 Column 组件实现)和 layoutWeight 属性扮演了核心角色。ArkTS 的 Row 和 Column 组件本质上是 Flex 容器,支持 Flex 布局的全部能力——主轴排列、交叉轴对齐、弹性伸缩等。

layoutWeight 属性是 ArkTS 中实现弹性布局的关键。它接受一个数值参数,表示组件在父容器剩余空间中所占的权重比例。当多个子组件设置了 layoutWeight 时,剩余空间按权重比例分配。在应用中,最常见的用法是设置 layoutWeight(1)——当只有一个子组件设置了该属性时,它会占据父容器的全部剩余空间。

在 compassList 的列表项中,左侧 Column 设置了 layoutWeight(1),使其占据 Row 中除右侧价格 Column 之外的全部空间。这种设计确保了列表项的左侧内容区能够自适应屏幕宽度——在窄屏设备上,左侧内容区会被压缩但仍然显示完整;在宽屏设备上,左侧内容区会扩展以填充更多空间。右侧价格 Column 没有设置 layoutWeight,其宽度由内容(价格文本)决定,保持固定宽度。

在 dirList 的方位行中,layoutWeight 的使用更为精妙。方位名称(44px)和五行属性(56px)使用固定宽度,角度值(36px)也使用固定宽度,进度条容器使用 layoutWeight(1) 占据剩余空间。这种"固定+弹性+固定"的混合布局确保了关键信息(名称、属性、角度)的宽度固定不变,而进度条区域能够自适应剩余空间,在不同屏幕宽度下都能合理布局。

在 orderList 的柱状图中,每个柱子使用 layoutWeight(1) 均分 Row 的宽度,确保十个柱子在水平方向均匀分布。无论屏幕宽度如何变化,每个柱子都获得相等的宽度,柱体本身固定为18像素宽,在 Column 中居中显示。

8.2 对齐方式

ArkTS 提供了丰富的对齐选项,应用中主要使用了以下几种对齐方式:

HorizontalAlign.Start(水平起始对齐):在 compassList、mountainList、masterList、orderList 的左侧 Column 中使用,使标题和描述文字左对齐。左对齐是阅读型文本的标准对齐方式,符合用户从左到右的阅读习惯。

HorizontalAlign.End(水平末尾对齐):在 compassList、masterList、orderList 的右侧 Column 中使用,使价格和金额信息右对齐。右对齐使数值的末位数字对齐,便于用户比较不同数值的大小。

HorizontalAlign.Center(水平居中对齐):在 orderList 柱状图的柱子 Column 中使用,使柱体和标签在各自的等分宽度内居中显示。居中对齐确保柱子之间的间距均匀。

FlexAlign.Center(主轴居中对齐):在 bottomBar 的两行 Row 中使用,使标签组在水平方向居中排列。居中排列使导航栏的视觉重心位于屏幕中央。

VerticalAlign.Bottom(垂直底部对齐):在 orderList 柱状图的 Row 中使用,使所有柱子底部对齐。底部对齐是柱状图的标准对齐方式,确保柱子的高度差异准确反映数据大小。

8.3 间距与圆角

应用中的间距设计遵循了系统化的原则。卡片内边距统一为10像素(padding(10)),弹窗内边距统一为18像素(padding(18)),列表项之间的间距统一为8像素(margin({ bottom: 8 }))。这种统一的间距规范使整个应用的视觉节奏一致,不会出现某些区域间距过大或过小的不协调感。

圆角设计同样遵循系统化原则。卡片圆角为12像素(borderRadius(12)),弹窗圆角为16像素(borderRadius(16)),标签圆角为8像素(borderRadius(8)),按钮圆角为10像素(borderRadius(10))。圆角大小与元素尺寸成正比——越大的元素使用越大的圆角,保持视觉比例的协调。

头部区域的圆角为22像素(borderRadius(22)),是应用中最大的圆角值。这种较大的圆角使头部区域呈现为圆角卡片效果,与下方的列表卡片形成层次区分。头部区域的高度为176像素,22像素的圆角相对于176像素的高度约为12.5%的圆角比例,在视觉上呈现出柔和但不夸张的圆角效果。

8.4 渐变效果

应用中大量使用了线性渐变(linearGradient)效果,主要体现在以下几个位置:

头部背景渐变:从 header1 ‘#4E3A28’ 到 header2 ‘#221812’,135度角度,模拟深色木质盘面的光影效果。

磁针渐变:从 needleA ‘#B8432E’ 到 needleB ‘#E3B23C’,90度角度,模拟传统罗盘磁针南红北金的双色效果。

方位进度条渐变:从方位专属颜色到 gold ‘#E3B23C’,90度角度,使进度条从方位色过渡到金色,增加视觉丰富度。

订单柱状图渐变:从 needleA ‘#B8432E’ 到 gold ‘#E3B23C’,90度角度,与磁针渐变一致,形成视觉呼应。

所有渐变都使用90度或135度角度。90度渐变是从上到下的垂直渐变,135度渐变是从左上到右下的斜向渐变。这两种角度的渐变在视觉上最为自然,符合光源从上方照射的视觉习惯。

linearGradient 的参数格式为 { angle: 角度, colors: [[颜色1, 位置1], [颜色2, 位置2]] }。位置值从0到1,表示渐变的起点和终点。在应用中,所有渐变都使用 [[color1, 0], [color2, 1]] 的格式——起始颜色在位置0,终止颜色在位置1,形成从颜色1到颜色2的均匀过渡。

九、Mermaid 流程图

9.1 应用整体架构流程图

0

1

2

3

4

5

showAdd

showEdit

showDel

应用启动

aboutToAppear 生命周期

启动 breath 定时器
每440ms 翻转

build 方法渲染

Stack 根容器

主内容区 Column

弹窗层 条件渲染

Scroll 滚动容器

bottomBar 底部导航栏

pageHeader 头部区域

tabContent 内容区域

curTab 值判断

compassList 罗盘列表

poolList 天池列表

dirList 方位列表

mountainList 二十四山列表

masterList 堪舆师列表

orderList 订单列表

弹窗状态判断

addModal 新增弹窗

editModal 编辑弹窗

delModal 删除弹窗

用户点击标签

更新 curTab

9.2 状态驱动与响应式更新流程图

定时器

UI更新

状态变量

用户交互

点击底部标签

点击新增按钮

点击编辑按钮

点击删除按钮

输入框输入

点击顺转/逆转

curTab

showAdd

showEdit

showDel

newName/newItem/newPrice

selDir.angle

breath

tabContent 切换内容

addModal 显示/隐藏

editModal 显示/隐藏

delModal 显示/隐藏

输入框文本同步

进度条宽度更新

头部动画刷新

setInterval 440ms

9.3 数据模型与UI组件映射关系图

渲染错误: Mermaid 渲染失败: Lexical error on line 2. Unrecognized text. ...B subgraph 数据模型 @Observed D1 ----------------------^

十、技术对比表格

10.1 ArkTS 状态管理装饰器对比

装饰器作用范围数据流向响应式更新使用场景本应用中的使用
@State组件内部单向(数据驱动视图)修改即触发重渲染组件内部状态管理curTab、breath、showAdd 等10个状态变量
@Observed可观察类类属性变更通知属性赋值触发通知数据模型类标记CompassItem 等6个数据模型类
@Prop父到子单向(父到子)父组件更新时同步父子组件简单数据传递本应用未使用(单组件架构)
@ObjectLink父到子双向(对象引用共享)对象属性变更双向同步@Observed 类的对象在子组件中使用本应用未使用(单组件架构)
@Link父到子双向(变量引用共享)双向同步更新父子组件双向数据绑定本应用未使用(单组件架构)
@Provide祖先到后代单向(向下注入)祖先更新时后代同步跨层级数据传递本应用未使用(单组件架构)
@Consume后代接收单向(接收注入)接收祖先更新跨层级数据接收本应用未使用(单组件架构)
@Builder组件内方法无数据流(UI片段)跟随组件状态更新可复用UI片段构建modalOverlay 等14个构建器
@Entry应用入口无数据流标记入口组件应用根组件标记LuoPanPage 入口组件
@Component自定义组件无数据流标记自定义组件组件声明LuoPanPage 组件声明

10.2 数据模型属性对比

模型类属性数量属性类型可变性关联UI组件关联辅助函数
CompassItem5string/number只读展示compassList
PoolItem4string/number只读展示poolListgradeColor(磁针颜色)
DirItem4string/numberangle 可变dirListaccBarW(进度条宽度)
MountainItem4string/number只读展示mountainListgradeColor(度数颜色)
MasterItem5string/number只读展示masterListgradeColor(等级颜色)
CompassOrderItem5string/number可新增(push)orderListorderBarH(柱状图高度)

10.3 UI组件复杂度对比

构建器代码行数子组件数动画绑定交互事件复杂度评级
modalOverlay81onClick(关闭)
pageHeader9530+breath(多处)极高
bottomBar207无(委托给bottomItem)
bottomItem152onClick(切换标签)
compassList30动态
poolList25动态
dirList40动态
mountainList30动态
masterList40动态onClick(删除)
orderList70动态极高
tabContent80动态onClick(新增/编辑)
addModal608onChange/onClick
editModal557onClick(顺转/逆转/完成)
delModal506onClick(取消/确认)

10.4 动画效果对比

动画元素绑定状态duration(ms)iterationsplayMode视觉效果
星点透明度breath650-1(无限)Alternate闪烁明暗交替
同心圆透明度breath700-1Alternate呼吸式渐隐渐显
磁针旋转breath520-1Alternate左右摆动模拟指北
标语透明度breath600-1Alternate缓慢闪烁
左侧装饰线旋转breath700-1Alternate左右倾斜摆动
右侧装饰线旋转breath700-1Alternate左右倾斜摆动(镜像)

10.5 前端框架特性对比

特性ArkTS (HarmonyOS)ReactVue 3Flutter
声明式UI是(JSX)是(模板)是(Widget)
状态管理@State/@ObserveduseState/Contextref/reactivesetState/Provider
响应式更新自动(代理拦截)自动(虚拟DOM diff)自动(Proxy代理)自动(Element树diff)
组件复用@Builder方法函数组件组件定义Widget组合
条件渲染if-else语法三元/&&v-if/v-showif-else
列表渲染ForEachmap()v-forListView.builder
类型系统强类型(ArkTS)可选(TypeScript)可选(TypeScript)强类型(Dart)
编译方式AOT编译JIT/AOTJIT/AOTAOT编译
动画系统animation属性CSS/第三方CSS/TransitionAnimationController
布局系统Flex(Row/Column)FlexboxFlexboxFlex/Stack

十一、技术要点总结

本文对一款基于 HarmonyOS ArkTS 框架开发的罗盘堪舆应用进行了全面而深入的代码级解析。通过对色彩体系、标签系统、数据模型、UI组件、状态管理、布局样式等各个层面的逐行分析,我们可以提炼出以下核心技术要点和架构设计经验。

11.1 响应式状态管理的精髓

应用采用了 ArkTS 框架提供的 @State 和 @Observed 两大状态管理装饰器,构建了一套完整的响应式数据流体系。@State 装饰器管理组件内部的十个状态变量,包括标签索引(curTab)、呼吸动画开关(breath)、弹窗显隐控制(showAdd/showEdit/showDel)、选中对象(selDir/selMaster)和输入数据(newName/newItem/newPrice)。这些状态变量的任何变更都会自动触发依赖它们的UI组件重新渲染,无需开发者手动调用刷新方法。

@Observed 装饰器则作用于六个数据模型类(CompassItem、PoolItem、DirItem、MountainItem、MasterItem、CompassOrderItem),使得这些类的实例属性在被修改时能够通知绑定的UI组件。在 editModal 弹窗中,用户点击"顺转15度"按钮修改 selDir.angle 属性后,dirList 组件中的进度条宽度和角度文本会立即更新——这种即时的数据-视图同步正是 @Observed 装饰器的核心能力。

值得深思的是,应用未使用 @Prop、@Link、@ObjectLink 等父子组件通信装饰器,因为整个应用采用单组件架构(LuoPanPage),所有UI逻辑都在同一个 @Component 中通过 @Builder 方法组织。这种架构简化了状态管理——无需考虑跨组件数据传递的复杂性,所有状态都在组件内部统一管理。在中小型应用中,这种单组件架构是合理且高效的选择;但在大型应用中,可能需要拆分为多个子组件,此时就需要引入 @Prop/@Link/@ObjectLink 来处理跨组件通信。

11.2 声明式UI的组件化设计

应用通过 @Builder 装饰器定义了十四个可复用的UI构建方法,涵盖了遮罩层、头部区域、导航栏、列表、弹窗等各类UI元素。@Builder 方法的参数化能力使得同一构建器能够适应不同的使用场景——modalOverlay 通过 onClose 参数实现了三个弹窗的遮罩层复用,bottomItem 通过索引参数 i 实现了六个导航标签的统一渲染。

@Builder 方法的另一个优势是其与组件状态的天然集成。构建器内部可以直接访问 this 引用的所有 @State 变量和组件方法,无需通过参数传递。pageHeader 构建器直接访问 this.breath 来驱动动画,addModal 构建器直接访问和修改 this.newName、this.showAdd 等状态变量。这种"构建器即组件方法"的设计模式使UI逻辑与状态逻辑紧密耦合,降低了代码的分散度。

tabContent 构建器通过 if-else 条件判断实现了六个标签页面的切换,这是 ArkTS 中实现多页面内容切换的标准模式。与路由跳转不同,条件渲染的页面切换在原地完成,不涉及页面栈的推入和弹出,切换速度更快、内存开销更低。但这种模式也有局限——所有页面的UI结构在编译时已确定,无法实现动态路由或延迟加载。对于页面数量固定且数量有限的应用(如本应用的六个标签页),条件渲染是最佳选择。

11.3 数据可视化的设计智慧

应用在数据可视化方面展现了精巧的设计智慧。dirList 中的方位进度条通过 accBarW 函数将角度值映射为像素宽度,使用线性渐变填充使进度条从方位专属色过渡到金色,既传达了数据信息又具有视觉美感。orderList 中的柱状图通过 orderBarH 函数将订单金额映射为柱体高度,十个柱子底部对齐排列,使金额的相对大小一目了然。

这些可视化设计的核心在于"数据到视觉的映射函数"。accBarW、craftBarW 和 orderBarH 三个函数都采用了相同的模式:Math.min 上限约束 + 除法缩放 + 基础偏移量。这种模式确保了无论输入数据的范围如何,输出的视觉尺寸都保持在合理区间内——既不会因数据过大而溢出容器,也不会因数据过小而不可见。基础偏移量(16或24像素)的设置保证了零值数据也有最小可见尺寸,避免了"零高度消失"的问题。

gradeColor 函数则实现了"语义到颜色"的映射——将堪舆师等级(国师/大师/名师/学徒)映射为不同的颜色值。这种映射使UI颜色不再是静态的硬编码值,而是根据数据动态计算的。当数据发生变化时(如堪舆师等级提升),颜色会自动更新,无需手动维护颜色与数据的对应关系。

11.4 动画系统的统一驱动

应用的动画系统采用了"单一状态源统一驱动"的设计模式。breath 布尔变量在 aboutToAppear 生命周期中通过 setInterval 每440毫秒翻转一次,成为所有呼吸动画的唯一驱动源。头部区域的星点透明度、同心圆透明度、磁针旋转角度、标语透明度、装饰线条旋转角度——这些分散在头部各处的动画效果全部绑定到同一个 breath 变量。

这种设计有三个显著优势。第一,同步性——所有动画由同一个状态驱动,确保了它们之间的节奏一致,不会出现某些动画快、某些动画慢的不同步问题。第二,简洁性——只需一个定时器和一个状态变量即可驱动多处动画,避免了多个独立定时器的资源开销和管理复杂性。第三,可控性——如果需要暂停所有动画(如在组件不可见时),只需停止 breath 定时器即可,无需逐一暂停每个动画。

各动画的 duration 值(520、600、650、700毫秒)略有差异,这是一个有意的设计。如果所有动画使用完全相同的 duration,它们的变化节奏将完全同步,产生机械感。略微不同的 duration 使各动画的变化节奏产生微妙的差异,形成更为自然、有机的视觉效果——如同自然界中不同生物的呼吸频率各有不同,整体呈现出和谐的多样性。

11.5 传统文化与技术的融合

应用最引人注目的特点是将罗盘堪舆这一传统文化主题与现代移动开发技术的深度融合。从色彩体系中的檀木色、铜金色、朱红色等传统色彩,到数据模型中的三合罗盘、玄空飞星、奇门遁甲等堪舆流派,再到UI文案中的"天池定针"“二十四山”"堪舆神工"等术语,每一个设计元素都承载着深厚的文化内涵。

这种融合并非简单的文化元素堆砌,而是深入到了代码架构层面。gradeColor 函数将堪舆师等级映射为五行色,dirList 的进度条使用方位专属色作为渐变起始色,磁针指针还原了传统罗盘"南红北金"的色彩约定——这些设计将堪舆学的理论体系融入了UI的视觉语言中,使应用不仅是一个功能工具,更是一个文化载体。

从技术角度看,这种文化与技术的融合为应用赋予了独特的品牌辨识度。在千篇一律的Material Design和扁平化设计风格中,一款充满古典中式美学的应用能够给用户留下深刻的印象。这也启示开发者:技术应用不应仅追求功能实现,更应思考如何通过技术传达文化价值,使产品具有超越功能本身的精神内涵。


安装DevEco Studio程序

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

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

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

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

在这里插入图片描述


完整代码:

interface ColorPalette {
  bg: string;
  cardBg: string;
  header1: string;
  header2: string;
  discA: string;
  discB: string;
  ringA: string;
  ringB: string;
  needleA: string;
  needleB: 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: '#F2EFE8',
  cardBg: '#FFFFFF',
  header1: '#4E3A28',
  header2: '#221812',
  discA: '#C9A87C',
  discB: '#8A6A4E',
  ringA: '#5A4632',
  ringB: '#3A2C20',
  needleA: '#B8432E',
  needleB: '#E3B23C',
  gold: '#E3B23C',
  title: '#FAF2E6',
  sub: '#D8C0A0',
  text1: '#33261A',
  text2: '#6B5844',
  text3: '#9C8A72',
  accent: '#8A5A3A',
  hot: '#B8432E',
  cool: '#3E6B5E',
  danger: '#D9534F',
  tabBg: '#4E3A28',
  tabOn: '#F2C14E',
  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 RING_IDX: number[] = [0, 1, 2, 3];
const DIR_CHARS: string[] = ['子', '丑', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'];
const TICK_IDX: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const STAR_IDX: number[] = [0, 1, 2, 3];

function dirX(i: number): number {
  return 150 + Math.round(78 * Math.sin(i * 3.14159 / 6));
}

function dirY(i: number): number {
  return 88 + Math.round(78 * Math.cos(i * 3.14159 / 6));
}

function ringSize(i: number): number {
  return 176 - i * 36;
}

function tickX(i: number): number {
  return 150 + Math.round(92 * Math.sin(i * 3.14159 / 6));
}

function tickY(i: number): number {
  return 88 + Math.round(92 * Math.cos(i * 3.14159 / 6));
}

function starX(i: number): number {
  return 40 + i * 60;
}

function starY(i: number): number {
  return 14 + (i % 2) * 10;
}

function gradeColor(g: string): string {
  if (g === '国师') {
    return COLORS.needleA;
  }
  if (g === '大师') {
    return COLORS.gold;
  }
  if (g === '名师') {
    return COLORS.cool;
  }
  return COLORS.text3;
}

function accBarW(a: number): number {
  return 16 + Math.min(a, 100) / 1.2;
}

function craftBarW(c: number): number {
  return 16 + Math.min(c, 30) / 0.375;
}

function orderBarH(amount: number): number {
  return 24 + Math.min(amount, 60000) / 600;
}

@Observed
export class CompassItem {
  name: string;
  layers: number;
  rings: number;
  price: number;
  wood: string;
  constructor(name: string, layers: number, rings: number, price: number, wood: string) {
    this.name = name;
    this.layers = layers;
    this.rings = rings;
    this.price = price;
    this.wood = wood;
  }
}

@Observed
export class PoolItem {
  name: string;
  glass: string;
  depth: number;
  magnet: string;
  constructor(name: string, glass: string, depth: number, magnet: string) {
    this.name = name;
    this.glass = glass;
    this.depth = depth;
    this.magnet = magnet;
  }
}

@Observed
export class DirItem {
  name: string;
  element: string;
  angle: number;
  color: string;
  constructor(name: string, element: string, angle: number, color: string) {
    this.name = name;
    this.element = element;
    this.angle = angle;
    this.color = color;
  }
}

@Observed
export class MountainItem {
  name: string;
  group: string;
  degree: number;
  use: string;
  constructor(name: string, group: string, degree: number, use: string) {
    this.name = name;
    this.group = group;
    this.degree = degree;
    this.use = use;
  }
}

@Observed
export class MasterItem {
  name: string;
  grade: string;
  years: number;
  cases: number;
  specialty: string;
  constructor(name: string, grade: string, years: number, cases: number, specialty: string) {
    this.name = name;
    this.grade = grade;
    this.years = years;
    this.cases = cases;
    this.specialty = specialty;
  }
}

@Observed
export class CompassOrderItem {
  buyer: string;
  item: string;
  amount: number;
  qty: number;
  date: string;
  constructor(buyer: string, item: string, amount: number, qty: number, date: string) {
    this.buyer = buyer;
    this.item = item;
    this.amount = amount;
    this.qty = qty;
    this.date = date;
  }
}

@Entry
@Component
struct LuoPanPage {
  @State curTab: number = 0;
  @State breath: boolean = false;
  @State showAdd: boolean = false;
  @State showEdit: boolean = false;
  @State showDel: boolean = false;
  @State selDir: DirItem | null = null;
  @State selMaster: MasterItem | null = null;
  @State newName: string = '';
  @State newItem: string = '';
  @State newPrice: string = '';

  @State compasses: CompassItem[] = [
    new CompassItem('三合罗盘', 22, 3, 2600, '黄檀木'),
    new CompassItem('三元罗盘', 24, 4, 3200, '黑檀木'),
    new CompassItem('综合罗盘', 30, 5, 4600, '红木'),
    new CompassItem('玄空飞星盘', 26, 4, 3800, '黄檀木'),
    new CompassItem('八宅罗盘', 20, 3, 2200, '楠木'),
    new CompassItem('大六壬盘', 28, 5, 4200, '紫檀木'),
    new CompassItem('奇门遁甲盘', 32, 6, 5800, '黑檀木'),
    new CompassItem('小玄空盘', 18, 2, 1600, '花梨木'),
    new CompassItem('风水铜盘', 25, 4, 3400, '青铜'),
    new CompassItem('堪舆精装盘', 36, 7, 7600, '紫檀木'),
    new CompassItem('祖传三合盘', 22, 3, 6800, '老黄檀'),
    new CompassItem('学徒练习盘', 16, 2, 980, '白蜡木')
  ];

  @State pools: PoolItem[] = [
    new PoolItem('水晶天池', '天然水晶', 4, '强磁针'),
    new PoolItem('琉璃天池', '古法琉璃', 3, '中磁针'),
    new PoolItem('玉髓天池', '天然玉髓', 5, '强磁针'),
    new PoolItem('玻璃天池', '光学玻璃', 2, '普通针'),
    new PoolItem('玛瑙天池', '天然玛瑙', 4, '强磁针'),
    new PoolItem('蓝宝天池', '蓝宝石面', 5, '特强针'),
    new PoolItem('水晶浮针池', '水晶+浮针', 6, '悬浮针'),
    new PoolItem('古法铜池', '黄铜底', 3, '老磁针')
  ];

  @State dirs: DirItem[] = [
    new DirItem('正东', '震·木', 90, '#3E6B5E'),
    new DirItem('正南', '离·火', 180, '#B8432E'),
    new DirItem('正西', '兑·金', 270, '#C9A87C'),
    new DirItem('正北', '坎·水', 0, '#3A5A8A'),
    new DirItem('东南', '巽·木', 135, '#4E7A5A'),
    new DirItem('西南', '坤·土', 225, '#8A6A4E'),
    new DirItem('西北', '乾·金', 315, '#5A6B8A'),
    new DirItem('东北', '艮·土', 45, '#7A5A4A')
  ];

  @State mountains: MountainItem[] = [
    new MountainItem('子·癸', '北方三山', 96, '宅运立向'),
    new MountainItem('丑·艮·寅', '东北三山', 90, '山形断吉'),
    new MountainItem('甲·卯·乙', '东方三山', 94, '龙脉寻踪'),
    new MountainItem('辰·巽·巳', '东南三山', 92, '水口定局'),
    new MountainItem('丙·午·丁', '南方三山', 97, '朝向择吉'),
    new MountainItem('未·坤·申', '西南三山', 89, '砂法观形'),
    new MountainItem('庚·酉·辛', '西方三山', 93, '水法立向'),
    new MountainItem('戌·乾·亥', '西北三山', 95, '天门合局'),
    new MountainItem('二十四山全盘', '综合', 98, '堪舆定盘'),
    new MountainItem('七十二龙分金', '细层', 96, '分金坐度')
  ];

  @State masters: MasterItem[] = [
    new MasterItem('杨公传人·陈', '国师', 42, 680, '三合定盘'),
    new MasterItem('赖公传人·罗', '国师', 38, 520, '天星催官'),
    new MasterItem('蒋公传人·吴', '大师', 30, 410, '玄空飞星'),
    new MasterItem('郭公传人·郑', '大师', 28, 360, '峦头点穴'),
    new MasterItem('何公传人·马', '名师', 20, 240, '八宅安宅'),
    new MasterItem('曾公传人·许', '名师', 18, 210, '大六壬断'),
    new MasterItem('新派堪舆·顾', '学徒', 6, 48, '罗盘入门'),
    new MasterItem('新派堪舆·韩', '学徒', 4, 32, '立向基础')
  ];

  @State orders: CompassOrderItem[] = [
    new CompassOrderItem('北京易学馆', '综合罗盘', 46000, 10, '2026-01-06'),
    new CompassOrderItem('上海风水阁', '玄空飞星盘', 38000, 10, '2026-01-18'),
    new CompassOrderItem('广州堪舆社', '奇门遁甲盘', 58000, 10, '2026-02-02'),
    new CompassOrderItem('成都古籍店', '三合罗盘', 26000, 10, '2026-02-15'),
    new CompassOrderItem('杭州国学馆', '堪舆精装盘', 38000, 5, '2026-03-04'),
    new CompassOrderItem('西安文化街', '大六壬盘', 42000, 10, '2026-03-17'),
    new CompassOrderItem('南京典籍铺', '三元罗盘', 32000, 10, '2026-03-28'),
    new CompassOrderItem('武汉易学会', '风水铜盘', 34000, 10, '2026-04-05'),
    new CompassOrderItem('苏州文房阁', '祖传三合盘', 34000, 5, '2026-04-12'),
    new CompassOrderItem('天津罗盘坊', '学徒练习盘', 9800, 10, '2026-04-18')
  ];

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

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

  @Builder
  pageHeader() {
    Column() {
      Stack() {
        Column()
          .width('100%')
          .height('100%')
          .borderRadius(22)
          .linearGradient({
            angle: 135,
            colors: [[COLORS.header1, 0], [COLORS.header2, 1]]
          })
        ForEach(STAR_IDX, (i: number) => {
          Text('✦')
            .fontSize(8 + (i % 2) * 3)
            .fontColor(COLORS.gold)
            .opacity(this.breath ? 0.3 : 0.9)
            .position({ x: starX(i), y: starY(i) })
            .animation({ duration: 650, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 's' + i)
        ForEach(RING_IDX, (i: number) => {
          Circle()
            .width(ringSize(i))
            .height(ringSize(i))
            .fill(Color.Transparent)
            .stroke(COLORS.ringA)
            .strokeWidth(2)
            .position({ x: 150 - ringSize(i) / 2, y: 88 - ringSize(i) / 2 })
            .opacity(this.breath ? 0.55 : 1)
            .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
        }, (i: number) => 'r' + i)
        ForEach(TICK_IDX, (i: number) => {
          Column()
            .width(2)
            .height(6)
            .backgroundColor(COLORS.gold)
            .position({ x: tickX(i) - 1, y: tickY(i) - 3 })
            .rotate({ angle: i * 30 })
        }, (i: number) => 't' + i)
        ForEach(DIR_CHARS, (ch: string, i: number) => {
          Text(ch)
            .fontSize(12)
            .fontColor(COLORS.sub)
            .position({ x: dirX(i) - 6, y: dirY(i) - 8 })
        }, (ch: string) => ch)
        Circle()
          .width(34)
          .height(34)
          .fill(COLORS.discB)
          .position({ x: 133, y: 71 })
        Circle()
          .width(18)
          .height(18)
          .fill(COLORS.gold)
          .position({ x: 141, y: 79 })
        Column()
          .width(8)
          .height(86)
          .borderRadius(4)
          .linearGradient({
            angle: 90,
            colors: [[COLORS.needleA, 0], [COLORS.needleB, 1]]
          })
          .position({ x: 146, y: 45 })
          .rotate({ angle: this.breath ? -18 : 18 })
          .animation({ duration: 520, iterations: -1, playMode: PlayMode.Alternate })
        Text('罗盘坊')
          .fontSize(24)
          .fontColor(COLORS.title)
          .fontWeight(FontWeight.Bold)
          .position({ x: 210, y: 58 })
        Text('天池定针 · 二十四山')
          .fontSize(12)
          .fontColor(COLORS.sub)
          .position({ x: 210, y: 92 })
        Text('堪舆神工 · 指北定南')
          .fontSize(10)
          .fontColor(COLORS.gold)
          .position({ x: 210, y: 118 })
          .opacity(this.breath ? 0.55 : 1)
          .animation({ duration: 600, iterations: -1, playMode: PlayMode.Alternate })
        Column()
          .width(1)
          .height(30)
          .backgroundColor(COLORS.gold)
          .position({ x: 36, y: 150 })
          .rotate({ angle: this.breath ? -8 : 8 })
          .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
        Column()
          .width(1)
          .height(30)
          .backgroundColor(COLORS.gold)
          .position({ x: 286, y: 150 })
          .rotate({ angle: this.breath ? 8 : -8 })
          .animation({ duration: 700, iterations: -1, playMode: PlayMode.Alternate })
      }
      .width('100%')
      .height(176)
    }
    .width('100%')
    .margin({ top: 8, bottom: 6 })
  }

  @Builder
  bottomBar() {
    Column() {
      Row() {
        ForEach(ROW1_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r1_' + i)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
      Row() {
        ForEach(ROW2_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => 'r2_' + i)
      }
      .width('100%')
      .justifyContent(FlexAlign.Center)
    }
    .width('100%')
    .padding({ top: 8, bottom: 10 })
    .backgroundColor(COLORS.tabBg)
  }

  @Builder
  bottomItem(i: number) {
    Column() {
      Text(TAB_LIST[i].icon)
        .fontSize(17)
      Text(TAB_LIST[i].label)
        .fontSize(10)
        .fontColor(this.curTab === i ? COLORS.tabOn : COLORS.sub)
    }
    .width(92)
    .padding({ top: 6, bottom: 6 })
    .borderRadius(10)
    .backgroundColor(this.curTab === i ? COLORS.header2 : Color.Transparent)
    .onClick(() => {
      this.curTab = i;
    })
  }

  @Builder
  compassList() {
    Column() {
      ForEach(this.compasses, (item: CompassItem) => {
        Row() {
          Column() {
            Text(item.name)
              .fontSize(14)
              .fontColor(COLORS.text1)
              .fontWeight(FontWeight.Medium)
            Text(item.wood + ' · ' + item.layers + '层 ' + item.rings + '盘')
              .fontSize(11)
              .fontColor(COLORS.text2)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column() {
            Text('¥' + item.price)
              .fontSize(13)
              .fontColor(COLORS.accent)
              .fontWeight(FontWeight.Medium)
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(10)
        .margin({ bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (item: CompassItem) => item.name)
    }
  }

  @Builder
  poolList() {
    Column() {
      ForEach(this.pools, (item: PoolItem) => {
        Row() {
          Text(item.name)
            .fontSize(14)
            .fontColor(COLORS.text1)
            .fontWeight(FontWeight.Medium)
          Text(' · ' + item.glass)
            .fontSize(11)
            .fontColor(COLORS.text2)
          Column()
            .layoutWeight(1)
          Text(item.magnet)
            .fontSize(11)
            .fontColor(gradeColor(item.magnet === '强磁针' ? '大师' : '名师'))
            .backgroundColor(COLORS.bg)
            .borderRadius(8)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        }
        .width('100%')
        .padding(10)
        .margin({ bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (item: PoolItem) => item.name)
    }
  }

  @Builder
  dirList() {
    Column() {
      Column() {
        Row() {
          Text('方位精度')
            .fontSize(12)
            .fontColor(COLORS.text2)
          Column()
            .layoutWeight(1)
          Text('满刻可读 · 误差±0.5°')
            .fontSize(10)
            .fontColor(COLORS.cool)
        }
        .width('100%')
        ForEach(this.dirs, (item: DirItem) => {
          Row() {
            Text(item.name)
              .fontSize(13)
              .fontColor(COLORS.text1)
              .width(44)
            Text(item.element)
              .fontSize(10)
              .fontColor(COLORS.text3)
              .width(56)
            Column() {
              Column()
                .width(accBarW(item.angle % 90))
                .height(8)
                .borderRadius(4)
                .linearGradient({
                  angle: 90,
                  colors: [[item.color, 0], [COLORS.gold, 1]]
                })
            }
            .layoutWeight(1)
            .alignItems(HorizontalAlign.End)
            Text(item.angle + '°')
              .fontSize(10)
              .fontColor(COLORS.accent)
              .width(36)
              .textAlign(TextAlign.End)
          }
          .width('100%')
          .padding({ top: 7, bottom: 7 })
        }, (item: DirItem) => item.name)
      }
      .width('100%')
      .padding(10)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
      .margin({ bottom: 8 })
    }
  }

  @Builder
  mountainList() {
    Column() {
      ForEach(this.mountains, (item: MountainItem) => {
        Row() {
          Column() {
            Text(item.name)
              .fontSize(14)
              .fontColor(COLORS.text1)
              .fontWeight(FontWeight.Medium)
            Text(item.group + ' · ' + item.use)
              .fontSize(11)
              .fontColor(COLORS.text2)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Text(item.degree + '分')
            .fontSize(11)
            .fontColor(gradeColor(item.degree > 94 ? '国师' : '大师'))
            .backgroundColor(COLORS.bg)
            .borderRadius(8)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        }
        .width('100%')
        .padding(10)
        .margin({ bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (item: MountainItem) => item.name)
    }
  }

  @Builder
  masterList() {
    Column() {
      ForEach(this.masters, (item: MasterItem) => {
        Row() {
          Column() {
            Text(item.name)
              .fontSize(14)
              .fontColor(COLORS.text1)
              .fontWeight(FontWeight.Medium)
            Text(item.specialty + ' · ' + item.years + '年')
              .fontSize(11)
              .fontColor(COLORS.text2)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column() {
            Text(item.grade)
              .fontSize(11)
              .fontColor(Color.White)
              .backgroundColor(gradeColor(item.grade))
              .borderRadius(8)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            Text('案牍 ' + item.cases)
              .fontSize(10)
              .fontColor(COLORS.text3)
              .margin({ top: 3 })
          }
          .alignItems(HorizontalAlign.End)
          Text('删除')
            .fontSize(11)
            .fontColor(COLORS.danger)
            .padding({ left: 8, right: 4 })
            .onClick(() => {
              this.selMaster = item;
              this.showDel = true;
            })
        }
        .width('100%')
        .padding(10)
        .margin({ bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (item: MasterItem) => item.name)
    }
  }

  @Builder
  orderList() {
    Column() {
      Column() {
        Row() {
          Text('订单金额')
            .fontSize(12)
            .fontColor(COLORS.text2)
          Column()
            .layoutWeight(1)
          Text('单位:元')
            .fontSize(10)
            .fontColor(COLORS.text3)
        }
        .width('100%')
        Row() {
          ForEach(this.orders, (item: CompassOrderItem) => {
            Column() {
              Column()
                .width(18)
                .height(orderBarH(item.amount))
                .borderRadius({ topLeft: 5, topRight: 5 })
                .linearGradient({
                  angle: 90,
                  colors: [[COLORS.needleA, 0], [COLORS.gold, 1]]
                })
              Text(item.qty + '件')
                .fontSize(9)
                .fontColor(COLORS.text3)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Center)
            .layoutWeight(1)
          }, (item: CompassOrderItem) => item.buyer)
        }
        .width('100%')
        .height(120)
        .alignItems(VerticalAlign.Bottom)
        .margin({ top: 6 })
      }
      .width('100%')
      .padding(10)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(12)
      .margin({ bottom: 8 })
      ForEach(this.orders, (item: CompassOrderItem) => {
        Row() {
          Column() {
            Text(item.item)
              .fontSize(14)
              .fontColor(COLORS.text1)
              .fontWeight(FontWeight.Medium)
            Text(item.buyer + ' · ' + item.date)
              .fontSize(11)
              .fontColor(COLORS.text2)
          }
          .alignItems(HorizontalAlign.Start)
          .layoutWeight(1)
          Column() {
            Text('¥' + item.amount)
              .fontSize(13)
              .fontColor(COLORS.accent)
              .fontWeight(FontWeight.Medium)
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding(10)
        .margin({ bottom: 8 })
        .backgroundColor(COLORS.cardBg)
        .borderRadius(12)
      }, (item: CompassOrderItem) => item.buyer + '_o')
    }
  }

  @Builder
  tabContent() {
    if (this.curTab === 0) {
      Column() {
        Row() {
          Text('罗盘 · 堪舆定盘之器')
            .fontSize(15)
            .fontColor(COLORS.text1)
            .fontWeight(FontWeight.Bold)
            .margin({ top: 4, bottom: 8 })
          Column()
            .layoutWeight(1)
          Text('+新增')
            .fontSize(12)
            .fontColor(COLORS.accent)
            .margin({ top: 4, bottom: 8 })
            .onClick(() => {
              this.showAdd = true;
            })
        }
        .width('100%')
        this.compassList()
      }
    } else if (this.curTab === 1) {
      Column() {
        Text('天池 · 磁针定南北')
          .fontSize(15)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 4, bottom: 8 })
        this.poolList()
      }
    } else if (this.curTab === 2) {
      Column() {
        Row() {
          Text('方位 · 八宫指路')
            .fontSize(15)
            .fontColor(COLORS.text1)
            .fontWeight(FontWeight.Bold)
            .margin({ top: 4, bottom: 8 })
          Column()
            .layoutWeight(1)
          Text('编辑')
            .fontSize(12)
            .fontColor(COLORS.cool)
            .margin({ top: 4, bottom: 8 })
            .onClick(() => {
              this.selDir = this.dirs[0];
              this.showEdit = true;
            })
        }
        .width('100%')
        this.dirList()
      }
    } else if (this.curTab === 3) {
      Column() {
        Text('二十四山 · 堪舆分金')
          .fontSize(15)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 4, bottom: 8 })
        this.mountainList()
      }
    } else if (this.curTab === 4) {
      Column() {
        Text('堪舆师 · 相地明师')
          .fontSize(15)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 4, bottom: 8 })
        this.masterList()
      }
    } else {
      Column() {
        Text('订单 · 罗盘往来')
          .fontSize(15)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
          .margin({ top: 4, bottom: 8 })
        this.orderList()
      }
    }
  }

  @Builder
  addModal() {
    Stack() {
      this.modalOverlay(() => {
        this.showAdd = false;
      })
      Column() {
        Text('新增罗盘订单')
          .fontSize(16)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
        TextInput({ placeholder: '买家名称', text: this.newName })
          .fontSize(13)
          .margin({ top: 14 })
          .onChange((v: string) => {
            this.newName = v;
          })
        TextInput({ placeholder: '订购罗盘', text: this.newItem })
          .fontSize(13)
          .margin({ top: 10 })
          .onChange((v: string) => {
            this.newItem = v;
          })
        TextInput({ placeholder: '订单金额', text: this.newPrice })
          .fontSize(13)
          .margin({ top: 10 })
          .onChange((v: string) => {
            this.newPrice = v;
          })
        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.text2)
            .padding({ left: 20, right: 20, top: 9, bottom: 9 })
            .backgroundColor(COLORS.bg)
            .borderRadius(10)
            .onClick(() => {
              this.showAdd = false;
            })
          Column()
            .width(12)
          Text('确认新增')
            .fontSize(14)
            .fontColor(Color.White)
            .padding({ left: 20, right: 20, top: 9, bottom: 9 })
            .backgroundColor(COLORS.accent)
            .borderRadius(10)
            .onClick(() => {
              this.orders.push(new CompassOrderItem(this.newName, this.newItem,
                parseInt(this.newPrice) || 1000, 1, '2026-05-01'));
              this.newName = '';
              this.newItem = '';
              this.newPrice = '';
              this.showAdd = false;
            })
        }
        .margin({ top: 18 })
      }
      .width('86%')
      .padding(18)
      .constraintSize({ maxHeight: '80%' })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .position({ x: 0, y: 0 })
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

  @Builder
  editModal() {
    Stack() {
      this.modalOverlay(() => {
        this.showEdit = false;
      })
      Column() {
        Text('编辑方位 · ' + (this.selDir !== null ? this.selDir.name : ''))
          .fontSize(16)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
        Text('当前角度 ' + (this.selDir !== null ? this.selDir.angle : 0) + '°,五行 '
          + (this.selDir !== null ? this.selDir.element : ''))
          .fontSize(12)
          .fontColor(COLORS.text2)
          .margin({ top: 8 })
        Row() {
          Text('顺转15°')
            .fontSize(13)
            .fontColor(Color.White)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(COLORS.cool)
            .borderRadius(10)
            .onClick(() => {
              if (this.selDir !== null) {
                this.selDir.angle = (this.selDir.angle + 15) % 360;
              }
            })
          Column()
            .width(10)
          Text('逆转15°')
            .fontSize(13)
            .fontColor(Color.White)
            .padding({ left: 14, right: 14, top: 8, bottom: 8 })
            .backgroundColor(COLORS.hot)
            .borderRadius(10)
            .onClick(() => {
              if (this.selDir !== null) {
                this.selDir.angle = (this.selDir.angle + 345) % 360;
              }
            })
        }
        .margin({ top: 14 })
        Text('完成')
          .fontSize(14)
          .fontColor(COLORS.text1)
          .padding({ left: 22, right: 22, top: 9, bottom: 9 })
          .backgroundColor(COLORS.bg)
          .borderRadius(10)
          .margin({ top: 16 })
          .onClick(() => {
            this.showEdit = false;
          })
      }
      .width('86%')
      .padding(18)
      .constraintSize({ maxHeight: '80%' })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .position({ x: 0, y: 0 })
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

  @Builder
  delModal() {
    Stack() {
      this.modalOverlay(() => {
        this.showDel = false;
      })
      Column() {
        Text('删除堪舆师')
          .fontSize(16)
          .fontColor(COLORS.text1)
          .fontWeight(FontWeight.Bold)
        Text('确认移除 ' + (this.selMaster !== null ? this.selMaster.name : '') + ' 及其名录?')
          .fontSize(12)
          .fontColor(COLORS.text2)
          .margin({ top: 10 })
        Row() {
          Text('取消')
            .fontSize(14)
            .fontColor(COLORS.text2)
            .padding({ left: 20, right: 20, top: 9, bottom: 9 })
            .backgroundColor(COLORS.bg)
            .borderRadius(10)
            .onClick(() => {
              this.showDel = false;
            })
          Column()
            .width(12)
          Text('确认删除')
            .fontSize(14)
            .fontColor(Color.White)
            .padding({ left: 20, right: 20, top: 9, bottom: 9 })
            .backgroundColor(COLORS.danger)
            .borderRadius(10)
            .onClick(() => {
              this.showDel = false;
            })
        }
        .margin({ top: 18 })
      }
      .width('86%')
      .padding(18)
      .constraintSize({ maxHeight: '80%' })
      .backgroundColor(COLORS.cardBg)
      .borderRadius(16)
      .position({ x: 0, y: 0 })
    }
    .width('100%')
    .height('100%')
    .zIndex(999)
  }

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

11.6 架构设计的启示

在这里插入图片描述

综合以上分析,本应用的架构设计为 HarmonyOS ArkTS 开发提供了以下启示:第一,接口先行——先定义 ColorPalette 和 TabMeta 接口,再提供具体实现,确保了类型安全和可扩展性。第二,函数封装——将几何计算(dirX/dirY/ringSize 等)和映射逻辑(gradeColor/accBarW/orderBarH 等)封装为独立函数,提升了代码的复用性和可测试性。第三,统一状态——使用单一 breath 变量驱动所有动画,使用统一的 curTab 变量控制所有页面切换,降低了状态管理的复杂性。第四,分层设计——build 方法将界面分为内容层和弹窗层,各 @Builder 方法将UI逻辑模块化,使代码结构清晰、职责分明。第五,防御编程——空值检查(selDir !== null)、数值约束(Math.min)、默认值(parseInt || 1000)等防御性编程技巧贯穿始终,确保了应用的健壮性。

更多推荐