一、技术前言

在高校信息化建设浪潮中,校园媒体资讯平台是连接师生与校园生态的核心枢纽。从头条要闻的横滑浏览到频道栏目的嵌套翻页,从校园站点的网页直达到下载文件的双链溯源,从晨报速递的 AI 字幕听报到个人订阅的身份管理,每一项功能都需要精确的数据驱动、流畅的滚动交互和丰富的多媒体能力。传统校园应用往往面临三大困境:资讯分类缺乏视觉锚点导致浏览效率低下、下载来源无法追溯导致文件去向不明、听力障碍或静音场景下音频内容难以触达。

HarmonyOS ArkUI 框架以其声明式 UI 范式为这些问题提供了系统级的解决方案。ArkUI 基于 TypeScript 扩展的 ArkTS 语言,通过 @Component 装饰器封装可复用组件,通过 @State@Observed 等状态管理装饰器实现数据驱动渲染,通过 @Builder 方法将复杂的 UI 结构拆分为可组合的构建块。linearGradient 线性渐变为页面注入青春蓝视觉基调,ForEach 配合 Scroll 实现横滑大卡与列表渲染,Stack 容器将弹窗遮罩叠加在页面之上。这种架构天然适合校园资讯场景中"多 Tab 独立布局、状态跨页共享、特性深度集成"的需求。

本平台深度融合了 HarmonyOS 6.1.1 的三大前沿特性。Speech Kit AI 字幕通过 AICaptionComponent 组件构建实时字幕预览,AICaptionOptions 新增 sourceLanguage/targetLanguage/fontSize/fontColor 四字段实现语言方向与字体样式的精细控制,中文源时目标语言锁定 'zh'writeAudio 以 640 字节 PCM 块分批写入演示音频流,字号采用 AICaptionFontSize 四档枚举(SMALL/NORMAL/BIG/LARGE)而非普通数字。ArkWeb 下载双 URL 溯源通过 WebDownloadDelegate 四回调链路——onBeforeDownload 提供沙箱路径启动任务、onDownloadUpdated 刷新进度条、onDownloadFailed 捕获失败、onDownloadFinish 中调用 getOriginalUrl(原始直链 URL)与 getReferrerUrl(引用页 URL)双接口还原每次下载来路,文件大小用 getTotalBytes() 换算,startDownload 支持应用侧主动发起下载。Tabs 嵌套滚动通过 nestedScroll(TabsNestedScrollMode) 挂载在内层 Tabs 上,SELF_FIRST 模式下内层栏目滑到边缘后外层频道接力翻页,一次手势完成两级切换,SELF_ONLY 模式下手势在内层终结需抬手再滑外层,全程零 import 直接使用 API 24 全局枚举。

从工程架构视角审视,本平台的核心设计理念是"状态集中声明、视图分散渲染"。所有状态变量统一声明在组件顶层——无论是呼吸动画的 breath 布尔翻转、弹窗三态的布尔开关,还是三大特性的配置参数——而渲染逻辑分散到各 Tab 的 @Builder 方法中。这种设计使跨 Tab 数据共享自然实现:我的 Tab 的"溯源"按钮可以直接修改 currentTab 跳转到下载 Tab,头部副标题可以读取任意 Tab 的状态数据,弹窗操作可以修改头条 Tab 的 newsList 并实时反映到横滑大卡。同时,@Observed 装饰器使数据模型类的实例具备可观察性,当对象属性变更时通知所有引用处刷新,实现数据到视图的单向流动。

二、整体架构流程图

弹窗系统三态

三大特性挂载

内容区 Tab 路由

页面根容器 Stack

主组件入口

headerBanner 头部青春蓝渐变Banner

内容区 6 Tab条件渲染

tabBar 底部6Tab导航

modalOverlay 弹窗遮罩层

头条Tab
横滑大卡+热榜+月度柱状图

频道Tab
频道×栏目双层Tabs嵌套

网页Tab
地址栏+快捷站点+Web组件

下载Tab
进度卡+双URL溯源记录

听报Tab
AI字幕五区块配置

我的Tab
身份渐变卡+收藏清单

特性C Tabs嵌套滚动
nestedScroll模式

特性B ArkWeb下载
双URL溯源链路

特性A Speech Kit
AI字幕四字段配置

panelAdd 新增要闻弹窗

panelEdit 编辑来源时间弹窗

panelDel 删除确认弹窗

刊号语联动副标题

三特性状态胶囊行

呼吸圆点动画

整体架构以主组件为根入口,采用 Stack 容器实现页面层叠:底层是 Column 纵向布局的头部渐变 Banner + 内容区 + 底部 Tab 栏,顶层是全屏弹窗遮罩。内容区通过 currentTab 状态索引在 6 个 @Builder 方法间条件渲染,每个 Tab 拥有完全独立的布局结构。三大特性分别挂载在频道、网页、听报三个 Tab 上,但它们的状态变量统一声明在组件顶层,实现跨 Tab 数据共享。弹窗系统以三态独立渲染——新增、编辑、删除各自拥有独立的 @State 布尔开关与 @Builder 方法,点遮罩即可关闭。呼吸动画定时器每秒翻转 breath 布尔值,联动头部圆点闪烁与柱状图柱高波动,为静态页面注入生命感。

三、色彩体系设计

3.1 ColorPalette 接口定义

平台采用浅色青春蓝白主题,通过 ColorPalette 接口集中声明全部颜色字段,确保颜色管理的单一数据源。接口定义在编译期即约束所有颜色字段必须是 string 类型,任何拼写错误或类型不匹配都会在编译阶段暴露,体现了 ArkTS 类型安全的优势:

interface ColorPalette {
  bg: string;       // 页面底色·浅云蓝白
  card: string;     // 卡片底色·纯白
  chip: string;     // 胶囊/输入底色·浅湖蓝
  title: string;    // 主标题·墨蓝黑
  sub: string;      // 次级文字·青灰蓝
  text3: string;    // 弱化文字·雾蓝灰
  blue: string;     // 主色·青春蓝
  red: string;      // 辅色·活力红(热榜 TOP1)
  green: string;    // 辅色·青葱绿
  orange: string;   // 辅色·暖阳橙
  line: string;     // 分割线·浅雾线
  tabOn: string;    // Tab 激活色·青春蓝
  mask: string;     // 弹窗遮罩·墨蓝半透
  white: string;    // 渐变卡上的纯白文字
  whiteSoft: string; // 渐变卡上的弱化白文字
  trackW: string;   // 渐变卡上的进度条轨道色
  gradA: string;    // 渐变起点·青春蓝(头部/身份卡)
  gradB: string;    // 渐变终点·深青春蓝(统计大卡)
}

注意接口中声明了 blueD 字段(深青春蓝)但未在接口类型中列出——这是 ArkTS 的灵活性体现,开发者在实现时按需扩展。接口注释采用"字段名 + 用途"的格式,使每个颜色的语义角色一目了然,后续维护者无需追踪代码即可理解色彩用途。whitewhiteSoft 专为渐变卡上的文字设计,确保蓝底白字的高对比可读性;trackW 是渐变卡上进度条的半透明轨道色,与纯白文字形成层次区分。

3.2 COLORS 常量逐色分析

const COLORS: ColorPalette = {
  bg: '#F4F7FB',      // 浅云蓝白,校园清晨视觉基调
  card: '#FFFFFF',    // 纯白卡片,最大对比度突出内容
  chip: '#E9F0F8',    // 浅湖蓝胶囊底,柔和区分交互元素
  title: '#1F2A3A',   // 墨蓝黑标题,保证浅色环境可读
  sub: '#5C6B80',     // 青灰蓝副标题,层次柔和过渡
  text3: '#93A2B5',   // 雾蓝灰弱文本,辅助信息不抢视觉
  blue: '#3B82F6',    // 青春蓝主色,交互元素的视觉锚点
  blueD: '#2563C9',   // 深青春蓝,渐变终点与招聘分类
  red: '#EF5350',     // 活力红,热榜 TOP1 与删除操作
  green: '#34A870',   // 青葱绿,学术分类与完成状态
  orange: '#F5A623',  // 暖阳橙,社团分类与进行中状态
  line: '#DFE8F2',    // 浅雾分割线,低对比不干扰内容
  tabOn: '#3B82F6',   // Tab 选中色与主色一致
  mask: 'rgba(31,42,58,0.5)', // 墨蓝半透遮罩
  white: '#FFFFFF',   // 渐变卡纯白文字
  whiteSoft: 'rgba(255,255,255,0.85)', // 渐变卡弱化白文字
  trackW: 'rgba(255,255,255,0.35)',   // 渐变卡进度轨道色
  gradA: '#3B82F6',   // 渐变起点·青春蓝
  gradB: '#2563C9'    // 渐变终点·深青春蓝
};

色彩设计遵循"青春活力"原则,每一色都有明确的语义角色。bg#F4F7FB 浅云蓝白,模拟校园清晨天空的视觉基调,降低白光对眼睛的刺激。card 为纯白 #FFFFFF,卡片与背景形成柔和对比,保证信息区块的清晰边界。chip#E9F0F8 浅湖蓝,用于胶囊徽章和输入框底色,与卡片底色仅差一档亮度,既区分又不突兀。

title#1F2A3A 墨蓝黑,主标题文字色,与浅色背景形成高对比度但不刺眼。sub#5C6B80 青灰蓝,副标题色,在标题与弱文本之间架起层次过渡。text3#93A2B5 雾蓝灰,三级弱文本,用于辅助说明和时间戳,视觉权重最低。

青春蓝作为主色统领全局交互,活力红、青葱绿、暖阳橙三色分别对应"热榜 TOP1/学术分类/社团分类"三种语义状态。red#EF5350 活力红,仅用于热榜 TOP1 和删除操作,通过低频使用强化警示语义。green#34A870 青葱绿,用于学术分类与完成状态。orange#F5A623 暖阳橙,用于社团分类与进行中状态。blueD#2563C9 深青春蓝,用于渐变终点和招聘分类。

头部 Banner 的 linearGradientgradA(青春蓝)到 gradB(深青春蓝)以 160 度角实现自然过渡,我的 Tab 身份卡则使用 135 度角渐变营造立体感。渐变卡上的文字一律使用 white 纯白或 whiteSoft 弱化白,确保蓝底白字的高对比可读性。maskrgba(31,42,58,0.5) 墨蓝半透,弹窗遮罩使用 RGBA 格式实现 50% 透明度,与青春蓝系协调。tabOnblue 同值,保证 Tab 选中态与主色一致。

四、Tab 元数据与常量定义

4.1 底部导航 Tab 定义

底部导航采用 6 Tab 单排布局,通过 TabMeta 接口声明图标与标签,TAB_LIST 常量数组承载全部导航项。这种将导航元数据与 UI 渲染分离的设计使 Tab 配置可独立维护,新增或调整 Tab 只需修改数组而无需触碰 @Builder 方法:

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

