Pārlūkot izejas kodu

feat: 新增日历控件

jiaxing.liao 1 mēnesi atpakaļ
vecāks
revīzija
31416d6bc3

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

@@ -25,6 +25,7 @@ declare module 'vue' {
     ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
     ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDropdown: typeof import('element-plus/es')['ElDropdown']
@@ -87,6 +88,7 @@ declare global {
   const ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
   const ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
   const ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
+  const ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
   const ElDialog: typeof import('element-plus/es')['ElDialog']
   const ElDivider: typeof import('element-plus/es')['ElDivider']
   const ElDropdown: typeof import('element-plus/es')['ElDropdown']

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

@@ -94,6 +94,7 @@
   "keyboard": "Keyboard",
   "createProjectFirst": "Please Create The Project First!",
   "dropdown": "Dropdown",
+  "calendar": "Calendar",
   "checkbox": "Checkbox",
   "switch": "Switch",
   "bar": "Bar",
@@ -137,5 +138,6 @@
   "roller": "Roller",
   "spinbox": "Spinbox",
   "animimg": "Animimg",
-  "animation": "Animation"
+  "animation": "Animation",
+  "time": "Time"
 }

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

@@ -94,6 +94,7 @@
   "keyboard": "键盘",
   "createProjectFirst": "请先创建项目",
   "dropdown": "下拉框",
+  "calendar": "日历",
   "checkbox": "复选框",
   "switch": "开关",
   "bar": "进度条",
@@ -137,5 +138,6 @@
   "roller": "滚轮",
   "spinbox": "微调框",
   "animimg": "动画图片",
-  "animation": "动画"
+  "animation": "动画",
+  "time": "时间"
 }

+ 397 - 0
src/renderer/src/lvgl-widgets/calendar/Calendar.vue

