一、技术前言

在这里插入图片描述

HarmonyOS ArkUI 框架是华为面向全场景分布式设备推出的声明式 UI 开发框架,其核心语言 ArkTS 在 TypeScript 基础上扩展了 @Entry@Component@State@Builder@Observed 等装饰器,使开发者能够以接近前端 React 的声明式范式构建高性能原生应用界面。与传统命令式 UI 不同,ArkUI 采用状态驱动的渲染机制——当 @State 修饰的状态变量发生变化时,框架自动触发关联的 UI 组件进行差异比对与精准刷新,无需开发者手动操作 DOM 节点。这种设计在大屏设备与移动设备上均可保持一致的渲染性能表现,尤其适合数据频繁更新的 IoT 监控类应用场景。在 ArkUI 中,Canvas 组件提供了与 Web Canvas 2D API 高度兼容的绘图接口,开发者可以通过 CanvasRenderingContext2D 上下文进行路径绘制、渐变填充、文本渲染等操作,实现折线图、进度环、柱状图等自定义数据可视化效果。

在这里插入图片描述
Notification Kit 是 HarmonyOS 6.1.1 版本引入的重要能力升级,它允许应用发布携带自定义声音的通知。在此之前,HarmonyOS 应用的通知铃声只能使用系统预设音效,开发者无法为不同业务场景(如灌溉告警、采收提醒、设备异常)指定差异化的提示音。6.1.1 新特性突破了这一限制:应用可以将沙箱内 EL1 区域的音频文件(WAV 格式)通过 fileUri.getUriFromPath() 转换为 uri:: 前缀的路径字符串,填入 NotificationRequest.sound 字段,系统在弹出通知时会播放该自定义音频而非默认铃声。这意味着应用可以在运行时动态生成音频字节(如正弦波 PCM 数据),写入应用沙箱 filesDir 目录,再作为通知铃声使用——全程无需预置资源文件,完全由代码逻辑驱动,为 IoT 场景中的"声音语义化"提供了底层能力支撑。

在这里插入图片描述
Canvas 2D 绘图能力在 HarmonyOS ArkUI 中通过 <Canvas> 组件暴露,其底层渲染引擎与 Chromium Skia 保持一致。开发者创建 CanvasRenderingContext2D 实例并绑定到 Canvas 组件后,即可在 onReady 回调中执行绘制命令。本应用中实现了两种典型的 Canvas 图表:其一是产量周折线图,利用 createLinearGradient 创建从绿色到透明的线性渐变填充面积区域,配合 moveTo/lineTo 路径命令绘制主折线,末端数据节点通过呼吸动画半径变化实现动态视觉效果;其二是灌溉进度环,使用 arc() 方法分别绘制底环(全圆灰色)与进度弧(部分圆绿色),通过 lineCap = 'round' 实现圆头端点,中心叠加百分比文本与标题文字。两个 Canvas 各自维护 ready 就绪标志,在每秒触发的呼吸定时器中联动重绘,形成"活"的数据面板。

在这里插入图片描述
数字农业 IoT(Internet of Things)是物联网技术在农业生产领域的垂直落地,其核心是通过部署在大棚、露地、果园中的各类传感器(温湿度传感器、土壤墒情探头、光照计、CO₂ 检测仪、风速计)实时采集环境数据,再通过 LoRa、NB-IoT 或 5G 网络回传至云平台,最终在 SaaS 应用层以可视化仪表盘呈现。智慧农业平台不仅需要展示数据,更要具备"决策触达"能力——当系统判定某块农田需要灌溉时,自动向管护人员的手机推送通知。这个通知如果携带特定语义的铃声(如"灌溉滴"提示音),管护人员无需查看屏幕即可凭声音判断业务类型,这在双手被农活占用的田间场景中至关重要。本应用将"自定义铃声生成器"“沙箱音频文件管理”"通知发布"三个子系统整合在铃音 Tab 中,形成完整的"声音即语义"闭环。

