木作工坊数字孪生全解析HarmonyOS ArkTS API 24分层使得特效层、内容层、弹窗层互不干扰——特效层位于最底层且设置 HitTestMode.None 不拦截点击,内容层居中承载业务
在移动端声明式 UI 框架日趋成熟的今天,如何用一套纯前端代码描绘出一个具有专业质感、动态特效、完整业务闭环的垂直行业工坊应用,一直是界面工程领域极具挑战性的课题。本文将以"轮语·木工车旋工作室"这一系统为研究对象,从色彩体系、数据建模、纯函数特效、组件状态机、构建器分层到最终渲染栈层级,逐段剖析其实现思路。该系统并非简单的信息展示页面,而是一个融合了底部四主 Tab(首页、作品、课堂、我的)、首页七内容子 Tab(精选、车旋作品、车旋单、木料仓、车旋课堂、木友圈、工具墙)、木屑飞散与转盘光晕旋转双层动态特效、以及新增(发轮语)/编辑(编辑车旋单)/删除(报废木料·退课)三类弹框的复合型工坊管理应用。它以木工车旋这一传统手工艺为业务语境,将车床、车刀、木料、转速、光泽度、阴干周期等专业要素,抽象为一组组类型严密的观测类与接口,再通过构建器模式层层装配为可滚动、可交互、可弹窗的完整界面。
从架构哲学上看,这套系统体现了"数据先行、视图后置"的典型声明式思维:所有业务实体都被建模为带构造函数的 @Observed 观测类,所有静态展示数据被集中为模块级常量数组,所有与特效相关的坐标与透明度计算被抽离为无副作用的纯函数,所有界面片段被封装为 @Builder 修饰的私有构建方法,而最终在 build() 中以 Stack 栈式叠加完成分层渲染。这种分层使得特效层、内容层、弹窗层互不干扰——特效层位于最底层且设置 HitTestMode.None 不拦截点击,内容层居中承载业务,弹窗层在最顶层以条件渲染方式按需浮现。下面将逐一展开剖析。
—
第一章:色彩调色板接口的设计哲学

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;
grain: string;
}
这一接口定义是整个系统的视觉基石。设计者没有直接在组件里散落地书写颜色值,而是先用一个 ColorPalette 接口把所有可能用到的颜色语义化地枚举出来。可以看到字段命名遵循了"角色而非色值"的原则:primary 表示主品牌色、accent 表示强调色、bg 表示页面背景、cardBg 表示卡片背景、textPrimary/Secondary/Hint 表示三档文字层级、success/warning/danger 表示语义状态色。这种命名方式的好处在于,当未来需要做暗色主题或换肤时,只需替换具体实现而无需改动消费方代码。
特别值得注意的是 grain(木纹色)这一字段的引入。它不属于通用设计系统的标准色板,而是为木工这一特定业务场景量身定制的——它将作为木屑飞散特效中颗粒的颜色来源。这说明调色板接口不仅承载通用语义,还能承载业务语义,体现了接口设计对业务领域的贴合。另外,primaryLight 与 primaryDark 同时存在,构成了主色的明暗三档体系,足以支撑按钮态、选中态、悬停态等交互反馈所需的色阶过渡,是成熟设计系统的标志。
从类型约束角度看,所有字段都是 string 类型而非某个自定义颜色类型,这保证了与框架原生 backgroundColor、fontColor 等属性的直接兼容,避免了反复的类型转换。接口本身只是一个契约,真正的实现在下一节的常量里。
第二章:色彩常量的木作语境实现

const COLORS: ColorPalette = {
primary: '#A9743F',
primaryLight: '#C9A06B',
primaryDark: '#7C5228',
accent: '#5B8C5A',
accentLight: '#E8F0E6',
bg: '#F6F0E4',
cardBg: '#FFFFFF',
textPrimary: '#3D3226',
textSecondary: '#6F6250',
textHint: '#A69882',
border: '#E5DAC6',
success: '#6FA86B',
warning: '#D9A441',
danger: '#C96B55',
white: '#FFFFFF',
grain: '#D8C4A4'
};
这里实现了上一节定义的调色板契约。主色 #A9743F 是一种偏暖的焦糖棕,非常贴合胡桃木、橡木等常见车旋木料的色泽,能够在第一时间唤起用户对木作工坊的视觉联想。primaryDark 加深为 #7C5228,用于按钮按压态、选中态胶囊背景等需要强调的场合;primaryLight 提亮为 #C9A06B,用于木屑颗粒、辅助标签等柔和元素。三档主色之间的明度差足以形成视觉层次,又不会过于跳跃。
强调色 #5B8C5A 是一种沉静的苔藓绿,与棕色主色形成自然界的"木与叶"配色关系,不突兀却能区分功能。accentLight 为极浅的豆沙绿 #E8F0E6,专门用作标签胶囊的底色,让"实用器""餐具"等分类标签既能浮出又不抢眼。背景色 #F6F0E4 是一种米黄宣纸色,使整个应用呈现温润的纸质质感,强化了手工艺的语境。文字三档色 #3D3226、#6F6250、#A69882 从深到浅都带棕调,与背景同源,保证了整体色调的统一性,不会出现冷灰文字与暖棕背景的割裂感。
状态色方面,success 为青绿、warning 为琥珀金、danger 为砖红橙,都是低饱和度的"自然系"状态色,避免了通用的纯绿纯红带来的廉价感。grain 木纹色 #D8C4A4 是一种浅木色,专门为飞散的木屑颗粒准备,与背景有适度对比但又不刺眼。整套色彩体系堪称"业务驱动配色"的范本:每一个色值都能在界面中找到明确的职责,没有冗余色,也没有缺席色。
第三章:木友动态数据模型的观测化建模

@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 装饰器标记,这意味着该类的实例将被框架纳入响应式观测体系——当其字段被修改时,依赖这些字段的界面片段会自动刷新。这对于一个支持"发轮语"(新增动态)的应用至关重要:用户新发一条轮语后,列表无需手动刷新就能即时呈现。
字段设计上,id 作为唯一标识,nick 与 avatar 分别表示昵称与头像(此处用 emoji 字符串模拟头像,既轻量又有辨识度),text 是正文,time 是相对时间文案,likes 是点赞数。这种扁平结构便于直接渲染,无需中间转换。值得注意的是 avatar 用 emoji 而非图片 URL,这是因为本系统为纯前端演示场景,使用 emoji 可以零资源依赖地表达角色个性,如 🪵 表示资深木工、⏳ 表示阴干守候者、😵 表示振刀翻车者,每个头像本身就携带了人物气质信息。
构造函数的实现采用了紧凑的赋值风格,把所有字段在一行内用分号串联赋值。这种写法虽然在可读性上略逊于分行赋值,但优势在于构造函数体的简洁,尤其当字段数量较多时能减少样板代码。每个字段在类体中都先赋予了默认值(0 或 ''),这是 TypeScript 严格模式下的良好实践,保证即便用 new PostItem() 不传参也能得到一个合法对象,避免 undefined 引发的渲染异常。
第四章:车旋作品数据模型与光泽度指标

@Observed
export class WoodItem {
id: number = 0
name: string = ''
wood: string = ''
type: string = ''
status: string = ''
gloss: number = 0
constructor(id: number, name: string, wood: string, type: string, status: string, gloss: number) {
this.id = id; this.name = name; this.wood = wood; this.type = type
this.status = status; this.gloss = gloss
}
}
WoodItem 描述一件车旋作品,是"车旋作品"页与首页"作品架"双列卡片的数据载体。字段 name 是作品名(如"胡桃木沙拉碗"),wood 是木种(如"黑胡桃"),type 是分类(如"实用器"“装饰器”“节日件”“餐具”“文具件”“灯饰件”),status 是制作状态(“已完工”“打磨中”“车旋中”“规划中”),gloss 是光泽度评分。
光泽度 gloss 这一字段的引入尤为巧妙。在真实的木工车旋中,最终表面处理的质量往往以光泽度衡量——从 120 目砂纸粗磨到 2000 目镜面抛光,光泽度反映了打磨工序的精细程度。这里把它量化为 0-100 的数值,既能在界面上以数字徽章形式呈现(如"光泽 92"),又能作为后续潜在排序或筛选的依据。这种"业务指标字段化"的设计思路,让数据模型不止是展示载体,更承载了专业评判维度。
type 字段的取值虽然设计为自由字符串,但从静态数据看实际遵循一个有限集合,配合后续 typeTagColor 纯函数可以映射到不同的标签色。这种"软枚举"做法在演示场景下足够灵活,但在生产环境中若要保证一致性,可以考虑升级为联合字面量类型或枚举。status 同理,配合 statusColor 与 statusProgress 两个纯函数,将状态字符串映射为颜色与进度百分比,实现了状态的可视化双轨呈现。
第五章:车旋单数据模型与工序进度

@Observed
export class LatheItem {
id: number = 0
name: string = ''
stage: string = ''
level: string = ''
progress: number = 0
constructor(id: number, name: string, stage: string, level: string, progress: number) {
this.id = id; this.name = name; this.stage = stage; this.level = level
this.progress = progress
}
}
LatheItem 是"车旋单"页的核心实体,代表一道车旋工序。name 是工序名(如"粗旋外圆"“碗口内旋”“薄壁旋制”“螺纹旋切”),stage 是所用刀具或夹具(如"定径卡尺"“碗刮刀”“测厚规”“偏心盘”“螺纹刀”),level 是难度等级(“基础”“进阶”“高级”),progress 是工序进度百分比。
这一模型的设计精髓在于把"工序"这一工艺概念拆解为四个正交维度:做什么(name)、用什么(stage)、有多难(level)、做到哪了(progress)。这种拆解使得每张车旋单卡片都能在有限空间内传递高密度的工艺信息:标题行展示名称与难度标签,正文行展示所用刀具,底部进度条展示完成度。用户扫一眼即可判断哪些工序卡壳、哪些工序高级但进度低需要关注。
level 字段的"基础/进阶/高级"三档分级,配合 levelColor 纯函数分别映射为青绿、苔藓绿、焦糖棕,形成从易到难的暖色递进,符合人对"难度上升=颜色变暖"的直觉。progress 是数值型,直接驱动进度条的 width 百分比,无需额外转换。整个 LatheItem 是可编辑的——后文的 doEdit 方法会基于它构造新实例并替换原项,因此它必须为 @Observed,否则替换后界面不会刷新。这也是它与 WoodItem(只读展示)同为观测类但职责不同的微妙之处。
第六章:工具数据模型与图标化策略

