jcq преди 5 дни
родител
ревизия
061d7e3a1a

+ 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>

+ 241 - 52
src/views/count/main/trend/index.vue

@@ -92,15 +92,9 @@
                </div>
             </template>
          </Lcard>
-         <Lcard :height="975">          
-            <el-date-picker
-               style="float: left;"
-               v-model="timeRange"
-               type="datetimerange"
-               range-separator="To"
-               start-placeholder="Start date"
-               end-placeholder="End date"
-            />
+         <Lcard :height="975">
+            <el-date-picker style="float: left;" v-model="timeRange" type="datetimerange" range-separator="To"
+               start-placeholder="Start date" end-placeholder="End date" />
             <div class="link" style="float: right;">订阅</div>
             <div class="link" style="float: right;">编辑指标</div>
             <div class="link" style="float: right;">生成AI简报</div>
@@ -115,27 +109,81 @@
                   </el-form-item>
                </el-form>
                <div class="flex items-center">
-                  <el-radio-group size="small">
+                  <el-radio-group v-model="lineChartUser">
                      <el-radio-button label="hour">新增用户</el-radio-button>
                      <el-radio-button label="day">活跃用户</el-radio-button>
                      <el-radio-button label="week">启动次数</el-radio-button>
                      <el-radio-button label="month">累计用户</el-radio-button>
                   </el-radio-group>
                </div>
-				</div>
+            </div>
             <div class="relative">
                <div ref="qChartRef" style="width: 100%; height: 320px"></div>
             </div>
+            <div class="mt-3">
+               <div class="flex items-center justify-between mb-2">
+                  <div class="text-base font-medium cursor-pointer select-none" @click="showDetail1 = !showDetail1">
+                     {{ showDetail1 ? '收起明细数据' : '展开明细数据' }}
+                  </div>
+                  <div>
+                     <el-button>导出</el-button>
+                  </div>
+               </div>
+               <el-table v-if="showDetail1" :data="pagedTableRows" border>
+                  <el-table-column prop="date" label="日期" min-width="140" />
+                  <el-table-column label="新增用户(占比)" min-width="220">
+                     <template #default="scope">
+                        <div class="flex items-center justify-between w-full">
+                           <span>{{ scope.row.newUsers }}</span>
+                           <span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
+                        </div>
+                     </template>
+                  </el-table-column>
+               </el-table>
+               <div v-if="showDetail1" class="flex justify-end mt-3">
+                  <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" background
+                     layout="total, prev, pager, next, sizes" :total="tableRows.length" :page-sizes="[5, 10, 20]" />
+               </div>
+            </div>
          </Lcard>
-      <Lcard :height="440" />
-      <Lcard :height="440" />
-      <Lcard :height="440" />
-   </div>
+         <Lcard :height="660">
+            <div class="msg">
+               <el-icon class="ml-1" style="color: #a4b8cf; vertical-align: middle;">
+                  <InfoFilled />
+               </el-icon>
+               点击下列环形图中单个版本/渠道将跳转到对应的详情页
+            </div>
+            <div class="box1">
+               <lcard :height="540" style="margin-right: 20px;">
+                  <div class="box1-title" style="float: left;">TOP版本</div>
+                  <div class="link" style="float: right;">详情</div>
+                  <div class="line" style="margin-top: 40px;"></div>
+                  <el-radio-group v-model="circleEchartUser1">
+                     <el-radio-button label="hour">新增用户</el-radio-button>
+                     <el-radio-button label="day">活跃用户</el-radio-button>
+                     <el-radio-button label="month">累计用户</el-radio-button>
+                  </el-radio-group>
+                  <div ref="circleEchartRef1" style="width: 100%; height: 380px;"></div>
+               </lcard>
+               <lcard :height="540">
+                  <div class="box1-title" style="float: left;">TOP版本</div>
+                  <div class="link" style="float: right;">详情</div>
+                  <div class="line" style="margin-top: 40px;"></div>
+                  <el-radio-group v-model="circleEchartUser1">
+                     <el-radio-button label="hour">新增用户</el-radio-button>
+                     <el-radio-button label="day">活跃用户</el-radio-button>
+                     <el-radio-button label="month">累计用户</el-radio-button>
+                  </el-radio-group>
+                  <div ref="circleEchartRef2" style="width: 100%; height: 380px;"></div>
+               </lcard>
+            </div>
+         </Lcard>
+      </div>
    </div>
 </template>
 
 <script lang="ts" name="countMainTrend" setup>
