前提条件

构建内容

在 Codelab 中,您可以利用面板小程序及 Three.js 的能力,开发构建出一个支持 3D 模型展示的智能风扇面板。

学习内容

所需条件

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

产品名称:智能风扇

需求原型

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

注册登录 涂鸦开发者平台,并在平台创建产品:

  1. 单击页面左侧 产品 > 产品开发,在 产品开发 页面单击 创建产品
  2. 标准类目 下选择 小家电,产品品类选择 风扇

  1. 选择智能化方式和产品方案,并完善产品信息,例如填写产品名称为 Fan
  2. 单击 创建产品 按钮,完成产品创建。
  3. 产品创建完成后,进入到 添加标准功能 页面,可以根据实际需求选择功能点。功能未选择不影响视频预览。

🎉 在这一步,成功完成创建了一个名为 Fan 的智能风扇产品。

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

面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。

详细操作步骤可以参考 面板小程序 > 创建面板小程序

拉取并运行模板项目

面板模板仓库:仓库地址

  1. 首先,拉取项目。
    git clone https://github.com/Tuya-Community/tuya-ray-demo.git
    
  2. 进入 panel-d3 模板,直接通过 IDE 导入源码目录,并关联到创建的小程序。
    cd ./examples/panel-d3
    
  3. 导入项目到 IDE,并关联到已经创建的面板小程序与产品。

模型加载

  1. 环境配置因为小程序环境的一些限制,使用 3D 功能需要引入 three.js 并在工程中配置,使用 Render Script (RJS) 进行开发。关于配置文档,请参考 文档
  2. 3D 模型加载支持 gltfglb 模型格式。模型加载可以透过 CDN 进行加载,需要您先将模型上传至 CDN 上。
    // 模型的 URL 支持 glb、gltf 格式,需要为线上地址,可以透过 CDN 上传。
    // Domain + URI  如: https://xxx.xxx.com/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf
    const modelUrl = useRef(
      `${domain}/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf`
    ).current;
    
  3. 引入 D3-Component
    import React, { FC, useCallback, useEffect, useMemo, useRef } from "react";
    import { useSelector } from "react-redux";
    import { View } from "@ray-js/components";
    import { selectDpStateByCode } from "@/redux/modules/dpStateSlice";
    import { fanSpeedCode, switchCode } from "@/config/dpCodes";
    import {
      ComponentView,
      ComponentApi,
      ComponentConst,
    } from "@/components/D3Component";
    
    import styles from "./index.module.less";
    import Temperature from "./Temperature";
    import CountdownTips from "./CountdownTips";
    
    type Props = {
      disabledAnimation?: boolean;
    };
    
    const Fan: FC<Props> = ({ disabledAnimation = false }) => {
      const componentId = useRef(`component_${new Date().getTime()}`).current;
      const isAppOnBackground = useRef(false);
      // 模型的 URL 支持 glb、gltf 格式,需要为线上地址,可以透过 CDN 上传
      // Domain + URI  如: https://xxx.xxx.com/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf
      const modelUrl = useRef(
        `${domain}/smart/miniapp/static/bay1591069675815HNkH/17109234773d561a0f716.gltf`
      ).current;
      const dpSwitch = useSelector(selectDpStateByCode(switchCode));
      const dpFanSpeed = useSelector(selectDpStateByCode(fanSpeedCode));
    
      const animationEnable = !disabledAnimation && dpSwitch;
    
      useEffect(() => {
        if (!animationEnable) {
          ComponentApi.setFanAnimation(componentId, {
            fanSpeed: ComponentConst.FAN_LEVEL.stop,
          });
        }
      }, [animationEnable]);
    
      useEffect(() => {
        updateFanSpeed();
      }, [dpFanSpeed, dpSwitch]);
    
      const updateFanSpeed = useCallback(() => {
        if (dpSwitch) {
          if (dpFanSpeed < 33) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.low,
            });
          } else if (dpFanSpeed > 33 && dpFanSpeed < 66) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.mid,
            });
          } else if (dpFanSpeed > 66 && dpFanSpeed < 80) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.hight,
            });
          } else if (dpFanSpeed > 80) {
            ComponentApi.setFanAnimation(componentId, {
              fanSpeed: ComponentConst.FAN_LEVEL.max,
            });
          }
        } else {
          ComponentApi.setFanAnimation(componentId, {
            fanSpeed: ComponentConst.FAN_LEVEL.stop,
          });
        }
      }, [dpFanSpeed, dpSwitch]);
    
      const onComponentLoadEnd = useCallback(
        (data: { infoKey: string; progress: number }) => {
          if (data.progress === 100) {
            ComponentApi.startAnimationFrame(componentId, { start: true });
            updateFanSpeed();
          }
        },
        []
      );
    
      const onGestureChange = useCallback((start: boolean) => {
        if (start) {
          console.log("onGestureChange", start);
        }
      }, []);
    
      const onResetControl = useCallback((success: boolean) => {
        console.log("onResetControl", success);
      }, []);
    
      /**
       * 进入后台时断开对应动画渲染
       */
      const onEnterBackground = () => {
        ty.onAppHide(() => {
          isAppOnBackground.current = true;
          // 停止整个场景渲染
          ComponentApi.startAnimationFrame(componentId, { start: false });
        });
      };
    
      /**
       * 进入前台时开启相关动画渲染
       */
      const onEnterForeground = () => {
        ty.onAppShow(() => {
          ComponentApi.startAnimationFrame(componentId, { start: true });
          isAppOnBackground.current = false;
        });
      };
    
      /**
       * 卸载整个组件
       */
      const unmount = () => {
        ComponentApi.setFanAnimation(componentId, {
          fanSpeed: ComponentConst.FAN_LEVEL.stop,
        });
        ComponentApi.disposeComponent(componentId);
      };
    
      useEffect(() => {
        onEnterBackground();
        onEnterForeground();
        return () => {
          unmount();
        };
      }, []);
    
      return (
        <View className={styles.container}>
          <View className={styles.view}>
            <ComponentView
              eventProps={{
                onComponentLoadEnd,
                onGestureChange,
                onResetControl,
              }}
              componentId={componentId}
              modelUrl={modelUrl}
            />
          </View>
          <Temperature />
          <CountdownTips />
        </View>
      );
    };
    
    export default Fan;
    

