一、技术前言

在这里插入图片描述

在移动互联网与智能终端深度融合的今天,电竞产业已经从一个小众的娱乐形式发展为覆盖全球数十亿用户的大型文化体育生态。从英雄联盟全球总决赛到王者荣耀职业联赛,从DOTA2国际邀请赛到各类高校电竞、城市争霸赛事,电竞赛事的观赏性、参与性和社交性都在不断攀升。围绕电竞赛事构建的社区平台,不仅需要提供赛事信息浏览、战绩数据展示等基础功能,更需要在赛事开战前为粉丝提供及时、个性化、富有竞技氛围的通知提醒能力——这正是本篇要剖析的"锐竞社·电竞社区平台"应用所解决的核心场景问题。
在这里插入图片描述
HarmonyOS 作为华为面向万物互联时代打造的分布式操作系统,其应用开发框架 ArkUI 提供了一套完整的声明式 UI 编程范式。ArkUI 通过 @Entry@Component@State@Builder@Observed 等装饰器,为开发者构建了强大的响应式编程体系。开发者只需声明 UI 的结构与状态之间的依赖关系,框架便能在状态变化时自动驱动 UI 的重新渲染,无需手动操作 DOM 或调用命令式的刷新接口。这种编程范式与 React 的 Hooks 体系、Flutter 的 StatefulWidget 机制、SwiftUI 的声明式语法一脉相承,但又融合了 HarmonyOS 特有的分布式能力和系统能力调用入口。

在这里插入图片描述
Notification Kit 是 HarmonyOS 提供的系统级通知服务套件,它允许应用向用户推送通知消息,支持基础文本、多行文本、图片、社交通信等多种内容类型,并可通过 NotificationSlot 管理通知渠道的优先级、提示音和振动行为。在 HarmonyOS 6.1.1 版本中,Notification Kit 引入了一项重要新特性——支持应用沙箱内的音频文件作为通知自定义铃声。具体而言,开发者可以将音频文件写入应用沙箱的 EL1 区域 filesDir 目录下,然后通过 fileUri.getUriFromPath() 将沙箱路径转换为系统可识别的 URI,再以 uri:: 前缀拼接后赋值给 NotificationRequest.sound 字段。当通知到达时,系统会从该沙箱 URI 加载音频并播放,从而实现完全由应用端控制的通知铃声定制能力。这项特性在电竞社区场景中尤为关键——粉丝可以为不同战队、不同赛事设置不同的战鼓、号角风格铃声,让通知本身就成为一种竞技氛围的延伸。

在这里插入图片描述
Canvas 组件是 ArkUI 提供的 2D 绘图能力载体,它基于标准的 CanvasRenderingContext2D 接口,支持路径绘制、填充、描边、文本渲染、渐变、透明度控制等全套 2D Canvas API。在本应用中,Canvas 被用于绘制个人战力六维雷达图——一个在电竞数据可视化领域极具代表性的图表类型。六维雷达图通过在六个维度轴(对枪、意识、操作、支援、发育、指挥)上标注选手能力数值,以多边形面积直观展现选手的综合战力分布。绘制过程涉及三层同心网格的逐层构建、六条轴线从中心向外的辐射绘制、数值多边形的半透明填充与描边、六维标签的外圈定位渲染,以及呼吸动画驱动的节点微动效果——每一层绘制逻辑都充分展现了 Canvas API 的灵活性和精确控制能力。

在这里插入图片描述
应用架构设计的角度来看,"锐竞社"采用了 4-Tab 单排导航的经典移动端布局,但每个 Tab 的内容布局完全不同:战报页聚焦于 Canvas 雷达图与 KDA 数据展示,赛程页采用竖向时间轴呈现赛事排列,铃音页提供了完整的铃声生成器与沙箱管理流程,我的页则通过段位渐变大卡和战绩统计清单呈现个人数据。这种"统一导航、差异化内容"的设计理念,确保了用户在熟悉底部导航交互模式的同时,每个页面都能带来新鲜且贴合业务场景的视觉体验。

在这里插入图片描述
视觉设计层面,应用采用了"竞技紫 + 霓虹青"的双主色调深色主题。背景以 #0F0A1E 深暗紫黑打底,卡片采用 #191030 深紫底色,通过竞技紫 #8B5CF6 和霓虹青 #22D3EE 两种高饱和度点缀色构建出电子竞技特有的赛博朋克氛围。辅以金色(#FBBF24,代表荣誉与段位)、红色(#F87171,代表进行中赛事与危险操作)、绿色(#4ADE80,代表授权状态与胜利标记)等语义色彩,形成了一套高度契合电竞场景的色彩语言体系。整个色彩系统通过 ColorPalette 接口集中声明,确保了全局色彩的一致性和可维护性。

此外,应用还构建了一套完整的弹窗系统——通过 Stack() 容器在页面顶层叠加全屏遮罩和居中面板,实现了新增赛程、编辑赛程、删除铃声三种交互弹窗。每个弹窗都支持点击遮罩关闭、表单输入绑定、确认/取消按钮回调等标准交互模式,为应用的增删改操作提供了完善的用户界面支撑。

下面,我们将从代码的第一行开始,逐段深入分析这款电竞社区平台的完整技术实现。


二、整体架构流程图

为了更直观地理解"锐竞社"应用的整体架构设计,我们使用以下 Mermaid 流程图来展示各模块之间的依赖关系与数据流向:

UI 构建层

核心业务方法

工具函数层

数据层

状态管理层

入口组件

Page1104
@Entry @Component

currentTab: 0~3
当前选中 Tab

breath: boolean
呼吸动画开关

addModal / editModal / delModal
三个弹窗开关

granted: boolean
通知授权状态

matchList / ringList / statList
三大列表数据源

genFreq / genDuration
铃声生成器参数

currentRingIdx / sandboxCount
铃声与沙箱状态

ColorPalette
24 字段色彩体系

MatchItem / RingItem / StatItem
@Observed 数据模型

MATCH_LIST / RING_LIST / STAT_LIST
Mock 数据集

RADAR_LABELS / RADAR_VALUES
雷达图维度与数值

MONTH_IDX / MONTH_NAME / MONTH_VAL
柱状图数据

matchColor()
比赛状态色映射

buildWavBytes()
正弦波 WAV 音频生成

requestAuth()
通知授权请求

saveRingToSandbox()
音频写入沙箱 EL1

importRingToSandbox()
铃声导入沙箱

getSoundValue()
沙箱路径转 URI

publishNotice()
发布自定义铃声通知

delRing()
删除铃声并清理沙箱

drawRadarChart()
Canvas 六维雷达图绘制

headerMain()
头部+授权胶囊+数据小卡

tabReport()
战报:雷达图+KDA+对局

tabSchedule()
赛程:竖向时间轴

tabRing()
铃音:生成器+铃声库

tabMine()
我的:段位卡+战绩

chartCard()
月度积分柱状图

tabBar()
4 Tab 底部导航

panelAdd/panelEdit/panelDel
三个弹窗面板

从架构流程图可以清晰地看到,整个应用以组件 Page1104 为核心枢纽,向上连接 UI 构建层(8 个 @Builder 函数),向下管理状态管理层(十余个 @State 变量)和数据层(色彩体系、数据模型、Mock 数据、常量配置)。工具函数层和核心业务方法作为中间桥梁,将数据层的原始素材加工为 UI 层可直接渲染的内容(如 WAV 音频字节、雷达图绘制指令、通知请求体)。三层弹窗面板通过 Stack() 叠加在主内容之上,形成完整的交互闭环。


三、模块依赖导入

3.1 系统能力套件导入

import { notificationManager } from '@kit.NotificationKit';
import { fileIo as fs, fileUri } from '@kit.CoreFileKit';
import { contextConstant, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';

应用首先通过四个 import 语句引入了实现核心功能所需的系统能力套件。

第一个导入的是 notificationManager,它来自 @kit.NotificationKit 通知套件。这个模块是整个应用"自定义铃声通知"特性的基石——通过它,应用能够请求通知授权、查询授权状态、发布携带自定义 sound 字段的通知请求、以及打开系统通知设置页面。在 HarmonyOS 的通知权限模型中,应用必须先通过 requestEnableNotification() 获取用户授权,才能发布通知;当用户曾经拒绝授权时,应用还可以通过 openNotificationSettings() 拉起系统通知设置页,引导用户进行二次授权。

第二个导入的是 fileIo(别名 fs)和 fileUri,它们来自 @kit.CoreFileKit 核心文件套件。fileIo 提供了文件的同步打开、写入、关闭、删除等底层 I/O 操作能力——在本应用中,它被用于将 WAV 音频字节写入应用沙箱的 EL1 区域 filesDir 目录,以及在删除铃声时同步清理沙箱文件。fileUri 则提供了 getUriFromPath() 方法,能够将沙箱内的文件路径转换为系统可识别的 uri:: 格式 URI,这正是 Notification Kit 6.1.1 新特性所要求的声音字段格式。

第三个导入的是 contextConstantcommon,来自 @kit.AbilityKit 能力套件。contextConstant.AreaMode.EL1 用于指定文件操作的数据沙箱级别——EL1 代表设备级加密存储区域,是应用沙箱文件最常用的存储级别。common.UIAbilityContext 则是 UIAbility 的上下文对象,通过它应用可以获取 getApplicationContext() 进而访问 filesDir 等沙箱路径信息。

第四个导入的是 BusinessError,来自 @kit.BasicServicesKit 基础服务套件。它是 HarmonyOS Promise 异常处理的标准错误类型,包含了错误码(code)和错误信息(message),在本应用中用于 requestEnableNotification()notificationManager.publish() 的 catch 分支,确保异常情况下的优雅降级。


四、颜色系统设计

4.1 色彩体系接口定义

interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  white: string;
  purple: string;
  purpleD: string;
  purpleL: string;
  cyan: string;
  cyanL: string;
  red: string;
  redL: string;
  gold: string;
  goldL: string;
  green: string;
  greenL: string;
  orange: string;
  line: string;
  tabOn: string;
  mask: string;
}

应用首先定义了一个 ColorPalette 接口,将页面所有颜色字段集中声明在一个类型契约中。这种设计模式的优势在于:第一,它为颜色常量提供了编译期类型检查,任何拼写错误都会在编译阶段暴露;第二,它形成了颜色字段的"目录",开发者只需查看接口定义即可了解整个应用涉及的所有色彩维度;第三,如果未来需要切换主题(如增加浅色主题),只需创建一个新的 ColorPalette 实现即可,无需修改组件代码。

从字段构成来看,这套色彩体系包含了以下几大类别:基础色系bg 背景、card 卡片底色、chip 芯片/输入框底色、line 分割线、mask 遮罩层);文本色系title 标题文本、sub 副文本、text3 三级文本、white 纯白文本);主色系purple / purpleD / purpleL 竞技紫三阶、cyan / cyanL 霓虹青两阶);语义色系red / redL 红色系——表示进行中赛事或危险操作、gold / goldL 金色系——表示荣誉段位或待导入状态、green / greenL 绿色系——表示授权成功或沙箱已导入、orange 橙色——表示未导入状态);导航色系tabOn 选中 Tab 颜色)。每个语义色都配有一个对应的浅色底色(L 后缀),用于构建状态标签胶囊的背景色,形成"深色文字 + 浅色底"的标签视觉模式。

4.2 深色主题色板常量

const COLORS: ColorPalette = {
  bg: '#0F0A1E',
  card: '#191030',
  chip: '#241840',
  title: '#EDE9FF',
  sub: '#8E82C8',
  text3: '#5A4E92',
  white: '#FFFFFF',
  purple: '#8B5CF6',
  purpleD: '#7C3AED',
  purpleL: '#251A45',
  cyan: '#22D3EE',
  cyanL: '#0E2A38',
  red: '#F87171',
  redL: '#2E1212',
  gold: '#FBBF24',
  goldL: '#2E2612',
  green: '#4ADE80',
  greenL: '#0A2E1A',
  orange: '#FB923C',
  line: '#241840',
  tabOn: '#8B5CF6',
  mask: 'rgba(0,0,0,0.55)'
};

