一、技术前言

在非物质文化遗产数字化保护领域,纹样素材的采集、整理与再创作是连接传统工艺与现代文创设计的核心纽带。从唐草卷纹的织锦提花到宋代缠枝的汝窑描银,从万字回纹锦的霞帔錾刻到冰梅青花纹的釉下彩绘,每一条纹样都承载着特定朝代的审美意趣与工艺密码。然而,传统纹样管理平台长期面临三大瓶颈:纹样分类缺乏可视化数据支撑导致馆藏结构不透明、朝代与品类交叉浏览时层级切换割裂导致素材检索效率低下、纹样素材的元数据无法就地读写校验导致版本溯源困难。

HarmonyOS ArkUI 框架以其声明式 UI 范式为这些问题提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂的 UI 结构拆分为可组合的构建块。这种架构天然适合纹样管理场景中"数据-视图-交互"紧耦合的需求。Canvas 2D 绘图接口为占比环形图提供了 arcfillTextglobalAlpha 等原子级绘制能力,使得统计可视化不再依赖第三方图表库。

本平台深度融合了 HarmonyOS 6.1.1 的三大前沿特性。Canvas 绘制特性通过 CanvasRenderingContext2D 构建五品类馆藏占比环形图,以 setInterval 驱动呼吸微动重绘,扇区中心镂空配合 fillText 标注百分比,外圈描边利用 globalAlpha 实现半透明呼吸效果后立即复位,确保画布状态不被意外污染。Tabs 嵌套滚动特性(API 24)通过内层 Tabs 挂载 nestedScroll(TabsNestedScrollMode) 实现 SELF_ONLY(仅内层)与 SELF_FIRST(先内后外)两种模式动态切换,让内层纹样类别滑到边缘后可联动外层朝代频道,实现朝代与品类的自然交叉浏览,用户在垂直滑动内容时无需抬手即可完成层级切换。Image Kit WebP 元数据特性(API 24)通过 readImageMetadataByType 读取五字段 → writeImageMetadata 写回 → 重建 ImageSource 二次读取回读校验,全程沙箱零权限操作,为纹样素材的元数据版本管理提供了可信链路,确保写入的帧延迟、循环次数等参数真实生效。

从工程架构视角审视,纹藏馆平台采用单文件组件化设计,整个应用包含约 1730 行 ArkTS 代码,涵盖色彩体系、常量数据、工具函数、数据模型、组件主体、六大 Tab 构建器、弹窗系统等七大模块。六个 Tab 各自拥有完全独立的布局结构和交互逻辑,却共享统一的色彩体系和状态管理机制,这种"分而治之、统而不乱"的架构设计既是 ArkUI 声明式范式的最佳实践,也是非遗数字平台从"素材仓库"向"创意工坊"升级的技术基石。

二、整体架构流程图

数据模型层

弹窗系统三态

三大前沿特性能力链

内容区六大功能页

页面布局层

根组件层

纹藏馆主组件 Page1283

headerBanner 头部朱砂渐变Banner

内容区 6 Tab 切换

tabBar 底部导航栏

modalOverlay 弹窗遮罩层

Tab0 素材馆
朝代横滚chips+双列纹样卡+Canvas环形图+月度柱状图

Tab1 频道
朝代×纹样双层Tabs嵌套滚动

Tab2 日志
nestedScroll翻页事件时间轴

Tab3 工坊
纹理五选一WebP样图生成器

Tab4 元数据
五字段读写回读校验链路

Tab5 我的
守艺人渐变大卡+创作任务清单

特性A Canvas绘制
drawPie环形图中心镂空+呼吸重绘

特性B Tabs嵌套滚动
nestedScroll SELF_ONLY/SELF_FIRST

特性C ImageKit WebP
像素画编码→元数据读写校验

panelAdd 新建纹样收藏

panelEdit 编辑用途描述

panelDel 删除确认

PatternItem 纹样素材

InnerCard 内层卡片

SwipeLog 滑动日志

WebpMetaSnapshot 元数据快照

MetaOpLog 操作日志

MineTask 创作任务

整体架构以主组件为根节点,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部 Banner + 内容区 + 底部 Tab 栏,顶层是全屏弹窗遮罩。内容区通过 currentTab 状态索引在 6 个 @Builder 方法间切换,每个 Tab 拥有完全独立的布局结构。三大特性(Canvas 环形图绘制、Tabs 嵌套滚动、ImageKit WebP 元数据)分别挂载在素材馆、频道、工坊与元数据四个 Tab 上,但它们的状态变量统一声明在组件顶层,实现跨 Tab 数据共享。呼吸动画定时器在 aboutToAppear 中以 1000ms 间隔翻转 breath 布尔值并手动调用 drawPie 重绘 Canvas——这是因为 Canvas 不随 @State 自动重绘,必须显式调用绘制方法。

数据模型层的六个 @Observed 类分别支撑各自 Tab 的列表渲染:PatternItem 同时被素材馆 Tab 和弹窗系统引用以实现 CRUD 操作,InnerCardinnerMockData 函数动态生成以填充双层 Tabs 的每个子页签,SwipeLog 记录每次翻页事件的层级、来源、目标和嵌套模式,WebpMetaSnapshot 镜像 WebP 元数据五字段用于读取快照和回读校验对比,MetaOpLog 记录元数据操作的完整历史流,MineTask 驱动守艺人中心的任务清单展示。六个模型类各司其职,构成了纹藏馆平台的数据骨架。

三、色彩体系设计

3.1 ColorPalette 接口定义

平台采用宣纸米白浅色主题,通过 ColorPalette 接口集中声明全部颜色字段,使全文件色彩管理统一可控:

interface ColorPalette {
  bg: string;       // 页面底色·宣纸米白
  card: string;     // 卡片底色·纯白
  chip: string;     // 胶囊/输入底色·米杏
  title: string;    // 主标题·墨褐
  sub: string;      // 次级文字·驼褐
  text3: string;    // 弱化文字·浅驼
  red: string;      // 主题色·朱砂
  redD: string;     // 主题色深·深朱砂
  blue: string;     // 辅色·黛蓝
  gold: string;     // 辅色·鎏金
  green: string;    // 辅色·苔绿
  line: string;     // 分割线·米灰
  tabOn: string;    // Tab 激活色·朱砂
  mask: string;     // 弹窗遮罩·墨褐半透
  onMain: string;   // 深色底上的白字
  gradA: string;    // 渐变起点·朱砂(头部/会员卡)
  gradB: string;    // 渐变终点·深朱砂(统计大卡)
}

这段接口定义体现了 ArkTS 的类型安全优势。与普通 JavaScript 动态添加属性不同,ColorPalette 接口在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露。接口共声明 16 个颜色字段,覆盖背景层(bg/card/chip)、文字层(title/sub/text3/onMain)、主辅色层(red/redD/blue/gold/green)、功能色层(line/tabOn/mask)和渐变色层(gradA/gradB)五个层级,形成了完整的色彩语义体系。每个字段的注释采用"字段名 + 用途"的格式,使每个颜色的语义角色一目了然,后续维护者无需追踪代码即可理解色彩用途。接口中特别增加了 blueD 字段(在常量中补充)用于会员卡三列小格的深色背景,体现了接口定义与常量实现之间的灵活扩展关系。

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  bg: '#F7F3EC',      // 宣纸米白,模拟传统手工纸底色
  card: '#FFFFFF',    // 纯白卡片,与米白背景形成微弱层次
  chip: '#EFE8DC',    // 米杏胶囊底,嵌入主背景不突兀
  title: '#3B2F25',   // 墨褐标题,高对比度保证可读
  sub: '#8A7A64',     // 驼褐副标题,层次柔和过渡
  text3: '#B5A88F',   // 浅驼弱文本,辅助信息不抢视觉
  red: '#C0392B',     // 朱砂红主色,馆藏标识与操作主色
  redD: '#9A2C20',    // 深朱砂,渐变终点与删除操作
  blue: '#2C3E7A',    // 黛蓝,朝代徽标与信息标识
  blueD: '#1F2D5C',   // 深黛蓝,会员卡统计小格底
  gold: '#B8860B',    // 鎏金,方胜品类与外层频道徽标
  green: '#5E8C61',   // 苔绿,完成状态与读回校验通过
  line: '#E8E0D0',    // 米灰分割线,低对比度不干扰内容
  tabOn: '#C0392B',   // Tab 选中色与主色一致
  mask: 'rgba(59,47,37,0.55)', // 墨褐半透遮罩
  onMain: '#FFFFFF',  // 朱砂底白字
  gradA: '#C0392B',   // 渐变起点朱砂
  gradB: '#9A2C20'    // 渐变终点深朱砂
};

色彩设计遵循"非遗传统色"原则,每一色都取自中国传统绘画矿物颜料谱系,具有明确的文化语义和功能角色。

背景层三色构建了三层深度的空间感。bg#F7F3EC 宣纸米白,模拟传统手工宣纸的温润底色,略带微黄的白色调降低了纯白背景的视觉刺激,适合长时间浏览纹样素材的文创工作场景。card 为纯白 #FFFFFF,卡片与背景形成柔和对比,保证信息区块的清晰边界,同时白色卡片为纹样展示提供了中性的"画纸"背景。chip#EFE8DC 米杏色,比背景色深约 4%,用于胶囊徽章、输入框底色和进度条底色,与卡片底色仅差一档亮度,既区分又不突兀,营造了"印泥盖在纸上"的微妙层次。

文字层三色建立了清晰的信息层级。title#3B2F25 墨褐色,主标题文字色,与浅色背景形成高对比度但不似纯黑那般刺眼,墨褐取自传统墨汁在宣纸上的晕染效果,与宣纸米白背景形成"墨落纸上"的文化呼应。sub#8A7A64 驼褐色,副标题色,在标题与弱文本之间架起层次过渡,驼褐色取自敦煌壁画中常见的矿物颜料色,具有古朴温润的质感。text3#B5A88F 浅驼色,三级弱文本,用于辅助说明、时间戳和占位文字,视觉权重最低。

主辅色五色是整个色彩体系的灵魂,分别对应五大纹样品类的视觉语义编码。red#C0392B 朱砂红,平台主色,象征吉祥与喜庆,贯穿环形图主扇区、Tab 选中态、按钮背景、品类徽标和渐变起点。redD#9A2C20 深朱砂,用于渐变终点、删除按钮和写入操作的强调态,比朱砂低约 20% 明度,形成稳重的深色锚点。blue#2C3E7A 黛蓝色,取自传统青花的钴蓝色料,专用于朝代徽标和信息标识,云纹品类也使用黛蓝,与朱砂主色形成冷暖对比。gold#B8860B 鎏金色,取自古代鎏金工艺的金属色泽,方胜品类和外层朝代频道徽标使用鎏金,象征尊贵与传承。green#5E8C61 苔绿色,取自青铜器铜锈的自然色泽,冰裂品类和完成状态使用苔绿,象征新生与通过。

功能色四色承担界面的辅助功能。line#E8E0D0 米灰色分割线,同时复用为 Canvas 网格线,低对比度不干扰内容。tabOnred 同值,保证 Tab 选中态与主色一致,形成视觉统一。mask 为半透明墨褐色 rgba(59,47,37,0.55),弹窗遮罩使用 RGBA 格式实现 55% 透明度,墨褐色与宣纸色系协调,比纯黑遮罩更具文化气息。onMain 为纯白 #FFFFFF,深色底(朱砂渐变、黛蓝小格)上的文字色,保证深色背景下的文字可读性。

渐变色两色驱动头部 Banner 和会员卡的渐变效果。gradAred 同值为渐变起点,gradBredD 同值为渐变终点,135 度或 160 度的线性渐变模拟朱砂印章盖在宣纸上的晕染效果,从浓到淡的色彩过渡呼应了非遗工艺的"晕染"美学。

四、Tab 元数据与辅助数据

4.1 底部导航 Tab 定义

interface TabMeta {
  icon: string;   // Tab 图标
  label: string;  // Tab 标签
}

