jiaxing.liao 1 неделя назад
Родитель
Сommit
51cc61d782

+ 1 - 0
package.json

@@ -23,6 +23,7 @@
   "dependencies": {
     "@electron-toolkit/preload": "^3.0.2",
     "@electron-toolkit/utils": "^4.0.0",
+    "@lottiefiles/dotlottie-vue": "^0.9.5",
     "@types/fs-extra": "^11.0.4",
     "@vueuse/components": "^14.0.0",
     "@vueuse/core": "^14.0.0",

+ 18 - 0
pnpm-lock.yaml

@@ -14,6 +14,9 @@ importers:
       '@electron-toolkit/utils':
         specifier: ^4.0.0
         version: 4.0.0(electron@38.2.2)
+      '@lottiefiles/dotlottie-vue':
+        specifier: ^0.9.5
+        version: 0.9.7(vue@3.5.22(typescript@5.9.3))
       '@types/fs-extra':
         specifier: ^11.0.4
         version: 11.0.4
@@ -693,6 +696,14 @@ packages:
   '@jridgewell/trace-mapping@0.3.31':
     resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
 
+  '@lottiefiles/dotlottie-vue@0.9.7':
+    resolution: {integrity: sha512-wqZLFYEjhL8w0CEcuBMksIYgxW5nmG9MXAipFrhN5TTHgmjDzobVIJ5vGyMw+si+QmJkO06VgvVScMYQI1PRjw==}
+    peerDependencies:
+      vue: ^3.3.4
+
+  '@lottiefiles/dotlottie-web@0.52.1':
+    resolution: {integrity: sha512-R44ZKjdT9LPGMu6GXfdRFu1327quUkkdIBOiPsOU6vRpROotYmJYMhBtjoJWRPbkIXo3Qge0gyU4UMpELQje5A==}
+
   '@malept/cross-spawn-promise@2.0.0':
     resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==}
     engines: {node: '>= 12.13.0'}
@@ -4337,6 +4348,13 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.2
       '@jridgewell/sourcemap-codec': 1.5.5
 
+  '@lottiefiles/dotlottie-vue@0.9.7(vue@3.5.22(typescript@5.9.3))':
+    dependencies:
+      '@lottiefiles/dotlottie-web': 0.52.1
+      vue: 3.5.22(typescript@5.9.3)
+
+  '@lottiefiles/dotlottie-web@0.52.1': {}
+
   '@malept/cross-spawn-promise@2.0.0':
     dependencies:
       cross-spawn: 7.0.6

+ 1 - 1
src/renderer/index.html

@@ -6,7 +6,7 @@
     <!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
     <meta
       http-equiv="Content-Security-Policy"
-      content="default-src 'self' data: local: http: https: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: http: https: blob:;"
+      content="default-src 'self' data: local: http: https: blob:; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: local: http: https: blob:;"
     />
     <style>
       body {

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

@@ -143,6 +143,8 @@
   "date": "Date",
   "dagitalClock": "DagitalClock",
   "analogClock": "AnalogClock",
+  "lottie": "Lottie",
   "video": "Video",
-  "media": "Media"
+  "media": "Media",
+  "advance": "Advance"
 }

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

@@ -144,5 +144,6 @@
   "dagitalClock": "数字时钟",
   "analogClock": "模拟时钟",
   "video": "视频",
-  "media": "多媒体"
+  "media": "多媒体",
+  "advance": "高级"
 }

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

@@ -37,6 +37,7 @@ import DateText from './datetext/index'
 import DagitalClock from './dagital-clock/index'
 import AnalogClock from './analog-clock/index'
 
+import Lottie from './lottie'
 import Video from './video/index'
 
 import Page from './page'
@@ -85,7 +86,9 @@ export const ComponentArray = [
   DagitalClock,
   AnalogClock,
 
-  Video
+  Video,
+
+  Lottie
 ] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

+ 134 - 0
src/renderer/src/lvgl-widgets/lottie/Lottie.vue