在接口定义之后,应用声明了一个 COLORS 常量作为深色主题的具体实现。背景色 #0F0A1E 是一种极深的暗紫黑色,RGB 值为 (15, 10, 30),营造出电竞场馆关灯后的暗场氛围。卡片底色 #191030(RGB 25, 16, 48)在背景基础上适度提亮,确保卡片在背景上有清晰的层次区分。芯片色 #241840(RGB 36, 24, 64)进一步提亮,用于输入框、标签胶囊等更小的容器元素。

主色竞技紫 #8B5CF6(RGB 139, 92, 246)是一种高饱和度的紫罗兰色,在深色背景上具有强烈的视觉冲击力,被广泛用于标题强调色、按钮背景、选中态标识、雷达图填充等场景。深紫 #7C3AED(RGB 124, 58, 237)用于渐变色的起始色,浅紫 #251A45(RGB 37, 26, 69)用于按钮底色和标签胶囊背景。霓虹青 #22D3EE(RGB 34, 211, 238)是一种带有荧光感的青色,在暗色背景上如同霓虹灯般醒目,被用于数据高亮、柱状图点缀、次级强调色。竞技紫与霓虹青的搭配,完美再现了电竞文化中赛博朋克风格的视觉基因。

文本色系采用三阶递减的设计:标题文本 #EDE9FF(RGB 237, 233, 255)是一种带有微紫色调的近白色,确保在深色背景上的高对比度可读性;副文本 #8E82C8(RGB 142, 130, 200)是一种中等明度的灰紫色,用于次要信息;三级文本 #5A4E92(RGB 90, 78, 146)明度更低,用于最不重要的辅助文字。这种三阶文本色体系确保了信息层级的清晰传达。

遮罩色 rgba(0,0,0,0.55) 使用半透明黑色,在弹窗场景中覆盖底层内容,既保持底层内容的可感知性(用户能看到底层有内容),又将视觉焦点集中到弹出的面板上。


五、常量定义与数据配置

5.1 底部导航 Tab 配置

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

const TAB_LIST: TabMeta[] = [
  { icon: '⚔️', label: '战报' },
  { icon: '🗓️', label: '赛程' },
  { icon: '🔔', label: '铃音' },
  { icon: '👤', label: '我的' }
];

TabMeta 接口定义了底部导航 Tab 的元数据结构,包含 icon(Emoji 图标)和 label(文字标签)两个字段。TAB_LIST 常量数组定义了四个 Tab 的具体配置——战报、赛程、铃音、我的,分别对应 Emoji 图标 ⚔️(交叉剑,代表战斗战报)、🗓️(日历,代表赛程日程)、🔔(铃铛,代表铃声通知)、👤(人像,代表个人中心)。

这种将 Tab 元数据外提为常量数组的设计,使得底部导航的配置数据与渲染逻辑解耦——如果需要增加或调整 Tab,只需修改 TAB_LIST 数组即可,无需在 @Builder tabBar() 中硬编码。同时,ForEach 循环渲染时会以 label 作为键值,确保 Tab 状态切换时的精准 diff 更新。

5.2 战报页数据卡与雷达图配置

interface MiniStat {
  icon: string;
  name: string;
  val: string;
}

const MINI_LIST: MiniStat[] = [
  { icon: '🎯', name: '场均 KDA', val: '3.6' },
  { icon: '🏆', name: '赛季胜率', val: '61.4%' },
  { icon: '💥', name: 'MVP 场次', val: '186' }
];

const RADAR_LABELS: string[] = ['对枪', '意识', '操作', '支援', '发育', '指挥'];
const RADAR_VALUES: number[] = [0.88, 0.76, 0.92, 0.7, 0.85, 0.8];

MiniStat 接口定义了战报页三格 KDA 小数据卡的结构——图标、名称、数值。MINI_LIST 常量配置了三张数据卡的内容:场均 KDA 3.6(衡量选手每场击杀+助攻与死亡比的综合指标)、赛季胜率 61.4%、MVP 场次 186 次。这三个指标从不同维度勾勒出选手的竞技水平画像。

RADAR_LABELSRADAR_VALUES 是雷达图的核心配置数据。六个维度——对枪(0.88,枪法精准度)、意识(0.76,地图感知与预判)、操作(0.92,微操手速与连招精准度)、支援(0.70,团队配合与游走支援)、发育(0.85,经济获取效率)、指挥(0.80,战术决策与节奏掌控)——涵盖了 MOBA/竞技类游戏选手能力的全部维度。数值采用 0~1 的归一化范围,方便在雷达图上按比例计算坐标位置。从数值分布来看,该选手的"操作"维度最高(0.92),"支援"维度最低(0.70),呈现出一个"个人操作突出、团队支援待加强"的典型刺客型选手画像。

5.3 月度积分柱状图与表单配置

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
const MONTH_VAL: number[] = [640, 720, 580, 810, 880, 860];
const MONTH_MAX: number = 900;

const FORM_LABELS: string[] = ['开赛时间', '赛事名称', '对阵双方'];

月度积分柱状图的数据由四个并行数组组成:MONTH_IDX 提供索引序列、MONTH_NAME 提供月份标签、MONTH_VAL 提供积分数值、MONTH_MAX 定义柱状图满量程最大值。从数据趋势来看,3月至8月的积分呈现整体上升态势(640→880),仅在5月出现回落(580),反映了战队在赛季中的成长曲线。柱状图的最后一个柱子(8月,860分)会通过呼吸动画实现微放大效果,形成视觉焦点。

FORM_LABELS 常量定义了新增/编辑赛程弹窗中三个表单字段的标签文本——开赛时间、赛事名称、对阵双方。将标签外提为常量数组,使弹窗面板代码中的标签引用更简洁,也便于未来国际化时统一管理。


六、辅助函数实现

6.1 比赛状态色映射函数

function matchColor(s: string): string {
  if (s === '已结束') {
    return COLORS.text3;
  } else if (s === '进行中') {
    return COLORS.red;
  } else if (s === '未开始') {
    return COLORS.cyan;
  }
  return COLORS.text3;
}