在这里插入图片描述
在布局设计层面,本应用采用底部四 Tab 单排导航架构(农情/灌溉/铃音/我的),每个 Tab 页面的布局结构完全不同——农情页是"大数字双卡 + 四格传感器小卡 + Canvas 折线图"的纵向信息流;灌溉页是"Canvas 进度环 + 农田列表行"的看板列表混合结构;铃音页是"生成器 + 当前铃声 + 铃声库 + 发布入口"的功能堆叠;我的页是"渐变大卡 + 统计清单 + 信息卡"的个人信息聚合。弹窗系统使用 Stack 容器包裹主内容与三层弹窗面板(新增农田/编辑农田/删除铃声),通过 @State 布尔开关控制弹窗显隐,点击遮罩区域自动关闭。主题色板采用浅色田园绿(主色 #22C55E)配合麦秆棕(辅色 #A16207)与浅绿背景(#F4FAF4),营造清新自然的农业视觉调性。

在这里插入图片描述
本应用的设计理念可以概括为"数据驱动决策,声音触达行动"。农情 Tab 回答"现在地里怎么样"的问题,通过大数字与图表让农情一目了然;灌溉 Tab 回答"今天该浇什么"的问题,通过进度环与农田列表给出灌溉执行清单;铃音 Tab 回答"通知怎么响才对"的问题,提供从音频生成到通知发布的全链路工具;我的 Tab 回答"经营得怎么样"的问题,用渐变大卡与统计清单呈现经营全景。四个 Tab 各司其职,又通过共享的状态变量(如 sentCount 通知计数在头部与铃音页均有展示)形成数据联动,体现了 SaaS 应用"模块解耦、状态共享"的架构哲学。

二、整体架构流程图

应用的整体架构从上到下分为五个层次:导入层负责引入系统 Kit 能力;颜色与常量层定义主题色板与静态数据;数据模型层声明可观察的业务实体;组件主体层承载状态管理与业务逻辑方法;构建层通过 Builder 函数组装四个 Tab 页面与弹窗系统。以下流程图展示了各层之间的调用关系与数据流向:

渲染错误: Mermaid 渲染失败: Parse error on line 18: ...graph 数据模型层 C1[FarmItem
@Obs ----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'subgraph', 'end', 'acc_title', 'acc_descr', 'acc_descr_multiline_value', 'AMP', 'COLON', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'DEFAULT', 'NUM', 'COMMA', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', 'direction_tb', 'direction_bt', 'direction_rl', 'direction_lr', 'direction_td', got 'LINK_ID'

三、系统 Kit 导入分析

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';

这段导入语句是整个应用能力体系的基石,四行代码分别引入了四个核心 Kit 模块。

第一行从 @kit.NotificationKit 导入 notificationManager,这是通知能力的中枢。它提供了 requestEnableNotification(请求授权)、openNotificationSettings(打开设置页)、isNotificationEnabled(查询授权状态)、publish(发布通知)等关键方法。在本应用中,通知授权流程与灌溉通知发布都依赖此模块。HarmonyOS 的通知机制要求应用必须先获得用户授权才能发布通知,这与 Android 的通知渠道(NotificationChannel)机制类似但更严格——未授权时 publish 调用会静默失败,不会弹出通知。

第二行从 @kit.CoreFileKit 导入 fileIo(别名 fs)与 fileUrifileIo 提供文件的同步与异步操作接口,本应用中使用 openSyncwriteSynccloseSyncunlinkSync 等同步方法操作沙箱音频文件——之所以选择同步而非异步,是因为音频字节数据通常在几十 KB 级别,同步写入耗时极短(毫秒级),不会阻塞 UI 线程,且代码结构更清晰。fileUrigetUriFromPath 方法是 Notification Kit 6.1.1 新特性的关键——它将应用沙箱内的文件路径转换为系统通知服务可识别的 URI 格式字符串,这是自定义铃声能被通知系统播放的前提条件。

第三行从 @kit.AbilityKit 导入 contextConstantcommoncontextConstant.AreaMode.EL1 用于指定文件操作的加密等级区域,HarmonyOS 将应用沙箱分为 EL1(设备级加密)与 EL2(用户级加密)等区域,通知铃声文件需放在 EL1 的 filesDir 目录下才能被通知服务读取。common.UIAbilityContext 是 Ability 上下文类型,通过 getUIContext().getHostContext() 获取后,可用于获取 ApplicationContext 进而访问沙箱路径。

第四行从 @kit.BasicServicesKit 导入 BusinessError,这是 HarmonyOS 统一的业务错误类型。所有 Promise 的 catch 回调都接收此类型的错误对象,包含 code(错误码)与 message(错误描述)字段。在本应用中,通知授权失败时会捕获错误码 1600004(未授权),此时引导用户前往通知设置页进行二次授权。

四、颜色系统与主题设计

4.1 ColorPalette 接口定义

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

这是一个 TypeScript 接口定义,名为 ColorPalette,它声明了一个完整的主题色板字段集合。在 ArkUI 开发中,将所有颜色集中定义在一个接口中是一种推荐的设计模式——这样做有三个好处:第一,颜色字段有类型约束,拼写错误会在编译期被发现;第二,切换主题时只需替换实现此接口的常量对象,所有引用处自动更新;第三,团队成员可以通过接口定义快速了解应用有哪些颜色可用,避免随意使用未定义的颜色值。

接口包含 23 个颜色字段,覆盖了应用的所有视觉元素需求。bg 是页面背景色,card 是卡片背景色,chip 是标签/输入框背景色,这三者构成了页面的三层背景层次。titlesubtext3 是三级文字颜色,从深到浅形成文字层级。white 是纯白常量。接下来是七组语义色对,每组包含主色(如 green)与浅色背景(如 greenL),分别用于文字/图标与背景填充——这种"深色文字 + 浅色背景"的搭配是 iOS Material Design 与 HarmonyOS Design 都推荐的标签样式。line 是分割线颜色,tabOn 是 Tab 选中态颜色,mask 是弹窗遮罩颜色(半透明深绿)。

4.2 浅色主题色板常量

const COLORS: ColorPalette = {
  bg: '#F4FAF4',
  card: '#FFFFFF',
  chip: '#E6F4E6',
  title: '#1B3A2A',
  sub: '#6B8F6B',
  text3: '#A3C2A3',
  white: '#FFFFFF',
  green: '#22C55E',
  greenD: '#16A34A',
  greenL: '#DCFCE7',
  brown: '#A16207',
  brownL: '#FEF3C7',
  blue: '#3B82F6',
  blueL: '#DBEAFE',
  orange: '#F97316',
  orangeL: '#FFEDD5',
  red: '#EF4444',
  redL: '#FEE2E2',
  purple: '#A78BFA',
  purpleL: '#EDE9FE',
  line: '#D8EED8',
  tabOn: '#22C55E',
  mask: 'rgba(27,58,42,0.5)'
};

COLORS 常量是 ColorPalette 接口的具体实现,定义了"农丰云·田园绿"主题的完整色值。整体色板以绿色为基调——背景色 #F4FAF4 是极淡的绿色调白,营造田园清晨的清新感;主色 #22C55E 是 Tailwind CSS green-500 标准色,饱和度适中、明度高,在浅绿背景上有良好的对比度;深绿 #16A34A(green-600)用于渐变大卡的起始色;浅绿 #DCFCE7(green-100)用于标签背景与进度环底环。

辅色麦秆棕 #A16207(amber-700)与浅棕 #FEF3C7(amber-100)构成第二组语义色,用于"农技课堂"入口与铃音时长滑块等辅助场景。其余五组语义色(蓝/橙/红/紫)分别对应不同业务状态:蓝色用于灌溉中状态与空气湿度,橙色用于温度与待灌溉状态,红色用于病虫预警与删除操作,紫色作为备用。值得注意的是 mask 使用了 rgba(27,58,42,0.5)——这是 title#1B3A2A 的 RGB 值加上 0.5 透明度,使遮罩呈现深绿半透明效果,与田园绿主题保持色系统一。

文字三级色的设计也体现了农业调性:title #1B3A2A 是深森林绿,接近墨绿但带暖调;sub #6B8F6B 是灰绿,用于副标题与说明文字;text3 #A3C2A3 是浅灰绿,用于最弱层级的辅助文字。三级文字色均带有绿色色相,避免了纯灰文字在绿色背景中的割裂感。

五、常量定义与静态数据

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(文字标签)两个字段。使用 Emoji 作为 Tab 图标是一种轻量化的设计选择——无需引入图标库或图片资源,且 Emoji 在各平台渲染一致性较高。TAB_LIST 常量数组包含四个 Tab 项,分别对应农情(🌾 麦穗)、灌溉(💧 水滴)、铃音(🔔 铃铛)、我的(👤 人像),图标语义与功能含义高度匹配。

在构建层中,TAB_LIST 通过 ForEach 渲染为底部导航栏。每个 Tab 项根据 currentTab === idx 判断是否为选中态——选中时图标放大(fontSize 20 vs 17)、不透明度为 1、文字加粗变为主题绿色;未选中时图标缩小、半透明、文字变浅灰色。这种状态差异通过 @State currentTab 驱动,点击 Tab 项时更新 currentTab 即可触发 UI 刷新。

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

const MINI_LIST: MiniStat[] = [
  { icon: '🌱', name: '土壤墒情', val: '65%' },
  { icon: '☀️', name: '光照强度', val: '42k' },
  { icon: '🫧', name: 'CO₂ 浓度', val: '480' },
  { icon: '🌬️', name: '风速', val: '2.1' }
];

MiniStat 接口定义了农情页四格传感器小数据卡的数据结构。MINI_LIST 包含四条传感器数据:土壤墒情 65%(🌱 幼苗图标)、光照强度 42k lux(☀️ 太阳图标)、CO₂ 浓度 480 ppm(🫧 气泡图标)、风速 2.1 m/s(🌬️ 风图标)。这些数值模拟了真实 IoT 传感器采集的环境参数,在实际应用中应从云平台 API 实时拉取。四格卡片通过 ForEach 渲染为横向等宽排列的小卡,每张卡片包含图标、数值与名称三行,数值用主题绿色加粗显示,突出数据本身。

5.2 Canvas 图表数据

const LINE_DATA: number[] = [12.5, 13.2, 11.8, 14.6, 15.1, 14.2, 15.8];
const LINE_MAX: number = 18;
const LINE_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];

这三组常量服务于农情页的 Canvas 产量折线图。LINE_DATA 是近 7 日采收产量数据(单位:吨),从周一到周日依次为 12.5、13.2、11.8、14.6、15.1、14.2、15.8 吨。数据呈现"先降后升"的趋势——周三产量最低(11.8 吨),周日产量最高(15.8 吨),折线图的末端节点通过呼吸动画放大突出,引导用户关注最新数据。LINE_MAX 设为 18 吨作为纵轴最大值,确保所有数据点都在图表可视区域内,同时预留一定顶部空间避免数据点贴边。LINE_LABELS 是横轴星期标签,使用单字"一二三四五六日"简洁标注。

drawLineChart 方法中,折线图纵坐标通过 h - pad - (LINE_DATA[i] / LINE_MAX) * (h - pad * 2) 公式计算,即将产量数值映射到 Canvas 坐标系的 Y 轴位置。数值越大,Y 坐标越小(越靠上),符合折线图"高值在上"的视觉习惯。LINE_MAX 的设定直接影响折线的视觉起伏幅度——如果设为 16,则 15.8 的点会接近图表顶部,视觉上更"陡峭";设为 20 则折线整体下压,起伏更平缓。

const RING_PROGRESS: number = 0.68;

RING_PROGRESS 是灌溉 Tab 进度环的数值,0.68 表示今日灌溉目标完成度 68%。这个值在 drawRingChart 方法中用于计算进度弧的绘制角度:-Math.PI / 2 + Math.PI * 2 * progress,即从 12 点钟方向(-π/2)开始顺时针绘制 68% 的圆弧。进度环中心显示 Math.round(0.68 * 100) = 68 加百分号。

const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
const MONTH_NAME: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
const MONTH_VAL: number[] = [48.2, 52.6, 45.8, 57.3, 61.2, 58.7];
const MONTH_MAX: number = 70;

这四组常量服务于通用图表卡中的月度产量柱状图。MONTH_IDX 是索引数组 [0,1,2,3,4,5],用于 ForEach 遍历;MONTH_NAME 是 3 月至 8 月的月份标签;MONTH_VAL 是各月产量(吨),呈现春季低、夏季高的季节性趋势——3 月 48.2 吨为最低,7 月 61.2 吨为最高;MONTH_MAX 设为 70 吨作为柱状图最大值参考。柱状图高度通过 MONTH_VAL[i] / MONTH_MAX * 110 计算,其中 110 是最大柱高(vp 单位),最末柱(8 月)在 breath 为 true 时高度额外加 4vp 并设最小值 24vp,实现呼吸放大动效。

const FORM_LABELS: string[] = ['农田名称', '种植作物', '灌溉面积'];

FORM_LABELS 是新增/编辑农田弹窗中的表单字段标签数组,三个标签对应三个 TextInput 输入框。使用数组常量统一管理标签文本,在 panelAddpanelEdit 两个弹窗中复用,避免标签文案不一致。

六、辅助函数

6.1 farmColor 农田状态色映射

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

farmColor 是一个纯函数,根据农田状态字符串返回对应的主题色值。四种状态与颜色的映射关系为:灌溉中→蓝色(#3B82F6,进行中的活跃感)、待灌溉→橙色(#F97316,需要关注的警示感)、已完成→绿色(#22C55E,完成的安全感)、休耕(或其他)→浅灰绿(#A3C2A3,弱化的休眠感)。

这个函数在灌溉 Tab 的农田列表行中被调用,用于设置状态标签文字的颜色。使用纯函数而非内联三元表达式有两个好处:第一,状态与颜色的映射关系集中在一处,后续新增状态类型只需修改此函数;第二,函数有明确的签名与返回类型,IDE 能提供更好的类型提示。在实际 IoT 应用中,农田状态可能从云端同步,状态值可能有英文枚举(如 IRRIGATING/PENDING/COMPLETED),只需调整此函数的判断条件即可适配。

6.2 buildWavBytes 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 音频文件字节序列。这个函数的输入是频率(Hz)与时长(ms),输出是一个 ArrayBuffer 字节缓冲区,可以直接写入沙箱文件作为通知自定义铃声。

函数首先计算音频参数:采样率 44100 Hz(CD 音质标准)、采样数 numSamples(采样率乘以时长除以 1000)、数据大小 dataSize(每个采样 2 字节,16bit PCM)。然后分配一个 ArrayBuffer,大小为 44 字节 WAV 头加 dataSize 字节音频数据。DataView 是操作 ArrayBuffer 的视图对象,提供按偏移量读写不同类型数值的方法。

WAV 文件头由 44 字节组成,分为三个区块:RIFF 区块头(字节 0-11)标识文件格式为 RIFF/WAVE;fmt 子区块头(字节 12-35)描述音频格式参数——音频格式 1(PCM)、声道数 1(单声道)、采样率 44100、字节率 88200(采样率×声道数×位深/8)、块对齐 2(声道数×位深/8)、位深 16bit;data 子区块头(字节 36-43)标识音频数据区开始与大小。所有多字节整数字段使用小端序(true 参数),这是 WAV 格式规范要求的。

音频数据区(字节 44 开始)通过循环填充采样值。每个采样点的计算涉及三个数学因子:t 是时间(秒),env 是起音包络(Attack Envelope)——前 20ms 内从 0 渐升到 1,避免音频开始时的"咔哒"爆音;decay 是衰减包络——从 1 线性衰减到 0,使声音随时间渐弱;v 是最终采样值,由正弦波 sin(2πft) 乘以 0.5 振幅再乘以包络与衰减得到。采样值通过 Math.round(v * 32767) 转换为 16bit 有符号整数范围(-32768~32767),再用 setInt16 以小端序写入。

这个函数的存在是 Notification Kit 6.1.1 新特性的关键支撑——它使应用能在运行时生成任意频率与时长的音频,无需预置音频资源文件。在数字农业场景中,不同业务通知可以对应不同频率的提示音:低频(220 Hz)像"夜雨滴答"适合夜间静默提醒,高频(1760 Hz)像"除虫蜂鸣"适合紧急告警,中频(880 Hz)像"丰收锣"适合日常通知。用户甚至可以通过铃音生成器自定义频率与时长,实现完全个性化的通知声音体系。

七、数据模型与 Mock 数据

7.1 @Observed 数据模型类

@Observed export class FarmItem {
  name: string;
  crop: string;
  area: string;
  status: string;

  constructor(name: string, crop: string, area: string, status: string) {
    this.name = name;
    this.crop = crop;
    this.area = area;
    this.status = status;
  }
}

FarmItem 是农田条目的数据模型类,使用 @Observed 装饰器修饰。@Observed 是 ArkUI 的可观察对象装饰器,它使类的实例属性变为可追踪的——当属性值被修改时,引用该对象的 @State@ObjectLink 变量会触发 UI 刷新。在本应用中,FarmItem 用于灌溉 Tab 的农田列表行,当用户编辑农田信息(调用 updateFarm 方法修改 name/crop/area 属性)时,列表行 UI 会自动更新。

FarmItem 包含四个字符串属性:name(农田名称,如"1 号大棚")、crop(种植作物,如"圣女果")、area(灌溉面积,如"2.4 亩")、status(灌溉状态,取值为"灌溉中"/“待灌溉”/“已完成”/“休耕”)。构造函数接收四个参数并赋值给对应属性。export 关键字使此类可被其他模块导入使用。

@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(文件大小字符串,初始为"—“,导入沙箱后更新为"XX KB”)、inSandbox(是否已导入沙箱的布尔标志)。inSandbox 属性是铃音 Tab 的核心状态——它决定铃声库列表项显示"导入沙箱"还是"沙箱中"按钮,以及是否可以直接设为默认通知铃声。

当用户点击"导入沙箱"按钮时,importRingToSandbox 方法将 inSandbox 设为 true 并更新 size@Observed 装饰器确保这一变化立即反映到 UI 上——列表项的按钮从"导入沙箱"变为隐藏,状态标签从"未导入"变为"沙箱中"。

@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(统计数值)、tag(标签描述)四个属性。用于我的 Tab 的统计清单列表,每行展示一个统计项,如"🌾 种植面积 128 亩 大棚 32 亩"。

7.2 Mock 数据

const FARM_LIST: Array<FarmItem> = [
  new FarmItem('1 号大棚', '圣女果', '2.4 亩', '灌溉中'),
  new FarmItem('2 号大棚', '水果黄瓜', '1.8 亩', '待灌溉'),
  new FarmItem('东区露地', '普罗旺斯番茄', '12.0 亩', '灌溉中'),
  new FarmItem('西区露地', '贝贝南瓜', '8.6 亩', '已完成'),
  new FarmItem('3 号大棚', '彩椒', '2.1 亩', '待灌溉'),
  new FarmItem('南区果园', '羊角蜜甜瓜', '6.2 亩', '休耕')
];

FARM_LIST 是农田 Mock 数据数组,包含 6 条 FarmItem 实例。数据模拟了一个中型家庭农场的农田分布:3 座大棚(1 号圣女果、2 号水果黄瓜、3 号彩椒)、2 块露地(东区普罗旺斯番茄、西区贝贝南瓜)、1 片果园(南区羊角蜜甜瓜),总面积约 33 亩。状态分布为 2 块灌溉中、2 块待灌溉、1 块已完成、1 块休耕,覆盖了所有四种状态类型,使列表行的状态色映射能充分展示。

在实际应用中,FARM_LIST 应替换为从云平台 API 获取的实时数据。Mock 数据的价值在于开发阶段快速搭建 UI 界面与交互逻辑,不依赖后端服务。

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 是铃声库 Mock 数据数组,包含 6 条 RingItem 实例。每条铃声都有语义化的名称与对应的频率参数:丰收锣(880 Hz,中高音)、灌溉滴(660 Hz,中音)、鸡鸣晨铃(440 Hz,中低音)、温室风铃(1320 Hz,高音)、除虫蜂鸣(1760 Hz,超高音)、夜雨滴答(220 Hz,低音)。初始 inSandbox 均为 falsesize 均为"—",用户需要主动"导入沙箱"后才能设为默认通知铃声。

频率覆盖 220 Hz 至 1760 Hz 的宽范围,对应不同的听觉感受——低频沉稳(夜雨滴答)、中频温和(灌溉滴/鸡鸣晨铃)、高频明亮(温室风铃/除虫蜂鸣)。这种频率多样性使通知铃声能传递丰富的语义信息,管护人员听到高频蜂鸣可能联想到"除虫紧急",听到低频滴答则联想到"夜间灌溉完成"。

const STAT_LIST: Array<StatItem> = [
  new StatItem('🌾', '种植面积', '128 亩', '大棚 32 亩'),
  new StatItem('🚜', '农机作业', '46 小时', '本周 +8 小时'),
  new StatItem('💧', '本月灌溉', '1,240 m³', '节水 12%'),
  new StatItem('📦', '本月出货', '58.7 吨', '环比 +9.2%'),
  new StatItem('💰', '本月产值', '¥286,400', '目标完成 84%')
];

STAT_LIST 是农场经营统计 Mock 数据,5 条数据覆盖了农场经营的核心维度:种植面积(资源规模)、农机作业(生产投入)、本月灌溉(资源消耗)、本月出货(产出规模)、本月产值(经济效益)。每条数据除了主数值外还有标签描述(如"节水 12%"“环比 +9.2%”),提供同比/环比等趋势信息,帮助农场主快速评估经营状况。

八、主页面组件状态管理

8.1 组件声明与状态变量

@Entry
@Component
struct Page1103 {
  @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 lineReady: boolean = false;
  @State ringReady: boolean = false;
  @State sentCount: number = 0;

@Entry 装饰器声明此组件为页面入口组件,@Component 声明其为 ArkUI 自定义组件。struct Page1103 是组件名。@State 装饰的变量是组件的响应式状态——当这些变量的值发生变化时,引用它们的 UI 部分会自动重新渲染。

前三个状态变量 currentTabaddModaleditModaldelModal 控制页面核心交互:currentTab 决定当前显示哪个 Tab 页面(0=农情、1=灌溉、2=铃音、3=我的),addModal/editModal/delModal 分别控制新增农田、编辑农田、删除铃声三个弹窗的显隐。

editIdxdelIdx 记录当前操作的列表项索引——编辑农田时记录被编辑项的索引,删除铃声时记录被删除项的索引,确保弹窗中的"确认"操作能精确作用于正确的数据项。

breath 是呼吸动画的开关变量,每秒在 true/false 之间翻转,驱动头部授权状态胶囊的闪烁、折线图末端节点的放大缩小、进度弧的微动以及月度柱状图末柱的呼吸放大。granted 记录通知授权状态,控制头部胶囊显示"已授权"(绿色)或"未授权·点击授权"(红色)。

notifyId 是通知 ID 计数器,初始值 100,每次发布通知后自增,确保每条通知有唯一 ID。currentRingIdx 记录当前默认铃声在列表中的索引。sandboxCount 统计已导入沙箱的铃声数量,在铃音页与头部展示。genFreqgenDuration 是铃声生成器的频率与时长参数,初始值 880 Hz 与 1200 ms。

lineReadyringReady 是两个 Canvas 的就绪标志,在 Canvas 的 onReady 回调中设为 true,呼吸定时器中只有当对应 Canvas 就绪时才执行重绘,避免在 Canvas 未初始化时调用绘制方法导致空指针异常。sentCount 统计已发布的通知条数,在头部数据小卡与铃音页底部均有展示。

8.2 数据源与 Canvas 上下文

  @State farmList: Array<FarmItem> = FARM_LIST;
  @State ringList: Array<RingItem> = RING_LIST;
  @State statList: Array<StatItem> = STAT_LIST;
  @State formName: string = '';
  @State formCrop: string = '';
  @State formArea: string = '';
  @State editName: string = '';
  @State editCrop: string = '';
  @State editArea: string = '';
  private timer: number = -1;
  private lineCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
  private ringCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));

farmListringListstatList 三个 @State 数组分别绑定到三个 Tab 的列表数据源,初始值为前面定义的 Mock 数据常量。由于数组元素类型是 @Observed 修饰的类实例,当实例属性被修改时(如 updateFarm 修改 FarmItem.name),UI 会自动刷新。

formName/formCrop/formArea 是新增农田弹窗的三个表单字段,editName/editCrop/editArea 是编辑农田弹窗的三个表单字段。新增与编辑使用独立的表单状态变量,避免编辑时误改新增表单的数据。编辑弹窗打开时(tabIrrigate 中的"编辑"按钮 onClick),会将当前农田项的属性值赋给 editName/editCrop/editArea,实现表单回填。

timer 是呼吸动画定时器句柄,类型为 number,初始值 -1。在 aboutToAppear 中通过 setInterval 赋值,在 aboutToDisappear 中通过 clearInterval 清理。private 修饰符表示此变量不参与 UI 渲染,仅作内部逻辑使用。

lineCtxringCtx 是两个 Canvas 的 2D 渲染上下文,通过 new CanvasRenderingContext2D(new RenderingContextSettings(true)) 创建。RenderingContextSettings(true)true 参数表示开启抗锯齿(antialias),使折线与圆弧的边缘更平滑。这两个上下文在 build 中绑定到对应的 <Canvas> 组件,在 onReady 回调后即可调用 drawLineChartdrawRingChart 方法进行绘制。

九、通知授权与沙箱铃声系统

9.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 方法实现了 HarmonyOS 通知授权的"双路径"策略。首先通过 this.getUIContext().getHostContext() 获取宿主 Ability 上下文,并使用 as common.UIAbilityContext 进行类型断言——这是因为 getHostContext() 返回的是 common.Context 基类,而通知授权方法需要 UIAbilityContext 子类。如果上下文为空(某些异常场景下可能发生),直接返回不做操作。

授权的第一路径是 notificationManager.requestEnableNotification(hostCtx)——这是首次调用时会弹出系统授权弹窗,询问用户是否允许应用发送通知。如果用户同意,Promise resolve,granted 设为 true,头部胶囊变为绿色"已授权"状态。

如果用户曾经拒绝过授权(或系统中通知被关闭),requestEnableNotification 会 reject,进入 catch 分支。此时执行第二路径:notificationManager.openNotificationSettings(hostCtx),拉起系统的通知设置页面,引导用户手动开启通知开关。这种"先请求、被拒后引导设置"的策略是 HarmonyOS 推荐的授权最佳实践——既尊重用户的首次选择权,又提供了二次授权的入口。如果设置页拉起也失败(极端情况),granted 保持 false

9.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 方法是沙箱铃声系统的核心,它将指定频率与时长的音频数据写入应用沙箱 EL1 区域的 filesDir 目录。方法接收三个参数:fileName(文件名,如"ring_880.wav")、freq(频率 Hz)、durationMs(时长 ms),返回沙箱文件的完整路径字符串。

方法首先获取 ApplicationContext,然后通过 appCtx.area = contextConstant.AreaMode.EL1 将文件操作区域切换到 EL1(设备级加密区)。这一步至关重要——Notification Kit 6.1.1 要求自定义铃声文件必须位于 EL1 区域的 filesDir 目录下,放在 EL2 区域的通知服务无法读取。appCtx.filesDir 返回的就是 EL1 区域的文件目录路径。

获取目录后拼接完整文件路径,调用 buildWavBytes 生成 WAV 字节数据。然后使用 fs.openSync 以三种模式组合打开文件:CREATE(文件不存在时创建)、WRITE_ONLY(只写模式)、TRUNC(如果文件已存在则清空内容再写入)。打开成功后通过 fs.writeSync(file.fd, data)ArrayBuffer 数据写入文件描述符 fd,最后 fs.closeSync(file) 关闭文件释放资源。整个写入过程被 try-catch 包裹,沙箱写入失败时静默忽略(返回空字符串),避免因文件系统异常导致应用崩溃。

返回的路径字符串会被 getSoundValue 方法用于生成通知 sound 字段的 URI 值,是连接"音频生成"与"通知发布"两个子系统的桥梁。

9.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 方法将铃声库中指定索引的条目导入沙箱。它调用 saveRingToSandbox 写入音频文件后,更新 RingItem 实例的两个属性:inSandbox 设为 truesize 更新为计算出的文件大小(KB)。文件大小通过 WAV 文件大小公式计算:44 字节头 + 采样数 × 2 字节,再除以 1024 转为 KB 并四舍五入。sandboxCount 计数器自增,该值在铃音页"当前默认铃声"卡与头部数据小卡中均有展示。

由于 RingItem@Observed 修饰的类,inSandboxsize 属性的修改会立即触发列表项 UI 刷新——“导入沙箱"按钮隐藏,状态标签从"未导入"变为"沙箱中”。

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 方法根据铃声生成器的频率与时长参数创建新的铃声条目。它计算序号(当前列表长度 + 1),构造 RingItem 实例(名称"自定义铃声N"、文件名"ring_custom_N.wav"、频率与时长取自 genFreqgenDuration 状态变量),push 到 ringList 数组后立即调用 importRingToSandbox 导入沙箱。这样用户通过滑块调整频率与时长后点击"生成铃声到沙箱"按钮,即可创建并导入一个完全自定义的铃声,无需从预设列表中选择。

9.4 设为默认铃声

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

setCurrentRing 方法将指定索引的铃声设为默认通知铃声。它有一个前置检查——如果该铃声尚未导入沙箱,先调用 importRingToSandbox 导入,确保默认铃声的音频文件一定存在于沙箱中。然后更新 currentRingIdx 状态变量,铃音页"当前默认铃声"卡会刷新显示新选中的铃声名称与 sound 字段值。

9.5 获取 sound 字段值

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 格式字符串。方法获取当前默认铃声的文件名,拼接沙箱 filesDir 路径得到完整文件路径,然后调用 fileUri.getUriFromPath(path) 将文件路径转换为系统 URI。

返回值格式为 'uri::' + uri——这个 uri:: 前缀是 Notification Kit 的特殊标识,告诉通知系统后面的字符串是一个文件 URI 而非资源 ID。通知服务收到此 sound 值后,会从 URI 指向的沙箱文件中读取音频数据并播放,而非使用系统默认铃声。如果上下文获取失败,返回 'uri::'(空 URI),通知系统会回退到默认铃声。

这个方法在铃音页"当前默认铃声"卡中被调用,将返回值显示在芯片背景的文本区域中,让用户直观看到 sound 字段的实际取值——这是一种"透明化"的设计,让开发者与用户都能理解自定义铃声背后的技术机制。

9.6 发布灌溉通知

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: '3 号农田已启动智能灌溉,预计 40 分钟完成,请留意墒情变化。',
        additionalText: '自定义铃声:' + ringName
      }
    },
    sound: soundVal
  };
  notificationManager.publish(request).then(() => {
    this.notifyId++;
    this.sentCount++;
  }).catch((err: BusinessError) => {
    // 失败:未授权 1600004
  });
}

