Procházet zdrojové kódy

feat: 添加spinner控件

jiaxing.liao před 1 týdnem
rodič
revize
bb5c0b63c4

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

@@ -131,5 +131,6 @@
   "icon": "Icon",
   "led": "LED",
   "chart": "Chart",
-  "canvas": "Canvas"
+  "canvas": "Canvas",
+  "spinner": "Spinner"
 }

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

@@ -131,5 +131,6 @@
   "icon": "图标",
   "led": "LED",
   "chart": "图表",
-  "canvas": "画布"
+  "canvas": "画布",
+  "spinner": "加载器"
 }

+ 92 - 36
src/renderer/src/lvgl-widgets/arc/Arc.vue

@@ -1,48 +1,99 @@
 <template>
-  <div :style="{
-    ...styleMap?.mainStyle
-  }" class="relative w-full h-full box-border overflow-hidden relative">
+  <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"
+    />
 
-    <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">
+    <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">
+          <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
+            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')"
+        <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" />
+          :opacity="styleMap?.mainStyle?.curve?.opacity"
+        />
 
         <!-- 进度值条 -->
-        <path :d="valuePath" fill="none"
-          :stroke="styleMap?.indicatorStyle?.curve?.imageSrc ? `url(#${arcId}-indicator-pattern)` : (styleMap?.indicatorStyle?.curve?.color || '#2092f5')"
+        <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" />
+          :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'" />
+        <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">
+      <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>
@@ -52,9 +103,9 @@
 <script setup lang="ts">
 import { computed, ref } from 'vue'
 import { useWidgetStyle } from '../hooks/useWidgetStyle'
-import { useProjectStore } from '@/store/modules/project';
+import { useProjectStore } from '@/store/modules/project'
 
-import ImageBg from '../ImageBg.vue';
+import ImageBg from '../ImageBg.vue'
 
 const arcId = ref('arc-' + Math.random().toString(36).slice(2))
 
