DragOrg2Tree.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. <template>
  2. <DraggableComp>
  3. <vue2-org-tree :data="treeData" :props="defaultProps" collapsable :render-content="renderNode"
  4. @on-expand="onExpand" />
  5. </DraggableComp>
  6. </template>
  7. <script>
  8. import DraggableComp from './DraggableComp.vue';
  9. let id = 1000
  10. export default {
  11. components: { DraggableComp },
  12. data() {
  13. return {
  14. defaultProps: { label: 'label', children: 'children', expand: 'expand' },
  15. treeData: { // 初始数据
  16. id: 0,
  17. label: '广东功道云科技有限公司',
  18. expand: true,
  19. children: [
  20. { id: 1, label: '研发部', expand: true, positions: ['前端工程师', '后端工程师', 'DevOps'], children: [{ id: 4, label: '前端组' }, { id: 5, label: '后端组' }] },
  21. { id: 2, label: '销售部' },
  22. { id: 3, label: '人事部' }
  23. ]
  24. },
  25. // 缩放相关数据
  26. zoom: 1, // 缩放比例,1表示100%
  27. minZoom: 0.3, // 最小缩放比例
  28. maxZoom: 3, // 最大缩放比例
  29. zoomStep: 0.1, // 每次滚轮滚动的缩放步长
  30. zoomOrigin: { x: 50, y: 50 }, // 缩放中心点,默认为中心
  31. // 拖拽相关数据
  32. isDragging: false,
  33. dragStart: { x: 0, y: 0 },
  34. position: { x: 0, y: 0 }, // 当前位置
  35. lastPosition: { x: 0, y: 0 }, // 上次位置(用于连续拖拽)
  36. boundary: {
  37. minX: -Infinity,
  38. maxX: Infinity,
  39. minY: -Infinity,
  40. maxY: Infinity
  41. } // 拖拽边界
  42. }
  43. },
  44. computed: {
  45. // 计算容器样式(同时包含缩放和位置)
  46. containerStyle() {
  47. return {
  48. transform: `translate(${this.position.x}px, ${this.position.y}px) scale(${this.zoom})`,
  49. transformOrigin: `${this.zoomOrigin.x}% ${this.zoomOrigin.y}%`,
  50. transition: this.isDragging ? 'none' : 'transform 0.15s ease-out',
  51. cursor: this.isDragging ? 'grabbing' : 'grab'
  52. };
  53. }
  54. },
  55. mounted() {
  56. // 组件挂载后设置初始缩放中心和计算边界
  57. this.updateZoomOrigin();
  58. this.calculateBoundary();
  59. // 添加全局鼠标事件监听
  60. window.addEventListener('mousemove', this.handleDrag);
  61. window.addEventListener('mouseup', this.stopDrag);
  62. // 窗口大小改变时重新计算边界
  63. window.addEventListener('resize', this.calculateBoundary);
  64. },
  65. beforeDestroy() {
  66. // 移除事件监听
  67. window.removeEventListener('mousemove', this.handleDrag);
  68. window.removeEventListener('mouseup', this.stopDrag);
  69. window.removeEventListener('resize', this.calculateBoundary);
  70. },
  71. created() {
  72. // 初始化职位显示状态
  73. this.initPositionVisibility(this.treeData);
  74. },
  75. methods: {
  76. /* --------------- 拖拽相关方法 --------------- */
  77. startDrag(event) {
  78. // 防止触发其他事件(如文本选择)
  79. event.preventDefault();
  80. // 记录拖拽开始位置
  81. this.isDragging = true;
  82. this.dragStart = {
  83. x: event.clientX - this.lastPosition.x,
  84. y: event.clientY - this.lastPosition.y
  85. };
  86. },
  87. handleDrag(event) {
  88. if (!this.isDragging) return;
  89. // 计算新的位置
  90. let newX = event.clientX - this.dragStart.x;
  91. let newY = event.clientY - this.dragStart.y;
  92. // 限制在边界内
  93. newX = Math.max(this.boundary.minX, Math.min(this.boundary.maxX, newX));
  94. newY = Math.max(this.boundary.minY, Math.min(this.boundary.maxY, newY));
  95. // 更新位置
  96. this.position.x = newX;
  97. this.position.y = newY;
  98. // 保存最后位置
  99. this.lastPosition.x = newX;
  100. this.lastPosition.y = newY;
  101. },
  102. stopDrag() {
  103. this.isDragging = false;
  104. },
  105. // 计算拖拽边界
  106. calculateBoundary() {
  107. const container = this.$refs.dragContainer;
  108. const area = this.$refs.componentArea;
  109. if (!container || !area) return;
  110. const containerRect = container.getBoundingClientRect();
  111. const areaRect = area.getBoundingClientRect();
  112. // 计算边界(确保容器不会完全拖出可视区域)
  113. const padding = 20; // 留出一些边距
  114. this.boundary = {
  115. minX: areaRect.left - containerRect.left + padding - (containerRect.width * (this.zoom - 1)) / 2,
  116. maxX: areaRect.right - containerRect.right - padding + (containerRect.width * (this.zoom - 1)) / 2,
  117. minY: areaRect.top - containerRect.top + padding - (containerRect.height * (this.zoom - 1)) / 2,
  118. maxY: areaRect.bottom - containerRect.bottom - padding + (containerRect.height * (this.zoom - 1)) / 2
  119. };
  120. },
  121. // 重置位置
  122. resetPosition() {
  123. this.position = { x: 0, y: 0 };
  124. this.lastPosition = { x: 0, y: 0 };
  125. },
  126. /* --------------- 缩放相关方法 --------------- */
  127. handleWheel(event) {
  128. event.preventDefault();
  129. // 计算鼠标在容器内的相对位置(作为缩放中心)
  130. const container = this.$refs.dragContainer;
  131. const rect = container.getBoundingClientRect();
  132. const mouseXPercent = ((event.clientX - rect.left) / rect.width) * 100;
  133. const mouseYPercent = ((event.clientY - rect.top) / rect.height) * 100;
  134. // 更新缩放中心
  135. this.zoomOrigin = {
  136. x: Math.max(0, Math.min(100, mouseXPercent)),
  137. y: Math.max(0, Math.min(100, mouseYPercent))
  138. };
  139. // 判断缩放方向
  140. const delta = event.deltaY > 0 ? -1 : 1;
  141. // 计算新缩放比例
  142. let newZoom = this.zoom + (delta * this.zoomStep);
  143. // 限制缩放范围
  144. newZoom = Math.max(this.minZoom, Math.min(this.maxZoom, newZoom));
  145. // 保存旧缩放值
  146. const oldZoom = this.zoom;
  147. // 应用缩放(保留一位小数)
  148. this.zoom = Math.round(newZoom * 10) / 10;
  149. // 缩放后重新计算边界
  150. this.calculateBoundary();
  151. // 调整位置,使鼠标所在点保持相对位置
  152. if (oldZoom !== this.zoom) {
  153. const zoomRatio = this.zoom / oldZoom;
  154. const mouseX = event.clientX - rect.left;
  155. const mouseY = event.clientY - rect.top;
  156. this.position.x = mouseX - (mouseX - this.position.x) * zoomRatio;
  157. this.position.y = mouseY - (mouseY - this.position.y) * zoomRatio;
  158. // 限制在边界内
  159. this.position.x = Math.max(this.boundary.minX, Math.min(this.boundary.maxX, this.position.x));
  160. this.position.y = Math.max(this.boundary.minY, Math.min(this.boundary.maxY, this.position.y));
  161. this.lastPosition.x = this.position.x;
  162. this.lastPosition.y = this.position.y;
  163. }
  164. },
  165. // 重置缩放
  166. resetZoom() {
  167. const oldZoom = this.zoom;
  168. this.zoom = 1;
  169. this.zoomOrigin = { x: 50, y: 50 };
  170. // 缩放后重新计算边界
  171. this.calculateBoundary();
  172. // 调整位置,使组件居中
  173. this.position.x = this.position.x / oldZoom;
  174. this.position.y = this.position.y / oldZoom;
  175. this.lastPosition.x = this.position.x;
  176. this.lastPosition.y = this.position.y;
  177. },
  178. // 更新缩放中心为容器中心
  179. updateZoomOrigin() {
  180. this.zoomOrigin = { x: 50, y: 50 };
  181. },
  182. initPositionVisibility(data) {
  183. if (data.positions && data.positions.length > 0) {
  184. this.$set(data, '_showPos', true);
  185. }
  186. if (data.children) {
  187. data.children.forEach(child => this.initPositionVisibility(child));
  188. }
  189. },
  190. /* --------------- 节点渲染 --------------- */
  191. renderNode(h, data) {
  192. // if (!data._showPos) this.$set(data, '_showPos', true) // 折叠状态
  193. return (
  194. <div class="node-wrapper">
  195. {/* 1. 折叠按钮 —— 在 popover 之外 */}
  196. {/* 2. 原 popover 包裹的节点主体 */}
  197. <el-popover placement="bottom" width="260" trigger="click">
  198. <div style="text-align:center;">
  199. <el-button size="mini" type="primary" onClick={() => this.addChild(data)}>添加子部门</el-button>
  200. <el-button size="mini" onClick={() => this.editNode(data)}>编辑</el-button>
  201. <el-button size="mini" type="danger" onClick={() => this.delNode(data)}>删除</el-button>
  202. </div>
  203. <div slot="reference" class="node-body">
  204. <div class="node-label">
  205. {data.label}
  206. {data.positions && data.positions.length > 0 && (
  207. <span
  208. class={['fold-btn', { 'collapsed': !data._showPos }]}
  209. onClick={e => { e.stopPropagation(); data._showPos = !data._showPos }}>
  210. {data._showPos ? '▲' : '▼'}
  211. </span>
  212. )}
  213. </div>
  214. {/* 职位列表 */}
  215. {data._showPos && data.positions && data.positions.length > 0 && (
  216. <ul class="pos-list">
  217. {data.positions.map(p => <li key={p}>{p}</li>)}
  218. </ul>
  219. )}
  220. </div>
  221. </el-popover>
  222. </div>
  223. )
  224. },
  225. /* --------------- 业务函数 --------------- */
  226. addChild(parent) { // 添加子节点
  227. this.$prompt('请输入新部门名称', '添加子部门', {
  228. inputValidator: v => v && v.trim().length > 0
  229. }).then(({ value }) => {
  230. if (!parent.children) this.$set(parent, 'children', [])
  231. parent.children.push({ id: ++id, label: value.trim(), expand: false })
  232. this.$message.success('添加成功')
  233. })
  234. },
  235. editNode(node) { // 编辑节点
  236. this.$prompt('请输入新的部门名称', '编辑部门', {
  237. inputValue: node.label,
  238. inputValidator: v => v && v.trim().length > 0
  239. }).then(({ value }) => {
  240. this.$set(node, 'label', value.trim())
  241. this.$message.success('修改成功')
  242. })
  243. },
  244. delNode(node) { // 删除节点
  245. this.$confirm(`确定删除 “${node.label}” 及其所有子部门吗?`, '提示', { type: 'warning' })
  246. .then(() => {
  247. const parent = this.findParent(this.treeData, node)
  248. if (!parent) return // 根节点不让删
  249. const idx = parent.children.indexOf(node)
  250. parent.children.splice(idx, 1)
  251. this.$message.success('删除成功')
  252. })
  253. },
  254. /* --------------- 辅助函数 --------------- */
  255. findParent(root, target, parent = null) { // 递归找父节点
  256. if (root === target) return parent
  257. if (root.children) {
  258. for (const child of root.children) {
  259. const res = this.findParent(child, target, root)
  260. if (res) return res
  261. }
  262. }
  263. return null
  264. },
  265. // 展开/收起全部
  266. expandAll(val) {
  267. // 同时切换所有节点的职位列表显示状态
  268. this.togglePositionVisibility(this.treeData, val);
  269. },
  270. toggleExpand(data, val) {
  271. this.$set(data, 'expand', val)
  272. if (data.children) data.children.forEach(c => this.toggleExpand(c, val))
  273. },
  274. togglePositionVisibility(data, val) {
  275. // 只有有职位的节点才设置 _showPos
  276. if (data.positions && data.positions.length > 0) {
  277. this.$set(data, '_showPos', val);
  278. }
  279. if (data.children) {
  280. data.children.forEach(child => this.togglePositionVisibility(child, val));
  281. }
  282. },
  283. collapse(list) {
  284. var that = this;
  285. list.forEach(function (child) {
  286. if (child.expand) {
  287. child.expand = false;
  288. }
  289. child.children && that.collapse(child.children);
  290. });
  291. },
  292. // 折叠回调
  293. onExpand(e, data) {
  294. console.log('expand change:', data.label, data.expand)
  295. if ("expand" in data) {
  296. data.expand = !data.expand;
  297. if (!data.expand && data.children) {
  298. this.collapse(data.children);
  299. }
  300. } else {
  301. this.$set(data, "expand", true);
  302. }
  303. }
  304. }
  305. }
  306. </script>
  307. <style lang="scss" scoped>
  308. .page {
  309. padding: 20px;
  310. height: 100vh;
  311. display: flex;
  312. flex-direction: column;
  313. overflow: hidden;
  314. /* 防止页面滚动 */
  315. }
  316. .toolbar {
  317. margin-bottom: 20px;
  318. display: flex;
  319. gap: 15px;
  320. align-items: center;
  321. flex-shrink: 0;
  322. z-index: 10;
  323. background: white;
  324. padding: 10px;
  325. border-radius: 8px;
  326. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  327. }
  328. .zoom-hint {
  329. margin-left: auto;
  330. font-size: 12px;
  331. color: #666;
  332. font-weight: 500;
  333. }
  334. /* 组件区域 */
  335. .component-area {
  336. flex: 1;
  337. position: relative;
  338. overflow: hidden;
  339. border: 1px dashed #d9d9d9;
  340. border-radius: 8px;
  341. background: linear-gradient(45deg, #fafafa 25%, transparent 25%, transparent 75%, #fafafa 75%, #fafafa),
  342. linear-gradient(45deg, #fafafa 25%, transparent 25%, transparent 75%, #fafafa 75%, #fafafa);
  343. background-size: 20px 20px;
  344. background-position: 0 0, 10px 10px;
  345. min-height: 500px;
  346. }
  347. /* 拖拽和缩放容器 */
  348. .drag-zoom-container {
  349. position: absolute;
  350. top: 50%;
  351. left: 50%;
  352. transform-origin: center;
  353. will-change: transform;
  354. /* 优化性能 */
  355. user-select: none;
  356. /* 防止文本选择 */
  357. }
  358. /* 组织架构树包装器 */
  359. .org-tree-wrapper {
  360. display: inline-block;
  361. background: white;
  362. border-radius: 8px;
  363. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  364. padding: 30px;
  365. position: relative;
  366. }
  367. /* 拖拽手柄 */
  368. .drag-handle {
  369. position: absolute;
  370. bottom: 10px;
  371. right: 10px;
  372. width: 32px;
  373. height: 32px;
  374. background: rgba(64, 158, 255, 0.9);
  375. border-radius: 50%;
  376. display: flex;
  377. align-items: center;
  378. justify-content: center;
  379. cursor: grab;
  380. color: white;
  381. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
  382. z-index: 5;
  383. transition: all 0.2s;
  384. }
  385. .drag-handle:hover {
  386. background: rgba(64, 158, 255, 1);
  387. transform: scale(1.1);
  388. }
  389. .drag-handle:active {
  390. cursor: grabbing;
  391. transform: scale(0.95);
  392. }
  393. /* 节点相关样式保持不变 */
  394. .node-label {
  395. padding: 4px 8px;
  396. cursor: pointer;
  397. border-radius: 3px;
  398. }
  399. .node-label:hover {
  400. background: #f5f7fa;
  401. }
  402. .node-body {
  403. display: inline-block;
  404. text-align: center;
  405. padding: 6px 10px;
  406. background: #fff;
  407. border: 1px solid #d9d9d9;
  408. border-radius: 4px;
  409. }
  410. .node-header {
  411. display: flex;
  412. align-items: center;
  413. justify-content: space-between;
  414. }
  415. .node-label {
  416. font-weight: 500;
  417. }
  418. .pos-list {
  419. list-style: none;
  420. margin: 4px 0 0 0;
  421. padding: 0;
  422. font-size: 12px;
  423. color: #666;
  424. text-align: left;
  425. }
  426. .pos-list li {
  427. padding: 2px 0;
  428. }
  429. .pos-list li::before {
  430. content: "· ";
  431. font-weight: bold;
  432. color: #409EFF;
  433. }
  434. .node-wrapper {
  435. display: inline-flex;
  436. align-items: flex-start;
  437. gap: 4px;
  438. }
  439. .fold-btn {
  440. cursor: pointer;
  441. font-size: 12px;
  442. color: #409EFF;
  443. user-select: none;
  444. padding: 2px 4px;
  445. box-sizing: border-box;
  446. transition: color 0.2s;
  447. }
  448. .fold-btn.collapsed {
  449. color: #909399;
  450. }
  451. /* 缩放比例指示器(浮动) */
  452. .zoom-indicator {
  453. position: absolute;
  454. bottom: 20px;
  455. left: 20px;
  456. background: rgba(0, 0, 0, 0.7);
  457. color: white;
  458. padding: 8px 12px;
  459. border-radius: 4px;
  460. font-size: 14px;
  461. z-index: 10;
  462. pointer-events: none;
  463. }
  464. /* 边界提示(调试用) */
  465. .boundary-debug {
  466. position: absolute;
  467. top: 0;
  468. left: 0;
  469. right: 0;
  470. bottom: 0;
  471. border: 2px dashed red;
  472. pointer-events: none;
  473. z-index: 100;
  474. }
  475. </style>