一、技术前言:HarmonyOS ArkUI 框架与智能家居能源管理的技术交汇

在这里插入图片描述

HarmonyOS(鸿蒙操作系统)作为华为自主研发的分布式操作系统,其应用层开发框架 ArkUI 提供了一套全新的声明式 UI 编程范式。ArkUI 基于 ArkTS 语言——这是 TypeScript 的超集,在保持 TypeScript 类型安全优势的同时,增加了 @Entry、@Component、@State、@Builder、@Observed 等装饰器,用于声明组件入口、组件结构、响应式状态、构建器函数和可观察数据模型。开发者只需描述界面的"是什么"状态,框架便会自动管理界面与状态的同步更新,极大降低了复杂 UI 的维护成本。与传统命令式 UI 框架相比,ArkUI 的声明式模型让数据驱动视图成为一等公民,当 @State 变量发生变化时,框架自动触发依赖该变量的 UI 组件重新渲染,无需手动调用刷新方法。

在这里插入图片描述
Notification Kit 是 HarmonyOS 提供的通知服务能力集,它允许应用向系统通知栏推送文本、图片、多行甚至交互式通知。在智能家居能源管理场景中,通知扮演着"异步触达用户"的关键角色:当空调定时启动、充电桩开始谷电补能、净水器滤芯寿命告警时,应用需要通过通知提醒用户。传统应用的通知铃声只能使用系统默认音或 rawfile 资源文件,而 HarmonyOS 6.1.1 引入了一项重要新特性——通知的 sound 字段支持应用沙箱文件路径作为自定义铃声源。这意味着应用可以在运行时动态生成音频文件、写入应用沙箱的 EL1 级别 files 目录,再通过 fileUri 将沙箱路径转换为 URI,最终以 ‘uri::’ + uri 的格式填入 NotificationRequest 的 sound 字段,实现完全动态化的通知铃声定制。这一突破使得智能家居应用可以根据不同场景(光伏发电完成、谷电开始、电费告警等)推送不同铃声的个性化通知。

在这里插入图片描述
Canvas 2D 绘图能力是 ArkUI 的另一项重要特性。通过 CanvasRenderingContext2D 对象,开发者可以在 Canvas 组件上执行与 Web Canvas API 高度兼容的绘图操作,包括路径绘制、渐变填充、文本渲染、坐标系变换等。在智能家居能源管理场景中,数据可视化是核心需求之一——用户需要直观地看到今日用电目标的完成进度、峰平谷三个电价时段的用电分布、近六个月电费变化趋势等。Canvas 进度环通过三段不同颜色的弧线(峰段橙、平段青、谷段绿)直观展示分时电价下各时段用电占比;柱状图则通过圆角矩形+线性渐变填充呈现月度电费的波动趋势。更巧妙的是,两个 Canvas 图表都与一个每秒翻转的呼吸动画定时器联动,通过 globalAlpha 透明度变化和末柱高度微变实现"活"的数据图表效果,让静态的数据展示拥有了动态生命力。

