add CustomerInviteRelation.vue
This commit is contained in:
@@ -99,22 +99,29 @@
|
||||
</t-form-item>
|
||||
<t-form-item label="介绍人">
|
||||
<div class="referrer">
|
||||
<t-input
|
||||
:value="formData.introducerCustomer?.name"
|
||||
readonly
|
||||
placeholder="选择介绍人"
|
||||
borderless
|
||||
:disabled="isCustomerBound"
|
||||
/>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
<div class="selector">
|
||||
<t-input
|
||||
:value="formData.introducerCustomer?.name"
|
||||
readonly
|
||||
placeholder="选择介绍人"
|
||||
borderless
|
||||
:disabled="isCustomerBound"
|
||||
@click="selectorVisible = true"
|
||||
>选择客户</t-button>
|
||||
/>
|
||||
<div class="actions">
|
||||
<t-button
|
||||
theme="primary"
|
||||
variant="outline"
|
||||
size="extra-small"
|
||||
:disabled="isCustomerBound"
|
||||
@click="selectorVisible = true"
|
||||
>选择客户</t-button>
|
||||
</div>
|
||||
</div>
|
||||
<customer-profile-card
|
||||
v-if="formData.introducerCustomer"
|
||||
class="profile"
|
||||
:customer="formData.introducerCustomer"
|
||||
/>
|
||||
</div>
|
||||
</t-form-item>
|
||||
<t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker">
|
||||
@@ -739,6 +746,12 @@ function buildAttachmentList(): Attachment[] {
|
||||
.referrer {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.selector {
|
||||
gap: .75rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: .5rem;
|
||||
|
||||
@@ -1,49 +1,53 @@
|
||||
<template>
|
||||
<t-pull-down-refresh
|
||||
<div
|
||||
v-can="permissionCodes.customer.read"
|
||||
class="customer-invite-rank"
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="refresh"
|
||||
@scrolltolower="loadRanks"
|
||||
>
|
||||
<t-cell-group v-if="rankedCustomerList.length" class="list" theme="card">
|
||||
<customer-cell
|
||||
v-for="(item, index) in rankedCustomerList"
|
||||
:key="item.id || item.code || index"
|
||||
v-entrance-transition="index * 30"
|
||||
class="item"
|
||||
:class="{ 'in-top3': index < 3 }"
|
||||
:customer="item"
|
||||
arrow
|
||||
hover
|
||||
@click="toCustomerDetail(item)"
|
||||
>
|
||||
<template #note>
|
||||
<div
|
||||
class="rank"
|
||||
:class="{
|
||||
top1: index === 0,
|
||||
top2: index === 1,
|
||||
top3: index === 2
|
||||
}"
|
||||
v-text="String(index + 1)"
|
||||
/>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="description">
|
||||
<t-tag
|
||||
theme="primary"
|
||||
variant="light-outline"
|
||||
:content="getInvitedCountText(item)"
|
||||
<t-pull-down-refresh
|
||||
class="list-wrapper"
|
||||
v-model="isRefreshing"
|
||||
:refresh-timeout="30000"
|
||||
@refresh="refresh"
|
||||
@scrolltolower="loadRanks"
|
||||
>
|
||||
<t-cell-group v-if="rankedCustomerList.length" class="list" theme="card">
|
||||
<customer-cell
|
||||
v-for="(item, index) in rankedCustomerList"
|
||||
:key="item.id || item.code || index"
|
||||
v-entrance-transition="index * 30"
|
||||
class="item"
|
||||
:class="{ 'in-top3': index < 3 }"
|
||||
:customer="item"
|
||||
arrow
|
||||
hover
|
||||
@click="toCustomerDetail(item)"
|
||||
>
|
||||
<template #note>
|
||||
<div
|
||||
class="rank"
|
||||
:class="{
|
||||
top1: index === 0,
|
||||
top2: index === 1,
|
||||
top3: index === 2
|
||||
}"
|
||||
v-text="String(index + 1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</customer-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="center-tips" v-text="emptyText" />
|
||||
<div v-if="rankedCustomerList.length" class="center-tips" v-text="loadStateText" />
|
||||
</t-pull-down-refresh>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="description">
|
||||
<t-tag
|
||||
theme="primary"
|
||||
variant="light-outline"
|
||||
:content="getInvitedCountText(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</customer-cell>
|
||||
</t-cell-group>
|
||||
<div v-else class="center-tips" v-text="emptyText" />
|
||||
<div v-if="rankedCustomerList.length" class="center-tips" v-text="loadStateText" />
|
||||
</t-pull-down-refresh>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -84,9 +88,6 @@ const isNoMore = ref(false);
|
||||
let loadedCustomerCount = 0;
|
||||
|
||||
const rankedCustomerList = computed(() => pageResult.list);
|
||||
const summaryText = computed(() => isNoMore.value
|
||||
? `${rankedCustomerList.value.length} 位客户有邀请记录`
|
||||
: `已加载 ${rankedCustomerList.value.length} 位客户`);
|
||||
const emptyText = computed(() => isLoading.value ? "正在加载客户邀请排行" : "暂无客户邀请记录");
|
||||
const loadStateText = computed(() => {
|
||||
if (isLoading.value) {
|
||||
@@ -195,43 +196,40 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
.list-wrapper {
|
||||
.list {
|
||||
|
||||
.list {
|
||||
.item {
|
||||
.rank {
|
||||
flex: 0 0 1.5rem;
|
||||
width: 1.5rem;
|
||||
color: #52607a;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
font-size: .76rem;
|
||||
background: #f0f0f0;
|
||||
font-weight: 700;
|
||||
align-items: center;
|
||||
border-radius: .5rem;
|
||||
justify-content: center;
|
||||
|
||||
.item {
|
||||
.rank {
|
||||
flex: 0 0 1.5rem;
|
||||
width: 1.5rem;
|
||||
color: #52607a;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
font-size: .76rem;
|
||||
background: #f0f0f0;
|
||||
font-weight: 700;
|
||||
align-items: center;
|
||||
border-radius: .5rem;
|
||||
justify-content: center;
|
||||
|
||||
&.top1 {
|
||||
color: #5c3a00;
|
||||
background: #ffd700;
|
||||
}
|
||||
|
||||
&.top2 {
|
||||
color: #374151;
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
&.top3 {
|
||||
color: #fff8f0;
|
||||
background: #cd7f32;
|
||||
}
|
||||
&.top1 {
|
||||
color: #5c3a00;
|
||||
background: #ffd700;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: .5rem;
|
||||
&.top2 {
|
||||
color: #374151;
|
||||
background: #c0c0c0;
|
||||
}
|
||||
|
||||
&.top3 {
|
||||
color: #fff8f0;
|
||||
background: #cd7f32;
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -131,9 +131,9 @@ const menuGroupList = computed<MenuGroup[]>(() => [
|
||||
visible: userStore.hasPermissions(permissionCodes.customer.read)
|
||||
},
|
||||
{
|
||||
icon: "order-list",
|
||||
path: "/management/customer-invite-rank",
|
||||
title: "客户邀请排行",
|
||||
icon: "git-merge",
|
||||
path: "/management/customer-invite-relation",
|
||||
title: "客户邀请关系",
|
||||
visible: userStore.hasPermissions(permissionCodes.customer.read)
|
||||
},
|
||||
{
|
||||
@@ -234,7 +234,7 @@ const menuGroupList = computed<MenuGroup[]>(() => [
|
||||
path: "/management/qr-code",
|
||||
title: "生成客户二维码",
|
||||
visible: userStore.hasPermissions(permissionCodes.qrCode.create)
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user