浏览代码

feat:卸载页面

cmy 1 周之前
父节点
当前提交
d3be8f9fd3

+ 223 - 0
src/views/churn/behavior/echarts/BarChart.vue

@@ -0,0 +1,223 @@
+<template>
+  <div class="bar-chart-container">
+    <div class="chart-wrapper">
+      <v-chart 
+        class="chart" 
+        :option="chartOption" 
+        :autoresize="true"
+        style="height: 244px;"
+      />
+    </div>
+    <div class="title">历史卸载次数</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart } from 'echarts/charts'
+import {
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent
+} from 'echarts/components'
+import VChart from 'vue-echarts'
+
+use([
+  CanvasRenderer,
+  BarChart,
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent
+])
+
+interface BarItem {
+  name: string
+  value: number
+  percentage: string
+}
+
+const props = withDefaults(defineProps<{
+  data?: BarItem[]
+}>(), {
+  data: () => []
+})
+
+// 默认数据
+const defaultData: BarItem[] = [
+  { name: '6次以上', value: 45, percentage: '45.0%' },
+  { name: '5次', value: 23, percentage: '23.0%' },
+  { name: '4次', value: 15, percentage: '15.0%' },
+  { name: '3次', value: 8, percentage: '8.0%' },
+  { name: '2次', value: 6, percentage: '6.0%' },
+  { name: '1次', value: 3, percentage: '3.0%' }
+]
+
+const displayData = computed(() => {
+  return props.data && props.data.length > 0 ? props.data : defaultData
+})
+
+const chartOption = computed(() => {
+  return {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'shadow'
+      },
+      formatter: function(params: any) {
+        const data = params[0]
+        return `${data.name}<br/>设备数:${data.value} (${data.data.percentage})`
+      },
+      backgroundColor: 'rgba(255, 255, 255, 0.9)',
+      borderColor: '#e6e6e6',
+      borderWidth: 1,
+      textStyle: {
+        color: '#333'
+      }
+    },
+    grid: {
+      left: '0',
+      right: '0',
+      bottom: '0',
+      top: '3%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'category',
+      data: displayData.value.map(item => item.name),
+      axisLine: {
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: '#666',
+        fontSize: 12
+      }
+    },
+    yAxis: {
+      type: 'value',
+      name: '次数',
+      nameTextStyle: {
+        color: '#666',
+        fontSize: 12
+      },
+      axisLine: {
+        show: false
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: '#666',
+        fontSize: 12
+      },
+      splitLine: {
+        lineStyle: {
+          color: '#f0f0f0',
+          type: 'dashed'
+        }
+      }
+    },
+    series: [
+      {
+        name: '卸载次数',
+        type: 'bar',
+        data: displayData.value.map(item => ({
+          value: item.value,
+          percentage: item.percentage
+        })),
+        itemStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: 'rgba(109, 173, 249, 1)' },
+              { offset: 1, color: 'rgba(109, 173, 249, 1)' }
+            ]
+          },
+          borderRadius: [0, 0, 0, 0]
+        },
+        emphasis: {
+          itemStyle: {
+            color: {
+              type: 'linear',
+              x: 0,
+              y: 0,
+              x2: 0,
+              y2: 1,
+              colorStops: [
+                { offset: 0, color: 'rgba(109, 173, 249, 1)' },
+                { offset: 1, color: 'rgba(109, 173, 249, 1)' }
+              ]
+            }
+          }
+        },
+        barWidth: '60%'
+      }
+    ]
+  }
+})
+
+onMounted(() => {
+  // 组件挂载后的初始化逻辑
+})
+
+// 监听数据变化
+watch(displayData, (newData) => {
+  console.log('Bar chart data updated:', newData)
+}, { deep: true })
+</script>
+
+<style scoped lang="scss">
+.bar-chart-container {
+  width: 100%;
+  height: 100%;
+  text-align: center;
+}
+
+.chart-wrapper {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.chart {
+  width: 100%;
+  height: 100%;
+}
+
+.title {
+  display: inline-block;
+  color: rgba(18, 18, 18, 1);
+  font-family: Source Han Sans SC;
+  font-weight: 400;
+  font-style: Regular;
+  font-size: 14px;
+  padding-left: 14px;
+  position: relative;
+  margin-top: 30px;
+  &::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 8px;
+    height: 8px;
+    background-color: rgba(109, 173, 249, 1);
+  }
+}
+
+</style> 

+ 275 - 0
src/views/churn/behavior/echarts/PieChart.vue

