소스 검색

pref: 优化卡顿问题

jiaxing.liao 1 개월 전
부모
커밋
1e18a1dc59
28개의 변경된 파일522개의 추가작업 그리고 209개의 파일을 삭제
  1. 1 2
      src/renderer/src/components/LocalImage/index.vue
  2. 37 16
      src/renderer/src/hooks/useHistory.ts
  3. 4 3
      src/renderer/src/lvgl-widgets/ImageBg.vue
  4. 3 3
      src/renderer/src/lvgl-widgets/analog-clock/AnalogClock.vue
  5. 2 3
      src/renderer/src/lvgl-widgets/animimg/Animimg.vue
  6. 3 3
      src/renderer/src/lvgl-widgets/base-meter/BaseMeter.vue
  7. 3 10
      src/renderer/src/lvgl-widgets/base-meter/NeedlesConfig.vue
  8. 1 3
      src/renderer/src/lvgl-widgets/button-matrix/ButtonMatrix.vue
  9. 6 11
      src/renderer/src/lvgl-widgets/canvas/Canvas.vue
  10. 11 3
      src/renderer/src/lvgl-widgets/hooks/useLongMode.ts
  11. 27 26
      src/renderer/src/lvgl-widgets/hooks/useWidgetStyle.ts
  12. 5 1
      src/renderer/src/lvgl-widgets/image-button/ImageButton.vue
  13. 2 2
      src/renderer/src/lvgl-widgets/image/Image.vue
  14. 1 3
      src/renderer/src/lvgl-widgets/image/index.ts
  15. 2 3
      src/renderer/src/lvgl-widgets/roller/Config.vue
  16. 5 9
      src/renderer/src/lvgl-widgets/spinner/Spinner.vue
  17. 73 31
      src/renderer/src/store/modules/project.ts
  18. 44 4
      src/renderer/src/views/designer/config/property/CusFormItem.vue
  19. 6 0
      src/renderer/src/views/designer/config/property/components/CusTextarea.vue
  20. 2 5
      src/renderer/src/views/designer/config/property/index.vue
  21. 12 0
      src/renderer/src/views/designer/sidebar/Hierarchy.vue
  22. 140 7
      src/renderer/src/views/designer/sidebar/Resource.vue
  23. 51 4
      src/renderer/src/views/designer/sidebar/Schema.vue
  24. 28 42
      src/renderer/src/views/designer/sidebar/components/ResourceItem.vue
  25. 4 4
      src/renderer/src/views/designer/sidebar/index.vue
  26. 2 2
      src/renderer/src/views/designer/workspace/stage/DesignerCanvas.vue
  27. 31 7
      src/renderer/src/views/designer/workspace/stage/Moveable.vue
  28. 16 2
      src/renderer/src/views/designer/workspace/stage/Node.vue

+ 1 - 2
src/renderer/src/components/LocalImage/index.vue