@@ -0,0 +1,134 @@
+<template>
+  <div :style="styleMap?.mainStyle" class="w-full h-full overflow-hidden relative box-border">
+    <div class="lottie-preview">
+      <DotLottieVue
+        v-if="animationData"
+        :key="playerKey"
+        class="lottie-preview__player"
+        autoplay
+        loop
+        :data="animationData"
+        :autoResizeCanvas="true"
+        :renderConfig="renderConfig"
+        :layout="layout"
+      />
+      <div v-else class="lottie-preview__empty">
+        <img :src="icon" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+import { DotLottieVue } from '@lottiefiles/dotlottie-vue'
+import { useProjectStore } from '@/store/modules/project'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import { getFileByPath } from '@/utils'
+import icon from '../assets/icon/icon_39lottie.svg'
+
+const props = defineProps<{
+  width: number
+  height: number
+  lottie: string
+  styles: any
+  part?: string
+  state?: string
+}>()
+
+const projectStore = useProjectStore()
+const animationData = ref('')
+const layout = {
+  fit: 'fill' as const,
+  align: [0.5, 0.5] as [number, number]
+}
+
+const styleMap = useWidgetStyle({
+  widget: 'lv_lottie',
+  props
+})
+
+const lottieMeta = ref<{
+  width?: number
+  height?: number
+  frameRate?: number
+  frames?: number
+  valid: boolean
+}>({
+  valid: false
+})
+
+const resource = computed(() => {
+  return projectStore.project?.resources.others.find((item) => item.id === props.lottie)
+})
+
+const playerKey = computed(() => `${props.lottie}-${props.width}-${props.height}`)
+
+const renderConfig = computed(() => {
+  const dpr =
+    typeof window !== 'undefined' ? Math.min(4, Math.max(window.devicePixelRatio || 1, 2)) : 2
+
+  return {
+    devicePixelRatio: dpr
+  }
+})
+
+watch(
+  () => [props.lottie, projectStore.projectPath],
+  async () => {
+    lottieMeta.value = { valid: false }
+    animationData.value = ''
+
+    if (!props.lottie || !resource.value?.path || !projectStore.projectPath) {
+      return
+    }
+
+    try {
+      const content = await getFileByPath(projectStore.projectPath + resource.value.path, 'utf-8')
+      if (!content) return
+
+      animationData.value = content
+      const json = JSON.parse(content)
+      const frameStart = Number(json?.ip)
+      const frameEnd = Number(json?.op)
+      lottieMeta.value = {
+        width: Number(json?.w) || undefined,
+        height: Number(json?.h) || undefined,
+        frameRate: Number(json?.fr) || undefined,
+        frames:
+          Number.isFinite(frameStart) && Number.isFinite(frameEnd)
+            ? Math.max(0, Math.round(frameEnd - frameStart))
+            : undefined,
+        valid: true
+      }
+    } catch {
+      lottieMeta.value = { valid: false }
+    }
+  },
+  {
+    immediate: true
+  }
+)
+</script>
+
+<style scoped>
+.lottie-preview {
+  width: 100%;
+  height: 100%;
+}
+
+.lottie-preview__player,
+.lottie-preview__empty {
+  width: 100%;
+  height: 100%;
+}
+
+.lottie-preview__player {
+  display: block;
+}
+
+.lottie-preview__empty {
+  display: grid;
+  place-items: center;
+}
+</style>

+ 139 - 0
src/renderer/src/lvgl-widgets/lottie/index.ts

@@ -0,0 +1,139 @@
+import Lottie from './Lottie.vue'
+import icon from '../assets/icon/icon_39lottie.svg'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import { stateList } from '@/constants'
+import defaultStyle from './style.json'
+import { h } from 'vue'
+import { BsFiletypeJson } from 'vue-icons-plus/bs'
+
+export default {
+  label: 'Lottie',
+  icon,
+  component: Lottie,
+  key: 'lv_lottie',
+  group: i18n.global.t('advance'),
+  sort: 2,
+  hasChildren: false,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'lottie',
+    props: {
+      x: 0,
+      y: 0,
+      width: 100,
+      height: 100,
+      lottie: ''
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#ffffff00'
+        }
+      }
+    ]
+  },
+  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' }
+          }
+        ]
+      }
+    ],
+    coreProps: [
+      {
+        label: 'lottie',
+        field: 'props.lottie',
+        labelWidth: '80px',
+        valueType: 'file',
+        labelPosition: 'top',
+        componentProps: {
+          extensionNames: ['json', 'JSON'],
+          appendIcon: h('img', { src: icon, style: { width: '24px', height: '24px' } }),
+          icon: BsFiletypeJson
+        }
+      }
+    ],
+    styles: [
+      {
+        label: '模块/状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        label: '背景',
+        field: 'background',
+        valueType: 'background',
+        componentProps: {
+          useType: 'pure',
+          onlyColor: true
+        }
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 15 - 0
src/renderer/src/lvgl-widgets/lottie/style.json

@@ -0,0 +1,15 @@
+{
+  "widget": "lv_lottie",
+  "styleName": "defualt",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff00"
+        }
+      },
+      "state": []
+    }
+  ]
+}

+ 4 - 1
src/renderer/src/lvgl-widgets/video/index.tsx