在这里插入图片描述
智能家居能源管理是 IoT 行业中一个快速增长的细分赛道。随着家庭分布式光伏装机量提升、电动汽车充电桩普及以及阶梯电价/分时电价政策的推广,家庭用户对"用电可见、用电可控、用电可省"的需求日益强烈。一个优秀的家庭能源管理应用应当具备以下能力:实时监测全屋功率与各电器耗电分布、按分时电价智能调度家电运行时段、通过光伏发电抵扣电网用电成本、提供月度电费趋势可视化分析,以及通过通知系统实现定时任务执行的异步提醒。本应用"绿电管家"正是围绕这些核心需求设计的,它以浅色能源绿(#10B981)为主色调,辅以蓝色(#3B82F6)作为科技点缀,构建了一套从数据展示到任务调度再到通知触达的完整能源管理闭环。

在这里插入图片描述
本应用的业务场景设计紧密围绕"家庭能源效率最大化"这一目标展开。在能源页面,用户可以一目了然地看到今日用电进度环(峰平谷三段色)、实时总功率大数字、光伏发电量与节电率数据卡,以及全屋电器耗电排行(空调、热水器、充电桩等六大电器占比可视化)。在日程页面,竖向时间轴排列着当日八条家电定时任务——从清晨 06:30 的空调预冷到深夜 22:00 的全屋待机断电,每条任务都可点击"执行通知"推送携带沙箱自定义铃声的通知。在铃音页面,用户可以使用频率滑块和时长滑块生成正弦波 WAV 音频文件、将其写入沙箱、设为默认通知铃声,还能实时预览 NotificationRequest.sound 字段的完整取值。在我的页面,家庭月度电费以渐变大卡呈现,配以接入设备数、自动化场景数、本月省电量、碳减排量、绿电积分等统计清单,底部 Canvas 柱状图展示近六个月电费变化趋势。

在这里插入图片描述
从设计理念层面看,本应用遵循了"数据可视化优先"与"异步交互闭环"两大原则。数据可视化优先意味着在有限的移动端屏幕上,通过 Canvas 进度环、柱状图、进度条等多种图表形式,将抽象的能源数据转化为用户可一眼读懂的视觉语言。异步交互闭环则意味着从家电定时任务设置到通知铃声自定义再到通知推送执行,形成了一条完整的用户操作链条——用户设定任务、选择铃声、收到通知,每个环节都有视觉反馈和状态追踪。此外,呼吸动画贯穿全局,头部授权胶囊的闪烁、进度环透明度的脉动、柱状图末柱的微放大,共同营造了一种"应用在实时运行"的活体感,避免了静态界面的冰冷感。

在这里插入图片描述

二、整体架构流程图

弹窗系统

四大Tab页面

核心方法群

状态管理 State

生命周期管理

应用入口

@Entry @Component
Page1107 主页面

aboutToAppear()
查询通知授权 + 启动呼吸定时器

aboutToDisappear()
清理定时器

currentTab / breath
Tab索引 + 呼吸开关

addModal / editModal / delModal
三态弹窗开关

granted / notifyId / sentCount
通知授权/ID/计数

genFreq / genDuration / currentRingIdx
铃声生成器参数

taskList / ringList / noticeLogs / statList
四大数据列表

canvasReady / barReady
双Canvas就绪标志

requestAuth()
通知授权

saveRingToSandbox()
写入EL1沙箱

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

drawRingChart() / drawBarChart()
双Canvas绘制

能源 Tab
进度环+功率+电器排行

日程 Tab
竖向时间轴+任务通知

铃音 Tab
铃声生成器+铃声库+sound预览

我的 Tab
电费大卡+统计+柱状图

panelAdd
新增定时任务

panelEdit
编辑任务

panelDel
删除铃声确认

三、模块导入与工程依赖

3.1 Kit 模块导入

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

这段导入语句是整个应用功能体系的基石。HarmonyOS 采用 Kit 模块化设计,每个 Kit 封装了一组相关领域的系统能力,应用按需导入即可获得对应能力。

第一行从 @kit.NotificationKit 导入了 notificationManager,这是通知服务的核心管理器对象,它提供了通知授权查询(isNotificationEnabled)、授权请求(requestEnableNotification)、通知设置页跳转(openNotificationSettings)、通知发布(publish)等关键方法。在本应用中,几乎所有与通知相关的功能都依赖此对象。

第二行从 @kit.CoreFileKit 导入了两个工具:fileIo(重命名为 fs)用于文件 I/O 操作,包括同步打开文件(openSync)、同步写入(writeSync)、同步关闭(closeSync)、同步删除(unlinkSync)等;fileUri 用于在沙箱文件路径与 URI 之间转换,其 getUriFromPath 方法是将沙箱音频路径转换为通知 sound 字段可识别格式的关键桥梁。

第三行从 @kit.AbilityKit 导入了 contextConstantcommoncontextConstant 提供了应用沙箱区域的枚举常量(如 AreaMode.EL1),common 提供了 UIAbilityContext 类型定义,用于获取应用上下文对象以访问沙箱目录。

第四行从 @kit.BasicServicesKit 导入了 BusinessError 类型,这是 HarmonyOS 异步 API 统一的错误类型,包含 codemessage 字段,在 catch 块中用于区分不同错误码(如 1600004 表示通知未授权)并采取不同的恢复策略。

四、颜色系统与主题设计

4.1 颜色面板接口定义

interface ColorPalette {
  bg: string;
  card: string;
  chip: string;
  title: string;
  sub: string;
  text3: string;
  white: string;
  green: string;
  greenD: string;
  greenL: string;
  blue: string;
  blueL: string;
  orange: string;
  orangeL: string;
  red: string;
  redL: string;
  purple: string;
  purpleL: string;
  cyan: string;
  cyanL: string;
  line: string;
  tabOn: string;
  mask: string;
}

颜色系统是整个应用视觉语言的根基。这里通过 interface ColorPalette 将所有颜色字段集中声明为一个接口类型,确保后续的 COLORS 常量对象必须完整填写每一个颜色字段,任何遗漏都会在编译期被 TypeScript 类型检查器捕获。这种"接口先行、常量后填"的设计模式,既保证了类型安全,又提高了代码可读性——其他开发者一眼就能看出这个应用使用了哪些颜色维度。

从字段命名可以看出,颜色被分为几大类:基础色(bg 背景、card 卡片白、white 纯白、mask 遮罩半透明黑)、文本层级色(title 标题深色、sub 副标题中灰、text3 弱化浅灰)、语义色系对(每个语义色提供深色版本和浅色背景版本,如 green/greenLorange/orangeLred/redL 等),以及布局辅助色(chip 芯片背景、line 分割线、tabOn Tab 选中色)。这种"深色+浅色背景"的成对设计是现代 UI 设计系统的常见模式——深色用于文字/图标/进度条,浅色用于背景/胶囊/标签。

4.2 浅色科技主题色板常量

const COLORS: ColorPalette = {
  bg: '#F4F8F7',
  card: '#FFFFFF',
  chip: '#E7F0EE',
  title: '#1E293B',
  sub: '#64748B',
  text3: '#A5B4C4',
  white: '#FFFFFF',
  green: '#10B981',
  greenD: '#059669',
  greenL: '#D9F2E8',
  blue: '#3B82F6',
  blueL: '#E4EEFD',
  orange: '#F59E0B',
  orangeL: '#FEF3DD',
  red: '#EF4444',
  redL: '#FDE8E8',
  purple: '#8B5CF6',
  purpleL: '#F0EAFD',
  cyan: '#06B6D4',
  cyanL: '#DFF7FB',
  line: '#E2EAE7',
  tabOn: '#10B981',
  mask: 'rgba(0,0,0,0.45)'
};

COLORS 常量实现了上述接口的全部字段。从色彩值来看,整体采用了一套精心调配的浅色科技主题:背景色 #F4F8F7 是一种极淡的青绿色,给人清爽、能源、自然的心理暗示;标题色 #1E293B 是接近石板黑的深蓝灰,保证高对比度可读性;主色 #10B981 是标准的设计系统绿色(类似 Tailwind CSS 的 Emerald-500),代表着能源、电力、环保的语义。

辅助色系统同样丰富:蓝色 #3B82F6 用于科技点缀和光伏相关数据;橙色 #F59E0B 用于峰段电价和待执行任务状态;红色 #EF4444 用于删除操作和警告;紫色 #8B5CF6 用于已发通知计数;青色 #06B6D4 用于平段电价。每个深色都配有一个极浅的背景色(如 greenL: #D9F2E8),用于胶囊标签、进度条背景、输入框背景等场景。

值得注意的细节是 tabOn 字段直接复用了 green 的值,表明底部导航的选中态使用主题主色,保持品牌一致性。mask 使用 rgba(0,0,0,0.45) 的半透明黑色,用于弹窗遮罩层,既能遮挡底层内容又不完全遮蔽,符合移动端弹窗设计的惯例。

五、常量定义与数据结构

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 的排列顺序也体现了功能优先级——能源数据是用户最高频查看的,放在首位;铃声配置是特色功能,排在第三位作为技术亮点展示。

5.2 能源数据常量

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

const ENERGY_MINI: MiniStat[] = [
  { icon: '🔋', name: '今日电量', val: '8.6 kWh' },
  { icon: '☀️', name: '光伏发电', val: '5.2 kWh' },
  { icon: '🌱', name: '节电率', val: '18%' }
];

能源页面底部排列的三个数据小卡使用 MiniStat 接口定义,每张卡包含图标、名称和数值三个字段。数据内容设计紧贴家庭能源管理场景:今日总用电量 8.6 kWh、光伏发电 5.2 kWh(占比超过 60%,体现了分布式光伏的价值)、节电率 18%(对比历史基准的节能效果)。这三个数据卡在视觉上使用 ForEach 横向等宽排列,每张卡内部垂直堆叠图标、数值和名称,形成紧凑的信息密度。

5.3 分时电价段数据

interface SegData {
  label: string;
  val: number;
}

const SEG_LIST: SegData[] = [
  { label: '峰段', val: 3.2 },
  { label: '平段', val: 3.0 },
  { label: '谷段', val: 2.4 }
];

const TODAY_KWH: number = 8.6;
const TARGET_KWH: number = 12;

分时电价(Time-of-Use)是电力需求侧管理的核心机制,将一天划分为峰、平、谷三个时段,峰段电价最高、谷段最低,引导用户在谷段集中用电。SegData 接口定义了时段标签和用电量两个字段。三个时段的用电量合计为 3.2 + 3.0 + 2.4 = 8.6 kWh,与 TODAY_KWH 一致。

TODAY_KWH(已用 8.6)和 TARGET_KWH(目标 12)的差值 3.4 kWh 代表用户今日还有 3.4 度的用电预算空间。这两个数值在 Canvas 进度环中以"当前电量/目标"的比例计算各段弧度角度,同时计算出目标完成度百分比(8.6/12 = 71.7%)。在右侧图例区域还会显示这个完成度百分比作为绿色胶囊标签,形成数据与可视化的双向印证。

5.4 电器耗电排行数据

interface ApplianceRank {
  name: string;
  power: string;
  pct: number;
}

const APPLIANCE_LIST: ApplianceRank[] = [
  { name: '客厅空调', power: '1.26 kW', pct: 32 },
  { name: '电热水器', power: '2.00 kW', pct: 21 },
  { name: '充电桩', power: '7.00 kW', pct: 18 },
  { name: '洗烘一体机', power: '0.92 kW', pct: 10 },
  { name: '双开门冰箱', power: '0.12 kW', pct: 7 },
  { name: '扫地机器人', power: '0.06 kW', pct: 4 }
];

const APPLIANCE_MAX: number = 32;

电器耗电排行是能源页面的重要信息模块,它展示了全屋六大电器的实时功率和耗电占比。ApplianceRank 接口定义了电器名称、功率字符串和占比百分比。数据按占比降序排列——客厅空调以 32% 居首(1.26 kW 功率),电热水器 21%(2.00 kW),充电桩 18%(7.00 kW 虽然功率最高但运行时间短),冰箱和扫地机等低功率持续运行设备占比最小。

APPLIANCE_MAX 常量取排行中的最大占比值 32,用于进度条宽度映射基准。在辅助函数 pctToBar 中,占比 32% 会被映射为 88% 的进度条宽度(留出 12% 的视觉边距),其余按比例换算。排名第一的电器使用橙色进度条(高耗能警示),其余使用绿色,通过颜色编码传递耗能等级信息。

5.5 月度电费柱状图数据

const MONTH_NAME: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
const MONTH_VAL: number[] = [186.2, 203.5, 241.8, 268.4, 312.6, 275.9];
const MONTH_MAX: number = 320;

这组数据用于"我的"页面的 Canvas 柱状图绘制。MONTH_NAME 提供近六个月的月份标签,MONTH_VAL 提供对应的电费金额。数据趋势呈现先升后降的形态——从 3 月的 186.2 元逐月上升到 7 月的 312.6 元峰值(夏季空调用电高峰),8 月回落至 275.9 元。这种趋势在柱状图中通过柱子高度直观可见,末柱(8 月)还会通过呼吸动画进行微放大处理以突出"当前月"。

MONTH_MAX 设为 320,略高于最大值 312.6,确保最高柱不会顶到图表顶部,留出标注数值的空间。柱状图绘制时以 MONTH_VAL[i] / MONTH_MAX 为比例计算每根柱子的高度。

5.6 表单标签常量

const FORM_LABELS: string[] = ['执行时间', '设备名称', '任务备注'];

这组常量定义了新增和编辑定时任务弹窗中三个输入字段的标签文案。将标签文案提取为常量数组,避免了在 panelAddpanelEdit 两个 Builder 中重复硬编码字符串,同时便于未来国际化扩展。

六、辅助函数群

6.1 任务状态颜色映射

function taskColor(s: string): string {
  if (s === '待执行') {
    return COLORS.orange;
  } else if (s === '执行中') {
    return COLORS.blue;
  } else if (s === '已完成') {
    return COLORS.green;
  }
  return COLORS.text3;
}

taskColor 函数将任务状态字符串映射为对应的主题颜色。待执行用橙色(表示"等待中、需要注意")、执行中用蓝色(表示"正在进行、科技感")、已完成用绿色(表示"完成、成功"),其余未知状态回退为弱化灰色。这个函数在日程 Tab 的竖向时间轴中被多次调用——任务标题旁的状态文字、时间轴圆点的填充色都通过此函数获取颜色,确保状态颜色在整个应用中一致。

6.2 分时电段颜色映射

function segColor(label: string): string {
  if (label === '峰段') {
    return COLORS.orange;
  } else if (label === '平段') {
    return COLORS.cyan;
  } else if (label === '谷段') {
    return COLORS.green;
  }
  return COLORS.text3;
}

segColor 函数将电价时段映射为颜色。峰段用橙色(电价高、提醒注意)、平段用青色(中间态)、谷段用绿色(电价低、鼓励用电)。这套颜色编码在 Canvas 进度环的三段弧线、能源页右侧图例的圆点标记中统一使用,形成了"峰橙平青谷绿"的视觉语言体系,帮助用户通过颜色快速识别各时段用电分布。

6.3 百分比转进度条宽度

function pctToBar(pct: number): number {
  return Math.max(8, Math.round(pct / APPLIANCE_MAX * 88));
}

pctToBar 函数将电器耗电占比转换为进度条的宽度百分比。公式为 (pct / 32) * 88,即以最大占比 32% 映射到 88% 宽度,同时通过 Math.max(8, ...) 保证最小宽度不低于 8%,即使是占比 4% 的扫地机器人也能显示出一个可见的进度条段。这个映射策略避免了低占比电器进度条过窄不可见的问题,同时整体不超过 88%,留出右侧百分比文字的显示空间。

6.4 时间格式化工具函数

function pad2(n: number): string {
  return n < 10 ? '0' + n.toString() : n.toString();
}

function nowTime(): string {
  const d = new Date();
  return pad2(d.getHours()) + ':' + pad2(d.getMinutes());
}

pad2 函数将个位数补零为两位字符串(如 7 变为 “07”),nowTime 函数利用 pad2 拼接当前时间的 HH:mm 格式字符串。这两个函数在通知发布记录(NoticeLog)中被调用——每次发布通知时,调用 nowTime() 获取当前时间作为通知历史记录的时间戳,显示在日程页面的通知发布记录列表中。

6.5 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 格式的音频字节流,模拟"用户生成或网络下载的音频文件",为通知沙箱铃声特性提供音频源。

函数首先计算采样参数:采样率 44100 Hz(CD 音质标准)、采样数 = 采样率 * 时长 / 1000、数据大小 = 采样数 * 2(16bit = 2 字节/采样)。然后分配 ArrayBuffer,大小为 44 字节 WAV 头 + 数据大小。

WAV 文件头部的写入严格遵循 RIFF/WAVE 格式规范:偏移 0-3 写入 “RIFF” 标识、偏移 4-7 写入文件大小(36 + 数据大小)、偏移 8-11 写入 “WAVE”、偏移 12-15 写入 "fmt " 子块标识、偏移 16-19 写入 fmt 块大小(16 字节)、偏移 20-21 写入音频格式(1 = PCM)、偏移 22-23 写入声道数(1 = 单声道)、偏移 24-27 写入采样率、偏移 28-31 写入字节率(采样率 * 块对齐 = 采样率 * 2)、偏移 32-33 写入块对齐(2 = 声道数 * 位深度/8)、偏移 34-35 写入位深度(16)、偏移 36-39 写入 “data” 子块标识、偏移 40-43 写入数据大小。所有多字节整数使用小端序(true 参数)。

音频数据的生成采用了包络(envelope)和衰减(decay)双重调制:每个采样点的值为 sin(2π * freq * t) * 0.5 * env * decay。其中 env = min(1, i / (sampleRate * 0.02)) 实现了 20ms 的线性淡入(避免开始瞬间的爆音),decay = max(0, 1 - t / duration) 实现了全时长的线性衰减(模拟铃声逐渐减弱的自然听感),最终值乘以 32767 并通过 setInt16 写入为有符号 16 位整数。这种"正弦波+淡入+衰减"的合成方式可以生成类似电子铃音的清脆音色,频率参数决定了音高(440Hz 约为标准 A4 音、880Hz 高八度、1760Hz 更高音),时长参数决定了持续秒数。

七、数据模型与 Mock 数据

7.1 可观察数据模型类

@Observed export class TaskItem {
  time: string;
  title: string;
  note: string;
  status: string;

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

TaskItem 类使用了 @Observed 装饰器,这使得该类的实例属性变为可观察状态。当实例的属性被修改时(如编辑任务后修改 timetitlenote),ArkUI 框架会自动检测到变化并触发依赖这些属性的 UI 组件重新渲染。这是实现"编辑任务后列表自动刷新"的关键机制。

TaskItem 包含四个字段:time(执行时间,如 “06:30”)、title(任务标题,如"空调定时开启")、note(任务备注,如"主卧 · 26℃ 节能风 · 峰前预冷")、status(状态,“待执行”/“执行中”/“已完成”)。构造函数采用传统的参数赋值方式。

@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(文件大小字符串,导入沙箱前为"—")、inSandbox(是否已导入沙箱布尔值)。当铃声被导入沙箱时,inSandbox 变为 true、size 更新为实际大小,@Observed 确保铃声库列表 UI 自动刷新。

@Observed export class NoticeLog {
  time: string;
  title: string;
  text: string;
  ok: boolean;

  constructor(time: string, title: string, text: string, ok: boolean) {
    this.time = time;
    this.title = title;
    this.text = text;
    this.ok = ok;
  }
}

NoticeLog 类代表通知发布历史记录条目。ok 布尔字段标识通知是否发布成功——成功时为 true,失败时(如未授权)为 false。通知历史以倒序方式(unshift 插入头部)展示在日程页面,最多保留 6 条记录,超过时通过 pop() 移除最旧的一条。

@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 类代表家庭统计条目,包含图标、名称、数值和标签四个字段。标签字段(tag)用于显示附加描述信息,如"Mesh 组网 · 在线 22 台""本周触发 86 次"等,为数值提供上下文解释。

7.2 Mock 数据初始化

const TASK_LIST: Array<TaskItem> = [
  new TaskItem('06:30', '空调定时开启', '主卧 · 26℃ 节能风 · 峰前预冷', '已完成'),
  new TaskItem('07:00', '热水器加热', '65℃ 峰前预热 · 谷电完成', '已完成'),
  new TaskItem('09:00', '扫地机清扫', '客厅+餐厅全屋巡航 · 避开日照', '已完成'),
  new TaskItem('11:30', '电饭煲预约煮饭', '晚餐杂粮饭 · 功率 900W', '待执行'),
  new TaskItem('13:00', '充电桩充电', '电动汽车谷电补能 · 7kW 慢充', '执行中'),
  new TaskItem('15:00', '净水器滤芯更换', '复合滤芯寿命剩余 8% · 提醒更换', '待执行'),
  new TaskItem('17:30', '窗帘自动关闭', '西晒遮阳 · 降低空调负荷', '待执行'),
  new TaskItem('22:00', '全屋待机断电', '关闭待机电器 · 预计省 0.3 度', '待执行')
];

定时任务 Mock 数据包含 8 条任务,覆盖了家庭一天从早到晚的典型能源管理场景。前三条状态为"已完成"(早晨峰前预冷、预热、清扫已执行完毕),一条"执行中"(午间充电桩补能),四条"待执行"(下午至夜间的任务)。每条任务的备注文案都融入了能源管理语义——"峰前预冷"意味着在电价高峰到来前提前制冷以降低峰段用电、"谷电补能"意味着利用谷段低电价充电、"西晒遮阳"意味着通过物理手段降低空调负荷。这些文案让 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)
];

