铃声定制化通知与深色科技蓝 Dashboard 的碰撞——HarmonyOS ArkUI 智慧餐饮门店 SaaS 平台的工程化实践
一、技术前言

在鸿蒙生态全面铺开的当下,HarmonyOS 的 ArkUI 声明式开发框架已经从一个"新框架"成长为能够承载复杂商业应用落地的成熟 UI 引擎。与传统的 Android View 体系或 iOS UIKit 不同,ArkUI 采用了与 SwiftUI、Jetpack Compose、Flutter 一脉相承的声明式范式——开发者只需描述 UI"应该是什么样子",框架自身负责将状态变更高效地映射到组件树的精确更新上。这种范式不仅大幅减少了命令式编程中的样板代码,更重要的是它天然契合响应式数据流的理念,使得数据与视图之间的绑定关系变得清晰而可预测。在本篇博文中,我们将深入剖析一款基于 ArkUI 构建的"智赢门店·门店经营平台"——一个面向智慧餐饮门店场景的 SaaS 化管理应用,从架构设计、通知铃声定制、Canvas 图表绘制、弹窗系统到深色主题色彩工程,逐段拆解其技术实现。

Notification Kit 是 HarmonyOS 系统级通知能力的核心 SDK,它提供了从通知授权、通知发布到通知槽位管理的完整能力链。在 HarmonyOS 6.1.1 版本中,Notification Kit 引入了一项令开发者振奋的新特性——应用可以将自身沙箱内的音频文件指定为通知的自定义铃声。这意味着,应用不再受限于系统预置的几种通知音效,而是可以为不同的业务场景(如收银到账、订单出餐、迎客进门等)生成并播放不同的音频提示音。技术上,这一能力通过 NotificationRequest 的 sound 字段实现,其值需采用 'uri::' + fileUri.getUriFromPath(沙箱音频路径) 的格式,将应用 EL1 沙箱 filesDir 目录下的音频文件路径转换为系统可识别的 URI。这个特性在餐饮门店场景中有着天然的契合度——取餐通知需要清晰、醒目且可区分的铃声来提醒顾客,而不同门店、不同时段可能需要不同的铃声方案,自定义通知铃声恰好满足了这一需求。

Canvas 绘图能力是 ArkUI 提供的原生 2D 绘图接口,它通过 Canvas 组件暴露 CanvasRenderingContext2D 上下文,支持路径绘制、渐变填充、文本渲染、图像合成等与 Web Canvas 2D API 高度兼容的操作。在本应用中,Canvas 被用于绘制近 7 日营业额折线图,涵盖了网格线绘制、线性渐变面积填充、折线描边、数据节点圆形绘制、横轴标签渲染等多个绘图原语的组合运用。更巧妙的是,该折线图与全局的呼吸动画定时器联动——每秒重绘一次,末端数据节点的半径在 3px 与 5px 之间交替变化,形成"呼吸"的视觉效果。这种将 Canvas 命令式绘图与 ArkUI 声明式状态驱动相结合的混合编程模式,是鸿蒙原生应用中数据可视化领域的一种典型实践。

从业务场景来看,餐饮门店经营是一个高度碎片化、多角色协作的复杂领域。一个典型的门店日常运营涉及订单流水追踪(今日多少单、每单多少金额、各订单处于什么状态)、营业额实时监控(今日营收、周环比、月趋势)、取餐通知推送(出餐后向顾客发送通知)、铃声个性化管理(不同场景配不同提示音)、经营数据汇总(累计订单、好评率、翻台效率、会员复购率)等多个维度的管理需求。传统餐饮 SaaS 应用往往将这些功能拆分到多个独立页面甚至多个子应用中,而本应用选择了一个更具挑战性的方案——在单个页面内通过 4 个 Tab 切换实现全部功能,且每个 Tab 的布局风格完全不同。概览页是"大数字 + 四格小卡 + Canvas 折线图 + 快捷操作"的仪表盘式布局;订单页是"竖向时间轴 + 固定行高订单卡 + 取餐通知按钮"的流水式布局;铃音页是"铃声生成器 + 当前铃声 + 铃声库 + 发布通知"的配置式布局;我的页是"渐变大卡 + 统计清单 + 门店信息"的总结式布局。这种"一页多态"的设计哲学,本质上是对"不同业务场景需要不同的信息表达方式"这一设计原则的深度践行。

