فهرست منبع

feat: 添加贝塞尔控件

jiaxing.liao 3 هفته پیش
والد
کامیت
b33f15b279

+ 1 - 1
src/renderer/src/components/BezierCurveEditorModal/index.vue

@@ -423,7 +423,7 @@ const addSegment = () => {
 }
 
 const removeSegment = () => {
-  if (!segments.value.length) return
+  if (segments.value.length <= 1) return
 
   segments.value.splice(selectedIndex.value, 1)
   selectedIndex.value = Math.max(0, Math.min(selectedIndex.value, segments.value.length - 1))

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

@@ -178,5 +178,6 @@
   "initialValue": "Initial Value",
   "switchToSingleScreenWarning": "Switching to single screen will delete all pages and widgets on the second screen. This action cannot be undone. Continue?",
   "warning": "Warning",
-  "editSuccess": "Edit Success"
+  "editSuccess": "Edit Success",
+  "bezier": "Bezier"
 }

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

@@ -177,5 +177,6 @@
   "initialValue": "初始值",
   "switchToSingleScreenWarning": "切换到单屏幕将删除第二屏幕的所有页面和组件,此操作不可撤销,是否继续?",
   "warning": "警告",
-  "editSuccess": "编辑成功"
+  "editSuccess": "编辑成功",
+  "bezier": "贝塞尔"
 }

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1 - 0
src/renderer/src/lvgl-widgets/assets/icon/icon_bezier.svg


+ 114 - 0
src/renderer/src/lvgl-widgets/bezier/Bezier.vue

@@ -0,0 +1,114 @@
+<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 class="absolute inset-0 w-full h-full block" :viewBox="viewBox" xmlns="http://www.w3.org/2000/svg">
+      <path
+        v-if="shapeMode && fillPath"
+        :d="fillPath"
+        :fill="styleMap?.itemsStyle?.backgroundColor || '#ffffffff'"
+      />
+      <path
+        v-for="(path, index) in strokePaths"
+        :key="index"
+        :d="path"
+        fill="none"
+        :stroke="styleMap?.mainStyle?.line?.color || '#212121ff'"
+        :stroke-width="strokeWidth"
+        :stroke-linecap="styleMap?.mainStyle?.line?.radius ? 'round' : 'butt'"
+        stroke-linejoin="round"
+        :stroke-dasharray="dashArray"
+      />
+    </svg>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import { useProjectStore } from '@/store/modules/project'
+import ImageBg from '../ImageBg.vue'
+
+const props = defineProps<{
+  width: number
+  height: number
+  styles: any
+  state?: string
+  part?: string
+  shapeMode?: boolean
+  path: {
+    source: 'resource' | 'custom'
+    resourceId: string
+    segments: {
+      start: { x: number; y: number }
+      control1: { x: number; y: number }
+      control2: { x: number; y: number }
+      end: { x: number; y: number }
+    }[]
+  }
+}>()
+
+const styleMap = useWidgetStyle({
+  widget: 'lv_bezier',
+  props
+})
+const projectStore = useProjectStore()
+
+const viewBox = computed(() => `0 0 ${props.width} ${props.height}`)
+const strokeWidth = computed(() => styleMap.value?.mainStyle?.line?.width ?? 5)
+const dashArray = computed(() => {
+  const line = styleMap.value?.mainStyle?.line
+  const dashWidth = Number(line?.dashWidth || 0)
+  const dashGap = Number(line?.dashGap || 0)
+  return dashWidth > 0 ? `${dashWidth} ${dashGap || dashWidth}` : undefined
+})
+
+const segments = computed(() => {
+  if (props.path?.source === 'resource') {
+    return (
+      projectStore.project?.resources.bezierAnimations.find(
+        (item) => item.id === props.path.resourceId
+      )?.segments || props.path?.segments || []
+    )
+  }
+  return props.path?.segments || []
+})
+
+const buildSegmentPath = (segment: any) => {
+  return `M ${segment.start.x} ${segment.start.y} C ${segment.control1.x} ${segment.control1.y}, ${segment.control2.x} ${segment.control2.y}, ${segment.end.x} ${segment.end.y}`
+}
+
+const strokePaths = computed(() => {
+  const list = segments.value.map(buildSegmentPath)
+  if (!props.shapeMode || !segments.value.length) return list
+
+  const first = segments.value[0]
+  const result = [...list]
+  for (let i = 0; i < segments.value.length - 1; i += 1) {
+    const current = segments.value[i]
+    const next = segments.value[i + 1]
+    result.push(`M ${current.end.x} ${current.end.y} L ${next.start.x} ${next.start.y}`)
+  }
+  const last = segments.value[segments.value.length - 1]
+  result.push(`M ${last.end.x} ${last.end.y} L ${first.start.x} ${first.start.y}`)
+  return result
+})
+
+const fillPath = computed(() => {
+  if (!props.shapeMode || !segments.value.length) return ''
+  const first = segments.value[0]
+  const parts = [`M ${first.start.x} ${first.start.y}`]
+  segments.value.forEach((segment) => {
+    parts.push(`C ${segment.control1.x} ${segment.control1.y}, ${segment.control2.x} ${segment.control2.y}, ${segment.end.x} ${segment.end.y}`)
+  })
+  for (let i = 0; i < segments.value.length - 1; i += 1) {
+    parts.push(`L ${segments.value[i + 1].start.x} ${segments.value[i + 1].start.y}`)
+  }
+  parts.push(`L ${first.start.x} ${first.start.y} Z`)
+  return parts.join(' ')
+})
+</script>