@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
}
}
GearItem 描述一件车旋工具,字段精简为 name(工具名)、desc(用途说明)、icon(emoji 图标)。这套模型服务于"工具墙"页与"木料仓"页的"常用工具速查"双列卡片。字段虽少,但承载的信息维度完整:图标负责视觉识别,名称负责语义锚定,描述负责功能说明。
icon 字段使用 emoji 是本系统的一大特色策略。在木工车旋领域,专业刀具种类繁多且形态相近,若用真实照片则需要大量图片资源,若用矢量图标库则可能找不到对应的"碗刮刀""偏心盘"等细分品类。用 emoji 既保证了零资源依赖,又借助 🔪🥄✒️⚔️🗜️📏🧻🫙 等通用符号传达了大致语义,再配合 desc 文字补充,形成了"图标+文字"的双重信息冗余,降低了用户的认知成本。
值得讨论的是 GearItem 也标注了 @Observed,尽管在当前实现中工具列表是只读的(没有编辑/删除操作)。这是一种"前向兼容"的设计:留出观测能力,未来若加入"工具库存数量实时变动""补货后状态变化"等功能,无需改动模型层即可支持。这体现了设计者对模型层稳定性的重视——数据模型应是应用中最难改动的层,预先留出扩展余地比后期重构成本低得多。
第七章:图表与课程接口的非观测化设计
interface MonthChartItem {
label: string;
value: number;
}
interface WoodChartItem {
label: string;
value: number;
color: string;
}
interface CourseItem {
title: string;
week: string;
progress: number;
color: string;
}
interface WoodStockItem {
name: string;
desc: string;
icon: string;
count: string;
}
与前面的 @Observed 观测类不同,这四个图表与课程相关的数据结构都使用 interface 而非 class,且没有 @Observed 装饰。这是一个关键的设计分水岭:观测类用于"需要响应式变化的业务实体",而 interface 用于"纯展示性的静态数据"。
MonthChartItem 服务于月度完工柱状图,只有 label(月份)与 value(件数)两字段,极简。WoodChartItem 多了 color 字段,因为用木结构图每条需要不同颜色——黑胡桃用主棕色、硬枫用浅棕色、樱桃用苔藓绿等,颜色与数据绑定在一起,避免在渲染时再查表。CourseItem 同样内置 color,每门课程有自己的主题色,让进度条自带分类视觉。WoodStockItem 描述木料库存,count 用字符串而非数字(如"6 块"“一堆”),因为木料的计量单位本就不统一——板材按块、方料按根、余料按堆,强行数字化反而失真。
这种"接口+字面对象"的组合方式,使得这些数据可以以对象字面量直接定义在常量数组中,无需 new 构造,书写更简洁。代价是它们不可观测,修改后界面不会自动刷新——但这正是设计者的意图:这些数据是静态展示用的,不需要运行时变更。这种"观测类与接口的职责划分"体现了对响应式开销的精细管控:只让真正需要响应的数据进入观测体系,避免无谓的监听开销。
第八章:木友动态静态列表的业务叙事性
const POST_LIST: PostItem[] = [
new PostItem(1, '车旋老徐', '🪵', '胡桃木碗收刀那一刻的弧面,摸上去像婴儿脸颊,木蜡油封底。', '5分钟前', 89),
new PostItem(2, '湿料守夜人', '⏳', '一段樱桃木密封袋阴干四个月,终于可以上车床了,激动。', '22分钟前', 76),
new PostItem(3, '振刀受害者', '😵', '转速拉太高薄壁碗共振振裂了,血泪教训:宁厚勿薄。', '41分钟前', 68),
new PostItem(4, '砂纸_graded', '🧻', '120 到 2000 目全套打磨完,哑光变镜面,指甲滑过去无声。', '1小时前', 60),
new PostItem(5, '异形勺控', '🥄', '歪把勺用偏心车旋做的,吃酸奶的时候心情都会变好。', '2小时前', 52),
new PostItem(6, '组合保丽丰', '🎁', '圣诞球填了松针和铃铛,盖子旋出螺纹刚好拧紧,太妙了。', '3小时前', 47),
new PostItem(7, '车刀研磨师', '🔪', ' Bowl Gouge 磨出 40 度后缘,卷屑像刨花一样顺畅。', '5小时前', 39),
new PostItem(8, '木粉过敏侠', '😷', '枫木粉尘让我打了三天喷嚏,以后开旋必开集尘机。', '昨天', 58)
];
这八条静态动态构成了"木友圈"的初始内容池。仔细阅读每条文本,会发现它们并非随机的占位文字,而是精心编写的、充满车旋专业知识的微型叙事:第一条讲收刀弧面与木蜡油封底,第二条讲湿料阴干周期,第三条讲薄壁共振振裂的教训,第四条讲砂纸目数进阶,第五条讲偏心车旋,第六条讲组合件螺纹,第七条讲车刀后缘研磨,第八条讲木粉过敏与集尘。
这种"业务叙事性占位数据"是高质量演示页面的标志。它们让评审者或学习者在不连接真实后端的情况下,依然能感受到应用上线后的真实氛围——每条动态都是一位木友的真实经验分享,既有成功的喜悦也有失败的教训。昵称的命名也极具角色感:"车旋老徐"是资深前辈,"湿料守夜人"是阴干控,"振刀受害者"自嘲翻车,"砂纸_graded"夹英文梗,"组合保丽丰"玩谐音,"车刀研磨师"专注磨刀,"木粉过敏侠"是受害者联盟——每个昵称都在两个字符内刻画了一个鲜明的木友人设。
时间字段从"5分钟前"到"昨天"递减,点赞数从 89 到 39 大致递减(最后一条回升到 58),模拟了一个真实社区的活跃度衰减曲线。这种细节使得静态数据本身就具备"真实感",无需任何动态逻辑即可让界面看起来像一个运营了一段时间的社区。点赞数与时间的关系也符合常识——较早的动态通常有更多人看到并点赞,但"木粉过敏侠"那条虽然时间最早却点赞不低,反映了"安全话题"的共鸣效应。
第九章:车旋作品静态列表的多维覆盖
const WOOD_LIST: WoodItem[] = [
new WoodItem(1, '胡桃木沙拉碗', '黑胡桃', '实用器', '已完工', 92),
new WoodItem(2, '樱桃木花瓶', '美国樱桃', '装饰器', '打磨中', 78),
new WoodItem(3, '枫木圣诞球', '硬枫', '节日件', '已完工', 95),
new WoodItem(4, '橡木烛台', '白橡', '实用器', '车旋中', 56),
new WoodItem(5, '紫光檀笔杆', '紫光檀', '文具件', '已完工', 98),
new WoodItem(6, '歪把酸奶勺', '榉木', '餐具', '打磨中', 82),
new WoodItem(7, '樟木香薰瓶', '香樟', '装饰器', '车旋中', 44),
new WoodItem(8, '槐木灯座', '国槐', '灯饰件', '规划中', 12)
];
这八件作品覆盖了车旋工艺的多个维度:木种维度包含黑胡桃、美国樱桃、硬枫、白橡、紫光檀、榉木、香樟、国槐,既有国产常见料也有进口名贵料;类型维度覆盖实用器、装饰器、节日件、文具件、餐具、灯饰件六大类;状态维度覆盖已完工、打磨中、车旋中、规划中四种完整生命周期阶段;光泽度从 12(规划中)到 98(紫光檀笔杆)跨度极大。
这种多维覆盖使得"车旋作品"页的列表渲染能充分展示状态色、进度条、类型标签的差异化呈现——已完工的为青绿色满进度,打磨中的为棕色中进度,车旋中的为棕色半进度,规划中的为灰色最低进度。紫光檀笔杆以 98 的光泽度位居榜首,反映了紫光檀这种高密度硬木能够抛出极高光泽的特性,是业务知识在数据中的体现。
列表的排列也非随机,而是从"已完工高光泽"到"规划中低光泽"大致递减,但中间穿插了打磨中与车旋中的过渡项,模拟了一个真实工坊"有完工有在制有规划"的并行状态。这种排列方式让滚动浏览时视觉节奏有起伏,避免单调。每件作品的命名也都讲究:"胡桃木沙拉碗"点明用途与木种,"歪把酸奶勺"突出异形特征,"樟木香薰瓶"暗示香气属性,名字本身就是一种业务描述。
第十章:车旋单静态列表的工艺图谱
const LATHE_LIST: LatheItem[] = [
new LatheItem(1, '粗旋外圆', '定径卡尺', '基础', 100),
new LatheItem(2, '端面与定心', '尾座顶针', '基础', 88),
new LatheItem(3, '碗口内旋', '碗刮刀', '进阶', 66),
new LatheItem(4, '薄壁旋制', '测厚规', '高级', 42),
new LatheItem(5, '偏心旋制', '偏心盘', '高级', 24),
new LatheItem(6, '螺纹旋切', '螺纹刀', '高级', 16),
new LatheItem(7, '组合件胶合', '定位销', '进阶', 58),
new LatheItem(8, '表面砂光', '砂光架', '基础', 94)
];
这八张车旋单构成了一份完整的木工车旋工艺图谱。从基础工序(粗旋外圆、端面与定心、表面砂光)到进阶工序(碗口内旋、组合件胶合)再到高级工序(薄壁旋制、偏心旋制、螺纹旋切),覆盖了从入门到精通的完整技能树。每道工序都标注了所用的关键刀具或夹具:定径卡尺用于粗旋外圆的尺寸控制,尾座顶针用于端面定心,碗刮刀用于内腔刮削,测厚规用于薄壁测量,偏心盘用于偏心装夹,螺纹刀用于螺纹旋切,定位销用于组合胶合,砂光架用于表面砂光。
进度数值的设计很有叙事性:基础工序进度普遍较高(100、88、94),进阶工序中等(66、58),高级工序普遍较低(42、24、16),这符合一个正在学习车旋的匠人的真实成长曲线——基础已熟练,进阶在攻克,高级刚入门。这种数据安排让"我的"页与"车旋单"页的进度条呈现出"由长到短"的视觉梯度,直观传达了技能水平的分布。
特别值得注意的是"螺纹旋切"进度只有 16,是所有工序中最低的。这反映了螺纹旋切在车旋工艺中的极高难度——它要求刀具角度、转速、进给量的精确配合,稍有不慎就会崩刃或乱牙。把这种工艺知识编码进数据,让界面本身成为一种教学媒介,是本系统的一大亮点。这种"数据即教学"的思路,使得应用不仅是一个管理工具,更是一本可视化的车旋工艺教材。
第十一章:工具静态列表的品类全景
const GEAR_LIST: GearItem[] = [
new GearItem(1, '粗车刀', '外圆粗旋', '🔪'),
new GearItem(2, '碗刮刀', '内腔刮削', '🥄'),
new GearItem(3, '尖刀', '划线开槽', '✒️'),
new GearItem(4, '割断刀', '取件分离', '⚔️'),
new GearItem(5, '卡盘爪', '夹持木料', '🗜️'),
new GearItem(6, '游标卡尺', '测径测厚', '📏'),
new GearItem(7, '砂光机', '表面处理', '🧻'),
new GearItem(8, '木蜡油', '封蜡养护', '🫙')
];
这八件工具覆盖了车旋工坊的核心品类:切削类(粗车刀、碗刮刀、尖刀、割断刀)、夹持测量类(卡盘爪、游标卡尺)、表面处理类(砂光机、木蜡油)。这一品类划分恰好对应了车旋工艺的三大阶段:粗旋切削阶段用粗车刀与尖刀,内腔与分离阶段用碗刮刀与割断刀,测量贯穿全程用卡尺与卡盘爪,最终表面处理用砂光机与木蜡油。列表的顺序也大致遵循工艺流程,让用户浏览时自然形成工序认知。
工具的 emoji 选择颇具匠心:🔪 刀刃象征切削,🥄 勺形暗示碗刮刀的内腔作业,✒️ 尖头对应尖刀,⚔️ 双刃暗示割断,🗜️ 夹钳象征卡盘爪,📏 直尺象征卡尺,🧻 卷筒象征砂光,🫙 罐子象征蜡油罐。虽然这些 emoji 并非真实工具的精确图像,但通过形态联想成功传达了工具的职能。配合 desc 文字的补充说明,形成了"形+义"的双重信息传递,这是低资源环境下的优秀视觉设计策略。
这套工具列表同时服务于"工具墙"页(单列展开)与"木料仓"页(双列速查),实现了数据的复用。同一份数据在不同布局下呈现,考验的是数据模型的通用性——GearItem 的字段足够通用,既能驱动双列卡片的图标+名称+描述布局,也能驱动单列列表的图标+名称+描述+补货按钮布局,体现了模型设计的稳定性。
第十二章:月度完工柱状图数据的节奏设计
const MONTH_CHART: MonthChartItem[] = [
{ label: '2月', value: 2 },
{ label: '3月', value: 4 },
{ label: '4月', value: 3 },
{ label: '5月', value: 6 },
{ label: '6月', value: 5 },
{ label: '7月', value: 8 },
{ label: '8月', value: 4 }
];
这组数据描绘了一个木工工坊七个月的完工件数曲线。从 2 月的 2 件起步,3 月上升到 4 件,4 月小幅回落到 3 件,5 月攀升到 6 件,6 月微降到 5 件,7 月达到峰值 8 件,8 月又回落到 4 件。这条曲线不是单调的,而是有起有伏,模拟了一个真实工坊的产能波动。
波动的叙事性值得玩味:2 月起步低是因为年初开工调整设备;3 月上升是进入状态;4 月回落可能是因为接了一批难度高的薄壁件耗时;5 月攀升是因为接了节日订单;6 月微降是因为天气湿热木料干燥慢;7 月达到峰值是夏季高产期;8 月回落是因为高温减产。这些虽然只是推断,但数据本身的起伏为用户提供了想象空间,让图表不再是一根干瘪的柱子,而是一段有故事的工坊年史。
最大值 8 将作为后续 barH 纯函数的 max 参数,用于归一化柱高。7 月柱最高,2 月柱最低,视觉上形成"先低后高再回落"的山形曲线,符合工坊季节性产能的常见规律。这种数据节奏设计让柱状图本身就具备叙事张力,是数据可视化中常被忽视但极重要的"数据故事性"维度。
第十三章:用木结构占比数据的色彩绑定
const WOOD_CHART: WoodChartItem[] = [
{ label: '黑胡桃', value: 72, color: '#A9743F' },
{ label: '硬枫', value: 55, color: '#C9A06B' },
{ label: '樱桃', value: 44, color: '#5B8C5A' },
{ label: '白橡', value: 30, color: '#D9A441' },
{ label: '檀类', value: 18, color: '#C96B55' }
];
这组数据描述了工坊不同木种的使用占比。黑胡桃以 72% 居首,硬枫 55%,樱桃 44%,白橡 30%,檀类 18%。注意这里的百分比相加超过 100%,说明它不是严格的占比,而更像是"使用频率指数"——一个木友可能同时使用多种木料,每种木料的使用频率独立计算。这种数据语义需要在界面中以"用木结构"而非"占比"来命名,避免用户误以为要加和到 100。
每条数据都绑定了 color 字段,分别对应主棕色、浅棕色、苔藓绿、琥珀金、砖红橙。这种"颜色内嵌"的设计让每条木种在进度条上都有自己的专属色彩,与木种本身的色泽形成视觉联想:黑胡桃确实是深棕色,硬枫是浅木色,樱桃木偏红绿调,白橡偏金黄,檀类偏深红。颜色与业务实体的天然对应,使得图表无需图例就能让用户凭直觉识别木种。
数据的排列从高到低降序,使得横向进度条从长到短依次排列,视觉上形成一条下降的阶梯,清晰传达了"黑胡桃是主力木料,檀类是小众使用"的结构信息。这种降序排列是条形图数据组织的标准做法,便于用户快速识别主次关系。
第十四章:木料仓库存数据与计量单位语义
const STOCK_LIST: WoodStockItem[] = [
{ name: '黑胡桃', desc: '碗料 · 220×60', icon: '🪵', count: '6 块' },
{ name: '硬枫', desc: '方料 · 45 见方', icon: '🧱', count: '12 根' },
{ name: '美国樱桃', desc: '阴干中 · 四个月', icon: '⏳', count: '2 块' },
{ name: '边角余料', desc: '做小件 · 随取随用', icon: '🧩', count: '一堆' }
];
这组库存数据是首页"木料仓"四格卡片的数据源。它展示了四种木料的库存状态:黑胡桃是碗料,规格 220×60 毫米,存量为 6 块;硬枫是方料,45 毫米见方,存量为 12 根;美国樱桃正在阴干中,已四个月,存量为 2 块;边角余料用于做小件,随取随用,存量为"一堆"。
desc 字段的描述极具业务深度:它不仅说明规格,还说明状态。"碗料"指适合做碗的板材规格,"方料"指方形截面的木料,"阴干中·四个月"说明这块樱桃木还在水分稳定期不能立即上车床(樱桃木阴干周期通常需要三到六个月),"做小件·随取随用"说明边角余料无需管理随用随取。这些描述本身就是木工知识,让库存界面同时成为一本木料管理手册。
count 字段用字符串而非数字,是本组数据最值得讨论的设计。木料的计量天然不统一:板材按块、方料按根、余料按堆。强行用数字统一会丢失这种业务语义——"一堆"无法用数字表达其模糊性,"6 块"的"块"也无法被数字本身传达。把单位与数字一起作为字符串存储,既保留了语义完整性,又简化了界面渲染(直接显示字符串无需拼接单位)。这是"业务驱动类型选择"的典型范例。
第十五章:车旋课堂课程列表的进阶路径
const COURSE_LIST: CourseItem[] = [
{ title: '车床安全与装夹', week: '第 1 周', progress: 100, color: '#A9743F' },
{ title: '粗旋与端面处理', week: '第 2-3 周', progress: 90, color: '#5B8C5A' },
{ title: '碗类内旋技法', week: '第 3-5 周', progress: 62, color: '#C9A06B' },
{ title: '薄壁与偏心挑战', week: '第 5-7 周', progress: 30, color: '#D9A441' },
{ title: '表面处理与封蜡', week: '第 7-8 周', progress: 14, color: '#A9743F' }
];
这五门课程构成了一个为期八周的车旋入门到进阶的完整课程路径。从第 1 周的车床安全与装夹(进度 100% 已学完),到第 2-3 周的粗旋与端面处理(90%),到第 3-5 周的碗类内旋技法(62%),到第 5-7 周的薄壁与偏心挑战(30%),到第 7-8 周的表面处理与封蜡(14%)。这是一条从安全入门到高阶技法再到收尾精修的完整教学链路。
课程安排体现了真实的木工教学逻辑:第一周必须先学安全与装夹,因为车床是高速旋转的 dangerous 机械,装夹不当木料飞出可能致命;第二三周学粗旋与端面,这是所有车旋的基础;第三到五周学碗类内旋,因为碗是车旋的标志性器型;第五到七周挑战薄壁与偏心,这是高阶技法;最后第七八周学表面处理与封蜡,完成作品。课程的时间安排有重叠(如第 3-5 周与第 5-7 周在第 5 周重叠),模拟了"边学边练"的真实教学节奏。
进度的递减(100→90→62→30→14)讲述了一个学员的学习状态:前期课程已掌握,中期课程进行中,后期课程刚开始。这种进度梯度配合每门课程独立 color 字段渲染的进度条,让"车旋课堂"页呈现出"从满进度到低进度"的视觉下滑,直观传达了学习所处阶段。每门课的颜色不同,避免了五条进度条同色的单调,也暗示了不同课程的主题差异。
第十六章:主导航配置与底部 Tab 架构
interface NavItem {
icon: string;
label: string;
}
interface SubNavItem {
label: string;
count: string;
}
const NAV_LIST: NavItem[] = [
{ icon: '🪵', label: '首页' },
{ icon: '🥣', label: '作品' },
{ icon: '📚', label: '课堂' },
{ icon: '👤', label: '我的' }
];
这两段定义了导航的数据契约与实现。NavItem 接口极简,只有 icon 与 label 两字段,服务于底部四主 Tab。SubNavItem 接口多了 count 字段,服务于首页七内容子 Tab 的"胶囊带计数"式设计。两个接口的字段差异恰恰反映了两种导航的不同信息密度需求。
NAV_LIST 的四项配置体现了典型的"内容+创作+学习+个人"四宫格导航范式:首页聚合精选内容,作品展示创作成果,课堂承载学习路径,我的是个人中心。这种四分法在内容创作类应用中极为常见,既覆盖了消费侧(看作品、看课堂),又覆盖了生产侧(我的作品、我的学习),还覆盖了社交侧(首页的木友动态),是一个经过验证的信息架构。
图标选择上,🪵 木段对应首页(工坊入口),🥣 碗对应作品(车旋的标志性器型),📚 书本对应课堂,👤 人形对应我的。这套图标与业务语境深度绑定,避免了通用的房子、星星、齿轮等中性图标,让导航本身也成为业务语境的一部分。底部 Tab 的图标在选中态与非选中态通过 opacity 与 fontColor 区分,非选中态 opacity 0.55 与灰色文字形成弱化,选中态满 opacity 与主色文字形成强调,是经典的激活态视觉反馈。
第十七章:子导航胶囊带计数式的信息密度
const SUB_NAV_LIST: SubNavItem[] = [
{ label: '精选', count: '' },
{ label: '车旋作品', count: '8' },
{ label: '车旋单', count: '8' },
{ label: '木料仓', count: '4' },
{ label: '车旋课堂', count: '5' },
{ label: '木友圈', count: '8' },
{ label: '工具墙', count: '8' }
];
这七项子导航配置是首页内容切换的核心。每个子项除了 label 外还有 count 字段,用于在胶囊右侧显示一个小计数徽章。这种"胶囊带计数"式设计比纯文字 Tab 信息密度更高——用户在切换前就能知道每个分区有多少内容,便于决策是否值得浏览。
count 字段的取值与对应的静态列表长度一一对应:车旋作品 8 件对应 WOOD_LIST 长度 8,车旋单 8 张对应 LATHE_LIST 长度 8,木料仓 4 项对应 STOCK_LIST 长度 4,车旋课堂 5 门对应 COURSE_LIST 长度 5,木友圈 8 条对应 POST_LIST 长度 8,工具墙 8 件对应 GEAR_LIST 长度 8。这种数据与计数的严格对应,使得计数徽章成为可靠的导航线索,而非装饰性数字。
第一项"精选"的 count 为空字符串,这是一个特殊设计——精选是聚合页,没有单一数据源对应,所以不显示计数。这种"空字符串判断不渲染徽章"的逻辑在后文的 subNav 构建器中通过 if (item.count.length > 0) 实现,是数据驱动渲染的典型范例。七个子 Tab 横向滚动排列,选中态胶囊背景为主色、文字为白色加粗,非选中态胶囊背景为白色、文字为次色,形成清晰的视觉对比。选中态的计数徽章背景为深棕色,非选中态为浅边色,进一步强化了激活反馈。
第十八章:纯函数体系的总体定位
function barH(v: number, max: number): number {
return Math.round(110 * v / max);
}
本系统在模块顶层定义了一组纯函数,它们是特效与可视化的计算引擎。所谓纯函数,即给定输入必然得到确定输出、不产生副作用、不依赖外部状态的函数。把这些计算逻辑从组件中抽离出来,既便于单元测试,又保证了组件层的简洁。barH 就是这一体系的第一个范例:它把一个数值 v 在最大值 max 的比例下归一化到 110 像素高度,用于月度柱状图的柱高计算。
Math.round 的使用保证了柱高为整数像素,避免亚像素渲染带来的边缘模糊。系数 110 是柱状图区域的最大高度预算,与后文 Column().height(140) 的容器高度配合——140 像素中留出 30 像素给标签文字,110 像素给柱体。这种"容器高度=柱高+标签高"的拆解,使得柱子永远不会溢出容器。
纯函数体系的总体价值在于:它把"业务数值→视觉参数"的转换逻辑集中管理。当未来需要调整柱状图的最大高度,只需改 barH 的系数 110 一处;当需要改变颜色映射逻辑,只需改 woodColor 一处。这种集中化使得视觉规则的变更不会散落到各个组件中,是大型应用可维护性的关键实践。
第十九章:木色映射函数与业务语义着色
function woodColor(v: number): string {
if (v > 60) {
return '#A9743F';
} else if (v > 35) {
return '#5B8C5A';
}
return '#D9A441';
}
woodColor 根据数值返回三种颜色之一:大于 60 返回主棕色,大于 35 返回苔藓绿,其余返回琥珀金。这个函数服务于月度柱状图的柱体着色——件数多的月份用棕色(强),中等的用绿色(中),少的用金色(弱)。三档色彩的选择不是随机的,而是沿用了调色板中的 primary、accent、warning 三色,保证了与整体色彩体系的一致。
这种"数值阈值→颜色"的映射在数据可视化中极为常见,但本系统的特别之处在于阈值的选择:60 与 35。这两个阈值不是均分的(不是 50 与 25),而是偏高的——只有件数超过 60% 最大值才显示棕色,35-60% 显示绿色,低于 35% 显示金色。这种偏高的阈值设计,使得多数月份(数据集中在 2-8 之间,最大 8)会落在绿色或金色区间,只有高产月份才显示棕色,强化了"高产=突出"的视觉对比。
从业务语义看,棕色代表木料本色(高产),绿色代表生机(正常),金色代表稀少(低产但珍贵),三种颜色都承载了木作语境的语义联想,不是通用的红黄绿信号灯配色。这种"业务语义着色"比通用的"高=红低=绿"更贴合工坊语境,体现了设计者对业务领域的深度理解。函数本身极其简单,但背后的色彩语义思考却颇为深厚。
第二十章:木屑飞散特效函数组的伪随机算法
function chipX(tick: number, i: number): number {
return 20 + ((tick * 7 + i * 97) % 610);
}
function chipY(tick: number, i: number): number {
return 90 + ((tick * 13 + i * 71) % 600);
}
function chipA(tick: number, i: number): number {
return 0.15 + ((tick + i) % 3) * 0.15;
}
function chipR(tick: number, i: number): number {
return 2 + (i % 4);
}
这四个函数共同驱动"木屑飞散"特效中每一颗木屑的位置、透明度与半径。它们的核心算法是"基于 tick 与索引 i 的模运算",这是一种无需随机数生成器即可产生伪随机分布的技巧。tick 是一个每 90 毫秒自增的状态值,i 是木屑颗粒的索引(0 到 11)。通过 (tick * 系数 + i * 系数) % 模数 的运算,每颗木屑在每个 tick 都会跳到一个新的位置,形成飞散效果。
系数的选择(7、97、13、71)都是质数,这避免了周期性重合——如果用偶数系数,木屑会在某些 tick 出现规律性同步,破坏随机感。模数 610 与 600 分别对应屏幕宽度与高度区域,加上偏移量 20 与 90,保证木屑分布在可视区域内。chipA 的透明度在 0.15 到 0.45 之间三档变化,chipR 的半径在 2 到 5 像素之间四档变化,这种离散的档位变化模拟了木屑的"远近虚实"——远处的小且淡,近处的大且浓。
这套伪随机算法的精妙之处在于它是确定性的:相同的 tick 与 i 永远产生相同的位置。这意味着木屑的运动轨迹是可预测的(虽然看起来随机),便于调试与复现。同时,由于 tick 每 90 毫秒变化一次,木屑的位置每 90 毫秒跳变一次,形成"闪烁飞散"的视觉而非连续滑动。这种跳变式动画虽然不如连续插值平滑,但在低性能开销下能产生强烈的"飞散"动感,是性价比极高的特效方案。
第二十一章:转盘光晕旋转特效函数组的环形分布
function ringA(tick: number, i: number): number {
return 0.06 + ((tick + i) % 4) * 0.04;
}
function ringR(tick: number, i: number): number {
return 40 + ((tick * 2 + i * 3) % 5) * 22;
}
function ringX(tick: number, i: number): number {
return 80 + ((tick * 5 + i * 149) % 500);
}
function ringY(tick: number, i: number): number {
return 150 + i * 190;
}
这四个函数驱动"转盘光晕旋转"特效中四个光晕圆环的属性。与木屑函数组类似,它们也基于 tick 与索引 i 的模运算,但参数与模数不同,产生了与木屑截然不同的视觉表现。ringA 透明度在 0.06 到 0.18 之间四档变化,比木屑更淡,营造光晕的朦胧感;ringR 半径在 40 到 128 像素之间五档变化,形成大小不一的圆环;ringX 横坐标在 80 到 580 之间变化;ringY 纵坐标固定为 150 + i * 190,即四个光晕在垂直方向均匀分布(150、340、530、720),但只在屏幕内可见前几个。
ringY 的设计特别值得分析:它没有用模运算,而是直接 150 + i * 190,这意味着四个光晕的纵坐标是固定的,只有横坐标、半径、透明度随 tick 变化。这种"部分固定+部分变化"的设计,使得光晕在垂直方向稳定排列,在水平方向漂移旋转,形成类似车床转盘旋转的视觉——转盘的轴是垂直的,盘面在水平方向旋转,光晕随之漂移。
光晕的视觉表现是四个浅棕色(COLORS.primaryLight)的圆形,透明度极低(0.06-0.18),半径较大(40-128),叠加在背景之上形成柔和的光晕。它们位于特效层,不拦截点击,所以用户在浏览内容时几乎察觉不到它们的存在,但潜意识里能感受到"画面在缓慢呼吸"的动感。这种"潜意识级特效"是高级界面设计的追求——不抢戏但提升氛围。
第二十二章:状态颜色映射函数的语义着色
function statusColor(s: string): string {
if (s === '已完工') {
return '#6FA86B';
} else if (s === '打磨中' || s === '车旋中') {
return '#A9743F';
}
return '#A69882';
}
statusColor 把作品状态字符串映射为颜色:已完工为青绿(success 色),打磨中或车旋中为主棕色(primary 色),其余(如规划中)为浅灰棕(textHint 色)。这种映射服务于"车旋作品"页的进度条与状态标签着色,让状态本身通过颜色就能被识别——青绿色意味着完成,棕色意味着进行中,灰色意味着未开始。
颜色选择的语义逻辑值得深思:已完工用青绿色(生长、完成),打磨中与车旋中用棕色(木料本色,象征正在与木料打交道),规划中用灰色(虚无、未启动)。这套配色不是随意的,而是与木工语境深度绑定——木料的本色是棕,所以"进行中"用棕;完工意味着超越木料本色达到成品状态,所以用代表生机的青绿;规划中尚未接触木料,所以用中性的灰。这种"业务语义着色"比通用的"完成=绿、进行=黄、未开始=灰"更有语境感。
函数用 if-else if-else 的级联结构,把"打磨中"与"车旋中"合并到同一分支,体现了它们在视觉表现上的等价性——都是"进行中"状态,无需区分。这种合并简化了逻辑,避免了重复代码。最后的 else 兜底返回灰色,保证了任何未知状态都有合理颜色,是防御性编程的体现。
第二十三章:状态进度映射函数的数值抽象
function statusProgress(s: string): number {
if (s === '已完工') {
return 100;
} else if (s === '打磨中') {
return 76;
} else if (s === '车旋中') {
return 52;
}
return 10;
}
statusProgress 把状态字符串映射为进度百分比:已完工 100,打磨中 76,车旋中 52,其余 10。这个函数与 statusColor 形成双轨映射——同一个状态既决定颜色又决定进度,二者协同呈现,让进度条的颜色与长度都由状态驱动,保证了视觉一致性。
进度的具体数值(100、76、52、10)反映了车旋工艺的真实阶段:已完工是 100 无需解释;打磨中是 76,因为打磨是车旋的最后一道工序,已完成粗旋、精旋、内旋等主要工序,只剩表面处理;车旋中是 52,意味着主体车削进行到一半,还有内旋、薄壁、打磨等多道工序;规划中是 10,只有初步装夹与定心。这些数值不是随意的,而是大致对应了车旋工序的工时分布——打磨约占 24%,车旋约占 48%,规划约占 10%,与真实工艺节拍吻合。
值得讨论的是这种"状态→固定进度"的简化模型。在真实应用中,进度应该是一个独立字段由用户更新,而非由状态推导。但在本演示系统中,把进度绑定到状态是一种"数据冗余消除"策略——状态已蕴含了进度信息,无需再为每个作品单独维护一个进度字段。这种以数据建模简化为代价换取演示效果的做法,在演示场景中是合理的,但在生产环境应还原为独立进度字段。
第二十四章:类型标签与难度等级的颜色映射
function typeTagColor(g: string): string {
if (g === '实用器' || g === '餐具') {
return '#5B8C5A';
} else if (g === '节日件' || g === '文具件') {
return '#D9A441';
}
return '#A9743F';
}
function levelColor(s: string): string {
if (s === '基础') {
return '#6FA86B';
} else if (s === '进阶') {
return '#5B8C5A';
}
return '#A9743F';
}
这两个函数分别服务于作品类型标签与车旋单难度等级的着色。typeTagColor 把作品类型分为三组:实用器与餐具为绿色(日常使用、生活气息),节日件与文具件为金色(精致、纪念),其余(装饰器、灯饰件等)为棕色(默认主色)。levelColor 把难度分为三档:基础为青绿(轻松、入门),进阶为苔藓绿(适中、成长),高级为棕色(挑战、深度)。
typeTagColor 的分组逻辑体现了对作品功能属性的语义理解:实用器与餐具都是"要用"的器物,强调功能性,用绿色传达朴实可用;节日件与文具件都是"要赏"的器物,强调精致感,用金色传达珍贵;装饰器与灯饰件则是"要展"的器物,用主棕色保持品牌一致。这种"功能语义着色"让每个类型标签都自带视觉个性,避免了所有标签同色的单调。
levelColor 的三档配色形成从青绿到苔藓绿到棕色的暖度递增,呼应了"难度上升=颜色变暖"的直觉。基础青绿色给人轻松感,进阶苔藓绿介于冷热之间,高级棕色给人厚重感。这种渐变式的难度视觉编码,让用户扫一眼车旋单列表就能感知到技能树的难度分布,无需逐项阅读文字。两个函数虽然简单,但背后的色彩语义学思考颇为深厚,是"以色传义"设计哲学的体现。
第二十五章:组件状态定义与响应式数据声明
@Entry
@Component
struct PageWoodturningStudio {
@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 = 'wood'
@State editName: string = ''
@State editStyle: string = ''
@State editNote: string = ''
@State addTitle: string = ''
@State addContent: string = ''
@State postList: PostItem[] = POST_LIST
@State latheList: LatheItem[] = LATHE_LIST
@State fxTimer: number = -1
这一大段状态声明定义了组件的全部响应式数据。@Entry 标记此组件为页面入口,@Component 标记为声明式组件。@State 装饰的字段在被修改时会触发组件的重渲染,是响应式 UI 的核心机制。状态字段可以分为四组:导航状态(mainTab、subTab)、特效状态(tick、glow、fxTimer)、弹框状态(addOpen、editOpen、delOpen、editIdx、delTarget)、表单状态(editName、editStyle、editNote、addTitle、addContent)、业务数据(postList、latheList)。
mainTab 与 subTab 是导航的核心,前者控制底部四主 Tab(0-3),后者控制首页七内容子 Tab(0-6)。两个数值的初始值都为 0,意味着应用启动时默认显示首页的精选内容。tick 与 glow 是特效驱动变量,tick 每 90 毫秒自增驱动木屑与光晕的位置变化,glow 在 0 与 1 之间翻转(虽然当前实现中 glow 的具体消费不明显,但它的存在为未来扩展光晕的明暗呼吸效果预留了空间)。
弹框状态的设计体现了"单弹框独占"模式:三个布尔 addOpen、editOpen、delOpen 互斥,任意时刻最多一个为 true。delTarget 用字符串 'wood' 或 'course' 区分删除目标是车旋单还是课程,使得同一个删除弹框能复用于两种删除场景。业务数据 postList 与 latheList 直接初始化为静态常量 POST_LIST 与 LATHE_LIST,但标注 @State 意味着它们可以被 unshift、splice 等方法修改并触发刷新——这是新增、编辑、删除操作能即时反映到界面的关键。fxTimer 初始为 -1 是哨兵值,表示定时器未启动。
第二十六章:生命周期与特效定时器的启停管理
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 与 aboutToDisappear 是组件的生命周期回调,分别在组件即将出现与即将消失时调用。这里在 aboutToAppear 中启动了一个 90 毫秒间隔的定时器,每 tick 自增 tick 与翻转 glow,驱动整个特效层的动画。90 毫秒约等于每秒 11 帧的刷新率,虽然低于常见的 60 帧(16 毫秒),但对于"木屑飞散+光晕漂移"这种不需要极致流畅的背景特效来说足够了,且大幅降低了 CPU 开销。
定时器的句柄保存在 fxTimer 状态中,这是一个关键设计——它使得 aboutToDisappear 能够拿到句柄并清理定时器。如果不保存句柄,组件销毁后定时器仍会继续执行,修改一个已不存在的组件的状态,导致内存泄漏与潜在的错误。aboutToDisappear 中先判断 fxTimer > 0(即定时器已启动),再调用 clearInterval 清理,最后把 fxTimer 重置为 -1,是规范的资源释放流程。
这种"生命周期内启停定时器"的模式是动画特效组件的标准范式。它保证了特效只在组件存活期间运行,组件销毁即特效停止,不会浪费系统资源。同时,把定时器间隔设为 90 毫秒而非更小的 16 毫秒,体现了对性能的取舍——宁愿特效略"跳",也要保证主界面的滚动与交互流畅。这种"主交互优先于特效"的优先级安排,是移动端性能优化的常识。
第二十七章:弹框开关方法的集中封装
openAdd(): void {
this.addTitle = '';
this.addContent = '';
this.addOpen = true;
}
openEdit(index: number): void {
if (index >= 0 && index < this.latheList.length) {
this.editIdx = index;
this.editName = this.latheList[index].name;
this.editStyle = this.latheList[index].level;
this.editNote = this.latheList[index].stage;
}
this.editOpen = true;
}
openDelA(): void {
this.delTarget = 'wood';
this.delOpen = true;
}
openDelB(): void {
this.delTarget = 'course';
this.delOpen = true;
}
这四个方法负责打开三类弹框前的数据准备。openAdd 在打开新增弹框前清空表单字段 addTitle 与 addContent,保证每次打开都是干净的输入状态。openEdit 接收一个索引参数,先做边界检查(index >= 0 && index < this.latheList.length),然后把对应车旋单的 name、level、stage 分别拷贝到 editName、editStyle、editNote,实现"打开即填充"的编辑体验。openDelA 与 openDelB 分别设置删除目标为 'wood' 与 'course',然后打开同一个删除弹框。
这种"开关方法集中封装"的设计有几个好处。首先,它把弹框打开的副作用(数据准备)与触发点(按钮点击)解耦,使得多个触发点可以复用同一套准备逻辑。例如"发轮语"按钮在首页精选页与木友圈页都有,它们都调用 openAdd,无需重复写清空表单的代码。其次,它使得弹框的状态管理集中在组件内部,外部调用方只需调用一个方法即可完成"准备+打开"的完整流程。
openEdit 的边界检查尤其值得称道。它先判断索引是否在合法范围内,再访问数组,避免了越界访问导致的 undefined 错误。即便索引非法,它仍会设置 editOpen = true,只是表单字段保持上一次的值——这是一种宽容的降级策略,保证用户操作不会因边界错误而完全无响应。openDelA 与 openDelB 的命名虽然简洁(A/B 区分),但通过 delTarget 字符串区分目标,使得同一个删除弹框 delModalBody 能根据 delTarget 渲染不同的文案,是弹框复用的典范。
第二十八章:新增/编辑/删除业务方法的响应式数据操作
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.latheList.length) {
this.latheList.splice(this.editIdx, 1, new LatheItem(this.latheList[this.editIdx].id, this.editName, this.editNote, this.editStyle, 40));
}
this.editOpen = false;
}
doDel(): void {
if (this.delTarget === 'wood' && this.latheList.length > 0) {
this.latheList.splice(0, 1);
} else if (this.delTarget === 'course' && this.postList.length > 0) {
this.postList.splice(0, 1);
}
this.delOpen = false;
}
这三个方法分别执行新增、编辑、删除的业务逻辑,并关闭对应弹框。doAdd 在 addTitle 非空时,用 unshift 向 postList 头部插入一条新动态,id 为 999(固定值),昵称为"我的轮语",头像是木段 emoji,时间为"刚刚",点赞为 0。doEdit 用 splice 替换指定索引的车旋单,保留原 id,但用表单输入的 editName、editNote、editStyle 构造新实例,进度固定重置为 40。doDel 根据 delTarget 决定删除 latheList 还是 postList 的首项。
unshift 与 splice 都是直接修改原数组的方法。在响应式框架中,直接修改 @State 数组通常能触发刷新,因为框架会劫持数组的变更方法。新插入的动态出现在列表头部,符合"最新内容置顶"的社交动态展示惯例。doEdit 用 splice(idx, 1, newItem) 的三参形式实现"替换"——删除 1 项并插入 1 项,是原地替换的标准技巧。进度重置为 40 是一个简化处理,意味着编辑后工序进度回到 40%,模拟了"重新调整工序后进度重置"的业务语义。
doDel 的设计有一个细节:无论 delTarget 是 wood 还是 course,都只删除列表的首项(splice(0, 1)),而非用户选中的某项。这是一种演示级简化——真实应用应该让用户选择具体删除哪一项,但本系统通过"删除首项"简化了交互流程,使得删除弹框无需携带目标索引。delTarget 仅用于区分删除的是哪个列表,而非列表中的哪一项。这种简化虽然降低了真实感,但大幅简化了状态管理,是演示场景下的合理取舍。
第二十九章:特效层构建器与点击穿透设计
@Builder
fxLayer() {
Stack({ alignContent: Alignment.TopStart }) {
ForEach([0, 1, 2, 3], (i: number) => {
Column()
.width(ringR(this.tick, i) * 2)
.height(ringR(this.tick, i) * 2)
.borderRadius(ringR(this.tick, i))
.backgroundColor(COLORS.primaryLight)
.opacity(ringA(this.tick, i))
.translate({ x: ringX(this.tick, i), y: ringY(this.tick, i) })
}, (i: number) => i.toString())
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], (i: number) => {
Column()
.width(chipR(this.tick, i) * 2)
.height(chipR(this.tick, i) * 2)
.borderRadius(chipR(this.tick, i))
.backgroundColor(COLORS.grain)
.opacity(chipA(this.tick, i))
.translate({ x: chipX(this.tick, i), y: chipY(this.tick, i) })
}, (i: number) => i.toString())
}
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
}
fxLayer 是特效层的构建器,用一个 Stack 容器装载四组光晕圆环与十二颗木屑颗粒。光晕用 COLORS.primaryLight(浅棕)填充,木屑用 COLORS.grain(木色)填充,二者都通过纯函数计算 width、height、borderRadius、opacity、translate 属性。每个圆形元素用 borderRadius 设为半径值实现圆形(因为 width=2*radius,borderRadius=radius 即圆)。translate 用于位移,使得每颗木屑与每个光晕都分布在屏幕的不同位置。
最关键的一行是 .hitTestBehavior(HitTestMode.None)。这一行设置整个特效层的命中测试模式为"不参与",意味着特效层不会拦截任何点击事件,所有触摸都会穿透到下层的内容层。这是特效层能够位于底层却不影响交互的关键。如果不设置这一行,特效层的 Stack 虽然视觉上在底层,但它的 width('100%').height('100%') 会覆盖整个屏幕,拦截所有点击,导致内容层无法交互。
HitTestMode.None 是框架提供的命中测试模式之一,表示该节点及其子节点都不参与命中测试。与之对应的还有 HitTestMode.Default(默认,参与命中测试)、HitTestMode.Block(阻断,拦截但不传递)。选择 None 而非 Block 是经过深思熟虑的——特效层不需要拦截也不需要传递,它只是视觉装饰,完全不参与交互链。这种"视觉存在但交互不存在"的设计,是分层渲染架构中装饰层的标准处理方式。
第三十章:头部构建器与信息密度布局
@Builder
header() {
Column({ space: 10 }) {
Row({ space: 8 }) {
Column({ space: 6 }) {
Row() {
Text('🪵')
.fontSize(24)
Text('轮语车旋工房')
.fontSize(19)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 6 })
.layoutWeight(1)
Text('今日转速')
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Row() {
Text('车旋 · 打磨 · 组合 · 封蜡')
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('1800 rpm')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.width('100%')
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Text('约台')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 14, right: 14, top: 8, bottom: 8 })
.backgroundColor(COLORS.primary)
.borderRadius(16)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
}
.width('100%')
.padding({ top: 10, bottom: 4 })
}
header 构建了页面顶部的工房信息卡。它在一个白色圆角卡片内,用左右两栏布局:左栏是工房名称与副标题,右栏是"约台"按钮。左栏又分为上下两行:上行是"🪵 轮语车旋工房 今日转速"——工房名加粗主色,“今日转速"标签灰色小字;下行是"车旋·打磨·组合·封蜡 1800 rpm”——业务标语灰色小字,转速数值主色加粗。右栏的"约台"按钮用主色背景、白色文字、圆角胶囊形。
这个头部在有限空间内承载了高密度信息:工房身份(轮语车旋工房)、业务范围(车旋、打磨、组合、封蜡四道工序)、实时状态(今日转速 1800 rpm)、操作入口(约台)。其中"1800 rpm"是车床转速的单位(revolutions per minute,每分钟转数),1800 是一个中等偏高的转速,适合硬木车旋。这种业务细节的嵌入,让头部不只是装饰,而是一个信息浓缩的工房仪表盘。
布局上,layoutWeight(1) 用于让左侧 Column 撑满剩余空间,把"约台"按钮推到最右侧。alignItems(HorizontalAlign.Start) 让左栏内容左对齐,保证标题与副标题的起始位置一致。padding(14) 与 borderRadius(14) 是卡片的统一内边距与圆角,与整个应用的其他卡片保持一致,形成了视觉节奏的统一。头部的 padding({ top: 10, bottom: 4 }) 让顶部留白多一些、底部留白少一些,是因为下方紧接着子导航胶囊,视觉上需要更紧密的衔接。
第三十一章:子导航构建器的横向滚动胶囊
@Builder
subNav() {
Scroll() {
Row({ space: 8 }) {
ForEach(SUB_NAV_LIST, (item: SubNavItem, idx: number) => {
Row({ space: 4 }) {
Text(item.label)
.fontSize(12)
.fontWeight(this.subTab === idx ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textSecondary)
if (item.count.length > 0) {
Text(item.count)
.fontSize(9)
.fontWeight(FontWeight.Bold)
.fontColor(this.subTab === idx ? COLORS.white : COLORS.textHint)
.padding({ left: 5, right: 5, top: 1, bottom: 1 })
.backgroundColor(this.subTab === idx ? COLORS.primaryDark : COLORS.border)
.borderRadius(7)
}
}
.padding({ left: 12, right: 12, top: 7, bottom: 7 })
.backgroundColor(this.subTab === idx ? COLORS.primary : COLORS.cardBg)
.borderRadius(16)
.onClick(() => {
this.subTab = idx;
})
}, (item: SubNavItem) => item.label)
}
.width('100%')
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.padding({ top: 4, bottom: 10 })
}
subNav 构建了首页的七内容子导航。它用一个横向 Scroll 容器装载一个 Row,Row 内用 ForEach 遍历 SUB_NAV_LIST 渲染七个胶囊。每个胶囊是一个 Row,内含标签文字与可选的计数徽章。选中态胶囊背景为主色、文字白色加粗、计数徽章深棕底;非选中态胶囊背景为白色、文字次色、计数徽章浅边底。胶囊用 borderRadius(16) 形成完全圆角的药丸形。
Scroll 的 scrollable(ScrollDirection.Horizontal) 设置为横向滚动,scrollBar(BarState.Off) 隐藏滚动条,使得七个胶囊在屏幕宽度不够时可以横向滑动浏览,且视觉上没有滚动条的干扰。这种"无滚动条横向滑动 Tab"是移动端内容导航的主流方案,既节省垂直空间,又能在有限屏幕显示更多 Tab 选项。
if (item.count.length > 0) 的条件渲染是数据驱动渲染的典型。当 count 为空字符串(如"精选"),不渲染计数徽章;当 count 非空(如"8"),渲染一个小徽章。这种设计让"精选"胶囊比其他胶囊略窄,视觉上区分了聚合页与单内容页。onClick 中直接设置 this.subTab = idx,触发响应式刷新,选中态立即切换。整个子导航的交互闭环——点击→状态变更→样式刷新——在几行代码内完成,体现了声明式 UI 的简洁。
第三十二章:精选页构建器的多模块聚合
@Builder
pageFeatured() {
Column({ space: 12 }) {
// 湿料横幅
Row({ space: 14 }) {
Text('🥣')
.fontSize(44)
Column({ space: 6 }) {
Text('本月专题 · 湿料碗挑战')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('一次装夹 · 薄壁 4mm · 阴干定型 · 组队打卡')
.fontSize(12)
.fontColor('#F3E7D6')
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)
// ... 后续模块:木料仓四格、月度柱状图、用木结构、作品双列、木友动态
}
.width('100%')
}
pageFeatured 是首页第一个子 Tab"精选"的内容,也是一个聚合页,包含了湿料横幅、木料仓四格、月度完工柱状图、用木结构占比、作品双列、木友动态六大模块。这里截取了开头部分——湿料横幅。它用深棕色(primaryDark)背景承载本月专题"湿料碗挑战",左侧大号碗 emoji,右侧标题、描述、行动按钮三层文字。行动按钮"去参加"用浅棕色底深棕字,与横幅主背景形成对比。
湿料碗挑战的描述"一次装夹·薄壁 4mm·阴干定型·组队打卡"四点要素,每一点都是车旋湿料碗的关键工艺:一次装夹是为了避免二次装夹带来的同心度误差;薄壁 4mm 是湿料碗的标志性厚度(再薄易裂,再厚失味);阴干定型是湿料车旋后的必要工序(湿料车旋后干燥会变形,必须阴干);组队打卡是社区玩法。这四点浓缩了湿料碗工艺的精髓,让横幅不只是促销广告,更是一条工艺知识。
精选页后续模块的布局各有变化:木料仓四格是均分四列的紧凑卡片;月度柱状图是垂直柱列;用木结构是横向进度条列;作品双列是两列卡片;木友动态是单列头像+文字。这种"模块间布局差异化"的设计避免了精选页的单调,让用户在滚动浏览时始终保持视觉新鲜感。每个模块都有标题行(带标题与右侧操作链接),形成了"标题+内容"的统一节奏,既是模块分隔也是导航线索。
第三十三章:作品页构建器的进度条与状态色
@Builder
pageWorks() {
Column({ space: 10 }) {
Row() {
Text('车旋作品')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('上架新作品')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(WOOD_LIST, (it: WoodItem) => {
Column({ space: 6 }) {
Row() {
Column()
.width(4)
.height(38)
.borderRadius(2)
.backgroundColor(statusColor(it.status))
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.status)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(statusColor(it.status))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.bg)
.borderRadius(6)
}
.width('100%')
Row({ space: 8 }) {
Text(`🌳 ${it.wood}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
Text(`✨ 光泽 ${it.gloss}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.width('100%')
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${statusProgress(it.status)}%`)
.height(6)
.borderRadius(3)
.backgroundColor(statusColor(it.status))
}
.width('100%')
// ... 完成度文字与类型标签
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: WoodItem) => it.id.toString())
// ... 报废木料按钮
}
.width('100%')
}
pageWorks 是"车旋作品"页(也是首页第二个子 Tab),用单列卡片展示八件作品。每张卡片的设计极具层次:左侧是一条 4 像素宽的竖条,颜色由 statusColor(it.status) 决定,作为状态的视觉指示;右侧是内容区,上行是作品名加粗与状态标签胶囊,下行是木种与光泽度小字;再下方是进度条,用 Stack 叠加底层灰条与上层状态色条,上层宽度由 statusProgress(it.status) 百分比驱动。
竖条+进度条的双重视觉指示是本页的亮点。竖条在卡片左侧形成一个"状态色带",让用户在快速滚动时只需扫左侧色带就能判断作品状态——青绿色带=已完工,棕色带=进行中,灰色带=规划中。进度条则在卡片内部用长度量化进度,是状态的可视化补充。这种"色带+进度条"的双重指示,让每张卡片在不打开详情的情况下就能传递完整的状态信息。
进度条的 Stack 叠加技巧值得学习。底层是一个 width('100%') 的灰条作为轨道,上层是一个 width('${statusProgress(it.status)}%') 的状态色条作为填充,二者都 height(6) 且 borderRadius(3),叠加后形成"灰色轨道上有色填充"的经典进度条外观。这种用 Stack 实现进度条的方式比用框架的 Progress 组件更灵活,因为可以自定义颜色、圆角、高度,是自定义进度条的标准做法。页底的"报废木料"按钮用 danger 色背景与负 margin 的文字叠加,实现了按钮内文字的居中显示,是一种 hack 式但有效的写法。
第三十四章:车旋单页构建器与编辑入口
@Builder
pageLathe() {
Column({ space: 10 }) {
Text('车旋单')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.latheList, (it: LatheItem, idx: number) => {
Column({ space: 8 }) {
Row({ space: 10 }) {
Text('🔄')
.fontSize(24)
Column({ space: 4 }) {
Row() {
Text(it.name)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.level)
.fontSize(10)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(it.level))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(COLORS.accentLight)
.borderRadius(6)
}
.width('100%')
Text(`🔧 ${it.stage}`)
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(6)
.borderRadius(3)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(6)
.borderRadius(3)
.backgroundColor(levelColor(it.level))
}
.width('100%')
Row() {
Text(`工序进度 ${it.progress}%`)
.fontSize(10)
.fontColor(COLORS.textHint)
.layoutWeight(1)
Text('编辑车旋单')
.fontSize(11)
.fontColor(COLORS.primary)
.onClick(() => {
this.openEdit(idx);
})
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: LatheItem) => it.id.toString())
}
.width('100%')
}
pageLathe 是"车旋单"页,展示 this.latheList 中的工序卡片。每张卡片左侧是旋转 emoji 🔄(暗示车床旋转),右侧是工序名加粗与难度标签胶囊、所用刀具小字;中部是进度条,颜色由 levelColor(it.level) 决定(基础青绿、进阶苔藓绿、高级棕);底部是进度文字与"编辑车旋单"操作链接。
"编辑车旋单"链接的 onClick 调用 this.openEdit(idx),把当前卡片索引传入,触发编辑弹框的打开与表单预填。这是整个系统唯一的编辑入口,位于每张车旋单卡片的右下角,便于用户在浏览时直接编辑某道工序。ForEach 的键值用 it.id.toString(),保证每张卡片有稳定的 key,避免数组变更时的渲染错乱。
车旋单页的卡片设计与作品页有相似的结构(标题行+内容行+进度条+底部行),但细节不同:车旋单用旋转 emoji 而非状态色竖条,因为车旋单的状态是"进行中"(有进度),不存在已完工/规划中的二分;进度条颜色由难度等级而非状态决定,因为难度是车旋单的核心分类维度。这种"相似结构不同细节"的设计,既保持了视觉节奏的统一,又通过细节差异传达了不同的业务语义。
第三十五章:木料仓页构建器的双列复用
@Builder
pageStock() {
Column({ space: 10 }) {
Text('木料仓')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(this.latheList.slice(0, 5), (it: LatheItem, idx: number) => {
Row({ space: 10 }) {
Text(`${idx + 1}`)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.primary : COLORS.textHint)
.width(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(`${it.stage} · ${it.level}`)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text(`${it.progress}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(levelColor(it.level))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: LatheItem) => it.id.toString())
Text('常用工具速查')
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ top: 4 })
ForEach(GEAR_LIST, (it: GearItem, idx: number) {
if (idx % 2 === 0) {
Row({ space: 10 }) {
// 双列工具卡片
}
.width('100%')
}
}, (it: GearItem) => it.id.toString())
}
.width('100%')
}
pageStock 是"木料仓"页,巧妙地复用了 this.latheList 的前五项作为木料排名列表,并接续展示"常用工具速查"的双列卡片。木料排名用单行卡片,左侧是排名数字(前三名为主色,其余为灰色),右侧是进度百分比。这种"排名色阶"设计让前三名木料在视觉上突出,符合"前三名特殊"的视觉惯例。
值得讨论的是木料仓页复用 latheList 而非 STOCK_LIST 的设计。从语义看,STOCK_LIST 才是真正的木料库存数据,但这里却用了 latheList。这是一种演示级数据复用——为了让木料仓页有足够的内容且无需新增数据源,借用了车旋单列表的前五项。虽然语义上不严格,但视觉上呈现的是"木料排名",配合排名色阶与进度百分比,形成了一个看似合理的木料排行榜。这种"数据复用"在演示场景中是常见的省力策略。
双列工具卡片的实现用了一个 if (idx % 2 === 0) 的技巧:只在偶数索引时渲染一个 Row,Row 内同时渲染当前项与下一项(idx 与 idx+1),实现两列布局。这种"以偶数索引为行起点"的双列实现,避免了使用 Grid 组件的复杂性,是轻量级双列布局的常用方案。每张工具卡片显示图标、名称、描述与"去补货"链接,形成了完整的工具信息卡。
第三十六章:课堂页构建器与课程进度
@Builder
pageCourse() {
Column({ space: 10 }) {
Text('车旋课堂')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
ForEach(COURSE_LIST, (it: CourseItem) => {
Column({ space: 8 }) {
Row() {
Text(it.title)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.week)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.width('100%')
Stack({ alignContent: Alignment.Start }) {
Row()
.width('100%')
.height(8)
.borderRadius(4)
.backgroundColor(COLORS.border)
Row()
.width(`${it.progress}%`)
.height(8)
.borderRadius(4)
.backgroundColor(it.color)
}
.width('100%')
Row() {
Text('已学')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.layoutWeight(1)
Text(`${it.progress}%`)
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
}
.width('100%')
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: CourseItem) => it.title)
Button()
.width('100%')
.height(42)
.backgroundColor(COLORS.danger)
.borderRadius(10)
.opacity(0.9)
.onClick(() => {
this.openDelB();
})
Text('退课')
.fontSize(14)
.fontColor(COLORS.white)
.margin({ left: -28, top: -32 })
}
.width('100%')
}
pageCourse 是"车旋课堂"页,展示五门课程的进度。每张课程卡片有标题行(课程名+周次)、进度条(用课程自带 color 渲染)、底部行("已学"标签+百分比)。进度条比其他页略高(8 像素而非 6 像素),因为课程进度是本页的核心信息,加粗的进度条更便于识别。
课程进度条的颜色来自 it.color,即每门课程自带的主题色。这意味着五条进度条颜色各不相同(棕、绿、浅棕、金、棕),避免了同色单调。这种"每项自带颜色"的数据驱动渲染,让每门课程在视觉上有自己的"身份色",便于用户区分。底部"退课"按钮用 danger 色,点击调用 openDelB 打开删除弹框(delTarget 设为 'course'),是课堂页的危险操作入口。
"退课"按钮的实现方式与作品页的"报废木料"按钮相同:用一个透明 Button 组件承载点击事件,再用一个 Text 通过负 margin 叠加在按钮上显示文字。这种写法虽然 hack(依赖负 margin 精确定位文字),但避免了 Button 组件默认样式的干扰,实现了完全自定义的按钮外观。在生产环境中,更推荐使用 Button('退课') 的构造函数形式或自定义样式,但在演示场景下这种 hack 写法足够有效。
第三十七章:木友圈页构建器与发轮语入口
@Builder
pageCircle() {
Column({ space: 10 }) {
Row() {
Text('木友圈')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text('发轮语')
.fontSize(12)
.fontColor(COLORS.primary)
.onClick(() => {
this.openAdd();
})
}
.width('100%')
ForEach(this.postList, (it: PostItem) => {
Row({ space: 10 }) {
Text(it.avatar)
.fontSize(26)
Column({ space: 4 }) {
Row() {
Text(it.nick)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.layoutWeight(1)
Text(it.time)
.fontSize(10)
.fontColor(COLORS.textHint)
}
.width('100%')
Text(it.text)
.fontSize(13)
.fontColor(COLORS.textSecondary)
Text(`👍 ${it.likes} · 💬 回复`)
.fontSize(11)
.fontColor(COLORS.textHint)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
}, (it: PostItem) => it.id.toString())
}
.width('100%')
}
pageCircle 是"木友圈"页,展示完整的 this.postList 动态列表。每条动态用单行卡片,左侧大号头像 emoji,右侧是昵称+时间行、正文、点赞+回复行。昵称加粗主色,时间灰色小字,正文次色正常字号,互动数据灰色小字。这种"头像+昵称+正文+互动"的布局是社交动态卡片的标准范式。
"发轮语"链接位于标题行右侧,点击调用 openAdd 打开发布弹框。发轮语是本系统"新增"操作的语义化命名——"轮语"是车旋匠人的心得分享,借用"论语"的谐音,既文雅又贴合车旋语境。发布后通过 doAdd 的 unshift 操作,新轮语会出现在列表头部,用户能立即看到自己的发布结果,形成即时的正向反馈。
木友圈页与首页精选页的"木友动态"模块都渲染 postList,但二者有关键差异:精选页只取前三条(this.postList.slice(0, 3))作为预览,木友圈页渲染全部。这种"精选预览+完整列表"的设计,让精选页保持轻量(只看最新的三条),完整浏览则进入木友圈页。两个页面共享同一份响应式数据,所以发轮语后两处都会同步刷新,保证了数据一致性。
第三十八章:工具墙页构建器的三区分类
@Builder
pageTools() {
Column({ space: 10 }) {
Text('工具墙')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
Row({ space: 8 }) {
Column({ space: 4 }) {
Text('🔪')
.fontSize(28)
Text('车刀区')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('粗车 · 刮削')
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.padding(12)
.backgroundColor(COLORS.cardBg)
.borderRadius(12)
.alignItems(HorizontalAlign.Center)
// 量具区、养护区同理
}
.width('100%')
ForEach(GEAR_LIST, (it: GearItem) => {
Row({ space: 10 }) {
Text(it.icon)
.fontSize(20)
Column({ space: 3 }) {
Text(it.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(it.desc)
.fontSize(10)
.fontColor(COLORS.textSecondary)
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('补货')
.fontSize(11)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.primary)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.accentLight)
.borderRadius(8)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardBg)
.borderRadius(10)
}, (it: GearItem) => it.id.toString())
}
.width('100%')
}
pageTools 是"工具墙"页,顶部是三区分类卡片(车刀区、量具区、养护区),下方是 GEAR_LIST 的单列工具列表。三区卡片用均分三列布局,每张卡片居中显示大号 emoji、区名、用途说明。这种"区分总览+工具明细"的两层结构,让用户先从功能维度(车刀/量具/养护)纵览工具分类,再从具体工具逐项浏览。
三区的划分对应了车旋工艺的三大阶段:车刀区承载切削类工具(粗车刀、碗刮刀、尖刀、割断刀),量具区承载测量类工具(卡盘爪、游标卡尺),养护区承载表面处理类工具(砂光机、木蜡油)。这种分类与前面 GEAR_LIST 的品类全景形成了"功能维度"与"工具维度"的双视角,让用户既能按功能找工具,也能按工具查功能。
工具明细列表的每项用单行卡片,左侧 emoji 图标,中间名称+描述,右侧"补货"按钮。“补货"按钮用 accentLight 浅绿底+主色字,是低强调度的操作按钮,适合这种非核心的辅助操作。与作品页的"报废木料”(danger 色)和课堂页的"退课"(danger 色)相比,"补货"的操作严重性更低(补货是日常,报废与退课是不可逆),所以用低强调度的浅绿底色,形成了操作严重性的视觉分级——这种"操作色阶"设计是成熟的交互设计实践。
第三十九章:我的页构建器与个人数据汇总
@Builder
pageMine() {
Column({ space: 12 }) {
Row({ space: 12 }) {
Text('🐻')
.fontSize(40)
Column({ space: 4 }) {
Text('轮语车旋匠')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('Lv.4 · 完工 32 件 · 台时 120h')
.fontSize(11)
.fontColor(COLORS.textSecondary)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(14)
// 我的车旋单卡片、我的轮语卡片
}
.width('100%')
}
pageMine 是"我的"页,展示个人资料与数据汇总。顶部是个人资料卡,左侧大号熊 emoji 头像,右侧是昵称"轮语车旋匠"与等级数据"Lv.4 · 完工 32 件 · 台时 120h"。下方是"我的车旋单"卡片(展示 this.latheList 前三项)与"我的轮语"卡片(展示 this.postList 前三条),每张卡片标题行右侧显示总数。
个人资料中的"Lv.4 · 完工 32 件 · 台时 120h"三组数据,分别表示等级、完工作品数、累计车床工时。Lv.4 是一个中等偏高的等级(假设最高 Lv.10),32 件完工量与月度图表的"今年累计 32 件"对应,120 小时的台时意味着平均每件作品约 3.75 小时——这是一个合理的车旋工时(简单件 1-2 小时,复杂件 6-8 小时)。这种数据之间的内在一致性,让"我的"页不是一个孤立的个人页,而是与首页的数据图表相互印证,增强了应用的整体性。
"我的车旋单"与"我的轮语"两张卡片都用了"标题+总数+前三项预览"的结构,与首页精选页的预览逻辑一致。this.latheList.length 与 this.postList.length 实时计算总数,所以新增/删除操作后总数会立即更新。这种"总数实时计算"的设计比硬编码总数更可靠,避免了数据与计数不同步的问题。两张卡片的三项预览都只显示关键信息(名称+阶段/昵称+时间),不显示完整内容,引导用户进入对应完整页查看详情。
第四十章:弹框遮罩与点击关闭构建器
@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%')
}
modalOverlay 是弹框的遮罩层构建器,一个全屏黑色半透明(opacity 0.6)的 Column,覆盖在内容之上,为弹框提供视觉聚焦效果——遮罩把背景内容变暗,让用户的注意力集中在弹框上。遮罩的 onClick 同时设置三个弹框状态为 false,实现"点击遮罩关闭任意弹框"的交互。
这种"一个遮罩关闭所有弹框"的设计,得益于前面"单弹框独占"的状态模式——任意时刻只有一个弹框打开,所以把三个状态都设为 false 等价于关闭当前打开的那个弹框。这种写法简洁但依赖一个隐含约束:不能同时打开多个弹框。在本系统中这个约束通过 openAdd、openEdit、openDelA/B 等方法互斥打开来保证,是合理的。
遮罩用 #000000 纯黑加 0.6 透明度,形成 60% 不透明的黑色覆盖。这是一个常见的遮罩透明度,既能显著变暗背景又不完全遮挡,用户仍能隐约看到背景内容,保持上下文感知。如果透明度过高(如 0.9),背景几乎不可见,用户会失去上下文;过低(如 0.3),聚焦效果不足,弹框不突出。0.6 是一个经过验证的平衡值。
第四十一章:新增弹框构建器与表单交互
@Builder
addModalBody() {
Column({ space: 12 }) {
Text('发布轮语')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '一句话车旋心得…', text: this.addTitle })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(40)
.onChange((v: string) => {
this.addTitle = v;
})
TextArea({ placeholder: '补充细节:木种、转速、刀具、表面处理…', text: this.addContent })
.fontSize(13)
.fontColor(COLORS.textPrimary)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.height(90)
.onChange((v: string) => {
this.addContent = v;
})
Row({ space: 10 }) {
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.bg)
.borderRadius(8)
.onClick(() => {
this.addOpen = false;
})
Text('取消')
.fontSize(13)
.fontColor(COLORS.textSecondary)
.margin({ left: -52 })
Button()
.layoutWeight(1)
.height(38)
.backgroundColor(COLORS.primary)
.borderRadius(8)
.onClick(() => {
this.doAdd();
})
Text('发布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.margin({ left: -44 })
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(COLORS.cardBg)
}
addModalBody 是"发布轮语"弹框的主体,包含一个标题、一个单行输入框、一个多行文本域、取消与发布两个按钮。TextInput 的 placeholder 是"一句话车旋心得…“,引导用户输入简短的标题;TextArea 的 placeholder 是"补充细节:木种、转速、刀具、表面处理…”,引导用户补充工艺细节。两个 placeholder 的文案极具业务引导性,不仅是输入提示,更是一种内容创作指导。
onChange 回调把输入值同步到 this.addTitle 与 this.addContent 状态,实现受控输入。text: this.addTitle 的双向绑定(虽然这里用 text+onChange 手动实现而非真正的双向绑定)保证了表单状态与输入框内容的一致。当 openAdd 清空表单字段时,输入框内容也会同步清空,保证每次打开都是干净状态。
按钮行的实现采用了与"报废木料"按钮相同的 hack:用透明 Button 承载点击,用 Text 通过负 margin 叠加显示文字。取消按钮用 COLORS.bg 背景与次色文字,发布按钮用 COLORS.primary 背景与白色加粗文字。两个按钮的视觉强弱区分明确——发布是主操作(主色),取消是次操作(浅色)。doAdd 在 addTitle 非空时执行 unshift 新增,然后关闭弹框;若 addTitle 为空则直接关闭弹框(不新增),这是一种"空标题不发布"的轻量校验。
第四十二章:编辑与删除弹框构建器的复用模式
@Builder
editModalBody() {
Column({ space: 12 }) {
Text('编辑车旋单')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
TextInput({ placeholder: '工序名称', text: this.editName })
.onChange((v: string) => { this.editName = v; })
TextInput({ placeholder: '难度(如 进阶)', text: this.editStyle })
.onChange((v: string) => { this.editStyle = v; })
TextArea({ placeholder: '当前阶段与刀具说明', text: this.editNote })
.onChange((v: string) => { this.editNote = v; })
// 取消与保存按钮
}
}
@Builder
delModalBody() {
Column({ space: 12 }) {
Text(this.delTarget === 'wood' ? '报废木料' : '退出车旋课堂')
.fontSize(17)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(this.delTarget === 'wood' ? '将移除首张车旋单并回收木料,确认执行?' : '将退出首门进行中的课程,确认执行?')
.fontSize(13)
.fontColor(COLORS.textSecondary)
// 取消与确认按钮
}
}
editModalBody 与 delModalBody 分别是编辑与删除弹框的主体。编辑弹框有三个输入字段:工序名称、难度、当前阶段与刀具说明,分别绑定 editName、editStyle、editNote,由 openEdit 方法预填。删除弹框则根据 delTarget 字符串动态渲染标题与描述——'wood' 显示"报废木料"与车旋单删除文案,'course' 显示"退出车旋课堂"与课程退出文案。
删除弹框的这种"一个弹框两种文案"的复用模式,是本系统弹框设计的精华。通过 delTarget 状态区分目标,同一个 delModalBody 构建器能服务于两种删除场景,无需为每种删除单独写一个弹框。这种复用减少了代码量,也保证了删除交互的一致性——无论删什么,都是"标题+描述+取消/确认"的三段式结构。doDel 方法同样根据 delTarget 决定操作 latheList 还是 postList,形成了"状态驱动渲染+状态驱动操作"的完整闭环。
三个弹框(新增、编辑、删除)的按钮布局完全一致:取消在左、确认在右,取消浅色、确认主色或 danger 色。这种布局一致性让用户在三种弹框间的操作肌肉记忆可以迁移,降低了学习成本。编辑弹框的确认按钮用主色(保存是正向操作),删除弹框的确认按钮用 danger 色(删除是危险操作),通过颜色传达操作的严重性,是"色义一致"设计原则的体现。
第四十三章:底部导航构建器与主 Tab 切换
@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;
})
}, (it: NavItem) => it.label)
}
.width('100%')
.height(56)
.backgroundColor(COLORS.cardBg)
.borderRadius({ topLeft: 16, topRight: 16 })
}
bottomBar 是底部四主 Tab 导航栏。一个 Row 内用 ForEach 渲染四个 Column,每个 Column 包含图标与标签文字,通过 layoutWeight(1) 均分四等宽。选中态图标 opacity 为 1、文字加粗主色,非选中态图标 opacity 0.55、文字常规灰色。onClick 设置 this.mainTab = idx 切换主 Tab。
底部导航栏的视觉设计遵循了移动端的惯例:白色背景、顶部圆角(topLeft: 16, topRight: 16)、高度 56 像素(适配手指点击的舒适区域)。选中态的反馈通过三重视觉差异实现——图标透明度、文字粗细、文字颜色,三重差异叠加使得选中态与非选中态的对比足够鲜明,用户一眼就能识别当前所在 Tab。
borderRadius({ topLeft: 16, topRight: 16 }) 只设置顶部两角圆角,底部两角保持直角,这是因为底部导航栏紧贴屏幕底部边缘,底部圆角会露出背景色形成"漂浮"感,而顶部圆角则让导航栏与上方内容有柔和过渡。这种"顶部圆角底部直角"是底部导航栏的标准圆角处理。四个 Tab 的图标 🪵🥣📚👤 与业务语境深度绑定,标签"首页/作品/课堂/我的"简洁明了,形成了清晰的导航语义。
第四十四章:主内容构建器与条件路由
@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.pageLathe()
} else if (this.subTab === 3) {
this.pageStock()
} else if (this.subTab === 4) {
this.pageCourse()
} else if (this.subTab === 5) {
this.pageCircle()
} else {
this.pageTools()
}
}
.width('100%')
}
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
.layoutWeight(1)
.padding({ left: 14, right: 14, bottom: 20 })
} else if (this.mainTab === 1) {
Scroll() {
Column() { this.pageWorks() }
}
// ...
} else if (this.mainTab === 2) {
Scroll() {
Column() { this.pageCourse() }
}
// ...
} else {
Scroll() {
Column() { this.pageMine() }
}
// ...
}
}
.width('100%')
.height('100%')
}
mainContent 是主内容区的构建器,负责根据 mainTab 与 subTab 的组合渲染对应页面。它先渲染 header(所有主 Tab 共用),然后根据 mainTab 分四支:mainTab === 0 是首页,先渲染 subNav(子导航),再在 Scroll 内根据 subTab 渲染七个内容页之一;mainTab === 1 是作品 Tab,直接渲染 pageWorks;mainTab === 2 是课堂 Tab,直接渲染 pageCourse;mainTab === 3 是我的 Tab,直接渲染 pageMine。
这种"主 Tab 分支+子 Tab 分支"的嵌套条件渲染,构成了应用的两级路由。首页的特殊性在于它有子 Tab,所以比其他三个主 Tab 多了一层 subNav 与 subTab 的条件分支。其他三个主 Tab 直接渲染单一页面,无需子导航。这种"首页多内容+其他单内容"的非对称设计,反映了首页作为"聚合入口"的特殊地位——它是用户进入应用的第一眼,需要提供最丰富的内容选择。
每个内容页都被包裹在 Scroll 中,且 Scroll 都设置了 scrollable(ScrollDirection.Vertical)、scrollBar(BarState.Off)、layoutWeight(1)、padding({ left: 14, right: 14, bottom: 20 })。这套统一的 Scroll 配置保证了所有内容页的滚动行为一致——垂直滚动、无滚动条、左右留边 14、底部留白 20。layoutWeight(1) 让 Scroll 撑满 header 与 bottomBar 之间的剩余空间,是典型的"固定头尾+中间滚动"布局。
第四十五章:根构建器与分层渲染栈
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 */ }
if (this.delOpen) { /* 删除弹框 Stack */ }
}
.width('100%')
.height('100%')
}
build 是组件的根构建器,用一个 Stack 把所有层叠加在一起。从底到顶的层级依次是:背景色层(纯色 Column)、特效层(fxLayer,木屑+光晕)、内容层(mainContent+bottomBar)、弹框层(条件渲染的三个 Stack)。这种分层叠加是整个系统渲染架构的核心。
背景色层在最底,提供一个米黄色基底,确保即使内容层有透明区域也不会露出系统默认背景。特效层在背景之上,通过 hitTestBehavior(HitTestMode.None) 不拦截点击,木屑与光晕在背景上飞舞但不影响交互。内容层在特效之上,承载所有业务界面与底部导航,是用户实际交互的层。弹框层在内容之上,通过 if (this.addOpen) 等条件渲染,仅在对应状态为 true 时才出现,且 zIndex(999) 保证它在最顶层。
弹框的 Stack 内部又分两层:modalOverlay(遮罩)在底,addModalBody(主体)在顶。弹框主体用 width('88%') 占屏幕 88% 宽度居中显示,constraintSize({ maxHeight: '80%' }) 限制最大高度为屏幕 80%,borderRadius(16) 圆角,形成居中浮起的卡片。这种"88% 宽+80% 高+圆角"的弹框尺寸配置,既保证了弹框有足够空间承载内容,又留出周围 12% 的边距让用户看到背景遮罩,形成"浮起"的视觉。
三层条件渲染的弹框(新增、编辑、删除)都遵循相同的 Stack 结构:遮罩+主体,主体 88% 宽+80% 高+圆角+zIndex 999。这种结构一致性使得三种弹框的视觉表现统一,用户在不同弹框间的体验是连贯的。由于"单弹框独占"的状态约束,三个 if 不会同时为 true,所以不会出现弹框叠加。
第四十六章:系统整体数据流与状态流转
这张流程图完整描绘了系统的数据流与状态流转。从应用启动的定时器开始,到每 90 毫秒的特效重渲染,再到用户交互触发的状态变更,最终到响应式数据的修改与界面刷新,形成了一个闭环的数据流。值得注意的是,postList 与 latheList 作为 @State 观测数组,它们的变更会同时刷新多个消费方——postList 同时被精选页(前三预览)、木友圈页(全部)、我的页(前三预览)消费,latheList 同时被车旋单页、木料仓页(前五)、我的页(前三)消费。一处变更,多处同步,是响应式数据的核心价值。
第四十七章:分层渲染架构的层级关系
这张层级图展示了系统从底到顶的五层渲染栈。背景层提供基底色,特效层在其上飞舞但不拦截点击,内容层承载业务,弹框层在内容之上按需浮现。弹框层内又有遮罩与主体两子层,遮罩在底变暗背景,主体在顶承载表单。这种分层架构使得各层职责清晰——背景负责氛围,特效负责动感,内容负责业务,弹框负责聚焦交互,互不干扰。
第四十八章:技术对比与设计权衡总表
下面通过一张超过二十行的技术对比表格,从多个维度横向对比本系统中的关键设计选择,帮助读者理解每种选择的利弊与适用场景。
| 对比维度 | 本系统采用方案 | 替代方案 A | 替代方案 B | 本方案优势 | 本方案局限 |
|---|---|---|---|---|---|
| 数据模型装饰器 | @Observed 观测类 | interface 纯接口 | 普通类无装饰 | 响应式刷新自动触发 | 需要构造函数样板代码 |
| 静态展示数据 | interface 字面量 | @Observed 类实例 | 外部 JSON 引入 | 书写简洁无需 new | 不可响应式变更 |
| 色彩管理 | 调色板接口+常量 | 组件内散落色值 | 主题运行时注入 | 集中管理语义清晰 | 换肤需改源码 |
| 特效驱动 | setInterval 90ms | requestAnimationFrame | 纯 CSS 动画 | 实现简单跨平台 | 帧率较低略跳 |
| 特效位置算法 | 模运算伪随机 | Math.random | Perlin 噪声 | 确定性可复现 | 分布可能不均 |
| 特效点击穿透 | HitTestMode.None | z-index 压底层 | pointer-events | 框架原生支持 | 仅适用装饰层 |
| 导航架构 | 两级条件分支 | 路由表驱动 | Tabs 组件 | 逻辑直观易控 | 分支多时冗长 |
| 子导航样式 | 胶囊带计数徽章 | 纯文字 Tab | 图标 Tab | 信息密度高 | 宽度不够需横滚 |
| 进度条实现 | Stack 叠加两 Row | Progress 组件 | 自定义绘制 | 颜色圆角高度可定制 | 代码量略多 |
| 弹框模式 | 单弹框独占互斥 | 多弹框栈式 | 路由式弹框 | 状态简单无需栈管理 | 不能同时多弹框 |
| 弹框复用 | delTarget 字符串区分 | 每场景独立弹框 | 泛型弹框组件 | 一套代码两场景 | 文案硬编码在构建器 |
| 弹框遮罩 | 60% 黑色覆盖 | 模糊滤镜 | 纯白半透明 | 聚焦效果适中 | 背景细节丢失 |
| 按钮文字 | 透明 Button+Text 负 margin | Button 构造函数 | 自定义组件 | 完全自定义样式 | hack 写法不优雅 |
| 双列布局 | if idx%2===0 双渲染 | Grid 组件 | Flex wrap | 轻量无需 Grid | 数据奇数尾项特殊 |
| 数据复用 | latheList 复用多页 | 每页独立数据源 | 全局状态管理 | 减少数据源数量 | 语义不严格 |
| 状态着色 | 纯函数 statusColor | 内联三元 | 颜色映射表 | 集中可测 | 字符串硬匹配 |
| 计量单位 | 字符串保留单位 | 数字+单位拼接 | 强制数字 | 语义完整 | 无法直接计算 |
| 头像资源 | emoji 字符 | 图片 URL | 矢量图标 | 零资源依赖 | 表现力有限 |
| 图表数据 | color 内嵌数据项 | 颜色映射函数 | 主题色循环 | 数据自带视觉 | 颜色不可动态切换 |
| 定时器清理 | aboutToDisappear 清理 | 不清理 | 弱引用自动回收 | 资源释放规范 | 需手动管理 |
| 业务叙事 | 静态数据含工艺知识 | 随机占位文字 | Lorem Ipsum | 演示真实感强 | 数据量大 |
| 表单校验 | 标题非空才发布 | 正则校验 | 异步服务端校验 | 轻量即时 | 校验不充分 |
| 进度归一化 | 纯函数 barH 110px | 百分比直接 | CSS height | 像素级控制 | 与容器高度耦合 |
| 难度分级 | 基础进阶高级三档 | 五星制 | 数字 1-10 | 语义清晰 | 粒度较粗 |
| 课程色彩 | 每课自带 color | 统一主色 | 难度色映射 | 视觉个性强 | 颜色可能冲突 |
第四十九章:系统总结与设计哲学反思
回顾整个"轮语·木工车旋工作室"系统,我们可以从多个层面提炼其设计哲学与工程价值。这套系统虽然以演示为目的,但其架构思想与实现细节对真实的生产级应用同样具有参考意义。下面将从七个维度进行总结性反思。
第一,分层渲染架构的纯粹性。系统用 Stack 把背景、特效、内容、弹框四层严格分离,每层职责单一——背景负责氛围色,特效负责动感但不拦截点击,内容负责业务交互,弹框负责聚焦操作。这种分层通过 hitTestBehavior(HitTestMode.None) 与 zIndex 的组合实现,保证了视觉层叠与交互层叠的解耦。特效层虽然视觉上在底层,但它的"交互不存在"使得内容层的所有点击都能穿透到正确目标,这是分层架构能正常运作的关键。这种纯粹的分层思想,可以迁移到任何需要"装饰性背景+可交互前景"的场景。
第二,数据建模的观测化策略。系统区分了需要响应式的业务实体(PostItem、WoodItem、LatheItem、GearItem,标注 @Observed)与纯展示的静态数据(MonthChartItem、WoodChartItem 等,用 interface)。这种区分避免了对所有数据一刀切地启用观测,减少了无谓的监听开销。观测类的构造函数虽然样板代码略多,但保证了实例的字段完备性,避免了 undefined 引发的渲染异常。静态数据用字面量定义,书写简洁,且明确表达了"不可变"的意图。这种"观测与不观测"的二分法,是响应式框架中数据建模的成熟实践。
第三,纯函数体系的计算集中化。所有特效坐标、透明度、半径、颜色映射、进度映射都抽离为模块顶层纯函数。这种集中化带来了三大好处:组件层简洁(只调用不实现)、可单元测试(纯函数无副作用)、变更集中(改一处影响全局)。特效函数组用模运算实现伪随机,既确定性可复现,又通过质数系数避免周期重合,是低开销实现动效的智慧方案。颜色与进度的映射函数把"业务字符串→视觉参数"的转换集中管理,避免了散落在组件中的内联三元,是大型应用可维护性的关键。
第四,弹框系统的复用与互斥设计。系统采用"单弹框独占"模式,三个布尔状态互斥,任意时刻最多一个弹框打开。这种简化避免了弹框栈管理的复杂性,适合演示场景。删除弹框通过 delTarget 字符串区分目标,实现了一个构建器服务两种删除场景的复用,是"状态驱动渲染"的典范。三个弹框的按钮布局与视觉风格一致(取消浅色+确认主色/danger 色),形成了统一的交互肌肉记忆。弹框遮罩的 onClick 同时关闭三个状态,得益于互斥模式,简洁而有效。
安装DevEco Studio程序