-import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+// import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
 import { useI18n } from 'vue-i18n';
 import * as echarts from 'echarts';
 // 引入组件
@@ -150,51 +198,191 @@ let qualityChart: echarts.ECharts | null = null;
 onMounted(() => {
    setTimeout(() => {
       initQualityChart();
+      initCircleChart2()
+      initCircleChart1()
    }, 500);
 });
 const qualityXAxis = ref<string[]>([
-	'2025-07-01',
-	'2025-07-08',
-	'2025-07-15',
-	'2025-07-22',
-	'2025-07-29',
-	'2025-08-05',
-	'2025-08-12',
-	'2025-08-19',
-	'2025-08-26',
-	'2025-09-02',
-	'2025-09-09',
-	'2025-09-16',
+   '2025-07-01',
+   '2025-07-08',
+   '2025-07-15',
+   '2025-07-22',
+   '2025-07-29',
+   '2025-08-05',
+   '2025-08-12',
+   '2025-08-19',
+   '2025-08-26',
+   '2025-09-02',
+   '2025-09-09',
+   '2025-09-16',
 ]);
 const retentionSeries = ref<number[]>([20, 23, 27, 24, 22, 15, 5, 4, 16, 26, 25, 2]);
 const industryAvgSeries = ref<number[]>([16, 18, 20, 24, 25, 24, 16, 10, 15, 22, 21, 12]);
 const peerSameScaleSeries = ref<number[]>([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
 function initQualityChart(): void {
-	if (!qChartRef.value) return;
-	if (qualityChart) qualityChart.dispose();
-	qualityChart = echarts.init( qChartRef.value);
-   debugger
-	const option: echarts.EChartsOption = {
-		tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
-		legend: { data: ['留存率', '同行业App', '同行业同规模App'] },
-		grid: { left: 40, right: 20, top: 30, bottom: 30 },
-		xAxis: { type: 'category', data: qualityXAxis.value },
-		yAxis: {
-			type: 'value',
-			min: 0,
-			max: 30,
-			axisLabel: { formatter: '{value}%' },
-			splitLine: { lineStyle: { color: '#f3f4f6' } },
-		},
-		series: [
-			{ name: '留存率', type: 'line', smooth: true, data: retentionSeries.value },
-			{ name: '同行业App', type: 'line', smooth: true, data: industryAvgSeries.value, color: '#f59e0b' },
-			{ name: '同行业同规模App', type: 'line', smooth: true, data: peerSameScaleSeries.value, color: '#60a5fa' },
-		],
-	};
-	qualityChart.setOption(option);
+   console.log(qChartRef.value, qChartRef);
+   if (!qChartRef.value) return;
+   if (qualityChart) qualityChart.dispose();
+   qualityChart = echarts.init(qChartRef.value);
+   const option: echarts.EChartsOption = {
+      tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
+      legend: { data: ['留存率', '同行业App', '同行业同规模App'] },
+      grid: { left: 40, right: 20, top: 30, bottom: 30 },
+      xAxis: { type: 'category', data: qualityXAxis.value },
+      yAxis: {
+         type: 'value',
+         min: 0,
+         max: 30,
+         axisLabel: { formatter: '{value}%' },
+         splitLine: { lineStyle: { color: '#f3f4f6' } },
+      },
+      series: [
+         { name: '留存率', type: 'line', smooth: true, data: retentionSeries.value },
+         { name: '同行业App', type: 'line', smooth: true, data: industryAvgSeries.value, color: '#f59e0b' },
+         { name: '同行业同规模App', type: 'line', smooth: true, data: peerSameScaleSeries.value, color: '#60a5fa' },
+      ],
+   };
+   qualityChart.setOption(option);
+}
+
+
+interface TableRow {
+	date: string;
+	newUsers: number;
+	ratio: string;
 }
+// 展开/收起明细
+const showDetail1 = ref(true);
+const pagedTableRows = computed(() => {
+	const startIndex = (currentPage.value - 1) * pageSize.value;
+	return tableRows.value.slice(startIndex, startIndex + pageSize.value);
+});
+// 表格相关(静态数据)
+const currentPage = ref(1);
+const pageSize = ref(5);
+const tableRows = ref<TableRow[]>(
+	Array.from({ length: 42 }).map((_, idx) => ({
+		date: `2025-08-${String(11).padStart(2, '0')}`,
+		newUsers: 727,
+		ratio: '97.45%',
+	}))
+);
+
 
+// 环形图
+// 添加圆环图相关的引用和数据
+const circleEchartRef1 = ref(null);
+let circleChart1: echarts.ECharts | null = null;
+const circleEchartUser1 = ref('hour');
+
+const circleEchartRef2 = ref(null);
+let circleChart2: echarts.ECharts | null = null;
+const circleEchartUser2 = ref('hour');
+
+// 初始化圆环图
+function initCircleChart1(): void {
+  if (!circleEchartRef1.value) return;
+  if (circleChart1) circleChart1.dispose();
+  
+  circleChart1 = echarts.init(circleEchartRef1.value);
+  
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'item'
+    },
+    legend: {
+      bottom: '0',
+      left: 'center'
+    },
+    series: [
+      {
+        name: '版本分布',
+        type: 'pie',
+        radius: ['50%', '70%'], // 设置内半径和外半径,形成圆环效果
+        avoidLabelOverlap: false,
+        padAngle: 2, // 扇区间隙
+        itemStyle: {
+          borderRadius: 5 // 扇区圆角
+        },
+        label: {
+          show: true,
+          formatter: '{b}: {d}%' // 显示标签和百分比
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontSize: 14,
+            fontWeight: 'bold'
+          }
+        },
+        labelLine: {
+          show: true
+        },
+        data: [
+          { value: 35, name: 'v3.2.1' },
+          { value: 25, name: 'v3.1.5' },
+          { value: 20, name: 'v3.0.8' },
+          { value: 10, name: 'v2.9.3' },
+          { value: 10, name: '其他' }
+        ]
+      }
+    ]
+  };
+  
+  circleChart1.setOption(option);
+}
+// 初始化圆环图
+function initCircleChart2(): void {
+  if (!circleEchartRef2.value) return;
+  if (circleChart2) circleChart2.dispose();
+  
+  circleChart2 = echarts.init(circleEchartRef2.value);
+  
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'item'
+    },
+    legend: {
+      bottom: '0',
+      left: 'center'
+    },
+    series: [
+      {
+        name: '版本分布',
+        type: 'pie',
+        radius: ['50%', '70%'], // 设置内半径和外半径,形成圆环效果
+        avoidLabelOverlap: false,
+        padAngle: 2, // 扇区间隙
+        itemStyle: {
+          borderRadius: 5 // 扇区圆角
+        },
+        label: {
+          show: true,
+          formatter: '{b}: {d}%' // 显示标签和百分比
+        },
+        emphasis: {
+          label: {
+            show: true,
+            fontSize: 14,
+            fontWeight: 'bold'
+          }
+        },
+        labelLine: {
+          show: true
+        },
+        data: [
+          { value: 35, name: 'v3.2.1' },
+          { value: 25, name: 'v3.1.5' },
+          { value: 20, name: 'v3.0.8' },
+          { value: 10, name: 'v2.9.3' },
+          { value: 10, name: '其他' }
+        ]
+      }
+    ]
+  };
+  
+  circleChart2.setOption(option);
+}
 
 
 </script>
