毛笔坊非遗制笔应用基于HarmonyOS API 24深度解析——采用了"单入口 + 多内容区 + 底部导航"的经典移动端布局模式。入口组件 BrushApp 作为整个应用的根容器

本文基于 HarmonyOS NEXT + ArkTS 声明式开发框架,完整实现一个「毛笔坊」非遗制笔主题 App,涵盖配色系统、状态管理、多 Tab 页面、自定义弹窗、动态颜色映射、圆形图标与不对称圆角设计等核心知识点,并附完整源码与运行效果截图。


一、引言:非遗制笔的数字化传承

在移动互联网与智能设备高速发展的今天,非物质文化遗产的传承与传播已经从单纯的线下演示,演变为一种高度数字化、流程化、个性化的线上线下融合体验。毛笔,作为中国传统文房四宝之首,距今已有数千年历史。一支好笔,从选毫、脱脂、齐毫、扎头、装管到刻字,需经数十道工序,凝聚着匠人的心血与毫毛的温润。大兰竹的雄浑、小白云的柔和、狼毫小楷的劲挺、鹤颈笔的修长、红豆笔的精准、长锋羊毫的飘逸,每一支笔都承载着中国人对书法与绘画的追求与向往。然而,这门古老的技艺正面临着传承人老龄化、受众面狭窄、销售渠道单一等现实困境。

正是在这样的背景下,毛笔坊类数字化应用应运而生,并迅速成为非遗制笔"活态传承"的数字载体。一个优秀的毛笔坊应用,不仅需要覆盖从"笔挂墙"到"客评口碑"的完整业务闭环,还要在视觉设计、交互体验、数据呈现上做到细致入微,让用户在指尖滑动间便能感受到毫毛的温润与制笔的雅致。

本文将深入剖析一个基于鸿蒙 ArkUI 声明式开发框架构建的毛笔坊非遗制笔应用。该应用以"笔挂墙"为核心,横向扩展出笔洗案、笔架阁、订单管理、客评口碑五大功能模块,形成了一个完整闭环的制笔管理生态。它不仅是一个功能完备的演示项目,更是一份典型的 ArkUI 工程实践范本,涵盖了状态管理、组件化设计、模态弹窗、列表渲染、图表可视化、动态颜色映射、圆形图标与不对称圆角设计等关键技术点。

毛笔坊应用整体运行效果 - 本周毛笔销量柱状图与笔挂墙列表


二、应用整体架构概览

整个应用采用了"单入口 + 多内容区 + 底部导航"的经典移动端布局模式。入口组件 BrushApp 作为整个应用的根容器,负责管理当前激活的 Tab 状态,并根据状态切换不同的内容区组件。

应用一共划分为五个一级页面:笔挂墙(BRUSH)、笔洗案(WASH)、笔架阁(HANG)、订单管理(ORDER)以及客评口碑(REVIEW)。每个页面都是一个独立的 @Component 装饰的自定义组件,内部封装了自己的状态、构建逻辑和子组件。这种"高内聚、低耦合"的组件化设计,使得每个页面可以独立开发、独立测试,同时也便于后续的扩展与维护。

在数据层面,应用定义了一套完整的类型系统,包括色彩主题、毛笔元数据、笔洗元数据、笔架元数据、订单元数据、客评元数据、周销量元数据、毫料构成元数据、工艺热度元数据、TOP榜元数据等,并通过常量数组将数据与 UI 表现层解耦。所有业务数据以 @Observed 装饰的可观察类 BrushData 为载体,配合五组各 12 条模拟数据,模拟了一个真实的毛笔坊数据环境。

在交互层面,应用通过条件渲染(if-else)实现了四类弹窗的叠加显示:新制毛笔、修改订单、撤下笔洗、笔架详情。弹窗以全屏半透明遮罩 + 居中卡片的形式呈现,与主内容共享同一根 Column,通过布尔状态控制显隐。

在视觉层面,应用独创了"圆形图标+不对称圆角"的毛笔造型设计——毛笔页每个卡片左侧是一个 56 宽的图标区,包含 44×44 圆形图标(圆角22)和下方一个 ✒️ 小笔尖图标,形成毛笔造型;卡片使用不对称圆角(topLeft24/topRight8/bottomLeft8/bottomRight24),模仿毛笔笔锋的造型感。笔洗页和订单页使用反向不对称圆角,形成视觉呼应。

毛笔坊客评模块运行效果 - 客评口碑列表(真机运行)

下面逐页展示应用的五大功能模块运行效果。


三、毛笔页:圆形图标与不对称圆角的笔挂墙

毛笔页是整个应用的默认首页,也是视觉设计最独特的页面。它包含了本周毛笔销量柱状图、笔挂墙列表,以及新制毛笔弹窗。

毛笔页顶部是"本周毛笔销量"柱状图卡片,采用白色背景配 1px 墨蓝半透明边框,圆角 18。通过 ForEach 遍历一周七天的销量数据,柱子高度直接绑定数值,销量超过 45 的日期(周五、周六、周日)使用主色木棕 #8A6A4F 突出显示,其余日期使用 accent 墨蓝 #3E4A5A,形成自然的高低起伏。周六以 61 支的销量居首,符合毛笔作为文房用品在周末热销的真实节奏。

下方是笔挂墙列表,采用了独创的"圆形图标+小笔尖"设计——每个卡片左侧是一个 56 宽的图标区,包含一个 44×44 的圆形图标(圆角22,背景色按名称动态着色)和下方一个 ✒️ 小笔尖图标,整体形成毛笔造型。右侧信息区展示毛笔名称、价格(颜色按价格动态着色)、毫料标签(颜色按名称动态着色)、尺寸标签(墨蓝色)和"尖齐圆健"文字。卡片使用不对称圆角 borderRadius({ topLeft: 24, topRight: 8, bottomLeft: 8, bottomRight: 24 }),模仿毛笔笔锋的造型感,这是本应用最具辨识度的视觉设计特征。

12 款毛笔覆盖了从 46 元(小白云)到 158 元(鹤颈笔)的完整价格梯队,毫料涵盖兼毫、羊毫、狼毫、紫毫、马毫五大类,尺寸从小楷、中楷、大楷到长锋、修复不等,每款都配有独特的 emoji 图标(大兰竹用🎍、小白云用☁️、狼毫小楷用🖊️、鹤颈笔用🦢、红豆笔用🫘、长锋羊毫用📜等),让毛笔不仅是书写工具,更是毫料与造型的双重体验。右上角 ➕ 按钮点击可弹出"新制毛笔"弹窗。


四、笔洗页:销量排行与撤洗管理

笔洗页以"笔洗"为核心,顶部是毛笔销量 TOP6 横向条形图,下方是笔洗案列表,支持撤洗操作。

笔洗模块 - 毛笔销量TOP6横向条形图与笔洗案列表

顶部 TOP6 卡片通过横向进度条展示销量排名前六的毛笔品种,大兰竹以 96 的销量居首,小白云、狼毫小楷紧随其后。进度条使用 layoutWeight 比例法实现,已售部分与剩余部分按比例分配宽度,颜色直接取数据中的配置色,六种毛笔各有特色。

下方笔洗案列表展示 12 款笔洗,卡片采用"左图标右文字"布局。左侧是一个 44×44 的图标方块,背景色通过 getWashColor 函数按窑级别分档着色(≥95用墨蓝、≥92用木棕、其余用金棕),圆角 10,展示笔洗 emoji 图标。右侧展示笔洗名称、釉色(青花釉/天青釉/窑变釉等)、窑级别(“窑级94”,颜色按级别动态着色)、产地(“景德镇 · 窑口”)和 🗑️ 撤洗按钮。卡片使用反向不对称圆角 borderRadius({ topLeft: 8, topRight: 24, bottomLeft: 24, bottomRight: 8 }),与毛笔页形成视觉呼应。

12 款笔洗釉色覆盖青花釉、天青釉、窑变釉、开片釉、白釉、梅子青、影青釉、茶叶末、釉里红、粉青釉、甜白釉、月白釉十二种,窑级别从 90(茶叶末洗)到 96(汝窑笔洗、釉里红洗)不等,汝窑笔洗和釉里红洗以 96 级并列居首,钧窑笔洗 95 级次之,符合"汝窑为魁、钧窑次之"的行业共识。产地从景德镇、宝丰、禹州、龙泉、曲阳、湖田、淄博、德化到汝州,几乎囊括了中国古代制瓷的全部名窑口。点击 🗑️ 按钮弹出"撤下笔洗"二次确认弹窗,采用警示型设计语言,"确认撤下"按钮使用危险色赭红,防止误操作。