matchColor() 是一个纯粹的值映射函数,它将赛程的三种状态字符串映射为对应的主题色值。"已结束"映射为 COLORS.text3(深灰紫色 #5A4E92),语义上弱化已结束赛事的视觉权重,使其在时间轴上"褪色"退后;"进行中"映射为 COLORS.red(红色 #F87171),利用红色的紧急感吸引注意力,让用户第一时间关注正在进行的比赛;"未开始"映射为 COLORS.cyan(霓虹青 #22D3EE),用明亮的青色暗示即将到来的赛事。

这个函数虽然简短,但体现了"业务语义→视觉语义"的映射设计思想。在赛程时间轴中,状态色被用于时间标签文字色、状态圆点填充色,确保同状态的赛事在视觉上形成统一的色彩编码。如果未来需要增加更多状态(如"暂停"、“延期”),只需在函数中增加映射分支即可。

6.2 正弦波 WAV 音频字节生成函数

function buildWavBytes(freq: number, durationMs: number): ArrayBuffer {
  const sampleRate = 44100;
  const numSamples = Math.floor(sampleRate * durationMs / 1000);
  const dataSize = numSamples * 2;
  const buf = new ArrayBuffer(44 + dataSize);
  const view = new DataView(buf);
  const writeStr = (offset: number, s: string) => {
    for (let i = 0; i < s.length; i++) {
      view.setUint8(offset + i, s.charCodeAt(i));
    }
  };
  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + dataSize, true);
  writeStr(8, 'WAVE');
  writeStr(12, 'fmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);
  writeStr(36, 'data');
  view.setUint32(40, dataSize, true);
  for (let i = 0; i < numSamples; i++) {
    const t = i / sampleRate;
    const env = Math.min(1, i / (sampleRate * 0.02));
    const decay = Math.max(0, 1 - t / (durationMs / 1000));
    const v = Math.sin(2 * Math.PI * freq * t) * 0.5 * env * decay;
    view.setInt16(44 + i * 2, Math.round(v * 32767), true);
  }
  return buf;
}

buildWavBytes() 是整个应用中技术含量最高的函数之一,它通过纯代码生成标准 WAV 格式的音频字节,模拟"网络下载或用户生成"的音频文件,而无需依赖任何外部音频资源。

函数首先确定音频参数:采样率 sampleRate = 44100(CD 质量标准采样率),采样数 numSamples 由时长毫秒数换算得出,每个采样占 2 字节(16bit PCM),因此数据区大小 dataSize = numSamples * 2。整个 WAV 文件的总字节数为 44 字节文件头加上数据区大小。

WAV 文件头的构建严格遵循 RIFF(Resource Interchange File Format)规范。偏移 0 处写入 4 字节 ASCII 字符串 'RIFF'(RIFF 容器标识);偏移 4 处写入 4 字节小端序 uint32,值为 36 + dataSize(文件总大小减去 8 字节的 RIFF 头本身);偏移 8 处写入 'WAVE'(WAVE 类型标识);偏移 12 处写入 'fmt '(格式块标识,注意末尾有空格补齐 4 字节);偏移 16 处写入 16(格式块大小,PCM 格式固定为 16 字节);偏移 20 处写入 1(音频格式码,1 = PCM);偏移 22 处写入 1(声道数,1 = 单声道);偏移 24 处写入 44100(采样率);偏移 28 处写入 88200(字节率 = 采样率 × 声道数 × 每样本字节数 = 44100 × 1 × 2);偏移 32 处写入 2(块对齐 = 声道数 × 每样本字节数);偏移 34 处写入 16(每样本位数);偏移 36 处写入 'data'(数据块标识);偏移 40 处写入 dataSize(数据块大小)。

在数据区写入阶段,函数为每个采样计算正弦波振幅值。变量 t 是当前采样的时间秒数,freq 是频率参数(220~1760 Hz),env 是起始包络(前 20ms 的淡入),decay 是线性衰减包络(从 1 衰减到 0),最终振幅 v = sin(2πf·t) × 0.5 × env × decay,再乘以 32767 转换为 16bit 整数。这种包络设计确保了铃声有平滑的淡入淡出效果,避免了硬切引起的爆音。writeStr 内联函数通过逐字节写入 ASCII 字符码的方式将字符串写入 DataView,这是因为在 ArkTS 中 DataView 没有直接写入字符串的 API。


七、数据模型定义

7.1 赛程条目模型

@Observed export class MatchItem {
  time: string;
  title: string;
  vs: string;
  status: string;

  constructor(time: string, title: string, vs: string, status: string) {
    this.time = time;
    this.title = title;
    this.vs = vs;
    this.status = status;
  }
}

MatchItem 是赛程条目的数据模型,使用 @Observed 装饰器标注。@Observed 是 ArkUI 的可观察对象装饰器,它使得该类的实例属性在被修改时,能够自动通知依赖它的 UI 组件进行更新。这在赛程列表的场景中尤为关键——当用户通过编辑弹窗修改某条赛程的时间、名称或对阵信息后,时间轴 UI 能够立即反映变化,无需手动调用刷新方法。

MatchItem 包含四个字段:time(开赛时间,如 “18:00”)、title(赛事名称,如 “巅峰联赛 S12 小组赛”)、vs(对阵双方,如 “ZG 曜光 vs GLM 苍穹”)、status(比赛状态,如 “未开始”/“进行中”/“已结束”)。构造函数采用全参数注入模式,确保每个实例在创建时字段完整。使用 export 导出使得该模型可以在其他文件中复用。

7.2 铃声条目模型

@Observed export class RingItem {
  name: string;
  file: string;
  freq: number;
  duration: number;
  size: string;
  inSandbox: boolean;

  constructor(name: string, file: string, freq: number,
    duration: number, size: string, inSandbox: boolean) {
    this.name = name;
    this.file = file;
    this.freq = freq;
    this.duration = duration;
    this.size = size;
    this.inSandbox = inSandbox;
  }
}

RingItem 是铃声条目的数据模型,同样使用 @Observed 标注。它包含六个字段:name(铃声名称,如 “五杀战鼓”)、file(沙箱文件名,如 “ring_880.wav”)、freq(频率 Hz)、duration(时长 ms)、size(文件大小显示文本,如 “106 KB”,导入沙箱前为 “—”)、inSandbox(是否已导入沙箱的布尔标志)。

这个模型的设计亮点在于 inSandbox 状态字段和 size 显示字段的联动——铃声在初始 Mock 数据中 inSandboxfalsesize 为 “—”,当用户点击"导入沙箱"后,inSandbox 变为 truesize 更新为实际文件大小(KB),UI 上的状态标签和操作按钮会即时响应变化。这种"导入前/导入后"的双态设计,完整地呈现了铃声从 Mock 数据到沙箱文件的完整生命周期。

7.3 战绩统计条目模型

@Observed export class StatItem {
  icon: string;
  name: string;
  val: string;
  tag: string;

  constructor(icon: string, name: string, val: string, tag: string) {
    this.icon = icon;
    this.name = name;
    this.val = val;
    this.tag = tag;
  }
}

StatItem 是战绩统计条目的数据模型,包含四个字段:icon(Emoji 图标)、name(统计名称,如 “总场次”)、val(统计数值,如 “1,286 场”)、tag(附加标签,如 “本赛季 214 场”)。该模型用于"我的" Tab 的战绩统计清单,每条对应一项选手数据指标,主数值用紫色高亮显示,附加标签用三级文本色弱化展示,形成"主数据 + 辅助说明"的信息层次。


八、Mock 数据初始化

8.1 赛程 Mock 数据

const MATCH_LIST: Array<MatchItem> = [
  new MatchItem('18:00', '巅峰联赛 S12 小组赛', 'ZG 曜光 vs GLM 苍穹', '未开始'),
  new MatchItem('19:30', '城市争霸赛 · 半决赛', 'DK 黑骑士 vs SLG 破晓', '未开始'),
  new MatchItem('15:00', '高校联赛 · 总决赛', '清华飞翼 vs 北大苍狼', '进行中'),
  new MatchItem('21:00', '全明星表演赛', '东部明星 vs 西部明星', '未开始'),
  new MatchItem('14:00', '季前热身赛', 'ZG 曜光 vs DK 黑骑士', '已结束'),
  new MatchItem('16:30', '青训选拔赛', 'KID 幼狮 vs TOP 新星', '已结束'),
  new MatchItem('20:00', '水友娱乐赛', '主播队 vs 选手队', '已结束')
];

MATCH_LIST 是赛程时间轴的数据源,包含 7 条 Mock 赛事数据。数据设计上兼顾了三种状态的覆盖——3 场未开始、1 场进行中、3 场已结束,确保时间轴 UI 能展示出完整的状态色彩分布。赛事名称涵盖了职业联赛(巅峰联赛)、城市赛事(城市争霸赛)、校园赛事(高校联赛)、表演性质赛事(全明星表演赛)、青训选拔赛、娱乐赛事等多种类型,呈现出电竞社区平台赛事类型的多样性。战队命名也充满了竞技感——“ZG 曜光”、“DK 黑骑士”、“SLG 破晓”、"KID 幼狮"等,每支战队都带有独特的语义色彩。

8.2 铃声库 Mock 数据

const RING_LIST: Array<RingItem> = [
  new RingItem('五杀战鼓', 'ring_880.wav', 880, 1200, '—', false),
  new RingItem('开赛号角', 'ring_660.wav', 660, 1500, '—', false),
  new RingItem('超神音浪', 'ring_440.wav', 440, 600, '—', false),
  new RingItem('团战号声', 'ring_1320.wav', 1320, 1000, '—', false),
  new RingItem('水晶破裂', 'ring_1760.wav', 1760, 800, '—', false),
  new RingItem('登场战曲', 'ring_220.wav', 220, 2000, '—', false)
];

RING_LIST 是铃声库的初始数据,包含 6 条语义铃声。每条铃声的频率从低到高覆盖了 220 Hz 到 1760 Hz 的范围——220 Hz 对应低沉的"登场战曲"(时长 2000ms,营造庄严感),1760 Hz 对应尖锐的"水晶破裂"(时长 800ms,短促高亢),880 Hz 的"五杀战鼓"处于中间频段(时长 1200ms,平衡而饱满)。这些铃声名称都富有电竞语义——“五杀”、“超神”、“团战”、"水晶"都是 MOBA 游戏中的经典术语,让通知铃声本身就承载了竞技文化的情感联想。

所有铃声初始状态 inSandbox 均为 falsesize 为 “—”,需要用户手动"导入沙箱"后才能作为通知铃声使用。这种设计模拟了真实场景中"铃声资源需下载到本地后方可设为通知音"的交互流程。

8.3 战绩统计 Mock 数据

const STAT_LIST: Array<StatItem> = [
  new StatItem('⚔️', '总场次', '1,286 场', '本赛季 214 场'),
  new StatItem('🎯', '综合胜率', '61.4%', '近十场 7 胜'),
  new StatItem('💥', '场均输出', '24,560', '高于同段位 18%'),
  new StatItem('🏅', 'MVP 次数', '186 次', '占比 14.5%'),
  new StatItem('⏱️', '游戏时长', '986 小时', '本周 21 小时')
];

STAT_LIST 是"我的" Tab 战绩统计清单的数据源,包含 5 条统计条目。每条数据都包含主数值和辅助标签——主数值(如 “1,286 场”)展示总量级,辅助标签(如 “本赛季 214 场”)提供更细粒度的上下文。数据设计涵盖了场次、胜率、输出、MVP、时长五个维度,完整勾勒出一个资深电竞选手的数据画像:1286 场总场次体现了游戏深度,61.4% 胜率体现了竞技水平,186 次 MVP 体现了个人能力占比,986 小时游戏时长体现了投入度。


九、组件状态声明

9.1 组件定义与状态变量

@Entry
@Component
struct Page1104 {
  @State currentTab: number = 0;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = 0;
  @State delIdx: number = 0;
  @State breath: boolean = false;
  @State granted: boolean = false;
  @State notifyId: number = 100;
  @State currentRingIdx: number = 0;
  @State sandboxCount: number = 0;
  @State genFreq: number = 880;
  @State genDuration: number = 1200;
  @State canvasReady: boolean = false;
  @State sentCount: number = 0;

Page1104 是应用的入口组件,使用 @Entry@Component 装饰器标注。@Entry 表示这是页面的根组件,@Component 表示这是一个自定义组件。组件内通过大量 @State 装饰器声明响应式状态变量,每个变量的变化都会触发依赖它的 UI 部分自动重新渲染。

currentTab(初始值 0)是当前选中的 Tab 索引,控制着内容区域显示哪个 Tab 的 @Builder 函数。addModaleditModaldelModal 是三个弹窗的布尔开关,为 true 时对应的弹窗面板会叠加在 Stack() 容器顶层。editIdxdelIdx 记录当前正在编辑或删除的列表索引。breath 是呼吸动画的布尔翻转开关,每秒在 true/false 之间翻转一次,驱动授权胶囊的透明度交替、雷达图节点微动、柱状图末柱微放大。granted 记录通知授权状态,初始为 false,用户授权后变为 true

notifyId(初始值 100)是通知 ID 自增计数器,每次成功发布通知后递增,确保每条通知有唯一的 ID。currentRingIdx 是当前默认铃声的索引,sandboxCount 记录已导入沙箱的铃声数量。genFreq(880 Hz)和 genDuration(1200 ms)是铃声生成器的频率和时长参数,与 Slider 组件双向绑定。canvasReady 是 Canvas 就绪标志,canvasReadytrue 后才会调用 drawRadarChart()sentCount 记录已发布的开赛提醒条数。

9.2 列表数据源与私有变量

  @State matchList: Array<MatchItem> = MATCH_LIST;
  @State ringList: Array<RingItem> = RING_LIST;
  @State statList: Array<StatItem> = STAT_LIST;
  @State formTime: string = '';
  @State formTitle: string = '';
  @State formVs: string = '';
  @State editTime: string = '';
  @State editTitle: string = '';
  @State editVs: string = '';
  private timer: number = -1;
  private radarCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));

三个列表数据源 matchListringListstatList 分别引用前面定义的 Mock 数据常量,通过 @State 装饰使其成为响应式数据——当列表内容增删或修改时,ForEach 渲染的列表 UI 会自动更新。

六个表单字段(formTime/formTitle/formVs 用于新增弹窗,editTime/editTitle/editVs 用于编辑弹窗)与 TextInput 组件双向绑定,用户输入时实时更新对应字段,确认保存时读取这些字段值构建新的 MatchItem

timer 是呼吸动画的定时器句柄,用 private 修饰表示不参与响应式渲染,仅用于内部逻辑。radarCtx 是 Canvas 的 2D 绘图上下文,通过 new CanvasRenderingContext2D(new RenderingContextSettings(true)) 创建——RenderingContextSettings(true) 中的 true 参数开启了抗锯齿模式,确保雷达图的线条和填充边缘平滑。


十、通知授权与沙箱铃声核心方法

10.1 通知授权请求

  requestAuth() {
    const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (!hostCtx) {
      return;
    }
    notificationManager.requestEnableNotification(hostCtx).then(() => {
      this.granted = true;
    }).catch((err: BusinessError) => {
      notificationManager.openNotificationSettings(hostCtx).then(() => {
      }).catch(() => {
        this.granted = false;
      });
    });
  }

requestAuth() 方法实现了通知授权的完整流程,采用"两级降级"策略。首先通过 this.getUIContext().getHostContext() 获取宿主 UIAbility 上下文,并使用 as common.UIAbilityContext 进行类型断言。如果上下文为空(在某些非 UIAbility 场景下可能出现),方法直接返回不做任何操作。

当上下文有效时,调用 notificationManager.requestEnableNotification(hostCtx) 请求系统弹出通知授权对话框。这是 HarmonyOS 的标准授权流程——首次调用时系统会弹出一个包含"允许"/“拒绝"按钮的授权弹窗。如果用户点击"允许”,Promise resolve,this.granted 设为 true,授权胶囊 UI 即时变为绿色"已授权"状态。

如果用户此前已拒绝过授权请求(或在系统设置中关闭了该应用的通知权限),requestEnableNotification() 会直接 reject,进入 catch 分支。此时方法执行第二级降级策略——调用 notificationManager.openNotificationSettings(hostCtx) 拉起系统的通知设置页面,引导用户在系统设置中手动开启通知权限。这种"先请求授权,被拒绝后引导设置"的两级策略,是 HarmonyOS 通知授权的最佳实践,兼顾了首次体验的流畅性和被拒绝后的可恢复性。

10.2 音频写入沙箱

  saveRingToSandbox(fileName: string, freq: number, durationMs: number): string {
    const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (!hostCtx) {
      return '';
    }
    const appCtx = hostCtx.getApplicationContext();
    appCtx.area = contextConstant.AreaMode.EL1;
    const dir = appCtx.filesDir;
    const path = dir + '/' + fileName;
    try {
      const data = buildWavBytes(freq, durationMs);
      const file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
      fs.writeSync(file.fd, data);
      fs.closeSync(file);
    } catch (e) {
      // 沙箱写入失败时忽略
    }
    return path;
  }

saveRingToSandbox() 方法实现了将 WAV 音频字节写入应用沙箱 EL1 区域的核心逻辑。方法接收三个参数:fileName(文件名)、freq(频率)、durationMs(时长),在内部调用 buildWavBytes() 生成音频字节后,通过 fileIo 同步 API 写入沙箱文件。

方法首先获取宿主上下文和 ApplicationContext,然后通过 appCtx.area = contextConstant.AreaMode.EL1 显式设置数据沙箱级别为 EL1(设备级加密存储)。EL1 是应用沙箱中最常用的存储级别,它的数据在设备解锁后可访问,适合存储铃声、缓存文件等非敏感数据。设置完成后,appCtx.filesDir 返回的就是 EL1 级别的 files 目录路径。

文件写入采用同步 I/O 方式——fs.openSync()CREATE | WRITE_ONLY | TRUNC 模式打开(或创建)文件:CREATE 表示文件不存在时自动创建,WRITE_ONLY 表示只写模式,TRUNC 表示如果文件已存在则截断为空。fs.writeSync(file.fd, data)ArrayBuffer 数据写入文件描述符,最后 fs.closeSync(file) 关闭文件句柄释放资源。整个 try-catch 块确保了即使沙箱写入失败(如磁盘空间不足或权限异常),应用也不会崩溃,而是静默忽略异常并返回路径。

10.3 铃声导入沙箱

  importRingToSandbox(idx: number) {
    const r = this.ringList[idx];
    this.saveRingToSandbox(r.file, r.freq, r.duration);
    r.inSandbox = true;
    const kb = Math.round((44 + Math.floor(44100 * r.duration / 1000) * 2) / 1024);
    r.size = kb + ' KB';
    this.sandboxCount++;
  }

