前提条件

构建内容

在此 Codelab 中,您将利用面板小程序开发构建出一个蓝牙门锁面板,通过 DP 协议、接口将实现以下能力:

支持蓝牙(通过网关)开关门、成员管理、临时密码设置、开关门日志、告警日志、常规门锁设置项的

学习内容

所需条件

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

产品名称

蓝牙门锁

需求原型

  1. 首页
    • 顶部区域展现了安全守护天数,中心区域展示设备的开关锁按钮及门锁状态,底部区域展示了门锁最近一次操作记录(包含开锁、关锁、告警、操作等)和快捷导航列表栏。

  1. 日志页
    • 列表默认展示近一年所有的操作日志,可以按照开门、关门、告警、操作等类型进行筛选,也可以按照近三天、近七天、近一个月等时间范围进行筛选,如果以上筛选条件不满足您的要求,日志接口也支持安装用户编号、时间范围自定义进行刷选。

  1. 成员管理页
    • 列表展示该家庭下所有用户,包括用户头像、用户名称、用户联系方式、指纹、密码、卡片、人脸、指静脉等开锁方式绑定数量展示。

  1. 成员详情页
    • 成员详情页面会详细展示开锁方式信息,提供新增和编辑的开锁方式的入口。如需要修改成员信息,可回退至 APP 家庭列表页修改。

  1. 添加开锁页
    • 本页面仅展示了添加指纹的流程,不同开锁方式添加方式也不相同,具体实现方式请关注后面章节。

  1. 添加开锁成功页
    • 添加开锁方式成功后支持修改开锁方式名称,本接口支持劫持告警功能实现。

  1. 添加开锁失败页
    • 添加开锁方式失败后的展示。

  1. 临时密码页
    • 根据 DP 和进制等配置动态展示支持的临时密码类型,在本页面获取密码后点击记录按钮可进入临时密码列表页。

  1. 临时密码列表页
    • 本页面通过列表形式展示了目前正在生效的临时密码。详细展示了密码生效周期,点击后会动态判断密码类型去进行重命名、删除、编辑等操作的入口提示框。

  1. 临时密码失效列表页

  1. 临时密码清空码页

  1. 设置页

功能汇总

当前门锁模板必须的功能点,实现了蓝牙本地开锁逻辑

check_code_set,
ble_unlock_check,

配置蓝牙开锁校验码

参数

取值

dpid

70

code

check_code_set

type

透传型(Raw)

mode

可下发可上报(rw)

蓝牙开锁(带校验码)

参数

取值

dpid

71

code

ble_unlock_check

type

透传型(Raw)

mode

可下发可上报(rw)

如果需要实现蓝牙关锁,请选择manual_lock功能点;远程开锁请选择remote_no_dp_keyble_unlock_check功能点。功能点详细说明请参考蓝牙门锁 DP

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

进入IoT 平台,点击左侧产品菜单,产品开发,创建产品,选择标准类目 -> 智能锁 -> 家用门锁PRO -> 产品方案 -> 通讯协议选择蓝牙。:

选择功能点,这里我们只需要默认的标准功能即可。

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

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

详细的操作路径可参考 面板小程序 - 创建面板小程序

IDE 基于模板创建项目工程

这部分我们在 Tuya MiniApp Tools 上进行操作,打开 IDE 创建一个基于门锁蓝牙模板的面板小程序项目。

详细的操作路径可参考 面板小程序 - 初始化项目工程

上面的步骤我们已经初始化好了一个面板小程序的开发模板,下面我们介绍下工程目录。

