浏览代码

feat:流失卸载

cmy 1 周之前
父节点
当前提交
957f633324

+ 234 - 0
src/views/churn/behavior/components/AfterUninstallStatus.vue

@@ -0,0 +1,234 @@
+<template>
+  <div class="after-situation">
+    <div class="card-title">
+      卸载用户竞品流向
+      <el-tooltip class="box-item" effect="light"
+        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
+            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>
+      </el-tooltip>
+    </div>
+    <div class="content">
+      <div class="connection-container">
+        <!-- SVG曲线连接 -->
+        <svg class="connection-lines" width="100%" height="100%" style="position: absolute; top: 0; left: 0; pointer-events: none; z-index: 1;">
+          <path 
+            v-for="(item, index) in subItems" 
+            :key="index"
+            :d="generateConnectionPath(index)"
+            stroke="#167AF0" 
+            stroke-width="2" 
+            fill="none" 
+            opacity="0.6"
+          />
+        </svg>
+        
+        <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="ring-chart">
+              <ProgressRing 
+                :progress="0.7223"
+                :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>
+          </div>
+          <div class="sub">
+            <div 
+              v-for="(item, index) in subItems" 
+              :key="index"
+              class="sub-item"
+            >
+              <div class="inner" :style="{
+                width: `${item.percentage}`,
+              }"></div>
+              <div class="sub-item-name">{{ item.name }}</div>
+              <div class="sub-item-value">{{ item.value }}</div>
+              <div class="sub-item-percentage">{{ item.percentage }}</div>
+            </div>
+          </div>
+        </el-row>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import ProgressRing from '../../echarts/ProgressRing.vue'
+
+// 定义sub-item的数据结构
+interface SubItem {
+  name: string
+  value: string | number
+  percentage: string
+}
+
+// 定义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 generateConnectionPath = (index: number) => {
+  const mainX = 275
+  const mainY = 90  // main卡片中心位置 (25 + 130/2)
+  const subStartX = 495  // sub-item的左边位置
+  const subItemHeight = 50
+  const subItemMargin = 24
+  const subY = 25 + index * (subItemHeight + subItemMargin) // sub-item中心位置
+  
+  // 计算中间点(一半距离)
+  const midX = (mainX + subStartX) / 2  // 390
+  
+  // 控制点1:先往上扬起,第一条曲线到一半时和main齐平
+  const control1X = 340
+  const control1Y = mainY - 30  // 扬起高度,确保第一条曲线到一半时和main齐平
+  
+  // 控制点2:平滑过渡到目标位置,确保曲线平滑地连接到sub-item左边
+  const control2X = 450
+  const control2Y = subY
+  
+  return `M ${mainX} ${mainY} C ${control1X} ${control1Y} ${control2X} ${control2Y} ${subStartX} ${subY}`
+}
+</script>
+
+<style scoped lang="scss">
+@import '../styles/common.scss';
+
+.after-situation {
+  .card-title {
+    vertical-align: middle;
+  }
+
+  .content {
+    overflow-x: auto;
+    padding: 10px;
+  }
+  
+  .connection-container {
+    position: relative;
+    width: 895px;
+    margin: 0 auto;
+  }
+  
+  .main {
+    width: 285px;
+    height: 130px;
+    border-radius: 15px;
+    border: 1px solid rgba(22, 122, 240, 1);
+    margin-right: 208px;
+    background: #fff;
+    padding: 16px;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    box-sizing: border-box;
+    margin-top: 25px;
+    position: relative;
+    
+    .p1 {
+      color: rgba(0, 0, 0, 1);
+      font-weight: 500;
+      font-size: 16px;
+      margin-bottom: 16px;
+      line-height: 19px;
+    }
+    
+    .p2, .p3 {
+        color: rgba(100, 100, 100, 1);
+        font-weight: 400;
+        font-size: 14px;
+        margin-bottom: 12px;
+        line-height: 17px;
+    }
+
+    .p3 {
+      margin-bottom: 0;
+    }
+    
+    .ring-chart {
+      position: absolute;
+      top: 16px;
+      right: 16px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      
+      .ring-text {
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50%, -50%);
+        color: rgba(0, 0, 0, 1);
+        font-size: 14px;
+      }
+    }
+  }
+
+  .sub {
+    .sub-item {
+      box-sizing: border-box;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      width: 400px;
+      height: 50px;
+      border-radius: 15px;
+      border: 1px solid rgba(22, 122, 240, 1);
+      background: #fff;
+      margin-bottom: 24px;
+      padding: 0 20px;
+      color: rgba(0, 0, 0, 1);
+      font-size: 14px;
+      overflow: hidden;
+      position: relative;
+
+      .inner {
+        width: 100%;
+        background: rgba(22, 122, 240, 0.1);
+        position: absolute;
+        height: 100%;
+        left: 0;
+        top: 0;
+      }
+      
+      &:last-child {
+        margin-bottom: 0;
+      }
+      
+      .sub-item-name {
+        flex: 1;
+      }
+      
+      .sub-item-value {
+        // margin-right: 16px;
+        text-align: right;
+      }
+      
+      .sub-item-percentage {
+        text-align: right;
+        width: 66px;
+      }
+    }
+  }
+}
+</style> 

