Prechádzať zdrojové kódy

feat: 添加事件及目标设置

jiaxing.liao 2 týždňov pred
rodič
commit
6862966bb4

+ 60 - 14
src/renderer/src/types/event.d.ts

@@ -1,18 +1,64 @@
+export type WidgetEventTriggerData = {
+  eventCode: string
+  direction?: string
+  key?: string
+}
+
+export type WidgetEventTargetKind =
+  | 'widget'
+  | 'page'
+  | 'custom_code'
+  | 'load_page'
+  | 'language_switch'
+  | 'theme_switch'
+
+export type WidgetEventTargetData = {
+  kind: WidgetEventTargetKind
+  pageId?: string
+  screenId?: string
+  targetId?: string
+  targetType?: string
+}
+
+export type WidgetEventActionData = {
+  actionKey: string
+}
+
+export type WidgetEventAction = {
+  id: string
+  name: string
+  nodeType: 'action'
+  value?: any
+  config?: {
+    data?: {
+      targetData?: WidgetEventTargetData
+      actionData?: WidgetEventActionData
+      kind?: WidgetEventTargetKind
+    }
+    [key: string]: any
+  }
+}
+
+export type WidgetEventTarget = {
+  id: string
+  name: string
+  value?: string
+  nodeType: 'target'
+  config?: {
+    data?: WidgetEventTargetData
+    [key: string]: any
+  }
+  children?: WidgetEventAction[]
+}
+
 export type WidgetEvent = {
-  // 事件ID
   id: string
-  // 事件名称
   name: string
-  // 触发事件
-  trigger: string
-  // 动作类型
-  type: string // 'play_animation' | 'function' -> play_animation: 播放动画 function: 执行函数
-  // 动画ID
-  animation: string
-  // 动画播放前函数ID
-  animationPlayerBeforeEvent: string
-  // 动画播放后函数ID
-  animationPlayerAfterEvent: string
-  // 函数ID
-  function: string
+  value?: string
+  nodeType: 'event'
+  config?: {
+    data?: WidgetEventTriggerData
+    [key: string]: any
+  }
+  children?: WidgetEventTarget[]
 }

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

@@ -1,61 +1,27 @@
 import { BaseWidget } from './baseWidget'
 import { VariableGroup } from './variables'
+import { WidgetEvent } from './event'
 
 export type ReferenceLine = {
-  // ID
   id: string
-  // 垂直 水平
   type: 'horizontal' | 'vertical' | null
-  // 位置
   value: number
-  // x
   x: number
-  // y
   y: number
 }
 
 export type Page = {
-  // 页面ID
   id: string
-  // 页面名称
   name: string
-  // 类型
   type: 'page'
-  // 隐藏
   hidden: boolean
-  // 锁定
   locked: boolean
-  // 当前部件
   part?: string
-  // 当前部件状态
   state?: string
-  // 参考线
   referenceLine: ReferenceLine[]
-  // 属性
   props: Record<string, any>
-  // 样式
   style: Record<string, any>[]
-  // 事件
-  events: {
-    // 事件ID
-    id: string
-    // 事件名称
-    name: string
-    // 触发器
-    trigger: string
-    // 执行类型
-    type: 'play_animation' | 'function'
-    // 动画ID
-    animation: string
-    // 动画播放前函数ID
-    animationPlayerBeforeEvent: string
-    // 动画播放后函数ID
-    animationPlayerAfterEvent: string
-    // 函数ID
-    function: string
-  }[]
-  // 页面变量
+  events: WidgetEvent[]
   variables: VariableGroup[]
-  // 子组件
   children: BaseWidget[]
 }

+ 110 - 140
src/renderer/src/views/designer/workspace/composite/eventEdit/ActionNode.vue

@@ -1,12 +1,12 @@
 <template>
-  <div class="min-h-32px bg-#4EB2BF rounded-4px flex items-center pr-4px">
-    <div class="w-160px text-#fff grid place-items-center shrink-0">
+  <div class="min-h-32px bg-#4EB2BF rounded-4px flex items-start pr-4px">
+    <div class="w-160px text-#fff grid place-items-center shrink-0 py-8px text-center">
       {{ node.name }}
     </div>
-    <div>
-      <!-- 自定义代码 -->
-      <div v-if="node.name === 'custom_code'" class="w-120px">
-        <el-select style="width: 120px" v-model="value" size="small">
+
+    <div class="flex-1 py-6px">
+      <div v-if="isCustomCode" class="w-180px">
+        <el-select v-model="methodValue" style="width: 180px" clearable>
           <el-option
             v-for="item in projectStore.project?.methods || []"
             :key="item.id"
@@ -15,137 +15,25 @@
           />
         </el-select>
       </div>
-      <!-- 字符串 -->
-      <div v-if="node?.config?.data?.valueType === 'string'">
-        <el-input spellcheck="false" v-model="value" size="small" />
-      </div>
-      <!-- 数字 -->
-      <div v-if="node?.config?.data?.valueType === 'number'">
-        <input-number
-          v-model="value"
-          :min="node.config.data?.min"
-          :max="node.config.data?.max"
-          size="small"
-        />
-      </div>
-      <!-- 布尔 -->
-      <div v-if="node?.config?.data?.valueType === 'boolean'">
-        <el-switch v-model="value" size="small" />
-      </div>
-      <!-- 枚举 -->
-      <div v-if="node?.config?.data?.valueType === 'select'">
-        <el-select
-          style="width: 160px"
-          v-model="value"
-          size="small"
-          placeholder="please select"
-          :multiple="node.config.data?.multiple"
-          collapse-tags
-          collapse-tags-tooltip
-        >
-          <el-option
-            v-for="item in optionMap?.[node.name] || []"
-            :key="item.value"
-            :label="item.label"
-            :value="item.value"
-          />
-        </el-select>
-      </div>
-      <!-- 颜色 -->
-      <div v-if="node?.config?.data?.valueType === 'color'">
-        <el-color-picker v-model="value" size="small" />
-      </div>
-      <!-- 动画 -->
-      <div
-        v-if="node?.config?.data?.valueType === 'animation'"
-        class="py-4px flex flex-col gap-4px"
-      >
-        <div>
-          <span class="inline-block w-80px">动画:</span>
-          <el-select
-            style="width: 160px"
-            v-model="value.animation"
-            size="small"
-            placeholder="select animation"
-          >
-            <el-option
-              v-for="item in projectStore.project?.animations || []"
-              :key="item.name"
-              :label="item.name"
-              :value="item.name"
-            />
-          </el-select>
-        </div>
-        <div>
-          <span class="inline-block w-80px">动画前事件:</span>
-          <el-select
-            style="width: 160px"
-            v-model="value.before"
-            size="small"
-            placeholder="before event"
-            clearable
-          >
-            <el-option
-              v-for="item in projectStore.project?.methods || []"
-              :key="item.id"
-              :label="item.name"
-              :value="item.id"
-            />
-          </el-select>
-        </div>
-        <div>
-          <span class="inline-block w-80px">动画后事件:</span>
-          <el-select
-            style="width: 160px"
-            v-model="value.after"
-            size="small"
-            placeholder="after event"
-            clearable
-          >
-            <el-option
-              v-for="item in projectStore.project?.methods || []"
-              :key="item.id"
-              :label="item.name"
-              :value="item.id"
-            />
-          </el-select>
-        </div>
-      </div>
-      <!-- 旋转 -->
-      <div v-if="node?.config?.data?.valueType === 'rotate'" class="py-4px flex flex-col gap-4px">
-        <div>
-          <span class="inline-block w-40px">X:</span>
-          <input-number
-            v-model="value.x"
-            :min="node.config.data?.min"
-            :max="node.config.data?.max"
-            size="small"
-          />
-        </div>
-        <div>
-          <span class="inline-block w-40px">Y:</span>
-          <input-number
-            v-model="value.y"
-            :min="node.config.data?.min"
-            :max="node.config.data?.max"
-            size="small"
-          />
-        </div>
-        <div>
-          <span class="inline-block w-40px">度数:</span>
-          <input-number
-            v-model="value.angle"
-            :min="node.config.data?.min"
-            :max="node.config.data?.max"
-            size="small"
+
+      <div v-else-if="actionSchemas?.length" class="pr-8px w-300px">
+        <el-form :model="formData">
+          <CusFormItem
+            v-for="item in actionSchemas"
+            :key="item.field || item.label"
+            :schema="item"
+            :form-data="formData"
+            @change-state-style="handleStateStyleChange"
           />
-        </div>
+        </el-form>
       </div>
+
+      <div v-else class="text-white/80 text-12px leading-24px">暂无可编辑内容</div>
     </div>
-    <!-- 删除按钮 -->
+
     <div
-      v-if="node.name !== 'custom_code'"
-      class="mx-4px p-2px cursor-pointer"
+      v-if="!isLockedAction"
+      class="mx-4px p-2px cursor-pointer self-center"
       @click="emit('delete', node.id)"
     >
       <LuTrash2 size="12px" class="text-accent-red" />
@@ -155,31 +43,113 @@
 
 <script setup lang="ts">
 import type { NodeItemType } from './type'
