소스 검색

feat: 变量属性创建、绑定

jiaxing.liao 1 개월 전
부모
커밋
0f8d7c095f

+ 165 - 0
.agent/SKILL.md

@@ -0,0 +1,165 @@
+# LVGL Designer 技能文档
+
+## 项目概述
+
+**LVGL Designer** 是一个基于 LVGL 9.3 的图形化设计器,采用 Electron + Vue 3 + TypeScript 技术栈构建。该工具提供可视化的 UI 设计界面,支持拖拽式组件布局,可生成 LVGL 代码用于嵌入式 GUI 开发。
+
+## 核心功能
+
+### 1. 可视化设计器
+
+- 画布拖拽操作,支持组件自由摆放
+- 组件属性实时编辑
+- 多页面管理与切换
+- 层级结构查看与管理
+
+### 2. 组件库
+
+提供丰富的 LVGL 原生组件及自定义组件
+
+### 3. 属性配置
+
+- 位置与尺寸设置
+- 背景与边框样式
+- 字体与颜色配置
+- 状态与标志位管理
+- 动画效果配置
+
+### 4. 资源管理
+
+- 图片资源导入与管理
+- 字体资源管理
+- 多语言支持
+- 主题配置
+
+### 5. 代码生成
+
+- 支持生成 C/C++ 代码
+- 支持生成项目模板
+- 代码预览与导出
+
+## 技术栈
+
+- **框架**: Vue 3.5 + TypeScript
+- **UI 框架**: Element Plus + Reka UI
+- **构建工具**: Electron Vite 7.1
+- **状态管理**: Pinia
+- **路由**: Vue Router
+- **国际化**: Vue I18n
+- **编辑器**: Monaco Editor
+- **动画**: Vue3 Moveable
+
+## 项目结构
+
+```
+src/
+├── main/                    # 主进程代码
+│   ├── client.ts            # 客户端通信
+│   ├── files.ts             # 文件操作
+│   ├── pipe-server.ts       # 管道服务
+│   └── index.ts             # 入口文件
+├── preload/                 # 预加载脚本
+└── renderer/                # 渲染进程
+    └── src/
+        ├── components/      # 公共组件
+        ├── lvgl-widgets/    # LVGL 组件定义
+        ├── views/           # 页面视图
+        │   └── designer/    # 设计器主界面
+        │       ├── config/   # 属性配置面板
+        │       ├── sidebar/  # 侧边栏
+        │       ├── tools/    # 工具栏
+        │       └── workspace/# 工作区
+        ├── store/           # 状态管理
+        ├── types/           # 类型定义
+        └── utils/           # 工具函数
+```
+
+## 开发指南
+
+### 环境要求
+
+- Node.js >= 20.x
+- pnpm >= 8.x
+
+### 安装依赖
+
+```bash
+pnpm install
+```
+
+### 开发模式
+
+```bash
+pnpm dev
+```
+
+### 构建命令
+
+```bash
+# Windows
+pnpm build:win
+
+# macOS
+pnpm build:mac
+
+# Linux
+pnpm build:linux
+```
+
+### 代码检查
+
+```bash
+# 格式化
+pnpm format
+
+# 代码检查
+pnpm lint
+
+# 类型检查
+pnpm typecheck
+```
+
+## 核心模块说明
+
+### 1. 状态管理 (store)
+
+- `app.ts`: 应用全局状态
+- `project.ts`: 项目状态管理
+- `action.ts`: 操作历史记录
+- `pipe.ts`: 管道通信状态
+
+### 2. 组件系统
+
+每个 LVGL 组件包含:
+
+- 组件定义文件 (`*.vue`)
+- 样式配置 (`style.json`)
+- 导出模块 (`index.ts`)
+
+### 3. 属性配置
+
+配置面板支持:
+
+- 基础属性(位置、尺寸、ID)
+- 样式属性(背景、边框、字体)
+- 状态属性(标志位、状态列表)
+- 动画配置(缓动函数、次数)
+
+## 扩展能力
+
+### 添加新组件
+
+1. 在 `src/renderer/src/lvgl-widgets/` 下创建组件目录
+2. 创建组件 Vue 文件和样式配置
+3. 在 `index.ts` 中注册组件
+
+### 自定义主题
+
+通过主题配置文件自定义颜色方案和样式
+
+## 注意事项
+
+- 组件 ID 需唯一,避免冲突
+- 画布操作支持撤销/重做
+- 项目文件使用 JSON5 格式存储
+- 支持多语言切换(中文/英文)

+ 8 - 12
src/renderer/src/constants/index.ts

@@ -80,18 +80,14 @@ export const stateList = [
  */
 
 export const variableType = [
-  { label: 'STRING', value: 'LV_STRING' },
-  { label: 'INT', value: 'LV_INT' },
-  { label: 'FLOAT', value: 'LV_FLOAT' },
-  { label: 'DOUBLE', value: 'LV_DOUBLE' },
-  { label: 'DECIMAL', value: 'LV_DECIMAL' },
-  { label: 'DATE', value: 'LV_DATE' },
-  { label: 'TIME', value: 'LV_TIME' },
-  { label: 'DATETIME', value: 'LV_DATETIME' },
-  { label: 'BOOLEAN', value: 'LV_BOOLEAN' },
-  { label: 'LIST<T>', value: 'LV_LIST<T>' },
-  { label: 'OBJECT', value: 'LV_OBJECT' },
-  { label: 'BYTE_ARRAY', value: 'LV_BYTE_ARRAY' }
+  { label: 'bool', value: 'bool' },
+  { label: 'char', value: 'char' },
+  { label: 'uint8_t', value: 'uint8_t' },
+  { label: 'uint16_t', value: 'uint16_t' },
+  { label: 'int16_t', value: 'int16_t' },
+  { label: 'uint32_t', value: 'uint32_t' },
+  { label: 'int32_t', value: 'int32_t' },
+  { label: 'enum', value: 'enum' }
 ]
 
 /**

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

@@ -170,5 +170,10 @@
   "Meter": "Meter",
   "vectorFont": "Vector Font",
   "fontPackaging": "Font Packging",
-  "fontSize": "Font Size"
+  "fontSize": "Font Size",
+  "pageVariables": "Page Variables",
+  "globalVariables": "Global Variables",
+  "variableName": "Variable Name",
+  "variableType": "Variable Type",
+  "initialValue": "Initial Value"
 }

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

@@ -169,5 +169,10 @@
   "Meter": "仪表",
   "vectorFont": "矢量字库",
   "fontPackaging": "字体存储",
-  "fontSize": "字号"
+  "fontSize": "字号",
+  "pageVariables": "页面变量",
+  "globalVariables": "全局变量",
+  "variableName": "变量名",
+  "variableType": "变量类型",
+  "initialValue": "初始值"
 }

+ 31 - 6
src/renderer/src/lvgl-widgets/button/index.ts

@@ -131,7 +131,10 @@ export default {
               span: 12
             },
             slots: { prefix: 'X' },
-            canUseEventSet: true
+            canUseEventSet: true,
+            variableConfig: {
+              type: 'int32_t'
+            }
           },
           {
             field: 'props.y',
@@ -140,7 +143,10 @@ export default {
               span: 12
             },
             slots: { prefix: 'Y' },
-            canUseEventSet: true
+            canUseEventSet: true,
+            variableConfig: {
+              type: 'int32_t'
+            }
           },
           {
             field: 'props.width',
@@ -149,7 +155,10 @@ export default {
               span: 12
             },
             slots: { prefix: 'W' },
-            canUseEventSet: true
+            canUseEventSet: true,
+            variableConfig: {
+              type: 'int32_t'
+            }
           },
           {
             field: 'props.height',
@@ -158,7 +167,10 @@ export default {
               span: 12
             },
             slots: { prefix: 'H' },
-            canUseEventSet: true
+            canUseEventSet: true,
+            variableConfig: {
+              type: 'int32_t'
+            }
           }
         ]
       },
@@ -194,7 +206,10 @@ export default {
           rows: 3,
           supportLangues: true
         },
-        canUseEventSet: true
+        canUseEventSet: true,
+        variableConfig: {
+          type: 'char'
+        }
       },
       {
         label: '模式',
@@ -209,7 +224,17 @@ export default {
             { label: 'Wrap', value: 'wrap' }
           ]
         },
-        canUseEventSet: true
+        canUseEventSet: true,
+        variableConfig: {
+          type: 'enum',
+          enumMap: {
+            0: 'wrap',
+            1: 'dot',
+            2: 'scroll',
+            3: 'circular',
+            4: 'clip'
+          }
+        }
       },
       {
         label: '静态文本',

+ 9 - 0
src/renderer/src/lvgl-widgets/type.d.ts

@@ -122,6 +122,15 @@ export interface IComponentModelConfig {
      */
     styles?: ComponentSchema[]
   }
