一、技术前言

在这里插入图片描述

在移动互联网内容生态日益繁荣的今天,创意设计素材下载平台已成为设计师、产品经理、运营人员等创意工作者日常不可或缺的工具类应用。然而,长期以来,移动端素材下载面临一个核心痛点——下载来源无法追溯。当用户在某个素材详情页点击下载按钮后,文件虽然成功保存到本地,但"从哪个页面发起的下载""下载的是哪个直链地址"这类关键溯源信息往往会随着浏览器或 WebView 的页面跳转而丢失。对于商用授权素材而言,来源信息的缺失意味着版权审计无法闭环、素材使用合规性无法自证。HarmonyOS 6.1.1 版本中 ArkWeb 引入了 WebDownloadDelegate 的双 URL 溯源接口——getOriginalUrl()getReferrerUrl(),为这一长期痛点提供了系统级的解决方案。

在这里插入图片描述

HarmonyOS 的 ArkUI 框架是华为自研的声明式 UI 开发范式,其核心设计哲学是通过 @Entry@Component@State@Builder@Observed 等装饰器体系,让开发者以接近自然语言描述的方式声明界面结构与状态依赖关系,由框架底层负责高效的差分渲染和响应式更新。ArkTS 语言在 TypeScript 的基础上进一步强化了类型安全——所有变量、参数、返回值、接口字段都需要明确的类型标注,编译期即可捕获大量潜在的运行时错误。在本文分析的"素材星球"应用中,ArkUI 的这些特性被充分施展:4 个 Tab 页面通过独立的 @Builder 函数封装,每个 Tab 采用完全不同的布局方式;状态变量通过 @State 管理并驱动响应式 UI 刷新;数据模型通过 @Observed 装饰器实现深度可观察,确保数据变更时引用它的列表自动重渲染;弹窗系统通过条件渲染按需挂载到 Stack 容器中,实现轻量的模态交互层。

在这里插入图片描述

ArkWeb 是 HarmonyOS 提供的 Web 组件能力套件,它允许开发者在应用内嵌入完整的浏览器引擎,实现网页内容的加载、交互和下载管理。在 ArkWeb 的 API 体系中,webview.WebviewController 是 Web 组件的控制器,负责页面加载、前进后退、缩放控制等操作;webview.WebDownloadDelegate 是下载代理,通过注册四个回调函数(onBeforeDownloadonDownloadUpdatedonDownloadFailedonDownloadFinish)来接管网页内触发的下载任务的完整生命周期。HarmonyOS 6.1.1 在 onDownloadFinish 回调中新增了两个关键接口:getOriginalUrl() 返回下载项的原始直链 URL 地址(即文件在网络上的实际存储路径),getReferrerUrl() 返回引用页 URL 地址(即用户是从哪个页面发起的下载)。这两个接口的配合使用,使得每一次素材下载都能完整追溯"来源页面→直链地址"的完整链路,为素材版权审计和下载管理提供了系统级的数据支撑。

在这里插入图片描述

在数据可视化方面,ArkUI 提供了 Canvas 组件和 CanvasRenderingContext2D 上下文,支持开发者使用标准的 2D Canvas API 进行自由绘制。这与 Web 平台的 Canvas 2D Context API 几乎完全兼容,包括 beginPathmoveTolineToarcfillstrokecreateLinearGradient 等核心方法。本应用在素材 Tab 使用 Canvas 绘制了一幅近 6 个月素材下载量趋势的折线图,包含背景网格、渐变填充区域、连续折线本体、数据点圆点和底部月份标签五个视觉层次,并通过每秒翻转的呼吸动画状态驱动 Canvas 重绘,实现数据点半径脉动和折线光晕的动态视觉效果。这种纯代码绘制方式无需引入任何第三方图表库,既控制了应用的包体积,又保证了绘制精度和自定义灵活性。

在这里插入图片描述

