|
@@ -1,11 +1,11 @@
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, onMounted, computed } from 'vue'
|
|
|
+import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
|
|
import { ElMessage } from 'element-plus'
|
|
|
-import { Download, Grid, TrendCharts, BarChart } from '@element-plus/icons-vue'
|
|
|
+import { Download, Grid, TrendCharts, DataLine } from '@element-plus/icons-vue'
|
|
|
import assist from '/@/api/assist'
|
|
|
import { useLoading } from '/@/utils/loading-util'
|
|
|
-import VueCharts from './VueCharts.vue'
|
|
|
import download from 'downloadjs'
|
|
|
+import VueCharts from './VueCharts.vue'
|
|
|
|
|
|
const props = defineProps<{
|
|
|
uuid: string
|
|
@@ -33,8 +33,8 @@ const exportToCSV = () => {
|
|
|
try {
|
|
|
// 构建CSV内容
|
|
|
const headers = data.value.fields.map((it) => `"${it}"`).join(',')
|
|
|
- const rows = data.value.data.map(rowJson => {
|
|
|
- return data.value!.fields.map(field => `"${rowJson[field]}"`).join(',')
|
|
|
+ const rows = data.value.data.map((rowJson) => {
|
|
|
+ return data.value!.fields.map((field) => `"${rowJson[field]}"`).join(',')
|
|
|
})
|
|
|
|
|
|
const csvContent = [headers, ...rows].join('\n')
|
|
@@ -51,10 +51,218 @@ const exportToCSV = () => {
|
|
|
// 组件挂载时加载数据
|
|
|
onMounted(doLoadingStruct)
|
|
|
|
|
|
+// 显示模式:表格、柱状图、折线图
|
|
|
const display = ref<'table' | 'bar' | 'line'>('table')
|
|
|
|
|
|
-const barChart = ref()
|
|
|
-const lineChart = ref()
|
|
|
+// 图表组件引用
|
|
|
+const chart = ref()
|
|
|
+
|
|
|
+// 图表配置选项
|
|
|
+const chartOptions = ref('')
|
|
|
+
|
|
|
+// 生成柱状图配置 - 统计每列数据的分布情况
|
|
|
+const generateBarChartOptions = () => {
|
|
|
+ if (!data.value || !data.value.fields || !data.value.data) return ''
|
|
|
+
|
|
|
+ // 统计每个字段的数据分布
|
|
|
+ const fieldStats: { [field: string]: { [value: string]: number } } = {}
|
|
|
+
|
|
|
+ // 初始化统计对象
|
|
|
+ data.value.fields.forEach(field => {
|
|
|
+ fieldStats[field] = {}
|
|
|
+ })
|
|
|
+
|
|
|
+ // 统计每个字段中每个值的出现次数
|
|
|
+ data.value.data.forEach(row => {
|
|
|
+ data.value!.fields.forEach(field => {
|
|
|
+ const value = row[field] || '空值'
|
|
|
+ fieldStats[field][value] = (fieldStats[field][value] || 0) + 1
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ // 准备图表数据
|
|
|
+ const xAxisData = data.value.fields // 横坐标是表头(字段名)
|
|
|
+ const seriesData: { [value: string]: number[] } = {}
|
|
|
+
|
|
|
+ // 收集所有可能的值
|
|
|
+ const allValues = new Set<string>()
|
|
|
+ Object.values(fieldStats).forEach(fieldStat => {
|
|
|
+ Object.keys(fieldStat).forEach(value => allValues.add(value))
|
|
|
+ })
|
|
|
+
|
|
|
+ // 为每个值创建一个系列
|
|
|
+ Array.from(allValues).forEach(value => {
|
|
|
+ seriesData[value] = data.value!.fields.map(field => fieldStats[field][value] || 0)
|
|
|
+ })
|
|
|
+
|
|
|
+ // 生成系列配置
|
|
|
+ const series = Object.entries(seriesData).map(([value, counts]) => ({
|
|
|
+ name: value,
|
|
|
+ type: 'bar',
|
|
|
+ data: counts,
|
|
|
+ stack: 'total' // 使用堆叠柱状图更好地展示分布
|
|
|
+ }))
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ title: {
|
|
|
+ text: '数据分布统计',
|
|
|
+ left: 'center'
|
|
|
+ },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: {
|
|
|
+ type: 'shadow'
|
|
|
+ },
|
|
|
+ backgroundColor: 'rgba(50, 50, 50, 0.9)',
|
|
|
+ borderColor: '#ccc',
|
|
|
+ borderWidth: 1,
|
|
|
+ textStyle: {
|
|
|
+ color: '#fff',
|
|
|
+ fontSize: 12
|
|
|
+ },
|
|
|
+ padding: [8, 12],
|
|
|
+ extraCssText: 'max-width: 300px; word-wrap: break-word;',
|
|
|
+ formatter: (params: any) => {
|
|
|
+ if (!params || params.length === 0) return ''
|
|
|
+ let result = `<div style="font-weight: bold; margin-bottom: 4px;">${params[0].axisValue}</div>`
|
|
|
+ params.forEach((param: any) => {
|
|
|
+ if (param.value > 0) { // 只显示有数据的项
|
|
|
+ result += `<div style="margin: 2px 0;">${param.marker}<span style="margin-left: 4px;">${param.seriesName}: ${param.value}</span></div>`
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return result
|
|
|
+ }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ data: Object.keys(seriesData),
|
|
|
+ top: 30,
|
|
|
+ type: 'scroll' // 如果图例太多,使用滚动
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ right: '4%',
|
|
|
+ bottom: '3%',
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: xAxisData,
|
|
|
+ axisLabel: {
|
|
|
+ rotate: 45 // 如果字段名太长,旋转显示
|
|
|
+ }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: '出现次数'
|
|
|
+ },
|
|
|
+ series: series
|
|
|
+ }
|
|
|
+
|
|
|
+ return encodeURIComponent(JSON.stringify(option))
|
|
|
+}
|
|
|
+
|
|
|
+// 生成折线图配置 - 展示数据趋势(按行索引)
|
|
|
+const generateLineChartOptions = () => {
|
|
|
+ if (!data.value || !data.value.fields || !data.value.data) return ''
|
|
|
+
|
|
|
+ // 尝试找到数值型字段
|
|
|
+ const numericFields = data.value.fields.filter(field => {
|
|
|
+ return data.value!.data.some(row => {
|
|
|
+ const value = row[field]
|
|
|
+ return !isNaN(parseFloat(value)) && isFinite(parseFloat(value))
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ // 如果没有数值型字段,则显示每个字段的数据长度趋势
|
|
|
+ const fieldsToShow = numericFields.length > 0 ? numericFields : data.value.fields
|
|
|
+
|
|
|
+ // X轴数据:行索引或者第一列数据
|
|
|
+ const xAxisData = data.value.data.map((_, index) => `第${index + 1}行`)
|
|
|
+
|
|
|
+ // 生成系列数据
|
|
|
+ const series = fieldsToShow.map(field => {
|
|
|
+ const seriesData = data.value!.data.map(row => {
|
|
|
+ const value = row[field]
|
|
|
+ // 如果是数值型字段,直接使用数值
|
|
|
+ if (numericFields.includes(field)) {
|
|
|
+ return parseFloat(value) || 0
|
|
|
+ }
|
|
|
+ // 否则使用字符串长度
|
|
|
+ return (value || '').toString().length
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ name: field,
|
|
|
+ type: 'line',
|
|
|
+ smooth: true,
|
|
|
+ data: seriesData,
|
|
|
+ connectNulls: false
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ const option = {
|
|
|
+ title: {
|
|
|
+ text: numericFields.length > 0 ? '数值趋势图' : '字段长度趋势图',
|
|
|
+ left: 'center'
|
|
|
+ },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ formatter: (params: any) => {
|
|
|
+ let result = `${params[0].axisValue}<br/>`
|
|
|
+ params.forEach((param: any) => {
|
|
|
+ const suffix = numericFields.includes(param.seriesName) ? '' : ' (字符长度)'
|
|
|
+ result += `${param.marker}${param.seriesName}: ${param.value}${suffix}<br/>`
|
|
|
+ })
|
|
|
+ return result
|
|
|
+ }
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ data: fieldsToShow,
|
|
|
+ top: 30,
|
|
|
+ type: 'scroll'
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ right: '4%',
|
|
|
+ bottom: '3%',
|
|
|
+ containLabel: true
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ boundaryGap: false,
|
|
|
+ data: xAxisData,
|
|
|
+ axisLabel: {
|
|
|
+ rotate: 45
|
|
|
+ }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ name: numericFields.length > 0 ? '数值' : '字符长度'
|
|
|
+ },
|
|
|
+ series: series
|
|
|
+ }
|
|
|
+
|
|
|
+ return encodeURIComponent(JSON.stringify(option))
|
|
|
+}
|
|
|
+
|
|
|
+// 监听显示模式变化,生成对应的图表配置
|
|
|
+watch(display, async (newVal) => {
|
|
|
+ if (newVal === 'table') return
|
|
|
+
|
|
|
+ // 根据显示模式生成对应的图表配置
|
|
|
+ switch (newVal) {
|
|
|
+ case 'bar':
|
|
|
+ chartOptions.value = generateBarChartOptions()
|
|
|
+ break
|
|
|
+ case 'line':
|
|
|
+ chartOptions.value = generateLineChartOptions()
|
|
|
+ break
|
|
|
+ }
|
|
|
+
|
|
|
+ await nextTick()
|
|
|
+ // 通知图表组件重新计算大小
|
|
|
+ chart.value?.resize()
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
@@ -62,31 +270,77 @@ const lineChart = ref()
|
|
|
<el-card v-loading="loadingStruct" shadow="none" :body-style="{ padding: 0, margin: 0 }">
|
|
|
<template #header>
|
|
|
<div class="card-header">
|
|
|
- <span>结构化数据表格</span>
|
|
|
- <el-popover placement="top" :width="120" trigger="hover" content="导出为CSV文件">
|
|
|
- <template #reference>
|
|
|
+ <span>结构化数据{{ display === 'table' ? '表格' : display === 'bar' ? '柱状图' : '折线图' }}</span>
|
|
|
+ <div class="header-controls">
|
|
|
+ <!-- 显示模式切换按钮组 -->
|
|
|
+ <el-button-group class="display-toggle">
|
|
|
+ <el-button
|
|
|
+ :type="display === 'table' ? 'primary' : ''"
|
|
|
+ :icon="Grid"
|
|
|
+ size="small"
|
|
|
+ @click="display = 'table'"
|
|
|
+ :disabled="!data || !data.fields || !data.data || data.data.length === 0"
|
|
|
+ >
|
|
|
+ 表格
|
|
|
+ </el-button>
|
|
|
<el-button
|
|
|
- :icon="Download"
|
|
|
+ :type="display === 'bar' ? 'primary' : ''"
|
|
|
+ :icon="TrendCharts"
|
|
|
size="small"
|
|
|
- text
|
|
|
- circle
|
|
|
- @click="exportToCSV"
|
|
|
+ @click="display = 'bar'"
|
|
|
:disabled="!data || !data.fields || !data.data || data.data.length === 0"
|
|
|
- class="export-btn"
|
|
|
- />
|
|
|
- </template>
|
|
|
- </el-popover>
|
|
|
+ >
|
|
|
+ 柱状图
|
|
|
+ </el-button>
|
|
|
+ <el-button
|
|
|
+ :type="display === 'line' ? 'primary' : ''"
|
|
|
+ :icon="DataLine"
|
|
|
+ size="small"
|
|
|
+ @click="display = 'line'"
|
|
|
+ :disabled="!data || !data.fields || !data.data || data.data.length === 0"
|
|
|
+ >
|
|
|
+ 折线图
|
|
|
+ </el-button>
|
|
|
+ </el-button-group>
|
|
|
+
|
|
|
+ <!-- 导出按钮 -->
|
|
|
+ <el-popover placement="top" :width="120" trigger="hover" content="导出为CSV文件">
|
|
|
+ <template #reference>
|
|
|
+ <el-button
|
|
|
+ :icon="Download"
|
|
|
+ size="small"
|
|
|
+ text
|
|
|
+ circle
|
|
|
+ @click="exportToCSV"
|
|
|
+ :disabled="!data || !data.fields || !data.data || data.data.length === 0"
|
|
|
+ class="export-btn"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </el-popover>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</template>
|
|
|
- <!-- 表格内容 -->
|
|
|
- <div v-if="data && data.fields && data.data" class="table-wrapper">
|
|
|
- <el-table :data="data.data" stripe border size="small" max-height="400">
|
|
|
- <el-table-column v-for="field in data.fields" :key="field" :prop="field" :label="field" align="center" min-width="120">
|
|
|
- <template #default="{ row }">
|
|
|
- <span>{{ row[field] || '-' }}</span>
|
|
|
- </template>
|
|
|
- </el-table-column>
|
|
|
- </el-table>
|
|
|
+ <!-- 内容区域 -->
|
|
|
+ <div v-if="data && data.fields && data.data">
|
|
|
+ <!-- 表格视图 -->
|
|
|
+ <div v-if="display === 'table'" class="table-wrapper">
|
|
|
+ <el-table :data="data.data" stripe border size="small" max-height="400">
|
|
|
+ <el-table-column v-for="field in data.fields" :key="field" :prop="field" :label="field" align="center" min-width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <span>{{ row[field] || '-' }}</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 图表视图 -->
|
|
|
+ <div v-else class="chart-wrapper">
|
|
|
+ <VueCharts
|
|
|
+ ref="chart"
|
|
|
+ :data="chartOptions"
|
|
|
+ v-if="chartOptions"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 空数据状态 -->
|
|
@@ -108,16 +362,34 @@ const lineChart = ref()
|
|
|
font-weight: 500;
|
|
|
color: var(--el-text-color-primary);
|
|
|
|
|
|
- .export-btn {
|
|
|
- color: var(--el-color-primary);
|
|
|
+ .header-controls {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
|
|
|
- &:hover {
|
|
|
- background-color: var(--el-color-primary-light-9);
|
|
|
+ .display-toggle {
|
|
|
+ .el-button {
|
|
|
+ padding: 6px 12px;
|
|
|
+ font-size: 12px;
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ color: var(--el-text-color-disabled);
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- &:disabled {
|
|
|
- color: var(--el-text-color-disabled);
|
|
|
- cursor: not-allowed;
|
|
|
+ .export-btn {
|
|
|
+ color: var(--el-color-primary);
|
|
|
+
|
|
|
+ &:hover {
|
|
|
+ background-color: var(--el-color-primary-light-9);
|
|
|
+ }
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ color: var(--el-text-color-disabled);
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
@@ -128,6 +400,16 @@ const lineChart = ref()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ .chart-wrapper {
|
|
|
+ padding: 20px;
|
|
|
+ min-height: 400px;
|
|
|
+
|
|
|
+ :deep(div) {
|
|
|
+ width: 100% !important;
|
|
|
+ height: 400px !important;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
.empty-state {
|
|
|
padding: 40px 0;
|
|
|
text-align: center;
|