cmy 2 日 前
コミット
36d13d5a43

+ 78 - 2
src/api/count/churn.ts

@@ -19,7 +19,7 @@ export const uninstallTrend = (data?: Object) => {
 };
 
 /**
- * 卸载画像 获取卸载趋势参数
+ * 卸载画像
  * @param startDate	开始时间(yyyy-MM-dd),不传则默认为当周第一天
  * @param endDate	结束时间(yyyy-MM-dd),不传则默认为当前时间
  * @param timeUnit	时间单位(day/week/month),不传则默认为day
@@ -125,4 +125,80 @@ export const uninstallAction = (data?: Object) => {
 		method: 'post',
 		data: data,
 	});
-};
+};
+
+/**
+ * 流失概况-获取卸载趋势明细数据
+ * @param startDate	开始时间(yyyy-MM-dd)
+ * @param endDate	结束时间(yyyy-MM-dd)	
+ * @param timeUnit	时间单位(week/month)	
+ * @param appId	appId	
+ * @param channel	渠道	
+ * @param version	版本	
+ * @param pageNum	页码	
+ * @param pageSize	每页条数	
+ * @returns 
+ */
+export const uninstallTrendDetail = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/trend/detail',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载洞察-卸载前状态
+ * @param cycle	周期(周/月)
+ * @param type	类型 1
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @returns 
+ */
+export const uninstallBefore = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/uninstallBefore',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载洞察-卸载后流失
+ * @param cycle	周期(周/月)
+ * @param type	类型 2
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @returns 
+ */
+export const uninstallAfter = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/uninstallAfter',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载洞察-卸载设备活跃情况
+ * @param cycle	周期(周/月)
+ * @param type	类型 4
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @returns 
+ */
+export const uninstallActive = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/active',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载洞察-设备系统分布
+ * @param type	类型 3
+ */

+ 3 - 4
src/views/count/churn/ascribe/index.vue

@@ -22,7 +22,7 @@
         <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
           <el-card shadow="none">
             <div class="trend-container">
-              <div class="title">卸载设备全量预测<svg
+              <div class="card-title" style="margin-bottom: 53px;">卸载设备全量预测<svg
                 width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
                 <path d="M0 13C0 5.8203 5.8203 0 13 0H22C23.1046 0 24 0.895431 24 2V9C24 16.1797 18.1797 22 11 22H2C0.89543 22 0 21.1046 0 20V13Z" fill="url(#paint0_linear_834_812)"/>
                 <path d="M6 16.5L9.63695 5.5H11.6343L15.2712 16.5H13.4379L12.5137 13.3806H8.6979L7.77376 16.5H6ZM9.11526 12.0075H12.0964L11.6641 10.5299C11.3064 9.33582 10.9784 8.11194 10.6356 6.87313H10.576C10.2481 8.12687 9.90525 9.33582 9.54752 10.5299L9.11526 12.0075Z" fill="white"/>
@@ -33,8 +33,7 @@
                 <stop offset="1" stop-color="#0081EB"/>
                 </linearGradient>
                 </defs>
-                </svg>
-              </div>
+              </svg></div>
               <div class="card-tabs">
                 <div class="card-tab" :class="{ active: activeTab === Type.CHURN }"
                   @click="handleTabClick(Type.CHURN)">
@@ -111,7 +110,7 @@
         <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
           <el-card shadow="none">
             <div class="trend-container">
-              <div class="title">安装卸载比</div>
+              <div class="card-title" style="margin-bottom: 53px;">安装卸载比</div>
               <!-- 折线图 -->
               <div class="chart-container">
                 <LineChart :data="data" :color="'#167af0'" height="270px" :showLegend="true"

+ 42 - 16
src/views/count/churn/behavior/components/AfterUninstallStatus.vue

@@ -3,7 +3,7 @@
     <div class="card-title">
       卸载用户竞品流向
       <el-tooltip class="box-item" effect="light"
-        content="「卸载用户同行业流向」基于友盟全网数据和算法能力共同预测得出,与应用自身是否使用友盟的服务无关。设置不允许他人关注后,您也将无法使用此功能。" placement="right-start">
+        content="「卸载用户同行业流向」基于全网数据和算法能力共同预测得出,与应用自身是否使用的服务无关。设置不允许他人关注后,您也将无法使用此功能。" placement="right-start">
         <svg style="margin-left: 8px;" width="14" height="14" viewBox="0 0 14 14" fill="none"
           xmlns="http://www.w3.org/2000/svg">
           <path
@@ -33,17 +33,17 @@
         <el-row :gutter="12" style="padding: 0; row-gap: 12px; margin-bottom: 12px; position: relative; z-index: 2;">
           <div class="main">
             <div class="p1">流向关注应用</div>
-            <div class="p2">流入人数:384444</div>
-            <div class="p3">占比:72.23%</div>
+            <div class="p2">流入人数:{{ count.total }}</div>
+            <div class="p3">占比:{{ count.proportion }}%</div>
             <div class="ring-chart">
               <ProgressRing 
-                :progress="0.7223"
+                :progress="count.proportion / 100"
                 :size="96"
                 background-color="rgba(239, 239, 239, 1)"
                 progress-color="rgba(22, 122, 240, 1)"
                 :stroke-width="10"
               />
