소스 검색

feat: 添加条形码控件

jiaxing.liao 1 개월 전
부모
커밋
f09d9636ae

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

@@ -147,5 +147,6 @@
   "video": "Video",
   "media": "Media",
   "advance": "Advance",
-  "qrcode": "QRCode"
+  "qrcode": "QRCode",
+  "barcode": "Barcode"
 }

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

@@ -146,5 +146,6 @@
   "video": "视频",
   "media": "多媒体",
   "advance": "高级",
-  "qrcode": "二维码"
+  "qrcode": "二维码",
+  "barcode": "条形码"
 }

+ 136 - 0
src/renderer/src/lvgl-widgets/barcode/Barcode.vue

@@ -0,0 +1,136 @@
+<template>
+  <div
+    :style="boxStyle"
+    class="relative overflow-hidden box-border flex items-center justify-center"
+  >
+    <canvas ref="barcodeCanvas" class="w-full h-full block"></canvas>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch, onMounted } from 'vue'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import { encodeCode128B } from './code128'
+import { useProjectStore } from '@/store/modules/project'
+
+const DEFAULT_TEXT = 'https://www.sv-elec.com/'
+const DEFAULT_SCALE = 1
+const BASE_THICKNESS = 60
+
+const props = defineProps<{
+  id?: string
+  width: number
+  height: number
+  styles: any
+  state?: string
+  part?: string
+  lightColor?: string
+  darkColor?: string
+  scale?: number
+  direction?: 'horizontal' | 'vertical'
+  text?: string
+}>()
+
+const barcodeCanvas = ref<HTMLCanvasElement | null>(null)
+const projectStore = useProjectStore()
+
+const styleMap = useWidgetStyle({
+  widget: 'barcode',
+  props
+})
+
+const boxStyle = computed(() => {
+  return {
+    width: `${props.width}px`,
+    height: `${props.height}px`,
+    ...(styleMap.value?.mainStyle || {})
+  }
+})
+
+const getAutoWidth = () => {
+  const pattern =
+    encodeCode128B((props.text || DEFAULT_TEXT).trim() || DEFAULT_TEXT) ||
+    encodeCode128B(DEFAULT_TEXT)
+  const totalUnits = pattern?.reduce((sum, unit) => sum + unit, 0) || 1
+  const scale = Math.max(1, Math.min(5, Math.round(Number(props.scale) || DEFAULT_SCALE)))
+
+  return props.direction === 'vertical' ? BASE_THICKNESS * scale : totalUnits * scale
+}
+
+const syncWidgetWidth = () => {
+  if (!props.id) return
+
+  const widget = projectStore.getWidgetById(props.id)
+  if (!widget?.props) return
+
+  const nextWidth = getAutoWidth()
+  if (widget.props.width !== nextWidth) {
+    widget.props.width = nextWidth
+  }
+}
+
+const renderBarcode = () => {
+  if (!barcodeCanvas.value) return
+
+  const canvas = barcodeCanvas.value
+  const context = canvas.getContext('2d')
+  if (!context) return
+
+  const width = Math.max(1, props.width || 1)
+  const height = Math.max(1, props.height || 1)
+
+  canvas.width = width
+  canvas.height = height
+
+  context.clearRect(0, 0, width, height)
+  context.fillStyle = props.lightColor || '#ffffff'
+  context.fillRect(0, 0, width, height)
+
+  const pattern = encodeCode128B((props.text || DEFAULT_TEXT).trim() || DEFAULT_TEXT)
+  if (!pattern?.length) {
+    console.error('Failed to render barcode: unsupported content')
+    return
+  }
+
+  const totalUnits = pattern.reduce((sum, unit) => sum + unit, 0)
+  const isVertical = props.direction === 'vertical'
+  const moduleSize = (isVertical ? height : width) / totalUnits
+
+  let offset = 0
+  let isBar = true
+
+  context.fillStyle = props.darkColor || '#000000'
+  pattern.forEach((unit) => {
+    const segmentSize = unit * moduleSize
+    if (isBar) {
+      if (isVertical) {
+        context.fillRect(0, offset, width, segmentSize)
+      } else {
+        context.fillRect(offset, 0, segmentSize, height)
+      }
+    }
+    offset += segmentSize
+    isBar = !isBar
+  })
+}
+
+watch(
+  () => [props.width, props.height, props.text, props.darkColor, props.lightColor, props.direction],
+  () => {
+    renderBarcode()
+  },
+  { immediate: true, flush: 'post' }
+)
+
+watch(
+  () => [props.id, props.text, props.scale, props.direction],
+  () => {
+    syncWidgetWidth()
+  },
+  { immediate: true, flush: 'post' }
+)
+
+onMounted(() => {
+  renderBarcode()
+})
+</script>

