Просмотр исходного кода

feat: 添加基础仪表盘控件

jiaxing.liao 6 дней назад
Родитель
Сommit
1571349672

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

@@ -148,5 +148,7 @@
   "media": "Media",
   "advance": "Advance",
   "qrcode": "QRCode",
-  "barcode": "Barcode"
+  "barcode": "Barcode",
+  "baseMeter": "Base Meter",
+  "Meter": "Meter"
 }

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

@@ -147,5 +147,7 @@
   "media": "多媒体",
   "advance": "高级",
   "qrcode": "二维码",
-  "barcode": "条形码"
+  "barcode": "条形码",
+  "baseMeter": "基础仪表",
+  "Meter": "仪表"
 }

+ 34 - 1
src/renderer/src/lvgl-widgets/barcode/index.ts

@@ -2,7 +2,7 @@ import Barcode from './Barcode.vue'
 import icon from '../assets/icon/icon_41barcode.svg'
 import type { IComponentModelConfig } from '../type'
 import i18n from '@/locales'
-import { stateList } from '@/constants'
+import { stateList, flagOptions, stateOptions } from '@/constants'
 import defaultStyle from './style.json'
 import { getCode128BUnitLength } from './code128'
 
@@ -68,6 +68,21 @@ export default {
     props: {
       x: 0,
       y: 0,
+      flags: [
+        'LV_OBJ_FLAG_CLICKABLE',
+        'LV_OBJ_FLAG_CLICK_FOCUSABLE',
+        'LV_OBJ_FLAG_SCROLLABLE',
+        'LV_OBJ_FLAG_SCROLL_ELASTIC',
+        'LV_OBJ_FLAG_SCROLL_MOMENTUM',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_HOR',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_VER',
+        'LV_OBJ_FLAG_SCROLL_CHAIN',
+        'LV_OBJ_FLAG_SCROLL_WITH_ARROW',
+        'LV_OBJ_FLAG_SNAPPABLE',
+        'LV_OBJ_FLAG_PRESS_LOCK',
+        'LV_OBJ_FLAG_GESTURE_BUBBLE'
+      ],
+      states: [],
       width: getBarcodeWidth(DEFAULT_TEXT, DEFAULT_SCALE, DEFAULT_DIRECTION),
       height: BASE_THICKNESS,
       text: DEFAULT_TEXT,
@@ -123,6 +138,24 @@ export default {
             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: [

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

@@ -0,0 +1,648 @@
+<template>
+  <div :style="styleMap?.mainStyle" class="relative w-full h-full box-border overflow-visible">
+    <div
+      v-if="mainBackgroundImageSrc"
+      class="absolute pointer-events-none flex items-center justify-center overflow-hidden"
+      :style="backgroundImageWrapStyle"
+    >
+      <ImageBg
+        v-if="mainBackgroundUseMask"
+        :src="mainBackgroundImageSrc"
+        :imageStyle="mainBackgroundMaskStyle"
+      />
+      <img
+        v-else
+        :src="mainBackgroundImageSrc"
+        alt=""
+        class="block max-w-none max-h-none"
+        :style="{ opacity: mainBackgroundOpacity }"
+      />
+    </div>
+
+    <svg
+      :viewBox="`0 0 ${width} ${height}`"
+      class="block w-full h-full overflow-visible"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <defs>
+        <pattern
+          v-if="mainCurveImageSrc"
+          :id="mainCurvePatternId"
+          patternUnits="userSpaceOnUse"
+          :x="curvePatternBox.x"
+          :y="curvePatternBox.y"
+          :width="curvePatternBox.size"
+          :height="curvePatternBox.size"
+        >
+          <image
+            :href="mainCurveImageSrc"
+            :x="curvePatternBox.x"
+            :y="curvePatternBox.y"
+            :width="curvePatternBox.size"
+            :height="curvePatternBox.size"
+            preserveAspectRatio="xMidYMid meet"
+            :opacity="mainCurveOpacity"
+          />
+        </pattern>
+      </defs>
+
+      <path
+        v-if="mainCurveWidth > 0 && mainArcPath"
+        :d="mainArcPath"
+        fill="none"
+        :stroke="mainCurveStroke"
+        :stroke-width="mainCurveWidth"
+        :stroke-linecap="mainCurveLinecap"
+      />
+
+      <path
+        v-for="section in sectionCurves"
+        :key="section.key"
+        :d="section.path"
+        fill="none"
+        :stroke="section.stroke"
+        :stroke-width="section.strokeWidth"
+        :stroke-linecap="mainCurveLinecap"
+      />
+
+      <line
+        v-for="tick in minorTicks"
+        :key="tick.key"
+        :x1="tick.x1"
+        :y1="tick.y1"
+        :x2="tick.x2"
+        :y2="tick.y2"
+        :stroke="tick.stroke"
+        :stroke-width="tick.strokeWidth"
+        :stroke-linecap="tick.linecap"
+      />
+
+      <line
+        v-for="tick in majorTicks"
+        :key="tick.key"
+        :x1="tick.x1"
+        :y1="tick.y1"
+        :x2="tick.x2"
+        :y2="tick.y2"
+        :stroke="tick.stroke"
+        :stroke-width="tick.strokeWidth"
+        :stroke-linecap="tick.linecap"
+      />
+
+      <text
+        v-for="label in labelItems"
+        :key="label.key"
+        :x="label.x"
+        :y="label.y"
+        text-anchor="middle"
+        dominant-baseline="middle"
+        :fill="label.fill"
+        :font-size="label.fontSize"
+        :font-family="label.fontFamily"
+        :letter-spacing="label.letterSpacing"
+        :text-decoration="label.decoration"
+      >
+        {{ label.text }}
+      </text>
+
+      <line
+        v-for="needle in lineNeedles"
+        :key="needle.key"
+        :x1="needle.x1"
+        :y1="needle.y1"
+        :x2="needle.x2"
+        :y2="needle.y2"
+        :stroke="needle.stroke"
+        :stroke-width="needle.strokeWidth"
+        :stroke-linecap="needle.linecap"
+      />
+    </svg>
+
+    <div
+      v-for="needle in imageNeedles"
+      :key="needle.key"
+      class="absolute pointer-events-none"
+      :style="needle.style"
+    >
+      <img
+        v-if="!needle.useMask"
+        :src="needle.src"
+        class="block w-full h-full"
+        :style="{ opacity: needle.opacity }"
+        alt=""
+      />
+      <ImageBg v-else :src="needle.src" :imageStyle="needle.imageStyle" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, type CSSProperties } from 'vue'
+import { useProjectStore } from '@/store/modules/project'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import ImageBg from '../ImageBg.vue'
+import type { BaseMeterNeedle, BaseMeterSection } from './types'
+
+const props = defineProps<{
+  id?: string
+  width: number
+  height: number
+  styles: any[]
+  state?: string
+  part?: string
+  mode: 'round_inner' | 'round_outer'
+  tick: number
+  mainTick: number
+  rangeStart: number
+  rangeEnd: number
+  angleRange: number
+  rotationAngle: number
+  enableText: boolean
+  labels: string
+  needles: BaseMeterNeedle[]
+  sections: BaseMeterSection[]
+}>()
+
+const projectStore = useProjectStore()
+
+const styleMap = useWidgetStyle({
+  widget: 'base_meter',
+  props
+})
+
+const getStyleConfig = (partName: string) => {
+  const targetState = props.part === partName ? props.state : 'default'
+
+  return (
+    props.styles?.find(
+      (item) => item.part?.name === partName && item.part?.state === targetState
+    ) ||
+    props.styles?.find((item) => item.part?.name === partName && item.part?.state === 'default')
+  )
+}
+
+const indicatorStyleConfig = computed(() => getStyleConfig('indicator'))
+const itemsStyleConfig = computed(() => getStyleConfig('items'))
+
+const clamp = (value: number, min: number, max: number) => {
+  return Math.min(max, Math.max(min, value))
+}
+
+const isTransparentColor = (color?: string) => {
+  if (!color) return true
+  const normalized = color.trim().toLowerCase()
+  return normalized === '#00000000' || normalized.endsWith('00')
+}
+
+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 width = computed(() => Math.max(1, Number(props.width) || 1))
+const height = computed(() => Math.max(1, Number(props.height) || 1))
+const minSide = computed(() => Math.min(width.value, height.value))
+const center = computed(() => ({
+  x: width.value / 2,
+  y: height.value / 2
+}))
+const halfMin = computed(() => minSide.value / 2)
+const maxLineWidth = computed(() => Math.max(0, halfMin.value))
+
+const mainCurveWidth = computed(() =>
+  clamp(Number(styleMap.value?.mainStyle?.curve?.width ?? 2), 0, maxLineWidth.value)
+)
+const mainCurveColor = computed(() =>
+  String(styleMap.value?.mainStyle?.curve?.color ?? '#212121FF')
+)
+const mainCurveLinecap = computed(() =>
+  styleMap.value?.mainStyle?.curve?.radius ? ('round' as const) : ('butt' as const)
+)
+const mainCurveImageSrc = computed(() => String(styleMap.value?.mainStyle?.curve?.imageSrc ?? ''))
+const mainCurveOpacity = computed(() => Number(styleMap.value?.mainStyle?.curve?.opacity ?? 1))
+const mainCurvePatternId = computed(() => `base-meter-curve-${props.id || 'default'}`)
+
+const mainTextColor = computed(() => String(styleMap.value?.mainStyle?.color ?? '#212121FF'))
+const mainFontSize = computed(() =>
+  Number.parseFloat(String(styleMap.value?.mainStyle?.fontSize ?? '14'))
+)
+const mainFontFamily = computed(() => String(styleMap.value?.mainStyle?.fontFamily ?? 'sans-serif'))
+const mainLetterSpacing = computed(() => String(styleMap.value?.mainStyle?.letterSpacing ?? '0px'))
+const mainTextDecoration = computed(() =>
+  String(styleMap.value?.mainStyle?.textDecoration ?? 'none')
+)
+
+const mainBackgroundImageSrc = computed(() => String(styleMap.value?.mainStyle?.imageSrc ?? ''))
+const mainBackgroundMaskStyle = computed(
+  () =>
+    ({
+      backgroundColor: styleMap.value?.mainStyle?.imageStyle?.backgroundColor,
+      opacity: styleMap.value?.mainStyle?.imageStyle?.opacity
+    }) as CSSProperties
+)
+const mainBackgroundOpacity = computed(() => Number(mainBackgroundMaskStyle.value.opacity ?? 1))
+const mainBackgroundUseMask = computed(() => {
+  return !isTransparentColor(String(mainBackgroundMaskStyle.value.backgroundColor ?? ''))
+})
+const backgroundImageWrapStyle = computed(() => {
+  return {
+    left: '-100px',
+    top: '-100px',
+    width: `${width.value + 200}px`,
+    height: `${height.value + 200}px`
+  }
+})
+
+const indicatorLength = computed(() =>
+  Math.max(0, Number(indicatorStyleConfig.value?.other?.length ?? 10))
+)
+const itemsLength = computed(() => Math.max(0, Number(itemsStyleConfig.value?.other?.length ?? 5)))
+const indicatorLineWidth = computed(() =>
+  clamp(Number(styleMap.value?.indicatorStyle?.line?.width ?? 2), 0, maxLineWidth.value)
+)
+const itemsLineWidth = computed(() =>
+  clamp(Number(styleMap.value?.itemsStyle?.line?.width ?? 2), 0, maxLineWidth.value)
+)
+const indicatorLineColor = computed(() =>
+  String(styleMap.value?.indicatorStyle?.line?.color ?? '#212121FF')
+)
+const itemsLineColor = computed(() =>
+  String(styleMap.value?.itemsStyle?.line?.color ?? '#212121FF')
+)
+const indicatorLinecap = computed(() =>
+  styleMap.value?.indicatorStyle?.line?.radius ? ('round' as const) : ('butt' as const)
+)
+const itemsLinecap = computed(() =>
+  styleMap.value?.itemsStyle?.line?.radius ? ('round' as const) : ('butt' as const)
+)
+
+const outerReserve = computed(() => {
+  if (props.mode === 'round_outer') {
+    return 0
+  }
+  return 8
+})
+
+const curveRadius = computed(() => {
+  const radius = halfMin.value - outerReserve.value - mainCurveWidth.value / 2
+  return Math.max(0, radius)
+})
+
+const curvePatternBox = computed(() => {
+  return {
+    x: center.value.x - minSide.value / 2,
+    y: center.value.y - minSide.value / 2,
+    size: minSide.value
+  }
+})
+
+const polarPoint = (radius: number, angleDeg: number) => {
+  const radians = (angleDeg * Math.PI) / 180
+  return {
+    x: center.value.x + Math.cos(radians) * radius,
+    y: center.value.y + Math.sin(radians) * radius
+  }
+}
+
+const describeArc = (radius: number, startAngle: number, sweepAngle: number) => {
+  if (radius <= 0 || sweepAngle <= 0) return ''
+
+  let normalizedSweep = sweepAngle % 360
+  if (normalizedSweep === 0) {
+    normalizedSweep = 359.999
+  }
+  if (normalizedSweep < 0) {
+    normalizedSweep += 360
+  }
+
+  const start = polarPoint(radius, startAngle)
+  const end = polarPoint(radius, startAngle + normalizedSweep)
+  const largeArcFlag = normalizedSweep > 180 ? '1' : '0'
+
+  return ['M', start.x, start.y, 'A', radius, radius, 0, largeArcFlag, 1, end.x, end.y].join(' ')
+}
+
+const mainArcPath = computed(() =>
+  describeArc(
+    curveRadius.value,
+    Number(props.rotationAngle) || 0,
+    clamp(Number(props.angleRange) || 0, 0, 360)
+  )
+)
+const mainCurveStroke = computed(() => {
+  return mainCurveImageSrc.value ? `url(#${mainCurvePatternId.value})` : mainCurveColor.value
+})
+
+const normalizedSections = computed(() => {
+  return (props.sections || []).map((section) => {
+    const start = Math.min(Number(section.start) || 0, Number(section.end) || 0)
+    const end = Math.max(Number(section.start) || 0, Number(section.end) || 0)
+
+    return {
+      ...section,
+      start,
+      end
+    }
+  })
+})
+
+const rangeMin = computed(() =>
+  Math.min(Number(props.rangeStart) || 0, Number(props.rangeEnd) || 0)
+)
+const rangeMax = computed(() =>
+  Math.max(Number(props.rangeStart) || 0, Number(props.rangeEnd) || 0)
+)
+const rangeSpan = computed(() => (Number(props.rangeEnd) || 0) - (Number(props.rangeStart) || 0))
+
+const normalizeValue = (value: number) => {
+  if (rangeSpan.value === 0) return 0
+  return clamp((value - (Number(props.rangeStart) || 0)) / rangeSpan.value, 0, 1)
+}
+
+const valueAt = (t: number) => {
+  return (Number(props.rangeStart) || 0) + rangeSpan.value * t
+}
+
+const angleAt = (t: number) => {
+  return (Number(props.rotationAngle) || 0) + clamp(Number(props.angleRange) || 0, 0, 360) * t
+}
+
+const valueToAngle = (value: number) => angleAt(normalizeValue(value))
+
+const getSectionForValue = (value: number) => {
+  return normalizedSections.value.find((section) => value >= section.start && value <= section.end)
+}
+
+const sectionCurves = computed(() => {
+  return normalizedSections.value
+    .map((section, index) => {
+      const startValue = clamp(section.start, rangeMin.value, rangeMax.value)
+      const endValue = clamp(section.end, rangeMin.value, rangeMax.value)
+      const startT = normalizeValue(startValue)
+      const endT = normalizeValue(endValue)
+      const pathStart = Math.min(startT, endT)
+      const pathEnd = Math.max(startT, endT)
+      const startAngle = angleAt(pathStart)
+      const path = describeArc(
+        curveRadius.value,
+        startAngle,
+        clamp(Number(props.angleRange) || 0, 0, 360) * (pathEnd - pathStart)
+      )
+      const strokeWidth = clamp(Number(section.curve.width) || 0, 0, 100)
+
+      return {
+        key: `section-curve-${index}`,
+        path,
+        stroke: String(section.curve.color || '#FF00FFFF'),
+        strokeWidth
+      }
+    })
+    .filter((section) => section.path && section.strokeWidth > 0)
+})
+
+const tickCount = computed(() => Math.max(2, Math.min(1000, Math.floor(Number(props.tick) || 41))))
+const majorTickGap = computed(() =>
+  Math.max(1, Math.min(tickCount.value - 1, Math.floor(Number(props.mainTick) || 8)))
+)
+
+const majorIndices = computed(() => {
+  const set = new Set<number>()
+  const lastIndex = tickCount.value - 1
+
+  for (let index = 0; index <= lastIndex; index += majorTickGap.value) {
+    set.add(index)
+  }
+
+  return set
+})
+
+const tickPositions = computed(() => {
+  const list: { index: number; value: number; angle: number; isMajor: boolean }[] = []
+  const lastIndex = tickCount.value - 1
+
+  for (let index = 0; index < tickCount.value; index++) {
+    const t = lastIndex <= 0 ? 0 : index / lastIndex
+    list.push({
+      index,
+      value: valueAt(t),
+      angle: angleAt(t),
+      isMajor: majorIndices.value.has(index)
+    })
+  }
+
+  return list
+})
+
+const createTickLine = (
+  prefix: string,
+  index: number,
+  angle: number,
+  length: number,
+  widthValue: number,
+  color: string,
+  linecap: 'round' | 'butt'
+) => {
+  const tickStartRadius =
+    props.mode === 'round_outer'
+      ? curveRadius.value + mainCurveWidth.value / 2
+      : curveRadius.value - mainCurveWidth.value / 2
+  const tickEndRadius =
+    props.mode === 'round_outer' ? tickStartRadius + length : tickStartRadius - length
+  const start = polarPoint(Math.max(0, tickStartRadius), angle)
+  const end = polarPoint(Math.max(0, tickEndRadius), angle)
+
+  return {
+    key: `${prefix}-${index}`,
+    x1: start.x,
+    y1: start.y,
+    x2: end.x,
+    y2: end.y,
+    stroke: color,
+    strokeWidth: widthValue,
+    linecap
+  }
+}
+
+const majorTicks = computed(() => {
+  return tickPositions.value
+    .filter((tick) => tick.isMajor)
+    .map((tick) => {
+      const section = getSectionForValue(tick.value)
+      const color = String(section?.line?.color || indicatorLineColor.value)
+      const strokeWidth = clamp(
+        Number(section?.line?.width ?? indicatorLineWidth.value),
+        0,
+        maxLineWidth.value
+      )
+
+      return createTickLine(
+        'major-tick',
+        tick.index,
+        tick.angle,
+        indicatorLength.value,
+        strokeWidth,
+        color,
+        indicatorLinecap.value
+      )
+    })
+    .filter((tick) => tick.strokeWidth > 0)
+})
+
+const minorTicks = computed(() => {
+  return tickPositions.value
+    .filter((tick) => !tick.isMajor)
+    .map((tick) => {
+      const section = getSectionForValue(tick.value)
+      const color = String(section?.line?.color || itemsLineColor.value)
+      const strokeWidth = clamp(
+        Number(section?.line?.width ?? itemsLineWidth.value),
+        0,
+        maxLineWidth.value
+      )
+
+      return createTickLine(
+        'minor-tick',
+        tick.index,
+        tick.angle,
+        itemsLength.value,
+        strokeWidth,
+        color,
+        itemsLinecap.value
+      )
+    })
+    .filter((tick) => tick.strokeWidth > 0)
+})
+
+const formatLabelValue = (value: number) => {
+  return String(Math.round(value))
+}
+
+const labelTexts = computed(() => {
+  const raw = (props.labels || '').trim()
+  if (raw) {
+    return raw
+      .split(',')
+      .map((item) => item.trim())
+      .filter(Boolean)
+  }
+
+  return tickPositions.value
+    .filter((tick) => tick.isMajor)
+    .map((tick) => formatLabelValue(tick.value))
+})
+
+const labelRadius = computed(() => {
+  if (props.mode === 'round_outer') {
+    return (
+      curveRadius.value +
+      mainCurveWidth.value / 2 +
+      indicatorLength.value +
+      mainFontSize.value / 2 +
+      6
+    )
+  }
+
+  return Math.max(
+    0,
+    curveRadius.value -
+      mainCurveWidth.value / 2 -
+      indicatorLength.value -
+      mainFontSize.value / 2 -
+      6
+  )
+})
+
+const labelItems = computed(() => {
+  if (!props.enableText) return []
+
+  const majorTicksOnly = tickPositions.value.filter((tick) => tick.isMajor)
+
+  return majorTicksOnly
+    .map((tick, index) => {
+      const text = labelTexts.value[index]
+      if (!text) return null
+
+      const point = polarPoint(labelRadius.value, tick.angle)
+      const section = getSectionForValue(tick.value)
+
+      return {
+        key: `label-${tick.index}`,
+        x: point.x,
+        y: point.y,
+        text,
+        fill: String(section?.text || mainTextColor.value),
+        fontSize: mainFontSize.value,
+        fontFamily: mainFontFamily.value,
+        letterSpacing: mainLetterSpacing.value,
+        decoration: mainTextDecoration.value
+      }
+    })
+    .filter((item): item is NonNullable<typeof item> => !!item)
+})
+
+const createLineNeedle = (needle: BaseMeterNeedle, index: number) => {
+  if (needle.type !== 'line') return null
+
+  const angle = valueToAngle(Number(needle.value) || 0)
+  const point = polarPoint(Math.max(0, Number(needle.line.size.height) || 0), angle)
+  const strokeWidth = clamp(Number(needle.line.size.width) || 0, 0, maxLineWidth.value)
+
+  return {
+    key: `line-needle-${index}`,
+    x1: center.value.x,
+    y1: center.value.y,
+    x2: point.x,
+    y2: point.y,
+    stroke: String(needle.line.color || '#212121FF'),
+    strokeWidth,
+    linecap: needle.line.round ? ('round' as const) : ('butt' as const)
+  }
+}
+
+const lineNeedles = computed(() => {
+  return (props.needles || [])
+    .map((needle, index) => createLineNeedle(needle, index))
+    .filter((needle): needle is NonNullable<typeof needle> => !!needle && needle.strokeWidth > 0)
+})
+
+const createImageNeedle = (needle: BaseMeterNeedle, index: number) => {
+  if (needle.type !== 'image' || !needle.image.image) return null
+  const src = getImageSrc(needle.image.image)
+  if (!src) return null
+
+  const angle = valueToAngle(Number(needle.value) || 0)
+  const widthValue = Math.max(1, Number(needle.image.size.width) || 1)
+  const heightValue = Math.max(1, Number(needle.image.size.height) || 1)
+  const centerX = Number(needle.image.center.x) || 0
+  const centerY = Number(needle.image.center.y) || 0
+  const recolor = String(needle.image.recolor || '#00000000')
+  const opacity = clamp(Number(needle.image.alpha ?? 255), 0, 255) / 255
+
+  return {
+    key: `image-needle-${index}`,
+    src,
+    useMask: !isTransparentColor(recolor),
+    opacity,
+    imageStyle: {
+      backgroundColor: recolor,
+      opacity
+    } as CSSProperties,
+    style: {
+      left: `${center.value.x - centerX}px`,
+      top: `${center.value.y - centerY}px`,
+      width: `${widthValue}px`,
+      height: `${heightValue}px`,
+      transform: `rotate(${angle}deg)`,
+      transformOrigin: `${centerX}px ${centerY}px`
+    } as CSSProperties
+  }
+}
+
+const imageNeedles = computed(() => {
+  return (props.needles || [])
+    .map((needle, index) => createImageNeedle(needle, index))
+    .filter((needle): needle is NonNullable<typeof needle> => !!needle)
+})
+</script>

+ 240 - 0
src/renderer/src/lvgl-widgets/base-meter/NeedlesConfig.vue

@@ -0,0 +1,240 @@
+<template>
+  <div>
+    <el-card
+      class="mb-12px"
+      :body-class="needleList.length ? 'pr-0!' : 'hidden'"
+      :header-class="needleList.length ? '' : 'border-b-none!'"
+    >
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span>{{ TEXT.title }}</span>
+          <span class="flex gap-4px">
+            <LuPlus class="cursor-pointer" size="14px" @click="handleAdd" />
+            <LuTrash2 class="cursor-pointer" size="14px" @click="handleClear" />
+          </span>
+        </div>
+      </template>
+
+      <el-scrollbar v-if="needleList.length" height="140px">
+        <div
+          v-for="(item, index) in needleList"
+          :key="item.name"
+          class="flex items-center pr-12px"
+          @click="handleEdit(item, index)"
+        >
+          <span class="flex-1 truncate text-#00ff00 cursor-pointer">
+            {{ item.name }} ({{ item.value }})
+          </span>
+          <LuTrash2 class="cursor-pointer shrink-0" size="14px" @click.stop="handleDelete(index)" />
+        </div>
+      </el-scrollbar>
+    </el-card>
+
+    <el-dialog v-model="dialogVisible" :title="TEXT.editTitle" width="520px" draggable>
+      <el-form v-if="formData" :model="formData" label-position="left" label-width="90px">
+        <el-form-item :label="TEXT.name">
+          <el-input v-model="formData.name" spellcheck="false" readonly />
+        </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-form-item>
+
+        <el-form-item :label="TEXT.value">
+          <input-number
+            v-model="formData.value"
+            controls-position="right"
+            style="width: 100%"
+            :min="-100000"
+            :max="100000"
+          />
+        </el-form-item>
+
+        <template v-if="formData.type === 'image'">
+          <el-form-item :label="TEXT.image">
+            <ImageSelect v-model="formData.image.image" />
+          </el-form-item>
+
+          <el-form-item :label="TEXT.imageAlpha">
+            <div class="w-full flex items-center gap-16px">
+              <el-slider v-model="formData.image.alpha" :min="0" :max="255" style="flex: 1" />
+              <span class="text-text-active w-40px text-right">{{ formData.image.alpha }}</span>
+            </div>
+          </el-form-item>
+
+          <el-form-item :label="TEXT.imageMask">
+            <ColorPicker
+              v-model:pureColor="formData.image.recolor"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.image.recolor }}</span>
+          </el-form-item>
+
+          <el-form-item :label="TEXT.size">
+            <div class="w-full flex gap-8px">
+              <input-number
+                v-model="formData.image.size.width"
+                controls-position="right"
+                :min="1"
+                :max="1000"
+              >
+                <template #prefix>W</template>
+              </input-number>
+              <input-number
+                v-model="formData.image.size.height"
+                controls-position="right"
+                :min="1"
+                :max="1000"
+              >
+                <template #prefix>H</template>
+              </input-number>
+            </div>
+          </el-form-item>
+
+          <el-form-item :label="TEXT.center">
+            <div class="w-full flex gap-8px">
+              <input-number
+                v-model="formData.image.center.x"
+                controls-position="right"
+                :min="-1000"
+                :max="1000"
+              >
+                <template #prefix>X</template>
+              </input-number>
+              <input-number
+                v-model="formData.image.center.y"
+                controls-position="right"
+                :min="-1000"
+                :max="1000"
+              >
+                <template #prefix>Y</template>
+              </input-number>
+            </div>
+          </el-form-item>
+        </template>
+
+        <template v-else>
+          <el-form-item :label="TEXT.lineColor">
+            <ColorPicker
+              v-model:pureColor="formData.line.color"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.line.color }}</span>
+          </el-form-item>
+
+          <el-form-item :label="TEXT.round">
+            <el-switch v-model="formData.line.round" />
+          </el-form-item>
+
+          <el-form-item :label="TEXT.size">
+            <div class="w-full flex gap-8px">
+              <input-number
+                v-model="formData.line.size.width"
+                controls-position="right"
+                :min="1"
+                :max="10000"
+              >
+                <template #prefix>W</template>
+              </input-number>
+              <input-number
+                v-model="formData.line.size.height"
+                controls-position="right"
+                :min="0"
+                :max="10000"
+              >
+                <template #prefix>H</template>
+              </input-number>
+            </div>
+          </el-form-item>
+        </template>
+      </el-form>
+
+      <template #footer>
+        <el-button type="primary" @click="submit">{{ TEXT.confirm }}</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, type Ref } from 'vue'
+import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+import { klona } from 'klona'
+import { ColorPicker } from '@/components'
+import ImageSelect from '@/views/designer/config/property/components/ImageSelect.vue'
+import { createNeedle, getNextNeedleIndex, type BaseMeterNeedle } from './types'
+
+const TEXT = {
+  title: '指针',
+  editTitle: '编辑指针',
+  name: '名称',
+  type: '指针类型',
+  value: '值',
+  image: '图片',
+  imageAlpha: '图片透明度',
+  imageMask: '图片遮罩',
+  size: '大小',
+  center: '中心',
+  lineColor: '指针颜色',
+  round: '圆角开关',
+  confirm: '确定'
+} as const
+
+const pointerTypeOptions = [
+  { label: '图片指针', value: 'image' },
+  { label: '线指针', value: 'line' }
+] as const
+
+const props = defineProps<{
+  values: Ref<BaseMeterNeedle[]>
+}>()
+
+const dialogVisible = ref(false)
+const editingIndex = ref<number | null>(null)
+const formData = ref<BaseMeterNeedle>()
+
+const needleList = computed({
+  get() {
+    return props.values?.value || []
+  },
+  set(list: BaseMeterNeedle[]) {
+    props.values.value = list
+  }
+})
+
+const handleAdd = () => {
+  const nextIndex = getNextNeedleIndex(needleList.value)
+  needleList.value.push(createNeedle(`needles_${nextIndex}`))
+}
+
+const handleDelete = (index: number) => {
+  needleList.value.splice(index, 1)
+}
+
+const handleClear = () => {
+  needleList.value = []
+}
+
+const handleEdit = (needle: BaseMeterNeedle, index: number) => {
+  editingIndex.value = index
+  formData.value = klona(needle)
+  dialogVisible.value = true
+}
+
+const submit = () => {
+  if (editingIndex.value === null || !formData.value) return
+  needleList.value[editingIndex.value] = klona(formData.value)
+  dialogVisible.value = false
+}
+</script>