@@ -108,8 +159,12 @@ const knobRadius = computed(() => {
     ?.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 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
 
@@ -132,6 +187,10 @@ function polarToCartesian(
 ) {
   // LVGL 习惯通常 0 度在右侧,如果需要 0 度在上方,这里减去 90
   // 这里我们遵循标准:angleStart 为输入值
+  // 当angleInDegress为360时,需要偏移一点
+  if (angleInDegrees === 360) {
+    angleInDegrees = 359.999
+  }
   const radians = ((angleInDegrees - 0) * Math.PI) / 180.0
   return {
     x: centerX + radius * Math.cos(radians),
@@ -188,9 +247,7 @@ const progressData = computed(() => {
 
   const rangeDiff = rangeEnd - rangeStart
   const ratio =
-    Math.abs(rangeDiff) < 1e-9
-      ? 0
-      : Math.max(0, Math.min(1, (value - rangeStart) / rangeDiff))
+    Math.abs(rangeDiff) < 1e-9 ? 0 : Math.max(0, Math.min(1, (value - rangeStart) / rangeDiff))
 
   const span = clockwiseSpan.value
   let startA = angleStart
@@ -235,10 +292,9 @@ const valuePath = computed(() => {
 // 4. 圆点位置(与弧同半径)
 const dotPos = computed(() => {
   // 原始结束角:normal / symmetrical 用 endA,reverse 用 startA
-  const baseAngle =
-    props.mode === 'reverse' ? progressData.value.startA : progressData.value.endA
+  const baseAngle = props.mode === 'reverse' ? progressData.value.startA : progressData.value.endA
   // 仅对“结束值原点”做偏移,不整体偏移整条进度弧
-  const offset = props.rotateOffset ? props.rotateOffsetValue ?? 0 : 0
+  const offset = props.rotateOffset ? (props.rotateOffsetValue ?? 0) : 0
   const angle = baseAngle + offset
   return polarToCartesian(cx.value, cx.value, trackRadius.value, angle)
 })
@@ -251,8 +307,8 @@ const knobOverlayStyle = computed(() => {
   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}%`,
+    width: `${((r * 2) / props.width) * 100}%`,
+    height: `${((r * 2) / props.height) * 100}%`,
     borderRadius: '50%'
   }
 })

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

@@ -22,6 +22,7 @@ import Menu from './menu/index'
 
 import Line from './line/index'
 import Arc from './arc'
+import Spinner from './spinner'
 import Scale from './scale/index'
 import Led from './led/index'
 import Chart from './chart/index'
@@ -60,7 +61,8 @@ export const ComponentArray = [
   Scale,
   Led,
   Chart,
-  Canvas
+  Canvas,
+  Spinner
 ]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

+ 205 - 0
src/renderer/src/lvgl-widgets/spinner/Spinner.vue

@@ -0,0 +1,205 @@
+<template>
+  <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
+      :viewBox="`0 0 ${width} ${height}`"
+      preserveAspectRatio="xMidYMid meet"
+      class="w-full h-full block"
+      xmlns="http://www.w3.org/2000/svg"
+      :style="svgStyle"
+    >
+      <defs>
+        <!-- 背景条图片 pattern:保持图像原始尺寸,居中显示 -->
+        <pattern
+          v-if="styleMap?.mainStyle?.curve?.imageSrc"
+          :id="`${spinnerId}-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="`${spinnerId}-indicator-pattern`"
+          patternUnits="userSpaceOnUse"
+          :width="width"
+          :height="height"
+          x="0"
+          y="0"
+        >
+          <image
+            :href="styleMap.indicatorStyle.curve.imageSrc"
+            transform="translate(-50%, -50%)"
+          />
+        </pattern>
+      </defs>
+
+      <!-- 背景环:0 度从顶部开始,顺时针绘制整圆 -->
+      <path
+        :d="bgPath"
+        fill="none"
+        :stroke="
+          styleMap?.mainStyle?.curve?.imageSrc
+            ? `url(#${spinnerId}-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"
+      />
+
+      <!-- 指示环:从 0 度顶部开始,按顺时针方向延伸 length 度 -->
+      <path
+        v-if="indicatorPath"
+        :d="indicatorPath"
+        fill="none"
+        :stroke="
+          styleMap?.indicatorStyle?.curve?.imageSrc
+            ? `url(#${spinnerId}-indicator-pattern)`
+            : styleMap?.indicatorStyle?.curve?.color || '#2092f5'
+        "
+        :stroke-width="styleMap?.indicatorStyle?.curve?.width ??
+          styleMap?.mainStyle?.curve?.width ??
+          1"
+        :stroke-linecap="styleMap?.indicatorStyle?.curve?.radius ? 'round' : 'butt'"
+        :opacity="styleMap?.indicatorStyle?.curve?.opacity"
+      />
+    </svg>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import ImageBg from '../ImageBg.vue'
+
+const spinnerId = ref('spinner-' + Math.random().toString(36).slice(2))
+
+const props = defineProps<{
+  width: number
+  height: number
+  styles: any
+  state?: string
+  part?: string
+  length: number
+  time: number
+}>()
+
+const styleMap = useWidgetStyle({
+  widget: 'lv_spinner',
+  props
+})
+
+const cx = computed(() => {
+  const min = Math.min(props.width, props.height)
+  return min / 2
+})
+
+/** 弧线半径:与曲线宽度一致,留出边距避免贴边 */
+const trackRadius = computed(() => {
+  const min = Math.min(props.width, props.height)
+  const curveWidth = styleMap.value?.mainStyle?.curve?.width ?? 12
+  return Math.max(4, min / 2 - curveWidth / 2)
+})
+
+/**
+ * 极坐标转直角坐标
+ * 这里定义:0 度在顶部(12 点钟),角度按顺时针增加
+ */
+function polarToCartesian(
+  centerX: number,
+  centerY: number,
+  radius: number,
+  angleInDegrees: number
+) {
+  const radians = ((angleInDegrees - 90) * Math.PI) / 180.0
+  return {
+    x: centerX + radius * Math.cos(radians),
+    y: centerY + radius * Math.sin(radians)
+  }
+}
+
+/**
+ * 生成 SVG 弧线路径指令
+ * 始终从 startAngle 顺时针画到 endAngle
+ */
+function describeArc(x: number, y: number, radius: number, startAngle: number, endAngle: number) {
+  // 归一化到 [0, 360)
+  const s = ((startAngle % 360) + 360) % 360
+  const e = ((endAngle % 360) + 360) % 360
+
+  // 顺时针跨度
+  let span = (e - s + 360) % 360
+  // 0/360 视作整圆,避免 SVG 报错
+  if (span === 0) span = 359.999
+
+  const start = polarToCartesian(x, y, radius, s)
+  const end = polarToCartesian(x, y, radius, s + span)
+  const largeArcFlag = span > 180 ? '1' : '0'
+  const sweepFlag = '1' // 顺时针
+
+  return [
+    'M',
+    start.x,
+    start.y,
+    'A',
+    radius,
+    radius,
+    0,
+    largeArcFlag,
+    sweepFlag,
+    end.x,
+    end.y
+  ].join(' ')
+}
+
+// 背景整圆:0~360 度,0 在顶部
+const bgPath = computed(() => {
+  const r = trackRadius.value
+  return describeArc(cx.value, cx.value, r, 0, 360)
+})
+
+// 指示弧:从 0 度开始,顺时针 length 度
+const indicatorPath = computed(() => {
+  const r = trackRadius.value
+  const len = Math.max(0, Math.min(360, props.length ?? 0))
+  if (!len) return ''
+  return describeArc(cx.value, cx.value, r, 0, len)
+})
+
+// 旋转动画:使用 time 作为一圈的时长(ms)
+const svgStyle = computed(() => {
+  if (!props.time || props.time <= 0) return {}
+  return {
+    animation: `lv-spinner-rotate ${props.time}ms linear infinite`,
+    transformOrigin: '50% 50%'
+  }
+})
+</script>
+
+<style scoped>
+@keyframes lv-spinner-rotate {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>
+

+ 221 - 0
src/renderer/src/lvgl-widgets/spinner/index.ts

@@ -0,0 +1,221 @@
+import Spinner from './Spinner.vue'
+import icon from '../assets/icon/icon_23Led.svg'
+import { flagOptions, stateOptions, stateList } from '@/constants'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import defaultStyle from './style.json'
+
+export default {
+  label: i18n.global.t('spinner'),
+  icon,
+  component: Spinner,
+  key: 'lv_spinner',
+  group: i18n.global.t('display'),
+  sort: 1,
+  hasChildren: false,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    },
+    {
+      name: 'indicator',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'spinner',
+    props: {
+      x: 0,
+      y: 0,
+      width: 100,
+      height: 100,
+      flags: [],
+      states: [],
+      length: 200,
+      time: 2000
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#ffffff00',
+          image: {
+            imgId: '',
+            recolor: '#ffffff00',
+            alpha: 255
+          }
+        },
+        padding: {
+          top: 0,
+          right: 0,
+          bottom: 0,
+          left: 0
+        },
+        curve: {
+          color: '#e0e0e0ff',
+          width: 12,
+          radius: true
+        },
+        shadow: {
+          color: '#000000ff',
+          offsetX: 0,
+          offsetY: 0,
+          spread: 0,
+          width: 0
+        }
+      }
+    ]
+  },
+  config: {
+    // 组件属性
+    props: [
+      {
+        label: '名称',
+        field: 'name',
+        valueType: 'text',
+        componentProps: {
+          placeholder: '请输入名称',
+          type: 'text'
+        }
+      },
+      {
+        label: '位置/大小',
+        valueType: 'group',
+        children: [
+          {
+            label: '',
+            field: 'props.x',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'X' }
+          },
+          {
+            label: '',
+            field: 'props.y',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'Y' }
+          },
+          {
+            label: '',
+            field: 'props.width',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'W' }
+          },
+          {
+            label: '',
+            field: 'props.height',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'H' }
+          }
+        ]
+      },
+      {
+        label: '标识',
+        field: 'props.flags',
+        valueType: 'checkbox',
+        componentProps: {
+          options: flagOptions,
+          defaultCollapsed: true
+        }
+      },
+      {
+        label: '状态',
+        field: 'props.states',
+        valueType: 'checkbox',
+        componentProps: {
+          options: stateOptions,
+          defaultCollapsed: true
+        }
+      }
+    ],
+    coreProps: [
+      {
+        label: '弧长',
+        field: 'props.length',
+        valueType: 'number',
+        componentProps: {
+          min: 0,
+          max: 360
+        }
+      },
+      {
+        label: '时间',
+        field: 'props.time',
+        valueType: 'number',
+        componentProps: {
+          min: 0,
+          max: 100000
+        },
+        slots: { suffix: 'ms' }
+      }
+    ],
+    // 组件样式
+    styles: [
+      {
+        label: '模块状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        valueType: 'dependency',
+        name: ['part'],
+        dependency: ({ part }) => {
+          return part?.name === 'main'
+            ? [
+                {
+                  label: '背景',
+                  field: 'background',
+                  valueType: 'background'
+                },
+                {
+                  label: '内边距',
+                  field: 'padding',
+                  valueType: 'padding'
+                },
+                {
+                  label: '曲线',
+                  field: 'curve',
+                  valueType: 'line'
+                },
+                {
+                  label: '阴影',
+                  field: 'shadow',
+                  valueType: 'shadow'
+                }
+              ]
+            : [
+                {
+                  label: '曲线',
+                  field: 'curve',
+                  valueType: 'line'
+                }
+              ]
+        }
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 49 - 0
src/renderer/src/lvgl-widgets/spinner/style.json

@@ -0,0 +1,49 @@
+{
+  "widget": "lv_spinner",
+  "styleName": "defualt",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff00",
+          "image": {
+            "imgId": "",
+            "recolor": "#ffffff00",
+            "alpha": 255
+          }
+        },
+        "padding": {
+          "top": 0,
+          "right": 0,
+          "bottom": 0,
+          "left": 0
+        },
+        "curve": {
+          "color": "#e0e0e0ff",
+          "width": 12,
+          "radius": true
+        },
+        "shadow": {
+          "color": "#000000ff",
+          "offsetX": 0,
+          "offsetY": 0,
+          "spread": 0,
+          "width": 0
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "indicator",
+      "defaultStyle": {
+        "curve": {
+          "color": "#2196f3ff",
+          "width": 12,
+          "radius": true
+        }
+      },
+      "state": []
+    }
+  ]
+}