Vue3 表单编辑器:左侧控件菜单 + 中间编辑区的拖拽全流程实现

作者:今天吃了嘛o日期:2026/8/13

Vue3 表单编辑器:左侧控件菜单 + 中间编辑区的拖拽全流程实现

基于 Vue3 + vuedraggable + Pinia 实现的表单编辑器,涵盖从左侧菜单拖拽添加控件、点击添加、中间区域排序调换、复制删除、切换题型等核心交互。本文只摘取代表性代码,梳理整体流程和设计思路。


目录

  • 一、整体架构与数据流
  • 二、技术选型
  • 三、左侧控件菜单(form_menu_drag.vue)
  • 四、中间编辑区(form_render_drag.vue)
  • 五、Pinia Store 核心方法(editor.ts)
  • 六、题目工具栏(drag_tool_bar.vue)
  • 七、核心流程详解
  • 八、性能优化:大列表按需渲染
  • 九、踩坑总结

一、整体架构与数据流

1┌──────────────┐     拖拽(clone) / 点击添加      ┌──────────────────┐
2  左侧菜单      ──────────────────────────────▶   中间编辑区       
3 form_menu_       vuedraggable group             form_render_     
4 drag.vue         name: 'componentsGroup'        drag.vue         
5└──────────────┘                                  └───────┬──────────┘
6                                                         
7                        ┌────────────────────────────────┘
8                         读写 formDataList
9                ┌───────────────┐
10                  Pinia Store  
11                  editor.ts    
12                 ┌───────────┐ 
13                 │formDataList│    唯一数据源
14                 │dragFormData│    拖拽副本(排序用)
15                 │List         
16                 │activeKey       当前选中项
17                 │skipSyncDrag│    拖拽同步锁
18                 └───────────┘ 
19                └───────────────┘
20

核心数据流

  • formDataList(Store)是唯一数据源,所有增删改都操作它
  • dragFormDataList(组件局部)是 vuedraggable 的拖拽副本,仅用于排序,变更后同步回 formDataList
  • skipSyncDrag 是一把锁,拖拽过程中阻止 syncDragList 重建拖拽副本

二、技术选型

技术作用
vuedraggableVue3 拖拽组件,基于 SortableJS 封装
Pinia状态管理,维护 formDataList 等核心数据
lodash-escloneDeep 深拷贝控件配置
IntersectionObserver大列表按需渲染
1npm install vuedraggable sortablejs
2

三、左侧控件菜单(form_menu_drag.vue)

左侧菜单的核心是 vuedraggable + clone 模式:拖拽时不移动原数据,而是克隆一份新数据扔到中间区域。

模板部分

1<template>
2  <div class="form_menu_drag">
3    <div v-for="(item, keyIndex) in formItemConf" :key="keyIndex">
4      <h4 class="tool_bar_title">{{ item.groupTitle }}</h4>
5      <draggable
6        :list="item.groupList"
7        class="tool_item_list"
8        :group="{ name: 'componentsGroup', pull: 'clone', put: false }"
9        :clone="handleClone"
10        :sort="false"
11        :item-key="'renderKey'"
12        @start="onMenuDragStart"
13        @end="onMenuDragEnd"
14      >
15        <template #item="{ element }">
16          <div class="tool_item">
17            <div
18              class="tool_item_drag"
19              :class="{ disabled: checkItem(element?.customConf?.tagName || '') }"
20              @click.stop="handleAdd(element)"
21            >
22              <Icon :icon="[`svg-icon:${element.customConf.tagIcon}`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.svg.md)" />
23              {{ element.customConf.label }}
24            </div>
25          </div>
26        </template>
27      </draggable>
28    </div>
29  </div>
30</template>
31

关键配置解读

属性作用
group.name'componentsGroup'左右两侧 group name 相同,才能跨区域拖拽
group.pull'clone'拖拽时克隆元素,不移动原数据
group.putfalse不允许从中间区域拖回左侧
:clonehandleClone自定义克隆逻辑,生成唯一 key
:sortfalse左侧菜单内部不排序

克隆逻辑 handleClone