publishNotice 方法是整个通知系统的"最后一公里"——它将前面所有准备工作(授权、音频生成、沙箱写入、URI 转换)汇聚为一次通知发布调用。方法首先进行边界检查,确保 currentRingIdx 在有效范围内;然后检查当前铃声是否已导入沙箱,未导入则自动导入;接着调用 getSoundValue 获取 sound 字段值。

通知请求对象 NotificationRequest 包含以下关键字段:

id 是通知唯一标识,使用 notifyId 状态变量,每次发布后自增,避免通知 ID 重复导致新通知覆盖旧通知。notificationSlotType 设为 SOCIAL_COMMUNICATION(社交通信类型),这是通知渠道的一种分类,影响通知的展示优先级与提示方式——社交通信类通知通常有较高的展示优先级与声音提示。content 定义通知内容:notificationContentType 设为 NOTIFICATION_CONTENT_BASIC_TEXT(基础文本类型),normal 子对象包含 title(标题"农丰云·灌溉通知")、text(正文描述灌溉信息)、additionalText(附加文本,显示当前使用的自定义铃声名称)。

最关键的字段是 sound: soundVal——这就是 6.1.1 新特性的应用点,将沙箱文件的 URI 填入通知请求的 sound 字段。通知发布成功后,notifyId 自增、sentCount 自增,头部"已发通知"数据小卡与铃音页底部统计同步刷新。发布失败时(如未授权错误码 1600004),catch 分支静默处理,不弹出错误提示。