+ 113 - 0
src/views/churn/behavior/components/BeforeUninstallStatus.vue

@@ -0,0 +1,113 @@
+<template>
+  <div class="before-situation">
+    <div class="card-title">卸载前使用粘性</div>
+    <div class="content">
+      <el-row :gutter="12" style="padding: 0; row-gap: 12px; margin-bottom: 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" style="margin-bottom: 30px;">
+              <p>卸载前最后使用App至卸载时间差</p>
+              <p>最后使用App至卸载时间差=最终卸载App日期-末次启动App日期</p>
+              <p>卸载前已经连续失活7天以上的用户占比:<span>100%</span></p>
+            </div>
+            <div class="content-item">
+              <HorizontalBarChart :data="chartData" :title="['时长分布', '占比']" />
+            </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>卸载设备前7天使用次数分布</p>
+              <p>卸载App前7天(含当日)启动App的次数</p>
+              <p>设备卸载前仍具备高粘性占比:<span>96.5%</span></p>
+            </div>
+            <div class="content-item">
+              <BarChart :title="'历史卸载次数'" :data="usageCountData" />
+            </div>
+          </div>
+        </el-col>
+      </el-row>
+      <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" style="margin-bottom: 24px;">
+              <p>卸载前体验干扰</p>
+            </div>
+            <div class="card-tabs">
+              <div class="card-tab" :class="{ active: last7DaysTab === 'unloadExperience' }" @click="last7DaysTab = 'unloadExperience'">前7天崩溃次数</div>
+              <div class="card-tab" :class="{ active: last7DaysTab === 'pushReceiptLast7Days' }" @click="last7DaysTab = 'pushReceiptLast7Days'">前7天推送接收</div>
+            </div>
+            <div class="content-item">
+              <BarChart :title="'卸载设备数'" :data="experienceData" />
+            </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" style="margin-bottom: 24px;">
+              <p>卸载前行为还原</p>
+            </div>
+            <div class="card-tabs">
+              <div class="card-tab" :class="{ active: viewPagesTab === 'highFrequencyPages' }" @click="viewPagesTab = 'highFrequencyPages'">高频浏览页面TOP10</div>
+              <div class="card-tab" :class="{ active: viewPagesTab === 'finalViewPages' }" @click="viewPagesTab = 'finalViewPages'">最终浏览页面TOP10</div>
+            </div>
+            <div class="content-item">
+              <HorizontalBarChart :data="chartData2" :title="['页面名称', '触发次数']" />
+            </div>
+          </div>
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import BarChart from '../echarts/BarChart.vue'
+import HorizontalBarChart from '../echarts/HorizontalBarChart.vue'
+
+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%' }
+]
+
+const experienceData = [
+  { name: '0次', value: 45, percentage: '45.0%' },
+  { name: '1次', value: 23, percentage: '23.0%' },
+  { name: '2次', value: 15, percentage: '15.0%' },
+  { name: '3次', value: 8, percentage: '8.0%' },
+  { name: '4次', value: 6, percentage: '6.0%' },
+  { name: '5次以上', value: 3, percentage: '3.0%' }
+]
+
+const chartData2 = [
+  { name: '首页首页首页首页', value: 45},
+  { name: '首页首页首页', value: 25},
+  { name: '首页首页首页', value: 43},
+  { name: '首页首页首页', value: 46},
+  { name: '首页首页首页', value: 45},
+  { name: '首页首页首页', value: 12},
+  { name: '首页首页首页', value: 76}
+]
+
+const last7DaysTab = ref('unloadExperience');
+const viewPagesTab = ref('highFrequencyPages');
+</script>
+
+<style scoped lang="scss">
+@import '../styles/common.scss';
+</style> 

+ 51 - 0
src/views/churn/behavior/components/SystemDistribution.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="system-situation">
+    <div class="card-title">
+      卸载设备维度分布TOP10
+    </div>
+    <div class="content">
+      <el-row :gutter="12" style="padding: 0; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="12" :lg="8" :xl="8">
+          <SystemItem :title="'来源渠道'" :icon="'1'" :list="getList()" />
+        </el-col>
+        <el-col :xs="24" :sm="24" :md="12" :lg="8" :xl="8">
+          <SystemItem :title="'末次使用机型'" :icon="'2'" :list="getList()" />
+        </el-col>
+        <el-col :xs="24" :sm="24" :md="12" :lg="8" :xl="8">
+          <SystemItem :title="'末次使用应用版本'" :icon="'3'" :list="getList()" />
+        </el-col>
+        <el-col :xs="24" :sm="24" :md="12" :lg="8" :xl="8">
+          <SystemItem :title="'末次操作系统版本'" :icon="'4'" :list="getList()" />
+        </el-col>
+        <el-col :xs="24" :sm="24" :md="12" :lg="8" :xl="8">
+          <SystemItem :title="'末次活跃城市'" :icon="'5'" :list="getList()" />
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+import SystemItem from './SystemItem.vue'
+
+const getList = () => {
+  const length = Math.floor(Math.random() * 10) + 1;
+  const list = [];
+  for (let i = 0; i < length; i++) {
+    list.push({ name: `渠道${i + 1}`, value: Math.floor(Math.random() * 100), percentage: `${Math.floor(Math.random() * 100)}%` })
+  }
+  return list;
+}
+
+</script>
+
+<style scoped lang="scss">
+@import '../styles/common.scss';
+
+.system-situation {
+  .card-title {
+    vertical-align: middle;
+  }
+}
+</style>

