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
+13
View File
@@ -99,6 +99,7 @@
</t-form-item> </t-form-item>
<t-form-item label="介绍人"> <t-form-item label="介绍人">
<div class="referrer"> <div class="referrer">
<div class="selector">
<t-input <t-input
:value="formData.introducerCustomer?.name" :value="formData.introducerCustomer?.name"
readonly readonly
@@ -116,6 +117,12 @@
>选择客户</t-button> >选择客户</t-button>
</div> </div>
</div> </div>
<customer-profile-card
v-if="formData.introducerCustomer"
class="profile"
:customer="formData.introducerCustomer"
/>
</div>
</t-form-item> </t-form-item>
<t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker"> <t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker">
<div class="picker-text" v-text="selectedAreaText" /> <div class="picker-text" v-text="selectedAreaText" />
@@ -739,6 +746,12 @@ function buildAttachmentList(): Attachment[] {
.referrer { .referrer {
gap: .75rem; gap: .75rem;
display: flex; display: flex;
flex-direction: column;
.selector {
gap: .75rem;
display: flex;
}
.actions { .actions {
gap: .5rem; gap: .5rem;
+5 -7
View File
@@ -1,7 +1,10 @@
<template> <template>
<t-pull-down-refresh <div
v-can="permissionCodes.customer.read" v-can="permissionCodes.customer.read"
class="customer-invite-rank" class="customer-invite-rank"
>
<t-pull-down-refresh
class="list-wrapper"
v-model="isRefreshing" v-model="isRefreshing"
:refresh-timeout="30000" :refresh-timeout="30000"
@refresh="refresh" @refresh="refresh"
@@ -44,6 +47,7 @@
<div v-else class="center-tips" v-text="emptyText" /> <div v-else class="center-tips" v-text="emptyText" />
<div v-if="rankedCustomerList.length" class="center-tips" v-text="loadStateText" /> <div v-if="rankedCustomerList.length" class="center-tips" v-text="loadStateText" />
</t-pull-down-refresh> </t-pull-down-refresh>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -84,9 +88,6 @@ const isNoMore = ref(false);
let loadedCustomerCount = 0; let loadedCustomerCount = 0;
const rankedCustomerList = computed(() => pageResult.list); const rankedCustomerList = computed(() => pageResult.list);
const summaryText = computed(() => isNoMore.value
? `${rankedCustomerList.value.length} 位客户有邀请记录`
: `已加载 ${rankedCustomerList.value.length} 位客户`);
const emptyText = computed(() => isLoading.value ? "正在加载客户邀请排行" : "暂无客户邀请记录"); const emptyText = computed(() => isLoading.value ? "正在加载客户邀请排行" : "暂无客户邀请记录");
const loadStateText = computed(() => { const loadStateText = computed(() => {
if (isLoading.value) { if (isLoading.value) {
@@ -195,8 +196,6 @@ onMounted(async () => {
} }
} }
.list-wrapper {
.list { .list {
.item { .item {
@@ -235,7 +234,6 @@ onMounted(async () => {
} }
} }
} }
}
:global(html.theme-dark) { :global(html.theme-dark) {
.customer-invite-rank { .customer-invite-rank {
@@ -0,0 +1,302 @@
<template>
<div
v-can="permissionCodes.customer.read"
class="customer-invite-relation"
>
<div v-if="isLoading" class="state">
<t-loading size="1.5rem" text="正在加载客户关系" />
</div>
<div v-else-if="errorText" class="state">
<t-empty :description="errorText" />
<t-button theme="primary" variant="outline" @click="loadCustomers">重新加载</t-button>
</div>
<div v-else-if="!relationCustomerList.length" class="state">
<t-empty description="暂无邀请关系" />
</div>
<customer-invite-relation-chart
v-else
ref="chartComponent"
:customers="relationCustomerList"
/>
<div v-if="relationCustomerList.length && !isLoading && !errorText" class="info-card">
<t-input
v-model.trim="searchKeyword"
class="search-input"
borderless
clearable
aria-label="搜索关系节点"
placeholder="搜索编码、姓名或拼音"
>
<template #prefix-icon>
<t-icon name="search" />
</template>
</t-input>
<div class="summary">
<span v-text="`客户 ${relationCustomerList.length} 人`" />
<span v-text="`关系 ${relationCount} 条`" />
<span>双指缩放查看关系</span>
<div class="legend">
<span v-for="item in inviteColorLegend" :key="item.label" class="legend-item">
<i class="color" :style="{ background: item.color }" />
<span v-text="item.label" />
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Toolkit } from "timi-web";
import { type PageResult, useUserStore } from "timi-web-api";
import CustomerAPI, { type CustomerPage } from "@/api/CustomerAPI";
import CustomerInviteRelationChart from "@/pages/customer/component/CustomerInviteRelationChart.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type InviteColorLegendItem = {
color: string;
label: string;
};
defineOptions({
name: "CustomerInviteRelation"
});
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const navBarStore = useNavBarStore();
const router = useRouter();
const chartComponent = ref<InstanceType<typeof CustomerInviteRelationChart>>();
const customerList = ref<Customer[]>([]);
const isLoading = ref(false);
const errorText = ref("");
const searchKeyword = ref("");
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const customerIdSet = computed(() => new Set(customerList.value.map(item => item.id).filter((id): id is string => !!id)));
const relationCustomerList = computed(() => filterRelationCustomers(customerList.value));
const searchMatchedCustomers = computed(() => {
const keyword = searchKeyword.value.trim().toLocaleLowerCase();
if (!keyword) {
return [];
}
return relationCustomerList.value.filter(customer => [
customer.name,
customer.code,
customer.telephone,
customer.namePinyinFull,
customer.namePinyinInitial,
customer.namePinyinMixed
].some(value => normalizeSearchValue(value).includes(keyword)));
});
const relationCount = computed(() => relationCustomerList.value.filter(item => !!item.introducerCustomerId).length);
const inviteColorLegend: InviteColorLegendItem[] = [
{ color: "#94a3b8", label: "0 人" },
{ color: "#60a5fa", label: "1-2 人" },
{ color: "#2563eb", label: "3-5 人" },
{ color: "#f59e0b", label: "6-9 人" },
{ color: "#dc2626", label: "10 人以上" }
];
function normalizeSearchValue(value?: string): string {
return value?.trim().toLocaleLowerCase() || "";
}
// ---------- 数据 ----------
async function loadCustomers(): Promise<void> {
if (isLoading.value) {
return;
}
isLoading.value = true;
errorText.value = "";
try {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
customerList.value = await loadAllCustomers();
} catch (error) {
customerList.value = [];
errorText.value = getErrorMessage(error, "客户关系加载失败");
Message.error(errorText.value);
} finally {
isLoading.value = false;
}
}
async function loadAllCustomers(): Promise<Customer[]> {
const pageSize = 500;
const list: Customer[] = [];
let pageIndex = 0;
let total = 0;
while (true) {
const page: CustomerPage = {
index: pageIndex,
size: pageSize,
storeId: currentStoreStore.currentStoreId
};
const result: PageResult<Customer> = await CustomerAPI.list(page);
const pageList = result.list || [];
list.push(...pageList);
total = result.total || total;
pageIndex++;
if (!pageList.length || (0 < total && total <= list.length) || (total <= 0 && pageList.length < pageSize)) {
break;
}
}
return list;
}
function filterRelationCustomers(list: Customer[]): Customer[] {
const relationCustomerIdSet = new Set<string>();
const idSet = customerIdSet.value;
for (const customer of list) {
const customerId = customer.id;
const introducerCustomerId = customer.introducerCustomerId;
if (!customerId || !introducerCustomerId || !idSet.has(introducerCustomerId)) {
continue;
}
relationCustomerIdSet.add(customerId);
relationCustomerIdSet.add(introducerCustomerId);
}
return list.filter(customer => !!customer.id && relationCustomerIdSet.has(customer.id));
}
// ---------- 节点搜索 ----------
const focusSearchCustomer = Toolkit.debounce(() => {
if (!searchKeyword.value.trim()) {
chartComponent.value?.clearFocus();
return;
}
const customer = searchMatchedCustomers.value[0];
if (!customer) {
chartComponent.value?.clearFocus();
return;
}
chartComponent.value?.focusCustomer(customer);
}, false);
// ---------- 导航 ----------
async function toInviteRank(): Promise<void> {
await router.push("/management/customer-invite-rank");
}
// ---------- 生命周期 ----------
onMounted(async () => {
navBarStore.setRightText("排行", "var(--td-brand-color)", toInviteRank);
await loadCustomers();
});
watch(searchKeyword, () => {
focusSearchCustomer();
});
onBeforeUnmount(() => {
navBarStore.clearRight();
});
</script>
<style scoped lang="less">
.customer-invite-relation {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
touch-action: none;
overscroll-behavior: none;
user-select: none;
.state {
gap: .75rem;
inset: var(--app-nav-height) 0 0;
display: flex;
position: absolute;
align-items: center;
flex-direction: column;
justify-content: center;
}
.info-card {
gap: .25rem;
left: .75rem;
right: .75rem;
top: calc(var(--app-nav-height) + .75rem);
padding: .5rem .75rem;
color: #52607a;
display: flex;
position: absolute;
z-index: 1;
font-size: .75rem;
background: rgba(255, 255, 255, .86);
border-radius: .75rem;
box-shadow: 0 .25rem 1rem rgba(15, 23, 42, .1);
backdrop-filter: blur(8px);
flex-direction: column;
pointer-events: auto;
user-select: text;
touch-action: auto;
.search-input {
padding: .25rem;
color: #1e293b;
background: transparent;
}
.summary {
gap: .5rem;
padding: 0;
color: inherit;
display: flex;
flex-wrap: wrap;
pointer-events: none;
.legend {
gap: .375rem;
display: flex;
flex-wrap: wrap;
.legend-item {
gap: .1875rem;
display: inline-flex;
align-items: center;
}
.color {
width: .5rem;
height: .5rem;
display: inline-block;
border-radius: 50%;
}
}
}
}
}
:global(html.theme-dark) {
.customer-invite-relation {
.info-card {
color: #cbd5e1;
background: rgba(15, 23, 42, .86);
box-shadow: 0 .25rem 1rem rgba(2, 6, 23, .35);
.search-input {
color: #e2e8f0;
}
}
}
}
</style>
@@ -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>
+4 -4
View File
@@ -131,9 +131,9 @@ const menuGroupList = computed<MenuGroup[]>(() => [
visible: userStore.hasPermissions(permissionCodes.customer.read) visible: userStore.hasPermissions(permissionCodes.customer.read)
}, },
{ {
icon: "order-list", icon: "git-merge",
path: "/management/customer-invite-rank", path: "/management/customer-invite-relation",
title: "客户邀请排行", title: "客户邀请关系",
visible: userStore.hasPermissions(permissionCodes.customer.read) visible: userStore.hasPermissions(permissionCodes.customer.read)
}, },
{ {
@@ -234,7 +234,7 @@ const menuGroupList = computed<MenuGroup[]>(() => [
path: "/management/qr-code", path: "/management/qr-code",
title: "生成客户二维码", title: "生成客户二维码",
visible: userStore.hasPermissions(permissionCodes.qrCode.create) visible: userStore.hasPermissions(permissionCodes.qrCode.create)
}, }
] ]
}, },
{ {
+15
View File
@@ -3,6 +3,7 @@ import QuickSignInPage from "@/pages/home/QuickSignInPage.vue";
import EventRecordEdit from "@/pages/event/EventRecordEdit.vue"; import EventRecordEdit from "@/pages/event/EventRecordEdit.vue";
import CustomerList from "@/pages/customer/CustomerList.vue"; import CustomerList from "@/pages/customer/CustomerList.vue";
import CustomerInviteRank from "@/pages/customer/CustomerInviteRank.vue"; import CustomerInviteRank from "@/pages/customer/CustomerInviteRank.vue";
import CustomerInviteRelation from "@/pages/customer/CustomerInviteRelation.vue";
import CustomerDetail from "@/pages/customer/CustomerDetail.vue"; import CustomerDetail from "@/pages/customer/CustomerDetail.vue";
import CustomerInviteeList from "@/pages/customer/CustomerInviteeList.vue"; import CustomerInviteeList from "@/pages/customer/CustomerInviteeList.vue";
import CustomerEdit from "@/pages/customer/CustomerEdit.vue"; import CustomerEdit from "@/pages/customer/CustomerEdit.vue";
@@ -64,6 +65,20 @@ const customerRoutes: RouteRecordRaw[] = [
darkBodyBackground: "#10161f" darkBodyBackground: "#10161f"
} }
}, },
{
path: "/management/customer-invite-relation",
name: "CustomerInviteRelation",
component: CustomerInviteRelation,
meta: {
depth: 3,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "客户邀请关系",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
}
},
{ {
path: "/management/customers/create", path: "/management/customers/create",
name: "CustomerCreate", name: "CustomerCreate",
+1 -1
View File
@@ -200,7 +200,7 @@ export const managementSettingRoutes: RouteRecordRaw[] = [
navBarCanBack: true, navBarCanBack: true,
navBarTitle: "开源软件协议", navBarTitle: "开源软件协议",
tabBarVisible: false, tabBarVisible: false,
bodyBackground: "#F5F5F8", bodyBackground: "#FFF",
darkBodyBackground: "#10161f" darkBodyBackground: "#10161f"
} }
}, },
+3
View File
@@ -13,6 +13,9 @@ export type Customer = {
storeId?: string; storeId?: string;
code?: string; code?: string;
name?: string; name?: string;
namePinyinFull?: string;
namePinyinInitial?: string;
namePinyinMixed?: string;
gender?: CustomerGender; gender?: CustomerGender;
age?: number; age?: number;
birthdate?: string; birthdate?: string;
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ButtonProps } from "tdesign-mobile-vue";
* 顶部导航右侧菜单项。 * 顶部导航右侧菜单项。
*/ */
export type NavBarAction = { export type NavBarAction = {
action: () => void | Promise<void>; action: () => void | Promise<unknown>;
disabled?: boolean; disabled?: boolean;
icon?: string; icon?: string;
key: string; key: string;