榫卯工坊:用鸿蒙 ArkTS 将「一榫一卯,百年不散」的东方木作智慧写进状态管理

一、写在前面:当木匠的凿声遇见代码的编译声

1.1 榫卯:不用一颗钉子的东方智慧

在中国传统木作的世界里,有一句流传千年的老话:“榫卯万年牢”。早在七千年前的河姆渡遗址中,先民们就已经用榫卯结构搭建起了干栏式房屋——没有一颗铁钉,没有一滴胶水,仅仅依靠构件之间凹凸部分的精密咬合,就能让家具、建筑在风雨中挺立数百年。燕尾榫的形如燕尾、粽角榫的三方交汇、霸王枨的斜撑承重……每一种榫卯都凝结着古代匠人对力学、材料与美学的深刻理解。

这份代码的主角,正是一座名为 “榫卯工坊” 的鸿蒙应用。它用原木深棕与米白为基调,试图在数字世界里还原那份属于木作的沉稳与匠心:一榫一卯之间,是结构的严谨;一刀一凿之间,是岁月的包浆。

1.2 从「糖画坊」到「榫卯工坊」:同一套骨架,不同的灵魂

如果你读过本系列的上一篇《糖画坊》,你会发现两份代码在宏观结构上惊人地相似——同样是"类型与常量层 → 数据模型层 → 工具函数层 → 界面层"的四层架构,同样是五个页签、三个弹窗、一组取色函数。但恰恰是这种"同构"让它们成为绝佳的对比教材:架构模板是通用的,但如何在模板之上做出差异化的主题表达,才是真正考验功力之处。

榫卯工坊与糖画坊的核心差异体现在三个地方,这也是贯穿本文的三条暗线:

  1. 页签元数据多了一个 color 字段:糖画坊的页签选中态统一使用全局主色,而榫卯工坊让每个页签携带自己的主题色——榫卯页深棕、工具页暗棕、图样页土金、工坊页木绿、评鉴页赭红。选中谁,谁就亮出自己的颜色。
  2. 顶栏装饰从"糖珠"变成了"木栅栏":一串交替着琥珀与焦糖色的圆点,变成了一排高低错落的木条,主题叙事的颗粒度细致到了装饰元素。
  3. 取色函数的维度从"销量/金额"变成了"强度/磨损/产量":数值不再代表钱,而是代表木作工艺的物理属性。

下面,我们将沿着代码的物理书写顺序,把 1193 行代码一字不落地拆开,逐段、逐层、逐函数地讲透。

二、项目总览:四层架构与一张全景图

在进入逐段解析之前,先用一张架构图把整份代码的"骨架"立起来。整份代码可以清晰划分成四个层次:

  • 类型与常量层SunmaoPalette 色板接口、COLORS 色板常量、SunmaoTab 页签枚举、STabMeta 页签元数据(含 color)、七组业务接口、WEEK_OUTPUT / ERA_DIST 图表常量。
  • 数据模型层@Observed 修饰的 SunmaoData 数据类,持有榫卯、工具、图样、工坊、评鉴五组数据。
  • 工具函数层getJointColorgetToolColorgetPatternColorgetWorkshopColorgetReviewTagColor 五个取色映射函数。
  • 界面层@Entry @Component 修饰的主组件 SunmaoApp、三个模态弹窗构建器、一个通用标签组件、五个内容子组件。