@@ -19,8 +19,7 @@ const projectStore = useProjectStore()
 const readFile = async () => {
   if (!props.src && !props.id) return
   if (props.id) {
-    const path =
-      projectStore.project?.resources.images.find((item) => item.id === props.id)?.path || ''
+    const path = projectStore.imagePathMap[props.id] || ''
     imageSrc.value = projectStore.projectPath + path
   } else {
     imageSrc.value = ''

+ 37 - 16
src/renderer/src/hooks/useHistory.ts

@@ -1,16 +1,16 @@
 import { ref, computed } from 'vue'
 import { useDebouncedRefHistory, useManualRefHistory } from '@vueuse/core'
 import { klona } from 'klona'
-import { isEqual } from 'lodash'
 
 import type { IProject } from '@/store/modules/project'
 import type { BaseWidget } from '@/types/baseWidget'
 
-const DEBOUNCE_TIME = 300
+const DEBOUNCE_TIME = 800
 const MAX_HISTORY_LENGTH = 20
 
 export const useHistory = () => {
   const project = ref<IProject>()
+  const projectRevision = ref(0)
   const activePageId = ref<string>()
   const openPageIds = ref<string[]>([])
   const activeWidgets = ref<BaseWidget[]>([])
@@ -34,35 +34,53 @@ export const useHistory = () => {
   })
 
   // 历史记录
+  const projectHistory = useDebouncedRefHistory(project, {
+    capacity: MAX_HISTORY_LENGTH,
+    clone: klona,
+    debounce: DEBOUNCE_TIME,
+    deep: true,
+    flush: 'post',
+    shouldCommit: () => {
+      operationHistory.commit()
+      projectRevision.value += 1
+      return true
+    }
+  })
+
   const {
     history,
     undo: _undo,
     redo: _redo,
     canRedo,
     canUndo,
-    clear,
-    last
-  } = useDebouncedRefHistory(project, {
-    capacity: MAX_HISTORY_LENGTH,
-    clone: klona,
-    debounce: DEBOUNCE_TIME,
-    deep: true,
-    shouldCommit: (_, newValue) => {
-      const should = !isEqual(newValue, last.value?.snapshot)
-      if (should) {
-        operationHistory.commit()
-      }
-      return should
+    clear
+  } = projectHistory
+
+  let pauseDepth = 0
+
+  const pauseHistory = () => {
+    if (pauseDepth === 0) {
+      projectHistory.pause()
     }
-  })
+    pauseDepth = 1
+  }
+
+  const resumeHistory = (commit = true) => {
+    if (pauseDepth === 0) return
+
+    pauseDepth = 0
+    projectHistory.resume(commit)
+  }
 
   const undo = () => {
     _undo()
     operationHistory.undo()
+    projectRevision.value += 1
   }
   const redo = () => {
     _redo()
     operationHistory.redo()
+    projectRevision.value += 1
   }
 
   /**
@@ -82,9 +100,12 @@ export const useHistory = () => {
     canRedo,
     canUndo,
     clear,
+    pauseHistory,
+    resumeHistory,
 
     initHistory,
     project,
+    projectRevision,
     activePageId,
     openPageIds,
     activeWidgets

+ 4 - 3
src/renderer/src/lvgl-widgets/ImageBg.vue

@@ -71,8 +71,9 @@ const maskStyle = computed((): CSSProperties => {
 const getImgSrc = computed(() => {
   const { id, src } = props
   if (src) return src
-  const imgResource = projectStore.project?.resources.images.find((img) => img.id === id)
-  if (!imgResource) return
-  return `local:///${(projectStore.projectPath + imgResource.path).replaceAll('\\', '/')}`
+  if (!id) return
+  const imagePath = projectStore.imagePathMap[id]
+  if (!imagePath) return
+  return `local:///${(projectStore.projectPath + imagePath).replaceAll('\\', '/')}`
 })
 </script>

+ 3 - 3
src/renderer/src/lvgl-widgets/analog-clock/AnalogClock.vue

@@ -391,9 +391,9 @@ const isTransparentColor = (color?: string) => {
 
 const getImageSrc = (imageId?: string) => {
   if (!imageId) return ''
-  const image = projectStore.project?.resources.images.find((item) => item.id === imageId)
-  if (!image) return ''
-  return `local:///${(projectStore.projectPath + image.path).replaceAll('\\', '/')}`
+  const imagePath = projectStore.imagePathMap[imageId]
+  if (!imagePath) return ''
+  return `local:///${(projectStore.projectPath + imagePath).replaceAll('\\', '/')}`
 }
 
 const mainImageSrc = computed(() => String(styleMap.value?.mainStyle?.imageSrc ?? ''))

+ 2 - 3
src/renderer/src/lvgl-widgets/animimg/Animimg.vue

@@ -51,7 +51,7 @@ const styleMap = useWidgetStyle({
 const frameSources = computed(() => {
   return (props.images || [])
     .map((id) => {
-      const imagePath = projectStore.project?.resources.images.find((item) => item.id === id)?.path
+      const imagePath = projectStore.imagePathMap[id]
       if (!imagePath) return ''
       return `local:///${(projectStore.projectPath + imagePath).replaceAll('\\', '/')}`
     })
@@ -185,7 +185,7 @@ const startPlayback = () => {
 
 watch(
   () => [
-    props.images,
+    props.images.join('|'),
     props.time,
     props.repeatCount,
     props.playback,
@@ -199,7 +199,6 @@ watch(
     startPlayback()
   },
   {
-    deep: true,
     immediate: true
   }
 )

+ 3 - 3
src/renderer/src/lvgl-widgets/base-meter/BaseMeter.vue

@@ -196,9 +196,9 @@ const isTransparentColor = (color?: string) => {
 
 const getImageSrc = (imageId?: string) => {
   if (!imageId) return ''
-  const image = projectStore.project?.resources.images.find((item) => item.id === imageId)
-  if (!image) return ''
-  return `local:///${(projectStore.projectPath + image.path).replaceAll('\\', '/')}`
+  const imagePath = projectStore.imagePathMap[imageId]
+  if (!imagePath) return ''
+  return `local:///${(projectStore.projectPath + imagePath).replaceAll('\\', '/')}`
 }
 
 const width = computed(() => Math.max(1, Number(props.width) || 1))

+ 3 - 10
src/renderer/src/lvgl-widgets/base-meter/NeedlesConfig.vue

@@ -43,14 +43,7 @@
         </el-form-item>
 
         <el-form-item :label="TEXT.type">
-          <el-select v-model="formData.type">
-            <el-option
-              v-for="option in pointerTypeOptions"
-              :key="option.value"
-              :label="option.label"
-              :value="option.value"
-            />
-          </el-select>
+          <el-select-v2 v-model="formData.type" :options="pointerTypeOptions"> </el-select-v2>
         </el-form-item>
 
         <el-form-item :label="TEXT.value">
@@ -195,12 +188,12 @@ const TEXT = {
   lineColor: '指针颜色',
   round: '圆角开关',
   confirm: '确定'
-} as const
+}
 
 const pointerTypeOptions = [
   { label: '图片指针', value: 'image' },
   { label: '线指针', value: 'line' }
-] as const
+]
 
 const props = defineProps<{
   values: Ref<BaseMeterNeedle[]>

+ 1 - 3
src/renderer/src/lvgl-widgets/button-matrix/ButtonMatrix.vue

@@ -96,9 +96,7 @@ const getBtnStyle = (btnItem: ButtonItem) => {
 
     if (key === 'background' && style?.[key]?.image?.imgId) {
       const basePath = projectStore?.projectPath
-      const imagePath = projectStore?.project?.resources.images.find(
-        (item) => item.id === style?.[key]?.image?.imgId
-      )?.path
+      const imagePath = projectStore.imagePathMap[style?.[key]?.image?.imgId]
       if (basePath && imagePath) {
         styleMap.imageSrc = `local:///${(basePath + imagePath).replaceAll('\\', '/')}`
         styleMap.imageStyle = {

+ 6 - 11
src/renderer/src/lvgl-widgets/canvas/Canvas.vue

@@ -191,12 +191,9 @@ const imageElements = computed(() => {
     .map((el) => {
       let src = ''
       const id = el.props.image
-      const project = projectStore.project
-      if (id && project) {
-        const imgRes = project.resources.images.find((img) => img.id === id)
-        if (imgRes) {
-          src = `local:///${(projectStore.projectPath + imgRes.path).replaceAll('\\', '/')}`
-        }
+      const imagePath = id ? projectStore.imagePathMap[id] : ''
+      if (imagePath) {
+        src = `local:///${(projectStore.projectPath + imagePath).replaceAll('\\', '/')}`
       }
       return { ...el, src }
     })
@@ -215,11 +212,9 @@ const getCanvasTextStyle = (textProps: TextProps) => {
     textProps.font_family &&
     textProps.font_family !== 'montserratMedium'
   ) {
-    const font = projectStore.project?.resources.fonts.find(
-      (item) => item.id === textProps.font_family
-    )
-    if (font?.fileName) {
-      resolvedStyle.fontFamily = `'${font.fileName}'`
+    const fontName = projectStore.fontNameMap[textProps.font_family]
+    if (fontName) {
+      resolvedStyle.fontFamily = `'${fontName}'`
     }
   }
 

+ 11 - 3
src/renderer/src/lvgl-widgets/hooks/useLongMode.ts

@@ -72,13 +72,21 @@ export const useLongMode = (
   }
   // 处理long_mode
   watch(
-    () => props,
+    () => [
+      props.longMode,
+      props.width,
+      props.height,
+      props.text,
+      boxEl.value?.clientWidth,
+      boxEl.value?.clientHeight,
+      el.value?.clientWidth,
+      el.value?.clientHeight
+    ],
     () => {
       handleLongMode()
     },
     {
-      immediate: true,
-      deep: true
+      immediate: true
     }
   )
 

+ 27 - 26
src/renderer/src/lvgl-widgets/hooks/useWidgetStyle.ts

@@ -1,7 +1,7 @@
 import componentMap from '..'
 import { useProjectStore } from '@/store/modules/project'
 import { assign } from 'lodash-es'
-import { ref, watch } from 'vue'
+import { computed, ref, watch } from 'vue'
 import { generateCssGradient } from '@/utils'
 import { DEFAULT_THEME_KEY } from '@/constants'
 
@@ -74,10 +74,7 @@ export const getStyle = (key, value, options?: any) => {
 
       // 处理字体
       if (value?.family && value?.family !== 'montserratMedium') {
-        const font = useProjectStore().project?.resources?.fonts?.find(
-          (item) => item.id === value.family
-        )
-        style.fontFamily = `'${font?.fileName}'`
+        style.fontFamily = `'${options?.fontNameMap?.[value.family] || ''}'`
       } else {
         style.fontFamily = 'montserratMedium'
       }
@@ -198,6 +195,9 @@ export const useWidgetStyle = (param: StyleParam) => {
   const parts = componentMap[widget].parts
   // 控件样式集合
   const styleMap = ref<StyleMap>({})
+  const globalWidgetStyle = computed(() =>
+    projectStore.globalStyle?.find((item) => item.widget === widget)
+  )
 
   /**
    * 获取控件兜底预设样式
@@ -222,16 +222,10 @@ export const useWidgetStyle = (param: StyleParam) => {
    * 获取控件默认样式
    */
   const getWidgetDefaultStyle = (partName: string, stateName?: string) => {
-    const widgetType = widget
+    const partStyle = globalWidgetStyle.value?.part?.find((item) => item.partName === partName)
+    const state = partStyle?.state
 
-    const state = projectStore.globalStyle
-      ?.find((item) => item.widget === widgetType)
-      ?.part?.find((item) => item.partName === partName)?.state
-
-    const defaultStyle =
-      projectStore.globalStyle
-        ?.find((item) => item.widget === widgetType)
-        ?.part?.find((item) => item.partName === partName)?.defaultStyle || {}
+    const defaultStyle = partStyle?.defaultStyle || {}
 
     const stateStyle = state?.find((item) => item.state === stateName)?.style || {}
 
@@ -241,9 +235,7 @@ export const useWidgetStyle = (param: StyleParam) => {
   const getImageSrc = (imgId: string | undefined) => {
     if (!imgId) return ''
     const basePath = projectStore?.projectPath
-    const imagePath = projectStore?.project?.resources.images.find(
-      (item) => item.id === imgId
-    )?.path
+    const imagePath = projectStore.imagePathMap[imgId]
     if (basePath && imagePath) {
       return `local:///${(basePath + imagePath).replaceAll('\\', '/')}`
     }
@@ -291,7 +283,8 @@ export const useWidgetStyle = (param: StyleParam) => {
           styleMap.value[`${partItem.name}Style`],
           getStyle(key, style?.[key], {
             width: props?.width,
-            height: props?.height
+            height: props?.height,
+            fontNameMap: projectStore.fontNameMap
           })
         )
 
@@ -341,15 +334,23 @@ export const useWidgetStyle = (param: StyleParam) => {
     })
   }
 
-  watch(() => param.props, handleStyle, {
-    deep: true,
-    immediate: true
-  })
-
   watch(
-    () => projectStore.project?.currentTheme,
-    () => {
-      handleStyle()
+    () => [
+      param.props.part,
+      param.props.state,
+      param.props.styles,
+      param.props.width,
+      param.props.height,
+      projectStore.project?.currentTheme,
+      projectStore.projectPath,
+      projectStore.imageResourceSignature,
+      projectStore.fontResourceSignature,
+      globalWidgetStyle.value
+    ],
+    handleStyle,
+    {
+      deep: true,
+      immediate: true
     }
   )
 

+ 5 - 1
src/renderer/src/lvgl-widgets/image-button/ImageButton.vue

@@ -58,7 +58,11 @@ watch(
     if (val && projectStore.project) {
       // 加载图片
       const basePath = projectStore.projectPath
-      const imagePath = projectStore.project.resources.images.find((item) => item.id === val)?.path
+      const imagePath = projectStore.imagePathMap[val]
+      if (!imagePath) {
+        src.value = ''
+        return
+      }
       const result = await getImageByPath(basePath + imagePath)
       src.value = result?.src!
     } else {

+ 2 - 2
src/renderer/src/lvgl-widgets/image/Image.vue

@@ -68,8 +68,8 @@ watch(
     if (val && projectStore.project) {
       // 加载图片
       const basePath = projectStore.projectPath
-      const imagePath = projectStore.project.resources.images.find((item) => item.id === val)?.path
-      src.value = `local:///${(basePath + imagePath).replaceAll('\\', '/')}`
+      const imagePath = projectStore.imagePathMap[val]
+      src.value = imagePath ? `local:///${(basePath + imagePath).replaceAll('\\', '/')}` : ''
     }
   },
   {

+ 1 - 3
src/renderer/src/lvgl-widgets/image/index.ts

@@ -200,9 +200,7 @@ export default {
             // 根据图片信息设置控件宽高
             if (value) {
               const projectStore = useProjectStore()
-              const imgPath = projectStore.project?.resources.images.find(
-                (item) => item.id === value
-              )?.path
+              const imgPath = projectStore.imagePathMap[value]
               if (imgPath) {
                 getImageByPath(projectStore.projectPath + imgPath).then((res) => {
                   if (res?.dimensions.height) {

+ 2 - 3
src/renderer/src/lvgl-widgets/roller/Config.vue

@@ -116,12 +116,11 @@ const normalizeSelected = (nextOptions = options.value) => {
 }
 
 watch(
-  options,
+  () => options.value.join('|'),
   (list) => {
-    normalizeSelected(list)
+    normalizeSelected(options.value)
   },
   {
-    deep: true,
     immediate: true
   }
 )

+ 5 - 9
src/renderer/src/lvgl-widgets/spinner/Spinner.vue

@@ -29,7 +29,7 @@
           x="0"
           y="0"
         >
-          <image :href="styleMap.mainStyle.curve.imageSrc" transform="translate(-50%, -50%)" />
+          <image :href="styleMap.mainStyle.curve.imageSrc" transform="translate(-50%,-50%)" />
         </pattern>
 
         <!-- 指示条图片 pattern:保持图像原始尺寸,居中显示 -->
@@ -42,10 +42,7 @@
           x="0"
           y="0"
         >
-          <image
-            :href="styleMap.indicatorStyle.curve.imageSrc"
-            transform="translate(-50%, -50%)"
-          />
+          <image :href="styleMap.indicatorStyle.curve.imageSrc" transform="translate(-50%, -50%)" />
         </pattern>
       </defs>
 
@@ -73,9 +70,9 @@
             ? `url(#${spinnerId}-indicator-pattern)`
             : styleMap?.indicatorStyle?.curve?.color || '#2092f5'
         "
-        :stroke-width="styleMap?.indicatorStyle?.curve?.width ??
-          styleMap?.mainStyle?.curve?.width ??
-          1"
+        :stroke-width="
+          styleMap?.indicatorStyle?.curve?.width ?? styleMap?.mainStyle?.curve?.width ?? 1
+        "
         :stroke-linecap="styleMap?.indicatorStyle?.curve?.radius ? 'round' : 'butt'"
         :opacity="styleMap?.indicatorStyle?.curve?.opacity"
       />
@@ -202,4 +199,3 @@ const svgStyle = computed(() => {
   }
 }
 </style>
-

+ 73 - 31
src/renderer/src/store/modules/project.ts

@@ -53,7 +53,10 @@ export const useProjectStore = defineStore('project', () => {
     canRedo,
     canUndo,
     clear,
+    pauseHistory,
+    resumeHistory,
     project,
+    projectRevision,
     openPageIds,
     activePageId,
     activeWidgets
@@ -63,6 +66,26 @@ export const useProjectStore = defineStore('project', () => {
   const projectDirectory = ref<string>()
   // 全局样式
   const globalStyle = ref<any[]>()
+  const imagePathMap = computed<Record<string, string>>(() => {
+    const map: Record<string, string> = {}
+    project.value?.resources.images.forEach((item) => {
+      map[item.id] = item.path
+    })
+    return map
+  })
+  const fontNameMap = computed<Record<string, string>>(() => {
+    const map: Record<string, string> = {}
+    project.value?.resources.fonts.forEach((item) => {
+      map[item.id] = item.fileName
+    })
+    return map
+  })
+  const imageResourceSignature = computed(() =>
+    project.value?.resources.images.map((item) => `${item.id}:${item.path}`).join('|')
+  )
+  const fontResourceSignature = computed(() =>
+    project.value?.resources.fonts.map((item) => `${item.id}:${item.fileName}`).join('|')
+  )
 
   const recentProjectStore = useRecentProject()
   const { t } = useI18n()
@@ -118,47 +141,59 @@ export const useProjectStore = defineStore('project', () => {
     }
   )
 
-  const checkNotLoadFont = (fontName: string) => {
-    return new Promise((resolve, reject) => {
-      document.fonts.ready.then(function () {
-        document.fonts.forEach(function (fontFace) {
-          if (fontFace.family === fontName) {
-            reject()
-          }
-        })
+  const loadedFontKeys = new Set<string>()
 
-        resolve(true)
-      })
+  const hasLoadedFont = async (fontName: string) => {
+    await document.fonts.ready
+    let loaded = false
+    document.fonts.forEach((fontFace) => {
+      if (fontFace.family === fontName) {
+        loaded = true
+      }
     })
+    return loaded
   }
 
   watch(
-    () => project.value?.resources.fonts,
-    async (fonts?: FontResource[]) => {
+    () =>
+      [
+        projectPath.value,
+        project.value?.resources.fonts
+          .map((font) => `${font.id}:${font.fileName}:${font.path}`)
+          .join('|')
+      ].join('||'),
+    async () => {
       // 动态加载全部字体
       // 判断字体是否已经加载
-      const fontPromises = fonts
-        ?.filter(async (font) => await checkNotLoadFont(font.fileName))
-        .map((font) => {
-          const fontFace = new FontFace(
-            font.fileName,
-            `url('local:///${projectPath.value + font.path}')`.replaceAll('\\', '/')
-          )
-          return fontFace.load()
-        })
+      const fontPromises = (project.value?.resources.fonts || []).map(async (font) => {
+        const fontUrl = `local:///${projectPath.value + font.path}`.replaceAll('\\', '/')
+        const fontKey = `${font.fileName}:${fontUrl}`
+        if (loadedFontKeys.has(fontKey) || (await hasLoadedFont(font.fileName))) {
+          return
+        }
 
-      if (fontPromises?.length) {
-        Promise.all(fontPromises)
-          .then((loadedFonts) => {
-            console.log('已加载字体:', loadedFonts)
-            loadedFonts.forEach((font) => document.fonts.add(font))
-            console.log('所有字体已加载并可用')
-          })
-          .catch((err) => console.error('字体加载失败:', err))
+        const fontFace = new FontFace(font.fileName, `url('${fontUrl}')`)
+        try {
+          const loadedFont = await fontFace.load()
+          loadedFontKeys.add(fontKey)
+          return loadedFont
+        } catch (err) {
+          console.error('字体加载失败:', err)
+          return
+        }
+      })
+
+      const loadedFonts = (await Promise.all(fontPromises)).filter((font): font is FontFace => {
+        return !!font
+      })
+
+      if (loadedFonts.length) {
+        loadedFonts.forEach((font) => document.fonts.add(font))
+        console.log('已加载字体:', loadedFonts)
+        console.log('所有字体已加载并可用')
       }
     },
     {
-      deep: true,
       immediate: true
     }
   )
@@ -511,6 +546,7 @@ export const useProjectStore = defineStore('project', () => {
   return {
     createApp,
     project,
+    projectRevision,
     activePageId,
     activePage,
     activeWidget,
@@ -525,6 +561,10 @@ export const useProjectStore = defineStore('project', () => {
     setSelectWidgets,
     activeWidgetMap,
     globalStyle,
+    imagePathMap,
+    fontNameMap,
+    imageResourceSignature,
+    fontResourceSignature,
     currentMaxScreen,
     activeScreen,
     getWidgetById,
@@ -539,6 +579,8 @@ export const useProjectStore = defineStore('project', () => {
     redo,
     canRedo,
     canUndo,
-    clear
+    clear,
+    pauseHistory,
+    resumeHistory
   }
 })

+ 44 - 4
src/renderer/src/views/designer/config/property/CusFormItem.vue

@@ -20,7 +20,8 @@
         :rows="1"
         resize="none"
         v-bind="componentProps"
-        @focus="syncTextSelection"
+        @focus="handleInputFocus"
+        @blur="handleInputBlur"
         @click="syncTextSelection"
         @keyup="syncTextSelection"
       >
@@ -41,6 +42,8 @@
         controls-position="right"
         style="width: 100%"
         v-bind="componentProps"
+        @focus="handleInputFocus"
+        @blur="handleInputBlur"
       >
         <template #prefix>
           {{ schema?.slots?.prefix }}
@@ -55,6 +58,7 @@
         :options="componentProps?.options || []"
         v-model="value"
         v-bind="componentProps"
+        @visible-change="handleSelectVisibleChange"
       >
         <template #prefix>
           {{ schema?.slots?.prefix }}
@@ -65,18 +69,34 @@
       </el-select-v2>
       <!-- 开关 -->
       <div v-if="schema.valueType === 'switch'" class="w-full flex justify-end">
-        <el-switch v-model="value" v-bind="componentProps" />
+        <el-switch
+          v-model="value"
+          v-bind="componentProps"
+          @change="projectStore.resumeHistory(true)"
+        />
       </div>
 
       <!-- 滑动条 -->
       <div v-if="schema.valueType === 'slider'" class="w-full flex gap-20px items-center">
-        <el-slider v-model="value" v-bind="componentProps" style="flex: 1"></el-slider>
+        <el-slider
+          v-model="value"
+          v-bind="componentProps"
+          style="flex: 1"
+          @input="projectStore.pauseHistory()"
+          @change="projectStore.resumeHistory(true)"
+        ></el-slider>
         <span class="text-text-active inline w-30px cursor-pointer">
           {{ value }}
         </span>
       </div>
       <!-- 文本框 -->
-      <CusTextarea v-if="schema.valueType === 'textarea'" v-model="value" v-bind="componentProps" />
+      <CusTextarea
+        v-if="schema.valueType === 'textarea'"
+        v-model="value"
+        v-bind="componentProps"
+        @focus="handleInputFocus"
+        @blur="handleInputBlur"
+      />
       <!-- 图片选择 -->
       <ImageSelect v-if="schema.valueType === 'image'" v-model="value" v-bind="componentProps" />
       <!-- 文件选择 -->
@@ -109,6 +129,7 @@
         v-model="value"
         style="width: 100%"
         v-bind="componentProps"
+        @visible-change="handleSelectVisibleChange"
       />
 
       <!-- 时间 -->
@@ -244,6 +265,7 @@ import type { CollapseModelValue } from 'element-plus'
 import { computed, defineComponent, nextTick, ref } from 'vue'
 import { get, set } from 'lodash-es'
 import { v4 } from 'uuid'
+import { useProjectStore } from '@/store/modules/project'
 
 import CusCheckbox from './components/CusCheckbox.vue'
 import CusTextarea from './components/CusTextarea.vue'
@@ -287,6 +309,7 @@ const props = defineProps<{
   widgetData?: Record<string, any>
 }>()
 const key = v4()
+const projectStore = useProjectStore()
 const textInputRef = ref()
 const languageModalRef = ref<InstanceType<typeof LanguageSelectModal>>()
 const textSelection = ref({
@@ -331,6 +354,23 @@ const syncTextSelection = () => {
   }
 }
 
+const handleInputFocus = () => {
+  projectStore.pauseHistory()
+  syncTextSelection()
+}
+
+const handleInputBlur = () => {
+  projectStore.resumeHistory(true)
+}
+
+const handleSelectVisibleChange = (visible: boolean) => {
+  if (visible) {
+    projectStore.pauseHistory()
+  } else {
+    projectStore.resumeHistory(true)
+  }
+}
+
 const insertLanguageToken = async (languageKey: string) => {
   const token = `#{${languageKey}}`
   const currentValue = String(value.value ?? '')

+ 6 - 0
src/renderer/src/views/designer/config/property/components/CusTextarea.vue

@@ -9,6 +9,7 @@
       :rows="rows"
       v-bind="$attrs"
       @focus="syncSelection"
+      @blur="emit('blur')"
       @click="syncSelection"
       @keyup="syncSelection"
     />
@@ -46,6 +47,10 @@ defineProps<{
 }>()
 
 const modelValue = defineModel<string>('modelValue')
+const emit = defineEmits<{
+  focus: []
+  blur: []
+}>()
 const textareaRef = ref()
 const symbolModalRef = ref<InstanceType<typeof SymbolSelectModal>>()
 const languageModalRef = ref<InstanceType<typeof LanguageSelectModal>>()
@@ -60,6 +65,7 @@ const getTextareaElement = () => {
 }
 
 const syncSelection = () => {
+  emit('focus')
   const textareaEl = getTextareaElement()
   if (!textareaEl) return
 

+ 2 - 5
src/renderer/src/views/designer/config/property/index.vue

@@ -250,15 +250,12 @@ watch(
 )
 
 watch(
-  () => part.value,
-  ({ name, state }) => {
+  () => [part.value.name, part.value.state],
+  ([name, state]) => {
     if (projectStore.activeWidget && name) {
       projectStore.activeWidget.part = name
       projectStore.activeWidget.state = state
     }
-  },
-  {
-    deep: true
   }
 )
 

+ 12 - 0
src/renderer/src/views/designer/sidebar/Hierarchy.vue

@@ -150,10 +150,22 @@ const collectCurrentAndDescendantNodeKeys = (node: HierarchyNodeData) => {
 
 const expandedNodeKeys = computed(() => [...expandedNodeKeySet.value])
 
+watch(
+  () => projectStore.project,
+  () => {
+    expandedNodeKeySet.value = new Set()
+    hasInitializedExpandedKeys.value = false
+  }
+)
+
 watch(
   hierarchyTreeData,
   (nodes) => {
     const nodeKeys = collectNodeKeys(nodes)
+    if (!nodeKeys.length) {
+      expandedNodeKeySet.value = new Set()
+      return
+    }
 
     if (!hasInitializedExpandedKeys.value) {
       expandedNodeKeySet.value = new Set(nodeKeys)

+ 140 - 7
src/renderer/src/views/designer/sidebar/Resource.vue

@@ -26,8 +26,14 @@
           <el-input spellcheck="false" v-model="imageSearch" size="small" placeholder="输入搜索..." />
         </div>
         <el-scrollbar class="flex-1">
-          <ResourceItem v-for="item in getImages || []" :key="item.id" :data="item" type="image"
-            @delete="deleteResource(item, 'images')" />
+          <ResourceItem
+            v-for="item in getImages || []"
+            :key="item.id"
+            :data="item"
+            type="image"
+            :image-use-count="imageUseCountMap[item.id] || 0"
+            @delete="deleteResource(item, 'images')"
+          />
           <div v-if="!getImages?.length" class="text-center text-text-secondary">暂无图片~</div>
         </el-scrollbar>
       </div>
@@ -75,8 +81,11 @@
 
 <script setup lang="ts">
 import type { FontResource, ImageResource, OtherResource, Resource } from '@/types/resource'
+import type { BaseWidget } from '@/types/baseWidget'
+import type { Page } from '@/types/page'
+import type { Screen } from '@/types/screen'
 
-import { ref, computed } from 'vue'
+import { computed, onBeforeUnmount, shallowRef, watch, ref } from 'vue'
 import { ElMessage } from 'element-plus'
 import { SplitterCollapse, SplitterCollapseItem } from '@/components/SplitterCollapse'
 import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
@@ -163,6 +172,131 @@ const getOthers = computed(() => {
 })
 
 // 添加图片
+const countImageRefs = (
+  value: unknown,
+  imageIds: Set<string>,
+  useCount: Record<string, number>
+) => {
+  if (!value) return
+
+  if (typeof value === 'string') {
+    if (imageIds.has(value)) {
+      useCount[value] = (useCount[value] || 0) + 1
+    }
+    return
+  }
+
+  if (Array.isArray(value)) {
+    value.forEach((item) => countImageRefs(item, imageIds, useCount))
+    return
+  }
+
+  if (typeof value === 'object') {
+    Object.values(value as Record<string, unknown>).forEach((item) =>
+      countImageRefs(item, imageIds, useCount)
+    )
+  }
+}
+
+const countWidgetImageRefs = (
+  widget: BaseWidget,
+  imageIds: Set<string>,
+  useCount: Record<string, number>
+) => {
+  countImageRefs(widget.props, imageIds, useCount)
+  countImageRefs(widget.style, imageIds, useCount)
+  countImageRefs(widget.events, imageIds, useCount)
+  widget.children?.forEach((child) => countWidgetImageRefs(child, imageIds, useCount))
+}
+
+const countPageImageRefs = (
+  page: Page,
+  imageIds: Set<string>,
+  useCount: Record<string, number>
+) => {
+  countImageRefs(page.props, imageIds, useCount)
+  countImageRefs(page.style, imageIds, useCount)
+  countImageRefs(page.events, imageIds, useCount)
+  page.children?.forEach((widget) => countWidgetImageRefs(widget, imageIds, useCount))
+}
+
+const imageUseCountMap = shallowRef<Record<string, number>>({})
+
+type IdleWindow = Window & {
+  requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number
+  cancelIdleCallback?: (handle: number) => void
+}
+
+let refreshTimer: ReturnType<typeof setTimeout> | undefined
+let refreshIdleId: number | undefined
+
+const clearScheduledUseCountRefresh = () => {
+  if (refreshTimer !== undefined) {
+    clearTimeout(refreshTimer)
+    refreshTimer = undefined
+  }
+
+  if (refreshIdleId !== undefined) {
+    ;(window as IdleWindow).cancelIdleCallback?.(refreshIdleId)
+    refreshIdleId = undefined
+  }
+}
+
+const refreshImageUseCount = () => {
+  const images = projectStore.project?.resources.images || []
+  const screens = projectStore.project?.screens || []
+  const imageIds = new Set(images.map((item) => item.id))
+  const useCount: Record<string, number> = {}
+
+  if (!imageIds.size) {
+    imageUseCountMap.value = useCount
+    return useCount
+  }
+
+  screens.forEach((screen: Screen) => {
+    screen.pages.forEach((page) => countPageImageRefs(page, imageIds, useCount))
+  })
+
+  imageUseCountMap.value = useCount
+  return useCount
+}
+
+const scheduleImageUseCountRefresh = () => {
+  clearScheduledUseCountRefresh()
+
+  refreshTimer = setTimeout(() => {
+    refreshTimer = undefined
+    const idleWindow = window as IdleWindow
+
+    if (idleWindow.requestIdleCallback) {
+      refreshIdleId = idleWindow.requestIdleCallback(
+        () => {
+          refreshIdleId = undefined
+          refreshImageUseCount()
+        },
+        { timeout: 1500 }
+      )
+    } else {
+      refreshImageUseCount()
+    }
+  }, 300)
+}
+
+watch(
+  () => [
+    projectStore.projectRevision,
+    projectStore.project?.resources.images.map((item) => item.id).join('|')
+  ],
+  scheduleImageUseCountRefresh,
+  {
+    immediate: true
+  }
+)
+
+onBeforeUnmount(() => {
+  clearScheduledUseCountRefresh()
+})
+
 const handleAddImage = async () => {
   const paths = await window.electron.ipcRenderer.invoke('get-file', {
     title: '选择文件',
@@ -219,10 +353,9 @@ const deleteResource = async (resource: Resource, type: 'images' | 'fonts' | 'ot
 
 // 清除未使用图片
 const handleClearUnusedImage = () => {
-  projectStore.project?.resources.images.forEach((item) => {
-    const str = JSON.stringify(projectStore.project?.screens ?? [])
-    const count = str.split(item.id).length - 1
-    if (count === 0) {
+  const useCount = refreshImageUseCount()
+  projectStore.project?.resources.images.slice().forEach((item) => {
+    if (!useCount[item.id]) {
       deleteResource(item, 'images')
     }
   })

+ 51 - 4
src/renderer/src/views/designer/sidebar/Schema.vue

@@ -14,24 +14,71 @@
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue'
+import { onBeforeUnmount, ref, watch } from 'vue'
 import MonacoEditor from '@/components/MonacoEditor/index.vue'
 import { useProjectStore } from '@/store/modules/project'
 import ViewTitle from '@/components/ViewTitle/index.vue'
 
 const projectStore = useProjectStore()
 const editorRef = ref<InstanceType<typeof MonacoEditor>>()
+type IdleWindow = Window & {
+  requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number
+  cancelIdleCallback?: (handle: number) => void
+}
+
+let updateTimer: ReturnType<typeof setTimeout> | undefined
+let idleId: number | undefined
+
+const clearScheduledUpdate = () => {
+  if (updateTimer !== undefined) {
+    clearTimeout(updateTimer)
+    updateTimer = undefined
+  }
+
+  if (idleId !== undefined) {
+    ;(window as IdleWindow).cancelIdleCallback?.(idleId)
+    idleId = undefined
+  }
+}
+
+const updateEditor = () => {
+  editorRef.value?.setValue(JSON.stringify(projectStore.project, null, 2))
+}
+
+const scheduleUpdate = () => {
+  clearScheduledUpdate()
+  updateTimer = setTimeout(() => {
+    updateTimer = undefined
+    const idleWindow = window as IdleWindow
+
+    if (idleWindow.requestIdleCallback) {
+      idleId = idleWindow.requestIdleCallback(
+        () => {
+          idleId = undefined
+          updateEditor()
+        },
+        { timeout: 1000 }
+      )
+      return
+    }
+
+    updateEditor()
+  }, 300)
+}
 
 watch(
-  () => projectStore.project,
+  () => projectStore.projectRevision,
   () => {
-    editorRef.value?.setValue(JSON.stringify(projectStore.project, null, 2))
+    scheduleUpdate()
   },
   {
-    deep: true,
     immediate: true
   }
 )
+
+onBeforeUnmount(() => {
+  clearScheduledUpdate()
+})
 </script>
 
 <style scoped></style>

+ 28 - 42
src/renderer/src/views/designer/sidebar/components/ResourceItem.vue

@@ -25,8 +25,8 @@
         v-if="type === 'image'"
         class="flex flex-col gap-2px w-80px text-8px text-text-secondary"
       >
-        <span>分辨率{{ imageInfo.width }}x{{ imageInfo.height }}</span>
-        <span>使用次数{{ imageInfo.useCount }}</span>
+        <span>分辨率:{{ imageInfo.width }}x{{ imageInfo.height }}</span>
+        <span>使用次数:{{ imageInfo.useCount }}</span>
       </span>
     </span>
 
@@ -78,13 +78,13 @@
 </template>
 
 <script setup lang="ts">
-import type { FontResource, ImageResource, Resource } from '@/types/resource'
 import type { DropdownInstance } from 'element-plus'
+import type { FontResource, ImageResource, Resource } from '@/types/resource'
 
-import { ref, watch } from 'vue'
+import { computed, ref, watch } from 'vue'
 import LocalImage from '@/components/LocalImage/index.vue'
 import { useProjectStore } from '@/store/modules/project'
-import { LuTrash2, LuPencilLine } from 'vue-icons-plus/lu'
+import { LuPencilLine, LuTrash2 } from 'vue-icons-plus/lu'
 import { BsFiletypeTtf, BsFiletypeWoff } from 'vue-icons-plus/bs'
 import fontImg from '@/assets/font.svg'
 import EditImageModal from './EditImageModal.vue'
@@ -98,6 +98,7 @@ defineEmits<{
 const props = defineProps<{
   data: Resource
   type: 'image' | 'font' | 'other'
+  imageUseCount?: number
 }>()
 
 const listBoxRef = ref<HTMLElement>()
@@ -117,24 +118,28 @@ const triggerRef = ref({
   getBoundingClientRect: () => position.value
 })
 
-const imageInfo = ref<{
+const imageSize = ref<{
   width?: number
   height?: number
-  useCount?: number
 }>({
   width: 0,
-  height: 0,
-  useCount: 0
+  height: 0
 })
 
+const imageInfo = computed(() => ({
+  width: imageSize.value.width,
+  height: imageSize.value.height,
+  useCount: props.imageUseCount || 0
+}))
+
 watch(
-  () => props.type,
+  () => [props.type, props.data.path, projectStore.projectPath],
   async () => {
     if (props.type === 'image') {
-      const resouce = props.data as ImageResource
-      const res = await getImageByPath(projectStore.projectPath + resouce.path)
-      imageInfo.value.width = res?.dimensions.width
-      imageInfo.value.height = res?.dimensions.height
+      const resource = props.data as ImageResource
+      const res = await getImageByPath(projectStore.projectPath + resource.path)
+      imageSize.value.width = res?.dimensions.width
+      imageSize.value.height = res?.dimensions.height
     }
   },
   {
@@ -142,22 +147,6 @@ watch(
   }
 )
 
-watch(
-  () => projectStore.project,
-  async () => {
-    if (projectStore.project && props.type === 'image') {
-      const str = JSON.stringify(projectStore.project.screens)
-      // 判断图片id出现的次数
-      const count = str.split(props.data.id).length - 1
-      imageInfo.value.useCount = count
-    }
-  },
-  {
-    immediate: true,
-    deep: true
-  }
-)
-
 const handleClick = () => {
   dropdownRef.value?.handleClose()
 }
@@ -172,7 +161,6 @@ const handleContextmenu = (event: MouseEvent) => {
   dropdownRef.value?.handleOpen()
 }
 
-// 资源管理器中打开
 const openInExplorer = () => {
   window.electron.ipcRenderer.invoke(
     'open-file-in-explorer',
@@ -180,12 +168,10 @@ const openInExplorer = () => {
   )
 }
 
-// 复制名称
 const copyName = () => {
   navigator.clipboard.writeText(props.data.fileName)
 }
 
-// 打开编辑
 const handleEdit = () => {
   if (props.type === 'image') {
     editImageModalRef.value?.edit(props.data as ImageResource)
@@ -195,18 +181,18 @@ const handleEdit = () => {
   }
 }
 
-// 编辑完成
-const handleChangeResource = async (resouce: Resource) => {
-  Object.entries(resouce).forEach(([key, value]) => {
-    props.data[key] = value
+const handleChangeResource = async (resource: Resource) => {
+  const oldFileName = props.data.fileName
+
+  Object.entries(resource).forEach(([key, value]) => {
+    ;(props.data as Record<string, unknown>)[key] = value
   })
-  if (resouce.fileName !== props.data.fileName) {
-    // 文件命名修改后,更新路径及本地资源
+
+  if (resource.fileName !== oldFileName) {
     const sourcePath = projectStore.projectPath + props.data.path
-    const targetPath =
-      projectStore.projectPath + props.data.path.replace(props.data.fileName, resouce.fileName)
+    const targetPath = projectStore.projectPath + props.data.path.replace(oldFileName, resource.fileName)
     await window.electron.ipcRenderer.invoke('modify-file-name', sourcePath, targetPath)
-    props.data.path = props.data.path.replace(props.data.fileName, resouce.fileName)
+    props.data.path = props.data.path.replace(oldFileName, resource.fileName)
   }
 }
 </script>

+ 4 - 4
src/renderer/src/views/designer/sidebar/index.vue

@@ -107,7 +107,7 @@
         </ul>
       </div>
     </div>
-    <div class="flex-1 overflow-hidden" v-show="activeMenu === 'widget'">
+    <div v-if="activeMenu === 'widget'" class="flex-1 overflow-hidden">
       <SplitterCollapse>
         <SplitterCollapseItem :title="t('widget')">
           <Libary />
@@ -117,13 +117,13 @@
         </SplitterCollapseItem>
       </SplitterCollapse>
     </div>
-    <div class="flex-1 overflow-hidden" v-show="activeMenu === 'resource'">
+    <div v-else-if="activeMenu === 'resource'" class="flex-1 overflow-hidden">
       <Resource />
     </div>
-    <div class="flex-1 overflow-hidden" v-show="activeMenu === 'code'">
+    <div v-else-if="activeMenu === 'code'" class="flex-1 overflow-hidden">
       <Method />
     </div>
-    <div class="flex-1 overflow-auto" v-show="activeMenu === 'json'">
+    <div v-else-if="activeMenu === 'json'" class="flex-1 overflow-auto">
       <Schema />
     </div>
   </div>

+ 2 - 2
src/renderer/src/views/designer/workspace/stage/DesignerCanvas.vue

@@ -116,8 +116,8 @@ const getStyles = computed(() => {
   return {
     // 舞台样式
     stageStyle: {
-      width: `${width * STAGE_SCALE}px`,
-      height: `${height * STAGE_SCALE}px`
+      width: `${scrollWidth}px`,
+      height: `${scrollHeight}px`
     },
     // 画布样式
     canvasStyle: {

+ 31 - 7
src/renderer/src/views/designer/workspace/stage/Moveable.vue

@@ -45,6 +45,10 @@
     @resizeEnd="onResizeEnd"
     @dragStart="onDragStart"
     @dragEnd="onDragEnd"
+    @resizeStart="onTransformStart"
+    @resizeGroupStart="onTransformStart"
+    @dragGroupStart="onTransformStart"
+    @rotateStart="onTransformStart"
     @dragGroup="onDragGroup"
     @dragGroupEnd="onDragGroupEnd"
     @resizeGroup="onResizeGroup"
@@ -126,7 +130,7 @@ const horizontalGuidelines = computed(() => {
 
 // 监听选中元素
 watch(
-  () => projectStore.activeWidgets,
+  () => projectStore.activeWidgets.map((item) => item.id).join('|'),
   () => {
     elements.value = []
     const ids = projectStore.activeWidgets.map((item) => item.id)
@@ -136,9 +140,6 @@ watch(
         elements.value.push(el as HTMLElement)
       }
     })
-  },
-  {
-    deep: true
   }
 )
 
@@ -208,10 +209,19 @@ const hasLockedWidgetTree = (widget?: BaseWidget | Page): boolean => {
   )
 }
 
+const onTransformStart = () => {
+  projectStore.pauseHistory()
+}
+
+const onTransformEnd = () => {
+  projectStore.resumeHistory(true)
+}
+
 // 拖拽开始
 const onDragStart = (e) => {
   const id = e.target.attributes['widget-id']?.value
   if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) return false
+  onTransformStart()
   zIndex.value = e.target.style.zIndex
   e.target.style.zIndex = 999
   appStore.draging = true
@@ -219,7 +229,9 @@ const onDragStart = (e) => {
 // 渲染节点拖拽
 const onDrag = (e) => {
   const id = e.target.attributes['widget-id']?.value
-  if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) return
+  if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) {
+    return
+  }
   // 当前选中节点整体移动
   e.target.style.transform = e.transform
 }
@@ -229,6 +241,7 @@ const onDragEnd = (e) => {
   const id = e.target.attributes['widget-id']?.value
   if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) {
     appStore.draging = false
+    onTransformEnd()
     return
   }
   if (id && projectStore.activeWidgetMap[id] && e?.lastEvent?.translate) {
@@ -236,6 +249,7 @@ const onDragEnd = (e) => {
     projectStore.activeWidgetMap[id].props.y = Math.round(e.lastEvent.translate[1])
   }
   appStore.draging = false
+  onTransformEnd()
 }
 // 渲染节点缩放
 const onResize = (e) => {
@@ -248,7 +262,10 @@ const onResize = (e) => {
 // 节点缩放完成
 const onResizeEnd = (e) => {
   const id = e.target.attributes['widget-id']?.value
-  if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) return
+  if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) {
+    onTransformEnd()
+    return
+  }
   if (e.lastEvent && id && projectStore.activeWidgetMap[id]) {
     const scale = getScale(projectStore.activeWidgetMap[id])
     let width = Math.round(e.lastEvent.width / scale)
@@ -290,6 +307,7 @@ const onResizeEnd = (e) => {
       projectStore.activeWidgetMap[id].props.y = Math.round(e.lastEvent.drag.translate[1])
     }
   }
+  onTransformEnd()
 }
 // 渲染节点事件
 const onRender = (e) => {
@@ -323,6 +341,7 @@ const onDragGroupEnd = ({ events }) => {
       projectStore.activeWidgetMap[id].props.y = Math.round(ev.lastEvent.translate[1])
     }
   })
+  onTransformEnd()
 }
 // 节点组缩放结束
 const onResizeGroupEnd = ({ events }) => {
@@ -371,16 +390,21 @@ const onResizeGroupEnd = ({ events }) => {
       }
     }
   })
+  onTransformEnd()
 }
 
 // 旋转结束
 const onRotateEnd = (e) => {
   const id = e.target.attributes['widget-id']?.value
-  if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) return
+  if (id && hasLockedWidgetTree(projectStore.activeWidgetMap[id])) {
+    onTransformEnd()
+    return
+  }
   if (e.lastEvent && id && projectStore.activeWidgetMap[id]) {
     // 设置位置
     projectStore.activeWidgetMap[id].props.rotation = Math.round(e.lastEvent.rotate)
   }
+  onTransformEnd()
 }
 </script>
 

+ 16 - 2
src/renderer/src/views/designer/workspace/stage/Node.vue

@@ -163,7 +163,22 @@ const getStyle = computed((): CSSProperties => {
 
 const layoutStyle = ref<CSSProperties>({})
 watch(
-  () => props.schema.props,
+  () => [
+    props.schema.type,
+    props.schema.props?.layout,
+    props.schema.props?.flex?.direction,
+    props.schema.props?.flex?.mainAxisAlign,
+    props.schema.props?.flex?.crossAxisAlign,
+    props.schema.props?.flex?.trackAxisAlign,
+    props.schema.props?.flex?.rowGap,
+    props.schema.props?.flex?.columnGap,
+    props.schema.props?.width,
+    props.schema.props?.height,
+    props.schema.props?.position,
+    props.schema.props?.sider,
+    props.schema.props?.titleMode,
+    props.schema.props?.inPage
+  ],
   async () => {
     await nextTick()
     const { schema } = props
@@ -205,7 +220,6 @@ watch(
     layoutStyle.value = style
   },
   {
-    deep: true,
     immediate: true
   }
 )