Commit 6a3f72ad authored by 赵乐's avatar 赵乐

首页

parent 8628b9ca
import request from '@/config/axios'
// 客户信息 API
export const homesApi = {
// 查询卡片信息
getHomeInfoFirst: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoFirst`, params })
},
// 竖柱状图数据
getHomeInfoBfztj: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoBfztj`, params })
},
// 折线图数据
getHomeInfoBfrtj: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoBfrtj`, params })
},
// 客户性质等级占比情况
getHomeInfoKhxzdjzbqk: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoKhxzdjzbqk`, params })
},
// 拜访人均分布情况
getHomeInfoBfrjfbqk: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoBfrjfbqk`, params })
},
// 客户拜访方式占比情况
getHomeInfoKhbffszbqk: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoKhbffszbqk`, params })
},
// 客户部门占比情况
getHomeInfoKhbmzbqk: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoKhbmzbqk`, params })
},
// 客户拜访类型占比情况
getHomeInfoKhbflxzbqk: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoKhbflxzbqk`, params })
},
// 拜访产品类型占比情况
getHomeInfoBfcplxzbqk: async (params: any) => {
return await request.get({ url: `/visit/home/getHomeInfoBfcplxzbqk`, params })
},
}
This diff is collapsed.
<template>
<div class="year-range-picker">
<el-input
v-model="displayValue"
readonly
clearable
@click="toggleYearPanel"
@clear="clearRange"
placeholder="选择年份范围"
class="year-input"
>
<template #suffix>
<i class="el-icon-arrow-down" :class="{ 'is-reverse': panelVisible }"></i>
</template>
</el-input>
<transition name="fade">
<div class="year-panel" v-show="panelVisible">
<div class="panel-header">
<div class="year-type">
<el-radio-group v-model="selectedType">
<el-radio-button label="start">开始年份</el-radio-button>
<el-radio-button label="end">结束年份</el-radio-button>
</el-radio-group>
</div>
<div class="panel-actions">
<el-button size="mini" @click="closePanel">取消</el-button>
<el-button size="mini" type="primary" @click="confirmRange" :disabled="!isValidRange"
>确定</el-button
>
</div>
</div>
<div class="year-grid">
<div
v-for="year in yearList"
:key="year"
:class="{
'year-item': true,
'is-selected': isSelectedYear(year),
'is-disabled': isDisabledYear(year)
}"
@click="selectYear(year)"
>
{{ year }}年
</div>
</div>
<div class="quick-options">
<el-button size="small" @click="selectLastYear">近1年</el-button>
<el-button size="small" @click="selectLast3Years">近3年</el-button>
<el-button size="small" @click="selectLast5Years">近5年</el-button>
</div>
</div>
</transition>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch, defineProps, defineEmits, nextTick } from 'vue'
import { ElInput, ElRadioGroup, ElRadioButton, ElButton } from 'element-plus'
const props = defineProps<{
modelValue: [string, string] | []
showQuickOptions: boolean
yearRange: [number, number] // 可选年份范围 [开始年份, 结束年份]
}>()
const emits = defineEmits<{
(event: 'update:modelValue', value: [string, string]): void
(event: 'change', value: [string, string]): void
}>()
// 内部状态
const panelVisible = ref(false)
const selectedType = ref('start') // 当前选择的是开始还是结束年份
const startYear = ref<string | null>(props.modelValue[0] || null)
const endYear = ref<string | null>(props.modelValue[1] || null)
// 生成年份列表(默认可选范围:当前年份前后10年)
const yearRange = computed(() => {
const [min, max] = props.yearRange || []
const currentYear = new Date().getFullYear()
const defaultMin = currentYear - 10
const defaultMax = currentYear + 10
return [min || defaultMin, max || defaultMax]
})
const yearList = computed(() => {
const [min, max] = yearRange.value
return Array.from({ length: max - min + 1 }, (_, i) => min + i)
})
// 显示值
const displayValue = computed(() => {
if (startYear.value && endYear.value) {
return `${startYear.value}年 - ${endYear.value}年`
} else if (startYear.value) {
return `${startYear.value}年 - 未选择结束年份`
} else if (endYear.value) {
return `未选择开始年份 - ${endYear.value}年`
}
return ''
})
// 判断年份是否已选择
const isSelectedYear = (year: number) => {
if (selectedType.value === 'start') {
return year.toString() === startYear.value
} else {
return year.toString() === endYear.value
}
}
// 判断年份是否禁用
const isDisabledYear = (year: number) => {
if (selectedType.value === 'start' && endYear.value) {
return year > parseInt(endYear.value)
} else if (selectedType.value === 'end' && startYear.value) {
return year < parseInt(startYear.value)
}
return false
}
// 判断范围是否有效
const isValidRange = computed(() => {
return !!(startYear.value && endYear.value && startYear.value <= endYear.value)
})
// 监听外部值变化
watch(
() => props.modelValue,
(newVal) => {
startYear.value = newVal[0] || null
endYear.value = newVal[1] || null
}
)
// 监听内部值变化,同步到外部
watch([startYear, endYear], ([newStart, newEnd]) => {
if (newStart && newEnd && newStart <= newEnd) {
emits('update:modelValue', [newStart, newEnd])
}
})
// 切换年份面板显示
const toggleYearPanel = () => {
panelVisible.value = !panelVisible.value
}
// 关闭面板
const closePanel = () => {
panelVisible.value = false
}
// 确认选择
const confirmRange = () => {
if (isValidRange.value) {
emits('update:modelValue', [startYear.value!, endYear.value!])
emits('change', [startYear.value!, endYear.value!])
panelVisible.value = false
}
}
// 选择年份
const selectYear = (year: number) => {
if (selectedType.value === 'start') {
startYear.value = year.toString()
// 如果开始年份大于结束年份,清空结束年份
if (endYear.value && startYear.value > endYear.value) {
endYear.value = null
}
} else {
endYear.value = year.toString()
}
}
// 清空选择
const clearRange = () => {
startYear.value = null
endYear.value = null
emits('update:modelValue', [])
}
// 快捷选择:近1年
const selectLastYear = () => {
const currentYear = new Date().getFullYear().toString()
const lastYear = (new Date().getFullYear() - 1).toString()
startYear.value = lastYear
endYear.value = currentYear
confirmRange()
}
// 快捷选择:近3年
const selectLast3Years = () => {
const currentYear = new Date().getFullYear().toString()
const threeYearsAgo = (new Date().getFullYear() - 3).toString()
startYear.value = threeYearsAgo
endYear.value = currentYear
confirmRange()
}
// 快捷选择:近5年
const selectLast5Years = () => {
const currentYear = new Date().getFullYear().toString()
const fiveYearsAgo = (new Date().getFullYear() - 5).toString()
startYear.value = fiveYearsAgo
endYear.value = currentYear
confirmRange()
}
// 点击外部关闭面板
const clickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
const pickerEl = document.querySelector('.year-range-picker') as HTMLElement
if (pickerEl && !pickerEl.contains(target)) {
panelVisible.value = false
}
}
// 添加点击外部事件监听
onMounted(() => {
document.addEventListener('click', clickOutside)
})
// 移除事件监听
onUnmounted(() => {
document.removeEventListener('click', clickOutside)
})
</script>
<style scoped lang="less">
.year-range-picker {
position: relative;
.year-input {
cursor: pointer;
}
.year-panel {
position: absolute;
top: 40px;
left: 0;
width: 300px;
background: #000;
border: 1px solid #dcdcdc;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 10px;
.panel-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
.year-type {
.el-radio-button__inner {
padding: 6px 15px;
}
}
.panel-actions {
display: flex;
gap: 10px;
}
}
.year-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 15px;
.year-item {
padding: 8px;
text-align: center;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover:not(.is-disabled) {
background-color: #f5f7fa;
}
&.is-selected {
background-color: #409eff;
color: #fff;
}
&.is-disabled {
color: #c0c4cc;
cursor: not-allowed;
}
}
}
.quick-options {
display: flex;
gap: 10px;
margin-top: 10px;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
}
</style>
\ No newline at end of file
<template>
<div class="stats-container">
<div class="stat-card">
<div class="stat-item single">
<span class="stat-number">{{ props.homeInfoFirst?.bfkhsl || 0 }}</span>
<span class="stat-label">拜访客户数量</span>
</div>
</div>
<div class="stat-card">
<div class="stat-item">
<span class="stat-number">{{ props.homeInfoFirst?.sykhsl || 0 }}</span>
<span class="stat-label">商业客户</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ props.homeInfoFirst?.ylkhsl || 0 }}</span>
<span class="stat-label">医疗客户数量</span>
</div>
</div>
<div class="stat-card">
<div class="stat-item single">
<span class="stat-number">{{ props.homeInfoFirst?.visitCount || 0 }}</span>
<span class="stat-label">客户拜访总次数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-item">
<span class="stat-number">{{ props.homeInfoFirst?.sykfcs || 0 }}</span>
<span class="stat-label">商业拜访次数</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ props.homeInfoFirst?.ylkfcs || 0 }}</span>
<span class="stat-label">日常拜访次数</span>
</div>
</div>
<div class="stat-card">
<div class="stat-item single">
<span class="stat-number">{{ props.homeInfoFirst?.ywysl || 0 }}</span>
<span class="stat-label">业务员数量</span>
</div>
</div>
<!-- <div class="stat-card">
<div class="stat-item single">
<span class="stat-number">{{ statsData.card2.number }}</span>
<span class="stat-label">{{ statsData.card2.label }}</span>
</div>
</div> -->
</div>
</template>
<script setup lang="ts">
const props = defineProps({
homeInfoFirst: {
type: [Object, String],
// eslint-disable-next-line vue/require-valid-default-prop
default: {}
}
})
onMounted(() => {})
</script>
<style scoped lang="css">
.stats-container {
display: flex;
justify-content: space-around;
padding: 20px 100px;
gap: 20px; /* 卡片之间的间距 */
flex-wrap: wrap; /* 适配小屏幕,自动换行 */
}
.stat-card {
border: 1px solid #ccc;
border-radius: 4px;
padding: 16px;
min-width: 200px;
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
.stat-item {
text-align: center;
margin: 0 10px;
}
.stat-item.single {
margin: 0;
}
.stat-number {
font-size: 24px;
font-weight: bold;
display: block;
color: #000;
}
.stat-label {
font-size: 14px;
color: #666;
}
</style>
\ No newline at end of file
<template>
<div class="bar-chart">
<h3 class="chart-title">{{ title }}</h3>
<chartsCard
:option="chartOption"
:width="width"
:height="height"
@chart-ready="handleChartReady"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import chartsCard from './chartsCard.vue'
const props = defineProps({
title: {
type: String,
default: '柱状图'
},
xData: {
type: Array,
required: true // X轴数据(通常是类别)
},
yData: {
type: Array,
required: true // Y轴数据(数值)
},
seriesName: {
type: String,
default: '数据' // 系列名称
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
},
color: {
type: String,
default: '#5793f3' // 柱子颜色
},
barWidth: {
type: [String, Number],
default: '40%' // 柱子宽度
}
})
const chartOption = computed(() => ({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#ddd',
borderWidth: 1,
textStyle: { color: '#333' },
formatter: (params: any) => {
const p = params[0]
return `${p.name}<br/>${p.seriesName}: ${p.value}`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: props.xData,
axisTick: {
show: false
},
axisLine: {
lineStyle: {
color: '#999'
}
},
axisLabel: {
color: '#666',
interval: 0, // 强制显示所有标签
rotate: 45 // 标签旋转角度
}
},
yAxis: {
type: 'value',
axisTick: {
show: false
},
axisLine: {
show: false
},
axisLabel: {
color: '#666'
},
splitLine: {
lineStyle: {
color: '#eee'
}
}
},
series: [
{
name: props.seriesName,
type: 'bar',
data: props.yData,
barWidth: props.barWidth,
itemStyle: {
color: props.color,
borderRadius: [4, 4, 0, 0] // 柱子圆角
},
emphasis: {
itemStyle: {
color: `${props.color}DD` // 高亮颜色
}
}
}
]
}))
const handleChartReady = (chart: any) => {
// 可添加交互逻辑
}
</script>
<style scoped lang="less">
.bar-chart {
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
}
</style>
\ No newline at end of file
<template>
<div class="line-chart">
<h3 class="chart-title">{{ title }}</h3>
<chartsCard
:option="chartOption"
:width="width"
:height="height"
@chart-ready="handleChartReady"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import chartsCard from './chartsCard.vue'
const props = defineProps({
title: {
type: String,
default: '折线图'
},
xData: {
type: Array,
required: true // X轴数据(通常是时间或类别)
},
yData: {
type: Array,
required: true // Y轴数据(数值)
},
seriesName: {
type: String,
default: '数据' // 系列名称
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
},
color: {
type: String,
default: '#5793f3' // 线条颜色
},
isSmooth: {
type: Boolean,
default: true // 是否平滑曲线
}
})
const chartOption = computed(() => ({
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderColor: '#ddd',
borderWidth: 1,
textStyle: { color: '#333' },
formatter: (params: any) => {
const p = params[0]
return `${p.name}<br/>${p.seriesName}: ${p.value}`
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: props.xData,
axisTick: {
show: false
},
axisLine: {
lineStyle: {
color: '#999'
}
},
axisLabel: {
color: '#666'
}
},
yAxis: {
type: 'value',
axisTick: {
show: false
},
axisLine: {
show: false
},
axisLabel: {
color: '#666'
},
splitLine: {
lineStyle: {
color: '#eee'
}
}
},
series: [
{
name: props.seriesName,
type: 'line',
data: props.yData,
smooth: props.isSmooth,
lineStyle: {
width: 2,
color: props.color
},
itemStyle: {
color: props.color,
borderWidth: 2
},
symbol: 'circle', // 标记点形状
symbolSize: 6, // 标记点大小
showSymbol: false, // 默认不显示标记点,鼠标悬停时显示
emphasis: {
symbol: 'circle',
symbolSize: 8
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${props.color}80` }, // 半透明
{ offset: 1, color: `${props.color}10` } // 几乎透明
]
}
}
}
]
}))
const handleChartReady = (chart: any) => {
// 可添加交互逻辑
}
</script>
<style scoped lang="less">
.line-chart {
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
}
</style>
\ No newline at end of file
<template>
<div class="visit-way-pie-chart">
<h3 class="chart-title">客户拜访方式占比情况</h3>
<chartsCard
:option="chartOption"
:width="width"
:height="height"
@chart-ready="handleChartReady"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import chartsCard from './chartsCard.vue'
const props = defineProps({
data: {
type: Array,
required: true
// 数据格式示例:
// [
// { name: '电话', value: 765, color: '#5793f3' },
// { name: '上门', value: 560, color: '#a9d672' },
// { name: '社交网络', value: 400, color: '#fac858' },
// { name: '推广渠道', value: 200, color: '#f96c6a' },
// { name: '其他', value: 120, color: '#c2a6f2' },
// ]
},
total: {
type: Number,
required: true // 总拜访量
},
width: {
type: String,
default: '400px'
},
height: {
type: String,
default: '400px'
},
tooltipTip: {
type: String,
default: '拜访方式支持自定义类型添加' // 提示框内容
}
})
const chartOption = computed(() => {
// console.log(props.data,"props.data");
const legendData = props.data.map((item) => item.name)
return {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
// 自定义提示框浮层样式
extraCssText: 'background: #fff9cc; border: 1px solid #ffe57f; padding: 8px;',
// 提示框位置
position: (point) => {
const x = point[0] > 300 ? point[0] - 100 : point[0]
const y = point[1] < 100 ? point[1] + 20 : point[1]
return [x, y]
},
// 提示框内容追加说明
formatter: (params) => {
const baseInfo = `${params.seriesName} <br/>${params.marker} ${params.name} : ${params.value} (${params.percent}%)`
return `${baseInfo} <br/><span style="color: #999; font-size: 12px;">${props.tooltipTip}</span>`
}
},
legend: {
orient: 'vertical',
right: 10,
top: 'center',
align: 'left',
icon: 'circle', // 图例标记为圆形
itemWidth: 12,
itemHeight: 12,
itemGap: 15,
textStyle: {
color: '#333',
fontSize: 14
},
data: legendData
},
series: [
{
name: '客户拜访方式占比情况',
type: 'pie',
radius: ['40%', '70%'], // 环形图内外半径
center: ['35%', '50%'], // 图表中心位置,留出右侧图例空间
label: {
show: false // 隐藏饼图扇区默认标签
},
labelLine: {
show: false // 隐藏标签连线
},
data: props.data.map((item) => ({
name: item.name,
value: item.value,
itemStyle: {
color: item.color
}
})),
// 中心文字配置
emphasis: {
label: {
show: false
}
},
// 中心显示总拜访量
renderItem: (params, api) => {
if (params.name === '') {
return {
type: 'text',
position: api.coord([api.value(0), api.value(1)]),
content: `总拜访量\n${props.total}`,
style: {
textAlign: 'center',
fill: '#333',
fontSize: 16,
fontWeight: 'bold',
lineHeight: 1.4
}
}
}
return null
}
}
]
}
})
const handleChartReady = (chart: echarts.ECharts) => {
// 可在此处对图表进行更多自定义操作,如监听事件等
}
</script>
<style scoped lang="less">
.visit-way-pie-chart {
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
color: #333;
}
}
</style>
\ No newline at end of file
<template>
<div class="horizontal-bar-chart">
<h3 class="chart-title">{{ title }}</h3>
<chartsCard
:option="chartOption"
:width="width"
:height="height"
@chart-ready="handleChartReady"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import chartsCard from './chartsCard.vue'
const props = defineProps({
title: {
type: String,
default: '拜访人均分布情况'
},
data: {
type: Array,
required: true
// 数据格式示例:
// [
// { name: '业务员1', value: 78, color: '#fcd36e' },
// { name: '业务员2', value: 68, color: '#f28b8b' },
// { name: '业务员3', value: 50, color: '#a491f2' },
// { name: '业务员4', value: 42, color: '#fbbc58' },
// { name: '业务员5', value: 32, color: '#7ecb5c' },
// { name: '业务员6', value: 30, color: '#68c0cf' },
// { name: '业务员7', value: 28, color: '#66b1fc' },
// ]
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
}
})
const colors = ['#fcd36e', '#f28b8b', '#a491f2', '#fbbc58', '#7ecb5c', '#68c0cf', '#66b1fc']
const chartOption = computed(() => ({
tooltip: {
trigger: 'item',
formatter: '{b} : {c}' // 格式化提示内容,显示名称和数值
},
grid: {
left: '60px', // 左侧留出空间显示业务员名称
right: '40px',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
// min: 0, // 最小值
// max: 100000, // 最大值
axisTick: {
show: false
},
axisLine: {
show: false
},
axisLabel: {
color: '#666'
},
splitLine: {
lineStyle: {
color: '#eee'
}
}
},
yAxis: {
type: 'category',
data: props.data.map((item) => item.name),
axisTick: {
show: false
},
axisLine: {
show: false
},
axisLabel: {
color: '#333',
fontSize: 14
}
},
series: [
{
name: '拜访人均分布',
type: 'bar',
barCategoryGap: '10%', // 柱子之间的间距
barWidth: 20, // 柱子宽度
data: props.data.map((item) => item.value),
itemStyle: {
color: (params: { dataIndex: number }) => {
const item = props.data[params.dataIndex]
return item.color || colors[params.dataIndex % colors.length]
},
borderRadius: [0, 6, 6, 0] // 仅右侧圆角,适配横向柱状图
},
label: {
show: true,
position: 'right', // 数值显示在柱子右侧
color: '#333',
fontSize: 12
}
}
]
}))
const handleChartReady = (chart: any) => {
// 可在此处对图表进行更多自定义操作,如监听事件等
}
</script>
<style scoped lang="less">
.horizontal-bar-chart {
.chart-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 10px;
color: '#333';
}
}
</style>
\ No newline at end of file
<template>
<div ref="chartRef" class="echarts-base" :style="{ width, height }"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
option: {
type: Object,
required: true
},
width: {
type: String,
default: '100%'
},
height: {
type: String,
default: '400px'
}
})
const emits = defineEmits(['chartReady', 'chartClick'])
const chartRef = ref<HTMLElement | null>(null)
let myChart: echarts.ECharts | null = null
const initChart = () => {
nextTick(() => {
if (chartRef.value) {
myChart = echarts.init(chartRef.value)
myChart.setOption(props.option)
emits('chartReady', myChart)
myChart.on('click', (params) => {
emits('chartClick', params)
})
}
})
}
const resizeChart = () => {
myChart?.resize()
}
onMounted(() => {
initChart()
window.addEventListener('resize', resizeChart)
})
onUnmounted(() => {
window.removeEventListener('resize', resizeChart)
myChart?.dispose()
myChart = null
})
watch(
() => props.option,
() => {
if (myChart) {
myChart.setOption(props.option)
}
},
{ deep: true }
)
</script>
<style scoped>
.echarts-base {
width: 100%;
height: 100%;
}
</style>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment