瀏覽代碼

Merge branch 'dev-cmn' of https://s-20coaj910c.zht2.com/cmy/data-marketing-platform into dev-ly

luoy 15 小時之前
父節點
當前提交
4b0f9f6b0e
共有 54 個文件被更改,包括 7073 次插入1049 次删除
  1. 84 0
      EXPORT_FEATURE_README.md
  2. 94 0
      IMPLEMENTATION_SUMMARY.md
  3. 8 0
      package-lock.json
  4. 1 0
      package.json
  5. 37 0
      src/api/count/churn.ts
  6. 52 0
      src/components/Svg.vue
  7. 0 6
      src/layout/navMenu/navIcon.vue
  8. 4 1
      src/layout/navMenu/vertical.vue
  9. 17 0
      src/theme/app.scss
  10. 95 0
      src/utils/exportExcel.ts
  11. 0 223
      src/views/churn/behavior/echarts/BarChart.vue
  12. 0 164
      src/views/churn/behavior/index.vue
  13. 0 165
      src/views/churn/overview/echarts/DoughnutChart.vue
  14. 0 428
      src/views/churn/overview/index.vue
  15. 339 0
      src/views/count/churn/ascribe/index.vue
  16. 234 0
      src/views/count/churn/behavior/components/AfterUninstallStatus.vue
  17. 113 0
      src/views/count/churn/behavior/components/BeforeUninstallStatus.vue
  18. 323 0
      src/views/count/churn/behavior/components/HorizontalBarChart.vue
  19. 271 0
      src/views/count/churn/behavior/components/MindChart.vue
  20. 41 21
      src/views/count/churn/behavior/components/PieChart.vue
  21. 51 0
      src/views/count/churn/behavior/components/SystemDistribution.vue
  22. 169 0
      src/views/count/churn/behavior/components/SystemItem.vue
  23. 177 0
      src/views/count/churn/behavior/index.vue
  24. 123 0
      src/views/count/churn/overview/ProgressCard.vue
  25. 415 0
      src/views/count/churn/overview/index.vue
  26. 89 0
      src/views/count/churn/portrait/components/DataTable.vue
  27. 119 0
      src/views/count/churn/portrait/components/UserInfo.vue
  28. 307 0
      src/views/count/churn/portrait/index.vue
  29. 342 0
      src/views/count/components/BarChart.vue
  30. 147 0
      src/views/count/components/BarChartUsageExample.vue
  31. 219 0
      src/views/count/components/ExportToCSV.vue
  32. 335 0
      src/views/count/components/HorizontalBarChart.vue
  33. 135 41
      src/views/count/components/LineChart.vue
  34. 75 0
      src/views/count/components/LineChartExample.vue
  35. 69 0
      src/views/count/components/ProgressRing.vue
  36. 81 0
      src/views/count/device/components/selectForm.vue
  37. 297 0
      src/views/count/device/device/index.vue
  38. 88 0
      src/views/count/device/location/dialog.vue
  39. 352 0
      src/views/count/device/location/index.vue
  40. 285 0
      src/views/count/device/network/index.vue
  41. 118 0
      src/views/count/engagement/components/FormEcharts.vue
  42. 67 0
      src/views/count/engagement/components/SelectForm.vue
  43. 133 0
      src/views/count/engagement/frequency/Month.vue
  44. 125 0
      src/views/count/engagement/frequency/OneDay.vue
  45. 133 0
      src/views/count/engagement/frequency/Week.vue
  46. 59 0
      src/views/count/engagement/frequency/index.vue
  47. 142 0
      src/views/count/engagement/interval/Interval.vue
  48. 76 0
      src/views/count/engagement/interval/index.vue
  49. 130 0
      src/views/count/engagement/pages/View.vue
  50. 63 0
      src/views/count/engagement/pages/index.vue
  51. 133 0
      src/views/count/engagement/time/OneDay.vue
  52. 132 0
      src/views/count/engagement/time/Single.vue
  53. 53 0
      src/views/count/engagement/time/index.vue
  54. 121 0
      src/views/count/styles/common.scss

+ 84 - 0
EXPORT_FEATURE_README.md

@@ -0,0 +1,84 @@
+# 表格导出功能实现
+
+## 功能概述
+
+为使用时长页面实现了表格数据导出功能,支持将表格数据导出为CSV格式文件。
+
+## 实现内容
+
+### 1. 导出工具函数 (`src/utils/exportExcel.ts`)
+
+创建了通用的导出工具函数,支持:
+- 数据格式验证
+- CSV格式导出
+- 中文编码支持
+- 错误处理和用户反馈
+
+### 2. 使用时长页面集成 (`src/views/count/engagement/time/index.vue`)
+
+在原有页面基础上添加了:
+- 导出按钮UI组件
+- 导出功能实现
+- 数据格式化处理
+- 错误处理机制
+
+## 功能特点
+
+### 导出按钮样式
+- 蓝色主题按钮
+- 包含导出图标
+- 悬停效果
+- 响应式设计
+
+### 数据处理
+- 自动格式化表格数据
+- 支持多系列数据导出
+- 包含启动次数和占比信息
+- 文件名包含日期信息
+
+### 用户体验
+- 导出成功/失败提示
+- 数据验证(空数据检查)
+- 错误处理机制
+- 自动下载文件
+
+## 使用方法
+
+1. 在使用时长页面,点击"导出"按钮
+2. 系统会自动生成包含当前数据的CSV文件
+3. 文件会自动下载到浏览器默认下载目录
+4. 文件名格式:`使用时长数据_YYYY-MM-DD.csv`
+
+## 技术实现
+
+### 导出格式
+- 使用CSV格式,兼容Excel打开
+- 支持中文编码(UTF-8 with BOM)
+- 自动处理特殊字符(逗号、引号等)
+
+### 数据格式
+```csv
+时长,2025-01-01启动次数,2025-01-01启动次数占比
+1秒-3秒,100,25%
+4秒-10秒,150,37.5%
+...
+```
+
+### 依赖库
+- 使用原生JavaScript实现
+- 无需额外依赖
+- 兼容所有现代浏览器
+
+## 扩展性
+
+该导出功能设计为通用工具,可以轻松扩展到其他页面:
+1. 导入导出工具函数
+2. 准备数据格式
+3. 调用导出函数
+
+## 注意事项
+
+1. 确保数据格式正确
+2. 大量数据导出时可能需要等待
+3. 浏览器需要允许下载权限
+4. 建议在数据加载完成后再进行导出操作

+ 94 - 0
IMPLEMENTATION_SUMMARY.md

@@ -0,0 +1,94 @@
+# 表格导出功能实现总结
+
+## 实现概述
+
+成功为使用时长页面实现了完整的表格导出功能,用户可以通过点击导出按钮将表格数据导出为CSV格式文件。
+
+## 主要实现内容
+
+### 1. 导出工具函数 (`src/utils/exportExcel.ts`)
+- ✅ 创建了通用的CSV导出函数
+- ✅ 支持中文编码(UTF-8 with BOM)
+- ✅ 自动处理特殊字符
+- ✅ 包含错误处理和用户反馈
+- ✅ 数据验证机制
+
+### 2. 使用时长页面集成 (`src/views/count/engagement/time/index.vue`)
+- ✅ 添加了导出按钮UI组件
+- ✅ 实现了导出功能逻辑
+- ✅ 优化了表格显示以支持多系列数据
+- ✅ 添加了完整的错误处理
+- ✅ 集成了用户消息提示
+
+### 3. 样式优化
+- ✅ 导出按钮采用蓝色主题
+- ✅ 包含导出图标
+- ✅ 添加悬停效果
+- ✅ 响应式设计
+
+## 功能特点
+
+### 数据处理能力
+- 支持多系列数据导出
+- 自动格式化表格数据
+- 包含启动次数和占比信息
+- 文件名自动包含日期
+
+### 用户体验
+- 导出成功/失败提示
+- 数据验证(空数据检查)
+- 错误处理机制
+- 自动下载文件
+
+### 技术实现
+- 使用原生JavaScript实现
+- 无需额外依赖库
+- 兼容所有现代浏览器
+- CSV格式兼容Excel打开
+
+## 使用方法
+
+1. 访问使用时长页面
+2. 选择需要的时间范围和对比数据
+3. 点击"导出"按钮
+4. 系统自动生成并下载CSV文件
+5. 文件名格式:`使用时长数据_YYYY-MM-DD.csv`
+
+## 导出数据格式
+
+```csv
+时长,2025-01-01启动次数,2025-01-01启动次数占比
+1秒-3秒,100,25%
+4秒-10秒,150,37.5%
+11秒-30秒,80,20%
+31秒-1分,50,12.5%
+1分-3分,15,3.75%
+3分-10分,5,1.25%
+```
+
+## 技术亮点
+
+1. **无依赖实现**:使用原生JavaScript,无需额外库
+2. **中文支持**:正确处理中文编码
+3. **错误处理**:完善的异常处理机制
+4. **用户体验**:友好的提示信息
+5. **扩展性**:可轻松扩展到其他页面
+
+## 测试验证
+
+- ✅ 导出功能正常工作
+- ✅ 数据格式正确
+- ✅ 中文显示正常
+- ✅ 错误处理有效
+- ✅ 用户体验良好
+
+## 后续优化建议
+
+1. 可以添加导出进度提示
+2. 支持更多导出格式(如Excel)
+3. 添加导出数据筛选功能
+4. 支持批量导出多个时间范围的数据
+
+## 总结
+
+成功实现了完整的表格导出功能,满足了用户将数据导出到本地进行进一步分析的需求。实现方案简洁高效,具有良好的扩展性和维护性。

+ 8 - 0
package-lock.json

@@ -54,6 +54,7 @@
 				"@types/node": "20.0.0",
 				"@types/nprogress": "0.2.3",
 				"@types/sortablejs": "1.15.8",
+				"@types/xlsx": "^0.0.35",
 				"@typescript-eslint/eslint-plugin": "8.17.0",
 				"@typescript-eslint/parser": "8.17.0",
 				"@vitejs/plugin-vue": "5.2.1",
@@ -1510,6 +1511,13 @@
 			"integrity": "sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==",
 			"license": "MIT"
 		},
+		"node_modules/@types/xlsx": {
+			"version": "0.0.35",
+			"resolved": "https://registry.npmjs.org/@types/xlsx/-/xlsx-0.0.35.tgz",
+			"integrity": "sha512-s0x3DYHZzOkxtjqOk/Nv1ezGzpbN7I8WX+lzlV/nFfTDOv7x4d8ZwGHcnaiB8UCx89omPsftQhS5II3jeWePxQ==",
+			"dev": true,
+			"license": "MIT"
+		},
 		"node_modules/@typescript-eslint/eslint-plugin": {
 			"version": "8.17.0",
 			"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz",

+ 1 - 0
package.json

@@ -61,6 +61,7 @@
 		"@types/node": "20.0.0",
 		"@types/nprogress": "0.2.3",
 		"@types/sortablejs": "1.15.8",
+		"@types/xlsx": "^0.0.35",
 		"@typescript-eslint/eslint-plugin": "8.17.0",
 		"@typescript-eslint/parser": "8.17.0",
 		"@vitejs/plugin-vue": "5.2.1",

+ 37 - 0
src/api/count/churn.ts

@@ -0,0 +1,37 @@
+import request from '/@/utils/request';
+
+/**
+ * 流失趋势 获取卸载趋势参数
+ * @param startDate	开始时间(yyyy-MM-dd),不传则默认为当周第一天
+ * @param endDate	结束时间(yyyy-MM-dd),不传则默认为当前时间
+ * @param timeUnit	时间单位(day/week/month),不传则默认为day
+ * @param appId	appId
+ * @param channel	渠道
+ * @param version	版本
+ * @returns 
+ */
+export const uninstallTrend = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/trend',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载画像 获取卸载趋势参数
+ * @param startDate	开始时间(yyyy-MM-dd),不传则默认为当周第一天
+ * @param endDate	结束时间(yyyy-MM-dd),不传则默认为当前时间
+ * @param timeUnit	时间单位(day/week/month),不传则默认为day
+ * @param appId	appId
+ * @param channel	渠道
+ * @param version	版本
+ * @returns 
+ */
+export const uninstallPortrait = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/portrait',
+		method: 'post',
+		data: data,
+	});
+};

+ 52 - 0
src/components/Svg.vue

@@ -0,0 +1,52 @@
+<template>
+  <!-- 导出 -->
+  <svg v-if="name === 'export'" 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>
+
+  <!-- 日期 + 号 -->
+  <svg v-if="name === 'date'" style="margin-left: 0; margin-right: 4px;" width="14" height="14" viewBox="0 0 14 14" fill="none"
+    xmlns="http://www.w3.org/2000/svg">
+    <path d="M7 2.625V11.375" stroke="white" stroke-width="1.5" stroke-linecap="round"
+      stroke-linejoin="round" />
+    <path d="M2.625 7H11.375" stroke="white" stroke-width="1.5" stroke-linecap="round"
+      stroke-linejoin="round" />
+  </svg>
+
+  <!-- 导出 -->
+  <svg v-if="name === 'export2'" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g clip-path="url(#clip0_735_955)">
+      <path
+        d="M14.0002 1.33398H2.00004C1.63184 1.33398 1.33336 1.63247 1.33337 2.00067L1.3337 14.0007C1.33371 14.3689 1.63219 14.6673 2.00037 14.6673H14.0002C14.3684 14.6673 14.6668 14.3689 14.6668 14.0007V2.00065C14.6668 1.63246 14.3684 1.33398 14.0002 1.33398Z"
+        stroke="#167AF0" />
+      <path d="M6.66956 11.3366H11.1668V6.66992" stroke="#167AF0" stroke-linecap="round"
+        stroke-linejoin="round" />
+      <path d="M9.66663 8.16602L10.1666 7.66602L11.1666 6.66602L12.1666 7.66602L12.6666 8.16602" stroke="#167AF0"
+        stroke-linecap="round" stroke-linejoin="round" />
+      <path d="M4.66663 1.33398V14.6673" stroke="#167AF0" stroke-linecap="round" />
+      <path d="M1.33337 4.67862L14.6667 4.66602" stroke="#167AF0" stroke-linecap="round" />
+      <path d="M2.66663 1.33398H9.33329" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+      <path d="M2.66663 14.666H9.33329" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+      <path d="M14.6666 2.66602V5.99935" stroke="#167AF0" stroke-linecap="round" />
+      <path d="M1.33337 2.66602V5.99935" stroke="#167AF0" stroke-linecap="round" />
+    </g>
+    <defs>
+      <clipPath id="clip0_735_955">
+        <rect width="16" height="16" fill="white" />
+      </clipPath>
+    </defs>
+  </svg>
+</template>
+<script setup lang="ts">
+const props = defineProps({
+  name: {
+    type: String,
+    default: ''
+  }
+})
+</script>

File diff suppressed because it is too large
+ 0 - 6
src/layout/navMenu/navIcon.vue


+ 4 - 1
src/layout/navMenu/vertical.vue

@@ -10,7 +10,9 @@
 		<template v-for="val in menuLists">
 			<el-sub-menu :index="val.path" v-if="val.children && val.children.length > 0" :key="val.path">
 				<template #title>
-					<SvgIcon :name="val.meta.icon" />
+          <navIcon v-if="val.path == '/count/churn'" :val=val style="margin-right: 8px;" />
+          <navIcon v-else-if="val.path == '/count/engagement'" :val=val style="margin-right: 8px;" />
+          <SvgIcon v-else :name="val.meta.icon" />
 					<span>{{ other.setMenuI18n(val) }}</span>
 				</template>
 				<SubItem :chil="val.children" />
@@ -33,6 +35,7 @@
 <script setup lang="ts" name="navMenuVertical">
 import { RouteRecordRaw } from 'vue-router';
 import { useThemeConfig } from '/@/stores/themeConfig';
+const navIcon = defineAsyncComponent(() => import('/@/layout/navMenu/navIcon.vue'));
 import other from '/@/utils/other';
 
 // 引入组件

+ 17 - 0
src/theme/app.scss

@@ -344,6 +344,23 @@ body,
 		background-color: var(--menu-bar-active-color) !important;
 		color: var(--menu-bar-active-font-color) !important;
 	}
+	/* 自定义菜单icon */
+	.el-menu-item svg.default-icon,
+	.el-sub-menu svg.default-icon {
+    display: block;
+	}
+	.el-menu-item svg.active-icon,
+	.el-sub-menu svg.active-icon {
+    display: none;
+	}
+	.el-menu-item.is-active svg.default-icon,
+	.el-sub-menu.is-active svg.default-icon {
+    display: none;
+	}
+  .el-menu-item.is-active svg.active-icon,
+  .el-sub-menu.is-active svg.active-icon {
+    display: block;
+  }
 }
 
 /* 横向 菜单 */

+ 95 - 0
src/utils/exportExcel.ts

@@ -0,0 +1,95 @@
+import { formatDate } from './formatTime';
+import { useMessage } from '/@/hooks/message';
+
+// 格式化数据防止Excel自动转换
+const formatValue = (value: string, preventConversion: boolean = false) => {
+  if (preventConversion && typeof value === 'string' && /^[\d\-/]+$/.test(value)) {
+      // 使用Excel公式格式强制文本显示
+      return `"=""${value}"""`;
+  }
+  
+  // 如果值包含逗号、引号或换行符,需要用引号包围
+  if (typeof value === 'string' && (value.includes(',') || value.includes('"') || value.includes('\n'))) {
+      return `"${value.replace(/"/g, '""')}"`;
+  }
+  
+  // 默认情况返回原值
+  return `"${value}"`;
+};
+
+/**
+ * 导出CSV文件
+ * @param data 要导出的数据
+ * @param fileName 文件名
+ */
+export function exportToExcel(data: any[], fileName: string, sheetName: string = 'Sheet1') {
+  try {
+    // 检查数据是否为空
+    if (!data || data.length === 0) {
+      useMessage().warning('没有数据可导出');
+      return;
+    }
+
+    // 获取表头
+    const headers = Object.keys(data[0]);
+    // 创建CSV内容
+    const csvContent = [
+      headers.join(','), // 表头
+      ...data.map(row => 
+        headers.map(header => {
+          const value = row[header];
+          return formatValue(value, true);
+        }).join(',')
+      )
+    ].join('\n');
+
+    // 添加BOM以支持中文
+    const BOM = '\uFEFF';
+    const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
+    
+    // 创建下载链接
+    const link = document.createElement('a');
+    const url = URL.createObjectURL(blob);
+    link.setAttribute('href', url);
+    link.setAttribute('download', `${fileName}.csv`);
+    link.style.visibility = 'hidden';
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+    URL.revokeObjectURL(url);
+    
+    useMessage().success('导出成功');
+  } catch (error) {
+    console.error('导出失败:', error);
+    useMessage().error('导出失败,请检查数据格式');
+  }
+}
+
+/**
+ * 格式化表格数据为导出格式
+ * @param tableData 表格数据
+ * @param columns 列配置
+ * @returns 格式化后的数据
+ */
+export function formatTableDataForExport(tableData: any[], columns: any[]) {
+  return tableData.map((row, index) => {
+    const exportRow: any = {};
+    
+    columns.forEach((column) => {
+      if (column.prop) {
+        // 处理特殊的数据格式
+        if (column.formatter) {
+          exportRow[column.label] = column.formatter(row, column, row[column.prop], index);
+        } else {
+          // 确保数据存在且不为undefined
+          const value = row[column.prop];
+          exportRow[column.label] = value !== undefined && value !== null ? value : '';
+        }
+      }
+    });
+    
+    return exportRow;
+  });
+}
+
+

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

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

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

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

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

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

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

+ 339 - 0
src/views/count/churn/ascribe/index.vue

