Jelajahi Sumber

feat: 添加canvas控件

jiaxing.liao 1 Minggu lalu
induk
melakukan
0e8818a271

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

@@ -130,5 +130,6 @@
   "desc": "Description",
   "icon": "Icon",
   "led": "LED",
-  "chart": "Chart"
+  "chart": "Chart",
+  "canvas": "Canvas"
 }

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

@@ -130,5 +130,6 @@
   "desc": "描述",
   "icon": "图标",
   "led": "LED",
-  "chart": "图表"
+  "chart": "图表",
+  "canvas": "画布"
 }

+ 271 - 0
src/renderer/src/lvgl-widgets/canvas/Canvas.vue

@@ -0,0 +1,271 @@
+<template>
+  <div :style="boxStyle" class="w-full h-full box-border overflow-hidden relative">
+    <svg
+      class="absolute left-0 top-0 w-full h-full"
+      :viewBox="`0 0 ${width} ${height}`"
+      xmlns="http://www.w3.org/2000/svg"
+    >
+      <!-- 元素渲染 -->
+      <template v-for="(el, index) in elements || []" :key="index">
+        <!-- 矩形 -->
+        <rect
+          v-if="el.type === 'rect'"
+          :x="el.props.x"
+          :y="el.props.y"
+          :width="el.props.width"
+          :height="el.props.height"
+          :fill="el.props.background_color"
+          :stroke="el.props.border?.color"
+          :stroke-width="el.props.border?.width"
+          :rx="el.props.border?.radius"
+          :ry="el.props.border?.radius"
+        />
+
+        <!-- 文本 -->
+        <text
+          v-else-if="el.type === 'text'"
+          :x="el.props.x"
+          :y="el.props.y"
+          :fill="el.props.font_color"
+          :font-size="el.props.font_size"
+          :text-decoration="textDecorMap[el.props.text_decor] || 'none'"
+          dominant-baseline="hanging"
+        >
+          {{ el.props.text }}
+        </text>
+
+        <!-- 圆弧 -->
+        <path
+          v-else-if="el.type === 'arc'"
+          :d="getArcPath(el.props)"
+          :stroke="el.props.color"
+          :stroke-width="el.props.width"
+          fill="none"
+          stroke-linecap="round"
+        />
+
+        <!-- 直线(折线) -->
+        <polyline
+          v-else-if="el.type === 'line'"
+          v-show="(el.props.points || []).length > 1"
+          :points="getLinePoints(el.props.points)"
+          :stroke="el.props.color"
+          :stroke-width="el.props.width"
+          :stroke-linecap="el.props.round ? 'round' : 'butt'"
+          fill="none"
+        />
+
+        <!-- 三角形 -->
+        <polygon
+          v-else-if="el.type === 'triangle'"
+          v-show="(el.props.points || []).length >= 3"
+          :points="getLinePoints((el.props.points || []).slice(0, 3))"
+          :fill="el.props.background_color"
+        />
+      </template>
+    </svg>
+
+    <!-- 图片使用 img 绝对定位到画布上,方便加载本地资源 -->
+    <template v-for="(el, index) in imageElements" :key="`img-${index}`">
+      <img
+        v-if="el.src"
+        class="absolute"
+        :style="{
+          left: `${el.props.x}px`,
+          top: `${el.props.y}px`,
+          width: `${el.props.width}px`,
+          height: `${el.props.height}px`,
+          opacity: el.props.alpha / 255
+        }"
+        :src="el.src"
+      />
+    </template>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useProjectStore } from '@/store/modules/project'
+
+type Point = { x: number; y: number }
+
+type RectProps = {
+  x: number
+  y: number
+  width: number
+  height: number
+  background_color: string
+  border: {
+    color: string
+    width: number
+    radius: number
+  }
+}
+
+type TextProps = {
+  x: number
+  y: number
+  width: number
+  font_color: string
+  font_size: number
+  font_family: string
+  text_decor: string
+  text: string
+}
+
+type ImageProps = {
+  x: number
+  y: number
+  width: number
+  height: number
+  image: string
+  alpha: number
+  recolor: string
+}
+
+type ArcProps = {
+  x: number
+  y: number
+  start_angle: number
+  end_angle: number
+  color: string
+  width: number
+  radius: number
+}
+
+type LineProps = {
+  color: string
+  width: number
+  round: boolean
+  points: Point[]
+}
+
+type TriangleProps = {
+  background_color: string
+  points: Point[]
+}
+
+type CanvasElement =
+  | { type: 'rect'; props: RectProps }
+  | { type: 'text'; props: TextProps }
+  | { type: 'image'; props: ImageProps }
+  | { type: 'arc'; props: ArcProps }
+  | { type: 'line'; props: LineProps }
+  | { type: 'triangle'; props: TriangleProps }
+
+const props = defineProps<{
+  width: number
+  height: number
+  styles: any
+  state?: string
+  part?: string
+  background_color: string
+  elements: CanvasElement[]
+}>()
+
+const projectStore = useProjectStore()
+
+const boxStyle = computed(() => {
+  return {
+    width: `${props.width}px`,
+    height: `${props.height}px`,
+    backgroundColor: props.background_color
+  }
+})
+
+// 文本装饰映射
+const textDecorMap: Record<string, string> = {
+  LV_TEXT_DECOR_NONE: 'none',
+  LV_TEXT_DECOR_UNDERLINE: 'underline',
+  LV_TEXT_DECOR_STRIKETHROUGH: 'line-through',
+  'LV_TEXT_DECOR_UNDERLINE | LV_TEXT_DECOR_STRIKETHROUGH': 'underline line-through'
+}
+
+// 计算图片元素及其本地路径
+const imageElements = computed(() => {
+  return (props.elements || [])
+    .filter((el): el is { type: 'image'; props: ImageProps } => el.type === 'image')
+    .map((el) => {
+      let src = ''
+      const id = el.props.image
+      const project = projectStore.project
+      if (id && project) {
+        const imgRes = project.resources.images.find((img) => img.id === id)
+        if (imgRes) {
+          src = `local:///${(projectStore.projectPath + imgRes.path).replaceAll('\\', '/')}`
+        }
+      }
+      return { ...el, src }
+    })
+})
+
+// 直线/多边形 points 字符串
+const getLinePoints = (points: Point[] = []) => {
+  return points.map((p) => `${p.x},${p.y}`).join(' ')
+}
+
+// 极坐标转直角坐标
+const polarToCartesian = (
+  centerX: number,
+  centerY: number,
+  radius: number,
+  angleInDegrees: number
+) => {
+  const radians = (angleInDegrees * Math.PI) / 180.0
+  return {
+    x: centerX + radius * Math.cos(radians),
+    y: centerY + radius * Math.sin(radians)
+  }
+}
+
+// 生成圆弧路径(始终顺时针)
+const describeArc = (
+  x: number,
+  y: number,
+  radius: number,
+  startAngle: number,
+  endAngle: number
+) => {
+  // 归一化角度到 [0, 360)
+  const s = ((startAngle % 360) + 360) % 360
+  const e = ((endAngle % 360) + 360) % 360
+
+  let clockwiseSpan = (e - s + 360) % 360
+  // 0 或 360 视作整圆,这里用 359.999° 避免 SVG 报错
+  if (clockwiseSpan === 0) clockwiseSpan = 359.999
+
+  const start = polarToCartesian(x, y, radius, s)
+  const end = polarToCartesian(x, y, radius, s + clockwiseSpan)
+
+  const largeArcFlag = clockwiseSpan > 180 ? '1' : '0'
+  const sweepFlag = '1'
+  return [
+    'M',
+    start.x,
+    start.y,
+    'A',
+    radius,
+    radius,
+    0,
+    largeArcFlag,
+    sweepFlag,
+    end.x,
+    end.y
+  ].join(' ')
+}
+
+const getArcPath = (p: ArcProps) => {
+  const cx = Number.isFinite(p.x) ? p.x : 0
+  const cy = Number.isFinite(p.y) ? p.y : 0
+  const radius = Number(p.radius)
+  const start = Number(p.start_angle)
+  const end = Number(p.end_angle)
+
+  if (!Number.isFinite(radius) || radius <= 0) return ''
+  if (!Number.isFinite(start) || !Number.isFinite(end)) return ''
+
+  return describeArc(cx, cy, radius, start, end)
+}
+</script>
+
+<style scoped></style>