const TAB_LIST: TabMeta[] = [
  { icon: '🏛️', label: '素材' },
  { icon: '🌀', label: '频道' },
  { icon: '📜', label: '日志' },
  { icon: '🎨', label: '工坊' },
  { icon: '🧬', label: '元数据' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了 Tab 导航项的最小数据结构:icon 为 emoji 字符串,label 为中文标签文字。TAB_LIST 常量数组按顺序声明六个 Tab 项,分别对应素材、频道、日志、工坊、元数据和我的。6 个 Tab 单排排列,从素材浏览到守艺人中心覆盖纹样管理全流程。每个 Tab 的图标与其功能语义紧密对应:🏛️ 代表馆藏素材库,是用户进入平台后的第一站;🌀 代表朝代频道流转,象征着历史长河中的纹样演变;📜 代表滑动日志记录,记录每一次翻页交互;🎨 代表纹理样图工坊,是创意生产的核心场所;🧬 代表元数据读写,对应纹样素材的基因层面管理;👤 代表守艺人个人中心,是创作者的专属空间。

这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断。ForEach 的第三个参数(键值生成函数)使用 tab.label 作为唯一标识,确保列表项的稳定引用。

4.2 朝代频道与纹样类别数据

interface ChannelItem {
  name: string;  // 朝代频道名
  icon: string;  // 朝代频道图标
}

const OUTER_CHANNELS: ChannelItem[] = [
  { name: '唐', icon: '🏯' },
  { name: '宋', icon: '🖌' },
  { name: '元', icon: '🏇' },
  { name: '明', icon: '🏮' },
  { name: '清', icon: '🪷' }
];

const INNER_TABS: string[] = ['回纹', '云纹', '方胜', '冰裂', '联珠'];

ChannelItem 接口定义了外层朝代频道的数据结构,包含朝代名和图标两个字段。OUTER_CHANNELS 常量数组包含五个朝代频道,按历史顺序排列为唐、宋、元、明、清,每个朝代配一个具有代表性的 emoji 图标:唐代的城楼象征大唐盛世的宏伟建筑,宋代的毛笔代表文人书画的黄金时代,元代的骑马形象反映游牧民族的马背文化,明代的灯笼寓意市井繁华的节日氛围,清代的莲花象征清雅脱俗的审美趣味。

INNER_TABS 字符串数组定义了内层纹样类别的五个子页签:回纹、云纹、方胜、冰裂、联珠。这五类纹样是中国传统装饰纹样中最具代表性的基本骨架,几乎所有传统纹样都可以追溯到这五种基本形态的演变与组合。

外层 5 个朝代频道涵盖唐宋元明清五大历史时期的纹样谱系,内层 5 个纹样类别覆盖五大传统纹样类型。两层 Tabs 嵌套形成 25 个朝代与品类的交叉矩阵,每个矩阵下有 8 条工艺卡片,共 200 个纹样素材点位,为 nestedScroll 的"滑到边缘联动"提供了充足的滚动内容——只有当内层内容超出一屏时,用户才能真切感受到"滑到边缘后继续滑动触发外层翻页"的嵌套滚动效果。

4.3 朝代筛选 chips 与工艺素材池

const DYNASTY_CHIPS: string[] = ['全部', '唐', '宋', '元', '明', '清'];

DYNASTY_CHIPS 常量数组定义了素材馆 Tab 的朝代筛选横滚 chips,包含"全部"档和五个朝代共 6 个选项。用户点击 chips 可快速筛选不同朝代的纹样素材,筛选逻辑由 switchDynasty 方法切换 activeDynasty 状态,filteredPatterns 方法计算筛选结果。这种在 @Builder 外预计算筛选结果的做法避免了在 ForEach 内部调用 filter 导致的性能问题,是 ArkUI 列表优化的最佳实践。

const INNER_TITLES: string[] = [
  '织锦提花', '瓷器釉彩', '金银錾刻', '雕版印染',
  '建筑彩画', '漆器螺钿', '刺绣锁边', '珐琅掐丝'
];

const INNER_WORKS: string[] = [
  '《缠枝宝相》妆花缎', '《青花冰梅》盖碗', '《龟背联珠》錾花银盘', '《落花流水》蓝印花布',
  '《旋子彩画》梁枋小样', '《黑漆嵌螺》圆盒', '《方胜如意》锁绣香囊', '《掐丝回纹》珐琅杯垫'
];

const INNER_NOTES: string[] = [
  '提花综片循环节奏已复刻,可直接对接针织 CAD',
  '釉下青花分水五色阶,冰裂纹开片率控制在 12%',
  '錾刻走刀 0.3mm 阳线,联珠圈带间隔等距',
  '灰缬防染浆配方开源,适合文创批量印制',
  '和玺与旋子彩画线稿分层,含金量标注齐全',
  '螺钿厚片切 0.2mm 贝光层,灯下呈虹彩光泽',
  '锁绣针距 3mm 标准化,双面异色绣法注解',
  '掐丝 1.2mm 紫铜丝,回纹转角回填工艺图'
];

这三组常量数组构成了内层子页签的工艺素材池,每组 8 条数据,分别对应工艺载体名、作品名和工艺注解。INNER_TITLES 涵盖织锦提花、瓷器釉彩等八种中国传统工艺门类,从纺织到陶瓷、从金属到漆艺、从建筑到刺绣,全面展示了非遗工艺的丰富形态。INNER_WORKS 为每件作品赋予了具体名称,如《缠枝宝相》妆花缎、《青花冰梅》盖碗等,使抽象的工艺类型落地为可感知的具体作品。INNER_NOTES 为每条工艺卡片提供了深度的工艺注解,包含具体的工艺参数、技术指标和应用场景,如"提花综片循环节奏已复刻""釉下青花分水五色阶"等,体现了纹藏馆平台的专业性和学术深度。

这三组数据在 innerMockData 函数中被组合使用,根据外层朝代频道和内层纹样类别的不同,动态生成具有行业化标题和描述的卡片内容,使每个交叉矩阵下的 8 条卡片都具有独特的语义内容,而非简单的重复占位。

4.4 Canvas 环形图与柱状图数据

interface PieData {
  val: number;    // 占比(%,合计 100)
  label: string;  // 品类名
}

const PIE_DATA: PieData[] = [
  { val: 30, label: '回纹' },
  { val: 25, label: '云纹' },
  { val: 18, label: '方胜' },
  { val: 15, label: '冰裂' },
  { val: 12, label: '联珠' }
];

const PIE_COLORS: string[] = [COLORS.red, COLORS.blue, COLORS.gold, COLORS.green, COLORS.redD];

const PIE_TOTAL: number = 1280;

PieData 接口定义了环形图数据的两字段结构,val 为百分比数值(合计 100),label 为品类名称。ArkTS 禁止在数组类型中内联 {val:number, label:string}[] 这样的对象字面量类型,必须先定义 interface 再使用,这是 ArkTS 严格类型系统的体现。

五品类馆藏占比数据合计 100%,回纹以 30% 居首、联珠以 12% 居末,反映了回纹作为"万不断"纹样在传统工艺中的广泛应用。扇区配色 PIE_COLORS 取自 COLORS 常量,按顺序分别为朱砂、黛蓝、鎏金、苔绿、深朱砂,与品类色徽保持一致,确保 Canvas 绘制与 UI 组件的品类配色完全统一。PIE_TOTAL 常量 1280 表示馆藏纹样总量,显示在环形图的中心位置,让用户一眼掌握馆藏规模。

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['03', '04', '05', '06', '07', '08'];
const DOWNLOAD_VAL: number[] = [320, 410, 380, 520, 470, 610];
const BAR_MAX: number = 640;

月度柱状图数据包含六个月的纹样素材成交量,从 03 月到 08 月整体呈上升趋势(320→610),反映文创市场对纹样素材的需求逐月增长。MONTH_IDX 数组为 0 到 5 的索引值,用于 ForEach 遍历并生成键值。BAR_MAX 常量 640 为柱状图的满刻度基准,柱高换算公式为 DOWNLOAD_VAL[i] / BAR_MAX * 96,即满刻度时柱高为 96px。最高值 610 对应 08 月,柱高约 91.5px,接近满刻度但留有余地,符合数据可视化的"留白"美学原则。

4.5 WebP 工坊纹理与参数预设

interface TextureItem {
  key: string;    // 像素算法键(diag/checker/horz/vert/ring)
  name: string;   // 对应纹样名
  label: string;  // 纹理标签
  desc: string;   // 寓意说明
}

const TEXTURES: TextureItem[] = [
  { key: 'diag', name: '回纹', label: '斜纹', desc: '横竖折线连绵不断,寓意福寿绵长、富贵不断头' },
  { key: 'checker', name: '方胜', label: '棋盘', desc: '两菱相扣同心相连,寓意同心永结、吉祥永续' },
  { key: 'horz', name: '云纹', label: '横带', desc: '如意云头层层叠叠,寓意平步青云、节节高升' },
  { key: 'vert', name: '冰裂', label: '竖带', desc: '冰面裂纹织理纵横,寓意破冰新生、寒尽春来' },
  { key: 'ring', name: '联珠', label: '同心环', desc: '圆珠连环成带成圈,寓意绵延不绝、珠联璧合' }
];

const WEBP_PALETTE: string[] = [COLORS.red, COLORS.blue, COLORS.gold, COLORS.green, COLORS.redD];

纹理五选一是工坊 Tab 的核心交互,将像素算法与传统纹样语义一一映射。每种纹理都有四个属性:key 是算法键名,对应 pixelColor 函数中的分支逻辑;name 是对应的传统纹样名称;label 是纹理的通俗标签;desc 是纹样的文化寓意说明。

五种算法各有特色:斜纹算法(行列相加取模形成 45 度斜带)对应回纹的连绵意象,斜向的线条如同回纹的折线般连绵不绝;棋盘算法(8x8 分块行列块索引相加取模)对应方胜的同心相连,棋盘格的交错结构象征方胜纹的两菱相扣;横带算法(每 16 行换一色)对应云纹的层叠如意,水平带状的色彩变化如同层层叠叠的云头;竖带算法(每 16 列换一色)对应冰裂的纵横织理,垂直带状的色彩分布如同冰面裂纹的纵向延伸;同心环算法(按到画布中心距离分环)对应联珠的绵延成圈,环形分布的色带如同联珠纹的圆珠连环。

五色色板 WEBP_PALETTE 取自应用主题辅助色,依次为朱砂、黛蓝、鎏金、苔绿、深朱砂,确保工坊产物与全局色彩体系统一。生成的 WebP 样图不仅是像素艺术,更是传统纹样语义的数字化转译。

const WEBP_QUALITY: number = 90;
const CANVAS_SIZE: number = 96;
const DELAY_PRESETS: number[] = [120, 200, 500];
const LOOP_PRESETS: number[] = [0, 1, 3, 5];

WebP 编码相关的四个常量定义了工坊和元数据页的参数基准。WEBP_QUALITY 为 90,表示 WebP 编码质量为 90%,在文件大小和图像质量之间取得平衡。CANVAS_SIZE 为 96,表示像素画画布为 96×96 像素,这个尺寸与 WebP 元数据的 canvasWidth/canvasHeight 字段同值写回,确保读取校验时尺寸一致。DELAY_PRESETS 为帧延迟预设档位,包含 120ms、200ms、500ms 三档,均在 [100, 65535] 的钳制区间内。LOOP_PRESETS 为循环次数预设档位,包含 0(不限)、1、3、5 四档,其中 0 表示无限循环,是动图常见的播放模式。

4.6 创作任务数据

interface MineTask {
  icon: string;   // 任务图标
  label: string;  // 任务名
  hint: string;   // 工分奖励
  done: boolean;  // 完成态
}

const MINE_TASKS: MineTask[] = [
  { icon: '🖌', label: '上传《唐草卷纹》描线稿', hint: '+40 工分', done: true },
  { icon: '🧵', label: '完成缂丝方胜纹矢量重绘', hint: '+35 工分', done: true },
  { icon: '📖', label: '校订《营造法式》彩画词条', hint: '+25 工分', done: false },
  { icon: '🏛️', label: '投稿景德镇冰裂釉专题', hint: '+30 工分', done: false },
  { icon: '🧬', label: '补录联珠纹织锦元数据', hint: '+20 工分', done: false },
  { icon: '🎁', label: '邀请同好共建纹样库', hint: '+15 工分', done: false }
];

MineTask 接口定义了守艺人中心任务清单的数据结构,包含任务图标、任务名、工分奖励和完成态四个字段。MINE_TASKS 常量数组包含 6 条创作任务,已完成 2 条、待完成 4 条,覆盖描线上传、矢量重绘、词条校订、专题投稿、元数据补录和社区邀请六种任务类型。任务工分从 15 分到 40 分不等,难度越高奖励越多,形成了合理的激励梯度。任务图标使用 emoji 直观表示任务类型,完成态由 done 布尔值控制,已完成任务显示苔绿色"已完成"徽章,待完成任务显示鎏金色"待办"徽章。

五、工具函数分析

5.1 嵌套模式文案映射

function modeLabel(mode: TabsNestedScrollMode): string {
  return mode === TabsNestedScrollMode.SELF_FIRST
    ? 'SELF_FIRST·先内后外' : 'SELF_ONLY·仅内层';
}

function modeShort(mode: TabsNestedScrollMode): string {
  return mode === TabsNestedScrollMode.SELF_FIRST ? '先内后外' : '仅内层';
}

modeLabel 提供完整文案用于日志时间轴的事发模式记录,modeShort 提供短文案用于头部状态胶囊与模式切换 chips。两个函数将 TabsNestedScrollMode 枚举值翻译为人类可读的中文描述,确保日志可追溯。SELF_FIRST 模式下内层滑到边缘后继续滑动会触发外层翻页(即"接力"效果),SELF_ONLY 模式下内层滑动止于自身边界不联动外层。

两个函数的命名体现了清晰的职责分工:modeLabel 返回带枚举名的完整标签,适合日志和调试场景;modeShort 返回纯中文短文案,适合空间有限的 UI 展示。这种"同数据不同表达"的多函数设计是工具函数层的常见模式,通过细粒度的函数拆分提高了代码的可读性和复用性。

5.2 元数据字段格式化

function fmtField(v: number, unit: string): string {
  return v < 0 ? '未提供' : `${v}${unit}`;
}

function loopText(v: number): string {
  if (v < 0) { return '未提供'; }
  if (v === 0) { return '0(不限)'; }
  return `${v}`;
}

function sizeText(v: number): string {
  return v < 0 ? '未提供' : `${v} px`;
}

三个格式化函数统一处理 WebP 元数据五字段中 -1 占位符的语义。fmtField 是通用数值格式化器,接收数值和单位两个参数,当数值小于 0 时返回"未提供",否则返回数值加单位的拼接字符串。loopText 专门处理 loopCount0=不限 特殊语义,有三道判断:小于 0 返回"未提供",等于 0 返回"0(不限)“,其他值返回"N 次”。sizeText 专门处理像素尺寸字段的 px 单位后缀,逻辑与 fmtField 类似但单位固定为 px。

-1 作为"未提供"的占位值贯穿整个元数据读写链路:读取时通过 ?? -1 兜底防止 undefined 被当作 0 渲染,写入时由控制台选择的档位值覆盖。这种约定优于配置的设计使得元数据的"有值/无值"状态清晰可辨,避免了 0 和 undefined 混淆导致的语义歧义。

5.3 像素颜色织纹算法

function hexToRgba(hex: string): number {
  const r = parseInt(hex.slice(1, 3), 16);
  const g = parseInt(hex.slice(3, 5), 16);
  const b = parseInt(hex.slice(5, 7), 16);
  return 0xFF000000 | (b << 16) | (g << 8) | r;
}

hexToRgba 函数将十六进制颜色字符串转换为 RGBA8888 格式的 32 位整数,供 Uint32Array 直接写入像素数据。函数解析 #RRGGBB 格式的十六进制字符串,分别提取红、绿、蓝三个分量,然后通过位运算组合为小端排布的 32 位整数:最高字节为 alpha 通道(固定 0xFF 即完全不透明),其次是蓝色分量、绿色分量、红色分量。这种小端 RGBA 排布是 PixelMapFormat.RGBA_8888 格式的内存布局要求,必须严格遵循才能正确显示颜色。

function pixelColor(row: number, col: number, key: string, palette: string[]): number {
  const n = palette.length;
  if (key === 'checker') {
    // 棋盘(方胜):8×8 分块行列块索引相加取模
    return hexToRgba(palette[(Math.floor(row / 8) + Math.floor(col / 8)) % n]);
  }
  if (key === 'horz') {
    // 横带(云纹):每 16 行换一色
    return hexToRgba(palette[Math.floor(row / 16) % n]);
  }
  if (key === 'vert') {
    // 竖带(冰裂):每 16 列换一色
    return hexToRgba(palette[Math.floor(col / 16) % n]);
  }
  if (key === 'ring') {
    // 同心环(联珠):按到画布中心的距离分环
    const dx = col - CANVAS_SIZE / 2;
    const dy = row - CANVAS_SIZE / 2;
    const dist = Math.sqrt(dx * dx + dy * dy);
    return hexToRgba(palette[Math.floor(dist / 9) % n]);
  }
  // 斜纹(回纹·默认):行列相加取模形成 45° 斜带
  return hexToRgba(palette[(row + col) % n]);
}

pixelColor 是工坊像素画生成的核心算法函数,根据行号、列号、纹理算法键和色板数组,计算并返回当前像素的 RGBA8888 颜色值。函数支持五种纹理算法,每种算法对应一种传统纹样的语义:

棋盘算法(方胜):将画布按 8×8 像素分块,行块索引与列块索引相加后对色板长度取模,决定当前块的颜色。8×8 的分块大小在 96×96 画布上形成 12×12 个方块,棋盘交错的视觉效果象征方胜纹"两菱相扣、同心相连"的寓意。

横带算法(云纹):每 16 行切换一种颜色,形成水平方向的带状条纹。96 行画布共产生 6 条横带,色彩从上到下循环变化,如同层层叠叠的如意云头。

竖带算法(冰裂):每 16 列切换一种颜色,形成垂直方向的带状条纹。96 列画布共产生 6 条竖带,色彩从左到右循环变化,如同冰面裂纹的纵向织理。

同心环算法(联珠):计算当前像素到画布中心的欧氏距离,每 9 像素为一环,按环号取模选色。距离越远环号越大,形成从中心向外辐射的同心圆环,象征联珠纹的"圆珠连环、绵延不绝"。

斜纹算法(回纹·默认):行号与列号相加后取模,形成 45 度角的斜向条纹。斜向线条连绵不断的视觉效果完美对应回纹"福寿绵长、富贵不断头"的文化寓意。

五种算法各有特色,但都遵循"取模循环"的统一模式——通过对位置坐标进行数学变换后取模,实现颜色在色板数组中的循环分配。这种算法设计简洁高效,在 96×96 = 9216 个像素的遍历中只需 O(1) 的计算量,保证了像素画生成的性能。

5.4 品类配色与适配度评分

function catColor(cat: string): string {
  if (cat === '回纹') { return COLORS.red; }
  if (cat === '云纹') { return COLORS.blue; }
  if (cat === '方胜') { return COLORS.gold; }
  if (cat === '冰裂') { return COLORS.green; }
  if (cat === '联珠') { return COLORS.redD; }
  return COLORS.text3;
}

catColor 函数将纹样品类名称映射为对应的主题色,是品类视觉编码体系的核心函数。五大品类各有专属色:回纹配朱砂红(主色地位)、云纹配黛蓝(冷色辅色)、方胜配鎏金(暖色辅色)、冰裂配苔绿(中性辅色)、联珠配深朱砂(深色辅色)。未知品类回退为浅驼色,保证函数始终返回有效值。这种一一对应的色彩映射使用户仅凭颜色即可快速识别纹样品类归属,在双列卡片、环形图图例和内层 Tabs 中保持一致的视觉编码。

function scoreBadge(score: number): string {
  if (score >= 92) { return '精选'; }
  if (score >= 88) { return '优选'; }
  return '良品';
}

function scoreColor(score: number): string {
  if (score >= 92) { return COLORS.red; }
  if (score >= 88) { return COLORS.gold; }
  return COLORS.green;
}

scoreBadgescoreColor 是一对配套函数,分别返回适配度评分的文字标签和对应颜色。适配度评分分为三档:92 分及以上为"精选"配朱砂红,代表最高品质的纹样素材;88 分及以上为"优选"配鎏金色,代表质量优良的素材;其余为"良品"配苔绿色,代表合格可用的素材。三道阈值(92、88)的设计形成了清晰的品质梯度,朱砂红的精选标识最为醒目,鎏金的优选次之,苔绿的良品则以温和的绿色传达"合格可用"的信号。

5.5 状态色映射体系

function genStateColor(state: string): string {
  if (state.indexOf('失败') >= 0) { return COLORS.redD; }
  if (state.indexOf('生成中') >= 0) { return COLORS.gold; }
  if (state.indexOf('已生成') >= 0) { return COLORS.green; }
  return COLORS.text3;
}

genStateColor 函数根据 WebP 生成状态文案返回对应的指示色。函数通过 indexOf 子串匹配判断状态:包含"失败"返回深朱砂(错误状态),包含"生成中"返回鎏金(进行中状态),包含"已生成"返回苔绿(成功状态),其余回退浅驼色(初始/待生成状态)。四种状态色彩构成了完整的状态机视觉编码:初始→进行中→成功/失败,颜色从弱化到暖色再到绿色或红色,符合用户对状态流转的直觉认知。

function opColor(op: string): string {
  if (op === '生成样图') { return COLORS.blue; }
  if (op === '读取元数据') { return COLORS.green; }
  if (op === '写入元数据') { return COLORS.red; }
  return COLORS.gold;
}

opColor 函数为元数据操作日志的操作类型分配颜色:生成样图配黛蓝(创新/生产)、读取元数据配苔绿(查询/获取)、写入元数据配朱砂红(修改/危险)、回读校验配鎏金(验证/确认)。四种操作颜色与操作语义一一对应,使用户在浏览操作日志流时能快速区分操作类型。读取操作用绿色传达"安全获取"的含义,写入操作用红色传达"数据变更"的警示,回读校验用金色传达"验证通过"的权威感。

function layerColor(layer: string): string {
  return layer === '外层朝代' ? COLORS.gold : COLORS.green;
}

layerColor 函数是滑动日志层的徽标配色函数,外层朝代频道用鎏金色、内层纹样类别用苔绿色。鎏金的尊贵感对应朝代频道的"上层"地位,苔绿的清新感对应纹样类别的"内容层"属性。两种颜色在日志时间轴的圆点徽标和时间文字上同时使用,形成"鎏金=外层、苔绿=内层"的快速识别体系。

六、数据模型层

6.1 PatternItem 纹样素材模型

@Observed
export class PatternItem {
  name: string;    // 纹样名(如 唐草卷纹)
  dynasty: string; // 朝代(唐/宋/元/明/清)
  cat: string;     // 品类(回纹/云纹/方胜/冰裂/联珠)
  uses: string;    // 用途描述(工艺载体 · 应用场景)
  score: number;   // 适配度评分(0~100)

  constructor(name: string, dynasty: string, cat: string, uses: string, score: number) {
    this.name = name;
    this.dynasty = dynasty;
    this.cat = cat;
    this.uses = uses;
    this.score = score;
  }
}

PatternItem 是纹藏馆平台的核心业务模型,用 @Observed 装饰器修饰,表示该类的实例具备可观察性。当任何实例的属性发生变化时(如编辑后修改 uses 用途描述),所有引用该实例的 @State 数组会收到通知并触发 UI 刷新。

五个属性构成了纹样素材的完整画像:name 为纹样名,是素材的核心标识;dynasty 为所属朝代,取值为唐、宋、元、明、清五大历史时期;cat 为纹样品类,取值为回纹、云纹、方胜、冰裂、联珠五大基本类型;uses 为用途描述,说明纹样的工艺载体和应用场景;score 为适配度评分,0 到 100 分衡量素材的品质等级。构造函数逐字段赋值,export 关键字使该类可被其他文件引用。

const PATTERN_LIST: PatternItem[] = [
  new PatternItem('唐草卷纹', '唐', '联珠', '织锦披帛底纹 · 敦煌莫高窟边饰复原', 96),
  new PatternItem('宋代缠枝', '宋', '云纹', '汝窑天青釉口沿描银 · 茶器套装礼盒', 92),
  new PatternItem('万字回纹锦', '明', '回纹', '霞帔坠边框錾刻 · 婚庆金饰系列', 94),
  new PatternItem('满池娇织金', '元', '联珠', '纳石失织金锦 · 国潮卫衣提花面料', 88),
  new PatternItem('冰梅青花纹', '清', '冰裂', '青花盖碗釉下彩 · 文创文具礼盒', 90),
  new PatternItem('方胜如意缂', '宋', '方胜', '缂丝团花心纹 · 真丝方巾礼盒', 89),
  new PatternItem('宝相花铜镜', '唐', '云纹', '海兽葡萄镜背面浮雕复刻 · 包装提袋', 95),
  new PatternItem('落花流水锦', '明', '云纹', '织金妆花缎 · 舞台服装数码印花', 87)
];

PATTERN_LIST 常量预置了 8 条纹样素材数据,每条数据都精心设计了真实的朝代、纹样名、品类、用途描述和适配度评分。从唐代的唐草卷纹到清代的冰梅青花,横跨五大朝代;从回纹到联珠,覆盖五大品类。评分从 87 分到 96 分分布,涵盖良品(87-88)、优选(89-91)和精选(92+)三个品质等级。用途描述均包含工艺载体和应用场景两部分,用"·"分隔,体现了纹藏馆平台"工艺 × 应用"的双重定位。

6.2 InnerCard 内层卡片模型

@Observed
export class InnerCard {
  id: string;     // 唯一键(朝代-品类-序号)
  tag: string;    // 所属纹样类别子页签名
  title: string;  // 卡片标题(工艺载体 第 N 期)
  desc: string;   // 卡片描述(作品名 + 工艺注解)

  constructor(id: string, tag: string, title: string, desc: string) {
    this.id = id;
    this.tag = tag;
    this.title = title;
    this.desc = desc;
  }
}

InnerCard 是双层 Tabs 嵌套滚动的内容卡片模型,用 @Observed 装饰器修饰以支持属性变更触发 UI 刷新。四个属性各司其职:id 为唯一键,由朝代名、品类名和序号拼接而成(如"唐-回纹-1"),确保在 200 个卡片中的全局唯一性;tag 为所属纹样类别子页签名,用于标识卡片的品类归属;title 为卡片标题,格式为"品类纹·工艺载体 第 N 期";desc 为卡片描述,包含朝代图标、频道名、子类名、期数、作品名和工艺注解的完整信息。

function innerMockData(channel: ChannelItem, tabName: string): InnerCard[] {
  const list: InnerCard[] = [];
  for (let i = 1; i <= 8; i++) {
    list.push(new InnerCard(
      `${channel.name}-${tabName}-${i}`,
      tabName,
      `${tabName}纹·${INNER_TITLES[i - 1]}${i}`,
      `${channel.icon}${channel.name}」频道「${tabName}」子类第 ${i} 条素材:${INNER_WORKS[i - 1]}${INNER_NOTES[i - 1]}`));
  }
  return list;
}

innerMockData 是内层卡片的 Mock 数据生成函数,接收外层朝代频道对象和内层纹样类别名两个参数,返回 8 条 InnerCard 实例数组。函数通过 for 循环生成 8 条卡片,每条卡片的标题和描述都结合了朝代信息、品类信息和工艺素材池中的对应数据,使每个交叉矩阵下的卡片内容都具有独特的语义价值。8 条数据的设计确保了内层列表内容超出一屏,为 nestedScroll 的"滑到边缘联动"效果提供了必要的滚动距离——如果内容不足一屏,用户无法滑到边缘,嵌套滚动效果便无法感知。

6.3 SwipeLog 滑动日志模型

@Observed
export class SwipeLog {
  layer: string;    // 层级(外层朝代/内层类别)
  tabName: string;  // 翻到的页签名
  fromIdx: number;  // 起始索引
  toIdx: number;    // 目标索引
  mode: string;     // 触发时的嵌套模式(modeLabel 结果)
  time: string;     // 记录时间

  constructor(layer: string, tabName: string, fromIdx: number, toIdx: number, mode: string) {
    this.layer = layer;
    this.tabName = tabName;
    this.fromIdx = fromIdx;
    this.toIdx = toIdx;
    this.mode = mode;
    const d = new Date();
    this.time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
  }
}

SwipeLog 模型记录每一次翻页事件的完整信息,是嵌套滚动特性可视化验证的核心数据结构。六个属性构成了完整的事件画像:layer 区分外层朝代和内层类别两个层级;tabName 为翻到的目标页签名;fromIdxtoIdx 分别为翻页前后的索引,可用于计算翻页方向(向右/向左)和距离;mode 记录事件触发时的嵌套滚动模式,用于验证 SELF_FIRST 模式下内层滑到边缘是否触发外层翻页;time 为事件发生时间,精确到秒。

构造函数中时间戳的生成使用了 padStart(2, '0') 方法进行两位补零,确保时间格式的统一。时间戳在构造时就地生成,无需调用方传入,简化了日志记录的调用方式——只需传入业务相关的五个参数,时间自动记录。

日志流采用 unshift 置顶 + pop 去尾的滑动窗口机制,最多保留 40 条记录,既保证了足够的历史回溯深度,又避免了内存无限增长。

6.4 WebpMetaSnapshot 元数据快照模型

@Observed
export class WebpMetaSnapshot {
  canvasWidth: number;         // 画布宽(px),-1=未提供
  canvasHeight: number;        // 画布高(px),-1=未提供
  delayTime: number;           // 帧延迟(钳制后 ms),-1=未提供
  unclampedDelayTime: number;  // 帧延迟(未钳制 ms),-1=未提供
  loopCount: number;           // 循环次数,-1=未提供(0=不限)

  constructor(w: number, h: number, d: number, u: number, l: number) {
    this.canvasWidth = w;
    this.canvasHeight = h;
    this.delayTime = d;
    this.unclampedDelayTime = u;
    this.loopCount = l;
  }
}

WebpMetaSnapshot 模型镜像了 WebP 元数据的五个核心字段,用于存储读取快照和回读校验快照。五个字段分别对应 WebPMetadata 的标准属性:canvasWidthcanvasHeight 为 WebP 画布的像素尺寸;delayTime 为经过限幅后的帧延迟时间(单位 ms);unclampedDelayTime 为未限幅的原始帧延迟时间;loopCount 为动画循环次数,0 表示无限循环。

所有字段均使用 -1 作为"未提供"的占位值,这是因为 WebP 元数据的字段全部是可选的——读取时某些字段可能不存在(undefined),通过 ?? -1 空值合并运算符将其统一为 -1,再由格式化函数渲染为"未提供"。这种设计避免了将 undefined 误判为 0 的语义歧义。

WebpMetaSnapshot 在元数据读写链路中承担着双重角色:metaSnapshot 存储首次读取的快照(用于展示当前元数据状态),verifySnapshot 存储写入后的回读快照(用于与写入值对比验证)。两个快照的对比是"写入-回读校验"链路的核心验证机制——只有当回读值与写入值完全一致时,才能确认元数据写入成功。

6.5 MetaOpLog 操作日志模型

@Observed
export class MetaOpLog {
  op: string;      // 操作名
  detail: string;  // 结果明细(含错误码)
  time: string;    // 记录时间

  constructor(op: string, detail: string) {
    this.op = op;
    this.detail = detail;
    const d = new Date();
    this.time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
  }
}

MetaOpLog 模型记录元数据操作的完整历史,包括生成样图、读取元数据、写入元数据和回读校验四类操作。三个属性构成了操作日志的基本要素:op 为操作名称,用于标识操作类型和颜色映射;detail 为操作结果明细,成功时记录操作参数和结果,失败时记录错误码和错误信息;time 为操作时间,同样在构造函数中自动生成。

操作日志流同样采用 unshift 置顶 + pop 去尾的滑动窗口机制,最多保留 30 条记录。每条日志的 detail 字段包含丰富的上下文信息:成功时包含具体的参数值(如画布尺寸、帧延迟、循环次数等),失败时包含错误码和错误描述,使用户能够根据日志快速定位问题。例如写入失败时的错误码 7700202 表示不支持该操作、7700204 表示参数非法,这些错误码信息直接展示在日志中,为调试和排查提供了有力支持。

6.6 数据模型总览

六个 @Observed 数据模型类各司其职,共同构成了纹藏馆平台的数据骨架。从业务实体(PatternItem)到内容载体(InnerCard),从事件记录(SwipeLog、MetaOpLog)到数据快照(WebpMetaSnapshot),再到任务条目(MineTask),每个模型都有明确的职责边界和应用场景。

任务条目层

数据快照层

事件记录层

内容载体层

业务实体层

驱动素材馆列表

驱动频道页列表

驱动日志时间轴

驱动元数据展示

驱动操作日志流

驱动任务清单

PatternItem
纹样素材核心实体

InnerCard
双层Tabs内容卡片

SwipeLog
滑动翻页事件

MetaOpLog
元数据操作事件

WebpMetaSnapshot
WebP五字段快照

MineTask
创作任务清单

Tab0 素材馆

Tab1 频道

Tab2 日志

Tab4 元数据

Tab5 我的

所有模型类均使用 @Observed 装饰器修饰,这意味着它们的实例属性变更会自动触发 UI 刷新。与普通类的实例赋值触发刷新不同,@Observed 类的内部属性变更即可触发刷新,大大简化了数据编辑的状态管理——修改 patternList[0].uses = '新用途' 就能自动更新双列卡片的用途描述,无需手动替换数组元素。

七、组件主体结构

7.1 状态变量声明与分层

@Entry
@Component
struct Page1283 {
  /************* 基础 UI 状态 *************/
  @State currentTab: number = 0;      // 当前 Tab 索引
  @State breath: boolean = false;     // 呼吸动画开关(驱动 Canvas 重绘与柱状图波动)
  private timer: number = -1;         // 呼吸动画定时器句柄

基础 UI 状态组包含三个变量,控制整个页面的最基础行为。currentTab 为当前 Tab 索引,初始值 0 对应素材馆 Tab,是内容区 if/else 切换链和底部 Tab 栏选中态的核心驱动变量。breath 为呼吸动画布尔值,每秒翻转一次,驱动 Canvas 环形图的半径微动和柱状图的柱高波动,营造"实时刷新"的视觉氛围。timer 为定时器句柄,使用 private 修饰且不加 @State,因为定时器 ID 只是一个数字句柄,其值的变化不需要触发 UI 刷新。

  /************* 弹窗状态(三态统一,绑定 PatternItem 素材实体) *************/
  @State addModal: boolean = false;   // 新建纹样收藏弹窗
  @State editModal: boolean = false;  // 编辑用途弹窗
  @State delModal: boolean = false;   // 删除确认弹窗
  @State editIdx: number = -1;        // 编辑条目索引
  @State delIdx: number = -1;         // 删除条目索引
  @State inputText: string = '';      // 弹窗输入框内容

弹窗状态组包含六个变量,实现了三态弹窗系统的完整状态管理。三个 boolean 变量分别控制新建、编辑、删除三个弹窗的显隐。两个 number 变量记录正在编辑或删除的条目索引,初始值 -1 表示无选中项。inputText 为弹窗输入框的内容缓存,同时服务于新建和编辑两个弹窗——新建时存储纹样名,编辑时存储用途描述。这种"单输入框复用"的设计简化了状态变量数量,但要求打开弹窗时必须正确赋值,关闭时统一清空。

  /************* 素材馆业务状态 *************/
  @State patternList: PatternItem[] = PATTERN_LIST;  // 纹样素材数据
  @State activeDynasty: string = '全部';             // 当前朝代筛选

素材馆业务状态组只有两个变量。patternList 为纹样素材数组,直接用 PATTERN_LIST 常量初始化,是素材馆双列卡片的数据源,同时也是弹窗 CRUD 操作的目标数组。activeDynasty 为当前朝代筛选标签,初始值为"全部",控制朝代筛选 chips 的选中态和 filteredPatterns 方法的筛选逻辑。

  /************* 特性 A 状态(Canvas 绘制) *************/
  private pieCtx: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(new RenderingContextSettings(true));  // 环形图上下文(private 非 @State)

Canvas 绘制状态组仅包含一个 privateCanvasRenderingContext2D 实例。pieCtx 是环形图的绘制上下文,在组件初始化时即创建,传入 RenderingContextSettings(true) 开启抗锯齿。使用 private 而非 @State 是因为 Canvas 上下文对象不参与响应式渲染——Canvas 的内容更新需要手动调用绘制方法,而非依赖状态变量的自动刷新机制。

  /************* 特性 B 状态(Tabs 嵌套滚动) *************/
  @State nestedMode: TabsNestedScrollMode = TabsNestedScrollMode.SELF_FIRST; // nestedScroll 嵌套模式
  @State outerIndex: number = 0;        // 外层朝代频道当前索引
  @State innerIndex: number = 0;        // 内层纹样类别当前索引
  @State swipeLogs: SwipeLog[] = [];    // 两层翻页日志

Tabs 嵌套滚动状态组包含四个变量。nestedMode 为嵌套滚动模式,初始值为 SELF_FIRST(先内后外),是嵌套滚动特性的核心控制变量,可在频道 Tab 中通过模式切换 chips 动态修改。outerIndexinnerIndex 分别记录外层朝代频道和内层纹样类别的当前索引,初始值均为 0,在 Tabs 的 onChange 回调中更新,用于头部状态行展示当前位置。swipeLogs 为滑动日志数组,初始为空,记录每次翻页事件的详细信息,驱动日志 Tab 的时间轴展示。

  /************* 特性 C 状态(WebP 元数据) *************/
  @State textureIdx: number = 0;                       // 工坊纹理五选一当前项
  @State pixelMap?: image.PixelMap = undefined;        // 像素画预览
  @State webpPath: string = '';                        // 沙箱落盘路径
  @State genState: string = '待生成';                   // 生成状态文案
  @State metaSnapshot?: WebpMetaSnapshot = undefined;  // 读取快照
  @State writeDelay: number = 120;                     // 待写帧延迟
  @State writeLoop: number = 3;                        // 待写循环次数
  @State verifySnapshot?: WebpMetaSnapshot = undefined; // 回读校验快照
  @State opLogs: MetaOpLog[] = [];                     // 元数据操作日志

WebP 元数据状态组是所有状态组中变量最多的,包含九个变量,完整覆盖了 WebP 样图生成和元数据读写的全链路状态。textureIdx 为当前选中的纹理索引,pixelMap 为生成的像素画预览对象(可选类型,初始 undefined),webpPath 为 WebP 文件的沙箱落盘路径,genState 为生成状态文案(驱动状态指示色)。metaSnapshotverifySnapshot 分别为读取快照和回读校验快照(均为可选类型),writeDelaywriteLoop 为写入控制台选择的帧延迟和循环次数。opLogs 为操作日志数组,记录生成、读取、写入、回读四类操作的历史。

这九个变量构成了"生成 → 读取 → 写入 → 回读"四步链路的完整状态画像,每个变量的变更都会驱动对应 UI 的刷新,使用户能清晰地看到每一步操作的结果和状态流转。

7.2 生命周期方法

  aboutToAppear() {
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      this.drawPie();  // Canvas 不随 @State 自动重绘,须手动调用绘制方法
    }, 1000);
  }

aboutToAppear 在组件创建后、build 执行前调用,是组件初始化的入口方法。此处仅完成一项任务:启动呼吸动画定时器。定时器以 1000ms(1 秒)为间隔执行回调,每次回调做两件事:翻转 breath 布尔值、调用 drawPie() 方法重绘 Canvas 环形图。

特别需要注意的是 Canvas 的手动重绘机制。在 ArkUI 中,@State 变量的变更会自动触发声明式 UI 的 diff 和重渲染,但 Canvas 组件是一个例外——Canvas 的绘制内容由上下文的绘制指令决定,框架无法自动感知绘制内容的变化,因此必须在状态变更后显式调用绘制方法。这就是为什么 drawPie() 需要在定时器回调中手动调用,而不能依赖 breath 变更自动触发。

  aboutToDisappear() {
    if (this.timer !== -1) {
      clearInterval(this.timer);
      this.timer = -1;
    }
  }

aboutToDisappear 在组件销毁前调用,负责资源清理。此处清除呼吸动画定时器,防止内存泄漏。增加了 timer !== -1 的判断,确保定时器存在时才清除,清除后将 timer 重置为 -1,与初始值保持一致。这种"判断-清除-复位"的三步清理模式是 ArkUI 定时器管理的最佳实践,避免了重复清除和野句柄问题。

7.3 根构建方法

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Column() {
        // 头部朱砂渐变 Banner(当前 Tab 联动副标题 + 双特性状态胶囊)
        this.headerBanner()
        Divider().strokeWidth(1).color(COLORS.line)
        // 内容区(6 Tab 各自独立布局,互不相同)
        Column() {
          if (this.currentTab === 0) {
            this.tabPatterns()
          } else if (this.currentTab === 1) {
            this.tabNested()
          } else if (this.currentTab === 2) {
            this.tabLogs()
          } else if (this.currentTab === 3) {
            this.tabStudio()
          } else if (this.currentTab === 4) {
            this.tabMeta()
          } else {
            this.tabMine()
          }
        }.layoutWeight(1).width('100%')
        // 底部 6 Tab 导航
        this.tabBar()
      }.width('100%').height('100%')
      // 全屏弹窗遮罩层(最后渲染,覆盖全页,点遮罩关闭)
      if (this.addModal || this.editModal || this.delModal) {
        this.modalOverlay(() => { this.closeAllModals(); })
      }
    }.width('100%').height('100%').backgroundColor(COLORS.bg)
  }

build 方法是组件的根构建方法,采用 Stack 层叠布局实现"主界面 + 弹窗"两层结构。

底层主界面使用 Column 纵向排列三部分内容:头部 Banner、分割线、内容区和底部 Tab 栏。头部 Banner 由 headerBanner() 构建器渲染,展示平台标题、Tab 联动副标题和特性状态胶囊。Divider 分割线线宽 1px、米灰色,在头部和内容区之间形成清晰的视觉分隔。内容区使用 Column 容器并设置 layoutWeight(1) 占满中间剩余高度,内部通过 if/else 链判断 currentTab 索引,切换到对应 Tab 的 @Builder 方法——六个 Tab 各自拥有完全独立的构建方法,布局结构互不相同。底部 Tab 栏由 tabBar() 构建器渲染,固定在页面底部。

顶层弹窗层通过 if 判断三个弹窗状态变量的逻辑或结果——只要有一个弹窗为 true,就渲染 modalOverlay 遮罩层。遮罩层内部根据激活的弹窗类型显示对应的面板(新建/编辑/删除),点击遮罩空白区域调用 closeAllModals 关闭全部弹窗。这种"统一遮罩 + 三面板切换"的设计比三个独立的 Stack 层叠更简洁,也保证了弹窗层的唯一性和互斥性。

整个 Stack 容器的背景色设为宣纸米白 COLORS.bg,宽度和高度均为 100%,铺满整个页面。

