壶中丹青:基于HarmonyOS 6.1.1与HarmonyOS ArkTS API 24的内画鼻烟壶坊应用温润玉色琥珀鎏金主题与单组件八Tab架构全记录

壶中丹青,方寸乾坤。内画鼻烟壶,是中国微型艺术的巅峰之作——以特制弯钩细笔,自壶口探入壶内,在不足寸方的壶壁内壁反向作画。山水人物、花鸟虫鱼、百子嬉春、松鹤延年,方寸之间尽显大千气象。本文完整记录一个基于鸿蒙HarmonyOS 6.1.1与HarmonyOS ArkTS API 24声明式框架构建的「内画鼻烟壶坊」非遗手作平台,从温润玉色琥珀鎏金的内画色彩主题,到墨色头部鼻烟壶瓶形构建(瓶身圆润scale呼吸脉冲+瓶颈瓶口+内画小窗闪烁星点translate位移动画),再到8Tab差异化布局(烟壶陈列列表/材质硬度横条/内画流派名录/匠人编辑交互/制作工序难度横条/题材热度进度条/藏家移除交互/订单金额柱状图)、单组件@Builder架构、@Observed聚合数据类SnuffShopData、6个工具函数(hardBarW/orderBarH/stepBarW/themeBarW/rankColor/statusColor)、鼻烟壶瓶形Stack层叠构建、三大弹窗(新增订单/编辑匠人/移除藏家)、条件渲染模态遮罩、PlayMode.Alternate呼吸动画体系,附完整源码与运行效果占位图。


一、引言:壶中丹青,方寸乾坤

内画鼻烟壶,是中国微型艺术的巅峰之作。鼻烟壶起源于清代,自欧洲鼻烟传入中国后,能工巧匠将鼻烟壶从实用容器升华为艺术品。内画鼻烟壶更是其中的奇葩——以特制弯钩细笔,自壶口探入壶内,在不足寸方的壶壁内壁反向作画,笔锋所至,山水人物跃然壁上,花鸟虫鱼栩栩如生。据传内画技艺由清代嘉庆年间甘桓文首创,后经周乐元、马少宣、丁二仲等大家发扬光大,形成了京派、冀派、鲁派、粤派、秦派、滇派、徽派、苏派八大内画流派,各擅胜场、群芳竞秀。

中国内画鼻烟壶材质丰富,工艺精湛。壶体材质涵盖水晶、玛瑙、料器、瓷、白玉、翡翠、琥珀、珊瑚、碧玺、青金石、红玛瑙、蜜蜡十二大类,每一种材质都有独特的硬度与通透度——水晶硬度7莫氏最硬最通透,玛瑙硬度7色泽斑斓,白玉硬度6温润如脂,翡翠硬度7翠色欲滴,琥珀硬度2最为柔软,珊瑚硬度3色泽喜人。莫氏硬度从2到7跨越五级,硬度越高越耐磨通透,也越适合内画创作。内画流派更是各具特色——京派工笔细腻、冀派墨彩并举、鲁派粗犷豪放、粤派金碧辉煌、秦派浑厚质朴、滇派色彩浓郁、徽派淡雅清新、苏派秀润婉约,八大流派地域分布从北京到衡水到淄博到广州到西安到云南到安徽到苏州,覆盖中国大江南北。

然而,随着工业化量产的冲击,内画鼻烟壶这门需要数十年功力方能入门的微缩绝技正面临传承困境——年轻人不了解内画文化,手工内画匠人缺乏展示和销售渠道,内画产品材质不透明、流派混乱、工序不清晰,正成为中国内画鼻烟壶非遗传承的现实痛点。一个优秀的内画鼻烟壶手作平台,不仅需要覆盖从"烟壶陈列"到"材质甄选"再到"内画流派"的完整产品展示闭环,还要在匠人名录、制作工序、内画题材、藏家雅集、订单管理等维度做到精准而高效。它需要以烟壶陈列为核心入口,横向扩展出材质、画派、匠人、工序、题材、藏家、订单八大功能模块,每个模块采用完全不同的布局风格,形成一个完整闭环的中国内画鼻烟壶非遗手作生态。

正是在这样的背景下,内画鼻烟壶坊类非遗手作平台应运而生。一个优秀的内画应用,需要以烟壶陈列为核心入口,横向扩展出材质硬度横条、内画流派名录、匠人编辑交互、制作工序难度、题材热度进度条、藏家移除交互、订单金额柱状图八大功能模块,每个模块采用完全不同的布局风格——烟壶=统计卡+烟壶陈列列表、材质=材质列表+莫氏硬度横条、画派=流派名录+装饰分割线、匠人=匠人名录+编辑交互、工序=工序列表+难度横条、题材=题材列表+热度进度条、藏家=藏家名录+移除交互、订单=柱状图+订单台账列表,形成了一个完整闭环的中国内画鼻烟壶非遗手作生态。它不仅是一个功能完备的演示项目,更是一份典型的ArkUI工程实践范本,涵盖了单组件@Builder架构设计、@Observed聚合数据类、@State状态管理、鼻烟壶瓶形Stack层叠scale呼吸动画、内画小窗星点translate位移闪烁、PlayMode.Alternate呼吸动画体系、条件渲染模态弹窗、layoutWeight弹性进度条、ForEach列表渲染等关键技术点。

在这里插入图片描述


二、应用整体架构概览

整个应用采用了"单组件@Builder架构 + @Observed聚合数据类 + 配置驱动 + @Builder方法拆分"的四层设计模式。入口组件SnuffShopApp作为整个应用的根容器,负责管理当前激活的Tab状态、三个弹窗的显隐状态、头部呼吸动画状态、表单数据以及聚合数据对象。与多组件拆分架构不同,本应用将整个应用的UI构建逻辑集中在单一入口组件SnuffShopApp中,通过@Builder装饰的方法将界面拆分为多个可复用的构建片段——headerArea(头部鼻烟壶瓶形区域)、contentArea(内容区八Tab切换)、bottomBar(底部双行导航栏)、bottomItem(单个导航项)、modalOverlay(模态遮罩)、addOrderModal(新增订单弹窗)、editArtistModal(编辑匠人弹窗)、removeCollectorModal(移除藏家弹窗),共8个@Builder方法。这种"单组件+多Builder"的组织方式,在中等复杂度的单页面应用里能减少组件间通信开销,让数据流更易追踪,是ArkUI中处理中等复杂度界面的推荐实践。

应用一共划分为八个一级页面:烟壶(Tab 0)、材质(Tab 1)、画派(Tab 2)、匠人(Tab 3)、工序(Tab 4)、题材(Tab 5)、藏家(Tab 6)、订单(Tab 7)。底部bottomBar采用双行四列布局——ROW1_IDX [0,1,2,3] 为第一行,ROW2_IDX [4,5,6,7] 为第二行,每个Tab项由@Builder bottomItem(i)构建,包含图标(18号Emoji)、标签文字(11号)、选中指示条(16x3圆角2玉色小条)三部分。激活态文字jadeDeep深玉色加粗+指示条显示,非激活态textSub灰色普通+指示条透明隐藏,点击切换activeTab触发contentArea重新渲染。八个Tab每个布局完全不同——烟壶采用统计卡+烟壶陈列列表(12款烟壶,材质画派信息,琥珀竖条呼吸装饰)、材质采用材质列表+莫氏硬度横条(8种材质,硬度≥6玉色/否则琥珀色双色分级,呼吸动画)、画派采用流派名录(8大流派,地域风格,装饰分割线"❖"符号)、匠人采用匠人名录+编辑交互(8位匠人,等级徽章rankColor分级,从艺年限与作品数,编辑弹窗)、工序采用工序列表+难度横条(8道工序,难度横条stepBarW映射,等级标签rankColor分级)、题材采用题材列表+热度进度条(8种题材,琥珀色热度横条themeBarW映射,呼吸动画)、藏家采用藏家名录+移除交互(8位藏家,珍藏数量,移除弹窗)、订单采用柱状图+订单台账列表(10笔订单,近六单金额柱状图orderBarH映射,状态标签statusColor分级),这种"一页一型"的差异化设计最大程度还原了真实中国内画鼻烟壶非遗手作平台的交互体验。

在数据层面,应用独创了**@Observed聚合数据类**设计——SnuffShopData一个类包含bottles/materials/schools/artists/steps/themes/collectors/orders八个数组,每个数组8-12条数据,用new构造函数初始化(每个数据类都有显式constructor),@Observed装饰整个类,配合@State在入口组件中持有引用,实现"一个数据对象贯穿所有页面"的聚合数据模式。这种设计将所有业务数据集中管理,入口组件通过@State data: SnuffShopData = new SnuffShopData()持有数据实例,contentArea中通过this.data.bottlesthis.data.materials等方式访问各数组,修改数据时(如addOrder/editArtist/removeCollector)框架自动感知属性变化并触发依赖视图的响应式刷新,是ArkUI状态管理的典型实践。应用还定义了8个@Observed数据类(BottleItem/MaterialItem/SchoolItem/ArtistItem/StepItem/ThemeItem/CollectorItem/OrderItem)和2个元数据接口(SnuffPalette/TabMeta),将数据结构与UI表现层解耦,实现配置驱动设计。

在颜色逻辑层面,应用定义了6个颜色与尺寸映射函数(hardBarW硬度宽度映射/orderBarH金额柱状图高度映射/stepBarW难度宽度映射/themeBarW热度宽度映射/rankColor等级3级着色/statusColor状态4级着色),将颜色和尺寸映射逻辑从UI层抽离,每个函数根据不同维度(硬度/金额/难度/热度/等级/状态)返回对应的颜色值或像素宽度,实现"数据即配色、数据即尺寸"的动态映射模式。应用配色采用"温润玉色琥珀鎏金"内画鼻烟壶非遗主题——bg #F6F2EA 暖米色(模拟古纸温润质感),jade #7FA08C 玉色(烟壶玉质主色调),jadeDeep #4E6B5A 深玉(玉质深沉色调),amber #B08D57 琥珀(瓶口瓶盖琥珀色),ink #3A3A3A 墨色(头部背景,营造浓郁书卷气),textMain #3A3A3A 墨褐(深色文字),五色各司其职形成"以玉色定壶、以琥珀定饰、以墨色定底、以暖米定面"的中国内画鼻烟壶语义着色体系。

在交互层面,应用通过三个@Builder弹窗方法实现了完整的增删改查交互闭环:addOrderModal新增订单(四字段TextInput表单:订单编号/客户姓名/定制烟壶/定金金额,提交订单jadeDeep深玉底)、editArtistModal编辑匠人(单字段TextInput表单:代表作品数,预填当前值,保存修改jadeDeep深玉底)、removeCollectorModal移除藏家(确认文案+双按钮,确认移除danger红底)。三个弹窗都是@Builder方法内联在入口组件中,通过if条件渲染控制显隐(showAddOrder/showEditArtist/showRemoveCollector三个@State布尔变量),遮罩用#55000000(33%黑)硬编码,弹窗主体与遮罩用Stack层叠,整体zIndex(999)覆盖主内容。烟壶页柱状图旁新增按钮触发addOrderModal,匠人页"编辑"点击触发editArtistModal(artistIdx/artistWorks预填当前值),藏家页"移除"点击触发removeCollectorModal(collectorIdx记录目标索引),形成了完整的"新增订单→编辑匠人→移除藏家"交互闭

下面逐页展示应用的八大功能模块。


三、烟壶页:统计卡与烟壶陈列

烟壶页以"烟壶陈列"为核心,采用"统计卡+烟壶陈列列表"的两层布局。

在这里插入图片描述

页面顶部第一部分是统计卡,Row包裹,padding上下12,cardBg白底,圆角12,border 1px border色,margin上下12:

Row() {
  Column() {
    Text('12').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
    Text('馆藏烟壶').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
  }.layoutWeight(1)
  Column() {
    Text('翡翠壶').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.amber)
    Text('最热款').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
  }.layoutWeight(1)
  Column() {
    Text('¥28.6w').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
    Text('本季成交').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
  }.layoutWeight(1)
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg).borderRadius(12)
.margin({ top: 12, bottom: 12 })
.border({ width: 1, color: COLORS.border })

统计卡采用三列等宽布局——每列通过layoutWeight(1)实现等宽分配,三列分别为"馆藏烟壶12款"(16号粗体深玉色)、“最热款翡翠壶”(13号粗体琥珀色)、“本季成交¥28.6万”(16号粗体深玉色),下方均配10号textHint灰字描述。三列数据直观呈现了内画鼻烟壶坊的核心经营指标——12款馆藏烟壶涵盖水晶到蜜蜡全材质,翡翠壶以翠色欲滴居最热款榜首,本季成交28.6万元体现内画鼻烟壶手作市场的高端定位。统计卡用cardBg白底圆角12+border 1px,与下方烟壶列表卡片视觉一致,形成统一的卡片化设计语言。

第二部分是烟壶陈列列表,ForEach遍历this.data.bottles数组,每款烟壶渲染为一张卡片:

ForEach(this.data.bottles, (b: BottleItem) => {
  Column() {
    Row() {
      Column() {
        Text(b.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
        Text(b.material + ' · ' + b.school).fontSize(11)
          .fontColor(COLORS.textSub).margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
      Text('¥' + b.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
      Column()
        .width(4).height(34).borderRadius(2)
        .backgroundColor(COLORS.amber).margin({ left: 10 })
        .opacity(this.amb ? 0.4 : 0.9)
        .animation({ duration: 1100, iterations: -1, playMode: PlayMode.Alternate })
    }
    .width('100%')
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (b: BottleItem) => b.name)

烟壶列表项采用Row横向布局,左侧信息区通过layoutWeight(1)占据剩余空间,包含烟壶名称(14号粗体墨褐色)和材质·画派信息(11号textSub灰色,通过字符串拼接b.material + ' · ' + b.school合并显示,如"水晶 · 京派")。右侧是价格(13号粗体深玉色,¥前缀)和一根4x34像素的琥珀色竖条装饰——这根竖条绑定了this.amb呼吸状态变量,opacity在amb为true时降至0.4、为false时为0.9,配合animation({ duration: 1100, iterations: -1, playMode: PlayMode.Alternate })实现1100毫秒的交替呼吸闪烁。这根琥珀色呼吸竖条模拟了烟壶内画的光泽流转,与内画"壶中丹青"的文化意象高度契合,是本应用烟壶列表设计的核心视觉亮点。

12款烟壶数据涵盖了中国内画鼻烟壶的主流材质:水晶壶(水晶·京派·¥12800)、玛瑙壶(玛瑙·冀派·¥9800)、料器壶(料器·鲁派·¥6800)、瓷壶(瓷·粤派·¥5600)、白玉壶(白玉·京派·¥36800)、翡翠壶(翡翠·京派·¥58800)、琥珀壶(琥珀·滇派·¥16800)、珊瑚壶(珊瑚·粤派·¥26800)、碧玺壶(碧玺·鲁派·¥46800)、青金石壶(青金石·冀派·¥32800)、红玛瑙壶(红玛瑙·京派·¥14800)、蜜蜡壶(蜜蜡·滇派·¥19800)。价格从¥5600(瓷壶)到¥58800(翡翠壶),跨越了约10倍的价格区间,体现了内画鼻烟壶从入门级到收藏级的完整产品梯度。翡翠壶以翠色欲滴居最热款榜首,白玉壶温润如脂、碧玺壶色泽斑斓,京派以水晶壶/白玉壶/翡翠壶/红玛瑙壶四款居首体现京派内画的正统地位。ForEach的键值生成器(b: BottleItem) => b.name使用烟壶名称作为唯一键,确保列表在数据变化时框架能正确进行diff对比。


四、材质页:莫氏硬度横条与材质甄选

材质页以"材质甄选"为核心,采用"标题行+说明行+材质列表+莫氏硬度横条"的布局,每条材质附带硬度进度条且采用双色分级。

在这里插入图片描述

Row() {
  Text('💠').fontSize(14).margin({ right: 6 })
  Text('材质甄选').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
  Column().layoutWeight(1)
  Text('莫氏硬度').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').padding({ top: 14, bottom: 8 })
Text('硬度:数值越高越耐磨通透').fontSize(10)
  .fontColor(COLORS.textHint).width('100%').margin({ bottom: 10 })
ForEach(this.data.materials, (m: MaterialItem) => {
  Column() {
    Row() {
      Text(m.name).fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textMain)
      Text('¥' + m.price + '/斤').fontSize(11).fontColor(COLORS.jadeDeep)
        .margin({ left: 8 })
      Column().layoutWeight(1)
      Text('库存 ' + m.stock + ' 件').fontSize(10).fontColor(COLORS.textHint)
    }
    .width('100%')
    Row() {
      Column()
        .width(hardBarW(m.hardness)).height(8).borderRadius(4)
        .backgroundColor(m.hardness >= 6 ? COLORS.jade : COLORS.amber)
        .opacity(this.amb ? 0.55 : 1)
        .animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
      Column().layoutWeight(1)
      Text(m.hardness + '.0').fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
    }
    .width('100%').margin({ top: 8 })
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (m: MaterialItem) => m.name)

材质页的标题行采用"图标+标题+弹性占位+提示"的四段式布局,下方额外增加了一行说明文字"硬度:数值越高越耐磨通透"(10号textHint灰色),向用户解释莫氏硬度的含义——硬度越高代表材质越耐磨、越通透,也越适合内画创作。这种在标题行下方增加说明行的设计,在其他Tab中没有出现,是材质页独有的信息层次。

材质列表项采用两层结构:上层是信息行Row,包含材质名称(13号粗体墨褐色)、单价(11号深玉色,¥/斤格式,如"¥800/斤")、layoutWeight(1)占位、库存(10号textHint灰色,如"库存 30 件")。下层是硬度进度条行Row——进度条主体由一个Column实现,其宽度通过hardBarW(m.hardness)函数计算,高度8像素,圆角4。进度条的背景色采用条件表达式双色分级m.hardness >= 6 ? COLORS.jade : COLORS.amber——当硬度≥6时用玉色jade,当硬度<6时用琥珀色amber。这种双色分级设计在材质页独有,通过颜色直观区分"硬质材质"与"软质材质",让用户在浏览列表时一眼就能识别哪些材质适合精细内画(硬度高)、哪些材质需要更小心处理(硬度低)。

hardBarW函数的实现是Math.min(170, h * 22)——将莫氏硬度值乘以22作为像素宽度,上限170像素。硬度范围2-7,对应进度条宽度44-154像素。水晶硬度7对应154像素(接近上限),琥珀硬度2对应44像素(最短),差异直观可见。进度条绑定了this.amb呼吸动画,opacity在0.55和1.0之间交替,1000毫秒周期。进度条右侧是layoutWeight(1)占位和硬度数值(10号textSub灰色,格式为"X.0",如"7.0""6.0"“2.0”)。

8种材质覆盖了内画鼻烟壶的主流壶体材料:水晶(硬度7·¥800/斤·库存30件)、玛瑙(硬度7·¥600/斤·库存40件)、料器(硬度5·¥200/斤·库存80件)、瓷(硬度6·¥300/斤·库存60件)、白玉(硬度6·¥2800/斤·库存15件)、翡翠(硬度7·¥4800/斤·库存8件)、琥珀(硬度2·¥1200/斤·库存25件)、珊瑚(硬度3·¥2000/斤·库存12件)。硬度从2(琥珀)到7(水晶/玛瑙/翡翠),水晶/玛瑙/翡翠硬度7最高用玉色进度条,瓷/白玉硬度6也用玉色(≥6),料器硬度5、琥珀硬度2、珊瑚硬度3用琥珀色进度条(<6)。价格从¥200/斤(料器)到¥4800/斤(翡翠),翡翠最贵且库存最少(仅8件),料器最便宜且库存最多(80件),体现了"稀缺即昂贵、通透即高端"的市场规律。白玉¥2800/斤库存仅15件,虽硬度6不算最高但温润如脂的价格却极高,体现材质价格不仅看硬度还看文化价值。


五、画派页:内画流派名录

画派页以"内画流派"为核心,采用"标题行+流派名录列表"的布局,每条流派附带地域风格和匠人数量。

在这里插入图片描述

ForEach(this.data.schools, (sc: SchoolItem) => {
  Column() {
    Row() {
      Column() {
        Text(sc.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
        Text(sc.region + ' · ' + sc.style).fontSize(11)
          .fontColor(COLORS.textSub).margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
      Text(sc.masters + '位').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
    }
    .width('100%')
    Row() {
      Column().layoutWeight(1)
        .height(1).backgroundColor(COLORS.border).margin({ top: 10 })
      Text('❖').fontSize(10).fontColor(COLORS.amber).margin({ top: 6, left: 8, right: 8 })
      Column().layoutWeight(1)
        .height(1).backgroundColor(COLORS.border).margin({ top: 10 })
    }
    .width('100%')
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (sc: SchoolItem) => sc.name)

流派列表项采用两层结构:上层是信息行Row,左侧信息区通过layoutWeight(1)占据剩余空间,包含流派名称(14号粗体墨褐色,如"京派"“冀派”)和"地域·风格"信息(11号textSub灰色,通过sc.region + ' · ' + sc.style拼接,如"北京 · 工笔细腻")。右侧是匠人数量(11号粗体深玉色,如"28位"),通过流派的匠人数量体现其传承规模。

下层是装饰分割线Row——左右各一条layoutWeight(1)的1像素高border色横线,中间夹一个10号琥珀色的"❖"符号(Unicode菱形装饰符,模拟内画壶壁的装饰纹样)。这种"左右细线+中间符号"的分割线设计与折扇坊的"⿻"符号类似但符号不同,"❖"菱形符号呼应了内画鼻烟壶壶壁上的菱形装饰纹样,是本应用画派页列表设计的细节亮点。

8大内画流派覆盖了中国内画的主要地域分布:京派(北京·工笔细腻·28位匠人)、冀派(衡水·墨彩并举·22位)、鲁派(淄博·粗犷豪放·16位)、粤派(广州·金碧辉煌·12位)、秦派(西安·浑厚质朴·10位)、滇派(云南·色彩浓郁·8位)、徽派(安徽·淡雅清新·9位)、苏派(苏州·秀润婉约·11位)。匠人数量从8位(滇派)到28位(京派),京派以28位匠人居首体现其作为内画发源地的正统地位,冀派22位次之(衡水已成为现代内画重镇),鲁派16位第三。八大流派地域从华北(北京/衡水)到华东(淄博/苏州/安徽)到华南(广州)到西北(西安)到西南(云南),覆盖中国大江南北,体现了内画鼻烟壶流派的广泛地域分布。风格各异——京派工笔细腻最精细,冀派墨彩并举最豪迈,鲁派粗犷豪放最大气,粤派金碧辉煌最华丽,秦派浑厚质朴最沉稳,滇派色彩浓郁最浓烈,徽派淡雅清新最素雅,苏派秀润婉约最柔美,八种风格代表了内画艺术的八种审美取向。


六、匠人页:匠人名录与编辑交互

匠人页以"匠人名录"为核心,采用"标题行+匠人名录列表"的布局,每位匠人附带等级徽章和编辑交互。

在这里插入图片描述

ForEach(this.data.artists, (a: ArtistItem, i: number) => {
  Column() {
    Row() {
      Column() {
        Row() {
          Text(a.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
          Text(a.rank).fontSize(10).fontColor(COLORS.white)
            .backgroundColor(rankColor(a.rank)).borderRadius(8)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ left: 8 })
        }
        Text(a.school).fontSize(11).fontColor(COLORS.textSub).margin({ top: 5 })
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
      Column() {
        Text(a.years + '年').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
        Text(a.works + '件作品').fontSize(10).fontColor(COLORS.textHint).margin({ top: 3 })
      }
      Text('编辑').fontSize(10).fontColor(COLORS.jade).margin({ left: 12 })
        .onClick(() => {
          this.artistIdx = i;
          this.artistWorks = a.works.toString();
          this.showEditArtist = true;
        })
    }
    .width('100%')
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (a: ArtistItem) => a.name)

匠人列表项采用Row横向布局,分为三个区域:左侧信息区通过layoutWeight(1)占据剩余空间,包含一个嵌套的Row——匠人姓名(14号粗体墨褐色)和等级徽章(10号白字小药丸,背景色由rankColor(a.rank)函数动态计算),下方是所属流派(11号textSub灰色,如"京派"“冀派”)。中间是数据区——一个Column,上方是从艺年限(12号粗体深玉色,如"38年"),下方是作品数量(10号textHint灰色,如"260件作品")。右侧是"编辑"按钮(10号玉色COLORS.jade文字按钮)。

"编辑"按钮的onClick事件将该匠人的索引i保存到this.artistIdx,将作品数量a.works.toString()保存到this.artistWorks(预填当前值),然后设置this.showEditArtist = true触发编辑弹窗显示。这种"点击时预填表单数据"的模式,使得编辑弹窗打开时TextInput能显示当前匠人的作品数,用户只需修改需要变更的值即可。

rankColor函数将等级文字映射为颜色:大师返回COLORS.amber琥珀色(最高等级),名师返回COLORS.success竹绿色(中等等级),匠人返回COLORS.textSub灰色(基础等级)。这种三级等级着色让用户在浏览匠人列表时通过颜色快速识别匠人级别——琥珀色为大师、竹绿色为名师、灰色为匠人。

8位内画匠人涵盖了从大师到匠人的完整梯队:王一壶(京派·38年·260件·大师)、李内画(冀派·34年·210件·大师)、张毫芒(鲁派·40年·320件·大师)、赵寸境(粤派·30年·160件·名师)、钱壶中(京派·26年·130件·名师)、孙纤毫(滇派·28年·150件·名师)、周微绘(徽派·22年·100件·匠人)、吴丹青(苏派·24年·120件·匠人)。从艺年限从22年(周微绘)到40年(张毫芒),张毫芒以40年从艺和320件作品居鲁派之首。3位大师(琥珀色徽章)+ 3位名师(竹绿色徽章)+ 2位匠人(灰色徽章),形成了"大师-名师-匠人"三级人才梯队。匠人名号也颇具内画文化韵味——"一壶"暗合壶中作画、"毫芒"指毫尖之末的微缩精度、"寸境"喻方寸之境、"纤毫"指纤细笔毫、"丹青"是绘画的雅称。


七、工序页:制作工序与难度横条

工序页以"制作工序"为核心,采用"标题行+工序列表+难度横条"的布局,每道工序附带难度等级标签和难度进度条。

在这里插入图片描述

ForEach(this.data.steps, (st: StepItem) => {
  Column() {
    Row() {
      Text(st.name).fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textMain)
      Text(st.rank).fontSize(10).fontColor(COLORS.white)
        .backgroundColor(rankColor(st.rank)).borderRadius(8)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ left: 8 })
      Column().layoutWeight(1)
      Text(st.diff + '').fontSize(10).fontColor(COLORS.textSub)
    }
    .width('100%')
    Text(st.desc).fontSize(10).fontColor(COLORS.textHint).width('100%').margin({ top: 4 })
    Row() {
      Column()
        .width(stepBarW(st.diff)).height(8).borderRadius(4)
        .backgroundColor(COLORS.jade)
        .opacity(this.amb ? 0.55 : 1)
        .animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
      Column().layoutWeight(1)
      Text('难度 ' + st.diff).fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
    }
    .width('100%').margin({ top: 8 })
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (st: StepItem) => st.name)

工序列表项采用三层结构:第一层是名称等级行Row——左侧是工序名称(13号粗体墨褐色),紧接着是等级标签(10号白字小药丸,背景色由rankColor(st.rank)函数动态计算——注意这里复用了匠人页的rankColor函数,但传入的参数不同:匠人页传"大师/名师/匠人",工序页传"高难/中等/基础"),右侧通过layoutWeight(1)占位推开,最右是难度数值(10号textSub灰色)。第二层是工序描述(10号textHint浅灰色,宽度100%,如"挑选通透坯料"“瓶内膛掏空”"内壁反向作画"等)。第三层是难度进度条行Row——进度条主体由一个Column实现,其宽度通过stepBarW(st.diff)函数计算,高度8像素,圆角4,背景色玉色,同样绑定呼吸动画(opacity在0.55和1.0之间交替,1000毫秒周期)。

stepBarW函数的实现是Math.min(160, d * 1.5)——将难度值乘以1.5作为像素宽度,上限160像素。难度范围35-90,对应进度条宽度52.5-135像素。作画90对应135像素,掏膛75对应112.5像素,选料40对应60像素。rankColor函数在本应用中被匠人页和工序页共用——传入"大师/名师/匠人"或"高难/中等/基础",返回琥珀色/竹绿色/灰色。这种函数复用减少了代码冗余,是DRY原则的体现。

8道制作工序按难度从低到高排列:选料(挑选通透坯料·难度40·基础)、掏膛(瓶内膛掏空·难度75·高难)、磨口(瓶口打磨·难度55·中等)、作画(内壁反向作画·难度90·高难)、落款(壶底题款·难度65·中等)、抛光(整体抛光·难度50·基础)、配盖(壶盖选配·难度45·基础)、装盒(锦盒收装·难度35·基础)。从选料到装盒,覆盖了内画鼻烟壶制作的完整工艺流程——选料挑选通透坯料→掏膛将瓶内膛掏空→磨口打磨瓶口→作画在内壁反向作画→落款壶底题款→抛光整体抛光→配盖选配壶盖→装盒锦盒收装,八道工序环环相扣。作画难度最高(90),因为内壁反向作画需要匠人以特制弯钩细笔自壶口探入壶内,在不足寸方的壶壁内壁反向作画,是内画鼻烟壶最核心也最困难的工艺。掏膛难度次之(75),需要将实心坯料的内部膛掏空成薄壁容器,稍有不慎便前功尽弃。选料和装盒难度最低(40和35),是基础工序。这种"作画最難、掏膛次之"的难度分布,真实反映了内画鼻烟壶制作工艺的难易层次。


八、题材页:内画题材与热度进度条

题材页以"内画题材"为核心,采用"标题行+题材列表+热度进度条"的布局,每种题材附带热度进度条。

在这里插入图片描述

ForEach(this.data.themes, (t: ThemeItem) => {
  Column() {
    Row() {
      Text(t.name).fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textMain)
      Text(t.desc).fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
      Column().layoutWeight(1)
      Text(t.hot + '').fontSize(10).fontColor(COLORS.textSub)
    }
    .width('100%')
    Row() {
      Column()
        .width(themeBarW(t.hot)).height(8).borderRadius(4)
        .backgroundColor(COLORS.amber)
        .opacity(this.amb ? 0.5 : 1)
        .animation({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate })
      Column().layoutWeight(1)
    }
    .width('100%').margin({ top: 8 })
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (t: ThemeItem) => t.name)

题材列表项采用两层结构:上层是名称描述行Row——左侧是题材名称(13号粗体墨褐色,如"山水"“花鸟”),紧接着是描述(10号textSub灰色,margin left 8,如"层峦叠嶂"“蝶舞花间”),右侧通过layoutWeight(1)占位推开,最右是热度数值(10号textSub灰色)。下层是热度进度条行Row——进度条主体由一个Column实现,其宽度通过themeBarW(t.hot)函数计算,高度8像素,圆角4,背景色为琥珀色COLORS.amber(与材质页硬度<6时的琥珀色进度条一致,但题材页统一用琥珀色,不做双色分级)。进度条绑定了this.amb呼吸动画,opacity在0.5和1.0之间交替,1200毫秒周期(比材质页和工序页的1000毫秒更长,节奏更舒缓)。进度条右侧只有layoutWeight(1)占位,没有额外的文字标注(因为热度数值已在上层显示)。

themeBarW函数的实现是Math.min(140, h * 1.3)——将热度值乘以1.3作为像素宽度,上限140像素。热度范围68-95,对应进度条宽度88.4-123.5像素。山水95对应123.5像素(接近上限),骏马68对应88.4像素,差异可见但不如硬度横条悬殊。

8种内画题材覆盖了内画鼻烟壶的经典画题:山水(层峦叠嶂·热度95)、花鸟(蝶舞花间·热度88)、人物(十八罗汉·热度82)、仕女(嫦娥奔月·热度78)、百子(百子嬉春·热度72)、骏马(八骏图·热度68)、虎啸(猛虎下山·热度74)、仙鹤(松鹤延年·热度70)。热度从68(骏马)到95(山水),山水以95度居首体现"山水"作为内画最经典题材的地位,花鸟88度次之。这八种题材涵盖了中国传统绘画的主要门类——山水(风景)、花鸟(动植物)、人物(宗教人物)、仕女(美女)、百子(童趣)、骏马(动物)、虎啸(猛兽)、仙鹤(吉祥),题材类型丰富多样,体现了内画鼻烟壶"方寸之间尽显大千气象"的艺术特色。


九、藏家页:藏家名录与移除交互

藏家页以"藏家名录"为核心,采用"标题行+藏家名录列表"的布局,每位藏家附带珍藏数量和移除交互。

在这里插入图片描述

ForEach(this.data.collectors, (co: CollectorItem, i: number) => {
  Column() {
    Row() {
      Column() {
        Text(co.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
        Text('镇宅:' + co.treasure).fontSize(11)
          .fontColor(COLORS.textSub).margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
      Column() {
        Text(co.count + '只').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
        Text('珍藏').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
      }
      Text('移除').fontSize(10).fontColor(COLORS.danger).margin({ left: 12 })
        .onClick(() => {
          this.collectorIdx = i;
          this.showRemoveCollector = true;
        })
    }
    .width('100%')
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (co: CollectorItem) => co.name)

藏家列表项采用单行Row布局,分为三个区域:左侧信息区通过layoutWeight(1)占据剩余空间,包含藏家名号(14号粗体墨褐色)和"镇宅"珍藏(11号textSub灰色,如"镇宅:京派水晶壶")。中间是珍藏数量区——一个Column,上方是"X只"(12号粗体深玉色),下方是"珍藏"描述(10号textHint灰色)。右侧是"移除"按钮(10号COLORS.danger红色文字按钮)。

"移除"按钮的onClick事件将该藏家的索引i保存到this.collectorIdx,然后设置this.showRemoveCollector = true触发移除确认弹窗显示。与匠人页的编辑交互不同,移除交互只传递索引值(不传递名称),因为移除确认弹窗内部会通过this.data.collectors[this.collectorIdx].name动态读取藏家名号,这种设计避免了冗余的状态变量,是简洁状态管理的体现。"移除"按钮用COLORS.danger红色文字而非红色背景,降低视觉侵入性,同时红色文字仍能起到警示作用。

8位藏家涵盖了内画鼻烟壶收藏的典型藏家类型:叶壶痴(42只·镇宅京派水晶壶)、孔壶迷(36只·镇宅翡翠壶)、白壶缘(28只·镇宅鲁派料器壶)、蔡壶雅(31只·镇宅冀派玛瑙壶)、顾壶韵(25只·镇宅白玉壶)、邵壶趣(22只·镇宅蜜蜡壶)、龚壶藏(38只·镇宅碧玺壶)、阮壶斋(19只·镇宅珊瑚壶)。珍藏数量从19只(阮壶斋)到42只(叶壶痴),叶壶痴以42只居首且镇宅京派水晶壶,体现其对京派内画的偏好。每位藏家的"镇宅"之宝都与烟壶页数据呼应——京派水晶壶对应水晶壶(京派),翡翠壶对应翡翠壶(京派),鲁派料器壶对应料器壶(鲁派),冀派玛瑙壶对应玛瑙壶(冀派),白玉壶对应白玉壶(京派),蜜蜡壶对应蜜蜡壶(滇派),碧玺壶对应碧玺壶(鲁派),珊瑚壶对应珊瑚壶(粤派),形成了"烟壶陈列→藏家镇宅"的数据闭环。藏家名号也颇具文化韵味——“壶痴”“壶迷”“壶缘”“壶雅”“壶韵”“壶趣”“壶藏”“壶斋”,以"壶"字为核心,体现了藏家对内画鼻烟壶的痴迷与雅趣。


十、订单页:柱状图与订单台账

订单页以"订单管理"为核心,采用"近六单金额柱状图+新增订单按钮+订单台账列表"的三层布局。

在这里插入图片描述

Row() {
  ForEach(TOP_ORDER_IDX, (i: number) => {
    Column() {
      Text(this.data.orders[i].piece.length > 2
        ? this.data.orders[i].piece.substring(0, 2) : this.data.orders[i].piece)
        .fontSize(9).fontColor(COLORS.textHint)
      Column()
        .width(26).height(orderBarH(this.data.orders[i].amount)).borderRadius(4)
        .backgroundColor(i % 2 === 0 ? COLORS.jadeDeep : COLORS.amber)
        .margin({ top: 6 })
        .opacity(this.amb ? 0.55 : 1)
        .animation({ duration: 900 + i * 150, iterations: -1, playMode: PlayMode.Alternate })
      Text((this.data.orders[i].amount / 1000).toFixed(0) + 'k')
        .fontSize(9).fontColor(COLORS.textSub).margin({ top: 4 })
    }
    .layoutWeight(1)
    .alignItems(HorizontalAlign.Center)
  }, (i: number) => i.toString())
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor(COLORS.cardBg).borderRadius(12)
.margin({ top: 4, bottom: 12 })
.alignItems(VerticalAlign.Bottom)
.border({ width: 1, color: COLORS.border })

柱状图是订单页的核心视觉亮点。ForEach遍历TOP_ORDER_IDX(即[0, 1, 2, 3, 4, 5]),为前六笔订单各渲染一根柱子。每根柱子是一个Column,内部从上到下包含三部分:柱顶标签(烟品名称截取前两个字,9号textHint灰色,通过piece.length > 2 ? piece.substring(0, 2) : piece进行安全截取防止名称过长)、柱体主体(26像素宽,高度由orderBarH(this.data.orders[i].amount)计算,圆角4,背景色根据索引奇偶在jadeDeep深玉色和amber琥珀色间交替)、柱底金额标注(amount / 1000toFixed(0)取整加"k"后缀,9号textSub灰色)。

orderBarH函数的实现是Math.min(110, v / 5)——将订单金额除以5作为像素高度,上限110像素。与折扇坊的countBarH(v * 2)不同,内画鼻烟壶坊用的是v / 5除法映射——因为内画鼻烟壶订单金额范围更大(5600-58800元),除以5后得到1120-11760,远超110上限,因此大部分柱子都会达到饱和高度。例如翡翠壶¥58800对应11760/5=110像素(达到上限),水晶壶¥12800对应2560像素(也达到上限),只有金额较小的订单柱子才会低于上限。这种除法映射策略适合金额范围较大的数据集,确保柱状图整体高度适中。

柱体绑定了this.amb呼吸动画,opacity在0.55和1.0之间交替,且每根柱子的动画时长不同——900 + i * 150(即900、1050、1200、1350、1500、1650毫秒递增),这种错开的时长避免了六根柱子同步呼吸,营造出自然律动的数据可视化效果。整个柱状图容器alignItems(VerticalAlign.Bottom)确保所有柱子从底部对齐生长,符合柱状图的视觉惯例。

柱状图下方是新增订单按钮行和订单台账列表:

Row() {
  Text('新增订单').fontSize(12).fontColor(COLORS.white)
    .backgroundColor(COLORS.jadeDeep).borderRadius(16)
    .padding({ left: 22, right: 22, top: 9, bottom: 9 })
    .onClick(() => {
      this.formNo = '';
      this.formBuyer = '';
      this.formPiece = '';
      this.formAmount = '';
      this.showAddOrder = true;
    })
  Column().layoutWeight(1)
  Text('共 ' + this.data.orders.length + ' 笔').fontSize(10).fontColor(COLORS.textHint)
}
.width('100%').margin({ bottom: 12 })
ForEach(this.data.orders, (o: OrderItem) => {
  Column() {
    Row() {
      Column() {
        Text(o.piece).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
        Text(o.no + ' · ' + o.buyer).fontSize(10)
          .fontColor(COLORS.textHint).margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start).layoutWeight(1)
      Text(o.status).fontSize(10).fontColor(COLORS.white)
        .backgroundColor(statusColor(o.status)).borderRadius(8)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
      Text('¥' + o.amount).fontSize(13).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.jadeDeep).margin({ left: 8 })
    }
    .width('100%')
  }
  .width('100%')
  .backgroundColor(COLORS.cardBg).borderRadius(12)
  .padding({ left: 14, right: 14, top: 12, bottom: 12 })
  .margin({ bottom: 10 })
  .border({ width: 1, color: COLORS.border })
}, (o: OrderItem) => o.no)

"新增订单"按钮采用jadeDeep深玉色背景、白色文字、圆角16的药丸形态,点击时先清空四个表单字段(formNo/formBuyer/formPiece/formAmount全部置空),然后设置showAddOrder = true触发新增订单弹窗。清空表单字段的目的是确保每次打开弹窗时表单都是空的,避免上次输入的残留数据影响当前操作。

statusColor函数将订单状态文字映射为颜色:已交付返回COLORS.success竹绿色(完成态),定制中返回#8E6FB0紫色(初始态,直接内联了一个未纳入COLORS契约的十六进制值),作画中返回COLORS.warning赭石色(核心工艺进行中),其他状态(如掏膛中、抛光中)返回COLORS.jade玉色。与折扇坊的statusColor相比,内画鼻烟壶坊增加了"作画中"状态——因为"作画"是内画鼻烟壶最核心的工序,需要单独用warning赭石色标识,体现了应用对内画工艺流程的精准语义化。

10笔订单覆盖了内画鼻烟壶制作的全流程状态:S20260801苏先生·水晶壶·¥12800·作画中、S20260803孔女士·翡翠壶·¥58800·掏膛中、S20260805白老师·料器壶·¥6800·已交付、S20260808蔡先生·碧玺壶·¥46800·作画中、S20260810顾女士·蜜蜡壶·¥19800·抛光中、S20260812邵先生·白玉壶·¥36800·掏膛中、S20260814龚先生·红玛瑙壶·¥14800·定制中、S20260816阮女士·珊瑚壶·¥26800·作画中、S20260818叶先生·瓷壶·¥5600·已交付、S20260820温女士·琥珀壶·¥16800·抛光中。状态涵盖作画中、掏膛中、已交付、抛光中、定制中等内画制作全流程节点。其中"作画中"出现3次最多(3笔订单正在内壁反向作画阶段),体现了内画作画工序耗时最长的特点。近六单柱状图中翡翠壶¥58800最高(柱高达上限110像素),料器壶¥6800最低(柱高约1360像素也达上限),金额差异虽大但柱高因饱和保护而视觉差异缩小。


十一、色彩主题系统:温润玉色琥珀鎏金的壶中雅韵

11.1 色彩接口定义

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

interface SnuffPalette {
  bg: string;
  cardBg: string;
  jade: string;
  jadeDeep: string;
  amber: string;
  ink: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

11.2 色彩实例化

const COLORS: SnuffPalette = {
  bg: '#F6F2EA',
  cardBg: '#FFFFFF',
  jade: '#7FA08C',
  jadeDeep: '#4E6B5A',
  amber: '#B08D57',
  ink: '#3A3A3A',
  textMain: '#3A3A3A',
  textSub: '#7A7A6A',
  textHint: '#B0B0A0',
  border: '#E6E0D2',
  success: '#5E7B4A',
  warning: '#B57B2E',
  danger: '#A83A2E',
  white: '#FFFFFF'
};

本应用采用"温润玉色琥珀鎏金"内画鼻烟壶非遗主题设计——bg #F6F2EA 暖米色(模拟古纸温润质感,内画文化载体),cardBg #FFFFFF 白(卡片背景),jade #7FA08C 玉色(烟壶玉质主色调,模拟翡翠白玉的温润色泽),jadeDeep #4E6B5A 深玉(玉质深沉色调,价格与强调色),amber #B08D57 琥珀(瓶口瓶盖琥珀色,装饰竖条与进度条),ink #3A3A3A 墨色(头部背景,营造浓郁书卷气),textMain #3A3A3A 墨褐(深色文字),textSub #7A7A6A 灰褐(次要信息),textHint #B0B0A0 浅木褐(提示信息),border #E6E0D2 浅米黄边(卡片边框),success #5E7B4A 竹绿(已交付状态),warning #B57B2E 赭石(作画中状态),danger #A83A2E 朱红(移除操作)。

主色体系采用"玉色+琥珀+墨色+暖米"四色组合——玉色用于烟壶主色调/进度条/柱状图/标签/导航激活态,琥珀用于瓶口瓶盖/装饰竖条/热度进度条/大师等级徽章,墨色用于头部背景/主文字,暖米用于页面背景,四色各司其职形成"以玉色定壶、以琥珀定饰、以墨色定底、以暖米定面"的中国内画鼻烟壶语义着色体系。与折扇坊的"竹青墨色鎏金"相比,内画鼻烟壶坊采用了更温润的"玉色琥珀"主色——内画鼻烟壶以玉质壶体为核心,玉色和琥珀色恰好呈现了翡翠白玉的温润与琥珀蜜蜡的暖金,与内画"壶中丹青"的文化意象高度契合。

11.3 6个颜色与尺寸映射函数

function hardBarW(h: number): number {
  return Math.min(170, h * 22);
}

function orderBarH(v: number): number {
  return Math.min(110, v / 5);
}

function stepBarW(d: number): number {
  return Math.min(160, d * 1.5);
}

function themeBarW(h: number): number {
  return Math.min(140, h * 1.3);
}

function rankColor(r: string): string {
  if (r === '大师') {
    return COLORS.amber;
  }
  if (r === '名师') {
    return COLORS.success;
  }
  return COLORS.textSub;
}

function statusColor(s: string): string {
  if (s === '已交付') {
    return COLORS.success;
  }
  if (s === '定制中') {
    return '#8E6FB0';
  }
  if (s === '作画中') {
    return COLORS.warning;
  }
  return COLORS.jade;
}

6个映射函数分为两类:尺寸映射函数和颜色映射函数。hardBarW将莫氏硬度值(2-7)映射为进度条宽度(44-154像素,上限170),orderBarH将订单金额(5600-58800)映射为柱状图高度(1120-11760除以5后饱和到上限110),stepBarW将难度值(35-90)映射为进度条宽度(52.5-135像素,上限160),themeBarW将热度值(68-95)映射为进度条宽度(88.4-123.5像素,上限140)。四个尺寸映射函数都使用Math.min进行饱和保护,但乘数因子各不相同——hardBarW用22(硬度范围小2-7需大乘数放大差异)、stepBarW用1.5(难度范围35-90适中)、themeBarW用1.3(热度范围68-95较窄需适中乘数)、orderBarH用除法1/5(金额范围大需缩小差异),每个函数的乘数都根据数据范围精心调整,确保进度条视觉效果合理。

rankColor将等级文字(大师/名师/匠人 或 高难/中等/基础)映射为颜色(琥珀/竹绿/灰色),被匠人页和工序页共用。statusColor将订单状态文字(已交付/定制中/作画中/其他)映射为颜色(竹绿/紫色/赭石/玉色),其中"作画中"用warning赭石色单独标识,体现了内画作画工序的重要性。这6个函数将颜色和尺寸映射逻辑从UI层抽离,使得组件的build()方法更聚焦于结构而非配色细节,是关注点分离的体现。与折扇坊的4个函数相比,内画鼻烟壶坊增加了themeBarW和hardBarW两个尺寸映射函数,因为内画应用有更多需要可视化进度条的维度(硬度/难度/热度/金额),体现了应用数据维度的丰富性。


十二、类型定义:@Observed聚合数据类与数据模型

12.1 @Observed聚合数据类SnuffShopData

应用独创了**@Observed聚合数据类**设计——SnuffShopData一个类包含bottles/materials/schools/artists/steps/themes/collectors/orders八个数组,共74条业务数据,用new构造函数初始化:

@Observed
export class SnuffShopData {
  bottles: BottleItem[] = [ /* 12款烟壶 */ ];
  materials: MaterialItem[] = [ /* 8种材质 */ ];
  schools: SchoolItem[] = [ /* 8大流派 */ ];
  artists: ArtistItem[] = [ /* 8位匠人 */ ];
  steps: StepItem[] = [ /* 8道工序 */ ];
  themes: ThemeItem[] = [ /* 8种题材 */ ];
  collectors: CollectorItem[] = [ /* 8位藏家 */ ];
  orders: OrderItem[] = [ /* 10笔订单 */ ];

  addOrder(no: string, buyer: string, piece: string, amount: number): void {
    this.orders.unshift(new OrderItem(no, buyer, piece, amount, '定制中'));
  }

  editArtist(idx: number, works: number): void {
    this.artists[idx].works = works;
  }

  removeCollector(idx: number): void {
    this.collectors.splice(idx, 1);
  }
}

SnuffShopData不仅承载数据,还封装了三个业务方法:addOrder使用unshift在订单数组头部插入新订单(新订单置顶显示,默认状态"定制中"),editArtist通过索引直接修改匠人的作品数量,removeCollector使用splice按索引删除藏家。与折扇坊的editBone(修改名称和价格两个字段)不同,内画鼻烟壶坊的editArtist只修改作品数量一个字段,因为匠人名录的核心可变数据是作品数(随着匠人持续创作而增长),这种差异体现了不同业务场景下编辑交互的精准设计。@Observed装饰整个类使得这些写操作执行后,框架能自动感知属性变化并触发依赖视图的响应式刷新。

12.2 8个@Observed数据类

应用定义了8个@Observed数据类,每个类对应一种业务实体:

  • BottleItem:烟壶项(name/material/school/price,12款烟壶)
  • MaterialItem:材质项(name/hardness/price/stock,8种材质)
  • SchoolItem:流派项(name/region/style/masters,8大流派)
  • ArtistItem:匠人项(name/school/years/works/rank,8位匠人)
  • StepItem:工序项(name/desc/diff/rank,8道工序)
  • ThemeItem:题材项(name/desc/hot,8种题材)
  • CollectorItem:藏家项(name/count/treasure,8位藏家)
  • OrderItem:订单项(no/buyer/piece/amount/status,10笔订单)

每个数据类都有显式的constructor构造函数,属性都有默认初始值(如name: string = ''),@Observed装饰确保属性级变化追踪有效。数据规模:8个业务数组共74条数据,完整呈现中国内画鼻烟壶非遗手作平台的数据生态。12款烟壶覆盖水晶/玛瑙/料器/瓷/白玉/翡翠/琥珀/珊瑚/碧玺/青金石/红玛瑙/蜜蜡十二大类材质,8种材质覆盖莫氏硬度2-7五级,8大流派覆盖京/冀/鲁/粤/秦/滇/徽/苏八大地域,8位匠人覆盖大师/名师/匠人三级梯队,8道工序覆盖选料到装盒完整流程,8种题材覆盖山水/花鸟/人物/仕女/百子/骏马/虎啸/仙鹤八大画题,8位藏家覆盖内画收藏典型类型,10笔订单覆盖作画中到已交付全流程状态。这些文化数据让应用不只是一个技术演示,更是一个中国内画鼻烟壶文化的数字载体。


十三、单组件@Builder架构:入口组件SnuffShopApp

13.1 入口组件状态声明

@Entry
@Component
struct SnuffShopApp {
  @State data: SnuffShopData = new SnuffShopData();
  @State activeTab: number = 0;
  @State amb: boolean = false;
  @State showAddOrder: boolean = false;
  @State showEditArtist: boolean = false;
  @State showRemoveCollector: boolean = false;
  @State formNo: string = '';
  @State formBuyer: string = '';
  @State formPiece: string = '';
  @State formAmount: string = '';
  @State artistIdx: number = 0;
  @State artistWorks: string = '';
  @State collectorIdx: number = 0;

SnuffShopApp是应用的入口组件,由@Entry与@Component双装饰器标注。组件内部声明了13个@State状态变量,分为四组:数据状态(data持有SnuffShopData聚合数据实例)、导航状态(activeTab当前Tab索引0-7)、动画状态(amb呼吸动画布尔开关)、弹窗状态(showAddOrder/showEditArtist/showRemoveCollector三个弹窗显隐布尔+formNo/formBuyer/formPiece/formAmount四个新增订单表单字段+artistIdx/artistWorks编辑匠人预填值+collectorIdx移除藏家目标索引)。每个@State都会被框架追踪依赖,当其值变化时,build()中引用该状态的片段将自动重新执行,这是一切交互响应的根基。

13.2 生命周期:aboutToAppear动画触发

aboutToAppear(): void {
  setTimeout(() => {
    this.amb = true;
  }, 100);
}

aboutToAppear是ArkTS组件的生命周期回调,在组件创建后、build()首次执行前调用。这里它在100毫秒延迟后把amb置为true。这一看似简单的操作,实则是整个应用所有呼吸动画的"启动开关"——amb从false到true的翻转,会让鼻烟壶瓶身的scale脉冲、内画小窗星点的translate位移闪烁、烟壶竖条的opacity呼吸、硬度进度条的opacity呼吸、工序难度横条的opacity呼吸、题材热度进度条的opacity呼吸、柱状图的opacity律动等所有动画同时启动。延迟100毫秒是为了等待首帧布局完成,避免动画在元素尚未就位时启动导致的闪烁。

13.3 头部鼻烟壶瓶形构建器:headerArea

@Builder
headerArea() {
  Stack() {
    Column() {
      Text('内画鼻烟壶坊').fontSize(20).fontColor(COLORS.jade)
        .fontWeight(FontWeight.Bold).letterSpacing(3)
      Text('壶中丹青 · 方寸乾坤').fontSize(11).fontColor(COLORS.textSub)
        .margin({ top: 4 }).letterSpacing(2)
    }
    .width('100%')
    .position({ x: 0, y: 18 })
    .alignItems(HorizontalAlign.Center)
    Column() {
      Column()
        .width(16).height(8).borderRadius(4)
        .backgroundColor(COLORS.jadeDeep)
      Column()
        .width(22).height(16).borderRadius(4)
        .backgroundColor(COLORS.amber).margin({ top: 2 })
      Column()
        .width(76).height(64).borderRadius({ topLeft: 30, topRight: 30, bottomLeft: 20, bottomRight: 20 })
        .backgroundColor(COLORS.jade)
        .scale({ x: this.amb ? 1.05 : 1.0, y: this.amb ? 1.05 : 1.0 })
        .animation({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate })
      Column()
        .width(20).height(10).borderRadius(5)
        .backgroundColor(COLORS.amber).margin({ top: 4 })
    }
    .position({ x: 118, y: 52 })
    ForEach(SPARK_IDX, (i: number) => {
      Column()
        .width(5 + i * 2).height(5 + i * 2).borderRadius(6)
        .backgroundColor(i % 2 === 0 ? COLORS.amber : COLORS.jade)
        .position({ x: 34 + i * 44, y: 140 + i * 12 })
        .opacity(this.amb ? 0.1 : 0.75)
        .translate({ y: this.amb ? -14 : 0 })
        .animation({ duration: 1500 + i * 300, iterations: -1, playMode: PlayMode.Alternate })
    }, (i: number) => i.toString())
  }
  .width('100%')
  .height(180)
  .backgroundColor(COLORS.ink)
  .borderRadius({ bottomLeft: 28, bottomRight: 28 })
}

headerArea是整个应用视觉的精华所在,它用一个Stack层叠容器拼装出一个鼻烟壶瓶形。Stack允许多个子元素在相同坐标系内层叠,通过position精确定位每个部件。头部由三层内容构成:

第一层是居中的标题文字——“内画鼻烟壶坊”(20号粗体玉色,字间距3营造舒展的排版气质)和副标题"壶中丹青 · 方寸乾坤"(11号textSub灰色,字间距2),通过position({ x: 0, y: 18 })alignItems(HorizontalAlign.Center)居中定位在头部上方。

第二层是鼻烟壶瓶形——通过一个Column纵向排列四个几何块拼装出完整的鼻烟壶轮廓。瓶口(16x8圆角4深玉色柱)→ 瓶颈(22x16圆角4琥珀色柱,margin top 2)→ 瓶身(76x64,圆角{ topLeft: 30, topRight: 30, bottomLeft: 20, bottomRight: 20 }非对称圆角——上圆30模拟壶肩圆润弧线,下圆20模拟壶底收口,玉色背景)→ 瓶底(20x10圆角5琥珀色椭圆,margin top 4,模拟壶底座)。整个瓶形通过position({ x: 118, y: 52 })定位在头部偏左位置。瓶身的scale呼吸脉冲是头部最具创意的动画设计——.scale({ x: this.amb ? 1.05 : 1.0, y: this.amb ? 1.05 : 1.0 })配合animation({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate }),瓶身在1.0和1.05之间交替缩放,1200毫秒周期,模拟鼻烟壶在内画创作时的呼吸感——仿佛壶中藏着生命,内画的光泽在壶壁间流转脉动。这种scale缩放呼吸与折扇坊的扇骨辐线translate位移不同,体现了"一主题一动画"的差异化设计理念。

第三层是内画小窗星点——通过ForEach遍历SPARK_IDX(即[0, 1, 2])生成三个闪烁星点。每个星点是一个Column,宽高为5 + i * 2(即5px、7px、9px递增),圆角6,背景色根据索引奇偶在琥珀色和玉色间交替。三个星点通过position({ x: 34 + i * 44, y: 140 + i * 12 })定位——x坐标34、78、122递增44,y坐标140、152、164递增12,形成从左下到右上的斜向排列,模拟内画壶壁上的星点闪光。每个星点绑定了opacity在amb为true时为0.1、false时为0.75(明暗交替闪烁)和translate({ y: this.amb ? -14 : 0 })(垂直向上位移14像素),配合animation({ duration: 1500 + i * 300, iterations: -1, playMode: PlayMode.Alternate })——每个星点的动画时长递增300毫秒(1500、1800、2100),三个星点此起彼伏地闪烁和浮动,模拟内画壶壁上丹青墨色的光泽流转。

整个header以墨色COLORS.ink为背景、底部两角28像素圆角,与暖米主体形成深浅对比,使鼻烟壶瓶形如同悬浮于墨色背景上的玉质壶影。

13.4 内容区与底部导航构建器

contentArea是八Tab内容切换的核心构建器,通过if-else if链对activeTab进行分发判断,每个分支渲染完全不同的布局结构。bottomBar与bottomItem的构建器结构与折扇坊一致——双行四列布局,每个Tab项包含图标、标签、选中指示条,激活态用jadeDeep深玉色(折扇坊用bambooDeep深竹色),差异仅在于颜色主题。

13.5 build主方法:整体布局组装

build() {
  Stack() {
    Column() {
      this.headerArea()
      this.contentArea()
      this.bottomBar()
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)

    if (this.showAddOrder) {
      Stack() {
        this.addOrderModal()
        this.modalOverlay(() => { this.showAddOrder = false })
      }
      .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
    }
    if (this.showEditArtist) {
      Stack() {
        this.editArtistModal()
        this.modalOverlay(() => { this.showEditArtist = false })
      }
      .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
    }
    if (this.showRemoveCollector) {
      Stack() {
        this.removeCollectorModal()
        this.modalOverlay(() => { this.showRemoveCollector = false })
      }
      .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
    }
  }
  .width('100%')
  .height('100%')
}

build方法是组件的渲染入口。它返回一个根Stack,其下分两层:底层是铺满全屏的主内容Column,纵向依次调用headerArea、contentArea、bottomBar三个@Builder拼出完整页面;上层是三组条件渲染的模态弹窗。每个弹窗由一个if条件块包裹,只有当对应的show*状态为true时才渲染。弹窗内部用一个Stack把弹窗主体与遮罩层叠,整体zIndex(999)确保浮于主内容之上。这种"主内容Column + 弹窗Stack层叠"的结构是ArkTS处理模态交互的经典模式——if条件渲染意味着弹窗在关闭时会被完全卸载(而非仅隐藏),再次打开时重新创建,状态干净,性能友好。


十四、三大弹窗:增删改查交互闭环

14.1 新增订单弹窗:addOrderModal

@Builder
addOrderModal() {
  Column() {
    Row() {
      Text('新增订单').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
      Column().layoutWeight(1)
      Text('✕').fontSize(16).fontColor(COLORS.textHint)
        .onClick(() => { this.showAddOrder = false })
    }
    .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 8 })
    Scroll() {
      Column() {
        Text('订单编号').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 8 })
        TextInput({ text: this.formNo, placeholder: '如 S20260822' })
          .placeholderColor(COLORS.textHint).fontSize(14).width('100%')
          .backgroundColor(COLORS.bg).borderRadius(8)
          .margin({ top: 4 }).onChange((v: string) => { this.formNo = v })
        // ... 客户姓名/定制烟壶/定金金额 三个TextInput
      }
      .width('100%').padding({ left: 20, right: 20 })
    }
    .width('100%')
    .constraintSize({ maxHeight: '52%' })
    Row() {
      Text('取消').fontSize(14).fontColor(COLORS.textSub)
        .backgroundColor(COLORS.border).borderRadius(20)
        .padding({ left: 26, right: 26, top: 10, bottom: 10 })
        .onClick(() => { this.showAddOrder = false })
      Text('提交订单').fontSize(14).fontColor(COLORS.white)
        .backgroundColor(COLORS.jadeDeep).borderRadius(20)
        .padding({ left: 26, right: 26, top: 10, bottom: 10 })
        .margin({ left: 12 })
        .onClick(() => {
          this.data.addOrder(
            this.formNo === '' ? 'S20260822' : this.formNo,
            this.formBuyer === '' ? '匿名客' : this.formBuyer,
            this.formPiece === '' ? '水晶壶' : this.formPiece,
            this.formAmount === '' ? 12000 : Number(this.formAmount));
          this.showAddOrder = false;
        })
    }
    .width('100%').justifyContent(FlexAlign.Center)
    .padding({ left: 20, right: 20, top: 16, bottom: 16 })
  }
  .width('90%')
  .constraintSize({ maxHeight: '80%' })
  .backgroundColor(COLORS.cardBg).borderRadius(16)
  .alignItems(HorizontalAlign.Center)
  .position({ x: '5%', y: '8%' })
}

新增订单弹窗是一个包含四字段TextInput表单的输入型弹窗。结构分三层:标题行(“新增订单"标题+弹性占位+✕关闭按钮)、表单滚动区(Scroll包裹的四个TextInput:订单编号/客户姓名/定制烟壶/定金金额,placeholder用内画鼻烟壶语境如"如 S20260822"“如 水晶壶”)、按钮行(“取消"次要按钮border灰色背景+“提交订单"主要按钮jadeDeep深玉色背景)。提交按钮调用this.data.addOrder()方法,对空值进行兜底处理——默认订单号"S20260822”、默认买家"匿名客”、默认烟壶"水晶壶”、默认金额12000,这些默认值都是内画鼻烟壶语境的合理值。

14.2 编辑匠人弹窗:editArtistModal

编辑匠人弹窗与新增订单弹窗结构类似,但只有单字段(代表作品数),且弹窗内显示"当前匠人:XX"的动态标题——通过this.data.artists[this.artistIdx].name动态读取当前匠人名号。TextInput的text属性绑定this.artistWorks(在匠人页点击"编辑"时已被预填a.works.toString()),因此弹窗打开时显示当前作品数。还增加了一行提示文字"提示:作品数将同步更新名录"(10号textHint灰色),向用户说明编辑作品数会影响匠人名录中的显示。提交按钮调用this.data.editArtist()方法,传入artistIdx索引和修改后的作品数。

14.3 移除藏家弹窗:removeCollectorModal

移除藏家弹窗是一个确认型弹窗,顶部是🧴烟壶Emoji大图标(34号字),核心是动态确认问句——通过this.data.collectors[this.collectorIdx].name动态读取当前要移除的藏家名号。"再想想"次要按钮用border灰色背景,"确认移除"主要按钮用COLORS.danger红色背景。点击"确认移除"调用this.data.removeCollector(this.collectorIdx)方法,通过splice按索引删除藏家。


十五、开发环境搭建

15.1 安装 DevEco Studio

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

DevEco Studio 安装欢迎界面

15.2 选择安装路径

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

选择安装位置

15.3 配置安装选项

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

安装选项配置

15.4 选择项目模板

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

选择 Empty Ability 模板

15.5 配置项目信息

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

配置项目信息


十六、技术对比

技术维度 传统命令式UI开发 本应用ArkTS声明式方案 差异分析
编程范式 命令式,手动操作控件树 声明式,描述状态自动同步视图 声明式大幅减少样板代码
状态管理 手动调用刷新方法 @State+@Observed自动追踪 精准局部更新,无需手动刷新
组件架构 继承或组合,耦合度高 单组件+@Builder方法拆分 减少组件间通信开销,数据流易追踪
数据建模 普通类,变更需手动通知 @Observed聚合数据类SnuffShopData 属性级响应式追踪,一个对象贯穿全局
动画实现 需编写动画控制器和时间线 animation属性声明+PlayMode.Alternate 状态驱动声明式动画,配置即动效
列表渲染 手动创建/复用ViewHolder ForEach自动diff+键值生成器 高效局部更新,键值保证diff效率
弹窗管理 手动管理弹窗生命周期 if条件渲染+Stack层叠+zIndex 按需创建用完即毁,性能友好
数据可视化 需引入第三方图表库 映射函数+容器尺寸属性 轻量可视化,无需第三方依赖
表单交互 手动管理输入状态 @State受控输入+onChange回写 输入数据可控可追踪
配色系统 色值散落,难以统一管理 接口约束+常量对象+映射函数 全局一致性,一处修改全应用同步
进度条实现 固定宽度或百分比 Math.min饱和保护+线性映射 带饱和保护的数值映射,防止溢出
头部动画 需复杂的自定义View Stack层叠+scale缩放+translate位移 几何块拼装瓶形,状态驱动呼吸脉冲
材质硬度可视化 需自定义图表组件 条件表达式双色分级+hardBarW映射 硬度≥6玉色/<6琥珀色,颜色即数据
内画小窗星点 需粒子动画系统 ForEach+position定位+opacity+translate 3星点斜向排列,错开时长自然律动

十七、总结

通过对这款内画鼻烟壶坊非遗手作平台的深度记录,我们可以看到,一个优秀的移动端应用远不止是功能的堆砌,它更是架构设计、数据建模、视觉系统、交互逻辑、动效设计、文化内涵等多维度能力的综合体现。

在架构层面,该应用采用了单组件@Builder架构设计——将整个应用的UI构建逻辑集中在单一入口组件SnuffShopApp中,通过8个@Builder方法将界面拆分为多个可复用的构建片段。入口组件通过13个@State变量管理导航状态、动画状态、弹窗状态和表单数据,所有业务数据集中在@Observed聚合数据类SnuffShopData中,三个业务方法(addOrder/editArtist/removeCollector)收敛了全部写操作逻辑,职责边界清晰,状态可预测、可追踪、可调试。与折扇坊的editBone(修改名称和价格两个字段)相比,内画鼻烟壶坊的editArtist只修改作品数量一个字段,体现了不同业务场景下编辑交互的精准设计——匠人名录的核心可变数据是作品数(随创作持续增长),而非名称价格。

在数据层面,应用独创了**@Observed聚合数据类**设计——SnuffShopData一个类包含bottles/materials/schools/artists/steps/themes/collectors/orders八个数组,共74条业务数据。8个@Observed数据类覆盖烟壶/材质/流派/匠人/工序/题材/藏家/订单八大业务实体,数据文化含量极高——12款烟壶覆盖水晶到蜜蜡十二大类材质,8种材质覆盖莫氏硬度2-7五级,8大流派覆盖京冀鲁粤秦滇徽苏八大地域,8位匠人覆盖大师名师匠人三级梯队,8道工序覆盖选料到装盒完整流程,8种题材覆盖山水花鸟人物仕女百子骏马虎啸仙鹤八大画题,10笔订单覆盖作画中到已交付全流程状态。这些文化数据让应用不只是一个技术演示,更是一个中国内画鼻烟壶文化的数字载体。

在视觉层面,本应用采用"温润玉色琥珀鎏金"内画鼻烟壶非遗主题设计——bg #F6F2EA 暖米色(模拟古纸温润质感),jade #7FA08C 玉色(烟壶玉质主色调),jadeDeep #4E6B5A 深玉(玉质深沉色调),amber #B08D57 琥珀(瓶口瓶盖琥珀色),ink #3A3A3A 墨色(头部背景),五色形成"以玉色定壶、以琥珀定饰、以墨色定底、以暖米定面"的中国内画鼻烟壶语义着色体系。头部鼻烟壶瓶形构建是本应用最大的视觉亮点——通过Column纵向排列四个几何块(瓶口16x8深玉色→瓶颈22x16琥珀色→瓶身76x64非对称圆角玉色→瓶底20x10琥珀色椭圆)拼装出完整的鼻烟壶轮廓,瓶身通过scale缩放在1.0和1.05之间交替呼吸脉冲,模拟壶中内画光泽的流转脉动。内画小窗星点通过ForEach生成三个闪烁光点,position斜向排列,opacity明暗交替+translate垂直位移,错开时长自然律动,模拟内画壶壁上丹青墨色的光泽闪烁。6个映射函数(hardBarW/orderBarH/stepBarW/themeBarW/rankColor/statusColor)将颜色和尺寸映射逻辑从UI层抽离,实现"数据即配色、数据即尺寸"的动态映射模式——特别是hardBarW配合条件表达式双色分级(硬度≥6玉色/<6琥珀色),让材质硬度可视化既看长度又看颜色,是本应用数据可视化的核心亮点。

在交互层面,应用通过三个@Builder弹窗方法实现了完整的增删改查交互闭环:addOrderModal新增订单(四字段TextInput受控输入表单+空值兜底+提交调用addOrder的unshift头部插入)、editArtistModal编辑匠人(单字段TextInput预填当前值+动态标题+提示文字+保存调用editArtist直接修改属性)、removeCollectorModal移除藏家(动态确认问句+danger红色确认按钮+splice索引删除)。三个弹窗通过if条件渲染控制显隐,配合modalOverlay遮罩和zIndex(999)层叠覆盖,是声明式UI管理模态交互的标准范式。PlayMode.Alternate呼吸动画体系贯穿全应用——鼻烟壶瓶身scale脉冲(1200ms)、内画小窗星点闪烁位移(1500+i300ms错开时长)、烟壶竖条呼吸(1100ms)、硬度进度条呼吸(1000ms)、工序难度横条呼吸(1000ms)、题材热度进度条呼吸(1200ms)、柱状图律动(900+i150ms错开时长),每个Tab的动画形式和时长各不相同,体现了"一页一动效"的差异化设计理念,所有动画都受amb这一单一@State变量统一调度。

基于HarmonyOS 6.1.1与基于HarmonyOS API 24开发的这一内画鼻烟壶坊应用,充分验证了ArkTS声明式开发范式在文化展示类应用中的适用性——开发者可以用接近自然语言的方式描述界面结构与交互逻辑,而将状态追踪、差异更新、动画过渡等底层机制交由框架自动处理,从而将更多精力聚焦于业务设计本身。内画鼻烟壶坊将壶中丹青的深厚文化底蕴与现代声明式开发技术深度融合,为垂直文化领域的移动应用开发提供了一个兼具技术深度与文化美感的参考范例。
在这里插入图片描述


十八、完整源代码

以下是本应用的完整源代码,基于HarmonyOS ArkTS API 24开发,共1016行,涵盖色彩接口定义、元数据配置、8个@Observed数据类、@Observed聚合数据类SnuffShopData(含3个业务方法)、6个映射函数、入口组件SnuffShopApp(含13个@State状态变量、aboutToAppear生命周期、8个@Builder方法、build主方法),可直接复制到DevEco Studio项目中运行:

// 内画鼻烟壶坊 · 壶中丹青
// 布局特色:温润玉色 + 鼻烟壶瓶形 header(瓶身圆润 + 瓶颈 + 瓶口 + 内画小窗呼吸闪烁)+ 材质硬度横条 + 订单金额柱状图

interface SnuffPalette {
  bg: string;
  cardBg: string;
  jade: string;
  jadeDeep: string;
  amber: string;
  ink: string;
  textMain: string;
  textSub: string;
  textHint: string;
  border: string;
  success: string;
  warning: string;
  danger: string;
  white: string;
}

const COLORS: SnuffPalette = {
  bg: '#F6F2EA',
  cardBg: '#FFFFFF',
  jade: '#7FA08C',
  jadeDeep: '#4E6B5A',
  amber: '#B08D57',
  ink: '#3A3A3A',
  textMain: '#3A3A3A',
  textSub: '#7A7A6A',
  textHint: '#B0B0A0',
  border: '#E6E0D2',
  success: '#5E7B4A',
  warning: '#B57B2E',
  danger: '#A83A2E',
  white: '#FFFFFF'
};

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

const TAB_LIST: TabMeta[] = [
  { label: '烟壶', icon: '🧴' },
  { label: '材质', icon: '💠' },
  { label: '画派', icon: '🎨' },
  { label: '匠人', icon: '🧑‍🎨' },
  { label: '工序', icon: '⚙️' },
  { label: '题材', icon: '🖼️' },
  { label: '藏家', icon: '🧓' },
  { label: '订单', icon: '📦' }
];

const ROW1_IDX: number[] = [0, 1, 2, 3];
const ROW2_IDX: number[] = [4, 5, 6, 7];
const SPARK_IDX: number[] = [0, 1, 2];
const TOP_ORDER_IDX: number[] = [0, 1, 2, 3, 4, 5];

@Observed
export class BottleItem {
  name: string = '';
  material: string = '';
  school: string = '';
  price: number = 0;

  constructor(name: string, material: string, school: string, price: number) {
    this.name = name;
    this.material = material;
    this.school = school;
    this.price = price;
  }
}

@Observed
export class MaterialItem {
  name: string = '';
  hardness: number = 0;
  price: number = 0;
  stock: number = 0;

  constructor(name: string, hardness: number, price: number, stock: number) {
    this.name = name;
    this.hardness = hardness;
    this.price = price;
    this.stock = stock;
  }
}

@Observed
export class SchoolItem {
  name: string = '';
  region: string = '';
  style: string = '';
  masters: number = 0;

  constructor(name: string, region: string, style: string, masters: number) {
    this.name = name;
    this.region = region;
    this.style = style;
    this.masters = masters;
  }
}

@Observed
export class ArtistItem {
  name: string = '';
  school: string = '';
  years: number = 0;
  works: number = 0;
  rank: string = '';

  constructor(name: string, school: string, years: number, works: number, rank: string) {
    this.name = name;
    this.school = school;
    this.years = years;
    this.works = works;
    this.rank = rank;
  }
}

@Observed
export class StepItem {
  name: string = '';
  desc: string = '';
  diff: number = 0;
  rank: string = '';

  constructor(name: string, desc: string, diff: number, rank: string) {
    this.name = name;
    this.desc = desc;
    this.diff = diff;
    this.rank = rank;
  }
}

@Observed
export class ThemeItem {
  name: string = '';
  desc: string = '';
  hot: number = 0;

  constructor(name: string, desc: string, hot: number) {
    this.name = name;
    this.desc = desc;
    this.hot = hot;
  }
}

@Observed
export class CollectorItem {
  name: string = '';
  count: number = 0;
  treasure: string = '';

  constructor(name: string, count: number, treasure: string) {
    this.name = name;
    this.count = count;
    this.treasure = treasure;
  }
}

@Observed
export class OrderItem {
  no: string = '';
  buyer: string = '';
  piece: string = '';
  amount: number = 0;
  status: string = '';

  constructor(no: string, buyer: string, piece: string, amount: number, status: string) {
    this.no = no;
    this.buyer = buyer;
    this.piece = piece;
    this.amount = amount;
    this.status = status;
  }
}

@Observed
export class SnuffShopData {
  bottles: BottleItem[] = [
    new BottleItem('水晶壶', '水晶', '京派', 12800),
    new BottleItem('玛瑙壶', '玛瑙', '冀派', 9800),
    new BottleItem('料器壶', '料器', '鲁派', 6800),
    new BottleItem('瓷壶', '瓷', '粤派', 5600),
    new BottleItem('白玉壶', '白玉', '京派', 36800),
    new BottleItem('翡翠壶', '翡翠', '京派', 58800),
    new BottleItem('琥珀壶', '琥珀', '滇派', 16800),
    new BottleItem('珊瑚壶', '珊瑚', '粤派', 26800),
    new BottleItem('碧玺壶', '碧玺', '鲁派', 46800),
    new BottleItem('青金石壶', '青金石', '冀派', 32800),
    new BottleItem('红玛瑙壶', '红玛瑙', '京派', 14800),
    new BottleItem('蜜蜡壶', '蜜蜡', '滇派', 19800)
  ];
  materials: MaterialItem[] = [
    new MaterialItem('水晶', 7, 800, 30),
    new MaterialItem('玛瑙', 7, 600, 40),
    new MaterialItem('料器', 5, 200, 80),
    new MaterialItem('瓷', 6, 300, 60),
    new MaterialItem('白玉', 6, 2800, 15),
    new MaterialItem('翡翠', 7, 4800, 8),
    new MaterialItem('琥珀', 2, 1200, 25),
    new MaterialItem('珊瑚', 3, 2000, 12)
  ];
  schools: SchoolItem[] = [
    new SchoolItem('京派', '北京', '工笔细腻', 28),
    new SchoolItem('冀派', '衡水', '墨彩并举', 22),
    new SchoolItem('鲁派', '淄博', '粗犷豪放', 16),
    new SchoolItem('粤派', '广州', '金碧辉煌', 12),
    new SchoolItem('秦派', '西安', '浑厚质朴', 10),
    new SchoolItem('滇派', '云南', '色彩浓郁', 8),
    new SchoolItem('徽派', '安徽', '淡雅清新', 9),
    new SchoolItem('苏派', '苏州', '秀润婉约', 11)
  ];
  artists: ArtistItem[] = [
    new ArtistItem('王一壶', '京派', 38, 260, '大师'),
    new ArtistItem('李内画', '冀派', 34, 210, '大师'),
    new ArtistItem('张毫芒', '鲁派', 40, 320, '大师'),
    new ArtistItem('赵寸境', '粤派', 30, 160, '名师'),
    new ArtistItem('钱壶中', '京派', 26, 130, '名师'),
    new ArtistItem('孙纤毫', '滇派', 28, 150, '名师'),
    new ArtistItem('周微绘', '徽派', 22, 100, '匠人'),
    new ArtistItem('吴丹青', '苏派', 24, 120, '匠人')
  ];
  steps: StepItem[] = [
    new StepItem('选料', '挑选通透坯料', 40, '基础'),
    new StepItem('掏膛', '瓶内膛掏空', 75, '高难'),
    new StepItem('磨口', '瓶口打磨', 55, '中等'),
    new StepItem('作画', '内壁反向作画', 90, '高难'),
    new StepItem('落款', '壶底题款', 65, '中等'),
    new StepItem('抛光', '整体抛光', 50, '基础'),
    new StepItem('配盖', '壶盖选配', 45, '基础'),
    new StepItem('装盒', '锦盒收装', 35, '基础')
  ];
  themes: ThemeItem[] = [
    new ThemeItem('山水', '层峦叠嶂', 95),
    new ThemeItem('花鸟', '蝶舞花间', 88),
    new ThemeItem('人物', '十八罗汉', 82),
    new ThemeItem('仕女', '嫦娥奔月', 78),
    new ThemeItem('百子', '百子嬉春', 72),
    new ThemeItem('骏马', '八骏图', 68),
    new ThemeItem('虎啸', '猛虎下山', 74),
    new ThemeItem('仙鹤', '松鹤延年', 70)
  ];
  collectors: CollectorItem[] = [
    new CollectorItem('叶壶痴', 42, '京派水晶壶'),
    new CollectorItem('孔壶迷', 36, '翡翠壶'),
    new CollectorItem('白壶缘', 28, '鲁派料器壶'),
    new CollectorItem('蔡壶雅', 31, '冀派玛瑙壶'),
    new CollectorItem('顾壶韵', 25, '白玉壶'),
    new CollectorItem('邵壶趣', 22, '蜜蜡壶'),
    new CollectorItem('龚壶藏', 38, '碧玺壶'),
    new CollectorItem('阮壶斋', 19, '珊瑚壶')
  ];
  orders: OrderItem[] = [
    new OrderItem('S20260801', '苏先生', '水晶壶', 12800, '作画中'),
    new OrderItem('S20260803', '孔女士', '翡翠壶', 58800, '掏膛中'),
    new OrderItem('S20260805', '白老师', '料器壶', 6800, '已交付'),
    new OrderItem('S20260808', '蔡先生', '碧玺壶', 46800, '作画中'),
    new OrderItem('S20260810', '顾女士', '蜜蜡壶', 19800, '抛光中'),
    new OrderItem('S20260812', '邵先生', '白玉壶', 36800, '掏膛中'),
    new OrderItem('S20260814', '龚先生', '红玛瑙壶', 14800, '定制中'),
    new OrderItem('S20260816', '阮女士', '珊瑚壶', 26800, '作画中'),
    new OrderItem('S20260818', '叶先生', '瓷壶', 5600, '已交付'),
    new OrderItem('S20260820', '温女士', '琥珀壶', 16800, '抛光中')
  ];

  addOrder(no: string, buyer: string, piece: string, amount: number): void {
    this.orders.unshift(new OrderItem(no, buyer, piece, amount, '定制中'));
  }

  editArtist(idx: number, works: number): void {
    this.artists[idx].works = works;
  }

  removeCollector(idx: number): void {
    this.collectors.splice(idx, 1);
  }
}

function hardBarW(h: number): number {
  return Math.min(170, h * 22);
}

function orderBarH(v: number): number {
  return Math.min(110, v / 5);
}

function stepBarW(d: number): number {
  return Math.min(160, d * 1.5);
}

function themeBarW(h: number): number {
  return Math.min(140, h * 1.3);
}

function rankColor(r: string): string {
  if (r === '大师') {
    return COLORS.amber;
  }
  if (r === '名师') {
    return COLORS.success;
  }
  return COLORS.textSub;
}

function statusColor(s: string): string {
  if (s === '已交付') {
    return COLORS.success;
  }
  if (s === '定制中') {
    return '#8E6FB0';
  }
  if (s === '作画中') {
    return COLORS.warning;
  }
  return COLORS.jade;
}

@Entry
@Component
struct SnuffShopApp {
  @State data: SnuffShopData = new SnuffShopData();
  @State activeTab: number = 0;
  @State amb: boolean = false;
  @State showAddOrder: boolean = false;
  @State showEditArtist: boolean = false;
  @State showRemoveCollector: boolean = false;
  @State formNo: string = '';
  @State formBuyer: string = '';
  @State formPiece: string = '';
  @State formAmount: string = '';
  @State artistIdx: number = 0;
  @State artistWorks: string = '';
  @State collectorIdx: number = 0;

  aboutToAppear(): void {
    setTimeout(() => {
      this.amb = true;
    }, 100);
  }

  @Builder
  bottomItem(i: number) {
    Column() {
      Text(TAB_LIST[i].icon).fontSize(18)
        .opacity(this.activeTab === i ? 1 : 0.55)
      Text(TAB_LIST[i].label).fontSize(11)
        .fontColor(this.activeTab === i ? COLORS.jadeDeep : COLORS.textSub)
        .fontWeight(this.activeTab === i ? FontWeight.Bold : FontWeight.Normal)
        .margin({ top: 3 })
      Column()
        .width(16).height(3).borderRadius(2)
        .backgroundColor(this.activeTab === i ? COLORS.jade : '#00000000')
        .margin({ top: 4 })
    }
    .layoutWeight(1)
    .padding({ top: 8, bottom: 8 })
    .onClick(() => {
      this.activeTab = i;
    })
  }

  @Builder
  modalOverlay(onClose: () => void) {
    Column()
      .width('100%').height('100%')
      .backgroundColor('#55000000')
      .onClick(() => {
        onClose();
      })
  }

  @Builder
  headerArea() {
    Stack() {
      Column() {
        Text('内画鼻烟壶坊').fontSize(20).fontColor(COLORS.jade)
          .fontWeight(FontWeight.Bold).letterSpacing(3)
        Text('壶中丹青 · 方寸乾坤').fontSize(11).fontColor(COLORS.textSub)
          .margin({ top: 4 }).letterSpacing(2)
      }
      .width('100%')
      .position({ x: 0, y: 18 })
      .alignItems(HorizontalAlign.Center)
      Column() {
        Column()
          .width(16).height(8).borderRadius(4)
          .backgroundColor(COLORS.jadeDeep)
        Column()
          .width(22).height(16).borderRadius(4)
          .backgroundColor(COLORS.amber).margin({ top: 2 })
        Column()
          .width(76).height(64).borderRadius({ topLeft: 30, topRight: 30, bottomLeft: 20, bottomRight: 20 })
          .backgroundColor(COLORS.jade)
          .scale({ x: this.amb ? 1.05 : 1.0, y: this.amb ? 1.05 : 1.0 })
          .animation({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate })
        Column()
          .width(20).height(10).borderRadius(5)
          .backgroundColor(COLORS.amber).margin({ top: 4 })
      }
      .position({ x: 118, y: 52 })
      ForEach(SPARK_IDX, (i: number) => {
        Column()
          .width(5 + i * 2).height(5 + i * 2).borderRadius(6)
          .backgroundColor(i % 2 === 0 ? COLORS.amber : COLORS.jade)
          .position({ x: 34 + i * 44, y: 140 + i * 12 })
          .opacity(this.amb ? 0.1 : 0.75)
          .translate({ y: this.amb ? -14 : 0 })
          .animation({ duration: 1500 + i * 300, iterations: -1, playMode: PlayMode.Alternate })
      }, (i: number) => i.toString())
    }
    .width('100%')
    .height(180)
    .backgroundColor(COLORS.ink)
    .borderRadius({ bottomLeft: 28, bottomRight: 28 })
  }

  @Builder
  addOrderModal() {
    Column() {
      Row() {
        Text('新增订单').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor(COLORS.textHint)
          .onClick(() => { this.showAddOrder = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 8 })
      Scroll() {
        Column() {
          Text('订单编号').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 8 })
          TextInput({ text: this.formNo, placeholder: '如 S20260822' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('100%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ top: 4 }).onChange((v: string) => { this.formNo = v })
          Text('客户姓名').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
          TextInput({ text: this.formBuyer, placeholder: '客户姓名' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('100%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ top: 4 }).onChange((v: string) => { this.formBuyer = v })
          Text('定制烟壶').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
          TextInput({ text: this.formPiece, placeholder: '如 水晶壶' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('100%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ top: 4 }).onChange((v: string) => { this.formPiece = v })
          Text('定金金额').fontSize(12).fontColor(COLORS.textSub).width('100%').margin({ top: 12 })
          TextInput({ text: this.formAmount, placeholder: '元' })
            .placeholderColor(COLORS.textHint).fontSize(14).width('100%')
            .backgroundColor(COLORS.bg).borderRadius(8)
            .margin({ top: 4 }).onChange((v: string) => { this.formAmount = v })
        }
        .width('100%').padding({ left: 20, right: 20 })
      }
      .width('100%')
      .constraintSize({ maxHeight: '52%' })
      Row() {
        Text('取消').fontSize(14).fontColor(COLORS.textSub)
          .backgroundColor(COLORS.border).borderRadius(20)
          .padding({ left: 26, right: 26, top: 10, bottom: 10 })
          .onClick(() => { this.showAddOrder = false })
        Text('提交订单').fontSize(14).fontColor(COLORS.white)
          .backgroundColor(COLORS.jadeDeep).borderRadius(20)
          .padding({ left: 26, right: 26, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => {
            this.data.addOrder(
              this.formNo === '' ? 'S20260822' : this.formNo,
              this.formBuyer === '' ? '匿名客' : this.formBuyer,
              this.formPiece === '' ? '水晶壶' : this.formPiece,
              this.formAmount === '' ? 12000 : Number(this.formAmount));
            this.showAddOrder = false;
          })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 16, bottom: 16 })
    }
    .width('90%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(COLORS.cardBg).borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '5%', y: '8%' })
  }

  @Builder
  editArtistModal() {
    Column() {
      Row() {
        Text('编辑匠人').fontSize(17).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
        Column().layoutWeight(1)
        Text('✕').fontSize(16).fontColor(COLORS.textHint)
          .onClick(() => { this.showEditArtist = false })
      }
      .width('100%').padding({ left: 20, right: 20, top: 18, bottom: 8 })
      Text('当前匠人:' + this.data.artists[this.artistIdx].name).fontSize(13)
        .fontColor(COLORS.textMain).width('100%').margin({ top: 8, left: 20 })
      Text('代表作品数').fontSize(12).fontColor(COLORS.textSub)
        .width('100%').margin({ top: 12, left: 20 })
      TextInput({ text: this.artistWorks, placeholder: '作品数' })
        .placeholderColor(COLORS.textHint).fontSize(14).width('90%')
        .backgroundColor(COLORS.bg).borderRadius(8)
        .margin({ top: 4 }).onChange((v: string) => { this.artistWorks = v })
      Text('提示:作品数将同步更新名录').fontSize(10).fontColor(COLORS.textHint)
        .width('100%').margin({ top: 10, left: 20 })
      Row() {
        Text('取消').fontSize(14).fontColor(COLORS.textSub)
          .backgroundColor(COLORS.border).borderRadius(20)
          .padding({ left: 26, right: 26, top: 10, bottom: 10 })
          .onClick(() => { this.showEditArtist = false })
        Text('保存修改').fontSize(14).fontColor(COLORS.white)
          .backgroundColor(COLORS.jadeDeep).borderRadius(20)
          .padding({ left: 26, right: 26, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => {
            this.data.editArtist(this.artistIdx,
              this.artistWorks === '' ? this.data.artists[this.artistIdx].works : Number(this.artistWorks));
            this.showEditArtist = false;
          })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 20, bottom: 16 })
    }
    .width('90%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(COLORS.cardBg).borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '5%', y: '8%' })
  }

  @Builder
  removeCollectorModal() {
    Column() {
      Text('🧴').fontSize(34).margin({ top: 24 })
      Text('移除藏家').fontSize(17).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.textMain).margin({ top: 10 })
      Text('确认将 ' + this.data.collectors[this.collectorIdx].name + ' 移出藏家名录?')
        .fontSize(13).fontColor(COLORS.textSub).margin({ top: 10 })
        .padding({ left: 28, right: 28 }).textAlign(TextAlign.Center)
      Row() {
        Text('再想想').fontSize(14).fontColor(COLORS.textSub)
          .backgroundColor(COLORS.border).borderRadius(20)
          .padding({ left: 26, right: 26, top: 10, bottom: 10 })
          .onClick(() => { this.showRemoveCollector = false })
        Text('确认移除').fontSize(14).fontColor(COLORS.white)
          .backgroundColor(COLORS.danger).borderRadius(20)
          .padding({ left: 26, right: 26, top: 10, bottom: 10 })
          .margin({ left: 12 })
          .onClick(() => {
            this.data.removeCollector(this.collectorIdx);
            this.showRemoveCollector = false;
          })
      }
      .width('100%').justifyContent(FlexAlign.Center)
      .padding({ left: 20, right: 20, top: 22, bottom: 20 })
    }
    .width('86%')
    .constraintSize({ maxHeight: '80%' })
    .backgroundColor(COLORS.cardBg).borderRadius(16)
    .alignItems(HorizontalAlign.Center)
    .position({ x: '7%', y: '28%' })
  }

  @Builder
  contentArea() {
    if (this.activeTab === 0) {
      Scroll() {
        Column() {
          Row() {
            Column() {
              Text('12').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
              Text('馆藏烟壶').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
            }.layoutWeight(1)
            Column() {
              Text('翡翠壶').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.amber)
              Text('最热款').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
            }.layoutWeight(1)
            Column() {
              Text('¥28.6w').fontSize(16).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
              Text('本季成交').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
            }.layoutWeight(1)
          }
          .width('100%')
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.cardBg).borderRadius(12)
          .margin({ top: 12, bottom: 12 })
          .border({ width: 1, color: COLORS.border })
          ForEach(this.data.bottles, (b: BottleItem) => {
            Column() {
              Row() {
                Column() {
                  Text(b.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
                  Text(b.material + ' · ' + b.school).fontSize(11)
                    .fontColor(COLORS.textSub).margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text('¥' + b.price).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
                Column()
                  .width(4).height(34).borderRadius(2)
                  .backgroundColor(COLORS.amber).margin({ left: 10 })
                  .opacity(this.amb ? 0.4 : 0.9)
                  .animation({ duration: 1100, iterations: -1, playMode: PlayMode.Alternate })
              }
              .width('100%')
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (b: BottleItem) => b.name)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    } else if (this.activeTab === 1) {
      Scroll() {
        Column() {
          Row() {
            Text('💠').fontSize(14).margin({ right: 6 })
            Text('材质甄选').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
            Column().layoutWeight(1)
            Text('莫氏硬度').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').padding({ top: 14, bottom: 8 })
          Text('硬度:数值越高越耐磨通透').fontSize(10)
            .fontColor(COLORS.textHint).width('100%').margin({ bottom: 10 })
          ForEach(this.data.materials, (m: MaterialItem) => {
            Column() {
              Row() {
                Text(m.name).fontSize(13).fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textMain)
                Text('¥' + m.price + '/斤').fontSize(11).fontColor(COLORS.jadeDeep)
                  .margin({ left: 8 })
                Column().layoutWeight(1)
                Text('库存 ' + m.stock + ' 件').fontSize(10).fontColor(COLORS.textHint)
              }
              .width('100%')
              Row() {
                Column()
                  .width(hardBarW(m.hardness)).height(8).borderRadius(4)
                  .backgroundColor(m.hardness >= 6 ? COLORS.jade : COLORS.amber)
                  .opacity(this.amb ? 0.55 : 1)
                  .animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
                Column().layoutWeight(1)
                Text(m.hardness + '.0').fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
              }
              .width('100%').margin({ top: 8 })
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (m: MaterialItem) => m.name)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    } else if (this.activeTab === 2) {
      Scroll() {
        Column() {
          Row() {
            Text('🎨').fontSize(14).margin({ right: 6 })
            Text('内画流派').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
            Column().layoutWeight(1)
            Text('群芳竞秀').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').padding({ top: 14, bottom: 8 })
          ForEach(this.data.schools, (sc: SchoolItem) => {
            Column() {
              Row() {
                Column() {
                  Text(sc.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
                  Text(sc.region + ' · ' + sc.style).fontSize(11)
                    .fontColor(COLORS.textSub).margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text(sc.masters + '位').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
              }
              .width('100%')
              Row() {
                Column().layoutWeight(1)
                  .height(1).backgroundColor(COLORS.border).margin({ top: 10 })
                Text('❖').fontSize(10).fontColor(COLORS.amber).margin({ top: 6, left: 8, right: 8 })
                Column().layoutWeight(1)
                  .height(1).backgroundColor(COLORS.border).margin({ top: 10 })
              }
              .width('100%')
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (sc: SchoolItem) => sc.name)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    } else if (this.activeTab === 3) {
      Scroll() {
        Column() {
          Row() {
            Text('🧑‍🎨').fontSize(14).margin({ right: 6 })
            Text('匠人名录').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
            Column().layoutWeight(1)
            Text('点击可编辑').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').padding({ top: 14, bottom: 8 })
          ForEach(this.data.artists, (a: ArtistItem, i: number) => {
            Column() {
              Row() {
                Column() {
                  Row() {
                    Text(a.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
                    Text(a.rank).fontSize(10).fontColor(COLORS.white)
                      .backgroundColor(rankColor(a.rank)).borderRadius(8)
                      .padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ left: 8 })
                  }
                  Text(a.school).fontSize(11).fontColor(COLORS.textSub).margin({ top: 5 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Column() {
                  Text(a.years + '年').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
                  Text(a.works + '件作品').fontSize(10).fontColor(COLORS.textHint).margin({ top: 3 })
                }
                Text('编辑').fontSize(10).fontColor(COLORS.jade).margin({ left: 12 })
                  .onClick(() => {
                    this.artistIdx = i;
                    this.artistWorks = a.works.toString();
                    this.showEditArtist = true;
                  })
              }
              .width('100%')
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (a: ArtistItem) => a.name)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    } else if (this.activeTab === 4) {
      Scroll() {
        Column() {
          Row() {
            Text('⚙️').fontSize(14).margin({ right: 6 })
            Text('制作工序').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
            Column().layoutWeight(1)
            Text('难度指数').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').padding({ top: 14, bottom: 8 })
          ForEach(this.data.steps, (st: StepItem) => {
            Column() {
              Row() {
                Text(st.name).fontSize(13).fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textMain)
                Text(st.rank).fontSize(10).fontColor(COLORS.white)
                  .backgroundColor(rankColor(st.rank)).borderRadius(8)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 }).margin({ left: 8 })
                Column().layoutWeight(1)
                Text(st.diff + '').fontSize(10).fontColor(COLORS.textSub)
              }
              .width('100%')
              Text(st.desc).fontSize(10).fontColor(COLORS.textHint).width('100%').margin({ top: 4 })
              Row() {
                Column()
                  .width(stepBarW(st.diff)).height(8).borderRadius(4)
                  .backgroundColor(COLORS.jade)
                  .opacity(this.amb ? 0.55 : 1)
                  .animation({ duration: 1000, iterations: -1, playMode: PlayMode.Alternate })
                Column().layoutWeight(1)
                Text('难度 ' + st.diff).fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
              }
              .width('100%').margin({ top: 8 })
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (st: StepItem) => st.name)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    } else if (this.activeTab === 5) {
      Scroll() {
        Column() {
          Row() {
            Text('🖼️').fontSize(14).margin({ right: 6 })
            Text('内画题材').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
            Column().layoutWeight(1)
            Text('热门度').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').padding({ top: 14, bottom: 8 })
          ForEach(this.data.themes, (t: ThemeItem) => {
            Column() {
              Row() {
                Text(t.name).fontSize(13).fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.textMain)
                Text(t.desc).fontSize(10).fontColor(COLORS.textSub).margin({ left: 8 })
                Column().layoutWeight(1)
                Text(t.hot + '').fontSize(10).fontColor(COLORS.textSub)
              }
              .width('100%')
              Row() {
                Column()
                  .width(themeBarW(t.hot)).height(8).borderRadius(4)
                  .backgroundColor(COLORS.amber)
                  .opacity(this.amb ? 0.5 : 1)
                  .animation({ duration: 1200, iterations: -1, playMode: PlayMode.Alternate })
                Column().layoutWeight(1)
              }
              .width('100%').margin({ top: 8 })
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (t: ThemeItem) => t.name)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    } else if (this.activeTab === 6) {
      Scroll() {
        Column() {
          Row() {
            Text('🧓').fontSize(14).margin({ right: 6 })
            Text('藏家名录').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
            Column().layoutWeight(1)
            Text('点击可移除').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').padding({ top: 14, bottom: 8 })
          ForEach(this.data.collectors, (co: CollectorItem, i: number) => {
            Column() {
              Row() {
                Column() {
                  Text(co.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
                  Text('镇宅:' + co.treasure).fontSize(11)
                    .fontColor(COLORS.textSub).margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Column() {
                  Text(co.count + '只').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.jadeDeep)
                  Text('珍藏').fontSize(10).fontColor(COLORS.textHint).margin({ top: 2 })
                }
                Text('移除').fontSize(10).fontColor(COLORS.danger).margin({ left: 12 })
                  .onClick(() => {
                    this.collectorIdx = i;
                    this.showRemoveCollector = true;
                  })
              }
              .width('100%')
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (co: CollectorItem) => co.name)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    } else {
      Scroll() {
        Column() {
          Row() {
            Text('📦').fontSize(14).margin({ right: 6 })
            Text('订单台账').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
            Column().layoutWeight(1)
            Text('近六单金额').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').padding({ top: 14, bottom: 8 })
          Row() {
            ForEach(TOP_ORDER_IDX, (i: number) => {
              Column() {
                Text(this.data.orders[i].piece.length > 2
                  ? this.data.orders[i].piece.substring(0, 2) : this.data.orders[i].piece)
                  .fontSize(9).fontColor(COLORS.textHint)
                Column()
                  .width(26).height(orderBarH(this.data.orders[i].amount)).borderRadius(4)
                  .backgroundColor(i % 2 === 0 ? COLORS.jadeDeep : COLORS.amber)
                  .margin({ top: 6 })
                  .opacity(this.amb ? 0.55 : 1)
                  .animation({ duration: 900 + i * 150, iterations: -1, playMode: PlayMode.Alternate })
                Text((this.data.orders[i].amount / 1000).toFixed(0) + 'k')
                  .fontSize(9).fontColor(COLORS.textSub).margin({ top: 4 })
              }
              .layoutWeight(1)
              .alignItems(HorizontalAlign.Center)
            }, (i: number) => i.toString())
          }
          .width('100%')
          .padding({ top: 12, bottom: 12 })
          .backgroundColor(COLORS.cardBg).borderRadius(12)
          .margin({ top: 4, bottom: 12 })
          .alignItems(VerticalAlign.Bottom)
          .border({ width: 1, color: COLORS.border })
          Row() {
            Text('新增订单').fontSize(12).fontColor(COLORS.white)
              .backgroundColor(COLORS.jadeDeep).borderRadius(16)
              .padding({ left: 22, right: 22, top: 9, bottom: 9 })
              .onClick(() => {
                this.formNo = '';
                this.formBuyer = '';
                this.formPiece = '';
                this.formAmount = '';
                this.showAddOrder = true;
              })
            Column().layoutWeight(1)
            Text('共 ' + this.data.orders.length + ' 笔').fontSize(10).fontColor(COLORS.textHint)
          }
          .width('100%').margin({ bottom: 12 })
          ForEach(this.data.orders, (o: OrderItem) => {
            Column() {
              Row() {
                Column() {
                  Text(o.piece).fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.textMain)
                  Text(o.no + ' · ' + o.buyer).fontSize(10)
                    .fontColor(COLORS.textHint).margin({ top: 4 })
                }
                .alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text(o.status).fontSize(10).fontColor(COLORS.white)
                  .backgroundColor(statusColor(o.status)).borderRadius(8)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                Text('¥' + o.amount).fontSize(13).fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.jadeDeep).margin({ left: 8 })
              }
              .width('100%')
            }
            .width('100%')
            .backgroundColor(COLORS.cardBg).borderRadius(12)
            .padding({ left: 14, right: 14, top: 12, bottom: 12 })
            .margin({ bottom: 10 })
            .border({ width: 1, color: COLORS.border })
          }, (o: OrderItem) => o.no)
        }
        .width('100%').padding({ left: 14, right: 14, bottom: 12 })
      }
      .width('100%')
      .layoutWeight(1)
    }
  }

  @Builder
  bottomBar() {
    Column() {
      Row() {
        ForEach(ROW1_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => i.toString())
      }
      .width('100%')
      Row() {
        ForEach(ROW2_IDX, (i: number) => {
          this.bottomItem(i)
        }, (i: number) => i.toString())
      }
      .width('100%')
    }
    .width('100%')
    .backgroundColor(COLORS.cardBg)
    .borderRadius({ topLeft: 20, topRight: 20 })
    .border({ width: 1, color: COLORS.border })
  }

  build() {
    Stack() {
      Column() {
        this.headerArea()
        this.contentArea()
        this.bottomBar()
      }
      .width('100%')
      .height('100%')
      .backgroundColor(COLORS.bg)

      if (this.showAddOrder) {
        Stack() {
          this.addOrderModal()
          this.modalOverlay(() => { this.showAddOrder = false })
        }
        .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
      }
      if (this.showEditArtist) {
        Stack() {
          this.editArtistModal()
          this.modalOverlay(() => { this.showEditArtist = false })
        }
        .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
      }
      if (this.showRemoveCollector) {
        Stack() {
          this.removeCollectorModal()
          this.modalOverlay(() => { this.showRemoveCollector = false })
        }
        .width('100%').height('100%').position({ x: 0, y: 0 }).zIndex(999)
      }
    }
    .width('100%')
    .height('100%')
  }
}

标签: HarmonyOS · ArkTS · API 24 · 内画鼻烟壶 · 温润玉色琥珀 · 单组件架构 · @Observed · 壶中丹青 · 移动端开发

Logo

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

更多推荐