铃声库 Mock 数据包含 5 条铃声,名称均与能源管理语义关联(光伏上铃、谷电开充、清泉叮咚、满格绿电、风铃晨光),频率从 440 Hz 到 1760 Hz 跨越两个八度。初始状态下所有铃声的 size 均为"—"且 inSandbox 为 false,用户需要手动点击"导入沙箱"将音频写入 EL1 区域后才能设为通知铃声。

const STAT_LIST: Array<StatItem> = [
  new StatItem('🔌', '接入设备', '23 台', 'Mesh 组网 · 在线 22 台'),
  new StatItem('🤖', '自动化场景', '12 个', '本周触发 86 次'),
  new StatItem('💡', '本月省电', '42.6 kWh', '约合电费 ¥28.7'),
  new StatItem('🌱', '碳减排', '18.4 kg', '等效种树 1.0 棵'),
  new StatItem('⭐', '绿电积分', '320 分', '可兑 ¥10 电费券')
];

家庭统计 Mock 数据包含 5 条条目,从接入设备数到自动化场景数,从省电量到碳减排再到绿电积分,构建了一个完整的家庭能源效率画像。每条都配有标签说明(如"约合电费 ¥28.7"将省电量转化为用户关心的费用维度),"等效种树 1.0 棵"则将碳减排量转化为直观的环保隐喻。

八、组件主体:状态声明与生命周期

8.1 组件入口与状态变量声明

@Entry
@Component
struct Page1107 {
  @State currentTab: number = 0;
  @State breath: boolean = false;
  @State addModal: boolean = false;
  @State editModal: boolean = false;
  @State delModal: boolean = false;
  @State editIdx: number = 0;
  @State delIdx: number = 0;
  @State granted: boolean = false;
  @State notifyId: number = 100;
  @State currentRingIdx: number = 0;
  @State sandboxCount: number = 0;
  @State sentCount: number = 0;
  @State genFreq: number = 880;
  @State genDuration: number = 1200;
  @State canvasReady: boolean = false;
  @State barReady: boolean = false;
  @State taskList: Array<TaskItem> = TASK_LIST;
  @State ringList: Array<RingItem> = RING_LIST;
  @State noticeLogs: Array<NoticeLog> = [];
  @State statList: Array<StatItem> = STAT_LIST;
  @State formTime: string = '';
  @State formTitle: string = '';
  @State formNote: string = '';
  @State editTime: string = '';
  @State editTitle: string = '';
  @State editNote: string = '';
  private timer: number = -1;
  private ringCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
  private barCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));

@Entry 装饰器声明此组件为页面入口组件,@Component 标记其为 ArkUI 组件。组件名为 Page1107

状态变量可以按功能分为六组。第一组是 Tab 与动画:currentTab(当前选中 Tab 索引)和 breath(呼吸动画开关,每秒翻转)。第二组是弹窗三态:addModal/editModal/delModal(新增/编辑/删除三个弹窗的开关)以及 editIdx/delIdx(当前编辑任务或删除铃声的索引)。第三组是通知状态:granted(通知授权布尔)、notifyId(通知 ID 自增计数器,初始 100)、currentRingIdx(当前默认铃声索引)、sandboxCount(沙箱铃声文件数)、sentCount(已发通知数)。第四组是铃声生成器参数:genFreq(频率 Hz,默认 880)和 genDuration(时长 ms,默认 1200)。第五组是双 Canvas 就绪标志:canvasReady(进度环 Canvas)和 barReady(柱状图 Canvas)。第六组是四大数据列表和表单字段。

private 成员变量不使用 @State,因为它们不需要触发 UI 重渲染。timersetInterval 返回的定时器句柄,ringCtxbarCtx 是两个 Canvas 绘图上下文,通过 new CanvasRenderingContext2D(new RenderingContextSettings(true)) 创建,参数 true 表示启用抗锯齿。