const TAB_LIST: TabMeta[] = [
  { icon: '📰', label: '头条' },
  { icon: '🌀', label: '频道' },
  { icon: '🌐', label: '网页' },
  { icon: '📥', label: '下载' },
  { icon: '🎧', label: '听报' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了 Tab 导航项的最小数据结构:icon 为 emoji 字符串,label 为中文标签文字。六个 Tab 按顺序分别对应头条要闻、频道嵌套、网页浏览、下载溯源、听报字幕和个人中心。底部导航栏在 tabBar() 构建器中通过 ForEach 遍历此数组渲染,选中态通过 currentTab 索引与 index 比较判断。选中 Tab 的图标在 breath 为 true 时透明度为 1(完全显示),为 false 时为 0.78(略暗),形成微妙的呼吸闪烁效果。

4.2 嵌套频道与栏目常量

频道 Tab 采用外层频道 × 内层栏目双层结构。ChannelItem 接口定义了外层频道的名称与图标,OUTER_CHANNELS 常量数组声明了 5 个校园频道:

interface ChannelItem {
  name: string;  // 频道名(要闻/学术/社团/体育/招聘)
  icon: string;  // 频道图标
}

const OUTER_CHANNELS: ChannelItem[] = [
  { name: '要闻', icon: '📰' },
  { name: '学术', icon: '🔬' },
  { name: '社团', icon: '🎭' },
  { name: '体育', icon: '⚽' },
  { name: '招聘', icon: '💼' }
];

五个频道分别对应校园资讯的五大板块——要闻、学术、社团、体育、招聘,每个频道配备一个语义化 emoji 图标。barMode(BarMode.Scrollable) 横滑页签模式下,当频道数超过屏幕宽度时自动启用横滑,保证在窄屏设备上也能完整展示全部频道。

const INNER_TABS: string[] = ['推荐', '最新', '热门', '深度', '图集'];
const NEWS_CATS: string[] = ['头条', '学术', '社团', '体育', '招聘'];

INNER_TABS 定义 5 个内层栏目子页签——推荐、最新、热门、深度、图集,每个子页签内通过 innerMockData 生成器生成 8 条资讯卡片,保证内容超一屏,这是 nestedScroll 演示的前提——内容必须溢出才能触发内层滚动到边缘后外层接力翻页的效果。NEWS_CATS 定义要闻分类常量,与频道名一一对应,横滑大卡分类 chips 与新增弹窗共用此常量,保证分类体系统一。

4.3 快捷站点与语言常量

interface QuickSite {
  icon: string;  // 站点图标
  name: string;  // 站点名
  url: string;   // 站点地址
}

const QUICK_SITES: QuickSite[] = [
  { icon: '🏫', name: '北京大学', url: 'https://www.pku.edu.cn' },
  { icon: '📚', name: '北大图书馆', url: 'https://lib.pku.edu.cn' },
  { icon: '🎓', name: '清华大学', url: 'https://www.tsinghua.edu.cn' },
  { icon: '📖', name: '中国教育在线', url: 'https://www.eol.cn' }
];

快捷站点常量定义 4 个高校/教育类真实站点,点击即加载到 Web 组件。当前加载站点的 chips 高亮为青春蓝底白字,其余为浅湖蓝底青灰蓝字。这些站点 URL 在 tabWeb 构建器中通过 ForEach 渲染为横滑 chips 行,点击直接设置 urlInputwebUrl 为站点 URL 并加载。

interface LangOption {
  code: string;  // 语言码
  name: string;  // 展示名
}

const SRC_LANGS: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' }
];

const TGT_LANGS_EN: LangOption[] = [
  { code: 'zh', name: '中文' },
  { code: 'en', name: '英文' },
  { code: 'zh-en', name: '中英双语' }
];

源语言常量 SRC_LANGS 提供中文/英文二选一,英文源时的目标语言常量 TGT_LANGS_EN 提供中文/英文/中英双语三选一。注意中文源时目标语言锁定 'zh'(取值范围仅 ['zh'],选其他值初始化失败),因此代码中中文源时目标语言区域不渲染选择 chips,而是显示"中文(锁定)“灰色文案并提示"中文源仅支持目标 zh”。

4.4 字幕字号与颜色常量

interface SizeOption {
  size: AICaptionFontSize;  // 枚举档位(非 number)
  name: string;             // 展示名
}

const SIZE_OPTIONS: SizeOption[] = [
  { size: AICaptionFontSize.SMALL, name: '小号' },
  { size: AICaptionFontSize.NORMAL, name: '标准' },
  { size: AICaptionFontSize.BIG, name: '大号' },
  { size: AICaptionFontSize.LARGE, name: '超大' }
];

const CAPTION_FONT_COLORS: string[] = ['#FFFFFF', '#FFE9B0', '#9CE8B5', '#9CD0FF', '#FFB3C1'];

字号四档常量 SIZE_OPTIONS 封装 AICaptionFontSize 枚举值与展示名。注意 size 字段的类型是 AICaptionFontSize 枚举而非普通 number,这是 6.1.1 版本的关键设计——字号采用四档枚举(SMALL/NORMAL/BIG/LARGE),默认标准 NORMAL,开发者不能传入任意数字。字幕字体颜色预设 CAPTION_FONT_COLORS 包含五种颜色(纯白/暖黄/薄荷绿/天蓝/粉红),fontColorResourceColor 类型,直接传 '#RRGGBB' 字符串即可。选中态叠加 bg 色的勾选标记,点击圆点更新 captionColor 状态,实时反映到字幕预览。

4.5 业务数据常量

月度柱状图数据由三个并行数组构成,MONTH_IDX(索引)、MONTH_NAME(月份名)、READ_VAL(阅读量),BAR_MAX 作为满刻度换算基准:

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['03月', '04月', '05月', '06月', '07月', '08月'];
const READ_VAL: number[] = [126, 168, 143, 192, 176, 214];
const BAR_MAX: number = 240;

6 个月校报阅读量数据驱动头条 Tab 的传统柱状图,最高值 214 千次对应当月(08月),柱高由 barHeight(i) 函数按 READ_VAL[i] / BAR_MAX * 96 换算,breath 布尔值控制奇偶柱交替波动正负 6%。满刻度 BAR_MAX 为 240 千次,是全部数据最大值 214 的约 1.12 倍,保证柱子不会顶满图表区域。

interface HotItem {
  title: string;  // 热榜标题
  hot: number;    // 热度值
}

const HOT_RANK: HotItem[] = [
  { title: '我校团队破解硅光芯片耦合难题', hot: 48600 },
  { title: '2026 春季双选会 320 家企业进校', hot: 41200 },
  { title: '图书馆 24 小时自习区明起试运行', hot: 37800 },
  { title: '校男篮逆转夺 CUBA 东南区冠军', hot: 35400 },
  { title: '三位学者入选新一批杰青名单', hot: 29900 },
  { title: '话剧社年度大戏《雷雨》开票', hot: 26300 },
  { title: '人工智能通识课下学期全覆盖', hot: 23800 },
  { title: '百年校庆志愿者招募启动', hot: 21500 }
];

校园热榜 HOT_RANK 包含 8 条真实校园话题,每条含标题与热度值,热度从 48600 递减至 21500。大编号列宽度固定 34px,TOP1~3 使用 20px 加粗字号高亮,其余使用 16px。编号颜色由 rankColor 函数按名次返回活力红/暖阳橙/青葱绿/雾蓝灰,形成前三名视觉梯度。

const INNER_TITLES: string[] = [
  '人事任免', '评比公示', '讲座预告', '赛事战报',
  '招生动态', '就业双选', '社团夜校', '校庆筹备'
];

const INNER_NOTES: string[] = [
  '校长办公会审议通过新版学分制细则,下周一起在两校区试行',
  '国家奖学金初评名单公示三天,接受实名书面异议',
  '图灵奖得主受邀开讲《硅光时代》,本科生可预约现场席位',
  '校运会游泳预赛刷新两项校纪录,决赛本周六上午开赛',
  '强基计划新增智能感知方向,报名通道月底关闭',
  '春季双选会企业名录已更新,支持按行业与城市筛选',
  '话剧社《雷雨》加开周六午场,票务通道今晚八点开启',
  '百年校庆志愿者第二批次招募,岗前培训同步开放'
];

内层栏目标题素材池 INNER_TITLESINNER_NOTES 各 8 条,配合栏目名拼装行业化标题与描述。innerMockData 生成器按频道与栏目名拼接 8 条数据,标题格式为"栏目名·素材池标题 第 N 条",描述格式为"频道图标 频道名 频道 栏目名 栏目 第 N 条:行业注解"。

4.6 收藏与订阅常量

interface FavRow {
  icon: string;  // 收藏类型图标
  label: string; // 收藏内容名
  hint: string;  // 大小/来源提示
}

const FAV_ROWS: FavRow[] = [
  { icon: '📄', label: '2026 夏季校报 PDF 合订本', hint: '2.8 MB' },
  { icon: '🎬', label: 'AI 芯片讲座全程回放', hint: '486 MB' },
  { icon: '🏀', label: 'CUBA 东南王决赛集锦', hint: '128 MB' },
  { icon: '📊', label: '双选会企业名录表格', hint: '1.2 MB' },
  { icon: '📅', label: '2026-2027 学年校历', hint: '0.9 MB' },
  { icon: '🎨', label: '社团招新海报素材包', hint: '15.6 MB' }
];

const SUB_CHIPS: string[] = ['要闻', '学术', '社团', '体育', '招聘', '讲座'];

收藏清单 FAV_ROWS 含 6 行校园资料,与下载记录一一呼应——每行的文件大小提示与下载 Tab 的 DOWNLOAD_RECORDS 中的文件大小完全一致,点击"溯源"按钮将 currentTab 设为 3 跳转到下载 Tab 查看双 URL 溯源信息,实现跨 Tab 导航联动。已订阅频道 SUB_CHIPS 含 6 个频道 chips,"要闻"频道高亮为青春蓝底白字,其余为浅湖蓝底青灰蓝字,与外层频道呼应。

五、工具函数层

工具函数层将业务逻辑从 UI 中解耦,共定义 8 个纯函数,覆盖模式文案翻译、语言码转换、分类配色、热榜着色、热度格式化与下载状态映射。所有函数均为无副作用的纯函数——入参相同则返回值相同,不依赖也不修改组件状态,使函数可独立测试和复用。

5.1 嵌套模式文案翻译

/** 嵌套模式完整文案:SELF_FIRST=先内后外 / SELF_ONLY=仅内层(nestedScroll 枚举翻译) */
function modeLabel(mode: TabsNestedScrollMode): string {
  return mode === TabsNestedScrollMode.SELF_FIRST
    ? 'SELF_FIRST·先内后外' : 'SELF_ONLY·仅内层';
}

/** 嵌套模式短文案(头部胶囊与模式切换 chips 用) */
function modeShort(mode: TabsNestedScrollMode): string {
  return mode === TabsNestedScrollMode.SELF_FIRST ? '先内后外' : '仅内层';
}

modeLabelTabsNestedScrollMode 枚举值翻译为完整中文文案,用于模式状态卡的行为解释说明。modeShort 返回简短文案,用于头部胶囊和模式切换 chips 的紧凑展示。这两个函数的设计体现了"文案与逻辑分离"的原则——枚举值的显示文案集中管理,后续修改文案只需改一处。

5.2 语言码转换

/** 语言码转展示名:zh→中文 / en→英文 / zh-en→中英双语 */
function langName(code: string): string {
  if (code === 'zh') {
    return '中文';
  }
  if (code === 'en') {
    return '英文';
  }
  return '中英双语';
}

langName 将语言码('zh'/'en'/'zh-en')转换为展示名,用于头部副标题(如"中文→中英双语")和字幕场景卡的语言方向标注。当场景卡的源/目标语言与当前配置匹配时,语言方向标注以青葱绿高亮,提供直观的"已应用"反馈。

5.3 分类配色与图标

/** 要闻分类配色:头条青春蓝 / 学术青葱绿 / 社团暖阳橙 / 体育活力红 / 招聘深青春蓝 */
function catColor(cat: string): string {
  if (cat === '头条') {
    return COLORS.blue;
  }
  if (cat === '学术') {
    return COLORS.green;
  }
  if (cat === '社团') {
    return COLORS.orange;
  }
  if (cat === '体育') {
    return COLORS.red;
  }
  if (cat === '招聘') {
    return COLORS.blueD;
  }
  return COLORS.text3;
}

/** 要闻分类图标:与外层频道图标一致,横滑大卡封面用 */
function catIcon(cat: string): string {
  if (cat === '头条') {
    return '📰';
  }
  if (cat === '学术') {
    return '🔬';
  }
  if (cat === '社团') {
    return '🎭';
  }
  if (cat === '体育') {
    return '⚽';
  }
  return '💼';
}

catColorcatIcon 按要闻分类返回对应的青春蓝/青葱绿/暖阳橙/活力红/深青春蓝配色与图标。横滑大卡封面色条与热度徽标共用 catColor 函数,确保分类视觉一致——用户凭颜色即可快速识别资讯分类。catIcon 返回的图标与 OUTER_CHANNELS 中的频道图标一一对应,保证同一分类在频道页签和大卡封面中视觉统一。

5.4 热榜排名着色

/** 热榜大编号配色:TOP1 活力红 / TOP2 暖阳橙 / TOP3 青葱绿 / 其余雾蓝灰 */
function rankColor(idx: number): string {
  if (idx === 0) {
    return COLORS.red;
  }
  if (idx === 1) {
    return COLORS.orange;
  }
  if (idx === 2) {
    return COLORS.green;
  }
  return COLORS.text3;
}

rankColor 按热榜排名返回配色——TOP1 活力红、TOP2 暖阳橙、TOP3 青葱绿、其余雾蓝灰,形成前三名视觉梯度。大编号列和热度徽标共用此函数,使排名信息在颜色和数字两个维度同时传达,提升信息获取效率。

5.5 热度值格式化

/** 热度值格式化:过万缩写为 w(48600 → 4.9w) */
function hotText(hot: number): string {
  if (hot >= 10000) {
    return (hot / 10000).toFixed(1) + 'w';
  }
  return hot.toString();
}

hotText 将热度值过万缩写为 w(如 48600 格式化为 4.9w),在空间受限的徽标中节省字符宽度。使用 toFixed(1) 保留一位小数,保证 4.9w 比 48600 更易读。横滑大卡的热度徽标和热榜列表的热度标签共用此函数。

5.6 下载状态映射

/** 下载状态配色:失败活力红 / 进行中暖阳橙 / 完成青葱绿 / 空闲雾蓝灰 */
function dlStateColor(state: string): string {
  if (state.indexOf('失败') >= 0) {
    return COLORS.red;
  }
  if (state.indexOf('下载') >= 0 || state.indexOf('开始') >= 0 || state.indexOf('发起') >= 0) {
    return COLORS.orange;
  }
  if (state.indexOf('完成') >= 0) {
    return COLORS.green;
  }
  return COLORS.text3;
}

dlStateColor 按下载状态文案关键字匹配返回失败活力红、进行中暖阳橙、完成青葱绿、空闲雾蓝灰。关键字匹配使用 indexOf 而非精确匹配,因为状态文案包含动态信息(如"正在下载 45%"、“下载失败 · abc123”),模糊匹配能覆盖更多文案变体。头部下载状态胶囊和下载 Tab 的状态文案共用此函数,保证同一状态的色彩一致。

六、数据模型层

数据模型层定义 5 个 @Observed 类与 1 个 Mock 生成器函数,支撑头条要闻、内层栏目卡片、滑动日志、下载记录与字幕场景五大业务实体。@Observed 装饰器使类的实例具备可观察性,当对象属性变更时通知所有引用处刷新。配合 this.newsList = this.newsList.slice() 触发数组级刷新,实现数据变更到视图更新的自动传播。

6.1 NewsItem 要闻模型

@Observed
export class NewsItem {
  title: string;   // 要闻标题
  cat: string;     // 分类(头条/学术/社团/体育/招聘)
  source: string;  // 来源(校新闻中心/就业指导中心等)
  time: string;    // 发布时间
  hot: number;     // 热度值

  constructor(title: string, cat: string, source: string, time: string, hot: number) {
    this.title = title;
    this.cat = cat;
    this.source = source;
    this.time = time;
    this.hot = hot;
  }
}

NewsItem 是头条 Tab 横滑大卡的业务实体,也是弹窗增删改的绑定对象。五个字段分别承载标题、分类、来源、时间与热度值。@Observed 装饰器使其属性变化能被 ArkUI 框架感知——当 editNews() 方法更新 newsList[editIdx].source 时,横滑大卡中对应卡片的来源标签自动刷新。构造函数接收全部五个参数,保证实例创建时数据完整性。

const NEWS_LIST: NewsItem[] = [
  new NewsItem('我校团队破解硅光芯片耦合难题', '头条', '校新闻中心', '今天 08:30', 48600),
  new NewsItem('2026 春季双选会 320 家企业进校', '招聘', '就业指导中心', '今天 07:55', 41200),
  new NewsItem('图书馆 24 小时自习区明起试运行', '头条', '图书馆', '昨天 21:10', 37800),
  new NewsItem('校男篮逆转夺 CUBA 东南区冠军', '体育', '体育部', '昨天 19:42', 35400),
  new NewsItem('三位学者入选新一批杰青名单', '学术', '科研院', '昨天 15:08', 29900),
  new NewsItem('话剧社年度大戏《雷雨》开票', '社团', '学生艺术团', '08-27 20:15', 26300),
  new NewsItem('人工智能通识课下学期全覆盖', '学术', '教务处', '08-27 11:26', 23800),
  new NewsItem('百年校庆志愿者招募启动', '社团', '校团委', '08-26 09:30', 21500)
];

NEWS_LIST 常量预置 8 条真实校园话题,涵盖硅光芯片突破、双选会、图书馆自习区、CUBA 夺冠、杰青入选、话剧开票、AI 通识课、校庆志愿者等多元主题,每条携带分类、来源、时间与热度值。新增弹窗发布的要闻默认热度 12000,unshift 到列表头部,slice() 触发数组级刷新使新卡片立即出现在横滑大卡最左侧。

6.2 InnerCard 与 innerMockData 生成器

@Observed
export class InnerCard {
  id: string;     // 唯一键(频道-栏目-序号)
  tag: string;    // 所属栏目子页签名
  title: string;  // 卡片标题(栏目·题材 第 N 条)
  desc: string;   // 卡片描述(频道栏目路径 + 行业注解)

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

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

InnerCard 是嵌套 Tabs 列表条目模型,包含唯一键 id、栏目标签 tag、标题 title 与描述 descinnerMockData 生成器按频道与栏目名拼接 8 条数据,保证内容超一屏。唯一键格式为"频道名-栏目名-序号"(如"学术-推荐-3"),作为 ForEach 的键值函数返回值确保每条卡片的唯一标识。标题格式为"栏目名·素材池标题 第 N 条",描述格式包含频道图标、频道名、栏目名、序号和 INNER_NOTES 注解池的内容。

6.3 SwipeLog 滑动日志模型

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

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

SwipeLog 记录外层/内层翻页行为,包含层级 layer(外层频道/内层栏目)、页签名 tabName、起始索引 fromIdx、目标索引 toIdx、触发时嵌套模式 mode 与自动生成的 time 时间戳。时间戳使用 padStart(2, '0') 补零保证格式一致(如 09:05:03)。每次翻页 unshift 置顶,列表超过 40 条时 pop 尾部,recentLogs 方法取最近 4 条压缩展示。外层日志用暖阳橙徽标,内层用青葱绿徽标,形成双色视觉区分。

6.4 DownloadRecord 双 URL 溯源模型

@Observed
export class DownloadRecord {
  fileName: string;     // 文件名(getSuggestedFileName 结果)
  fileSize: string;     // 文件大小(getTotalBytes 换算)
  finishTime: string;   // 完成时间
  originalUrl: string;  // 原始 URL 地址(getOriginalUrl 结果)
  referrerUrl: string;  // 引用页 URL 地址(getReferrerUrl 结果)

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

DownloadRecord 是特性 B 的核心数据载体。originalUrl 存储文件直链来源地址(如 https://news.pku.edu.cn/download/campus_paper_2026summer.pdf?from=campus),referrerUrl 存储触发下载的引用页面地址(如 https://news.pku.edu.cn/paper/list?year=2026)。这两个字段让用户能追溯每次下载的完整来路——不仅知道下载了什么文件,还知道从哪个页面发起的下载。下载完成回调中 unshift 新记录置顶到列表头部。

const DOWNLOAD_RECORDS: DownloadRecord[] = [
  new DownloadRecord('campus_paper_2026summer.pdf', '2.8 MB', '今天 09:12',
    'https://news.pku.edu.cn/download/campus_paper_2026summer.pdf?from=campus',
    'https://news.pku.edu.cn/paper/list?year=2026'),
  new DownloadRecord('ai_chip_lecture_full.mp4', '486 MB', '今天 08:40',
    'https://media.tsinghua.edu.cn/lecture/2026/ai_chip_full.mp4?track=edu',
    'https://www.tsinghua.edu.cn/lecture/list.htm?id=204'),
  new DownloadRecord('cuba_final_highlight.mp4', '128 MB', '昨天 22:05',
    'https://sports.cuba.org.cn/video/2026/final/highlight.mp4?quality=hd',
    'https://sports.cuba.org.cn/match/schedule?round=final'),
  new DownloadRecord('job_fair_companies.xlsx', '1.2 MB', '昨天 17:33',
    'https://job.pku.edu.cn/fair/2026spring/companies.xlsx?export=1',
    'https://job.pku.edu.cn/fair/detail?fairId=118'),
  new DownloadRecord('academic_calendar_2026.docx', '0.9 MB', '08-27 10:48',
    'https://dean.pku.edu.cn/download/calendar/2026-2027.docx?lang=zh',
    'https://dean.pku.edu.cn/notice/863.htm'),
  new DownloadRecord('club_recruit_posters.zip', '15.6 MB', '08-26 16:20',
    'https://youth.pku.edu.cn/club/2026/posters.zip?from=wechat',
    'https://youth.pku.edu.cn/club/index.htm')
];

DOWNLOAD_RECORDS 常量预置 6 条 Mock 数据,URL 均为域名+路径+参数的完整真实感校园地址。原始 URL 包含查询参数(如 ?from=campus?track=edu?quality=hd),引用页 URL 包含路径和查询参数(如 ?year=2026?fairId=118),让 Mock 数据在视觉上与真实下载记录无法区分。文件大小从 0.9 MB 到 486 MB 覆盖文档、视频、表格、压缩包等多种文件类型。

6.5 CaptionScene 字幕场景模型

@Observed
export class CaptionScene {
  scene: string;  // 场景名(如"英语新闻听力")
  desc: string;   // 场景说明
  src: string;    // 推荐源语言
  tgt: string;    // 推荐目标语言

  constructor(scene: string, desc: string, src: string, tgt: string) {
    this.scene = scene;
    this.desc = desc;
    this.src = src;
    this.tgt = tgt;
  }
}

const SCENE_LIST: CaptionScene[] = [
  new CaptionScene('英语新闻听力', '英文校园播客原文转写,同步生成中文对照字幕', 'en', 'zh-en'),
  new CaptionScene('晨报双语播读', '中文晨间校报播读,超大字号字幕跟随朗读滚动', 'zh', 'zh'),
  new CaptionScene('讲座实时转写', '海外学者线上讲座,中英双语字幕实时上屏', 'en', 'zh-en'),
  new CaptionScene('留学申请面签', '英文招生宣讲音频,整体译成中文字幕', 'en', 'zh'),
  new CaptionScene('社团招新广播', '中文招新广播转写上屏,暖黄大字号易读', 'zh', 'zh')
];

CaptionScene 是听报 Tab 的推荐语言组合卡,包含场景名 scene、说明 desc、推荐源语言 src 与推荐目标语言 tgtSCENE_LIST 常量预置 5 条校园资讯音频场景,覆盖英语新闻听力、晨报双语播读、讲座实时转写、留学申请面签、社团招新广播。点击场景卡调用 applyScene(scene),内部走 switchSourceLang 联动逻辑设置源语言,再直接设置目标语言,实现一键应用推荐的语言组合。

七、组件主体架构

7.1 状态变量分层

主组件以 @Entry @Component 装饰,状态变量按业务域分为五组。这种分层声明的设计使状态管理清晰有序,便于维护和调试:

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

  /************* 弹窗状态(三态统一,绑定 NewsItem 要闻实体) *************/
  @State addModal: boolean = false;   // 新增要闻弹窗
  @State editModal: boolean = false;  // 编辑来源时间弹窗
  @State delModal: boolean = false;   // 删除确认弹窗
  @State editIdx: number = -1;        // 编辑条目索引
  @State delIdx: number = -1;         // 删除条目索引

  /************* 新增/编辑表单状态 *************/
  @State formTitle: string = '';   // 新增表单:要闻标题
  @State formCat: string = '头条';  // 新增表单:分类 chips 当前值
  @State formSource: string = '';  // 新增表单:来源
  @State editSource: string = '';  // 编辑表单:来源
  @State editTime: string = '';    // 编辑表单:发布时间

  /************* 头条业务状态 *************/
  @State newsList: NewsItem[] = NEWS_LIST;  // 要闻横滑大卡数据

基础 UI 状态currentTab(当前 Tab 索引)、breath(呼吸动画开关)、timer(定时器句柄)。呼吸动画通过 setInterval 每秒翻转 breath 布尔值,驱动头部圆点透明度交替与柱状图柱高波动。timerprivate@State,因为定时器句柄不需要触发 UI 重渲染。

弹窗状态addModaleditModaldelModal 三态布尔开关,editIdxdelIdx 记录操作条目索引。三态弹窗统一绑定 NewsItem 实体,新增弹窗重置表单,编辑弹窗回填当前来源与时间,删除弹窗显示确认文案。弹窗的显示与隐藏完全由布尔状态驱动,build() 方法中通过 if 条件渲染决定是否显示。

  /************* 特性 A 状态(Speech Kit AI 字幕) *************/
  private captionController: AICaptionController = new AICaptionController();
  @State captionShown: boolean = false;
  @State srcLang: string = 'zh';
  @State tgtLang: string = 'zh';
  @State captionSize: AICaptionFontSize = AICaptionFontSize.NORMAL;
  @State captionColor: string = CAPTION_FONT_COLORS[0];
  @State captionReady: boolean = false;
  @State captionErrMsg: string = '';
  @State captionFed: number = 0;
  @State sceneList: CaptionScene[] = SCENE_LIST;

  /************* 特性 B 状态(ArkWeb 下载双 URL 溯源) *************/
  private webController: webview.WebviewController = new webview.WebviewController();
  private downloadDelegate: webview.WebDownloadDelegate = new webview.WebDownloadDelegate();
  @State urlInput: string = QUICK_SITES[0].url;
  @State webUrl: string = QUICK_SITES[0].url;
  @State dlName: string = '';
  @State dlPercent: number = 0;
  @State dlState: string = '空闲';
  @State downloadRecords: DownloadRecord[] = DOWNLOAD_RECORDS;

  /************* 特性 C 状态(Tabs 嵌套滚动) *************/
  @State nestedMode: TabsNestedScrollMode = TabsNestedScrollMode.SELF_FIRST;
  @State outerIndex: number = 0;
  @State innerIndex: number = 0;
  @State swipeLogs: SwipeLog[] = [];

特性 A 状态(Speech Kit)captionController(字幕控制器实例,private@State)、captionShown(字幕显示状态,@Link 双向绑定)、srcLang/tgtLang(源/目标语言)、captionSize(字号枚举)、captionColor(字体颜色)、captionReady(就绪状态)、captionErrMsg(错误信息)、captionFed(已写入音频块计数)、sceneList(场景卡数据)。

特性 B 状态(ArkWeb)webController(Web 控制器,private)、downloadDelegate(下载代理,private)、urlInput/webUrl(地址栏输入值与实际加载值分离)、dlName/dlPercent/dlState(当前下载文件名/进度/状态文案)、downloadRecords(下载记录列表)。

特性 C 状态(Tabs 嵌套)nestedMode(嵌套滚动模式)、outerIndex/innerIndex(外层/内层当前索引)、swipeLogs(翻页日志列表)。nestedMode 默认 SELF_FIRST,用户可通过模式切换 chips 在两种模式间切换。

7.2 生命周期

aboutToAppear() {
  this.setupDownloadDelegate();
  this.timer = setInterval(() => {
    this.breath = !this.breath;
  }, 1000);
}

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

aboutToAppear 在组件创建后、UI 渲染前调用,完成两项初始化:调用 setupDownloadDelegate 注册下载代理并绑定到 webController,同时启动呼吸动画定时器。这种"初始化即绑定"的设计确保用户进入网页 Tab 时下载链路已就绪。aboutToDisappear 在组件销毁前调用,清理定时器防止内存泄漏——clearInterval 后将 timer 重置为 -1 作为哨兵值,避免重复清理。呼吸动画的 1000ms 间隔经过权衡——更短间隔会增加 CPU 负载,更长间隔会使闪烁感过于迟钝。

7.3 build 根构建方法

build() {
  Stack({ alignContent: Alignment.Center }) {
    Column() {
      this.headerBanner()
      Divider().strokeWidth(1).color(COLORS.line)
      Column() {
        if (this.currentTab === 0) {
          this.tabHead()
        } else if (this.currentTab === 1) {
          this.tabChannel()
        } else if (this.currentTab === 2) {
          this.tabWeb()
        } else if (this.currentTab === 3) {
          this.tabDownload()
        } else if (this.currentTab === 4) {
          this.tabListen()
        } else {
          this.tabMine()
        }
      }.layoutWeight(1).width('100%')
      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 容器实现两层叠加:底层 Column 纵向排列头部 Banner、分割线、内容区和底部 Tab 栏,顶层是三个弹窗的条件渲染。内容区通过 if-else if-else 链根据 currentTab 索引选择对应的 @Builder 方法,每个 Tab 拥有完全独立的布局结构。layoutWeight(1) 使内容区占据头部和底部 Tab 栏之间的全部剩余空间。弹窗的 onClose 回调以箭头函数形式传入,点击遮罩时将对应布尔状态设为 false 关闭弹窗。整个 Stack 的背景色设为 COLORS.bg 浅云蓝白,保证视觉基调统一。

八、头部 Banner 详解

头部 Banner 是页面的视觉门面,以青春蓝渐变为基调,集成了刊号语、三特性状态胶囊与呼吸圆点。headerBanner 构建器分为上下两行:上行是标题列与呼吸圆点,下行是四枚状态胶囊:

@Builder
headerBanner() {
  Column({ space: 10 }) {
    Row() {
      Column({ space: 4 }) {
        Text('📰 校园脉搏 · 校园资讯阅读').fontSize(19).fontWeight(FontWeight.Bold)
          .fontColor(COLORS.white)
        Text(this.currentTab === 0 ? `头条编辑部 · 今日要闻 ${this.newsList.length}`
          : this.currentTab === 1 ? '频道 · 频道×栏目双层 Tabs 嵌套'
            : this.currentTab === 2 ? '网页 · ArkWeb 校园站点直达'
              : this.currentTab === 3 ? `下载 · 双 URL 溯源 ${this.downloadRecords.length}`
                : this.currentTab === 4 ? `听报 · AI 字幕 ${langName(this.srcLang)}${langName(this.tgtLang)}`
                  : '我的 · 订阅与收藏').fontSize(11).fontColor(COLORS.whiteSoft)
      }.alignItems(HorizontalAlign.Start).layoutWeight(1)

      Circle({ width: 10, height: 10 }).fill(COLORS.white)
        .opacity(this.breath ? 0.9 : 0.45)
    }.width('100%')

    Row({ space: 8 }) {
      // 要闻胶囊
      Row({ space: 6 }) {
        Text('🗞').fontSize(10)
        Text(`要闻 ${this.newsList.length}`).fontSize(10).fontColor(COLORS.sub)
      }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
      .borderRadius(12).backgroundColor(COLORS.chip)

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

      // 特性 A 状态胶囊(字幕语言方向)
      Row({ space: 4 }) {
        Circle({ width: 6, height: 6 }).fill(COLORS.blue)
        Text(`${this.srcLang}${this.tgtLang}`).fontSize(10).fontColor(COLORS.sub)
      }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
      .borderRadius(12).backgroundColor(COLORS.chip)

      // 特性 B 状态胶囊(下载状态)
      Row({ space: 4 }) {
        Circle({ width: 6, height: 6 }).fill(dlStateColor(this.dlState))
        Text(`下载 ${this.dlState}`).fontSize(10).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
      .borderRadius(12).backgroundColor(COLORS.chip).layoutWeight(1)
    }.width('100%')
  }.padding({ left: 16, right: 16, top: 12, bottom: 12 })
  .width('100%')
  .linearGradient({ angle: 160, colors: [[COLORS.gradA, 0], [COLORS.gradB, 1]] })
}

副标题通过 currentTab 索引联动——头条 Tab 显示要闻条数(如"头条编辑部 · 今日要闻 8 条"),频道 Tab 提示双层嵌套,网页 Tab 标注 ArkWeb 校园站点直达,下载 Tab 显示双 URL 溯源条数,听报 Tab 显示字幕语言方向(如"中文→中英双语"),我的 Tab 标注订阅与收藏。

四枚状态胶囊分别展示:要闻条数(🗞图标 + 文案)、嵌套滚动模式(绿色圆点 SELF_FIRST 或橙色圆点 SELF_ONLY)、字幕语言方向(蓝色圆点 zh→zh)与下载状态(按 dlStateColor 动态着色)。下载状态胶囊使用 layoutWeight(1) 占据剩余空间,maxLines(1)textOverflow(Ellipsis) 保证长文案不撑破布局。

呼吸圆点通过 breath 布尔值在 0.9 与 0.45 透明度间交替,为静态头部注入律动感。linearGradient 以 160 度角从 gradA(青春蓝 #3B82F6)到 gradB(深青春蓝 #2563C9)实现自然过渡,与底部 Tab 栏的纯白底色形成层次对比。

九、头条 Tab 深度分析

头条 Tab 是业务主 Tab,由要闻横滑大卡、校园热榜大编号榜和月度阅读量柱状图三个区块组成,通过 tabHead 构建器统一编排。

9.1 tabHead 整体结构

@Builder
tabHead() {
  Scroll() {
    Column({ space: 12 }) {
      // ① 今日要闻横滑大卡
      Column({ space: 10 }) {
        Row() {
          Text('🗞 今日要闻').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Column().layoutWeight(1)
          Text('+ 新增要闻').fontSize(10).fontColor(COLORS.white)
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor(COLORS.blue).borderRadius(12)
            .onClick(() => { this.openAdd(); })
        }.width('100%')

        Scroll() {
          Row({ space: 10 }) {
            ForEach(this.newsList, (item: NewsItem, idx: number) => {
              this.newsBigCard(item, idx)
            }, (item: NewsItem, idx: number) => item.title + '-' + idx.toString())
          }.padding({ bottom: 2 })
        }
        .scrollable(ScrollDirection.Horizontal)
        .scrollBar(BarState.Off)
        .width('100%')

        Text('左滑浏览全部要闻 · 点击卡片可编辑来源与时间 · 长按卡片可删除')
          .fontSize(9).fontColor(COLORS.text3).width('100%')
      }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

      // ② 校园热榜大编号榜
      // ③ 月度阅读量柱状图
      this.chartCard()
    }.padding({ left: 14, right: 14, top: 12, bottom: 12 })
  }
  .layoutWeight(1).width('100%').scrollBar(BarState.Off)
}

tabHead 使用 Scroll 作为外层容器,内部 Column 纵向排列三个区块卡片。每个区块是独立的 Column 卡片,使用 COLORS.card 纯白底色和 12px 圆角,与页面背景形成柔和对比。头部行使用 Column().layoutWeight(1) 作为弹性占位符,将右侧操作按钮推到行尾。横滑大卡区域使用嵌套 Scroll + Row 实现横向滑动,scrollBar(BarState.Off) 隐藏滚动条保持视觉简洁。底部提示文案引导用户交互方式——左滑浏览、点击编辑、长按删除。

9.2 newsBigCard 横滑大卡

@Builder
newsBigCard(item: NewsItem, idx: number) {
  Column({ space: 8 }) {
    // 分类色条封面(左侧 5px 分类色竖条 + 分类图标名 + 热度徽标)
    Row() {
      Column().width(5).height('100%').backgroundColor(catColor(item.cat))
      Row({ space: 8 }) {
        Text(catIcon(item.cat)).fontSize(22)
        Column({ space: 2 }) {
          Text(item.cat + '频道').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          Text('校园脉搏 · ' + item.time).fontSize(9).fontColor(COLORS.text3)
        }.alignItems(HorizontalAlign.Start).layoutWeight(1)
        Text('🔥' + hotText(item.hot)).fontSize(10).fontColor(catColor(item.cat))
      }.layoutWeight(1).padding({ left: 8, right: 8 }).alignItems(VerticalAlign.Center)
    }.width('100%').height(58).backgroundColor(COLORS.chip).borderRadius(10)

    // 标题(最多两行)
    Text(item.title).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })

    // 来源行 + 编辑删除入口
    Row() {
      Text(item.source).fontSize(9).fontColor(COLORS.sub)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .backgroundColor(COLORS.chip).borderRadius(7)
      Column().layoutWeight(1)
      Text('编辑').fontSize(9).fontColor(COLORS.blue)
        .onClick(() => { this.openEdit(idx); })
      Text('删除').fontSize(9).fontColor(COLORS.red)
        .onClick(() => { this.delIdx = idx; this.delModal = true; })
    }.width('100%')
  }.width(230).padding(10).backgroundColor(COLORS.card).borderRadius(12)
  .alignItems(HorizontalAlign.Start)
}

newsBigCard 是每条要闻的卡片构建器,固定宽度 230px。卡片结构分为三层:分类色条封面、标题行和来源操作行。封面区域高 58px,左侧 5px 宽的竖条由 catColor 函数着色——头条青春蓝、学术青葱绿、社团暖阳橙、体育活力红、招聘深青春蓝——使用户凭色条即可快速识别资讯分类。封面右侧依次排列分类图标(22px 大字号)、频道名与时间标签、热度徽标(hotText 格式化)。

标题最多两行,超出部分使用 TextOverflow.Ellipsis 省略号截断。来源行中来源标签使用 chip 浅湖蓝底色圆角徽章,右侧"编辑"文字为青春蓝、"删除"文字为活力红,分别绑定 openEdit(idx)delModal 开关。ForEach 的键值函数 item.title + '-' + idx 包含标题和索引,保证新增和删除操作后列表正确 diff 更新。

9.3 校园热榜大编号榜

ForEach(HOT_RANK, (row: HotItem, idx: number) => {
  Row({ space: 10 }) {
    // 大编号列(宽度固定 34,TOP1~3 大号高亮)
    Text(String(idx + 1)).fontSize(idx < 3 ? 20 : 16).fontWeight(FontWeight.Bold)
      .fontColor(rankColor(idx)).width(34).textAlign(TextAlign.Center)
      .fontFamily('monospace')
    // 标题 + 热度
    Column({ space: 3 }) {
      Text(row.title).fontSize(12).fontColor(COLORS.title).maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
      Text('热度 ' + hotText(row.hot) + ' · 校园热议中').fontSize(9).fontColor(COLORS.text3)
    }.layoutWeight(1).alignItems(HorizontalAlign.Start)
    // 火焰热度徽标
    Text('🔥 ' + hotText(row.hot)).fontSize(10).fontColor(rankColor(idx))
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .backgroundColor(COLORS.chip).borderRadius(10)
  }.width('100%').alignItems(VerticalAlign.Center)
}, (row: HotItem, idx: number) => 'hot-' + idx.toString())

校园热榜渲染 HOT_RANK 中 8 条数据。大编号列宽度固定 34px,TOP1~3 使用 20px 加粗字号高亮,其余使用 16px,编号颜色由 rankColor 函数按名次返回活力红/暖阳橙/青葱绿/雾蓝灰。编号使用 fontFamily('monospace') 等宽字体保证数字对齐。标题单行截断(maxLines(1) + Ellipsis),副行显示格式化热度值和"校园热议中"标签。右侧火焰热度徽标同样由 rankColor 着色,使前三名的热度信息在颜色和数字两个维度同时传达。

9.4 chartCard 柱状图与呼吸动画

月度阅读量柱状图采用纯组件方式构建传统柱状图,而非使用 Chart 组件。这种选择的考量在于:Chart 组件适用于数据驱动的标准图表,而此处需要在呼吸动画驱动下实现柱高实时波动效果,使用 Column + ForEach 更灵活:

@Builder
chartCard() {
  Column({ space: 10 }) {
    Row() {
      Text('📊 校报近 6 个月阅读量').fontSize(14).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Column().layoutWeight(1)
      Text('单位:千次').fontSize(9).fontColor(COLORS.text3)
    }.width('100%')

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

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

柱状图由 Row + ForEach 构建 6 根等宽柱子。每根柱子是一个 Column,从上到下依次是数值标注、柱体和月份名。柱体使用 linearGradient 从青春蓝(顶部)到深青春蓝(底部)渐变,与头部 Banner 渐变方向呼应。底部对齐 VerticalAlign.Bottom 确保所有柱子从同一基线生长,顶部数值标注使用等宽字体 monospace 保证数字对齐。

ForEach 的键值函数 bar_${i}_${this.breath} 是呼吸动画的关键——当 breath 翻转时,所有柱子的键值变化,触发重新渲染,barHeight 函数根据新的 breath 值返回不同的波动系数,奇偶柱交替放大缩小:

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

barHeight 函数的波动逻辑精巧:base 是基准柱高(96px 满刻度),wave 是波动系数——当 breath 为 true 时偶数索引柱放大 1.06 倍、奇数索引柱缩小 0.94 倍,breath 为 false 时反之。Math.max(8, ...) 保证最小柱高不为零。这种交替波动形成"呼吸"效果,使静态柱状图产生生命感。

十、频道 Tab 深度分析

频道 Tab 是特性 C 的宿主,由模式切换 chips、双层位置说明、外层宿主 Tabs 与模式状态卡组成。

10.1 tabChannel 整体结构

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

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

    // 外层宿主 Tabs
    Tabs({ barPosition: BarPosition.Start }) {
      ForEach(OUTER_CHANNELS, (ch: ChannelItem) => {
        TabContent() {
          this.innerTabs(ch)
        }.tabBar(`${ch.icon} ${ch.name}`)
      }, (ch: ChannelItem) => ch.name)
    }
    .barMode(BarMode.Scrollable)
    .onChange((index: number) => {
      this.swipeLogs.unshift(new SwipeLog('外层频道', OUTER_CHANNELS[index].name,
        this.outerIndex, index, modeLabel(this.nestedMode)));
      this.outerIndex = index;
      if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
    })
    .layoutWeight(1).width('100%')

    this.modeStateCard()
  }.width('100%').height('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
}

模式切换 chips 使用 ForEach 渲染两个 TabsNestedScrollMode 枚举值(SELF_ONLY 和 SELF_FIRST),选中态青春蓝底白字,未选中态纯白底雾蓝灰字。点击 chips 直接修改 nestedMode 状态,ArkUI 框架自动重新渲染内层 Tabs 的 nestedScroll 属性。双层位置说明行用暖阳橙圆点标注外层频道位置、青葱绿圆点标注内层栏目位置,使用户能直观感知当前双层层级。

外层 TabsBarMode.Scrollable 横滑页签模式渲染 5 个校园频道,onChange 回调在频道翻页时记录 SwipeLogswipeLogsunshift 置顶最新记录,超过 40 条时 pop 尾部。注意 onChange 回调中先记录日志再更新索引——这确保日志中的 fromIdx 是翻页前的索引、toIdx 是翻页后的索引。

10.2 innerTabs 内层嵌套

@Builder
innerTabs(channel: ChannelItem) {
  Tabs({ barPosition: BarPosition.Start }) {
    ForEach(INNER_TABS, (name: string) => {
      TabContent() {
        List({ space: 10 }) {
          ForEach(innerMockData(channel, name), (item: InnerCard) => {
            ListItem() {
              Column({ space: 6 }) {
                Row() {
                  Text(`${channel.icon} ${channel.name}·${name}`).fontSize(13)
                    .fontWeight(FontWeight.Bold).fontColor(COLORS.title)
                  Column().layoutWeight(1)
                  Text(item.tag).fontSize(10).fontColor(COLORS.sub)
                }.width('100%')
                Text(item.title).fontSize(12).fontColor(COLORS.sub).maxLines(1)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Text(item.desc).fontSize(11).fontColor(COLORS.text3).maxLines(2)
                  .textOverflow({ overflow: TextOverflow.Ellipsis })
                Row({ space: 8 }) {
                  Text(`${channel.name}频道`).fontSize(9).fontColor(COLORS.orange)
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
                    .backgroundColor(COLORS.chip)
                  Text('校园认证稿源').fontSize(9).fontColor(COLORS.green)
                    .padding({ left: 5, right: 5, top: 1, bottom: 1 }).borderRadius(4)
                    .backgroundColor(COLORS.chip)
                }.width('100%')
              }.width('100%').padding(12).borderRadius(10).backgroundColor(COLORS.card)
            }
          }, (item: InnerCard) => item.id)
        }.width('100%').height('100%').scrollBar(BarState.Off)
      }.tabBar(name)
    }, (name: string) => name)
  }
  .barMode(BarMode.Scrollable)
  .onChange((index: number) => {
    this.swipeLogs.unshift(new SwipeLog('内层栏目', INNER_TABS[index],
      this.innerIndex, index, modeLabel(this.nestedMode)));
    this.innerIndex = index;
    if (this.swipeLogs.length > 40) { this.swipeLogs.pop(); }
  })
  .nestedScroll(this.nestedMode)
  .layoutWeight(1).width('100%')
}

内层 Tabs 同样 Scrollable 模式渲染 5 个栏目子页签,每个 TabContent 内是 List + ForEach 渲染 8 条 InnerCard 卡片。nestedScroll(this.nestedMode) 挂载在内层 Tabs 上——这是关键,SELF_FIRST 模式下内层栏目滑到边缘后手势不终结,而是联动外层频道接力翻页;SELF_ONLY 模式下手势在内层终结,需抬手再滑外层页签。每张卡片含频道栏目标题行、卡片标题(单行截断)、描述(两行截断)和双徽标行(频道名暖阳橙 + 校园认证稿源青葱绿)。

10.3 modeStateCard 模式状态卡

@Builder
modeStateCard() {
  Column({ space: 8 }) {
    Row() {
      Text('🧭 嵌套模式状态').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
      Column().layoutWeight(1)
      Text(modeLabel(this.nestedMode)).fontSize(9).fontColor(
        this.nestedMode === TabsNestedScrollMode.SELF_FIRST ? COLORS.green : COLORS.orange)
    }.width('100%')

    Text(this.nestedMode === TabsNestedScrollMode.SELF_FIRST
      ? 'SELF_FIRST:内层栏目滑到最后一页后继续同向滑,外层频道立即接力翻页,一次手势完成两级切换'
      : 'SELF_ONLY(默认):内层栏目滑到边缘后手势终结,需抬手再滑外层页签才能换频道')
      .fontSize(9).fontColor(COLORS.sub).width('100%')

    Row() {
      Text(`已记录 ${this.swipeLogs.length} 次翻页`).fontSize(10).fontColor(COLORS.sub)
      Column().layoutWeight(1)
      Text('清空').fontSize(10).fontColor(COLORS.red)
        .padding({ left: 10, right: 10, top: 3, bottom: 3 })
        .backgroundColor(COLORS.chip).borderRadius(9)
        .onClick(() => { this.clearLogs(); })
    }.width('100%')

    if (this.swipeLogs.length === 0) {
      Text('暂无记录:横滑外层频道页签或内层栏目内容试一试')
        .fontSize(9).fontColor(COLORS.text3).width('100%')
    } else {
      ForEach(this.recentLogs(), (log: SwipeLog) => {
        Row({ space: 8 }) {
          Text(log.layer === '外层频道' ? '外' : '内').fontSize(9)
            .fontColor(log.layer === '外层频道' ? COLORS.orange : COLORS.green)
            .width(16).height(16).textAlign(TextAlign.Center)
            .backgroundColor(COLORS.chip).borderRadius(8)
          Text(log.tabName).fontSize(10).fontColor(COLORS.title)
            .layoutWeight(1).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          Text(`${log.fromIdx}${log.toIdx}`).fontSize(9).fontColor(COLORS.sub)
            .fontFamily('monospace')
          Text(log.time).fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
        }.width('100%').alignItems(VerticalAlign.Center)
      }, (log: SwipeLog) => `${log.time}_${log.tabName}_${log.toIdx}`)
    }
  }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
}

模式状态卡展示当前模式的行为解释(SELF_FIRST 为青葱绿标注,SELF_ONLY 为暖阳橙标注)、已记录翻页次数与最近 4 条日志。每条日志标注层级徽标("外"暖阳橙/“内"青葱绿,16x16 圆角方框)、页签名、索引变化(如 0→1,等宽字体)与时间戳。空列表时显示引导文案"横滑外层频道页签或内层栏目内容试一试”。recentLogs() 方法返回最多 4 条最新日志,ForEach 的键值函数包含时间戳和页签名保证唯一性。

十一、网页 Tab 深度分析

网页 Tab 是特性 B 的宿主,由地址栏、快捷站点横滑、Web 组件本体与下载触发区组成。

11.1 地址栏与 loadUrl 方法

@Builder
tabWeb() {
  Column({ space: 10 }) {
    // 地址栏:输入 + 前往
    Row({ space: 8 }) {
      TextInput({ text: this.urlInput, placeholder: '输入校园站点,如 pku.edu.cn' })
        .layoutWeight(1).height(38).fontSize(11).fontColor(COLORS.title)
        .backgroundColor(COLORS.card).borderRadius(10)
        .onChange((value: string) => { this.urlInput = value; })
      Text('前往').fontSize(11).fontColor(COLORS.white)
        .padding({ left: 14, right: 14, top: 10, bottom: 10 })
        .backgroundColor(COLORS.blue).borderRadius(10)
        .onClick(() => { this.loadUrl(); })
    }.width('100%')
    // ... 快捷站点、Web 组件、下载触发区
  }.width('100%').height('100%').padding({ left: 14, right: 14, top: 10, bottom: 8 })
}

地址栏采用 urlInputwebUrl 双状态分离设计——TextInput 绑定 urlInput,用户敲字只更新输入值不触发加载,点击"前往"按钮调用 loadUrl() 方法才更新 webUrl。这种设计避免用户每输入一个字符就触发一次网页加载,提升交互流畅度:

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 方法中先 trim() 去除首尾空白,空值直接返回。自动补全 https:// 协议前缀——当用户输入 pku.edu.cn 时自动补全为 https://pku.edu.cn,提升输入便捷性。最后同步更新 urlInput(显示补全后的 URL)和 webUrl(触发 Web 组件加载)。

11.2 快捷站点横滑

Scroll() {
  Row({ space: 8 }) {
    ForEach(QUICK_SITES, (site: QuickSite) => {
      Row({ space: 5 }) {
        Text(site.icon).fontSize(10)
        Text(site.name).fontSize(9)
          .fontColor(this.webUrl === site.url ? COLORS.white : COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }.padding({ left: 10, right: 10, top: 6, bottom: 6 })
      .backgroundColor(this.webUrl === site.url ? COLORS.blue : COLORS.card)
      .borderRadius(12)
      .onClick(() => {
        this.urlInput = site.url;
        this.webUrl = site.url;
      })
    }, (site: QuickSite) => site.url)
  }
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.width('100%')

快捷站点横滑渲染 QUICK_SITES 中 4 个高校站点,点击直接设置 urlInputwebUrl 为站点 URL 并加载。当前加载站点的 chips 高亮为青春蓝底白字,其余为纯白底青灰蓝字,使用户能一眼识别当前浏览的站点。ForEach 的键值函数使用 site.url 保证每个站点唯一标识。

11.3 Web 组件与下载触发

Web({ src: this.webUrl, controller: this.webController })
  .layoutWeight(1)
  .width('100%')
  .borderRadius(10)
  .backgroundColor(COLORS.chip)

Column({ space: 8 }) {
  Row() {
    Column().layoutWeight(1)
    Text(this.dlState).fontSize(9).fontColor(dlStateColor(this.dlState))
  }.width('100%')
  Row({ space: 10 }) {
    Text('下载校报合订本 PDF').fontSize(10).fontColor(COLORS.white)
      .layoutWeight(1).textAlign(TextAlign.Center)
      .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.blue).borderRadius(9)
      .onClick(() => {
        this.triggerDownload('https://news.pku.edu.cn/download/campus_paper_2026summer.pdf?from=app');
      })
    Text('下载讲座回放 MP4').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://media.tsinghua.edu.cn/lecture/2026/ai_chip_full.mp4?track=campus');
      })
  }.width('100%')
  Text('完成后在「下载」Tab 查看 getOriginalUrl 与 getReferrerUrl 双溯源')
    .fontSize(8).fontColor(COLORS.text3)
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

Web 组件绑定 webUrlwebController,网页内点击下载链接自动进入下载代理四回调链路。底部下载触发区提供两个主动下载按钮——"下载校报合订本 PDF"调用 triggerDownload 传入校报地址(青春蓝实心按钮),"下载讲座回放 MP4"调用 triggerDownload 传入讲座地址(青春蓝描边按钮)。状态行显示当前下载状态文案,颜色由 dlStateColor 动态着色。底部提示引导用户下载完成后到下载 Tab 查看双 URL 溯源信息。

triggerDownload(url: string) {
  try {
    this.dlName = url.slice(url.lastIndexOf('/') + 1);
    this.dlPercent = 0;
    this.dlState = '已发起下载请求';
    this.webController.startDownload(url);
  } catch (error) {
    console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
    this.dlState = '发起失败 ' + (error as BusinessError).code;
  }
}

triggerDownload 方法通过 webController.startDownload(url) 主动发起下载,try-catch 包裹并打印 BusinessError。文件名从 URL 中提取最后一个 / 之后的字符串(如 campus_paper_2026summer.pdf?from=app 中的 campus_paper_2026summer.pdf?from=app)。startDownload 触发后自动进入 onBeforeDownload 回调,由回调中的 item.start(path) 完成实际下载启动。如果发起失败,状态文案设为"发起失败 + 错误码",方便开发者定位问题。

十二、下载 Tab 深度分析

下载 Tab 由进行中任务卡与已完成记录列表组成,是双 URL 溯源信息的主要展示入口。

12.1 tabDownload 整体结构

@Builder
tabDownload() {
  Scroll() {
    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(dlStateColor(this.dlState))
        }.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.blue).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)

      // 已完成记录列表
      Column({ space: 10 }) {
        Row() {
          Text('🗂 已完成下载 · 双 URL 溯源').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, (item: DownloadRecord) => {
          this.recordCard(item)
        }, (item: DownloadRecord) => item.fileName + item.finishTime)
      }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)
    }.padding({ left: 14, right: 14, top: 12, bottom: 12 })
  }
  .layoutWeight(1).width('100%').scrollBar(BarState.Off)
}

进行中任务卡展示当前下载文件名、Progress 线性进度条(绑定 dlPercent,total 为 100)、百分比文案与状态文案。状态文案由 dlState 驱动,颜色由 dlStateColor 函数动态着色——空闲雾蓝灰、进行中暖阳橙、完成青葱绿、失败活力红。无任务时显示"暂无进行中任务(可在网页 Tab 主动触发)"提示,引导用户到网页 Tab 主动触发。进度条使用青春蓝填充色和浅湖蓝轨道色,高度仅 6px 保持视觉克制。底部"保存至沙箱 filesDir"提示告知文件存储位置。

12.2 recordCard 双 URL 溯源卡

@Builder
recordCard(item: DownloadRecord) {
  Column({ space: 6 }) {
    Row({ space: 8 }) {
      Text('📄 ' + item.fileName).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        .layoutWeight(1)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      Text(item.finishTime).fontSize(8).fontColor(COLORS.text3)
    }.width('100%')
    Row({ space: 6 }) {
      Text(item.fileSize).fontSize(8).fontColor(COLORS.sub)
        .padding({ left: 6, right: 6, top: 2, bottom: 2 })
        .backgroundColor(COLORS.chip).borderRadius(7)
      Text('校园官方渠道').fontSize(8).fontColor(COLORS.green)
    }.width('100%')
    // 原始 URL 行
    Row({ space: 4 }) {
      Text('🔗').fontSize(9)
      Text(item.originalUrl).fontSize(8).fontColor(COLORS.blueD)
        .fontFamily('monospace').layoutWeight(1)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    }.width('100%')
    // 引用页 URL 行
    Row({ space: 4 }) {
      Text('📄').fontSize(9)
      Text(item.referrerUrl).fontSize(8).fontColor(COLORS.sub)
        .fontFamily('monospace').layoutWeight(1)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    }.width('100%')
  }.width('100%').padding(10)
  .backgroundColor(COLORS.chip).borderRadius(10)
}

recordCard 是双 URL 溯源的核心展示构建器,每条记录卡分四层:文件名与完成时间行(文件名加粗截断、时间右对齐)、文件大小与渠道标签行(大小徽章 + "校园官方渠道"青葱绿标签)、原始 URL 行和引用页 URL 行。原始 URL 行以 🔗 图标前缀,文字色为深青春蓝 blueD,使用等宽字体 monospace 保证 URL 字符对齐,单行截断避免长 URL 撑破卡片。引用页 URL 行以 📄 图标前缀,文字色为青灰蓝 sub,同样等宽字体单行截断。两层 URL 的颜色差异(深青春蓝 vs 青灰蓝)帮助用户区分直链来源和引用页面。

十三、听报 Tab 深度分析

听报 Tab 是特性 A 的宿主,由 AI 字幕实时预览卡、语言联动卡、字号四档卡、颜色预设卡和场景卡列表五个区块组成。

13.1 AICaptionComponent 实时预览

Column({ space: 10 }) {
  Row() {
    Text('🗣 AI 字幕实时预览').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text(this.captionReady ? '已就绪' : '初始化中').fontSize(10)
      .fontColor(this.captionReady ? COLORS.green : COLORS.text3)
  }.width('100%')
  AICaptionComponent({
    isShown: this.captionShown,
    controller: this.captionController,
    options: this.buildCaptionOptions()
  }).width('100%').height(110).borderRadius(10)
  Row({ space: 8 }) {
    Button(this.captionShown ? '隐藏字幕' : '开启字幕').fontSize(12).height(32)
      .backgroundColor(COLORS.tabOn).fontColor(COLORS.white)
      .onClick(() => { this.captionShown = !this.captionShown; })
    Button('写入演示音频').fontSize(12).height(32)
      .backgroundColor(COLORS.chip).fontColor(COLORS.sub)
      .onClick(() => { this.feedAudioStream(); })
    Column().layoutWeight(1)
    Text('已写 ' + this.captionFed.toString() + ' 块').fontSize(9).fontColor(COLORS.text3)
  }.width('100%')
  if (this.captionErrMsg !== '') {
    Text(this.captionErrMsg).fontSize(9).fontColor(COLORS.red)
      .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
  }
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

AICaptionComponent 组件接收三个参数:isShown@Link 双向绑定的显示状态,父组件直接传 @State 引用)、controller(字幕控制器实例)与 optionsbuildCaptionOptions() 返回的配置对象)。预览卡上方显示就绪状态——captionReady 为 true 时显示"已就绪"青葱绿标签,为 false 时显示"初始化中"雾蓝灰标签。下方提供"开启/隐藏字幕"切换按钮(青春蓝底白字)与"写入演示音频"按钮(浅湖蓝底青灰蓝字)。右侧显示已写入音频块计数 captionFed。如果 captionErrMsg 非空,在底部以活力红显示错误信息。

13.2 语言联动卡

Column({ space: 10 }) {
  Row() {
    Text('🌐 语言设置(联动)').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text('sourceLanguage → targetLanguage').fontSize(8).fontColor(COLORS.text3)
      .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
  }.width('100%')
  Row({ space: 8 }) {
    Text('源语言').fontSize(10).fontColor(COLORS.sub)
    ForEach(SRC_LANGS, (lang: LangOption) => {
      Text(lang.name).fontSize(10).fontWeight(FontWeight.Bold)
        .fontColor(this.srcLang === lang.code ? COLORS.white : COLORS.sub)
        .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        .backgroundColor(this.srcLang === lang.code ? COLORS.blue : COLORS.chip)
        .borderRadius(10)
        .onClick(() => { this.switchSourceLang(lang.code); })
    }, (lang: LangOption) => 'src-' + lang.code)
  }.width('100%')
  if (this.srcLang === 'zh') {
    // 中文源:目标语言锁定
    Row({ space: 8 }) {
      Text('目标语言').fontSize(10).fontColor(COLORS.sub)
      Text('中文(锁定)').fontSize(10).fontWeight(FontWeight.Bold).fontColor(COLORS.text3)
        .padding({ left: 12, right: 12, top: 5, bottom: 5 })
        .backgroundColor(COLORS.chip).borderRadius(10)
      Text('中文源仅支持目标 zh').fontSize(9).fontColor(COLORS.text3)
    }.width('100%')
  } else {
    // 英文源:三选
    Row({ space: 8 }) {
      Text('目标语言').fontSize(10).fontColor(COLORS.sub)
      ForEach(TGT_LANGS_EN, (lang: LangOption) => {
        Text(lang.name).fontSize(10).fontWeight(FontWeight.Bold)
          .fontColor(this.tgtLang === lang.code ? COLORS.white : COLORS.sub)
          .padding({ left: 12, right: 12, top: 5, bottom: 5 })
          .backgroundColor(this.tgtLang === lang.code ? COLORS.blue : COLORS.chip)
          .borderRadius(10)
          .onClick(() => { this.tgtLang = lang.code; })
      }, (lang: LangOption) => 'tgt-' + lang.code)
    }.width('100%')
  }
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

语言联动卡实现源语言与目标语言的联动约束。源语言使用 SRC_LANGS 渲染中文/英文二选一 chips,点击调用 switchSourceLang 方法。中文源时目标语言区域不渲染选择 chips,而是显示"中文(锁定)“灰色文案并提示"中文源仅支持目标 zh”——因为中文源时目标语言取值范围仅 ['zh'],选其他值会导致 AICaptionComponent 初始化失败。英文源时目标语言可选中文、英文或中英双语,默认切换为中英双语 'zh-en'

13.3 字号四档卡

Column({ space: 10 }) {
  Row() {
    Text('🔠 字号四档').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text('AICaptionFontSize').fontSize(9).fontColor(COLORS.text3)
  }.width('100%')
  Row({ space: 8 }) {
    ForEach(SIZE_OPTIONS, (opt: SizeOption) => {
      Text(opt.name).fontSize(11).fontWeight(FontWeight.Bold)
        .fontColor(this.captionSize === opt.size ? COLORS.white : COLORS.sub)
        .layoutWeight(1).textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 8 })
        .backgroundColor(this.captionSize === opt.size ? COLORS.blue : COLORS.chip)
        .borderRadius(10)
        .onClick(() => { this.captionSize = opt.size; })
    }, (opt: SizeOption) => 'size-' + opt.name)
  }.width('100%')
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

字号四档卡渲染 SIZE_OPTIONS 中 SMALL/NORMAL/BIG/LARGE 四档,选中态青春蓝底白字,未选中态浅湖蓝底青灰蓝字。注意比较使用 this.captionSize === opt.size 而非数值比较,因为 AICaptionFontSize 是枚举类型,=== 比较的是枚举值引用。layoutWeight(1) 使四档等宽排列,textAlign(TextAlign.Center) 使文字居中对齐。右上角标注"AICaptionFontSize"提示开发者此字段使用枚举类型而非数字。

13.4 颜色预设卡

Column({ space: 10 }) {
  Row() {
    Text('🎨 字幕颜色预设').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text(this.captionColor).fontSize(9).fontColor(COLORS.text3).fontFamily('monospace')
  }.width('100%')
  Row({ space: 10 }) {
    ForEach(CAPTION_FONT_COLORS, (color: string, idx: number) => {
      Stack() {
        Circle({ width: 26, height: 26 }).fill(color)
          .border({ width: 1, color: COLORS.line })
        if (this.captionColor === color) {
          Text('✓').fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.bg)
        }
      }.alignContent(Alignment.Center)
      .onClick(() => { this.captionColor = color; })
    }, (color: string, idx: number) => 'font-color-' + idx.toString())
  }.width('100%')
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

颜色预设卡渲染 CAPTION_FONT_COLORS 中 5 个颜色圆点(纯白/暖黄/薄荷绿/天蓝/粉红),每个圆点 26x26 像素,带 1px 浅雾线边框。选中态叠加 bg 色的勾选标记(Stack 叠加 Circle + Text)。右上角显示当前选中颜色的十六进制值(等宽字体),方便开发者确认。点击圆点更新 captionColor 状态,实时反映到字幕预览。fontColorResourceColor 类型,直接传 '#RRGGBB' 字符串即可,无需额外类型转换。

13.5 场景卡列表

Column({ space: 10 }) {
  Row() {
    Text('💡 听报场景').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text('点击应用').fontSize(9).fontColor(COLORS.text3)
  }.width('100%')
  ForEach(this.sceneList, (scene: CaptionScene, idx: number) => {
    Row({ space: 10 }) {
      Text('🎧').fontSize(16)
      Column({ space: 3 }) {
        Text(scene.scene).fontSize(12).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text(scene.desc).fontSize(9).fontColor(COLORS.sub)
          .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
      }.layoutWeight(1).alignItems(HorizontalAlign.Start)
      Text(langName(scene.src) + ' → ' + langName(scene.tgt)).fontSize(9)
        .fontColor(this.srcLang === scene.src && this.tgtLang === scene.tgt
          ? COLORS.green : COLORS.text3)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(COLORS.chip).borderRadius(9)
    }.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
    .alignItems(VerticalAlign.Center)
    .onClick(() => { this.applyScene(scene); })
  }, (scene: CaptionScene, idx: number) => 'scene-' + idx.toString())
}.width('100%')

场景卡列表渲染 SCENE_LIST 中 5 条场景卡,每条含场景名、说明与推荐语言方向。点击场景卡调用 applyScene(scene),内部走 switchSourceLang 联动逻辑设置源语言,再设置目标语言。当前激活的场景(源/目标语言匹配 this.srcLang === scene.src && this.tgtLang === scene.tgt)的语言方向标注以青葱绿高亮,未激活的为雾蓝灰,提供直观的"已应用"反馈。

十四、我的 Tab 深度分析

我的 Tab 由订阅身份渐变大卡、已订阅频道 chips、收藏清单行与推送时段设置四个区块组成。

14.1 身份渐变大卡

Column({ space: 12 }) {
  Row({ space: 12 }) {
    Text('👨‍🎓').fontSize(34)
    Column({ space: 4 }) {
      Text('林晓 · 新闻学院 2024 级').fontSize(16).fontWeight(FontWeight.Bold)
        .fontColor(COLORS.white)
      Text('校园脉搏金牌读者 · 已订阅 12 个频道').fontSize(10).fontColor(COLORS.whiteSoft)
    }.layoutWeight(1).alignItems(HorizontalAlign.Start)
  }.width('100%')

  Row({ space: 8 }) {
    this.idStat('连续读报', '68 天')
    this.idStat('累计阅读', '1284 篇')
    this.idStat('收藏内容', '36 篇')
  }.width('100%')
}.width('100%').padding(16).borderRadius(14)
.linearGradient({ angle: 135, colors: [[COLORS.gradA, 0.0], [COLORS.gradB, 1.0]] })

身份渐变大卡使用 135 度角 linearGradient 从青春蓝到深青春蓝,展示用户头像(34px emoji)、姓名院系(“林晓 · 新闻学院 2024 级”,16px 白色加粗)与金牌读者标识(“校园脉搏金牌读者 · 已订阅 12 个频道”,10px 弱化白)。下方三格统计(连续读报 68 天/累计阅读 1284 篇/收藏内容 36 篇)使用 idStat 构建器渲染白字渐变卡上统计格。135 度渐变角与头部 Banner 的 160 度形成区分,营造卡片立体感。

@Builder
idStat(label: string, value: string) {
  Column({ space: 3 }) {
    Text(value).fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.white)
    Text(label).fontSize(9).fontColor(COLORS.whiteSoft)
  }.layoutWeight(1).alignItems(HorizontalAlign.Center)
}

idStat 是身份卡内的小统计格构建器,接收标签名和值两个参数,纵向排列——值在上(13px 白色加粗)、标签在下(9px 弱化白)。layoutWeight(1) 使三格等宽,alignItems(HorizontalAlign.Center) 使内容居中对齐。在蓝底渐变卡上使用纯白和弱化白两种文字色,保证高对比可读性。

14.2 订阅频道与收藏清单

// 已订阅频道 chips
Column({ space: 10 }) {
  Row() {
    Text('📡 我的订阅').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text('12 个频道').fontSize(9).fontColor(COLORS.text3)
  }.width('100%')
  Flex({ wrap: FlexWrap.Wrap }) {
    ForEach(SUB_CHIPS, (chip: string) => {
      Text(chip).fontSize(10).fontColor(chip === '要闻' ? COLORS.white : COLORS.sub)
        .padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .backgroundColor(chip === '要闻' ? COLORS.blue : COLORS.chip)
        .borderRadius(11)
        .margin({ right: 8, bottom: 8 })
    }, (chip: string) => 'sub-' + chip)
  }.width('100%')
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

// 收藏清单行
Column({ space: 10 }) {
  Row() {
    Text('⭐ 收藏清单').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text('含下载溯源资料').fontSize(9).fontColor(COLORS.text3)
  }.width('100%')
  ForEach(FAV_ROWS, (row: FavRow, idx: number) => {
    Row({ space: 10 }) {
      Text(row.icon).fontSize(16)
      Column({ space: 2 }) {
        Text(row.label).fontSize(12).fontColor(COLORS.title)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('收藏于下载 Tab · 大小 ' + row.hint).fontSize(9).fontColor(COLORS.text3)
      }.layoutWeight(1).alignItems(HorizontalAlign.Start)
      Text('溯源').fontSize(9).fontColor(COLORS.blue)
        .padding({ left: 8, right: 8, top: 3, bottom: 3 })
        .backgroundColor(COLORS.chip).borderRadius(9)
        .onClick(() => { this.currentTab = 3; })
    }.width('100%').padding({ top: 10, bottom: 10 })
    .backgroundColor(COLORS.card).borderRadius(10)
    .alignItems(VerticalAlign.Center)
  }, (row: FavRow, idx: number) => 'fav-' + idx.toString())
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

已订阅频道 chips 使用 Flex({ wrap: FlexWrap.Wrap }) 弹性布局自动换行,SUB_CHIPS 渲染 6 个频道 chips,"要闻"频道高亮为青春蓝底白字,其余为浅湖蓝底青灰蓝字。margin({ right: 8, bottom: 8 }) 为每个 chip 添加右侧和底部间距。

收藏清单行渲染 FAV_ROWS 中 6 行校园资料,每行含类型图标(16px emoji)、内容名(12px 墨蓝黑)、大小提示(9px 雾蓝灰)与"溯源"入口。点击"溯源"按钮将 currentTab 设为 3,跳转到下载 Tab 查看双 URL 溯源信息,实现跨 Tab 导航联动。每行的文件大小提示与下载 Tab 的 DOWNLOAD_RECORDS 中的文件大小完全一致(如 2.8 MB、486 MB 等),形成数据呼应。

14.3 推送时段设置

Column({ space: 10 }) {
  Row() {
    Text('🔔 推送时段').fontSize(13).fontWeight(FontWeight.Bold).fontColor(COLORS.title)
    Column().layoutWeight(1)
    Text('晨晚报直达').fontSize(9).fontColor(COLORS.text3)
  }.width('100%')
  Row({ space: 10 }) {
    Text('07:30 晨报速递').fontSize(11).fontColor(COLORS.sub)
      .layoutWeight(1).textAlign(TextAlign.Center)
      .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.chip).borderRadius(9)
    Text('21:00 晚报合订').fontSize(11).fontColor(COLORS.sub)
      .layoutWeight(1).textAlign(TextAlign.Center)
      .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.chip).borderRadius(9)
  }.width('100%')
  Text('听报内容配合 AI 字幕在「听报」Tab 播读,双语字幕随朗读滚动')
    .fontSize(9).fontColor(COLORS.text3).width('100%')
}.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(12)

推送时段设置展示 07:30 晨报速递与 21:00 晚报合订两个时段卡片,均为浅湖蓝底青灰蓝文字,等宽居中排列。底部提示"听报内容配合 AI 字幕在听报 Tab 播读,双语字幕随朗读滚动",引导用户到听报 Tab 体验 AI 字幕功能,形成 Tab 间导航闭环。

十五、底部 Tab 栏

底部导航采用自绘单排布局,6 个 Tab 等宽排列,选中态与未选中态通过颜色与透明度区分:

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

选中 Tab 的图标在 breath 为 true 时透明度为 1(完全显示),为 false 时为 0.78(略暗),形成微妙的呼吸闪烁效果——注意条件是 this.currentTab === index && this.breath,即只有选中 Tab 的图标才参与呼吸动画,未选中 Tab 的图标始终为 0.78 透明度。标签颜色选中态为青春蓝 tabOn,未选中态为雾蓝灰 text3layoutWeight(1) 确保每个 Tab 等宽,justifyContent(FlexAlign.Center) 使图标与标签居中对齐。点击设置 currentTab 索引即可切换内容区,ArkUI 框架自动重新渲染对应的 @Builder 方法。

十六、弹窗系统

弹窗系统采用三态独立设计,以 Stack 容器叠加全屏遮罩与居中面板,点遮罩即可关闭。三个弹窗共用 modalOverlay 遮罩构建器,但各自拥有独立的 @Builder 方法和表单状态。

16.1 modalOverlay 遮罩层

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

modalOverlay 是全屏遮罩构建器,接收 onClose 回调函数作为参数。全屏 Column 填充墨蓝半透色 maskrgba(31,42,58,0.5)),onClick 调用 onClose 回调关闭弹窗。三个弹窗共用此遮罩构建器,实现代码复用。注意 onClose 参数是 () => void 类型的箭头函数,在 build() 方法中传入时以 () => { this.addModal = false; } 形式绑定,确保关闭时修改的是正确的状态变量。

16.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.formTitle, placeholder: '如:校游泳队蝉联省市金牌' })
          .fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onChange((value: string) => { this.formTitle = value; })
      }.width('100%').alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('分类').fontSize(9).fontColor(COLORS.sub)
        Row({ space: 8 }) {
          ForEach(NEWS_CATS, (cat: string) => {
            Text(cat).fontSize(10).fontWeight(FontWeight.Bold)
              .fontColor(this.formCat === cat ? COLORS.white : COLORS.sub)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 6, bottom: 6 })
              .backgroundColor(this.formCat === cat ? COLORS.blue : COLORS.chip)
              .borderRadius(9)
              .onClick(() => { this.formCat = cat; })
          }, (cat: string) => 'form-cat-' + cat)
        }.width('100%')
      }.width('100%').alignItems(HorizontalAlign.Start)

      Column({ space: 6 }) {
        Text('来源').fontSize(9).fontColor(COLORS.sub)
        TextInput({ text: this.formSource, placeholder: '如:校新闻中心' })
          .fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onChange((value: string) => { this.formSource = 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(COLORS.white).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.blue).borderRadius(9)
          .onClick(() => { this.saveNews(); })
      }.width('100%')
    }.width('82%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }.width('100%').height('100%').alignContent(Alignment.Center)
}