从业务场景来看,"素材星球"定位为创意设计素材下载平台,面向设计师和创意工作者提供原创商用授权素材的分发与下载服务。应用设计了 4 个功能维度:素材 Tab 展示精选素材包渐变大卡、本周精选设计师横滑卡、大编号热门素材榜以及 Canvas 下载量趋势折线图,是应用的内容发现与浏览入口;网页 Tab 是 ArkWeb 特性页,提供地址栏输入、快捷站点横滑、Web 组件网页浏览和应用侧主动下载演示,让用户在应用内即可浏览素材站点并触发下载;下载 Tab 展示进行中下载任务的实时进度条和已完成记录的双 URL 溯源列表,每条记录都完整展示原始 URL 和引用页 URL 两条溯源信息;我的 Tab 提供设计师等级渐变大卡、素材统计行和功能清单,突出用户等级和版权存证等增值能力。整体设计采用浅色主题——云白底色(#F6F7FB)搭配珊瑚橙(#FF7A59)和靛蓝(#4A5DE8)的双主色系,营造出明亮、清新、创意感十足的视觉氛围。

在这里插入图片描述

下面,我们将从代码的第一行开始,逐段、逐块地深入分析这个设计素材下载平台的完整技术实现,重点解读 ArkWeb 双 URL 下载溯源特性的集成方式和 Canvas 趋势图的绘制细节。

在这里插入图片描述

二、整体架构流程图

弹窗系统

底部导航

头部区域

内容区域_Scroll

Canvas 核心

ArkWeb 核心

数据层

状态管理层

入口组件

素材星球 主页面
@Entry @Component

currentTab: number
当前激活 Tab

breath: boolean
呼吸动画状态(每秒翻转)

addModal / editModal / delModal
三个弹窗开关

cateIdx / editIdx / delIdx
分类与记录操作索引

packList / downloadRecords / statList
三大列表数据

formUrl / formNote / editNote
表单输入值

canvasReady: boolean
Canvas 就绪标志

urlInput / webUrl
地址栏双状态分离

dlName / dlPercent / dlState
下载任务实时状态

ColorPalette 色彩体系
16 个颜色常量

TAB_LIST / CATE_TAGS
导航与分类常量

FEATURED_PACKS / QUICK_SITES
精选素材包与快捷站点

DOWNLOAD_TREND
折线图 6 月数据

4 个 @Observed 数据模型
PackItem / DownloadRecord
UserStat / CreatorItem

PACK_LIST / DOWNLOAD_RECORDS
STAT_LIST / CREATORS
4 组 Mock 数据数组

3 个工具函数
trendColor / trendIcon / siteHost

webController
WebviewController 控制器

downloadDelegate
WebDownloadDelegate 下载代理

setupDownloadDelegate
四回调注册 + 双URL溯源

loadUrl
地址栏加载

triggerDownload
应用侧主动下载

lineCtx
CanvasRenderingContext2D

drawLineChart
五层折线图绘制

tabMaterial
素材Tab

tabWeb
网页Tab

tabDownload
下载Tab

tabMine
我的Tab

headerMain
渐变Banner+搜索条+分类chips

tabBar
4 Tab 单排

modalOverlay
全屏遮罩

panelAdd
新建素材下载弹窗

panelEdit
编辑备注弹窗

panelDel
删除确认弹窗

从上述架构流程图可以清晰地看到,整个应用以主页面组件为核心枢纽,向下连接了状态管理层、数据层、ArkWeb 核心、Canvas 核心、内容区域、头部区域、底部导航和弹窗系统八大子系统。状态管理通过 @State 装饰器实现响应式绑定,其中 currentTab 驱动 Tab 切换,breath 驱动呼吸动画和 Canvas 重绘,urlInput/webUrl 双状态分离实现了"输入不等于加载"的安全设计。ArkWeb 核心层通过 setupDownloadDelegate 方法注册四个下载回调,在完成回调中调用 6.1.1 新增的 getOriginalUrlgetReferrerUrl 双接口实现溯源。Canvas 核心层的 drawLineChart 方法受 breathcanvasReady 双状态驱动,每秒重绘实现动态折线图。4 个 Tab 的内容区域通过 if/else if 条件链根据 currentTab 动态切换,弹窗系统通过三个布尔状态变量按需挂载到 Stack 容器中。


三、文件头部注释与设计意图

/**
 * =====================================================================
 * 素材星球 · 设计素材下载平台(现代行业:创意设计素材市场)浅色主题
 * 头部样式:顶部渐变 Banner(品牌slogan+本周素材数)+ 搜索条 + 横滑分类 chips
 * 布局风格:4 个 Tab 每个布局完全不同
 *   素材=精选素材包渐变大卡+精选设计师横滑+大编号热门素材榜+Canvas下载量趋势折线图
 *   网页=ArkWeb 特性页(地址栏+Web组件+主动下载)/ 下载=下载管理(进度+双URL溯源)
 *   我的=设计师等级渐变大卡+素材统计+功能清单行
 * ArkWeb 特性(HarmonyOS 6.1.1 新特性):WebDownloadDelegate 下载完成回调新增
 *   getOriginalUrl(下载项原始 URL 地址)/ getReferrerUrl(引用页 URL 地址)
 *   双接口让每次素材下载都能追溯"从哪个页面、下载的哪个直链",来源信息完整
 * Canvas 图表:素材 Tab 近 6 个月素材下载量趋势折线图 drawLineChart()
 *   (moveTo/lineTo 连续折线 + 珊瑚橙渐变填充 + 数据点圆点 + 底部月份标签,呼吸动画联动重绘)
 * 弹窗系统:modalOverlay 全屏遮罩 + panelAdd 新建素材下载 / panelEdit 编辑备注 / panelDel 删除确认
 * 底部 4 Tab 单排,主题:云白底(#F6F7FB) + 珊瑚橙(#FF7A59) + 靛蓝(#4A5DE8)
 * =====================================================================
 */

这段文件头部注释扮演着"架构设计文档"的角色,在团队协作中为后续开发者提供了快速理解整个文件设计意图的入口。我们来逐段解读:

应用定位与行业:第一行明确给出了应用名称"素材星球"和所属行业"创意设计素材市场"。创意设计素材市场是一个连接素材创作者与素材使用者的双边平台——创作者上传原创素材包(图标、背景、插画、样机、PPT模板等),使用者付费下载并获取商用授权。这一行业对版权追溯有天然的高需求,每一次下载的来源信息都是版权审计的重要依据。

头部样式描述:头部区域包含三个信息维度——渐变 Banner(品牌 slogan + 本周上新素材数)、搜索条和横滑分类 chips。渐变 Banner 采用珊瑚橙到靛蓝的对角渐变,是整个浅色页面中的视觉焦点。搜索条同时承载搜索素材和粘贴下载链接两个功能入口。分类 chips 横滑列表覆盖了平面、UI 插画、图标、样机、PPT、背景六大素材分类。

4 个 Tab 的布局风格:这是整个应用最核心的设计理念——每个 Tab 都采用完全不同的布局方式。素材 Tab 是内容发现入口,组合了渐变大卡、设计师横滑、大编号榜单和 Canvas 折线图四种布局形式,信息密度最高;网页 Tab 是 ArkWeb 特性页,以地址栏 + Web 组件 + 主动下载演示的纵向布局为主;下载 Tab 是下载管理中心,以进度条 + 代码预览 + 双 URL 溯源列表的纵向布局为主;我的 Tab 是用户中心,以渐变等级卡 + 功能清单行的纵向布局为主。

ArkWeb 特性描述:这段注释精确指出了 HarmonyOS 6.1.1 的关键新特性——WebDownloadDelegateonDownloadFinish 回调中新增 getOriginalUrlgetReferrerUrl 两个接口。getOriginalUrl 返回下载项的原始直链 URL 地址(即 CDN 上的实际文件路径),getReferrerUrl 返回引用页 URL 地址(即用户触发下载的来源页面)。双接口的配合让"从哪个页面、下载的哪个直链"这条溯源链路完整可查。

Canvas 图表描述:指明素材 Tab 使用 drawLineChart() 方法绘制近 6 个月素材下载量趋势折线图,绘制要素包括 moveTo/lineTo 连续折线、珊瑚橙渐变填充、数据点圆点和底部月份标签,并通过呼吸动画联动重绘实现动态视觉效果。

弹窗系统与底部 Tab:弹窗系统使用 modalOverlay 全屏遮罩作为统一遮罩层,三个弹窗分别对应新建下载、编辑备注和删除确认三个核心操作。底部采用单排 4 Tab 设计,主题色为云白底(#F6F7FB)+ 珊瑚橙(#FF7A59)+ 靛蓝(#4A5DE8),营造明亮的创意感。


四、模块导入与 Kit 依赖

import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';

这两行导入语句揭示了应用所依赖的全部系统能力套件,每一行都承载着特定的技术职责。

4.1 ArkWeb 导入

第一行从 @kit.ArkWeb 导入了 webview 模块。这是整个应用的核心依赖——所有与 Web 组件控制、下载代理管理相关的功能都由这个模块提供。webview 是一个命名空间对象,内部包含了 WebviewController(Web 控制器)、WebDownloadDelegate(下载代理)、WebDownloadItem(下载项)三个核心类。在本应用中,WebviewController 被用于加载网页和绑定下载代理,WebDownloadDelegate 被用于注册四个下载生命周期回调,WebDownloadItem 作为回调参数传递下载项的当前状态信息和操作方法。特别是 WebDownloadItem 在 6.1.1 版本新增了 getOriginalUrl()getReferrerUrl() 两个方法,这是本应用双 URL 溯源特性的 API 基础。

4.2 BasicServicesKit 导入

第二行从 @kit.BasicServicesKit 导入了 BusinessError 类型。这是 HarmonyOS 的统一错误类型,所有异步 API 的 catch 回调都接收此类型的参数,包含 code(错误码)和 message(错误消息)两个字段。在本应用中,setDownloadDelegatestartDownload 两个方法都使用了 try-catch 包裹,catch 回调中通过 (error as BusinessError).code(error as BusinessError).message 获取错误详情。这种统一的错误处理模式确保了任何 ArkWeb API 调用异常都能被捕获和记录,不会导致应用崩溃。


五、色彩体系设计(ColorPalette 接口与 COLORS 常量)

5.1 色彩接口定义

/** 主题色板接口:集中声明页面所有颜色字段(云白底+珊瑚橙+靛蓝浅色系) */
interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  coral: string;
  coralD: string;
  indigo: string;
  red: string;
  green: string;
  gold: string;
  line: string;
  tabOn: string;
  mask: string;
  codeBg: string;
}

ColorPalette 接口定义了应用所需的全部 16 个颜色变量,涵盖了背景色、卡片色、芯片色、三级文本色、珊瑚橙系、靛蓝、红、绿、金、线条色、Tab 选中色、遮罩色和代码背景色。这种"一份接口约束全部颜色"的设计方式在大型项目中尤为重要——它确保了任何新增的颜色引用都必须符合接口契约,TypeScript 编译器会在编译期检查类型完整性,避免了散落的硬编码颜色值。

色彩语义的划分值得注意:title 代表主标题色(深炭灰 #23262F),sub 代表副文本色(中灰 #6B7280),text3 代表三级辅助文本色(浅灰 #A6ADBB)——这三级文本色构成了一套从主到次的视觉层次。珊瑚橙系有 coral(标准 #FF7A59)和 coralD(深色变体 #F0562F,用于渐变起点),加上 indigo(靛蓝 #4A5DE8)构成了"珊瑚橙 + 靛蓝"的双主色系。mask 使用半透明白色 rgba(255,255,255,0.72) 作为弹窗遮罩,与浅色主题和谐统一。codeBg 使用深靛蓝色 #2B3170 作为代码预览卡背景,在浅色页面中形成局部深色区块,增强代码可读性。

5.2 色彩常量实例化

/** 浅色主题色板常量(素材星球 · 云白底 + 珊瑚橙 + 靛蓝) */
const COLORS: ColorPalette = {
  bg: '#F6F7FB',
  card: '#FFFFFF',
  chip: '#EEF0F6',
  title: '#23262F',
  sub: '#6B7280',
  text3: '#A6ADBB',
  coral: '#FF7A59',
  coralD: '#F0562F',
  indigo: '#4A5DE8',
  red: '#E5484D',
  green: '#2FA96E',
  gold: '#C77800',
  line: '#E9EBF2',
  tabOn: '#FF7A59',
  mask: 'rgba(255,255,255,0.72)',
  codeBg: '#2B3170'
};

COLORS 常量将 ColorPalette 接口实例化为具体的颜色值。背景色 bg 使用 #F6F7FB——一种带有极淡蓝灰调的云白色,比纯白色 #FFFFFF 更加柔和,长时间阅读不易产生视觉疲劳。卡片色 card 使用纯白 #FFFFFF,在云白底色上形成微妙的层次差异。芯片色 chip 使用 #EEF0F6,是介于背景色和卡片色之间的中间色,用于列表行的交替底色和标签底色。

珊瑚橙 coral 的 RGB 值为 (255, 122, 89),是一种温暖、活泼的橙红色,在浅色背景上具有极高的视觉辨识度,被用于主操作按钮、Tab 选中色、折线图主色和渐变 Banner 的中间过渡色。深珊瑚橙 coralD 的 RGB 值为 (240, 86, 47),是珊瑚橙的加深变体,用于渐变起点,使渐变从深到浅的过渡更加鲜明。靛蓝 indigo 的 RGB 值为 (74, 93, 232),是一种沉稳的蓝紫色,与珊瑚橙形成冷暖对比,用于次级操作按钮、原始 URL 文字色和渐变 Banner 的终点色。

语义色方面,red#E5484D)用于删除操作和趋势上升标记,green#2FA96E)用于成功状态和趋势下降标记,gold#C77800)用于引用页 URL 文字色——三种语义色在浅色背景上都有足够的对比度。line#E9EBF2)用于分割线和边框,是极浅的灰蓝色。tabOn 直接引用 coral 的值,表示 Tab 选中时使用珊瑚橙高亮。


六、常量定义与分类数据

6.1 Tab 元数据接口与导航常量

/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
  icon: string;
  label: string;
}

/** 底部导航 Tab 常量列表(4 Tab 单排:素材/网页/下载/我的) */
const TAB_LIST: TabMeta[] = [
  { icon: '🎨', label: '素材' },
  { icon: '🌐', label: '网页' },
  { icon: '⬇', label: '下载' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了底部导航 Tab 的数据结构,包含 icon(Emoji 图标)和 label(文本标签)两个字段。TAB_LIST 常量数组定义了 4 个 Tab 的元数据——素材 Tab 使用画板图标,代表创意素材内容;网页 Tab 使用地球图标,代表网络浏览能力;下载 Tab 使用下箭头图标,代表下载管理;我的 Tab 使用人像图标,代表用户中心。

使用 Emoji 作为 Tab 图标是一种轻量化的设计选择——无需准备图标资源文件,直接使用 Unicode 字符即可在所有设备上显示一致的视觉效果。4 个 Tab 采用单排布局,每个 Tab 使用 layoutWeight(1) 等宽分布,在主流手机屏幕宽度下每个 Tab 的触控区域约为 90~100dp,完全满足 Material Design 规范中 48dp 最小触控目标的要求。

6.2 分类标签与精选素材包

/** 头部横滑分类 chips 文案(设计素材分类) */
const CATE_TAGS: string[] = ['平面', 'UI插画', '图标', '样机', 'PPT', '背景'];

/** 精选素材包接口(素材 Tab 渐变大卡内展示) */
interface PackRec {
  icon: string;   // 素材包 emoji 图标
  name: string;   // 素材包名
  author: string; // 创作者 / 工作室
  meta: string;   // 推荐理由
}

/** 精选素材包 Mock 数据(3 条) */
const FEATURED_PACKS: PackRec[] = [
  { icon: '🍂', name: '秋日肌理背景包 200 张', author: '拾叶设计部', meta: '编辑精选 · 4K 无损分层' },
  { icon: '🧊', name: '渐变玻璃图标集 480 枚', author: '玻璃岛工作室', meta: '本周热门 · 深浅双主题' },
  { icon: '📱', name: 'UI 组件样机全家桶', author: '像素方糖', meta: 'Figma / Sketch 双源文件' }
];

CATE_TAGS 数组定义了 6 个设计素材分类标签,覆盖了平面设计、UI 插画、图标、样机、PPT 模板和背景图等设计素材市场最主流的分类方向。这些分类标签以横滑 chips 的形式展示在头部区域,选中状态使用珊瑚橙背景 + 白色文字,未选中状态使用白色卡片背景 + 灰色文字,通过颜色对比快速传达当前选中的分类。

PackRec 接口定义了精选素材包的数据结构,包含 Emoji 图标、素材包名、创作者名称和推荐理由四个字段。FEATURED_PACKS 数组提供了 3 条精选素材包的 Mock 数据,分别对应秋日肌理背景包、渐变玻璃图标集和 UI 组件样机全家桶三条精选内容。每条数据都携带创作者信息和推荐理由,帮助用户快速判断素材包的价值和适用场景。使用 Mock 数据而非真实 API 调用,使应用可以在没有后端服务的情况下独立运行和演示。

6.3 快捷站点与折线图数据

/** 快捷站点常量(网页 Tab 横滑入口,真实可访问站点) */
const QUICK_SITES: string[] = [
  'https://developer.huawei.com',
  'https://gitee.com',
  'https://dribbble.com',
  'https://unsplash.com'
];

/** 折线图数据点接口(Canvas 近 6 个月素材下载量趋势) */
interface TrendPoint {
  month: string; // 月份标签
  val: number;   // 下载量(万次)
}

/** 近 6 个月素材下载量 Mock 数据(折线图坐标点) */
const DOWNLOAD_TREND: TrendPoint[] = [
  { month: '03月', val: 86 },
  { month: '04月', val: 102 },
  { month: '05月', val: 95 },
  { month: '06月', val: 128 },
  { month: '07月', val: 146 },
  { month: '08月', val: 168 }
];

/** 折线图卡片右上角单位文案 */
const TREND_UNIT: string = '单位:万次';

QUICK_SITES 数组定义了 4 个真实可访问的快捷站点 URL,分别为华为开发者官网、Gitee 代码托管平台、Dribbble 设计社区和 Unsplash 免费图片站。这些站点都与设计和开发密切相关,用户点击快捷站点即可在 Web 组件中加载对应网页。每个站点 URL 同时作为快捷站点标签的展示文本(通过 siteHost 函数去掉协议前缀后展示域名部分)和 webUrl 状态的比较值——当 webUrl 等于某个站点 URL 时,该站点标签高亮显示。

TrendPoint 接口定义了折线图数据点的结构,包含月份标签和下载量数值两个字段。DOWNLOAD_TREND 数组提供了近 6 个月的下载量 Mock 数据,从 3 月的 86 万次到 8 月的 168 万次,呈现明显的上升趋势。最后一个月(8 月)的 168 万是最高值,也是呼吸动画重点脉动的数据点。TREND_UNIT 常量定义了折线图右上角的单位文案"单位:万次",明确数据量级。


七、辅助函数设计

/** 榜单趋势颜色映射:上升红 / 下降绿 / 持平弱化 */
function trendColor(t: string): string {
  if (t === 'up') { return COLORS.red; }
  if (t === 'down') { return COLORS.green; }
  return COLORS.text3;
}

/** 榜单趋势图标文案:上升 / 下降 / 持平 */
function trendIcon(t: string): string {
  if (t === 'up') { return '↑ 上升'; }
  if (t === 'down') { return '↓ 下降'; }
  return '— 持平';
}

/** 站点 URL 转展示域名(去掉协议前缀,地址栏/快捷站点卡用) */
function siteHost(url: string): string {
  const head = 'https://';
  if (url.startsWith(head)) {
    return url.slice(head.length);
  }
  return url;
}

这三个辅助函数分别处理趋势颜色映射、趋势图标文案和站点域名提取,是连接数据层与 UI 展示层之间的转换桥梁。

7.1 trendColor 趋势颜色映射函数

trendColor 函数接收一个趋势状态字符串('up''down' 或其他),返回对应的颜色值。上升趋势返回红色(COLORS.red),下降趋势返回绿色(COLORS.green),持平返回三级文本灰色(COLORS.text3)。这里的颜色语义与直觉相反——上升用红色而非绿色,是因为在素材下载榜单场景中,下载量上升意味着竞争激烈、热度攀升,是"值得关注的积极信号",使用红色醒目提示;下降用绿色表示"热度回落、趋于平稳",是"需要关注的消极信号"但使用弱化色。这种"红升绿降"的配色方案在股票行情、销售排行等竞品分析场景中是主流设计。

7.2 trendIcon 趋势图标文案函数

trendIcon 函数与 trendColor 配合使用,返回趋势的文字描述——上升返回 '↑ 上升',下降返回 '↓ 下降',持平返回 '— 持平'。箭头符号(↑↓—)配合文字描述,使趋势信息即使不看颜色也能被理解,符合无障碍设计中的"颜色不作为唯一信息载体"原则。

7.3 siteHost 站点域名提取函数

siteHost 函数接收一个完整的 URL 字符串,去掉 https:// 协议前缀后返回剩余的域名和路径部分。这个函数在网页 Tab 的快捷站点标签中使用——完整 URL 太长不适合作为标签文本展示,去掉协议前缀后剩余的 developer.huawei.com 等内容更加简洁可读。函数逻辑简单直接:先检查 URL 是否以 https:// 开头,如果是则使用 slice 截取第 8 个字符之后的内容,否则原样返回。这种处理方式不覆盖 http:// 协议的 URL,在本应用中所有快捷站点都使用 https:// 协议,因此不存在遗漏。


八、数据模型层设计

8.1 热门素材榜数据模型

/** 素材包榜单条目(素材 Tab 大编号热门素材榜) */
@Observed export class PackItem {
  rank: string;    // 名次字符串
  name: string;    // 素材包名
  author: string;  // 创作者 / 工作室
  size: string;    // 素材包大小文本
  heat: string;    // 本周下载量文本
  trend: string;   // 趋势:'up' / 'down' / 'flat'

  constructor(rank: string, name: string, author: string, size: string, heat: string, trend: string) {
    this.rank = rank;
    this.name = name;
    this.author = author;
    this.size = size;
    this.heat = heat;
    this.trend = trend;
  }
}

PackItem 类是素材 Tab 热门素材榜的数据模型,使用 @Observed 装饰器标记为可观察对象。@Observed 的作用是让该类的实例在被 @State 变量引用时,其属性变化能够触发引用它的 ForEach 列表自动重渲染。这对于后续可能涉及编辑、删除操作的列表尤为重要——当某个 PackItem 实例的属性发生变化时,UI 会自动刷新,无需手动触发。

PackItem 包含六个字段:rank(名次,使用字符串类型而非数字,因为名次可能包含特殊格式如"1"、“2"等)、name(素材包名)、author(创作者或工作室名称)、size(素材包文件大小文本,如"86.2 MB”)、heat(本周下载量文本,如"42.1万")和 trend(趋势标记,取值为 'up''down''flat')。构造函数接收全部六个参数并赋值给实例属性,确保每个 PackItem 实例在创建时就是完整的。

8.2 下载记录双 URL 溯源数据模型

/** 下载记录条目(下载 Tab 双 URL 溯源卡:ArkWeb 6.1.1 特性数据载体) */
@Observed export class DownloadRecord {
  fileName: string;     // 文件名
  fileSize: string;     // 大小文本
  finishTime: string;   // 完成时间
  originalUrl: string;  // ★ getOriginalUrl() 结果:下载项原始 URL
  referrerUrl: string;  // ★ getReferrerUrl() 结果:引用页 URL
  note: string;         // 用户备注(可编辑)

  constructor(fileName: string, fileSize: string, finishTime: string,
    originalUrl: string, referrerUrl: string, note: string) {
    this.fileName = fileName;
    this.fileSize = fileSize;
    this.finishTime = finishTime;
    this.originalUrl = originalUrl;
    this.referrerUrl = referrerUrl;
    this.note = note;
  }
}

DownloadRecord 类是本应用最核心的数据模型——它是 ArkWeb 6.1.1 双 URL 溯源特性的数据载体。该类包含六个字段,其中 originalUrlreferrerUrl 两个字段标注了星号注释,直接对应 WebDownloadItemgetOriginalUrl()getReferrerUrl() 两个 6.1.1 新增接口的返回值。

fileName 存储下载文件的名称(由 getSuggestedFileName() 获取),fileSize 存储文件大小文本(由 getTotalBytes() 除以 1048576 转换为 MB 后取整获得),finishTime 存储完成时间文本,originalUrl 存储下载项的原始直链 URL 地址,referrerUrl 存储引用页 URL 地址,note 存储用户自定义备注。note 字段是可编辑的——用户可以通过编辑弹窗修改每条下载记录的备注信息,这使得溯源信息不仅包含系统自动获取的 URL 数据,还包含用户的主观标注,形成"客观数据 + 主观标注"的完整溯源记录。

8.3 用户功能清单与精选设计师数据模型

/** 我的页功能清单条目 */
@Observed export class UserStat {
  icon: string;    // 功能图标
  label: string;   // 功能名
  value: string;   // 状态/数值文本
  arrow: boolean;  // 是否显示右箭头

  constructor(icon: string, label: string, value: string, arrow: boolean) {
    this.icon = icon;
    this.label = label;
    this.value = value;
    this.arrow = arrow;
  }
}

/** 精选设计师条目(素材 Tab 本周精选设计师横滑卡,行业模型) */
@Observed export class CreatorItem {
  avatar: string;  // 头像 emoji
  name: string;    // 设计师昵称
  field: string;   // 擅长领域
  packs: string;   // 在售素材包数文本
  likes: string;   // 获赞数文本

  constructor(avatar: string, name: string, field: string, packs: string, likes: string) {
    this.avatar = avatar;
    this.name = name;
    this.field = field;
    this.packs = packs;
    this.likes = likes;
  }
}

UserStat 类定义了我的 Tab 功能清单的数据结构,包含功能图标、功能名、状态/数值文本和是否显示右箭头四个字段。arrow 布尔字段控制是否在功能行右侧显示 箭头——显示箭头的行表示可点击进入二级页面,不显示的行表示纯展示信息。

CreatorItem 类定义了素材 Tab 本周精选设计师横滑卡的数据结构,包含头像 Emoji、设计师昵称、擅长领域、在售素材包数和获赞数五个字段。这些字段完整描述了一位设计师创作者的画像信息——昵称建立品牌认知,领域帮助用户匹配需求,素材包数和获赞数提供量化评价依据。@Observed 装饰器确保了这些数据模型在状态管理框架中的可观察性。


九、Mock 数据集详解

9.1 热门素材榜 Mock 数据

/** 热门素材榜 Mock 数据(8 条) */
const PACK_LIST: Array<PackItem> = [
  new PackItem('1', '渐变玻璃图标集 480 枚', '玻璃岛工作室', '86.2 MB', '42.1万', 'up'),
  new PackItem('2', '噪点肌理背景包 300 张', '拾叶设计部', '128.4 MB', '38.6万', 'up'),
  new PackItem('3', 'UI 组件样机全家桶', '像素方糖', '96.7 MB', '35.9万', 'flat'),
  new PackItem('4', '极简线条插画合集 Vol.6', '白鹿插画社', '45.3 MB', '29.4万', 'down'),
  new PackItem('5', '商务 PPT 模板 120 套', '云帆演示', '210.5 MB', '26.7万', 'up'),
  new PackItem('6', '手写黑体中文字体包', '墨言造字', '32.8 MB', '22.8万', 'down'),
  new PackItem('7', '3D 图标渲染素材库', '立方体实验室', '158.9 MB', '19.5万', 'up'),
  new PackItem('8', '弥散光渐变背景 60 张', '晨光视觉', '58.2 MB', '16.2万', 'flat')
];

PACK_LIST 数组提供了热门素材榜的 8 条 Mock 数据,每条数据都是一个 PackItem 实例。数据按照本周下载量从高到低排列——第 1 名 42.1 万次到第 8 名 16.2 万次,差距接近 3 倍。趋势字段覆盖了 up(上升)、down(下降)和 flat(持平)三种状态,使榜单呈现出真实的数据波动感。

从素材类型来看,8 条数据覆盖了图标集(渐变玻璃图标集、3D 图标渲染素材库)、背景包(噪点肌理背景包、弥散光渐变背景)、样机(UI 组件样机全家桶)、插画(极简线条插画合集)、PPT 模板(商务 PPT 模板)和字体(手写黑体中文字体包)六大素材分类,与头部分类 chips 完全对应。从创作者来看,玻璃岛工作室、拾叶设计部、像素方糖、白鹿插画社等创作者名称与精选素材包和精选设计师中的数据保持一致,形成了完整的数据生态闭环。

9.2 历史下载记录 Mock 数据

/** 历史下载记录 Mock 数据(6 条,双 URL 均为带域名/路径/参数的完整地址) */
const DOWNLOAD_RECORDS: Array<DownloadRecord> = [
  new DownloadRecord('autumn_texture_200.zip', '386.4 MB', '今天 10:18',
    'https://cdn.suxingqiu.cn/pack/autumn_texture_200_v3.zip',
    'https://www.suxingqiu.cn/pack/detail?id=2048&tab=texture', '编辑精选推荐'),
  new DownloadRecord('glass_icons_480.zip', '86.2 MB', '今天 09:02',
    'https://dl.bolidao.cn/icon/glass_icons_480_v2.1.zip',
    'https://www.bolidao.cn/creator/glass-island?from=rank', '玻璃拟态风格'),
  new DownloadRecord('ui_mockup_bundle.zip', '96.7 MB', '昨天 21:40',
    'https://file.pixelfang.cn/mockup/ui_mockup_bundle_figma.zip',
    'https://www.pixelfang.cn/mockup/phone?device=fold', '样机改稿急用'),
  new DownloadRecord('lineart_vol6.zip', '45.3 MB', '昨天 19:12',
    'https://dl.bailuart.cn/illu/lineart_vol6_full.zip',
    'https://www.bailuart.cn/gallery/line?sort=hot', '公众号封面'),
  new DownloadRecord('biz_ppt_120.zip', '210.5 MB', '昨天 15:33',
    'https://cdn.yunfanshow.cn/ppt/biz_ppt_120_2026.zip',
    'https://www.yunfanshow.cn/ppt/search?q=年终汇报', '年终汇报救急'),
  new DownloadRecord('diffuse_bg_60.zip', '58.2 MB', '08-23 11:07',
    'https://pack.chenguang.vision/bg/diffuse_gradient_60.zip',
    'https://chenguang.vision/bg/collection/gradient', '弥散光背景')
];

DOWNLOAD_RECORDS 数组提供了 6 条历史下载记录的 Mock 数据,这是本应用最核心的展示数据——每条记录都完整包含双 URL 溯源信息。我们以第一条数据为例深入分析:

文件名 autumn_texture_200.zip 是一个语义化的素材包文件名,由 autumn_texture(秋日肌理)+ 200(张数)+ .zip(压缩格式)组成,用户一眼即可判断素材内容。文件大小 386.4 MB 是一个真实的大文件尺寸——高分辨率素材包通常体积较大。完成时间 今天 10:18 使用相对时间格式,比绝对时间戳更友好。

原始 URL https://cdn.suxingqiu.cn/pack/autumn_texture_200_v3.zip 是一个典型的 CDN 直链地址——域名 cdn.suxingqiu.cn 表示这是素材星球 CDN 节点,路径 /pack/autumn_texture_200_v3.zip 表示素材包存储路径,文件名后缀 _v3 表示版本号。引用页 URL https://www.suxingqiu.cn/pack/detail?id=2048&tab=texture 是用户触发下载的来源页面——路径 /pack/detail 表示素材详情页,查询参数 id=2048 标识具体素材包,tab=texture 标识当前选中的标签页。备注 编辑精选推荐 是用户自定义的溯源标注。

六条数据的时间跨度从"今天"到"08-23",文件大小从 45.3 MB 到 386.4 MB,覆盖了肌理背景、图标集、UI 样机、插画、PPT 模板和渐变背景六种素材类型,双 URL 均为带域名、路径和查询参数的完整地址,充分展示了双 URL 溯源特性的数据丰富度。

9.3 功能清单与精选设计师 Mock 数据

/** 我的页功能清单 Mock 数据(8 行) */
const STAT_LIST: Array<UserStat> = [
  new UserStat('⬇', '累计下载', '1,286 个素材', true),
  new UserStat('🔗', '来源审计', '双 URL 溯源已开启', true),
  new UserStat('📁', '下载目录', '/data/storage/files', true),
  new UserStat('💎', '星球会员', '商用授权 8 折', true),
  new UserStat('🕘', '下载历史', '近 30 天 92 条', true),
  new UserStat('🔔', '上新提醒', '已关注 26 位创作者', true),
  new UserStat('🛡', '版权存证', '已存证 18 份授权书', true),
  new UserStat('⚙', '下载偏好设置', '仅 Wi-Fi 下载', true)
];

/** 本周精选设计师 Mock 数据(4 位) */
const CREATORS: Array<CreatorItem> = [
  new CreatorItem('🦊', '玻璃岛', '图标 · 玻璃拟态', '86 套', '2.4万'),
  new CreatorItem('🍃', '拾叶', '肌理 · 背景', '63 套', '1.8万'),
  new CreatorItem('🍬', '像素方糖', 'UI · 样机', '42 套', '1.2万'),
  new CreatorItem('🦌', '白鹿', '线条 · 插画', '58 套', '9,600')
];

STAT_LIST 数组提供了我的 Tab 功能清单的 8 行 Mock 数据,覆盖了累计下载量、来源审计状态、下载目录、会员权益、下载历史、上新提醒、版权存证和下载偏好设置八个功能维度。其中"来源审计"行的值为"双 URL 溯源已开启",直接呼应了 ArkWeb 6.1.1 的核心特性;“版权存证"行的值为"已存证 18 份授权书”,体现了素材市场对版权合规的重视。每行的 arrow 字段都为 true,表示所有功能行都可点击进入详情。

CREATORS 数组提供了素材 Tab 本周精选设计师横滑卡的 4 位 Mock 数据。四位设计师分别擅长图标玻璃拟态、肌理背景、UI 样机和线条插画四个领域,与热门素材榜中的创作者名称一一对应——玻璃岛对应"渐变玻璃图标集",拾叶对应"噪点肌理背景包",像素方糖对应"UI 组件样机全家桶",白鹿对应"极简线条插画合集",形成了跨数据模块的创作者一致性。


十、组件主体与状态管理

@Entry
@Component
struct Page1122 {
  /** 当前选中 Tab 索引 */
  @State currentTab: number = 0;
  /** 呼吸动画开关(每秒翻转,联动折线图与状态文案) */
  @State breath: boolean = false;
  /** 呼吸动画定时器句柄 */
  @State timer: number = -1;
  /** 头部分类 chips 选中索引 */
  @State cateIdx: number = 0;
  /** 新建素材下载弹窗开关 */
  @State addModal: boolean = false;
  /** 编辑备注弹窗开关 */
  @State editModal: boolean = false;
  /** 删除记录确认弹窗开关 */
  @State delModal: boolean = false;
  /** 当前编辑的记录索引 */
  @State editIdx: number = 0;
  /** 当前删除的记录索引 */
  @State delIdx: number = 0;
  /** 热门素材榜数据 */
  @State packList: Array<PackItem> = PACK_LIST;
  /** 下载记录列表数据 */
  @State downloadRecords: Array<DownloadRecord> = DOWNLOAD_RECORDS;
  /** 我的页功能清单数据 */
  @State statList: Array<UserStat> = STAT_LIST;
  /** 新建表单:素材下载链接 URL */
  @State formUrl: string = '';
  /** 新建表单:素材备注 */
  @State formNote: string = '';
  /** 编辑表单:记录备注 */
  @State editNote: string = '';

@Entry 装饰器标记该组件为页面入口,可以被路由系统直接加载。@Component 装饰器声明这是一个自定义组件,拥有独立的构建逻辑和状态管理。struct Page1122 是组件名称。

10.1 Tab 切换与呼吸动画状态

currentTab 是 Tab 切换的核心状态变量,初始值为 0(素材 Tab)。当用户点击底部导航的某个 Tab 时,currentTab 被更新为对应索引,build() 方法中的 if/else if 条件链根据新值渲染对应的 Tab 内容。由于 ArkUI 的响应式机制,只有条件分支变化的部分会被重新渲染,其他 Tab 的内容不会被销毁和重建,保证了切换的流畅性。

breath 是呼吸动画的核心状态变量,初始值为 false。在 aboutToAppear 生命周期中,通过 setInterval 每秒翻转 breath 的值(true ↔ false),驱动两个视觉效果:一是 Canvas 折线图的数据点半径在 3px 和 4px 之间脉动、折线在 2px 和 5px 线宽之间切换光晕效果;二是下载 Tab 的进度条和状态文案的联动更新。timer 存储定时器句柄,在 aboutToDisappear 中用于 clearInterval 清理。

10.2 弹窗与操作索引状态

三个弹窗开关变量 addModaleditModaldelModal 分别控制新建下载、编辑备注和删除确认三个弹窗的显示与隐藏。初始值都为 false,表示页面加载时所有弹窗都不可见。当对应的操作触发时(如点击"新建下载"按钮、点击记录的"改"按钮、点击记录的"删"按钮),对应的弹窗开关被设置为 truebuild() 方法中的条件渲染块检测到该值为 true 后将弹窗组件挂载到 Stack 容器中。

editIdxdelIdx 分别存储当前正在编辑和删除的记录在 downloadRecords 数组中的索引。当用户点击某条记录的"改"按钮时,openEditRecord(idx) 方法将 editIdx 设置为该记录的索引,并从 downloadRecords[editIdx] 中回填备注到 editNote,然后打开编辑弹窗。删除操作类似——delIdx 存储待删除记录的索引,delRecord() 方法通过 splice(delIdx, 1) 从数组中移除该记录。

10.3 列表数据与表单状态

packListdownloadRecordsstatList 三个 @State 变量分别绑定热门素材榜、下载记录列表和功能清单的 Mock 数据。其中 downloadRecords 是最关键的列表——当新的下载完成时,onDownloadFinish 回调通过 this.downloadRecords.unshift(new DownloadRecord(...)) 在数组头部插入新记录,由于 @State 的响应式机制,列表 UI 会自动刷新展示新记录。

formUrlformNote 是新建下载弹窗的两个表单输入值,editNote 是编辑备注弹窗的输入值。这三个变量通过 onChange 回调与 TextInput 组件双向绑定,用户输入时实时更新对应的状态变量。

10.4 Canvas 与 ArkWeb 私有状态

  // --- Canvas 状态(素材 Tab 近 6 个月下载量趋势折线图) ---
  /** 折线图 Canvas 就绪标志 */
  @State canvasReady: boolean = false;
  /** 折线图 Canvas 上下文(private,不用 @State) */
  private lineCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));

  // --- ArkWeb 状态(6.1.1 特性:双 URL 溯源) ---
  /** Web 控制器(加载页面 + 绑定下载代理 + 主动发起下载) */
  private webController: webview.WebviewController = new webview.WebviewController();
  /** 下载代理(四个回调:开始前/进行中/失败/完成) */
  private downloadDelegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
  /** 地址栏输入值(敲字不等于加载) */
  @State urlInput: string = QUICK_SITES[0];
  /** Web 组件实际加载值(点"前往"后才更新) */
  @State webUrl: string = QUICK_SITES[0];
  /** 当前下载文件名 */
  @State dlName: string = '';
  /** 当前下载进度(0~100) */
  @State dlPercent: number = 0;
  /** 下载状态文案 */
  @State dlState: string = '空闲';

canvasReady 是 Canvas 就绪标志,初始值为 false。在 Canvas 组件的 onReady 回调中被设置为 true,此后呼吸动画定时器中的 drawLineChart() 调用才会真正执行。这个标志的设计是为了防止在 Canvas 组件尚未初始化完成时调用绘制方法导致异常。lineCtxCanvasRenderingContext2D 实例,使用 private 修饰符而非 @State——因为 Canvas 上下文是一个引用对象,其内部状态变化不需要触发 ArkUI 的响应式更新(Canvas 的重绘由 drawLineChart() 方法手动调用)。

ArkWeb 相关状态分为两类:private 修饰的控制器和代理对象(webControllerdownloadDelegate),以及 @State 修饰的下载状态变量(dlNamedlPercentdlState)。urlInputwebUrl 的分离设计是本应用的一个亮点——urlInput 绑定地址栏的 TextInput 组件,用户每次敲字都会更新 urlInput,但不会触发 Web 组件加载;只有当用户点击"前往"按钮后,loadUrl() 方法才会将 urlInput 的值赋给 webUrl,Web 组件才会加载新地址。这种"输入不等于加载"的双状态分离设计,避免了用户输入过程中频繁触发网页加载的性能浪费。


十一、ArkWeb 下载代理注册(核心特性)

  /** 注册下载代理:四回调齐全,完成回调中调用 6.1.1 新增双接口 */
  setupDownloadDelegate() {
    // 下载开始前:必须调用 start() 提供沙箱路径,否则任务停在 PENDING
    this.downloadDelegate.onBeforeDownload((item: webview.WebDownloadItem) => {
      const hostCtx = this.getUIContext().getHostContext();
      const dir = hostCtx ? hostCtx.filesDir : '';
      item.start(dir + '/' + item.getSuggestedFileName());
    });
    // 下载进行中:刷新进度条与状态文案
    this.downloadDelegate.onDownloadUpdated((item: webview.WebDownloadItem) => {
      this.dlPercent = item.getPercentComplete();
      this.dlState = '正在下载 ' + item.getPercentComplete() + '%';
    });
    // 下载失败:置失败文案(进度清零)
    this.downloadDelegate.onDownloadFailed((item: webview.WebDownloadItem) => {
      this.dlState = '下载失败 · ' + item.getGuid();
      this.dlPercent = 0;
    });
    // 下载完成:★ 6.1.1 新特性——getOriginalUrl + getReferrerUrl 双溯源
    this.downloadDelegate.onDownloadFinish((item: webview.WebDownloadItem) => {
      const originalUrl: string = item.getOriginalUrl();   // 原始 URL 地址
      const referrerUrl: string = item.getReferrerUrl();   // 引用页 URL 地址
      this.downloadRecords.unshift(new DownloadRecord(
        item.getSuggestedFileName(), Math.round(item.getTotalBytes() / 1048576) + ' MB',
        '刚刚', originalUrl, referrerUrl, '本次会话下载'));
      this.dlState = '下载完成';
      this.dlPercent = 100;
    });
    // 绑定到 controller:网页内触发的下载才会进入上述回调(try-catch 消除抛错告警)
    try {
      this.webController.setDownloadDelegate(this.downloadDelegate);
    } catch (error) {
      console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
    }
  }

setupDownloadDelegate() 方法是整个应用的技术核心——它注册了 WebDownloadDelegate 的四个下载生命周期回调,并在完成回调中调用了 HarmonyOS 6.1.1 新增的双 URL 溯源接口。我们来逐一深入分析每个回调:

11.1 onBeforeDownload 下载开始前回调

onBeforeDownload 回调在下载任务即将开始时被触发,接收一个 WebDownloadItem 参数。在这个回调中,开发者必须调用 item.start(filePath) 方法来提供文件的沙箱保存路径,否则下载任务会一直停留在 PENDING 状态无法开始。

具体实现中,首先通过 this.getUIContext().getHostContext() 获取宿主上下文对象,然后从 hostCtx.filesDir 获取应用沙箱的 files 目录路径。filesDir 是 HarmonyOS 应用沙箱中用于存储持久化文件的目录,应用具有完全的读写权限。将 filesDir 路径与 item.getSuggestedFileName() 返回的建议文件名拼接,形成完整的沙箱文件路径,如 /data/storage/files/autumn_texture_200.zipgetSuggestedFileName() 方法会根据下载项的 URL 和 HTTP 响应头自动推断文件名,确保文件名合理。

这里有一个安全判断 hostCtx ? hostCtx.filesDir : ''——当 getHostContext() 返回 null 时(理论上不应该发生,但在某些异常场景下可能),使用空字符串作为目录,item.start() 会使用默认路径。这种防御性编程确保了即使在异常情况下下载也不会卡住。

11.2 onDownloadUpdated 下载进行中回调

onDownloadUpdated 回调在下载任务进行过程中被周期性触发,接收 WebDownloadItem 参数。在回调中通过 item.getPercentComplete() 获取当前下载进度百分比(0~100),并将其更新到 dlPercentdlState 两个 @State 变量中。

dlPercent 的更新会驱动下载 Tab 的 Progress 进度条组件实时移动——Progress({ value: this.dlPercent, total: 100, type: ProgressType.Linear })value 属性绑定到 dlPercent,当 dlPercent 变化时进度条自动更新。dlState 的更新会驱动进度条下方的状态文案实时变化,如"正在下载 42%"。这种将系统回调直接绑定到 @State 变量的设计,使得下载进度的 UI 展示完全由状态驱动,无需手动调用 UI 更新方法。

11.3 onDownloadFailed 下载失败回调

onDownloadFailed 回调在下载任务失败时被触发,接收 WebDownloadItem 参数。在回调中将 dlState 设置为包含错误标识符的文案 '下载失败 · ' + item.getGuid(),并将 dlPercent 清零。

getGuid() 返回下载项的全局唯一标识符(GUID),这个标识符在下载任务创建时由系统分配,可以用于错误日志追踪和问题排查。将 GUID 附加到失败文案中,使得开发者或用户在遇到下载失败时可以快速定位到具体的下载任务实例。进度清零的设计意图是——失败后进度条归零,视觉上传达"下载未完成、需重新下载"的语义。

11.4 onDownloadFinish 下载完成回调(6.1.1 新特性核心)

onDownloadFinish 回调是整个应用的技术焦点——它在下载任务成功完成时被触发,在此回调中调用了 HarmonyOS 6.1.1 新增的 getOriginalUrl()getReferrerUrl() 两个双 URL 溯源接口。

item.getOriginalUrl() 返回下载项的原始 URL 地址——即文件在网络上的实际存储直链,例如 https://cdn.suxingqiu.cn/pack/autumn_texture_200_v3.zip。这个 URL 直接指向 CDN 上的文件资源,是文件的真实来源。

item.getReferrerUrl() 返回引用页 URL 地址——即用户是从哪个网页发起的下载,例如 https://www.suxingqiu.cn/pack/detail?id=2048&tab=texture。这个 URL 记录了触发下载的来源页面,包含了页面的路径和查询参数,可以精确还原用户的浏览路径。

获取到双 URL 后,代码使用 item.getSuggestedFileName() 获取文件名、Math.round(item.getTotalBytes() / 1048576) + ' MB' 计算文件大小(总字节数除以 1048576 转为 MB 并四舍五入)、'刚刚' 作为完成时间文案、'本次会话下载' 作为默认备注,构造一个完整的 DownloadRecord 实例,并通过 this.downloadRecords.unshift(...) 插入到下载记录数组的头部。

unshift 而非 push 的设计选择——新记录插入到数组头部(即列表最上方),使得最新的下载记录在列表顶部展示,用户无需滚动即可看到最新下载。由于 downloadRecords@State 变量,unshift 操作会触发 ArkUI 的响应式更新,列表 UI 自动刷新展示新记录。

11.5 setDownloadDelegate 绑定与容错

四回调注册完成后,通过 this.webController.setDownloadDelegate(this.downloadDelegate) 将下载代理绑定到 Web 控制器。绑定后,网页内触发的任何下载操作(用户点击网页中的下载链接、网页 JavaScript 触发下载等)都会进入上述四个回调。应用侧主动发起的下载(通过 webController.startDownload(url))也会进入这些回调。

绑定操作使用 try-catch 包裹,catch 回调通过 (error as BusinessError).code(error as BusinessError).message 获取错误详情并输出到控制台。这种容错设计确保了即使 setDownloadDelegate 在某些异常情况下抛出错误(如控制器未初始化、代理已绑定等),应用也不会崩溃,只是下载代理功能不可用。


十二、地址栏加载与主动下载

12.1 地址栏"前往"加载方法

  /** 地址栏"前往":校验协议前缀(无 http(s):// 时自动补 https://)再加载 */
  loadUrl() {
    let url = this.urlInput.trim();
    if (url === '') {
      return;
    }
    if (!url.startsWith('https://') && !url.startsWith('http://')) {
      url = 'https://' + url;
    }
    this.urlInput = url;
    this.webUrl = url;
  }

loadUrl() 方法处理地址栏"前往"按钮的点击逻辑,实现了 URL 校验和协议补全两个关键功能。

首先使用 this.urlInput.trim() 去除输入值的首尾空白字符,防止用户误输入空格导致 URL 无效。如果去除空白后为空字符串,直接 return 不做任何处理——这种"空输入静默返回"的设计避免了向 Web 组件加载空 URL 导致的异常。

接下来进行协议前缀校验——检查 URL 是否以 https://http:// 开头。如果都不是(即用户只输入了域名如 dribbble.com),自动补全 https:// 前缀。默认使用 https:// 而非 http:// 是安全最佳实践——HTTPS 提供加密传输和身份验证,在网络安全意识日益增强的今天应该作为默认选择。

最后将校验后的 URL 同时赋给 urlInput(更新地址栏显示,让用户看到补全后的完整 URL)和 webUrl(触发 Web 组件加载新地址)。webUrl 的更新会触发 Web({ src: this.webUrl, controller: this.webController }) 组件重新加载网页。

12.2 应用侧主动发起下载方法

  /** 应用侧主动发起素材下载(无需网页内点击,try-catch 包裹 BusinessError) */
  triggerDownload(url: string) {
    try {
      this.dlName = url.slice(url.lastIndexOf('/') + 1);
      this.dlPercent = 0;
      this.dlState = '已发起下载请求';
      this.webController.startDownload(url);
    } catch (error) {
      this.dlState = '发起失败 ' + (error as BusinessError).code;
    }
  }

triggerDownload(url) 方法实现了应用侧主动发起素材下载的能力——无需用户在网页内点击下载链接,而是直接从应用代码中调用 webController.startDownload(url) 发起下载。这是 ArkWeb 的一个重要能力——应用可以在任何时机(如用户点击"下载肌理背景包"按钮、用户在新建下载弹窗中提交表单等)主动触发下载任务,这些下载任务同样会进入 WebDownloadDelegate 的四个回调。

方法实现分为三步:首先通过 url.slice(url.lastIndexOf('/') + 1) 从 URL 中提取文件名——lastIndexOf('/') 找到最后一个斜杠的位置,+1 跳过斜杠,slice 截取斜杠后的部分作为文件名,如从 https://cdn.suxingqiu.cn/pack/autumn_texture_200_v3.zip 中提取出 autumn_texture_200_v3.zip。然后重置下载状态——dlPercent 清零、dlState 设置为"已发起下载请求",为下载开始做好准备。最后调用 this.webController.startDownload(url) 正式发起下载。

整个方法体使用 try-catch 包裹,catch 回调将错误码附加到 dlState 文案中(如"发起失败 171001"),让用户和开发者都能从 UI 上看到失败原因。这种"错误码上屏"的设计在调试阶段尤其有价值——开发者无需连接调试器即可从应用界面获取错误信息。


十三、Canvas 趋势折线图绘制(drawLineChart)

  /** 绘制近 6 个月素材下载量趋势折线图(网格 + 渐变填充 + 连续折线 + 数据点 + 月份标签) */
  drawLineChart() {
    const ctx = this.lineCtx;
    const w = 330;      // 画布逻辑宽度
    const h = 190;      // 画布逻辑高度
    const padL = 26;    // 左边距
    const padR = 16;    // 右边距
    const padT = 20;    // 顶边距(留数值标签)
    const padB = 30;    // 底边距(留月份标签)
    const maxVal = 180; // 纵轴上限(万次)
    const stepX = (w - padL - padR) / (DOWNLOAD_TREND.length - 1); // 相邻点横向间距
    ctx.clearRect(0, 0, w, h);

drawLineChart() 方法是 Canvas 数据可视化的核心——它使用 CanvasRenderingContext2D 上下文手动绘制一幅完整的折线图,包含五个视觉层次:背景网格、渐变填充区域、连续折线、数据点圆点和底部月份标签。

13.1 画布参数与坐标系定义

方法首先定义了画布的逻辑宽度和高度(330x190),以及四个方向的边距:左边距 padL = 26(为 Y 轴数值标签留空间)、右边距 padR = 16、顶边距 padT = 20(为数据点顶部的数值标签留空间)和底边距 padB = 30(为月份标签留空间)。

纵轴上限 maxVal = 180 万次——下载量数据的最大值是 168 万(8 月),纵轴上限设为 180 可以确保折线不会贴到画布顶部,同时数据点之间有足够的视觉差异。stepX 计算相邻数据点之间的横向间距,公式为 (w - padL - padR) / (DOWNLOAD_TREND.length - 1)——可用宽度(总宽减去左右边距)除以间隔数(数据点数减一),6 个数据点有 5 个间隔,每个间距为 (330 - 26 - 16) / 5 = 57.6px

ctx.clearRect(0, 0, w, h) 清空画布——在每次重绘前清除上一帧的内容,这是 Canvas 动画的标准操作,否则新旧帧会叠加在一起形成残影。

13.2 背景网格绘制

    // 背景网格:3 条水平参考线
    ctx.strokeStyle = COLORS.line;
    ctx.lineWidth = 1;
    for (let i = 0; i <= 3; i++) {
      const gy = padT + (h - padT - padB) * i / 3;
      ctx.beginPath();
      ctx.moveTo(padL, gy);
      ctx.lineTo(w - padR, gy);
      ctx.stroke();
    }

背景网格由 4 条水平参考线组成(i 从 0 到 3),将图表区域纵向分为 3 等分。每条线的 Y 坐标计算公式为 padT + (h - padT - padB) * i / 3——从顶边距开始,按可用高度(总高减去上下边距)的 1/3、2/3、3/3 处绘制。线条颜色使用 COLORS.line(极浅灰蓝 #E9EBF2),线宽 1px,在浅色背景上形成微妙的参考线,不抢夺折线的视觉焦点。

每条线的绘制使用 beginPath()moveTo()lineTo()stroke() 的标准 Canvas 路径绘制流程。beginPath() 开始新路径,moveTo(padL, gy) 将画笔移动到线条起点(左边距处),lineTo(w - padR, gy) 画一条水平线到右边距处,stroke() 将路径渲染为可见线条。

13.3 渐变填充区域绘制

    // 渐变填充区域:折线与横轴围成的面(珊瑚橙自上而下渐隐)
    const grad = ctx.createLinearGradient(0, padT, 0, h - padB);
    grad.addColorStop(0, 'rgba(255,122,89,0.32)');
    grad.addColorStop(1, 'rgba(255,122,89,0.02)');
    ctx.beginPath();
    ctx.moveTo(padL, h - padB);
    for (let i = 0; i < DOWNLOAD_TREND.length; i++) {
      const x = padL + i * stepX;
      const y = h - padB - (DOWNLOAD_TREND[i].val / maxVal) * (h - padT - padB);
      ctx.lineTo(x, y);
    }
    ctx.lineTo(w - padR, h - padB);
    ctx.closePath();
    ctx.fillStyle = grad;
    ctx.fill();

渐变填充区域是折线图最具视觉冲击力的部分——它在折线与横轴之间填充一个从珊瑚橙到透明的渐变面,营造出"下载量增长的热度区域"的视觉效果。

首先使用 ctx.createLinearGradient(0, padT, 0, h - padB) 创建一个垂直线性渐变对象——起点 Y 为顶边距 padT,终点 Y 为横轴位置 h - padB。通过 addColorStop 设置两个色标:起点(0%)为 rgba(255,122,89,0.32)——珊瑚橙 32% 透明度,终点(100%)为 rgba(255,122,89,0.02)——珊瑚橙 2% 透明度,几乎透明。这种从上到下由浓到淡的渐变,使填充区域在折线附近显眼、在横轴附近自然消失,形成优雅的"光晕投射"效果。

填充区域的路径构建:从横轴左端 (padL, h - padB) 开始,依次 lineTo 到每个数据点的坐标位置,最后回到横轴右端 (w - padR, h - padB),形成闭合路径。数据点的 Y 坐标计算公式为 h - padB - (val / maxVal) * (h - padT - padB)——从横轴位置(h - padB)向上偏移值占比乘以可用高度。值越大,Y 坐标越小(越靠上),折线越高。closePath() 闭合路径,fill() 使用渐变填充。

13.4 连续折线与呼吸光晕绘制

    // 连续折线:moveTo 起点后逐段 lineTo(呼吸态先描一层半透明光晕)
    ctx.beginPath();
    for (let i = 0; i < DOWNLOAD_TREND.length; i++) {
      const x = padL + i * stepX;
      const y = h - padB - (DOWNLOAD_TREND[i].val / maxVal) * (h - padT - padB);
      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    }
    ctx.strokeStyle = COLORS.coral;
    ctx.lineWidth = 2;
    if (this.breath) {
      ctx.globalAlpha = 0.35; // 呼吸态:加粗半透明光晕层
      ctx.lineWidth = 5;
      ctx.stroke();
      ctx.globalAlpha = 1;    // globalAlpha 用后复位为 1
      ctx.lineWidth = 2;
    }
    ctx.stroke();

连续折线是折线图的主体——使用 moveTo 定位起点后逐段 lineTo 连接所有数据点。折线颜色为珊瑚橙 COLORS.coral,线宽 2px。

呼吸光晕效果是本方法的精妙之处——当 this.breathtrue 时(每秒翻转一次),先以 globalAlpha = 0.35(35% 透明度)和 lineWidth = 5(5px 线宽)绘制一层半透明的粗线作为"光晕底层",然后恢复 globalAlpha = 1lineWidth = 2,再绘制正常的折线。这样每秒钟折线会在"普通"和"带光晕"两种状态之间切换,形成折线在"呼吸"的动态视觉效果。

特别值得注意的是 ctx.globalAlpha = 1 的复位操作——globalAlpha 是 Canvas 上下文的全局透明度属性,设置后会影响后续所有绘制操作的透明度。如果在绘制光晕后不复位,后续的数据点和标签也会以 35% 透明度绘制,导致整个图表变淡。这种"用后即复位"的 Canvas 状态管理模式是专业 Canvas 绘制的必备习惯。

13.5 数据点圆点与数值标签绘制

    // 数据点圆点(白底珊瑚描边,半径随呼吸脉动)+ 顶部数值标签
    for (let i = 0; i < DOWNLOAD_TREND.length; i++) {
      const x = padL + i * stepX;
      const y = h - padB - (DOWNLOAD_TREND[i].val / maxVal) * (h - padT - padB);
      ctx.beginPath();
      ctx.arc(x, y, this.breath ? 4 : 3, 0, Math.PI * 2);
      ctx.fillStyle = COLORS.card;
      ctx.fill();
      ctx.strokeStyle = COLORS.coral;
      ctx.lineWidth = 1.5;
      ctx.stroke();
      ctx.fillStyle = COLORS.sub;
      ctx.font = '8px sans-serif';
      ctx.textAlign = 'center';
      ctx.fillText(DOWNLOAD_TREND[i].val + '', x, y - 9);
    }

每个数据点绘制为一个"空心圆"——内部填充白色(COLORS.card,与卡片背景色一致),外部描边珊瑚橙(COLORS.coral),线宽 1.5px。这种设计让数据点在折线和渐变填充区域上清晰可见,白色内填充与珊瑚橙描边形成强烈的色彩对比。圆点半径随呼吸状态在 3px 和 4px 之间脉动——this.breath ? 4 : 3,每秒钟数据点会"放大"1px 再"缩小"1px,与折线光晕同步形成统一的呼吸节奏。

ctx.arc(x, y, radius, 0, Math.PI * 2) 绘制完整圆形——起始角度 0、终止角度 Math.PI * 2(360 度)表示完整圆。fill() 填充圆内部,stroke() 描边圆轮廓。

每个数据点上方还绘制了数值标签——使用 fillText(DOWNLOAD_TREND[i].val + '', x, y - 9) 将下载量数值(如"168")绘制在数据点上方 9px 处。字体为 8px sans-serif,颜色为副文本灰 COLORS.subtextAlign = 'center' 确保文本水平居中对齐于数据点。

13.6 底部月份标签绘制

    // 底部月份标签
    ctx.font = '9px sans-serif';
    ctx.fillStyle = COLORS.text3;
    for (let i = 0; i < DOWNLOAD_TREND.length; i++) {
      const x = padL + i * stepX;
      ctx.fillText(DOWNLOAD_TREND[i].month, x, h - 10);
    }
  }

底部月份标签使用 9px sans-serif 字体,颜色为三级文本灰 COLORS.text3#A6ADBB),是最浅的文字色,确保月份标签不会喧宾夺主。每个月份标签的 X 坐标与对应数据点的 X 坐标一致(padL + i * stepX),Y 坐标为 h - 10(画布底部上方 10px 处),位于底边距区域内。textAlign 未在此处显式设置,但上一段循环中已设置为 'center',因此月份标签也会水平居中对齐于数据点。

整个 drawLineChart() 方法的绘制顺序遵循了"从底到顶"的图层叠放原则:先画背景网格(最底层)→ 渐变填充区域 → 折线光晕 → 折线本体 → 数据点 → 数值标签和月份标签(最顶层),确保后绘制的元素覆盖先绘制的元素,视觉层次清晰。


十四、弹窗业务方法

14.1 打开编辑备注弹窗

  /** 打开编辑备注弹窗(回填当前记录备注) */
  openEditRecord(idx: number) {
    this.editIdx = idx;
    this.editNote = this.downloadRecords[idx].note;
    this.editModal = true;
  }

openEditRecord(idx) 方法处理"编辑备注"操作的初始化逻辑。接收记录索引 idx 作为参数,依次完成三步操作:将 editIdx 设置为该索引(供 updateRecord() 方法使用)、从 downloadRecords[idx].note 回填当前备注到 editNote(让编辑弹窗的 TextInput 显示当前备注内容)、将 editModal 设置为 true 打开编辑弹窗。这种"回填+打开"的模式确保了用户在编辑弹窗中看到的是当前记录的现有备注,而非空白输入。

14.2 保存新建素材下载

  /** 保存新建素材下载(空 URL 兜底默认演示链接,触发 startDownload) */
  saveDownload() {
    const url = this.formUrl === '' ? 'https://cdn.suxingqiu.cn/pack/autumn_texture_200_v3.zip' : this.formUrl;
    this.triggerDownload(url);
    this.formUrl = '';
    this.formNote = '';
    this.addModal = false;
  }

saveDownload() 方法处理新建下载弹窗的"开始下载"按钮逻辑。如果用户未输入 URL(formUrl 为空),使用一个默认的演示链接 https://cdn.suxingqiu.cn/pack/autumn_texture_200_v3.zip 兜底——这种"空值兜底"设计确保即使用户不输入任何内容也能触发下载演示,体现了 Demo 应用的教学友好性。

获取 URL 后调用 this.triggerDownload(url) 发起下载,然后清空表单(formUrl = ''formNote = ''),最后关闭弹窗(addModal = false)。表单清空的设计意图是——每次打开新建弹窗时输入框都是空白状态,避免上一次的残留内容造成混淆。

14.3 保存编辑备注

  /** 保存编辑备注(整体刷新数组引用以刷新列表) */
  updateRecord() {
    if (this.editIdx >= 0 && this.editIdx < this.downloadRecords.length) {
      if (this.editNote !== '') {
        this.downloadRecords[this.editIdx].note = this.editNote;
      }
      this.downloadRecords = this.downloadRecords.slice();
    }
    this.editModal = false;
  }

updateRecord() 方法处理编辑备注弹窗的"保存"按钮逻辑。首先进行边界校验——检查 editIdx 是否在有效范围内(>= 0< length),防止越界访问。如果 editNote 非空,将其赋值给 downloadRecords[editIdx].note,完成备注更新。

关键操作是 this.downloadRecords = this.downloadRecords.slice()——通过 slice() 方法创建数组的浅拷贝并赋值给 downloadRecords 自身。这一步看起来"多余"但实际上是必须的——ArkUI 的 @State 数组变量在元素属性变化时不会自动触发列表重渲染(因为数组引用未变),通过 slice() 创建新数组引用可以强制触发 @State 的响应式更新,使列表 UI 刷新展示新的备注内容。这是 ArkUI 中"数组内容变更触发刷新"的经典技巧。

14.4 删除下载记录

  /** 删除下载记录(确认弹窗回调) */
  delRecord() {
    if (this.delIdx >= 0 && this.delIdx < this.downloadRecords.length) {
      this.downloadRecords.splice(this.delIdx, 1);
    }
    this.delModal = false;
  }

delRecord() 方法处理删除确认弹窗的"删除"按钮逻辑。同样先进行边界校验,然后使用 splice(this.delIdx, 1) 从数组中移除指定索引的记录。splice 方法会直接修改原数组(删除元素并返回被删除的元素),由于 splice 改变了数组的长度和内容,ArkUI 能够检测到这种变化并自动触发列表重渲染,无需像 updateRecord 那样额外使用 slice() 创建新引用。


十五、生命周期管理

  /** 生命周期:绑定下载代理 + 启动呼吸动画定时器(联动 Canvas 重绘) */
  aboutToAppear() {
    this.setupDownloadDelegate();
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      if (this.canvasReady) {
        this.drawLineChart();
      }
    }, 1000);
  }

  /** 生命周期:销毁时清理定时器 */
  aboutToDisappear() {
    clearInterval(this.timer);
  }

15.1 aboutToAppear 组件即将出现

aboutToAppear() 是 ArkUI 组件的生命周期回调,在组件实例创建后、build() 方法执行前被调用。本应用在此方法中完成两个关键初始化:

第一,调用 this.setupDownloadDelegate() 注册下载代理。在组件出现时即注册下载代理,确保后续无论是网页内触发的下载还是应用侧主动发起的下载,都能被下载代理的回调正确处理。如果延迟注册(如延迟到用户首次进入网页 Tab 时),可能导致早期下载操作遗漏。

第二,通过 setInterval(() => {...}, 1000) 启动呼吸动画定时器,每 1000 毫秒(1 秒)执行一次回调。回调中先翻转 breath 状态(this.breath = !this.breath),然后检查 canvasReady 标志——如果 Canvas 已就绪,调用 drawLineChart() 重绘折线图。canvasReady 检查是必要的防御——在组件刚出现的瞬间 Canvas 组件可能尚未触发 onReady 回调,此时调用 drawLineChart() 会因 Canvas 上下文未初始化而异常。定时器句柄存储在 this.timer 中,供 aboutToDisappear 清理使用。

15.2 aboutToDisappear 组件即将销毁

aboutToDisappear() 是 ArkUI 组件的销毁生命周期回调,在组件从组件树中移除前被调用。本应用在此方法中调用 clearInterval(this.timer) 清理呼吸动画定时器。

定时器清理是移动端开发中极易被忽视但又极其重要的操作——如果组件销毁后定时器仍在运行,定时器回调会继续尝试访问已销毁组件的属性和方法,导致内存泄漏和潜在的崩溃。通过在 aboutToDisappear 中清理定时器,确保了组件的生命周期与定时器的生命周期完全同步,不会出现"组件已销毁但定时器仍在运行"的孤儿状态。


十六、页面主构建方法

  /** 页面主构建:Stack 包裹主内容与三层弹窗 */
  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabMaterial()
            } else if (this.currentTab === 1) {
              this.tabWeb()
            } else if (this.currentTab === 2) {
              this.tabDownload()
            } else {
              this.tabMine()
            }
          }
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        this.tabBar()
      }
      .width('100%')
      .height('100%')

      if (this.addModal) {
        this.panelAdd(() => {
          this.addModal = false;
        })
      }
      if (this.editModal) {
        this.panelEdit(() => {
          this.editModal = false;
        })
      }
      if (this.delModal) {
        this.panelDel(() => {
          this.delModal = false;
        })
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor(COLORS.bg)
  }

