1.1.60 销售合同仪表盘页面。

dev
yinq 2 days ago
parent 64aaedc906
commit 4d5b6e1c3f

@ -0,0 +1,11 @@
import request from '@/utils/request';
import type { SalesContractDashboardBo, SalesContractDashboardVo } from './types';
/** 查询销售合同仪表盘数据 */
export function getSalesContractDashboard(query?: SalesContractDashboardBo) {
return request<SalesContractDashboardVo>({
url: '/oa/erp/salesContractDashboard/data',
method: 'get',
params: query
});
}

@ -0,0 +1,33 @@
/** 销售合同仪表盘视图对象 */
export interface SalesContractDashboardVo {
/** 成交总额 */
totalTransactionAmount?: number;
/** 本周新签合同额 */
weeklyNewContractAmount?: number;
/** 业务方向销售额占比 */
businessDirectionRatios?: SalesContractDashboardVo[];
/** 成交额客户分布 */
customerTransactionDistribution?: SalesContractDashboardVo[];
/** 销售合同额排行 */
salesContractRanking?: SalesContractDashboardVo[];
/** 业务方向名称 / 通用名称 */
name?: string;
/** 业务方向金额 / 通用数值 */
value?: number;
/** 客户名称 */
customerName?: string;
/** 客户成交额 */
amount?: number;
/** 销售人员名称 */
salespersonName?: string;
/** 销售合同金额 */
contractAmount?: number;
}
/** 销售合同仪表盘业务对象(查询条件) */
export interface SalesContractDashboardBo {
/** 合同签订开始日期 yyyy-MM-dd */
beginDate?: string;
/** 合同签订结束日期 yyyy-MM-dd */
endDate?: string;
}