ComponentApi

ComponentApi 封装的是一个可以从业务层与 3D 组件进行通信的 API function,参考该设计,可以自定义如何从业务层来向 3D 模型发出指令。您可根据自己的需要,来构建对应的能力。

例如,本模板中设置是否启动风扇,参考以下步骤:

  1. 首先,在 ComponentApi 中定义一个 Function,并透过事件分发的方式分发到视图层中:示例文件为:src/components/D3Component/api/index.ts
    /**
     * 控制风扇是否转动和设置它的转速
     * @param componentId
     * @param opts fanSpeed: FAN_LEVEL
     * @param cb
     * @returns
     */
    const setFanAnimation = (
      componentId: string,
      opts: { fanSpeed: number },
      cb?: (data: IEventData) => void
    ) => {
      NotificationCenter.pushNotification(`${componentId}${propsEventCallee}`, {
        method: "setFanAnimation",
        componentId,
        calleeProps: {
          timestamp: new Date().getTime(),
          ...opts,
        },
      });
      if (typeof cb !== "function") {
        return new Promise((resolve, reject) => {
          onLaserApiCallback(
            `${componentId}_setFanAnimation`,
            (eventData: IEventData) => normalResolve(eventData, resolve, reject)
          );
        });
      }
    
      onLaserApiCallback(`${componentId}_setFanAnimation`, cb);
      return null;
    };
    
  2. 然后,在小程序组件 JS 中定义一个接收该事件的方法,并透过 deconstructApiProps 分发到 RJS 视图层。示例文件为:/src/components/D3Component/components/rjs-component/index.js
    /**
     * @language zh-CN
     * @description 小程序组件
     */
    import { actions, store } from "../../redux";
    import Render from "./index.rjs";
    import { NotificationCenter } from "../../notification";
    import { propsEventCallee } from "../../api";
    
    const componentOptions = {
      options: {
        pureDataPattern: /^isInitial$/,
      },
      data: {
        isInitial: true,
      },
      properties: {
        componentId: String,
        modelUrl: String,
      },
    
      lifetimes: {
        created() {
          this.instanceType = "rjs";
          this.render = new Render(this);
        },
        ready() {
          // 注册 API 方法调用事件
          this.registerApiCallee();
          if (this.render) {
            this.render.initComponent(this.data.componentId, this.data.modelUrl);
            this.storeComponent();
            this.setData({ isInitial: false });
          }
        },
        detached() {
          this.clearComponent();
          this.removeComponentApiCallee();
          this.render = null;
        },
      },
      pageLifetimes: {},
      methods: {
        storeComponent() {
          store.dispatch(
            actions.component.initComponent({
              componentId: this.data.componentId,
              instanceType: this.instanceType,
            })
          );
        },
        clearComponent() {
          this.render.disposeComponent();
          store.dispatch(
            actions.component.willUnmount({ componentId: this.data.componentId })
          );
        },
        registerApiCallee() {
          if (!this.data.componentId) {
            return;
          }
          NotificationCenter.addEventListener(
            `${this.data.componentId}${propsEventCallee}`,
            (data) => {
              if (data && data.method) {
                if (this.render) {
                  this.render.deconstructApiProps(data);
                }
              }
            }
          );
        },
        removeComponentApiCallee() {
          NotificationCenter.removeEventListener(
            `${this.data.componentId}${propsEventCallee}`
          );
        },
        onComponentApiCallee(data) {
          if (data && data.componentId && data.method) {
            const results = {
              componentId: data.componentId,
              results: data,
            };
            NotificationCenter.pushNotification(
              `${data.componentId}_${data.method}`,
              results
            );
          }
        },
        /**
         * @language zh-CN
         * @description 以下方法都是绑定小程序原生组件和 Ray 组件之间的通信
         * @param {*} data
         */
        onComponentLoadEnd(data) {
          this.triggerEvent("oncomponentloadend", data);
        },
        onGestureChange(data) {
          this.triggerEvent("ongesturechange", data);
        },
        onResetControl(data) {
          this.triggerEvent("onresetcontrol", data);
        },
      },
    };
    
    Component(componentOptions);
    
  3. 最后,在 RJS 中定义执行这个方法的具体函数,就实现了从业务层如何触发到视图层地图的 3D 模块。示例文件为:/src/components/D3Component/components/rjs-component/index.rjs
    import _ from "lodash";
    import { KTInstance } from "../../lib/KTInstance";
    import { getLocalUrl } from "../../lib/utils/Functions.ts";
    import { ELECTRIC_FAN } from "../../lib/models";
    
    export default Render({
      _onComponentLoadEnd(data) {
        this.callMethod("onComponentLoadEnd", data);
      },
    
      _onGestureChange(data) {
        this.callMethod("onGestureChange", data);
      },
    
      _onResetControl(data) {
        this.callMethod("onResetControl", data);
      },
    
      _onComponentApiCallee(data) {
        this.callMethod("onComponentApiCallee", data);
      },
    
      // 透过 _onComponentApiCallee 把 API 执行结果回调给业务层
      _resolveSuccess(methodName, results) {
        const timeEnd = new Date().getTime();
        const timeStart = results.props.timestamp;
        this._onComponentApiCallee({
          method: methodName,
          componentId: this.componentId,
          status: true,
          communicationTime: timeEnd - timeStart,
          timestamp: timeEnd,
          props: results.props,
          values: results.values,
        });
      },
    
      // 透过 _onComponentApiCallee 把 API 执行结果回调给业务层
      _resolveFailed(methodName, results) {
        const timeEnd = new Date().getTime();
        const timeStart = results.props.timestamp;
        this._onComponentApiCallee({
          method: methodName,
          componentId: this.componentId,
          status: false,
          communicationTime: timeEnd - timeStart,
          timestamp: timeEnd,
          props: results.props,
          values: results.values,
        });
      },
    
      /** initComponent
       * 初始化
       * @param sysInfo
       * @param newValue
       */
      async initComponent(componentId, pixelRatio, modelUrl) {
        try {
          const canvas = await getCanvasById(componentId);
          const THREE = await requirePlugin("rjs://three").catch((err) => {
            console.log("usePlugins occur an error", err);
          });
          const { GLTFLoader } = await requirePlugin(
            "rjs://three/examples/jsm/loaders/GLTFLoader"
          ).catch((err) => {
            console.log("usePlugins occur an error", err);
          });
          this._GLTFLoader = GLTFLoader;
          this.componentId = componentId;
    
          await this.instanceDidMount(canvas, pixelRatio, modelUrl);
        } catch (e) {
          console.log("initComponent occur an error", e);
        }
      },
    
      async instanceDidMount(canvas, pixelRatio, modelUrl) {
        this.fanModelList = [];
        const params = {
          canvas: canvas,
          antialias: true,
          pixelRatio: pixelRatio,
          logarithmicDepthBuffer: true,
          preserveDrawingBuffer: true,
          onComponentLoadEnd: this._onComponentLoadEnd,
          onGestureChange: this._onGestureChange,
          onResetControl: this._onResetControl,
          onClickInfoWindow: this._onClickInfoWindow,
          GLTFLoader: this._GLTFLoader,
        };
        this.kTInstance = new KTInstance(params);
    
        // 这里兜底一个模型
        const url = modelUrl || getLocalUrl(ELECTRIC_FAN);
        const model = await this.kTInstance.loadModelLayer({
          infoKey: "model",
          uri: url,
        });
    
        if (model) {
          this.layer = model.scene;
          this.animations = model.animations;
          // 准备一个关键帧
          this.animationMixer = this.kTInstance.kTComponent.createAnimationMixer(
            this.layer
          );
          this.clipAnimation = this.animations.map((item) => {
            return this.animationMixer.clipAction(item);
          });
    
          // 根据不同的模型做调整
          this.layer.scale.set(0.4, 0.4, 0.4);
          this.kTInstance.putLayerIntoScene(this.layer);
          this.setFanModel();
        }
      },
    
      /**
       * 解构对应的 API
       * @param opts
       */
      deconstructApiProps(opts) {
        if (typeof this[opts.method] === "function") {
          this[opts.method](opts.calleeProps);
        }
      },
    
      /**
       * 从模型中找到对应的风扇叶片
       */
      setFanModel() {
        this.fanModel = this.kTInstance.getChildByNameFromObject(
          this.layer,
          "组080"
        )[0];
      },
    
      /**
       * 启动风扇运转动画
       * @param speed
       */
      startFanAnimate(fanSpeed) {
        this.stopFanAnimate();
        this.fanAnimationIdList = [this.fanModel].map((item) =>
          this.kTInstance.startRotateAnimationOnZ(item, fanSpeed)
        );
      },
    
      /**
       * 停止风扇运转动画
       */
      stopFanAnimate() {
        if (this.fanAnimationIdList) {
          this.fanAnimationIdList.map((item) =>
            this.kTInstance.stopAnimation(item)
          );
        }
      },
    
      /**
       * 开启动画渲染
       * @param start
       */
      startAnimationFrameVision(start) {
        if (this.kTInstance.kTComponent) {
          this.kTInstance.kTComponent.animateStatus = start;
        }
      },
      /**
       * 控制风扇是否转动和设置它的转速
       * @param opts
       */
      setFanAnimation(opts) {
        const methodName = "setFanAnimation";
        if (opts) {
          const results = {
            props: opts,
            values: {},
          };
          const { fanSpeed = 0.1 } = opts;
          if (this.kTInstance) {
            if (fanSpeed !== 0) {
              this.startFanAnimate(fanSpeed);
            } else {
              this.stopFanAnimate();
            }
            this._resolveSuccess(methodName, results);
          } else {
            this._resolveFailed(methodName, results);
          }
        }
      },
    
      /**
       * 开启场景渲染
       * @param opts
       */
      startAnimationFrame(opts) {
        const methodName = "startAnimationFrame";
        if (opts) {
          const results = {
            props: opts,
            values: {},
          };
          const { start } = opts;
          if (this.kTInstance) {
            this.startAnimationFrameVision(start);
            this._resolveSuccess(methodName, results);
          } else {
            this._resolveFailed(methodName, results);
          }
        }
      },
    
      /**
       * 注销 3D 组件
       * @param opts
       */
      disposeComponent() {
        const methodName = "disposeComponent";
        const results = {
          props: {},
          values: {},
        };
        if (this.kTInstance) {
          this.kTInstance.kTComponent.disposeComponent();
          this._resolveSuccess(methodName, results);
        } else {
          this._resolveFailed(methodName, results);
        }
      },
    });
    