├── src
│  	├── api // 云端API
│  	│   ├── base.ts // 基类API汇集
│  	│   ├── bleApi.ts // 蓝牙相关API
│  	│   ├── getCachedLaunchOptions.ts // 获取启动参数
│  	│   ├── getCachedSystemInfo.ts // 获取设备信息
│  	│   ├── homeApi.ts // 首页相关API
│  	│   ├── interface.ts // API接口定义
│  	│   ├── logsApi.ts // 日志相关API
│  	│   ├── setting.ts // 设置相关API
│  	│   └── index.ts
│  	├── components
│  	│   ├── DataRangePicker // 日期范围选择组件
│  	│   ├── DateTimePicker // 日期时间选择组件
│  	│   ├── DefendTime // 布防组件
│  	│   ├── Empty // 空状态组件
│  	│   ├── FamilyIcon // 家庭图标组件
│  	│   ├── LatestLog // 最近一条日志组件
│  	│   ├── OfflinePwdFooter // 离线密码底部组件
│  	│   ├── PanelList // 列表组件
│  	│   ├── PasswordIcon // 密码图标组件
│  	│   ├── PasswordNameInput // 密码名称输入组件
│  	│   ├── PasswordPanel // 密码组件
│  	│   ├── RandomPassword // 随机密码组件
│  	│   ├── RecordItem // 记录组件
│  	│   ├── RemoteOpenDoor // 远程开门组件
│  	│   ├── RotateImage // 旋转图片组件
│  	│   ├── StatusBar // 门锁状态栏
│  	│   ├── connect.tsx
│  	│   └── index.ts
│  	├── config // 常用配置
│  	├── constant // 放置常用变量
│  	├── i18n // 多语言
│  	├── hooks // 常用hooks
│  	├── models // redux
│  	│   ├── combine.ts // combineReducers
│  	│   ├── configureStore.ts
│  	│   ├── index.ts
│  	│   ├── store.ts
│  	│   └── modules
│  	│       ├── common.ts // 基础 action 及 reducer
│  	├── pages
│   │   ├── home // 首页
│   │   ├── log // 日志
│   │   ├── family // 成员管理
│   │   ├── family-user-detail // 成员详情
│   │   ├── family-add-unlock // 添加开锁
│   │   ├── family-edit-unlock // 编辑开锁
│   │   ├── setting // 设置
│   │   ├── temp // 临时密码
│   │   ├── temp-record // 临时密码列表
│   │   ├── temp-invalid-record // 临时密码失效列表
│   │   ├── temp-emptyCode // 清空码
│   │   ├── temp-update // 更新自定义密码
│   ├── res // 本地图片
│   ├── utils // 常用工具方法
│   ├── app.config.ts
│   ├── app.tsx
│   ├── composeLayout.tsx // 处理监听子设备添加,解绑,DP变化等
│   ├── global.config.ts
│   ├── routes.config.ts // 配置路由
  1. src/pages/home/index.tsx 文件:
import { hooks } from "@ray-js/panel-sdk";
import { connectBLEDevice } from "@/utils/ble";

const { useDeviceOnline } = hooks;

const Home = () => {
  const { deviceOnline } = useDeviceOnline();

  useEffect(() => {

    // 当设备离线时,主动连接蓝牙设备

    if (!deviceOnline) {
      connectBLEDevice();
    }
  }, [deviceOnline]);

  return (
    <View>
      <Text>Home</Text>
    </View>
  );
};

export default Home;
  1. src/utils/ble.ts 文件:
import { showLoading, hideLoading, showToast } from "@ray-js/ray";
import Strings from "@/i18n";
import { getCachedSystemInfo } from "@/api/getCachedSystemInfo";
import { getCachedLaunchOptions } from "@/api/getCachedLaunchOptions";

const devId = getCachedLaunchOptions()?.query?.deviceId;
const systemInfo = getCachedSystemInfo();

/**
 * 连接蓝牙设备
 * @param {number} [timeout=10000] - 超时时间
 */
const connectBLEDevice = (timeout = 10000) => {

  // 检查蓝牙是否开启

  ty.device.bluetoothIsPowerOn({
    success: (res) => {
      if (!res) {

        // 安卓设备打开蓝牙设置

        if (systemInfo.platform === "android") {
          ty.openSystemBluetoothSetting({
            success: () => {
              showLoading({ title: Strings.getLang("connectBLEDevice") });
              startConnectBLEDevice(timeout);
            },
            fail: (res) => {
              console.log("openBluetoothAdapter fail", res);
            },
          });
        } else {
          showToast({ title: Strings.getLang("openPhone"), icon: "error" });
        }
      } else {
        showLoading({ title: Strings.getLang("connectBLEDevice") });
        startConnectBLEDevice(timeout);
      }
    },
    fail: (res) => {
      showToast({ title: Strings.getLang("connectFail"), icon: "error" });
      hideLoading();
    },
  });
};

/**
 * 开始连接蓝牙设备
 * @param {number} [timeout=10000] - 超时时间
 */
const startConnectBLEDevice = (timeout = 10000) => {
  console.log("主动连接蓝牙设备");
  ty.device.connectBluetoothDevice({
    devId: devId,
    timeoutMillis: timeout,
    souceType: 1,
    connectType: 1,
    success: () => {
      showToast({ title: Strings.getLang("openSuccess"), icon: "success" });
    },
    fail: (res) => {
      showToast({ title: Strings.getLang("connectFail"), icon: "error" });
    },
  });
};

export { connectBLEDevice };

在代码路径 src/components/RemoteOpenDoor/index.tsx中,通过实时获取 lock_motor_state 功能点状态,当长按 UI 按钮时,根据功能点状态进行开、关门操作。

  1. 首先导入依赖和配置
import { useMemo, useState, useRef, useEffect } from 'react';

// 导入UI组件和工具函数

import { Text, View, Image, router, showToast, hideToast, onDpDataChange, offDpDataChange, showModal } from '@ray-js/ray';
import { utils } from '@ray-js/panel-sdk';
import { useStore } from 'react-redux';
import { RotateImage } from '@/components';

// 导入资源文件和国际化字符串

import Res from '@/res';
import Strings from '@/i18n';

// 导入工具函数和API方法

import { formatDpChange, putDpData, connectBLEDevice } from '@/utils';
import { dpCodes, globalConfig } from '@/config';
import { remoteOpen as remoteOpenApi } from '@/api/homeApi';