-              <div class="ring-text">72.23%</div>
+              <div class="ring-text">{{ count.proportion }}%</div>
             </div>
           </div>
           <div class="sub">
@@ -69,6 +69,7 @@
 <script lang="ts" setup>
 import { ref, computed } from 'vue'
 import ProgressRing from '/@/views/count/components/ProgressRing.vue'
+import { uninstallAfter } from '/@/api/count/churn'
 
 // 定义sub-item的数据结构
 interface SubItem {
@@ -78,13 +79,16 @@ interface SubItem {
 }
 
 // 定义sub-items数据
-const subItems = ref<SubItem[]>([
-  { name: '鸿蒙电商Demo', value: '11915', percentage: '27%' },
-  { name: '教育行业Demo', value: '19615', percentage: '47%' },
-  { name: '通用Demo', value: '9615', percentage: '2%' },
-  { name: '游戏行业Demo', value: '11961', percentage: '7%' },
-  { name: '阅读行业Demo', value: '1115', percentage: '22.47%' },
-])
+const subItems = ref<SubItem[]>([])
+const count = ref<{
+  total: number,
+  appName: string,
+  proportion: number
+}>({
+  total: 0,
+  appName: '',
+  proportion: 0
+})
 
 // 生成连接路径的函数
 const generateConnectionPath = (index: number) => {
@@ -98,16 +102,38 @@ const generateConnectionPath = (index: number) => {
   // 计算中间点(一半距离)
   const midX = (mainX + subStartX) / 2  // 390
   
-  // 控制点1:先往上扬起,第一条曲线到一半时和main齐平
-  const control1X = 340
-  const control1Y = mainY - 30  // 扬起高度,确保第一条曲线到一半时和main齐平
+  const control1X = 320
+  const control1Y = mainY - 100
   
   // 控制点2:平滑过渡到目标位置,确保曲线平滑地连接到sub-item左边
-  const control2X = 450
+  const control2X = 360
   const control2Y = subY
   
   return `M ${mainX} ${mainY} C ${control1X} ${control1Y} ${control2X} ${control2Y} ${subStartX} ${subY}`
 }
+
+const getUninstallAfter = () => {
+  uninstallAfter({
+    cycle: 'week',
+    type: '2',
+  }).then((res) => {
+    if(res.code === 0 && res.data?.length > 0) {
+      res.data.forEach((item: any) => {
+        count.value.total += item.count;
+        count.value.proportion += parseFloat(item.proportion);
+        subItems.value.push({
+          name: item.appName,
+          value: item.count,
+          percentage: item.proportion + '%'
+        })
+      })
+    }
+  })
+}
+
+onMounted(() => {
+  getUninstallAfter()
+})
 </script>
 
 <style scoped lang="scss">

+ 60 - 22
src/views/count/churn/behavior/components/BeforeUninstallStatus.vue

@@ -8,7 +8,7 @@
             <div class="description" style="margin-bottom: 30px;">
               <p>卸载前最后使用App至卸载时间差</p>
               <p>最后使用App至卸载时间差=最终卸载App日期-末次启动App日期</p>
-              <p>卸载前已经连续失活7天以上的用户占比:<span>100%</span></p>
+              <p>卸载前已经连续失活7天以上的用户占比:<span>{{ inactive7DaysPercentage }}%</span></p>
             </div>
             <div class="content-item">
               <HorizontalBarChart :data="chartData" :title="['时长分布', '占比']" />
@@ -20,7 +20,7 @@
             <div class="description">
               <p>卸载设备前7天使用次数分布</p>
               <p>卸载App前7天(含当日)启动App的次数</p>
-              <p>设备卸载前仍具备高粘性占比:<span>96.5%</span></p>
+              <p>设备卸载前仍具备高粘性占比:<span>{{ highFrequencyPercentage }}%</span></p>
             </div>
             <div class="content-item">
               <BarChart :title="'历史卸载次数'" :data="usageCountData" />
@@ -65,26 +65,20 @@
 <script lang="ts" setup>
 import BarChart from '/@/views/count/components/BarChart.vue'
 import HorizontalBarChart from './HorizontalBarChart.vue'
-import { uninstallAction, uninstallInterfere } from '/@/api/count/churn'
-
-const chartData = [
-  { name: '0-7天', value: 45, percentage: '84.00%' },
-  { name: '8-15天', value: 23, percentage: '4.00%' },
-  { name: '16-30天', value: 23, percentage: '4.00%' },
-  { name: '31-45天', value: 23, percentage: '14.00%' },
-  { name: '46-60天', value: 23, percentage: '4.00%' },
-  { name: '61-90天', value: 23, percentage: '4.00%' },
-  { name: '90天以上', value: 23, percentage: '4.00%' }
-]
-
-const usageCountData = [
-  { name: '1次', value: 45, percentage: '45.0%' },
-  { name: '2次', value: 23, percentage: '23.0%' },
-  { name: '3次', value: 15, percentage: '15.0%' },
-  { name: '4次', value: 8, percentage: '8.0%' },
-  { name: '5次', value: 6, percentage: '6.0%' },
-  { name: '6次以上', value: 3, percentage: '3.0%' }
-]
+import { uninstallAction, uninstallInterfere, uninstallBefore } from '/@/api/count/churn'
+
+interface ChartData {
+  name: string;
+  value: number;
+  percentage: string;
+}
+
+const chartData = ref<ChartData[]>([])
+// 卸载前已经连续失活7天以上的用户占比
+const inactive7DaysPercentage = ref(0);
+const usageCountData = ref<ChartData[]>([]);
+// 设备卸载前仍具备高粘性占比
+const highFrequencyPercentage = ref(0);
 
 const experienceData = ref([] as any[])
 const ViewPagesData = ref([] as any[])
@@ -116,6 +110,7 @@ const getUninstallInterfere = () => {
           value: val
         })
       })