importRingToSandbox() 是铃声库条目导入沙箱的封装方法。它接收铃声索引 idx,从 ringList 中取出对应的 RingItem 实例,调用 saveRingToSandbox() 将音频写入沙箱后,更新三个字段:inSandbox 设为 true(标记已导入)、size 计算为实际文件大小文本(44 字节文件头 + 采样数 × 2 字节,再换算为 KB 取整)、sandboxCount 递增(统计已导入数量)。

由于 RingItem 使用了 @Observed 装饰器,当 inSandboxsize 被修改后,铃声库列表 UI 中的状态标签(从"未导入"变为"沙箱中")和文件大小显示会自动更新。同时 sandboxCount 的变化也会驱动"当前默认铃声"卡中的"沙箱文件 N 个"计数显示更新。这种响应式数据驱动的 UI 更新机制,使得复杂的联动状态变化变得声明式且自动化。

10.4 生成器创建铃声

  createRingByGen() {
    const seq = this.ringList.length + 1;
    const ring = new RingItem('自定义铃声' + seq, 'ring_custom_' + seq + '.wav',
      this.genFreq, this.genDuration, '—', false);
    this.ringList.push(ring);
    this.importRingToSandbox(this.ringList.length - 1);
  }

createRingByGen() 方法使用铃声生成器的当前参数(genFreqgenDuration)创建一条新的铃声并直接导入沙箱。它以 ringList.length + 1 作为序号生成铃声名称(“自定义铃声1”、“自定义铃声2”……)和文件名(“ring_custom_1.wav”、“ring_custom_2.wav”……),创建 RingItem 后 push 到列表末尾,然后立即调用 importRingToSandbox() 导入沙箱。

这里的设计逻辑是:生成器产出的铃声在创建后自动导入沙箱(因为用户通过生成器创建铃声的目的就是要使用它),而铃声库中的预设铃声则需要用户手动点击"导入沙箱"。这种差异化的导入策略,区分了"主动生成"和"预设导入"两种不同的用户意图。

10.5 设为默认铃声

  setCurrentRing(idx: number) {
    if (!this.ringList[idx].inSandbox) {
      this.importRingToSandbox(idx);
    }
    this.currentRingIdx = idx;
  }

setCurrentRing() 方法将指定索引的铃声设为默认通知铃声。方法内部包含一个自动导入逻辑——如果该铃声尚未导入沙箱(inSandboxfalse),先调用 importRingToSandbox() 导入,再设置 currentRingIdx。这确保了"设为默认"操作的原子性:用户点击"设为默认"后,铃声一定已经存在于沙箱中,可以直接用于通知发布,不会出现"设了默认但沙箱中无文件"的异常状态。


十一、通知铃声 URI 生成与通知发布

11.1 沙箱路径转 URI

  getSoundValue(): string {
    const ring = this.ringList[this.currentRingIdx];
    const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
    if (!hostCtx) {
      return 'uri::';
    }
    const appCtx = hostCtx.getApplicationContext();
    const path = appCtx.filesDir + '/' + ring.file;
    return 'uri::' + fileUri.getUriFromPath(path);
  }

getSoundValue() 方法是 Notification Kit 6.1.1 新特性的核心实现——将当前默认铃声的沙箱路径转换为通知请求 sound 字段所需的 URI 格式值。方法首先获取当前默认铃声的 RingItem,然后获取宿主上下文的 ApplicationContext,拼接出沙箱文件的完整路径(filesDir + '/' + ring.file)。

关键一步是调用 fileUri.getUriFromPath(path) 将沙箱路径转换为系统 URI。这个 URI 以 file:// 前缀开头,指明了文件的沙箱位置。方法返回 'uri::' + uri,即以 uri:: 前缀拼接完整 URI。这种 uri:: 前缀格式正是 HarmonyOS 6.1.1 通知 sound 字段所要求的格式——系统在解析通知请求时,如果 sound 字段以 uri:: 开头,会自动提取后续的 URI 并从应用沙箱加载音频文件播放。

如果上下文不可用(hostCtx 为空),方法返回 'uri::'(空 URI),这种降级处理确保了方法不会因为上下文异常而抛出错误,但通知将使用系统默认铃声。

11.2 发布开赛提醒通知

  publishNotice() {
    if (this.currentRingIdx < 0 || this.currentRingIdx >= this.ringList.length) {
      return;
    }
    const ring = this.ringList[this.currentRingIdx];
    if (!ring.inSandbox) {
      this.importRingToSandbox(this.currentRingIdx);
    }
    const soundVal = this.getSoundValue();
    const ringName = ring.name;
    const request: notificationManager.NotificationRequest = {
      id: this.notifyId,
      notificationSlotType: notificationManager.SlotType.SOCIAL_COMMUNICATION,
      content: {
        notificationContentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
        normal: {
          title: '锐竞社 · 开赛提醒',
          text: '你关注的比赛 10 分钟后开战,速来为战队应援助威!',
          additionalText: '自定义铃声:' + ringName
        }
      },
      sound: soundVal
    };
    notificationManager.publish(request).then(() => {
      this.notifyId++;
      this.sentCount++;
    }).catch((err: BusinessError) => {
      // 失败:未授权 1600004
    });
  }

publishNotice() 是整个应用的核心业务方法——发布携带沙箱自定义铃声的开赛提醒通知。这个方法完整地串联了"铃声管理→沙箱写入→URI 转换→通知发布"的全链路逻辑。

方法首先进行索引边界校验,确保 currentRingIdx 在有效范围内。然后取出当前默认铃声的 RingItem,如果该铃声尚未导入沙箱,自动调用 importRingToSandbox() 导入。接着调用 getSoundValue() 获取沙箱 URI 格式的 sound 值。

通知请求体的构建是方法的核心。id 字段使用自增的 notifyId,确保每条通知有唯一标识。notificationSlotType 设为 SlotType.SOCIAL_COMMUNICATION(社交通信类型),这是适合电竞社区通知的渠道类型。content 对象定义了通知的显示内容:notificationContentType 设为基础文本类型,normal 对象包含 title(“锐竞社 · 开赛提醒”)、text(正文"你关注的比赛 10 分钟后开战……“)、additionalText(附加文本"自定义铃声:五杀战鼓”)。最关键的是 sound: soundVal——这个字段携带了沙箱音频的 URI,当通知到达时系统会播放该音频作为铃声。

通知发布通过 notificationManager.publish(request) 异步执行。成功时递增 notifyIdsentCount,失败时进入 catch 分支静默处理(典型场景是未获通知授权时返回错误码 1600004)。

11.3 删除铃声与沙箱清理

  delRing() {
    const idx = this.delIdx;
    if (idx >= 0 && idx < this.ringList.length) {
      const r = this.ringList[idx];
      if (r.inSandbox) {
        this.sandboxCount--;
        try {
          const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
          if (hostCtx) {
            const appCtx = hostCtx.getApplicationContext();
            appCtx.area = contextConstant.AreaMode.EL1;
            fs.unlinkSync(appCtx.filesDir + '/' + r.file);
          }
        } catch (e) {
          // 沙箱文件不存在时忽略
        }
      }
      this.ringList.splice(idx, 1);
      if (this.currentRingIdx >= this.ringList.length) {
        this.currentRingIdx = this.ringList.length - 1;
      }
    }
    this.delModal = false;
  }

delRing() 方法实现了铃声删除操作,包含沙箱文件同步清理和列表数据移除两个关键步骤。当用户在删除确认弹窗中点击"确认删除"后,此方法被调用。

方法首先取出待删除铃声的索引 delIdx,进行边界校验后取出 RingItem 实例。如果该铃声已导入沙箱(inSandboxtrue),则需要同步清理沙箱文件:递减 sandboxCount 计数,然后通过 fs.unlinkSync() 删除沙箱中的 WAV 文件。删除前需要重新设置 appCtx.area = contextConstant.AreaMode.EL1 确保访问的是正确的沙箱区域。删除操作包裹在 try-catch 中,如果沙箱文件不存在(可能已被其他途径清理),异常被静默忽略。

沙箱文件清理完成后,调用 this.ringList.splice(idx, 1) 从列表中移除该条目。如果被删除的是当前默认铃声(currentRingIdx 超出新列表范围),则将 currentRingIdx 调整为新列表的最后一项,确保默认铃声索引始终有效。最后关闭删除弹窗(this.delModal = false)。


十二、赛程增删改与生命周期管理

12.1 新增与编辑赛程

  saveMatch() {
    if (this.formTime === '' || this.formTitle === '' || this.formVs === '') {
      return;
    }
    this.matchList.unshift(new MatchItem(this.formTime, this.formTitle, this.formVs, '未开始'));
    this.formTime = '';
    this.formTitle = '';
    this.formVs = '';
    this.addModal = false;
  }

  updateMatch() {
    if (this.editIdx >= 0 && this.editIdx < this.matchList.length) {
      this.matchList[this.editIdx].time = this.editTime;
      this.matchList[this.editIdx].title = this.editTitle;
      this.matchList[this.editIdx].vs = this.editVs;
    }
    this.editModal = false;
  }

saveMatch() 处理新增赛程的保存逻辑。方法首先进行表单非空校验——如果三个字段中任何一个为空字符串,直接返回不执行保存(这是一种简单但有效的表单校验策略,确保新增的赛程条目字段完整)。校验通过后,使用表单字段的值创建一个新的 MatchItem(状态固定为"未开始",因为新增的赛事尚未开赛),通过 unshift() 插入到 matchList 的头部——这使得新增的赛程出现在时间轴的最上方,符合"最新添加的最显眼"的交互预期。最后清空三个表单字段并关闭新增弹窗。

updateMatch() 处理编辑赛程的保存逻辑。方法首先校验 editIdx 的有效性,然后直接修改 matchList[editIdx] 的三个字段值为编辑表单中的新值。由于 MatchItem 使用了 @Observed 装饰器,属性修改后时间轴 UI 会自动更新对应条目的显示内容。最后关闭编辑弹窗。

这两个方法共同构成了赛程模块的增改能力——新增走 saveMatch(),编辑走 updateMatch(),删除则由赛程条目的自然过期(状态变为"已结束")替代硬删除操作,保持了赛程数据的完整性。

12.2 生命周期与呼吸动画

  aboutToAppear() {
    notificationManager.isNotificationEnabled().then((enabled: boolean) => {
      this.granted = enabled;
    }).catch(() => {
      this.granted = false;
    });
    this.timer = setInterval(() => {
      this.breath = !this.breath;
      if (this.canvasReady) {
        this.drawRadarChart();
      }
    }, 1000);
  }

  aboutToDisappear() {
    clearInterval(this.timer);
  }

aboutToAppear() 是 ArkUI 组件的生命周期回调,在组件创建后、build() 执行前被调用。方法执行两个初始化操作。

第一,调用 notificationManager.isNotificationEnabled() 异步查询当前应用的通知授权状态。成功时将 enabled 值赋给 this.granted,使授权胶囊在页面首次显示时即呈现正确的授权状态(已授权显示绿色,未授权显示红色并提示点击授权)。失败时设为 false。这种"先查状态再展示"的设计,避免了用户每次打开应用都看到"未授权"状态(即使已经授权过),提升了体验一致性。

第二,通过 setInterval() 启动一个每 1000ms(1 秒)执行一次的定时器,赋值给 this.timer。定时器回调中做两件事:翻转 breath 布尔值(驱动呼吸动画效果),以及如果 Canvas 已就绪则调用 drawRadarChart() 重绘雷达图(使雷达图的节点产生每秒一次的微动呼吸效果)。这种"状态翻转 + 条件重绘"的模式,用极简的代码实现了持续性的动画效果,无需依赖 ArkUI 的 animateTo()animation 属性。

aboutToDisappear() 在组件销毁前被调用,执行 clearInterval(this.timer) 清理定时器,防止组件销毁后定时器仍然在后台执行导致的内存泄漏和无效操作。这是 ArkUI 组件生命周期管理的标准实践。


十三、Canvas 六维雷达图绘制