// 导入自定义钩子

import { usePanelStore, useDeviceOnline, useDevInfo, useDpPermission } from '@/hooks';

// 导入样式文件

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

// 从配置文件中获取设备属性代码

const { remoteNoDpKey, remoteUnlockCheck, manualLock } = dpCodes;

// 定义门状态枚举

enum DoorStatus {
  open = 'open',
  close = 'close',
  offline = 'offline'
}
  1. 门锁的状态和颜色配置
// 定义不同门状态下的UI展示信息

const statusInfo = {
  open: { bg: Res.showOpenImage, iconBg: Res.openImage, text: Strings.getLang('close'), color: '#F67352' },
  close: { bg: Res.showImage, iconBg: Res.lockImage, text: Strings.getLang('clickOpen'), color: '#5EAAFF' },
  offline: { bg: Res.outLineImage, iconBg: '', text: Strings.getLang('off'), color: '#A2A2A2' }
};
  1. 组件定义与状态管理
// 定义RemoteOpenDoor组件

const RemoteOpenDoor = () => {
  // 使用Redux store

  const store = useStore();

  // 使用自定义钩子获取设备状态和属性

  const { dpState, lockData, deviceProps, user } = usePanelStore();
  const deviceOnline = useDeviceOnline();
  const devInfo = useDevInfo();
  const { dpRemotePdSetkeyCheck, dpUnlockRecordCheck } = useDpPermission();
  const { remoteSwitch, bleOnlineState } = deviceProps;
  const { lock_motor_state } = dpState;
  const { isGW = false } = devInfo || {};

  // 定义组件内部状态

  const [loading, setLoading] = useState(false);
  const timer = useRef(null);
  const loadRef = useRef(loading);
  const themeColor = globalConfig.getConfig('themeColor') as string;

  // 使用useMemo计算门的状态

  const status = useMemo(() => deviceOnline ? (lock_motor_state ? DoorStatus.open : DoorStatus.close) : DoorStatus.offline, [deviceOnline, lock_motor_state]);

  // 根据门的状态获取显示文本

  const text = useMemo(() => statusInfo[status].text, [status, loading, deviceOnline, lock_motor_state]);
  1. 事件处理和逻辑函数
  // 监听数据点变化

  useEffect(() => {
    onDpDataChange(dpDataChangeHandle);
    return () => offDpDataChange(dpDataChangeHandle);
  }, []);

  // 同步loading状态到ref

  useEffect(() => {
    loadRef.current = loading;
  }, [loading]);

  // 功能点变化处理函数

  const dpDataChangeHandle = data => {
    const { getState } = store;
    const { devInfo: _devInfo } = getState();
    const _dpState = formatDpChange(_devInfo.arrSchema, data.dps);
    if (Object.keys(_dpState).length > 10) return;

    // 处理功能点变化逻辑...
  };

  // 远程开门函数

  const remoteOpen = async () => {
    if (loading) return;
    if (!deviceOnline) {
      showModal({
        title: Strings.getLang('off'), content: Strings.getLang('timeout'), confirmText: Strings.getLang('confirm'), confirmColor: themeColor, showCancel: false,
        success: res => { if (res.confirm) { connectBLEDevice(); } }
      });
      return;
    }
    setLoading(true);

    // 执行远程开门逻辑...
  };

  // 设置超时计时器

  const setTimer = () => {
    timer.current = setTimeout(() => {
      if (loadRef.current) {
        showToast({ title: Strings.getLang('timeout'), icon: 'none' });
      } else {
        hideToast();
      }
      setLoading(false);
    }, 15000);
  };

  // 清除计时器

  const clearTimer = () => {
    timer.current && clearTimeout(timer.current);
  };
  1. 渲染远程开门组件
  // 组件渲染逻辑

  return (
    <View className={styles.container}>
      {status !== DoorStatus.offline && (
        <RotateImage active={loading} rotateImage={statusInfo[status].bg} onLongClick={() => remoteOpen()}>
          <View className={styles.centerView}>
            {!!statusInfo[status].iconBg && <Image src={statusInfo[status].iconBg} className={styles.centerImage} />}
            <Text className={styles.lockText} style={{ color: statusInfo[status].color}}>{text}</Text>
          </View>
        </RotateImage>
      )}
      {status === DoorStatus.offline && (
        <View className={styles.outLineWrap}>
          <Image src={statusInfo[status].bg} className={styles.outLineBg} />
          <Text className={styles.outLineText} style={{ color: statusInfo[status].color }}>{text}</Text>
        </View>
      )}
    </View>
  );
};

export default RemoteOpenDoor;

以上代码中的中文注释提供了对每个部分的详细解释,帮助开发者或其他阅读者更好地理解代码的结构和功能。

引入了代码之后,我们的首页有了一个可长按点击的圆形按钮,这时我们可以修改 IDE 右侧控制面板 -> lock_motor_state -> false/true 进行虚拟面板 dp 点的下发与上报,查看 IDE 控制面板的状态来验证我们的产品功能