-import { ref, watch } from 'vue'
+
+import { computed, ref, watch } from 'vue'
+import { klona } from 'klona'
 import { LuTrash2 } from 'vue-icons-plus/lu'
 import { useProjectStore } from '@/store/modules/project'
-import { optionMap } from './config'
+import CusFormItem from '@/views/designer/config/property/CusFormItem.vue'
+import { resolveActionDescriptor } from './config'
 
 const emit = defineEmits<{
   (e: 'delete', id: string): void
   (e: 'update', node: NodeItemType): void
 }>()
+
 const props = defineProps<{
   node: NodeItemType
 }>()
+
 const projectStore = useProjectStore()
+const formData = ref<{ payload: any }>({
+  payload: klona(props.node.value)
+})
+
+const actionMeta = computed(() => props.node.config?.data)
+const isCustomCode = computed(() => actionMeta.value?.kind === 'custom_code')
+const isLockedAction = computed(
+  () =>
+    isCustomCode.value ||
+    ['builtin.load_page', 'builtin.language_switch', 'builtin.theme_switch'].includes(
+      actionMeta.value?.actionData?.actionKey
+    )
+)
+
+const actionDescriptor = computed(() =>
+  resolveActionDescriptor(
+    actionMeta.value?.targetData,
+    actionMeta.value?.actionData,
+    projectStore.project,
+    projectStore.activePage?.id
+  )
+)
 
-const value = ref(props.node.value)
+const actionSchemas = computed(() => actionDescriptor.value?.schemas || [])
+
+const methodValue = computed({
+  get() {
+    return props.node.value
+  },
+  set(val) {
+    emit('update', {
+      ...props.node,
+      value: val
+    })
+  }
+})
+
+watch(
+  () => props.node.value,
+  (val) => {
+    formData.value = {
+      payload: klona(val)
+    }
+  },
+  {
+    deep: true,
+    immediate: true
+  }
+)
+
+watch(
+  () => actionDescriptor.value,
+  (descriptor) => {
+    if (!descriptor) return
+    if (props.node.value !== undefined) return
+
+    const defaultValue = klona(descriptor.defaultValue)
+    formData.value = {
+      payload: defaultValue
+    }
+
+    emit('update', {
+      ...props.node,
+      value: defaultValue
+    })
+  },
+  {
+    immediate: true
+  }
+)
 
 watch(
-  () => value.value,
+  () => formData.value.payload,
   (val) => {
-    emit('update', { ...props.node, value: val })
+    if (isCustomCode.value) return
+
+    emit('update', {
+      ...props.node,
+      value: klona(val)
+    })
   },
   {
     deep: true
   }
 )
-</script>
 
-<style scoped></style>
+const handleStateStyleChange = (_field: string, type: 'add' | 'delete') => {
+  if (type === 'delete') {
+    emit('delete', props.node.id)
+  }
+}
+</script>

+ 175 - 56
src/renderer/src/views/designer/workspace/composite/eventEdit/NodeItem.vue

@@ -1,35 +1,38 @@
 <template>
-  <!-- 添加节点 -->
   <SelectPopover
     v-if="node.nodeType === 'add'"
     v-bind="getAddOptions(node.parentType!)"
-    @confirm="(val) => val && emit('add', node.parentId!, node.parentType!, val)"
+    @confirm="(val) => val?.length && emit('add', node.parentId!, node.parentType!, val)"
   >
     <div
-      v-if="node.nodeType === 'add'"
       :style="addBtnStyle"
-      class="w-120px h-24px bg-#0068ff text-#fff grid place-items-center rounded-4px hover:box-shadow-md cursor-pointer"
+      class="w-120px h-24px text-#fff grid place-items-center rounded-4px hover:box-shadow-md cursor-pointer"
     >
       <LuPlus />
     </div>
   </SelectPopover>
 
-  <!-- 主节点 -->
   <div
-    v-if="node.nodeType === 'root'"
+    v-else-if="node.nodeType === 'root'"
     class="w-200px h-40px bg-#0068ff text-#fff grid place-items-center rounded-4px"
   >
     {{ node.name }}
   </div>
 
-  <!-- 事件节点 -->
   <div
-    v-if="node.nodeType === 'event'"
+    v-else-if="node.nodeType === 'event'"
     class="w-200px h-40px bg-#4b85e2 text-#fff grid place-items-center rounded-4px relative group"
   >
     <div class="absolute top-4px right-4px gap-4px hidden group-hover:flex">
-      <SelectPopover v-bind="getAddOptions('root')" :multiple="false" @confirm="handleChangeEvent">
-        <div class="bg-[rgba(0,0,0,.5)] p-2px cursor-pointer"><LuPencilLine size="12px" /></div>
+      <SelectPopover
+        v-bind="getAddOptions('root')"
+        :multiple="false"
+        :selected="node.value"
+        @confirm="handleChangeEvent"
+      >
+        <div class="bg-[rgba(0,0,0,.5)] p-2px cursor-pointer">
+          <LuPencilLine size="12px" />
+        </div>
       </SelectPopover>
 
       <div class="bg-[rgba(0,0,0,.5)] p-2px cursor-pointer" @click="emit('delete', node.id)">
@@ -39,12 +42,22 @@
     {{ node.name }}
   </div>
 
-  <!-- 目标节点 -->
   <div
-    v-if="node.nodeType === 'target'"
-    class="w-200px h-40px bg-#4B9889 text-#fff grid place-items-center rounded-4px group"
+    v-else-if="node.nodeType === 'target'"
+    class="w-200px h-40px bg-#4B9889 text-#fff grid place-items-center rounded-4px relative group"
   >
     <div class="absolute top-4px right-4px gap-4px hidden group-hover:flex">
+      <SelectPopover
+        v-bind="getAddOptions('event')"
+        :multiple="false"
+        :selected="node.value"
+        @confirm="handleChangeTarget"
+      >
+        <div class="bg-[rgba(0,0,0,.5)] p-2px cursor-pointer">
+          <LuPencilLine size="12px" />
+        </div>
+      </SelectPopover>
+
       <div class="bg-[rgba(0,0,0,.5)] p-2px cursor-pointer" @click="emit('delete', node.id)">
         <LuTrash2 size="12px" class="text-accent-red" />
       </div>
@@ -52,9 +65,8 @@
     {{ node.name }}
   </div>
 
-  <!-- 动作节点 -->
   <ActionNode
-    v-if="node.nodeType === 'action'"
+    v-else-if="node.nodeType === 'action'"
     :node="node"
     @delete="() => emit('delete', node.id)"
     @update="(val) => emit('change', val)"
@@ -62,81 +74,188 @@
 </template>
 
 <script setup lang="ts">
-import type { NodeItemType } from './type'
+import type { NodeItemType, OptionType } from './type'
+import type { WidgetEventTargetData } from '@/types/event'
 
 import { computed } from 'vue'
-import { LuPlus, LuTrash2, LuPencilLine } from 'vue-icons-plus/lu'
+import { klona } from 'klona'
+import { v4 } from 'uuid'
+import { LuPencilLine, LuPlus, LuTrash2 } from 'vue-icons-plus/lu'
+
 import SelectPopover from './SelectPopover.vue'
 import ActionNode from './ActionNode.vue'
-import { widgetEventOptions, pageEventOptions, actionOptions } from './config'
-import { useAllWidgets } from '@/hooks/useAllWidgets'
+import {
+  getActionOptions,
+  getBuiltinActionData,
+  getEventOptions,
+  getTargetOptions,
+  isBuiltinTarget
+} from './config'
 import { useProjectStore } from '@/store/modules/project'
 
 const emit = defineEmits<{
-  (e: 'add', parentId: string, parentType: string, value: any[]): void
+  (e: 'add', parentId: string, parentType: string, value: OptionType[]): void
   (e: 'change', node: NodeItemType): void
   (e: 'delete', nodeId: string): void
 }>()
+
 const props = defineProps<{
   node: NodeItemType
 }>()
 
-const { allWidgets } = useAllWidgets()
 const projectStore = useProjectStore()
 
