ForgeFlow-ERP/frontend/src/components/OrgMindMap.vue
2026-06-12 16:29:05 +08:00

553 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>