DIY 场景编辑功能

适用范围:Wi-Fi + BLE 面板

🖼️ 预览:

DIY 场景编辑功能模块允许用户创建和编辑自定义灯光场景,支持两种编辑模式:动态场景编辑(基于颜色和动效参数)和静态场景编辑(基于灯珠涂抹)。该模块提供了完整的场景创建、编辑、预览、保存和管理功能,是用户个性化灯光效果的核心工具。

功能列表

  1. 动态 DIY 场景编辑:通过颜色列表和动效参数创建动态场景
  2. 静态 DIY 场景编辑:通过涂抹方式精确控制每个灯珠的颜色
  3. 场景预览:实时预览场景效果
  4. 场景保存:将场景保存到云存储
  5. 场景加载:从云存储加载已有场景进行编辑
  6. 场景删除:删除不需要的场景
  7. 场景列表管理:查看和管理所有 DIY 场景

功能实现详解

动态 DIY 场景编辑

动态 DIY 场景编辑使用SceneData数据结构,包含颜色列表和动效参数。编辑页面提供颜色管理和参数调整功能,场景数据通过rgbic_linerlight_scene DP 点下发。

核心组件
  • src/pages/diyEdit/index.tsx: 动态 DIY 场景编辑页面
  • src/components/colors/index.tsx: 颜色列表组件
  • src/components/motion-config/index.tsx: 动效参数配置组件
  • src/hooks/useSceneSet.ts: 场景设置 Hook
数据流
flowchart TD
    A[进入编辑页面] --> B[加载场景数据]
    B --> C{是否编辑模式}
    C -->|是| D[从云存储加载]
    C -->|否| E[使用默认数据]
    D --> F[显示颜色列表]
    E --> F
    F --> G[用户添加/删除颜色]
    G --> H[选择动效类型]
    H --> I[调整动效参数]
    I --> J[预览场景]
    J --> K[保存场景]
    K --> L[编码场景数据]
    L --> M[保存到云存储]
    M --> N[下发DP到设备]
关键代码片段

场景保存实现:

  // 参考:src/pages/diyEdit/index.tsx
  const handleSave = async (sceneName: string) => {
    const params = {
      ...DEFAULTOPTIONS,
      ...values,
      key: 100,
      name: sceneName || '',
      changeType: current,
      colors: getArray(diyEditColors).map(color => ({
        hue: color.h,
        saturation: Math.floor(color.s / 10),
        value: Math.floor(color.v / 10),
        brightness: 0,
        temperature: 0,
      })),
      dataType: 0,
    } as SceneData;

    if (isDataId(dataId)) {
      await storage.updateItem(dataId, encodeCloudSceneData(params));
      sceneApi.set(params);
      Dispatcher.instance.dispatch('diyCreated', dataId);
    } else {
      showLoading({
        title: '',
        mask: true,
      });
      const newDataId = await storage.addItem(encodeCloudSceneData(params), {
        dedup: (a, b) => {
          try {
            return JSON.parse(a).name === JSON.parse(b).name;
          } catch (error) {
            return false;
          }
        },
      });
      hideLoading();
      if (typeof newDataId === 'number') {
        sceneApi.set(params);
        Dispatcher.instance.dispatch('diyCreated', newDataId);
      } else {
        TipApi.show({
          show: true,
          content: (
            <View className={styles.tipWrap}>
              <Image src={res.icon_warn} className={styles.tipIcon} />
              <View className={styles.tipText}>{Strings.getLang('name_dedup')}</View>
            </View>
          ),
        });
        return -1;
      }
    }
  };
数据结构

场景数据格式:

type SceneData = {
  dataType: number; // 0: 动态场景
  name: string; // 场景名称
  key: number; // 场景键值
  changeType: number; // 动效类型
  colors: Array<{
    // 颜色列表
    hue: number;
    saturation: number;
    value: number;
    brightness: number;
    temperature: number;
  }>;
  // 其他动效参数...
};