五、笔架页:毫料构成与笔架详情

笔架页以"笔架"为核心,顶部是毫料构成占比图,下方是笔架阁列表,支持查看详情。

笔架模块 - 毫料构成占比图与笔架阁列表

顶部毫料构成卡片展示三种毫料的分布占比:羊毫 5 款、狼毫 4 款、兼毫 3 款,合计 12 款,恰好对应笔挂墙的毫料总数。进度条以 12 为满刻度进行比例渲染,比百分比更直观地传达了"几毫料"的语义。

下方笔架阁列表展示 12 款笔架,卡片采用"左图标右文字"布局。左侧是一个 44×44 的图标方块,背景统一使用 warning 金棕 14% 透明度,圆角 10,展示笔架 emoji 图标。右侧展示笔架名称、材质(大红酸枝/小叶紫檀/湘妃竹等)、挂笔数(“挂8笔”,金棕色)、产地(“苏州 · 工坊”)和 👁️ 详情按钮。卡片使用正向不对称圆角,与毛笔页一致。

12 款笔架材质覆盖大红酸枝、小叶紫檀、湘妃竹、黄花梨、黑檀木、鸡翅木、榉木、老树根、黄铜、白瓷、螺钿镶嵌、毛竹帘十二种,挂笔数从 5(紫檀笔山、瓷雕笔山)到 20(老竹笔帘)不等,老竹笔帘以 20 笔居首,根雕笔挂 14 笔次之,竹节笔挂 12 笔第三,符合"笔帘容量最大、根雕次之"的行业共识。产地从苏州、北京、湖南、海南、东阳、广西、福建、佛山、德化、扬州到安吉,呈现出"笔架天下"的格局。点击 👁️ 按钮弹出"笔架详情"弹窗,包含笔架展示区、文艺风格描述文案(“选老料开形,随木就势凿出峰峦。打磨数遍至手感温润,再上蜂蜡护木。架上一排笔悬锋朝下,毫锋不受损,案头亦添一段山林气。”)、产地工坊(“江苏 · 苏州”)和匠人评级(“特级 · 木作廿二年”)。


六、订单页:制笔工艺热度与订单改单

订单页以"订单"为核心,顶部是制笔工艺热度排行,下方是订单列表,支持修改订单。

订单模块 - 制笔工艺热度排行与笔坊订单列表

顶部制笔工艺热度卡片展示六门工序的热度指数:选毫 95、脱脂 93、齐毫 96、扎头 91、装管 92、刻字 89,恰好对应制笔制作的完整流程(选毫→脱脂→齐毫→扎头→装管→刻字),齐毫以 96 的热度居首(不是第一道选毫),呈现出"齐毫为核心工序"的合理分布。六根进度条颜色从木棕到墨蓝到金棕到蓝色到浅灰到赭红,形成了一条色彩渐变的工艺流水线。

下方订单列表展示 12 条订单,卡片采用"左图标右文字"布局。左侧是一个 40×40 的圆形图标(圆角20),背景色通过 getOrderColor 函数按订单金额分档着色(15万以上墨蓝、11-15万木棕、8-11万金棕、8万以下蓝色),展示客户 emoji 图标。右侧展示客户名称、✏️ 编辑按钮、区域标签和订单详情(“· 620 支 · ¥148000”)。卡片使用反向不对称圆角,与笔洗页一致。

12 类客户覆盖美院书画系、少儿书法连锁、文房四宝店、书画培训机构、老年大学、国画工作室、印社篆刻行、文创礼品公司、日本书道院、书法直播基地、茶空间雅集、孔庙文创部。少儿书法连锁以 2400 支的数量居首,日本书道院虽然只有 420 支,但金额高达 186000,单价最高,体现了非遗制笔在海外市场的高溢价。国画工作室以 156000 元的金额位居第二,符合高端书画工作室对精品毛笔的高需求。点击 ✏️ 按钮弹出"修改订单"弹窗,可调整数量和金额,数量行使用箭头符号"→"直观展示修改前后的对比。


七、客评页:口碑展示与标签体系

客评页是应用中最具"人情味"的页面,展示了 12 条用户好评。

客评模块 - 客评口碑列表

页面顶部是标题行"⭐ 客评口碑"和统计摘要"12条好评 · 平均4.9"。下方 12 条评价卡片采用"左头像右信息"的横向布局:最左侧是用户 emoji 头像,中间是用户名称和日期,右侧是星级评分(通过 String.repeat 一行代码生成对应数量的星星)和评价标签(通过动态颜色函数按标签语义分档着色)。评价内容正文在卡片下方,使用次要色文字和 16px 行高。卡片使用正向不对称圆角,与毛笔页一致。

12 条评价每条都有独特的标签:“锋颖如锥”、“久用不开叉”、“勾线利器”、“顺手好写”、“全班订购”、“印稿精准”、“弹幕刷屏”、“礼盒体面”、“茶席雅物”、“釉色温润”、“老笔新生”、“研学指定”,这些标签既概括了评价核心,又形成了毛笔坊的口碑关键词云。

评价内容充满生活气息,如"狼毫小楷锋颖如锥,题跋小字锋锋见笔"、“少儿班人手一支小白云,开叉率极低”、“鹤颈笔勾线如行云,衣纹一气呵成”、“漂洋过海毫无损,书道课全班订购”、“修笔换毫手艺绝,老笔重获新生”、“研学团指定用笔,家长回头加购”,让数据看起来不是冷冰冰的测试桩,而是真实书友的心声。卡片背景使用白色与浅灰色奇偶交替,整体视觉雅致而温润。


八、色彩主题系统:木棕墨蓝的制笔视觉语言

8.1 色彩接口定义

任何一款设计精良的应用,背后都有一套严谨的色彩规范。该应用首先通过一个 BrushPalette 接口,将整个应用所需的色彩抽象为一个结构化的契约:

interface BrushPalette {
  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;
  hairBrown: string;
  paperWhite: string;
}
  • primary:主色调,木棕 #8A6A4F,用于按钮、强调文字、激活状态、毛笔页图标等高优先级视觉元素,是毛笔笔杆的标志性色彩。
  • primaryDark:主色深色变体 #46352A,作为顶部头部背景,营造深木笔匣的沉稳感。
  • accent:强调色,墨蓝 #3E4A5A,与木棕形成冷暖对比,用于笔洗、墨色等元素。
  • bg:页面整体背景色 #F5F3EF,灰白暖调,营造宣纸般的温润质感。
  • hairBrown:毫棕 #8A6A4F,专用于头部装饰竖条,语义上强调"毫"的质感。
  • paperWhite:纸白 #FBFAF7,用于装饰性背景,营造出宣纸的柔和质感。

毛笔坊毛笔模块真机运行效果 - 木棕主色与墨蓝强调色的视觉呈现

8.2 色彩实例化

const COLORS: BrushPalette = {
  primary: '#8A6A4F',
  primaryLight: '#C9B29A',
  primaryDark: '#46352A',
  accent: '#3E4A5A',
  accentLight: '#8494A6',
  bg: '#F5F3EF',
  cardBg: '#FFFFFF',
  cardAlt: '#EAE6DE',
  textPrimary: '#36302A',
  textSecondary: '#7A7268',
  textHint: '#A8A096',
  border: '#DFD9CE',
  line: '#EAE5DC',
  success: '#6E8F5A',
  warning: '#B0893F',
  danger: '#8E3B2E',
  white: '#FFFFFF',
  hairBrown: '#8A6A4F',
  paperWhite: '#FBFAF7'
};

主色 #8A6A4F 是一种醇厚的木棕色,既契合"制笔"主题(毛笔笔杆的标志性色彩),又具有传统、沉稳的气质。强调色 #3E4A5A 墨蓝与木棕形成冷暖对比,呼应墨色、笔洗的色彩。hairBrown 毫棕 #8A6A4F 用于头部装饰竖条,营造出毫毛排列的视觉感。背景色 #F5F3EF 灰白暖调与主色同暖色系,营造出宣纸般的温润氛围。这种将所有色彩集中管理的做法保证了全局一致性,支持主题切换,降低了维护成本。

毛笔坊笔洗模块真机运行效果 - 灰白背景与浅灰色交替卡片的层次效果


九、类型定义:构建严谨的数据契约

应用通过一组接口定义,为每一类业务对象建立了明确的数据契约。

