Browse Source

feat: 添加贝塞尔资源功能

jiaxing.liao 4 weeks ago
parent
commit
8da6821319

+ 4 - 0
src/renderer/components.d.ts

@@ -12,9 +12,11 @@ export {}
 /* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
+    BezierCurveEditorModal: typeof import('./src/components/BezierCurveEditorModal/index.vue')['default']
     ColorModal: typeof import('./src/components/ColorModal/index.vue')['default']
     ColorPicker: typeof import('./src/components/ColorPicker/index.vue')['default']
     EditorModal: typeof import('./src/components/EditorModal/index.vue')['default']
+    ElAlert: typeof import('element-plus/es')['ElAlert']
     ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElCard: typeof import('element-plus/es')['ElCard']
@@ -78,9 +80,11 @@ declare module 'vue' {
 
 // For TSX support
 declare global {
+  const BezierCurveEditorModal: typeof import('./src/components/BezierCurveEditorModal/index.vue')['default']
   const ColorModal: typeof import('./src/components/ColorModal/index.vue')['default']
   const ColorPicker: typeof import('./src/components/ColorPicker/index.vue')['default']
   const EditorModal: typeof import('./src/components/EditorModal/index.vue')['default']
+  const ElAlert: typeof import('element-plus/es')['ElAlert']
   const ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
   const ElButton: typeof import('element-plus/es')['ElButton']
   const ElCard: typeof import('element-plus/es')['ElCard']

+ 625 - 0
src/renderer/src/components/BezierCurveEditorModal/index.vue

@@ -0,0 +1,625 @@
+<template>
+  <el-dialog
+    v-model="show"
+    :title="props.title"
+    width="980px"
+    draggable
+    append-to-body
+    :close-on-click-modal="false"
+    align-center
+  >
+    <div class="bezier-editor">
+      <div ref="stageRef" class="bezier-stage" @wheel.prevent="handleWheel">
+        <svg
+          class="bezier-svg"
+          :viewBox="viewBox"
+          @pointerdown="handleStagePointerDown"
+          @pointermove="handlePointerMove"
+          @pointerup="handlePointerUp"
+          @pointerleave="handlePointerUp"
+        >
+          <defs>
+            <pattern
+              :id="gridId"
+              :x="gridStart.x"
+              :y="gridStart.y"
+              :width="gridSize"
+              :height="gridSize"
+              patternUnits="userSpaceOnUse"
+            >
+              <path
+                :d="`M ${gridSize} 0 L 0 0 0 ${gridSize}`"
+                fill="none"
+                stroke="var(--el-border-color-lighter)"
+                :stroke-width="1 / scale"
+              />
+            </pattern>
+          </defs>
+          <rect
+            :x="view.x"
+            :y="view.y"
+            :width="view.width"
+            :height="view.height"
+            :fill="`url(#${gridId})`"
+          />
+          <rect
+            v-if="hasBounds"
+            x="0"
+            y="0"
+            :width="boundsWidth"
+            :height="boundsHeight"
+            fill="transparent"
+            stroke="var(--el-color-primary)"
+            :stroke-width="1.5 / scale"
+            stroke-dasharray="8 6"
+          />
+          <g v-for="(segment, index) in segments" :key="index">
+            <path
+              :d="getSegmentPath(segment)"
+              fill="none"
+              :stroke="
+                selectedIndex === index ? 'var(--el-color-primary)' : 'var(--el-text-color-regular)'
+              "
+              :stroke-width="(selectedIndex === index ? 3 : 2) / scale"
+              stroke-linecap="round"
+              class="cursor-pointer"
+              @pointerdown.stop="selectSegment(index)"
+            />
+            <g v-if="selectedIndex === index">
+              <line
+                :x1="segment.start.x"
+                :y1="segment.start.y"
+                :x2="segment.control1.x"
+                :y2="segment.control1.y"
+                stroke="var(--el-text-color-secondary)"
+                :stroke-width="1 / scale"
+                stroke-dasharray="6 5"
+              />
+              <line
+                :x1="segment.end.x"
+                :y1="segment.end.y"
+                :x2="segment.control2.x"
+                :y2="segment.control2.y"
+                stroke="var(--el-text-color-secondary)"
+                :stroke-width="1 / scale"
+                stroke-dasharray="6 5"
+              />
+              <circle
+                v-for="point in pointKeys"
+                :key="point"
+                :cx="segment[point].x"
+                :cy="segment[point].y"
+                :r="pointRadius(point)"
+                :class="['bezier-point', `bezier-point-${point}`]"
+                @pointerdown.stop="handlePointPointerDown(index, point, $event)"
+              />
+            </g>
+          </g>
+        </svg>
+        <div class="stage-toolbar">
+          <el-tooltip content="适应视图">
+            <el-button size="small" circle @click="fitView">
+              <LuScanLine size="14" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="放大">
+            <el-button size="small" circle @click="zoomAtCenter(1.2)">
+              <LuZoomIn size="14" />
+            </el-button>
+          </el-tooltip>
+          <el-tooltip content="缩小">
+            <el-button size="small" circle @click="zoomAtCenter(1 / 1.2)">
+              <LuZoomOut size="14" />
+            </el-button>
+          </el-tooltip>
+        </div>
+        <div class="stage-status">
+          <span>{{ hasBounds ? `${boundsWidth} x ${boundsHeight}` : '无限画布' }}</span>
+          <span>{{ Math.round(scale * 100) }}%</span>
+        </div>
+      </div>
+
+      <div class="bezier-panel">
+        <el-form label-position="top">
+          <el-form-item label="线段">
+            <el-select v-model="selectedIndex" :disabled="!segments.length">
+              <el-option
+                v-for="(_, index) in segments"
+                :key="index"
+                :label="`线段 ${index + 1}`"
+                :value="index"
+              />
+            </el-select>
+          </el-form-item>
+          <div class="panel-actions">
+            <el-button size="small" :disabled="segments.length === 100" @click="addSegment">
+              <LuPlus size="14" />
+              新增线段
+            </el-button>
+            <el-button size="small" :disabled="!segments.length" @click="removeSegment">
+              <LuTrash2 size="14" />
+              删除线段
+            </el-button>
+          </div>
+          <el-alert
+            v-if="lockJoin"
+            class="my-10px"
+            type="info"
+            show-icon
+            :closable="false"
+            title="已启用首尾相连"
+          />
+          <template v-if="currentSegment">
+            <div v-for="point in pointKeys" :key="point" class="point-row">
+              <div class="point-title">{{ pointLabels[point] }}</div>
+              <div class="point-inputs">
+                <input-number
+                  v-model="currentSegment[point].x"
+                  size="small"
+                  @change="handleCoordinateChange(point)"
+                >
+                  <template #prefix>X</template>
+                </input-number>
+                <input-number
+                  v-model="currentSegment[point].y"
+                  size="small"
+                  @change="handleCoordinateChange(point)"
+                >
+                  <template #prefix>Y</template>
+                </input-number>
+              </div>
+            </div>
+          </template>
+          <el-empty v-else description="暂无线段" :image-size="80" />
+        </el-form>
+      </div>
+    </div>
+    <template #footer>
+      <el-button @click="show = false">取消</el-button>
+      <el-button type="primary" @click="submit">确定</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import type { BezierPoint, BezierSegment } from '@/types/resource'
+
+import { computed, nextTick, ref } from 'vue'
+import { klona } from 'klona'
+import { v4 } from 'uuid'
+import { LuPlus, LuScanLine, LuTrash2, LuZoomIn, LuZoomOut } from 'vue-icons-plus/lu'
+
+export type BezierCurveEditorValue = {
+  width?: number
+  height?: number
+  segments: BezierSegment[]
+  lockJoin?: boolean
+}
+
+type PointKey = keyof BezierSegment
+
+const pointKeys: PointKey[] = ['start', 'control1', 'control2', 'end']
+const pointLabels: Record<PointKey, string> = {
+  start: '起点',
+  control1: '控制点 1',
+  control2: '控制点 2',
+  end: '终点'
+}
+
+const props = withDefaults(
+  defineProps<{
+    title?: string
+  }>(),
+  {
+    title: '编辑贝塞尔曲线'
+  }
+)
+
+const emit = defineEmits<{
+  confirm: [value: BezierCurveEditorValue]
+}>()
+
+const show = ref(false)
+const gridId = `bezier-editor-grid-${v4()}`
+const stageRef = ref<HTMLDivElement>()
+const segments = ref<BezierSegment[]>([])
+const selectedIndex = ref(0)
+const lockJoin = ref(false)
+const boundsWidth = ref<number>()
+const boundsHeight = ref<number>()
+const scale = ref(1)
+const view = ref({
+  x: -40,
+  y: -40,
+  width: 520,
+  height: 360
+})
+
+const dragState = ref<
+  | {
+      type: 'point'
+      segmentIndex: number
+      point: PointKey
+    }
+  | {
+      type: 'pan'
+      x: number
+      y: number
+      viewX: number
+      viewY: number
+    }
+>()
+
+const hasBounds = computed(
+  () => boundsWidth.value !== undefined && boundsHeight.value !== undefined
+)
+const viewBox = computed(
+  () => `${view.value.x} ${view.value.y} ${view.value.width} ${view.value.height}`
+)
+const gridSize = 100
+const gridStart = computed(() => ({
+  x: Math.floor(view.value.x / gridSize) * gridSize,
+  y: Math.floor(view.value.y / gridSize) * gridSize
+}))
+const currentSegment = computed(() => segments.value[selectedIndex.value])
+
+// 初始线段按 80px 宽、40px 高生成,后续新增段会基于上一段继续推导。
+const createDefaultSegment = (start?: BezierPoint): BezierSegment => {
+  const point = start || { x: 0, y: 0 }
+  return {
+    start: { ...point },
+    control1: { x: point.x + 24, y: point.y },
+    control2: { x: point.x + 56, y: point.y - 40 },
+    end: { x: point.x + 80, y: point.y - 40 }
+  }
+}
+
+// 新增段规则:起点接上、控制点镜像、终点按上一段宽度递增,Y 方向交替。
+const createNextSegment = (prevSegment?: BezierSegment): BezierSegment => {
+  if (!prevSegment) return createDefaultSegment()
+
+  const width = prevSegment.end.x - prevSegment.start.x
+
+  return roundSegment({
+    start: { ...prevSegment.end },
+    control1: {
+      x: prevSegment.end.x * 2 - prevSegment.control2.x,
+      y: prevSegment.end.y * 2 - prevSegment.control2.y
+    },
+    control2: {
+      x: prevSegment.end.x + (prevSegment.end.x - prevSegment.control1.x),
+      y: prevSegment.start.y
+    },
+    end: {
+      x: prevSegment.end.x + width,
+      y: prevSegment.start.y
+    }
+  })
+}
+
+const roundPoint = (point: BezierPoint): BezierPoint => ({
+  x: Math.round(point.x),
+  y: Math.round(point.y)
+})
+
+const roundSegment = (segment: BezierSegment): BezierSegment => ({
+  start: roundPoint(segment.start),
+  control1: roundPoint(segment.control1),
+  control2: roundPoint(segment.control2),
+  end: roundPoint(segment.end)
+})
+
+const normalizeSegments = (list: BezierSegment[]): BezierSegment[] => list.map(roundSegment)
+
+const getSegmentPath = (segment: BezierSegment) => {
+  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 pointRadius = (point: PointKey) =>
+  (point === 'control1' || point === 'control2' ? 5 : 6) / scale.value
+
+// 将鼠标屏幕坐标换算为 SVG 画布坐标,坐标点统一取整。
+const getCanvasPoint = (clientX: number, clientY: number): BezierPoint => {
+  const rect = stageRef.value?.getBoundingClientRect()
+  if (!rect) return { x: 0, y: 0 }
+
+  return roundPoint({
+    x: view.value.x + ((clientX - rect.left) / rect.width) * view.value.width,
+    y: view.value.y + ((clientY - rect.top) / rect.height) * view.value.height
+  })
+}
+
+// 开启首尾相连时,拖拽端点会同步相邻线段的连接点。
+const applyJoinByPoint = (segmentIndex: number, point: PointKey) => {
+  if (!lockJoin.value) return
+
+  const segment = segments.value[segmentIndex]
+  if (!segment) return
+
+  if (point === 'end') {
+    const nextSegment = segments.value[segmentIndex + 1]
+    if (nextSegment) {
+      nextSegment.start = { ...segment.end }
+    }
+  }
+
+  if (point === 'start') {
+    const prevSegment = segments.value[segmentIndex - 1]
+    if (prevSegment) {
+      prevSegment.end = { ...segment.start }
+    }
+  }
+}
+
+const normalizeJoinedSegments = () => {
+  if (!lockJoin.value) return
+
+  for (let i = 1; i < segments.value.length; i += 1) {
+    segments.value[i].start = { ...segments.value[i - 1].end }
+  }
+}
+
+const selectSegment = (index: number) => {
+  selectedIndex.value = index
+}
+
+const handlePointPointerDown = (segmentIndex: number, point: PointKey, event: PointerEvent) => {
+  selectSegment(segmentIndex)
+  ;(event.currentTarget as SVGCircleElement).setPointerCapture(event.pointerId)
+  dragState.value = {
+    type: 'point',
+    segmentIndex,
+    point
+  }
+}
+
+const handleStagePointerDown = (event: PointerEvent) => {
+  dragState.value = {
+    type: 'pan',
+    x: event.clientX,
+    y: event.clientY,
+    viewX: view.value.x,
+    viewY: view.value.y
+  }
+}
+
+const handlePointerMove = (event: PointerEvent) => {
+  if (!dragState.value) return
+
+  if (dragState.value.type === 'point') {
+    const segment = segments.value[dragState.value.segmentIndex]
+    if (!segment) return
+
+    segment[dragState.value.point] = getCanvasPoint(event.clientX, event.clientY)
+    applyJoinByPoint(dragState.value.segmentIndex, dragState.value.point)
+    return
+  }
+
+  const rect = stageRef.value?.getBoundingClientRect()
+  if (!rect) return
+
+  const dx = ((event.clientX - dragState.value.x) / rect.width) * view.value.width
+  const dy = ((event.clientY - dragState.value.y) / rect.height) * view.value.height
+  view.value.x = dragState.value.viewX - dx
+  view.value.y = dragState.value.viewY - dy
+}
+
+const handlePointerUp = () => {
+  dragState.value = undefined
+}
+
+const handleCoordinateChange = (point: PointKey) => {
+  const segment = currentSegment.value
+  if (segment) {
+    segment[point] = roundPoint(segment[point])
+  }
+  applyJoinByPoint(selectedIndex.value, point)
+}
+
+const addSegment = () => {
+  segments.value.push(createNextSegment(segments.value.at(-1)))
+  selectedIndex.value = segments.value.length - 1
+  fitView()
+}
+
+const removeSegment = () => {
+  if (!segments.value.length) return
+
+  segments.value.splice(selectedIndex.value, 1)
+  selectedIndex.value = Math.max(0, Math.min(selectedIndex.value, segments.value.length - 1))
+  normalizeJoinedSegments()
+  fitView()
+}
+
+const getContentBounds = () => {
+  const points = segments.value.flatMap((segment) => pointKeys.map((point) => segment[point]))
+
+  if (hasBounds.value) {
+    points.push({ x: 0, y: 0 }, { x: boundsWidth.value!, y: boundsHeight.value! })
+  }
+
+  if (!points.length) {
+    points.push({ x: 0, y: 0 }, { x: 300, y: 180 })
+  }
+
+  const xs = points.map((point) => point.x)
+  const ys = points.map((point) => point.y)
+  return {
+    minX: Math.min(...xs),
+    maxX: Math.max(...xs),
+    minY: Math.min(...ys),
+    maxY: Math.max(...ys)
+  }
+}
+
+// 根据当前所有控制点和可选边界自适应视口,保证打开弹窗能看到完整曲线。
+const fitView = async () => {
+  await nextTick()
+  const bounds = getContentBounds()
+  const rect = stageRef.value?.getBoundingClientRect()
+  const stageRatio = rect ? rect.width / rect.height : 1.5
+  const padding = 60
+  let width = Math.max(120, bounds.maxX - bounds.minX + padding * 2)
+  let height = Math.max(120, bounds.maxY - bounds.minY + padding * 2)
+  const contentRatio = width / height
+
+  if (contentRatio > stageRatio) {
+    height = width / stageRatio
+  } else {
+    width = height * stageRatio
+  }
+
+  view.value = {
+    x: (bounds.minX + bounds.maxX) / 2 - width / 2,
+    y: (bounds.minY + bounds.maxY) / 2 - height / 2,
+    width,
+    height
+  }
+  scale.value = Math.max(0.1, Math.min(8, 500 / width))
+}
+
+const zoomAt = (factor: number, center: BezierPoint) => {
+  const nextWidth = Math.max(20, Math.min(20000, view.value.width / factor))
+  const nextHeight = Math.max(20, Math.min(20000, view.value.height / factor))
+  const ratioX = (center.x - view.value.x) / view.value.width
+  const ratioY = (center.y - view.value.y) / view.value.height
+
+  view.value = {
+    x: center.x - nextWidth * ratioX,
+    y: center.y - nextHeight * ratioY,
+    width: nextWidth,
+    height: nextHeight
+  }
+  scale.value = Math.max(0.1, Math.min(8, scale.value * factor))
+}
+
+const zoomAtCenter = (factor: number) => {
+  zoomAt(factor, {
+    x: view.value.x + view.value.width / 2,
+    y: view.value.y + view.value.height / 2
+  })
+}
+
+const handleWheel = (event: WheelEvent) => {
+  zoomAt(event.deltaY < 0 ? 1.12 : 1 / 1.12, getCanvasPoint(event.clientX, event.clientY))
+}
+
+defineExpose({
+  edit: (value: BezierCurveEditorValue) => {
+    boundsWidth.value = value.width
+    boundsHeight.value = value.height
+    lockJoin.value = !!value.lockJoin
+    segments.value = normalizeSegments(
+      klona(value.segments?.length ? value.segments : [createDefaultSegment()])
+    )
+    normalizeJoinedSegments()
+    selectedIndex.value = 0
+    show.value = true
+    fitView()
+  }
+})
+
+const submit = () => {
+  segments.value = normalizeSegments(segments.value)
+  show.value = false
+  emit('confirm', {
+    width: boundsWidth.value,
+    height: boundsHeight.value,
+    lockJoin: lockJoin.value,
+    segments: klona(segments.value)
+  })
+}
+</script>
+
+<style scoped>
+.bezier-editor {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 280px;
+  gap: 12px;
+  height: 560px;
+}
+
+.bezier-stage {
+  position: relative;
+  min-width: 0;
+  overflow: hidden;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+  background: var(--el-bg-color);
+}
+
+.bezier-svg {
+  width: 100%;
+  height: 100%;
+  touch-action: none;
+  user-select: none;
+}
+
+.bezier-point {
+  cursor: grab;
+  stroke: var(--el-bg-color);
+  stroke-width: 2;
+}
+
+.bezier-point-start,
+.bezier-point-end {
+  fill: var(--el-color-primary);
+}
+
+.bezier-point-control1,
+.bezier-point-control2 {
+  fill: var(--el-color-warning);
+}
+
+.stage-toolbar {
+  position: absolute;
+  top: 8px;
+  right: 8px;
+  display: flex;
+  gap: 6px;
+}
+
+.stage-status {
+  position: absolute;
+  left: 8px;
+  bottom: 8px;
+  display: flex;
+  gap: 10px;
+  padding: 3px 8px;
+  border-radius: 4px;
+  background: var(--el-bg-color-overlay);
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+  box-shadow: var(--el-box-shadow-lighter);
+}
+
+.bezier-panel {
+  min-width: 0;
+  overflow-y: auto;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+  padding: 12px;
+}
+
+.panel-actions {
+  display: flex;
+  gap: 8px;
+}
+
+.point-row {
+  padding: 10px 0;
+  border-bottom: 1px solid var(--el-border-color-lighter);
+}
+
+.point-title {
+  margin-bottom: 8px;
+  color: var(--el-text-color-secondary);
+  font-size: 12px;
+}
+
+.point-inputs {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 8px;
+}
+</style>

+ 21 - 13
src/renderer/src/components/InputNumber/index.vue

@@ -17,16 +17,20 @@
 </template>
 
 <script setup lang="ts">
-import { computed, useAttrs, ref } from 'vue'
-import { isNumber } from 'lodash-es'
-
+import { computed, ref } from 'vue'
+// 移除未使用的 useAttrs 和 lodash-es 依赖,简化逻辑
 const modelValue = defineModel<number>('modelValue')
+
 const props = withDefaults(
   defineProps<{
     allowDecimal?: boolean
+    min?: number
+    max?: number
   }>(),
   {
-    allowDecimal: false
+    allowDecimal: false,
+    min: undefined,
+    max: undefined
   }
 )
 
@@ -34,14 +38,15 @@ defineOptions({
   name: 'InputNumber'
 })
 
-const attrs = useAttrs()
 const beforeVal = ref<number>()
+
 const getValue = (value?: number | null) => {
   if (value === undefined || value === null) {
     return value
   }
   return props.allowDecimal ? value : Math.trunc(value)
 }
+
 const displayValue = computed(() => getValue(modelValue.value))
 
 const onFocus = () => {
@@ -54,20 +59,23 @@ const onBlur = () => {
     modelValue.value = beforeVal.value
   }
 }
+
 const onChange = (value?: number) => {
   // 处理数字边界问题
   if (value === undefined || value === null) {
     return
   }
-  const normalizedValue = getValue(value)
-  const { min, max } = attrs
-  if (isNumber(min) && normalizedValue! < min) {
-    modelValue.value = min as number
-  } else if (isNumber(max) && normalizedValue! > max) {
-    modelValue.value = max as number
-  } else {
-    modelValue.value = normalizedValue as number
+
+  let normalizedValue = getValue(value)
+
+  // 此时 props.min 和 props.max 类型明确为 number | undefined,可直接比较
+  if (props.min !== undefined && normalizedValue! < props.min) {
+    normalizedValue = props.min
+  } else if (props.max !== undefined && normalizedValue! > props.max) {
+    normalizedValue = props.max
   }
+
+  modelValue.value = normalizedValue as number
 }
 </script>
 

+ 1 - 0
src/renderer/src/components/index.ts

@@ -6,3 +6,4 @@ export { default as ViewTitle } from './ViewTitle/index.vue'
 export { default as IconButton } from './IconButton/index.vue'
 export { default as HightLight } from './HightLight/index.vue'
 export { default as ColorModal } from './ColorModal/index.vue'
+export { default as BezierCurveEditorModal } from './BezierCurveEditorModal/index.vue'

+ 44 - 1
src/renderer/src/lvgl-widgets/button/index.ts

@@ -43,7 +43,43 @@ export default {
       states: [],
       text: 'Button',
       longMode: 'wrap',
-      isStaticText: false
+      isStaticText: false,
+      bezierConfig: {
+        enabled: false,
+        mode: 'time',
+        time: {
+          duration: 500,
+          easing: 'linear',
+          repeatCount: -1,
+          playback: {
+            enabled: false,
+            duration: 500,
+            delay: 500
+          },
+          autoPlay: false,
+          reverse: false,
+          scale: {
+            enabled: false,
+            targetWidth: undefined,
+            targetHeight: undefined,
+            duration: 500,
+            delay: 500
+          }
+        },
+        value: {
+          range: {
+            min: 0,
+            max: 100
+          },
+          current: 50
+        },
+        path: {
+          source: 'resource',
+          resourceId: '',
+          segments: []
+        },
+        showTrack: true
+      }
     },
     styles: [
       {
@@ -193,6 +229,13 @@ export default {
           defaultCollapsed: true
         },
         canUseEventSet: true
+      },
+      {
+        label: '贝塞尔动画',
+        field: 'props.bezierConfig',
+        valueType: 'bezier',
+        // labelWidth: '0',
+        componentProps: {}
       }
     ],
     // 核心属性

+ 22 - 1
src/renderer/src/model/index.ts

@@ -2,7 +2,7 @@ import type { Page } from '@/types/page'
 import type { Screen } from '@/types/screen'
 import type { ScreenConfig } from '@/types/appMeta'
 import type { Bin } from '@/types/bins'
-import type { Resource } from '@/types/resource'
+import type { BezierAnimationResource, Resource } from '@/types/resource'
 import type { IComponentModelConfig } from '@/lvgl-widgets/type'
 
 import { v4 } from 'uuid'
@@ -153,6 +153,27 @@ export const createFileResource = (path: string, type: 'image' | 'font' | 'other
   }
 }
 
+/**
+ * 创建贝塞尔动画路径资源
+ */
+export const createBezierAnimationResource = (
+  index: number,
+  name = `bezier_animation_${index}`
+): BezierAnimationResource => {
+  return {
+    id: v4(),
+    name,
+    segments: [
+      {
+        start: { x: 0, y: 0 },
+        control1: { x: 80, y: 0 },
+        control2: { x: 160, y: 120 },
+        end: { x: 240, y: 120 }
+      }
+    ]
+  }
+}
+
 /**
  * 创建函数
  */