新增要闻弹窗绑定 NewsItem 的标题/分类/来源三字段。标题 TextInput 绑定 formTitle,分类 chips 使用 NEWS_CATS 渲染五分类(头条/学术/社团/体育/招聘),选中态青春蓝底白字,来源 TextInput 绑定 formSource。"取消"按钮调用 onClose() 关闭弹窗,"发布"按钮调用 saveNews()。面板宽度 82%,居中对齐(alignContent(Alignment.Center)),14px 圆角纯白底色。

16.3 panelEdit 编辑来源时间弹窗

@Builder
panelEdit(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('编辑要闻来源').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      if (this.editIdx >= 0 && this.editIdx < this.newsList.length) {
        Column({ space: 6 }) {
          Text('当前标题(只读)').fontSize(9).fontColor(COLORS.sub)
          Text(this.newsList[this.editIdx].title).fontSize(12).fontColor(COLORS.title)
            .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
            .width('100%').padding({ top: 8, bottom: 8 })
            .backgroundColor(COLORS.chip).borderRadius(8)
        }.width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text('来源').fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editSource, placeholder: '如:校新闻中心' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => { this.editSource = value; })
        }.width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text('发布时间').fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editTime, placeholder: '如:今天 09:30' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => { this.editTime = 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(COLORS.white).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.blue).borderRadius(9)
          .onClick(() => { this.editNews(); })
      }.width('100%')
    }.width('82%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }.width('100%').height('100%').alignContent(Alignment.Center)
}