9.1 毛笔、笔洗、笔架元数据

interface BrushItem {
  name: string; hair: string; size: string; price: number; emoji: string;
}
interface WashItem {
  name: string; glaze: string; level: number; place: string; emoji: string;
}
interface HangItem {
  name: string; material: string; count: number; origin: string; emoji: string;
}

BrushItem 描述毛笔的名称、毫料、尺寸、价格和图标。WashItem 描述笔洗的名称、釉色、窑级别、产地和图标,用 level 字段体现"以级定品"。HangItem 描述笔架的名称、材质、挂笔数、产地和图标,用 count 字段体现容量。

9.2 订单、客评与图表元数据

interface BrushOrderItem {
  name: string; region: string; count: number; amount: number; emoji: string;
}
interface BrushReviewItem {
  name: string; score: number; date: string; content: string; tag: string; emoji: string;
}
interface WeekBrushMeta { day: string; value: number; }
interface HairShareMeta { name: string; count: number; color: string; }
interface CraftHotMeta { name: string; heat: number; color: string; }
interface BrushTopMeta { name: string; sold: number; color: string; }

四组图表元数据(WeekBrushMeta/HairShareMeta/CraftHotMeta/BrushTopMeta)都自带 color 字段,实现了"数据即配色"的图表渲染模式,组件渲染时无需额外的颜色映射逻辑。


十、配置驱动:常量数据与可视化配置

10.1 Tab 导航配置

enum BrushTab { BRUSH = 0, WASH = 1, HANG = 2, ORDER = 3, REVIEW = 4 }

const TAB_LIST: TabMeta[] = [
  { key: 'brush', icon: '🖌️', label: '毛笔', color: '#8A6A4F' },
  { key: 'wash', icon: '🥣', label: '笔洗', color: '#3E4A5A' },
  { key: 'hang', icon: '🪵', label: '笔架', color: '#B0893F' },
  { key: 'order', icon: '📦', label: '订单', color: '#4E7BA8' },
  { key: 'review', icon: '⭐', label: '客评', color: '#8A6A4F' }
];

枚举提供类型安全的 Tab 标识,TAB_LIST 为每个 Tab 配置独立的图标、标签和主题色:毛笔用木棕、笔洗用墨蓝、笔架用金棕、订单用蓝色、客评用木棕,"一 Tab 一色"让每个页面有鲜明的视觉身份。

10.2 周销量、毫料构成、工艺热度与 TOP 榜

const WEEK_SOLD = [
  { day: '周一', value: 15 }, { day: '周二', value: 22 },
  { day: '周三', value: 29 }, { day: '周四', value: 35 },
  { day: '周五', value: 47 }, { day: '周六', value: 61 }, { day: '周日', value: 54 }
];
const HAIR_SHARE = [
  { name: '羊毫', count: 5, color: '#8A6A4F' },
  { name: '狼毫', count: 4, color: '#3E4A5A' },
  { name: '兼毫', count: 3, color: '#B0893F' }
];
const CRAFT_HOT = [
  { name: '选毫', heat: 95 }, { name: '脱脂', heat: 93 }, { name: '齐毫', heat: 96 },
  { name: '扎头', heat: 91 }, { name: '装管', heat: 92 }, { name: '刻字', heat: 89 }
];
const BRUSH_TOP = [
  { name: '大兰竹', sold: 96 }, { name: '小白云', sold: 93 },
  { name: '狼毫小楷', sold: 90 }, { name: '鹤颈笔', sold: 87 },
  { name: '叶筋笔', sold: 84 }, { name: '衣纹笔', sold: 81 }
];

CRAFT_HOT 的六门工艺恰好对应制笔制作的完整流程(选毫→脱脂→齐毫→扎头→装管→刻字),齐毫以 96 的热度居首(不是第一道选毫),呈现出"齐毫为核心工序"的合理分布。BRUSH_COL 常量 [0…11] 用于顶部头部的装饰带渲染,按索引模 3 分别渲染:毫棕竖条 5×16 圆角3(模0)、墨蓝圆点 8×8(模1)、竹绿叶圆 8×8(模2),形成一条毫毛排列的装饰线,呼应毛笔的"毫锋"视觉元素。


十一、数据模型:可观察的毛笔实体

@Observed
export class BrushData {
  brushes: BrushItem[] = [ /* 12 款毛笔 */ ];
  washes: WashItem[] = [ /* 12 款笔洗 */ ];
  hangs: HangItem[] = [ /* 12 款笔架 */ ];
  orders: BrushOrderItem[] = [ /* 12 条订单 */ ];
  reviews: BrushReviewItem[] = [ /* 12 条客评 */ ];
}

BrushData 是核心数据模型,以五个数组承载毛笔坊全部业务信息。@Observed 装饰器使得该类实例在被 @State 或 @ObjectLink 引用时,属性变化能自动触发 UI 刷新。每个数组精确包含 12 条数据,"五五对称、各十二条"的设计保证了每个页面都有足够的内容密度。所有数据在类定义时直接初始化,"字段即默认值"的写法避免了构造函数中重复赋值。


十二、模拟数据:构建真实的毛笔坊数据环境

12.1 毛笔模拟数据

12 款毛笔覆盖了从 46 元(小白云)到 158 元(鹤颈笔)的完整价格梯队,毫料涵盖兼毫、羊毫、狼毫、紫毫、马毫五大类,尺寸从小楷、中楷、大楷到长锋、修复不等。大兰竹¥88、小白云¥46、大白云¥62、狼毫小楷¥128、鹤颈笔¥158、叶筋笔¥76、衣纹笔¥68、玉筍笔¥96、红豆笔¥142、山马笔¥108、长锋羊毫¥118、秃笔换毫¥58,emoji 图标与毛笔名称呼应(大兰竹用🎍、小白云用☁️、狼毫小楷用🖊️、鹤颈笔用🦢等)。

12.2 笔洗与笔架模拟数据

笔洗釉色覆盖青花釉、天青釉、窑变釉、开片釉、白釉、梅子青、影青釉、茶叶末、釉里红、粉青釉、甜白釉、月白釉十二种,窑级别从 90 到 96 不等,汝窑笔洗和釉里红洗以 96 级并列居首。笔架材质覆盖大红酸枝、小叶紫檀、湘妃竹、黄花梨、黑檀木、鸡翅木、榉木、老树根、黄铜、白瓷、螺钿镶嵌、毛竹帘十二种,挂笔数从 5 到 20 不等,老竹笔帘以 20 笔居首。

12.3 订单与客评模拟数据

订单覆盖 12 类客户,少儿书法连锁以 2400 支的数量居首,日本书道院单价最高,国画工作室金额第二。客评 12 条各有独特标签(锋颖如锥、久用不开叉、勾线利器、顺手好写、全班订购、印稿精准、弹幕刷屏、礼盒体面、茶席雅物、釉色温润、老笔新生、研学指定),评价内容充满生活气息,如"狼毫小楷锋颖如锥,题跋小字锋锋见笔"、“修笔换毫手艺绝,老笔重获新生”。


十三、动态颜色函数:数据驱动的视觉映射

应用通过五个动态颜色函数将业务数据映射为视觉色彩,实现"数据即配色"。

function getBrushColor(name: string): string {
  if (name === '狼毫小楷' || name === '鹤颈笔' || name === '叶筋笔' || name === '衣纹笔') return '#3E4A5A';
  else if (name === '小白云' || name === '大白云' || name === '长锋羊毫') return '#8A6A4F';
  else if (name === '红豆笔' || name === '山马笔' || name === '玉筍笔') return '#B0893F';
  return '#4E7BA8';
}
function getWashColor(level: number): string {
  if (level >= 95) return '#3E4A5A';
  else if (level >= 92) return '#8A6A4F';
  return '#B0893F';
}
function getPriceColor(price: number): string {
  if (price >= 120) return '#3E4A5A';
  else if (price >= 90) return '#8A6A4F';
  else if (price >= 60) return '#B0893F';
  return '#4E7BA8';
}
function getOrderColor(amount: number): string {
  if (amount >= 150000) return '#3E4A5A';
  else if (amount >= 110000) return '#8A6A4F';
  else if (amount >= 80000) return '#B0893F';
  return '#4E7BA8';
}
function getReviewTagColor(tag: string): string { /* 按标签语义分四档着色 */ }
  • getBrushColor:按毛笔名称分四组色彩(狼毫小楷/鹤颈笔/叶筋笔/衣纹笔类墨蓝、小白云/大白云/长锋羊毫类木棕、红豆笔/山马笔/玉筍笔类金棕、其他蓝色)。
  • getWashColor:按窑级别分三档(95以上墨蓝、92-94木棕、其余金棕),“以色定级”。
  • getPriceColor:按价格分四档(120以上墨蓝、90-119木棕、60-89金棕、60以下蓝色)。
  • getOrderColor:按订单金额分四档(15万以上墨蓝、11-15万木棕、8-11万金棕、8万以下蓝色)。
  • getReviewTagColor:按评价标签语义分四色系。