1const handleClone = (e) => {
2  const isExist = EditorUtils.checkExist(e, unref(formDataList));
3  if (!isExist) {
4    const cloned = cloneDeep(e);
5    // 生成唯一标识,避免多实例冲突
6    cloned.formItemKey = EditorUtils.getRandomId();
7    cloned.renderKey = EditorUtils.getRandomKey(cloned.formItemKey);
8    cloned.baseConf.paginationNum = editorStore.activePageNum ?? 0;
9
10    // 关键:立即推入 formDataList,并锁定同步
11    editorStore.skipSyncDrag = true;
12    unref(formDataList).push(cloned);
13    return cloned;
14  }
15};
16

为什么 clone 时就要 push 到 formDataList? 因为 vuedraggable 的 clone 机制会在拖拽完成后把克隆元素放进目标 list(即 dragFormDataList),但此时组件已经开始渲染。如果不提前 push 到 store,p_*.vue 组件通过 renderKey 找不到数据就会报错。同时设置 skipSyncDrag = true 防止 syncDragList 重建 dragFormDataList 干扰拖拽。

点击添加 handleAdd

1const handleAdd = async (e) => {
2  const isExist = EditorUtils.checkExist(e, unref(formDataList));
3  if (!isExist) {
4    const item = cloneDeep(e);
5    const tagName = item.customConf.tagName;
6    // 调用 store 方法添加,内部处理位置插入和激活
7    await editorStore.addFormItem(tagName, '', undefined, true);
8    editorStore.setFormSetting(false);
9    // 等两帧后滚动到新题目
10    nextTick(() => {
11      requestAnimationFrame(() => {
12        const newDom = document.querySelector('.drag_item.isActive');
13        newDom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
14      });
15    });
16  }
17};
18

拖拽开始/结束:防护措施

1const onMenuDragStart = () => {
2  // 1. 设置全局拖拽标志(编辑器组件内部通过此标志禁止写入)
3  (window as any).__isDragging = true;
4
5  // 2. 保存所有题目的标题/描述数据(拖拽后恢复用)
6  const preDragTitleData = new Map();
7  unref(formDataList).forEach((item) => {
8    preDragTitleData.set(item.formItemKey, {
9      title: item?.customConf?.title || '',
10      titleHtml: item?.customConf?.titleHtml || '',
11      desc: item?.customConf?.desc || '',
12    });
13  });
14  (window as any).__preDragTitleData = preDragTitleData;
15
16  // 3. 禁用标题/描述/富文本区域的 pointer-events,阻止浏览器 drop 插入
17  const selectors = '.title_readonly, .desc_readonly, [contenteditable="true"], .w-e-text-container';
18  document.querySelectorAll(selectors).forEach((el) => {
19    (el as HTMLElement).style.pointerEvents = 'none';
20  });
21};
22
23const onMenuDragEnd = () => {
24  (window as any).__isDragging = false;
25  // 恢复 pointer-events
26  document.querySelectorAll(selectors).forEach((el) => {
27    (el as HTMLElement).style.pointerEvents = '';
28  });
29  // 通知画布恢复 DOM
30  window.dispatchEvent(new CustomEvent('editor-sync-after-drag'));
31};
32

四、中间编辑区(form_render_drag.vue)

中间区域是 vuedraggable 的接收方和排序方,绑定局部 dragFormDataList,变更后同步回 Store。

模板部分