这个方法在两个位置被调用:灌溉 Tab 农田列表行的"灌溉通知"按钮、铃音 Tab 的"立即发布通知"按钮,形成了从数据到行动的完整闭环。

十、数据操作方法

10.1 删除铃声

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 状态变量(在铃声库列表的"删除"按钮 onClick 中赋值)。如果被删除的铃声已导入沙箱,先递减 sandboxCount 计数器,再通过 fs.unlinkSync 删除沙箱中的 WAV 文件——同样需要先切换到 EL1 区域再操作。文件删除使用 try-catch 包裹,沙箱文件不存在时静默忽略。

删除数据后通过 ringList.splice(idx, 1) 从数组中移除条目,@State 数组的 splice 操作会触发 ForEach 刷新。如果被删除的铃声恰好是当前默认铃声(currentRingIdx 超出列表范围),则将 currentRingIdx 回退到列表末尾,避免后续 getSoundValue 方法访问越界索引。最后关闭删除确认弹窗。

这个方法体现了"数据一致性"设计原则——删除铃声不仅移除列表数据,还同步清理物理文件与计数器,确保 UI 状态与文件系统状态保持同步。

10.2 新增农田

saveFarm() {
  if (this.formName === '' || this.formCrop === '' || this.formArea === '') {
    return;
  }
  this.farmList.push(new FarmItem(this.formName, this.formCrop, this.formArea, '待灌溉'));
  this.formName = '';
  this.formCrop = '';
  this.formArea = '';
  this.addModal = false;
}

