Browse Source

feat: 添加组合功能

liaojiaxing 11 months ago
parent
commit
1df3efe887

+ 69 - 6
src/store/modules/action.ts

@@ -4,6 +4,7 @@ import { LayerEnum } from "@/enum/layerEnum";
 import { useProjectStore } from "@/store/modules/project";
 import { cloneDeep } from "lodash";
 import { CustomElement } from "#/project";
+import { uuid } from "@/utils";
 // import { recoverRecord } from "@/utils/recover";
 
 // type RecordItem = {
@@ -53,9 +54,8 @@ export const useAcionStore = defineStore({
       }
 
       this.activeIndex = this.records.length - 1;
-      console.log(this.activeIndex, this.records);
     },
-    // 撤销
+    /* 撤销 */
     actionUndo() {
       if (this.activeIndex <= 0) return;
       --this.activeIndex;
@@ -63,7 +63,7 @@ export const useAcionStore = defineStore({
       this.projectStore.updateProjectInfo(projectInfo);
       this.appKey++;
     },
-    // 重做
+    /* 重做 */
     actionRedo() {
       if (this.activeIndex >= this.records.length - 1) return;
       ++this.activeIndex;
@@ -72,7 +72,7 @@ export const useAcionStore = defineStore({
       this.appKey++;
     },
     actionClear() {},
-    // 对齐
+    /* 对齐 */
     actionAlign(type: AlignEnum) {
       const activeElements = this.projectStore.currentSelectedElements;
       switch (type) {
@@ -161,7 +161,6 @@ export const useAcionStore = defineStore({
           const minY = Math.min(
             ...activeElements.map((item) => item.container.props.y)
           );
-          console.log(this.projectStore.currentSelectedElements, minY);
           activeElements.forEach((item) => {
             this.projectStore.updateElement(
               item.key,
@@ -175,7 +174,7 @@ export const useAcionStore = defineStore({
       }
       this.addRecord();
     },
-    // 图层调整
+    /* 图层调整 */
     actionLayer(type: LayerEnum) {
       const activeElements = this.projectStore.currentSelectedElements;
       const elements = cloneDeep(
@@ -254,5 +253,69 @@ export const useAcionStore = defineStore({
       }
       this.addRecord();
     },
+    /* 添加组合 */
+    actionGroup() {
+      const elements = this.projectStore.currentSelectedElements;
+      const key = uuid();
+      // 1、移除元素
+      elements.forEach((element) => {
+        this.projectStore.removeElement(element.key);
+      });
+      const minX = Math.min(...elements.map((item) => item.container.props.x));
+      const minY = Math.min(...elements.map((item) => item.container.props.y));
+      const maxX = Math.max(...elements.map((item) => item.container.props.x + item.container.props.width));
+      const maxY = Math.max(...elements.map((item) => item.container.props.y + item.container.props.height));
+      const maxZIndex = Math.max(...elements.map((item) => item.zIndex));
+      const groupIndex = this.projectStore.elements.filter((item) => item.componentType === "group").length + 1;
+      // 重新计算子元素位置
+      elements.forEach((item) => {
+        item.container.props.x -= minX;
+        item.container.props.y -= minY;
+        item.parentKey = key;
+      });
+      const group: CustomElement = {
+        key,
+        name: "组合" + groupIndex,
+        componentType: "group",
+        visible: true,
+        locked: false,
+        zIndex: maxZIndex,
+        container: {
+          style: {},
+          props: {
+            width: maxX - minX,
+            height: maxY - minY,
+            x: minX,
+            y: minY,
+          },
+        },
+        children: elements,
+        collapsed: false,
+        events: [],
+        animations: [],
+        props: {}
+      }
+      // 2、添加组合元素
+      this.projectStore.addElement(group);
+    },
+   /* 拆分组合元素 */
+    actionUngroup() {
+      const group = this.projectStore.currentSelectedElements[0];
+      // 1、取出子元素
+      const elements = group.children?.map((item) => {
+        // 2、计算子元素位置
+        item.container.props.x += group.container.props.x;
+        item.container.props.y += group.container.props.y;
+        delete item.parentKey;
+        return item;
+      });
+    
+      // 3、移除组
+      this.projectStore.removeElement(group.key);
+      // 4、添加子元素
+      elements?.forEach((item) => {
+        this.projectStore.addElement(item, undefined, true);
+      });
+    }
   },
 });

+ 41 - 22
src/store/modules/project.ts

@@ -1,4 +1,4 @@
-import type { ProjectInfo, Page, ReferLine } from "#/project";
+import type { ProjectInfo, Page, ReferLine, CustomElement } from "#/project";
 import { defineStore } from "pinia";
 import { asyncComponentAll } from "shalu-dashboard-ui";
 import { ScreenFillEnum } from "@/enum/screenFillEnum";
@@ -11,7 +11,7 @@ type ProjectState = {
   projectInfo: ProjectInfo;
   activePageIndex: number;
   addCompData: {
-    key: number;
+    key: string;
     name: string;
     componentType: string;
     container: {
@@ -20,7 +20,7 @@ type ProjectState = {
     };
   } | null;
   mode: "edit" | "player";
-  selectedElementKeys: number[];
+  selectedElementKeys: string[];
 };
 const defaultPage: Page = {
   key: "1",
@@ -70,9 +70,21 @@ export const useProjectStore = defineStore({
       return state.projectInfo.pages[state.activePageIndex];
     },
     currentSelectedElements(state) {
-      return state.projectInfo.pages[state.activePageIndex].elements.filter(
-        (item) => state.selectedElementKeys.includes(item.key)
-      );
+      const list: CustomElement[] = [];
+      state.projectInfo.pages[state.activePageIndex].elements.forEach((item) => {
+        if (state.selectedElementKeys.includes(item.key)) {
+          list.push(item);
+        }
+        // 为组时,遍历组下面成员
+        if (item.children) {
+          item.children.forEach((child) => {
+            if (state.selectedElementKeys.includes(child.key)) {
+              list.push(child);
+            }
+          });
+        }
+      });
+      return list;
     },
   },
   actions: {
@@ -105,7 +117,7 @@ export const useProjectStore = defineStore({
     addReferLine(line: ReferLine) {
       this.projectInfo.pages[this.activePageIndex].referLines.push(line);
     },
-    removeReferLine(key: number) {
+    removeReferLine(key: string) {
       const index = this.referLines.findIndex((line) => line.key === key);
       index !== -1 &&
         this.projectInfo.pages[this.activePageIndex].referLines.splice(
@@ -120,33 +132,41 @@ export const useProjectStore = defineStore({
       }
     },
     // 添加组件
-    async addElement(element: any) {
+    async addElement(element: any, position?: 'center', cancelSelect?: boolean = false) {
       this.addCompData = null;
       if (!element) return;
 
+      // 组合
+      if(element.componentType === 'group') {
+        this.projectInfo.pages[this.activePageIndex].elements.push(element);
+        this.selectedElementKeys = [element.key];
+        return;
+      }
+
       const elements = this.projectInfo.pages[this.activePageIndex].elements;
       // 获取每个自定义组件暴露出来的默认属性
       const { defaultPropsValue } =
         (await asyncComponentAll[element.componentType]?.()) || {};
 
-      const { width = 400, height = 260 } =
+      const { defaultWidth = 400, defaultHeight = 260 } =
         defaultPropsValue?.container?.props || {};
-      const { props: containerProps = {}, style = {} } =
+      const { props: containerDefaultProps = {}, style: containerDefaultStyle = {} } =
         defaultPropsValue?.container || {};
 
       const index =
         elements.filter((item) => item.componentType === element.componentType)
           .length + 1;
 
-      const { x, y } = element.container.props;
+      const { x, y, width, height } = element.container.props;
       const container = getNormalizedContainer({
-        style,
+        containerDefaultStyle,
         props: {
-          ...containerProps,
-          width,
-          height,
-          x: x - width / 2,
-          y: y - height / 2,
+          ...containerDefaultProps,
+          width: width ?? defaultWidth,
+          height: height ?? defaultHeight,
+          // 判断是否需要居中,是的话需要减去宽高的一半
+          x: position === 'center' ? x - defaultWidth / 2 : x,
+          y: position === 'center' ? y - defaultHeight / 2 : y,
         },
       });
       // 添加组件
@@ -159,10 +179,10 @@ export const useProjectStore = defineStore({
         container,
       });
 
-      this.selectedElementKeys = [element.key];
+      if(!cancelSelect) this.selectedElementKeys = [element.key];
     },
     // 更新组件
-    updateElement(key: number, path: string, payload: any) {
+    updateElement(key: string, path: string, payload: any) {
       const pageIndex = this.activePageIndex;
 
       const element = this.projectInfo.pages[pageIndex].elements.find((item) => item.key === key);
@@ -186,7 +206,7 @@ export const useProjectStore = defineStore({
       }
     },
     // 删除组件
-    removeElement(key: number) {
+    removeElement(key: string) {
       const index = this.projectInfo.pages[
         this.activePageIndex
       ].elements.findIndex((item) => item.key === key);
@@ -206,7 +226,7 @@ export const useProjectStore = defineStore({
       this.mode = mode;
     },
     // 设置选中的元素
-    setSelectedElementKeys(keys: number[]) {
+    setSelectedElementKeys(keys: string[]) {
       this.selectedElementKeys = keys;
     },
     // 删除所有选中的元素
@@ -220,7 +240,6 @@ export const useProjectStore = defineStore({
     setFillType(fillType: ScreenFillEnum) {
       this.projectInfo.fillType = fillType;
     },
-
     // 保存当前项目到服务器
     async handleSaveProject() {
       const params = {

+ 10 - 0
src/utils/index.ts

@@ -171,3 +171,13 @@ function drawMinTick(
   ctx.lineWidth = 1 * drp;
   ctx.stroke();
 }
+
+
+/* uuid */
+export function uuid() {
+  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
+    var r = (Math.random() * 16) | 0,
+      v = c === "x" ? r : (r & 0x3) | 0x8;
+    return v.toString(16);
+  });
+}

+ 123 - 0
src/utils/scale.ts

@@ -0,0 +1,123 @@
+/**
+ * 对选中的组件进行缩放
+ * 如果操作的是group组件,递归缩放子元素
+ * 
+ * @param projectStore 项目store
+ * @param type 缩放类型
+ * @param moveX x轴移动距离
+ * @param moveY y轴移动距离
+ * @param pathPrefix 路径前缀
+ * @param elements 选中的组件
+ * @param parentKey 父组件key
+ * @returns void
+ *
+ */
+
+import { CustomElement } from "#/project";
+
+export const scaleAction = ({
+  projectStore,
+  type,
+  moveX,
+  moveY,
+  pathPrefix = "",
+  elements,
+  parentKey,
+}: {
+  projectStore: any;
+  type: string;
+  moveX: number;
+  moveY: number;
+  elements: CustomElement[];
+  pathPrefix?: string;
+  parentKey?: string;
+}) => {
+  // 对选中的组件进行缩放
+  elements.forEach((item, index) => {
+    let { x, y, width, height } = item.container.props || {};
+
+    switch (type) {
+      case "top-left":
+        width -= moveX;
+        height -= moveY;
+        if(!parentKey) {
+          x += moveX;
+          y += moveY;
+        }
+        break;
+      case "top-center":
+        height -= moveY;
+        if(!parentKey) {
+          y += moveY;
+        }
+        break;
+      case "top-right":
+        width += moveX;
+        height -= moveY;
+        if(!parentKey) {
+          y += moveY;
+        }
+        break;
+      case "left-center":
+        width -= moveX;
+        if(!parentKey) {
+          x += moveX;
+        }
+        break;
+      case "right-center":
+        width += moveX;
+        break;
+      case "bottom-left":
+        width -= moveX;
+        height += moveY;
+        if(!parentKey) {
+          x += moveX;
+        }
+        break;
+      case "bottom-center":
+        height += moveY;
+        break;
+      case "bottom-right":
+        width += moveX;
+        height += moveY;
+        break;
+    }
+
+    if (width < 10 || height < 10) return;
+
+    const prefix = pathPrefix ? `${pathPrefix}[${index}].` : "";
+
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.x",
+      Math.round(x)
+    );
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.y",
+      Math.round(y)
+    );
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.width",
+      Math.round(width)
+    );
+    projectStore.updateElement(
+      parentKey || item.key,
+      prefix + "container.props.height",
+      Math.round(height)
+    );
+    // 如果是group组件,递归缩放子元素
+    if (item.componentType === "group") {
+      scaleAction({
+        projectStore,
+        type,
+        moveX,
+        moveY,
+        pathPrefix: `children`,
+        elements: item.children || [],
+        parentKey: item.key,
+      });
+    }
+  });
+};

+ 4 - 3
src/views/designer/component/ComponentLibary.vue

@@ -103,6 +103,7 @@ import { useEventListener } from "@vueuse/core";
 import { useProjectStore } from "@/store/modules/project";
 import { useStageStore } from "@/store/modules/stage";
 import { DiffOutlined } from "@ant-design/icons-vue";
+import { uuid } from "@/utils";
 
 const projectStore = useProjectStore();
 const stageStore = useStageStore();
@@ -164,7 +165,7 @@ const handleAddComp = (item: CompItem) => {
   selectedKeys.value = [];
   openDrawer.value = false;
   const compData = {
-    key: Date.now(),
+    key: uuid(),
     name: item.name,
     componentType: item.componetName,
     container: {
@@ -175,7 +176,7 @@ const handleAddComp = (item: CompItem) => {
     },
   };
 
-  projectStore.addElement(compData);
+  projectStore.addElement(compData, 'center');
 };
 // 拖拽添加组件方式
 // 1、拖拽开始时记录要添加的对象
@@ -186,7 +187,7 @@ const handleDragStart = (item: CompItem) => {
   selectedKeys.value = [];
 
   projectStore.setAddCompData({
-    key: Date.now(),
+    key: uuid(),
     name: item.name,
     componentType: item.componetName,
     container: {

+ 53 - 73
src/views/designer/component/ComponentWrapper.vue

@@ -4,7 +4,16 @@
     ref="componentWrapperRef"
     :style="warpperStyle"
   >
-    <Container v-bind="componentData.container" @click.stop="handleSelectComponent">
+    <div class="group-box" v-if="componentData.componentType === 'group'">
+      <ComponentWrapper
+        v-for="item in componentData.children"
+        v-show="item.visible"
+        :component-data="item"
+        :key="item.key"
+        :style="{ zIndex: item.zIndex }"
+      />
+    </div>
+    <Container v-bind="componentData.container" v-else>
       <component
         :is="component"
         v-bind="componentData.props"
@@ -39,14 +48,16 @@ import { useProjectStore } from "@/store/modules/project";
 import { useDraggable } from "@vueuse/core";
 import { UseDraggable } from "@vueuse/components";
 import { useAcionStore } from "@/store/modules/action";
-import { asyncComponentAll } from 'shalu-dashboard-ui';
+import { asyncComponentAll } from "shalu-dashboard-ui";
 import Container from "@/components/Container/index.vue";
+import { scaleAction } from "@/utils/scale";
 
 const { componentData } = defineProps<{ componentData: CustomElement }>();
 // 动态引入组件
-const component = defineAsyncComponent(
-  asyncComponentAll[componentData.componentType]
-);
+const component =
+  componentData.componentType === "group"
+    ? ""
+    : defineAsyncComponent(asyncComponentAll[componentData.componentType]);
 
 const componentWrapperRef = ref<HTMLElement | null>(null);
 const stageStore = useStageStore();
@@ -69,14 +80,16 @@ const editWapperStyle = computed(() => {
 // 组件宽--根据边距计算
 const getComponentWidth = computed(() => {
   const { width = 400 } = componentData.container.props || {};
-  const { paddingLeft = 0, paddingRight = 0 } = componentData.container.props || {};
+  const { paddingLeft = 0, paddingRight = 0 } =
+    componentData.container.props || {};
   return width - paddingLeft - paddingRight;
 });
 
 // 组件高--根据边距计算
 const getComponentHeight = computed(() => {
   const { height = 260 } = componentData.container.props || {};
-  const { paddingTop = 0, paddingBottom = 0 } = componentData.container.props || {};
+  const { paddingTop = 0, paddingBottom = 0 } =
+    componentData.container.props || {};
   return height - paddingTop - paddingBottom;
 });
 
@@ -88,7 +101,7 @@ const warpperStyle = computed(() => {
     y,
   } = componentData.container.props || {};
   // const style = transformStyle(componentData.container?.style || {});
-  
+
   return {
     width: `${width}px`,
     height: `${height}px`,
@@ -123,22 +136,27 @@ useDraggable(componentWrapperRef, {
     // 计算移动的距离
     const xMoveLength = position.x - originPosition.left;
     const yMoveLentgh = position.y - originPosition.top;
-    const { x, y } = componentData.container.props || {};
 
     moveLeft = Math.max(Math.abs(xMoveLength), Math.abs(yMoveLentgh));
-    projectStore.updateElement(
-      componentData.key,
-      "container.props.x",
-      Math.round(x + xMoveLength)
-    );
-    projectStore.updateElement(
-      componentData.key,
-      "container.props.y",
-      Math.round(y + yMoveLentgh)
-    );
+    // 对每个选中的组件进行移动
+    projectStore.currentSelectedElements.forEach((item) => {
+      const { x, y } = item.container.props || {};
+      projectStore.updateElement(
+        item.key,
+        "container.props.x",
+        Math.round(x + xMoveLength)
+      );
+      projectStore.updateElement(
+        item.key,
+        "container.props.y",
+        Math.round(y + yMoveLentgh)
+      );
+    });
   },
   onStart: () => {
-    projectStore.setSelectedElementKeys([componentData.key]);
+    if (!projectStore.selectedElementKeys.includes(componentData.key)) {
+      projectStore.setSelectedElementKeys([componentData.key]);
+    }
     showNameTip.value = false;
     moveLeft = 0;
   },
@@ -147,9 +165,6 @@ useDraggable(componentWrapperRef, {
     moveLeft && actionStore.addRecord(); // 记录操作
   },
 });
-const handleSelectComponent = () => {
-  projectStore.setSelectedElementKeys([componentData.key]);
-};
 
 /* ===============================缩放组件==================================== */
 const dragPointList = [
@@ -167,63 +182,22 @@ const startPoint = {
   x: 0,
   y: 0,
 };
-// 拖拽点移动
+// 拖拽点移动 => 缩放组件
 const handleDragPoint = (type: string, e: PointerEvent) => {
   const moveX = (e.x - startPoint.x) / stageStore.scale;
   const moveY = (e.y - startPoint.y) / stageStore.scale;
 
-  let { x, y, width, height } = componentData.container.props || {};
-
-  switch (type) {
-    case "top-left":
-      width -= moveX;
-      height -= moveY;
-      x += moveX;
-      y += moveY;
-      break;
-    case "top-center":
-      height -= moveY;
-      y += moveY;
-      break;
-    case "top-right":
-      width += moveX;
-      height -= moveY;
-      y += moveY;
-      break;
-    case "left-center":
-      width -= moveX;
-      x += moveX;
-      break;
-    case "right-center":
-      width += moveX;
-      break;
-    case "bottom-left":
-      width -= moveX;
-      height += moveY;
-      x += moveX;
-      break;
-    case "bottom-center":
-      height += moveY;
-      break;
-    case "bottom-right":
-      width += moveX;
-      height += moveY;
-      break;
-  }
-
   startPoint.x = e.x;
   startPoint.y = e.y;
 
-  if (width < 10 || height < 10) return;
-
-  projectStore.updateElement(componentData.key, "container.props.x", Math.round(x));
-  projectStore.updateElement(componentData.key, "container.props.y", Math.round(y));
-  projectStore.updateElement(componentData.key, "container.props.width", Math.round(width));
-  projectStore.updateElement(
-    componentData.key,
-    "container.props.height",
-    Math.round(height)
-  );
+  // 对选中的组件进行缩放
+  scaleAction({
+    projectStore,
+    type,
+    moveX,
+    moveY,
+    elements: projectStore.currentSelectedElements,
+  });
 };
 // 拖拽点开始
 const handleDragStart = (_: any, e: PointerEvent) => {
@@ -240,6 +214,12 @@ const handleDragEnd = () => {
 };
 </script>
 
+<script lang="ts">
+export default {
+  name: "ComponentWrapper",
+};
+</script>
+
 <style lang="less" scoped>
 .component-wrapper {
   position: absolute;

+ 133 - 9
src/views/designer/component/Configurator.vue

@@ -9,8 +9,16 @@
       </TabPane>
     </Tabs>
 
+    <Tabs centered v-else-if="isGroup">
+      <TabPane key="1" tab="组合">
+        <div class="config-content">
+          <CusForm :columns="groupFormItems" @change="handleGroupChange" />
+        </div>
+      </TabPane>
+    </Tabs>
+
     <!-- 组件设置 -->
-    <Tabs centered v-else>
+    <Tabs centered v-else-if="isComponent">
       <TabPane key="1" tab="内容">
         <div class="config-content">
           <component
@@ -36,7 +44,7 @@
 </template>
 
 <script setup lang="ts">
-import { shallowRef, watch } from "vue";
+import { computed, shallowRef, watch } from "vue";
 import { Tabs, TabPane } from "ant-design-vue";
 import { useProjectStore } from "@/store/modules/project";
 import PageConfig from "./PageConfig.vue";
@@ -49,16 +57,65 @@ const configComponent = shallowRef<null | string>(null);
 const currentElementProps = shallowRef<any>({});
 const { formItems } = useComponentConfig();
 
+const groupFormItems = computed(() => {
+  const containerProps =
+    projectStore.currentSelectedElements?.[0]?.container?.props;
+  return containerProps ? [
+    {
+      label: "宽度",
+      prop: "props.width",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.width ?? 400,
+    },
+    {
+      label: "高度",
+      prop: "props.height",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.height ?? 260,
+    },
+    {
+      label: "X",
+      prop: "props.x",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.x ?? 0,
+    },
+    {
+      label: "Y",
+      prop: "props.y",
+      type: "inputNumber",
+      fieldProps: {
+        min: 0,
+        addonAfter: "px",
+      },
+      defaultValue: containerProps?.y ?? 0,
+    },
+  ] : [];
+});
+
 watch(
   () => projectStore.currentSelectedElements,
   async (val) => {
-    if (val.length === 1) {
+    // 组件类型
+    if (val.length === 1 && val[0].componentType !== "group") {
       const { Config } = await asyncComponentAll[
         val[0].componentType as keyof typeof asyncComponentAll
       ]?.();
       configComponent.value = Config;
       currentElementProps.value = val[0].props;
     } else {
+      // 多选或者组暂时为空
       configComponent.value = null;
       currentElementProps.value = {};
     }
@@ -66,10 +123,39 @@ watch(
   { immediate: true, deep: true }
 );
 
+const isComponent = computed(() => {
+  return (
+    projectStore.currentSelectedElements.length === 1 &&
+    projectStore.currentSelectedElements[0].componentType !== "group"
+  );
+});
+
+const isGroup = computed(() => {
+  return (
+    projectStore.currentSelectedElements.length === 1 &&
+    projectStore.currentSelectedElements[0].componentType === "group"
+  );
+});
+
 // 组件内容配置
 const handleContentConfigChange = (config: any) => {
-  const key = projectStore.selectedElementKeys[0];
-  projectStore.updateElement(key, "props", config);
+  const element = projectStore.currentSelectedElements[0];
+  let prefix: string = "";
+  if (element?.parentKey !== undefined) {
+    const parent = projectStore.elements.find(
+      (item) => item.key === element.parentKey
+    );
+    parent?.children?.findIndex((item, index) => {
+      if (item.key === element.key) {
+        prefix = `children[${index}].`;
+      }
+    });
+  }
+  projectStore.updateElement(
+    element?.parentKey ?? element.key,
+    prefix + "props",
+    config
+  );
 };
 
 // 组件配置
@@ -82,10 +168,48 @@ const handleComponentConfigChange = (config: Record<string, any>) => {
     set(container, key, value);
   });
   defaultsDeep(container, currentContainer);
-  const key = projectStore.selectedElementKeys[0];
-  console.log(container);
-  projectStore.updateElement(key, "container", container);
-  projectStore.updateElement(key, "name", container?.name);
+  const element = projectStore.currentSelectedElements[0];
+  let prefix: string = "";
+  if (element?.parentKey !== undefined) {
+    const parent = projectStore.elements.find(
+      (item) => item.key === element.parentKey
+    );
+    parent?.children?.findIndex((item, index) => {
+      if (item.key === element.key) {
+        prefix = `children[${index}].`;
+      }
+    });
+  }
+  // 判断是否存在父级情况
+  projectStore.updateElement(
+    element?.parentKey ?? element.key,
+    prefix + "container",
+    container
+  );
+  projectStore.updateElement(
+    element?.parentKey ?? element.key,
+    prefix + "name",
+    container?.name
+  );
+};
+
+const handleGroupChange = (config: Record<string, any>) => {
+  const element = projectStore.currentSelectedElements[0];
+  const container: Record<string, any> = {};
+  Object.entries(config).forEach(([key, value]) => {
+    set(container, key, value);
+  });
+  const changeWidth = container.props.width - element.container.props.width;
+  const changeHeight = container.props.height - element.container.props.height;
+
+  projectStore.updateElement(element.key, "container.props", container?.props);
+  // 更新子元素的宽高
+  element.children?.forEach((child, index) => {
+    child.container.props.width += changeWidth;
+    child.container.props.height += changeHeight;
+    projectStore.updateElement(element.key, `children[${index}].container.props.width`, child.container.props.width);
+    projectStore.updateElement(element.key, `children[${index}].container.props.height`, child.container.props.height);
+  });
 };
 </script>
 

+ 39 - 4
src/views/designer/component/LayerItem.vue

@@ -14,6 +14,16 @@
         <EyeInvisibleOutlined v-if="!data.visible" @click="handleVisible(true)" />
       </span>
       <span class="layer-name">
+        <span v-if="type === 'group'" class="collapse-icon" @click="handleCollapse">
+          <CaretUpOutlined v-if="data.collapsed" />
+          <CaretDownOutlined v-else />
+        </span>
+        <span class="comp-icon" v-if="type === 'group'">
+          <FolderOutlined />
+        </span>
+        <span v-else class="comp-icon" :class="{'child-icon': type === 'child'}">
+          <PieChartOutlined />
+        </span>
         <Tooltip :title="data.name">
           <span @dblclick="isEditing = true">{{ data.name }}</span>
         </Tooltip>
@@ -68,12 +78,20 @@ import {
   UnlockOutlined,
   EditOutlined,
   DeleteOutlined,
+  PieChartOutlined,
+  FolderOutlined,
+  CaretUpOutlined,
+  CaretDownOutlined
 } from "@ant-design/icons-vue";
 import { useProjectStore } from "@/store/modules/project";
 import { useAcionStore } from "@/store/modules/action";
 
 const props = defineProps<{
   data: CustomElement;
+  // 组件 | 组 | 子组件
+  type: "layer" | "group" | "child";
+  // 子组件所在的索引
+  index?: number;
 }>();
 
 const projectStore = useProjectStore();
@@ -99,7 +117,8 @@ const handleChangeName = () => {
     layerName.value = props.data.name;
     return;
   };
-  projectStore.updateElement(props.data.key, "name", layerName.value);
+  const prefix = props.index !== undefined ? `children[${props.index}].` : '';
+  projectStore.updateElement(props.data?.parentKey ?? props.data.key, `${prefix}name`, layerName.value);
   actionStore.addRecord() // 添加记录
 };
 
@@ -108,14 +127,20 @@ const handleActive = () => {
 };
 
 const handleLock = (locked: boolean) => {
-  projectStore.updateElement(props.data.key, "locked", locked);
+  const prefix = props.index !== undefined ? `children[${props.index}].` : '';
+  projectStore.updateElement(props.data?.parentKey ?? props.data.key, `${prefix}locked`, locked);
   actionStore.addRecord() // 添加记录
 };
 
 const handleVisible = (visible: boolean) => {
-  projectStore.updateElement(props.data.key, "visible", visible);
+  const prefix = props.index !== undefined ? `children[${props.index}].` : '';
+  projectStore.updateElement(props.data?.parentKey ?? props.data.key, `${prefix}visible`, visible);
   actionStore.addRecord() // 添加记录
 };
+
+const handleCollapse = () => {
+  projectStore.updateElement(props.data.key, "collapsed", !props.data.collapsed);
+};
 </script>
 
 <style lang="less" scoped>
@@ -134,7 +159,7 @@ const handleVisible = (visible: boolean) => {
   }
   &-visible {
     cursor: pointer;
-    width: 10px;
+    width: 0px;
   }
   .layer-name {
     width: 100px;
@@ -150,5 +175,15 @@ const handleVisible = (visible: boolean) => {
     width: 30px;
     text-align: right;
   }
+  .comp-icon {
+    margin-right: 4px;
+  }
+  .collapse-icon {
+    margin-right: 4px;
+    cursor: pointer;
+  }
+  .child-icon {
+    margin-left: 20px;
+  }
 }
 </style>

+ 20 - 1
src/views/designer/component/LayerManagement.vue

@@ -25,7 +25,23 @@
         @end="dragEnd"
       >
         <template #item="{ element }">
-          <LayerItem :data="element" />
+          <div :class="{'group-bg': projectStore.selectedElementKeys.includes(element.key)}" v-if="element.componentType === 'group'">
+            <LayerItem :data="element" type="group"/>
+            <VueDraggable
+              :list="element.children"
+              ghost-class="item-ghost"
+              chosen-class="item-chosen"
+              animation="300"
+              itemKey="id"
+              @end="dragEnd"
+              v-show="!element.collapsed"
+            >
+              <template #item="{ element: item, index }">
+                <LayerItem :data="item" type="child" :index="index" />
+              </template>
+            </VueDraggable>
+          </div>
+          <LayerItem v-else :data="element" type="layer"/>
         </template>
       </VueDraggable>
 
@@ -120,5 +136,8 @@ const dragEnd = (event: CustomEvent & {newIndex: number}) => {
     margin-bottom: 8px;
     margin-left: -8px;
   }
+  .group-bg {
+    background: #f7f9ff;
+  }
 }
 </style>

+ 62 - 14
src/views/designer/component/MenuBar.vue

@@ -5,7 +5,12 @@
         <div>撤销</div>
         <div>ctrl+z</div>
       </template>
-      <Button type="text" size="small" :disabled="actionStore.undoDisabled" @click="actionStore.actionUndo">
+      <Button
+        type="text"
+        size="small"
+        :disabled="actionStore.undoDisabled"
+        @click="actionStore.actionUndo"
+      >
         <UndoOutlined />
       </Button>
     </Tooltip>
@@ -15,7 +20,12 @@
         <div>还原</div>
         <div>ctrl+shift+z</div>
       </template>
-      <Button type="text" size="small" :disabled="actionStore.redoDisabled" @click="actionStore.actionRedo">
+      <Button
+        type="text"
+        size="small"
+        :disabled="actionStore.redoDisabled"
+        @click="actionStore.actionRedo"
+      >
         <RedoOutlined />
       </Button>
     </Tooltip>
@@ -27,7 +37,12 @@
         <div>组合</div>
         <div>ctrl+g</div>
       </template>
-      <Button type="text" size="small">
+      <Button
+        type="text"
+        size="small"
+        :disabled="projectStore.selectedElementKeys.length <= 1"
+        @click="actionStore.actionGroup"
+      >
         <BlockOutlined />
       </Button>
     </Tooltip>
@@ -37,7 +52,8 @@
         <div>取消组合</div>
         <div>ctrl+shift+g</div>
       </template>
-      <Button type="text" size="small">
+
+      <Button type="text" size="small" :disabled="ungroupDisabled" @click="actionStore.actionUngroup">
         <SplitCellsOutlined />
       </Button>
     </Tooltip>
@@ -47,7 +63,12 @@
         <div>删除</div>
         <div>del</div>
       </template>
-      <Button type="text" size="small" :disabled="!projectStore.selectedElementKeys.length" @click="handleDeleteElements">
+      <Button
+        type="text"
+        size="small"
+        :disabled="!projectStore.selectedElementKeys.length"
+        @click="handleDeleteElements"
+      >
         <DeleteOutlined />
       </Button>
     </Tooltip>
@@ -56,7 +77,10 @@
 
     <Dropdown trigger="click">
       <template #overlay>
-        <Menu @click="handleAlignClick" :disabled="projectStore.selectedElementKeys.length < 2">
+        <Menu
+          @click="handleAlignClick"
+          :disabled="projectStore.selectedElementKeys.length < 2"
+        >
           <MenuItem :key="AlignEnum.Left">
             <VerticalRightOutlined />
             左对齐
@@ -88,7 +112,11 @@
         <template #title>
           <div>对齐</div>
         </template>
-        <Button :disabled="projectStore.selectedElementKeys.length < 2" type="text" size="small">
+        <Button
+          :disabled="projectStore.selectedElementKeys.length < 2"
+          type="text"
+          size="small"
+        >
           <VerticalAlignMiddleOutlined />
           <CaretDownOutlined
             style="font-size: 10px; vertical-align: baseline"
@@ -99,12 +127,21 @@
 
     <Dropdown trigger="click">
       <template #overlay>
-        <Menu @click="handleLayerClick" :disabled="projectStore.selectedElementKeys.length < 1">
-          <MenuItem :key="LayerEnum.UP" v-if="projectStore.selectedElementKeys.length === 1">
+        <Menu
+          @click="handleLayerClick"
+          :disabled="projectStore.selectedElementKeys.length < 1"
+        >
+          <MenuItem
+            :key="LayerEnum.UP"
+            v-if="projectStore.selectedElementKeys.length === 1"
+          >
             <ArrowUpOutlined />
             上移一层
           </MenuItem>
-          <MenuItem :key="LayerEnum.DOWN" v-if="projectStore.selectedElementKeys.length === 1">
+          <MenuItem
+            :key="LayerEnum.DOWN"
+            v-if="projectStore.selectedElementKeys.length === 1"
+          >
             <ArrowDownOutlined />
             下移一层
           </MenuItem>
@@ -122,9 +159,15 @@
         <template #title>
           <div>层级</div>
         </template>
-        <Button type="text" size="small" :disabled="projectStore.selectedElementKeys.length < 1">
+        <Button
+          type="text"
+          size="small"
+          :disabled="projectStore.selectedElementKeys.length < 1"
+        >
           <ColumnHeightOutlined />
-          <CaretDownOutlined style="font-size: 10px; vertical-align: baseline" />
+          <CaretDownOutlined
+            style="font-size: 10px; vertical-align: baseline"
+          />
         </Button>
       </Tooltip>
     </Dropdown>
@@ -132,6 +175,7 @@
 </template>
 
 <script setup lang="ts">
+import { computed } from "vue";
 import {
   Button,
   Divider,
@@ -166,7 +210,11 @@ import { useProjectStore } from "@/store/modules/project";
 
 const actionStore = useAcionStore();
 const projectStore = useProjectStore();
-
+const ungroupDisabled = computed(() => {
+  const length = projectStore.selectedElementKeys.length;
+  const current = projectStore.currentSelectedElements?.[0];
+  return !(length === 1 && current?.componentType === 'group');
+});
 const handleAlignClick = ({ key }: any) => {
   actionStore.actionAlign(key);
 };
@@ -177,7 +225,7 @@ const handleDeleteElements = () => {
   projectStore.selectedElementKeys.forEach((key) => {
     projectStore.removeElement(key);
   });
-}
+};
 </script>
 
 <style scoped></style>

+ 11 - 2
src/views/designer/component/Stage.vue

@@ -33,6 +33,7 @@ import { useProjectStore } from "@/store/modules/project";
 import { useScroll } from "@vueuse/core";
 import ComponentWrapper from "./ComponentWrapper.vue";
 import { useAcionStore } from "@/store/modules/action";
+import { uuid } from "@/utils";
 
 const stageWrapperRef: Ref<HTMLElement | null> = ref(null);
 const stageRef: Ref<HTMLElement | null> = ref(null);
@@ -153,7 +154,7 @@ const handleDrop = (e: DragEvent) => {
 
   if(projectStore.addCompData) {
     const compData = {
-      key: Date.now(),
+      key: uuid(),
       name: projectStore.addCompData.name,
       componentType: projectStore.addCompData.componentType,
       container: {
@@ -169,9 +170,17 @@ const handleDrop = (e: DragEvent) => {
 };
 
 /* 适应大小设置 */
+watch(
+  () => stageStore.scale,
+  (val) => {
+    if(!val) {
+      initScale();
+      initStagePosition();  
+    }
+  }
+);
 watch(
   () => [
-    stageStore.scale,
     projectStore.projectInfo.width,
     projectStore.projectInfo.height
   ],

+ 5 - 0
src/views/designer/component/Workspace.vue

@@ -94,6 +94,11 @@ const fillOptions = [
 
 const handleSizeChange = (val: any) => {
   if (Number.isFinite(val)) {
+    // 为0时为自动适应大小
+    if(val === 0) {
+      stageStore.setScale(0);
+      return;
+    }
     stageStore.setScale((val as number) < 0.1 ? 0.1 : val);
   }
   if (typeof val === "string") {

+ 20 - 3
src/views/view/index.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="player-page" :style="bodyStyle">
     <div class="page-wrapper" :style="pageWapperStyle">
-      <RenderComponent v-for="element in currentPage?.elements" :key="element.key" :element="element"/>
+      <RenderComponent v-for="element in getElements" :key="element.key" :element="element"/>
       <Result
         v-if="!currentPage"
         status="warning"
@@ -17,8 +17,8 @@
 </template>
 
 <script setup lang="ts">
-import type { ProjectInfo } from '#/project';
-import { ref, onMounted, onBeforeUnmount, StyleValue } from "vue";
+import type { CustomElement, ProjectInfo } from '#/project';
+import { ref, onMounted, onBeforeUnmount, StyleValue, computed } from "vue";
 import { Result, Button } from "ant-design-vue";
 import RenderComponent from "./component/RenderComponent.vue";
 import { ScreenFillEnum } from "@/enum/screenFillEnum";
@@ -29,6 +29,23 @@ const currentPage = ref(projectInfo.pages?.[0]);
 const pageWapperStyle = ref<StyleValue>();
 const bodyStyle = ref<StyleValue>();
 
+const getElements = computed(() => {
+  const list: CustomElement[] = [];
+  (currentPage.value?.elements || []).forEach((item) => {
+    if(item.componentType === 'group') {
+      item.children?.forEach((child) => {
+        child.container.props.x += item.container.props.x;
+        child.container.props.y += item.container.props.y;
+        list.push(child);
+      });
+    } else {
+      list.push(item);
+    }
+  });
+
+  return list;
+});
+
 // 页面样式
 const getWapperStyle = () => {
   if (!currentPage.value) return;

+ 8 - 3
types/project.d.ts

@@ -1,4 +1,3 @@
-import type { ComponentType } from "@/components";
 declare interface BackgroundOptions {
   // 背景类型
   type: 'color' | 'image' | 'none';
@@ -12,11 +11,11 @@ declare interface BackgroundOptions {
 
 declare interface CustomElement {
   // 元素唯一标识
-  key: number;
+  key: string;
   // 元素名称
   name: string;
   // 组件类型
-  componentType: ComponentType;
+  componentType: string | 'group';
   // 元素层级
   zIndex: number;
   // 是否可见
@@ -36,6 +35,12 @@ declare interface CustomElement {
   animations: Record<string, any>;
   // 组件内容 -- 数据源, 数据样式等
   props: Record<string, any>;
+  // 子元素
+  children?: CustomElement[];
+  // group 折叠
+  collapsed?: boolean;
+  // 父级key
+  parentKey?: string;
 }
 
 declare export interface ReferLine {