Ver código fonte

feat: 添加arc控件

jiaxing.liao 2 semanas atrás
pai
commit
9d7245a35d

+ 38 - 0
src/main/files.ts

@@ -1,6 +1,10 @@
 import type { OpenDialogOptions, Event } from 'electron'
 import { dialog, ipcMain, shell } from 'electron'
 import * as fs from 'fs-extra'
+import * as path from 'path'
+
+/** 当前项目文件夹锁:持有一个项目内文件的句柄,防止用户删除项目文件夹 */
+let projectLockFd: number | null = null
 
 /**
  * 选择文件夹
@@ -121,6 +125,36 @@ export const openFileInExplorer = (_e: Event, path: string) => {
   return shell.showItemInFolder(path)
 }
 
+/**
+ * 锁定项目文件夹:打开项目目录内的 project.ui 并保持句柄,防止用户在资源管理器中删除该文件夹
+ * @param projectDir 项目根目录路径(如 D:\projects\myProject)
+ */
+const lockProjectFolder = async (_e: Event, projectDir: string) => {
+  if (projectLockFd !== null) {
+    try {
+      fs.closeSync(projectLockFd)
+    } catch (_) {}
+    projectLockFd = null
+  }
+  const lockFilePath = path.join(projectDir, 'project.ui')
+  if (!fs.existsSync(lockFilePath)) return
+  try {
+    projectLockFd = fs.openSync(lockFilePath, 'r')
+  } catch (_) {}
+}
+
+/**
+ * 解锁项目文件夹:关闭句柄,允许用户删除项目文件夹
+ */
+export const unlockProjectFolder = () => {
+  if (projectLockFd !== null) {
+    try {
+      fs.closeSync(projectLockFd)
+    } catch (_) {}
+    projectLockFd = null
+  }
+}
+
 export function handleFile() {
   // 获取文件夹
   ipcMain.handle('get-directory', choeseDirectory)
@@ -146,4 +180,8 @@ export function handleFile() {
   ipcMain.handle('modify-file-name', renameFile)
   // 资源管理器中打开
   ipcMain.handle('open-file-in-explorer', openFileInExplorer)
+  // 锁定/解锁项目文件夹(防止用户删除正在使用的项目目录)
+  ipcMain.handle('lock-project-folder', lockProjectFolder)
+  // 解锁项目文件夹
+  ipcMain.handle('unlock-project-folder', unlockProjectFolder)
 }

+ 6 - 1
src/main/index.ts

@@ -2,7 +2,7 @@ import { app, shell, BrowserWindow, ipcMain, protocol, net as electronNet } from
 import { join } from 'path'
 import { electronApp, optimizer, is } from '@electron-toolkit/utils'
 import icon from '../../resources/icon.png?asset'
-import { handleFile } from './files'
+import { handleFile, unlockProjectFolder } from './files'
 
 const net = require('net')
 
@@ -104,6 +104,11 @@ app.whenReady().then(() => {
   })
 })
 
+// 退出前解锁项目文件夹,释放文件句柄
+app.on('before-quit', () => {
+  unlockProjectFolder()
+})
+
 // Quit when all windows are closed, except on macOS. There, it's common
 // for applications and their menu bar to stay active until the user quits
 // explicitly with Cmd + Q.

+ 153 - 76
src/renderer/src/lvgl-widgets/arc/Arc.vue

@@ -1,50 +1,62 @@
 <template>