+ 154 - 0
src/renderer/src/lvgl-widgets/barcode/code128.ts

@@ -0,0 +1,154 @@
+const CODE128_PATTERNS: number[][] = [
+  [2, 1, 2, 2, 2, 2],
+  [2, 2, 2, 1, 2, 2],
+  [2, 2, 2, 2, 2, 1],
+  [1, 2, 1, 2, 2, 3],
+  [1, 2, 1, 3, 2, 2],
+  [1, 3, 1, 2, 2, 2],
+  [1, 2, 2, 2, 1, 3],
+  [1, 2, 2, 3, 1, 2],
+  [1, 3, 2, 2, 1, 2],
+  [2, 2, 1, 2, 1, 3],
+  [2, 2, 1, 3, 1, 2],
+  [2, 3, 1, 2, 1, 2],
+  [1, 1, 2, 2, 3, 2],
+  [1, 2, 2, 1, 3, 2],
+  [1, 2, 2, 2, 3, 1],
+  [1, 1, 3, 2, 2, 2],
+  [1, 2, 3, 1, 2, 2],
+  [1, 2, 3, 2, 2, 1],
+  [2, 2, 3, 2, 1, 1],
+  [2, 2, 1, 1, 3, 2],
+  [2, 2, 1, 2, 3, 1],
+  [2, 1, 3, 2, 1, 2],
+  [2, 2, 3, 1, 1, 2],
+  [3, 1, 2, 1, 3, 1],
+  [3, 1, 1, 2, 2, 2],
+  [3, 2, 1, 1, 2, 2],
+  [3, 2, 1, 2, 2, 1],
+  [3, 1, 2, 2, 1, 2],
+  [3, 2, 2, 1, 1, 2],
+  [3, 2, 2, 2, 1, 1],
+  [2, 1, 2, 1, 2, 3],
+  [2, 1, 2, 3, 2, 1],
+  [2, 3, 2, 1, 2, 1],
+  [1, 1, 1, 3, 2, 3],
+  [1, 3, 1, 1, 2, 3],
+  [1, 3, 1, 3, 2, 1],
+  [1, 1, 2, 3, 1, 3],
+  [1, 3, 2, 1, 1, 3],
+  [1, 3, 2, 3, 1, 1],
+  [2, 1, 1, 3, 1, 3],
+  [2, 3, 1, 1, 1, 3],
+  [2, 3, 1, 3, 1, 1],
+  [1, 1, 2, 1, 3, 3],
+  [1, 1, 2, 3, 3, 1],
+  [1, 3, 2, 1, 3, 1],
+  [1, 1, 3, 1, 2, 3],
+  [1, 1, 3, 3, 2, 1],
+  [1, 3, 3, 1, 2, 1],
+  [3, 1, 3, 1, 2, 1],
+  [2, 1, 1, 3, 3, 1],
+  [2, 3, 1, 1, 3, 1],
+  [2, 1, 3, 1, 1, 3],
+  [2, 1, 3, 3, 1, 1],
+  [2, 1, 3, 1, 3, 1],
+  [3, 1, 1, 1, 2, 3],
+  [3, 1, 1, 3, 2, 1],
+  [3, 3, 1, 1, 2, 1],
+  [3, 1, 2, 1, 1, 3],
+  [3, 1, 2, 3, 1, 1],
+  [3, 3, 2, 1, 1, 1],
+  [3, 1, 4, 1, 1, 1],
+  [2, 2, 1, 4, 1, 1],
+  [4, 3, 1, 1, 1, 1],
+  [1, 1, 1, 2, 2, 4],
+  [1, 1, 1, 4, 2, 2],
+  [1, 2, 1, 1, 2, 4],
+  [1, 2, 1, 4, 2, 1],
+  [1, 4, 1, 1, 2, 2],
+  [1, 4, 1, 2, 2, 1],
+  [1, 1, 2, 2, 1, 4],
+  [1, 1, 2, 4, 1, 2],
+  [1, 2, 2, 1, 1, 4],
+  [1, 2, 2, 4, 1, 1],
+  [1, 4, 2, 1, 1, 2],
+  [1, 4, 2, 2, 1, 1],
+  [2, 4, 1, 2, 1, 1],
+  [2, 2, 1, 1, 1, 4],
+  [4, 1, 3, 1, 1, 1],
+  [2, 4, 1, 1, 1, 2],
+  [1, 3, 4, 1, 1, 1],
+  [1, 1, 1, 2, 4, 2],
+  [1, 2, 1, 1, 4, 2],
+  [1, 2, 1, 2, 4, 1],
+  [1, 1, 4, 2, 1, 2],
+  [1, 2, 4, 1, 1, 2],
+  [1, 2, 4, 2, 1, 1],
+  [4, 1, 1, 2, 1, 2],
+  [4, 2, 1, 1, 1, 2],
+  [4, 2, 1, 2, 1, 1],
+  [2, 1, 2, 1, 4, 1],
+  [2, 1, 4, 1, 2, 1],
+  [4, 1, 2, 1, 2, 1],
+  [1, 1, 1, 1, 4, 3],
+  [1, 1, 1, 3, 4, 1],
+  [1, 3, 1, 1, 4, 1],
+  [1, 1, 4, 1, 1, 3],
+  [1, 1, 4, 3, 1, 1],
+  [4, 1, 1, 1, 1, 3],
+  [4, 1, 1, 3, 1, 1],
+  [1, 1, 3, 1, 4, 1],
+  [1, 1, 4, 1, 3, 1],
+  [3, 1, 1, 1, 4, 1],
+  [4, 1, 1, 1, 3, 1],
+  [2, 1, 1, 4, 1, 2],
+  [2, 1, 1, 2, 1, 4],
+  [2, 1, 1, 2, 3, 2],
+  [2, 3, 3, 1, 1, 1, 2],
+  [2, 1, 1, 1, 3, 3]
+]
+
+const START_B = 104
+const STOP = 106
+
+const getCode128BValue = (char: string) => {
+  const code = char.charCodeAt(0)
+  if (code >= 32 && code <= 126) {
+    return code - 32
+  }
+
+  if (code === 127) {
+    return 95
+  }
+
+  return null
+}
+
+export const encodeCode128B = (value: string) => {
+  if (!value) return null
+
+  const codes = [START_B]
+  let checksum = START_B
+
+  for (const [index, char] of Array.from(value).entries()) {
+    const codeValue = getCode128BValue(char)
+    if (codeValue === null) {
+      return null
+    }
+
+    codes.push(codeValue)
+    checksum += (index + 1) * codeValue
+  }
+
+  codes.push(checksum % 103, STOP)
+
+  return codes.flatMap((code) => CODE128_PATTERNS[code] || [])
+}
+
+export const getCode128BUnitLength = (value: string) => {
+  const pattern = encodeCode128B(value)
+  if (!pattern?.length) return null
+
+  return pattern.reduce((sum, unit) => sum + unit, 0)
+}