在代码路径src/pages/family-user-detail/index.tsx中新增、编辑、删除开锁方式代码实现。

  1. 给成员添加包括指纹、密码、卡片、指静脉、人脸解锁方式的具体代码:
const addUnlock = (dpCode) => {
  // 普通用户无权限新增
  if (ordinaryUser) {
    showToast({ title: Strings.getLang('hasNoPermissionAdd'), icon: 'none' });
    return;
  }

  // 设备离线时尝试连接蓝牙
  if (!deviceOnline) {
    connectBLEDevice();
    return;
  }

  // 组装 DP 数据
  const type = getModeType[dpCode];
  const phase = '00'; // 录入开始阶段
  const adminType = getUserType[userType];
  const _userId = utils.numToHexString(lockUserId || userId); // 用户ID
  const hardwareNumber = 'ff'; // 硬件编号
  const time = timeScheduleInfo.userTimeSet; // 时效性
  const dpData = `${type}${phase}${adminType}${_userId}${hardwareNumber}${time}000000`;

  putDpDatas.current = dpData;

  // 密码直接进入录入页面
  if (dpCode === unlockPassword) {
    router.push(`/family-add-unlock?dpCode=${dpCode}&userId=${userId}&putDpDatas=${dpData}`);
    return;
  }

  // 发送 DP 数据以创建解锁方式
  showToast({ title: Strings.getLang('connecting'), icon: 'none' });
  putDpData(unlockMethodCreate, dpData);
};
  1. src/pages/family-user-detail/component/RecordItem/index.tsx 删除、修改成员解锁方式实现逻辑。
import { FC, useState } from 'react';
import { View, useQuery, Text, Input, showLoading, hideLoading, router, showToast } from '@ray-js/ray';
import { useRequest } from 'ahooks';
import { usePageEvent } from 'ray';
import { globalConfig } from '@/config';
import { Api } from '@/api';

const Index: FC = () => {
  const [detailInfo, setDetailInfo] = useState({ unlockName: '' });
  const [name, setName] = useState('');
  const query = useQuery();

  // 页面展示时触发数据请求
  usePageEvent('onShow', () => run());

  // 获取解锁详情请求
  const { run } = useRequest(() => Api.apis.fetchUnlockDetail(query.opModeId), {
    onBefore: () => showLoading({ title: '', mask: true }),
    onSuccess: (data) => {
      setDetailInfo(data);
      hideLoading();
    },
    onError: hideLoading,
    manual: true,
    ready: query && query.opModeId,
  });

  // 更新解锁信息请求
  const { run: update } = useRequest(Api.apis.updateUnlock, {
    onBefore: () => showLoading({ title: Strings.getLang('changing'), mask: true }),
    onSuccess: () => {
      hideLoading();
      showToast({ title: Strings.getLang('Password_form_modifySuccess'), icon: 'success' });
      router.back();
    },
    onError: () => {
      hideLoading();
      showToast({ title: Strings.getLang('Password_form_modifyError'), icon: 'fail' });
    },
    manual: true,
  });

  // 删除解锁信息请求
  const { run: deleteUnlock } = useRequest(Api.apis.deleteUnlock, {
    onBefore: () => showLoading({ title: Strings.getLang('deleting'), mask: true }),
    onSuccess: () => {
      hideLoading();
      showToast({ title: Strings.getLang('Password_delete_success'), icon: 'success' });
      router.back();
    },
    onError: () => {
      hideLoading();
      showToast({ title: Strings.getLang('Password_delete_failure'), icon: 'fail' });
    },
    manual: true,
  });
};

export default Index;

src/pages/log/index.tsx中展示门锁的开门、关门、告警、操作记录,支持按时间筛选、按类型筛选。

  1. 工具函数和配置导入
import React, { FC, useState, useReducer } from 'react';
import { useCreation, useRequest } from 'ahooks';
import { View, Text, ScrollView, showLoading, hideLoading } from '@ray-js/ray';
import List from '@ray-js/components-ty-cell';
import Icon from '@ray-js/components/lib/Icon';
import ActionSheet from '@ray-js/components-ty-actionsheet';
import _uniq from 'lodash/uniq';
import { globalConfig } from '@/config';
import Empty from '@/components/Empty';
import { getLogs } from '@/api/logsApi';
import { formatLog, logCategoryList, timeList, getSelectTime } from '@/utils';
import Strings from '@/i18n';
import styles from './index.module.less';
  1. 组件定义和状态管理