八、头部区域详解

  @Builder
  headerBanner() {
    Column({ space: 10 }) {
      Row() {
        Column({ space: 4 }) {
          Text('纹藏馆 · 非遗纹样素材平台').fontSize(20).fontWeight(FontWeight.Bold)
            .fontColor(COLORS.onMain)
          Text(this.currentTab === 0 ? `素材馆 · 馆藏 ${this.patternList.length}`
            : this.currentTab === 1 ? '频道 · 朝代×纹样双层 Tabs'
              : this.currentTab === 2 ? '滑动日志 · nestedScroll 时间线'
                : this.currentTab === 3 ? 'WebP 纹理样图工坊'
                  : this.currentTab === 4 ? '元数据读写 · 五字段'
                    : '守艺人中心').fontSize(11).fontColor(COLORS.onMain).opacity(0.85)
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)

        // 呼吸圆点(breath 翻转驱动透明度)
        Circle({ width: 10, height: 10 })
          .fill(COLORS.onMain)
          .opacity(this.breath ? 0.9 : 0.45)
      }.width('100%')

头部 Banner 的第一行为标题行,由左侧标题列和右侧呼吸圆点组成。标题列包含 20 号粗体主标题"纹藏馆 · 非遗纹样素材平台"和 11 号副标题,主副标题均为白色文字(onMain),副标题透明度 0.85 形成层次。副标题通过六重三元运算符与 currentTab 联动,每个 Tab 对应一句简短的功能描述:素材馆 Tab 显示馆藏数量(动态读取 patternList.length),频道 Tab 显示"朝代×纹样双层 Tabs",日志 Tab 显示"滑动日志 · nestedScroll 时间线",工坊 Tab 显示"WebP 纹理样图工坊",元数据 Tab 显示"元数据读写 · 五字段",我的 Tab 显示"守艺人中心"。这种联动设计使用户切换 Tab 时立即获得上下文确认。

右侧为一个 10px 的白色圆点,填充色为 onMain 纯白,透明度随 breath 在 0.9 和 0.45 之间切换,实现"心跳呼吸"效果。圆点虽然小巧,但每秒明暗交替的节奏暗示着平台的"生命力",与 Canvas 环形图的呼吸微动和柱状图的呼吸波动形成联动,营造出整个页面"活"起来的视觉氛围。

      Row({ space: 8 }) {
        // 馆藏胶囊
        Row({ space: 6 }) {
          Text('🏛️').fontSize(10)
          Text(`馆藏 ${PIE_TOTAL}`).fontSize(10).fontColor(COLORS.sub)
        }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(12).backgroundColor(COLORS.chip)

        // 特性 B 状态胶囊(nestedScroll 嵌套模式)
        Row({ space: 4 }) {
          Circle({ width: 6, height: 6 })
            .fill(this.nestedMode === TabsNestedScrollMode.SELF_FIRST ? COLORS.green : COLORS.gold)
          Text(`嵌套 ${modeShort(this.nestedMode)}`).fontSize(10).fontColor(COLORS.sub)
        }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(12).backgroundColor(COLORS.chip)

        // 特性 C 状态胶囊(WebP 生成状态)
        Row({ space: 4 }) {
          Circle({ width: 6, height: 6 }).fill(genStateColor(this.genState))
          Text(`WebP ${this.genState}`).fontSize(10).fontColor(COLORS.sub)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
        .borderRadius(12).backgroundColor(COLORS.chip).layoutWeight(1)
      }.width('100%')

第二行为三枚特性状态胶囊,采用 Row({ space: 8 }) 横向排布,每枚胶囊由图标/圆点 + 文字标签组成,背景色为米杏色 COLORS.chip,圆角 12。

第一枚为馆藏胶囊,展示🏛️图标和"馆藏 1280 件"文字,是平台的核心数据指标,使用固定值 PIE_TOTAL 展示。第二枚为嵌套滚动模式胶囊,6px 圆点的颜色随 nestedMode 在苔绿(SELF_FIRST)和鎏金(SELF_ONLY)间切换,文字由 modeShort 函数生成短文案。第三枚为 WebP 生成状态胶囊,圆点颜色由 genStateColor 函数根据生成状态返回,文字显示当前生成状态(待生成/生成中/已生成/失败),使用 layoutWeight(1) 占满剩余宽度使布局右对齐,同时设置 maxLines(1) 和省略模式防止状态文字过长时撑破布局。

    }.padding({ left: 16, right: 16, top: 12, bottom: 12 })
    .width('100%')
    .linearGradient({
      angle: 160,
      colors: [[COLORS.gradA, 0], [COLORS.bg, 1]]
    })
  }

头部 Banner 整体设置 16px 左右内边距和 12px 上下内边距,宽度铺满。最关键的视觉效果是 linearGradient 线性渐变:160 度角从朱砂红(gradA)过渡到宣纸米白(bg),模拟朱砂印章盖在宣纸上的晕染效果。160 度的角度选择使渐变从左上向右下倾斜,朱砂色集中在左上角,向右下方逐渐过渡为宣纸底色,既有传统印章的文化意象,又保证了右侧白色文字的可读性。

九、素材馆 Tab 深度分析

9.1 布局结构总览

  @Builder
  tabPatterns() {
    Column({ space: 10 }) {
      Scroll() {
        Column({ space: 10 }) {

          // —— 第二段:朝代筛选横滚 chips + 新建收藏入口 ——
          Row() {
            Text(`纹样卡片 · ${this.filteredPatterns().length}`)
              .fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Blank()
            Text('+ 收藏').fontSize(11).fontColor(COLORS.red)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 })
              .borderRadius(10).backgroundColor(COLORS.chip)
              .onClick(() => { this.openAdd(); })
          }.width('100%')

          // 朝代分类横滚 chips(唐/宋/元/明/清)
          Scroll() {
            Row({ space: 8 }) {
              ForEach(DYNASTY_CHIPS, (tag: string) => {
                Text(tag === '全部' ? '全部' : `${tag}`)
                  .fontSize(11)
                  .fontColor(this.activeDynasty === tag ? COLORS.onMain : COLORS.sub)
                  .padding({ left: 14, right: 14, top: 6, bottom: 6 })
                  .borderRadius(14)
                  .backgroundColor(this.activeDynasty === tag ? COLORS.red : COLORS.chip)
                  .onClick(() => { this.switchDynasty(tag); })
              }, (tag: string) => `dyn_${tag}`)
            }.padding({ left: 4, right: 4 })
          }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')

          // —— 第三段:纹样双列卡片(两条一组 Row) ——
          Column({ space: 10 }) {
            ForEach(this.patternPairs(), (pair: PatternItem[], pi: number) => {
              Row({ space: 10 }) {
                ForEach(pair, (item: PatternItem) => {
                  this.patternCard(item)
                }, (item: PatternItem) => `card_${item.name}_${item.score}`)
              }.width('100%').alignItems(VerticalAlign.Top)
            }, (pair: PatternItem[], pi: number) => `pair_${pi}_${pair[0].name}`)
          }.width('100%')

          // —— 第四段:Canvas 环形图(五品类占比) ——
          this.canvasPieCard()

          // —— 第五段:月度成交量柱状图(Column+ForEach 传统柱状,breath 波动) ——
          this.chartCard()

          // 底部说明卡
          Column({ space: 5 }) {
            Text('素材馆说明').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text('环形图为 Canvas 自绘(drawPie 中心镂空 + 中心文字),柱状图为 Column+ForEach 传统实现')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Text('两图数据同源:五品类占比与月度成交量均来自纹藏馆运营后台')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
          }.padding(10).borderRadius(12).backgroundColor(COLORS.chip).width('100%')
        }.width('100%')
      }.scrollBar(BarState.Off).width('100%').layoutWeight(1)
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

素材馆 Tab 是用户进入平台后的首屏,布局结构最为丰富,由五大模块纵向排列构成。整个 Tab 采用 Scroll 滚动容器包裹内容,使用户可以在有限的屏幕空间内浏览全部信息。滚动条关闭(scrollBar(BarState.Off)),使界面更加简洁。外层 Column 设置 12px 左右内边距和 4px/8px 上下内边距,与宣纸米白背景形成均匀留白。

标题操作行是第一模块,左侧显示"纹样卡片 · N 套"标题,数量动态读取 filteredPatterns().length 即筛选后的纹样数量。右侧为"+ 收藏"按钮,朱砂红文字、米杏色胶囊背景,点击调用 openAdd() 打开新建纹样收藏弹窗。

朝代筛选横滚 chips 是第二模块,使用横向 Scroll 包裹 Row,实现可横滑的朝代筛选标签。6 个 chips 从"全部"到"清"依次排列,选中态为朱砂红底白字、未选中为米杏底驼褐色字。点击调用 switchDynasty(tag) 方法切换筛选条件。ForEach 的键值函数使用 dyn_${tag} 前缀确保唯一性。

纹样双列卡片是第三模块,也是素材馆的核心内容区。与使用 Flex({ wrap: FlexWrap.Wrap }) 实现双列布局的方式不同,此处采用"两条一组 Row"的预分组策略——先通过 patternPairs() 方法将筛选结果按两条一组切分为二维数组,再用外层 ForEach 遍历每组、内层 ForEach 遍历每组中的两个卡片。这种策略的优势在于对每组的对齐方式(alignItems(VerticalAlign.Top) 顶部对齐)有更精细的控制,避免了 Flex 布局中卡片高度不一致导致的底部留白问题。

Canvas 环形图卡是第四模块,由 canvasPieCard() 构建器渲染,展示五品类馆藏占比的可视化图表。月度柱状图卡是第五模块,由 chartCard() 构建器渲染,展示近六个月的纹样素材成交量。底部说明卡为第六模块,用浅米杏色背景的卡片简要说明图表的技术实现和数据来源。

9.2 纹样双列卡片解析

  @Builder
  patternCard(item: PatternItem) {
    Column({ space: 8 }) {
      // 纹样名 + 朝代徽标行
      Row({ space: 6 }) {
        Text(item.name).fontSize(13).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title).maxLines(1).layoutWeight(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(item.dynasty).fontSize(9).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.onMain)
          .padding({ left: 7, right: 7, top: 3, bottom: 3 })
          .borderRadius(8).backgroundColor(COLORS.blue)
      }.width('100%')

      // 品类色徽 + 适配度行
      Row({ space: 6 }) {
        Text(item.cat).fontSize(10).fontColor(COLORS.onMain)
          .padding({ left: 7, right: 7, top: 3, bottom: 3 })
          .borderRadius(8).backgroundColor(catColor(item.cat))
        Text(`${scoreBadge(item.score)} ${item.score}`).fontSize(10)
          .fontColor(scoreColor(item.score)).fontFamily('monospace')
      }.width('100%')

      // 用途描述(两行截断)
      Text(item.uses).fontSize(10).fontColor(COLORS.sub).maxLines(2).width('100%')
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      // 行内操作:编辑 / 删除
      Row({ space: 12 }) {
        Blank()
        Text('编辑').fontSize(10).fontColor(COLORS.blue)
          .onClick(() => { this.openEdit(this.indexOfPattern(item)); })
        Text('删除').fontSize(10).fontColor(COLORS.redD)
          .onClick(() => { this.openDel(this.indexOfPattern(item)); })
      }.width('100%')
    }.padding(12).borderRadius(12).backgroundColor(COLORS.card)
    .layoutWeight(1).alignItems(HorizontalAlign.Start)
  }

纹样卡片是素材馆的基本信息单元,每张卡片由四行内容构成,卡片整体为白底圆角 12、内边距 12 的容器,使用 layoutWeight(1) 在双列 Row 中等分宽度。

第一行为纹样名和朝代徽标:纹样名 13 号粗体墨褐,占据主要宽度(layoutWeight(1)),单行省略;朝代徽标为 9 号粗体白字的黛蓝色胶囊,黛蓝是朝代标识的专属色,与品类色徽形成区分。

第二行为品类色徽和适配度评分:品类色徽为 10 号白字胶囊,背景色由 catColor 函数根据品类名返回(回纹朱砂/云纹黛蓝/方胜鎏金/冰裂苔绿/联珠深朱砂);适配度评分由 scoreBadgescoreColor 两个函数配合显示,标签文字加数字评分,使用 fontFamily('monospace') 等宽字体使数字对齐更整齐。

第三行为用途描述,10 号驼褐色字,最多显示两行,超出省略。用途描述通常包含"工艺载体 · 应用场景"两部分信息,是纹样素材最有价值的参考内容。

第四行为行内操作按钮,使用 Blank() 将两个按钮推到右侧:"编辑"按钮为黛蓝色文字,调用 openEdit 并传入 indexOfPattern(item) 计算出的真实索引;"删除"按钮为深朱砂色文字,调用 openDel 同样传入真实索引。

indexOfPattern 方法的存在是因为筛选后的列表与原始列表的索引不一致——如果直接使用筛选列表中的索引,会指向错误的原始数据。通过遍历原始列表按引用相等查找真实索引,确保编辑和删除操作作用于正确的原始数据。

9.3 Canvas 环形图绘制原理

  drawPie() {
    const ctx = this.pieCtx;
    const size = 210;
    const cx = size / 2;
    const cy = size / 2;
    const r = 66 + (this.breath ? 4 : 0);  // 呼吸微动半径
    // 清底重画
    ctx.clearRect(0, 0, size, size);
    // 外圈呼吸描边(透明度用后立即复位)
    ctx.globalAlpha = 0.16;
    ctx.beginPath();
    ctx.arc(cx, cy, r + 10, 0, Math.PI * 2);
    ctx.strokeStyle = COLORS.red;
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.globalAlpha = 1;

drawPie 方法是 Canvas 绘制特性的核心实现,绘制五品类馆藏占比环形图。画布尺寸固定为 210×210,圆心在画布正中,半径基础值 66,随 breath 状态增加 0 或 4 像素实现呼吸微动效果。

绘制流程的第一步是清除画布(clearRect),确保每次重绘都是在干净的画布上进行。第二步绘制外圈呼吸描边:设置 globalAlpha = 0.16 实现 16% 透明度,画一个半径比主环大 10px 的完整圆环,朱砂红色 2px 线宽。绘制完成后立即将 globalAlpha 复位为 1——这是 Canvas 编程的重要原则,全局状态(如透明度、描边色、填充色等)修改后必须及时恢复,避免影响后续的绘制指令。

    // 五扇区(从 12 点方向顺时针)
    let start = -Math.PI / 2;
    for (let i = 0; i < PIE_DATA.length; i++) {
      const angle = (PIE_DATA[i].val / 100) * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.arc(cx, cy, r, start, start + angle);
      ctx.fillStyle = PIE_COLORS[i];
      ctx.fill();
      // 扇区百分比标注
      const mid = start + angle / 2;
      ctx.fillStyle = COLORS.onMain;
      ctx.font = 'bold 10px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(`${PIE_DATA[i].val}%`, cx + Math.cos(mid) * r * 0.72, cy + Math.sin(mid) * r * 0.72 + 3);
      start += angle;
    }

第三步绘制五扇区,这是环形图的主体内容。起始角度设为 -Math.PI / 2(即 12 点钟方向),通过 for 循环依次绘制每个扇区。每个扇区的角度由占比计算得出:val / 100 * 2π。绘制方式为从圆心移动到起点,画弧线到终点,形成扇形区域后填充对应颜色。

每个扇区还标注了百分比文字,位置计算在扇区中线(mid = start + angle / 2)的 0.72 倍半径处,使用白色粗体 10px 字,水平居中对齐。Y 坐标加 3px 是为了修正文字基线偏移,使文字视觉上居中。

    // 中心镂空(环形图)
    ctx.beginPath();
    ctx.arc(cx, cy, r * 0.58, 0, Math.PI * 2);
    ctx.fillStyle = COLORS.card;
    ctx.fill();
    // 中心文字(馆藏总量)
    ctx.fillStyle = COLORS.title;
    ctx.font = 'bold 20px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(`${PIE_TOTAL}`, cx, cy + 1);
    ctx.fillStyle = COLORS.sub;
    ctx.font = '10px sans-serif';
    ctx.fillText('馆藏纹样件', cx, cy + 17);
  }

第四步绘制中心镂空,用白色(卡片底色)画一个半径为 0.58r 的圆覆盖扇区中心部分,使饼图变成环形图。这种"先画实心饼、再挖空中心"的策略比用 arc 配合 stroke 画圆环更灵活——因为扇区的百分比文字需要画在扇区内部,而实心扇形更容易计算文字位置。

第五步绘制中心文字,分两行显示:第一行为馆藏总量 1280,20 号粗体墨褐色;第二行为"馆藏纹样件"说明文字,10 号驼褐色。两行文字均水平居中,Y 坐标分别为 cy + 1cy + 17,形成以中心略偏上的视觉平衡。

9.4 月度柱状图与呼吸波动

  @Builder
  chartCard() {
    Column({ space: 10 }) {
      Row() {
        Text('📊 纹样素材月度成交量').fontSize(13).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.title)
        Blank()
        Text('单位:件').fontSize(9).fontColor(COLORS.text3)
      }.width('100%')

      Row({ space: 6 }) {
        ForEach(MONTH_IDX, (i: number) => {
          Column({ space: 4 }) {
            Text(`${DOWNLOAD_VAL[i]}`).fontSize(8).fontColor(COLORS.text3)
              .fontFamily('monospace')
            Column()
              .width('64%')
              .height(this.barHeight(i))
              .borderRadius(4)
              .linearGradient({
                angle: 180,
                colors: [[COLORS.red, 0], [COLORS.redD, 1]]
              })
            Text(MONTH_NAME[i]).fontSize(9).fontColor(COLORS.sub)
          }.layoutWeight(1).alignItems(HorizontalAlign.Center)
        }, (i: number) => `bar_${i}_${this.breath}`)
      }.width('100%').alignItems(VerticalAlign.Bottom).height(132)

      Text('柱高随 breath 呼吸在 ±6% 区间交替波动,满刻度按 640 件换算')
        .fontSize(9).fontColor(COLORS.text3).width('100%')
    }.padding(14).borderRadius(12).backgroundColor(COLORS.card).width('100%')
  }

月度柱状图卡采用纯 ArkUI 组件实现(非 Canvas),展示近六个月的纹样素材成交量。卡片标题行显示📊图标和标题文字,右侧标注单位。柱状图主体使用 Row 容器,高度 132px,底部对齐(alignItems(VerticalAlign.Bottom)),使柱子从底部向上生长。

每个月份的柱子由三部分纵向排列构成:顶部为数值标签(8 号等宽浅驼色字),中部为柱体(64% 宽度,高度由 barHeight 计算,180 度线性渐变从朱砂到深朱砂,顶部圆角 4px),底部为月份标签(9 号驼褐色字)。ForEach 的键值函数包含 this.breath,使呼吸状态变化时柱子被视为新元素重新渲染,触发高度动画。

  barHeight(i: number): number {
    const base = DOWNLOAD_VAL[i] / BAR_MAX * 96;
    const wave = (i % 2 === 0) === this.breath ? 1.06 : 0.94;
    return Math.max(8, Math.round(base * wave));
  }

barHeight 方法计算每根柱子的高度。基础高度为数值除以满刻度 640 再乘以 96px(即满刻度时柱高 96px)。呼吸波动通过 wave 系数实现:奇偶索引的柱子与 breath 布尔值进行异或运算,当 breath 为 true 时偶数列柱高乘以 1.06(增长 6%)、奇数列乘以 0.94(缩减 6%);当 breath 为 false 时则相反。这种"交替波动"的设计使柱状图呈现出左右摇摆的呼吸效果,比整体同步伸缩更具动感。Math.max(8, ...) 确保最小柱高不低于 8px,避免数据过小时柱子消失。

9.5 筛选与索引计算方法

  switchDynasty(tag: string) {
    this.activeDynasty = tag;
  }

  filteredPatterns(): PatternItem[] {
    if (this.activeDynasty === '全部') { return this.patternList; }
    const result: PatternItem[] = [];
    for (const item of this.patternList) {
      if (item.dynasty === this.activeDynasty) {
        result.push(item);
      }
    }
    return result;
  }

switchDynasty 方法切换朝代筛选,仅需一行赋值操作——将 activeDynasty 设为传入的标签值,@State 机制自动触发 UI 刷新,筛选 chips 的选中态和双列卡片的内容随之更新。

filteredPatterns 方法计算筛选后的纹样列表。当选中"全部"时直接返回原数组(零开销),否则使用 for 循环遍历原数组,将朝代匹配的条目推入结果数组。使用 for 循环而非 Array.filter 是因为 ArkTS 对标准数组方法的支持有一定限制,同时显式循环的性能更可控。此方法在 @Builder 外预计算,避免了在 ForEach 内调用 filter 导致的性能问题。

  patternPairs(): PatternItem[][] {
    const src = this.filteredPatterns();
    const pairs: PatternItem[][] = [];
    for (let i = 0; i < src.length; i += 2) {
      const pair: PatternItem[] = [];
      pair.push(src[i]);
      if (i + 1 < src.length) {
        pair.push(src[i + 1]);
      }
      pairs.push(pair);
    }
    return pairs;
  }

patternPairs 方法将筛选结果按两条一组切分为二维数组,服务于双列卡片布局。使用步长为 2 的 for 循环,每次取两个元素组成一个子数组。最后一组如果只有一个元素(总数为奇数时),子数组长度为 1,对应的 Row 中只有一个卡片,右侧留白。这种处理方式保证了布局的健壮性。

  indexOfPattern(item: PatternItem): number {
    for (let i = 0; i < this.patternList.length; i++) {
      if (this.patternList[i] === item) { return i; }
    }
    return -1;
  }

indexOfPattern 方法查找纹样条目在原始列表中的真实索引。由于筛选后的列表索引与原始列表不一致,编辑和删除操作必须基于原始索引。方法通过 for 循环遍历原始数组,使用引用相等(===)比较查找目标对象的位置。找到则返回索引,未找到返回 -1(防御性设计)。

