HarmonyOS 6.1.1 ArkWeb双URL溯源新特性实战:从WebDownloadDelegate的getOriginalUrl/getReferrerUrl接口全链路解析
一、技术前言:HarmonyOS ArkUI 与 ArkWeb 下载溯源的技术背景

HarmonyOS 作为华为面向万物互联时代打造的分布式操作系统,其应用开发框架 ArkUI 承载着构建多端一致体验的核心使命。ArkUI 采用声明式开发范式,开发者通过 ArkTS 语言(TypeScript 的超集,专为 HarmonyOS 设备侧应用开发定制)描述界面结构与状态绑定关系,框架底层负责高效的差分渲染与状态驱动更新。在 ArkUI 的声明式模型中,@Entry 标注入口组件,@Component 声明自定义组件,@State 管理组件内部可变状态,@Builder 封装可复用的 UI 构建函数,@Observed 配合 @ObjectLink 实现跨组件的对象级观察。这套状态管理—声明式构建—差分渲染的三层体系,是理解 HarmonyOS 原生应用开发的基础范式。当状态变量发生变化时,ArkUI 框架会自动触发依赖该状态的 Builder 函数重新执行,仅更新发生变化的 UI 节点,避免全量重绘带来的性能开销——这一机制在本应用的呼吸动画联动 Canvas 重绘场景中得到了充分体现。

ArkWeb 是 HarmonyOS 系统内置的 Web 引擎组件,通过 Web 组件与 webview.WebviewController 控制器,开发者可以在原生应用中嵌入完整的 Web 页面渲染能力,实现原生 UI 与 Web 内容的混合开发。Web 组件接收 src(加载地址)与 controller(控制器实例)两个核心参数,支持页面加载、前进后退、JavaScript 交互等标准浏览器能力。更为关键的是,ArkWeb 提供了 WebDownloadDelegate(下载代理)接口,允许应用接管 Web 页面中触发的文件下载行为——当用户在嵌入的 Web 页面中点击下载链接时,系统不会启动默认的浏览器下载流程,而是将下载事件回调给应用注册的代理对象,由应用决定下载的保存路径、监听进度、处理完成与失败。这一机制使原生应用能够完全掌控 Web 内的下载行为,为文件管理、来源追踪、断点续传等高级功能提供了技术基础。

HarmonyOS 6.1.1 版本为 WebDownloadDelegate 的 onDownloadFinish(下载完成)回调新增了两个关键的溯源接口——getOriginalUrl() 与 getReferrerUrl(),这是本次实践的核心技术点。getOriginalUrl() 返回下载项的原始 URL 地址,即文件资源在服务器上的直链地址(如 CDN 上的 PDF 文件直链);getReferrerUrl() 返回触发该下载的引用页 URL 地址,即用户当时浏览的 Web 页面地址(如课程详情页)。这两个接口的组合使用,使得每一份通过应用下载的文件都能完整追溯"从哪个页面下载了哪个直链"的来源链路。在传统的下载实现中,文件保存后往往丢失来源信息,用户事后难以回忆某份资料是从哪个课程页面获取的;而双 URL 溯源能力彻底解决了这一痛点,为学习资料的来源审计、版权追溯、二次获取提供了完整的信息支撑。WebDownloadDelegate 还提供 onBeforeDownload(下载开始前,需调用 item.start() 提供沙箱路径)、onDownloadUpdated(下载进行中,可通过 getPercentComplete() 获取进度)、onDownloadFailed(下载失败)三个回调,与完成回调共同构成完整的下载生命周期管理。

Canvas 绘图是 ArkUI 提供的原生画布组件能力,通过 Canvas 组件接收一个 CanvasRenderingContext2D 上下文对象,开发者可以使用类 HTML5 Canvas 2D API 进行自由图形绘制。Canvas 组件在 onReady 回调触发后表明画布就绪,此时方可调用绘图方法;CanvasRenderingContext2D 提供了 beginPath、moveTo、lineTo、arc、stroke、fill、createLinearGradient 等标准方法,支持路径绘制、圆形绘制、渐变填充、文本渲染等操作。在本应用中,Canvas 被用于绘制近六个月课程资料下载量的趋势折线图,包含背景网格线、渐变面积填充、连续折线、数据点圆点、数值标签与月份标签六个图层。更为巧妙的是,折线图与呼吸动画联动——一个每秒翻转的布尔状态 breath 驱动数据点半径与线宽在两个值之间切换,每秒触发一次 drawLineChart() 重绘,使图表呈现出微妙的"呼吸"动效。这种用定时器翻转布尔值驱动 Canvas 重绘的方案,是声明式 UI 框架下实现画布动画的经典范式。

在线教育与网课资料下载是移动互联网教育赛道中极具实用价值的垂直场景。随着 Coursera、中国大学 MOOC、学堂在线等在线学习平台的普及,学习者面临的核心痛点是:课程配套的讲义、题库、笔记等资料分散在各平台的不同课程页面中,下载后难以追溯来源。一份考研数学讲义可能是从某课程详情页点击下载的,但文件名往往是 kaoyan_math_handout_2027.pdf 这样的无意义字符串,事后用户难以回忆这份资料对应哪门课程、哪个讲师。HarmonyOS 6.1.1 的双 URL 溯源能力恰好直击这一痛点:getOriginalUrl() 记录资料的 CDN 直链地址,getReferrerUrl() 记录课程详情页地址,二者组合即可完整还原"从哪个课程页下载了哪个直链文件"的来源链路。此外,应用还通过 startDownload() 方法支持应用侧主动发起下载(无需用户在 Web 页面内点击链接),配合新建下载任务弹窗,实现了"粘贴链接—发起下载—自动溯源记录"的一站式资料获取流程。

