前提条件

开发环境

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

产品名称:扫地机器人

需求原型

由于产品定义了面板和设备所拥有的功能点,所以在开发一个智能设备面板之前,我们首先需要创建一个激光型扫地机器人产品,定义产品有哪些功能点,然后再在面板中一一实现这些功能点。

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

  1. 单击页面左侧 产品 > 产品开发,在 产品开发 页面单击 创建产品
  2. 标准类目 下选择 小家电,产品品类选择 扫地机器人
  3. 选择智能化方式,产品方案 选择 激光型扫地机,并完善产品信息,如填写 产品名称Robot

  1. 单击 创建产品,完成产品创建。
  2. 产品创建完成后,进入到 添加标准功能 页面,根据自己需求添加功能即可(这些功能未选择不影响视频预览),然后单击 确定

🎉 完成以上步骤后,一个名为 Robot 的扫地机器人产品创建完成。

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

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

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

IDE 基于模板创建项目工程

打开 IDE 创建一个基于 扫地机小程序模板 的面板小程序项目,需要在 Tuya MiniApp IDE 上操作。

详细操作步骤可以参考 初始化项目工程

完成以上步骤后,一个面板小程序的开发模板初始化完成。以下为工程目录的介绍:

├── src
│  	├── api
│  	│   └── ossApi.ts // oss 地图下载相关的 API
│  	├── components
│  	│   ├── DecisionBar // 确认框组件
│  	│   ├── EmptyMap // 空地图组件
│  	│   ├── HomeTopBar // 首页 TopBar 组件
│  	│   ├── IpcRecordTimer // IPC 录制时间组件
│  	│   ├── IpcRecordTip // IPC 录制提示组件
│  	│   ├── Loading // 地图 Loading 组件
│  	│   ├── Map // 地图组件 (WebViewMap, RjsMap)
│  	│   ├── RoomNamePopLayout // 房间命名弹窗组件
│  	│   ├── RoomPreferencePopLayout // 房间清洁偏好弹窗组件
│  	│   ├── Selector // 选择器组件
│  	├── constant
│  	│   ├── dpCodes.ts // dpCode 常量
│  	│   ├── index.ts // 存放所有的常量配置
│  	├── devices // 设备模型
│  	├── hooks // hooks
│  	├── i18n // 多语言
│  	├── iconfont // IconFont 文件
│  	├── pages
│   │   ├── addTiming // 添加定时页面
│   │   ├── cleanPreference // 清洁偏好页面
│   │   ├── cleanRecordDetail // 清扫记录详情页面
│   │   ├── cleanRecords // 清扫记录列表页面
│   │   ├── doNotDisturb // 勿扰模式页面
│   │   ├── home // 首页
│   │   ├── ipc // 视频监控页面
│   │   ├── manual // 手动控制页面
│   │   ├── mapEdit // 地图编辑页面
│   │   ├── multiMap // 多地图管理页面
│   │   ├── roomEdit // 房间编辑页面
│   │   ├── roomFloorMaterial // 房间地板材质页面
│   │   ├── setting // 设置页
│   │   ├── timing // 定时列表页面
│   │   ├── voicePack // 语音包页面
│  	├── redux // redux
│   ├── res // 图片资源 & svg 相关
│   ├── styles // 全局样式
│   ├── utils
│   │   ├── index.ts // 业务常用工具方法
│   │   ├── ipc.ts // ipc 相关工具方法
│   │   ├── robotStatus.ts // 扫地机状态判断方法
│   ├── app.config.ts
│   ├── app.less
│   ├── app.tsx
│   ├── composeLayout.tsx // 处理监听子设备添加,解绑,DP 变化等
│   ├── global.config.ts
│   ├── mixins.less // less mixins
│   ├── routes.config.ts // 配置路由
│   ├── variables.less // less variables
├── typings // 全局类型定义

为了能够让开发者更关注在 UI 层面的处理,而不需要过多关心一些其他的流程逻辑处理,我们将扫地机进行模块拆分,将底层实现与业务调用独立。目前扫地机面板主要依赖的包有以下几个:

对于普遍的扫地机需求,基本上只关注应用业务逻辑和 UI 展示,不需要关心内部依赖包中的实现,依赖包的升级会做到向下兼容,可以在项目中针对依赖包进行单独升级。

实时地图的显示是一个扫地机应用的核心功能,那么,如何在首页渲染出第一张地图呢?

地图组件

@ray-js/robot-map 提供了两种类型的地图组件:

你可以按业务场景需要选择使用 WebViewMap 组件 还是 RjsMap 组件

首页通常为实时展示的扫地机地图,更推荐使用 WebViewMap 组件,可以按以下方式引入:

import WebViewMap from "@/components/Map/WebViewMap";

// Add your custom logic here

return (
  <WebViewMap
    map={map}
    path={path}
    roomProperties={roomProperties}
    virtualWalls={virtualWalls}
    forbiddenSweepZones={forbiddenSweepZones}
    forbiddenMopZones={forbiddenMopZones}
    cleanZones={cleanZones}
    spots={spots}
    runtime={{
      enableRoomSelection: false,
      selectRoomIds,
      editingCleanZoneIds,
    }}
    onMapReady={handleMapReady}
    onMapDrawed={handleMapDrawed}
    onClickRoom={handleClickRoom}
    onClickRoomProperties={handleClickRoom}
  />
);

你可以通过文档查阅更多有关于 @ray-js/robot-map 的详细信息:

$ npx serve node_modules/@ray-js/robot-map-sdk/dist-docs

数据接入

那么,地图和路径数据是怎么注入到这个组件的呢?

模板封装了 @ray-js/robot-data-stream 工具库,内置了 P2P 初始化、建立连接、下载数据流、销毁等一系列流程,只需要调用 useP2PDataStream 这个 Hooks,就能实时获得从扫地机设备传输过来的地图、路径数据(前提是您的扫地机设备已经开发支持 P2P 数据传输)。

import { useP2PDataStream } from "@ray-js/robot-data-stream";
import { useMapData, usePathData } from "@/hooks";

// useMapData 是将地图数据进行一些处理后注入到 地图组件 的hooks
const { onMapData } = useMapData();
// usePathData 是将路径数据进行一些处理后注入到 地图组件 的hooks
const { onPathData } = usePathData();

