Ver código fonte

✨ feat: 创建产品线无限滚动tree 卡片数据替换

WanGxC 1 ano atrás
pai
commit
1f87a11c24

+ 5 - 0
src/views/demo/index.vue

@@ -37,6 +37,11 @@
       </div>
     </div>
   </div>
+  <div>
+    <p style="max-width: 100px; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: bottom; overflow: hidden;">
+      住在我心里孤独的 孤独的海怪 痛苦之王 开始厌倦 深海的光 停滞的海浪
+    </p>
+  </div>
 </template>
 
 <script lang="ts" setup>

+ 45 - 0
src/views/productCenter/productAnalysis/api.ts

@@ -0,0 +1,45 @@
+import { request } from '/@/utils/service'
+import { UserPageQuery, AddReq, DelReq, EditReq, InfoReq } from '@fast-crud/fast-crud'
+
+export const apiPrefix = '/api/ad_manage/sbcampaigns/'
+export function getCardData(query: UserPageQuery) {
+  return request({
+    url: apiPrefix + 'total/',
+    method: 'GET',
+    params: query,
+  })
+}
+export function getLineData(query: UserPageQuery) {
+  query['dateRangeType'] = 'D'
+  return request({
+    url: apiPrefix + 'daily/',
+    method: 'GET',
+    params: query,
+  })
+}
+
+export function getLineWeekData(query: UserPageQuery) {
+  query['dateRangeType'] = 'W'
+  return request({
+    url: apiPrefix + 'daily/',
+    method: 'GET',
+    params: query,
+  })
+}
+
+export function getLineMonthData(query: UserPageQuery) {
+  query['dateRangeType'] = 'M'
+  return request({
+    url: apiPrefix + 'daily/',
+    method: 'GET',
+    params: query,
+  })
+}
+
+export function getProductLineSelect(query) {
+  return request({
+    url: '/api/sellers/productline/',
+    method: 'GET',
+    params: query,
+  })
+}

+ 13 - 0
src/views/productCenter/productAnalysis/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+
+</script>
+
+<style scoped>
+
+</style>

+ 0 - 0
src/views/productCenter/productAnalyze/index.vue


+ 9 - 1
src/views/productCenter/productList/api.ts

@@ -4,7 +4,7 @@ import { UserPageQuery, AddReq, DelReq, EditReq, InfoReq } from '@fast-crud/fast
 export const apiPrefix = '/api/ad_manage/sbcampaigns/'
 export function getCardData(query: UserPageQuery) {
   return request({
-    url: apiPrefix + 'total/',
+    url: '/api/sellers/home/total/',
     method: 'GET',
     params: query,
   })
@@ -43,3 +43,11 @@ export function getProductLineSelect(query) {
     params: query,
   })
 }
+export function getAllProduct(query) {
+  return request({
+    url: '/api/sellers/goods/all/',
+    method: 'GET',
+    params: query,
+  })
+}
+

+ 91 - 0
src/views/productCenter/productList/components/AsinTable.vue

@@ -0,0 +1,91 @@
+<template>
+  <div style="overflow: hidden; width: 100%; height: 600px">
+    <vxe-grid v-bind="gridOptions">
+
+      <template #name_default="{ row }">
+        <vxe-button type="text" @click="openDetail(row)">点击{{ row.name }}</vxe-button>
+      </template>
+      <template #sex_default="{ row }">
+        <span>{{ formatSex(row) }}</span>
+      </template>
+    </vxe-grid>
+  </div>
+</template>
+
+<script setup>
+import { reactive } from 'vue'
+import { VXETable } from 'vxe-table'
+
+const gridOptions = reactive({
+  height: "auto",
+  border: false,
+  round: true,
+  columnConfig: {
+    resizable: true
+  },
+  toolbarConfig: {
+    custom: true,
+  },
+  columns: [
+    { field: 'name', title: 'Name', align: 'center', width: 130, slots: { default: 'name_default' } },
+    { field: 'sex', title: 'Sex', align: 'center', width: 80, slots: { default: 'sex_default' } },
+    { field: 'name', title: '产品线', align: 'left', fixed: 'left', width: 180, sortable: true },
+    { field: 'sex', title: '总销售额', align: 'right', width: 130, sortable: true },
+    { field: 'age', title: '总订单数', align: 'right', width: 130, sortable: true },
+    { field: 'time', title: '总销量', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '单均价', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '广告销售额', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '本商品广告销售额', align: 'right', width: 180, sortable: true },
+    { field: 'address', title: '广告订单数', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '本商品广告订单数', align: 'right', width: 180, sortable: true },
+    { field: 'address', title: '广告销量', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '本商品广告销量', align: 'right', width: 180, sortable: true },
+    { field: 'address', title: '花费', align: 'right', width: 130, sortable: true, showOverflow: true },
+    { field: 'address', title: 'ACOS', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: 'ROAS', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: 'TACOS', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '转化率', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '点击率', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '点击成本', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '总订单成本', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '广告订单成本', align: 'right', width: 180, sortable: true },
+    { field: 'address', title: '曝光量', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '点击量', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '会话次数', align: 'right', width: 150, sortable: true },
+    { field: 'address', title: '商品会话百分比', align: 'right', width: 150, sortable: true },
+    { field: 'address', title: '页面浏览量', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: '推荐报价(购买按钮)百分比', align: 'right', width: 180, sortable: true, showHeaderOverflow: true },
+    { field: 'address', title: 'FBA库存', align: 'right', width: 130, sortable: true },
+    { field: 'address', title: 'FBM库存', align: 'right', width: 130, sortable: true, showHeaderOverflow: true },
+  ],
+  data: [
+    { id: 10001, name: 'Test1', role: 'Develop', sex: '0', age: 28, num: 234, address: 'test abc' },
+    { id: 10002, name: 'Test2', role: 'Test', sex: '1', age: 22, num: 34, address: 'Guangzhou' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'Test3', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+    { id: 10003, name: 'TestLast', role: 'PM', sex: '0', age: 32, num: 12, address: 'Shanghai' },
+  ],
+})
+const formatSex = (row) => {
+  return row.sex === '1' ? '男' : '女'
+}
+const openDetail = (row) => {
+  VXETable.modal.message({
+    status: 'success',
+    content: `点击了${row.name}`,
+  })
+}
+</script>
+
+<style scoped>
+
+</style>

+ 323 - 0
src/views/productCenter/productList/components/DataTendency.vue

@@ -0,0 +1,323 @@
+<template>
+  <div v-loading="loading">
+    <MetricsCards v-model="metrics" :metric-items="metricsItems" @change="changeMetric"></MetricsCards>
+    <el-radio-group v-model="statDim" class="chart-button-group" @change="changeStatDim">
+      <el-radio-button label="day">日</el-radio-button>
+      <el-radio-button label="week" :disabled="!props.fetchLineWeek">周</el-radio-button>
+      <el-radio-button label="month" :disabled="!props.fetchLineWeek">月</el-radio-button>
+    </el-radio-group>
+    <div style="height: 350px" ref="chartRef"></div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, onBeforeUnmount, Ref, unref, watch, computed } from 'vue'
+import * as echarts from 'echarts'
+import { productListMetricsEnum } from '/@/views/productCenter/utils/enum'
+// import MetricsCards from '/@/components/MetricsCards/index.vue'
+import MetricsCards from '../components/MetricsCards/index.vue'
+import XEUtils from 'xe-utils'
+import { buildChartOpt, parseQueryParams } from '/@/views/adManage/utils/tools.js'
+
+
+defineOptions({
+  name: 'DataTendencyChart',
+})
+
+interface Props {
+  fetchCard: Function
+  fetchLine: Function
+  fetchLineMonth?: Function
+  fetchLineWeek?: Function
+  query: { [key: string]: any }
+  initMetric?: ShowMetric[]
+  metricEnum?: { [key: string]: string }[]
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  initMetric: () => [
+    { metric: 'Impression', color: '#0085ff', label: '曝光量' },
+    { metric: 'Click', color: '#3fd4cf', label: '点击量' },
+    { metric: 'Spend', color: '#ff9500', label: '花费' },
+  ],
+  metricEnum: () => productListMetricsEnum,
+})
+
+const metrics = ref(props.initMetric)
+
+const metricsItems: Ref<MetricData[]> = ref([])
+let chartObj: any
+const chartRef = ref()
+const statDim = ref('day')
+const option: any = {
+  dataset: {
+    source: [],
+  },
+  tooltip: {
+    trigger: 'axis',
+    axisPointer: {
+      label: {
+        backgroundColor: '#6a7985',
+      },
+    },
+  },
+  legend: {
+    selected: {}, // 控制显隐
+    show: false,
+  },
+  grid: {
+    top: 50,
+    right: 150,
+    bottom: 30,
+    left: 65,
+  },
+  xAxis: {
+    type: 'category',
+  },
+  yAxis: [
+    {
+      id: 0,
+      type: 'value',
+      name: '',
+      splitLine: {
+        show: true, // 设置显示分割线
+      },
+      axisLine: {
+        show: true,
+        lineStyle: { color: '' },
+      },
+      show: true,
+    },
+    {
+      id: 1,
+      type: 'value',
+      name: '',
+      position: 'right',
+      splitLine: {
+        show: false,
+      },
+      axisLine: {
+        show: true,
+        lineStyle: {
+          color: '',
+        },
+      },
+      show: true,
+    },
+    {
+      id: 2,
+      type: 'value',
+      position: 'right',
+      offset: 90,
+      name: '',
+      splitLine: {
+        show: false,
+      },
+      axisLine: {
+        show: true,
+        lineStyle: {
+          color: '',
+        },
+      },
+      show: true,
+    },
+  ],
+  series: [
+    {
+      id: 0,
+      name: '',
+      type: 'bar',
+      encode: {
+        x: 'Name',
+        y: '',
+      },
+      barWidth: '18px',
+      yAxisIndex: 0,
+      itemStyle: {
+        color: '',
+        borderRadius: 4,
+      },
+    },
+    {
+      id: 1,
+      name: '',
+      type: 'line',
+      encode: {
+        x: 'Name',
+        y: '',
+      },
+      symbolSize: 6,
+      symbol: 'circle',
+      smooth: true,
+      yAxisIndex: 1,
+      itemStyle: {
+        // color: '#ff9500',
+        // borderColor: '#ff9500'
+      },
+      areaStyle: {
+        // color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+        //   { offset: 0, color: '#3fd4cf53' },
+        //   { offset: 1, color: '#3fd4cf03' },
+        // ]),
+      },
+      emphasis: {
+        focus: 'series',
+      },
+    },
+    {
+      id: 2,
+      name: '',
+      type: 'line',
+      encode: {
+        x: 'Name',
+        y: '',
+      },
+      symbolSize: 6,
+      symbol: 'circle',
+      smooth: true,
+      yAxisIndex: 2,
+      itemStyle: {},
+      areaStyle: {},
+      emphasis: {
+        focus: 'series',
+      },
+    },
+  ],
+}
+const loading = ref(true)
+const queryParams = computed(() => parseQueryParams(props.query))
+
+onMounted(() => {
+  getMetricsItems()
+  addResize()
+  // initLine()
+  setTimeout(() => {
+    initLine()
+  }, 0)
+})
+onBeforeUnmount(() => {
+  if (chartObj) {
+    chartObj.dispose()
+    chartObj = null
+  }
+  removeResize()
+})
+
+const initLine = async () => {
+  chartObj = echarts.init(chartRef.value)
+  const items = await getDataset()
+  option.dataset.source = items
+
+  XEUtils.arrayEach(option.series, (info: any, index) => {
+    const color = metrics.value[index].color
+    info.name = metrics.value[index].label
+    info.encode.y = metrics.value[index].metric
+    if (info.type === 'bar') {
+      info.itemStyle.color = color
+    } else {
+      info.itemStyle = { color: color, borderColor: color }
+      info.areaStyle = {
+        color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+          { offset: 0, color: color + '53' },
+          { offset: 1, color: color + '03' },
+        ]),
+      }
+    }
+  })
+  XEUtils.arrayEach(option.yAxis, (info: any, index) => {
+    info.name = metrics.value[index].label
+    info.axisLine.lineStyle.color = metrics.value[index].color
+  })
+
+  XEUtils.arrayEach(props.metricEnum, (info) => {
+    option.legend.selected[info.label] = false
+  })
+  for (const info of metrics.value) {
+    option.legend.selected[info.label] = true
+  }
+  // console.log(option)
+  chartObj.setOption(option)
+  loading.value = false
+}
+const getDataset = async () => {
+  if (statDim.value === 'week') {
+    if (props.fetchLineWeek) {
+      const resp = await props.fetchLineWeek(queryParams.value)
+      return resp.data
+    }
+  } else if (statDim.value === 'month') {
+    if (props.fetchLineMonth) {
+      const resp = await props.fetchLineMonth(queryParams.value)
+      return resp.data
+    }
+  } else {
+    const resp = await props.fetchLine(queryParams.value)
+    return resp.data
+  }
+}
+const getMetricsItems = async () => {
+  const resp = await props.fetchCard(queryParams.value)
+  const data = resp.data
+  metricsItems.value.length = 0
+  XEUtils.arrayEach(props.metricEnum, (info) => {
+    const tmp: MetricData = {
+      label: info.label,
+      value: info.value,
+      metricVal: data[info.value],
+      gapVal: data[`gap${info.value}`],
+      preVal: data[`prev${info.value}`],
+    }
+    metricsItems.value.push(tmp)
+  })
+}
+
+const changeMetric = () => {
+  const opt = buildChartOpt(option, metrics.value)
+  chartObj.setOption(opt)
+}
+
+const changeStatDim = async () => {
+  loading.value = true
+  let source = await getDataset()
+  if (source.length > 0) {
+    chartObj.setOption({ dataset: { source: source } })
+  }
+  loading.value = false
+}
+
+watch(props.query, async () => {
+  // console.log("------watch-----queryParams", props.query)
+  loading.value = true
+  await getMetricsItems()
+  const items = await getDataset()
+  const opt = { dataset: { source: items } }
+  chartObj.setOption(opt)
+  loading.value = false
+})
+
+const resizeChart = () => {
+  chartObj.resize()
+}
+const addResize = () => {
+  window.addEventListener('resize', resizeChart)
+}
+const removeResize = () => {
+  window.removeEventListener('resize', resizeChart)
+}
+</script>
+
+<style scoped>
+.metrics-cards {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  width: 100%;
+}
+
+.chart-button-group {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 5px;
+}
+</style>

+ 149 - 0
src/views/productCenter/productList/components/MetricsCards/index.vue

@@ -0,0 +1,149 @@
+<template>
+  <div class="metrics-cards">
+    <MCard
+     v-model="info.metric"
+     :metric-items="props.metricItems"
+     :color="info.color"
+     v-for="info in displayMetrics"
+     @change-metric="changedMetric"
+     @click="clickCard(info.metric)"/>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, Ref, onBeforeMount, watch, onMounted, computed } from 'vue'
+import MCard from './mCard.vue'
+import XEUtils from 'xe-utils';
+
+interface Props {
+  modelValue: ShowMetric[],
+  metricItems: MetricData[],
+}
+const colorsMap: { [key: string]: boolean } = {}
+const props = defineProps<Props>()
+const emits = defineEmits(['change', 'update:modelValue'])
+// const allMetricItems = ref(props.metricItems)
+const selectedMetric = ref(props.modelValue)
+const displayMetrics: Ref<{metric:string, color?: string}[]> = ref([])
+
+const metricMap = computed(():{[key: string]: string} => {
+  const tmp:{[key: string]: string} = {}
+  for (const info of props.metricItems) {
+    tmp[info.value] = info.label
+  }
+  return tmp
+})
+onBeforeMount(()=> {
+  const dup:{[key: string]: boolean} = {}
+  // 初始显示图线的三个维度
+  for (const info of selectedMetric.value) {
+    displayMetrics.value.push({ metric: info.metric, color: info.color })
+    dup[info.metric] = true
+    if (info.color) { colorsMap[info.color] = true }
+  }
+})
+
+const getColor = () => {
+  for (const [k,v] of Object.entries(colorsMap)) {
+    if (!v) {
+      colorsMap[k] = true
+      return k
+    }
+  }
+  return ""
+}
+const unsetColor = (color: string ) => {
+  if (XEUtils.has(colorsMap, color)) {
+    colorsMap[color] = false
+  }
+}
+const changedMetric = (newVal: string, oldVal: string) => {
+  for (const info of props.metricItems) {
+    if (info.value === newVal) {
+      info.disabled = true 
+    } else if (info.value === oldVal) {
+      info.disabled = false
+    }
+  }
+  const index = selectedMetric.value.findIndex( info => info.metric === oldVal)
+  if (index > -1) {
+    selectedMetric.value[index].metric = newVal
+    selectedMetric.value[index].label = metricMap.value[newVal]
+    emits('update:modelValue', selectedMetric.value)
+    emits('change', selectedMetric.value)
+  }
+}
+const clickCard = (metric: string) => {
+  const index = selectedMetric.value.findIndex( info => info.metric === metric)
+  if (index > -1) {  // 已存在则删除
+    if (selectedMetric.value.length <= 1 ) return
+    const tmp = selectedMetric.value[index]
+    selectedMetric.value.splice(index, 1)
+    unsetColor(tmp.color)
+    emits('update:modelValue', selectedMetric.value)
+    emits('change', selectedMetric.value)
+  } else {  // 不存在则添加
+    if (selectedMetric.value.length === 3) { 
+      selectedMetric.value[2].metric = metric
+      selectedMetric.value[2].label = metricMap.value[metric]
+    } else {
+      const color = getColor()
+      selectedMetric.value.push({ metric: metric, color: color, label: metricMap.value[metric]})
+    }
+    emits('update:modelValue', selectedMetric.value)
+    emits('change', selectedMetric.value)
+  }
+}
+watch(selectedMetric.value, () => {
+  const cache:{ [key: string]: string } = {}
+  for (const info of selectedMetric.value) {
+    cache[info.metric] = info.color
+  }
+  for (const info of displayMetrics.value) {
+    const color = cache[info.metric]
+    if (color) {
+      info.color = color
+    } else {
+      info.color = undefined
+    }
+  }
+})
+
+watch(
+  props.metricItems,
+  () => {
+    const dup:{[key: string]: boolean} = {}
+    for (const info of displayMetrics.value) { dup[info.metric] = true }
+    let needNum = 6 - displayMetrics.value.length
+    if (needNum > 0) {  
+      // 从所有维度中选择剩余
+      for (const info of props.metricItems) {
+        if (!dup[info.value]) {
+          displayMetrics.value.push({ metric: info.value })
+          dup[info.value] = true
+          needNum --
+          if (needNum === 0) break
+        }
+      }
+    }
+    for (const info of props.metricItems) {
+      if (dup[info.value]) {
+        info.disabled = true
+      } else {
+        info.disabled = false
+      }  
+    }
+  }
+)
+
+</script>
+
+<style scoped>
+.metrics-cards {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  gap: 12px;
+  width: 100%;
+}
+</style>

+ 100 - 0
src/views/productCenter/productList/components/MetricsCards/mCard.vue

@@ -0,0 +1,100 @@
+<template>
+  <el-card class="metric-card">
+    <div class="metric-card__color" :style="boardTopStyle"></div>
+    <TextSelector v-model="metric" :options="props.metricItems" @change="changeMetric"></TextSelector>
+    <div class="metric-value">{{ selectedData?.metricVal }}</div>
+    <div class="metric-pre">
+      <span>{{ selectedData?.preVal }}&nbsp;&nbsp;</span>
+      <el-icon v-show="selectedData?.gapVal" style="display: inline-block; padding-top: 2px">
+        <Top :class="colorClass" v-if="isBoost"/>
+        <Bottom :class="colorClass" v-else/>
+      </el-icon>
+      <span :class="colorClass">{{ selectedData?.gapVal ? selectedData?.gapVal + '%' : '' }}</span>
+    </div>
+  </el-card>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import TextSelector from '/@/components/TextSelector/index.vue'
+
+defineOptions({
+  name: 'MCard'
+})
+
+interface Props {
+  modelValue: string,
+  metricItems: MetricData[],
+  color?: string,
+}
+const props = defineProps<Props>()
+const emits = defineEmits(["update:modelValue", "change-metric"])
+const metric = ref(props.modelValue)
+const changeMetric = ( newVal: string, oldVal: string) => {
+  emits('update:modelValue', newVal)
+  emits('change-metric', newVal, oldVal)
+}
+const selectedData = computed(():MetricData|null => {
+  const info = props.metricItems.find(item => item.value === metric.value)
+  if(!info) return null
+  return info
+})
+const boardTopStyle = computed(() => {
+  const style_ = { "border-top-color": "rgb(232, 244, 255)" }
+  if (props.color) { style_["border-top-color"] = props.color }
+  return style_
+})
+const isBoost = computed(():boolean => {
+  return (selectedData.value?.gapVal ?? -1) > 0
+})
+const colorClass = computed((): "green"|"red" => isBoost.value ? "green": "red")
+
+</script>
+
+<style scoped>
+:deep(.el-card__body) {
+  padding: 0;
+}
+.metric-card {
+  padding: 12px 8px;
+  height: 100px;
+
+  position: relative;
+  min-width: 150px;
+  overflow-y: hidden;
+  line-height: 1.4;
+  background-color: #fff;
+  border-radius: 10px;
+  box-shadow: 0 0 12px rgba(51,89,181,.1607843137254902);
+  cursor: pointer;
+  flex-grow: 1;
+}
+.metric-card__color {
+  position: absolute;
+  top: 0;
+  left: 8px;
+  width: calc(100% - 16px);
+  height: 0;
+  border-top: 4px solid #86909c;
+  border-left: 2px solid transparent;
+  border-right: 2px solid transparent;
+}
+.metric-value {
+  padding: 8px 0;
+  font-size: 18px;
+  font-weight: 700;
+  line-height: 25px;
+}
+
+.metric-pre {
+  color: #6b7785;
+  font-size: 12px;
+  white-space: nowrap;
+}
+.red {
+  color: red;
+}
+.green {
+  color: #1cbc0e;
+}
+</style>

+ 206 - 5
src/views/productCenter/productList/components/ProductLineDialog.vue

@@ -1,21 +1,222 @@
 <template>
-  <el-dialog v-model="createProductDialog" title="Tips" width="500">
-    <span>This is a message</span>
+  <el-dialog v-model="createProductDialog" title="新建产品线" width="1000">
+    <el-form
+      ref="ruleFormRef"
+      class="custom-ruleForm"
+      :model="ruleForm"
+      :rules="rules"
+      label-width="120px"
+      size="default"
+      label-position="top"
+      status-icon>
+      <el-form-item label="产品线名称:" prop="productLine">
+        <el-input v-model="ruleForm.productLine" maxlength="150" show-word-limit />
+      </el-form-item>
+      <el-form-item label="商品:" prop="product" required>
+        <div class="product-select">
+          <div class="left-part" v-loading="infiniteLoad">
+            <el-tabs v-model="activeName">
+              <el-tab-pane label="搜索" name="search">
+                <!-- <el-input v-model="searchInp" placeholder="请输入商品名称" @input="handleSearch" class="search-input" /> -->
+                <vxe-input v-model="searchInp" placeholder="请输入商品名称" type="search" class="search-input"></vxe-input>
+                <ul v-infinite-scroll="load" class="infinite-list" style="overflow: auto">
+                  <el-tree :data="data" :props="{ label: 'label', children: 'children' }" show-checkbox>
+                    <template #default="{ node, data }">
+                      <span style="margin-right: 6px">
+                        {{ node.label }}
+                      </span>
+                      <span class="custom-tree-node" style="display: flex">
+                        <!-- 显示子节点的图片 -->
+                        <span v-if="data.imageUrl" style="width: 50px">
+                          <img :src="data.imageUrl" alt="Product Image" style="height: 50px" />
+                        </span>
+                        <el-tooltip effect="dark" :content="data.title" placement="top-start">
+                          <span class="custom-node-title">
+                            {{ data.title }}
+                          </span>
+                        </el-tooltip>
+                      </span>
+                    </template>
+                  </el-tree>
+                </ul>
+              </el-tab-pane>
+              <el-tab-pane label="输入" name="input">Config</el-tab-pane>
+            </el-tabs>
+          </div>
+          <div class="right-part"></div>
+        </div>
+      </el-form-item>
+      <el-form-item>
+        <el-button type="primary" @click="submitForm(ruleFormRef)"> 创建 </el-button>
+        <el-button @click="resetForm(ruleFormRef)">取消</el-button>
+      </el-form-item>
+    </el-form>
   </el-dialog>
 </template>
 
 <script setup lang="ts">
-import { onBeforeUnmount, ref } from 'vue'
+import { onBeforeUnmount, onMounted, reactive, ref, inject } from 'vue'
 import emitter from '/@/utils/emitter'
+import type { FormInstance, FormRules } from 'element-plus'
+import { getAllProduct } from '../api'
+
+const profile = <any>inject('profile')
+
+const activeName = ref('search')
+
+// 搜索相关
+const searchInp = ref('')
+function handleSearch() {}
+
+// 树节点相关
+const infiniteLoad = ref(false)
+const data = ref([])
+let currentPage = 1
+let total = 0
+let limit = 10
+
+function transformProductsToTreeData(products) {
+  return products.map((product) => ({
+    label: product.parentAsin + ` (${product.childAsin.length} ASIN)`, // 使用parentAsin作为节点标签
+    // numChildren: product.childAsin.length, // 添加子节点的数量
+    children: product.childAsin.map((child) => ({
+      title: child.Title,
+      imageUrl: child.Image,
+    })),
+  }))
+}
+
+async function fetchAllProduct() {
+  infiniteLoad.value = true
+  const query = {
+    profile_id: profile.value.profile_id,
+    page: currentPage,
+    limit: limit,
+  }
+  try {
+    const res = await getAllProduct(query)
+    if (res && res.data) {
+      // 转换数据并更新data
+      const newData = transformProductsToTreeData(res.data)
+      if (currentPage > 1) {
+        data.value = [...data.value, ...newData] // 追加数据而不是替换
+      } else {
+        data.value = newData // 第一页数据,直接替换
+      }
+      total = res.total // 更新总条目数
+    }
+  } catch (error) {
+    console.log('error:', error)
+  } finally {
+    infiniteLoad.value = false
+  }
+}
+
+function load() {
+  if (currentPage * limit < total) {
+    currentPage++
+    fetchAllProduct()
+  }
+}
+
+// 表单相关
+interface RuleForm {
+  productLine: string
+}
+const ruleFormRef = ref<FormInstance>()
+const ruleForm = reactive<RuleForm>({
+  productLine: '',
+})
+
+const rules = reactive<FormRules<RuleForm>>({
+  productLine: [{ required: true, message: '请输入产品线名称', trigger: 'blur' }],
+})
+
+const submitForm = async (formEl: FormInstance | undefined) => {
+  if (!formEl) return
+  await formEl.validate((valid, fields) => {
+    if (valid) {
+      console.log('submit!')
+    } else {
+      console.log('error submit!', fields)
+    }
+  })
+}
+
+const resetForm = (formEl: FormInstance | undefined) => {
+  if (!formEl) return
+  formEl.resetFields()
+}
 
 const createProductDialog = ref(false)
-emitter.on('create-product-dialog', (value: any) => {
+emitter.on('open-productLine-dialog', (value: any) => {
   createProductDialog.value = value.isVisible
 })
 
+onMounted(() => {
+  fetchAllProduct()
+})
+
 onBeforeUnmount(() => {
   emitter.all.clear()
 })
 </script>
 
-<style scoped></style>
+<style scoped>
+::v-deep(.el-form-item__content) {
+  display: block !important;
+}
+.product-select {
+  display: flex;
+  border: 1px solid #dde0eb;
+  border-radius: 4px;
+  color: #4e5969;
+}
+.left-part {
+  width: 50%;
+}
+.right-part {
+  width: 50%;
+}
+.infinite-list {
+  height: 300px;
+  padding: 0;
+  margin: 0;
+  list-style: none;
+}
+.infinite-list .infinite-list-item {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 50px;
+  background: var(--el-color-primary-light-9);
+  margin: 10px;
+  color: var(--el-color-primary);
+}
+.infinite-list .infinite-list-item + .list-item {
+  margin-top: 10px;
+}
+::v-deep(.el-tree-node__content) {
+  height: auto;
+}
+.custom-node-title {
+  max-width: 350px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  display: inline-block;
+  vertical-align: bottom;
+  overflow: hidden;
+}
+/* Tab栏 */
+::v-deep(.el-tabs__nav-scroll) {
+  overflow: hidden;
+  margin-left: 10px;
+}
+::v-deep(.el-table__inner-wrapper::before) {
+  background-color: white;
+}
+.search-input {
+  width: 95%;
+  margin: 10px;
+}
+</style>

+ 10 - 2
src/views/productCenter/productList/components/ProductTab.vue

@@ -10,7 +10,15 @@
   </div>
 </template>
 
-<script setup>
+<script setup lang="ts">
+import { onMounted, inject } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useShopInfo } from '/@/stores/shopInfo'
+
+const shopInfo = useShopInfo()
+const { profile } = storeToRefs(shopInfo)
+const dateRange = <any>inject('dateRange')
+
 
 </script>
 
@@ -31,4 +39,4 @@
   background: var(--el-color-danger-light-9);
   color: var(--el-color-danger);
 }
-</style>
+</style>

+ 2 - 12
src/views/productCenter/productList/components/TopFilters.vue

@@ -13,7 +13,7 @@ import DateRangePicker from '/@/components/DateRangePicker/index.vue'
 import { usePublicData } from '/@/stores/publicData'
 import { storeToRefs } from 'pinia'
 import { useShopInfo } from '/@/stores/shopInfo'
-import { getProductLineSelect } from '../api'
+
 import { onMounted, ref } from 'vue'
 
 const publicData = usePublicData()
@@ -24,18 +24,8 @@ const { profile } = storeToRefs(shopInfo)
 const value = ref('')
 const options = ref([])
 
-async function getSelectData() {
-  const query = { profile_id: profile.value.profile_id }
-  try {
-    const response = await getProductLineSelect(query)
-    options.value = response.data
-  } catch (error) {
-    console.error('error:', error)
-  }
-}
-
 onMounted(()=>{
-  getSelectData()
+
 })
 </script>
 

+ 2 - 2
src/views/productCenter/productList/utils/button.ts → src/views/productCenter/productList/hooks/useButton.ts

@@ -1,6 +1,6 @@
 import { ref } from 'vue'
 
-export function useButtonGroup() {
+export default function useButtonGroup() {
   const buttons = ['产品线', '父ASIN', 'ASIN', 'SKU'] // 按钮列表
   const activeButton = ref('产品线')
   const activeStyle = {
@@ -10,7 +10,7 @@ export function useButtonGroup() {
 
   function handleClickBtn(buttonName) {
     activeButton.value = buttonName // 更新当前激活的按钮
-    console.log(activeButton.value)
+    console.log('🚀 ~ activeButton.value', activeButton.value)
   }
 
   // 返回需要在模板中使用的响应式数据和方法

+ 48 - 0
src/views/productCenter/productList/hooks/useTableColumns.ts

@@ -0,0 +1,48 @@
+import { onMounted } from "vue"
+
+export default function() {
+  const columns = [
+    { field: 'name', title: '产品线', align: "left", fixed: 'left', width: 180, sortable: true },
+    { field: 'sex', title: '总销售额', align: "right", width: 130, sortable: true },
+    { field: 'age', title: '总订单数', align: "right", width: 130, sortable: true },
+    { field: 'time', title: '总销量',  align: "right",width: 130, sortable: true },
+    { field: 'address', title: '单均价', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '广告销售额', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '本商品广告销售额', align: "right", width: 180, sortable: true },
+    { field: 'address', title: '广告订单数', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '本商品广告订单数', align: "right", width: 180, sortable: true },
+    { field: 'address', title: '广告销量', align: "right",width: 130, sortable: true },
+    { field: 'address', title: '本商品广告销量', align: "right", width: 180, sortable: true },
+    { field: 'address', title: '花费', align: "right", width: 130, sortable: true, showOverflow: true },
+    { field: 'address', title: 'ACOS', align: "right", width: 130, sortable: true },
+    { field: 'address', title: 'ROAS', align: "right", width: 130, sortable: true },
+    { field: 'address', title: 'TACOS', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '转化率', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '点击率', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '点击成本', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '总订单成本', align: "right", width: 150, sortable: true },
+    { field: 'address', title: '广告订单成本', align: "right", width: 180, sortable: true },
+    { field: 'address', title: '曝光量', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '点击量', align: "right", width: 130, sortable: true },
+    { field: 'address', title: '会话次数', align: "right", width: 150, sortable: true },
+    { field: 'address', title: '商品会话百分比', align: "right", width: 150, sortable: true, formatter: formatTalk },
+    { field: 'address', title: '页面浏览量', align: "right", width: 180, sortable: true },
+    { field: 'address', title: '推荐报价(购买按钮)百分比', align: "right", width: 180, sortable: true, showHeaderOverflow: true, formatter: formatPrice },
+    { field: 'address', title: 'FBA库存', align: "right", width: 130, sortable: true },
+    { field: 'address', title: 'FBM库存', align: "right", width: 130, sortable: true, showHeaderOverflow: true },
+  ]
+  function formatTalk({ cellValue }) {
+    const item = cellValue + "万"
+    return item
+  }
+  function formatPrice({ cellValue }) {
+    const item = cellValue + "%"
+    return item
+  }
+
+  onMounted(()=>{
+    console.log('这是useTable的onMounted')
+  })
+  return {columns,formatTalk,formatPrice}
+} 
+

+ 23 - 78
src/views/productCenter/productList/index.vue

@@ -7,6 +7,7 @@
     </div>
     <el-card>
       <DataTendencyChart
+        :metricEnum="productListMetricsEnum"
         :query="queryParams"
         :fetchCard="getCardData"
         :fetchLine="getLineData"
@@ -18,6 +19,7 @@
   <ProductTab></ProductTab>
   <div class="pl-and-asin-tables">
     <div class="asin-table-container">
+      <vxe-button class="custom-button" type="text" status="primary" content="创建产品线" icon="vxe-icon-add" @click="handleProductlog"></vxe-button>
       <div class="xp-radio-group-wrapper">
         <el-button-group>
           <el-button
@@ -31,36 +33,7 @@
           </el-button>
         </el-button-group>
       </div>
-      <div>
-        <vxe-toolbar ref="toolbarRef" custom>
-          <template #buttons>
-            <vxe-button type="text" status="primary" content="创建产品线" icon="vxe-icon-add" @click="visibleDialog"></vxe-button>
-          </template>
-          <!-- <template #tools>
-            <vxe-button type="text" icon="vxe-icon-undo" class="tool-btn"></vxe-button>
-            <vxe-button type="text" icon="vxe-icon-funnel" class="tool-btn" @click="funnelEvent"></vxe-button>
-          </template> -->
-        </vxe-toolbar>
-      </div>
-      <div style="overflow: hidden; width: 100%; height: 600px">
-        <vxe-table ref="tableRef" round height="auto" show-overflow :data="tableData" :column-config="{ resizable: true }" :row-config="{ isHover: true }">
-          <vxe-column
-            v-for="(column, index) in columns"
-            :key="index"
-            :field="column.field"
-            :title="column.title"
-            :align="column.align"
-            :fixed="column.fixed"
-            :width="column.width"
-            :sortable="column.sortable"
-            :show-overflow="column.showOverflow"
-            :show-header-overflow="column.showHeaderOverflow"
-            :formatter="column.formatter"></vxe-column>
-        </vxe-table>
-      </div>
-      <div>
-        <vxe-pager v-model:current-page="pagination.currentPage" v-model:page-size="pagination.pageSize" :total="pagination.total" size="small" />
-      </div>
+      <AsinTable></AsinTable>
     </div>
   </div>
   <ProductLineDialog></ProductLineDialog>
@@ -70,70 +43,38 @@
 import ProductTab from './components/ProductTab.vue'
 import ProductLineDialog from './components/ProductLineDialog.vue'
 import TopFilters from './components/TopFilters.vue'
-import { ref, reactive, nextTick } from 'vue'
-import { VXETable } from 'vxe-table'
+import AsinTable from './components/AsinTable.vue'
+import { ref, provide } from 'vue'
 import { storeToRefs } from 'pinia'
 import { usePublicData } from '/@/stores/publicData'
-import DataTendencyChart from '/@/views/adManage/sb/chartComponents/dataTendency.vue'
+import DataTendencyChart from './components/DataTendency.vue'
 import { useShopInfo } from '/@/stores/shopInfo'
 import { getCardData, getLineData, getLineMonthData, getLineWeekData } from './api'
-import { useButtonGroup } from './utils/button'
-import { tableColumns } from './utils/tableColumns'
+import { productListMetricsEnum } from '../utils/enum'
+import useButtonGroup from './hooks/useButton'
 import emitter from '/@/utils/emitter'
 
-const columns = tableColumns
-const publicData = usePublicData()
-const { dateRange } = storeToRefs(publicData)
+
+// 店铺信息
 const shopInfo = useShopInfo()
 const { profile } = storeToRefs(shopInfo)
+// 公共数据
+const publicData = usePublicData()
+const { dateRange } = storeToRefs(publicData)
 const queryParams = ref({
   profileId: profile.value.profile_id,
   dateRange,
 })
 
+provide('dateRange', dateRange)
+provide('profile', profile)
+
 // 按钮组
 const { buttons, activeButton, activeStyle, handleClickBtn } = useButtonGroup()
 
-// Table相关
-const pagination = reactive({
-  currentPage: 1,
-  pageSize: 10,
-  total: 10,
-})
-const tableRef = ref()
-const toolbarRef = ref()
-
-function funnelEvent() {
-  VXETable.modal.alert({ content: '点击事件' })
-}
-nextTick(() => {
-  // 将表格和工具栏进行关联
-  const $table = tableRef.value
-  const $toolbar = toolbarRef.value
-  if ($table && $toolbar) {
-    $table.connect($toolbar)
-  }
-})
-
-const tableData = ref([
-  { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
-  { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
-  { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
-  { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 23, address: 'test abc' },
-  { id: 10005, name: 'Test5', role: 'Develop', sex: 'Women', age: 30, address: 'Shanghai' },
-  { id: 10006, name: 'Test6', role: 'Designer', sex: 'Women', age: 21, address: 'test abc' },
-  { id: 10007, name: 'Test7', role: 'Test', sex: 'Man', age: 29, address: 'test abc' },
-  { id: 10008, name: 'Test8', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
-  { id: 10009, name: 'Test9', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
-  { id: 100010, name: 'Test10', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
-  { id: 100011, name: 'Test11', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
-  { id: 100012, name: 'Test12', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
-  { id: 100013, name: 'Test13', role: 'Develop', sex: 'Man', age: 35, address: 'test abc' },
-])
-
-// 激活弹窗
-function visibleDialog() {
-  emitter.emit('create-product-dialog', { isVisible: true })
+// 控制创建产品线弹窗
+function handleProductlog() {
+  emitter.emit('open-productLine-dialog', { isVisible: true })
 }
 </script>
 
@@ -164,4 +105,8 @@ function visibleDialog() {
   margin-bottom: 12px;
   align-items: center;
 }
+.custom-button {
+  position: absolute;
+  color: #3a83f7 !important;
+}
 </style>

+ 0 - 38
src/views/productCenter/productList/utils/tableColumns.ts

@@ -1,38 +0,0 @@
-export const tableColumns = [
-  { field: 'name', title: '产品线', align: "left", fixed: 'left', width: 180, sortable: true },
-  { field: 'sex', title: '总销售额', align: "right", width: 130, sortable: true },
-  { field: 'age', title: '总订单数', align: "right", width: 130, sortable: true },
-  { field: 'time', title: '总销量',  align: "right",width: 130, sortable: true },
-  { field: 'address', title: '单均价', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '广告销售额', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '本商品广告销售额', align: "right", width: 180, sortable: true },
-  { field: 'address', title: '广告订单数', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '本商品广告订单数', align: "right", width: 180, sortable: true },
-  { field: 'address', title: '广告销量', align: "right",width: 130, sortable: true },
-  { field: 'address', title: '本商品广告销量', align: "right", width: 180, sortable: true },
-  { field: 'address', title: '花费', align: "right", width: 130, sortable: true, showOverflow: true },
-  { field: 'address', title: 'ACOS', align: "right", width: 130, sortable: true },
-  { field: 'address', title: 'ROAS', align: "right", width: 130, sortable: true },
-  { field: 'address', title: 'TACOS', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '转化率', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '点击率', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '点击成本', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '总订单成本', align: "right", width: 150, sortable: true },
-  { field: 'address', title: '广告订单成本', align: "right", width: 180, sortable: true },
-  { field: 'address', title: '曝光量', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '点击量', align: "right", width: 130, sortable: true },
-  { field: 'address', title: '会话次数', align: "right", width: 150, sortable: true },
-  { field: 'address', title: '商品会话百分比', align: "right", width: 150, sortable: true, formatter: formatTalk },
-  { field: 'address', title: '页面浏览量', align: "right", width: 180, sortable: true },
-  { field: 'address', title: '推荐报价(购买按钮)百分比', align: "right", width: 180, sortable: true, showHeaderOverflow: true, formatter: formatPrice },
-  { field: 'address', title: 'FBA库存', align: "right", width: 130, sortable: true },
-  { field: 'address', title: 'FBM库存', align: "right", width: 130, sortable: true, showHeaderOverflow: true },
-]
-
-function formatTalk({ cellValue }) {
-  const item = cellValue + "万"
-  return item
-}
-export function formatPrice({ cellValue }) {
-  // console.log('cellValue', cellValue)
-}

+ 23 - 0
src/views/productCenter/utils/enum.ts

@@ -0,0 +1,23 @@
+export const productListMetricsEnum = [
+  {label: '曝光量', value: 'Impression'},
+  {label: '点击量', value: 'Click'},
+  {label: '花费', value: 'Spend'},
+  {label: '总订单数', value: 'TotalUnitOrdered'},
+  {label: '总销售额', value: 'TotalSales'},
+  {label: '单均价', value: 'TotalOrderItems'},
+  {label: '广告订单数', value: 'TotalAdPurchases'},
+  {label: '广告销售额', value: 'TotalAdSales'},
+  {label: '广告销量', value: 'TotalAdUnitOrdered'},
+  {label: '本商品广告销售额', value: 'TotalAdSalesSameSKU'},
+  {label: '本商品广告订单数', value: 'TotalAdPurchasesSameSKU'},
+  {label: '本商品广告销量', value: 'TotalAdUnitOrderedSameSKU'},
+  {label: 'SAP', value: 'SAP'},
+  {label: 'ACOS', value: 'ACOS'},
+  {label: 'ROAS', value: 'ROAS'},
+  {label: 'TACOS', value: 'TACOS'},
+  {label: '转化率', value: 'CR'},
+  {label: '点击率', value: 'CTR'},
+  {label: 'CPC', value: 'CPC'},
+  {label: 'CPO', value: 'CPO'},
+  {label: 'CPA', value: 'CPA'},
+]