8.2 通知授权请求

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() 获取宿主上下文并转型为 UIAbilityContext,这是调用通知 API 的必要参数。如果上下文为空则直接返回(防御性编程)。

核心逻辑在 requestEnableNotification 的 Promise 链中:成功时设置 granted = true,UI 上的授权胶囊变为绿色"通知已开启"状态。失败时(常见错误码 1600004,表示用户曾经拒绝过授权),不会简单放弃,而是调用 openNotificationSettings 拉起系统通知设置页面,引导用户手动开启通知权限——这是一种"二次授权"策略,因为首次被拒绝后系统不再弹出授权框,只能引导用户到设置页手动操作。如果设置页拉起也失败,则设置 granted = false

8.3 沙箱音频文件写入

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);
    return path;
  } catch (e) {
    return '';
  }
}

saveRingToSandbox 方法是通知沙箱铃声特性的核心基础设施。它将生成的 WAV 音频字节写入应用沙箱的 EL1 级别 files 目录。

方法首先获取宿主上下文和 ApplicationContext,然后通过 appCtx.area = contextConstant.AreaMode.EL1 显式设置沙箱区域为 EL1。这一步至关重要——HarmonyOS 的通知服务要求自定义铃声音频必须位于 EL1(设备级加密区域)的 files 目录中,位于其他级别(如 EL2/EL3)的音频文件不会被通知服务识别。

获取 filesDir 后拼接完整路径,调用 buildWavBytes 生成音频数据,使用 fs.openSync 以"创建+只写+截断"模式打开文件,fs.writeSync 写入数据,fs.closeSync 关闭文件句柄。成功返回完整路径字符串,异常时返回空字符串(调用方据此判断是否降级为系统铃声)。

8.4 铃声导入与生成

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

importRingToSandbox 方法将铃声库中指定索引的铃声导入沙箱。首先做越界检查,然后调用 saveRingToSandbox 写入音频文件。成功后计算文件大小(44 字节头 + 采样数 * 2 字节数据,转 KB),更新 RingIteminSandbox 为 true 和 size 字段,并递增 sandboxCount 计数器。由于 RingItem@Observed 类,属性变更自动触发铃声库列表 UI 刷新——“未导入"标签变为"沙箱中”,"导入沙箱"按钮消失。

genRing() {
  const seq = this.ringList.length + 1;
  const file = 'ring_gen_' + seq.toString() + '.wav';
  const path = this.saveRingToSandbox(file, this.genFreq, this.genDuration);
  const ok = path !== '';
  const kb = Math.round((44 + Math.floor(44100 * this.genDuration / 1000) * 2) / 1024);
  this.ringList.push(new RingItem('绿电铃音' + seq.toString(), file, this.genFreq, this.genDuration,
    ok ? kb.toString() + ' KB' : '—', ok));
  if (ok) {
    this.sandboxCount++;
  }
}

genRing 方法使用铃声生成器的当前参数(genFreqgenDuration)新建一条铃声。文件名按序列号自动编号(如 ring_gen_6.wav),铃声名称为"绿电铃音6"。调用 saveRingToSandbox 写入沙箱后,根据成功与否设置文件大小和沙箱状态,然后将新的 RingItem 推入 ringList。由于 ringList@State 数组,push 操作触发铃声库列表自动追加新条目。

8.5 默认铃声管理

setCurrentRing(idx: number) {
  if (idx < 0 || idx >= this.ringList.length) {
    return;
  }
  if (!this.ringList[idx].inSandbox) {
    this.importRingToSandbox(idx);
  }
  this.currentRingIdx = idx;
}

setCurrentRing 方法将指定铃声设为默认通知铃声。关键设计是:如果该铃声尚未导入沙箱,会先自动调用 importRingToSandbox 导入,再设置 currentRingIdx。这保证了默认铃声始终有对应的沙箱音频文件,后续发布通知时不会因缺少文件而降级。

后续的 currentRingName()currentRingDesc()currentRingInSandbox()getSandboxPath()getSoundValue() 五个方法都是基于 currentRingIdx 的只读访问器,分别返回当前默认铃声的名称、参数描述(频率/时长/大小)、沙箱状态、沙箱完整路径和 sound 字段完整值。其中 getSoundValue 是最关键的——它返回 'uri::' + fileUri.getUriFromPath(sandboxPath),这正是填入 NotificationRequest.sound 字段的格式。

8.6 发布携带自定义铃声的通知

publishNotice(title: string, text: string) {
  const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
  if (!hostCtx || this.currentRingIdx < 0 || this.currentRingIdx >= this.ringList.length) {
    return;
  }
  const ring = this.ringList[this.currentRingIdx];
  if (!ring.inSandbox) {
    this.importRingToSandbox(this.currentRingIdx);
  }
  const appCtx = hostCtx.getApplicationContext();
  appCtx.area = contextConstant.AreaMode.EL1;
  const sandboxPath = appCtx.filesDir + '/' + ring.file;
  const uri = fileUri.getUriFromPath(sandboxPath);
  const soundVal = 'uri::' + uri;
  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: title,
        text: text,
        additionalText: '铃声:' + ringName
      }
    },
    sound: soundVal
  };
  notificationManager.publish(request).then(() => {
    this.sentCount++;
    this.noticeLogs.unshift(new NoticeLog(nowTime(), title, text, true));
    if (this.noticeLogs.length > 6) {
      this.noticeLogs.pop();
    }
  }).catch((err: BusinessError) => {
    this.noticeLogs.unshift(new NoticeLog(nowTime(), title, text, false));
    if (this.noticeLogs.length > 6) {
      this.noticeLogs.pop();
    }
  });
}

publishNotice 方法是整个应用的技术核心——它实现了 HarmonyOS 6.1.1 的沙箱自定义铃声通知发布特性。

方法首先进行上下文和索引的防御性检查,然后确保当前铃声已导入沙箱。接着获取 ApplicationContext 并设置 EL1 区域,拼接沙箱路径。关键的三行代码是:

const uri = fileUri.getUriFromPath(sandboxPath);
const soundVal = 'uri::' + uri;

