Преглед изворни кода

feat:完善部分图标功能 新增看板 图表和列表渲染

叶静 пре 4 недеља
родитељ
комит
43ef1d9552

+ 1 - 1
.env.development

@@ -21,7 +21,7 @@ SHEEP_USE_MOCK = false
 # 代理配置
 SHEEP_USE_PROXY = false
 SHEEP_PROXY_PREFIX = '/api'
-SHEEP_PROXY_TARGET = http://124.222.152.234:8501
+SHEEP_PROXY_TARGET = http://192.168.0.101:8401
 
 ##### API路由配置
 # API路由功能总开关

+ 272 - 225
src/app/shop/admin/data/report/components/chartEdit.vue

@@ -1,95 +1,82 @@
 <template>
-  <div class="chart-edit-container">
-    <!-- 图表类型选择 -->
-    <div class="chart-type-selector">
-      <div class="chart-type-item" 
-           :class="{ active: selectedChartType === 'line' }"
-           @click="selectedChartType = 'line'">
-        <i class="el-icon-data-line"></i>
-        <span>折线图</span>
-      </div>
-      <div class="chart-type-item" 
-           :class="{ active: selectedChartType === 'bar' }"
-           @click="selectedChartType = 'bar'">
-        <i class="el-icon-data-board"></i>
-        <span>柱状图</span>
-      </div>
-      <div class="chart-type-item" 
-           :class="{ active: selectedChartType === 'pie' }"
-           @click="selectedChartType = 'pie'">
-        <i class="el-icon-pie-chart"></i>
-        <span>饼图</span>
-      </div>
-    </div>
-
-    <!-- 主要内容区域 -->
-    <div class="chart-content">
-      <!-- 左侧图表列表 -->
-      <div class="chart-list-panel">
-        <div class="search-box">
-          <el-input 
-            v-model="searchKeyword" 
-            placeholder="搜索图表名称"
-            prefix-icon="el-icon-search"
-            clearable
-            @input="filterCharts"
-          />
-        </div>
-        <div class="chart-list" v-loading="chartListLoading">
-          <div 
-            v-for="chart in filteredCharts" 
-            :key="chart.id"
-            class="chart-item"
-            :class="{ active: selectedChart?.id === chart.id }"
-            @click="selectChart(chart)"
-          >
-            <div class="chart-icon">
-              <i class="el-icon-data-analysis"></i>
+  <el-container>
+    <el-main>
+      <div class="chart-edit-modal">
+        <!-- 主要内容区域 -->
+        <div class="main-content">
+          <!-- 左侧图表列表 -->
+          <div class="chart-list-section">
+            <div class="list-header">
+              <h4>选择数据维度</h4>
+              <div class="search-box">
+                <el-input v-model="searchKeyword" placeholder="搜索图表名称" prefix-icon="Search" clearable
+                  @input="filterCharts" />
+              </div>
             </div>
-            <div class="chart-info">
-              <div class="chart-name">{{ chart.name }}</div>
-              <div class="chart-memo">{{ chart.memo }}</div>
+
+            <div class="chart-list" v-loading="chartListLoading">
+              <div v-for="chart in filteredCharts" :key="chart.id" class="chart-item"
+                :class="{ active: selectedCharts.includes(chart.id) }" @click="toggleChart(chart)">
+                <el-checkbox :model-value="selectedCharts.includes(chart.id)" @change="toggleChart(chart)"
+                  class="chart-checkbox" />
+                <div class="chart-info">
+                  <div class="chart-name">{{ chart.name }}</div>
+                </div>
+              </div>
+
+              <div v-if="!filteredCharts.length" class="empty-state">
+                <el-icon class="empty-icon">
+                  <Search />
+                </el-icon>
+                <div class="empty-text">{{ searchKeyword ? '未找到匹配的图表' : '暂无可选图表' }}</div>
+              </div>
             </div>
           </div>
-        </div>
-      </div>
 
-      <!-- 右侧预览区域 -->
-      <div class="chart-preview-panel">
-        <div v-if="!selectedChart" class="empty-preview">
-          <i class="el-icon-data-analysis"></i>
-          <p>请选择左侧图表查看预览</p>
-        </div>
-        <div v-else class="preview-content">
-          <div class="preview-header">
-            <h3>{{ selectedChart.name }}</h3>
-            <p>{{ selectedChart.memo }}</p>
-          </div>
-          <div class="preview-chart">
-            <!-- 这里可以放置图表预览组件 -->
-            <div class="chart-placeholder">
-              <i class="el-icon-data-line" v-if="selectedChartType === 'line'"></i>
-              <i class="el-icon-data-board" v-else-if="selectedChartType === 'bar'"></i>
-              <i class="el-icon-pie-chart" v-else-if="selectedChartType === 'pie'"></i>
-              <p>{{ selectedChartType === 'line' ? '折线图' : selectedChartType === 'bar' ? '柱状图' : '饼图' }}预览</p>
+          <!-- 右侧预览区域 -->
+          <div class="preview-section">
+
+            <div v-if="selectedCharts.length === 0" class="empty-preview">
+              <el-icon class="empty-icon">
+                <TrendCharts />
+              </el-icon>
+              <div class="empty-text">请选择左侧数据维度进行预览</div>
+              <div class="empty-desc">选择后将显示对应的图表预览效果</div>
+            </div>
+
+            <div v-else class="preview-content">
+              <div class="chart-preview">
+                <report-chart :type="'line'" :data="currentChartData" :height="300" :show-date-picker="false"
+                  :title="lastSelectedChart?.name" />
+              </div>
             </div>
           </div>
         </div>
+
+        <!-- 底部操作按钮 -->
+        <el-footer class="sa-footer--submit">
+          <el-button @click="cancel">取消</el-button>
+          <el-button type="primary" @click="confirm" :disabled="selectedCharts.length === 0">
+            <el-icon>
+              <Plus />
+            </el-icon>
+            确认添加 ({{ selectedCharts.length }})
+          </el-button>
+        </el-footer>
       </div>