@@ -0,0 +1,339 @@
+<template>
+  <div class="layout-padding">
+    <div class="ascribe">
+      <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="none" style="padding: 10px 14px;">
+            <div class="top-info">
+              <div class="title">卸载归因<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">
+                    <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>
+                  <template #content>
+                    <div style="width: 300px;">
+                      卸载归因解读卸载设备在您应用中的最后活跃行为。
+                      本模块功能展示周期内的卸载设备,在卸载前的最后7天(含当天)在您的应用中浏览次数TOP10的页面;展示应用在全网设备中的卸载量,及是否在当前周期内新安装了您关注行业的头部竞品。同时基于行业提供各周期的应用安装卸载比,辅助您判断行业的规模趋势。
+                    </div>
+                  </template>
+                </el-tooltip>
+              </div>
+
+              <div class="data-source-status">数据源状态:Demo数据</div>
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <el-card shadow="none">
+            <div class="trend-container">
+              <div class="title">卸载设备全量预测</div>
+              <div class="card-tabs">
+                <div class="card-tab" :class="{ active: activeTab === 'churnTrend' }"
+                  @click="handleTabClick('churnTrend')">
+                  卸载流失设备(预测)
+                  <el-tooltip 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 fill-rule="evenodd" clip-rule="evenodd"
+                        d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434Z"
+                        fill="white" />
+                      <path
+                        d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434ZM6.99957 2.33322C6.6774 2.33322 6.41623 2.5944 6.41623 2.91656C6.41623 3.23872 6.6774 3.49989 6.99957 3.49989C7.32173 3.49989 7.5829 3.23872 7.5829 2.91656C7.5829 2.5944 7.32173 2.33322 6.99957 2.33322Z"
+                        fill="white" />
+                      <path d="M7.19477 10.8888V5.44434H6.80588H6.41699" fill="white" />
+                      <path
+                        d="M6.41645 10.8892V6.22255C5.98689 6.22255 5.63867 5.87432 5.63867 5.44477C5.63867 5.01522 5.98689 4.66699 6.41645 4.66699H7.19423L7.27398 4.67079C7.66608 4.71071 7.97201 5.04213 7.97201 5.44477V10.8892C7.97201 11.3188 7.70354 11.3452 7.27398 11.3452C6.84443 11.3452 6.41645 11.3188 6.41645 10.8892Z"
+                        fill="white" />
+                      <path
+                        d="M8.55566 10C8.98522 10 9.33344 10.3482 9.33344 10.7778C9.33344 11.2073 8.98522 11.5556 8.55566 11.5556H5.83344C5.40389 11.5556 5.05566 11.2073 5.05566 10.7778C5.05566 10.3482 5.40389 10 5.83344 10H8.55566Z"
+                        fill="white" />
+                    </svg>
+                    <template #content>
+                      <div style="width: 300px;">
+                        由算法根据您的全量活跃设备预测得出,不受版本覆盖率影响。
+                      </div>
+                    </template>
+                  </el-tooltip>
+
+                </div>
+                <div class="card-tab" :class="{ active: activeTab === 'recallTrend' }"
+                  @click="handleTabClick('recallTrend')">卸载召回设备(预测)
+                  <el-tooltip 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 fill-rule="evenodd" clip-rule="evenodd"
+                        d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434Z"
+                        fill="white" />
+                      <path
+                        d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434ZM6.99957 2.33322C6.6774 2.33322 6.41623 2.5944 6.41623 2.91656C6.41623 3.23872 6.6774 3.49989 6.99957 3.49989C7.32173 3.49989 7.5829 3.23872 7.5829 2.91656C7.5829 2.5944 7.32173 2.33322 6.99957 2.33322Z"
+                        fill="white" />
+                      <path d="M7.19477 10.8888V5.44434H6.80588H6.41699" fill="white" />
+                      <path
+                        d="M6.41645 10.8892V6.22255C5.98689 6.22255 5.63867 5.87432 5.63867 5.44477C5.63867 5.01522 5.98689 4.66699 6.41645 4.66699H7.19423L7.27398 4.67079C7.66608 4.71071 7.97201 5.04213 7.97201 5.44477V10.8892C7.97201 11.3188 7.70354 11.3452 7.27398 11.3452C6.84443 11.3452 6.41645 11.3188 6.41645 10.8892Z"
+                        fill="white" />
+                      <path
+                        d="M8.55566 10C8.98522 10 9.33344 10.3482 9.33344 10.7778C9.33344 11.2073 8.98522 11.5556 8.55566 11.5556H5.83344C5.40389 11.5556 5.05566 11.2073 5.05566 10.7778C5.05566 10.3482 5.40389 10 5.83344 10H8.55566Z"
+                        fill="white" />
+                    </svg>
+                    <template #content>
+                      <div style="width: 300px;">
+                        由卸载流失设备(预测)推测得出,预测为卸载的设备在当前周期下重新安装,即为卸载召回设备(预测)
+                      </div>
+                    </template>
+                  </el-tooltip>
+                </div>
+              </div>
+              <!-- 折线图 -->
+              <div class="chart-container">
+                <LineChart :data="currentChartData" :color="'#167af0'" height="270px"
+                  :smooth="false" :area-style="true" :title="activeTab === 'churnTrend' ? '卸载流失设备' : '卸载召回设备'" :showLegend="true" />
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <el-card shadow="none">
+            <div class="trend-container">
+              <div class="title">安装卸载比</div>
+              <!-- 折线图 -->
+              <div class="chart-container">
+                <LineChart :data="data" :color="'#167af0'" height="270px" :showLegend="true"
+                  :smooth="false" :area-style="true" :isMultiSeries="true" :seriesNames="['当前应用', '移动视频行业TOP5', '医疗服务行业TOP5']" />
+              </div>
+              
+              <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);"/>
+
+              <!-- 表格 -->
+              <ExportToCSV :hidePage="true" :data="formatData" :columns="columns" :fileName="''" :hide-table="false" :tableStyle="{minWidth: '1476px'}" />
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" name="churnAscribe" setup>
+import LineChart from '/@/views/count/components/LineChart.vue'
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import { ref, computed } from 'vue'
+
+// 流失趋势数据
+const churnTrendData = ref([
+  { date: '2025-01-01', value: 25 },
+  { date: '2025-01-02', value: 32 },
+  { date: '2025-01-03', value: 28 },
+  { date: '2025-01-04', value: 45 },
+  { date: '2025-01-05', value: 38 },
+  { date: '2025-01-06', value: 42 },
+  { date: '2025-01-07', value: 35 },
+  { date: '2025-01-08', value: 48 },
+  { date: '2025-01-09', value: 52 },
+  { date: '2025-01-10', value: 39 },
+  { date: '2025-01-11', value: 44 },
+  { date: '2025-01-12', value: 37 },
+  { date: '2025-01-13', value: 41 },
+  { date: '2025-01-14', value: 46 }
+])
+
+// 召回趋势数据
+const recallTrendData = ref([
+  { date: '2025-01-01', value: 15 },
+  { date: '2025-01-02', value: 22 },
+  { date: '2025-01-03', value: 18 },
+  { date: '2025-01-04', value: 35 },
+  { date: '2025-01-05', value: 28 },
+  { date: '2025-01-06', value: 32 },
+  { date: '2025-01-07', value: 25 },
+  { date: '2025-01-08', value: 38 },
+  { date: '2025-01-09', value: 42 },
+  { date: '2025-01-10', value: 29 },
+  { date: '2025-01-11', value: 34 },
+  { date: '2025-01-12', value: 27 },
+  { date: '2025-01-13', value: 31 },
+  { date: '2025-01-14', value: 36 }
+])
+
+const activeTab = ref('churnTrend')
+
+// 计算当前图表数据
+const currentChartData = computed(() => {
+  return activeTab.value === 'churnTrend' ? churnTrendData.value : recallTrendData.value
+})
+
+const handleTabClick = (tab: string) => {
+  activeTab.value = tab
+}
+
+const data = ref([
+  { date: '2025-01-01', values: [{ value: 1, seriesName: '当前应用' }, { value: 2, seriesName: '移动视频行业TOP5' }, { value: 5, seriesName: '医疗服务行业TOP5' }] },
+  { date: '2025-01-02', values: [{ value: 11, seriesName: '当前应用' }, { value: 4, seriesName: '移动视频行业TOP5' }, { value: 3, seriesName: '医疗服务行业TOP5' }] },
+  { date: '2025-01-03', values: [{ value: 2, seriesName: '当前应用' }, { value: 2, seriesName: '移动视频行业TOP5' }, { value: 3, seriesName: '医疗服务行业TOP5' }] },
+  { date: '2025-01-04', values: [{ value: 11, seriesName: '当前应用' }, { value: 5, seriesName: '移动视频行业TOP5' }, { value: 9, seriesName: '医疗服务行业TOP5' }] }
+])
+
+const columns = ref([
+  {prop: 'date', label: '日期'},
+  {prop: 'app1', label: '当前应用'},
+  {prop: 'app2', label: '移动视频行业TOP5'},
+  {prop: 'app3', label: '医疗服务行业TOP5'},
+])
+
+const formatData = computed(() => {
+  return data.value.map((item: any) => ({
+    date: item.date,
+    app1: item.values[0].value,
+    app2: item.values[1].value,
+    app3: item.values[2].value
+  }))
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.ascribe {
+  font-family: Source Han Sans SC;
+  color: rgba(18, 18, 18, 1);
+
+  .top-info {
+    color: rgba(18, 18, 18, 1);
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0;
+    margin: -2.5px 0;
+
+    .title {
+      font-size: 16px;
+      font-weight: 500;
+      line-height: 20px;
+      padding: 4px 0;
+    }
+
+    .data-source-status {
+      font-weight: 400;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(18, 18, 18, 1);
+      padding: 4px 0;
+    }
+  }
+
+  .trend-container {
+    padding: 10px 14px;
+
+    .title {
+      line-height: 19px;
+      font-weight: 500;
+      font-size: 16px;
+      padding-left: 12px;
+      position: relative;
+      margin-bottom: 53px;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 4px;
+        height: 14px;
+        background: rgba(22, 122, 240, 1);
+      }
+    }
+
+    .card-tabs {
+      width: 100%;
+      max-width: 1476px;
+      margin: 0 auto 0;
+
+      .card-tab {
+        svg {
+          margin: 0;
+        }
+      }
+    }
+
+    .chart-container {
+      // margin: 20px 85px 0;
+      width: 100%;
+      max-width: 1476px;
+      margin: 20px auto 0;
+      text-align: center;
+    }
+
+    .echarts-name {
+      display: inline-block;
+      margin: 28px auto 0;
+      padding-left: 16px;
+      font-weight: 400;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(18, 18, 18, 1);
+      position: relative;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 8px;
+        height: 8px;
+        background: rgba(22, 122, 240, 1);
+        border-radius: 50%;
+      }
+    }
+
+    .table-container {
+      // padding: 0 85px;
+      width: 100%;
+      max-width: 1476px;
+      margin: 0 auto;
+
+      .btn-toggle-table {
+        font-weight: 500;
+        font-size: 14px;
+        line-height: 20px;
+        color: rgba(22, 122, 240, 1);
+        margin-bottom: 20px;
+        cursor: pointer;
+
+        svg {
+          transition: transform 0.3s ease-in-out;
+        }
+
+        &.hide-table svg {
+          transform: rotate(-180deg);
+        }
+      }
+
+      .table-container {
+        margin-top: 20px;
+      }
+    }
+  }
+}
+</style>

+ 234 - 0
src/views/count/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 '/@/views/count/components/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/count/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 '/@/views/count/components/BarChart.vue'
+import HorizontalBarChart from './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> 

+ 323 - 0
src/views/count/churn/behavior/components/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/count/churn/behavior/components/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 → src/views/count/churn/behavior/components/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;

+ 51 - 0
src/views/count/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/count/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>

+ 177 - 0
src/views/count/churn/behavior/index.vue

@@ -0,0 +1,177 @@
+<template>
+   <div class="layout-padding">
+    <div class="behavior">
+      <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="none" style="padding: 10px 14px;">
+            <div class="top-info">
+              <div class="title">卸载洞察<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">
+                    <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>
+                  <template #content>
+                    <div style="width: 300px;">
+                      卸载洞察展示您每周卸载设备的活跃特征,如从安装到卸载的生命周期时长分布、卸载前活跃情况、末次活跃至卸载行为的时间差分布、卸载设备终端特征。
+                    </div>
+                  </template>
+                </el-tooltip>
+              </div>
+
+              <div class="data-source-status">数据源状态:Demo数据</div>
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <el-card shadow="none">
+            <div class="active-situation">
+              <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="24" :xl="12">
+                    <div class="el-card" style="flex: 1; overflow: visible; padding: 24px 40px; height: 475px;">
+                      <div class="description" style="margin-bottom: 25px;">
+                        <p>安装至卸载存量时长分布</p>
+                        <p>存量时长=最近卸载日期-最近卸载前的安装日期</p>
+                        <p>用户卸载集中在安装App后:<span>90天以上</span></p>
+                      </div>
+                      <div class="content-item">
+                        <PieChart />
+                      </div>
+                    </div>
+                  </el-col>
+                  <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="12">
+                    <div class="el-card" style="flex: 1; overflow: visible; padding: 24px 40px; height: 475px;">
+                      <div class="description">
+                        <p>历史卸载次数分布</p>
+                        <p>当前周的卸载设备,按当前是第几次卸载App进行分布</p>
+                        <p>历史上<span>96.5%</span>卸载设备会反复卸载。</p>
+                      </div>
+                      <div class="content-item">
+                        <BarChart title="历史卸载次数" :data="historyUninstallData" />
+                      </div>
+                    </div>
+                  </el-col>
+                </el-row>
+              </div>
+
+            </div>
+          </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>
+  </div>
+</template>
+
+<script lang="ts" name="churnBehavior" setup>
+import BarChart from '/@/views/count/components/BarChart.vue'
+import BeforeUninstallStatus from './components/BeforeUninstallStatus.vue'
+import AfterUninstallStatus from './components/AfterUninstallStatus.vue'
+import SystemDistribution from './components/SystemDistribution.vue'
+import PieChart from './components/PieChart.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('before');
+
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.behavior {
+  font-family: Source Han Sans SC;
+
+  .top-info {
+    color: rgba(18, 18, 18, 1);
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0;
+    margin: -2.5px 0;
+
+    .title {
+      font-size: 16px;
+      font-weight: 500;
+      line-height: 20px;
+      padding: 4px 0;
+    }
+
+    .data-source-status {
+      font-weight: 400;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(18, 18, 18, 1);
+      padding: 4px 0;
+    }
+  }
+
+  .active-situation {
+    padding: 10px 14px;
+
+    .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-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);
+      }
+    }
+
+    .active {
+      color: rgba(22, 122, 240, 1);
+      background-color: rgba(232, 242, 254, 1);
+    }
+  }
+
+}
+</style>

+ 123 - 0
src/views/count/churn/overview/ProgressCard.vue

@@ -0,0 +1,123 @@
+<template>
+  <div class="el-card" style="flex: 1; overflow: visible;">
+    <div class="content-item">
+      <div class="content-item-left">
+        <ProgressRing 
+          :progress="progress"
+          :size="111"
+          background-color="rgba(239, 239, 239, 1)"
+          :progress-color="color"
+          :stroke-width="15"
+          :soft-corner="true"
+        />
+      </div>
+      <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 
+          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">
+              <rect width="14" height="14" fill="#D9D9D9" />
+            </mask>
+            <g mask="url(#mask0_611_555)">
+              <path
+                d="M11.1702 4.43275C11.5204 4.43302 11.8046 4.71698 11.8047 5.06725L11.8041 10.7783C11.8041 11.1287 11.5201 11.4128 11.1696 11.4128L5.45857 11.4134C5.10831 11.4133 4.82435 11.1291 4.82408 10.7789C4.82408 10.4284 5.1087 10.1438 5.45916 10.1438L9.6377 10.1438L2.69566 3.20174C2.44785 2.95393 2.44785 2.55215 2.69566 2.30434C2.94348 2.05652 3.34526 2.05652 3.59307 2.30434L10.5351 9.24637L10.5351 5.06783C10.5351 4.71737 10.8197 4.43275 11.1702 4.43275Z"
+                fill="#00BC71" />
+            </g>
+          </svg><svg v-else width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+            <mask id="mask0_611_558" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="14"
+              height="14">
+              <rect width="14" height="14" transform="matrix(1 0 0 -1 0 14)" fill="#D9D9D9" />
+            </mask>
+            <g mask="url(#mask0_611_558)">
+              <path
+                d="M11.1702 9.56725C11.5204 9.56698 11.8046 9.28302 11.8047 8.93275L11.8041 3.22173C11.8041 2.87127 11.5201 2.58723 11.1696 2.58723L5.45857 2.58665C5.10831 2.58668 4.82435 2.87093 4.82408 3.22114C4.82408 3.5716 5.1087 3.85622 5.45916 3.85622L9.6377 3.85622L2.69566 10.7983C2.44785 11.0461 2.44785 11.4479 2.69566 11.6957C2.94348 11.9435 3.34526 11.9435 3.59307 11.6957L10.5351 4.75363L10.5351 8.93217C10.5351 9.28263 10.8197 9.56725 11.1702 9.56725Z"
+                fill="#E64242" />
+            </g>
+          </svg>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import ProgressRing from '/@/views/count/components/ProgressRing.vue'
+
+const props = defineProps({
+  title: {
+    type: String,
+    required: true
+  },
+  counts: {
+    type: Number,
+    required: true
+  },
+  rates: {
+    type: Number,
+    required: true
+  },
+})
+
+const progress = computed(() => {
+  return props.rates / 100
+})
+
+const color = computed(() => {
+  return props.rates > 0 ? 'rgba(230, 66, 66, 1)' : 'rgba(0, 188, 113, 1)'
+})
+</script>
+
+
+<style scoped lang="scss">
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 8px;
+}
+
+.top-content {
+  display: flex;
+  gap: 20px;
+
+  .content-item {
+    display: flex;
+    align-items: center;
+    gap: 0;
+    flex: 1;
+    padding: 27.5px;
+    justify-content: center;
+  }
+
+  .content-item-left {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 30px;
+  }
+
+  .content-item-title {
+    color: rgba(18, 18, 18, 1);
+    font-weight: 400;
+    font-size: 15px;
+    line-height: 20px;
+    margin-bottom: 12px;
+  }
+
+  .content-item-value {
+    font-weight: 500;
+    font-style: Medium;
+    font-size: 28px;
+    margin-bottom: 16px;
+    line-height: 41px;
+  }
+
+  .content-item-percent {
+    font-weight: 400;
+    font-size: 14px;
+    vertical-align: middle;
+    line-height: 20px;
+  }
+}
+
+</style>

+ 415 - 0
src/views/count/churn/overview/index.vue

@@ -0,0 +1,415 @@
+<template>
+   <div class="layout-padding">
+    <div class="overview">
+      <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="none" style="padding: 10px 14px;">
+            <div class="top-info">
+              <div class="title">流失概况</div>
+              <div class="aside">
+                <div class="data-source-status">数据源状态: Demo数据</div>
+                <el-button class="goto-smart-operation" type="primary" link>前往智能运营发短信
+                  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <path d="M10.6667 4.66699L13.3334 7.33366L10.6667 10.0003" stroke="#167AF0" stroke-width="1.5"
+                      stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M2.66671 12.6663V8.33301C2.66671 7.78071 3.11441 7.33301 3.66671 7.33301H13.3334"
+                      stroke="#167AF0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
+                  </svg>
+                </el-button>
+              </div>
+            </div>
+            <div class="top-content">
+              <ProgressCard :title="'当周卸载流失设备数'" :counts="trendProgressData.uninstallCounts" :rates="trendProgressData.uninstallRates" />
+              <ProgressCard :title="'当周卸载召回设备数'" :counts="trendProgressData.recallCounts" :rates="trendProgressData.recallRates" />
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <el-card shadow="none">
+            <div class="trend-container">
+              <div class="title">流失趋势</div>
+              <div class="tabs">
+                <div class="tabs-item" :class="{ active: activeTab === 'churnTrend' }" @click="handleTabClick('churnTrend')">卸载流失设备</div>
+                <div class="tabs-item" :class="{ active: activeTab === 'recallTrend' }" @click="handleTabClick('recallTrend')">卸载召回设备</div>
+              </div>
+              <!-- 折线图 -->
+              <div class="chart-container">
+                <LineChart :data="currentChartData" :color="'#167af0'" :title="currentChartTitle" height="270px"
+                  :smooth="false" :area-style="true" />
+                <div class="echarts-name">{{currentChartTitle}}</div>
+              </div>
+              
+              <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);"/>
+
+              <div class="table-container">
+                <div class="btn-toggle-table" :class="{ 'hide-table': hideTable }" @click="hideTable = !hideTable">
+                  {{ hideTable ? '展开' : '收起' }}明细数据<svg 
+                    width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+                    <path d="M10.5 8.75L7 5.25L3.5 8.75" stroke="#167AF0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                  </svg>
+                </div>
+
+                <el-table class="statistics-table" :data="state.dataList" row-key="date" style="width: 100%"
+                  :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>
+
+                <pagination 
+                  v-if="!hideTable"
+                  @current-change="currentChangeHandle" 
+                  @size-change="sizeChangeHandle" 
+                  v-bind="state.pagination">
+                </pagination>
+              </div>
+            </div>
+          </el-card>
+        </el-col>
+      </el-row>
+    </div>
+   </div>
+</template>
+
+<script lang="ts" name="churnOverview" setup>
+import LineChart from '/@/views/count/components/LineChart.vue'
+import ProgressRing from '/@/views/count/components/ProgressRing.vue'
+import { BasicTableProps, useTable } from '/@/hooks/table';
+import { ref, computed, reactive } from 'vue'
+import { uninstallTrend } from '/@/api/count/churn'
+import { formatDate } from '/@/utils/formatTime';
+import ProgressCard from './ProgressCard.vue'
+
+const cellStyle = ref({
+  textAlign: 'center',
+  fontSize: '14px',
+  height: '50px',
+  background: 'transparent !important'
+})
+
+const headerCellStyle = ref({
+  textAlign: 'center',
+  border: 'none',
+  background: 'rgba(244, 245, 250, 1)',
+  height: '32px',
+  color: 'rgba(100, 100, 100, 1)',
+  fontSize: '14px',
+})
+
+const trendProgressData = ref({
+  recallCounts: 0,  
+  recallRates: 0,
+  uninstallCounts: 0,
+  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
+})
+
+// 计算当前图表标题
+const currentChartTitle = computed(() => {
+  return activeTab.value === 'churnTrend' ? '卸载流失设备' : '卸载召回设备'
+})
+
+const handleTabClick = (tab: string) => {
+  activeTab.value = tab
+} 
+
+// 表格数据
+const hideTable = ref(false);
+const state: BasicTableProps = reactive<BasicTableProps>({
+  queryForm: {
+    ip: '',
+  },
+  pageList: () => Promise.resolve([]),
+  pagination: {
+    current: 1,
+    size: 10,
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  },
+  dataList: [
+    {
+      date: '2025-01-01',
+      churn: 10,
+      recall: 20
+    },
+    {
+      date: '2025-01-02',
+      churn: 15,
+      recall: 25
+    },
+    {
+      date: '2025-01-03',
+      churn: 20,
+      recall: 30
+    },
+    {
+      date: '2025-01-04',
+      churn: 25,
+      recall: 35
+    }
+  ]
+});
+const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
+  return cellValue || '--';
+}
+
+onMounted(() => {
+  uninstallTrend({
+    startDate: formatDate(new Date(new Date().setDate(new Date().getDate() - 7)), 'YYYY-mm-dd'),
+    endDate: formatDate(new Date(), 'YYYY-mm-dd'),
+    timeUnit: 'day',
+  }).then(res => {
+    trendProgressData.value = res.data
+  })
+})
+
+</script>
+<style scoped lang="scss">
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+.overview {
+  font-family: Source Han Sans SC;
+  color: rgba(18, 18, 18, 1);
+}
+
+.top-info {
+  color: rgba(18, 18, 18, 1);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 25px;
+
+
+  .title {
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 20px;
+    padding: 4px 0;
+  }
+
+  .aside {
+    display: flex;
+  }
+
+  .data-source-status {
+    font-weight: 400;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(18, 18, 18, 1);
+    padding: 4px 16px 4px 0;
+  }
+
+  .goto-smart-operation {
+    padding: 4px 12px;
+    border-radius: 4px;
+    border: 1px solid rgba(22, 122, 240, 1);
+    font-weight: 400;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(22, 122, 240, 1);
+  }
+
+}
+
+.top-content {
+  display: flex;
+  gap: 20px;
+
+  .content-item {
+    display: flex;
+    align-items: center;
+    gap: 0;
+    flex: 1;
+    padding: 27.5px;
+    justify-content: center;
+  }
+
+  .content-item-left {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    margin-right: 30px;
+  }
+
+  .content-item-right {
+    // flex: 1;
+  }
+
+  .content-item-title {
+    color: rgba(18, 18, 18, 1);
+    font-weight: 400;
+    font-size: 15px;
+    line-height: 20px;
+    margin-bottom: 12px;
+  }
+
+  .content-item-value {
+    font-weight: 500;
+    font-style: Medium;
+    font-size: 28px;
+    margin-bottom: 16px;
+    line-height: 41px;
+  }
+
+  .content-item-percent {
+    font-weight: 400;
+    font-size: 14px;
+    vertical-align: middle;
+    line-height: 20px;
+  }
+
+  .content-item-percent span {
+    color: rgba(0, 188, 113, 1);
+  }
+}
+
+.trend-container {
+  padding: 10px 14px;
+  .title {
+    line-height: 19px;
+    font-weight: 500;
+    font-size: 16px;
+    padding-left: 12px;
+    position: relative;
+    margin-bottom: 53px;
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 4px;
+      height: 14px;
+      background: rgba(22, 122, 240, 1);
+    }
+  }
+
+  .tabs {
+    display: flex;
+    width: 100%;
+    max-width: 1476px;
+    margin: 0 auto 0;
+    .tabs-item {
+      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;
+      }
+    }
+  }
+
+  .chart-container {
+    // margin: 20px 85px 0;
+    width: 100%;
+    max-width: 1476px;
+    margin: 20px auto 0;
+    text-align: center;
+  }
+
+  .echarts-name {
+    display: inline-block;
+    margin: 28px auto 0;
+    padding-left: 16px;
+    font-weight: 400;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(18, 18, 18, 1);
+    position: relative;
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 8px;
+      height: 8px;
+      background: rgba(22, 122, 240, 1);
+      border-radius: 50%;
+    }
+  }
+
+  .table-container {
+    // padding: 0 85px;
+    width: 100%;
+    max-width: 1476px;
+    margin: 0 auto;
+    
+    .btn-toggle-table {
+      font-weight: 500;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(22, 122, 240, 1);
+      margin-bottom: 20px;
+      cursor: pointer;
+      svg {
+        transition: transform 0.3s ease-in-out;
+      }
+      &.hide-table svg {
+        transform: rotate(-180deg);
+      }
+    }
+
+    .table-container {
+      margin-top: 20px;
+    }
+
+  }
+
+}
+</style>

+ 89 - 0
src/views/count/churn/portrait/components/DataTable.vue

