Sfoglia il codice sorgente

feat: 添加键盘控件

jiaxing.liao 9 ore fa
parent
commit
055d188e84

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

@@ -91,6 +91,7 @@
   "image": "Image",
   "richText": "Span Group",
   "textarea": "Textarea",
+  "keyboard": "Keyboard",
   "createProjectFirst": "Please Create The Project First!",
   "dropdown": "Dropdown",
   "checkbox": "Checkbox",

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

@@ -91,6 +91,7 @@
   "image": "图片",
   "richText": "富文本",
   "textarea": "文本框",
+  "keyboard": "键盘",
   "createProjectFirst": "请先创建项目",
   "dropdown": "下拉框",
   "checkbox": "复选框",

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

@@ -5,6 +5,7 @@ import MatrixButton from './button-matrix/index'
 import Image from './image'
 import SpanGroup from './span-group/index'
 import Textarea from './textarea'
+import Keyboard from './keyboard'
 import Dropdown from './dropdown/index'
 import Checkbox from './checkbox'
 import Switch from './switch'
@@ -66,7 +67,8 @@ export const ComponentArray = [
   Canvas,
   Spinner,
   Roller,
-  Spinbox
+  Spinbox,
+  Keyboard
 ] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

+ 161 - 0
src/renderer/src/lvgl-widgets/keyboard/Config.vue