const Index: FC = () => {
  // 状态管理
  const [category, setCategory] = useState(''); // 当前选择的日志类别
  const [tempCategory, setTempCategory] = useState(''); // 临时选择的日志类别
  const [time, setTime] = useState('time'); // 当前选择的时间范围
  const [tempTime, setTempTime] = useState('time'); // 临时选择的时间范围
  const [, forceUpdate] = useReducer(x => x + 1, 0); // 强制更新组件
  const themeColor = globalConfig.getConfig('themeColor') as string; // 主题颜色

  // 引用管理
  const Ref = useCreation(() => ({
    lastRowKey: '', // 上次请求的最后一行的key
    hasMore: false, // 是否有更多数据
    logsData: [] // 日志数据
  }), []);
  1. 日志请求逻辑
  // 获取日志数据的请求逻辑
  const { run } = useRequest(() => getLogs({
    logCategories: category,
    startTime: getSelectTime(time)[1],
    endTime: getSelectTime(time)[0],
    lastRowKey: Ref.lastRowKey,
  }), {
    onBefore: () => showLoading({ title: '', mask: true }), // 请求前显示加载中
    onSuccess(data) {
      Ref.lastRowKey = data.lastRowKey;
      Ref.hasMore = data.hasMore;
      Ref.logsData = _uniq([...Ref.logsData, ...data.records]); // 合并去重日志数据
      forceUpdate();
      hideLoading(); // 请求成功后隐藏加载中
    },
    onError: hideLoading, // 请求失败后隐藏加载中
    refreshDeps: [category, time], // 依赖项变化时重新请求
  });

  // 滚动到底部时触发加载更多数据
  const onScrollToLower = () => {
    if (Ref.hasMore) run();
  };
  1. 类别选择模态框逻辑,时间选择模态框逻辑相似,不一一列举描述
  // 类别选择模态框逻辑
  const { modal: categoryModal, setVisible: setShowCategory, visible: showCategory } = ActionSheet.useActionSheet({
    header: Strings.getLang('pleaseSelect'),
    onOk: () => {
      Ref.lastRowKey = '';
      Ref.logsData = [];
      setCategory(tempCategory);
      setShowCategory(false);
    },
    onCancel: () => setShowCategory(false),
    okText: Strings.getLang('confirm'),
    cancelText: Strings.getLang('cancel'),
    onClickOverlay: () => setShowCategory(false),
    content: () => (
      <ScrollView scrollY className={styles.modalWarp}>
        <List.Row
          dataSource={logCategoryList}
          rowKey="key"
          renderItem={item => (
            <List.Item
              className={styles.listItem}
              title={item.title}
              onClick={() => setTempCategory(item.key)}
              content={item.key === tempCategory ? <Icon type="icon-checkmark" size={24} color={themeColor} /> : null}
            />
          )}
          splitStyle={{ width: '100%', left: 0 }}
        />
      </ScrollView>
    ),
  });
  1. 页面渲染逻辑
  return (
    <>
      <View className={styles.container}>
        <View className={styles.tabItem} onClick={() => setShowCategory(!showCategory)}>
          <Text>{logCategoryList.find(item => item.key === category)?.title || Strings.getLang('allCategories')}</Text>
          <Icon type="icon-down" size={24} color="#999" />
        </View>
        <View className={styles.tabItem} onClick={() => setShowTime(!showTime)}>
          <Text>{timeList.find(item => item.key === time)?.title || Strings.getLang('allTimes')}</Text>
          <Icon type="icon-down" size={24} color="#999" />
        </View>
      </View>
      {categoryModal}
      {timeModal}
      <ScrollView scrollY className={styles.warp} onScrollToLower={onScrollToLower}>
        {Ref.logsData.length > 0 ? (
          Ref.logsData.map(item => {
            const { formatTime, formatUserName, openText } = formatLog(item);
            return (
              <View key={item.logId} className={styles.item}>
                <Text>{`${formatTime} ${formatUserName ? `【${formatUserName}】` : ''} ${openText}`}</Text>
              </View>
            );
          })
        ) : (
          <Empty />
        )}
      </ScrollView>
    </>
  );
};

export default Index;

因为整体临时密码代码量颇多,仅展示逻辑最复杂部分,在 src/api/bleApi.ts中展示临时密码中的自定义密码的新增、删除、修改功能。

  1. 导入依赖和工具函数
import { utils } from '@ray-js/panel-sdk';
import {
  showToast,
  onDpDataChange,
  offDpDataChange,
  deleteTemporaryPassword,
  createTemporaryPassword,
  updateTempPassword,
} from '@ray-js/ray';
import { padStart } from 'lodash';
import Strings from '@/i18n';
import { store } from '@/redux';
import { putDpData } from '@/utils/sendDp';
import { connectBLEDevice } from '@/utils/ble';
import { ApiCreatorBase } from './base';
import { getCachedLaunchOptions } from './getCachedLaunchOptions';
import {
  ICreatePasswordParams,
  IDeleteParams,
  IUpdateParams,
  IScheduleType,
  ICreatePasswordResult,
} from './interface';

const { numToHexString } = utils;
  1. 蓝牙临时密码相关 API 类
/** 蓝牙公版接口 */
export default class BlueToothAPi extends ApiCreatorBase {

  /**
   * 创建临时密码
   * @param params 创建密码的参数
   * @returns 创建成功的结果
   */
  createPwd<T = ICreatePasswordParams, R = ICreatePasswordResult>(params: T): Promise<R> {
    return this.listenCreateDp(params)
      .then((res: R) => {
        offDpDataChange(() => {});
        return res;
      });
  }