值得注意的是,毛笔页的圆形图标背景色调用 getBrushColor,而卡片整体背景则固定使用白色与浅灰色奇偶交替,形成了"图标多彩、卡片统一白"的视觉层次。这种"圆形图标+小笔尖+不对称圆角+动态颜色图标"的组合是本应用最具辨识度的视觉设计特征。


十四、入口组件:应用的骨架与导航中枢

14.1 组件声明与状态

@Entry
@Component
struct BrushApp {
  @State curTab: number = 0;
  @State data: BrushData = new BrushData();
  @State showAddBrush: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeleteWash: boolean = false;
  @State showDetailHang: boolean = false;
  @State delWashName: string = '';
  @State detailHangName: string = '';
  @State penFloat: boolean = false;
  @State scrollSpin: boolean = false;
}

10 个 @State 状态分四类:导航状态(curTab)、数据状态(data)、弹窗控制状态(4个布尔+2个参数字符串)、交互动效状态(penFloat、scrollSpin)。

14.2 顶部头部构建

顶部头部采用 primaryDark #46352A 深木棕色背景。第一层标题栏左侧是标题"毛笔坊"和副标题"Brush · 尖齐圆健 笔写春秋",右侧是动效区:毛笔🖌️点击触发上下浮动动画(translate Y 从 3 到 -10,duration 700ms)、卷轴📜点击触发旋转动画(rotate 从 0 到 360 度,duration 900ms)、羽毛🪶静态装饰。第二层是"装饰带",通过 ForEach 遍历 BRUSH_COL,按模 3 渲染毫棕竖条 5×16 圆角3、墨蓝圆点 8×8、竹绿叶圆 8×8,形成一条毫毛排列的装饰线,呼应毛笔的"毫锋"视觉元素。第三层是标语分割线"🪶 尖齐圆健 · 健笔凌云 🖌️"。

14.3 底部 Tab 导航

底部导航顶部有一条 3px 高的木棕色装饰条,与深木棕色头部形成上下呼应。每个 Tab 激活态有三重视觉反馈:标签文字主题色加粗、背景填充主题色 8% 透明度、主题色实线边框,圆角 12(笔锋造型风格)。非激活态用灰色文字、透明背景、无边框。导航栏高度 60。

14.4 条件渲染弹窗

应用采用条件渲染(if-else)实现四个弹窗:新制毛笔(AddBrushModal)、修改订单(EditBrushOrderModal)、撤下笔洗(DeleteWashModal)、笔架详情(DetailHangModal)。弹窗直接叠加在根 Column 末尾,通过布尔状态控制显隐,每个弹窗内部是全屏半透明遮罩(#66000000)+ 居中白色卡片。弹窗卡片统一使用圆角 22,信息行圆角 14。


十五、关键技术特性对比

特性维度 实现方式 技术要点 设计价值
色彩管理 BrushPalette 接口 + COLORS 常量 集中定义 20 种语义色,含 hairBrown/paperWhite 装饰色 保证视觉一致性,支持主题切换
数据建模 @Observed 装饰的 BrushData 类 5 个数组各 12 条数据,覆盖制笔全业务维度 数据与 UI 双向联动,响应式更新
配置驱动 常量数组 + 元数据接口 WEEK_SOLD/HAIR_SHARE/CRAFT_HOT/BRUSH_TOP 自带 color 数据与表现解耦,扩展零成本
动态颜色 5 个全局映射函数 按名称/级别/价格/金额/标签分档着色,毛笔页图标多彩卡片统一白 数据即配色,信息密度翻倍
圆形图标+小笔尖 56宽图标区+44x44圆形+✒️笔尖 毛笔页左侧图标区包含圆形图标和小笔尖,形成毛笔造型 视觉辨识度高,主题感强
不对称圆角 borderRadius 对象四值 毛笔/笔架/客评页正向不对称,笔洗/订单页反向不对称 模仿毛笔笔锋造型,视觉呼应
状态管理 @State + @ObjectLink 10 个状态分四类(导航/数据/弹窗/动效) 细粒度状态控制,自动触发渲染
组件化 @Component + @Builder 5 个页面组件 + 4 个弹窗组件 + BrushTag 通用组件 高内聚低耦合,代码复用率高
模态交互 条件渲染弹窗 + 全屏遮罩 4 个弹窗 if-else 叠加,#66000000 半透明遮罩 灵活可控,完全自定义布局
列表渲染 ForEach + 奇偶交替背景 12 条数据 × 5 页面,cardBg/cardAlt 交替 数据驱动渲染,长列表可读性强
数据可视化 柱状图 + 横向条形图 height 绑定数据、layoutWeight 比例法、阈值着色 多维度呈现,直观易读
星级渲染 String.repeat 技巧 ‘⭐’.repeat(score) 一行代码生成星级 简洁高效,无需循环
防御编程 字段默认值 + 类型安全 所有数据字段声明即初始化,枚举替代魔法数字 健壮性强,无运行时异常
布局系统 Column/Row + layoutWeight 弹性权重分配、条件渲染弹窗、Scroll 滚动容器 自适应布局,层次分明
交互动效 translate + rotate + animation 毛笔浮动 700ms、卷轴旋转 900ms、EaseOut 曲线 静态头部灵动化,趣味性强
视觉层次 装饰带/不对称圆角/圆形图标 BRUSH_COL 模3渲染毫棕竖条装饰、全局不对称圆角、毛笔页圆形图标 精致细腻,制笔氛围感强

完整代码

interface BrushPalette {
  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;
  hairBrown: string;
  paperWhite: string;
}

const COLORS: BrushPalette = {
  primary: '#8A6A4F',
  primaryLight: '#C9B29A',
  primaryDark: '#46352A',
  accent: '#3E4A5A',
  accentLight: '#8494A6',
  bg: '#F5F3EF',
  cardBg: '#FFFFFF',
  cardAlt: '#EAE6DE',
  textPrimary: '#36302A',
  textSecondary: '#7A7268',
  textHint: '#A8A096',
  border: '#DFD9CE',
  line: '#EAE5DC',
  success: '#6E8F5A',
  warning: '#B0893F',
  danger: '#8E3B2E',
  white: '#FFFFFF',
  hairBrown: '#8A6A4F',
  paperWhite: '#FBFAF7'
};

enum BrushTab {
  BRUSH = 0,
  WASH = 1,
  HANG = 2,
  ORDER = 3,
  REVIEW = 4
}

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

const TAB_LIST: TabMeta[] = [
  { key: 'brush', icon: '🖌️', label: '毛笔', color: '#8A6A4F' },
  { key: 'wash', icon: '🥣', label: '笔洗', color: '#3E4A5A' },
  { key: 'hang', icon: '🪵', label: '笔架', color: '#B0893F' },
  { key: 'order', icon: '📦', label: '订单', color: '#4E7BA8' },
  { key: 'review', icon: '⭐', label: '客评', color: '#8A6A4F' }
];

const BRUSH_COL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];

interface BrushItem {
  name: string;
  hair: string;
  size: string;
  price: number;
  emoji: string;
}

interface WashItem {
  name: string;
  glaze: string;
  level: number;
  place: string;
  emoji: string;
}

interface HangItem {
  name: string;
  material: string;
  count: number;
  origin: string;
  emoji: string;
}

interface BrushOrderItem {
  name: string;
  region: string;
  count: number;
  amount: number;
  emoji: string;
}

interface BrushReviewItem {
  name: string;
  score: number;
  date: string;
  content: string;
  tag: string;
  emoji: string;
}

interface WeekBrushMeta {
  day: string;
  value: number;
}

interface HairShareMeta {
  name: string;
  count: number;
  color: string;
}

interface CraftHotMeta {
  name: string;
  heat: number;
  color: string;
}

interface BrushTopMeta {
  name: string;
  sold: number;
  color: string;
}

