本文档面向已经了解 面板小程序开发
的开发者,你需要充分的了解什么是面板小程序
产品功能
若您对上述概念有所疑问,我们推荐你去看一些预备知识。如果您已了解,可跳过本文。
面板作为 IoT 智能设备在 App 终端上的产品形态,创建产品之前,首先来了解一下什么是面板,以及和产品、设备之间的关系。
面板
是运行在 智能生活 App
、OEM App(涂鸦定制 App)
上的界面交互程序,用于控制 智能设备
的运行,展示 智能设备
实时状态。产品
将 面板
与 智能设备
联系起来,产品描述了其具备的功能、在 App 上面板显示的名称、智能设备拥有的功能点等。智能设备
是搭载了 涂鸦智能模组
的设备,通常在设备上都会贴有一张二维码,使用 智能生活 App
扫描二维码,即可在 App 中获取并安装该设备的控制 面板
。产品
、面板
、设备
之间的关系可参考下图。产品名称:智能风扇
实时风速调节
功能(开关
,风速控制
等)灯光开关
功能定时
功能首先需要创建一个家电类产品,定义产品有哪些功能点,然后面板中再根据这些功能点一一实现。
进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择标准类目 -> 小家电 -> 风扇
:
选择功能点,这里根据自己需求选择即可,这些功能未选择不影响视频预览。
🎉 在这一步,我们创建了一个名为 Fan
的智能风扇产品。
这部分我们在 小程序开发者
平台上进行操作,注册登录 小程序开发者平台。
IDE 点击新建 -> 关联面板小程序和产品 -> 选择模板
拉取项目
git clone https://github.com/Tuya-Community/tuya-ray-demo.git
进入 家电 模板,直接通过 IDE 导入源码目录,并关联到创建的小程序。
cd ./examples/panel-fan
导入项目到 IDE,并关联到已经创建的面板小程序与产品。
控制设备的开关及风速,需要根据 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;
上面的步骤我们已经初始化好了一个面板小程序
的开发模板,下面我们介绍下工程目录。
ray-panel
├─ README.md
├─ .editorconfig
├─ .eslintrc.js
├─ .gitignore
├─ .npmrc
├─ .prettierrc.js
├─ commitlint.config.js
├─ package.json
├─ project.tuya.json
├─ ray.config.ts
├─ src
│ ├─ api
│ │ └─ index.ts
│ ├─ app.config.ts
│ ├─ app.tsx // 项目入口文件
│ ├─ components // 组件目录
│ │ ├─ connect.tsx
│ │ └─ index.tsx
│ ├─ config // 配置文件,根据需求删除或保留
│ │ ├─ dpCodes.ts
│ │ ├─ index.ts
│ │ └─ theme.ts
│ ├─ constant // 常量定义
│ │ └─ index.ts
│ ├─ global.config.ts // 项目全局配置项,参照 https://developer.tuya.com/cn/ray/guide/tutorial/global-config
│ ├─ i18n // 多语言本地配置
│ │ ├─ index.ts
│ │ └─ strings.ts
│ ├─ kits.deps.json // 由 IDE 生成,配置 TTT 能力依赖
│ ├─ pages // 页面目录,根据情况添加或删除
│ ├─ redux // redux 逻辑, 根据情况添加或删除
│ │ ├─ actions
│ │ │ ├─ common.ts
│ │ │ └─ theme.ts
│ │ ├─ index.ts
│ │ ├─ reducers
│ │ │ ├─ common.ts
│ │ │ └─ theme.ts
│ │ └─ store.ts
│ ├─ res // 资源目录,根据需求添加或删除
│ │ ├─ index.ts
│ ├─ routes.config.ts // 路由配置 参照https://developer.tuya.com/cn/ray/guide/tutorial/routes
│ ├─ utils // 工具方法存放目录
│ │ └─ index.ts
│ └─ variables.less
├─ tsconfig.json
└─ typings
└─ index.d.ts
上传多语言时需将相应的多语言一并上传,字段信息在/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);