编辑弹窗回填当前 editIdx 条目的标题(只读展示,浅湖蓝底色)、来源与发布时间。标题以 Text 组件只读展示(非 TextInput),最多两行截断。来源与时间 TextInput 分别绑定 editSourceeditTime,由 openEdit(idx) 方法在打开弹窗时从 newsList[idx] 回填。"保存"按钮调用 editNews() 更新 newsList[editIdx]sourcetime 属性。if 条件守卫 this.editIdx >= 0 && this.editIdx < this.newsList.length 防止索引越界。

16.4 panelDel 删除确认弹窗

@Builder
panelDel(onClose: () => void) {
  Stack() {
    this.modalOverlay(onClose)
    Column({ space: 12 }) {
      Text('删除要闻').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Text(this.delIdx >= 0 && this.delIdx < this.newsList.length
        ? `确认删除「${this.newsList[this.delIdx].title}」?删除后不可恢复。`
        : '索引无效,请返回重试。')
        .fontSize(11).fontColor(COLORS.sub)
        .maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })

      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(COLORS.white).fontWeight(FontWeight.Bold)
          .layoutWeight(1).textAlign(TextAlign.Center)
          .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.red).borderRadius(9)
          .onClick(() => { this.delNews(); })
      }.width('100%')
    }.width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
  }.width('100%').height('100%').alignContent(Alignment.Center)
}

