基于HarmonyOS ArkTS API 24微缩模型沙盘工坊主Tab索引、子Tab索引、特效帧计数器、弹框开关、编辑索引、表单字段在内的十余个响应式状态变量
在移动端声明式UI开发范式日益成熟的今天,如何在一个单页面应用中同时承载多层导航体系、实时动态特效、复杂状态管理以及多类型模态弹框,成为了衡量一个前端架构设计是否优雅的重要标尺。本文将带领读者深入剖析一个名为"缩语·微缩模型沙盘工坊"的完整页面级应用系统,该系统以微缩模型制作为主题域,围绕沙盘作品展示、在制工单管理、手作课堂进度跟踪、模友社交动态以及工具耗材管理五大业务线展开,在视觉层面通过灰尘微粒漂浮与聚光灯斑扫掠两种叠加特效营造出工坊特有的沉浸式氛围。
从技术架构的角度来看,该系统采用了基于声明式组件模型的分层渲染策略,将视觉特效层、主内容层和模态弹框层通过Stack容器进行Z轴堆叠,每一层各司其职、互不干扰。在状态管理方面,系统通过@State装饰器维护了包括主Tab索引、子Tab索引、特效帧计数器、弹框开关、编辑索引、表单字段在内的十余个响应式状态变量,配合@Observed装饰的可观察数据模型类,实现了数据驱动视图的完整闭环。在导航层面,系统创新性地采用了"底部四主Tab + 首页七内容Tab"的二级导航架构,首页内容Tab以胶囊式横向滚动呈现,既保证了功能的丰富度,又通过视觉层次的精心设计避免了信息过载。
特效系统是该系统最具技术亮点的部分。灰尘微粒漂浮效果通过12个独立计算的微粒元素,基于tick帧计数器和元素索引i的数学运算生成各自独立的位置、透明度和半径,模拟出灰尘在空气中随机漂浮的自然观感。聚光灯斑扫掠效果则通过5个柔光圆形元素,以不同的透明度阶梯和水平位移速度营造出灯光在工坊上方缓慢扫掠的光影氛围。两种特效均以90毫秒为间隔的setInterval定时器驱动,在生命周期钩子中精确控制启动与销毁,确保资源不泄漏。
本文将从色彩体系、数据模型、静态数据、纯函数、状态管理、生命周期、业务方法、特效层、各个页面构建器、模态弹框以及顶层渲染策略等维度,共计二十八个章节进行逐段拆解,力求让读者对声明式UI的工程实践有一个从微观到宏观的全面认知。
—
一、系统总体架构概览
整个系统由一个@Entry入口组件PageDioramaStudio承载,采用单组件多Builder的架构模式。组件内部通过build()方法构建一个Stack根容器,从底到顶依次放置背景色层、特效层、主内容层(含header、subNav/内容区、bottomBar)以及三个条件渲染的模态弹框层。这种分层堆叠策略确保了特效不遮挡用户交互,弹框始终位于最顶层。

从架构图可以清晰看到,系统采用了经典的"洋葱式"渲染层级。最外层的Stack容器是一个全屏定位的盒子,内部每一层都是绝对覆盖关系。背景色层只负责填充底色,特效层通过hitTestBehavior(HitTestMode.None)设置穿透点击,主内容层承载所有可交互的UI元素,而三个弹框层通过布尔状态变量控制是否渲染,实现了按需挂载的优化策略。
这种架构的巧妙之处在于:特效层虽然是全屏覆盖的,但由于设置了HitTestMode.None,所有触摸事件会自动穿透到下方的主内容层,用户完全感知不到特效层的存在,却能享受到它带来的视觉氛围提升。这是声明式UI中"视觉与交互解耦"的典型实践。
二、色彩体系设计(ColorPalette接口与COLORS常量)

interface ColorPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
success: string;
warning: string;
danger: string;
white: string;
chalk: string;
}
色彩体系是任何UI系统的基石。这里通过ColorPalette接口定义了一套包含16个语义化色彩字段的调色板规范,涵盖了主色三阶(primary/primaryLight/primaryDark)、强调色二阶(accent/accentLight)、背景色(bg/cardBg)、文字三阶(textPrimary/textSecondary/textHint)、功能色(success/warning/danger)以及辅助色(white/chalk)。接口先行、常量后置的设计模式,使得色彩定义具有类型安全保证,任何字段缺失都会在编译期被捕获。
主色采用#B07A4A这种偏暖的陶土棕色,精准呼应了微缩模型制作中黏土、木材、旧化涂装等材质的视觉印象。强调色#5E8A96是一种低饱和度的蓝灰色,用于工单状态、进度条等需要与主色形成对比但不喧宾夺主的场景。背景色#F4F1EA是一种带有轻微暖调的米白色,模拟工坊工作台的灯光环境,而chalk色#CFC5B2则专门用于灰尘微粒特效,模拟粉笔灰在光柱中飘浮的质感。
这套色彩体系的核心设计哲学是"温度感优先"。与常见的冷色调科技风不同,整个系统刻意选择了暖色系作为基调,从背景到文字、从卡片到按钮,所有色彩都统一在暖色谱内,营造出一种手作工坊特有的温暖、专注、略带怀旧的空间氛围。功能色虽然保持了各自的语义属性(绿色成功、黄色警告、红色危险),但都经过了低饱和度处理,确保不会破坏整体的色彩和谐。
三、模友动态数据模型(PostItem)

@Observed
export class PostItem {
id: number = 0
nick: string = ''
avatar: string = ''
text: string = ''
time: string = ''
likes: number = 0
constructor(id: number, nick: string, avatar: string, text: string, time: string, likes: number) {
this.id = id; this.nick = nick; this.avatar = avatar; this.text = text
this.time = time; this.likes = likes
}
}
PostItem是模友圈社交动态的核心数据模型,使用@Observed装饰器标记为可观察对象。这意味着当PostItem实例的属性发生变化时,与之绑定的UI组件会自动刷新,无需手动调用更新方法。该模型包含六个字段:id用于唯一标识和列表渲染的key生成,nick存储用户昵称,avatar使用Emoji字符作为头像(如🪖、🏙️),text存储动态内容文本,time存储相对时间描述,likes存储点赞数。
构造函数采用了紧凑的赋值方式,将六个参数在一行内依次赋给对应字段。这种写法虽然在可读性上略逊于逐行赋值,但在数据模型数量较多时能显著减少代码体积。值得注意的是,所有字段都声明了默认值(空字符串和零值),这确保了即使在反序列化过程中某些字段缺失,也不会出现undefined导致的运行时错误。
在业务场景中,PostItem实例主要出现在精选页面的"模友动态"区块(展示前3条)和模友圈页面(展示全部)。当用户通过"发缩语"弹框提交新动态时,系统会创建一个id为999的新PostItem实例,通过unshift方法插入到列表头部,实现新动态置顶展示的效果。@Observed装饰器确保了这一插入操作能够被UI层立即感知并渲染。
四、沙盘作品与在制工单数据模型(DioramaItem与PartItem)

@Observed
export class DioramaItem {
id: number = 0
name: string = ''
scale: string = ''
theme: string = ''
status: string = ''
tag: string = ''
constructor(id: number, name: string, scale: string, theme: string, status: string, tag: string) {
this.id = id; this.name = name; this.scale = scale; this.theme = theme
this.status = status; this.tag = tag
}
}
@Observed
export class PartItem {
id: number = 0
name: string = ''
step: string = ''
level: string = ''
progress: number = 0
constructor(id: number, name: string, step: string, level: string, progress: number) {
this.id = id; this.name = name; this.step = step; this.level = level
this.progress = progress
}
}
DioramaItem是沙盘作品的数据模型,记录了每个微缩沙盘作品的核心元数据。其中scale字段存储比例信息(如1:35、1:87),这是微缩模型领域的核心参数,直接决定了作品的尺寸规格。status字段使用三值枚举(已完工/在制/规划中)标识作品生命周期阶段。tag字段存储作品分类标签(如叙事、旧化、结构、灯光等),用于在视觉上通过颜色编码快速区分作品类型。
PartItem则是在制工单的数据模型,记录了每个作品当前正在制作的零件/工序信息。step字段描述当前工序状态(如"砖缝雕刻"“布线测试”),level字段标识难度等级(基础/进阶/高级),progress字段以0-100的数值表示完成百分比。这两个模型虽然字段不多,但通过字段间的语义关联(如工单的name通常以"作品名 · 零件名"格式呈现),构建出了一个层次分明的作品-工序管理体系。
这两个模型都使用了@Observed装饰器,使得作品列表和工单列表的增删改操作能够实时反映到UI上。特别是在编辑工单弹框中,用户修改editName、editStyle、editNote后点击保存,系统会通过splice方法用新构造的PartItem替换原数组中对应位置的元素,@Observed机制确保这一替换操作被UI层捕获并重渲染对应卡片。
五、工具数据模型与图表接口(GearItem、BuildChartItem、ScaleChartItem)

