add CustomerInviteRelation.vue

This commit is contained in:
Timi
2026-09-11 10:36:30 +08:00
parent 4209f2abaa
commit 4f1ac9ef0e
9 changed files with 1066 additions and 96 deletions
@@ -0,0 +1,639 @@
<template>
<div ref="chartElement" class="chart" />
</template>
<script setup lang="ts">
import type { ECharts, EChartsOption } from "echarts";
import { GraphChart } from "echarts/charts";
import { TooltipComponent } from "echarts/components";
import { init, use } from "echarts/core";
import { CanvasRenderer } from "echarts/renderers";
import type { Customer } from "@/types/Customer";
use([GraphChart, TooltipComponent, CanvasRenderer]);
type RelationNode = {
id: string;
name: string;
x: number;
y: number;
draggable: boolean;
symbol: string;
symbolSize: [number, number];
cursor: "move";
itemStyle: {
color: string;
};
label: {
color: string;
fontSize: number;
show: boolean;
silent: boolean;
overflow: "truncate";
position: "inside";
width: number;
};
};
type RelationLink = {
source: string;
target: string;
lineStyle: {
color: string;
width: number;
opacity: number;
};
};
type RelationGraphPoint = [number, number];
type RelationGraphNode = {
dataIndex: number;
getLayout: () => RelationGraphPoint;
getGraphicEl: () => {
scaleX?: number;
};
};
type RelationGraphLinePath = {
pointAt: (percent: number) => RelationGraphPoint;
tangentAt: (percent: number) => RelationGraphPoint;
};
type RelationGraphArrow = {
setPosition: (position: RelationGraphPoint) => void;
markRedraw: () => void;
};
type RelationGraphLine = {
setLinePoints: (points: RelationGraphPoint[]) => void;
childOfName: (name: string) => RelationGraphLinePath | RelationGraphArrow | undefined;
beforeUpdate?: (this: RelationGraphLine) => void;
relationArrowOffset?: number;
relationArrowPatched?: boolean;
};
type RelationGraphEdge = {
node1: RelationGraphNode;
node2: RelationGraphNode;
getLayout: () => RelationGraphPoint[];
setLayout: (layout: RelationGraphPoint[]) => void;
getGraphicEl: () => RelationGraphLine;
};
type RelationGraph = {
eachEdge: (callback: (edge: RelationGraphEdge) => void) => void;
getNodeById: (id: string) => RelationGraphNode | undefined;
};
type RelationGraphSeriesModel = {
get: (path: string) => unknown;
getGraph: () => RelationGraph;
};
type RelationChartModel = {
getSeriesByIndex: (index: number) => RelationGraphSeriesModel | undefined;
};
type RelationChart = {
getModel: () => RelationChartModel;
};
defineOptions({
name: "CustomerInviteRelationChart"
});
const props = defineProps<{
customers: Customer[];
}>();
const chartElement = ref<HTMLElement>();
const chartOption = computed<EChartsOption>(() => buildChartOption(props.customers));
const nodeSymbol = "path://M 6 0 H 50 A 6 6 0 0 1 56 6 V 18 A 6 6 0 0 1 50 24 H 6 A 6 6 0 0 1 0 18 V 6 A 6 6 0 0 1 6 0 Z";
const nodeSize: [number, number] = [56, 24];
const nodeCornerRadius = 6;
const relationArrowLength = 4;
const relationArrowTipOffset = 2;
const relationEdgeGap = 0;
const nodeLabelFontSize = 10;
const nodeLabelWidth = 48;
const centerTransitionDuration = 300;
let chart: ECharts | undefined;
let centerAnimationFrame: number | undefined;
function getChartZoom(): number {
const seriesModel = getRelationSeriesModel();
const modelZoom = seriesModel?.get("zoom");
if (typeof modelZoom === "number") {
return modelZoom;
}
const option = chart?.getOption();
const series = Array.isArray(option?.series) ? option.series[0] : option?.series;
const zoom = (series as { zoom?: unknown } | undefined)?.zoom;
return typeof zoom === "number" ? zoom : 1;
}
function getRelationSeriesModel(): RelationGraphSeriesModel | undefined {
return (chart as unknown as RelationChart | undefined)?.getModel().getSeriesByIndex(0);
}
function getNodeGraphicScale(node: RelationGraphNode): number {
const scale = node.getGraphicEl().scaleX;
return typeof scale === "number" && 0 < scale ? scale : 1;
}
function getRoundedRectBoundary(
directionX: number,
directionY: number,
halfWidth: number,
halfHeight: number,
cornerRadius: number
): number {
const absoluteX = Math.abs(directionX);
const absoluteY = Math.abs(directionY);
if (absoluteX < Number.EPSILON) {
return halfHeight;
}
if (absoluteY < Number.EPSILON) {
return halfWidth;
}
const sideDistance = Math.min(halfWidth / absoluteX, halfHeight / absoluteY);
const cornerX = halfWidth - cornerRadius;
const cornerY = halfHeight - cornerRadius;
const sidePointX = sideDistance * absoluteX;
const sidePointY = sideDistance * absoluteY;
if (sidePointX <= cornerX || sidePointY <= cornerY) {
return sideDistance;
}
const projection = cornerX * absoluteX + cornerY * absoluteY;
const cross = cornerX * absoluteY - cornerY * absoluteX;
const discriminant = Math.max(0, cornerRadius ** 2 - cross ** 2);
return projection + Math.sqrt(discriminant);
}
function patchRelationArrowPosition(line: RelationGraphLine): void {
if (!line.beforeUpdate || line.relationArrowPatched) {
return;
}
const beforeUpdate = line.beforeUpdate;
line.relationArrowPatched = true;
line.beforeUpdate = function (this: RelationGraphLine): void {
beforeUpdate.call(this);
const offset = this.relationArrowOffset || 0;
if (!offset) {
return;
}
const linePath = this.childOfName("line") as RelationGraphLinePath | undefined;
const arrow = this.childOfName("toSymbol") as RelationGraphArrow | undefined;
if (!linePath || !arrow) {
return;
}
const endpoint = linePath.pointAt(1);
const tangent = linePath.tangentAt(1);
const tangentLength = Math.hypot(tangent[0], tangent[1]);
if (tangentLength < Number.EPSILON) {
return;
}
arrow.setPosition([
endpoint[0] + tangent[0] / tangentLength * offset,
endpoint[1] + tangent[1] / tangentLength * offset
]);
arrow.markRedraw();
};
}
function adjustRelationEdges(): void {
if (!chart) {
return;
}
const series = (chart as unknown as RelationChart).getModel().getSeriesByIndex(0);
const graph = series?.getGraph();
if (!graph) {
return;
}
graph.eachEdge(edge => {
const source = edge.node1.getLayout();
const target = edge.node2.getLayout();
const directionX = target[0] - source[0];
const directionY = target[1] - source[1];
const distance = Math.hypot(directionX, directionY);
if (distance < Number.EPSILON) {
return;
}
const unitX = directionX / distance;
const unitY = directionY / distance;
const sourceScale = getNodeGraphicScale(edge.node1);
const targetScale = getNodeGraphicScale(edge.node2);
const sourceBoundary = getRoundedRectBoundary(
unitX,
unitY,
nodeSize[0] / 2 * sourceScale,
nodeSize[1] / 2 * sourceScale,
nodeCornerRadius * sourceScale
);
const targetBoundary = getRoundedRectBoundary(
unitX,
unitY,
nodeSize[0] / 2 * targetScale,
nodeSize[1] / 2 * targetScale,
nodeCornerRadius * targetScale
);
const sourceInset = sourceBoundary + relationEdgeGap * sourceScale;
const targetInset = targetBoundary + (relationArrowLength + relationEdgeGap) * targetScale;
if (distance <= sourceInset + targetInset) {
return;
}
const line = edge.getGraphicEl();
patchRelationArrowPosition(line);
line.relationArrowOffset = relationArrowTipOffset * targetScale;
const points = edge.getLayout();
if (points.length !== 2) {
return;
}
const sourcePoint = points[0];
const targetPoint = points[1];
const nextSourceX = source[0] + unitX * sourceInset;
const nextSourceY = source[1] + unitY * sourceInset;
const nextTargetX = target[0] - unitX * targetInset;
const nextTargetY = target[1] - unitY * targetInset;
if (
sourcePoint[0] === nextSourceX &&
sourcePoint[1] === nextSourceY &&
targetPoint[0] === nextTargetX &&
targetPoint[1] === nextTargetY
) {
return;
}
// 保留 ECharts 写入的原始中心点,避免下一次布局继续累积缩短距离。
sourcePoint[0] = nextSourceX;
sourcePoint[1] = nextSourceY;
targetPoint[0] = nextTargetX;
targetPoint[1] = nextTargetY;
edge.setLayout(points);
line.setLinePoints(points);
});
}
function getRelationGraph(): RelationGraph | undefined {
return getRelationSeriesModel()?.getGraph();
}
function focusCustomer(customer: Customer): void {
const id = customer.id;
if (!id || !chart) {
return;
}
const node = getRelationGraph()?.getNodeById(id);
if (!node) {
return;
}
chart.dispatchAction({ type: "downplay", seriesIndex: 0 }, { silent: true });
chart.dispatchAction({ type: "unfocusNodeAdjacency", seriesIndex: 0 }, { silent: true });
chart.dispatchAction({
type: "highlight",
seriesIndex: 0,
dataIndex: node.dataIndex
}, { silent: true });
centerChartOnNode(node);
}
function centerChartOnNode(node: RelationGraphNode): void {
if (!chart) {
return;
}
stopCenterAnimation();
const pixel = chart.convertToPixel({ seriesIndex: 0 }, node.getLayout());
if (!Array.isArray(pixel) || 2 > pixel.length) {
return;
}
const deltaX = chart.getWidth() / 2 - pixel[0];
const deltaY = chart.getHeight() / 2 - pixel[1];
if (Math.abs(deltaX) < 1 && Math.abs(deltaY) < 1) {
return;
}
const beginAt = window.performance.now();
let previousProgress = 0;
const animate = (timestamp: number): void => {
if (!chart) {
centerAnimationFrame = undefined;
return;
}
const progress = Math.min(1, (timestamp - beginAt) / centerTransitionDuration);
const easedProgress = 1 - (1 - progress) ** 3;
const deltaProgress = easedProgress - previousProgress;
if (0 < deltaProgress) {
chart.dispatchAction({
type: "graphRoam",
seriesIndex: 0,
dx: deltaX * deltaProgress,
dy: deltaY * deltaProgress
}, { silent: true });
}
previousProgress = easedProgress;
if (1 <= progress) {
centerAnimationFrame = undefined;
return;
}
centerAnimationFrame = window.requestAnimationFrame(animate);
};
centerAnimationFrame = window.requestAnimationFrame(animate);
}
function stopCenterAnimation(): void {
if (centerAnimationFrame === undefined) {
return;
}
window.cancelAnimationFrame(centerAnimationFrame);
centerAnimationFrame = undefined;
}
function clearFocus(): void {
if (!chart) {
return;
}
chart.dispatchAction({ type: "downplay", seriesIndex: 0 }, { silent: true });
chart.dispatchAction({ type: "unfocusNodeAdjacency", seriesIndex: 0 }, { silent: true });
}
function buildChartOption(list: Customer[]): EChartsOption {
const customerMap = new Map<string, Customer>();
for (const customer of list) {
if (customer.id) {
customerMap.set(customer.id, customer);
}
}
const depthMap = new Map<string, number>();
const levelMap = new Map<number, Customer[]>();
for (const customer of customerMap.values()) {
const depth = getCustomerDepth(customer, customerMap, depthMap, new Set<string>());
const levelList = levelMap.get(depth) || [];
levelList.push(customer);
levelMap.set(depth, levelList);
}
const nodeList: RelationNode[] = [];
const linkList: RelationLink[] = [];
for (const [depth, levelList] of levelMap) {
const levelOffset = (levelList.length - 1) / 2;
levelList.forEach((customer, index) => {
const id = customer.id;
if (!id) {
return;
}
nodeList.push({
id,
name: getCustomerName(customer),
x: depth * 220 + 120,
y: (index - levelOffset) * 96 + 1200,
draggable: true,
symbol: nodeSymbol,
symbolSize: nodeSize,
cursor: "move",
itemStyle: {
color: getNodeColor(customer)
},
label: {
color: "#ffffff",
fontSize: nodeLabelFontSize,
show: true,
silent: true,
overflow: "truncate",
position: "inside",
width: nodeLabelWidth
}
});
const introducerId = customer.introducerCustomerId;
if (introducerId && customerMap.has(introducerId)) {
linkList.push({
source: introducerId,
target: id,
lineStyle: {
color: "#0f766e",
width: 2,
opacity: 1
}
});
}
});
}
return {
animationDuration: 400,
tooltip: {
formatter: params => {
const data = Array.isArray(params) ? params[0]?.data : params.data;
const node = data as RelationNode | undefined;
if (!node) {
return "";
}
const customer = customerMap.get(node.id);
if (!customer) {
return node.name;
}
return [
`客户:${getCustomerName(customer)}`,
`编码:${customer.code || "未填写"}`,
`邀请人数:${customer.invitedCount ?? 0} 人`
].join("<br />");
}
},
series: [
{
type: "graph",
layout: "force",
roam: true,
roamTrigger: "global",
draggable: true,
nodeScaleRatio: 0,
scaleLimit: {
min: .5,
max: 4
},
focusNodeAdjacency: true,
force: {
initLayout: "none",
repulsion: 1000,
edgeLength: 640,
gravity: .01,
layoutAnimation: true
},
labelLayout: () => {
const scale = getChartZoom();
return {
fontSize: nodeLabelFontSize * scale,
width: nodeLabelWidth * scale
};
},
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: [0, 10],
data: nodeList,
links: linkList,
lineStyle: {
color: "#0f766e"
},
emphasis: {
scale: false,
focus: "adjacency"
},
blur: {
itemStyle: {
opacity: .5
},
lineStyle: {
opacity: .3
},
label: {
opacity: 1
}
},
stateAnimation: {
duration: 300,
easing: "cubicOut"
}
}
]
};
}
function getCustomerDepth(customer: Customer, customerMap: Map<string, Customer>, depthMap: Map<string, number>, visiting: Set<string>): number {
const id = customer.id;
if (!id) {
return 0;
}
const cachedDepth = depthMap.get(id);
if (cachedDepth !== undefined) {
return cachedDepth;
}
if (visiting.has(id)) {
return 0;
}
const introducerId = customer.introducerCustomerId;
if (!introducerId || !customerMap.has(introducerId)) {
depthMap.set(id, 0);
return 0;
}
visiting.add(id);
const depth = getCustomerDepth(customerMap.get(introducerId) as Customer, customerMap, depthMap, visiting) + 1;
visiting.delete(id);
depthMap.set(id, depth);
return depth;
}
function getCustomerName(customer: Customer): string {
return customer.name || customer.code || customer.id || "未命名客户";
}
function getNodeColor(customer: Customer): string {
const invitedCount = customer.invitedCount ?? 0;
if (10 <= invitedCount) {
return "#dc2626";
}
if (6 <= invitedCount) {
return "#f59e0b";
}
if (3 <= invitedCount) {
return "#2563eb";
}
if (1 <= invitedCount) {
return "#60a5fa";
}
return "#94a3b8";
}
function renderChart(): void {
if (!chartElement.value || !props.customers.length) {
return;
}
if (!chart) {
chart = init(chartElement.value);
chart.getZr().animation.on("frame", adjustRelationEdges);
chart.on("graphroam", updateLabelLayout);
}
chart.setOption(chartOption.value, true);
chart.resize();
adjustRelationEdges();
chart.getZr().refreshImmediately();
}
function resizeChart(): void {
if (!chart) {
return;
}
chart.resize();
adjustRelationEdges();
chart.getZr().refreshImmediately();
}
function updateLabelLayout(...args: unknown[]): void {
const payload = args[0] as { zoom?: unknown } | undefined;
if (typeof payload?.zoom !== "number") {
return;
}
chart?.updateLabelLayout();
}
watch(chartOption, async () => {
await nextTick();
renderChart();
}, { deep: true });
onMounted(() => {
window.addEventListener("resize", resizeChart);
renderChart();
});
onBeforeUnmount(() => {
stopCenterAnimation();
window.removeEventListener("resize", resizeChart);
chart?.getZr().animation.off("frame", adjustRelationEdges);
chart?.off("graphroam", updateLabelLayout);
chart?.dispose();
chart = undefined;
});
defineExpose({
focusCustomer,
clearFocus
});
</script>
<style scoped lang="less">
.chart {
width: 100%;
height: 100%;
touch-action: none;
overscroll-behavior: none;
cursor: grab;
&:active {
cursor: grabbing;
}
:deep(canvas) {
touch-action: none;
}
}
</style>