十、频道 Tab 深度分析

10.1 双层 Tabs 嵌套结构

  @Builder
  tabNested() {
    Column({ space: 10 }) {
      // 模式说明 + 切换 chips(SELF_ONLY / SELF_FIRST)
      Row({ space: 8 }) {
        Text(`嵌套模式:${modeLabel(this.nestedMode)}`)
          .fontSize(11).fontColor(COLORS.sub).layoutWeight(1)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        ForEach([TabsNestedScrollMode.SELF_ONLY, TabsNestedScrollMode.SELF_FIRST],
          (m: TabsNestedScrollMode) => {
            Text(modeShort(m)).fontSize(10)
              .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
              .fontColor(this.nestedMode === m ? COLORS.onMain : COLORS.text3)
              .backgroundColor(this.nestedMode === m ? COLORS.red : COLORS.card)
              .onClick(() => { this.nestedMode = m; })
          }, (m: TabsNestedScrollMode) => `mode_${m}`)
      }.width('100%')

频道 Tab 是嵌套滚动特性的演示页,布局由模式控制区、位置说明区和双层 Tabs 主体三部分构成。第一行为嵌套模式控制区:左侧显示当前模式的完整文案(由 modeLabel 函数生成),右侧为两个模式切换 chips——SELF_ONLY 和 SELF_FIRST。选中态为朱砂红底白字,未选中为白底浅驼色字。点击 chip 直接修改 nestedMode 状态,内层 Tabs 的 nestedScroll 属性会随之响应变化,即时切换嵌套滚动模式。

      // 当前双层位置说明行(外层鎏金 / 内层苔绿双徽标)
      Row({ space: 6 }) {
        Circle({ width: 6, height: 6 }).fill(COLORS.gold)
        Text(`外层 ${OUTER_CHANNELS[this.outerIndex].name}代频道`)
          .fontSize(10).fontColor(COLORS.sub)
        Blank()
        Circle({ width: 6, height: 6 }).fill(COLORS.green)
        Text(`内层 ${INNER_TABS[this.innerIndex]}(第 ${this.innerIndex + 1}/5 页)`)
          .fontSize(10).fontColor(COLORS.sub)
      }.width('100%')

第二行为当前位置说明行,左右对称地展示外层和内层的当前位置。外层用鎏金色圆点标识朝代频道,内层用苔绿色圆点标识纹样类别,圆点颜色与日志时间轴的层级配色一致,形成"鎏金=外层、苔绿=内层"的统一认知。右侧显示当前内层页签的序号(第 N/5 页),方便用户定位。

      // 外层宿主 Tabs(5 朝代频道,BarMode.Scrollable 横滑页签)
      Tabs({ barPosition: BarPosition.Start }) {
        ForEach(OUTER_CHANNELS, (ch: ChannelItem) => {
          TabContent() {
            this.innerTabs(ch)
          }.tabBar(`${ch.icon} ${ch.name}`)
        }, (ch: ChannelItem) => ch.name)
      }
      .barMode(BarMode.Scrollable)
      .onChange((index: number) => {
        // SELF_FIRST 下内层滑到边缘继续滑 → 此处被触发,即"接力"证据
        this.swipeLogs.unshift(new SwipeLog('外层朝代', OUTER_CHANNELS[index].name,
          this.outerIndex, index, modeLabel(this.nestedMode)));
        this.outerIndex = index;
        if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
      })
      .layoutWeight(1).width('100%')

第三行为外层 Tabs 主体,是嵌套滚动的宿主层。外层 Tabs 包含五个朝代频道的 TabContent,每个内容区域由 innerTabs(ch) 构建器渲染(即内层 Tabs)。页签栏模式设为 BarMode.Scrollable,即横滑页签,五个朝代图标加名称可以横向滑动。

onChange 回调在外层翻页时触发,记录一条 SwipeLog 到日志流中——层级为"外层朝代",记录翻页前后的索引和当前嵌套模式。这条日志是验证嵌套滚动效果的关键:在 SELF_FIRST 模式下,当内层列表滑到边缘后继续滑动,应该触发外层 Tabs 的翻页(即"接力"效果),此时外层的 onChange 被调用,日志流中会出现一条外层翻页记录。用户通过对比日志和操作行为,即可直观验证嵌套滚动的工作机制。

      // 底部特性说明
      Text('内层滑到边缘后是否联动外层,由 nestedScroll 模式决定(★ 6.1.1 新特性)')
        .fontSize(9).fontColor(COLORS.text3).width('100%')
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

底部一行小字说明嵌套滚动特性的来源(HarmonyOS 6.1.1 新特性),作为功能的版权注脚。

10.2 内层 Tabs 与 nestedScroll 挂载点

  @Builder
  innerTabs(channel: ChannelItem) {
    Tabs({ barPosition: BarPosition.Start }) {
      ForEach(INNER_TABS, (name: string) => {
        TabContent() {
          List({ space: 10 }) {
            ForEach(innerMockData(channel, name), (item: InnerCard) => {
              ListItem() {
                Column({ space: 6 }) {
                  Row() {
                    Text(`${channel.icon} ${name}`).fontSize(13)
                      .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                    Blank()
                    Text(item.tag).fontSize(10).fontColor(COLORS.sub)
                  }.width('100%')
                  Text(item.title).fontSize(12).fontColor(COLORS.sub).maxLines(1)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(item.desc).fontSize(11).fontColor(COLORS.text3).maxLines(2)
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Row({ space: 8 }) {
                    Text(`${channel.name}代频道`).fontSize(9).fontColor(COLORS.gold)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
                    Text('可商用授权').fontSize(9).fontColor(COLORS.green)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
                  }.width('100%')
                }.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
              }
            }, (item: InnerCard) => item.id)
          }.width('100%').height('100%').scrollBar(BarState.Off)
        }.tabBar(name)
      }, (name: string) => name)
    }
    .barMode(BarMode.Scrollable)
    .onChange((index: number) => {
      // 内层翻页记日志:layer='内层类别',记录事发时嵌套模式
      this.swipeLogs.unshift(new SwipeLog('内层类别', INNER_TABS[index],
        this.innerIndex, index, modeLabel(this.nestedMode)));
      this.innerIndex = index;
      if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
    })
    .nestedScroll(this.nestedMode)   // ★ nestedScroll:内层滑到边缘后是否联动外层(挂载点=被嵌套的内层)
    .layoutWeight(1).width('100%')
  }

innerTabs 构建器是嵌套滚动特性的核心——内层 Tabs,也是 nestedScroll 的挂载点。这里的设计原则是:nestedScroll 挂载在被嵌套的内层组件上,而非外层宿主。这是理解嵌套滚动特性的关键:内层组件决定自己滑到边缘后是否"接力"给外层。

内层 Tabs 包含五个纹样类别子页签,每个 TabContent 内是一个 List 列表,列表项为工艺卡片。每个卡片包含五行信息:标题行(朝代图标 + 纹样名 + 品类标签)、副标题行(工艺载体·第 N 期)、描述行(作品名 + 工艺注解,两行省略)、标签行(朝代频道鎏金徽标 + 可商用授权苔绿徽标)。卡片为白底圆角 10、内边距 12。

内层 onChange 回调同样记录翻页日志,层级为"内层类别",记录翻页前后索引和事发时的嵌套模式。由于 innerIndex 是组件级状态,切换外层朝代频道时内层索引不会重置——这在一定程度上保持了用户的浏览位置,但也意味着不同外层频道下的内层位置可能不同。

最关键的一行是 .nestedScroll(this.nestedMode),这行代码将当前嵌套模式绑定到内层 Tabs 上。nestedScroll 是 HarmonyOS API 24 新增的 Tabs 属性,接收 TabsNestedScrollMode 枚举值:

  • SELF_ONLY:仅内层滚动,滑到边缘后不联动外层。适合需要精确控制内层内容、不希望意外触发外层翻页的场景。
  • SELF_FIRST:先内后外,内层滑到边缘后继续滑动会触发外层翻页。这是"接力"模式,提供流畅的跨层级浏览体验。

10.3 嵌套滚动交互流程

SELF_ONLY 模式

SELF_FIRST 模式

用户滑动手势

用户在内层列表
向左滑动

内层 Tabs 响应滑动
切换到下一品类

内层已到
最后一页?

内层继续翻页

手势接力给外层
外层 Tabs 响应

外层切换到
下一朝代频道

内层 Tabs 响应滑动
切换到下一品类

内层已到
最后一页?

内层继续翻页

滑动停止
不触发外层

嵌套滚动的交互流程如上图所示。在 SELF_FIRST 模式下,用户的滑动手势先被内层 Tabs 消费,当内层到达边界后,剩余的手势动量"接力"给外层 Tabs,触发外层翻页。这种"接力"效果使用户在浏览内容时无需抬手即可完成层级切换,体验流畅自然。而在 SELF_ONLY 模式下,内层的滑动止于自身边界,不会触发外层翻页,提供了更精确的控制感。

两种模式各有适用场景:内容浏览型应用适合 SELF_FIRST 模式,提供沉浸式的连续浏览体验;精确操作型应用适合 SELF_ONLY 模式,避免误触外层翻页。纹藏馆平台将模式切换放在频道页的顶部,用户可以根据自己的使用习惯自由选择,这也是 API 24 嵌套滚动特性的灵活之处。

十一、日志 Tab 深度分析

11.1 时间轴布局结构

  @Builder
  tabLogs() {
    Column({ space: 10 }) {
      // 顶部计数 + 清空按钮
      Row() {
        Text(`已记录 ${this.swipeLogs.length} 次翻页`).fontSize(13)
          .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Button('清空日志')
          .fontSize(11).height(32).borderRadius(10)
          .fontColor(COLORS.sub).backgroundColor(COLORS.chip)
          .enabled(this.swipeLogs.length > 0)
          .onClick(() => { this.clearLogs(); })
      }.width('100%')

日志 Tab 是嵌套滚动特性的可视化验证页,以时间轴形式展示每一次翻页事件。布局由顶部控制区、图例说明区和时间轴主体三部分构成。第一行为顶部控制区:左侧显示已记录的翻页次数,动态读取 swipeLogs.length;右侧为"清空日志"按钮,米杏底驼褐字,按钮的 enabled 属性绑定日志长度——当日志为空时按钮置灰不可点击,防止无意义操作。点击按钮调用 clearLogs() 方法清空日志数组。

      // 图例说明行(外层朝代鎏金 / 内层类别苔绿 + 当前 nestedScroll 模式)
      Row({ space: 12 }) {
        Row({ space: 5 }) {
          Circle({ width: 6, height: 6 }).fill(COLORS.gold)
          Text('外层朝代翻页').fontSize(9).fontColor(COLORS.sub)
        }
        Row({ space: 5 }) {
          Circle({ width: 6, height: 6 }).fill(COLORS.green)
          Text('内层类别翻页').fontSize(9).fontColor(COLORS.sub)
        }
        Blank()
        Text(`当前 ${modeLabel(this.nestedMode)}`).fontSize(9).fontColor(COLORS.text3)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }.width('100%')

第二行为图例说明行,解释时间轴上不同颜色圆点的含义:鎏金色=外层朝代翻页,苔绿色=内层类别翻页。右侧显示当前嵌套模式的完整文案,使用户在查看日志时能对照模式理解事件来源。

      if (this.swipeLogs.length === 0) {
        // 空态占位卡
        Column({ space: 8 }) {
          Text('📜').fontSize(30)
          Text('暂无翻页记录').fontSize(12).fontColor(COLORS.sub)
          Text('去「频道」页滑动外层朝代或内层纹样类别,每一次翻页都会记入时间轴')
            .fontSize(10).fontColor(COLORS.text3).textAlign(TextAlign.Center)
        }.width('100%').padding({ top: 40, bottom: 40 }).borderRadius(12)
        .backgroundColor(COLORS.card)
      } else {

当日志为空时显示空态占位卡:大图标📜、标题"暂无翻页记录"和引导文字,提示用户去频道页操作。空态设计避免了空白页面带来的困惑,同时引导用户发现功能。

        // 翻页时间轴(unshift 倒序最新置顶;行固定 .height(72),竖线在行高内 layoutWeight 填满)
        List() {
          ForEach(this.swipeLogs, (log: SwipeLog) => {
            ListItem() {
              Row() {
                // 时间列(固定宽 48,时间 + 层级短名)
                Column({ space: 4 }) {
                  Text(log.time).fontSize(10).fontWeight(FontWeight.Bold)
                    .fontColor(layerColor(log.layer)).fontFamily('monospace')
                  Text(log.layer === '外层朝代' ? '朝代' : '类别').fontSize(8)
                    .fontColor(COLORS.text3)
                }.width(48).height('100%').justifyContent(FlexAlign.Center)

                // 竖线轨道列(圆点徽标 + 竖线在固定行高内填满)
                Column() {
                  Circle({ width: 10, height: 10 }).fill(layerColor(log.layer))
                  Column().width(2).layoutWeight(1).backgroundColor(COLORS.line)
                }.width(14).height('100%').alignItems(HorizontalAlign.Center)

日志非空时显示时间轴主体,使用 List 组件滚动展示。每条日志为一个 ListItem,内部由三列构成时间轴布局。

第一列为时间列,固定宽 48px,垂直居中。显示两行内容:时间戳(10 号粗体等宽字,颜色由 layerColor 根据层级返回鎏金或苔绿)和层级短名(8 号浅驼色字,“朝代"或"类别”)。

第二列为竖线轨道列,固定宽 14px,水平居中。顶部是 10px 的实心圆点(颜色同时间文字),底部是 2px 宽的竖线(米灰色),竖线使用 layoutWeight(1) 填满剩余高度。由于每行高度固定为 72px,竖线在每行内填满,行与行首尾相接,形成连续的时间轴效果。

                // 事件卡片(固定行高等高)
                Column({ space: 4 }) {
                  Row({ space: 6 }) {
                    Text(log.tabName).fontSize(12).fontWeight(FontWeight.Bold)
                      .fontColor(COLORS.title)
                    Text(`${log.fromIdx + 1}${log.toIdx + 1}`).fontSize(10)
                      .fontColor(COLORS.sub).fontFamily('monospace')
                  }.width('100%')
                  Text(`事发模式:${log.mode}`).fontSize(9).fontColor(COLORS.text3)
                    .maxLines(1).width('100%')
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(log.layer === '外层朝代' ? '外层朝代频道 TabContent 切换' : '内层纹样类别 TabContent 切换')
                    .fontSize(9).fontColor(COLORS.text3)
                }.layoutWeight(1).height('100%').justifyContent(FlexAlign.Center)
                .padding({ left: 10, right: 10, top: 8, bottom: 8 })
                .borderRadius(10).backgroundColor(COLORS.card)
              }.width('100%').height(72).margin({ bottom: 6 })
            }
          }, (log: SwipeLog) => `${log.time}_${log.tabName}_${log.toIdx}`)
        }.scrollBar(BarState.Off).width('100%').layoutWeight(1)
      }
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

第三列为事件卡片,等分宽度,白底圆角 10,内边距 10/8。卡片内部包含三行信息:标题行(页签名粗体墨褐字 + 翻页方向等宽驼褐字,格式为"第 M → N 页")、事发模式行(完整模式文案,浅驼色字,单行省略)、说明行(外层/内层翻页的文字说明,浅驼色字)。

每行固定高度 72px,底部 6px 间距。固定行高是时间轴布局的关键——只有行高固定,竖线才能准确对齐,形成规整的时间轴视觉效果。ForEach 的键值函数由时间、页签名和目标索引拼接而成,确保日志条目的唯一标识。

日志流采用 unshift 置顶 + pop 去尾的策略,最新的翻页事件始终显示在列表顶部,最多保留 40 条记录。这种"最新置顶"的设计符合事件流的阅读习惯,用户可以第一时间看到刚刚发生的操作。

11.2 清空日志方法

  clearLogs() {
    this.swipeLogs = [];
  }

clearLogs 方法非常简洁,只需将 swipeLogs 数组重置为空数组即可。由于 swipeLogs@State 变量,赋值操作会自动触发 UI 刷新,时间轴列表变为空态,顶部计数显示为 0,清空按钮变为不可用状态。

十二、工坊 Tab 深度分析

12.1 布局结构总览

  @Builder
  tabStudio() {
    Column({ space: 10 }) {
      Scroll() {
        Column({ space: 10 }) {
          // 参数说明卡(画布 / 质量 / 当前纹理)
          Column({ space: 8 }) {
            Text('样图参数').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Row({ space: 8 }) {
              this.paramChip('画布', `${CANVAS_SIZE}×${CANVAS_SIZE}`)
              this.paramChip('质量', `${WEBP_QUALITY}`)
              this.paramChip('纹理', TEXTURES[this.textureIdx].label)
            }.width('100%')
            Text('色板:朱砂 / 黛蓝 / 鎏金 / 苔绿 / 深朱砂 五色非遗色')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
          }.padding(10).borderRadius(12).backgroundColor(COLORS.card).width('100%')

工坊 Tab 是 WebP 样图生成的核心功能页,布局由参数说明卡、纹理选择区、预览区、生成按钮、沙箱状态卡和生成链路说明卡六个模块纵向排列构成。整个 Tab 使用 Scroll 滚动容器包裹,滚动条关闭。

第一模块为参数说明卡,白底圆角 12,内边距 10。卡片顶部为"样图参数"标题,下方三枚参数小胶囊(由 paramChip 构建器渲染)分别展示画布尺寸(96×96)、编码质量(90)和当前纹理(随选择动态变化)。底部一行说明五色非遗色板的构成。

          // 纹理五选一(斜纹=回纹 / 棋盘=方胜 / 横带=云纹 / 竖带=冰裂 / 同心环=联珠)
          Column({ space: 8 }) {
            Text('纹理五选一(像素算法 × 纹样语义)').fontSize(12)
              .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Scroll() {
              Row({ space: 8 }) {
                ForEach(TEXTURES, (t: TextureItem, idx: number) => {
                  Text(`${t.label}·${t.name}`).fontSize(11)
                    .fontColor(this.textureIdx === idx ? COLORS.onMain : COLORS.sub)
                    .padding({ left: 12, right: 12, top: 7, bottom: 7 })
                    .borderRadius(14)
                    .backgroundColor(this.textureIdx === idx ? COLORS.red : COLORS.chip)
                    .onClick(() => { this.textureIdx = idx; })
                }, (t: TextureItem) => `tex_${t.key}`)
              }.padding({ left: 4, right: 4 })
            }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')
            Text(`${TEXTURES[this.textureIdx].label}对应「${TEXTURES[this.textureIdx].name}」:${TEXTURES[this.textureIdx].desc}`)
              .fontSize(9).fontColor(COLORS.text3).width('100%')
          }.padding(10).borderRadius(12).backgroundColor(COLORS.card).width('100%')
            .alignItems(HorizontalAlign.Start)

第二模块为纹理五选一区,是工坊的核心交互区域。卡片标题为"纹理五选一(像素算法 × 纹样语义)“,点明了工坊的设计理念——将像素算法与传统纹样语义一一对应。下方为横向可滑动的纹理选择 chips,共 5 个选项,每个显示"纹理标签·纹样名”(如"斜纹·回纹")。选中态为朱砂红底白字,未选中为米杏底驼褐色字。点击 chip 将 textureIdx 设为对应索引,触发 UI 更新——参数卡中的纹理标签、下方的寓意说明和预览区(已有预览时)都会随之更新。

底部一行文字动态展示当前选中纹理的寓意说明,格式为"XX 对应「XX」:寓意描述",使用户在选择纹理时能了解其文化内涵。

          // 预览区:像素画 PixelMap(判空 + 非空断言)+ 右侧纹理说明
          Row({ space: 12 }) {
            if (this.pixelMap !== undefined) {
              Image(this.pixelMap!)
                .width(160).height(160).borderRadius(12)
                .objectFit(ImageFit.Fill)
            } else {
              Column({ space: 6 }) {
                Text('🎨').fontSize(30)
                Text('尚未生成样图').fontSize(10).fontColor(COLORS.text3)
              }.width(160).height(160).borderRadius(12).backgroundColor(COLORS.chip)
              .justifyContent(FlexAlign.Center)
            }

            Column({ space: 6 }) {
              Text(TEXTURES[this.textureIdx].name).fontSize(14)
                .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
              Text(TEXTURES[this.textureIdx].desc).fontSize(10).fontColor(COLORS.sub)
              Text(this.genState).fontSize(11).fontColor(genStateColor(this.genState))
            }.layoutWeight(1).alignItems(HorizontalAlign.Start)
          }.width('100%').alignItems(VerticalAlign.Center)

第三模块为预览区,由左侧预览图和右侧说明文字组成。左侧预览区使用判空渲染模式:如果 pixelMap 已生成(不为 undefined),则显示 Image 组件加载像素图,尺寸 160×160,圆角 12,填充模式为 ImageFit.Fill(完全填充);如果尚未生成,则显示占位卡——大图标🎨、"尚未生成样图"文字,米杏色背景。

右侧说明列等分宽度,显示三行信息:纹样名(14 号粗体墨褐字)、寓意说明(10 号驼褐色字)、生成状态(11 号字,颜色由 genStateColor 函数根据状态返回)。三行信息左对齐,使用户能快速了解当前纹理的详细信息和生成状态。

          // 生成按钮
          Button('生成 WebP 样图')
            .fontSize(12).height(38).borderRadius(10)
            .fontColor(COLORS.onMain).backgroundColor(COLORS.red)
            .width('100%')
            .onClick(() => { this.genWebpFile(); })

          // 沙箱落盘状态卡(生成后显示)
          if (this.webpPath !== '') {
            Column({ space: 6 }) {
              Row() {
                Text('沙箱落盘路径').fontSize(11).fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.title)
                Blank()
                Text('filesDir').fontSize(9).fontColor(COLORS.text3)
              }.width('100%')
              Text(this.webpPath).fontSize(9).fontFamily('monospace')
                .fontColor(COLORS.green).width('100%').maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
            }.padding(10).borderRadius(12).backgroundColor(COLORS.chip).width('100%')
          }

第四模块为生成按钮,全宽 38px 高,朱砂红底白字,圆角 10。点击调用 genWebpFile() 方法生成 WebP 样图。

第五模块为沙箱落盘状态卡,仅当 webpPath 非空时显示(即生成成功后)。卡片为米杏色背景,顶部一行显示"沙箱落盘路径"标题和"filesDir"标签,底部显示完整的沙箱路径,等宽苔绿色字体,最多显示两行。这张卡片不仅展示了文件路径,也验证了 WebP 文件已成功写入应用沙箱——而沙箱文件正是元数据 Tab 中 readImageMetadataByType 的读写对象。

          // 生成链路说明卡
          Column({ space: 5 }) {
            Text('生成链路').fontSize(11).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            Text('1. pixelColor 按纹理算法逐像素织纹 → createPixelMap(RGBA_8888)')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Text('2. createImagePacker().packToData(format:image/webp)')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Text('3. fileIo.openSync(READ_WRITE|CREATE|TRUNC) 落盘沙箱')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Text('4. 工坊产物即「元数据」页 readImageMetadataByType 的读写对象')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
          }.padding(10).borderRadius(12).backgroundColor(COLORS.chip).width('100%')
        }.width('100%')
      }.scrollBar(BarState.Off).width('100%').layoutWeight(1)
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

第六模块为生成链路说明卡,用浅米杏色背景的卡片分四步说明 WebP 样图的生成链路:像素织纹 → PixelMap 创建 → WebP 编码 → 沙箱落盘。第四步特别点明工坊与元数据页的关联——工坊生成的 WebP 文件就是元数据页的读写对象,形成了"生产 → 管理"的完整闭环。

12.2 WebP 样图生成核心方法

  async genWebpFile() {
    this.genState = '生成中…';
    const hostCtx = this.getUIContext().getHostContext();
    const dir = hostCtx ? hostCtx.filesDir : '';
    if (dir === '') {
      this.genState = '生成失败(无沙箱)';
      this.opLogs.unshift(new MetaOpLog('生成样图', '失败:未取到宿主 Context 的 filesDir'));
      return;
    }

genWebpFile 方法是工坊的核心功能,将纹理像素画编码为 WebP 格式并写入沙箱。方法为 async 异步函数,因为涉及多个异步 API 调用(createPixelMappackToData 等)。

方法开始时将 genState 设为"生成中…",驱动头部状态胶囊和预览区的状态文字变为鎏金色(进行中状态)。然后获取宿主上下文的 filesDir 路径——通过 getUIContext().getHostContext() 获取 UIAbility 上下文,再读取其 filesDir 属性。如果获取失败(宿主上下文不存在),将状态设为"生成失败(无沙箱)"并记录一条失败日志,然后提前返回。

    try {
      // 1. 按所选纹理算法逐像素织纹(五色非遗色板)
      const texture = TEXTURES[this.textureIdx];
      const total = CANVAS_SIZE * CANVAS_SIZE;
      const buf = new ArrayBuffer(total * 4);
      const pixels = new Uint32Array(buf);
      for (let i = 0; i < total; i++) {
        const row = Math.floor(i / CANVAS_SIZE);
        const col = i % CANVAS_SIZE;
        pixels[i] = pixelColor(row, col, texture.key, WEBP_PALETTE);
      }

生成流程分为四步。第一步是按纹理算法逐像素织纹。首先计算总像素数 total = 96 × 96 = 9216,分配一个 total * 4 字节的 ArrayBuffer(每个像素 4 字节 RGBA),再创建 Uint32Array 视图用于按 32 位整数写入像素。

然后通过 for 循环遍历每个像素,计算当前像素的行号 row 和列号 col,调用 pixelColor 函数计算颜色值(RGBA8888 格式的 32 位整数),写入 pixels 数组。9216 次循环在现代设备上几乎瞬间完成,但由于后续有异步操作,整个方法仍需声明为 async。

      // 2. ArrayBuffer → PixelMap
      const opts: image.InitializationOptions = {
        size: { width: CANVAS_SIZE, height: CANVAS_SIZE },
        pixelFormat: image.PixelMapFormat.RGBA_8888
      };
      const pm = await image.createPixelMap(buf, opts);
      this.pixelMap = pm;

第二步是将 ArrayBuffer 转换为 PixelMap 对象。构造 InitializationOptions 参数,指定尺寸为 96×96、像素格式为 RGBA_8888,然后调用 image.createPixelMap 异步创建 PixelMap。创建成功后将 pixelMap 状态变量赋值,触发预览区的 Image 组件更新——用户可以立即看到生成的像素画。

      // 3. PixelMap → WebP 编码(packer 用后必须 release)
      const packer = image.createImagePacker();
      const webpBuf = await packer.packToData(pm, { format: 'image/webp', quality: WEBP_QUALITY });
      await packer.release();

第三步是将 PixelMap 编码为 WebP 格式数据。首先创建 ImagePacker 实例,然后调用 packToData 方法进行编码,指定格式为 'image/webp'、质量为 90。编码完成后得到 ArrayBuffer 格式的 WebP 数据。特别重要的是 packer.release()——ImagePacker 是系统资源,使用完毕后必须调用 release 释放,否则会造成资源泄漏。

      // 4. 落盘沙箱 filesDir(元数据写回要求文件以可写方式打开)
      const path = `${dir}/pattern_sample.webp`;
      const file = fileIo.openSync(path,
        fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.CREATE | fileIo.OpenMode.TRUNC);
      fileIo.writeSync(file.fd, webpBuf);
      fileIo.closeSync(file);
      this.webpPath = path;
      this.genState = `已生成 ${(webpBuf.byteLength / 1024).toFixed(1)}KB`;
      this.opLogs.unshift(new MetaOpLog('生成样图',
        `${CANVAS_SIZE}×${CANVAS_SIZE} ${texture.label}${texture.name})像素画编码为 WebP 并落盘`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }

第四步是将 WebP 数据写入应用沙箱。文件路径为 filesDir/pattern_sample.webp,文件名固定为 pattern_sample.webp。打开文件使用 READ_WRITE | CREATE | TRUNC 三种模式的组合:读写模式、创建模式(文件不存在时创建)、截断模式(文件已存在时清空内容)。使用 fileIo.openSync 同步打开、fileIo.writeSync 同步写入、fileIo.closeSync 同步关闭,全程同步操作确保数据落盘的可靠性。

写入成功后更新三个状态变量:webpPath(保存路径,触发沙箱状态卡显示)、genState(更新为"已生成 X.X KB",显示文件大小)、opLogs(追加一条生成成功的操作日志)。文件大小通过 webpBuf.byteLength / 1024 计算,保留一位小数。

    } catch (e) {
      const err = e as BusinessError;
      this.genState = `生成失败(${err.code})`;
      this.opLogs.unshift(new MetaOpLog('生成样图', `失败:code ${err.code}${err.message}`));
    }
  }

整个生成流程包裹在 try-catch 中,任何一步失败都会被捕获。失败时将 genState 设为"生成失败(错误码)",并追加一条失败日志。用户可以通过操作日志流查看具体的错误码和错误信息,方便排查问题。

12.3 参数小胶囊构建器

  @Builder
  paramChip(label: string, value: string) {
    Row({ space: 5 }) {
      Text(label).fontSize(9).fontColor(COLORS.text3)
      Text(value).fontSize(10).fontColor(COLORS.sub).fontFamily('monospace')
    }.padding({ left: 8, right: 8, top: 6, bottom: 6 }).borderRadius(10)
    .backgroundColor(COLORS.chip).layoutWeight(1).justifyContent(FlexAlign.Center)
  }

paramChip 是一个小型的可复用构建器,用于生成参数说明卡中的三枚小胶囊。接收标签名和参数值两个参数,内部为 Row 横向排列标签(9 号浅驼色字)和值(10 号等宽驼褐色字)。胶囊为米杏色背景,圆角 10,使用 layoutWeight(1) 等分宽度,内容居中。这种小型构建器的抽离避免了重复代码,使参数卡的三枚胶囊样式完全一致。

十三、元数据 Tab 深度分析

13.1 读写回读全链路布局

  @Builder
  tabMeta() {
    Column({ space: 10 }) {
      Scroll() {
        Column({ space: 10 }) {

          // 读取区(标题 + 读取按钮)
          Row() {
            Column({ space: 3 }) {
              Text('WebPMetadata 五字段').fontSize(13).fontWeight(FontWeight.Bold)
                .fontColor(COLORS.title)
              Text('读取 → 写入 → 回读校验 全链路沙箱演示')
                .fontSize(9).fontColor(COLORS.text3)
            }.alignItems(HorizontalAlign.Start).layoutWeight(1)
            Button('读取元数据')
              .fontSize(11).height(34).borderRadius(10)
              .fontColor(COLORS.onMain).backgroundColor(COLORS.red)
              .onClick(() => { this.readMeta(); })
          }.width('100%').padding(10).borderRadius(12).backgroundColor(COLORS.card)

元数据 Tab 是 Image Kit WebP 元数据特性的演示页,展示"读取 → 写入 → 回读校验"的完整链路。布局由读取区、读取快照卡、写入控制台、回读校验卡、操作日志流和五字段速查卡六个模块构成。

第一模块为读取区,白底圆角 12,内边距 10。左侧为标题列,显示"WebPMetadata 五字段"主标题和"读取 → 写入 → 回读校验 全链路沙箱演示"副标题。右侧为"读取元数据"按钮,朱砂红底白字,34px 高,点击调用 readMeta() 方法读取 WebP 文件的元数据。

          // 读取快照卡(判空渲染)
          if (this.metaSnapshot !== undefined) {
            this.metaCard('读取快照 · readImageMetadataByType', this.metaSnapshot!, false)
          } else {
            Column({ space: 5 }) {
              Text('暂无快照').fontSize(11).fontColor(COLORS.sub)
              Text('先在「工坊」生成样图,再点上方「读取元数据」')
                .fontSize(9).fontColor(COLORS.text3)
            }.width('100%').padding({ top: 18, bottom: 18 }).borderRadius(12)
            .backgroundColor(COLORS.card)
          }

第二模块为读取快照卡,使用判空渲染模式。如果 metaSnapshot 已存在(已读取过),则调用 metaCard 构建器显示五字段快照卡片,标题为"读取快照 · readImageMetadataByType",高亮参数为 false(普通边框)。如果尚未读取,则显示占位卡——"暂无快照"标题和引导文字,提示用户先去工坊生成样图。

          // 写入控制台(帧延迟 chips + 循环 chips + 写入按钮)
          Column({ space: 10 }) {
            Text('写入控制台 · writeImageMetadata').fontSize(12)
              .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
            // 帧延迟档位
            Row({ space: 8 }) {
              Text('帧延迟').fontSize(11).fontColor(COLORS.sub).width(50)
              ForEach(DELAY_PRESETS, (d: number) => {
                Text(`${d}ms`).fontSize(10)
                  .fontColor(this.writeDelay === d ? COLORS.onMain : COLORS.sub)
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
                  .backgroundColor(this.writeDelay === d ? COLORS.red : COLORS.chip)
                  .onClick(() => { this.writeDelay = d; })
              }, (d: number) => `delay_${d}`)
            }.width('100%')
            // 循环档位
            Row({ space: 8 }) {
              Text('循环').fontSize(11).fontColor(COLORS.sub).width(50)
              ForEach(LOOP_PRESETS, (l: number) => {
                Text(l === 0 ? '0 不限' : `${l}`).fontSize(10)
                  .fontColor(this.writeLoop === l ? COLORS.onMain : COLORS.sub)
                  .padding({ left: 10, right: 10, top: 5, bottom: 5 }).borderRadius(12)
                  .backgroundColor(this.writeLoop === l ? COLORS.chip : COLORS.chip)
                  .onClick(() => { this.writeLoop = l; })
              }, (l: number) => `loop_${l}`)
            }.width('100%')
            // 当前选择说明
            Text(`将写回:canvas ${CANVAS_SIZE}×${CANVAS_SIZE},delayTime ${this.writeDelay}ms,` +
              `loopCount ${this.writeLoop === 0 ? '不限' : this.writeLoop}`)
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Button('写入并回读校验')
              .fontSize(12).height(36).borderRadius(10).width('100%')
              .fontColor(COLORS.onMain).backgroundColor(COLORS.redD)
              .onClick(() => { this.writeMeta(); })
          }.padding(10).borderRadius(12).backgroundColor(COLORS.card).width('100%')
            .alignItems(HorizontalAlign.Start)

第三模块为写入控制台,是元数据页的核心交互区,白底圆角 12,内边距 10。

控制台包含三行内容:第一行为帧延迟档位选择,左侧标签"帧延迟"(固定宽 50px),右侧三枚 chips 分别为 120ms、200ms、500ms,选中态朱砂红底白字。第二行为循环次数档位选择,左侧标签"循环",右侧四枚 chips 分别为 0 不限、1 次、3 次、5 次。第三行为当前选择说明,动态显示将写入的参数值(画布尺寸、帧延迟、循环次数)。

底部为"写入并回读校验"按钮,深朱砂红底白字,全宽 36px 高。深朱砂色比普通朱砂红更深,暗示这是一个"写入"操作——比读取更危险、更需要确认。点击调用 writeMeta() 方法写入元数据并自动触发回读校验。

          // 回读校验卡(判空渲染 + 高亮描边)
          if (this.verifySnapshot !== undefined) {
            this.metaCard('回读校验 · 重建 ImageSource 二次读取', this.verifySnapshot!, true)
          }

第四模块为回读校验卡,仅当 verifySnapshot 存在时显示(即执行过写入操作后)。调用 metaCard 构建器,标题为"回读校验 · 重建 ImageSource 二次读取",高亮参数为 true(苔绿色边框),表示这是验证结果的卡片。苔绿色边框与"通过/成功"的语义一致,暗示回读校验是验证写入是否成功的关键步骤。

          // 操作日志流(固定高度 140,unshift 置顶)
          Column({ space: 6 }) {
            Row() {
              Text('操作日志流').fontSize(12).fontWeight(FontWeight.Bold)
                .fontColor(COLORS.title)
              Blank()
              Text(`${this.opLogs.length}`).fontSize(10).fontColor(COLORS.text3)
            }.width('100%')
            if (this.opLogs.length === 0) {
              Text('暂无操作记录 · 先在「工坊」页生成 WebP 样图').fontSize(10)
                .fontColor(COLORS.text3).width('100%').textAlign(TextAlign.Center)
                .padding({ top: 16, bottom: 16 })
            } else {
              Scroll() {
                Column({ space: 6 }) {
                  ForEach(this.opLogs, (log: MetaOpLog) => {
                    Row({ space: 8 }) {
                      Text(log.op).fontSize(10).fontColor(opColor(log.op)).width(52)
                      Text(log.detail).fontSize(10).fontColor(COLORS.sub).layoutWeight(1)
                        .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
                      Text(log.time).fontSize(9).fontColor(COLORS.text3)
                    }.padding(8).borderRadius(8).backgroundColor(COLORS.card).width('100%')
                  }, (log: MetaOpLog) => `${log.time}_${log.op}_${log.detail}`)
                }
              }.height(140).width('100%').scrollBar(BarState.Off)
            }
          }.padding(10).borderRadius(12).backgroundColor(COLORS.chip).width('100%')

第五模块为操作日志流,米杏色背景,内边距 10。顶部一行显示"操作日志流"标题和条目数。当日志为空时显示提示文字;非空时显示固定高度 140px 的滚动日志列表。

每条日志由三列构成:操作名(固定宽 52px,颜色由 opColor 函数返回)、操作明细(等分宽度,驼褐色字,两行省略)、时间(9 号浅驼色字)。日志条目为白底圆角 8,内边距 8。日志流同样采用 unshift 置顶策略,最新操作显示在顶部,最多保留 30 条。

          // 五字段速查卡
          Column({ space: 5 }) {
            Text('WebPMetadata 五字段速查').fontSize(11).fontWeight(FontWeight.Bold)
              .fontColor(COLORS.title)
            Text('canvasWidth/canvasHeight:WebP 画布尺寸(px)')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Text('delayTime:限幅后帧延迟(ms);unclampedDelayTime:未限幅帧延迟')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Text('loopCount:循环次数,0=无限循环,未提供时渲染为「未提供」')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
            Text('五字段全部可选:读取后须 webp?.xxx ?? -1 兜底,禁止把 undefined 当 0')
              .fontSize(9).fontColor(COLORS.text3).width('100%')
          }.padding(10).borderRadius(12).backgroundColor(COLORS.chip).width('100%')
        }.width('100%')
      }.scrollBar(BarState.Off).width('100%').layoutWeight(1)
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

第六模块为五字段速查卡,米杏色背景,简要说明 WebP 元数据五个字段的含义和注意事项。特别强调了"五字段全部可选"的特性——读取时必须用 ?? -1 兜底,禁止把 undefined 当作 0 处理。这张卡片既是功能说明,也是最佳实践的提示。

13.2 元数据读写核心方法

  async readMeta() {
    if (this.webpPath === '') {
      this.opLogs.unshift(new MetaOpLog('读取元数据', '请先在「工坊」生成 WebP 样图'));
      return;
    }
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const types: image.MetadataType[] = [image.MetadataType.WEBP_METADATA];
      // ★ 6.1.1 类型化读取:index=0(多帧图帧索引,静态 WebP 取 0)
      const meta = await source.readImageMetadataByType(types, 0);
      const webp = meta.webPMetadata;
      this.metaSnapshot = new WebpMetaSnapshot(
        webp?.canvasWidth ?? -1, webp?.canvasHeight ?? -1,
        webp?.delayTime ?? -1, webp?.unclampedDelayTime ?? -1,
        webp?.loopCount ?? -1);
      await source.release();
      fileIo.closeSync(file);
      this.opLogs.unshift(new MetaOpLog('读取元数据',
        `画布 ${this.metaSnapshot!.canvasWidth}×${this.metaSnapshot!.canvasHeight}` +
        `帧延迟 ${fmtField(this.metaSnapshot!.delayTime, 'ms')}` +
        `循环 ${fmtField(this.metaSnapshot!.loopCount, ' 次')}`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }
    } catch (e) {
      const err = e as BusinessError;
      this.opLogs.unshift(new MetaOpLog('读取元数据', `失败:code ${err.code}${err.message}`));
    }
  }

readMeta 方法按类型读取 WebP 元数据,是读取链路的核心实现。方法首先检查 webpPath 是否为空——如果还没生成 WebP 文件,直接追加提示日志并返回。

读取流程分为五步:1. 以 READ_WRITE 模式打开沙箱文件(读也需要可写模式,因为后续可能写入);2. 用文件描述符创建 ImageSource;3. 构造元数据类型数组(仅包含 WEBP_METADATA),调用 readImageMetadataByType(types, 0) 读取指定类型的元数据,帧索引取 0(静态 WebP 只有一帧);4. 从返回的元数据中提取 webPMetadata,用 ?? -1 对每个字段兜底后构造 WebpMetaSnapshot 快照;5. 释放 ImageSource 资源并关闭文件。

readImageMetadataByType 是 HarmonyOS 6.1.1 新增的 API,相比传统的 getImageMetadata 可以精确指定要读取的元数据类型,避免不必要的解析开销,同时支持帧索引参数(用于多帧 WebP 的不同帧元数据读取)。

读取成功后追加一条成功日志,包含画布尺寸、帧延迟和循环次数的摘要信息。失败时捕获异常并追加失败日志,包含错误码和错误信息。

  async writeMeta() {
    if (this.webpPath === '') {
      this.opLogs.unshift(new MetaOpLog('写入元数据', '请先在「工坊」生成 WebP 样图'));
      return;
    }
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      // ★ 6.1.1 写回:WebPMetadata 全可选字段,对象字面量 as 断言(官方样例模式)
      const webpMeta = {
        canvasWidth: CANVAS_SIZE,
        canvasHeight: CANVAS_SIZE,
        delayTime: this.writeDelay,
        unclampedDelayTime: this.writeDelay,
        loopCount: this.writeLoop
      } as image.WebPMetadata;
      const meta: image.ImageMetadata = { webPMetadata: webpMeta };
      await source.writeImageMetadata(meta);
      await source.release();
      fileIo.closeSync(file);

writeMeta 方法写回 WebP 元数据,是写入链路的核心实现。与读取类似,先检查 WebP 文件是否存在。写入流程为:1. 以读写模式打开文件;2. 创建 ImageSource;3. 构造 WebPMetadata 对象字面量,填入画布尺寸(96×96)、帧延迟(用户选择的 writeDelay)、未限幅帧延迟(同 delayTime)和循环次数(用户选择的 writeLoop),使用 as image.WebPMetadata 类型断言——这是官方样例的推荐模式,因为 WebPMetadata 的所有字段都是可选的;4. 构造 ImageMetadata 包装对象,调用 writeImageMetadata 写入;5. 释放资源并关闭文件。

      this.opLogs.unshift(new MetaOpLog('写入元数据',
        `帧延迟=${this.writeDelay}ms,循环=${this.writeLoop === 0 ? '不限' : this.writeLoop}`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }
      // 写入完成后立即回读校验
      await this.verifyRead();
    } catch (e) {
      const err = e as BusinessError;
      this.opLogs.unshift(new MetaOpLog('写入元数据',
        `失败:code ${err.code}${err.message}(7700202=不支持,7700204=参数非法)`));
    }
  }

写入成功后追加一条成功日志,记录写入的帧延迟和循环次数参数。然后立即调用 verifyRead() 执行回读校验——这是"写入-回读"校验链路的关键设计:写入操作完成后自动触发回读,无需用户手动点击。失败时追加失败日志,并附带常见错误码说明(7700202=不支持该操作,7700204=参数非法),帮助用户快速定位问题。

  async verifyRead() {
    try {
      const file = fileIo.openSync(this.webpPath, fileIo.OpenMode.READ_WRITE);
      const source = image.createImageSource(file.fd);
      const meta = await source.readImageMetadataByType([image.MetadataType.WEBP_METADATA], 0);
      const webp = meta.webPMetadata;
      this.verifySnapshot = new WebpMetaSnapshot(
        webp?.canvasWidth ?? -1, webp?.canvasHeight ?? -1,
        webp?.delayTime ?? -1, webp?.unclampedDelayTime ?? -1,
        webp?.loopCount ?? -1);
      await source.release();
      fileIo.closeSync(file);
      const ok = this.verifySnapshot!.delayTime === this.writeDelay
        && this.verifySnapshot!.loopCount === this.writeLoop;
      this.opLogs.unshift(new MetaOpLog('回读校验',
        ok ? '已生效:delayTime/loopCount 与写入值一致'
          : `差异:delayTime=${fmtField(this.verifySnapshot!.delayTime, 'ms')}` +
            `loopCount=${fmtField(this.verifySnapshot!.loopCount, ' 次')}`));
      if (this.opLogs.length > 30) { this.opLogs.pop(); }
    } catch (e) {
      const err = e as BusinessError;
      this.opLogs.unshift(new MetaOpLog('回读校验', `失败:code ${err.code}${err.message}`));
    }
  }

verifyRead 方法执行回读校验,是验证链路的核心实现。回读的流程与 readMeta 几乎完全相同:打开文件 → 创建 ImageSource → 读取 WebP 元数据 → 构造快照 → 释放资源。但关键区别在于回读后会与写入值进行比对——比较 delayTimeloopCount 是否与 writeDelaywriteLoop 完全一致。

如果一致,日志显示"已生效:delayTime/loopCount 与写入值一致",验证通过;如果不一致,日志显示具体的差异值,方便排查。这种"写入 → 回读 → 比对"的三段式校验是元数据管理的最佳实践——只有当回读值与写入值完全一致时,才能确认数据确实被正确写入,避免了"写入成功但实际未生效"的隐性问题。

13.3 元数据快照卡构建器

  @Builder
  metaCard(title: string, snap: WebpMetaSnapshot, highlight: boolean) {
    Column({ space: 6 }) {
      Row() {
        Text(title).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
        Blank()
        Text(highlight ? '回读值' : '快照值').fontSize(9)
          .fontColor(highlight ? COLORS.green : COLORS.text3)
      }.width('100%')
      this.metaRow('canvasWidth', sizeText(snap.canvasWidth))
      this.metaRow('canvasHeight', sizeText(snap.canvasHeight))
      this.metaRow('delayTime', fmtField(snap.delayTime, 'ms'))
      this.metaRow('unclampedDelayTime', fmtField(snap.unclampedDelayTime, 'ms'))
      this.metaRow('loopCount', loopText(snap.loopCount))
    }.padding(10).borderRadius(12).width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: 1, color: highlight ? COLORS.green : COLORS.line })
  }

metaCard 构建器是元数据快照卡的可复用实现,接收标题、快照对象和是否高亮三个参数。卡片顶部为标题行:左侧显示卡片标题,右侧显示"快照值"或"回读值"标签(高亮时苔绿色,否则浅驼色)。

卡片主体为五行元数据字段,每行由 metaRow 构建器渲染,分别展示 canvasWidth、canvasHeight、delayTime、unclampedDelayTime 和 loopCount 五个字段的值。值的格式化由对应的格式化函数处理(sizeText 处理尺寸字段,fmtField 处理延迟字段,loopText 处理循环字段),统一处理 -1 占位值为"未提供"。

卡片底部为边框设置——高亮模式下边框为苔绿色(1px),普通模式下为米灰色分割线色。苔绿色边框暗示"验证通过"的语义,使回读校验卡在视觉上与读取快照卡区分开来。

  @Builder
  metaRow(field: string, value: string) {
    Row() {
      Text(field).fontSize(10).fontFamily('monospace').fontColor(COLORS.text3)
      Blank()
      Text(value).fontSize(10).fontFamily('monospace').fontColor(COLORS.sub)
    }.width('100%').padding({ top: 3, bottom: 3 })
  }

metaRow 构建器是更小粒度的可复用组件,渲染元数据的单行字段。字段名和值均使用等宽字体(fontFamily('monospace')),字段名为浅驼色,值为驼褐色,左右对齐,中间留白。每行上下 3px 内边距,使五行字段疏密有致。

十四、我的 Tab 深度分析

14.1 守艺人渐变大卡

  @Builder
  tabMine() {
    Column({ space: 10 }) {
      Scroll() {
        Column({ space: 10 }) {
          // 会员渐变大卡(年度守艺人 + 三列战绩 + 呼吸圆点)
          Column({ space: 12 }) {
            Row({ space: 12 }) {
              Text('🧧').fontSize(38)
              Column({ space: 4 }) {
                Text('纹藏馆 · 年度守艺人').fontSize(16).fontWeight(FontWeight.Bold)
                  .fontColor(COLORS.onMain)
                Text('LV6 金纹匠 · ID Pattern-Keeper-1283').fontSize(10)
                  .fontColor(COLORS.onMain).opacity(0.85)
              }.alignItems(HorizontalAlign.Start).layoutWeight(1)
              // 呼吸圆点
              Circle({ width: 8, height: 8 }).fill(COLORS.gold)
                .opacity(this.breath ? 0.9 : 0.45)
            }.width('100%')

我的 Tab 是守艺人个人中心,布局由守艺人渐变大卡、创作任务清单和版本信息三部分构成。第一部分为守艺人渐变大卡,是整个 Tab 的视觉焦点。

卡片顶部第一行展示用户头像和等级信息:左侧为 38 号的🧧红包图标(象征守艺人的荣誉与奖励),中间为等级信息列——主标题"纹藏馆 · 年度守艺人"(16 号粗体白字)和副标题"LV6 金纹匠 · ID Pattern-Keeper-1283"(10 号白字,透明度 0.85)。右侧为一个 8px 的鎏金色呼吸圆点,透明度随 breath 状态在 0.9 和 0.45 之间切换,与头部 Banner 的呼吸圆点呼应,形成全局统一的呼吸节奏。

            // 三列战绩(收藏纹样 / 累计下载 / 工分)
            Row({ space: 8 }) {
              this.statBig('收藏纹样', '38', '套', COLORS.onMain)
              this.statBig('累计下载', '1260', '次', COLORS.gold)
              this.statBig('守艺工分', '8920', '分', COLORS.green)
            }.width('100%')

            Text('年度共建任务 68/100 · 距离「御纹匠」还差 32 项')
              .fontSize(9).fontColor(COLORS.onMain).opacity(0.85).width('100%')
          }.padding(14).borderRadius(14).width('100%')
          .linearGradient({
            angle: 135,
            colors: [[COLORS.gradA, 0], [COLORS.gradB, 1]]
          })

第二行为三列战绩统计,由 statBig 构建器渲染三枚小格,分别展示收藏纹样 38 套(白字)、累计下载 1260 次(鎏金字)、守艺工分 8920 分(苔绿字)。第三行为年度共建任务进度说明,显示"68/100"的进度和距离下一等级的差距,激励用户继续参与。

卡片整体使用 135 度线性渐变从朱砂红到深朱砂,内边距 14,圆角 14。朱砂渐变的底色与白色文字形成强烈对比,使守艺人卡成为页面上最醒目的视觉元素。

          // 创作任务清单(6 行,完成态绿徽 / 待办金徽)
          Column() {
            ForEach(MINE_TASKS, (task: MineTask) => {
              Row({ space: 10 }) {
                Text(task.icon).fontSize(16)
                Column({ space: 3 }) {
                  Text(task.label).fontSize(12).fontColor(COLORS.title).maxLines(1).width('100%')
                    .textOverflow({ overflow: TextOverflow.Ellipsis })
                  Text(task.hint).fontSize(9).fontColor(COLORS.text3)
                }.alignItems(HorizontalAlign.Start).layoutWeight(1)
                Text(task.done ? '已完成' : '待办').fontSize(9)
                  .fontColor(COLORS.onMain)
                  .padding({ left: 8, right: 8, top: 3, bottom: 3 }).borderRadius(8)
                  .backgroundColor(task.done ? COLORS.green : COLORS.gold)
              }.padding({ top: 12, bottom: 12, left: 12, right: 12 }).width('100%')
            }, (task: MineTask) => task.label)
          }.borderRadius(12).backgroundColor(COLORS.card).width('100%')

第二部分为创作任务清单,白底圆角 12,展示 6 条创作任务。每行任务由三部分横向排列:左侧为 16 号任务图标,中间为任务信息(任务名 12 号墨褐字 + 工分奖励 9 号浅驼色字),右侧为状态徽章——已完成任务显示苔绿色"已完成"徽章,待完成任务显示鎏金色"待办"徽章。

任务行上下 12px 内边距,6 条任务形成规整的列表。已完成的 2 条(上传描线稿、矢量重绘)和待完成的 4 条(词条校订、专题投稿、元数据补录、社区邀请)形成了"有进展、有目标"的任务画像,激励用户持续参与纹藏馆的共建。

          // 版本信息
          Text('纹藏馆 v1.0.0 · HarmonyOS API 24 · Canvas × Tabs 嵌套滚动 × ImageKit WebP 元数据')
            .fontSize(9).fontColor(COLORS.text3).width('100%')
            .textAlign(TextAlign.Center).padding({ top: 6, bottom: 6 })
        }.width('100%')
      }.scrollBar(BarState.Off).width('100%').layoutWeight(1)
    }.width('100%').height('100%').padding({ left: 12, right: 12, top: 4, bottom: 8 })
  }

第三部分为版本信息,居中显示一行小字,包含应用版本和三大特性标识,作为整个应用的版权和技术说明。

14.2 统计小格构建器

  @Builder
  statBig(label: string, value: string, unit: string, color: string) {
    Column({ space: 3 }) {
      Row({ space: 2 }) {
        Text(value).fontSize(22).fontWeight(FontWeight.Bold)
          .fontColor(color).fontFamily('monospace')
        Text(unit).fontSize(10).fontColor(COLORS.onMain).opacity(0.85)
      }
      Text(label).fontSize(9).fontColor(COLORS.onMain).opacity(0.85)
    }.padding({ top: 10, bottom: 10 }).borderRadius(10)
    .backgroundColor(COLORS.blueD).layoutWeight(1)
  }

statBig 构建器渲染守艺人卡中的三列战绩小格。接收标签、数值、单位和数值颜色四个参数。每个小格为深黛蓝色(blueD)背景,圆角 10,上下 10px 内边距,使用 layoutWeight(1) 等分宽度。

小格内部纵向排列:顶部为数值行,数值 22 号粗体等宽字(颜色由参数传入),单位 10 号白字(透明度 0.85);底部为标签,9 号白字(透明度 0.85)。深黛蓝的背景色与朱砂渐变的卡片底色形成冷暖对比,使数据格更加突出,同时深黛蓝的沉稳感与"统计数据"的语义相符。

十五、底部 Tab 栏

  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (tab: TabMeta, index: number) => {
        Column({ space: 3 }) {
          Text(tab.icon).fontSize(17)
          Text(tab.label).fontSize(9)
            .fontColor(this.currentTab === index ? COLORS.tabOn : COLORS.text3)
        }.justifyContent(FlexAlign.Center)
        .layoutWeight(1)
        .padding({ top: 7, bottom: 7 })
        .onClick(() => { this.currentTab = index; })
      }, (tab: TabMeta) => tab.label)
    }.width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: { top: 1 }, color: COLORS.line })
  }

底部 Tab 栏采用自绘实现而非 Tabs 组件。Row 容器白底,顶部 1px 米灰色边框,ForEach 遍历 TAB_LIST 渲染六个 Tab 项,每项等分宽度(layoutWeight(1)),内部纵向排列 emoji 图标(17 号)和中文标签(9 号)。

选中态标签颜色为 tabOn(朱砂红,与主色一致),未选中为 text3 浅驼色。onClick 回调将 currentTab 设为当前索引,触发内容区 if/else 链切换到对应 Tab 的 @Builder 方法。ForEach 的第三个参数(键值生成函数)使用 tab.label 作为唯一标识。

自绘 Tab 栏相比 Tabs 组件提供了更精细的样式控制:emoji 字体大小、图标与标签的间距、上下内边距、选中态颜色等都可以精确调整。同时,自绘 Tab 栏与内容区的 if/else 切换配合,避免了 Tabs 组件在复杂嵌套场景下的手势冲突问题,使整体架构更加清晰可控。

Tab 栏的设计遵循"轻量导航"原则——白底、细线、小图标、小文字,弱化导航的视觉权重,将更多视觉空间留给内容。选中态使用朱砂红高亮,与主色保持一致,形成"朱砂落纸"的视觉统一。

十六、弹窗系统

16.1 弹窗遮罩层

  @Builder
  modalOverlay(onClose: () => void) {
    Column() {
      // 空白遮罩区(点击关闭弹窗)
      Column().width('100%').layoutWeight(1)
        .onClick(() => { onClose(); })
      // 弹窗面板(按激活标志三选一)
      if (this.addModal) {
        this.panelAdd(onClose)
      } else if (this.editModal) {
        this.panelEdit(onClose)
      } else if (this.delModal) {
        this.panelDel(onClose)
      }
    }.width('100%').height('100%').backgroundColor(COLORS.mask)
    .justifyContent(FlexAlign.End)
  }

modalOverlay 是三组弹窗共用的遮罩层构建器,接收一个 onClose 回调函数用于关闭弹窗。与独立的弹窗 Stack 层叠不同,此处采用"统一遮罩 + 底部面板"的设计——整个遮罩层为一个全屏 Column,墨褐半透明背景,底部对齐。

遮罩层分为上下两部分:上部为空白遮罩区(layoutWeight(1) 占满剩余高度),点击空白处调用 onClose 关闭弹窗;下部为弹窗面板,根据激活的弹窗状态三选一显示。这种底部弹出的面板设计(Bottom Sheet)比居中对话框更适合移动端操作,用户拇指可以轻松触达底部的操作按钮。

"三选一"的设计保证了弹窗的互斥性——同一时间只能有一个弹窗显示,避免了弹窗叠加的混乱。三个弹窗共享同一个遮罩层和同一个关闭回调,代码结构简洁清晰。

16.2 新建纹样收藏弹窗

  @Builder
  panelAdd(onClose: () => void) {
    Column({ space: 12 }) {
      Text('新建纹样收藏').fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
      Text('输入纹样名后会置顶到素材列表首位,朝代跟随当前筛选,品类默认回纹')
        .fontSize(10).fontColor(COLORS.text3).width('100%')
      TextInput({ placeholder: '输入纹样名,如:北魏忍冬纹' })
        .fontSize(12).height(40)
        .fontColor(COLORS.title)
        .placeholderColor(COLORS.text3)
        .backgroundColor(COLORS.chip)
        .onChange((value: string) => { this.inputText = value; })
      Row({ space: 10 }) {
        Button('取消')
          .fontSize(12).height(38).borderRadius(10)
          .fontColor(COLORS.sub).backgroundColor(COLORS.chip)
          .layoutWeight(1)
          .onClick(() => { onClose(); })
        Button('收藏')
          .fontSize(12).height(38).borderRadius(10)
          .fontColor(COLORS.onMain).backgroundColor(COLORS.red)
          .layoutWeight(1)
          .onClick(() => { this.confirmAdd(); })
      }.width('100%')
    }.padding(16).borderRadius({ topLeft: 16, topRight: 16 })
    .backgroundColor(COLORS.card).width('100%')
  }

panelAdd 为新建纹样收藏弹窗,底部弹出式面板,顶部左右圆角 16px,白底,内边距 16。面板内部纵向排列四行内容:

第一行为标题"新建纹样收藏",15 号粗体墨褐字。第二行为说明文案,10 号浅驼色字,说明新建后的行为——置顶到素材列表首位、朝代跟随当前筛选、品类默认回纹,使用户在输入前就了解新建规则。第三行为输入框,40px 高,米杏色背景,提示文字为"输入纹样名,如:北魏忍冬纹",输入内容实时写入 inputText 状态变量。第四行为两个操作按钮,"取消"按钮米杏底驼褐字,"收藏"按钮朱砂红底白字,两按钮等分宽度。

新建弹窗的设计体现了"智能默认"的理念——朝代和品类都有合理的默认值,用户只需输入纹样名即可完成收藏,大大降低了操作成本。

16.3 编辑用途描述弹窗

  @Builder
  panelEdit(onClose: () => void) {
    Column({ space: 12 }) {
      Text('编辑用途描述').fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
      Text(this.editIdx >= 0 && this.editIdx < this.patternList.length
        ? `${this.patternList[this.editIdx].name} · ${this.patternList[this.editIdx].dynasty}代 · ${this.patternList[this.editIdx].cat}` : '')
        .fontSize(11).fontColor(COLORS.sub)
      TextInput({
        text: this.editIdx >= 0 && this.editIdx < this.patternList.length
          ? this.patternList[this.editIdx].uses : '',
        placeholder: '输入新的用途描述(工艺载体 · 应用场景)'
      })
        .fontSize(12).height(40)
        .fontColor(COLORS.title)
        .placeholderColor(COLORS.text3)
        .backgroundColor(COLORS.chip)
        .onChange((value: string) => { this.inputText = value; })
      Row({ space: 10 }) {
        Button('取消')
          .fontSize(12).height(38).borderRadius(10)
          .fontColor(COLORS.sub).backgroundColor(COLORS.chip)
          .layoutWeight(1)
          .onClick(() => { onClose(); })
        Button('保存')
          .fontSize(12).height(38).borderRadius(10)
          .fontColor(COLORS.onMain).backgroundColor(COLORS.red)
          .layoutWeight(1)
          .onClick(() => { this.confirmEdit(); })
      }.width('100%')
    }.padding(16).borderRadius({ topLeft: 16, topRight: 16 })
    .backgroundColor(COLORS.card).width('100%')
  }

panelEdit 为编辑用途描述弹窗,结构与新建弹窗类似但内容不同。标题为"编辑用途描述",副标题显示当前编辑的纹样信息(名称 · 朝代 · 品类),使用户确认正在编辑的对象。

输入框的初始值为当前纹样的用途描述(由 openEdit 方法在打开弹窗时写入 inputText),用户修改后保存。这里特别注意:输入框的 text 参数直接读取 patternList[this.editIdx].uses,而不是 inputText。这是因为 @Observed 类的属性变更会自动触发 UI 刷新,如果输入框绑定 inputText,而编辑保存后修改了 patternList 的属性,输入框内容不会自动同步。直接绑定数据源属性确保了弹窗打开时输入框显示的是最新值。

底部按钮为"取消"和"保存",保存按钮调用 confirmEdit() 方法提交修改。

16.4 删除确认弹窗

  @Builder
  panelDel(onClose: () => void) {
    Column({ space: 12 }) {
      Text('删除纹样收藏').fontSize(15).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.title)
      Text(`确认将「${this.delIdx >= 0 && this.delIdx < this.patternList.length
        ? this.patternList[this.delIdx].name : ''}」移出素材列表?删除后不可恢复。`)
        .fontSize(11).fontColor(COLORS.sub)
      Row({ space: 10 }) {
        Button('取消')
          .fontSize(12).height(38).borderRadius(10)
          .fontColor(COLORS.sub).backgroundColor(COLORS.chip)
          .layoutWeight(1)
          .onClick(() => { onClose(); })
        Button('删除')
          .fontSize(12).height(38).borderRadius(10)
          .fontColor(COLORS.onMain).backgroundColor(COLORS.redD)
          .layoutWeight(1)
          .onClick(() => { this.confirmDel(); })
      }.width('100%')
    }.padding(16).borderRadius({ topLeft: 16, topRight: 16 })
    .backgroundColor(COLORS.card).width('100%')
  }