静态 DIY 场景编辑

静态 DIY 场景编辑使用DiySceneData数据结构,包含每个灯珠的颜色信息。通过涂抹方式控制每个灯珠,场景数据通过diy_scene DP 点下发。

核心组件
  • src/pages/staticDiyEdit/index.tsx: 静态 DIY 场景编辑页面
  • src/components/light-strip/index.tsx: 灯带可视化组件
  • src/hooks/useDrawToolDataList.ts: 涂抹工具数据管理 Hook
  • src/hooks/useCutDrawDiyScene.ts: 涂抹场景设置 Hook
数据流
sequenceDiagram
    participant User as 用户
    participant Page as 编辑页面
    participant Strip as 灯带组件
    participant Hook as 数据Hook
    participant Protocol as 协议解析器
    participant Device as 设备

    User->>Page: 选择颜色和涂抹模式
    User->>Strip: 选择灯珠
    Strip->>Page: 更新选中状态
    Page->>Hook: 调用更新函数
    Hook->>Hook: 更新灯带数据
    Hook->>Protocol: 编码场景数据
    Protocol->>Device: 下发DP
    Device-->>User: 显示效果
    User->>Page: 保存场景
    Page->>Hook: 保存到云存储
关键代码片段

场景保存实现:

// 参考:src/pages/staticDiyEdit/index.tsx
  const handleSave = async (sceneName: string) => {
    const params: DiySceneData = {
      name: sceneName,
      ...(diy_scene || ({} as DiySceneData)),
      segments: hasChange ? diy_scene?.segments : getArray(getDefault().segments),
    };
    console.log('[handleSave]:', params);
    if (isDataId(dataId)) {
      await storage.updateItem(dataId, encodeCloudSceneData(params));
      cutDrawDiySceneSet(params);
      Dispatcher.instance.dispatch('diyCreated', dataId);
    } else {
      showLoading({ title: '', mask: true });

      const newDataId = await storage.addItem(encodeCloudSceneData(params));
      if (typeof newDataId === 'number') {
        cutDrawDiySceneSet(params);
        Dispatcher.instance.dispatch('diyCreated', newDataId);
        hideLoading();
      } else {
        hideLoading();
        TipApi.show({
          show: true,
          content: (
            <View className={styles.tipWrap}>
              <Image src={res.icon_warn} className={styles.tipIcon} />
              <View className={styles.tipText}>{Strings.getLang('name_dedup')}</View>
            </View>
          ),
        });
        return -1;
      }
    }
  };

涂抹场景设置:

// 参考:src/hooks/useCutDrawDiyScene.ts
export const useCutDrawDiySceneSet = () => {
  const ledNumber = useProps(p => p[dpCodes.led_number_set]);
  const structuredActions = useStructuredActions();

  return (data: DiySceneData, options?: PublishDpsOptions) => {
    const newData = { ...(data || ({} as DiySceneData)) };
    const segments = getArray(newData?.segments);
    if (ledNumber) {
      if (segments.length >= ledNumber) {
        newData.segments = segments.slice(0, ledNumber);
      } else {
        const defaultColor: DiySceneData['segments'][0] = {
          isWhite: false,
          hue: 0,
          saturation: 1000,
          value: 1000,
          brightness: 0,
          temperature: 0,
        };
        newData.segments = segments.concat(
          ...new Array(ledNumber - segments.length).fill(0).map(() => defaultColor)
        );
      }
    }
    structuredActions.diy_scene.set(newData, options);
  };
};
数据结构

DIY 场景数据格式:

type DiySceneData = {
  name?: string;
  version?: number;
  id?: number;
  daubType?: 'all' | 'single' | 'clear'; // 涂抹类型
  effect?: number; // 效果类型
  segments?: Array<{
    // 灯珠数据
    hue?: number;
    saturation?: number;
    value?: number;
    brightness?: number;
    temperature?: number;
    isWhite?: boolean;
  }>;
};