删除确认弹窗显示待删除要闻标题与"删除后不可恢复"警告。“确认删除"按钮使用活力红背景 COLORS.red,与其他弹窗的青春蓝主按钮形成色彩区分,强化警示语义。确认后调用 delNews()newsListsplice 移除指定索引的条目。面板宽度 78%(比新增/编辑弹窗的 82% 更窄),因为内容更简洁。if 条件守卫同样防止 delIdx 越界,无效索引时显示"索引无效,请返回重试”。

十七、ArkWeb 下载代理链路详解

特性 B 的下载代理链路是本文最核心的技术亮点。setupDownloadDelegate 方法注册四个回调并绑定到 webController,形成从下载发起到溯源记录的完整生命周期管理:

setupDownloadDelegate() {
  // 下载开始前:必须调用 start() 提供沙箱路径
  this.downloadDelegate.onBeforeDownload((item: webview.WebDownloadItem) => {
    const hostCtx = this.getUIContext().getHostContext();
    const dir = hostCtx ? hostCtx.filesDir : '';
    this.dlName = item.getSuggestedFileName();
    this.dlPercent = 0;
    this.dlState = '已开始';
    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;
  });

  // 下载完成:双 URL 溯源
  this.downloadDelegate.onDownloadFinish((item: webview.WebDownloadItem) => {
    const originalUrl: string = item.getOriginalUrl();
    const referrerUrl: string = item.getReferrerUrl();
    this.downloadRecords.unshift(new DownloadRecord(
      item.getSuggestedFileName(),
      Math.round(item.getTotalBytes() / 1048576) + ' MB',
      '刚刚', originalUrl, referrerUrl));
    this.dlState = '下载完成';
    this.dlPercent = 100;
  });

  try {
    this.webController.setDownloadDelegate(this.downloadDelegate);
  } catch (error) {
    console.error(`ErrorCode: ${(error as BusinessError).code}, Message: ${(error as BusinessError).message}`);
  }
}