+ 169 - 0
src/views/churn/behavior/components/SystemItem.vue

@@ -0,0 +1,169 @@
+<template>
+  <el-card shadow="none">
+    <div class="system-item">
+      <div class="title">
+        <svg v-if="icon === '1'" width="24" height="24" viewBox="0 0 24 24" fill="none"
+          xmlns="http://www.w3.org/2000/svg">
+          <path
+            d="M12.0002 5.8095C13.0467 5.8095 13.895 4.95673 13.895 3.90476C13.895 2.85279 13.0467 2 12.0002 2C10.9538 2 10.1055 2.85279 10.1055 3.90476C10.1055 4.95673 10.9538 5.8095 12.0002 5.8095Z"
+            stroke="#167AF0" stroke-width="1.5" stroke-linejoin="round" />
+          <path
+            d="M4.89473 20.0957C5.94115 20.0957 6.78945 19.2429 6.78945 18.1909C6.78945 17.1389 5.94115 16.2861 4.89473 16.2861C3.8483 16.2861 3 17.1389 3 18.1909C3 19.2429 3.8483 20.0957 4.89473 20.0957Z"
+            stroke="#167AF0" stroke-width="1.5" stroke-linejoin="round" />
+          <path
+            d="M19.1052 20.0957C20.1516 20.0957 20.9999 19.2429 20.9999 18.1909C20.9999 17.1389 20.1516 16.2861 19.1052 16.2861C18.0587 16.2861 17.2104 17.1389 17.2104 18.1909C17.2104 19.2429 18.0587 20.0957 19.1052 20.0957Z"
+            stroke="#167AF0" stroke-width="1.5" stroke-linejoin="round" />
+          <path
+            d="M16.5713 5.15723C19.222 6.73158 20.9999 9.63333 20.9999 12.9525C20.9999 13.2409 20.9865 13.5262 20.9603 13.8077"
+            stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+          <path
+            d="M15.507 21.2871C14.4293 21.7459 13.2442 21.9997 12.0001 21.9997C10.756 21.9997 9.57091 21.7459 8.49316 21.2871"
+            stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+          <path d="M3.03968 13.8077C3.01343 13.5262 3 13.2409 3 12.9525C3 9.63333 4.77795 6.73158 7.42865 5.15723"
+            stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+        </svg>
+        <svg v-if="icon === '2'" width="24" height="24" viewBox="0 0 24 24" fill="none"
+          xmlns="http://www.w3.org/2000/svg">
+          <path
+            d="M17 2H7C6.17157 2 5.5 2.67157 5.5 3.5V20.5C5.5 21.3284 6.17157 22 7 22H17C17.8284 22 18.5 21.3284 18.5 20.5V3.5C18.5 2.67157 17.8284 2 17 2Z"
+            stroke="#333333" stroke-width="1.5" />
+          <path d="M11 5H13" stroke="#167AF0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+          <path d="M10 19H14" stroke="#167AF0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+        </svg>
+        <svg v-if="icon === '3'" width="24" height="24" viewBox="0 0 24 24" fill="none"
+          xmlns="http://www.w3.org/2000/svg">
+          <rect x="3.25" y="6.25" width="14.5" height="14.5" rx="1.25" stroke="#333333" stroke-width="1.5" />
+          <rect x="6.25" y="3.25" width="14.5" height="14.5" rx="1.25" fill="white" stroke="#333333"
+            stroke-width="1.5" />
+          <path
+            d="M12.438 15.5002L9.5 5.86719H11.528L12.789 10.5602C13.088 11.6262 13.296 12.5882 13.595 13.6672H13.66C13.972 12.5882 14.18 11.6262 14.479 10.5602L15.727 5.86719H17.677L14.739 15.5002H12.438Z"
+            fill="#167AF0" />
+        </svg>
+        <svg v-if="icon === '4'" width="24" height="24" viewBox="0 0 24 24" fill="none"
+          xmlns="http://www.w3.org/2000/svg">
+          <path
+            d="M7 3.75C8.79493 3.75 10.25 5.20507 10.25 7V10.25H7C5.20507 10.25 3.75 8.79493 3.75 7C3.75 5.20507 5.20507 3.75 7 3.75Z"
+            stroke="#167AF0" stroke-width="1.5" />
+          <path
+            d="M7 13.75H10.25V17C10.25 18.7949 8.79493 20.25 7 20.25C5.20507 20.25 3.75 18.7949 3.75 17C3.75 15.2051 5.20507 13.75 7 13.75Z"
+            stroke="#333333" stroke-width="1.5" />
+          <path
+            d="M17 3.75C18.7949 3.75 20.25 5.20507 20.25 7C20.25 8.79493 18.7949 10.25 17 10.25H13.75V7C13.75 5.20507 15.2051 3.75 17 3.75Z"
+            stroke="#333333" stroke-width="1.5" />
+          <path
+            d="M17 13.75C18.7949 13.75 20.25 15.2051 20.25 17C20.25 18.7949 18.7949 20.25 17 20.25C15.2051 20.25 13.75 18.7949 13.75 17V13.75H17Z"
+            stroke="#167AF0" stroke-width="1.5" />
+        </svg>
+        <svg v-if="icon === '5'" width="24" height="24" viewBox="0 0 24 24" fill="none"
+          xmlns="http://www.w3.org/2000/svg">
+          <path d="M2 21H22" stroke="#333333" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+          <path
+            d="M7 13H5C4.44772 13 4 13.4477 4 14V20C4 20.5523 4.44772 21 5 21H7C7.55228 21 8 20.5523 8 20V14C8 13.4477 7.55228 13 7 13Z"
+            stroke="#333333" stroke-width="1.5" stroke-linejoin="round" />
+          <path d="M6 17.5V16.5" stroke="#167AF0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+          <path
+            d="M19 2H9C8.44772 2 8 2.44772 8 3V20C8 20.5523 8.44772 21 9 21H19C19.5523 21 20 20.5523 20 20V3C20 2.44772 19.5523 2 19 2Z"
+            stroke="#333333" stroke-width="1.5" stroke-linejoin="round" />
+          <path d="M12.25 5.75V6.25H11.75V5.75H12.25Z" fill="#333333" stroke="#167AF0" stroke-width="1.5" />
+          <path d="M16.25 5.75V6.25H15.75V5.75H16.25Z" fill="#333333" stroke="#167AF0" stroke-width="1.5" />
+          <path d="M12.25 9.25V9.75H11.75V9.25H12.25Z" fill="#333333" stroke="#167AF0" stroke-width="1.5" />
+          <path d="M16.25 9.25V9.75H15.75V9.25H16.25Z" fill="#333333" stroke="#167AF0" stroke-width="1.5" />
+          <path d="M16.25 12.75V13.25H15.75V12.75H16.25Z" fill="#333333" stroke="#167AF0" stroke-width="1.5" />
+          <path d="M16.25 16.25V16.75H15.75V16.25H16.25Z" fill="#333333" stroke="#167AF0" stroke-width="1.5" />
+        </svg>
+
+
+        <span>{{ title }}</span>
+      </div>
+      <div class="content">
+        <div v-for="(item, index) in list" :key="index" class="item">
+          <div class="inner" :style="{
+            width: `${item.percentage}`,
+          }"></div>
+          <div class="name">{{ item.name }}</div>
+          <div class="value">{{ item.value }}&nbsp;&nbsp;-&nbsp;&nbsp;{{ item.percentage }}</div>
+        </div>
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script lang="ts" setup>
+
+interface Item {
+  name: string
+  value: string | number
+  percentage: string
+}
+
+const props = defineProps<{
+  title: string
+  icon: string
+  list: Item[]
+}>()
+</script>
+
+<style scoped lang="scss">
+.system-item {
+  padding: 10px 14px;
+
+  .title {
+    vertical-align: middle;
+    font-size: 16px;
+    color: rgba(18, 18, 18, 1);
+    line-height: 24px;
+    margin-bottom: 20px;
+
+    span {
+      vertical-align: middle;
+      margin-left: 8px;
+      line-height: 24px;
+    }
+
+    svg {
+      vertical-align: middle;
+    }
+  }
+
+  .content {
+    height: 340px;
+    overflow-y: auto;
+    .item {
+      box-sizing: border-box;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      width: 100%;
+      height: 50px;
+      background: rgba(248, 251, 253, 1);
+      margin-bottom: 8px;
+      padding: 0 24px;
+      color: rgba(18, 18, 18, 1);
+      font-size: 14px;
+      overflow: hidden;
+      position: relative;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+
+      .inner {
+        height: 50px;
+        background: rgba(22, 122, 240, 0.1);
+        position: absolute;
+        height: 100%;
+        left: 0;
+        top: 0;
+      }
+
+      .name {
+        flex: 1;
+      }
+
+      .value {
+        text-align: right;
+      }
+    }
+  }
+}
+</style>

