ソースを参照

feat: 添加弹性布局及自定义组件

jiaxing.liao 1 ヶ月 前
コミット
fbdb017716

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "@types/fs-extra": "^11.0.4",
     "@vueuse/components": "^14.0.0",
     "@vueuse/core": "^14.0.0",
+    "@zumer/snapdom": "^2.0.2",
     "deep-diff": "^1.0.2",
     "element-plus": "^2.11.4",
     "fs-extra": "^11.3.2",

+ 8 - 0
pnpm-lock.yaml

@@ -23,6 +23,9 @@ importers:
       '@vueuse/core':
         specifier: ^14.0.0
         version: 14.0.0(vue@3.5.22(typescript@5.9.3))
+      '@zumer/snapdom':
+        specifier: ^2.0.2
+        version: 2.0.2
       deep-diff:
         specifier: ^1.0.2
         version: 1.0.2
@@ -1246,6 +1249,9 @@ packages:
     resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
     engines: {node: '>=10.0.0'}
 
+  '@zumer/snapdom@2.0.2':
+    resolution: {integrity: sha512-W6quT4lMcPu8Q9O/Q6witSfc6/+xuY8C8yDoHug/+o7zYKCNE/e0I3//XsWDkyq9C0mDE0TAWF/8bwCR7x3gHQ==}
+
   abbrev@1.1.1:
     resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
 
@@ -5011,6 +5017,8 @@ snapshots:
 
   '@xmldom/xmldom@0.8.11': {}
 
+  '@zumer/snapdom@2.0.2': {}
+
   abbrev@1.1.1: {}
 
   acorn-jsx@5.3.2(acorn@8.15.0):

+ 2 - 0
src/renderer/components.d.ts

@@ -53,6 +53,7 @@ declare module 'vue' {
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
     ElTree: typeof import('element-plus/es')['ElTree']
     ElTreeV2: typeof import('element-plus/es')['ElTreeV2']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     IconButton: typeof import('./src/components/IconButton/index.vue')['default']
     InputNumber: typeof import('./src/components/InputNumber/index.vue')['default']
     LocalImage: typeof import('./src/components/LocalImage/index.vue')['default']
@@ -111,6 +112,7 @@ declare global {
   const ElTooltip: typeof import('element-plus/es')['ElTooltip']
   const ElTree: typeof import('element-plus/es')['ElTree']
   const ElTreeV2: typeof import('element-plus/es')['ElTreeV2']
+  const ElUpload: typeof import('element-plus/es')['ElUpload']
   const IconButton: typeof import('./src/components/IconButton/index.vue')['default']
   const InputNumber: typeof import('./src/components/InputNumber/index.vue')['default']
   const LocalImage: typeof import('./src/components/LocalImage/index.vue')['default']

+ 1 - 1
src/renderer/index.html

@@ -6,7 +6,7 @@
     <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
     <meta
       http-equiv="Content-Security-Policy"
-      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: http: https:;"
+      content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: http: https: blob:;"
     />
     <style>
       body {

+ 5 - 1
src/renderer/src/locales/en_US.json

@@ -123,5 +123,9 @@
   "VsyncWidthRequired": "VsyncWidth is required",
   "event": "Event",
   "log": "Log",
-  "screenDirection": "Direction"
+  "screenDirection": "Direction",
+  "createComponent": "Create Component",
+  "name": "Name",
+  "desc": "Description",
+  "icon": "Icon"
 }

+ 5 - 1
src/renderer/src/locales/zh_CN.json

@@ -123,5 +123,9 @@
   "VsyncWidthRequired": "VsyncWidth不能为空",
   "event": "事件",
   "log": "日志",
-  "screenDirection": "方向"
+  "screenDirection": "方向",
+  "createComponent": "创建组件",
+  "name": "名称",
+  "desc": "描述",
+  "icon": "图标"
 }

+ 0 - 2
src/renderer/src/lvgl-widgets/container/index.ts

@@ -210,7 +210,6 @@ export default {
                       }
                     },
                     {
-                      label: '',
                       field: 'props.flex.rowGap',
                       valueType: 'number',
                       componentProps: {
@@ -221,7 +220,6 @@ export default {
                       slots: { prefix: 'R' }
                     },
                     {
-                      label: '',
                       field: 'props.flex.columnGap',
                       valueType: 'number',
                       componentProps: {

+ 16 - 1
src/renderer/src/model/index.ts

@@ -10,6 +10,7 @@ import { klona } from 'klona'
 import { BaseWidget } from '@/types/baseWidget'
 import { getUUID } from '@/utils'
 import LvglWidgets from '@/lvgl-widgets'
+import { bfsWalk } from 'simple-mind-map/src/utils'
 
 /**
  * 创建屏幕
@@ -152,7 +153,21 @@ export const createMethod = () => {
  * 创建控件数据
  * @param schema 组件模型
  */
-export const createWidget = (schema: IComponentModelConfig, index: number) => {
+export const createWidget = (
+  schema: IComponentModelConfig | BaseWidget,
+  index: number,
+  isCustom?: boolean
+): BaseWidget => {
+  // 自定义组件
+  if (isCustom) {
+    const newComp = klona(schema as BaseWidget)
+    bfsWalk(newComp, (child) => {
+      child.id = v4()
+    })
+    // todo:处理关联性问题
+    newComp.id = v4()
+    return newComp as BaseWidget
+  }
   const { defaultSchema, hasChildren, parts } = schema
   const componentSchema: BaseWidget = {
     id: v4(),

+ 2 - 2
src/renderer/src/store/modules/action.ts

@@ -290,7 +290,7 @@ export const useActionStore = defineStore('action', () => {
         obj.copyFrom = newWidget.id
         obj.isCopy = true
         // 复制一份复用的
-        child.children.unshift({
+        child.children.push({
           ...newWidget,
           id: v4()
         })
@@ -330,7 +330,7 @@ export const useActionStore = defineStore('action', () => {
         id: v4(),
         events: []
       })
-      list.unshift(newWidget)
+      list.push(newWidget)
       newArr.push(newWidget)
     })
     projectStore.setSelectWidgets(newArr)

+ 23 - 0
src/renderer/src/store/modules/project.ts

@@ -21,6 +21,7 @@ import dayjs from 'dayjs'
 import { ElMessage } from 'element-plus'
 import { useI18n } from 'vue-i18n'
 import { ComponentArray } from '@/lvgl-widgets'
+import { bfsWalk } from 'simple-mind-map/src/utils'
 
 export interface IProject {
   version: string
@@ -305,6 +306,27 @@ export const useProjectStore = defineStore('project', () => {
     activeWidgets.value = widgets
   }
 
+  /**
+   * 根据id获取widget
+   * @param id
+   * @returns widget
+   */
+  const getWidgetById = (id: string): BaseWidget | undefined => {
+    if (!project.value) return
+    let widget: BaseWidget | undefined
+    project.value.screens.find((screen) => {
+      return screen.pages.find((page) => {
+        return bfsWalk(page, (child) => {
+          if (child.id === id) {
+            widget = child
+          }
+        })
+      })
+    })
+
+    return widget
+  }
+
   return {
     createApp,
     project,
@@ -323,6 +345,7 @@ export const useProjectStore = defineStore('project', () => {
     globalStyle,
     currentMaxScreen,
     activeScreen,
+    getWidgetById,
 
     // 历史记录
     history,

+ 10 - 0
src/renderer/src/types/index.ts

@@ -0,0 +1,10 @@
+import { BaseWidget } from './baseWidget'
+
+export interface ICustomWidget {
+  name: string
+  description?: string
+  iconFile: File | Blob | null
+  widget: BaseWidget
+  createBy: string
+  order?: number
+}

+ 32 - 0
src/renderer/src/utils/database.ts

@@ -0,0 +1,32 @@
+import { openDB } from 'idb'
+
+// 初始化数据库
+export const dbPromise = openDB('sunmicro-lvgl-designer', 1, {
+  upgrade(db) {
+    db.createObjectStore('data', { keyPath: 'key' })
+  }
+})
+
+// 新增数据
+export async function addIDBData(key: string, data: any) {
+  const db = await dbPromise
+  await db.put('data', { key, value: data })
+}
+
+// 获取数据
+export async function getIDBData(key: string) {
+  const db = await dbPromise
+  return await db.get('data', key)
+}
+
+// 删除数据
+export async function deleteIDBData(key: string) {
+  const db = await dbPromise
+  await db.delete('data', key)
+}
+
+// 获取全部数据
+export async function getAllIDBData() {
+  const db = await dbPromise
+  return await db.getAll('data')
+}

+ 2 - 0
src/renderer/src/utils/index.ts

@@ -59,6 +59,8 @@ export function getUUID(randomLength = 6, type: 'number' | 'string' = 'string'):
  * 获取新增控件的index
  */
 export function getAddWidgetIndex(page: Page, type: string) {
+  if (!type) return 1
+
   let count = 0
   bfsWalk(page, (node) => {
     if (node.type === type) {

+ 2 - 0
src/renderer/src/views/designer/index.vue

@@ -23,6 +23,7 @@
     </div>
   </div>
   <ProjectModal ref="projectModel" />
+  <CreateComponentModal />
 </template>
 
 <script setup lang="ts">
@@ -33,6 +34,7 @@ import Workspace from './workspace/index.vue'
 import Config from './config/index.vue'
 // import Info from './info/index.vue'
 import ProjectModal from './modals/projectModal/index.vue'
+import CreateComponentModal from './modals/createComponentModal/index.vue'
 import { ref, provide } from 'vue'
 
 const projectModel = ref<InstanceType<typeof ProjectModal>>()

+ 153 - 0
src/renderer/src/views/designer/modals/createComponentModal/index.vue

@@ -0,0 +1,153 @@
+<template>
+  <el-dialog
+    v-model="show"
+    :title="$t('createComponent')"
+    width="600px"
+    :modal="false"
+    :close-on-click-modal="false"
+    align-center
+  >
+    <el-form label-position="top" ref="form" :model="formData" :rules="rules">
+      <el-form-item :label="$t('name')" prop="name">
+        <el-input v-model="formData.name" :placeholder="$t('name')" />
+      </el-form-item>
+      <el-form-item :label="$t('desc')" prop="description">
+        <el-input
+          v-model="formData.description"
+          :rows="3"
+          type="textarea"
+          :placeholder="$t('desc')"
+        />
+      </el-form-item>
+      <el-form-item :label="$t('icon')" prop="iconFile">
+        <el-upload
+          drap
+          action="#"
+          class="avatar-uploader"
+          accept=".jpg, .png, .svg"
+          :http-request="handleUpload"
+          :show-file-list="false"
+          :on-success="handleAvatarSuccess"
+        >
+          <img v-if="imageUrl" :src="imageUrl" class="avatar" />
+          <LuPlus v-if="!imageUrl" size="18px" class="avatar-uploader-icon" />
+        </el-upload>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button type="primary" @click="onSubmit">{{ $t('confirm') }}</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, onBeforeUnmount } from 'vue'
+import { useEventBus } from '@vueuse/core'
+import { snapdom } from '@zumer/snapdom'
+import { addIDBData } from '@/utils/database'
+import { useProjectStore } from '@/store/modules/project'
+import { ElMessage } from 'element-plus'
+import { v4 } from 'uuid'
+
+import { LuPlus } from 'vue-icons-plus/lu'
+
+import { type UploadProps, type UploadRequestOptions, type FormInstance, dayjs } from 'element-plus'
+import { ICustomWidget } from '@/types'
+import { klona } from 'klona'
+
+const show = ref(false)
+const imageUrl = ref('')
+const form = ref<FormInstance>()
+const createCustomCompBus = useEventBus('create-custom-comp')
+const addedCustomCompBus = useEventBus('created-custom-comp')
+const projectStore = useProjectStore()
+
+const formData = ref<{
+  id: string
+  name: string
+  description?: string
+  iconFile: File | Blob | null
+}>({
+  id: '',
+  name: '',
+  description: '',
+  iconFile: null
+})
+
+const rules = {
+  name: [{ required: true, message: '请输入组件名称', trigger: 'blur' }],
+  iconFile: [{ required: true, message: '请选择图标文件', trigger: 'change' }]
+}
+
+/**
+ * 创建自定义组件
+ */
+const unsubscribe = createCustomCompBus.on(async (_e, id) => {
+  show.value = true
+  formData.value.id = id
+
+  // 获取元素图片
+  const el = document.querySelector('[widget-id="' + id + '"]')
+  if (el) {
+    const blob = await snapdom.toBlob(el, {
+      exclude: ['.moveable-control-box'],
+      outerTransforms: false
+    })
+    imageUrl.value = URL.createObjectURL(blob)
+    formData.value.iconFile = blob
+  }
+})
+
+const handleAvatarSuccess: UploadProps['onSuccess'] = (_, uploadFile) => {
+  imageUrl.value = URL.createObjectURL(uploadFile.raw!)
+}
+
+const handleUpload = async (options: UploadRequestOptions) => {
+  const { file, onSuccess } = options
+  formData.value.iconFile = file
+  onSuccess({ code: 200, message: '上传成功' } as any)
+}
+
+onBeforeUnmount(() => {
+  unsubscribe()
+})
+
+const onSubmit = () => {
+  form.value?.validate().then(() => {
+    const widget = projectStore.getWidgetById(formData.value.id)
+    if (widget) {
+      // todo:处理widget数据
+      const newComponent: ICustomWidget = {
+        name: formData.value.name,
+        description: formData.value.description,
+        iconFile: formData.value.iconFile,
+        widget: widget,
+        createBy: dayjs().format('YYYY-MM-DD HH:mm:ss'),
+        order: 0
+      }
+      addIDBData(v4(), klona(newComponent))
+      form.value?.resetFields()
+      ElMessage.success('添加成功')
+      show.value = false
+      addedCustomCompBus.emit()
+    }
+  })
+}
+</script>
+
+<style lang="less" scoped>
+.avatar-uploader .el-upload {
+  width: 178px;
+  height: 178px;
+  border: 1px dashed #eee;
+  border-radius: 6px;
+  cursor: pointer;
+  position: relative;
+  overflow: hidden;
+  transition: var(--el-transition-duration-fast);
+}
+.avatar {
+  width: 178px;
+  height: 178px;
+}
+</style>

+ 12 - 100
src/renderer/src/views/designer/sidebar/Libary.vue

@@ -1,111 +1,23 @@
 <template>
   <el-scrollbar>
-    <el-input
-      v-model="search"
-      placeholder="请输入组件名称"
-      class="m-8px"
-      style="width: calc(100% - 16px)"
-    />
-    <el-collapse v-model="activeCollapse">
-      <el-collapse-item
-        v-for="group in getGroups"
-        :key="group.label"
-        :title="group.label"
-        :name="group.label"
-      >
-        <div class="px-2 pb-2 pt-1 grid grid-cols-[repeat(auto-fill,minmax(70px,1fr))] gap-row-2">
-          <LibaryItem
-            v-for="item in group.items"
-            :key="item.key"
-            :comp="item"
-            @click="handleAdd(item)"
-          />
-        </div>
-      </el-collapse-item>
-    </el-collapse>
-    <el-empty v-if="!getGroups.length" :image-size="80">
-      <template #description>
-        <div>没有找到组件</div>
-      </template>
-    </el-empty>
+    <el-tabs v-model="activeTab" stretch>
+      <el-tab-pane label="控件" name="widget">
+        <WidgetLibary />
+      </el-tab-pane>
+      <el-tab-pane label="组件" name="component">
+        <ComponentLibary />
+      </el-tab-pane>
+    </el-tabs>
   </el-scrollbar>
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
-import { ComponentArray } from '@/lvgl-widgets'
-import LibaryItem from './components/LibaryItem.vue'
-import { createWidget } from '@/model'
-import { getAddWidgetIndex } from '@/utils'
-import { useProjectStore } from '@/store/modules/project'
+import { ref } from 'vue'
 
-import type { IComponentModelConfig } from '@/lvgl-widgets/type'
-import { klona } from 'klona'
+import WidgetLibary from './components/WidgetLibary.vue'
+import ComponentLibary from './components/ComponentLibary.vue'
 
-const search = ref('')
-const activeCollapse = ref<string[]>([])
-const projectStore = useProjectStore()
-
-const groupMap = ref<{
-  [key: string]: {
-    label: string
-    items: IComponentModelConfig[]
-  }
-}>({})
-
-// 变量全部控件
-ComponentArray.filter((item) => !item.hideLibary).forEach((item) => {
-  if (!groupMap.value[item.group]) {
-    groupMap.value[item.group] = {
-      label: item.group,
-      items: []
-    }
-  }
-  if (!item?.hideLibary) {
-    groupMap.value[item.group].items.push(item)
-  }
-
-  return item.group
-})
-
-// 根据搜索条件获取分组信息
-const getGroups = computed(() => {
-  const list = klona(
-    Object.values(groupMap.value).filter((item) =>
-      item.items.some((item) => item.label.includes(search.value))
-    )
-  )
-
-  list.forEach((item) => {
-    item.items = item.items.filter((item) => item.label.includes(search.value))
-  })
-
-  activeCollapse.value = list.map((item) => item.label)
-
-  return list
-})
-
-// 处理点击添加控件
-function handleAdd(item: IComponentModelConfig) {
-  const page = projectStore.activePage
-  const index = getAddWidgetIndex(page!, item.key)
-  const newWidget = createWidget(item, index)
-  // 查找当前screen
-  const screen = projectStore.project?.screens.find(
-    (screen) => !!screen.pages.find((p) => page?.id === p.id)
-  )
-  // 随机位置
-  const r = Math.floor(Math.random() * 100)
-  const width = newWidget.props.width || 0
-  const height = newWidget.props.height || 0
-  if (screen) {
-    newWidget.props.x = Math.round(screen.width / 2 - width / 2 + r)
-    newWidget.props.y = Math.round(screen.height / 2 - height / 2 + r)
-  }
-
-  projectStore.activePage?.children?.unshift(newWidget)
-  projectStore.setSelectWidgets([newWidget])
-}
+const activeTab = ref('widget')
 </script>
 
 <style scoped lang="less">

+ 44 - 0
src/renderer/src/views/designer/sidebar/components/CompLibaryItem.vue

@@ -0,0 +1,44 @@
+<template>
+  <div
+    ref="libaryItemRef"
+    class="p-8px text-center border border-solid border-border rounded-md flex flex-col items-center justify-center cursor-pointer"
+  >
+    <el-tooltip :content="comp.description">
+      <div class="bg-gray-900 w-80px h-80px rounded-md flex justify-center items-center">
+        <img :src="img" class="object-fit-cover" width="60px" height="60px" alt="custom comp" />
+      </div>
+    </el-tooltip>
+    <div class="text-xs truncate mt-1">
+      {{ comp.name }}
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { useDrag } from 'vue-hooks-plus'
+
+import type { ICustomWidget } from '@/types'
+
+const props = defineProps<{
+  comp: ICustomWidget
+}>()
+
+const img = computed(() => {
+  return props.comp.iconFile ? URL.createObjectURL(props.comp.iconFile) : ''
+})
+
+const libaryItemRef = ref<HTMLElement>()
+const draging = ref(false)
+
+useDrag(props.comp.widget, libaryItemRef, {
+  onDragStart: () => {
+    draging.value = true
+  },
+  onDragEnd: () => {
+    draging.value = false
+  }
+})
+</script>
+
+<style scoped></style>

+ 72 - 0
src/renderer/src/views/designer/sidebar/components/ComponentLibary.vue

@@ -0,0 +1,72 @@
+<template>
+  <div>
+    <div
+      class="px-2 pb-2 pt-1 grid grid-cols-[repeat(auto-fill,minmax(70px,1fr))] gap-row-2 gap-10px"
+    >
+      <CompLibaryItem
+        v-for="item in components"
+        :key="item.key"
+        :comp="item.value"
+        @click="handleAdd(item.value)"
+      />
+    </div>
+    <el-empty
+      v-if="!components.length"
+      description="暂无数据,右键选择自定义组件创建"
+      :image-size="80"
+    ></el-empty>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onBeforeUnmount } from 'vue'
+import { useEventBus } from '@vueuse/core'
+import { getAllIDBData } from '@/utils/database'
+import { useProjectStore } from '@/store/modules/project'
+import { createWidget } from '@/model'
+
+import CompLibaryItem from './CompLibaryItem.vue'
+
+import type { ICustomWidget } from '@/types'
+
+const components = ref<{ key: string; value: ICustomWidget }[]>([])
+const addedCustomCompBus = useEventBus('created-custom-comp')
+const projectStore = useProjectStore()
+
+const getAllData = async () => {
+  const data = await getAllIDBData()
+  console.log(data)
+  components.value = data || []
+}
+
+const handleAdd = (item: ICustomWidget) => {
+  const newWidget = createWidget(item.widget, 1, true)
+  const page = projectStore.activePage
+  // 查找当前screen
+  const screen = projectStore.project?.screens.find(
+    (screen) => !!screen.pages.find((p) => page?.id === p.id)
+  )
+  // 随机位置
+  const r = Math.floor(Math.random() * 100)
+  const width = newWidget.props?.width || 0
+  const height = newWidget.props?.height || 0
+  if (screen) {
+    newWidget.props.x = Math.round(screen.width / 2 - width / 2 + r)
+    newWidget.props.y = Math.round(screen.height / 2 - height / 2 + r)
+  }
+
+  projectStore.activePage?.children?.push(newWidget)
+  projectStore.setSelectWidgets([newWidget])
+}
+
+const unsubscribe = addedCustomCompBus.on(() => {})
+
+onMounted(() => {
+  getAllData()
+})
+onBeforeUnmount(() => {
+  unsubscribe()
+})
+</script>
+
+<style scoped></style>

+ 113 - 0
src/renderer/src/views/designer/sidebar/components/WidgetLibary.vue

@@ -0,0 +1,113 @@
+<template>
+  <el-input
+    v-model="search"
+    placeholder="请输入组件名称"
+    class="m-8px"
+    style="width: calc(100% - 16px)"
+  />
+  <el-collapse v-model="activeCollapse">
+    <el-collapse-item
+      v-for="group in getGroups"
+      :key="group.label"
+      :title="group.label"
+      :name="group.label"
+    >
+      <div class="px-2 pb-2 pt-1 grid grid-cols-[repeat(auto-fill,minmax(70px,1fr))] gap-row-2">
+        <LibaryItem
+          v-for="item in group.items"
+          :key="item.key"
+          :comp="item"
+          @click="handleAdd(item)"
+        />
+      </div>
+    </el-collapse-item>
+  </el-collapse>
+  <el-empty v-if="!getGroups.length" :image-size="80">
+    <template #description>
+      <div>没有找到组件</div>
+    </template>
+  </el-empty>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { ComponentArray } from '@/lvgl-widgets'
+import LibaryItem from './LibaryItem.vue'
+import { createWidget } from '@/model'
+import { getAddWidgetIndex } from '@/utils'
+import { useProjectStore } from '@/store/modules/project'
+
+import type { IComponentModelConfig } from '@/lvgl-widgets/type'
+import { klona } from 'klona'
+
+const search = ref('')
+const activeCollapse = ref<string[]>([])
+const projectStore = useProjectStore()
+
+const groupMap = ref<{
+  [key: string]: {
+    label: string
+    items: IComponentModelConfig[]
+  }
+}>({})
+
+// 变量全部控件
+ComponentArray.filter((item) => !item.hideLibary).forEach((item) => {
+  if (!groupMap.value[item.group]) {
+    groupMap.value[item.group] = {
+      label: item.group,
+      items: []
+    }
+  }
+  if (!item?.hideLibary) {
+    groupMap.value[item.group].items.push(item)
+  }
+
+  return item.group
+})
+
+// 根据搜索条件获取分组信息
+const getGroups = computed(() => {
+  const list = klona(
+    Object.values(groupMap.value).filter((item) =>
+      item.items.some((item) => item.label.includes(search.value))
+    )
+  )
+
+  list.forEach((item) => {
+    item.items = item.items.filter((item) => item.label.includes(search.value))
+  })
+
+  activeCollapse.value = list.map((item) => item.label)
+
+  return list
+})
+
+// 处理点击添加控件
+function handleAdd(item: IComponentModelConfig) {
+  const page = projectStore.activePage
+  const index = getAddWidgetIndex(page!, item.key)
+  const newWidget = createWidget(item, index)
+  // 查找当前screen
+  const screen = projectStore.project?.screens.find(
+    (screen) => !!screen.pages.find((p) => page?.id === p.id)
+  )
+  // 随机位置
+  const r = Math.floor(Math.random() * 100)
+  const width = newWidget.props.width || 0
+  const height = newWidget.props.height || 0
+  if (screen) {
+    newWidget.props.x = Math.round(screen.width / 2 - width / 2 + r)
+    newWidget.props.y = Math.round(screen.height / 2 - height / 2 + r)
+  }
+
+  projectStore.activePage?.children?.push(newWidget)
+  projectStore.setSelectWidgets([newWidget])
+}
+</script>
+
+<style scoped lang="less">
+::v-deep(.el-collapse-item__content) {
+  padding: 0;
+}
+</style>

+ 34 - 4
src/renderer/src/views/designer/workspace/stage/ContentMenu.vue

@@ -33,7 +33,7 @@
 <script setup lang="ts">
 import type { DropdownInstance } from 'element-plus'
 
-import { ref } from 'vue'
+import { onBeforeUnmount, ref } from 'vue'
 import {
   LuCopy,
   LuCopyPlus,
@@ -47,19 +47,40 @@ import {
   LuArrowDownToLine,
   LuArrowUp,
   LuArrowDown,
-  LuZap
+  LuZap,
+  LuStar
 } from 'vue-icons-plus/lu'
 
+import { useEventBus } from '@vueuse/core'
 import { useActionStore } from '@/store/modules/action'
 
-defineProps<{
+const props = defineProps<{
+  id: string
   virtualRef: any
   widgetType: 'page' | 'widget'
 }>()
 
 const dropdownRef = ref<DropdownInstance>()
-
 const actionStore = useActionStore()
+const bus = useEventBus('context-menu')
+const createCustomCompBus = useEventBus('create-custom-comp')
+
+const unsubscribe = bus.on((_event, id: string) => {
+  if (id !== props.id) {
+    dropdownRef.value?.handleClose()
+  }
+})
+
+onBeforeUnmount(() => {
+  unsubscribe()
+})
+
+/**
+ * 创建自定义组件
+ */
+const createCustomComponent = () => {
+  createCustomCompBus.emit('create', props.id)
+}
 
 const widgetMenus = [
   {
@@ -155,6 +176,14 @@ const widgetMenus = [
     icon: LuZap,
     divider: true,
     onclick: () => actionStore.onShowEvent()
+  },
+  {
+    label: '创建自定义组件',
+    value: 'custom-component',
+    fastKey: '',
+    icon: LuStar,
+    divider: true,
+    onclick: createCustomComponent
   }
 ]
 
@@ -202,6 +231,7 @@ defineExpose({
     dropdownRef.value?.handleClose()
   },
   handleOpen: () => {
+    bus.emit('open', props.id)
     dropdownRef.value?.handleOpen()
   }
 })

+ 14 - 13
src/renderer/src/views/designer/workspace/stage/Node.vue

@@ -32,8 +32,9 @@
     </div>
   </div>
   <!-- 右键菜单 -->
-  <ContentMenu
-    ref="contentMenuRef"
+  <ContextMenu
+    ref="contextMenuRef"
+    :id="schema.id"
     :virtualRef="triggerRef"
     :widgetType="schema.type === 'page' ? 'page' : 'widget'"
   />
@@ -54,7 +55,7 @@ import { useAppStore } from '@/store/modules/app'
 import { useActionStore } from '@/store/modules/action'
 import { has, isEmpty } from 'lodash-es'
 
-import ContentMenu from './ContentMenu.vue'
+import ContextMenu from './ContextMenu.vue'
 import { getAddWidgetIndex, isDescendant } from '@/utils'
 import { klona } from 'klona'
 
@@ -183,6 +184,7 @@ const layoutStyle = computed((): CSSProperties => {
     }
     return {
       display: 'flex',
+      overflow: 'hidden',
       flexDirection: directionMap?.[flex?.direction],
       flexWrap: flex?.direction?.includes('wrap') ? 'wrap' : 'nowrap',
       justifyContent: flex?.mainAxisAlign,
@@ -224,16 +226,16 @@ useDrop(nodeRef, {
 
     // 创建控件
     const { offsetX = 0, offsetY = 0 } = event || {}
-    const index = getAddWidgetIndex(page!, content.key)
-    const newWidget = createWidget(content, index)
+    const index = getAddWidgetIndex(page!, content?.key)
+    const newWidget = createWidget(content, index, !!content?.id)
     newWidget.props.x = offsetX
     newWidget.props.y = offsetY
 
     // 添加到前面
     if (has(schema.props, 'activeIndex')) {
-      schema.children[schema.props.activeIndex].children?.unshift(newWidget)
+      schema.children[schema.props.activeIndex].children?.push(newWidget)
     } else {
-      schema.children?.unshift(newWidget)
+      schema.children?.push(newWidget)
     }
 
     projectStore.setSelectWidgets([newWidget])
@@ -280,9 +282,9 @@ watch(nodeState, (state) => {
       actionStore.onDeleteById(node.id)
       // 添加到前面
       if (has(schema.props, 'activeIndex')) {
-        schema.children[schema.props.activeIndex].children?.unshift(node)
+        schema.children[schema.props.activeIndex].children?.push(node)
       } else {
-        schema.children?.unshift(node)
+        schema.children?.push(node)
       }
       projectStore.activeWidgets = [node]
     }
@@ -356,7 +358,7 @@ const handleSelect = (e: MouseEvent) => {
     projectStore.setSelectWidgets([])
   }
   // 关闭右键菜单
-  contentMenuRef.value?.handleClose()
+  contextMenuRef.value?.handleClose()
 }
 
 /******************************右键菜单*********************************/
@@ -367,7 +369,7 @@ const position = ref({
   right: 0
 } as DOMRect)
 
-const contentMenuRef = ref<InstanceType<typeof ContentMenu>>()
+const contextMenuRef = ref<InstanceType<typeof ContextMenu>>()
 
 const triggerRef = ref({
   getBoundingClientRect: () => position.value
@@ -381,8 +383,7 @@ const handleContextmenu = (event: MouseEvent) => {
     y: clientY
   })
   event.preventDefault()
-  contentMenuRef.value?.handleOpen()
-  // todo 关闭其他菜单
+  contextMenuRef.value?.handleOpen()
 
   // 没选中当前节点时 右键选中节点
   if (!projectStore.activeWidgets.map((item) => item.id).includes(props.schema.id)) {