+ 25 - 2
src/renderer/src/store/modules/project.ts

@@ -1,6 +1,11 @@
 import type { AppMeta } from '@/types/appMeta'
 import type { Bin } from '@/types/bins'
-import type { ImageResource, OtherResource, FontResource } from '@/types/resource'
+import type {
+  BezierAnimationResource,
+  ImageResource,
+  OtherResource,
+  FontResource
+} from '@/types/resource'
 import type { VariableGroup } from '@/types/variables'
 import type { Theme } from '@/types/theme'
 import type { Animation } from '@/types/animation'
@@ -32,6 +37,7 @@ export interface IProject {
     images: ImageResource[]
     fonts: FontResource[]
     others: OtherResource[]
+    bezierAnimations: BezierAnimationResource[]
   }
   widgets: BaseWidget[]
   variables: VariableGroup[]
@@ -255,6 +261,21 @@ export const useProjectStore = defineStore('project', () => {
     setCurrentTheme(project.value.currentTheme || DEFAULT_THEME_KEY)
   }
 
+  const normalizeResources = () => {
+    if (!project.value) return
+
+    project.value.resources ||= {
+      images: [],
+      fonts: [],
+      others: [],
+      bezierAnimations: []
+    }
+    project.value.resources.images ||= []
+    project.value.resources.fonts ||= []
+    project.value.resources.others ||= []
+    project.value.resources.bezierAnimations ||= []
+  }
+
   const removeThemeStyles = (themeKey: string) => {
     if (!project.value || !themeKey || themeKey === DEFAULT_THEME_KEY) return
 
@@ -320,7 +341,8 @@ export const useProjectStore = defineStore('project', () => {
       resources: {
         images: [],
         fonts: [],
-        others: []
+        others: [],
+        bezierAnimations: []
       },
       bins: [],
       widgets: [],
@@ -424,6 +446,7 @@ export const useProjectStore = defineStore('project', () => {
    */
   const loadProject = (newProject: IProject, path: string, style: any) => {
     project.value = newProject
+    normalizeResources()
     normalizeThemes()
     globalStyle.value = style
     currentMaxScreen.value = null

+ 26 - 1
src/renderer/src/types/resource.d.ts

@@ -82,4 +82,29 @@ export type OtherResource = {
   bin: string
 }
 
-export type Resource = ImageResource | FontResource | OtherResource
+export type BezierPoint = {
+  x: number
+  y: number
+}
+
+export type BezierSegment = {
+  // 起点
+  start: BezierPoint
+  // 控制点1
+  control1: BezierPoint
+  // 控制点2
+  control2: BezierPoint
+  // 终点
+  end: BezierPoint
+}
+
+export type BezierAnimationResource = {
+  // ID
+  id: string
+  // 名称
+  name: string
+  // 贝塞尔线段
+  segments: BezierSegment[]
+}
+
+export type Resource = ImageResource | FontResource | OtherResource | BezierAnimationResource

+ 10 - 2
src/renderer/src/views/designer/config/property/CusFormItem.vue

@@ -176,6 +176,13 @@
               :options="componentProps?.options"
               v-model="value"
             />
+
+            <!-- 贝塞尔动画配置 -->
+            <BezierConfig
+              v-if="schema.valueType === 'bezier'"
+              v-model="value"
+              v-bind="componentProps"
+            />
           </el-row>
         </el-card>
       </el-collapse-item>
@@ -311,6 +318,7 @@ import StyleImage from './components/StyleImage.vue'
 import StyleTransform from './components/StyleTransform.vue'
 import StyleAnimation from './components/StyleAnimation.vue'
 import StyleOther from './components/StyleOther.vue'
+import BezierConfig from './components/BezierConfig.vue'
 import LanguageSelectModal from './components/LanguageSelectModal.vue'
 import { LuLanguages } from 'vue-icons-plus/lu'
 import VariableBindWrapper from './components/VariableBindWrapper.vue'
@@ -479,7 +487,8 @@ const isFormItem = computed(() => {
     'transform',
     'outline',
     'animation',
-    'other'
+    'other',
+    'bezier'
   ]
   // 排除字段
   const excludeFields: string[] = []
@@ -561,4 +570,3 @@ const dependencyFormItems = computed(() => {
   margin-bottom: 8px;
 }
 </style>
-

+ 420 - 0
src/renderer/src/views/designer/config/property/components/BezierConfig.vue

@@ -0,0 +1,420 @@
+<template>
+  <div class="bezier-config">
+    <el-form-item label="贝塞尔动画" label-position="left" label-width="90px">
+      <div class="w-full flex justify-end">
+        <el-switch v-model="config.enabled" />
+      </div>
+    </el-form-item>
+
+    <template v-if="config.enabled">
+      <el-form-item label="模式" label-position="left" label-width="90px">
+        <el-select v-model="config.mode" :options="modeOptions" />
+      </el-form-item>
+
+      <template v-if="config.mode === 'time'">
+        <el-form-item label="时间" label-position="left" label-width="90px">
+          <input-number
+            v-model="config.time.duration"
+            :min="0"
+            :max="100000"
+            controls-position="right"
+            style="width: 100%"
+          >
+            <template #suffix>ms</template>
+          </input-number>
+        </el-form-item>
+        <el-form-item label="缓动效果" label-position="left" label-width="90px">
+          <el-select-v2 v-model="config.time.easing" :options="easingOptions" />
+        </el-form-item>
+        <el-form-item label="重复次数" label-position="left" label-width="90px">
+          <input-number
+            v-model="config.time.repeatCount"
+            :min="-1"
+            :max="100000"
+            controls-position="right"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="回放" label-position="left" label-width="90px">
+          <div class="w-full flex justify-end">
+            <el-switch v-model="config.time.playback.enabled" />
+          </div>
+        </el-form-item>
+        <template v-if="config.time.playback.enabled">
+          <el-form-item label="回放时间" label-position="left" label-width="90px">
+            <input-number
+              v-model="config.time.playback.duration"
+              :min="0"
+              :max="100000"
+              controls-position="right"
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item label="回放延时" label-position="left" label-width="90px">
+            <input-number
+              v-model="config.time.playback.delay"
+              :min="0"
+              :max="100000"
+              controls-position="right"
+              style="width: 100%"
+            />
+          </el-form-item>
+        </template>
+        <el-form-item label="自动播放" label-position="left" label-width="90px">
+          <div class="w-full flex justify-end">
+            <el-switch v-model="config.time.autoPlay" />
+          </div>
+        </el-form-item>
+        <el-form-item label="倒放" label-position="left" label-width="90px">
+          <div class="w-full flex justify-end">
+            <el-switch v-model="config.time.reverse" />
+          </div>
+        </el-form-item>
+        <el-form-item label="缩放" label-position="left" label-width="90px">
+          <div class="w-full flex justify-end">
+            <el-switch v-model="config.time.scale.enabled" />
+          </div>
+        </el-form-item>
+        <template v-if="config.time.scale.enabled">
+          <el-form-item label="目标宽度" label-position="left" label-width="90px">
+            <input-number
+              v-model="config.time.scale.targetWidth"
+              :min="1"
+              :max="10000"
+              controls-position="right"
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item label="目标高度" label-position="left" label-width="90px">
+            <input-number
+              v-model="config.time.scale.targetHeight"
+              :min="1"
+              :max="10000"
+              controls-position="right"
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item label="缩放时间" label-position="left" label-width="90px">
+            <input-number
+              v-model="config.time.scale.duration"
+              :min="0"
+              :max="100000"
+              controls-position="right"
+              style="width: 100%"
+            />
+          </el-form-item>
+          <el-form-item label="缩放延时" label-position="left" label-width="90px">
+            <input-number
+              v-model="config.time.scale.delay"
+              :min="0"
+              :max="100000"
+              controls-position="right"
+              style="width: 100%"
+            />
+          </el-form-item>
+        </template>
+      </template>
+
+      <template v-else>
+        <el-form-item label="最小" label-position="left" label-width="90px">
+          <input-number
+            v-model="config.value.range.min"
+            :min="-100000"
+            :max="100000"
+            controls-position="right"
+            style="width: 100%"
+            @change="normalizeCurrentValue"
+          />
+        </el-form-item>
+        <el-form-item label="最大" label-position="left" label-width="90px">
+          <input-number
+            v-model="config.value.range.max"
+            :min="-100000"
+            :max="100000"
+            controls-position="right"
+            style="width: 100%"
+            @change="normalizeCurrentValue"
+          />
+        </el-form-item>
+        <el-form-item label="当前值" label-position="left" label-width="90px">
+          <input-number
+            v-model="config.value.current"
+            :min="config.value.range.min"
+            :max="config.value.range.max"
+            controls-position="right"
+            style="width: 100%"
+          />
+        </el-form-item>
+      </template>
+
+      <el-form-item label="贝塞尔段" label-position="left" label-width="90px">
+        <el-select v-model="config.path.source" placeholder="请选择">
+          <el-option value="resource" label="从资源绑定"></el-option>
+          <el-option value="custom" label="自定义段"></el-option>
+        </el-select>
+      </el-form-item>
+      <el-form-item
+        v-if="config.path.source === 'resource'"
+        label="路径资源"
+        label-position="left"
+        label-width="90px"
+      >
+        <el-select-v2
+          v-model="config.path.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.path.segments.length }} 段
+          </div>
+        </div>
+      </template>
+
+      <el-form-item label="轨迹显示" label-position="left" label-width="90px">
+        <div class="w-full flex justify-end">
+          <el-switch v-model="config.showTrack" />
+        </div>
+      </el-form-item>
+    </template>
+
+    <BezierCurveEditorModal ref="bezierEditorRef" @confirm="handleBezierConfirm" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { BezierSegment } from '@/types/resource'
+
+import { computed, ref, watch } 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 BezierAnimationMode = 'time' | 'value'
+export type BezierPathSource = 'resource' | 'custom'
+export type BezierEasing =
+  | 'linear'
+  | 'ease-in'
+  | 'ease-out'
+  | 'ease-in-out'
+  | 'bounce'
+  | 'back'
+  | 'cubic-in-out'
+
+export type BezierAnimationConfig = {
+  enabled: boolean
+  mode: BezierAnimationMode
+  time: {
+    duration: number
+    easing: BezierEasing
+    repeatCount: number
+    playback: {
+      enabled: boolean
+      duration: number
+      delay: number
+    }
+    autoPlay: boolean
+    reverse: boolean
+    scale: {
+      enabled: boolean
+      targetWidth: number
+      targetHeight: number
+      duration: number
+      delay: number
+    }
+  }
+  value: {
+    range: {
+      min: number
+      max: number
+    }
+    current: number
+  }
+  path: {
+    source: BezierPathSource
+    resourceId: string
+    segments: BezierSegment[]
+  }
+  showTrack: boolean
+}
+
+const props = withDefaults(
+  defineProps<{
+    defaultWidth?: number
+    defaultHeight?: number
+  }>(),
+  {
+    defaultWidth: 100,
+    defaultHeight: 100
+  }
+)
+
+const modelValue = defineModel<BezierAnimationConfig>('modelValue')
+const projectStore = useProjectStore()
+const bezierEditorRef = ref<InstanceType<typeof BezierCurveEditorModal>>()
+
+const modeOptions = [
+  { label: '时间控制', value: 'time' },
+  { label: '值控制', value: 'value' }
+]
+
+const easingOptions = [
+  { label: 'Linear', value: 'linear' },
+  { label: 'Ease In', value: 'ease-in' },
+  { label: 'Ease Out', value: 'ease-out' },
+  { label: 'Ease In Out', value: 'ease-in-out' },
+  { label: 'Bounce', value: 'bounce' },
+  { label: 'Back', value: 'back' },
+  { label: 'Cubic In Out', value: 'cubic-in-out' }
+]
+
+const createDefaultSegment = (): BezierSegment => ({
+  start: { x: 0, y: 0 },
+  control1: { x: 50, y: 0 },
+  control2: { x: 0, y: 100 },
+  end: { x: 100, y: 100 }
+})
+
+const createDefaultConfig = (): BezierAnimationConfig => ({
+  enabled: false,
+  mode: 'time',
+  time: {
+    duration: 500,
+    easing: 'linear',
+    repeatCount: -1,
+    playback: {
+      enabled: false,
+      duration: 500,
+      delay: 500
+    },
+    autoPlay: false,
+    reverse: false,
+    scale: {
+      enabled: false,
+      targetWidth: props.defaultWidth,
+      targetHeight: props.defaultHeight,
+      duration: 500,
+      delay: 500
+    }
+  },
+  value: {
+    range: {
+      min: 0,
+      max: 100
+    },
+    current: 50
+  },
+  path: {
+    source: 'resource',
+    resourceId: '',
+    segments: [createDefaultSegment()]
+  },
+  showTrack: true
+})
+
+const mergeConfig = (value?: Partial<BezierAnimationConfig>): BezierAnimationConfig => {
+  const defaults = createDefaultConfig()
+  return {
+    ...defaults,
+    ...value,
+    time: {
+      ...defaults.time,
+      ...value?.time,
+      playback: {
+        ...defaults.time.playback,
+        ...value?.time?.playback
+      },
+      scale: {
+        ...defaults.time.scale,
+        ...value?.time?.scale
+      }
+    },
+    value: {
+      ...defaults.value,
+      ...value?.value,
+      range: {
+        ...defaults.value.range,
+        ...value?.value?.range
+      }
+    },
+    path: {
+      ...defaults.path,
+      ...value?.path,
+      segments: value?.path?.segments?.length
+        ? klona(value.path.segments).slice(0, 100)
+        : defaults.path.segments
+    }
+  }
+}
+
+const config = computed<BezierAnimationConfig>(() => modelValue.value || createDefaultConfig())
+
+const bezierResourceOptions = computed(() => {
+  return (projectStore.project?.resources.bezierAnimations || []).map((item) => ({
+    label: item.name,
+    value: item.id
+  }))
+})
+
+watch(
+  () => modelValue.value,
+  (value) => {
+    if (!value) {
+      modelValue.value = createDefaultConfig()
+      return
+    }
+
+    Object.assign(value, mergeConfig(value))
+  },
+  {
+    immediate: true,
+    deep: false
+  }
+)
+
+const normalizeCurrentValue = () => {
+  const { min, max } = config.value.value.range
+  if (min > max) {
+    config.value.value.range.max = min
+  }
+  config.value.value.current = Math.min(
+    Math.max(config.value.value.current, config.value.value.range.min),
+    config.value.value.range.max
+  )
+}
+
+const openBezierEditor = () => {
+  bezierEditorRef.value?.edit({
+    segments: klona(
+      config.value.path.segments?.length ? config.value.path.segments : [createDefaultSegment()]
+    )
+  })
+}
+
+const handleBezierConfirm = (value: { segments: BezierSegment[] }) => {
+  config.value.path.segments = klona(value.segments).slice(0, 100)
+}
+</script>
+
+<style scoped>
+.bezier-config {
+  width: 100%;
+  padding: 12px;
+}
+
+.segment-actions {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 8px;
+  margin-bottom: 8px;
+}
+</style>

+ 1 - 0
src/renderer/src/views/designer/config/property/index.vue

@@ -393,6 +393,7 @@ const onChangeStyleByState = (type: 'select' | 'cancel') => {
 .property-scroll {
   height: calc(100vh - 130px);
   overflow: auto;
+  overflow-x: hidden;
   overscroll-behavior: contain;
   contain: layout paint;
   position: relative;

+ 8 - 3
src/renderer/src/views/designer/sidebar/Hierarchy.vue

@@ -47,6 +47,7 @@ import ScreenTreeItem from './components/ScreenTreeItem.vue'
 import PageTreeItem from './components/PageTreeItem.vue'
 import { useProjectStore } from '@/store/modules/project'
 
+import type { ComponentInternalInstance } from 'vue'
 import type { BaseWidget } from '@/types/baseWidget'
 import type { Page } from '@/types/page'
 import type { Screen } from '@/types/screen'
@@ -233,7 +234,12 @@ const setOpenedPage = (screenId?: string, pageId?: string) => {
   }
 }
 
-const handleNodeClick = (nodeData: HierarchyNodeData, _node: unknown, e?: MouseEvent) => {
+const handleNodeClick = (
+  nodeData: HierarchyNodeData,
+  _node: unknown,
+  _nodeInstance: ComponentInternalInstance | null,
+  e?: MouseEvent
+) => {
   if (nodeData.nodeType === 'page') {
     projectStore.activePageId = nodeData.id
     setOpenedPage(nodeData.screenId, nodeData.id)
@@ -371,8 +377,7 @@ const reorderPage = (
 
   screen.pages.splice(dropType === 'before' ? nextTargetIndex : nextTargetIndex + 1, 0, page)
 
-  const screenIndex =
-    projectStore.project?.screens.findIndex((item) => item.id === screen.id) ?? -1
+  const screenIndex = projectStore.project?.screens.findIndex((item) => item.id === screen.id) ?? -1
   const openedPageId = projectStore.openPageIds[screenIndex]
   setOpenedPage(
     screen.id,

+ 100 - 16
src/renderer/src/views/designer/sidebar/Resource.vue

@@ -23,7 +23,12 @@
       </template>
       <div class="w-full h-full flex flex-col">
         <div class="p-12px flex gap-8px shrink-0">
-          <el-input spellcheck="false" v-model="imageSearch" size="small" placeholder="输入搜索..." />
+          <el-input
+            spellcheck="false"
+            v-model="imageSearch"
+            size="small"
+            placeholder="输入搜索..."
+          />
         </div>
         <el-scrollbar class="flex-1">
           <ResourceItem
@@ -48,15 +53,56 @@
       </template>
       <div class="w-full h-full flex flex-col">
         <div class="p-12px flex gap-8px shrink-0">
-          <el-input spellcheck="false" v-model="fontSearch" size="small" placeholder="输入搜索..." />
+          <el-input
+            spellcheck="false"
+            v-model="fontSearch"
+            size="small"
+            placeholder="输入搜索..."
+          />
         </div>
         <el-scrollbar class="flex-1">
-          <ResourceItem v-for="item in getFonts || []" :key="item.id" :data="item" type="font"
-            @delete="deleteResource(item, 'fonts')" />
+          <ResourceItem
+            v-for="item in getFonts || []"
+            :key="item.id"
+            :data="item"
+            type="font"
+            @delete="deleteResource(item, 'fonts')"
+          />
           <div v-if="!getFonts?.length" class="text-center text-text-secondary">暂无字体~</div>
         </el-scrollbar>
       </div>
     </SplitterCollapseItem>
+    <SplitterCollapseItem title="贝塞尔动画">
+      <template #header-right>
+        <el-tooltip content="添加">
+          <el-button type="text" class="mr-12px" @click.capture.stop="handleAddBezier">
+            <LuPlus size="14px" />
+          </el-button>
+        </el-tooltip>
+      </template>
+      <div class="w-full h-full flex flex-col">
+        <div class="p-12px flex gap-8px shrink-0">
+          <el-input
+            spellcheck="false"
+            v-model="bezierSearch"
+            size="small"
+            placeholder="输入搜索..."
+          />
+        </div>
+        <el-scrollbar class="flex-1">
+          <ResourceItem
+            v-for="item in getBezierAnimations || []"
+            :key="item.id"
+            :data="item"
+            type="bezier"
+            @delete="deleteBezierResource(item)"
+          />
+          <div v-if="!getBezierAnimations?.length" class="text-center text-text-secondary">
+            暂无贝塞尔动画~
+          </div>
+        </el-scrollbar>
+      </div>
+    </SplitterCollapseItem>
     <SplitterCollapseItem title="其他资源">
       <template #header-right>
         <el-tooltip content="添加">
@@ -67,11 +113,21 @@
       </template>
       <div class="w-full h-full flex flex-col">
         <div class="p-12px flex gap-8px shrink-0">
-          <el-input spellcheck="false" v-model="otherSearch" size="small" placeholder="输入搜索..." />
+          <el-input
+            spellcheck="false"
+            v-model="otherSearch"
+            size="small"
+            placeholder="输入搜索..."
+          />
         </div>
         <el-scrollbar class="flex-1">
-          <ResourceItem v-for="item in getOthers || []" :key="item.id" :data="item" type="other"
-            @delete="deleteResource(item, 'others')" />
+          <ResourceItem
+            v-for="item in getOthers || []"
+            :key="item.id"
+            :data="item"
+            type="other"
+            @delete="deleteResource(item, 'others')"
+          />
           <div v-if="!getOthers?.length" class="text-center text-text-secondary">暂无资源~</div>
         </el-scrollbar>
       </div>
@@ -80,7 +136,12 @@
 </template>
 
 <script setup lang="ts">
-import type { FontResource, ImageResource, OtherResource, Resource } from '@/types/resource'
+import type {
+  BezierAnimationResource,
+  FontResource,
+  ImageResource,
+  OtherResource
+} from '@/types/resource'
 import type { BaseWidget } from '@/types/baseWidget'
 import type { Page } from '@/types/page'
 import type { Screen } from '@/types/screen'
@@ -90,15 +151,17 @@ import { ElMessage } from 'element-plus'
 import { SplitterCollapse, SplitterCollapseItem } from '@/components/SplitterCollapse'
 import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
 import { useProjectStore } from '@/store/modules/project'
-import { createFileResource } from '@/model'
+import { createBezierAnimationResource, createFileResource } from '@/model'
 import ResourceItem from './components/ResourceItem.vue'
 
 const projectStore = useProjectStore()
 const imageSearch = ref('')
 const fontSearch = ref('')
 const otherSearch = ref('')
+const bezierSearch = ref('')
 type ResourceListKey = 'images' | 'fonts' | 'others'
 type ResourceType = 'image' | 'font' | 'other'
+type FileResource = ImageResource | FontResource | OtherResource
 
 const normalizeResourcePath = (path: string) => path.replaceAll('/', '\\').toLowerCase()
 
@@ -171,7 +234,13 @@ const getOthers = computed(() => {
   )
 })
 
-// 添加图片
+const getBezierAnimations = computed(() => {
+  return projectStore.project?.resources.bezierAnimations.filter((item) =>
+    item.name.includes(bezierSearch.value)
+  )
+})
+
+// 递归统计项目中图片资源的引用次数,用于清理未使用图片。
 const countImageRefs = (
   value: unknown,
   imageIds: Set<string>,
@@ -341,16 +410,31 @@ const handleAddOther = async () => {
   await addResourceFiles(paths, 'other', 'others')
 }
 
-// 删除资源
-const deleteResource = async (resource: Resource, type: 'images' | 'fonts' | 'others') => {
-  await window.electron.ipcRenderer.invoke('delete-file', projectStore.projectPath + resource.path)
-  const index =
-    projectStore.project?.resources[type].findIndex((item) => item.id === resource.id) ?? -1
+// 贝塞尔动画是结构化路径数据,不需要复制外部文件。
+const handleAddBezier = () => {
+  const resource = createBezierAnimationResource(
+    (projectStore.project?.resources.bezierAnimations.length || 0) + 1
+  )
+  projectStore.project?.resources.bezierAnimations.push(resource)
+}
+
+const removeResourceById = <T extends { id: string }>(list: T[] | undefined, id: string) => {
+  const index = list?.findIndex((item) => item.id === id) ?? -1
   if (index > -1) {
-    projectStore.project?.resources[type].splice(index, 1)
+    list?.splice(index, 1)
   }
 }
 
+// 删除资源
+const deleteResource = async (resource: FileResource, type: 'images' | 'fonts' | 'others') => {
+  await window.electron.ipcRenderer.invoke('delete-file', projectStore.projectPath + resource.path)
+  removeResourceById(projectStore.project?.resources[type], resource.id)
+}
+
+const deleteBezierResource = (resource: BezierAnimationResource) => {
+  removeResourceById(projectStore.project?.resources.bezierAnimations, resource.id)
+}
+
 // 清除未使用图片
 const handleClearUnusedImage = () => {
   const useCount = refreshImageUseCount()

+ 75 - 16
src/renderer/src/views/designer/sidebar/components/ResourceItem.vue

@@ -6,21 +6,27 @@
     @contextmenu="handleContextmenu"
   >
     <span v-if="type === 'font'" class="w-32px h-32px grid place-items-center">
-      <BsFiletypeTtf v-if="data.fileType === 'ttf'" size="16px" />
-      <BsFiletypeWoff v-else-if="data.fileType === 'woff'" size="16px" />
+      <BsFiletypeTtf v-if="fileType === 'ttf'" size="16px" />
+      <BsFiletypeWoff v-else-if="fileType === 'woff'" size="16px" />
       <img v-else :src="fontImg" class="w-16px h-16px" />
     </span>
+    <span
+      v-else-if="type === 'bezier'"
+      class="w-32px h-32px rounded-4px bg-bg-tertiary grid place-items-center"
+    >
+      <LuSpline size="16px" />
+    </span>
     <span
       v-if="type === 'image'"
       class="shrink-0 w-32px h-32px rounded-4px bg-bg-tertiary grid place-items-center"
     >
       <LocalImage
-        :src="projectStore.projectPath + data.path"
+        :src="projectStore.projectPath + resourcePath"
         class="h-full w-full object-contain"
       />
     </span>
     <span class="flex-1 flex items-center gap-4px">
-      <span class="truncate max-w-120px" :title="data.fileName">{{ data.fileName }}</span>
+      <span class="truncate max-w-120px" :title="resourceName">{{ resourceName }}</span>
       <span
         v-if="type === 'image'"
         class="flex flex-col gap-2px w-80px text-8px text-text-secondary"
@@ -66,7 +72,9 @@
     >
       <template #dropdown>
         <el-dropdown-menu>
-          <el-dropdown-item @click="openInExplorer">在资源管理器中打开</el-dropdown-item>
+          <el-dropdown-item v-if="type !== 'bezier'" @click="openInExplorer">
+            在资源管理器中打开
+          </el-dropdown-item>
           <el-dropdown-item @click="copyName">复制名称</el-dropdown-item>
         </el-dropdown-menu>
       </template>
@@ -75,21 +83,29 @@
 
   <EditImageModal ref="editImageModalRef" @change="handleChangeResource" />
   <EditFontModal ref="editFontModalRef" @change="handleChangeResource" />
+  <BezierCurveEditorModal ref="editBezierModalRef" @confirm="handleBezierConfirm" />
 </template>
 
 <script setup lang="ts">
 import type { DropdownInstance } from 'element-plus'
-import type { FontResource, ImageResource, Resource } from '@/types/resource'
+import type {
+  BezierAnimationResource,
+  FontResource,
+  ImageResource,
+  Resource
+} from '@/types/resource'
 
 import { computed, ref, watch } from 'vue'
 import LocalImage from '@/components/LocalImage/index.vue'
 import { useProjectStore } from '@/store/modules/project'
-import { LuPencilLine, LuTrash2 } from 'vue-icons-plus/lu'
+import { LuPencilLine, LuSpline, LuTrash2 } from 'vue-icons-plus/lu'
 import { BsFiletypeTtf, BsFiletypeWoff } from 'vue-icons-plus/bs'
 import fontImg from '@/assets/font.svg'
 import EditImageModal from './EditImageModal.vue'
 import EditFontModal from './EditFontModal.vue'
 import { getImageByPath } from '@/utils'
+import { klona } from 'klona'
+import BezierCurveEditorModal from '@/components/BezierCurveEditorModal/index.vue'
 
 defineEmits<{
   delete: []
@@ -97,7 +113,7 @@ defineEmits<{
 
 const props = defineProps<{
   data: Resource
-  type: 'image' | 'font' | 'other'
+  type: 'image' | 'font' | 'other' | 'bezier'
   imageUseCount?: number
 }>()
 
@@ -105,6 +121,7 @@ const listBoxRef = ref<HTMLElement>()
 const projectStore = useProjectStore()
 const editImageModalRef = ref<InstanceType<typeof EditImageModal>>()
 const editFontModalRef = ref<InstanceType<typeof EditFontModal>>()
+const editBezierModalRef = ref<InstanceType<typeof BezierCurveEditorModal>>()
 
 const dropdownRef = ref<DropdownInstance>()
 const position = ref({
@@ -132,8 +149,29 @@ const imageInfo = computed(() => ({
   useCount: props.imageUseCount || 0
 }))
 
+const resourceName = computed(() => {
+  if (props.type === 'bezier') {
+    return (props.data as BezierAnimationResource).name
+  }
+
+  return (props.data as ImageResource | FontResource).fileName
+})
+
+const resourcePath = computed(() => {
+  if (props.type === 'bezier') return ''
+
+  return (props.data as ImageResource | FontResource).path
+})
+
+const fileType = computed(() => {
+  if (props.type !== 'font') return ''
+
+  return (props.data as FontResource).fileType
+})
+
+// 只在图片资源变化时读取本地图片信息,贝塞尔资源没有文件路径。
 watch(
-  () => [props.type, props.data.path, projectStore.projectPath],
+  () => [props.type, resourcePath.value, projectStore.projectPath],
   async () => {
     if (props.type === 'image') {
       const resource = props.data as ImageResource
@@ -162,14 +200,17 @@ const handleContextmenu = (event: MouseEvent) => {
 }
 
 const openInExplorer = () => {
+  if (props.type === 'bezier') return
+
+  const resource = props.data as ImageResource | FontResource
   window.electron.ipcRenderer.invoke(
     'open-file-in-explorer',
-    projectStore.projectPath + props.data.path
+    projectStore.projectPath + resource.path
   )
 }
 
 const copyName = () => {
-  navigator.clipboard.writeText(props.data.fileName)
+  navigator.clipboard.writeText(resourceName.value)
 }
 
 const handleEdit = () => {
@@ -179,22 +220,40 @@ const handleEdit = () => {
   if (props.type === 'font') {
     editFontModalRef.value?.edit(props.data as FontResource)
   }
+  if (props.type === 'bezier') {
+    const resource = props.data as BezierAnimationResource
+    // 贝塞尔资源直接编辑路径数据,不涉及文件重命名。
+    editBezierModalRef.value?.edit({
+      segments: klona(resource.segments)
+    })
+  }
 }
 
 const handleChangeResource = async (resource: Resource) => {
-  const oldFileName = props.data.fileName
+  if (props.type === 'bezier') return
+
+  // 文件资源修改名称时,需要同步重命名项目目录下的实际文件。
+  const data = props.data as ImageResource | FontResource
+  const nextResource = resource as ImageResource | FontResource
+  const oldFileName = data.fileName
 
   Object.entries(resource).forEach(([key, value]) => {
     ;(props.data as Record<string, unknown>)[key] = value
   })
 
-  if (resource.fileName !== oldFileName) {
-    const sourcePath = projectStore.projectPath + props.data.path
-    const targetPath = projectStore.projectPath + props.data.path.replace(oldFileName, resource.fileName)
+  if (nextResource.fileName !== oldFileName) {
+    const sourcePath = projectStore.projectPath + data.path
+    const targetPath =
+      projectStore.projectPath + data.path.replace(oldFileName, nextResource.fileName)
     await window.electron.ipcRenderer.invoke('modify-file-name', sourcePath, targetPath)
-    props.data.path = props.data.path.replace(oldFileName, resource.fileName)
+    data.path = data.path.replace(oldFileName, nextResource.fileName)
   }
 }
+
+const handleBezierConfirm = (value: { segments: BezierAnimationResource['segments'] }) => {
+  const resource = props.data as BezierAnimationResource
+  resource.segments = klona(value.segments)
+}
 </script>
 
 <style scoped></style>

+ 1 - 1
src/renderer/src/views/designer/workspace/composite/eventEdit/SelectPopover.vue

@@ -7,7 +7,7 @@
     placement="top"
     popper-class="event-edit-select-popover"
     popper-style="width: 440px; z-index: 12000;"
-    :popper-options="popperOptions"
+    :popper-options="popperOptions as any"
     @hide="handleCancel"
   >
     <template #reference>