这三行完成了从沙箱文件路径到通知 sound 字段值的完整转换。fileUri.getUriFromPath 将沙箱路径转换为系统可识别的 URI 格式(类似 file:///data/.../ring_880.wav),然后加上 'uri::' 前缀形成最终的 sound 值。这个前缀是通知服务识别"这是一个 URI 而非 rawfile 文件名"的标记。

NotificationRequest 对象的构建也值得分析。id 使用自增计数器 notifyId++,保证每条通知有唯一 ID。notificationSlotType 设为 SOCIAL_COMMUNICATION(社交通信类型,优先级较高)。内容类型为 NOTIFICATION_CONTENT_BASIC_TEXT(基础文本通知),normal 对象包含 title(标题)、text(正文)和 additionalText(附加文本,显示为"铃声:光伏上铃")。sound 字段填入 soundVal——这是 HarmonyOS 6.1.1 新特性所在。

notificationManager.publish(request) 返回 Promise。成功时递增 sentCount、向通知历史头部插入成功记录、超出 6 条时移除最旧记录。失败时同样记录但 ok 为 false。无论成功失败,sentCount 只在成功时递增,但通知历史都保留——用户可以看到失败记录(如未授权时),了解通知推送的状态。

8.7 任务通知触发

sendTaskNotice(item: TaskItem) {
  if (!this.granted) {
    this.requestAuth();
    return;
  }
  this.publishNotice('家电任务', item.title);
}

sendTaskNotice 方法是日程页面"执行通知"按钮的回调。它实现了"先授权后发布"的策略:检查 granted 状态,未授权时调用 requestAuth 触发授权流程(本次不发布通知,等用户授权后再次点击),已授权时直接调用 publishNotice 以"家电任务"为标题、任务标题为正文发布通知。这种设计避免了在未授权状态下调用 publish 导致的静默失败。

8.8 铃声删除

deleteRing(idx: number) {
  if (idx >= 0 && idx < this.ringList.length) {
    const r = this.ringList[idx];
    if (r.inSandbox) {
      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.sandboxCount--;
    }
    this.ringList.splice(idx, 1);
    if (this.currentRingIdx >= this.ringList.length) {
      this.currentRingIdx = this.ringList.length - 1;
    }
  }
  this.delModal = false;
}

deleteRing 方法处理铃声删除的完整流程。如果铃声已导入沙箱,先通过 fs.unlinkSync 删除沙箱中的音频文件(同样需要设置 EL1 区域),异常时忽略(文件可能已被清理)。然后递减 sandboxCount,通过 splice 从数组移除该条目。删除后还需要修正 currentRingIdx——如果删除的恰好是最后一个或之后的铃声,索引回退到新的末尾位置。最后关闭删除确认弹窗(delModal = false)。

8.9 任务新增与编辑

saveTask() {
  if (this.formTime === '' || this.formTitle === '' || this.formNote === '') {
    return;
  }
  this.taskList.push(new TaskItem(this.formTime, this.formTitle, this.formNote, '待执行'));
  this.formTime = '';
  this.formTitle = '';
  this.formNote = '';
  this.addModal = false;
}

saveTask 方法处理新增定时任务。首先做非空校验(三个字段都必须填写),然后创建 TaskItem(状态固定为"待执行")推入 taskList。保存后清空表单字段并关闭弹窗。由于 taskList@State 数组且 TaskItem@Observed 类,新增任务后时间轴列表自动追加新行。

updateTask() {
  if (this.editIdx >= 0 && this.editIdx < this.taskList.length) {
    this.taskList[this.editIdx].time = this.editTime;
    this.taskList[this.editIdx].title = this.editTitle;
    this.taskList[this.editIdx].note = this.editNote;
  }
  this.editModal = false;
}

updateTask 方法处理编辑任务保存。通过 editIdx 定位到目标 TaskItem,直接修改其三个属性。由于 TaskItem@Observed 类,属性变更触发时间轴对应行自动刷新——时间、标题、备注都会实时更新。

8.10 生命周期与呼吸动画

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

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

aboutToAppear 是组件生命周期钩子,在组件出现前调用。它完成两件事:第一,调用 notificationManager.isNotificationEnabled() 异步查询通知授权状态并更新 granted,这样应用启动时就能显示正确的授权状态(而非默认 false)。第二,启动一个每秒执行一次的 setInterval 定时器。

定时器内部做三件事:翻转 breath 布尔值(true/false 交替),在 Canvas 就绪时重绘进度环,在柱状图 Canvas 就绪时重绘柱状图。这个"呼吸动画定时器"是整个应用动态效果的核心驱动——每秒一次的 breath 翻转触发头部授权胶囊的透明度脉动,同时通过 Canvas 重绘让进度环的 globalAlpha 和柱状图末柱高度产生微变化。canvasReadybarReady 标志确保在 Canvas 组件 onReady 回调前不会调用绘图方法(此时 Canvas 上下文可能尚未就绪)。

aboutToDisappear 在组件销毁时清理定时器,防止内存泄漏。这是一个必须遵守的 ArkUI 生命周期最佳实践——所有 setInterval 必须有对应的 clearInterval

九、Canvas 图表绘制

9.1 峰平谷三段色进度环

drawRingChart() {
  const ctx = this.ringCtx;
  const cx = 90;
  const cy = 90;
  const r = 78;
  ctx.clearRect(0, 0, 180, 180);
  // 底环(灰色目标环)
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.strokeStyle = COLORS.chip;
  ctx.lineWidth = 14;
  ctx.lineCap = 'round';
  ctx.stroke();
  // 三段进度弧(角度按各段电量占目标比例,透明度随呼吸微变)
  let start = -Math.PI / 2;
  ctx.globalAlpha = this.breath ? 1 : 0.82;
  for (let i = 0; i < SEG_LIST.length; i++) {
    const angle = (SEG_LIST[i].val / TARGET_KWH) * Math.PI * 2;
    ctx.beginPath();
    ctx.arc(cx, cy, r, start, start + angle);
    ctx.strokeStyle = segColor(SEG_LIST[i].label);
    ctx.lineWidth = 14;
    ctx.lineCap = 'round';
    ctx.stroke();
    start += angle;
  }
  ctx.globalAlpha = 1;
  // 中心文字:当前电量 / 目标
  ctx.textAlign = 'center';
  ctx.font = '10px sans-serif';
  ctx.fillStyle = COLORS.text3;
  ctx.fillText('今日用电目标', cx, cy - 32);
  ctx.font = 'bold 30px sans-serif';
  ctx.fillStyle = COLORS.title;
  ctx.fillText(TODAY_KWH.toString(), cx, cy + 4);
  ctx.font = '10px sans-serif';
  ctx.fillStyle = COLORS.sub;
  ctx.fillText('kWh / 目标 ' + TARGET_KWH.toString() + ' kWh', cx, cy + 24);
}

drawRingChart 方法绘制能源页面的今日用电目标进度环。这是一个 180x180 像素的 Canvas 画布,以 (90, 90) 为圆心、78 为半径绘制环形进度。

绘制分三步。第一步绘制底环——一个完整的灰色圆环(COLORS.chip),线宽 14 像素,线帽圆角,作为进度的背景轨道。第二步绘制三段进度弧——从 -π/2(正上方)开始,按峰、平、谷三段各自的电量占目标的比例计算弧度角度,依次绘制三段彩色弧线。每段弧的颜色通过 segColor 函数获取(峰橙、平青、谷绿),弧的起止角度通过累加 start 变量实现无缝衔接。关键细节是 ctx.globalAlpha = this.breath ? 1 : 0.82——三段弧的透明度随呼吸状态在 1.0 和 0.82 之间交替变化,产生"呼吸闪烁"的动态效果。绘制完成后恢复 globalAlpha = 1,避免影响后续文字绘制。

第三步绘制中心文字——三行文字垂直排列:顶部小字"今日用电目标"(灰色)、中间大字"8.6"(30px 粗体深色)、底部小字"kWh / 目标 12 kWh"(副灰色)。textAlign = 'center' 确保文字水平居中于圆心。

9.2 月度电费圆角柱状图

drawBarChart() {
  const ctx = this.barCtx;
  const w = 320;
  const h = 180;
  const pad = 24;
  const top = 26;
  const bottom = 20;
  const chartH = h - top - bottom;
  const slot = (w - pad * 2) / MONTH_VAL.length;
  const barW = 22;
  ctx.clearRect(0, 0, w, h);
  // 横向网格线
  ctx.strokeStyle = COLORS.line;
  ctx.lineWidth = 1;
  for (let i = 0; i <= 2; i++) {
    const gy = top + chartH * i / 2;
    ctx.beginPath();
    ctx.moveTo(pad, gy);
    ctx.lineTo(w - pad, gy);
    ctx.stroke();
  }
  // 六根圆角柱
  for (let i = 0; i < MONTH_VAL.length; i++) {
    const cx = pad + slot * i + slot / 2;
    let bh = MONTH_VAL[i] / MONTH_MAX * chartH;
    if (i === MONTH_VAL.length - 1 && this.breath) {
      bh = Math.min(chartH, bh + 6);
    }
    const x = cx - barW / 2;
    const y = top + chartH - bh;
    const rr = Math.min(6, barW / 2);
    const grad = ctx.createLinearGradient(0, y, 0, y + bh);
    grad.addColorStop(0, COLORS.green);
    grad.addColorStop(1, COLORS.greenD);
    ctx.beginPath();
    ctx.moveTo(x, y + bh);
    ctx.lineTo(x, y + rr);
    ctx.quadraticCurveTo(x, y, x + rr, y);
    ctx.lineTo(x + barW - rr, y);
    ctx.quadraticCurveTo(x + barW, y, x + barW, y + rr);
    ctx.lineTo(x + barW, y + bh);
    ctx.closePath();
    ctx.fillStyle = grad;
    ctx.fill();
    // 顶部数值
    ctx.font = '9px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillStyle = i === MONTH_VAL.length - 1 ? COLORS.greenD : COLORS.sub;
    ctx.fillText(MONTH_VAL[i].toString(), cx, y - 6);
    // 底部月份标签
    ctx.font = '9px sans-serif';
    ctx.fillStyle = COLORS.text3;
    ctx.fillText(MONTH_NAME[i], cx, h - 6);
  }
}

drawBarChart 方法绘制"我的"页面的近六个月电费柱状图。画布尺寸 320x180 像素,预留左右内边距 24、顶部 26(给数值标注留空间)、底部 20(给月份标签留空间),有效绘图高度 chartH = 180 - 26 - 20 = 134。每根柱子的水平槽位宽度 slot = (320 - 48) / 6 = 45.33,柱宽 22 像素,居中于槽位。

绘制分三层。第一层是三条横向网格线(顶/中/底),使用浅色线条为柱状图提供高度参考刻度。第二层是六根圆角柱——每根柱子通过 moveTo/lineTo/quadraticCurveTo 路径绘制出顶部圆角矩形形状,填充使用线性渐变(从顶部 green 到底部 greenD,形成深浅过渡的立体感)。关键动画细节是:末柱(当前月,8月)在 breath 为 true 时高度额外加 6 像素(Math.min(chartH, bh + 6) 防止溢出),产生"当前月柱呼吸放大"的动效。第三层是文字标注——每根柱子顶部标注电费数值(末柱用深绿色突出,其余用副灰色),底部标注月份名称。

十、页面构建与布局

10.1 主构建方法

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

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

build 方法是组件的渲染入口。整体采用 Stack 作为最外层容器,居中对齐——这允许弹窗面板叠加在主内容之上。

Stack 内部第一层是一个 Column,从上到下依次排列:头部栏(headerBar)、分割线(Divider)、可滚动内容区(Scroll)、底部导航栏(tabBar)。Scroll 使用 layoutWeight(1) 占据中间全部剩余空间,scrollBar(BarState.Off) 隐藏滚动条。内容区内部是一个 Column,通过 if/else if/else 条件分支根据 currentTab 渲染对应的 Tab 页面 Builder 函数。

Stack 内部第二层及以后是弹窗系统。当任意弹窗开关为 true 时,先渲染全屏遮罩(modalOverlay),再渲染对应的弹窗面板(panelAdd/panelEdit/panelDel)。遮罩的 onClick 回调会关闭所有弹窗——点击遮罩区域即可关闭弹窗,符合移动端交互习惯。弹窗面板渲染在遮罩之上(因为后渲染),通过 Stack 的居中对齐自动居中显示。

10.2 头部栏 Builder

@Builder
headerBar() {
  Column({ space: 10 }) {
    Row() {
      Column({ space: 3 }) {
        Text('绿电管家').fontSize(20).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Text('智能家居 · 家庭能源管理').fontSize(12).fontColor(COLORS.sub)
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      Row({ space: 5 }) {
        Circle().width(6).height(6).fill(this.granted ? COLORS.green : COLORS.orange)
          .opacity(this.breath ? 1 : 0.4)
        Text(this.granted ? '通知已开启' : '去开启').fontSize(10)
          .fontColor(this.granted ? COLORS.green : COLORS.orange)
      }
      .padding({ left: 9, right: 9, top: 5, bottom: 5 })
      .backgroundColor(this.granted ? COLORS.greenL : COLORS.orangeL)
      .borderRadius(11)
      .opacity(this.breath ? 1 : 0.85)
      .onClick(() => {
        if (!this.granted) {
          this.requestAuth();
        }
      })
    }
    .width('100%')

    Row({ space: 10 }) {
      Column({ space: 3 }) {
        Text('实时功率').fontSize(9).fontColor(COLORS.text3)
        Text('3.86 kW').fontSize(11).fontColor(COLORS.greenD).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('5.2 kWh').fontSize(11).fontColor(COLORS.blue).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.purple).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 })
}

headerBar 是一个 @Builder 函数,构建头部区域。整体是一个 Column,上下两行。

第一行是应用标识行:左侧是应用名"绿电管家"(20px 粗体)和副标题"智能家居 · 家庭能源管理"(12px 副灰色),使用 layoutWeight(1) 占据剩余宽度。右侧是通知授权呼吸胶囊——一个 Row 内含一个 6px 圆点和文字。圆点颜色随 granted 状态变化(绿色已授权/橙色未授权),透明度随 breath 在 1 和 0.4 之间脉动(呼吸效果)。整个胶囊的背景色也随状态变化(绿底/橙底),透明度也随呼吸在 1 和 0.85 之间微变。点击胶囊时,如果未授权则触发 requestAuth

第二行是三个等宽数据小卡:实时功率(3.86 kW,绿色)、今日光伏(5.2 kWh,蓝色)、已发通知(动态计数,紫色)。三张卡使用 layoutWeight(1) 等宽排列,白色卡片背景配圆角,数据值用粗体强调。已发通知的计数绑定 this.sentCount,每次发布通知后自动更新。

十一、能源 Tab 页面

11.1 用电目标进度环卡

@Builder
tabEnergy() {
  Column({ space: 12 }) {
    Column({ space: 10 }) {
      Row() {
        Text('🎯 今日用电目标').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('单位:kWh').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      Row({ space: 12 }) {
        Canvas(this.ringCtx).width(180).height(180)
          .onReady(() => {
            this.canvasReady = true;
            this.drawRingChart();
          })

        Column({ space: 8 }) {
          ForEach(SEG_LIST, (s: SegData) => {
            Row({ space: 6 }) {
              Circle().width(8).height(8).fill(segColor(s.label))
              Text(s.label).fontSize(10).fontColor(COLORS.sub)
              Column().layoutWeight(1)
              Text(s.val.toString() + ' kWh').fontSize(10).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
            }
            .width('100%')
          }, (s: SegData) => s.label)

          Text('目标完成 ' + Math.round(TODAY_KWH / TARGET_KWH * 100).toString() + '%')
            .fontSize(10).fontColor(COLORS.greenD).fontWeight(FontWeight.Bold)
            .padding({ left: 8, right: 8, top: 3, bottom: 3 })
            .backgroundColor(COLORS.greenL).borderRadius(8)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Start)
      }
      .width('100%')
      .alignItems(VerticalAlign.Center)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

能源 Tab 的第一个卡片是今日用电目标进度环卡。标题行左侧是"🎯 今日用电目标",右侧标注"单位:kWh"。主体是一个 Row,左侧是 180x180 的 Canvas 组件(绑定 ringCtx 上下文),右侧是图例列。

CanvasonReady 回调在组件就绪时设置 canvasReady = true 并立即调用 drawRingChart() 绘制初始画面。此后每秒的呼吸定时器会重复调用 drawRingChart() 实现动态重绘。

右侧图例使用 ForEach 遍历 SEG_LIST,为每个时段生成一行:色点(Circle + segColor)、时段名(如"峰段")、弹性占位、用电量数值。图例下方还有一个绿色胶囊标签显示目标完成度百分比(71.7% 取整为 72%)。

11.2 实时功率大数字卡

    Column({ space: 6 }) {
      Text('⚡ 实时总功率').fontSize(10).fontColor(COLORS.sub)
      Row({ space: 4 }) {
        Text('3.86').fontSize(34).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
        Text('kW').fontSize(12).fontColor(COLORS.sub)
      }
      .alignItems(VerticalAlign.Bottom)
      Row({ space: 10 }) {
        Text('较昨日 +0.42 kW').fontSize(9).fontColor(COLORS.orange)
        Text('光伏输入 2.10 kW').fontSize(9).fontColor(COLORS.sub)
        Text('负载 1.76 kW').fontSize(9).fontColor(COLORS.sub)
      }
    }
    .width('100%').padding({ top: 16, bottom: 16 })
    .backgroundColor(COLORS.card).borderRadius(12).alignItems(HorizontalAlign.Center)

第二张卡是实时总功率大数字展示。中心区域是"3.86"(34px 超大字号绿色粗体)加"kW"(12px 单位),垂直底对齐使单位紧贴数字底部。下方一行三个对比信息:较昨日增幅(橙色警示)、光伏输入功率、负载功率。整张卡居中对齐,上下内边距 16 提供呼吸空间。

11.3 能源数据小卡与电器排行

    Row({ space: 10 }) {
      ForEach(ENERGY_MINI, (m: MiniStat) => {
        Column({ space: 4 }) {
          Text(m.icon).fontSize(16)
          Text(m.val).fontSize(13).fontColor(COLORS.greenD).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%')

三个能源数据小卡使用 ForEach 遍历 ENERGY_MINI 常量数组,每张卡内含图标(16px emoji)、数值(13px 绿色粗体)、名称(8px 浅灰),等宽排列于白色圆角卡片中。

    Column({ space: 8 }) {
      Row() {
        Text('🔌 电器耗电排行').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('今日占比').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

      ForEach(APPLIANCE_LIST, (item: ApplianceRank, idx: number) => {
        Row({ space: 10 }) {
          Text((idx + 1).toString()).fontSize(11).fontWeight(FontWeight.Bold)
            .fontColor(idx === 0 ? COLORS.orange : COLORS.text3).width(18).textAlign(TextAlign.Center)

          Column({ space: 5 }) {
            Row() {
              Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
              Column().layoutWeight(1)
              Text(item.power).fontSize(10).fontColor(COLORS.sub)
            }
            .width('100%')

            Row({ space: 8 }) {
              Column() {
                Column().width(pctToBar(item.pct).toString() + '%')
                  .height(5).borderRadius(3)
                  .backgroundColor(idx === 0 ? COLORS.orange : COLORS.green)
              }
              .layoutWeight(1).height(5).borderRadius(3).backgroundColor(COLORS.chip)
              Text(item.pct.toString() + '%').fontSize(9).fontColor(COLORS.text3)
            }
            .width('100%')
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)
        }
        .width('100%').padding({ top: 6, bottom: 6 }).backgroundColor(COLORS.card).borderRadius(10)
      }, (item: ApplianceRank) => item.name)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }
  .width('100%')
}

电器耗电排行是能源 Tab 的最后一个卡片。标题行后使用 ForEach 遍历 APPLIANCE_LIST,每个电器生成一行:左侧排名序号(第一名橙色突出,其余灰色)、电器名称、实时功率、进度条(外层灰色背景 + 内层彩色填充,宽度通过 pctToBar 计算,第一名橙色其余绿色)、占比百分比文字。进度条使用嵌套 Column 实现——外层 layoutWeight(1) 撑满宽度且背景为 chip 色,内层 Column 宽度为百分比字符串、背景为语义色,形成"轨道+填充"的进度条效果。

十二、日程 Tab 页面

12.1 竖向时间轴布局

@Builder
tabSchedule() {
  Column({ space: 10 }) {
    Row() {
      Text('⏰ 今日家电定时').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('共 ' + this.taskList.length.toString() + ' 项').fontSize(9).fontColor(COLORS.text3)
      Text('+ 新增任务').fontSize(9).fontColor(COLORS.green)
        .padding({ left: 8, right: 8, top: 4, bottom: 4 }).backgroundColor(COLORS.greenL).borderRadius(8)
        .onClick(() => { this.addModal = true; })
    }
    .width('100%')

    Text('点击"执行通知"推送携带沙箱铃声的家电任务提醒')
      .fontSize(8).fontColor(COLORS.text3)

日程 Tab 的标题行包含标题文字、任务总数(动态绑定 taskList.length)和"新增任务"按钮(点击打开 addModal 弹窗)。下方一行提示文字引导用户使用"执行通知"功能。

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

        Column() {
          Circle().width(8).height(8).fill(taskColor(item.status))
          if (idx < this.taskList.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.note).fontSize(9).fontColor(COLORS.sub)
              .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
          }
          .alignItems(HorizontalAlign.Start).layoutWeight(1)

          Column({ space: 4 }) {
            Text('执行通知').fontSize(9).fontColor(COLORS.green)
              .padding({ left: 8, right: 8, top: 3, bottom: 3 }).backgroundColor(COLORS.greenL).borderRadius(8)
              .onClick(() => { this.sendTaskNotice(item); })
            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.editNote = item.note;
                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: TaskItem) => item.time + item.title)
  }
  .width('100%')
}

竖向时间轴是日程 Tab 的核心布局。每行固定高度 72 像素,从左到右三列:时间列(44px 宽,显示时间和状态)、时间轴线列(10px 宽,顶部圆点 + 竖线)、任务卡列(弹性占满,白色圆角卡片内含标题/备注 + 执行通知/编辑按钮)。

时间轴线的设计精巧:顶部是 8px 的圆点(颜色通过 taskColor 映射状态),下方是一条 2px 宽的竖线(layoutWeight(1) 撑满行高,颜色为浅灰 line)。最后一条任务通过 if (idx < this.taskList.length - 1) 条件不渲染竖线,避免时间轴尾部多余线条。

任务卡内右侧两个按钮:绿色"执行通知"调用 sendTaskNotice,灰色"编辑"打开编辑弹窗(先将当前任务数据填入编辑表单字段,再设置 editModal = true)。标题和备注都设置了 maxLines(1)textOverflow(Ellipsis),超长文字自动截断省略,保持行高一致。

十三、铃音 Tab 页面

13.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.greenD).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('440 ~ 1760 Hz').fontSize(8).fontColor(COLORS.text3)
      }
      .width('100%')

      Slider({ value: this.genFreq, min: 440, max: 1760, step: 20, style: SliderStyle.OutSet })
        .selectedColor(COLORS.green).trackColor(COLORS.chip).blockColor(COLORS.greenD)
        .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.blue).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.blue).trackColor(COLORS.chip).blockColor(COLORS.blue)
        .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.green).borderRadius(10)
        .onClick(() => { this.genRing(); })
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

铃音 Tab 的第一个卡片是沙箱铃声生成器。它提供两个 Slider 滑块:频率滑块(440-1760 Hz,步进 20,绿色主题)和时长滑块(600-2400 ms,步进 100,蓝色主题)。每个滑块上方有标签行显示当前值和取值范围。滑块的 onChange 回调将值同步到 genFreqgenDuration 状态变量。底部绿色按钮"生成到沙箱"调用 genRing() 方法,使用当前参数生成 WAV 音频并写入沙箱。

13.2 当前默认铃声状态卡

    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%')

      Row({ space: 8 }) {
        Text('🔔').fontSize(16)
        Column({ space: 3 }) {
          Text(this.currentRingName()).fontSize(14).fontColor(COLORS.greenD).fontWeight(FontWeight.Bold)
          Text(this.currentRingDesc()).fontSize(9).fontColor(COLORS.text3)
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)

        Text(this.currentRingInSandbox() ? '沙箱中' : '未导入').fontSize(8)
          .fontColor(this.currentRingInSandbox() ? COLORS.green : COLORS.orange)
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor(this.currentRingInSandbox() ? COLORS.greenL : COLORS.orangeL).borderRadius(6)
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

第二个卡片展示当前默认铃声的状态。标题行右侧显示沙箱文件总数(绑定 sandboxCount)。主体行显示铃铛图标、铃声名称(调用 currentRingName())、参数描述(调用 currentRingDesc(),格式为"880 Hz · 1200 ms · 51 KB")和沙箱状态标签(调用 currentRingInSandbox(),"沙箱中"绿色或"未导入"橙色)。

13.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.green).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.orangeL).borderRadius(6)
          }
          .width('100%')

          Row({ space: 8 }) {
            if (!item.inSandbox) {
              Text('导入沙箱').fontSize(9).fontColor(COLORS.orange)
                .layoutWeight(1).textAlign(TextAlign.Center)
                .padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.orangeL).borderRadius(7)
                .onClick(() => { this.importRingToSandbox(idx); })
            }
            Text('设为默认').fontSize(9).fontColor(COLORS.green)
              .layoutWeight(1).textAlign(TextAlign.Center)
              .padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.greenL).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)