场景预览

场景预览通过useSceneSet Hook 的preview方法实现,将场景数据编码后下发到设备,不保存到云存储。

核心组件
  • src/hooks/useSceneSet.ts: 场景设置 Hook
数据流
flowchart TD
    A[用户点击预览] --> B[构建场景数据]
    B --> C[useSceneSet.preview]
    C --> D[编码场景数据]
    D --> E[下发DP到设备]
    E --> F[设备执行场景]
    F --> G[用户观察效果]
    G --> H{继续编辑?}
    H -->|是| I[返回编辑]
    H -->|否| J[保存场景]
关键代码片段

预览实现:

// 参考:src/hooks/useSceneSet.ts
  const preview = async (scene: SceneData) => {
    if (getArray(scene?.colors).length > 0) {
      await set(scene);
    }
  };

场景保存

场景保存包括数据编码、云存储保存、DP 下发三个步骤。支持新增和更新两种模式,保存成功后触发场景创建事件。

核心组件
  • src/pages/diyEdit/index.tsx: 动态场景保存
  • src/pages/staticDiyEdit/index.tsx: 静态场景保存
  • src/hooks/useCloudStorageCombinedList.ts: 云存储列表管理
  • src/hooks/useCloudDrawToolList.ts: 场景数据编解码
数据流

已在功能 1 和功能 2 中详细说明。

场景加载

场景加载从云存储读取场景数据,解码后填充到编辑页面。根据场景类型(动态或静态)加载到对应的编辑页面。

核心组件
  • src/pages/diyEdit/index.tsx: 动态场景加载
  • src/pages/staticDiyEdit/index.tsx: 静态场景加载
  • src/hooks/useCloudDrawToolList.ts: 场景数据解码
数据流
flowchart TD
    A[用户选择编辑] --> B[获取场景ID]
    B --> C[从云存储读取]
    C --> D[解码场景数据]
    D --> E{场景类型}
    E -->|动态| F[加载到动态编辑页]
    E -->|静态| G[加载到静态编辑页]
    F --> H[填充颜色列表]
    G --> I[填充灯珠数据]
    H --> J[填充动效参数]
    I --> K[显示灯带状态]

场景删除

场景删除从云存储删除场景数据,更新场景列表。删除前需要用户确认。

核心组件
  • src/pages/diyEdit/index.tsx: 删除功能
  • src/pages/staticDiyEdit/index.tsx: 删除功能
  • src/hooks/useCloudStorageCombinedList.ts: 云存储删除
关键代码片段

删除实现(以动态场景为例):

// 参考:src/pages/diyEdit/index.tsx
        onRightClick={() => {
          showConfirmDeleteModal({
            content: Strings.getLang('deleteSceneTip'),
            success(res) {
              if (res.confirm) {
                storage.delItem(dataId);
                router.back();
              }
            },
          });
        }}

场景列表管理

场景列表管理通过useDiySceneInit Hook 实现,从云存储读取所有场景,按类型分类显示,支持场景选择和切换。

核心组件
  • src/components/tab-diy/index.tsx: DIY 场景列表组件
  • src/hooks/useDiySceneInit.ts: DIY 场景初始化 Hook
数据流
flowchart TD
    A[进入DIY标签] --> B[useDiySceneInit初始化]
    B --> C[从云存储读取场景列表]
    C --> D[解码场景数据]
    D --> E[按类型分类]
    E --> F[显示场景卡片]
    F --> G[用户选择场景]
    G --> H{场景类型}
    H -->|动态| I[应用动态场景]
    H -->|静态| J[应用静态场景]
    I --> K[下发场景DP]
    J --> K
    K --> L[更新当前场景ID]
关键代码片段

场景初始化:

// 参考:src/hooks/useDiySceneInit.ts
  const initDiyScene = useCallback(() => {
    if (!inited) return;
    if (!switch_led) return;
    console.log('init diy', current);
    if (current) {
      if (+current !== -1) {
        const item = getArray(storage.list).find(item => +item.id === +current);
        if (item?.value) {
          const props = decodeCloudSceneData(item?.value);
          if (props.type === 'scene') {
            sceneApi.set(props.ret);
            actions.work_mode.set('scene');
            setCurrent(String(item?.id));
          }
          if (props.type === 'draw') {
            cutDrawDiySceneSet(props?.ret);
            setCurrent(String(item?.id));
          }
        }
      } else {
        const item = getArray(storage.list)[0];
        if (item?.value) {
          const props = decodeCloudSceneData(item?.value);
          if (props.type === 'scene') {
            sceneApi.set(props.ret);
            actions.work_mode.set('scene');
            setCurrent(String(item?.id));
          }
          if (props.type === 'draw') {
            cutDrawDiySceneSet(props?.ret);
            setCurrent(String(item?.id));
          }
        }
      }
    }
  }, [current, storage.list, switch_led, inited]);

场景列表生成:

// 参考:src/hooks/useDiySceneInit.ts
  const diySceneList = useMemo(
    () =>
      splitArray(
        getArray(storage.list)
          .sort((a, b) => b.id - a.id)
          .map(item => {
            const props = decodeCloudSceneData(item.value);
            return {
              dataId: item.id,
              data: props?.ret || {},
              cloudType: props.type,
            };
          }),
        2
      ),
    [storage.list]
  );

注意事项

实现难点

  1. 场景数据编解码:

    • 难点:动态场景和静态场景使用不同的数据格式和 DP 点
    • 解决方案:使用encodeCloudSceneData和decodeCloudSceneData统一编解码,通过type字段区分场景类型
  2. 灯珠数据对齐:

    • 难点:静态场景的灯珠数据需要与设备灯珠数量对齐
    • 解决方案:在useCutDrawDiySceneSet中检查数据长度,不足时补全默认颜色,超出时截断
  3. 场景类型区分:

    • 难点:需要区分动态场景(scene)和静态场景(draw)
    • 解决方案:在云存储数据中使用type字段标识,加载时根据类型选择对应的处理逻辑
  4. 场景名称去重:

    • 难点:防止场景名称重复
    • 解决方案:使用dedup函数检查名称,保存时提示用户修改

常见问题

  1. 问题:场景加载后数据不正确

    • 原因:数据解码失败或格式不匹配
    • 解决方法:检查数据格式,增加错误处理,使用默认值兜底
  2. 问题:预览无效果

    • 原因:设备未连接或 DP 下发失败
    • 解决方法:检查设备连接状态,查看 DP 下发日志
  3. 问题:静态场景灯珠数量不匹配

    • 原因:设备灯珠数量变化或数据损坏
    • 解决方法:自动对齐灯珠数量,补全或截断数据

性能优化

  1. 场景列表缓存:

    • 使用useMemo缓存场景列表,避免重复计算
    • 场景列表按 ID 倒序排列,最新场景在前
  2. 数据编码优化:

    • 场景数据编码使用高效的字符串拼接
    • 避免不必要的深拷贝
  3. 懒加载:

    • 场景列表分页显示,避免一次性加载所有场景
    • 场景数据按需解码
  4. 防抖处理:

    • 场景保存使用防抖,避免重复保存
    • 场景预览使用节流,避免频繁下发 DP

边界情况

  1. 场景列表为空:

    • 处理:显示空状态提示,引导用户创建场景
    • 提供默认场景选项
  2. 场景数据损坏:

    • 处理:解码失败时使用默认数据
    • 提示用户场景数据异常
  3. 设备灯珠数量变化:

    • 处理:自动对齐灯珠数量
    • 超出部分截断,不足部分补全默认颜色
  4. 场景名称过长:

    • 处理:限制场景名称长度
    • 超出部分截断或提示用户
  5. 同时编辑多个场景:

    • 处理:同一时间只能编辑一个场景
    • 切换编辑时保存当前场景