@@ -0,0 +1,275 @@
+<template>
+  <div class="pie-chart-container">
+    <div class="chart-wrapper">
+      <div class="chart-container">
+        <v-chart class="chart" :option="chartOption" :autoresize="true" style="height: 300px;" />
+        <div class="center-label">
+          <div class="center-title">卸载设备总数</div>
+          <div class="center-number">{{ total }}</div>
+        </div>
+      </div>
+    </div>
+    <div class="legend-wrapper">
+      <div class="legend-title">
+        <div class="item">时长分布</div>
+        <div class="item">卸载设备数</div>
+      </div>
+      <div class="legend-list">
+        <div v-for="(item, index) in displayData" :key="index" class="legend-item"
+          @click="handleLegendClick(item, index)">
+          <div class="item">
+            <div class="legend-number" :style="{ backgroundColor: item.color }">{{ getLegendNumber(index) }}</div>
+            {{ item.name }}
+          </div>
+          <div class="item">{{ item.value }}({{ item.percentage }})</div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { PieChart } from 'echarts/charts'
+import {
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent
+} from 'echarts/components'
+import VChart from 'vue-echarts'
+
+use([
+  CanvasRenderer,
+  PieChart,
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent
+])
+
+interface LegendItem {
+  name: string
+  value: number
+  percentage: string
+  color: string
+}
+
+const props = withDefaults(defineProps<{
+  data?: LegendItem[]
+}>(), {
+  data: () => []
+})
+
+// 默认数据
+const defaultData: LegendItem[] = [
+  { name: '0-7天', value: 12, percentage: '24.00%', color: 'rgba(22, 122, 240, 1)' },
+  { name: '8-15天', value: 12, percentage: '24.00%', color: 'rgba(47, 168, 255, 1)' },
+  { name: '16-30天', value: 12, percentage: '24.00%', color: 'rgba(189, 112, 224, 1)' },
+  { name: '31-45天', value: 5, percentage: '4.00%', color: 'rgba(23, 84, 166, 1)' },
+  { name: '46-60天', value: 3, percentage: '4.00%', color: 'rgba(49, 198, 204, 1)' },
+  { name: '61-90天', value: 52, percentage: '4.00%', color: 'rgba(252, 158, 33, 1)' },
+  { name: '90天以上', value: 2, percentage: '2.00%', color: 'rgba(49, 204, 108, 1)' }
+]
+
+const displayData = computed(() => {
+  return props.data && props.data.length > 0 ? props.data : defaultData
+})
+
+// 计算总数
+const total = computed(() => {
+  return displayData.value.reduce((sum, item) => sum + item.value, 0)
+})
+
+// 获取图例编号(支持特殊字符)
+const getLegendNumber = (index: number): string => {
+  const numbers = ['1', '2', '3', '4', '5', '6', '7']
+  return numbers[index] || (index + 1).toString()
+}
+
+const chartOption = computed(() => {
+  return {
+    tooltip: {
+      trigger: 'item',
+      formatter: '{a} <br/>{b}: {c} ({d}%)',
+      backgroundColor: 'rgba(255, 255, 255, 0.9)',
+      borderColor: '#e6e6e6',
+      borderWidth: 1,
+      textStyle: {
+        color: '#333'
+      }
+    },
+    series: [
+      {
+        name: '时长分布',
+        type: 'pie',
+        radius: [105, 145],
+        center: ['50%', '50%'],
+        avoidLabelOverlap: false,
+        itemStyle: {
+          // borderRadius: 8,
+          // borderColor: '#fff',
+          // borderWidth: 2
+        },
+        label: {
+          show: false
+        },
+        emphasis: {
+          // label: {
+          //   show: true,
+          //   position: 'center',
+          //   formatter: `卸载设备总数\n${total}`,
+          //   fontSize: 14,
+          //   fontWeight: 'bold',
+          //   color: '#333',
+          //   lineHeight: 20
+          // },
+          itemStyle: {
+            // shadowBlur: 10,
+            // shadowOffsetX: 0,
+            // shadowColor: 'rgba(255, 255, 255, 0.5)'
+          }
+        },
+        labelLine: {
+          show: false
+        },
+        data: displayData.value.map((item, index) => ({
+          value: item.value,
+          name: item.name,
+          itemStyle: {
+            color: item.color
+          }
+        }))
+      }
+    ]
+  }
+})
+
+const handleLegendClick = (item: LegendItem, index: number) => {
+  // 可以在这里添加图例点击事件处理
+  console.log('Legend clicked:', item, index)
+}
+
+onMounted(() => {
+  // 组件挂载后的初始化逻辑
+})
+
+// 监听数据变化
+watch(displayData, (newData) => {
+  console.log('Chart data updated:', newData)
+}, { deep: true })
+</script>
+
+<style scoped lang="scss">
+.pie-chart-container {
+  display: flex;
+  align-items: center;
+  justify-content: space-evenly;
+  width: 100%;
+  height: 100%;
+
+
+  .chart-wrapper {
+    width: 300px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+
+    .chart-container {
+      position: relative;
+      width: 100%;
+      max-width: 400px;
+    }
+
+    .chart {
+      width: 100%;
+      max-width: 400px;
+    }
+
+    .center-label {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      text-align: center;
+      pointer-events: none;
+      z-index: 10;
+
+      .center-title {
+        font-size: 14px;
+        font-weight: 500;
+        color: rgba(0, 0, 0, 1);
+        line-height: 20px;
+        margin-bottom: 12px;
+      }
+
+      .center-number {
+        font-size: 26px;
+        color: rgba(0, 0, 0, 1);
+        line-height: 38px;
+      }
+    }
+  }
+
+  .legend-wrapper {
+    padding: 0;
+    .legend-title {
+      max-width: 300px;
+      font-size: 14px;
+      font-weight: 500;
+      color: rgba(100, 100, 100, 1);
+      margin-bottom: 18px;
+      display: flex;
+
+      .item {
+        width: 50%;
+        text-wrap: nowrap;
+
+        &:nth-child(1) {
+          width: 150px;
+        }
+      }
+    }
+
+    .legend-list {
+      max-width: 300px;
+
+      .legend-item {
+        display: flex;
+        align-items: center;
+        font-size: 14px;
+        line-height: 20px;
+        margin-bottom: 20px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        .item {
+          text-wrap: nowrap;
+          width: 50%;
+          color: rgba(18, 18, 18, 1);
+
+          &:nth-child(1) {
+            width: 150px;
+          }
+        }
+      }
+
+      .legend-number {
+        color: white;
+        font-size: 16px;
+        border-radius: 8px;
+        display: inline-block;
+        width: 16px;
+        height: 16px;
+        margin-right: 10px;
+        text-align: center;
+        vertical-align: middle;
+        line-height: 16px;
+      }
+    }
+  }
+}
+</style>