+  /**
+   * 变量配置
+   */
+  variableConfig?: {
+    /** 支持类型 */
+    type: VariableType
+    /** 枚举map */
+    enumMap?: Record<string, any>
+  }
   /**
    * 宽高触发事件
    * @param props

+ 2 - 2
src/renderer/src/types/page.d.ts

@@ -1,5 +1,5 @@
 import { BaseWidget } from './baseWidget'
-import { VariableGroup } from './variables'
+import { Variable, VariableGroup } from './variables'
 import { WidgetEvent } from './event'
 
 export type ReferenceLine = {
@@ -22,6 +22,6 @@ export type Page = {
   props: Record<string, any>
   style: Record<string, any>[]
   events: WidgetEvent[]
-  variables: VariableGroup[]
+  variables: Variable[]
   children: BaseWidget[]
 }

+ 28 - 8
src/renderer/src/types/variables.d.ts

@@ -1,19 +1,39 @@
-export type Variable = {
+export type VariableType =
+  | 'bool'
+  | 'char'
+  | 'uint8_t'
+  | 'uint16_t'
+  | 'int16_t'
+  | 'uint32_t'
+  | 'int32_t'
+  | 'enum'
+
+// 变量组
+export type VariableGroup = {
   // ID
   id: string
   // 名称
   name: string
-  // 值
-  value: any
-  // 数据类型
-  type: string
+  // 变量
+  variables: Variable[]
 }
 
-export type VariableGroup = {
+// 变量
+export type Variable = {
   // ID
   id: string
   // 名称
   name: string
-  // 变量
-  variables: Variable[]
+  // 值
+  value: any
+  // 数据类型
+  type: VariableType
+}
+
+// 变量绑定
+export type VariableBinding = {
+  // 变量ID
+  varId: string
+  // 原始值
+  originValue: any
 }

+ 160 - 0
src/renderer/src/utils/variableBinding.ts

@@ -0,0 +1,160 @@
+import type { VariableBinding } from '@/types/variables'
+import type { Variable, VariableGroup } from '@/types/variables'
+import type { BaseWidget } from '@/types/baseWidget'
+
+/**
+ * 判断属性值是否绑定了变量
+ */
+export function isVariableBound(value: any): value is VariableBinding {
+  return typeof value === 'object' && 'varId' in value
+}
+
+/**
+ * 获取属性的实际显示值
+ * 如果绑定了变量,返回变量的当前值;否则返回原值
+ */
+export function getPropertyDisplayValue(propValue: any, allVariables: VariableGroup[]): any {
+  if (!isVariableBound(propValue)) {
+    return propValue
+  }
+
+  const variable = findVariableById(propValue.varId, allVariables)
+  return variable ? variable.value : propValue.originValue
+}
+
+/**
+ * 绑定变量到属性
+ */
+export function bindVariableToProperty(propValue: any, varId: string): VariableBinding {
+  return {
+    varId,
+    originValue: propValue
+  }
+}
+
+/**
+ * 解绑属性变量,恢复原始值
+ */
+export function unbindPropertyVariable(boundValue: VariableBinding): any {
+  return boundValue.originValue
+}
+
+/**
+ * 根据ID查找变量
+ */
+export function findVariableById(varId: string, allVariables: VariableGroup[]): Variable | null {
+  for (const group of allVariables) {
+    const found = group.variables.find((v) => v.id === varId)
+    if (found) return found
+  }
+  return null
+}
+
+/**
+ * 获取所有变量(全局 + 当前页面)
+ */
+export function getAllVariables(
+  projectVariables: VariableGroup[],
+  pageVariables?: Variable[]
+): VariableGroup[] {
+  const allVars = [...projectVariables]
+  if (pageVariables && pageVariables.length > 0) {
+    allVars.push({
+      id: 'page-vars',
+      name: '页面变量',
+      variables: pageVariables
+    })
+  }
+  return allVars
+}
+
+/**
+ * 查找使用某个变量的所有控件属性
+ */
+export function findVariableUsages(
+  varId: string,
+  widgets: BaseWidget[]
+): Array<{ widgetId: string; widgetName: string; fieldPath: string }> {
+  const usages: Array<{ widgetId: string; widgetName: string; fieldPath: string }> = []
+
+  function traverse(widget: BaseWidget, parentPath: string = '') {
+    const widgetPath = parentPath ? `${parentPath}.${widget.name}` : widget.name
+
+    if (widget.props) {
+      for (const [key, value] of Object.entries(widget.props)) {
+        const fieldPath = `${widgetPath}.props.${key}`
+
+        if (isVariableBound(value) && value.varId === varId) {
+          usages.push({
+            widgetId: widget.id,
+            widgetName: widget.name,
+            fieldPath
+          })
+        }
+      }
+    }
+
+    if (widget.children) {
+      widget.children.forEach((child) => traverse(child, widgetPath))
+    }
+  }
+
+  widgets.forEach((widget) => traverse(widget))
+  return usages
+}
+
+/**
+ * 自动解绑指定变量的所有引用
+ */
+export function unbindAllVariableReferences(varId: string, widgets: BaseWidget[]): void {
+  function traverse(widget: BaseWidget) {
+    if (widget.props) {
+      for (const [key, value] of Object.entries(widget.props)) {
+        if (isVariableBound(value) && value.varId === varId) {
+          widget.props[key] = value.originValue
+        }
+      }
+    }
+
+    if (widget.children) {
+      widget.children.forEach((child) => traverse(child))
+    }
+  }
+
+  widgets.forEach((widget) => traverse(widget))
+}
+
+/**
+ * 获取嵌套对象属性的值
+ */
+export function getNestedValue(obj: any, path: string): any {
+  const keys = path.split('.')
+  let result = obj
+
+  for (const key of keys) {
+    if (result === undefined || result === null) {
+      return undefined
+    }
+    result = result[key]
+  }
+
+  return result
+}
+
+/**
+ * 设置嵌套对象属性的值
+ */
+export function setNestedValue(obj: any, path: string, value: any): void {
+  const keys = path.split('.')
+  let current = obj
+
+  for (let i = 0; i < keys.length - 1; i++) {
+    const key = keys[i]
+    if (!(key in current)) {
+      current[key] = {}
+    }
+    current = current[key]
+  }
+
+  current[keys[keys.length - 1]] = value
+}

+ 0 - 809
src/renderer/src/views/designer/config/PropertyConfig.vue