+ 126 - 0
src/renderer/src/lvgl-widgets/bezier/Config.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="bezier-path-config">
+    <el-form-item label="贝塞尔段" label-position="left" label-width="90px">
+      <el-select v-model="config.source" placeholder="请选择">
+        <el-option
+          value="resource"
+          label="从资源中绑定"
+          :disabled="!bezierResourceOptions.length"
+        ></el-option>
+        <el-option value="custom" label="自定义段"></el-option>
+      </el-select>
+    </el-form-item>
+
+    <el-form-item
+      v-if="config.source === 'resource'"
+      label="路径资源"
+      label-position="left"
+      label-width="90px"
+    >
+      <el-select-v2
+        v-model="config.resourceId"
+        :options="bezierResourceOptions"
+        clearable
+        placeholder="请选择"
+      />
+    </el-form-item>
+
+    <template v-else>
+      <div class="segment-actions">
+        <el-button type="text" size="small" class="px-0!" @click="openBezierEditor">
+          <LuPencilLine size="14" />
+          编辑
+        </el-button>
+        <div class="text-12px text-text-secondary">已配置 {{ config.segments.length }} 段</div>
+      </div>
+    </template>
+
+    <BezierCurveEditorModal ref="bezierEditorRef" @confirm="handleBezierConfirm" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { BezierSegment } from '@/types/resource'
+import type { Ref } from 'vue'
+
+import { computed, ref } from 'vue'
+import { klona } from 'klona'
+import { LuPencilLine } from 'vue-icons-plus/lu'
+import { useProjectStore } from '@/store/modules/project'
+import BezierCurveEditorModal from '@/components/BezierCurveEditorModal/index.vue'
+
+export type BezierPathSource = 'resource' | 'custom'
+
+export type BezierPathConfigValue = {
+  source: BezierPathSource
+  resourceId: string
+  segments: BezierSegment[]
+}
+
+const props = defineProps<{
+  values: Ref<BezierPathConfigValue>
+  width: number
+  height: number
+}>()
+
+const projectStore = useProjectStore()
+const bezierEditorRef = ref<InstanceType<typeof BezierCurveEditorModal>>()
+
+const config = computed({
+  get() {
+    console.log('get config', props)
+    return (props.values?.value || createDefaultConfig()) as BezierPathConfigValue
+  },
+  set(val: BezierPathConfigValue) {
+    props.values.value = val
+  }
+})
+
+const createDefaultSegment = (): BezierSegment => ({
+  start: { x: 0, y: 100 },
+  control1: { x: 24, y: 100 },
+  control2: { x: 56, y: 60 },
+  end: { x: 80, y: 60 }
+})
+
+const createDefaultConfig = (): BezierPathConfigValue => ({
+  source: 'resource',
+  resourceId: '',
+  segments: [createDefaultSegment()]
+})
+
+const bezierResourceOptions = computed(() => {
+  return (projectStore.project?.resources.bezierAnimations || []).map((item) => ({
+    label: item.name,
+    value: item.id
+  }))
+})
+
+const openBezierEditor = () => {
+  bezierEditorRef.value?.edit({
+    segments: klona(
+      config.value.segments?.length ? config.value.segments : [createDefaultSegment()]
+    ),
+    width: props.width,
+    height: props.height
+  })
+}
+
+const handleBezierConfirm = (value: { segments: BezierSegment[] }) => {
+  config.value.segments = klona(value.segments).slice(0, 100)
+}
+</script>
+
+<style scoped>
+.bezier-path-config {
+  width: 100%;
+}
+
+.segment-actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+</style>