@@ -0,0 +1,89 @@
+<template>
+  <el-table class="table" :data="data" row-key="name" style="width: 100%" :cell-style="{
+    ...cellStyle,
+    ...props.cellStyle
+  }" :header-cell-style="{
+    ...headerCellStyle,
+    ...props.headerCellStyle
+    }">
+    <el-table-column v-for="item in columns" :key="item.prop" :label="item.label" :prop="item.prop" show-overflow-tooltip :width="item.width || ''">
+      <template #default="scope">
+        <div class="percent-box" v-if="item.type === 'percentDom'">
+          <div class="inner" :style="{ width: scope.row[item.prop] + '%' }"></div>
+        </div>
+        <div v-else class="ellipsis-box">{{ scope.row[item.prop] + (item.prop == 'percent' ? '%' : '') }}</div>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+<script setup lang="ts">
+import { BasicTableProps, useTable } from '/@/hooks/table';
+
+const cellStyle = ref({
+  textAlign: 'center',
+  fontSize: '14px',
+  height: '50px',
+  background: 'transparent !important'
+})
+
+const headerCellStyle = ref({
+  textAlign: 'center',
+  border: 'none',
+  background: 'rgba(244, 245, 250, 1)',
+  height: '32px',
+  color: 'rgba(100, 100, 100, 1)',
+  fontSize: '14px',
+})
+
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => []
+  },
+  columns: {
+    type: Array as PropType<{
+      label?: string;
+      prop?: string;
+      type?: string;
+      width?: string;
+    }[]>,
+    default: () => []
+  },
+  cellStyle: {
+    type: Object,
+    default: () => {}
+  },
+  headerCellStyle: {
+    type: Object,
+    default: () => {}
+  }
+})
+
+const state = reactive<BasicTableProps>({
+  pagination: {
+    current: 1,
+    size: 10,
+    total: 0
+  }
+})
+const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+</script>
+<style scoped lang="scss">
+.percent-box {
+  width: 100%;
+  height: 12px;
+  background: rgba(244, 244, 244, 1);
+  border-radius: 4px;
+
+  .inner {
+    height: 100%;
+    background: rgba(109, 173, 249, 1);
+  }
+}
+.ellipsis-box {
+  width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 119 - 0
src/views/count/churn/portrait/components/UserInfo.vue

@@ -0,0 +1,119 @@
+<template>
+  <div class="user-info" :class="{ 'easy': type }">
+    <div class="user-info-content">
+      <div class="user-info-header">
+        <svg v-if="type" width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
+          <circle cx="30" cy="30" r="30" fill="url(#paint0_linear_829_3662)" />
+          <ellipse cx="19.5" cy="24" rx="3.5" ry="5" fill="#0F3461" />
+          <ellipse cx="40.5" cy="24" rx="3.5" ry="5" fill="#0F3461" />
+          <path d="M38 42C36.7075 39.0567 33.7577 37 30.3256 37C27.1735 37 24.4282 38.7348 23 41.2979" stroke="#0F3461"
+            stroke-width="2" stroke-linecap="round" />
+          <defs>
+            <linearGradient id="paint0_linear_829_3662" x1="30" y1="0" x2="30" y2="60" gradientUnits="userSpaceOnUse">
+              <stop stop-color="#E8F2FE" />
+              <stop offset="1" stop-color="#AED3FF" />
+            </linearGradient>
+          </defs>
+        </svg>
+        <svg v-else width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
+          <circle cx="30" cy="30" r="30" fill="url(#paint0_linear_829_3663)" />
+          <ellipse cx="19.5" cy="24" rx="3.5" ry="5" fill="#04790C" />
+          <ellipse cx="40.5" cy="24" rx="3.5" ry="5" fill="#04790C" />
+          <path d="M38 37C36.7075 39.9433 33.7577 42 30.3256 42C27.1735 42 24.4282 40.2652 23 37.7021" stroke="#04790C"
+            stroke-width="2" stroke-linecap="round" />
+          <defs>
+            <linearGradient id="paint0_linear_829_3663" x1="30" y1="0" x2="30" y2="60" gradientUnits="userSpaceOnUse">
+              <stop stop-color="#FAFEE8" />
+              <stop offset="1" stop-color="#B8FFAE" />
+            </linearGradient>
+          </defs>
+        </svg>
+      </div>
+      <div class="title">{{ title }}</div>
+      <div class="tags" style="min-height: 20px;">
+        <div class="tag" v-for="tag in tags" :key="tag">{{ tag }}</div>
+      </div>
+    </div>
+    <div class="tips">{{ tips }}</div>
+  </div>
+</template>
+<script setup lang="ts">
+defineProps({
+  type: {
+    type: Boolean,
+    default: true
+  },
+  title: {
+    type: String,
+    default: ''
+  },
+  tags: {
+    type: Array as PropType<string[]>,
+    default: () => []
+  },
+  tips: {
+    type: String,
+    default: ''
+  }
+})  
+</script>
+<style scoped lang="scss">
+.user-info {
+  .user-info-content {
+    position: relative;
+    padding-left: 80px;
+    margin-bottom: 12px;
+
+    .user-info-header {
+      position: absolute;
+      top: 0;
+      left: 0;
+    }
+
+    .title {
+      color: rgba(18, 18, 18, 1);
+      font-family: Source Han Sans SC;
+      font-weight: 500;
+      font-size: 16px;
+      line-height: 23px;
+      margin-bottom: 12px;
+    }
+
+    .tags {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+
+      .tag {
+        border-radius: 4px;
+        font-family: Source Han Sans SC;
+        font-weight: 400;
+        font-size: 12px;
+        line-height: 12px;
+        padding: 6px 10px;
+        background: rgba(222, 255, 216, 1);
+        color: rgba(4, 121, 12, 1);
+      }
+    }
+  }
+
+  .tips {
+    color: rgba(100, 100, 100, 1);
+    font-family: Source Han Sans SC;
+    font-weight: 400;
+    font-size: 12px;
+    line-height: 100%;
+  }
+
+  &.easy {
+    .user-info-content {
+      .tags {
+        .tag {
+          background: rgba(232, 242, 254, 1);
+          color: rgba(22, 122, 240, 1);
+        }
+      }
+    }
+  }
+}
+</style>

+ 307 - 0
src/views/count/churn/portrait/index.vue

@@ -0,0 +1,307 @@
+<template>
+  <div class="layout-padding">
+    <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 class="select-form-card" shadow="none" style="padding: 10px 14px">
+          <div class="card-title no-padding">卸载画像<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">
+                <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="select-form">
+            <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <path d="M33 15H15V30H33V15Z" fill="#167AF0" stroke="#167AF0" stroke-width="2" stroke-linejoin="round" />
+              <path d="M20 35L24 30L28 35" stroke="#167AF0" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round" />
+              <path d="M18 25L21.0967 22.034L23.5345 24.3738L29 19" stroke="white" stroke-width="2"
+                stroke-linecap="round" stroke-linejoin="round" />
+              <path d="M13 15H35" stroke="#167AF0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
+              <rect width="48" height="48" rx="14" fill="#167AF0" fill-opacity="0.1" />
+            </svg>
+            <el-form :inline="true" :model="form" label-width="0">
+              <el-form-item label="">
+                <el-select v-model="form.time" placeholder="" @change="selectTimeChange">
+                  <el-option label="按周查看" value="week" />
+                  <el-option label="按月查看" value="month" />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="">
+                <el-date-picker :type="'daterange'" style="width: 242px;" v-model="form.dateArray" range-separator="至" @change="selectDateChange"
+                  start-placeholder="开始日期" end-placeholder="结束日期" />
+              </el-form-item>
+              <el-form-item label="">
+                一周内,卸载流失设备数<span>{{ uninstallCount.count }}</span>
+              </el-form-item>
+              <div class="tips">您可以根据下方的卸载画像报表,查看易卸载用户和高粘性用户的特征,了解什么样的用户留不住。</div>
+            </el-form>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="none">
+          <el-row :gutter="12" style="padding: 0 12px 0; row-gap: 12px;">
+            <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+              <el-card shadow="none">
+                <user-info title="不易卸载用户画像" :type="true" :tags="hardData.length > 0 ? hardData.map((item: any) => item.name) : []" tips="卸载用户 vs. 活跃用户具备的明显特征,有以下特征的用户易卸载" style="margin-bottom: 24px;"></user-info>
+                <data-table :data="hardData" :columns="columns"></data-table>
+              </el-card>
+            </el-col>
+            <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+              <el-card shadow="none">
+                <user-info title="不易卸载用户特征" :type="false" :tags="easyData.length > 0 ? easyData.map((item: any) => item.name) : []" tips="活跃用户 vs. 卸载用户具备的明显差异点,有以下特征的人群卸载可能性低" style="margin-bottom: 24px;"></user-info>
+                <data-table :data="easyData" :columns="columns"></data-table>
+              </el-card>
+            </el-col>
+          </el-row>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="none" style="padding: 10px 0 0;">
+          <div class="card-header">
+            <svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <rect width="60" height="60" rx="14" fill="#167AF0" fill-opacity="0.1" />
+              <path d="M16.667 19.167H43.3337" stroke="#2B65AA" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round" />
+              <path d="M25 14.167H35" stroke="#2B65AA" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round" />
+              <path
+                d="M20 24.167H40V43.3337C40 44.7144 38.8807 45.8337 37.5 45.8337H22.5C21.1192 45.8337 20 44.7144 20 43.3337V24.167Z"
+                stroke="#2B65AA" stroke-width="2" stroke-linejoin="round" />
+              <path d="M26.667 30.833L33.3337 37.4997" stroke="#2B65AA" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round" />
+              <path d="M33.3337 30.833L26.667 37.4997" stroke="#2B65AA" stroke-width="2" stroke-linecap="round"
+                stroke-linejoin="round" />
+            </svg>
+            <div class="title">新用户卸载高峰阶段</div>
+            <div class="tips">卸载用户集中在使用App<span>未使用</span>后卸载。(仅计算在当前时间周期内卸载,且卸载前30天内新安装App的设备。)</div>
+          </div>
+          <el-row :gutter="12" style="padding: 12px 12px 12px; row-gap: 12px;">
+            <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+              <div style="padding-top: 8px;" v-if="echartsData.length > 0">
+                <BarChart :data="echartsData" :is-multi-series="true" :series-names="seriesNames" title=""
+                  style="height: 320px;" :chartHeight="320" />
+              </div>
+            </el-col>
+            <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+              <div class="">
+                <data-table :data="usageTimeData" :columns="columns2"></data-table>
+              </div>
+            </el-col>
+          </el-row>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+<script setup lang="ts">
+import { ref } from 'vue';
+import UserInfo from './components/UserInfo.vue';
+import DataTable from './components/DataTable.vue';
+import BarChart from '/@/views/count/components/BarChart.vue';
+import { formatDate } from '/@/utils/formatTime';
+import { uninstallPortrait } from '/@/api/count/churn';
+
+const columns = ref([
+  { label: '用户特征', prop: 'name', width: '100px' },
+  { prop: 'percent', type: 'percentDom' },
+  { label: 'TGI', prop: 'tgi', width: '100px' },
+  { label: '百分比', prop: 'percent', width: '100px' },
+])
+
+const columns2 = ref([
+  { label: '卸载前使用App次数', prop: 'phase', width: '150px' },
+  { prop: 'percent', type: 'percentDom' },
+  { label: '百分比', prop: 'percent', width: '100px' },
+  { label: '卸载用户数', prop: 'count', width: '100px' },
+])
+
+const form = ref({
+  time: 'week',
+  dateArray: [formatDate(new Date(new Date().setDate(new Date().getDate() - 7)), 'YYYY-mm-dd'), formatDate(new Date(), 'YYYY-mm-dd')]
+})
+
+const echartsData = ref<any[]>([])
+const seriesNames = ref<string[]>(['卸载用户数']);
+const easyData = ref<any[]>([])
+const hardData = ref<any[]>([])
+const usageTimeData = ref<any[]>([])// 使用次数
+const uninstallCount = ref({
+  count: 0,
+})
+
+const init = () => {
+  uninstallPortrait({
+    startDate: formatDate(new Date(form.value.dateArray[0]), 'YYYY-mm-dd'),
+    endDate: formatDate(new Date(form.value.dateArray[1]), 'YYYY-mm-dd'),
+    timeUnit: form.value.time,
+  }).then(res => {
+    uninstallCount.value = {
+      count: res.data?.uninstallCount.count,
+    }
+    // if (res.data?.uninstallPortraitPhaseList) {
+      const newData: any[] = [];
+      easyData.value = [];
+      hardData.value = [];
+      usageTimeData.value = [];
+      res.data?.uninstallPortraitPhaseList.forEach((item: any) => {
+        newData.push({
+          name: item.phase,
+          values: [
+            {
+              value: item.count,
+              percentage: item.rate + "%",
+            }
+          ]
+        });
+      });
+      echartsData.value = newData;
+      res.data?.uninstallPortraitPhaseList.forEach((item: any) => {
+        usageTimeData.value.push({
+          ...item,
+          percent: item.rate,
+        });
+      })
+    // }
+    // if (res.data?.uninstallPortraitCharactList) {
+      res.data?.uninstallPortraitCharactList.forEach((item: any) => {
+        if(item.status == 0){
+          easyData.value.push({
+            ...item,
+            percent: item.percent.substring(0, item.percent.length - 1),
+          });
+        } else {
+          hardData.value.push({
+            ...item,
+            percent: item.percent.substring(0, item.percent.length - 1),
+          });
+        }
+      })
+    // }
+  })
+}
+
+const selectTimeChange = () => {
+  console.log(form.value.time);
+  init();
+}
+
+const selectDateChange = () => {
+  console.log(form.value.dateArray);
+  init();
+}
+
+onMounted(() => {
+  init();
+})
+
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.select-form-card {
+  .card-title {
+    color: rgba(18, 18, 18, 1);
+    font-weight: 500;
+    font-family: Source Han Sans SC;
+  }
+}
+
+.select-form {
+  margin-top: 20px;
+  padding-left: 68px;
+  position: relative;
+  font-family: Source Han Sans SC;
+  font-weight: 400;
+  font-size: 14px;
+  color: rgba(100, 100, 100, 1);
+  line-height: 14px;
+
+  :deep(.el-form-item--default) {
+    margin-bottom: 12px;
+  }
+
+  span {
+    color: rgba(22, 122, 240, 1);
+    font-weight: 500;
+    font-size: 20px;
+    padding: 0 8px;
+  }
+
+  svg {
+    position: absolute;
+    left: 0;
+    top: 0;
+    margin: 0 0 0 0!important;
+  }
+
+  .el-form-item__content {
+    .el-select {
+      width: 147px !important;
+      min-width: 147px !important;
+    }
+  }
+}
+
+.card-header {
+  padding-left: 92px;
+  position: relative;
+  margin-bottom: 12px;
+
+  svg {
+    position: absolute;
+    top: 0;
+    left: 0px;
+  }
+
+  .title {
+    color: rgba(18, 18, 18, 1);
+    font-family: Source Han Sans SC;
+    font-weight: 500;
+    font-size: 16px;
+    line-height: 23px;
+    margin-bottom: 12px;
+  }
+
+  .tips {
+    color: rgba(100, 100, 100, 1);
+    font-family: Source Han Sans SC;
+    font-weight: 400;
+    font-size: 12px;
+    line-height: 100%;
+    span {
+      color: rgba(18, 18, 18, 1);
+      font-weight: 500;
+      font-size: 20px;
+      padding: 0 8px;
+    }
+  }
+}
+
+.card-content {
+  display: flex;
+  gap: 12px;
+  padding: 0px 12px;
+
+  .chart-container {
+    width: 100%;
+    height: 300px;
+  }
+}
+</style>

+ 342 - 0
src/views/count/components/BarChart.vue

@@ -0,0 +1,342 @@
+<template>
+  <div class="bar-chart-container">
+    <div class="chart-wrapper">
+      <v-chart class="chart" :option="chartOption" :autoresize="true" :style="{height: chartHeight || 244 + 'px'}" />
+    </div>
+    <div v-if="!isMultiSeries" class="title">{{ title }}</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart } from 'echarts/charts'
+import {
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent
+} from 'echarts/components'
+import VChart from 'vue-echarts'
+
+use([
+  CanvasRenderer,
+  BarChart,
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent
+])
+
+// 支持两种数据格式
+interface BarItem {
+  name: string
+  value: number
+  percentage?: string
+}
+
+interface MultiBarItem {
+  name: string
+  values: Array<{
+    value: number
+    percentage?: string
+    seriesName?: string
+  }>
+}
+
+const props = withDefaults(defineProps<{
+  data?: BarItem[] | MultiBarItem[]
+  title?: string
+  seriesNames?: string[] // 系列名称,用于多系列对比
+  isMultiSeries?: boolean // 是否为多系列数据
+  chartHeight?: number // 图表高度
+}>(), {
+  data: () => [],
+  seriesNames: () => ['系列1', '系列2'],
+  isMultiSeries: false
+})
+
+// 默认数据
+const defaultData: BarItem[] = [
+  // { name: '6次以上', value: 45, percentage: '45.0%' },
+  // { name: '5次', value: 23, percentage: '23.0%' },
+  // { name: '4次', value: 15, percentage: '15.0%' },
+  // { name: '3次', value: 8, percentage: '8.0%' },
+  // { name: '2次', value: 6, percentage: '6.0%' },
+  // { name: '1次', value: 3, percentage: '3.0%' }
+]
+
+const displayData = computed(() => {
+  return props.data && props.data.length > 0 ? props.data : defaultData
+})
+
+// 获取x轴数据
+const xAxisData = computed(() => {
+  return displayData.value.map(item => item.name)
+})
+
+// 生成系列数据
+const seriesData = computed(() => {
+  if (!props.isMultiSeries) {
+    // 单系列数据
+    const singleData = displayData.value as BarItem[]
+    return [{
+      name: '数据',
+      type: 'bar',
+      data: singleData.map((item) => ({
+        value: item.value,
+        percentage: item.percentage || ''
+      })),
+      itemStyle: {
+        color: {
+          type: 'linear',
+          x: 0,
+          y: 0,
+          x2: 0,
+          y2: 1,
+          colorStops: [
+            { offset: 0, color: 'rgba(109, 173, 249, 1)' },
+            { offset: 1, color: 'rgba(109, 173, 249, 1)' }
+          ]
+        },
+        borderRadius: [0, 0, 0, 0]
+      },
+      emphasis: {
+        itemStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: 'rgba(109, 173, 249, 1)' },
+              { offset: 1, color: 'rgba(109, 173, 249, 1)' }
+            ]
+          }
+        }
+      },
+      barWidth: '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',
+      data: multiData.map((item) => ({
+        value: item.values[index]?.value || 0,
+        percentage: item.values[index]?.percentage || ''
+      })),
+      itemStyle: {
+        color: {
+          type: 'linear',
+          x: 0,
+          y: 0,
+          x2: 0,
+          y2: 1,
+          colorStops: [
+            { offset: 0, color: colors[index % colors.length] },
+            { offset: 1, color: colors[index % colors.length] }
+          ]
+        },
+        borderRadius: [0, 0, 0, 0]
+      },
+      emphasis: {
+        itemStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: colors[index % colors.length] },
+              { offset: 1, color: colors[index % colors.length] }
+            ]
+          }
+        }
+      },
+      barWidth: props.isMultiSeries ? 60 / props.seriesNames.length + 'px' : '54px'
+    }))
+  }
+})
+
+const chartOption = computed(() => {
+  return {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'shadow'
+      },
+      position: function (point: any, params: any, dom: any, rect: any, size: any) {
+        // 自定义位置逻辑
+        // point: 鼠标位置
+        // params: 数据项参数
+        // dom: tooltip的dom对象
+        // rect: 只有鼠标在图形上时有效,是一个用x, y, width, height四个属性描述的矩形
+        // size: 包含 contentSize(tooltip内容区域的大小)和 viewSize(可视区域的大小)
+        
+        // 示例:将tooltip显示在鼠标上方
+        return [point[0], point[1] - 10];
+        
+        // 其他位置选项:
+        // return 'top'; // 固定在顶部
+        // return 'bottom'; // 固定在底部
+        // return 'left'; // 固定在左侧
+        // return 'right'; // 固定在右侧
+        // return [10, 10]; // 固定坐标位置
+      },
+      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>`
+          }).join('\n')
+        } else {
+          let result = `<div style="text-align: left;">${params[0].name}<br/>`
+          params.forEach((param: any) => {
+            const percentageText = param.data.percentage ? ` (${param.data.percentage})` : ''
+            result += `${param.seriesName}:${param.value}${percentageText}<br/>`
+          })
+          return result + '</div>'
+        }
+      },
+      backgroundColor: 'rgba(255, 255, 255, 0.9)',
+      borderColor: '#e6e6e6',
+      borderWidth: 1,
+      textStyle: {
+        color: '#333'
+      }
+    },
+    legend: props.isMultiSeries ? {
+      data: props.seriesNames,
+      bottom: '0%',
+      textStyle: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 12
+      },
+      icon: 'rect',
+      itemWidth: 8,
+      itemHeight: 8,
+      itemGap: 20,
+      selectedMode: true,
+      itemStyle: {
+        borderWidth: 0,
+      },
+    } : undefined,
+    grid: {
+      left: '0',
+      right: '0',
+      bottom: props.isMultiSeries ? '15%' : '0',
+      top: '3%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'category',
+      data: xAxisData.value,
+      axisLine: {
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14
+      }
+    },
+    yAxis: {
+      type: 'value',
+      name: '次数',
+      nameTextStyle: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14
+      },
+      axisLine: {
+        show: false
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 13
+      },
+      splitLine: {
+        lineStyle: {
+          color: '#f0f0f0',
+          type: 'dashed'
+        }
+      }
+    },
+    series: seriesData.value
+  }
+})
+
+onMounted(() => {
+  // 组件挂载后的初始化逻辑
+})
+
+
+
+// 监听数据变化
+watch(displayData, (newData) => {
+  console.log('Bar chart data updated:', newData)
+}, { deep: true })
+</script>
+
+<style scoped lang="scss">
+.bar-chart-container {
+  width: 100%;
+  height: 100%;
+  text-align: center;
+}
+
+.chart-wrapper {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.chart {
+  width: 100%;
+  height: 100%;
+}
+
+.title {
+  display: inline-block;
+  color: rgba(18, 18, 18, 1);
+  font-family: Source Han Sans SC;
+  font-weight: 400;
+  font-style: Regular;
+  font-size: 14px;
+  padding-left: 14px;
+  position: relative;
+  margin-top: 30px;
+
+  &::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 8px;
+    height: 8px;
+    background-color: rgba(109, 173, 249, 1);
+  }
+}
+</style>

+ 147 - 0
src/views/count/components/BarChartUsageExample.vue

@@ -0,0 +1,147 @@
+<template>
+  <div class="example-container">
+    <h2>柱状图使用示例</h2>
+    
+    <!-- 单系列数据示例 -->
+    <div class="example-section">
+      <h3>单系列数据(有百分比)</h3>
+      <BarChart 
+        :data="singleSeriesData" 
+        title="单系列柱状图"
+        style="height: 300px;"
+      />
+    </div>
+
+    <!-- 单系列数据无百分比示例 -->
+    <div class="example-section">
+      <h3>单系列数据(无百分比)</h3>
+      <BarChart 
+        :data="singleSeriesDataNoPercentage" 
+        title="单系列柱状图(无百分比)"
+        style="height: 300px;"
+      />
+    </div>
+
+    <!-- 多系列数据示例 -->
+    <div class="example-section">
+      <h3>多系列数据对比</h3>
+      <BarChart 
+        :data="multiSeriesData" 
+        :is-multi-series="true"
+        :series-names="['2023年', '2024年']"
+        title="多系列对比柱状图"
+        style="height: 300px;"
+      />
+    </div>
+
+    <!-- 多系列数据部分有百分比示例 -->
+    <div class="example-section">
+      <h3>多系列数据(部分有百分比)</h3>
+      <BarChart 
+        :data="multiSeriesDataMixed" 
+        :is-multi-series="true"
+        :series-names="['产品A', '产品B', '产品C']"
+        title="多系列混合数据柱状图"
+        style="height: 300px;"
+      />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref } from 'vue'
+import BarChart from './BarChart.vue'
+
+// 单系列数据(有百分比)
+const singleSeriesData = ref([
+  { name: '6次以上', value: 45, percentage: '45.0%' },
+  { name: '5次', value: 23, percentage: '23.0%' },
+  { name: '4次', value: 15, percentage: '15.0%' },
+  { name: '3次', value: 8, percentage: '8.0%' },
+  { name: '2次', value: 6, percentage: '6.0%' },
+  { name: '1次', value: 3, percentage: '3.0%' }
+])
+
+// 单系列数据(无百分比)
+const singleSeriesDataNoPercentage = ref([
+  { name: '北京', value: 1200 },
+  { name: '上海', value: 980 },
+  { name: '广州', value: 850 },
+  { name: '深圳', value: 720 },
+  { name: '杭州', value: 650 }
+])
+
+// 多系列数据对比
+const multiSeriesData = ref([
+  {
+    name: 'Q1',
+    values: [
+      { value: 120, percentage: '30.0%' },
+      { value: 140, percentage: '35.0%' }
+    ]
+  },
+  {
+    name: 'Q2',
+    values: [
+      { value: 180, percentage: '45.0%' },
+      { value: 160, percentage: '40.0%' }
+    ]
+  },
+  {
+    name: 'Q3',
+    values: [
+      { value: 100, percentage: '25.0%' },
+      { value: 100, percentage: '25.0%' }
+    ]
+  }
+])
+
+// 多系列数据(部分有百分比)
+const multiSeriesDataMixed = ref([
+  {
+    name: '1月',
+    values: [
+      { value: 100, percentage: '25.0%' },
+      { value: 120 },
+      { value: 80, percentage: '20.0%' }
+    ]
+  },
+  {
+    name: '2月',
+    values: [
+      { value: 150, percentage: '37.5%' },
+      { value: 130 },
+      { value: 120, percentage: '30.0%' }
+    ]
+  },
+  {
+    name: '3月',
+    values: [
+      { value: 150, percentage: '37.5%' },
+      { value: 150 },
+      { value: 200, percentage: '50.0%' }
+    ]
+  }
+])
+</script>
+
+<style scoped lang="scss">
+.example-container {
+  padding: 20px;
+  
+  h2 {
+    color: #333;
+    margin-bottom: 30px;
+  }
+  
+  .example-section {
+    margin-bottom: 40px;
+    
+    h3 {
+      color: #666;
+      margin-bottom: 15px;
+      font-size: 16px;
+    }
+  }
+}
+</style>

+ 219 - 0
src/views/count/components/ExportToCSV.vue

@@ -0,0 +1,219 @@
+<template>
+  <div>
+    <div class="table-container" :style="props.tableStyle">
+      <div class="btn-toggle-table" v-if="!hideToggle" :class="{ 'hide-table': hideTable }" @click="hideTable = !hideTable">
+        {{ hideTable ? '展开' : '收起' }}明细数据<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
+          xmlns="http://www.w3.org/2000/svg">
+          <path d="M10.5 8.75L7 5.25L3.5 8.75" stroke="#167AF0" stroke-width="1.5" stroke-linecap="round"
+            stroke-linejoin="round" />
+        </svg>
+      </div>
+
+      <div v-if="fileName" class="export-button" @click="handleExportData(dataList, columns, fileName)">导出
+        <span style="vertical-align: middle; margin: 0 0 0 8px;">
+          <Svg name="export2"></Svg>
+        </span>
+      </div>
+
+        <div style="overflow-x: auto;">
+          <el-table class="table" :data="paginatedData" row-key="name" style="width: 100%"
+            :cell-style="cellStyle" :header-cell-style="headerCellStyle" v-if="!hideTable">
+            <el-table-column v-for="(column, index) in columns" :key="column.prop" :label="column.label"
+              :prop="column.prop" show-overflow-tooltip :formatter="statusFormatter">
+              <template #default="scope">
+                <!-- 省市列特殊处理 -->
+                <span v-if="column.prop === 'province'" 
+                      class="province-link" 
+                      @click="handleProvinceClick(scope.row[column.prop])">
+                  {{ scope.row[column.prop] }}
+                </span>
+                <!-- 其他列正常显示 -->
+                <span v-else>{{ scope.row[column.prop] }}</span>
+              </template>
+            </el-table-column>
+          </el-table>
+      </div>
+
+      <pagination v-if="!hideTable && !hidePage" @current-change="handleCurrentChange" @size-change="handleSizeChange"
+        v-bind="state.pagination">
+      </pagination>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { BasicTableProps, useTable } from '/@/hooks/table';
+import { ref, reactive, PropType, watch, computed } from 'vue'
+import { exportToExcel, formatTableDataForExport } from '/@/utils/exportExcel';
+import { useMessage } from '/@/hooks/message';
+import Svg from '/@/components/Svg.vue';
+
+const cellStyle = ref({
+  textAlign: 'center',
+  fontSize: '14px',
+  height: '50px',
+  background: 'transparent !important'
+})
+
+const headerCellStyle = ref({
+  textAlign: 'center',
+  border: 'none',
+  background: 'rgba(244, 245, 250, 1)',
+  height: '32px',
+  color: 'rgba(100, 100, 100, 1)',
+  fontSize: '14px',
+})
+
+const props = defineProps({
+  data: {
+    type: Array,
+    default: () => []
+  },
+  columns: {
+    type: Array as PropType<{ prop: string; label: string }[]>,
+    default: () => []
+  },
+  fileName: {
+    type: String,
+    default: ''
+  },
+  hideTable: {
+    type: Boolean,
+    default: true
+  },
+  tableStyle: {
+    type: Object,
+    default: () => ({})
+  },
+  hidePage: {
+    type: Boolean,
+    default: false
+  },
+  hideToggle: {
+    type: Boolean,
+    default: false
+  },
+  size: {
+    type: Number,
+    default: 5
+  }
+})
+
+// 定义 emit 事件
+const emit = defineEmits<{
+  provinceClick: [province: string]
+}>()
+
+// 表格数据
+const dataList = ref(props.data);
+const hideTable = ref(props.hideTable);
+const state: BasicTableProps = reactive<BasicTableProps>({
+  queryForm: {
+    ip: '',
+  },
+  pageList: () => Promise.resolve([]),
+  pagination: {
+    current: 1,
+    size: props.size,
+    total: 0,
+    pageSizes: [5, 10, 20, 50, 100]
+  },
+  // dataList: []
+});
+
+// 计算分页后的数据
+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);
+});
+
+watch(() => props.data, (newVal) => {
+  dataList.value = newVal;
+  console.log(`dataList: `);
+  console.log(dataList.value);
+  state.pagination!.total = dataList.value.length;
+}, { immediate: true, deep: true })
+
+const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state);
+
+// 重写分页处理函数,实现本地分页
+const handleCurrentChange = (val: number) => {
+  state.pagination!.current = val;
+};
+
+const handleSizeChange = (val: number) => {
+  state.pagination!.size = val;
+  state.pagination!.current = 1; // 切换每页条数时重置到第一页
+};
+
+const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
+  return cellValue || '--';
+}
+
+// 处理省市点击事件
+const handleProvinceClick = (province: string) => {
+  emit('provinceClick', province);
+}
+
+const handleExportData = (data: any, columns: any, fileName: string) => {
+  try {
+    // 检查是否有数据
+    if (!data || data.length === 0) {
+      useMessage().warning('没有数据可导出');
+      return;
+    }
+
+    const exportData = formatTableDataForExport(data, columns);
+    exportToExcel(exportData, fileName);
+  } catch (error) {
+    console.error('导出失败:', error);
+    useMessage().error('导出失败,请检查数据格式');
+  }
+}
+
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.table-container {
+  margin: 0 auto;
+  width: 100%;
+  max-width: 1360px;
+}
+
+.export-button {
+  font-size: 14px;
+  font-family: Source Han Sans SC;
+  font-weight: 500;
+  font-size: 14px;
+  color: rgba(22, 122, 240, 1);
+
+  svg {
+    margin-left: 8px;
+    margin-right: 0;
+  }
+}
+
+.province-link {
+  color: rgba(22, 122, 240, 1);
+  cursor: pointer;
+  &:hover {
+    color: rgba(22, 122, 240, 1);
+  }
+}
+
+:deep(.el-input__inner),
+:deep(.el-date-editor--dates .el-input__wrapper) {
+  cursor: pointer;
+}
+</style>

+ 335 - 0
src/views/count/components/HorizontalBarChart.vue

@@ -0,0 +1,335 @@
+<template>
+  <div class="bar-chart-container">
+    <div class="chart-wrapper">
+      <v-chart class="chart" :option="chartOption" :autoresize="true" :style="{height: chartHeight || 300 + 'px'}" />
+    </div>
+    <div v-if="!isMultiSeries" class="title">{{ title }}</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, onMounted, watch } from 'vue'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart } from 'echarts/charts'
+import {
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent
+} from 'echarts/components'
+import VChart from 'vue-echarts'
+
+use([
+  CanvasRenderer,
+  BarChart,
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent
+])
+
+// 支持两种数据格式
+interface BarItem {
+  name: string
+  value: number
+  percentage?: string
+}
+
+interface MultiBarItem {
+  name: string
+  values: Array<{
+    value: number
+    percentage?: string
+    seriesName?: string
+  }>
+}
+
+const props = withDefaults(defineProps<{
+  data?: BarItem[] | MultiBarItem[]
+  title?: string
+  seriesNames?: string[] // 系列名称,用于多系列对比
+  isMultiSeries?: boolean // 是否为多系列数据
+  chartHeight?: number // 图表高度
+}>(), {
+  data: () => [],
+  seriesNames: () => ['系列1', '系列2'],
+  isMultiSeries: false
+})
+
+// 默认数据
+const defaultData: BarItem[] = [
+  // { name: '6次以上', value: 45, percentage: '45.0%' },
+  // { name: '5次', value: 23, percentage: '23.0%' },
+  // { name: '4次', value: 15, percentage: '15.0%' },
+  // { name: '3次', value: 8, percentage: '8.0%' },
+  // { name: '2次', value: 6, percentage: '6.0%' },
+  // { name: '1次', value: 3, percentage: '3.0%' }
+]
+
+const displayData = computed(() => {
+  return props.data && props.data.length > 0 ? props.data : defaultData
+})
+
+// 获取x轴数据
+const xAxisData = computed(() => {
+  return displayData.value.map(item => item.name)
+})
+
+// 生成系列数据
+const seriesData = computed(() => {
+  if (!props.isMultiSeries) {
+    // 单系列数据
+    const singleData = displayData.value as BarItem[]
+    return [{
+      name: '',
+      type: 'bar',
+      data: singleData.map((item, index) => ({
+        value: item.value,
+        percentage: item.percentage || '',
+        name: xAxisData.value[index] || item.name
+      })),
+      itemStyle: {
+        color: {
+          type: 'linear',
+          x: 0,
+          y: 0,
+          x2: 0,
+          y2: 1,
+          colorStops: [
+            { offset: 0, color: 'rgba(22, 122, 240, 1)' },
+            { offset: 1, color: 'rgba(22, 122, 240, 1)' }
+          ]
+        },
+        borderRadius: [0, 0, 0, 0]
+      },
+      emphasis: {
+        itemStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: 'rgba(22, 122, 240, 1)' },
+              { offset: 1, color: 'rgba(22, 122, 240, 1)' }
+            ]
+          }
+        }
+      },
+      barWidth: '14px'
+    }]
+  } else {
+    // 多系列数据
+    const multiData = displayData.value as MultiBarItem[]
+    const colors = [
+      'rgba(22, 122, 240, 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',
+      data: multiData.map((item) => ({
+        value: item.values[index]?.value || 0,
+        percentage: item.values[index]?.percentage || ''
+      })),
+      itemStyle: {
+        color: {
+          type: 'linear',
+          x: 0,
+          y: 0,
+          x2: 0,
+          y2: 1,
+          colorStops: [
+            { offset: 0, color: colors[index % colors.length] },
+            { offset: 1, color: colors[index % colors.length] }
+          ]
+        },
+        borderRadius: [0, 0, 0, 0]
+      },
+      emphasis: {
+        itemStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: colors[index % colors.length] },
+              { offset: 1, color: colors[index % colors.length] }
+            ]
+          }
+        }
+      },
+      barWidth: props.isMultiSeries ? 60 / props.seriesNames.length + 'px' : '54px'
+    }))
+  }
+})
+
+const chartOption = computed(() => {
+  return {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'shadow'
+      },
+      position: function (point: any, params: any, dom: any, rect: any, size: any) {
+        // 自定义位置逻辑
+        // point: 鼠标位置
+        // params: 数据项参数
+        // dom: tooltip的dom对象
+        // rect: 只有鼠标在图形上时有效,是一个用x, y, width, height四个属性描述的矩形
+        // size: 包含 contentSize(tooltip内容区域的大小)和 viewSize(可视区域的大小)
+        
+        // 示例:将tooltip显示在鼠标上方
+        // return [point[0], point[1] - 10];
+        
+        // 其他位置选项:
+        // return 'top'; // 固定在顶部
+        // return 'bottom'; // 固定在底部
+        // return 'left'; // 固定在左侧
+        // return 'right'; // 固定在右侧
+        // return [10, 10]; // 固定坐标位置
+      },
+      formatter: function (params: any) {
+        console.log(params);
+        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>`
+        }).join('\n')
+      },
+      backgroundColor: 'rgba(255, 255, 255, 0.9)',
+      borderColor: '#e6e6e6',
+      borderWidth: 1,
+      textStyle: {
+        color: '#333'
+      }
+    },
+    legend: props.isMultiSeries ? {
+      data: props.seriesNames,
+      bottom: '0%',
+      textStyle: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 12
+      },
+      icon: 'rect',
+      itemWidth: 8,
+      itemHeight: 8,
+      itemGap: 20,
+      selectedMode: true,
+      itemStyle: {
+        borderWidth: 0,
+      },
+    } : undefined,
+    grid: {
+      left: '1%',
+      right: '4%',
+      bottom: props.isMultiSeries ? '15%' : '3%',
+      top: '3%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'value',
+      name: '',
+      nameTextStyle: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14
+      },
+      axisLine: {
+        show: false
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 13
+      },
+      splitLine: {
+        lineStyle: {
+          color: '#f0f0f0',
+          type: 'dashed'
+        }
+      }
+    },
+    yAxis: {
+      type: 'category',
+      data: xAxisData.value,
+      axisLine: {
+        lineStyle: {
+          color: '#e6e6e6'
+        }
+      },
+      axisTick: {
+        show: false
+      },
+      axisLabel: {
+        color: 'rgba(100, 100, 100, 1)',
+        fontSize: 14
+      }
+    },
+    series: seriesData.value
+  }
+})
+
+onMounted(() => {
+  // 组件挂载后的初始化逻辑
+})
+
+
+
+// 监听数据变化
+watch(displayData, (newData) => {
+  console.log('Bar chart data updated:', newData)
+}, { deep: true })
+</script>
+
+<style scoped lang="scss">
+.bar-chart-container {
+  width: 100%;
+  height: 100%;
+  text-align: center;
+}
+
+.chart-wrapper {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.chart {
+  width: 100%;
+  height: 100%;
+}
+
+.title {
+  display: inline-block;
+  color: rgba(18, 18, 18, 1);
+  font-family: Source Han Sans SC;
+  font-weight: 400;
+  font-style: Regular;
+  font-size: 14px;
+  padding-left: 14px;
+  position: relative;
+  margin-top: 30px;
+
+  &::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 8px;
+    height: 8px;
+    background-color: rgba(22, 122, 240, 1);
+  }
+}
+</style>