13.1 雷达图绘制核心逻辑

  drawRadarChart() {
    const ctx = this.radarCtx;
    const cx = 160;
    const cy = 95;
    const r = 62;
    const n = RADAR_LABELS.length;
    ctx.clearRect(0, 0, 320, 190);

drawRadarChart() 是应用中 Canvas 绘图能力的核心展现。方法首先从组件实例获取 radarCtx 上下文,设定雷达图的几何参数:中心点 (cx, cy) = (160, 95)、半径 r = 62、维度数 n = 6(来自 RADAR_LABELS.length)。然后调用 ctx.clearRect(0, 0, 320, 190) 清空整个 Canvas 画布,确保每次重绘时不会残留上一帧的内容。这些参数的选择基于 Canvas 组件 width('100%').height(190) 的尺寸约束,中心点 (160, 95) 大致位于画布的几何中心,半径 62 留出了足够的外圈空间放置维度标签。

13.2 三层同心网格绘制

    // 三层同心网格
    for (let layer = 1; layer <= 3; layer++) {
      const lr = r * layer / 3;
      ctx.beginPath();
      for (let i = 0; i < n; i++) {
        const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
        const x = cx + Math.cos(angle) * lr;
        const y = cy + Math.sin(angle) * lr;
        if (i === 0) {
          ctx.moveTo(x, y);
        } else {
          ctx.lineTo(x, y);
        }
      }
      ctx.closePath();
      ctx.strokeStyle = COLORS.line;
      ctx.lineWidth = 1;
      ctx.stroke();
    }

雷达图的第一层绘制是三层同心六边形网格。外层循环 layer 从 1 到 3,对应三个同心层级,每层半径为 r * layer / 3(即半径的 1/3、2/3、3/3 处)。内层循环遍历六个维度方向,计算每个方向上该层半径对应的坐标点。

角度的计算公式 angle = -Math.PI / 2 + (i / n) * Math.PI * 2 是雷达图绘制的核心数学逻辑。-Math.PI / 2 表示从正上方(12 点钟方向)开始,(i / n) * Math.PI * 2 表示每个维度间隔 2π/n = 60°。这样六个维度点分别位于正上方、右上、右下、正下、左下、左上方,形成标准六边形布局。坐标计算使用 Math.cos(angle) * lrMath.sin(angle) * lr,以中心点为基准向外辐射。

路径构建使用 moveTo(第一个点)和 lineTo(后续点)的交替模式,最后 closePath() 闭合路径形成完整六边形。strokeStyle 设为 COLORS.line#241840),lineWidth 设为 1,通过 ctx.stroke() 绘制描边。三层网格叠加在一起,形成了从中心向外的层次网格效果。

13.3 轴线与数值多边形绘制

    // 轴线
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      ctx.beginPath();
      ctx.moveTo(cx, cy);
      ctx.lineTo(cx + Math.cos(angle) * r, cy + Math.sin(angle) * r);
      ctx.strokeStyle = COLORS.line;
      ctx.stroke();
    }
    // 数值多边形填充
    ctx.beginPath();
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      const val = RADAR_VALUES[i];
      const x = cx + Math.cos(angle) * r * val;
      const y = cy + Math.sin(angle) * r * val;
      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    }
    ctx.closePath();
    ctx.fillStyle = COLORS.purple;
    ctx.globalAlpha = 0.3;
    ctx.fill();
    ctx.globalAlpha = 1;
    ctx.strokeStyle = COLORS.purple;
    ctx.lineWidth = 2;
    ctx.stroke();

轴线绘制部分从中心点 (cx, cy) 向六个方向辐射六条直线,每条直线到达最外层网格边界(半径 r)。这些轴线构成了雷达图的骨架结构,帮助用户将数值多边形与各维度方向对应起来。

数值多边形是雷达图的核心数据呈现——它通过六个维度的实际数值(RADAR_VALUES)计算每个顶点的坐标位置。与网格层的区别在于半径计算:网格层使用固定的层半径 lr,而数值多边形使用 r * val,其中 val 是 0~1 的归一化数值。这意味着数值越高,顶点离中心越远,多边形面积越大,直观地表现了选手的综合能力分布。

数值多边形先以 globalAlpha = 0.3(30% 透明度)填充竞技紫,再恢复 globalAlpha = 1 后以 lineWidth = 2 描边。这种"半透明填充 + 实线描边"的处理方式,既展现了数值区域的面积感,又保持了多边形轮廓的清晰度,是数据可视化中常用的双层渲染技巧。

13.4 节点与标签绘制

    // 数值节点(呼吸微动)
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      const val = RADAR_VALUES[i];
      const x = cx + Math.cos(angle) * r * val;
      const y = cy + Math.sin(angle) * r * val;
      ctx.beginPath();
      ctx.arc(x, y, this.breath ? 4 : 3.5, 0, Math.PI * 2);
      ctx.fillStyle = COLORS.purple;
      ctx.fill();
    }
    // 维度标签
    ctx.font = '10px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillStyle = COLORS.sub;
    for (let i = 0; i < n; i++) {
      const angle = -Math.PI / 2 + (i / n) * Math.PI * 2;
      const x = cx + Math.cos(angle) * (r + 16);
      const y = cy + Math.sin(angle) * (r + 16) + 3;
      ctx.fillText(RADAR_LABELS[i], x, y);
    }
  }

数值节点绘制在数值多边形的六个顶点位置,使用 ctx.arc() 绘制圆形实心点。节点半径通过 this.breath ? 4 : 3.5 实现呼吸微动——当 breathtrue 时半径 4,为 false 时半径 3.5,每秒交替一次。这种微小的 0.5 像素半径变化,配合每秒一次的 drawRadarChart() 重绘调用,使雷达图节点产生轻微的"心跳"动感,为静态图表注入了生命力。

维度标签绘制在每条轴线的外侧,位置为 (r + 16) 半径处——即超出最外层网格 16 像素的位置。文字使用 10px sans-serif 字体、居中对齐、副文本色 COLORS.sub,清晰但不喧宾夺主。y + 3 的微调是为了在视觉上使文字垂直居中于轴线方向(Canvas 文本绘制的 y 坐标是基线位置,需要向下偏移约半个字高)。


十四、页面主构建函数

14.1 Stack 容器与主内容布局

  build() {
    Stack() {
      Column() {
        this.headerMain()
        Divider().strokeWidth(1).color(COLORS.line)
        Scroll() {
          Column() {
            if (this.currentTab === 0) {
              this.tabReport()
            } else if (this.currentTab === 1) {
              this.tabSchedule()
            } else if (this.currentTab === 2) {
              this.tabRing()
            } else {
              this.tabMine()
            }
            this.chartCard()
          }
          .padding({ left: 14, right: 14, top: 12, bottom: 12 })
        }
        .layoutWeight(1)
        .scrollBar(BarState.Off)
        this.tabBar()
      }
      .width('100%')
      .height('100%')

build() 方法是组件的渲染入口,采用 Stack() 作为根容器。Stack 是 ArkUI 的层叠布局容器,它允许子元素在同一空间内叠加——在本应用中,主内容 Column 在底层,弹窗面板在上层,通过条件渲染决定是否显示。

主内容 Column 从上到下包含四个部分:headerMain() 头部区域、Divider 分割线、Scroll 可滚动内容区、tabBar() 底部导航栏。Scroll 容器中是一个 Column,根据 currentTab 的值通过 if/else if 条件链渲染对应的 Tab @Builder 函数,最后无条件渲染 chartCard()(月度积分柱状图作为所有 Tab 的公共底部内容)。Scroll 设置 layoutWeight(1) 占据分割线和底部导航之间的全部剩余空间,scrollBar(BarState.Off) 隐藏滚动条确保视觉整洁。

内容区域左右 padding 14、上下 padding 12,为内容提供了舒适的边距空间。

14.2 弹窗条件叠加

      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)
  }

在主内容 Column 之后,Stack 内部通过三个 if 条件判断叠加弹窗面板。当 addModaltrue 时渲染 panelAdd()editModaltrue 时渲染 panelEdit()delModaltrue 时渲染 panelDel()。每个弹窗面板都接收一个关闭回调 () => { this.xxxModal = false },用于在用户点击遮罩或取消按钮时关闭弹窗。

这种通过 if 条件渲染控制弹窗显示/隐藏的设计,是 ArkUI 声明式 UI 的典型模式——开发者不需要命令式地调用 show()/hide(),只需修改状态变量,框架自动处理 DOM 的增删。Stack 的层叠特性确保了弹窗面板会覆盖在主内容上方,配合弹窗内部的遮罩层(modalOverlay),实现了完整的弹窗视觉层级。

最后 Stack 设置 width('100%').height('100%') 撑满屏幕,backgroundColor(COLORS.bg) 设置深色背景。


十五、头部区域构建

15.1 应用名与授权胶囊

  @Builder
  headerMain() {
    Column({ space: 10 }) {
      Row() {
        Column({ space: 3 }) {
          Text('锐竞社').fontSize(17).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text('电竞社区 · 赛事数据与应援平台').fontSize(9).fontColor(COLORS.sub)
        }
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)

        Row({ space: 5 }) {
          Circle().width(6).height(6).fill(this.granted ? COLORS.green : COLORS.red)
            .opacity(this.breath ? 1 : 0.35)
          Text(this.granted ? '已授权' : '未授权·点击授权').fontSize(10)
            .fontColor(this.granted ? COLORS.green : COLORS.red)
        }
        .padding({ left: 8, right: 8, top: 4, bottom: 4 })
        .backgroundColor(this.granted ? COLORS.greenL : COLORS.redL)
        .borderRadius(10)
        .onClick(() => {
          if (!this.granted) {
            this.requestAuth();
          }
        })
      }
      .width('100%')

headerMain() 是头部区域的构建函数,使用 @Builder 装饰器标注。头部采用上下两行的布局结构——上行是应用名 + 授权胶囊的左右排列,下行是三个战绩数据小卡。

上行左侧是一个 Column,包含应用名"锐竞社"(17px 粗体标题色)和副标题"电竞社区 · 赛事数据与应援平台"(9px 副文本色),alignItems(HorizontalAlign.Start) 确保左对齐,layoutWeight(1) 占据左侧空间。

上行右侧是通知授权状态胶囊,这是头部最精巧的设计。胶囊内部包含一个 6px 的状态圆点和一段状态文字,圆点的颜色根据 granted 状态在绿色(已授权)和红色(未授权)之间切换。圆点的透明度通过 this.breath ? 1 : 0.35 实现呼吸闪烁——未授权状态下,圆点每秒在 100% 和 35% 透明度之间交替,形成醒目的闪烁提示,吸引用户注意并引导点击授权。文字内容也动态切换——已授权时显示"已授权",未授权时显示"未授权·点击授权"。整个胶囊的背景色也随状态在浅绿和浅红之间切换。

胶囊绑定 onClick 事件,点击时如果 grantedfalse 则调用 requestAuth() 发起授权请求。已授权状态下点击不做任何操作。

15.2 战绩数据小卡

      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text('赛季胜率').fontSize(9).fontColor(COLORS.text3)
          Text('61.4%').fontSize(11).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.card).borderRadius(10)

        Column({ space: 3 }) {
          Text('巅峰分').fontSize(9).fontColor(COLORS.text3)
          Text('2,468').fontSize(11).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.card).borderRadius(10)

        Column({ space: 3 }) {
          Text('已发提醒').fontSize(9).fontColor(COLORS.text3)
          Text(this.sentCount.toString() + ' 条').fontSize(11).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.card).borderRadius(10)
      }
      .width('100%')
    }
    .width('100%')
    .padding({ left: 14, right: 14, top: 12, bottom: 10 })
  }

头部下排是三个等宽的数据小卡,通过 layoutWeight(1) 实现三等分。每张卡片包含一个 9px 三级文本色的标签和一个 11px 粗体的数值——第一张"赛季胜率 61.4%"用竞技紫高亮,第二张"巅峰分 2,468"用霓虹青高亮,第三张"已发提醒 N 条"用金色高亮。三种不同颜色的高亮为三组不同语义的数据赋予了视觉区分度。

第三张数据卡的内容是动态的——this.sentCount.toString() + ' 条',当用户每发布一条开赛提醒通知后,sentCount 递增,卡片数值即时更新。这形成了"发布通知→头部计数增加"的即时视觉反馈闭环,让用户直观感知到自己的操作效果。

整个头部 Column 设置 padding({ left: 14, right: 14, top: 12, bottom: 10 }),与内容区域的左右 padding 对齐,确保视觉一致性。


十六、战报 Tab 构建