铃声库列表使用 ForEach 遍历 ringList。每条铃声显示名称(如果是默认铃声则附加绿色"默认"标签)、参数描述和沙箱状态标签。底部三个操作按钮(条件性渲染):未导入时显示"导入沙箱"(橙色)、始终显示"设为默认"(绿色)和"删除"(红色,点击设置 delIdx 并打开删除确认弹窗)。

13.4 sound 字段代码预览与通知试听

    Column({ space: 8 }) {
      Text('🧾 NotificationRequest.sound 实时预览')
        .fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Column({ space: 6 }) {
        Text("sound: '" + this.getSoundValue() + "'")
          .fontSize(9).fontFamily('monospace').fontColor(COLORS.greenD)
          .maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')
        Text("'uri::' + fileUri.getUriFromPath('" + this.getSandboxPath() + "')")
          .fontSize(8).fontFamily('monospace').fontColor(COLORS.sub)
          .maxLines(3).textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')
      }
      .width('100%').padding(10).backgroundColor(COLORS.chip).borderRadius(8)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

sound 字段预览卡是一个开发者向的功能模块。它以等宽字体(monospace)实时显示当前铃声对应的 NotificationRequest.sound 字段完整值(第一行)和等价的表达式代码(第二行)。这让开发者可以直观看到 'uri::' + fileUri.getUriFromPath('沙箱路径') 的实际拼接结果,理解沙箱铃声特性的技术实现。

    Column({ space: 8 }) {
      Text('📣 通知试听').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Text('🔔 发布一条通知试听').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
        .width('100%').textAlign(TextAlign.Center)
        .padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.green).borderRadius(10)
        .onClick(() => { this.publishNotice('绿电管家', '铃声试听:' + this.currentRingName()); })
      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%')
}