@@ -1,809 +0,0 @@
-<template>
-  <el-scrollbar height="calc(100vh - 130px)">
-    <el-form label-position="top" class="h-full">
-      <SplitterCollapse>
-        <SplitterCollapseItem title="组件属性" class="flex-none">
-          <el-collapse v-model="activeNames" class="p-10px">
-            <el-scrollbar
-              :height="
-                data.type && LvglWidgets[data.type].config.props && data.type != 'page'
-                  ? 'calc(100vh - 280px)'
-                  : ''
-              "
-            >
-              <template
-                v-for="prop in data.type && LvglWidgets[data.type].config.props"
-                :key="prop.field"
-              >
-                <!-- 判断是否为 group 类型 -->
-                <template v-if="prop.valueType === 'group'">
-                  <el-collapse-item :title="prop.label" :name="prop.label">
-                    <div
-                      v-for="child in prop.children"
-                      :key="child.field"
-                      style="flex: 0 0 calc(50% - 5px)"
-                    >
-                      <el-form-item :label="child.label" class="flex w-full">
-                        <input-number
-                          v-if="child.valueType === 'number'"
-                          v-model="data.props[child.field]"
-                          :min="0"
-                          :placeholder="child.componentProps.placeholder"
-                          :readonly="child.componentProps.readOnly"
-                          :disabled="child.componentProps.disabled"
-                          style="width: 100%"
-                          :step="0.1"
-                        />
-                        <el-input
-                          v-if="child.valueType === 'text' && child.field === 'name'"
-                          v-model="data[child.field]"
-                          :placeholder="child.componentProps.placeholder"
-                          :readonly="child.componentProps.readOnly"
-                          :disabled="child.componentProps.disabled"
-                        />
-                        <el-input
-                          v-if="child.valueType === 'text' && child.field !== 'name'"
-                          v-model="data.props[child.field]"
-                          :placeholder="child.componentProps.placeholder"
-                          :readonly="child.componentProps.readOnly"
-                          :disabled="child.componentProps.disabled"
-                        />
-                        <el-select
-                          v-if="child.valueType === 'select'"
-                          v-model="data.props[child.field]"
-                          :placeholder="child.componentProps.placeholder"
-                          :readonly="child.componentProps.readOnly"
-                          :disabled="child.componentProps.disabled"
-                        >
-                          <el-option
-                            v-for="select in child.componentProps.options"
-                            :key="select.value"
-                            :label="select.label"
-                            :value="select.value"
-                          />
-                        </el-select>
-                        <!-- <div class="chart-container">
-                        <div class="chart-placeholder">xxx</div>
-                      </div> -->
-                      </el-form-item>
-                    </div>
-                  </el-collapse-item>
-                </template>
-
-                <!-- 多选平铺 -->
-                <template
-                  v-else-if="
-                    prop.valueType === 'checkbox' &&
-                    ['addFlags', 'removeFlags'].includes(prop.field)
-                  "
-                >
-                  <el-collapse-item :title="prop.label" :name="prop.label">
-                    <div class="flex flex-wrap gap-10px mb-10px">
-                      <div
-                        v-for="addFlags in prop.componentProps?.options"
-                        :key="addFlags.value"
-                        :class="[
-                          'bg-#333333',
-                          'h-[30px]',
-                          'rounded-[5px]',
-                          'line-height-[30px]',
-                          'text-center',
-                          'cursor-pointer',
-                          { 'bg-[var(--accent-blue)]': isActive(prop, addFlags) }
-                        ]"
-                        style="flex: 0 0 calc(50% - 5px)"
-                        @click="handleClick(prop, addFlags)"
-                      >
-                        {{ addFlags.label }}
-                      </div>
-                    </div>
-                  </el-collapse-item>
-                </template>
-
-                <!-- 非 group 类型的字段 -->
-                <template v-else>
-                  <el-form-item :label="prop.label">
-                    <!-- 文本类型字段 -->
-                    <el-input
-                      v-if="prop.valueType === 'text' && prop.field === 'name'"
-                      v-model="data[prop.field]"
-                      :placeholder="prop.componentProps?.placeholder"
-                    />
-                    <el-input
-                      v-if="prop.valueType === 'text' && prop.field !== 'name'"
-                      v-model="data.props[prop.field]"
-                      :placeholder="prop.componentProps?.placeholder"
-                    />
-                    <!-- 数字类型字段 -->
-                    <input-number
-                      v-if="prop.valueType === 'number'"
-                      v-model="data.props[prop.field]"
-                      style="width: 100%"
-                      :min="0"
-                      :step="0.1"
-                    />
-                    <!-- 下拉类型字段 -->
-                    <el-select
-                      v-if="prop.valueType === 'select'"
-                      v-model="data.props[prop.field]"
-                      :placeholder="prop.componentProps?.placeholder"
-                      :readonly="prop.componentProps?.readOnly"
-                      :disabled="prop.componentProps?.disabled"
-                    >
-                      <el-option
-                        v-for="select in prop.componentProps?.options"
-                        :key="select.value"
-                        :label="select.label"
-                        :value="select.value"
-                      />
-                    </el-select>
-                    <!-- <div class="chart-container">
-                    <div class="chart-placeholder"></div>
-                  </div> -->
-                  </el-form-item>
-                </template>
-              </template>
-            </el-scrollbar>
-          </el-collapse>
-        </SplitterCollapseItem>
-
-        <!--  -->
-
-        <SplitterCollapseItem title="样式配置" class="flex-none">
-          <el-collapse v-model="activeNames" class="p-10px">
-            <el-scrollbar
-              :height="
-                data.type && LvglWidgets[data.type].config.styles && data.type != 'page'
-                  ? 'calc(100vh - 280px)'
-                  : ''
-              "
-            >
-              <template
-                v-for="prop in data.type && LvglWidgets[data.type].config.styles"
-                :key="prop.field"
-              >
-                <el-form-item v-if="prop.field === 'part'" :label="prop.label" class="flex w-full">
-                  <div class="flex flex-1 justify-between items-center gap-10px">
-                    <!-- 模块 -->
-                    <el-select v-model="moduleValue" @change="handlePartChange">
-                      <el-option
-                        v-for="part in LvglWidgets[data.type].parts"
-                        :key="part.name"
-                        :label="part.name"
-                        :value="part.name"
-                      />
-                    </el-select>
-
-                    <!-- 状态 -->
-                    <el-select v-model="stateValue" @change="handleStateChange">
-                      <el-option
-                        v-for="state in getStateList(moduleValue)"
-                        :key="state"
-                        :label="state"
-                        :value="state"
-                      />
-                    </el-select>
-                    <!-- 控制区域 -->
-                    <el-icon style="width: 60px" class="cursor-pointer" @click="handleVisibleStyle"
-                      ><View
-                    /></el-icon>
-                  </div>
-                </el-form-item>
-              </template>
-              <!-- 样式配置区域 -->
-              <template v-if="visibleStyle">
-                <template
-                  v-for="prop in data.type && LvglWidgets[data.type].config.styles"
-                  :key="prop.field"
-                >
-                  <!-- 固定依赖 -->
-                  <el-form-item
-                    v-if="prop.field !== 'part'"
-                    :label="prop.label"
-                    class="flex w-full"
-                  >
-                    <!-- 背景颜色 -->
-                    <template v-if="prop.field === 'background'">
-                      <template
-                        v-if="data.style?.[styleIndex]?.[prop.field]?.hasOwnProperty('color')"
-                      >
-                        <div class="flex [flex-direction:column] flex-1">
-                          <div class="flex flex-1">
-                            <el-color-picker
-                              v-model="data.style[styleIndex][prop.field].color"
-                              :show-alpha="true"
-                              :predefine="predefineColors"
-                            />
-                            <div class="ml-10px">
-                              {{ data.style[styleIndex][prop.field].color }}
-                            </div>
-                          </div>
-
-                          <template
-                            v-if="data.style?.[styleIndex]?.[prop.field]?.hasOwnProperty('image')"
-                          >
-                            <div class="flex flex-1 mt-10px">
-                              <div class="w-32px h-32px bg-#cccc mr-10px"></div>
-                              <el-color-picker
-                                v-model="data.style[styleIndex][prop.field].image.color"
-                                :show-alpha="true"
-                                :predefine="predefineColors"
-                              />
-                              <div class="ml-10px">
-                                {{ data.style[styleIndex][prop.field].image.color }}
-                              </div>
-                            </div>
-                          </template>
-                        </div>
-                      </template>
-                    </template>
-
-                    <!-- 字体 -->
-                    <div v-if="prop.field === 'text'" class="w-full">
-                      <div class="flex flex-1 gap-10px mb-10px">
-                        <el-color-picker
-                          v-model="data.style[styleIndex][prop.field].color"
-                          :show-alpha="true"
-                          :predefine="predefineColors"
-                        />
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].size"
-                          :min="0"
-                          placeholder="字体大小"
-                          class="flex-1"
-                        />
-                      </div>
-                      <div class="flex flex-1 gap-10px mb-10px">
-                        <el-select
-                          v-model="data.style[styleIndex][prop.field].weight"
-                          placeholder="字体加粗"
-                        >
-                          <el-option
-                            v-for="weight in textWeight"
-                            :key="weight.value"
-                            :label="weight.label"
-                            :value="weight.value"
-                          />
-                        </el-select>
-                        <el-select v-model="data.style[styleIndex][prop.field].family"></el-select>
-                      </div>
-                      <el-select
-                        v-model="data.style[styleIndex][prop.field].align"
-                        placeholder="对齐方式"
-                      >
-                        <el-option
-                          v-for="align in textAlign"
-                          :key="align.value"
-                          :label="align.label"
-                          :value="align.value"
-                        />
-                      </el-select>
-                    </div>
-
-                    <!-- 边框 -->
-                    <div v-if="prop.field === 'border'" class="w-full">
-                      <div class="flex flex-1 gap-10px mb-10px">
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].width"
-                          placeholder="边框大小"
-                          class="flex-1"
-                          :min="0"
-                        />
-                        <el-color-picker
-                          v-model="data.style[styleIndex][prop.field].color"
-                          :show-alpha="true"
-                          :predefine="predefineColors"
-                        />
-                      </div>
-                      <div class="flex flex-1 gap-10px">
-                        <el-select
-                          v-model="data.style[styleIndex][prop.field].side"
-                          multiple
-                          collapse-tags
-                          class="flex-1"
-                        >
-                          <el-option
-                            v-for="sides in borderSides"
-                            :key="sides.value"
-                            :label="sides.label"
-                            :value="sides.value"
-                          />
-                        </el-select>
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].radius"
-                          placeholder="圆角大小"
-                          class="flex-1"
-                          :min="0"
-                        />
-                      </div>
-                    </div>
-                    <!-- 阴影 -->
-                    <div v-if="prop.field === 'shadow'" class="w-full">
-                      <el-color-picker
-                        v-model="data.style[styleIndex][prop.field].color"
-                        :show-alpha="true"
-                        :predefine="predefineColors"
-                        class="mb-10px"
-                      />
-                      <div class="flex flex-1 gap-10px mb-10px">
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].x"
-                          placeholder="水平偏移"
-                          class="flex-1"
-                          :min="0"
-                        />
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].y"
-                          placeholder="垂直偏移"
-                          class="flex-1"
-                          :min="0"
-                        />
-                      </div>
-                      <div class="flex flex-1 gap-10px">
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].width"
-                          placeholder="模糊半径"
-                          class="flex-1"
-                          :min="0"
-                        />
-
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].spread"
-                          placeholder="阴影扩展半径"
-                          class="flex-1"
-                          :min="0"
-                        />
-                      </div>
-                    </div>
-                    <!-- 内边距 -->
-                    <div v-if="prop.field === 'padding'" class="w-full">
-                      <div class="flex flex-1 gap-10px mb-10px">
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].top"
-                          placeholder="内部上边距"
-                          class="flex-1"
-                          :min="0"
-                        />
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].right"
-                          placeholder="内部右边距"
-                          class="flex-1"
-                          :min="0"
-                        />
-                      </div>
-                      <div class="flex flex-1 gap-10px">
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].bottom"
-                          placeholder="内部下边距"
-                          class="flex-1"
-                          :min="0"
-                        />
-
-                        <input-number
-                          v-model="data.style[styleIndex][prop.field].left"
-                          placeholder="内部左边距"
-                          class="flex-1"
-                          :min="0"
-                        />
-                      </div>
-                    </div>
-                    <!-- 间距 -->
-                    <div v-if="prop.field === 'gap'" class="w-full flex flex-1 gap-10px">
-                      <input-number
-                        v-model="data.style[styleIndex][prop.field].row"
-                        placeholder="横向"
-                        class="flex-1"
-                        :min="0"
-                      />
-
-                      <input-number
-                        v-model="data.style[styleIndex][prop.field].column"
-                        placeholder="纵向"
-                        class="flex-1"
-                        :min="0"
-                      />
-                    </div>
-                  </el-form-item>
-
-                  <!-- 动态显示依赖项 -->
-                  <template v-if="prop.valueType === 'dependency'">
-                    <el-form-item
-                      v-for="dependency in getDynamicFields(prop)"
-                      :key="dependency.field"
-                      :label="dependency.label"
-                    >
-                      <!-- 背景颜色 -->
-                      <template v-if="dependency.field === 'background'">
-                        <template
-                          v-if="
-                            data.style?.[styleIndex]?.[dependency.field]?.hasOwnProperty('color')
-                          "
-                        >
-                          <div class="flex [flex-direction:column] flex-1">
-                            <div class="flex flex-1">
-                              <el-color-picker
-                                v-model="data.style[styleIndex][dependency.field].color"
-                                :show-alpha="true"
-                                :predefine="predefineColors"
-                              />
-                              <div class="ml-10px">
-                                {{ data.style[styleIndex][dependency.field].color }}
-                              </div>
-                            </div>
-
-                            <template
-                              v-if="
-                                data.style?.[styleIndex]?.[dependency.field]?.hasOwnProperty(
-                                  'image'
-                                )
-                              "
-                            >
-                              <div class="flex flex-1 mt-10px">
-                                <div class="w-32px h-32px bg-#cccc mr-10px"></div>
-                                <el-color-picker
-                                  v-model="data.style[styleIndex][dependency.field].image.color"
-                                  :show-alpha="true"
-                                  :predefine="predefineColors"
-                                />
-                                <div class="ml-10px">
-                                  {{ data.style[styleIndex][dependency.field].image.color }}
-                                </div>
-                              </div>
-                            </template>
-                          </div>
-                        </template>
-                      </template>
-
-                      <!-- 字体 -->
-                      <div v-if="dependency.field === 'text'" class="w-full">
-                        <div class="flex flex-1 gap-10px mb-10px">
-                          <el-color-picker
-                            v-model="data.style[styleIndex][dependency.field].color"
-                            :show-alpha="true"
-                            :predefine="predefineColors"
-                          />
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].size"
-                            :min="0"
-                            placeholder="字体大小"
-                            class="flex-1"
-                          />
-                        </div>
-                        <div class="flex flex-1 gap-10px mb-10px">
-                          <el-select
-                            v-model="data.style[styleIndex][dependency.field].weight"
-                            placeholder="字体加粗"
-                          >
-                            <el-option
-                              v-for="weight in textWeight"
-                              :key="weight.value"
-                              :label="weight.label"
-                              :value="weight.value"
-                            />
-                          </el-select>
-                          <el-select
-                            v-model="data.style[styleIndex][dependency.field].family"
-                          ></el-select>
-                        </div>
-                        <el-select
-                          v-model="data.style[styleIndex][dependency.field].align"
-                          placeholder="对齐方式"
-                        >
-                          <el-option
-                            v-for="align in textAlign"
-                            :key="align.value"
-                            :label="align.label"
-                            :value="align.value"
-                          />
-                        </el-select>
-                      </div>
-
-                      <!-- 边框 -->
-                      <div v-if="dependency.field === 'border'" class="w-full">
-                        <div class="flex flex-1 gap-10px mb-10px">
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].width"
-                            placeholder="边框大小"
-                            class="flex-1"
-                            :min="0"
-                          />
-                          <el-color-picker
-                            v-model="data.style[styleIndex][dependency.field].color"
-                            :show-alpha="true"
-                            :predefine="predefineColors"
-                          />
-                        </div>
-                        <div class="flex flex-1 gap-10px">
-                          <el-select
-                            v-model="data.style[styleIndex][dependency.field].side"
-                            multiple
-                            collapse-tags
-                            class="flex-1"
-                          >
-                            <el-option
-                              v-for="sides in borderSides"
-                              :key="sides.value"
-                              :label="sides.label"
-                              :value="sides.value"
-                            />
-                          </el-select>
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].radius"
-                            placeholder="圆角大小"
-                            class="flex-1"
-                            :min="0"
-                          />
-                        </div>
-                      </div>
-                      <!-- 阴影 -->
-                      <div v-if="dependency.field === 'shadow'" class="w-full">
-                        <el-color-picker
-                          v-model="data.style[styleIndex][dependency.field].color"
-                          :show-alpha="true"
-                          :predefine="predefineColors"
-                          class="mb-10px"
-                        />
-                        <div class="flex flex-1 gap-10px mb-10px">
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].x"
-                            placeholder="水平偏移"
-                            class="flex-1"
-                            :min="0"
-                          />
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].y"
-                            placeholder="垂直偏移"
-                            class="flex-1"
-                            :min="0"
-                          />
-                        </div>
-                        <div class="flex flex-1 gap-10px">
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].width"
-                            placeholder="模糊半径"
-                            class="flex-1"
-                            :min="0"
-                          />
-
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].spread"
-                            placeholder="阴影扩展半径"
-                            class="flex-1"
-                            :min="0"
-                          />
-                        </div>
-                      </div>
-                      <!-- 内边距 -->
-                      <div v-if="dependency.field === 'padding'" class="w-full">
-                        <div class="flex flex-1 gap-10px mb-10px">
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].top"
-                            placeholder="内部上边距"
-                            class="flex-1"
-                            :min="0"
-                          />
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].right"
-                            placeholder="内部右边距"
-                            class="flex-1"
-                            :min="0"
-                          />
-                        </div>
-                        <div class="flex flex-1 gap-10px">
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].bottom"
-                            placeholder="内部下边距"
-                            class="flex-1"
-                            :min="0"
-                          />
-
-                          <input-number
-                            v-model="data.style[styleIndex][dependency.field].left"
-                            placeholder="内部左边距"
-                            class="flex-1"
-                            :min="0"
-                          />
-                        </div>
-                      </div>
-                      <!-- 间距 -->
-                      <div v-if="dependency.field === 'gap'" class="w-full flex flex-1 gap-10px">
-                        <input-number
-                          v-model="data.style[styleIndex][dependency.field].row"
-                          placeholder="横向"
-                          class="flex-1"
-                          :min="0"
-                        />
-
-                        <input-number
-                          v-model="data.style[styleIndex][dependency.field].column"
-                          placeholder="纵向"
-                          class="flex-1"
-                          :min="0"
-                        />
-                      </div>
-                    </el-form-item>
-                  </template>
-                </template>
-              </template>
-            </el-scrollbar>
-          </el-collapse>
-        </SplitterCollapseItem>
-      </SplitterCollapse>
-    </el-form>
-  </el-scrollbar>
-</template>
-
-<script setup lang="ts">
-import { ref, watch, computed } from 'vue'
-import type { Page } from '@/types/page'
-import LvglWidgets from '@/lvgl-widgets'
-import { View } from '@element-plus/icons-vue'
-import { textAlign, textWeight, borderSides } from '@/constants'
-import { SplitterCollapse, SplitterCollapseItem } from '@/components/SplitterCollapse'
-
-interface Emits {
-  (e: 'update:selected', val: Page['props']): void
-}
-const props = defineProps<{
-  selected?: Page['props']
-}>()
-const emit = defineEmits<Emits>()
-const data = ref<Page['props']>(props.selected || ({} as Page['props']))
-watch(data, (val) => emit('update:selected', val))
-
-watch(
-  () => props.selected,
-  (value) => {
-    if (value) {
-      moduleValue.value = LvglWidgets[data.value.type]?.parts[0].name || 'main'
-      stateValue.value = LvglWidgets[data.value.type]?.parts[0].stateList[0] || 'default'
-      data.value = value || ({} as Page['props'])
-    }
-  }
-)
-
-const activeNames = ref<string[]>(['基本属性', '位置/大小', '添加标识', '删除标识'])
-
-const moduleValue = ref('')
-const stateValue = ref('')
-const visibleStyle = ref(true)
-const predefineColors = [
-  '#ff4500',
-  '#ff8c00',
-  '#ffd700',
-  '#90ee90',
-  '#00ced1',
-  '#1e90ff',
-  '#c71585',
-  'rgba(255, 69, 0, 0.68)',
-  'rgb(255, 120, 0)',
-  'hsv(51, 100, 98)',
-  'hsva(120, 40, 94, 0.5)',
-  'hsl(181, 100%, 37%)',
-  'hsla(209, 100%, 56%, 0.73)',
-  '#c7158577'
-]
-
-const loadStyle = async (name) => {
-  try {
-    const Json = await import(`@/lvgl-widgets/${name}/style.json`)
-    return Json.default
-  } catch (error) {
-    console.error(error)
-  }
-}
-
-const styleIndex = computed(() => {
-  return data.value.style.findIndex(
-    (item) => item.part.name === moduleValue.value && item.part.state === stateValue.value
-  )
-})
-
-const getStateList = (partName) => {
-  const part = LvglWidgets[data.value.type].parts.find((item) => item.name === partName)
-  return part ? part.stateList : []
-}
-
-const isActive = (prop, addFlags) => {
-  return data.value.props[prop.field].some((item) => item.value === addFlags.value)
-}
-
-const handleClick = (prop, addFlags) => {
-  const selectedOptions = data.value.props[prop.field]
-
-  const index = selectedOptions.findIndex((item) => item.value === addFlags.value)
-
-  if (index !== -1) {
-    selectedOptions.splice(index, 1)
-  } else {
-    selectedOptions.push(addFlags)
-  }
-}
-
-const handleVisibleStyle = () => {
-  visibleStyle.value = !visibleStyle.value
-}
-
-const handlePartChange = (targetPartName) => {
-  const existingPart = data.value.style.find((item) => item.part.name === targetPartName)
-  if (!existingPart) {
-    loadStyle(data.value.props.name).then((loadedStyle) => {
-      if (loadedStyle) {
-        const targetPart = loadedStyle.part.find((item) => item.partName === moduleValue.value)
-
-        if (targetPart) {
-          const targetStyle = targetPart.state.find((item) => item.state === targetPart)
-
-          if (targetStyle) {
-            targetStyle.style.part = {
-              name: moduleValue.value,
-              state: targetPart
-            }
-            data.value.style.push(targetStyle.style)
-          }
-        }
-      }
-    })
-  }
-}
-
-const handleStateChange = (targetState) => {
-  const existingState = data.value.style.find((item) => item.part.state === targetState)
-
-  if (!existingState) {
-    loadStyle(data.value.props.name).then((loadedStyle) => {
-      if (loadedStyle) {
-        const targetPart = loadedStyle.part.find((item) => item.partName === moduleValue.value)
-
-        if (targetPart) {
-          const targetStyle = targetPart.state.find((item) => item.state === targetState)
-
-          if (targetStyle) {
-            targetStyle.style.part = {
-              name: moduleValue.value,
-              state: targetState
-            }
-            data.value.style.push(targetStyle.style)
-          }
-        }
-      }
-    })
-  }
-}
-
-const getDynamicFields = (prop) => {
-  if (prop.valueType === 'dependency' && prop.name && Array.isArray(prop.name)) {
-    const fieldValues = prop.name.reduce((values, field) => {
-      if (field === 'part') {
-        values[field] = moduleValue.value
-      } else {
-        values[field] = data.value[field] || data.value.props[field]
-      }
-
-      return values
-    }, {})
-
-    return prop.dependency(fieldValues) || []
-  }
-  return []
-}
-</script>
-
-<style scoped lang="less">
-:deep(.el-collapse-item__header) {
-  padding-right: 0px;
-}
-:deep(.el-collapse-item__content) {
-  padding-bottom: 0;
-  display: flex;
-  flex-wrap: wrap;
-  gap: 0 10px;
-}
-
-:deep(.el-scrollbar__view) {
-  height: 100%;
-}
-:deep(.flex-none) {
-  flex: none !important;
-}
-</style>