渲染错误: Mermaid 渲染失败: Parse error on line 2: ...lor / 七组Item接口] --> B[数据模型层
@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 主题色板:原木深棕的 SunmaoPaletteCOLORS

代码的第一段,是定义"榫卯工坊"视觉基调的色板接口与色板常量。原木、深棕、米白,是这份代码想传递的全部气质。

// ============ 榫卯工坊 · 原木深棕 ============
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(榫头)。虽然在实际代码中它们的值分别与 primaryaccent 相同,但语义上是两个独立的设计概念——“木料色"与"榫头色”。这种"语义冗余但概念清晰"的写法在大型设计系统中很常见:宁可多定义几个同值的语义变量,也不要在使用处写错语义

第四,色板的完整体系primary/primaryLight/primaryDark 构成深木棕主色梯度,accent/accentLight 是辅助的暖棕强调色,bg/cardBg/cardAlt 负责页面与卡片背景的层次,textPrimary/textSecondary/textHint 控制文字的三级信息密度,success/warning/danger 是状态色(注意这里的 danger#8E3B2E——一种偏赭红的深褐,比糖画坊的 #A8362A 更暗沉,更贴合木作的质感)。最后 timbertenon 呼应主题。

3.2 页签体系:带主题色的 SunmaoTabSTabMetaTAB_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.JOINTthis.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(工具):namematerial(材质,如"红木"“牛角”“钢”)、wear(磨损率百分比)、owner(归属,如"主匠"“学徒”“堂主”)、emoji。磨损率喂给 getToolColor
  • PatternItem(图样):namestrokes(笔数)、erapopular(是否热门,布尔类型)、emojipopular 是五个接口里唯一的布尔字段,它直接决定图样页的取色逻辑——这是后续 getPatternColor 的输入来源。
  • WorkshopItem(工坊):nameregion(地区,如"江南"“华北”“岭南”)、masters(匠师数)、output(月产量)、emoji。月产量喂给 getWorkshopColor
  • SunmaoReviewItem(评鉴):namescore(评分)、datecontenttagemoji

第二,接口先行,UI 后行。这些接口既是数据的"形状契约",也决定了列表项 UI 要展示哪些字段——JointItemera 所以在列表里能看到年代标签,ToolItemwear 所以能看到磨损百分比,PatternItempopular 所以能显示"热门/冷门"。数据驱动渲染的基础,就是先把数据形状定义清楚。

第三,图表元数据WeekOutputMeta(周产出:天 + 数值)与 EraDistMeta(年代分布:名称 + 数量 + 颜色)。注意 EraDistMetacolor 直接内嵌在数据里——当颜色属于数据本身的属性时,放在数据里比放在映射函数里更自然,这与糖画坊的 COLOR_SHARE 做法一致。

3.4 图表数据常量:WEEK_OUTPUTERA_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';
}

逐点拆解:

这五个函数是"数值/文本 → 主题色"的映射器,集中体现了阈值分级布尔二分白名单分级三种映射策略。

第一,getJointColorgetToolColor同构的阈值分级,只是输入维度不同(强度 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.accentCOLORS.primaryLight,圆角 3——一眼望去像一排木栅栏,贴合木作主题。装饰元素也为主题叙事服务,这是本系列代码一贯的细节追求。

第二,凿子的位移动画Text('🪚').translate({ x: this.chiselSlide ? 10 : -4, y: this.chiselSlide ? 2 : 6 })——点击后在两个位移点之间滑动,模拟"凿子滑入滑出";配合 600ms 的 Curve.EaseOut 缓出曲线,形成自然的入位感。与糖画坊的 scale(缩放)、opacity(明暗)相比,这里多了一种动画维度:位移translatescaleopacityrotate 一起,构成了 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 收到 onAddSunmaoToolContent 收到 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) })——“严丝合缝”“远渡无损”“传代可承”"镇宅添雅"归暗棕,“一榫定乾”“稳如泰山”“结实耐用”"课堂标配"归土金,“极简可期”“难度递进”“盼出图纸”"盼出小号"归木绿。同样一个胶囊标签,颜色随业务语义变化,这就是白名单映射的威力。

四、数据流全景:从点击到重绘的闭环

把上面的代码串起来,一条完整的数据流闭环就清晰了。用一张时序图来收束:

SunmaoData 数据类 主组件 SunmaoApp 内容子组件 SunmaoToolContent 用户 SunmaoData 数据类 主组件 SunmaoApp 内容子组件 SunmaoToolContent 用户 点击 🗑️ 图标(退役工具) 调用 onDel(t.name) 回调上抛 delToolName = t.name showDeleteTool = true 渲染 deleteToolModal 弹窗 点击确认/关闭,showDeleteTool = false 点击底部页签(如"工坊") curTab = 3 (SunmaoTab.WORKSHOP) 条件渲染 SunmaoWorkshopContent @ObjectLink 读取 workshops 数据 返回 12 条工坊数据 getWorkshopColor(output) 映射颜色 渲染工坊列表(产量越高越绿)

这条闭环印证了本项目的核心设计:状态在顶层(主组件),数据在模型(数据类),展示在底层(内容组件),一切变更都通过状态驱动回流。理解了这个闭环,也就理解了 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)
  }
}
![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/f0cee57a8fd6463a94abd9848d5f15be.png)



Logo

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

更多推荐