通知试听卡提供绿色按钮"发布一条通知试听",点击调用 publishNotice 发布一条标题为"绿电管家"、正文为"铃声试听:当前铃声名"的通知。底部显示当前通知 ID 计数和已发送条数,让用户了解通知状态。

十四、我的 Tab 页面

14.1 家庭电费渐变大卡

@Builder
tabMine() {
  Column({ space: 12 }) {
    Column({ space: 8 }) {
      Row() {
        Text('🏡 家庭账户 · 本月电费').fontSize(11).fontColor('rgba(255,255,255,0.75)')
        Column().layoutWeight(1)
        Text('阶梯第二档').fontSize(9).fontColor(COLORS.white)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor('rgba(255,255,255,0.25)').borderRadius(8)
      }
      .width('100%')

      Text('¥ 275.90').fontSize(30).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
      Text('阶梯电价:第二档 0.62 元/度 · 距第三档还剩 124 度')
        .fontSize(9).fontColor('rgba(255,255,255,0.75)').maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })

      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text('本月用电').fontSize(8).fontColor('rgba(255,255,255,0.75)')
          Text('445.0 度').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.75)')
          Text('96.3 度').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.75)')
          Text('124 度').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.greenD, 0], [COLORS.cyan, 1]] })

我的 Tab 的第一张卡是家庭电费渐变大卡,使用 linearGradient 设置 135 度对角线性渐变背景(从深绿 greenD 到青色 cyan),所有文字为白色或半透明白色。卡片内容分层:标题行(家庭账户标签 + 阶梯档位标签)、大字电费金额(30px 粗体)、阶梯电价说明、三个等宽数据列(本月用电/光伏抵扣/阶梯余量)。这张卡在浅色主题页面中通过深色渐变背景形成视觉焦点,突出本月电费这一核心数据。

14.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.greenD).fontWeight(FontWeight.Bold)
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
    }, (item: StatItem) => item.name)

    this.chartCard()

统计清单使用 ForEach 遍历 statList,每条生成一行白色圆角卡片:图标、名称+标签(左弹性)、数值(右粗体绿色)。紧接着调用 chartCard() Builder 渲染月度电费柱状图卡片。

14.3 特性说明卡

    Column({ space: 10 }) {
      Text('✨ 特性说明').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)

      Row({ space: 8 }) {
        Text('🔔').fontSize(14)
        Column({ space: 3 }) {
          Text('Notification Kit 沙箱铃声').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text('通知 sound 字段支持应用沙箱 EL1 files 目录的音频,取值须为 uri:: 前缀加 fileUri 转换结果。')
            .fontSize(9).fontColor(COLORS.sub).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%')

      Row({ space: 8 }) {
        Text('📊').fontSize(14)
        Column({ space: 3 }) {
          Text('Canvas 图表').fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text('能源页峰平谷三段色进度环与本页月度电费柱状图均由 CanvasRenderingContext2D 绘制并随呼吸动画重绘。')
            .fontSize(9).fontColor(COLORS.sub).maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
  }
  .width('100%')
}

特性说明卡位于页面底部,以图文并茂的方式总结两大技术特性:Notification Kit 沙箱铃声和 Canvas 图表绘制。每条特性有图标、标题和描述文案,描述文案以 maxLines(2)Ellipsis 控制为最多两行截断。这张卡既是技术文档又是用户引导,帮助用户理解应用的核心能力。

十五、图表卡与底部导航

15.1 月度电费柱状图卡

@Builder
chartCard() {
  Column({ space: 10 }) {
    Row() {
      Text('📊 近 6 个月家庭电费').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('单位:元').fontSize(9).fontColor(COLORS.text3)
    }
    .width('100%')

    Canvas(this.barCtx).width('100%').height(180)
      .onReady(() => {
        this.barReady = true;
        this.drawBarChart();
      })
  }
  .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}

chartCard Builder 封装了月度电费柱状图。标题行后是一个宽度 100%、高度 180 的 Canvas 组件,绑定 barCtx 上下文。onReady 回调设置 barReady = true 并调用 drawBarChart() 初始绘制。此后每秒呼吸定时器重复调用 drawBarChart() 实现动态重绘。

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

底部导航栏使用 ForEach 遍历 TAB_LIST 生成四个等宽 Tab。选中态通过三重视觉差异区分:图标字号更大(20 vs 17)、透明度更高(1 vs 0.65)、标签颜色为主题色(tabOn 绿色 vs text3 浅灰)、字重更粗。每个 Tab 的 onClick 设置 currentTab 为对应索引,触发主内容区的条件分支切换页面。顶部有 1 像素分割线分隔内容区和导航栏。

十六、弹窗系统

16.1 通用遮罩

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

modalOverlay 是一个接受 onClose 回调参数的 Builder 函数。它渲染一个全屏半透明黑色 Column,点击时触发回调关闭所有弹窗。这是弹窗系统的统一遮罩层。

16.2 新增任务弹窗

@Builder
panelAdd(onClose: () => void) {
  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: '如 07:30' })
        .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.formNote, placeholder: '如 峰前预热至 65℃' })
        .fontSize(11).fontColor(COLORS.title).backgroundColor(COLORS.chip).borderRadius(8)
        .onChange((value: string) => { this.formNote = 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.green).borderRadius(9)
        .onClick(() => { this.saveTask(); })
    }
    .width('100%')
  }
  .width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(16)
}