const WEEK_SOLD: WeekBrushMeta[] = [
  { day: '周一', value: 15 },
  { day: '周二', value: 22 },
  { day: '周三', value: 29 },
  { day: '周四', value: 35 },
  { day: '周五', value: 47 },
  { day: '周六', value: 61 },
  { day: '周日', value: 54 }
];

const HAIR_SHARE: HairShareMeta[] = [
  { name: '羊毫', count: 5, color: '#8A6A4F' },
  { name: '狼毫', count: 4, color: '#3E4A5A' },
  { name: '兼毫', count: 3, color: '#B0893F' }
];

const CRAFT_HOT: CraftHotMeta[] = [
  { name: '选毫', heat: 95, color: '#8A6A4F' },
  { name: '脱脂', heat: 93, color: '#3E4A5A' },
  { name: '齐毫', heat: 96, color: '#B0893F' },
  { name: '扎头', heat: 91, color: '#4E7BA8' },
  { name: '装管', heat: 92, color: '#7C8AA0' },
  { name: '刻字', heat: 89, color: '#8E3B2E' }
];

const BRUSH_TOP: BrushTopMeta[] = [
  { name: '大兰竹', sold: 96, color: '#8A6A4F' },
  { name: '小白云', sold: 93, color: '#3E4A5A' },
  { name: '狼毫小楷', sold: 90, color: '#B0893F' },
  { name: '鹤颈笔', sold: 87, color: '#7C8AA0' },
  { name: '叶筋笔', sold: 84, color: '#4E7BA8' },
  { name: '衣纹笔', sold: 81, color: '#8E3B2E' }
];

@Observed
export class BrushData {
  brushes: BrushItem[] = [
    { name: '大兰竹', hair: '兼毫', size: '大楷', price: 88, emoji: '🎍' },
    { name: '小白云', hair: '羊毫', size: '中楷', price: 46, emoji: '☁️' },
    { name: '大白云', hair: '羊毫', size: '大楷', price: 62, emoji: '🌤️' },
    { name: '狼毫小楷', hair: '狼毫', size: '小楷', price: 128, emoji: '🖊️' },
    { name: '鹤颈笔', hair: '狼毫', size: '长锋', price: 158, emoji: '🦢' },
    { name: '叶筋笔', hair: '狼毫', size: '小楷', price: 76, emoji: '🍃' },
    { name: '衣纹笔', hair: '狼毫', size: '小楷', price: 68, emoji: '👕' },
    { name: '玉筍笔', hair: '兼毫', size: '中楷', price: 96, emoji: '🌽' },
    { name: '红豆笔', hair: '紫毫', size: '小楷', price: 142, emoji: '🫘' },
    { name: '山马笔', hair: '马毫', size: '大楷', price: 108, emoji: '🐎' },
    { name: '长锋羊毫', hair: '羊毫', size: '长锋', price: 118, emoji: '📜' },
    { name: '秃笔换毫', hair: '兼毫', size: '修复', price: 58, emoji: '♻️' }
  ];

  washes: WashItem[] = [
    { name: '青花笔洗', glaze: '青花釉', level: 94, place: '景德镇', emoji: '🔹' },
    { name: '汝窑笔洗', glaze: '天青釉', level: 96, place: '宝丰', emoji: '🟦' },
    { name: '钧窑笔洗', glaze: '窑变釉', level: 95, place: '禹州', emoji: '🌌' },
    { name: '哥窑笔洗', glaze: '开片釉', level: 93, place: '龙泉', emoji: '🕸️' },
    { name: '定窑笔洗', glaze: '白釉', level: 92, place: '曲阳', emoji: '🤍' },
    { name: '龙泉笔洗', glaze: '梅子青', level: 94, place: '龙泉', emoji: '🟢' },
    { name: '影青笔洗', glaze: '影青釉', level: 91, place: '湖田', emoji: '🩵' },
    { name: '茶叶末洗', glaze: '茶叶末', level: 90, place: '淄博', emoji: '🍵' },
    { name: '釉里红洗', glaze: '釉里红', level: 96, place: '景德镇', emoji: '🔴' },
    { name: '粉青笔洗', glaze: '粉青釉', level: 93, place: '龙泉', emoji: '💚' },
    { name: '甜白笔洗', glaze: '甜白釉', level: 92, place: '德化', emoji: '🥛' },
    { name: '月白笔洗', glaze: '月白釉', level: 91, place: '汝州', emoji: '🌙' }
  ];

  hangs: HangItem[] = [
    { name: '红木笔架', material: '大红酸枝', count: 8, origin: '苏州', emoji: '🪵' },
    { name: '紫檀笔山', material: '小叶紫檀', count: 5, origin: '北京', emoji: '⛰️' },
    { name: '竹节笔挂', material: '湘妃竹', count: 12, origin: '湖南', emoji: '🎋' },
    { name: '黄花梨架', material: '黄花梨', count: 6, origin: '海南', emoji: '🌴' },
    { name: '黑檀笔架', material: '黑檀木', count: 10, origin: '东阳', emoji: '⬛' },
    { name: '鸡翅木架', material: '鸡翅木', count: 9, origin: '广西', emoji: '🍗' },
    { name: '榉木笔山', material: '榉木', count: 7, origin: '苏州', emoji: '🪑' },
    { name: '根雕笔挂', material: '老树根', count: 14, origin: '福建', emoji: '🌳' },
    { name: '铜制笔架', material: '黄铜', count: 6, origin: '佛山', emoji: '🟡' },
    { name: '瓷雕笔山', material: '白瓷', count: 5, origin: '德化', emoji: '🏺' },
    { name: '螺钿笔挂', material: '螺钿镶嵌', count: 8, origin: '扬州', emoji: '🐚' },
    { name: '老竹笔帘', material: '毛竹帘', count: 20, origin: '安吉', emoji: '🧵' }
  ];

  orders: BrushOrderItem[] = [
    { name: '美院书画系', region: '华东', count: 620, amount: 148000, emoji: '🎓' },
    { name: '少儿书法连锁', region: '华北', count: 2400, amount: 116000, emoji: '🧒' },
    { name: '文房四宝店', region: '华中', count: 860, amount: 132000, emoji: '🏪' },
    { name: '书画培训机构', region: '西南', count: 1100, amount: 98000, emoji: '📚' },
    { name: '老年大学', region: '东北', count: 540, amount: 62000, emoji: '👴' },
    { name: '国画工作室', region: '华东', count: 380, amount: 156000, emoji: '🖼️' },
    { name: '印社篆刻行', region: '华南', count: 260, amount: 74000, emoji: '🯣' },
    { name: '文创礼品公司', region: '华北', count: 1500, amount: 108000, emoji: '🎁' },
    { name: '日本书道院', region: '海外', count: 420, amount: 186000, emoji: '🗾' },
    { name: '书法直播基地', region: '华东', count: 1900, amount: 92000, emoji: '🎥' },
    { name: '茶空间雅集', region: '华南', count: 320, amount: 58000, emoji: '🍵' },
    { name: '孔庙文创部', region: '华东', count: 480, amount: 124000, emoji: '🏛️' }
  ];

  reviews: BrushReviewItem[] = [
    { name: '美院教授', score: 5, date: '09-04', content: '狼毫小楷锋颖如锥,题跋小字锋锋见笔。', tag: '锋颖如锥', emoji: '🎓' },
    { name: '书法老师', score: 5, date: '09-03', content: '少儿班人手一支小白云,开叉率极低。', tag: '久用不开叉', emoji: '🧒' },
    { name: '国画画师', score: 5, date: '09-02', content: '鹤颈笔勾线如行云,衣纹一气呵成。', tag: '勾线利器', emoji: '🖼️' },
    { name: '老年学员', score: 4, 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: 5, date: '08-25', content: '修笔换毫手艺绝,老笔重获新生。', tag: '老笔新生', emoji: '♻️' },
    { name: '孔庙经理', score: 5, date: '08-24', content: '研学团指定用笔,家长回头加购。', tag: '研学指定', emoji: '🏛️' }
  ];
}

function getBrushColor(name: string): string {
  if (name === '狼毫小楷' || name === '鹤颈笔' || name === '叶筋笔' || name === '衣纹笔') {
    return '#3E4A5A';
  } else if (name === '小白云' || name === '大白云' || name === '长锋羊毫') {
    return '#8A6A4F';
  } else if (name === '红豆笔' || name === '山马笔' || name === '玉筍笔') {
    return '#B0893F';
  }
  return '#4E7BA8';
}

function getWashColor(level: number): string {
  if (level >= 95) {
    return '#3E4A5A';
  } else if (level >= 92) {
    return '#8A6A4F';
  }
  return '#B0893F';
}

function getPriceColor(price: number): string {
  if (price >= 120) {
    return '#3E4A5A';
  } else if (price >= 90) {
    return '#8A6A4F';
  } else if (price >= 60) {
    return '#B0893F';
  }
  return '#4E7BA8';
}

function getOrderColor(amount: number): string {
  if (amount >= 150000) {
    return '#3E4A5A';
  } else if (amount >= 110000) {
    return '#8A6A4F';
  } else if (amount >= 80000) {
    return '#B0893F';
  }
  return '#4E7BA8';
}

function getReviewTagColor(tag: string): string {
  if (tag === '锋颖如锥' || tag === '全班订购' || tag === '印稿精准' || tag === '弹幕刷屏') {
    return '#3E4A5A';
  } else if (tag === '久用不开叉' || tag === '勾线利器' || tag === '顺手好写') {
    return '#8A6A4F';
  } else if (tag === '礼盒体面' || tag === '茶席雅物' || tag === '釉色温润' || tag === '老笔新生' || tag === '研学指定') {
    return '#B0893F';
  }
  return '#4E7BA8';
}

@Entry
@Component
struct BrushApp {
  @State curTab: number = 0;
  @State data: BrushData = new BrushData();
  @State showAddBrush: boolean = false;
  @State showEditOrder: boolean = false;
  @State showDeleteWash: boolean = false;
  @State showDetailHang: boolean = false;
  @State delWashName: string = '';
  @State detailHangName: string = '';
  @State penFloat: boolean = false;
  @State scrollSpin: boolean = false;

  @Builder modalOverlay(onClose: () => void) {
    Column() {
      Text('')
        .width(0)
        .height(0)
        .opacity(0)
      Button('')
        .width(1)
        .height(1)
        .opacity(0)
        .onClick(() => {
          onClose();
        })
    }
    .width(1)
    .height(1)
  }

  build() {
    Column() {
      Column() {
        Column() {
          Row() {
            Column() {
              Text('🖌️ 毛笔坊')
                .fontSize(20)
                .fontWeight(FontWeight.Bold)
                .fontColor(COLORS.white)
              Text('Brush · 尖齐圆健 笔写春秋')
                .fontSize(10)
                .fontColor(COLORS.primaryLight)
                .margin({ top: 3 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)
            Row() {
              Text('🖌️')
                .fontSize(18)
                .onClick(() => {
                  this.penFloat = !this.penFloat;
                })
                .translate({ x: 0, y: this.penFloat ? -10 : 3 })
                .animation({ duration: 700, curve: Curve.EaseOut })
              Text('📜')
                .fontSize(16)
                .margin({ left: 10 })
                .onClick(() => {
                  this.scrollSpin = !this.scrollSpin;
                })
                .rotate({ angle: this.scrollSpin ? 360 : 0 })
                .animation({ duration: 900, curve: Curve.EaseOut })
              Text('🪶')
                .fontSize(14)
                .margin({ left: 6 })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(COLORS.primaryLight + '2E')
            .borderRadius(26)
            .border({ width: 1, color: COLORS.primaryLight + '80' })
          }
          .width('100%')

          Row() {
            ForEach(BRUSH_COL, (r: number) => {
              Row() {
                if (r % 3 === 0) {
                  Column()
                    .width(5)
                    .height(16)
                    .backgroundColor(COLORS.hairBrown)
                    .borderRadius(3)
                } else if (r % 3 === 1) {
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.accent)
                    .borderRadius(4)
                } else {
                  Column()
                    .width(8)
                    .height(8)
                    .backgroundColor(COLORS.success)
                    .borderRadius(4)
                }
              }
              .width(14)
              .justifyContent(FlexAlign.Center)
            }, (r: number) => 'brush' + r)
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ top: 12 })

          Row() {
            Column()
              .layoutWeight(1)
              .height(1)
              .backgroundColor(COLORS.primaryLight + '66')
            Text('🪶 尖齐圆健 · 健笔凌云 🖌️')
              .fontSize(9)
              .fontColor(COLORS.primaryLight)
              .margin({ left: 8, right: 8 })
            Column()
              .layoutWeight(1)
              .height(1)
              .backgroundColor(COLORS.primaryLight + '66')
          }
          .width('100%')
          .margin({ top: 10 })
        }
        .width('100%')
        .padding({ left: 16, right: 16, top: 12, bottom: 12 })
        .backgroundColor(COLORS.primaryDark)

        if (this.curTab === BrushTab.BRUSH) {
          BrushContent({ data: this.data, onAdd: () => {
            this.showAddBrush = true;
          } })
        } else if (this.curTab === BrushTab.WASH) {
          WashContent({ data: this.data, onDel: (n: string) => {
            this.delWashName = n;
            this.showDeleteWash = true;
          } })
        } else if (this.curTab === BrushTab.HANG) {
          HangContent({ data: this.data, onDetail: (n: string) => {
            this.detailHangName = n;
            this.showDetailHang = true;
          } })
        } else if (this.curTab === BrushTab.ORDER) {
          BrushOrderContent({ data: this.data, onEdit: () => {
            this.showEditOrder = true;
          } })
        } else {
          BrushReviewContent({ data: this.data })
        }
      }
      .width('100%')
      .height('100%')

      Column() {
        Column()
          .width('100%')
          .height(3)
          .backgroundColor(COLORS.primary)
        Row() {
          ForEach(TAB_LIST, (t: TabMeta) => {
            Column() {
              Text(t.icon)
                .fontSize(19)
              Text(t.label)
                .fontSize(10)
                .fontColor(this.curTab === TAB_LIST.indexOf(t) ? t.color : COLORS.textHint)
                .fontWeight(this.curTab === TAB_LIST.indexOf(t) ? FontWeight.Bold : FontWeight.Normal)
                .margin({ top: 2 })
            }
            .layoutWeight(1)
            .justifyContent(FlexAlign.Center)
            .padding({ top: 7, bottom: 7 })
            .backgroundColor(this.curTab === TAB_LIST.indexOf(t) ? t.color + '14' : '#00000000')
            .borderRadius(12)
            .border(this.curTab === TAB_LIST.indexOf(t) ? { width: 1, color: t.color } : { width: 0 })
            .onClick(() => {
              this.curTab = TAB_LIST.indexOf(t);
            })
          }, (t: TabMeta) => t.key)
        }
        .width('100%')
        .height(60)
        .padding({ left: 8, right: 8 })
        .backgroundColor(COLORS.cardBg)
      }
      .width('100%')

      if (this.showAddBrush) {
        AddBrushModal({
          onClose: () => {
            this.showAddBrush = false;
          }
        })
      }
      if (this.showEditOrder) {
        EditBrushOrderModal({
          onClose: () => {
            this.showEditOrder = false;
          }
        })
      }
      if (this.showDeleteWash) {
        DeleteWashModal({
          title: this.delWashName, onClose: () => {
            this.showDeleteWash = false;
          }
        })
      }
      if (this.showDetailHang) {
        DetailHangModal({
          name: this.detailHangName, onClose: () => {
            this.showDetailHang = false;
          }
        })
      }
    }
  }
}

@Component
struct BrushTag {
  @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 + '14')
      .borderRadius(6)
      .border({ width: 1, color: this.color + '40' })
  }
}