1<template>
2  <div class="form_render_drag">
3    <div class="drag_content">
4      <!-- 不可拖拽的特殊项(分页、说明等)单独渲染 -->
5      <div v-for="(item, index) in instructionsList" :key="item.formItemKey">
6        <div class="drag_item" @click.stop="selectItem(item.renderKey)">
7          <FormReducer :formItemData="item" />
8        </div>
9      </div>
10
11      <!-- 可拖拽排序的区域 -->
12      <draggable
13        class="draggable_wrap"
14        :group="{ name: 'componentsGroup' }"
15        :list="dragFormDataList"
16        :handle="'.handle_bar'"
17        :item-key="'renderKey'"
18        :sort="true"
19        @start="onDragStart"
20        @end="onDragEnd"
21        @change="draggableChange"
22      >
23        <template #item="{ element, index }">
24          <div
25            class="drag_item"
26            :class="{ isActive: activeKey == resolveFormItem(element).renderKey }"
27            @click.stop="selectItem(resolveFormItem(element).renderKey)"
28            v-show="element?.baseConf?.paginationNum == activePageNum"
29          >
30            <DragToolBar
31              v-if="activeKey == resolveFormItem(element).renderKey"
32              :tagName="resolveFormItem(element)?.customConf?.tagName || ''"
33              :renderKey="resolveFormItem(element).renderKey"
34              @trigger-copy="onCopyFn(element?.customConf?.tagName, element.formItemKey, index + 1)"
35              @trigger-del="onDeleteFn(element.formItemKey, index)"
36              @trigger-change="(type) => onChangeFn(type, element.formItemKey)"
37            />
38            <div class="item_content">
39              <FormReducer :formItemData="resolveFormItem(element)" />
40            </div>
41          </div>
42        </template>
43      </draggable>
44    </div>
45  </div>
46</template>
47

关键配置解读

属性作用
:listdragFormDataList绑定局部拖拽副本,vuedraggable 直接操作它
:handle'.handle_bar'只有拖拽手柄能触发拖拽,避免误触输入框
:sorttrue允许内部排序
@changedraggableChange拖拽变更回调,区分 added 和 moved

拖拽副本同步机制

1const dragFormDataList = ref<any[]>([]);
2let lastListSig = '';      // 签名缓存,避免不必要的重建
3let dragCompleted = false;  // 拖拽是否成功完成
4
5//  Store 同步到拖拽副本(仅在增删/顺序变化时)
6const syncDragList = () => {
7  if (unref(skipSyncDrag)) return;  // 拖拽中不同步
8  const dataList = unref(formDataList).filter((item) => {
9    return item.formItemKey && !EditorUtils.excludeList.includes(item?.customConf?.tagName || '');
10  });
11  // 签名对比:如果 formItemKey 列表没变,跳过重建
12  const sig = dataList.map((item) => item.formItemKey).join(',');
13  if (sig === lastListSig) return;
14  lastListSig = sig;
15  dragFormDataList.value = dataList.slice();
16};
17
18// 渲染时始终从 Store 取最新数据,避免拖拽副本与 formDataList 脱节
19const resolveFormItem = (element: any) => {
20  if (!element?.formItemKey) return element;
21  return unref(formDataList).find((i) => i.formItemKey === element.formItemKey) ?? element;
22};
23
24// 监听 formDataList  key 变化,自动同步
25watch(
26  () => unref(formDataList).map((item) => item.formItemKey).join(','),
27  () => syncDragList(),
28  { immediate: true },
29);
30

为什么需要两个 list? formDataList 是 Store 中的唯一数据源,但 vuedraggable 需要直接操作 list 来实现拖拽排序。如果直接绑定 formDataList,每次排序都会触发 Store 更新和响应式连锁反应。所以用一个局部 dragFormDataList 做缓冲,排序完成后统一同步回 Store。


五、Pinia Store 核心方法(editor.ts)

添加控件 addFormItem