-  <div
-    :style="{
-      ...styleMap?.mainStyle
-    }"
-    class="w-full h-full box-border overflow-hidden relative"
-  >
-    <svg
-      :viewBox="`0 0 ${width} ${height}`"
-      preserveAspectRatio="xMinYMin meet"
-      class="w-full h-full"
-      xmlns="http://www.w3.org/2000/svg"
-      :style="{ transform: `rotate(${props.rotate}deg)` }"
-    >
-      <!-- 背景条:绘制完整的起始到结束角度 -->
-      <path
-        :d="bgPath"
-        fill="none"
-        :stroke="styles?.mainStyle?.curve?.color || '#eeeeee'"
-        :stroke-width="styles?.mainStyle?.curve?.width || 1"
-        :stroke-linecap="styles?.mainStyle?.curve?.radius ? 'round' : 'butt'"
-      />
-
-      <!-- 进度值条 -->
-      <path
-        :d="valuePath"
-        fill="none"
-        :stroke="styles?.indicatorStyle?.curve?.color || '#eeeeee'"
-        :stroke-width="styles?.indicatorStyle?.curve?.width || 1"
-        :stroke-linecap="styles?.indicatorStyle?.curve?.radius ? 'round' : 'butt'"
-      />
-
-      <!-- 进度圆点 -->
-      <circle
-        v-if="dotPos"
-        :cx="dotPos.x"
-        :cy="dotPos.y"
-        :r="styles?.knobStyle?.padding?.left || 5"
-        :fill="styles?.knobStyle?.backgroundColor || '#2092f5'"
-      />
-    </svg>
+  <div :style="{
+    ...styleMap?.mainStyle
+  }" class="relative w-full h-full box-border overflow-hidden relative">
+
+    <ImageBg v-if="styleMap?.mainStyle?.imageSrc" :src="styleMap?.mainStyle?.imageSrc"
+      :imageStyle="styleMap?.mainStyle?.imageStyle" />
+
+    <div class="absolute inset-0 w-full h-full" :style="{ transform: `rotate(${props.rotate}deg)` }">
+      <svg :viewBox="`0 0 ${width} ${height}`" preserveAspectRatio="xMidYMid meet" class="w-full h-full block"
+        xmlns="http://www.w3.org/2000/svg">
+        <defs>
+          <!-- 背景条图片 pattern:保持图像原始尺寸,居中显示 -->
+          <pattern v-if="styleMap?.mainStyle?.curve?.imageSrc" :id="`${arcId}-bg-pattern`" patternUnits="userSpaceOnUse"
+            :width="width" :height="height" x="0" y="0">
+            <image :href="styleMap.mainStyle.curve.imageSrc" transform="translate(-50%, -50%)" />
+          </pattern>
+          <!-- 进度值条图片 pattern:保持图像原始尺寸,居中显示 -->
+          <pattern v-if="styleMap?.indicatorStyle?.curve?.imageSrc" :id="`${arcId}-indicator-pattern`"
+            patternUnits="userSpaceOnUse" :width="width" :height="height" x="0" y="0">
+            <image :href="styleMap.indicatorStyle.curve.imageSrc" transform="translate(-50%, -50%)" />
+          </pattern>
+        </defs>
+        <!-- 背景条:绘制完整的起始到结束角度 -->
+        <path :d="bgPath" fill="none"
+          :stroke="styleMap?.mainStyle?.curve?.imageSrc ? `url(#${arcId}-bg-pattern)` : (styleMap?.mainStyle?.curve?.color || '#eeeeee')"
+          :stroke-width="styleMap?.mainStyle?.curve?.width ?? 1"
+          :stroke-linecap="styleMap?.mainStyle?.curve?.radius ? 'round' : 'butt'"
+          :opacity="styleMap?.mainStyle?.curve?.opacity" />
+
+        <!-- 进度值条 -->
+        <path :d="valuePath" fill="none"
+          :stroke="styleMap?.indicatorStyle?.curve?.imageSrc ? `url(#${arcId}-indicator-pattern)` : (styleMap?.indicatorStyle?.curve?.color || '#2092f5')"
+          :stroke-width="styleMap?.indicatorStyle?.curve?.width ?? 1"
+          :stroke-linecap="styleMap?.indicatorStyle?.curve?.radius ? 'round' : 'butt'"
+          :opacity="styleMap?.indicatorStyle?.curve?.opacity" />
+
+        <!-- 进度圆点:无背景图时用实心圆 -->
+        <circle v-if="dotPos && !styleMap?.knobStyle?.imageSrc" :cx="dotPos.x" :cy="dotPos.y" :r="knobRadius.radius"
+          :fill="styleMap?.knobStyle?.backgroundColor || '#2092f5'" />
+      </svg>
+
+      <!-- 进度圆点背景图:与 SVG 同坐标系,用 ImageBg 统一实现 -->
+      <div v-if="dotPos && styleMap?.knobStyle?.imageSrc" class="absolute overflow-hidden pointer-events-none"
+        :style="knobOverlayStyle">
+        <ImageBg :src="styleMap.knobStyle.imageSrc" :imageStyle="styleMap.knobStyle.imageStyle" />
+      </div>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
 import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import { useProjectStore } from '@/store/modules/project';