+ 164 - 0
src/views/churn/behavior/index.vue

@@ -0,0 +1,164 @@
+<template>
+  <div class="behavior">
+    <el-row :gutter="12" style="padding: 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="hover" style="padding: 10px 14px;">
+          <div class="top-info">
+            <div class="title">卸载洞察<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
+                xmlns="http://www.w3.org/2000/svg">
+                <path
+                  d="M7 14C8.93298 14 10.683 13.2165 11.9497 11.9497C13.2165 10.683 14 8.93298 14 7C14 5.06702 13.2165 3.31702 11.9497 2.05025C10.683 0.783503 8.93298 0 7 0C5.06702 0 3.31702 0.783503 2.05025 2.05025C0.783503 3.31702 0 5.06702 0 7C0 8.93298 0.783503 10.683 2.05025 11.9497C3.31702 13.2165 5.06702 14 7 14Z"
+                  fill="#1B4D88" fill-opacity="0.4" />
+                <path
+                  d="M4 4.702C4 4.40333 4.02333 4.09533 4.07 3.778C4.126 3.46067 4.21933 3.17133 4.35 2.91C4.49 2.64867 4.67667 2.434 4.91 2.266C5.14333 2.08867 5.45133 2 5.834 2H7.794C8.102 2 8.37267 2.06533 8.606 2.196C8.84867 2.31733 9.04467 2.476 9.194 2.672C9.35267 2.868 9.474 3.092 9.558 3.344C9.65133 3.596 9.70733 3.848 9.726 4.1C9.754 4.352 9.74467 4.59467 9.698 4.828C9.66067 5.06133 9.59533 5.26667 9.502 5.444L7.934 8.314V9.574H6.324V8.146L7.808 5.556C7.892 5.416 7.948 5.234 7.976 5.01C8.01333 4.786 8.01333 4.57133 7.976 4.366C7.948 4.15133 7.878 3.96933 7.766 3.82C7.66333 3.67067 7.514 3.596 7.318 3.596H6.408C6.24933 3.596 6.11867 3.624 6.016 3.68C5.91333 3.72667 5.82933 3.80133 5.764 3.904C5.708 3.99733 5.67067 4.114 5.652 4.254C5.63333 4.38467 5.624 4.534 5.624 4.702H4ZM7.976 12.15H6.324V10.512H7.976V12.15Z"
+                  fill="white" />
+              </svg>
+            </div>
+            <div class="data-source-status">数据源状态:Demo数据</div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="hover">
+          <div class="active-situation">
+            <div class="title">卸载设备活跃情况</div>
+            <div class="content">
+
+              <el-row :gutter="12" style="padding: 0; row-gap: 12px;">
+                <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+                  <div class="el-card" style="flex: 1; overflow: visible; padding: 24px 40px; height: 475px;">
+                    <div class="description">
+                      <p>安装至卸载存量时长分布</p>
+                      <p>存量时长=最近卸载日期-最近卸载前的安装日期</p>
+                      <p>用户卸载集中在安装App后:<span>90天以上</span></p>
+                    </div>
+                    <div class="content-item">
+                      <PieChart />
+                    </div>
+                  </div>
+                </el-col>
+                <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+                  <div class="el-card" style="flex: 1; overflow: visible; padding: 24px 40px; height: 475px;">
+                    <div class="description">
+                      <p>历史卸载次数分布</p>
+                      <p>当前周的卸载设备,按当前是第几次卸载App进行分布</p>
+                      <p>历史上<span>96.5%</span>卸载设备会反复卸载。</p>
+                    </div>
+                    <div class="content-item">
+                      <BarChart />
+                    </div>
+                  </div>
+                </el-col>
+              </el-row>
+            </div>
+
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script lang="ts" name="churnBehavior" setup>
+import PieChart from './echarts/PieChart.vue'
+import BarChart from './echarts/BarChart.vue'
+</script>
+<style scoped lang="scss">
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.behavior {
+  font-family: Source Han Sans SC;
+
+  .top-info {
+    color: rgba(18, 18, 18, 1);
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0;
+    margin: -2.5px 0;
+
+    .title {
+      font-size: 16px;
+      font-weight: 500;
+      line-height: 20px;
+      padding: 4px 0;
+    }
+
+    .data-source-status {
+      font-weight: 400;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(18, 18, 18, 1);
+      padding: 4px 0;
+    }
+  }
+
+  .active-situation {
+    padding: 10px 14px;
+
+    .title {
+      line-height: 19px;
+      font-weight: 500;
+      font-size: 16px;
+      padding-left: 12px;
+      position: relative;
+      margin-bottom: 23px;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 4px;
+        height: 14px;
+        background: rgba(22, 122, 240, 1);
+      }
+    }
+
+    .content {
+
+      // display: flex;
+      // gap: 20px;
+      .description {
+        margin-bottom: 35px;
+
+        p:nth-child(1) {
+          color: rgba(18, 18, 18, 1);
+          font-weight: 500;
+          font-size: 16px;
+          margin-bottom: 8px;
+        }
+
+        p:nth-child(2) {
+          font-weight: 400;
+          font-size: 14px;
+          color: rgba(100, 100, 100, 1);
+          margin-bottom: 8px;
+        }
+
+        p:nth-child(3) {
+          color: rgba(18, 18, 18, 1);
+          font-weight: 400;
+          font-size: 14px;
+          line-height: 32px;
+
+          span {
+            font-weight: 400;
+            font-size: 22px;
+            color: rgba(22, 122, 240, 1);
+            line-height: 32px;
+            padding: 0 2px;
+          }
+        }
+      }
+    }
+  }
+
+}
+</style>