16.1 雷达图卡与 Canvas 集成

  @Builder
  tabReport() {
    Column({ space: 12 }) {
      Column({ space: 10 }) {
        Row() {
          Text('⚔️ 个人战力雷达').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('本赛季').fontSize(9).fontColor(COLORS.text3)
        }
        .width('100%')

        Canvas(this.radarCtx).width('100%').height(190)
          .onReady(() => {
            this.canvasReady = true;
            this.drawRadarChart();
          })
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

tabReport() 是战报 Tab 的构建函数,包含四个内容区块:雷达图卡、KDA 小卡行、最近对局结果卡、六维能力图例。

雷达图卡是战报页的视觉焦点。卡片顶部是一个标题行,左侧是"⚔️ 个人战力雷达"标题,右侧是"本赛季"辅助标签,中间用 Column().layoutWeight(1) 占位实现左右分布。卡片主体是 Canvas 组件,通过 this.radarCtx 传入绘图上下文,设置 width('100%').height(190)onReady 回调在 Canvas 组件初始化完成后触发,设置 canvasReady = true 并立即调用 drawRadarChart() 执行首次绘制。这个 canvasReady 标志也用于呼吸动画定时器中——只有在 Canvas 就绪后才会触发重绘,避免了 Canvas 未初始化时调用绘图 API 导致的异常。

16.2 KDA 小卡行与对局结果卡

      Row({ space: 10 }) {
        ForEach(MINI_LIST, (m: MiniStat) => {
          Column({ space: 4 }) {
            Text(m.icon).fontSize(16)
            Text(m.val).fontSize(13).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
            Text(m.name).fontSize(8).fontColor(COLORS.text3)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
          .padding({ top: 10, bottom: 10 })
          .backgroundColor(COLORS.card).borderRadius(10)
        }, (m: MiniStat) => m.name)
      }
      .width('100%')

      Column({ space: 8 }) {
        Row() {
          Text('🎮 上一场对决').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('胜利').fontSize(10).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.greenL).borderRadius(8)
        }
        .width('100%')

        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('K / D / A').fontSize(8).fontColor(COLORS.text3)
            Text('12 / 3 / 9').fontSize(12).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('输出伤害').fontSize(8).fontColor(COLORS.text3)
            Text('28,400').fontSize(12).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('对局时长').fontSize(8).fontColor(COLORS.text3)
            Text('15:42').fontSize(12).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%')
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

KDA 小卡行通过 ForEach(MINI_LIST, ...) 渲染三张等宽数据卡,每张包含图标(16px Emoji)、数值(13px 霓虹青粗体)和名称(8px 三级文本色)。ForEach 的键值函数使用 m.name 作为唯一标识,确保列表渲染的精准 diff。三张卡片之间通过 space: 10 设置间距。

最近对局结果卡呈现上一场比赛的详细数据。卡片顶部左侧是标题"🎮 上一场对决",右侧是一个绿色的"胜利"标签(greenL 浅绿底 + green 深绿文字 + 粗体),直观传达比赛结果。卡片主体是三等分的对局数据——KDA(12/3/9,竞技紫)、输出伤害(28,400,霓虹青)、对局时长(15:42,金色)。三种颜色对应三种数据维度,与头部数据卡的色彩编码保持一致。

16.3 六维能力图例

      Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) {
        ForEach(RADAR_LABELS, (label: string, idx: number) => {
          Row({ space: 8 }) {
            Circle().width(8).height(8).fill(COLORS.purple)
            Column({ space: 2 }) {
              Text(label).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Text('能力评分 ' + Math.round(RADAR_VALUES[idx] * 100).toString() + ' 分')
                .fontSize(8).fontColor(COLORS.text3)
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)
            Text(Math.round(RADAR_VALUES[idx] * 100).toString() + '%')
              .fontSize(12).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
          }
          .width('48%').padding(10)
          .backgroundColor(COLORS.card).borderRadius(10).margin({ bottom: 10 })
        }, (label: string) => label)
      }
      .width('100%')
    }
    .width('100%')
  }

六维能力图例是雷达图的数据补充呈现。使用 Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.SpaceBetween }) 实现自动换行的两列网格布局——每个图例项宽度 48%,通过 SpaceBetween 对齐方式在两列之间均匀分布间距。FlexWrap.Wrap 确保六个图例项自动换行为三行两列。

每个图例项内部包含三部分:左侧一个 8px 紫色圆点(与雷达图填充色对应)、中间的维度名称 + 能力评分文字、右侧的百分比数值。百分比通过 Math.round(RADAR_VALUES[idx] * 100) 将 0~1 的归一化值转换为 0~100 的百分制——如"对枪 88%"、"操作 92%"等。百分比使用霓虹青高亮,能力评分描述使用三级文本色弱化,形成"主数据醒目、辅助说明弱化"的信息层次。

图例与雷达图形成了"图形 + 文字"的双重数据呈现——雷达图提供面积直观感,图例提供精确数值,用户可以互相参照查看。


十七、赛程 Tab 构建

17.1 标题行与新增入口

  @Builder
  tabSchedule() {
    Column({ space: 10 }) {
      Row() {
        Text('🗓️ 今日赛程').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('共 ' + this.matchList.length.toString() + ' 场').fontSize(9).fontColor(COLORS.text3)
        Text('+ 新增赛程').fontSize(9).fontColor(COLORS.purple)
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor(COLORS.purpleL).borderRadius(8)
          .onClick(() => {
            this.addModal = true;
          })
      }
      .width('100%')

tabSchedule() 是赛程 Tab 的构建函数,采用竖向时间轴布局。标题行包含三部分:左侧标题"🗓️ 今日赛程"、右侧的赛事总数统计(动态读取 this.matchList.length)、最右侧的"+ 新增赛程"按钮。新增按钮使用浅紫底 + 紫色文字,点击后设置 this.addModal = true 触发新增赛程弹窗显示。

赛事总数 this.matchList.length.toString() 是动态的——当用户通过弹窗新增赛程后,总数即时更新。这种实时反映数据变化的计数器,让用户直观感知到操作效果。

17.2 竖向时间轴实现

      ForEach(this.matchList, (item: MatchItem, idx: number) => {
        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text(item.time).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(item.status).fontSize(8).fontColor(matchColor(item.status))
          }
          .width(44).height('100%').alignItems(HorizontalAlign.Start).padding({ top: 12 })

          Column() {
            Circle().width(8).height(8).fill(matchColor(item.status))
            if (idx < this.matchList.length - 1) {
              Column().width(2).layoutWeight(1).backgroundColor(COLORS.line).margin({ top: 2 })
            }
          }
          .width(10).height('100%').alignItems(HorizontalAlign.Center).padding({ top: 14 })

          Row() {
            Column({ space: 4 }) {
              Text(item.title).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
              Text(item.vs).fontSize(9).fontColor(COLORS.sub)
                .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
            }
            .alignItems(HorizontalAlign.Start).layoutWeight(1)

            Column({ space: 4 }) {
              Text('开赛提醒').fontSize(9).fontColor(COLORS.purple)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .backgroundColor(COLORS.purpleL).borderRadius(8)
                .onClick(() => {
                  this.publishNotice();
                })
              Text('编辑').fontSize(9).fontColor(COLORS.sub)
                .padding({ left: 8, right: 8, top: 3, bottom: 3 })
                .backgroundColor(COLORS.chip).borderRadius(8)
                .onClick(() => {
                  this.editIdx = idx;
                  this.editTime = item.time;
                  this.editTitle = item.title;
                  this.editVs = item.vs;
                  this.editModal = true;
                })
            }
          }
          .layoutWeight(1).height('100%').padding(10)
          .backgroundColor(COLORS.card).borderRadius(10)
        }
        .width('100%')
        .height(72)
        .alignItems(VerticalAlign.Top)
        .margin({ bottom: 6 })
      }, (item: MatchItem) => item.time + item.title)
    }
    .width('100%')
  }

竖向时间轴是赛程页的核心视觉设计。每行固定高度 72px,通过 Row 水平排列三个区域:左侧时间+状态列(宽 44px)、中间圆点+连接线列(宽 10px)、右侧赛程卡(layoutWeight(1) 自适应宽度)。

左侧时间列显示开赛时间(11px 粗体标题色)和比赛状态(8px,颜色由 matchColor() 函数根据状态返回对应主题色)。中间列是时间轴的视觉骨架——顶部一个 8px 圆点(颜色同样由 matchColor() 决定),下方一条 2px 宽的竖线连接到下一行。竖线通过 if (idx < this.matchList.length - 1) 条件渲染,确保最后一行不显示竖线,形成"链条"效果。竖线使用 Column().layoutWeight(1) 撑满行高,背景色为 COLORS.line

右侧赛程卡内部分为左右两部分:左侧是赛事名称(12px 粗体标题色,maxLines(1) 单行 + 省略号截断)和对阵双方(9px 副文本色),右侧是两个操作按钮——“开赛提醒"和"编辑”。"开赛提醒"按钮点击后调用 publishNotice() 发布通知,"编辑"按钮点击后将该条赛程的数据填充到编辑表单字段并打开编辑弹窗。

ForEach 的键值函数使用 item.time + item.title 作为复合键,确保每条赛程的唯一性标识。当新增赛程 unshift 到列表头部时,ForEach 能够精准识别变化并增量更新。


十八、铃音 Tab 构建