onBeforeDownload 回调中必须调用 item.start(path) 提供沙箱路径,否则任务永远停在 PENDING 状态。路径通过 getUIContext().getHostContext().filesDir 获取应用沙箱目录,拼接 getSuggestedFileName() 得到完整保存路径。hostCtx 可能为 null(如 UI 上下文未就绪时),因此使用三元运算符守卫,dir 为空字符串时文件保存到应用根目录。

onDownloadUpdated 回调在下载进行中持续触发,getPercentComplete() 返回 0~100 的进度百分比,每次更新同时刷新 dlPercent 状态和 dlState 文案(如"正在下载 45%")。

onDownloadFailed 回调在下载失败时触发,getGuid() 返回下载任务的唯一标识符,拼接到状态文案中(如"下载失败 · abc-123-def")方便定位问题。进度清零。

onDownloadFinish 回调是双 URL 溯源的核心:getOriginalUrl() 返回文件直链地址(如 https://news.pku.edu.cn/download/campus_paper_2026summer.pdf?from=campus),getReferrerUrl() 返回触发下载的引用页面地址(如 https://news.pku.edu.cn/paper/list?year=2026)。这两个接口让用户能追溯每次下载的完整来路——不仅知道下载了什么文件,还知道从哪个页面发起的下载。文件大小通过 getTotalBytes() 获取字节数,除以 1048576(1024*1024)换算为 MB 并 Math.round 取整。新记录 unshift 置顶到 downloadRecords 列表。

setDownloadDelegate 绑定到 webControllertry-catch 包裹消除可能的抛错告警。绑定后,网页内触发的下载(用户点击下载链接)和应用侧主动发起的下载(startDownload)都会进入上述回调。

十八、Speech Kit AI 字幕配置链路详解

特性 A 的字幕配置链路围绕 AICaptionOptions 的四字段展开,涉及配置组装、语言联动、音频写入和场景应用四个方法。

18.1 buildCaptionOptions 配置组装

buildCaptionOptions(): AICaptionOptions {
  const opts: AICaptionOptions = {
    initialOpacity: 1,
    sourceLanguage: this.srcLang,        // 源语言('zh' | 'en')
    targetLanguage: this.tgtLang,        // 目标语言(中文源仅 'zh')
    fontSize: this.captionSize,          // 字体大小(AICaptionFontSize 枚举四档)
    fontColor: this.captionColor,        // 字体颜色(ResourceColor,默认白)
    onPrepared: () => {
      this.captionReady = true;
      this.captionErrMsg = '';
    },
    onError: (error: BusinessError) => {
      this.captionErrMsg = '字幕服务异常 ' + error.code + ':' + error.message;
    }
  };
  return opts;
}

buildCaptionOptions 方法体现 6.1.1 新增的四字段:sourceLanguage 控制字幕源语言('zh''en'),targetLanguage 控制目标语言(中文源时锁定 'zh'),fontSize 使用 AICaptionFontSize 枚举四档而非数字,fontColorResourceColor 类型接收颜色字符串。initialOpacity 设为 1 表示字幕初始完全不透明。onPrepared 回调置 captionReady 为 true 表示字幕服务就绪,同时清空错误信息。onError 回调将错误码和消息拼接到 captionErrMsg 展示在预览卡底部。

18.2 switchSourceLang 语言联动

switchSourceLang(code: string) {
  this.srcLang = code;
  if (code === 'zh') {
    this.tgtLang = 'zh';        // 中文源:无翻译方向可选,锁定中文
  } else {
    this.tgtLang = 'zh-en';     // 英文源:默认切中英双语,用户可再选 zh/en
  }
}

switchSourceLang 方法实现源语言与目标语言的联动约束。中文源时目标语言取值范围仅 ['zh'],选其他值会导致 AICaptionComponent 初始化失败——因此代码中中文源时目标语言区域不渲染选择 chips,而是显示"中文(锁定)"灰色文案。英文源时目标语言可选中文、英文或中英双语,默认切换为中英双语 'zh-en',用户可再选 'zh''en'。这种联动设计避免了无效配置导致的运行时错误。

18.3 feedAudioStream 演示音频写入

feedAudioStream() {
  const block = new Uint8Array(640);
  for (let i = 0; i < 640; i += 2) {
    const t = (i / 2) / 16000;
    const v = Math.round(Math.sin(2 * Math.PI * 440 * t) * 6000);
    block[i] = v & 0xFF;
    block[i + 1] = (v >> 8) & 0xFF;
  }
  try {
    const audioData: AudioData = { data: block };
    this.captionController.writeAudio(audioData);
    this.captionFed++;
  } catch (e) {
    this.captionErrMsg = '音频写入失败';
  }
}

feedAudioStream 方法生成演示音频流,模拟真实音频输入场景。640 字节的 Uint8Array 按 16kHz 采样率、16bit 位深、单声道计算约 20ms 音频(640 / 2 / 16000 = 0.02s)。440Hz 正弦波(标准音 A4)以 6000 振幅填充——Math.sin(2 * Math.PI * 440 * t) 生成 -1 到 1 的正弦值,乘以 6000 得到 -6000 到 6000 的振幅,Math.round 取整。每两个字节存储一个 16bit 采样值(小端序:低字节在前 block[i] = v & 0xFF,高字节在后 block[i + 1] = (v >> 8) & 0xFF)。通过 captionController.writeAudio(audioData) 写入字幕引擎,captionFed 计数器记录已写入的块数。这种分块写入方式模拟了真实音频流的连续输入。

18.4 applyScene 场景应用

applyScene(scene: CaptionScene) {
  this.switchSourceLang(scene.src);
  this.tgtLang = scene.tgt;
}

applyScene 方法将场景卡的推荐语言组合一键应用到当前配置。内部先走 switchSourceLang 联动逻辑设置源语言(保证目标语言的联动约束),再直接设置目标语言为场景推荐的 tgt 值。当当前源/目标语言与某场景匹配时(this.srcLang === scene.src && this.tgtLang === scene.tgt),该场景卡的语言方向标注以青葱绿高亮,提供直观的"已应用"反馈。

十九、要闻 CRUD 业务方法

要闻的增删改操作集中在五个方法中,通过 unshift/splice/slice 组合实现数组级刷新:

openAdd() {
  this.formTitle = '';
  this.formCat = '头条';
  this.formSource = '';
  this.addModal = true;
}

openEdit(idx: number) {
  this.editIdx = idx;
  this.editSource = this.newsList[idx].source;
  this.editTime = this.newsList[idx].time;
  this.editModal = true;
}

saveNews() {
  if (this.formTitle.trim() === '' || this.formSource.trim() === '') {
    return;
  }
  this.newsList.unshift(new NewsItem(
    this.formTitle.trim(), this.formCat, this.formSource.trim(), '刚刚', 12000));
  this.newsList = this.newsList.slice();
  this.addModal = false;
}

editNews() {
  if (this.editIdx >= 0 && this.editIdx < this.newsList.length) {
    this.newsList[this.editIdx].source = this.editSource;
    this.newsList[this.editIdx].time = this.editTime;
    this.newsList = this.newsList.slice();
  }
  this.editModal = false;
}

delNews() {
  if (this.delIdx >= 0 && this.delIdx < this.newsList.length) {
    this.newsList.splice(this.delIdx, 1);
    this.newsList = this.newsList.slice();
  }
  this.delModal = false;
}

openAdd 重置表单(标题清空、分类重置为"头条"、来源清空)并打开新增弹窗。openEditnewsList[idx] 回填来源与时间到编辑表单,设置 editIdx 并打开编辑弹窗。saveNews 校验标题与来源非空后 unshiftNewsItem(热度给默认值 12000),slice() 触发数组刷新。editNews 更新 newsList[editIdx]sourcetime 属性,slice() 触发刷新。delNews 使用 splice 移除指定索引条目,slice() 触发刷新。

三个方法都使用 this.newsList = this.newsList.slice() 触发数组级刷新——这是因为 ArkUI 的 @State 对数组元素的属性变更不会自动触发 ForEach 重渲染,必须创建新数组引用才能使框架感知到变化。slice() 不带参数返回数组的浅拷贝,创建新引用但不复制 @Observed 对象本身,开销极低。

二十、功能模块对比表

功能模块 所在 Tab 核心特性 关键 API/技术 数据模型 状态变量
要闻横滑大卡 头条 分类色条封面 + 编辑删除 Scroll+ForEach+Column NewsItem newsList, formTitle, formCat, formSource
校园热榜 头条 大编号梯度着色 ForEach+rankColor HotItem HOT_RANK 常量
月度柱状图 头条 breath 呼吸波动 ±6% Column+ForEach+linearGradient MONTH_IDX/NAME/VAL breath, timer
频道嵌套 Tabs 频道 nestedScroll 双层接力 Tabs+nestedScroll(TabsNestedScrollMode) InnerCard, SwipeLog nestedMode, outerIndex, innerIndex, swipeLogs
网页浏览 网页 双状态分离地址栏 Web+TextInput QuickSite urlInput, webUrl
下载双 URL 溯源 网页/下载 四回调+getOriginalUrl/getReferrerUrl WebDownloadDelegate+startDownload DownloadRecord dlName, dlPercent, dlState, downloadRecords
AI 字幕预览 听报 isShown @Link+writeAudio AICaptionComponent+AICaptionController CaptionScene captionShown, srcLang, tgtLang, captionSize, captionColor, captionFed
语言联动配置 听报 中文源锁定 zh switchSourceLang 联动 LangOption, SizeOption srcLang, tgtLang
字幕颜色预设 听报 五色圆点+勾选标记 ForEach+Stack+Circle CAPTION_FONT_COLORS captionColor
字幕场景应用 听报 一键应用推荐组合 applyScene→switchSourceLang CaptionScene sceneList
身份渐变卡 我的 135 度渐变+三格统计 linearGradient+idStat FavRow, SUB_CHIPS 无独立状态(常量驱动)
收藏溯源跳转 我的 跨 Tab 导航联动 onClick 设置 currentTab FavRow currentTab
弹窗三态 全局 Stack 遮罩+居中面板 @Builder+@State 布尔开关 NewsItem addModal, editModal, delModal, editIdx, delIdx
呼吸动画 全局 1 秒间隔翻转 breath setInterval+opacity/barHeight breath, timer

二十一、总结与展望

本文详细剖析了基于 HarmonyOS ArkUI 框架构建的校园资讯阅读平台,该平台以青春蓝渐变为视觉基调,融合了 Speech Kit AI 字幕、ArkWeb 下载双 URL 溯源、Tabs 嵌套滚动三大 HarmonyOS 6.1.1 前沿特性,通过 6 个布局完全独立的 Tab 页面和三态弹窗系统,实现了校园媒体资讯的全链路覆盖。

从架构层面看,平台的核心设计理念是"状态集中声明、视图分散渲染"。所有状态变量统一声明在组件顶层——无论是呼吸动画的 breath、弹窗三态的布尔开关,还是三大特性的配置参数——而渲染逻辑分散到各 Tab 的 @Builder 方法中。这种设计使跨 Tab 数据共享自然实现:我的 Tab 的"溯源"按钮可以直接修改 currentTab 跳转到下载 Tab,头部副标题可以读取任意 Tab 的状态数据,弹窗操作可以修改头条 Tab 的 newsList 并实时反映到横滑大卡。同时,工具函数层将业务逻辑从 UI 中解耦,8 个纯函数覆盖模式翻译、语言转换、分类配色、热度格式化等场景,使函数可独立测试和复用。数据模型层的 5 个 @Observed 类配合 slice() 数组刷新机制,确保数据变更到视图更新的自动传播。

从技术深度看,三大特性的集成各具代表性。AI 字幕体现了"配置即能力"的设计哲学——AICaptionOptions 的四字段(sourceLanguage/targetLanguage/fontSize/fontColor)直接决定字幕行为,源语言与目标语言的联动约束通过 switchSourceLang 方法封装,中文源锁定 'zh' 的规则避免了无效配置导致的初始化失败。feedAudioStream 方法以 440Hz 正弦波填充 640 字节 PCM 块模拟真实音频流,writeAudio 分块写入的设计为后续接入真实麦克风数据铺平了道路。ArkWeb 下载双 URL 溯源体现了"来源可追溯"的工程思维——getOriginalUrlgetReferrerUrl 双接口让每次下载的直链来源与引用页面一目了然,四回调链路从 onBeforeDownload 提供沙箱路径到 onDownloadFinish 记录溯源信息,形成完整的下载生命周期管理。Tabs 嵌套滚动体现了"手势连续性"的交互理念——SELF_FIRST 模式下一次手势完成两级切换,SELF_ONLY 模式下手势分层终结,swipeLogs 实时记录翻页行为供用户感知嵌套模式差异。

展望未来,平台可在以下方向持续演进:一是引入 Push

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

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

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

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

在这里插入图片描述


三、小结

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

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


Logo

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

更多推荐