فهرست منبع

新增推广商品SB-商品投放页面;修改布局样式

WanGxC 1 سال پیش
والد
کامیت
3c9c037b05

+ 1 - 1
src/components/DateRangePicker/index.vue

@@ -14,7 +14,7 @@
       :clearable="false"
       :popper-options="{placement: props.popperPlacement}"
       @change="changedValue"
-      style="border-radius: 0;"
+
     />
   </div>
 </template>

+ 82 - 18
src/theme/app.scss

@@ -368,6 +368,7 @@ body,
 // 自定义全局样式
 .asj-container {
   padding: 0px 10px 3px 5px;
+  background-color: #fafafa;
 }
 
 .fs-page-custom {
@@ -449,13 +450,13 @@ body,
   background-color: #f8f8f8;
   box-shadow: 0px 0px 0px rgba(51, 89, 181, 0.16);
 
-  .el-input__wrapper {
+  /*.el-input__wrapper {
     border-radius: 0;
-  }
+  }*/
 }
 
 .chart-tabs {
-  margin: 5px 0;
+  margin: 5px 0 15px 0;
 
   .el-tabs__nav {
     padding-left: 0 !important;
@@ -474,35 +475,98 @@ body,
   background-color: #f8f8f8;
   box-shadow: 0px 0px 0px rgba(51, 89, 181, 0.16);
 
-  .el-input__wrapper {
+  /*.el-input__wrapper {
     border-radius: 0;
-  }
+  }*/
 }
 
+// 广告总览顶部样式
 .overview-top {
   background-color: #fff;
   position: sticky;
-  top: 0px;
+  top: 0;
   z-index: 9;
-  box-shadow: 0px 0px 12px rgba(51, 89, 181, 0.16);
-
+  box-shadow: 0 0 12px rgba(51, 89, 181, 0.16);
   padding: 0 12px;
   display: flex;
   height: 40px;
   gap: 24px;
 }
 
+// 图表卡片样式
+.el-tabs.el-tabs--top.el-tabs--border-card.chart-tabs {
+  border-radius: 10px;
+  overflow: hidden;
+}
+
+// 表格样式
+.fs-crud-container .fs-crud-header {
+  padding: 0 0 0 0 !important;
+}
+
+.fs-crud-container .fs-crud-header .fs-crud-actionbar {
+  display: flex;
+  flex: 10000;
+   align-items: center;
+  min-width: 1px;
+  border: 0.5px solid #dddfe6;
+  border-top-left-radius: 10px;
+  border-bottom: 0;
+  border-right: 0;
+  margin: 0 0 0 0;
+  padding: 10px 0 10px 10px;
+  background-color: white;
+}
+
+.fs-page-content .fs-crud-container .fs-crud-header .fs-crud-toolbar {
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  margin-right: 0;
+  padding: 0 10px 0 0;
+  flex: 1;
+  border: 0.5px solid #dddfe6;
+  border-bottom: 0;
+  border-top-right-radius: 10px;
+  background-color: white;
+}
+
+.fs-header-middle+div {
+  border-left: 0.5px solid #dddfe6;
+  border-top-left-radius: 10px;
+}
+.fs-page-content .fs-crud-container .fs-crud-header .fs-header-middle+div.fs-crud-toolbar{
+  padding: 10px 10px 10px 0;
+}
+.fs-page-content .fs-crud-container .fs-crud-header .fs-crud-actionbar+div {
+  border-left: 0;
+}
+
+.fs-container .box .inner .body {
+  border-radius: 10px;
+  flex: 1;
+  padding: 0 10px 10px 10px;
+  border: 0.5px solid #dddfe6;
+  background-color: white;
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+  border-top-left-radius: 0;
+  border-top-right-radius: 0;
+  border-top: 0;
+  border-bottom-color: #ffffff00;
+}
+
+.fs-container .box .inner .footer {
+  flex-shrink: 0;
+  background: #fff;
+  border: 0.5px solid #dddfe6;
+  border-top: 0;
+  border-bottom-right-radius: 10px;
+  border-bottom-left-radius: 10px;
+}
+
+// 表格单元格样式
 .el-table__footer td.el-table__cell {
-  //border: none!important;
-  //border-top: none!important;
-  //margin-top: -1px!important;
   border: none !important;
   border-bottom: 0.5px solid rgb(211, 211, 211) !important;
-  //margin-top: 2px!important;
 }
-
-//.el-table tr:last-child td {
-//	//border: 2px !important;
-//	border-bottom: 1px solid rgb(211, 211, 211) !important;
-//}
-

+ 9 - 8
src/views/adManage/sb/campaigns/chartComponents/adStruct.vue

@@ -1,7 +1,7 @@
 <template>
   <div v-loading="loading">
     <el-row :gutter="5">
-      <el-col :span="8">
+      <el-col :span="7">
         <div>
           <!--<TextSelector v-model="modelValue" :options="computedPieOptions" @change="changePie" style="margin-top: 5px"/>-->
           <el-select v-model="modelValue" class="m-2" size="small" @change="changePie" style="width: 120px">
@@ -15,7 +15,7 @@
         </div>
         <div ref="pie" style="height: 400px;"></div>
       </el-col>
-      <el-col :span="16">
+      <el-col :span="17">
         <div style="margin-left: 40%">
           <span style="background: #3a83f7; width: 18px; height: 10px; margin-top: 8px; display: inline-block; border-radius: 3px;"></span>
           <TextSelector v-model="barModelValue1" :options="computedBarOptions1" @change="changeBarOne"
@@ -73,7 +73,6 @@ let allData = null
 
 async function setAdStructureData() {
   allData = await getAdStructureData({ startDate: dateRange.value[0], endDate: dateRange.value[1], profileId: shopInfo.profile.profile_id })
-  console.log('allData.data', allData.data)
   return allData.data
 }
 
@@ -262,11 +261,12 @@ function initChart() {
         yAxisIndex: 0,
         itemStyle: {
           color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
-            { offset: 0, color: '#3a83f7' },
-            { offset: 1, color: 'rgb(111, 209, 206)' },
+            { offset: 0, color: '#3a83f7' }, // 起始的鲜亮蓝色
+            { offset: 0.5, color: '#5a9ef4' }, // 中间色,中度蓝色
+            { offset: 1, color: '#8ab6f1' } // 结束的浅蓝色
           ]),
           // 柱状图圆角
-          borderRadius: [6, 6, 6, 6],
+          borderRadius: [4, 4, 4, 4],
         },
       },
       {
@@ -278,10 +278,11 @@ function initChart() {
         itemStyle: {
           color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
             { offset: 0, color: '#f19a37' },
-            { offset: 1, color: 'rgb(234,207,135)' },
+            { offset: 0.5, color: '#f7b96c' }, // 中间色,浅橙色
+            { offset: 1, color: 'rgb(234, 207, 135)' } // 结束的浅黄色
           ]),
           // 柱状图圆角
-          borderRadius: [6, 6, 6, 6],
+          borderRadius: [4, 4, 4, 4],
         },
       },
     ],

+ 1 - 1
src/views/adManage/sb/campaigns/chartComponents/dataTendency.vue