+ 165 - 0
src/views/churn/overview/echarts/DoughnutChart.vue

@@ -0,0 +1,165 @@
+<template>
+  <div ref="chartRef" class="doughnut-chart" :style="{ width: width, height: height }"></div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import * as echarts from 'echarts'
+
+interface ChartData {
+  name: string
+  value: number
+  itemStyle?: {
+    color?: string
+  }
+}
+
+interface Props {
+  data: ChartData[]
+  width?: string
+  height?: string
+  title?: string
+  colors?: string[]
+  center?: [string, string]
+  radius?: [string, string]
+  showLabel?: boolean
+  showLegend?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  width: '100%',
+  height: '200px',
+  title: '',
+  colors: () => ['#167AF0', '#00BC71', '#FF6B35', '#FFD700', '#9C27B0'],
+  center: () => ['50%', '50%'],
+  radius: () => ['40%', '70%'],
+  showLabel: true,
+  showLegend: false
+})
+
+const chartRef = ref<HTMLElement>()
+let chartInstance: echarts.ECharts | null = null
+
+// 初始化图表
+const initChart = () => {
+  if (!chartRef.value) return
+
+  chartInstance = echarts.init(chartRef.value)
+  updateChart()
+}
+
+// 更新图表数据
+const updateChart = () => {
+  if (!chartInstance) return
+
+  const option: echarts.EChartsOption = {
+    title: props.title ? {
+      text: props.title,
+      left: 'center',
+      top: '10%',
+      textStyle: {
+        fontSize: 14,
+        fontWeight: 'normal',
+        color: '#333'
+      }
+    } : undefined,
+    tooltip: {
+      trigger: 'item',
+      formatter: '{a} <br/>{b}: {c} ({d}%)',
+      position: ['50%', '50%']
+    },
+    legend: props.showLegend ? {
+      orient: 'vertical',
+      left: 'left',
+      top: 'middle',
+      textStyle: {
+        fontSize: 12,
+        color: '#666'
+      }
+    } : undefined,
+    series: [
+      {
+        name: props.title || '数据',
+        type: 'pie',
+        radius: props.radius,
+        center: props.center,
+        avoidLabelOverlap: false,
+        label: props.showLabel ? {
+          show: true,
+          position: 'outside',
+          formatter: '{b}\n{d}%',
+          fontSize: 12,
+          color: '#333'
+        } : {
+          show: false
+        },
+        labelLine: props.showLabel ? {
+          show: true,
+          length: 10,
+          length2: 10
+        } : {
+          show: false
+        },
+        data: props.data.map((item, index) => ({
+          ...item,
+          itemStyle: {
+            borderRadius: 15,
+            color: item.itemStyle?.color || props.colors[index % props.colors.length]
+          }
+        })),
+        emphasis: {
+          itemStyle: {
+            shadowBlur: 5,
+            shadowOffsetX: 0,
+            shadowOffsetY: 0,
+            shadowColor: 'rgba(0, 0, 0, 0.3)'
+          }
+        }
+      }
+    ]
+  }
+
+  chartInstance.setOption(option)
+}
+
+// 监听数据变化
+watch(() => props.data, () => {
+  nextTick(() => {
+    updateChart()
+  })
+}, { deep: true })
+
+// 监听窗口大小变化
+const handleResize = () => {
+  if (chartInstance) {
+    chartInstance.resize()
+  }
+}
+
+onMounted(() => {
+  initChart()
+  window.addEventListener('resize', handleResize)
+})
+
+onUnmounted(() => {
+  if (chartInstance) {
+    chartInstance.dispose()
+    chartInstance = null
+  }
+  window.removeEventListener('resize', handleResize)
+})
+
+// 暴露方法给父组件
+defineExpose({
+  getChartInstance: () => chartInstance,
+  resize: handleResize
+})
+</script>
+
+<style scoped lang="scss">
+.doughnut-chart {
+  display: inline-block;
+  padding: 5px;
+  box-sizing: border-box;
+}
+</style>

