add CustomerInviteRelation.vue
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user