+ 179 - 0
src/renderer/src/lvgl-widgets/base-meter/SectionsConfig.vue

@@ -0,0 +1,179 @@
+<template>
+  <div>
+    <el-card
+      class="mb-12px"
+      :body-class="sectionList.length ? 'pr-0!' : 'hidden'"
+      :header-class="sectionList.length ? '' : 'border-b-none!'"
+    >
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span>{{ TEXT.title }}</span>
+          <span class="flex gap-4px">
+            <LuPlus class="cursor-pointer" size="14px" @click="handleAdd" />
+            <LuTrash2 class="cursor-pointer" size="14px" @click="handleClear" />
+          </span>
+        </div>
+      </template>
+
+      <el-scrollbar v-if="sectionList.length" height="140px">
+        <div
+          v-for="(item, index) in sectionList"
+          :key="item.name"
+          class="flex items-center pr-12px"
+          @click="handleEdit(item, index)"
+        >
+          <span class="flex-1 truncate text-#00ff00 cursor-pointer">
+            {{ item.name }} ({{ item.start }} - {{ item.end }})
+          </span>
+          <LuTrash2 class="cursor-pointer shrink-0" size="14px" @click.stop="handleDelete(index)" />
+        </div>
+      </el-scrollbar>
+    </el-card>
+
+    <el-dialog v-model="dialogVisible" :title="TEXT.editTitle" width="520px" draggable>
+      <el-form v-if="formData" :model="formData" label-position="left" label-width="90px">
+        <el-form-item :label="TEXT.name">
+          <el-input v-model="formData.name" spellcheck="false" readonly />
+        </el-form-item>
+
+        <el-form-item :label="TEXT.start">
+          <input-number
+            v-model="formData.start"
+            controls-position="right"
+            style="width: 100%"
+            :min="-100000"
+            :max="100000"
+          />
+        </el-form-item>
+
+        <el-form-item :label="TEXT.end">
+          <input-number
+            v-model="formData.end"
+            controls-position="right"
+            style="width: 100%"
+            :min="-100000"
+            :max="100000"
+          />
+        </el-form-item>
+
+        <el-form-item :label="TEXT.text">
+          <ColorPicker
+            v-model:pureColor="formData.text"
+            format="hex8"
+            picker-type="chrome"
+            use-type="pure"
+          />
+          <span class="text-text-active">{{ formData.text }}</span>
+        </el-form-item>
+
+        <el-form-item :label="TEXT.lineColor">
+          <ColorPicker
+            v-model:pureColor="formData.line.color"
+            format="hex8"
+            picker-type="chrome"
+            use-type="pure"
+          />
+          <span class="text-text-active">{{ formData.line.color }}</span>
+        </el-form-item>
+
+        <el-form-item :label="TEXT.lineWidth">
+          <input-number
+            v-model="formData.line.width"
+            controls-position="right"
+            style="width: 100%"
+            :min="0"
+            :max="100"
+          />
+        </el-form-item>
+
+        <el-form-item :label="TEXT.curveColor">
+          <ColorPicker
+            v-model:pureColor="formData.curve.color"
+            format="hex8"
+            picker-type="chrome"
+            use-type="pure"
+          />
+          <span class="text-text-active">{{ formData.curve.color }}</span>
+        </el-form-item>
+
+        <el-form-item :label="TEXT.curveWidth">
+          <input-number
+            v-model="formData.curve.width"
+            controls-position="right"
+            style="width: 100%"
+            :min="0"
+            :max="100"
+          />
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <el-button type="primary" @click="submit">{{ TEXT.confirm }}</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, type Ref } from 'vue'
+import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+import { klona } from 'klona'
+import { ColorPicker } from '@/components'
+import { createSection, getNextSectionIndex, type BaseMeterSection } from './types'
+
+const TEXT = {
+  title: '区域',
+  editTitle: '编辑区域',
+  name: '名称',
+  start: '开始值',
+  end: '结束值',
+  text: '文本',
+  lineColor: '直线颜色',
+  lineWidth: '直线宽度',
+  curveColor: '曲线颜色',
+  curveWidth: '曲线宽度',
+  confirm: '确定'
+} as const
+
+const props = defineProps<{
+  values: Ref<BaseMeterSection[]>
+}>()
+
+const dialogVisible = ref(false)
+const editingIndex = ref<number | null>(null)
+const formData = ref<BaseMeterSection>()
+
+const sectionList = computed({
+  get() {
+    return props.values?.value || []
+  },
+  set(list: BaseMeterSection[]) {
+    props.values.value = list
+  }
+})
+
+const handleAdd = () => {
+  const nextIndex = getNextSectionIndex(sectionList.value)
+  sectionList.value.push(createSection(`section_${nextIndex || 1}`))
+}
+
+const handleDelete = (index: number) => {
+  sectionList.value.splice(index, 1)
+}
+
+const handleClear = () => {
+  sectionList.value = []
+}
+
+const handleEdit = (section: BaseMeterSection, index: number) => {
+  editingIndex.value = index
+  formData.value = klona(section)
+  dialogVisible.value = true
+}
+
+const submit = () => {
+  if (editingIndex.value === null || !formData.value) return
+  sectionList.value[editingIndex.value] = klona(formData.value)
+  dialogVisible.value = false
+}
+</script>