saveFarm 方法处理新增农田弹窗的保存逻辑。首先进行非空校验——三个表单字段任一为空则直接返回,不执行新增操作(这是一种轻量级的表单验证,实际应用中可增加更复杂的格式校验)。校验通过后,创建 FarmItem 实例(状态默认为"待灌溉"),push 到 farmList 数组。然后清空三个表单字段(为下次新增准备干净的表单),关闭新增弹窗。

10.3 编辑农田

updateFarm() {
  if (this.editIdx >= 0 && this.editIdx < this.farmList.length) {
    this.farmList[this.editIdx].name = this.editName;
    this.farmList[this.editIdx].crop = this.editCrop;
    this.farmList[this.editIdx].area = this.editArea;
  }
  this.editModal = false;
}

updateFarm 方法处理编辑农田弹窗的保存逻辑。通过 editIdx 定位到 farmList 中的目标条目,将三个编辑表单字段的值赋给条目属性。由于 FarmItem@Observed 修饰的类,属性修改会自动触发列表项 UI 刷新——农田名称、作物、面积等文字会实时更新。最后关闭编辑弹窗。

十一、生命周期与动画系统

11.1 aboutToAppear

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

aboutToAppear 是 ArkUI 组件的生命周期回调,在组件创建后、build 执行前调用。方法执行两件事:

第一件事是查询通知授权状态。notificationManager.isNotificationEnabled() 返回一个 Promise,resolve 时传入布尔值表示通知是否已启用。将此值赋给 granted 状态变量,使头部胶囊在页面首次加载时就能显示正确的授权状态(而非默认的 false)。如果查询失败,granted 保持 false

第二件事是启动呼吸动画定时器。setInterval 每隔 1000 毫秒(1 秒)执行一次回调:将 breath 状态在 true/false 之间翻转,触发所有依赖 breath 的 UI 元素刷新(头部胶囊闪烁、月度柱状图末柱放大);如果折线图 Canvas 已就绪(lineReady === true),调用 drawLineChart 重绘折线图(实现末端节点呼吸放大);如果进度环 Canvas 已就绪(ringReady === true),调用 drawRingChart 重绘进度环(实现进度弧微动)。定时器句柄保存到 this.timer,供 aboutToDisappear 清理。

这种"定时器驱动双 Canvas 联动重绘"的设计,使数据面板具有持续的生命力——即使用户不操作,图表也在每秒微动,传达"数据在实时更新"的视觉信号。在实际 IoT 应用中,可在此定时器中同时拉取传感器最新数据并更新 LINE_DATA 等常量(需改为状态变量),实现真正的实时数据可视化。

11.2 aboutToDisappear

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

aboutToDisappear 是组件销毁前的生命周期回调,清理 aboutToAppear 中创建的定时器。这一步至关重要——如果不清理定时器,组件销毁后定时器仍在运行,持续调用 drawLineChartdrawRingChart 方法,而这些方法引用的 Canvas 上下文可能已失效,导致内存泄漏或运行时异常。clearInterval(this.timer) 确保定时器随组件生命周期正确销毁。

十二、Canvas 图表绘制

12.1 产量折线图绘制