@@ -0,0 +1,161 @@
+<template>
+  <el-card
+    class="mb-12px"
+    :body-class="!options.length ? 'hidden' : 'pr-0!'"
+    :header-class="!options.length ? 'border-b-none!' : ''"
+  >
+    <template #header>
+      <div class="flex items-center justify-between">
+        <span>文本框</span>
+        <span class="flex gap-4px">
+          <LuPlus class="cursor-pointer" size="16px" @click="handleAdd" />
+          <LuTrash2 class="cursor-pointer" size="16px" @click="handleClear" />
+        </span>
+      </div>
+    </template>
+    <el-scrollbar max-height="160px">
+      <div
+        v-for="(_, index) in options"
+        :key="index"
+        class="w-full box-border mb-6px flex items-center gap-4px pr-12px"
+      >
+        <span
+          class="flex-1 truncate"
+          :title="allTextareaMap[options[index]]?.name || options[index]"
+        >
+          {{ allTextareaMap[options[index]]?.name || options[index] }}
+        </span>
+        <LuTrash2 class="cursor-pointer shrink-0" size="14px" @click.stop="handleDelete(index)" />
+      </div>
+    </el-scrollbar>
+  </el-card>
+  <el-dialog align-center draggable v-model="showDialog" title="选择文本框" width="30%">
+    <el-form>
+      <el-form-item label="文本框">
+        <el-select
+          v-model="selectedTextareas"
+          multiple
+          placeholder="选择文本框"
+          style="width: 100%"
+        >
+          <el-option
+            v-for="option in textareaOptions"
+            :key="option.value"
+            :label="option.label"
+            :value="option.value"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button :disabled="!selectedTextareas.length" @click="handleDialogConfirm"
+          >确定</el-button
+        >
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, type Ref } from 'vue'
+import { LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+import { useProjectStore } from '@/store/modules/project'
+
+type FormData = {
+  props?: {
+    textareaIds?: string[]
+  }
+}
+
+const props = defineProps<{
+  values: Ref<FormData | undefined>
+}>()
+
+const showDialog = ref(false)
+const selectedTextareas = ref<string[]>([])
+const projectStore = useProjectStore()
+
+const textareaOptions = computed(() => {
+  const currentPage = projectStore.activePage
+  if (!currentPage) {
+    return []
+  }
+
+  // 返回没有被选过的文本框列表
+  return currentPage.children
+    .filter((widget) => widget.type === 'lv_textarea')
+    .filter((widget) => !options.value.includes(widget.id))
+    .map((widget) => ({
+      label: widget.name,
+      value: widget.id
+    }))
+})
+
+const allTextareaMap = computed(() => {
+  const currentPage = projectStore.activePage
+  if (!currentPage) {
+    return {}
+  }
+
+  const list = currentPage.children.filter((widget) => widget.type === 'lv_textarea')
+  return list.reduce(
+    (acc, widget) => {
+      acc[widget.id] = widget
+      return acc
+    },
+    {} as Record<string, any>
+  )
+})
+
+const ensureRollerProps = () => {
+  const widget = props.values?.value
+  if (!widget) {
+    return undefined
+  }
+
+  if (!widget.props) {
+    widget.props = {}
+  }
+
+  if (!Array.isArray(widget.props.textareaIds)) {
+    widget.props.textareaIds = []
+  }
+
+  return widget.props
+}
+
+const options = computed<string[]>({
+  get() {
+    return ensureRollerProps()?.textareaIds ?? []
+  },
+  set(list: string[]) {
+    const rollerProps = ensureRollerProps()
+    if (rollerProps) {
+      rollerProps.textareaIds = list
+    }
+  }
+})
+
+const handleAdd = () => {
+  selectedTextareas.value = []
+  showDialog.value = true
+}
+
+const handleDelete = (index: number) => {
+  const next = options.value.slice()
+  next.splice(index, 1)
+  options.value = next
+}
+
+const handleClear = () => {
+  options.value = []
+}
+
+const handleDialogConfirm = () => {
+  options.value = Array.from(new Set([...options.value, ...selectedTextareas.value]))
+  showDialog.value = false
+}
+</script>
+
+<style scoped></style>

+ 360 - 0
src/renderer/src/lvgl-widgets/keyboard/Keyboard.vue

@@ -0,0 +1,360 @@
+<template>
+  <div ref="containerRef" class="keyboard-root relative w-full h-full overflow-visible">
+    <div
+      :style="styleMap?.mainStyle"
+      class="keyboard-main w-full h-full box-border flex flex-col overflow-hidden relative"
+    >
+      <ImageBg
+        v-if="styleMap?.mainStyle?.imageSrc"
+        :src="styleMap?.mainStyle?.imageSrc"
+        :imageStyle="styleMap?.mainStyle?.imageStyle"
+      />
+      <div
+        v-for="(row, rowIndex) in layoutRows"
+        :key="`${currentMode}-${rowIndex}`"
+        class="flex-1 w-full flex items-center"
+        :style="{ columnGap: styleMap?.mainStyle?.columnGap }"
+      >
+        <button
+          v-for="key in row"
+          :key="key.id"
+          type="button"
+          class="keyboard-key h-full flex items-center justify-center overflow-hidden relative border-none p-0 cursor-default"
+          :class="{
+            'keyboard-key--action': isActionKey(key),
+            'keyboard-key--space': key.action === 'space'
+          }"
+          :style="getKeyStyle(key, row)"
+          @pointerdown="handlePointerDown(key, $event)"
+          @pointerup="clearPopup"
+          @pointercancel="clearPopup"
+          @pointerleave="handlePointerLeave(key)"
+        >
+          <ImageBg
+            v-if="styleMap?.itemsStyle?.imageSrc"
+            :src="styleMap?.itemsStyle?.imageSrc"
+            :imageStyle="styleMap?.itemsStyle?.imageStyle"
+          />
+          <i
+            v-if="isSymbolKey(key)"
+            class="keyboard-key__icon lvgl-icon not-italic z-1"
+            v-html="getSymbol(key.symbol || '')"
+          ></i>
+          <span v-else class="keyboard-key__label z-1">{{ key.label }}</span>
+        </button>
+      </div>
+    </div>
+
+    <div v-if="popup" class="keyboard-popup absolute z-30 pointer-events-none" :style="popup.style">
+      <div class="keyboard-popup__inner" :style="popupBubbleStyle">
+        <span class="z-1">{{ popup.label }}</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch, type CSSProperties } from 'vue'
+import { useProjectStore } from '@/store/modules/project'
+import { getSymbol } from '@/utils'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import ImageBg from '../ImageBg.vue'
+
+import { KEYBOARD_LAYOUTS, type KeyboardKey, type KeyboardMode } from './data'
+
+const props = defineProps<{
+  id: string
+  width: number
+  height: number
+  styles: any
+  state?: string
+  part?: string
+  mode?: KeyboardMode
+  showPopovers?: boolean
+  textareaId?: string
+}>()
+
+const projectStore = useProjectStore()
+const containerRef = ref<HTMLDivElement>()
+const pressedKeyId = ref('')
+const currentMode = ref<KeyboardMode>('lower')
+const cursorIndex = ref(0)
+const popup = ref<{
+  label: string
+  style: CSSProperties
+} | null>(null)
+let lastTextareaId = ''
+
+const styleMap = useWidgetStyle({
+  widget: 'lv_keyboard',
+  props
+})
+
+watch(
+  () => props.mode,
+  (value) => {
+    currentMode.value = value || 'lower'
+  },
+  {
+    immediate: true
+  }
+)
+
+const layoutRows = computed(() => {
+  return KEYBOARD_LAYOUTS[currentMode.value] || KEYBOARD_LAYOUTS.lower
+})
+
+const targetTextarea = computed(() => {
+  if (!props.textareaId) return
+  const widget = projectStore.getWidgetById(props.textareaId)
+  return widget?.type === 'lv_textarea' ? widget : undefined
+})
+
+const targetText = computed(() => String(targetTextarea.value?.props?.text ?? ''))
+
+watch(
+  () => [props.textareaId, targetText.value],
+  ([textareaId, text]) => {
+    if (textareaId !== lastTextareaId) {
+      cursorIndex.value = text?.length || 0
+      lastTextareaId = textareaId || ''
+      return
+    }
+    cursorIndex.value = clamp(cursorIndex.value, 0, text?.length || 0)
+  },
+  {
+    immediate: true
+  }
+)
+
+const popupBubbleStyle = computed((): CSSProperties => {
+  const itemsStyle = styleMap.value?.itemsStyle || {}
+  const backgroundColor =
+    typeof itemsStyle.backgroundColor === 'string' ? itemsStyle.backgroundColor : '#ffffffff'
+
+  return {
+    ...itemsStyle,
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    height: '100%',
+    filter: 'brightness(1.02)',
+    boxShadow: itemsStyle.boxShadow || '0 8px 20px rgba(0, 0, 0, 0.12)',
+    position: 'relative',
+    zIndex: 1,
+    ['--popup-tail-color' as string]: backgroundColor
+  }
+})
+
+function clamp(value: number, min: number, max: number) {
+  return Math.min(max, Math.max(min, value))
+}
+
+const getKeyFlex = (keyWidth: number, row: KeyboardKey[]) => {
+  const total = row.reduce((sum, item) => sum + item.width, 0)
+  const basis = `${(keyWidth / total) * 100}%`
+  return {
+    flexBasis: basis,
+    flexGrow: 1,
+    flexShrink: 1
+  }
+}
+
+const isActionKey = (key: KeyboardKey) =>
+  typeof key.actionStyle === 'boolean'
+    ? key.actionStyle
+    : key.action !== 'input' && key.action !== 'space'
+const isSymbolKey = (key: KeyboardKey) => !!key.symbol
+
+const getKeyStyle = (key: KeyboardKey, row: KeyboardKey[]) => {
+  const itemsStyle = styleMap.value?.itemsStyle || {}
+
+  const actionStyle = isActionKey(key)
+    ? {
+        backgroundColor: '#e0e0e0',
+        fontSize: '10px'
+      }
+    : {}
+
+  return {
+    ...itemsStyle,
+    ...getKeyFlex(key.width, row),
+    ...actionStyle,
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    userSelect: 'none',
+    backgroundClip: 'padding-box'
+  } as CSSProperties
+}
+
+const getPopupLabel = (key: KeyboardKey) => {
+  if (key.action === 'space') return key.popupLabel || 'Space'
+  return key.popupLabel || key.value || key.label
+}
+
+const shouldShowPopup = (key: KeyboardKey) => {
+  return !!props.showPopovers && (key.action === 'input' || key.action === 'space')
+}
+
+const showPopup = (key: KeyboardKey, element: HTMLElement) => {
+  if (!containerRef.value || !shouldShowPopup(key)) return
+
+  const containerRect = containerRef.value.getBoundingClientRect()
+  const rect = element.getBoundingClientRect()
+  popup.value = {
+    label: getPopupLabel(key),
+    style: {
+      left: `${rect.left - containerRect.left}px`,
+      top: `${Math.max(rect.top - containerRect.top - rect.height * 0.82, 0)}px`,
+      width: `${rect.width}px`,
+      height: `${Math.max(rect.height * 1.18, rect.height + 10)}px`
+    }
+  }
+}
+
+const clearPopup = () => {
+  pressedKeyId.value = ''
+  popup.value = null
+}
+
+const handlePointerDown = (key: KeyboardKey, event: PointerEvent) => {
+  pressedKeyId.value = key.id
+  const element = event.currentTarget as HTMLElement | null
+  if (element) {
+    showPopup(key, element)
+  }
+}
+
+const handlePointerLeave = (key: KeyboardKey) => {
+  if (pressedKeyId.value === key.id) {
+    clearPopup()
+  }
+}
+
+const insertText = (fragment: string) => {
+  const widget = targetTextarea.value
+  if (!widget) return
+
+  const currentText = String(widget.props.text ?? '')
+  const cursor = clamp(cursorIndex.value, 0, currentText.length)
+  let nextFragment = fragment
+  const allowString = String(widget.props.allowString ?? '')
+
+  if (allowString) {
+    nextFragment = nextFragment
+      .split('')
+      .filter((char) => allowString.includes(char))
+      .join('')
+  }
+
+  const maxLength = Number(widget.props.maxLength ?? 0)
+  if (maxLength > 0) {
+    const remaining = maxLength - currentText.length
+    if (remaining <= 0) return
+    nextFragment = nextFragment.slice(0, remaining)
+  }
+
+  if (!nextFragment) return
+
+  widget.props.text = `${currentText.slice(0, cursor)}${nextFragment}${currentText.slice(cursor)}`
+  cursorIndex.value = cursor + nextFragment.length
+}
+
+const deleteBackward = () => {
+  const widget = targetTextarea.value
+  if (!widget) return
+
+  const currentText = String(widget.props.text ?? '')
+  const cursor = clamp(cursorIndex.value, 0, currentText.length)
+  if (cursor <= 0) return
+
+  widget.props.text = `${currentText.slice(0, cursor - 1)}${currentText.slice(cursor)}`
+  cursorIndex.value = cursor - 1
+}
+
+const insertEnter = () => {
+  if (targetTextarea.value?.props?.nowrap) return
+  insertText('\n')
+}
+
+const moveCursor = (offset: number) => {
+  cursorIndex.value = clamp(cursorIndex.value + offset, 0, targetText.value.length)
+}
+
+const handleKeyClick = (key: KeyboardKey) => {
+  switch (key.action) {
+    case 'input':
+      insertText(key.value || '')
+      break
+    case 'space':
+      insertText(' ')
+      break
+    case 'backspace':
+      deleteBackward()
+      break
+    case 'enter':
+      insertEnter()
+      break
+    case 'left':
+      moveCursor(-1)
+      break
+    case 'right':
+      moveCursor(1)
+      break
+    case 'switch-mode':
+      currentMode.value = key.nextMode || 'lower'
+      break
+    case 'close':
+    case 'ok':
+      break
+  }
+  clearPopup()
+}
+</script>
+
+<style lang="less" scoped>
+.keyboard-key {
+  appearance: none;
+  transition:
+    transform 0.12s ease,
+    filter 0.12s ease;
+}
+
+.keyboard-key--action {
+  background: #dedede;
+}
+
+.keyboard-key__label {
+  padding: 0 6px;
+}
+
+.keyboard-key__icon {
+  font-size: 1.1em;
+  line-height: 1;
+}
+
+.keyboard-key--space .keyboard-key__label {
+  opacity: 0;
+}
+
+.keyboard-popup {
+  transform: translateY(-6px);
+}
+
+.keyboard-popup__inner {
+  overflow: visible;
+}
+
+.keyboard-popup__inner::after {
+  content: '';
+  position: absolute;
+  left: 50%;
+  bottom: -8px;
+  transform: translateX(-50%);
+  border-left: 8px solid transparent;
+  border-right: 8px solid transparent;
+  border-top: 8px solid var(--popup-tail-color, #ffffff);
+}
+</style>

+ 249 - 0
src/renderer/src/lvgl-widgets/keyboard/data.ts

@@ -0,0 +1,249 @@
+export type KeyboardMode = 'lower' | 'upper' | 'special' | 'number'
+
+export type KeyboardAction =
+  | 'input'
+  | 'backspace'
+  | 'enter'
+  | 'space'
+  | 'left'
+  | 'right'
+  | 'switch-mode'
+  | 'close'
+  | 'ok'
+
+export type KeyboardKey = {
+  id: string
+  label: string
+  action: KeyboardAction
+  width: number
+  value?: string
+  nextMode?: KeyboardMode
+  popupLabel?: string
+  symbol?: string
+  actionStyle?: boolean
+}
+
+const inputKey = (
+  mode: KeyboardMode,
+  id: string,
+  label: string,
+  width = 1,
+  value = label,
+  extra: Partial<KeyboardKey> = {}
+): KeyboardKey => ({
+  id: `${mode}-${id}`,
+  label,
+  action: 'input',
+  width,
+  value,
+  ...extra
+})
+
+const actionKey = (
+  mode: KeyboardMode,
+  id: string,
+  label: string,
+  action: KeyboardAction,
+  width: number,
+  extra: Partial<KeyboardKey> = {}
+): KeyboardKey => ({
+  id: `${mode}-${id}`,
+  label,
+  action,
+  width,
+  ...extra
+})
+
+const ICON_SYMBOLS = {
+  backspace: 'LV_SYMBOL_BACKSPACE',
+  enter: 'LV_SYMBOL_NEW_LINE',
+  close: 'LV_SYMBOL_KEYBOARD',
+  left: 'LV_SYMBOL_LEFT',
+  right: 'LV_SYMBOL_RIGHT',
+  ok: 'LV_SYMBOL_OK'
+} as const
+
+const footerRow = (mode: KeyboardMode): KeyboardKey[] => [
+  actionKey(mode, 'close', '', 'close', 2, { symbol: ICON_SYMBOLS.close }),
+  actionKey(mode, 'left', '', 'left', 2, { symbol: ICON_SYMBOLS.left }),
+  actionKey(mode, 'space', '', 'space', 6, { value: ' ', popupLabel: 'Space' }),
+  actionKey(mode, 'right', '', 'right', 2, { symbol: ICON_SYMBOLS.right }),
+  actionKey(mode, 'ok', '', 'ok', 2, { symbol: ICON_SYMBOLS.ok })
+]
+
+export const KEYBOARD_LAYOUTS: Record<KeyboardMode, KeyboardKey[][]> = {
+  lower: [
+    [
+      actionKey('lower', 'to-special', '1#', 'switch-mode', 2, { nextMode: 'special' }),
+      inputKey('lower', 'q', 'q'),
+      inputKey('lower', 'w', 'w'),
+      inputKey('lower', 'e', 'e'),
+      inputKey('lower', 'r', 'r'),
+      inputKey('lower', 't', 't'),
+      inputKey('lower', 'y', 'y'),
+      inputKey('lower', 'u', 'u'),
+      inputKey('lower', 'i', 'i'),
+      inputKey('lower', 'o', 'o'),
+      inputKey('lower', 'p', 'p'),
+      actionKey('lower', 'backspace', '', 'backspace', 3, { symbol: ICON_SYMBOLS.backspace })
+    ],
+    [
+      actionKey('lower', 'to-upper', 'ABC', 'switch-mode', 3, { nextMode: 'upper' }),
+      inputKey('lower', 'a', 'a'),
+      inputKey('lower', 's', 's'),
+      inputKey('lower', 'd', 'd'),
+      inputKey('lower', 'f', 'f'),
+      inputKey('lower', 'g', 'g'),
+      inputKey('lower', 'h', 'h'),
+      inputKey('lower', 'j', 'j'),
+      inputKey('lower', 'k', 'k'),
+      inputKey('lower', 'l', 'l'),
+      actionKey('lower', 'enter', '', 'enter', 3, { symbol: ICON_SYMBOLS.enter })
+    ],
+    [
+      inputKey('lower', 'underscore', '_', 1, '_', { actionStyle: true }),
+      inputKey('lower', 'minus', '-', 1, '-', { actionStyle: true }),
+      inputKey('lower', 'z', 'z'),
+      inputKey('lower', 'x', 'x'),
+      inputKey('lower', 'c', 'c'),
+      inputKey('lower', 'v', 'v'),
+      inputKey('lower', 'b', 'b'),
+      inputKey('lower', 'n', 'n'),
+      inputKey('lower', 'm', 'm'),
+      inputKey('lower', 'dot', '.', 1, '.', { actionStyle: true }),
+      inputKey('lower', 'comma', ',', 1, ',', { actionStyle: true }),
+      inputKey('lower', 'colon', ':', 1, ':', { actionStyle: true })
+    ],
+    footerRow('lower')
+  ],
+  upper: [
+    [
+      actionKey('upper', 'to-special', '1#', 'switch-mode', 2, { nextMode: 'special' }),
+      inputKey('upper', 'q', 'Q'),
+      inputKey('upper', 'w', 'W'),
+      inputKey('upper', 'e', 'E'),
+      inputKey('upper', 'r', 'R'),
+      inputKey('upper', 't', 'T'),
+      inputKey('upper', 'y', 'Y'),
+      inputKey('upper', 'u', 'U'),
+      inputKey('upper', 'i', 'I'),
+      inputKey('upper', 'o', 'O'),
+      inputKey('upper', 'p', 'P'),
+      actionKey('upper', 'backspace', '', 'backspace', 3, { symbol: ICON_SYMBOLS.backspace })
+    ],
+    [
+      actionKey('upper', 'to-lower', 'abc', 'switch-mode', 3, { nextMode: 'lower' }),
+      inputKey('upper', 'a', 'A'),
+      inputKey('upper', 's', 'S'),
+      inputKey('upper', 'd', 'D'),
+      inputKey('upper', 'f', 'F'),
+      inputKey('upper', 'g', 'G'),
+      inputKey('upper', 'h', 'H'),
+      inputKey('upper', 'j', 'J'),
+      inputKey('upper', 'k', 'K'),
+      inputKey('upper', 'l', 'L'),
+      actionKey('upper', 'enter', '', 'enter', 3, { symbol: ICON_SYMBOLS.enter })
+    ],
+    [
+      inputKey('upper', 'underscore', '_', 1, '_', { actionStyle: true }),
+      inputKey('upper', 'minus', '-', 1, '-', { actionStyle: true }),
+      inputKey('upper', 'z', 'Z'),
+      inputKey('upper', 'x', 'X'),
+      inputKey('upper', 'c', 'C'),
+      inputKey('upper', 'v', 'V'),
+      inputKey('upper', 'b', 'B'),
+      inputKey('upper', 'n', 'N'),
+      inputKey('upper', 'm', 'M'),
+      inputKey('upper', 'dot', '.', 1, '.', { actionStyle: true }),
+      inputKey('upper', 'comma', ',', 1, ',', { actionStyle: true }),
+      inputKey('upper', 'colon', ':', 1, ':', { actionStyle: true })
+    ],
+    footerRow('upper')
+  ],
+  special: [
+    [
+      inputKey('special', '1', '1'),
+      inputKey('special', '2', '2'),
+      inputKey('special', '3', '3'),
+      inputKey('special', '4', '4'),
+      inputKey('special', '5', '5'),
+      inputKey('special', '6', '6'),
+      inputKey('special', '7', '7'),
+      inputKey('special', '8', '8'),
+      inputKey('special', '9', '9'),
+      inputKey('special', '0', '0'),
+      actionKey('special', 'backspace', '', 'backspace', 3, { symbol: ICON_SYMBOLS.backspace })
+    ],
+    [
+      actionKey('special', 'to-lower', 'abc', 'switch-mode', 3, { nextMode: 'lower' }),
+      inputKey('special', 'plus', '+'),
+      inputKey('special', 'amp', '&'),
+      inputKey('special', 'slash', '/'),
+      inputKey('special', 'star', '*'),
+      inputKey('special', 'equal', '='),
+      inputKey('special', 'percent', '%'),
+      inputKey('special', 'bang', '!'),
+      inputKey('special', 'question', '?'),
+      inputKey('special', 'hash', '#'),
+      inputKey('special', 'lt', '<'),
+      inputKey('special', 'gt', '>')
+    ],
+    [
+      inputKey('special', 'backslash', '\\'),
+      inputKey('special', 'at', '@'),
+      inputKey('special', 'dollar', '$'),
+      inputKey('special', 'lparen', '('),
+      inputKey('special', 'rparen', ')'),
+      inputKey('special', 'lbrace', '{'),
+      inputKey('special', 'rbrace', '}'),
+      inputKey('special', 'lbracket', '['),
+      inputKey('special', 'rbracket', ']'),
+      inputKey('special', 'semi', ';'),
+      inputKey('special', 'double-quote', '"'),
+      inputKey('special', 'single-quote', "'")
+    ],
+    footerRow('special')
+  ],
+  number: [
+    [
+      inputKey('number', '1', '1'),
+      inputKey('number', '2', '2'),
+      inputKey('number', '3', '3'),
+      actionKey('number', 'close', '', 'close', 2, {
+        symbol: ICON_SYMBOLS.close,
+        actionStyle: true
+      })
+    ],
+    [
+      inputKey('number', '4', '4'),
+      inputKey('number', '5', '5'),
+      inputKey('number', '6', '6'),
+      actionKey('number', 'ok', '', 'ok', 2, {
+        symbol: ICON_SYMBOLS.ok,
+        actionStyle: true
+      })
+    ],
+    [
+      inputKey('number', '7', '7'),
+      inputKey('number', '8', '8'),
+      inputKey('number', '9', '9'),
+      actionKey('number', 'backspace', '', 'backspace', 2, {
+        symbol: ICON_SYMBOLS.backspace,
+        actionStyle: false
+      })
+    ],
+    [
+      inputKey('number', 'plus-minus', '+/-'),
+      inputKey('number', '0', '0'),
+      inputKey('number', 'dot', '.'),
+      actionKey('number', 'left', '', 'left', 1, {
+        symbol: ICON_SYMBOLS.left,
+        actionStyle: false
+      }),
+      actionKey('number', 'right', '', 'right', 1, {
+        symbol: ICON_SYMBOLS.right,
+        actionStyle: false
+      })
+    ]
+  ]
+}

+ 283 - 0
src/renderer/src/lvgl-widgets/keyboard/index.ts

@@ -0,0 +1,283 @@
+import { h } from 'vue'
+import Keyboard from './Keyboard.vue'
+import Config from './Config.vue'
+import icon from '../assets/icon/icon_29keyboad.svg'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import { flagOptions, stateOptions, stateList } from '@/constants'
+import defaultStyle from './style.json'
+
+export default {
+  label: i18n.global.t('keyboard'),
+  icon,
+  component: Keyboard,
+  key: 'lv_keyboard',
+  group: i18n.global.t('display'),
+  sort: 1,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    },
+    {
+      name: 'items',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'keyboard',
+    props: {
+      x: 0,
+      y: 0,
+      width: 500,
+      height: 200,
+      flags: [
+        'LV_OBJ_FLAG_CLICKABLE',
+        'LV_OBJ_FLAG_SCROLLABLE',
+        'LV_OBJ_FLAG_SCROLL_ELASTIC',
+        'LV_OBJ_FLAG_SCROLL_MOMENTUM',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_HOR',
+        'LV_OBJ_FLAG_SCROLL_CHAIN_VER',
+        'LV_OBJ_FLAG_SCROLL_CHAIN',
+        'LV_OBJ_FLAG_SCROLL_WITH_ARROW',
+        'LV_OBJ_FLAG_SNAPPABLE',
+        'LV_OBJ_FLAG_PRESS_LOCK',
+        'LV_OBJ_FLAG_GESTURE_BUBBLE'
+      ],
+      states: [],
+      mode: 'lower',
+      showPopovers: false,
+      textareaIds: []
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#f5f5f5ff',
+          image: {
+            imgId: '',
+            recolor: '#ffffff00',
+            alpha: 255
+          }
+        },
+        border: {
+          color: '#000000ff',
+          width: 0,
+          radius: 0,
+          side: ['all']
+        },
+        outline: {
+          color: '#000000ff',
+          width: 0,
+          pad: 0
+        },
+        padding: {
+          top: 11,
+          right: 11,
+          bottom: 11,
+          left: 11,
+          row: 11,
+          column: 11
+        },
+        shadow: {
+          color: '#000000ff',
+          offsetX: 0,
+          offsetY: 0,
+          spread: 0,
+          width: 0
+        }
+      },
+      {
+        part: {
+          name: 'items',
+          state: 'default'
+        },
+        background: {
+          color: '#ffffffff',
+          image: {
+            imgId: '',
+            recolor: '#ffffff00',
+            alpha: 255
+          }
+        },
+        text: {
+          color: '#212121ff',
+          family: 'xx',
+          size: 14,
+          align: 'center',
+          decoration: 'none'
+        },
+        border: {
+          color: '#000000ff',
+          width: 0,
+          radius: 10,
+          side: ['all']
+        }
+      }
+    ]
+  },
+  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.states',
+        valueType: 'checkbox',
+        componentProps: {
+          options: stateOptions,
+          defaultCollapsed: true
+        }
+      }
+    ],
+    coreProps: [
+      {
+        label: '模式',
+        field: 'props.mode',
+        labelWidth: '100px',
+        valueType: 'select',
+        componentProps: {
+          options: [
+            { label: 'Lower', value: 'lower' },
+            { label: 'Upper', value: 'upper' },
+            { label: 'Special', value: 'special' },
+            { label: 'Number', value: 'number' }
+          ]
+        }
+      },
+      {
+        label: '弹出提示',
+        field: 'props.showPopovers',
+        labelWidth: '100px',
+        valueType: 'switch'
+      },
+      {
+        label: '输入框',
+        field: 'props.textareaIds',
+        valueType: '',
+        render: (value) => h(Config, { values: value })
+      }
+    ],
+    styles: [
+      {
+        label: '模块/状态',
+        field: 'part',
+        valueType: 'part'
+      },
+      {
+        label: '背景',
+        field: 'background',
+        valueType: 'background'
+      },
+      {
+        label: '边框',
+        field: 'border',
+        valueType: 'border'
+      },
+      {
+        valueType: 'dependency',
+        name: ['part'],
+        dependency: ({ part }) => {
+          return part?.name === 'items'
+            ? [
+                {
+                  label: '字体',
+                  field: 'text',
+                  valueType: 'font'
+                }
+              ]
+            : [
+                {
+                  label: '轮廓',
+                  field: 'outline',
+                  valueType: 'outline'
+                },
+                {
+                  label: '内边距',
+                  field: 'padding',
+                  valueType: 'padding',
+                  componentProps: {
+                    hasGap: true
+                  }
+                },
+                {
+                  label: '阴影',
+                  field: 'shadow',
+                  valueType: 'shadow'
+                }
+              ]
+        }
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 74 - 0
src/renderer/src/lvgl-widgets/keyboard/style.json

@@ -0,0 +1,74 @@
+{
+  "widget": "lv_keyboard",
+  "styleName": "defualt",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#f5f5f5ff",
+          "image": {
+            "imgId": "",
+            "recolor": "#ffffff00",
+            "alpha": 255
+          }
+        },
+        "border": {
+          "color": "#000000ff",
+          "width": 0,
+          "radius": 0,
+          "side": ["all"]
+        },
+        "outline": {
+          "color": "#000000ff",
+          "width": 0,
+          "pad": 0
+        },
+        "padding": {
+          "top": 11,
+          "right": 11,
+          "bottom": 11,
+          "left": 11,
+          "row": 11,
+          "column": 11
+        },
+        "shadow": {
+          "color": "#000000ff",
+          "offsetX": 0,
+          "offsetY": 0,
+          "spread": 0,
+          "width": 0
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "items",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffffff",
+          "image": {
+            "imgId": "",
+            "recolor": "#ffffff00",
+            "alpha": 255
+          }
+        },
+        "text": {
+          "color": "#212121ff",
+          "family": "xx",
+          "size": 14,
+          "align": "center",
+          "decoration": "none"
+        },
+        "border": {
+          "color": "#000000ff",
+          "width": 0,
+          "radius": 10,
+          "side": ["all"]
+        }
+      },
+      "state": []
+    }
+  ]
+}
+

