【招生用户端】 新增# 工作台:对接数据简报、线索转化情况统计接口

main
kaeery 2025-03-05 19:15:57 +08:00
parent a8cc9b437b
commit c57cc282fb
7 changed files with 405 additions and 35 deletions

View File

@ -0,0 +1,26 @@
import request from '@/utils/request'
// 数据简报
export function dataOverviewApi(params?: any) {
return request.get({ url: '/control/dataPresentation', params })
}
// 线索转客户统计
export function convertProcessApi(params?: any) {
return request.get({ url: '/control/leadToCustomerStatistics', params })
}
// 线索转化情况统计
export function clueStatusApi(params?: any) {
return request.get({ url: '/control/clueStatistics', params })
}
// 获取所有组织以及人员信息
export function allUserListApi(params?: any) {
return request.get({ url: '/organization/getAllChildOrgInfo', params })
}
// 获取所有团队
export function allTeamListApi(params?: any) {
return request.get({ url: '/organization/getAllGroup', params })
}
// 获取所有组织
export function allOrgListApi(params?: any) {
return request.get({ url: '/organization/getAllOrg', params })
}

View File

@ -1,5 +1,5 @@
import { isObject } from '@vue/shared' import { isObject } from '@vue/shared'
import { ElMessage, type messageType } from 'element-plus' import { ElMessage, dayjs, type messageType } from 'element-plus'
import { cloneDeep } from 'lodash' import { cloneDeep } from 'lodash'
import { isArray } from './is' import { isArray } from './is'
import type { FieldNamesProps } from '@/components/ProTable/interface' import type { FieldNamesProps } from '@/components/ProTable/interface'
@ -326,3 +326,74 @@ export function findItemNested(enumData: any, callValue: any, value: string, chi
if (current[children]) return findItemNested(current[children], callValue, value, children) if (current[children]) return findItemNested(current[children], callValue, value, children)
}, null) }, null)
} }
export const shortcuts = [
{
text: '今天',
value: () => {
const start = new Date()
const end = new Date()
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
return [start, end]
}
},
{
text: '一周后',
value: () => {
const start = new Date()
const end = new Date()
end.setDate(end.getDate() + 7)
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
return [start, end]
}
},
{
text: '一个月后',
value: () => {
const start = new Date()
const end = new Date()
end.setMonth(end.getMonth() + 1)
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
return [start, end]
}
},
{
text: '三个月后',
value: () => {
const start = new Date()
const end = new Date()
end.setMonth(end.getMonth() + 3)
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
return [start, end]
}
},
{
text: '半年后',
value: () => {
const start = new Date()
const end = new Date()
end.setMonth(end.getMonth() + 6)
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
return [start, end]
}
},
{
text: '一年后',
value: () => {
const start = new Date()
const end = new Date()
end.setFullYear(end.getFullYear() + 1)
start.setHours(0, 0, 0, 0)
end.setHours(23, 59, 59, 999)
return [start, end]
}
}
]
// 今天
export const getCurDate = (type: string, format = 'YYYY-MM-DD HH:mm:ss') => {
return type == 'start' ? dayjs().startOf('day').format(format) : dayjs().endOf('day').format(format)
}

View File