panelAdd 是新增定时任务弹窗面板。宽度 86%(居中显示),内含标题和三个表单字段(使用 FORM_LABELS 常量数组作为标签),每个字段是一个 TextInput 组件,背景为 chip 色、圆角 8。底部两个等宽按钮:“取消”(灰色,调用 onClose 关闭弹窗)和"保存"(绿色,调用 saveTask 保存任务)。

16.3 编辑任务弹窗

@Builder
panelEdit(onClose: () => void) {
  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.editNote, placeholder: '任务备注' })
        .fontSize(11).fontColor(COLORS.title).backgroundColor(COLORS.chip).borderRadius(8)
        .onChange((value: string) => { this.editNote = value; })
    }
    .width('100%').alignItems(HorizontalAlign.Start)

    Row({ space: 10 }) {
      Text('取消').fontSize(12).fontColor(COLORS.sub)
        .layoutWeight(1).textAlign(TextAlign.Center)
        .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
        .onClick(() => onClose())
      Text('保存').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
        .layoutWeight(1).textAlign(TextAlign.Center)
        .padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.green).borderRadius(9)
        .onClick(() => { this.updateTask(); })
    }
    .width('100%')
  }
  .width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(16)
}

panelEdit 的结构与 panelAdd 完全对称,区别在于绑定的是 editTime/editTitle/editNote 状态变量,保存按钮调用 updateTask()。在日程页点击"编辑"按钮时,会先将当前任务的数据填入这三个编辑字段(在 tabSchedule 的编辑按钮 onClick 中完成),然后打开弹窗,用户看到的是预填的任务数据。

16.4 删除铃声确认弹窗

@Builder
panelDel(onClose: () => void) {
  Column({ space: 12 }) {
    Text('删除铃声').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
    Text('确认删除铃声「' + (this.delIdx >= 0 && this.delIdx < this.ringList.length ?
      this.ringList[this.delIdx].name : '') + '」吗?删除后将同步清理沙箱中的音频文件,不可恢复。')
      .fontSize(10).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.deleteRing(this.delIdx); })
    }
    .width('100%')
  }
  .width('86%').padding(16).backgroundColor(COLORS.card).borderRadius(16)
}

panelDel 是删除铃声的确认弹窗。它显示一条带铃声名称的确认文案(“确认删除铃声「光伏上铃」吗?删除后将同步清理沙箱中的音频文件,不可恢复。”),底部"取消"灰色按钮和"确认删除"红色按钮。确认删除调用 deleteRing(this.delIdx),该方法会清理沙箱文件并从数组移除条目。文案中"同步清理沙箱中的音频文件,不可恢复"这一提示告知用户删除操作的副作用,体现了良好的交互设计。

十七、技术特性对比表格

技术维度传统通知方案本应用沙箱铃声方案技术优势说明
通知铃声来源系统默认或 rawfile 资源文件运行时动态生成 WAV 写入沙箱 EL1铃声可在运行时按业务场景动态生成,无需预置资源
sound 字段取值rawfile 文件名‘uri::’ + fileUri.getUriFromPath(沙箱路径)HarmonyOS 6.1.1 新特性,支持沙箱 URI 格式
音频文件区域无区域限制必须位于 EL1 级别 files 目录通知服务要求 EL1 设备级加密区域,保证安全性
音频内容定制固定预置铃声正弦波合成,频率/时长可调不同场景可生成不同音高和时长的铃声
授权流程仅首次弹框首次弹框 + 拒绝后跳转设置页二次授权策略,提高授权成功率
数据可视化系统组件或静态图片Canvas 2D 动态绘制 + 呼吸动画进度环和柱状图可随数据变化实时重绘
进度环图表单色环形进度峰平谷三段色弧线 + 中心多层文字一图展示分时电价用电分布+完成度
柱状图渲染矩形柱圆角柱+线性渐变+末柱呼吸放大视觉精致度高,当前月数据突出展示
动画驱动CSS 动画或属性动画setInterval 每秒翻转 breath 布尔统一驱动头部胶囊+双Canvas重绘
状态管理手动刷新@State + @Observed 自动响应数据变更自动触发UI重渲染
弹窗系统系统弹窗Stack叠加遮罩+自定义面板三态弹窗统一管理,点击遮罩可关闭
任务数据模型普通对象@Observed 装饰器类属性变更自动触发列表刷新

十八、总结

本应用"绿电管家"以 HarmonyOS ArkUI 声明式开发范式为基础,围绕智能家居家庭能源管理这一行业场景,构建了一个集数据可视化、定时任务调度、通知铃声定制于一体的完整应用。通过四个布局完全不同的 Tab 页面——能源数据总览、日程时间轴、铃音生成管理、个人统计中心——覆盖了家庭能源管理从"看数据"到"设任务"再到"收通知"的完整用户旅程。

在通知铃声定制方面,本应用充分展示了 HarmonyOS 6.1.1 的沙箱自定义铃声新特性。通过 buildWavBytes 函数在内存中动态合成标准 WAV 音频字节流,通过 saveRingToSandbox 方法将音频写入 EL1 级别沙箱 files 目录,通过 fileUri.getUriFromPath 将沙箱路径转换为 URI 并加上 'uri::' 前缀填入 NotificationRequest.sound 字段,最终通过 notificationManager.publish 发布携带自定义铃声的通知。这条完整的技术链条打破了传统通知铃声只能使用预置资源的限制,实现了"运行时生成-沙箱存储-URI 转换-通知发布"的全动态铃声定制流程。

在数据可视化方面,两个 Canvas 图表展现了 ArkUI 的 2D 绘图能力。进度环通过三段不同颜色弧线(峰橙平青谷绿)直观展示分时电价下各时段用电占比,中心多层文字提供精确数值;柱状图通过圆角矩形+线性渐变填充+末柱呼吸放大呈现月度电费趋势。两个图表都与每秒一次的呼吸动画定时器联动,通过 globalAlpha 透明度脉动和末柱高度微变实现"活"的动态图表效果,避免了数据可视化的静态冰冷感。

在交互设计方面,弹窗系统采用 Stack 叠加遮罩+自定义面板的模式,统一管理新增任务、编辑任务、删除铃声三个弹窗。日程页面的竖向时间轴通过固定行高 72px、时间轴线圆点+竖线、状态颜色编码等设计,将八条家电定时任务以清晰的时间序列方式呈现。铃声生成器的双滑块设计(频率+时长)让用户可以直观地调节音频参数并实时预览 sound 字段取值。

在状态管理方面,@State 装饰器管理了组件的全部响应式状态——从 Tab 索引到弹窗开关、从通知授权到铃声参数、从数据列表到表单字段,共计二十余个状态变量。@Observed 装饰器使得 TaskItemRingItemNoticeLogStatItem 四个数据模型类的属性变更能够自动触发列表 UI 刷新。aboutToAppearaboutToDisappear 生命周期钩子完成了通知授权查询和定时器启停的资源管理。

在工程架构方面,代码组织遵循了"接口定义-常量声明-辅助函数-数据模型-组件主体"的分层结构。颜色系统通过 ColorPalette 接口集中管理,确保主题一致性;辅助函数群提供了状态映射、格式化、音频合成等工具能力;数据模型类与 Mock 数据分离,便于未来接入真实数据源;组件主体的方法群按功能内聚(授权/沙箱/通知/Canvas/任务 CRUD),@Builder 函数群按 UI 区域划分。这种架构使得代码可读性高、维护成本低、扩展性强。

总而言之,本应用是一个将 HarmonyOS 系统能力(Notification Kit、CoreFileKit、Canvas)与行业业务场景(智能家居能源管理)深度融合的典范之作。它不仅展示了鸿蒙原生应用的技术深度,更通过精心设计的 UI 交互和数据可视化,让"家庭能源效率"这一抽象概念变得可见、可控、可优化。

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

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


一、创建新项目

1.1 进入欢迎界面

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

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

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

在这里插入图片描述

1.2 选择项目模板

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

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

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

在这里插入图片描述

1.3 配置项目信息

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

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

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

在这里插入图片描述

1.4 完成创建

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

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

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

在这里插入图片描述

1.5 项目结构概览

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

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

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

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

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

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

在这里插入图片描述


二、查看 SDK 版本

2.1 查看 HarmonyOS SDK

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

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

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

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

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

在这里插入图片描述

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

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

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

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

版本SDK 版本号阶段状态
API Version 246.1.1.100Release✅ 已安装
API Version 236.1.0.28Beta1未安装
API Version 226.0.2.112Release未安装

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

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

在这里插入图片描述


三、小结

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

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


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

Logo

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

更多推荐