  /**
   * 更新临时密码
   * @param params 更新密码的参数
   * @returns 更新是否成功
   */
  updatePwd(params: IUpdateParams): Promise<boolean> {
    return this.listenUpdateDp(params)
      .then(() => {
        offDpDataChange(() => {});
        return true;
      });
  }

  /**
   * 删除临时密码 API 调用
   * @param params 删除密码的参数
   * @returns 删除是否成功
   */
  deletePwdApi(params: Omit<IDeleteParams, 'deviceOnline'>): Promise<boolean> {
    const devId = getCachedLaunchOptions()?.query?.deviceId;
    return deleteTemporaryPassword({
      devId,
      unlockBindingId: params.id,
    });
  }

  /**
   * 删除临时密码,先判断设备是否在线
   * @param params 删除密码的参数
   * @returns 删除是否成功
   */
  deletePwd({ deviceOnline, ...restParams }: IDeleteParams): Promise<boolean> {
    if (!deviceOnline) {
      connectBLEDevice();
      return Promise.reject({ message: Strings.getLang('off') });
    }

    if (restParams.pwdType === 0) {
      putDpData('temporary_password_delete', numToHexString(restParams.sn as number, 2));
      return this.listenDeleteDp(restParams.id as number).then(() => true);
    }

    return this.checkAndDeletePwd(restParams);
  }
  1. 监听 DP 数据变化的方法
  /**
   * 监听删除临时密码的 DP 数据变化
   * @param deleteId 删除密码的 ID
   * @returns 删除是否成功的 Promise
   */
  listenDeleteDp = (deleteId: number) => {
    return new Promise((resolve, reject) => {
      onDpDataChange(data => {
        const { devInfo } = store.getState();
        const { schema } = devInfo;
        const dpId = schema['temporary_password_delete'].id;
        const temPwdDelete = data.dps[dpId] as string;
        if (temPwdDelete) {
          const status = temPwdDelete.slice(2, 4); // 返回阶段

          if (status === '00') {
            this.deletePwdApi({ id: deleteId })
              .then(resolve)
              .catch(reject);
            showToast({ title: Strings.getLang('Password_delete_success'), icon: 'success' });
          } else {
            reject();
            showToast({ title: Strings.getLang('Password_delete_failure'), icon: 'error' });
          }
        }
      });
    });
  };

  /**
   * 监听创建临时密码的 DP 数据变化
   * @param params 创建密码的参数
   * @returns 创建成功的结果
   */
  listenCreateDp = async <R = any, T = ICreatePasswordResult>(params: any) => {
    const scheduleInfo = {
      beginTime: params.effectiveTime,
      endTime: params.invalidTime,
      allDay: true,
      effectiveTime: 0,
      invalidTime: 0,
      workingDay: '',
    };
    Object.assign(scheduleInfo, params.schedule && JSON.parse(params.schedule)[0]);
    const dpData = this.getCreatePwdDpData(scheduleInfo, params.originalPassword);
    putDpData('temporary_password_creat', dpData);
    return new Promise((resolve, reject) => {
      onDpDataChange(data => {
        const { devInfo } = store.getState();
        const { schema } = devInfo;
        const dpId = schema['temporary_password_creat'].id;
        const temPwdCreate = data.dps[dpId] as string;
        if (temPwdCreate) {
          const devId = getCachedLaunchOptions()?.query?.deviceId;
          const unLockId = parseInt(temPwdCreate.slice(0, 2), 16);
          const status = temPwdCreate.slice(2, 4); // 返回阶段
          if (status === '00') {
            resolve(
              createTemporaryPassword({
                devId,
                symbolic: true, // 是否仅保存静态数据
                dpTunnel: 2, // symbolic 为 false 时,必填 1. app 2. 云端
                availTime: 0,
                sn: unLockId,
                ...params,
              })
            );
          } else {
            reject({ message: Strings.getLang(`Password_add_error_${status}` as any) });
          }
        }
      });
    });
  };