+ 166 - 50
src/renderer/src/views/designer/config/VariableConfig.vue

@@ -1,6 +1,56 @@
 <template>
   <el-scrollbar height="calc(100vh - 130px)" class="config">
-    <ViewTitle title="设置变量">
+    <ViewTitle :title="t('pageVariables')">
+      <template #right>
+        <el-button type="text" @click="addPageVariables"
+          ><el-icon class="cursor-pointer"> <Plus /> </el-icon
+        ></el-button>
+      </template>
+    </ViewTitle>
+    <div class="p-10px">
+      <!-- 变量表格 -->
+      <el-table :data="pageVariables || []">
+        <el-table-column :label="t('variableName')">
+          <template #default="{ row }">
+            <el-input
+              spellcheck="false"
+              v-model="row.name"
+              placeholder="请输入"
+              @input="(val) => handleVariableNameInput(row, val)"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('variableType')">
+          <template #default="{ row }">
+            <el-select v-model="row.type" placeholder="请选择">
+              <el-option
+                v-for="type in variableType"
+                :key="type.value"
+                :label="type.label"
+                :value="type.value"
+              />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column :label="t('initialValue')">
+          <template #default="{ row }">
+            <el-input spellcheck="false" v-model="row.value" placeholder="请输入" />
+          </template>
+        </el-table-column>
+        <el-table-column width="40">
+          <template #default="{ row }">
+            <el-icon
+              class="cursor-pointer"
+              @click="handleVariablesRemove(pageVariables || [], row, 'page')"
+            >
+              <Delete />
+            </el-icon>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+
+    <ViewTitle :title="t('globalVariables')">
       <template #right>
         <el-button type="text" @click="addVariableGroup"
           ><el-icon class="cursor-pointer"> <Plus /> </el-icon