+ 208 - 0
src/views/churn/overview/echarts/LineChart.vue

@@ -0,0 +1,208 @@
+<template>
+  <div ref="chartRef" class="line-chart" :style="{ width: width, height: height }"></div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import * as echarts from 'echarts'
+
+interface ChartDataItem {
+  date: string
+  value: number
+}
+
+interface Props {
+  data: ChartDataItem[]
+  width?: string
+  height?: string
+  title?: string
+  color?: string
+  showGrid?: boolean
+  showTooltip?: boolean
+  showLegend?: boolean
+  smooth?: boolean
+  areaStyle?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  width: '100%',
+  height: '270px',
+  title: '',
+  color: '#167AF0',
+  showGrid: true,
+  showTooltip: true,
+  showLegend: false,
+  smooth: false,
+  areaStyle: false
+})
+
+const chartRef = ref<HTMLElement>()
+let chartInstance: echarts.ECharts | null = null
+
+// 初始化图表
+const initChart = () => {
+  if (!chartRef.value) return
+  
+  chartInstance = echarts.init(chartRef.value)
+  updateChart()
+}
+
+// 更新图表数据
+const updateChart = () => {
+  if (!chartInstance) return
+
+  const dates = props.data.map(item => item.date)
+  const values = props.data.map(item => item.value)
+
+  const option: echarts.EChartsOption = {
+    tooltip: props.showTooltip ? {
+      trigger: 'axis',
+      backgroundColor: 'rgba(255, 255, 255, 0.95)',
+      borderColor: '#E5E5E5',
+      borderWidth: 1,
+      textStyle: {
+        color: '#333',
+        fontSize: 12
+      },
+      axisPointer: {
+        type: 'line',
+        lineStyle: {
+          color: props.color,
+          width: 1,
+          type: 'dashed'
+        }
+      },
+      formatter: function(params: any) {
+        const data = params[0]
+        return `<div style="padding: 8px;">
+          <div style="font-weight: 500; margin-bottom: 4px;">${data.name}</div>
+          <div style="color: ${props.color}; font-size: 14px; font-weight: 500;">
+            ${data.seriesName}: ${data.value}
+          </div>
+        </div>`
+      }
+    } : undefined,
+    legend: props.showLegend ? {
+      data: [props.title || '数据'],
+      top: '40px',
+      textStyle: {
+        fontSize: 12,
+        color: '#666'
+      }
+    } : undefined,
+    grid: {
+      left: '0%',
+      right: '0%',
+      bottom: '0%',
+      top: '10px',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'category',
+      boundaryGap: true,
+      data: dates,
+      axisLine: {
+        lineStyle: {
+          color: '#E5E5E5'
+        }
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14
+      },
+    },
+    yAxis: {
+      type: 'value',
+      axisLine: {
+        show: false
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14,
+      },
+      splitLine: props.showGrid ? {
+        lineStyle: {
+          color: 'rgba(230, 230, 230, 1)',
+          type: 'dashed'
+        }
+      } : {
+        show: false
+      }
+    },
+    series: [
+      {
+        name: props.title || '数据',
+        type: 'line',
+        data: values,
+        smooth: props.smooth,
+        symbol: 'circle',
+        symbolSize: 8,
+        lineStyle: {
+          color: props.color,
+          width: 2
+        },
+        itemStyle: {
+          color: props.color,
+          borderWidth: 2,
+          borderColor: props.color,
+        },
+        emphasis: {
+          itemStyle: {
+            color: props.color,
+            borderWidth: 2,
+            borderColor: props.color,
+          }
+        }
+      }
+    ]
+  }
+
+  chartInstance.setOption(option)
+}
+
+// 监听数据变化
+watch(() => props.data, () => {
+  nextTick(() => {
+    updateChart()
+  })
+}, { deep: true })
+
+// 监听窗口大小变化
+const handleResize = () => {
+  if (chartInstance) {
+    chartInstance.resize()
+  }
+}
+
+onMounted(() => {
+  initChart()
+  window.addEventListener('resize', handleResize)
+})
+
+onUnmounted(() => {
+  if (chartInstance) {
+    chartInstance.dispose()
+    chartInstance = null
+  }
+  window.removeEventListener('resize', handleResize)
+})
+
+// 暴露方法给父组件
+defineExpose({
+  getChartInstance: () => chartInstance,
+  resize: handleResize
+})
+</script>
+
+<style scoped lang="scss">
+.line-chart {
+  display: block;
+  box-sizing: border-box;
+}
+</style> 