从设计理念来看,本应用采用了深色科技蓝(#0A1628)作为底色、科技蓝(#4C8DFF)作为主操作色、香槟金(#F5C451)作为高亮数据色的暗色主题方案。这种配色不是随意的选择——深色背景在餐饮门店的强光环境下(厨房灯光、户外阳光)具有更好的可读性,减少屏幕反光对视觉的干扰;科技蓝传达专业、可靠的 SaaS 工具气质;香槟金则在视觉焦点处(如今日营收大数字、当前默认铃声名)形成温暖且高端的视觉锚点。整个色彩体系通过 ColorPalette 接口集中管理 22 个颜色字段,每一组色系都遵循"主色 + 深色变体 + 浅色变体"的三级结构,确保了深色主题下文字可读性、按钮可辨识性和状态可区分性的平衡。

此外,本应用在工程化层面也体现了良好的实践。数据模型使用 @Observed 装饰器标注,为响应式数据更新做好了准备;状态管理通过 @State 统一管理,涵盖 Tab 索引、弹窗开关、授权状态、铃声索引、生成器参数等 20 余个状态变量;生命周期函数 aboutToAppear / aboutToDisappear 负责授权查询和定时器创建/清理;弹窗系统通过 Stack 层叠包裹,用三个独立的面板构建函数管理新增订单、编辑订单、删除铃声三种交互场景。这些工程化细节虽然不似视觉设计那样直观,但正是它们支撑了应用在功能完整性、交互流畅度和代码可维护性上的平衡。下面,我们将从代码的第一行开始,逐段深入分析这个智慧餐饮门店经营平台的完整实现。
二、整体架构流程图
为了更好地理解"智赢门店"应用的整体架构和各模块间的数据流向,我们使用以下 Mermaid 流程图来展示组件之间的关系:
从上述架构流程图可以清晰地看到,整个应用以主页面组件为核心枢纽,向下连接了状态管理层、数据层、通知与沙箱能力层、UI 构建层、弹窗层和 Canvas 绘图层六个子系统。状态管理层是整个应用的"神经中枢",20 余个 @State 变量驱动了从 Tab 切换到弹窗显示、从呼吸动画到通知发布的全部交互行为。
数据层通过 ColorPalette 接口定义统一的色彩体系,3 个 @Observed 数据模型为响应式更新做好准备,Mock 数据在初始化时注入列表状态变量。通知与沙箱能力层是本应用最具技术深度的子系统——它串联了从通知授权请求、音频字节生成、沙箱文件写入、URI 转换到通知发布的完整链路,是 HarmonyOS 6.1.1 自定义通知铃声特性的完整工程化落地。
UI 构建层包含 7 个 @Builder 函数,头部、4 个 Tab 内容、通用图表和底部导航各司其职。弹窗层通过 modalOverlay 统一遮罩管理,三个面板分别处理增/改/删三种交互。Canvas 绘图层通过 drawLineChart 方法实现命令式绘图,与呼吸动画定时器联动实现动态重绘。各层之间通过状态变量和数据流紧密耦合,形成了一个完整的应用生态。
三、文件头部注释与设计意图
/**
* =====================================================================
* 智赢门店 · 门店经营平台(现代行业:智慧餐饮门店 SaaS)深色主题
* 头部样式:应用名+副题+通知授权呼吸胶囊,下排 3 个经营数据小卡
* 布局风格:4 个 Tab 每个布局完全不同
* 概览=今日营收大数字卡+4 格小数据卡+Canvas 营业额周折线图+快捷操作
* 订单=竖向时间轴(时间+圆点竖线+订单卡,固定行高72,取餐通知按钮)
* 铃音=铃声生成器+当前默认铃声+铃声库(导入沙箱/设默认/删除)+发布通知卡
* 我的=营业额渐变大卡+经营统计清单
* Notification 特性(HarmonyOS 6.1.1 新特性):应用沙箱文件作为通知自定义铃声
* NotificationRequest.sound = 'uri::' + fileUri.getUriFromPath(沙箱音频路径)
* 音频须放沙箱 EL1 区域 files 目录(模拟网络下载/用户生成的音频文件)
* Canvas 图表:概览页近 7 日营业额折线图 drawLineChart()(呼吸动画联动重绘)
* 弹窗系统:build() 用 Stack() 包裹;panelAdd 新增订单 / panelEdit 编辑订单 / panelDel 删除铃声
* 底部 4 Tab 单排,主题:科技蓝(#4C8DFF) + 香槟金(#F5C451) + 深色背景(#0A1628)
* =====================================================================
*/
这段文件头部注释是整个源文件的"设计蓝图",在团队协作和代码维护中承担着架构文档的作用。下面逐块解读其设计意图:
“智赢门店·门店经营平台(现代行业:智慧餐饮门店 SaaS)深色主题”:开篇即定位了应用名称、行业领域、产品形态和视觉基调。"智赢门店"四字浓缩了"智慧经营、赢在门店"的产品愿景;"智慧餐饮门店 SaaS"明确了这是一个面向餐饮行业的 SaaS 化工具,而非单一门店的本地应用——SaaS 的定位意味着设计上要考虑多门店、多角色的通用性;"深色主题"则直接指明了整体的视觉方向,与浅色主题的餐饮管理工具形成差异化。
“头部样式:应用名+副题+通知授权呼吸胶囊,下排 3 个经营数据小卡”:描述了头部区域的视觉结构。头部是用户进入应用后看到的第一屏信息,需要在有限空间内传达应用身份、授权状态和核心经营数据三个维度的信息。通知授权呼吸胶囊是一个巧妙的设计——它既是状态指示器(已授权/未授权),又是操作入口(点击授权),还通过呼吸动画(透明度交替)吸引用户注意,实现了一箭三雕的信息传达效率。下排 3 个经营数据小卡则提供了今日订单、今日营收、已发通知三个即时性指标的快速概览。
“布局风格:4 个 Tab 每个布局完全不同”:这是整个应用最核心的设计理念声明。4 个 Tab 分别采用仪表盘式、流水时间轴式、配置管理式、总结清单式四种截然不同的布局风格。这种设计背后的哲学是:不同的业务数据有不同的信息特征和用户阅读模式。概览页需要"一眼看清全局",所以用大数字 + 小卡 + 趋势图的层次;订单页需要"按时间顺序追踪",所以用竖向时间轴;铃音页需要"配置和管理资源",所以用生成器 + 列表的配置式布局;我的页需要"汇总和回顾",所以用渐变大卡 + 统计清单。每个布局都是为其业务场景量身定制的最优表达。
“Notification 特性(HarmonyOS 6.1.1 新特性)”:这是本应用最具技术深度的部分。注释明确指出了核心技术点——NotificationRequest.sound 字段接受 'uri::' + fileUri.getUriFromPath(沙箱音频路径) 格式的值,音频文件必须放置在应用沙箱 EL1 区域的 files 目录下。注释还特别说明这些音频"模拟网络下载/用户生成的音频文件"——在真实场景中,门店可能从云端下载铃声包,或由用户自行录制/生成,最终都落地到沙箱目录中。这种设计在 Demo 中通过 buildWavBytes 函数动态生成 WAV 字节来模拟。
“Canvas 图表:概览页近 7 日营业额折线图 drawLineChart()(呼吸动画联动重绘)”:指出了 Canvas 绘图的核心特性——折线图不是静态的,而是与全局呼吸动画定时器联动的。每秒定时器触发 breath 状态翻转,同时调用 drawLineChart() 重绘整个图表,末端数据节点的半径在大小之间交替变化,形成"呼吸"效果。这种将命令式 Canvas 绘图与声明式状态驱动混合使用的技术模式,是 ArkUI 数据可视化的一种高级实践。
“底部 4 Tab 单排,主题:科技蓝(#4C8DFF) + 香槟金(#F5C451) + 深色背景(#0A1628)”:明确了导航和色彩方案。4 Tab 单排是移动端最常见的导航模式,在有限的底部空间内容纳概览、订单、铃音、我的四个入口。色彩方案的三色组合形成了清晰的视觉层次:深色背景打底、科技蓝承担交互操作色、香槟金承担数据高亮色,三者各司其职。
四、颜色系统设计(ColorPalette 接口与 COLORS 常量)
4.1 色彩接口定义
/** 主题色板接口:集中声明页面所有颜色字段 */
interface ColorPalette {
bg: string;
card: string;
chip: string;
title: string;
sub: string;
text3: string;
white: string;
blue: string;
blueD: string;
blueL: string;
gold: string;
goldD: string;
goldL: string;
green: string;
greenL: string;
red: string;
redL: string;
purple: string;
purpleL: string;
orange: string;
line: string;
tabOn: string;
mask: string;
}
ColorPalette 接口定义了 22 个颜色字段,是整个深色主题视觉体系的基石。在 ArkUI 中使用 TypeScript 的 interface 来约束颜色常量对象的结构,确保所有颜色引用都有明确的语义名称,而非散落在代码各处的硬编码色值。这种做法的好处在于:当需要调整主题色调时,只需修改一个常量对象,所有引用处自动更新,避免了"改了一个色值,全局搜索替换"的低效操作。下面逐一解读每个字段的设计意图:
bg(背景色):页面最底层的背景色,采用深色 #0A1628。这个值不是纯黑,而是带有深蓝调的近黑色,在视觉上比纯黑更柔和,同时保留了深色主题的高对比度优势。
card(卡片色):用于卡片容器的背景色,比背景色稍亮的深蓝 #12233F。在深色主题中,卡片色需要与背景色有足够的区分度,使得"内容浮于背景"的层次感得以体现,同时又不能太亮以免造成视觉割裂。
chip(芯片色):用于标签、小型区块的背景色,介于 bg 和 card 之间的 #1A2E52。芯片色常用于表单输入框背景、未选中状态标签等元素,是"比卡片更深一层"的容器色。
title、sub、text3(文字层级):定义了文字的三级色彩层次。title 是 #E8F0FF(近白偏蓝),用于主要标题和关键数据;sub 是 #7E96C4(灰蓝),用于副文本和说明文字;text3 是 #4E6494(深灰蓝),用于辅助信息和最弱层级的提示文字。三级色彩层次在深色主题中尤为关键——合理的文字层次能够在暗色背景上创造清晰的信息优先级。
white(纯白色):用于需要最高对比度的文字,如按钮上的文字、渐变卡上的数字等。在深色主题中,纯白色的使用需要克制,否则会造成视觉刺眼。
blue、blueD、blueL(科技蓝色系):品牌主色调的三个层级。blue(#4C8DFF)是标准科技蓝,用于按钮、选中状态、链接等需要吸引注意力的交互元素;blueD(#3B6FE0)是深科技蓝,用于渐变起始色和需要更深色调的场景;blueL(#10224A)是浅科技蓝(实际是极深蓝),用作按钮的浅色背景变体。
gold、goldD、goldL(香槟金色系):高亮数据色的三个层级。gold(#F5C451)是标准香槟金,用于今日营收大数字、当前铃声名称等需要视觉锚点的关键数据;goldD(#D9A631)是深香槟金,用于渐变和深色变体;goldL(#2E2612)是极深的暗金色,用作金色按钮的浅色背景变体——在深色主题中,"浅色背景变体"实际上是更深的暗色。
green、greenL(绿色系):功能色之一,用于"已完成"状态、增长指标、成功提示等场景。green(#4ADE80)是鲜亮绿色,greenL(#0A2E1A)是极深绿色,用作绿色按钮的背景。
red、redL(红色系):功能色之一,用于"已取消"状态、删除操作、负向指标等场景。red(#F87171)是柔和红色,redL(#2E1212)是极深红色,用作红色按钮的背景。
purple、purpleL(紫色系):功能色之一,用于渐变大卡("我的"页面的营业额渐变卡)的渐变终止色。purple(#A78BFA)是柔和紫色,purpleL(#1E1B38)是极深紫色背景。
orange(橙色系):功能色之一,用于"未导入"状态标签,与"沙箱中"的绿色形成对比。橙色在视觉上介于金色和红色之间,传达"需关注但非紧急"的语义。
line(线条色):用于分割线、边框、时间轴竖线等分隔元素,采用 #1A2E52 的深蓝色,与芯片色相同,起到低调的分隔作用。
tabOn(Tab 选中色):Tab 选中状态的文字和图标颜色,采用科技蓝 #4C8DFF,与品牌主色一致。
mask(遮罩色):弹窗出现时覆盖在内容之上的半透明遮罩层,采用 rgba(0,0,0,0.55) 的半透明黑色。55% 的不透明度是一个经验值——既能有效遮挡底层内容使弹窗成为视觉焦点,又不会完全遮断背景,保持了一定的空间纵深感。
4.2 深色主题常量定义
/** 深色主题色板常量(智赢门店 · 科技蓝 + 香槟金) */
const COLORS: ColorPalette = {
bg: '#0A1628',
card: '#12233F',
chip: '#1A2E52',
title: '#E8F0FF',
sub: '#7E96C4',
text3: '#4E6494',
white: '#FFFFFF',
blue: '#4C8DFF',
blueD: '#3B6FE0',
blueL: '#10224A',
gold: '#F5C451',
goldD: '#D9A631',
goldL: '#2E2612',
green: '#4ADE80',
greenL: '#0A2E1A',
red: '#F87171',
redL: '#2E1212',
purple: '#A78BFA',
purpleL: '#1E1B38',
orange: '#FB923C',
line: '#1A2E52',
tabOn: '#4C8DFF',
mask: 'rgba(0,0,0,0.55)'
};
COLORS 常量是 ColorPalette 接口的具体实现,它将 22 个颜色字段绑定到具体的色值上。这个常量在整个文件中作为唯一的颜色来源被引用,任何组件需要使用颜色时都通过 COLORS.xxx 的方式获取。这种集中式颜色管理的优势在于:
第一,主题一致性有保障。由于所有颜色都来自同一个常量对象,不会出现"某个按钮用了 #4C8DFF,另一个按钮用了 #4D8DFF"这种细微色差问题。
第二,主题切换成本低。如果未来需要支持浅色主题或自定义主题,只需创建一个新的 ColorPalette 实现并在运行时切换引用即可,无需修改任何组件代码。
第三,语义化引用提升可读性。代码中 COLORS.gold 比 '#F5C451' 更能表达"这是一个用于高亮关键数据的金色"的设计意图,使代码本身成为设计文档的一部分。
值得注意的是,深色主题中的"浅色变体"(如 blueL、goldL、greenL)实际上是非常深的暗色。这是因为这些颜色通常用作按钮的背景色——在深色主题中,一个蓝色文字按钮的背景不应该用浅蓝色(那样会与浅色主题混淆),而应该用"极深的蓝色",使得蓝色文字在深蓝背景上形成"暗色中的色彩对比"。这是深色主题色彩工程中一个容易踩坑的设计点。
五、常量定义与数据配置
5.1 Tab 导航配置
/** Tab 元数据接口:底部导航图标 + 标签 */
interface TabMeta {
icon: string;
label: string;
}
/** 底部导航 Tab 常量列表(4 Tab 单排) */
const TAB_LIST: TabMeta[] = [
{ icon: '📊', label: '概览' },
{ icon: '🧾', label: '订单' },
{ icon: '🔔', label: '铃音' },
{ icon: '👤', label: '我的' }
];
TabMeta 接口定义了底部导航中每个 Tab 的元数据——图标(Emoji)和标签(文字)。TAB_LIST 常量数组按顺序定义了 4 个 Tab:概览(📊 图表)、订单(🧾 收据)、铃音(🔔 铃铛)、我的(👤 人形)。
使用 Emoji 作为 Tab 图标是一个轻量级的选择——在 Demo 应用中,不需要引入图标字体或 SVG 资源文件,Emoji 天生支持彩色且跨平台一致性较好。每个 Tab 的标签都是两字词语,在底部有限的空间内保持简洁。Tab 的顺序也经过了考量:概览是"总览全局"的入口,放在最左侧;订单是"日常高频"操作,放在第二位;铃音是"配置管理"操作,放在第三位;我的是"个人数据"入口,放在最右侧——这与大多数移动应用的导航习惯一致。
5.2 概览页小数据卡配置
/** 概览小卡数据接口:图标 + 名称 + 数值 */
interface MiniStat {
icon: string;
name: string;
val: string;
}
/** 概览页四格经营小数据卡 */
const MINI_LIST: MiniStat[] = [
{ icon: '🧾', name: '今日订单', val: '128' },
{ icon: '🍚', name: '出餐份数', val: '316' },
{ icon: '🪑', name: '翻台率', val: '5.2' },
{ icon: '😊', name: '好评率', val: '98%' }
];
MiniStat 接口定义了概览页四格小数据卡的元数据结构。MINI_LIST 包含 4 条数据:今日订单 128 单、出餐份数 316 份、翻台率 5.2 次、好评率 98%。
这四个指标的选择涵盖了餐饮门店经营的核心维度——订单量(销售规模)、出餐量(产能利用)、翻台率(空间效率)、好评率(顾客满意度)。翻台率 5.2 是一个相当优秀的数值(一般 3-5 为正常水平),好评率 98% 也暗示了这是一家运营良好的门店。这些 Mock 数据虽然不是真实数据,但数值设定合理且有故事性,使得 Demo 更具真实感。
5.3 折线图数据配置
/** 近 7 日营业额数据(Canvas 折线图) */
const LINE_DATA: number[] = [8200, 9100, 7600, 10500, 9800, 11200, 10600];
/** 折线图纵轴最大值 */
const LINE_MAX: number = 12000;
/** 折线图横轴星期标签 */
const LINE_LABELS: string[] = ['一', '二', '三', '四', '五', '六', '日'];
这三组常量共同定义了 Canvas 折线图的完整数据。LINE_DATA 是 7 个数据点,对应周一到周日的营业额。从数据趋势来看,周三(7600)是一个低谷,周六(11200)是峰值——这符合餐饮门店"工作日低、周末高"的典型经营规律。LINE_MAX 设为 12000,略高于最大值 11200,使得最高点不会顶到图表上边界,留出视觉余量。LINE_LABELS 是单字星期标签,简洁清晰。
将图表数据从绘图逻辑中分离出来是一个好的工程实践——如果未来需要接入真实数据,只需替换 LINE_DATA 的数据源即可,绘图函数 drawLineChart() 无需修改。LINE_MAX 作为独立常量而非自动计算最大值,是因为在实际应用中,纵轴的最大值可能需要"取整"(如 12000 而非 11200)以获得更美观的网格线刻度。
5.4 月度营收与表单常量
/** 月度营收柱状图月份索引 */
const MONTH_IDX: number[] = [0, 1, 2, 3, 4, 5];
/** 月度柱状图月份名称 */
const MONTH_NAME: string[] = ['3月', '4月', '5月', '6月', '7月', '8月'];
/** 月度营收数值(万元) */
const MONTH_VAL: number[] = [21.6, 24.8, 19.5, 27.2, 30.6, 28.9];
/** 月度柱状图最大值 */
const MONTH_MAX: number = 32;
/** 新增订单表单标签数组 */
const FORM_LABELS: string[] = ['下单时间', '菜品内容', '订单金额'];
月度营收数据包含了 6 个月(3 月至 8 月)的营业额数值,以"万元"为单位。从趋势来看,5 月是一个低谷(19.5 万),7 月达到峰值(30.6 万),整体呈波动上升趋势。MONTH_MAX 设为 32 万,为最大值 30.6 留出了约 5% 的视觉余量。MONTH_IDX 使用索引数组而非直接遍历,是为了在 ForEach 中提供稳定的键值。
FORM_LABELS 是新增/编辑订单弹窗中三个表单字段的标签——下单时间、菜品内容、订单金额。将标签提取为常量数组,使得新增和编辑两个弹窗可以共享同一组标签,保证了表单标签的一致性,同时也便于国际化扩展。
六、辅助函数设计
6.1 订单状态颜色映射
/** 订单状态色映射:已完成绿 / 进行中蓝 / 已取消红 / 其余弱化 */
function statusColor(s: string): string {
if (s === '已完成') {
return COLORS.green;
} else if (s === '进行中') {
return COLORS.blue;
} else if (s === '已取消') {
return COLORS.red;
}
return COLORS.text3;
}
statusColor 是一个纯函数,将订单状态文本映射为对应的主题色值。这种"业务语义到视觉表现"的映射函数在应用中扮演着重要的角色——它将"已完成"“进行中”"已取消"这三个业务概念分别关联到绿色、蓝色、红色三个视觉概念,使得用户在看到颜色的瞬间就能理解订单状态,无需阅读文字。
绿色对应"已完成",传达"成功、完成、正面"的语义;蓝色对应"进行中",与品牌主色一致,传达"活跃、进行、待关注"的语义;红色对应"已取消",传达"终止、异常、负面"的语义;对于未匹配的状态,返回 text3(最弱文字色),起到"弱化"效果。这个函数在订单时间轴中被多次调用——订单状态文字、状态圆点、时间轴竖线都使用这个颜色,保证了同一状态在整个订单卡中的颜色一致性。
6.2 WAV 音频字节生成器
/** 生成正弦波 WAV 音频字节(16bit 单声道 PCM,模拟用户生成/网络下载的音频文件) */
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 是整个应用中最具技术深度的辅助函数,它通过纯 JavaScript 代码在内存中生成符合 WAV 文件格式规范的音频字节序列。这个函数模拟了"网络下载或用户生成的音频文件"——在真实场景中,门店可能从服务器下载铃声音频包,而这里通过代码生成来模拟这一过程。下面逐块解析其实现:
采样率与数据量计算:sampleRate = 44100 是 CD 音质的标准采样率(每秒 44100 个采样点)。numSamples 根据时长(毫秒)计算总采样点数。dataSize = numSamples * 2 是音频数据段的字节数——每个采样点用 16 位(2 字节)存储。44 + dataSize 是整个 WAV 文件的总字节数——44 字节的文件头加上音频数据。
DataView 字节操作:ArrayBuffer 是一段固定长度的二进制数据缓冲区,DataView 提供了读写 ArrayBuffer 中多种数据类型的能力。writeStr 是一个内联辅助函数,将字符串的每个字符的 ASCII 码写入指定偏移量——WAV 文件头中有多个 4 字节的标识字符串(如 RIFF、WAVE、fmt 、data)需要写入。
WAV 文件头写入:WAV 文件遵循 RIFF(Resource Interchange File Format)规范。文件头的 44 字节包含以下字段:偏移 0 处 4 字节 RIFF 标识;偏移 4 处 4 字节文件大小(不含前 8 字节,即 36 + dataSize);偏移 8 处 4 字节 WAVE 标识;偏移 12 处 4 字节 fmt 子块标识;偏移 16 处 4 字节 fmt 块大小(固定为 16);偏移 20 处 2 字节音频格式(1 = PCM);偏移 22 处 2 字节声道数(1 = 单声道);偏移 24 处 4 字节采样率;偏移 28 处 4 字节字节率(sampleRate * 2 = 采样率 × 声道数 × 每样本字节数);偏移 32 处 2 字节块对齐(2 = 声道数 × 每样本字节数);偏移 34 处 2 字节位深度(16 bit);偏移 36 处 4 字节 data 标识;偏移 40 处 4 字节数据大小。
音频数据生成:循环遍历每个采样点,生成正弦波音频。t = i / sampleRate 是当前时间(秒);env = Math.min(1, i / (sampleRate * 0.02)) 是包络(Envelope)的前 20 毫秒淡入,避免音频开始时的"咔嗒"声;decay = Math.max(0, 1 - t / (durationMs / 1000)) 是线性衰减包络,使声音逐渐减弱;v = Math.sin(2 * PI * freq * t) * 0.5 * env * decay 是最终振幅——正弦波乘以 0.5 的基础音量、淡入包络和衰减包络。最终将振幅值(-1 到 1)映射到 16 位整数范围(-32768 到 32767),以小端序写入。
这个函数生成的 WAV 文件虽然简单(纯正弦波、单声道、无谐波),但完全符合 WAV 格式规范,可以被 HarmonyOS 的通知系统识别并播放。在真实应用中,可以用更复杂的音频数据替换正弦波,或直接从网络下载真实的音频文件写入沙箱。
七、数据模型设计
7.1 订单数据模型
/** 订单条目(订单 Tab 竖向时间轴) */
@Observed export class OrderItem {
time: string;
title: string;
status: string;
amount: string;
constructor(time: string, title: string, status: string, amount: string) {
this.time = time;
this.title = title;
this.status = status;
this.amount = amount;
}
}
OrderItem 是订单数据的模型类,使用 @Observed 装饰器标注。@Observed 是 ArkUI 提供的响应式装饰器——当被标注的类的实例属性发生变化时,依赖这些属性的 UI 组件会自动触发重新渲染。这意味着当用户编辑某个订单的时间或金额后,订单列表的 UI 会自动更新,无需手动调用刷新方法。
OrderItem 包含 4 个字段:time(下单时间,如"11:58")、title(菜品内容,如"招牌牛肉面 x2 · 微辣")、status(订单状态,如"已完成")、amount(订单金额,如"56.00")。这些字段都是 string 类型——在实际项目中,时间和金额可能使用更精确的类型(如 Date 和 number),但在 Demo 中使用字符串简化了数据处理和 UI 渲染。
构造函数接收 4 个参数并赋值给对应属性。使用 export 关键字导出,使得该类可以在其他模块中引用(如果项目拆分为多文件)。
7.2 铃声数据模型
/** 铃声条目(铃音 Tab:沙箱自定义铃声) */
@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 标注。它包含 6 个字段,覆盖了铃声的完整生命周期信息:
name(铃声名称,如"收银叮咚")——用于 UI 展示,需要有辨识度和语义含义。file(文件名,如"ring_880.wav")——用于沙箱文件路径拼接。freq(频率,赫兹)——用于 buildWavBytes 生成音频数据,也用于 UI 展示铃声参数。duration(时长,毫秒)——同上。size(文件大小,如"102 KB")——在铃声导入沙箱后计算并更新,初始值为"—"表示未计算。inSandbox(是否已导入沙箱)——布尔标志,控制铃声库列表中显示"沙箱中"还是"未导入"标签,以及是否显示"导入沙箱"按钮。
inSandbox 字段是铃声管理的核心状态——它决定了铃声是否可以被设为通知铃声。只有 inSandbox 为 true 的铃声,其音频文件才存在于沙箱目录中,才能被 getSoundValue() 转换为有效的 uri:: 路径。这种设计模拟了真实场景中"铃声需要先下载到本地才能使用"的逻辑。
7.3 统计数据模型
/** 统计条目(我的 Tab 经营统计清单) */
@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 是经营统计数据模型,包含 4 个字段:icon(Emoji 图标)、name(统计名称,如"累计订单")、val(统计数值,如"12,860 单")、tag(标签/环比信息,如"本月 +1,286")。tag 字段是"辅助信息"——它不是主数据,而是为主数据提供上下文(如环比增长、排名等),在 UI 中以更弱的文字色展示,形成主次分明的数据展示层次。
八、Mock 数据定义
8.1 订单列表数据
/** 今日订单 Mock 数据(7 条) */
const ORDER_LIST: Array<OrderItem> = [
new OrderItem('11:58', '招牌牛肉面 x2 · 微辣', '已完成', '56.00'),
new OrderItem('12:06', '黄金鸡排堡套餐 x1', '进行中', '32.00'),
new OrderItem('12:15', '酸菜鱼米饭 x3 · 加饭', '已完成', '96.00'),
new OrderItem('12:22', '芝士奶茶 x2 · 三分糖', '进行中', '30.00'),
new OrderItem('12:31', '香辣鸡腿堡 x2 · 去冰', '已取消', '44.00'),
new OrderItem('12:40', '番茄牛腩饭 x1 · 例汤', '进行中', '28.00'),
new OrderItem('12:52', '双拼烤鱼套餐 x2', '已完成', '108.00')
];
ORDER_LIST 包含 7 条订单 Mock 数据,时间从 11:58 到 12:52,覆盖了午高峰时段。每条订单的菜品内容都有语义化的描述——"招牌牛肉面 x2 · 微辣"包含了菜品名、数量和口味偏好三个维度的信息。订单状态的分布也经过精心设计:3 条已完成、3 条进行中、1 条已取消,使得状态筛选和时间轴上的颜色分布有足够的多样性。金额从 28 元到 108 元不等,覆盖了不同的客单价区间。这些 Mock 数据虽然是虚构的,但数据质量和真实门店的订单流水高度一致。
8.2 铃声库数据
/** 铃声库 Mock 数据(6 条:收银/订单/迎客语义铃声) */
const RING_LIST: Array<RingItem> = [
new RingItem('收银叮咚', 'ring_880.wav', 880, 1200, '—', false),
new RingItem('订单号角', 'ring_660.wav', 660, 1500, '—', false),
new RingItem('门铃迎客', 'ring_440.wav', 440, 600, '—', false),
new RingItem('翻台吉铃', 'ring_1320.wav', 1320, 1000, '—', false),
new RingItem('满堂彩铃', 'ring_1760.wav', 1760, 800, '—', false),
new RingItem('夜市低音', 'ring_220.wav', 220, 2000, '—', false)
];
RING_LIST 包含 6 条铃声 Mock 数据,每条铃声都有语义化的名称和对应的频率参数。铃声名称遵循"场景 + 声音描述"的命名模式——“收银叮咚”“订单号角”“门铃迎客”“翻台吉铃”“满堂彩铃”“夜市低音”,每个名称都传达了铃声的使用场景和听觉特征。频率参数从 220 Hz(低音)到 1760 Hz(高音)覆盖了六个八度的范围,不同的频率对应不同的听觉感受——低频沉稳、高频清亮,适合不同的通知场景。初始时所有铃声的 inSandbox 均为 false,size 均为"—",表示铃声尚未导入沙箱。用户需要手动点击"导入沙箱"按钮才能将铃声写入沙箱并计算大小。
8.3 经营统计数据
/** 经营统计 Mock 数据(5 条) */
const STAT_LIST: Array<StatItem> = [
new StatItem('🧾', '累计订单', '12,860 单', '本月 +1,286'),
new StatItem('💰', '累计营收', '¥486,320', '环比 +12.6%'),
new StatItem('😊', '顾客好评', '4.9 分', '好评率 98%'),
new StatItem('🔁', '翻台效率', '5.2 次/天', '高峰 7.8 次'),
new StatItem('⭐', '会员复购', '63.8%', '新增会员 214')
];
STAT_LIST 包含 5 条经营统计数据,覆盖了餐饮门店的核心 KPI 维度:累计订单(规模)、累计营收(金额)、顾客好评(口碑)、翻台效率(空间利用)、会员复购(忠诚度)。每条数据的 tag 字段提供了环比或补充信息——"本月 +1,286"“环比 +12.6%”“好评率 98%”“高峰 7.8 次”“新增会员 214”——这些辅助信息使得统计数据更加立体和有故事性,而非干巴巴的数字。
九、组件主体与状态管理
9.1 组件声明与入口
/** 智赢门店 · 门店经营平台主页面 */
@Entry
@Component
struct Page1101 {
@Entry 装饰器标注该组件为页面的入口组件——每个页面有且仅有一个 @Entry 组件,它是整个页面组件树的根节点。@Component 装饰器声明这是一个自定义组件,可以被其他组件引用(虽然作为入口组件通常不被外部引用)。struct 是 ArkUI 中定义组件的关键字(区别于 TypeScript 的 class),ArkUI 组件必须使用 struct 声明。
9.2 状态变量定义
/** 当前选中 Tab 索引 */
@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;
/** 通知 ID(发布成功后自增) */
@State notifyId: number = 100;
/** 当前默认铃声索引 */
@State currentRingIdx: number = 0;
/** 已导入沙箱的铃声数量 */
@State sandboxCount: number = 0;
/** 生成器频率(Hz) */
@State genFreq: number = 880;
/** 生成器时长(ms) */
@State genDuration: number = 1200;
/** 折线图 Canvas 就绪标志 */
@State canvasReady: boolean = false;
/** 已发布取餐通知条数 */
@State sentCount: number = 0;
/** 呼吸动画定时器句柄 */
private timer: number = -1;
/** 订单列表(时间轴数据源) */
@State orderList: Array<OrderItem> = ORDER_LIST;
/** 铃声库列表 */
@State ringList: Array<RingItem> = RING_LIST;
/** 经营统计列表 */
@State statList: Array<StatItem> = STAT_LIST;
/** 新增表单:下单时间 */
@State formTime: string = '';
/** 新增表单:菜品内容 */
@State formTitle: string = '';
/** 新增表单:订单金额 */
@State formAmount: string = '';
/** 编辑表单:下单时间 */
@State editTime: string = '';
/** 编辑表单:菜品内容 */
@State editTitle: string = '';
/** 编辑表单:订单金额 */
@State editAmount: string = '';
这是整个应用最核心的状态定义区域,20 余个状态变量共同驱动了应用的所有交互行为。我们可以将这些状态变量分为以下几组来理解:
Tab 与弹窗状态组:currentTab(当前 Tab 索引)控制页面内容区域的条件渲染;addModal、editModal、delModal(三个布尔开关)控制三种弹窗的显示与隐藏。这组状态是最简单的"开关型"状态,每次变化都会触发 build() 的重新执行,决定哪些 UI 元素被渲染。
索引状态组:editIdx(当前编辑的订单索引)和 delIdx(当前删除的铃声索引)记录了当前操作的目标列表项。当用户点击某个订单的"编辑"按钮时,该订单的索引被写入 editIdx,编辑弹窗打开后通过这个索引读取和修改对应的订单数据。
动画与授权状态组:breath(呼吸动画开关)每秒在 true 和 false 之间翻转,驱动头部授权胶囊的透明度交替、Canvas 折线图末端节点半径交替、月度柱状图末柱高度交替;granted(通知授权状态)记录当前应用是否已获得通知授权,驱动授权胶囊显示"已授权"(绿色)还是"未授权·点击授权"(红色)。
通知与铃声状态组:notifyId(通知 ID)从 100 开始,每发布一条通知自增 1,确保每条通知有唯一的 ID;sentCount(已发送通知数)记录成功发布的通知条数,显示在头部和铃音页;currentRingIdx(当前默认铃声索引)记录用户选择的通知铃声在铃声库中的位置;sandboxCount(已导入沙箱数)记录有多少铃声已写入沙箱。
生成器参数组:genFreq(生成器频率)和 genDuration(生成器时长)是铃声生成器的两个可调参数,初始值分别为 880 Hz 和 1200 ms。用户通过 Slider 调整这两个值后,点击"生成铃声到沙箱"按钮即可用新参数生成一个自定义铃声。
Canvas 状态组:canvasReady 标记 Canvas 组件是否已就绪——只有 Canvas 的 onReady 回调触发后才能开始绘图。这个标志防止了在 Canvas 尚未初始化时调用 drawLineChart() 导致的空指针异常。
列表数据组:orderList、ringList、statList 三个列表分别初始化为对应的 Mock 数据常量。由于 OrderItem、RingItem、StatItem 都使用了 @Observed 装饰器,当列表中元素的属性发生变化(如铃声导入沙箱后 inSandbox 变为 true)时,UI 会自动更新。
表单状态组:formTime、formTitle、formAmount 是新增订单弹窗的三个输入字段;editTime、editTitle、editAmount 是编辑订单弹窗的三个输入字段。这些状态通过 TextInput 组件的 onChange 回调与用户输入双向绑定。
值得注意的是,timer 变量使用 private 而非 @State 标注——因为定时器句柄不需要触发 UI 更新,它只是一个内部的引用,用于在组件销毁时清理定时器。
9.3 Canvas 上下文初始化
/** 折线图 Canvas 上下文 */
private lineCtx: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true));
lineCtx 是 Canvas 绘图的上下文对象,通过 CanvasRenderingContext2D 构造函数创建。RenderingContextSettings(true) 中的 true 参数启用了抗锯齿(antialias)功能,使得绘制的线条和圆形边缘更加平滑。这个上下文对象在 build() 中被传递给 Canvas(this.lineCtx) 组件,建立起 UI 组件与绘图上下文之间的关联。后续的 drawLineChart() 方法通过这个上下文对象执行所有绘图操作——设置描边样式、填充样式、绘制路径、填充渐变等。
十、通知授权机制
10.1 通知授权请求
/** 请求通知授权(首次调用弹系统授权框;曾被拒绝则拉起通知设置页二次授权) */
requestAuth() {
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return;
}
notificationManager.requestEnableNotification(hostCtx).then(() => {
this.granted = true;
}).catch((err: BusinessError) => {
notificationManager.openNotificationSettings(hostCtx).then(() => {
}).catch(() => {
this.granted = false;
});
});
}
requestAuth 方法实现了 HarmonyOS 通知授权的"两段式"请求逻辑。首先通过 this.getUIContext().getHostContext() 获取当前 UI 上下文对应的 UIAbilityContext——这是应用与系统能力交互的桥梁。如果获取失败(null),直接返回,避免后续操作的空指针异常。
第一段:调用 notificationManager.requestEnableNotification(hostCtx)。这是 HarmonyOS 推荐的通知授权请求方式——首次调用时系统会弹出授权对话框,询问用户是否允许该应用发送通知。如果用户同意,Promise resolve,granted 被设为 true。
第二段(catch 分支):如果用户曾经拒绝过授权请求,requestEnableNotification 会直接 reject,进入 catch 分支。此时调用 notificationManager.openNotificationSettings(hostCtx) 拉起系统的通知设置页面,引导用户手动开启通知权限。这是一种"降级处理"策略——当系统授权框无法弹出时,引导用户到设置页手动操作,确保用户有第二次授权的机会。如果设置页也打开失败(catch 内层),则将 granted 设为 false。
这种两段式授权逻辑是 HarmonyOS 通知授权的最佳实践——它处理了"首次请求"和"二次请求"两种场景,保证了授权流程的完整性。BusinessError 类型来自 @kit.BasicServicesKit,是 HarmonyOS 统一的错误类型,包含错误码和错误信息。
10.2 生命周期管理
/** 生命周期:查询授权状态并启动呼吸动画定时器 */
aboutToAppear() {
notificationManager.isNotificationEnabled().then((enabled: boolean) => {
this.granted = enabled;
}).catch(() => {
this.granted = false;
});
this.timer = setInterval(() => {
this.breath = !this.breath;
if (this.canvasReady) {
this.drawLineChart();
}
}, 1000);
}
/** 生命周期:销毁时清理定时器 */
aboutToDisappear() {
clearInterval(this.timer);
}
aboutToAppear 是组件生命周期函数,在组件即将出现(但尚未渲染完成)时调用。这个方法完成两件关键工作:
第一,查询通知授权状态。调用 notificationManager.isNotificationEnabled() 异步查询当前应用的通知授权状态。如果已授权,granted 被设为 true,头部授权胶囊显示绿色"已授权"。这是在页面加载时同步授权状态的必要步骤——用户可能在应用外部(系统设置)中修改了授权状态,应用需要在加载时获取最新状态。
第二,启动呼吸动画定时器。通过 setInterval 创建一个每 1000 毫秒(1 秒)执行一次的定时器,保存在 this.timer 中。定时器回调中做两件事:一是翻转 breath 状态(true 变 false,false 变 true),驱动所有依赖 breath 的 UI 元素的呼吸效果;二是如果 Canvas 已就绪(canvasReady 为 true),调用 drawLineChart() 重绘折线图,使末端数据节点的半径随 breath 状态交替变化。
aboutToDisappear 是组件销毁前的生命周期函数,调用 clearInterval(this.timer) 清理定时器。这一步至关重要——如果不清理,组件销毁后定时器仍会继续执行,导致内存泄漏和潜在的空引用错误。这是 ArkUI 生命周期管理的标准实践:在 aboutToAppear 中创建的资源,必须在 aboutToDisappear 中清理。
十一、沙箱铃声文件管理
11.1 音频写入沙箱
/** 将生成的音频写入沙箱 EL1 的 files 目录,返回沙箱路径 */
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 方法是将音频数据写入应用沙箱的核心方法,它串联了从音频生成到文件写入的完整流程。下面逐行解析:
获取应用上下文:与 requestAuth 类似,先获取 UIAbilityContext,再通过 getApplicationContext() 获取应用级上下文 appCtx。应用级上下文提供了应用沙箱目录的访问能力。
设置沙箱区域:appCtx.area = contextConstant.AreaMode.EL1 将文件操作区域设为 EL1。HarmonyOS 的应用沙箱分为多个安全级别(EL1-EL4),EL1 是最低级别(设备级加密),对于通知铃声这种需要被系统通知服务读取的文件,必须放置在 EL1 区域。这是一个容易踩坑的配置——如果区域设置不正确,通知系统可能无法访问沙箱内的音频文件。
构造文件路径:dir = appCtx.filesDir 获取应用的 files 目录路径,拼接文件名得到完整路径。filesDir 是应用专有的文件目录,应用卸载时会被清除。
写入文件:先调用 buildWavBytes(freq, durationMs) 生成 WAV 音频字节序列。然后使用 fs.openSync 以 CREATE | WRITE_ONLY | TRUNC 模式打开文件——CREATE 表示文件不存在时创建,WRITE_ONLY 表示只写模式,TRUNC 表示如果文件已存在则截断(清空原有内容)。使用 fs.writeSync(file.fd, data) 将 ArrayBuffer 数据写入文件,最后 fs.closeSync(file) 关闭文件描述符。整个写入过程用 try-catch 包裹,写入失败时静默忽略(返回空路径),保证应用的健壮性。
返回路径:方法返回沙箱路径,供调用方使用。这个路径在后续的 getSoundValue() 方法中会被转换为 uri:: 格式。
11.2 铃声导入沙箱
/** 铃声库条目导入沙箱(更新大小与状态) */
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 的基础上封装了铃声状态更新逻辑。它接收铃声在列表中的索引 idx,执行以下操作:
首先调用 saveRingToSandbox(r.file, r.freq, r.duration) 将铃声的音频数据写入沙箱——文件名、频率和时长都从 RingItem 实例中获取。
然后将 r.inSandbox 设为 true,标记该铃声已导入沙箱。由于 RingItem 使用了 @Observed 装饰器,这个属性变化会自动触发铃声库列表 UI 的更新——状态标签从"未导入"变为"沙箱中"(颜色从橙色变为绿色),"导入沙箱"按钮消失。
接着计算文件大小:44 + Math.floor(44100 * r.duration / 1000) * 2 是 WAV 文件的总字节数(44 字节文件头 + 音频数据字节),除以 1024 转为 KB 单位并四舍五入。这个计算逻辑与 buildWavBytes 函数中的字节计算完全一致,确保显示的文件大小与实际文件大小匹配。将计算结果拼接" KB"后缀赋值给 r.size。
最后将 sandboxCount 自增 1,更新已导入沙箱的铃声总数。这个计数会在铃音页的"当前默认铃声"卡片中显示。
11.3 生成器创建铃声
/** 用生成器参数新建铃声并写入沙箱 */
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 方法使用铃声生成器的当前参数(genFreq 和 genDuration)创建一个新的自定义铃声。序列号 seq 基于当前铃声列表长度计算,确保命名唯一性。新铃声的名称为"自定义铃声1"“自定义铃声2"等,文件名为"ring_custom_1.wav”"ring_custom_2.wav"等。初始状态 inSandbox 为 false,然后立即调用 importRingToSandbox 导入沙箱——因为用户点击"生成铃声到沙箱"按钮的意图就是生成并导入,所以不需要用户再手动导入。
将新铃声 push 到 ringList 后,由于 ringList 是 @State 装饰器标注的数组,新增元素会触发铃声库列表的 ForEach 重新渲染,新铃声立即出现在列表中。调用 importRingToSandbox 时传入的索引是 this.ringList.length - 1(即刚 push 进去的元素的索引),确保操作的是正确的铃声。
11.4 设置默认铃声
/** 设为默认通知铃声(未导入沙箱时自动导入) */
setCurrentRing(idx: number) {
if (!this.ringList[idx].inSandbox) {
this.importRingToSandbox(idx);
}
this.currentRingIdx = idx;
}
setCurrentRing 方法将指定索引的铃声设为默认通知铃声。方法内有一个关键的"自动导入"逻辑——如果目标铃声尚未导入沙箱(inSandbox 为 false),先调用 importRingToSandbox(idx) 自动导入。这是因为通知铃声必须存在于沙箱中才能被 getSoundValue() 转换为有效的 URI,自动导入保证了"设为默认"操作后铃声立即可用。然后将 currentRingIdx 设为目标索引,后续发布通知时会使用这个索引对应的铃声。
十二、通知铃声 URI 构建与发布
12.1 sound 字段值构建
/** 当前通知请求 sound 字段值(6.1.1 新特性:沙箱路径转 uri:: 前缀) */
getSoundValue(): string {
const ring = this.ringList[this.currentRingIdx];
const hostCtx = this.getUIContext().getHostContext() as common.UIAbilityContext;
if (!hostCtx) {
return 'uri::';
}
const appCtx = hostCtx.getApplicationContext();
const path = appCtx.filesDir + '/' + ring.file;
return 'uri::' + fileUri.getUriFromPath(path);
}
getSoundValue 方法是 HarmonyOS 6.1.1 自定义通知铃声特性的核心实现。它构建 NotificationRequest.sound 字段的值,格式为 'uri::' + fileUri.getUriFromPath(沙箱音频路径)。下面深入解析:
首先获取当前默认铃声对象 ring 和应用上下文。如果上下文获取失败,返回空前缀 'uri::'(这种情况下通知会使用系统默认铃声)。
然后构造沙箱音频文件的完整路径:appCtx.filesDir + '/' + ring.file。例如,如果 filesDir 是 /data/app/myapp/files,铃声文件名是 ring_880.wav,则完整路径为 /data/app/myapp/files/ring_880.wav。
最后调用 fileUri.getUriFromPath(path) 将文件系统路径转换为系统可识别的 URI 格式,再拼接 'uri::' 前缀。fileUri.getUriFromPath 来自 @kit.CoreFileKit,它将沙箱路径转换为形如 file://myapp/files/ring_880.wav 的 URI 字符串。'uri::' 前缀是 Notification Kit 约定的标识——通知系统看到 sound 字段值以 uri:: 开头时,会将其后的内容作为文件 URI 解析,从沙箱中读取音频文件并播放。
这个方法在铃音页的"当前默认铃声"卡片中被调用,将构建的 sound 值显示在 UI 中(用 8px 字号、最多 2 行),让用户直观地看到通知铃声的实际 URI 取值。这种将内部技术参数透明化展示的设计,既方便开发者调试,也让用户理解铃声的"工作原理"。
12.2 发布取餐通知
/** 发布携带沙箱自定义铃声的取餐通知(核心:sound 字段填沙箱 uri) */
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: '您的餐品已出餐,请凭小票号到取餐口取餐,祝您用餐愉快!',
additionalText: '自定义铃声:' + ringName
}
},
sound: soundVal // HarmonyOS 6.1.1:支持应用沙箱内的音频路径
};
notificationManager.publish(request).then(() => {
this.notifyId++;
this.sentCount++;
}).catch((err: BusinessError) => {
// 失败:未授权 1600004
});
}
publishNotice 方法是整个通知铃声定制化能力的最终出口——它将前面所有的准备工作(授权、沙箱写入、URI 构建)整合为一条完整的通知发布请求。下面逐块解析:
边界检查:首先检查 currentRingIdx 是否在有效范围内。如果铃声列表为空或索引越界,直接返回,避免后续操作的数组越界异常。
自动导入:与 setCurrentRing 类似,如果当前铃声未导入沙箱,先自动导入。这保证了即使用户没有手动导入就点击"发布通知",铃声也会被自动写入沙箱。
构建通知请求:NotificationRequest 对象包含以下关键字段:
id:通知 ID,使用 this.notifyId(初始值 100,每次发布成功后自增)。每条通知需要唯一 ID,相同 ID 的通知会覆盖而非新建。
notificationSlotType:通知槽位类型,设为 SOCIAL_COMMUNICATION(社交通信类型)。不同槽位类型对应不同的通知优先级和行为——社交通信类型具有较高优先级,会弹出横幅提示,适合取餐通知这种需要即时提醒的场景。
content:通知内容,使用 NOTIFICATION_CONTENT_BASIC_TEXT(基础文本类型)。normal 对象包含 title(标题"智赢门店·取餐通知")、text(正文"您的餐品已出餐,请凭小票号到取餐口取餐,祝您用餐愉快!“)、additionalText(附加文本"自定义铃声:xxx”)。附加文本在通知展开时显示,告知用户当前使用的铃声名称。
sound:核心字段——设为 soundVal,即 getSoundValue() 返回的 'uri::' + URI 值。这是 HarmonyOS 6.1.1 新特性的关键使用点:通知系统读取 sound 字段,发现以 uri:: 开头,从沙箱中读取对应路径的音频文件并播放。
发布与回调:调用 notificationManager.publish(request) 发布通知。成功时,notifyId 自增(为下一条通知准备唯一 ID),sentCount 自增(更新已发送计数,UI 自动刷新)。失败时进入 catch 分支——典型的失败原因是未授权(错误码 1600004),此时静默处理。在实际应用中,可以在 catch 中检查错误码,如果是未授权则引导用户授权。
这个方法在两个地方被调用:订单时间轴中每个订单的"取餐通知"按钮,以及铃音页的"立即发布通知"按钮。两种入口共享同一套铃声配置,确保通知铃声的一致性。
十三、铃声删除与沙箱清理
/** 删除铃声(同步清理沙箱文件) */
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 是否在有效范围内。delIdx 在用户点击铃声库中的"删除"按钮时被设置,同时打开删除确认弹窗。
沙箱文件清理:如果被删除的铃声已导入沙箱(inSandbox 为 true),先递减 sandboxCount,然后通过 fs.unlinkSync 删除沙箱中的音频文件。文件路径的构造方式与 saveRingToSandbox 完全一致——设置 EL1 区域、拼接 filesDir 和文件名。删除操作用 try-catch 包裹,文件不存在时静默忽略(可能已被其他途径删除)。
列表移除:调用 this.ringList.splice(idx, 1) 从铃声库列表中移除该条目。由于 ringList 是 @State 数组且 RingItem 是 @Observed 类,splice 操作会触发铃声库列表的 ForEach 重新渲染,被删除的铃声条目从 UI 中消失。
默认铃声索引修正:如果被删除的铃声正好是(或排在)当前默认铃声之后,currentRingIdx 可能越界。此时将 currentRingIdx 修正为列表最后一项的索引,确保后续的 getSoundValue() 和 publishNotice() 不会越界访问。
关闭弹窗:最后将 delModal 设为 false,关闭删除确认弹窗。这一行代码在方法末尾而非开头,是因为只有在删除操作完成后才应关闭弹窗——如果中途出现异常,弹窗保持打开状态,用户可以看到操作未完成。
十四、订单增删改操作
14.1 新增订单
/** 保存新增订单(新增弹窗确认按钮) */
saveOrder() {
if (this.formTime === '' || this.formTitle === '' || this.formAmount === '') {
return;
}
this.orderList.push(new OrderItem(this.formTime, this.formTitle, '进行中', this.formAmount));
this.formTime = '';
this.formTitle = '';
this.formAmount = '';
this.addModal = false;
}
saveOrder 方法处理新增订单的保存逻辑。首先进行表单校验——如果三个输入字段中有任意一个为空,直接返回不执行保存。这种"前置校验"避免了无效数据进入列表。校验通过后,使用表单数据创建新的 OrderItem 实例(状态固定为"进行中",因为新订单刚创建时一定处于进行中状态),push 到 orderList 中。新增的订单会出现在时间轴的末尾,UI 自动更新。然后将三个表单字段重置为空字符串,为下次新增清空表单。最后关闭新增弹窗(addModal = false)。
14.2 编辑订单
/** 保存编辑订单(编辑弹窗确认按钮) */
updateOrder() {
if (this.editIdx >= 0 && this.editIdx < this.orderList.length) {
this.orderList[this.editIdx].time = this.editTime;
this.orderList[this.editIdx].title = this.editTitle;
this.orderList[this.editIdx].amount = this.editAmount;
}
this.editModal = false;
}
updateOrder 方法处理编辑订单的保存逻辑。首先检查 editIdx 是否有效(编辑弹窗打开时 editIdx 已被设置为目标订单的索引)。然后将编辑表单中的三个字段值更新到对应的 OrderItem 实例属性上。由于 OrderItem 是 @Observed 类,属性变化会触发 UI 自动更新——时间轴中对应订单卡的时间、菜品内容和金额会实时更新。注意编辑操作不修改 status 字段——订单状态的变化通过其他方式管理(如取餐完成后自动变为"已完成"),编辑操作只修改订单的基本信息。最后关闭编辑弹窗。
十五、Canvas 折线图绘制
/** 绘制近 7 日营业额折线图(含渐变填充与末端呼吸节点) */
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);
// 横向网格线
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();
}
// 渐变面积填充
const grad = ctx.createLinearGradient(0, pad, 0, h - pad);
grad.addColorStop(0, COLORS.blue);
grad.addColorStop(1, 'rgba(76,141,255,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();
// 主折线
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.blue;
ctx.lineWidth = 2;
ctx.stroke();
// 数据节点(末端呼吸放大)
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.gold;
ctx.lineWidth = 1.5;
ctx.stroke();
}
// 横轴星期标签
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);
}
}
drawLineChart 方法是整个应用中 Canvas 命令式绘图的核心实现,它绘制了一张包含网格线、渐变面积填充、主折线、数据节点和横轴标签的完整折线图。下面分块详细解析:
绘图参数计算:w = 320 和 h = 180 定义了画布的逻辑尺寸(与 Canvas 组件的 width/height 一致)。pad = 30 是内边距,为横轴标签和网格线留出空间。stepX = (w - pad * 2) / (LINE_DATA.length - 1) 计算每个数据点之间的水平间距——可用宽度除以(数据点数 - 1)得到间距。对于 7 个数据点,stepX = (320 - 60) / 6 = 43.33。
清空画布:每次重绘前调用 ctx.clearRect(0, 0, w, h) 清空整个画布。这是因为 Canvas 是保留模式的——上一次绘制的内容不会自动消失,如果不清空,新旧内容会叠加。在呼吸动画每秒重绘的场景下,这一步尤为重要。
横向网格线:使用 COLORS.line(深蓝色)绘制 4 条水平网格线(i 从 0 到 3)。网格线的 Y 坐标通过 pad + (h - pad * 2) * i / 3 计算——将绘图区域的垂直空间等分为 3 段,绘制 4 条线。网格线提供了数据值的参考刻度,使用户能够大致估算每个数据点的数值。每条线通过 beginPath → moveTo → lineTo → stroke 四步绘制。
渐变面积填充:这是折线图最具视觉冲击力的部分。首先通过 ctx.createLinearGradient(0, pad, 0, h - pad) 创建一个从顶部到底部的线性渐变——起始色为 COLORS.blue(不透明科技蓝),终止色为 rgba(76,141,255,0.05)(几乎透明的蓝色)。然后绘制一个闭合路径:从左下角开始,依次连接所有数据点,回到右下角,闭合。用渐变色填充这个区域,形成了折线下方的渐变面积——顶部不透明、底部近乎透明,营造出"光从上方洒下"的视觉效果。
数据点的 Y 坐标计算公式为 y = h - pad - (LINE_DATA[i] / LINE_MAX) * (h - pad * 2)。这个公式的逻辑是:用数据值除以最大值得到比例(0 到 1),乘以绘图区高度得到偏移量,用底部坐标减去偏移量得到 Y 坐标——值越大,Y 越小(越高),符合折线图的视觉惯例。
主折线:绘制折线路径——第一个点用 moveTo(移动画笔不画线),后续点用 lineTo(画线到指定位置)。使用 COLORS.blue 描边,线宽 2px。折线是折线图的核心信息载体,清晰展示数据的变化趋势。
数据节点:在每个数据点位置绘制一个圆形节点。节点的填充色为 COLORS.card(卡片色,与背景一致),描边色为 COLORS.gold(香槟金),线宽 1.5px。这种"卡片色填充 + 金色描边"的设计使节点在折线上形成醒目的视觉锚点。最关键的是末端(最后一个)数据节点的半径——当 this.breath 为 true 时半径为 5px,为 false 时半径为 3px。由于 drawLineChart 每秒被呼吸定时器调用一次,末端节点的半径在 5 和 3 之间交替,形成了"呼吸"的动画效果,吸引用户关注最新数据(周日的营业额)。其他数据节点的半径固定为 3px。
横轴标签:最后绘制星期标签。设置字体为 9px sans-serif,文本对齐方式为居中(textAlign = 'center'),填充色为 COLORS.sub(副文本色)。在 pad + i * stepX 位置(每个数据点的正下方,h - pad + 14 的 Y 坐标)绘制对应的星期单字。标签使用副文本色而非标题色,是为了在视觉层次上弱于数据节点和折线,避免喧宾夺主。
整个 drawLineChart 方法展示了 Canvas 2D API 的核心绘图原语的组合运用——路径绘制(beginPath/moveTo/lineTo/closePath)、描边(stroke)、填充(fill)、渐变(createLinearGradient/addColorStop)、圆形(arc)、文本(fillText)。通过这些原语的有序组合,从零开始绘制出了一张专业级的折线图,无需任何第三方图表库。
十六、页面主构建 build()
/** 页面主构建:Stack 包裹主内容与三层弹窗 */
build() {
Stack() {
Column() {
this.headerMain()
Divider().strokeWidth(1).color(COLORS.line)
Scroll() {
Column() {
if (this.currentTab === 0) {
this.tabOverview()
} else if (this.currentTab === 1) {
this.tabOrder()
} 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 的核心特性是子元素从下到上层叠——后添加的子元素覆盖在前面的子元素之上。这个特性使得弹窗可以自然地覆盖在主内容之上。
主内容层:使用 Column 垂直排列三个区域——头部(headerMain)、分割线(Divider)、滚动内容区(Scroll)、底部导航(tabBar)。头部固定在顶部不随内容滚动,底部导航固定在底部不随内容滚动,只有中间的内容区可滚动。
分割线:Divider().strokeWidth(1).color(COLORS.line) 在头部和内容区之间绘制一条 1px 的深蓝色分割线,在视觉上分隔头部和内容区域。
Scroll 内容区:Scroll 组件包裹 Column,实现垂直滚动。layoutWeight(1) 使其占据除头部和底部导航之外的全部剩余空间。scrollBar(BarState.Off) 隐藏滚动条,保持界面简洁。
条件渲染:if/else if 条件链根据 currentTab 的值决定渲染哪个 Tab 的 @Builder 函数。这种设计确保同一时间只有一个 Tab 的内容被渲染,节省了内存和渲染资源。chartCard(月度营收柱状图)在条件链之外,始终显示在所有 Tab 内容的下方——无论用户在哪个 Tab,都能看到月度营收统计。
弹窗层:三个 if 条件分别判断 addModal、editModal、delModal 是否为 true,如果为 true 则渲染对应的弹窗面板。每个弹窗接收一个 onClose 回调函数,用于点击遮罩或取消按钮时关闭弹窗。由于三个弹窗的渲染条件是独立的(不是 if/else if),理论上可以同时显示多个弹窗,但在实际交互中,由于同一时间用户只能触发一个操作入口,不会出现多个弹窗同时显示的情况。
整体背景:最外层 Stack 使用 COLORS.bg(深色背景 #0A1628)作为背景色,贯穿整个页面。
十七、头部构建 headerMain
/** 头部:应用名 + 授权呼吸胶囊 + 3 个经营数据小卡 */
@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%')
// 头部下排 3 个数据小卡
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('今日订单').fontSize(9).fontColor(COLORS.text3)
Text('128').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('¥3682.5').fontSize(11).fontColor(COLORS.gold).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 })
}
headerMain 构建函数渲染应用的头部区域,包含应用名称、通知授权状态胶囊和三个经营数据小卡。下面分块解析:
应用名称区:左侧使用 Column 垂直排列应用名"智赢门店"(17px 粗体标题色)和副标题"智慧餐饮 · 门店经营平台"(9px 副文本色)。layoutWeight(1) 使其占据除授权胶囊外的全部水平空间,alignItems(HorizontalAlign.Start) 使文字左对齐。
通知授权状态胶囊:右侧是一个圆角胶囊形 Row,包含一个 6px 的状态圆点和状态文字。这是整个头部最具交互性的元素:
当 granted 为 true(已授权)时,圆点为绿色、文字为"已授权"、背景为浅绿色。当 granted 为 false(未授权)时,圆点为红色、文字为"未授权·点击授权"、背景为浅红色。圆点的透明度通过 this.breath 控制——breath 为 true 时完全不透明(opacity(1)),为 false 时半透明(opacity(0.35)),形成呼吸闪烁效果。这个呼吸效果只在未授权时有意义——闪烁的红色圆点吸引用户注意"通知尚未授权,点击授权"。
点击事件中判断 !this.granted,只有未授权时才调用 requestAuth()——已授权时点击不做任何操作,避免不必要的系统授权弹窗。
三个数据小卡:头部下排是三个等宽的数据小卡——今日订单(128,蓝色数字)、今日营收(¥3682.5,金色数字)、已发通知(sentCount,绿色数字)。每个小卡使用 9px 三级文本色显示标签,11px 粗体彩色显示数值。三种颜色分别对应不同的业务语义——蓝色对应订单量、金色对应金额、绿色对应操作结果。"已发通知"小卡使用 this.sentCount.toString() 动态显示已发送的通知数量,每次成功发布通知后自动更新。
十八、概览 Tab 构建 tabOverview
/** 概览 Tab:营收大数字卡 + 四格小卡 + Canvas 折线图 + 快捷操作 */
@Builder
tabOverview() {
Column({ space: 12 }) {
// 今日营收大数字卡
Column({ space: 6 }) {
Text('今日营收(元)').fontSize(10).fontColor(COLORS.sub)
Text('3682.50').fontSize(36).fontColor(COLORS.gold).fontWeight(FontWeight.Bold)
Row({ space: 10 }) {
Text('较昨日 +12.6%').fontSize(9).fontColor(COLORS.green)
Text('客单价 ¥28.4').fontSize(9).fontColor(COLORS.sub)
Text('支付转化 92%').fontSize(9).fontColor(COLORS.sub)
}
}
.width('100%').padding({ top: 16, bottom: 16 })
.backgroundColor(COLORS.card).borderRadius(12).alignItems(HorizontalAlign.Center)
// 四格经营小数据卡
Row({ space: 10 }) {
ForEach(MINI_LIST, (m: MiniStat) => {
Column({ space: 4 }) {
Text(m.icon).fontSize(16)
Text(m.val).fontSize(13).fontColor(COLORS.blue).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%')
// Canvas 营业额周折线图卡
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.canvasReady = true;
this.drawLineChart();
})
Row() {
Text('峰值 ¥11,200').fontSize(8).fontColor(COLORS.gold)
Text('均值 ¥9,571').fontSize(8).fontColor(COLORS.sub)
Text('环比 +8.3%').fontSize(8).fontColor(COLORS.green)
}
.width('100%').justifyContent(FlexAlign.SpaceBetween)
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
// 常用快捷操作条
Row({ space: 8 }) {
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.red)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.redL).borderRadius(9)
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.gold)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 8, bottom: 8 }).backgroundColor(COLORS.goldL).borderRadius(9)
}
.width('100%')
}
.width('100%')
}
tabOverview 构建函数渲染概览 Tab 的完整内容,采用"大数字 + 四格小卡 + Canvas 折线图 + 快捷操作"的仪表盘式布局。下面逐块解析:
今日营收大数字卡:这是概览页的视觉焦点——36px 的超大金色数字"3682.50"直接占据了视觉中心。大数字下方是三个辅助指标:较昨日增长率(绿色,表示正向变化)、客单价(副文本色)、支付转化率(副文本色)。这种"一个大数字 + 三个辅助指标"的设计模式是数据看板中的经典模式——用一个最具代表性的指标(今日营收)作为主信息,辅以相关指标提供上下文。
四格经营小数据卡:通过 ForEach 遍历 MINI_LIST 常量数组,渲染四个等宽的小数据卡。每个小卡从上到下包含:Emoji 图标(16px)、数值(13px 蓝色粗体)、名称(8px 三级文本色)。四格卡片使用 layoutWeight(1) 等分水平空间,在 Row 中以 10px 间距排列。键值函数 (m: MiniStat) => m.name 使用名称作为唯一键,确保列表项的稳定标识。
Canvas 折线图卡:这是概览页最具技术含量的部分。卡片标题栏左侧是"📈 近 7 日营业额走势"(13px 粗体标题色),右侧是"单位:元"(9px 三级文本色),中间用 Column().layoutWeight(1) 推开两端。核心是 Canvas(this.lineCtx) 组件——它将之前创建的 lineCtx 上下文与 Canvas UI 组件关联。width('100%').height(180) 设置画布尺寸,onReady 回调在 Canvas 初始化完成后触发,设置 canvasReady = true 并调用 drawLineChart() 绘制折线图。折线图下方是峰值、均值、环比三个数据摘要,使用金色、副文本色、绿色三种颜色区分。
快捷操作条:底部是四个等宽的快捷操作按钮——快捷接单(蓝色)、退款(红色)、清台(绿色)、交接班(金色)。每个按钮的文字色与背景色形成"深色文字 + 极深色背景"的深色主题配色方案。这四个操作覆盖了门店日常运营的高频场景:接单(新订单来)、退款(异常处理)、清台(翻台准备)、交接班(班次切换)。
十九、订单 Tab 构建 tabOrder
/** 订单 Tab:筛选条 + 竖向时间轴(固定行高 72,取餐通知按钮) */
@Builder
tabOrder() {
Column({ space: 10 }) {
// 标题行 + 新增入口
Row() {
Text('🧾 今日订单流水').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('共 ' + this.orderList.length.toString() + ' 单').fontSize(9).fontColor(COLORS.text3)
Text('+ 新增订单').fontSize(9).fontColor(COLORS.blue)
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor(COLORS.blueL).borderRadius(8)
.onClick(() => {
this.addModal = true;
})
}
.width('100%')
// 订单状态筛选条
Row({ space: 8 }) {
Text('全部').fontSize(9).fontColor(COLORS.blue)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.blueL).borderRadius(9)
Text('进行中').fontSize(9).fontColor(COLORS.sub)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(9)
Text('已完成').fontSize(9).fontColor(COLORS.sub)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(9)
Text('已取消').fontSize(9).fontColor(COLORS.sub)
.padding({ left: 10, right: 10, top: 4, bottom: 4 })
.backgroundColor(COLORS.chip).borderRadius(9)
}
.width('100%')
// 竖向时间轴(每行固定高 72)
ForEach(this.orderList, (item: OrderItem, idx: number) => {
Row({ space: 10 }) {
// 左侧时间 + 状态
Column({ space: 3 }) {
Text(item.time).fontSize(11).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text(item.status).fontSize(8).fontColor(statusColor(item.status))
}
.width(44).height('100%').alignItems(HorizontalAlign.Start).padding({ top: 12 })
// 中间圆点 + 连接竖线
Column() {
Circle().width(8).height(8).fill(statusColor(item.status))
if (idx < this.orderList.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.amount).fontSize(9).fontColor(COLORS.sub)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
// 操作按钮组:取餐通知 + 编辑
Column({ space: 4 }) {
Text('取餐通知').fontSize(9).fontColor(COLORS.blue)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.blueL).borderRadius(8)
.onClick(() => {
this.publishNotice();
})
Text('编辑').fontSize(9).fontColor(COLORS.sub)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.chip).borderRadius(8)
.onClick(() => {
this.editIdx = idx;
this.editTime = item.time;
this.editTitle = item.title;
this.editAmount = item.amount;
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: OrderItem) => item.time + item.title)
}
.width('100%')
}
tabOrder 构建函数渲染订单 Tab,采用"标题行 + 筛选条 + 竖向时间轴"的流水式布局。这是整个应用中布局最复杂的 Tab。下面逐块解析:
标题行 + 新增入口:左侧是"🧾 今日订单流水"标题(13px 粗体),中间用 Column().layoutWeight(1) 推开,右侧依次是订单总数"共 N 单"(9px 三级文本色)和"+ 新增订单"按钮(9px 蓝色文字 + 浅蓝背景)。点击新增按钮将 addModal 设为 true,打开新增订单弹窗。订单总数使用 this.orderList.length.toString() 动态显示,新增或删除订单后自动更新。
订单状态筛选条:四个筛选标签——全部、进行中、已完成、已取消。当前选中的"全部"使用蓝色文字 + 浅蓝背景,其余使用副文本色 + 芯片色背景。在 Demo 中筛选标签是静态的(不实际过滤数据),但在真实应用中,可以为每个标签添加 onClick 事件,根据状态过滤 orderList。
竖向时间轴:这是订单 Tab 的核心视觉设计。通过 ForEach 遍历 orderList,为每条订单渲染一个固定高度 72px 的时间轴行。每行从左到右包含三个部分:
左侧时间 + 状态:44px 宽度的 Column,显示下单时间(11px 粗体标题色)和订单状态(8px,颜色由 statusColor 函数动态计算)。alignItems(HorizontalAlign.Start) 使文字左对齐,padding({ top: 12 }) 与时间轴圆点垂直对齐。
中间圆点 + 连接竖线:10px 宽度的 Column,顶部是一个 8px 的状态色圆点(颜色与订单状态一致)。如果不是最后一个订单,圆点下方有一条 2px 宽的竖线(COLORS.line 深蓝色),通过 layoutWeight(1) 填充剩余高度。这条竖线将所有时间轴圆点串联起来,形成连续的时间轴视觉效果。最后一个订单没有竖线,表示时间轴的终点。
右侧订单卡:占据剩余空间的 Row,背景为卡片色、圆角 10px。左侧是菜品内容(12px 粗体标题色,maxLines(1) 限制单行,textOverflow 省略号处理)和金额(9px 副文本色)。右侧是两个操作按钮——“取餐通知”(蓝色,点击调用 publishNotice() 发布通知)和"编辑"(副文本色,点击时将当前订单的索引和数据复制到编辑表单状态变量,然后打开编辑弹窗)。
固定行高设计:.height(72) 确保每行高度固定为 72px。这种设计使得时间轴的竖线长度一致,视觉效果整齐。如果行高不固定(根据内容自适应),竖线长度会参差不齐,影响时间轴的连续感。
键值函数:(item: OrderItem) => item.time + item.title 使用时间 + 菜品内容作为唯一键,确保每条订单有稳定的标识。
二十、铃音 Tab 构建 tabRing
/** 铃音 Tab:生成器 + 当前默认铃声 + 铃声库 + 发布通知入口 */
@Builder
tabRing() {
Column({ space: 12 }) {
// 1. 沙箱铃声生成器卡
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.blue).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.blue).trackColor(COLORS.chip).blockColor(COLORS.blue)
.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.gold).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.gold).trackColor(COLORS.chip).blockColor(COLORS.gold)
.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.blue).borderRadius(10)
.onClick(() => {
this.createRingByGen();
})
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
tabRing 构建函数渲染铃音 Tab,这是整个应用功能最丰富的 Tab,包含铃声生成器、当前铃声展示、铃声库管理和通知发布四个卡片。由于篇幅较长,我们分块解析。
沙箱铃声生成器卡:这是铃音页的第一个卡片,允许用户通过两个 Slider 调整频率和时长参数,然后生成自定义铃声写入沙箱。
频率控制区显示当前频率值(蓝色粗体)和范围说明(220~1760 Hz)。Slider 组件绑定 genFreq 状态,步进值 20 Hz,滑块颜色为科技蓝。onChange 回调在用户拖动滑块时实时更新 genFreq,使得频率显示和后续铃声生成都使用最新值。
时长控制区与频率控制区结构相同,但使用香槟金色系(selectedColor 和 blockColor 均为 COLORS.gold),步进值 100 ms,范围 600~2400 ms。频率用蓝色、时长用金色的颜色区分设计,使得用户能够直观区分两个参数。
"生成铃声到沙箱"按钮使用蓝色背景 + 白色文字,全宽居中。点击时调用 createRingByGen() 方法,使用当前生成器参数创建新铃声并写入沙箱。
// 2. 当前默认铃声卡(展示 sound 字段实际取值)
Column({ space: 8 }) {
Row() {
Text('🎵 当前默认铃声').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('沙箱文件 ' + this.sandboxCount.toString() + ' 个').fontSize(9).fontColor(COLORS.green)
}
.width('100%')
Text(this.currentRingIdx >= 0 && this.currentRingIdx < this.ringList.length ?
this.ringList[this.currentRingIdx].name : '暂无铃声')
.fontSize(15).fontColor(COLORS.gold).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)
当前默认铃声卡:展示当前选中的通知铃声名称和 sound 字段的实际 URI 值。标题栏右侧显示已导入沙箱的铃声数量(绿色文字)。铃声名称使用 15px 香槟金粗体——这是铃音页的视觉焦点,与概览页的营收大数字形成呼应。
最值得关注的是 URI 展示区——this.getSoundValue() 返回的 'uri::' + URI 字符串以 8px 副文本色显示在芯片色背景上,最多 2 行,超出省略。这种将内部技术参数(通知 sound 字段值)透明化展示在 UI 中的设计,是本应用的一个技术亮点——它让用户(尤其是开发者用户)能够直观看到通知铃声的实际配置值,理解"自定义铃声"的技术原理。
// 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.blue).borderRadius(5)
}
}
Text(item.freq.toString() + ' Hz · ' + item.duration.toString() + ' ms · ' + item.size)
.fontSize(8).fontColor(COLORS.text3)
}
.alignItems(HorizontalAlign.Start).layoutWeight(1)
Text(item.inSandbox ? '沙箱中' : '未导入').fontSize(8)
.fontColor(item.inSandbox ? COLORS.green : COLORS.orange)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor(item.inSandbox ? COLORS.greenL : COLORS.goldL)
.borderRadius(6)
}
.width('100%')
Row({ space: 8 }) {
if (!item.inSandbox) {
Text('导入沙箱').fontSize(9).fontColor(COLORS.gold)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.goldL).borderRadius(7)
.onClick(() => {
this.importRingToSandbox(idx);
})
}
Text('设为默认').fontSize(9).fontColor(COLORS.blue)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 5, bottom: 5 }).backgroundColor(COLORS.blueL).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,为每条铃声渲染一个管理卡片。每个卡片包含信息区和操作区两部分:
信息区显示铃声图标(🎵)、名称(12px 粗体标题色)、当前默认标记(蓝色"默认"标签,仅 currentRingIdx 对应的铃声显示)、参数信息(频率 + 时长 + 大小,8px 三级文本色)和沙箱状态标签("沙箱中"绿色 / "未导入"橙色)。
操作区包含三个按钮(条件渲染):如果铃声未导入沙箱,显示"导入沙箱"按钮(金色,点击调用 importRingToSandbox(idx));"设为默认"按钮(蓝色,点击调用 setCurrentRing(idx))始终显示;"删除"按钮(红色,点击设置 delIdx 并打开删除确认弹窗)。三个按钮使用 layoutWeight(1) 等分宽度,颜色策略遵循"金色=导入、蓝色=设置、红色=删除"的一致语义。
// 4. 行业发布通知卡
Column({ space: 8 }) {
Text('📣 发布取餐通知').fontSize(13).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('向到店顾客推送携带沙箱自定义铃声的取餐通知,出餐完成后一键送达。')
.fontSize(9).fontColor(COLORS.sub)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
Text('立即发布通知').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
.width('100%').textAlign(TextAlign.Center)
.padding({ top: 10, bottom: 10 }).backgroundColor(COLORS.gold).borderRadius(10)
.onClick(() => {
this.publishNotice();
})
Row() {
Text('通知 ID:' + this.notifyId.toString()).fontSize(8).fontColor(COLORS.text3)
Column().layoutWeight(1)
Text('已发送:' + this.sentCount.toString() + ' 条').fontSize(8).fontColor(COLORS.text3)
}
.width('100%')
}
.width('100%').padding(14).backgroundColor(COLORS.card).borderRadius(12)
}
.width('100%')
}
发布通知卡:铃音页的最后一个卡片,提供了发布取餐通知的直接入口。标题"📣 发布取餐通知",下方是功能说明文字(9px 副文本色,最多 2 行)。核心是"立即发布通知"按钮——使用金色背景 + 白色文字,与铃声生成器的蓝色按钮形成颜色对比,暗示不同的操作类型。点击调用 publishNotice() 发布通知。底部显示当前通知 ID(notifyId)和已发送通知数(sentCount),让用户了解通知的状态信息。
二十一、我的 Tab 构建 tabMine
/** 我的 Tab:营业额渐变大卡 + 经营统计清单 */
@Builder
tabMine() {
Column({ space: 12 }) {
// 本月营业额渐变大卡
Column({ space: 8 }) {
Text('🏪 门店本月营业额').fontSize(11).fontColor('rgba(255,255,255,0.7)')
Text('¥ 152,680').fontSize(30).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
Row({ space: 10 }) {
Column({ space: 3 }) {
Text('月目标完成').fontSize(8).fontColor('rgba(255,255,255,0.7)')
Text('86%').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('5,286').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('31%').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.blueD, 0], [COLORS.purple, 1]] })
tabMine 构建函数渲染"我的"Tab,采用"渐变大卡 + 统计清单 + 门店信息"的总结式布局。下面逐块解析:
本月营业额渐变大卡:这是整个应用视觉冲击力最强的卡片——使用 linearGradient 属性设置了 135 度角的对角线渐变,从 blueD(深科技蓝 #3B6FE0)到 purple(柔和紫色 #A78BFA)。蓝紫渐变在深色主题上营造出高级、梦幻的视觉效果,与其它卡片的纯色背景形成鲜明对比,使得"我的"页的顶部区域成为整个应用的视觉高潮。
卡片内容采用白色系文字——标题使用 rgba(255,255,255,0.7)(70% 不透明白色),大数字"¥ 152,680"使用纯白色 30px 粗体。在渐变背景上使用白色文字而非深色主题的其他文字色,是因为渐变背景本身较亮,白色文字才能保证足够的对比度。大数字下方是三个等宽的数据指标——月目标完成 86%、本月订单 5,286、新客占比 31%,均使用白色粗体数字 + 半透明白色标签的组合。
// 经营统计清单
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.blue).fontWeight(FontWeight.Bold)
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
}, (item: StatItem) => item.name)
// 门店信息卡
Column({ space: 6 }) {
Row() {
Text('🏪 门店信息').fontSize(12).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column().layoutWeight(1)
Text('营业中').fontSize(9).fontColor(COLORS.green)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.backgroundColor(COLORS.greenL).borderRadius(8)
}
.width('100%')
Text('智赢餐饮·科技园旗舰店 · 营业时间 10:00-22:00')
.fontSize(9).fontColor(COLORS.sub)
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%').padding(12).backgroundColor(COLORS.card).borderRadius(10)
}
.width('100%')
}
经营统计清单:通过 ForEach 遍历 statList,为每条统计数据渲染一个行卡片。每行从左到右包含:Emoji 图标(16px)、统计名称(11px 粗体标题色)和标签(8px 三级文本色)的 Column、以及右侧的统计数值(13px 蓝色粗体)。这种"图标 + 名称/标签 + 数值"的三段式布局是统计列表的经典模式——左侧图标提供视觉识别,中间文字提供语义说明,右侧数字提供核心数据。使用 layoutWeight(1) 使中间区域占据图标和数值之间的空间,保证数值右对齐。
门店信息卡:最后一部分是门店信息卡片,标题栏左侧"🏪 门店信息",右侧"营业中"状态标签(绿色文字 + 浅绿背景),传达门店当前处于营业状态。下方是门店名称和营业时间"智赢餐饮·科技园旗舰店 · 营业时间 10:00-22:00"(9px 副文本色,单行省略)。这个卡片为整个"我的"页收尾,提供了门店的基本信息上下文。
二十二、月度营收柱状图 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.blue)
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 内容的下方。与概览页的 Canvas 折线图不同,这个柱状图完全使用 ArkUI 的声明式布局实现,没有使用 Canvas 绘图。下面解析其实现:
标题栏:左侧"📊 门店月度营收"(13px 粗体),右侧"单位:万元"(9px 三级文本色),中间用 layoutWeight(1) 推开。
柱状图实现:通过 ForEach 遍历 MONTH_IDX(6 个月份索引),为每个月渲染一个柱状图单元。外层 Row 使用 alignItems(VerticalAlign.Bottom) 使所有柱子底部对齐,height(150) 限定图表区域高度。
每个柱状图单元从上到下包含:柱体(Column 组件,宽度 16px,高度动态计算)和月份标签(8px 三级文本色)。柱体高度的计算公式为 Math.max(20, MONTH_VAL[i] / MONTH_MAX * 110)——用月度数值除以最大值得到比例,乘以 110 得到像素高度,Math.max(20, ...) 确保最小高度 20px(即使数值为 0 也有可见的柱体)。这种声明式的柱状图实现方式虽然不如 Canvas 灵活,但对于简单的柱状图来说足够使用,且代码更简洁。
末端柱体呼吸效果:最后一个柱体(8 月)的高度计算增加了呼吸逻辑——当 this.breath 为 true 且是最后一个柱体时,高度额外增加 4px(+ 4),为 false 时不增加。这使得最后一个月的柱体在高度上产生 4px 的波动,形成呼吸效果,与折线图末端节点的呼吸逻辑形成呼应。柱体使用 COLORS.blue(科技蓝)作为填充色,圆角 4px。
组件位置:chartCard 在 build() 中位于条件链之外,始终显示在 Tab 内容下方。无论用户在概览、订单、铃音还是我的 Tab,都能看到月度营收柱状图。这种设计使得月度营收统计成为全局性的数据反馈,不受 Tab 切换影响。
二十三、底部导航栏 tabBar
/** 底部导航:4 Tab 单排 */
@Builder
tabBar() {
Row() {
ForEach(TAB_LIST, (t: TabMeta, idx: number) => {
Column({ space: 3 }) {
Text(t.icon).fontSize(this.currentTab === idx ? 20 : 17)
.opacity(this.currentTab === idx ? 1 : 0.65)
Text(t.label).fontSize(9)
.fontColor(this.currentTab === idx ? COLORS.tabOn : COLORS.text3)
.fontWeight(this.currentTab === idx ? FontWeight.Bold : FontWeight.Normal)
}
.layoutWeight(1).alignItems(HorizontalAlign.Center)
.padding({ top: 7, bottom: 7 })
.onClick(() => {
this.currentTab = idx;
})
}, (t: TabMeta) => t.label)
}
.width('100%')
.backgroundColor(COLORS.card)
.border({ width: { top: 1 }, color: COLORS.line })
}
tabBar 构建函数渲染底部 4 Tab 导航栏。通过 ForEach 遍历 TAB_LIST,为每个 Tab 渲染一个图标 + 标签的 Column。选中态与非选中态的视觉差异通过三个维度实现:
图标大小:选中 Tab 的图标 20px,非选中 17px——3px 的尺寸差异在视觉上使得选中态更突出。
图标透明度:选中 Tab 的图标完全不透明(opacity(1)),非选中 0.65——透明度差异使得非选中 Tab 在视觉上"后退",选中 Tab “前突”。
标签颜色与粗细:选中 Tab 的标签使用 COLORS.tabOn(科技蓝)+ 粗体,非选中使用 COLORS.text3(三级文本色)+ 常规字重——颜色和粗细的双重差异确保了选中态的高辨识度。
每个 Tab 使用 layoutWeight(1) 等分底部空间,alignItems(HorizontalAlign.Center) 使图标和标签居中对齐。点击事件将 currentTab 设为被点击 Tab 的索引,触发条件渲染切换 Tab 内容。底部导航栏使用卡片色背景,顶部有 1px 的深蓝色边框线(border({ width: { top: 1 }, color: COLORS.line }))与内容区分隔。
二十四、弹窗系统
24.1 全屏遮罩
/** 弹窗全屏遮罩(点击遮罩关闭弹窗) */
@Builder
modalOverlay(onClose: () => void) {
Stack() {
Column().width('100%').height('100%').backgroundColor(COLORS.mask)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
.onClick(() => onClose())
}
modalOverlay 是弹窗系统的基础组件——一个全屏的半透明遮罩层。它接收一个 onClose 回调函数作为参数,点击遮罩时调用该回调关闭弹窗。遮罩使用 COLORS.mask(rgba(0,0,0,0.55) 半透明黑色)作为背景色,alignContent(Alignment.Center) 使得在遮罩之上添加的弹窗面板内容自动居中。这种"遮罩 + 居中面板"的模式是弹窗系统的标准设计——遮罩阻断用户与底层内容的交互,弹窗面板成为唯一的视觉焦点。
24.2 新增订单弹窗
/** 新增订单弹窗面板 */
@Builder
panelAdd(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('新增订单').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column({ space: 6 }) {
Text(FORM_LABELS[0]).fontSize(9).fontColor(COLORS.sub)
TextInput({ text: this.formTime, placeholder: '如 13:05' })
.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: '如 招牌牛肉面 x2' })
.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.formAmount, placeholder: '如 56.00' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.formAmount = value;
})
}
.width('100%').alignItems(HorizontalAlign.Start)
Row({ space: 10 }) {
Text('取消').fontSize(12).fontColor(COLORS.sub)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
.onClick(() => onClose())
Text('保存').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.blue).borderRadius(9)
.onClick(() => {
this.saveOrder();
})
}
.width('100%')
}
.width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
panelAdd 是新增订单弹窗面板。它使用 Stack 将 modalOverlay(遮罩层)和 Column(面板内容)层叠在一起。面板宽度为屏幕的 78%,内边距 16px,卡片色背景,14px 圆角。
面板内容包含标题"新增订单"和三个表单字段。每个表单字段使用 Column 垂直排列标签和输入框——标签来自 FORM_LABELS 常量数组(“下单时间”、“菜品内容”、“订单金额”),9px 副文本色。TextInput 组件的 text 属性绑定对应的 @State 表单变量,placeholder 提供输入提示。输入框使用芯片色背景和 8px 圆角,onChange 回调将用户输入实时写入表单状态变量。
底部是"取消"和"保存"两个等宽按钮。"取消"使用副文本色 + 芯片色背景,点击调用 onClose 关闭弹窗。"保存"使用白色文字 + 科技蓝背景,点击调用 saveOrder() 方法保存订单。这种"弱化取消 + 强化保存"的按钮颜色策略是表单弹窗的标准设计——引导用户聚焦于主要操作(保存)。
24.3 编辑订单弹窗
/** 编辑订单弹窗面板 */
@Builder
panelEdit(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('编辑订单').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Column({ space: 6 }) {
Text(FORM_LABELS[0]).fontSize(9).fontColor(COLORS.sub)
TextInput({ text: this.editTime, placeholder: '下单时间' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.editTime = value;
})
}
.width('100%').alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text(FORM_LABELS[1]).fontSize(9).fontColor(COLORS.sub)
TextInput({ text: this.editTitle, placeholder: '菜品内容' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.editTitle = value;
})
}
.width('100%').alignItems(HorizontalAlign.Start)
Column({ space: 6 }) {
Text(FORM_LABELS[2]).fontSize(9).fontColor(COLORS.sub)
TextInput({ text: this.editAmount, placeholder: '订单金额' })
.fontSize(11).fontColor(COLORS.title)
.backgroundColor(COLORS.chip).borderRadius(8)
.onChange((value: string) => {
this.editAmount = value;
})
}
.width('100%').alignItems(HorizontalAlign.Start)
Row({ space: 10 }) {
Text('取消').fontSize(12).fontColor(COLORS.sub)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
.onClick(() => onClose())
Text('保存').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.blue).borderRadius(9)
.onClick(() => {
this.updateOrder();
})
}
.width('100%')
}
.width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
panelEdit 是编辑订单弹窗面板,结构与 panelAdd 高度一致——相同的三表单字段布局、相同的取消/保存按钮设计。区别在于:标题改为"编辑订单";表单状态变量使用 editTime/editTitle/editAmount 而非 formTime/formTitle/formAmount;保存按钮调用 updateOrder() 而非 saveOrder()。
当用户点击订单时间轴中的"编辑"按钮时,目标订单的索引和数据被复制到编辑表单状态变量(editIdx = idx; editTime = item.time; editTitle = item.title; editAmount = item.amount),然后打开编辑弹窗。这种"先填充表单数据再打开弹窗"的设计确保了弹窗打开时输入框已有预填值,用户可以在原有数据基础上修改。保存时,updateOrder() 方法通过 editIdx 找到目标订单,将编辑后的数据写回 orderList,由于 OrderItem 是 @Observed 类,属性变化自动触发时间轴 UI 更新。
24.4 删除铃声确认弹窗
/** 删除铃声确认弹窗面板 */
@Builder
panelDel(onClose: () => void) {
Stack() {
this.modalOverlay(onClose)
Column({ space: 12 }) {
Text('删除铃声').fontSize(15).fontColor(COLORS.title).fontWeight(FontWeight.Bold)
Text('确认删除该铃声吗?删除后将同步清理沙箱中的音频文件,且不可恢复。')
.fontSize(10).fontColor(COLORS.sub)
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 10 }) {
Text('取消').fontSize(12).fontColor(COLORS.sub)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.chip).borderRadius(9)
.onClick(() => onClose())
Text('确认删除').fontSize(12).fontColor(COLORS.white).fontWeight(FontWeight.Bold)
.layoutWeight(1).textAlign(TextAlign.Center)
.padding({ top: 9, bottom: 9 }).backgroundColor(COLORS.red).borderRadius(9)
.onClick(() => {
this.delRing();
})
}
.width('100%')
}
.width('78%').padding(16).backgroundColor(COLORS.card).borderRadius(14)
}
.width('100%')
.height('100%')
.alignContent(Alignment.Center)
}
panelDel 是删除铃声确认弹窗面板。与前两个弹窗不同,这个弹窗没有表单字段,只有一个确认提示和两个按钮。确认提示文字"确认删除该铃声吗?删除后将同步清理沙箱中的音频文件,且不可恢复。"明确告知用户删除操作会同步清理沙箱文件且不可撤销——这种风险提示在删除类操作中是必要的 UX 设计。
按钮设计遵循"弱化取消 + 强化删除"的策略:"取消"使用副文本色 + 芯片色背景(调用 onClose 关闭弹窗不执行删除),"确认删除"使用白色文字 + 红色背景(调用 delRing() 执行删除)。红色背景在视觉上传达"危险操作"的语义,与删除操作的性质一致。确认删除后,delRing() 方法完成沙箱文件清理、列表移除、索引修正和弹窗关闭四步操作。
整个弹窗系统通过 modalOverlay 统一遮罩、Stack 层叠包裹和条件渲染(if 判断弹窗状态)三个机制协同工作,实现了新增、编辑、删除三种交互场景的统一管理。这种设计将弹窗的显示逻辑集中在 build() 中,将面板内容拆分到独立的 @Builder 函数中,做到了显示控制与内容渲染的解耦。
二十五、技术特性对比表
| 技术维度 | 概览 Tab | 订单 Tab | 铃音 Tab | 我的 Tab |
|---|---|---|---|---|
| 布局风格 | 仪表盘式(大数字+小卡+图表) | 流水时间轴式 | 配置管理式 | 总结清单式 |
| 核心视觉元素 | 36px 金色大数字 + Canvas 折线图 | 时间圆点 + 竖线 + 订单卡 | 生成器 Slider + 铃声库列表 | 蓝紫渐变大卡 + 统计行 |
| 数据模型 | MiniStat(4 字段) | OrderItem(4 字段) | RingItem(6 字段) | StatItem(4 字段) |
| 数据量 | 4 条小卡 + 7 日折线 | 7 条订单 | 6 条铃声 | 5 条统计 |
| 动画效果 | Canvas 末端节点呼吸 | 状态色圆点 | 生成器滑块实时值 | 渐变大卡静态 |
| 交互操作 | 快捷接单/退款/清台/交接班 | 取餐通知/编辑/新增 | 导入沙箱/设默认/删除/生成 | 查看统计 |
| 颜色策略 | 金色高亮营收 + 蓝色数据 | 状态色映射(绿/蓝/红) | 蓝金双色区分参数 | 白色文字 + 蓝紫渐变 |
| 信息密度 | 高(大数字+4 小卡+图表+操作) | 中高(时间轴+筛选+操作) | 高(生成器+铃声+库+通知) | 中(渐变卡+清单+信息) |
| 技术亮点 | Canvas 命令式绘图 + 呼吸联动 | 固定行高时间轴布局 | 沙箱文件管理 + URI 构建 | 线性渐变背景 |
| 能力维度 | 实现方式 | 关键代码/机制 |
|---|---|---|
| 通知铃声定制化 | NotificationRequest.sound + 沙箱 URI | 'uri::' + fileUri.getUriFromPath(path) |
| 沙箱文件管理 | fileIo EL1 区域读写删除 | fs.openSync / writeSync / unlinkSync |
| WAV 音频生成 | 纯 JS 构造 RIFF 格式字节 | buildWavBytes() 函数 |
| 通知授权两段式 | requestEnableNotification + openNotificationSettings | requestAuth() 方法 |
| Canvas 折线图 | CanvasRenderingContext2D 命令式绘图 | drawLineChart() 方法 |
| 呼吸动画联动 | setInterval 定时器 + breath 状态 | aboutToAppear() 启动 + drawLineChart 重绘 |
| 状态管理 | @State + @Observed 响应式 | 20+ 状态变量 + 3 个 @Observed 类 |
| 组件化拆分 | @Builder 构建函数 | 11 个 Builder 函数 |
| 弹窗系统 | Stack 层叠 + 条件渲染 + 遮罩 | modalOverlay + panelAdd/Edit/Del |
| 深色主题 | ColorPalette 接口 + 22 色常量 | COLORS 常量对象 |
| 生命周期管理 | aboutToAppear / aboutToDisappear | 定时器创建与清理 |
二十六、总结
"智赢门店·门店经营平台"作为一款基于 HarmonyOS ArkUI 框架开发的智慧餐饮门店 SaaS Demo 应用,在通知铃声定制化、沙箱文件管理、Canvas 数据可视化、深色主题色彩工程、多 Tab 差异化布局等方面展现了丰富的技术实践和工程化思考,是鸿蒙原生应用开发中一份极具参考价值的实现方案。
通知铃声定制化方面,这是本应用最具技术深度的核心特性。应用完整实现了 HarmonyOS 6.1.1 自定义通知铃声的全链路——从 WAV 音频字节的内存生成(buildWavBytes 函数构造符合 RIFF 规范的 16bit 单声道 PCM 音频),到沙箱 EL1 区域的文件写入(saveRingToSandbox 设置 contextConstant.AreaMode.EL1 并使用 fs.openSync 写入),再到 URI 转换与通知发布(getSoundValue 通过 fileUri.getUriFromPath 构造 uri:: 前缀值,publishNotice 将其填入 NotificationRequest.sound 字段)。这条技术链路涵盖了音频编解码、文件系统、通知系统三大子系统的交叉运用,是 HarmonyOS 系统能力整合的典型案例。在真实场景中,门店可以将云端下载的铃声包或用户自行录制的音频文件落地到沙箱目录,实现"一店一铃、一场景一铃"的个性化通知方案。
沙箱文件管理方面,应用展示了应用沙箱的完整生命周期管理——文件创建(CREATE | WRITE_ONLY | TRUNC 模式打开、写入、关闭)、文件删除(unlinkSync 同步删除)、区域设置(EL1 级别确保通知系统可访问)。铃声的 inSandbox 状态标志、sandboxCount 计数器、删除时的同步清理逻辑,构成了一个完善的资源管理闭环。这种"数据状态与文件系统同步"的设计理念,在实际项目中避免资源泄漏和状态不一致的关键工程实践。
Canvas 数据可视化方面,应用展示了 ArkUI 中 Canvas 命令式绘图与声明式状态驱动的混合编程模式。drawLineChart 方法从零开始绘制了包含网格线、渐变面积填充、主折线、数据节点和横轴标签的完整折线图,运用了路径绘制、线性渐变、圆形绘制、文本渲染等 Canvas 2D API 的核心原语。更巧妙的是,折线图与全局呼吸动画定时器联动——每秒重绘一次,末端数据节点半径在 3px 和 5px 之间交替变化,形成"呼吸"效果。这种将命令式绘图的精确控制能力与声明式状态驱动的响应式更新能力相结合的模式,为鸿蒙原生应用中的数据可视化提供了一种兼顾灵活性和响应性的实践路径。月度营收柱状图则展示了另一种方案——完全使用 ArkUI 声明式布局(Column 高度动态计算)实现简单图表,代码更简洁但灵活性低于 Canvas。
深色主题色彩工程方面,应用通过 ColorPalette 接口集中管理 22 个颜色字段,采用"科技蓝 + 香槟金 + 深色背景"的三色组合方案。每个色系遵循"主色 + 深色变体 + 浅色变体"的三级结构,其中深色主题中的"浅色变体"实际上是极深的暗色(如 blueL 为 #10224A),用于按钮背景——这是深色主题色彩工程中一个容易被忽视但至关重要的设计要点。三级文字层次(title/sub/text3)在深色背景上创造了清晰的信息优先级,功能色(蓝/金/绿/红/紫/橙)为不同业务场景提供了丰富的视觉表达。linearGradient 渐变在"我的"页面的营业额大卡上创造了整个应用的视觉高潮,展示了深色主题下渐变设计的魅力。
多 Tab 差异化布局方面,4 个 Tab 采用仪表盘式、流水时间轴式、配置管理式、总结清单式四种截然不同的布局风格。这种"一页多态"的设计哲学是对"不同业务场景需要不同的信息表达方式"这一设计原则的深度践行。订单 Tab 的竖向时间轴(固定行高 72px、状态色圆点、连接竖线)是布局设计中的亮点——它将时间维度、状态维度和数据维度通过"左时间 + 中圆点竖线 + 右订单卡"的三栏结构有机融合。铃音 Tab 的配置管理式布局(生成器 + 当前铃声 + 铃声库 + 发布入口)将复杂的通知铃声配置流程拆分为四个清晰的步骤卡片,引导用户从"生成/导入铃声"到"设为默认"再到"发布通知"完成完整操作。
工程化实践方面,应用展示了多个值得借鉴的工程化细节:@Observed 数据模型为响应式更新做好准备,列表数据变化自动反映到 UI;aboutToAppear/aboutToDisappear 生命周期函数负责定时器创建与清理,避免内存泄漏;弹窗系统通过 Stack 层叠 + 条件渲染 + 统一遮罩实现三种交互场景的集中管理;通知授权的两段式请求逻辑(requestEnableNotification + openNotificationSettings)处理了首次请求和二次请求两种场景;buildWavBytes 函数展示了在不依赖外部库的情况下通过纯代码构造符合格式规范的二进制文件的能力。这些工程化细节虽然不如视觉设计那样直观,但正是它们支撑了应用在功能完整性、交互流畅度和代码可维护性上的平衡。
改进方向方面,虽然 Demo 应用已经展现了很高的技术完成度,但在实际生产环境中仍有以下拓展空间:使用 LazyForEach 替代 ForEach 以优化长列表(如大量订单)的渲染性能;引入路由管理处理订单详情、铃声编辑等深层页面跳转;将数据模型和 Mock 数据抽取到独立模块文件中,实现关注点分离;增加网络请求层实现真实数据加载和铃声包云端下载;添加错误处理和加载状态(如通知发布失败的 Toast 提示);使用 @StorageLink 或 @Provide/@Consume 实现跨组件状态共享(如授权状态的全局同步);将 Canvas 折线图封装为可复用的自定义组件,支持动态数据源和配置参数。这些改进方向既是 Demo 走向生产化的必经之路,也是进一步探索 ArkUI 框架能力的方向。
总的来说,"智赢门店"通过 HarmonyOS 6.1.1 自定义通知铃声特性的完整工程化落地、Canvas 呼吸联动折线图的命令式绘图实践、深色科技蓝主题的色彩工程、4 种差异化 Tab 布局的设计表达,以及沙箱文件全生命周期管理的系统能力整合,成功地构建了一个功能完整、技术深厚、视觉优雅的智慧餐饮门店经营平台 Demo。它不仅展示了 ArkUI 框架在复杂 SaaS 应用中的技术承载力,更为通知铃声定制化、沙箱文件管理、Canvas 数据可视化等 HarmonyOS 特色能力的工程化实践提供了有价值的参考范例。
附录:DevEco Studio 创建新项目与查看 SDK 版本
本章节演示如何使用 DevEco Studio 创建一个 HarmonyOS 新项目,并查看当前 IDE 已安装的 SDK 版本,适合作为其他技术博文的补充操作指南。
一、创建新项目
1.1 进入欢迎界面
启动 DevEco Studio 后,首先看到的是欢迎界面。左侧导航栏默认选中 “项目”,右侧提供三个主要入口:
- 新建项目:从头创建新项目
- 打开项目:打开本地已有项目
- 克隆仓库:从 Git 等版本控制拉取代码
点击 “新建项目” 按钮,进入项目创建向导。

1.2 选择项目模板
在弹出的"新建项目"对话框中,左侧分类标签提供了两种项目类型:
| 类型 | 说明 |
|---|---|
| 应用(Application) | 开发标准的 HarmonyOS 应用,具备完整的 Ability 生命周期 |
| 元服务(Atomic Service) | 开发轻量级的原子化服务,无需安装即可使用 |
选择 “应用” 标签后,右侧展示多种模板。对于大多数场景,推荐选择 “Empty Ability” —— 这是一个最基础的入门模板,仅包含 Hello World 功能,适合从零开始构建应用。

1.3 配置项目信息
点击 “下一步” 后,进入项目配置界面,需要填写以下核心参数:
| 配置项 | 示例值 | 说明 |
|---|---|---|
| 项目名称(Project name) | rollboat |
应用的项目名称,建议使用英文命名 |
| 包名(Bundle name) | com.rollboat.myapplication |
应用唯一标识,采用反向域名格式 |
| 保存路径(Save location) | D:\CodeFactory\rollboat |
项目本地存储路径,避免使用中文和空格 |
| 兼容 SDK(Compatible SDK) | 6.1.1(24) |
目标 HarmonyOS API 版本,点击"查看参考"可了解各版本差异 |
| 模块名称(Module name) | entry |
主模块名称,默认 entry 为应用入口模块 |
| 设备类型(Device types) | ☑ Phone | 勾选目标设备:Phone / Tablet / 2in1 / Car / Wearable / TV |
右侧预览区会实时展示当前模板的默认效果 —— 一个居中显示的 “Hello World” 文本。

1.4 完成创建
确认配置无误后,点击右下角 “完成” 按钮,IDE 将自动执行以下操作:
- 生成项目骨架(Stage 模型目录结构)
- 执行
ohpm install安装依赖 - 运行 Hvigor 构建初始化(
Build Init)
构建日志中显示 “退出代码为 0” 表示项目初始化成功。

1.5 项目结构概览
创建完成后,左侧项目面板展示的是标准的 Stage 模型 目录结构:
rollboat/
├── .hvigor/ # Hvigor 构建工具缓存
├── .idea/ # IDE 配置文件
├── AppScope/ # 应用级全局配置
│ └── app.json5
├── entry/ # 主模块(入口模块)
│ ├── src/main/ets/
│ │ ├── entryability/ # Ability 生命周期管理
│ │ │ └── EntryAbility.ets
│ │ └── pages/ # UI 页面
│ │ └── Index.ets # 首页(默认 Hello World)
│ ├── src/main/resources/ # 资源文件
│ ├── module.json5 # 模块配置
│ └── build-profile.json5 # 构建配置
├── oh_modules/ # OHPM 依赖包
├── build-profile.json5 # 工程构建配置
├── hvigorfile.ts # Hvigor 构建脚本
└── oh-package.json5 # 包管理配置
核心文件 Index.ets 的默认代码如下,采用 ArkTS 声明式 UI 语法:
@Entry
@Component
struct Index {
@State message: string = 'Hello World';
build() {
RelativeContainer() {
Text(this.message)
.id('HelloWorld')
.fontSize($r('app.float.page_text_font_size'))
.fontWeight(FontWeight.Bold)
.alignRules({
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onClick(() => {
this.message = 'Welcome';
})
}
.height('100%')
.width('100%')
}
}
| 关键语法 | 作用 |
|---|---|
@Entry |
标记为页面入口,可用于路由跳转 |
@Component |
声明为自定义组件 |
@State |
状态变量,数据变更时自动触发 UI 刷新 |
RelativeContainer |
相对布局容器,替代传统线性布局 |
.onClick() |
点击事件,此处点击后文本变为 “Welcome” |
打开右侧 Previewer(预览器),选择 Phone 设备,即可实时预览 Hello World 效果,无需连接真机或启动模拟器。

二、查看 SDK 版本
2.1 查看 HarmonyOS SDK
DevEco Studio 安装时已内置 HarmonyOS SDK,无需单独下载。通过以下路径查看:
文件 → 设置 → HarmonyOS SDK(或快捷键
Ctrl + Alt + S搜索 “HarmonyOS SDK”)
在设置面板中,可以看到当前已安装的 SDK 版本信息:
| 名称 | 阶段 | 状态 |
|---|---|---|
| HarmonyOS 6.1.1 | Release | ✅ 已安装 |
界面顶部提示:“HarmonyOS SDK 已经包含在 IDE,无需单独安装”,省去了手动配置 SDK 的繁琐步骤。

2.2 查看 ArkUI-X SDK(跨平台扩展)
如果项目需要将 ArkUI 框架扩展到多个 OS 平台(Android / iOS / OpenHarmony),还需要配置 ArkUI-X SDK。路径如下:
文件 → 设置 → 语言和框架 → ArkUI-X
在这里可以查看已安装和可选的 ArkUI-X SDK 版本:
| 版本 | SDK 版本号 | 阶段 | 状态 |
|---|---|---|---|
| API Version 24 | 6.1.1.100 | Release | ✅ 已安装 |
| API Version 23 | 6.1.0.28 | Beta1 | 未安装 |
| API Version 22 | 6.0.2.112 | Release | 未安装 |
安装路径示例:D:\DevTools\ArkUI-X\sdk
说明:ArkUI-X 允许开发者使用一套 ArkTS 主代码,同时构建多平台应用。如果仅开发 HarmonyOS 原生应用,无需额外安装 ArkUI-X SDK。

三、小结
| 步骤 | 操作 | 关键点 |
|---|---|---|
| 创建项目 | 欢迎页 → 新建项目 → 选择 Empty Ability 模板 → 配置项目信息 → 完成 | 使用 Stage 模型 + ArkTS 语言 |
| 查看 SDK | 设置 → HarmonyOS SDK | SDK 已内置,无需手动安装 |
| 跨平台扩展 | 设置 → ArkUI-X | 根据需要安装对应 API 版本 |
至此,DevEco Studio 的项目创建与 SDK 环境确认全部完成,可以开始 HarmonyOS 应用的功能开发。
本文基于 DevEco Studio 6.1.1 Release 版本编写,不同版本界面可能存在细微差异。
更多推荐

所有评论(0)