18.1 沙箱铃声生成器

  @Builder
  tabRing() {
    Column({ space: 12 }) {
      Column({ space: 10 }) {
        Text('🎛️ 沙箱铃声生成器').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Row({ space: 8 }) {
          Text('频率').fontSize(10).fontColor(COLORS.sub)
          Text(this.genFreq.toString() + ' Hz').fontSize(10).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('220 ~ 1760 Hz').fontSize(8).fontColor(COLORS.text3)
        }
        .width('100%')

        Slider({ value: this.genFreq, min: 220, max: 1760, step: 20, style: SliderStyle.OutSet })
          .selectedColor(COLORS.purple).trackColor(COLORS.chip).blockColor(COLORS.purple)
          .width('100%')
          .onChange((value: number) => {
            this.genFreq = value;
          })

        Row({ space: 8 }) {
          Text('时长').fontSize(10).fontColor(COLORS.sub)
          Text(this.genDuration.toString() + ' ms').fontSize(10).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('600 ~ 2400 ms').fontSize(8).fontColor(COLORS.text3)
        }
        .width('100%')

        Slider({ value: this.genDuration, min: 600, max: 2400, step: 100, style: SliderStyle.OutSet })
          .selectedColor(COLORS.cyan).trackColor(COLORS.chip).blockColor(COLORS.cyan)
          .width('100%')
          .onChange((value: number) => {
            this.genDuration = value;
          })

        Text('生成铃声到沙箱').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          .width('100%').textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.purple).borderRadius(10)
          .onClick(() => {
            this.createRingByGen();
          })
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

tabRing() 是铃音 Tab 的构建函数,包含四个区块:铃声生成器卡、当前默认铃声卡、铃声库列表、发布通知入口。

铃声生成器卡是铃音页最核心的交互区块。它提供了两个 Slider 滑块控件——频率滑块(220~1760 Hz,步长 20,紫色主题色)和时长滑块(600~2400 ms,步长 100,青色主题色)。两个滑块的当前值通过 onChange 回调实时更新 genFreqgenDuration 状态变量,同时滑块上方的数值显示文字也跟随更新。每个滑块行都包含标签、当前值、范围说明三部分信息。

底部"生成铃声到沙箱"按钮使用竞技紫背景 + 白色粗体文字,点击后调用 createRingByGen() 使用当前滑块参数生成 WAV 音频并写入沙箱。这实现了用户自定义铃声参数→即时生成音频→自动导入沙箱→可设为通知铃声的完整自助式铃声创作流程。

18.2 当前默认铃声与 sound 字段展示

      Column({ space: 8 }) {
        Row() {
          Text('🎵 当前默认铃声').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('沙箱文件 ' + this.sandboxCount.toString() + ' 个').fontSize(9).fontColor(COLORS.green)
        }
        .width('100%')

        Text(this.currentRingIdx >= 0 && this.currentRingIdx < this.ringList.length ?
          this.ringList[this.currentRingIdx].name : '暂无铃声')
          .fontSize(15).fontColor(COLORS.cyan).fontWeight(FontWeight.Bold)

        Text(this.getSoundValue()).fontSize(8).maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .fontColor(COLORS.sub).width('100%').padding(8)
          .backgroundColor(COLORS.chip).borderRadius(8)
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

当前默认铃声卡以醒目的方式呈现当前选中的通知铃声信息。卡片顶部左侧是标题"🎵 当前默认铃声",右侧是沙箱文件计数(动态读取 sandboxCount)。卡片主体显示当前铃声名称(15px 霓虹青粗体,currentRingIdx 越界时显示"暂无铃声")。卡片底部用 8px 小字展示 getSoundValue() 的返回值——即通知请求 sound 字段的完整 URI 值。这个技术细节的展示让用户(尤其是开发者用户)能直观看到铃声文件在沙箱中的 URI 格式,理解"uri::" 前缀的实际构成。

18.3 铃声库列表与操作按钮

      Column({ space: 8 }) {
        Text('📁 铃声库(导入沙箱后即可设为通知铃声)')
          .fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        ForEach(this.ringList, (item: RingItem, idx: number) => {
          Column({ space: 8 }) {
            Row({ space: 8 }) {
              Text('🎵').fontSize(14)
              Column({ space: 3 }) {
                Row({ space: 6 }) {
                  Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
                  if (idx === this.currentRingIdx) {
                    Text('默认').fontSize(8).fontColor(COLORS.white)
                      .padding({ left: 5, right: 5, top: 1, bottom: 1 })
                      .backgroundColor(COLORS.purple).borderRadius(5)
                  }
                }
                Text(item.freq.toString() + ' Hz · ' + item.duration.toString() + ' ms · ' + item.size)
                  .fontSize(8).fontColor(COLORS.text3)
              }
              .alignItems(HorizontalAlign.Start).layoutWeight(1)

              Text(item.inSandbox ? '沙箱中' : '未导入').fontSize(8)
                .fontColor(item.inSandbox ? COLORS.green : COLORS.orange)
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .backgroundColor(item.inSandbox ? COLORS.greenL : COLORS.goldL)
                .borderRadius(6)
            }
            .width('100%')

            Row({ space: 8 }) {
              if (!item.inSandbox) {
                Text('导入沙箱').fontSize(9).fontColor(COLORS.gold)
                  .layoutWeight(1).textAlign(TextAlign.Center)
                  .padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.goldL).borderRadius(7)
                  .onClick(() => {
                    this.importRingToSandbox(idx);
                  })
              }
              Text('设为默认').fontSize(9).fontColor(COLORS.purple)
                .layoutWeight(1).textAlign(TextAlign.Center)
                .padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.purpleL).borderRadius(7)
                .onClick(() => {
                  this.setCurrentRing(idx);
                })
              Text('删除').fontSize(9).fontColor(COLORS.red)
                .layoutWeight(1).textAlign(TextAlign.Center)
                .padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.redL).borderRadius(7)
                .onClick(() => {
                  this.delIdx = idx;
                  this.delModal = true;
                })
            }
            .width('100%')
          }
          .width('100%').padding(10).backgroundColor(COLORS.chip).borderRadius(10)
        }, (item: RingItem) => item.name + item.file)
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

铃声库列表是铃音页数据最丰富的区块。每条铃声以 Column 卡片呈现,分上下两层。上层是铃声信息——左侧 Emoji 图标、中间铃声名称 + “默认"标签(仅当前默认铃声显示)+ 频率/时长/大小参数、右侧沙箱状态标签(“沙箱中"绿色或"未导入"橙色)。下层是操作按钮行——未导入时显示"导入沙箱"按钮(金色),始终显示"设为默认”(紫色)和"删除”(红色)按钮。三个按钮等宽分布(layoutWeight(1)),颜色编码清晰区分了操作语义。

"导入沙箱"调用 importRingToSandbox(idx) 将音频写入沙箱并更新状态。"设为默认"调用 setCurrentRing(idx) 自动导入并设为默认。"删除"设置 delIdx 并打开删除确认弹窗。

18.4 发布通知入口

      Column({ space: 8 }) {
        Text('📣 发布开赛提醒').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('向战队粉丝推送携带沙箱自定义铃声的开赛提醒,开战前集结应援。')
          .fontSize(9).fontColor(COLORS.sub)
          .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
        Text('立即发布提醒').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          .width('100%').textAlign(TextAlign.Center)
          .padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.purple).borderRadius(10)
          .onClick(() => {
            this.publishNotice();
          })
        Row() {
          Text('通知 ID:' + this.notifyId.toString()).fontSize(8).fontColor(COLORS.text3)
          Column().layoutWeight(1)
          Text('已发送:' + this.sentCount.toString() + ' 条').fontSize(8).fontColor(COLORS.text3)
        }
        .width('100%')
      }
      .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
    }
    .width('100%')
  }

发布通知入口卡是铃音页的业务出口。卡片包含标题、描述文字、"立即发布提醒"按钮和底部状态行。描述文字以 9px 副文本色两行截断的方式说明了通知的业务语义——“向战队粉丝推送携带沙箱自定义铃声的开赛提醒,开战前集结应援”。发布按钮使用竞技紫背景,点击调用 publishNotice() 发布携带沙箱铃声的通知。

底部状态行展示通知 ID(notifyId 自增计数器当前值)和已发送条数(sentCount),让用户了解通知的发布历史和状态。这两个数值在每次成功发布后递增,形成即时反馈。


十九、我的 Tab 构建

