553 lines
16 KiB
Vue
553 lines
16 KiB
Vue
<template>
|
||
<div
|
||
ref="mindMapViewport"
|
||
class="org-mind-map"
|
||
:class="{ 'org-mind-map--panning': isCanvasPanning }"
|
||
@mousedown="startCanvasPan"
|
||
>
|
||
<svg
|
||
class="org-mind-map-canvas"
|
||
:viewBox="`0 0 ${canvasWidth} ${canvasHeight}`"
|
||
:style="scaledCanvasStyle"
|
||
role="img"
|
||
aria-label="组织权限脑图"
|
||
>
|
||
<g class="org-mind-map-connectors" fill="none">
|
||
<path
|
||
v-for="connector in layout.connectors"
|
||
:key="connector.key"
|
||
class="org-mind-map-connector"
|
||
:d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
|
||
/>
|
||
</g>
|
||
|
||
<g
|
||
v-for="node in layout.nodes"
|
||
:key="node.key"
|
||
:class="['org-mind-map-node', { 'org-mind-map-node--employee': node.isEmployee }]"
|
||
:transform="`translate(${node.x} ${node.y})`"
|
||
tabindex="0"
|
||
role="button"
|
||
@click="emit('select-node', node.raw)"
|
||
@contextmenu.prevent.stop="emit('node-contextmenu', { node: node.raw, x: $event.clientX, y: $event.clientY })"
|
||
@keyup.enter="emit('select-node', node.raw)"
|
||
>
|
||
<title>{{ node.title }} · {{ node.subtitle }}</title>
|
||
<rect class="org-mind-map-card" :width="node.width" :height="NODE_HEIGHT" rx="16" />
|
||
<text class="org-mind-map-title" x="18" y="31">{{ node.displayTitle }}</text>
|
||
<text class="org-mind-map-subtitle" x="18" y="57">{{ node.displaySubtitle }}</text>
|
||
<text
|
||
v-if="!node.isEmployee && node.employee_count"
|
||
class="org-mind-map-employee-count"
|
||
x="18"
|
||
y="76"
|
||
>
|
||
{{ node.employee_count }} 人
|
||
</text>
|
||
|
||
<g
|
||
v-if="!node.isEmployee && node.employee_count"
|
||
class="org-mind-map-employee-toggle"
|
||
:transform="`translate(${node.width - 38} 14)`"
|
||
role="button"
|
||
tabindex="0"
|
||
@click.stop="toggleEmployeeLeaves(node.raw)"
|
||
@contextmenu.prevent.stop
|
||
@keyup.enter.stop="toggleEmployeeLeaves(node.raw)"
|
||
>
|
||
<circle r="15" cx="15" cy="15" />
|
||
<text x="15" y="20" text-anchor="middle">{{ isEmployeeExpanded(node.raw) ? "−" : "+" }}</text>
|
||
</g>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||
|
||
const props = defineProps({
|
||
nodes: {
|
||
type: Array,
|
||
default: () => []
|
||
},
|
||
zoomScale: {
|
||
type: Number,
|
||
default: 1
|
||
},
|
||
fitRequestKey: {
|
||
type: Number,
|
||
default: 0
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves", "fit-scale"]);
|
||
|
||
const NODE_MIN_WIDTH = 190;
|
||
const NODE_MAX_WIDTH = 420;
|
||
const NODE_HEIGHT = 92;
|
||
const TEXT_PADDING_X = 40;
|
||
const TOGGLE_RESERVE_WIDTH = 52;
|
||
const COLUMN_GAP = 150;
|
||
const ROW_GAP = 34;
|
||
const CANVAS_PADDING = 24;
|
||
|
||
const COLUMN_WIDTH = NODE_MAX_WIDTH + COLUMN_GAP;
|
||
const ROW_HEIGHT = NODE_HEIGHT + ROW_GAP;
|
||
const ROOT_GROUP_GAP = 1;
|
||
const FIT_SCALE_MIN = 0.12;
|
||
const FIT_SCALE_MAX = 1;
|
||
|
||
const mindMapViewport = ref(null);
|
||
const isCanvasPanning = ref(false);
|
||
const canvasPan = ref({
|
||
startX: 0,
|
||
startY: 0,
|
||
scrollLeft: 0,
|
||
scrollTop: 0
|
||
});
|
||
const expandedEmployeeNodeIds = ref(new Set());
|
||
const collapsedEmployeeNodeIds = computed(() => {
|
||
const collapsed = new Set();
|
||
for (const node of flattenOrgNodes(props.nodes)) {
|
||
if ((node.employees || []).length && !expandedEmployeeNodeIds.value.has(node.node_id)) {
|
||
collapsed.add(node.node_id);
|
||
}
|
||
}
|
||
return collapsed;
|
||
});
|
||
|
||
function flattenOrgNodes(nodes) {
|
||
const result = [];
|
||
for (const node of nodes || []) {
|
||
result.push(node);
|
||
result.push(...flattenOrgNodes(node.children || []));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function isEmployeeExpanded(node) {
|
||
return expandedEmployeeNodeIds.value.has(node?.node_id);
|
||
}
|
||
|
||
function toggleEmployeeLeaves(node) {
|
||
if (!node?.node_id) {
|
||
return;
|
||
}
|
||
const next = new Set(expandedEmployeeNodeIds.value);
|
||
if (next.has(node.node_id)) {
|
||
next.delete(node.node_id);
|
||
} else {
|
||
next.add(node.node_id);
|
||
}
|
||
expandedEmployeeNodeIds.value = next;
|
||
emit("toggle-employee-leaves", node);
|
||
}
|
||
|
||
function shouldIgnoreCanvasPan(event) {
|
||
const target = event.target;
|
||
return Boolean(
|
||
target?.closest?.(".org-mind-map-node") ||
|
||
target?.closest?.(".org-mind-map-employee-toggle")
|
||
);
|
||
}
|
||
|
||
function startCanvasPan(event) {
|
||
if (event.button !== 0 || shouldIgnoreCanvasPan(event)) {
|
||
return;
|
||
}
|
||
const viewport = mindMapViewport.value;
|
||
if (!viewport) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
isCanvasPanning.value = true;
|
||
canvasPan.value = {
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
scrollLeft: viewport.scrollLeft,
|
||
scrollTop: viewport.scrollTop
|
||
};
|
||
window.addEventListener("mousemove", moveCanvasPan);
|
||
window.addEventListener("mouseup", endCanvasPan);
|
||
}
|
||
|
||
function moveCanvasPan(event) {
|
||
if (!isCanvasPanning.value) {
|
||
return;
|
||
}
|
||
const viewport = mindMapViewport.value;
|
||
if (!viewport) {
|
||
return;
|
||
}
|
||
const deltaX = event.clientX - canvasPan.value.startX;
|
||
const deltaY = event.clientY - canvasPan.value.startY;
|
||
event.preventDefault();
|
||
viewport.scrollLeft = canvasPan.value.scrollLeft - deltaX;
|
||
viewport.scrollTop = canvasPan.value.scrollTop - deltaY;
|
||
}
|
||
|
||
function endCanvasPan() {
|
||
isCanvasPanning.value = false;
|
||
window.removeEventListener("mousemove", moveCanvasPan);
|
||
window.removeEventListener("mouseup", endCanvasPan);
|
||
}
|
||
|
||
function estimateTextWidth(value, fontSize = 16) {
|
||
return Array.from(String(value || "")).reduce((width, char) => {
|
||
if (/[\u4e00-\u9fff\uff00-\uffef]/.test(char)) {
|
||
return width + fontSize;
|
||
}
|
||
if (/\s/.test(char)) {
|
||
return width + fontSize * 0.35;
|
||
}
|
||
if (/[A-Z0-9]/.test(char)) {
|
||
return width + fontSize * 0.62;
|
||
}
|
||
return width + fontSize * 0.55;
|
||
}, 0);
|
||
}
|
||
|
||
function clamp(value, min, max) {
|
||
return Math.min(max, Math.max(min, value));
|
||
}
|
||
|
||
function fitTextToWidth(value, maxWidth, fontSize = 16) {
|
||
const text = String(value || "");
|
||
if (estimateTextWidth(text, fontSize) <= maxWidth) {
|
||
return text;
|
||
}
|
||
let result = "";
|
||
for (const char of Array.from(text)) {
|
||
const next = `${result}${char}`;
|
||
if (estimateTextWidth(`${next}...`, fontSize) > maxWidth) {
|
||
return `${result}...`;
|
||
}
|
||
result = next;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function computeNodeWidth({ title, subtitle, hasEmployeeToggle }) {
|
||
const toggleReserve = hasEmployeeToggle ? TOGGLE_RESERVE_WIDTH : 0;
|
||
const titleWidth = estimateTextWidth(title, 16) + TEXT_PADDING_X + toggleReserve;
|
||
const subtitleWidth = estimateTextWidth(subtitle, 13) + TEXT_PADDING_X;
|
||
return clamp(Math.ceil(Math.max(titleWidth, subtitleWidth)), NODE_MIN_WIDTH, NODE_MAX_WIDTH);
|
||
}
|
||
|
||
function toVisualChildren(node) {
|
||
const orgChildren = Array.isArray(node.children) ? node.children : [];
|
||
const shouldShowEmployees = Boolean(node.node_id) && !collapsedEmployeeNodeIds.value.has(node.node_id);
|
||
const employeeLeaves = shouldShowEmployees && Array.isArray(node.employees)
|
||
? node.employees.map((employee) => ({
|
||
...employee,
|
||
parent_node_id: node.node_id,
|
||
parent_node_label: node.node_label,
|
||
node_type: "EMPLOYEE",
|
||
node_label: employee.employee_name,
|
||
children: [],
|
||
employees: []
|
||
}))
|
||
: [];
|
||
return [...orgChildren, ...employeeLeaves];
|
||
}
|
||
|
||
function normalizeNode(node, index, parentKey = "root") {
|
||
const isEmployee = node.node_type === "EMPLOYEE" || Boolean(node.employee_id && !node.node_id);
|
||
const id = node.node_id ?? node.employee_id ?? `${parentKey}-${index}`;
|
||
const key = `${isEmployee ? "employee" : "org"}-${id}`;
|
||
const managerSubtitle = Array.isArray(node.manager_names) && node.manager_names.length
|
||
? node.manager_names.join("、")
|
||
: node.manager_name || node.subtitle || "未设置主管";
|
||
const title = node.node_label || node.employee_name || node.title || "未命名节点";
|
||
const subtitle = isEmployee ? node.mobile || "未设置电话" : managerSubtitle;
|
||
const employeeCount = Array.isArray(node.employees) ? node.employees.length : 0;
|
||
const width = computeNodeWidth({
|
||
title,
|
||
subtitle,
|
||
hasEmployeeToggle: !isEmployee && employeeCount > 0
|
||
});
|
||
return {
|
||
raw: node,
|
||
key,
|
||
isEmployee,
|
||
width,
|
||
employee_count: employeeCount,
|
||
title,
|
||
subtitle,
|
||
displayTitle: fitTextToWidth(title, width - TEXT_PADDING_X - (!isEmployee && employeeCount ? TOGGLE_RESERVE_WIDTH : 0), 16),
|
||
displaySubtitle: fitTextToWidth(subtitle, width - TEXT_PADDING_X, 13),
|
||
children: toVisualChildren(node).map((child, childIndex) => normalizeNode(child, childIndex, key))
|
||
};
|
||
}
|
||
|
||
function normalizeNodes(nodes) {
|
||
return nodes.map((node, index) => normalizeNode(node, index));
|
||
}
|
||
|
||
function measureSubtree(node) {
|
||
if (!node.children.length) {
|
||
node.subtreeSpan = 1;
|
||
return node.subtreeSpan;
|
||
}
|
||
|
||
node.subtreeSpan = node.children.reduce((total, child) => total + measureSubtree(child), 0);
|
||
return node.subtreeSpan;
|
||
}
|
||
|
||
function segmentKey(segment) {
|
||
const x1 = Math.round(segment.x1 * 100) / 100;
|
||
const y1 = Math.round(segment.y1 * 100) / 100;
|
||
const x2 = Math.round(segment.x2 * 100) / 100;
|
||
const y2 = Math.round(segment.y2 * 100) / 100;
|
||
const start = `${Math.min(x1, x2)},${Math.min(y1, y2)}`;
|
||
const end = `${Math.max(x1, x2)},${Math.max(y1, y2)}`;
|
||
return `${start}-${end}`;
|
||
}
|
||
|
||
function connectorSegments(parent, child, laneX, trunkFromY, trunkToY) {
|
||
const parentEdgeX = parent.x + parent.width;
|
||
const parentCenterY = parent.y + NODE_HEIGHT / 2;
|
||
const childEdgeX = child.x;
|
||
const childCenterY = child.y + NODE_HEIGHT / 2;
|
||
|
||
// Orthogonal connector logic: edge stems stay outside cards, trunk stays in the column gutter.
|
||
return [
|
||
{ x1: parentEdgeX, y1: parentCenterY, x2: laneX, y2: parentCenterY },
|
||
{ x1: laneX, y1: trunkFromY, x2: laneX, y2: trunkToY },
|
||
{ x1: laneX, y1: childCenterY, x2: childEdgeX, y2: childCenterY }
|
||
];
|
||
}
|
||
|
||
function toConnector(segment) {
|
||
return {
|
||
key: segmentKey(segment),
|
||
fromX: segment.x1,
|
||
fromY: segment.y1,
|
||
midX: segment.x2,
|
||
toY: segment.y2,
|
||
toX: segment.x2
|
||
};
|
||
}
|
||
|
||
function layoutTree(rootNodes) {
|
||
const nodes = [];
|
||
const connectors = [];
|
||
const connectorMap = new Map();
|
||
let nextLeafRow = 0;
|
||
let maxDepth = 0;
|
||
|
||
function placeNode(node, depth) {
|
||
maxDepth = Math.max(maxDepth, depth);
|
||
|
||
if (!node.children.length) {
|
||
const row = nextLeafRow;
|
||
nextLeafRow += 1;
|
||
const layoutNode = {
|
||
...node,
|
||
depth,
|
||
row,
|
||
x: CANVAS_PADDING + depth * COLUMN_WIDTH,
|
||
y: CANVAS_PADDING + row * ROW_HEIGHT
|
||
};
|
||
nodes.push(layoutNode);
|
||
return layoutNode;
|
||
}
|
||
|
||
const childNodes = node.children.map((child) => placeNode(child, depth + 1));
|
||
const firstChild = childNodes[0];
|
||
const lastChild = childNodes[childNodes.length - 1];
|
||
const row = (firstChild.row + lastChild.row) / 2;
|
||
const layoutNode = {
|
||
...node,
|
||
depth,
|
||
row,
|
||
x: CANVAS_PADDING + depth * COLUMN_WIDTH,
|
||
y: CANVAS_PADDING + row * ROW_HEIGHT
|
||
};
|
||
nodes.push(layoutNode);
|
||
|
||
const laneX = layoutNode.x + layoutNode.width + COLUMN_GAP / 2;
|
||
const childCenterYs = childNodes.map((child) => child.y + NODE_HEIGHT / 2);
|
||
const trunkFromY = Math.min(...childCenterYs);
|
||
const trunkToY = Math.max(...childCenterYs);
|
||
|
||
childNodes.forEach((child) => {
|
||
connectorSegments(layoutNode, child, laneX, trunkFromY, trunkToY).forEach((segment) => {
|
||
const key = segmentKey(segment);
|
||
if (!connectorMap.has(key)) {
|
||
connectorMap.set(key, true);
|
||
connectors.push(toConnector(segment));
|
||
}
|
||
});
|
||
});
|
||
|
||
return layoutNode;
|
||
}
|
||
|
||
rootNodes.forEach((rootNode, index) => {
|
||
measureSubtree(rootNode);
|
||
placeNode(rootNode, 0);
|
||
if (index < rootNodes.length - 1) {
|
||
nextLeafRow += ROOT_GROUP_GAP;
|
||
}
|
||
});
|
||
|
||
const maxRow = Math.max(0, ...nodes.map((node) => node.row));
|
||
|
||
return {
|
||
nodes,
|
||
connectors,
|
||
width: CANVAS_PADDING * 2 + NODE_MAX_WIDTH + maxDepth * COLUMN_WIDTH,
|
||
height: CANVAS_PADDING * 2 + NODE_HEIGHT + maxRow * ROW_HEIGHT
|
||
};
|
||
}
|
||
|
||
const layout = computed(() => layoutTree(normalizeNodes(props.nodes)));
|
||
const canvasWidth = computed(() => Math.max(760, layout.value.width));
|
||
const canvasHeight = computed(() => Math.max(460, layout.value.height));
|
||
const scaledCanvasStyle = computed(() => ({
|
||
width: `${canvasWidth.value * props.zoomScale}px`,
|
||
height: `${canvasHeight.value * props.zoomScale}px`
|
||
}));
|
||
|
||
function centerCanvasInViewport() {
|
||
const viewport = mindMapViewport.value;
|
||
if (!viewport) {
|
||
return;
|
||
}
|
||
viewport.scrollLeft = Math.max(0, (viewport.scrollWidth - viewport.clientWidth) / 2);
|
||
viewport.scrollTop = Math.max(0, (viewport.scrollHeight - viewport.clientHeight) / 2);
|
||
}
|
||
|
||
async function fitCanvasToViewport() {
|
||
await nextTick();
|
||
const viewport = mindMapViewport.value;
|
||
if (!viewport) {
|
||
return;
|
||
}
|
||
const availableWidth = Math.max(1, viewport.clientWidth - 24);
|
||
const availableHeight = Math.max(1, viewport.clientHeight - 24);
|
||
const widthScale = availableWidth / canvasWidth.value;
|
||
const heightScale = availableHeight / canvasHeight.value;
|
||
const nextScale = clamp(Math.min(widthScale, heightScale), FIT_SCALE_MIN, FIT_SCALE_MAX);
|
||
emit("fit-scale", Number(nextScale.toFixed(2)));
|
||
requestAnimationFrame(centerCanvasInViewport);
|
||
}
|
||
|
||
watch(
|
||
() => props.fitRequestKey,
|
||
() => {
|
||
if (props.fitRequestKey > 0) {
|
||
fitCanvasToViewport();
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
onBeforeUnmount(() => {
|
||
endCanvasPan();
|
||
});
|
||
|
||
defineExpose({
|
||
fitCanvasToViewport
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.org-mind-map {
|
||
width: 100%;
|
||
height: clamp(520px, 68vh, 760px);
|
||
min-height: 460px;
|
||
overflow: auto;
|
||
overscroll-behavior: contain;
|
||
border: 1px solid rgba(58, 82, 105, 0.18);
|
||
border-radius: 24px;
|
||
background:
|
||
linear-gradient(90deg, rgba(58, 82, 105, 0.06) 1px, transparent 1px) 0 0 / 28px 28px,
|
||
linear-gradient(0deg, rgba(58, 82, 105, 0.05) 1px, transparent 1px) 0 0 / 28px 28px,
|
||
radial-gradient(circle at 12% 12%, rgba(14, 165, 233, 0.12), transparent 30%),
|
||
linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(232, 239, 247, 0.72));
|
||
background-size:
|
||
28px 28px,
|
||
28px 28px,
|
||
auto,
|
||
auto;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
|
||
cursor: grab;
|
||
user-select: none;
|
||
}
|
||
|
||
.org-mind-map--panning {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.org-mind-map-canvas {
|
||
display: block;
|
||
}
|
||
|
||
.org-mind-map-connector {
|
||
stroke: rgba(79, 115, 148, 0.7);
|
||
stroke-width: 2.25;
|
||
stroke-linecap: square;
|
||
stroke-linejoin: miter;
|
||
filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.76));
|
||
}
|
||
|
||
.org-mind-map-node {
|
||
cursor: pointer;
|
||
outline: none;
|
||
}
|
||
|
||
.org-mind-map-node:focus .org-mind-map-card,
|
||
.org-mind-map-node:hover .org-mind-map-card {
|
||
stroke: #0ea5e9;
|
||
stroke-width: 2;
|
||
filter: drop-shadow(0 18px 26px rgba(37, 99, 235, 0.18));
|
||
}
|
||
|
||
.org-mind-map-card {
|
||
fill: rgba(255, 255, 255, 0.96);
|
||
stroke: rgba(58, 82, 105, 0.2);
|
||
filter: drop-shadow(0 14px 22px rgba(15, 23, 42, 0.11));
|
||
}
|
||
|
||
.org-mind-map-node--employee .org-mind-map-card {
|
||
fill: rgba(240, 253, 250, 0.96);
|
||
stroke: rgba(20, 184, 166, 0.34);
|
||
}
|
||
|
||
.org-mind-map-title {
|
||
fill: #142033;
|
||
font-size: 16px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.org-mind-map-subtitle,
|
||
.org-mind-map-employee-count {
|
||
fill: #647587;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.org-mind-map-node--employee .org-mind-map-title {
|
||
fill: #0f766e;
|
||
}
|
||
|
||
.org-mind-map-employee-toggle {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.org-mind-map-employee-toggle circle {
|
||
fill: #e0f2fe;
|
||
stroke: rgba(14, 165, 233, 0.38);
|
||
}
|
||
|
||
.org-mind-map-employee-toggle text {
|
||
fill: #0369a1;
|
||
font-size: 18px;
|
||
font-weight: 900;
|
||
pointer-events: none;
|
||
}
|
||
</style>
|