@@ -8,11 +58,11 @@
       </template>
     </ViewTitle>
     <el-form label-position="top">
-      <el-collapse v-for="item in data" :key="item.id" v-model="activeNames">
-        <el-collapse-item :name="item.name">
+      <el-collapse v-for="item in globalVariables" :key="item.id" v-model="activeNames">
+        <el-collapse-item :name="item.id">
           <template #title>
             <div class="collapse-title">
-              <el-icon class="arrow" :class="{ active: activeNames.includes(item.name) }">
+              <el-icon class="arrow" :class="{ active: activeNames.includes(item.id) }">
                 <ArrowRight />
               </el-icon>
 
@@ -34,97 +84,163 @@
             </div>
           </template>
           <div class="p-10px">
-            <!-- 头部描述 -->
-            <el-card v-for="(element, index) in item.variables" :key="element.id" class="mb-10px">
-              <div class="flex justify-between items-center mb-10px">
-                <span>变量: {{ element.name || index + 1 }}</span>
-                <el-icon
-                  class="cursor-pointer"
-                  @click="handleVariablesRemove(item.variables, element)"
-                >
-                  <Delete />
-                </el-icon>
-              </div>
-              <div class="flex gap-10px mb-10px">
-                <el-input spellcheck="false" v-model="element.name" placeholder="变量名称" />
-                <el-select v-model="element.type" placeholder="变量类型">
-                  <el-option
-                    v-for="type in variableType"
-                    :key="type.value"
-                    :label="type.label"
-                    :value="type.value"
+            <!-- 变量表格 -->
+            <el-table :data="item.variables">
+              <el-table-column :label="t('variableName')">
+                <template #default="{ row }">
+                  <el-input
+                    spellcheck="false"
+                    v-model="row.name"
+                    placeholder="请输入"
+                    @input="(val) => handleVariableNameInput(row, val)"
                   />
-                </el-select>
-              </div>
-              <el-input
-                spellcheck="false"
-                v-model="element.value"
-                type="textarea"
-                :rows="3"
-                placeholder="初始值"
-              />
-            </el-card>
+                </template>
+              </el-table-column>
+              <el-table-column :label="t('variableType')">
+                <template #default="{ row }">
+                  <el-select v-model="row.type" placeholder="请选择">
+                    <el-option
+                      v-for="type in variableType"
+                      :key="type.value"
+                      :label="type.label"
+                      :value="type.value"
+                    />
+                  </el-select>
+                </template>
+              </el-table-column>
+              <el-table-column :label="t('initialValue')">
+                <template #default="{ row }">
+                  <el-input spellcheck="false" v-model="row.value" placeholder="请输入" />
+                </template>
+              </el-table-column>
+              <el-table-column width="40">
+                <template #default="{ row }">
+                  <el-icon
+                    class="cursor-pointer"
+                    @click="handleVariablesRemove(item.variables, row, 'global')"
+                  >
+                    <Delete />
+                  </el-icon>
+                </template>
+              </el-table-column>
+            </el-table>
           </div>
         </el-collapse-item>
       </el-collapse>
+      <el-empty v-if="globalVariables.length === 0" description="暂无全局变量" />
     </el-form>
   </el-scrollbar>
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue'
-import type { VariableGroup } from '@/types/variables'
+import { computed, ref } from 'vue'
+import type { Variable, VariableGroup } from '@/types/variables'
+import type { BaseWidget } from '@/types/baseWidget'
 import { ArrowRight, Plus, Delete } from '@element-plus/icons-vue'
 import { ElMessageBox } from 'element-plus'
 import { variableType } from '@/constants'
 import { v4 } from 'uuid'
+import { useI18n } from 'vue-i18n'
+
 interface Emits {
   (e: 'update:variables', val: VariableGroup[]): void
+  (e: 'update:pageVariables', val: VariableGroup[]): void
 }
 const emit = defineEmits<Emits>()
 const props = defineProps<{
   variables?: VariableGroup[]
+  pageVariables?: Variable[]
 }>()
