【招生小程序】 优化# 团队:线索转化情况分析布局

master
kaeery 2025-03-05 23:10:53 +08:00
parent c8ace90b43
commit 4226411775
20 changed files with 232 additions and 115 deletions

View File

@ -16,6 +16,10 @@ export function apiOrganizationByIdList() {
export function apiOrganizationAllList() {
return request.get({ url: '/organization/getAllChildOrgInfo' })
}
// 岗位列表
export function postLists(params?: any) {
return request.get({ url: '/system/post/list', data: params })
}
// 数据简报
export function apiDataOverview(params: any) {
@ -29,3 +33,11 @@ export function convertProcessApi(params?: any) {
export function clueStatusApi(params?: any) {
return request.get({ url: '/control/clueStatistics', data: params })
}
// 成交客户转化统计
export function convertedSuccessApi(params?: any) {
return request.get({ url: '/control/clueTransactionCustomerStatistics', data: params })
}
// Top5
export function rankListApi(params?: any) {
return request.get({ url: '/control/top', data: params })
}

View File

@ -25,7 +25,7 @@
<template v-if="!loading && data.length > 0">
<TTable :columns="columns" :data="data">
<template #index="scope">
<text class="px-[16rpx] py-[6rpx] rounded-[4px]" :style="rankStyle(scope.row)">
<text class="px-[20rpx] py-[6rpx] rounded-[4px]" :style="rankStyle(scope.row)">
{{ scope.row }}
</text>
</template>

View File

@ -157,7 +157,7 @@ export default defineComponent({
flex-grow: 1;
height: 66rpx;
padding: 0 24rpx;
font-size: 26rpx;
// font-size: 26rpx;
line-height: 66rpx;
color: var(--dropdown-text-color);
text-align: center;
@ -190,7 +190,7 @@ export default defineComponent({
margin-right: 20rpx;
margin-bottom: 20rpx;
overflow: hidden;
font-size: 28rpx;
// font-size: 28rpx;
color: var(--dropdown-text-color);
background-color: #f5f5f5;
border-radius: 999rpx;

View File

@ -119,7 +119,7 @@ export default defineComponent({
height: var(--cell-height);
padding: 0 24rpx;
overflow: hidden;
font-size: 28rpx;
// font-size: 28rpx;
color: var(--dropdown-text-color);
white-space: nowrap;
border-bottom: 1rpx solid #dedede;

View File

@ -0,0 +1,70 @@
<template>
<view class="flex flex-wrap p-[32rpx] gap-[16rpx] text-[28rpx] bg-white">
<view
class="bg-[#F0F7FF] rounded-[8rpx] px-[16rpx] py-[8rpx]"
v-for="(value, key) in filteredForm"
:key="key"
>
<template v-if="key === 'createTimeRange'">
<text class="text-[#1E40AF]">{{ parseLabel(key) }}:</text>
<text class="text-primary">
{{ form.createTimeStart }} - {{ form.createTimeEnd }}
</text>
</template>
<template v-else>
<text class="text-[#1E40AF]">{{ parseLabel(key) }}:</text>
<text class="text-primary">{{ value }}</text>
</template>
</view>
</view>
</template>
<script setup lang="ts">
import { PropType } from 'vue'
import { IForm } from './team/index.vue'
import { computed } from 'vue'
import { AdminTabEnum } from '@/enums'
const formMap: Record<AdminTabEnum, any> = {
[AdminTabEnum.TEAM]: {
organizationId: '组织',
createTimeRange: '日期'
},
[AdminTabEnum.PERSONALLY]: {
postId: '组织',
userId: '用户',
createTimeRange: '日期'
}
}
const props = defineProps({
form: {
type: Object as PropType<IForm>,
default: () => ({})
},
activeTab: {
type: Number as PropType<AdminTabEnum>,
default: AdminTabEnum.TEAM
}
})
const filteredForm = computed(() => {
const { createTimeStart, createTimeEnd, ...rest } = props.form
const activeTabMap = formMap[props.activeTab]
const filtered = Object.keys(rest).reduce((acc, key) => {
if (activeTabMap[key]) {
acc[key] = rest[key]
}
return acc
}, {} as Record<string, any>)
if (createTimeStart && createTimeEnd) {
filtered['createTimeRange'] = 'createTimeRange'
}
return filtered
})
const parseLabel = computed(() => (key: string) => {
const { activeTab } = props
return formMap[activeTab][key]
})
</script>
<style scoped></style>

View File

@ -201,7 +201,7 @@ export default defineComponent({
align-items: center;
justify-content: space-between;
padding: 24rpx;
font-size: 24rpx;
// font-size: 24rpx;
color: var(--dropdown-text-color);
text-align: left;

View File

@ -9,7 +9,7 @@
>
<text>{{ item.label }}</text>
<template v-if="postId == item.id">
<TIcon name="icon-check" color="#0E66FB" />
<TIcon name="icon-check" color="#0E66FB" :size="24" />
</template>
</view>
<dropdownFooter @reset="handleReset" @confirm="handleConfirm" />
@ -35,6 +35,7 @@ const handleCellClick = (item: any) => {
const { id } = item
postId.value = id
emit('update:modelValue', id)
emit('confirm', item)
}
const handleReset = () => {
postId.value = 0

View File

@ -7,7 +7,7 @@
<position-tabs
v-model="postId"
@reset="handleReset('position')"
@confirm="handleConfirm('position', value)"
@confirm="value => handleConfirm('position', value)"
/>
</view>
</u-dropdown-item>
@ -32,7 +32,7 @@
</u-dropdown>
</view>
<view class="flex-1 mt-3 px-[32rpx] overflow-auto bg-[#FAFAFE]">
<z-paging
<!-- <z-paging
ref="paging"
v-model="dataList"
@query="queryList"
@ -42,7 +42,7 @@
<template v-for="(item, index) in dataList" :key="`unique_${index}`">
<telesale-card :item="item" @handleCardClick="handleCardClick" />
</template>
</z-paging>
</z-paging> -->
</view>
</view>
</template>
@ -90,6 +90,7 @@ const handleCardClick = (type: string, item: any) => {
break
}
}
//
const handleConfirm = (type: string, item) => {
switch (type) {
case 'organization':
@ -100,11 +101,14 @@ const handleConfirm = (type: string, item) => {
break
case 'position':
console.log(item)
break
default:
break
}
}
//
const handleReset = (type: string) => {
switch (type) {
case 'organization':

View File

@ -14,11 +14,17 @@
:key="`unique_${index}`"
>
<text class="text-gray5">{{ item.label }}</text>
<u-line-progress
:percentage="item.value"
activeColor="#5783FE"
shape="square"
></u-line-progress>
<view class="flex items-center gap-[12rpx]">
<view class="flex-1">
<u-line-progress
:percentage="item.value"
activeColor="#5783FE"
shape="square"
:showText="false"
></u-line-progress>
</view>
<text>{{ item.value + '%' }}</text>
</view>
</view>
</view>
</template>
@ -56,6 +62,8 @@ const fetchData = async (payload: IForm) => {
value: result[item] || 0
}
})
console.log(data.value)
loading.value = false
} catch (error) {}
}

View File

@ -26,6 +26,7 @@
import { ref } from 'vue'
import card from '../../card.vue'
import { IForm } from '../index.vue'
import { convertedSuccessApi } from '@/api/admin'
interface IConvertedData {
name: string
@ -33,29 +34,24 @@ interface IConvertedData {
}
const data = ref<IConvertedData[]>([])
const loading = ref(false)
const fetchData = (payload: IForm) => {
const fetchData = async (payload: IForm) => {
loading.value = true
setTimeout(() => {
data.value = [
{
name: '湛江团队',
try {
const result = await convertedSuccessApi(payload)
console.log(result)
data.value = result.map(item => {
const { clientCount, clueCount, percentConversion } = item.leadToCustomerStatisticsVo
return {
name: item.organizationName,
children: [
{ label: '线索', value: '52个' },
{ label: '成交客户', value: '52个' },
{ label: '转化率', value: '15%' }
]
},
{
name: '广州团队',
children: [
{ label: '线索', value: '52个' },
{ label: '成交客户', value: '52个' },
{ label: '转化率', value: '15%' }
{ label: '线索', value: clueCount ?? 0 + '个' },
{ label: '成交客户', value: clientCount ?? 0 + '个' },
{ label: '转化率', value: percentConversion }
]
}
]
loading.value = false
}, 300)
})
} catch (error) {}
loading.value = false
}
defineExpose({
fetchData

View File

@ -1,6 +1,6 @@
<template>
<view class="px-[32rpx]">
<card>
<view class="px-[32rpx] mt-[12rpx]">
<card className="pt-2 pb-3">
<u-tabs
:list="tabs"
:scrollable="false"
@ -23,11 +23,11 @@
<u-loading-icon></u-loading-icon>
</view>
</template>
<template v-if="!loading && data.length > 0">
<template v-else-if="!loading && data.length > 0">
<TTable :columns="columns" :data="data">
<template #index="scope">
<text
class="px-[16rpx] py-[6rpx] rounded-[4px]"
class="px-[20rpx] py-[6rpx] rounded-[4px]"
:style="rankStyle(scope.row)"
>
{{ scope.row }}
@ -41,25 +41,26 @@
<text>查看更多</text>
</view>
</template>
<template v-else>
<w-empty />
</template>
</card>
</view>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import card from '../../card.vue'
import { useRank } from '@/hooks/useCommon'
const emit = defineEmits(['refresh'])
const { tabs, activeTab, columns, data, loading, rankStyle, handleChangeTab, fetchData } = useRank({
width: 120
width: 120,
callback: () => {
emit('refresh')
}
})
watch(
() => activeTab.value,
(val: number) => {
console.log(val)
}
)
const handleMore = () => {
uni.navigateTo({
url: '/bundle/pages/rank-list/index'

View File

@ -31,9 +31,10 @@
</u-dropdown-item>
</u-dropdown>
</view>
<filter-value :form="form" :activeTab="activeTab" />
<data-overview ref="dataOverviewRef" />
<converted-overview ref="convertedOverviewRef" />
<rank ref="rankRef" />
<rank ref="rankRef" @refresh="refreshData" />
<clue-status ref="clueStatusRef" />
</view>
</template>
@ -46,8 +47,11 @@ import rank from './components/rank.vue'
import clueStatus from './components/clue-status.vue'
import dateDropdownPicker from '@/components/date-dropdown/daterange.vue'
import selectDropdown from '@/components/select-dropdown/index.vue'
import filterValue from '../filter-value.vue'
import { apiOrganizationByIdList, apiOrganizationList, apiOrganizationTeamList } from '@/api/admin'
import { formateDate, getCurDate } from '@/utils/util'
import { PropType } from 'vue'
import { AdminTabEnum } from '@/enums'
export interface IForm {
organizationId: number | null
@ -55,6 +59,13 @@ export interface IForm {
createTimeEnd: string
userId: string
}
defineProps({
activeTab: {
type: Number as PropType<AdminTabEnum>,
default: AdminTabEnum.TEAM
}
})
const clueStatusRef = ref<InstanceType<typeof clueStatus>>()
const rankRef = ref<InstanceType<typeof rank>>()
const convertedOverviewRef = ref<InstanceType<typeof convertedOverview>>()
@ -131,7 +142,9 @@ const handleReset = (type: string) => {
const closeDropDown = () => {
uDropdownRef.value.close()
}
const refreshData = () => {
rankRef.value?.fetchData(form.value)
}
onMounted(async () => {
await fetchOrganizationList()
fetchAllData()

View File

@ -1,7 +1,7 @@
<template>
<view class="empty-comp text-center text-[#969799]">
<template v-if="type == 'data'">
<image :src="dataImage" mode="aspectFit" class="w-[240px]" />
<image :src="dataImage" mode="aspectFit" class="w-[240px] h-[200px]" />
<view>暂无数据</view>
</template>
<template v-else-if="type == 'location'">

View File

@ -21,3 +21,7 @@ export enum AdminTabEnum {
TEAM = 1,
PERSONALLY = 2
}
export enum PositionEnum {
TELESALE = 5, //电销
RECRUITSALE = 6 //招生
}

View File

@ -1,5 +1,7 @@
import { postLists, rankListApi } from '@/api/admin'
import { apiCluseDetail } from '@/api/clue'
import { IForm } from '@/components/widgets/admin/team/index.vue'
import { PositionEnum } from '@/enums'
import { computed, ref, shallowRef } from 'vue'
// 获取线索详情
@ -32,12 +34,12 @@ interface IRankTab {
id: number
}
// 主账号排行榜
export function useRank({ width }: { width: number }) {
export function useRank({ width, callback }: { width: number; callback?: () => void }) {
const tabs = ref<IRankTab[]>([])
const activeTab = ref()
const loading = ref(false)
const data = ref<IRank[]>([])
const totalLabel = computed(() => (activeTab.value == 5 ? '意向' : '成交'))
const totalLabel = computed(() => (activeTab.value == PositionEnum.TELESALE ? '意向' : '成交'))
const rankStyle = computed(() => row => {
const styleMap: Record<number, string> = {
'1': '#FCAE3C',
@ -52,56 +54,46 @@ export function useRank({ width }: { width: number }) {
const handleChangeTab = item => {
activeTab.value = item.id
generateColumns()
columns.value = generateColumns()
callback && callback()
}
const generateColumns = () => {
return [
{ name: 'index', label: '排名', slot: true },
{ name: 'name', label: '姓名' },
{ name: 'total', label: totalLabel.value + '客户数', width, align: 'right' }
{ name: 'rank', label: '排名', slot: true },
{ name: 'username', label: '姓名' },
{ name: 'clueCount', label: totalLabel.value + '客户数', width, align: 'right' }
]
}
const columns = ref(generateColumns())
const fetchTabs = () => {
return new Promise(resolve => {
tabs.value = [
{ name: '电销业绩排行榜', value: 1, id: 5 },
{ name: '招生业绩排行榜', value: 2, id: 6 }
]
if (tabs.value.length > 0) activeTab.value = tabs.value[0]
resolve(tabs.value)
})
}
const fetchData = async (payload: IForm) => {
await fetchTabs()
console.log(activeTab.value)
loading.value = true
setTimeout(() => {
data.value = [
{
index: 1,
name: '王小虎1789789789789789',
total: 100
},
{
index: 2,
name: '王小虎2',
total: 20
},
{
index: 3,
name: '王小虎2',
total: 20
},
{
index: 4,
name: '王小虎2',
total: 20
const columns = ref()
// 获取岗位列表
const fetchTabs = async () => {
try {
const result = await postLists()
tabs.value = result.lists.map(item => {
const { name } = item
return {
id: item.id,
name: name + '业绩排行榜'
}
]
loading.value = false
}, 500)
})
if (tabs.value.length > 0) activeTab.value = tabs.value[0].id
columns.value = generateColumns()
} catch (error) {}
}
// 获取每一个岗位前5名
const fetchData = async (payload: IForm) => {
if (!tabs.value.length) await fetchTabs()
try {
loading.value = true
const newPayload = {
...payload,
post: activeTab.value
}
const result = await rankListApi(newPayload)
data.value = result ?? []
} catch (error) {}
loading.value = false
}
return {
@ -120,21 +112,17 @@ export function useRank({ width }: { width: number }) {
export function usePositions() {
const positionList = ref()
const postId = ref()
const fetchPositions = () => {
const fetchPositions = async () => {
try {
positionList.value = [
{
label: '电销老师',
id: 5,
value: 5
},
{
label: '招生老师',
id: 6,
value: 6
const result = await postLists()
positionList.value = result.lists.map(item => {
return {
label: item.name,
id: item.id,
value: item.id
}
]
if (positionList.value.length > 0) postId.value = positionList.value
})
if (positionList.value.length > 0) postId.value = positionList.value[0].id
} catch (error) {}
}

View File

@ -60,7 +60,8 @@
{
"path": "pages/admin/my/index",
"style": {
"navigationBarTitleText": ""
"navigationBarTitleText": "",
"navigationStyle": "custom"
},
"auth": true
},

View File

@ -10,8 +10,8 @@
@change="handleChangeTab"
></u-tabs>
<view class="flex-1 bg-gray3">
<admin-team v-if="activeTab === AdminTabEnum.TEAM" />
<personally v-if="activeTab === AdminTabEnum.PERSONALLY" />
<admin-team v-if="activeTab === AdminTabEnum.TEAM" :activeTab="activeTab" />
<personally v-if="activeTab === AdminTabEnum.PERSONALLY" :activeTab="activeTab" />
</view>
</TContainer>
</template>

View File

@ -15,6 +15,7 @@ export default {
percentage: 0,
showText: true,
height: 12,
shape: 'circle'
shape: 'circle',
thresholdValue: 10
}
}

View File

@ -30,6 +30,10 @@ export const props = defineMixin({
shape: {
type: String,
default: () => defProps.lineProgress.shape
},
thresholdValue: {
type: Number,
default: () => defProps.lineProgress.thresholdValue
}
}
})

View File

@ -13,7 +13,11 @@
></view>
<view class="u-line-progress__line" :style="[progressStyle]">
<slot>
<text v-if="showText && percentage >= 10" class="u-line-progress__text">
<text
v-if="showText && percentage >= thresholdValue"
class="u-line-progress__text"
:style="{ right: textGap }"
>
{{ innserPercentage + '%' }}
</text>
</slot>
@ -66,6 +70,14 @@ export default {
innserPercentage() {
// 0-100
return range(0, 100, this.percentage)
},
textGap() {
let value =
typeof this.lineWidth == 'string'
? Math.ceil(parseFloat(this.lineWidth))
: Math.ceil(this.lineWidth)
const newVal = value + 17
return '-' + newVal + 'px'
}
},
mounted() {
@ -128,18 +140,20 @@ export default {
bottom: 0;
align-items: center;
@include flex(row);
color: #ffffff;
// color: #ffffff;
// border-radius: 100px;
transition: width 0.5s ease;
justify-content: flex-end;
}
&__text {
position: absolute;
bottom: 0;
font-size: 10px;
align-items: center;
text-align: right;
color: #ffffff;
margin-right: 5px;
// text-align: right;
// color: #ffffff;
// margin-right: 5px;
transform: scale(0.9);
}
}