-// 添加节点弹窗配置
+const addBtnStyle = computed(() => {
+  const node = props.node
+  return {
+    backgroundColor:
+      node.parentType === 'root' ? '#4b85e2' : node.parentType === 'event' ? '#4B9889' : '#4EB2BF'
+  }
+})
+
+const findParentChildren = (parentId: string) => {
+  const rootChildren = (projectStore.activeWidget?.events || []) as unknown as NodeItemType[]
+
+  if (props.node.parentType === 'root') {
+    return rootChildren
+  }
+
+  for (const eventNode of rootChildren) {
+    if (eventNode.id === parentId) {
+      return eventNode.children || []
+    }
+
+    const targetNode = (eventNode.children || []).find((item) => item.id === parentId)
+    if (targetNode) {
+      return targetNode.children || []
+    }
+  }
+
+  return []
+}
+
+const getSiblingValues = (parentType: 'root' | 'event' | 'target') => {
+  if (parentType === 'target') return []
+
+  const siblings = props.node.parentId ? findParentChildren(props.node.parentId) : []
+  return siblings
+    .filter((item) => item.id !== props.node.id && item.nodeType !== 'add')
+    .map((item) => item.value)
+    .filter(Boolean) as string[]
+}
+
+const withDisabled = (options: OptionType[], selectedValues: string[]) =>
+  options.map((item) => ({
+    ...item,
+    disabled: selectedValues.includes(item.value)
+  }))
+
 const getAddOptions = (type: string) => {
-  const widget = projectStore.activeWidgets?.at(-1)
+  const widget = projectStore.activeWidget
+
   switch (type) {
     case 'root':
       return {
-        title: '触发方式',
+        title: '事件',
         multiple: true,
-        // 判断选择元素是否是页面
-        options: widget?.type === 'page' ? pageEventOptions : widgetEventOptions
+        options: withDisabled(getEventOptions(widget?.type), getSiblingValues('root'))
       }
+
     case 'event':
-      const options = allWidgets.value.map((item) => ({
-        label: item.name,
-        value: item.id,
-        data: item
-      }))
       return {
         title: '目标',
         multiple: true,
-        options: [
-          {
-            label: '自定义代码',
-            value: 'customCode'
-          },
-          ...options
-        ]
+        options: withDisabled(getTargetOptions(projectStore.project), getSiblingValues('event'))
       }
-    case 'target':
+
+    case 'target': {
+      const targetData = props.node.config?.data as WidgetEventTargetData | undefined
       return {
         title: '动作',
-        multiple: true,
-        options: actionOptions
+        multiple: false,
+        options: getActionOptions(targetData, projectStore.project, projectStore.activePage?.id)
+      }
+    }
+
+    default:
+      return {
+        title: '',
+        multiple: false,
+        options: [] as OptionType[]
       }
   }
+}
+
+const handleChangeEvent = (val?: OptionType[]) => {
+  const item = val?.[0]
+  if (!item || props.node.nodeType !== 'event') return
 
-  return
+  emit('change', {
+    ...props.node,
+    name: item.label,
+    value: item.value,
+    config: {
+      ...props.node.config,
+      data: item.data
+    }
+  })
 }
 