+ 178 - 0
src/renderer/src/lvgl-widgets/barcode/index.ts

@@ -0,0 +1,178 @@
+import Barcode from './Barcode.vue'
+import icon from '../assets/icon/icon_41barcode.svg'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import { stateList } from '@/constants'
+import defaultStyle from './style.json'
+import { getCode128BUnitLength } from './code128'
+
+const DEFAULT_TEXT = 'https://www.sv-elec.com/'
+const DEFAULT_SCALE = 1
+const DEFAULT_DIRECTION = 'horizontal'
+const BASE_THICKNESS = 60
+
+const normalizeScale = (value: number) => {
+  return Math.max(1, Math.min(5, Math.round(Number(value) || DEFAULT_SCALE)))
+}
+
+const normalizeDirection = (value: string) => {
+  return value === 'vertical' ? 'vertical' : DEFAULT_DIRECTION
+}
+
+const normalizeText = (value?: string) => {
+  return value?.trim() || DEFAULT_TEXT
+}
+
+const getBarcodeWidth = (text?: string, scale?: number, direction?: string) => {
+  const unitLength =
+    getCode128BUnitLength(normalizeText(text)) ?? getCode128BUnitLength(DEFAULT_TEXT) ?? 1
+  const currentScale = normalizeScale(scale || DEFAULT_SCALE)
+  const currentDirection = normalizeDirection(direction || DEFAULT_DIRECTION)
+  const longSide = Math.max(1, unitLength * currentScale)
+  const shortSide = BASE_THICKNESS * currentScale
+
+  return currentDirection === 'vertical' ? shortSide : longSide
+}
+
+const syncBarcodeWidth = (formData: any) => {
+  formData.props.width = getBarcodeWidth(
+    formData?.props?.text,
+    formData?.props?.scale,
+    formData?.props?.direction
+  )
+}
+
+export default {
+  label: i18n.global.t('barcode'),
+  icon,
+  component: Barcode,
+  key: 'barcode',
+  group: i18n.global.t('advance'),
+  sort: 3,
+  hasChildren: false,
+  defaultStyle,
+  onChangeSize: (props, size) => {
+    return {
+      width: getBarcodeWidth(props?.text, props?.scale, props?.direction),
+      height: Math.max(1, Math.round(size.currentHeight || props?.height || BASE_THICKNESS))
+    }
+  },
+  parts: [
+    {
+      name: 'main',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'barcode',
+    props: {
+      x: 0,
+      y: 0,
+      width: getBarcodeWidth(DEFAULT_TEXT, DEFAULT_SCALE, DEFAULT_DIRECTION),
+      height: BASE_THICKNESS,
+      text: DEFAULT_TEXT,
+      lightColor: '#ffffff',
+      darkColor: '#000000',
+      scale: DEFAULT_SCALE,
+      direction: DEFAULT_DIRECTION
+    },
+    styles: []
+  },
+  config: {
+    props: [
+      {
+        label: '名称',
+        field: 'name',
+        valueType: '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.height',
+            valueType: 'number',
+            componentProps: {
+              span: 12,
+              min: 1,
+              max: 10000
+            },
+            slots: { prefix: 'H' }
+          }
+        ]
+      }
+    ],
+    coreProps: [
+      {
+        label: '暗色',
+        field: 'props.darkColor',
+        valueType: 'color'
+      },
+      {
+        label: '亮色',
+        field: 'props.lightColor',
+        valueType: 'color'
+      },
+      {
+        label: '缩放',
+        field: 'props.scale',
+        valueType: 'number',
+        componentProps: {
+          min: 1,
+          max: 5,
+          onValueChange: (_value: number, formData: any) => {
+            syncBarcodeWidth(formData)
+          }
+        }
+      },
+      {
+        label: '方向',
+        field: 'props.direction',
+        valueType: 'select',
+        componentProps: {
+          options: [
+            { label: 'Horitional', value: 'horizontal' },
+            { label: 'Vertical', value: 'vertical' }
+          ],
+          onValueChange: (_value: string, formData: any) => {
+            syncBarcodeWidth(formData)
+          }
+        }
+      },
+      {
+        label: '文本',
+        field: 'props.text',
+        valueType: 'textarea',
+        componentProps: {
+          onValueChange: (_value: string, formData: any) => {
+            syncBarcodeWidth(formData)
+          }
+        }
+      }
+    ],
+    styles: []
+  }
+} as IComponentModelConfig

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

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

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

