榫卯工坊:用鸿蒙 ArkTS 将「一榫一卯,百年不散」的东方木作智慧写进状态管理
榫卯工坊:用鸿蒙 ArkTS 将「一榫一卯,百年不散」的东方木作智慧写进状态管理
一、写在前面:当木匠的凿声遇见代码的编译声
1.1 榫卯:不用一颗钉子的东方智慧
在中国传统木作的世界里,有一句流传千年的老话:“榫卯万年牢”。早在七千年前的河姆渡遗址中,先民们就已经用榫卯结构搭建起了干栏式房屋——没有一颗铁钉,没有一滴胶水,仅仅依靠构件之间凹凸部分的精密咬合,就能让家具、建筑在风雨中挺立数百年。燕尾榫的形如燕尾、粽角榫的三方交汇、霸王枨的斜撑承重……每一种榫卯都凝结着古代匠人对力学、材料与美学的深刻理解。
这份代码的主角,正是一座名为 “榫卯工坊” 的鸿蒙应用。它用原木深棕与米白为基调,试图在数字世界里还原那份属于木作的沉稳与匠心:一榫一卯之间,是结构的严谨;一刀一凿之间,是岁月的包浆。
1.2 从「糖画坊」到「榫卯工坊」:同一套骨架,不同的灵魂
如果你读过本系列的上一篇《糖画坊》,你会发现两份代码在宏观结构上惊人地相似——同样是"类型与常量层 → 数据模型层 → 工具函数层 → 界面层"的四层架构,同样是五个页签、三个弹窗、一组取色函数。但恰恰是这种"同构"让它们成为绝佳的对比教材:架构模板是通用的,但如何在模板之上做出差异化的主题表达,才是真正考验功力之处。
榫卯工坊与糖画坊的核心差异体现在三个地方,这也是贯穿本文的三条暗线:
- 页签元数据多了一个
color字段:糖画坊的页签选中态统一使用全局主色,而榫卯工坊让每个页签携带自己的主题色——榫卯页深棕、工具页暗棕、图样页土金、工坊页木绿、评鉴页赭红。选中谁,谁就亮出自己的颜色。 - 顶栏装饰从"糖珠"变成了"木栅栏":一串交替着琥珀与焦糖色的圆点,变成了一排高低错落的木条,主题叙事的颗粒度细致到了装饰元素。
- 取色函数的维度从"销量/金额"变成了"强度/磨损/产量":数值不再代表钱,而是代表木作工艺的物理属性。
下面,我们将沿着代码的物理书写顺序,把 1193 行代码一字不落地拆开,逐段、逐层、逐函数地讲透。
二、项目总览:四层架构与一张全景图
在进入逐段解析之前,先用一张架构图把整份代码的"骨架"立起来。整份代码可以清晰划分成四个层次:
- 类型与常量层:
SunmaoPalette色板接口、COLORS色板常量、SunmaoTab页签枚举、STabMeta页签元数据(含 color)、七组业务接口、WEEK_OUTPUT/ERA_DIST图表常量。 - 数据模型层:
@Observed修饰的SunmaoData数据类,持有榫卯、工具、图样、工坊、评鉴五组数据。 - 工具函数层:
getJointColor、getToolColor、getPatternColor、getWorkshopColor、getReviewTagColor五个取色映射函数。 - 界面层:
@Entry @Component修饰的主组件SunmaoApp、三个模态弹窗构建器、一个通用标签组件、五个内容子组件。
@Observe -----------------------^ Expecting 'AMP', 'COLON', 'PIPE', 'TESTSTR', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'LINK_ID'
这张图揭示了一个关键事实:界面层的数据全部来自 SunmaoData 单例对象,子组件通过 @ObjectLink 与其建立深度绑定,主组件通过 @State 管理页签和弹窗的可见性。四层各司其职、互不越界,这正是工程可读性的来源,也是"同一套模板换主题"之所以可行的根本原因。
三、逐段代码解析(全量呈现,一字不落)
3.1 主题色板:原木深棕的 SunmaoPalette 与 COLORS
代码的第一段,是定义"榫卯工坊"视觉基调的色板接口与色板常量。原木、深棕、米白,是这份代码想传递的全部气质。
// ============ 榫卯工坊 · 原木深棕 ============
interface SunmaoPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
cardAlt: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
timber: string;
tenon: string;
}
const COLORS: SunmaoPalette = {
primary: '#5D4037',
primaryLight: '#9C7B6A',
primaryDark: '#2E1F17',
accent: '#8D6E63',
accentLight: '#BCAAA4',
bg: '#EFE9E4',
cardBg: '#FFFFFF',
cardAlt: '#E4DAD0',
textPrimary: '#2E1F17',
textSecondary: '#6D5A4A',
textHint: '#A89580',
border: '#D7C9BB',
line: '#E8DCC8',
success: '#5A7D4C',
warning: '#B0893F',
danger: '#8E3B2E',
white: '#FFFFFF',
timber: '#5D4037',
tenon: '#8D6E63'
};
逐点拆解:
第一,用接口定义"色板契约",用常量给出具体色值。SunmaoPalette 接口相当于一份"设计规范清单",把界面里可能用到的所有语义色全部登记在册;COLORS 常量则给出具体色值。后续任何组件里写 .backgroundColor(COLORS.cardAlt),语义一目了然,而不会出现满屏裸色值这种难以维护的局面。这与糖画坊的 SugarPalette 在结构上完全同构,但气质截然不同。
第二,色值本身就在讲木作的故事。#5D4037 是沉稳的深木棕(primary),#2E1F17 是接近墨色的老木色(primaryDark),#EFE9E4 是刨花后的原木米白(bg),#8D6E63 是榫头咬合处的暗棕(accent),#BCAAA4 是木纹在光线下泛起的浅褐(accentLight)。这些颜色不是随机的 HEX 值,而是主题语境的延伸——就像糖画坊的焦糖色一样,颜色在替代码说话。
第三,接口末尾多出两个主题点色:timber(木料)与 tenon(榫头)。虽然在实际代码中它们的值分别与 primary、accent 相同,但语义上是两个独立的设计概念——“木料色"与"榫头色”。这种"语义冗余但概念清晰"的写法在大型设计系统中很常见:宁可多定义几个同值的语义变量,也不要在使用处写错语义。
第四,色板的完整体系:primary/primaryLight/primaryDark 构成深木棕主色梯度,accent/accentLight 是辅助的暖棕强调色,bg/cardBg/cardAlt 负责页面与卡片背景的层次,textPrimary/textSecondary/textHint 控制文字的三级信息密度,success/warning/danger 是状态色(注意这里的 danger 是 #8E3B2E——一种偏赭红的深褐,比糖画坊的 #A8362A 更暗沉,更贴合木作的质感)。最后 timber 与 tenon 呼应主题。
3.2 页签体系:带主题色的 SunmaoTab、STabMeta 与 TAB_LIST
接下来是页签枚举、页签元数据接口与页签列表。这一节是整个榫卯工坊与糖画坊差异化最鲜明的地方。
enum SunmaoTab {
JOINT = 0,
TOOL = 1,
PATTERN = 2,
WORKSHOP = 3,
REVIEW = 4
}
interface STabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: STabMeta[] = [
{ key: 'joint', icon: '🪚', label: '榫卯', color: '#5D4037' },
{ key: 'tool', icon: '🔧', label: '工具', color: '#8D6E63' },
{ key: 'pattern', icon: '📐', label: '图样', color: '#B0893F' },
{ key: 'workshop', icon: '🏭', label: '工坊', color: '#5A7D4C' },
{ key: 'review', icon: '⭐', label: '评鉴', color: '#8E3B2E' }
];
逐点拆解:
其一,enum SunmaoTab 把五个页签的索引语义化。代码里 this.curTab === SunmaoTab.JOINT 比 this.curTab === 0 可读性强得多,而且当页签顺序调整时,枚举名不会跟着错位。这是用枚举替代魔法数字的最大收益。
其二,STabMeta 比糖画坊多了一个 color 字段,这是本节最值得注意的差异。糖画坊的页签元数据只有 key/icon/label 三项,选中态统一用全局 COLORS.primary;而榫卯工坊给每个页签配了专属颜色:榫卯页深棕 #5D4037、工具页暗棕 #8D6E63、图样页土金 #B0893F、工坊页木绿 #5A7D4C、评鉴页赭红 #8E3B2E。
这个设计会带来连锁反应:在底部导航渲染时,选中态不再依赖全局主色,而是直接用 t.color——文字颜色、加粗、背景色、描边全部跟随页签自身颜色变化。“页签即主题” 的效果是:用户切到"图样"页,导航高亮是土金;切到"工坊"页,高亮变成木绿。视觉上更活泼,代码上则演示了"元数据驱动样式"的写法——样式信息也可以作为数据存在数组里。
其三,五种颜色并非随意挑选:深棕是榫卯(木作本体)、暗棕是工具(辅助配件)、土金是图样(设计与图纸)、木绿是工坊(生产与生机)、赭红是评鉴(口碑与热度)。每个颜色都与该页签的业务语义绑定,这是"颜色即语义"的又一例证。
3.3 业务实体接口:五组数据契约
紧随页签体系,定义了五组业务实体的接口。它们是整个应用业务数据的"形状契约"。
interface JointItem {
name: string;
era: string;
strength: number;
use: string;
emoji: string;
}
interface ToolItem {
name: string;
material: string;
wear: number;
owner: string;
emoji: string;
}
interface PatternItem {
name: string;
strokes: number;
era: string;
popular: boolean;
emoji: string;
}
interface WorkshopItem {
name: string;
region: string;
masters: number;
output: number;
emoji: string;
}
interface WorkshopItem {
name: string;
region: string;
masters: number;
output: number;
emoji: string;
}
interface SunmaoReviewItem {
name: string;
score: number;
date: string;
content: string;
tag: string;
emoji: string;
}
interface WeekOutputMeta {
day: string;
value: number;
}
interface EraDistMeta {
name: string;
count: number;
color: string;
}
逐点拆解:
第一,五组业务接口各有侧重,字段设计紧扣业务语义:
JointItem(榫卯):name(名称)、era(年代,如"先秦"“明清”)、strength(强度分,1-100)、use(用途,如"箱屉"“桌角”)、emoji。强度分直接喂给getJointColor取色。ToolItem(工具):name、material(材质,如"红木"“牛角”“钢”)、wear(磨损率百分比)、owner(归属,如"主匠"“学徒”“堂主”)、emoji。磨损率喂给getToolColor。PatternItem(图样):name、strokes(笔数)、era、popular(是否热门,布尔类型)、emoji。popular是五个接口里唯一的布尔字段,它直接决定图样页的取色逻辑——这是后续getPatternColor的输入来源。WorkshopItem(工坊):name、region(地区,如"江南"“华北”“岭南”)、masters(匠师数)、output(月产量)、emoji。月产量喂给getWorkshopColor。SunmaoReviewItem(评鉴):name、score(评分)、date、content、tag、emoji。
第二,接口先行,UI 后行。这些接口既是数据的"形状契约",也决定了列表项 UI 要展示哪些字段——JointItem 有 era 所以在列表里能看到年代标签,ToolItem 有 wear 所以能看到磨损百分比,PatternItem 有 popular 所以能显示"热门/冷门"。数据驱动渲染的基础,就是先把数据形状定义清楚。
第三,图表元数据:WeekOutputMeta(周产出:天 + 数值)与 EraDistMeta(年代分布:名称 + 数量 + 颜色)。注意 EraDistMeta 把 color 直接内嵌在数据里——当颜色属于数据本身的属性时,放在数据里比放在映射函数里更自然,这与糖画坊的 COLOR_SHARE 做法一致。
3.4 图表数据常量:WEEK_OUTPUT 与 ERA_DIST
const WEEK_OUTPUT: WeekOutputMeta[] = [
{ day: '周一', value: 18 },
{ day: '周二', value: 24 },
{ day: '周三', value: 30 },
{ day: '周四', value: 36 },
{ day: '周五', value: 50 },
{ day: '周六', value: 64 },
{ day: '周日', value: 56 }
];
const ERA_DIST: EraDistMeta[] = [
{ name: '先秦', count: 2, color: '#5D4037' },
{ name: '汉唐', count: 3, color: '#8D6E63' },
{ name: '宋元', count: 4, color: '#B0893F' },
{ name: '明清', count: 5, color: '#5A7D4C' },
{ name: '近代', count: 2, color: '#8E3B2E' }
];
逐点拆解:
WEEK_OUTPUT 是一周榫卯产出数据,峰值出现在周六(64 件),与糖画坊的"周六 88 支"异曲同工——都是"周末是旺季"的叙事设定。数据里"周六 64 件"的数字直接决定了页面右上角提示语"周六64件"的来源,柱状图里 value >= 48 的柱子会被高亮为强调色。
ERA_DIST 则是年代分布数据:先秦 2 种、汉唐 3 种、宋元 4 种、明清 5 种、近代 2 种。这条数据自带叙事:明清最多(5 种榫卯),符合"明清家具工艺登峰造极"的历史常识;宋元次之(4 种),也是木作大发展的时期。数据不是乱填的,而是为业务背景服务的。
柱状图的高度直接用 count * 16 计算——数量越多柱子越高,颜色直接用数据里内嵌的 e.color。把图表数据抽成模块级常量而非散落在组件内部,好处是数据与渲染解耦,后续接真实接口时只需替换数据来源。
3.5 数据模型层:@Observed 修饰的 SunmaoData
这是应用的灵魂底层——被观察的核心数据实体,完整包含 12×5 共计 60 条木作业务数据,一字不落。
@Observed
export class SunmaoData {
joints: JointItem[] = [
{ name: '燕尾榫', era: '先秦', strength: 95, use: '箱屉', emoji: '🕊️' },
{ name: '粽角榫', era: '明清', strength: 92, use: '桌角', emoji: '🎯' },
{ name: '格角榫', era: '宋元', strength: 88, use: '框格', emoji: '📐' },
{ name: '揣榫', era: '汉唐', strength: 86, use: '暗藏', emoji: '🕳️' },
{ name: '透榫', era: '先秦', strength: 94, use: '贯通', emoji: '➡️' },
{ name: '楔钉榫', era: '明清', strength: 90, use: '加固', emoji: '📌' },
{ name: '霸王枨', era: '明清', strength: 87, use: '斜撑', emoji: '⚔️' },
{ name: '夹头榫', era: '宋元', strength: 85, use: '夹固', emoji: '🧷' },
{ name: '插肩榫', era: '汉唐', strength: 84, use: '肩承', emoji: '🤝' },
{ name: '勾挂榫', era: '明清', strength: 89, use: '互锁', emoji: '🪝' },
{ name: '大进小出榫', era: '宋元', strength: 83, use: '变截面', emoji: '⏩' },
{ name: '老匠独门榫', era: '近代', strength: 96, use: '秘制', emoji: '📜' }
];
tools: ToolItem[] = [
{ name: '鲁班尺', material: '红木', wear: 20, owner: '主匠', emoji: '📏' },
{ name: '墨斗', material: '牛角', wear: 35, owner: '学徒', emoji: '🧵' },
{ name: '凿子', material: '钢', wear: 50, owner: '主匠', emoji: '🪚' },
{ name: '刨子', material: '铁木', wear: 40, owner: '学徒', emoji: '🪵' },
{ name: '锯子', material: '钢', wear: 60, owner: '主匠', emoji: '🦷' },
{ name: '墨线', material: '麻', wear: 70, owner: '学徒', emoji: '📐' },
{ name: '木锤', material: '檀木', wear: 25, owner: '主匠', emoji: '🔨' },
{ name: '刨刃', material: '钢', wear: 55, owner: '学徒', emoji: '🔪' },
{ name: '角尺', material: '铜', wear: 15, owner: '主匠', emoji: '📐' },
{ name: '划线笔', material: '竹', wear: 80, owner: '学徒', emoji: '✏️' },
{ name: '夹具', material: '铁', wear: 30, owner: '主匠', emoji: '🗜️' },
{ name: '老匠传家斧', material: '钢', wear: 45, owner: '堂主', emoji: '🪓' }
];
patterns: PatternItem[] = [
{ name: '万字纹', strokes: 24, era: '明清', popular: true, emoji: '🀦' },
{ name: '回字纹', strokes: 18, era: '汉唐', popular: true, emoji: '🌀' },
{ name: '云雷纹', strokes: 32, era: '先秦', popular: false, emoji: '☁️' },
{ name: '冰裂纹', strokes: 20, era: '宋元', popular: true, emoji: '❄️' },
{ name: '如意纹', strokes: 16, era: '明清', popular: true, emoji: '🍀' },
{ name: '卷草纹', strokes: 28, era: '汉唐', popular: false, emoji: '🌿' },
{ name: '缠枝莲', strokes: 36, era: '宋元', popular: true, emoji: '🌸' },
{ name: '龟背纹', strokes: 22, era: '明清', popular: false, emoji: '🐢' },
{ name: '锁子甲纹', strokes: 40, era: '先秦', popular: false, emoji: '🛡️' },
{ name: '宝相花', strokes: 30, era: '汉唐', popular: true, emoji: '🌺' },
{ name: '灯笼锦', strokes: 26, era: '明清', popular: true, emoji: '🏮' },
{ name: '老匠秘纹', strokes: 48, era: '近代', popular: false, emoji: '📜' }
];
workshops: WorkshopItem[] = [
{ name: '苏作工坊', region: '江南', masters: 12, output: 240, emoji: '🏛️' },
{ name: '京作工坊', region: '华北', masters: 15, output: 320, emoji: '🏯' },
{ name: '广作工坊', region: '岭南', masters: 10, output: 280, emoji: '🏖️' },
{ name: '晋作工坊', region: '三晋', masters: 8, output: 180, emoji: '⛰️' },
{ name: '宁作工坊', region: '宁波', masters: 14, output: 360, emoji: '🌊' },
{ name: '仙作工坊', region: '闽中', masters: 11, output: 300, emoji: '🍵' },
{ name: '京珐琅作', region: '京华', masters: 6, output: 120, emoji: '✨' },
{ name: '海外汉作', region: '海外', masters: 5, output: 90, emoji: '🌏' },
{ name: '非遗课堂', region: '多地', masters: 20, output: 480, emoji: '📖' },
{ name: '影视道具作', region: '横店', masters: 7, output: 150, emoji: '🎬' },
{ name: '老字号木作', region: '江南', masters: 9, output: 220, emoji: '🏪' },
{ name: '定制传家作', region: '京华', masters: 4, output: 60, emoji: '🎩' }
];
reviews: SunmaoReviewItem[] = [
{ name: '木作藏家', score: 5, date: '09-04', content: '燕尾榫严丝合缝,百年不开。', tag: '严丝合缝', emoji: '🧓' },
{ name: '古建修复师', score: 5, date: '09-03', content: '粽角榫复刻宋式梁架,一榫定乾坤。', tag: '一榫定乾', emoji: '🏛️' },
{ name: '家具设计师', score: 4, date: '09-02', content: '格角榫极简,盼出大号套件。', tag: '极简可期', emoji: '📐' },
{ name: '研学导师', score: 5, date: '09-01', content: '孩子拆装到半夜,难度递进合理。', tag: '难度递进', emoji: '🎒' },
{ name: '茶馆掌柜', score: 5, date: '08-31', content: '霸王枨茶桌稳如泰山,客赞不绝。', tag: '稳如泰山', emoji: '🍵' },
{ name: '影视道具', score: 5, date: '08-30', content: '剧组连拍三部戏,榫件一点没散。', tag: '结实耐用', emoji: '🎬' },
{ name: '海外汉友', score: 5, date: '08-29', content: '海运两月无损,木色反而更润。', tag: '远渡无损', emoji: '🌏' },
{ name: '老字号学徒', score: 4, date: '08-28', content: '老匠独门榫地道,盼出图纸册。', tag: '盼出图纸', emoji: '🏪' },
{ name: '定制藏家', score: 5, date: '08-27', content: '传家作传代不散,子孙可承。', tag: '传代可承', emoji: '🎩' },
{ name: '非遗课堂', score: 5, date: '08-26', content: '榫卯成非遗标配教具,学生抢着学。', tag: '课堂标配', emoji: '📖' },
{ name: '文创买手', score: 4, date: '08-25', content: '冰裂纹极美,盼出小号便携款。', tag: '盼出小号', emoji: '❄️' },
{ name: '京华主顾', score: 5, date: '08-24', content: '京作大气,摆在堂屋镇宅添雅。', tag: '镇宅添雅', emoji: '🏯' }
];
}
逐点拆解:
第一,@Observed 与 @ObjectLink 的深度绑定机制。@Observed 装饰器让 SunmaoData 成为可观察对象:当它的属性(比如 joints 数组)被修改时,所有通过 @ObjectLink 绑定它的子组件都会收到通知并自动重绘。主组件持有 @State data: SunmaoData = new SunmaoData(),然后以 SunmaoJointContent({ data: this.data, ... }) 的形式传给五个子组件,子组件内部用 @ObjectLink data: SunmaoData 接收。@State 保证数据在组件树顶层存活,@ObjectLink 保证子组件对数据的修改能"反向通知"其他观察者。单一数据源、多端同步,这是 ArkUI 状态管理的核心心法。
第二,数据字段的取值区间,决定了取色函数的分档是否"看得见差异"。仔细看:
- 榫卯的
strength从 83(大进小出榫)到 96(老匠独门榫)分布均匀,getJointColor的四档(≥95 / ≥90 / ≥86 / 其余)每一档都能命中至少两条数据; - 工具的
wear从 15(角尺)到 80(划线笔)横跨全档位,视觉上能看出"新旧工具混用"的合理状态——角尺还是九成新的铜器,划线笔已经磨损到 80% 快报废了; - 图样的
popular布尔值,12 条数据恰好 6 热门 6 冷门,画面色彩均衡; - 工坊的
output从 60(定制传家作)到 480(非遗课堂),同样覆盖了取色函数的全部档位。
这说明好的示例数据不是随便填的,而是为视觉呈现服务的——每个档位都要有数据命中,取色函数才有意义。
第三,数据里刻意埋了"故事线":燕尾榫 95 分是先秦活化石,老匠独门榫 96 分是近代秘传;非遗课堂月产 480 件是最繁忙的工坊,定制传家作只有 60 件却是"传代不散"的高端定制;评鉴里"镇宅添雅""一榫定乾"这类标签,又会在取色函数里被映射成不同色阶。数据与函数、UI 三者环环相扣。
3.6 工具函数层:五种取色映射策略
function getJointColor(strength: number): string {
if (strength >= 95) {
return '#8E3B2E';
} else if (strength >= 90) {
return '#B0893F';
} else if (strength >= 86) {
return '#8D6E63';
}
return '#5A7D4C';
}
function getToolColor(wear: number): string {
if (wear >= 60) {
return '#8E3B2E';
} else if (wear >= 40) {
return '#B0893F';
} else if (wear >= 25) {
return '#8D6E63';
}
return '#5A7D4C';
}
function getPatternColor(popular: boolean): string {
if (popular) {
return '#B0893F';
}
return '#8D6E63';
}
function getWorkshopColor(output: number): string {
if (output >= 300) {
return '#5A7D4C';
} else if (output >= 200) {
return '#8D6E63';
} else if (output >= 120) {
return '#B0893F';
}
return '#6D5A4A';
}
function getReviewTagColor(tag: string): string {
if (tag === '严丝合缝' || tag === '远渡无损' || tag === '传代可承' || tag === '镇宅添雅') {
return '#8D6E63';
} else if (tag === '一榫定乾' || tag === '稳如泰山' || tag === '结实耐用' || tag === '课堂标配') {
return '#B0893F';
} else if (tag === '极简可期' || tag === '难度递进' || tag === '盼出图纸' || tag === '盼出小号') {
return '#5A7D4C';
}
return '#6D5A4A';
}
逐点拆解:
这五个函数是"数值/文本 → 主题色"的映射器,集中体现了阈值分级、布尔二分与白名单分级三种映射策略。
第一,getJointColor 与 getToolColor 是同构的阈值分级,只是输入维度不同(强度 vs 磨损率)——强度越高颜色越"重"(赭红 > 土金 > 暗棕 > 木绿),磨损越高同样越"重"。这种"越高越深"的视觉隐喻统一了全应用的语义:深色 = 高数值。列表里扫一眼颜色,就知道哪件榫卯强度最高、哪件工具磨损最重。
第二,getPatternColor 是布尔二分:热门图样土金 #B0893F、冷门图样暗棕 #8D6E63。它是五个函数里最简单的一个,也最直接——热门与否没有中间态,颜色自然只有两档。注意它接收的是布尔值而非数字,这演示了取色函数的输入可以灵活适配业务字段类型。
第三,getWorkshopColor 则是反向分级的妙用:产量越高颜色越"轻"(木绿 > 暗棕 > 土金 > 深褐),与前三者恰好相反。这并非笔误,而是因为"产出高"是正面信号,用代表生机的绿色表达更贴切。取色函数的方向(越高越深 or 越高越亮)本身就是一种语义选择——强度高是"珍贵",用深色;产量高是"繁荣",用亮色。这是本系列代码里最值得玩味的设计细节之一。
第四,getReviewTagColor 是白名单分级:把评鉴标签按语义归类到固定色阶——“严丝合缝”“远渡无损”“传代可承”"镇宅添雅"是品质类赞誉,归暗棕;“一榫定乾”“稳如泰山”“结实耐用”"课堂标配"是功能类赞誉,归土金;“极简可期”“难度递进”“盼出图纸”"盼出小号"是期待类反馈,归木绿。白名单写法虽然不通用(每加一个标签都要改函数),但在数据量固定、语义明确的场景下反而最可控——颜色由"业务含义"决定,而不是由数值大小决定。
第五,把取色逻辑收敛成独立函数的隐性收益:颜色计算只发生一次、只在一处,后续如果主题要换色系(比如从原木棕改成青竹色),只需改函数返回值,全应用联动。
3.7 主组件 SunmaoApp:状态中枢与三大弹窗构建器
@Entry
@Component
struct SunmaoApp {
@State curTab: number = 0;
@State data: SunmaoData = new SunmaoData();
@State showAddJoint: boolean = false;
@State showDeleteTool: boolean = false;
@State showEditPattern: boolean = false;
@State delToolName: string = '';
@State editPatternName: string = '';
@State chiselSlide: boolean = false;
@State woodGrain: boolean = false;
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => { onClose(); })
}
逐点拆解:
SunmaoApp 是整个应用的状态中枢,@State 变量可以分成三组:
- 导航状态:
curTab记录当前页签索引,驱动内容区条件渲染; - 弹窗状态:
showAddJoint / showDeleteTool / showEditPattern三个布尔值控制弹窗显隐,delToolName / editPatternName记录"要退役的工具名"“要改定的图样名”,让弹窗知道自己在操作谁; - 动画状态:
chiselSlide / woodGrain控制顶栏凿子的位移与木纹的明暗。
这种"状态分组"的习惯很重要:把互不相关的状态拆开,各自独立更新,才能避免一个状态变化触发整棵组件树重绘。
接着看 modalOverlay——这是一个参数化 @Builder:它接受一个"关闭回调",渲染一个半透明黑色全屏蒙层,点击蒙层即触发回调。注意回调由调用方注入,所以遮罩组件本身完全不关心"关闭后要做什么",只负责"点击了我就要通知你"。这是控制反转在 UI 构建中的体现。遮罩色 #66000000 是约 40% 不透明度的纯黑,这是移动端弹窗遮罩的经典取值。
下面三个弹窗构建器,我们逐个完整呈现并解析。
3.7.1 弹窗一:addJointModal(新制榫卯)
@Builder addJointModal() {
Stack() {
this.modalOverlay(() => { this.showAddJoint = false; })
Column() {
Row() {
Text('🪚 新制榫卯')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => { this.showAddJoint = false; })
}
.width('100%')
Row() {
Text('品名')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('暗销暗榫')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('年代')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('明清 · 桌案暗藏')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('强度')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('★★★★ · 91 分')
.fontSize(11)
.fontColor(getJointColor(91))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('用途')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('暗藏加固')
.fontSize(11)
.fontColor(getJointColor(91))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.border({ width: 1, color: COLORS.border })
.onClick(() => { this.showAddJoint = false; })
Text('确认制榫')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.margin({ left: 10 })
.onClick(() => { this.showAddJoint = false; })
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 14 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
逐点拆解:
addJointModal 是"新制榫卯"的录入确认弹窗,结构分为四层:
第一层是遮罩:this.modalOverlay(() => { this.showAddJoint = false; }) 注入关闭回调,点击蒙层关闭弹窗。
第二层是卡片容器:宽度 86%、白底、圆角 18、constraintSize({ maxHeight: '80%' }) 限制最大高度,配合遮罩构成经典的居中弹窗形态。constraintSize 是鸿蒙特有的尺寸约束写法,防止内容过长时溢出屏幕。
第三层是内容区:标题行用 Column().layoutWeight(1) 作为弹性占位把标题和关闭按钮推到两端;四行信息行(品名、年代、强度、用途)采用"标签 + 值"的双列布局,标签固定宽度 56、值随内容自适应。注意强度行的值 '★★★★ · 91 分' 用 getJointColor(91) 着色——弹窗内的展示数据也会复用映射函数,91 分落在 ≥90 档,显示土金 #B0893F。这证明取色函数在弹窗与列表之间是共享的,同一套业务规则贯穿全应用。
第四层是操作区:底部操作行用 FlexAlign.End 右对齐,主次按钮通过实底(确认制榫,COLORS.primary)与描边(再想想,COLORS.border)区分权重。两个按钮的 onClick 都只是把 showAddJoint 置为 false——示例代码里弹窗是纯展示的,真正的新增逻辑留待接入真实数据时补全,这是示例代码"演示交互形态"的正常取舍。
3.7.2 弹窗二:deleteToolModal(退役工具)
@Builder deleteToolModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteTool = false; })
Column() {
Text('🗑️ 退役工具')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('确定让「' + this.delToolName + '」退役吗?')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 10 })
Text('退役的工具将从工具箱移除。')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
Row() {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.border({ width: 1, color: COLORS.border })
.onClick(() => { this.showDeleteTool = false; })
Text('确认退役')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.danger)
.borderRadius(14)
.margin({ left: 10 })
.onClick(() => { this.showDeleteTool = false; })
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 16 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '78%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
逐点拆解:
deleteToolModal 是确认型弹窗,比 addJointModal 更简洁——没有信息行,只有标题 + 两段提示文字 + 双按钮。
第一,动态文案:Text('确定让「' + this.delToolName + '」退役吗?') 把 delToolName(由列表项点击时通过回调上抛赋值)拼进提示语,弹窗因此知道自己在问"哪件工具"。这是弹窗"知道操作对象"的关键——delToolName 是主组件持有、由子组件写入的 @State 中转变量。
第二,危险操作的视觉强调:确认按钮用 COLORS.danger(赭红 #8E3B2E)实底,与新增弹窗的主色实底形成对比——删除类操作用警示色、新增类操作用品牌色,这是移动端交互设计的安全惯例。
第三,文案的温润感:“确认让「XX」退役吗"而非生硬的"确认删除吗”——"退役"是木作行当里对老旧工具的敬语,与主题语境完全贴合。文案也是主题的一部分,这是本系列代码在细节上的一贯追求。
3.7.3 弹窗三:editPatternModal(改定图样)
@Builder editPatternModal() {
Stack() {
this.modalOverlay(() => { this.showEditPattern = false; })
Column() {
Row() {
Text('📐 改定图样')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => { this.showEditPattern = false; })
}
.width('100%')
Row() {
Text('图样')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text(this.editPatternName)
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('原笔数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('24 笔')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('新笔数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('24 → 32 笔')
.fontSize(11)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('年代')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('明清 → 宋元')
.fontSize(11)
.fontColor(getPatternColor(true))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.border({ width: 1, color: COLORS.border })
.onClick(() => { this.showEditPattern = false; })
Text('保存改定')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.margin({ left: 10 })
.onClick(() => { this.showEditPattern = false; })
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 14 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
逐点拆解:
editPatternModal 是"改定图样"的变更对比弹窗,最大特点是用"原 → 新"的箭头展示变更:
- 第一行"图样"显示
this.editPatternName(由列表点击上抛的动态值); - 第二行"原笔数"显示
'24 笔'(旧值); - 第三行"新笔数"显示
'24 → 32 笔',且用COLORS.accent(琥珀金)高亮——变更后的新值用强调色标注,引导用户视线聚焦"将要发生的变化"; - 第四行"年代"显示
'明清 → 宋元',值用getPatternColor(true)着色(热门土金)——复用了布尔取色函数,说明这个字段对应的图样将被标记为热门。
三个弹窗的结构高度同构——都是"标题 + 信息行 + 双按钮",只是文案与字段不同。作者没有把它们压成一个泛化组件,而是各自独立成 @Builder。这个取舍在示例代码里是可接受的:可读性优先于 DRY 原则,三个弹窗各自独立、一目了然,也方便单独调整某个弹窗的布局。
3.8 顶栏 topBar:木栅栏装饰与凿子位移动画
@Builder topBar() {
Column() {
Row() {
Column() {
Text('🪚 榫卯工坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Sunmao · 一榫一卯 百年不散')
.fontSize(10)
.fontColor(COLORS.accentLight)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('🪚')
.fontSize(18)
.translate({ x: this.chiselSlide ? 10 : -4, y: this.chiselSlide ? 2 : 6 })
.animation({ duration: 600, curve: Curve.EaseOut })
.onClick(() => { this.chiselSlide = !this.chiselSlide; })
Text('🪵')
.fontSize(16)
.margin({ left: 10 })
.opacity(this.woodGrain ? 1.0 : 0.45)
.animation({ duration: 500, curve: Curve.EaseOut })
.onClick(() => { this.woodGrain = !this.woodGrain; })
Text('📏')
.fontSize(14)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.accent + '2E')
.borderRadius(16)
.border({ width: 1, color: COLORS.accent + '80' })
}
.width('100%')
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (r: number) => {
Column()
.width(6)
.height(18)
.backgroundColor(r % 2 === 0 ? COLORS.accent : COLORS.primaryLight)
.borderRadius(3)
}, (r: number) => 'gr' + r)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 12 })
Row() {
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.accent + '66')
Text('🪵 选材凿卯 · 百年不散 🪚')
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ left: 8, right: 8 })
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.accent + '66')
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.primaryDark)
}
逐点拆解:
topBar 是"深色底 + 三行结构"的顶栏:第一行是标题与交互小图标,第二行是装饰条,第三行是"线—标语—线"的分割组件。
第一,木栅栏装饰条:与糖画坊的 8 个圆点不同,这里是 10 根宽 6 高 18 的竖条,交替使用 COLORS.accent 与 COLORS.primaryLight,圆角 3——一眼望去像一排木栅栏,贴合木作主题。装饰元素也为主题叙事服务,这是本系列代码一贯的细节追求。
第二,凿子的位移动画:Text('🪚').translate({ x: this.chiselSlide ? 10 : -4, y: this.chiselSlide ? 2 : 6 })——点击后在两个位移点之间滑动,模拟"凿子滑入滑出";配合 600ms 的 Curve.EaseOut 缓出曲线,形成自然的入位感。与糖画坊的 scale(缩放)、opacity(明暗)相比,这里多了一种动画维度:位移。translate 与 scale、opacity、rotate 一起,构成了 ArkUI 属性动画的四大基础变换。
第三,木纹的明暗动画:Text('🪵').opacity(this.woodGrain ? 1.0 : 0.45)——用透明度模拟"木料光泽变化",500ms 缓出。
第四,透明度的十六进制拼接:.backgroundColor(COLORS.accent + '2E')——'#8D6E63' + '2E' 得到 8 位色值,末尾两位 2E(十进制 46)是约 18% 的透明度。这是本系列代码里反复使用的小技巧:带透明度的十六进制拼接,无需额外定义半透明色。
第五,分割标语:“🪵 选材凿卯 · 百年不散 🪚”,两侧各一条 layoutWeight(1) 的细线,形成居中对称的装饰。标语与主题呼应——"选材凿卯"是工序,"百年不散"是信念。
3.9 主构建函数 build():页签切换、底部导航与弹窗挂载
build() {
Column() {
this.topBar()
if (this.curTab === SunmaoTab.JOINT) {
SunmaoJointContent({ data: this.data, onAdd: () => { this.showAddJoint = true; } })
} else if (this.curTab === SunmaoTab.TOOL) {
SunmaoToolContent({ data: this.data, onDel: (n: string) => {
this.delToolName = n;
this.showDeleteTool = true;
} })
} else if (this.curTab === SunmaoTab.PATTERN) {
SunmaoPatternContent({ data: this.data, onEdit: (n: string) => {
this.editPatternName = n;
this.showEditPattern = true;
} })
} else if (this.curTab === SunmaoTab.WORKSHOP) {
SunmaoWorkshopContent({ data: this.data })
} else {
SunmaoReviewContent({ data: this.data })
}
Column()
.width('100%')
.height(3)
.backgroundColor(COLORS.primary)
Row() {
ForEach(TAB_LIST, (t: STabMeta, idx: number) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === idx ? t.color : COLORS.textHint)
.fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.curTab === idx ? t.color + '14' : '#00000000')
.border(this.curTab === idx ? { width: 1, color: t.color } : { width: 0 })
.borderRadius(14)
.onClick(() => { this.curTab = idx; })
}, (t: STabMeta) => t.key)
}
.width('100%')
.height(60)
.padding({ left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
if (this.showAddJoint) {
this.addJointModal()
}
if (this.showDeleteTool) {
this.deleteToolModal()
}
if (this.showEditPattern) {
this.editPatternModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}
逐点拆解:
build() 的布局是标准的"三段式":顶部顶栏、中间内容区、底部导航栏,弹窗以覆盖层形式挂在最外层 Column 之后。
第一,内容区的条件渲染。if / else if 链把 curTab 映射到五个子组件,切换页签就是改一个 @State,框架自动销毁旧组件、创建新组件。注意回调参数的传递方式——SunmaoJointContent 收到 onAdd、SunmaoToolContent 收到 onDel(n)、SunmaoPatternContent 收到 onEdit(n),统一通过"回调上抛"的方式把子组件的操作意图传回主组件。子组件不直接改弹窗状态,只喊"我要制榫/我要退役/我要改样",具体怎么弹窗由父组件决定,组件间通信方向保持单向清晰。
第二,底部导航的双轨选中态。与前一篇糖画坊相比,这里的选中态反馈多了一重——描边:
- 文字颜色:选中
t.color,未选中COLORS.textHint; - 字体加粗:选中 Bold,未选中 Normal;
- 背景色:选中
t.color + '14'(约 8% 透明度的页签自身色),未选中'#00000000'(全透明); - 描边:选中
{ width: 1, color: t.color },未选中{ width: 0 }(宽度为 0 视觉上完全隐藏)。
三重反馈让"选中"的边界更加清晰。更关键的是,所有颜色都来自 t.color——“页签即主题”:切到"图样"页高亮是土金,切到"工坊"页高亮是木绿。这是与糖画坊最显著的差异化设计。
第三,弹窗按需挂载。if (this.showAddJoint) { this.addJointModal() } 等三段——只有在对应布尔为 true 时才把弹窗加入渲染树,未打开的弹窗不消耗任何渲染内存,这是"延迟就绪"的性能优化思想。
3.10 通用标签组件 SunmaoTag
@Component
struct SunmaoTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(9)
.border({ width: 1, color: this.color + '40' })
}
}
逐点拆解:
SunmaoTag 是一个 10 行左右的迷你胶囊标签,与糖画坊的 SugarTag 完全同构(只是名字不同),印证了"通用胶囊标签"在这套模板里的可复用地位:
- 文字用
@Prop传入,颜色由调用方指定; - 背景用
this.color + '1F'(约 12% 透明度)——同色系浅底; - 边框用
this.color + '40'(约 25% 透明度)——同色系中透明描边。
@Prop 与 @State 的区别在这里体现得很清楚:@Prop 是单向传递的只读属性,父组件改值后子组件同步更新,但子组件内部改它不会反向影响父组件,适合这种"纯展示"型小组件。this.color + '1F' 这种透明度拼色的写法让标签永远与传入色保持同色系,不用额外计算浅色版本。
3.11 内容子组件一:SunmaoJointContent(榫卯谱)
@Component
struct SunmaoJointContent {
@ObjectLink data: SunmaoData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 本周榫卯产出')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六64件')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_OUTPUT, (w: WeekOutputMeta) => {
Column() {
Text(w.value + '')
.fontSize(8)
.fontColor(w.value >= 48 ? COLORS.accent : COLORS.primaryLight)
Column()
.width(20)
.height(w.value)
.backgroundColor(w.value >= 48 ? COLORS.accent : COLORS.primaryLight)
.borderRadius(10)
.margin({ top: 4 })
Text(w.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (w: WeekOutputMeta) => w.day)
}
.width('100%')
.height(150)
.alignItems(VerticalAlign.Bottom)
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.border({ width: 1, color: COLORS.accent + '55' })
Row() {
Text('🪚 榫卯谱')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('点击➕制榫')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('➕')
.fontSize(14)
.margin({ left: 6 })
.onClick(() => { this.onAdd(); })
}
.width('100%')
.margin({ top: 14, bottom: 4 })
ForEach(this.data.joints, (j: JointItem, i: number) => {
Row() {
Column()
.width(4)
.height(56)
.backgroundColor(getJointColor(j.strength))
.borderRadius(2)
Column() {
Row() {
Text(j.emoji)
.fontSize(16)
Text(j.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 8 })
Text(j.era)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
Row() {
SunmaoTag({ text: j.use, color: getJointColor(j.strength) })
SunmaoTag({ text: '强度' + j.strength, color: COLORS.primaryLight })
}
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
}
.width('100%')
.padding({ top: 10, bottom: 10, left: 8, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (j: JointItem, i: number) => 'jt' + j.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
逐点拆解:
SunmaoJointContent 的结构是"统计卡片 + 列表"的复合体,拆开看有三层:
第一层是柱状图卡片。没有引入任何图表库,纯用 ForEach 生成 7 根柱子:每根柱子是一个 Column,内部自上而下是数值文字、矩形色块(宽度固定 20、高度等于产出值)、星期文字。整行 height(150) 加 alignItems(VerticalAlign.Bottom),让所有柱子底部对齐,形成天然坐标系。产出 ≥48 的柱子用强调色(周五 50、周六 64、周日 56 三根被高亮),其余用浅色,峰值一目了然——"周六 64 件"的数字和页头提示语互相印证。数据、颜色、文案三者互相印证是这套代码的固定叙事手法。
第二层是列表头。标题 + 弹性占位 + 提示语 + 加号按钮,onClick 直接调用父组件传入的 this.onAdd() 回调。点击加号,弹窗由主组件打开,子组件只管"报告事件"。
第三层是列表项。与糖画坊的"圆形色块"不同,榫卯列表项是左侧一条 4px 宽的色条——Column().width(4).height(56).backgroundColor(getJointColor(j.strength)),像一根立起的榫条,色条颜色即强度档位(燕尾榫 95 赭红、老匠独门榫 96 赭红、粽角榫 92 土金……)。右侧是"emoji + 名称 + 年代"一行、“用途标签 + 强度标签"一行的复合结构。用细色条替代圆形象征块,是这套代码在列表视觉上的第一次差异化——色条更克制、更"木作”。
列表行背景用 i % 2 === 0 做斑马纹——偶数列白底、奇数列浅米底(COLORS.cardAlt),缓解长列表的阅读疲劳。ForEach 的第三个参数是键值生成器,'jt' + j.name + i 保证每条数据有稳定身份,是列表复用与 diff 的基础。
3.12 内容子组件二:SunmaoToolContent(工具箱)
@Component
struct SunmaoToolContent {
@ObjectLink data: SunmaoData;
onDel: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Text('📊 年代分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row() {
ForEach(ERA_DIST, (e: EraDistMeta) => {
Column() {
Text(e.count + '')
.fontSize(10)
.fontColor(e.color)
Column()
.width(28)
.height(e.count * 16)
.backgroundColor(e.color)
.borderRadius({ topLeft: 4, topRight: 4 })
.margin({ top: 4 })
Text(e.name)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (e: EraDistMeta) => e.name)
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.border({ width: 1, color: COLORS.accent + '55' })
Row() {
Text('🔧 工具箱')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('点击🗑️退役')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ top: 14, bottom: 4 })
ForEach(this.data.tools, (t: ToolItem, i: number) => {
Row() {
Column()
.width(4)
.height(48)
.backgroundColor(getToolColor(t.wear))
.borderRadius(2)
Column() {
Text(t.emoji)
.fontSize(18)
}
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(getToolColor(t.wear) + '22')
.borderRadius(21)
.border({ width: 1, color: getToolColor(t.wear) + '55' })
.margin({ left: 8 })
Column() {
Row() {
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(t.material)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
Text('🗑️')
.fontSize(12)
.margin({ left: 8 })
.onClick(() => { this.onDel(t.name); })
}
.width('100%')
Row() {
Text('磨损' + t.wear + '% · ' + t.owner)
.fontSize(9)
.fontColor(getToolColor(t.wear))
Text('工具')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 6, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (t: ToolItem, i: number) => 'tl' + t.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
逐点拆解:
SunmaoToolContent 的头部卡片换成了年代分布柱状图,列表则是工具条目。
第一,年代分布柱状图的特别之处:
- 柱子高度是
count * 16——数量 × 系数 = 像素高度,五根柱子(2/3/4/5/2)形成"低高低"的分布形态,明清 5 最高; - 颜色直接用数据里内嵌的
e.color——先秦深棕、汉唐暗棕、宋元土金、明清木绿、近代赭红,与页签五色体系一脉相承; - 顶部仅上圆角(
borderRadius({ topLeft: 4, topRight: 4 })),模拟统计柱状图的经典形态,底部与水平线平齐; - 整行
alignItems(VerticalAlign.Bottom)保证柱子底部对齐。
第二,列表项的三段式结构:左侧 4px 色条(磨损档位色)+ 圆形图标容器(emoji + 同色系低透明底 + 同色系描边)+ 右侧信息列(名称材质删除键一行、磨损率归属一行)。比榫卯页多了一个圆形图标层,信息更密集。
第三,删除回调上抛:点击 🗑️ 图标调用 this.onDel(t.name),把工具名上抛给主组件,主组件写入 delToolName 并打开 deleteToolModal。子组件不直接删数据,只报告"我要退役这件工具"——单向数据流的严格执行。
3.13 内容子组件三:SunmaoPatternContent(图样册)
@Component
struct SunmaoPatternContent {
@ObjectLink data: SunmaoData;
onEdit: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('📐 图样册')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('点击✏️改样')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ bottom: 6 })
ForEach(this.data.patterns, (p: PatternItem, i: number) => {
Row() {
Column()
.width(4)
.height(48)
.backgroundColor(getPatternColor(p.popular))
.borderRadius(2)
Column() {
Text(p.emoji)
.fontSize(18)
}
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(getPatternColor(p.popular) + '22')
.borderRadius(21)
.border({ width: 1, color: getPatternColor(p.popular) + '55' })
.margin({ left: 8 })
Column() {
Row() {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(p.strokes + '笔')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
Text('✏️')
.fontSize(12)
.margin({ left: 8 })
.onClick(() => { this.onEdit(p.name); })
}
.width('100%')
Row() {
Text(p.era + (p.popular ? ' · 热门' : ' · 冷门'))
.fontSize(9)
.fontColor(getPatternColor(p.popular))
Text('图样')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 6, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (p: PatternItem, i: number) => 'pt' + p.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
逐点拆解:
SunmaoPatternContent 没有统计卡片,直接铺满 12 条图样记录,结构与工具页高度相似,但有两个差异点值得注意:
第一,状态文字的动态拼接:Text(p.era + (p.popular ? ' · 热门' : ' · 冷门'))——用三元表达式把年代和热门状态拼成一行文案,比如"明清 · 热门"“先秦 · 冷门”。同时这行文字的取色用的是 getPatternColor(p.popular)——热门土金、冷门暗棕,文字颜色与状态语义绑定。
第二,编辑回调上抛:点击 ✏️ 图标调用 this.onEdit(p.name),把图样名上抛给主组件,主组件写入 editPatternName 并打开 editPatternModal。色条、圆形图标、名称笔数、状态行,与工具页是"同一套骨架换字段"——再次印证模板化构建的高效。
3.14 内容子组件四:SunmaoWorkshopContent(各地工坊)
@Component
struct SunmaoWorkshopContent {
@ObjectLink data: SunmaoData;
build() {
Scroll() {
Column() {
Row() {
Text('🏭 各地工坊')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('按产出排序')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ bottom: 6 })
ForEach(this.data.workshops, (w: WorkshopItem, i: number) => {
Row() {
Column()
.width(4)
.height(48)
.backgroundColor(getWorkshopColor(w.output))
.borderRadius(2)
Column() {
Text(w.emoji)
.fontSize(16)
}
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(getWorkshopColor(w.output) + '22')
.borderRadius(20)
.border({ width: 1, color: getWorkshopColor(w.output) + '55' })
.margin({ left: 8 })
Column() {
Row() {
Text(w.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(w.region)
.fontSize(9)
.fontColor(getWorkshopColor(w.output))
.margin({ left: 8 })
}
.width('100%')
Row() {
Text(w.masters + ' 位匠师 · 月产' + w.output + ' 件')
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text('工坊')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 6, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (w: WorkshopItem, i: number) => 'ws' + w.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
逐点拆解:
SunmaoWorkshopContent 是最纯粹的列表页之一(没有统计卡片、没有操作按钮),但有两点值得注意:
第一,反向取色的直观呈现:工坊条目按 getWorkshopColor(w.output) 着色——非遗课堂(480 件)木绿、宁作工坊(360 件)木绿、京作工坊(320 件)木绿、仙作工坊(300 件)木绿,而定制传家作(60 件)深褐、海外汉作(90 件)深褐、京珐琅作(120 件)土金。产量越高颜色越"绿",一眼扫过就能看出哪些工坊最繁忙。这与榫卯/工具页"越高越深"形成鲜明对比,是"反向分级"语义的完美展示。
第二,信息行文案:w.masters + ' 位匠师 · 月产' + w.output + ' 件'——"12 位匠师 · 月产240 件"这种拼串文案,把两个业务字段自然融合成一句话。
3.15 内容子组件五:SunmaoReviewContent(评鉴口碑)
@Component
struct SunmaoReviewContent {
@ObjectLink data: SunmaoData;
build() {
Scroll() {
Column() {
Row() {
Text('⭐ 评鉴口碑')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('12条好评 · 平均4.8')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ bottom: 6 })
ForEach(this.data.reviews, (r: SunmaoReviewItem, i: number) => {
Column() {
Row() {
Text(r.emoji)
.fontSize(15)
Column() {
Text(r.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(r.date)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Text('⭐'.repeat(r.score))
.fontSize(10)
.fontColor(COLORS.warning)
SunmaoTag({ text: r.tag, color: getReviewTagColor(r.tag) })
}
.width('100%')
Text(r.content)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.lineHeight(16)
.margin({ top: 6 })
}
.width('100%')
.padding(12)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (r: SunmaoReviewItem, i: number) => 'rv' + r.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
逐点拆解:
SunmaoReviewContent 是标准客评列表,与前几篇的评鉴页同构,核心技巧有两个:
第一,星级用字符串方法生成:'⭐'.repeat(r.score)——一行代码把数字评分转成视觉星标,4 分就是四个星、5 分就是五个星。这是字符串方法在 UI 中的妙用,避免了循环创建多个图标组件的开销。星星颜色统一用 COLORS.warning(琥珀金)。
第二,标签的语义取色:SunmaoTag({ text: r.tag, color: getReviewTagColor(r.tag) })——“严丝合缝”“远渡无损”“传代可承”"镇宅添雅"归暗棕,“一榫定乾”“稳如泰山”“结实耐用”"课堂标配"归土金,“极简可期”“难度递进”“盼出图纸”"盼出小号"归木绿。同样一个胶囊标签,颜色随业务语义变化,这就是白名单映射的威力。
四、数据流全景:从点击到重绘的闭环
把上面的代码串起来,一条完整的数据流闭环就清晰了。用一张时序图来收束:
这条闭环印证了本项目的核心设计:状态在顶层(主组件),数据在模型(数据类),展示在底层(内容组件),一切变更都通过状态驱动回流。理解了这个闭环,也就理解了 ArkUI 声明式开发的精髓。
五、写在最后:从"模板复用"到"主题演绎"
回顾整份代码,它用大约 1193 行 ArkTS 呈现了一个完整、可交互、有主题质感的木作非遗应用。如果说糖画坊展示了"如何从零搭起一套架构",那么榫卯工坊展示的就是"如何在同一套架构上演绎不同的主题"。至少有三个经验值得吸收:
其一,元数据即样式。STabMeta 增加 color 字段后,底部导航的选中态就拥有了每页独立的主题色。这说明在声明式 UI 里,"样式信息"完全可以作为数据存在,让 UI 更灵活、更易配置。把可变的东西放进数据,把不变的东西写进代码,是这套模板最核心的工程哲学。
其二,取色函数的方向是语义。同样是阈值分级,强度/磨损"越高越深",产量却"越高越绿"——颜色深浅的方向性本身就是信息传达的一部分。设计取色逻辑时要先想清楚"深色代表什么、亮色代表什么",而不是机械地套用同一套阈值。
其三,装饰细节为主题服务。木栅栏装饰条、凿子位移动画、色条型列表图标、土金与木绿的页签配色……每一个视觉决策都在强化"木作"这个主题语境。做主题化应用时,小到装饰元素、大到配色体系,都应该围绕同一个叙事展开。
当然,与系列其他代码一样,它也有可优化的空间:取色函数里的硬编码色值未收敛进 COLORS、三个弹窗的重复布局可抽象通用组件、ERA_DIST 的颜色与 COLORS 存在重复定义。这些既是瑕疵,也是读者动手重构的练习点——看懂一份代码的"好",与看出它的"可改进之处",同样重要。
六、技术要点对比表:糖画坊 vs 榫卯工坊
| 对比维度 | 糖画坊(前篇) | 榫卯工坊(本篇) |
|---|---|---|
| 主题色系 | 焦糖棕 + 琥珀金 | 原木深棕 + 米白 |
| 页签元数据 | STabMeta 无 color,选中态用全局主色 |
STabMeta 含 color,每页签独立主题色 |
| 顶栏装饰 | 8 个圆点交替色 | 10 根竖条"木栅栏" |
| 动画维度 | scale(缩放)、opacity(明暗) | translate(位移)、opacity(明暗) |
| 列表图标形态 | 圆形色块 + 双标签 | 左侧 4px 色条 + 标签 |
| 取色函数 | 4 个:匠级/工艺/金额/客评标签 | 5 个:强度/磨损/热门/产量/客评标签 |
| 取色方向 | 阈值分级为主 | 阈值 + 布尔二分 + 反向分级混合 |
| 选中态反馈 | 文字变色 + 背景变淡 | 文字变色 + 背景变淡 + 描边 |
| 数据维度 | 销量、糖色占比 | 周产出、年代分布 |
| 差异化亮点 | 透明度十六进制拼接 | 数据内嵌 color 的元数据驱动 |
| 弹窗语义 | 新增糖画 / 撤下配方 / 调校糖色 | 新制榫卯 / 退役工具 / 改定图样 |
| 通用标签组件 | SugarTag | SunmaoTag(结构同构,仅命名不同) |
这张表清晰地展示了:同一套架构模板,通过改变色板、元数据、取色函数与装饰细节,就能演绎出气质迥异的主题应用。这就是本系列代码最想传达的工程思想——模板是骨架,主题是血肉,而状态管理是贯穿始终的脉搏。
七、附代码
// ============ 榫卯工坊 · 原木深棕 ============
interface SunmaoPalette {
primary: string;
primaryLight: string;
primaryDark: string;
accent: string;
accentLight: string;
bg: string;
cardBg: string;
cardAlt: string;
textPrimary: string;
textSecondary: string;
textHint: string;
border: string;
line: string;
success: string;
warning: string;
danger: string;
white: string;
timber: string;
tenon: string;
}
const COLORS: SunmaoPalette = {
primary: '#5D4037',
primaryLight: '#9C7B6A',
primaryDark: '#2E1F17',
accent: '#8D6E63',
accentLight: '#BCAAA4',
bg: '#EFE9E4',
cardBg: '#FFFFFF',
cardAlt: '#E4DAD0',
textPrimary: '#2E1F17',
textSecondary: '#6D5A4A',
textHint: '#A89580',
border: '#D7C9BB',
line: '#E8DCC8',
success: '#5A7D4C',
warning: '#B0893F',
danger: '#8E3B2E',
white: '#FFFFFF',
timber: '#5D4037',
tenon: '#8D6E63'
};
enum SunmaoTab {
JOINT = 0,
TOOL = 1,
PATTERN = 2,
WORKSHOP = 3,
REVIEW = 4
}
interface STabMeta {
key: string;
icon: string;
label: string;
color: string;
}
const TAB_LIST: STabMeta[] = [
{ key: 'joint', icon: '🪚', label: '榫卯', color: '#5D4037' },
{ key: 'tool', icon: '🔧', label: '工具', color: '#8D6E63' },
{ key: 'pattern', icon: '📐', label: '图样', color: '#B0893F' },
{ key: 'workshop', icon: '🏭', label: '工坊', color: '#5A7D4C' },
{ key: 'review', icon: '⭐', label: '评鉴', color: '#8E3B2E' }
];
interface JointItem {
name: string;
era: string;
strength: number;
use: string;
emoji: string;
}
interface ToolItem {
name: string;
material: string;
wear: number;
owner: string;
emoji: string;
}
interface PatternItem {
name: string;
strokes: number;
era: string;
popular: boolean;
emoji: string;
}
interface WorkshopItem {
name: string;
region: string;
masters: number;
output: number;
emoji: string;
}
interface SunmaoReviewItem {
name: string;
score: number;
date: string;
content: string;
tag: string;
emoji: string;
}
interface WeekOutputMeta {
day: string;
value: number;
}
interface EraDistMeta {
name: string;
count: number;
color: string;
}
const WEEK_OUTPUT: WeekOutputMeta[] = [
{ day: '周一', value: 18 },
{ day: '周二', value: 24 },
{ day: '周三', value: 30 },
{ day: '周四', value: 36 },
{ day: '周五', value: 50 },
{ day: '周六', value: 64 },
{ day: '周日', value: 56 }
];
const ERA_DIST: EraDistMeta[] = [
{ name: '先秦', count: 2, color: '#5D4037' },
{ name: '汉唐', count: 3, color: '#8D6E63' },
{ name: '宋元', count: 4, color: '#B0893F' },
{ name: '明清', count: 5, color: '#5A7D4C' },
{ name: '近代', count: 2, color: '#8E3B2E' }
];
@Observed
export class SunmaoData {
joints: JointItem[] = [
{ name: '燕尾榫', era: '先秦', strength: 95, use: '箱屉', emoji: '🕊️' },
{ name: '粽角榫', era: '明清', strength: 92, use: '桌角', emoji: '🎯' },
{ name: '格角榫', era: '宋元', strength: 88, use: '框格', emoji: '📐' },
{ name: '揣榫', era: '汉唐', strength: 86, use: '暗藏', emoji: '🕳️' },
{ name: '透榫', era: '先秦', strength: 94, use: '贯通', emoji: '➡️' },
{ name: '楔钉榫', era: '明清', strength: 90, use: '加固', emoji: '📌' },
{ name: '霸王枨', era: '明清', strength: 87, use: '斜撑', emoji: '⚔️' },
{ name: '夹头榫', era: '宋元', strength: 85, use: '夹固', emoji: '🧷' },
{ name: '插肩榫', era: '汉唐', strength: 84, use: '肩承', emoji: '🤝' },
{ name: '勾挂榫', era: '明清', strength: 89, use: '互锁', emoji: '🪝' },
{ name: '大进小出榫', era: '宋元', strength: 83, use: '变截面', emoji: '⏩' },
{ name: '老匠独门榫', era: '近代', strength: 96, use: '秘制', emoji: '📜' }
];
tools: ToolItem[] = [
{ name: '鲁班尺', material: '红木', wear: 20, owner: '主匠', emoji: '📏' },
{ name: '墨斗', material: '牛角', wear: 35, owner: '学徒', emoji: '🧵' },
{ name: '凿子', material: '钢', wear: 50, owner: '主匠', emoji: '🪚' },
{ name: '刨子', material: '铁木', wear: 40, owner: '学徒', emoji: '🪵' },
{ name: '锯子', material: '钢', wear: 60, owner: '主匠', emoji: '🦷' },
{ name: '墨线', material: '麻', wear: 70, owner: '学徒', emoji: '📐' },
{ name: '木锤', material: '檀木', wear: 25, owner: '主匠', emoji: '🔨' },
{ name: '刨刃', material: '钢', wear: 55, owner: '学徒', emoji: '🔪' },
{ name: '角尺', material: '铜', wear: 15, owner: '主匠', emoji: '📐' },
{ name: '划线笔', material: '竹', wear: 80, owner: '学徒', emoji: '✏️' },
{ name: '夹具', material: '铁', wear: 30, owner: '主匠', emoji: '🗜️' },
{ name: '老匠传家斧', material: '钢', wear: 45, owner: '堂主', emoji: '🪓' }
];
patterns: PatternItem[] = [
{ name: '万字纹', strokes: 24, era: '明清', popular: true, emoji: '🀦' },
{ name: '回字纹', strokes: 18, era: '汉唐', popular: true, emoji: '🌀' },
{ name: '云雷纹', strokes: 32, era: '先秦', popular: false, emoji: '☁️' },
{ name: '冰裂纹', strokes: 20, era: '宋元', popular: true, emoji: '❄️' },
{ name: '如意纹', strokes: 16, era: '明清', popular: true, emoji: '🍀' },
{ name: '卷草纹', strokes: 28, era: '汉唐', popular: false, emoji: '🌿' },
{ name: '缠枝莲', strokes: 36, era: '宋元', popular: true, emoji: '🌸' },
{ name: '龟背纹', strokes: 22, era: '明清', popular: false, emoji: '🐢' },
{ name: '锁子甲纹', strokes: 40, era: '先秦', popular: false, emoji: '🛡️' },
{ name: '宝相花', strokes: 30, era: '汉唐', popular: true, emoji: '🌺' },
{ name: '灯笼锦', strokes: 26, era: '明清', popular: true, emoji: '🏮' },
{ name: '老匠秘纹', strokes: 48, era: '近代', popular: false, emoji: '📜' }
];
workshops: WorkshopItem[] = [
{ name: '苏作工坊', region: '江南', masters: 12, output: 240, emoji: '🏛️' },
{ name: '京作工坊', region: '华北', masters: 15, output: 320, emoji: '🏯' },
{ name: '广作工坊', region: '岭南', masters: 10, output: 280, emoji: '🏖️' },
{ name: '晋作工坊', region: '三晋', masters: 8, output: 180, emoji: '⛰️' },
{ name: '宁作工坊', region: '宁波', masters: 14, output: 360, emoji: '🌊' },
{ name: '仙作工坊', region: '闽中', masters: 11, output: 300, emoji: '🍵' },
{ name: '京珐琅作', region: '京华', masters: 6, output: 120, emoji: '✨' },
{ name: '海外汉作', region: '海外', masters: 5, output: 90, emoji: '🌏' },
{ name: '非遗课堂', region: '多地', masters: 20, output: 480, emoji: '📖' },
{ name: '影视道具作', region: '横店', masters: 7, output: 150, emoji: '🎬' },
{ name: '老字号木作', region: '江南', masters: 9, output: 220, emoji: '🏪' },
{ name: '定制传家作', region: '京华', masters: 4, output: 60, emoji: '🎩' }
];
reviews: SunmaoReviewItem[] = [
{ name: '木作藏家', score: 5, date: '09-04', content: '燕尾榫严丝合缝,百年不开。', tag: '严丝合缝', emoji: '🧓' },
{ name: '古建修复师', score: 5, date: '09-03', content: '粽角榫复刻宋式梁架,一榫定乾坤。', tag: '一榫定乾', emoji: '🏛️' },
{ name: '家具设计师', score: 4, date: '09-02', content: '格角榫极简,盼出大号套件。', tag: '极简可期', emoji: '📐' },
{ name: '研学导师', score: 5, date: '09-01', content: '孩子拆装到半夜,难度递进合理。', tag: '难度递进', emoji: '🎒' },
{ name: '茶馆掌柜', score: 5, date: '08-31', content: '霸王枨茶桌稳如泰山,客赞不绝。', tag: '稳如泰山', emoji: '🍵' },
{ name: '影视道具', score: 5, date: '08-30', content: '剧组连拍三部戏,榫件一点没散。', tag: '结实耐用', emoji: '🎬' },
{ name: '海外汉友', score: 5, date: '08-29', content: '海运两月无损,木色反而更润。', tag: '远渡无损', emoji: '🌏' },
{ name: '老字号学徒', score: 4, date: '08-28', content: '老匠独门榫地道,盼出图纸册。', tag: '盼出图纸', emoji: '🏪' },
{ name: '定制藏家', score: 5, date: '08-27', content: '传家作传代不散,子孙可承。', tag: '传代可承', emoji: '🎩' },
{ name: '非遗课堂', score: 5, date: '08-26', content: '榫卯成非遗标配教具,学生抢着学。', tag: '课堂标配', emoji: '📖' },
{ name: '文创买手', score: 4, date: '08-25', content: '冰裂纹极美,盼出小号便携款。', tag: '盼出小号', emoji: '❄️' },
{ name: '京华主顾', score: 5, date: '08-24', content: '京作大气,摆在堂屋镇宅添雅。', tag: '镇宅添雅', emoji: '🏯' }
];
}
function getJointColor(strength: number): string {
if (strength >= 95) {
return '#8E3B2E';
} else if (strength >= 90) {
return '#B0893F';
} else if (strength >= 86) {
return '#8D6E63';
}
return '#5A7D4C';
}
function getToolColor(wear: number): string {
if (wear >= 60) {
return '#8E3B2E';
} else if (wear >= 40) {
return '#B0893F';
} else if (wear >= 25) {
return '#8D6E63';
}
return '#5A7D4C';
}
function getPatternColor(popular: boolean): string {
if (popular) {
return '#B0893F';
}
return '#8D6E63';
}
function getWorkshopColor(output: number): string {
if (output >= 300) {
return '#5A7D4C';
} else if (output >= 200) {
return '#8D6E63';
} else if (output >= 120) {
return '#B0893F';
}
return '#6D5A4A';
}
function getReviewTagColor(tag: string): string {
if (tag === '严丝合缝' || tag === '远渡无损' || tag === '传代可承' || tag === '镇宅添雅') {
return '#8D6E63';
} else if (tag === '一榫定乾' || tag === '稳如泰山' || tag === '结实耐用' || tag === '课堂标配') {
return '#B0893F';
} else if (tag === '极简可期' || tag === '难度递进' || tag === '盼出图纸' || tag === '盼出小号') {
return '#5A7D4C';
}
return '#6D5A4A';
}
@Component
struct SunmaoTag {
@Prop text: string;
@Prop color: string;
build() {
Text(this.text)
.fontSize(9)
.fontColor(this.color)
.padding({ left: 7, right: 7, top: 2, bottom: 2 })
.backgroundColor(this.color + '1F')
.borderRadius(9)
.border({ width: 1, color: this.color + '40' })
}
}
@Component
struct SunmaoJointContent {
@ObjectLink data: SunmaoData;
onAdd: () => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Row() {
Text('📈 本周榫卯产出')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('周六64件')
.fontSize(9)
.fontColor(COLORS.accent)
.margin({ left: 8 })
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
Row() {
ForEach(WEEK_OUTPUT, (w: WeekOutputMeta) => {
Column() {
Text(w.value + '')
.fontSize(8)
.fontColor(w.value >= 48 ? COLORS.accent : COLORS.primaryLight)
Column()
.width(20)
.height(w.value)
.backgroundColor(w.value >= 48 ? COLORS.accent : COLORS.primaryLight)
.borderRadius(10)
.margin({ top: 4 })
Text(w.day)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (w: WeekOutputMeta) => w.day)
}
.width('100%')
.height(150)
.alignItems(VerticalAlign.Bottom)
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.border({ width: 1, color: COLORS.accent + '55' })
Row() {
Text('🪚 榫卯谱')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('点击➕制榫')
.fontSize(9)
.fontColor(COLORS.textHint)
Text('➕')
.fontSize(14)
.margin({ left: 6 })
.onClick(() => { this.onAdd(); })
}
.width('100%')
.margin({ top: 14, bottom: 4 })
ForEach(this.data.joints, (j: JointItem, i: number) => {
Row() {
Column()
.width(4)
.height(56)
.backgroundColor(getJointColor(j.strength))
.borderRadius(2)
Column() {
Row() {
Text(j.emoji)
.fontSize(16)
Text(j.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.margin({ left: 8 })
Text(j.era)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
Row() {
SunmaoTag({ text: j.use, color: getJointColor(j.strength) })
SunmaoTag({ text: '强度' + j.strength, color: COLORS.primaryLight })
}
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.padding({ left: 12 })
}
.width('100%')
.padding({ top: 10, bottom: 10, left: 8, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (j: JointItem, i: number) => 'jt' + j.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct SunmaoToolContent {
@ObjectLink data: SunmaoData;
onDel: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Column() {
Text('📊 年代分布')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
.width('100%')
.margin({ bottom: 10 })
Row() {
ForEach(ERA_DIST, (e: EraDistMeta) => {
Column() {
Text(e.count + '')
.fontSize(10)
.fontColor(e.color)
Column()
.width(28)
.height(e.count * 16)
.backgroundColor(e.color)
.borderRadius({ topLeft: 4, topRight: 4 })
.margin({ top: 4 })
Text(e.name)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Center)
}, (e: EraDistMeta) => e.name)
}
.width('100%')
.alignItems(VerticalAlign.Bottom)
}
.width('100%')
.padding(14)
.backgroundColor(COLORS.cardBg)
.borderRadius(16)
.border({ width: 1, color: COLORS.accent + '55' })
Row() {
Text('🔧 工具箱')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('点击🗑️退役')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ top: 14, bottom: 4 })
ForEach(this.data.tools, (t: ToolItem, i: number) => {
Row() {
Column()
.width(4)
.height(48)
.backgroundColor(getToolColor(t.wear))
.borderRadius(2)
Column() {
Text(t.emoji)
.fontSize(18)
}
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(getToolColor(t.wear) + '22')
.borderRadius(21)
.border({ width: 1, color: getToolColor(t.wear) + '55' })
.margin({ left: 8 })
Column() {
Row() {
Text(t.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(t.material)
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
Text('🗑️')
.fontSize(12)
.margin({ left: 8 })
.onClick(() => { this.onDel(t.name); })
}
.width('100%')
Row() {
Text('磨损' + t.wear + '% · ' + t.owner)
.fontSize(9)
.fontColor(getToolColor(t.wear))
Text('工具')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 6, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (t: ToolItem, i: number) => 'tl' + t.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct SunmaoPatternContent {
@ObjectLink data: SunmaoData;
onEdit: (n: string) => void = () => {};
build() {
Scroll() {
Column() {
Row() {
Text('📐 图样册')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('点击✏️改样')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ bottom: 6 })
ForEach(this.data.patterns, (p: PatternItem, i: number) => {
Row() {
Column()
.width(4)
.height(48)
.backgroundColor(getPatternColor(p.popular))
.borderRadius(2)
Column() {
Text(p.emoji)
.fontSize(18)
}
.width(42)
.height(42)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(getPatternColor(p.popular) + '22')
.borderRadius(21)
.border({ width: 1, color: getPatternColor(p.popular) + '55' })
.margin({ left: 8 })
Column() {
Row() {
Text(p.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(p.strokes + '笔')
.fontSize(9)
.fontColor(COLORS.textSecondary)
.margin({ left: 8 })
Text('✏️')
.fontSize(12)
.margin({ left: 8 })
.onClick(() => { this.onEdit(p.name); })
}
.width('100%')
Row() {
Text(p.era + (p.popular ? ' · 热门' : ' · 冷门'))
.fontSize(9)
.fontColor(getPatternColor(p.popular))
Text('图样')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 6, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (p: PatternItem, i: number) => 'pt' + p.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct SunmaoWorkshopContent {
@ObjectLink data: SunmaoData;
build() {
Scroll() {
Column() {
Row() {
Text('🏭 各地工坊')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('按产出排序')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ bottom: 6 })
ForEach(this.data.workshops, (w: WorkshopItem, i: number) => {
Row() {
Column()
.width(4)
.height(48)
.backgroundColor(getWorkshopColor(w.output))
.borderRadius(2)
Column() {
Text(w.emoji)
.fontSize(16)
}
.width(40)
.height(40)
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor(getWorkshopColor(w.output) + '22')
.borderRadius(20)
.border({ width: 1, color: getWorkshopColor(w.output) + '55' })
.margin({ left: 8 })
Column() {
Row() {
Text(w.name)
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(w.region)
.fontSize(9)
.fontColor(getWorkshopColor(w.output))
.margin({ left: 8 })
}
.width('100%')
Row() {
Text(w.masters + ' 位匠师 · 月产' + w.output + ' 件')
.fontSize(9)
.fontColor(COLORS.textSecondary)
Text('工坊')
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ left: 8 })
}
.width('100%')
.margin({ top: 6 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.margin({ left: 10 })
}
.width('100%')
.padding({ top: 8, bottom: 8, left: 6, right: 8 })
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (w: WorkshopItem, i: number) => 'ws' + w.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Component
struct SunmaoReviewContent {
@ObjectLink data: SunmaoData;
build() {
Scroll() {
Column() {
Row() {
Text('⭐ 评鉴口碑')
.fontSize(13)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('12条好评 · 平均4.8')
.fontSize(9)
.fontColor(COLORS.textHint)
}
.width('100%')
.margin({ bottom: 6 })
ForEach(this.data.reviews, (r: SunmaoReviewItem, i: number) => {
Column() {
Row() {
Text(r.emoji)
.fontSize(15)
Column() {
Text(r.name)
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text(r.date)
.fontSize(9)
.fontColor(COLORS.textHint)
.margin({ top: 1 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
.margin({ left: 8 })
Text('⭐'.repeat(r.score))
.fontSize(10)
.fontColor(COLORS.warning)
SunmaoTag({ text: r.tag, color: getReviewTagColor(r.tag) })
}
.width('100%')
Text(r.content)
.fontSize(11)
.fontColor(COLORS.textSecondary)
.lineHeight(16)
.margin({ top: 6 })
}
.width('100%')
.padding(12)
.backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
.borderRadius(14)
.border({ width: 1, color: COLORS.line })
.margin({ top: 8 })
}, (r: SunmaoReviewItem, i: number) => 'rv' + r.name + i)
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 16 })
}
.width('100%')
.layoutWeight(1)
.scrollable(ScrollDirection.Vertical)
.scrollBar(BarState.Off)
}
}
@Entry
@Component
struct SunmaoApp {
@State curTab: number = 0;
@State data: SunmaoData = new SunmaoData();
@State showAddJoint: boolean = false;
@State showDeleteTool: boolean = false;
@State showEditPattern: boolean = false;
@State delToolName: string = '';
@State editPatternName: string = '';
@State chiselSlide: boolean = false;
@State woodGrain: boolean = false;
@Builder modalOverlay(onClose: () => void) {
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => { onClose(); })
}
@Builder addJointModal() {
Stack() {
this.modalOverlay(() => { this.showAddJoint = false; })
Column() {
Row() {
Text('🪚 新制榫卯')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => { this.showAddJoint = false; })
}
.width('100%')
Row() {
Text('品名')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('暗销暗榫')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('年代')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('明清 · 桌案暗藏')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('强度')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('★★★★ · 91 分')
.fontSize(11)
.fontColor(getJointColor(91))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('用途')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('暗藏加固')
.fontSize(11)
.fontColor(getJointColor(91))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.border({ width: 1, color: COLORS.border })
.onClick(() => { this.showAddJoint = false; })
Text('确认制榫')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.margin({ left: 10 })
.onClick(() => { this.showAddJoint = false; })
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 14 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
@Builder deleteToolModal() {
Stack() {
this.modalOverlay(() => { this.showDeleteTool = false; })
Column() {
Text('🗑️ 退役工具')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Text('确定让「' + this.delToolName + '」退役吗?')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.margin({ top: 10 })
Text('退役的工具将从工具箱移除。')
.fontSize(10)
.fontColor(COLORS.textHint)
.margin({ top: 4 })
Row() {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.border({ width: 1, color: COLORS.border })
.onClick(() => { this.showDeleteTool = false; })
Text('确认退役')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.danger)
.borderRadius(14)
.margin({ left: 10 })
.onClick(() => { this.showDeleteTool = false; })
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 16 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '78%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
@Builder editPatternModal() {
Stack() {
this.modalOverlay(() => { this.showEditPattern = false; })
Column() {
Row() {
Text('📐 改定图样')
.fontSize(16)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.textPrimary)
Column().layoutWeight(1)
Text('✕')
.fontSize(16)
.fontColor(COLORS.textHint)
.onClick(() => { this.showEditPattern = false; })
}
.width('100%')
Row() {
Text('图样')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text(this.editPatternName)
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 14 })
Row() {
Text('原笔数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('24 笔')
.fontSize(11)
.fontColor(COLORS.textPrimary)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('新笔数')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('24 → 32 笔')
.fontSize(11)
.fontColor(COLORS.accent)
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('年代')
.fontSize(11)
.fontColor(COLORS.textSecondary)
.width(56)
Text('明清 → 宋元')
.fontSize(11)
.fontColor(getPatternColor(true))
}
.width('100%')
.padding(10)
.backgroundColor(COLORS.cardAlt)
.borderRadius(12)
.margin({ top: 8 })
Row() {
Text('再想想')
.fontSize(12)
.fontColor(COLORS.textSecondary)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.borderRadius(14)
.border({ width: 1, color: COLORS.border })
.onClick(() => { this.showEditPattern = false; })
Text('保存改定')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
.padding({ left: 16, right: 16, top: 7, bottom: 7 })
.backgroundColor(COLORS.primary)
.borderRadius(14)
.margin({ left: 10 })
.onClick(() => { this.showEditPattern = false; })
}
.width('100%')
.justifyContent(FlexAlign.End)
.margin({ top: 14 })
}
.width('86%')
.padding(16)
.backgroundColor(COLORS.cardBg)
.borderRadius(18)
.constraintSize({ maxHeight: '80%' })
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
}
@Builder topBar() {
Column() {
Row() {
Column() {
Text('🪚 榫卯工坊')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(COLORS.white)
Text('Sunmao · 一榫一卯 百年不散')
.fontSize(10)
.fontColor(COLORS.accentLight)
.margin({ top: 3 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
Row() {
Text('🪚')
.fontSize(18)
.translate({ x: this.chiselSlide ? 10 : -4, y: this.chiselSlide ? 2 : 6 })
.animation({ duration: 600, curve: Curve.EaseOut })
.onClick(() => { this.chiselSlide = !this.chiselSlide; })
Text('🪵')
.fontSize(16)
.margin({ left: 10 })
.opacity(this.woodGrain ? 1.0 : 0.45)
.animation({ duration: 500, curve: Curve.EaseOut })
.onClick(() => { this.woodGrain = !this.woodGrain; })
Text('📏')
.fontSize(14)
.margin({ left: 6 })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(COLORS.accent + '2E')
.borderRadius(16)
.border({ width: 1, color: COLORS.accent + '80' })
}
.width('100%')
Row() {
ForEach([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], (r: number) => {
Column()
.width(6)
.height(18)
.backgroundColor(r % 2 === 0 ? COLORS.accent : COLORS.primaryLight)
.borderRadius(3)
}, (r: number) => 'gr' + r)
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
.margin({ top: 12 })
Row() {
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.accent + '66')
Text('🪵 选材凿卯 · 百年不散 🪚')
.fontSize(9)
.fontColor(COLORS.accentLight)
.margin({ left: 8, right: 8 })
Column()
.layoutWeight(1)
.height(1)
.backgroundColor(COLORS.accent + '66')
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(COLORS.primaryDark)
}
build() {
Column() {
this.topBar()
if (this.curTab === SunmaoTab.JOINT) {
SunmaoJointContent({ data: this.data, onAdd: () => { this.showAddJoint = true; } })
} else if (this.curTab === SunmaoTab.TOOL) {
SunmaoToolContent({ data: this.data, onDel: (n: string) => {
this.delToolName = n;
this.showDeleteTool = true;
} })
} else if (this.curTab === SunmaoTab.PATTERN) {
SunmaoPatternContent({ data: this.data, onEdit: (n: string) => {
this.editPatternName = n;
this.showEditPattern = true;
} })
} else if (this.curTab === SunmaoTab.WORKSHOP) {
SunmaoWorkshopContent({ data: this.data })
} else {
SunmaoReviewContent({ data: this.data })
}
Column()
.width('100%')
.height(3)
.backgroundColor(COLORS.primary)
Row() {
ForEach(TAB_LIST, (t: STabMeta, idx: number) => {
Column() {
Text(t.icon)
.fontSize(19)
Text(t.label)
.fontSize(10)
.fontColor(this.curTab === idx ? t.color : COLORS.textHint)
.fontWeight(this.curTab === idx ? FontWeight.Bold : FontWeight.Normal)
.margin({ top: 2 })
}
.layoutWeight(1)
.justifyContent(FlexAlign.Center)
.padding({ top: 7, bottom: 7 })
.backgroundColor(this.curTab === idx ? t.color + '14' : '#00000000')
.border(this.curTab === idx ? { width: 1, color: t.color } : { width: 0 })
.borderRadius(14)
.onClick(() => { this.curTab = idx; })
}, (t: STabMeta) => t.key)
}
.width('100%')
.height(60)
.padding({ left: 8, right: 8 })
.backgroundColor(COLORS.cardBg)
if (this.showAddJoint) {
this.addJointModal()
}
if (this.showDeleteTool) {
this.deleteToolModal()
}
if (this.showEditPattern) {
this.editPatternModal()
}
}
.width('100%')
.height('100%')
.backgroundColor(COLORS.bg)
}
}

更多推荐



所有评论(0)