panelDel 为删除确认弹窗,结构最为简洁。标题为"删除纹样收藏",确认文案显示待删除的纹样名称,并用"删除后不可恢复"强调操作的不可逆性。

底部两个按钮中,"删除"按钮使用深朱砂红(redD)背景,比普通朱砂红更深,强化危险操作的视觉警示。深朱砂色在整个色彩体系中仅用于删除按钮和错误状态,通过低频使用强化其"危险/警告"的语义。

16.5 弹窗操作方法

  openAdd() {
    this.inputText = '';
    this.addModal = true;
  }

  openEdit(idx: number) {
    this.editIdx = idx;
    this.inputText = idx >= 0 && idx < this.patternList.length ? this.patternList[idx].uses : '';
    this.editModal = true;
  }

  openDel(idx: number) {
    this.delIdx = idx;
    this.delModal = true;
  }

三个 openXxx 方法分别打开对应的弹窗。openAdd 清空输入框后打开新建弹窗。openEdit 记录编辑索引,将当前纹样的用途描述预填入 inputText,然后打开编辑弹窗。openDel 记录删除索引后打开删除确认弹窗。

  closeAllModals() {
    this.addModal = false;
    this.editModal = false;
    this.delModal = false;
    this.editIdx = -1;
    this.delIdx = -1;
    this.inputText = '';
  }