+      experienceData.value.reverse();
     }
   })
 }
@@ -139,6 +134,48 @@ const getUninstallAction = () => {
   })
 }
 
+const getUninstallBefore = () => {
+  inactive7DaysPercentage.value = 0;
+  highFrequencyPercentage.value = 0;
+  usageCountData.value = [];
+  chartData.value = [];
+
+  uninstallBefore({
+    cycle: 'week',
+    type: '1',
+  }).then((res) => {
+    if(res.code === 0 && res.data?.uninstallTimeDiffs?.length > 0) {
+      res.data.uninstallTimeDiffs.forEach((item: any) => {
+        chartData.value.push({
+          name: item.time,
+          value: item.count,
+          percentage: item.rate + '%'
+        })
+        if(item.time != '0-7天') {
+          inactive7DaysPercentage.value += item.rate;
+        }
+      })
+    }
+
+    if(res.code === 0 && res.data?.uninstallBeforeSevens?.length > 0) {
+
+      res.data.uninstallBeforeSevens.forEach((item: any) => {
+        usageCountData.value.push({
+          name: item.time,
+          value: item.count,
+          percentage: item.rate + '%'
+        })
+      
+        if (item.time === '6次以上' || item.time === '5次') {
+          highFrequencyPercentage.value += item.rate;
+        }
+      })
+
+      usageCountData.value.reverse();
+    }
+  })
+}
+
 const handleLast7DaysTabClick = (tab: Last7DaysTab) => {
   last7DaysTab.value = tab
   getUninstallInterfere()
@@ -152,6 +189,7 @@ const handleViewPagesTabClick = (tab: ViewPagesTab) => {
 onMounted(() => {
   getUninstallInterfere();
   getUninstallAction();
+  getUninstallBefore();
 })
 
 </script>

+ 4 - 4
src/views/count/churn/behavior/components/HorizontalBarChart.vue

@@ -177,9 +177,9 @@ onUnmounted(() => {
 
 // 监听数据变化
 watch(displayData, (newData) => {
-  count.value = newData.reduce((count, item) => {
-    count = item.value + count;
-    return count;
+  count.value = newData.reduce((i: number, item: BarItem) => {
+    i = item.value + i;
+    return i;
   }, 0);
 }, {
   deep: true,
@@ -251,7 +251,7 @@ watch(displayData, (newData) => {
       div {
         height: 100%;
         background: rgba(92, 223, 223, 1);
-        transition: all 0.3s ease;
+        transition: all 0.3s ease-out;
       }
     }
     .chart-item-value {

+ 0 - 271
src/views/count/churn/behavior/components/MindChart.vue

@@ -1,271 +0,0 @@
-<template>
-  <div class="mind-map-container">
-    <div ref="chartRef" class="mind-map-chart"></div>
-  </div>
-</template>
-
-<script setup>
-import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
-import * as echarts from 'echarts';
-
-// 使用 ref 创建图表容器的引用
-const chartRef = ref(null);
-let chartInstance = null;
-
-// 定义组件接收的 props
-const props = defineProps({
-  nodes: {
-    type: Array,
-    default: () => [],
-  },
-  links: {
-    type: Array,
-    default: () => [],
-  },
-  theme: {
-    type: String,
-    default: 'light'
-  }
-});
-
-// 生成示例数据的函数(用于测试)
-const generateSampleData = () => {
-  const sampleNodes = [
-    { name: '主概念', category: 0, value: '核心主题', symbolSize: 60 },
-    { name: '子概念1', category: 1, value: '相关主题1', symbolSize: 40 },
-    { name: '子概念2', category: 1, value: '相关主题2', symbolSize: 40 },
-    { name: '子概念3', category: 1, value: '相关主题3', symbolSize: 40 },
-    { name: '孙概念1', category: 1, value: '详细主题1', symbolSize: 30 },
-    { name: '孙概念2', category: 1, value: '详细主题2', symbolSize: 30 }
-  ];
-  
-  const sampleLinks = [
-    { source: '主概念', target: '子概念1' },
-    { source: '主概念', target: '子概念2' },
-    { source: '主概念', target: '子概念3' },
-    { source: '子概念1', target: '孙概念1' },
-    { source: '子概念2', target: '孙概念2' }
-  ];
-  
-  return { nodes: sampleNodes, links: sampleLinks };
-};
-
-// 验证数据格式
-const validateData = () => {
-  if (!Array.isArray(props.nodes) || props.nodes.length === 0) {
-    console.warn('MindChart: nodes 数据为空或格式不正确');
-    return false;
-  }
-  
-  // 确保每个节点都有必要的属性
-  const validNodes = props.nodes.every(node => 
-    node && typeof node === 'object' && node.name
-  );
-  
-  if (!validNodes) {
-    console.warn('MindChart: 某些节点数据格式不正确');
-    return false;
-  }
-  
-  return true;
-};
-
-// 获取实际使用的数据
-const getChartData = () => {
-  let nodes = props.nodes;
-  let links = props.links;
-  
-  // 如果没有数据,使用示例数据
-  if (!Array.isArray(nodes) || nodes.length === 0) {
-    const sampleData = generateSampleData();
-    nodes = sampleData.nodes;
-    links = sampleData.links;
-    console.log('MindChart: 使用示例数据');
-  }
-  
-  return { nodes, links };
-};
-
-// 初始化图表
-const initChart = async () => {
-  if (!chartRef.value) {
-    console.warn('MindChart: 图表容器未找到');
-    return;
-  }
-  
-  // 获取图表数据
-  const { nodes, links } = getChartData();
-  
-  // 验证数据
-  if (!nodes || nodes.length === 0) {
-    console.warn('MindChart: 没有可用的节点数据');
-    return;
-  }
-  
-  try {
-    // 销毁旧的图表实例(如果存在)
-    if (chartInstance) {
-      echarts.dispose(chartInstance);
-      chartInstance = null;
-    }
-    
-    // 等待DOM更新完成
-    await nextTick();
-    
-    // 确保容器有尺寸
-    const container = chartRef.value;
-    if (container.offsetWidth === 0 || container.offsetHeight === 0) {
-      console.warn('MindChart: 容器尺寸为0,延迟初始化');
-      setTimeout(() => initChart(), 100);
-      return;
-    }
-    
-    // 初始化 ECharts 实例
-    chartInstance = echarts.init(container, props.theme);
-    
-    // 配置图表选项
-    const option = {
-      backgroundColor: 'transparent',
-      tooltip: {
-        show: true,
-        formatter: (params) => {
-          if (params.dataType === 'node') {
-            return `${params.data.name}<br/>${params.data.value || ''}`;
-          }
-          return `${params.data.source} → ${params.data.target}`;
-        }
-      },
-      series: [{
-        type: 'graph',
-        layout: 'force', // 使用力导向布局
-        force: {
-          repulsion: 200, // 节点之间的斥力
-          gravity: 0.1, // 向中心的引力
-          edgeLength: 100, // 边的默认长度
-          layoutAnimation: true,
-        },
-        data: nodes.map(node => ({
-          ...node,
-          category: node.category || 1, // 确保有category属性
-          symbolSize: node.symbolSize || (node.category === 0 ? 50 : 30)
-        })),
-        links: links || [],
-        categories: [
-          { name: '主节点' },
-          { name: '子节点' }
-        ],
-        roam: true, // 开启缩放和平移
-        label: {
-          show: true,
-          position: 'right',
-          formatter: '{b}',
-          fontSize: 12,
-          color: '#333'
-        },
-        edgeLabel: {
-          show: false
-        },
-        emphasis: {
-          focus: 'adjacency',
-          lineStyle: {
-            width: 3
-          }
-        },
-        lineStyle: {
-          opacity: 0.8,
-          width: 2,
-          curveness: 0.2, // 连接线曲度,0-1之间,0为直线,1为最大曲度
-          color: '#666', // 连接线颜色
-          type: 'solid' // 线条类型:solid, dashed, dotted
-        },
-        symbolSize: (val) => {
-          // 主节点大小较大,子节点较小
-          // 添加安全检查,防止 val 为 undefined 或没有 category 属性
-          if (!val || typeof val.category === 'undefined') {
-            return 30; // 默认大小
-          }
-          return val.category === 0 ? 50 : 30;
-        },
-        itemStyle: {
-          borderColor: '#fff',
-          borderWidth: 1,
-          shadowBlur: 10,
-          shadowColor: 'rgba(0, 0, 0, 0.3)'
-        }
-      }]
-    };
-    
-    // 设置图表选项
-    chartInstance.setOption(option);
-    
-    // 添加点击事件处理(可选)
-    chartInstance.on('click', (params) => {
-      if (params.dataType === 'node') {
-        console.log('点击了节点:', params.data);
-        // 可以在这里触发自定义事件
-      }
-    });
-    
-    console.log('MindChart: 图表初始化成功');
-  } catch (error) {
-    console.error('MindChart: 图表初始化失败', error);
-  }
-};
-
-// 响应窗口大小变化
-const handleResize = () => {
-  if (chartInstance) {
-    try {
-      chartInstance.resize();
-    } catch (error) {
-      console.error('MindChart: 调整大小失败', error);
-    }
-  }
-};
-
-// 监听 props 变化
-watch(() => [props.nodes, props.links, props.theme], () => {
-  nextTick(() => {
-    initChart();
-  });
-}, { deep: true });
-
-// 生命周期钩子
-onMounted(() => {
-  // 延迟初始化,确保DOM完全渲染
-  setTimeout(() => {
-    initChart();
-  }, 100);
-  
-  window.addEventListener('resize', handleResize);
-});
-
-onUnmounted(() => {
-  if (chartInstance) {
-    try {
-      chartInstance.dispose();
-    } catch (error) {
-      console.error('MindChart: 销毁图表失败', error);
-    }
-    chartInstance = null;
-  }
-  window.removeEventListener('resize', handleResize);
-});
-</script>
-
-<style scoped>
-.mind-map-container {
-  width: 100%;
-  height: 600px;
-  border: 1px solid #eaeaea;
-  border-radius: 8px;
-  overflow: hidden;
-  position: relative;
-}
-
-.mind-map-chart {
-  width: 100%;
-  height: 100%;
-  min-height: 400px;
-}
-</style>

+ 14 - 41
src/views/count/churn/behavior/components/PieChart.vue

@@ -18,10 +18,9 @@
           </tr>
         </thead>
         <tbody class="legend-list">
-          <tr v-for="(item, index) in displayData" :key="index" class="legend-item"
-            @click="handleLegendClick(item, index)">
+          <tr v-for="(item, index) in data" :key="index" class="legend-item">
             <td class="item">
-              <div class="legend-number" :style="{ backgroundColor: item.color }">{{ getLegendNumber(index) }}</div>
+              <div class="legend-number" :style="{ backgroundColor: colorList[index % colorList.length] }">{{ index+1 }}</div>
               {{ item.name }}
             </td>
             <td class="item">{{ item.value }}({{ item.percentage }})</td>
@@ -33,7 +32,7 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, computed, onMounted, watch } from 'vue'
+import { computed } from 'vue'
 import { use } from 'echarts/core'
 import { CanvasRenderer } from 'echarts/renderers'
 import { PieChart } from 'echarts/charts'
@@ -56,7 +55,6 @@ interface LegendItem {
   name: string
   value: number
   percentage: string
-  color: string
 }
 
 const props = withDefaults(defineProps<{
@@ -65,32 +63,21 @@ const props = withDefaults(defineProps<{
   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 colorList = [
+  'rgba(22, 122, 240, 1)',
+  'rgba(47, 168, 255, 1)',
+  'rgba(189, 112, 224, 1)',
+  'rgba(23, 84, 166, 1)',
+  'rgba(49, 198, 204, 1)',
+  'rgba(252, 158, 33, 1)',
+  '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)
+  return props.data.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: {
@@ -137,11 +124,11 @@ const chartOption = computed(() => {
         labelLine: {
           show: false
         },
-        data: displayData.value.map((item, index) => ({
+        data: props.data.map((item, index) => ({
           value: item.value,
           name: item.name,
           itemStyle: {
-            color: item.color
+            color: colorList[index % colorList.length]
           }
         }))
       }
@@ -149,19 +136,6 @@ const chartOption = computed(() => {
   }
 })
 
-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">
@@ -254,7 +228,6 @@ watch(displayData, (newData) => {
       .legend-item {
         font-size: 14px;
         line-height: 20px;
-        cursor: pointer;
 
         &:last-child {
           margin-bottom: 0;

+ 63 - 18
src/views/count/churn/behavior/index.vue

@@ -4,9 +4,6 @@
       <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
         <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
           <LayoutHeader title="卸载洞察" :style="{ marginBottom: '0' }">
-            <template #aside>
-              <div class="data-source-status">数据源状态: Demo数据</div>
-            </template>
             <template #tooltip-content>
               卸载洞察展示您每周卸载设备的活跃特征,如从安装到卸载的生命周期时长分布、卸载前活跃情况、末次活跃至卸载行为的时间差分布、卸载设备终端特征。
             </template>
@@ -30,10 +27,10 @@
                       <div class="description" style="margin-bottom: 25px;">
                         <p>安装至卸载存量时长分布</p>
                         <p>存量时长=最近卸载日期-最近卸载前的安装日期</p>
-                        <p>用户卸载集中在安装App后:<span>90天以上</span></p>
+                        <p>用户卸载集中在安装App后:<span>{{ maxValue.name }}</span></p>
                       </div>
                       <div class="content-item">
-                        <PieChart />
+                        <PieChart :data="newData" :total="acount" />
                       </div>
                     </div>
                   </el-col>
@@ -42,7 +39,7 @@
                       <div class="description">
                         <p>历史卸载次数分布</p>
                         <p>当前周的卸载设备,按当前是第几次卸载App进行分布</p>
-                        <p>历史上<span>96.5%</span>卸载设备会反复卸载。</p>
+                        <p>历史上<span>{{ historyUninstallPercentage }}%</span>卸载设备会反复卸载。</p>
                       </div>
                       <div class="content-item">
                         <BarChart title="历史卸载次数" :data="historyUninstallData" />
@@ -64,9 +61,9 @@
         </div>
         <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
           <el-card shadow="none">
-            <BeforeUninstallStatus v-show="activeTab === 'before'" />
-            <AfterUninstallStatus v-show="activeTab === 'after'" />
-            <SystemDistribution v-show="activeTab === 'system'" />
+            <BeforeUninstallStatus v-if="activeTab === 'before'" />
+            <AfterUninstallStatus v-if="activeTab === 'after'" />
+            <SystemDistribution v-if="activeTab === 'system'" />
           </el-card>
         </el-col>
       </el-row>
@@ -81,18 +78,66 @@ import AfterUninstallStatus from './components/AfterUninstallStatus.vue'
 import SystemDistribution from './components/SystemDistribution.vue'
 import PieChart from './components/PieChart.vue'
 import LayoutHeader from '/@/views/count/components/LayoutHeader.vue'
+import { uninstallActive } from '/@/api/count/churn'
 
-const historyUninstallData = [
-  { name: '1次', value: 45, percentage: '45.0%' },
-  { name: '2次', value: 23, percentage: '23.0%' },
-  { name: '3次', value: 15, percentage: '15.0%' },
-  { name: '4次', value: 8, percentage: '8.0%' },
-  { name: '5次', value: 100, percentage: '6.0%' },
-  { name: '6次以上', value: 3, percentage: '3.0%' }
-]
+interface LegendItem {
+  name: string
+  value: number
+  percentage: string
+}
 
-const activeTab = ref('before');
+interface BarItem {
+  name: string
+  value: number
+  percentage: string
+}
 
+const activeTab = ref('before');
+const acount = ref(0);
+const newData = ref<LegendItem[]>([]);
+const maxValue = ref<LegendItem>({ name: '', value: 0, percentage: '' });
+const historyUninstallData = ref<BarItem[]>([]);
+const historyUninstallPercentage = ref(0);
+
+onMounted(() => {
+  uninstallActive({
+    cycle: 'week',
+    type: '4',
+  }).then((res) => {
+    console.log(res);
+    historyUninstallData.value = [];
+    newData.value = [];
+    maxValue.value = { name: '', value: 0, percentage: '' };
+    acount.value = 0;
+    historyUninstallPercentage.value = 0;
+
+    res?.data?.lifecycleDistribution.forEach((item: any) => {
+      acount.value += item.uninstallCount;
+      if (item.uninstallCount > maxValue.value.value) {
+        maxValue.value = {
+          name: item.time,
+          value: item.uninstallCount,
+          percentage: `${item.uninstallRate}%`
+        };
+      }
+      newData.value.push({
+        name: item.time,
+        value: item.uninstallCount,
+        percentage: `${item.uninstallRate}%`
+      })
+    })
+
+    res?.data?.uninstallDistribution.forEach((item: any) => {
+      historyUninstallPercentage.value += item.rate;
+      historyUninstallData.value.push({
+        name: item.count,
+        value: item.num,
+        percentage: `${item.rate}%`
+      })
+    })
+    historyUninstallData.value.reverse();
+  })
+})
 </script>
 <style scoped lang="scss">
 @import '/@/views/count/styles/common.scss';

+ 1 - 1
src/views/count/churn/overview/ProgressCard.vue

@@ -14,7 +14,7 @@
       <div class="content-item-right">
         <div class="content-item-title">{{ title }}</div>
         <div class="content-item-value">{{ counts }}</div>
-        <div class="content-item-percent">环比<span :style="{ color: color }">{{ rates }}%</span><svg 
+        <div class="content-item-percent">环比&nbsp;&nbsp;<span :style="{ color: color }">{{ rates>0?'+':'-' }}{{ rates }}%</span><svg 
           v-if="rates <= 0" 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">

+ 65 - 72
src/views/count/churn/overview/index.vue

@@ -4,8 +4,13 @@
       <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
         <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
           <LayoutHeader title="流失概况">
-            <template #aside>
-              <div class="data-source-status">数据源状态: Demo数据</div>
+            <template #tooltip-content>
+              流失概况展示您应用每周的卸载用户量和召回用户量,并展示趋势。
+              <br /><span>卸载设备数:</span>当前时段内,有过卸载行为的设备去重数。
+              若某设备在当前时段内卸载又重新安装、或多次卸载再安装,都算它为有过卸载行为的1个卸载设备。 
+              <br /><span>卸载召回设备:</span>曾经被识别为卸载,当前时段内又被识别为重新安装的设备去重数。
+              仅统计卸载后90天内重新安装的设备,卸载90天后重新安装的设备不算在卸载召回设备中。
+              产品页面中,会在每周四刷新上周的卸载召回数据。
             </template>
             <template #content>
               <div class="top-content">
@@ -42,20 +47,24 @@
                   </svg>
                 </div>
 
-                <el-table class="statistics-table" :data="state.dataList" row-key="date" style="width: 100%"
+                <el-table class="statistics-table" :data="paginatedData" row-key="date" style="width: 100%"
                   :cell-style="cellStyle"
                   :header-cell-style="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-column :label="'日期'" prop="date" show-overflow-tooltip>
+                    <template #default="scope">
+                      {{ scope.row.startDate }} - {{ scope.row.endDate }}
+                    </template>
+                  </el-table-column>
+                  <el-table-column :label="'卸载流失设备'" :formatter="statusFormatter" prop="uninstallCounts" show-overflow-tooltip></el-table-column>
+                  <el-table-column :label="'卸载召回设备'" :formatter="statusFormatter" prop="recallCounts" show-overflow-tooltip></el-table-column>
                 </el-table>
 
                 <pagination 
                   v-if="!hideTable"
-                  @current-change="currentChangeHandle" 
-                  @size-change="sizeChangeHandle" 
+                  @current-change="handleCurrentChange" 
+                  @size-change="handleSizeChange" 
                   v-bind="state.pagination">
                 </pagination>
               </div>
@@ -71,7 +80,7 @@
 import LineChart from '/@/views/count/components/LineChart.vue'
 import { BasicTableProps, useTable } from '/@/hooks/table';
 import { ref, computed, reactive } from 'vue'
-import { uninstallTrend } from '/@/api/count/churn'
+import { uninstallTrend, uninstallTrendDetail } from '/@/api/count/churn'
 import { formatDate } from '/@/utils/formatTime';
 import ProgressCard from './ProgressCard.vue'
 import LayoutHeader from '/@/views/count/components/LayoutHeader.vue'
@@ -99,47 +108,14 @@ const trendProgressData = ref({
   uninstallRates: 0,
 })
 
-// 流失趋势数据
-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
+  return dataList.value.map((item: any) => ({
+    date: item.endDate,
+    value: activeTab.value === 'churnTrend' ? item.uninstallCounts : item.recallCounts
+  }))
 })
 
 // 计算当前图表标题
@@ -155,51 +131,68 @@ const handleTabClick = (tab: string) => {
 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 dataList = ref([]);
+
 const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
   return cellValue || '--';
 }
 
-onMounted(() => {
+const handleCurrentChange = (val: number) => {
+  state.pagination!.current = val;
+};
+
+const handleSizeChange = (val: number) => {
+  state.pagination!.size = val;
+  state.pagination!.current = 1; // 切换每页条数时重置到第一页
+};
+
+// 计算分页后的数据
+const paginatedData = computed(() => {
+  if (!state.pagination || typeof state.pagination.current === 'undefined' || typeof state.pagination.size === 'undefined') {
+    return dataList.value;
+  }
+  const start = (state.pagination.current - 1) * state.pagination.size;
+  const end = start + state.pagination.size;
+  return dataList.value.slice(start, end);
+});
+
+const getUninstallTrend = () => {
   uninstallTrend({
     startDate: formatDate(new Date(new Date().setDate(new Date().getDate() - 7)), 'YYYY-mm-dd'),
     endDate: formatDate(new Date(), 'YYYY-mm-dd'),
     timeUnit: 'day',
+    pageNum: state.pagination!.current,
+    pageSize: state.pagination!.size,
   }).then(res => {
     trendProgressData.value = res.data
   })
+}
+
+const getUninstallTrendDetail = () => {
+  uninstallTrendDetail({
+    startDate: formatDate(new Date(new Date().setDate(new Date().getDate() - 50)), 'YYYY-mm-dd'),
+    endDate: formatDate(new Date(), 'YYYY-mm-dd'),
+    timeUnit: 'week',
+    pageNum: state.pagination!.current,
+    pageSize: state.pagination!.size,
+  }).then(res => {
+    dataList.value = res?.data?.records || [];
+    state.pagination!.total = res?.data?.total || 0;
+  })
+}
+
+onMounted(() => {
+  getUninstallTrend();
+  getUninstallTrendDetail();
 })
 
 </script>

+ 17 - 14
src/views/count/components/BarChart.vue

@@ -71,6 +71,14 @@ const xAxisData = computed(() => {
   return displayData.value.map(item => item.name)
 })
 
+const colors = [
+  'rgba(109, 173, 249, 1)',
+  'rgba(255, 107, 107, 1)',
+  'rgba(255, 193, 7, 1)',
+  'rgba(40, 167, 69, 1)',
+  'rgba(220, 53, 69, 1)'
+]
+
 // 生成系列数据
 const seriesData = computed(() => {
   if (!props.isMultiSeries) {
@@ -112,19 +120,12 @@ const seriesData = computed(() => {
           }
         }
       },
-      barWidth: '54px'
+      barWidth: displayData.value.length > 8 ? 300 / displayData.value.length + 'px' : '54px'
     }]
   } else {
     // 多系列数据
     const multiData = displayData.value as MultiBarItem[]
-    const colors = [
-      'rgba(109, 173, 249, 1)',
-      'rgba(255, 107, 107, 1)',
-      'rgba(255, 193, 7, 1)',
-      'rgba(40, 167, 69, 1)',
-      'rgba(220, 53, 69, 1)'
-    ]
-
+    
     return multiData[0].values.map((_, index) => ({
       name: props.seriesNames[index] || `系列${index + 1}`,
       type: 'bar',
@@ -194,14 +195,16 @@ const chartOption = computed(() => {
       formatter: function (params: any) {
         if (!props.isMultiSeries) {
           return params.map((item: any) => {
-            return `<i style="display: inline-block; width: 10px; height: 10px; background-color:rgba(22, 122, 240, 1); border-radius: 50%;"></i>
-            ${item.name}: <span style="color:rgba(22, 122, 240, 1);">${item.value}</span>`
+            return `<div style="text-align: left;"><div style="line-height: 2;">${item.name}</div>
+            <i style="display: inline-block; width: 10px; height: 10px; background-color: rgba(109, 173, 249, 1); border-radius: 50%;"></i>
+            ${props.title} : <span style="color: rgba(109, 173, 249, 1);">${item.value}</span>${item.data.percentage ? ` (${item.data.percentage})` : ''}</div>`
           }).join('\n')
         } else {
-          let result = `<div style="text-align: left;">${params[0].name}<br/>`
-          params.forEach((param: any) => {
+          let result = `<div style="text-align: left;"><div style="line-height: 2;">${params[0].name}</div>`
+          params.forEach((param: any, index: number) => {
             const percentageText = param.data.percentage ? ` (${param.data.percentage})` : ''
-            result += `${param.seriesName}:${param.value}${percentageText}<br/>`
+            result += `<i style="display: inline-block; width: 10px; height: 10px; background-color: ${colors[index % colors.length]}; border-radius: 50%; margin-right: 5px;"></i>
+            ${param.seriesName}<span style="color: rgba(109, 173, 249, 1);">:${param.value}</span>${percentageText}<br/>`
           })
           return result + '</div>'
         }

+ 19 - 3
src/views/count/components/LayoutHeader.vue

@@ -1,7 +1,7 @@
 <template>
   <el-card shadow="none" :style="computedCardStyle">
     <div class="top-info" :style="style">
-      <div class="title">{{ title }}<slot v-if="tooltip" name="tooltip">
+      <div class="title">{{ title }}<slot v-if="showTooltip" name="tooltip">
           <el-tooltip class="box-item" effect="light"
             content="" placement="right-start">
             <svg style="margin: 0 0 0 8px; vertical-align: baseline;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -13,7 +13,7 @@
                 fill="white" />
             </svg>
             <template #content>
-              <div style="width: 300px;">
+              <div class="tooltip-content-wrapper" style="width: 300px;">
                 <slot name="tooltip-content"></slot>
               </div>
             </template>
@@ -28,7 +28,7 @@
   </el-card>
 </template>
 <script lang="ts" setup>
-import { computed } from 'vue'
+import { computed, useSlots } from 'vue'
 
 const props = defineProps({
   title: {
@@ -49,9 +49,16 @@ const props = defineProps({
   }
 })
 
+const slots = useSlots()
+
 const computedCardStyle = computed(() => {
   return Object.keys(props.cardStyle).length > 0 ? props.cardStyle : { padding: '10px 14px' }
 })
+
+// 判断是否显示tooltip:当tooltip属性有值或者有tooltip-content插槽内容时显示
+const showTooltip = computed(() => {
+  return props.tooltip || (slots['tooltip-content'] && slots['tooltip-content']())
+})
 </script>
 <style lang="scss" scoped>
 
@@ -76,4 +83,13 @@ const computedCardStyle = computed(() => {
   }
 }
 
+</style>
+
+<style lang="scss">
+// 全局样式,不使用 scoped
+.tooltip-content-wrapper {
+  span {
+    color: rgba(22, 122, 240, 1) !important;
+  }
+}
 </style>

+ 4 - 2
src/views/count/components/LineChart.vue

@@ -176,7 +176,8 @@ const updateChart = () => {
           
           params.forEach((param: any) => {
             html += `<div style="color: ${param.color}; font-size: 14px; font-weight: 500; margin: 2px 0;">
-              ${param.seriesName}: ${param.value}
+              <i style="width: 10px; height: 10px; background: ${param.color}; border-radius: 50%; display: inline-block; margin-right: 4px;"></i>
+              <span style="color: rgba(100, 100, 100, 1); ">${param.seriesName}:</span> ${param.value}
             </div>`
           })
           
@@ -187,7 +188,8 @@ const updateChart = () => {
           return `<div style="padding: 8px; text-align: left;">
             <div style="font-weight: 900; margin-bottom: 4px; font-size: 14px;">${data.name}</div>
             <div style="color: ${props.color}; font-size: 14px; font-weight: 500;">
-              ${data.seriesName}: ${data.value}
+              <i style="width: 10px; height: 10px; background: ${props.color}; border-radius: 50%; display: inline-block; margin-right: 4px;"></i>
+              <span style="color: rgba(100, 100, 100, 1); ">${data.seriesName}:</span> ${data.value}
             </div>
           </div>`
         }

+ 1 - 1
src/views/count/components/ProgressRing.vue

@@ -8,10 +8,10 @@
       fill="none" 
       :stroke="backgroundColor" 
       :stroke-width="strokeWidth"
-      :stroke-linecap="softCorner ? 'round' : 'butt'"
     />
     <!-- 进度圆环 -->
     <circle 
+      v-if="progress > 0"
       :cx="center" 
       :cy="center" 
       :r="radius" 

+ 1 - 0
src/views/count/styles/common.scss

@@ -63,6 +63,7 @@
   margin-bottom: 23px;
   font-weight: 500;
   font-family: Source Han Sans SC;
+  color: rgba(18, 18, 18, 1);
 
   svg {
     // line-height: 19px;

+ 1 - 1
src/views/marketing/statistics/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="layout-padding" style="width: 100%;">
+  <div class="layout-padding">
     <div class="layout-padding-auto layout-padding-view">
       <el-row class="ml10" v-show="showSearch">
         <el-form :inline="true" :model="state.queryForm" @keyup.enter="withCollapsedChildren(getDataList)" ref="queryRef">