|
|
|
|
@@ -1,631 +1,353 @@
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
<ContentWrap>
|
|
|
|
|
|
|
|
|
|
<div class="app-container">
|
|
|
|
|
<!-- 头部区域 -->
|
|
|
|
|
<!-- <header class="app-header">
|
|
|
|
|
<h1 class="title">
|
|
|
|
|
<i class="el-icon-menu"></i>
|
|
|
|
|
|
|
|
|
|
</h1>
|
|
|
|
|
</header> -->
|
|
|
|
|
|
|
|
|
|
<el-table
|
|
|
|
|
:data="processedTableData" :span-method="objectSpanMethod" border stripe
|
|
|
|
|
height="calc(100vh - 300px)" style="width: 100%" v-loading="loading" :summary-method="getSummaries"
|
|
|
|
|
show-summary :summary-text="summaryText">
|
|
|
|
|
<!-- 动态生成表头 -->
|
|
|
|
|
<template v-for="dim in selectedDimensions" :key="dim">
|
|
|
|
|
<el-table-column
|
|
|
|
|
:prop="dim" :label="getDimensionName(dim)" :min-width="getColumnWidth(dim)"
|
|
|
|
|
show-overflow-tooltip>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span>{{ row[dim] || '-' }}</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<!-- 统计指标列 -->
|
|
|
|
|
<el-table-column prop="salesAmount" label="销售额(万)" width="120" sortable>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
{{ formatNumber(row.salesAmount) }}
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<!-- 搜索区域 -->
|
|
|
|
|
<!-- <div class="search-container">
|
|
|
|
|
<el-input
|
|
|
|
|
v-model="searchQuery"
|
|
|
|
|
placeholder="搜索应用..."
|
|
|
|
|
clearable
|
|
|
|
|
prefix-icon="el-icon-search"
|
|
|
|
|
class="search-input"
|
|
|
|
|
></el-input>
|
|
|
|
|
</div> -->
|
|
|
|
|
|
|
|
|
|
<el-table-column prop="salesQuantity" label="销售数量" width="120" sortable>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
{{ formatNumber(row.salesQuantity) }}
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
<!-- 空状态 -->
|
|
|
|
|
<div v-if="Object.keys(filteredCategories).length === 0" class="empty-state">
|
|
|
|
|
<i class="el-icon-warning empty-icon"></i>
|
|
|
|
|
<h3>未找到匹配的应用</h3>
|
|
|
|
|
<p>请尝试调整搜索关键词</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<el-table-column prop="growthRate" label="同比增长" width="120" sortable>
|
|
|
|
|
<template #default="{ row }">
|
|
|
|
|
<span :class="getGrowthClass(row.growthRate)">
|
|
|
|
|
{{ formatPercent(row.growthRate) }}
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-table-column>
|
|
|
|
|
</el-table>
|
|
|
|
|
|
|
|
|
|
</ContentWrap>
|
|
|
|
|
<!-- 分类应用展示 -->
|
|
|
|
|
<div
|
|
|
|
|
v-for="(category, categoryName) in filteredCategories"
|
|
|
|
|
:key="categoryName"
|
|
|
|
|
class="category-section">
|
|
|
|
|
<div class="category-header">
|
|
|
|
|
<h2 class="category-title">{{ categoryName }}</h2>
|
|
|
|
|
<el-tag size="small" class="app-count">{{ category.apps.length }} 个应用</el-tag>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="apps-grid">
|
|
|
|
|
<div
|
|
|
|
|
v-for="app in category.apps"
|
|
|
|
|
:key="app.id"
|
|
|
|
|
class="app-card"
|
|
|
|
|
@click="handleAppClick(app)">
|
|
|
|
|
<div class="app-icon-wrapper">
|
|
|
|
|
<i :class="app.iconClass" class="app-icon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="app-name">{{ app.name }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- 应用详情对话框 -->
|
|
|
|
|
<el-dialog
|
|
|
|
|
v-model:visible="dialogVisible"
|
|
|
|
|
title="应用详情"
|
|
|
|
|
width="30%"
|
|
|
|
|
custom-class="app-dialog">
|
|
|
|
|
<div v-if="selectedApp" class="dialog-content">
|
|
|
|
|
<div class="dialog-icon-wrapper">
|
|
|
|
|
<i :class="selectedApp.iconClass" class="dialog-app-icon"></i>
|
|
|
|
|
</div>
|
|
|
|
|
<h3 class="dialog-app-name">{{ selectedApp.name }}</h3>
|
|
|
|
|
<p class="dialog-app-id">ID: {{ selectedApp.id }}</p>
|
|
|
|
|
<p class="dialog-app-category" v-if="selectedApp.category">分类: {{ selectedApp.category }}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<template #footer>
|
|
|
|
|
<span class="dialog-footer">
|
|
|
|
|
<el-button @click="dialogVisible = false">取消</el-button>
|
|
|
|
|
<el-button type="primary" @click="confirmLaunch">启动</el-button>
|
|
|
|
|
</span>
|
|
|
|
|
</template>
|
|
|
|
|
</el-dialog>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
|
|
import { Check } from '@element-plus/icons-vue'
|
|
|
|
|
import * as echarts from 'echarts'
|
|
|
|
|
|
|
|
|
|
// 维度定义 - 7个维度
|
|
|
|
|
const dimensions = ref([
|
|
|
|
|
{ id: 'salesType', name: '销售类型' },
|
|
|
|
|
{ id: 'customer', name: '客户' },
|
|
|
|
|
{ id: 'dosageForm', name: '剂型' },
|
|
|
|
|
{ id: 'product', name: '产品' },
|
|
|
|
|
{ id: 'administrativeArea', name: '行政区域' },
|
|
|
|
|
{ id: 'salesman', name: '业务员' },
|
|
|
|
|
{ id: 'specification', name: '品规' }
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
// 状态管理 - 默认选中所有维度
|
|
|
|
|
const selectedDimensions = ref(dimensions.value.map(d => d.id))
|
|
|
|
|
const primaryDimension = ref('product') // 默认主维度为产品
|
|
|
|
|
const tableData = ref([])
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
const lastUpdate = ref('')
|
|
|
|
|
const showChart = ref(true)
|
|
|
|
|
const activeChartTab = ref('bar')
|
|
|
|
|
|
|
|
|
|
// 合计行相关状态
|
|
|
|
|
const showSummaryRow = ref(true)
|
|
|
|
|
const totalStats = ref(null)
|
|
|
|
|
const summaryText = computed(() => showSummaryRow.value ? '合计' : '')
|
|
|
|
|
|
|
|
|
|
// 计算属性
|
|
|
|
|
const processedTableData = computed(() => {
|
|
|
|
|
return tableData.value.map(item => ({
|
|
|
|
|
...item,
|
|
|
|
|
formattedAmount: formatNumber(item.salesAmount),
|
|
|
|
|
formattedQuantity: formatNumber(item.salesQuantity),
|
|
|
|
|
formattedGrowth: formatPercent(item.growthRate)
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 方法定义
|
|
|
|
|
const toggleDimension = (dimensionId) => {
|
|
|
|
|
const index = selectedDimensions.value.indexOf(dimensionId)
|
|
|
|
|
if (index > -1) {
|
|
|
|
|
// 如果当前维度是主维度,且只有一个维度被选中,不能移除
|
|
|
|
|
if (selectedDimensions.value.length === 1) {
|
|
|
|
|
ElMessage.warning('至少需要保留一个维度')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果要移除的是当前主维度,则自动切换到下一个维度
|
|
|
|
|
if (dimensionId === primaryDimension.value) {
|
|
|
|
|
// 找下一个可用的维度作为主维度
|
|
|
|
|
const nextDim = selectedDimensions.value.find(d => d !== dimensionId)
|
|
|
|
|
if (nextDim) {
|
|
|
|
|
primaryDimension.value = nextDim
|
|
|
|
|
<script>
|
|
|
|
|
export default {
|
|
|
|
|
name: 'AppManager',
|
|
|
|
|
data() {
|
|
|
|
|
return {
|
|
|
|
|
searchQuery: '',
|
|
|
|
|
dialogVisible: false,
|
|
|
|
|
selectedApp: null,
|
|
|
|
|
appsData: {
|
|
|
|
|
统计报表: [
|
|
|
|
|
{id: 'office1', name: '驾驶舱总屏幕', iconClass: 'el-icon-document'},
|
|
|
|
|
{id: 'office2', name: '销售报表', iconClass: 'el-icon-tickets'},
|
|
|
|
|
{id: 'office3', name: '生产报表', iconClass: 'el-icon-present'}
|
|
|
|
|
],
|
|
|
|
|
工业互联网: [
|
|
|
|
|
{id: 'dev1', name: '产线组态', iconClass: 'el-icon-setting'}
|
|
|
|
|
],
|
|
|
|
|
数据中台: [
|
|
|
|
|
{id: 'fun1', name: '数据采集', iconClass: 'el-icon-headset'},
|
|
|
|
|
// {id: 'fun2', name: '视频观看', iconClass: 'el-icon-video-play'},
|
|
|
|
|
// {id: 'fun3', name: '游戏中心', iconClass: 'el-icon-game'},
|
|
|
|
|
// {id: 'fun4', name: '阅读器', iconClass: 'el-icon-reading'}
|
|
|
|
|
]
|
|
|
|
|
// 系统工具: [
|
|
|
|
|
// {id: 'sys1', name: '系统监控', iconClass: 'el-icon-monitor'},
|
|
|
|
|
// {id: 'sys2', name: '磁盘清理', iconClass: 'el-icon-delete-solid'},
|
|
|
|
|
// {id: 'sys3', name: '网络诊断', iconClass: 'el-icon-mobile-phone'},
|
|
|
|
|
// {id: 'sys4', name: '安全防护', iconClass: 'el-icon-lock'}
|
|
|
|
|
// ]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
selectedDimensions.value.splice(index, 1)
|
|
|
|
|
} else {
|
|
|
|
|
selectedDimensions.value.push(dimensionId)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getDimensionName = (dimensionId) => {
|
|
|
|
|
const dim = dimensions.value.find(d => d.id === dimensionId)
|
|
|
|
|
return dim ? dim.name : dimensionId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 单元格合并逻辑
|
|
|
|
|
const objectSpanMethod = ({ row, column, rowIndex, columnIndex }) => {
|
|
|
|
|
if (!primaryDimension.value) return { rowspan: 1, colspan: 1 }
|
|
|
|
|
|
|
|
|
|
const prop = column.property
|
|
|
|
|
if (prop !== primaryDimension.value) return { rowspan: 1, colspan: 1 }
|
|
|
|
|
|
|
|
|
|
// 计算相同值的行数
|
|
|
|
|
let spanCount = 1
|
|
|
|
|
for (let i = rowIndex + 1; i < tableData.value.length; i++) {
|
|
|
|
|
if (tableData.value[i][prop] === row[prop]) {
|
|
|
|
|
spanCount++
|
|
|
|
|
} else {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果是第一行,返回合并
|
|
|
|
|
let isFirstRow = true
|
|
|
|
|
for (let i = rowIndex - 1; i >= 0; i--) {
|
|
|
|
|
if (tableData.value[i][prop] === row[prop]) {
|
|
|
|
|
isFirstRow = false
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isFirstRow && spanCount > 1) {
|
|
|
|
|
return { rowspan: spanCount, colspan: 1 }
|
|
|
|
|
} else {
|
|
|
|
|
return { rowspan: 0, colspan: 0 }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 自定义合计方法 - 包含同比增长的加权平均计算
|
|
|
|
|
const getSummaries = (param) => {
|
|
|
|
|
const { columns, data } = param
|
|
|
|
|
const sums = []
|
|
|
|
|
|
|
|
|
|
columns.forEach((column, index) => {
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
sums[index] = showSummaryRow.value ? '合计' : ''
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 如果不是数值列,不显示合计
|
|
|
|
|
if (!['salesAmount', 'salesQuantity', 'growthRate'].includes(column.property)) {
|
|
|
|
|
sums[index] = ''
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 计算数值列的总和或加权平均
|
|
|
|
|
const values = data.map(item => Number(item[column.property]))
|
|
|
|
|
|
|
|
|
|
if (!values.every(value => isNaN(value))) {
|
|
|
|
|
if (column.property === 'salesAmount' || column.property === 'salesQuantity') {
|
|
|
|
|
// 销售额和销售数量直接求和
|
|
|
|
|
const sum = values.reduce((prev, curr) => {
|
|
|
|
|
const value = Number(curr)
|
|
|
|
|
if (!isNaN(value)) {
|
|
|
|
|
return prev + curr
|
|
|
|
|
} else {
|
|
|
|
|
return prev
|
|
|
|
|
}
|
|
|
|
|
}, 0)
|
|
|
|
|
|
|
|
|
|
if (column.property === 'salesAmount') {
|
|
|
|
|
sums[index] = formatNumber(sum)
|
|
|
|
|
} else if (column.property === 'salesQuantity') {
|
|
|
|
|
sums[index] = formatNumber(sum)
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
computed: {
|
|
|
|
|
categorizedApps() {
|
|
|
|
|
const result = {};
|
|
|
|
|
for (const [category, apps] of Object.entries(this.appsData)) {
|
|
|
|
|
result[category] = {
|
|
|
|
|
apps: apps.map(app => ({...app, category}))
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
},
|
|
|
|
|
filteredCategories() {
|
|
|
|
|
if (!this.searchQuery.trim()) {
|
|
|
|
|
return this.categorizedApps;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const query = this.searchQuery.toLowerCase();
|
|
|
|
|
const result = {};
|
|
|
|
|
|
|
|
|
|
for (const [categoryName, categoryData] of Object.entries(this.categorizedApps)) {
|
|
|
|
|
const filteredApps = categoryData.apps.filter(app =>
|
|
|
|
|
app.name.toLowerCase().includes(query) ||
|
|
|
|
|
app.category.toLowerCase().includes(query)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (filteredApps.length > 0) {
|
|
|
|
|
result[categoryName] = {...categoryData, apps: filteredApps};
|
|
|
|
|
}
|
|
|
|
|
} else if (column.property === 'growthRate') {
|
|
|
|
|
// 同比增长率需要计算加权平均值(按销售额加权)
|
|
|
|
|
let totalAmount = 0
|
|
|
|
|
let weightedGrowthSum = 0
|
|
|
|
|
|
|
|
|
|
data.forEach(item => {
|
|
|
|
|
const amount = Number(item.salesAmount) || 0
|
|
|
|
|
const growth = Number(item.growthRate) || 0
|
|
|
|
|
|
|
|
|
|
if (!isNaN(amount) && !isNaN(growth)) {
|
|
|
|
|
totalAmount += amount
|
|
|
|
|
weightedGrowthSum += growth * amount
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const avgGrowth = totalAmount > 0 ? weightedGrowthSum / totalAmount : 0
|
|
|
|
|
sums[index] = formatPercent(avgGrowth)
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
sums[index] = '-'
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
},
|
|
|
|
|
totalApps() {
|
|
|
|
|
return Object.values(this.appsData).reduce((total, apps) => total + apps.length, 0);
|
|
|
|
|
},
|
|
|
|
|
categoriesCount() {
|
|
|
|
|
return Object.keys(this.appsData).length;
|
|
|
|
|
},
|
|
|
|
|
visibleCategories() {
|
|
|
|
|
return Object.keys(this.filteredCategories).length;
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return sums
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 数据获取
|
|
|
|
|
const fetchData = async () => {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
|
|
|
|
// 使用模拟数据
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
tableData.value = generateMockData()
|
|
|
|
|
|
|
|
|
|
// 计算并保存总统计数据
|
|
|
|
|
calculateTotalStats()
|
|
|
|
|
|
|
|
|
|
lastUpdate.value = new Date().toLocaleString()
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
|
|
|
|
// 更新图表
|
|
|
|
|
if (showChart.value) {
|
|
|
|
|
updateCharts()
|
|
|
|
|
}
|
|
|
|
|
}, 1000)
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('获取数据失败:', error)
|
|
|
|
|
loading.value = false
|
|
|
|
|
ElMessage.error('获取数据失败')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 计算总统计数据
|
|
|
|
|
const calculateTotalStats = () => {
|
|
|
|
|
const totalAmount = tableData.value.reduce((sum, row) => sum + (row.salesAmount || 0), 0)
|
|
|
|
|
const totalQuantity = tableData.value.reduce((sum, row) => sum + (row.salesQuantity || 0), 0)
|
|
|
|
|
|
|
|
|
|
// 计算加权平均增长率(按销售额加权)
|
|
|
|
|
const weightedGrowthSum = tableData.value.reduce(
|
|
|
|
|
(sum, row) => sum + (row.growthRate || 0) * (row.salesAmount || 0), 0
|
|
|
|
|
)
|
|
|
|
|
const avgGrowth = totalAmount > 0 ? weightedGrowthSum / totalAmount : 0
|
|
|
|
|
|
|
|
|
|
totalStats.value = {
|
|
|
|
|
totalAmount,
|
|
|
|
|
totalQuantity,
|
|
|
|
|
avgGrowth
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 生成模拟数据
|
|
|
|
|
const generateMockData = () => {
|
|
|
|
|
const data = []
|
|
|
|
|
|
|
|
|
|
// 定义各维度的取值范围
|
|
|
|
|
const dimValues = {
|
|
|
|
|
salesType: ['零售', '批发', '直销'],
|
|
|
|
|
customer: ['客户1', '客户2', '客户3', '客户4', '客户5'],
|
|
|
|
|
dosageForm: ['片剂', '胶囊', '颗粒', '口服液'],
|
|
|
|
|
product: ['产品A', '产品B', '产品C', '产品D'],
|
|
|
|
|
administrativeArea: ['上海', '北京', '广州', '深圳', '杭州'],
|
|
|
|
|
salesman: ['张三', '李四', '王五', '赵六'],
|
|
|
|
|
specification: ['10mg*20片', '20mg*30片', '50mg*10片', '100mg*5片']
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 为每个产品生成一些数据
|
|
|
|
|
const products = dimValues.product
|
|
|
|
|
const rowCount = 100 // 生成100行数据
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < rowCount; i++) {
|
|
|
|
|
const row = {}
|
|
|
|
|
|
|
|
|
|
// 为每个维度生成随机值
|
|
|
|
|
dimensions.value.forEach(dim => {
|
|
|
|
|
const values = dimValues[dim.id] || ['默认值']
|
|
|
|
|
row[dim.id] = values[Math.floor(Math.random() * values.length)]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 添加统计指标
|
|
|
|
|
row.salesAmount = Math.random() * 200 + 50
|
|
|
|
|
row.salesQuantity = Math.floor(Math.random() * 5000) + 1000
|
|
|
|
|
row.growthRate = (Math.random() * 0.3 - 0.1) // -10% 到 20%
|
|
|
|
|
|
|
|
|
|
data.push(row)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 按主维度排序
|
|
|
|
|
return data.sort((a, b) => {
|
|
|
|
|
const aValue = a[primaryDimension.value] || ''
|
|
|
|
|
const bValue = b[primaryDimension.value] || ''
|
|
|
|
|
return aValue.localeCompare(bValue)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 辅助方法
|
|
|
|
|
const formatNumber = (num) => {
|
|
|
|
|
if (num === undefined || num === null) return '-'
|
|
|
|
|
return Number(num).toLocaleString('zh-CN', {
|
|
|
|
|
minimumFractionDigits: 2,
|
|
|
|
|
maximumFractionDigits: 2
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const formatPercent = (num) => {
|
|
|
|
|
if (num === undefined || num === null) return '-'
|
|
|
|
|
return (num * 100).toFixed(2) + '%'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getGrowthClass = (rate) => {
|
|
|
|
|
if (rate > 0) return 'positive'
|
|
|
|
|
if (rate < 0) return 'negative'
|
|
|
|
|
return 'neutral'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const getColumnWidth = (dimensionId) => {
|
|
|
|
|
const widthMap = {
|
|
|
|
|
salesType: 100,
|
|
|
|
|
customer: 120,
|
|
|
|
|
dosageForm: 80,
|
|
|
|
|
product: 100,
|
|
|
|
|
administrativeArea: 100,
|
|
|
|
|
salesman: 100,
|
|
|
|
|
specification: 120
|
|
|
|
|
}
|
|
|
|
|
return widthMap[dimensionId] || 100
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 图表相关
|
|
|
|
|
let barChartInstance = null
|
|
|
|
|
let pieChartInstance = null
|
|
|
|
|
|
|
|
|
|
const updateCharts = () => {
|
|
|
|
|
if (barChartInstance) {
|
|
|
|
|
barChartInstance.dispose()
|
|
|
|
|
}
|
|
|
|
|
if (pieChartInstance) {
|
|
|
|
|
pieChartInstance.dispose()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 更新柱状图
|
|
|
|
|
const barElement = document.querySelector('.chart-container .el-tab-pane:first-child div')
|
|
|
|
|
if (barElement) {
|
|
|
|
|
barChartInstance = echarts.init(barElement)
|
|
|
|
|
|
|
|
|
|
// 按主维度分组数据
|
|
|
|
|
const groups = {}
|
|
|
|
|
tableData.value.forEach(d => {
|
|
|
|
|
const key = d[primaryDimension.value] || '其他'
|
|
|
|
|
if (!groups[key]) {
|
|
|
|
|
groups[key] = 0
|
|
|
|
|
}
|
|
|
|
|
groups[key] += d.salesAmount || 0
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const barOption = {
|
|
|
|
|
title: { text: `${getDimensionName(primaryDimension.value)}销售额分布`, left: 'center' },
|
|
|
|
|
tooltip: {
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
formatter: '{b}: {c}万元'
|
|
|
|
|
},
|
|
|
|
|
xAxis: {
|
|
|
|
|
type: 'category',
|
|
|
|
|
data: Object.keys(groups)
|
|
|
|
|
},
|
|
|
|
|
yAxis: {
|
|
|
|
|
type: 'value',
|
|
|
|
|
name: '销售额(万)'
|
|
|
|
|
},
|
|
|
|
|
series: [{
|
|
|
|
|
data: Object.values(groups),
|
|
|
|
|
type: 'bar',
|
|
|
|
|
itemStyle: {
|
|
|
|
|
color: '#409eff'
|
|
|
|
|
}
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
barChartInstance.setOption(barOption)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 更新饼图
|
|
|
|
|
const pieElement = document.querySelector('.chart-container .el-tab-pane:last-child div')
|
|
|
|
|
if (pieElement) {
|
|
|
|
|
pieChartInstance = echarts.init(pieElement)
|
|
|
|
|
|
|
|
|
|
// 按第一个维度分组数据
|
|
|
|
|
const firstDim = selectedDimensions.value[0]
|
|
|
|
|
if (firstDim) {
|
|
|
|
|
const groups = {}
|
|
|
|
|
tableData.value.forEach(d => {
|
|
|
|
|
const key = d[firstDim] || '其他'
|
|
|
|
|
if (!groups[key]) {
|
|
|
|
|
groups[key] = 0
|
|
|
|
|
}
|
|
|
|
|
groups[key] += d.salesAmount || 0
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const pieOption = {
|
|
|
|
|
title: { text: `${getDimensionName(firstDim)}占比`, left: 'center' },
|
|
|
|
|
tooltip: {
|
|
|
|
|
trigger: 'item',
|
|
|
|
|
formatter: '{a}<br/>{b}: {c}万元 ({d}%)'
|
|
|
|
|
},
|
|
|
|
|
series: [{
|
|
|
|
|
name: '销售额',
|
|
|
|
|
type: 'pie',
|
|
|
|
|
radius: '50%',
|
|
|
|
|
data: Object.keys(groups).map(key => ({
|
|
|
|
|
name: key,
|
|
|
|
|
value: groups[key]
|
|
|
|
|
})),
|
|
|
|
|
emphasis: {
|
|
|
|
|
itemStyle: {
|
|
|
|
|
shadowBlur: 10,
|
|
|
|
|
shadowOffsetX: 0,
|
|
|
|
|
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}]
|
|
|
|
|
}
|
|
|
|
|
pieChartInstance.setOption(pieOption)
|
|
|
|
|
},
|
|
|
|
|
methods: {
|
|
|
|
|
handleAppClick(app) {
|
|
|
|
|
this.selectedApp = app;
|
|
|
|
|
this.dialogVisible = true;
|
|
|
|
|
},
|
|
|
|
|
confirmLaunch() {
|
|
|
|
|
this.$message.success(`正在启动 ${this.selectedApp.name}...`);
|
|
|
|
|
this.dialogVisible = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reset = () => {
|
|
|
|
|
selectedDimensions.value = dimensions.value.map(d => d.id)
|
|
|
|
|
primaryDimension.value = 'product'
|
|
|
|
|
tableData.value = []
|
|
|
|
|
showSummaryRow.value = true
|
|
|
|
|
totalStats.value = null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 监听维度变化
|
|
|
|
|
watch(() => [...selectedDimensions.value], (newVal) => {
|
|
|
|
|
// 如果当前主维度不在选中维度中,则设置为第一个选中的维度
|
|
|
|
|
if (!newVal.includes(primaryDimension.value) && newVal.length > 0) {
|
|
|
|
|
primaryDimension.value = newVal[0]
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 监听主维度变化
|
|
|
|
|
watch(primaryDimension, () => {
|
|
|
|
|
if (tableData.value.length > 0) {
|
|
|
|
|
fetchData()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 初始化
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
fetchData()
|
|
|
|
|
// 监听窗口变化调整图表
|
|
|
|
|
window.addEventListener('resize', () => {
|
|
|
|
|
if (barChartInstance) barChartInstance.resize()
|
|
|
|
|
if (pieChartInstance) pieChartInstance.resize()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
.multi-dimension-report {
|
|
|
|
|
display: flex;
|
|
|
|
|
height: 100vh;
|
|
|
|
|
background: #f5f7fa;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/* 头部样式 */
|
|
|
|
|
.app-header {
|
|
|
|
|
padding: 30px 0;
|
|
|
|
|
color: white;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-controls {
|
|
|
|
|
.title {
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
font-size: 2.5rem;
|
|
|
|
|
text-shadow: 0 2px 4px rgb(0 0 0 / 10%);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.subtitle {
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
opacity: 0.9;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 统计信息样式 */
|
|
|
|
|
.stats-container {
|
|
|
|
|
display: flex;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
background: white;
|
|
|
|
|
border-bottom: 1px solid #e6e6e6;
|
|
|
|
|
margin-bottom: 30px;
|
|
|
|
|
background: rgb(255 255 255 / 20%);
|
|
|
|
|
border-radius: 15px;
|
|
|
|
|
justify-content: space-around;
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
.stat-card {
|
|
|
|
|
color: white;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-value {
|
|
|
|
|
margin-bottom: 5px;
|
|
|
|
|
font-size: 2rem;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.stat-label {
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
opacity: 0.9;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 搜索区域样式 */
|
|
|
|
|
.search-container {
|
|
|
|
|
max-width: 500px;
|
|
|
|
|
margin: 0 auto 30px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.search-input ::v-deep .el-input__inner {
|
|
|
|
|
padding-left: 40px;
|
|
|
|
|
border-radius: 25px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 空状态样式 */
|
|
|
|
|
.empty-state {
|
|
|
|
|
padding: 60px 20px;
|
|
|
|
|
margin-bottom: 30px;
|
|
|
|
|
color: rgb(255 255 255 / 80%);
|
|
|
|
|
text-align: center;
|
|
|
|
|
background: rgb(255 255 255 / 10%);
|
|
|
|
|
border-radius: 15px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.empty-icon {
|
|
|
|
|
display: block;
|
|
|
|
|
margin-bottom: 15px;
|
|
|
|
|
font-size: 3rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 分类区域样式 */
|
|
|
|
|
.category-section {
|
|
|
|
|
padding: 25px;
|
|
|
|
|
margin-bottom: 30px;
|
|
|
|
|
background: white;
|
|
|
|
|
border-radius: 15px;
|
|
|
|
|
box-shadow: 0 10px 30px rgb(0 0 0 / 10%);
|
|
|
|
|
animation: fadeIn 0.5s ease-out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.category-header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-selection {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
.category-title {
|
|
|
|
|
padding-left: 15px;
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
color: #303133;
|
|
|
|
|
border-left: 5px solid #409EFF;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-selection h3 {
|
|
|
|
|
margin: 0 0 15px;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
color: #606266;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(4, 1fr);
|
|
|
|
|
gap: 10px;
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-item {
|
|
|
|
|
position: relative;
|
|
|
|
|
padding: 12px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
.app-count {
|
|
|
|
|
color: #409eff;
|
|
|
|
|
background: #ecf5ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 应用网格样式 */
|
|
|
|
|
.apps-grid {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
|
|
|
|
gap: 25px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.app-card {
|
|
|
|
|
padding: 20px 15px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
background-color: #ecf5ff;
|
|
|
|
|
border: 2px solid #409eff;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
transition: all 0.3s;
|
|
|
|
|
user-select: none;
|
|
|
|
|
background: #f8f9fa;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
box-shadow: 0 2px 10px rgb(0 0 0 / 5%);
|
|
|
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-item:hover {
|
|
|
|
|
background-color: #d9ecff;
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
box-shadow: 0 2px 8px rgb(64 158 255 / 20%);
|
|
|
|
|
.app-card:hover {
|
|
|
|
|
color: white;
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
transform: translateY(-5px);
|
|
|
|
|
box-shadow: 0 10px 25px rgb(64 158 255 / 20%);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-item.inactive {
|
|
|
|
|
font-weight: normal;
|
|
|
|
|
color: #606266;
|
|
|
|
|
background-color: #f5f7fa;
|
|
|
|
|
border-color: #dcdfe6;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-item.inactive:hover {
|
|
|
|
|
background-color: #ebeef5;
|
|
|
|
|
transform: translateY(-2px);
|
|
|
|
|
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-item .check-icon {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 5px;
|
|
|
|
|
right: 5px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #409eff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dimension-item.inactive .check-icon {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.report-container {
|
|
|
|
|
flex: 1;
|
|
|
|
|
padding: 20px;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.report-header {
|
|
|
|
|
.app-icon-wrapper {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
width: 60px;
|
|
|
|
|
height: 60px;
|
|
|
|
|
margin: 0 auto 12px;
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
line-height: 60px;
|
|
|
|
|
color: white;
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
align-items: center;
|
|
|
|
|
margin-bottom: 20px;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.statistics-info {
|
|
|
|
|
padding: 8px 15px;
|
|
|
|
|
.app-name {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
color: #666;
|
|
|
|
|
background: #f5f7fa;
|
|
|
|
|
border: 1px solid #e6e6e6;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.table-container {
|
|
|
|
|
padding: 20px;
|
|
|
|
|
background: white;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
|
|
|
|
|
/* 对话框样式 */
|
|
|
|
|
::v-deep .app-dialog .el-dialog__body {
|
|
|
|
|
padding: 30px 20px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.positive {
|
|
|
|
|
color: #67c23a;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.negative {
|
|
|
|
|
color: #f56c6c;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.neutral {
|
|
|
|
|
color: #909399;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.chart-container {
|
|
|
|
|
padding: 20px;
|
|
|
|
|
margin-top: 20px;
|
|
|
|
|
background: white;
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
box-shadow: 0 2px 12px 0 rgb(0 0 0 / 10%);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-table .cell) {
|
|
|
|
|
padding: 4px 8px;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-table th) {
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
background-color: #f8f9fa;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-table__footer-wrapper .el-table__cell) {
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
background-color: #f0f9ff;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-table__footer-wrapper .positive) {
|
|
|
|
|
color: #67c23a;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-table__footer-wrapper .negative) {
|
|
|
|
|
color: #f56c6c;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
:deep(.el-table__footer-wrapper .neutral) {
|
|
|
|
|
color: #909399;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* 维度选择提示 */
|
|
|
|
|
.dimension-hint {
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: #909399;
|
|
|
|
|
.dialog-content {
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|
|
|
|
|
.dialog-icon-wrapper {
|
|
|
|
|
display: flex;
|
|
|
|
|
width: 80px;
|
|
|
|
|
height: 80px;
|
|
|
|
|
margin: 0 auto 20px;
|
|
|
|
|
font-size: 36px;
|
|
|
|
|
line-height: 80px;
|
|
|
|
|
color: white;
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dialog-app-name {
|
|
|
|
|
margin: 0 0 10px;
|
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dialog-app-id,
|
|
|
|
|
.dialog-app-category {
|
|
|
|
|
margin: 5px 0;
|
|
|
|
|
color: #909399;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dialog-footer {
|
|
|
|
|
text-align: right;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
|