1async addFormItem(tagName: string, formItemKey: string, index?: number, setActive = true) {
2  // 1. 获取默认配置(新建)或拷贝已有配置(复制)
3  let defaultItem: IFormModel.formItemConf;
4  if (formItemKey.length < 1) {
5    // 新建:从默认配置模板中取
6    defaultItem = EditorUtils.getDefaultItemConfig().filter(
7      (item) => item.customConf.tagName === tagName
8    )[0];
9  } else {
10    // 复制:从已有数据中拷贝
11    defaultItem = this.formDataList.filter(
12      (item) => item.formItemKey === formItemKey
13    )[0];
14  }
15
16  // 2. 生成唯一标识
17  const _formItemKey = EditorUtils.getRandomId();
18  const _renderKey = EditorUtils.getRandomKey(_formItemKey);
19  const item = Object.assign(cloneDeep(defaultItem), {
20    formItemKey: _formItemKey,
21    renderKey: _renderKey,
22  });
23  item.baseConf.paginationNum = this.activePageNum ?? 0;
24
25  // 3. 计算插入位置(当前选中项的下一个)
26  const currentIndex = this.formDataList.findIndex(
27    (item) => item.renderKey === this.activeKey
28  );
29  if (index != undefined) {
30    this.formDataList.splice(Math.max(index, 0), 0, item);
31  } else {
32    index = currentIndex < 0 ? this.formDataList.length : currentIndex + 1;
33    this.formDataList.splice(Math.max(index, 0), 0, item);
34  }
35
36  // 4. 激活新项
37  if (setActive) this.setActiveKey(item.renderKey);
38  this.formItemKey = item.formItemKey;
39
40  return item;
41}
42

删除控件 delFormItem

1delFormItem(forItemKey: string, pos: number) {
2  this.formDataList = this.formDataList.filter((item) => {
3    return item.formItemKey != forItemKey;
4  });
5  // 自动选中上一项
6  if (this.formDataList[0]) {
7    this.setActiveKey(this.formDataList[Math.max(pos - 1, 0)].renderKey);
8  } else {
9    this.settingForm = true;  // 没有题目了,显示表单设置
10  }
11}
12

切换题型 changeTagType

1changeTagType(formItemKey: string, item: any) {
2  const allConfigs = EditorUtils.getDefaultItemConfig();
3  
4  this.formDataList = this.formDataList.map((obj) => {
5    if (obj.formItemKey === formItemKey) {
6      // 合并配置:只覆盖已有字段
7      const mergedConf = this.mergeCustomConf(obj.customConf, item);
8
9      // 标题处理:只有用户没自定义标题时,才更新为新题型默认标题
10      const oldDefaultTitle = allConfigs.find(c => c.customConf.tagName === obj.customConf.tagName)?.customConf?.title;
11      const newDefaultTitle = allConfigs.find(c => c.customConf.tagName === item.tagName)?.customConf?.title || item.label;
12      
13      const currentTitle = (obj.customConf.title || '').trim();
14      const isNotCustomized = !oldDefaultTitle || currentTitle === oldDefaultTitle;
15      
16      if (isNotCustomized) {
17        mergedConf.title = newDefaultTitle;
18        mergedConf.titleHtml = [`<p>${newDefaultTitle}</p>`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.p.md);
19      }
20
21      return { ...obj, customConf: mergedConf };
22    }
23    return obj;
24  });
25}
26

六、题目工具栏(drag_tool_bar.vue)

每个题目选中后,顶部显示工具栏:题型切换、必填、复制、删除、拖拽手柄。

1<template>
2  <div class="tool_bar">
3    <!-- 题型切换下拉框 -->
4    <div class="tag_select" v-show="activeKey == currentData.renderKey">
5      <div class="switch_type" v-if="showTypeList.includes(tagName)">
6        <el-select v-model="tagType" @change="selectTag">
7          <el-option v-for="item in tagTypeList" :key="item.tagName" :label="item.label" :value="item.tagName" />
8        </el-select>
9      </div>
10      <span v-else>{{ labelName }}</span>
11    </div>
12
13    <!-- 操作按钮 -->
14    <div class="edit_btns">
15      <ElCheckbox v-model="currentData.baseConf.isRequired" label="必填" />
16      <span @click.stop="triggerCopy"><Icon icon="svg-icon:drag_item_copy" />复制</span>
17      <span @click.stop="triggerDelect"><Icon icon="svg-icon:drag_item_del" />删除</span>
18    </div>
19
20    <!-- 拖拽手柄(只有拖这个位置才能拖拽) -->
21    <div class="handle_bar">
22      <Icon icon="svg-icon:drag-handle-line" :size="30" />
23    </div>
24  </div>
25</template>
26

注意:handle="'.handle_bar'" 指定了只有 .handle_bar 元素能触发拖拽。这样用户点击输入框、按钮等交互元素时不会误触发拖拽。


