Просмотр исходного кода

✨ feat: 搜索词-分析页热力图; 商品列表-选择卡片添加无数据内容显示, 避免出现行空白

WanGxC 9 месяцев назад
Родитель
Сommit
03edc4f27f

+ 1 - 1
.env.development

@@ -5,7 +5,7 @@ ENV = 'development'
 # 本地环境接口地址
 # VITE_API_URL = 'http://127.0.0.1:8000'
 # VITE_API_URL = 'http://192.168.1.225/'
-VITE_API_URL = 'http://192.168.1.27:8080/'
+ VITE_API_URL = 'http://192.168.1.16:8080/'
 # VITE_API_URL = 'https://ads.vzzon.com'
 
 # 是否启用按钮权限

+ 78 - 53
src/views/productCenter/productList/components/ProductSelectCard.vue

@@ -1,52 +1,52 @@
 <script setup lang="ts">
-import { Delete, Edit } from '@element-plus/icons-vue'
-import * as echarts from 'echarts'
-import { ElMessage } from 'element-plus'
-import { inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
-import emitter from '/@/utils/emitter'
-import { getProductCardData, postDeleteProductLine } from '/@/views/productCenter/productList/api'
+import { Delete, Edit } from '@element-plus/icons-vue';
+import * as echarts from 'echarts';
+import { ElMessage } from 'element-plus';
+import { inject, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import emitter from '/@/utils/emitter';
+import { getProductCardData, postDeleteProductLine } from '/@/views/productCenter/productList/api';
 
 emitter.on('ProductList-updateCardData', (value: any) => {
   if (value.isUpdate) {
-    cardData.value.splice(0)
-    fetchProductCardData()
+    cardData.value.splice(0);
+    fetchProductCardData();
   }
-})
+});
 
 emitter.on('TopFilters-selectValue', (value: any) => {
-  const newIndex = cardData.value.findIndex((item) => item.productlineId === value.selectValue)
+  const newIndex = cardData.value.findIndex((item) => item.productlineId === value.selectValue);
   if (newIndex !== -1) {
-    selectedCardIndex.value = newIndex
+    selectedCardIndex.value = newIndex;
   }
-})
+});
 
-const profile = <any>inject('profile')
-const dateRange = <any>inject('dateRange')
-const loading = ref(false)
-const cardData = ref([])
-const pieChartRefs = ref<HTMLDivElement[]>([])
+const profile = <any>inject('profile');
+const dateRange = <any>inject('dateRange');
+const loading = ref(false);
+const cardData = ref([]);
+const pieChartRefs = ref<HTMLDivElement[]>([]);
 
-const selectedCardIndex = ref(0)
+const selectedCardIndex = ref(0);
 
 async function fetchProductCardData() {
   try {
     const { data } = await getProductCardData({
       profileId: profile.value.profile_id,
       startDate: dateRange.value[0],
-      endDate: dateRange.value[1]
-    })
-    cardData.value = data
+      endDate: dateRange.value[1],
+    });
+    cardData.value = data;
   } catch (error) {
-    console.log('error:', error)
+    console.log('error:', error);
   }
 }
 
 function initChart() {
   cardData.value.forEach((item, index) => {
-    const chartId = `chart${index}-${item.productlineId}`
-    const el = document.getElementById(chartId)
+    const chartId = `chart${index}-${item.productlineId}`;
+    const el = document.getElementById(chartId);
     if (el) {
-      const pieChart = echarts.init(el)
+      const pieChart = echarts.init(el);
       const option = {
         animation: false,
         series: [
@@ -91,64 +91,65 @@ function initChart() {
             ],
           },
         ],
-      }
-      pieChart.setOption(option)
-      pieChartRefs.value[chartId] = pieChart
+      };
+      pieChart.setOption(option);
+      pieChartRefs.value[chartId] = pieChart;
     }
-  })
+  });
 }
 
 function selectCard(index: number, item: any) {
-  selectedCardIndex.value = index
-  const productlineId = item.productlineId
-  emitter.emit('ProductSelectCard-cardId', { productlineId: productlineId })
+  selectedCardIndex.value = index;
+  const productlineId = item.productlineId;
+  emitter.emit('ProductSelectCard-cardId', { productlineId: productlineId });
 }
 
 function editCard(item) {
-  emitter.emit('ProductTab-editProductCard', { isVisible: true, data: item })
+  emitter.emit('ProductTab-editProductCard', { isVisible: true, data: item });
 }
 
 async function deleteProductLine(item) {
   const obj = {
     productlineId: item.productlineId,
-  }
+  };
   try {
-    const response = await postDeleteProductLine(obj)
+    const response = await postDeleteProductLine(obj);
     if (response.data.code == 'success') {
-      ElMessage({ message: '已删除', type: 'success' })
-      await fetchProductCardData()
-      emitter.emit('ProductSelectCard-reloading', { reloading: true })
+      ElMessage({ message: '已删除', type: 'success' });
+      await fetchProductCardData();
+      emitter.emit('ProductSelectCard-reloading', { reloading: true });
     } else {
-      ElMessage({ message: '删除失败', type: 'error' })
+      ElMessage({ message: '删除失败', type: 'error' });
     }
   } catch (error) {
-    console.log('error:', error)
+    console.log('error:', error);
   }
 }
 
-watch(dateRange, async() => {
-  loading.value = true
-  await fetchProductCardData()
-  loading.value = false
-})
+watch(dateRange, async () => {
+  loading.value = true;
+  await fetchProductCardData();
+  loading.value = false;
+});
 
 onMounted(async () => {
-  await fetchProductCardData()
-  initChart()
-})
+  await fetchProductCardData();
+  initChart();
+});
 
 onBeforeUnmount(() => {
-  emitter.all.clear()
-})
+  emitter.all.clear();
+});
 </script>
 
 <template>
   <div class="out-container" v-loading="loading">
     <el-scrollbar>
-      <div class="scrollbar-flex-content">
+      <div class="scrollbar-flex-content" v-show="cardData">
         <el-card
           v-for="(item, index) in cardData"
           :key="item.productlineId"
+          shadow="hover"
           body-style="padding: 0;box-sizing: border-box; position: relative; width: 100%;"
           class="scrollbar-demo-item"
           :class="{ selected: selectedCardIndex === index }"
@@ -156,7 +157,9 @@ onBeforeUnmount(() => {
           <div class="pct-chart" :id="`chart${index}-${item.productlineId}`"></div>
           <el-popover v-if="index !== 0" placement="bottom" :width="150" trigger="click">
             <template #reference>
-              <el-icon class="custom-icon" @click.stop=""><Setting /></el-icon>
+              <el-icon class="custom-icon" @click.stop="">
+                <Setting />
+              </el-icon>
             </template>
             <div class="custom-popoer">
               <el-button :icon="Edit" text size="small" @click="editCard(item)">编辑</el-button>
@@ -173,6 +176,16 @@ onBeforeUnmount(() => {
           </div>
         </el-card>
       </div>
+      <el-card
+        shadow="hover"
+        body-style="padding: 0;box-sizing: border-box; position: relative; width: 100%;"
+        class="scrollbar-demo-item">
+        <el-empty description=" ">
+          <template #image>
+            <div style="color: #919398">暂无数据</div>
+          </template>
+        </el-empty>
+      </el-card>
     </el-scrollbar>
   </div>
 </template>
@@ -182,21 +195,25 @@ onBeforeUnmount(() => {
   width: 100%;
   padding: 5px 12px 0 12px;
 }
+
 .scrollbar-flex-content {
   display: flex;
 }
+
 .scrollbar-demo-item {
   flex-shrink: 0;
   display: flex;
+  gap: 5px;
   width: 202px;
   height: 92px;
-  margin: 10px 5px;
+  margin: 10px 0;
   border-radius: 4px;
   box-sizing: border-box;
   border: 1px solid transparent; /* 添加透明边框 */
   transition: border-color 0.3s; /* 可选,使边框颜色改变更平滑 */
   cursor: pointer;
 }
+
 .product-line-name {
   position: relative;
   padding: 0 12px 8px 0;
@@ -208,19 +225,23 @@ onBeforeUnmount(() => {
   white-space: nowrap;
   text-overflow: ellipsis;
 }
+
 .custom-part {
   padding-top: 8px;
 }
+
 .total-sales {
   color: #4e5969;
   font-size: 13px;
   font-weight: 700;
 }
+
 .label {
   padding-right: 4px;
   color: #c9cdd4;
   font-size: 12px;
 }
+
 .pct-chart {
   box-sizing: border-box;
   position: absolute;
@@ -229,9 +250,11 @@ onBeforeUnmount(() => {
   width: 50px;
   height: 50px;
 }
+
 .selected {
   border: 1px solid #3359b5;
 }
+
 .custom-icon {
   position: absolute;
   top: 8px;
@@ -241,10 +264,12 @@ onBeforeUnmount(() => {
   /* padding: 4px; */
   transform: rotate(90deg);
 }
+
 .custom-popoer {
   display: flex;
   flex-direction: column;
 }
+
 .left-part-container {
   padding: 10px;
   position: relative;

+ 18 - 0
src/views/searchTerm/analysisPage/IndicatorChart.vue

@@ -0,0 +1,18 @@
+<script setup lang="ts">
+/**
+ * @Name: IndicatorChart.vue
+ * @Description:
+ * @Author: Cheney
+ */
+import IndicatorHeatmap from './IndicatorHeatmap.vue'
+</script>
+
+<template>
+  <el-card shadow="hover"  style="border: none; margin-bottom: 10px">
+    <IndicatorHeatmap />
+  </el-card>
+</template>
+
+<style scoped>
+
+</style>

+ 203 - 0
src/views/searchTerm/analysisPage/IndicatorHeatmap.vue

@@ -0,0 +1,203 @@
+<script setup lang="ts">
+/**
+ * @Name: IndicatorHeatmap.vue
+ * @Description:
+ * @Author: Cheney
+ */
+
+import * as echarts from 'echarts';
+import { inject, onBeforeUnmount, onMounted, Ref, ref } from 'vue';
+import * as api from './api';
+import emitter from '/@/utils/emitter';
+
+const filter = inject<Ref>('filter');
+const hasData = ref(true);
+const heatmapLoading = ref(false);
+const chartRef = ref<HTMLElement | null>(null);
+let chart: echarts.ECharts | null = null;
+let resizeObserver: ResizeObserver | null = null;
+
+onMounted(() => {
+  initChart();
+  fetchHeatmapData();
+});
+
+onBeforeUnmount(() => {
+  emitter.all.clear();
+  // 清理 ResizeObserver
+  if (resizeObserver) {
+    resizeObserver.disconnect();
+  }
+  if (chart) {
+    chart.dispose();
+    chart = null;
+  }
+});
+
+emitter.on('QueryCondition-sendHeatmapRequest', () => {
+  fetchHeatmapData();
+});
+
+function initChart() {
+  if (chartRef.value) {
+    // chart = echarts.init(chartRef.value);
+    // chart.setOption(option);
+  }
+}
+
+async function fetchHeatmapData() {
+  heatmapLoading.value = true;
+  const query = {
+    date_start: filter.value.reportDate[0],
+    date_end: filter.value.reportDate[1],
+    layer_type: filter.value.layerType,
+    search_term: filter.value.searchTerm,
+    report_range: filter.value.reportType,
+    [filter.value.layerType.split('_')[0]]: filter.value.variable,
+    metric: filter.value.metric,
+  };
+  try {
+    const responseData = await api.getHeatmapData(query);
+    if (!responseData.data || responseData.data.length === 0) {
+      // 处理空数据的情况
+      hasData.value = false;
+      if (chart) {
+        chart.clear();
+      }
+      return;
+    }
+    hasData.value = true;
+    const days = responseData.data.map((item) => item.Reporting_Date); // y轴数据
+    const keywords = Object.keys(responseData.data[0]).filter((key) => key !== 'Reporting_Date'); // x轴数据
+    const data = [];
+
+    // 找出所有数值的最大值,用于设置 visualMap 的 max 值
+    const maxValue = Math.max(
+      ...responseData.data.flatMap((item) =>
+        Object.entries(item)
+          .filter(([key]) => key !== 'Reporting_Date')
+          .map(([, value]) => value as number)
+      )
+    );
+
+    responseData.data.forEach((item, yIndex) => {
+      keywords.forEach((keyword, xIndex) => {
+        if (item[keyword] !== undefined && item[keyword] !== null) {
+          data.push([xIndex, yIndex, item[keyword]]); // 只添加非空值
+        }
+      });
+    });
+
+    const option = {
+      tooltip: {
+        position: 'top',
+      },
+      grid: {
+        height: '50%',
+        top: '10%',
+      },
+      position: 'top',
+      xAxis: {
+        type: 'category',
+        name: '日期',
+        nameGap: 20,
+        nameLocation: 'start',
+        nameTextStyle: {
+          fontWeight: 'bold',
+          fontSize: 14,
+          color: '#333',
+        },
+        axisLabel: {
+          formatter: function (value) {
+            if (value.length > 10) {
+              return value.substring(0, 10) + '...';
+            }
+            return value;
+          },
+        },
+        data: keywords,
+        splitArea: {
+          show: true,
+        },
+      },
+      yAxis: {
+        type: 'category',
+        name: '搜索词',
+        nameGap: 20,
+        nameTextStyle: {
+          fontWeight: 'bold',
+          fontSize: 14,
+          color: '#333',
+        },
+        // nameTruncate: {
+        //   maxWidth: 20,
+        //   ellipsis: '...'
+        // },
+        data: days,
+        splitArea: {
+          show: true,
+        },
+      },
+      visualMap: {
+        min: 0,
+        max: maxValue, // 使用计算出的最大值
+        calculable: true,
+        orient: 'horizontal',
+        left: 'center',
+        bottom: '15%',
+      },
+      series: [
+        {
+          name: 'Punch Card',
+          type: 'heatmap',
+          data: data,
+          label: {
+            show: true,
+          },
+          emphasis: {
+            itemStyle: {
+              shadowBlur: 10,
+              shadowColor: 'rgba(0, 0, 0, 0.5)',
+            },
+          },
+        },
+      ],
+    };
+
+    if (!chart) {
+      chart = echarts.init(chartRef.value);
+    }
+    chart.setOption(option);
+
+    // 添加 ResizeObserver 以处理图表大小变化
+    if (!resizeObserver) {
+      resizeObserver = new ResizeObserver(() => {
+        chart?.resize();
+      });
+      resizeObserver.observe(chartRef.value);
+    }
+  } catch (error) {
+    hasData.value = false;
+    console.error('==Error==', error);
+  } finally {
+    heatmapLoading.value = false;
+  }
+}
+</script>
+
+<template>
+  <div v-loading="heatmapLoading">
+    <el-radio-group v-model="filter.metric" @change="fetchHeatmapData">
+      <el-radio-button label="Search_Query_Score" value="Search_Query_Score" />
+      <el-radio-button label="Search_Query_Volume" value="Search_Query_Volume" />
+    </el-radio-group>
+    <div>
+      <div v-show="!heatmapLoading && !hasData" class="no-data-message">
+        <el-empty />
+      </div>
+      <div v-show="hasData" ref="chartRef" style="width: 100%; height: 400px"></div>
+    </div>
+  </div>
+</template>
+
+<style scoped></style>

+ 1 - 2
src/views/searchTerm/analysisPage/IndicatorOverview.vue

@@ -17,7 +17,7 @@ const indicator = reactive([
   },
   {
     title: '',
-    value: '--',
+    value: '',
     compare: '',
     color: '#eefdf0',
   },
@@ -60,7 +60,6 @@ const mapping = {
 };
 
 watch(queryConditionData, (newData) => {
-  console.log('newData', newData);
   if (newData) {
     for (const key in mapping) {
       if (newData[key]) {

+ 34 - 30
src/views/searchTerm/analysisPage/QueryCondition.vue

@@ -4,60 +4,64 @@
  * @Description:
  * @Author: Cheney
  */
-import { RefreshLeft, Search, Upload, View } from '@element-plus/icons-vue';
-import { reactive, ref, watch } from 'vue';
+import { RefreshLeft, Search } from '@element-plus/icons-vue';
+import { ref, watch } from 'vue';
 import * as api from './api';
+import emitter from '/@/utils/emitter';
 
-const emit = defineEmits(['dataFetched']);
-
+const responseData = defineModel('responseData');
+const filter: any = defineModel('filter'); // 在父组件中初始化
+const pageLoading = defineModel('pageLoading', { default: false });
 const defaultLabel = ref('ASIN');
-const filter = reactive({
-  layerType: '',
-  searchTerm: '',
-  reportType: '',
-  reportDate: '',
-  variable: '',
-});
 
 watch(
-  () => filter.layerType,
+  () => filter.value.layerType,
   (newValue) => {
     if (newValue === 'asin_view') {
       defaultLabel.value = 'ASIN';
-      // filter.variable = 'asin';
     } else if (newValue === 'brand_view') {
       defaultLabel.value = 'Brand';
-      // filter.variable = 'brand';
     } else {
       defaultLabel.value = 'ASIN';
-      filter.variable = '';
+      filter.value.variable = '';
     }
   }
 );
 
-async function handleQuery() {
+function handleQuery() {
+  pageLoading.value = true;
+  fetchIndicatorData();
+  fetchHeatmapData();
+  pageLoading.value = false;
+}
+
+async function fetchIndicatorData() {
   const query = {
-    date_start: filter.reportDate[0],
-    date_end: filter.reportDate[1],
-    layer_type: filter.layerType,
-    search_term: filter.searchTerm,
-    report_range: filter.reportType,
-    [filter.variable]: filter.variable,
+    date_start: filter.value.reportDate[0],
+    date_end: filter.value.reportDate[1],
+    layer_type: filter.value.layerType,
+    search_term: filter.value.searchTerm,
+    report_range: filter.value.reportType,
+    [filter.value.layerType.split('_')[0]]: filter.value.variable,
   };
   try {
     const response = await api.getAsinMetrics(query);
-    emit('dataFetched', response.data);
+    responseData.value = response.data;
   } catch (error) {
     console.error('==Error==', error);
   }
 }
 
+async function fetchHeatmapData() {
+  emitter.emit('QueryCondition-sendHeatmapRequest');
+}
+
 function resetCondition() {
-  filter.layerType = '';
-  filter.searchTerm = '';
-  filter.reportType = '';
-  filter.reportDate = '';
-  filter.variable = '';
+  filter.value.layerType = '';
+  filter.value.searchTerm = '';
+  filter.value.reportType = '';
+  filter.value.reportDate = '';
+  filter.value.variable = '';
 }
 </script>
 
@@ -65,7 +69,7 @@ function resetCondition() {
   <el-card body-class="flex justify-between gap-3.5" shadow="hover" style="border: none; margin-bottom: 10px">
     <div class="flex flex-wrap gap-7">
       <div>
-        <span class="font-bold mr-2" style="color: #303133">报告类型:</span>
+        <span class="font-bold mr-2" style="color: #303133">层级类型:</span>
         <el-select v-model="filter.layerType" style="width: 130px">
           <el-option label="Asin View" value="asin_view"></el-option>
           <el-option label="Brand View" value="brand_view"></el-option>
@@ -95,7 +99,7 @@ function resetCondition() {
           start-placeholder="开始日期"
           end-placeholder="结束日期"
           value-format="YYYY-MM-DD"
-          :disabled-date="(time: Date) => time > new Date()"/>
+          :disabled-date="(time: Date) => time > new Date()" />
       </div>
     </div>
     <div class="flex gap-3.5">

+ 8 - 0
src/views/searchTerm/analysisPage/api.ts

@@ -9,3 +9,11 @@ export function getAsinMetrics(query: any) {
     params: query,
   });
 }
+
+export function getHeatmapData(query: any) {
+  return request({
+    url: apiPrefix + 'heatmap/',
+    method: 'GET',
+    params: query,
+  });
+}

+ 15 - 7
src/views/searchTerm/analysisPage/index.vue

@@ -4,23 +4,31 @@
  * @Description: 分析页
  * @Author: Cheney
  */
-import { onMounted, provide, ref } from 'vue';
+import { provide, ref, watch } from 'vue';
 import QueryCondition from './QueryCondition.vue';
 import IndicatorOverview from './IndicatorOverview.vue';
+import IndicatorChart from './IndicatorChart.vue';
 
+const pageLoading = ref();
 const queryConditionData = ref();
 provide('queryConditionData', queryConditionData);
 
-function handleDataUpdate(data: any) {
-  queryConditionData.value = data;
-}
-
+const filter = ref({  // 初始化 QueryCondition组件的filter
+  layerType: 'asin_view',
+  searchTerm: '',
+  reportType: 'MONTHLY',
+  reportDate: ['2024-04-01', '2024-06-01'],
+  variable: 'B00TEST0001',
+  metric: 'Search_Query_Score'
+});
+provide('filter', filter);
 </script>
 
 <template>
-  <div class="py-2 px-2.5">
-    <QueryCondition @dataFetched="handleDataUpdate" />
+  <div v-loading="pageLoading" class="py-2 px-2.5">
+    <QueryCondition v-model:response-data="queryConditionData" v-model:filter="filter" v-model:pageLoading="pageLoading" />
     <IndicatorOverview />
+    <IndicatorChart />
   </div>
 </template>