在此 Codelab 中,您将利用面板小程序开发构建出一个高压储能柜,实现控制设备工作模式和充放电的基本能力。
详见 面板小程序 - 搭建环境
产品名称:智能储能柜
工作模式
功能峰谷电设置
功能本模版的功能在「节能能源」品类中较为通用,这里以「高压储能设备」为例
首先需要创建一个节能能源类产品,定义产品有哪些功能点,然后面板中再根据这些功能点一一实现。
进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择 标准类目 -> 节能能源 -> 高压储能设备
:
选择功能点,这里根据自己需求选择即可,视频中预览使用了自定义功能(充电开关 —— charger_switch 布尔型、放电开关 —— discharge_switch 布尔型、时间段设置 —— time_schedule 透传型、工作模式 —— work_mode 字符型)。
🎉 在这一步,我们创建了一个智能储能柜产品。
这部分我们在 小程序开发者
平台上进行操作,注册登录 小程序开发者平台。
拉取项目
git clone https://github.com/Tuya-Community/tuya-ray-demo.git
进入 模式设置模板,直接通过 IDE 导入源码目录,并关联到创建的小程序。
cd ./examples/panel-energy-mode-set
导入项目到 IDE,并关联到已经创建的面板小程序与产品。
上面的步骤我们已经初始化好了一个面板小程序
的开发模板,下面我们介绍下工程目录。
.
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .npmrc
├── .prettierrc.js
├── commitlint.config.js
├── README_zh.md
├── README.md
├── package.json
├── tsconfig.json // TS 配置文件
├── project.tuya.json // IDE 项目配置,参照 https://developer.tuya.com/cn/ray/guide/tutorial/directory
├── ray.config.ts // Ray 工程配置文件,参照 https://developer.tuya.com/cn/ray/guide/tutorial/directory
├── public // 静态资源目录
├── src
│ ├── api // 接口目录
│ ├── components // 组件目录
│ ├── constant // 常数
│ ├── devices // 智能设备模型,参照 https://developer.tuya.com/cn/miniapp/develop/ray/extended/sdm/usage
│ ├── hooks // react hooks
│ ├── i18n // 多语言本地配置
│ ├── pages // 页面目录,存放所有页面组件源码,至少存在 index.tsx 文件。
│ ├── redux // 状态管理目录
│ ├── utils // 工具方法目录
│ ├── app.config.ts // 运行项目时默认生成的文件,该文件不需要进行 git 提交。
│ ├── app.less // 全局样式
│ ├── app.tsx // 项目入口文件
│ ├── composeLayout.tsx // 容器封装
│ ├── global.config.ts // 项目全局配置项,参照 https://developer.tuya.com/cn/ray/guide/tutorial/global-config
│ ├── routes.config.ts // 路由配置,参照 https://developer.tuya.com/cn/ray/guide/tutorial/routes
│ ├── theme.json // 主题配置,参照 https://developer.tuya.com/cn/miniapp/develop/miniapp/framework/app/theme
│ └── variables.less // 全局 less 变量
└── typings // 全局项目 TS Typing 定义目录
└── index.d.ts
控制设备的工作模式,需要根据 DP 值展示及下发对应的 DP 值。
工作模式对应 dp 为work_mode
。
这里工作模式约定为:
import React, { useState, useEffect } from 'react';
import { View, switchTab } from '@ray-js/ray';
import { useActions, useProps } from '@ray-js/panel-sdk';
import { TopBar } from '@/components';
import WorkModeCard from '@/components/mode';
import styles from './index.module.less';
export function Home() {
// 当前 dp 值
const work_mode = useProps(props => props.work_mode);
const actions = useActions();
// 页面展示值
const [workMode, setWorkMode] = useState<'1' | '2' | '3' | '4'>(
work_mode as '1' | '2' | '3' | '4'
);
useEffect(() => {
setWorkMode(work_mode as '1' | '2' | '3' | '4');
}, [work_mode]);
return (
<View className={styles.view}>
<TopBar />
<View className={styles.content}>
<WorkModeCard
value={workMode}
onChange={workMode => {
// 下发 dp 值
actions.work_mode.set(workMode);
setWorkMode(workMode);
}}
onMore={() => {
switchTab({ url: '/pages/settings/index' });
}}
/>
</View>
</View>
);
}
export default Home;
在顶部是手动开关按钮,当手动开关只要任意一个开启,定时模块收起不能设置;每个时间段,充电放电都设置开关(有同时启用充放电的应用场景,根据产品具体情况做修改调整)。
手动充电开关对应 dp 为 charger_switch
; 手动放电开关对应 dp 为 discharge_switch
; 定时对应 dp 为 time_schedule
。
定时,最多4个时间段,格式:第1个字节是起始小时,第二字节是起始分钟,第3个字节是结束小时,第4个字节是结束分钟,第5个字节是充电开关(0X01 开,0X00关,第6个字节是放电开关(0X01 开,0X00关)以此类推,6个字节*4组=24字节。
import React, { useState } from 'react';
import { Switch, ScrollView, View, showToast as showtoast, usePageEvent } from '@ray-js/ray';
import { useActions, useProps, useStructuredActions, useStructuredProps } from '@ray-js/panel-sdk';
import { cloneDeep, debounce } from 'lodash-es';
import clsx from 'clsx';
import { TopBar } from '@/components';
import TimeRange from '@/components/time-range';
import Strings from '@/i18n';
import styles from './index.module.less';
export function Settings() {
const { charger_switch, discharge_switch } = useProps(props => props);
const actions = useActions();
const dpStructuredActions = useStructuredActions();
const time_schedule = useStructuredProps(props => props.time_schedule);
const [chargerManualSwitch, setChargerManualSwitch] = useState(charger_switch);
const [dischargeManualSwitch, setDischargeManualSwitch] = useState(discharge_switch);
const [peakValleyList, setPeakValleyList] = useState(time_schedule);
const [pageContainerShow, setPageContainerShow] = useState(false);
const [peakEdit, setPeakEdit] = useState(false);
const [selectedPeakItemCode, setSelectedPeakItemCode] = useState<any>();
usePageEvent('onShow', () => {
setChargerManualSwitch(charger_switch);
setDischargeManualSwitch(discharge_switch);
setPeakValleyList(time_schedule);
});
function showToast(title, duration = 3000) {
showtoast({ title, icon: 'none', duration });
}
// 校验时间段(仅支持1天内24小时)
function checkSelectTime(curSelPeakItem, peakList) {
function countMinutes(hour, minute) {
return +hour * 60 + +minute;
}
const result = {
isConflict: false,
errorMessage: '',
};
const curTime = { ...curSelPeakItem };
const list = cloneDeep(peakList);
try {
if (
countMinutes(curTime.startTime, curTime.startMinute) >=
countMinutes(curTime.endTime, curTime.endMinute)
) {
result.isConflict = true;
result.errorMessage = Strings.getLang('schedule_setting_compare_time_tip');
}
for (let i = 0, len = list.length; i < len; i++) {
// 与时间段有交集
if (
(countMinutes(curTime.startTime, curTime.startMinute) >
countMinutes(list[i].startTime, list[i].startMinute) &&
countMinutes(curTime.startTime, curTime.startMinute) <
countMinutes(list[i].endTime, list[i].endMinute)) ||
(countMinutes(curTime.endTime, curTime.endMinute) >
countMinutes(list[i].startTime, list[i].startMinute) &&
countMinutes(curTime.endTime, curTime.endMinute) <
countMinutes(list[i].endTime, list[i].endMinute))
) {
result.isConflict = true;
result.errorMessage = Strings.getLang('schedule_setting_time_repeat_tip');
break;
}
// 包含了已有的时间段
if (
countMinutes(curTime.startTime, curTime.startMinute) <=
countMinutes(list[i].startTime, list[i].startMinute) &&
countMinutes(curTime.endTime, curTime.endMinute) >=
countMinutes(list[i].endTime, list[i].endMinute)
) {
result.isConflict = true;
result.errorMessage = Strings.getLang('schedule_setting_time_repeat_tip');
break;
}
}
} catch (e) {
console.error('checkSelectPeriodTime:', e);
}
return result;
}
const handleManualSwitchCharge = event => {
setChargerManualSwitch(event.value);
};
const handleManualSwitchDisCharge = event => {
setDischargeManualSwitch(event.value);
};
const editClick = () => {
setPeakEdit(!peakEdit);
};
// 删除定时元素
const delPeakItem = event => {
const { code } = event.origin.currentTarget.dataset.peakItem;
const list = [...peakValleyList];
list.splice(code, 1);
for (let i = 0, len = list.length; i < len; i++) {
list[i].code = i;
}
setPeakValleyList(list);
};
// 定时充电switch
const handleTimeSwitchCharge = event => {
const { origin, value } = event;
const { code } = origin.currentTarget.dataset;
const list = [...peakValleyList];
for (let i = 0, len = list.length; i < len; i++) {
if (list[i].code === code) {
list[i].chargeSwitch = value;
}
}
setPeakValleyList(list);
};
// 定时放电switch
const handleTimeSwitchDisCharge = event => {
const { origin, value } = event;
const { code } = origin.currentTarget.dataset;
const list = [...peakValleyList];
for (let i = 0, len = list.length; i < len; i++) {
if (list[i].code === code) {
list[i].disChargeSwitch = value;
}
}
setPeakValleyList(list);
};
// 弹出时间选择器
const showTimePageContainer = event => {
const { code } = event.origin.currentTarget.dataset.seleItem;
setTimeout(() => {
setSelectedPeakItemCode(code);
setPageContainerShow(true);
}, 50);
};
// 添加定时
const addTimeSchedule = debounce(
function (e) {
const list = [...peakValleyList];
list.push({
code: list.length,
startTime: '00', // 起始时间
startMinute: '00',
endTime: '01', // 结束时间
endMinute: '00',
chargeSwitch: false,
disChargeSwitch: false,
});
setPeakValleyList(list);
},
500,
{ leading: true, trailing: false }
);
// 保存定时设置
const saveTimeSchedule = debounce(
function () {
if (chargerManualSwitch || dischargeManualSwitch) {
// 如果手动开关开启,仅下发手动开关
actions.charger_switch.set(chargerManualSwitch);
actions.discharge_switch.set(dischargeManualSwitch);
} else {
// 时间冲突校验
for (let i = 0, len = peakValleyList.length; i < len; i++) {
const List = [];
peakValleyList.forEach(item => {
if (peakValleyList[i].code !== item.code) {
List.push(item);
}
});
const checkRes = checkSelectTime(peakValleyList[i], List);
if (checkRes.isConflict) {
showToast(checkRes.errorMessage);
return;
}
}
actions.charger_switch.set(chargerManualSwitch);
actions.discharge_switch.set(dischargeManualSwitch);
dpStructuredActions.time_schedule.set(peakValleyList);
}
showToast(Strings.getLang('schedule_setting_success'));
},
1000,
{ leading: true, trailing: false }
);
return (
<>
<ScrollView refresherTriggered scrollY className={styles.view}>
<TopBar title="" style={{ background: 'unset' }} />
<View className={styles.container}>
{/* <!-- 手动开关 --> */}
<View className={styles['tab-label-content']}>
<View className={styles['tab-label-title']}>
{Strings.getLang('schedule_manual_switch')}
</View>
<View className={styles['manual-content']}>
<View className={styles['switch-btn-border']}>
<View className={styles['switch-btn-label']}>
{Strings.getLang('schedule_charge')}:
</View>
<Switch
checked={chargerManualSwitch}
color="#3678E3"
onChange={handleManualSwitchCharge}
/>
</View>
<View className={styles['switch-btn-border']}>
<View className={styles['switch-btn-label']}>
{Strings.getLang('schedule_discharge')}:
</View>
<Switch
checked={dischargeManualSwitch}
color="#3678E3"
onChange={handleManualSwitchDisCharge}
/>
</View>
</View>
</View>
{/* <!-- 定时开关 --> */}
<View className={clsx(styles['tab-label-content'], styles['mt-20'])}>
<View className={styles['tab-label-title']}>
{Strings.getLang('schedule_char_dis_time_setting')}
</View>
{!chargerManualSwitch && !dischargeManualSwitch ? (
<View className={styles['time-schedule-content']}>
<View className={styles['edit-btn-cont']} onClick={editClick}>
<View className={styles['edit-btn']}>
{peakEdit
? Strings.getLang('schedule_cancel')
: Strings.getLang('schedule_edit')}
</View>
</View>
{peakValleyList.map((item, index) => (
<View
key={index}
className={clsx(
styles['time-schedule-item-wrapper'],
peakEdit ? styles.editing : null
)}
>
<View
className={clsx(styles['del-icon'], peakEdit ? null : styles.invisible)}
data-peak-item={item}
onClick={delPeakItem}
/>
<View className={styles['time-schedule-ls']}>
<View className={styles['schedule-switch']}>
<View className={styles['switch-btn-label']}>
{Strings.getLang('schedule_period')}
{index + 1}
</View>
<View className={styles['schedule-switch-content']}>
<View className={styles['switch-btn-border']}>
<View className={styles['switch-btn-label']}>
{Strings.getLang('schedule_charge')}:
</View>
<Switch
data-code={item.code}
checked={item.chargeSwitch}
color="#3678E3"
onChange={handleTimeSwitchCharge}
/>
</View>
<View className={styles['switch-btn-border']}>
<View className={styles['switch-btn-label']}>
{Strings.getLang('schedule_discharge')}:
</View>
<Switch
data-code={item.code}
checked={item.disChargeSwitch}
color="#3678E3"
onChange={handleTimeSwitchDisCharge}
/>
</View>
</View>
</View>
<View
className={styles.peakValley_time}
data-sele-item={item}
onClick={showTimePageContainer}
>
<View className={styles.peakValley_time_text}>
{item.startTime}:{item.startMinute}
</View>
<View className={styles.peakValley_time_line}>~</View>
<View className={styles.peakValley_time_text}>
{item.endTime}:{item.endMinute}
</View>
</View>
{index >= 3 ? null : <View className={styles['bottom-line']} />}
</View>
</View>
))}
{/* <!-- 添加时段 --> */}
{!peakEdit && peakValleyList.length < 4 ? (
<View className={styles['add-time-schedule']} onClick={addTimeSchedule}>
{Strings.getLang('schedule_add_time_period')}
</View>
) : null}
</View>
) : null}
</View>
{/* <!-- 保存按钮 --> */}
{!peakEdit ? (
<View className={styles['save-btn']} onClick={saveTimeSchedule}>
{Strings.getLang('schedule_save')}
</View>
) : null}
</View>
</ScrollView>
{/* <!-- page-container --> */}
<TimeRange
show={pageContainerShow}
code={selectedPeakItemCode}
list={peakValleyList}
onHide={list => {
if (list) {
setPeakValleyList(list);
}
setPageContainerShow(false);
}}
/>
</>
);
}
export default Settings;
上传多语言时需将相应的多语言一并上传,字段信息在/src/i18n
目录下。 i18n 中, 我们建议至少配置两种类型的语言。 一种中文,一种英文。 建议所有的多语言字段按照 dsc* 开头,并在每个单词中间使用 * 作为分隔。如果是 dp 多语言,则以 dp* 开头,并在每个单词中间使用 * 作为分隔。
使用一般多语言, 您可以使用
import Strings from "@/i18n";
const text = Strings.getLang("dsc_cancel");
如果存在类似于一个 DP 状态,有多个枚举值,每个枚举的状态对应的多语言不同, 您可以如下使用
import Strings from "@/i18n";
// dpCode 为 DP 的标识符, dpValue 为 DP 的值
const text = Strings.getDpLang(dpCode, dpValue);