Gogs 3 mesi fa
parent
commit
e3226f735d

+ 3 - 1
src/store/modules/permission.js

@@ -112,7 +112,9 @@ export function filterDynamicRoutes(routes) {
 
 function normalizeViewPath(view) {
   if (!view) return view
-  return view.replace('lifecycle/sku-detail', 'lifecycle/skuAnalysis')
+  const normalized = view.replace('lifecycle/sku-detail', 'lifecycle/skuAnalysis')
+  if (normalized.endsWith('.vue')) return normalized
+  return `${normalized}.vue`
 }
 
 export const loadView = (view) => {

+ 202 - 0
src/views/order/channel/index.vue

@@ -0,0 +1,202 @@
+<template>
+  <div class="page-container">
+    <!-- 1. 顶部标题 -->
+    <header class="page-header">
+      <h1 class="main-title">商品渠道透视</h1>
+      <p class="subtitle">分析商品在不同销售渠道的覆盖广度</p>
+    </header>
+
+    <!-- 2. 可视化图表  -->
+    <div class="chart-card">
+      <h3 class="chart-title">商品渠道覆盖 Top 20 趋势</h3>
+      <div ref="channelChartRef" style="width: 100%; height: 500px;"></div>
+    </div>
+
+    <!-- 3. 数据表格 -->
+    <div class="table-card">
+      <h3 class="chart-title">商品渠道覆盖明细</h3>
+      <table class="data-table">
+        <thead>
+          <tr>
+            <th>排名</th>
+            <th>商品编码</th>
+            <th>覆盖平台数</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="(item, index) in paginatedData" :key="item.productCode">
+            <td>{{ (currentPage - 1) * itemsPerPage + index + 1 }}</td>
+            <td>{{ item.productCode }}</td>
+            <td>{{ item.platformCount }}</td>
+          </tr>
+        </tbody>
+      </table>
+      <!-- 分页控制器 -->
+      <div class="pagination-controls">
+        <button @click="prevPage" :disabled="currentPage === 1">上一页</button>
+        <span>第 {{ currentPage }} / {{ totalPages }} 页</span>
+        <button @click="nextPage" :disabled="currentPage === totalPages">下一页</button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import * as echarts from 'echarts';
+import axios from 'axios';
+
+export default {
+  name: 'OrderChannel',
+  data() {
+    return {
+      allProducts: [],
+      currentPage: 1,
+      itemsPerPage: 10
+    };
+  },
+  computed: {
+    paginatedData() {
+      const start = (this.currentPage - 1) * this.itemsPerPage;
+      const end = start + this.itemsPerPage;
+      return this.allProducts.slice(start, end);
+    },
+    totalPages() {
+      if (this.allProducts.length === 0) return 1;
+      return Math.ceil(this.allProducts.length / this.itemsPerPage);
+    }
+  },
+  mounted() {
+    this.fetchData();
+  },
+  methods: {
+    initLineChart() {
+      const chartEl = this.$refs.channelChartRef;
+      if (!chartEl) return;
+      const myChart = echarts.init(chartEl);
+      const top20Data = this.allProducts.slice(0, 20);
+      const option = {
+        tooltip: { trigger: 'axis' },
+        grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+        xAxis: {
+          type: 'category',
+          boundaryGap: false,
+          data: top20Data.map(item => item.productCode),
+          axisLabel: { interval: 0, rotate: 30 }
+        },
+        yAxis: {
+          type: 'value',
+          name: '?????'
+        },
+        series: [
+          {
+            name: '????',
+            type: 'line',
+            smooth: true,
+            data: top20Data.map(item => item.platformCount),
+            itemStyle: { color: '#5470C6' },
+            areaStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: 'rgba(84, 112, 198, 0.5)' },
+                { offset: 1, color: 'rgba(84, 112, 198, 0)' }
+              ])
+            }
+          }
+        ]
+      };
+      myChart.setOption(option);
+      window.addEventListener('resize', () => myChart.resize());
+    },
+    async fetchData() {
+      try {
+        const response = await axios.get('/api/shop/import/cross-selling-products');
+        if (response.data.success) {
+          this.allProducts = response.data.data || [];
+          this.initLineChart();
+        }
+      } catch (error) {
+        console.error('??????:', error);
+      }
+    },
+    nextPage() {
+      if (this.currentPage < this.totalPages) {
+        this.currentPage += 1;
+      }
+    },
+    prevPage() {
+      if (this.currentPage > 1) {
+        this.currentPage -= 1;
+      }
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+.page-container {
+  padding: 20px;
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  background-color: #f0f2f5;
+}
+.page-header {
+  margin-bottom: 10px;
+}
+.main-title {
+  font-size: 24px;
+  font-weight: 600;
+  color: #333;
+}
+.subtitle {
+  font-size: 14px;
+  color: #999;
+}
+.chart-card, .table-card {
+  background-color: #fff;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px 0 rgba(0,0,0,0.1);
+}
+.chart-title {
+  font-size: 18px;
+  color: #333;
+  margin-bottom: 20px;
+}
+.data-table {
+  width: 100%;
+  border-collapse: collapse;
+}
+.data-table th, .data-table td {
+  padding: 12px 15px;
+  border: 1px solid #e0e0e0;
+  text-align: left;
+}
+.data-table th {
+  background-color: #f7f7f7;
+  font-weight: 600;
+}
+.pagination-controls {
+  margin-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  gap: 15px;
+}
+.pagination-controls button {
+  padding: 8px 12px;
+  border: 1px solid #ccc;
+  background-color: #fff;
+  border-radius: 4px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+.pagination-controls button:hover:not(:disabled) {
+    border-color: #5470C6;
+    color: #5470C6;
+}
+.pagination-controls button:disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+}
+</style>

+ 203 - 0
src/views/order/efficiency/index.vue

@@ -0,0 +1,203 @@
+<template>
+  <div class="department-efficiency-view">
+    <!-- 1. 页面头部 -->
+    <header class="page-header">
+      <h1 class="page-title">综合运营分析</h1>
+      <p class="page-description">分析各部门的平均销售额与渠道商品多样性。</p>
+    </header>
+
+    <!-- 2. 部门运营效率分析 -->
+    <section class="chart-area">
+      <div v-if="barChart.loading" class="status-overlay">
+        <p>hina正在努力加载部门效率数据中...</p>
+      </div>
+      <div v-else-if="barChart.error" class="status-overlay error">
+        <p>部门效率数据加载失败</p>
+        <p class="error-message">{{ barChart.error }}</p>
+      </div>
+      <div ref="barChartRef" style="width: 100%; height: 500px;"></div>
+    </section>
+
+    <!-- 3. 渠道商品多样性分析 -->
+    <section class="chart-area">
+      <div v-if="pieChart.loading" class="status-overlay">
+        <p>hina正在努力加载渠道多样性数据中...</p>
+      </div>
+      <div v-else-if="pieChart.error" class="status-overlay error">
+        <p>渠道多样性数据加载失败</p>
+        <p class="error-message">{{ pieChart.error }}</p>
+      </div>
+      <div ref="pieChartRef" style="width: 100%; height: 500px;"></div>
+    </section>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+import * as echarts from 'echarts';
+
+export default {
+  name: 'OrderEfficiency',
+  data() {
+    return {
+      barChartInstance: null,
+      pieChartInstance: null,
+      barChart: { loading: true, error: null },
+      pieChart: { loading: true, error: null }
+    };
+  },
+  mounted() {
+    this.initBarChart();
+    this.initPieChart();
+    window.addEventListener('resize', this.handleResize);
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize);
+    if (this.barChartInstance) this.barChartInstance.dispose();
+    if (this.pieChartInstance) this.pieChartInstance.dispose();
+  },
+  methods: {
+    async initBarChart() {
+      this.barChart.loading = true;
+      try {
+        const response = await axios.get('/api/shop/import/department-efficiency');
+        if (!response.data || !response.data.success) {
+          throw new Error(response.data.message || '??????????');
+        }
+        const rawData = response.data.data;
+        const chartData = Object.entries(rawData).map(([name, value]) => ({
+          name,
+          value: parseFloat(value).toFixed(2)
+        }));
+
+        await this.$nextTick();
+        const chartEl = this.$refs.barChartRef;
+        if (chartEl) {
+          this.barChartInstance = echarts.init(chartEl);
+          const option = {
+            title: { text: '??????????', left: 'center' },
+            tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, formatter: '{b}<br/>?????: {c} ?' },
+            grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
+            xAxis: { type: 'category', data: chartData.map(item => item.name), axisLabel: { rotate: 30, interval: 0 } },
+            yAxis: { type: 'value', name: '?????(?)' },
+            series: [{
+              name: '?????',
+              type: 'bar',
+              data: chartData.map(item => item.value),
+              barWidth: '40%',
+              itemStyle: {
+                borderRadius: [5, 5, 0, 0],
+                color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                  { offset: 0, color: '#83bff6' },
+                  { offset: 1, color: '#188df0' }
+                ])
+              }
+            }]
+          };
+          this.barChartInstance.setOption(option);
+        }
+      } catch (err) {
+        this.barChart.error = err.message || '????';
+      } finally {
+        this.barChart.loading = false;
+      }
+    },
+    async initPieChart() {
+      this.pieChart.loading = true;
+      try {
+        const response = await axios.get('/api/shop/import/channel-diversity');
+        if (!response.data || !response.data.success) {
+          throw new Error(response.data.message || '???????????');
+        }
+        const rawData = response.data.data;
+        const chartData = Object.entries(rawData).map(([name, value]) => ({ name, value }));
+
+        await this.$nextTick();
+        const chartEl = this.$refs.pieChartRef;
+        if (chartEl) {
+          this.pieChartInstance = echarts.init(chartEl);
+          const option = {
+            title: { text: '?????????', left: 'center' },
+            tooltip: { trigger: 'item', formatter: '{a} <br/>{b} : {c} ({d}%)' },
+            legend: { orient: 'vertical', left: 'left', top: '10%' },
+            series: [{
+              name: '????',
+              type: 'pie',
+              radius: [20, 140],
+              center: ['50%', '60%'],
+              roseType: 'area',
+              itemStyle: { borderRadius: 5 },
+              data: chartData
+            }]
+          };
+          this.pieChartInstance.setOption(option);
+        }
+      } catch (err) {
+        this.pieChart.error = err.message || '????';
+      } finally {
+        this.pieChart.loading = false;
+      }
+    },
+    handleResize() {
+      if (this.barChartInstance) this.barChartInstance.resize();
+      if (this.pieChartInstance) this.pieChartInstance.resize();
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+.department-efficiency-view {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  padding: 20px;
+}
+.page-header {
+  padding: 15px 20px;
+  background-color: #ffffff;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+}
+.page-title {
+  margin: 0;
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+}
+.page-description {
+  margin: 4px 0 0;
+  font-size: 14px;
+  color: #666;
+}
+.chart-area {
+  position: relative;
+  padding: 20px;
+  background-color: #ffffff;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+  min-height: 540px;
+}
+.status-overlay {
+  position: absolute;
+  inset: 0; /* a shorthand for top, right, bottom, left */
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+  background-color: rgba(255, 255, 255, 0.8);
+  border-radius: 8px;
+  z-index: 10;
+  color: #555;
+  font-size: 16px;
+}
+.status-overlay.error {
+  color: #f56c6c;
+}
+.error-message {
+  font-size: 14px;
+  color: #999;
+  margin-top: 8px;
+}
+</style>

+ 129 - 0
src/views/order/ordervalue/FunnelChart/index.vue

@@ -0,0 +1,129 @@
+<template>
+  <div class="chart-card">
+    <h3 class="chart-title">支付决策漏斗图</h3>
+    <div ref="funnelChart" style="width: 100%; height: 300px;"></div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+import * as echarts from 'echarts';
+
+export default {
+  name: 'FunnelChart',
+  props: {
+    dateRange: {
+      type: Object,
+      default: () => ({ start: '', end: '' })
+    }
+  },
+  data() {
+    return {
+      totalOrders: 0,
+      unpaidOrders: 0,
+      rawData: {}
+    };
+  },
+  watch: {
+    dateRange: {
+      handler(newRange) {
+        this.fetchFunnelData(newRange);
+      },
+      deep: true
+    }
+  },
+  mounted() {
+    this.fetchFunnelData();
+  },
+  methods: {
+    getMockData() {
+      return {
+        paidWithin5Mins: 7000,
+        paidBetween5And30Mins: 1000,
+        paidAfter30Mins: 0,
+        unpaidOrders: 2000
+      };
+    },
+    async fetchFunnelData(dateRange = null) {
+      try {
+        let apiUrl = '/api/analysis/payment-decision-funnel';
+        if (dateRange && dateRange.start && dateRange.end) {
+          apiUrl += `?startDate=${dateRange.start}&endDate=${dateRange.end}`;
+        }
+        const response = await axios.get(apiUrl);
+        const data = response.data.data;
+        this.rawData = data;
+        const paidSum = data.paidWithin5Mins + data.paidBetween5And30Mins + data.paidAfter30Mins;
+        this.totalOrders = paidSum + data.unpaidOrders;
+        this.unpaidOrders = data.unpaidOrders;
+      } catch (error) {
+        console.error('???????????????', error);
+        const mockData = this.getMockData();
+        this.rawData = mockData;
+        const paidSum = mockData.paidWithin5Mins + mockData.paidBetween5And30Mins + mockData.paidAfter30Mins;
+        this.totalOrders = paidSum + mockData.unpaidOrders;
+        this.unpaidOrders = mockData.unpaidOrders;
+      }
+      this.renderChart();
+    },
+    renderChart() {
+      const chartEl = this.$refs.funnelChart;
+      if (!this.rawData || !chartEl) return;
+      const myChart = echarts.init(chartEl);
+      const categories = ['30??????', '5-30????', '5?????', '????'];
+      const seriesData = [
+        this.rawData.paidAfter30Mins || 0,
+        this.rawData.paidBetween5And30Mins || 0,
+        this.rawData.paidWithin5Mins || 0,
+        this.totalOrders
+      ];
+      const option = {
+        tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+        grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
+        xAxis: {
+          type: 'value',
+          axisLabel: { formatter: value => value.toLocaleString() }
+        },
+        yAxis: {
+          type: 'category',
+          data: categories
+        },
+        series: [
+          {
+            name: '???',
+            type: 'bar',
+            data: seriesData,
+            itemStyle: {
+              color: params => (params.dataIndex === 3 ? '#3366CC' : '#6699FF')
+            },
+            label: {
+              show: true,
+              position: 'right',
+              formatter: '{c}'
+            }
+          }
+        ]
+      };
+      myChart.setOption(option);
+      window.addEventListener('resize', () => myChart.resize());
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+.chart-card {
+  background-color: white;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+}
+.chart-title {
+  text-align: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 20px;
+}
+</style>

+ 121 - 0
src/views/order/ordervalue/KpiCard/index.vue

@@ -0,0 +1,121 @@
+<template>
+  <div class="kpi-card">
+    <div class="card-header">
+      <h4 class="card-title">{{ title }}</h4>
+      <span class="icon-wrapper" :style="{ backgroundColor: iconColor }">
+        {{ icon }}
+      </span>
+    </div>
+    
+    <div class="value-area">
+      <p class="main-value">{{ value }}</p>
+      <p class="trend-text" :style="{ color: trendColorMap[trendColor] }">
+        <span class="trend-icon" :style="{ color: trendColorMap[trendColor] }">
+          {{ trendIcon }}
+        </span>
+        {{ Math.abs(parseFloat(trend)) }}% 较上月
+      </p>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'KpiCard',
+  props: {
+    title: String,
+    value: String,
+    trend: String,
+    trendColor: {
+      type: String,
+      default: 'green'
+    },
+    icon: {
+      type: String,
+      default: '??'
+    }
+  },
+  computed: {
+    trendColorMap() {
+      return {
+        green: '#38D6A4',
+        red: '#FF6347'
+      };
+    },
+    iconColorMap() {
+      return {
+        '???? (GMV)': '#6699FF',
+        'P80 ?????': '#F7D742',
+        'Top 5 ?????': '#FF9966',
+        '??????': '#4ECDC4'
+      };
+    },
+    iconColor() {
+      return this.iconColorMap[this.title] || '#6699FF';
+    },
+    trendIcon() {
+      if (!this.trend) return '';
+      return this.trend.includes('-') ? '?' : '?';
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+.kpi-card {
+  background-color: white;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+  display: flex;
+  flex-direction: column;
+}
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 15px;
+}
+
+.card-title {
+  font-size: 14px;
+  color: #666;
+  weight: 500;
+  margin: 0;
+  flex-grow: 1;
+}
+
+.icon-wrapper {
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+  /* 背景色由 JS 动态控制 */
+}
+
+.main-value {
+  font-size: 24px;
+  font-weight: bold;
+  color: #333;
+  margin: 0; /* 修复: 移除默认边距 */
+}
+
+.trend-text {
+  font-size: 12px;
+  margin-top: 5px;
+  font-weight: 500;
+  display: flex;
+  align-items: center;
+}
+
+.trend-icon {
+  margin-right: 4px;
+  font-size: 14px;
+  line-height: 1;
+}
+</style>

+ 137 - 0
src/views/order/ordervalue/LeakageCard/index.vue

@@ -0,0 +1,137 @@
+<template>
+  <div class="leakage-card">
+    <div class="card-header">
+      <h4 class="card-title">订单价值漏损分析 (退款)</h4>
+      <span class="icon-wrapper">💧</span>
+    </div>
+    <div class="value-area">
+      <p class="main-value">{{ leakageData.leakageRatePercent }}%</p>
+      <p class="sub-text">价值漏损率</p>
+    </div>
+    <div class="details-area">
+      <div class="detail-item">
+        <p class="detail-label">总退款金额</p>
+        <p class="detail-value refund">{{ leakageData.totalRefundAmount }}</p>
+      </div>
+      <div class="detail-item">
+        <p class="detail-label">总成功交易额</p>
+        <p class="detail-value success">{{ leakageData.totalSuccessAmount }}</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+
+export default {
+  name: 'LeakageCard',
+  props: {
+    dateRange: {
+      type: Object,
+      default: () => ({ start: '', end: '' })
+    }
+  },
+  data() {
+    return {
+      leakageData: {
+        totalRefundAmount: '?0.00',
+        leakageRatePercent: 0,
+        totalSuccessAmount: '?0.00'
+      },
+      currencyFormatter: new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' })
+    };
+  },
+  watch: {
+    dateRange: {
+      handler(newRange) {
+        this.fetchLeakageData(newRange);
+      },
+      deep: true
+    }
+  },
+  mounted() {
+    this.fetchLeakageData();
+  },
+  methods: {
+    async fetchLeakageData(dateRange = null) {
+      try {
+        let apiUrl = '/api/analysis/leakage-rate';
+        if (dateRange && dateRange.start && dateRange.end) {
+          apiUrl += `?startDate=${dateRange.start}&endDate=${dateRange.end}`;
+        }
+        const response = await axios.get(apiUrl);
+        if (response.data) {
+          this.leakageData.totalRefundAmount = this.currencyFormatter.format(response.data.totalRefundAmount || 0);
+          this.leakageData.leakageRatePercent = (response.data.leakageRatePercent || 0).toFixed(1);
+          this.leakageData.totalSuccessAmount = this.currencyFormatter.format(response.data.totalSuccessAmount || 0);
+        }
+      } catch (error) {
+        console.error('?????????:', error);
+      }
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+.leakage-card {
+  background-color: white;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+  border-left: 4px solid #E6A23C;
+}
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 15px;
+}
+.card-title {
+  font-size: 16px;
+  font-weight: 600;
+  margin: 0;
+}
+.icon-wrapper {
+  font-size: 24px;
+  color: #E6A23C;
+}
+.value-area {
+  text-align: center;
+  margin: 10px 0 20px;
+}
+.main-value {
+  font-size: 32px;
+  font-weight: bold;
+  color: #E6A23C;
+  margin: 0;
+}
+.sub-text {
+  font-size: 12px;
+  color: #999;
+  margin-top: 5px;
+}
+.details-area {
+  display: flex;
+  justify-content: space-around;
+  border-top: 1px solid #f0f0f0;
+  padding-top: 15px;
+}
+.detail-item {
+  text-align: center;
+}
+.detail-label {
+  font-size: 12px;
+  color: #999;
+  margin-bottom: 5px;
+}
+.detail-value {
+  font-size: 14px;
+  font-weight: 500;
+  margin: 0;
+}
+.detail-value.refund { color: #F56C6C; }
+.detail-value.success { color: #67C23A; }
+</style>

+ 173 - 0
src/views/order/ordervalue/Top5PieChart/index.vue

@@ -0,0 +1,173 @@
+<template>
+  <div class="chart-card">
+    <h3 class="chart-title">明星商品价值环图 (Top 5)</h3>
+    <div ref="pieChart" style="width: 100%; height: 300px;"></div>
+    
+    <div class="legend-area">
+      <div class="percentage-label">
+        <span class="percent-value">{{ top5Percent }}%</span>
+        <p class="percent-text">Top 5 商品贡献占比</p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+import * as echarts from 'echarts';
+
+export default {
+  name: 'Top5PieChart',
+  props: {
+    dateRange: {
+      type: Object,
+      default: () => ({ start: '', end: '' })
+    }
+  },
+  data() {
+    return {
+      chartData: [],
+      legendData: [],
+      top5Percent: 0,
+      colors: ['#3366CC', '#4ECDC4', '#A5D8FF', '#FFB347', '#FF6347']
+    };
+  },
+  watch: {
+    dateRange: {
+      handler(newRange) {
+        this.fetchPieData(newRange);
+      },
+      deep: true
+    }
+  },
+  mounted() {
+    this.fetchPieData();
+  },
+  methods: {
+    async fetchPieData(dateRange = null) {
+      try {
+        let productsUrl = '/api/analysis/top5-products';
+        let percentageUrl = '/api/analysis/top5-percentage';
+        if (dateRange && dateRange.start && dateRange.end) {
+          const params = `?startDate=${dateRange.start}&endDate=${dateRange.end}`;
+          productsUrl += params;
+          percentageUrl += params;
+        }
+
+        const productsResponse = await axios.get(productsUrl);
+        const rawProducts = productsResponse.data || [];
+
+        this.chartData = rawProducts.map(product => ({
+          value: parseFloat(product.totalSales),
+          name: `${product.sku} (${product.name})`
+        }));
+
+        this.legendData = rawProducts.map(product => `${product.sku} (${product.name})`);
+
+        const percentageResponse = await axios.get(percentageUrl);
+        if (percentageResponse.data.success) {
+          this.top5Percent = Math.round(percentageResponse.data.data.top5Percentage);
+        }
+
+        this.renderChart();
+      } catch (error) {
+        console.error('?????????:', error);
+      }
+    },
+    renderChart() {
+      const chartEl = this.$refs.pieChart;
+      if (!this.chartData.length || !chartEl) return;
+      const myChart = echarts.init(chartEl);
+      const option = {
+        color: this.colors,
+        tooltip: {
+          trigger: 'item',
+          formatter: params => {
+            const index = this.chartData.findIndex(item => item.name === params.name);
+            const fullLabel = this.legendData[index] || params.name;
+            return `${fullLabel}: ${params.value.toLocaleString()} (${params.percent}%)`;
+          }
+        },
+        legend: {
+          show: true,
+          type: 'scroll',
+          orient: 'horizontal',
+          bottom: 10,
+          data: this.legendData,
+          itemGap: 20,
+          itemWidth: 15,
+          itemHeight: 10,
+          textStyle: { fontSize: 12 },
+          width: '90%',
+          height: 'auto'
+        },
+        series: [
+          {
+            name: 'Top 5 ????',
+            type: 'pie',
+            radius: ['50%', '70%'],
+            center: ['50%', '45%'],
+            data: this.chartData,
+            emphasis: {
+              itemStyle: {
+                shadowBlur: 10,
+                shadowOffsetX: 0,
+                shadowColor: 'rgba(0, 0, 0, 0.5)'
+              }
+            },
+            label: {
+              show: true,
+              formatter: params => params.name.split(' (')[0],
+              position: 'outside'
+            },
+            labelLine: { show: true }
+          }
+        ]
+      };
+      myChart.setOption(option);
+      window.addEventListener('resize', () => myChart.resize());
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+/* 样式部分和之前一样,不用修改 */
+.chart-card {
+  background-color: white;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+  position: relative;
+}
+.chart-title {
+  text-align: center;
+  font-size: 16px;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 20px;
+}
+.percentage-label {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%); 
+    pointer-events: none; 
+    z-index: 10;
+    text-align: center;
+}
+.percent-value {
+    font-size: 24px;
+    font-weight: bold;
+    color: #333;
+}
+.percent-text {
+    font-size: 12px;
+    color: #666;
+    margin-top: 5px;
+}
+[ref="pieChart"] {
+    position: relative;
+}
+</style>

+ 0 - 0
src/views/order/ordervalue/Top5PieChart_restore/index.vue


+ 441 - 0
src/views/order/ordervalue/index.vue

@@ -0,0 +1,441 @@
+<template>
+  <div class="order-value-view">
+    <!-- 头部区域 -->
+    <header class="page-header">
+      <h1 class="page-title">订单价值</h1>
+      <div class="header-controls">
+        <span class="control-label">时光回溯控制器</span>
+        <div class="control-item-group">
+      <button :class="{'time-tab': true, 'active': activeTab === '7d'}" @click="selectDateRange('7d')">最近7天</button>
+      <button :class="{'time-tab': true, 'active': activeTab === 'tm'}" @click="selectDateRange('tm')">本月</button>
+      <button :class="{'time-tab': true, 'active': activeTab === 'lm'}" @click="selectDateRange('lm')">上月</button>
+      <button :class="{'time-tab': true, 'active': activeTab === 'all'}" @click="selectDateRange('all')">全量数据</button>
+    </div>
+  </div>
+    </header>
+
+    <!-- ✨【修改点1:KPI卡片的值现在是动态的】✨ -->
+    <section class="kpi-cards-grid">
+      <KpiCard title="总交易额 (GMV)" :value="kpiData.gmv" :trend="kpiData.gmvTrend" :trend-color="getTrendColor(kpiData.gmvTrend)" icon="📈" />
+      <KpiCard title="P80 订单贡献比" :value="kpiData.p80Contribution" :trend="kpiData.p80Trend" :trend-color="getTrendColor(kpiData.p80Trend)" icon="🏆" />
+      <KpiCard title="Top 5 商品贡献比" :value="kpiData.top5Contribution" :trend="kpiData.top5Trend" :trend-color="getTrendColor(kpiData.top5Trend)" icon="🔥" />
+      <KpiCard title="平均支付响应" :value="kpiData.averagePaymentTime" :trend="kpiData.avgTimeTrend" :trend-color="getTrendColor(kpiData.avgTimeTrend)" icon="⏱" />
+    </section>
+    
+    <!-- 图表区域 -->
+    <section class="charts-area">
+      <!-- 支付决策漏斗图 -->
+      <div class="chart-wrapper funnel-chart-wrapper">
+        <FunnelChart :date-range="currentDateRange" /> 
+      </div>
+      
+      <!-- 明星商品价值环图 (Top 5) -->
+      <div class="chart-wrapper">
+        <Top5PieChart :date-range="currentDateRange" /> 
+      </div>
+    </section>
+    
+    <!-- 订单价值漏损分析 (退款) - 铺满整排 -->
+    <section class="leakage-section">
+      <div class="chart-wrapper leakage-card-wrapper full-width">
+        <LeakageCard :date-range="currentDateRange" />
+      </div>
+    </section>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+import KpiCard from './KpiCard/index.vue';
+import FunnelChart from './FunnelChart/index.vue';
+import Top5PieChart from './Top5PieChart/index.vue';
+import LeakageCard from './LeakageCard/index.vue';
+
+export default {
+  name: 'OrderValue',
+  components: {
+    KpiCard,
+    FunnelChart,
+    Top5PieChart,
+    LeakageCard
+  },
+  data() {
+    return {
+      selectedDate: new Date().toISOString().split('T')[0],
+      activeTab: '7d',
+      maxDate: '',
+      currentDateRange: { start: '', end: '' },
+      kpiData: {
+        gmv: '?0',
+        gmvTrend: '+0%',
+        p80Contribution: '0%',
+        p80Trend: '+0%',
+        top5Contribution: '0%',
+        top5Trend: '+0%',
+        averagePaymentTime: '00:00',
+        avgTimeTrend: '+0%'
+      }
+    };
+  },
+  watch: {
+    selectedDate(newVal) {
+      if (this.activeTab === '7d') {
+        const startDate = this.formatYmd(this.addDays(newVal, -6));
+        const endDate = this.formatYmd(newVal);
+        this.currentDateRange = { start: startDate, end: endDate };
+        this.fetchAllApiData({ start: startDate, end: endDate });
+      } else {
+        this.selectDateRange(this.activeTab);
+      }
+    }
+  },
+  mounted() {
+    this.initDashboard();
+  },
+  methods: {
+    pad2(value) {
+      return String(value).padStart(2, '0');
+    },
+    toDate(value) {
+      if (value instanceof Date) return new Date(value.getTime());
+      if (!value) return new Date();
+      return new Date(`${value}T00:00:00`);
+    },
+    formatYmd(value) {
+      const date = this.toDate(value);
+      return `${date.getFullYear()}-${this.pad2(date.getMonth() + 1)}-${this.pad2(date.getDate())}`;
+    },
+    addDays(value, days) {
+      const date = this.toDate(value);
+      date.setDate(date.getDate() + days);
+      return date;
+    },
+    addMonths(value, months) {
+      const date = this.toDate(value);
+      date.setMonth(date.getMonth() + months);
+      return date;
+    },
+    startOfMonth(value) {
+      const date = this.toDate(value);
+      return new Date(date.getFullYear(), date.getMonth(), 1);
+    },
+    endOfMonth(value) {
+      const date = this.toDate(value);
+      return new Date(date.getFullYear(), date.getMonth() + 1, 0);
+    },
+    async fetchAllApiData(customRange = null) {
+      try {
+        let startDate;
+        let endDate;
+        if (customRange && customRange.start && customRange.end) {
+          startDate = customRange.start;
+          endDate = customRange.end;
+        } else {
+          endDate = this.selectedDate;
+          startDate = this.formatYmd(this.addDays(endDate, -6));
+        }
+
+        const [gmvRes, p80Res, top5Res, avgTimeRes] = await Promise.all([
+          axios.get(`/api/analysis/gmv?startDate=${startDate}&endDate=${endDate}`),
+          axios.get(`/api/analysis/r-big?startDate=${startDate}&endDate=${endDate}`),
+          axios.get(`/api/analysis/top5-percentage?startDate=${startDate}&endDate=${endDate}`),
+          axios.get(`/api/analysis/average-payment-time?startDate=${startDate}&endDate=${endDate}`)
+        ]);
+
+        const gmvValue = gmvRes.data || 0;
+        this.kpiData.gmv = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(gmvValue);
+
+        const p80Value = p80Res.data?.rBigRatio || 0;
+        this.kpiData.p80Contribution = `${Math.round(p80Value)}%`;
+
+        const top5Value = top5Res.data?.data?.top5Percentage || 0;
+        this.kpiData.top5Contribution = `${Math.round(top5Value)}%`;
+
+        const avgSeconds = avgTimeRes.data?.averagePaymentSeconds || 0;
+        const minutes = Math.floor(avgSeconds / 60);
+        const seconds = Math.round(avgSeconds % 60);
+        this.kpiData.averagePaymentTime = `${this.pad2(minutes)}:${this.pad2(seconds)} ??`;
+
+        if (this.activeTab !== 'tm') {
+          this.kpiData.gmvTrend = '+0%';
+          this.kpiData.p80Trend = '+0%';
+          this.kpiData.top5Trend = '+0%';
+          this.kpiData.avgTimeTrend = '+0%';
+        }
+      } catch (error) {
+        console.error('??????:', error);
+      }
+    },
+    calculateTrend(currentValue, previousValue) {
+      if (previousValue === 0) return '+0%';
+      const change = ((currentValue - previousValue) / previousValue) * 100;
+      const sign = change >= 0 ? '+' : '';
+      return `${sign}${change.toFixed(1)}%`;
+    },
+    parseTimeString(timeString) {
+      if (!timeString) return 0;
+      const timeMatch = timeString.match(/(\d{2}):(\d{2})/);
+      if (timeMatch) {
+        const minutes = parseInt(timeMatch[1], 10);
+        const seconds = parseInt(timeMatch[2], 10);
+        return minutes * 60 + seconds;
+      }
+      return 0;
+    },
+    getTrendColor(trend) {
+      if (!trend) return 'green';
+      const value = parseFloat(trend);
+      return value >= 0 ? 'green' : 'red';
+    },
+    async fetchPreviousMonthData(currentStart) {
+      try {
+        const currentStartDate = this.toDate(currentStart);
+        const previousMonthBase = this.addMonths(currentStartDate, -1);
+        const previousMonthStart = this.formatYmd(this.startOfMonth(previousMonthBase));
+        const previousMonthEnd = this.formatYmd(this.endOfMonth(previousMonthBase));
+
+        const [gmvRes, p80Res, top5Res, avgTimeRes] = await Promise.all([
+          axios.get(`/api/analysis/gmv?startDate=${previousMonthStart}&endDate=${previousMonthEnd}`),
+          axios.get(`/api/analysis/r-big?startDate=${previousMonthStart}&endDate=${previousMonthEnd}`),
+          axios.get(`/api/analysis/top5-percentage?startDate=${previousMonthStart}&endDate=${previousMonthEnd}`),
+          axios.get(`/api/analysis/average-payment-time?startDate=${previousMonthStart}&endDate=${previousMonthEnd}`)
+        ]);
+
+        return {
+          gmv: gmvRes.data || 0,
+          p80: p80Res.data?.rBigRatio || 0,
+          top5: top5Res.data?.data?.top5Percentage || 0,
+          avgTime: avgTimeRes.data?.averagePaymentSeconds || 0
+        };
+      } catch (error) {
+        console.error('????????:', error);
+        return null;
+      }
+    },
+    async initDashboard() {
+      try {
+        const res = await axios.get('/api/analysis/max-date');
+        this.maxDate = res.data;
+        this.selectedDate = res.data;
+
+        const startDate = this.formatYmd(this.addDays(res.data, -6));
+        this.currentDateRange = { start: startDate, end: res.data };
+        await this.fetchAllApiData();
+        this.kpiData.gmvTrend = '+0%';
+        this.kpiData.p80Trend = '+0%';
+        this.kpiData.top5Trend = '+0%';
+        this.kpiData.avgTimeTrend = '+0%';
+      } catch (error) {
+        console.error('???????????????');
+        const today = this.formatYmd(new Date());
+        this.maxDate = today;
+        this.selectedDate = today;
+        const startDate = this.formatYmd(this.addDays(today, -6));
+        this.currentDateRange = { start: startDate, end: today };
+        await this.fetchAllApiData();
+        this.kpiData.gmvTrend = '+0%';
+        this.kpiData.p80Trend = '+0%';
+        this.kpiData.top5Trend = '+0%';
+        this.kpiData.avgTimeTrend = '+0%';
+      }
+    },
+    async selectDateRange(type) {
+      this.activeTab = type;
+      const baseDate = this.toDate(this.selectedDate);
+      let start;
+      let end;
+
+      if (type === '7d') {
+        end = this.formatYmd(baseDate);
+        start = this.formatYmd(this.addDays(baseDate, -6));
+      } else if (type === 'tm') {
+        start = this.formatYmd(this.startOfMonth(baseDate));
+        end = this.formatYmd(this.endOfMonth(baseDate));
+      } else if (type === 'lm') {
+        const lastMonth = this.addMonths(baseDate, -1);
+        start = this.formatYmd(this.startOfMonth(lastMonth));
+        end = this.formatYmd(this.endOfMonth(lastMonth));
+      } else if (type === 'all') {
+        start = '2022-01-01';
+        end = this.maxDate || this.formatYmd(baseDate);
+      }
+
+      this.currentDateRange = { start, end };
+
+      if (type === 'tm') {
+        await this.fetchAllApiData({ start, end });
+        const previousMonthData = await this.fetchPreviousMonthData(start);
+        if (previousMonthData) {
+          const currentGmv = parseFloat(this.kpiData.gmv.replace(/[?,]/g, '')) || 0;
+          const currentP80 = parseFloat(this.kpiData.p80Contribution.replace('%', '')) || 0;
+          const currentTop5 = parseFloat(this.kpiData.top5Contribution.replace('%', '')) || 0;
+          const currentAvgTime = this.parseTimeString(this.kpiData.averagePaymentTime) || 0;
+
+          this.kpiData.gmvTrend = this.calculateTrend(currentGmv, previousMonthData.gmv);
+          this.kpiData.p80Trend = this.calculateTrend(currentP80, previousMonthData.p80);
+          this.kpiData.top5Trend = this.calculateTrend(currentTop5, previousMonthData.top5);
+          this.kpiData.avgTimeTrend = this.calculateTrend(currentAvgTime, previousMonthData.avgTime);
+        }
+      } else {
+        this.fetchAllApiData({ start, end });
+      }
+    },
+    async fetchAllKpiData() {
+      try {
+        const [gmvRes, p80Res, top5Res, avgTimeRes] = await Promise.all([
+          axios.get('/api/analysis/gmv'),
+          axios.get('/api/analysis/r-big'),
+          axios.get('/api/analysis/top5-percentage'),
+          axios.get('/api/analysis/average-payment-time')
+        ]);
+
+        const gmvValue = gmvRes.data || 0;
+        this.kpiData.gmv = new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(gmvValue);
+        const p80Value = p80Res.data?.rBigRatio || 0;
+        this.kpiData.p80Contribution = `${Math.round(p80Value)}%`;
+        const top5Value = top5Res.data?.data?.top5Percentage || 0;
+        this.kpiData.top5Contribution = `${Math.round(top5Value)}%`;
+        const avgSeconds = avgTimeRes.data?.averagePaymentSeconds || 0;
+        const minutes = Math.floor(avgSeconds / 60);
+        const seconds = Math.round(avgSeconds % 60);
+        this.kpiData.averagePaymentTime = `${this.pad2(minutes)}:${this.pad2(seconds)} ??`;
+      } catch (error) {
+        console.error('????KPI????:', error);
+        this.kpiData.gmv = '????';
+        this.kpiData.p80Contribution = '????';
+        this.kpiData.top5Contribution = '????';
+        this.kpiData.averagePaymentTime = '????';
+      }
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+.header-controls {
+  display: flex;
+  align-items: center;
+  gap: 15px;
+  margin-top: 10px;
+}
+.control-label {
+  font-size: 14px;
+  color: #666;
+}
+.control-item-group {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+}
+.date-input {
+  padding: 4px 8px;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  font-size: 14px;
+}
+.time-tab {
+  padding: 5px 15px;
+  border: 1px solid #ddd;
+  background-color: #fff;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  transition: all 0.2s;
+}
+.time-tab:hover {
+  border-color: #188df0;
+  color: #188df0;
+}
+.time-tab.active {
+  background-color: #188df0;
+  color: #fff;
+  border-color: #188df0;
+}
+/* 样式部分和之前一样,不用修改 */
+.order-value-view {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background-color: white;
+  padding: 15px 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+}
+.page-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: #333;
+  margin: 0;
+}
+.header-controls {
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+}
+.control-item {
+  margin-left: 20px;
+  color: #666;
+}
+.time-range-selector {
+  display: flex;
+  align-items: center;
+}
+.date-input {
+  padding: 6px 10px;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  margin-right: 10px;
+}
+.time-tab {
+  background: white;
+  border: 1px solid #ddd;
+  padding: 8px 12px;
+  margin-left: -1px;
+  cursor: pointer;
+  font-size: 12px;
+  color: #555;
+  transition: all 0.2s;
+}
+.time-tab:first-of-type {
+  border-top-left-radius: 4px;
+  border-bottom-left-radius: 4px;
+}
+.time-tab:last-of-type {
+  border-top-right-radius: 4px;
+  border-bottom-right-radius: 4px;
+}
+.time-tab.active {
+  background-color: #3366CC;
+  color: white;
+  border-color: #3366CC;
+  z-index: 1;
+}
+.kpi-cards-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 20px;
+}
+.charts-area {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+.leakage-section {
+  display: flex;
+}
+.full-width {
+  width: 100%;
+}
+.chart-wrapper {
+  background-color: white;
+  padding: 15px;
+  border-radius: 8px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
+}
+</style>

+ 343 - 0
src/views/order/related/index.vue

@@ -0,0 +1,343 @@
+<template>
+  <div class="product-analysis-view">
+    <!-- 页面头部 -->
+    <header class="page-header">
+      <h1 class="page-title">商品关联透视</h1>
+      <p class="page-subtitle">探索商品之间的共现购买关系,发现最佳销售组合。</p>
+    </header>
+
+    <!-- 共现购买关系表格 -->
+    <div class="table-container">
+      <h2 class="table-title">热门商品组合</h2>
+      <table>
+        <thead>
+          <tr>
+            <th>商品 A (SKU)</th>
+            <th>商品 B (SKU)</th>
+            <th>共同购买次数</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-if="loading">
+            <td colspan="3">正在努力加载数据中... 🐾</td>
+          </tr>
+          <!-- ✨【保持列位置不变,只调整数字位置】✨ -->
+          <tr v-else-if="coPurchaseData.length > 0" v-for="(item, index) in paginatedData" :key="index" class="data-row">
+            <td class="sku-cell" :title="`商品A: ${item.productA}`">{{ item.productAId }}</td>
+            <td class="sku-cell" :title="`商品B: ${item.productB}`">{{ item.productBId }}</td>
+            <td class="count-cell" :title="`共同购买次数: ${item.coPurchaseCount}`">
+              <div class="count-wrapper">
+                {{ item.coPurchaseCount }}
+              </div>
+            </td>
+          </tr>
+          <tr v-else-if="!loading && coPurchaseData.length === 0">
+            <td colspan="3">暂时没有找到商品组合数据喵...</td>
+          </tr>
+        </tbody>
+      </table>
+      
+      <!-- 分页控件 -->
+      <div class="pagination">
+        <button 
+          :disabled="currentPage === 1" 
+          @click="prevPage"
+          class="page-button"
+        >
+          上一页
+        </button>
+        <span class="page-info">
+          第 {{ currentPage }} 页,共 {{ totalPages }} 页
+        </span>
+        <button 
+          :disabled="currentPage === totalPages" 
+          @click="nextPage"
+          class="page-button"
+        >
+          下一页
+        </button>
+      </div>
+    </div>
+
+    <!-- 网络图部分(保持不变) -->
+    <div class="chart-container">
+      <h2 class="chart-title">共购规则网络图</h2>
+      <div ref="networkChart" style="width: 100%; height: 600px;"></div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+import * as echarts from 'echarts';
+
+export default {
+  name: 'OrderRelated',
+  data() {
+    return {
+      coPurchaseData: [],
+      loading: true,
+      currentPage: 1,
+      itemsPerPage: 10
+    };
+  },
+  computed: {
+    paginatedData() {
+      const start = (this.currentPage - 1) * this.itemsPerPage;
+      const end = start + this.itemsPerPage;
+      return this.coPurchaseData.slice(start, end);
+    },
+    totalPages() {
+      return Math.ceil(this.coPurchaseData.length / this.itemsPerPage);
+    }
+  },
+  mounted() {
+    this.fetchData();
+  },
+  methods: {
+    prevPage() {
+      if (this.currentPage > 1) {
+        this.currentPage -= 1;
+      }
+    },
+    nextPage() {
+      if (this.currentPage < this.totalPages) {
+        this.currentPage += 1;
+      }
+    },
+    async fetchData() {
+      try {
+        const response = await axios.get('/api/analysis/co-purchase');
+        this.coPurchaseData = response.data || [];
+        this.renderNetworkChart(this.coPurchaseData);
+      } catch (error) {
+        console.error("获取共现购买数据失败:", error);
+      } finally {
+        this.loading = false;
+      }
+    },
+    renderNetworkChart(data) {
+      const chartEl = this.$refs.networkChart;
+      if (!chartEl || !data) return;
+      const myChart = echarts.init(chartEl);
+  
+  // 统计每个节点的出现次数(核心度)
+  const nodeCount = {};
+  // 统计每条连线的权重
+  const linkWeights = {};
+  
+  data.forEach(item => {
+    // 统计节点出现次数
+    nodeCount[item.productAId] = (nodeCount[item.productAId] || 0) + 1;
+    nodeCount[item.productBId] = (nodeCount[item.productBId] || 0) + 1;
+    
+    // 统计连线权重
+    const linkKey = `${item.productAId}-${item.productBId}`;
+    linkWeights[linkKey] = (linkWeights[linkKey] || 0) + item.coPurchaseCount;
+  });
+  
+  // 创建节点和连线
+  const nodes = [];
+  const links = [];
+  const nodeSet = new Set();
+  
+  // 用于自动聚类的颜色分类
+  const categories = [
+    { name: '社群1', itemStyle: { color: '#FF6B6B' } },
+    { name: '社群2', itemStyle: { color: '#4ECDC4' } },
+    { name: '社群3', itemStyle: { color: '#45B7D1' } },
+    { name: '社群4', itemStyle: { color: '#96CEB4' } },
+    { name: '社群5', itemStyle: { color: '#FFEAA7' } },
+    { name: '社群6', itemStyle: { color: '#DDA0DD' } }
+  ];
+  
+  // 用于社群聚类的简单算法
+  const nodeCategories = {};
+  let categoryIndex = 0;
+  
+  data.forEach(item => {
+    // 添加节点A
+    if (!nodeSet.has(item.productAId)) {
+      nodeSet.add(item.productAId);
+      // 根据出现次数确定节点大小(核心度)
+      const count = nodeCount[item.productAId];
+      // 节点大小范围:15-50
+      const symbolSize = Math.min(15 + count * 5, 50);
+      
+      // 简单的社群聚类:根据第一个关联商品确定社群
+      if (!nodeCategories[item.productAId]) {
+        nodeCategories[item.productAId] = categoryIndex % categories.length;
+        categoryIndex++;
+      }
+      
+      nodes.push({ 
+        id: item.productAId, 
+        name: item.productAId, 
+        symbolSize: symbolSize, 
+        category: nodeCategories[item.productAId],
+        value: count // 用于tooltip显示
+      });
+    }
+    
+    // 添加节点B
+    if (!nodeSet.has(item.productBId)) {
+      nodeSet.add(item.productBId);
+      // 根据出现次数确定节点大小(核心度)
+      const count = nodeCount[item.productBId];
+      // 节点大小范围:15-50
+      const symbolSize = Math.min(15 + count * 5, 50);
+      
+      // 简单的社群聚类:根据第一个关联商品确定社群
+      if (!nodeCategories[item.productBId]) {
+        nodeCategories[item.productBId] = categoryIndex % categories.length;
+        categoryIndex++;
+      }
+      
+      nodes.push({ 
+        id: item.productBId, 
+        name: item.productBId, 
+        symbolSize: symbolSize, 
+        category: nodeCategories[item.productBId],
+        value: count // 用于tooltip显示
+      });
+    }
+    
+    // 添加连线
+    const linkKey = `${item.productAId}-${item.productBId}`;
+    const weight = linkWeights[linkKey];
+    links.push({
+      source: item.productAId,
+      target: item.productBId,
+      // 线条粗细代表关联强度
+      value: weight,
+      // 使用默认线条粗细
+      lineStyle: {
+        width: 1 // 恢复到默认线条粗细
+      }
+    });
+  });
+
+  const option = {
+    tooltip: {
+      formatter: (params) => {
+        if (params.dataType === 'node') {
+          return `${params.name}<br/>出现次数: ${params.data.value}`;
+        } else if (params.dataType === 'edge') {
+          return `${params.data.source} ↔ ${params.data.target}<br/>共购次数: ${params.data.value}`;
+        }
+        return '';
+      }
+    },
+    legend: [{
+      data: categories.map(cat => cat.name),
+      bottom: 0, // 将图例放置在容器底部
+      left: 'center', // 将图例水平居中
+      itemGap: 20, // 增加图例项间距
+      itemWidth: 15,
+      itemHeight: 10,
+      backgroundColor: 'white', // 设置图例背景为白色
+      borderRadius: [8, 8, 8, 8], // 四周圆角,与页面其他组件保持一致
+      shadowColor: 'rgba(0, 0, 0, 0.1)', // 设置阴影
+      shadowBlur: 5, // 设置阴影模糊度
+      padding: [15, 20, 15, 20] // 设置内边距
+    }],
+    series: [
+      {
+        type: 'graph',
+        layout: 'force',
+        data: nodes,
+        links: links,
+        categories: categories,
+        roam: true,
+        label: {
+          show: true,
+          position: 'right',
+          formatter: '{b}',
+          fontSize: 12
+        },
+        force: {
+          repulsion: 300, // 适度增加节点间排斥力,使节点在水平方向分散
+          edgeLength: [150, 250], // 调整连线长度范围,平衡水平分散效果
+          gravity: 0.2, // 增加重力,使节点在垂直方向更集中
+          layoutAnimation: true
+        },
+        // 调整图表中心位置,将图表向上移动一点
+        center: ['50%', '40%'],
+        // 设置图表左右边界,只在水平方向拉伸
+        left: 0,
+        right: 0,
+        // 节点样式
+        itemStyle: {
+          borderColor: '#fff',
+          borderWidth: 1,
+          shadowBlur: 10,
+          shadowColor: 'rgba(0, 0, 0, 0.3)'
+        },
+        // 连线样式
+        lineStyle: {
+          opacity: 0.9,
+          curveness: 0 // 直线连接
+        },
+        // 鼠标悬停效果
+        emphasis: {
+          focus: 'adjacency',
+          lineStyle: {
+            width: 10
+          }
+        }
+      }
+    ]
+  };
+      myChart.setOption(option);
+    }
+  }
+};
+</script>
+
+<style scoped>
+.product-analysis-view { display: flex; flex-direction: column; gap: 20px; }
+.page-header, .table-container, .chart-container { background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); }
+.page-title { font-size: 20px; font-weight: 600; margin: 0; color: #333; }
+.page-subtitle { font-size: 14px; color: #666; margin-top: 8px; }
+.table-title, .chart-title { font-size: 18px; font-weight: 600; margin-bottom: 20px; color: #333; }
+table { width: 100%; border-collapse: collapse; }
+th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #f0f0f0; }
+thead th { background-color: #f8f9fa; font-size: 14px; font-weight: 600; color: #333; }
+.data-row:hover { background-color: #f8f9fa; }
+.sku-cell { font-family: 'Courier New', monospace; font-size: 14px; }
+.count-cell { text-align: left; font-size: 14px; }
+.count-wrapper { font-weight: bold; color: #3366CC; text-align: center; display: inline-block; min-width: 90px; }
+
+/* 分页样式 */
+.pagination {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 20px;
+  gap: 15px;
+}
+.page-button {
+  padding: 8px 16px;
+  background-color: #f0f4f8;
+  border: 1px solid #ddd;
+  border-radius: 4px;
+  cursor: pointer;
+  font-size: 14px;
+  font-weight: 500;
+  transition: all 0.2s;
+}
+.page-button:hover:not(:disabled) {
+  background-color: #e1e8f0;
+  transform: translateY(-1px);
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+.page-button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+.page-info {
+  font-size: 14px;
+  color: #666;
+  font-weight: 500;
+}
+</style>

+ 538 - 0
src/views/order/shopvalue/index.vue

@@ -0,0 +1,538 @@
+<template>
+  <div class="shop-value-analysis-page">
+    <!-- 1. 页面主标题 -->
+    <header class="page-header">
+      <div>
+        <h1 class="main-title">店铺价值分析</h1>
+        <p class="subtitle">实时监控关键指标与趋势</p>
+      </div>
+    </header>
+
+    <!-- 新增:Top 5 商品贡献分析 (移到顶部) -->
+    <div class="chart-card">
+      <div class="top-product-contribution-container">
+        <!-- 顶部的三个指标卡片 -->
+        <div class="kpi-card-grid">
+          <div class="kpi-card">
+            <span class="kpi-title">总销售额</span>
+            <span class="kpi-value">¥{{ formatNumber(topProductData.totalSales) }}</span>
+          </div>
+          <div class="kpi-card">
+            <span class="kpi-title">Top 5 商品总销售额</span>
+            <span class="kpi-value">¥{{ formatNumber(topProductData.top5TotalSales) }}</span>
+          </div>
+          <div class="kpi-card">
+            <span class="kpi-title">Top 5 贡献占比</span>
+            <span class="kpi-value">{{ (topProductData.contributionRatio * 100).toFixed(2) }}%</span>
+          </div>
+        </div>
+        
+        <!-- 下方的环形图 -->
+        <div class="chart-card">
+          <h3 class="chart-title">Top 5 商品销售分布</h3>
+          <div ref="donutChartRef" style="width: 100%; height: 400px;"></div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 3. 筛选与操作栏 -->
+    <div class="filter-bar">
+      <div class="filters">
+        <span>筛选:</span>
+        <select><option>全部时间</option></select>
+        <select><option>全部渠道</option></select>
+        <select><option>全部区域</option></select>
+      </div>
+      <div class="actions">
+        <button class="btn-secondary">导出数据</button>
+        <button class="btn-primary" @click="fetchData">刷新</button>
+      </div>
+    </div>
+
+    <!-- 4. 图表卡片区域 -->
+    <div class="charts-container">
+      <!-- 部门图表:销量 + 销售金额 -->
+      <div class="chart-card">
+        <div class="card-header">
+          <h3 class="chart-title">各部门业绩分析(销量/金额)</h3>
+        </div>
+        <div class="chart-wrapper">
+          <div ref="unitContributionChart" class="chart-instance"></div>
+        </div>
+      </div>
+
+      <!-- 渠道图表:销量 + 销售金额 -->
+      <div class="chart-card">
+        <div class="card-header">
+          <h3 class="chart-title">各渠道业绩分析(销量/金额)</h3>
+        </div>
+        <div class="chart-wrapper">
+          <div ref="channelTotalChart" class="chart-instance"></div>
+        </div>
+      </div>
+
+      <!-- 平台图表:销量 + 销售金额 -->
+      <div class="chart-card">
+        <div class="card-header">
+          <h3 class="chart-title">各平台价值分析(销量/金额)</h3>
+        </div>
+        <div class="chart-wrapper">
+          <div ref="platformChart" class="chart-instance"></div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios';
+import * as echarts from 'echarts';
+
+export default {
+  name: 'ShopValue',
+  data() {
+    return {
+      topProductData: {
+        totalSales: 0,
+        top5TotalSales: 0,
+        contributionRatio: 0,
+        top5Products: []
+      }
+    };
+  },
+  mounted() {
+    this.fetchData();
+    window.addEventListener('resize', this.handleResize);
+  },
+  beforeDestroy() {
+    window.removeEventListener('resize', this.handleResize);
+  },
+  methods: {
+    formatNumber(num) {
+      if (!num) return '0';
+      return new Intl.NumberFormat().format(Number(num).toFixed(0));
+    },
+    handleResize() {
+      const refs = this.$refs;
+      if (refs.unitContributionChart) echarts.getInstanceByDom(refs.unitContributionChart)?.resize();
+      if (refs.channelTotalChart) echarts.getInstanceByDom(refs.channelTotalChart)?.resize();
+      if (refs.platformChart) echarts.getInstanceByDom(refs.platformChart)?.resize();
+      if (refs.donutChartRef) echarts.getInstanceByDom(refs.donutChartRef)?.resize();
+    },
+    initDonutChart(chartData) {
+      const chartEl = this.$refs.donutChartRef;
+      if (!chartEl) return;
+      const myChart = echarts.getInstanceByDom(chartEl) || echarts.init(chartEl);
+      const option = {
+        tooltip: {
+          trigger: 'item',
+          formatter: '{a} <br/>{b} : ?{c} ({d}%)'
+        },
+        legend: {
+          orient: 'horizontal',
+          bottom: '0%',
+          left: 'center'
+        },
+        series: [
+          {
+            name: '???',
+            type: 'pie',
+            radius: ['60%', '80%'],
+            avoidLabelOverlap: false,
+            label: {
+              show: true,
+              position: 'outside',
+              formatter: '{b}: {d}%'
+            },
+            labelLine: {
+              show: true,
+              length: 15,
+              length2: 10
+            },
+            emphasis: {
+              label: {
+                show: true,
+                fontSize: '14',
+                fontWeight: 'bold'
+              }
+            },
+            data: chartData
+          }
+        ]
+      };
+      myChart.setOption(option, true);
+      return myChart;
+    },
+    async fetchTopProductData() {
+      try {
+        const response = await axios.get('/api/shop/import/top-product-contribution');
+        if (response.data.success) {
+          const data = response.data.data;
+          this.topProductData.totalSales = data.totalSales;
+          this.topProductData.top5TotalSales = data.top5TotalSales;
+          this.topProductData.contributionRatio = data.contributionRatio;
+          this.topProductData.top5Products = data.top5Products;
+
+          const chartData = data.top5Products.map(p => ({
+            name: p.productCode,
+            value: p.salesAmount
+          }));
+          this.initDonutChart(chartData);
+        }
+      } catch (error) {
+        console.error('??Top 5??????:', error);
+      }
+    },
+    initDualIndicatorBarChart(chartEl, data, categoryKey, seriesConfig) {
+      if (!chartEl) return;
+      const spacePerBar = 120;
+      const containerWidth = Math.max(800, spacePerBar * data.length);
+      chartEl.style.width = `${containerWidth}px`;
+
+      let myChart = echarts.getInstanceByDom(chartEl);
+      if (!myChart) myChart = echarts.init(chartEl);
+
+      const categories = data.map(item => item[categoryKey]);
+      const series = seriesConfig.map(({ key, name, color, formatter }) => ({
+        name,
+        type: 'bar',
+        data: data.map(item => item[key]),
+        barMaxWidth: '40px',
+        itemStyle: { color, borderRadius: [4, 4, 0, 0] },
+        yAxisIndex: seriesConfig.findIndex(c => c.key === key)
+      }));
+
+      const option = {
+        tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+        legend: { data: seriesConfig.map(c => c.name), top: 0 },
+        grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
+        xAxis: {
+          type: 'category',
+          data: categories,
+          axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
+          axisTick: { alignWithLabel: true }
+        },
+        yAxis: seriesConfig.map(({ name, formatter }, index) => ({
+          type: 'value',
+          name,
+          nameTextStyle: { color: '#666' },
+          axisLabel: {
+            color: '#666',
+            formatter: formatter || (value => {
+              if (value >= 1000000) return (value / 1000000) + 'M';
+              if (value >= 1000) return (value / 1000) + 'K';
+              return value;
+            })
+          },
+          position: index === 0 ? 'left' : 'right',
+          offset: index === 1 ? 40 : 0
+        })),
+        series
+      };
+
+      myChart.setOption(option, true);
+      return myChart;
+    },
+    initPlatformValueChart(chartEl, data) {
+      if (!chartEl) return;
+      const spacePerBar = 140;
+      const containerWidth = Math.max(800, spacePerBar * data.length);
+      chartEl.style.width = `${containerWidth}px`;
+
+      let myChart = echarts.getInstanceByDom(chartEl);
+      if (!myChart) myChart = echarts.init(chartEl);
+
+      const categories = data.map(item => item.name);
+
+      const option = {
+        tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
+        legend: { data: ['??', '????'], top: 0 },
+        grid: { left: '80px', right: '80px', top: '40px', bottom: '60px' },
+        xAxis: {
+          type: 'category',
+          data: categories,
+          axisLabel: { interval: 0, rotate: 45, color: '#666', margin: 20 },
+          axisTick: { alignWithLabel: true }
+        },
+        yAxis: [
+          {
+            type: 'value',
+            name: '??',
+            nameTextStyle: { color: '#666' },
+            axisLabel: {
+              color: '#5470c6',
+              formatter: value => {
+                if (value >= 1000000) return (value / 1000000) + 'M';
+                if (value >= 1000) return (value / 1000) + 'K';
+                return value;
+              }
+            },
+            position: 'left'
+          },
+          {
+            type: 'value',
+            name: '????(?)',
+            nameTextStyle: { color: '#e9c46a' },
+            axisLabel: {
+              color: '#e9c46a',
+              formatter: value => `?${value.toFixed(2)}`
+            },
+            position: 'right',
+            offset: 40
+          }
+        ],
+        series: [
+          {
+            name: '??',
+            type: 'bar',
+            data: data.map(item => item.totalVolume),
+            barMaxWidth: '40px',
+            itemStyle: { color: '#5470c6', borderRadius: [4, 4, 0, 0] },
+            yAxisIndex: 0
+          },
+          {
+            name: '????',
+            type: 'bar',
+            data: data.map(item => item.avgOrderValue),
+            barMaxWidth: '40px',
+            itemStyle: { color: '#e9c46a', borderRadius: [4, 4, 0, 0] },
+            yAxisIndex: 1
+          }
+        ]
+      };
+
+      myChart.setOption(option, true);
+      return myChart;
+    },
+    async fetchData() {
+      try {
+        const [
+          unitRes,
+          channelTotalRes,
+          channelContributionRes,
+          channelRoiValueRes
+        ] = await Promise.all([
+          axios.get('/api/shop/import/unit-contribution'),
+          axios.get('/api/shop/import/channel-total-contribution'),
+          axios.get('/api/shop/import/channel-contribution'),
+          axios.get('/api/shop/import/channel-roi-value')
+        ]);
+
+        if (unitRes.data.success && unitRes.data.data) {
+          const sortedData = [...unitRes.data.data].sort((a, b) => b.totalAmount - a.totalAmount);
+          this.initDualIndicatorBarChart(
+            this.$refs.unitContributionChart,
+            sortedData,
+            'name',
+            [
+              { key: 'totalVolume', name: '??', color: '#5470c6' },
+              {
+                key: 'totalAmount',
+                name: '????(?)',
+                color: '#91cc75',
+                formatter: value => `?${(value >= 10000 ? (value / 10000) + 'w' : value)}`
+              }
+            ]
+          );
+        }
+
+        if (channelTotalRes.data.success && channelTotalRes.data.data) {
+          const sortedData = [...channelTotalRes.data.data].sort((a, b) => b.totalAmount - a.totalAmount);
+          this.initDualIndicatorBarChart(
+            this.$refs.channelTotalChart,
+            sortedData,
+            'name',
+            [
+              { key: 'totalVolume', name: '??', color: '#5470c6' },
+              {
+                key: 'totalAmount',
+                name: '????(?)',
+                color: '#e9c46a',
+                formatter: value => `?${(value >= 10000 ? (value / 10000) + 'w' : value)}`
+              }
+            ]
+          );
+        }
+
+        if (
+          channelContributionRes.data.success &&
+          channelRoiValueRes.data.success &&
+          channelContributionRes.data.data &&
+          channelRoiValueRes.data.data
+        ) {
+          const platformSales = Object.entries(channelContributionRes.data.data).map(([name, value]) => ({
+            name,
+            totalVolume: Number(value)
+          }));
+
+          const platformAvgOrder = Object.entries(channelRoiValueRes.data.data).map(([name, value]) => ({
+            name,
+            avgOrderValue: Number(value)
+          }));
+
+          const mergedData = platformSales
+            .map(sale => ({
+              ...sale,
+              avgOrderValue: platformAvgOrder.find(avg => avg.name === sale.name)?.avgOrderValue || 0
+            }))
+            .sort((a, b) => b.totalVolume - a.totalVolume);
+
+          this.initPlatformValueChart(this.$refs.platformChart, mergedData);
+        }
+
+        this.fetchTopProductData();
+      } catch (error) {
+        console.error('??????:', error);
+      }
+    }
+  }
+};
+</script>
+
+
+<style scoped>
+/* 保持原有样式不变 */
+.shop-value-analysis-page {
+  padding: 24px;
+  background-color: #f4f7f9;
+  font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
+}
+
+.page-header {
+  margin-bottom: 20px;
+}
+.main-title {
+  font-size: 24px;
+  font-weight: 600;
+  color: #1d2129;
+}
+.subtitle {
+  font-size: 14px;
+  color: #86909c;
+  margin-top: 4px;
+}
+
+.kpi-grid {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  gap: 20px;
+  margin-bottom: 20px;
+}
+.kpi-card {
+  background: #fff;
+  padding: 20px;
+  border-radius: 4px;
+  border: 1px solid #e5e6eb;
+}
+.kpi-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.kpi-title {
+  font-size: 14px;
+  color: #4e5969;
+}
+.kpi-value {
+  font-size: 28px;
+  font-weight: 700;
+  color: #1d2129;
+}
+.kpi-comparison {
+  font-size: 12px;
+  margin-top: 8px;
+}
+.kpi-comparison.positive { color: #00b42a; }
+.kpi-comparison.negative { color: #f53f3f; }
+
+.filter-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: #fff;
+  padding: 16px 20px;
+  border-radius: 4px;
+  border: 1px solid #e5e6eb;
+  margin-bottom: 20px;
+}
+.filters { display: flex; align-items: center; gap: 16px; font-size: 14px; color: #4e5969; }
+.filters select { padding: 4px 8px; border-radius: 4px; border: 1px solid #e5e6eb; }
+.actions { display: flex; gap: 12px; }
+.btn-primary { background-color: #1677ff; color: #fff; border: none; padding: 6px 16px; border-radius: 4px; cursor: pointer; }
+.btn-secondary { background-color: #f2f3f5; color: #4e5969; border: 1px solid #e5e6eb; padding: 6px 16px; border-radius: 4px; cursor: pointer; }
+
+.charts-container {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+}
+.chart-card {
+  background: #fff;
+  padding: 24px;
+  border-radius: 4px;
+  border: 1px solid #e5e6eb;
+  display: grid;
+  grid-template-rows: auto 1fr;
+  overflow: hidden;
+}
+.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
+.chart-title { font-size: 16px; font-weight: 600; color: #1d2129; }
+.chart-wrapper { overflow-x: auto; overflow-y: hidden; min-width: 0; }
+.chart-instance { height: 400px; }
+
+.chart-wrapper::-webkit-scrollbar { height: 8px; }
+.chart-wrapper::-webkit-scrollbar-track { background: #f1f1f1; }
+.chart-wrapper::-webkit-scrollbar-thumb { background: #c9cdd4; border-radius: 4px; }
+
+/* 新增的Top 5商品贡献分析样式 */
+.top-product-contribution-container {
+  display: flex;
+  flex-direction: column;
+  gap: 20px; /* 卡片和图表之间的间距 */
+  width: 100%;
+}
+
+/* KPI卡片网格布局 */
+.kpi-card-grid {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr); /* 三个卡片平分宽度 */
+  gap: 20px; /* 卡片之间的间距 */
+}
+
+/* 单个KPI卡片的样式 */
+.kpi-card {
+  background-color: #ffffff;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+  display: flex;
+  flex-direction: column;
+}
+
+.kpi-title {
+  font-size: 14px;
+  color: #666;
+  margin-bottom: 10px;
+}
+
+.kpi-value {
+  font-size: 28px;
+  font-weight: bold;
+  color: #333;
+}
+
+/* 图表卡片的样式 */
+.chart-card {
+  background-color: #ffffff;
+  padding: 20px;
+  border-radius: 8px;
+  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+}
+
+.chart-title {
+  font-size: 18px;
+  color: #333;
+  margin-bottom: 20px;
+}
+</style>

+ 1 - 1
vue.config.js

@@ -32,7 +32,7 @@ module.exports = {
   devServer: {
     host: '0.0.0.0',
     port: port,
-    open: true,
+    open: false,
     proxy: {
       // detail: https://cli.vuejs.org/config/#devserver-proxy
       [process.env.VUE_APP_BASE_API]: {