前提条件

构建内容

在此 Codelab 中,您将利用面板小程序开发构建出一个高压储能柜,实现控制设备工作模式和充放电的基本能力。

学习内容

所需条件

详见 面板小程序 - 搭建环境

产品名称:智能储能柜

需求原型

  1. 支持 工作模式 功能
  2. 支持 峰谷电设置 功能

本模版的功能在「节能能源」品类中较为通用,这里以「高压储能设备」为例

首先需要创建一个节能能源类产品,定义产品有哪些功能点,然后面板中再根据这些功能点一一实现。

进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择 标准类目 -> 节能能源 -> 高压储能设备 :

选择功能点,这里根据自己需求选择即可,视频中预览使用了自定义功能(充电开关 —— charger_switch 布尔型、放电开关 —— discharge_switch 布尔型、时间段设置 —— time_schedule 透传型、工作模式 —— work_mode 字符型)。

🎉 在这一步,我们创建了一个智能储能柜产品。

开发者平台创建面板小程序

这部分我们在 小程序开发者 平台上进行操作,注册登录 小程序开发者平台

拉取并运行模板项目

通过 github 拉取模板

面板模板仓库

拉取项目

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

这里工作模式约定为:

  1. 负载优先
  2. 定时模式
  3. 电池优先
  4. 智能模式
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);