closeAllModals 方法统一关闭全部弹窗并复位状态。除了将三个弹窗 boolean 设为 false 外,还将编辑索引、删除索引和输入框内容一并复位。这种"一键清理"的设计避免了弹窗关闭后残留状态导致的潜在问题,是弹窗系统健壮性的重要保障。

  confirmAdd() {
    if (this.inputText.trim() !== '') {
      const dynasty = this.activeDynasty === '全部' ? '唐' : this.activeDynasty;
      this.patternList.unshift(new PatternItem(this.inputText.trim(), dynasty, '回纹',
        '新建收藏纹样 · 待匠人补全用途与适配说明', 82));
    }
    this.closeAllModals();
  }

confirmAdd 方法确认新建纹样收藏。首先检查输入内容去除空格后是否非空——空输入不创建,避免空白条目。创建新纹样时使用三个智能默认:朝代跟随当前筛选(如果当前是"全部"则默认"唐"),品类默认"回纹",适配度默认 82 分(良品级别),用途描述默认"新建收藏纹样 · 待匠人补全用途与适配说明"。新建的纹样通过 unshift 置顶到素材列表首位,使用户立即看到新建结果。最后调用 closeAllModals 关闭弹窗并复位。

  confirmEdit() {
    if (this.editIdx >= 0 && this.editIdx < this.patternList.length && this.inputText.trim() !== '') {
      this.patternList[this.editIdx].uses = this.inputText.trim();
    }
    this.closeAllModals();
  }