+ 8 - 7
src/views/churn/behavior/echarts/BarChart.vue

@@ -8,7 +8,7 @@
         style="height: 244px;"
       />
     </div>
-    <div class="title">历史卸载次数</div>
+    <div class="title">{{ title }}</div>
   </div>
 </template>
 
@@ -42,6 +42,7 @@ interface BarItem {
 
 const props = withDefaults(defineProps<{
   data?: BarItem[]
+  title?: string
 }>(), {
   data: () => []
 })
@@ -97,16 +98,16 @@ const chartOption = computed(() => {
         show: false
       },
       axisLabel: {
-        color: '#666',
-        fontSize: 12
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14
       }
     },
     yAxis: {
       type: 'value',
       name: '次数',
       nameTextStyle: {
-        color: '#666',
-        fontSize: 12
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14
       },
       axisLine: {
         show: false
@@ -115,8 +116,8 @@ const chartOption = computed(() => {
         show: false
       },
       axisLabel: {
-        color: '#666',
-        fontSize: 12
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 13
       },
       splitLine: {
         lineStyle: {

+ 323 - 0
src/views/churn/behavior/echarts/HorizontalBarChart.vue

@@ -0,0 +1,323 @@
+<template>
+  <div class="horizontal-bar-chart-container">
+    <div class="chart-wrapper">
+      <div class="title">
+        <div v-for="item in title" :key="item">
+          {{ item }}
+        </div>
+      </div>
+      <div class="chart-container">
+        <div class="chart-item" v-for="item in displayData" :key="item.name" @mouseenter="showTooltip($event, item)" @mouseleave="hideTooltip" @mousemove="updateTooltipPosition($event)">
+          <div class="chart-item-name" :title="item.name">{{ item.name }}</div>
+          <div class="chart-item-bar">
+            <div :style="{ width: `${item.percentage ? item.percentage : item.value / count * 100 + '%'}` }"></div>
+          </div>
+          <div class="chart-item-value">{{ item.percentage || item.value }}</div>
+        </div>
+      </div>
+    </div>
+    
+    <!-- 鼠标跟随浮窗 -->
+    <Transition name="tooltip-fade" mode="out-in">
+      <div v-if="tooltipVisible" 
+           class="tooltip" 
+           :class="{ 'tooltip-left': shouldShowOnLeft }"
+           :style="{ 
+             left: tooltipPosition.x + 'px', 
+             top: tooltipPosition.y + 'px',
+             opacity: tooltipOpacity
+           }"
+           v-html="tooltipContent">
+      </div>
+    </Transition>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, watch, ref, onUnmounted } from 'vue'
+
+interface BarItem {
+  name: string
+  value: number
+  percentage?: string
+}
+
+const props = withDefaults(defineProps<{
+  title: string[],
+  data?: BarItem[],
+}>(), {
+  data: () => []
+})
+
+// 默认数据
+const defaultData: BarItem[] = []
+const count = ref(0);
+
+// tooltip相关状态
+const tooltipVisible = ref(false)
+const tooltipContent = ref('')
+const tooltipPosition = ref({ x: 0, y: 0 })
+const tooltipOpacity = ref(0)
+const isHovering = ref(false)
+
+// 计算属性:判断tooltip是否应该显示在左边
+const shouldShowOnLeft = computed(() => {
+  return tooltipPosition.value.x < (window.innerWidth / 2)
+})
+
+// 防抖定时器
+let debounceTimer: number | null = null
+let fadeTimer: number | null = null
+
+// 防抖函数
+const debounce = (func: Function, delay: number) => {
+  if (debounceTimer) {
+    clearTimeout(debounceTimer)
+  }
+  debounceTimer = window.setTimeout(func, delay)
+}
+
+// tooltip方法
+const showTooltip = (event: MouseEvent, item: BarItem) => {
+  tooltipContent.value = `${props.title[0]}:${item.name}<br/>${props.title[1]}:${item.percentage || item.value}`
+  
+  // 设置悬停状态
+  isHovering.value = true
+  
+  // 清除之前的淡出定时器
+  if (fadeTimer) {
+    clearTimeout(fadeTimer)
+    fadeTimer = null
+  }
+  
+  // 立即显示,但透明度为0
+  tooltipVisible.value = true
+  tooltipOpacity.value = 0
+  
+  // 使用requestAnimationFrame确保DOM更新后再设置位置和透明度
+  requestAnimationFrame(() => {
+    updateTooltipPosition(event)
+    // 淡入效果
+    tooltipOpacity.value = 1
+  })
+}
+
+const updateTooltipPosition = (event: MouseEvent) => {
+  // 获取窗口宽度
+  const windowWidth = window.innerWidth
+  const tooltipWidth = 120 // 预估的tooltip宽度
+  const offset = 10
+  
+  // 计算tooltip应该显示的位置
+  let x = event.clientX + offset
+  let y = event.clientY - offset
+  
+  // 如果tooltip会超出右边界,则显示在左边
+  if (x + tooltipWidth > windowWidth) {
+    x = event.clientX - tooltipWidth - offset
+  }
+  
+  // 确保不超出左边界
+  if (x < 0) {
+    x = offset
+  }
+  
+  // 确保不超出上边界
+  if (y < 0) {
+    y = offset
+  }
+  
+  // 如果正在悬停,直接更新位置,不使用防抖
+  if (isHovering.value) {
+    tooltipPosition.value = { x, y }
+  } else {
+    // 只有在非悬停状态才使用防抖
+    debounce(() => {
+      tooltipPosition.value = { x, y }
+    }, 16) // 约60fps的更新频率
+  }
+}
+
+const hideTooltip = () => {
+  // 设置悬停状态为false
+  isHovering.value = false
+  
+  // 淡出效果
+  tooltipOpacity.value = 0
+  
+  // 等待淡出动画完成后隐藏
+  if (fadeTimer) {
+    clearTimeout(fadeTimer)
+  }
+  fadeTimer = window.setTimeout(() => {
+    // 只有在不悬停时才隐藏
+    if (!isHovering.value) {
+      tooltipVisible.value = false
+    }
+  }, 200) // 与CSS过渡时间匹配
+}
+
+const displayData = computed(() => {
+  return props.data && props.data.length > 0 ? props.data : defaultData
+})
+
+onMounted(() => {
+  // 组件挂载后的初始化逻辑
+})
+
+onUnmounted(() => {
+  // 清理定时器
+  if (debounceTimer) {
+    clearTimeout(debounceTimer)
+  }
+  if (fadeTimer) {
+    clearTimeout(fadeTimer)
+  }
+})
+
+// 监听数据变化
+watch(displayData, (newData) => {
+  count.value = newData.reduce((count, item) => {
+    count = item.value + count;
+    return count;
+  }, 0);
+}, {
+  deep: true,
+  immediate: true
+})
+</script>
+
+<style scoped lang="scss">
+.horizontal-bar-chart-container {
+  width: 100%;
+  height: 100%;
+  text-align: center;
+}
+
+.chart-wrapper {
+}
+
+.title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-weight: 400;
+  color: rgba(100, 100, 100, 1);
+  font-weight: 500;
+  font-size: 14px;
+  vertical-align: middle;
+  margin-bottom: 10px;
+}
+
+.chart-container {
+  .chart-item {
+    display: flex;
+    justify-content: left;
+    width: 100%;
+    height: 40px;
+    vertical-align: middle;
+    align-items: center;
+    margin-bottom: 0;
+    transition: all 0.3s ease;
+    &:hover {
+      cursor: pointer;
+      .chart-item-bar {
+        border: 1px solid rgba(92, 223, 223, 1);
+        div {
+          // background: #81b6f7;
+        }
+      }
+    }
+    .chart-item-name,
+    .chart-item-value {
+      width: 75px;
+      font-size: 14px;
+      color: rgba(18, 18, 18, 1);
+      text-align: left;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+    .chart-item-name {
+      width: 95px;
+    }
+    .chart-item-value {
+      width: 84px;
+    }
+    .chart-item-bar {
+      width: 100%;
+      height: 12px;
+      background: rgba(244, 244, 244, 1);
+      div {
+        height: 100%;
+        background: rgba(92, 223, 223, 1);
+        transition: all 0.3s ease;
+      }
+    }
+    .chart-item-value {
+      text-align: right;
+    }
+     }
+   
+ }
+
+.tooltip {
+  position: fixed;
+  z-index: 9999;
+  background: rgba(255, 255, 255, 0.95);
+  color: #000000;
+  padding: 8px 12px;
+  border-radius: 6px;
+  font-size: 12px;
+  line-height: 1.4;
+  pointer-events: none;
+  white-space: nowrap;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  backdrop-filter: blur(4px);
+  border: 1px solid rgba(255, 255, 255, 0.2);
+  transition: opacity 0.2s ease, transform 0.2s ease;
+  transform: translateY(-2px);
+  text-align: left;
+  
+  &::before {
+    content: '';
+    position: absolute;
+    top: 25px;
+    left: -4px;
+    width: 0;
+    height: 0;
+    border-top: 4px solid transparent;
+    border-bottom: 4px solid transparent;
+    border-right: 4px solid rgba(255, 255, 255, 0.95);
+    filter: drop-shadow(-1px 0 1px rgba(0, 0, 0, 0.1));
+  }
+  
+  // 当显示在左边时的样式
+  &.tooltip-left {
+    &::before {
+      left: auto;
+      right: -4px;
+      border-right: none;
+      border-left: 4px solid rgba(255, 255, 255, 0.95);
+      filter: drop-shadow(1px 0 1px rgba(0, 0, 0, 0.1));
+    }
+  }
+}
+
+// 淡入淡出动画
+.tooltip-fade-enter-active,
+.tooltip-fade-leave-active {
+  transition: opacity 0.2s ease, transform 0.2s ease;
+}
+
+.tooltip-fade-enter-from {
+  opacity: 0;
+  transform: translateY(-4px) scale(0.95);
+}
+
+.tooltip-fade-leave-to {
+  opacity: 0;
+  transform: translateY(-4px) scale(0.95);
+}
+ 
+ </style> 

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

@@ -0,0 +1,271 @@
+<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>

+ 41 - 21
src/views/churn/behavior/echarts/PieChart.vue

@@ -10,20 +10,24 @@
       </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>
+      <table class="legend-table">
+        <thead>
+          <tr class="legend-title">
+            <th class="item">时长分布</th>
+            <th class="item">卸载设备数</th>
+          </tr>
+        </thead>
+        <tbody class="legend-list">
+          <tr v-for="(item, index) in displayData" :key="index" class="legend-item"
+            @click="handleLegendClick(item, index)">
+            <td class="item">
+              <div class="legend-number" :style="{ backgroundColor: item.color }">{{ getLegendNumber(index) }}</div>
+              {{ item.name }}
+            </td>
+            <td class="item">{{ item.value }}({{ item.percentage }})</td>
+          </tr>
+        </tbody>
+      </table>
     </div>
   </div>
 </template>
@@ -214,17 +218,29 @@ watch(displayData, (newData) => {
 
   .legend-wrapper {
     padding: 0;
+    
+    .legend-table {
+      max-width: 300px;
+      border-collapse: collapse;
+      width: 100%;
+    }
+    
     .legend-title {
       max-width: 300px;
       font-size: 14px;
       font-weight: 500;
       color: rgba(100, 100, 100, 1);
       margin-bottom: 18px;
-      display: flex;
+      th {
+        height: 38px;
+      }
 
-      .item {
-        width: 50%;
+      th.item {
         text-wrap: nowrap;
+        width: 50%;
+        text-align: left;
+        padding: 0;
+        border: none;
 
         &:nth-child(1) {
           width: 150px;
@@ -236,20 +252,24 @@ watch(displayData, (newData) => {
       max-width: 300px;
 
       .legend-item {
-        display: flex;
-        align-items: center;
         font-size: 14px;
         line-height: 20px;
-        margin-bottom: 20px;
+        cursor: pointer;
 
         &:last-child {
           margin-bottom: 0;
         }
 
-        .item {
+        td {
+          height: 38px;
+        }
+        td.item {
           text-wrap: nowrap;
           width: 50%;
           color: rgba(18, 18, 18, 1);
+          padding: 0;
+          border: none;
+          vertical-align: middle;
 
           &:nth-child(1) {
             width: 150px;

+ 81 - 68
src/views/churn/behavior/index.vue

@@ -2,18 +2,22 @@
   <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;">
+        <el-card shadow="none" 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 class="title">卸载洞察
+              <el-tooltip class="box-item" effect="light"
+                content="卸载洞察展示您每周卸载设备的活跃特征,如从安装到卸载的生命周期时长分布、卸载前活跃情况、末次活跃至卸载行为的时间差分布、卸载设备终端特征。" placement="right-start">
+                <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>
+              </el-tooltip>
             </div>
+
             <div class="data-source-status">数据源状态:Demo数据</div>
           </div>
         </el-card>
@@ -21,15 +25,15 @@
     </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">
+        <el-card shadow="none">
           <div class="active-situation">
-            <div class="title">卸载设备活跃情况</div>
+            <div class="card-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">
+                <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="12">
                   <div class="el-card" style="flex: 1; overflow: visible; padding: 24px 40px; height: 475px;">
-                    <div class="description">
+                    <div class="description" style="margin-bottom: 25px;">
                       <p>安装至卸载存量时长分布</p>
                       <p>存量时长=最近卸载日期-最近卸载前的安装日期</p>
                       <p>用户卸载集中在安装App后:<span>90天以上</span></p>
@@ -39,7 +43,7 @@
                     </div>
                   </div>
                 </el-col>
-                <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+                <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="12">
                   <div class="el-card" style="flex: 1; overflow: visible; padding: 24px 40px; height: 475px;">
                     <div class="description">
                       <p>历史卸载次数分布</p>
@@ -47,7 +51,7 @@
                       <p>历史上<span>96.5%</span>卸载设备会反复卸载。</p>
                     </div>
                     <div class="content-item">
-                      <BarChart />
+                      <BarChart title="历史卸载次数" :data="historyUninstallData" />
                     </div>
                   </div>
                 </el-col>
@@ -58,19 +62,56 @@
         </el-card>
       </el-col>
     </el-row>
+    <el-row :gutter="12" style="padding: 0 12px 12px;">
+      <div class="tabs">
+        <div class="tab" :class="{ active: activeTab === 'before' }" @click="activeTab = 'before'">卸载前状态</div>
+        <div class="tab" :class="{ active: activeTab === 'after' }" @click="activeTab = 'after'">卸载后流向</div>
+        <div class="tab" :class="{ active: activeTab === 'system' }" @click="activeTab = 'system'">设备系统分布</div>
+      </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'" />
+        </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'
+import BeforeUninstallStatus from './components/BeforeUninstallStatus.vue'
+import AfterUninstallStatus from './components/AfterUninstallStatus.vue'
+import SystemDistribution from './components/SystemDistribution.vue'
+
+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%' }
+]
+
+const activeTab = ref('system');
+
+const formatter = (value: number) => {
+  return `${value}%`
+}
+
 </script>
 <style scoped lang="scss">
+@import './styles/common.scss';
+
 svg {
   vertical-align: middle;
   margin: 0 0 0 12px;
 }
 
+
+
 .behavior {
   font-family: Source Han Sans SC;
 
@@ -101,62 +142,34 @@ svg {
   .active-situation {
     padding: 10px 14px;
 
-    .title {
-      line-height: 19px;
+    .content {}
+  }
+
+  .tabs {
+    display: flex;
+    padding: 0 6px;
+
+    .tab {
+      cursor: pointer;
+      height: 42px;
+      line-height: 42px;
+      color: rgba(100, 100, 100, 1);
       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);
+      font-style: Medium;
+      font-size: 14px;
+      padding: 0 25px;
+      border-left: 1px solid rgba(221, 228, 237, 1);
+      border-top: 1px solid rgba(221, 228, 237, 1);
+      background-color: #ffffff;
+
+      &:last-child {
+        border-right: 1px solid rgba(221, 228, 237, 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;
-          }
-        }
-      }
+    .active {
+      color: rgba(22, 122, 240, 1);
+      background-color: rgba(232, 242, 254, 1);
     }
   }
 

+ 72 - 0
src/views/churn/behavior/styles/common.scss

@@ -0,0 +1,72 @@
+.description {
+  margin-bottom: 35px;
+
+  p:nth-child(1) {
+    color: rgba(18, 18, 18, 1);
+    font-weight: 500;
+    font-size: 16px;
+    margin-bottom: 8px;
+    line-height: 23px;
+  }
+
+  p:nth-child(2) {
+    font-weight: 400;
+    font-size: 14px;
+    color: rgba(100, 100, 100, 1);
+    margin-bottom: 8px;
+    line-height: 21px;
+  }
+
+  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;
+    }
+  }
+}
+
+.card-tabs {
+  display: flex;
+  margin-bottom: 42px;
+  .card-tab {
+    padding: 0 12px;
+    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;
+    }
+  }
+}
+
+.card-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);
+  }
+} 

+ 69 - 0
src/views/churn/echarts/ProgressRing.vue

@@ -0,0 +1,69 @@
+<template>
+  <svg :width="size" :height="size" :viewBox="`0 0 ${size} ${size}`">
+    <!-- 背景圆环 -->
+    <circle 
+      :cx="center" 
+      :cy="center" 
+      :r="radius" 
+      fill="none" 
+      :stroke="backgroundColor" 
+      :stroke-width="strokeWidth"
+      :stroke-linecap="softCorner ? 'round' : 'butt'"
+    />
+    <!-- 进度圆环 -->
+    <circle 
+      :cx="center" 
+      :cy="center" 
+      :r="radius" 
+      fill="none" 
+      :stroke="progressColor" 
+      :stroke-width="strokeWidth"
+      :stroke-dasharray="`${circumference * progress} ${circumference}`"
+      :transform="`rotate(-90 ${center} ${center})`"
+      :stroke-linecap="softCorner ? 'round' : 'butt'"
+    />
+  </svg>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue'
+
+interface Props {
+  // 进度值 (0-1)
+  progress?: number
+  // 圆环大小
+  size?: number
+  // 背景圆环颜色
+  backgroundColor?: string
+  // 进度圆环颜色
+  progressColor?: string
+  // 圆环粗细
+  strokeWidth?: number
+  // 是否为软角
+  softCorner?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  progress: 0,
+  size: 96,
+  backgroundColor: 'rgba(239, 239, 239, 1)',
+  progressColor: 'rgba(0, 188, 113, 1)',
+  strokeWidth: 10,
+  softCorner: false
+})
+
+// 计算中心点
+const center = computed(() => props.size / 2)
+
+// 计算半径 (减去strokeWidth的一半,确保圆环完全显示)
+const radius = computed(() => (props.size - props.strokeWidth) / 2)
+
+// 计算圆周长
+const circumference = computed(() => 2 * Math.PI * radius.value)
+</script>
+
+<style scoped>
+svg {
+  vertical-align: middle;
+}
+</style> 

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

@@ -1,165 +0,0 @@
-<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>

+ 21 - 9
src/views/churn/overview/index.vue

@@ -2,7 +2,7 @@
   <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;">
+        <el-card shadow="none" style="padding: 10px 14px;">
           <div class="top-info">
             <div class="title">流失概况</div>
             <div class="aside">
@@ -21,8 +21,14 @@
             <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" />
+                  <ProgressRing 
+                    :progress="0.7223"
+                    :size="111"
+                    background-color="rgba(239, 239, 239, 1)"
+                    progress-color="rgba(0, 188, 113, 1)"
+                    :stroke-width="15"
+                    :soft-corner="true"
+                  />
                 </div>
                 <div class="content-item-right">
                   <div class="content-item-title">当周卸载流失设备数</div>
@@ -46,8 +52,14 @@
             <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" />
+                  <ProgressRing 
+                    :progress="0.38"
+                    :size="111"
+                    background-color="rgba(239, 239, 239, 1)"
+                    progress-color="rgba(230, 66, 66, 1)"
+                    :stroke-width="15"
+                    :soft-corner="true"
+                  />
                 </div>
                 <div class="content-item-right">
                   <div class="content-item-title">当周卸载召回设备数</div>
@@ -75,7 +87,7 @@
     </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">
+        <el-card shadow="none">
           <div class="trend-container">
             <div class="title">流失趋势</div>
             <div class="tabs">
@@ -122,8 +134,8 @@
 </template>
 
 <script lang="ts" name="churnOverview" setup>
-import DoughnutChart from './echarts/DoughnutChart.vue'
 import LineChart from './echarts/LineChart.vue'
+import ProgressRing from '../echarts/ProgressRing.vue'
 import { BasicTableProps, useTable } from '/@/hooks/table';
 import { ref, computed, reactive } from 'vue'
 
@@ -299,6 +311,7 @@ svg {
     display: flex;
     align-items: center;
     justify-content: center;
+    margin-right: 30px;
   }
 
   .content-item-right {
@@ -355,11 +368,10 @@ svg {
   }
 
   .tabs {
-    width: 240px;
     display: flex;
     margin-left: 85px;
     .tabs-item {
-      flex: 1;
+      padding: 0 12px;
       height: 32px;
       line-height: 30px;
       border: 1px solid rgba(22, 122, 240, 1);