@ -1,24 +1,19 @@
<template> <template>
<div class="flex-1 w-full"> <div class="flex-1 w-full">
<card title="线索转化情况统计"> <card title="线索转化情况统计">
<v-charts style="height: 350px" :option="option" :autoresize="true" /> <v-charts ref="chartRef" v-loading="loading" style="height: 350px" :option="option" :autoresize="true" />
</card> </card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { clueStatusApi } from '@/api/workbench'
import card from './card.vue' import card from './card.vue'
import vCharts from 'vue-echarts' import vCharts from 'vue-echarts'
import type { IForm } from '../index.vue'
const data = [ const chartRef = ref()
{ value: 1048, name: '有意向' }, const data = ref<any[]>([])
{ value: 735, name: '待领取' },
{ value: 580, name: '转化中' },
{ value: 484, name: '已添加' },
{ value: 300, name: '异常待处理' },
{ value: 300, name: '已成交' },
{ value: 300, name: '已战败' }
]
const option = ref({ const option = ref({
color: ['#73DDFF', '#73ACFF', '#FDD56A', '#FDB36A', '#FD866A', '#9E87FF', '#58D5FF'], color: ['#73DDFF', '#73ACFF', '#FDD56A', '#FDB36A', '#FD866A', '#9E87FF', '#58D5FF'],
tooltip: { tooltip: {
@ -47,5 +42,45 @@ const option = ref({
} }
] ]
}) })
const loading = ref(false)
const dataMap: Record<string, string> = {
unclaimedCount: '待领取',
conversionCount: '转化中',
addedCount: '已添加',
abnormalCount: '异常待处理',
tradedCount: '已成交',
failCount: '已战败'
}
const fetchData = async (payload: IForm) => {
try {
loading.value = true
const result = await clueStatusApi(payload)
data.value = Object.keys(dataMap).map(item => {
return {
name: dataMap[item],
value: result[item] || 0
}
})
// 0
const allZero = data.value.every(item => item.value === 0)
if (allZero) {
chartRef.value.setOption({
title: {
text: '暂无数据',
x: 'center',
y: 'center'
},
legend: {
show: false
},
series: []
})
}
} catch (error) {}
loading.value = false
}
defineExpose({
fetchData
})
</script> </script>
<style scoped></style> <style scoped></style>

View File