useP2PDataStream(getDevInfo().devId, onMapData, onPathData);

IDE 的地图调试

如以上步骤已顺利完成,也许你已经成功在手机上看到了地图,但如果开发阶段就要依赖于手机扫码的方式进行调试,会有些许不便。那么,是否有方法能在 IDE 上也展示实时地图呢?

虽然 IDE 不具备 P2P 连接的环境,但可以借助 扫地机调试助手 插件来实现,具体使用方式可参考 文档

清扫模式

清扫是扫地机器人最基本的功能。模板内置了 4 种清扫模式:全屋选区定点划区

所有清扫模式的状态都通过 Redux 进行管理,存储在 mapStateSlice 中。当前激活的清扫模式保存在 currentMode 字段中。

切换清扫模式

清扫模式的切换非常简单,只需要通过 Redux 更新 currentMode 状态:

import { useDispatch } from "react-redux";
import { updateMapState } from "@/redux/modules/mapStateSlice";

const dispatch = useDispatch();

/**
 * 切换清扫模式
 * @param mode - 清扫模式:'smart' | 'select_room' | 'pose' | 'zone'
 */
const handleSwitchMode = async (mode: Mode) => {
  // 获取视口中心点(用于定点清扫)
  let spotPoint = { x: 0, y: 0 };
  if (mode === "pose") {
    spotPoint = await mapApi.getSpotPointByViewportCenter();
  }

  dispatch(
    updateMapState({
      currentMode: mode,
      selectRoomIds: [],
      spots: mode === "pose" ? [{ id: "0", point: spotPoint }] : [],
      cleanZones: mode === "zone" ? [] : [],
    })
  );
};

定点清扫(指哪扫哪)

定点清扫模式允许用户在地图上添加一个可移动的清扫点。新模板通过 MapApi 实例和 Redux 状态来实现这一功能。

获取 MapApi 实例

MapApi 实例在地图组件加载完成后会通过 onMapReady 回调返回,并存储到 Redux 中:

import { setMapApi } from "@/redux/modules/mapApisSlice";

const handleMapReady = (mapApi: MapApi) => {
  // 将 mapApi 实例存储到 Redux,方便其他组件调用
  dispatch(setMapApi({ key: "home", mapApi }));
};

添加定点清扫点

在其他组件中,你可以通过 Redux selector 获取 mapApi 实例,并使用它来获取视口中心的定点坐标:

import { useSelector, useDispatch } from "react-redux";
import { selectMapApiByKey } from "@/redux/modules/mapApisSlice";
import { updateMapState } from "@/redux/modules/mapStateSlice";

const mapApi = useSelector(selectMapApiByKey("home"));
const dispatch = useDispatch();

/**
 * 添加一个定点清扫的移动点
 */
const handleAddSpot = async () => {
  // 获取视口中心点坐标
  const spotPoint = await mapApi.getSpotPointByViewportCenter();

  dispatch(
    updateMapState({
      currentMode: "pose",
      spots: [
        {
          id: "0",
          point: spotPoint,
        },
      ],
    })
  );
};

地图组件中的定点清扫配置

定点清扫的交互由 WebViewMap 组件的 props 控制:

<WebViewMap
  // 定点清扫的点位数据
  spots={spots}
  runtime={{
    // 当机器人不工作且处于定点模式时,允许编辑定点位置
    editingSpotIds:
      robotIsNotWorking(dpStatus) && currentMode === "pose"
        ? spots.map((spot) => spot.id)
        : [],
  }}
  // 定点更新回调
  onUpdateSpot={(spot: SpotParam) => {
    dispatch(updateMapState({ spots: [spot] }));
  }}
/>

划区清扫

划区清扫模式允许用户在地图上绘制多个矩形区域进行清扫。

添加划区清扫区域

使用 mapApi.getCleanZonePointsByViewportCenter() 方法可以在视口中心生成一个划区框:

import { useSelector, useDispatch } from "react-redux";
import { selectMapApiByKey } from "@/redux/modules/mapApisSlice";
import {
  selectMapStateByKey,
  updateMapState,
} from "@/redux/modules/mapStateSlice";
import { nanoid } from "@reduxjs/toolkit";
import { offsetPointsToAvoidOverlap } from "@ray-js/robot-map";

const mapApi = useSelector(selectMapApiByKey("home"));
const cleanZones = useSelector(selectMapStateByKey("cleanZones"));
const dispatch = useDispatch();

/**
 * 新增划区框
 */
const handleAddCleanZone = async () => {
  // 在视口中心生成一个划区框,大小为 1.6m
  const zonePoints = await mapApi.getCleanZonePointsByViewportCenter({
    size: 1.6,
  });

  // 避免与现有划区框重叠
  offsetPointsToAvoidOverlap(
    zonePoints,
    cleanZones.map((zone) => zone.points)
  );

  const newId = nanoid();

  dispatch(
    updateMapState({
      cleanZones: [
        ...cleanZones,
        {
          points: zonePoints,
          id: newId,
        },
      ],
      // 设置为可编辑状态
      editingCleanZoneIds: [newId],
    })
  );
};

地图组件中的划区清扫配置

<WebViewMap
  // 划区清扫的区域数据
  cleanZones={cleanZones}
  runtime={{
    // 当前可编辑的划区ID列表
    editingCleanZoneIds,
  }}
  // 点击划区框时触发
  onClickCleanZone={(data: ZoneParam) => {
    dispatch(updateMapState({ editingCleanZoneIds: [data.id] }));
  }}
  // 划区框更新时触发(拖动、缩放)
  onUpdateCleanZone={(cleanZone: ZoneParam) => {
    dispatch(
      updateMapState({
        cleanZones: cleanZones.map((zone) =>
          zone.id === cleanZone.id ? cleanZone : zone
        ),
      })
    );
  }}
  // 删除划区框时触发
  onRemoveCleanZone={(id: string) => {
    dispatch(
      updateMapState({
        cleanZones: cleanZones.filter((zone) => zone.id !== id),
      })
    );
  }}
/>

选区清扫

选区清扫允许用户点击地图上的房间来选择需要清扫的区域。

地图组件中的选区清扫配置

<WebViewMap
  runtime={{
    // 开启房间选择模式
    enableRoomSelection: currentMode === "select_room",
    // 已选中的房间ID列表
    selectRoomIds,
  }}
  // 点击房间时触发
  onClickRoom={(data: RoomData) => {
    if (robotIsNotWorking(dpStatus) && currentMode === "select_room") {
      const { selectRoomIds } = store.getState().mapState;

      if (selectRoomIds.includes(data.id)) {
        // 取消选择
        dispatch(
          updateMapState({
            selectRoomIds: selectRoomIds.filter((id) => id !== data.id),
          })
        );
      } else {
        // 添加选择
        dispatch(
          updateMapState({
            selectRoomIds: [...selectRoomIds, data.id],
          })
        );
      }
    }
  }}
/>

下发清扫指令

当用户点击开始清扫按钮时,需要根据当前的清扫模式下发相应的指令:

import {
  encodeZoneClean0x3a,
  encodeSpotClean0x3e,
  encodeRoomClean0x14,
} from "@ray-js/robot-protocol";
import { PROTOCOL_VERSION } from "@/constant";

/**
 * 下发划区清扫指令
 */
const handleZoneStart = async () => {
  const { cleanZones } = store.getState().mapState;

  const command = encodeZoneClean0x3a({
    version: PROTOCOL_VERSION,
    protocolVersion: 2,
    cleanMode: 0,
    suction: 1,
    cistern: 1,
    cleanTimes: 1,
    origin: { x: 0, y: 0 },
    zones: cleanZones.map((item) => ({
      name: "",
      points: item.points,
    })),
  });

  await dpActions[commandTransCode].set(command);
  await dpActions[modeCode].set("zone");
  await dpActions[switchGoCode].set(true);
};

/**
 * 下发定点清扫指令
 */
const handlePoseStart = async () => {
  const { spots } = store.getState().mapState;

  const command = encodeSpotClean0x3e({
    version: PROTOCOL_VERSION,
    protocolVersion: 1,
    cleanMode: 0,
    suction: 4,
    cistern: 0,
    cleanTimes: 2,
    origin: { x: 0, y: 0 },
    points: spots.map((item) => item.point),
  });

  await dpActions[commandTransCode].set(command);
  await dpActions[modeCode].set("pose");
  await dpActions[switchGoCode].set(true);
};

/**
 * 下发选区清扫指令
 */
const handleSelectRoomStart = async () => {
  const { version, selectRoomIds } = store.getState().mapState;

  const data = encodeRoomClean0x14({
    cleanTimes: 1,
    roomIds: selectRoomIds,
    mapVersion: version,
  });

  dpActions[commandTransCode].set(data);
  dpActions[modeCode].set("select_room");
  dpActions[switchGoCode].set(true);
};

核心概念总结

  1. 状态管理:所有清扫相关的状态都存储在 Redux 的 mapStateSlice
  2. MapApi 实例:通过 mapApisSlice 存储和访问,提供地图操作的核心方法
  3. 地图组件WebViewMap 组件通过 props 接收状态,通过回调函数通知状态变更
  4. 清扫模式:通过 currentMode 字段控制,配合不同的 runtime 配置实现不同的交互模式

概述

多地图功能允许扫地机器人保存和管理多张不同的地图(例如不同楼层的地图)。模板采用了快照图片的方式实现多地图列表展示,相比直接渲染多个地图组件实例,这种方式具有更好的性能表现。

实现方案对比

建议使用的两种多地图实现方案:

方案

优点

缺点

适用场景

快照图片方式(推荐)

性能优秀,内存占用低,加载速度快

图片为静态快照,无法交互

多地图列表展示

RjsMap 组件方式

可交互,功能完整

创建多个实例导致性能开销大,内存占用高

需要交互的场景

模板采用快照图片方式,使用 MapApi.snapshotByData() 方法生成地图快照。

核心实现

多地图功能的核心逻辑封装在 multiMapsSlice 中,主要包含以下几个部分:

1. 获取地图列表

通过 fetchMultiMaps 异步 action 获取设备存储的多地图数据:

import { useDispatch, useSelector } from "react-redux";
import {
  fetchMultiMaps,
  selectMultiMaps,
} from "@/redux/modules/multiMapsSlice";

const dispatch = useDispatch();
const multiMaps = useSelector(selectMultiMaps);

useEffect(() => {
  // 获取多地图列表
  dispatch(fetchMultiMaps());
}, []);

fetchMultiMaps 的执行流程:

  1. 从本地缓存读取已生成的快照图片
  2. 调用 getMultipleMapFiles API 获取云端地图文件列表
  3. 对比缓存和云端数据,清理已删除的地图快照
  4. 对未生成快照的地图,加入异步队列等待处理
  5. 返回地图列表数据

2. 生成地图快照

模板使用异步任务队列 createAsyncQueue 来串行处理快照生成任务,避免同时处理过多地图导致性能问题:

const taskQueue = createAsyncQueue(
  async (params: { filePathKey: string; bucket: string; file: string }) => {
    try {
      const { filePathKey, bucket, file } = params;

      // 1. 从云端下载地图数据
      const data = await getMapInfoFromCloudFile({
        bucket,
        file,
      });

      const {
        virtualState: { virtualWalls, forbiddenMopZones, forbiddenSweepZones },
      } = data;

      // 2. 获取 home 页面的 MapApi 实例
      const homeMapApi = store.getState().mapApis.home;

      if (homeMapApi) {
        // 3. 使用 snapshotByData 生成快照图片
        const snapshotImage = await homeMapApi.snapshotByData({
          map: data.originMap,
          roomProperties: decodeRoomProperties(data.originMap),
          virtualWalls,
          forbiddenMopZones,
          forbiddenSweepZones,
        });

        // 4. 将快照存储到 Redux
        store.dispatch(
          upsertSnapshotImage({ key: filePathKey, snapshotImage })
        );
      }
    } catch (err) {
      console.error(err);
    }
  },
  // 队列处理完成后,将所有快照保存到本地缓存
  () => {
    const { snapshotImageMap } = store.getState().multiMaps;
    setStorage({
      key: `snapshotImageMap_${devices.common.getDevInfo().devId}`,
      data: JSON.stringify(snapshotImageMap),
    });
  }
);

关键点:

3. 显示地图快照

在多地图列表页面,直接使用 Image 组件显示快照:

import { Image } from "@ray-js/ray";
import { useSelector } from "react-redux";
import { ReduxState } from "@/redux";

const Item: FC<{ data: MultiMap }> = ({ data }) => {
  const { filePathKey } = data;

  // 从 Redux 获取快照图片
  const snapshotImage = useSelector(
    (state: ReduxState) => state.multiMaps.snapshotImageMap[filePathKey]
  );

  return (
    <View className={styles.mapWrapper}>
      {snapshotImage && (
        <Image
          className={styles.mapImage}
          src={snapshotImage}
          mode="aspectFit"
        />
      )}
      {/* 快照未生成时显示 Loading */}
      <Loading isLoading={!snapshotImage} />
    </View>
  );
};

4. 地图操作

多地图支持两种操作:使用地图删除地图

使用地图

import { encodeUseMap0x2e } from "@ray-js/robot-protocol";
import { commandTransCode } from "@/constant/dpCodes";

const handleUseMap = async () => {
  const { bucket, robotUseFile, mapId } = data;

  // 获取云端文件的完整 URL
  const { data: url } = await ossApiInstance.getCloudFileUrl(
    bucket,
    robotUseFile
  );

  // 下发使用地图指令
  actions[commandTransCode].set(
    encodeUseMap0x2e({
      mapId,
      url,
    })
  );
};

删除地图

import { encodeDeleteMap0x2c } from "@ray-js/robot-protocol";

const handleDelete = () => {
  const { id } = data;

  // 下发删除地图指令
  actions[commandTransCode].set(encodeDeleteMap0x2c({ id }));
};

使用 RjsMap 组件方式(备选)

如果需要在多地图列表中实现交互功能,可以使用 RjsMap 组件直接渲染地图:

import RjsMap from "@/components/Map/RjsMap";

const Item: FC<{ data: MultiMap }> = ({ data }) => {
  return (
    <RjsMap
      map={map}
      path={path}
      roomProperties={roomProperties}
      virtualWalls={virtualWalls}
      forbiddenSweepZones={forbiddenSweepZones}
      forbiddenMopZones={forbiddenMopZones}
      cleanZones={cleanZones}
      spots={spots}
      // ... 其他配置
    />
  );
};

核心 API

API

说明

fetchMultiMaps()

获取多地图列表(异步 thunk)

getMapInfoFromCloudFile()

从云端下载地图数据

mapApi.snapshotByData()

根据地图数据生成快照图片

upsertSnapshotImage()

更新快照图片到 Redux

selectMultiMaps

选择所有地图列表

selectSnapshotImageByFilePathKey

根据 key 选择快照图片

概述

地图编辑功能允许用户在地图上设置虚拟限制区域,包括虚拟墙禁扫区禁拖区。这些区域会限制扫地机器人的清扫范围,保护特定区域不被打扰。

页面状态管理

地图编辑页面使用本地 state 管理所有编辑状态,不直接修改 Redux 中的数据,只有在用户点击"确定"时才下发指令:

const [virtualWalls, setVirtualWalls] = useState<VirtualWallParam[]>(
  () => store.getState().mapState.virtualWalls
);
const [forbiddenSweepZones, setForbiddenSweepZones] = useState<ZoneParam[]>(
  () => store.getState().mapState.forbiddenSweepZones
);
const [forbiddenMopZones, setForbiddenMopZones] = useState<ZoneParam[]>(
  () => store.getState().mapState.forbiddenMopZones
);

// 编辑状态
const [editingVirtualWallIds, setEditingVirtualWallIds] = useState<string[]>(
  []
);
const [editingForbiddenMopZoneIds, setEditingForbiddenMopZoneIds] = useState<
  string[]
>([]);
const [editingForbiddenSweepZoneIds, setEditingForbiddenSweepZoneIds] =
  useState<string[]>([]);

核心功能实现

1. 添加虚拟墙

虚拟墙是一条可拖动两端点的线段,使用 mapApi.getWallPointsByViewportCenter() 在视口中心生成:

import { useThrottleFn } from "ahooks";
import { nanoid } from "@reduxjs/toolkit";
import { offsetPointsToAvoidOverlap } from "@ray-js/robot-map";

const { run: handleVirtualWall } = useThrottleFn(
  async () => {
    if (!mapApi) return;

    // 在视口中心生成虚拟墙,宽度 1.2m
    const wallPoints = await mapApi.getWallPointsByViewportCenter({
      width: 1.2,
    });

    // 自动偏移,避免与已有的虚拟墙重叠
    offsetPointsToAvoidOverlap(
      wallPoints,
      virtualWalls.map((wall) => wall.points)
    );

    const newId = nanoid();
    setVirtualWalls([...virtualWalls, { id: newId, points: wallPoints }]);
    setEditingVirtualWallIds([newId]);
  },
  { wait: 300, leading: true, trailing: false }
);

关键点:

2. 添加禁扫区

禁扫区是一个可拖动、可缩放的矩形区域:

const { run: handleNoGo } = useThrottleFn(
  async () => {
    // 在视口中心生成禁扫区,大小 1.6m x 1.6m
    const points = await mapApi.getForbiddenSweepZonePointsByViewportCenter({
      size: 1.6,
    });

    // 自动偏移,避免与已有的禁扫区和禁拖区重叠
    offsetPointsToAvoidOverlap(points, [
      ...forbiddenSweepZones.map((zone) => zone.points),
      ...forbiddenMopZones.map((zone) => zone.points),
    ]);

    const newId = nanoid();
    setForbiddenSweepZones([...forbiddenSweepZones, { id: newId, points }]);
    setEditingForbiddenSweepZoneIds([newId]);
  },
  { wait: 300, leading: true, trailing: false }
);

3. 添加禁拖区

禁拖区的实现与禁扫区类似,只是使用不同的 API 方法:

const { run: handleNoMop } = useThrottleFn(
  async () => {
    // 在视口中心生成禁拖区
    const points = await mapApi.getForbiddenMopZonePointsByViewportCenter({
      size: 1.6,
    });

    offsetPointsToAvoidOverlap(points, [
      ...forbiddenSweepZones.map((zone) => zone.points),
      ...forbiddenMopZones.map((zone) => zone.points),
    ]);

    const newId = nanoid();
    setForbiddenMopZones([...forbiddenMopZones, { id: newId, points }]);
    setEditingForbiddenMopZoneIds([newId]);
  },
  { wait: 300, leading: true, trailing: false }
);

4. 地图组件配置

WebViewMap 组件中配置虚拟墙和禁区的交互:

<WebViewMap
  onMapReady={handleMapReady}
  // 数据
  virtualWalls={virtualWalls}
  forbiddenSweepZones={forbiddenSweepZones}
  forbiddenMopZones={forbiddenMopZones}
  runtime={{
    // 编辑状态
    editingVirtualWallIds,
    editingForbiddenMopZoneIds,
    editingForbiddenSweepZoneIds,
    // 隐藏路径
    showPath: false,
  }}
  // 虚拟墙事件
  onUpdateVirtualWall={handleUpdateVirtualWall}
  onClickVirtualWall={handleClickVirtualWall}
  onRemoveVirtualWall={handleRemoveVirtualWall}
  // 禁拖区事件
  onUpdateForbiddenMopZone={handleUpdateForbiddenMopZone}
  onClickForbiddenMopZone={handleClickForbiddenMopZone}
  onRemoveForbiddenMopZone={handleRemoveForbiddenMopZone}
  // 禁扫区事件
  onUpdateForbiddenSweepZone={handleUpdateForbiddenSweepZone}
  onClickForbiddenSweepZone={handleClickForbiddenSweepZone}
  onRemoveForbiddenSweepZone={handleRemoveForbiddenSweepZone}
/>

5. 事件处理

更新: 拖动或缩放时触发

const handleUpdateVirtualWall = (wall: VirtualWallParam) => {
  setVirtualWalls(virtualWalls.map((w) => (w.id === wall.id ? wall : w)));
};

const handleUpdateForbiddenSweepZone = (zone: ZoneParam) => {
  setForbiddenSweepZones(
    forbiddenSweepZones.map((z) => (z.id === zone.id ? zone : z))
  );
};

点击: 切换编辑状态

const handleClickVirtualWall = (wall: VirtualWallParam) => {
  setEditingVirtualWallIds([wall.id]);
};

const handleClickForbiddenSweepZone = (zone: ZoneParam) => {
  setEditingForbiddenSweepZoneIds([zone.id]);
};

删除: 移除禁区或虚拟墙

const handleRemoveVirtualWall = (removedId: string) => {
  setVirtualWalls(virtualWalls.filter((w) => w.id !== removedId));
  setEditingVirtualWallIds(
    editingVirtualWallIds.filter((id) => id !== removedId)
  );
};

6. 保存和取消

取消按钮: 恢复到进入页面时的状态

const handleReset = () => {
  // 从 Redux store 中重新获取初始数据
  const initialState = store.getState().mapState;

  // 重置所有数据到初始状态
  setVirtualWalls(initialState.virtualWalls);
  setForbiddenSweepZones(initialState.forbiddenSweepZones);
  setForbiddenMopZones(initialState.forbiddenMopZones);

  // 清空所有编辑状态
  setEditingVirtualWallIds([]);
  setEditingForbiddenMopZoneIds([]);
  setEditingForbiddenSweepZoneIds([]);
};

确定按钮: 下发指令保存到设备

import {
  encodeVirtualArea0x38,
  encodeVirtualWall0x12,
} from "@ray-js/robot-protocol";
import { PROTOCOL_VERSION } from "@/constant";

const handleConfirm = async () => {
  // 编码禁区指令(禁扫区 + 禁拖区)
  const zonesCommand = encodeVirtualArea0x38({
    version: PROTOCOL_VERSION,
    protocolVersion: 1,
    virtualAreas: forbiddenSweepZones
      .map((item) => ({
        points: item.points,
        mode: 1, // 禁扫区
        name: "",
      }))
      .concat(
        forbiddenMopZones.map((item) => ({
          points: item.points,
          mode: 2, // 禁拖区
          name: "",
        }))
      ),
    origin: { x: 0, y: 0 },
  });

  // 编码虚拟墙指令
  const virtualWallsCommand = encodeVirtualWall0x12({
    version: PROTOCOL_VERSION,
    origin: { x: 0, y: 0 },
    walls: virtualWalls.map((item) => item.points),
  });

  // 下发指令
  actions[commandTransCode].set(zonesCommand);
  actions[commandTransCode].set(virtualWallsCommand);

  // 清空编辑状态
  setEditingForbiddenMopZoneIds([]);
  setEditingForbiddenSweepZoneIds([]);
  setEditingVirtualWallIds([]);
};

最佳实践

  1. 防止重叠:使用 offsetPointsToAvoidOverlap 自动偏移新创建的禁区
  2. 节流处理:使用 useThrottleFn 防止用户快速点击导致创建多个禁区
  3. 本地状态:编辑过程中使用本地 state,只在确定时下发指令
  4. 可撤销:提供取消按钮,允许用户恢复到初始状态

概述

房间编辑功能允许用户对地图上的房间进行管理,包括房间合并房间分割房间重命名清扫顺序设置。这些功能帮助用户更好地组织和控制清扫任务。

状态管理

房间编辑使用多层状态管理,临时状态只在确定时才提交:

type RoomEditStatus = "normal" | "split" | "merge" | "reName" | "order";

// 编辑状态
const [roomEditStatus, setRoomEditStatus] = useState<RoomEditStatus>("normal");
const [enableRoomSelection, setEnableRoomSelection] = useState(false);
const [selectRoomIds, setSelectRoomIds] = useState<number[]>([]);
const [dividingRoomId, setDividingRoomId] = useState<number | null>(null);

// 房间选择模式
const [roomSelectionMode, setRoomSelectionMode] = useState<
  "checkmark" | "order"
>("checkmark");

// 临时状态(未提交)
const [tempCleaningOrder, setTempCleaningOrder] = useState<
  Record<number, number>
>({});
const [tempName, setTempName] = useState<Record<number, string>>({});

// 合并原始数据和临时状态
const finalRoomProperties = useMemo(() => {
  return roomProperties.map((room) => ({
    ...room,
    order: tempCleaningOrder[room.id] ?? room.order ?? 0,
    name: tempName[room.id] ?? room.name ?? "",
  }));
}, [roomProperties, tempCleaningOrder, tempName]);

核心功能实现

1. 房间合并

合并两个相邻的房间为一个:

/**
 * 进入合并模式
 */
const handleMerge = async () => {
  setEnableRoomSelection(true);
  setSelectRoomIds([]);
  setRoomEditStatus("merge");
  setRoomSelectionMode("checkmark");
};

/**
 * 点击房间选择逻辑
 */
const handleClickRoom = (room: RoomData) => {
  if (roomEditStatus === "merge") {
    if (selectRoomIds.includes(room.id)) {
      // 取消选择
      const newSelectRoomIds = selectRoomIds.filter((id) => id !== room.id);
      setSelectRoomIds(newSelectRoomIds);
      setActiveConfirm(newSelectRoomIds.length === 2);
    } else {
      if (selectRoomIds.length >= 2) {
        // 只能合并两个房间
        ToastInstance("只能选择两个房间进行合并");
        return;
      }
      const newSelectRoomIds = [...selectRoomIds, room.id];
      setSelectRoomIds(newSelectRoomIds);
      setActiveConfirm(newSelectRoomIds.length === 2);
    }
  }
};

/**
 * 确定合并
 */
const handleConfirmMerge = async () => {
  // 检查房间是否相邻
  const isAdjacent = await mapApi.areRoomsAdjacent(selectRoomIds);

  if (!isAdjacent) {
    ToastInstance.fail("只能合并相邻的房间");
    return;
  }

  showLoading({ title: "" });

  // 编码合并指令
  const command = encodePartitionMerge0x1e({
    roomIds: selectRoomIds,
    version: PROTOCOL_VERSION,
  });

  actions[commandTransCode].set(command);

  // 设置超时提示
  timerRef.current = setTimeout(() => {
    hideLoading();
    ToastInstance.fail({ message: "合并失败" });
  }, 20 * 1000);
};

关键点:

2. 房间分割

在房间内绘制分割线,将其分成两个房间:

/**
 * 进入分割模式
 */
const handleSplit = () => {
  setActiveConfirm(false);
  setRoomEditStatus("split");
  setEnableRoomSelection(true);
  setSelectRoomIds([]);
  setRoomSelectionMode("checkmark");
};

/**
 * 点击房间选择要分割的房间
 */
const handleClickRoom = (room: RoomData) => {
  if (roomEditStatus === "split") {
    setSelectRoomIds([room.id]);
    setDividingRoomId(room.id); // 启用分割线绘制
    setActiveConfirm(true);
  }
};

/**
 * 分割线更新时,检查是否有效
 */
const handleUpdateDivider = async () => {
  const effectiveDividerPoints = await mapApi.getEffectiveDividerPoints();

  if (!effectiveDividerPoints) {
    setActiveConfirm(false); // 无效的分割线,禁用确定按钮
  } else {
    setActiveConfirm(true);
  }
};

/**
 * 确定分割
 */
const handleConfirmSplit = async () => {
  // 获取有效的分割线点位
  const points = await mapApi.getEffectiveDividerPoints();

  const command = encodePartitionDivision0x1c({
    roomId: dividingRoomId,
    points,
    origin: { x: 0, y: 0 },
    version: PROTOCOL_VERSION,
  });

  actions[commandTransCode].set(command);
};

关键点:

3. 房间重命名

为房间设置自定义名称:

/**
 * 进入重命名模式
 */
const handleRename = () => {
  setRoomEditStatus("reName");
  setEnableRoomSelection(true);
  setSelectRoomIds([]);
  setRoomSelectionMode("checkmark");
};

/**
 * 点击房间显示重命名弹窗
 */
const handleClickRoom = (room: RoomData) => {
  if (roomEditStatus === "reName") {
    setSelectRoomIds([room.id]);
    setShowRenameModal(true);
  }
};

/**
 * 重命名弹窗确定
 */
const handleRenameConfirm = (name: string) => {
  setShowRenameModal(false);
  setTempName({ ...tempName, [selectRoomIds[0]]: name });
  setActiveConfirm(true);
};

/**
 * 确定重命名
 */
const handleConfirmRename = async () => {
  const { version } = store.getState().mapState;

  const command = encodeSetRoomName0x24({
    mapVersion: version,
    version: PROTOCOL_VERSION,
    rooms: Object.entries(tempName).map(([roomId, name]) => ({
      roomId: Number(roomId),
      name,
    })),
  });

  actions[commandTransCode].set(command);
  showLoading({ title: "" });
};

关键点:

4. 清扫顺序

设置房间的清扫先后顺序:

/**
 * 进入排序模式
 */
const handleOrder = async () => {
  setRoomEditStatus("order");
  setEnableRoomSelection(true);
  setSelectRoomIds([]);
  setRoomSelectionMode("order"); // 使用顺序选择模式
};

/**
 * 点击房间设置/取消顺序
 */
const handleClickRoom = (room: RoomData) => {
  if (roomEditStatus === "order") {
    const currentOrder =
      finalRoomProperties.find((r) => r.id === room.id)?.order || 0;

    setTempCleaningOrder((prev) => {
      if (currentOrder > 0) {
        // 取消顺序,其他房间顺序递减
        const updates = { ...prev, [room.id]: 0 };
        finalRoomProperties.forEach((r) => {
          if (r.order > currentOrder) {
            const originalOrder =
              roomProperties.find((orig) => orig.id === r.id)?.order || 0;
            updates[r.id] = (prev[r.id] ?? originalOrder) - 1;
          }
        });
        return updates;
      }

      // 设置新顺序(追加到末尾)
      const maxOrder = Math.max(0, ...finalRoomProperties.map((r) => r.order));
      return { ...prev, [room.id]: maxOrder + 1 };
    });
  }
};

/**
 * 确定排序
 */
const handleConfirmOrder = async () => {
  const { version } = store.getState().mapState;

  // 按顺序排列房间 ID
  const roomIds = finalRoomProperties
    .sort((a, b) => a.order - b.order)
    .map((room) => room.id);

  const command = encodeRoomOrder0x26({
    version: PROTOCOL_VERSION,
    roomIds,
    mapVersion: version,
  });

  actions[commandTransCode].set(command);
  showLoading({ title: "" });
};

关键点:

5. 地图组件配置

<WebViewMap
  roomProperties={finalRoomProperties}
  runtime={{
    // 启用房间选择
    enableRoomSelection,
    selectRoomIds,
    // 显示房间顺序数字
    showRoomOrder: true,
    // 分割模式下的房间 ID
    dividingRoomId,
    // 选择模式:checkmark(勾选)或 order(顺序)
    roomSelectionMode,
    // 隐藏路径
    showPath: false,
  }}
  onMapReady={handleMapReady}
  onMapFirstDrawed={handleMapFirstDrawed}
  onClickRoom={handleClickRoom}
  onClickRoomProperties={handleClickRoom}
  onUpdateDivider={handleUpdateDivider}
/>

6. 设备回复处理

监听设备的指令回复,处理成功/失败状态:

useEffect(() => {
  const handleRoomEditResponse = ({ cmd, command }) => {
    if (timerRef.current) {
      // 房间分割回复
      if (cmd === PARTITION_DIVISION_CMD_ROBOT_V1) {
        const splitResponse = decodePartitionDivision0x1d({ command });
        if (splitResponse) {
          clearTimeout(timerRef.current);
          hideLoading();
          handleNormal(); // 恢复正常状态

          if (splitResponse.success) {
            ToastInstance.success({ message: "分割成功" });
          } else {
            ToastInstance.fail({ message: "分割失败" });
          }
        }
      }

      // 房间合并回复
      if (cmd === PARTITION_MERGE_CMD_ROBOT_V1) {
        const mergeResponse = decodePartitionMerge0x1f({ command });
        // ... 类似处理
      }

      // 房间重命名回复
      if (cmd === SET_ROOM_NAME_CMD_ROBOT_V1) {
        const roomNameResponse = decodeSetRoomName0x25({
          command,
          version: PROTOCOL_VERSION,
          mapVersion: store.getState().mapState.version,
        });
        // ... 类似处理
      }
    }
  };

  emitter.on("receiveRoomEditResponse", handleRoomEditResponse);

  return () => {
    emitter.off("receiveRoomEditResponse", handleRoomEditResponse);
  };
}, []);

定时功能使用 DP 定时-device_timer

定时列表

通过 decodeDeviceTimer0x31 可以将定时 DP 解析为定时列表数据。

import { decodeDeviceTimer0x31 } from "@ray-js/robot-protocol";

type TimerData = {
  effectiveness: number;
  week: number[];
  time: {
    hour: number;
    minute: number;
  };
  roomIds: number[];
  cleanMode: number;
  fanLevel: number;
  waterLevel: number;
  sweepCount: number;
  roomNum: number;
};

const [timerList, setTimerList] = useState<TimerData[]>([]);

const dpDeviceTimer = useProps((props) => props[deviceTimerCode]);

useEffect(() => {
  if (dpDeviceTimer) {
    const { list } = decodeDeviceTimer0x31({
      command: dpDeviceTimer,
      version: PROTOCOL_VERSION,
    }) ?? { list: [] };

    setTimerList(list);
  }
}, [dpDeviceTimer]);

对定时项可以进行删除/开关操作,可以使用 encodeDeviceTimer0x30 把新的定时列表转换为 DP 指令进行下发。

import { encodeDeviceTimer0x30 } from "@ray-js/robot-protocol";
import produce from "immer";

type TimerData = {
  effectiveness: number;
  week: number[];
  time: {
    hour: number;
    minute: number;
  };
  roomIds: number[];
  cleanMode: number;
  fanLevel: number;
  waterLevel: number;
  sweepCount: number;
  roomNum: number;
};

const [timerList, setTimerList] = useState<TimerData[]>([]);

// 删除一条定时
const deleteTimer = (index: number) => {
  const newList = [...timerList];
  newList.splice(index, 1);
  const command = encodeDeviceTimer0x30({
    list: newList,
    version: PROTOCOL_VERSION,
    number: newList.length,
  });
  actions[deviceTimerCode].set(command);
};

// 打开/关闭一条定时
const toggleTimer = (index: number, enable: boolean) => {
  const newList = produce(timerList, (draft) => {
    draft[index].effectiveness = enable;
  });

  const command = encodeDeviceTimer0x30({
    list: newList,
    version: PROTOCOL_VERSION,
    number: newList.length,
  });
  actions[deviceTimerCode].set(command);
};

添加定时

添加定时同样使用 encodeDeviceTimer0x30 来组装指令。

// 添加一条定时
const addTimer = (newTimer: TimerData) => {
  const newList = [newTimer, ...timerList];

  const command = encodeDeviceTimer0x30({
    list: newList,
    version: PROTOCOL_VERSION,
    number: newList.length,
  });
  actions[deviceTimerCode].set(command);
};

勿扰模式使用 DP 勿扰时间设置-disturb_time_set

在设置完开关、开始时间、结束时间信息后,单击 保存 即可下发勿扰模式,通过 encodeDoNotDisturb0x40 可以将相关信息组装为 DP 指令。

import { encodeDoNotDisturb0x40 } from "@ray-js/robot-protocol";

// Add your custom logic here

// 保存下发勿扰模式信息
const handleSave = () => {
  const command = encodeDoNotDisturb0x40({
    // 勿扰开关
    enable,
    // 开始时间-小时
    startHour,
    // 开始时间-分钟
    startMinute,
    // 结束时间-小时
    endHour,
    // 结束时间-分钟
    endMinute,
  });

  actions[commandTransCode].set(command);
};

同样,可以使用 decodeDoNotDisturb0x41 将设备上报的勿扰模式 DP 解析并呈现在页面上。

import { decodeDoNotDisturb0x41 } from "@ray-js/robot-protocol";

const dpDisturbTimeSet = useProps((props) => props[disturbTimeSetCode]);
// 勿扰模式dp解析为结构化数据
const { enable, startHour, startMinute, endHour, endMinute } =
  decodeDoNotDisturb0x41(dpDisturbTimeSet) ?? DEFAULT_VALUE;

// Add your custom logic here

清扫记录列表

清扫记录数据的获取,请参考 清扫记录 API

模板已经在 Redux 里封装了 cleanRecordsSlice 用于清扫记录数据的删改查,可以参考相关代码。

import {
  deleteCleanRecord,
  fetchCleanRecords,
  selectCleanRecords,
} from "@/redux/modules/cleanRecordsSlice";

const records = useSelector(selectCleanRecords);

const handleDelete = (id: number) => {
  dispatch(deleteCleanRecord(id));
};

useEffect(() => {
  (dispatch as AppDispatch)(fetchCleanRecords());
}, []);

return (
  <View className={styles.container}>
    {records.map((record) => (
      <Item key={record.id} data={record} onDeleted={handleDelete} />
    ))}
  </View>
);

清扫记录详情

清扫记录详情页面展示单次清扫任务的完整信息,包括清扫时间、清扫面积、清扫模式以及对应的地图和路径。

数据获取

从 Redux store 获取清扫记录,并从云端下载对应的地图文件:

// 获取清扫记录
const { bucket, file, extendInfo } = useSelector((state: ReduxState) =>
  selectCleanRecordById(state, Number(id))
);

const { mapLength, pathLength, cleanMode, time, area } = extendInfo;

// 从云端加载地图数据
useEffect(() => {
  const fetchHistoryMap = async () => {
    const mapData = await getMapInfoFromCloudFile({
      bucket,
      file,
      mapLen: mapLength,
      pathLen: pathLength,
    });

    if (mapData) {
      const { originMap, originPath, virtualState } = mapData;

      setMap(originMap);
      setPath(originPath);

      // 解析虚拟墙和禁区
      setVirtualWalls(
        virtualState.virtualWallData.map((points) => ({
          points,
          id: nanoid(),
        }))
      );
      setForbiddenSweepZones(
        virtualState.virtualAreaData.map(({ points }) => ({
          points,
          id: nanoid(),
        }))
      );
      setForbiddenMopZones(
        virtualState.virtualMopAreaData.map(({ points }) => ({
          points,
          id: nanoid(),
        }))
      );
    }
  };

  fetchHistoryMap();
}, [bucket, file, mapLength, pathLength]);

地图渲染

使用 WebViewMap 组件渲染历史地图和路径:

<WebViewMap
  map={map} // 地图数据
  path={path} // 清扫路径
  virtualWalls={virtualWalls} // 虚拟墙
  forbiddenSweepZones={forbiddenSweepZones} // 禁扫区
  forbiddenMopZones={forbiddenMopZones} // 禁拖区
/>

关键点:

关于语音包数据的获取,请参考 机器语音 API

import { getVoiceList } from "@ray-js/ray";

type Voice = {
  auditionUrl: string;
  desc?: string;
  extendData: {
    extendId: number;
    version: string;
  };
  id: number;
  imgUrl: string;
  name: string;
  officialUrl: string;
  productId: string;
  region: string[];
};

const [voices, setVoices] = useState<Voice[]>([]);

useEffect(() => {
  const fetchVoices = async () => {
    const res = await getVoiceList({
      devId: getDevInfo().devId,
      offset: 0,
      limit: 100,
    });

    setVoices(res.datas);
  };

  fetchVoices();
}, []);

return (
  <View className={styles.container}>
    {voices.map((voice) => (
      <Item key={voice.id} data={voice} deviceVoice={deviceVoice} />
    ))}
  </View>
);

语音包的下发上报使用 DP 语音包数据下发-voice_data,可使用 @ray-js/robot-protocol 提供的 encodeVoice0x34decodeVoice0x35 完成 DP 数据的组装和解析。

当下发使用某个语音包:

import { useActions } from "@ray-js/panel-sdk";

const actions = useActions();

const handleUse = () => {
  actions[voiceDataCode].set(
    encodeVoice0x34({
      // id url md5 数据均来源于机器语音 API
      id: extendData.extendId,
      url: officialUrl,
      md5: desc,
    })
  );
};

解析语音包上报的数据,获取语音包信息、下载进度、使用状态:

import { useProps } from "@ray-js/panel-sdk";

const dpVoiceData = useProps((props) => props[voiceDataCode]);

const { languageId, status, progress } = decodeVoice0x35({
  command: dpVoiceData,
});

关于语音包的试听,可以参考 音频能力 中的方法。

手动控制是一般的 DP 下发功能,使用 DP 方向-direction_control

模板已经封装了简易的手动控制组件及页面,请参考 src/pages/manual 页面。

import React, { FC, useEffect } from "react";
import {
  View,
  navigateBack,
  onNavigationBarBack,
  setNavigationBarBack,
} from "@ray-js/ray";
import Strings from "@/i18n";
import { Dialog, DialogInstance } from "@ray-js/smart-ui";
import { useActions } from "@ray-js/panel-sdk";
import { directionControlCode, modeCode } from "@/constant/dpCodes";
import ManualPanel from "@/components/ManualPanel";

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

const Manual: FC = () => {
  const actions = useActions();

  useEffect(() => {
    ty.setNavigationBarTitle({
      title: Strings.getLang("dsc_manual"),
    });

    // 进入远程控制需要下发手动模式
    actions[modeCode].set("manual");

    setNavigationBarBack({ type: "custom" });

    onNavigationBarBack(async () => {
      try {
        await DialogInstance.confirm({
          context: this,
          title: Strings.getLang("dsc_tips"),
          icon: true,
          message: Strings.getLang("dsc_exit_manual_tips"),
          confirmButtonText: Strings.getLang("dsc_confirm"),
          cancelButtonText: Strings.getLang("dsc_cancel"),
        });

        actions[directionControlCode].set("exit");
        setNavigationBarBack({ type: "system" });

        setTimeout(() => {
          navigateBack();
        }, 0);
      } catch (err) {
        // do nothing
      }
    });

    return () => {
      setNavigationBarBack({ type: "system" });
    };
  }, []);

  return (
    <View className={styles.container}>
      <ManualPanel />
      <Dialog id="smart-dialog" />
    </View>
  );
};

export default Manual;

模板已内置一个 视频监控 页面。

具体可参考 IPC 通用模板 教程。