+ 135 - 41
src/views/churn/overview/echarts/LineChart.vue → src/views/count/components/LineChart.vue

@@ -3,16 +3,24 @@
 </template>
 
 <script lang="ts" setup>
-import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
+import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
 import * as echarts from 'echarts'
 
 interface ChartDataItem {
   date: string
   value: number
 }
+interface ChartMulDataItem {
+  date: string
+  values: Array<{
+    value: number
+    percentage?: string
+    seriesName?: string
+  }>
+}
 
 interface Props {
-  data: ChartDataItem[]
+  data: ChartDataItem[] | ChartMulDataItem[]
   width?: string
   height?: string
   title?: string
@@ -22,6 +30,8 @@ interface Props {
   showLegend?: boolean
   smooth?: boolean
   areaStyle?: boolean
+  isMultiSeries?: boolean
+  seriesNames?: string[]
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -33,7 +43,9 @@ const props = withDefaults(defineProps<Props>(), {
   showTooltip: true,
   showLegend: false,
   smooth: false,
-  areaStyle: false
+  areaStyle: false,
+  isMultiSeries: false,
+  seriesNames: () => ['系列1', '系列2']
 })
 
 const chartRef = ref<HTMLElement>()
@@ -47,12 +59,97 @@ const initChart = () => {
   updateChart()
 }
 
+// 生成系列数据
+const seriesData = computed(() => {
+  if (!props.isMultiSeries) {
+    const values = (props.data as ChartDataItem[]).map(item => item.value)
+    return [
+      {
+        name: props.title || '数据',
+        type: 'line',
+        data: values,
+        smooth: props.smooth,
+        symbol: 'circle',
+        symbolSize: 8,
+        lineStyle: {
+          color: props.color,
+          width: 2
+        },
+        itemStyle: {
+          color: props.color,
+          borderWidth: 2,
+          borderColor: props.color,
+        },
+        emphasis: {
+          itemStyle: {
+            color: props.color,
+            borderWidth: 2,
+            borderColor: props.color,
+          }
+        }
+      }
+    ]
+  } else {
+    const multiData = props.data as ChartMulDataItem[]
+    if (!multiData || multiData.length === 0 || !multiData[0].values) {
+      return []
+    }
+    
+    const colors = [
+      'rgba(22, 122, 240, 1)',
+      'rgba(159, 91, 255, 1)',
+      'rgba(255, 157, 0, 1)',
+      'rgba(40, 167, 69, 1)',
+      'rgba(220, 53, 69, 1)'
+    ]
+    
+    return multiData[0].values.map((_, index) => ({
+      name: props.seriesNames[index] || `系列${index + 1}`,
+      type: 'line',
+      data: multiData.map((item) => item.values[index]?.value || 0),
+      smooth: props.smooth,
+      symbol: 'circle',
+      symbolSize: 8,
+      lineStyle: {
+        color: colors[index % colors.length],
+        width: 2
+      },
+      itemStyle: {
+        color: colors[index % colors.length],
+        borderWidth: 2,
+        borderColor: colors[index % colors.length],
+      },
+      emphasis: {
+        itemStyle: {
+          color: colors[index % colors.length],
+          borderWidth: 2,
+          borderColor: colors[index % colors.length],
+        }
+      },
+    }))
+  }
+})
+
+// 获取图例数据
+const legendData = computed(() => {
+  if (!props.isMultiSeries) {
+    return [props.title || '数据']
+  } else {
+    const multiData = props.data as ChartMulDataItem[]
+    if (!multiData || multiData.length === 0 || !multiData[0].values) {
+      return []
+    }
+    return multiData[0].values.map((_, index) => 
+      props.seriesNames[index] || `系列${index + 1}`
+    )
+  }
+})
+
 // 更新图表数据
 const updateChart = () => {
   if (!chartInstance) return
 
   const dates = props.data.map(item => item.date)
-  const values = props.data.map(item => item.value)
 
   const option: echarts.EChartsOption = {
     tooltip: props.showTooltip ? {
@@ -73,27 +170,49 @@ const updateChart = () => {
         }
       },
       formatter: function(params: any) {
-        const data = params[0]
-        return `<div style="padding: 8px;">
-          <div style="font-weight: 500; margin-bottom: 4px;">${data.name}</div>
-          <div style="color: ${props.color}; font-size: 14px; font-weight: 500;">
-            ${data.seriesName}: ${data.value}
-          </div>
-        </div>`
+        if (props.isMultiSeries) {
+          let html = `<div style="padding: 8px; text-align: left;">
+            <div style="font-weight: 900; margin-bottom: 4px; font-size: 14px;">${params[0].name}</div>`
+          
+          params.forEach((param: any) => {
+            html += `<div style="color: ${param.color}; font-size: 14px; font-weight: 500; margin: 2px 0;">
+              ${param.seriesName}: ${param.value}
+            </div>`
+          })
+          
+          html += '</div>'
+          return html
+        } else {
+          const data = params[0]
+          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}
+            </div>
+          </div>`
+        }
       }
     } : undefined,
     legend: props.showLegend ? {
-      data: [props.title || '数据'],
-      top: '40px',
+      data: legendData.value,
+      bottom: '0px',
       textStyle: {
         fontSize: 12,
         color: '#666'
-      }
+      },
+      icon: 'circle',
+      itemWidth: 8,
+      itemHeight: 8,
+      itemGap: 30,
+      selectedMode: true,
+      itemStyle: {
+        borderWidth: 0
+      },
     } : undefined,
     grid: {
       left: '0%',
       right: '0%',
-      bottom: '0%',
+      bottom: props.showLegend ? '60px' : '0',
       top: '10px',
       containLabel: true
     },
@@ -135,32 +254,7 @@ const updateChart = () => {
         show: false
       }
     },
-    series: [
-      {
-        name: props.title || '数据',
-        type: 'line',
-        data: values,
-        smooth: props.smooth,
-        symbol: 'circle',
-        symbolSize: 8,
-        lineStyle: {
-          color: props.color,
-          width: 2
-        },
-        itemStyle: {
-          color: props.color,
-          borderWidth: 2,
-          borderColor: props.color,
-        },
-        emphasis: {
-          itemStyle: {
-            color: props.color,
-            borderWidth: 2,
-            borderColor: props.color,
-          }
-        }
-      }
-    ]
+    series: seriesData.value as any
   }
 
   chartInstance.setOption(option)

+ 75 - 0
src/views/count/components/LineChartExample.vue

@@ -0,0 +1,75 @@
+<template>
+  <div class="line-chart-example">
+    <h3>多线图表示例</h3>
+    <LineChart 
+      :data="chartData" 
+      :lines="lineConfigs"
+      height="400px"
+      title="销售数据趋势"
+      :show-legend="true"
+      :show-tooltip="true"
+      :show-grid="true"
+    />
+  </div>
+</template>
+
+<script lang="ts" setup>
+import LineChart from './LineChart.vue'
+
+// 图表数据 - 每条线对应一个数据字段
+const chartData = [
+  { date: '2024-01', sales: 1200, profit: 300, cost: 900 },
+  { date: '2024-02', sales: 1500, profit: 400, cost: 1100 },
+  { date: '2024-03', sales: 1800, profit: 500, cost: 1300 },
+  { date: '2024-04', sales: 1600, profit: 450, cost: 1150 },
+  { date: '2024-05', sales: 2200, profit: 600, cost: 1600 },
+  { date: '2024-06', sales: 2500, profit: 700, cost: 1800 },
+  { date: '2024-07', sales: 2800, profit: 800, cost: 2000 },
+  { date: '2024-08', sales: 3000, profit: 900, cost: 2100 },
+  { date: '2024-09', sales: 3200, profit: 1000, cost: 2200 },
+  { date: '2024-10', sales: 3500, profit: 1100, cost: 2400 },
+  { date: '2024-11', sales: 3800, profit: 1200, cost: 2600 },
+  { date: '2024-12', sales: 4000, profit: 1300, cost: 2700 }
+]
+
+// 线条配置 - 定义每条线的样式和行为
+const lineConfigs = [
+  {
+    name: 'sales',        // 对应数据中的字段名
+    color: '#167AF0',     // 线条颜色
+    smooth: true,         // 是否平滑
+    areaStyle: false,     // 是否显示区域填充
+    symbolSize: 8,        // 数据点大小
+    lineWidth: 2          // 线条宽度
+  },
+  {
+    name: 'profit',
+    color: '#52C41A',
+    smooth: true,
+    areaStyle: true,      // 显示区域填充
+    symbolSize: 6,
+    lineWidth: 2
+  },
+  {
+    name: 'cost',
+    color: '#FA8C16',
+    smooth: false,        // 不平滑
+    areaStyle: false,
+    symbolSize: 8,
+    lineWidth: 2
+  }
+]
+</script>
+
+<style scoped lang="scss">
+.line-chart-example {
+  padding: 20px;
+  
+  h3 {
+    margin-bottom: 20px;
+    color: #333;
+    font-size: 18px;
+    font-weight: 500;
+  }
+}
+</style>

+ 69 - 0
src/views/count/components/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> 

+ 81 - 0
src/views/count/device/components/selectForm.vue

@@ -0,0 +1,81 @@
+<template>
+  <el-card class="select-form-card" shadow="none" style="padding: 10px 14px 0;">
+    <div class="card-title no-padding">{{ title }}</div>
+    <div class="select-form">
+      <el-form :inline="true" :model="form" label-width="0">
+        <el-form-item label="">
+          <div class="card-tabs">
+            <div class="card-tab" v-for="item in tabList" :key="item.value" 
+              :class="{ active: form.tab === item.value }" @click="handleTabChange(item.value)"
+            >
+              {{ item.label }}
+            </div>
+          </div>
+        </el-form-item>
+        <el-form-item label="">
+          <el-select v-model="form.version" placeholder="">
+            <el-option label="全部版本" value="all" />
+            <el-option label="1.0.0" value="1" />
+            <el-option label="1.0.1" value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="">
+          <el-select v-model="form.channel" placeholder="">
+            <el-option label="全部渠道" value="all" />
+            <el-option label="应用宝" value="1" />
+            <el-option label="华为应用市场" value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="">
+          <el-date-picker :type="'daterange'" :clearable="false" style="width: 242px;" v-model="form.dateArray"
+            range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" />
+        </el-form-item>
+      </el-form>
+    </div>
+  </el-card>
+</template>
+<script lang="ts" name="selectForm" setup>
+import { ref, PropType } from 'vue'
+
+const props = defineProps({
+  tabList: {
+    type: Array as PropType<{ label: string, value: number }[]>,
+    default: () => []
+  },
+  title: {
+    type: String,
+    default: ''
+  },
+  form: {
+    type: Object as PropType<{
+      tab: number,
+      version: string,
+      channel: string,
+      dateArray: Date[]
+    }>,
+    default: () => ({})
+  }
+})
+
+// const form = ref({
+//   tab: 1,
+//   version: 'all',
+//   channel: 'all',
+//   dateArray: [new Date(new Date().setDate(new Date().getDate() - 7)), new Date()]
+// })
+
+const handleTabChange = (tab: number) => {
+  props.form!.tab = tab;
+}
+
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+.select-form-card {
+  .card-tabs {
+    margin-bottom: 0;
+  }
+}
+</style>

+ 297 - 0
src/views/count/device/device/index.vue

@@ -0,0 +1,297 @@
+<template>
+  <div class="layout-padding device">
+    <el-row :gutter="12" style="padding:0 12px 0; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <!-- <el-card class="select-form-card" shadow="none" style="padding: 10px 14px 0;">
+          <div class="card-title no-padding">设备终端</div>
+          <div class="select-form">
+            <el-form :inline="true" :model="form" label-width="0">
+              <el-form-item label="">
+                <div class="card-tabs">
+                  <div class="card-tab" :class="{ active: form.tab === 1 }" @click="handleTabChange(1)">机型</div>
+                  <div class="card-tab" :class="{ active: form.tab === 2 }" @click="handleTabChange(2)">分辨率</div>
+                  <div class="card-tab" :class="{ active: form.tab === 3 }" @click="handleTabChange(3)">操作系统</div>
+                </div>
+              </el-form-item>
+              <el-form-item label="">
+                <el-select v-model="form.version" placeholder="">
+                  <el-option label="全部版本" value="all" />
+                  <el-option label="1.0.0" value="1" />
+                  <el-option label="1.0.1" value="2" />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="">
+                <el-select v-model="form.channel" placeholder="">
+                  <el-option label="全部渠道" value="all" />
+                  <el-option label="应用宝" value="1" />
+                  <el-option label="华为应用市场" value="2" />
+                </el-select>
+              </el-form-item>
+              <el-form-item label="">
+                <el-date-picker :type="'daterange'" :clearable="false" style="width: 242px;" v-model="form.dateArray" range-separator="至"
+                  start-placeholder="开始日期" end-placeholder="结束日期" />
+              </el-form-item>
+            </el-form>
+          </div>
+        </el-card> -->
+        <SelectForm :tabList="tabList" :title="'设备终端'" :form="form" />
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="12" style="padding: 12px 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="none">
+          <div class="device-container">
+            <div class="title">TOP10机型
+              <el-tooltip 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 fill-rule="evenodd" clip-rule="evenodd"
+                    d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434Z"
+                    fill="white" />
+                  <path
+                    d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434ZM6.99957 2.33322C6.6774 2.33322 6.41623 2.5944 6.41623 2.91656C6.41623 3.23872 6.6774 3.49989 6.99957 3.49989C7.32173 3.49989 7.5829 3.23872 7.5829 2.91656C7.5829 2.5944 7.32173 2.33322 6.99957 2.33322Z"
+                    fill="white" />
+                  <path d="M7.19477 10.8888V5.44434H6.80588H6.41699" fill="white" />
+                  <path
+                    d="M6.41645 10.8892V6.22255C5.98689 6.22255 5.63867 5.87432 5.63867 5.44477C5.63867 5.01522 5.98689 4.66699 6.41645 4.66699H7.19423L7.27398 4.67079C7.66608 4.71071 7.97201 5.04213 7.97201 5.44477V10.8892C7.97201 11.3188 7.70354 11.3452 7.27398 11.3452C6.84443 11.3452 6.41645 11.3188 6.41645 10.8892Z"
+                    fill="white" />
+                  <path
+                    d="M8.55566 10C8.98522 10 9.33344 10.3482 9.33344 10.7778C9.33344 11.2073 8.98522 11.5556 8.55566 11.5556H5.83344C5.40389 11.5556 5.05566 11.2073 5.05566 10.7778C5.05566 10.3482 5.40389 10 5.83344 10H8.55566Z"
+                    fill="white" />
+                </svg>
+                <template #content>
+                  <div style="width: 300px;">
+                    您可以查看在指定时段(1天、7天、30天)内用户{{ wayType[form.tab - 1] }}的分布情况,并可以进行版本、渠道和分群的交叉筛选。<br />筛选只展示昨日及之前的数据,启动次数指标只支持昨日之前的查询。
+                  </div>
+                </template>
+              </el-tooltip>
+            </div>
+            <div class="card-tabs">
+              <div class="card-tab" :class="{ active: echartsTab === 'newUser' }"
+                @click="handleTabClick('newUser')">
+                新增用户
+              </div>
+              <div class="card-tab" :class="{ active: echartsTab === 'startCount' }"
+                @click="handleTabClick('startCount')">启动次数
+              </div>
+            </div>
+            <!-- 横条图 -->
+            <div class="chart-container">
+              <HorizontalBarChart :data="newUserData" :color="'#167af0'" height="270px" :smooth="false" :area-style="true"
+                :title="title" 
+                  :showLegend="true" />
+            </div>
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="none">
+          <div class="device-container">
+            <div class="title">数据明细</div>
+            <div class="export-button">
+              <ExportToCSV :hideToggle="true" :hideTable="true" :data="formatData" :columns="columns" :fileName="title + '- 设备终端'" />
+            </div>
+          </div>
+          <div class="chart-container">
+            <ExportToCSV :size="10" :tableStyle="{width: '100%', maxWidth: '1476px', margin: '0 auto'}" :hideToggle="true" :hideTable="false" :data="formatData" :columns="columns" :fileName="title" />
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+  </div>
+</template>
+
+<script lang="ts" name="device" setup>
+import { ref } from 'vue'
+import HorizontalBarChart from '/@/views/count/components/HorizontalBarChart.vue'
+import { formatDate } from '/@/utils/formatTime'
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue'
+import SelectForm from '../components/selectForm.vue'
+
+const tabList = ref([
+  { label: '机型', value: 1 },
+  { label: '分辨率', value: 2 },
+  { label: '操作系统', value: 3 }
+])
+
+const wayType = ref(['机型', '分辨率', '操作系统'])
+
+const form = ref({
+  tab: 1,
+  version: 'all',
+  channel: 'all',
+  dateArray: [new Date(new Date().setDate(new Date().getDate() - 7)), new Date()]
+})
+
+const echartsTab = ref('newUser');
+
+const handleTabChange = (tab: number) => {
+  form.value.tab = tab;
+}
+
+const handleTabClick = (tab: string) => {
+  echartsTab.value = tab
+}
+
+// 新增用户数据
+const newUserData = ref([
+  { name: 'iPhone 14 Pro', value: 22 },
+  { name: 'iPhone 14', value: 18 },
+  { name: 'iPhone 13 Pro', value: 28 },
+  { name: 'iPhone 13', value: 32 },
+  { name: 'iPhone 12 Pro', value: 38 },
+  { name: 'iPhone 12', value: 42 },
+  { name: 'iPhone 11 Pro', value: 34 },
+])
+
+const title = computed(() => {
+  const startDate = formatDate(new Date(form.value.dateArray[0]), 'YYYY-mm-dd')
+  const endDate = formatDate(new Date(form.value.dateArray[1]), 'YYYY-mm-dd')
+  return echartsTab.value === 'newUser' 
+    ? startDate + ' - ' + endDate + ' 新增用户' 
+    : startDate + ' - ' + endDate + ' 启动次数'
+})
+
+// 定义表格列配置
+const columns = [
+  { prop: 'device', label: '机型' },
+  { prop: 'newUser', label: '新增用户' },
+  { prop: 'newUserPercentage', label: '新增用户占比' },
+  { prop: 'startCount', label: '启动次数' },
+  { prop: 'startCountPercentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(newUserData.value);
+  return newUserData.value.map((item: any) => {
+    return {
+      device: item.name,
+      newUser: item.value,
+      newUserPercentage: `${item.value}%`,
+      startCount: item.value,
+      startCountPercentage: `${item.value}%`
+    }
+  })
+})
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+.device {
+.card-tabs {
+  margin-bottom: 0;
+}
+
+.device-container {
+  padding: 10px 14px;
+  position: relative;
+
+  .title {
+    line-height: 19px;
+    font-weight: 500;
+    font-size: 16px;
+    padding-left: 12px;
+    position: relative;
+    margin-bottom: 40px;
+
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 4px;
+      height: 14px;
+      background: rgba(22, 122, 240, 1);
+    }
+  }
+
+  .export-button {
+    width: 60px;
+    position: absolute;
+    right: 0;
+    top: 0;
+  }
+
+  .card-tabs {
+    width: 100%;
+    max-width: 1476px;
+    margin: 0 auto 0;
+
+    .card-tab {
+      svg {
+        margin: 0;
+      }
+    }
+  }
+
+  .chart-container {
+    // margin: 20px 85px 0;
+    width: 100%;
+    max-width: 1476px;
+    margin: 20px auto 0;
+    text-align: center;
+  }
+
+  .echarts-name {
+    display: inline-block;
+    margin: 28px auto 0;
+    padding-left: 16px;
+    font-weight: 400;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(18, 18, 18, 1);
+    position: relative;
+
+    &::before {
+      content: '';
+      position: absolute;
+      left: 0;
+      top: 50%;
+      transform: translateY(-50%);
+      width: 8px;
+      height: 8px;
+      background: rgba(22, 122, 240, 1);
+      border-radius: 50%;
+    }
+  }
+
+  .table-container {
+    // padding: 0 85px;
+    width: 100%;
+    max-width: 1476px;
+    margin: 0 auto;
+
+    .btn-toggle-table {
+      font-weight: 500;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(22, 122, 240, 1);
+      margin-bottom: 20px;
+      cursor: pointer;
+
+      svg {
+        transition: transform 0.3s ease-in-out;
+      }
+
+      &.hide-table svg {
+        transform: rotate(-180deg);
+      }
+    }
+
+    .table-container {
+      margin-top: 20px;
+    }
+  }
+}
+
+}
+</style>

+ 88 - 0
src/views/count/device/location/dialog.vue

@@ -0,0 +1,88 @@
+<template>
+  <!-- 省市详情弹窗 -->
+  <el-dialog v-model="props.dialogVisible" :title="`详细数据`" width="80%" :before-close="handleClose" destroy-on-close>
+    <div class="province-detail">
+      <div class="header">
+        <ExportToCSV :hideToggle="true" :hideTable="true" :data="formatData" :columns="columns" :fileName="title" />
+      </div>
+      <div class="dialog-container">
+        <ExportToCSV :size="10" :tableStyle="{ width: '100%', maxWidth: '1476px', margin: '0 auto' }"
+          :hideToggle="true" :hideTable="false" :data="formatData" :columns="columns" :fileName="title" />
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script lang="ts" name="device" setup>
+import { ref, computed } from 'vue'
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue'
+
+const props = defineProps({
+  provinceDetail: {
+    type: Array,
+    default: () => []
+  },
+  selectedProvince: {
+    type: String,
+    default: ''
+  },
+  dialogVisible: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const emit = defineEmits(['update:dialogVisible'])
+
+// 关闭弹窗
+const handleClose = () => {
+  emit('update:dialogVisible', false)
+}
+
+const title = computed(() => {
+  return props.selectedProvince + ' 详细数据'
+})
+
+// 定义表格列配置
+const columns = [
+  { prop: 'city', label: '城市' },
+  { prop: 'newUser', label: '新增用户' },
+  { prop: 'newUserPercentage', label: '新增用户占比' },
+  { prop: 'activeUser', label: '活跃用户' },
+  { prop: 'activeUserPercentage', label: '活跃用户占比' },
+  { prop: 'startCount', label: '启动次数' },
+  { prop: 'startCountPercentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(props.provinceDetail);
+  return props.provinceDetail.map((item: any) => {
+    return {
+      city: item.name,
+      newUser: item.value,
+      newUserPercentage: `${item.value}%`,
+      activeUser: item.value,
+      activeUserPercentage: `${item.value}%`,
+      startCount: item.value,
+      startCountPercentage: `${item.value}%`
+    }
+  })
+})
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+.header {
+  height: 30px;
+}
+</style>
+
+<style lang="scss">
+.el-overlay .el-overlay-dialog .el-dialog .el-dialog__body {
+  padding: 0 !important;
+}
+.el-dialog__header {
+  padding-bottom: 0!important;
+}
+</style>

+ 352 - 0
src/views/count/device/location/index.vue

@@ -0,0 +1,352 @@
+<template>
+  <div class="layout-padding device">
+    <el-row :gutter="12" style="padding:0 12px 0; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <SelectForm :tabList="tabList" :title="'地域'" :form="form" />
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="12" style="padding: 12px 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="none">
+          <div class="device-container">
+            <div class="title">TOP10省市
+              <el-tooltip 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 fill-rule="evenodd" clip-rule="evenodd"
+                    d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434Z"
+                    fill="white" />
+                  <path
+                    d="M6.99957 1.94434C7.5365 1.94434 7.97179 2.37962 7.97179 2.91656C7.97179 3.4535 7.5365 3.88878 6.99957 3.88878C6.46263 3.88878 6.02734 3.4535 6.02734 2.91656C6.02734 2.37962 6.46263 1.94434 6.99957 1.94434ZM6.99957 2.33322C6.6774 2.33322 6.41623 2.5944 6.41623 2.91656C6.41623 3.23872 6.6774 3.49989 6.99957 3.49989C7.32173 3.49989 7.5829 3.23872 7.5829 2.91656C7.5829 2.5944 7.32173 2.33322 6.99957 2.33322Z"
+                    fill="white" />
+                  <path d="M7.19477 10.8888V5.44434H6.80588H6.41699" fill="white" />
+                  <path
+                    d="M6.41645 10.8892V6.22255C5.98689 6.22255 5.63867 5.87432 5.63867 5.44477C5.63867 5.01522 5.98689 4.66699 6.41645 4.66699H7.19423L7.27398 4.67079C7.66608 4.71071 7.97201 5.04213 7.97201 5.44477V10.8892C7.97201 11.3188 7.70354 11.3452 7.27398 11.3452C6.84443 11.3452 6.41645 11.3188 6.41645 10.8892Z"
+                    fill="white" />
+                  <path
+                    d="M8.55566 10C8.98522 10 9.33344 10.3482 9.33344 10.7778C9.33344 11.2073 8.98522 11.5556 8.55566 11.5556H5.83344C5.40389 11.5556 5.05566 11.2073 5.05566 10.7778C5.05566 10.3482 5.40389 10 5.83344 10H8.55566Z"
+                    fill="white" />
+                </svg>
+                <template #content>
+                  <div style="width: 300px;">
+                    您可以查看在指定时段(1天、7天、30天)内用户省市的分布情况,并可以进行版本、渠道和分群的交叉筛选。
+                    <br />
+                    筛选只展示昨日及之前的数据,启动次数指标只支持昨日之前的查询。
+                    <br />
+                    如果当日用户在A省启动之后又在B省启动了,分地域查看数据时,此用户在A、B两省都会被算为活跃用户。(按总体查看数据时不受影响)
+                  </div>
+                </template>
+              </el-tooltip>
+            </div>
+            <div class="card-tabs">
+              <div class="card-tab" :class="{ active: echartsTab === 'newUser' }" @click="handleTabClick('newUser')">
+                新增用户
+              </div>
+              <div class="card-tab" :class="{ active: echartsTab === 'activeUser' }"
+                @click="handleTabClick('activeUser')">活跃用户
+              </div>
+              <div class="card-tab" :class="{ active: echartsTab === 'startCount' }"
+                @click="handleTabClick('startCount')">启动次数
+              </div>
+            </div>
+            <!-- 横条图 -->
+            <div class="chart-container">
+              <HorizontalBarChart :data="newUserData" :color="'#167af0'" height="270px" :smooth="false"
+                :area-style="true" :title="title" :showLegend="true" />
+            </div>
+          </div>
+
+          <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+
+          <div class="chart-container">
+            <ExportToCSV :size="10" :tableStyle="{ width: '100%', maxWidth: '1476px', margin: '0 auto' }"
+              :hideTable="false" :data="formatData" :columns="columns" :fileName="title + '- 地域'"
+              @province-click="handleProvinceClick" />
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 省市详情弹窗 -->
+    <ProvinceDetailDialog :dialogVisible="dialogVisible" :selectedProvince="selectedProvince"
+      :provinceDetail="provinceDetail" @update:dialogVisible="dialogVisible = $event" />
+
+  </div>
+</template>
+
+<script lang="ts" name="device" setup>
+import { ref, computed } from 'vue'
+import HorizontalBarChart from '/@/views/count/components/HorizontalBarChart.vue'
+import { formatDate } from '/@/utils/formatTime'
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue'
+import ProvinceDetailDialog from './dialog.vue'
+import SelectForm from '../components/selectForm.vue'
+
+const tabList = ref([
+  { label: '省份', value: 1 },
+  { label: '国家/地区', value: 2 }
+])
+
+const form = ref({
+  tab: 1,
+  version: 'all',
+  channel: 'all',
+  dateArray: [new Date(new Date().setDate(new Date().getDate() - 7)), new Date()]
+})
+
+const echartsTab = ref('newUser');
+
+// 弹窗相关状态
+const dialogVisible = ref(false)
+const selectedProvince = ref('')
+const provinceDetail = ref<any[]>([])
+
+const handleTabClick = (tab: string) => {
+  echartsTab.value = tab
+}
+
+// 处理省市点击事件
+const handleProvinceClick = (province: string) => {
+  selectedProvince.value = province
+  // 根据省市名称查找对应的数据
+  const provinceData = newUserData.value.find(item => item.name === province)
+  if (provinceData) {
+    provinceDetail.value = [
+      { name: '运城市', value: 22 },
+      { name: '大同市', value: 18 },
+      { name: '太原市', value: 28 },
+      { name: '晋城市', value: 32 },
+      { name: '朔州市', value: 38 },
+      { name: '长治市', value: 42 },
+      { name: '晋中市', value: 34 },
+      { name: '临汾市', value: 42 },
+      { name: '忻州市', value: 34 },
+      { name: '阳泉市', value: 42 },
+      { name: '吕梁市', value: 34 },
+    ]
+  }
+  dialogVisible.value = true
+}
+
+// 新增用户数据
+const newUserData = ref([
+  { name: '北京', value: 22 },
+  { name: '上海', value: 18 },
+  { name: '广东', value: 28 },
+  { name: '浙江', value: 32 },
+  { name: '江苏', value: 38 },
+  { name: '山东', value: 42 },
+  { name: '四川', value: 34 },
+])
+
+const title = computed(() => {
+  const startDate = formatDate(new Date(form.value.dateArray[0]), 'YYYY-mm-dd')
+  const endDate = formatDate(new Date(form.value.dateArray[1]), 'YYYY-mm-dd')
+  return echartsTab.value === 'newUser'
+    ? startDate + ' - ' + endDate + ' 新增用户'
+    : echartsTab.value === 'activeUser'
+      ? startDate + ' - ' + endDate + ' 活跃用户'
+      : startDate + ' - ' + endDate + ' 启动次数'
+})
+
+// 定义表格列配置
+const columns = [
+  { prop: 'province', label: '省市' },
+  { prop: 'newUser', label: '新增用户' },
+  { prop: 'newUserPercentage', label: '新增用户占比' },
+  { prop: 'activeUser', label: '活跃用户' },
+  { prop: 'activeUserPercentage', label: '活跃用户占比' },
+  { prop: 'startCount', label: '启动次数' },
+  { prop: 'startCountPercentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(newUserData.value);
+  return newUserData.value.map((item: any) => {
+    return {
+      province: item.name,
+      newUser: item.value,
+      newUserPercentage: `${item.value}%`,
+      activeUser: item.value,
+      activeUserPercentage: `${item.value}%`,
+      startCount: item.value,
+      startCountPercentage: `${item.value}%`
+    }
+  })
+})
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+.device {
+  .device-container {
+    padding: 10px 14px;
+    position: relative;
+
+    .title {
+      line-height: 19px;
+      font-weight: 500;
+      font-size: 16px;
+      padding-left: 12px;
+      position: relative;
+      margin-bottom: 40px;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 4px;
+        height: 14px;
+        background: rgba(22, 122, 240, 1);
+      }
+    }
+
+    .export-button {
+      width: 60px;
+      position: absolute;
+      right: 0;
+      top: 0;
+    }
+
+    .card-tabs {
+      width: 100%;
+      max-width: 1476px;
+      margin: 0 auto 0;
+
+      .card-tab {
+        svg {
+          margin: 0;
+        }
+      }
+    }
+
+    .chart-container {
+      // margin: 20px 85px 0;
+      width: 100%;
+      max-width: 1476px;
+      margin: 20px auto 0;
+      text-align: center;
+    }
+
+    .echarts-name {
+      display: inline-block;
+      margin: 28px auto 0;
+      padding-left: 16px;
+      font-weight: 400;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(18, 18, 18, 1);
+      position: relative;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 8px;
+        height: 8px;
+        background: rgba(22, 122, 240, 1);
+        border-radius: 50%;
+      }
+    }
+
+    .table-container {
+      // padding: 0 85px;
+      width: 100%;
+      max-width: 1476px;
+      margin: 0 auto;
+
+      .btn-toggle-table {
+        font-weight: 500;
+        font-size: 14px;
+        line-height: 20px;
+        color: rgba(22, 122, 240, 1);
+        margin-bottom: 20px;
+        cursor: pointer;
+
+        svg {
+          transition: transform 0.3s ease-in-out;
+        }
+
+        &.hide-table svg {
+          transform: rotate(-180deg);
+        }
+      }
+
+      .table-container {
+        margin-top: 20px;
+      }
+    }
+  }
+
+  // 省市详情弹窗样式
+  .province-detail {
+    .detail-card {
+      text-align: center;
+      padding: 20px;
+      background: #f8f9fa;
+      border-radius: 8px;
+      margin-bottom: 20px;
+
+      .detail-title {
+        font-size: 14px;
+        color: #666;
+        margin-bottom: 10px;
+      }
+
+      .detail-value {
+        font-size: 24px;
+        font-weight: bold;
+        color: #167af0;
+        margin-bottom: 5px;
+      }
+
+      .detail-percentage {
+        font-size: 12px;
+        color: #999;
+      }
+    }
+
+    .detail-chart {
+      margin-top: 30px;
+
+      .chart-title {
+        font-size: 16px;
+        font-weight: 500;
+        margin-bottom: 15px;
+        color: #333;
+      }
+
+      .chart-placeholder {
+        height: 200px;
+        background: #f8f9fa;
+        border: 1px dashed #ddd;
+        border-radius: 8px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        .placeholder-text {
+          color: #999;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+
+  .dialog-footer {
+    .el-button {
+      margin-left: 10px;
+    }
+  }
+
+}
+</style>

+ 285 - 0
src/views/count/device/network/index.vue

@@ -0,0 +1,285 @@
+<template>
+  <div class="layout-padding device">
+    <el-row :gutter="12" style="padding:0 12px 0; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <SelectForm :tabList="tabList" :title="'网络及运营商'" :form="form" />
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="12" style="padding: 12px 12px 12px; row-gap: 12px;">
+      <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+        <el-card shadow="none">
+          <div class="device-container">
+            <div class="title">TOP10联网方式</div>
+            <div class="card-tabs">
+              <div class="card-tab" :class="{ active: echartsTab === 'newUser' }" @click="handleTabClick('newUser')">
+                新增用户
+              </div>
+              <div class="card-tab" :class="{ active: echartsTab === 'startCount' }"
+                @click="handleTabClick('startCount')">启动次数
+              </div>
+            </div>
+            <!-- 横条图 -->
+            <div class="chart-container">
+              <HorizontalBarChart :data="newUserData" :color="'#167af0'" height="270px" :smooth="false"
+                :area-style="true" :title="title" :showLegend="true" />
+            </div>
+          </div>
+          
+          <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+
+          <div class="chart-container">
+            <ExportToCSV :size="10" :tableStyle="{ width: '100%', maxWidth: '1476px', margin: '0 auto' }"
+              :hideTable="false" :data="formatData" :columns="columns" :fileName="title + '- 网络及运营商'" />
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+
+<script lang="ts" name="device" setup>
+import { ref, computed } from 'vue'
+import HorizontalBarChart from '/@/views/count/components/HorizontalBarChart.vue'
+import { formatDate } from '/@/utils/formatTime'
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue'
+import SelectForm from '../components/selectForm.vue'
+
+const tabList = ref([
+  { label: '联网用户', value: 1 },
+  { label: '运营商', value: 2 }
+])
+
+const form = ref({
+  tab: 1,
+  version: 'all',
+  channel: 'all',
+  dateArray: [new Date(new Date().setDate(new Date().getDate() - 7)), new Date()]
+})
+
+const echartsTab = ref('newUser');
+
+const handleTabChange = (tab: number) => {
+  form.value.tab = tab;
+}
+
+const handleTabClick = (tab: string) => {
+  echartsTab.value = tab
+}
+
+// 新增用户数据
+const newUserData = ref([
+  { name: 'WIFI', value: 22 },
+  { name: '4G', value: 18 },
+  { name: '3G', value: 28 },
+  { name: '2G', value: 32 },
+  { name: '其他', value: 38 },
+  { name: '未知', value: 42 },
+])
+
+const title = computed(() => {
+  const startDate = formatDate(new Date(form.value.dateArray[0]), 'YYYY-mm-dd')
+  const endDate = formatDate(new Date(form.value.dateArray[1]), 'YYYY-mm-dd')
+  return echartsTab.value === 'newUser'
+    ? startDate + ' - ' + endDate + ' 新增用户'
+    : startDate + ' - ' + endDate + ' 启动次数'
+})
+
+// 定义表格列配置
+const columns = [
+  { prop: 'network', label: '联网方式' },
+  { prop: 'newUser', label: '新增用户' },
+  { prop: 'newUserPercentage', label: '新增用户占比' },
+  { prop: 'startCount', label: '启动次数' },
+  { prop: 'startCountPercentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(newUserData.value);
+  return newUserData.value.map((item: any) => {
+    return {
+      network: item.name,
+      newUser: item.value,
+      newUserPercentage: `${item.value}%`,
+      startCount: item.value,
+      startCountPercentage: `${item.value}%`
+    }
+  })
+})
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+.device {
+  .card-tabs {
+    margin-bottom: 0;
+  }
+
+  .device-container {
+    padding: 10px 14px;
+    position: relative;
+
+    .title {
+      line-height: 19px;
+      font-weight: 500;
+      font-size: 16px;
+      padding-left: 12px;
+      position: relative;
+      margin-bottom: 40px;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 4px;
+        height: 14px;
+        background: rgba(22, 122, 240, 1);
+      }
+    }
+
+    .export-button {
+      width: 60px;
+      position: absolute;
+      right: 0;
+      top: 0;
+    }
+
+    .card-tabs {
+      width: 100%;
+      max-width: 1476px;
+      margin: 0 auto 0;
+
+      .card-tab {
+        svg {
+          margin: 0;
+        }
+      }
+    }
+
+    .chart-container {
+      // margin: 20px 85px 0;
+      width: 100%;
+      max-width: 1476px;
+      margin: 20px auto 0;
+      text-align: center;
+    }
+
+    .echarts-name {
+      display: inline-block;
+      margin: 28px auto 0;
+      padding-left: 16px;
+      font-weight: 400;
+      font-size: 14px;
+      line-height: 20px;
+      color: rgba(18, 18, 18, 1);
+      position: relative;
+
+      &::before {
+        content: '';
+        position: absolute;
+        left: 0;
+        top: 50%;
+        transform: translateY(-50%);
+        width: 8px;
+        height: 8px;
+        background: rgba(22, 122, 240, 1);
+        border-radius: 50%;
+      }
+    }
+
+    .table-container {
+      // padding: 0 85px;
+      width: 100%;
+      max-width: 1476px;
+      margin: 0 auto;
+
+      .btn-toggle-table {
+        font-weight: 500;
+        font-size: 14px;
+        line-height: 20px;
+        color: rgba(22, 122, 240, 1);
+        margin-bottom: 20px;
+        cursor: pointer;
+
+        svg {
+          transition: transform 0.3s ease-in-out;
+        }
+
+        &.hide-table svg {
+          transform: rotate(-180deg);
+        }
+      }
+
+      .table-container {
+        margin-top: 20px;
+      }
+    }
+  }
+
+  // 省市详情弹窗样式
+  .province-detail {
+    .detail-card {
+      text-align: center;
+      padding: 20px;
+      background: #f8f9fa;
+      border-radius: 8px;
+      margin-bottom: 20px;
+
+      .detail-title {
+        font-size: 14px;
+        color: #666;
+        margin-bottom: 10px;
+      }
+
+      .detail-value {
+        font-size: 24px;
+        font-weight: bold;
+        color: #167af0;
+        margin-bottom: 5px;
+      }
+
+      .detail-percentage {
+        font-size: 12px;
+        color: #999;
+      }
+    }
+
+    .detail-chart {
+      margin-top: 30px;
+
+      .chart-title {
+        font-size: 16px;
+        font-weight: 500;
+        margin-bottom: 15px;
+        color: #333;
+      }
+
+      .chart-placeholder {
+        height: 200px;
+        background: #f8f9fa;
+        border: 1px dashed #ddd;
+        border-radius: 8px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+
+        .placeholder-text {
+          color: #999;
+          font-size: 14px;
+        }
+      }
+    }
+  }
+
+  .dialog-footer {
+    .el-button {
+      margin-left: 10px;
+    }
+  }
+
+}
+</style>

+ 118 - 0
src/views/count/engagement/components/FormEcharts.vue

@@ -0,0 +1,118 @@
+<template>
+  <div class="form-container">
+    <el-form :inline="true" :model="form" label-width="0">
+      <el-form-item label="">
+        <el-select style="width: 106px !important; min-width: 106px !important;" v-model="form.timeCompare"
+          placeholder="时段对比">
+          <el-option label="时段对比" value="time1" />
+        </el-select>
+      </el-form-item>
+      <el-form-item label="">
+        <el-button type="primary" style="background-color: rgba(22, 122, 240, 1); border-color: rgba(22, 122, 240, 1);"
+          @click="openDatePicker">
+          <Svg name="date"></Svg>对比时间
+          <el-date-picker v-if="type === 'date'" class="date-picker" v-model="tempSelectedDates" type="dates" placeholder=""
+            format="YYYY-MM-DD" value-format="YYYY-MM-DD" style="width: 100%; margin-bottom: 16px;"
+            :close-on-click-modal="false" :close-on-press-escape="false" :teleported="false"
+            :popper-class="'date-picker-no-close'" @change="handleDateChange">
+          </el-date-picker>
+        </el-button>
+        <div class="clear-btn" style="display: none;" @click="clearAllDates">清空</div>
+      </el-form-item>
+    </el-form>
+    <div class="average-time">
+      {{ averageValue.time }}:&nbsp;
+      <span>{{ averageValue.value }}</span>
+    </div>
+  </div>
+</template>
+<script setup lang="ts">
+import { formatDate } from '/@/utils/formatTime';
+import { ref, reactive, defineEmits, defineProps } from 'vue'
+import Svg from '/@/components/Svg.vue';
+
+const emit = defineEmits(['dateChange']);
+
+const props = defineProps({
+  averageValue: {
+    type: Object,
+    default: () => ({
+      time: '',
+      value: ''
+    })
+  },
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+// const form = reactive(props.form);
+
+// 临时存储选中的日期,用于弹窗内编辑
+const tempSelectedDates = ref<string[]>([]);
+// 打开弹窗时初始化临时数据
+const openDatePicker = () => { };
+// 清空选中的日期
+const clearAllDates = () => {
+  tempSelectedDates.value = [];
+}
+
+// 日期更新事件
+const handleDateChange = (val: string[]) => {
+  // 触发父组件的日期更新事件
+  emit('dateChange', val);
+}
+
+watch(props.form, (newVal) => {
+  console.log(newVal);
+  const date = formatDate(new Date(newVal.date), 'YYYY-mm-dd');
+  tempSelectedDates.value = [date];
+  handleDateChange([date]);
+}, { deep: true, immediate: true })
+
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+.form-container {
+  position: relative;
+  margin: 53px auto 0px;
+  width: 100%;
+  max-width: 1360px;
+  display: flex;
+  justify-content: space-between;
+
+  .average-time {
+    font-family: Source Han Sans SC;
+    font-weight: 400;
+    font-style: Regular;
+    font-size: 16px;
+    color: rgba(18, 18, 18, 1);
+
+    span {
+      color: rgba(22, 122, 240, 1);
+    }
+  }
+
+  :deep(.el-form-item__content .el-date-editor) {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 106px !important;
+    min-width: 106px !important;
+    height: 100%;
+    opacity: 0;
+  }
+
+  :deep(.el-input__inner),
+  :deep(.el-date-editor--dates .el-input__wrapper) {
+    cursor: pointer;
+  }
+
+}
+</style>

+ 67 - 0
src/views/count/engagement/components/SelectForm.vue

@@ -0,0 +1,67 @@
+<template>
+  <el-card class="select-form-card" shadow="none" style="padding: 10px 14px 0px 14px;">
+    <div class="card-title no-padding">{{ title }}</div>
+    <div class="select-form">
+      <el-form :inline="true" :model="form" label-width="0">
+        <el-form-item label="">
+          <el-date-picker v-if="type === 'date'" :type="type" style="width: 147px; min-width: 147px;" v-model="form.date" placeholder="开始日期" />
+          <el-date-picker v-if="type === 'daterange'" :type="type" style="width: 242px;" v-model="form.dateArray" range-separator="至"
+            start-placeholder="开始日期" end-placeholder="结束日期" />
+        </el-form-item>
+        <el-form-item label="">
+          <el-select v-model="form.channel" placeholder="请选择渠道">
+            <el-option label="全部渠道" value="all" />
+            <el-option label="渠道1" value="1" />
+            <el-option label="渠道2" value="2" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="">
+          <el-select v-model="form.version" placeholder="请选择版本">
+            <el-option label="全部版本" value="all" />
+            <el-option label="版本1" value="1" />
+            <el-option label="版本2" value="2" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  },
+  title: {
+    type: String,
+    default: ''
+  }
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+.select-form-card {
+  .card-title {
+    color: rgba(18, 18, 18, 1);
+  }
+}
+.select-form {
+    margin-top: 20px;
+
+    form {
+      // height: 30px;
+    }
+
+    .el-form-item__content {
+      .el-select {
+        width: 147px !important;
+        min-width: 147px !important;
+      }
+    }
+  }
+</style>

+ 133 - 0
src/views/count/engagement/frequency/Month.vue

@@ -0,0 +1,133 @@
+<template>
+  <el-card shadow="none" style="padding: 10px 14px;">
+    <div class="trend-container">
+      <div class="card-title">
+        月启动次数分布<el-tooltip class="box-item" effect="light" placement="right-start">
+          <template #content>
+            <div style="width: 300px;">
+              您可以查看用户在任意某个自然月的月启动次数的分布情况,同时可以对月启动次数的数据进行版本、渠道、分群的交叉筛选。<br />
+              <span style="color: rgba(22, 122, 240, 1);">月启动次数:</span>(用户)一个自然月内启动应用的次数
+            </div>
+          </template>
+          <span style=" vertical-align: middle; margin: 0 0 0 8px;">
+            <Svg name="export"></Svg>
+          </span>
+        </el-tooltip>
+      </div>
+      <FormEcharts v-if="type === 'date'" @dateChange="handleDateChange" :averageValue="averageValue" :type="type" :form="form" />
+      <div class="chart-container">
+        <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title="多系列对比柱状图"
+          style="height: 300px;" />
+      </div>
+      <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+      <ExportToCSV :data="formatData" :columns="columns" :fileName="'使用时长数据'" />
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import BarChart from '/@/views/count/components/BarChart.vue';
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import FormEcharts from '../components/FormEcharts.vue';
+import Svg from '/@/components/Svg.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const averageValue = ref({
+  time: '平均每月启动次数',
+  value: '1.23'
+})
+
+// 多系列数据对比
+const usageTimeData = ref([
+  {
+    name: '1-2',
+    values: []
+  },
+  {
+    name: '3-5',
+    values: []
+  },
+  {
+    name: '6-10',
+    values: []
+  },
+  {
+    name: '11-20',
+    values: []
+  },
+  {
+    name: '21-30',
+    values: []
+  },
+  {
+    name: '31-40',
+    values: []
+  }
+])
+
+const seriesNames = ref<string[]>([]);
+
+const handleDateChange = (val: string[]) => {
+  // 强制触发响应式更新
+  seriesNames.value = [...val];
+
+  // 创建新的数据对象以确保响应式更新
+  const newData = usageTimeData.value.map((item: any) => ({
+    ...item,
+    values: val.map((date: string) => {
+      const value = Math.floor(Math.random() * 100);
+      return {
+        value: value,
+        percentage: `${value}%`
+      }
+    })
+  }));
+
+  console.log(newData);
+
+  usageTimeData.value = newData;
+}
+
+
+// 定义表格列配置
+const columns = [
+  { prop: 'time', label: '启动次数' },
+  { prop: 'count', label: '用户数' },
+  { prop: 'percentage', label: '用户数比例' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(usageTimeData.value);
+  return usageTimeData.value.map((item: any) => {
+    return {
+      time: item.name,
+      count: item.values[0]?.value || 0,
+      percentage: `${item.values[0]?.value || 0}%`
+    }
+  })
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.chart-container {
+  margin: 0 auto;
+  max-width: 1360px;
+}
+</style>

+ 125 - 0
src/views/count/engagement/frequency/OneDay.vue

@@ -0,0 +1,125 @@
+<template>
+  <el-card shadow="none" style="padding: 10px 14px;">
+    <div class="trend-container">
+      <div class="card-title">
+        日启动次数分布<el-tooltip class="box-item" effect="light" placement="right-start">
+          <template #content>
+            <div style="width: 300px;">
+              您可以查看用户在任意1天内日启动次数的分布情况,同时可以对日启动次数的数据进行版本、渠道、分群的交叉筛选。<br />
+              <span style="color: rgba(22, 122, 240, 1);">日启动次数:</span>(用户)一天内启动应用的次数
+            </div>
+          </template>
+          <span style=" vertical-align: middle; margin: 0 0 0 8px;">
+            <Svg name="export"></Svg>
+          </span>
+        </el-tooltip>
+      </div>
+      <FormEcharts v-if="type === 'date'" @dateChange="handleDateChange" :averageValue="averageValue" :type="type" :form="form" />
+      <div class="chart-container">
+        <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title="多系列对比柱状图"
+          style="height: 300px;" />
+      </div>
+      <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+      <ExportToCSV :data="formatData" :columns="columns" :fileName="'日启动次数分布'" />
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import BarChart from '/@/views/count/components/BarChart.vue';
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import FormEcharts from '../components/FormEcharts.vue';
+import Svg from '/@/components/Svg.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const averageValue = ref({
+  time: '平均每日启动次数',
+  value: '1.23'
+})
+
+// 多系列数据对比
+const usageTimeData = ref([
+  {
+    name: '1-2',
+    values: []
+  },
+  {
+    name: '3-5',
+    values: []
+  },
+  {
+    name: '6-10',
+    values: []
+  },
+  {
+    name: '11-20',
+    values: []
+  }
+])
+
+const seriesNames = ref<string[]>([]);
+
+const handleDateChange = (val: string[]) => {
+  // 强制触发响应式更新
+  seriesNames.value = [...val];
+
+  // 创建新的数据对象以确保响应式更新
+  const newData = usageTimeData.value.map((item: any) => ({
+    ...item,
+    values: val.map((date: string) => {
+      const value = Math.floor(Math.random() * 100);
+      return {
+        value: value,
+        percentage: `${value}%`
+      }
+    })
+  }));
+
+  console.log(newData);
+
+  usageTimeData.value = newData;
+}
+
+
+// 定义表格列配置
+const columns = [
+  { prop: 'time', label: '启动次数' },
+  { prop: 'count', label: '用户数' },
+  { prop: 'percentage', label: '用户数比例' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(usageTimeData.value);
+  return usageTimeData.value.map((item: any) => {
+    return {
+      time: item.name,
+      count: item.values[0]?.value || 0,
+      percentage: `${item.values[0]?.value || 0}%`
+    }
+  })
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.chart-container {
+  margin: 0 auto;
+  max-width: 1360px;
+}
+</style>

+ 133 - 0
src/views/count/engagement/frequency/Week.vue

@@ -0,0 +1,133 @@
+<template>
+  <el-card shadow="none" style="padding: 10px 14px;">
+    <div class="trend-container">
+      <div class="card-title">
+        周启动次数分布<el-tooltip class="box-item" effect="light" placement="right-start">
+          <template #content>
+            <div style="width: 300px;">
+              您可以查看用户在任意某个自然周的周启动次数的分布情况,同时可以对周启动次数的数据进行版本、渠道、分群的交叉筛选。<br />
+              <span style="color: rgba(22, 122, 240, 1);">周启动次数:</span>(用户)一个自然周内启动应用的次数
+            </div>
+          </template>
+          <span style=" vertical-align: middle; margin: 0 0 0 8px;">
+            <Svg name="export"></Svg>
+          </span>
+        </el-tooltip>
+      </div>
+      <FormEcharts v-if="type === 'date'" @dateChange="handleDateChange" :averageValue="averageValue" :type="type" :form="form" />
+      <div class="chart-container">
+        <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title="多系列对比柱状图"
+          style="height: 300px;" />
+      </div>
+      <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+      <ExportToCSV :data="formatData" :columns="columns" :fileName="'使用时长数据'" />
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import BarChart from '/@/views/count/components/BarChart.vue';
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import FormEcharts from '../components/FormEcharts.vue';
+import Svg from '/@/components/Svg.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const averageValue = ref({
+  time: '平均每周启动次数',
+  value: '1.23'
+})
+
+// 多系列数据对比
+const usageTimeData = ref([
+  {
+    name: '1-2',
+    values: []
+  },
+  {
+    name: '3-5',
+    values: []
+  },
+  {
+    name: '6-10',
+    values: []
+  },
+  {
+    name: '11-20',
+    values: []
+  },
+  {
+    name: '21-30',
+    values: []
+  },
+  {
+    name: '31-40',
+    values: []
+  }
+])
+
+const seriesNames = ref<string[]>([]);
+
+const handleDateChange = (val: string[]) => {
+  // 强制触发响应式更新
+  seriesNames.value = [...val];
+
+  // 创建新的数据对象以确保响应式更新
+  const newData = usageTimeData.value.map((item: any) => ({
+    ...item,
+    values: val.map((date: string) => {
+      const value = Math.floor(Math.random() * 100);
+      return {
+        value: value,
+        percentage: `${value}%`
+      }
+    })
+  }));
+
+  console.log(newData);
+
+  usageTimeData.value = newData;
+}
+
+
+// 定义表格列配置
+const columns = [
+  { prop: 'time', label: '启动次数' },
+  { prop: 'count', label: '用户数' },
+  { prop: 'percentage', label: '用户数比例' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(usageTimeData.value);
+  return usageTimeData.value.map((item: any) => {
+    return {
+      time: item.name,
+      count: item.values[0]?.value || 0,
+      percentage: `${item.values[0]?.value || 0}%`
+    }
+  })
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.chart-container {
+  margin: 0 auto;
+  max-width: 1360px;
+}
+</style>

+ 59 - 0
src/views/count/engagement/frequency/index.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="layout-padding">
+    <div class="engagement-time">
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <SelectForm :type="type" :form="form" :title="'使用频率'" />
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <OneDay :type="type" :form="form" />
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <Week :type="type" :form="form" />
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <Month :type="type" :form="form" />
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" name="engagementTime" setup>
+import { reactive } from 'vue'
+import OneDay from './OneDay.vue';
+import Week from './Week.vue';
+import Month from './Month.vue';
+import SelectForm from '../components/SelectForm.vue';
+
+const type = ref('date');
+
+const form = reactive({
+  dateArray: [new Date().getTime() - 1000 * 60 * 60 * 24 * 7, new Date().getTime()],
+  date: new Date().getTime(),
+  selectedDates: [] as string[], // 存储多个选中的日期
+  timeCompare: 'time1',
+  channel: 'all',
+  version: 'all'
+})
+
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.engagement-time {
+
+}
+
+</style>

+ 142 - 0
src/views/count/engagement/interval/Interval.vue

@@ -0,0 +1,142 @@
+<template>
+  <el-card shadow="none" style="padding: 10px 14px;">
+    <div class="trend-container">
+      <div class="card-title">
+        使用间隔分布<el-tooltip class="box-item" effect="light" placement="right-start">
+          <template #content>
+            <div style="width: 300px;">
+              您可以查看任意30天内用户相邻两次启动间隔的分布情况,并可以进行版本、渠道的筛选。<br />
+              <span style="color: rgba(22, 122, 240, 1);">使用间隔:</span>同一用户相邻两次启动间隔的时间长度。在固定的查询时段内,若用户A仅在第2天、第3天、第7天启动过应用,则“1天”和“4天”的计数分别加1;若用户B仅在第4天启动过三次应用,则“0-24h”的计数加2;若用户C仅第10天启动过一次应用,则“首次”的计数加1<br />
+              <span style="color: rgba(22, 122, 240, 1);">首次:</span>在固定的查询时段内只启动过一次的用户记为首次
+            </div>
+          </template>
+          <span style=" vertical-align: middle; margin: 0 0 0 8px;">
+            <Svg name="export"></Svg>
+          </span>
+        </el-tooltip>
+      </div>
+      <FormEcharts v-if="type === 'date'" @dateChange="handleDateChange" :averageValue="averageValue" />
+      <div class="chart-container">
+        <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title="多系列对比柱状图"
+          style="height: 300px;" />
+      </div>
+      <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+      <ExportToCSV :data="formatData" :columns="columns" :fileName="'使用间隔'" :hideTable="false" />
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import BarChart from '/@/views/count/components/BarChart.vue';
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import FormEcharts from '../components/FormEcharts.vue';
+import Svg from '/@/components/Svg.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const averageValue = ref({
+  time: '',
+  value: ''
+})
+
+// 多系列数据对比
+const usageTimeData = ref([
+  {
+    name: '首次',
+    values: []
+  },
+  {
+    name: '0-24h',
+    values: []
+  },
+  {
+    name: '1-2天',
+    values: []
+  },
+  {
+    name: '2-3天',
+    values: []
+  },
+  {
+    name: '3-4天',
+    values: []
+  },
+  {
+    name: '4-5天',
+    values: []
+  },
+  {
+    name: '5-6天',
+    values: []
+  },
+])
+
+const seriesNames = ref<string[]>([]);
+
+const handleDateChange = (val: string[]) => {
+  // 强制触发响应式更新
+  seriesNames.value = [...val];
+
+  // 创建新的数据对象以确保响应式更新
+  const newData = usageTimeData.value.map((item: any) => ({
+    ...item,
+    values: val.map((date: string) => {
+      const value = Math.floor(Math.random() * 100);
+      return {
+        value: value,
+        percentage: `${value}%`
+      }
+    })
+  }));
+
+  console.log(newData);
+
+  usageTimeData.value = newData;
+}
+
+
+// 定义表格列配置
+const columns = [
+  { prop: 'time', label: '使用间隔' },
+  { prop: 'count', label: '启动次数' },
+  { prop: 'percentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(usageTimeData.value);
+  return usageTimeData.value.map((item: any) => {
+    return {
+      time: item.name,
+      count: item.values[0]?.value || 0,
+      percentage: `${item.values[0]?.value || 0}%`
+    }
+  })
+})
+
+defineExpose({
+  handleDateChange
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.chart-container {
+  margin: 0 auto;
+  max-width: 1360px;
+}
+</style>

+ 76 - 0
src/views/count/engagement/interval/index.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="layout-padding">
+    <div class="engagement-time">
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <SelectForm :type="type" :form="form" :title="'使用间隔'" />
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <Interval :type="type" :form="form" ref="intervalRef" />
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" name="engagementTime" setup>
+import { reactive } from 'vue'
+import Interval from './Interval.vue';
+import SelectForm from '../components/SelectForm.vue';
+import { formatDate } from '/@/utils/formatTime';
+
+const type = ref('daterange');
+
+const form = reactive({
+  dateArray: [new Date().getTime() - 1000 * 60 * 60 * 24 * 7, new Date().getTime()],
+  date: new Date().getTime(),
+  selectedDates: [] as string[], // 存储多个选中的日期
+  timeCompare: 'time1',
+  channel: 'all',
+  version: 'all'
+})
+const intervalRef = ref<InstanceType<typeof Interval>>();
+
+watch(form, (newVal) => {
+  console.log([`${formatDate(new Date(newVal.dateArray[0]), 'YYYY-mm-dd')} - ${formatDate(new Date(newVal.dateArray[1]), 'YYYY-mm-dd')}`]);
+  nextTick(() => {
+    intervalRef.value?.handleDateChange([`${formatDate(new Date(newVal.dateArray[0]), 'YYYY-mm-dd')} - ${formatDate(new Date(newVal.dateArray[1]), 'YYYY-mm-dd')}`]);
+  })
+}, { deep: true, immediate: true })
+
+onMounted(() => {
+  nextTick(() => {
+    intervalRef.value?.handleDateChange([`${formatDate(new Date(form.dateArray[0]), 'YYYY-mm-dd')} - ${formatDate(new Date(form.dateArray[1]), 'YYYY-mm-dd')}`]);
+  })
+})
+
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.engagement-time {
+
+  .select-form {
+    margin-top: 20px;
+
+    form {
+      // height: 30px;
+    }
+
+    .el-form-item__content {
+      .el-select {
+        width: 147px !important;
+        min-width: 147px !important;
+      }
+    }
+  }
+}
+
+</style>

+ 130 - 0
src/views/count/engagement/pages/View.vue

@@ -0,0 +1,130 @@
+<template>
+  <el-card shadow="none" style="padding: 10px 14px;">
+    <div class="trend-container">
+      <div class="card-title">
+        访问页面分布<el-tooltip class="box-item" effect="light" placement="right-start">
+          <template #content>
+            <div style="width: 300px;">
+              您可以查看用户在指定时段(1天、7天、30天)内访问页面数的分布情况,并可以进行版本、渠道和分群的交叉筛选。如果您在Android应用中使用了Fragment页面统计功能,这里的页面包括您指定统计的Activity和Fragment。<br />
+              <span style="color: rgba(22, 122, 240, 1);">访问页面:</span>用户一次启动内访问的页面数。若用户在一次访问中先后访问了页面A、页面B、页面A,则用户本次启动的访问页面数为3
+            </div>
+          </template>
+          <span style=" vertical-align: middle; margin: 0 0 0 8px;">
+            <Svg name="export"></Svg>
+          </span>
+        </el-tooltip>
+      </div>
+      <FormEcharts v-if="type === 'date'" @dateChange="handleDateChange" :averageValue="averageValue" />
+      <div class="chart-container">
+        <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title="多系列对比柱状图"
+          style="height: 300px;" />
+      </div>
+      <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+      <ExportToCSV :data="formatData" :columns="columns" :fileName="'访问页面'" :hideTable="false" />
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import BarChart from '/@/views/count/components/BarChart.vue';
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import FormEcharts from '../components/FormEcharts.vue';
+import Svg from '/@/components/Svg.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const averageValue = ref({
+  time: '平均访问页面数',
+  value: '1.23'
+})
+
+// 多系列数据对比
+const usageTimeData = ref([
+  {
+    name: '1-2',
+    values: []
+  },
+  {
+    name: '3-5',
+    values: []
+  },
+  {
+    name: '6-10',
+    values: []
+  },
+  {
+    name: '11-20',
+    values: []
+  }
+])
+
+const seriesNames = ref<string[]>([]);
+
+const handleDateChange = (val: string[]) => {
+  console.log(val);
+  // 强制触发响应式更新
+  seriesNames.value = [...val];
+
+  // 创建新的数据对象以确保响应式更新
+  const newData = usageTimeData.value.map((item: any) => ({
+    ...item,
+    values: val.map((date: string) => {
+      const value = Math.floor(Math.random() * 100);
+      return {
+        value: value,
+        percentage: `${value}%`
+      }
+    })
+  }));
+
+  console.log(newData);
+
+  usageTimeData.value = newData;
+}
+
+
+// 定义表格列配置
+const columns = [
+  { prop: 'time', label: '访问页面' },
+  { prop: 'count', label: '启动次数' },
+  { prop: 'percentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(usageTimeData.value);
+  return usageTimeData.value.map((item: any) => {
+    return {
+      time: item.name,
+      count: item.values[0]?.value || 0,
+      percentage: `${item.values[0]?.value || 0}%`
+    }
+  })
+})
+
+defineExpose({
+  handleDateChange
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.chart-container {
+  margin: 0 auto;
+  max-width: 1360px;
+}
+</style>

+ 63 - 0
src/views/count/engagement/pages/index.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="layout-padding">
+    <div class="engagement-time">
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <SelectForm :type="type" :form="form" :title="'访问页面'" />
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <View :type="type" :form="form" ref="viewRef" />
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" name="engagementTime" setup>
+import { reactive } from 'vue'
+import View from './View.vue';
+import SelectForm from '../components/SelectForm.vue';
+import { formatDate } from '/@/utils/formatTime';
+
+const type = ref('daterange');
+
+const form = reactive({
+  dateArray: [new Date().getTime() - 1000 * 60 * 60 * 24 * 7, new Date().getTime()],
+  date: new Date().getTime(),
+  selectedDates: [] as string[], // 存储多个选中的日期
+  timeCompare: 'time1',
+  channel: 'all',
+  version: 'all'
+})
+
+const viewRef = ref<InstanceType<typeof View>>();
+
+watch(form, (newVal) => {
+  console.log([`${formatDate(new Date(newVal.dateArray[0]), 'YYYY-mm-dd')} - ${formatDate(new Date(newVal.dateArray[1]), 'YYYY-mm-dd')}`]);
+  nextTick(() => {
+    viewRef.value?.handleDateChange([`${formatDate(new Date(newVal.dateArray[0]), 'YYYY-mm-dd')} - ${formatDate(new Date(newVal.dateArray[1]), 'YYYY-mm-dd')}`]);
+  })
+}, { deep: true, immediate: true })
+
+onMounted(() => {
+  nextTick(() => {
+    viewRef.value?.handleDateChange([`${formatDate(new Date(form.dateArray[0]), 'YYYY-mm-dd')} - ${formatDate(new Date(form.dateArray[1]), 'YYYY-mm-dd')}`]);
+  })
+})
+
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.engagement-time {
+
+}
+
+</style>

+ 133 - 0
src/views/count/engagement/time/OneDay.vue

@@ -0,0 +1,133 @@
+<template>
+  <el-card shadow="none" style="padding: 10px 14px;">
+    <div class="trend-container">
+      <div class="card-title">
+        日使用时长分布<el-tooltip class="box-item" effect="light" placement="right-start">
+          <template #content>
+            <div style="width: 300px;">
+              您可以查看用户在任意1天内日使用时长的分布情况,同时可以对日使用时长的数据进行版本、渠道、分群的交叉筛选。<br />
+              <span style="color: rgba(22, 122, 240, 1);">日使用时长:</span>(用户)一天内使用应用的时长
+            </div>
+          </template>
+          <span style=" vertical-align: middle; margin: 0 0 0 8px;">
+            <Svg name="export"></Svg>
+          </span>
+        </el-tooltip>
+      </div>
+      <FormEcharts v-if="type === 'date'" @dateChange="handleDateChange" :averageValue="averageValue" :type="type" :form="form" />
+      <div class="chart-container">
+        <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title="多系列对比柱状图"
+          style="height: 300px;" />
+      </div>
+      <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+      <ExportToCSV :data="formatData" :columns="columns" :fileName="'使用时长数据'" />
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import BarChart from '/@/views/count/components/BarChart.vue';
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import FormEcharts from '../components/FormEcharts.vue';
+import Svg from '/@/components/Svg.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const averageValue = ref({
+  time: '平均日使用时长',
+  value: '00:01:17'
+})
+
+// 多系列数据对比
+const usageTimeData = ref([
+  {
+    name: '1秒-3秒',
+    values: []
+  },
+  {
+    name: '4秒-10秒',
+    values: []
+  },
+  {
+    name: '11秒-30秒',
+    values: []
+  },
+  {
+    name: '31秒-1分',
+    values: []
+  },
+  {
+    name: '1分-3分',
+    values: []
+  },
+  {
+    name: '3分-10分',
+    values: []
+  }
+])
+
+const seriesNames = ref<string[]>([]);
+
+const handleDateChange = (val: string[]) => {
+  // 强制触发响应式更新
+  seriesNames.value = [...val];
+
+  // 创建新的数据对象以确保响应式更新
+  const newData = usageTimeData.value.map((item: any) => ({
+    ...item,
+    values: val.map((date: string) => {
+      const value = Math.floor(Math.random() * 100);
+      return {
+        value: value,
+        percentage: `${value}%`
+      }
+    })
+  }));
+
+  console.log(newData);
+
+  usageTimeData.value = newData;
+}
+
+
+// 定义表格列配置
+const columns = [
+  { prop: 'time', label: '时长' },
+  { prop: 'count', label: '启动次数' },
+  { prop: 'percentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(usageTimeData.value);
+  return usageTimeData.value.map((item: any) => {
+    return {
+      time: item.name,
+      count: item.values[0]?.value || 0,
+      percentage: `${item.values[0]?.value || 0}%`
+    }
+  })
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.chart-container {
+  margin: 0 auto;
+  max-width: 1360px;
+}
+</style>

+ 132 - 0
src/views/count/engagement/time/Single.vue

@@ -0,0 +1,132 @@
+<template>
+  <el-card shadow="none" style="padding: 10px 14px;">
+    <div class="trend-container">
+      <div class="card-title">
+        单次使用时长分布<el-tooltip class="box-item" effect="light" placement="right-start">
+          <template #content>
+            <div style="width: 300px;">
+              您可以查看用户在任意1天内单次使用时长的分布情况,同时可以对单次使用时长的数据进行版本、渠道、分群的交叉筛选。<br />
+              <span style="color: rgba(22, 122, 240, 1);">单次使用时长:</span>一次启动的使用时长
+            </div>
+          </template>
+          <span style=" vertical-align: middle; margin: 0 0 0 8px;">
+            <Svg name="export"></Svg>
+          </span>
+        </el-tooltip>
+      </div>
+      <FormEcharts v-if="type === 'date'" @dateChange="handleDateChange" :averageValue="averageValue" :type="type" :form="form" />
+      <div class="chart-container">
+        <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title="多系列对比柱状图"
+          style="height: 300px;" />
+      </div>
+      <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);" />
+      <ExportToCSV :data="formatData" :columns="columns" :fileName="'使用时长数据'" />
+    </div>
+  </el-card>
+</template>
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import BarChart from '/@/views/count/components/BarChart.vue';
+import ExportToCSV from '/@/views/count/components/ExportToCSV.vue';
+import FormEcharts from '../components/FormEcharts.vue';
+import Svg from '/@/components/Svg.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const averageValue = ref({
+  time: '平均单次使用时长',
+  value: '00:01:17'
+})
+
+// 多系列数据对比
+const usageTimeData = ref([
+  {
+    name: '1秒-3秒',
+    values: []
+  },
+  {
+    name: '4秒-10秒',
+    values: []
+  },
+  {
+    name: '11秒-30秒',
+    values: []
+  },
+  {
+    name: '31秒-1分',
+    values: []
+  },
+  {
+    name: '1分-3分',
+    values: []
+  },
+  {
+    name: '3分-10分',
+    values: []
+  }
+])
+
+const seriesNames = ref<string[]>([]);
+
+const handleDateChange = (val: string[]) => {
+  // 强制触发响应式更新
+  seriesNames.value = [...val];
+
+  // 创建新的数据对象以确保响应式更新
+  const newData = usageTimeData.value.map((item: any) => ({
+    ...item,
+    values: val.map((date: string) => {
+      const value = Math.floor(Math.random() * 100);
+      return {
+        value: value,
+        percentage: `${value}%`
+      }
+    })
+  }));
+
+  console.log(newData);
+
+  usageTimeData.value = newData;
+}
+
+// 定义表格列配置
+const columns = [
+  { prop: 'time', label: '时长' },
+  { prop: 'count', label: '启动次数' },
+  { prop: 'percentage', label: '启动次数占比' }
+];
+
+const formatData = computed(() => {
+  console.log(`formatData: `);
+  console.log(usageTimeData.value);
+  return usageTimeData.value.map((item: any) => {
+    return {
+      time: item.name,
+      count: item.values[0]?.value || 0,
+      percentage: `${item.values[0]?.value || 0}%`
+    }
+  })
+})
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.chart-container {
+  margin: 0 auto;
+  max-width: 1360px;
+}
+</style>

+ 53 - 0
src/views/count/engagement/time/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="layout-padding">
+    <div class="engagement-time">
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <SelectForm :type="type" :form="form" :title="'使用时长'" />
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <Single :type="type" :form="form" />
+        </el-col>
+      </el-row>
+      <el-row :gutter="12" style="padding: 0 12px 12px; row-gap: 12px;">
+        <el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
+          <OneDay :type="type" :form="form" />
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" name="engagementTime" setup>
+import { reactive } from 'vue'
+import OneDay from './OneDay.vue';
+import Single from './Single.vue';
+import SelectForm from '../components/SelectForm.vue';
+
+const type = ref('date');
+
+const form = reactive({
+  dateArray: [new Date().getTime() - 1000 * 60 * 60 * 24 * 7, new Date().getTime()],
+  date: new Date().getTime(),
+  selectedDates: [] as string[], // 存储多个选中的日期
+  timeCompare: 'time1',
+  channel: 'all',
+  version: 'all'
+})
+
+</script>
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+svg {
+  vertical-align: middle;
+  margin: 0 0 0 12px;
+}
+
+.engagement-time {
+
+}
+
+</style>

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

@@ -0,0 +1,121 @@
+.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;
+    border-right: none;
+    &.active {
+      background: rgba(22, 122, 240, 1);
+      color: #ffffff;
+    }
+  }
+  .card-tab:last-child {
+    border-right: 1px solid rgba(22, 122, 240, 1);
+  }
+}
+
+.card-title {
+  line-height: 19px;
+  font-size: 16px;
+  padding-left: 12px;
+  position: relative;
+  margin-bottom: 23px;
+  font-weight: 500;
+  font-family: Source Han Sans SC;
+
+  svg {
+    // line-height: 19px;
+    // height: 19px;
+    // vertical-align: middle;
+  }
+
+  &::before {
+    content: '';
+    position: absolute;
+    left: 0;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 4px;
+    height: 14px;
+    background: rgba(22, 122, 240, 1);
+  }
+
+  &.no-padding {
+    padding-left: 0;
+
+    &::before {
+      display: none;
+    }
+  }
+} 
+.table-container {
+  position: relative;
+  .btn-toggle-table {
+    font-weight: 500;
+    font-size: 14px;
+    line-height: 20px;
+    color: rgba(22, 122, 240, 1);
+    margin-bottom: 20px;
+    cursor: pointer;
+    svg {
+      transition: transform 0.3s ease-in-out;
+    }
+    &.hide-table svg {
+      transform: rotate(-180deg);
+    }
+  }
+  .export-button {
+    position: absolute;
+    right: 0;
+    top: 0;
+    color: rgba(22, 122, 240, 1);
+    font-weight: 500;
+    font-size: 14px;
+    cursor: pointer;
+    svg {
+      margin-left: 8px;
+      margin-right: 0;
+    }
+  }
+}

Some files were not shown because too many files changed in this diff