@ -1,18 +1,16 @@
<template> <template>
<div class="flex-1 w-full chart-card"> <div class="flex-1 w-full chart-card">
<card title="线索转客户统计"> <card title="线索转客户统计">
<v-charts style="height: 350px" :autoresize="true" :option="option" /> <v-charts ref="chartRef" v-loading="loading" style="height: 350px" :autoresize="true" :option="option" />
</card> </card>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { convertProcessApi } from '@/api/workbench'
import card from './card.vue' import card from './card.vue'
import vCharts from 'vue-echarts' import vCharts from 'vue-echarts'
import type { IForm } from '../index.vue'
const data = [
{ name: '湛江团队', clueNumber: 52, client: 52, rate: 100 },
{ name: '广州团队', clueNumber: 8, client: 6, rate: 15 }
]
function createBarSeries(data, name, field) { function createBarSeries(data, name, field) {
return { return {
name, name,
@ -27,18 +25,21 @@ function createLineSeries(data, name) {
type: 'line', type: 'line',
tooltip: { tooltip: {
valueFormatter: function (value) { valueFormatter: function (value) {
return (value as number) + '%' return value as number
} }
}, },
data: data.map(item => item.client), data: data.map(item => item.client),
yAxisIndex: 1 yAxisIndex: 1
} }
} }
const xAxisData = () => data.map(item => item.name) const loading = ref(false)
const data = ref([])
const xAxisData = ref([])
const series = [ const series = [
createBarSeries(data, '线索数', 'clueNumber'), createBarSeries(data.value, '线索数', 'clueNumber'),
createBarSeries(data, '线索转客户数', 'client'), createBarSeries(data.value, '线索转客户数', 'client'),
createLineSeries(data, '线索转客户率') createLineSeries(data.value, '线索转客户率')
] ]
const option = ref({ const option = ref({
@ -57,7 +58,7 @@ const option = ref({
xAxis: [ xAxis: [
{ {
type: 'category', type: 'category',
data: xAxisData() data: xAxisData.value
} }
], ],
yAxis: [ yAxis: [
@ -70,12 +71,60 @@ const option = ref({
{ {
type: 'value', type: 'value',
axisLabel: { axisLabel: {
formatter: '{value} %' formatter: '{value}'
} }
} }
], ],
series series
}) })
const chartRef = ref()
const fetchData = async (payload: IForm) => {
loading.value = true
try {
const result = await convertProcessApi(payload)
if (result.length > 0) {
data.value = result.map((item: any) => {
const { clueCount, transactionClient, percentConversion } = item.leadToCustomerStatisticsVo
return {
name: item.organizationName,
clueNumber: clueCount,
client: transactionClient,
rate: percentConversion
}
})
xAxisData.value = result.map((item: any) => item.organizationName)
if (series[2].data.length === 0) {
chartRef.value.setOption({
title: {
text: '暂无数据',
x: 'center',
y: 'center'
},
xAxis: [
{
type: 'category',
data: [],
splitLine: {
show: false
},
axisLine: {
show: false
}
}
],
legend: {
show: false
}
})
}
}
} catch (error) {}
loading.value = false
}
defineExpose({
fetchData
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@media (max-width: 1000px) { @media (max-width: 1000px) {

View File

@ -1,6 +1,6 @@
<template> <template>
<card title="数据简报"> <card title="数据简报">
<div class="data-overview"> <div class="data-overview" v-loading="loading">
<div <div
class="flex-1 bg-[#F7F8FA] flex flex-col gap-[12px] p-[20px] rounded-[8px]" class="flex-1 bg-[#F7F8FA] flex flex-col gap-[12px] p-[20px] rounded-[8px]"
v-for="(item, index) in dataOverview" v-for="(item, index) in dataOverview"
@ -15,19 +15,41 @@
<script setup lang="ts"> <script setup lang="ts">
import card from './card.vue' import card from './card.vue'
const dataOverview = ref([ import type { IForm } from '../index.vue'
{ label: '新增跟进记录(个)', value: 60 }, import { dataOverviewApi } from '@/api/workbench'
{ label: '新增客户(个)', value: 60 },
{ label: '成交客户(个)', value: 60 }, const loading = ref(false)
{ label: '转化中客户(个)', value: 60 }, const dataOverview = ref<any[]>([])
{ label: '异常待处理(个)', value: 60 }, const dataMap: Record<string, string> = {
{ label: '战败客户(个)', value: 60 } followUpRecord: '新增跟进记录(个)',
]) newCustomer: '新增客户(个)',
transactionClient: '成交客户(个)',
convertingClient: '转化中客户(个)',
exceptionPending: '异常待处理(个)',
defeatedClient: '战败客户(个)'
}
const fetchData = async (payload: IForm) => {
loading.value = true
try {
const result = await dataOverviewApi(payload)
dataOverview.value = Object.keys(dataMap).map(key => {
return {
label: dataMap[key],
value: result[key] || 0
}
})
} catch (error) {}
loading.value = false
}
defineExpose({
fetchData
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.data-overview { .data-overview {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px; gap: 16px;
min-height: 100px;
} }
</style> </style>

View File

@ -0,0 +1,129 @@
<template>
<el-card class="!border-none" shadow="never">
<div class="flex gap-[30px]">
<div class="flex gap-[30px]">
<el-tree-select
v-model="queryParams.organizationId"
:props="defaultProps"
default-expand-all
:data="organizationList"
:render-after-expand="false"
/>
<div>
<daterange-picker
type="datetimerange"
format="YYYY-MM-DD HH:mm:ss"
isDisabledDate
isDisabledHours
:shortcuts="shortcuts"
v-model:startTime="queryParams.createTimeStart"
v-model:endTime="queryParams.createTimeEnd"
/>
</div>
<el-cascader v-model="selectedUserId" :props="CascaderProps" :options="userList" @change="handleCascaderChange" />
</div>
<div>
<el-button type="primary" @click="resetPage"></el-button>
<el-button @click="resetParams"></el-button>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { organzationLists } from '@/api/account_center/organization'
import { shortcuts } from '@/utils/util'
import type { PropType } from 'vue'
import type { IForm } from '../index.vue'
import { allUserListApi } from '@/api/workbench'
const defaultProps = {
label: 'name',
value: 'id'
}
const CascaderProps = {
label: 'name',
value: 'id'
}
const props = defineProps({
queryParams: {
type: Object as PropType<IForm>,
default: () => ({
organizationId: '',
createTimeStart: '',
createTimeEnd: '',
userId: []
})
}
})
const emit = defineEmits(['update:queryParams', 'resetParams', 'resetPage', 'fectAllData'])
const localValue = computed({
get() {
return props.queryParams
},
set(newValue) {
emit('update:queryParams', newValue)
}
})
const selectedUserId = ref([])
const resetParams = () => {
selectedUserId.value = []
emit('resetParams')
}
const resetPage = () => {
emit('resetPage')
}
const organizationList = ref([])
const userList = ref<any[]>([])
const fetchOrganizationList = async () => {
try {
const result = await organzationLists()
organizationList.value = result ?? []
if (result.length > 0) {
localValue.value = {
...localValue.value,
organizationId: result[0].id
}
emit('fectAllData', result[0].id)
}
} catch (error) {}
}
const fetchAllUserList = async () => {
try {
const result = await allUserListApi()
const renamedData = renameFields(result)
userList.value = renamedData
} catch (error) {}
}
const renameFields = (data: any[]): any[] => {
return data.map(item => {
const newItem = { ...item }
if (newItem.organizationVoList) {
newItem.children = renameFields(newItem.organizationVoList)
delete newItem.organizationVoList
}
if (newItem.userVos) {
newItem.userVos.forEach(item => {
item.name = item.username
})
newItem.children = newItem.children ? [...newItem.children, ...newItem.userVos] : newItem.userVos
delete newItem.userVos
}
if (!newItem.children.length) {
newItem.disabled = true
}
return newItem
})
}
const handleCascaderChange = value => {
const lastVal = value[value.length - 1]
localValue.value = {
...localValue.value,
userId: lastVal.toString()
}
}
fetchOrganizationList()
fetchAllUserList()
</script>
<style scoped></style>

View File

@ -1,13 +1,14 @@
<template> <template>
<div class="workbench flex flex-col gap-[16px]"> <div class="workbench flex flex-col gap-[16px]">
<data-overview /> <search-form v-model:queryParams="form" @reset-page="resetPage" @reset-params="resetParams" @fect-all-data="fetchAllData" />
<data-overview ref="dataOverviewRef" />
<div class="flex gap-[16px] flex-wrap"> <div class="flex gap-[16px] flex-wrap">
<conversion-process-chart /> <conversion-process-chart ref="coversionProcessChartRef" />
<converted-chart /> <converted-chart />
</div> </div>
<div class="flex gap-[16px] flex-wrap"> <div class="flex gap-[16px] flex-wrap">
<rank /> <rank />
<clue-status-pie /> <clue-status-pie ref="clueStatusPieRef" />
<!-- <converted-line-chart /> --> <!-- <converted-line-chart /> -->
</div> </div>
</div> </div>
@ -20,6 +21,43 @@ import convertedChart from './components/converted-chart.vue'
import rank from './components/rank.vue' import rank from './components/rank.vue'
import clueStatusPie from './components/clue-status-pie.vue' import clueStatusPie from './components/clue-status-pie.vue'
// import convertedLineChart from './components/converted-line-chart.vue' // import convertedLineChart from './components/converted-line-chart.vue'
import searchForm from './components/search-form.vue'
import { getCurDate } from '@/utils/util'
export interface IForm {
organizationId: number | null
createTimeStart: string
createTimeEnd: string
userId: string
}
const initialVal = ref()
const form = ref<IForm>({
organizationId: null,
createTimeStart: getCurDate('start'),
createTimeEnd: getCurDate('end'),
userId: ''
})
const clueStatusPieRef = ref<InstanceType<typeof clueStatusPie>>()
const coversionProcessChartRef = ref<InstanceType<typeof conversionProcessChart>>()
const dataOverviewRef = ref<InstanceType<typeof dataOverview>>()
const resetPage = () => {
fetchAllData(initialVal.value)
}
const resetParams = () => {
form.value = {
organizationId: initialVal.value,
createTimeStart: getCurDate('start').toString(),
createTimeEnd: getCurDate('end').toString(),
userId: ''
}
fetchAllData(initialVal.value)
}
const fetchAllData = (defaultValue?: number) => {
initialVal.value = defaultValue ?? null
dataOverviewRef.value?.fetchData(form.value)
coversionProcessChartRef.value?.fetchData(form.value)
clueStatusPieRef.value?.fetchData(form.value)
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>