@Component
struct BrushContent {
  @ObjectLink data: BrushData;
  onAdd: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('📈 本周毛笔销量')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('周六61支')
              .fontSize(9)
              .fontColor(COLORS.primary)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          Row() {
            ForEach(WEEK_SOLD, (w: WeekBrushMeta) => {
              Column() {
                Text(w.value + '')
                  .fontSize(8)
                  .fontColor(w.value >= 45 ? COLORS.primary : COLORS.accent)
                Column()
                  .width(14)
                  .height(w.value)
                  .backgroundColor(w.value >= 45 ? COLORS.primary : COLORS.accent)
                  .borderRadius(7)
                  .margin({ top: 4 })
                Text(w.day)
                  .fontSize(9)
                  .fontColor(COLORS.textSecondary)
                  .margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (w: WeekBrushMeta) => w.day)
          }
          .width('100%')
          .height(150)
          .alignItems(VerticalAlign.Bottom)
          .margin({ top: 10 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.accent + '44' })

        Column() {
          Row() {
            Text('🖌️ 笔挂墙')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击➕制笔')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
            Text('➕')
              .fontSize(14)
              .margin({ left: 8 })
              .onClick(() => {
                this.onAdd();
              })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.brushes, (p: BrushItem, i: number) => {
            Row() {
              Column() {
                Column() {
                  Text(p.emoji)
                    .fontSize(17)
                    .textAlign(TextAlign.Center)
                }
                .width(44)
                .height(44)
                .justifyContent(FlexAlign.Center)
                .alignItems(HorizontalAlign.Center)
                .backgroundColor(getBrushColor(p.name) + '14')
                .borderRadius(22)
                .border({ width: 1, color: getBrushColor(p.name) + '66' })
                Text('✒️')
                  .fontSize(9)
                  .margin({ top: 3 })
              }
              .width(56)
              .alignItems(HorizontalAlign.Center)

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('¥' + p.price)
                    .fontSize(10)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(getPriceColor(p.price))
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                Row() {
                  BrushTag({ text: p.hair, color: getBrushColor(p.name) })
                  BrushTag({ text: p.size, color: COLORS.accent })
                  Text('尖齐圆健')
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 8 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius({ topLeft: 24, topRight: 8, bottomLeft: 8, bottomRight: 24 })
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: BrushItem, i: number) => 'bp' + p.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct WashContent {
  @ObjectLink data: BrushData;
  onDel: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🏆 毛笔销量 TOP6')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('大兰竹居首')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(BRUSH_TOP, (t: BrushTopMeta) => {
            Row() {
              Text(t.name)
                .fontSize(10)
                .width(64)
                .fontColor(COLORS.textPrimary)
              Row() {
                Row()
                  .layoutWeight(t.sold)
                  .height(12)
                  .backgroundColor(t.color)
                  .borderRadius(6)
                Row()
                  .layoutWeight(100 - t.sold)
                  .height(12)
                  .backgroundColor(COLORS.line)
                  .borderRadius(6)
              }
              .layoutWeight(1)
              .height(12)
              .margin({ left: 6 })
              Text(t.sold + '')
                .fontSize(10)
                .width(28)
                .textAlign(TextAlign.End)
                .fontColor(t.color)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (t: BrushTopMeta) => t.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.accent + '44' })

        Column() {
          Row() {
            Text('🥣 笔洗案')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击🗑️撤洗')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.washes, (p: WashItem, i: number) => {
            Row() {
              Column() {
                Text(p.emoji)
                  .fontSize(18)
                  .textAlign(TextAlign.Center)
              }
              .width(44)
              .height(44)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .backgroundColor(getWashColor(p.level) + '14')
              .borderRadius(10)
              .border({ width: 1, color: getWashColor(p.level) + '66' })

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(p.glaze)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ left: 8 })
                  Text('🗑️')
                    .fontSize(12)
                    .margin({ left: 8 })
                    .onClick(() => {
                      this.onDel(p.name);
                    })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                Row() {
                  Text('窑级' + p.level)
                    .fontSize(9)
                    .fontColor(getWashColor(p.level))
                  Text(p.place + ' · 窑口')
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius({ topLeft: 8, topRight: 24, bottomLeft: 24, bottomRight: 8 })
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: WashItem, i: number) => 'ws' + p.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct HangContent {
  @ObjectLink data: BrushData;
  onDetail: (n: string) => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('毫料构成')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('羊毫为多')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(HAIR_SHARE, (s: HairShareMeta) => {
            Row() {
              Text(s.name)
                .fontSize(10)
                .width(40)
                .fontColor(COLORS.textPrimary)
              Row() {
                Row()
                  .layoutWeight(s.count)
                  .height(14)
                  .backgroundColor(s.color)
                  .borderRadius(7)
                Row()
                  .layoutWeight(12 - s.count)
                  .height(14)
                  .backgroundColor(COLORS.line)
                  .borderRadius(7)
              }
              .layoutWeight(1)
              .height(14)
              .margin({ left: 6 })
              Text(s.count + ' 款')
                .fontSize(10)
                .width(36)
                .textAlign(TextAlign.End)
                .fontColor(s.color)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (s: HairShareMeta) => s.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.accent + '44' })

        Column() {
          Row() {
            Text('🪵 笔架阁')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击👁️赏架')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.hangs, (p: HangItem, i: number) => {
            Row() {
              Column() {
                Text(p.emoji)
                  .fontSize(18)
                  .textAlign(TextAlign.Center)
              }
              .width(44)
              .height(44)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .backgroundColor(COLORS.warning + '14')
              .borderRadius(10)
              .border({ width: 1, color: COLORS.warning + '66' })

              Column() {
                Row() {
                  Text(p.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text(p.material)
                    .fontSize(9)
                    .fontColor(COLORS.textSecondary)
                    .margin({ left: 8 })
                  Text('👁️')
                    .fontSize(12)
                    .margin({ left: 8 })
                    .onClick(() => {
                      this.onDetail(p.name);
                    })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                Row() {
                  Text('挂' + p.count + '笔')
                    .fontSize(9)
                    .fontColor(COLORS.warning)
                  Text(p.origin + ' · 工坊')
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 8 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius({ topLeft: 24, topRight: 8, bottomLeft: 8, bottomRight: 24 })
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (p: HangItem, i: number) => 'hg' + p.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct BrushOrderContent {
  @ObjectLink data: BrushData;
  onEdit: () => void = () => {};

  build() {
    Scroll() {
      Column() {
        Column() {
          Row() {
            Text('🔥 制笔工艺热度')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('齐毫最要紧')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(CRAFT_HOT, (h: CraftHotMeta) => {
            Row() {
              Text(h.name)
                .fontSize(10)
                .width(52)
                .fontColor(COLORS.textPrimary)
              Row() {
                Row()
                  .layoutWeight(h.heat)
                  .height(10)
                  .backgroundColor(h.color)
                  .borderRadius(5)
                Row()
                  .layoutWeight(100 - h.heat)
                  .height(10)
                  .backgroundColor(COLORS.line)
                  .borderRadius(5)
              }
              .layoutWeight(1)
              .height(10)
              .margin({ left: 6 })
              Text(h.heat + '')
                .fontSize(10)
                .width(28)
                .textAlign(TextAlign.End)
                .fontColor(h.color)
            }
            .width('100%')
            .margin({ top: 6 })
          }, (h: CraftHotMeta) => h.name)
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardBg)
        .borderRadius(18)
        .border({ width: 1, color: COLORS.accent + '44' })

        Column() {
          Row() {
            Text('📦 笔坊订单')
              .fontSize(13)
              .fontWeight(FontWeight.Bold)
              .fontColor(COLORS.textPrimary)
            Text('点击✏️改单')
              .fontSize(9)
              .fontColor(COLORS.textHint)
              .margin({ left: 8 })
          }
          .width('100%')
          .justifyContent(FlexAlign.SpaceBetween)
          .margin({ bottom: 10 })
          ForEach(this.data.orders, (o: BrushOrderItem, i: number) => {
            Row() {
              Column() {
                Text(o.emoji)
                  .fontSize(16)
                  .textAlign(TextAlign.Center)
              }
              .width(40)
              .height(40)
              .justifyContent(FlexAlign.Center)
              .alignItems(HorizontalAlign.Center)
              .backgroundColor(getOrderColor(o.amount) + '14')
              .borderRadius(20)
              .border({ width: 1, color: getOrderColor(o.amount) + '66' })

              Column() {
                Row() {
                  Text(o.name)
                    .fontSize(13)
                    .fontWeight(FontWeight.Bold)
                    .fontColor(COLORS.textPrimary)
                  Text('✏️')
                    .fontSize(12)
                    .margin({ left: 8 })
                    .onClick(() => {
                      this.onEdit();
                    })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                Row() {
                  Text(o.region)
                    .fontSize(9)
                    .fontColor(getOrderColor(o.amount))
                  Text('· ' + o.count + ' 支 · ¥' + o.amount)
                    .fontSize(9)
                    .fontColor(COLORS.textHint)
                    .margin({ left: 6 })
                }
                .width('100%')
                .justifyContent(FlexAlign.Start)
                .margin({ top: 6 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(10)
            .backgroundColor(i % 2 === 0 ? COLORS.cardBg : COLORS.cardAlt)
            .borderRadius({ topLeft: 8, topRight: 24, bottomLeft: 24, bottomRight: 8 })
            .border({ width: 1, color: COLORS.line })
            .margin({ top: 8 })
          }, (o: BrushOrderItem, i: number) => 'od' + o.name + i)
        }
        .width('100%')
        .margin({ top: 12 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct BrushReviewContent {
  @ObjectLink data: BrushData;

  build() {
    Scroll() {
      Column() {
        Row() {
          Text('⭐ 客评口碑')
            .fontSize(13)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('12条好评 · 平均4.9')
            .fontSize(9)
            .fontColor(COLORS.textHint)
            .margin({ left: 8 })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        .margin({ bottom: 6 })
        ForEach(this.data.reviews, (r: BrushReviewItem, 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)
              BrushTag({ 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({ topLeft: 24, topRight: 8, bottomLeft: 8, bottomRight: 24 })
          .border({ width: 1, color: COLORS.line })
          .margin({ top: 8 })
        }, (r: BrushReviewItem, i: number) => 'rv' + r.name + i)
      }
      .width('100%')
      .constraintSize({ maxHeight: '100%' })
    }
    .width('100%')
    .constraintSize({ maxHeight: '100%' })
    .scrollable(ScrollDirection.Vertical)
    .scrollBar(BarState.Off)
  }
}

@Component
struct AddBrushModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('🖌️ 新制毛笔')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Row() {
          Text('品名')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('冬狼大楷')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(14)
        .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(14)
        .margin({ top: 8 })
        Row() {
          Text('定价')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('¥168')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(14)
        .margin({ top: 8 })
        Row() {
          Text('取消')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(16)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.onClose();
            })
          Text('确认制笔')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .backgroundColor(COLORS.primary)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 14 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(22)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct EditBrushOrderModal {
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('✏️ 修改订单')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Row() {
          Text('客户')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('少儿书法连锁')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(14)
        .margin({ top: 14 })
        Row() {
          Text('数量')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('2400 → 3200 支')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(14)
        .margin({ top: 8 })
        Row() {
          Text('金额')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('¥152000')
            .fontSize(11)
            .fontColor(COLORS.accent)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(14)
        .margin({ top: 8 })
        Row() {
          Text('取消')
            .fontSize(12)
            .fontColor(COLORS.textSecondary)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .borderRadius(16)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.onClose();
            })
          Text('保存修改')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .backgroundColor(COLORS.primary)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 14 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(22)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DeleteWashModal {
  @Prop title: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Text('🗑️ 撤下笔洗')
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.textPrimary)
        Text('确定撤下「' + this.title + '」吗?')
          .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(16)
            .border({ width: 1, color: COLORS.border })
            .onClick(() => {
              this.onClose();
            })
          Text('确认撤下')
            .fontSize(12)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.white)
            .padding({ left: 16, right: 16, top: 7, bottom: 7 })
            .backgroundColor(COLORS.danger)
            .borderRadius(16)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.End)
        .margin({ top: 16 })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(22)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

@Component
struct DetailHangModal {
  @Prop name: string;
  onClose: () => void = () => {};

  build() {
    Column() {
      Column() {
        Row() {
          Text('👁️ 笔架详情')
            .fontSize(16)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
          Text('✕')
            .fontSize(16)
            .fontColor(COLORS.textHint)
            .onClick(() => {
              this.onClose();
            })
        }
        .width('100%')
        .justifyContent(FlexAlign.SpaceBetween)
        Column() {
          Text('🪵')
            .fontSize(30)
          Text(this.name)
            .fontSize(14)
            .fontWeight(FontWeight.Bold)
            .fontColor(COLORS.textPrimary)
            .margin({ top: 6 })
        }
        .width('100%')
        .padding(14)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(16)
        .margin({ top: 12 })
        Text('选老料开形,随木就势凿出峰峦。打磨数遍至手感温润,再上蜂蜡护木。架上一排笔悬锋朝下,毫锋不受损,案头亦添一段山林气。')
          .fontSize(11)
          .fontColor(COLORS.textSecondary)
          .lineHeight(17)
          .margin({ top: 10 })
        Row() {
          Text('产地工坊')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('江苏 · 苏州')
            .fontSize(11)
            .fontColor(COLORS.textPrimary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(14)
        .margin({ top: 8 })
        Row() {
          Text('匠人评级')
            .fontSize(11)
            .fontColor(COLORS.textSecondary)
            .width(56)
          Text('特级 · 木作廿二年')
            .fontSize(11)
            .fontColor(COLORS.primary)
        }
        .width('100%')
        .padding(10)
        .backgroundColor(COLORS.cardAlt)
        .borderRadius(14)
        .margin({ top: 8 })
        Text('知道了')
          .fontSize(12)
          .fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
          .padding({ left: 18, right: 18, top: 7, bottom: 7 })
          .backgroundColor(COLORS.primary)
          .borderRadius(16)
          .margin({ top: 12 })
          .onClick(() => {
            this.onClose();
          })
      }
      .width('86%')
      .padding(16)
      .backgroundColor(COLORS.cardBg)
      .borderRadius(22)
      .constraintSize({ maxHeight: '78%' })
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#66000000')
  }
}

十六、开发环境搭建

16.1 安装 DevEco Studio

DevEco Studio 是 HarmonyOS 应用开发的官方 IDE。下载安装包后双击运行,进入安装欢迎界面,点击「下一步」继续。

DevEco Studio 安装欢迎界面

16.2 选择安装路径

选择目标安装目录,建议安装到非系统盘,确认所需空间后点击「下一步」。

选择安装位置

16.3 配置安装选项

设置环境变量,勾选创建桌面快捷方式、将 bin 文件夹添加到 PATH、添加上下文菜单,但是需要重启一下电脑使环境变量生效。

安装选项配置

16.4 选择项目模板

新建一个空白模板,在模板市场中选择 Empty Ability。

选择 Empty Ability 模板

16.5 配置项目信息

设置 API 为 24 的模板项目,项目名称 kfschool,Compatible SDK 6.1.1(24),设备类型勾选 Phone,点击完成后初始化项目,自动下载相关依赖。

配置项目信息


十七、总结

通过对这款毛笔坊非遗制笔应用的深度剖析,我们可以看到,一个优秀的移动端应用远不止是功能的堆砌,它更是架构设计、数据建模、视觉系统、交互逻辑、防御性编程等多维度能力的综合体现。

在架构层面,该应用采用了"配置驱动 + 组件化 + 状态管理"的三层设计模式。配置层将所有可变的数据与视觉属性抽离为常量数组和元数据接口,使得业务逻辑与表现层彻底解耦;组件层将五个功能页面拆分为独立的 @Component,每个组件内部通过 @Builder 进一步复用 UI 片段;状态层利用 ArkUI 的 @State、@Observed 和 @ObjectLink 机制,实现了数据变化到 UI 刷新的自动响应。

在数据层面,BrushData 类作为核心数据模型,以 5 个数组各 12 条数据完整描述了毛笔坊的全部业务信息。5 个动态颜色映射函数将业务数据转化为视觉色彩,实现了"数据即配色"的高级渲染模式,这是该应用最具特色的技术亮点之一。

在视觉层面,本应用独创了"圆形图标+小笔尖+不对称圆角"的毛笔造型设计——毛笔页每个卡片左侧是一个 56 宽的图标区,包含 44×44 圆形图标(圆角22)和下方一个 ✒️ 小笔尖图标,整体形成毛笔造型;卡片使用不对称圆角(topLeft24/topRight8/bottomLeft8/bottomRight24),模仿毛笔笔锋的造型感。笔洗页和订单页使用反向不对称圆角,形成视觉呼应。全局圆角设计细腻,模仿毛笔的笔锋造型。统一的色彩系统是整个应用的视觉基石,木棕主色搭配墨蓝强调色,配合金棕语义色和蓝色装饰色,营造出既传统又雅致的制笔主题氛围。顶部头部的毫棕竖条装饰带、毛笔浮动+卷轴旋转微动效、圆形图标与不对称圆角等细节处理,使得界面在信息密度较高的同时依然保持精致与舒适,处处透露出"毛笔坊"的主题气质。

在交互层面,应用实现了完整的 CRUD 闭环——新制毛笔、修改订单、撤下笔洗、笔架详情四类弹窗各司其职,配合统一的条件渲染机制和全屏半透明遮罩,交互流畅且一致。撤洗操作的二次确认、订单金额的动态着色、销量阈值的高亮显示等细节,都体现了对用户体验的深入考量。

毛笔坊应用整体运行效果 - 本周毛笔销量柱状图与笔挂墙列表


标签: HarmonyOS · ArkTS · 华为 · 非遗制笔 · 移动端开发

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