drawLineChart() {
  const ctx = this.lineCtx;
  const w = 320;
  const h = 180;
  const pad = 30;
  const stepX = (w - pad * 2) / (LINE_DATA.length - 1);
  ctx.clearRect(0, 0, w, h);

drawLineChart 方法绘制农情页的近 7 日产量折线图。首先获取折线图 Canvas 上下文 lineCtx,定义图表参数:宽度 320、高度 180(与 Canvas 组件的 width('100%').height(180) 对应)、内边距 30(图表四周留白)、步长 stepX(相邻数据点的水平距离,由图表宽度减去两侧内边距后除以数据点数减一计算得出)。clearRect(0, 0, w, h) 清空整个画布,确保每次重绘时不会残留上一次的绘制内容——这是呼吸动画每秒重绘的前提条件。

  // 横向网格线
  ctx.strokeStyle = COLORS.line;
  ctx.lineWidth = 1;
  for (let i = 0; i <= 3; i++) {
    const y = pad + (h - pad * 2) * i / 3;
    ctx.beginPath();
    ctx.moveTo(pad, y);
    ctx.lineTo(w - pad, y);
    ctx.stroke();
  }

网格线绘制部分使用 for 循环绘制 4 条水平线(i 从 0 到 3),将图表纵向分为 3 等份。每条线的 Y 坐标通过 pad + (h - pad * 2) * i / 3 计算——i=0 时为顶部内边距位置,i=3 时为底部内边距位置。网格线使用 COLORS.line(浅绿 #D8EED8)与 1px 线宽,颜色淡雅不抢主折线风头。beginPath/moveTo/lineTo/stroke 是 Canvas 2D 路径绘制的标准四步:开始路径、移动到起点、画线到终点、描边渲染。

  // 渐变面积填充
  const grad = ctx.createLinearGradient(0, pad, 0, h - pad);
  grad.addColorStop(0, COLORS.green);
  grad.addColorStop(1, 'rgba(34,197,94,0.05)');
  ctx.beginPath();
  ctx.moveTo(pad, h - pad);
  for (let i = 0; i < LINE_DATA.length; i++) {
    const x = pad + i * stepX;
    const y = h - pad - (LINE_DATA[i] / LINE_MAX) * (h - pad * 2);
    ctx.lineTo(x, y);
  }
  ctx.lineTo(w - pad, h - pad);
  ctx.closePath();
  ctx.fillStyle = grad;
  ctx.fill();

渐变面积填充是折线图的视觉亮点。首先通过 createLinearGradient(0, pad, 0, h - pad) 创建一个从顶部(pad)到底部(h - pad)的线性渐变对象,addColorStop(0, COLORS.green) 在顶部使用不透明绿色,addColorStop(1, 'rgba(34,197,94,0.05)') 在底部使用几乎透明的绿色——这种"上实下虚"的渐变使面积区域呈现从折线向下逐渐消散的效果,视觉上更柔和。

面积路径的构建:从左下角 (pad, h - pad) 开始,沿着数据点连线到右下角 (w - pad, h - pad),再 closePath 闭合回起点形成封闭区域。每个数据点的 Y 坐标通过 h - pad - (LINE_DATA[i] / LINE_MAX) * (h - pad * 2) 计算——产量值除以最大值得到比例,乘以可用高度得到偏移量,从底部减去偏移量得到 Y 坐标。最后 fill() 用渐变填充封闭路径。

  // 主折线
  ctx.beginPath();
  for (let i = 0; i < LINE_DATA.length; i++) {
    const x = pad + i * stepX;
    const y = h - pad - (LINE_DATA[i] / LINE_MAX) * (h - pad * 2);
    if (i === 0) {
      ctx.moveTo(x, y);
    } else {
      ctx.lineTo(x, y);
    }
  }
  ctx.strokeStyle = COLORS.green;
  ctx.lineWidth = 2;
  ctx.stroke();

主折线绘制与面积填充的路径类似,但不闭合底部——只连接数据点本身。第一个点使用 moveTo(移动到起点,不画线),后续点使用 lineTo(画线到目标点)。折线使用 COLORS.green#22C55E)2px 线宽描边,位于渐变面积的上边缘,形成清晰的折线轮廓。

  // 数据节点(末端呼吸放大)
  for (let i = 0; i < LINE_DATA.length; i++) {
    const x = pad + i * stepX;
    const y = h - pad - (LINE_DATA[i] / LINE_MAX) * (h - pad * 2);
    ctx.beginPath();
    ctx.arc(x, y, i === LINE_DATA.length - 1 ? (this.breath ? 5 : 3) : 3, 0, Math.PI * 2);
    ctx.fillStyle = COLORS.card;
    ctx.fill();
    ctx.strokeStyle = COLORS.orange;
    ctx.lineWidth = 1.5;
    ctx.stroke();
  }

数据节点绘制在每个数据点位置画一个圆形标记。所有节点半径为 3px,但最后一个节点(i === LINE_DATA.length - 1,即周日最新数据)的半径根据 breath 状态在 5px 与 3px 之间切换——这就是呼吸动画的视觉效果,末端节点每秒放大缩小一次,引导用户关注最新数据。节点填充白色(COLORS.card),描边橙色(COLORS.orange)1.5px,形成"白心橙边"的视觉风格,在绿色折线上十分醒目。

  // 横轴星期标签
  ctx.font = '9px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillStyle = COLORS.sub;
  for (let i = 0; i < LINE_LABELS.length; i++) {
    ctx.fillText(LINE_LABELS[i], pad + i * stepX, h - pad + 14);
  }
}

横轴标签在图表底部绘制星期文字。font 设为 9px sans-serif,textAlign 设为 center(文字水平居中对齐到坐标点),fillStyle 设为副文字色 COLORS.sub。循环遍历 LINE_LABELS,在对应数据点的 X 坐标、底部内边距下方 14px 处绘制星期文字。这样折线图就有了完整的横轴标注,用户可以直观对应每个数据点的星期。

12.2 灌溉进度环绘制

drawRingChart() {
  const ctx = this.ringCtx;
  const cx = 90;
  const cy = 90;
  const r = 62;
  const progress = RING_PROGRESS;
  const breathVal = this.breath ? 1.0 : 0.85;
  ctx.clearRect(0, 0, 180, 180);

drawRingChart 方法绘制灌溉 Tab 的今日灌溉目标进度环。参数定义:圆心坐标 (90, 90)(Canvas 尺寸 180×180 的中心)、半径 62px、进度值 0.68(来自 RING_PROGRESS 常量)。breathVal 根据 breath 状态在 1.0 与 0.85 之间切换——这会使进度弧的绘制角度在 100% 与 85% 之间微动,产生"呼吸"般的视觉脉动效果。clearRect 清空画布。

  // 底环
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.strokeStyle = COLORS.chip;
  ctx.lineWidth = 11;
  ctx.stroke();

底环是一个完整的圆(arc 从 0 到 Math.PI * 2),使用 COLORS.chip(浅绿 #E6F4E6)11px 线宽描边。底环作为进度弧的"轨道",让用户直观看到进度的"分母"。

  // 进度弧(呼吸微动)
  ctx.beginPath();
  ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * progress * breathVal);
  ctx.strokeStyle = COLORS.green;
  ctx.lineWidth = 11;
  ctx.lineCap = 'round';
  ctx.stroke();

进度弧从 12 点钟方向(-Math.PI / 2,即 -90 度)开始顺时针绘制,结束角度为 -Math.PI / 2 + Math.PI * 2 * progress * breathVal——完整圆周 Math.PI * 2 乘以进度 0.68 再乘以呼吸系数 breathVal(1.0 或 0.85)。使用 COLORS.green 11px 线宽,lineCap 设为 round 使弧的两端呈圆头,视觉上更柔和。进度弧叠在底环之上,两者线宽相同,形成"灰色轨道 + 绿色进度"的经典进度环效果。

  // 中心百分比与标题
  ctx.fillStyle = COLORS.title;
  ctx.font = 'bold 20px sans-serif';
  ctx.textAlign = 'center';
  ctx.fillText(Math.round(progress * 100).toString() + '%', cx, cy + 2);
  ctx.font = '9px sans-serif';
  ctx.fillStyle = COLORS.sub;
  ctx.fillText('今日灌溉目标', cx, cy + 20);
}

进度环中心绘制两行文字:第一行是百分比数值"68%“(Math.round(0.68 * 100)),使用 COLORS.title(深绿)bold 20px 字体,Y 坐标略低于圆心(cy + 2)以视觉居中;第二行是标题"今日灌溉目标”,使用 COLORS.sub(灰绿)9px 字体,位于百分比下方 18px 处。两行文字水平居中对齐到圆心 X 坐标,形成"大数字 + 小标题"的层级结构,信息传达清晰高效。

十三、页面构建与布局

13.1 build 主入口

build() {
  Stack() {
    Column() {
      this.headerMain()
      Divider().strokeWidth(1).color(COLORS.line)
      Scroll() {
        Column() {
          if (this.currentTab === 0) {
            this.tabFarm()
          } else if (this.currentTab === 1) {
            this.tabIrrigate()
          } 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%')

    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 的作用是将主内容与弹窗面板叠加在同一层级——主内容在底层,弹窗面板在上层(通过 if 条件渲染控制显隐)。这种设计使弹窗能覆盖整个页面,同时点击遮罩可关闭弹窗。

Stack 内部是一个 Column(纵向布局),从上到下依次排列:headerMain()(头部区域)、Divider(分割线)、Scroll(可滚动内容区)、tabBar()(底部导航栏)。Scroll 使用 layoutWeight(1) 占据剩余空间,scrollBar(BarState.Off) 隐藏滚动条使界面更简洁。Scroll 内部是 Column,根据 currentTab 的值条件渲染对应的 Tab 页面 Builder 函数,下方始终渲染 chartCard()(月度产量柱状图,所有 Tab 共享)。

三个弹窗面板通过 if 条件渲染——当 addModal/editModal/delModaltrue 时渲染对应面板,每个面板接收一个关闭回调函数(将对应状态变量设为 false)。StackalignContent 默认为 Alignment.Center,弹窗面板会居中显示。最外层 backgroundColor(COLORS.bg) 设置页面背景为浅绿。

13.2 headerMain 头部

@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 函数,使用 @Builder 装饰器声明为构建函数。头部分为两行:第一行是应用名与通知授权胶囊。

应用名部分是一个 Column,包含"农丰云"(17px 粗体深绿)与"智慧农业 · 物联网种植管理平台"(9px 灰绿副标题),左对齐排列,layoutWeight(1) 占据剩余宽度。

通知授权胶囊是一个 Row,包含一个 6px 圆形指示灯与授权状态文字。指示灯颜色根据 granted 在绿色与红色之间切换,透明度根据 breath 在 1 与 0.35 之间切换——未授权时指示灯闪烁红色,已授权时常亮绿色。胶囊背景色与文字色同步切换(绿底绿字或红底红字),圆角 10px。点击胶囊时,如果未授权则调用 requestAuth() 发起授权流程。

    Row({ space: 10 }) {
      Column({ space: 3 }) {
        Text('大棚温度').fontSize(9).fontColor(COLORS.text3)
        Text('28.6°C').fontSize(11).fontColor(COLORS.orange).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('72%').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.green).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)):大棚温度 28.6°C(橙色)、空气湿度 72%(蓝色)、已发通知数(绿色,值来自 sentCount 状态变量)。每张卡片包含标签(9px 浅灰绿)与数值(11px 粗体),居中对齐,白色背景圆角 10px。已发通知卡片与通知系统联动——每次发布通知后 sentCount 自增,此处数字实时更新,让用户感知通知的发送状态。

13.3 tabFarm 农情页

@Builder
tabFarm() {
  Column({ space: 12 }) {
    Row({ space: 10 }) {
      Column({ space: 6 }) {
        Text('🌡️ 大棚温度').fontSize(10).fontColor(COLORS.sub)
        Text('28.6').fontSize(30).fontColor(COLORS.orange).fontWeight(FontWeight.Bold)
        Text('适宜区间 24~30°C').fontSize(8).fontColor(COLORS.text3)
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
      .padding({ top: 14, bottom: 14 })
      .backgroundColor(COLORS.card).borderRadius(12)

      Column({ space: 6 }) {
        Text('💧 空气湿度').fontSize(10).fontColor(COLORS.sub)
        Text('72').fontSize(30).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
        Text('适宜区间 60~80%').fontSize(8).fontColor(COLORS.text3)
      }
      .layoutWeight(1).alignItems(HorizontalAlign.Center)
      .padding({ top: 14, bottom: 14 })
      .backgroundColor(COLORS.card).borderRadius(12)
    }
    .width('100%')

农情页的第一部分是温湿度大数字双卡。两张卡片并排排列,每张包含三行:标签(10px 灰绿)、大数值(30px 粗体)、适宜区间说明(8px 浅灰绿)。温度卡用橙色数值,湿度卡用蓝色数值,颜色语义与数据类型对应。30px 的大字号使核心数据在页面中视觉权重最高,用户一眼即可获取关键农情信息。

    Row({ space: 10 }) {
      ForEach(MINI_LIST, (m: MiniStat) => {
        Column({ space: 4 }) {
          Text(m.icon).fontSize(16)
          Text(m.val).fontSize(13).fontColor(COLORS.green).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 遍历 MINI_LIST 渲染。每张卡片三行:Emoji 图标(16px)、数值(13px 粗体绿)、名称(8px 浅灰绿)。四张卡片等宽排列,紧凑展示土壤墒情、光照强度、CO₂ 浓度、风速四项传感器数据。ForEach 的第三个参数是键值生成器 (m: MiniStat) => m.name,使用名称作为列表项的唯一标识,优化 diff 性能。

    Column({ space: 10 }) {
      Row() {
        Text('📈 近 7 日采收产量').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('单位:吨').fontSize(9).fontColor(COLORS.text3)
      }
      .width('100%')

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

      Row() {
        Text('峰值 15.8 吨').fontSize(8).fontColor(COLORS.orange)
        Text('均值 13.9 吨').fontSize(8).fontColor(COLORS.sub)
        Text('环比 +6.4%').fontSize(8).fontColor(COLORS.green)
      }
      .width('100%').justifyContent(FlexAlign.SpaceBetween)
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

第三部分是 Canvas 产量折线图卡。卡片头部是标题行:左侧"📈 近 7 日采收产量"(13px 粗体深绿)、右侧"单位:吨"(9px 浅灰绿),中间用 Column().layoutWeight(1) 撑开形成左右分布。中间是 <Canvas> 组件,绑定 lineCtx 上下文,宽度 100%、高度 180px。onReady 回调在 Canvas 初始化完成后触发,将 lineReady 设为 true 并首次调用 drawLineChart 绘制图表。卡片底部是三组统计数据,使用 justifyContent(FlexAlign.SpaceBetween) 均匀分布:峰值(橙色)、均值(灰绿)、环比增长率(绿色)。

    Row({ space: 8 }) {
      Text('🌾 长势巡田').fontSize(9).fontColor(COLORS.green)
        .layoutWeight(1).textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.greenL).borderRadius(9)
      Text('🦠 病虫预警').fontSize(9).fontColor(COLORS.red)
        .layoutWeight(1).textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.redL).borderRadius(9)
      Text('🌤️ 天气预报').fontSize(9).fontColor(COLORS.blue)
        .layoutWeight(1).textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.blueL).borderRadius(9)
      Text('📊 农技课堂').fontSize(9).fontColor(COLORS.brown)
        .layoutWeight(1).textAlign(TextAlign.Center)
        .padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.brownL).borderRadius(9)
    }
    .width('100%')
  }
  .width('100%')
}

农情页最后一部分是四个快捷入口:长势巡田(绿底)、病虫预警(红底)、天气预报(蓝底)、农技课堂(棕底)。每个入口等宽排列,文字居中,圆角 9px。颜色搭配遵循"深色文字 + 浅色背景"原则,四种颜色对应四种业务场景,视觉区分度高。

13.4 tabIrrigate 灌溉页

@Builder
tabIrrigate() {
  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%')

      Column() {
        Canvas(this.ringCtx).width(180).height(180)
          .onReady(() => {
            this.ringReady = true;
            this.drawRingChart();
          })
      }
      .width('100%').alignItems(HorizontalAlign.Center)

      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text('已灌溉').fontSize(8).fontColor(COLORS.text3)
          Text('3 块田').fontSize(11).fontColor(COLORS.green).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('计划灌溉').fontSize(8).fontColor(COLORS.text3)
          Text('5 块田').fontSize(11).fontColor(COLORS.blue).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)

        Column({ space: 3 }) {
          Text('今日用水').fontSize(8).fontColor(COLORS.text3)
          Text('126 m³').fontSize(11).fontColor(COLORS.brown).fontWeight(FontWeight.Bold)
        }
        .layoutWeight(1).alignItems(HorizontalAlign.Center)
      }
      .width('100%')
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

灌溉页的第一部分是进度环卡。卡片头部标题"💧 今日灌溉目标"与"智能滴灌"标签左右分布。中间是 <Canvas> 进度环,绑定 ringCtx 上下文,固定尺寸 180×180px,居中显示。onReady 回调设置 ringReadytrue 并首次绘制进度环。卡片底部三列统计数据:已灌溉 3 块田(绿色)、计划灌溉 5 块田(蓝色)、今日用水 126 m³(棕色)——三列颜色对应三种数据维度,数值粗体居中。

    Row() {
      Text('🚜 农田灌溉列表').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
      Column().layoutWeight(1)
      Text('共 ' + this.farmList.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%')

第二部分是列表标题行:左侧"🚜 农田灌溉列表"标题、中间农田总数统计、右侧"+ 新增农田"按钮。点击新增按钮将 addModal 设为 true,触发新增农田弹窗渲染。farmList.length.toString() 动态显示农田数量,新增或删除农田后自动更新。

    ForEach(this.farmList, (item: FarmItem, idx: number) => {
      Row({ space: 10 }) {
        Column({ space: 3 }) {
          Text(item.name).fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
          Text(item.crop + ' · ' + item.area).fontSize(8).fontColor(COLORS.text3)
            .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
        }
        .alignItems(HorizontalAlign.Start).layoutWeight(1)

        Text(item.status).fontSize(8).fontColor(farmColor(item.status))
          .padding({ left: 6, right: 6, top: 2, bottom: 2 })
          .backgroundColor(COLORS.chip).borderRadius(6)

        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.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.editName = item.name;
              this.editCrop = item.crop;
              this.editArea = item.area;
              this.editModal = true;
            })
        }
      }
      .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
    }, (item: FarmItem) => item.name + item.status)
  }
  .width('100%')
}

第三部分是农田列表行,通过 ForEach 遍历 farmList 渲染。每行包含:左侧农田信息(名称粗体 + 作物与面积副信息,maxLines(1) 限制单行并省略溢出文字)、中间状态标签(调用 farmColor 函数获取颜色,浅绿背景圆角胶囊)、右侧操作按钮组("灌溉通知"绿底按钮与"编辑"灰底按钮纵向排列)。

点击"灌溉通知"调用 publishNotice() 发布携带自定义铃声的灌溉通知。点击"编辑"将当前农田索引与属性值赋给编辑状态变量,然后打开编辑弹窗。ForEach 的键值生成器使用 item.name + item.status 组合键,确保名称或状态变化时列表项能正确 diff 更新。

13.5 tabRing 铃音页

@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.green).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.green).trackColor(COLORS.chip).blockColor(COLORS.green)
        .width('100%')
        .onChange((value: number) => {
          this.genFreq = value;
        })

铃音页的第一部分是沙箱铃声生成器卡。卡片标题"🎛️ 沙箱铃声生成器"后是频率控制区:标签"频率"与当前值(绿色粗体)左侧排列,范围提示"220 ~ 1760 Hz"右侧排列。Slider 滑块组件绑定 genFreq 状态变量,范围 220-1760 Hz,步长 20 Hz,外凸样式(OutSet)。滑块颜色配置:选中轨道绿色、未选中轨道浅绿、滑块绿色——整体绿色调与主题统一。onChange 回调在滑块拖动时更新 genFreq

      Row({ space: 8 }) {
        Text('时长').fontSize(10).fontColor(COLORS.sub)
        Text(this.genDuration.toString() + ' ms').fontSize(10).fontColor(COLORS.brown).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.brown).trackColor(COLORS.chip).blockColor(COLORS.brown)
        .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.createRingByGen();
        })
    }
    .width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)

