✨ feat: 数据大屏
This commit is contained in:
@@ -93,4 +93,8 @@
|
||||
|
||||
.el-dropdown .el-dropdown-link{
|
||||
color: var(--el-color-primary) !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown {
|
||||
z-index: 9999 !important; /* 需大于全屏容器的z-index */
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export default {
|
||||
computed: {
|
||||
filteredMenus() {
|
||||
const filterHidden = (menus) => {
|
||||
console.log(menus)
|
||||
return menus
|
||||
.filter(menu => menu.hidden !== true)
|
||||
.map(menu => {
|
||||
|
||||
187
gear-ui3/src/views/dashboard/components/CustomerFollowStatus.vue
Normal file
187
gear-ui3/src/views/dashboard/components/CustomerFollowStatus.vue
Normal 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>
|
||||
251
gear-ui3/src/views/dashboard/components/OrderTrendChart.vue
Normal file
251
gear-ui3/src/views/dashboard/components/OrderTrendChart.vue
Normal 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>
|
||||
203
gear-ui3/src/views/dashboard/components/ProductSalesRank.vue
Normal file
203
gear-ui3/src/views/dashboard/components/ProductSalesRank.vue
Normal 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>
|
||||
@@ -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>
|
||||
204
gear-ui3/src/views/dashboard/components/SalesByCustomerChart.vue
Normal file
204
gear-ui3/src/views/dashboard/components/SalesByCustomerChart.vue
Normal 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>
|
||||
195
gear-ui3/src/views/dashboard/components/SalesByManagerChart.vue
Normal file
195
gear-ui3/src/views/dashboard/components/SalesByManagerChart.vue
Normal 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>
|
||||
246
gear-ui3/src/views/dashboard/grid/index.vue
Normal file
246
gear-ui3/src/views/dashboard/grid/index.vue
Normal 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-24,24为整行)
|
||||
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存入localStorage,key为"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需要:orders,ProductSalesRank需要: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>
|
||||
416
gear-ui3/src/views/dashboard/grid/setting.vue
Normal file
416
gear-ui3/src/views/dashboard/grid/setting.vue
Normal 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>
|
||||
93
gear-ui3/src/views/dashboard/home.vue
Normal file
93
gear-ui3/src/views/dashboard/home.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
165
gear-ui3/src/views/dashboard/layout/index.vue
Normal file
165
gear-ui3/src/views/dashboard/layout/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user