在 Codelab 中,您可以利用面板小程序及 Three.js
的能力,开发构建出一个支持 3D 模型展示的智能风扇面板。
详见 面板小程序 > 搭建环境。
产品名称:智能风扇
首先需要创建一个产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。
注册登录 涂鸦开发者平台,并在平台创建产品:
🎉 在这一步,成功完成创建了一个名为 Fan 的智能风扇产品。
面板小程序的开发在 小程序开发者 平台上进行操作,首先请前往 小程序开发者平台 完成平台的注册登录。
详细操作步骤可以参考 面板小程序 > 创建面板小程序。
面板模板仓库:仓库地址
git clone https://github.com/Tuya-Community/tuya-ray-demo.git
panel-d3
模板,直接通过 IDE 导入源码目录,并关联到创建的小程序。cd ./examples/panel-d3
three.js
并在工程中配置,使用 Render Script (RJS) 进行开发。关于配置文档,请参考 文档。gltf
和 glb
模型格式。模型加载可以透过 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;
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
封装的是一个可以从业务层与 3D 组件进行通信的 API function,参考该设计,可以自定义如何从业务层来向 3D 模型发出指令。您可根据自己的需要,来构建对应的能力。
例如,本模板中设置是否启动风扇,参考以下步骤:
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;
};
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);
/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 模块用来在 3D 内部触发模型事件之后,将事件结果回调给业务层。
/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;
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);
},
/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
目录中,建议至少配置两种类型的语言,中文和英文。
dsc
开头,并在每个单词之间使用 *
作为分隔。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);