七、核心流程详解

流程一:从左侧拖拽到中间

1用户拖拽控件
2    
3    
4handleClone() ──▶ 生成唯一 key + push  formDataList + skipSyncDrag=true
5    
6    
7vuedraggable 将克隆元素放入 dragFormDataList
8    
9    
10onDragStart() ──▶ 设置 __isDragging + 保存标题数据 + 禁用 pointer-events
11    
12    
13onDragEnd() ──▶ 恢复 pointer-events + 重置 skipSyncDrag
14                    如果 !dragCompleted(拖拽取消),清理孤立数据
15    
16draggableChange({ added }) ──▶ dragCompleted=true
17    
18    
19同步 dragFormDataList 顺序  formDataList(排除项 + 普通项合并)
20    
21    
22nextTick: skipSyncDrag=false + syncDragList()
23

流程二:点击添加

1用户点击控件
2    
3    
4handleAdd() ──▶ editorStore.addFormItem(tagName)
5                    Store 内部:生成 key + 计算位置 + splice 插入 + 激活
6    
7formDataList 变化触发 watch
8    
9    
10syncDragList() ──▶ 重建 dragFormDataList
11    
12    
13visibleCount +1 ──▶ 新题目立即渲染
14    
15    
16nextTick + rAF ──▶ scrollIntoView 滚动到新题目
17

流程三:中间区域排序

1用户拖拽手柄排序
2    
3    
4onDragStart() ──▶ blur 所有编辑器 + 保存标题数据
5    
6    
7vuedraggable 调整 dragFormDataList 顺序
8    
9    
10draggableChange({ moved }) ──▶ 检查逻辑规则
11    
12    ├─── 无冲突 ──▶ 同步顺序到 formDataList
13    
14    └─── 有冲突 ──▶ 弹出确认框  用户确认后同步
15    
16    
17onDragEnd() ──▶ 恢复 pointer-events + skipSyncDrag=false
18

draggableChange 核心实现

1const draggableChange = (res) => {
2  dragCompleted = true;
3
4  const saveAndProceed = async () => {
5    if (res.added) {
6      // 从左侧拖入的新元素
7      const element = res.added.element;
8      editorStore.setActiveKey(element.renderKey);
9      editorStore.setFormSetting(false);
10      
11      // 同步 dragFormDataList 顺序到 formDataList
12      syncOrderToStore();
13    } else if (res.moved) {
14      // 内部排序
15      const formItemKey = res.moved?.element?.formItemKey || '';
16      // 检查逻辑规则冲突
17      const checkMsg = getPrevRuleItemIndex(formItemKey, res.moved.newIndex);
18      if (!checkMsg) {
19        syncOrderToStore();
20      } else {
21        // 有冲突,弹确认框
22        dragRuleCheckContent.value = checkMsg;
23        showDragRuleCheck.value = true;
24      }
25    }
26  };
27
28  // 延迟 50ms,确保 onDragStart  blur 已完成
29  setTimeout(saveAndProceed, 50);
30};
31
32//  dragFormDataList 的顺序同步回 formDataList
33const syncOrderToStore = () => {
34  skipSyncDrag.value = true;
35  const dragList = unref(dragFormDataList);
36  const excludeList = EditorUtils.excludeList;
37  
38  // 排除项(分页、说明等)保持原位
39  const excludedItems = unref(formDataList).filter((item) =>
40    excludeList.includes(item?.customConf?.tagName || '')
41  );
42  // 普通项按拖拽顺序排列
43  const normalItems = dragList.map(
44    (item) => unref(formDataList).find((i) => i.formItemKey === item.formItemKey) || item
45  );
46  formDataList.value = [...excludedItems, ...normalItems];
47
48  nextTick(() => {
49    skipSyncDrag.value = false;
50    syncDragList();
51  });
52};
53

八、性能优化:大列表按需渲染

当表单题目超过 50 个时,一次性渲染所有题目组件会导致严重卡顿。项目采用 IntersectionObserver 按需渲染策略:

1const ESTIMATED_ITEM_HEIGHT = 160;
2const INITIAL_VISIBLE = 50;  // 首屏渲染 50 
3const VISIBLE_BUFFER = 15;   // 上下缓冲 15 
4const visibleCount = ref(INITIAL_VISIBLE);
5
6// 模板中:index < visibleCount 的才渲染真实组件,其余用占位 div
7// <template v-if="index < visibleCount">
8//   <FormReducer :formItemData="element" />
9// </template>
10// <div v-else class="item_placeholder" :data-index="index" :style="{ minHeight: '160px' }"></div>
11
12// IntersectionObserver 监听占位 div 进入视口
13const observePlaceholders = () => {
14  const root = document.querySelector('.el-scrollbar__wrap');
15  placeholderObserver = new IntersectionObserver(
16    (entries) => {
17      entries.forEach((entry) => {
18        if (!entry.isIntersecting) return;
19        const index = Number((entry.target as HTMLElement).dataset.index);
20        if (!Number.isNaN(index)) ensureVisibleUpTo(index);
21      });
22    },
23    { root, rootMargin: '400px 0px', threshold: 0 }
24  );
25  root.querySelectorAll('.item_placeholder[data-index]').forEach((el) => {
26    placeholderObserver?.observe(el);
27  });
28};
29
30const ensureVisibleUpTo = (index: number) => {
31  const target = Math.min(index + VISIBLE_BUFFER + 1, dragFormDataList.value.length);
32  if (target > visibleCount.value) {
33    visibleCount.value = target;
34  }
35};
36

策略说明

  • 首屏只渲染前 50 个题目,其余用固定高度的占位 div
  • 滚动时 IntersectionObserver 检测占位 div 进入视口,动态扩大 visibleCount
  • 批量导入(一次增加 5+ 题)时重置为首屏渲染,避免一次性渲染过多
  • 单题添加时 visibleCount + 1,新题立即渲染

九、踩坑总结

1. 拖拽时标题/描述被浏览器覆盖

问题:拖拽过程中,浏览器的原生 drop 行为会把拖拽数据插入到 contenteditable 的标题/描述区域,导致文字被覆盖。

解决方案:三层防护

1// 第一层:拖拽开始时禁用标题/描述/编辑器的 pointer-events
2const selectors = '.title_readonly, .desc_readonly, [contenteditable="true"], .w-e-text-container';
3document.querySelectorAll(selectors).forEach((el) => {
4  (el as HTMLElement).style.pointerEvents = 'none';
5});
6
7// 第二层:设置全局 __isDragging 标志,编辑器组件内部通过此标志禁止写入
8(window as any).__isDragging = true;
9
10// 第三层:拖拽前保存标题数据,拖拽后恢复(兜底)
11const preDragTitleData = new Map();
12formDataList.forEach((item) => {
13  preDragTitleData.set(item.formItemKey, {
14    title: item.customConf.title,
15    titleHtml: item.customConf.titleHtml,
16    desc: item.customConf.desc,
17  });
18});
19

2. 克隆元素 key 为空导致组件报错

问题:vuedraggable 的 clone 默认是浅拷贝,克隆出来的元素 formItemKeyrenderKey 与原元素相同,导致组件找不到数据。

解决方案:在 handleClone 中立即生成唯一 key 并 push 到 Store:

1const handleClone = (e) => {
2  const cloned = cloneDeep(e);
3  cloned.formItemKey = EditorUtils.getRandomId();      // 唯一 ID
4  cloned.renderKey = EditorUtils.getRandomKey(cloned.formItemKey);  // 唯一渲染 key
5  editorStore.skipSyncDrag = true;  // 锁定同步
6  unref(formDataList).push(cloned);  // 立即推入 Store
7  return cloned;
8};
9

3. 拖拽取消后残留孤立数据

问题:用户拖拽到非目标区域取消时,handleClone 已经把数据 push 到了 formDataList,但 dragFormDataList 中没有,导致数据不一致。

解决方案onDragEnd 中检查 dragCompleted 标志,如果未完成则清理:

1const onDragEnd = () => {
2  skipSyncDrag.value = false;
3  if (!dragCompleted) {
4    // 拖拽取消:清理 handleClone 推入但 dragFormDataList 中不存在的数据
5    const dragKeys = new Set(dragFormDataList.value.map((i) => i.formItemKey));
6    formDataList.value = formDataList.value.filter(
7      (item) =>
8        EditorUtils.excludeList.includes(item?.customConf?.tagName || '') ||
9        dragKeys.has(item.formItemKey)
10    );
11  }
12  dragCompleted = false;
13};
14

4. 双 list 同步时机问题

问题dragFormDataListformDataList 是两个 list,排序时如果同步时机不对,会导致数据错乱。

解决方案

  • 拖拽中设置 skipSyncDrag = true,阻止 syncDragList 重建 dragFormDataList
  • draggableChange 中先同步顺序到 formDataList,再在 nextTick 中重置 skipSyncDrag 并调用 syncDragList
  • formItemKey 签名对比避免不必要的重建

总结:整个拖拽编辑器的核心设计是双 list + 同步锁 + clone 即入栈formDataList 是唯一数据源,dragFormDataList 是拖拽操作缓冲区,skipSyncDrag 是同步锁防止拖拽过程中互相干扰。配合 handle_bar 限定拖拽触发区域、IntersectionObserver 按需渲染、三层防护避免标题覆盖,实现了流畅的表单编辑体验。


Vue3 表单编辑器:左侧控件菜单 + 中间编辑区的拖拽全流程实现》 是转载文章,点击查看原文


相关推荐


分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
阿里云大数据AI技术2026/8/4

作者:赵阳,钛动科技 DSP 大数据架构团队 在 DSP 广告业务中,数据链路需要同时支撑在线投放、计费、Ad-hoc 分析和 BI 报表等多类场景。不同场景对数据新鲜度、查询延迟、回刷能力和存储成本的要求差异很大。如果继续用一套统一链路承载所有数据,系统很容易在成本、性能和稳定性之间相互牵制。 钛动科技 DSP 广告链路的核心数据流可以概括为:Request、Response、Impression、Click 和 Postback。随着业务规模增长,原有架构逐渐暴露出三个问题:数据规模持续扩


Java Undertow 实战指南:从轻量 Web Server 到 Spring Boot 嵌入式容器
唐青枫2026/7/27

简介 Undertow 是一个轻量级 Java Web Server。 它可以直接嵌入到 Java 代码里运行,也可以作为 Servlet 容器使用,还曾经是 WildFly 的默认 Web Server。 简单理解: Undertow = 轻量 HTTP Server + Servlet 容器 + WebSocket 支持 它和 Tomcat、Jetty 一样,都可以承载 Java Web 应用。 但 Undertow 的风格更偏底层、更偏嵌入式:一个 HTTP 服务可以由几个 Handle


Kimi k3是真碉,但太不经用,也有拉跨点!
甲维斯2026/7/19

K3 已测,确实强,但要命的是配额太少了! 一天不到,一周的配额已经烧完了,大概就生成了 5 个测试页面! 从五个例子中,可以知道,有些确实很强,有些呢又差点意思,有些比较糟糕。 我不是那种收钱办事儿只会吹牛逼的博主,我也不会针对 Kimi 只说它的缺点。我会依次给大家展现测试结果,同时和其它国产模型,和国外的 GPT 5.6 和 Fable 5 做一个对比,尤其是 Fable 5! 下面我就一点一点来说,看完这篇文章大家应该对K3有一个相对全面的解了。 这篇文章,我要先从这张图片说起:


【中阶·安全】大模型逆向攻击与数据隐私保护深度解析:从模型反演到差分隐私防御的全链路实战
AI智能专家2026/7/10

【中阶·安全】大模型逆向攻击与数据隐私保护深度解析:从模型反演到差分隐私防御的全链路实战 专栏:《AI 工程与安全深度实战》· 第6轮·第2篇 前言 核心痛点:大模型不仅记住了训练数据中的知识——在某些条件下,攻击者可以通过模型输出反推出训练数据中的敏感信息(个人身份、医疗记录、专有代码),这对模型发布方的隐私合规和用户信任构成根本性挑战 适配人群:AI 安全工程师、隐私合规负责人、对模型隐私保护有深度学习需求的 ML 工程师和安全研究员 收获能力:读