confirmEdit 方法确认保存编辑后的用途描述。采用"空值不覆盖"策略——只有当索引有效且输入内容非空时才覆盖原值,确保编辑时不误清数据。由于 PatternItem@Observed 类,直接修改 uses 属性即可触发 UI 刷新,无需替换数组元素。

  confirmDel() {
    if (this.delIdx >= 0 && this.delIdx < this.patternList.length) {
      this.patternList.splice(this.delIdx, 1);
    }
    this.closeAllModals();
  }

confirmDel 方法确认删除纹样收藏。通过 splice 删除指定索引的元素,索引有效时才执行删除。删除后数组自动重新排列,UI 随之刷新。

十七、功能模块对比表

功能模块 核心技术 数据模型 状态变量 布局策略 关键特性
素材馆 Tab Canvas 绘制 + 纯组件柱状图 PatternItem patternList, activeDynasty Scroll 纵向排列 + 双列卡片预分组 drawPie 环形图中心镂空 + 呼吸微动 + 朝代筛选
频道 Tab Tabs 嵌套滚动 InnerCard nestedMode, outerIndex, innerIndex, swipeLogs 双层 Tabs 嵌套 + List 列表 nestedScroll SELF_ONLY/SELF_FIRST 动态切换 + 翻页日志
日志 Tab List 时间轴 SwipeLog swipeLogs 固定行高 72 + 三列时间轴 unshift 置顶滑动窗口 + 双层级色徽标
工坊 Tab Image Kit WebP 编码 无独立模型 textureIdx, pixelMap, webpPath, genState Scroll 纵向六模块 pixelColor 五算法织纹 + createPixelMap + packToData + 沙箱落盘
元数据 Tab Image Kit WebP 元数据 WebpMetaSnapshot, MetaOpLog metaSnapshot, writeDelay, writeLoop, verifySnapshot, opLogs 读写回读四步链路 + 双快照卡 readImageMetadataByType + writeImageMetadata + 回读校验
我的 Tab linearGradient + 纯组件 MineTask 无独立状态 渐变大卡 + 任务清单 朱砂渐变守艺人卡 + 三列战绩 + 六任务清单
弹窗系统 @State 三态管理 复用 PatternItem addModal, editModal, delModal, editIdx, delIdx, inputText 底部弹出面板 + 统一遮罩 新建/编辑/删除三态 + 智能默认值 + 空值不覆盖
头部区域 linearGradient + 三元运算 TabMeta currentTab, breath, nestedMode, genState 两行纵向 Banner Tab 联动副标题 + 三特性状态胶囊 + 呼吸圆点
底部 Tab 栏 自绘 Row + ForEach TabMeta currentTab 单排六等分 emoji 图标 + 文字标签 + 朱砂选中态

十八、总结与展望

本文以"纹藏馆 · 非遗纹样素材平台"为业务场景,完整解析了一个基于 HarmonyOS ArkUI 框架的 1730 行单文件组件化应用架构。从 16 色宣纸米白 + 朱砂红主题的色彩体系,到六大 Tab 各自独立的布局结构,再到三大前沿特性(Canvas 环形图绘制、Tabs 嵌套滚动、Image Kit WebP 元数据)的深度集成,平台展示了 ArkUI 声明式 UI 范式在复杂业务场景下的系统级表达能力。

架构层面,平台采用"根组件 Stack 层叠 + Column 三段式(头部 Banner/内容区/底部 Tab 栏)+ 顶层弹窗系统"的经典布局架构,通过 currentTab 状态索引在六个 @Builder 方法间切换实现 Tab 内容隔离。六个 @Observed 数据模型类分别支撑各自 Tab 的列表渲染,@State 状态变量按功能分组统一声明在组件顶层,实现跨 Tab 数据共享。弹窗系统通过三个 boolean 状态变量控制显隐、一个 string 复用输入框内容、两个 number 记录操作索引,形成了"数据-状态-视图"三层清晰的单向流动。

技术亮点方面,Canvas 绘制特性通过 drawPie 实现五品类馆藏占比环形图,以 setInterval 驱动呼吸微动重绘,外圈描边利用 globalAlpha 实现半透明效果后立即复位,展示了 Canvas 编程的精细控制能力。Tabs 嵌套滚动特性(API 24)通过内层 Tabs 挂载 nestedScroll 实现 SELF_ONLY 与 SELF_FIRST 两种模式动态切换,配合滑动日志时间轴直观验证了"内层滑到边缘接力给外层"的交互效果。Image Kit WebP 元数据特性(API 24)构建了"生成 → 读取 → 写入 → 回读校验"的完整链路,readImageMetadataByType 精确读取五字段、writeImageMetadata 写回参数、重建 ImageSource 二次读取比对验证,全程沙箱零权限操作,为纹样素材的元数据版本管理提供了可信方案。

工程实践层面,平台在多个方面体现了 ArkUI 开发的最佳实践:工具函数层将色彩映射、格式化、像素算法等纯逻辑抽离为独立函数,提高了代码复用性和可测试性;数据模型层使用 @Observed 装饰器使属性变更自动触发 UI 刷新,简化了状态管理;列表渲染采用预计算策略(filteredPatternspatternPairs),避免了在 ForEach 内调用 filter 的性能问题;Canvas 绘制遵循"全局状态用后即恢复"原则,防止绘制指令间的状态污染;弹窗系统采用"统一遮罩 + 三面板切换 + 一键清理"设计,保证了弹窗状态的健壮性。

未来展望,纹藏馆平台可在以下方向深化。其一,接入真实后端 API 替换 Mock 数据,使纹样素材、馆藏统计和成交量数据具备实时性和真实性。其二,将像素画算法生成的 WebP 样图升级为真实纹样素材的矢量图导入与导出,支持 SVG、AI 等专业格式,提升工坊的实用价值。其三,引入 @StorageLink 跨组件持久化状态,使用户的收藏列表、生成记录和元数据配置在应用重启后保持。其四,利用 WebP 动图能力生成动态纹样效果,将静态的纹理算法升级为帧动画,使工坊产物更具表现力。其五,增加社交分享功能,支持纹样素材的一键分享和社区互动,从"个人素材库"升级为"创作者社区"。其六,引入 AI 纹样生成能力,基于用户输入的关键词自动生成符合传统纹样语义的创新设计,连接传统工艺与人工智能。这些方向将使纹藏馆平台从技术演示走向产品化落地,真正服务于非遗纹样的数字化保护与创新传承。

附录:DevEco Studio 创建新项目与查看 SDK 版本

本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。


一、创建新项目

1.1 进入欢迎界面

启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:

  • 新建项目:从头创建新项目
  • 打开项目:打开本地已有项目
  • 克隆仓库:从 Git 等版本控制拉取代码

点击 “新建项目” 按钮,进入项目创建向导。

在这里插入图片描述

1.2 选择项目模板

在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:

类型 说明
应用(Application) 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期
元服务(Atomic Service) 开发轻量级的原子化服务,无需安装即可使用

选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

在这里插入图片描述

1.3 配置项目信息

点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:

配置项 示例值 说明
项目名称(Project name) rollboat 应用的项目名称,建议使用英文命名
包名(Bundle name) com.rollboat.myapplication 应用唯一标识,采用反向域名格式
保存路径(Save location) D:\CodeFactory\rollboat 项目本地存储路径,避免使用中文和空格
兼容 SDK(Compatible SDK) 6.1.1(24) 目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异
模块名称(Module name) entry 主模块名称,默认 entry 为应用入口模块
设备类型(Device types) ☑ Phone 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV

右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

在这里插入图片描述

1.4 完成创建

确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:

  1. 生成项目骨架(Stage 模型目录结构)
  2. 执行 ohpm install 安装依赖
  3. 运行 Hvigor 构建初始化(Build Init

构建日志中显示 “退出代码为 0” 表示项目初始化成功。

在这里插入图片描述

1.5 项目结构概览

创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:

rollboat/
├── .hvigor/                   # Hvigor 构建工具缓存
├── .idea/                     # IDE 配置文件
├── AppScope/                  # 应用级全局配置
│   └── app.json5
├── entry/                     # 主模块(入口模块)
│   ├── src/main/ets/
│   │   ├── entryability/      # Ability 生命周期管理
│   │   │   └── EntryAbility.ets
│   │   └── pages/             # UI 页面
│   │       └── Index.ets      # 首页(默认 Hello World)
│   ├── src/main/resources/    # 资源文件
│   ├── module.json5           # 模块配置
│   └── build-profile.json5    # 构建配置
├── oh_modules/                # OHPM 依赖包
├── build-profile.json5        # 工程构建配置
├── hvigorfile.ts              # Hvigor 构建脚本
└── oh-package.json5           # 包管理配置

核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:

@Entry
@Component
struct Index {
  @State message: string = 'Hello World';

  build() {
    RelativeContainer() {
      Text(this.message)
        .id('HelloWorld')
        .fontSize($r('app.float.page_text_font_size'))
        .fontWeight(FontWeight.Bold)
        .alignRules({
          center: { anchor: '__container__', align: VerticalAlign.Center },
          middle: { anchor: '__container__', align: HorizontalAlign.Center }
        })
        .onClick(() => {
          this.message = 'Welcome';
        })
    }
    .height('100%')
    .width('100%')
  }
}
关键语法 作用
@Entry 标记为页面入口,可用于路由跳转
@Component 声明为自定义组件
@State 状态变量,数据变更时自动触发 UI 刷新
RelativeContainer 相对布局容器,替代传统线性布局
.onClick() 点击事件,此处点击后文本变为 “Welcome”

打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:

文件 → 设置 → HarmonyOS SDK(或快捷键 Ctrl + Alt + S 搜索 “HarmonyOS SDK”)

在设置面板中,可以看到当前已安装的 SDK 版本信息:

名称 阶段 状态
HarmonyOS 6.1.1 Release ✅ 已安装

界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

在这里插入图片描述

2.2 查看 ArkUI-X SDK(跨平台扩展)

如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:

文件 → 设置 → 语言和框架 → ArkUI-X

在这里可以查看已安装和可选的 ArkUI-X SDK 版本:

版本 SDK 版本号 阶段 状态
API Version 24 6.1.1.100 Release ✅ 已安装
API Version 23 6.1.0.28 Beta1 未安装
API Version 22 6.0.2.112 Release 未安装

安装路径示例:D:\DevTools\ArkUI-X\sdk

说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

在这里插入图片描述


三、小结

步骤 操作 关键点
创建项目 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 使用 Stage 模型 + ArkTS 语言
查看 SDK 设置 → HarmonyOS SDK SDK 已内置,无需手动安装
跨平台扩展 设置 → ArkUI-X 根据需要安装对应 API 版本

至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。


Logo

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

更多推荐