-    </div>
-
-    <!-- 底部操作按钮 -->
-    <div class="chart-actions">
-      <el-button @click="cancel">取消</el-button>
-      <el-button type="primary" @click="confirm" :disabled="!selectedChart">确认</el-button>
-    </div>
-  </div>
+    </el-main>
+  </el-container>
 </template>
 
 <script setup>
-import { computed, onMounted, ref } from 'vue';
+import { ref, computed, onMounted, nextTick } from 'vue';
 import { ElMessage } from 'element-plus';
+import {
+  Search,
+  Plus
+} from '@element-plus/icons-vue';
 import { api } from '../../data.service.js';
+import ReportChart from './report-chart.vue';
 
 const props = defineProps({
   modal: {
@@ -103,18 +90,45 @@ const emit = defineEmits(['modalCallBack']);
 // 响应式数据
 const chartListLoading = ref(false);
 const chartList = ref([]);
-const selectedChart = ref(null);
-const selectedChartType = ref('line'); // 默认选择折线图
+const selectedCharts = ref([]); // 改为多选数组
+const lastSelectedChart = ref(null); // 最后选中的图表用于预览
+
 const searchKeyword = ref('');
 
+// 根据图表名称生成对应的模拟数据
+const generateMockData = (chartName) => {
+  const dates = ['2025-08-28', '2025-08-29', '2025-08-30', '2025-08-31', '2025-09-01', '2025-09-02', '2025-09-03'];
+
+  // 根据图表名称生成不同的数据模式
+  const dataPatterns = {
+    '商城停留时间统计': () => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 60) + 30, series: '停留时间(分钟)' })),
+    '种子统计': () => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 1000) + 500, series: '种子数量' })),
+    '装饰统计': () => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 200) + 100, series: '装饰销量' })),
+    '虫子统计': () => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 50) + 10, series: '虫子数量' })),
+    '次日留存': () => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 30) + 60, series: '留存率(%)' })),
+    '7日留存': () => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 20) + 40, series: '留存率(%)' })),
+    '入账笔数': () => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 500) + 200, series: '笔数' }))
+  };
+
+  // 如果有对应的数据模式,使用它;否则使用默认模式
+  const generator = dataPatterns[chartName] || (() => dates.map((date, index) => ({ x: date, y: Math.floor(Math.random() * 1000) + 100, series: chartName || '默认数据' })));
+  return generator();
+};
+
+// 当前图表数据(根据选中的图表动态生成)
+const currentChartData = computed(() => {
+  if (!lastSelectedChart.value) return [];
+  return generateMockData(lastSelectedChart.value.name);
+});
+
 // 过滤后的图表列表
 const filteredCharts = computed(() => {
   if (!searchKeyword.value) {
     return chartList.value;
   }
-  return chartList.value.filter(chart => 
+  return chartList.value.filter(chart =>
     chart.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
-    chart.memo.toLowerCase().includes(searchKeyword.value.toLowerCase())
+    (chart.memo && chart.memo.toLowerCase().includes(searchKeyword.value.toLowerCase()))
   );
 });
 
@@ -127,7 +141,7 @@ async function getChartList() {
       ElMessage.error('缺少看板ID参数');
       return;
     }
-    
+
     const { code, data } = await api.report.spectaculars.addView(spectId);
     if (code === '200') {
       chartList.value = data || [];
@@ -142,9 +156,21 @@ async function getChartList() {
   }
 }
 
-// 选择图表
-function selectChart(chart) {
-  selectedChart.value = chart;
+// 切换图表选择状态
+function toggleChart(chart) {
+  const index = selectedCharts.value.indexOf(chart.id);
+  if (index > -1) {
+    selectedCharts.value.splice(index, 1);
+    // 如果取消选择的是当前预览的图表,更新预览
+    if (lastSelectedChart.value?.id === chart.id) {
+      lastSelectedChart.value = selectedCharts.value.length > 0
+        ? chartList.value.find(c => c.id === selectedCharts.value[selectedCharts.value.length - 1])
+        : null;
+    }
+  } else {
+    selectedCharts.value.push(chart.id);
+    lastSelectedChart.value = chart; // 更新预览图表
+  }
 }
 
 // 过滤图表
@@ -159,8 +185,8 @@ function cancel() {
 
 // 确认添加图表
 async function confirm() {
-  if (!selectedChart.value) {
-    ElMessage.warning('请选择一个图表');
+  if (selectedCharts.value.length === 0) {
+    ElMessage.warning('请选择至少一个图表');
     return;
   }
 
@@ -168,17 +194,15 @@ async function confirm() {
     const spectId = props.modal.params.spectId;
     const submitData = {
       id: spectId,
-      dimensions: [
-        {
-          dimensionId: selectedChart.value.id,
-          viewType: selectedChartType.value
-        }
-      ]
+      dimensions: selectedCharts.value.map(chartId => ({
+        dimensionId: chartId,
+        viewType: 'line'
+      }))
     };
 
-    const { code } = await api.report.spectaculars.dimensions.add(submitData);
+    const { code } = await api.report.dimensions.add(submitData);
     if (code === '200') {
-      ElMessage.success('添加图表成功');
+      ElMessage.success(`成功添加${selectedCharts.value.length}个图表`);
       emit('modalCallBack', { event: 'confirm' });
     } else {
       ElMessage.error('添加图表失败');
@@ -200,124 +224,88 @@ onMounted(() => {
 </script>
 
 <style scoped>
-.chart-edit-container {
+.chart-edit-modal {
   display: flex;
   flex-direction: column;
-  height: 600px;
-  background: #fff;
-}
-
-/* 图表类型选择器 */
-.chart-type-selector {
-  display: flex;
-  justify-content: center;
-  gap: 20px;
-  padding: 20px;
-  border-bottom: 1px solid #e4e7ed;
-  background: #f8f9fa;
-}
-
-.chart-type-item {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 8px;
-  padding: 16px 24px;
-  border: 2px solid #e4e7ed;
-  border-radius: 8px;
-  cursor: pointer;
-  transition: all 0.3s ease;
+  height: 700px;
   background: #fff;
-  min-width: 80px;
-}
-
-.chart-type-item:hover {
-  border-color: #409eff;
-  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
-}
-
-.chart-type-item.active {
-  border-color: #409eff;
-  background: #ecf5ff;
-  color: #409eff;
+  border-radius: 12px;
+  overflow: hidden;
 }
 
-.chart-type-item i {
-  font-size: 24px;
-}
 
-.chart-type-item span {
-  font-size: 14px;
-  font-weight: 500;
-}
 
 /* 主要内容区域 */
-.chart-content {
+.main-content {
   display: flex;
   flex: 1;
   overflow: hidden;
 }
 
 /* 左侧图表列表 */
-.chart-list-panel {
-  width: 300px;
-  border-right: 1px solid #e4e7ed;
+.chart-list-section {
+  width: 320px;
+  border-right: 1px solid #f0f0f0;
   display: flex;
   flex-direction: column;
+  background: #fafbfc;
+}
+
+.list-header {
+  padding: 20px 20px 16px 20px;
+  border-bottom: 1px solid #f0f0f0;
+  background: #fff;
+}
+
+.list-header h4 {
+  margin: 0 0 16px 0;
+  font-size: 16px;
+  font-weight: 600;
+  color: #1f2937;
 }
 
 .search-box {
-  padding: 16px;
-  border-bottom: 1px solid #e4e7ed;
+  position: relative;
 }
 
 .chart-list {
   flex: 1;
   overflow-y: auto;
-  padding: 8px;
+  padding: 16px;
 }
 
 .chart-item {
   display: flex;
   align-items: center;
   gap: 12px;
-  padding: 12px;
+  padding: 12px 16px;
   border: 1px solid #e4e7ed;
   border-radius: 6px;
-  margin-bottom: 8px;
-  cursor: pointer;
-  transition: all 0.3s ease;
   background: #fff;
+  cursor: pointer;
+  transition: all 0.2s ease;
+  margin-bottom: 8px;
 }
 
 .chart-item:hover {
-  border-color: #409eff;
-  box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
+  border-color: var(--el-color-primary);
+  background: #f9f5ff;
 }
 
 .chart-item.active {
-  border-color: #409eff;
-  background: #ecf5ff;
+  border-color: var(--el-color-primary);
+  /* 淡紫色 */
+  background: #f9f5ff;
+  box-shadow: 0 0 0 1px var(--el-color-primary);
 }
 
-.chart-icon {
-  width: 40px;
-  height: 40px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #f0f2f5;
-  border-radius: 6px;
-  color: #606266;
-}
-
-.chart-item.active .chart-icon {
-  background: #409eff;
-  color: #fff;
+.chart-checkbox {
+  margin-right: 8px;
 }
 
-.chart-icon i {
-  font-size: 18px;
+.chart-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
+  background-color: var(--el-color-primary);
+  border-color: var(--el-color-primary);
 }
 
 .chart-info {
@@ -328,27 +316,63 @@ onMounted(() => {
 .chart-name {
   font-size: 14px;
   font-weight: 500;
-  color: #303133;
-  margin-bottom: 4px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+  color: #1f2937;
+  line-height: 1.4;
 }
 
-.chart-memo {
-  font-size: 12px;
-  color: #909399;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+.chart-item.active .chart-name {
+  color: #1e40af;
+}
+
+.chart-check {
+  color: var(--el-color-primary);
+  font-size: 20px;
+  animation: checkIn 0.3s ease;
+}
+
+@keyframes checkIn {
+  0% {
+    transform: scale(0);
+    opacity: 0;
+  }
+
+  50% {
+    transform: scale(1.2);
+  }
+
+  100% {
+    transform: scale(1);
+    opacity: 1;
+  }
+}
+
+.empty-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 60px 20px;
+  color: #9ca3af;
+}
+
+.empty-icon {
+  font-size: 48px;
+  margin-bottom: 16px;
+  opacity: 0.6;
+}
+
+.empty-text {
+  font-size: 14px;
+  font-weight: 500;
 }
 
 /* 右侧预览区域 */
-.chart-preview-panel {
+.preview-section {
   flex: 1;
   display: flex;
   flex-direction: column;
-  background: #fafafa;
+  padding: 24px;
+  background: #fff;
 }
 
 .empty-preview {
@@ -357,77 +381,79 @@ onMounted(() => {
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  color: #909399;
+  color: #9ca3af;
+  background: #fafbfc;
+  border-radius: 12px;
+  border: 2px dashed #e5e7eb;
 }
 
-.empty-preview i {
-  font-size: 48px;
-  margin-bottom: 16px;
+.empty-preview .empty-icon {
+  font-size: 64px;
+  margin-bottom: 20px;
+  opacity: 0.4;
 }
 
-.empty-preview p {
+.empty-preview .empty-text {
+  font-size: 16px;
+  font-weight: 600;
+  margin: 0 0 8px 0;
+  color: #6b7280;
+}
+
+.empty-preview .empty-desc {
   font-size: 14px;
   margin: 0;
+  color: #9ca3af;
 }
 
 .preview-content {
   flex: 1;
   display: flex;
   flex-direction: column;
-  padding: 20px;
 }
 
 .preview-header {
-  margin-bottom: 20px;
+  margin-bottom: 24px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #f0f0f0;
 }
 
 .preview-header h3 {
   margin: 0 0 8px 0;
-  font-size: 18px;
-  color: #303133;
+  font-size: 20px;
+  font-weight: 700;
+  color: #1f2937;
 }
 
-.preview-header p {
+.preview-header .chart-desc {
   margin: 0;
   font-size: 14px;
-  color: #606266;
+  color: #6b7280;
+  line-height: 1.5;
 }
 
-.preview-chart {
+.chart-preview {
   flex: 1;
   background: #fff;
-  border: 1px solid #e4e7ed;
-  border-radius: 8px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.chart-placeholder {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 16px;
-  color: #909399;
-}
-
-.chart-placeholder i {
-  font-size: 64px;
-}
-
-.chart-placeholder p {
-  margin: 0;
-  font-size: 16px;
+  border: 1px solid #e5e7eb;
+  border-radius: 12px;
+  padding: 20px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
 }
 
 /* 底部操作按钮 */
-.chart-actions {
+.footer-actions {
   display: flex;
   justify-content: flex-end;
   gap: 12px;
-  padding: 16px 20px;
-  border-top: 1px solid #e4e7ed;
-  background: #fff;
+  padding: 24px;
+  border-top: 1px solid #f0f0f0;
+  background: #fafbfc;
+}
+
+.footer-actions .el-button {
+  min-width: 100px;
+  font-weight: 600;
 }
 
 /* 滚动条样式 */
@@ -436,16 +462,37 @@ onMounted(() => {
 }
 
 .chart-list::-webkit-scrollbar-track {
-  background: #f1f1f1;
+  background: #f1f5f9;
   border-radius: 3px;
 }
 
 .chart-list::-webkit-scrollbar-thumb {
-  background: #c1c1c1;
+  background: #cbd5e1;
   border-radius: 3px;
 }
 
 .chart-list::-webkit-scrollbar-thumb:hover {
-  background: #a8a8a8;
+  background: #94a3b8;
+}
+
+/* 响应式设计 */
+@media (max-width: 768px) {
+  .chart-edit-modal {
+    height: 100vh;
+  }
+
+  .main-content {
+    flex-direction: column;
+  }
+
+  .chart-list-section {
+    width: 100%;
+    max-height: 300px;
+  }
+
+  .type-button {
+    min-width: 70px;
+    padding: 12px 16px;
+  }
 }
 </style>

+ 133 - 50
src/app/shop/admin/data/report/components/report-chart.vue

@@ -7,61 +7,86 @@
             <h3 class="chart-title">{{ title || '默认图表' }}</h3>
             <p class="chart-subtitle" v-if="subtitle">{{ subtitle }}</p>
           </div>
-          <div class="chart-actions">
+          <div class="chart-actions mt-20px justify-between flex">
             <!-- 图表类型切换 -->
             <el-radio-group v-model="currentType" size="small" @change="handleTypeChange" class="chart-type-selector">
-              <el-radio-button
-                v-for="type in availableTypes"
-                :key="type.value"
-                :label="type.value"
-                :disabled="!type.enabled"
-              >
+              <el-radio-button v-for="type in availableTypes" :key="type.value" :label="type.value"
+                :disabled="!type.enabled">
                 {{ type.label }}
               </el-radio-button>
             </el-radio-group>
-            
+
             <div class="chart-controls">
               <!-- 时间范围选择 -->
-              <el-date-picker
-                v-if="showDatePicker"
-                v-model="dateRange"
-                type="daterange"
-                range-separator="至"
-                start-placeholder="开始日期"
-                end-placeholder="结束日期"
-                size="small"
-                @change="handleDateChange"
-              />
-              
+              <el-date-picker v-if="showDatePicker" v-model="dateRange" type="daterange" range-separator="至"
+                start-placeholder="开始日期" end-placeholder="结束日期" size="small" @change="handleDateChange" />
+
               <!-- 刷新按钮 -->
               <el-button size="small" @click="refreshChart" class="sa-button-refresh">
-                <el-icon><Refresh /></el-icon>
+                <el-icon>
+                  <Refresh />
+                </el-icon>
               </el-button>
-              
+
               <!-- 全屏按钮 -->
               <el-button size="small" @click="toggleFullscreen">
-                <el-icon><FullScreen /></el-icon>
+                <el-icon>
+                  <FullScreen />
+                </el-icon>
               </el-button>
             </div>
           </div>
         </div>
       </template>
-      
+
       <div class="chart-container" :class="{ 'fullscreen': isFullscreen }" v-loading="loading">
-        <v-chart
-          ref="chartRef"
-          :option="chartOption"
-          :autoresize="true"
-          :style="chartStyle"
-          @click="handleChartClick"
-        />
-        
+        <!-- 全屏状态下的控制栏 -->
+        <div v-if="isFullscreen" class="fullscreen-header">
+          <div class="chart-title-section">
+            <h3 class="chart-title">{{ title || '默认图表' }}</h3>
+          </div>
+          <div class="chart-controls">
+            <!-- 图表类型切换 -->
+            <el-radio-group v-model="currentType" size="small" @change="handleTypeChange" class="chart-type-selector">
+              <el-radio-button v-for="type in availableTypes" :key="type.value" :label="type.value"
+                :disabled="!type.enabled">
+                {{ type.label }}
+              </el-radio-button>
+            </el-radio-group>
+
+            <!-- 退出全屏按钮 -->
+            <el-button size="small" @click="toggleFullscreen" type="primary">
+              <el-icon>
+                <FullScreen />
+              </el-icon>
+              退出全屏
+            </el-button>
+          </div>
+        </div>
+
+        <!-- 图表内容 -->
+        <div v-if="!loading && data && data.length > 0" class="chart-content">
+          <v-chart ref="chartRef" :option="chartOption" :autoresize="true" :style="chartStyle"
+            @click="handleChartClick" />
+        </div>
+
         <!-- 无数据状态 -->
-        <div v-if="!loading && (!data || data.length === 0)" class="no-data">
-          <el-empty description="暂无数据" />
+        <div v-else-if="!loading" class="no-data">
+          <div class="empty-chart-placeholder" :style="chartStyle">
+            <el-empty description="暂无数据">
+              <template #image>
+                <div class="empty-chart-icon">
+                  📊
+                </div>
+              </template>
+              <template #description>
+                <span class="empty-description">{{ title || '图表' }}暂无数据</span>
+              </template>
+            </el-empty>
+          </div>
         </div>
       </div>
-      
+
       <!-- 图表说明 -->
       <div v-if="description" class="chart-description">
         <el-text type="info" size="small">{{ description }}</el-text>
@@ -114,7 +139,7 @@ const props = defineProps({
   },
   colors: {
     type: Array,
-    default: () => ['#409EFF', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
+    default: () => ['#806af6', '#67C23A', '#E6A23C', '#F56C6C', '#909399']
   },
   xAxisKey: {
     type: String,
@@ -148,9 +173,9 @@ const availableTypes = computed(() => [
   { label: '折线图', value: 'line', enabled: true },
   { label: '柱状图', value: 'bar', enabled: true },
   { label: '饼图', value: 'pie', enabled: true },
-  { label: '散点图', value: 'scatter', enabled: true },
-  { label: '雷达图', value: 'radar', enabled: false },
-  { label: '漏斗图', value: 'funnel', enabled: false }
+  // { label: '散点图', value: 'scatter', enabled: true },
+  // { label: '雷达图', value: 'radar', enabled: false },
+  // { label: '漏斗图', value: 'funnel', enabled: false }
 ])
 
 // 计算属性
@@ -163,7 +188,7 @@ const chartOption = computed(() => {
   if (!props.data || props.data.length === 0) {
     return {}
   }
-  
+
   const baseOption = {
     color: props.colors,
     tooltip: {
@@ -193,7 +218,7 @@ const chartOption = computed(() => {
     },
     ...props.options
   }
-  
+
   switch (currentType.value) {
     case 'line':
       return generateLineChart(baseOption)
@@ -216,7 +241,7 @@ const chartOption = computed(() => {
 const generateLineChart = (baseOption) => {
   const xData = props.data.map(item => item[props.xAxisKey])
   const series = extractSeries()
-  
+
   return {
     ...baseOption,
     xAxis: {
@@ -267,7 +292,7 @@ const generateLineChart = (baseOption) => {
 const generateBarChart = (baseOption) => {
   const xData = props.data.map(item => item[props.xAxisKey])
   const series = extractSeries()
-  
+
   return {
     ...baseOption,
     xAxis: {
@@ -315,7 +340,7 @@ const generatePieChart = (baseOption) => {
     name: item[props.xAxisKey],
     value: item[props.yAxisKey]
   }))
-  
+
   return {
     ...baseOption,
     tooltip: {
@@ -363,7 +388,7 @@ const generateScatterChart = (baseOption) => {
     item[props.xAxisKey],
     item[props.yAxisKey]
   ])
-  
+
   return {
     ...baseOption,
     xAxis: {
@@ -425,11 +450,11 @@ const generateFunnelChart = (baseOption) => {
 // 提取系列数据
 const extractSeries = () => {
   if (!props.data || props.data.length === 0) return []
-  
+
   // 如果数据中有series字段,按series分组
   if (props.data[0][props.seriesKey]) {
     const seriesMap = new Map()
-    
+
     props.data.forEach(item => {
       const seriesName = item[props.seriesKey]
       if (!seriesMap.has(seriesName)) {
@@ -437,7 +462,7 @@ const extractSeries = () => {
       }
       seriesMap.get(seriesName).push(item[props.yAxisKey])
     })
-    
+
     return Array.from(seriesMap.entries()).map(([name, data]) => ({
       name,
       data
@@ -546,9 +571,67 @@ defineExpose({
     height: 100vh;
     z-index: 9999;
     background: white;
-    
-    .chart-content {
-      height: calc(100vh - 60px);
+    display: flex;
+    flex-direction: column;
+
+    .fullscreen-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 16px 24px;
+      border-bottom: 1px solid #e4e7ed;
+      background: white;
+      z-index: 10000;
+
+      .chart-title {
+        margin: 0;
+        font-size: 18px;
+        font-weight: 600;
+        color: #303133;
+      }
+
+      .chart-controls {
+        display: flex;
+        align-items: center;
+        gap: 16px;
+      }
+    }
+
+    .echarts {
+      flex: 1;
+      height: auto !important;
+    }
+
+    .no-data {
+      .empty-chart-placeholder {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        background: #fafafa;
+        border: 1px dashed #d9d9d9;
+        border-radius: 6px;
+        min-height: 300px;
+
+        .empty-chart-icon {
+          font-size: 48px;
+          margin-bottom: 16px;
+          opacity: 0.6;
+        }
+
+        .empty-description {
+          color: #909399;
+          font-size: 14px;
+        }
+
+        :deep(.el-empty) {
+          padding: 40px 0;
+        }
+
+        :deep(.el-empty__image) {
+          width: auto;
+          height: auto;
+        }
+      }
     }
   }
 }

+ 150 - 53
src/app/shop/admin/data/report/index.vue

@@ -81,12 +81,7 @@
               </el-icon>
               刷新数据
             </el-button>
-            <el-button @click="showDimensionManagement" size="small">
-              <el-icon>
-                <Setting />
-              </el-icon>
-              维度管理
-            </el-button>
+
           </div>
         </div>
 
@@ -94,7 +89,6 @@
         <div v-loading="loading" class="charts-grid">
           <div v-for="chart in currentCharts" :key="chart.id" class="chart-card">
             <div class="chart-header">
-              <h3>{{ chart.name }}</h3>
               <div class="chart-actions">
                 <el-button text size="small" @click="showChartDetail(chart)">
                   <el-icon>
@@ -102,26 +96,26 @@
                   </el-icon>
                   查看详情
                 </el-button>
-                <el-button text size="small" @click="editChart(chart)">
+                <el-button text size="small" @click="deleteChart(chart)" type="danger">
+                  <el-icon>
+                    <Delete />
+                  </el-icon>
+                  删除
+                </el-button>
+                <el-button text size="small" @click="downloadChart(chart)">
                   <el-icon>
-                    <Edit />
+                    <TrendCharts />
                   </el-icon>
-                  编辑
+                  下载图表
                 </el-button>
               </div>
             </div>
             <div class="chart-content">
-              <div class="chart-summary">
-                <div v-for="section in chart.sections" :key="section.id" class="summary-item">
-                  <div class="summary-label">{{ section.sectionName || section.name }}</div>
-                  <div class="summary-value">
-                    {{ formatValue(section.statisticsValue || section.total, chart.unitType) }}
-                  </div>
-                </div>
-              </div>
-              <div class="chart-placeholder">
-                <div class="chart-type-indicator">{{ getChartTypeLabel(chart.viewType) }}</div>
-              </div>
+              <reportChart :title="chart.name" :data="transformChartData(chart)" :type="chart.viewType || 'line'"
+                :height="350" :show-date-picker="false" :x-axis-key="'date'" />
+            </div>
+            <div class="chart-footer">
+              <span class="update-time">更新时间:{{ chart.updateTime || '暂无' }}</span>
             </div>
           </div>
 
@@ -141,13 +135,14 @@
 <script setup>
 import { onMounted, reactive, ref, computed } from 'vue';
 import { ElMessage, ElMessageBox } from 'element-plus';
-import { Setting, Plus, Grid, View, Edit, Delete, TrendCharts, Refresh } from '@element-plus/icons-vue';
+import { Plus, Grid, View, Edit, Delete, TrendCharts, Refresh } from '@element-plus/icons-vue';
 import { useModal } from '@/sheep/hooks';
 import { api } from '../data.service.js';
-import dimensionManagementEdit from './components/dimensionManagementEdit.vue';
+
 import dataDisplayEdit from './components/dataDisplayEdit.vue';
 import dashboardEdit from './components/dashboardEdit.vue';
 import chartEdit from './components/chartEdit.vue';
+import reportChart from './components/report-chart.vue';
 
 // 响应式数据
 const loading = ref(false);
@@ -243,9 +238,55 @@ function processChartData(data) {
   // 处理从detail接口返回的数据,转换为图表可用格式
   if (data && data.dimensions) {
     console.log('处理图表数据:', data.dimensions);
+    // 将dimensions数据转换为图表数据
+    const newCharts = data.dimensions.map(dimension => ({
+      ...dimension,
+      dashboardId: selectedDashboard.value?.id,
+      sections: dimension.sections || []
+    }));
+
+    // 更新图表数据
+    chartData.value = chartData.value.filter(chart => chart.dashboardId !== selectedDashboard.value?.id);
+    chartData.value.push(...newCharts);
   }
 }
 
+// 转换图表数据格式
+function transformChartData(chart) {
+  if (!chart.sections || chart.sections.length === 0) {
+    // 返回空数据结构,让图表组件显示"暂无数据"状态
+    return [];
+  }
+
+  // 获取所有日期(从第一个section的statistics中提取)
+  const firstSection = chart.sections.find(section => section.statistics && section.statistics.length > 0);
+  if (!firstSection) {
+    // 如果没有有效的statistics数据,返回空数组让图表显示"暂无数据"
+    return [];
+  }
+
+  const dates = firstSection.statistics.map(stat => stat.cutDay);
+
+  // 转换为图表需要的格式
+  const result = dates.map(date => {
+    const dataPoint = { date };
+
+    // 为每个section添加数据
+    chart.sections.forEach(section => {
+      if (section.statistics && section.name !== chart.name + '汇总') {
+        const stat = section.statistics.find(s => s.cutDay === date);
+        if (stat) {
+          dataPoint[section.name] = stat.statisticsValue;
+        }
+      }
+    });
+
+    return dataPoint;
+  });
+
+  return result;
+}
+
 
 
 // 删除看板
@@ -278,6 +319,60 @@ function showChartDetail(chart) {
   showDataDisplay({ chart });
 }
 
+// 删除图表
+function deleteChart(chart) {
+  ElMessageBox.confirm(
+    `确定要删除图表 "${chart.name}" 吗?`,
+    '删除确认',
+    {
+      confirmButtonText: '确定',
+      cancelButtonText: '取消',
+      type: 'warning',
+    }
+  ).then(async () => {
+    try {
+      const { code } = await api.report.dimensions.delete(chart.spectacularsDimensionId);
+      if (code == 200) {
+        ElMessage.success('图表删除成功');
+        refreshChartData()
+      }
+    } catch (error) {
+      console.error('删除图表失败:', error);
+    }
+  }).catch(() => {
+    // 取消删除
+  });
+}
+
+// 下载图表
+function downloadChart(chart) {
+  try {
+    // 创建图表数据的JSON文件
+    const chartDataForDownload = {
+      name: chart.name,
+      type: chart.viewType || 'line',
+      data: transformChartData(chart),
+      updateTime: chart.updateTime,
+      exportTime: new Date().toLocaleString()
+    };
+
+    const dataStr = JSON.stringify(chartDataForDownload, null, 2);
+    const dataBlob = new Blob([dataStr], { type: 'application/json' });
+
+    const link = document.createElement('a');
+    link.href = URL.createObjectURL(dataBlob);
+    link.download = `${chart.name}_图表数据_${new Date().toISOString().slice(0, 10)}.json`;
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+
+    ElMessage.success('图表数据下载成功');
+  } catch (error) {
+    console.error('下载图表失败:', error);
+    ElMessage.error('下载图表失败,请重试');
+  }
+}
+
 
 
 // 显示数据展示页面
@@ -319,6 +414,7 @@ function editDashboard(row) {
     {
       title: '编辑看板',
       type: 'edit',
+      width: '500px',
       id: row.id,
       dashboardData: row
     },
@@ -338,7 +434,7 @@ function addChart() {
     ElMessage.warning('请先选择一个看板');
     return;
   }
-  
+
   useModal(
     chartEdit,
     {
@@ -397,25 +493,7 @@ function getChartTypeLabel(viewType) {
 
 
 
-// 维度管理
-function showDimensionManagement() {
-  useModal(
-    dimensionManagementEdit,
-    {
-      title: '维度管理',
-      width: '90%',
-      height: '80vh'
-    },
-    {
-      confirm: () => {
-        getDashboardList();
-        if (selectedDashboard.value) {
-          getChartData(selectedDashboard.value.id);
-        }
-      }
-    }
-  );
-}
+
 
 onMounted(() => {
   getDashboardList();
@@ -610,11 +688,12 @@ onMounted(() => {
 /* 图表网格 */
 .charts-grid {
   flex: 1;
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
-  gap: 20px;
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
   overflow-y: auto;
   padding: 16px 0;
+  align-content: flex-start;
 }
 
 .chart-card {
@@ -623,7 +702,11 @@ onMounted(() => {
   padding: 16px;
   border: 1px solid #e4e7ed;
   transition: all 0.3s;
-  height: fit-content;
+  width: 380px;
+  height: 430px;
+  margin: 10px;
+  display: flex;
+  flex-direction: column;
 }
 
 .chart-card:hover {
@@ -648,18 +731,32 @@ onMounted(() => {
 }
 
 .chart-actions {
+  width: 100%;
   display: flex;
+  justify-content: flex-end;
   gap: 4px;
-  opacity: 0;
-  transition: opacity 0.3s;
-}
-
-.chart-card:hover .chart-actions {
   opacity: 1;
 }
 
 .chart-content {
-  min-height: 120px;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+}
+
+.chart-footer {
+  margin-top: 12px;
+  padding-top: 8px;
+  border-top: 1px solid #e4e7ed;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.update-time {
+  font-size: 12px;
+  color: #909399;
+  font-style: italic;
 }
 
 .chart-summary {

+ 149 - 146
src/sheep/components/sa-modal/sa-modal.vue

@@ -1,16 +1,8 @@
 <template>
-  <el-dialog
-    v-for="(modal, index) in list"
-    :key="modal.id"
-    v-model="modal.show"
-    :close-on-click-modal="false"
-    :show-close="false"
-    :fullscreen="modal.fullscreen"
-    :width="modal.params.width || '50%'"
-    :class="modal.params.class ? modal.params.class + ' sa-dialog' : 'sa-dialog'"
-    :draggable="true"
-    :modal="modal.params.hasOwnProperty('modal') ? modal.params.modal : true"
-  >
+  <el-dialog v-for="(modal, index) in list" :key="modal.id" v-model="modal.show" :close-on-click-modal="false"
+    :show-close="false" :fullscreen="modal.fullscreen" :width="modal.params.width || '80%'"
+    :class="modal.params.class ? modal.params.class + ' sa-dialog' : 'sa-dialog'" :draggable="true"
+    :modal="modal.params.hasOwnProperty('modal') ? modal.params.modal : true">
     <!-- header -->
     <template #header>
       <div class="title">
@@ -47,173 +39,184 @@
 </template>
 
 <script>
-  import { isString } from 'lodash';
-  // 全局页面弹框
-  export default {
-    name: 'SaModal',
-  };
+import { isString } from 'lodash';
+// 全局页面弹框
+export default {
+  name: 'SaModal',
+};
 
-  let list = [];
+let list = [];
 
-  export const useModal = async (component, params, callback = {}) => {
-    if (isString(component) && component.includes('.')) {
-      component = await getComponent(component);
-    }
-    component = markRaw(component);
-
-    let modal = {
-      id: buildShortUUID('modal'),
-      component: component,
-      params,
-      show: true,
-      fullscreen: false,
-      minimize: false,
-      callback,
-    };
-    list.push(modal);
-    return modal;
+export const useModal = async (component, params, callback = {}) => {
+  if (isString(component) && component.includes('.')) {
+    component = await getComponent(component);
+  }
+  component = markRaw(component);
+
+  let modal = {
+    id: buildShortUUID('modal'),
+    component: component,
+    params,
+    show: true,
+    fullscreen: false,
+    minimize: false,
+    callback,
   };
+  list.push(modal);
+  return modal;
+};
 
-  async function getComponent(page) {
-    page = page.split('.');
-    let path = '/src/app';
-    page.forEach((p, k) => {
-      if (k + 1 == page.length) {
-        path += '/' + p + '.vue';
-      } else {
-        path += '/' + p;
-      }
-    });
-    let component = await import(/* @vite-ignore */ path);
-    return component.default;
-  }
+async function getComponent(page) {
+  page = page.split('.');
+  let path = '/src/app';
+  page.forEach((p, k) => {
+    if (k + 1 == page.length) {
+      path += '/' + p + '.vue';
+    } else {
+      path += '/' + p;
+    }
+  });
+  let component = await import(/* @vite-ignore */ path);
+  return component.default;
+}
 </script>
 
 <script setup>
-  import { reactive, markRaw } from 'vue';
-  import { buildShortUUID, isMobile } from '@/sheep/utils';
+import { reactive, markRaw } from 'vue';
+import { buildShortUUID, isMobile } from '@/sheep/utils';
 
-  list = reactive([]);
+list = reactive([]);
 
-  // 全屏/还原
-  function fullscreen(index) {
-    let modal = list[index];
-    modal.fullscreen = !modal.fullscreen;
-    if (modal.callback.fullscreen) {
-      modal.callback.fullscreen();
-    }
+// 全屏/还原
+function fullscreen(index) {
+  let modal = list[index];
+  modal.fullscreen = !modal.fullscreen;
+  if (modal.callback.fullscreen) {
+    modal.callback.fullscreen();
   }
+}
 
-  // 最小化/还原
-  function minimize(index) {
-    let modal = list[index];
-    modal.show = modal.minimize;
-    modal.minimize = !modal.minimize;
-    if (modal.callback.minimize) {
-      modal.callback.minimize();
-    }
+// 最小化/还原
+function minimize(index) {
+  let modal = list[index];
+  modal.show = modal.minimize;
+  modal.minimize = !modal.minimize;
+  if (modal.callback.minimize) {
+    modal.callback.minimize();
   }
+}
 
-  // 关闭
-  function close(index = 0) {
-    let modal = list[index];
-    if (modal.callback.close) {
-      modal.callback.close();
-    }
-    list.splice(index, 1);
+// 关闭
+function close(index = 0) {
+  let modal = list[index];
+  if (modal.callback.close) {
+    modal.callback.close();
   }
+  list.splice(index, 1);
+}
 
-  // 模态框数据回调
-  function modalCallBack(index, data) {
-    let modal = list[index];
-    if (modal.callback[data.event]) {
-      modal.callback[data.event](data);
-    }
-    close(index);
+// 模态框数据回调
+function modalCallBack(index, data) {
+  let modal = list[index];
+  if (modal.callback[data.event]) {
+    modal.callback[data.event](data);
   }
+  close(index);
+}
 </script>
 <style lang="scss">
-  @media only screen and (max-width: 768px) {
-    .sa-dialog {
-      .full-button {
-        display: none;
-      }
+@media only screen and (max-width: 768px) {
+  .sa-dialog {
+    .full-button {
+      display: none;
     }
   }
-  .minimize-wrap {
-    position: absolute;
-    bottom: 0;
-    left: 126px;
-    width: calc(100% - 240px);
-    height: 30px;
-    z-index: 10;
-    overflow: hidden;
-    .minimize-button {
-      position: relative;
-      margin-right: 12px;
-      &:last-of-type {
-        margin-right: 0;
-      }
+}
+
+.minimize-wrap {
+  position: absolute;
+  bottom: 0;
+  left: 126px;
+  width: calc(100% - 240px);
+  height: 30px;
+  z-index: 10;
+  overflow: hidden;
+
+  .minimize-button {
+    position: relative;
+    margin-right: 12px;
+
+    &:last-of-type {
+      margin-right: 0;
+    }
+
+    &::before {
+      pointer-events: none;
+      content: '';
+      width: 0;
+      height: 3px;
+      background: var(--el-color-primary);
+      border-radius: 2px;
+      position: absolute;
+      bottom: 0;
+      left: 50%;
+      margin-left: -12px;
+      transition: all 0.2s ease-in-out;
+    }
+
+    .close {
+      display: none;
+    }
+
+    &:hover {
       &::before {
-        pointer-events: none;
-        content: '';
-        width: 0;
-        height: 3px;
-        background: var(--el-color-primary);
-        border-radius: 2px;
-        position: absolute;
-        bottom: 0;
-        left: 50%;
-        margin-left: -12px;
-        transition: all 0.2s ease-in-out;
+        width: 24px;
       }
+
       .close {
-        display: none;
+        display: block;
       }
-      &:hover {
-        &::before {
-          width: 24px;
-        }
-        .close {
-          display: block;
-        }
-      }
-      .minimize-button-tip {
-        width: fit-content;
+    }
+
+    .minimize-button-tip {
+      width: fit-content;
+      height: 24px;
+      padding: 0 8px 0 0;
+      border-radius: 4px;
+      color: var(--sa-footer-color);
+      font-size: 14px;
+      overflow: hidden;
+      cursor: pointer;
+
+      .sa-table-line-1 {
         height: 24px;
-        padding: 0 8px 0 0;
-        border-radius: 4px;
-        color: var(--sa-footer-color);
-        font-size: 14px;
-        overflow: hidden;
-        cursor: pointer;
-        .sa-table-line-1 {
-          height: 24px;
-          line-height: 24px;
-        }
+        line-height: 24px;
       }
     }
-    @keyframes drive {
-      from {
-        width: 8px;
-      }
-      to {
-        width: 100px;
-      }
+  }
+
+  @keyframes drive {
+    from {
+      width: 8px;
+    }
+
+    to {
+      width: 100px;
     }
   }
+}
 
-  /* ==================
+/* ==================
     el-col: 布局分栏
  ==================== */
-  @media only screen and (min-width: 992px) {
-    .sa-dialog:not(.is-fullscreen) {
-      @for $i from 0 through 24 {
-        .sa-col-#{$i} {
-          max-width: calc(calc($i * 100%) / 24) !important;
-          flex: 0 0 calc(calc($i * 100%) / 24) !important;
-        }
+@media only screen and (min-width: 992px) {
+  .sa-dialog:not(.is-fullscreen) {
+    @for $i from 0 through 24 {
+      .sa-col-#{$i} {
+        max-width: calc(calc($i * 100%) / 24) !important;
+        flex: 0 0 calc(calc($i * 100%) / 24) !important;
       }
     }
   }
+}
 </style>

+ 1 - 1
src/sheep/request/index.js

@@ -63,7 +63,7 @@ const handleAuthFailure = (errorData) => {
  */
 export const request = axios.create({
   baseURL,
-  timeout: 8000,
+  timeout: 20000,
   method: 'GET',
   headers: {
     // "Accept": "*/*",