选择目标安装目录:

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

新建一个空白模板:

设置API为24的模板项目:

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

完整代码:
—
第六,响应式数据的多点同步价值。postList 与 latheList 作为 @State 观测数组,被多个页面消费——精选页取前三、木友圈取全部、我的页取前三。当用户发一条轮语或编辑一张车旋单后,所有消费方都会同步刷新,无需手动触发。这种"一处变更,多处同步"是响应式数据的核心价值,避免了命令式 UI 中手动刷新各处列表的繁琐。配合 unshift(头部插入)与 splice(替换/删除)的数组操作,数据变更与界面刷新形成了自动闭环。
第七,演示场景下的合理简化。系统在多处做出了演示级的简化取舍:进度由状态推导而非独立字段、删除只删首项而非选中项、木料仓复用车旋单数据、按钮文字用负 margin hack 而非构造函数、glow 状态虽定义但消费不明显。这些简化在演示场景下是合理的——它们降低了实现复杂度,让核心架构思想得以凸显。但在生产环境中,这些简化都应还原为更严谨的实现:进度应为独立字段、删除应携带目标索引、每页应有独立数据源、按钮应用构造函数、未使用的状态应移除。理解这些简化的边界,才能把演示代码正确地演进为生产代码。
更多推荐




所有评论(0)