暑假CSP-S NOIP集训学生要做哪些内容,集训完如何消化所学的知识
dllglvzhenfeng2026/7/2

暑假是 CSP-S 向 NOIP 进阶的‌黄金窗口期‌,集训核心在于‌体系化构建高阶算法能力‌与‌实战应试策略‌。以下是针对 CSP-S/NOIP 层级学生的集训内容及赛后消化方案: 一、暑假集训核心内容 集训需遵循“理论精讲 + 专项刷题 + 全真模考”闭环,重点突破以下模块: 1、‌高阶算法深度攻坚‌ (1)、‌动态规划(DP)‌:         从线性 DP 延伸至树形 DP、区间 DP、状压 DP 及斜率/四边形优化,重点训练状态设计与转移方程推导 。


Unity之使用火山引擎实现流式语音合成|优化版本
心前阳光2026/6/23

初始版本 Unity之使用火山引擎实现流式语音合成 优化版本 优化内容: 原始版本使用NativeWebSocket插件,无法获取X-Tt-Logid,使用WebSocketSharp插件解决这个问题提取两个插件的共性,可依据需要替换 示例 通信类 websocket通信基类 using UnityEngine; using System; /// <summary> /// 文字转语音 /// </summary> public abstract class BaseTextT


LLM —— 多模态(文本、图片、音频、视频)
kishu_iOS&AI2026/6/15

目录 一、什么是多模态 二、常见模态分类 三、多模态原理 单模态 vs 多模态 四、文本 五、图片 核心原理: 流程:         ① 图像预处理(统一输入格式)         ② 加载预训练视觉编码器         ③ 前向传播,抽取特征向量         ④ 向量归一化(L2 归一化,必做)         ⑤ 持久化存储 方案代码: 关键细节         ① 向量维度怎么选?         ② 相似度计算方式 容易出错点 其他轻量化


别再乱套ScrollViewer了!WPF中ItemsControl滚动条失效的3个隐藏坑与终极修复方案
许筱淳2026/6/8

WPF中ItemsControl滚动条失效的深度剖析与实战解决方案 当你在WPF项目中遇到ItemsControl滚动条"罢工"时,是否也经历过这样的场景:明明按照标准做法添加了ScrollViewer,滚动条却像被施了隐身术一样消失不见?或者鼠标滚轮在嵌套控件中滑动时毫无反应?这些问题往往源于WPF视觉树和事件路由机制的深层特性。本文将带你深入这些"坑"的本质,并提供真正有效的解决方案。 1. 为什么简单的ScrollViewer包裹会失效? 许多开发者第一次遇到ItemsControl


python爬虫-基本库-urllib库(常用速查)
bigfootyazi2026/6/1

urllib库 库的用途四个基本模块request模块error模块parse模块robotparser模块 库的用途 实现HTTP请求的发送 扩展:基本HTTP库有urllib、requests、httpx等 四个基本模块 request 基本的HTTP请求模块error 异常处理模块parse 工具模块,提供URL的处理方法robotparser 识别网站的robost.txt文件 request模块 urlopen (function) def urlopen(


📱随时随地大小编:TraeSolo 移动端初体验
海石2026/5/12

1、前言 最早认识Trae SOLO 还是在Trae IDE 内测 SOLO 模式的时候 (大概是2025年的11月) 这一转眼,Trae SOLO 居然都已经单独出了 桌面端 和 移动端 了 除了劳动节那阵联动星巴克☕吸引我之外不是 咳咳,最有魅力的还是双端协同这一点上 本文就是一篇“利用Trae SOLO的双端协同能力,进行随时随地开发微信小程序”的实践分享文章, 非常欢迎掘友们一起交流交流,共同探讨Trae SOLO的最佳使用“范式”~ 2、结果先行 1、移动端可以随时查看微信

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读