build() 方法是 ArkUI 组件的核心——它声明了页面的完整 UI 结构。整个页面使用 Stack 作为最外层容器,Stack 的特性是子元素层叠放置(后声明的子元素覆盖在先声明的子元素之上),这种布局方式非常适合"主内容 + 浮层弹窗"的页面结构。

16.1 主内容列布局

Stack 的第一个子元素是一个 Column,它承载了页面的主内容,从上到下依次是:头部区域 headerMain()、分割线 Divider、可滚动内容区域 Scroll 和底部导航 tabBar()

Divider().strokeWidth(1).color(COLORS.line) 在头部和内容区域之间绘制一条 1px 的极浅灰蓝色分割线,在视觉上将头部与内容区域轻微分隔。Scroll 组件包裹了 Tab 内容,并设置了 layoutWeight(1) 使其占据主内容列中除头部和底部导航外的全部剩余空间。scrollBar(BarState.Off) 隐藏了滚动条,使页面在视觉上更加简洁——移动端用户通常通过手势滑动而非滚动条来滚动页面。padding 为内容区域设置了四周内边距,确保内容不会贴着屏幕边缘。

16.2 Tab 条件渲染

内容区域的 Column 内部使用 if / else if / else 条件链根据 currentTab 的值渲染对应的 Tab 内容——0 渲染 tabMaterial()、1 渲染 tabWeb()、2 渲染 tabDownload()、3(else 分支)渲染 tabMine()。这种条件渲染方式确保每次只有一个 Tab 的内容被渲染到组件树中,其他 Tab 的 @Builder 函数不会被调用,实现了按需渲染的性能优化。