@Observed
export class GearItem {
id: number = 0
name: string = ''
desc: string = ''
icon: string = ''
constructor(id: number, name: string, desc: string, icon: string) {
this.id = id; this.name = name; this.desc = desc; this.icon = icon
}
}
interface BuildChartItem {
label: string;
value: number;
}
interface ScaleChartItem {
label: string;
value: number;
color: string;
}
GearItem是工具/耗材的数据模型,使用Emoji作为图标(如🔪笔刀、🥢镊子、🧴胶水),desc字段简述工具用途。这里同样使用@Observed装饰器,使得工具列表的补货状态变化能够实时反映到UI。
图表接口方面,BuildChartItem定义了月度完工量柱状图的数据结构,仅包含label和value两个字段,其颜色通过纯函数scaleColor动态计算。而ScaleChartItem则在BuildChartItem的基础上增加了color字段,因为比例占比图需要为每个比例条目指定独立的颜色,这些颜色在静态数据中预先定义。两种图表数据结构的差异体现了"按需设计"的原则:柱状图的颜色基于数值映射生成,条形图的颜色则基于业务语义静态绑定。
这种接口与类的混用策略值得注意:需要被观察的数据模型使用@Observed class,而纯展示用的图表数据使用interface。这是因为图表数据在运行时不需要被修改,使用接口可以获得更轻量的类型约束,避免了类实例化的开销。这种区分体现了对声明式框架性能特性的深入理解。
六、课程与主题接口(CourseItem与ThemeItem)

interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface ThemeItem {
name: string;
desc: string;
icon: string;
hot: boolean;
}
CourseItem定义了手作课堂的课程数据结构,title是课程名称,week标识授课周次(如"第2-3周"),progress记录学习进度百分比,color为该课程进度条的专属颜色。每门课程拥有独立颜色这一设计,使得用户在浏览课程列表时能够通过色彩快速区分不同课程,降低了视觉搜索的认知负担。
ThemeItem定义了场景主题的数据结构,hot布尔字段标识该主题是否为热门。在精选页面的四格展示中,热门主题会显示醒目的"HOT"标签(使用danger红色),非热门主题则显示空白。desc字段采用"关键词 · 关键词 · 关键词"的格式,用分隔符将主题的核心要素浓缩在一行内,配合maxLines(1)和TextOverflow.Ellipsis确保超长文本优雅截断。
两个接口都使用interface而非class,因为它们都是纯静态展示数据,不需要@Observed的可观察能力。这种"能用接口就不用类"的约束,是声明式UI数据建模的最佳实践之一,它减少了运行时对象创建的开销,同时保持了类型安全。
七、顶层静态数据之模友动态与沙盘作品列表
const POST_LIST: PostItem[] = [
new PostItem(1, '战地考古组', '🪖', '1:35 的废弃哨所做完了,锈蚀效果用了三层干扫,质感很满意。', '5分钟前', 91),
new PostItem(2, '街角叙事者', '🏙️', '小面馆沙盘加了暖光 LED,晚上看像真的有人在里面吃面。', '18分钟前', 84),
// ... 共8条
];
const DIORAMA_LIST: DioramaItem[] = [
new DioramaItem(1, '雨后巷口', '1:35', '市井生活', '已完工', '叙事'),
new DioramaItem(2, '废弃哨所', '1:35', '军事废墟', '已完工', '旧化'),
// ... 共8条
];
静态数据数组在模块顶层以const声明,确保了数据的不可变性和全局可访问性。POST_LIST包含8条模友动态,每条都经过精心编排:昵称暗示了用户的专长方向(如"战地考古组"擅长军事题材、"植被大师"擅长植物制作、"水景新手"专注水面效果),动态内容包含了具体的比例、技法、材料和感受,使得模拟数据具有极强的真实感和代入感。
DIORAMA_LIST包含8件沙盘作品,覆盖了1:12到1:700共6种比例,横跨市井生活、军事废墟、铁路场景、夜市小店、军事场景、室内微缩、田园风光、舰船海景8种主题。作品状态分布为3件已完工、3件在制、2件规划中,这一比例设计使得UI展示时各状态颜色标签都能充分呈现,不会出现某类状态空缺的情况。
静态数据的设计体现了"数据即叙事"的理念。每一条数据都不是随意编造的,而是围绕微缩模型制作这一主题域,构建出了一个有角色、有故事、有专业深度的虚拟社区生态。这种精心设计的模拟数据,使得开发阶段的UI预览效果接近真实生产环境,大大降低了后期对接真实数据时的视觉调优成本。
八、在制工单与工具列表静态数据
const PART_LIST: PartItem[] = [
new PartItem(1, '雨后巷口 · 地台', '铺色完成', '基础', 100),
new PartItem(2, '雨后巷口 · 砖墙', '砖缝雕刻', '进阶', 82),
new PartItem(3, '废弃哨所 · 铁丝网', '扭制中', '高级', 64),
// ... 共8条
];
const GEAR_LIST: GearItem[] = [
new GearItem(1, '笔刀', '切削修件', '🔪'),
new GearItem(2, '尖头镊子', '贴片取放', '🥢'),
// ... 共8条
];
PART_LIST的8条工单数据与DIORAMA_LIST存在业务关联:工单名称采用"作品名 · 零件名"格式,如"雨后巷口 · 地台"“废弃哨所 · 铁丝网”,这建立了一种隐式的作品-工单映射关系。难度等级(基础/进阶/高级)分布均匀,进度值从100%到18%跨度较大,使得UI上的进度条呈现出丰富的长度差异和颜色变化(通过stepColor函数映射)。
GEAR_LIST的8条工具数据覆盖了微缩模型制作的核心工具链:切削(笔刀)、取放(镊子)、粘接(胶水、补土)、涂装(喷笔、干扫笔)、特殊效果(树脂浇注、LED灯组)。每条数据由名称、用途描述和Emoji图标三部分组成,结构简洁但信息完整。在零件库页面和工具坊页面中,这份数据分别以双列卡片和列表两种形式展示,体现了同一数据源在不同视图中的复用能力。
两份数据的设计都遵循了"刚好够用"的原则:字段数量精简到展示所需的最小集合,不包含冗余的创建时间、更新时间等审计字段,因为这是前端展示层的模拟数据,不需要承担持久化存储的职责。
九、图表数据与导航配置
const BUILD_CHART: BuildChartItem[] = [
{ label: '1月', value: 1 },
{ label: '2月', value: 2 },
// ... 共7个月
];
const SCALE_CHART: ScaleChartItem[] = [
{ label: '1:35', value: 82, color: '#B07A4A' },
{ label: '1:48', value: 64, color: '#D2A97F' },
// ... 共5个比例
];
const NAV_LIST: NavItem[] = [
{ icon: '🏘️', label: '首页' },
{ icon: '🧩', label: '作品' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
const SUB_NAV_LIST: string[] = ['精选', '沙盘作品', '在制工单', '零件库', '手作课堂', '模友圈', '工具坊'];
图表数据方面,BUILD_CHART记录了1-7月的月度完工量,数值范围1-4,配合barH函数(最大值108像素/最大值4)可以计算出每根柱子的高度。SCALE_CHART记录了5种比例的收藏占比,每种比例携带独立颜色,从82%到12%形成清晰的视觉梯度。这两份数据共同支撑了精选页面的数据可视化区块。
导航配置是整个系统的路由骨架。NAV_LIST定义了底部4个主Tab,每个Tab由Emoji图标和文字标签组成。SUB_NAV_LIST定义了首页的7个内容Tab标签,以纯字符串数组形式存储,其选中状态通过subTab索引值控制。这种"数据驱动导航"的设计使得导航项的增删只需修改数据数组,无需改动UI逻辑代码,符合开闭原则。
值得注意的是,SUB_NAV_LIST的7个Tab与pageFeatured、pageWorks、pageOrders、pageParts、pageCourse、pageCircle、pageTools七个Builder方法一一对应,通过if-else if-else链式条件判断在mainContent中路由分发。这种设计虽然简洁,但在Tab数量进一步增长时可以考虑策略模式优化。
十、纯函数之柱状图高度与比例颜色映射
function barH(v: number, max: number): number {
return Math.round(108 * v / max);
}
function scaleColor(v: number): string {
if (v > 60) {
return '#B07A4A';
} else if (v > 30) {
return '#5E8A96';
}
return '#D9A441';
}
barH函数是一个极简的柱状图高度计算器,将数值v映射到0-108像素的视觉空间。使用Math.round确保返回整数像素值,避免了亚像素渲染导致的边缘模糊。max参数作为分母,使得该函数可以适配不同量级的数据——在当前场景中max固定为4(月度最高完工量),但函数设计预留了通用性。
scaleColor函数实现了数值到颜色的阶梯映射:大于60返回主色(陶土棕),30-60之间返回强调色(蓝灰),30以下返回警告色(琥珀黄)。这种三阶色彩映射在数据可视化中非常常见,它将数值的"高低"信息编码为"暖冷"色彩感知,用户无需精确读数就能通过色彩直觉判断数据量级。
两个函数都是纯函数——相同的输入永远产生相同的输出,不依赖任何外部状态,也不产生副作用。在声明式UI中,纯函数的使用至关重要,因为框架可能会在状态变化时多次调用这些函数来重新计算渲染值,纯函数保证了计算结果的一致性和可预测性。将这类计算逻辑抽取为独立函数而非内联在Builder中,也使得代码更易于测试和复用。
十一、纯函数之灰尘微粒运动算法
function dustX(tick: number, i: number): number {
return 24 + ((tick * 5 + i * 93) % 590);
}
function dustY(tick: number, i: number): number {
return 60 + i * 124;
}
function dustA(tick: number, i: number): number {
return (i + tick) % 3 === 0 ? 0.45 : 0.16;
}
function dustR(tick: number, i: number): number {
return 2 + (i % 3);
}
灰尘微粒系统由四个纯函数驱动,分别控制每个微粒的X坐标、Y坐标、透明度和半径。dustX通过(tick * 5 + i * 93) % 590的取模运算生成水平位移,其中tick * 5使得微粒随帧数递增持续向右移动,i * 93为每个微粒赋予不同的初始偏移,取模590确保位移在屏幕宽度内循环。24作为基础偏移量,避免微粒出现在屏幕最左边缘。
dustY采用线性分布60 + i * 124,12个微粒从Y=60开始以124像素的间距垂直排列,覆盖了约1468像素的垂直范围。这种固定间距的垂直分布确保了微粒在整个屏幕高度上均匀散布。dustA通过(i + tick) % 3 === 0的三值判断,让约三分之一的微粒保持较高透明度(0.45),其余保持较低透明度(0.16),模拟出灰尘在光柱中忽明忽暗的视觉效果。dustR通过i % 3生成2-4像素的三种半径,增加了微粒大小的层次感。
这套算法的核心设计理念是"伪随机"。它不使用Math.random(),而是通过帧计数器和元素索引的数学运算生成确定性的位置序列。这意味着相同的tick值永远产生相同的画面,避免了使用随机数导致的画面跳变问题。同时,取模运算确保了运动的循环性,微粒移动到屏幕边缘后会"瞬移"回起点,但由于透明度较低且微粒较小,这种瞬移在视觉上几乎不可察觉。
十二、纯函数之聚光灯斑与状态颜色映射
function spotX(tick: number, i: number): number {
return 40 + ((tick * 7 + i * 137) % 520);
}
function spotA(tick: number, i: number): number {
return 0.08 + ((tick + i) % 4) * 0.05;
}
function spotY(tick: number, i: number): number {
return 140 + i * 165;
}
function statusColor(s: string): string {
if (s === '已完工') {
return '#7FA86B';
} else if (s === '在制') {
return '#B07A4A';
}
return '#A79C8C';
}
聚光灯斑系统同样由三个函数驱动,与灰尘系统的设计思路一脉相承。spotX使用(tick * 7 + i * 137) % 520生成水平位移,tick * 7的增长速度比灰尘的tick * 5更快,营造出灯光扫掠比灰尘漂浮更敏捷的运动感。i * 137是一个较大的质数乘数,确保5个灯斑的初始水平位置充分分散。spotY以165像素的间距垂直排列灯斑,起始位置140确保灯斑不出现在顶部信息区域。
spotA的透明度计算0.08 + ((tick + i) % 4) * 0.05生成了0.08-0.23的四阶梯透明度,且随着tick的变化,每个灯斑的透明度会循环切换,模拟出灯光亮度自然波动的效果。整个灯斑系统使用primaryLight色(#D2A97F,浅陶土棕)作为背景色,配合低透明度,在屏幕上呈现出柔和的暖色光晕,完美契合工坊灯光的氛围。
statusColor函数是业务层面的颜色映射器,将作品状态枚举值转换为对应的色值。已完工对应success绿色(#7FA86B),在制对应primary棕色(#B07A4A),规划中对应textHint灰色(#A79C8C)。这种"状态-颜色"的映射贯穿了作品页面和工单页面的所有视觉元素,包括状态标签、进度条和左侧色条,形成了统一的视觉语言体系。
十三、纯函数之进度、标签与步骤颜色映射
function workProgress(s: string): number {
if (s === '已完工') {
return 100;
} else if (s === '在制') {
return 58;
}
return 12;
}
function scaleTagColor(g: string): string {
if (g === '叙事' || g === '季节') {
return '#5E8A96';
} else if (g === '旧化' || g === '结构') {
return '#B07A4A';
}
return '#D9A441';
}
function stepColor(s: string): string {
if (s === '基础') {
return '#7FA86B';
} else if (s === '进阶') {
return '#5E8A96';
}
return '#B07A4A';
}
workProgress函数将作品状态映射为进度百分比:已完工100%、在制58%、规划中12%。这些不是随机数值,而是经过精心设计的"视觉合理值"——在制58%暗示作品已过半但尚未完成,规划中12%暗示刚刚起步。这些固定值在UI上呈现出合理的进度条长度差异,避免了所有进度条看起来一样的单调感。
scaleTagColor函数将作品标签映射为颜色,采用了"同类合并"的策略:叙事和季节归为蓝灰色系(accent),旧化和结构归为棕色系(primary),其余归为琥珀色系(warning)。这种分组映射使得相关类型的标签在视觉上形成聚类,用户一眼就能识别出作品的技法分类。
stepColor函数为工单难度等级分配颜色:基础对应绿色(入门友好)、进阶对应蓝灰色(中等挑战)、高级对应棕色(高难度)。这种"难度-色彩温度"的映射利用了人类对暖色的天然警觉感,难度越高颜色越暖,形成了一种直觉化的视觉编码系统。三个函数共同构成了系统的"颜色语义层",将业务概念转化为视觉语言。
十四、组件状态定义体系
@Entry
@Component
struct PageDioramaStudio {
@State mainTab: number = 0
@State subTab: number = 0
@State tick: number = 0
@State glow: number = 0
@State addOpen: boolean = false
@State editOpen: boolean = false
@State delOpen: boolean = false
@State editIdx: number = 0
@State delTarget: string = 'works'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State partList: PartItem[] = PART_LIST
@State fxTimer: number = -1
}
组件状态是整个系统的数据中枢,16个@State变量可以按功能分为五组。第一组是导航状态:mainTab控制底部4主Tab的选中索引,subTab控制首页7内容Tab的选中索引。第二组是特效状态:tick是帧计数器,驱动灰尘和灯斑的运动计算;glow是双值闪烁计数器,虽然声明了但在当前代码中未被直接使用于渲染,预留了扩展空间。第三组是弹框开关:addOpen、editOpen、delOpen三个布尔值分别控制新增、编辑、删除三个模态弹框的显示与隐藏。
第四组是表单状态:editIdx记录正在编辑的工单索引,delTarget区分删除目标是作品(‘works’)还是课程(‘course’),editName/editStyle/editNote三个字段存储编辑弹框的表单值,addTitle/addContent存储新增弹框的表单值。第五组是数据状态:postList和partList分别存储模友动态列表和工单列表,它们被声明为@State是因为需要支持运行时的增删操作。第六组是资源句柄:fxTimer存储定时器ID,用于生命周期中的精确销毁。
@State装饰器的核心机制是:当被装饰的变量值发生变化时,框架会自动触发依赖该变量的Builder方法重新执行,实现UI的响应式更新。例如,当subTab从0变为1时,mainContent中的条件分支会重新执行,渲染pageWorks替代pageFeatured。这种数据驱动的更新机制是声明式UI与命令式UI最本质的区别。
十五、生命周期管理(aboutToAppear与aboutToDisappear)
aboutToAppear(): void {
this.fxTimer = setInterval(() => {
this.tick = this.tick + 1;
this.glow = (this.glow + 1) % 2;
}, 90);
}
aboutToDisappear(): void {
if (this.fxTimer > 0) {
clearInterval(this.fxTimer);
this.fxTimer = -1;
}
}
aboutToAppear是组件生命周期钩子,在组件实例创建后、首次渲染前调用。这里启动了一个90毫秒间隔的setInterval定时器,将返回的定时器ID存储到fxTimer状态变量中。定时器回调函数执行两个操作:递增tick计数器(驱动灰尘和灯斑的位置重计算)和切换glow值(在0和1之间循环)。90毫秒的间隔大约对应11帧/秒的刷新率,虽然低于常见的60FPS标准,但由于特效元素少且运动幅度小,这一刷新率在视觉上已经足够流畅,同时显著降低了CPU占用。
aboutToDisappear是组件销毁前的清理钩子,这里通过clearInterval精确销毁定时器。关键的防御性编程在于if (this.fxTimer > 0)判断:只有当定时器ID有效时才执行清除操作,避免了重复清除导致的运行时警告。清除后将fxTimer重置为-1(初始值),确保即使组件被复用(在某些框架的组件缓存机制下),也不会出现定时器ID残留的问题。
这对生命周期钩子体现了"谁创建谁销毁"的资源管理原则。定时器作为一种系统级资源,如果不及时释放,会导致组件销毁后定时器仍在后台运行,造成内存泄漏和CPU浪费。在声明式UI框架中,生命周期钩子是管理这类副作用的唯一正确位置,将副作用局限在钩子内部而非散落在业务方法中,是保持组件纯净性的关键实践。
十六、弹框状态管理与业务方法
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.partList.length) {
this.editIdx = index;
this.editName = this.partList[index].name;
this.editStyle = this.partList[index].level;
this.editNote = this.partList[index].step;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'works';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
四个open*方法构成了弹框的打开逻辑。openAdd在打开新增弹框前清空表单字段,确保每次打开都是干净的初始状态。openEdit接受一个index参数,先通过边界检查(index >= 0 && index < this.partList.length)确保索引有效,然后将目标工单的数据回填到表单字段中,实现了"编辑即回填"的交互模式。如果索引无效,弹框仍然会打开但表单保持上次值——这是一个轻微的设计妥协,在实际产品中可能需要更严格的错误处理。
openDelA和openDelB分别设置删除目标为作品(‘works’)和课程(‘course’),然后打开删除确认弹框。这种"先标记目标再执行操作"的设计模式,使得同一个删除弹框可以服务于不同的删除场景,只需通过delTarget字段区分行为分支。相比为每个删除场景创建独立的弹框,这种复用策略显著减少了代码重复。
边界检查在openEdit中的使用体现了防御性编程的思想。在声明式UI中,用户可能在快速操作时触发多次点击或列表项已被删除后仍尝试编辑,索引校验确保了不会访问越界元素导致的运行时崩溃。虽然partList在当前实现中不会被外部修改,但预留这一保护层为未来的异步数据加载场景提供了安全保障。
十七、业务执行方法(doAdd、doEdit、doDel)
doAdd(): void {
if (this.addTitle.length > 0) {
this.postList.unshift(new PostItem(999, '我的缩语', '🏘️', this.addTitle, '刚刚', 0));
}
this.addOpen = false;
}
doEdit(): void {
if (this.editIdx >= 0 && this.editIdx < this.partList.length) {
this.partList.splice(this.editIdx, 1, new PartItem(this.partList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'works' && this.partList.length > 0) {
this.partList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
doAdd方法处理新增缩语的提交逻辑。通过addTitle.length > 0的非空校验确保不会插入空动态,然后使用unshift将新PostItem插入到postList头部,实现新动态置顶显示。新创建的PostItem使用固定ID 999、固定昵称"我的缩语"、固定头像🏘️和固定时间"刚刚",点赞数初始化为0。提交后立即关闭弹框。
doEdit方法通过splice(editIdx, 1, newItem)的"替换式删除"语法,用新构造的PartItem替换原数组中指定位置的元素。新工单的id保留了原工单的ID,progress被重置为固定值40。这里有一个设计细节:用户编辑的editNote映射到PartItem的step字段,editStyle映射到level字段,这种"表单字段名与模型字段名不同"的设计需要开发者在映射时保持清晰的对应关系。
doDel方法根据delTarget的值执行不同的删除逻辑:目标为’works’时删除partList的首元素(拆除沙盘),目标为’course’时删除postList的首元素(退课)。两种删除操作都添加了length > 0的空列表保护。三个do方法的共同模式是:执行数据操作后立即关闭对应弹框,确保UI状态与数据状态的一致性。
十八、特效层构建(fxLayer)
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3, 4], (i: number) => {
Column()
.width(64)
.height(64)
.borderRadius(32)
.backgroundColor(COLORS.primaryLight)
.opacity(spotA(this.tick, i))
.translate({ x: spotX(this.tick, i), y: spotY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (i: number) => {
Column()
.width(dustR(this.tick, i) * 2)
.height(dustR(this.tick, i) * 2)
.borderRadius(dustR(this.tick, i))
.backgroundColor(COLORS.chalk)
.opacity(dustA(this.tick, i))
.translate({ x: dustX(this.tick, i), y: dustY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
特效层是整个系统视觉氛围的核心载体,由两个ForEach循环构建:上层渲染5个聚光灯斑,下层渲染12个灰尘微粒。灯斑使用64x64像素的圆形Column,背景色为primaryLight(浅陶土棕),通过borderRadius(32)实现完美圆形。透明度和位置分别由spotA、spotX、spotY三个纯函数基于当前tick值计算得出,通过translate属性进行位移变换。
灰尘微粒使用动态尺寸的圆形Column,宽高均为dustR(this.tick, i) * 2(4-8像素),背景色为chalk(粉笔灰),透明度由dustA函数计算(0.16或0.45),位置由dustX和dustY函数计算。所有微粒和灯斑都被放置在同一个Stack容器中,alignContent设置为TopStart作为基准定位点。
整个特效层的灵魂在于最后一行:.hitTestBehavior(HitTestMode.None)。这个属性设置使得整个Stack及其所有子元素不再参与触摸事件测试,所有触摸事件会穿透到下层的实际内容层。这是实现"纯视觉特效层"的关键技术手段——它覆盖在整个页面之上,但完全不干扰用户的任何交互操作。配合90毫秒的定时器驱动tick递增,所有元素的位置和透明度会持续重新计算并渲染,形成持续运动的视觉效果。
十九、头部区域构建(header)
@Builder
header() {
Column({ space: 8 }) {
Row() {
Text('🏘️')
.fontSize(24)
Column() {
Text('缩语沙盘工坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('地台 · 建筑 · 旧化 · 叙事')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 8 })
.layoutWeight(1)
Text('开工')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
}
.width('100%')
// ... 统计数据四宫格
}
.width('100%')
.padding({ top: 10, bottom: 8 })
}
头部区域由两部分组成:上方的品牌标识行和下方的统计四宫格。品牌标识行使用Row水平布局,左侧是工坊图标Emoji,中间是工坊名称和副标题(用竖排Column承载),右侧是"开工"行动按钮。副标题"地台 · 建筑 · 旧化 · 叙事"用分隔符串联了微缩模型制作的四大工序阶段,以10号字体和hint色呈现,作为品牌名称的注脚。中间的Column设置了layoutWeight(1),使其占据按钮以外的所有水平空间,确保按钮始终靠右对齐。
统计四宫格由四个等宽的Column组成,分别展示完成作品数(3座/primary色)、在制工单数(5张/accent色)、零件存量(214件/warning色)和工坊积分(860分/danger色)。每个统计项采用"上标签下数值"的双行结构,标签用9号字体hint色,数值用13号加粗字体且各自使用不同的语义色。四个Column都使用layoutWeight(1)实现等分宽度,配合borderRadius(10)和cardBg背景形成独立的卡片视觉单元。
header的设计体现了"信息密度与视觉留白平衡"的原则。在有限的顶部空间内,通过字体大小的三级层次(24号图标、20号标题、10-13号辅助信息)和色彩的三级对比(primary/accent/warning/danger),在保持视觉整洁的同时传递了丰富的信息。四个统计数字使用不同的语义色,不仅增加了视觉层次,还暗示了各指标的不同性质——完成数是成就、工单数是进行中、零件数是资源、积分是激励。
二十、胶囊式子导航构建(subNav)
@Builder
subNav() {
Scroll() {
Row({ space: 8 }) {
ForEach(SUB_NAV_LIST, (item: string, idx: number) => {
Text(item)
.fontSize(13)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
.backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
.padding({ left: 14, right: 14, top: 7, bottom: 7 })
.borderRadius(16)
.onClick(() => {
this.subTab = idx;
})
}, (item: string) => item)
}
.width('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 4, bottom: 10 })
}
胶囊式子导航是首页内容切换的核心交互组件。外层使用Scroll容器包裹Row,通过scrollable(ScrollDirection.Horizontal)启用横向滚动,scrollBar(BarState.Off)隐藏滚动条,营造出无缝滚动的体验。7个Tab标签通过ForEach从SUB_NAV_LIST数组动态生成,每个标签都是一个Text组件,样式通过borderRadius(16)形成胶囊形状。
选中态的视觉切换是该组件的设计亮点。当subTab === idx时,标签文字变白色加粗、背景变为primary棕色;未选中时,文字为secondary灰色、背景为白色。这种"反转色"设计使得选中态在视觉上具有强烈的突出感,用户一眼就能识别当前所在的内容区。onClick回调仅执行一行代码——将subTab赋值为当前索引,框架的响应式机制会自动触发mainContent中条件分支的重新执行,渲染对应的内容页面。
胶囊式导航相比传统的Tab+下划线设计,优势在于每个Tab的选中区域有完整的视觉容器(背景色+圆角),触摸热区更大且视觉反馈更明确。横向滚动设计使得即使Tab数量增加(当前7个),也不会出现空间不足的问题,用户体验始终保持一致。padding值(水平14、垂直7)经过精心调优,在视觉饱满度和紧凑性之间取得了平衡。
二十一、精选页面构建(pageFeatured上篇:主题横幅与四格)
@Builder
pageFeatured() {
Column({ space: 12 }) {
Row({ space: 14 }) {
Text('🏮')
.fontSize(44)
Column({ space: 6 }) {
Text('本月主题 · 深夜食堂')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('1:24 室内微缩 · 暖光叙事 · 组队投稿')
.fontSize(12)
.fontColor('#F3E4D4')
Text('去开工')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primaryDark)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.primaryLight)
.borderRadius(10)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.primaryDark)
.borderRadius(16)
// ... 场景主题四格
}
}
精选页面的顶部是"本月主题"横幅,使用primaryDark深棕色作为背景,配合🏮灯笼图标和白色标题文字,形成强烈的视觉焦点。横幅内嵌一个"去开工"行动按钮,使用primaryLight浅棕色背景和primaryDark深棕色文字,在深色背景上形成对比但不喧宾夺主。副标题使用#F3E4D4(一种偏暖的浅米色),在深色背景上保持了良好的可读性同时不刺眼。
场景主题四格区块使用THEME_LIST数据驱动渲染,每个主题卡片包含图标(24号)、名称(11号加粗)、描述(9号,单行省略)和HOT标签(9号,热门时显示danger红色)。四个卡片通过layoutWeight(1)等分宽度,配合8像素间距形成整齐的四列网格。maxLines(1)和textOverflow({ overflow: TextOverflow.Ellipsis })确保描述文本超长时优雅截断而非溢出破坏布局。
横幅和四格区块共同构成了精选页面的"发现"区域,引导用户关注当月活动和热门主题。从色彩层次来看,横幅使用最深的primaryDark背景制造最强视觉冲击,四格卡片使用白色cardBg背景保持轻盈感,一深一浅的对比使得页面顶部层次分明。横幅内的按钮虽然体积小,但通过色彩反转(深底浅字变浅底深字)成功吸引了注意力,引导用户进行下一步操作。
二十二、精选页面构建(pageFeatured中篇:图表可视化)
// 月度完工柱状图
Column() {
Row() {
Text('月度完工量')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('单位 座')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row({ space: 10 }) {
ForEach(BUILD_CHART, (it: BuildChartItem) => {
Column({ space: 4 }) {
Column()
.width(20)
.height(barH(it.value, 4))
.borderRadius(5)
.backgroundColor(scaleColor(it.value))
Text(it.label)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (it: BuildChartItem) => it.label + it.value.toString())
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
.height(140)
.margin({ top: 12 })
// ... 累计统计
}
// 比例占比条形图
ForEach(SCALE_CHART, (it: ScaleChartItem) => {
Row({ space: 8 }) {
Text(it.label)
.fontSize(12)
.fontColor(COLORS.textSecondary)
.width(56)
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.value}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.layoutWeight(1)
Text(`${it.value}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width(44)
.textAlign(TextAlign.End)
}
.width('100%')
.margin({ top: 8 })
}, (it: ScaleChartItem) => it.label)
月度完工柱状图采用纯声明式组件构建:每根柱子是一个Column元素,宽度固定20像素,高度通过barH(it.value, 4)函数计算(0-108像素),颜色通过scaleColor函数根据数值映射。柱子下方是月份标签。整个图表容器设置alignItems(VerticalAlign.Bottom)确保所有柱子底部对齐,height(140)预留了柱子最大高度加上标签的空间。这是一种"无依赖"的图表实现方式——不引入任何第三方图表库,纯用布局组件堆叠实现。
比例占比条形图采用Stack容器实现进度条效果:底层是一个100%宽度的border色背景条,上层是一个宽度为it.value%的彩色前景条。每行包含比例标签(固定56像素宽)、进度条(layoutWeight填充剩余空间)和百分比数值(固定44像素宽,右对齐)。这种"标签-进度条-数值"的三段式布局是数据展示的经典范式,信息一目了然。
两种图表的可视化策略截然不同:柱状图通过高度差异传达"量"的对比,条形图通过长度差异传达"占比"的分布。柱状图的颜色是动态计算的(基于数值),条形图的颜色是静态绑定的(每个比例有专属色)。这种差异化设计避免了视觉单调,同时各自最适合其展示的数据类型——柱状图适合离散的月度数据,条形图适合连续的占比数据。整个图表区块被包裹在cardBg白色卡片中,与页面背景形成层次。
二十三、精选页面构建(pageFeatured下篇:作品架与模友动态)
// 作品双列展示
ForEach(DIORAMA_LIST, (it: DioramaItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
if (idx < DIORAMA_LIST.length) {
Column({ space: 6 }) {
Row() {
Text('🏘️')
.fontSize(22)
.layoutWeight(1)
Text(DIORAMA_LIST[idx].scale)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(scaleTagColor(DIORAMA_LIST[idx].tag))
// ...
}
Text(DIORAMA_LIST[idx].name)
Text(DIORAMA_LIST[idx].theme)
}
.layoutWeight(1)
// ...
}
if (idx + 1 < DIORAMA_LIST.length) {
// ... 第二列卡片
}
}
.width('100%')
}
}, (it: DioramaItem) => it.id.toString())
// 模友动态(精选展示前3条)
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(22)
Column({ space: 3 }) {
Row() {
Text(it.nick)
.layoutWeight(1)
Text(it.time)
}
Text(it.text)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`👍 ${it.likes}`)
}
.layoutWeight(1)
}
// ...
})
作品架采用双列网格布局,通过idx % 2 === 0判断将数据列表按每两项一行分组。每张卡片包含Emoji图标、比例标签(带颜色编码)、作品名称和主题描述。比例标签的颜色通过scaleTagColor函数映射,使用accentLight浅蓝灰色作为背景,形成柔和的色彩编码。layoutWeight(1)确保两列等宽,space(10)控制列间距。
模友动态区块精选展示前3条(通过postList.slice(0, 3)截取),每条动态包含头像Emoji、昵称、时间、内容文本和点赞数。内容文本设置maxLines(2)限制为两行,超出部分省略显示,确保每条动态卡片高度一致,列表整体视觉整齐。头部行的"发缩语"链接通过onClick触发openAdd方法,打开新增弹框。
双列网格的实现方式值得关注:它没有使用Grid容器,而是通过ForEach配合if (idx % 2 === 0)条件判断手动分组。这种"手动双列"的实现方式虽然代码略显冗长,但提供了更精细的布局控制——每行的列数、间距、对齐方式都可以独立调整。对于固定双列的简单场景,这种方式的灵活性优于Grid。精选页面通过动态、作品架、图表、主题四格等多种内容模块的组合,形成了一个信息丰富但层次分明的"发现"首页。
二十四、作品页面构建(pageWorks)
@Builder
pageWorks() {
Column({ space: 10 }) {
Row() {
Text('沙盘作品')
.layoutWeight(1)
Text('上架新作品')
.onClick(() => { this.openAdd(); })
}
ForEach(DIORAMA_LIST, (it: DioramaItem, idx: number) => {
Column({ space: 6 }) {
Row() {
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(statusColor(it.status))
Column({ space: 4 }) {
Row() {
Text(it.name)
.layoutWeight(1)
Text(it.status)
.fontColor(statusColor(it.status))
}
Row({ space: 8 }) {
Text(`📏 ${it.scale}`)
Text(`🖼️ ${it.theme}`)
}
}
.layoutWeight(1)
}
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.backgroundColor(COLORS.border)
Row()
.width(`${workProgress(it.status)}%`)
.height(6)
.backgroundColor(statusColor(it.status))
}
Row({ space: 10 }) {
Text(`完工度 ${workProgress(it.status)}%`)
.layoutWeight(1)
Text(it.tag)
.fontColor(scaleTagColor(it.tag))
}
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
})
Button()
.backgroundColor(COLORS.danger)
.onClick(() => { this.openDelA(); })
Text('拆除沙盘')
}
}
作品页面以列表形式展示所有沙盘作品,每张作品卡片由三个视觉层次构成。第一层是左侧的状态色条——一个4像素宽、38像素高的窄条,颜色由statusColor函数根据作品状态映射(绿色/棕色/灰色),这是整张卡片最先被视觉捕获的元素,用户通过色条颜色就能快速判断作品状态。第二层是内容区,包含作品名称、状态标签、比例和主题信息,采用"名称左+状态右"的对齐布局。第三层是进度条,通过Stack容器叠加背景条和前景条,宽度由workProgress函数计算。
页面底部是一个danger红色的"拆除沙盘"按钮,点击触发openDelA方法打开删除确认弹框。这里使用了一个巧妙的视觉技巧:Button组件本身不包含文字,而是在其上方通过Text组件以负margin(margin({ left: -70, top: -32 }))叠加显示按钮文字。这种做法虽然在语义上不如直接设置Button文字优雅,但在某些声明式框架中可以绕过Button组件对文字样式的限制,实现更精细的字体控制。
作品页面的设计核心是"状态可视化"。每张卡片通过色条、状态标签、进度条三重视觉编码传达作品的状态信息,且三者的颜色保持一致(均由statusColor函数驱动)。这种"多维度同色编码"策略确保了信息的一致性——即使用户只扫到色条,也能准确推断出进度条和状态标签的内容。底部固定的删除按钮提供了一种"批量管理"的入口,通过统一的删除确认弹框保护用户免受误操作。
二十五、在制工单页面构建(pageOrders)
@Builder
pageOrders() {
Column({ space: 10 }) {
Text('在制工单')
.fontSize(16)
.fontWeight(FontWeight.Bold)
ForEach(this.partList, (it: PartItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🧾')
.fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)
.layoutWeight(1)
Text(it.level)
.fontColor(stepColor(it.level))
}
Text(`🔧 ${it.step}`)
}
.layoutWeight(1)
}
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(6)
.backgroundColor(stepColor(it.level))
}
Row() {
Text(`工单进度 ${it.progress}%`)
.layoutWeight(1)
Text('编辑工单')
.onClick(() => { this.openEdit(idx); })
}
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
})
}
}
在制工单页面展示所有正在制作的零件/工序信息。每张工单卡片以🧾单据Emoji作为视觉图标,内容区包含工单名称、难度等级标签(颜色由stepColor映射)、当前工序描述(以🔧图标前缀)和进度条。进度条的颜色同样由stepColor函数驱动,与难度标签颜色保持一致,形成了"难度越高颜色越暖"的视觉编码体系。
每张卡片的底部有一个"编辑工单"操作链接,点击后调用openEdit(idx)方法,将当前工单索引传入并打开编辑弹框。这里的idx参数是ForEach的循环索引,作为编辑操作的定位凭证。openEdit方法会根据该索引从partList中读取对应工单的数据并回填到表单字段,用户修改后点击保存,doEdit方法通过splice替换原数组中对应位置的元素。
工单页面的数据源是this.partList而非静态的PART_LIST常量,这是一个关键区别。因为工单支持编辑和删除操作,必须使用@State声明的可变数组作为数据源,才能确保增删改操作能被UI层感知。而作品页面使用的是静态的DIORAMA_LIST常量,因为作品列表在当前版本中不支持修改(删除操作实际删除的是partList的首元素而非作品列表)。这种数据源的区分体现了"可变性按需"的设计原则。
二十六、零件库页面构建(pageParts)
@Builder
pageParts() {
Column({ space: 10 }) {
Text('零件库')
// 排行榜列表(前5条)
ForEach(this.partList.slice(0, 5), (it: PartItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`)
.fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
.width(20)
Column({ space: 3 }) {
Text(it.name)
Text(`${it.step} · ${it.level}`)
}
.layoutWeight(1)
Text(`${it.progress}%`)
.fontColor(stepColor(it.level))
}
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
})
// 常用零件速查(双列网格)
Text('常用零件速查')
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
if (idx % 2 === 0) {
Row({ space: 10 }) {
// ... 双列卡片,每个含图标、名称、描述和"去补货"链接
}
}
})
}
}
零件库页面分为两个区块:上方的零件进度排行榜和下方的常用零件速查。排行榜取partList前5条数据,每行以序号开头,前三名序号使用primary棕色突出显示,后两名使用hint灰色弱化,形成一种"Top 3"的荣誉感。每行包含零件名称、工序+难度的组合描述和进度百分比,结构紧凑高效。
常用零件速查区块使用GEAR_LIST数据,采用与精选页作品架相同的双列手动分组布局(idx % 2 === 0)。每张卡片包含工具图标(26号Emoji)、工具名称、用途描述和"去补货"行动链接。补货链接以primary色文字呈现,虽然当前未绑定点击事件,但在视觉上提供了明确的操作入口暗示。
零件库页面的设计体现了"管理+速查"的双职能定位。上半部分的排行榜帮助用户追踪各零件的制作进度,识别瓶颈工序;下半部分的速查网格帮助用户快速找到常用工具的补给渠道。两种列表样式(单行紧凑型vs双列卡片型)在同一页面内并存,通过视觉密度的差异自然区隔了两个功能区,无需额外的分隔线或标题装饰。数据源的复用也值得关注:partList同时服务于工单页面和零件库页面,但通过slice(0, 5)截取实现了不同的展示范围,避免了数据冗余。
二十七、手作课堂页面构建(pageCourse)
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('手作课堂')
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title)
.layoutWeight(1)
Text(it.week)
.fontColor(COLORS.textHint)
}
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(8)
.backgroundColor(it.color)
}
Row() {
Text('已学')
.layoutWeight(1)
Text(`${it.progress}%`)
.fontColor(COLORS.primary)
}
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
})
Button()
.backgroundColor(COLORS.danger)
.onClick(() => { this.openDelB(); })
Text('退课')
}
}
手作课堂页面展示5门课程的学习进度。每张课程卡片包含课程名称、授课周次、进度条和已学百分比。与工单页面不同的是,课程进度条的颜色来自CourseItem自身的color字段,而非通过函数计算——每门课程在静态数据中预定义了专属颜色,使得不同课程的进度条在视觉上各具特色。
课程卡片的布局采用了"标题行+进度条+数据行"的三段式结构。标题行左侧是课程名称(14号加粗),右侧是周次信息(11号hint色),形成"课程-时间"的二元信息对。进度条高度8像素,比工单页面的6像素进度条更宽,这是因为课程进度是用户更关注的核心指标,更宽的进度条提供了更好的视觉辨识度。数据行左侧是"已学"标签,右侧是百分比数值,使用primary色强调当前进度值。
页面底部的"退课"按钮与作品页面的"拆除沙盘"按钮采用了相同的实现模式:danger红色背景Button配合负margin叠加Text文字。点击触发openDelB方法,设置delTarget为’course’并打开删除确认弹框。在doDel方法中,当delTarget为’course’时会删除postList的首元素——这里的业务语义是将退课操作映射为移除模友圈的首条动态,这在实际业务逻辑中可能需要调整,但在当前演示版本中展示了删除弹框的多目标复用能力。
二十八、模友圈与工具坊页面构建(pageCircle与pageTools)
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('模友圈')
.layoutWeight(1)
Text('发缩语')
.onClick(() => { this.openAdd(); })
}
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)
.layoutWeight(1)
Text(it.time)
}
Text(it.text)
.fontSize(13)
Text(`👍 ${it.likes} · 💬 回复`)
}
.layoutWeight(1)
}
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
})
}
}
@Builder
pageTools() {
Column({ space: 10 }) {
Text('工具坊')
Row({ space: 8 }) {
// 切割区、涂装区、灯光区三列
}
ForEach(GEAR_LIST, (it: GearItem, idx: number) => {
Row({ space: 10 }) {
Text(it.icon)
Column({ space: 3 }) {
Text(it.name)
Text(it.desc)
}
.layoutWeight(1)
Text('补货')
.backgroundColor(COLORS.accentLight)
.borderRadius(8)
}
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
})
}
}
模友圈页面展示全部模友动态(不截取),每条动态的完整度比精选页面的精简版更高——头像Emoji使用26号字体(精选页为22号),动态内容不限制行数(精选页限制2行),底部增加了"💬 回复"互动入口。这种"完整版vs精简版"的差异展示了同一数据在不同上下文中的展示策略:精选页面作为"预览"只展示核心信息并限制数量,模友圈页面作为"全览"展示完整信息并支持互动。
工具坊页面分为两个区块:顶部的切割/涂装/灯光三区快捷入口(三列等宽卡片)和下方的工具列表。三区入口使用28号大号Emoji图标,每区列出该区域的核心工具(如切割区列出"笔刀 · 剪钳"),为用户提供按工作区域分类的工具导航。下方的工具列表使用GEAR_LIST数据,每行包含工具图标、名称、描述和"补货"按钮。补货按钮使用accentLight浅蓝灰色背景和primary色文字,形成柔和但明确的操作入口。
两个页面的数据源选择体现了不同的设计意图。模友圈使用this.postList(可变数据源),因为用户可以通过"发缩语"功能添加新动态,新增的动态需要立即出现在列表中。工具坊使用GEAR_LIST静态常量,因为工具列表在当前版本中不支持修改,使用静态数据可以避免不必要的响应式开销。补货按钮虽然当前未绑定实际事件,但其视觉存在为未来的电商功能预留了入口。
二十九、我的页面构建(pageMine)
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🦊')
.fontSize(40)
Column({ space: 4 }) {
Text('缩语造景师')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text('Lv.4 · 完工 17 座 · 积分 860')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
}
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 我的工单(前3条)
Column({ space: 10 }) {
Text('我的工单')
.layoutWeight(1)
Text(`${this.partList.length} 张`)
ForEach(this.partList.slice(0, 3), (it: PartItem) => {
Row() {
Text(it.name)
.layoutWeight(1)
Text(it.step)
.fontColor(stepColor(it.level))
}
})
}
// 我的缩语(前3条)
Column({ space: 10 }) {
Text('我的缩语')
.layoutWeight(1)
Text(`${this.postList.length} 条`)
ForEach(this.postList.slice(0, 3), (it: PostItem) => {
Row() {
Text(it.nick)
.layoutWeight(1)
Text(it.time)
}
})
}
}
}
“我的"页面作为用户个人中心,由用户信息卡片、工单摘要卡片和缩语摘要卡片三个区块组成。用户信息卡片使用🦊狐狸Emoji作为头像(40号大字体),右侧展示用户昵称"缩语造景师"和等级/完工数/积分的汇总信息。这三项数据与header区域的统计数据形成呼应,但表述方式从"完成作品3座"变成了"完工17座”,暗示了用户的历史总完工量(17座)与当前活跃作品数(3座)的区别。
工单摘要和缩语摘要卡片采用了相同的布局模式:标题行(标题+数量统计)+ 列表预览(前3条)。工单摘要展示零件名称和当前工序,缩语摘要展示昵称和时间。两个摘要卡片都使用slice(0, 3)截取前三条数据,作为完整列表(工单页/模友圈页)的入口预览。数量统计使用了this.partList.length和this.postList.length动态计算,确保增删操作后数量立即更新。
"我的"页面的设计遵循了"个人中心=信息概览+入口预览"的经典模式。用户在此页面可以快速查看自己的核心数据(等级、作品数、积分)和最近活动(工单进度、缩语动态),而无需进入具体的子页面。三个卡片通过相同的borderRadius(14)和cardBg背景保持视觉统一,space(12)的垂直间距确保了卡片间的呼吸感。头像使用Emoji而非图片资源,既减少了资源加载开销,又保持了与整个系统一致的视觉语言。
三十、模态弹框体系构建
@Builder
modalOverlay() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor('#000000')
.opacity(0.6)
.onClick(() => {
this.addOpen = false;
this.editOpen = false;
this.delOpen = false;
})
}
.width('100%')
.height('100%')
}
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布缩语')
TextInput({ placeholder: '一句话造景心得…', text: this.addTitle })
.onChange((v: string) => { this.addTitle = v; })
TextArea({ placeholder: '补充细节:比例、技法、材料、感受…', text: this.addContent })
.onChange((v: string) => { this.addContent = v; })
Row({ space: 10 }) {
Button()
.backgroundColor(COLORS.bg)
.onClick(() => { this.addOpen = false; })
Text('取消')
Button()
.backgroundColor(COLORS.primary)
.onClick(() => { this.doAdd(); })
Text('发布')
}
}
.padding(16)
.backgroundColor(COLORS.cardBg)
}
模态弹框体系由一个共享遮罩层和三个独立的弹框主体构成。modalOverlay是一个全屏黑色半透明遮罩(opacity 0.6),点击遮罩区域会将三个弹框开关同时设为false,实现"点击遮罩关闭任意弹框"的统一行为。这种设计简化了用户操作——无论当前打开的是哪个弹框,点击遮罩外区域都能关闭它。
新增弹框(addModalBody)包含标题"发布缩语"、一个TextInput单行输入框(造景心得标题)、一个TextArea多行文本域(补充细节)和取消/发布双按钮。输入框通过onChange回调实时同步输入值到addTitle和addContent状态变量,确保提交时数据已就绪。发布按钮点击调用doAdd方法执行提交逻辑,取消按钮直接关闭弹框。按钮文字同样使用了负margin叠加技巧。
编辑弹框(editModalBody)和删除弹框(delModalBody)遵循相同的架构模式,但表单内容不同:编辑弹框包含工单名称、难度等级和工序说明三个输入字段,删除弹框则是纯文本确认信息(根据delTarget动态显示"拆除沙盘"或"退出手作课堂"的标题和描述)。三个弹框的统一设计语言——相同的padding(16)、cardBg背景、space(12)间距、双按钮布局——构成了系统的"模态层视觉规范",确保了所有弹框在视觉上的一致性。
三十一、底部导航栏与主内容路由构建
@Builder
bottomBar() {
Row() {
ForEach(NAV_LIST, (it: NavItem, idx: number) => {
Column({ space: 2 }) {
Text(it.icon)
.fontSize(20)
.opacity(this.mainTab === idx ? 1 : 0.55)
Text(it.label)
.fontSize(10)
.fontWeight(this.mainTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.mainTab === idx ? COLORS.primary : COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
.onClick(() => {
this.mainTab = idx;
})
})
}
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
@Builder
mainContent() {
Column() {
this.header()
if (this.mainTab === 0) {
this.subNav()
Scroll() {
Column() {
if (this.subTab === 0) { this.pageFeatured() }
else if (this.subTab === 1) { this.pageWorks() }
else if (this.subTab === 2) { this.pageOrders() }
else if (this.subTab === 3) { this.pageParts() }
else if (this.subTab === 4) { this.pageCourse() }
else if (this.subTab === 5) { this.pageCircle() }
else { this.pageTools() }
}
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
} else if (this.mainTab === 1) {
Scroll() { Column() { this.pageWorks() } }
} else if (this.mainTab === 2) {
Scroll() { Column() { this.pageCourse() } }
} else {
Scroll() { Column() { this.pageMine() } }
}
}
}
底部导航栏通过ForEach从NAV_LIST数据数组渲染4个Tab项,每个项包含Emoji图标(20号)和文字标签(10号)。选中态通过图标透明度(1 vs 0.55)、文字粗细(Bold vs Normal)和文字颜色(primary vs hint)三重视觉差异来体现。整个导航栏高度56像素,背景色为cardBg白色,顶部两角设置16像素圆角,使其与上方内容区形成自然的视觉分隔。
mainContent是整个系统的路由中枢,通过mainTab和subTab两个状态变量的条件判断实现页面切换。当mainTab === 0(首页)时,先渲染subNav胶囊导航,再根据subTab的值(0-6)选择渲染7个内容页面之一;其他主Tab(作品/课堂/我的)直接渲染对应页面。每个内容页面都被包裹在Scroll垂直滚动容器中,scrollBar(BarState.Off)隐藏滚动条,layoutWeight(1)使其占据header和bottomBar之间的所有剩余空间。
路由系统的设计采用了"if-else if-else"链式条件判断而非策略模式或映射表,这在当前4+7的Tab规模下是合理的选择——代码直观、性能开销最小。但如果Tab数量进一步增长,这种方式的代码可维护性会下降。一个可能的优化方向是将Builder方法引用存储在数组中,通过索引直接调用,但这在声明式框架中需要确保Builder调用的上下文正确性。当前实现的优点是每次条件判断都会精确渲染一个页面,不会出现多余组件的实例化。
三十二、顶层构建方法(build)
build() {
Stack() {
Column()
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
this.fxLayer()
Column() {
this.mainContent()
this.bottomBar()
}
.width('100%')
.height('100%')
if (this.addOpen) {
Stack() {
this.modalOverlay()
Column() {
this.addModalBody()
}
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
.width('100%')
.height('100%')
}
if (this.editOpen) {
Stack() {
this.modalOverlay()
Column() { this.editModalBody() }
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
}
if (this.delOpen) {
Stack() {
this.modalOverlay()
Column() { this.delModalBody() }
.width('88%')
.constraintSize({ maxHeight: '80%' })
.borderRadius(16)
.zIndex(999)
}
}
}
.width('100%')
.height('100%')
}
build方法是整个组件的渲染入口,它构建了一个Stack根容器,从底到顶依次堆叠了五层内容。第一层是纯色背景层(Column + bg色),填充整个屏幕作为底色。第二层是特效层(fxLayer),覆盖在背景之上但位于内容之下,由于设置了HitTestMode.None不影响交互。第三层是主内容层(Column包含mainContent和bottomBar),承载所有可交互的UI元素。第四到六层是三个条件渲染的弹框层,分别由addOpen、editOpen、delOpen三个布尔状态控制。
弹框层的构建模式高度统一:每个弹框都是一个Stack容器,内含modalOverlay遮罩和居中的Column弹框主体。弹框主体宽度88%(留出12%的遮罩可见区域),constraintSize({ maxHeight: '80%' })限制最大高度为屏幕的80%,borderRadius(16)形成圆角卡片,zIndex(999)确保弹框始终位于最顶层。条件渲染(if (this.addOpen))确保弹框在关闭时不会占用渲染资源——当状态为false时,对应的Stack及其子元素不会被创建。
这种分层堆叠的架构有两大优势:一是渲染顺序由代码顺序决定(后写的在上层),非常直观;二是通过条件渲染实现了弹框的按需挂载,关闭弹框时其整个组件树会被销毁,释放内存。唯一的代价是每次打开弹框时需要重新创建组件树,但对于简单的表单弹框来说,这一开销可以忽略不计。整个build方法的结构清晰地反映了系统的渲染层级策略:背景 < 特效 < 内容 < 弹框,每一层各司其职。
系统整体数据流与状态联动
从数据流图可以看出,系统的状态联动呈现出"用户交互 -> 状态变更 -> 渲染更新"的单向数据流。用户的所有操作首先改变@State变量的值,框架检测到变化后自动触发依赖该变量的Builder方法重新执行,实现UI的响应式更新。弹框中的操作(doAdd/doEdit/doDel)会进一步修改postList或partList数据,这些数据变化又会反向触发内容页面的列表重渲染,形成"操作-数据-视图"的完整闭环。
技术对比与特性总结
以下表格从多个维度对比了本系统中采用的关键技术决策与替代方案之间的差异:
| 技术维度 | 本系统采用方案 | 替代方案A | 替代方案B | 选择理由 |
|---|---|---|---|---|
| 背景色层 | 纯色Column填充 | 渐变背景 | 图片背景 | 纯色性能最优且符合暖色主题 |
| 特效层定位 | Stack堆叠+HitTestMode.None | Canvas绘制 | WebGL渲染 | 声明式组件更易维护,穿透点击原生支持 |
| 特效驱动 | setInterval 90ms | requestAnimationFrame | CSS动画 | setInterval实现简单,90ms足够流畅 |
| 灰尘运动算法 | 取模伪随机 | Math.random | Perlin噪声 | 确定性序列避免画面跳变 |
| 灯斑数量 | 5个 | 3个 | 10个 | 5个兼顾视觉效果与性能 |
| 灰尘数量 | 12个 | 8个 | 20个 | 12个覆盖屏幕且不过载 |
| 主导航 | 底部4Tab | 顶部Tab | 侧边抽屉 | 底部Tab单手操作更便捷 |
| 子导航 | 横向滚动胶囊 | 固定Tab+下划线 | 下拉菜单 | 胶囊式视觉更突出且可扩展 |
| 内容路由 | if-else条件链 | 策略模式 | 映射表+反射 | 11个分支内if-else最直观高效 |
| 列表渲染 | ForEach+索引key | ForEach+唯一ID | 手动展开 | 索引key适合静态数据,ID适合动态数据 |
| 双列网格 | 手动分组(idx%2) | Grid容器 | FlexWrap | 手动分组控制更精细 |
| 进度条实现 | Stack叠加双Row | ProgressBar组件 | Canvas绘制 | Stack方式样式自定义最灵活 |
| 颜色映射 | 纯函数阶梯判断 | 查找表对象 | CSS变量 | 纯函数无副作用且类型安全 |
| 数据模型 | @Observed class | interface | plain object | @Observed支持响应式更新 |
| 图表数据 | interface定义 | class定义 | 内联对象 | 接口更轻量,无需实例化 |
| 弹框管理 | 三布尔变量分别控制 | 单枚举状态变量 | 路由参数控制 | 布尔变量简单直观,支持多弹框共存 |
| 弹框渲染 | 条件if渲染 | 始终渲染+visibility控制 | 动画进出 | 条件渲染关闭时零内存占用 |
| 弹框遮罩 | 共享modalOverlay | 每个弹框独立遮罩 | 全局遮罩管理器 | 共享遮罩减少代码重复 |
| 按钮文字 | 负margin叠加Text | Button.text属性 | 自定义Button组件 | 负margin绕过样式限制 |
| 定时器清理 | 生命周期钩子管理 | 组件外全局管理 | 不清理 | 生命周期钩子确保资源释放 |
| 统计数字 | 静态硬编码 | 动态计算 | 后端接口 | 演示阶段硬编码最快验证UI |
| 头像方案 | Emoji字符 | 网络图片 | 本地图标资源 | Emoji零加载开销且跨平台 |
| 数据截取 | slice方法 | 分页加载 | 虚拟列表 | slice简单适合小数据集 |
| 选中态编码 | 三重视觉(透明度+粗细+颜色) | 单一颜色变化 | 动画过渡 | 三重编码辨识度最高 |
总结
本文对"缩语·微缩模型沙盘工坊"系统的完整源码进行了逐段深度解析,从色彩体系定义到数据模型设计,从纯函数算法到Builder构建器实现,从状态管理到生命周期控制,全面覆盖了声明式UI开发的各个技术层面。该系统虽然是一个单页面应用,但其内部架构的复杂度和完整性足以作为一个微型前端工程的教学范本。
从架构设计角度来看,系统最突出的特点是通过Stack分层堆叠实现了"视觉特效层、主内容层、模态弹框层"的物理隔离。特效层通过HitTestMode.None实现了视觉覆盖但交互穿透的效果,这是声明式UI中"关注点分离"原则的优雅实践。模态弹框通过条件渲染实现了按需挂载,关闭时完全不占用渲染资源,这种设计在弹框数量较少时是最优选择。主内容层通过二级路由(mainTab + subTab)实现了4+7=11个视图的统一管理,虽然使用if-else条件链而非策略模式,但在当前规模下代码的直观性和性能都处于最优区间。
从状态管理角度来看,系统采用了"扁平化状态"的设计——16个@State变量直接定义在组件内部,没有使用集中式状态管理。这种设计在当前规模下是合理的:状态数量适中,状态间的关联关系简单(主要是弹框开关与表单字段的配对关系),不需要引入复杂的状态管理框架。但如果系统进一步扩展(如增加更多弹框、支持多用户切换、引入异步数据加载),扁平化状态的维护成本会快速上升,届时需要考虑将相关状态聚合成对象或引入状态管理中间件。
从特效实现角度来看,系统采用的"纯函数+定时器"驱动方案是一个在性能和可维护性之间的精妙平衡。12个灰尘微粒和5个灯斑共17个动态元素,每90毫秒重新计算一次位置和透明度,总计算量为17次纯函数调用——这在任何现代设备上都是可以忽略的开销。使用确定性算法(取模运算)而非随机数,确保了特效运动的可重复性和可预测性,这对于调试和视觉调优至关重要。如果需要进一步提升视觉表现力,可以考虑引入缓动函数让运动更自然,或增加粒子数量并使用Canvas/WebGL渲染以支持更复杂的粒子系统。
从数据建模角度来看,系统区分了"可观察数据模型"和"纯展示接口"两类数据结构——需要响应式更新的列表数据(PostItem、DioramaItem、PartItem、GearItem)使用@Observed class,纯静态展示数据(BuildChartItem、ScaleChartItem、CourseItem、ThemeItem)使用interface。这种区分体现了对框架特性的深入理解:@Observed装饰器会为类实例添加响应式追踪能力,但也会带来额外的内存和性能开销,对于不需要修改的静态数据使用interface可以避免这些开销。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:

从交互设计角度来看,系统实现了"新增-编辑-删除"的完整CRUD闭环(缺少查询,因为所有数据在初始化时已加载)。三个弹框通过统一的设计语言(相同的遮罩、相同的卡片样式、相同的按钮布局)构成了系统的"模态层视觉规范"。编辑弹框的"回填式打开"模式(先读取数据到表单字段再打开弹框)和删除弹框的"目标标记式打开"模式(先设置delTarget再打开弹框)都体现了"状态先行、UI响应"的声明式编程思想。
从可维护性角度来看,系统的主要改进空间在于:Button文字使用了负margin叠加Text的技巧,这是一种hack式实现,在组件库升级时可能出现兼容性问题;doDel方法中退课操作删除的是postList而非课程数据,存在业务逻辑与UI操作的语义不一致;静态数据硬编码在源文件中,未来需要对接后端接口时需要重构数据层。这些都是在从演示阶段走向生产阶段时需要解决的技术债务。整体而言,该系统展示了声明式UI在复杂单页面应用中的工程实践能力,其分层架构、状态管理和特效实现策略都具有较高的参考价值。
更多推荐




所有评论(0)