Callbacks

Callbacks 模块用来在 3D 内部触发模型事件之后,将事件结果回调给业务层。

  1. 首先,在组件上使用 Bind 方法绑定对应的事件。示例文件为:/src/components/D3Component/view/index.tsx
    import React from "react";
    import { View } from "@ray-js/ray";
    import RjsView from "../components/rjs-component";
    import { IProps, defaultProps } from "./props";
    import { getRayRealCallbacksName } from "../callbacks";
    import styles from "./index.module.less";
    
    const Component: React.FC<IProps> = (props: IProps) => {
      const {
        componentId = `component_${new Date().getTime()}`,
        modelUrl,
        eventProps,
      } = props;
      /**
       * 触发指定的事件
       */
      const onEvent = (evt: { type: string; detail: any }) => {
        const { detail, type } = evt;
        eventProps[getRayRealCallbacksName(type)]?.(detail);
      };
      return (
        <View className={styles.view}>
          <RjsView
            componentId={componentId}
            modelUrl={modelUrl}
            bindoncomponentloadend={onEvent}
            bindongesturechange={onEvent}
            bindonresetcontrol={onEvent}
          />
        </View>
      );
    };
    
    Component.defaultProps = defaultProps;
    Component.displayName = "Component";
    
    export default Component;
    
  2. 然后,在小程序组件中定义触发该事件的 triggerEvent。示例文件为:/src/components/D3Component/components/rjs-component/index.js
    /**
     * @language zh-CN
     * @description 以下方法都是绑定小程序原生组件和 Ray 组件之间的通信
     * @param {*} data
     */
    onComponentLoadEnd(data) {
    this.triggerEvent('oncomponentloadend', data);
    },
    onGestureChange(data) {
    this.triggerEvent('ongesturechange', data);
    },
    onResetControl(data) {
    this.triggerEvent('onresetcontrol', data);
    },
    
  3. 最后,在视图层定义触发的实际触发函数。示例文件为:/src/components/D3Component/components/rjs-component/index.rjs
    export default Render({
      _onComponentLoadEnd(data) {
        this.callMethod("onComponentLoadEnd", data);
      },
    
      _onGestureChange(data) {
        this.callMethod("onGestureChange", data);
      },
    
      _onResetControl(data) {
        this.callMethod("onResetControl", data);
      },
    
      _onComponentApiCallee(data) {
        this.callMethod("onComponentApiCallee", data);
      },
    });
    