-// 新增按钮样式
-const addBtnStyle = computed(() => {
-  const node = props.node
-  return {
-    backgroundColor:
-      node.parentType === 'root' ? '#4b85e2' : node.parentType === 'event' ? '#4B9889' : '#4EB2BF'
-  }
-})
+const handleChangeTarget = (val?: OptionType[]) => {
+  const item = val?.[0]
+  if (!item || props.node.nodeType !== 'target') return
 
-const handleChangeEvent = (val?: any[]) => {
-  if (val?.length) {
-    const item = val[0]
-    props.node.name = item.value
-    props.node.config.data = item?.data
-  }
+  const targetData = item.data as WidgetEventTargetData
+  const builtinActionData = getBuiltinActionData(targetData)
+  const actionOptions = getActionOptions(targetData, projectStore.project, projectStore.activePage?.id)
+  const actionOption = builtinActionData
+    ? actionOptions.find((option) => option.value === builtinActionData.actionKey)
+    : undefined
+
+  const currentFirstChild = props.node.children?.[0]
+  const nextChildren: NodeItemType[] =
+    targetData.kind === 'custom_code'
+      ? [
+          {
+            id: currentFirstChild?.id || v4(),
+            name: '自定义代码',
+            nodeType: 'action',
+            value: currentFirstChild?.value ?? '',
+            config: {
+              data: {
+                kind: 'custom_code',
+                targetData
+              }
+            }
+          }
+        ]
+      : actionOption
+        ? [
+            {
+              id: currentFirstChild?.id || v4(),
+              name: actionOption.label,
+              nodeType: 'action',
+              value: klona(actionOption.defaultValue),
+              config: {
+                data: {
+                  targetData,
+                  actionData: actionOption.data,
+                  kind: targetData.kind
+                }
+              }
+            }
+          ]
+        : []
+
+  emit('change', {
+    ...props.node,
+    name: item.label,
+    value: item.value,
+    config: {
+      ...props.node.config,
+      data: item.data
+    },
+    children: isBuiltinTarget(targetData) ? nextChildren : []
+  })
 }
 </script>

+ 115 - 49
src/renderer/src/views/designer/workspace/composite/eventEdit/SelectPopover.vue

@@ -1,47 +1,55 @@
 <template>
   <el-popover
+    ref="popoverRef"
+    :teleported="true"
     append-to="body"
     trigger="click"
-    popper-style="width: 440px;"
-    ref="popoverRef"
+    placement="top"
+    popper-class="event-edit-select-popover"
+    popper-style="width: 440px; z-index: 12000;"
+    :popper-options="popperOptions"
     @hide="handleCancel"
   >
     <template #reference>
       <slot></slot>
     </template>
-    <div>
+
+    <div class="w-full">
       <div class="h-32px leading-32px pl-12px bg-bg-tertiary flex justify-between items-center">
         <span>{{ title }}</span>
         <el-input
-          spellcheck="false"
           v-model="search"
+          spellcheck="false"
           style="width: 200px"
           placeholder="search..."
           size="small"
         />
       </div>
-      <div class="my-12px flex flex-wrap gap-x-12px gap-y-8px">
-        <div
-          style="width: calc(50% - 6px)"
-          class="shrink-0"
-          v-for="item in getOptions || []"
-          :key="item.value"
-        >
+
+      <el-scrollbar max-height="360px">
+        <div class="w-full my-12px flex flex-wrap gap-x-12px gap-y-8px">
           <div
-            class="w-full h-20px border-dashed border-1px border-border grid place-items-center cursor-pointer hover:bg-bg-primary"
-            :class="
-              selectedList.includes(item.value)
-                ? 'bg-accent-blue hover:bg-accent-blue! text-white'
-                : ''
-            "
-            @click="handleSelect(item.value)"
+            v-for="item in filteredOptions"
+            :key="item.value"
+            style="width: calc(50% - 8px)"
+            class="shrink-0"
           >
-            {{ item.label }}
+            <div
+              class="w-full min-h-20px border-dashed border-1px border-border grid place-items-center py-4px text-center"
+              :class="getItemClass(item)"
+              @click="handleSelect(item)"
+            >
+              {{ item.label }}
+            </div>
+          </div>
+
+          <div v-if="!filteredOptions.length" class="w-full h-120px grid place-items-center">
+            暂无数据
           </div>
         </div>
-        <div v-if="!getOptions?.length" class="w-full h-120px grid place-items-center">无数据~</div>
-      </div>
-      <div class="w-full flex justify-end">
+      </el-scrollbar>
+
+      <div class="w-full flex justify-end gap-8px">
         <el-button size="small" @click="handleCancel">取消</el-button>
         <el-button size="small" type="primary" @click="handleConfirm">确定</el-button>
       </div>
@@ -53,56 +61,114 @@
 import type { PopoverInstance } from 'element-plus'
 import type { OptionType } from './type'
 
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
 
 const emit = defineEmits<{
   (e: 'confirm', value?: OptionType[]): void
 }>()
-const props = defineProps<{
-  title?: string
-  multiple?: boolean
-  options?: OptionType[]
-  selected?: string | string[]
-}>()
+
+const props = withDefaults(
+  defineProps<{
+    title?: string
+    multiple?: boolean
+    options?: OptionType[]
+    selected?: string | string[]
+  }>(),
+  {
+    title: '',
+    multiple: false,
+    options: () => []
+  }
+)
 
 const selectedList = ref<string[]>([])
 const search = ref('')
 const popoverRef = ref<PopoverInstance>()
-// 搜索
-const getOptions = computed(() => {
-  if (!search.value) {
-    return props.options
+
+const popperOptions = {
+  strategy: 'fixed',
+  modifiers: [
+    {
+      name: 'preventOverflow',
+      options: {
+        boundary: document.body,
+        padding: 8
+      }
+    },
+    {
+      name: 'flip',
+      options: {
+        fallbackPlacements: ['top', 'bottom', 'right', 'left']
+      }
+    }
+  ]
+}
+
+const normalizeSelected = (value?: string | string[]) => {
+  if (!value) return []
+  return Array.isArray(value) ? [...value] : [value]
+}
+
+watch(
+  () => props.selected,
+  (value) => {
+    selectedList.value = normalizeSelected(value)
+  },
+  {
+    immediate: true
   }
-  return props.options?.filter((item) => item.label.includes(search.value))
+)
+
+const filteredOptions = computed(() => {
+  const keyword = search.value.trim().toLowerCase()
+  const options = props.options || []
+
+  if (!keyword) return options
+
+  return options.filter((item) => item.label.toLowerCase().includes(keyword))
 })
 
-// 选中处理
-const handleSelect = (value: string) => {
-  if (selectedList.value.includes(value)) {
-    selectedList.value = selectedList.value.filter((item) => item !== value)
-    return
+const getItemClass = (item: OptionType) => {
+  if (item.disabled) {
+    return 'cursor-not-allowed bg-bg-secondary text-text-secondary opacity-50'
   }
-  if (props.multiple) {
-    selectedList.value = [...selectedList.value, value]
-  } else {
-    selectedList.value = [value]
+
+  if (selectedList.value.includes(item.value)) {
+    return 'cursor-pointer bg-accent-blue text-white hover:bg-accent-blue!'
   }
+
+  return 'cursor-pointer hover:bg-bg-primary'
+}
+
+const handleSelect = (item: OptionType) => {
+  if (item.disabled) return
+
+  if (selectedList.value.includes(item.value)) {
+    selectedList.value = selectedList.value.filter((value) => value !== item.value)
+    return
+  }
+
+  selectedList.value = props.multiple ? [...selectedList.value, item.value] : [item.value]
 }
 
-// 取消
 const handleCancel = () => {
   popoverRef.value?.hide()
-  selectedList.value = []
+  selectedList.value = normalizeSelected(props.selected)
 }
 
-// 确定
 const handleConfirm = () => {
-  const result = props.options?.filter((item) => selectedList.value.includes(item.value))
+  const result = (props.options || []).filter((item) => selectedList.value.includes(item.value))
   handleCancel()
   setTimeout(() => {
     emit('confirm', result)
-  }, 500)
+  }, 200)
 }
 </script>
 
-<style lang="less" scoped></style>
+<style lang="less">
+.event-edit-select-popover.el-popper {
+  z-index: 12000 !important;
+  max-height: calc(100vh - 24px);
+  overflow: hidden;
+}
+</style>

+ 556 - 135
src/renderer/src/views/designer/workspace/composite/eventEdit/config.ts

@@ -1,149 +1,570 @@
+import type { IProject } from '@/store/modules/project'
+import type { ComponentSchema } from '@/lvgl-widgets/type'
+import type {
+  WidgetEventActionData,
+  WidgetEventTargetData,
+  WidgetEventTriggerData
+} from '@/types/event'
+import type { OptionType } from './type'
+
+import { klona } from 'klona'
+import { bfsWalk } from 'simple-mind-map/src/utils'
+import LvglWidgets from '@/lvgl-widgets'
 import { flagOptions, stateOptions } from '@/constants'
 
-/**
- * @description: 组件事件选项
- */
-export const widgetEventOptions = [
-  { label: 'Clicked', value: 'LV_EVENT_SINGLE_CLICKED' },
-  { label: 'Short Clicked', value: 'LV_EVENT_SHORT_CLICKED' },
-  { label: 'Key', value: 'LV_EVENT_KEY' },
-  { label: 'Pressed', value: 'LV_EVENT_PRESSED' },
-  { label: 'Pressing', value: 'LV_EVENT_PRESSING' },
-  { label: 'Press Lost', value: 'LV_EVENT_PRESS_LOST' },
-  { label: 'Long Pressed', value: 'LV_EVENT_LONG_PRESSED' },
-  { label: 'Long Pressed Repeat', value: 'LV_EVENT_LONG_PRESSED_REPEAT' },
-  { label: 'Released', value: 'LV_EVENT_RELEASED' },
-  { label: 'Value Changed', value: 'LV_EVENT_VALUE_CHANGED' },
-  { label: 'Scroll', value: 'LV_EVENT_SCROLL' },
-  { label: 'Scroll Begin', value: 'LV_EVENT_SCROLL_BEGIN' },
-  { label: 'Scroll End', value: 'LV_EVENT_SCROLL_END' },
-  { label: 'Focused', value: 'LV_EVENT_FOCUSED' },
-  { label: 'Defocused', value: 'LV_EVENT_DEFOCUSED' },
-  { label: 'Leave', value: 'LV_EVENT_LEAVE' },
-  { label: 'Hit Test', value: 'LV_EVENT_HIT_TEST' }
+type SchemaActionDescriptor = {
+  actionKey: string
+  defaultValue: any
+  label: string
+  schemas: ComponentSchema[]
+}
+
+const pageLoadAnimationOptions = [
+  { label: 'None', value: 'none' },
+  { label: 'Over Left', value: 'over_left' },
+  { label: 'Over Right', value: 'over_right' },
+  { label: 'Over Top', value: 'over_top' },
+  { label: 'Over Bottom', value: 'over_bottom' },
+  { label: 'Move Left', value: 'move_left' },
+  { label: 'Move Right', value: 'move_right' },
+  { label: 'Move Top', value: 'move_top' },
+  { label: 'Move Bottom', value: 'move_bottom' },
+  { label: 'Fade In', value: 'fade_in' },
+  { label: 'Fade On', value: 'fade_on' },
+  { label: 'Fade Out', value: 'fade_out' },
+  { label: 'Out Left', value: 'out_left' },
+  { label: 'Out Right', value: 'out_right' },
+  { label: 'Out Top', value: 'out_top' },
+  { label: 'Out Bottom', value: 'out_bottom' }
+]
+
+const eventOption = (
+  label: string,
+  eventCode: string,
+  extra: Omit<WidgetEventTriggerData, 'eventCode'> = {}
+) =>
+  ({
+    label,
+    value:
+      extra.key || extra.direction ? `${eventCode}:${extra.key || extra.direction}` : eventCode,
+    data: {
+      eventCode,
+      ...extra
+    } satisfies WidgetEventTriggerData
+  }) satisfies OptionType
+
+const commonEventOptions = [
+  eventOption('Clicked', 'LV_EVENT_CLICKED'),
+  eventOption('Short Clicked', 'LV_EVENT_SHORT_CLICKED'),
+  eventOption('Single Clicked', 'LV_EVENT_SINGLE_CLICKED'),
+  eventOption('Double Clicked', 'LV_EVENT_DOUBLE_CLICKED'),
+  eventOption('Triple Clicked', 'LV_EVENT_TRIPLE_CLICKED'),
+  eventOption('Pressed', 'LV_EVENT_PRESSED'),
+  eventOption('Pressing', 'LV_EVENT_PRESSING'),
+  eventOption('Press Lost', 'LV_EVENT_PRESS_LOST'),
+  eventOption('Long Pressed', 'LV_EVENT_LONG_PRESSED'),
+  eventOption('Long Pressed Repeat', 'LV_EVENT_LONG_PRESSED_REPEAT'),
+  eventOption('Released', 'LV_EVENT_RELEASED'),
+  eventOption('Value Changed', 'LV_EVENT_VALUE_CHANGED'),
+  eventOption('Scroll', 'LV_EVENT_SCROLL'),
+  eventOption('Scroll Begin', 'LV_EVENT_SCROLL_BEGIN'),
+  eventOption('Scroll End', 'LV_EVENT_SCROLL_END'),
+  eventOption('Gesture Top', 'LV_EVENT_GESTURE', { direction: 'LV_DIR_TOP' }),
+  eventOption('Gesture Bottom', 'LV_EVENT_GESTURE', { direction: 'LV_DIR_BOTTOM' }),
+  eventOption('Gesture Left', 'LV_EVENT_GESTURE', { direction: 'LV_DIR_LEFT' }),
+  eventOption('Gesture Right', 'LV_EVENT_GESTURE', { direction: 'LV_DIR_RIGHT' }),
+  eventOption('Key', 'LV_EVENT_KEY'),
+  eventOption('Key Up', 'LV_EVENT_KEY', { key: 'LV_KEY_UP' }),
+  eventOption('Key Down', 'LV_EVENT_KEY', { key: 'LV_KEY_DOWN' }),
+  eventOption('Key Right', 'LV_EVENT_KEY', { key: 'LV_KEY_RIGHT' }),
+  eventOption('Key Left', 'LV_EVENT_KEY', { key: 'LV_KEY_LEFT' }),
+  eventOption('Key Esc', 'LV_EVENT_KEY', { key: 'LV_KEY_ESC' }),
+  eventOption('Key Del', 'LV_EVENT_KEY', { key: 'LV_KEY_DEL' }),
+  eventOption('Key Backspace', 'LV_EVENT_KEY', { key: 'LV_KEY_BACKSPACE' }),
+  eventOption('Key Enter', 'LV_EVENT_KEY', { key: 'LV_KEY_ENTER' }),
+  eventOption('Key Next', 'LV_EVENT_KEY', { key: 'LV_KEY_NEXT' }),
+  eventOption('Key Prev', 'LV_EVENT_KEY', { key: 'LV_KEY_PREV' }),
+  eventOption('Key Home', 'LV_EVENT_KEY', { key: 'LV_KEY_HOME' }),
+  eventOption('Key End', 'LV_EVENT_KEY', { key: 'LV_KEY_END' }),
+  eventOption('Focused', 'LV_EVENT_FOCUSED'),
+  eventOption('Defocused', 'LV_EVENT_DEFOCUSED'),
+  eventOption('Leave', 'LV_EVENT_LEAVE'),
+  eventOption('Hit Test', 'LV_EVENT_HIT_TEST'),
+  eventOption('Insert', 'LV_EVENT_INSERT'),
+  eventOption('Ready', 'LV_EVENT_READY'),
+  eventOption('Cancel', 'LV_EVENT_CANCEL')
 ]
 
-/**
- * 页面事件选项
- */
+export const widgetEventOptions = [...commonEventOptions]
+
 export const pageEventOptions = [
-  { label: 'Clicked', value: 'LV_EVENT_SINGLE_CLICKED' },
-  { label: 'Short Clicked', value: 'LV_EVENT_SHORT_CLICKED' },
-  { label: 'Key', value: 'LV_EVENT_KEY' },
-  { label: 'Pressed', value: 'LV_EVENT_PRESSED' },
-  { label: 'Pressing', value: 'LV_EVENT_PRESSING' },
-  { label: 'Press Lost', value: 'LV_EVENT_PRESS_LOST' },
-  { label: 'Long Pressed', value: 'LV_EVENT_LONG_PRESSED' },
-  { label: 'Long Pressed Repeat', value: 'LV_EVENT_LONG_PRESSED_REPEAT' },
-  { label: 'Released', value: 'LV_EVENT_RELEASED' },
-  { label: 'Value Changed', value: 'LV_EVENT_VALUE_CHANGED' },
-  { label: 'Scroll', value: 'LV_EVENT_SCROLL' },
-  { label: 'Scroll Begin', value: 'LV_EVENT_SCROLL_BEGIN' },
-  { label: 'Scroll End', value: 'LV_EVENT_SCROLL_END' },
-  { label: 'Focused', value: 'LV_EVENT_FOCUSED' },
-  { label: 'Defocused', value: 'LV_EVENT_DEFOCUSED' },
-  { label: 'Leave', value: 'LV_EVENT_LEAVE' },
-  { label: 'Hit Test', value: 'LV_EVENT_HIT_TEST' },
-  { label: 'Unload Start', value: 'LV_EVENT_SCREEN_UNLOAD_START' },
-  { label: 'Load Start', value: 'LV_EVENT_SCREEN_LOAD_START' },
-  { label: 'Loaded', value: 'LV_EVENT_SCREEN_LOADED' },
-  { label: 'Unloaded', value: 'LV_EVENT_SCREEN_UNLOADED' },
-  { label: 'Created', value: 'LV_EVENT_CHILD_CREATED' }
+  ...commonEventOptions,
+  eventOption('Load Start', 'LV_EVENT_SCREEN_LOAD_START'),
+  eventOption('Loaded', 'LV_EVENT_SCREEN_LOADED'),
+  eventOption('Unload Start', 'LV_EVENT_SCREEN_UNLOAD_START'),
+  eventOption('Unloaded', 'LV_EVENT_SCREEN_UNLOADED')
 ]
 
-/**
- * @description: 动作选项
- */
-export const actionOptions = [
-  { label: 'X', value: 'x', valueType: 'number', defaultValue: 0 },
-  { label: 'Y', value: 'y', valueType: 'number', defaultValue: 0 },
-  { label: 'Width', value: 'width', valueType: 'number', defaultValue: 100 },
-  { label: 'Height', value: 'height', valueType: 'number', defaultValue: 100 },
-  { label: 'Text', value: 'text', valueType: 'string', defaultValue: 'default' },
-  { label: 'Font Size', value: 'font_size', valueType: 'number', defaultValue: 12 },
-  {
-    label: 'Add Flag',
-    value: 'add_flag',
-    valueType: 'select',
-    multiple: true
-  },
-  {
-    label: 'Remove Flag',
-    value: 'remove_flag',
-    valueType: 'select',
-    multiple: true
-  },
-  { label: 'Background Color', value: 'bg_color', valueType: 'color' },
-  { label: 'Background Alpha', value: 'bg_alpha', valueType: 'number', min: 0, max: 255 },
-  { label: 'Gradient Color', value: 'bg_grad_color', valueType: 'color' },
-  {
-    label: 'Gradient Direction',
-    value: 'grad_dir',
-    valueType: 'select'
-  },
-  { label: 'Background Image Alpha', value: 'bg_img_alpha', valueType: 'number', min: 0, max: 255 },
-  { label: 'Background Image Render Color', value: 'bg_img_render_color', valueType: 'color' },
-  { label: 'Image Alpha', value: 'img_alpha', valueType: 'number', min: 0, max: 255 },
-  { label: 'Image Render Color', value: 'img_render_color', valueType: 'color' },
-  { label: 'Image Render Alpha', value: 'img_render_alpha', valueType: 'number', min: 0, max: 255 },
-  { label: 'Alpha', value: 'alpha', valueType: 'number', min: 0, max: 255 },
-  {
-    label: 'Border',
-    value: 'border_type',
-    valueType: 'select'
-  },
-  { label: 'Border Size', value: 'border_width', valueType: 'number', defaultValue: 1 },
-  { label: 'Border Alpha', value: 'border_alpha', valueType: 'number', min: 0, max: 255 },
-  { label: 'Border Color', value: 'border_color', valueType: 'color' },
-  { label: 'Light', value: 'led_set_light', valueType: 'number', min: 0, max: 255 },
-  { label: 'Color', value: 'led_color', valueType: 'color' },
-  { label: 'Radius', value: 'radius', valueType: 'number' },
-  { label: 'Visible', value: 'visible', valueType: 'boolean' },
-  { label: 'Add State', value: 'add_state', valueType: 'select' },
-  { label: 'Remove State', value: 'remove_state', valueType: 'select' },
-  {
-    label: 'Rotate',
-    value: 'rotate',
-    valueType: 'rotate',
-    defaultValue: {
-      x: 0,
-      y: 0,
-      angle: 0
+const builtinVisibleAction: SchemaActionDescriptor = {
+  actionKey: 'builtin.visible',
+  label: '显示',
+  defaultValue: true,
+  schemas: [
+    {
+      label: '显示',
+      field: 'payload',
+      labelWidth: '120px',
+      valueType: 'switch'
     }
-  },
-  { label: 'Zoom', value: 'zoom', valueType: 'number' },
-  { label: 'Widget Value', value: 'widget_value', valueType: 'string' },
-  {
-    label: 'Play Animation',
-    value: 'play_animation',
-    valueType: 'animation',
-    defaultValue: {
-      animation: undefined,
-      before: undefined,
-      after: undefined
+  ]
+}
+
+function getSchemaLabel(schema: ComponentSchema): string {
+  if (schema.label) return schema.label
+
+  const prefix = typeof schema.slots === 'object' ? schema.slots?.prefix : undefined
+  if (typeof prefix === 'string' && prefix.trim()) return prefix
+
+  return schema.field?.split('.').pop() || '动作'
+}
+
+function isSchemaActionable(schema?: ComponentSchema): boolean {
+  if (!schema?.field) return false
+  if (schema.valueType === 'dependency') return false
+  if (schema.valueType === 'part') return false
+  if (schema.valueType === 'code') return false
+  if (!schema.valueType) return false
+  if (schema.field === 'name') return false
+  if (schema.field === 'props.head_code') return false
+  if (schema.field === 'props.feature_code') return false
+  return true
+}
+
+function createDefaultValue(schema: ComponentSchema): any {
+  if ((schema as any).defaultValue !== undefined) return klona((schema as any).defaultValue)
+
+  switch (schema.valueType) {
+    case 'number':
+    case 'slider':
+      return 0
+    case 'switch':
+      return false
+    case 'checkbox':
+      return []
+    case 'select':
+    case 'text':
+    case 'textarea':
+    case 'image':
+    case 'file':
+    case 'symbol':
+    case 'date':
+    case 'time':
+    case 'font':
+      return ''
+    case 'color':
+      return '#000000ff'
+    case 'group':
+      return {}
+    case 'animation':
+      return {
+        animation: undefined,
+        before: undefined,
+        after: undefined
+      }
+    case 'rotate':
+      return {
+        x: 0,
+        y: 0,
+        angle: 0
+      }
+    default:
+      return {}
+  }
+}
+
+function cloneActionSchema(schema: ComponentSchema): ComponentSchema {
+  const cloned = klona(schema)
+  cloned.field = 'payload'
+  return cloned
+}
+
+function flattenComponentSchemas(items: ComponentSchema[] = []): SchemaActionDescriptor[] {
+  const result: SchemaActionDescriptor[] = []
+
+  items.forEach((item) => {
+    if (!item) return
+
+    if (item.valueType === 'group') {
+      if (!isSchemaActionable(item)) {
+        result.push(...flattenComponentSchemas(item.children || []))
+        return
+      }
+
+      result.push({
+        actionKey: item.field!,
+        label: getSchemaLabel(item),
+        schemas: [cloneActionSchema(item)],
+        defaultValue: createDefaultValue(item)
+      })
+      return
     }
+
+    if (!isSchemaActionable(item)) return
+
+    result.push({
+      actionKey: item.field!,
+      label: getSchemaLabel(item),
+      schemas: [cloneActionSchema(item)],
+      defaultValue: createDefaultValue(item)
+    })
+  })
+
+  return result
+}
+
+function getStyleActionDescriptors(items: ComponentSchema[] = []): SchemaActionDescriptor[] {
+  return items
+    .filter((item) => !!item && isSchemaActionable(item))
+    .map((item) => ({
+      actionKey: `style.${item.field}`,
+      label: item.label || item.field || '样式',
+      schemas: [cloneActionSchema(item)],
+      defaultValue: createDefaultValue(item)
+    }))
+}
+
+function getTargetModel(targetData?: WidgetEventTargetData) {
+  if (!targetData) return
+  if (targetData.kind === 'page' || targetData.kind === 'load_page') return LvglWidgets.page
+  if (!targetData.targetType) return
+  return LvglWidgets[targetData.targetType]
+}
+
+function getSchemaActionDescriptors(targetData?: WidgetEventTargetData): SchemaActionDescriptor[] {
+  const model = getTargetModel(targetData)
+  if (!model) return []
+
+  return [
+    builtinVisibleAction,
+    ...flattenComponentSchemas(model.config.props || []),
+    ...flattenComponentSchemas(model.config.coreProps || []),
+    ...getStyleActionDescriptors(model.config.styles || [])
+  ]
+}
+
+function getLoadPageActionDescriptor(
+  project?: IProject,
+  currentPageId?: string
+): SchemaActionDescriptor {
+  const pageOptions =
+    project?.screens.flatMap((screen) =>
+      screen.pages
+        .filter((page) => page.id !== currentPageId)
+        .map((page) => ({
+          label: `[${screen.name}] ${page.name}`,
+          value: page.id
+        }))
+    ) || []
+
+  return {
+    actionKey: 'builtin.load_page',
+    label: '加载页面',
+    defaultValue: {
+      pageId: pageOptions[0]?.value || '',
+      animationType: 'none',
+      duration: 0,
+      delay: 0,
+      autoDeletePrevious: false,
+      cleanupCurrent: false
+    },
+    schemas: [
+      {
+        label: '目标页面',
+        field: 'payload.pageId',
+        labelWidth: '120px',
+        valueType: 'select',
+        componentProps: {
+          options: pageOptions
+        }
+      },
+      {
+        label: '动画类型',
+        field: 'payload.animationType',
+        labelWidth: '120px',
+        valueType: 'select',
+        componentProps: {
+          options: pageLoadAnimationOptions
+        }
+      },
+      {
+        label: '动画时长(ms)',
+        field: 'payload.duration',
+        labelWidth: '120px',
+        valueType: 'number',
+        componentProps: {
+          min: 0
+        }
+      },
+      {
+        label: '动画延时(ms)',
+        field: 'payload.delay',
+        labelWidth: '120px',
+        valueType: 'number',
+        componentProps: {
+          min: 0
+        }
+      },
+      {
+        label: '自动删除旧页面',
+        field: 'payload.autoDeletePrevious',
+        labelWidth: '120px',
+        valueType: 'switch'
+      },
+      {
+        label: '清理当前页面',
+        field: 'payload.cleanupCurrent',
+        labelWidth: '120px',
+        valueType: 'switch'
+      }
+    ]
   }
-]
+}
+
+function getLanguageSwitchActionDescriptor(project?: IProject): SchemaActionDescriptor {
+  const languageOptions =
+    project?.languages.map((item) => ({
+      label: item.key,
+      value: item.key
+    })) || []
+
+  return {
+    actionKey: 'builtin.language_switch',
+    label: '语言切换',
+    defaultValue: {
+      languageKey: languageOptions[0]?.value || '',
+      refreshPage: true
+    },
+    schemas: [
+      {
+        label: '语言',
+        field: 'payload.languageKey',
+        labelWidth: '120px',
+        valueType: 'select',
+        componentProps: {
+          options: languageOptions
+        }
+      },
+      {
+        label: '刷新页面',
+        field: 'payload.refreshPage',
+        labelWidth: '120px',
+        valueType: 'switch'
+      }
+    ]
+  }
+}
+
+function getThemeSwitchActionDescriptor(project?: IProject): SchemaActionDescriptor {
+  const themeOptions =
+    project?.themes.map((item) => ({
+      label: item.key,
+      value: item.key
+    })) || []
+
+  const preferredTheme =
+    themeOptions.find((item) => item.value !== project?.currentTheme)?.value ||
+    themeOptions[0]?.value ||
+    ''
+
+  const isDualScreen = project?.meta?.screenType === 'double'
+
+  return {
+    actionKey: 'builtin.theme_switch',
+    label: '主题切换',
+    defaultValue: {
+      screen1Theme: preferredTheme,
+      ...(isDualScreen ? { screen2Theme: preferredTheme } : {})
+    },
+    schemas: [
+      {
+        label: '屏幕1主题',
+        field: 'payload.screen1Theme',
+        labelWidth: '120px',
+        valueType: 'select',
+        componentProps: {
+          options: themeOptions
+        }
+      },
+      ...(isDualScreen
+        ? [
+            {
+              label: '屏幕2主题',
+              field: 'payload.screen2Theme',
+              labelWidth: '120px',
+              valueType: 'select',
+              componentProps: {
+                options: themeOptions
+              }
+            } satisfies ComponentSchema
+          ]
+        : [])
+    ]
+  }
+}
+
+function getBuiltinActionDescriptors(
+  targetData?: WidgetEventTargetData,
+  project?: IProject,
+  currentPageId?: string
+) {
+  if (!targetData) return []
+
+  switch (targetData.kind) {
+    case 'load_page':
+      return [getLoadPageActionDescriptor(project, currentPageId)]
+    case 'language_switch':
+      return [getLanguageSwitchActionDescriptor(project)]
+    case 'theme_switch':
+      return [getThemeSwitchActionDescriptor(project)]
+    case 'custom_code':
+      return []
+    default:
+      return getSchemaActionDescriptors(targetData)
+  }
+}
+
+export function isBuiltinTarget(targetData?: WidgetEventTargetData) {
+  return (
+    targetData?.kind === 'custom_code' ||
+    targetData?.kind === 'load_page' ||
+    targetData?.kind === 'language_switch' ||
+    targetData?.kind === 'theme_switch'
+  )
+}
+
+export function getBuiltinActionData(
+  targetData?: WidgetEventTargetData
+): WidgetEventActionData | null {
+  if (!targetData) return null
+
+  switch (targetData.kind) {
+    case 'load_page':
+      return { actionKey: 'builtin.load_page' }
+    case 'language_switch':
+      return { actionKey: 'builtin.language_switch' }
+    case 'theme_switch':
+      return { actionKey: 'builtin.theme_switch' }
+    default:
+      return null
+  }
+}
+
+export function getEventOptions(widgetType?: string) {
+  return widgetType === 'page' ? pageEventOptions : widgetEventOptions
+}
+
+export function getTargetOptions(project?: IProject): OptionType[] {
+  const options: OptionType[] = [
+    {
+      label: '加载页面',
+      value: 'builtin:load_page',
+      data: {
+        kind: 'load_page'
+      } satisfies WidgetEventTargetData
+    },
+    {
+      label: '自定义代码',
+      value: 'builtin:custom_code',
+      data: {
+        kind: 'custom_code'
+      } satisfies WidgetEventTargetData
+    },
+    {
+      label: '语言切换',
+      value: 'builtin:language_switch',
+      data: {
+        kind: 'language_switch'
+      } satisfies WidgetEventTargetData
+    },
+    {
+      label: '主题切换',
+      value: 'builtin:theme_switch',
+      data: {
+        kind: 'theme_switch'
+      } satisfies WidgetEventTargetData
+    }
+  ]
+
+  project?.screens.forEach((screen) => {
+    screen.pages.forEach((page) => {
+      options.push({
+        label: `[${screen.name}] [Page] ${page.name}`,
+        value: `page:${page.id}`,
+        data: {
+          kind: 'page',
+          pageId: page.id,
+          screenId: screen.id,
+          targetId: page.id,
+          targetType: 'page'
+        } satisfies WidgetEventTargetData
+      })
+
+      bfsWalk(page, (child) => {
+        if (child.id === page.id) return
+
+        options.push({
+          label: `[${screen.name}] [${page.name}] ${child.name}`,
+          value: `widget:${child.id}`,
+          data: {
+            kind: 'widget',
+            pageId: page.id,
+            screenId: screen.id,
+            targetId: child.id,
+            targetType: child.type
+          } satisfies WidgetEventTargetData
+        })
+      })
+    })
+  })
+
+  return options
+}
+
+export function getActionOptions(
+  targetData?: WidgetEventTargetData,
+  project?: IProject,
+  currentPageId?: string
+): OptionType[] {
+  const descriptors = getBuiltinActionDescriptors(targetData, project, currentPageId)
+
+  return descriptors.map((item) => ({
+    label: item.label,
+    value: item.actionKey,
+    data: {
+      actionKey: item.actionKey
+    } satisfies WidgetEventActionData,
+    defaultValue: klona(item.defaultValue)
+  }))
+}
+
+export function resolveActionDescriptor(
+  targetData?: WidgetEventTargetData,
+  actionData?: WidgetEventActionData,
+  project?: IProject,
+  currentPageId?: string
+) {
+  if (!targetData || !actionData?.actionKey) return
+
+  return getBuiltinActionDescriptors(targetData, project, currentPageId).find(
+    (item) => item.actionKey === actionData.actionKey
+  )
+}
 
-/**
- * @description: 选项map
- */
 export const optionMap = {
-  add_flag: flagOptions,
-  remove_flag: flagOptions,
-  add_state: stateOptions,
-  remove_state: stateOptions,
-  grid_dir: [
-    { label: 'None', value: 'none' },
-    { label: 'Vertical', value: 'vertical' },
-    { label: 'Horizontal', value: 'horizontal' }
+  visible: [
+    { label: '显示', value: true },
+    { label: '隐藏', value: false }
   ],
-  border_type: [
-    { label: 'None', value: 'none' },
-    { label: 'All', value: 'all' },
-    { label: 'Top', value: 'top' },
-    { label: 'Bottom', value: 'bottom' },
-    { label: 'Left', value: 'left' },
-    { label: 'Right', value: 'right' }
-  ]
+  flags: flagOptions,
+  states: stateOptions
 }

+ 325 - 177
src/renderer/src/views/designer/workspace/composite/eventEdit/index.vue

@@ -1,245 +1,393 @@
 <template>
-  <div class="w-full h-full" ref="containerRef"></div>
+  <div ref="containerRef" class="w-full h-full"></div>
 </template>
 
 <script setup lang="tsx">
-import { ref, defineComponent, createApp, watch, nextTick } from 'vue'
+import type { AppContext } from 'vue'
+import type { NodeItemType, OptionType } from './type'
+import type { WidgetEventTargetData } from '@/types/event'
+
+import { createApp, defineComponent, getCurrentInstance, nextTick, ref, watch } from 'vue'
 import MindMap from 'simple-mind-map'
-import { useElementSize, watchOnce } from '@vueuse/core'
-import defaultTheme from './theme'
-import { useResizeObserver } from '@vueuse/core'
-import NodeItem from './NodeItem.vue'
-import { useProjectStore } from '@/store/modules/project'
-import { useAppStore } from '@/store/modules/app'
 import { bfsWalk } from 'simple-mind-map/src/utils'
 import { klona } from 'klona'
-
-import type { NodeItemType, OptionType } from './type'
 import { v4 } from 'uuid'
+import { useElementSize, useResizeObserver, watchOnce } from '@vueuse/core'
+
+import defaultTheme from './theme'
+import NodeItem from './NodeItem.vue'
+import { useAppStore } from '@/store/modules/app'
+import { useProjectStore } from '@/store/modules/project'
+import { getActionOptions, getBuiltinActionData, isBuiltinTarget } from './config'
 
 const containerRef = ref<HTMLElement | null>(null)
 const { width, height } = useElementSize(containerRef)
 const mindMap = ref<MindMap | null>(null)
+const mindMapData = ref<NodeItemType>()
+
 const projectStore = useProjectStore()
+const appStore = useAppStore()
+const appContext = getCurrentInstance()?.appContext
 
-// 监听容器尺寸变化
 useResizeObserver(containerRef, () => {
   if (mindMap.value && width.value && height.value) {
     mindMap.value.resize()
   }
 })
 
-const mindMapData = ref<NodeItemType>()
-const appStore = useAppStore()
+const getAddNode = (parentId: string, parentType: 'root' | 'event' | 'target'): NodeItemType => ({
+  id: v4(),
+  name: 'add_btn',
+  nodeType: 'add',
+  parentId,
+  parentType
+})
 
-watch(
-  () => [projectStore.activeWidget, appStore.compositeTabAcitve],
-  async () => {
-    const widget = klona(projectStore.activeWidget)
-    // 当前选择的最后一个元素且不是当前元素 切换
-    if (widget && widget.id !== mindMapData.value?.id) {
-      const data: NodeItemType = {
-        id: widget.id,
-        name: widget.name,
-        nodeType: 'root',
-        children: widget.events as unknown as any,
-        config: null
-      }
-      // 添加增加节点
-      bfsWalk(data, (child) => {
-        if (child.children && child.nodeType !== 'action') {
-          child.children.push(getAddNode(child.id, child.nodeType))
+const createBuiltinActionNode = (
+  targetId: string,
+  targetData: WidgetEventTargetData,
+  option?: OptionType
+): NodeItemType | null => {
+  if (targetData.kind === 'custom_code') {
+    return {
+      id: v4(),
+      name: '自定义代码',
+      nodeType: 'action',
+      parentId: targetId,
+      parentType: 'target',
+      value: '',
+      config: {
+        data: {
+          targetData,
+          kind: 'custom_code'
         }
-      })
-      mindMapData.value = data
-      await nextTick()
-      mindMap.value?.setData(mindMapData.value)
+      }
     }
   }
-)
 
-watch(
-  () => mindMapData.value,
-  () => {
-    const data = klona(mindMapData.value)
-    const item = projectStore.activeWidget
-    if (data && item?.id === data.id) {
-      // 移除添加按钮
-      bfsWalk(data, (child) => {
-        if (child.children) {
-          child.children = child.children.filter((item) => item.nodeType !== 'add')
-        }
-      })
-      item.events = data.children as unknown as any
+  if (!option) return null
+
+  return {
+    id: v4(),
+    name: option.label,
+    nodeType: 'action',
+    parentId: targetId,
+    parentType: 'target',
+    value: klona(option.defaultValue),
+    config: {
+      data: {
+        targetData,
+        actionData: option.data,
+        kind: targetData.kind
+      }
     }
-  },
-  {
-    immediate: true,
-    deep: true
   }
-)
+}
 
-// 刷新节点
-const refresh = () => {
-  mindMap.value?.updateData(mindMapData.value)
+const createTargetChildren = (targetId: string, targetData: WidgetEventTargetData): NodeItemType[] => {
+  if (!isBuiltinTarget(targetData)) {
+    return [getAddNode(targetId, 'target')]
+  }
+
+  const builtinActionData = getBuiltinActionData(targetData)
+  const actionOptions = getActionOptions(targetData, projectStore.project, projectStore.activePage?.id)
+  const actionOption = builtinActionData
+    ? actionOptions.find((item) => item.value === builtinActionData.actionKey)
+    : undefined
+  const actionNode = createBuiltinActionNode(targetId, targetData, actionOption)
+
+  return actionNode ? [actionNode] : []
+}
+
+const createTargetNode = (option: OptionType): NodeItemType => {
+  const id = v4()
+  const targetData = option.data as WidgetEventTargetData
+
+  return {
+    id,
+    name: option.label,
+    value: option.value,
+    nodeType: 'target',
+    config: {
+      data: targetData
+    },
+    children: createTargetChildren(id, targetData)
+  }
+}
+
+const findNodeById = (id: string) => {
+  let result: NodeItemType | undefined
+
+  bfsWalk(mindMapData.value, (node) => {
+    if ((node as NodeItemType).id === id) {
+      result = node as NodeItemType
+    }
+  })
+
+  return result
 }
 
-// 获取添加按钮
-const getAddNode = (parentId: string, parentType: NodeItemType['parentType']): NodeItemType => {
+const createActionNode = (parentId: string, option: OptionType): NodeItemType => {
+  const parentNode = findNodeById(parentId)
+  const targetData = parentNode?.config?.data as WidgetEventTargetData | undefined
+
   return {
     id: v4(),
-    name: 'add_btn',
-    nodeType: 'add',
+    name: option.label,
+    nodeType: 'action',
     parentId,
-    parentType
+    parentType: 'target',
+    value: klona(option.defaultValue),
+    config: {
+      data: {
+        targetData,
+        actionData: option.data,
+        kind: targetData?.kind
+      }
+    }
   }
 }
 
-// 添加节点
+const shouldHaveAddNode = (node: NodeItemType) => {
+  if (node.nodeType === 'root' || node.nodeType === 'event') return true
+  if (node.nodeType !== 'target') return false
+
+  const targetData = node.config?.data as WidgetEventTargetData | undefined
+  return !isBuiltinTarget(targetData)
+}
+
+const attachUiNodes = (node: NodeItemType): NodeItemType => {
+  if (node.nodeType === 'add' || node.nodeType === 'action') {
+    return { ...node }
+  }
+
+  const children = (node.children || [])
+    .filter((child) => child.nodeType !== 'add')
+    .map((child) =>
+      attachUiNodes({
+        ...child,
+        parentId: node.id,
+        parentType: node.nodeType === 'root' ? 'root' : node.nodeType
+      })
+    )
+
+  if (shouldHaveAddNode(node)) {
+    children.push(getAddNode(node.id, node.nodeType === 'root' ? 'root' : node.nodeType))
+  }
+
+  return {
+    ...node,
+    children
+  }
+}
+
+const stripUiNodes = (node?: NodeItemType): NodeItemType | undefined => {
+  if (!node || node.nodeType === 'add') return
+
+  const baseNode: NodeItemType = {
+    ...node
+  }
+
+  if (node.children) {
+    baseNode.children = node.children
+      .map((child) => stripUiNodes(child))
+      .filter(Boolean) as NodeItemType[]
+  }
+
+  delete baseNode.parentId
+  delete baseNode.parentType
+  return baseNode
+}
+
+const updateStoreEvents = () => {
+  const widget = projectStore.activeWidget
+  const data = stripUiNodes(mindMapData.value)
+
+  if (!widget || !data || widget.id !== data.id) return
+  widget.events = (data.children || []) as any
+}
+
+const refresh = () => {
+  if (!mindMapData.value) return
+
+  const cleanData = stripUiNodes(mindMapData.value)
+  if (!cleanData) return
+
+  mindMapData.value = attachUiNodes(cleanData)
+  updateStoreEvents()
+  mindMap.value?.updateData(mindMapData.value)
+}
+
+const getSiblingUsedValues = (parentId: string, currentNodeId?: string) => {
+  const parentNode = findNodeById(parentId)
+  if (!parentNode?.children) return new Set<string>()
+
+  return new Set(
+    parentNode.children
+      .filter((item) => item.id !== currentNodeId && item.nodeType !== 'add')
+      .map((item) => item.value)
+      .filter(Boolean) as string[]
+  )
+}
+
 const handleAddNode = (parentId: string, parentType: string, values: OptionType[]) => {
-  const nodes: NodeItemType[] = []
+  const parentNode = findNodeById(parentId)
+  if (!parentNode?.children?.length) return
+
+  const usedValues = getSiblingUsedValues(parentId)
+  const insertNodes: NodeItemType[] = []
+
   switch (parentType) {
     case 'root':
-      values.forEach((val) => {
-        const id = v4()
-        nodes.push({
-          id,
-          name: val.label,
-          value: val.value,
-          nodeType: 'event',
-          config: {
-            data: val?.data
-          },
-          children: [getAddNode(id, 'event')]
-        })
-      })
-      break
-    case 'event':
-      values.forEach((val) => {
-        const id = v4()
-        // 自定义代码
-        if (val.value === 'customCode') {
-          nodes.push({
-            id,
-            name: val.label,
-            value: val.value,
-            nodeType: 'target',
-            config: {
-              data: val?.data
-            },
-            children: [
-              {
-                id: v4(),
-                name: 'custom_code',
-                value: '',
-                nodeType: 'action',
-                config: {}
-              }
-            ]
-          })
-        } else {
-          nodes.push({
+      values
+        .filter((item) => !usedValues.has(item.value))
+        .forEach((item) => {
+          const id = v4()
+          insertNodes.push({
             id,
-            name: val.label,
-            nodeType: 'target',
-            value: val.value,
+            name: item.label,
+            value: item.value,
+            nodeType: 'event',
             config: {
-              data: val?.data
+              data: item.data
             },
-            children: [getAddNode(id, 'target')]
+            children: [getAddNode(id, 'event')]
           })
-        }
-      })
+        })
       break
-    case 'target':
-      values.forEach((val) => {
-        const id = v4()
-        nodes.push({
-          id,
-          name: val.value,
-          nodeType: 'action',
-          value: klona(val?.defaultValue),
-          config: {
-            data: val
-          }
+
+    case 'event':
+      values
+        .filter((item) => !usedValues.has(item.value))
+        .forEach((item) => {
+          insertNodes.push(createTargetNode(item))
         })
+      break
+
+    case 'target':
+      values.forEach((item) => {
+        insertNodes.push(createActionNode(parentId, item))
       })
       break
   }
-  bfsWalk(mindMapData.value, (node) => {
-    if (parentId === node.id) {
-      // 往倒数第二个位置插入
-      node.children.splice(node.children.length - 1, 0, ...nodes)
-    }
-  })
+
+  if (!insertNodes.length) return
+
+  parentNode.children.splice(parentNode.children.length - 1, 0, ...insertNodes)
   refresh()
 }
 
-// 删除节点
-const handleDeleteNode = (id: string) => {
+const removeNodeById = (id: string) => {
   bfsWalk(mindMapData.value, (node) => {
-    if (node.children) {
-      node.children = node.children.filter((item) => item.id !== id)
-    }
+    const currentNode = node as NodeItemType
+    if (!currentNode.children?.length) return
+    currentNode.children = currentNode.children.filter((item) => item.id !== id)
   })
+}
+
+const handleDeleteNode = (id: string) => {
+  removeNodeById(id)
   refresh()
 }
 
-// 修改节点
 const handleChangeNode = (node: NodeItemType) => {
-  if (!node) return
-  bfsWalk(mindMapData.value, (child) => {
-    if (child.id === node.id) {
-      child.name = node.name
-      child.config = node.config
-      child.value = node.value
-    }
+  if (!mindMapData.value) return
+
+  bfsWalk(mindMapData.value, (item) => {
+    const currentNode = item as NodeItemType
+    if (currentNode.id !== node.id) return
+    Object.assign(currentNode, node)
+  })
+
+  refresh()
+}
+
+const buildRootNode = async () => {
+  const widget = projectStore.activeWidget
+  if (!widget) {
+    mindMapData.value = undefined
+    return
+  }
+
+  const rootNode = attachUiNodes({
+    id: widget.id,
+    name: widget.name,
+    nodeType: 'root',
+    config: null,
+    children: klona(widget.events || []) as NodeItemType[]
   })
+
+  mindMapData.value = rootNode
+  await nextTick()
+  mindMap.value?.setData(rootNode)
+}
+
+const inheritParentAppContext = (childApp: ReturnType<typeof createApp>, parent?: AppContext) => {
+  if (!parent) return
+
+  Object.assign(childApp._context.components, parent.components)
+  Object.assign(childApp._context.directives, parent.directives)
+  Object.assign(childApp._context.config.globalProperties, parent.config.globalProperties)
+  childApp._context.provides = parent.provides
 }
 
+watch(
+  () => [projectStore.activeWidget?.id, appStore.compositeTabAcitve],
+  async () => {
+    await buildRootNode()
+  },
+  {
+    immediate: true
+  }
+)
+
+watch(
+  () => mindMapData.value,
+  () => {
+    updateStoreEvents()
+  },
+  {
+    deep: true
+  }
+)
+
 watchOnce(
   () => [width.value, height.value],
   ([w, h]) => {
-    if (w && h) {
-      // 初始化思维导图
-      // 配置参考:https://wanglin2.github.io/mind-map-docs/api/constructor/constructor-options.html
-      mindMap.value = new MindMap({
-        el: containerRef.value,
-        themeConfig: defaultTheme,
-        initRootNodePosition: ['5%', 'center'],
-        fitPadding: 12,
-        data: mindMapData.value,
-        readonly: true,
-        isUseCustomNodeContent: true,
-        customCreateNodeContent: (node: any) => {
-          const sourceData = node.nodeData
-          const App = defineComponent({
-            render() {
-              return (
-                <NodeItem
-                  node={sourceData as NodeItemType}
-                  onAdd={handleAddNode}
-                  onDelete={handleDeleteNode}
-                  onChange={handleChangeNode}
-                />
-              )
-            }
-          })
+    if (!w || !h) return
 
-          // return你的自定义DOM节点
-          let div = document.createElement('div')
-          const app = createApp(App)
-          div.style = 'user-select: none;'
-          app.mount(div)
+    mindMap.value = new MindMap({
+      el: containerRef.value,
+      themeConfig: defaultTheme,
+      initRootNodePosition: ['5%', 'center'],
+      fitPadding: 12,
+      data: mindMapData.value,
+      readonly: true,
+      isUseCustomNodeContent: true,
+      customCreateNodeContent: (node: any) => {
+        const sourceData = node.nodeData as NodeItemType
 
-          return div
-        }
-      } as any)
-      // 监听数据变化
-      mindMap.value.on('data_change_detail', (data: any) => {
-        console.log('date change detail', data)
-      })
-    }
+        const App = defineComponent({
+          render() {
+            return (
+              <NodeItem
+                node={sourceData}
+                onAdd={handleAddNode}
+                onDelete={handleDeleteNode}
+                onChange={handleChangeNode}
+              />
+            )
+          }
+        })
+
+        const div = document.createElement('div')
+        const app = createApp(App)
+        inheritParentAppContext(app, appContext)
+        div.style.userSelect = 'none'
+        app.mount(div)
+        return div
+      }
+    } as any)
   }
 )
 </script>

+ 9 - 26
src/renderer/src/views/designer/workspace/composite/eventEdit/type.d.ts

@@ -1,36 +1,19 @@
 export type NodeItemType = {
-  /**
-   * @description: 节点id
-   */
   id: string
-  /**
-   * @description: 节点名称
-   */
   name: string
-  /**
-   * @description: 数据值
-   */
   value?: any
-  /**
-   * @description: 节点类型
-   */
   nodeType: 'root' | 'event' | 'target' | 'action' | 'add'
-  /**
-   * @description: 节点配置
-   */
   config?: any
-  /**
-   * @description: 节点子节点
-   */
   children?: NodeItemType[]
-  /**
-   * @description: 节点父节点
-   */
   parentId?: string
-  /**
-   * @description: 父节点类型
-   */
-  parentType?: 'root' | 'event' | 'target'
+  parentType?: 'root' | 'event' | 'target' | 'action' | 'add'
 }
 
-export type OptionType = { label: string; value: string; [key: string]: any }
+export type OptionType = {
+  label: string
+  value: string
+  disabled?: boolean
+  data?: any
+  defaultValue?: any
+  [key: string]: any
+}