本案例"网课资料宝·课程资料下载平台"正是基于上述技术栈与业务理解构建的完整示例应用。应用采用晨雾白底(#F5F8FC)作为全局底色,叠加青柠绿(#22B573)与天空蓝(#3E9BE8)形成清新明亮的浅色学习主题,营造专注而不沉闷的学习氛围。应用设置四个底部 Tab:资料(本周热门资料卡+大编号资料榜+Canvas月下载趋势折线图)、网页(ArkWeb 特性页,含地址栏+快捷站点+Web组件+主动下载演示)、下载(进行中任务卡+代码预览卡+双URL溯源记录列表)、我的(学习者渐变大卡+学习统计+功能清单行)。同时配套新建下载任务、编辑备注、删除确认三套弹窗系统,通过全屏遮罩+居中面板的模式实现模态交互。整体设计将 ArkWeb 的下载溯源能力深度嵌入网课资料管理流程,是 HarmonyOS 原生应用中 Web 能力与垂直业务场景融合的典型范例。
二、整体架构流程图
以下流程图展示了本应用的整体架构层次,从顶层组件到底层数据模型与系统服务的依赖关系:
流程图清晰地呈现了应用的多层架构:入口层的主页面作为根组件驱动整个页面;状态管理层维护 Tab 切换、呼吸动画、弹窗开关、表单数据与四大业务数据列表;Canvas 绘图状态独立维护画布就绪标志与 2D 上下文;ArkWeb 状态层将 Web 控制器、下载代理、地址栏双值分离与下载进度状态有机串联。Builder 构建层将界面拆分为头部、四个 Tab 内容、折线图卡、底部导航与三套弹窗面板。数据模型层以 @Observed 类承载关联课程资料、资料榜单、下载记录(含双 URL 字段)、功能清单等业务实体。ArkWeb 系统服务层则将 Web 组件、WebDownloadDelegate 四回调、startDownload 主动下载与 getOriginalUrl/getReferrerUrl 双溯源接口串联,形成完整的下载来源追溯闭环。
三、颜色系统与常量定义
3.1 导入声明与主题色板接口
应用首先从 ArkWeb 与基础服务 Kit 中导入所需的类型,随后定义颜色系统接口。
import { webview } from '@kit.ArkWeb';
import { BusinessError } from '@kit.BasicServicesKit';
// ============ ② 颜色系统 ============
/** 主题色板接口:集中声明页面所有颜色字段(晨雾白底+青柠绿+天空蓝浅色系) */
interface ColorPalette {
bg: string;
card: string;
chip: string;
title: string;
sub: string;
text3: string;
green: string;
greenD: string;
blue: string;
blueD: string;
red: string;
gold: string;
line: string;
tabOn: string;
mask: string;
codeBg: string;
}
导入语句从 @kit.ArkWeb 中引入了 webview 命名空间,该命名空间下包含 WebviewController(Web 控制器,负责页面加载、前进后退、主动下载等操作)与 WebDownloadDelegate(下载代理,承载四个下载生命周期回调)以及 WebDownloadItem(下载项,封装单个下载任务的状态与元数据方法)。从 @kit.BasicServicesKit 引入的 BusinessError 则用于 try-catch 块中的错误信息结构化处理——当 setDownloadDelegate 或 startDownload 调用失败时,异常对象会被转换为 BusinessError 类型,通过其 code 与 message 属性获取错误码与错误描述。
ColorPalette 接口采用集中式颜色字段声明策略,将应用所有颜色分为背景类(bg/card/chip/codeBg)、文字类(title/sub/text3)、主题强调类(green/greenD/blue/blueD/red/gold)、辅助类(line/tabOn/mask)四组。接口约束的好处在于:一旦后续需要切换主题色系(如从浅色青柠绿切换为深色暮霭蓝),只需替换 COLORS 常量对象的具体色值,所有引用 COLORS.xxx 的代码自动生效,无需逐处修改硬编码颜色——这是声明式 UI 框架下主题管理的标准实践。codeBg 字段的引入是为了下载 Tab 中的代码预览卡,使用深色背景(#12303B)衬托代码文本,与浅色主题形成对比,增强代码区域的可读性。
3.2 浅色主题色板常量
/** 浅色主题色板常量(网课资料宝 · 晨雾白底 + 青柠绿 + 天空蓝) */
const COLORS: ColorPalette = {
bg: '#F5F8FC',
card: '#FFFFFF',
chip: '#EAF2FB',
title: '#17323F',
sub: '#5A7684',
text3: '#9AB0BE',
green: '#22B573',
greenD: '#14875A',
blue: '#3E9BE8',
blueD: '#2A7CC4',
red: '#E86A6A',
gold: '#F0A83C',
line: '#E6EDF4',
tabOn: '#22B573',
mask: 'rgba(23,50,63,0.5)',
codeBg: '#12303B'
};
COLORS 常量是整个应用的视觉基调。晨雾白底(#F5F8FC)作为全局背景色,略带蓝灰调的白营造柔和的晨雾质感,避免纯白带来的刺眼感;卡片白(#FFFFFF)用于内容卡片的背景,与晨雾白底形成微妙的层次对比;浅蓝芯片色(#EAF2FB)用于次要容器与斑马纹背景。文字色采用三级灰度体系:标题深青(#17323F)是最深色,用于标题与重要信息;副文本灰蓝(#5A7684)用于次要说明文字;三级文本浅灰蓝(#9AB0BE)用于辅助标签与提示,三级灰度确保了信息层级的清晰传达。
主题强调色采用青柠绿(#22B573)与天空蓝(#3E9BE8)双主色策略,各自配有深色变体(greenD/blueD)用于渐变的深色端。青柠绿作为主操作色,用于选中态、主按钮、进度条与折线图;天空蓝作为辅助操作色,用于次级按钮与部分徽章。红色(#E86A6A)用于删除与下降趋势、暖金(#F0A83C)用于笔记类型徽章与引用页 URL。遮罩色采用 rgba(23,50,63,0.5)——半透明深青,在弹窗出现时覆盖底层内容但不完全遮挡,保持了上下文可见性。这套色彩体系既保证了浅色主题的明亮清新,又通过多色强调确保了信息层级的丰富表达。
3.3 Tab元数据与导航常量
/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
icon: string;
label: string;
}
/** 底部导航 Tab 常量列表(4 Tab 单排:资料/网页/下载/我的) */
const TAB_LIST: TabMeta[] = [
{ icon: '📚', label: '资料' },
{ icon: '🌐', label: '网页' },
{ icon: '⬇', label: '下载' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义了底部导航项的数据结构,仅含 icon(emoji 图标)与 label(文字标签)两个字段。TAB_LIST 常量数组定义了四个底部 Tab 的完整配置:资料(📚,课程资料浏览与趋势图表)、网页(🌐,ArkWeb 内嵌浏览器与主动下载)、下载(⬇,下载管理与双 URL 溯源)、我的(👤,学习者信息与功能设置)。四个 Tab 的命名与图标精准对应了网课资料下载平台的核心功能维度——从浏览资料到搜索下载再到管理溯源的完整用户旅程。使用 emoji 作为图标避免了引入图片资源的开销,在轻量级应用中是高效的图标方案。底部导航在 tabBar Builder 中通过 ForEach 遍历 TAB_LIST 渲染,选中态通过 currentTab 索引与当前索引的比较来切换颜色与字号。
3.4 学科分类与快捷站点常量
/** 头部横滑学科 chips 文案(6 大学科) */
const CATE_TAGS: string[] = ['考研', '编程', '语言', '职场', '设计', '考证'];
/** 快捷站点常量(网页 Tab 横滑入口,学习向真实站点) */
const QUICK_SITES: string[] = [
'https://www.coursera.org',
'https://www.icourse163.org',
'https://study.163.com',
'https://www.xuetangx.com'
];
CATE_TAGS 定义了头部学科分类标签的六个学科方向:考研(学术深造)、编程(技术技能)、语言(外语学习)、职场(职业提升)、设计(创意技能)、考证(资格认证)。这六个方向覆盖了在线教育市场最主流的学习品类,使应用能够通过分类筛选为不同学习目标的用户提供精准的资料推荐。学科 chips 在头部以横滑列表形式展示,选中态通过 cateIdx 索引控制背景色切换为青柠绿,未选中态为卡片白底,用户点击即可切换学科筛选上下文。
QUICK_SITES 定义了网页 Tab 的四个快捷站点入口,均为真实的主流在线学习平台:Coursera(国际 MOOC 平台)、中国大学 MOOC(国内高校课程平台)、网易云课堂(职业技能学习)、学堂在线(清华大学发起的 MOOC 平台)。使用真实站点地址而非 Mock 域名,使 Web 组件加载时有真实页面可渲染,也让主动下载演示更加贴近实际使用场景。快捷站点以横滑卡片形式展示,点击后直接设置 urlInput 与 webUrl 两个状态,实现一键加载。siteHost() 辅助函数会去掉 https:// 协议前缀,使站点在卡片中以更简洁的域名形式展示。
3.5 折线图数据常量
/** 折线图 Mock 数据:近 6 个月课程资料下载量(单位:万份,月维度) */
const LINE_DATA: number[] = [128, 156, 149, 187, 214, 246];
/** 折线图底部月份标签(与 LINE_DATA 一一对应) */
const LINE_LABELS: string[] = ['03月', '04月', '05月', '06月', '07月', '08月'];
/** 折线图纵轴最大刻度(用于按比例换算数据点 Y 坐标) */
const LINE_MAX: number = 280;
这三个常量共同定义了 Canvas 折线图的完整数据集。LINE_DATA 包含近六个月的课程资料下载量数据(单位:万份),从 3 月的 128 万到 8 月的 246 万,整体呈上升趋势,其中 5 月有微降(149,较 4 月的 156 略降),6 月起进入毕业季与暑期学习高峰,下载量显著攀升。这一数据走势与实际在线教育市场的季节性规律吻合——毕业季(5-6 月)的论文与答辩资料需求、暑期(7-8 月)的考研备考与技能提升需求共同推动了下载量的增长。
LINE_LABELS 与 LINE_DATA 严格一一对应,提供折线图底部的月份标签。LINE_MAX 设为 280,略高于数据最大值 246,使最高数据点的 Y 坐标不会贴到画布顶部边缘,留出了数值标签的渲染空间。在 drawLineChart() 方法中,每个数据点的 Y 坐标通过 h - padB - (LINE_DATA[i] / LINE_MAX) * (h - padT - padB) 公式计算——数据值除以最大刻度得到比例,乘以绘图区域高度得到偏移量,从底部基线减去偏移量得到 Y 坐标,这是 Canvas 图表绘制中标准的数值到坐标的映射方法。
四、辅助函数
4.1 趋势颜色与图标映射
/** 榜单趋势颜色映射:上升红 / 下降绿 / 持平弱化 */
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 '— 持平';
}
trendColor() 与 trendIcon() 是一对配套的趋势映射函数,接收趋势字符串('up'/'down'/'flat'),分别返回对应的颜色值与图标文案。值得注意的是,这里的颜色映射采用了一个反直觉的设计:上升用红色(COLORS.red),下降用绿色(COLORS.green)。这与金融行情中"红涨绿跌"的中国股市惯例一致——在中国市场文化中,红色代表上涨(喜庆),绿色代表下跌。这一设计选择体现了应用面向中文学习用户的文化适配,使趋势指示更符合本土用户的认知习惯。持平状态使用三级文本灰色(COLORS.text3),弱化视觉权重,暗示"无变化"的信息低重要性。
trendIcon() 返回带箭头符号的文案:'↑ 上升'、'↓ 下降'、'— 持平',箭头方向直观传达趋势。这两个函数在热门资料榜 rankCard 中配合使用:trendColor(d.trend) 设置趋势文字颜色,trendIcon(d.trend) 提供趋势文字内容,二者通过同一个 trend 字段驱动,确保颜色与文案的语义一致性。这种将业务逻辑(趋势判断)封装为独立函数的做法,使模板代码更加简洁,也便于后续扩展新的趋势类型(如"暴涨"、“暴跌”)时只需修改函数而无需改动 Builder 代码。
4.2 资料类型徽章颜色映射
/** 资料类型徽章颜色:讲义青柠绿 / 题库天空蓝 / 笔记暖金 */
function kindColor(kind: string): string {
if (kind === '讲义') { return COLORS.green; }
if (kind === '题库') { return COLORS.blue; }
return COLORS.gold;
}
kindColor() 函数将资料类型字符串映射为主题色:讲义映射为青柠绿(COLORS.green,主操作色)、题库映射为天空蓝(COLORS.blue,辅助操作色)、笔记映射为暖金(COLORS.gold,强调色)。这种类型到颜色的映射使不同类型的资料在视觉上具有即时识别性——用户扫一眼徽章颜色就能区分这是讲义、题库还是笔记。该函数在本周热门资料卡 recBanner 中使用,通过 border 属性将颜色应用于徽章的描边,形成"文字色+描边色"同色的镂空徽章效果。
函数的 else 分支返回暖金色作为默认值,这意味着除"讲义"和"题库"外的所有类型(包括"笔记")都会使用暖金色。这种设计使函数具有前向兼容性——如果未来新增"课件"、"试卷"等类型而未更新函数,它们会以暖金色展示,不会出现颜色缺失。三个颜色分别对应应用的三种主题强调色,使资料类型徽章的色彩分布与应用整体色彩体系保持和谐统一。
4.3 站点域名提取函数
/** 站点 URL 转展示域名(去掉协议前缀,地址栏/快捷站点卡用) */
function siteHost(url: string): string {
const head = 'https://';
if (url.startsWith(head)) {
return url.slice(head.length);
}
return url;
}
siteHost() 函数接收完整 URL,去掉 https:// 协议前缀后返回纯域名部分。例如 https://www.coursera.org 经处理后返回 www.coursera.org。这一处理在网页 Tab 的快捷站点卡片中使用——卡片宽度有限,显示完整 URL 会超出宽度并被截断,去掉协议前缀后域名更简洁,用户可快速识别站点。函数仅处理 https:// 前缀(未处理 http://),因为 QUICK_SITES 常量中所有站点均使用 HTTPS 协议,这是一种基于已知数据的精确处理而非通用 URL 解析。在网页 Tab 中,快捷站点卡片通过 siteHost(site) 获取展示域名,同时通过 this.webUrl === site 判断当前加载站点,高亮选中态。
五、数据模型
5.1 关联课程资料卡 CourseItem
/** 关联课程资料卡条目(资料 Tab 本周热门资料卡:资料 + 类型 + 配套课程) */
@Observed export class CourseItem {
icon: string; // 资料类型 emoji 图标
name: string; // 资料名
kind: string; // 资料类型:讲义 / 题库 / 笔记
course: string; // 配套课程名 + 讲师
constructor(icon: string, name: string, kind: string, course: string) {
this.icon = icon;
this.name = name;
this.kind = kind;
this.course = course;
}
}
/** 本周热门资料 Mock 数据(3 条:资料名 + 类型 + 配套课程名) */
const COURSE_RECS: Array<CourseItem> = [
new CourseItem('📘', '考研数学(高数·线代)冲刺讲义', '讲义', '2027 考研数学全程班 · 张宇'),
new CourseItem('📝', 'Python 数据分析实战题库', '题库', 'Python 数据分析 21 天训练营 · 李拓'),
new CourseItem('🗒', '雅思写作 8 分范文笔记', '笔记', '雅思 7+ 全科突破班 · Emma 老师')
];
CourseItem 类使用 @Observed 装饰器标注,使其成为可观察对象——当其实例的属性发生变化时,依赖该实例的 UI 组件会自动更新。类的四个字段分别承载资料的类型图标(emoji)、名称、类型分类与配套课程信息。icon 使用 emoji 作为资料类型的视觉标识(📘 表示讲义、📝 表示题库、🗒 表示笔记),与 kind 字段的文字分类形成双重标识。course 字段将课程名与讲师名拼接为单一字符串(如"2027 考研数学全程班 · 张宇"),在 UI 中以一行文字展示,节省纵向空间。
COURSE_RECS 常量提供三条本周热门资料的 Mock 数据,覆盖考研、编程、语言三个学科方向,每条数据包含完整的资料名、类型与配套课程信息。这些数据在组件中通过 @State courseRecs 状态变量引用,在 recBanner Builder 中通过 ForEach 遍历渲染。资料名使用括号补充说明范围(如"高数·线代"标明冲刺讲义的覆盖科目),课程名后跟讲师名使资料与具体课程形成强关联——这正是网课资料场景的核心特征:资料不是孤立的文件,而是特定课程的配套学习材料。
5.2 资料榜单条目 DocItem
/** 资料榜单条目(资料 Tab 大编号热门资料榜) */
@Observed export class DocItem {
rank: string; // 名次字符串
name: string; // 资料名
kind: string; // 资料类型:讲义 / 题库 / 笔记
course: string; // 配套课程名
size: string; // 资料文件大小文本
heat: string; // 周下载量文本
trend: string; // 趋势:'up' / 'down' / 'flat'
constructor(rank: string, name: string, kind: string, course: string,
size: string, heat: string, trend: string) {
this.rank = rank;
this.name = name;
this.kind = kind;
this.course = course;
this.size = size;
this.heat = heat;
this.trend = trend;
}
}
/** 热门资料榜 Mock 数据(8 条:含大小/下载量/趋势) */
const DOC_LIST: Array<DocItem> = [
new DocItem('1', '考研数学高数冲刺讲义', '讲义', '考研数学全程班', '86.4 MB', '42.1万', 'up'),
new DocItem('2', 'Python 数据分析实战题库', '题库', 'Python 21 天训练营', '12.8 MB', '38.6万', 'up'),
new DocItem('3', '雅思写作 8 分范文笔记', '笔记', '雅思 7+ 突破班', '5.2 MB', '35.9万', 'flat'),
new DocItem('4', 'C++ 高频面试题集 2026 版', '题库', '大厂后端冲刺营', '3.6 MB', '29.4万', 'down'),
new DocItem('5', 'CPA 经济法思维导图', '讲义', 'CPA 三年通关计划', '9.4 MB', '26.7万', 'up'),
new DocItem('6', 'UI 设计规范速查手册', '笔记', 'UI 进阶实战课', '18.3 MB', '22.8万', 'down'),
new DocItem('7', '日语 N2 语法汇总讲义', '讲义', '日语 N2 冲刺班', '7.6 MB', '19.5万', 'up'),
new DocItem('8', '职场英语口语题库', '题库', '职场英语提升课', '2.9 MB', '16.2万', 'flat')
];
DocItem 类在 CourseItem 基础上扩展了四个字段:rank(名次,字符串类型以便显示"1"-“8”)、size(文件大小文本,如"86.4 MB")、heat(周下载量文本,如"42.1万")、trend(趋势标记,取值 'up'/'down'/'flat')。这四个新增字段使榜单条目不仅展示资料基本信息,还携带了文件体积、热度与趋势三个维量的量化指标,使榜单从"资料列表"升级为"资料排行",增强了信息密度与决策参考价值。
DOC_LIST 常量提供八条热门资料榜的 Mock 数据,覆盖考研数学、Python 数据分析、雅思写作、C++ 面试、CPA 经济法、UI 设计、日语语法、职场英语八个品类,与头部的六大学科分类形成呼应。八条数据的名次从 1 到 8,周下载量从 42.1 万递减至 16.2 万,趋势包含上升(5 条)、下降(2 条)、持平(1 条)三种状态,数据分布合理。在 rankCard Builder 中,前三名(idx < 3)的名次数字使用青柠绿高亮且字号 20 加粗,第四名及以后使用三级灰色,形成"头部突出"的视觉层级。斑马纹背景(idx % 2 === 0 判断)使相邻条目有微妙的底色区分,提升长列表的可读性。
5.3 下载记录条目 DownloadRecord(双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;
}
}
/** 历史下载记录 Mock 数据(6 条:课程站直链 + 课程详情页引用页,双 URL 完整真实感地址) */
const DOWNLOAD_RECORDS: Array<DownloadRecord> = [
new DownloadRecord('kaoyan_math_handout_2027.pdf', '86.4 MB', '今天 09:42',
'https://cdn.wangkebao.cn/docs/kaoyan/math/kaoyan_math_handout_2027_v3.pdf',
'https://www.wangkebao.cn/course/128/detail?lesson=math', '配套讲义'),
new DownloadRecord('python_data_analysis_exercises.zip', '12.8 MB', '今天 08:15',
'https://dl.wangkebao.cn/python/python_data_analysis_exercises_2.1.zip',
'https://www.wangkebao.cn/course/256/detail?lesson=python', '训练营题库'),
new DownloadRecord('ielts_writing_band8_notes.pdf', '5.2 MB', '昨天 21:36',
'https://cdn.wangkebao.cn/ielts/ielts_writing_band8_notes_0903.pdf',
'https://www.wangkebao.cn/course/301/detail?lesson=writing', '老师推荐'),
new DownloadRecord('cpp_interview_top100.zip', '3.6 MB', '昨天 19:04',
'https://dl.wangkebao.cn/interview/cpp_interview_top100_2026Q3.zip',
'https://www.wangkebao.cn/course/412/detail?lesson=cpp', '秋招冲刺'),
new DownloadRecord('cpa_economic_law_mindmap.pdf', '9.4 MB', '昨天 12:48',
'https://cdn.wangkebao.cn/cpa/cpa_economic_law_mindmap_v2.pdf',
'https://www.wangkebao.cn/course/508/detail?lesson=cpa', '免费公开课'),
new DownloadRecord('japanese_n2_grammar.pdf', '7.6 MB', '08-23 10:22',
'https://dl.wangkebao.cn/japanese/japanese_n2_grammar_2026.pdf',
'https://so.wangkebao.cn/search?q=N2%E8%AF%AD%E6%B3%95', '搜索下载')
];
DownloadRecord 是本应用的核心数据模型,承载了 HarmonyOS 6.1.1 ArkWeb 双 URL 溯源特性的全部数据。类的六个字段中,originalUrl 与 referrerUrl 是两个标注了星号注释的关键字段——它们分别对应 WebDownloadItem 的 getOriginalUrl() 与 getReferrerUrl() 接口返回值。originalUrl 存储资料的 CDN 直链地址(如 https://cdn.wangkebao.cn/docs/kaoyan/math/kaoyan_math_handout_2027_v3.pdf),referrerUrl 存储触发下载的课程详情页地址(如 https://www.wangkebao.cn/course/128/detail?lesson=math)。两个字段的组合使得每条下载记录都能完整回答"这份文件是从哪个课程页面的哪个直链下载的"这一来源追溯问题。
DOWNLOAD_RECORDS 常量提供六条历史下载记录的 Mock 数据,每条数据的双 URL 地址均采用真实感的域名结构:originalUrl 使用 cdn.wangkebao.cn 或 dl.wangkebao.cn 作为 CDN/下载域名,路径中包含学科分类与文件名版本号;referrerUrl 使用 www.wangkebao.cn/course/数字/detail?lesson=学科 的课程详情页 URL 结构,模拟真实的网课平台路由。最后一条记录的 referrerUrl 使用了 so.wangkebao.cn/search?q=N2语法 的搜索页 URL,展示了"通过搜索页触发下载"的另一种来源路径,使溯源场景更加丰富。note 字段存储用户自定义备注(如"配套讲义"、“老师推荐”、“秋招冲刺”),可通过编辑弹窗修改,为资料来源添加人工标注信息。
5.4 我的页功能清单 UserStat
/** 我的页功能清单条目 */
@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;
}
}
/** 我的页功能清单 Mock 数据(8 行) */
const STAT_LIST: Array<UserStat> = [
new UserStat('🔗', '来源审计', '双 URL 溯源已开启', true),
new UserStat('📁', '资料保存目录', '/data/storage/files', true),
new UserStat('⭐', '我的收藏', '86 份资料', true),
new UserStat('📤', '上传的笔记', '12 份 · 3 份被下载', true),
new UserStat('🕘', '下载历史', '近 30 天 52 条', true),
new UserStat('🔔', '课程更新提醒', '已关注 9 门课程', true),
new UserStat('🌙', '夜间护眼模式', '跟随系统', true),
new UserStat('⚙', '下载偏好设置', '仅 Wi-Fi 下载', true)
];
UserStat 类定义了我的页功能清单的条目结构,包含功能图标、功能名、状态值与右箭头显示四个字段。arrow 布尔值控制是否渲染右侧的 › 箭头——所有八条数据均设为 true,表示所有功能项均可点击进入下一级页面(在本示例中实际跳转逻辑仅在"下载历史"项实现,跳转到下载 Tab)。
STAT_LIST 的八条功能数据覆盖了网课资料下载平台用户中心的核心功能维度:来源审计(双 URL 溯源状态总览,直接呼应应用的核心技术特性)、资料保存目录(沙箱路径展示)、我的收藏、上传的笔记、下载历史、课程更新提醒、夜间护眼模式、下载偏好设置。其中"来源审计"项的值为"双 URL 溯源已开启",将 HarmonyOS 6.1.1 的技术特性以用户可理解的语言呈现在功能列表中,使用户能够感知到下载溯源能力已处于工作状态。这种将技术特性转化为用户可感知功能的设计,是技术能力与用户体验之间的关键桥梁。
六、组件主体状态与生命周期
6.1 组件状态声明
/** 主页面 */
@Entry
@Component
struct Page1128 {
/** 当前选中 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 docList: Array<DocItem> = DOC_LIST;
/** 本周热门资料(关联课程卡)数据 */
@State courseRecs: Array<CourseItem> = COURSE_RECS;
/** 下载记录列表数据 */
@State downloadRecords: Array<DownloadRecord> = DOWNLOAD_RECORDS;
/** 我的页功能清单数据 */
@State statList: Array<UserStat> = STAT_LIST;
/** 新建表单:资料下载链接 URL */
@State formUrl: string = '';
/** 新建表单:资料备注 */
@State formNote: string = '';
/** 编辑表单:记录备注 */
@State editNote: string = '';
组件状态声明区集中定义了驱动整个应用 UI 更新的状态变量。currentTab 控制四个 Tab 的切换显示,初始值为 0(资料 Tab)。breath 与 timer 共同构成呼吸动画系统——breath 是每秒翻转的布尔值,timer 存储 setInterval 返回的定时器句柄用于销毁时清理。breath 状态的翻转会驱动折线图的数据点半径(4.5 与 3.5 之间切换)与线宽(2.6 与 2.0 之间切换)变化,使图表产生微妙的呼吸感。
三组弹窗状态(addModal/editModal/delModal)分别控制新建下载、编辑备注、删除确认三个弹窗的显示与隐藏。editIdx 与 delIdx 记录当前操作的记录索引,使弹窗操作能够精确作用于正确的记录。四个业务数据状态(docList/courseRecs/downloadRecords/statList)分别引用各自的数据常量,作为 @State 状态后,当数组引用发生变化时(如 splice、slice、unshift),UI 会自动刷新。三个表单状态(formUrl/formNote/editNote)承载弹窗中输入框的值,与 TextInput 组件双向绑定。这种将所有可变状态集中于组件顶部的做法,使状态来源一目了然,便于调试与维护。
6.2 Canvas与ArkWeb状态
// --- Canvas 状态(资料 Tab 月下载趋势折线图) ---
/** 折线图 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 = '空闲';
Canvas 状态区定义了折线图的两个关键变量。canvasReady 是 @State 布尔值,在 Canvas 组件的 onReady 回调中被设为 true,标记画布已就绪可以绘图。lineCtx 是 CanvasRenderingContext2D 实例,使用 private 而非 @State——因为它是一个对象引用而非影响 UI 渲染的原始值,不需要框架观察其变化。RenderingContextSettings(true) 的 true 参数开启抗锯齿,使折线与圆点的边缘更加平滑。lineCtx 在 Canvas(this.lineCtx) 组件声明时传入,建立上下文与画布的绑定关系,此后所有绘图操作都通过该上下文执行。
ArkWeb 状态区定义了 Web 组件与下载代理的完整状态体系。webController 与 downloadDelegate 均为 private 实例,在组件构造时即创建——WebviewController 提供页面加载(loadUrl)、主动下载(startDownload)、绑定代理(setDownloadDelegate)等能力;WebDownloadDelegate 提供四个下载生命周期回调的注册能力。urlInput 与 webUrl 的双值分离是地址栏交互的关键设计:urlInput 绑定地址栏输入框,用户敲字时实时更新但不触发页面加载;webUrl 绑定 Web 组件的 src 属性,仅在用户点击"前往"按钮后才被赋值为 urlInput 的值——这种分离避免了"敲字即加载"的不良体验。dlName/dlPercent/dlState 三个状态共同描述当前下载任务的信息,在下载 Tab 的进行中任务卡与网页 Tab 的主动下载演示行中展示。
6.3 下载代理注册 setupDownloadDelegate
/** 注册下载代理:四回调齐全,完成回调中调用 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 的四个下载生命周期回调。onBeforeDownload 回调在下载开始前触发,接收 WebDownloadItem 参数——此回调中必须调用 item.start(path) 方法并提供沙箱存储路径,否则下载任务会停留在 PENDING 状态无法开始。路径通过 this.getUIContext().getHostContext().filesDir 获取应用的沙箱文件目录,拼接 getSuggestedFileName() 返回的文件名(服务器建议的文件名,通常从 URL 末段或 Content-Disposition 头解析),组成完整的保存路径。hostCtx 的空值判断(hostCtx ? hostCtx.filesDir : '')是防御性编程,避免 getHostContext() 返回 undefined 时抛错。
onDownloadUpdated 回调在下载进行中反复触发,通过 item.getPercentComplete() 获取当前进度百分比,更新 dlPercent 与 dlState 两个状态——这些状态变化会驱动下载 Tab 的进度条与状态文案实时刷新。onDownloadFailed 回调在下载失败时触发,通过 item.getGuid() 获取下载任务的唯一标识用于错误追踪,将进度清零并设置失败文案。onDownloadFinish 回调是 6.1.1 新特性的核心落地点:调用 item.getOriginalUrl() 获取资料的原始直链 URL,调用 item.getReferrerUrl() 获取触发下载的引用页 URL,两个字段连同 getSuggestedFileName()(文件名)、getTotalBytes()(总字节数,除以 1048576 转为 MB 并四舍五入)一起构造 DownloadRecord 实例,通过 unshift() 插入到 downloadRecords 数组头部——新下载的记录会出现在下载列表的最上方。最后通过 try-catch 包裹 setDownloadDelegate 绑定调用,捕获可能抛出的 BusinessError,避免绑定失败导致应用崩溃。
6.4 地址栏加载 loadUrl
/** 地址栏"前往":校验协议前缀(无 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 协议的自动补全。方法首先对输入值做 trim() 去除首尾空白字符,空字符串直接返回不处理。随后检查 URL 是否以 https:// 或 http:// 开头——如果用户输入的是裸域名(如 www.coursera.org),则自动补上 https:// 前缀。这一设计大幅降低了用户的输入负担:用户无需手动输入协议前缀,只需输入域名即可,系统自动补全为 HTTPS 协议(而非 HTTP),也体现了对安全连接的优先选择。
协议补全后,方法将处理后的 URL 同时赋值给 urlInput 与 webUrl 两个状态。赋值 urlInput 使地址栏输入框显示补全后的完整 URL(用户可看到协议已自动添加),赋值 webUrl 使 Web 组件的 src 属性更新,触发页面加载。这种"一次赋值、双状态更新"的设计,正是 urlInput/webUrl 双值分离策略的最终落地——urlInput 是用户可编辑的输入态,webUrl 是实际加载态,二者在"前往"按钮点击时同步。如果用户不点击"前往"而只是在输入框敲字,webUrl 不会改变,Web 组件不会重新加载,避免了打字过程中的频繁页面刷新。
6.5 应用侧主动下载 triggerDownload
/** 应用侧主动发起下载(无需网页内点击,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() 方法通过 webController.startDownload(url) 实现应用侧主动发起下载——这是区别于"网页内点击下载链接"的另一种下载触发方式。startDownload 接收一个 URL 参数,直接向该 URL 发起下载请求,无需用户在嵌入的 Web 页面中点击任何链接。方法首先从 URL 中提取文件名:url.slice(url.lastIndexOf('/') + 1) 取 URL 最后一个 / 之后的部分作为文件名(如 https://cdn.wangkebao.cn/docs/kaoyan/math/kaoyan_math_handout_2027_v3.pdf 提取出 kaoyan_math_handout_2027_v3.pdf),设置到 dlName 状态。随后将进度清零、状态设为"已发起下载请求",最后调用 startDownload 触发实际下载。
整个方法体被 try-catch 包裹,捕获可能抛出的 BusinessError——当 URL 格式不合法、网络不可用或下载代理未正确绑定时,startDownload 会抛出异常,catch 块将错误码设置到 dlState 状态,使用户在 UI 上看到失败原因而非应用崩溃。这种防御性编程确保了下载功能的健壮性。triggerDownload 在两个场景被调用:一是网页 Tab 的"下载考研数学讲义 PDF"与"下载 Python 题库 ZIP"两个演示按钮的点击事件,二是新建下载任务弹窗的 saveDownload() 方法。两种入口均通过同一方法实现下载触发,保证了下载行为的一致性。
6.6 折线图绘制 drawLineChart
/** 绘制近 6 个月课程资料下载量趋势折线图(渐变面积 + 连续折线 + 数据点 + 数值/月份标签) */
drawLineChart() {
const ctx = this.lineCtx;
const w = 330; // 画布逻辑宽
const h = 200; // 画布逻辑高
const padL = 34; // 左边距(容纳数值标签)
const padR = 18; // 右边距
const padT = 26; // 顶边距(容纳数值标签)
const padB = 26; // 底边距(容纳月份标签)
const stepX = (w - padL - padR) / (LINE_DATA.length - 1); // 相邻月份横向步长
const dotR = this.breath ? 4.5 : 3.5; // 数据点半径随呼吸动画变化
ctx.clearRect(0, 0, w, h);
// 背景网格横线(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();
}
// 渐变面积填充:从折线下方到基线,青柠绿由浓到透明
const grad = ctx.createLinearGradient(0, padT, 0, h - padB);
grad.addColorStop(0, 'rgba(34,181,115,0.28)');
grad.addColorStop(1, 'rgba(34,181,115,0.02)');
ctx.beginPath();
ctx.moveTo(padL, h - padB);
for (let i = 0; i < LINE_DATA.length; i++) {
const px = padL + i * stepX;
const py = h - padB - (LINE_DATA[i] / LINE_MAX) * (h - padT - padB);
ctx.lineTo(px, py);
}
ctx.lineTo(padL + (LINE_DATA.length - 1) * stepX, h - padB);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
// 连续折线(moveTo 起点 + 逐点 lineTo,线宽随呼吸微变)
ctx.beginPath();
for (let i = 0; i < LINE_DATA.length; i++) {
const px = padL + i * stepX;
const py = h - padB - (LINE_DATA[i] / LINE_MAX) * (h - padT - padB);
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = this.breath ? 2.6 : 2;
ctx.stroke();
// 数据点圆点 + 数值标签 + 底部月份标签
ctx.textAlign = 'center';
for (let i = 0; i < LINE_DATA.length; i++) {
const px = padL + i * stepX;
const py = h - padB - (LINE_DATA[i] / LINE_MAX) * (h - padT - padB);
// 白底圆点 + 青柠绿描边
ctx.beginPath();
ctx.arc(px, py, dotR, 0, Math.PI * 2);
ctx.fillStyle = COLORS.card;
ctx.fill();
ctx.strokeStyle = COLORS.green;
ctx.lineWidth = 1.5;
ctx.stroke();
// 数值标签(数据点上方)
ctx.fillStyle = COLORS.sub;
ctx.font = '9px sans-serif';
ctx.fillText(LINE_DATA[i] + '万', px, py - 10);
// 月份标签(基线下方)
ctx.fillStyle = COLORS.text3;
ctx.fillText(LINE_LABELS[i], px, h - padB + 16);
}
}
drawLineChart() 方法是 Canvas 折线图绘制的完整实现,分为六个图层依次绘制。首先是画布尺寸与边距的定义:画布逻辑宽 330、高 200,四向边距分别为左 34(容纳左侧数值标签)、右 18、顶 26(容纳顶部数值标签)、底 26(容纳底部月份标签)。stepX 计算相邻数据点的横向步长:可用宽度(w - padL - padR)除以数据间隔数(LINE_DATA.length - 1,6 个数据点之间有 5 个间隔),得到每个数据点的横向步进。dotR 数据点半径通过三元表达式与 breath 状态联动——呼吸开时为 4.5,关时为 3.5,使数据点产生大小呼吸的动效。
第一层是背景网格横线,3 等分绘制四条极浅色横线(COLORS.line),通过 beginPath+moveTo+lineTo+stroke 的标准路径绘制流程完成。第二层是渐变面积填充,通过 createLinearGradient 创建从折线顶部到底部基线的垂直渐变,青柠绿从 0.28 透明度渐变到 0.02 透明度(近乎透明),路径从左下角起点开始,经过所有数据点,到右下角终点闭合,用渐变色填充——这层渐变面积是折线图的视觉主体,使趋势走势具有面积感与色彩浓度感。第三层是连续折线,moveTo 第一个数据点后逐个 lineTo 后续数据点,使用青柠绿描边,线宽同样随 breath 状态在 2.6 与 2.0 之间切换——折线与面积填充共同构成图表的核心视觉。
第四、五、六层在同一个 for 循环中绘制:每个数据点位置绘制一个白底青柠绿描边的圆点(arc 画圆 + fill 白色填充 + stroke 绿色描边,形成"空心圆"效果),圆点上方绘制数值标签(fillText 输出如"128万",使用副文本灰色),圆点下方基线外绘制月份标签(fillText 输出如"03月",使用三级文本浅灰色)。textAlign = 'center' 确保文本以数据点 x 坐标为中心对齐。整个方法在 aboutToAppear 启动的定时器中每秒被调用一次(当 canvasReady 为 true 时),通过 breath 状态的翻转使数据点半径与折线线宽每秒变化,实现呼吸动画效果——这是用 Canvas 2D API 实现图表动画的经典范式。
6.7 弹窗操作方法
/** 打开编辑备注弹窗(回填当前记录备注) */
openEditRecord(idx: number) {
this.editIdx = idx;
this.editNote = this.downloadRecords[idx].note;
this.editModal = true;
}
/** 保存新建下载任务(空 URL 兜底默认演示链接,触发 startDownload) */
saveDownload() {
const url = this.formUrl === '' ? 'https://cdn.wangkebao.cn/docs/kaoyan/math/kaoyan_math_handout_2027_v3.pdf' : this.formUrl;
this.triggerDownload(url);
this.formUrl = '';
this.formNote = '';
this.addModal = false;
}
/** 保存编辑备注(整体刷新数组引用以刷新列表) */
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;
}
/** 删除下载记录(确认弹窗回调) */
delRecord() {
if (this.delIdx >= 0 && this.delIdx < this.downloadRecords.length) {
this.downloadRecords.splice(this.delIdx, 1);
}
this.delModal = false;
}
这四个方法分别处理三套弹窗的业务逻辑。openEditRecord(idx) 在用户点击下载记录的"改"按钮时调用,接收记录索引,将索引存入 editIdx、将该记录的当前备注回填到 editNote(使弹窗打开时输入框已预填当前备注),最后打开编辑弹窗。这种"先回填再打开"的顺序确保用户看到的输入框内容与当前记录一致,是编辑流程的标准交互。
saveDownload() 处理新建下载弹窗的确认逻辑:如果用户未输入 URL(formUrl 为空),则使用默认的演示链接(考研数学讲义 PDF 的 CDN 直链)作为兜底,确保即使用户不输入也能演示下载流程。随后调用 triggerDownload(url) 发起下载,清空表单状态,关闭弹窗。updateRecord() 处理编辑备注的保存:先校验 editIdx 有效性,再将 editNote 写入对应记录的 note 字段,关键是最后一步 this.downloadRecords = this.downloadRecords.slice()——slice() 不带参数会返回数组的浅拷贝,赋值给 downloadRecords 后改变了数组引用,触发 @State 的变化检测,驱动 ForEach 重新渲染列表。这是 ArkUI 中刷新 @Observed 对象数组的标准技巧:直接修改对象属性不会触发列表级刷新,必须改变数组引用才行。delRecord() 通过 splice 删除指定索引的记录,splice 本身会改变数组引用,因此无需额外的 slice 操作。
6.8 生命周期 aboutToAppear/aboutToDisappear
/** 生命周期:绑定下载代理 + 启动呼吸动画定时器(联动 Canvas 重绘) */
aboutToAppear() {
this.setupDownloadDelegate();
this.timer = setInterval(() => {
this.breath = !this.breath;
if (this.canvasReady) {
this.drawLineChart();
}
}, 1000);
}
/** 生命周期:销毁时清理定时器 */
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear() 是 ArkUI 组件的生命周期回调,在组件实例创建后、build() 执行前调用。方法执行两个初始化操作:首先调用 setupDownloadDelegate() 注册下载代理的四个回调并绑定到 webController——这一步必须在 Web 组件渲染前完成,确保用户在 Web 页面中触发下载时代理已就绪。随后通过 setInterval 启动每 1000 毫秒(1 秒)执行一次的定时器,回调函数中翻转 breath 布尔值,并在 canvasReady 为 true 时调用 drawLineChart() 重绘折线图——这使折线图每秒重绘一次,数据点半径与线宽随 breath 翻转而变化,形成呼吸动画。定时器句柄存入 timer 状态,供销毁时清理。
aboutToDisappear() 在组件销毁前调用,通过 clearInterval(this.timer) 清理定时器——这是防止内存泄漏的必要操作,如果不清理,定时器会在组件销毁后继续执行,尝试访问已销毁的组件状态与 Canvas 上下文,导致异常。这种"创建时启动、销毁时清理"的定时器管理范式,是 ArkUI 中使用定时器的标准实践。值得注意的是,breath 翻转与 drawLineChart 调用被放在同一个定时器回调中,确保了"状态翻转"与"画布重绘"的原子性——不会有"状态已翻转但画布未重绘"的中间态,保证了动画的流畅性。
七、页面主构建与头部
7.1 build主构建
/** 页面主构建:Stack 包裹主内容与三层弹窗 */
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabDocs()
} 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() 方法是组件的渲染入口,采用 Stack(堆叠布局)作为根容器。Stack 的层叠特性使其能够将主内容与弹窗面板叠加显示——主内容 Column 在底层,弹窗面板在条件为 true 时叠加在上层。主内容 Column 从上到下依次渲染:头部 headerMain()、分割线 Divider、可滚动的内容区 Scroll、底部导航 tabBar()。Scroll 组件通过 layoutWeight(1) 占据头部与底部导航之间的全部剩余空间,内部包裹一个 Column,根据 currentTab 的值条件渲染四个 Tab 内容之一——if-else if-else 结构确保同一时刻只有一个 Tab 的内容被渲染,避免了未显示 Tab 的不必要的 UI 构建开销。
Scroll 的 scrollBar(BarState.Off) 隐藏了滚动条,使长内容列表的滚动体验更加沉浸。内容 Column 的 padding 设置了 14 的左右边距与 12 的上下边距,使内容与屏幕边缘保持呼吸距离。三层弹窗通过 if 条件渲染:addModal 为 true 时渲染 panelAdd,editModal 为 true 时渲染 panelEdit,delModal 为 true 时渲染 panelDel——每个弹窗接收一个 onClose 回调函数(() => { this.xxxModal = false; }),弹窗内部点击遮罩或取消按钮时调用该回调关闭自身。整个 Stack 设置了 100% 宽高与晨雾白底背景色,确保根容器铺满屏幕。
7.2 headerMain 头部
/** 头部:青柠绿渐变 Banner(学习问候语 + 本周新增资料数)+ 搜索条 + 学科 chips */
@Builder
headerMain() {
Column({ space: 12 }) {
// 青柠绿渐变 Banner
Column({ space: 6 }) {
Row() {
Text('📚 网课资料宝').fontSize(17).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('今日 2.6 万人在学').fontSize(9).fontColor('rgba(255,255,255,0.78)')
}
.width('100%')
Text('讲义 · 题库 · 笔记,一站配齐').fontSize(11).fontColor('rgba(255,255,255,0.9)')
Text('本周新增 1286 份资料 · 覆盖 6 大学科').fontSize(9).fontColor('rgba(255,255,255,0.62)')
}
.width('100%')
.padding(14)
.borderRadius(14)
.linearGradient({ angle: 120, colors: [[COLORS.greenD, 0], [COLORS.green, 0.6], ['#7BD9A8', 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.green).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.green : 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)
}
头部 Builder 分为三个区块。第一是青柠绿渐变 Banner,使用 linearGradient 属性设置 120 度角的线性渐变,三个色标从深青柠绿(greenD #14875A)到标准青柠绿(green #22B573)到浅青柠绿(#7BD9A8),形成由深到浅的对角渐变效果。Banner 内含三行文字:应用名"网课资料宝"(17 号加粗白色)与右侧"今日 2.6 万人在学"(9 号半透明白色)组成第一行;副标题"讲义·题库·笔记,一站配齐"(11 号白色)点明资料类型覆盖;底部"本周新增 1286 份资料·覆盖 6 大学科"(9 号更透明白色)提供运营数据。三行文字使用不同透明度的白色(1.0/0.9/0.62),形成信息层级递减的视觉效果。
第二是搜索条与新建下载按钮的组合行。搜索条是一个可点击的 Row(内含搜索 emoji 与占位文案"搜索资料/粘贴课程链接"),点击后切换到网页 Tab(currentTab = 1),暗示用户可以在网页 Tab 中粘贴课程链接进行下载。右侧"+新建下载"按钮使用青柠绿背景与白色文字,点击后打开新建下载弹窗(addModal = true),允许用户直接粘贴资料链接发起下载。第三是学科 chips 横滑列表,Scroll 设置 ScrollDirection.Horizontal 实现横向滚动,6 个学科标签通过 ForEach 渲染,选中态(cateIdx === idx)背景为青柠绿、文字为白色,未选中态背景为卡片白、文字为副文本灰,点击切换 cateIdx。ForEach 的第三个参数 (tag: string) => tag 是键值生成函数,使用标签文本作为唯一键,确保列表渲染的 diff 正确性。
八、资料Tab构建
8.1 tabDocs 资料Tab
/** 资料 Tab:本周热门资料卡 + 大编号资料榜 + Canvas 月下载趋势折线图 */
@Builder
tabDocs() {
Column({ space: 12 }) {
this.recBanner()
this.rankCard()
this.chartCard()
}
.width('100%')
}
资料 Tab 是应用的默认首页(currentTab 初始值为 0),通过 Column 纵向排列三个 Builder 组件:本周热门资料卡 recBanner()、大编号热门资料榜 rankCard()、Canvas 月下载趋势折线图卡 chartCard()。三个组件之间通过 space: 12 设置 12 的纵向间距,形成清晰的区块分隔。资料 Tab 的设计逻辑是从"编辑精选"到"数据排行"到"趋势可视化"的递进——先展示人工策划的本周热门资料(3 条精选),再展示按下载量排名的热门资料榜(8 条数据),最后用折线图展示近 6 个月的下载趋势走势,使资料 Tab 兼具内容推荐与数据分析的双重价值。
8.2 recBanner 本周热门资料
/** 本周热门资料卡(3 条:资料名 + 类型徽章 + 配套课程名) */
@Builder
recBanner() {
Column({ space: 10 }) {
Row() {
Text('🔥 本周热门资料').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('每周一 08:00 更新').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
ForEach(this.courseRecs, (rec: CourseItem) => {
Row({ space: 10 }) {
Text(rec.icon).fontSize(24)
Column({ space: 3 }) {
Text(rec.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 6 }) {
Text(rec.kind).fontSize(8).fontColor(kindColor(rec.kind))
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.borderRadius(6)
.border({ width: 1, color: kindColor(rec.kind) })
Text(rec.course).fontSize(9).fontColor(COLORS.sub)
.layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Text('下载').fontSize(10).fontColor(COLORS.blue)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.borderRadius(12)
.border({ width: 1, color: COLORS.blue })
.onClick(() => {
this.currentTab = 1;
})
}
.width('100%')
.padding(11)
.borderRadius(11)
.backgroundColor(COLORS.chip)
}, (rec: CourseItem) => rec.name)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
本周热门资料卡是一个白色卡片容器,内部从上到下包含标题行与三条资料条目。标题行左侧"🔥 本周热门资料"使用 13 号加粗标题色,右侧"每周一 08:00 更新"使用 9 号三级文本色,告知用户更新周期。三条资料条目通过 ForEach 遍历 courseRecs 渲染,每条条目是一个浅蓝芯片色背景的 Row,内部从左到右排列:资料类型 emoji 图标(24 号大字号)、资料信息列(资料名+类型徽章+配套课程名)、"下载"按钮。
资料信息列采用 Column 纵向排列两行内容:第一行是资料名(12 号加粗标题色,maxLines(1) 限制单行+textOverflow(Ellipsis) 超出省略号截断),第二行是类型徽章与配套课程名的横向排列。类型徽章使用 kindColor() 函数映射的颜色同时作为文字色与描边色,配合 6 的圆角与 1 的描边宽度,形成镂空徽章效果。配套课程名使用 9 号副文本色,同样限制单行省略号截断。"下载"按钮使用天空蓝描边镂空样式,点击后切换到网页 Tab(currentTab = 1),暗示用户可以在网页 Tab 中搜索并下载该资料。ForEach 的键值函数使用 rec.name(资料名)作为唯一键,在列表数据变化时确保 diff 正确。
8.3 rankCard 热门资料榜
/** 大编号热门资料榜(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.docList, (d: DocItem, idx: number) => {
Row({ space: 10 }) {
Text(d.rank).fontSize(20).fontWeight(FontWeight.Bold)
.fontColor(idx < 3 ? COLORS.green : COLORS.text3)
.width(30)
Column({ space: 3 }) {
Text(d.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
Text(d.course + ' · 下载 ' + d.heat).fontSize(9).fontColor(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.layoutWeight(1).alignItems(HorizontalAlign.Start)
Column({ space: 2 }) {
Text(d.size).fontSize(9).fontColor(COLORS.sub)
Text(trendIcon(d.trend)).fontSize(9).fontColor(trendColor(d.trend))
}
.alignItems(HorizontalAlign.End)
}
.width('100%')
.padding({ top: 8, bottom: 8 })
.borderRadius(10)
.backgroundColor(idx % 2 === 0 ? COLORS.chip : COLORS.card)
}, (d: DocItem) => d.rank)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
热门资料榜是一个白色卡片容器,内部包含标题行与八条榜单条目。标题行左侧"🏆 热门资料榜",右侧"按周下载量"说明排名依据。八条榜单条目通过 ForEach 遍历 docList 渲染,每条条目是一个 Row,从左到右排列:名次数字、资料信息列、右侧指标列。
名次数字使用 20 号加粗大字号,前三名(idx < 3)使用青柠绿高亮,第四名及以后使用三级灰色——这种"头部突出"设计使前三名在视觉上具有明显的优先级。名次 Text 设置了 width(30) 固定宽度,确保所有名次数字左对齐,不因数字位数不同而错位。资料信息列包含资料名(12 号加粗标题色,单行省略号)与"课程名·下载量"组合文本(9 号副文本色,单行省略号),通过字符串拼接将课程名与下载量合为一行,节省纵向空间。右侧指标列右对齐排列文件大小(9 号副文本色)与趋势文案(9 号,颜色通过 trendColor() 映射),使大小与趋势信息一目了然。条目背景使用 idx % 2 === 0 判断的斑马纹——偶数行为浅蓝芯片色,奇数行为卡片白色,相邻条目微妙的底色差异提升了长列表的可读性。
8.4 chartCard 图表卡
/** 图表卡:Canvas 近 6 个月课程资料月下载趋势折线图(呼吸动画联动重绘) */
@Builder
chartCard() {
Column({ space: 10 }) {
Row() {
Text('📈 资料月下载趋势').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('近 6 个月').fontSize(9).fontColor(COLORS.text3)
}
.width('100%')
Canvas(this.lineCtx)
.width('100%')
.height(200)
.onReady(() => {
this.canvasReady = true;
this.drawLineChart();
})
Row() {
Text('毕业季 + 暑期学习热带动 6~8 月下载增长').fontSize(9).fontColor(COLORS.sub)
Column().layoutWeight(1)
Text('环比 +15.0%').fontSize(9).fontColor(COLORS.green)
}
.width('100%')
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
图表卡是资料 Tab 的第三个组件,包含标题行、Canvas 画布与解读行三部分。标题行左侧"📈 资料月下载趋势",右侧"近 6 个月"标明数据范围。Canvas(this.lineCtx) 组件接收之前创建的 lineCtx 上下文对象作为参数,建立画布与上下文的绑定,设置 100% 宽度与 200 高度。onReady 回调在画布初始化完成后触发,将 canvasReady 设为 true 并立即调用 drawLineChart() 绘制初始图表——此后每秒的定时器回调会在 canvasReady 为 true 时再次调用 drawLineChart(),通过 breath 状态的翻转实现呼吸动画。
解读行位于图表下方,左侧"毕业季+暑期学习热带动 6~8 月下载增长"是对折线图走势的文字解读——6 月毕业季的论文答辩资料需求与 7-8 月暑期考研备考、技能提升需求共同推动了下载量增长。右侧"环比+15.0%"使用青柠绿色,量化展示增长率。解读行的存在使图表不仅是可视化展示,更有文字层面的趋势分析,增强了信息传达的完整性。图表卡的整体设计体现了"数据可视化+文字解读"双管齐下的数据呈现理念,使用户既能直观看到趋势走势,又能快速理解趋势背后的原因。
九、网页Tab构建
9.1 tabWeb 网页Tab
/** 网页 Tab:地址栏 + 快捷站点 + Web 组件 + 主动下载(ArkWeb 特性页) */
@Builder
tabWeb() {
Column({ space: 10 }) {
// 地址栏:输入 + 前往(urlInput/webUrl 双状态分离)
Row({ space: 8 }) {
TextInput({ text: this.urlInput, placeholder: '输入网址,如 www.coursera.org' })
.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.green).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(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding({ left: 10, right: 10, top: 6, bottom: 6 })
.backgroundColor(this.webUrl === site ? COLORS.green : COLORS.chip)
.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.chip)
// 主动下载演示行(应用侧 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.blue)
}
.width('100%')
Row({ space: 10 }) {
Text('下载考研数学讲义 PDF').fontSize(10).fontColor('#FFFFFF')
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.green).borderRadius(9)
.onClick(() => {
this.triggerDownload('https://cdn.wangkebao.cn/docs/kaoyan/math/kaoyan_math_handout_2027_v3.pdf');
})
Text('下载 Python 题库 ZIP').fontSize(10).fontColor(COLORS.blue)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 })
.borderRadius(9).border({ width: 1, color: COLORS.blue })
.onClick(() => {
this.triggerDownload('https://dl.wangkebao.cn/python/python_data_analysis_exercises_2.1.zip');
})
}
.width('100%')
Text('提示:网页内点击资料下载链接同样会触发 WebDownloadDelegate 四回调').fontSize(8).fontColor(COLORS.text3)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
}
网页 Tab 是 ArkWeb 特性的集中展示页,从上到下分为四个区块。第一是地址栏,由 TextInput 输入框与"前往"按钮组成。TextInput 绑定 urlInput 状态,onChange 回调实时更新 urlInput(但不触发页面加载),"前往"按钮点击调用 loadUrl() 方法,在协议补全后将 URL 同步到 webUrl 触发加载。这种"输入与加载分离"的设计,使用户可以在输入框中自由编辑 URL 而不会每次按键都触发页面刷新,只有点击"前往"后才开始加载——这是浏览器地址栏的标准交互范式。
第二是快捷站点横滑列表,四个真实学习平台站点通过 ForEach 渲染为横滑卡片。每个卡片显示链接 emoji 与 siteHost() 处理后的域名,点击后直接设置 urlInput 与 webUrl 两个状态为该站点 URL,实现一键加载。选中态(webUrl === site)背景为青柠绿,未选中态为浅蓝芯片色,使用户能直观看到当前加载的站点。第三是 Web 组件本体,接收 webUrl 作为 src(加载地址)与 webController 作为 controller(控制器),通过 layoutWeight(1) 占据地址栏与主动下载演示行之间的空间。Web 组件渲染后,网页内任何点击下载链接的行为都会被 webController 绑定的 downloadDelegate 拦截,进入四个回调流程。
第四是主动下载演示行,包含标题行、两个下载按钮与提示文字。标题行显示"应用侧主动下载演示"与当前下载状态(dlState),下载完成时状态文字为青柠绿,其他状态为天空蓝。两个下载按钮分别是"下载考研数学讲义 PDF"(青柠绿实底按钮)与"下载 Python 题库 ZIP"(天空蓝描边按钮),点击均调用 triggerDownload() 方法传入对应的 CDN 直链 URL。底部提示文字"网页内点击资料下载链接同样会触发 WebDownloadDelegate 四回调"告知用户:不仅主动下载按钮会触发代理回调,在上方 Web 组件内点击下载链接也会触发相同的回调流程——两种下载入口殊途同归,均会经过 onBeforeDownload→onDownloadUpdated→onDownloadFinish 的完整生命周期,最终在完成回调中通过双 URL 接口获取溯源信息。
十、下载Tab构建
10.1 tabDownload 下载Tab
/** 下载 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.green)
}
.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.green).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)
// 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('#9FC3D8')
Text(' (item: webview.WebDownloadItem) => {').fontSize(9).fontFamily('monospace').fontColor('#9FC3D8')
Text(' const original = item.getOriginalUrl();').fontSize(9).fontFamily('monospace').fontColor(COLORS.green)
Text(' const referrer = item.getReferrerUrl();').fontSize(9).fontFamily('monospace').fontColor(COLORS.gold)
Text(' });').fontSize(9).fontFamily('monospace').fontColor('#9FC3D8')
}
.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)
// 已完成记录列表(每条含双 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.green)
.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%')
}
下载 Tab 是双 URL 溯源特性的展示核心,从上到下分为三个区块。
10.2 进行中任务卡与代码预览卡
进行中任务卡展示当前下载任务的状态信息。标题行显示"资料下载任务"与右侧 dlState 状态文案(青柠绿色)。文件名行显示 dlName,如果为空则显示"暂无进行中任务(可在网页 Tab 触发)“的占位提示。Progress 组件是 ArkUI 内置的线性进度条,value 绑定 dlPercent(0-100),total 为 100,type 为 ProgressType.Linear,颜色为青柠绿,轨道背景为浅蓝芯片色,高度仅 6 形成细条进度条。底部行左侧显示"进度 X%”,右侧显示"保存至沙箱 filesDir",告知用户文件保存位置。整个任务卡随 dlPercent 与 dlState 的实时更新而动态变化,使用户能直观跟踪下载进度。
代码预览卡是本应用的一大特色——它将 HarmonyOS 6.1.1 新增的 onDownloadFinish 回调代码以代码预览的形式嵌入 UI。卡片标题"onDownloadFinish 回调(HarmonyOS 6.1.1 新增)"点明技术特性。代码块使用深色背景(COLORS.codeBg #12303B),每行代码是一个 Text 组件,使用 monospace 等宽字体与不同颜色语法高亮:普通代码行为浅蓝色(#9FC3D8),getOriginalUrl() 行为青柠绿,getReferrerUrl() 行为暖金色——颜色与下载记录中两个 URL 的颜色一致,形成视觉呼应。底部说明文字"原始 URL 追踪资料直链来源,引用页 URL 记录触发的课程详情页"解释了双 URL 的语义。这种将源代码嵌入 UI 的设计,使应用不仅是功能载体,更是技术文档的展示窗口。
10.3 双URL溯源记录列表
历史下载记录列表是下载 Tab 的第三个区块,也是双 URL 溯源能力的最终展示窗口。标题行显示"历史下载记录"与记录条数(downloadRecords.length 条)。记录通过 ForEach 遍历 downloadRecords 渲染,每条记录是一个浅蓝芯片色背景的 Column,内含三行内容。
第一行是记录信息行,从左到右排列:文件 emoji 图标、文件信息列(文件名+大小/时间/备注组合文本)、"改"按钮(副文本色,点击调用 openEditRecord(idx) 打开编辑弹窗)、"删"按钮(红色,点击设置 delIdx 并打开删除确认弹窗)。文件名使用 11 号加粗标题色,单行省略号截断;大小/时间/备注组合文本使用 9 号副文本色,将三个维度的信息拼为一行节省空间。第二行是原始 URL 溯源行,🔗 emoji 后跟 rec.originalUrl,使用 8 号等宽字体与青柠绿色——这个颜色与代码预览卡中 getOriginalUrl() 行的颜色一致,建立了"代码-数据"的视觉关联。第三行是引用页 URL 溯源行,📄 emoji 后跟 rec.referrerUrl,使用 8 号等宽字体与暖金色——同样与代码预览卡中 getReferrerUrl() 行的颜色一致。两行 URL 均使用 monospace 等宽字体,使 URL 中的路径分隔符、查询参数等字符对齐,便于阅读。maxLines(1) 与省略号截断确保长 URL 不会撑破卡片布局。
这种"每条记录展示双 URL"的设计,使用户能够对每份已下载资料追溯完整的来源链路:通过 originalUrl 可以知道这份文件是从哪个 CDN 直链下载的(如果直链仍有效,可以重新下载);通过 referrerUrl 可以知道这份文件是从哪个课程详情页触发的(可以回到该页面查看更多配套资料)。双 URL 的颜色编码(青柠绿=原始 URL,暖金=引用页 URL)贯穿代码预览卡与记录列表,形成了从"技术实现"到"数据展示"的完整视觉语言体系。
十一、我的Tab与底部导航
11.1 tabMine 我的Tab
/** 我的 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('资料会员 · 连续打卡 46 天').fontSize(9).fontColor('rgba(255,255,255,0.8)')
}
.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.22)')
}
.width('100%')
Row({ space: 8 }) {
Column({ space: 2 }) {
Text('268').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
Text('累计下载').fontSize(8).fontColor('rgba(255,255,255,0.7)')
}
.layoutWeight(1)
Column({ space: 2 }) {
Text('52').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
Text('本月记录').fontSize(8).fontColor('rgba(255,255,255,0.7)')
}
.layoutWeight(1)
Column({ space: 2 }) {
Text('86').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
Text('收藏资料').fontSize(8).fontColor('rgba(255,255,255,0.7)')
}
.layoutWeight(1)
Column({ space: 2 }) {
Text('12').fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
Text('上传笔记').fontSize(8).fontColor('rgba(255,255,255,0.7)')
}
.layoutWeight(1)
}
.width('100%')
}
.width('100%').padding(16).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.greenD, 0], [COLORS.green, 0.55], [COLORS.blue, 1]] })
// 功能清单行
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%')
}
我的 Tab 从上到下分为三个区块。第一是学习者渐变大卡,使用 135 度角的三色线性渐变——从深青柠绿(greenD)到标准青柠绿(green)到天空蓝(blue),形成从绿色到蓝色的跨色渐变,视觉上比头部 Banner 的单色系渐变更丰富。卡片内含两行内容:第一行是学习者信息,左侧毕业帽 emoji(34 号大字号),中间昵称"资料学霸养成中·小林"(15 号加粗白色)与副标题"资料会员·连续打卡 46 天"(9 号半透明白色),右侧"Lv.6 学习等级"徽章(半透明白底圆角)。第二行是四个学习统计数据列,每列包含数值(15 号加粗白色)与标签(8 号半透明白色),分别展示累计下载(268)、本月记录(52)、收藏资料(86)、上传笔记(12)四个维度的量化数据。
第二是功能清单行,通过 ForEach 遍历 statList 渲染八行功能项。每行从左到右排列:功能 emoji 图标(15 号)、功能名(11 号标题色,layoutWeight(1) 占据中间空间)、状态值(9 号三级文本色)、右箭头 ›(14 号三级文本色,仅当 st.arrow 为 true 时渲染)。每行底部有 1 像素的分割线(border({ width: { bottom: 1 }, color: COLORS.line })),使功能项形成清晰的行间分隔。点击事件中,当功能名为"下载历史"时切换到下载 Tab(currentTab = 2),实现了从"我的"到"下载"的跨 Tab 导航。第三是版本号文字"网课资料宝 v6.1.1·ArkWeb 双 URL 溯源版",8 号三级文本色,点明应用版本与核心技术特性。
11.2 tabBar 底部导航
/** 底部导航: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 })
}
底部导航是应用的 Tab 切换控件,通过 ForEach 遍历 TAB_LIST 渲染四个 Tab 项。每个 Tab 项是一个 Column(纵向排列图标与标签),通过 layoutWeight(1) 等分宽度,使四个 Tab 均匀分布在屏幕底部。选中态(currentTab === idx)的视觉变化有三处:图标字号从 17 放大到 20、透明度从 0.65 提升到 1.0(完全不透明)、标签文字颜色从三级灰变为青柠绿(COLORS.tabOn)且字重从 Normal 变为 Bold。这三种变化叠加,使选中 Tab 在视觉上明显突出于未选中 Tab。
padding({ top: 7, bottom: 7 }) 为每个 Tab 项设置上下 7 的内边距,使底部导航有足够的点击区域。整个 Row 设置卡片白底色与顶部 1 像素的分割线,与上方内容区形成清晰的边界分隔。点击任意 Tab 项设置 currentTab 为该索引,触发 build() 中 if-else 条件的重渲染,切换显示对应的 Tab 内容。底部导航的"图标放大+不透明度提升+颜色变绿+字重加粗"四重选中态反馈,是移动端 Tab 导航的经典设计范式。
十二、弹窗系统
12.1 modalOverlay 遮罩
/** 弹窗全屏遮罩(点击遮罩关闭弹窗) */
@Builder
modalOverlay(onClose: () => void) {
Stack() {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
.onClick(() => onClose())
}
modalOverlay 是弹窗系统的公共遮罩组件,接收一个 onClose 回调函数作为参数。组件使用 Stack 作为容器,内部只有一个 100% 宽高、半透明深青背景色(COLORS.mask,rgba(23,50,63,0.5))的 Column——这个 Column 覆盖整个屏幕,形成底层内容与弹窗之间的半透明隔离层。Stack 的 alignContent(Alignment.Center) 设置子内容居中对齐,但遮罩本身没有子内容(居中设置是为弹窗面板准备的——弹窗面板会在外层 Stack 中叠加在遮罩之上)。
遮罩的 onClick 绑定 onClose 回调,使用户点击遮罩区域(弹窗面板之外的区域)时关闭弹窗——这是模态弹窗的标准交互范式。遮罩作为公共组件被三个弹窗面板(panelAdd/panelEdit/panelDel)复用,每个弹窗在 Stack 中先调用 this.modalOverlay(onClose) 渲染遮罩,再叠加自身的弹窗内容。这种"公共遮罩+独立面板"的复用设计,使弹窗系统的代码结构清晰且易于维护——新增弹窗只需编写面板内容,遮罩行为自动复用。
12.2 panelAdd 新建下载
/** 新建下载任务弹窗面板(资料链接 + 备注) */
@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.wangkebao.cn/docs/xxx.pdf' })
.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.green).borderRadius(9)
.onClick(() => {
this.saveDownload();
})
}
.width('100%')
}
.width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
新建下载弹窗面板使用 Stack 包裹遮罩与面板内容。面板内容是一个 78% 宽度的 Column(居中显示),白色卡片背景与 14 的圆角。面板从上到下包含:标题"新建资料下载"(15 号加粗标题色)、资料下载链接输入区(标签+TextInput 绑定 formUrl)、备注输入区(标签+TextInput 绑定 formNote)、技术提示文字、取消与开始下载按钮行。
两个 TextInput 的 onChange 回调分别更新 formUrl 与 formNote 状态,使用户输入实时同步到组件状态。TextInput 的背景使用浅蓝芯片色(COLORS.chip)而非卡片白,使输入框在白色面板中具有微妙的凹陷感,形成视觉层次。技术提示文字"发起后经 startDownload 触发,完成回调记录双 URL"向用户说明了下载流程的技术路径——从 startDownload 发起,到 onDownloadFinish 完成回调中通过双 URL 接口记录溯源信息,使用户对下载溯源机制有基本的认知。
按钮行使用 Row 横向排列取消与开始下载两个按钮,各占 layoutWeight(1) 等分宽度。取消按钮使用浅蓝芯片色背景与副文本色文字,点击调用 onClose 关闭弹窗。开始下载按钮使用青柠绿背景与白色加粗文字,点击调用 saveDownload() 方法——该方法会使用 formUrl(空则兜底默认链接)调用 triggerDownload 发起下载,随后清空表单并关闭弹窗。两个按钮的颜色对比(灰底取消 vs 绿底确认)形成了操作风险层级的视觉编码,引导用户关注主操作。
12.3 panelEdit 编辑备注
/** 编辑备注弹窗面板(回填当前记录备注) */
@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.green).borderRadius(9)
.onClick(() => {
this.updateRecord();
})
}
.width('100%')
}
.width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
编辑备注弹窗的结构与新建弹窗高度相似,但只有一个输入框(备注)。面板标题为"编辑资料备注",TextInput 绑定 editNote 状态——该状态在 openEditRecord(idx) 方法被调用时已回填了当前记录的备注值,因此用户打开弹窗时输入框已显示当前备注内容,可在其基础上修改。确认按钮文案为"保存",点击调用 updateRecord() 方法,将 editNote 写入 downloadRecords[editIdx].note 字段,并通过 slice() 刷新数组引用触发列表重渲染。
编辑弹窗的设计体现了"编辑流程"的标准交互范式:先通过 openEditRecord 回填数据(确保用户看到的是当前值而非空白),再在弹窗中修改,最后保存时更新数据并刷新列表。updateRecord 方法中的 slice() 调用是关键——@Observed 对象的属性修改不会触发 ForEach 的列表级刷新,必须通过改变数组引用(slice() 返回新数组)才能使列表重新渲染,这是 ArkUI 中可观察数组刷新的标准技巧。
12.4 panelDel 删除确认
/** 删除记录确认弹窗面板 */
@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)
}
}
删除确认弹窗是一个纯确认型弹窗,不含输入框,只有图标、标题、说明文字与取消/删除按钮。面板宽度 72%(比新建与编辑弹窗的 78% 略窄),因为内容更少不需要太宽。顶部删除 emoji(30 号大字号)作为视觉焦点,标题"删除这条下载记录?"使用 14 号加粗标题色,说明文字"仅移除记录,不影响已保存到沙箱的资料文件"使用 9 号三级文本色——这段说明非常重要,它告知用户删除操作仅移除应用内的记录条目,不会删除已保存到沙箱目录的实际文件,消除了用户对"删除记录导致文件丢失"的顾虑。
按钮行中,取消按钮使用浅蓝芯片色(与新建/编辑弹窗一致),删除按钮使用红色背景(COLORS.red #E86A6A)与白色加粗文字。三套弹窗的确认按钮分别使用青柠绿(新建下载)、青柠绿(编辑保存)、红色(删除确认)——删除操作使用红色背景,是"危险操作"的视觉警示编码,与新建设计的绿色"安全操作"形成颜色风险层级区分。点击删除按钮调用 delRecord() 方法,通过 splice 删除 downloadRecords[delIdx] 条目并关闭弹窗。splice 会直接改变数组引用,触发 ForEach 重新渲染列表,被删除的记录从列表中消失。
十三、技术特性对比表格
| 技术维度 | 传统下载方案 | 本案例 ArkWeb 双 URL 溯源方案(HarmonyOS 6.1.1) | 优势说明 |
|---|---|---|---|
| 下载来源记录 | 仅记录文件名与保存路径 | getOriginalUrl + getReferrerUrl 双 URL 完整溯源 | 原始 URL 追踪资料直链,引用页 URL 记录课程详情页,来源链路完整 |
| 下载触发方式 | 仅网页内点击下载链接 | 网页内点击 + 应用侧 startDownload 主动发起 | 双入口触发同一代理回调,下载行为一致,支持粘贴链接直接下载 |
| 下载生命周期 | 系统默认下载流程不可控 | WebDownloadDelegate 四回调全接管 | onBeforeDownload 控制保存路径,onDownloadUpdated 监听进度,onDownloadFinish 溯源 |
| 保存路径控制 | 系统默认下载目录 | onBeforeDownload 中指定沙箱 filesDir | 文件保存到应用沙箱目录,安全可控,路径可拼接建议文件名 |
| 进度监听 | 无或需轮询系统下载管理器 | onDownloadUpdated 回调实时推送百分比 | 下载进行中反复回调 getPercentComplete,进度条实时刷新无延迟 |
| 失败处理 | 静默失败或系统通知 | onDownloadFailed 回调结构化错误信息 | 通过 getGuid 获取任务标识,dlState 显示失败原因,用户体验友好 |
| 来源审计能力 | 无来源信息,事后无法追溯 | 每条记录携带双 URL,支持来源审计 | 用户可随时查看每份资料的直链与课程页地址,支持二次获取与版权追溯 |
| Canvas 图表 | 需第三方图表库 | 原生 Canvas 2D API 自绘折线图 | 无第三方依赖,渐变面积+折线+数据点+标签六层自绘,呼吸动画联动 |
| 图表动画 | CSS 或动画库 | 单 breath 布尔状态驱动 Canvas 重绘 | 定时器每秒翻转布尔值,联动数据点半径与线宽变化,极简动画范式 |
| 主题系统 | 各处硬编码颜色 | ColorPalette 接口 + COLORS 常量集中管理 | 接口约束颜色字段,主题切换只需替换常量对象,全应用自动生效 |
| 弹窗系统 | 第三方 Modal 组件 | Stack 堆叠 + 状态驱动 + 遮罩回调 | 原生 Stack 层叠实现模态,公共遮罩复用,三套面板独立维护 |
| 数据模型 | interface 或 plain object | @Observed class + @State 数组 + slice 刷新 | 可观察对象自动驱动 UI 更新,slice 改变引用触发列表级刷新 |
| 地址栏交互 | 输入即加载或需手动拼协议 | urlInput/webUrl 双值分离 + 协议自动补全 | 敲字不等于加载,前往按钮同步双值,裸域名自动补 https:// |
| 选中态反馈 | 单一颜色变化 | 图标放大+不透明度提升+颜色变绿+字重加粗 | 四重反馈叠加,选中 Tab 视觉突出明显 |
| 操作风险编码 | 统一颜色按钮 | 新建绿色/编辑绿色/删除红色三色编码 | 通过颜色语义暗示操作风险层级,删除用红色警示 |
十四、总结
本文完整剖析了一个基于 HarmonyOS ArkUI 框架与 ArkWeb 6.1.1 双 URL 溯源新特性构建的网课资料下载平台应用。从技术架构层面看,该应用展示了 ArkUI 声明式开发范式的完整实践:@Entry/@Component/@State/@Builder/@Observed 等装饰器协同工作,构建了从入口组件到数据模型的全链路状态管理—声明式渲染体系。Stack 堆叠容器作为弹窗系统的底层支撑,实现了公共遮罩与独立面板的复用组合;Scroll 横向滚动实现学科 chips 与快捷站点的横滑列表;ForEach 实现数据驱动渲染;if-else 条件渲染实现 Tab 切换——这些原生容器组件的组合使用证明了 ArkUI 在不依赖第三方 UI 库的情况下,足以构建功能完整、交互丰富、视觉精致的应用界面。
从 ArkWeb 6.1.1 新特性的实践层面看,该应用将 WebDownloadDelegate 的四个下载生命周期回调全部纳入应用管控。onBeforeDownload 回调中通过 item.start(dir + '/' + item.getSuggestedFileName()) 指定沙箱保存路径,使文件下载到应用可控的 filesDir 目录;onDownloadUpdated 回调中通过 getPercentComplete() 实时获取进度百分比,驱动线性进度条与状态文案的动态刷新;onDownloadFailed 回调中通过 getGuid() 获取任务标识用于错误追踪;最关键的 onDownloadFinish 回调中调用 6.1.1 新增的 getOriginalUrl() 与 getReferrerUrl() 双接口,将资料的 CDN 直链地址与课程详情页地址完整记录到 DownloadRecord 模型中。startDownload() 方法支持应用侧主动发起下载,配合新建下载任务弹窗的"粘贴链接—发起下载—自动溯源"一站式流程,使下载溯源能力深度融入用户操作路径。两种下载入口(网页内点击与应用侧主动发起)殊途同归,均经过同一代理回调流程,保证了下载行为与溯源记录的一致性。
从 Canvas 图表绘制层面看,应用在资料 Tab 中使用原生 Canvas 2D API 自绘了近 6 个月课程资料下载量趋势折线图,包含背景网格横线、渐变面积填充、连续折线、数据点圆点、数值标签与月份标签六个图层。createLinearGradient 创建的青柠绿渐变面积填充使图表具有面积感与色彩浓度感;白底青柠绿描边的圆点形成"空心圆"数据点效果;数值标签与月份标签分别置于数据点上方与基线下方,信息完整。更为巧妙的是,折线图与呼吸动画联动——aboutToAppear 中启动的每秒定时器翻转 breath 布尔值,当 canvasReady 为 true 时调用 drawLineChart() 重绘,使数据点半径(4.5/3.5 切换)与线宽(2.6/2.0 切换)每秒变化,形成微妙的呼吸动效。这种用定时器翻转布尔值驱动 Canvas 重绘的方案,是声明式 UI 框架下实现画布动画的经典范式,体现了"最小状态—最大表达"的设计哲学。
从设计理念层面看,应用采用晨雾白底(#F5F8FC)+青柠绿(#22B573)+天空蓝(#3E9BE8)的浅色主题,营造了清新明亮的学习氛围。ColorPalette 接口与 COLORS 常量构成的集中式颜色管理系统,将背景类、文字类、主题强调类、辅助类颜色分组管理,确保了视觉一致性与主题可扩展性。三级文字灰度体系(标题深青/副文本灰蓝/三级文本浅灰)确保了信息层级的清晰传达。双 URL 的颜色编码(青柠绿=原始 URL,暖金=引用页 URL)贯穿代码预览卡与下载记录列表,形成了从"技术实现"到"数据展示"的完整视觉语言体系。三套弹窗的确认按钮分别使用绿色(新建/编辑,安全操作)与红色(删除,危险操作),通过颜色语义编码操作风险层级,是无障碍交互的细节体现。
从业务场景层面看,应用精准切入在线教育市场中"课程资料分散、下载后难以追溯来源"的核心痛点。DownloadRecord 模型的双 URL 字段使每份资料都能回答"从哪个课程页下载了哪个直链"的来源追溯问题——originalUrl 记录 CDN 直链支持二次下载,referrerUrl 记录课程详情页支持回到原页面查看更多配套资料。六条 Mock 数据覆盖了课程详情页触发与搜索页触发两种来源路径,八条资料榜单数据覆盖了考研、编程、语言、职场、设计、考证六大主流学习品类,与头部学科分类形成呼应。我的页功能清单中"来源审计"项以"双 URL 溯源已开启"的文案向用户呈现技术特性,将底层技术能力转化为用户可感知的功能状态。总体而言,该应用是 HarmonyOS 原生生态中 Web 能力与垂直业务场景融合的典型范例,对 ArkWeb 6.1.1 双 URL 溯源特性的实践具有直接的参考价值,为 HarmonyOS 开发者理解与运用下载溯源能力提供了完整的代码级参考。
附录: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 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 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 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)