时长控制区与频率控制区结构相同,但滑块颜色改为棕色系(selectedColor/blockColor 均为 COLORS.brown),与频率滑块视觉区分。时长范围 600-2400 ms,步长 100 ms。底部"生成铃声到沙箱"按钮是绿色实心按钮,点击调用 createRingByGen() 根据当前频率与时长生成 WAV 音频并写入沙箱,同时在铃声库列表末尾添加新条目。

    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.green).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)

第二部分是当前默认铃声卡。卡片头部标题与沙箱文件计数左右分布。中间显示当前铃声名称(15px 粗体绿),通过三元表达式安全访问 ringList 数组,索引越界时显示"暂无铃声"。底部显示 getSoundValue() 返回的 sound 字段实际值——即 'uri::' + 文件URI 字符串,用浅绿背景芯片包裹,最多显示两行,溢出部分省略。这个设计让开发者与高级用户能直观看到通知 sound 字段的底层取值,理解自定义铃声的技术机制。

    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.brown)
                .layoutWeight(1).textAlign(TextAlign.Center)
                .padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.brownL).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)

第三部分是铃声库列表。每个铃声条目包含两行:第一行是铃声信息(🎵 图标 + 名称 + 频率/时长/大小副信息 + 沙箱状态标签),如果当前条目是默认铃声则名称旁显示绿色"默认"标签;第二行是操作按钮组,三个按钮等宽排列:未导入时显示"导入沙箱"棕色按钮(已导入时隐藏),"设为默认"绿色按钮始终显示,"删除"红色按钮始终显示。点击"导入沙箱"调用 importRingToSandbox,点击"设为默认"调用 setCurrentRing,点击"删除"设置 delIdx 并打开删除确认弹窗。

    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.brown).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)

铃音页最后一部分是发布通知卡。标题"📣 发布灌溉通知"后是功能说明文字(两行省略),底部是棕色实心"立即发布通知"按钮,点击调用 publishNotice() 发布携带当前默认铃声的灌溉通知。最底部是通知 ID 与已发送条数的统计信息,左右分布。棕色按钮在铃音页的绿色主调中形成视觉对比,提示用户这是"行动入口"。

13.6 tabMine 我的页