+ 393 - 0
src/renderer/src/lvgl-widgets/base-meter/index.ts

@@ -0,0 +1,393 @@
+import { h } from 'vue'
+import BaseMeter from './BaseMeter.vue'
+import icon from '../assets/icon/icon_meter.svg'
+import type { ComponentSchema, IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import { stateList, flagOptions, stateOptions } from '@/constants'
+import defaultStyle from './style.json'
+import NeedlesConfig from './NeedlesConfig.vue'
+import SectionsConfig from './SectionsConfig.vue'
+import { createNeedle } from './types'
+
+const getSquareSize = (formData: any) => {
+  const width = Math.max(1, Math.min(10000, Math.round(Number(formData?.props?.width) || 1)))
+  const height = Math.max(1, Math.min(10000, Math.round(Number(formData?.props?.height) || 1)))
+  return Math.min(width, height)
+}
+
+const syncSquareSize = (_value: number, formData: any) => {
+  const next = getSquareSize(formData)
+  formData.props.width = next
+  formData.props.height = next
+}
+
+const createSquareSizeField = (
+  field: 'props.width' | 'props.height',
+  prefix: 'W' | 'H'
+): ComponentSchema => ({
+  label: '',
+  field,
+  valueType: 'number',
+  componentProps: {
+    span: 12,
+    min: 1,
+    max: 10000,
+    onValueChange: syncSquareSize
+  },
+  slots: { prefix }
+})
+
+const createRangeField = (
+  field: 'props.rangeStart' | 'props.rangeEnd',
+  prefix: 'S' | 'E'
+): ComponentSchema => ({
+  field,
+  valueType: 'number',
+  componentProps: {
+    span: 12,
+    min: -100000,
+    max: 100000,
+    onValueChange: (_value: number, formData: any) => {
+      if (formData.props.rangeStart >= formData.props.rangeEnd) {
+        formData.props.rangeEnd = formData.props.rangeStart + 1
+      }
+    }
+  },
+  slots: { prefix }
+})
+
+export default {
+  label: i18n.global.t('baseMeter'),
+  icon,
+  component: BaseMeter,
+  key: 'base_meter',
+  group: i18n.global.t('Meter'),
+  sort: 2,
+  hasChildren: false,
+  defaultStyle,
+  onChangeSize: (_props, size) => {
+    const nextSize = Math.max(1, Math.min(size.currentWidth, size.currentHeight))
+    return {
+      width: nextSize,
+      height: nextSize
+    }
+  },
+  parts: [
+    {
+      name: 'main',
+      stateList
+    },
+    {
+      name: 'indicator',
+      stateList
+    },
+    {
+      name: 'items',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'base_meter',
+    props: {
+      x: 0,
+      y: 0,
+      width: 200,
+      height: 200,
+      mode: 'round_inner',
+      flags: [
+        'LV_OBJ_FLAG_CLICKABLE',
+        'LV_OBJ_FLAG_CLICK_FOCUSABLE',
+        'LV_OBJ_FLAG_SCROLLABLE',
+        'LV_OBJ_FLAG_SCROLL_ELASTIC',
+        'LV_OBJ_FLAG_SCROLL_MOMENTUM',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_HOR',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_VER',
+        'LV_OBJ_FLAG_SCROLL_CHAIN',
+        'LV_OBJ_FLAG_SCROLL_WITH_ARROW',
+        'LV_OBJ_FLAG_SNAPPABLE',
+        'LV_OBJ_FLAG_PRESS_LOCK',
+        'LV_OBJ_FLAG_GESTURE_BUBBLE'
+      ],
+      states: [],
+      tick: 41,
+      mainTick: 8,
+      rangeStart: 0,
+      rangeEnd: 100,
+      angleRange: 300,
+      rotationAngle: 90,
+      enableText: false,
+      labels: '',
+      needles: [createNeedle('needles_1', 20)],
+      sections: []
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#FFFFFFFF',
+          image: {
+            imgId: '',
+            recolor: '#00000000',
+            alpha: 255
+          }
+        },
+        text: {
+          color: '#212121FF',
+          family: 'xx',
+          size: 14,
+          align: 'left',
+          decoration: 'none'
+        },
+        spacer: {
+          letterSpacing: 0
+        },
+        border: {
+          color: '#000000FF',
+          width: 0,
+          radius: 0,
+          side: ['all']
+        },
+        curve: {
+          color: '#212121FF',
+          width: 2,
+          radius: true,
+          alpha: 255,
+          image: ''
+        },
+        shadow: {
+          color: '#000000FF',
+          offsetX: 0,
+          offsetY: 0,
+          spread: 0,
+          width: 0
+        }
+      }
+    ]
+  },
+  config: {
+    props: [
+      {
+        label: '名称',
+        field: 'name',
+        valueType: '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' }
+          },
+          createSquareSizeField('props.width', 'W'),
+          createSquareSizeField('props.height', '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.mode',
+        valueType: 'select',
+        labelWidth: '100px',
+        componentProps: {
+          options: [
+            { label: 'Round Inner', value: 'round_inner' },
+            { label: 'Round Outer', value: 'round_outer' }
+          ]
+        }
+      },
+      {
+        label: '刻度数',
+        field: 'props.tick',
+        valueType: 'number',
+        labelWidth: '100px',
+        componentProps: {
+          min: 2,
+          max: 1000
+        }
+      },
+      {
+        label: '主刻度',
+        field: 'props.mainTick',
+        valueType: 'number',
+        labelWidth: '100px',
+        componentProps: {
+          min: 2,
+          max: 1000
+        }
+      },
+      {
+        label: '范围',
+        valueType: 'group',
+        children: [
+          createRangeField('props.rangeStart', 'S'),
+          createRangeField('props.rangeEnd', 'E')
+        ]
+      },
+      {
+        label: '角度范围',
+        field: 'props.angleRange',
+        valueType: 'number',
+        labelWidth: '100px',
+        componentProps: {
+          min: 0,
+          max: 360
+        }
+      },
+      {
+        label: '旋转角度',
+        field: 'props.rotationAngle',
+        valueType: 'number',
+        labelWidth: '100px',
+        componentProps: {
+          min: 0,
+          max: 360
+        }
+      },
+      {
+        label: '启用文本',
+        field: 'props.enableText',
+        valueType: 'switch',
+        labelWidth: '100px'
+      },
+      {
+        label: '标签',
+        field: 'props.labels',
+        valueType: 'textarea',
+        labelWidth: '100px',
+        componentProps: {
+          placeholder: '标签内容用英文逗号分隔'
+        }
+      },
+      {
+        label: '指针',
+        field: 'props.needles',
+        valueType: '',
+        render: (value) => h(NeedlesConfig, { values: value })
+      },
+      {
+        label: '区域',
+        field: 'props.sections',
+        valueType: '',
+        render: (value) => h(SectionsConfig, { values: value })
+      }
+    ],
+    styles: [
+      {
+        label: '模块/状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        valueType: 'dependency',
+        name: ['part'],
+        dependency: ({ part }) => {
+          return part?.name === 'main'
+            ? [
+                {
+                  label: '背景',
+                  field: 'background',
+                  valueType: 'background'
+                },
+                {
+                  label: '字体',
+                  field: 'text',
+                  valueType: 'font'
+                },
+                {
+                  label: '间距',
+                  field: 'spacer',
+                  valueType: 'spacer',
+                  componentProps: {
+                    hideLineHeight: true
+                  }
+                },
+                {
+                  label: '边框',
+                  field: 'border',
+                  valueType: 'border'
+                },
+                {
+                  label: '曲线',
+                  field: 'curve',
+                  valueType: 'line',
+                  componentProps: {
+                    hasImage: true,
+                    useWidgetHalfMinAsWidthMax: true
+                  }
+                },
+                {
+                  label: '阴影',
+                  field: 'shadow',
+                  valueType: 'shadow'
+                }
+              ]
+            : [
+                {
+                  label: '直线',
+                  field: 'line',
+                  valueType: 'line',
+                  componentProps: {
+                    useWidgetHalfMinAsWidthMax: true
+                  }
+                },
+                {
+                  label: '其它',
+                  field: 'other',
+                  valueType: 'other',
+                  componentProps: {
+                    keys: ['length'],
+                    componentProps: {
+                      length: {
+                        min: 0,
+                        max: 10000
+                      }
+                    }
+                  }
+                }
+              ]
+        }
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 78 - 0
src/renderer/src/lvgl-widgets/base-meter/style.json

@@ -0,0 +1,78 @@
+{
+  "widget": "base_meter",
+  "styleName": "default",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#FFFFFFFF",
+          "image": {
+            "imgId": "",
+            "recolor": "#00000000",
+            "alpha": 255
+          }
+        },
+        "text": {
+          "color": "#212121FF",
+          "family": "xx",
+          "size": 14,
+          "align": "left",
+          "decoration": "none"
+        },
+        "spacer": {
+          "letterSpacing": 0
+        },
+        "border": {
+          "color": "#000000FF",
+          "width": 0,
+          "radius": 0,
+          "side": ["all"]
+        },
+        "curve": {
+          "color": "#212121FF",
+          "width": 2,
+          "radius": true,
+          "alpha": 255,
+          "image": ""
+        },
+        "shadow": {
+          "color": "#000000FF",
+          "offsetX": 0,
+          "offsetY": 0,
+          "spread": 0,
+          "width": 0
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "indicator",
+      "defaultStyle": {
+        "line": {
+          "color": "#212121FF",
+          "width": 2,
+          "radius": false
+        },
+        "other": {
+          "length": 10
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "items",
+      "defaultStyle": {
+        "line": {
+          "color": "#212121FF",
+          "width": 2,
+          "radius": false
+        },
+        "other": {
+          "length": 5
+        }
+      },
+      "state": []
+    }
+  ]
+}

+ 107 - 0
src/renderer/src/lvgl-widgets/base-meter/types.ts

@@ -0,0 +1,107 @@
+export type BaseMeterMode = 'round_inner' | 'round_outer'
+
+export type BaseMeterNeedleType = 'image' | 'line'
+
+export type BaseMeterNeedleImage = {
+  image: string
+  alpha: number
+  recolor: string
+  size: {
+    width: number
+    height: number
+  }
+  center: {
+    x: number
+    y: number
+  }
+}
+
+export type BaseMeterNeedleLine = {
+  color: string
+  round: boolean
+  size: {
+    width: number
+    height: number
+  }
+}
+
+export type BaseMeterNeedle = {
+  name: string
+  type: BaseMeterNeedleType
+  value: number
+  image: BaseMeterNeedleImage
+  line: BaseMeterNeedleLine
+}
+
+export type BaseMeterSection = {
+  name: string
+  start: number
+  end: number
+  text: string
+  line: {
+    color: string
+    width: number
+  }
+  curve: {
+    color: string
+    width: number
+  }
+}
+
+const getNextIndex = (items: { name: string }[], prefix: string) => {
+  const indexes = items
+    .map((item) => Number(item.name.replace(`${prefix}_`, '')))
+    .filter((value) => !Number.isNaN(value))
+
+  return indexes.length ? Math.max(...indexes) + 1 : 0
+}
+
+export const getNextNeedleIndex = (items: BaseMeterNeedle[]) => getNextIndex(items, 'needles')
+
+export const getNextSectionIndex = (items: BaseMeterSection[]) => getNextIndex(items, 'section')
+
+export const createNeedle = (name: string, value = 0): BaseMeterNeedle => {
+  return {
+    name,
+    type: 'line',
+    value,
+    image: {
+      image: '',
+      alpha: 255,
+      recolor: '#00000000',
+      size: {
+        width: 60,
+        height: 4
+      },
+      center: {
+        x: 0,
+        y: 2
+      }
+    },
+    line: {
+      color: '#212121FF',
+      round: false,
+      size: {
+        width: 4,
+        height: 60
+      }
+    }
+  }
+}
+
+export const createSection = (name: string): BaseMeterSection => {
+  return {
+    name,
+    start: 0,
+    end: 20,
+    text: '#FFFF00FF',
+    line: {
+      color: '#FF00FFFF',
+      width: 3
+    },
+    curve: {
+      color: '#FF00FFFF',
+      width: 2
+    }
+  }
+}

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

@@ -36,6 +36,7 @@ import Calendar from './calendar/index'
 import DateText from './datetext/index'
 import DagitalClock from './dagital-clock/index'
 import AnalogClock from './analog-clock/index'
+import BaseMeter from './base-meter/index'
 
 import Lottie from './lottie'
 import Video from './video/index'
@@ -92,7 +93,9 @@ export const ComponentArray = [
 
   Lottie,
   QRCode,
-  Barcode
+  Barcode,
+
+  BaseMeter
 ] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

+ 19 - 1
src/renderer/src/lvgl-widgets/qrcode/index.ts

@@ -2,7 +2,7 @@ import QRCode from './QRCode.vue'
 import icon from '../assets/icon/icon_40crcode.svg'
 import type { IComponentModelConfig } from '../type'
 import i18n from '@/locales'
-import { stateList } from '@/constants'
+import { stateList, flagOptions, stateOptions } from '@/constants'
 import defaultStyle from './style.json'
 
 export default {
@@ -81,6 +81,24 @@ export default {
             slots: { prefix: 'Y' }
           }
         ]
+      },
+      {
+        label: '标识',
+        field: 'props.flags',
+        valueType: 'checkbox',
+        componentProps: {
+          options: flagOptions,
+          defaultCollapsed: true
+        }
+      },
+      {
+        label: '状态',
+        field: 'props.states',
+        valueType: 'checkbox',
+        componentProps: {
+          options: stateOptions,
+          defaultCollapsed: true
+        }
       }
     ],
     coreProps: [

+ 1 - 1
src/renderer/src/lvgl-widgets/type.d.ts

@@ -36,7 +36,7 @@ export interface ComponentSchema {
   // 插槽名称(组件为插槽类型时,需要设置插槽name),可选
   slotName?: string
   // 插槽列表,可选
-  slots?: { [slotName: string]: ComponentSchema[] }
+  slots?: { [slotName: string]: ComponentSchema[] | string }
   // 节点类型,必选
   valueType: string
   // 渲染表单项

+ 3 - 1
src/renderer/src/views/designer/workspace/stage/Node.vue

@@ -141,7 +141,9 @@ const getStyle = computed((): CSSProperties => {
   // 存在旋转属性
   if (schema.props?.rotation) {
     rotate = `rotate(${schema.props.rotation}deg)`
-    other.transformOrigin = `${schema.props.pivot.x}px ${schema.props.pivot.y}px`
+    if (schema.props?.pivot) {
+      other.transformOrigin = `${schema.props.pivot.x}px ${schema.props.pivot.y}px`
+    }
 
     if (other.transform) {
       other.transform += ` ${rotate}`