控制开关及风速

控制设备的开关及风速,需要根据 DP 状态和 和 DP 的定义来获取及下发对应的 DP 值。

如果您需要将风扇打开,则按照如下操作,从 Hooks 中取出对应开关的状态,并根据开关状态来下发您对应的数据:

import React, { FC, useState } from "react";
import clsx from "clsx";
import { Text, View } from "@ray-js/components";
import { useDispatch, useSelector } from "react-redux";
import { selectDpStateByCode, updateDp } from "@/redux/modules/dpStateSlice";
import { useThrottleFn } from "ahooks";
import { TouchableOpacity } from "@/components";
import Strings from "@/i18n";
import styles from "./index.module.less";

type Props = {};
const Control: FC<Props> = () => {
  const dispatch = useDispatch();
  // 获取到当前的设备开关状态
  const dpSwitch = useSelector(selectDpStateByCode(switchCode));

  const [panelVisible, setPanelVisible] = useState(false);

  // 绑定按钮点击事件,并使用节流函数进行节流
  const handleSwitch = useThrottleFn(
    () => {
      setPanelVisible(false);
      // 更新 DP 状态
      dispatch(updateDp({ [switchCode]: !dpSwitch }));
      ty.vibrateShort({ type: "light" });
    },
    { wait: 600, trailing: false }
  ).run;

  return (
    <View className={styles.container}>
      <TouchableOpacity
        className={styles.item}
        activeOpacity={1}
        onClick={handleSwitch}
      >
        <View
          className={clsx(
            styles.controlButton,
            styles.controlButtonSwitch,
            dpSwitch && "active"
          )}
        >
          <Text
            className="iconfontpanel icon-panel-power"
            style={{ color: "#fff" }}
          />
        </View>
        <Text className={styles.itemText}>{Strings.getLang("dsc_switch")}</Text>
      </TouchableOpacity>
    </View>
  );
};

