feat: 数据大屏

This commit is contained in:
砂糖
2025-09-05 09:28:13 +08:00
parent c4814ed3cc
commit a44abee2f6
16 changed files with 2319 additions and 205 deletions

View File

@@ -93,4 +93,8 @@
.el-dropdown .el-dropdown-link{
color: var(--el-color-primary) !important;
}
.el-select-dropdown {
z-index: 9999 !important; /* 需大于全屏容器的z-index */
}

View File

@@ -63,6 +63,7 @@ export default {
computed: {
filteredMenus() {
const filterHidden = (menus) => {
console.log(menus)
return menus
.filter(menu => menu.hidden !== true)
.map(menu => {

View File

@@ -0,0 +1,187 @@
<template>
<div class="chart-wrapper">
<div class="chart-header">
<h3 class="chart-title">客户跟进状态分布</h3>
</div>
<div ref="chartRef" class="chart-content"></div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
customers: { // 客户数据源
type: Array,
default: () => []
},
isRefreshing: {
type: Boolean,
default: false
}
});
// 组件状态
const chartRef = ref(null);
let chartInstance = null;
// 跟进状态映射与接口返回的followUpStatus对应
const followStatusMap = {
0: '未跟进',
1: '跟进中',
2: '已跟进'
};
// 初始化
onMounted(() => {
initChart();
window.addEventListener('resize', handleResize);
});
// 监听数据变化
watch([() => props.customers, () => props.isRefreshing], () => {
if (chartInstance) {
renderChart();
}
}, { deep: true });
// 卸载清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (chartInstance) {
chartInstance.dispose();
}
});
// 初始化ECharts
const initChart = () => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value, 'dark');
renderChart();
};
// 渲染图表
const renderChart = () => {
if (!chartInstance) return;
// 1. 统计客户跟进状态
const statusStats = { 0: 0, 1: 0, 2: 0 };
props.customers.forEach(customer => {
const status = customer.followUpStatus;
if (statusStats[status] !== undefined) {
statusStats[status]++;
}
});
// 2. 转换为图表数据
const chartData = Object.entries(statusStats)
.map(([status, count]) => ({
name: followStatusMap[status],
value: count,
// 自定义每个状态的颜色
itemStyle: {
color: status === '0' ? '#ef4444' : (status === '1' ? '#f59e0b' : '#10b981')
}
}))
.filter(item => item.value > 0); // 过滤数量为0的状态
// 3. ECharts配置
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' },
formatter: params => {
const total = props.customers.length;
const rate = total > 0 ? ((params.value / total) * 100).toFixed(2) : 0;
return `${params.name}<br/>客户数:${params.value} (${rate}%)`;
}
},
legend: {
top: 0,
left: 'center',
textStyle: { color: '#cbd5e1', fontSize: 12 },
data: chartData.map(item => item.name)
},
series: [
{
name: '客户跟进状态',
type: 'pie',
radius: '60%',
center: ['50%', '55%'],
data: chartData,
label: {
show: true,
position: 'center',
formatter: params => {
// 中心显示总客户数
if (params.name === chartData[0].name) {
return `总客户数\n${props.customers.length}`;
}
return '';
},
textStyle: {
color: '#f8fafc',
fontSize: 16,
fontWeight: 600,
lineHeight: 24
}
},
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(255, 255, 255, 0.2)'
}
}
}
]
};
// 无数据时显示提示
if (props.customers.length === 0) {
option.graphic = {
type: 'text',
left: 'center',
top: 'center',
style: { text: '暂无客户数据', color: '#94a3b8', fontSize: 14 }
};
}
chartInstance.setOption(option);
};
// 窗口resize
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
</script>
<style scoped>
.chart-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-header {
margin-bottom: 12px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #f8fafc;
margin: 0;
}
.chart-content {
flex: 1;
width: 100%;
height: calc(100% - 40px);
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<div class="chart-wrapper">
<!-- 图表标题+筛选器 -->
<div class="chart-header">
<h3 class="chart-title">订单趋势分析</h3>
<el-select
v-model="timeRange"
placeholder="选择时间范围"
size="small"
@change="renderChart"
class="time-select"
append-to="#full-dashboard-container"
>
<el-option label="近7天" value="7" />
<el-option label="近30天" value="30" />
<el-option label="近90天" value="90" />
</el-select>
</div>
<!-- 图表容器 -->
<div ref="chartRef" class="chart-content"></div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
orders: { // 订单数据源
type: Array,
default: () => []
},
isRefreshing: { // 刷新状态(控制重新渲染)
type: Boolean,
default: false
}
});
// 组件状态
const chartRef = ref(null);
const timeRange = ref('30'); // 默认近30天
let chartInstance = null; // ECharts实例
// 初始化图表
onMounted(() => {
initChart();
// 监听窗口resize确保图表自适应
window.addEventListener('resize', handleResize);
});
// 监听数据变化,重新渲染图表
watch([() => props.orders, () => props.isRefreshing, timeRange], () => {
if (chartInstance) {
renderChart();
}
}, { deep: true });
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (chartInstance) {
chartInstance.dispose();
}
});
// 初始化ECharts实例
const initChart = () => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value, 'dark'); // 深色主题
renderChart();
};
// 渲染图表核心逻辑
const renderChart = () => {
if (!chartInstance || props.orders.length === 0) {
return;
}
// 1. 生成时间范围近N天的日期列表
const days = Number(timeRange.value);
const dateList = [];
const now = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(now);
date.setDate(now.getDate() - i);
// 统一日期格式2024-09-04
const formatDate = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
dateList.push(formatDate);
}
// 2. 按日期统计订单数和销售额
const orderCountMap = {}; // 订单数映射
const salesMap = {}; // 销售额映射
// 初始化映射为0
dateList.forEach(date => {
orderCountMap[date] = 0;
salesMap[date] = 0;
});
// 遍历订单数据统计
props.orders.forEach(order => {
if (!order.createTime) return; // 跳过无创建时间的订单
const orderDate = new Date(order.createTime).toLocaleDateString();
// 格式统一适配不同浏览器的toLocaleDateString结果
const standardDate = `${orderDate.split('/')[0]}-${orderDate.split('/')[1].padStart(2, '0')}-${orderDate.split('/')[2].padStart(2, '0')}`;
if (orderCountMap[standardDate] !== undefined) {
orderCountMap[standardDate]++;
salesMap[standardDate] += Number(order.taxAmount || 0);
}
});
// 3. 组装ECharts配置
const option = {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' }
},
legend: {
data: ['订单数', '销售额'],
top: 0,
textStyle: { color: '#cbd5e1' },
itemWidth: 12,
itemHeight: 12
},
grid: {
left: '5%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true
},
xAxis: [
{
type: 'category',
data: dateList,
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: {
color: '#94a3b8',
fontSize: 12,
rotate: 45, // 旋转x轴标签避免重叠
interval: days > 30 ? 7 : 0 // 90天时每7天显示一个标签
},
splitLine: { show: false }
}
],
yAxis: [
// 左轴:订单数
{
type: 'value',
name: '订单数',
nameTextStyle: { color: '#94a3b8' },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8', fontSize: 12 },
splitLine: { lineStyle: { color: 'rgba(51, 65, 85, 0.3)' } },
min: 0
},
// 右轴:销售额
{
type: 'value',
name: '销售额(元)',
nameTextStyle: { color: '#94a3b8' },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: {
color: '#94a3b8',
fontSize: 12,
formatter: '¥{value}'
},
splitLine: { show: false },
min: 0,
yAxisIndex: 1
}
],
series: [
// 订单数(柱状图)
{
name: '订单数',
type: 'bar',
data: dateList.map(date => orderCountMap[date]),
barWidth: '40%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#3b82f6' },
{ offset: 1, color: '#1e40af' }
])
},
emphasis: {
itemStyle: { color: '#60a5fa' }
}
},
// 销售额(折线图)
{
name: '销售额',
type: 'line',
yAxisIndex: 1, // 关联右轴
data: dateList.map(date => salesMap[date]),
smooth: true, // 平滑曲线
lineStyle: { width: 3, color: '#f59e0b' },
itemStyle: { color: '#f59e0b', borderWidth: 2 },
symbol: 'circle', // 标记点样式
symbolSize: 6
}
]
};
chartInstance.setOption(option);
};
// 窗口resize时调整图表大小
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
</script>
<style scoped>
.chart-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #f8fafc;
margin: 0;
}
.time-select {
width: 120px;
--el-select-dropdown-bg-color: #1e293b;
--el-select-option-hover-bg-color: #334155;
--el-select-option-text-color: #f8fafc;
}
.chart-content {
flex: 1;
width: 100%;
height: calc(100% - 40px);
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<div class="chart-wrapper">
<div class="chart-header">
<h3 class="chart-title">产品销售排行Top10</h3>
<el-select
v-model="statType"
placeholder="统计维度"
size="small"
@change="renderChart"
class="stat-select"
append-to="#full-dashboard-container"
>
<el-option label="销量" value="quantity" />
<el-option label="销售额" value="amount" />
</el-select>
</div>
<div ref="chartRef" class="chart-content"></div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
orderDetails: { // 订单项数据源
type: Array,
default: () => []
},
isRefreshing: {
type: Boolean,
default: false
}
});
// 组件状态
const chartRef = ref(null);
const statType = ref('quantity'); // 默认按销量统计
let chartInstance = null;
// 初始化
onMounted(() => {
initChart();
window.addEventListener('resize', handleResize);
});
// 监听数据变化
watch([() => props.orderDetails, () => props.isRefreshing, statType], () => {
if (chartInstance) {
renderChart();
}
}, { deep: true });
// 卸载清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (chartInstance) {
chartInstance.dispose();
}
});
// 初始化ECharts
const initChart = () => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value, 'dark');
renderChart();
};
// 渲染图表
const renderChart = () => {
if (!chartInstance || props.orderDetails.length === 0) {
// 无数据时显示提示
chartInstance.setOption({
graphic: {
type: 'text',
left: 'center',
top: 'center',
style: { text: '暂无订单项数据', color: '#94a3b8', fontSize: 14 }
}
});
return;
}
// 1. 按产品聚合数据(销量/销售额)
const productStats = {};
props.orderDetails.forEach(detail => {
const productName = detail.productName || `未知产品(${detail.productCode})`;
if (!productStats[productName]) {
productStats[productName] = {
quantity: 0, // 销量
amount: 0 // 销售额(销量*含税单价)
};
}
// 累加销量
productStats[productName].quantity += Number(detail.quantity || 0);
// 累加销售额
const unitPrice = Number(detail.taxPrice || 0);
productStats[productName].amount += unitPrice * Number(detail.quantity || 0);
});
// 2. 转换为数组并排序取Top10
const productList = Object.entries(productStats)
.map(([name, stats]) => ({
name,
value: statType.value === 'quantity' ? stats.quantity : stats.amount
}))
.sort((a, b) => b.value - a.value) // 降序排列
.slice(0, 10); // 取前10
// 3. ECharts配置
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' },
formatter: params => {
const label = statType.value === 'quantity' ? '销量' : '销售额';
const unit = statType.value === 'quantity' ? '件' : '元';
return `${params.name}<br/>${label}${params.value} ${unit}`;
}
},
legend: {
show: false // 饼图无需图例(名称直接显示在扇区)
},
series: [
{
name: statType.value === 'quantity' ? '销量' : '销售额',
type: 'pie',
radius: ['40%', '70%'], // 环形饼图
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 8,
borderColor: '#0f172a', // 扇区间隙颜色(匹配大屏背景)
borderWidth: 2
},
label: {
show: true,
position: 'outside',
textStyle: { color: '#cbd5e1', fontSize: 12 },
formatter: '{b}: {c}', // 显示产品名+数值
lineHeight: 16
},
labelLine: {
show: true,
lineStyle: { color: '#334155' }
},
data: productList,
// 自定义颜色(适配深色主题)
color: [
'#3b82f6', '#10b981', '#f59e0b', '#6366f1', '#ec4899',
'#8b5cf6', '#14b8a6', '#f97316', '#a855f7', '#ef4444'
]
}
]
};
chartInstance.setOption(option);
};
// 窗口resize
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
</script>
<style scoped>
.chart-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #f8fafc;
margin: 0;
}
.stat-select {
width: 120px;
--el-select-dropdown-bg-color: #1e293b;
--el-select-option-hover-bg-color: #334155;
--el-select-option-text-color: #f8fafc;
}
.chart-content {
flex: 1;
width: 100%;
height: calc(100% - 40px);
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="chart-wrapper">
<div class="chart-header">
<h3 class="chart-title">退换货分析</h3>
</div>
<div ref="chartRef" class="chart-content"></div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
returnExchanges: { // 退换货数据源
type: Array,
default: () => []
},
isRefreshing: {
type: Boolean,
default: false
}
});
// 组件状态
const chartRef = ref(null);
let chartInstance = null;
// 初始化
onMounted(() => {
initChart();
window.addEventListener('resize', handleResize);
});
// 监听数据变化
watch([() => props.returnExchanges, () => props.isRefreshing], () => {
if (chartInstance) {
renderChart();
}
}, { deep: true });
// 卸载清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (chartInstance) {
chartInstance.dispose();
}
});
// 初始化ECharts
const initChart = () => {
if (!chartRef.value) return;
chartInstance = echarts.init(chartRef.value, 'dark');
renderChart();
};
// 渲染图表
const renderChart = () => {
if (!chartInstance) return;
// 1. 生成近30天日期固定时间范围避免数据分散
const dateList = [];
const now = new Date();
for (let i = 29; i >= 0; i--) {
const date = new Date(now);
date.setDate(now.getDate() - i);
const formatDate = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
dateList.push(formatDate);
}
// 2. 按日期统计退换货量和金额
const returnCountMap = {}; // 退换货数量
const returnAmountMap = {}; // 退换货金额
// 初始化映射为0
dateList.forEach(date => {
returnCountMap[date] = 0;
returnAmountMap[date] = 0;
});
// 遍历退换货数据统计
props.returnExchanges.forEach(item => {
if (!item.createTime) return;
const returnDate = new Date(item.createTime).toLocaleDateString();
const standardDate = `${returnDate.split('/')[0]}-${returnDate.split('/')[1].padStart(2, '0')}-${returnDate.split('/')[2].padStart(2, '0')}`;
if (returnCountMap[standardDate] !== undefined) {
returnCountMap[standardDate]++;
returnAmountMap[standardDate] += Number(item.amount || 0);
}
});
// 3. ECharts配置
const option = {
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' }
},
legend: {
top: 0,
left: 'center',
textStyle: { color: '#cbd5e1', fontSize: 12 },
data: ['退换货量', '退换货金额']
},
grid: {
left: '5%',
right: '5%',
bottom: '15%',
top: '20%',
containLabel: true
},
xAxis: {
type: 'category',
data: dateList,
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: {
color: '#94a3b8',
fontSize: 11,
rotate: 45,
interval: 6 // 每7天显示一个标签
},
splitLine: { show: false }
},
yAxis: [
// 左轴:退换货量
{
type: 'value',
name: '退换货量',
nameTextStyle: { color: '#94a3b8' },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8', fontSize: 12 },
splitLine: { lineStyle: { color: 'rgba(51, 65, 85, 0.3)' } },
min: 0
},
// 右轴:退换货金额
{
type: 'value',
name: '金额(元)',
nameTextStyle: { color: '#94a3b8' },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: {
color: '#94a3b8',
fontSize: 12,
formatter: '¥{value}'
},
splitLine: { show: false },
min: 0,
yAxisIndex: 1
}
],
series: [
// 退换货量(柱状图)
{
name: '退换货量',
type: 'bar',
data: dateList.map(date => returnCountMap[date]),
barWidth: '40%',
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#ef4444' },
{ offset: 1, color: '#b91c1c' }
])
}
},
// 退换货金额(折线图)
{
name: '退换货金额',
type: 'line',
yAxisIndex: 1,
data: dateList.map(date => returnAmountMap[date]),
smooth: true,
lineStyle: { width: 3, color: '#8b5cf6' },
itemStyle: { color: '#8b5cf6', borderWidth: 2 },
symbol: 'circle',
symbolSize: 6
}
]
};
// 无数据时显示提示
if (props.returnExchanges.length === 0) {
option.graphic = {
type: 'text',
left: 'center',
top: 'center',
style: { text: '暂无退换货数据', color: '#94a3b8', fontSize: 14 }
};
}
chartInstance.setOption(option);
};
// 窗口resize
const handleResize = () => {
if (chartInstance) {
chartInstance.resize();
}
};
</script>
<style scoped>
.chart-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-header {
margin-bottom: 12px;
}
.chart-title {
font-size: 16px;
font-weight: 600;
color: #f8fafc;
margin: 0;
}
.chart-content {
flex: 1;
width: 100%;
height: calc(100% - 40px);
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<div class="chart-wrapper">
<div class="chart-header">
<h3 class="chart-title">客户订单汇总</h3>
</div>
<!-- 左右分栏布局 -->
<div class="chart-layout">
<div ref="barChartRef" class="chart-column"></div>
<div ref="radarChartRef" class="chart-column"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
orders: { type: Array, default: () => [] },
isRefreshing: { type: Boolean, default: false }
});
// 图表容器引用
const barChartRef = ref(null);
const radarChartRef = ref(null);
let barChart = null;
let radarChart = null;
// 初始化图表
onMounted(() => {
initBarChart();
initRadarChart();
window.addEventListener('resize', handleResize);
});
// 监听数据变化,重新渲染
watch([() => props.orders, () => props.isRefreshing], () => {
if (barChart) renderBarChart();
if (radarChart) renderRadarChart();
}, { deep: true });
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (barChart) barChart.dispose();
if (radarChart) radarChart.dispose();
});
// ---------- 左侧订单数柱状图Top10客户 ----------
const renderBarChart = () => {
if (!barChartRef.value || props.orders.length === 0) return;
// 统计每个客户的订单数取Top10
const customerStats = {};
props.orders.forEach(order => {
const customer = order.customerName || `客户#${order.customerId}`;
customerStats[customer] = (customerStats[customer] || 0) + 1;
});
// 按订单数降序取Top10
const sortedCustomers = Object.entries(customerStats)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const customerNames = sortedCustomers.map(item => item[0]);
const orderCounts = sortedCustomers.map(item => item[1]);
// 粉紫渐变柱状图(匹配参考图风格)
const barColor = new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#ec4899' },
{ offset: 1, color: '#8b5cf6' }
]);
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' }
},
xAxis: {
type: 'category',
data: customerNames,
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8', fontSize: 11, rotate: 40 },
splitLine: { show: false }
},
yAxis: {
type: 'value',
name: '订单数',
nameTextStyle: { color: '#94a3b8' },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8', fontSize: 12 },
splitLine: { lineStyle: { color: 'rgba(51, 65, 85, 0.3)' } },
min: 0
},
series: [{
name: '订单数',
type: 'bar',
data: orderCounts,
barWidth: '40%',
itemStyle: { color: barColor }
}]
};
barChart.setOption(option);
};
const initBarChart = () => {
if (!barChartRef.value) return;
barChart = echarts.init(barChartRef.value, 'dark');
renderBarChart();
};
// ---------- 右侧订单额雷达图Top10客户占比 ----------
const renderRadarChart = () => {
if (!radarChartRef.value || props.orders.length === 0) return;
// 统计每个客户的订单总额取Top10& 计算占比
const customerStats = {};
let totalAmount = 0;
props.orders.forEach(order => {
const customer = order.customerName || `客户#${order.customerId}`;
const amount = Number(order.taxAmount || 0);
totalAmount += amount;
customerStats[customer] = (customerStats[customer] || 0) + amount;
});
// 按订单额降序取Top10
const sortedCustomers = Object.entries(customerStats)
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const customerNames = sortedCustomers.map(item => item[0]);
const radarData = sortedCustomers.map(item => ({
name: item[0],
value: totalAmount > 0 ? (item[1] / totalAmount) * 100 : 0 // 占比(%
}));
// 浅粉紫渐变雷达图(匹配参考图风格)
const radarColor = new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#fbcfe8' },
{ offset: 1, color: '#bfdbfe' }
]);
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' },
formatter: '{b}: {c}%' // 显示占比
},
radar: {
indicator: customerNames.map(name => ({ name, max: 100 })), // 最大占比100%
splitArea: { areaStyle: { color: 'rgba(51, 65, 85, 0.2)' } },
splitLine: { lineStyle: { color: 'rgba(51, 65, 85, 0.5)' } },
axisLine: { lineStyle: { color: '#94a3b8' } }
},
series: [{
name: '订单额占比',
type: 'radar',
data: [{
value: radarData.map(item => item.value),
name: '订单额占比',
itemStyle: { color: radarColor },
areaStyle: { color: 'rgba(236, 72, 153, 0.2)' } // 透明填充
}]
}]
};
radarChart.setOption(option);
};
const initRadarChart = () => {
if (!radarChartRef.value) return;
radarChart = echarts.init(radarChartRef.value, 'dark');
renderRadarChart();
};
// 窗口resize时重绘
const handleResize = () => {
if (barChart) barChart.resize();
if (radarChart) radarChart.resize();
};
</script>
<style scoped>
/* 与负责人图表样式一致,确保布局统一 */
.chart-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-header { margin-bottom: 12px; }
.chart-title { font-size: 16px; font-weight: 600; color: #f8fafc; margin: 0; }
.chart-layout {
display: flex;
flex: 1;
gap: 10px;
}
.chart-column {
flex: 1;
height: 100%;
}
</style>

View File

@@ -0,0 +1,195 @@
<template>
<div class="chart-wrapper">
<div class="chart-header">
<h3 class="chart-title">负责人订单汇总</h3>
</div>
<!-- 左右分栏布局 -->
<div class="chart-layout">
<div ref="barChartRef" class="chart-column"></div>
<div ref="radarChartRef" class="chart-column"></div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue';
import * as echarts from 'echarts';
const props = defineProps({
orders: { type: Array, default: () => [] },
isRefreshing: { type: Boolean, default: false }
});
// 图表容器引用
const barChartRef = ref(null);
const radarChartRef = ref(null);
let barChart = null;
let radarChart = null;
// 初始化图表
onMounted(() => {
initBarChart();
initRadarChart();
window.addEventListener('resize', handleResize);
});
// 监听数据变化,重新渲染
watch([() => props.orders, () => props.isRefreshing], () => {
if (barChart) renderBarChart();
if (radarChart) renderRadarChart();
}, { deep: true });
// 组件卸载时清理
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
if (barChart) barChart.dispose();
if (radarChart) radarChart.dispose();
});
// ---------- 左侧:订单数柱状图 ----------
const renderBarChart = () => {
if (!barChartRef.value || props.orders.length === 0) return;
// 统计每个负责人的订单数
const managerStats = {};
props.orders.forEach(order => {
const manager = order.salesManager || '未知负责人';
managerStats[manager] = (managerStats[manager] || 0) + 1;
});
const managers = Object.keys(managerStats);
const orderCounts = managers.map(m => managerStats[m]);
// 粉紫渐变柱状图(匹配参考图风格)
const barColor = new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#9333ea' },
{ offset: 1, color: '#3b82f6' }
]);
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' }
},
xAxis: {
type: 'category',
data: managers,
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8', fontSize: 12, rotate: 30 },
splitLine: { show: false }
},
yAxis: {
type: 'value',
name: '订单数',
nameTextStyle: { color: '#94a3b8' },
axisLine: { lineStyle: { color: '#334155' } },
axisLabel: { color: '#94a3b8', fontSize: 12 },
splitLine: { lineStyle: { color: 'rgba(51, 65, 85, 0.3)' } },
min: 0
},
series: [{
name: '订单数',
type: 'bar',
data: orderCounts,
barWidth: '40%',
itemStyle: { color: barColor }
}]
};
barChart.setOption(option);
};
const initBarChart = () => {
if (!barChartRef.value) return;
barChart = echarts.init(barChartRef.value, 'dark');
renderBarChart();
};
// ---------- 右侧:订单额雷达图 ----------
const renderRadarChart = () => {
if (!radarChartRef.value || props.orders.length === 0) return;
// 统计每个负责人的订单总额 & 计算占比
const managerStats = {};
let totalAmount = 0;
props.orders.forEach(order => {
const manager = order.salesManager || '未知负责人';
const amount = Number(order.taxAmount || 0);
totalAmount += amount;
managerStats[manager] = (managerStats[manager] || 0) + amount;
});
const managers = Object.keys(managerStats);
const radarData = managers.map(m => ({
name: m,
value: totalAmount > 0 ? (managerStats[m] / totalAmount) * 100 : 0 // 占比(%
}));
// 浅紫渐变雷达图(匹配参考图风格)
const radarColor = new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#f0abfc' },
{ offset: 1, color: '#bfdbfe' }
]);
const option = {
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 23, 42, 0.9)',
borderColor: '#334155',
textStyle: { color: '#f8fafc' },
formatter: '{b}: {c}%' // 显示占比
},
radar: {
indicator: managers.map(name => ({ name, max: 100 })), // 最大占比100%
splitArea: { areaStyle: { color: 'rgba(51, 65, 85, 0.2)' } },
splitLine: { lineStyle: { color: 'rgba(51, 65, 85, 0.5)' } },
axisLine: { lineStyle: { color: '#94a3b8' } }
},
series: [{
name: '订单额占比',
type: 'radar',
data: [{
value: radarData.map(item => item.value),
name: '订单额占比',
itemStyle: { color: radarColor },
areaStyle: { color: 'rgba(147, 51, 234, 0.2)' } // 透明填充
}]
}]
};
radarChart.setOption(option);
};
const initRadarChart = () => {
if (!radarChartRef.value) return;
radarChart = echarts.init(radarChartRef.value, 'dark');
renderRadarChart();
};
// 窗口resize时重绘
const handleResize = () => {
if (barChart) barChart.resize();
if (radarChart) radarChart.resize();
};
</script>
<style scoped>
.chart-wrapper {
display: flex;
flex-direction: column;
height: 100%;
}
.chart-header { margin-bottom: 12px; }
.chart-title { font-size: 16px; font-weight: 600; color: #f8fafc; margin: 0; }
.chart-layout {
display: flex;
flex: 1;
gap: 10px; /* 左右图表间距 */
}
.chart-column {
flex: 1;
height: 100%;
}
</style>

View File

@@ -0,0 +1,246 @@
<template>
<!-- 图表容器含滚动 -->
<div class="charts-container">
<!-- Element 布局el-row 为行容器gutter 控制间距 -->
<el-row
class="charts-row"
:gutter="20"
:style="{ height: `calc(100% - 40px)` }"
>
<!-- 动态渲染图表遍历持久化后的配置数组 -->
<el-col
v-for="(chartConfig, index) in persistedChartConfigs"
:key="chartConfig.id"
class="chart-col"
:xs="chartConfig.layout.xs"
:sm="chartConfig.layout.sm"
:md="chartConfig.layout.md"
:lg="chartConfig.layout.lg"
:xl="chartConfig.layout.xl"
:style="{ height: `calc(${100 / Math.ceil(persistedChartConfigs.length / 2)}% - 10px)` }"
>
<!-- 动态加载图表组件 -->
<component
:is="chartComponentMap[chartConfig.componentName]"
class="chart-item"
v-bind="getChartProps(chartConfig)"
:is-refreshing="isRefreshing"
/>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { useStorage } from '@vueuse/core'; // 导入持久化工具
import { ElRow, ElCol } from 'element-plus'; // 导入Element布局组件
// 1. 导入所有图表子组件
import OrderTrendChart from '../components/OrderTrendChart.vue';
import ProductSalesRank from '../components/ProductSalesRank.vue';
import CustomerFollowStatus from '../components/CustomerFollowStatus.vue';
import ReturnExchangeAnalysis from '../components/ReturnExchangeAnalysis.vue';
import SalesByManagerChart from '../components/SalesByManagerChart.vue';
import SalesByCustomerChart from '../components/SalesByCustomerChart.vue';
// 2. 图表组件映射表:用于动态匹配组件(组件名 → 组件实例)
const chartComponentMap = {
OrderTrendChart,
ProductSalesRank,
CustomerFollowStatus,
ReturnExchangeAnalysis,
SalesByManagerChart,
SalesByCustomerChart
};
// 3. 图表默认配置数组:定义每个图表的基础信息、布局、数据源
const DEFAULT_CHART_CONFIGS = [
{
id: 'order-trend', // 唯一标识(不可重复)
componentName: 'OrderTrendChart', // 对应组件名与chartComponentMap匹配
title: '订单趋势图表', // 图表标题(可用于组件内部或表头)
dataKey: 'orders', // 数据源key对应props中的数据字段
layout: { // Element Col 布局配置span范围1-2424为整行
xs: 24, // 超小屏独占1行
sm: 24, // 小屏独占1行
md: 12, // 中屏占1/2行
lg: 12, // 大屏占1/2行
xl: 12 // 超大屏占1/2行
}
},
{
id: 'product-rank',
componentName: 'ProductSalesRank',
title: '产品销售排行图表',
dataKey: 'orderDetails', // 依赖orderDetails数据
layout: {
xs: 24,
sm: 24,
md: 12,
lg: 12,
xl: 12
}
},
{
id: 'sales-manager',
componentName: 'SalesByManagerChart',
title: '负责人订单汇总',
dataKey: 'orders', // 依赖orders数据
layout: {
xs: 24,
sm: 24,
md: 12,
lg: 12,
xl: 12
}
},
{
id: 'customer-follow',
componentName: 'CustomerFollowStatus',
title: '客户跟进状态图表',
dataKey: 'customers', // 依赖customers数据
layout: {
xs: 24,
sm: 24,
md: 12,
lg: 12,
xl: 12
}
},
{
id: 'return-exchange',
componentName: 'ReturnExchangeAnalysis',
title: '退换货分析图表',
dataKey: 'returnExchanges', // 依赖returnExchanges数据
layout: {
xs: 24,
sm: 24,
md: 12,
lg: 12,
xl: 12
}
},
{
id: 'sales-customer',
componentName: 'SalesByCustomerChart',
title: '客户订单汇总',
dataKey: 'orders', // 依赖orders数据
layout: {
xs: 24,
sm: 24,
md: 12,
lg: 12,
xl: 12
}
}
];
// 4. 持久化图表配置用useStorage存入localStoragekey为"saleDashboardChartConfigs"
// 逻辑优先读取localStorage中的配置若无则使用默认配置
const persistedChartConfigs = useStorage(
'saleDashboardChartConfigs', // 存储key自定义确保唯一
DEFAULT_CHART_CONFIGS, // 默认值
localStorage, // 存储介质localStorage/sessionStorage
{ mergeDefaults: true } // 合并默认值与存储值(避免字段缺失)
);
// 5. 接收父组件传入的数据源与状态
const props = defineProps({
orders: {
type: Array,
required: true,
default: () => []
},
orderDetails: {
type: Array,
required: true,
default: () => []
},
customers: {
type: Array,
required: true,
default: () => []
},
returnExchanges: {
type: Array,
required: true,
default: () => []
},
isRefreshing: {
type: Boolean,
required: true,
default: false
}
});
// 6. 工具函数根据图表配置动态生成组件所需的props
const getChartProps = (chartConfig) => {
// 映射数据源根据chartConfig.dataKey匹配props中的数据
const dataMap = {
orders: props.orders,
orderDetails: props.orderDetails,
customers: props.customers,
returnExchanges: props.returnExchanges
};
// 返回该图表需要的props如OrderTrendChart需要:ordersProductSalesRank需要:order-details
return {
// 驼峰转连字符如orderDetails → order-details匹配组件props定义
[chartConfig.dataKey.replace(/([A-Z])/g, '-$1').toLowerCase()]: dataMap[chartConfig.dataKey],
title: chartConfig.title // 可选:传递标题给图表组件
};
};
</script>
<style scoped>
/* 图表容器(含滚动) */
.charts-container {
width: 100%;
height: 100%;
padding: 20px;
overflow: auto;
box-sizing: border-box;
background-color: #0f172a; /* 继承父组件深色背景 */
}
/* Element Row 容器清除默认margin确保高度自适应 */
.charts-row {
width: 100%;
margin: 0;
display: flex;
flex-wrap: wrap;
align-content: flex-start; /* 顶部对齐,避免空白 */
}
/* Element Col 容器:控制列的高度与间距 */
.chart-col {
margin-bottom: 20px; /* 行间距与gutter配合 */
box-sizing: border-box;
}
/* 图表项样式:保持原有设计,适配弹性布局 */
.chart-item {
width: 100%;
height: 100%;
background-color: #1e293b;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
overflow: hidden;
display: flex;
flex-direction: column;
}
/* 小屏幕下优化:减少内边距 */
@media (max-width: 768px) {
.charts-container {
padding: 10px;
}
.chart-item {
padding: 12px;
}
.chart-col {
margin-bottom: 10px;
}
}
</style>

View File

@@ -0,0 +1,416 @@
<template>
<div class="chart-settings-panel">
<!-- 面板标题与操作区 -->
<div class="panel-header">
<h2 class="panel-title">图表布局设置</h2>
<div class="header-actions">
<el-button
size="small"
type="primary"
@click="applySettings"
:loading="saving"
>
<el-icon v-if="saving"><Loading /></el-icon>
<span>应用设置</span>
</el-button>
<el-button
size="small"
@click="resetToSession"
:disabled="saving"
>
取消修改
</el-button>
</div>
</div>
<!-- 配置内容区 -->
<div class="settings-content">
<!-- 布局预览 -->
<div class="layout-preview">
<h3 class="section-title">布局预览</h3>
<div class="preview-container">
<el-row :gutter="10" class="preview-row">
<el-col
v-for="(chart, index) in tempChartConfigs"
:key="chart.id"
:span="getPreviewSpan(chart)"
class="preview-col"
:class="{ 'preview-col-hidden': !chart.visible }"
>
<div class="preview-chart">
<div class="chart-header">
<span class="chart-name">{{ chart.title }}</span>
<el-switch
v-model="chart.visible"
size="small"
active-color="#1677ff"
/>
</div>
<div class="chart-placeholder">
<span class="chart-id">{{ chart.id }}</span>
</div>
</div>
</el-col>
</el-row>
</div>
</div>
<!-- 图表配置列表 -->
<div class="charts-config">
<h3 class="section-title">图表配置</h3>
<el-table
:data="tempChartConfigs"
border
size="small"
:height="tableHeight"
>
<el-table-column
prop="title"
label="图表名称"
width="200"
/>
<el-table-column
prop="id"
label="标识"
width="160"
/>
<el-table-column label="显示状态">
<template #default="scope">
<el-switch
v-model="scope.row.visible"
size="small"
active-color="#1677ff"
/>
</template>
</el-table-column>
<!-- 布局尺寸配置 -->
<el-table-column label="超小屏 (≤768px)">
<template #default="scope">
<el-select
v-model="scope.row.layout.xs"
size="small"
:disabled="!scope.row.visible"
>
<el-option :value="24">独占一行</el-option>
<el-option :value="12">半行宽度</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="中大屏 (≥992px)">
<template #default="scope">
<el-select
v-model="scope.row.layout.md"
size="small"
:disabled="!scope.row.visible"
>
<el-option :value="24">独占一行</el-option>
<el-option :value="12">半行宽度</el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button
size="small"
text
@click="moveChart(scope.$index, 'up')"
:disabled="scope.$index === 0"
>
上移
</el-button>
<el-button
size="small"
text
@click="moveChart(scope.$index, 'down')"
:disabled="scope.$index === tempChartConfigs.length - 1"
>
下移
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<!-- 提示信息 -->
<div class="settings-footer">
<el-alert
title="提示:取消修改将恢复到打开设置时的状态"
type="info"
size="small"
:closable="false"
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useStorage } from '@vueuse/core';
import { useEventListener } from '@vueuse/core';
import { ElMessage } from 'element-plus';
import { Loading } from '@element-plus/icons-vue';
// 接收存储键名props
const props = defineProps({
storageKey: {
type: String,
required: true,
default: 'saleDashboardChartConfigs'
}
});
// 从storage读取配置不设置预设默认值
const persistedChartConfigs = useStorage(
props.storageKey,
[], // 空数组作为初始值,不预设默认配置
localStorage,
{
serializer: {
read: (v) => {
try {
if (typeof v === 'string') {
return JSON.parse(v);
}
if (Array.isArray(v)) {
return v;
}
return []; // 解析失败时返回空数组
} catch (e) {
console.error('解析存储的图表配置失败,使用空配置:', e);
return [];
}
},
write: (v) => JSON.stringify(v)
},
mergeDefaults: false // 关闭默认值合并
}
);
// 临时配置(用于编辑)
const tempChartConfigs = ref([]);
// 保存弹窗打开时的初始配置快照直接来自storage
const initialChartConfigs = ref([]);
// 保存状态
const saving = ref(false);
// 窗口尺寸响应
const windowWidth = ref(window.innerWidth);
const tableHeight = ref(300);
// 监听窗口尺寸变化
useEventListener('resize', () => {
windowWidth.value = window.innerWidth;
calculateTableHeight();
});
// 初始化从storage读取数据并保存初始快照
onMounted(() => {
// 确保初始数据是数组
if (!Array.isArray(persistedChartConfigs.value)) {
persistedChartConfigs.value = [];
}
// 初始化临时配置和初始快照
resetToSession();
calculateTableHeight();
});
// 计算表格高度
const calculateTableHeight = () => {
tableHeight.value = Math.max(300, window.innerHeight - 500);
};
// 重置临时配置为打开弹窗时的状态
const resetToSession = () => {
try {
// 从storage数据读取
const source = Array.isArray(persistedChartConfigs.value)
? persistedChartConfigs.value
: [];
// 深拷贝保存到临时配置和初始快照
const configCopy = JSON.parse(JSON.stringify(source));
tempChartConfigs.value = configCopy;
initialChartConfigs.value = JSON.parse(JSON.stringify(configCopy));
ElMessage.info('已恢复到打开设置时的状态');
} catch (e) {
console.error('重置配置失败:', e);
ElMessage.error('重置配置失败,请重试');
}
};
// 计算预览区域的span值
const getPreviewSpan = (chart) => {
return Math.floor(chart.layout.md / 4);
};
// 移动图表位置
const moveChart = (index, direction) => {
if (direction === 'up' && index > 0) {
[tempChartConfigs.value[index], tempChartConfigs.value[index - 1]] =
[tempChartConfigs.value[index - 1], tempChartConfigs.value[index]];
} else if (direction === 'down' && index < tempChartConfigs.value.length - 1) {
[tempChartConfigs.value[index], tempChartConfigs.value[index + 1]] =
[tempChartConfigs.value[index + 1], tempChartConfigs.value[index]];
}
};
// 应用设置
const applySettings = async () => {
try {
saving.value = true;
if (Array.isArray(tempChartConfigs.value)) {
persistedChartConfigs.value = JSON.parse(JSON.stringify(tempChartConfigs.value));
emits('config-updated', persistedChartConfigs.value);
// 更新初始快照为当前已应用的配置
initialChartConfigs.value = JSON.parse(JSON.stringify(tempChartConfigs.value));
ElMessage.success('配置已保存并生效');
} else {
throw new Error('配置数据格式错误,必须为数组');
}
} catch (error) {
console.error('保存配置失败:', error);
ElMessage.error('保存配置失败,请重试');
} finally {
saving.value = false;
}
};
// 定义事件
const emits = defineEmits(['config-updated', 'close']);
// 暴露方法给父组件
defineExpose({
resetToSession,
applySettings
});
</script>
<style scoped>
/* 保持原有样式不变 */
.chart-settings-panel {
display: flex;
flex-direction: column;
height: 100%;
min-width: 600px;
padding: 10px;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
}
.panel-title {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
}
.settings-content {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.layout-preview {
padding: 12px;
background-color: #f5f7fa;
border-radius: 6px;
}
.preview-container {
width: 100%;
overflow: hidden;
}
.preview-row {
width: 100%;
margin-bottom: 10px !important;
}
.preview-col {
padding: 5px !important;
}
.preview-col-hidden {
opacity: 0.3;
}
.preview-chart {
background-color: white;
border: 1px dashed #ccc;
border-radius: 4px;
height: 80px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 8px;
background-color: #f0f2f5;
font-size: 12px;
border-bottom: 1px dashed #ccc;
}
.chart-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chart-placeholder {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #888;
}
.charts-config {
flex: 1;
display: flex;
flex-direction: column;
}
.settings-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #eee;
}
@media (max-width: 768px) {
.chart-settings-panel {
min-width: auto;
width: 100%;
}
.settings-content {
gap: 12px;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<!-- 布局组件传入状态与方法 -->
<DashboardLayout
:title="title"
:loading="loading"
:is-refreshing="isRefreshing"
:handle-back="handleBack"
:handle-refresh="handleRefresh"
>
<!-- 图表网格组件传入数据源 -->
<DashboardChartsGrid
:orders="orders"
:order-details="orderDetails"
:customers="customers"
:return-exchanges="returnExchanges"
:is-refreshing="isRefreshing"
/>
</DashboardLayout>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
// 导入子组件
import DashboardLayout from './layout/index.vue';
import DashboardChartsGrid from './grid/index.vue';
// API 请求(业务逻辑)
import { listOrder } from '@/api/wms/order';
import { listOrderDetail } from '@/api/wms/orderDetail';
import { listReturnExchange } from '@/api/oa/returnExchange';
import { listCustomer } from '@/api/wms/customer';
// 路由实例
const router = useRouter();
// 1. 页面状态(供子组件使用)
const title = ref('销售数据可视化大屏');
const loading = ref(false); // 加载态(全屏加载)
const isRefreshing = ref(false); // 刷新动画状态
// 2. 图表数据源(供图表组件使用)
const customers = ref([]);
const orders = ref([]);
const orderDetails = ref([]);
const returnExchanges = ref([]);
// 3. 生命周期:页面初始化加载数据
onMounted(() => {
fetchAllData();
});
// 4. 核心方法:统一请求所有数据
const fetchAllData = async () => {
try {
loading.value = true;
// 并行请求(提升加载效率)
const [customerRes, orderRes, detailRes, returnRes] = await Promise.all([
listCustomer({ pageNum: 1, pageSize: 1000 }),
listOrder({ pageNum: 1, pageSize: 1000 }),
listOrderDetail({ pageNum: 1, pageSize: 1000 }),
listReturnExchange({ pageNum: 1, pageSize: 1000 })
]);
// 适配接口返回格式(兼容 data/rows 两种常见结构)
customers.value = customerRes.data || customerRes.rows || [];
orders.value = orderRes.data || orderRes.rows || [];
orderDetails.value = detailRes.data || detailRes.rows || [];
returnExchanges.value = returnRes.data || returnRes.rows || [];
} catch (error) {
console.error('数据加载失败:', error);
ElMessage.error('数据加载失败,请重试');
} finally {
loading.value = false;
isRefreshing.value = false; // 结束刷新动画
}
};
// 5. 导航方法:返回上一页
const handleBack = () => {
router.back();
};
// 6. 刷新方法:触发数据重新加载
const handleRefresh = () => {
isRefreshing.value = true; // 启动刷新动画
// 延迟触发(确保动画可见,避免数据加载过快导致动画闪烁)
setTimeout(() => {
fetchAllData();
}, 300);
};
</script>

View File

@@ -1,18 +1,37 @@
<template>
<div ref="fullscreenTarget" class="dashboard-container">
<Layout></Layout>
<div ref="fullscreenTarget" class="dashboard-container" id="full-dashboard-container">
<!-- 大屏介绍区域 - 全屏时隐藏 -->
<div v-if="!isFullscreen" class="dashboard-intro">
<div class="intro-content">
<h2>数据可视化大屏</h2>
<p>本大屏展示了系统核心数据指标和关键业务指标通过直观的可视化方式呈现数据趋势和分析结果</p>
<ul>
<li>实时监控关键业务数据</li>
<li>多维度数据可视化分析</li>
<li>自定义数据筛选与展示</li>
</ul>
<button @click="handleEnterFullscreen" class="enter-fullscreen-btn">
打开大屏 <i class="icon-fullscreen"></i>
</button>
</div>
</div>
<!-- 大屏内容 - 仅在全屏时显示 -->
<Home v-if="isFullscreen" />
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import Layout from './layout.vue'
// 从 vue-router 导入路由钩子,而不是从 vue 导入
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { useRouter } from 'vue-router';
import Home from './home.vue'
import { onBeforeRouteLeave } from 'vue-router';
import { useFullscreen } from '@vueuse/core';
import { debounce } from '@/utils/index';
// 路由实例
const router = useRouter();
// 全屏目标元素
const fullscreenTarget = ref(null);
@@ -49,26 +68,35 @@ const handleEnterFullscreen = () => {
}
};
// 组件挂载后自动全屏
onMounted(() => {
// 延迟执行以确保DOM已完全渲染
const documentWidth = document.body.offsetWidth;
const ratio = documentWidth / 1920;
if (documentWidth > 1920) {
document.body.style.transform = `scale(${ratio}, ${ratio})`
}
const resizeFn = debounce(function () {
const documentWidth = document.body.offsetWidth;
const ratio = documentWidth / 1920;
if (documentWidth > 1920) {
document.body.style.transform = `scale(${ratio}, ${ratio})`
}
}, 200)
window.addEventListener('resize', resizeFn);
setTimeout(handleEnterFullscreen, 100);
// 当退出全屏时回到首页
watch(isFullscreen, (newVal) => {
if (!newVal) {
router.push('/');
}
});
// 处理窗口大小变化的函数
const resizeFn = debounce(function () {
const documentWidth = document.body.offsetWidth;
const ratio = documentWidth / 1920;
if (documentWidth > 1920) {
document.body.style.transform = `scale(${ratio}, ${ratio})`;
}
}, 200);
// 组件挂载后自动全屏
// onMounted(() => {
// const documentWidth = document.body.offsetWidth;
// const ratio = documentWidth / 1920;
// if (documentWidth > 1920) {
// document.body.style.transform = `scale(${ratio}, ${ratio})`;
// }
// window.addEventListener('resize', resizeFn);
// setTimeout(handleEnterFullscreen, 100);
// });
// 路由离开前退出全屏
onBeforeRouteLeave((to, from, next) => {
if (isFullscreen.value) {
@@ -83,19 +111,93 @@ onBeforeUnmount(() => {
if (isFullscreen.value) {
exit();
}
window.removeEventListener('resize', resizeFn);
});
</script>
<style scoped>
.dashboard-container {
width: 100vw;
height: 100vh;
/* background: url('@/assets/images/dashboard/normal_bg.png') center center no-repeat; */
background-size: 100% 100%;
color: #fff;
position: relative;
overflow: hidden;
}
/* 大屏介绍区域样式 */
.dashboard-intro {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: 24px;
padding: 2rem;
box-sizing: border-box;
}
.intro-content {
max-width: 800px;
text-align: center;
padding: 3rem;
border-radius: 10px;
backdrop-filter: blur(10px);
}
.intro-content h2 {
font-size: 2.5rem;
margin-bottom: 1.5rem;
color: #4cc9f0;
}
.intro-content p {
font-size: 1.2rem;
margin-bottom: 2rem;
line-height: 1.6;
}
.intro-content ul {
list-style: none;
padding: 0;
margin-bottom: 2.5rem;
text-align: left;
}
.intro-content li {
font-size: 1.1rem;
margin-bottom: 1rem;
padding-left: 1.5rem;
position: relative;
}
.intro-content li:before {
content: "•";
color: #4cc9f0;
font-size: 1.5rem;
position: absolute;
left: 0;
top: -5px;
}
/* 打开大屏按钮样式 */
.enter-fullscreen-btn {
background-color: #4cc9f0;
color: #1a1a2e;
border: none;
padding: 0.8rem 2rem;
font-size: 1.2rem;
border-radius: 5px;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.enter-fullscreen-btn:hover {
background-color: #4361ee;
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(76, 201, 240, 0.2);
}
.icon-fullscreen {
font-size: 1.3rem;
}
</style>

View File

@@ -1,176 +0,0 @@
<template>
<div class="dashboard-layout">
<!-- 顶部导航栏 -->
<header class="layout-header">
<button
class="header-btn back-btn"
@click="handleBack"
aria-label="返回"
>
<el-icon><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<h1 class="layout-title">{{ title }}</h1>
<div style="display: flex;align-items: center;gap: 10px;">
<button class="header-btn" aria-label="设置">
<el-icon><Setting /></el-icon>
<span>设置</span>
</button>
<button
class="header-btn refresh-btn"
@click="handleRefresh"
aria-label="刷新"
>
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</button>
</div>
</header>
<!-- 图表内容区域 -->
<main class="charts-container">
<!-- 图表网格布局 -->
<div class="charts-grid">
<div>图表1</div>
<div>图表2</div>
<div>图表3</div>
<div>图表4</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { ArrowLeft, Refresh, Setting } from '@element-plus/icons-vue';
// 路由实例
const router = useRouter();
// 大屏标题
const title = ref('数据可视化大屏');
// 返回上一页
const handleBack = () => {
router.back();
};
// 刷新数据
const handleRefresh = () => {
// 这里可以实现数据刷新逻辑
// 示例:触发所有图表组件重新加载数据
const event = new CustomEvent('refresh-data');
window.dispatchEvent(event);
// 可选:添加刷新动画效果
const refreshBtn = document.querySelector('.refresh-btn i');
refreshBtn.classList.add('refreshing');
setTimeout(() => {
refreshBtn.classList.remove('refreshing');
}, 800);
};
</script>
<style scoped>
.dashboard-layout {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 顶部导航样式 */
.layout-header {
height: 60px;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
background: url('@/assets/images/dashboard/title_bg.png') center center no-repeat;
background-size: 100% 100%;
border-bottom: 1px solid #334155;
z-index: 10;
}
.layout-title {
font-size: 20px;
font-weight: 600;
color: #f8fafc;
margin: 0;
}
.header-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: #334155;
color: #f8fafc;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.header-btn:hover {
background-color: #475569;
}
.header-btn i {
font-size: 16px;
}
/* 图标样式 */
.icon-arrow-left::before {
content: '←';
}
.icon-refresh::before {
content: '↺';
}
.refreshing {
animation: spin 0.8s linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 图表容器样式 */
.charts-container {
flex: 1;
padding: 20px;
overflow: auto;
box-sizing: border-box;
}
.charts-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 20px;
height: 100%;
}
.chart-item {
background-color: #1e293b;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.charts-grid {
grid-template-columns: 1fr;
grid-template-rows: repeat(4, 1fr);
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<div class="dashboard-layout">
<!-- 顶部导航栏通用布局 -->
<header class="layout-header">
<button
class="header-btn back-btn"
@click="handleBack"
aria-label="返回"
>
<el-icon><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<h1 class="layout-title">{{ title }}</h1>
<div style="display: flex;align-items: center;gap: 10px;">
<button class="header-btn" aria-label="设置" @click="settingVisible = true">
<el-icon><Setting /></el-icon>
<span>设置</span>
</button>
<button
class="header-btn refresh-btn"
@click="handleRefresh"
aria-label="刷新"
:disabled="loading"
:class="{ refreshing: isRefreshing }"
>
<el-icon><Refresh /></el-icon>
<span>刷新</span>
</button>
</div>
</header>
<!-- 内容插槽用于插入图表网格 -->
<main class="layout-content" v-loading="loading">
<slot></slot>
</main>
<el-dialog v-model="settingVisible" title="图表设置" width="50%">
<ChartSetting />
</el-dialog>
</div>
</template>
<script setup>
import { ArrowLeft, Refresh, Setting } from '@element-plus/icons-vue';
import ChartSetting from '../grid/setting.vue';
// 接收外部传入的状态与方法props 类型约束)
const props = defineProps({
title: {
type: String,
required: true,
default: '数据可视化大屏'
},
loading: {
type: Boolean,
required: true,
default: false
},
isRefreshing: {
type: Boolean,
required: true,
default: false
},
handleBack: {
type: Function,
required: true
},
handleRefresh: {
type: Function,
required: true
}
});
const settingVisible = ref(false);
</script>
<style scoped>
/* 布局根容器样式 */
.dashboard-layout {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: #0f172a; /* 深色背景统一在布局层定义 */
}
/* 顶部导航样式 */
.layout-header {
height: 60px;
padding: 0 20px;
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(120deg, #1e293b, #0f172a);
border-bottom: 1px solid #334155;
z-index: 10;
}
.layout-title {
font-size: 20px;
font-weight: 600;
color: #f8fafc;
margin: 0;
}
.header-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: #334155;
color: #f8fafc;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.header-btn:hover {
background-color: #475569;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.1);
}
.header-btn:disabled {
background-color: #2d3748;
cursor: not-allowed;
opacity: 0.8;
}
.header-btn i {
font-size: 16px;
}
/* 刷新按钮动画 */
.refreshing el-icon {
animation: spin 0.8s linear;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 内容区域容器(插槽父容器) */
.layout-content {
flex: 1;
overflow: hidden; /* 避免与子组件滚动冲突 */
}
/* 小屏幕适配(导航栏) */
@media (max-width: 768px) {
.layout-title {
font-size: 16px;
}
.header-btn span {
display: none; /* 隐藏按钮文字节省空间 */
}
.header-btn {
padding: 6px 8px;
}
}
</style>