+ 227 - 0
src/renderer/src/lvgl-widgets/bezier/index.tsx

@@ -0,0 +1,227 @@
+import Bezier from './Bezier.vue'
+import icon from '../assets/icon/icon_bezier.svg'
+import type { IComponentModelConfig } from '../type.js'
+import i18n from '@/locales'
+import { flagOptions, stateOptions, stateList, DEFAULT_THEME_KEY } from '@/constants'
+import defaultStyle from './style.json'
+import Config from './Config.vue'
+
+const createDefaultSegment = () => ({
+  start: { x: 0, y: 100 },
+  control1: { x: 24, y: 100 },
+  control2: { x: 56, y: 60 },
+  end: { x: 80, y: 60 }
+})
+
+export default {
+  label: i18n.global.t('bezier'),
+  icon,
+  component: Bezier,
+  key: 'lv_bezier',
+  group: i18n.global.t('display'),
+  sort: 1,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    },
+    {
+      name: 'items',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'bezier',
+    props: {
+      x: 0,
+      y: 0,
+      width: 300,
+      height: 300,
+      flags: [
+        '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: [],
+      shapeMode: false,
+      path: {
+        source: 'custom',
+        resourceId: '',
+        segments: [createDefaultSegment()]
+      }
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        theme: DEFAULT_THEME_KEY,
+        background: {
+          color: '#ffffff00',
+          image: {
+            imgId: '',
+            recolor: '#ffffff00',
+            alpha: 255
+          }
+        },
+        line: {
+          color: '#212121ff',
+          width: 5,
+          radius: false,
+          dashWidth: 0,
+          dashGap: 0
+        }
+      },
+      {
+        part: {
+          name: 'items',
+          state: 'default'
+        },
+        theme: DEFAULT_THEME_KEY,
+        background: {
+          color: '#ffffffff'
+        }
+      }
+    ]
+  },
+  config: {
+    props: [
+      {
+        label: '名称',
+        field: 'name',
+        valueType: 'text',
+        componentProps: {
+          placeholder: '请输入名称',
+          type: 'text'
+        }
+      },
+      {
+        label: '位置/大小',
+        valueType: 'group',
+        children: [
+          {
+            field: 'props.x',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'X' },
+            canUseEventSet: true
+          },
+          {
+            field: 'props.y',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'Y' },
+            canUseEventSet: true
+          },
+          {
+            field: 'props.width',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'W' },
+            canUseEventSet: true
+          },
+          {
+            field: 'props.height',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'H' },
+            canUseEventSet: true
+          }
+        ]
+      },
+      {
+        label: '标识',
+        field: 'props.flags',
+        valueType: 'checkbox',
+        componentProps: {
+          options: flagOptions,
+          defaultCollapsed: true
+        },
+        canUseEventSet: true
+      },
+      {
+        label: '状态',
+        field: 'props.states',
+        valueType: 'checkbox',
+        componentProps: {
+          options: stateOptions,
+          defaultCollapsed: true
+        },
+        canUseEventSet: true
+      }
+    ],
+    coreProps: [
+      {
+        valueType: 'dependency',
+        name: ['props'],
+        dependency: ({ props }) => {
+          return [
+            {
+              label: '贝塞尔段',
+              field: 'props.path',
+              render: (val) => {
+                return <Config values={val} width={props.width} height={props.height} />
+              }
+            }
+          ]
+        }
+      },
+      {
+        label: '图形模式',
+        field: 'props.shapeMode',
+        labelWidth: 100,
+        valueType: 'switch'
+      }
+    ],
+    styles: [
+      {
+        label: '模块状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        label: '背景',
+        field: 'background',
+        valueType: 'background',
+        show: (ctx) => {
+          const partName = ctx?.formData?.part?.name
+          if (partName === 'main') return true
+          if (partName === 'items') return !!ctx?.widgetData?.props?.shapeMode
+          return false
+        }
+      },
+      {
+        label: '直线',
+        field: 'line',
+        valueType: 'line',
+        show: (ctx) => ctx?.formData?.part?.name === 'main'
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 36 - 0
src/renderer/src/lvgl-widgets/bezier/style.json

@@ -0,0 +1,36 @@
+{
+  "widget": "lv_bezier",
+  "styleName": "default",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff00",
+          "image": {
+            "imgId": "",
+            "recolor": "#ffffff00",
+            "alpha": 255
+          }
+        },
+        "line": {
+          "color": "#212121ff",
+          "width": 5,
+          "radius": false,
+          "dashWidth": 0,
+          "dashGap": 0
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "items",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffffff"
+        }
+      },
+      "state": []
+    }
+  ]
+}

+ 2 - 0
src/renderer/src/lvgl-widgets/index.ts

@@ -42,6 +42,7 @@ import Lottie from './lottie'
 import Video from './video/index'
 import QRCode from './qrcode/index'
 import Barcode from './barcode/index'
+import Bezier from './bezier/index'
 
 import Page from './page'
 import { IComponentModelConfig } from './type'
@@ -95,6 +96,7 @@ export const ComponentArray = [
   Lottie,
   QRCode,
   Barcode,
+  Bezier,
 
   BaseMeter
 ].map((item) => enhanceWidgetVariableConfig(item)) as IComponentModelConfig[]

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

@@ -1,6 +1,7 @@
 import { CSSProperties } from 'vue'
 import type { FormItemRule } from 'element-plus'
 import type { VariableType } from '@/types/variables'
+import type { BezierSegment } from '@/types/resource'
 
 type PartItem = {
   name: string
@@ -156,6 +157,13 @@ export interface IComponentModelConfig {
   getChildStyle?: (props: any) => CSSProperties
 }
 
+export type RenderCallbackParams = {
+  formData?: Record<string, any>
+  widgetData?: Record<string, any>
+  schema?: ComponentSchema
+  value?: any
+}
+
 /**
  * 渐变颜色
  */
@@ -344,6 +352,15 @@ export interface IStyleConfig {
     // 虚线间隔
     dashGap?: number
   }
+  // 贝塞尔路径配置
+  bezierPath?: {
+    path: {
+      source: 'resource' | 'custom'
+      resourceId: string
+      segments: BezierSegment[]
+    }
+    shapeMode?: boolean
+  }
   // 图像样式
   imageStyle?: {
     recolor: string

+ 4 - 0
src/renderer/src/lvgl-widgets/variableConfig.ts

@@ -166,6 +166,10 @@ const widgetVariableConfigMap: WidgetVariableConfigMap = {
   lv_line: {
     ...commonPositionSizeFields
   },
+  lv_bezier: {
+    ...commonPositionSizeFields,
+    'props.shapeMode': { type: 'bool' }
+  },
   lv_arc: {
     ...commonPositionSizeFields,
     'props.mode': { type: 'enum', enumMap: arcModeEnumMap },