定时

如果您需要在合适的时间,让设备固定去执行对应的逻辑,例如开关,则可以使用定时 API。通常来讲,定时涉及到获取定时列表、添加定时、修改定时和删除定时 4 个主要功能。

主要涉及到的接口如下:

// 获取定时列表
export const fetchTimingsApi = async (
  category = DEFAULT_TIMING_CATEGORY,
  isGroup = false
) => {
  try {
    const response = await apiRequest<IQueryTimerTasksResponse>({
      api: "m.clock.dps.list",
      version: "1.0",
      data: {
        bizType: isGroup ? "1" : "0",
        bizId: getDevId(),
        category,
      },
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};

// 添加定时
export const addTimingApi = async (params: IAndSingleTime) => {
  try {
    const response = await apiRequest<EntityId>({
      api: "m.clock.dps.add",
      version: "1.0",
      data: params,
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};
// 更新定时
export const updateTimingApi = async (params: IModifySingleTimer) => {
  try {
    const response = await apiRequest<boolean>({
      api: "m.clock.dps.update",
      version: "1.0",
      data: params,
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};

// 删除定时
export const updateStatusOrDeleteTimingApi = async (param: {
  ids: string;
  status: 0 | 1 | 2;
}) => {
  const { groupId: devGroupId, devId } = getDevInfo();
  const defaultParams = {
    bizType: devGroupId ? "1" : "0",
    bizId: devId,
  };
  try {
    const response = await apiRequest<boolean>({
      api: "m.clock.batch.status.update",
      version: "1.0",
      data: { ...defaultParams, ...param },
    });
    return response;
  } catch (err) {
    return Promise.reject(err);
  }
};

通常会把定时 API 与 Redux 结合到一起,来进行对应的 API 数据的更新,具体可以参考 timingSlice 的内容:

import {
  addTimingApi,
  fetchTimingsApi,
  updateStatusOrDeleteTimingApi,
  updateTimingApi,
} from "@/api";
import {
  createAsyncThunk,
  createEntityAdapter,
  createSlice,
  EntityId,
} from "@reduxjs/toolkit";
import { DEFAULT_TIMING_CATEGORY } from "@/constant";
import moment from "moment";
import { ReduxState } from "..";
import { kit } from "@ray-js/panel-sdk";

const { getDevInfo } = kit;

type Timer = IAndSingleTime & {
  time: string;
  id: EntityId;
};

type AddTimerPayload = {
  dps: string;
  time: string;
  loops: string;
  actions: any;
  aliasName?: string;
};

const timingsAdapter = createEntityAdapter<Timer>({
  sortComparer: (a, b) =>
    moment(a.time, "HH:mm").isBefore(moment(b.time, "HH:mm")) ? -1 : 1,
});

export const fetchTimings = createAsyncThunk<Timer[]>(
  "timings/fetchTimings",
  async () => {
    const { timers } = await fetchTimingsApi();

    return timers as unknown as Timer[];
  }
);

export const addTiming = createAsyncThunk<Timer, AddTimerPayload>(
  "timings/addTiming",
  async (param) => {
    const { groupId: devGroupId, devId } = getDevInfo();
    const defaultParams = {
      bizId: devGroupId || devId,
      bizType: devGroupId ? "1" : "0",
      isAppPush: false,
      category: DEFAULT_TIMING_CATEGORY,
    };
    const params = { ...defaultParams, ...param };
    const id = await addTimingApi(params);
    return { id, status: 1, ...params };
  }
);

export const updateTiming = createAsyncThunk(
  "timings/updateTiming",
  async (param: AddTimerPayload & { id: EntityId }) => {
    const { groupId: devGroupId, devId } = getDevInfo();
    const defaultParams = {
      bizId: devGroupId || devId,
      bizType: devGroupId ? "1" : "0",
      isAppPush: false,
      category: DEFAULT_TIMING_CATEGORY,
    };
    const params = { ...defaultParams, ...param };
    await updateTimingApi(params);
    return { id: param.id, changes: param };
  }
);

export const deleteTiming = createAsyncThunk<EntityId, EntityId>(
  "timings/deleteTiming",
  async (id) => {
    // status 2 --- 删除
    await updateStatusOrDeleteTimingApi({ ids: String(id), status: 2 });
    return id;
  }
);

export const updateTimingStatus = createAsyncThunk(
  "timings/updateTimingStatus",
  async ({ id, status }: { id: EntityId; status: 0 | 1 }) => {
    // status 0 --- 关闭  1 --- 开启
    await updateStatusOrDeleteTimingApi({ ids: String(id), status });
    return { id, changes: { status: status ?? 0 } };
  }
);

/**
 * Slice
 */
const timingsSlice = createSlice({
  name: "timings",
  initialState: timingsAdapter.getInitialState(),
  reducers: {},
  extraReducers(builder) {
    builder.addCase(fetchTimings.fulfilled, (state, action) => {
      timingsAdapter.upsertMany(state, action.payload);
    });
    builder.addCase(addTiming.fulfilled, (state, action) => {
      timingsAdapter.upsertOne(state, action.payload);
    });
    builder.addCase(deleteTiming.fulfilled, (state, action) => {
      timingsAdapter.removeOne(state, action.payload);
    });
    builder.addCase(updateTimingStatus.fulfilled, (state, action) => {
      timingsAdapter.updateOne(state, action.payload);
    });
    builder.addCase(updateTiming.fulfilled, (state, action) => {
      timingsAdapter.updateOne(state, action.payload);
    });
  },
});

/**
 * Selectors
 */
const selectors = timingsAdapter.getSelectors(
  (state: ReduxState) => state.timings
);
export const {
  selectIds: selectAllTimingIds,
  selectAll: selectAllTimings,
  selectTotal: selectTimingsTotal,
  selectById: selectTimingById,
  selectEntities: selectTimingEntities,
} = selectors;

export default timingsSlice.reducer;

您只需要按照与 DP 类似的操作,从 Redux 获取并更新 Redux 及可操作与云端的接口交互。

例如,添加定时:

import React, { FC, useMemo, useState } from "react";
import clsx from "clsx";
import { EntityId } from "@reduxjs/toolkit";
import {
  PageContainer,
  ScrollView,
  Switch,
  Text,
  View,
} from "@ray-js/components";
import TimePicker from "@ray-js/components-ty-time-picker";
import { useSelector } from "react-redux";
import { DialogInput, TouchableOpacity, WeekSelector } from "@/components";
import Strings from "@/i18n";
import { WEEKS } from "@/constant";
import { checkDpExist, getDpIdByCode } from "@/utils";
import { lightCode, switchCode } from "@/config/dpCodes";
import {
  addTiming,
  selectTimingById,
  updateTiming,
} from "@/redux/modules/timingsSlice";
import { ReduxState, useAppDispatch } from "@/redux";

import styles from "./index.module.less";

type Props = {
  visible: boolean;
  onClose: () => void;
  id?: EntityId;
};

const TimingAdd: FC<Props> = ({ id, visible, onClose }) => {
  const dispatch = useAppDispatch();
  const { language } = useMemo(() => ty.getSystemInfoSync(), []);

  // 编辑时的初始值
  const currentTiming = useSelector((state: ReduxState) =>
    id ? selectTimingById(state, id) : null
  );

  const [timeState, setTimeState] = useState(() => {
    if (currentTiming) {
      const [h, m] = currentTiming?.time.split(":");
      return {
        hour: Number(h),
        minute: Number(m),
      };
    }

    return {
      hour: new Date().getHours(),
      minute: new Date().getMinutes(),
    };
  });

  const dpsObject = useMemo(() => {
    return currentTiming?.dps ? JSON.parse(currentTiming.dps) : {};
  }, [currentTiming]);

  const [loops, setLoops] = useState(
    (currentTiming?.loops ?? "0000000").split("")
  );
  const [dialogVisible, setDialogVisible] = useState(false);
  const [remark, setRemark] = useState(currentTiming?.aliasName ?? "");
  const [fanSwitch, setFanSwitch] = useState(
    () => dpsObject?.[getDpIdByCode(switchCode)] ?? false
  );
  const [lightSwitch, setLightSwitch] = useState(
    () => dpsObject?.[getDpIdByCode(lightCode)] ?? false
  );

  const handleSave = async () => {
    const { hour, minute } = timeState;
    const time = `${String(hour).padStart(2, "0")}:${String(minute).padStart(
      2,
      "0"
    )}`;

    const dps = {
      [getDpIdByCode(switchCode)]: fanSwitch,
      [getDpIdByCode(lightCode)]: lightSwitch,
    };

    try {
      if (id) {
        await dispatch(
          updateTiming({
            id,
            time,
            loops: loops.join(""),
            aliasName: remark,
            dps: JSON.stringify(dps),
            actions: JSON.stringify({
              time,
              dps,
            }),
          })
        ).unwrap();
      } else {
        await dispatch(
          addTiming({
            time,
            loops: loops.join(""),
            aliasName: remark,
            dps: JSON.stringify(dps),
            actions: JSON.stringify({
              time,
              dps,
            }),
          })
        ).unwrap();
      }

      ty.showToast({
        title: Strings.getLang(id ? "dsc_edit_success" : "dsc_create_success"),
        icon: "success",
      });

      onClose();
    } catch (err) {
      ty.showToast({
        title: err?.message ?? Strings.getLang("dsc_error"),
        icon: "fail",
      });
    }
  };

  const handleTimeChange = (newTime) => {
    setTimeState(newTime);
    ty.vibrateShort({ type: "light" });
  };

  const handleFilterChange = (newLoops: string[]) => {
    setLoops(newLoops);
  };

  return (
    <PageContainer
      show={visible}
      customStyle="backgroundColor: transparent"
      position="bottom"
      overlayStyle="background: rgba(0, 0, 0, 0.1);"
      onLeave={onClose}
      onClickOverlay={onClose}
    >
      <View className={styles.container}>
        <View className={styles.header}>
          <TouchableOpacity className={styles.headerBtnText} onClick={onClose}>
            {Strings.getLang("dsc_cancel")}
          </TouchableOpacity>
          <Text className={styles.title}>
            {Strings.getLang(id ? "dsc_edit_timing" : "dsc_add_timing")}
          </Text>
          <TouchableOpacity
            className={clsx(styles.headerBtnText, "active")}
            onClick={handleSave}
          >
            {Strings.getLang("dsc_save")}
          </TouchableOpacity>
        </View>
        <View className={styles.content}>
          <TimePicker
            columnWrapClassName={styles.pickerColumn}
            indicatorStyle={{ height: "60px", lineHeight: "60px" }}
            wrapStyle={{
              width: "400rpx",
              height: "480rpx",
              marginBottom: "64rpx",
            }}
            is24Hour={false}
            value={timeState}
            fontSize="52rpx"
            fontWeight="600"
            unitAlign={language.includes("zh") ? "left" : "right"}
            onChange={handleTimeChange}
            amText={Strings.getLang("dsc_am")}
            pmText={Strings.getLang("dsc_pm")}
          />
          <WeekSelector
            value={loops}
            texts={WEEKS.map((item) =>
              Strings.getLang(`dsc_week_full_${item}`)
            )}
            onChange={handleFilterChange}
          />
          <View className={styles.featureRow}>
            <Text className={styles.featureText}>
              {Strings.getLang("dsc_remark")}
            </Text>
            <TouchableOpacity
              className={styles.featureBtn}
              onClick={() => setDialogVisible(true)}
            >
              <Text className={styles.remark}>{remark}</Text>
              <Text className="iconfontpanel icon-panel-angleRight" />
            </TouchableOpacity>
          </View>
          {checkDpExist(switchCode) && (
            <View className={styles.featureRow}>
              <Text className={styles.featureText}>
                {Strings.getDpLang(switchCode)}
              </Text>
              <Switch
                color="#6395f6"
                checked={fanSwitch}
                onChange={() => {
                  setFanSwitch(!fanSwitch);
                  ty.vibrateShort({ type: "light" });
                }}
              />
            </View>
          )}
          {checkDpExist(lightCode) && (
            <View className={styles.featureRow}>
              <Text className={styles.featureText}>
                {Strings.getDpLang(lightCode)}
              </Text>
              <Switch
                color="#6395f6"
                checked={lightSwitch}
                onChange={() => {
                  setLightSwitch(!lightSwitch);
                  ty.vibrateShort({ type: "light" });
                }}
              />
            </View>
          )}
        </View>
      </View>
      <DialogInput
        defaultValue={remark}
        onChange={setRemark}
        visible={dialogVisible}
        onClose={() => setDialogVisible(false)}
      />
    </PageContainer>
  );
};

export default TimingAdd;

上传多语言时,需将相应页面的多语言一并上传,字段信息在 /src/i18n 目录下。 在该 i18n 目录中,建议至少配置两种类型的语言,中文和英文。

使用一般多语言,您可以使用:

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