| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557 |
- <template>
- <DraggableComp>
- <vue2-org-tree :data="treeData" :props="defaultProps" collapsable :render-content="renderNode"
- @on-expand="onExpand" />
-
- </DraggableComp>
- </template>
- <script>
- import DraggableComp from './DraggableComp.vue';
- let id = 1000
- export default {
- components: { DraggableComp },
- data() {
- return {
- defaultProps: { label: 'label', children: 'children', expand: 'expand' },
- treeData: { // 初始数据
- id: 0,
- label: '广东功道云科技有限公司',
- expand: true,
- children: [
- { id: 1, label: '研发部', expand: true, positions: ['前端工程师', '后端工程师', 'DevOps'], children: [{ id: 4, label: '前端组' }, { id: 5, label: '后端组' }] },
- { id: 2, label: '销售部' },
- { id: 3, label: '人事部' }
- ]
- },
- // 缩放相关数据
- zoom: 1, // 缩放比例,1表示100%
- minZoom: 0.3, // 最小缩放比例
- maxZoom: 3, // 最大缩放比例
- zoomStep: 0.1, // 每次滚轮滚动的缩放步长
- zoomOrigin: { x: 50, y: 50 }, // 缩放中心点,默认为中心
- // 拖拽相关数据
- isDragging: false,
- dragStart: { x: 0, y: 0 },
- position: { x: 0, y: 0 }, // 当前位置
- lastPosition: { x: 0, y: 0 }, // 上次位置(用于连续拖拽)
- boundary: {
- minX: -Infinity,
- maxX: Infinity,
- minY: -Infinity,
- maxY: Infinity
- } // 拖拽边界
- }
- },
- computed: {
- // 计算容器样式(同时包含缩放和位置)
- containerStyle() {
- return {
- transform: `translate(${this.position.x}px, ${this.position.y}px) scale(${this.zoom})`,
- transformOrigin: `${this.zoomOrigin.x}% ${this.zoomOrigin.y}%`,
- transition: this.isDragging ? 'none' : 'transform 0.15s ease-out',
- cursor: this.isDragging ? 'grabbing' : 'grab'
- };
- }
- },
- mounted() {
- // 组件挂载后设置初始缩放中心和计算边界
- this.updateZoomOrigin();
- this.calculateBoundary();
- // 添加全局鼠标事件监听
- window.addEventListener('mousemove', this.handleDrag);
- window.addEventListener('mouseup', this.stopDrag);
- // 窗口大小改变时重新计算边界
- window.addEventListener('resize', this.calculateBoundary);
- },
- beforeDestroy() {
- // 移除事件监听
- window.removeEventListener('mousemove', this.handleDrag);
- window.removeEventListener('mouseup', this.stopDrag);
- window.removeEventListener('resize', this.calculateBoundary);
- },
- created() {
- // 初始化职位显示状态
- this.initPositionVisibility(this.treeData);
- },
- methods: {
- /* --------------- 拖拽相关方法 --------------- */
- startDrag(event) {
- // 防止触发其他事件(如文本选择)
- event.preventDefault();
- // 记录拖拽开始位置
- this.isDragging = true;
- this.dragStart = {
- x: event.clientX - this.lastPosition.x,
- y: event.clientY - this.lastPosition.y
- };
- },
- handleDrag(event) {
- if (!this.isDragging) return;
- // 计算新的位置
- let newX = event.clientX - this.dragStart.x;
- let newY = event.clientY - this.dragStart.y;
- // 限制在边界内
- newX = Math.max(this.boundary.minX, Math.min(this.boundary.maxX, newX));
- newY = Math.max(this.boundary.minY, Math.min(this.boundary.maxY, newY));
- // 更新位置
- this.position.x = newX;
- this.position.y = newY;
- // 保存最后位置
- this.lastPosition.x = newX;
- this.lastPosition.y = newY;
- },
- stopDrag() {
- this.isDragging = false;
- },
- // 计算拖拽边界
- calculateBoundary() {
- const container = this.$refs.dragContainer;
- const area = this.$refs.componentArea;
- if (!container || !area) return;
- const containerRect = container.getBoundingClientRect();
- const areaRect = area.getBoundingClientRect();
- // 计算边界(确保容器不会完全拖出可视区域)
- const padding = 20; // 留出一些边距
- this.boundary = {
- minX: areaRect.left - containerRect.left + padding - (containerRect.width * (this.zoom - 1)) / 2,
- maxX: areaRect.right - containerRect.right - padding + (containerRect.width * (this.zoom - 1)) / 2,
- minY: areaRect.top - containerRect.top + padding - (containerRect.height * (this.zoom - 1)) / 2,
- maxY: areaRect.bottom - containerRect.bottom - padding + (containerRect.height * (this.zoom - 1)) / 2
- };
- },
- // 重置位置
- resetPosition() {
- this.position = { x: 0, y: 0 };
- this.lastPosition = { x: 0, y: 0 };
- },
- /* --------------- 缩放相关方法 --------------- */
- handleWheel(event) {
- event.preventDefault();
- // 计算鼠标在容器内的相对位置(作为缩放中心)
- const container = this.$refs.dragContainer;
- const rect = container.getBoundingClientRect();
- const mouseXPercent = ((event.clientX - rect.left) / rect.width) * 100;
- const mouseYPercent = ((event.clientY - rect.top) / rect.height) * 100;
- // 更新缩放中心
- this.zoomOrigin = {
- x: Math.max(0, Math.min(100, mouseXPercent)),
- y: Math.max(0, Math.min(100, mouseYPercent))
- };
- // 判断缩放方向
- const delta = event.deltaY > 0 ? -1 : 1;
- // 计算新缩放比例
- let newZoom = this.zoom + (delta * this.zoomStep);
- // 限制缩放范围
- newZoom = Math.max(this.minZoom, Math.min(this.maxZoom, newZoom));
- // 保存旧缩放值
- const oldZoom = this.zoom;
- // 应用缩放(保留一位小数)
- this.zoom = Math.round(newZoom * 10) / 10;
- // 缩放后重新计算边界
- this.calculateBoundary();
- // 调整位置,使鼠标所在点保持相对位置
- if (oldZoom !== this.zoom) {
- const zoomRatio = this.zoom / oldZoom;
- const mouseX = event.clientX - rect.left;
- const mouseY = event.clientY - rect.top;
- this.position.x = mouseX - (mouseX - this.position.x) * zoomRatio;
- this.position.y = mouseY - (mouseY - this.position.y) * zoomRatio;
- // 限制在边界内
- this.position.x = Math.max(this.boundary.minX, Math.min(this.boundary.maxX, this.position.x));
- this.position.y = Math.max(this.boundary.minY, Math.min(this.boundary.maxY, this.position.y));
- this.lastPosition.x = this.position.x;
- this.lastPosition.y = this.position.y;
- }
- },
- // 重置缩放
- resetZoom() {
- const oldZoom = this.zoom;
- this.zoom = 1;
- this.zoomOrigin = { x: 50, y: 50 };
- // 缩放后重新计算边界
- this.calculateBoundary();
- // 调整位置,使组件居中
- this.position.x = this.position.x / oldZoom;
- this.position.y = this.position.y / oldZoom;
- this.lastPosition.x = this.position.x;
- this.lastPosition.y = this.position.y;
- },
- // 更新缩放中心为容器中心
- updateZoomOrigin() {
- this.zoomOrigin = { x: 50, y: 50 };
- },
- initPositionVisibility(data) {
- if (data.positions && data.positions.length > 0) {
- this.$set(data, '_showPos', true);
- }
- if (data.children) {
- data.children.forEach(child => this.initPositionVisibility(child));
- }
- },
- /* --------------- 节点渲染 --------------- */
- renderNode(h, data) {
- // if (!data._showPos) this.$set(data, '_showPos', true) // 折叠状态
- return (
- <div class="node-wrapper">
- {/* 1. 折叠按钮 —— 在 popover 之外 */}
- {/* 2. 原 popover 包裹的节点主体 */}
- <el-popover placement="bottom" width="260" trigger="click">
- <div style="text-align:center;">
- <el-button size="mini" type="primary" onClick={() => this.addChild(data)}>添加子部门</el-button>
- <el-button size="mini" onClick={() => this.editNode(data)}>编辑</el-button>
- <el-button size="mini" type="danger" onClick={() => this.delNode(data)}>删除</el-button>
- </div>
- <div slot="reference" class="node-body">
- <div class="node-label">
- {data.label}
- {data.positions && data.positions.length > 0 && (
- <span
- class={['fold-btn', { 'collapsed': !data._showPos }]}
- onClick={e => { e.stopPropagation(); data._showPos = !data._showPos }}>
- {data._showPos ? '▲' : '▼'}
- </span>
- )}
- </div>
- {/* 职位列表 */}
- {data._showPos && data.positions && data.positions.length > 0 && (
- <ul class="pos-list">
- {data.positions.map(p => <li key={p}>{p}</li>)}
- </ul>
- )}
- </div>
- </el-popover>
- </div>
- )
- },
- /* --------------- 业务函数 --------------- */
- addChild(parent) { // 添加子节点
- this.$prompt('请输入新部门名称', '添加子部门', {
- inputValidator: v => v && v.trim().length > 0
- }).then(({ value }) => {
- if (!parent.children) this.$set(parent, 'children', [])
- parent.children.push({ id: ++id, label: value.trim(), expand: false })
- this.$message.success('添加成功')
- })
- },
- editNode(node) { // 编辑节点
- this.$prompt('请输入新的部门名称', '编辑部门', {
- inputValue: node.label,
- inputValidator: v => v && v.trim().length > 0
- }).then(({ value }) => {
- this.$set(node, 'label', value.trim())
- this.$message.success('修改成功')
- })
- },
- delNode(node) { // 删除节点
- this.$confirm(`确定删除 “${node.label}” 及其所有子部门吗?`, '提示', { type: 'warning' })
- .then(() => {
- const parent = this.findParent(this.treeData, node)
- if (!parent) return // 根节点不让删
- const idx = parent.children.indexOf(node)
- parent.children.splice(idx, 1)
- this.$message.success('删除成功')
- })
- },
- /* --------------- 辅助函数 --------------- */
- findParent(root, target, parent = null) { // 递归找父节点
- if (root === target) return parent
- if (root.children) {
- for (const child of root.children) {
- const res = this.findParent(child, target, root)
- if (res) return res
- }
- }
- return null
- },
- // 展开/收起全部
- expandAll(val) {
- // 同时切换所有节点的职位列表显示状态
- this.togglePositionVisibility(this.treeData, val);
- },
- toggleExpand(data, val) {
- this.$set(data, 'expand', val)
- if (data.children) data.children.forEach(c => this.toggleExpand(c, val))
- },
- togglePositionVisibility(data, val) {
- // 只有有职位的节点才设置 _showPos
- if (data.positions && data.positions.length > 0) {
- this.$set(data, '_showPos', val);
- }
- if (data.children) {
- data.children.forEach(child => this.togglePositionVisibility(child, val));
- }
- },
- collapse(list) {
- var that = this;
- list.forEach(function (child) {
- if (child.expand) {
- child.expand = false;
- }
- child.children && that.collapse(child.children);
- });
- },
- // 折叠回调
- onExpand(e, data) {
- console.log('expand change:', data.label, data.expand)
- if ("expand" in data) {
- data.expand = !data.expand;
- if (!data.expand && data.children) {
- this.collapse(data.children);
- }
- } else {
- this.$set(data, "expand", true);
- }
- }
- }
- }
- </script>
- <style lang="scss" scoped>
- .page {
- padding: 20px;
- height: 100vh;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- /* 防止页面滚动 */
- }
- .toolbar {
- margin-bottom: 20px;
- display: flex;
- gap: 15px;
- align-items: center;
- flex-shrink: 0;
- z-index: 10;
- background: white;
- padding: 10px;
- border-radius: 8px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
- }
- .zoom-hint {
- margin-left: auto;
- font-size: 12px;
- color: #666;
- font-weight: 500;
- }
- /* 组件区域 */
- .component-area {
- flex: 1;
- position: relative;
- overflow: hidden;
- border: 1px dashed #d9d9d9;
- border-radius: 8px;
- background: linear-gradient(45deg, #fafafa 25%, transparent 25%, transparent 75%, #fafafa 75%, #fafafa),
- linear-gradient(45deg, #fafafa 25%, transparent 25%, transparent 75%, #fafafa 75%, #fafafa);
- background-size: 20px 20px;
- background-position: 0 0, 10px 10px;
- min-height: 500px;
- }
- /* 拖拽和缩放容器 */
- .drag-zoom-container {
- position: absolute;
- top: 50%;
- left: 50%;
- transform-origin: center;
- will-change: transform;
- /* 优化性能 */
- user-select: none;
- /* 防止文本选择 */
- }
- /* 组织架构树包装器 */
- .org-tree-wrapper {
- display: inline-block;
- background: white;
- border-radius: 8px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
- padding: 30px;
- position: relative;
- }
- /* 拖拽手柄 */
- .drag-handle {
- position: absolute;
- bottom: 10px;
- right: 10px;
- width: 32px;
- height: 32px;
- background: rgba(64, 158, 255, 0.9);
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: grab;
- color: white;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
- z-index: 5;
- transition: all 0.2s;
- }
- .drag-handle:hover {
- background: rgba(64, 158, 255, 1);
- transform: scale(1.1);
- }
- .drag-handle:active {
- cursor: grabbing;
- transform: scale(0.95);
- }
- /* 节点相关样式保持不变 */
- .node-label {
- padding: 4px 8px;
- cursor: pointer;
- border-radius: 3px;
- }
- .node-label:hover {
- background: #f5f7fa;
- }
- .node-body {
- display: inline-block;
- text-align: center;
- padding: 6px 10px;
- background: #fff;
- border: 1px solid #d9d9d9;
- border-radius: 4px;
- }
- .node-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- }
- .node-label {
- font-weight: 500;
- }
- .pos-list {
- list-style: none;
- margin: 4px 0 0 0;
- padding: 0;
- font-size: 12px;
- color: #666;
- text-align: left;
- }
- .pos-list li {
- padding: 2px 0;
- }
- .pos-list li::before {
- content: "· ";
- font-weight: bold;
- color: #409EFF;
- }
- .node-wrapper {
- display: inline-flex;
- align-items: flex-start;
- gap: 4px;
- }
- .fold-btn {
- cursor: pointer;
- font-size: 12px;
- color: #409EFF;
- user-select: none;
- padding: 2px 4px;
- box-sizing: border-box;
- transition: color 0.2s;
- }
- .fold-btn.collapsed {
- color: #909399;
- }
- /* 缩放比例指示器(浮动) */
- .zoom-indicator {
- position: absolute;
- bottom: 20px;
- left: 20px;
- background: rgba(0, 0, 0, 0.7);
- color: white;
- padding: 8px 12px;
- border-radius: 4px;
- font-size: 14px;
- z-index: 10;
- pointer-events: none;
- }
- /* 边界提示(调试用) */
- .boundary-debug {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- border: 2px dashed red;
- pointer-events: none;
- z-index: 100;
- }
- </style>
|