+ 428 - 0
src/views/churn/overview/index.vue

@@ -0,0 +1,428 @@
+<template>
+  <div class="overview">
+    <el-row :gutter="12" style="padding: 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="hover" style="padding: 10px 14px;">
+          <div class="top-info">
+            <div class="title">流失概况</div>
+            <div class="aside">
+              <div class="data-source-status">数据源状态: Demo数据</div>
+              <el-button class="goto-smart-operation" type="primary" link>前往智能运营发短信
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  <path d="M10.6667 4.66699L13.3334 7.33366L10.6667 10.0003" stroke="#167AF0" stroke-width="1.5"
+                    stroke-linecap="round" stroke-linejoin="round" />
+                  <path d="M2.66671 12.6663V8.33301C2.66671 7.78071 3.11441 7.33301 3.66671 7.33301H13.3334"
+                    stroke="#167AF0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+                </svg>
+              </el-button>
+            </div>
+          </div>
+          <div class="top-content">
+            <div class="el-card" style="flex: 1; overflow: visible;">
+              <div class="content-item">
+                <div class="content-item-left">
+                  <DoughnutChart :data="churnData" width="150px" height="150px" :radius="['35px', '55px']"
+                    :show-label="false" :show-legend="false" />
+                </div>
+                <div class="content-item-right">
+                  <div class="content-item-title">当周卸载流失设备数</div>
+                  <div class="content-item-value">38</div>
+                  <div class="content-item-percent">环比<span>-74.23%</span>
+                    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+                      <mask id="mask0_611_555" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="14"
+                        height="14">
+                        <rect width="14" height="14" fill="#D9D9D9" />
+                      </mask>
+                      <g mask="url(#mask0_611_555)">
+                        <path
+                          d="M11.1702 4.43275C11.5204 4.43302 11.8046 4.71698 11.8047 5.06725L11.8041 10.7783C11.8041 11.1287 11.5201 11.4128 11.1696 11.4128L5.45857 11.4134C5.10831 11.4133 4.82435 11.1291 4.82408 10.7789C4.82408 10.4284 5.1087 10.1438 5.45916 10.1438L9.6377 10.1438L2.69566 3.20174C2.44785 2.95393 2.44785 2.55215 2.69566 2.30434C2.94348 2.05652 3.34526 2.05652 3.59307 2.30434L10.5351 9.24637L10.5351 5.06783C10.5351 4.71737 10.8197 4.43275 11.1702 4.43275Z"
+                          fill="#00BC71" />
+                      </g>
+                    </svg>
+                  </div>
+                </div>
+              </div>
+            </div>
+            <div class="el-card" style="flex: 1; overflow: visible;">
+              <div class="content-item">
+                <div class="content-item-left">
+                  <DoughnutChart :data="recallData" width="150px" height="150px" :radius="['35px', '55px']"
+                    :show-label="false" :show-legend="false" />
+                </div>
+                <div class="content-item-right">
+                  <div class="content-item-title">当周卸载召回设备数</div>
+                  <div class="content-item-value">38</div>
+                  <div class="content-item-percent">
+                    环比<span>+74.23%</span>
+                    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+                      <mask id="mask0_611_558" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="14"
+                        height="14">
+                        <rect width="14" height="14" transform="matrix(1 0 0 -1 0 14)" fill="#D9D9D9" />
+                      </mask>
+                      <g mask="url(#mask0_611_558)">
+                        <path
+                          d="M11.1702 9.56725C11.5204 9.56698 11.8046 9.28302 11.8047 8.93275L11.8041 3.22173C11.8041 2.87127 11.5201 2.58723 11.1696 2.58723L5.45857 2.58665C5.10831 2.58668 4.82435 2.87093 4.82408 3.22114C4.82408 3.5716 5.1087 3.85622 5.45916 3.85622L9.6377 3.85622L2.69566 10.7983C2.44785 11.0461 2.44785 11.4479 2.69566 11.6957C2.94348 11.9435 3.34526 11.9435 3.59307 11.6957L10.5351 4.75363L10.5351 8.93217C10.5351 9.28263 10.8197 9.56725 11.1702 9.56725Z"
+                          fill="#E64242" />
+                      </g>
+                    </svg>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+    <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="hover">
+          <div class="trend-container">
+            <div class="title">流失趋势</div>
+            <div class="tabs">
+              <div class="tabs-item" :class="{ active: activeTab === 'churnTrend' }" @click="handleTabClick('churnTrend')">卸载流失设备</div>
+              <div class="tabs-item" :class="{ active: activeTab === 'recallTrend' }" @click="handleTabClick('recallTrend')">卸载召回设备</div>
+            </div>
+            <!-- 折线图 -->
+            <div class="chart-container">
+              <LineChart :data="currentChartData" :color="'#167af0'" :title="currentChartTitle" height="270px"
+                :smooth="false" :area-style="true" />
+              <div class="echarts-name">{{currentChartTitle}}</div>
+            </div>
+            <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);"/>
+            <div class="table-container">
+              <div class="btn-toggle-table" :class="{ 'hide-table': hideTable }" @click="hideTable = !hideTable">
+                {{ hideTable ? '展开' : '收起' }}明细数据<svg 
+                  width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  <path d="M10.5 8.75L7 5.25L3.5 8.75" stroke="#167AF0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
+              </div>
+
+              <el-table class="statistics-table" :data="state.dataList" row-key="date" style="width: 100%"
+                border :cell-style="tableStyle.cellStyle"
+                :header-cell-style="tableStyle.headerCellStyle"
+                v-if="!hideTable"
+                >
+                <el-table-column :label="'日期'" prop="date" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="'卸载流失设备'" :formatter="statusFormatter" prop="churn" show-overflow-tooltip></el-table-column>
+                <el-table-column :label="'卸载召回设备'" :formatter="statusFormatter" prop="recall" show-overflow-tooltip></el-table-column>
+              </el-table>
+
+              <pagination 
+                v-if="!hideTable"
+                @current-change="currentChangeHandle" 
+                @size-change="sizeChangeHandle" 
+                v-bind="state.pagination">
+              </pagination>
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script lang="ts" name="churnOverview" setup>
+import DoughnutChart from './echarts/DoughnutChart.vue'
+import LineChart from './echarts/LineChart.vue'
+import { BasicTableProps, useTable } from '/@/hooks/table';
+import { ref, computed, reactive } from 'vue'
+
+// 流失数据
+const churnData = ref([
+  { name: '流失设备', value: 38, itemStyle: { color: 'rgba(0, 188, 113, 1)' } },
+  { name: '其他', value: 62, itemStyle: { color: 'rgba(239, 239, 239, 1)' } }
+])
+
+// 召回数据
+const recallData = ref([
+  { name: '召回设备', value: 38, itemStyle: { color: 'rgba(230, 66, 66, 1)' } },
+  { name: '其他', value: 62, itemStyle: { color: 'rgba(239, 239, 239, 1)' } }
+])
+
+// 流失趋势数据
+const churnTrendData = ref([
+  { date: '2025-01-01', value: 25 },
+  { date: '2025-01-02', value: 32 },
+  { date: '2025-01-03', value: 28 },
+  { date: '2025-01-04', value: 45 },
+  { date: '2025-01-05', value: 38 },
+  { date: '2025-01-06', value: 42 },
+  { date: '2025-01-07', value: 35 },
+  { date: '2025-01-08', value: 48 },
+  { date: '2025-01-09', value: 52 },
+  { date: '2025-01-10', value: 39 },
+  { date: '2025-01-11', value: 44 },
+  { date: '2025-01-12', value: 37 },
+  { date: '2025-01-13', value: 41 },
+  { date: '2025-01-14', value: 46 }
+])
+
+// 召回趋势数据
+const recallTrendData = ref([
+  { date: '2025-01-01', value: 15 },
+  { date: '2025-01-02', value: 22 },
+  { date: '2025-01-03', value: 18 },
+  { date: '2025-01-04', value: 35 },
+  { date: '2025-01-05', value: 28 },
+  { date: '2025-01-06', value: 32 },
+  { date: '2025-01-07', value: 25 },
+  { date: '2025-01-08', value: 38 },
+  { date: '2025-01-09', value: 42 },
+  { date: '2025-01-10', value: 29 },
+  { date: '2025-01-11', value: 34 },
+  { date: '2025-01-12', value: 27 },
+  { date: '2025-01-13', value: 31 },
+  { date: '2025-01-14', value: 36 }
+])
+
+const activeTab = ref('churnTrend')
+
+// 计算当前图表数据
+const currentChartData = computed(() => {
+  return activeTab.value === 'churnTrend' ? churnTrendData.value : recallTrendData.value
+})
+
+// 计算当前图表标题
+const currentChartTitle = computed(() => {
+  return activeTab.value === 'churnTrend' ? '卸载流失设备' : '卸载召回设备'
+})
+
+const handleTabClick = (tab: string) => {
+  activeTab.value = tab
+} 
+
+// 表格数据
+const hideTable = ref(false);
+const state: BasicTableProps = reactive<BasicTableProps>({
+  queryForm: {
+    ip: '',
+  },
+  pageList: () => Promise.resolve([]),
+  pagination: {
+    current: 1,
+    size: 10,
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  },
+  dataList: [
+    {
+      date: '2025-01-01',
+      churn: 10,
+      recall: 20
+    },
+    {
+      date: '2025-01-02',
+      churn: 15,
+      recall: 25
+    },
+    {
+      date: '2025-01-03',
+      churn: 20,
+      recall: 30
+    },
+    {
+      date: '2025-01-04',
+      churn: 25,
+      recall: 35
+    }
+  ]
+});
+const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
+  return cellValue || '--';
+}
+
+</script>
+<style scoped lang="scss">
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+.overview {
+  font-family: Source Han Sans SC;
+  color: rgba(18, 18, 18, 1);
+}
+
+.top-info {
+  color: rgba(18, 18, 18, 1);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 25px;
+
+
+  .title {
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 20px;
+    padding: 4px 0;
+  }
+
+  .aside {
+    display: flex;
+  }
+
+  .data-source-status {
+    font-weight: 400;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(18, 18, 18, 1);
+    padding: 4px 16px 4px 0;
+  }
+
+  .goto-smart-operation {
+    padding: 4px 12px;
+    border-radius: 4px;
+    border: 1px solid rgba(22, 122, 240, 1);
+    font-weight: 400;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(22, 122, 240, 1);
+  }
+
+}
+
+.top-content {
+  display: flex;
+  gap: 20px;
+
+  .content-item {
+    display: flex;
+    align-items: center;
+    gap: 0;
+    flex: 1;
+    padding: 27.5px;
+    justify-content: center;
+  }
+
+  .content-item-left {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .content-item-right {
+    // flex: 1;
+  }
+
+  .content-item-title {
+    color: rgba(18, 18, 18, 1);
+    font-weight: 400;
+    font-size: 15px;
+    line-height: 20px;
+    margin-bottom: 12px;
+  }
+
+  .content-item-value {
+    font-weight: 500;
+    font-style: Medium;
+    font-size: 28px;
+    margin-bottom: 16px;
+    line-height: 41px;
+  }
+
+  .content-item-percent {
+    font-weight: 400;
+    font-size: 14px;
+    vertical-align: middle;
+    line-height: 20px;
+  }
+
+  .content-item-percent span {
+    color: rgba(0, 188, 113, 1);
+  }
+}
+
+.trend-container {
+  padding: 10px 14px;
+  .title {
+    line-height: 19px;
+    font-weight: 500;
+    font-size: 16px;
+    padding-left: 12px;
+    position: relative;
+    margin-bottom: 53px;
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 4px;
+      height: 14px;
+      background: rgba(22, 122, 240, 1);
+    }
+  }
+
+  .tabs {
+    width: 240px;
+    display: flex;
+    margin-left: 85px;
+    .tabs-item {
+      flex: 1;
+      height: 32px;
+      line-height: 30px;
+      border: 1px solid rgba(22, 122, 240, 1);
+      text-align: center;
+      color: rgba(22, 122, 240, 1);
+      cursor: pointer;
+      &.active {
+        background: rgba(22, 122, 240, 1);
+        color: #ffffff;
+      }
+    }
+  }
+
+  .chart-container {
+    margin: 20px 85px 0;
+    text-align: center;
+  }
+
+  .echarts-name {
+    display: inline-block;
+    margin: 28px auto 0;
+    padding-left: 16px;
+    font-weight: 400;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(18, 18, 18, 1);
+    position: relative;
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 8px;
+      height: 8px;
+      background: rgba(22, 122, 240, 1);
+      border-radius: 50%;
+    }
+  }
+
+  .table-container {
+    padding: 0 85px;
+    
+    .btn-toggle-table {
+      font-weight: 500;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(22, 122, 240, 1);
+      margin-bottom: 20px;
+      cursor: pointer;
+      svg {
+        transition: transform 0.3s ease-in-out;
+      }
+      &.hide-table svg {
+        transform: rotate(-180deg);
+      }
+    }
+
+    .table-container {
+      margin-top: 20px;
+    }
+
+  }
+
+}
+</style>