19.1 段位渐变大卡

  @Builder
  tabMine() {
    Column({ space: 12 }) {
      Column({ space: 8 }) {
        Text('🏆 当前段位').fontSize(11).fontColor('rgba(255,255,255,0.7)')
        Text('最强王者 50 星').fontSize(28).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
        Text('距巅峰宗师还差 1,860 胜点')
          .fontSize(9).fontColor('rgba(255,255,255,0.7)')
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        Row({ space: 10 }) {
          Column({ space: 3 }) {
            Text('赛季胜点').fontSize(8).fontColor('rgba(255,255,255,0.7)')
            Text('1,860').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('服务器排名').fontSize(8).fontColor('rgba(255,255,255,0.7)')
            Text('No.128').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)

          Column({ space: 3 }) {
            Text('巅峰分').fontSize(8).fontColor('rgba(255,255,255,0.7)')
            Text('2,468').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
        }
        .width('100%').margin({ top: 6 })
      }
      .width('100%').padding(18).borderRadius(14)
      .linearGradient({ angle: 135, colors: [[COLORS.purpleD, 0], [COLORS.cyan, 1]] })

tabMine() 是"我的" Tab 的构建函数。第一块内容是段位渐变大卡——这是整个应用视觉冲击力最强的组件。卡片使用 linearGradient({ angle: 135, colors: [[COLORS.purpleD, 0], [COLORS.cyan, 1]] }) 设置了 135 度对角渐变背景,从深紫 #7C3AED 渐变到霓虹青 #22D3EE。在渐变背景上,所有文字都使用白色或半透明白色(rgba(255,255,255,0.7)),确保在彩色背景上的可读性。

卡片内容自上而下依次是:"🏆 当前段位"标签(11px)、"最强王者 50 星"段位名(28px 超大字号粗体,是全应用最大的文字)、"距巅峰宗师还差 1,860 胜点"进度提示(9px,单行截断),以及底部三等分的数据行——赛季胜点、服务器排名、巅峰分。28px 的段位名是视觉焦点,通过超大字号传达荣誉感和成就感。

19.2 战绩统计清单与选手名片

      ForEach(this.statList, (item: StatItem) => {
        Row({ space: 10 }) {
          Text(item.icon).fontSize(16)
          Column({ space: 3 }) {
            Text(item.name).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            Text(item.tag).fontSize(8).fontColor(COLORS.text3)
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
          Text(item.val).fontSize(13).fontColor(COLORS.purple).fontWeight(FontWeight.Bold)
        }
        .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
      }, (item: StatItem) => item.name)

      Column({ space: 6 }) {
        Row() {
          Text('🎮 选手名片').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Column().layoutWeight(1)
          Text('本命:曜').fontSize(9).fontColor(COLORS.cyan)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.cyanL).borderRadius(8)
        }
        .width('100%')
        Text('ID:夜神King · 战队:ZG 曜光青训 · 擅长分路:打野/中单')
          .fontSize(9).fontColor(COLORS.sub)
          .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
    }
    .width('100%')
  }

战绩统计清单通过 ForEach(this.statList, ...) 渲染 5 条统计数据。每条包含左侧 Emoji 图标、中间统计名称+附加标签、右侧主数值(13px 竞技紫粗体)。ForEachitem.name 为键,确保列表 diff 的精准性。五条数据呈现了完整的选手画像——总场次、胜率、输出、MVP、时长。

最后是选手名片卡,包含"🎮 选手名片"标题、右侧的"本命:曜"标签(霓虹青底色),以及底部的选手信息(ID、战队、擅长分路,9px 副文本色单行截断)。这个卡片为数据化的"我的"页面增添了个人身份的辨识度,让选手信息更加立体。


二十、月度积分柱状图与底部导航

20.1 月度积分柱状图

  @Builder
  chartCard() {
    Column({ space: 10 }) {
      Row() {
        Text('📊 战队月度积分').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('单位:分').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Row({ space: 8 }) {
        ForEach(MONTH_IDX, (i: number) => {
          Column({ space: 5 }) {
            Column().width(16)
              .height(this.breath && i === MONTH_IDX.length - 1 ?
                Math.max(20, MONTH_VAL[i] / MONTH_MAX * 110 + 4) :
                Math.max(20, MONTH_VAL[i] / MONTH_MAX * 110))
              .borderRadius(4).backgroundColor(COLORS.purple)
            Text(MONTH_NAME[i]).fontSize(8).fontColor(COLORS.text3)
          }
          .layoutWeight(1).alignItems(HorizontalAlign.Center)
        }, (i: number) => 'm' + i)
      }
      .width('100%').alignItems(VerticalAlign.Bottom).height(150)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }

chartCard() 是通用图表卡,在所有 Tab 的内容底部都会渲染。它展示战队月度积分柱状图——通过 ForEach(MONTH_IDX, ...) 渲染 6 个柱子,每个柱子的数据来自 MONTH_VAL[i]

柱状图的柱子高度计算公式为 Math.max(20, MONTH_VAL[i] / MONTH_MAX * 110)——先将积分数值除以最大值 900 得到比例,再乘以 110 像素得到实际高度,最后通过 Math.max(20, ...) 确保最小高度不低于 20px(防止数值过小时柱子完全消失)。柱子宽度固定 16px,圆角 4px,填充竞技紫。

柱状图的呼吸动画效果体现在最后一个柱子(当前月份 8月)——当 this.breathtruei === MONTH_IDX.length - 1 时,柱子高度额外加 4 像素(+ 4),形成每秒一次的微放大效果。这种"末柱呼吸"设计将用户注意力引导到最新数据上,符合"关注当前"的信息消费习惯。

整个 Row 容器设置 alignItems(VerticalAlign.Bottom) 底部对齐和 height(150) 固定高度,确保所有柱子从底部向上生长,形成标准的柱状图视觉。

20.2 底部导航栏

  @Builder
  tabBar() {
    Row() {
      ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
        Column({ space: 3 }) {
          Text(t.icon).fontSize(this.currentTab === idx ? 20 : 17)
            .opacity(this.currentTab === idx ? 1 : 0.65)
          Text(t.label).fontSize(9)
            .fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
            .fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
        .padding({ top: 7, bottom: 7 })
        .onClick(() => {
          this.currentTab = idx;
        })
      }, (t: TabMeta) => t.label)
    }
    .width('100%')
    .backgroundColor(COLORS.card)
    .border({ width: { top: 1 }, color: COLORS.line })
  }

tabBar() 是底部导航栏的构建函数。通过 ForEach(TAB_LIST, ...) 渲染 4 个等宽的 Tab 项,每个 Tab 包含 Emoji 图标和文字标签。选中态通过三重视觉差异体现:图标字号更大(20px vs 17px)、透明度更高(1 vs 0.65)、标签文字使用选中色(tabOn 竞技紫)且粗体。未选中态使用三级文本色和正常字重。

每个 Tab 绑定 onClick 事件,点击后设置 this.currentTab = idx,触发内容区域的 if/else if 条件链重新渲染对应的 Tab @Builder 函数。ForEacht.label 为键确保精准 diff。

底部导航栏整体使用 COLORS.card 深紫底色,顶部有一道 1px 的 COLORS.line 分割线,与内容区域形成清晰的视觉分界。


二十一、弹窗系统构建

21.1 全屏遮罩通用构建

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

modalOverlay() 是弹窗遮罩的通用构建函数,接收一个 onClose 回调参数。遮罩是一个 Stack 容器,内部是一个撑满全屏的 Column,背景色为 COLORS.maskrgba(0,0,0,0.55) 半透明黑色)。alignContent(Alignment.Center) 确保后续在 Stack 中叠加的弹窗面板居中显示。遮罩绑定 onClick 事件,点击遮罩区域时触发 onClose() 回调关闭弹窗——这是移动端弹窗的标准交互模式,用户可以点击弹窗外部区域快速关闭。

21.2 新增赛程弹窗

  @Builder
  panelAdd(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('新增赛程').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

        Column({ space: 6 }) {
          Text(FORM_LABELS[0]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formTime, placeholder: '如 20:00' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formTime = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text(FORM_LABELS[1]).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(FORM_LABELS[2]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.formVs, placeholder: '如 ZG 曜光 vs DK 黑骑士' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.formVs = 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.purple).borderRadius(9)
            .onClick(() => {
              this.saveMatch();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%').height('100%')
    .alignContent(Alignment.Center)
  }

panelAdd() 是新增赛程弹窗面板。采用 Stack 层叠结构——底层是 modalOverlay(onClose) 遮罩(点击可关闭),上层是居中的表单面板。面板宽度 78%,圆角 14px,深紫底色 COLORS.card

表单包含三个 TextInput 输入框——开赛时间、赛事名称、对阵双方,每个输入框上方有 9px 副文本色的标签(来自 FORM_LABELS 常量数组),输入框内 placeholder 提供输入示例。每个 TextInputonChange 回调将输入值实时同步到对应的 @State 变量(formTimeformTitleformVs),确保点击保存时能读取到最新输入值。

底部是等宽的"取消"和"保存"按钮——取消使用 chip 深色底 + sub 副文本色,保存使用 purple 紫底 + 白色粗体。保存按钮点击调用 saveMatch() 执行新增逻辑(含表单非空校验)。

21.3 编辑与删除弹窗

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

        Column({ space: 6 }) {
          Text(FORM_LABELS[0]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editTime, placeholder: '开赛时间' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editTime = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text(FORM_LABELS[1]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editTitle, placeholder: '赛事名称' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editTitle = value;
            })
        }
        .width('100%').alignItems(HorizontalAlign.Start)

        Column({ space: 6 }) {
          Text(FORM_LABELS[2]).fontSize(9).fontColor(COLORS.sub)
          TextInput({ text: this.editVs, placeholder: '对阵双方' })
            .fontSize(11).fontColor(COLORS.title)
            .backgroundColor(COLORS.chip).borderRadius(8)
            .onChange((value: string) => {
              this.editVs = 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.purple).borderRadius(9)
            .onClick(() => {
              this.updateMatch();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%').height('100%')
    .alignContent(Alignment.Center)
  }

panelEdit() 是编辑赛程弹窗,结构与 panelAdd() 完全对称——区别仅在于绑定的是 editTime/editTitle/editVs 变量和保存按钮调用 updateMatch()。在赛程时间轴中点击"编辑"按钮时,方法先将该条赛程的数据填充到编辑表单字段(this.editTime = item.time 等),再打开弹窗。因此弹窗打开时 TextInputtext 属性已经携带了当前赛程的数据,用户看到的是预填充了现有内容的输入框,而非空白表单。这种"先填充再展示"的编辑交互模式,确保了用户在编辑时能看到当前值并在此基础上修改。

21.4 删除铃声确认弹窗

  @Builder
  panelDel(onClose: () => void) {
    Stack() {
      this.modalOverlay(onClose)
      Column({ space: 12 }) {
        Text('删除铃声').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('确认删除该铃声吗?删除后将同步清理沙箱中的音频文件,且不可恢复。')
          .fontSize(10).fontColor(COLORS.sub)
          .maxLines(2).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.delRing();
            })
        }
        .width('100%')
      }
      .width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
    }
    .width('100%').height('100%')
    .alignContent(Alignment.Center)
  }
}

panelDel() 是删除铃声确认弹窗,设计风格比新增/编辑弹窗更简洁——没有表单输入框,只有标题、警告描述文本和两个按钮。警告文本"确认删除该铃声吗?删除后将同步清理沙箱中的音频文件,且不可恢复"明确告知用户删除操作的后果(包括沙箱文件清理和不可恢复性),符合破坏性操作应提供明确确认的交互设计原则。

确认删除按钮使用红色背景 COLORS.red + 白色粗体文字,与取消按钮的深色形成强烈对比,视觉上强调了"这是危险操作"。点击后调用 delRing() 执行删除和沙箱清理。

三个弹窗面板(新增、编辑、删除)共享相同的结构模式——Stack 层叠 + 遮罩 + 居中面板 + 表单/内容 + 按钮行,通过 @Builder 函数的参数化设计实现了高度的代码复用和一致的交互体验。


二十二、技术特性对比

技术维度 实现方案 技术要点 场景价值
通知铃声定制 Notification Kit 6.1.1 sound 字段 + 沙箱 URI uri:: + fileUri.getUriFromPath() 拼接 电竞通知铃声完全由应用端控制,无需系统铃声
音频文件生成 纯代码生成标准 WAV 字节 DataView 逐字节写入 RIFF 头 + 正弦波 PCM 数据 无需外部音频资源,运行时动态生成任意频率铃声
沙箱文件管理 EL1 区域 filesDir + fileIo 同步 I/O contextConstant.AreaMode.EL1 + openSync/writeSync/closeSync 铃声文件安全存储在应用沙箱,随应用生命周期管理
通知授权流程 两级降级授权策略 requestEnableNotificationopenNotificationSettings 首次弹框授权,被拒后引导设置页二次授权
Canvas 雷达图 CanvasRenderingContext2D 2D 绘图 三层网格 + 数值多边形 + 呼吸节点 + 标签 选手六维战力直观可视化,面积感+精确数值双呈现
呼吸动画 setInterval 定时器 + @State 翻转 1000ms 翻转 breath 布尔值驱动多组件联动 授权胶囊闪烁、雷达图节点微动、柱状图末柱放大
深色主题体系 ColorPalette 接口 + 24 字段常量 竞技紫 #8B5CF6 + 霓虹青 #22D3EE 双主色 赛博朋克电竞氛围,深色底+高饱和点缀色
竖向时间轴 ForEach + 固定行高 + 圆点连线 72px 行高 + 状态色圆点 + 连接竖线条件渲染 赛程可视化,状态色彩编码一眼可辨
弹窗系统 Stack 层叠 + modalOverlay 遮罩 全屏半透明遮罩 + 居中面板 + onClose 回调 新增/编辑/删除三种交互,点击遮罩关闭
渐变段位卡 linearGradient 135 度对角渐变 purpleD → cyan 双色渐变 + 白色文字 段位荣誉感的视觉强化,全应用最大字号
响应式数据 @State + @Observed 双层驱动 @State 管理列表,@Observed 管理条目属性 列表增删和属性修改即时反映到 UI
条件渲染 Tab if/else if + @Builder 函数 currentTab 值决定渲染哪个 Tab Builder 按需渲染,避免多页面实例的内存开销

二十三、总结

23.1 技术架构总结

纵观整个"锐竞社·电竞社区平台"的技术实现,我们可以清晰地看到一款优秀的 HarmonyOS ArkUI 应用应当具备的架构素养。应用以 @Entry @Component 装饰的 Page1104 组件为单一入口,向下通过十余个 @State 响应式状态变量管理 UI 的动态数据,向上通过八个 @Builder 构建函数组织页面布局。状态变量的任何变化都会自动触发依赖其的 UI 部分重新渲染,开发者无需手动调用刷新命令——这是声明式 UI 范式的核心优势,也是 ArkUI 框架的设计哲学基石。

数据层的设计体现了"接口先行"的工程规范——ColorPalette 接口为色彩体系提供了类型契约,TabMetaMiniStatMatchItemRingItemStatItem 等接口和 @Observed 类为业务数据提供了结构化定义。Mock 数据集中初始化,与组件状态变量绑定后成为可响应式更新的数据源。工具函数层(matchColorbuildWavBytes)将纯逻辑运算与 UI 渲染分离,提升了代码的可测试性和可复用性。

23.2 Notification Kit 新特性价值

应用最核心的技术亮点是对 Notification Kit 6.1.1 通知自定义铃声特性的完整实践。从 WAV 音频字节的纯代码生成,到 EL1 沙箱文件写入,到 fileUri.getUriFromPath() 的 URI 转换,再到 NotificationRequest.sound 字段的 uri:: 前缀拼接——整条链路的每一步都在代码中有清晰的实现和注释。这种"从音频生成到通知发布"的全链路自研方案,使得应用完全摆脱了对外部音频资源的依赖,用户可以通过铃声生成器自由调整频率和时长,创作属于自己战队的独特战鼓号角铃声。

两级降级的授权策略(requestEnableNotificationopenNotificationSettings)也是通知功能的最佳实践——既尊重了用户的首次授权选择权,又为被拒绝后的场景提供了恢复路径,避免了"一次拒绝永久无法使用"的死胡同。配合头部授权胶囊的呼吸闪烁视觉提示,用户能直观感知授权状态并便捷地发起授权请求。

23.3 Canvas 绘图能力分析

Canvas 六维雷达图的绘制充分展现了 ArkUI Canvas 组件的 2D 绘图能力。从三层同心网格的逐层构建,到六条轴线的辐射绘制,到数值多边形的半透明填充与实线描边,到呼吸节点的圆形渲染,再到外圈维度标签的定位输出——每一步都精确使用了 CanvasRenderingContext2D 的标准 API。呼吸动画通过 setInterval 定时器每秒翻转 breath 状态并重绘整个雷达图,实现了节点半径在 3.5~4 像素之间的微动效果。虽然这种"全量重绘"的方式在性能上不如增量绘制高效,但对于六维雷达图这种轻量级图表而言,完全在可接受范围内,且代码简洁度大幅提升。

23.4 设计理念与行业价值

从行业场景的角度来看,"锐竞社"展示了电竞社区平台应当具备的核心能力矩阵——战报数据可视化(雷达图+KDA)、赛事信息管理(时间轴+增删改)、通知铃声定制(生成器+沙箱+发布)、个人数据展示(段位卡+统计清单)。这些能力的组合不是简单的功能堆砌,而是围绕"电竞粉丝的赛事参与体验"这一核心场景有机串联——粉丝查看战报了解选手实力,浏览赛程关注感兴趣的比赛,设置个性化铃声定制通知体验,在开赛前收到富有竞技氛围的铃声提醒集结应援。

深色竞技紫+霓虹青的赛博朋克视觉风格,段位渐变大卡的荣誉感设计,竖向时间轴的赛事可读性布局,铃声生成器的自助创作能力——每一处设计都紧密贴合电竞文化基因,让技术不仅是功能载体,更是情感表达的媒介。这正是优秀的行业应用应有的境界:技术服务于场景,场景承载于文化。

23.5 工程实践启示

从工程实践的角度来看,这份代码提供了多个值得借鉴的设计模式。第一,色彩体系通过接口集中定义、常量统一实现,确保了全局视觉一致性。第二,@Builder 函数将布局逻辑按功能区域拆分为独立单元,每个函数职责单一、可独立维护。第三,弹窗系统通过 Stack 层叠 + 参数化回调的通用模式,实现了三种弹窗的统一交互范式。第四,ForEach 的键值函数设计确保了列表渲染的精准 diff 和高效更新。第五,生命周期回调中定时器的启动和清理配对使用,防止了内存泄漏。这些实践模式不仅适用于本应用,也可作为 HarmonyOS ArkUI 应用开发的通用参考。

总而言之,"锐竞社"以一个电竞社区平台的完整业务场景,串联了 HarmonyOS Notification Kit 自定义铃声、Canvas 数据可视化、沙箱文件管理、响应式状态驱动、声明式条件渲染等多项核心技术能力。它不是简单的功能 Demo,而是一个有业务深度、有技术广度、有设计温度的行业应用样本——值得每一位 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 将自动执行以下操作:

  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 应用的功能开发。


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

Logo

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

更多推荐