@@ -40,6 +40,7 @@ import AnalogClock from './analog-clock/index'
 import Lottie from './lottie'
 import Video from './video/index'
 import QRCode from './qrcode/index'
+import Barcode from './barcode/index'
 
 import Page from './page'
 import { IComponentModelConfig } from './type'
@@ -90,7 +91,8 @@ export const ComponentArray = [
   Video,
 
   Lottie,
-  QRCode
+  QRCode,
+  Barcode
 ] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

+ 7 - 11
src/renderer/src/lvgl-widgets/qrcode/index.ts

@@ -50,16 +50,12 @@ export default {
   config: {
     props: [
       {
-        label: 'Name',
+        label: '名称',
         field: 'name',
-        valueType: 'text',
-        componentProps: {
-          placeholder: 'Enter name',
-          type: 'text'
-        }
+        valueType: 'text'
       },
       {
-        label: 'Position / Size',
+        label: '位置',
         valueType: 'group',
         children: [
           {
@@ -89,17 +85,17 @@ export default {
     ],
     coreProps: [
       {
-        label: 'Dark Color',
+        label: '暗色',
         field: 'props.darkColor',
         valueType: 'color'
       },
       {
-        label: 'Light Color',
+        label: '亮色',
         field: 'props.lightColor',
         valueType: 'color'
       },
       {
-        label: 'Size',
+        label: '大小',
         field: 'props.size',
         valueType: 'number',
         componentProps: {
@@ -108,7 +104,7 @@ export default {
         }
       },
       {
-        label: 'Text',
+        label: '文本',
         field: 'props.text',
         valueType: 'textarea'
       }

+ 8 - 0
src/renderer/src/views/designer/workspace/stage/Moveable.vue

@@ -151,6 +151,14 @@ const individualGroupableProps = (element: HTMLElement | SVGElement | null | und
   if (['lv_checkbox', 'qrcode'].includes(widgetType)) {
     return { resizable: false }
   }
+  if (['barcode'].includes(widgetType)) {
+    return {
+      draggable: true,
+      resizable: true,
+      renderDirections: ['n', 's'],
+      edge: ['n', 's']
+    }
+  }
   if (['lv_image'].includes(widgetType)) {
     return { rotatable: true }
   }