@@ -157,7 +157,10 @@ export default {
                   label: '视频文件',
                   field: 'props.source.file.id',
                   labelWidth: '80px',
-                  valueType: 'video'
+                  valueType: 'file',
+                  componentProps: {
+                    extensionNames: ['mp4']
+                  }
                 },
                 {
                   label: 'SD路径',

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

@@ -80,9 +80,9 @@
         v-model="value"
         v-bind="schema?.componentProps"
       />
-      <!-- 视频选择 -->
-      <VideoSelect
-        v-if="schema.valueType === 'video'"
+      <!-- 文件选择 -->
+      <FileSelect
+        v-if="schema.valueType === 'file'"
         v-model="value"
         v-bind="schema?.componentProps"
       />
@@ -282,7 +282,7 @@ import CusTimePicker from './components/CusTimePicker.vue'
 import ImageSelect from './components/ImageSelect.vue'
 import SymbolSelect from './components/SymbolSelect.vue'
 import { ColorPicker, MonacoEditor } from '@/components'
-import VideoSelect from './components/VideoSelect.vue'
+import FileSelect from './components/FileSelect.vue'
 
 import StyleBackground from './components/StyleBackground.vue'
 import StyleBorder from './components/StyleBorder.vue'

+ 29 - 7
src/renderer/src/views/designer/config/property/components/VideoSelect.vue

@@ -3,21 +3,21 @@
     <div class="flex h-320px overflow-y-auto gap-8px flex-wrap">
       <div
         class="w-100px h-100px border-solid border-border cursor-pointer flex flex-col"
-        v-for="item in videoList"
+        v-for="item in fileList"
         :key="item.id"
         :class="item.id === selected ? 'border-accent-blue!' : ''"
         @click="handleClick(item.id)"
         :title="item.fileName"
       >
         <div class="w-100px h-70px flex items-center justify-center">
-          <GrDocumentVideo size="45px" />
+          <component :is="icon || GrDocumentVideo" size="45px" />
         </div>
 
         <div class="h-20px leading-30px text-12px text-text-secondary px-2px truncate">
           {{ item.fileName }}
         </div>
       </div>
-      <el-empty class="mx-auto" v-if="!videoList.length" description="暂无资源" />
+      <el-empty class="mx-auto" v-if="!fileList.length" description="暂无资源" />
     </div>
     <template #footer>
       <el-button @click="handleCancel">取消</el-button>
@@ -30,7 +30,12 @@
       <LuX v-if="selectedFile" class="cursor-pointer" size="16px" @click="handleClear" />
     </template>
     <template #append>
-      <GrDocumentVideo size="20px" class="cursor-pointer" @click="visible = true" />
+      <component
+        :is="appendIcon || GrDocumentVideo"
+        size="20px"
+        class="cursor-pointer"
+        @click="visible = true"
+      />
     </template>
   </el-input>
 </template>
@@ -40,18 +45,35 @@ import { computed, ref } from 'vue'
 import { useProjectStore } from '@/store/modules/project'
 import { LuX } from 'vue-icons-plus/lu'
 import { GrDocumentVideo } from 'vue-icons-plus/gr'
+import { IconType } from 'vue-icons-plus'
+
+type FileSelectProps = {
+  // 扩展名列表
+  extensionNames: string[]
+  // input插槽图标
+  appendIcon?: IconType
+  // 选择插槽图标
+  icon?: IconType
+}
 
 const modelValue = defineModel('modelValue')
+
+const props = defineProps<FileSelectProps>()
+
 const projectStore = useProjectStore()
 const visible = ref(false)
 const selected = ref(modelValue.value)
 // 资源列表
-const videoList = computed(() => {
-  return (projectStore.project?.resources.others || []).filter((item) => item.fileType === 'mp4')
+const fileList = computed(() => {
+  // 文件扩展名:['mp4']
+  const extensionNames = (props.extensionNames || []).map((item) => item.toLowerCase())
+  return (projectStore.project?.resources.others || []).filter((item) =>
+    extensionNames.includes((item.fileType || '').toLowerCase())
+  )
 })
 // 选中资源
 const selectedFile = computed(() => {
-  return videoList.value.find((item) => item.id === modelValue.value)
+  return fileList.value.find((item) => item.id === modelValue.value)
 })
 
 const handleClick = (val: string) => {

+ 1 - 1
src/renderer/src/views/designer/sidebar/Resource.vue

@@ -182,7 +182,7 @@ const handleAddOther = async () => {
     )
     // 记录文件
     projectStore.project?.resources.others.push(
-      createFileResource(`\\src\\assets\\images\\${fileName}`, 'other') as OtherResource
+      createFileResource(`\\src\\assets\\others\\${fileName}`, 'other') as OtherResource
     )
   })
 }

+ 4 - 1
src/renderer/src/views/designer/sidebar/components/ResourceItem.vue

@@ -21,7 +21,10 @@
     </span>
     <span class="flex-1 flex items-center gap-4px">
       <span class="truncate max-w-120px" :title="data.fileName">{{ data.fileName }}</span>
-      <span class="flex flex-col gap-2px w-80px text-8px text-text-secondary">
+      <span
+        v-if="type === 'image'"
+        class="flex flex-col gap-2px w-80px text-8px text-text-secondary"
+      >
         <span>分辨率:{{ imageInfo.width }}x{{ imageInfo.height }}</span>
         <span>使用次数:{{ imageInfo.useCount }}</span>
       </span>