@Builder
tabMine() {
  Column({ space: 12 }) {
    Column({ space: 8 }) {
      Text('🌾 丰源家庭农场').fontSize(11).fontColor('rgba(255,255,255,0.7)')
      Text('¥ 286,400').fontSize(30).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
      Text('本月产值 · 同比 +14.2%')
        .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('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('9 种').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('6 年').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.brown, 1]] })

我的页的第一部分是农场渐变大卡——这是整个应用视觉冲击力最强的组件。卡片使用 linearGradient 线性渐变背景,135 度角从深绿(COLORS.greenD #16A34A)过渡到麦秆棕(COLORS.brown #A16207),形成"从绿到金"的丰收色调。卡片内文字均使用白色或半透明白色(rgba(255,255,255,0.7)),在深色渐变背景上保持高对比度。

卡片内容从上到下:农场名称"丰源家庭农场"(11px 半透明白)、本月产值"¥ 286,400"(30px 粗体纯白,全卡最大字号)、产值同比信息(9px 半透明白),底部三列统计:种植面积 128 亩、在田作物 9 种、服务年限 6 年。这张卡片浓缩了农场的核心经营指标,渐变背景营造高级感与品牌感。

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

第二部分是农场经营统计清单,通过 ForEach 遍历 statList 渲染。每行包含:左侧 Emoji 图标(16px)、中间统计项名称(粗体深绿)与标签描述(浅灰绿)、右侧统计数值(13px 粗体绿)。白色背景圆角卡片,简洁的信息列表布局。

    Column({ space: 6 }) {
      Row() {
        Text('🏡 农场档案').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
        Column().layoutWeight(1)
        Text('正常运营').fontSize(9).fontColor(COLORS.green)
          .padding({ left: 8, right: 8, top: 3, bottom: 3 })
          .backgroundColor(COLORS.greenL).borderRadius(8)
      }
      .width('100%')
      Text('山东寿光 · 智能连栋大棚 12 座 · 溯源码已开通')
        .fontSize(9).fontColor(COLORS.sub)
        .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
  }
  .width('100%')
}

最后一部分是农场信息卡,标题"🏡 农场档案"右侧是绿色"正常运营"状态标签,底部是农场地址与设施信息(单行省略)。这张卡片提供了农场的"身份信息",在实际应用中可链接到更详细的农场档案页面。

13.7 chartCard 月度图表

@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.green)
          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 根柱子,每根柱子是一个 Column 组件,宽度 16px,高度通过 MONTH_VAL[i] / MONTH_MAX * 110 计算(最大 110vp),最小高度 20vp 保证空柱可见。最末柱(8 月)在 breathtrue 时高度额外加 4vp 并设最小值 24vp,实现呼吸放大动效——这是唯一的非 Canvas 图表动效,使用 ArkUI 组件的 height 属性绑定 breath 状态实现。柱子使用绿色背景、4px 圆角,底部是月份标签。RowalignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,形成标准柱状图效果。

13.8 tabBar 底部导航

@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 项是 Column 布局,包含 Emoji 图标与文字标签。选中态(currentTab === idx)与未选中态的视觉差异:图标字号 20 vs 17、不透明度 1 vs 0.65、文字颜色绿色 vs 浅灰绿、字重粗体 vs 常规。四个 Tab 等宽排列(layoutWeight(1)),点击时更新 currentTab 触发页面切换。导航栏白色背景,顶部有 1px 浅绿分割线。

十四、弹窗系统

14.1 modalOverlay 遮罩

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

modalOverlay 是弹窗遮罩的通用 Builder 函数,接收一个 onClose 回调参数。它是一个全屏 Stack,内部有一个全屏 Column 设置半透明深绿背景色(COLORS.maskrgba(27,58,42,0.5))。alignContent(Alignment.Center) 使后续叠加的弹窗内容居中显示。点击遮罩区域触发 onClose 回调关闭弹窗——这是移动端弹窗的标准交互模式,用户点击遮罩即可关闭弹窗,无需专门找"关闭"按钮。

这个 Builder 函数在 panelAddpanelEditpanelDel 三个弹窗中被复用,通过传入不同的 onClose 回调实现各自弹窗的关闭逻辑,体现了 Builder 函数的复用价值。

14.2 panelAdd 新增农田弹窗

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

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

      Column({ space: 6 }) {
        Text(FORM_LABELS[1]).fontSize(9).fontColor(COLORS.sub)
        TextInput({ text: this.formCrop, placeholder: '如 草莓 / 樱桃番茄' })
          .fontSize(11).fontColor(COLORS.title)
          .backgroundColor(COLORS.chip).borderRadius(8)
          .onChange((value: string) => {
            this.formCrop = value;
          })
      }
      .width('100%').alignItems(HorizontalAlign.Start)

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

panelAdd 是新增农田弹窗面板。Stack 内先渲染 modalOverlay 遮罩,再叠加弹窗内容 Column(宽度 78%、白色背景、圆角 14px)。弹窗内容包含:标题"新增农田"、三个表单字段组(标签 + TextInput 输入框)、底部按钮行(取消灰底 + 保存绿底)。

每个 TextInput 绑定对应的 @State 表单变量(formName/formCrop/formArea),onChange 回调实时更新状态值。placeholder 提供输入示例引导用户填写。点击"取消"调用 onClose 关闭弹窗,点击"保存"调用 saveFarm() 执行新增逻辑(包含非空校验与数据写入)。三个表单字段的标签来自 FORM_LABELS 常量数组,保证文案统一。

14.3 panelEdit 编辑农田弹窗

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

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

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

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

panelEdit 是编辑农田弹窗面板,结构与 panelAdd 几乎相同——差异仅在于标题改为"编辑农田"、TextInput 绑定的状态变量改为 editName/editCrop/editArea、保存按钮调用 updateFarm() 而非 saveFarm()。编辑弹窗打开前,tabIrrigate 中的"编辑"按钮 onClick 会将当前农田项的属性值赋给 editName/editCrop/editArea,实现表单回填——用户看到的输入框中已填入当前农田的信息,修改后点击保存即可更新。

14.4 panelDel 删除铃声确认弹窗

@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 是删除铃声确认弹窗,结构比新增/编辑弹窗更简洁——没有表单输入,只有确认提示文字与按钮。提示文案明确告知用户"删除后将同步清理沙箱中的音频文件,且不可恢复",强调操作的不可逆性。底部按钮行:"取消"灰底按钮调用 onClose 关闭弹窗,"确认删除"红底按钮调用 delRing() 执行删除(包含沙箱文件清理与列表数据移除)。红色按钮在视觉上传达"危险操作"的警示信号,符合用户对删除操作的色彩心理预期。

十五、技术特性对比表格

技术维度 传统通知方案 本应用方案(Notification Kit 6.1.1) 优势说明
通知铃声来源 系统预设音效,不可自定义 应用沙箱 EL1 区域动态生成的 WAV 音频 支持"声音即语义",不同业务通知对应不同铃声
音频生成方式 预置资源文件,打包在 APP 中 运行时通过 buildWavBytes 函数动态生成 PCM 字节 无需预置资源,频率与时长完全由代码控制
音频文件存储 应用资源目录(只读) 应用沙箱 filesDir 目录(可读写) 支持运行时创建、更新、删除音频文件
sound 字段格式 资源 ID 引用 uri:: 前缀 + fileUri.getUriFromPath 转换的 URI 突破预设资源限制,指向任意沙箱音频文件
授权策略 单次请求,被拒后无法再引导 双路径:先 requestEnableNotification,被拒后 openNotificationSettings 提供二次授权入口,提升授权转化率
Canvas 图表 依赖第三方图表库 原生 Canvas 2D API 手绘 无第三方依赖,包体积更小,绘制逻辑完全可控
图表动画 静态图表或复杂动画框架 setInterval 每秒翻转 breath 状态驱动重绘 实现极简,双 Canvas 联动重绘,视觉效果生动
状态管理 命令式手动刷新 UI @State + @Observed + @Builder 声明式驱动 数据变化自动触发 UI 刷新,开发效率高
弹窗架构 独立页面跳转或第三方弹窗组件 Stack 包裹 + @State 布尔开关 + Builder 函数 原生实现,遮罩点击关闭,复用 modalOverlay
主题色板 硬编码颜色值散落各处 ColorPalette 接口 + COLORS 常量集中管理 类型安全,主题切换只需替换常量对象
数据模型 普通对象或接口 @Observed 装饰器类 + @State 数组绑定 属性级响应式追踪,列表项修改自动刷新
Tab 导航 第三方 Tab 组件 ForEach + currentTab 状态 + 条件渲染 Builder 原生实现,每个 Tab 布局完全独立,互不干扰

十六、总结

本文深入剖析了一个基于 HarmonyOS ArkUI 框架构建的数字农业 IoT SaaS 应用——“农丰云·智慧农业平台”。该应用以田园绿色为视觉基调,通过底部四 Tab 架构(农情/灌溉/铃音/我的)组织了完整的农业种植管理功能体系,每个 Tab 页面的布局结构完全不同,既保持了导航的一致性,又为不同业务场景提供了最优的信息呈现方式。

在通知能力方面,本应用充分运用了 Notification Kit 6.1.1 的自定义铃声特性——通过 buildWavBytes 函数在内存中动态构建 16bit 单声道 PCM WAV 音频字节序列,写入应用沙箱 EL1 区域的 filesDir 目录,再通过 fileUri.getUriFromPath 将文件路径转换为 uri:: 前缀的 URI 字符串填入 NotificationRequest.sound 字段,实现了通知铃声的完全自定义。这种"运行时音频生成 + 沙箱文件写入 + URI 转换 + 通知发布"的全链路方案,突破了传统通知只能使用系统预设铃声的限制,为 IoT 场景中的"声音语义化"提供了底层能力支撑。不同频率的提示音对应不同的业务通知——低频沉稳如夜雨滴答适合夜间静默提醒,高频明亮如除虫蜂鸣适合紧急告警,管护人员凭声音即可判断业务类型,无需查看屏幕。

在数据可视化方面,本应用通过原生 Canvas 2D API 手绘了两种图表——产量折线图与灌溉进度环。折线图利用 createLinearGradient 创建从绿色到透明的线性渐变填充面积区域,末端数据节点通过呼吸动画半径变化实现动态视觉效果;进度环使用 arc 方法分别绘制底环与进度弧,通过 lineCap = 'round' 实现圆头端点,中心叠加百分比文本。两个 Canvas 各自维护 ready 就绪标志,在每秒触发的呼吸定时器中联动重绘,形成"活"的数据面板。此外,月度柱状图通过 ArkUI 组件的 height 属性绑定 breath 状态实现末柱呼吸放大,展示了非 Canvas 图表的动效实现路径。

在架构设计方面,本应用体现了多个值得借鉴的工程实践。颜色系统通过 ColorPalette 接口与 COLORS 常量集中管理 23 个主题色字段,确保色彩使用的一致性与可维护性;数据模型使用 @Observed 装饰器修饰 FarmItemRingItemStatItem 三个业务实体类,实现属性级的响应式追踪;弹窗系统使用 Stack 包裹主内容与三层弹窗面板,通过 @State 布尔开关控制显隐,复用 modalOverlay 通用遮罩 Builder 函数;通知授权采用"先请求、被拒后引导设置"的双路径策略,提升授权转化率。这些设计模式不仅适用于农业 IoT 应用,也可迁移到其他 HarmonyOS 原生应用的开发中。

从业务价值角度看,这个应用构建了从"数据采集"到"决策触达"的完整闭环。农情 Tab 通过温湿度大数字卡、四格传感器小卡与产量折线图,回答"现在地里怎么样"的问题;灌溉 Tab 通过进度环与农田列表,回答"今天该浇什么"的问题并直接触发灌溉通知;铃音 Tab 通过生成器、铃声库与发布入口,回答"通知怎么响才对"的问题;我的 Tab 通过渐变大卡与统计清单,回答"经营得怎么样"的问题。四个 Tab 各司其职,又通过共享的状态变量(如 sentCount 在头部与铃音页均有展示)形成数据联动,体现了 SaaS 应用"模块解耦、状态共享"的架构哲学。

最后,HarmonyOS ArkUI 框架的声明式开发范式、@State/@Observed/@Builder 装饰器体系、Canvas 2D 绘图能力与 Notification Kit 的沙箱自定义铃声特性,共同构成了构建高质量原生应用的技术基石。随着 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、测试、元服务和应用上架分发等。

更多推荐