-const data = ref<VariableGroup[]>(props.variables || ({} as VariableGroup[]))
-watch(data, (val) => emit('update:variables', val))
-const activeNames = ref<string[]>([])
 
-watch(activeNames.value, (value) => console.log(value))
+const { t } = useI18n()
+
+const globalVariables = computed({
+  get() {
+    return props.variables || []
+  },
+  set(val: VariableGroup[]) {
+    emit('update:variables', val)
+  }
+})
+
+const activeNames = ref<string[]>(['page_variables'])
+
+const normalizeCVariableName = (value: string) => {
+  const validChars = value.replace(/[^a-zA-Z0-9_]/g, '')
+  return validChars.replace(/^[0-9]+/, '')
+}
+
+const handleVariableNameInput = (row: Variable, value: string) => {
+  row.name = normalizeCVariableName(value)
+}
 
 const addVariables = (variables) => {
   variables.push({
     id: v4(),
     name: `var_${variables.length + 1}`,
     value: '',
-    type: ''
+    initialValue: '',
+    source: 'global',
+    type: 'char'
   })
 }
 
-const handleVariablesRemove = (variables, element) => {
-  const index = variables.findIndex((item) => item.id === element.id)
-  if (index !== -1) {
-    variables.splice(index, 1)
-  }
+const walkWidgets = (widgets: BaseWidget[] = [], callback: (widget: BaseWidget) => void) => {
+  widgets.forEach((widget) => {
+    callback(widget)
+    walkWidgets(widget.children || [], callback)
+  })
+}
+
+/**
+ * 添加页面变量
+ */
+const addPageVariables = () => {
+  // todo: 打开弹窗,列出当前页面全部控件,然后根据控件属性选择变量
+  // 选择属性后会自动绑定属性的变量类型,初始值使用属性的当前值
+}
+
+/**
+ * 删除变量操作
+ * @variables 变量列表
+ * @varItem 变量项
+ * @scope 作用域(全局或页面)
+ * @description 该函数用于处理变量的删除操作。根据作用域(全局或页面),它会从相应的变量列表中删除指定的变量项,并确保在
+ */
+const handleVariablesRemove = (
+  variables: Variable[],
+  varItem: Variable,
+  scope: 'global' | 'page'
+) => {
+  // todo: 判断当前变量是否被使用,若被使用需要提示确认操作后再删除
 }
 
+/**
+ * 删除变量组操作
+ * @param variables
+ */
 const removeVariables = (variables) => {
-  ElMessageBox.confirm('确定删除?', '提示', {
+  ElMessageBox.confirm('确定删除变量组?', '提示', {
     confirmButtonText: '确定',
     cancelButtonText: '取消',
     type: 'warning'
   }).then(() => {
-    const index = data.value.findIndex((item) => item.id === variables.id)
+    const index = globalVariables.value.findIndex((item) => item.id === variables.id)
     if (index !== -1) {
-      data.value.splice(index, 1)
+      globalVariables.value.splice(index, 1)
     }
   })
 }
 
+/**
+ * 添加变量组操作
+ * @description 该函数用于添加一个新的变量组。它会生成一个唯一的ID,并将一个新的变量组对象添加到全局变量列表中。同时,它还会将新添加的变量组的ID添加到activeNames数组中,以确保新组在UI中展开。
+ */
 const addVariableGroup = () => {
   const id = v4()
-  data.value.push({
+  globalVariables.value.push({
     id,
-    name: `vargroup_${data.value.length + 1}`,
+    name: `vargroup_${globalVariables.value.length + 1}`,
     variables: []
   })
   activeNames.value.push(id)

+ 9 - 1
src/renderer/src/views/designer/config/index.vue

@@ -5,7 +5,7 @@
         <property />
       </el-tab-pane>
       <el-tab-pane label="变量">
-        <variable-config v-model:variables="variables" />
+        <variable-config v-model:variables="variables" v-model:page-variables="pageVariables" />
       </el-tab-pane>
       <el-tab-pane label="动画">
         <animation-config v-model:animation="animation" />
@@ -33,6 +33,14 @@ const variables = computed({
     if (projectStore.project) projectStore.project.variables = val
   }
 })
+const pageVariables = computed({
+  get() {
+    return projectStore.activePage?.variables || []
+  },
+  set(val) {
+    if (projectStore.activePage) projectStore.activePage.variables = val
+  }
+})
 // 动画
 const animation = computed({
   get() {

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

@@ -10,138 +10,156 @@
       :label-width="schema.label ? (schema?.labelWidth ?? '50px') : '0px'"
       :label-position="schema?.labelPosition"
     >
-      <!-- 文本 -->
-      <el-input
-        v-if="schema.valueType === 'text'"
-        ref="textInputRef"
-        v-model="value"
-        spellcheck="false"
-        :type="schema?.slots ? 'text' : 'textarea'"
-        :rows="1"
-        resize="none"
-        v-bind="componentProps"
-        @focus="handleInputFocus"
-        @blur="handleInputBlur"
-        @click="syncTextSelection"
-        @keyup="syncTextSelection"
+      <VariableBindWrapper
+        :model-value="value"
+        :field-path="schema.field as string"
+        :can-bind-variable="!!schema.variableConfig"
+        :variable-config="schema.variableConfig"
+        @update:model-value="
+          (val) => {
+            value = val
+          }
+        "
+        @bind="handleOpenVariableSelector"
+        @unbind="handleUnbindVariable"
       >
-        <template #prefix>
-          {{ schema?.slots?.prefix }}
-        </template>
-        <template #suffix>
-          {{ schema?.slots?.suffix }}
-        </template>
-        <template #append v-if="componentProps?.supportLangues">
-          <LuLanguages size="12px" class="cursor-pointer" @click="handleOpenLanguageModal" />
-        </template>
-      </el-input>
-      <!-- 数字 -->
-      <input-number
-        v-if="schema.valueType === 'number'"
-        v-model="value"
-        controls-position="right"
-        style="width: 100%"
-        v-bind="componentProps"
-        @focus="handleInputFocus"
-        @blur="handleInputBlur"
-      >
-        <template #prefix>
-          {{ schema?.slots?.prefix }}
-        </template>
-        <template #suffix>
-          {{ schema?.slots?.suffix }}
-        </template>
-      </input-number>
-      <!-- 选择框 -->
-      <el-select-v2
-        v-if="schema.valueType === 'select'"
-        :options="componentProps?.options || []"
-        v-model="value"
-        v-bind="componentProps"
-        @visible-change="handleSelectVisibleChange"
-      >
-        <template #prefix>
-          {{ schema?.slots?.prefix }}
-        </template>
-        <template #suffix>
-          {{ schema?.slots?.suffix }}
-        </template>
-      </el-select-v2>
-      <!-- 开关 -->
-      <div v-if="schema.valueType === 'switch'" class="w-full flex justify-end">
-        <el-switch
+        <!-- 文本 -->
+        <el-input
+          v-if="schema.valueType === 'text'"
+          ref="textInputRef"
+          v-model="value"
+          spellcheck="false"
+          :type="schema?.slots ? 'text' : 'textarea'"
+          :rows="1"
+          resize="none"
+          v-bind="componentProps"
+          @focus="handleInputFocus"
+          @blur="handleInputBlur"
+          @click="syncTextSelection"
+          @keyup="syncTextSelection"
+        >
+          <template #prefix>
+            {{ schema?.slots?.prefix }}
+          </template>
+          <template #suffix>
+            {{ schema?.slots?.suffix }}
+          </template>
+          <template #append v-if="componentProps?.supportLangues">
+            <LuLanguages size="12px" class="cursor-pointer" @click="handleOpenLanguageModal" />
+          </template>
+        </el-input>
+        <!-- 数字 -->
+        <input-number
+          v-if="schema.valueType === 'number'"
+          v-model="value"
+          controls-position="right"
+          style="width: 100%"
+          v-bind="componentProps"
+          @focus="handleInputFocus"
+          @blur="handleInputBlur"
+        >
+          <template #prefix>
+            {{ schema?.slots?.prefix }}
+          </template>
+          <template #suffix>
+            {{ schema?.slots?.suffix }}
+          </template>
+        </input-number>
+        <!-- 选择框 -->
+        <el-select-v2
+          v-if="schema.valueType === 'select'"
+          :options="componentProps?.options || []"
+          v-model="value"
+          v-bind="componentProps"
+          @visible-change="handleSelectVisibleChange"
+        >
+          <template #prefix>
+            {{ schema?.slots?.prefix }}
+          </template>
+          <template #suffix>
+            {{ schema?.slots?.suffix }}
+          </template>
+        </el-select-v2>
+        <!-- 开关 -->
+        <div v-if="schema.valueType === 'switch'" class="w-full flex justify-end">
+          <el-switch
+            v-model="value"
+            v-bind="componentProps"
+            @change="projectStore.resumeHistory(true)"
+          />
+        </div>
+
+        <!-- 滑动条 -->
+        <div v-if="schema.valueType === 'slider'" class="w-full flex gap-20px items-center">
+          <el-slider
+            v-model="value"
+            v-bind="componentProps"
+            style="flex: 1"
+            @input="projectStore.pauseHistory()"
+            @change="projectStore.resumeHistory(true)"
+          ></el-slider>
+          <span class="text-text-active inline w-30px cursor-pointer">
+            {{ value }}
+          </span>
+        </div>
+        <!-- 文本框 -->
+        <CusTextarea
+          v-if="schema.valueType === 'textarea'"
+          v-model="value"
+          v-bind="componentProps"
+          @focus="handleInputFocus"
+          @blur="handleInputBlur"
+        />
+        <!-- 图片选择 -->
+        <ImageSelect v-if="schema.valueType === 'image'" v-model="value" v-bind="componentProps" />
+        <!-- 文件选择 -->
+        <FileSelect v-if="schema.valueType === 'file'" v-model="value" v-bind="componentProps" />
+        <!-- 图标选择 -->
+        <SymbolSelect
+          v-if="schema.valueType === 'symbol'"
           v-model="value"
           v-bind="componentProps"
-          @change="projectStore.resumeHistory(true)"
         />
-      </div>
+        <!-- 颜色选择 -->
+        <div class="flex" v-if="schema.valueType === 'color'">
+          <ColorPicker
+            v-model:pureColor="value"
+            use-type="pure"
+            picker-type="chrome"
+            format="hex8"
+            v-bind="componentProps"
+          />
+          <span class="text-text-active">{{ value }}</span>
+        </div>
 
-      <!-- 滑动条 -->
-      <div v-if="schema.valueType === 'slider'" class="w-full flex gap-20px items-center">
-        <el-slider
+        <!-- 代码 -->
+        <div
+          v-if="schema.valueType === 'code'"
+          class="w-full relative border border-solid border-border rounded-4px overflow-hidden"
+        >
+          <MonacoEditor v-model="value" v-bind="componentProps" />
+        </div>
+
+        <!-- 日期 -->
+        <el-date-picker
+          v-if="schema.valueType === 'date'"
           v-model="value"
+          style="width: 100%"
           v-bind="componentProps"
-          style="flex: 1"
-          @input="projectStore.pauseHistory()"
-          @change="projectStore.resumeHistory(true)"
-        ></el-slider>
-        <span class="text-text-active inline w-30px cursor-pointer">
-          {{ value }}
-        </span>
-      </div>
-      <!-- 文本框 -->
-      <CusTextarea
-        v-if="schema.valueType === 'textarea'"
-        v-model="value"
-        v-bind="componentProps"
-        @focus="handleInputFocus"
-        @blur="handleInputBlur"
-      />
-      <!-- 图片选择 -->
-      <ImageSelect v-if="schema.valueType === 'image'" v-model="value" v-bind="componentProps" />
-      <!-- 文件选择 -->
-      <FileSelect v-if="schema.valueType === 'file'" v-model="value" v-bind="componentProps" />
-      <!-- 图标选择 -->
-      <SymbolSelect v-if="schema.valueType === 'symbol'" v-model="value" v-bind="componentProps" />
-      <!-- 颜色选择 -->
-      <div class="flex" v-if="schema.valueType === 'color'">
-        <ColorPicker
-          v-model:pureColor="value"
-          use-type="pure"
-          picker-type="chrome"
-          format="hex8"
+          @visible-change="handleSelectVisibleChange"
+        />
+
+        <!-- 时间 -->
+        <CusTimePicker
+          v-if="schema.valueType === 'time'"
+          v-model="value"
+          style="width: 100%"
           v-bind="componentProps"
         />
-        <span class="text-text-active">{{ value }}</span>
-      </div>
 
-      <!-- 代码 -->
-      <div
-        v-if="schema.valueType === 'code'"
-        class="w-full relative border border-solid border-border rounded-4px overflow-hidden"
-      >
-        <MonacoEditor v-model="value" v-bind="componentProps" />
-      </div>
-
-      <!-- 日期 -->
-      <el-date-picker
-        v-if="schema.valueType === 'date'"
-        v-model="value"
-        style="width: 100%"
-        v-bind="componentProps"
-        @visible-change="handleSelectVisibleChange"
-      />
-
-      <!-- 时间 -->
-      <CusTimePicker
-        v-if="schema.valueType === 'time'"
-        v-model="value"
-        style="width: 100%"
-        v-bind="componentProps"
-      />
-
-      <!-- 字体样式 -->
-      <FamilySelect v-if="schema.valueType === 'family'" v-model="value" />
+        <!-- 字体样式 -->
+        <FamilySelect v-if="schema.valueType === 'family'" v-model="value" />
+      </VariableBindWrapper>
     </el-form-item>
 
     <!-- 分组 -->
@@ -264,6 +282,17 @@
     @change-state-style="(field, type) => $emit('changeStateStyle', field, type)"
   />
   <LanguageSelectModal ref="languageModalRef" @select="handleSelectLanguage" />
+  <!-- 变量选择弹窗 -->
+  <VariableSelectorDialog
+    v-model="showVariableSelector"
+    :all-variables="
+      getAllVariables(projectStore.project?.variables || [], projectStore.activePage?.variables)
+    "
+    :current-var-id="currentBoundVarId"
+    :support-type="schema.variableConfig?.type"
+    @confirm="handleVariableBindConfirm"
+    @cancel="showVariableSelector = false"
+  />
 </template>
 
 <script setup lang="tsx">
@@ -301,6 +330,9 @@ import StyleAnimation from './components/StyleAnimation.vue'
 import StyleOther from './components/StyleOther.vue'
 import LanguageSelectModal from './components/LanguageSelectModal.vue'
 import { LuLanguages } from 'vue-icons-plus/lu'
+import VariableBindWrapper from './components/VariableBindWrapper.vue'
+import VariableSelectorDialog from './components/VariableSelectorDialog.vue'
+import { bindVariableToProperty, getAllVariables } from '@/utils/variableBinding'
 
 defineOptions({
   name: 'CusFormItem'
@@ -331,6 +363,10 @@ const componentProps = computed(() => {
 })
 const expandStyle = ref(true)
 
+const showVariableSelector = ref(false)
+const currentBindField = ref('')
+const currentBindValue = ref<any>(null)
+
 // 绑定数据
 const value = computed({
   get() {
@@ -349,6 +385,13 @@ const value = computed({
   }
 })
 
+const currentBoundVarId = computed(() => {
+  const currentValue = value.value
+  return currentValue && typeof currentValue === 'object' && currentValue.varId
+    ? currentValue.varId
+    : ''
+})
+
 const getTextInputElement = () => {
   const inputInstance = textInputRef.value as any
   return inputInstance?.input || inputInstance?.textarea || null
@@ -412,6 +455,37 @@ const handleSelectLanguage = (languageKey: string) => {
   insertLanguageToken(languageKey)
 }
 
+/**
+ * 打开变量选择器
+ */
+const handleOpenVariableSelector = () => {
+  currentBindField.value = props.schema.field || ''
+  currentBindValue.value = value.value
+  showVariableSelector.value = true
+}
+
+/**
+ * 变量选择确认后的处理
+ * @param varId
+ */
+const handleVariableBindConfirm = (varId: string) => {
+  const boundValue = bindVariableToProperty(currentBindValue.value, varId)
+  if (props.schema.field && props.formData) {
+    set(props.formData, props.schema.field, boundValue)
+  }
+  showVariableSelector.value = false
+}
+
+/**
+ * 解绑变量
+ */
+const handleUnbindVariable = () => {
+  const currentValue = value.value
+  if (currentValue && typeof currentValue === 'object' && currentValue.varId) {
+    value.value = currentValue.originValue
+  }
+}
+
 /**
  * 使用表单组件渲染表单项
  */

+ 155 - 0
src/renderer/src/views/designer/config/property/components/VariableBindWrapper.vue

@@ -0,0 +1,155 @@
+<template>
+  <div class="variable-bind-wrapper">
+    <div v-if="!isBound" class="input-container">
+      <slot></slot>
+    </div>
+
+    <el-popover v-if="canBindVariable" ref="popoverRef" placement="right" trigger="click">
+      <template #reference>
+        <div class="cursor-pointer" :class="isBound ? 'active w-full flex items-center' : ''">
+          <TbVariable :size="14" class="shrink-0" style="margin-top: 0.2em" />
+          <span v-if="isBound" class="ml-4px flex-1 truncate">{{ boundVariableName }}</span>
+        </div>
+      </template>
+      <div class="flex-col">
+        <el-button
+          v-if="!isBound"
+          class="w-full mb-12px"
+          size="small"
+          type="primary"
+          @click="addPageVariables"
+          ><LuPlus size="12" /> 添加变量</el-button
+        >
+        <el-button
+          v-if="!isBound"
+          class="w-full ml-0!"
+          size="small"
+          type="primary"
+          @click="emit('bind')"
+          ><LuLink size="12" /> 绑定变量</el-button
+        >
+        <el-button
+          v-if="isBound"
+          class="w-full ml-0!"
+          size="small"
+          type="primary"
+          @click="emit('unbind')"
+          ><LuUnlink size="12" /> 解绑变量</el-button
+        >
+      </div>
+    </el-popover>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+import { ElMessageBox, ElMessage } from 'element-plus'
+import { isVariableBound, findVariableById, getAllVariables } from '@/utils/variableBinding'
+import { useProjectStore } from '@/store/modules/project'
+import { LuPlus, LuLink, LuUnlink } from 'vue-icons-plus/lu'
+import { v4 } from 'uuid'
+import { TbVariable } from 'vue-icons-plus/tb'
+
+import type { Variable, VariableType } from '@/types/variables'
+import type { PopoverInstance } from 'element-plus'
+
+const props = defineProps<{
+  modelValue: any
+  fieldPath: string
+  canBindVariable?: boolean
+  variableConfig?: {
+    type: VariableType
+    enumMap?: Record<number, string>
+  }
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: any]
+  bind: []
+  unbind: []
+}>()
+
+const projectStore = useProjectStore()
+
+const popoverRef = ref<PopoverInstance>()
+
+const isBound = computed(() => {
+  return isVariableBound(props.modelValue)
+})
+
+const boundVariableName = computed(() => {
+  if (!isBound.value) return ''
+
+  const allVars = getAllVariables(
+    projectStore.project?.variables || [],
+    projectStore.activePage?.variables
+  )
+
+  const variable = findVariableById(props.modelValue.varId, allVars)
+  return variable?.name || ''
+})
+
+/**
+ * 添加页面变量
+ */
+const addPageVariables = () => {
+  // 1、打开弹窗输入变量名称
+  // 2、从variableConfig.type获取变量类型,variableConfig.enumMap获取枚举选项(如果是枚举类型),将这些信息传递给弹窗
+  // 3、从modelValue获取当前值,作为变量的初始值
+  // 4、用户确认后创建变量到页面变量,并自动绑定
+  popoverRef.value?.hide()
+
+  ElMessageBox.prompt('请输入变量名称', '提示', {
+    confirmButtonText: '确认',
+    cancelButtonText: '取消',
+    // C语言变量命名规则:只能包含字母、数字和下划线,且必须以字母或下划线开头
+    inputPattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/,
+    inputErrorMessage: '变量名称只能包含字母、数字和下划线,且必须以字母或下划线开头'
+  })
+    .then(({ value }) => {
+      let val = props.modelValue
+      // 如果是枚举
+      if (props.variableConfig?.type === 'enum' && props.variableConfig.enumMap) {
+        // 尝试将当前值转换为枚举的key
+        const enumKey = Object.keys(props.variableConfig.enumMap).find(
+          (key) => props.variableConfig?.enumMap?.[key] === props.modelValue
+        )
+        val = enumKey ?? val
+      }
+
+      const newVar: Variable = {
+        id: v4(),
+        name: value,
+        type: props.variableConfig?.type as VariableType,
+        value: val
+      }
+
+      projectStore.activePage?.variables?.push(newVar)
+      emit('update:modelValue', { varId: newVar.id, originValue: val })
+    })
+    .catch(() => {
+      ElMessage({
+        message: '取消添加'
+      })
+    })
+}
+</script>
+
+<style scoped lang="less">
+.variable-bind-wrapper {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  width: 100%;
+
+  .input-container {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .active {
+    color: var(--accent-blue);
+    font-size: 14px;
+  }
+}
+</style>

+ 235 - 0
src/renderer/src/views/designer/config/property/components/VariableSelectorDialog.vue

@@ -0,0 +1,235 @@
+<template>
+  <el-dialog
+    v-model="dialogVisible"
+    title="选择变量"
+    width="720px"
+    append-to-body
+    :close-on-click-modal="false"
+  >
+    <div class="variable-selector">
+      <el-input
+        v-model="searchKeyword"
+        placeholder="搜索变量名称或类型..."
+        prefix-icon="Search"
+        clearable
+        style="margin-bottom: 16px"
+      />
+
+      <el-scrollbar max-height="420px">
+        <div v-if="filteredVariableGroups.length === 0" class="empty-tip">
+          <el-empty description="暂无可用变量" />
+        </div>
+
+        <div v-for="group in filteredVariableGroups" :key="group.id" class="variable-group">
+          <div class="group-title">{{ group.name }}</div>
+          <el-table
+            :data="group.variables"
+            size="small"
+            border
+            highlight-current-row
+            :row-class-name="getRowClassName"
+            @row-click="handleSelectVariable"
+          >
+            <el-table-column width="52" align="center">
+              <template #default="{ row }">
+                <el-radio v-model="selectedVarId" :value="row.id" @click.stop />
+              </template>
+            </el-table-column>
+            <el-table-column prop="name" label="变量名" min-width="180" show-overflow-tooltip />
+            <el-table-column label="值" min-width="180" show-overflow-tooltip>
+              <template #default="{ row }">
+                {{ formatValue(row.value) }}
+              </template>
+            </el-table-column>
+            <el-table-column prop="type" label="类型" width="120" show-overflow-tooltip />
+          </el-table>
+        </div>
+      </el-scrollbar>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer">
+        <span class="selected-name">当前选中:{{ selectedVariableName || '未选择' }}</span>
+        <div>
+          <el-button @click="handleCancel">取消</el-button>
+          <el-button type="primary" @click="handleConfirm" :disabled="!selectedVariable">
+            确定
+          </el-button>
+        </div>
+      </div>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue'
+import type { Variable, VariableGroup, VariableType } from '@/types/variables'
+
+const props = defineProps<{
+  modelValue: boolean
+  allVariables: VariableGroup[]
+  currentVarId?: string
+  supportType?: VariableType
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+  confirm: [varId: string]
+  cancel: []
+}>()
+
+const dialogVisible = computed({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const searchKeyword = ref('')
+const selectedVarId = ref<string>('')
+
+const sortedVariableGroups = computed(() => {
+  return [...props.allVariables].sort((a, b) => {
+    if (a.id === 'page-vars') return -1
+    if (b.id === 'page-vars') return 1
+    return 0
+  })
+})
+
+const filteredVariableGroups = computed(() => {
+  const keyword = searchKeyword.value.toLowerCase()
+  return sortedVariableGroups.value
+    .map((group) => ({
+      ...group,
+      variables: group.variables.filter((v) => {
+        const matchType = !props.supportType || v.type === props.supportType
+        const matchKeyword =
+          !keyword ||
+          v.name.toLowerCase().includes(keyword) ||
+          v.type.toLowerCase().includes(keyword) ||
+          String(v.value ?? '')
+            .toLowerCase()
+            .includes(keyword)
+
+        return matchType && matchKeyword
+      })
+    }))
+    .filter((group) => group.variables.length > 0)
+})
+
+const selectedVariable = computed<Variable | undefined>(() => {
+  if (!selectedVarId.value) return undefined
+
+  for (const group of filteredVariableGroups.value) {
+    const variable = group.variables.find((item) => item.id === selectedVarId.value)
+    if (variable) return variable
+  }
+
+  return undefined
+})
+
+const selectedVariableName = computed(() => selectedVariable.value?.name || '')
+
+const formatValue = (value: any): string => {
+  if (value === undefined || value === null) return 'null'
+  if (typeof value === 'string') {
+    const truncated = value.length > 40 ? value.substring(0, 40) + '...' : value
+    return `"${truncated}"`
+  }
+  if (typeof value === 'object') return JSON.stringify(value)
+  return String(value)
+}
+
+const handleSelectVariable = (variable: Variable) => {
+  selectedVarId.value = variable.id
+}
+
+const getRowClassName = ({ row }: { row: Variable }) => {
+  return row.id === selectedVarId.value ? 'is-selected-row' : ''
+}
+
+watch(
+  () => props.modelValue,
+  (val) => {
+    if (val) {
+      selectedVarId.value = props.currentVarId || ''
+      searchKeyword.value = ''
+    }
+  }
+)
+
+watch(filteredVariableGroups, () => {
+  if (!selectedVarId.value) return
+  if (!selectedVariable.value) {
+    selectedVarId.value = ''
+  }
+})
+
+const handleConfirm = () => {
+  if (selectedVariable.value) {
+    emit('confirm', selectedVarId.value)
+    dialogVisible.value = false
+  }
+}
+
+const handleCancel = () => {
+  emit('cancel')
+  dialogVisible.value = false
+}
+</script>
+
+<style scoped lang="less">
+.variable-selector {
+  .empty-tip {
+    text-align: center;
+    padding: 40px 0;
+  }
+
+  .variable-group {
+    margin-bottom: 20px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    .group-title {
+      font-weight: 600;
+      color: var(--text-primary);
+      margin-bottom: 12px;
+      padding-left: 4px;
+      font-size: 14px;
+    }
+
+    :deep(.el-table__row) {
+      cursor: pointer;
+    }
+
+    :deep(.is-selected-row) {
+      --el-table-tr-bg-color: var(--el-color-primary-light-9);
+    }
+
+    :deep(.el-radio) {
+      height: 20px;
+
+      .el-radio__label {
+        display: none;
+      }
+    }
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  width: 100%;
+
+  .selected-name {
+    font-size: 12px;
+    min-width: 0;
+    color: var(--text-primary);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+}
+</style>