  /**
   * 监听更新临时密码的 DP 数据变化
   * @param params 更新密码的参数
   * @returns 更新是否成功的 Promise
   */
  listenUpdateDp = async (params: IUpdateParams) => {
    const scheduleInfo = {
      beginTime: params.effectiveTime,
      endTime: params.invalidTime,
      allDay: true,
      effectiveTime: 0,
      invalidTime: 0,
      workingDay: '',
      sn: params.sn,
    };
    Object.assign(scheduleInfo, params.schedule && JSON.parse(params.schedule)[0]);
    const dpData = this.getUpdatePwdDpData(scheduleInfo);
    putDpData('temporary_password_modify', dpData);

    return new Promise((resolve, reject) => {
      onDpDataChange(data => {
        const { devInfo } = store.getState();
        const { schema } = devInfo;
        const dpId = schema['temporary_password_modify'].id;
        const temPwdUpdate = data.dps[dpId] as string;
        if (temPwdUpdate) {
          const status = temPwdUpdate.slice(2, 4); // 返回阶段

          if (status === '00') {
            const updateData = { unlockBindingId: params.id, ...params };
            delete updateData.id;
            resolve(updateTempPassword(updateData));
          } else {
            reject({ message: Strings.getLang('Password_form_modifyError') });
          }
        }
      });
    });
  };
  1. 生成 DP 数据的方法
  /**
   * 获取更新临时密码的 DP 数据
   * @param params 调度信息
   * @returns 更新临时密码的 DP 数据
   */
  getUpdatePwdDpData = (params: IScheduleType): string => {
    const type = '01';
    const count = '00';
    const pswLen = '00'; // 修改临时密码,密码长度直接设置为 0
    const { allDay = true, beginTime, endTime, effectiveTime, invalidTime, workingDay } = params;

    let time = '';
    const unLockId = padStart(params.sn?.toString(16), 2, '0');
    const beginTime2Hex = parseInt(String(beginTime)).toString(16);
    const endTime2Hex = parseInt(String(endTime)).toString(16);
    time += `${beginTime2Hex}${endTime2Hex}`;

    if (allDay) {
      time += '000000000000000000';
    } else {
      time += `02000000${padStart(parseInt(workingDay).toString(16), 2, '0')}`;
      time += `${padStart(parseInt(`${effectiveTime / 60}`, 10).toString(16), 2, '0')}${padStart(
        parseInt(`${+effectiveTime % 60}`, 10).toString(16),
        2,
        '0'
      )}`;
      time += `${padStart(parseInt(`${+invalidTime / 60}`, 10).toString(16), 2, '0')}${padStart(
        parseInt(`${+invalidTime % 60}`, 10).toString(16),
        2,
        '0'
      )}`;
    }
    return `${unLockId}${type}${time}${count}${pswLen}`;
  };

  /**
   * 获取创建临时密码的 DP 数据
   * @param params 调度信息
   * @param originalPassword 原始密码
   * @returns 创建临时密码的 DP 数据
   */
  getCreatePwdDpData = (params: IScheduleType, originalPassword: string): string => {
    const type = '01';
    const count = '00';
    const pswLen = padStart(originalPassword.length.toString(16), 2, '0');
    const resPassword = [...originalPassword].map(i => padStart(Number(i).toString(16), 2, '0')).join('');
    const { allDay = true, beginTime, endTime, effectiveTime, invalidTime, workingDay } = params;
    let time = '';
    const beginTime2Hex = parseInt(String(beginTime)).toString(16);
    const endTime2Hex = parseInt(String(endTime)).toString(16);
    time += `${beginTime2Hex}${endTime2Hex}`;

    if (allDay) {
      time += '000000000000000000';
    } else {
      time += `02000000${padStart(parseInt(workingDay).toString(16), 2, '0')}`;
      time += `${padStart(parseInt(`${effectiveTime / 60}`, 10).toString(16), 2, '0')}${padStart(
        parseInt(`${+effectiveTime % 60}`, 10).toString(16),
        2,
        '0'
      )}`;
      time += `${padStart(parseInt(`${+invalidTime / 60}`, 10).toString(16), 2, '0')}${padStart(
        parseInt(`${+invalidTime % 60}`, 10).toString(16),
        2,
        '0'
      )}`;
    }
    return `${type}${time}${count}${pswLen}${resPassword}`;
  };
}

src/pages/setting/index.tsx中展示设备的设置项,包括远程开锁开关、自动落锁、反锁、语音提示、离家布防、忽扰等功能。

  1. 导入依赖和工具函数
import React, { FC, useState } from 'react';
import { useCreation } from 'ahooks';
import { DpSchema, utils } from '@ray-js/panel-sdk';
import { View, Text, ScrollView, Slider } from '@ray-js/ray';

import { globalConfig, Code } from '@/config';
import { putDpData } from '@/utils/sendDp';
import { connectBLEDevice } from '@/utils/ble';
import { saveDeviceProps } from '@/redux/actions';
import { getSettingDpSchema } from '@/utils/getSettingDpSchema';
import { usePanelStore, useDevInfo, useDeviceOnline, useUserPermission } from '@/hooks';
import { uploadSettingRecord, setDeviceProps } from '@/api/settingApi';

import Icon from '@ray-js/components/lib/Icon';
import List from '@ray-js/components-ty-cell';
import Switch from '@ray-js/components-ty-switch';
import ActionSheet from '@ray-js/components-ty-actionsheet';

import Strings from '@/i18n';
import Codes from '@/config/dpCodes';
import styles from './index.module.less';
  1. 组件定义和状态管理
