add CustomerInviteRelation.vue
This commit is contained in:
@@ -99,22 +99,29 @@
|
|||||||
</t-form-item>
|
</t-form-item>
|
||||||
<t-form-item label="介绍人">
|
<t-form-item label="介绍人">
|
||||||
<div class="referrer">
|
<div class="referrer">
|
||||||
<t-input
|
<div class="selector">
|
||||||
:value="formData.introducerCustomer?.name"
|
<t-input
|
||||||
readonly
|
:value="formData.introducerCustomer?.name"
|
||||||
placeholder="选择介绍人"
|
readonly
|
||||||
borderless
|
placeholder="选择介绍人"
|
||||||
:disabled="isCustomerBound"
|
borderless
|
||||||
/>
|
|
||||||
<div class="actions">
|
|
||||||
<t-button
|
|
||||||
theme="primary"
|
|
||||||
variant="outline"
|
|
||||||
size="extra-small"
|
|
||||||
:disabled="isCustomerBound"
|
: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>
|
</div>
|
||||||
|
<customer-profile-card
|
||||||
|
v-if="formData.introducerCustomer"
|
||||||
|
class="profile"
|
||||||
|
:customer="formData.introducerCustomer"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
<t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker">
|
<t-form-item label="调理部位" :arrow="!isCustomerBound" @click="openBodyAreaPicker">
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,49 +1,53 @@
|
|||||||
<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"
|
||||||
v-model="isRefreshing"
|
|
||||||
:refresh-timeout="30000"
|
|
||||||
@refresh="refresh"
|
|
||||||
@scrolltolower="loadRanks"
|
|
||||||
>
|
>
|
||||||
<t-cell-group v-if="rankedCustomerList.length" class="list" theme="card">
|
<t-pull-down-refresh
|
||||||
<customer-cell
|
class="list-wrapper"
|
||||||
v-for="(item, index) in rankedCustomerList"
|
v-model="isRefreshing"
|
||||||
:key="item.id || item.code || index"
|
:refresh-timeout="30000"
|
||||||
v-entrance-transition="index * 30"
|
@refresh="refresh"
|
||||||
class="item"
|
@scrolltolower="loadRanks"
|
||||||
:class="{ 'in-top3': index < 3 }"
|
>
|
||||||
:customer="item"
|
<t-cell-group v-if="rankedCustomerList.length" class="list" theme="card">
|
||||||
arrow
|
<customer-cell
|
||||||
hover
|
v-for="(item, index) in rankedCustomerList"
|
||||||
@click="toCustomerDetail(item)"
|
:key="item.id || item.code || index"
|
||||||
>
|
v-entrance-transition="index * 30"
|
||||||
<template #note>
|
class="item"
|
||||||
<div
|
:class="{ 'in-top3': index < 3 }"
|
||||||
class="rank"
|
:customer="item"
|
||||||
:class="{
|
arrow
|
||||||
top1: index === 0,
|
hover
|
||||||
top2: index === 1,
|
@click="toCustomerDetail(item)"
|
||||||
top3: index === 2
|
>
|
||||||
}"
|
<template #note>
|
||||||
v-text="String(index + 1)"
|
<div
|
||||||
/>
|
class="rank"
|
||||||
</template>
|
:class="{
|
||||||
<template #description>
|
top1: index === 0,
|
||||||
<div class="description">
|
top2: index === 1,
|
||||||
<t-tag
|
top3: index === 2
|
||||||
theme="primary"
|
}"
|
||||||
variant="light-outline"
|
v-text="String(index + 1)"
|
||||||
:content="getInvitedCountText(item)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</template>
|
||||||
</template>
|
<template #description>
|
||||||
</customer-cell>
|
<div class="description">
|
||||||
</t-cell-group>
|
<t-tag
|
||||||
<div v-else class="center-tips" v-text="emptyText" />
|
theme="primary"
|
||||||
<div v-if="rankedCustomerList.length" class="center-tips" v-text="loadStateText" />
|
variant="light-outline"
|
||||||
</t-pull-down-refresh>
|
: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>
|
</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,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 {
|
&.top1 {
|
||||||
.rank {
|
color: #5c3a00;
|
||||||
flex: 0 0 1.5rem;
|
background: #ffd700;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
&.top2 {
|
||||||
margin-top: .5rem;
|
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)
|
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)
|
||||||
},
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user