+ 723 - 0
src/renderer/src/lvgl-widgets/canvas/Config.vue

@@ -0,0 +1,723 @@
+<template>
+  <div>
+    <el-card class="mb-12px" body-class="pr-0px!">
+      <template #header>
+        <div class="flex items-center justify-between">
+          <span>元素</span>
+          <span class="flex gap-4px">
+            <LuPlus class="cursor-pointer" @click="handleAdd" size="14px" />
+            <LuTrash2 class="cursor-pointer" @click="handleClear" size="14px" />
+          </span>
+        </div>
+      </template>
+      <el-scrollbar height="140px">
+        <div
+          v-for="(item, index) in elements || []"
+          :key="index"
+          class="flex items-center pr-12px"
+          @click="handleEdit(item, index)"
+        >
+          <span class="flex-1 truncate text-#00ff00 cursor-pointer">
+            {{ getElementLabel(item, index) }}
+          </span>
+          <LuTrash2 class="cursor-pointer shrink-0" @click.stop="handleDelete(index)" size="14px" />
+        </div>
+      </el-scrollbar>
+    </el-card>
+
+    <el-dialog v-model="dialogVisible" title="编辑元素" width="520px" draggable>
+      <el-form v-if="formData" :model="formData" label-position="left" label-width="90px">
+        <el-form-item label="类型">
+          <el-select v-model="formData.type">
+            <el-option label="矩形" value="rect" />
+            <el-option label="文本" value="text" />
+            <el-option label="图片" value="image" />
+            <el-option label="圆弧" value="arc" />
+            <el-option label="直线" value="line" />
+            <el-option label="三角形" value="triangle" />
+          </el-select>
+        </el-form-item>
+
+        <!-- Rect -->
+        <template v-if="formData.type === 'rect'">
+          <el-form-item label="位置/大小">
+            <div class="w-full flex gap-4px">
+              <input-number
+                v-model="formData.props.x"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>X</template>
+              </input-number>
+              <input-number
+                v-model="formData.props.y"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>Y</template>
+              </input-number>
+            </div>
+            <div class="w-full flex gap-4px mt-4px">
+              <input-number
+                v-model="formData.props.width"
+                :min="0"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>宽</template>
+              </input-number>
+              <input-number
+                v-model="formData.props.height"
+                :min="0"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>高</template>
+              </input-number>
+            </div>
+          </el-form-item>
+          <el-form-item label="背景颜色">
+            <ColorPicker
+              v-model:pureColor="formData.props.background_color"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.props.background_color }}</span>
+          </el-form-item>
+          <el-form-item label="边框颜色">
+            <ColorPicker
+              v-model:pureColor="formData.props.border.color"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.props.border.color }}</span>
+          </el-form-item>
+          <el-form-item label="边框宽度">
+            <input-number
+              v-model="formData.props.border.width"
+              :min="0"
+              :max="10000"
+              style="width: 100%"
+              controls-position="right"
+            />
+          </el-form-item>
+          <el-form-item label="圆角">
+            <input-number
+              v-model="formData.props.border.radius"
+              :min="0"
+              :max="10000"
+              style="width: 100%"
+              controls-position="right"
+            />
+          </el-form-item>
+        </template>
+
+        <!-- Text -->
+        <template v-else-if="formData.type === 'text'">
+          <el-form-item label="位置">
+            <div class="w-full flex gap-4px">
+              <input-number
+                v-model="formData.props.x"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>X</template>
+              </input-number>
+              <input-number
+                v-model="formData.props.y"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>Y</template>
+              </input-number>
+            </div>
+          </el-form-item>
+          <el-form-item label="宽度">
+            <input-number
+              v-model="formData.props.width"
+              :min="1"
+              :max="1000"
+              style="width: 100%"
+              controls-position="right"
+            />
+          </el-form-item>
+          <el-form-item label="字体颜色">
+            <ColorPicker
+              v-model:pureColor="formData.props.font_color"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.props.font_color }}</span>
+          </el-form-item>
+          <el-form-item label="字体样式">
+            <el-select-v2
+              v-model="formData.props.font_family"
+              placeholder="请选择"
+              :options="fontOptions"
+            />
+          </el-form-item>
+          <el-form-item label="字体大小">
+            <input-number
+              v-model="formData.props.font_size"
+              :min="1"
+              :max="1000"
+              style="width: 100%"
+              controls-position="right"
+            />
+          </el-form-item>
+          <el-form-item label="">
+            <el-radio-group v-model="formData.props.text_decor">
+              <el-radio-button :value="LineEnum.LV_TEXT_DECOR_NONE">
+                <el-tooltip content="none">
+                  <MdNotInterested size="14px" />
+                </el-tooltip>
+              </el-radio-button>
+              <el-radio-button :value="LineEnum.LV_TEXT_DECOR_UNDERLINE">
+                <el-tooltip content="underline">
+                  <RxUnderline size="14px" />
+                </el-tooltip>
+              </el-radio-button>
+              <el-radio-button :value="LineEnum.LV_TEXT_DECOR_STRIKETHROUGH">
+                <el-tooltip content="strikethrough">
+                  <RxStrikethrough size="14px" />
+                </el-tooltip>
+              </el-radio-button>
+              <el-radio-button
+                :value="LineEnum['LV_TEXT_DECOR_UNDERLINE | LV_TEXT_DECOR_STRIKETHROUGH']"
+              >
+                <el-tooltip content="underline & strikethrough">
+                  <GoStrikethrough size="14px" />
+                </el-tooltip>
+              </el-radio-button>
+            </el-radio-group>
+          </el-form-item>
+          <el-form-item label="文本">
+            <el-input
+              spellcheck="false"
+              type="textarea"
+              v-model="formData.props.text"
+              placeholder="请输入文本"
+            />
+          </el-form-item>
+        </template>
+
+        <!-- Image -->
+        <template v-else-if="formData.type === 'image'">
+          <el-form-item label="位置/大小">
+            <div class="w-full flex gap-4px">
+              <input-number
+                v-model="formData.props.x"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>X</template>
+              </input-number>
+              <input-number
+                v-model="formData.props.y"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>Y</template>
+              </input-number>
+            </div>
+            <div class="w-full flex gap-4px mt-4px">
+              <input-number
+                v-model="formData.props.width"
+                :min="0"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>宽</template>
+              </input-number>
+              <input-number
+                v-model="formData.props.height"
+                :min="0"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>高</template>
+              </input-number>
+            </div>
+          </el-form-item>
+          <el-form-item label="图片">
+            <ImageSelect v-model="formData.props.image" />
+          </el-form-item>
+          <el-form-item label="透明度">
+            <div class="w-full flex items-center gap-16px">
+              <el-slider v-model="formData.props.alpha" :min="0" :max="255" style="flex: 1" />
+              <span class="text-text-active w-40px text-right">
+                {{ formData.props.alpha }}
+              </span>
+            </div>
+          </el-form-item>
+          <el-form-item label="遮罩颜色">
+            <ColorPicker
+              v-model:pureColor="formData.props.recolor"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.props.recolor }}</span>
+          </el-form-item>
+        </template>
+
+        <!-- Arc -->
+        <template v-else-if="formData.type === 'arc'">
+          <el-form-item label="位置">
+            <div class="w-full flex gap-4px">
+              <input-number
+                v-model="formData.props.x"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>X</template>
+              </input-number>
+              <input-number
+                v-model="formData.props.y"
+                :min="-10000"
+                :max="10000"
+                controls-position="right"
+              >
+                <template #prefix>Y</template>
+              </input-number>
+            </div>
+          </el-form-item>
+          <el-form-item label="角度">
+            <div class="w-full flex gap-4px">
+              <input-number
+                v-model="formData.props.start_angle"
+                :min="0"
+                :max="360"
+                controls-position="right"
+              >
+                <template #prefix>开始</template>
+              </input-number>
+              <input-number
+                v-model="formData.props.end_angle"
+                :min="0"
+                :max="360"
+                controls-position="right"
+              >
+                <template #prefix>结束</template>
+              </input-number>
+            </div>
+          </el-form-item>
+          <el-form-item label="颜色">
+            <ColorPicker
+              v-model:pureColor="formData.props.color"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.props.color }}</span>
+          </el-form-item>
+          <el-form-item label="宽度">
+            <input-number
+              v-model="formData.props.width"
+              :min="0"
+              :max="10000"
+              style="width: 100%"
+              controls-position="right"
+            />
+          </el-form-item>
+          <el-form-item label="半径">
+            <input-number
+              v-model="formData.props.radius"
+              :min="0"
+              :max="10000"
+              style="width: 100%"
+              controls-position="right"
+            />
+          </el-form-item>
+        </template>
+
+        <!-- Line -->
+        <template v-else-if="formData.type === 'line'">
+          <el-form-item label="颜色">
+            <ColorPicker
+              v-model:pureColor="formData.props.color"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.props.color }}</span>
+          </el-form-item>
+          <el-form-item label="宽度">
+            <input-number
+              v-model="formData.props.width"
+              :min="0"
+              :max="10000"
+              style="width: 100%"
+              controls-position="right"
+            />
+          </el-form-item>
+          <el-form-item label="圆角">
+            <el-switch v-model="formData.props.round" />
+          </el-form-item>
+          <el-card class="mt-8px" body-class="p-8px!">
+            <template #header>
+              <div class="flex items-center justify-between">
+                <span>点</span>
+                <LuPlus class="cursor-pointer" size="16px" @click="handleAddPoint('line')" />
+              </div>
+            </template>
+            <el-scrollbar max-height="120px">
+              <div
+                class="flex items-center gap-4px box-border pr-12px mb-4px"
+                v-for="(p, idx) in formData.props.points || []"
+                :key="idx"
+              >
+                <div class="w-full flex items-center gap-4px relative">
+                  <input-number
+                    controls-position="right"
+                    :model-value="p.x"
+                    :min="0"
+                    :max="10000"
+                    @change="(val) => setPoint('line', idx, 'x', val)"
+                  >
+                    <template #prefix>X</template>
+                  </input-number>
+                  <input-number
+                    controls-position="right"
+                    :model-value="p.y"
+                    :min="0"
+                    :max="10000"
+                    @change="(val) => setPoint('line', idx, 'y', val)"
+                  >
+                    <template #prefix>Y</template>
+                  </input-number>
+                  <div class="cursor-pointer" @click="handleDeletePoint('line', idx)">
+                    <LuTrash2 size="14px" />
+                  </div>
+                </div>
+              </div>
+            </el-scrollbar>
+          </el-card>
+        </template>
+
+        <!-- Triangle -->
+        <template v-else-if="formData.type === 'triangle'">
+          <el-form-item label="背景颜色">
+            <ColorPicker
+              v-model:pureColor="formData.props.background_color"
+              format="hex8"
+              picker-type="chrome"
+              use-type="pure"
+            />
+            <span class="text-text-active">{{ formData.props.background_color }}</span>
+          </el-form-item>
+          <el-card class="mt-8px" body-class="p-8px!">
+            <template #header>
+              <div class="flex items-center justify-between">
+                <span>点</span>
+                <LuPlus class="cursor-pointer" size="16px" @click="handleAddPoint('triangle')" />
+              </div>
+            </template>
+            <el-scrollbar max-height="120px">
+              <div
+                class="flex items-center gap-4px box-border pr-12px mb-4px"
+                v-for="(p, idx) in formData.props.points || []"
+                :key="idx"
+              >
+                <div class="w-full flex items-center gap-4px relative">
+                  <input-number
+                    controls-position="right"
+                    :model-value="p.x"
+                    :min="0"
+                    :max="10000"
+                    @change="(val) => setPoint('triangle', idx, 'x', val)"
+                  >
+                    <template #prefix>X</template>
+                  </input-number>
+                  <input-number
+                    controls-position="right"
+                    :model-value="p.y"
+                    :min="0"
+                    :max="10000"
+                    @change="(val) => setPoint('triangle', idx, 'y', val)"
+                  >
+                    <template #prefix>Y</template>
+                  </input-number>
+                  <div class="cursor-pointer" @click="handleDeletePoint('triangle', idx)">
+                    <LuTrash2 size="14px" />
+                  </div>
+                </div>
+              </div>
+            </el-scrollbar>
+          </el-card>
+        </template>
+      </el-form>
+
+      <template #footer>
+        <el-button type="primary" @click="dialogVisible = false">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { Ref } from 'vue'
+import { computed, ref, watch } from 'vue'
+import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+import { ColorPicker } from '@/components'
+import { LineEnum } from '../span-group/data'
+import { MdNotInterested } from 'vue-icons-plus/md'
+import { RxUnderline, RxStrikethrough } from 'vue-icons-plus/rx'
+import { GoStrikethrough } from 'vue-icons-plus/go'
+import ImageSelect from '@/views/designer/config/property/components/ImageSelect.vue'
+import { useProjectStore } from '@/store/modules/project'
+
+type CanvasElementType = 'rect' | 'text' | 'image' | 'arc' | 'line' | 'triangle'
+
+type Point = { x: number; y: number }
+
+type RectProps = {
+  x: number
+  y: number
+  width: number
+  height: number
+  background_color: string
+  border: {
+    color: string
+    width: number
+    radius: number
+  }
+}
+
+type TextProps = {
+  x: number
+  y: number
+  width: number
+  font_color: string
+  font_size: number
+  font_family: string
+  text_decor: LineEnum
+  text: string
+}
+
+type ImageProps = {
+  x: number
+  y: number
+  width: number
+  height: number
+  image: string
+  alpha: number
+  recolor: string
+}
+
+type ArcProps = {
+  x: number
+  y: number
+  start_angle: number
+  end_angle: number
+  color: string
+  width: number
+  radius: number
+}
+
+type LineProps = {
+  color: string
+  width: number
+  round: boolean
+  points: Point[]
+}
+
+type TriangleProps = {
+  background_color: string
+  points: Point[]
+}
+
+type CanvasElement =
+  | { type: 'rect'; props: RectProps }
+  | { type: 'text'; props: TextProps }
+  | { type: 'image'; props: ImageProps }
+  | { type: 'arc'; props: ArcProps }
+  | { type: 'line'; props: LineProps }
+  | { type: 'triangle'; props: TriangleProps }
+
+const props = defineProps<{
+  values: Ref<CanvasElement[]>
+}>()
+
+const dialogVisible = ref(false)
+const formData = ref<CanvasElement>()
+const editingIndex = ref<number | null>(null)
+
+const elements = computed({
+  get() {
+    return (props.values?.value || []) as CanvasElement[]
+  },
+  set(list: CanvasElement[]) {
+    props.values.value = list
+  }
+})
+
+const projectStore = useProjectStore()
+const fontOptions = computed(() => {
+  const list = (projectStore.project?.resources.fonts || []).map((font) => {
+    return {
+      label: font.fileName,
+      value: font.id
+    }
+  })
+  return [{ label: '默认字体', value: 'xx' }, ...list]
+})
+
+const getElementLabel = (el: CanvasElement, index: number) => {
+  const typeLabelMap: Record<CanvasElementType, string> = {
+    rect: 'Rect',
+    text: 'Text',
+    image: 'Image',
+    arc: 'Arc',
+    line: 'Line',
+    triangle: 'Triangle'
+  }
+  return `${typeLabelMap[el.type] || 'Element'}_${index}`
+}
+
+const createDefaultElement = (type: CanvasElementType): CanvasElement => {
+  if (type === 'rect') {
+    return {
+      type: 'rect',
+      props: {
+        x: 100,
+        y: 75,
+        width: 100,
+        height: 50,
+        background_color: '#2196f3ff',
+        border: {
+          color: '#000000ff',
+          width: 0,
+          radius: 0
+        }
+      }
+    }
+  }
+  if (type === 'text') {
+    return {
+      type: 'text',
+      props: {
+        x: 100,
+        y: 75,
+        width: 100,
+        font_color: '#000000ff',
+        font_size: 16,
+        font_family: 'xx',
+        text_decor: LineEnum.LV_TEXT_DECOR_NONE,
+        text: ''
+      }
+    }
+  }
+  if (type === 'image') {
+    return {
+      type: 'image',
+      props: {
+        x: 100,
+        y: 75,
+        width: 100,
+        height: 75,
+        image: '',
+        alpha: 255,
+        recolor: '#ffffff00'
+      }
+    }
+  }
+  if (type === 'arc') {
+    return {
+      type: 'arc',
+      props: {
+        x: 100,
+        y: 80,
+        start_angle: 0,
+        end_angle: 270,
+        color: '#000000ff',
+        width: 6,
+        radius: 30
+      }
+    }
+  }
+  if (type === 'line') {
+    return {
+      type: 'line',
+      props: {
+        color: '#000000ff',
+        width: 2,
+        round: true,
+        points: []
+      }
+    }
+  }
+  return {
+    type: 'triangle',
+    props: {
+      background_color: '#2196f3ff',
+      points: []
+    }
+  }
+}
+
+const handleAdd = () => {
+  elements.value.push(createDefaultElement('text'))
+}
+
+const handleDelete = (index: number) => {
+  elements.value.splice(index, 1)
+}
+
+const handleClear = () => {
+  elements.value = []
+}
+
+const handleEdit = (record: CanvasElement, index: number) => {
+  editingIndex.value = index
+  formData.value = record
+  dialogVisible.value = true
+}
+
+// 切换元素类型时,按类型重置该元素的数据
+watch(
+  () => formData.value?.type,
+  (newType, oldType) => {
+    if (!newType || !oldType || newType === oldType) return
+    const idx = editingIndex.value
+    if (idx === null) return
+    const next = createDefaultElement(newType as CanvasElementType)
+    elements.value[idx] = next
+    formData.value = elements.value[idx]
+  }
+)
+
+const handleAddPoint = (shape: 'line' | 'triangle') => {
+  if (!formData.value) return
+  const points = (formData.value.props as any).points as Point[]
+  if (shape === 'line' && points.length >= 2) return
+  if (shape === 'triangle' && points.length >= 3) return
+  points.push({ x: 0, y: 0 })
+}
+
+const handleDeletePoint = (_shape: 'line' | 'triangle', index: number) => {
+  if (!formData.value) return
+  const points = (formData.value.props as any).points as Point[]
+  points.splice(index, 1)
+}
+
+const setPoint = (_shape: 'line' | 'triangle', index: number, key: 'x' | 'y', val?: number) => {
+  if (typeof val === 'undefined' || !formData.value) return
+  const points = (formData.value.props as any).points as Point[]
+  const p = points[index]
+  if (!p) return
+  p[key] = val
+}
+</script>
+
+<style scoped></style>