16.3 弹窗层叠挂载

Stack 的第二、三、四个子元素是三个条件渲染的弹窗组件。if (this.addModal) 检查新建弹窗开关,为 true 时渲染 panelAdd() 弹窗;if (this.editModal) 检查编辑弹窗开关;if (this.delModal) 检查删除弹窗开关。每个弹窗组件接收一个 onClose 回调函数作为参数,点击弹窗的遮罩或取消按钮时调用该回调将对应的弹窗开关设置为 false,关闭弹窗。

由于弹窗在 Stack 中声明在主内容之后,弹窗会层叠在主内容之上,形成模态遮罩效果。三个弹窗的条件渲染是独立的——理论上可以同时为 true,但在实际交互中只会同时打开一个弹窗(因为打开一个弹窗后用户需要先关闭它才能触发另一个弹窗的打开操作)。


十七、头部区域 Builder

  /** 头部:渐变 Banner(品牌slogan + 本周素材数)+ 搜索条 + 分类 chips */
  @Builder
  headerMain() {
    Column({ space: 12 }) {
      // 渐变 Banner(珊瑚橙 → 靛蓝)
      Column({ space: 6 }) {
        Row() {
          Text('🎨 素材星球').fontSize(17).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('本周上新 2,486 套').fontSize(9).fontColor('rgba(255,255,255,0.82)')
        }
        .width('100%')
        Text('原创商用授权 · 每次下载都知道来路').fontSize(11).fontColor('rgba(255,255,255,0.92)')
        Text('ArkWeb 6.1.1 双 URL 下载内核已上线').fontSize(9).fontColor('rgba(255,255,255,0.66)')
      }
      .width('100%')
      .padding(14)
      .borderRadius(14)
      .linearGradient({ angle: 120, colors: [[COLORS.coralD, 0], [COLORS.coral, 0.6], [COLORS.indigo, 1]] })

      // 搜索条 + 新建下载按钮
      Row({ space: 8 }) {
        Row({ space: 6 }) {
          Text('🔍').fontSize(12)
          Text('搜索素材 / 粘贴下载链接').fontSize(10).fontColor(COLORS.text3)
        }
        .layoutWeight(1).height(34).padding({ left: 10, right: 10 })
        .backgroundColor(COLORS.card).borderRadius(17)
        .onClick(() => {
          this.currentTab = 1;
        })
        Text('+ 新建下载').fontSize(10).fontColor('#FFFFFF')
          .padding({ left: 12, right: 12, top: 9, bottom: 9 })
          .backgroundColor(COLORS.coral).borderRadius(17)
          .onClick(() => {
            this.addModal = true;
          })
      }
      .width('100%')

      // 分类 chips 横滑
      Scroll() {
        Row({ space: 8 }) {
          ForEach(CATE_TAGS, (tag: string, idx: number) => {
            Text(tag).fontSize(10)
              .fontColor(this.cateIdx === idx ? '#FFFFFF' : COLORS.sub)
              .padding({ left: 12, right: 12, top: 6, bottom: 6 })
              .backgroundColor(this.cateIdx === idx ? COLORS.coral : COLORS.card)
              .borderRadius(13)
              .onClick(() => {
                this.cateIdx = idx;
              })
          }, (tag: string) => tag)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12, bottom: 10 })
    .backgroundColor(COLORS.bg)
  }

headerMain() Builder 函数构建了页面的头部区域,从上到下包含三个部分:渐变 Banner、搜索条 + 新建下载按钮、分类 chips 横滑列表。

17.1 渐变 Banner 设计

渐变 Banner 使用 linearGradient 属性设置对角渐变——angle: 120 表示渐变方向为 120 度(从左上到右下),colors 数组定义了三个色标:0% 处为深珊瑚橙 COLORS.coralD#F0562F),60% 处为标准珊瑚橙 COLORS.coral#FF7A59),100% 处为靛蓝 COLORS.indigo#4A5DE8)。这种三色渐变从暖色(珊瑚橙系)过渡到冷色(靛蓝),形成"热情创意→沉稳专业"的视觉语义。

Banner 内部包含三行内容:第一行是品牌名"素材星球"(17px 白色粗体)和本周上新素材数"本周上新 2,486 套"(9px 半透明白色),中间用 Column().layoutWeight(1) 撑开空间使两者分别左右对齐。第二行是品牌 slogan"原创商用授权 · 每次下载都知道来路"(11px),直接传达了应用的核心价值主张——商用授权和下载溯源。第三行是技术标识"ArkWeb 6.1.1 双 URL 下载内核已上线"(9px 更淡的白色),向用户展示应用的技术先进性。

17.2 搜索条与新建下载按钮

搜索条使用白色卡片背景 + 圆角 17px 的胶囊形设计,内含搜索图标和占位文案"搜索素材 / 粘贴下载链接"。点击搜索条会将 currentTab 切换为 1(网页 Tab),让用户在网页 Tab 中搜索或粘贴链接。这种"搜索入口跳转到网页 Tab"的设计逻辑是因为搜索和粘贴链接都需要 Web 组件参与——搜索需要加载搜索引擎页面,粘贴链接需要在地址栏中输入。

“新建下载"按钮使用珊瑚橙背景 + 白色文字的胶囊形设计,点击后打开新建下载弹窗(addModal = true)。按钮文案前的”+"号是移动端"新建"操作的标准视觉符号。

17.3 分类 chips 横滑列表

分类 chips 使用 Scroll 横滑容器包裹 Row 布局的 chips 列表,scrollable(ScrollDirection.Horizontal) 设置横向滚动,scrollBar(BarState.Off) 隐藏滚动条。每个 chip 使用 ForEach 渲染,选中状态(cateIdx === idx)为珊瑚橙背景 + 白色文字,未选中状态为白色卡片背景 + 灰色文字。点击 chip 时将 cateIdx 更新为对应索引,@State 的响应式机制自动更新所有 chip 的样式。

ForEach 的第三个参数是键值生成函数 (tag: string) => tag,使用标签文本本身作为唯一键。键值生成函数在 ArkUI 的列表渲染中至关重要——它帮助框架识别哪些列表项发生了变化,实现最小化的差分更新。


十八、素材 Tab Builder

18.1 素材 Tab 主体结构

  /** 素材 Tab:精选素材包渐变大卡 + 精选设计师横滑 + 热门素材榜 + Canvas 折线图 */
  @Builder
  tabMaterial() {
    Column({ space: 12 }) {
      this.recBanner()
      this.rankCard()
      this.chartCard()
    }
    .width('100%')
  }

tabMaterial() Builder 函数定义了素材 Tab 的整体结构——一个垂直排列的 Column,依次调用三个子 Builder:recBanner()(精选素材包 + 设计师横滑)、rankCard()(热门素材榜)和 chartCard()(Canvas 折线图)。三个子卡片之间通过 space: 12 设置 12px 的间距。这种"一个 Tab = 多个独立卡片"的模块化设计使得每个卡片可以独立开发、测试和替换,维护性极高。

18.2 精选素材包与设计师横滑卡

  /** 精选素材包渐变大卡(3 条素材包 + 本周精选设计师横滑) */
  @Builder
  recBanner() {
    Column({ space: 10 }) {
      Row() {
        Text('✨ 本周精选素材包').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('每周一 10:00 更新').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(FEATURED_PACKS, (rec: PackRec) => {
        Row({ space: 10 }) {
          Text(rec.icon).fontSize(24)
          Column({ space: 3 }) {
            Text(rec.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(rec.author + ' · ' + rec.meta).fontSize(9).fontColor(COLORS.sub)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Text('获取').fontSize(10).fontColor(COLORS.coral)
            .padding({ left: 12, right: 12, top: 6, bottom: 6 })
            .borderRadius(12)
            .border({ width: 1, color: COLORS.coral })
            .onClick(() => {
              this.currentTab = 1;
            })
        }
        .width('100%')
        .padding(11)
        .borderRadius(11)
        .backgroundColor(COLORS.chip)
      }, (rec: PackRec) => rec.name)

      // 本周精选设计师横滑(CreatorItem 行业模型)
      Row() {
        Text('🖌 本周精选设计师').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('关注领 5 元素材券').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Scroll() {
        Row({ space: 8 }) {
          ForEach(CREATORS, (c: CreatorItem) => {
            Column({ space: 4 }) {
              Text(c.avatar).fontSize(22)
              Text(c.name).fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text(c.field).fontSize(8).fontColor(COLORS.sub)
              Text(c.packs + ' · 获赞 ' + c.likes).fontSize(8).fontColor(COLORS.text3)
            }
            .width(88)
            .padding({ top: 10, bottom: 10 })
            .alignItems(HorizontalAlign.Center)
            .backgroundColor(COLORS.chip)
            .borderRadius(10)
          }, (c: CreatorItem) => c.name)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }

recBanner() Builder 函数构建了精选素材包区域,包含三个层次:标题行、精选素材包列表和精选设计师横滑卡。

标题行使用 Row 布局,左侧是"本周精选素材包"标题(13px 粗体深色),右侧是"每周一 10:00 更新"时间标注(9px 浅灰色),中间用 Column().layoutWeight(1) 撑开。这种"标题 + 时间标注"的标题行模式在内容型应用中非常常见。

精选素材包列表使用 ForEach 遍历 FEATURED_PACKS 数组渲染 3 条素材包卡片。每条卡片使用 Row 横向布局——左侧是 24px 的素材包 Emoji 图标,中间是素材包名和创作者 + 推荐理由的纵向文本列(使用 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 确保长文本省略号截断),右侧是"获取"按钮(珊瑚橙描边 + 珊瑚橙文字的胶囊按钮)。点击"获取"按钮跳转到网页 Tab(currentTab = 1),让用户在 Web 组件中浏览素材详情。

精选设计师横滑卡使用 Scroll 横滑容器包裹 4 位设计师卡片。每位设计师卡片宽 88px,内部纵向排列头像 Emoji(22px)、昵称(10px 粗体)、擅长领域(8px)和素材包数 + 获赞数(8px)四行信息,背景为芯片色 COLORS.chip,圆角 10px。横滑设计使得有限的空间内可以展示多位设计师,用户通过左右滑动浏览全部。

18.3 大编号热门素材榜

  /** 大编号热门素材榜(8 条:名次 + 素材包 + 大小 + 周下载 + 趋势) */
  @Builder
  rankCard() {
    Column({ space: 10 }) {
      Row() {
        Text('🏆 热门素材榜').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('按本周下载量').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(this.packList, (p: PackItem, idx: number) => {
        Row({ space: 10 }) {
          Text(p.rank).fontSize(20).fontWeight(FontWeight.Bold)
            .fontColor(idx < 3 ? COLORS.coral : COLORS.text3)
            .width(30)
          Column({ space: 3 }) {
            Text(p.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(p.author + ' · ' + p.size).fontSize(9).fontColor(COLORS.sub)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Column({ space: 2 }) {
            Text('周下载 ' + p.heat).fontSize(9).fontColor(COLORS.sub)
            Text(trendIcon(p.trend)).fontSize(9).fontColor(trendColor(p.trend))
          }
          .alignItems(HorizontalAlign.End)
        }
        .width('100%')
        .padding({ top: 8, bottom: 8 })
        .borderRadius(10)
        .backgroundColor(idx % 2 === 0 ? COLORS.chip : COLORS.card)
      }, (p: PackItem) => p.rank)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }

rankCard() Builder 函数构建了热门素材榜区域,展示 8 条素材包排行。每条排行使用 Row 横向布局,从左到右依次是:大编号名次、素材包信息列和下载量 + 趋势列。

名次文本使用 20px 大字号粗体,前三名(idx < 3)使用珊瑚橙 COLORS.coral 高亮,第四名及以后使用三级灰色 COLORS.text3 弱化。这种"前三名高亮"的设计是排行榜的视觉惯例——用户的注意力天然聚焦在前三名,高亮色彩进一步强化这一聚焦效应。名次列固定宽度 30px,确保所有名次文本左对齐。

素材包信息列使用 layoutWeight(1) 占据中间空间,纵向排列素材包名(12px 粗体)和创作者 + 大小(9px 灰色)。下载量 + 趋势列使用 alignItems(HorizontalAlign.End) 右对齐,纵向排列周下载量文本和趋势图标文案。趋势文案通过 trendColor(p.trend)trendIcon(p.trend) 两个辅助函数动态生成颜色和文案,实现"红升绿降灰持平"的趋势可视化。

奇偶行使用交替背景色——idx % 2 === 0 时为芯片色 COLORS.chip,否则为卡片色 COLORS.card(白色)。这种斑马纹设计在长列表中帮助用户快速定位行数,减少视觉疲劳。

18.4 Canvas 趋势折线图卡片

  /** 图表卡:Canvas 近 6 个月素材下载量趋势折线图(呼吸动画联动重绘) */
  @Builder
  chartCard() {
    Column({ space: 10 }) {
      Row() {
        Text('📈 素材下载量趋势').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text(TREND_UNIT).fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Canvas(this.lineCtx)
        .width('100%')
        .height(190)
        .onReady(() => {
          this.canvasReady = true;
          this.drawLineChart();
        })

      Row() {
        Text('夏季改稿季带动下载量走高').fontSize(9).fontColor(COLORS.sub)
        Column().layoutWeight(1)
        Text('环比 +18.4%').fontSize(9).fontColor(COLORS.green)
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }

chartCard() Builder 函数构建了 Canvas 折线图卡片。卡片顶部是标题行——左侧"素材下载量趋势"标题(13px 粗体),右侧单位文案 TREND_UNIT(“单位:万次”,9px 灰色)。

中间是 Canvas 组件本体——Canvas(this.lineCtx)lineCtx 上下文绑定到 Canvas 组件,width('100%') 使 Canvas 宽度充满卡片,height(190) 设置固定高度 190px(与 drawLineChart() 中的 h = 190 对应)。onReady() 回调在 Canvas 组件初始化完成后被触发——在此回调中将 canvasReady 设置为 true(允许呼吸动画定时器调用 drawLineChart()),并立即调用 drawLineChart() 绘制第一帧图表。

卡片底部是数据解读行——左侧"夏季改稿季带动下载量走高"是趋势的文字解读(9px 副文本灰),右侧"环比 +18.4%"是环比增长率(9px 绿色)。绿色表示正向增长,与折线图的整体上升趋势呼应。这一行将折线图的视觉信息转化为文字解读,帮助用户理解图表含义。


十九、网页 Tab Builder(ArkWeb 特性页)

  /** 网页 Tab:地址栏 + 快捷站点 + Web 组件 + 主动下载(ArkWeb 特性页) */
  @Builder
  tabWeb() {
    Column({ space: 10 }) {
      // 地址栏:输入 + 前往(urlInput/webUrl 双状态分离)
      Row({ space: 8 }) {
        TextInput({ text: this.urlInput, placeholder: '输入网址,如 dribbble.com' })
          .layoutWeight(1).height(38).fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.card).borderRadius(10)
          .onChange((value: string) => {
            this.urlInput = value;
          })
        Text('前往').fontSize(11).fontColor('#FFFFFF')
          .padding({ left: 14, right: 14, top: 10, bottom: 10 })
          .backgroundColor(COLORS.coral).borderRadius(10)
          .onClick(() => {
            this.loadUrl();
          })
      }
      .width('100%')

      // 快捷站点横滑(点击即加载)
      Scroll() {
        Row({ space: 8 }) {
          ForEach(QUICK_SITES, (site: string) => {
            Row({ space: 5 }) {
              Text('🔗').fontSize(10)
              Text(siteHost(site)).fontSize(9)
                .fontColor(this.webUrl === site ? '#FFFFFF' : COLORS.sub)
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(this.webUrl === site ? COLORS.coral : COLORS.card)
            .borderRadius(12)
            .onClick(() => {
              this.urlInput = site;
              this.webUrl = site;
            })
          }, (site: string) => site)
        }
      }
      .scrollable(ScrollDirection.Horizontal)
      .scrollBar(BarState.Off)
      .width('100%')

      // Web 组件本体:网页内点击素材下载链接自动进入 delegate 回调
      Web({ src: this.webUrl, controller: this.webController })
        .layoutWeight(1)
        .width('100%')
        .borderRadius(10)
        .backgroundColor(COLORS.card)

      // 主动下载演示行(应用侧 startDownload 触发)
      Column({ space: 8 }) {
        Row() {
          Text('🧪 应用侧主动下载演示').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text(this.dlState).fontSize(9).fontColor(this.dlState === '下载完成' ? COLORS.green : COLORS.coral)
        }
        .width('100%')
        Row({ space: 10 }) {
          Text('下载肌理背景包').fontSize(10).fontColor('#FFFFFF')
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.coral).borderRadius(9)
            .onClick(() => {
              this.triggerDownload('https://cdn.suxingqiu.cn/pack/autumn_texture_200_v3.zip');
            })
          Text('下载玻璃图标集').fontSize(10).fontColor(COLORS.indigo)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 })
            .borderRadius(9).border({ width: 1, color: COLORS.indigo })
            .onClick(() => {
              this.triggerDownload('https://dl.bolidao.cn/icon/glass_icons_480_v2.1.zip');
            })
        }
        .width('100%')
        Text('提示:网页内点击素材下载链接同样会触发 WebDownloadDelegate 四回调').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
    }
    .width('100%')
    .height('100%')
  }

tabWeb() Builder 函数构建了网页 Tab——这是整个应用的 ArkWeb 特性页,集中展示了 Web 组件的加载、浏览和下载能力。页面从上到下分为四个区域:地址栏、快捷站点横滑、Web 组件本体和主动下载演示行。

19.1 地址栏与双状态分离

地址栏由 TextInput 输入框和"前往"按钮组成。TextInput 绑定 urlInput 状态变量,onChange 回调在用户每次输入时更新 urlInput。但关键在于——urlInput 的更新不会触发 Web 组件加载,只有点击"前往"按钮调用 loadUrl() 方法后,urlInput 的值才会被校验、补全协议后赋给 webUrl,Web 组件才会加载新地址。

这种"输入状态"与"加载状态"的双状态分离设计是本应用的一个工程亮点。如果使用单一状态变量,用户每次敲一个字符都会触发 Web 组件重新加载网页,不仅造成性能浪费,还会导致网页频繁闪烁。双状态分离后,用户可以自由输入而不影响当前网页浏览,只有明确点击"前往"后才发起加载,符合用户的操作预期。

19.2 快捷站点横滑

快捷站点横滑卡展示 4 个真实可访问站点,使用 ForEach 遍历 QUICK_SITES 数组渲染。每个站点标签使用 siteHost(site) 函数去掉 https:// 协议前缀后展示域名部分。当前正在浏览的站点(webUrl === site)使用珊瑚橙背景 + 白色文字高亮,其他站点使用白色背景 + 灰色文字。点击站点标签时同时更新 urlInputwebUrl——urlInput 的更新让地址栏显示该站点 URL,webUrl 的更新触发 Web 组件加载该站点。

19.3 Web 组件本体

Web({ src: this.webUrl, controller: this.webController }) 是 ArkWeb 的核心组件——src 属性绑定 webUrl 状态变量,当 webUrl 变化时 Web 组件自动加载新地址;controller 属性绑定之前创建的 webController 实例,通过控制器可以调用 startDownloadsetDownloadDelegate 等方法。Web 组件使用 layoutWeight(1) 占据地址栏和主动下载演示行之间的全部剩余空间,borderRadius(10) 设置圆角,backgroundColor(COLORS.card) 设置白色背景。

Web 组件是网页内下载的触发入口——当用户在加载的网页中点击素材下载链接时,浏览器引擎会自动将下载任务交给已注册的 WebDownloadDelegate 处理,依次触发 onBeforeDownloadonDownloadUpdatedonDownloadFinish(或 onDownloadFailed)回调链。这意味着用户无需离开应用即可在应用内浏览素材站点并下载素材,下载过程完全由应用接管。

19.4 主动下载演示行

主动下载演示行展示了应用侧主动发起下载的能力——两个按钮分别对应"下载肌理背景包"和"下载玻璃图标集",点击时调用 triggerDownload(url) 方法传入对应的素材直链 URL。

"下载肌理背景包"按钮使用珊瑚橙背景 + 白色文字的实心样式(主操作按钮),"下载玻璃图标集"按钮使用靛蓝描边 + 靛蓝文字的空心样式(次操作按钮)。两种按钮样式的对比设计在移动端 UI 规范中非常常见——实心按钮引导主操作,空心按钮提供备选操作。两个按钮都使用 layoutWeight(1) + textAlign(TextAlign.Center) 等宽居中排列。

按钮上方是状态行——左侧"应用侧主动下载演示"标题,右侧 dlState 状态文案。dlState 的颜色根据状态动态变化——当状态为"下载完成"时使用绿色,其他状态使用珊瑚橙。这种"状态色彩语义化"的设计让用户一眼即可判断下载是否成功。

底部提示文案"网页内点击素材下载链接同样会触发 WebDownloadDelegate 四回调"向用户说明了一个重要信息——无论是应用侧主动发起的下载还是网页内触发的下载,都会经过相同的下载代理回调链,确保溯源信息的一致性和完整性。


二十、下载 Tab Builder(双 URL 溯源展示)

  /** 下载 Tab:进行中任务卡 + 完成记录双 URL 溯源列表 + 代码预览卡 */
  @Builder
  tabDownload() {
    Column({ space: 12 }) {
      // 进行中任务卡(进度条 + 状态)
      Column({ space: 10 }) {
        Row() {
          Text('⬇ 下载任务').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text(this.dlState).fontSize(9).fontColor(COLORS.coral)
        }
        .width('100%')
        Text(this.dlName === '' ? '暂无进行中任务(可在网页 Tab 触发)' : this.dlName)
          .fontSize(10).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Progress({ value: this.dlPercent, total: 100, type: ProgressType.Linear })
          .width('100%').height(6)
          .color(COLORS.coral).backgroundColor(COLORS.chip)
        Row() {
          Text('进度 ' + this.dlPercent + '%').fontSize(9).fontColor(COLORS.sub)
          Column().layoutWeight(1)
          Text('保存至沙箱 filesDir').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

tabDownload() Builder 函数构建了下载 Tab——这是双 URL 溯源特性的核心展示页。页面从上到下分为三个区域:进行中任务卡、onDownloadFinish 代码预览卡和历史下载记录列表。

20.1 进行中下载任务卡

进行中任务卡展示当前下载任务的实时状态。卡片顶部是标题行——左侧"下载任务"标题,右侧 dlState 状态文案(珊瑚橙色)。中间是文件名文本——当 dlName 为空时显示"暂无进行中任务(可在网页 Tab 触发)"的占位文案,否则显示当前下载的文件名。文件名使用 maxLines(1)textOverflow({ overflow: TextOverflow.Ellipsis }) 确保长文件名省略号截断,不破坏卡片布局。

Progress 组件是 ArkUI 内置的进度条组件——value 绑定 dlPercent(当前进度 0~100),total 设置为 100(满进度值),type 设置为 ProgressType.Linear(线性进度条)。进度条高度 6px,前景色珊瑚橙 COLORS.coral,背景色芯片色 COLORS.chip。当 dlPercentonDownloadUpdated 回调中被更新时,进度条自动移动,无需手动调用 UI 更新方法——这是 @State 响应式绑定的直接优势。

进度条下方是辅助信息行——左侧"进度 X%"实时显示百分比数值,右侧"保存至沙箱 filesDir"提示文件保存位置。filesDir 的提示让用户了解下载文件存储在应用沙箱目录中,而非公共下载目录——这是 HarmonyOS 应用沙箱安全模型的体现。

20.2 onDownloadFinish 代码预览卡

      // onDownloadFinish 代码预览卡(体现 6.1.1 新增双接口)
      Column({ space: 6 }) {
        Text('⌨️ onDownloadFinish 回调(HarmonyOS 6.1.1 新增)').fontSize(12)
          .fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column({ space: 4 }) {
          Text('this.downloadDelegate.onDownloadFinish(').fontSize(9).fontFamily('monospace').fontColor('#C9CFF6')
          Text('  (item: webview.WebDownloadItem) => {').fontSize(9).fontFamily('monospace').fontColor('#C9CFF6')
          Text('    const original = item.getOriginalUrl();').fontSize(9).fontFamily('monospace').fontColor('#FF9E7C')
          Text('    const referrer = item.getReferrerUrl();').fontSize(9).fontFamily('monospace').fontColor('#FFD489')
          Text('  });').fontSize(9).fontFamily('monospace').fontColor('#C9CFF6')
        }
        .width('100%').padding(10).borderRadius(8).backgroundColor(COLORS.codeBg)
        Text('原始 URL 追踪素材直链来源,引用页 URL 记录触发下载的页面').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

代码预览卡是本应用的教学设计亮点——它在 UI 中直接展示了 onDownloadFinish 回调的代码片段,让用户直观了解双 URL 溯源特性的 API 调用方式。卡片使用深靛蓝背景 COLORS.codeBg#2B3170)模拟代码编辑器的深色主题,代码文本使用 fontFamily('monospace') 等宽字体。

代码片段的每行使用不同的颜色高亮——函数签名行 this.downloadDelegate.onDownloadFinish( 和参数行使用淡蓝灰色 #C9CFF6getOriginalUrl() 调用行使用珊瑚橙浅色 #FF9E7C(与原始 URL 的展示颜色呼应),getReferrerUrl() 调用行使用金黄色 #FFD489(与引用页 URL 的展示颜色呼应)。这种"代码高亮色与数据展示色一致"的设计,帮助用户建立"API 调用→数据展示"的视觉关联。

卡片底部是功能说明文案"原始 URL 追踪素材直链来源,引用页 URL 记录触发下载的页面",用一句话概括了双 URL 溯源的核心价值。

20.3 历史下载记录列表(双 URL 溯源展示)

      // 已完成记录列表(每条含双 URL 溯源信息)
      Column({ space: 10 }) {
        Row() {
          Text('🗂 历史下载记录').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text(this.downloadRecords.length + ' 条').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')

        ForEach(this.downloadRecords, (rec: DownloadRecord, idx: number) => {
          Column({ space: 6 }) {
            Row({ space: 8 }) {
              Text('📦').fontSize(14)
              Column({ space: 2 }) {
                Text(rec.fileName).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(rec.fileSize + ' · ' + rec.finishTime + ' · ' + rec.note).fontSize(9).fontColor(COLORS.sub)
                  .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              }
              .layoutWeight(1).alignItems(HorizontalAlign.Start)
              Text('改').fontSize(9).fontColor(COLORS.sub)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor(COLORS.chip).borderRadius(8)
                .onClick(() => {
                  this.openEditRecord(idx);
                })
              Text('删').fontSize(9).fontColor(COLORS.red)
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .backgroundColor(COLORS.chip).borderRadius(8)
                .onClick(() => {
                  this.delIdx = idx;
                  this.delModal = true;
                })
            }
            .width('100%')

            // ★ 原始 URL 溯源行(getOriginalUrl 结果)
            Row({ space: 6 }) {
              Text('🔗').fontSize(9)
              Text(rec.originalUrl).fontSize(8).fontFamily('monospace').fontColor(COLORS.indigo)
                .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width('100%')

            // ★ 引用页 URL 溯源行(getReferrerUrl 结果)
            Row({ space: 6 }) {
              Text('📄').fontSize(9)
              Text(rec.referrerUrl).fontSize(8).fontFamily('monospace').fontColor(COLORS.gold)
                .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .width('100%')
          }
          .width('100%').padding(11).borderRadius(11).backgroundColor(COLORS.chip)
        }, (rec: DownloadRecord) => rec.fileName)
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
    }
    .width('100%')
  }

历史下载记录列表是双 URL 溯源特性的最终展示界面——每条记录都完整展示文件信息和双 URL 溯源信息。列表使用 ForEach 遍历 downloadRecords 数组渲染,当新下载完成时 onDownloadFinish 回调通过 unshift 在数组头部插入新记录,列表自动刷新展示。

每条记录卡片包含三个层次:

第一层是文件信息行——左侧是📦文件图标(14px),中间是文件名(11px 粗体深色)和文件大小 + 完成时间 + 备注的元信息行(9px 灰色),右侧是"改"和"删"两个操作按钮。"改"按钮使用芯片色背景 + 灰色文字,点击调用 openEditRecord(idx) 打开编辑弹窗;"删"按钮使用芯片色背景 + 红色文字,点击设置 delIdx 并打开删除确认弹窗。文件名和元信息都使用 maxLines(1) + textOverflow 省略号截断,防止长文本破坏布局。

第二层是原始 URL 溯源行——使用🔗图标 + 靛蓝色(COLORS.indigo)等宽字体文本展示 rec.originalUrl。靛蓝色是 getOriginalUrl() 返回值的专属展示色,与代码预览卡中 getOriginalUrl() 行的高亮色(#FF9E7C 珊瑚橙浅色)形成呼应。URL 使用 fontFamily('monospace') 等宽字体展示,更接近 URL 的原始格式,提升可读性。

第三层是引用页 URL 溯源行——使用📄图标 + 金色(COLORS.gold)等宽字体文本展示 rec.referrerUrl。金色是 getReferrerUrl() 返回值的专属展示色,与代码预览卡中 getReferrerUrl() 行的高亮色(#FFD489 金黄色)呼应。

原始 URL 和引用页 URL 使用不同颜色区分,使"直链地址"和"来源页面"两条信息在视觉上易于区分。两层 URL 都使用 layoutWeight(1) + maxLines(1) + textOverflow 省略号截断,在有限空间内展示 URL 的前半部分(通常包含域名和路径,足以辨识来源)。


二十一、我的 Tab Builder

21.1 设计师等级渐变大卡

  /** 我的 Tab:设计师等级渐变大卡 + 素材统计行 + 功能清单行 */
  @Builder
  tabMine() {
    Column({ space: 12 }) {
      // 设计师等级渐变大卡
      Column({ space: 8 }) {
        Row({ space: 12 }) {
          Text('🎨').fontSize(34)
          Column({ space: 4 }) {
            Text('灵感捕手 · 阿栗').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('金星创作者 · 剩余 214 天').fontSize(9).fontColor('rgba(255,255,255,0.85)')
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Start)
          Text('灵感 Lv.6').fontSize(10).fontColor('#FFFFFF')
            .padding({ left: 10, right: 10, top: 5, bottom: 5 })
            .borderRadius(10).backgroundColor('rgba(255,255,255,0.24)')
        }
        .width('100%')
        Row({ space: 8 }) {
          Column({ space: 2 }) {
            Text('1,286').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('累计下载').fontSize(8).fontColor('rgba(255,255,255,0.78)')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text('92').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('本月记录').fontSize(8).fontColor('rgba(255,255,255,0.78)')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text('354').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('获赞素材').fontSize(8).fontColor('rgba(255,255,255,0.78)')
          }
          .layoutWeight(1)
          Column({ space: 2 }) {
            Text('47').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            Text('收藏夹').fontSize(8).fontColor('rgba(255,255,255,0.78)')
          }
          .layoutWeight(1)
        }
        .width('100%')
      }
      .width('100%').padding(16).borderRadius(14)
      .linearGradient({ angle: 135, colors: [[COLORS.indigo, 0], [COLORS.coral, 0.55], [COLORS.coralD, 1]] })

设计师等级渐变大卡是我的 Tab 的视觉焦点——使用 135 度对角渐变(靛蓝→珊瑚橙→深珊瑚橙),与头部 Banner 的渐变方向相反(头部 120 度从珊瑚橙到靛蓝,我的页 135 度从靛蓝到珊瑚橙),形成"入口暖→出口冷→入口冷→出口暖"的色彩呼应。

大卡上半部分是用户身份行——左侧 34px 的大号🎨图标,中间是用户昵称"灵感捕手 · 阿栗"(15px 白色粗体)和会员等级 + 到期时间"金星创作者 · 剩余 214 天"(9px 半透明白色),右侧是等级徽章"灵感 Lv.6"(10px 白色文字 + 半透明白色背景的胶囊标签)。

大卡下半部分是四列统计数据——累计下载 1,286、本月记录 92、获赞素材 354、收藏夹 47。四列使用 layoutWeight(1) 等宽分布,每列纵向排列数值(15px 白色粗体)和标签(8px 半透明白色),形成紧凑的数据仪表盘效果。

21.2 功能清单行

      // 功能清单行
      Column({ space: 0 }) {
        ForEach(this.statList, (st: UserStat) => {
          Row({ space: 10 }) {
            Text(st.icon).fontSize(15)
            Text(st.label).fontSize(11).fontColor(COLORS.title)
              .layoutWeight(1)
            Text(st.value).fontSize(9).fontColor(COLORS.text3)
            if (st.arrow) {
              Text('›').fontSize(14).fontColor(COLORS.text3)
            }
          }
          .width('100%')
          .padding({ top: 11, bottom: 11 })
          .border({ width: { bottom: 1 }, color: COLORS.line })
          .onClick(() => {
            if (st.label === '下载历史') {
              this.currentTab = 2;
            }
          })
        }, (st: UserStat) => st.label)
      }
      .width('100%').padding({ left: 14, right: 14 }).backgroundColor(COLORS.card).borderRadius(12)

      Text('素材星球 v6.1.1 · ArkWeb 双 URL 溯源版').fontSize(8).fontColor(COLORS.text3)
    }
    .width('100%')
  }

功能清单行使用 ForEach 遍历 statList 数组渲染 8 行功能条目。每行使用 Row 横向布局——左侧功能图标(15px Emoji)、中间功能名(11px 深色,layoutWeight(1) 占据中间空间)、右侧状态/数值文本(9px 浅灰色)和可选的右箭头 (14px 浅灰色,仅当 st.arrowtrue 时显示)。

每行底部使用 border({ width: { bottom: 1 }, color: COLORS.line }) 添加 1px 的极浅灰蓝色底边框,形成行间的视觉分隔。最后一行也会显示底边框,虽然视觉上略有冗余,但在实际使用中通常被卡片的圆角裁切隐藏。

功能清单的点击交互实现了跨 Tab 导航——当用户点击"下载历史"行时,onClick 回调检查 st.label === '下载历史',如果匹配则将 currentTab 设置为 2(下载 Tab),实现从我的 Tab 跳转到下载 Tab的导航。这种"功能清单行跳转到对应 Tab"的设计使得功能入口与功能页面之间建立了直接联系。

底部版本号文案"素材星球 v6.1.1 · ArkWeb 双 URL 溯源版"(8px 极浅灰色)标注了应用版本和核心技术特性,是移动端应用"关于"信息的轻量化展示方式。


二十二、底部导航 Builder

  /** 底部导航:4 Tab 单排(选中珊瑚橙高亮 + 图标放大) */
  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
        Column({ space: 3 }) {
          Text(t.icon).fontSize(this.currentTab === idx ? 20 : 17)
            .opacity(this.currentTab === idx ? 1 : 0.65)
          Text(t.label).fontSize(9)
            .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
            .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 7, bottom: 7 })
        .onClick(() => {
          this.currentTab = idx;
        })
      }, (t: TabMeta) => t.label)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: { top: 1 }, color: COLORS.line })
  }

tabBar() Builder 函数构建了底部导航栏。导航栏使用 Row 横向布局 4 个 Tab,每个 Tab 使用 layoutWeight(1) 等宽分布。每个 Tab 内部是 Column 纵向排列图标和文字标签。

选中态的视觉反馈通过三个维度的变化来实现:图标字号从 17px 放大到 20px(this.currentTab === idx ? 20 : 17)、图标透明度从 0.65 提升到 1.0(this.currentTab === idx ? 1 : 0.65)、标签颜色从三级灰色变为珊瑚橙(COLORS.tabOn)、标签字重从 Normal 变为 Bold。这四个维度的变化叠加在一起,使选中 Tab 与未选中 Tab 之间形成鲜明的视觉对比,用户可以一眼识别当前所在页面。

底部导航栏使用白色卡片背景 COLORS.card,顶部使用 border({ width: { top: 1 }, color: COLORS.line }) 添加 1px 的极浅灰蓝色顶边框,在视觉上将 Tab 栏与内容区域分隔。padding({ top: 7, bottom: 7 }) 为每个 Tab 提供上下 7px 的内边距,确保 Tab 的触控区域足够大。


二十三、弹窗系统 Builder

23.1 全屏遮罩

  /** 弹窗全屏遮罩(点击遮罩关闭弹窗) */
  @Builder
  modalOverlay(onClose: () => void) {
    Stack() {
      Column().width('100%').height('100%').backgroundColor(COLORS.mask)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
    .onClick(() => onClose())
  }

modalOverlay(onClose) Builder 函数构建了弹窗的全屏遮罩层。遮罩使用半透明白色背景 COLORS.maskrgba(255,255,255,0.72)),覆盖整个屏幕区域。Stack 容器使用 alignContent(Alignment.Center) 使子元素居中对齐——这意味着后续弹窗面板会放置在遮罩之上并居中显示。

遮罩的 onClick 回调调用 onClose() 函数——点击遮罩任意区域即可关闭弹窗。这种"点击遮罩关闭"的交互模式是模态弹窗的标准设计,用户无需寻找关闭按钮,点击弹窗外的任何区域即可退出。onClose 作为参数传入,使得同一个 modalOverlay 可以被多个弹窗复用,每个弹窗传入自己的关闭逻辑。

23.2 新建素材下载弹窗

  /** 新建素材下载弹窗面板(下载链接 + 备注) */
  @Builder
  panelAdd(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('新建素材下载').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Column({ space: 6 }) {
          Text('素材下载链接').fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formUrl, placeholder: 'https://cdn.suxingqiu.cn/pack/xxx.zip' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formUrl = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text('备注(可选)').fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formNote, placeholder: '如:编辑精选 / 改稿急用' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formNote = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Text('发起后经 startDownload 触发,完成回调记录双 URL').fontSize(8).fontColor(COLORS.text3)

        Row({ space: 10 }) {
          Text('取消').fontSize(12).fontColor(COLORS.sub)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
            .onClick(() => onClose())
          Text('开始下载').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.coral).borderRadius(9)
            .onClick(() => {
              this.saveDownload();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

panelAdd(onClose) Builder 函数构建了新建素材下载弹窗面板。弹窗使用 Stack 包裹遮罩层和面板内容——遮罩层在下,面板内容在上,通过 alignContent(Alignment.Center) 使面板居中显示在遮罩之上。

面板宽度 78%,使用白色卡片背景 + 14px 圆角,内边距 16px。面板内容从上到下依次是:标题"新建素材下载"、素材下载链接输入框、备注输入框、技术提示文案和取消/开始下载按钮行。

两个 TextInput 输入框分别绑定 formUrlformNote 状态变量,使用芯片色背景 + 8px 圆角的轻量化设计。onChange 回调实时更新对应的状态变量。链接输入框的占位文案 https://cdn.suxingqiu.cn/pack/xxx.zip 提供了 URL 格式的输入引导。

技术提示文案"发起后经 startDownload 触发,完成回调记录双 URL"向用户说明了新建下载的完整流程——通过 startDownload 发起下载,完成后在 onDownloadFinish 回调中自动记录双 URL 溯源信息。这条提示将弹窗操作与 ArkWeb 6.1.1 的核心技术特性关联起来。

按钮行使用 Row 横向排列"取消"和"开始下载"两个等宽按钮。"取消"按钮使用芯片色背景 + 灰色文字(次要操作),点击调用 onClose() 关闭弹窗;"开始下载"按钮使用珊瑚橙背景 + 白色粗体文字(主要操作),点击调用 saveDownload() 发起下载。

23.3 编辑备注弹窗

  /** 编辑备注弹窗面板(回填当前记录备注) */
  @Builder
  panelEdit(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('编辑素材备注').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Column({ space: 6 }) {
          Text('记录备注').fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editNote, placeholder: '如:公众号封面 / 改稿急用' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editNote = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Row({ space: 10 }) {
          Text('取消').fontSize(12).fontColor(COLORS.sub)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
            .onClick(() => onClose())
          Text('保存').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.coral).borderRadius(9)
            .onClick(() => {
              this.updateRecord();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

panelEdit(onClose) Builder 函数构建了编辑备注弹窗面板,结构与新建弹窗类似但只有一个输入框——备注输入框绑定 editNote 状态变量。当用户通过 openEditRecord(idx) 打开编辑弹窗时,editNote 已被回填为当前记录的备注值,因此用户在输入框中看到的是现有备注内容而非空白。

按钮行同样使用"取消"+"保存"的等宽按钮设计。“保存"按钮点击调用 updateRecord() 方法,该方法将 editNote 的值写入 downloadRecords[editIdx].note 并通过 slice() 刷新数组引用以触发列表重渲染,然后关闭弹窗。编辑备注功能使得每条下载记录的溯源信息不仅包含系统自动获取的 URL 数据,还包含用户的主观标注(如"公众号封面”"改稿急用"等),形成"客观数据 + 主观标注"的完整溯源记录。

23.4 删除确认弹窗

  /** 删除记录确认弹窗面板 */
  @Builder
  panelDel(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 14 }) {
        Text('🗑').fontSize(30)
        Text('删除这条下载记录?').fontSize(14).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('仅移除记录,不影响已保存到沙箱的素材文件').fontSize(9).fontColor(COLORS.text3)
        Row({ space: 10 }) {
          Text('取消').fontSize(12).fontColor(COLORS.sub)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
            .onClick(() => onClose())
          Text('删除').fontSize(12).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
            .layoutWeight(1).textAlign(TextAlign.Center)
            .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.red).borderRadius(9)
            .onClick(() => {
              this.delRecord();
            })
        }
        .width('100%')
      }
      .width('72%').padding(18).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%')
    .height('100%')
    .alignContent(Alignment.Center)
  }

panelDel(onClose) Builder 函数构建了删除确认弹窗面板。与新建和编辑弹窗不同,删除弹窗不包含任何输入框,仅展示确认信息。

弹窗内容从上到下依次是:30px 的大号🗑垃圾桶图标(视觉警示)、"删除这条下载记录?“确认标题(14px 粗体深色)、说明文案"仅移除记录,不影响已保存到沙箱的素材文件”(9px 浅灰色)和取消/删除按钮行。

说明文案"仅移除记录,不影响已保存到沙箱的素材文件"是一个重要的用户提示——它明确告知用户删除操作只移除溯源记录列表中的条目,不会删除已经保存到应用沙箱 filesDir 目录中的实际素材文件。这种"记录与文件分离"的设计确保了用户的素材资产安全——即使用户误删了溯源记录,已下载的素材文件仍然完好无损地保存在沙箱中。

弹窗宽度 72%(比新建弹窗的 78% 和编辑弹窗的 78% 更窄),营造出更聚焦的"确认对话"视觉感。按钮行中"删除"按钮使用红色背景(COLORS.red)+ 白色粗体文字,与"取消"按钮的芯片色背景 + 灰色文字形成强烈对比——红色在移动端设计规范中代表"破坏性操作",通过色彩警示提醒用户此操作不可撤销。

"删除"按钮点击调用 this.delRecord() 方法,该方法通过 splice(this.delIdx, 1)downloadRecords 数组中移除指定索引的记录,然后关闭弹窗。splice 操作改变了数组长度,ArkUI 的 @State 响应式机制自动检测到变化并刷新列表 UI。


二十四、技术亮点总结

24.1 架构设计层面

  1. 组件化拆分:4 个 Tab 的内容各自封装为独立的 @Builder 函数(tabMaterialtabWebtabDownloadtabMine),弹窗系统也独立封装为三个 @Builder 函数加一个共享的 modalOverlay 遮罩。这种高内聚低耦合的架构使得每个业务模块可以独立开发、测试和迭代。

  2. 声明式条件渲染:通过 if/else if 链和 @State currentTab 实现 Tab 切换,代码逻辑清晰,无需手动管理页面的显示与隐藏。每次只渲染当前激活的 Tab 内容,实现了按需渲染的性能优化。

  3. 单一数据源:所有状态通过 @State 变量管理,UI 完全由状态驱动。下载进度、下载状态、记录列表等核心状态在任何 Tab 中变化时,所有引用它们的组件都会自动更新。

24.2 ArkWeb 双 URL 溯源层面

  1. 完整的下载生命周期管理:通过 WebDownloadDelegate 的四个回调(onBeforeDownloadonDownloadUpdatedonDownloadFailedonDownloadFinish)完整覆盖了下载任务从开始到结束的全生命周期,每个阶段都有对应的 UI 状态反馈。

  2. 双 URL 溯源核心特性:在 onDownloadFinish 回调中调用 HarmonyOS 6.1.1 新增的 getOriginalUrl()getReferrerUrl() 双接口,获取下载项的原始直链 URL 和引用页 URL,构造完整的 DownloadRecord 记录并插入到列表头部。每次素材下载都能追溯"从哪个页面、下载的哪个直链",来源信息完整。

  3. 双触发路径:下载任务既可以在网页内通过用户点击下载链接触发(由 Web 组件自动交给下载代理处理),也可以通过应用侧调用 webController.startDownload(url) 主动发起。两条路径都经过相同的下载代理回调链,确保溯源信息的一致性。

24.3 Canvas 数据可视化层面

  1. 五层折线图:素材 Tab 的 Canvas 折线图包含背景网格、渐变填充区域、连续折线本体、数据点圆点和底部月份标签五个视觉层次,通过纯代码绘制实现了接近专业图表库的视觉效果。

  2. 呼吸动画联动重绘:利用定时器每秒翻转 breath 状态并重新调用 drawLineChart(),实现数据点半径脉动(3px↔4px)和折线光晕切换(2px↔5px 半透明层)的动态效果。这种"状态驱动 Canvas 重绘"的模式是 ArkUI 中 Canvas 动画的经典实现。

  3. 渐变填充与色彩呼应:折线图使用珊瑚橙渐变填充,与应用的主题色完全一致。数据点采用"白底珊瑚描边"的空心圆设计,在折线和渐变区域上清晰可见。

24.4 视觉设计层面

  1. 浅色主题+冷暖对比:整体采用云白底色(#F6F7FB)搭配珊瑚橙(#FF7A59)和靛蓝(#4A5DE8)作为双主色,珊瑚橙代表创意活力,靛蓝代表专业沉稳,冷暖对比鲜明而和谐。

  2. 双渐变 Banner 呼应:头部 Banner 使用珊瑚橙→靛蓝的 120 度渐变,我的页等级卡使用靛蓝→珊瑚橙的 135 度渐变,两个渐变方向相反,形成"入口暖→出口冷→入口冷→出口暖"的色彩呼应。

  3. 色彩语义化:原始 URL 使用靛蓝色展示,引用页 URL 使用金色展示,趋势上升使用红色,趋势下降使用绿色,删除操作使用红色——每种颜色都承载明确的语义,帮助用户快速理解信息类型。

24.5 工程实践层面

  1. 定时器清理:在 aboutToDisappear 生命周期中调用 clearInterval 清理定时器,防止组件销毁后的内存泄漏。

  2. Canvas 就绪标志:使用 canvasReady 布尔标志在定时器中判断 Canvas 是否已初始化,避免在 Canvas 未就绪时调用绘制方法导致异常。

  3. 地址栏双状态分离urlInputwebUrl 分离实现了"输入不等于加载"的安全设计,用户在地址栏输入时不会频繁触发网页加载,只有点击"前往"按钮后才加载新地址。

  4. 数组引用刷新技巧:编辑备注时通过 this.downloadRecords = this.downloadRecords.slice() 创建新数组引用,强制触发 @State 的响应式更新,确保列表 UI 刷新展示新内容。

  5. try-catch 容错降级setDownloadDelegatestartDownload 都使用 try-catch 包裹,catch 回调通过 BusinessError 获取错误详情并输出到控制台或展示在 UI 上,确保任何 ArkWeb API 调用异常都不会导致应用崩溃。


二十五、技术特性对比总览表

维度素材 Tab网页 Tab下载 Tab我的 Tab
布局方式渐变大卡+设计师横滑+大编号榜单+Canvas折线图地址栏+快捷站点横滑+Web组件+主动下载演示进度条+代码预览+双URL溯源列表渐变等级卡+统计行+功能清单行
数据模型PackRec/CreatorItem/PackItem/TrendPointQUICK_SITES常量DownloadRecordUserStat
核心操作浏览精选/查看榜单/查看趋势输入地址/加载网页/触发下载查看进度/编辑备注/删除记录查看等级/跳转功能
动画效果折线图呼吸脉动进度条实时更新
颜色主题珊瑚橙折线+靛蓝/金色URL珊瑚橙按钮+靛蓝描边珊瑚橙进度+靛蓝原始URL+金色引用页URL靛蓝→珊瑚橙渐变
数据量3精选包+4设计师+8榜单+6月趋势4快捷站点6条历史记录+1进行中4统计+8功能行
特殊组件Canvas折线图/Scroll横滑TextInput/Web组件/Scroll横滑Progress进度条/ForEach列表linearGradient渐变
ArkWeb能力Web加载+startDownload主动下载WebDownloadDelegate四回调+双URL溯源
技术亮点Canvas五层绘制+呼吸重绘urlInput/webUrl双状态分离+快捷站点高亮getOriginalUrl/getReferrerUrl双接口+代码预览教学渐变背景+功能行跨Tab跳转

二十六、总结与展望

通过对"素材星球"设计素材下载平台源码的逐段深入分析,我们可以看到这款应用在 HarmonyOS ArkUI 框架下展现了多项核心技术能力,其中最突出的当属 ArkWeb 6.1.1 双 URL 下载溯源特性的完整实现。

首先,ArkWeb 的双 URL 溯源能力体现了下载管理的全新维度。 WebDownloadDelegateonDownloadFinish 回调中调用的 getOriginalUrl()getReferrerUrl() 两个 6.1.1 新增接口,为每一次素材下载提供了完整的来源追溯能力——getOriginalUrl() 返回的原始直链 URL 记录了文件在网络上的实际存储路径(如 CDN 节点上的 zip 文件地址),getReferrerUrl() 返回的引用页 URL 记录了用户是从哪个页面发起的下载(如素材详情页、创作者主页、搜索结果页等)。这两个 URL 的配合使用,使得"从哪个页面、下载的哪个直链"这条溯源链路完整可查,为素材版权审计和下载来源管理提供了系统级的数据支撑。在此之前,移动端的下载管理通常只能获取文件名和文件大小,来源信息会随着浏览器页面跳转而丢失,商用授权素材的版权审计无法闭环。6.1.1 的双 URL 接口从根本上解决了这一长期痛点。

其次,下载代理的四回调生命周期管理体现了系统级下载的完整闭环。 WebDownloadDelegate 通过 onBeforeDownload(开始前提供沙箱路径)、onDownloadUpdated(进行中刷新进度)、onDownloadFailed(失败处理)、onDownloadFinish(完成溯源记录)四个回调完整覆盖了下载任务的全生命周期。特别是 onBeforeDownload 回调中必须调用 item.start(filePath) 提供沙箱保存路径的设计,确保了下载文件被保存到应用可控的沙箱 filesDir 目录中,符合 HarmonyOS 应用沙箱安全模型的要求。下载进度通过 @State dlPercent 变量直接绑定到 Progress 组件,实现了"系统回调→状态变量→UI 组件"的响应式数据流,无需手动调用 UI 更新方法。

再次,Canvas 2D 绘图能力展现了 ArkUI 的数据可视化潜力。 素材 Tab 的折线图通过 CanvasRenderingContext2D 实现了五个视觉层次——背景网格、渐变填充、折线本体、数据点圆点和底部月份标签——全部使用标准 Canvas 2D API 手动绘制。更巧妙的是,通过定时器驱动的 breath 状态联动 drawLineChart() 重绘,实现了数据点半径脉动(3px↔4px)和折线光晕切换(2px↔5px 半透明层)的动态呼吸效果。globalAlpha 属性"用后即复位"的 Canvas 状态管理模式,体现了专业 Canvas 绘制的规范习惯。这种"状态驱动 Canvas 重绘"的模式为 ArkUI 中的动态图表提供了可复用的实现范式。

然后,双触发路径的设计体现了 ArkWeb 下载能力的灵活性。 应用中素材下载有两条触发路径——网页内点击下载链接(由 Web 组件自动交给下载代理处理)和应用侧调用 webController.startDownload(url) 主动发起。两条路径都经过相同的下载代理回调链,确保溯源信息的一致性和完整性。triggerDownload 方法通过 url.slice(url.lastIndexOf('/') + 1) 从 URL 中提取文件名的技巧,以及 loadUrl 方法中"无协议前缀自动补全 https://"的安全校验逻辑,都体现了对 URL 处理的工程化考量。

最后,浅色主题与色彩语义化设计体现了创意素材市场的视觉调性。 应用采用云白底色(#F6F7FB)搭配珊瑚橙(#FF7A59)和靛蓝(#4A5DE8)的双主色系,营造出明亮、清新、创意感十足的视觉氛围。原始 URL 使用靛蓝色展示与 getOriginalUrl() 关联,引用页 URL 使用金色展示与 getReferrerUrl() 关联,趋势上升使用红色、下降使用绿色——每种颜色都承载明确的语义。头部 Banner 和我的页等级卡使用方向相反的渐变设计,形成了"入口暖→出口冷→入口冷→出口暖"的色彩呼应,是应用视觉设计的一大亮点。

从工程优化的角度来看,这款应用还有一些可以进一步改进的方向:

  1. 数据持久化:当前的下载记录列表在应用重启后会重置。实际应用中需要接入 @kit.ArkData 的 Preferences 或关系型数据库实现数据持久化,特别是双 URL 溯源记录需要长期保存以支持版权审计追溯。

  2. 真实网络请求startDownload 发起的下载和 Mock 数据中的 URL 都是预置的。实际应用中需要接入真实素材 CDN 服务,并处理网络异常、断点续传、大文件分片下载等复杂场景。

  3. URL 展示优化:当前原始 URL 和引用页 URL 使用 maxLines(1) + 省略号截断,长 URL 的后半部分不可见。可以增加点击展开/复制功能,让用户查看完整 URL 并支持复制到剪贴板。

  4. Canvas 尺寸自适应:当前折线图的宽度固定为 330px,在不同屏幕尺寸的设备上可能无法充满 Canvas 组件。可以通过 onAreaChange 回调获取实际渲染尺寸,动态调整画布宽度和数据点间距。

  5. 下载分类与筛选:当前下载记录列表是线性排列的。实际应用中可以按素材类型、创作者、时间范围等维度进行分类筛选,并支持按原始 URL 域名或引用页域名分组统计下载来源分布。

  6. 版权存证链路:功能清单中的"版权存证"功能目前只是展示文案。实际应用中可以将双 URL 溯源数据(原始 URL + 引用页 URL + 文件哈希 + 下载时间戳)上链存证,形成不可篡改的版权来源凭证。

总而言之,"素材星球"作为一款基于 HarmonyOS ArkUI 的创意设计素材下载平台 Demo,不仅完整展示了 ArkWeb 6.1.1 双 URL 下载溯源这一核心新特性的端到端实现,还涵盖了 Canvas 数据可视化、Web 组件集成、多 Tab 布局设计、呼吸动画编排、弹窗交互系统、状态管理等多项技术能力。其代码结构清晰、注释详尽、设计理念明确,特别是"每次下载都知道来路"的产品理念与 HarmonyOS 6.1.1 的双 URL 溯源技术特性高度契合,是学习 HarmonyOS 应用开发,特别是 ArkWeb 下载管理和 Canvas 绘图的优秀参考案例。

附录: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.1Release✅ 已安装

界面顶部提示:“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 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

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

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

在这里插入图片描述


三、小结

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

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


本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。

Logo

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

更多推荐