@ -0,0 +1,653 @@
<template>
<div ref="dashboardRef" class="sales-contract-dashboard" :class="{ 'is-fullscreen': isFullscreen }">
<header class="dashboard-header">
<div class="dashboard-header__filter">
<span class="dashboard-header__filter-label">签订时间</span>
<el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
:clearable="false"
class="dashboard-header__date-picker"
@change="handleDateChange"
/>
<el-button type="primary" plain icon="Search" v-hasPermi="['oa/erp:salesContractDashboard:query']" @click="handleQuery"></el-button>
</div>
<h1 class="dashboard-header__title">销售合同仪表盘</h1>
<div class="dashboard-header__actions">
<el-button link type="primary" v-hasPermi="['oa/erp:salesContractDashboard:query']" @click="handleRefresh">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<el-button link type="primary" @click="toggleFullscreen">
<el-icon><component :is="isFullscreen ? Close : FullScreen" /></el-icon>
{{ isFullscreen ? '退出全屏' : '全屏' }}
</el-button>
</div>
</header>
<div v-loading="loading" class="dashboard-body">
<!-- 顶部指标区 -->
<section class="metrics-row">
<div class="metric-card metric-card--summary">
<div class="metric-card__accent" />
<div class="metric-card__content">
<div class="metric-card__value-wrap">
<span class="metric-card__value">{{ formatAmountNumber(dashboardData.totalTransactionAmount) }}</span>
<span class="metric-card__unit"></span>
</div>
<div class="metric-card__label">合同成交总额</div>
</div>
</div>
<div class="metric-card metric-card--summary">
<div class="metric-card__accent" />
<div class="metric-card__content">
<div class="metric-card__value-wrap">
<span class="metric-card__value">{{ formatAmountNumber(dashboardData.weeklyNewContractAmount) }}</span>
<span class="metric-card__unit"></span>
</div>
<div class="metric-card__label">本周新签合同额</div>
</div>
</div>
<div class="metric-card metric-card--chart">
<div class="metric-card__chart-header">
<span class="metric-card__chart-title">业务方向销售额比例</span>
</div>
<div ref="ratioChartRef" class="metric-card__chart" />
</div>
</section>
<!-- 底部图表区 -->
<section class="charts-row">
<div class="chart-panel">
<div class="chart-panel__header">
<span class="chart-panel__title">成交额客户分布</span>
<span class="chart-panel__unit">单位</span>
</div>
<div ref="customerChartRef" class="chart-panel__body chart-panel__body--customer" :style="{ minHeight: `${customerChartHeight}px` }" />
</div>
<div class="chart-panel">
<div class="chart-panel__header">
<span class="chart-panel__title">销售合同额排行榜</span>
<span class="chart-panel__unit">单位</span>
</div>
<div ref="rankingChartRef" class="chart-panel__body" />
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts" name="SalesContractDashboard">
import { Close, FullScreen, Refresh } from '@element-plus/icons-vue';
import { useResizeObserver } from '@vueuse/core';
import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import { getSalesContractDashboard } from '@/api/oa/erp/salesContractDashboard';
import type { SalesContractDashboardVo } from '@/api/oa/erp/salesContractDashboard/types';
import { checkPermi } from '@/utils/permission';
const DASHBOARD_QUERY_PERM = ['oa/erp:salesContractDashboard:query'];
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const createEmptyDashboard = (): SalesContractDashboardVo => ({
totalTransactionAmount: 0,
weeklyNewContractAmount: 0,
businessDirectionRatios: [],
customerTransactionDistribution: [],
salesContractRanking: []
});
const getCurrentYearRange = (): [string, string] => {
const year = new Date().getFullYear();
return [`${year}-01-01`, `${year}-12-31`];
};
const dashboardRef = ref<HTMLElement>();
const ratioChartRef = ref<HTMLElement>();
const customerChartRef = ref<HTMLElement>();
const rankingChartRef = ref<HTMLElement>();
const loading = ref(false);
const dateRange = ref<[string, string]>(getCurrentYearRange());
const dashboardData = ref<SalesContractDashboardVo>(createEmptyDashboard());
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen(dashboardRef);
let ratioChart: ECharts | null = null;
let customerChart: ECharts | null = null;
let rankingChart: ECharts | null = null;
const CHART_COLORS = {
primary: '#3B76F1',
ranking: '#B37FEB',
/** 业务方向饼图配色(多色系、易区分) */
ratioPalette: [
'#3B76F1',
'#5AD8A6',
'#F6BD16',
'#E86452',
'#6DC8EC',
'#945FB9',
'#FF9845',
'#1E9493',
'#FF99C3',
'#269A99',
'#7585A2',
'#F08BB4'
],
palette: ['#3B76F1', '#5B8FF9', '#69C0FF', '#91D5FF', '#ADC6FF']
};
const AMOUNT_UNIT = '元';
const formatAmountNumber = (amount: number) => {
return Number(amount || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
};
const formatAmount = (amount: number) => `${formatAmountNumber(amount)} ${AMOUNT_UNIT}`;
/** 根据类目文字长度估算 Y 轴左侧留白(中文约 13px/字) */
const calcCategoryGridLeft = (labels: string[]) => {
const maxLen = labels.reduce((max, label) => Math.max(max, (label || '').length), 0);
return Math.min(maxLen * 0.1, 10);
};
const customerChartHeight = computed(() => {
const count = dashboardData.value.customerTransactionDistribution?.length || 0;
return Math.max(320, count * 38 + 72);
});
/** 加载仪表盘数据 */
const loadDashboard = async () => {
if (!checkPermi(DASHBOARD_QUERY_PERM)) {
return;
}
if (!dateRange.value?.length || dateRange.value.length !== 2) {
proxy?.$modal.msgWarning('请选择签订时间区间');
return;
}
loading.value = true;
try {
const res = await getSalesContractDashboard({
beginDate: dateRange.value[0],
endDate: dateRange.value[1]
});
dashboardData.value = res.data || createEmptyDashboard();
await nextTick();
renderCharts();
} finally {
loading.value = false;
}
};
const handleDateChange = (value: [string, string] | null) => {
if (value?.length === 2) {
handleQuery();
}
};
const handleQuery = () => {
loadDashboard();
};
const buildRatioChartOption = (): echarts.EChartsOption => {
const data = dashboardData.value.businessDirectionRatios || [];
return {
color: CHART_COLORS.ratioPalette,
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const { name, value, percent } = params;
return `${name}<br/>${formatAmount(value)} (${percent}%)`;
}
},
legend: {
orient: 'horizontal',
bottom: 0,
left: 'center',
itemWidth: 10,
itemHeight: 10,
textStyle: { fontSize: 12, color: '#606266' }
},
series: [
{
name: '业务方向销售额比例',
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '45%'],
avoidLabelOverlap: true,
label: { show: false },
emphasis: {
label: { show: true, fontSize: 14, fontWeight: 'bold' }
},
itemStyle: { borderRadius: 4 },
data: data.map((item) => ({ name: item.name, value: item.value ?? 0 }))
}
]
};
};
const buildCustomerChartOption = (): echarts.EChartsOption => {
const list = dashboardData.value.customerTransactionDistribution || [];
const sorted = [...list].sort((a, b) => (a.amount ?? 0) - (b.amount ?? 0));
const customerNames = sorted.map((item) => item.customerName || '未知客户');
const gridLeft = calcCategoryGridLeft(customerNames);
return {
color: [CHART_COLORS.primary],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
return `${item.axisValue}<br/>成交额: ${formatAmountNumber(item.value)}`;
}
},
grid: { left: gridLeft, right: 56, top: 16, bottom: 16, containLabel: true },
xAxis: {
type: 'value',
axisLabel: {
formatter: (value: number) => formatAmountNumber(value)
},
splitLine: { lineStyle: { type: 'dashed', color: '#E4E7ED' } }
},
yAxis: {
type: 'category',
data: customerNames,
axisTick: { show: false },
axisLine: { lineStyle: { color: '#DCDFE6' } },
axisLabel: {
color: '#303133',
fontSize: 12,
interval: 0,
hideOverlap: false,
align: 'right',
margin: 10
}
},
series: [
{
name: '成交额',
type: 'bar',
barMaxWidth: 28,
itemStyle: { borderRadius: [0, 6, 6, 0] },
label: {
show: true,
position: 'right',
color: '#606266',
formatter: (params: any) => (params.value > 0 ? formatAmountNumber(params.value) : '')
},
data: sorted.map((item) => item.amount ?? 0)
}
]
};
};
const buildRankingChartOption = (): echarts.EChartsOption => {
const list = dashboardData.value.salesContractRanking || [];
const sorted = [...list].sort((a, b) => (a.contractAmount ?? 0) - (b.contractAmount ?? 0));
return {
color: [CHART_COLORS.ranking],
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const item = Array.isArray(params) ? params[0] : params;
return `${item.axisValue}<br/>合同金额: ${formatAmountNumber(item.value)}`;
}
},
legend: {
bottom: 8,
left: 'center',
data: ['合同金额'],
itemWidth: 12,
itemHeight: 12,
textStyle: { fontSize: 12, color: '#606266' }
},
grid: { left: 56, right: 24, top: 32, bottom: 48 },
xAxis: {
type: 'category',
data: sorted.map((item) => item.salespersonName),
axisTick: { alignWithLabel: true },
axisLine: { lineStyle: { color: '#DCDFE6' } },
axisLabel: { color: '#303133' }
},
yAxis: {
type: 'value',
axisLabel: {
formatter: (value: number) => formatAmountNumber(value)
},
splitLine: { lineStyle: { type: 'dashed', color: '#E4E7ED' } }
},
series: [
{
name: '合同金额',
type: 'bar',
barMaxWidth: 48,
itemStyle: { borderRadius: [6, 6, 0, 0] },
label: {
show: true,
position: 'top',
color: '#606266',
formatter: (params: any) => formatAmountNumber(params.value)
},
data: sorted.map((item) => item.contractAmount ?? 0)
}
]
};
};
const initChart = (el: HTMLElement | undefined, theme?: string) => {
if (!el) return null;
return echarts.getInstanceByDom(el) ?? echarts.init(el, theme);
};
const renderCharts = () => {
if (ratioChartRef.value) {
ratioChart = initChart(ratioChartRef.value);
ratioChart?.setOption(buildRatioChartOption(), true);
}
if (customerChartRef.value) {
customerChart = initChart(customerChartRef.value);
customerChart?.setOption(buildCustomerChartOption(), true);
}
if (rankingChartRef.value) {
rankingChart = initChart(rankingChartRef.value);
rankingChart?.setOption(buildRankingChartOption(), true);
}
};
const resizeCharts = () => {
ratioChart?.resize();
customerChart?.resize();
rankingChart?.resize();
};
const disposeCharts = () => {
ratioChart?.dispose();
customerChart?.dispose();
rankingChart?.dispose();
ratioChart = null;
customerChart = null;
rankingChart = null;
};
const handleRefresh = () => {
loadDashboard();
};
watch(isFullscreen, () => {
nextTick(() => resizeCharts());
});
watch(customerChartHeight, () => {
nextTick(() => customerChart?.resize());
});
useResizeObserver(dashboardRef, () => {
resizeCharts();
});
onMounted(() => {
loadDashboard();
});
onBeforeUnmount(() => {
disposeCharts();
});
</script>
<style lang="scss" scoped>
.sales-contract-dashboard {
min-height: calc(100vh - 84px);
padding: 16px 20px 24px;
background: linear-gradient(180deg, #eef2f8 0%, #f5f7fa 120px, #f5f7fa 100%);
box-sizing: border-box;
&.is-fullscreen {
min-height: 100vh;
padding: 20px 28px 28px;
overflow: auto;
}
}
.dashboard-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 14px 20px;
background: #fff;
border-radius: 10px;
border: 1px solid #ebeef5;
box-shadow: 0 2px 12px rgba(31, 45, 61, 0.06);
&__filter {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
&__filter-label {
font-size: 14px;
font-weight: 500;
color: #606266;
white-space: nowrap;
}
&__date-picker {
width: 280px;
}
&__title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #1f2d3d;
text-align: center;
letter-spacing: 2px;
}
&__actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
}
.dashboard-body {
display: flex;
flex-direction: column;
gap: 18px;
}
.metrics-row {
display: grid;
grid-template-columns: 1fr 1fr 1.5fr;
gap: 18px;
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
}
.metric-card {
position: relative;
overflow: hidden;
min-height: 168px;
background: #fff;
border-radius: 10px;
border: 1px solid #ebeef5;
box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(31, 45, 61, 0.08);
}
&--summary {
display: flex;
flex-direction: column;
}
&__accent {
height: 4px;
background: linear-gradient(90deg, #e75e58 0%, #f08984 100%);
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px 20px 24px;
}
&__value-wrap {
display: flex;
align-items: baseline;
justify-content: center;
gap: 6px;
flex-wrap: wrap;
}
&__value {
font-size: 38px;
font-weight: 700;
line-height: 1.1;
color: #e75e58;
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
}
&__unit {
font-size: 18px;
font-weight: 600;
color: #e75e58;
opacity: 0.85;
}
&__label {
margin-top: 14px;
font-size: 15px;
font-weight: 500;
color: #606266;
}
&--chart {
display: flex;
flex-direction: column;
padding: 16px 18px 12px;
}
&__chart-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 4px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f2f5;
}
&__chart-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
&__chart-unit {
font-size: 12px;
color: #909399;
white-space: nowrap;
}
&__chart {
flex: 1;
width: 100%;
min-height: 132px;
}
}
.charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
@media (max-width: 1200px) {
grid-template-columns: 1fr;
}
}
.chart-panel {
display: flex;
flex-direction: column;
min-height: 400px;
padding: 0 18px 16px;
background: #fff;
border-radius: 10px;
border: 1px solid #ebeef5;
box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
transition: box-shadow 0.2s ease;
&:hover {
box-shadow: 0 8px 20px rgba(31, 45, 61, 0.08);
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 0 12px;
margin-bottom: 4px;
border-bottom: 1px solid #f0f2f5;
}
&__title {
position: relative;
padding-left: 12px;
font-size: 16px;
font-weight: 600;
color: #303133;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 4px;
height: 16px;
border-radius: 2px;
background: var(--el-color-primary);
transform: translateY(-50%);
}
}
&__unit {
font-size: 12px;
color: #909399;
white-space: nowrap;
}
&__body {
flex: 1;
min-height: 320px;
&--customer {
min-height: 320px;
}
}
}
</style>
Loading…
Cancel
Save