@@ -132,7 +132,7 @@ const option: any = {
             yAxisIndex: 0,
             itemStyle: {
                 color: '#0085ff',
-                borderRadius: [6, 6, 6, 6],
+                borderRadius: 4,
             }
         },
         {

+ 12 - 10
src/views/adManage/sb/campaigns/crud.tsx

@@ -1,8 +1,7 @@
 import * as api from './api'
-import {dict, UserPageQuery, AddReq, DelReq, EditReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet} from '@fast-crud/fast-crud'
+import {AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery} from '@fast-crud/fast-crud'
 import {inject} from 'vue'
-import {BaseColumn, SbBaseColumn} from '/@/views/adManage/utils/commonTabColumn.js'
-import {dynBidStrategyEnum} from '/@/views/adManage/utils/enum.js'
+import {SbBaseColumn} from '/@/views/adManage/utils/commonTabColumn.js'
 import {parseQueryParams} from '/@/views/adManage/utils/tools.js'
 import XEUtils from 'xe-utils'
 
@@ -30,7 +29,6 @@ export const createCrudOptions = function ({crudExpose, context}: CreateCrudOpti
     crudOptions: {
       table: {
         height: 800,
-        // showSummary: true,
         headerCellStyle: {
           backgroundColor: '#f6f7fa', // 直接设置背景颜色
           height: '20px',
@@ -47,18 +45,21 @@ export const createCrudOptions = function ({crudExpose, context}: CreateCrudOpti
       },
       actionbar: {
         show: true,
+        color: "#626aef",
         buttons: {
           add: {
             show: false
           },
           create: {
             text: '新建广告活动',
-            type: 'primary',
+            // type: 'primary',
+            color: "#626aef",
+            plain: true,
             show: true,
             click() {
 
             }
-          }
+          },
         }
       },
       search: {
@@ -82,7 +83,8 @@ export const createCrudOptions = function ({crudExpose, context}: CreateCrudOpti
       },
       rowHandle: {
         fixed: 'right',
-        width: 80,
+        width: 100,
+        align: 'center',
         buttons: {
           view: {
             show: false,
@@ -145,14 +147,14 @@ export const createCrudOptions = function ({crudExpose, context}: CreateCrudOpti
             show: false,
           },
           column: {
-            width: '90px',
+            width: '100px',
             sortable: true,
             align: 'center',
             formatter: (row) => {
               switch (row.value) {
-                case true:
+                case 1:
                   return '自动竞价'
-                case false:
+                case 0:
                   return '自定义竞价'
                 default:
                   return '-'

+ 3 - 1
src/views/adManage/sb/campaigns/index.vue

@@ -65,7 +65,7 @@ import {usePublicData} from '/@/stores/publicData'
 import {storeToRefs} from 'pinia'
 import {useRouter} from 'vue-router'
 import AdStructChart from './chartComponents/adStruct.vue'
-import DataTendencyChart from '/@/views/adManage/sp/chartComponents/dataTendency.vue'
+import DataTendencyChart from '/@/views/adManage/sb/chartComponents/dataTendency.vue'
 import {getCardData, getLineData, getLineMonthData, getLineWeekData} from './api'
 import {createMul, SbBaseColumn} from '/@/views/adManage/utils/commonTabColumn.js'
 import DataCompare from '/@/components/dataCompare/index.vue'
@@ -111,6 +111,8 @@ watch(queryParams, () => {
 
 .en-text {
   max-width: 100%;
+  font-size: 13px;
+  font-weight: 420;
   word-break: break-word;
   overflow: hidden;
   text-overflow: ellipsis;

+ 321 - 0
src/views/adManage/sb/chartComponents/dataTendency.vue

@@ -0,0 +1,321 @@
+<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 { spCampaignMetricsEnum } from '/@/views/adManage/utils/enum.js'
+import MetricsCards from '/@/components/MetricsCards/index.vue'
+import XEUtils from 'xe-utils'
+import { buildChartOpt, parseQueryParams } from '/@/views/adManage/utils/tools.js'
+
+// import { useShopInfo } from '/@/stores/shopInfo'
+// import { usePublicData } from '/@/stores/publicData'
+// import { storeToRefs } from 'pinia'
+
+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: () => spCampaignMetricsEnum
+})
+
+const metrics = ref(props.initMetric)
+// const shopInfo = useShopInfo()
+// const publicData = usePublicData()
+// const { dateRange } = storeToRefs(publicData)
+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>

+ 6 - 21
src/views/adManage/sb/index.vue

@@ -15,7 +15,6 @@
         <el-option v-for="info of portfolios" :label="info.name" :value="info.portfolioId"></el-option>
       </el-select>
     </div>
-
     <div class="asj-tabs">
       <div v-for="tab of tabs" :key="tab.name" :class="['asj-tab', { active: tabActiveName === tab.name }]" @click="tabActiveName = tab.name">
         {{ tab.label }}
@@ -33,8 +32,8 @@ import { storeToRefs } from 'pinia'
 import { GetAllPortfolios } from '/@/views/adManage/portfolios/api'
 import DateRangePicker from '/@/components/DateRangePicker/index.vue'
 import Campaigns from '/@/views/adManage/sb/campaigns/index.vue'
-import Keywords from './keywords/index.vue'
-import Targets from './targets/index.vue'
+import Keywords from '/@/views/adManage/sb/keywords/index.vue'
+import Targets from '/@/views/adManage/sb/targets/index.vue'
 import SearchTerm from './searchTerm/index.vue'
 import AdvertisedProducts from './advertisedProducts/index.vue'
 import PurchasedOtherProducts from './purchasedOtherProducts/index.vue'
@@ -48,8 +47,8 @@ const { dateRange } = storeToRefs(publicData)
 const tabActiveName = ref('Campaigns')
 const tabs = [
   { label: '广告活动', name: 'Campaigns' },
-  // { label: '关键词投放', name: 'Keywords' },
-  // { label: '商品投放', name: 'Targets' },
+  { label: '关键词', name: 'Keywords' },
+  { label: '商品投放', name: 'Targets' },
   // { label: '推广商品', name: 'AdvertisedProducts' },
   // { label: '购买的其他商品', name: 'PurchasedOtherProducts' },
   // { label: '搜索词', name: 'SearchTerm' },
@@ -57,8 +56,8 @@ const tabs = [
 ]
 const tabsComponents: any = {
   Campaigns,
-  // Keywords,
-  // Targets,
+  Keywords,
+  Targets,
   // AdvertisedProducts,
   // PurchasedOtherProducts,
   // SearchTerm,
@@ -75,18 +74,4 @@ onBeforeMount(async () => {
 </script>
 
 <style scoped>
-::v-deep(.el-tabs.el-tabs--top.el-tabs--border-card.chart-tabs) {
-  border-radius: 10px;
-  overflow: hidden;
-}
-.fs-container .box .inner .body {
-  boder-radius: 10px;
-}
-::v-deep(.fs-container .box .inner .body) {
-  border-radius: 10px;
-  flex: 1;
-  padding: 10px;
-  border: 0.5px solid #dddfe6;
-  background-color: white;
-}
 </style>

+ 84 - 0
src/views/adManage/sb/keywords/api.ts

@@ -0,0 +1,84 @@
+import {request} from '/@/utils/service'
+import {AddReq, DelReq, EditReq, UserPageQuery} from '@fast-crud/fast-crud'
+
+export const apiPrefix = '/api/ad_manage/sbkeywords/';
+export function GetList(query: UserPageQuery) {
+  return request({
+    url: apiPrefix + "list/",
+    method: 'get',
+    params: query,
+  })
+}
+export function GetObj(id: any) {
+  return request({
+    url: apiPrefix + id + "/",
+    method: 'get',
+  });
+}
+
+export function AddObj(obj: AddReq) {
+  return request({
+    url: apiPrefix,
+    method: 'post',
+    data: obj,
+  });
+}
+
+export function UpdateObj(obj: EditReq) {
+  return request({
+    url: apiPrefix + obj.id + '/',
+    method: 'put',
+    data: obj,
+  });
+}
+
+export function DelObj(id: DelReq) {
+  return request({
+    url: apiPrefix + id + '/',
+    method: 'delete',
+    data: { id },
+  });
+}
+
+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 getAdStructureData(query: UserPageQuery) {
+  return request({
+    url: apiPrefix + "structure/",
+    method: 'GET',
+    params: query
+  })
+}

+ 241 - 0
src/views/adManage/sb/keywords/chartComponents/adStruct.vue

@@ -0,0 +1,241 @@
+<template>
+  <div v-loading="loading">
+    <el-row :gutter="5">
+      <el-col :span="24">
+        <div style="margin-left: 45%">
+          <span style="background: #3a83f7; width: 18px; height: 10px; margin-top: 8px; display: inline-block; border-radius: 3px;"></span>
+          <TextSelector v-model="barModelValue1" :options="computedBarOptions1" @change="changeBarOne" style="margin-top: 5px; margin-left: 8px;"/>
+          <span style="background: #f19a37; width: 18px; height: 10px; margin-top: 8px; margin-left: 20px; display: inline-block; border-radius: 3px;"></span>
+          <TextSelector v-model="barModelValue2" :options="computedBarOptions2" @change="changeBarTwo" style="margin-top: 5px; margin-left: 8px;"/>
+        </div>
+        <div ref="bar" style="height: 400px;"></div>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref, watch } from "vue"
+import * as echarts from "echarts"
+import TextSelector from '/@/components/TextSelector/index.vue'
+import { getAdStructureData } from "/@/views/adManage/sb/keywords/api"
+import { createDisabledOptions } from '../../../utils/dropdowndisable'
+import { barOptions1, barOptions2, barOptionsMap } from "/@/views/adManage/utils/enum"
+import { useShopInfo } from '/@/stores/shopInfo'
+import { usePublicData } from "/@/stores/publicData"
+import { storeToRefs } from 'pinia'
+
+
+const shopInfo = useShopInfo()
+const publicData = usePublicData()
+const {dateRange} = storeToRefs(publicData)
+const {profile} = storeToRefs(shopInfo)
+let barChart = ref()
+const pie = ref()
+const bar = ref()
+const loading = ref(true)
+
+// const dateRange = inject('dateRange')
+
+// 下拉框相关
+let barModelValue1 = ref(barOptions1[0].value)
+let barModelValue2 = ref(barOptions2[2].value)
+
+onMounted(async () => {
+  barChart = echarts.init(bar.value)
+
+  window.addEventListener('resize', resizeChart)  // 监听窗口大小变化,调整图表大小
+  setTimeout(() => {
+    resizeChart()
+  }, 0)
+
+  await initBarData()
+  initChart()
+})
+
+// 获取总数据
+let allData = null
+
+async function setAdStructureData() {
+  allData = await getAdStructureData({ startDate: dateRange.value[0], endDate: dateRange.value[1], profileId: shopInfo.profile.profile_id })
+  return allData.data
+}
+
+// 饼图总数据和柱状图总数据
+let barData = null
+let responseData = null
+// 柱状图初始数据
+let ACOSList
+let SpendList
+let xAxisList
+let xAxisMapList
+
+async function initBarData() {
+  responseData = await setAdStructureData()
+  barData = responseData
+  // 柱状图初始化数据
+  ACOSList = barData.map(item => item.ACOS)
+  SpendList = barData.map(item => item.Spend)
+  // 将x轴映射为中文
+  xAxisList = barData.map(item => item.matchType)
+  const classificationMap = {
+    'BROAD': '关键词-广泛',
+    'category': '品类',
+    'EXACT': '关键词-精准',
+    'asin': '商品',
+    'PHRASE': '关键词-词组',
+    'close-match': '紧密匹配',
+    'loose-match': '广泛匹配',
+    'substitutes': '同类商品',
+    'complements': '关联商品'
+  }
+  xAxisMapList = xAxisList.map(item => classificationMap[item])
+  loading.value = false
+}
+
+// 重置图像
+let flag = ref()
+let option
+let option2
+
+// 点击下拉框后重新渲染柱状图
+function changeBarOne(newValue) {
+  barModelValue1.value = newValue
+  updateBarChart()
+}
+
+function changeBarTwo(newValue) {
+  barModelValue2.value = newValue
+  updateBarChart()
+}
+
+function updateBarChart() {
+  const barValues1 = barData.map(item => item[barModelValue1.value])
+  const barValues2 = barData.map(item => item[barModelValue2.value])
+
+  option.series[0].data = barValues1
+  option.series[1].data = barValues2
+  barChart.setOption(option)
+}
+
+// 监听时间变化重新渲染表格
+watch(dateRange, async () => {
+  if (dateRange.value) {
+    loading.value = true
+    const resp = await setAdStructureData()
+    updateBarChartData(resp)
+    loading.value = false
+  }
+})
+
+// 根据新数据和当前下拉框选择更新 柱状图数据
+function updateBarChartData(resp) {
+  const barValues1 = resp.map(item => item[barModelValue1.value])
+  const barValues2 = resp.map(item => item[barModelValue2.value])
+
+  option.series[0].data = barValues1
+  option.series[1].data = barValues2
+  barChart.setOption(option)
+}
+
+const computedBarOptions1 = computed(() => createDisabledOptions(barOptions1, barModelValue2.value, barModelValue1.value))
+const computedBarOptions2 = computed(() => createDisabledOptions(barOptions2, barModelValue1.value, barModelValue2.value))
+
+// 初始化图表
+function initChart() {
+  // 柱状图配置
+  option = {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'shadow',
+        label: {
+          backgroundColor: '#6a7985'
+        }
+      }
+    },
+    // legend: {data: ['数据1', '数据2'],},
+    toolbox: {
+      feature: {
+        saveAsImage: { yAxisIndex: 'none' }
+      }
+    },
+    grid: { top: 50, right: 60, bottom: 50, left: 60 },
+    xAxis: [
+      {
+        type: 'category',
+        boundaryGap: true,
+        data: xAxisMapList,
+      },
+    ],
+    yAxis: [
+      {
+        type: 'value',
+        axisLine: {
+          show: true,
+          lineStyle: {
+            color: '#3a83f7' // 第一个 Y 轴的颜色
+          }
+        }
+      },
+      {
+        type: 'value',
+        splitLine: {
+          show: false
+        },
+        axisLine: {
+          show: true,
+          lineStyle: {
+            color: '#f19a37' // 第一个 Y 轴的颜色
+          }
+        }
+      }
+    ],
+    series: [
+      {
+        name: barOptionsMap[barModelValue1.value],
+        type: 'bar',
+        barWidth: '3%',
+        data: ACOSList,
+        yAxisIndex: 0,
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: '#3a83f7' }, // 起始的鲜亮蓝色
+            { offset: 0.5, color: '#5a9ef4' }, // 中间色,中度蓝色
+            { offset: 1, color: '#8ab6f1' } // 结束的浅蓝色
+          ]),
+          // 柱状图圆角
+          borderRadius: [4, 4, 4, 4],
+        },
+      },
+      {
+        name: barOptionsMap[barModelValue2.value],
+        type: 'bar',
+        barWidth: '3%',
+        data: SpendList,
+        yAxisIndex: 1,
+        itemStyle: {
+          color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+            { offset: 0, color: '#f19a37' },
+            { offset: 0.5, color: '#f7b96c' }, // 中间色,浅橙色
+            { offset: 1, color: 'rgb(234, 207, 135)' } // 结束的浅黄色
+          ]),
+          // 柱状图圆角
+          borderRadius: [4, 4, 4, 4],
+        },
+      },
+    ],
+  }
+  barChart.setOption(option)
+  resizeChart()
+}
+
+function resizeChart() {
+  barChart.resize()
+}
+defineExpose({ resizeChart })
+</script>
+
+<style scoped>
+
+</style>

+ 186 - 0
src/views/adManage/sb/keywords/crud.ts

@@ -0,0 +1,186 @@
+import * as api from './api'
+import {AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery} from '@fast-crud/fast-crud'
+import {inject} from 'vue'
+import {SbBaseColumn} from '/@/views/adManage/utils/commonTabColumn.js'
+import {parseQueryParams} from '/@/views/adManage/utils/tools.js'
+import XEUtils from 'xe-utils'
+
+export const createCrudOptions = function ({crudExpose, context}: CreateCrudOptionsProps): CreateCrudOptionsRet {
+  const pageRequest = async (query: UserPageQuery) => {
+    const params = parseQueryParams(context.value)
+    XEUtils.assign(query, params)
+    return await api.GetList(query)
+  }
+  const editRequest = async ({form, row}: EditReq) => {
+    form.id = row.id
+    return await api.UpdateObj(form)
+  }
+  const delRequest = async ({row}: DelReq) => {
+    return await api.DelObj(row.id)
+  }
+  const addRequest = async ({form}: AddReq) => {
+    return await api.AddObj(form)
+  }
+
+  //权限判定
+  const hasPermissions = inject('$hasPermissions')
+
+  return {
+    crudOptions: {
+      table: {
+        height: 800,
+        headerCellStyle: {
+          backgroundColor: '#f6f7fa', // 直接设置背景颜色
+          height: '20px',
+          // border: '0.5px solid #ddd',
+        },
+        cellStyle: {
+          border: 'none',
+          borderBottom: '0.5px solid #ddd',
+        },
+        showSummary: true,
+      },
+      container: {
+        fixedHeight: false
+      },
+      actionbar: {
+        show: true,
+        buttons: {
+          add: {
+            show: false
+          },
+          create: {
+            text: '新建广告活动',
+            type: 'primary',
+            show: true,
+            click() {
+
+            }
+          }
+        }
+      },
+      search: {
+        show: false
+      },
+      toolbar: {
+        buttons: {
+          search: {
+            show: true
+          },
+          compact: {
+            show: false
+          }
+        }
+      },
+      request: {
+        pageRequest,
+        addRequest,
+        editRequest,
+        delRequest,
+      },
+      rowHandle: {
+        fixed: 'right',
+        width: 100,
+        align: 'center',
+        buttons: {
+          view: {
+            show: false,
+          },
+          edit: {
+            iconRight: 'Edit',
+            type: 'text',
+            text: null
+            // show: hasPermissions('dictionary:Update'),
+          },
+          remove: {
+            show: false
+            // iconRight: 'Delete',
+            // type: 'text',
+            // text: null
+            // show: hasPermissions('dictionary:Delete'),
+          },
+        },
+      },
+      columns: {
+        id: {
+          title: 'ID',
+          column: {
+            show: false
+          },
+          form: {
+            show: false
+          }
+        },
+        keywordText:{
+          title: '关键词',
+          column: {
+            fixed: 'left',
+            width: 180,
+            sortable: true
+          },
+        },
+        state: {
+          title: '状态',
+          column: {
+            width: '90px',
+            align: 'center',
+            sortable: true,
+          },
+          type: 'dict-select',
+          search: {
+            show: true
+          },
+          dict: dict({
+            data: [
+              {value: 'paused', label: '已暂停', color: 'warning'},
+              {value: 'enabled', label: '投放中', color: 'success'},
+            ]
+          })
+        },
+        campaignName: {
+          title: '广告活动名称',
+          column: {
+            fixed: 'left',
+            width: 180,
+          }
+        },
+        adGroupName: {
+          title: '广告组名称',
+          column: {
+            width: 180,
+          }
+        },
+        suggestedBid: {
+          title: '建议竞价',
+          column: {
+            width: 130,
+            align: 'right'
+          }
+        },
+        bid: {
+          title: '出价',
+          column: {
+            width: 80,
+            align: 'left',
+            sortable: true,
+            formatter: (row) => {
+                return '$' + row.value
+            }
+          }
+        },
+        suggestedBid_lower: {
+          column: {
+            show: false,
+          }
+        },
+        suggestedBid_upper: {
+          column: {
+            show: false,
+          }
+        },
+
+        ...SbBaseColumn
+      }
+    }
+  }
+}

+ 151 - 0
src/views/adManage/sb/keywords/index.vue

@@ -0,0 +1,151 @@
+<template>
+  <fs-page class="fs-page-custom">
+    <fs-crud ref="crudRef" v-bind="crudBinding">
+      <template #header-middle>
+        <el-tabs v-model="tabActiveName" class="chart-tabs" type="border-card">
+          <el-tab-pane label="数据趋势" name="dataTendency">
+            <DataTendencyChart
+                v-if="tabActiveName === 'dataTendency'"
+                :query="queryParams"
+                :fetchCard="getCardData"
+                :fetchLine="getLineData"
+                :fetch-line-month="getLineMonthData"
+                :fetch-line-week="getLineWeekData">
+            </DataTendencyChart>
+          </el-tab-pane>
+          <el-tab-pane label="广告结构" name="adStruct">
+            <AdStructChart v-if="tabActiveName === 'adStruct'" />
+          </el-tab-pane>
+          <el-tab-pane label="散点视图" name="scatterView">
+            <div v-if="tabActiveName === 'scatterView'">散点视图</div>
+          </el-tab-pane>
+        </el-tabs>
+      </template>
+      <template #cell_percentTimeInBudget="scope">
+        <el-progress :percentage="scope.row.percentTimeInBudget > 0 ? scope.row.percentTimeInBudget * 100 : 0" />
+      </template>
+      <template #cell_keywordText="scope">
+        <el-tooltip  effect="dark" :content="scope.row.keywordText" placement="top">
+          <el-link  :underline="false" @click="jumpGroup(scope.row)">
+            <div class="en-text">{{ scope.row.keywordText }}</div>
+          </el-link>
+        </el-tooltip>
+      </template>
+      <template #cell_campaignName="scope">
+        <el-tooltip  effect="dark" :content="scope.row.campaignName" placement="top">
+          <el-link type="primary" :underline="false" @click="jumpGroup(scope.row)">
+            <div class="en-text">{{ scope.row.campaignName }}</div>
+          </el-link>
+        </el-tooltip>
+      </template>
+      <template #cell_adGroupName="scope">
+        <el-tooltip  effect="dark" :content="scope.row.adGroupName" placement="top">
+          <el-link type="primary" :underline="false" @click="jumpGroup(scope.row)">
+            <div class="en-text">{{ scope.row.adGroupName }}</div>
+          </el-link>
+        </el-tooltip>
+      </template>
+      <template #cell_suggestedBid="scope">
+        <div>${{ scope.row.suggestedBid }}</div>
+        <div class="text-range">${{ scope.row.suggestedBid_lower }} ~ ${{ scope.row.suggestedBid_upper }}</div>
+      </template>
+      <template #cell_MissedImpressions="scope">
+        {{ scope.row.MissedImpressionsLower ?? '0' }} ~ {{ scope.row.MissedImpressionsUpper ?? '0' }}
+      </template>
+      <template #cell_MissedClicks="scope"> {{ scope.row.MissedClicksLower ?? '0' }} ~ {{ scope.row.MissedClicksUpper ?? '0' }} </template>
+      <template #cell_MissedSales="scope"> {{ scope.row.MissedSalesLower ?? '0' }} ~ {{ scope.row.MissedSalesUpper ?? '0' }} </template>
+
+      <template v-for="field of Object.keys(SbBaseColumn)" #[`cell_${field}`]="scope">
+        <DataCompare
+            :field="field"
+            :value="scope.row[field]"
+            :prev-val="scope.row[`prev${field}`]"
+            :gap-val="scope.row[`gap${field}`]"
+            :date-range="dateRange"
+            :show-compare="showCompare"/>
+      </template>
+      <template #toolbar-left>
+        <div class="campare-switch">
+          <span>数据对比 </span>
+          <el-switch v-model="showCompare" size="small" />
+        </div>
+      </template>
+    </fs-crud>
+  </fs-page>
+</template>
+
+<script lang="ts" setup>
+import {onMounted, ref, watch} from 'vue'
+import {FsPage, useFs} from '@fast-crud/fast-crud'
+import {createCrudOptions} from './crud'
+import {useShopInfo} from '/@/stores/shopInfo'
+import {usePublicData} from '/@/stores/publicData'
+import {storeToRefs} from 'pinia'
+import {useRouter} from 'vue-router'
+import AdStructChart from './chartComponents/adStruct.vue'
+import DataTendencyChart from '/@/views/adManage/sp/chartComponents/dataTendency.vue'
+import {getCardData, getLineData, getLineMonthData, getLineWeekData} from './api'
+import {SbBaseColumn} from '/@/views/adManage/utils/commonTabColumn.js'
+import DataCompare from '/@/components/dataCompare/index.vue'
+
+const tabActiveName = ref('dataTendency')
+const shopInfo = useShopInfo()
+const publicData = usePublicData()
+const { dateRange } = storeToRefs(publicData)
+const { profile } = storeToRefs(shopInfo)
+const queryParams = ref({
+  profileId: profile.value.profile_id,
+  dateRange
+})
+const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context: queryParams })
+const router = useRouter()
+const showCompare = ref(false)
+
+const jumpGroup = (row: any) => {
+  router.push({
+    name: 'CampaignDetail',
+    query: { campaignId: row.campaignId, tagsViewName: row.campaignName },
+  })
+}
+
+onMounted( () => {
+  crudExpose.doRefresh()
+})
+
+watch(queryParams, () => {
+  crudExpose.doRefresh()
+}, { deep: true })
+
+</script>
+
+<style lang="scss" scoped>
+.campare-switch {
+  flex: none;
+}
+
+.en-text {
+  max-width: 100%;
+  font-size: 13px;
+  font-weight: 420;
+  word-break: break-word;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: normal;
+  display: -webkit-box;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+}
+::v-deep(.el-table__footer-wrapper td.el-table__cell:nth-child(n+3):nth-child(-n+6) .cell) {
+  display: none;
+}
+::v-deep(.el-table--border .el-table__footer-wrapper) {
+  border: none;
+}
+::v-deep(.el-table .el-table__footer-wrapper .cell) {
+  font-weight: 600;
+}
+
+.text-range {
+  color: #808184;
+}
+</style>

+ 85 - 0
src/views/adManage/sb/targets/api.ts

@@ -0,0 +1,85 @@
+import { request } from '/@/utils/service';
+import { UserPageQuery, AddReq, DelReq, EditReq, InfoReq } from '@fast-crud/fast-crud';
+import XEUtils from 'xe-utils';
+
+export const apiPrefix = '/api/ad_manage/sbtargets/';
+export function GetList(query: UserPageQuery) {
+    return request({
+        url: apiPrefix + 'list/',
+        method: 'get',
+        params: query,
+    })
+}
+export function GetObj(id: any) {
+    return request({
+        url: apiPrefix + id + "/",
+        method: 'get',
+    });
+}
+
+export function AddObj(obj: AddReq) {
+    return request({
+        url: apiPrefix,
+        method: 'post',
+        data: obj,
+    });
+}
+
+export function UpdateObj(obj: EditReq) {
+    return request({
+        url: apiPrefix + obj.id + '/',
+        method: 'put',
+        data: obj,
+    });
+}
+
+export function DelObj(id: DelReq) {
+    return request({
+        url: apiPrefix + id + '/',
+        method: 'delete',
+        data: { id },
+    });
+}
+
+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 getAdStructureData(query: UserPageQuery) {
+    return request({
+        url: apiPrefix + "structure/",
+        method: 'GET',
+        params: query
+    })
+}

+ 236 - 0
src/views/adManage/sb/targets/chartComponents/adStruct.vue

@@ -0,0 +1,236 @@
+<template>
+    <div v-loading="loading">
+        <el-row :gutter="5">
+            <el-col :span="24">
+                <div style="margin-left: 45%">
+                    <span style="background: #3a83f7; width: 18px; height: 10px; margin-top: 8px; display: inline-block; border-radius: 3px;"></span>
+                    <TextSelector v-model="barModelValue1" :options="computedBarOptions1" @change="changeBarOne" style="margin-top: 5px; margin-left: 8px;"/>
+                    <span style="background: #f19a37; width: 18px; height: 10px; margin-top: 8px; margin-left: 20px; display: inline-block; border-radius: 3px;"></span>
+                    <TextSelector v-model="barModelValue2" :options="computedBarOptions2" @change="changeBarTwo" style="margin-top: 5px; margin-left: 8px;"/>
+                </div>
+                <div ref="bar" style="height: 400px;"></div>
+            </el-col>
+        </el-row>
+    </div>
+</template>
+
+<script setup>
+import { onMounted, ref, inject, computed, watch } from "vue"
+import * as echarts from "echarts"
+import TextSelector from '/@/components/TextSelector/index.vue'
+import { getAdStructureData } from "/@/views/adManage/sp/targets/api"
+import { createDisabledOptions } from '../../../utils/dropdowndisable'
+import { barOptions1, barOptions2, barOptionsMap } from "/@/views/adManage/utils/enum"
+import { useShopInfo } from '/@/stores/shopInfo'
+
+
+const shopInfo = useShopInfo()
+let barChart = ref()
+const pie = ref()
+const bar = ref()
+const loading = ref(true)
+
+const dateRange = inject('dateRange')
+
+// 下拉框相关
+let barModelValue1 = ref(barOptions1[0].value)
+let barModelValue2 = ref(barOptions2[2].value)
+
+onMounted(async () => {
+    barChart = echarts.init(bar.value)
+
+    window.addEventListener('resize', resizeChart)  // 监听窗口大小变化,调整图表大小
+    setTimeout(() => {
+        resizeChart()
+    }, 0)
+
+    await initBarData()
+    initChart()
+})
+
+// 获取总数据
+let allData = null
+
+async function setAdStructureData() {
+    allData = await getAdStructureData({ startDate: dateRange.value[0], endDate: dateRange.value[1], profileId: shopInfo.profile.profile_id })
+    return allData.data
+}
+
+// 柱状图总数据
+let barData = null
+let responseData = null
+// 柱状图初始数据
+let ACOSList
+let SpendList
+let xAxisList
+let xAxisMapList
+
+async function initBarData() {
+    responseData = await setAdStructureData()
+    barData = responseData
+    // 柱状图初始化数据
+    ACOSList = barData.map(item => item.ACOS)
+    SpendList = barData.map(item => item.Spend)
+    // 将x轴映射为中文
+    xAxisList = barData.map(item => item.Classification)
+    const classificationMap = {
+        'BROAD': '关键词-广泛',
+        'category': '品类',
+        'EXACT': '关键词-精准',
+        'asin': '商品',
+        'PHRASE': '关键词-词组',
+        'close-match': '紧密匹配',
+        'loose-match': '广泛匹配',
+        'substitutes': '同类商品',
+        'complements': '关联商品'
+    }
+    xAxisMapList = xAxisList.map(item => classificationMap[item])
+    loading.value = false
+}
+
+// 重置图像
+let flag = ref()
+let option
+let option2
+
+// 点击下拉框后重新渲染柱状图
+function changeBarOne(newValue) {
+    barModelValue1.value = newValue
+    updateBarChart()
+}
+
+function changeBarTwo(newValue) {
+    barModelValue2.value = newValue
+    updateBarChart()
+}
+
+function updateBarChart() {
+    const barValues1 = barData.map(item => item[barModelValue1.value])
+    const barValues2 = barData.map(item => item[barModelValue2.value])
+
+    option.series[0].data = barValues1
+    option.series[1].data = barValues2
+    barChart.setOption(option)
+}
+
+// 监听时间变化重新渲染表格
+watch(dateRange, async () => {
+    if (dateRange.value) {
+        loading.value = true
+        const resp = await setAdStructureData()
+        updateBarChartData(resp)
+        loading.value = false
+    }
+})
+
+// 根据新数据和当前下拉框选择更新 柱状图数据
+function updateBarChartData(resp) {
+    const barValues1 = resp.map(item => item[barModelValue1.value])
+    const barValues2 = resp.map(item => item[barModelValue2.value])
+
+    option.series[0].data = barValues1
+    option.series[1].data = barValues2
+    barChart.setOption(option)
+}
+
+const computedBarOptions1 = computed(() => createDisabledOptions(barOptions1, barModelValue2.value, barModelValue1.value))
+const computedBarOptions2 = computed(() => createDisabledOptions(barOptions2, barModelValue1.value, barModelValue2.value))
+
+// 初始化图表
+function initChart() {
+    // 柱状图配置
+    option = {
+        tooltip: {
+            trigger: 'axis',
+            axisPointer: {
+                type: 'shadow',
+                label: {
+                    backgroundColor: '#6a7985'
+                }
+            }
+        },
+        // legend: {data: ['数据1', '数据2'],},
+        toolbox: {
+            feature: {
+                saveAsImage: { yAxisIndex: 'none' }
+            }
+        },
+        grid: { top: 50, right: 60, bottom: 50, left: 60 },
+        xAxis: [
+            {
+                type: 'category',
+                boundaryGap: true,
+                data: xAxisMapList,
+            },
+        ],
+        yAxis: [
+            {
+                type: 'value',
+                axisLine: {
+                    show: true,
+                    lineStyle: {
+                        color: '#3a83f7' // 第一个 Y 轴的颜色
+                    }
+                }
+            },
+            {
+                type: 'value',
+                splitLine: {
+                    show: false
+                },
+                axisLine: {
+                    show: true,
+                    lineStyle: {
+                        color: '#f19a37' // 第一个 Y 轴的颜色
+                    }
+                }
+            }
+        ],
+        series: [
+            {
+                name: barOptionsMap[barModelValue1.value],
+                type: 'bar',
+                barWidth: '3%',
+                data: ACOSList,
+                yAxisIndex: 0,
+                itemStyle: {
+                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                      { offset: 0, color: '#3a83f7' }, // 起始的鲜亮蓝色
+                      { offset: 0.5, color: '#5a9ef4' }, // 中间色,中度蓝色
+                      { offset: 1, color: '#8ab6f1' } // 结束的浅蓝色
+                    ]),
+                    // 柱状图圆角
+                    borderRadius: [6, 6, 6, 6],
+                },
+            },
+            {
+                name: barOptionsMap[barModelValue2.value],
+                type: 'bar',
+                barWidth: '3%',
+                data: SpendList,
+                yAxisIndex: 1,
+                itemStyle: {
+                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                      { offset: 0, color: '#f19a37' },
+                      { offset: 0.5, color: '#f7b96c' }, // 中间色,浅橙色
+                      { offset: 1, color: 'rgb(234, 207, 135)' } // 结束的浅黄色
+                    ]),
+                    // 柱状图圆角
+                    borderRadius: [6, 6, 6, 6],
+                },
+            },
+        ],
+    }
+    barChart.setOption(option)
+    resizeChart()
+}
+
+function resizeChart() {
+    barChart.resize()
+}
+defineExpose({ resizeChart })
+</script>
+
+<style scoped>
+
+</style>

+ 261 - 0
src/views/adManage/sb/targets/chartComponents/dataTendency.vue

@@ -0,0 +1,261 @@
+<template>
+    <div v-loading="loading">
+        <MetricsCards v-model="metrics" :metric-items="metricsItems" @change="changeMetric"></MetricsCards>
+        <div style="height: 350px;" ref="chartRef"></div>
+    </div>
+</template>
+
+<script lang="ts" setup>
+import {ref, onMounted, onBeforeUnmount, Ref, onBeforeMount, watch, computed} from 'vue'
+import * as echarts from 'echarts'
+import {useShopInfo} from '/@/stores/shopInfo'
+import {getLineData, getCardData} from '../api'
+import {spCampaignMetricsEnum} from '/@/views/adManage/utils/enum.js'
+import MetricsCards from '/@/components/MetricsCards/index.vue'
+import XEUtils from 'xe-utils'
+import {buildChartOpt} from '/@/views/adManage/utils/tools.js'
+import {usePublicData} from '/@/stores/publicData'
+import {storeToRefs} from 'pinia'
+
+defineOptions({
+    name: 'DataTendencyChart'
+})
+
+onMounted(() => {
+    getMetricsItems()
+    addResize()
+    // initLine()
+    setTimeout(() => {
+        initLine()
+    }, 0)
+})
+onBeforeUnmount(() => {
+    if (chartObj) {
+        chartObj.dispose()
+        chartObj = null
+    }
+    removeResize()
+})
+
+const publicData = usePublicData()
+const metrics = ref([
+    {metric: 'Impression', color: '#0085ff', 'label': '曝光量'},
+    {metric: 'Click', color: '#3fd4cf', 'label': '点击量'},
+    {metric: 'Spend', color: '#ff9500', 'label': '花费'},
+])
+const shopInfo = useShopInfo()
+const metricsItems: Ref<MetricData[]> = ref([])
+let chartObj: any
+const chartRef = ref()
+const option: any = {
+    dataset: {
+        source: []
+    },
+    tooltip: {
+        trigger: 'axis',
+        axisPointer: {
+            label: {
+                backgroundColor: '#6a7985'
+            }
+        }
+    },
+    legend: {
+        selected: {},  // 控制显隐
+        show: false
+    },
+    grid: {
+        top: 50, right: 150, bottom: 30, left: 55,
+    },
+    xAxis: {
+        type: 'category'
+    },
+    yAxis: [
+        {
+            id: 0,
+            type: 'value',
+            name: '曝光量',
+            splitLine: {
+                show: true // 设置显示分割线
+            },
+            axisLine: {
+                show: true,
+                lineStyle: {
+                    color: '#0085ff'
+                }
+            },
+            show: true
+        },
+        {
+            id: 1,
+            type: 'value',
+            name: '点击量',
+            position: 'right',
+            splitLine: {
+                show: false
+            },
+            axisLine: {
+                show: true,
+                lineStyle: {
+                    color: '#3fd4cf'
+                }
+            },
+            show: true
+        },
+        {
+            id: 2,
+            type: 'value',
+            position: 'right',
+            offset: 90,
+            name: '花费',
+            splitLine: {
+                show: false
+            },
+            axisLine: {
+                show: true,
+                lineStyle: {
+                    color: '#ff9500'
+                }
+            },
+            show: true
+        }
+    ],
+    series: [
+        {
+            id: 0,
+            name: '曝光量',
+            type: 'bar',
+            encode: {
+                x: 'date',
+                y: 'Impression'
+            },
+            barWidth: '20px',
+            yAxisIndex: 0,
+            itemStyle: {
+                color: '#0085ff',
+                borderRadius: [6, 6, 6, 6],
+            }
+        },
+        {
+            id: 1,
+            name: '点击量',
+            type: 'line',
+            encode: {
+                x: 'date',
+                y: 'Click'
+            },
+            symbolSize: 6,
+            symbol: 'circle',
+            smooth: true,
+            yAxisIndex: 1,
+            itemStyle: {color: '#3fd4cf', borderColor: '#3fd4cf'},
+            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: 'date',
+                y: 'Spend'
+            },
+            symbolSize: 6,
+            symbol: 'circle',
+            smooth: true,
+            yAxisIndex: 2,
+            itemStyle: {color: '#ff9500', borderColor: '#ff9500'},
+            areaStyle: {
+                color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                    {offset: 0, color: '#ff950053'},
+                    {offset: 1, color: '#ff950003'},
+                ]),
+            },
+            emphasis: {
+                focus: 'series'
+            }
+        }
+    ]
+}
+const {dateRange} = storeToRefs(publicData)
+const loading = ref(true)
+
+const getDataset = async () => {
+    const resp = await getLineData({profile: shopInfo.profile.profile_id, start: dateRange.value[0], end: dateRange.value[1]})
+    return resp.data
+}
+const initLine = async () => {
+    chartObj = echarts.init(chartRef.value)
+    const items = await getDataset()
+    option.dataset.source = items
+    XEUtils.arrayEach(metricsItems.value, info => {
+        option.legend.selected[info.label] = false
+    })
+    for (const info of metrics.value) {
+        option.legend.selected[info.label] = true
+    }
+    chartObj.setOption(option)
+    loading.value = false
+}
+
+const getMetricsItems = async () => {
+    const resp = await getCardData({start: dateRange.value[0], end: dateRange.value[1], profile: shopInfo.profile.profile_id})
+    const data = resp.data
+    metricsItems.value.length = 0
+    XEUtils.arrayEach(spCampaignMetricsEnum, 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)
+}
+
+watch(
+        dateRange,
+        async () => {
+            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%;
+}
+</style>

+ 192 - 0
src/views/adManage/sb/targets/crud.tsx

@@ -0,0 +1,192 @@
+import * as api from './api'
+import {AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery} from '@fast-crud/fast-crud'
+import {inject} from 'vue'
+import {SbBaseColumn} from '/@/views/adManage/utils/commonTabColumn.js'
+import {parseQueryParams} from '/@/views/adManage/utils/tools.js'
+import XEUtils from 'xe-utils'
+
+export const createCrudOptions = function ({crudExpose, context}: CreateCrudOptionsProps): CreateCrudOptionsRet {
+  const pageRequest = async (query: UserPageQuery) => {
+    const params = parseQueryParams(context.value)
+    XEUtils.assign(query, params)
+    return await api.GetList(query)
+  }
+  const editRequest = async ({form, row}: EditReq) => {
+    form.id = row.id
+    return await api.UpdateObj(form)
+  }
+  const delRequest = async ({row}: DelReq) => {
+    return await api.DelObj(row.id)
+  }
+  const addRequest = async ({form}: AddReq) => {
+    return await api.AddObj(form)
+  }
+
+  //权限判定
+  const hasPermissions = inject('$hasPermissions')
+
+  return {
+    crudOptions: {
+      table: {
+        height: 800,
+        headerCellStyle: {
+          backgroundColor: '#f6f7fa', // 直接设置背景颜色
+          height: '20px',
+          // border: '0.5px solid #ddd',
+        },
+        cellStyle: {
+          border: 'none',
+          borderBottom: '0.5px solid #ddd',
+        },
+        showSummary: true,
+      },
+      container: {
+        fixedHeight: false
+      },
+      actionbar: {
+        show: true,
+        buttons: {
+          add: {
+            show: false
+          },
+          create: {
+            text: '新建广告活动',
+            type: 'primary',
+            show: true,
+            click() {
+
+            }
+          }
+        }
+      },
+      search: {
+        show: false
+      },
+      toolbar: {
+        buttons: {
+          search: {
+            show: true
+          },
+          compact: {
+            show: false
+          }
+        }
+      },
+      request: {
+        pageRequest,
+        addRequest,
+        editRequest,
+        delRequest,
+      },
+      rowHandle: {
+        fixed: 'right',
+        width: 100,
+        align: 'center',
+        buttons: {
+          view: {
+            show: false,
+          },
+          edit: {
+            iconRight: 'Edit',
+            type: 'text',
+            text: null
+            // show: hasPermissions('dictionary:Update'),
+          },
+          remove: {
+            show: false
+            // iconRight: 'Delete',
+            // type: 'text',
+            // text: null
+            // show: hasPermissions('dictionary:Delete'),
+          },
+        },
+      },
+      columns: {
+        id: {
+          title: 'ID',
+          column: {
+            show: false
+          },
+          form: {
+            show: false
+          }
+        },
+        resolvedExpression_value:{
+          title: '商品和分类',
+          column: {
+            fixed: 'left',
+            width: 180,
+            sortable: true
+          },
+        },
+        state: {
+          title: '状态',
+          column: {
+            width: '90px',
+            align: 'center',
+            sortable: true,
+          },
+          type: 'dict-select',
+          search: {
+            show: true
+          },
+          dict: dict({
+            data: [
+              {value: 'paused', label: '已暂停', color: 'warning'},
+              {value: 'enabled', label: '投放中', color: 'success'},
+            ]
+          })
+        },
+        campaignName: {
+          title: '广告活动名称',
+          column: {
+            width: 180,
+          }
+        },
+        adGroupName: {
+          title: '广告组名称',
+          column: {
+            width: 180,
+          }
+        },
+        ASIN: {
+          title: 'ASIN',
+          column: {
+            align: 'left',
+            width: 130,
+          }
+        },
+        suggestedBid: {
+          title: '建议竞价',
+          column: {
+            width: 130,
+            align: 'right'
+          }
+        },
+        bid: {
+          title: '出价',
+          column: {
+            width: 80,
+            align: 'left',
+            sortable: true,
+            formatter: (row) => {
+              return '$' + row.value
+            }
+          }
+        },
+        suggestedBid_lower: {
+          column: {
+            show: false,
+          }
+        },
+        suggestedBid_upper: {
+          column: {
+            show: false,
+          }
+        },
+
+        ...SbBaseColumn
+      }
+    }
+  }
+}

+ 168 - 0
src/views/adManage/sb/targets/index.vue

@@ -0,0 +1,168 @@
+<template>
+  <fs-page class="fs-page-custom">
+    <fs-crud ref="crudRef" v-bind="crudBinding">
+      <template #header-middle>
+        <el-tabs v-model="tabActiveName" class="chart-tabs" type="border-card" @tab-change="changeTab">
+          <el-tab-pane label="数据趋势" name="dataTendency">
+            <DataTendencyChart
+                :query="queryParams"
+                v-if="tabActiveName === 'dataTendency'"
+                :fetchCard="getCardData"
+                :fetch-line-month="getLineMonthData"
+							  :fetch-line-week="getLineWeekData"
+                :fetchLine="getLineData">
+            </DataTendencyChart>
+          </el-tab-pane>
+          <el-tab-pane label="广告结构" name="adStruct" :lazy="true">
+            <AdStructChart ref="adStructChartRef"/>
+          </el-tab-pane>
+          <el-tab-pane label="散点视图" name="scatterView" :lazy="true"></el-tab-pane>
+        </el-tabs>
+      </template>
+      <template #cell_percentTimeInBudget="scope">
+        <el-progress :percentage="scope.row.percentTimeInBudget > 0 ? scope.row.percentTimeInBudget * 100 : 0" />
+      </template>
+      <template #cell_campaignName="scope">
+        <el-tooltip  effect="dark" :content="scope.row.campaignName" placement="top">
+          <el-link type="primary" :underline="false" @click="jumpGroup(scope.row)">
+            <div class="en-text">{{ scope.row.campaignName }}</div>
+          </el-link>
+        </el-tooltip>
+      </template>
+      <template #cell_adGroupName="scope">
+        <el-tooltip  effect="dark" :content="scope.row.adGroupName" placement="top">
+          <el-link type="primary" :underline="false" @click="jumpGroup(scope.row)">
+            <div class="en-text">{{ scope.row.adGroupName }}</div>
+          </el-link>
+        </el-tooltip>
+      </template>
+      <template #cell_ASIN="scope">
+          <el-link type="primary" :underline="false" @click="jumpGroup(scope.row)">
+            <div class="en-text">{{ scope.row.ASIN }}</div>
+          </el-link>
+      </template>
+      <template #cell_suggestedBid="scope">
+        <div>${{ scope.row.suggestedBid }}</div>
+        <div class="text-range">${{ scope.row.suggestedBid_lower }} ~ ${{ scope.row.suggestedBid_upper }}</div>
+      </template>
+      <template #cell_MissedImpressions="scope">
+        {{ scope.row.MissedImpressionsLower ?? '0' }} ~ {{ scope.row.MissedImpressionsUpper ?? '0' }}
+      </template>
+      <template #cell_MissedClicks="scope"> {{ scope.row.MissedClicksLower ?? '0' }} ~ {{ scope.row.MissedClicksUpper ?? '0' }} </template>
+      <template #cell_MissedSales="scope"> {{ scope.row.MissedSalesLower ?? '0' }} ~ {{ scope.row.MissedSalesUpper ?? '0' }} </template>
+
+      <template v-for="field of Object.keys(SbBaseColumn)" #[`cell_${field}`]="scope">
+        <DataCompare
+            :field="field"
+            :value="scope.row[field]"
+            :prev-val="scope.row[`prev${field}`]"
+            :gap-val="scope.row[`gap${field}`]"
+            :date-range="dateRange"
+            :show-compare="showCompare"/>
+      </template>
+      <template #toolbar-left>
+        <div class="campare-switch">
+          <span>数据对比 </span>
+          <el-switch v-model="showCompare" size="small" />
+        </div>
+      </template>
+    </fs-crud>
+  </fs-page>
+</template>
+
+<script lang="ts" setup>
+import {nextTick, onMounted, ref, watch} from 'vue'
+import {FsPage, useFs} from '@fast-crud/fast-crud'
+import {createCrudOptions} from './crud'
+import {useRoute, useRouter} from 'vue-router'
+import DataTendencyChart from '/@/views/adManage/sb/chartComponents/dataTendency.vue'
+import {useShopInfo} from '/@/stores/shopInfo'
+import {usePublicData} from '/@/stores/publicData'
+import AdStructChart from './chartComponents/adStruct.vue'
+import {getCardData, getLineData, getLineMonthData, getLineWeekData} from './api'
+import {storeToRefs} from 'pinia'
+import {SbBaseColumn} from '/@/views/adManage/utils/commonTabColumn'
+import DataCompare from '/@/components/dataCompare/index.vue'
+
+
+const tabActiveName = ref('dataTendency')
+const shopInfo = useShopInfo()
+const publicData = usePublicData()
+const {dateRange} = storeToRefs(publicData)
+const {profile} = storeToRefs(shopInfo)
+const queryParams = ref({
+  profileId: profile.value.profile_id,
+  dateRange
+})
+
+const {crudBinding, crudRef, crudExpose} = useFs({createCrudOptions, context: queryParams})
+const route = useRoute()
+const router = useRouter()
+const adStructChartRef = ref()
+const dataTendencyRef = ref()
+const showCompare = ref(false)
+
+
+onMounted(() => {
+  crudExpose.doRefresh()
+})
+
+const jumpGroup = (row: any) => {
+  router.push({
+    name: 'CampaignDetail',
+    query: { campaignId: row.campaignId, tagsViewName: row.campaignName },
+  })
+}
+
+const resizeTabChart = () => {
+  if (tabActiveName.value === 'dataTendency') {
+    dataTendencyRef.value.resizeChart()
+  } else if (tabActiveName.value === 'adStruct') {
+    adStructChartRef.value.resizeChart()
+  }
+}
+const changeTab = () => {
+  nextTick(() => {
+    resizeTabChart()
+  })
+}
+defineExpose({resizeTabChart})
+
+watch(queryParams, async () => {
+  crudExpose.doRefresh()
+}, {deep: true})
+
+</script>
+
+<style lang="scss" scoped>
+.campare-switch {
+  flex: none;
+}
+
+::v-deep(.el-table__footer-wrapper td.el-table__cell:nth-child(n+3):nth-child(-n+6) .cell) {
+  display: none;
+}
+
+.en-text {
+  max-width: 100%;
+  font-size: 13px;
+  font-weight: 420;
+  word-break: break-word;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: normal;
+  display: -webkit-box;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+}
+::v-deep(.el-table__footer-wrapper) {
+  border: 0;
+}
+::v-deep(.el-table .el-table__footer-wrapper .cell) {
+  font-weight: 600;
+}
+
+.text-range {
+  color: #808184;
+}
+</style>

+ 2 - 2
src/views/adManage/sp/chartComponents/dataTendency.vue

@@ -132,11 +132,11 @@ const option: any = {
         x: 'Name',
         y: ''
       },
-      barWidth: '20px',
+      barWidth: '18px',
       yAxisIndex: 0,
       itemStyle: {
         color: '',
-        borderRadius: [6, 6, 6, 6],
+        borderRadius: [4, 4, 4, 4],
       }
     },
     {

+ 1 - 1
src/views/adManage/sp/targets/crud.tsx

@@ -92,7 +92,7 @@ export const createCrudOptions = function ({crudExpose, context}: CreateCrudOpti
             show: false
           }
         },
-        '商品和分类': {title: '商品和分类'},
+        resolvedExpression_value: {title: '商品和分类'},
         state: {
           title: '状态',
           column: {

+ 1 - 0
src/views/adManage/utils/commonTabColumn.tsx

@@ -198,6 +198,7 @@ export const SbBaseColumn = {
       )
       },
       formatter: (row) => {
+        console.log(row.value)
         if (row.value != null) {
           return row.value
         } else {