@@ -234,8 +422,9 @@ function initQualityChart(): void {
       }
    }
 }
+
 .line {
-   margin: 90px -30px 30px;
+   margin: 60px -30px 30px;
    height: 1px;
    background-color: #E6E6E6;
 }

+ 1 - 1
src/views/count/user/activations/index.vue

@@ -223,7 +223,7 @@ const showDetail1 = ref(true);
 
 
 </script>
-
+ 
 <style lang="scss" scoped>
 .highlight {
 	color: #2196f3;

+ 11 - 0
src/views/count/user/versionDistribution/i18n/en.ts

@@ -0,0 +1,11 @@
+export default {
+	addUser: {
+		analytics:'Added user analysis',
+		growth:'Go to U-Growth to text/push',
+		ai:'Generative AI intelligence',
+		addtrend:'New trends',
+		channel:'Select a channel',
+		userQuality:'User quality',
+		average:'Industry average'
+	},
+};

+ 10 - 0
src/views/count/user/versionDistribution/i18n/zh-cn.ts

@@ -0,0 +1,10 @@
+export default {
+	versionDistribution: {
+		analytics:'版本分布',
+		aijb:'查看完整ai简报',
+		addtrend:'新增趋势',
+		version:'版本',
+		allVersion:'全部版本',
+		versionComparison:'版本对比'
+	},
+};

+ 237 - 0
src/views/count/user/versionDistribution/index.vue

@@ -0,0 +1,237 @@
+<template>
+	<div class="layout-padding">
+		<div class="!overflow-auto px-1">
+			<div class="el-card p-2">
+				<div class="flex justify-between">
+					<Title :title="t('activations.analytics')">
+						<template #default>
+							<el-popover class="box-item" placement="right" trigger="hover" width="250">
+								<template #reference>
+									<el-icon class="ml-1" style="color: #a4b8cf"><QuestionFilled /></el-icon>
+								</template>
+								<template #default>
+									<div class="ant-popover-inner-content">
+										<div class="um-page-tips-content" style="line-height: 24px">
+											<p>
+												<span class="highlight">启动次数:</span
+												><span
+													>打开应用视为启动。完全退出或后台运行超过30s后再次进入应用,视为一次新启动。开发过程中可以通过setSessionContinueMills来自定义两次启动的间隔,默认30s</span
+												>
+											</p>
+											<p><span class="highlight">启动次数占比:</span><span>某日/周/月的启动次数占所选时间段总启动次数的比例</span></p>
+											<p>
+												<span
+													>按天、周或月查看数据可进行版本、渠道的交叉筛选,小时数据最多展示7天并且不支持筛选。周区间定义为周日至次周周六。按周(按月)显示时,界面上用每周的周日(每个月的第一日)来代表该周(该月)</span
+												>
+											</p>
+										</div>
+									</div>
+								</template>
+							</el-popover>
+						</template>
+					</Title>
+				</div>
+				<div>
+					<el-row shadow="hover" class="ml10 mt-2">
+						<el-form :inline="true" :model="formData" @keyup.enter="query" ref="queryRef">
+							<el-form-item>
+								<el-date-picker v-model="formData.time" type="daterange" start-placeholder="开始时间" end-placeholder="结束时间" />
+							</el-form-item>
+							<el-form-item>
+								<el-select v-model="selectedChannelCompare" class="w-[140px]" placeholder="全部渠道">
+									<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
+								</el-select>
+							</el-form-item>
+							<el-form-item>
+								<el-select v-model="selectedChannelCompare" class="w-[140px]" placeholder="全部版本">
+									<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
+								</el-select>
+							</el-form-item>
+						</el-form>
+					</el-row>
+				</div>
+			</div>
+			<div class="mt-2 el-card p-2">
+				<div class="">
+					<div class="flex items-center justify-between mb-2 mt-3">
+						<div>
+							<el-select v-model="selectedChannelCompare" class="w-[140px] ml-2" style="width: 140px" placeholder="版本对比">
+								<el-option v-for="item in channelCompareOptions" :key="item.value" :label="item.label" :value="item.value" />
+							</el-select>
+							<el-button type="primary" class="ml-2">{{ t('activations.version') }}</el-button>
+						</div>
+
+						<div class="flex items-center">
+							<el-radio-group v-model="timeGranularity" size="small">
+								<el-radio-button label="hour">小时</el-radio-button>
+
+								<el-radio-button label="day">天</el-radio-button>
+								<el-radio-button label="week">周</el-radio-button>
+								<el-radio-button label="month">月</el-radio-button>
+							</el-radio-group>
+						</div>
+					</div>
+
+					<div class="relative">
+						<div ref="lineChartRef" style="width: 100%; height: 320px"></div>
+					</div>
+				</div>
+
+				<!-- 明细表格 -->
+				<div class="mt-3">
+					<div class="flex items-center justify-between mb-2">
+						<div class="text-base font-medium cursor-pointer select-none" @click="showDetail1 = !showDetail1">
+							{{ showDetail1 ? '收起明细数据' : '展开明细数据' }}
+						</div>
+						<div>
+							<el-button>导出</el-button>
+						</div>
+					</div>
+					<el-table v-if="showDetail1" :data="pagedTableRows" border>
+						<el-table-column prop="date" label="日期" align="center" min-width="140" />
+						<el-table-column prop="hyyh" label="启动次数" align="center" min-width="140" />
+						<el-table-column prop="ratio" label="启动次数(占比)" align="center" min-width="220"> </el-table-column>
+					</el-table>
+					<div v-if="showDetail1" class="flex justify-end mt-2">
+						<el-pagination
+							v-model:current-page="currentPage"
+							v-model:page-size="pageSize"
+							background
+							layout="total, prev, pager, next, sizes"
+							:total="tableRows.length"
+							:page-sizes="[5, 10, 20]"
+						/>
+					</div>
+				</div>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+import * as echarts from 'echarts';
+import { useI18n } from 'vue-i18n';
+
+const { t } = useI18n();
+
+interface TableRow {
+	date: string;
+	newUsers: number;
+	ratio: string;
+}
+
+const formData = ref<Record<string, any>>({});
+const query = () => {
+	console.log(formData.value);
+};
+
+const selectedChannelCompare = ref('');
+const channelCompareOptions = [
+	{ label: '渠道对比', value: 'compare' },
+	{ label: '渠道A', value: 'a' },
+	{ label: '渠道B', value: 'b' },
+];
+
+// 图表相关
+const timeGranularity = ref<'hour' | 'day' | 'week' | 'month'>('week');
+const lineChartRef = ref<HTMLDivElement | null>(null);
+let chartInstance: echarts.ECharts | null = null;
+
+const lineChartData = ref<Array<{ x: string; value: number }>>([
+	{ x: '2025-07-01', value: 900 },
+	{ x: '2025-07-08', value: 1000 },
+	{ x: '2025-07-15', value: 1100 },
+	{ x: '2025-07-22', value: 1000 },
+	{ x: '2025-07-29', value: 600 },
+	{ x: '2025-08-05', value: 300 },
+	{ x: '2025-08-12', value: 250 },
+	{ x: '2025-08-19', value: 200 },
+	{ x: '2025-08-26', value: 650 },
+	{ x: '2025-09-02', value: 950 },
+	{ x: '2025-09-09', value: 900 },
+	{ x: '2025-09-16', value: 120 },
+]);
+
+function initLineChart(): void {
+	if (!lineChartRef.value) return;
+	if (chartInstance) chartInstance.dispose();
+	chartInstance = echarts.init(lineChartRef.value);
+	const option: echarts.EChartsOption = {
+		tooltip: { trigger: 'axis' },
+		grid: { left: 40, right: 20, top: 20, bottom: 30 },
+		xAxis: {
+			type: 'category',
+			data: lineChartData.value.map((d) => d.x),
+			axisLine: { lineStyle: { color: '#e5e7eb' } },
+			axisLabel: { color: '#6b7280' },
+			axisTick: { alignWithLabel: true },
+		},
+		yAxis: {
+			type: 'value',
+			axisLine: { show: false },
+			splitLine: { lineStyle: { color: '#f3f4f6' } },
+			axisLabel: { color: '#6b7280' },
+		},
+		series: [
+			{
+				name: '新增人数',
+				type: 'line',
+				smooth: true,
+				showSymbol: true,
+				symbolSize: 6,
+				itemStyle: { color: '#409EFF' },
+				lineStyle: { color: '#409EFF' },
+				data: lineChartData.value.map((d) => d.value),
+			},
+		],
+	};
+	chartInstance.setOption(option);
+}
+
+onMounted(() => {
+	initLineChart();
+});
+
+watch(timeGranularity, () => {
+	// 静态页面:仅重新渲染
+	initLineChart();
+});
+
+// 表格相关(静态数据)
+const tableRows = ref<TableRow[]>(
+	Array.from({ length: 42 }).map((_, idx) => ({
+		date: `2025-08-${String(11).padStart(2, '0')}`,
+		newUsers: 727,
+		hyyh: '115',
+		ratio: '97.45%',
+	}))
+);
+
+const currentPage = ref(1);
+const pageSize = ref(5);
+const pagedTableRows = computed(() => {
+	const startIndex = (currentPage.value - 1) * pageSize.value;
+
+	return tableRows.value.slice(startIndex, startIndex + pageSize.value);
+});
+
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+
+// 展开/收起明细
+const showDetail1 = ref(true);
+
+
+</script>
+ 
+<style lang="scss" scoped>
+.highlight {
+	color: #2196f3;
+}
+.el-form-item--default{
+	margin-bottom: 0;
+}
+.el-form.el-form--inline .el-form-item--default.el-form-item:last-of-type, .el-form.el-form--inline .el-form-item--small.el-form-item:last-of-type{
+	margin-bottom: 0 !important;
+}
+</style>