@@ -0,0 +1,397 @@
+<template>
+  <div :style="styleMap?.mainStyle" class="relative w-full h-full box-border overflow-hidden">
+    <ImageBg
+      v-if="styleMap?.mainStyle?.imageSrc"
+      :src="styleMap?.mainStyle?.imageSrc"
+      :imageStyle="styleMap?.mainStyle?.imageStyle"
+    />
+
+    <div class="absolute left-0 top-0 overflow-hidden" :style="canvasStyle">
+      <div class="relative overflow-hidden box-border" :style="scaledInnerStyle">
+        <div
+          v-if="showHeader"
+          class="relative flex items-center justify-between"
+          :style="headerStyle"
+        >
+          <ImageBg
+            v-if="styleMap?.headerStyle?.imageSrc"
+            :src="styleMap?.headerStyle?.imageSrc"
+            :imageStyle="styleMap?.headerStyle?.imageStyle"
+          />
+
+          <template v-if="props.titleMode === 'arrow-buttons'">
+            <button class="calendar-nav-btn" :style="navButtonStyle">
+              <i
+                class="lvgl-icon not-italic"
+                v-html="getSymbol('LV_SYMBOL_LEFT')"
+                :style="navIconStyle"
+              ></i>
+            </button>
+            <div class="relative z-1 flex-1 text-center font-600" :style="headerTextStyle">
+              {{ titleText }}
+            </div>
+            <button class="calendar-nav-btn" :style="navButtonStyle">
+              <i
+                class="lvgl-icon not-italic"
+                v-html="getSymbol('LV_SYMBOL_RIGHT')"
+                :style="navIconStyle"
+              ></i>
+            </button>
+          </template>
+
+          <template v-else>
+            <div class="calendar-select-row relative z-1">
+              <div class="calendar-select-box" :style="dropdownStyle">
+                <span :style="headerTextStyle">{{ displayYear }}</span>
+                <i
+                  class="lvgl-icon not-italic"
+                  v-html="getSymbol('LV_SYMBOL_DOWN')"
+                  :style="dropdownArrowStyle"
+                ></i>
+              </div>
+              <div class="calendar-select-box" :style="dropdownStyle">
+                <span :style="headerTextStyle">{{ displayMonth }}</span>
+                <i
+                  class="lvgl-icon not-italic"
+                  v-html="getSymbol('LV_SYMBOL_DOWN')"
+                  :style="dropdownArrowStyle"
+                ></i>
+              </div>
+            </div>
+          </template>
+        </div>
+
+        <div class="calendar-body" :style="bodyStyle">
+          <div class="calendar-weekdays">
+            <div
+              v-for="label in weekdayLabels"
+              :key="label"
+              class="calendar-weekday"
+              :style="weekdayStyle"
+            >
+              {{ label }}
+            </div>
+          </div>
+
+          <div class="calendar-grid">
+            <div
+              v-for="cell in calendarCells"
+              :key="cell.key"
+              class="calendar-cell relative box-border"
+              :style="getCellStyle(cell)"
+            >
+              <ImageBg
+                v-if="styleMap?.itemsStyle?.imageSrc"
+                :src="styleMap?.itemsStyle?.imageSrc"
+                :imageStyle="styleMap?.itemsStyle?.imageStyle"
+              />
+              <div class="relative z-1 flex h-full w-full flex-col items-center justify-center">
+                <span :style="dayTextStyle(cell)">
+                  {{ cell.day }}
+                </span>
+                <span v-if="props.showLunar" :style="lunarTextStyle(cell)">
+                  {{ cell.lunarLabel }}
+                </span>
+              </div>
+              <div v-if="cell.isCurrentDate" class="calendar-current-ring" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, type CSSProperties } from 'vue'
+import { useWidgetStyle } from '../hooks/useWidgetStyle'
+import ImageBg from '../ImageBg.vue'
+import { getSymbol } from '@/utils'
+import {
+  buildCalendarCells,
+  formatTitle,
+  getWeekdayLabels,
+  resolveCurrentDate,
+  resolveDisplayDate
+} from './utils'
+
+const BASE_WIDTH = 400
+const BASE_HEIGHT = 320
+const HEADER_HEIGHT = 56
+const HORIZONTAL_PADDING = 12
+const BODY_BOTTOM_PADDING = 12
+
+const props = defineProps<{
+  width: number
+  height: number
+  styles: any
+  state?: string
+  part?: string
+  currentDate?: string
+  displayDate?: string
+  showLunar?: boolean
+  titleMode?: 'none' | 'arrow-buttons' | 'drop-down'
+}>()
+
+const styleMap = useWidgetStyle({
+  widget: 'lv_calendar',
+  props
+})
+
+const weekdayLabels = getWeekdayLabels()
+
+const showHeader = computed(() => props.titleMode !== 'none')
+
+const contentPadding = computed(() => {
+  const padding = String(styleMap.value?.mainStyle?.padding || '0px')
+    .split(' ')
+    .map((item) => Number.parseFloat(item) || 0)
+
+  if (padding.length === 4) {
+    return {
+      top: padding[0],
+      right: padding[1],
+      bottom: padding[2],
+      left: padding[3]
+    }
+  }
+
+  return {
+    top: 0,
+    right: 0,
+    bottom: 0,
+    left: 0
+  }
+})
+
+const canvasStyle = computed((): CSSProperties => {
+  const { top, right, bottom, left } = contentPadding.value
+  const availableWidth = Math.max(props.width - left - right, 0)
+  const availableHeight = Math.max(props.height - top - bottom, 0)
+
+  return {
+    left: `${left}px`,
+    top: `${top}px`,
+    width: `${availableWidth}px`,
+    height: `${availableHeight}px`
+  }
+})
+
+const scaledInnerStyle = computed((): CSSProperties => {
+  const width = Number.parseFloat(String(canvasStyle.value.width || 0)) || 0
+  const height = Number.parseFloat(String(canvasStyle.value.height || 0)) || 0
+  const scaleX = width / BASE_WIDTH || 0
+  const scaleY = height / BASE_HEIGHT || 0
+
+  return {
+    width: `${BASE_WIDTH}px`,
+    height: `${BASE_HEIGHT}px`,
+    transform: `scale(${scaleX}, ${scaleY})`,
+    transformOrigin: 'left top'
+  }
+})
+
+const titleText = computed(() => formatTitle(props.displayDate, props.currentDate))
+
+const displayValue = computed(() => {
+  const currentDate = resolveCurrentDate(props.currentDate)
+  return resolveDisplayDate(props.displayDate, currentDate)
+})
+
+const displayYear = computed(() => String(displayValue.value.year))
+const displayMonth = computed(() => String(displayValue.value.month).padStart(2, '0'))
+
+const calendarCells = computed(() => buildCalendarCells(props.displayDate, props.currentDate))
+
+const headerStyle = computed((): CSSProperties => {
+  return {
+    ...styleMap.value?.headerStyle,
+    height: `${HEADER_HEIGHT}px`,
+    marginBottom: '8px',
+    padding: '0 12px',
+    boxSizing: 'border-box'
+  }
+})
+
+const headerTextStyle = computed((): CSSProperties => {
+  return {
+    color: styleMap.value?.headerStyle?.color,
+    fontSize: styleMap.value?.headerStyle?.fontSize,
+    fontFamily: styleMap.value?.headerStyle?.fontFamily,
+    fontStyle: styleMap.value?.headerStyle?.fontStyle,
+    fontWeight: styleMap.value?.headerStyle?.fontWeight,
+    textDecoration: styleMap.value?.headerStyle?.textDecoration,
+    letterSpacing: styleMap.value?.headerStyle?.letterSpacing
+  }
+})
+
+const navButtonStyle = computed((): CSSProperties => {
+  return {
+    width: '30px',
+    height: '30px',
+    borderRadius: '999px',
+    border: 'none',
+    background: '#2196f3',
+    color: '#ffffff',
+    padding: 0,
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    position: 'relative',
+    zIndex: 1
+  }
+})
+
+const navIconStyle = computed((): CSSProperties => {
+  return {
+    fontSize: '12px',
+    lineHeight: 1
+  }
+})
+
+const dropdownStyle = computed((): CSSProperties => {
+  return {
+    minWidth: '132px',
+    height: '34px',
+    padding: '0 14px',
+    borderRadius: '10px',
+    border: '1px solid #dedede',
+    backgroundColor: '#ffffff',
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'space-between',
+    boxSizing: 'border-box'
+  }
+})
+
+const dropdownArrowStyle = computed((): CSSProperties => {
+  return {
+    color: styleMap.value?.headerStyle?.color,
+    fontSize: '12px',
+    lineHeight: 1,
+    marginLeft: '12px'
+  }
+})
+
+const bodyStyle = computed((): CSSProperties => {
+  const topPadding = showHeader.value ? 0 : 10
+  return {
+    height: `${BASE_HEIGHT - (showHeader.value ? HEADER_HEIGHT + 8 : 0)}px`,
+    padding: `${topPadding}px ${HORIZONTAL_PADDING}px ${BODY_BOTTOM_PADDING}px`,
+    boxSizing: 'border-box'
+  }
+})
+
+const weekdayStyle = computed((): CSSProperties => {
+  return {
+    ...headerTextStyle.value,
+    fontWeight: 400,
+    opacity: 0.6,
+    textAlign: 'center'
+  }
+})
+
+const itemGap = computed(() => {
+  if (!props.showLunar) return 0
+  const lineHeightValue = String(styleMap.value?.itemsStyle?.lineHeight || '')
+  const parsedLineHeight = Number.parseFloat(lineHeightValue)
+  const fontSize = Number.parseFloat(String(styleMap.value?.itemsStyle?.fontSize || 16)) || 16
+  if (!parsedLineHeight) return 0
+  return parsedLineHeight - fontSize * 1.2
+})
+
+const dayTextStyle = (cell: { inMonth: boolean }): CSSProperties => {
+  return {
+    color: styleMap.value?.itemsStyle?.color,
+    fontSize: styleMap.value?.itemsStyle?.fontSize,
+    fontFamily: styleMap.value?.itemsStyle?.fontFamily,
+    fontStyle: styleMap.value?.itemsStyle?.fontStyle,
+    fontWeight: styleMap.value?.itemsStyle?.fontWeight || 400,
+    textDecoration: styleMap.value?.itemsStyle?.textDecoration,
+    letterSpacing: styleMap.value?.itemsStyle?.letterSpacing,
+    opacity: cell.inMonth ? 1 : 0.45,
+    lineHeight: 1
+  }
+}
+
+const lunarTextStyle = (cell: { inMonth: boolean }): CSSProperties => {
+  return {
+    ...dayTextStyle(cell),
+    fontSize: '0.85em',
+    // 默认-8在视觉是协调的
+    marginTop: `${itemGap.value + 8}px`,
+    whiteSpace: 'nowrap'
+  }
+}
+
+const getCellStyle = (cell: { inMonth: boolean; isCurrentDate: boolean }): CSSProperties => {
+  return {
+    ...styleMap.value?.itemsStyle,
+    display: 'flex',
+    alignItems: 'center',
+    justifyContent: 'center',
+    boxSizing: 'border-box',
+    position: 'relative',
+    backgroundColor: styleMap.value?.itemsStyle?.backgroundColor,
+    opacity: cell.inMonth ? 1 : 0.72
+  }
+}
+</script>
+
+<style scoped lang="less">
+.calendar-body {
+  display: flex;
+  flex-direction: column;
+}
+
+.calendar-weekdays,
+.calendar-grid {
+  display: grid;
+  grid-template-columns: repeat(7, minmax(0, 1fr));
+}
+
+.calendar-weekdays {
+  margin-bottom: 10px;
+}
+
+.calendar-weekday {
+  height: 30px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.calendar-grid {
+  grid-auto-rows: minmax(0, 1fr);
+  gap: 8px;
+  height: calc(100% - 40px);
+}
+
+.calendar-cell {
+  min-height: 0;
+}
+
+.calendar-current-ring {
+  position: absolute;
+  inset: 0;
+  border: 2px solid #2196f3;
+  box-sizing: border-box;
+  pointer-events: none;
+}
+
+.calendar-nav-btn {
+  appearance: none;
+  cursor: default;
+}
+
+.calendar-select-row {
+  width: 100%;
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.calendar-select-box {
+  flex: 1 1 0;
+}
+</style>

+ 370 - 0
src/renderer/src/lvgl-widgets/calendar/index.tsx

@@ -0,0 +1,370 @@
+import Calendar from './Calendar.vue'
+import icon from '../assets/icon/icon_32calendar.svg'
+import { flagOptions, stateOptions, stateList } from '@/constants'
+import type { IComponentModelConfig } from '../type'
+import i18n from '@/locales'
+import defaultStyle from './style.json'
+import { getTodayDefaults } from './utils'
+
+const todayDefaults = getTodayDefaults()
+
+export default {
+  label: i18n.global.t('calendar'),
+  icon,
+  component: Calendar,
+  key: 'lv_calendar',
+  group: i18n.global.t('time'),
+  sort: 1,
+  defaultStyle,
+  parts: [
+    {
+      name: 'main',
+      stateList
+    },
+    {
+      name: 'header',
+      stateList
+    },
+    {
+      name: 'items',
+      stateList
+    }
+  ],
+  defaultSchema: {
+    name: 'calendar',
+    props: {
+      x: 0,
+      y: 0,
+      width: 400,
+      height: 320,
+      flags: [
+        'LV_OBJ_FLAG_CLICKABLE',
+        'LV_OBJ_FLAG_CLICK_FOCUSABLE',
+        '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_ON_FOCUS',
+        'LV_OBJ_FLAG_SCROLL_WITH_ARROW',
+        'LV_OBJ_FLAG_SNAPPABLE',
+        'LV_OBJ_FLAG_PRESS_LOCK',
+        'LV_OBJ_FLAG_GESTURE_BUBBLE'
+      ],
+      states: [],
+      currentDate: todayDefaults.currentDate,
+      displayDate: todayDefaults.displayDate,
+      showLunar: false,
+      titleMode: 'arrow-buttons'
+    },
+    styles: [
+      {
+        part: {
+          name: 'main',
+          state: 'default'
+        },
+        background: {
+          color: '#ffffffff',
+          image: {
+            imgId: '',
+            recolor: '#00000000',
+            alpha: 255
+          }
+        },
+        border: {
+          color: '#e0e0e0ff',
+          width: 2,
+          radius: 10,
+          side: ['all']
+        },
+        outline: {
+          color: '#000000ff',
+          width: 0,
+          pad: 0
+        },
+        shadow: {
+          color: '#000000ff',
+          offsetX: 0,
+          offsetY: 0,
+          spread: 0,
+          width: 0
+        },
+        transform: {
+          width: 0,
+          height: 0,
+          translateX: 0,
+          translateY: 0,
+          originX: 0,
+          originY: 0,
+          rotate: 0,
+          scale: 256
+        }
+      },
+      {
+        part: {
+          name: 'items',
+          state: 'default'
+        },
+        background: {
+          color: '#ffffff33',
+          image: {
+            imgId: '',
+            recolor: '#00000000',
+            alpha: 255
+          }
+        },
+        text: {
+          color: '#212121ff',
+          size: 16,
+          family: 'xx',
+          align: 'left',
+          decoration: 'none'
+        },
+        spacer: {
+          lineHeight: -8,
+          letterSpacing: 0
+        },
+        border: {
+          color: '#e0e0e0ff',
+          width: 1,
+          radius: 0,
+          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.currentDate',
+        valueType: 'date',
+        labelWidth: '80px',
+        componentProps: {
+          type: 'date',
+          format: 'YYYY-MM-DD',
+          valueFormat: 'YYYY-MM-DD'
+        }
+      },
+      {
+        label: '显示日期',
+        field: 'props.displayDate',
+        valueType: 'date',
+        labelWidth: '80px',
+        componentProps: {
+          type: 'month',
+          format: 'YYYY-MM',
+          valueFormat: 'YYYY-MM'
+        }
+      },
+      {
+        label: '农历开关',
+        field: 'props.showLunar',
+        valueType: 'switch',
+        labelWidth: '80px'
+      },
+      {
+        label: '标题模式',
+        field: 'props.titleMode',
+        valueType: 'select',
+        labelWidth: '80px',
+        componentProps: {
+          options: [
+            { label: 'None', value: 'none' },
+            { label: 'Arrow buttons', value: 'arrow-buttons' },
+            { label: 'Drop-down', value: 'drop-down' }
+          ]
+        }
+      }
+    ],
+    styles: [
+      {
+        label: '模块/状态',
+        field: 'part',
+        valueType: 'part',
+        componentProps: {
+          filterPartOptions: (item, widgetData) => {
+            if (item.name === 'header' && widgetData?.props?.titleMode === 'none') {
+              return null
+            }
+
+            return {
+              label: item.name,
+              value: item.name
+            }
+          }
+        }
+      },
+      {
+        valueType: 'dependency',
+        name: ['part', 'props.titleMode', 'props.showLunar'],
+        dependency: ({ part, 'props.titleMode': titleMode, 'props.showLunar': showLunar }) => {
+          if (part?.name === 'main') {
+            return [
+              {
+                label: '背景',
+                field: 'background',
+                valueType: 'background'
+              },
+              {
+                label: '边框',
+                field: 'border',
+                valueType: 'border'
+              },
+              {
+                label: '轮廓',
+                field: 'outline',
+                valueType: 'outline'
+              },
+              {
+                label: '阴影',
+                field: 'shadow',
+                valueType: 'shadow'
+              },
+              {
+                label: '变换',
+                field: 'transform',
+                valueType: 'transform'
+              }
+            ]
+          }
+
+          if (part?.name === 'header') {
+            if (titleMode === 'none') {
+              return []
+            }
+
+            return [
+              {
+                label: '背景',
+                field: 'background',
+                valueType: 'background'
+              },
+              {
+                label: '字体',
+                field: 'text',
+                valueType: 'font'
+              },
+              {
+                label: '间距',
+                field: 'spacer',
+                valueType: 'spacer',
+                componentProps: {
+                  hideLetterSpacing: true
+                }
+              }
+            ]
+          }
+
+          return [
+            {
+              label: '背景',
+              field: 'background',
+              valueType: 'background',
+              componentProps: {
+                onlyColor: true
+              }
+            },
+            {
+              label: '字体',
+              field: 'text',
+              valueType: 'font'
+            },
+            {
+              label: '间距',
+              field: 'spacer',
+              valueType: 'spacer',
+              componentProps: {
+                hideLetterSpacing: !showLunar
+              }
+            },
+            {
+              label: '边框',
+              field: 'border',
+              valueType: 'border'
+            }
+          ]
+        }
+      }
+    ]
+  }
+} as IComponentModelConfig

+ 104 - 0
src/renderer/src/lvgl-widgets/calendar/style.json

@@ -0,0 +1,104 @@
+{
+  "widget": "lv_calendar",
+  "styleName": "defualt",
+  "part": [
+    {
+      "partName": "main",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffffff",
+          "image": {
+            "imgId": "",
+            "recolor": "#00000000",
+            "alpha": 255
+          }
+        },
+        "border": {
+          "color": "#e0e0e0ff",
+          "width": 2,
+          "radius": 10,
+          "side": ["all"]
+        },
+        "outline": {
+          "color": "#000000ff",
+          "width": 0,
+          "pad": 0
+        },
+        "shadow": {
+          "color": "#000000ff",
+          "offsetX": 0,
+          "offsetY": 0,
+          "spread": 0,
+          "width": 0
+        },
+        "transform": {
+          "width": 0,
+          "height": 0,
+          "translateX": 0,
+          "translateY": 0,
+          "originX": 0,
+          "originY": 0,
+          "rotate": 0,
+          "scale": 256
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "header",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff00",
+          "image": {
+            "imgId": "",
+            "recolor": "#00000000",
+            "alpha": 255
+          }
+        },
+        "text": {
+          "color": "#212121ff",
+          "size": 16,
+          "family": "xx",
+          "align": "left",
+          "decoration": "none"
+        },
+        "spacer": {
+          "lineHeight": 0,
+          "letterSpacing": 0
+        }
+      },
+      "state": []
+    },
+    {
+      "partName": "items",
+      "defaultStyle": {
+        "background": {
+          "color": "#ffffff33",
+          "image": {
+            "imgId": "",
+            "recolor": "#00000000",
+            "alpha": 255
+          }
+        },
+        "text": {
+          "color": "#212121ff",
+          "size": 16,
+          "family": "xx",
+          "align": "left",
+          "decoration": "none"
+        },
+        "spacer": {
+          "lineHeight": -8,
+          "letterSpacing": 0
+        },
+        "border": {
+          "color": "#e0e0e0ff",
+          "width": 1,
+          "radius": 0,
+          "side": ["all"]
+        }
+      },
+      "state": []
+    }
+  ]
+}

+ 276 - 0
src/renderer/src/lvgl-widgets/calendar/utils.ts

@@ -0,0 +1,276 @@
+export type CalendarCell = {
+  key: string
+  date: Date
+  day: number
+  inMonth: boolean
+  isCurrentDate: boolean
+  lunarLabel: string
+}
+
+const WEEKDAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
+const MONTH_LABELS = [
+  'January',
+  'February',
+  'March',
+  'April',
+  'May',
+  'June',
+  'July',
+  'August',
+  'September',
+  'October',
+  'November',
+  'December'
+]
+
+const SOLAR_FESTIVALS: Record<string, string> = {
+  '01-01': '元旦',
+  '02-14': '情人节',
+  '03-08': '妇女节',
+  '03-12': '植树节',
+  '03-15': '消费节',
+  '04-01': '愚人节',
+  '05-01': '劳动节',
+  '05-04': '青年节',
+  '06-01': '儿童节',
+  '07-01': '建党节',
+  '08-01': '建军节',
+  '09-10': '教师节',
+  '10-01': '国庆节',
+  '10-31': '万圣节',
+  '12-24': '平安夜',
+  '12-25': '圣诞节'
+}
+
+const LUNAR_FESTIVALS: Record<string, string> = {
+  '01-01': '春节',
+  '01-15': '元宵节',
+  '05-05': '端午节',
+  '07-07': '七夕节',
+  '07-15': '中元节',
+  '08-15': '中秋节',
+  '09-09': '重阳节',
+  '12-08': '腊八节'
+}
+
+const LUNAR_MONTH_NAMES = [
+  '',
+  '正月',
+  '二月',
+  '三月',
+  '四月',
+  '五月',
+  '六月',
+  '七月',
+  '八月',
+  '九月',
+  '十月',
+  '十一月',
+  '腊月'
+]
+
+const LUNAR_DAY_NAMES = [
+  '',
+  '初一',
+  '初二',
+  '初三',
+  '初四',
+  '初五',
+  '初六',
+  '初七',
+  '初八',
+  '初九',
+  '初十',
+  '十一',
+  '十二',
+  '十三',
+  '十四',
+  '十五',
+  '十六',
+  '十七',
+  '十八',
+  '十九',
+  '二十',
+  '廿一',
+  '廿二',
+  '廿三',
+  '廿四',
+  '廿五',
+  '廿六',
+  '廿七',
+  '廿八',
+  '廿九',
+  '三十'
+]
+
+const CURRENT_DATE_REG = /^(\d{4})-(\d{2})-(\d{2})$/
+const DISPLAY_DATE_REG = /^(\d{4})-(\d{2})$/
+
+export const getWeekdayLabels = () => WEEKDAY_LABELS
+
+const pad = (value: number) => String(value).padStart(2, '0')
+
+export const formatDateValue = (date: Date) =>
+  `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+
+export const formatDisplayValue = (date: Date) =>
+  `${date.getFullYear()}-${pad(date.getMonth() + 1)}`
+
+export const getTodayDefaults = () => {
+  const today = new Date()
+  return {
+    currentDate: formatDateValue(today),
+    displayDate: formatDisplayValue(today)
+  }
+}
+
+const createDate = (year: number, month: number, day = 1) => new Date(year, month - 1, day)
+
+export const resolveCurrentDate = (value?: string) => {
+  const match = value?.match(CURRENT_DATE_REG)
+  if (!match) {
+    return new Date()
+  }
+
+  const year = Number(match[1])
+  const month = Number(match[2])
+  const day = Number(match[3])
+  const date = createDate(year, month, day)
+
+  if (date.getFullYear() !== year || date.getMonth() + 1 !== month || date.getDate() !== day) {
+    return new Date()
+  }
+
+  return date
+}
+
+export const resolveDisplayDate = (value?: string, fallbackDate?: Date) => {
+  const fallback = fallbackDate || new Date()
+  const match = value?.match(DISPLAY_DATE_REG)
+  if (!match) {
+    return {
+      year: fallback.getFullYear(),
+      month: fallback.getMonth() + 1
+    }
+  }
+
+  const year = Number(match[1])
+  const month = Number(match[2])
+  if (month < 1 || month > 12) {
+    return {
+      year: fallback.getFullYear(),
+      month: fallback.getMonth() + 1
+    }
+  }
+
+  return { year, month }
+}
+
+const getLunarMonthName = (value: string) => {
+  if (!value) return ''
+  if (value.includes('月')) return value
+  const num = Number(value.replace(/[^\d]/g, ''))
+  if (!num) return value
+  return LUNAR_MONTH_NAMES[num] || value
+}
+
+const getLunarDayName = (value: string) => {
+  if (!value) return ''
+  if (!/^\d+$/.test(value)) return value
+  const num = Number(value)
+  return LUNAR_DAY_NAMES[num] || value
+}
+
+const getLunarInfo = (date: Date) => {
+  try {
+    const formatter = new Intl.DateTimeFormat('zh-Hans-CN-u-ca-chinese', {
+      month: 'long',
+      day: 'numeric'
+    })
+    const parts = formatter.formatToParts(date)
+    const monthValue = getLunarMonthName(parts.find((item) => item.type === 'month')?.value || '')
+    const dayValue = getLunarDayName(parts.find((item) => item.type === 'day')?.value || '')
+
+    if (!monthValue || !dayValue) {
+      const text = formatter.format(date)
+      const match = text.match(/(闰?.+?月).*(初.|\d+)/)
+      return {
+        monthText: match?.[1] || '',
+        dayText: getLunarDayName(match?.[2] || '')
+      }
+    }
+
+    return {
+      monthText: monthValue,
+      dayText: dayValue
+    }
+  } catch {
+    return {
+      monthText: '',
+      dayText: ''
+    }
+  }
+}
+
+export const getLunarLabel = (date: Date) => {
+  const solarKey = `${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
+  if (SOLAR_FESTIVALS[solarKey]) {
+    return SOLAR_FESTIVALS[solarKey]
+  }
+
+  const lunarInfo = getLunarInfo(date)
+  const nextDate = new Date(date)
+  nextDate.setDate(date.getDate() + 1)
+  const nextLunarInfo = getLunarInfo(nextDate)
+
+  if (
+    lunarInfo.monthText === '腊月' &&
+    nextLunarInfo.monthText === '正月' &&
+    nextLunarInfo.dayText === '初一'
+  ) {
+    return '除夕'
+  }
+
+  const monthIndex = LUNAR_MONTH_NAMES.findIndex((item) => item === lunarInfo.monthText)
+  const dayIndex = LUNAR_DAY_NAMES.findIndex((item) => item === lunarInfo.dayText)
+  const lunarKey = monthIndex > 0 && dayIndex > 0 ? `${pad(monthIndex)}-${pad(dayIndex)}` : ''
+
+  if (lunarKey && LUNAR_FESTIVALS[lunarKey]) {
+    return LUNAR_FESTIVALS[lunarKey]
+  }
+
+  if (lunarInfo.dayText === '初一') {
+    return lunarInfo.monthText
+  }
+
+  return lunarInfo.dayText
+}
+
+export const buildCalendarCells = (displayDate?: string, currentDate?: string): CalendarCell[] => {
+  const selectedDate = resolveCurrentDate(currentDate)
+  const { year, month } = resolveDisplayDate(displayDate, selectedDate)
+
+  const monthStart = createDate(year, month, 1)
+  const start = new Date(monthStart)
+  start.setDate(1 - monthStart.getDay())
+
+  return Array.from({ length: 42 }).map((_, index) => {
+    const date = new Date(start)
+    date.setDate(start.getDate() + index)
+
+    return {
+      key: formatDateValue(date),
+      date,
+      day: date.getDate(),
+      inMonth: date.getMonth() === monthStart.getMonth(),
+      isCurrentDate: formatDateValue(date) === formatDateValue(selectedDate),
+      lunarLabel: getLunarLabel(date)
+    }
+  })
+}
+
+export const formatTitle = (displayDate?: string, currentDate?: string) => {
+  const selectedDate = resolveCurrentDate(currentDate)
+  const { year, month } = resolveDisplayDate(displayDate, selectedDate)
+  return `${year} ${MONTH_LABELS[month - 1]}`
+}

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

@@ -8,6 +8,7 @@ import Textarea from './textarea'
 import Keyboard from './keyboard'
 import Animimg from './animimg'
 import Dropdown from './dropdown/index'
+import Calendar from './calendar/index'
 import Checkbox from './checkbox'
 import Switch from './switch'
 import Bar from './bar'
@@ -71,7 +72,9 @@ export const ComponentArray = [
   Spinbox,
   Keyboard,
 
-  Animimg
+  Animimg,
+
+  Calendar
 ] as IComponentModelConfig[]
 
 const componentMap: { [key: string]: IComponentModelConfig } = ComponentArray.reduce((acc, cur) => {

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

@@ -106,6 +106,14 @@
       >
         <MonacoEditor v-model="value" v-bind="schema?.componentProps" />
       </div>
+
+      <!-- 日期 -->
+      <el-date-picker
+        v-if="schema.valueType === 'date'"
+        v-model="value"
+        style="width: 100%"
+        v-bind="schema?.componentProps"
+      />
     </el-form-item>
 
     <!-- 分组 -->

+ 1 - 1
src/renderer/src/views/designer/config/property/components/StylePart.vue

@@ -73,7 +73,7 @@ const partOptions = computed(() => {
       return typeof fn === 'function'
         ? fn(item, props.widgetData)
         : { label: item.name, value: item.name }
-    }) || []
+    })?.filter(Boolean) || []
   )
 })