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

feat: 新增模拟时钟控件

jiaxing.liao недель назад: 2
Родитель
Сommit
6e50f4f026

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

@@ -1,4 +1,4 @@
-{
+{
   "directory": "Directory",
   "widgetLibrary": "Widget Library",
   "resourceManager": "Resource Manager",
@@ -141,5 +141,8 @@
   "animation": "Animation",
   "time": "Time",
   "date": "Date",
-  "dagitalClock": "DagitalClock"
+  "dagitalClock": "DagitalClock",
+  "analogClock": "AnalogClock"
 }
+
+

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

@@ -141,5 +141,6 @@
   "animation": "动画",
   "time": "时间",
   "date": "日期",
-  "dagitalClock": "数字时钟"
+  "dagitalClock": "数字时钟",
+  "analogClock": "模拟时钟"
 }

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

@@ -0,0 +1,533 @@
+<template>
+  <div class="relative w-full h-full overflow-visible">
+    <div class="absolute inset-0 flex items-center justify-center overflow-visible">
+      <div :style="faceStyle" class="relative box-border">
+        <div
+          v-if="mainImageSrc"
+          class="absolute inset-0 flex items-center justify-center overflow-hidden pointer-events-none"
+          :style="{ opacity: mainImageOpacity }"
+        >
+          <img
+            v-if="!mainImageUseMask"
+            :src="mainImageSrc"
+            class="block max-w-none max-h-none"
+            alt=""
+          />
+          <ImageBg v-else :src="mainImageSrc" :imageStyle="mainImageStyle" />
+        </div>
+
+        <svg
+          :viewBox="`0 0 ${faceSize} ${faceSize}`"
+          class="block w-full h-full overflow-visible"
+          xmlns="http://www.w3.org/2000/svg"
+        >
+          <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 labels"
+            :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="hand in lineHands"
+            :key="hand.key"
+            :x1="hand.x1"
+            :y1="hand.y1"
+            :x2="hand.x2"
+            :y2="hand.y2"
+            :stroke="hand.stroke"
+            :stroke-width="hand.strokeWidth"
+            stroke-linecap="round"
+          />
+        </svg>
+
+        <div
+          v-for="hand in imageHands"
+          :key="hand.key"
+          class="absolute pointer-events-none"
+          :style="hand.style"
+        >
+          <img
+            v-if="!hand.useMask"
+            :src="hand.src"
+            class="block w-full h-full"
+            :style="{ opacity: hand.opacity }"
+            alt=""
+          />
+          <ImageBg v-else :src="hand.src" :imageStyle="hand.imageStyle" />
+        </div>
+      </div>
+    </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'
+
+type PointerType = 'image' | 'line'
+
+type PointerImage = {
+  image: string
+  alpha: number
+  recolor: string
+  size: {
+    width: number
+    height: number
+  }
+  center: {
+    x: number
+    y: number
+  }
+}
+
+type PointerLine = {
+  color: string
+  size: {
+    width: number
+    height: number
+  }
+}
+
+type HandConfig = {
+  time: number
+  type: PointerType
+  image: PointerImage
+  line: PointerLine
+}
+
+type TickLine = {
+  key: string
+  x1: number
+  y1: number
+  x2: number
+  y2: number
+  stroke: string
+  strokeWidth: number
+  linecap: 'round' | 'butt'
+}
+
+type LineHand = {
+  key: string
+  x1: number
+  y1: number
+  x2: number
+  y2: number
+  stroke: string
+  strokeWidth: number
+}
+
+type ImageHand = {
+  key: string
+  src: string
+  useMask: boolean
+  opacity: number
+  imageStyle: CSSProperties
+  style: CSSProperties
+}
+
+const props = defineProps<{
+  width: number
+  height: number
+  styles: any[]
+  part?: string
+  state?: string
+  enableText: boolean
+  totalTicks: number
+  majorTickInterval: number
+  hourHand: HandConfig
+  minuteHand: HandConfig
+  secondHand: HandConfig
+}>()
+
+const projectStore = useProjectStore()
+
+const styleMap = useWidgetStyle({
+  widget: 'analog_clock',
+  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 faceSize = computed(() => Math.max(0, Math.min(props.width || 0, props.height || 0)))
+const center = computed(() => faceSize.value / 2)
+const radius = computed(() => faceSize.value / 2)
+const lineWidthLimit = computed(() => Math.max(0, radius.value))
+
+const minorTickLength = computed(() =>
+  Math.max(0, Number(itemsStyleConfig.value?.other?.length ?? 6))
+)
+const majorTickLength = computed(() =>
+  Math.max(0, Number(indicatorStyleConfig.value?.other?.length ?? 10))
+)
+
+const minorTickWidth = computed(() =>
+  Math.max(0, Math.min(Number(styleMap.value?.itemsStyle?.line?.width ?? 2), lineWidthLimit.value))
+)
+const majorTickWidth = computed(() =>
+  Math.max(
+    0,
+    Math.min(Number(styleMap.value?.indicatorStyle?.line?.width ?? 2), lineWidthLimit.value)
+  )
+)
+
+const minorTickColor = computed(() =>
+  String(styleMap.value?.itemsStyle?.line?.color ?? '#212121FF')
+)
+const majorTickColor = computed(() =>
+  String(styleMap.value?.indicatorStyle?.line?.color ?? '#212121FF')
+)
+const minorTickLinecap = computed(() =>
+  styleMap.value?.itemsStyle?.line?.radius ? ('round' as const) : ('butt' as const)
+)
+const majorTickLinecap = computed(() =>
+  styleMap.value?.indicatorStyle?.line?.radius ? ('round' as const) : ('butt' as const)
+)
+
+const labelColor = computed(() => String(styleMap.value?.indicatorStyle?.color ?? '#212121FF'))
+const labelFontSize = computed(() =>
+  Number.parseFloat(String(styleMap.value?.indicatorStyle?.fontSize ?? 14))
+)
+const labelFontFamily = computed(() =>
+  String(styleMap.value?.indicatorStyle?.fontFamily ?? 'sans-serif')
+)
+const labelLetterSpacing = computed(() =>
+  String(styleMap.value?.indicatorStyle?.letterSpacing ?? '0px')
+)
+const labelDecoration = computed(() =>
+  String(styleMap.value?.indicatorStyle?.textDecoration ?? 'none')
+)
+
+const uniqueTickCount = computed(() => {
+  const total = Math.max(0, Math.floor(Number(props.totalTicks) || 0))
+  if (total <= 1) return total
+  return total - 1
+})
+
+const normalizedMajorTickInterval = computed(() =>
+  Math.max(0, Math.floor(Number(props.majorTickInterval) || 0))
+)
+
+const hasIncompleteLeadingMajor = computed(() => {
+  if (normalizedMajorTickInterval.value <= 0) return 0
+  return uniqueTickCount.value % normalizedMajorTickInterval.value
+})
+
+const tickStepAngle = computed(() => {
+  if (uniqueTickCount.value <= 0) return 0
+  return 360 / uniqueTickCount.value
+})
+
+const outerTickRadius = computed(() => {
+  const maxLineWidth = Math.max(minorTickWidth.value, majorTickWidth.value)
+  return Math.max(0, radius.value - maxLineWidth / 2 - 2)
+})
+
+const polarPoint = (distance: number, angleDeg: number) => {
+  const rad = (angleDeg * Math.PI) / 180
+  return {
+    x: center.value + Math.cos(rad) * distance,
+    y: center.value + Math.sin(rad) * distance
+  }
+}
+
+const tickMeta = computed(() => {
+  const list: { index: number; angle: number; isMajor: boolean }[] = []
+  for (let index = 0; index < uniqueTickCount.value; index++) {
+    list.push({
+      index,
+      angle: -90 + tickStepAngle.value * index,
+      isMajor:
+        normalizedMajorTickInterval.value > 0 &&
+        index % normalizedMajorTickInterval.value === 0 &&
+        !(hasIncompleteLeadingMajor.value > 0 && index === 0)
+    })
+  }
+  return list
+})
+
+const createTickLine = (
+  index: number,
+  angle: number,
+  length: number,
+  width: number,
+  stroke: string,
+  linecap: 'round' | 'butt'
+): TickLine => {
+  const start = polarPoint(outerTickRadius.value, angle)
+  const end = polarPoint(Math.max(0, outerTickRadius.value - length), angle)
+
+  return {
+    key: `tick-${index}`,
+    x1: start.x,
+    y1: start.y,
+    x2: end.x,
+    y2: end.y,
+    stroke,
+    strokeWidth: width,
+    linecap
+  }
+}
+
+const majorTicks = computed(() => {
+  if (majorTickWidth.value <= 0 || majorTickLength.value <= 0) return []
+
+  return tickMeta.value
+    .filter((item) => item.isMajor)
+    .map((item) =>
+      createTickLine(
+        item.index,
+        item.angle,
+        majorTickLength.value,
+        majorTickWidth.value,
+        majorTickColor.value,
+        majorTickLinecap.value
+      )
+    )
+})
+
+const minorTicks = computed(() => {
+  if (minorTickWidth.value <= 0 || minorTickLength.value <= 0) return []
+
+  return tickMeta.value
+    .filter((item) => !item.isMajor)
+    .map((item) =>
+      createTickLine(
+        item.index,
+        item.angle,
+        minorTickLength.value,
+        minorTickWidth.value,
+        minorTickColor.value,
+        minorTickLinecap.value
+      )
+    )
+})
+
+const labels = computed(() => {
+  if (!props.enableText || normalizedMajorTickInterval.value <= 0) return []
+
+  const labelRadius = Math.max(
+    0,
+    outerTickRadius.value - majorTickLength.value - labelFontSize.value * 0.9 - 0
+  )
+
+  const majorLabelTicks = tickMeta.value.filter((item) => item.isMajor)
+  const labelCount = majorLabelTicks.length
+
+  if (labelCount <= 0) return []
+
+  return majorLabelTicks.map((item, index) => {
+      const point = polarPoint(labelRadius, item.angle)
+      const text =
+        hasIncompleteLeadingMajor.value > 0
+          ? String(index + 1)
+          : index === 0
+            ? String(labelCount)
+            : String(index)
+
+      return {
+        key: `label-${item.index}`,
+        x: point.x,
+      y: point.y,
+      text,
+      fill: labelColor.value,
+      fontSize: labelFontSize.value,
+      fontFamily: labelFontFamily.value,
+      letterSpacing: labelLetterSpacing.value,
+      decoration: labelDecoration.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 mainImageSrc = computed(() => String(styleMap.value?.mainStyle?.imageSrc ?? ''))
+const mainImageStyle = computed(
+  () =>
+    ({
+      backgroundColor: styleMap.value?.mainStyle?.imageStyle?.backgroundColor,
+      opacity: styleMap.value?.mainStyle?.imageStyle?.opacity
+    }) as CSSProperties
+)
+const mainImageOpacity = computed(() => Number(mainImageStyle.value.opacity ?? 1))
+const mainImageUseMask = computed(() => {
+  return !isTransparentColor(String(mainImageStyle.value.backgroundColor ?? ''))
+})
+
+const faceStyle = computed(() => {
+  const mainStyle = styleMap.value?.mainStyle || {}
+  const {
+    imageSrc: _imageSrc,
+    imageStyle: _imageStyle,
+    line: _line,
+    curve: _curve,
+    ...style
+  } = mainStyle
+
+  return {
+    ...style,
+    width: `${faceSize.value}px`,
+    height: `${faceSize.value}px`,
+    borderRadius: '50%',
+    overflow: 'hidden'
+  }
+})
+
+const getHourAngle = (value: number) => ((value % 12) / 12) * 360 - 90
+const getMinuteSecondAngle = (value: number) => ((value % 60) / 60) * 360 - 90
+const getHandTime = (hand: HandConfig) =>
+  hand.time ?? (hand.type === 'image' ? (hand as any)?.image?.time : (hand as any)?.line?.time) ?? 0
+
+const createLineHand = (key: string, hand: HandConfig, angle: number) => {
+  if (hand.type !== 'line') return null
+
+  const strokeWidth = Math.max(
+    0,
+    Math.min(Number(hand.line?.size?.width ?? 0), lineWidthLimit.value)
+  )
+  const length = Math.max(0, Number(hand.line?.size?.height ?? 0))
+  const point = polarPoint(length, angle)
+
+  return {
+    key,
+    x1: center.value,
+    y1: center.value,
+    x2: point.x,
+    y2: point.y,
+    stroke: String(hand.line?.color ?? '#212121FF'),
+    strokeWidth
+  }
+}
+
+const isLineHand = (hand: LineHand | null): hand is LineHand => !!hand
+
+const lineHands = computed(() => {
+  const result = [
+    createLineHand('hour-line', props.hourHand, getHourAngle(Number(getHandTime(props.hourHand)))),
+    createLineHand(
+      'minute-line',
+      props.minuteHand,
+      getMinuteSecondAngle(Number(getHandTime(props.minuteHand)))
+    ),
+    createLineHand(
+      'second-line',
+      props.secondHand,
+      getMinuteSecondAngle(Number(getHandTime(props.secondHand)))
+    )
+  ]
+
+  return result.filter(isLineHand)
+})
+
+const createImageHand = (key: string, hand: HandConfig, angle: number) => {
+  if (hand.type !== 'image') return null
+
+  const src = getImageSrc(hand.image?.image)
+  if (!src) return null
+
+  const width = Math.max(1, Number(hand.image?.size?.width ?? 1))
+  const height = Math.max(1, Number(hand.image?.size?.height ?? 1))
+  const centerX = Number(hand.image?.center?.x ?? 0)
+  const centerY = Number(hand.image?.center?.y ?? 0)
+  const recolor = String(hand.image?.recolor ?? '#00000000')
+  const opacity = Math.max(0, Math.min(1, Number(hand.image?.alpha ?? 255) / 255))
+
+  return {
+    key,
+    src,
+    useMask: !isTransparentColor(recolor),
+    opacity,
+    imageStyle: {
+      backgroundColor: recolor,
+      opacity
+    } as CSSProperties,
+    style: {
+      left: `${center.value - centerX}px`,
+      top: `${center.value - centerY}px`,
+      width: `${width}px`,
+      height: `${height}px`,
+      transform: `rotate(${angle}deg)`,
+      transformOrigin: `${centerX}px ${centerY}px`
+    } as CSSProperties
+  }
+}
+
+const isImageHand = (hand: ImageHand | null): hand is ImageHand => !!hand
+
+const imageHands = computed(() => {
+  const result = [
+    createImageHand(
+      'hour-image',
+      props.hourHand,
+      getHourAngle(Number(getHandTime(props.hourHand)))
+    ),
+    createImageHand(
+      'minute-image',
+      props.minuteHand,
+      getMinuteSecondAngle(Number(getHandTime(props.minuteHand)))
+    ),
+    createImageHand(
+      'second-image',
+      props.secondHand,
+      getMinuteSecondAngle(Number(getHandTime(props.secondHand)))
+    )
+  ]
+
+  return result.filter(isImageHand)
+})
+</script>

+ 531 - 0
src/renderer/src/lvgl-widgets/analog-clock/index.ts

@@ -0,0 +1,531 @@
+import AnalogClock from './AnalogClock.vue'
+import icon from '../assets/icon/icon_34time1.svg'
+import type { ComponentSchema, IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import { flagOptions, stateList, stateOptions } from '@/constants'
+import defaultStyle from './style.json'
+
+const pointerTypeOptions = [
+  { label: '图片指针', value: 'image' },
+  { label: '线指针', value: 'line' }
+]
+
+const syncSquareSize = (value: number, formData: any) => {
+  const next = Math.max(1, Math.min(10000, Math.round(Number(value) || 1)))
+  formData.props.width = next
+  formData.props.height = next
+}
+
+const createSquareSizeField = (field: 'props.width' | 'props.height', prefix: 'W' | 'H') => ({
+  label: '',
+  field,
+  valueType: 'number',
+  componentProps: {
+    span: 12,
+    min: 1,
+    max: 10000,
+    onValueChange: syncSquareSize
+  },
+  slots: { prefix }
+})
+
+const createImageStyle = () => ({
+  image: '',
+  alpha: 255,
+  recolor: '#00000000',
+  size: {
+    width: 60,
+    height: 4
+  },
+  center: {
+    x: 0,
+    y: 2
+  }
+})
+
+const createLineStyle = (width: number, height: number) => ({
+  color: '#212121FF',
+  size: {
+    width,
+    height
+  }
+})
+
+const createHandConfig = (
+  field: 'props.hourHand' | 'props.minuteHand' | 'props.secondHand',
+  max: number
+): ComponentSchema[] => {
+  return [
+    {
+      label: '指针样式',
+      labelWidth: '100px',
+      field: `${field}.type`,
+      valueType: 'select',
+      componentProps: {
+        options: pointerTypeOptions
+      }
+    },
+    {
+      label: '时间',
+      labelWidth: '100px',
+      field: `${field}.time`,
+      valueType: 'number',
+      componentProps: {
+        min: 0,
+        max
+      }
+    },
+    {
+      valueType: 'dependency',
+      name: [`${field}.type`],
+      dependency: (dependency) => {
+        return dependency?.[`${field}.type`] === 'image'
+          ? [
+              {
+                label: '图片',
+                labelWidth: '100px',
+                field: `${field}.image.image`,
+                valueType: 'image'
+              },
+              {
+                label: '图片透明度',
+                labelWidth: '100px',
+                field: `${field}.image.alpha`,
+                valueType: 'slider',
+                componentProps: {
+                  min: 0,
+                  max: 255
+                }
+              },
+              {
+                label: '图片遮罩',
+                labelWidth: '100px',
+                field: `${field}.image.recolor`,
+                valueType: 'color'
+              },
+              {
+                label: '大小',
+                valueType: 'group',
+                children: [
+                  {
+                    label: '',
+                    field: `${field}.image.size.width`,
+                    valueType: 'number',
+                    componentProps: {
+                      span: 12,
+                      min: 1,
+                      max: 1000
+                    },
+                    slots: { prefix: 'W' }
+                  },
+                  {
+                    label: '',
+                    field: `${field}.image.size.height`,
+                    valueType: 'number',
+                    componentProps: {
+                      span: 12,
+                      min: 1,
+                      max: 1000
+                    },
+                    slots: { prefix: 'H' }
+                  }
+                ]
+              },
+              {
+                label: '中心',
+                valueType: 'group',
+                children: [
+                  {
+                    label: '',
+                    field: `${field}.image.center.x`,
+                    valueType: 'number',
+                    componentProps: {
+                      span: 12,
+                      min: -1000,
+                      max: 1000
+                    },
+                    slots: { prefix: 'X' }
+                  },
+                  {
+                    label: '',
+                    field: `${field}.image.center.y`,
+                    valueType: 'number',
+                    componentProps: {
+                      span: 12,
+                      min: -1000,
+                      max: 1000
+                    },
+                    slots: { prefix: 'Y' }
+                  }
+                ]
+              }
+            ]
+          : [
+              {
+                label: '指针颜色',
+                labelWidth: '100px',
+                field: `${field}.line.color`,
+                valueType: 'color'
+              },
+              {
+                label: '大小',
+                valueType: 'group',
+                children: [
+                  {
+                    label: '',
+                    field: `${field}.line.size.width`,
+                    valueType: 'number',
+                    componentProps: {
+                      span: 12,
+                      min: 0,
+                      max: 10000
+                    },
+                    slots: { prefix: 'W' }
+                  },
+                  {
+                    label: '',
+                    field: `${field}.line.size.height`,
+                    valueType: 'number',
+                    componentProps: {
+                      span: 12,
+                      min: 0,
+                      max: 10000
+                    },
+                    slots: { prefix: 'H' }
+                  }
+                ]
+              }
+            ]
+      }
+    }
+  ]
+}
+
+export default {
+  label: i18n.global.t('analogClock'),
+  icon,
+  component: AnalogClock,
+  key: 'analog_clock',
+  group: i18n.global.t('time'),
+  sort: 1,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    },
+    {
+      name: 'indicator',
+      stateList
+    },
+    {
+      name: 'items',
+      stateList
+    }
+  ],
+  onChangeSize: (_props, size) => {
+    const nextSize = Math.max(size.currentWidth, size.currentHeight)
+    return {
+      width: nextSize,
+      height: nextSize
+    }
+  },
+  defaultSchema: {
+    name: 'analog_clock',
+    props: {
+      x: 0,
+      y: 0,
+      width: 200,
+      height: 200,
+      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: [],
+      enableText: true,
+      totalTicks: 61,
+      majorTickInterval: 5,
+      hourHand: {
+        time: 2,
+        type: 'line',
+        image: createImageStyle(),
+        line: createLineStyle(4, 30)
+      },
+      minuteHand: {
+        time: 5,
+        type: 'line',
+        image: createImageStyle(),
+        line: createLineStyle(3, 40)
+      },
+      secondHand: {
+        time: 40,
+        type: 'line',
+        image: createImageStyle(),
+        line: createLineStyle(2, 60)
+      }
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#FFFFFFFF',
+          image: {
+            imgId: '',
+            recolor: '#00000000',
+            alpha: 255
+          }
+        },
+        shadow: {
+          color: '#000000FF',
+          offsetX: 0,
+          offsetY: 0,
+          spread: 0,
+          width: 0
+        }
+      },
+      {
+        part: {
+          name: 'indicator',
+          state: 'default'
+        },
+        text: {
+          color: '#212121FF',
+          family: 'xx',
+          size: 14,
+          align: 'left',
+          decoration: 'none'
+        },
+        spacer: {
+          letterSpacing: 0
+        },
+        line: {
+          color: '#212121FF',
+          width: 2,
+          radius: false
+        },
+        other: {
+          length: 10
+        }
+      },
+      {
+        part: {
+          name: 'items',
+          state: 'default'
+        },
+        line: {
+          color: '#212121FF',
+          width: 2,
+          radius: false
+        },
+        other: {
+          length: 6
+        }
+      }
+    ]
+  },
+  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' }
+          },
+          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: '启用文本',
+        labelWidth: '100px',
+        field: 'props.enableText',
+        valueType: 'switch'
+      },
+      {
+        label: '总刻度数',
+        labelWidth: '100px',
+        field: 'props.totalTicks',
+        valueType: 'number',
+        componentProps: {
+          min: 0,
+          max: 61
+        }
+      },
+      {
+        label: '主刻度间隔',
+        labelWidth: '100px',
+        field: 'props.majorTickInterval',
+        valueType: 'number',
+        componentProps: {
+          min: 0,
+          max: 5
+        }
+      },
+      {
+        label: '时针',
+        valueType: 'group',
+        children: createHandConfig('props.hourHand', 12)
+      },
+      {
+        label: '分针',
+        valueType: 'group',
+        children: createHandConfig('props.minuteHand', 59)
+      },
+      {
+        label: '秒针',
+        valueType: 'group',
+        children: createHandConfig('props.secondHand', 59)
+      }
+    ],
+    styles: [
+      {
+        label: '模块状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        valueType: 'dependency',
+        name: ['part'],
+        dependency: ({ part }) => {
+          return part?.name === 'main'
+            ? [
+                {
+                  label: '背景',
+                  field: 'background',
+                  valueType: 'background'
+                },
+                {
+                  label: '阴影',
+                  field: 'shadow',
+                  valueType: 'shadow'
+                }
+              ]
+            : part?.name === 'indicator'
+              ? [
+                  {
+                    label: '字体',
+                    field: 'text',
+                    valueType: 'font'
+                  },
+                  {
+                    label: '间距',
+                    field: 'spacer',
+                    valueType: 'spacer',
+                    componentProps: {
+                      hideLineHeight: true
+                    }
+                  },
+                  {
+                    label: '直线',
+                    field: 'line',
+                    valueType: 'line',
+                    componentProps: {
+                      useWidgetHalfMinAsWidthMax: true
+                    }
+                  },
+                  {
+                    label: '其它',
+                    field: 'other',
+                    valueType: 'other',
+                    componentProps: {
+                      keys: ['length'],
+                      componentProps: {
+                        length: {
+                          min: 0,
+                          max: 10000
+                        }
+                      }
+                    }
+                  }
+                ]
+              : [
+                  {
+                    label: '直线',
+                    field: 'line',
+                    valueType: 'line',
+                    componentProps: {
+                      useWidgetHalfMinAsWidthMax: true
+                    }
+                  },
+                  {
+                    label: '其它',
+                    field: 'other',
+                    valueType: 'other',
+                    componentProps: {
+                      keys: ['length'],
+                      componentProps: {
+                        length: {
+                          min: 0,
+                          max: 10000
+                        }
+                      }
+                    }
+                  }
+                ]
+        }
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 65 - 0
src/renderer/src/lvgl-widgets/analog-clock/style.json

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

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

@@ -35,6 +35,7 @@ import Roller from './roller'
 import Calendar from './calendar/index'
 import DateText from './datetext/index'
 import DagitalClock from './dagital-clock/index'
+import AnalogClock from './analog-clock/index'
 
 import Page from './page'
 import { IComponentModelConfig } from './type'
@@ -79,7 +80,8 @@ export const ComponentArray = [
 
   Calendar,
   DateText,
-  DagitalClock
+  DagitalClock,
+  AnalogClock
 ] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

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

@@ -125,7 +125,7 @@ export interface IComponentModelConfig {
    * @param props
    * @returns
    */
-  onChangeSize?: (props: any, ...args) => void
+  onChangeSize?: (props: any, ...args) => void | { width?: number; height?: number }
   /**
    * 位置触发事件
    * @param props

+ 20 - 21
src/renderer/src/views/designer/config/property/components/StyleLine.vue

@@ -10,6 +10,8 @@
         v-model="width"
         controls-position="right"
         style="width: 100%"
+        :min="widthMin"
+        :max="resolvedWidthMax"
       />
     </el-form-item>
     <el-form-item v-if="!hideRadius" label="圆角" label-position="left" label-width="60px">
@@ -19,14 +21,6 @@
     <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> -->
 
     <el-form-item v-if="hasDash" label="虚线宽度" label-position="left" label-width="60px">
       <input-number
@@ -53,15 +47,21 @@
 
 <script setup lang="ts">
 import { computed } from 'vue'
+import { useProjectStore } from '@/store/modules/project'
 
 import ImageSelect from './ImageSelect.vue'
 
-defineProps<{
+const props = defineProps<{
   hasImage?: boolean
   hasDash?: boolean
   hideRadius?: boolean
+  widthMin?: number
+  widthMax?: number
+  useWidgetHalfMinAsWidthMax?: boolean
 }>()
 
+const projectStore = useProjectStore()
+
 const modelValue = defineModel<{
   color: string
   width: number
@@ -92,6 +92,17 @@ const width = computed({
   }
 })
 
+const resolvedWidthMax = computed(() => {
+  if (props.useWidgetHalfMinAsWidthMax) {
+    const width = Number(projectStore.activeWidget?.props?.width ?? 0)
+    const height = Number(projectStore.activeWidget?.props?.height ?? 0)
+
+    return Math.max(0, Math.floor(Math.min(width, height) / 2))
+  }
+
+  return props.widthMax
+})
+
 // radius
 const radius = computed({
   get: () => modelValue.value?.radius,
@@ -102,18 +113,6 @@ 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

+ 10 - 6
src/renderer/src/views/designer/workspace/stage/Moveable.vue

@@ -204,20 +204,22 @@ const onResizeEnd = (e) => {
   const id = e.target.attributes['widget-id']?.value
   if (e.lastEvent && id && projectStore.activeWidgetMap[id]) {
     const scale = getScale(projectStore.activeWidgetMap[id])
-    const width = Math.round(e.lastEvent.width / scale)
-    const height = Math.round(e.lastEvent.height / scale)
+    let width = Math.round(e.lastEvent.width / scale)
+    let height = Math.round(e.lastEvent.height / scale)
 
     // 触发控件监听事件
     const onChangeSize = componentMap[projectStore.activeWidgetMap[id].type]?.onChangeSize
     if (typeof onChangeSize === 'function') {
       const originWidth = projectStore.activeWidgetMap[id].props.width
       const originHeight = projectStore.activeWidgetMap[id].props.height
-      onChangeSize(projectStore.activeWidgetMap[id].props, {
+      const result = onChangeSize(projectStore.activeWidgetMap[id].props, {
         originWidth,
         currentWidth: width,
         originHeight,
         currentHeight: height
       })
+      width = result?.width ?? width
+      height = result?.height ?? height
     }
 
     if (projectStore.activeWidgetMap[id].props.width !== undefined) {
@@ -276,20 +278,22 @@ const onResizeGroupEnd = ({ events }) => {
     const id = ev.target.attributes['widget-id']?.value
     if (ev.lastEvent && id && projectStore.activeWidgetMap[id]) {
       const scale = getScale(projectStore.activeWidgetMap[id])
-      const width = Math.round(ev.lastEvent.width / scale)
-      const height = Math.round(ev.lastEvent.height / scale)
+      let width = Math.round(ev.lastEvent.width / scale)
+      let height = Math.round(ev.lastEvent.height / scale)
 
       // 触发控件监听事件
       const onChangeSize = componentMap[projectStore.activeWidgetMap[id].type]?.onChangeSize
       if (typeof onChangeSize === 'function') {
         const originWidth = projectStore.activeWidgetMap[id].props.width
         const originHeight = projectStore.activeWidgetMap[id].props.height
-        onChangeSize(projectStore.activeWidgetMap[id].props, {
+        const result = onChangeSize(projectStore.activeWidgetMap[id].props, {
           originWidth,
           currentWidth: width,
           originHeight,
           currentHeight: height
         })
+        width = result?.width ?? width
+        height = result?.height ?? height
       }
 
       if (projectStore.activeWidgetMap[id].props.width !== undefined) {