+ 6 - 2
src/renderer/src/lvgl-widgets/roller/Config.vue

@@ -1,5 +1,9 @@
 <template>
-  <el-card class="mb-12px" body-class="p-8px!">
+  <el-card
+    class="mb-12px"
+    :body-class="!options.length ? 'hidden' : 'pr-0!'"
+    :header-class="!options.length ? 'border-b-none!' : ''"
+  >
     <template #header>
       <div class="flex items-center justify-between">
         <span>选项</span>
@@ -21,7 +25,7 @@
             v-model="options[index]"
             class="flex-1"
             spellcheck="false"
-            placeholder="Enter option"
+            placeholder="输入选项"
           />
           <LuTrash2 class="cursor-pointer shrink-0" size="14px" @click.stop="handleDelete(index)" />
         </div>

+ 0 - 2
src/renderer/src/lvgl-widgets/roller/index.tsx

@@ -39,8 +39,6 @@ export default {
         'LV_OBJ_FLAG_SCROLL_ELASTIC',
         'LV_OBJ_FLAG_SCROLL_MOMENTUM',
         'LV_OBJ_FLAG_SCROLL_CHAIN_HOR',
-        'LV_OBJ_FLAG_SCROLL_CHAIN_VER',
-        'LV_OBJ_FLAG_SCROLL_CHAIN',
         'LV_OBJ_FLAG_SCROLL_WITH_ARROW',
         'LV_OBJ_FLAG_SNAPPABLE',
         'LV_OBJ_FLAG_PRESS_LOCK',

+ 4 - 1
src/renderer/src/lvgl-widgets/spinbox/Spinbox.vue

@@ -17,7 +17,10 @@
       </span>
     </div>
 
-    <div class="relative min-w-0 flex-1 overflow-hidden box-border" :style="styleMap?.mainStyle">
+    <div
+      class="h-full relative min-w-0 flex-1 overflow-hidden box-border"
+      :style="styleMap?.mainStyle"
+    >
       <ImageBg
         v-if="styleMap?.mainStyle?.imageSrc"
         :src="styleMap.mainStyle.imageSrc"