+
+import ImageBg from '../ImageBg.vue';
+
+const arcId = ref('arc-' + Math.random().toString(36).slice(2))
 
 const props = defineProps<{
   width: number
@@ -59,8 +71,14 @@ const props = defineProps<{
   angleEnd: number
   value: number
   rotate: number
+  // 偏移开关
+  rotateOffset?: boolean
+  // 偏移量(度数)
+  rotateOffsetValue?: number
 }>()
 
+const projectStore = useProjectStore()
+
 const styleMap = useWidgetStyle({
   widget: 'lv_arc',
   props
@@ -72,6 +90,36 @@ const cx = computed(() => {
   return min / 2
 })
 
+/** 弧线半径:与背景/进度条一致,留出边距避免贴边 */
+const trackRadius = computed(() => {
+  const { width, height } = props
+  const min = Math.min(width, height)
+  const curveWidth = styleMap.value?.mainStyle?.curve?.width ?? 12
+  return Math.max(4, min / 2 - curveWidth / 2 - knobRadius.value.padding)
+})
+
+/** 从 schema 的 styles 中解析 knob 的 padding.left 作为圆点半径 */
+const knobRadius = computed(() => {
+  const defaultIndicatorStyle = projectStore.globalStyle
+    ?.find((item) => item.widget === 'lv_arc')
+    ?.part?.find((item) => item.partName === 'indicator')?.defaultStyle
+
+  const defaultKnobStyle = projectStore.globalStyle
+    ?.find((item) => item.widget === 'lv_arc')
+    ?.part?.find((item) => item.partName === 'knob')?.defaultStyle
+
+  const indicatorStyle = props.styles?.find((s: any) => s.part?.name === 'indicator' && s.part.state === props.state)
+  const knobStyle = props.styles?.find((s: any) => s.part?.name === 'knob' && s.part.state === props.state)
+  const padding = knobStyle?.padding?.left ?? defaultKnobStyle?.padding?.left
+  const r = indicatorStyle?.curve?.width ?? defaultIndicatorStyle?.curve?.width ?? 12
+
+  return {
+    radius: r / 2 + padding,
+    padding: padding,
+    width: r
+  }
+})
+
 /**
  * 极坐标转直角坐标
  * SVG 角度习惯:0度在右侧(3点钟),需调整使0度在上方(12点钟)
@@ -93,17 +141,17 @@ function polarToCartesian(
 
 /**
  * 生成 SVG 弧线路径指令
+ * 始终从 startAngle 顺时针画到 endAngle,得到“近整圆、缺口在底部”的环(图2效果)
  */
 function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) {
-  // 如果起始角度大于结束角度,交换位置(取决于业务逻辑,这里默认顺时针绘制)
-  const isReversed = startAngle > endAngle
-  const start = polarToCartesian(x, y, radius, isReversed ? endAngle : startAngle)
-  const end = polarToCartesian(x, y, radius, isReversed ? startAngle : endAngle)
-
-  const diff = Math.abs(endAngle - startAngle)
-  const largeArcFlag = diff <= 180 ? '0' : '1'
-  // sweep-flag: 1 为顺时针
-  const sweepFlag = isReversed ? '0' : '1'
+  const start = polarToCartesian(x, y, radius, startAngle)
+  const end = polarToCartesian(x, y, radius, endAngle)
+  // 顺时针扫过的角度(0~360)
+  let clockwiseSpan = (endAngle - startAngle) % 360
+  if (clockwiseSpan <= 0) clockwiseSpan += 360
+  if (clockwiseSpan === 0) clockwiseSpan = 360
+  const largeArcFlag = clockwiseSpan > 180 ? '1' : '0'
+  const sweepFlag = '1' // 顺时针
 
   return [
     'M',
@@ -122,61 +170,90 @@ function describeArc(x: number, y: number, radius: number, startAngle: number, e
 
 // 1. 背景路径
 const bgPath = computed(() => {
-  const { width, height } = props
-  const min = Math.min(width, height)
-  const r = min / 2
-  return describeArc(r, r, r - 10, props.angleStart, props.angleEnd)
+  const r = trackRadius.value
+  return describeArc(cx.value, cx.value, r, props.angleStart, props.angleEnd)
 })
 
-// 2. 进度计算逻辑
+// 顺时针从 angleStart 到 angleEnd 的弧度跨度(0~360)
+const clockwiseSpan = computed(() => {
+  const { angleStart, angleEnd } = props
+  let s = (angleEnd - angleStart) % 360
+  if (s <= 0) s += 360
+  return s === 0 ? 360 : s
+})
+
+// 2. 进度计算逻辑(与 describeArc 的顺时针方向一致)
 const progressData = computed(() => {
   const { value, rangeStart, rangeEnd, angleStart, angleEnd, mode } = props
 
-  // 基础百分比计算 (0-1)
-  let ratio = (value - rangeStart) / (rangeEnd - rangeStart)
-  ratio = Math.max(0, Math.min(1, ratio)) // 限制在 0-1
+  const rangeDiff = rangeEnd - rangeStart
+  const ratio =
+    Math.abs(rangeDiff) < 1e-9
+      ? 0
+      : Math.max(0, Math.min(1, (value - rangeStart) / rangeDiff))
 
+  const span = clockwiseSpan.value
   let startA = angleStart
   let endA = angleEnd
 
   if (mode === 'normal') {
-    endA = angleStart + (angleEnd - angleStart) * ratio
+    // 从 angleStart 顺时针填充 span * ratio
+    endA = angleStart + span * ratio
   } else if (mode === 'reverse') {
-    // 从终点往回走
-    startA = angleEnd - (angleEnd - angleStart) * ratio
+    startA = angleEnd - span * ratio
     endA = angleEnd
   } else if (mode === 'symmetrical') {
-    // 对称模式:通常 rangeStart 到 rangeEnd 的中间值是起点
-    const midAngle = (angleStart + angleEnd) / 2
+    const midAngle = angleStart + span / 2
     const midValue = (rangeStart + rangeEnd) / 2
+    const rangeToEnd = rangeEnd - midValue
+    const rangeToStart = midValue - rangeStart
 
-    if (value >= midValue) {
+    if (value >= midValue && Math.abs(rangeToEnd) >= 1e-9) {
       startA = midAngle
-      const halfRatio = (value - midValue) / (rangeEnd - midValue)
-      endA = midAngle + (angleEnd - midAngle) * halfRatio
+      const halfRatio = Math.max(0, Math.min(1, (value - midValue) / rangeToEnd))
+      endA = midAngle + (span / 2) * halfRatio
+    } else if (value < midValue && Math.abs(rangeToStart) >= 1e-9) {
+      endA = midAngle
+      const halfRatio = Math.max(0, Math.min(1, (midValue - value) / rangeToStart))
+      startA = midAngle - (span / 2) * halfRatio
     } else {
+      startA = midAngle
       endA = midAngle
-      const halfRatio = (midValue - value) / (midValue - rangeStart)
-      startA = midAngle - (midAngle - angleStart) * halfRatio
     }
   }
 
   return { startA, endA }
 })
 
-// 3. 值路径
+// 3. 值路径(与背景弧同半径)
 const valuePath = computed(() => {
   const { startA, endA } = progressData.value
   if (startA === endA) return ''
-  return describeArc(cx.value, cx.value, cx.value, startA, endA)
+  return describeArc(cx.value, cx.value, trackRadius.value, startA, endA)
 })
 
-// 4. 圆点位置
+// 4. 圆点位置(与弧同半径)
 const dotPos = computed(() => {
-  // const { endA } = progressData.value
-  // 如果是 reverse 模式,圆点可能应该在 startA(取决于视觉习惯)
-  // 这里默认跟随进度的“活动端”
-  const angle = props.mode === 'reverse' ? progressData.value.startA : progressData.value.endA
-  return polarToCartesian(cx.value, cx.value, cx.value, angle)
+  // 原始结束角:normal / symmetrical 用 endA,reverse 用 startA
+  const baseAngle =
+    props.mode === 'reverse' ? progressData.value.startA : progressData.value.endA
+  // 仅对“结束值原点”做偏移,不整体偏移整条进度弧
+  const offset = props.rotateOffset ? props.rotateOffsetValue ?? 0 : 0
+  const angle = baseAngle + offset
+  return polarToCartesian(cx.value, cx.value, trackRadius.value, angle)
+})
+
+/** 进度圆点 overlay 的定位样式(与 viewBox 比例一致,用 ImageBg 时使用) */
+const knobOverlayStyle = computed(() => {
+  if (!dotPos.value || !props.width || !props.height) return {}
+  const { x, y } = dotPos.value
+  const r = knobRadius.value.radius
+  return {
+    left: `${((x - r) / props.width) * 100}%`,
+    top: `${((y - r) / props.height) * 100}%`,
+    width: `${(r * 2 / props.width) * 100}%`,
+    height: `${(r * 2 / props.height) * 100}%`,
+    borderRadius: '50%'
+  }
 })
 </script>

+ 92 - 45
src/renderer/src/lvgl-widgets/arc/index.ts

@@ -42,7 +42,12 @@ export default {
       angleStart: 135,
       angleEnd: 45,
       value: 70,
-      rotate: 0
+      rotate: 0,
+      // 旋转偏移开关
+      rotateOffset: false,
+      rotateOffsetValue: 60,
+      // 变化率
+      changeRate: 720
     },
     styles: [
       {
@@ -51,7 +56,12 @@ export default {
           state: 'default'
         },
         background: {
-          color: '#ffffffff'
+          color: '#ffffff00',
+          image: {
+            imgId: '',
+            color: '#ffffff00',
+            alpha: 255
+          }
         },
         border: {
           color: '#2092f5ff',
@@ -66,9 +76,11 @@ export default {
           left: 20
         },
         curve: {
-          color: '#eeeeeeff',
+          color: '#e0e0e0ff',
           width: 12,
-          radius: true
+          radius: true,
+          alpha: 255,
+          image: ''
         },
         shadow: {
           color: '#2092f5ff',
@@ -76,32 +88,16 @@ export default {
           y: 0,
           spread: 0,
           width: 0
-        }
-      },
-      {
-        part: {
-          name: 'indicator',
-          state: 'default'
-        },
-        curve: {
-          color: '#2092f5ff',
-          width: 12,
-          radius: true
-        }
-      },
-      {
-        part: {
-          name: 'knob',
-          state: 'default'
         },
-        background: {
-          color: '#2092f5ff'
-        },
-        padding: {
-          left: 8,
-          right: 8,
-          top: 8,
-          bottom: 8
+        transform: {
+          width: 0,
+          height: 0,
+          translateX: 0,
+          translateY: 0,
+          originX: 0,
+          originY: 0,
+          rotate: 0,
+          scale: 256
         }
       }
     ]
@@ -192,6 +188,7 @@ export default {
         label: '模式',
         field: 'props.mode',
         valueType: 'select',
+        labelWidth: '80px',
         componentProps: {
           options: [
             { label: 'normal', value: 'normal' },
@@ -208,6 +205,7 @@ export default {
             field: 'props.rangeStart',
             valueType: 'number',
             componentProps: {
+              span: 12,
               min: -100000,
               max: 100000
             },
@@ -219,6 +217,7 @@ export default {
             field: 'props.rangeEnd',
             valueType: 'number',
             componentProps: {
+              span: 12,
               min: -100000,
               max: 100000
             },
@@ -228,6 +227,12 @@ export default {
           }
         ]
       },
+      {
+        label: '值',
+        field: 'props.value',
+        labelWidth: '80px',
+        valueType: 'number'
+      },
       {
         label: '角度',
         valueType: 'group',
@@ -236,6 +241,7 @@ export default {
             field: 'props.angleStart',
             valueType: 'number',
             componentProps: {
+              span: 12,
               min: 0,
               max: 360
             },
@@ -247,6 +253,7 @@ export default {
             field: 'props.angleEnd',
             valueType: 'number',
             componentProps: {
+              span: 12,
               min: 0,
               max: 360
             },
@@ -256,15 +263,47 @@ export default {
           }
         ]
       },
-      {
-        label: '值',
-        field: 'props.value',
-        valueType: 'number'
-      },
       {
         label: '旋转',
         field: 'props.rotate',
+        labelWidth: '80px',
         valueType: 'number'
+      },
+      {
+        label: '旋转偏移',
+        field: 'props.rotateOffset',
+        labelWidth: '80px',
+        valueType: 'switch'
+      },
+      {
+        valueType: 'dependency',
+        name: ['props.rotateOffset'],
+        dependency: (dependency) => {
+          return dependency?.['props.rotateOffset']
+            ? [
+                {
+                  label: '旋转偏移值',
+                  field: 'props.rotateOffsetValue',
+                  labelWidth: '80px',
+                  valueType: 'number',
+                  componentProps: {
+                    min: -360,
+                    max: 360
+                  }
+                }
+              ]
+            : []
+        }
+      },
+      {
+        label: '变化率',
+        field: 'props.changeRate',
+        valueType: 'number',
+        labelWidth: '80px',
+        componentProps: {
+          min: 1,
+          max: 10000
+        }
       }
     ],
     // 组件样式
@@ -283,10 +322,7 @@ export default {
                 {
                   label: '背景',
                   field: 'background',
-                  ValueType: 'background',
-                  componentProps: {
-                    onlyColor: true
-                  }
+                  valueType: 'background'
                 },
                 {
                   label: '边框',
@@ -301,12 +337,20 @@ export default {
                 {
                   label: '曲线',
                   field: 'curve',
-                  valueType: 'line'
+                  valueType: 'line',
+                  componentProps: {
+                    hasImage: true
+                  }
                 },
                 {
                   label: '阴影',
                   field: 'shadow',
                   valueType: 'shadow'
+                },
+                {
+                  label: '变换',
+                  field: 'transform',
+                  valueType: 'transform'
                 }
               ]
             : part?.name === 'indicator'
@@ -314,22 +358,25 @@ export default {
                   {
                     label: '曲线',
                     field: 'curve',
-                    valueType: 'line'
+                    valueType: 'line',
+                    componentProps: {
+                      hasImage: true
+                    }
                   }
                 ]
               : [
                   {
                     label: '背景',
                     field: 'background',
-                    ValueType: 'background',
-                    componentProps: {
-                      onlyColor: true
-                    }
+                    valueType: 'background'
                   },
                   {
                     label: '内边距',
                     field: 'padding',
-                    valueType: 'padding'
+                    valueType: 'padding',
+                    componentProps: {
+                      allInOne: true
+                    }
                   }
                 ]
         }

+ 67 - 167
src/renderer/src/lvgl-widgets/arc/style.json

@@ -1,189 +1,89 @@
 {
   "widget": "lv_arc",
-  "styleName": "defualt",
+  "styleName": "default",
   "part": [
     {
       "partName": "main",
-      "state": [
-        {
-          "state": "default",
-          "style": {
-            "background": {
-              "color": "#ffffffff"
-            },
-            "border": {
-              "color": "#2092f5ff",
-              "width": 0,
-              "radius": 6,
-              "side": ["all"]
-            },
-            "padding": {
-              "top": 20,
-              "right": 20,
-              "bottom": 20,
-              "left": 20
-            },
-            "curve": {
-              "color": "#eeeeeeff",
-              "width": 12,
-              "radius": true
-            },
-            "shadow": {
-              "color": "#2092f5ff",
-              "x": 0,
-              "y": 0,
-              "spread": 0,
-              "width": 0
-            }
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff00",
+          "image": {
+            "imgId": "",
+            "color": "#ffffff00",
+            "alpha": 255
           }
         },
-        {
-          "state": "focused",
-          "style": {
-            "background": {
-              "color": "#ffffffff"
-            },
-            "border": {
-              "color": "#2092f5ff",
-              "width": 0,
-              "radius": 6,
-              "side": ["all"]
-            },
-            "padding": {
-              "top": 20,
-              "right": 20,
-              "bottom": 20,
-              "left": 20
-            },
-            "curve": {
-              "color": "#eeeeeeff",
-              "width": 12,
-              "radius": true
-            },
-            "shadow": {
-              "color": "#2092f5ff",
-              "x": 0,
-              "y": 0,
-              "spread": 0,
-              "width": 0
-            }
-          }
+        "border": {
+          "color": "#2092f5ff",
+          "width": 0,
+          "radius": 6,
+          "side": ["all"]
         },
-        {
-          "state": "disabled",
-          "style": {
-            "background": {
-              "color": "#ffffffff"
-            },
-            "border": {
-              "color": "#2092f5ff",
-              "width": 0,
-              "radius": 6,
-              "side": ["all"]
-            },
-            "padding": {
-              "top": 20,
-              "right": 20,
-              "bottom": 20,
-              "left": 20
-            },
-            "curve": {
-              "color": "#eeeeeeff",
-              "width": 12,
-              "radius": true
-            },
-            "shadow": {
-              "color": "#2092f5ff",
-              "x": 0,
-              "y": 0,
-              "spread": 0,
-              "width": 0
-            }
-          }
+        "padding": {
+          "top": 20,
+          "right": 20,
+          "bottom": 20,
+          "left": 20
+        },
+        "curve": {
+          "color": "#e0e0e0ff",
+          "width": 12,
+          "radius": true,
+          "alpha": 255,
+          "image": ""
+        },
+        "shadow": {
+          "color": "#2092f5ff",
+          "x": 0,
+          "y": 0,
+          "spread": 0,
+          "width": 0
+        },
+        "transform": {
+          "width": 0,
+          "height": 0,
+          "translateX": 0,
+          "translateY": 0,
+          "originX": 0,
+          "originY": 0,
+          "rotate": 0,
+          "scale": 256
         }
-      ]
+      },
+      "state": []
     },
     {
       "partName": "indicator",
-      "state": [
-        {
-          "state": "default",
-          "style": {
-            "curve": {
-              "color": "#2092f5ff",
-              "width": 12,
-              "radius": true
-            }
-          }
-        },
-        {
-          "state": "focused",
-          "style": {
-            "curve": {
-              "color": "#2092f5ff",
-              "width": 12,
-              "radius": true
-            }
-          }
-        },
-        {
-          "state": "disabled",
-          "style": {
-            "curve": {
-              "color": "#2092f5ff",
-              "width": 12,
-              "radius": true
-            }
-          }
+      "defaultStyle": {
+        "curve": {
+          "color": "#2196f3ff",
+          "width": 12,
+          "radius": true,
+          "alpha": 255,
+          "image": ""
         }
-      ]
+      },
+      "state": []
     },
     {
       "partName": "knob",
-      "state": [
-        {
-          "state": "default",
-          "style": {
-            "background": {
-              "color": "#2092f5ff"
-            },
-            "padding": {
-              "left": 8,
-              "right": 8,
-              "top": 8,
-              "bottom": 8
-            }
+      "defaultStyle": {
+        "background": {
+          "color": "#2196f3ff",
+          "image": {
+            "imgId": "",
+            "color": "#ffffff00",
+            "alpha": 255
           }
         },
-        {
-          "state": "focused",
-          "style": {
-            "background": {
-              "color": "#2092f5ff"
-            },
-            "padding": {
-              "left": 8,
-              "right": 8,
-              "top": 8,
-              "bottom": 8
-            }
-          }
-        },
-        {
-          "state": "disabled",
-          "style": {
-            "background": {
-              "color": "#2092f5ff"
-            },
-            "padding": {
-              "left": 8,
-              "right": 8,
-              "top": 8,
-              "bottom": 8
-            }
-          }
+        "padding": {
+          "left": 5,
+          "right": 5,
+          "top": 5,
+          "bottom": 5
         }
-      ]
+      },
+      "state": []
     }
   ]
 }

+ 49 - 16
src/renderer/src/lvgl-widgets/hooks/useWidgetStyle.ts

@@ -25,8 +25,22 @@ type StyleMap = Record<
     imageStyle?: CSSProperties
     // 图片样式
     image?: CSSProperties
-    line?: { color: string; width: number; radius: boolean }
-    curve?: { color: string; width: number; radius: boolean }
+    line?: {
+      color: string
+      width: number
+      radius: boolean
+      imageSrc?: string
+      // 背景图样式
+      opacity?: number
+    }
+    curve?: {
+      color: string
+      width: number
+      radius: boolean
+      imageSrc?: string
+      // 背景图样式
+      opacity?: number
+    }
   }
 >
 
@@ -215,6 +229,18 @@ export const useWidgetStyle = (param: StyleParam) => {
     return klona(assign({}, defaultStyle, stateStyle))
   }
 
+  const getImageSrc = (imgId: string | undefined) => {
+    if (!imgId) return ''
+    const basePath = projectStore?.projectPath
+    const imagePath = projectStore?.project?.resources.images.find(
+      (item) => item.id === imgId
+    )?.path
+    if (basePath && imagePath) {
+      return `local:///${(basePath + imagePath).replaceAll('\\', '/')}`
+    }
+    return ''
+  }
+
   const handleStyle = () => {
     const { part, state, styles = [] } = param.props
     parts.forEach((partItem) => {
@@ -244,17 +270,10 @@ export const useWidgetStyle = (param: StyleParam) => {
 
         // 获取背景图片src及颜色
         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
-          if (basePath && imagePath) {
-            styleMap.value[`${partItem.name}Style`].imageSrc =
-              `local:///${(basePath + imagePath).replaceAll('\\', '/')}`
-            styleMap.value[`${partItem.name}Style`].imageStyle = {
-              backgroundColor: style?.[key]?.image?.color,
-              opacity: (style?.[key]?.image?.alpha || 255) / 255
-            }
+          styleMap.value[`${partItem.name}Style`].imageSrc = getImageSrc(style?.[key]?.image?.imgId)
+          styleMap.value[`${partItem.name}Style`].imageStyle = {
+            backgroundColor: style?.[key]?.image?.color,
+            opacity: (style?.[key]?.image?.alpha || 255) / 255
           }
         }
         // 图片样式
@@ -264,12 +283,26 @@ export const useWidgetStyle = (param: StyleParam) => {
             opacity: (style?.[key]?.alpha || 255) / 255
           }
         }
-        // 线段返回原本值
+        // 线段返回原本值,并解析 image 为 imageSrc/imageAlpha
         if (key === 'line') {
-          styleMap.value[`${partItem.name}Style`].line = style?.[key]
+          const lineData = style?.line
+          styleMap.value[`${partItem.name}Style`].line = {
+            color: lineData?.color ?? '',
+            width: lineData?.width ?? 0,
+            radius: lineData?.radius ?? false,
+            imageSrc: getImageSrc(lineData?.image),
+            opacity: (lineData?.alpha ?? 255) / 255
+          }
         }
         if (key === 'curve') {
-          styleMap.value[`${partItem.name}Style`].curve = style?.[key]
+          const curveData = style?.curve
+          styleMap.value[`${partItem.name}Style`].curve = {
+            color: curveData?.color ?? '',
+            width: curveData?.width ?? 0,
+            radius: curveData?.radius ?? false,
+            imageSrc: getImageSrc(curveData?.image),
+            opacity: (curveData?.alpha ?? 255) / 255
+          }
         }
       })
       // 处理行高 默认行高为1.2倍

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

@@ -21,7 +21,7 @@ import Window from './window/index'
 import Menu from './menu/index'
 
 import Line from './line/index'
-// import Arc from './arc'
+import Arc from './arc'
 
 import Page from './page'
 import { IComponentModelConfig } from './type'
@@ -51,8 +51,8 @@ export const ComponentArray = [
   Window,
   Menu,
 
-  Line
-  // Arc
+  Line,
+  Arc
 ]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

+ 11 - 14
src/renderer/src/lvgl-widgets/line/Line.vue

@@ -1,19 +1,14 @@
 <template>
-  <div :style="styleMap?.mainStyle" class="w-full h-full box-border overflow-hidden">
-    <svg
+  <div :style="styleMap?.mainStyle" class="relative w-full h-full box-border overflow-hidden">
+    <ImageBg v-if="styleMap?.mainStyle?.imageSrc" :src="styleMap?.mainStyle?.imageSrc"
+      :imageStyle="styleMap?.mainStyle?.imageStyle" />
+
+    <svg class="absolute left-0 top-0 z-2"
       :viewBox="`${-(strokeWidth / 2)} ${-(strokeWidth / 2)} ${width + strokeWidth} ${height + strokeWidth}`"
-      xmlns="http://www.w3.org/2000/svg"
-    >
-      <line
-        v-for="line in lines"
-        :x1="line.x1"
-        :y1="line.y1"
-        :x2="line.x2"
-        :y2="line.y2"
-        :stroke="styleMap?.mainStyle?.line?.color"
-        :stroke-width="strokeWidth"
-        :stroke-linecap="styleMap?.mainStyle?.line?.radius ? 'round' : 'butt'"
-      />
+      xmlns="http://www.w3.org/2000/svg">
+      <line v-for="line in lines" :x1="line.x1" :y1="line.y1" :x2="line.x2" :y2="line.y2"
+        :stroke="styleMap?.mainStyle?.line?.color" :stroke-width="strokeWidth"
+        :stroke-linecap="styleMap?.mainStyle?.line?.radius ? 'round' : 'butt'" />
     </svg>
   </div>
 </template>
@@ -22,6 +17,8 @@
 import { computed } from 'vue'
 import { useWidgetStyle } from '../hooks/useWidgetStyle'
 
+import ImageBg from '../ImageBg.vue'
+
 const props = defineProps<{
   width: number
   height: number

+ 16 - 1
src/renderer/src/lvgl-widgets/line/index.tsx

@@ -41,12 +41,27 @@ export default {
           state: 'default'
         },
         background: {
-          color: '#ffffff00'
+          color: '#ffffff00',
+          image: {
+            imgId: '',
+            color: '#ffffff00',
+            alpha: 255
+          }
         },
         line: {
           color: '#777777ff',
           width: 2,
           radius: true
+        },
+        transform: {
+          width: 0,
+          height: 0,
+          translateX: 0,
+          translateY: 0,
+          originX: 0,
+          originY: 0,
+          rotate: 0,
+          scale: 256
         }
       }
     ]

+ 24 - 13
src/renderer/src/lvgl-widgets/line/style.json

@@ -4,21 +4,32 @@
   "part": [
     {
       "partName": "main",
-      "state": [
-        {
-          "state": "default",
-          "style": {
-            "background": {
-              "color": "#ffffff00"
-            },
-            "line": {
-              "color": "#777777ff",
-              "width": 2,
-              "radius": true
-            }
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff00",
+          "image": {
+            "imgId": "",
+            "color": "#ffffff00",
+            "alpha": 255
           }
+        },
+        "line": {
+          "color": "#777777ff",
+          "width": 2,
+          "radius": true
+        },
+        "transform": {
+          "width": 0,
+          "height": 0,
+          "translateX": 0,
+          "translateY": 0,
+          "originX": 0,
+          "originY": 0,
+          "rotate": 0,
+          "scale": 256
         }
-      ]
+      },
+      "state": []
     }
   ]
 }

+ 8 - 0
src/renderer/src/lvgl-widgets/type.d.ts

@@ -287,6 +287,10 @@ export interface IStyleConfig {
     width: number
     // 圆角开关
     radius: boolean
+    // 透明度
+    alpha?: number
+    // 图像
+    image?: string
   }
   // 直线
   line?: {
@@ -296,6 +300,10 @@ export interface IStyleConfig {
     width: number
     // 圆角开关
     radius: boolean
+    // 透明度
+    alpha?: number
+    // 图像
+    image?: string
   }
   // 图像样式
   imageStyle?: {

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

@@ -234,6 +234,9 @@ export const useProjectStore = defineStore('project', () => {
       createTime: meta.createTime,
       modifyTime: meta.modifyTime
     })
+
+    // 锁定项目文件夹,防止用户在资源管理器中删除正在使用的项目目录
+    window.electron.ipcRenderer.invoke('lock-project-folder', `${meta.path}\\${meta.name}`)
   }
 
   /**
@@ -265,6 +268,8 @@ export const useProjectStore = defineStore('project', () => {
       modifyTime: newProject.meta.modifyTime!
     })
     clear()
+    // 锁定项目文件夹,防止用户在资源管理器中删除正在使用的项目目录
+    window.electron.ipcRenderer.invoke('lock-project-folder', path)
   }
 
   // 保存当前项目

+ 44 - 6
src/renderer/src/views/designer/config/property/components/StyleLine.vue

@@ -5,26 +5,41 @@
       <span class="text-text-active">{{ modelValue?.color }}</span>
     </el-form-item>
     <el-form-item label="宽度" label-position="left" label-width="60px">
-      <input-number
-        placeholder="请输入"
-        v-model="width"
-        controls-position="right"
-        style="width: 100%"
-      />
+      <input-number placeholder="请输入" v-model="width" controls-position="right" style="width: 100%" />
     </el-form-item>
     <el-form-item label="圆角" label-position="left" label-width="60px">
       <el-switch v-model="radius" />
     </el-form-item>
+
+    <el-form-item v-if="hasImage" label="图片" label-position="left" label-width="60px">
+      <ImageSelect v-model="image" />
+    </el-form-item>
+    <el-form-item v-if="hasImage" label="透明度" label-position="left" label-width="60px">
+      <div class="w-full flex gap-20px items-center">
+        <el-slider v-model="alpha" :max="255" :min="0" style="flex: 1"></el-slider>
+        <span class="text-text-active inline w-30px cursor-pointer">
+          {{ alpha }}
+        </span>
+      </div>
+    </el-form-item>
   </div>
 </template>
 
 <script setup lang="ts">
 import { computed } from 'vue'
 
+import ImageSelect from './ImageSelect.vue'
+
+defineProps<{
+  hasImage?: boolean
+}>()
+
 const modelValue = defineModel<{
   color: string
   width: number
   radius: boolean
+  image?: string
+  alpha?: number
 }>('modelValue')
 
 // color
@@ -56,6 +71,29 @@ const radius = computed({
     }
   }
 })
+
+// 图像透明度
+const alpha = computed({
+  get() {
+    return modelValue.value?.alpha
+  },
+  set(val: number) {
+    if (modelValue.value) {
+      modelValue.value.alpha = val
+    }
+  }
+})
+
+const image = computed({
+  get() {
+    return modelValue.value?.image
+  },
+  set(val: string) {
+    if (modelValue.value) {
+      modelValue.value.image = val ?? ''
+    }
+  }
+})
 </script>
 
 <style scoped></style>