const Index: FC = () => {

  // 状态管理

  const [dpCode, setDpCode] = useState('');
  const [enumValue, setEnumValue] = useState('');
  const [numberValue, setNumberValue] = useState(0);

  const { dpState, deviceProps } = usePanelStore();
  const { isOwnerOrAdmin } = useUserPermission();
  const { schema } = useDevInfo();
  const deviceOnline = useDeviceOnline();
  const { remoteSwitch } = deviceProps;

  const themeColor = globalConfig.getConfig('themeColor') as string;

  // 获取设置的 DP Schema

  const settingsSchema = useCreation(() => {
    return getSettingDpSchema(
      schema as DpSchema[],
      isOwnerOrAdmin ? Code.logTypeFormDpCodes : Code.noAdminLogTypeFormDpCodes
    );
  }, [schema]);

  // 获取枚举类型的 DP 数据源

  const enumDpDataSource = useCreation(() => {
    return settingsSchema
      .find(d => d.code === dpCode)
      ?.property?.range?.map((rangeValue: string) => ({
        label: Strings.getDpLang(dpCode, rangeValue),
        value: rangeValue,
        checked: rangeValue === enumValue,
      }));
  }, [dpCode, enumValue]);

  // 获取数值类型的 DP 数据

  const numberDpData = useCreation(() => {
    return settingsSchema.find(d => d.code === dpCode)?.property;
  }, [dpCode]);
  1. 枚举类型模态框逻辑,数值类型模态框逻辑类似不一一列举
  // 枚举类型的模态框逻辑

  const {
    modal: enumModal,
    setVisible: setShowEnum,
    visible: showEnum,
  } = ActionSheet.useActionSheet({
    header: Strings.getLang('pleaseSelect'),
    onOk: () => {
      putDpData(dpCode, enumValue);
      setShowEnum(false);
      uploadSettingRecord(dpCode, [enumValue]);
    },
    onCancel: () => setShowEnum(false),
    onClickOverlay: () => setShowEnum(false),
    okText: Strings.getLang('confirm'),
    cancelText: Strings.getLang('cancel'),
    content: () => (
      <ScrollView scrollY className={styles.listWarp}>
        <List.Row
          className={styles.list}
          dataSource={enumDpDataSource}
          rowKey="label"
          renderItem={item => (
            <List.Item
              className={styles.listItem}
              title={item.label}
              onClick={() => setEnumValue(item.value)}
              content={item.checked ? <Icon type="icon-checkmark" size={24} color={themeColor} /> : null}
            />
          )}
          splitStyle={{ width: '100%', left: 0 }}
        />
      </ScrollView>
    ),
  });
  1. 页面渲染逻辑
  return (
    <ScrollView scrollY className={styles.warp}>
      <List>
        {/* 远程开门开关 */}
        <List.Item
          title={Strings.getDpLang(Codes.remoteNoDpKey)}
          content={
            <Switch
              style={{ marginRight: 0 }}
              checkedColor={themeColor}
              checked={remoteSwitch}
              onChange={value => {
                setDeviceProps(JSON.stringify({ UNLOCK_PHONE_REMOTE: value }));
                uploadSettingRecord('remote_unlock_setting', [value.toString()]);
                saveDeviceProps({ remoteSwitch: value });
              }}
            />
          }
        />
        {/* 渲染 DP 项 */}
        {settingsSchema.map(item => {
          let content = <></>;
          const dpMode = item.mode;
          const dpType = item.property?.type;
          const dpUnit = item.property?.unit ?? '';
          const dpScale = item.property?.scale ?? 0;

          switch (dpType) {
            case 'bool':
              content = (
                <Switch
                  style={{ marginRight: 0 }}
                  checkedColor={themeColor}
                  checked={!!dpState[item.code]}
                  onChange={value => {
                    if (!deviceOnline) {
                      connectBLEDevice();
                      return;
                    }
                    putDpData(item.code, value);
                  }}
                />
              );
              break;
            case 'enum':
              content = (
                <View className={styles.valueView}>
                  <Text className={styles.text}>{Strings.getDpLang(item.code, dpState[item.code])}</Text>
                  <Icon type="icon-right" size={24} color={'rgba(0,0,0,0.2)'} />
                </View>
              );
              break;
            case 'value':
              content = (
                <View className={styles.valueView}>
                  <Text className={styles.text}>{`${utils.scaleNumber(dpScale, dpState[item.code])}${dpUnit}`}</Text>
                  <Icon type="icon-right" size={24} color={'rgba(0,0,0,0.2)'} />
                </View>
              );
              break;
            default:
              break;
          }

          return (
            <List.Item
              key={item.code}
              title={Strings.getDpLang(item.code)}
              content={content}
              onClick={() => {
                if (dpMode === 'ro') return;
                if (!['enum', 'value'].includes(dpType)) return;
                if (!deviceOnline) {
                  connectBLEDevice();
                  return;
                }
                setDpCode(item.code);
                if (dpType === 'enum') {
                  setEnumValue(dpState[item.code]);
                  setShowEnum(true);
                } else if (dpType === 'value') {
                  setNumberValue(dpState[item.code]);
                  setShowValue(true);
                }
              }}
            />
          );
        })}
      </List>
      {enumModal}
      {valueModal}
    </ScrollView>
  );
};

export default Index;