+ 165 - 0
src/renderer/src/lvgl-widgets/canvas/index.tsx

@@ -0,0 +1,165 @@
+import Canvas from './Canvas.vue'
+import icon from '../assets/icon/icon_25canvas.svg'
+import { flagOptions, stateOptions } from '@/constants'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import Config from './Config.vue'
+
+export default {
+  label: i18n.global.t('canvas'),
+  icon,
+  component: Canvas,
+  key: 'lv_canvas',
+  group: i18n.global.t('display'),
+  sort: 1,
+  hasChildren: false,
+  parts: [],
+  defaultSchema: {
+    name: 'canvas',
+    props: {
+      x: 0,
+      y: 0,
+      width: 300,
+      height: 200,
+      flags: [],
+      states: [],
+      scrollbar: 'off',
+      background_color: '#f5f5f5ff',
+      elements: [
+        {
+          type: 'rect',
+          props: {
+            x: 100,
+            y: 75,
+            width: 100,
+            height: 50,
+            background_color: '#2196f3ff',
+            border: {
+              color: '#000000ff',
+              width: 0,
+              radius: 0
+            }
+          }
+        }
+      ]
+    },
+    styles: []
+  },
+  config: {
+    // 组件属性
+    props: [
+      {
+        label: '名称',
+        field: 'name',
+        valueType: 'text',
+        componentProps: {
+          placeholder: '请输入名称',
+          type: 'text'
+        }
+      },
+      {
+        label: '位置/大小',
+        valueType: 'group',
+        children: [
+          {
+            label: '',
+            field: 'props.x',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'X' }
+          },
+          {
+            label: '',
+            field: 'props.y',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: -10000,
+              max: 10000
+            },
+            slots: { prefix: 'Y' }
+          },
+          {
+            label: '',
+            field: 'props.width',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'W' }
+          },
+          {
+            label: '',
+            field: 'props.height',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'H' }
+          }
+        ]
+      },
+      {
+        label: '标识',
+        field: 'props.flags',
+        valueType: 'checkbox',
+        componentProps: {
+          options: flagOptions,
+          defaultCollapsed: true
+        }
+      },
+      {
+        label: '滚动条',
+        field: 'props.scrollbar',
+        valueType: 'select',
+        componentProps: {
+          options: [
+            { label: 'On', value: 'on' },
+            { label: 'Off', value: 'off' },
+            { label: 'Auto', value: 'auto' },
+            { label: 'Active', value: 'active' }
+          ]
+        }
+      },
+      {
+        label: '状态',
+        field: 'props.states',
+        valueType: 'checkbox',
+        componentProps: {
+          options: stateOptions,
+          defaultCollapsed: true
+        }
+      }
+    ],
+    coreProps: [
+      {
+        label: '背景色',
+        field: 'props.background_color',
+        valueType: 'color',
+        componentProps: {
+          format: 'hex8',
+          pickerType: 'chrome',
+          useType: 'pure'
+        }
+      },
+      {
+        label: '元素',
+        field: 'props.elements',
+        valueType: '',
+        render: (val) => {
+          return <Config values={val} />
+        }
+      }
+    ],
+    // 组件样式
+    styles: []
+  }
+} as IComponentModelConfig

+ 5 - 0
src/renderer/src/lvgl-widgets/canvas/style.json

@@ -0,0 +1,5 @@
+{
+  "widget": "lv_canvas",
+  "styleName": "defualt",
+  "part": []
+}

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

@@ -25,6 +25,7 @@ import Arc from './arc'
 import Scale from './scale/index'
 import Led from './led/index'
 import Chart from './chart/index'
+import Canvas from './canvas/index'
 
 import Page from './page'
 import { IComponentModelConfig } from './type'
@@ -58,7 +59,8 @@ export const ComponentArray = [
   Arc,
   Scale,
   Led,
-  Chart
+  Chart,
+  Canvas
 ]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

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

@@ -91,7 +91,6 @@
       <div class="flex" v-if="schema.valueType === 'color'">
         <ColorPicker
           v-model:pureColor="value"
-          v-model:gradientColor="value"
           use-type="pure"
           picker-type="chrome"
           format="hex8"