本文档面向已经了解 面板小程序开发 的开发者,你需要充分的了解什么是面板小程序 产品功能 若您对上述概念有所疑问,我们推荐你去看一些预备知识。如果您已了解,可跳过本文。

理解关系

面板作为 IoT 智能设备在 App 终端上的产品形态,创建产品之前,首先来了解一下什么是面板,以及和产品、设备之间的关系。

  1. 面板 是运行在 智能生活 AppOEM App(涂鸦定制 App) 上的界面交互程序,用于控制 智能设备 的运行,展示 智能设备 实时状态。
  2. 产品面板智能设备 联系起来,产品描述了其具备的功能、在 App 上面板显示的名称、智能设备拥有的功能点等。
  3. 智能设备 是搭载了 涂鸦智能模组 的设备,通常在设备上都会贴有一张二维码,使用 智能生活 App 扫描二维码,即可在 App 中获取并安装该设备的控制 面板
  4. 产品面板设备 之间的关系可参考下图。

相关概念

产品名称:智能风扇

需求原型

  1. 支持实时风速调节功能(开关风速控制等)
  2. 支持灯光开关功能
  3. 支持定时功能

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

进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择标准类目 -> 小家电 -> 风扇:

选择功能点,这里根据自己需求选择即可,这些功能未选择不影响视频预览。

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

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

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

拉取并运行模板项目

1.通过 IDE 拉取模板

IDE 点击新建 -> 关联面板小程序和产品 -> 选择模板

2.通过 github 拉取模板

面板模板仓库

拉取项目

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