浏览代码

Merge remote-tracking branch 'origin/dev-ly' into dev-jcq

jcq 6 天之前
父节点
当前提交
c2f404ae3e
共有 70 个文件被更改,包括 10395 次插入1056 次删除
  1. 1 0
      .env
  2. 84 0
      EXPORT_FEATURE_README.md
  3. 94 0
      IMPLEMENTATION_SUMMARY.md
  4. 8 0
      package-lock.json
  5. 1 0
      package.json
  6. 128 0
      src/api/count/churn.ts
  7. 76 0
      src/components/LYcom/Lprogress/index.vue
  8. 52 0
      src/components/Svg.vue
  9. 4 1
      src/layout/navMenu/horizontal.vue
  10. 0 6
      src/layout/navMenu/navIcon.vue
  11. 5 1
      src/layout/navMenu/vertical.vue
  12. 26 2
      src/theme/app.scss
  13. 95 0
      src/utils/exportExcel.ts
  14. 0 223
      src/views/churn/behavior/echarts/BarChart.vue
  15. 0 164
      src/views/churn/behavior/index.vue
  16. 0 165
      src/views/churn/overview/echarts/DoughnutChart.vue
  17. 0 428
      src/views/churn/overview/index.vue
  18. 393 0
      src/views/count/channel/allChannel.vue
  19. 10 0
      src/views/count/channel/index.vue
  20. 1113 0
      src/views/count/channel/otherChannel.vue
  21. 381 0
      src/views/count/churn/ascribe/index.vue
  22. 234 0
      src/views/count/churn/behavior/components/AfterUninstallStatus.vue
  23. 161 0
      src/views/count/churn/behavior/components/BeforeUninstallStatus.vue
  24. 323 0
      src/views/count/churn/behavior/components/HorizontalBarChart.vue
  25. 271 0
      src/views/count/churn/behavior/components/MindChart.vue
  26. 41 21
      src/views/count/churn/behavior/components/PieChart.vue
  27. 51 0
      src/views/count/churn/behavior/components/SystemDistribution.vue
  28. 169 0
      src/views/count/churn/behavior/components/SystemItem.vue
  29. 167 0
      src/views/count/churn/behavior/index.vue
  30. 123 0
      src/views/count/churn/overview/ProgressCard.vue
  31. 406 0
      src/views/count/churn/overview/index.vue
  32. 89 0
      src/views/count/churn/portrait/components/DataTable.vue
  33. 119 0
      src/views/count/churn/portrait/components/UserInfo.vue
  34. 320 0
      src/views/count/churn/portrait/index.vue
  35. 337 0
      src/views/count/components/BarChart.vue
  36. 147 0
      src/views/count/components/BarChartUsageExample.vue
  37. 218 0
      src/views/count/components/ExportToCSV.vue
  38. 334 0
      src/views/count/components/HorizontalBarChart.vue
  39. 79 0
      src/views/count/components/LayoutHeader.vue
  40. 135 41
      src/views/count/components/LineChart.vue
  41. 75 0
      src/views/count/components/LineChartExample.vue
  42. 69 0
      src/views/count/components/ProgressRing.vue
  43. 82 0
      src/views/count/device/components/selectForm.vue
  44. 265 0
      src/views/count/device/device/index.vue
  45. 88 0
      src/views/count/device/location/dialog.vue
  46. 351 0
      src/views/count/device/location/index.vue
  47. 285 0
      src/views/count/device/network/index.vue
  48. 118 0
      src/views/count/engagement/components/FormEcharts.vue
  49. 75 0
      src/views/count/engagement/components/SelectForm.vue
  50. 133 0
      src/views/count/engagement/frequency/Month.vue
  51. 125 0
      src/views/count/engagement/frequency/OneDay.vue
  52. 133 0
      src/views/count/engagement/frequency/Week.vue
  53. 59 0
      src/views/count/engagement/frequency/index.vue
  54. 142 0
      src/views/count/engagement/interval/Interval.vue
  55. 76 0
      src/views/count/engagement/interval/index.vue
  56. 130 0
      src/views/count/engagement/pages/View.vue
  57. 63 0
      src/views/count/engagement/pages/index.vue
  58. 133 0
      src/views/count/engagement/time/OneDay.vue
  59. 132 0
      src/views/count/engagement/time/Single.vue
  60. 53 0
      src/views/count/engagement/time/index.vue
  61. 189 0
      src/views/count/featureUsage/eventMannage/AddEventModal.vue
  62. 184 0
      src/views/count/featureUsage/eventMannage/BatchImportModal.vue
  63. 511 0
      src/views/count/featureUsage/eventMannage/EventEdit.vue
  64. 184 0
      src/views/count/featureUsage/eventMannage/EventPropEditModal.vue
  65. 399 0
      src/views/count/featureUsage/eventMannage/EventTable.vue
  66. 10 0
      src/views/count/featureUsage/eventMannage/index.vue
  67. 9 0
      src/views/count/main/trend/icons/icon1.svg
  68. 1 0
      src/views/count/main/trend/icons/icon2.svg
  69. 5 4
      src/views/count/main/trend/index.vue
  70. 121 0
      src/views/count/styles/common.scss

+ 1 - 0
.env

@@ -5,6 +5,7 @@ VITE_IS_MICRO= true
 VITE_PUBLIC_PATH = /
 VITE_PUBLIC_PATH = /
 
 
 # 后端请求前缀
 # 后端请求前缀
+# VITE_API_URL = http://192.168.3.118:9999
 VITE_API_URL = http://192.168.10.101:9999
 VITE_API_URL = http://192.168.10.101:9999
 # VITE_API_URL = http://192.168.3.17:9999
 # VITE_API_URL = http://192.168.3.17:9999
 
 

+ 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/node": "20.0.0",
 				"@types/nprogress": "0.2.3",
 				"@types/nprogress": "0.2.3",
 				"@types/sortablejs": "1.15.8",
 				"@types/sortablejs": "1.15.8",
+				"@types/xlsx": "^0.0.35",
 				"@typescript-eslint/eslint-plugin": "8.17.0",
 				"@typescript-eslint/eslint-plugin": "8.17.0",
 				"@typescript-eslint/parser": "8.17.0",
 				"@typescript-eslint/parser": "8.17.0",
 				"@vitejs/plugin-vue": "5.2.1",
 				"@vitejs/plugin-vue": "5.2.1",
@@ -1510,6 +1511,13 @@
 			"integrity": "sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==",
 			"integrity": "sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==",
 			"license": "MIT"
 			"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": {
 		"node_modules/@typescript-eslint/eslint-plugin": {
 			"version": "8.17.0",
 			"version": "8.17.0",
 			"resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz",
 			"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/node": "20.0.0",
 		"@types/nprogress": "0.2.3",
 		"@types/nprogress": "0.2.3",
 		"@types/sortablejs": "1.15.8",
 		"@types/sortablejs": "1.15.8",
+		"@types/xlsx": "^0.0.35",
 		"@typescript-eslint/eslint-plugin": "8.17.0",
 		"@typescript-eslint/eslint-plugin": "8.17.0",
 		"@typescript-eslint/parser": "8.17.0",
 		"@typescript-eslint/parser": "8.17.0",
 		"@vitejs/plugin-vue": "5.2.1",
 		"@vitejs/plugin-vue": "5.2.1",

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

@@ -0,0 +1,128 @@
+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,
+	});
+};
+
+/**
+ * 卸载归因-卸载预测
+ * @param cycle	周期(周/月)
+ * @param type	类型(0-卸载流失设备, 1-召回设备(预测))	
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @param startDate	开始日期	
+ * @param endDate	结束日期	
+ * @returns 
+ */
+export const uninstallPredict = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/predict',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载归因-安装卸载比
+ * @param cycle	周期(周/月)
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @param startDate	开始日期	
+ * @param endDate	结束日期	
+ * @param pageNum	页码
+ * @param pageSize	每页大小
+ * @returns 
+ */
+export const predictDetail = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/predict/detail',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载归因-安装卸载比明细
+ * @param cycle	周期(周/月)
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @param pageNum	页码
+ * @param pageSize	每页大小	
+ * @returns 
+ */
+export const predictDetailList = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/predict/detailList',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载洞察-体验干扰
+ * @param cycle	周期(周/月)
+ * @param type	类型
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @returns 
+ */
+export const uninstallInterfere = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/uninstallInterfere',
+		method: 'post',
+		data: data,
+	});
+};
+
+/**
+ * 卸载洞察-行为还原
+ * @param cycle	周期(周/月)
+ * @param type	类型
+ * @param appId	应用ID	
+ * @param channel	渠道	
+ * @param version	应用版本	
+ * @returns 
+ */
+export const uninstallAction = (data?: Object) => {
+	return request({
+		url: '/stats/uninstall/uninstallAction',
+		method: 'post',
+		data: data,
+	});
+};

+ 76 - 0
src/components/LYcom/Lprogress/index.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="l-progress">
+    <span class="l-progress-label">{{ label }}</span>
+    <div class="l-progress-bar-container">
+      <div 
+        class="l-progress-bar" 
+        :style="{ width: percentage + '%' }"
+      ></div>
+    </div>
+    <span class="l-progress-value">{{ percentage }}%</span>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+// 定义组件props
+interface Props {
+  label: string
+  num: number
+  count: number
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  label: '',
+  num: 0,
+  count: 0
+})
+
+// 计算占比百分比
+const percentage = computed(() => {
+  if (props.count === 0) return 0
+  return Math.min(100, Math.max(0, (props.num / props.count) * 100))
+})
+</script>
+
+<style scoped lang="scss">
+.l-progress {
+  display: flex;
+  align-items: center;
+  width: 100%;
+  
+  .l-progress-label {
+    font-size: 14px;
+    color: #121212;
+    white-space: nowrap;
+    margin-right: 12px;
+    display: inline-block;
+    width: 80px;
+    text-align: right;
+  }
+  
+  .l-progress-bar-container {
+    flex: 1;
+    height: 10px;
+    background-color: #e4e7ed;
+    border-radius: 5px;
+    overflow: hidden;
+    margin-right: 12px;
+    
+    .l-progress-bar {
+      height: 100%;
+      background: linear-gradient(90deg, #409eff, #66b1ff);
+      border-radius: 5px;
+      transition: width 0.3s ease;
+    }
+  }
+  
+  .l-progress-value {
+    font-size: 14px;
+    color: #121212;
+    font-weight: 500;
+    white-space: nowrap;
+  }
+}
+</style>

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

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

@@ -5,7 +5,10 @@
 				<template v-for="val in menuLists">
 				<template v-for="val in menuLists">
 					<el-menu-item v-if="val.children && val.children.length > 0" :index="val.path" :key="val.path">
 					<el-menu-item v-if="val.children && val.children.length > 0" :index="val.path" :key="val.path">
 						<template #title>
 						<template #title>
-							<SvgIcon :name="val.meta.icon" />
+              <navIcon v-if="val.path == '/marketing'" :val=val style="margin-right: 8px;" />
+              <navIcon v-else-if="val.path == '/count'" :val=val style="margin-right: 8px;" />
+              <navIcon v-else-if="val.path == '/system'" :val=val style="margin-right: 8px;" />
+							<SvgIcon v-else :name="val.meta.icon" />
 							{{ other.setMenuI18n(val) }}
 							{{ other.setMenuI18n(val) }}
 						</template>
 						</template>
 					</el-menu-item>
 					</el-menu-item>

文件差异内容过多而无法显示
+ 0 - 6
src/layout/navMenu/navIcon.vue


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

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

+ 26 - 2
src/theme/app.scss

@@ -344,6 +344,23 @@ body,
 		background-color: var(--menu-bar-active-color) !important;
 		background-color: var(--menu-bar-active-color) !important;
 		color: var(--menu-bar-active-font-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;
+  }
 }
 }
 
 
 /* 横向 菜单 */
 /* 横向 菜单 */
@@ -378,9 +395,16 @@ body,
 	.el-menu-item:hover svg,
 	.el-menu-item:hover svg,
 	.el-sub-menu.is-active svg,
 	.el-sub-menu.is-active svg,
 	.el-sub-menu:hover svg {
 	.el-sub-menu:hover svg {
-	    path {
-	        fill: var(--menu-bar-active-font-color) !important;
+    path {
+        fill: var(--menu-bar-active-font-color) !important;
+        stroke: var(--menu-bar-active-font-color) !important;
 		}
 		}
+    line {
+      stroke: var(--menu-bar-active-font-color) !important;
+    }
+    rect {
+      fill: var(--menu-bar-active-font-color) !important;
+    }
 	}
 	}
 }
 }
 
 

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

+ 393 - 0
src/views/count/channel/allChannel.vue

@@ -0,0 +1,393 @@
+<template>
+  <div class="layout-padding">
+    <div class="!overflow-auto px-1">
+      <Lcard :height="150">
+        <template #default>
+          <div class="box1">
+            <Title style="margin-bottom: 10px;" title="渠道分析">
+              <el-popover class="box-item" placement="right" trigger="hover" width="250">
+                <template #reference>
+                  <el-icon class="ml-1" style="color: #a4b8cf">
+                    <InfoFilled />
+                  </el-icon>
+                </template>
+                <template #default>
+                  <div class="ant-popover-inner-content">
+                    渠道分析指您投放在各大应用市场的渠道包,您可通过当前页面了解各渠道的活跃度、新增用户排名和渠道价值。
+                  </div>
+                </template>
+              </el-popover>
+            </Title>
+            <span class="link">查看完整AI简报</span>
+          </div>
+          <div class="box1" style="margin-top: 20px;">
+            <el-select v-model="selectChannel" style="width: 200px;">
+              <el-option label="全部渠道" value="1"></el-option>
+              <el-option label="安卓" value="2"></el-option>
+              <el-option label="苹果" value="3"></el-option>
+            </el-select>
+            <div class="box1-time">
+              <el-radio-group v-model="timeGroup">
+                <el-radio-button label="day">今天</el-radio-button>
+                <el-radio-button label="week">过去七天</el-radio-button>
+                <el-radio-button label="month">过去三十天</el-radio-button>
+              </el-radio-group>
+              <el-date-picker style="float: left;" v-model="timeRange" type="datetimerange" range-separator="To"
+                start-placeholder="Start date" end-placeholder="End date" />
+            </div>
+          </div>
+        </template>
+      </Lcard>
+      <Lcard :height="550">
+        <Title left-line title="渠道趋势对比">
+        </Title>
+        <div class="flex items-center justify-between mb-2 mt-3" style="margin-top: 20px;margin-left: 60px;">
+          <div class="flex items-center">
+            <el-radio-group v-model="lineChartUser">
+              <el-radio-button label="hour">新增用户</el-radio-button>
+              <el-radio-button label="day">活跃用户</el-radio-button>
+              <el-radio-button label="week">启动次数</el-radio-button>
+              <el-radio-button label="month">平均单次使用时长</el-radio-button>
+              <el-radio-button label="month">新增次日留存率</el-radio-button>
+            </el-radio-group>
+          </div>
+          <div class="flex items-center">
+            <el-select v-model="selectVersion" style="width: 200px;">
+              <el-option label="全部版本" value="1"></el-option>
+              <el-option label="1.0.1" value="2"></el-option>
+              <el-option label="1.02" value="3"></el-option>
+            </el-select>
+          </div>
+        </div>
+        <el-select v-model="selectLineChannel" multiple clearable style="width: 400px;margin: 20px 60px;">
+          <el-option label="App Store" value="1"></el-option>
+          <el-option label="应用宝" value="2"></el-option>
+          <el-option label="安卓" value="3"></el-option>
+        </el-select>
+        <div class="relative">
+          <div ref="qChartRef" style="width: 100%; height: 320px"></div>
+        </div>
+      </Lcard>
+      <Lcard :height="650">
+        <Title left-line title="渠道矩阵分析">
+          <el-popover class="box-item" placement="right" trigger="hover" width="250">
+            <template #reference>
+              <el-icon class="ml-1" style="color: #a4b8cf">
+                <InfoFilled />
+              </el-icon>
+            </template>
+            <template #default>
+              <div class="ant-popover-inner-content">
+                <div class="um-page-tips-content" style="line-height: 24px">
+                  可通过矩阵分析了解各个渠道的价值,在您推广平台选择时也可选择高转化高偏好的渠道作为重点渠道
+                </div>
+              </div>
+            </template>
+          </el-popover>
+        </Title>
+        <div style="margin: 30px 60px">
+          <span>横轴:新增用户(日均)</span>
+          纵轴:<el-select v-model="selectSanDian" style="width: 200px;display: inline-block;">
+            <el-option label="次日留存率(日均)" value="1"></el-option>
+            <el-option label="后日留存率(日均)" value="2"></el-option>
+          </el-select>
+        </div>
+        <div ref="SDChartRef" style="width: 100%; height: 480px"></div>
+      </Lcard>
+      <Lcard>
+        <Title left-line title="渠道明细数据">
+        </Title>
+        <div class="box1" style="margin: 30px 60px">
+          <div class="flex items-center justify-between">
+            <el-radio-group v-model="radioTableDay">
+              <el-radio-button label="today">今日</el-radio-button>
+              <el-radio-button label="yesterday">昨日</el-radio-button>
+            </el-radio-group>
+            <el-select class="ml-2" v-model="tableChannel" style="width: 140px" placeholder="渠道选择">
+              <el-option label="全部" value="1"></el-option>
+              <el-option label="未分组" value="2"></el-option>
+            </el-select>
+          </div>
+          <div class="link">导出</div>
+        </div>
+        <el-table :data="pagedTableRows" border>
+          <el-table-column prop="date" label="日期" min-width="140" />
+          <el-table-column label="新增用户(占比)" min-width="220">
+            <template #default="scope">
+              <div class="flex items-center justify-between w-full">
+                <span>{{ scope.row.newUsers }}</span>
+                <span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
+              </div>
+            </template>
+          </el-table-column>
+        </el-table>
+        <div v-if="showDetail1" class="flex justify-end mt-3">
+          <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" background
+            layout="total, prev, pager, next, sizes" :total="tableRows.length" :page-sizes="[5, 10, 20]" />
+        </div>
+      </Lcard>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+// import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+import { useI18n } from 'vue-i18n';
+import * as echarts from 'echarts';
+// 引入组件
+const Lcard = defineAsyncComponent(() => import('/@/components/LYcom/Lcard/index.vue'));
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const { t } = useI18n();
+
+const props = defineProps({
+  channel: {
+    type: String,
+    default: '1',
+  },
+});
+const emit = defineEmits(['update:channel'])
+
+// 渠道选择
+const selectChannel = computed({
+  get() {
+    return props.channel;
+  },
+  set(val) {
+    emit('update:channel', val);
+    console.log(val);
+  },
+})
+
+const timeGroup = ref('1')
+const timeRange: any = ref(null);
+watch(timeGroup, (newVal) => {
+  const now = new Date();
+  let start: Date;
+  let end: Date = new Date(now);
+
+  switch (newVal) {
+    case 'day':
+      // 设置为今天 00:00:00 到 23:59:59
+      start = new Date(now);
+      start.setHours(0, 0, 0, 0);
+      end.setHours(23, 59, 59, 999);
+      break;
+    case 'week':
+      // 设置为本周第一天(周日)到本周最后一天(周六)
+      start = new Date(now);
+      const day = start.getDay();
+      const diff = start.getDate() - day;
+      start.setDate(diff);
+      start.setHours(0, 0, 0, 0);
+      end = new Date(start);
+      end.setDate(start.getDate() + 6);
+      end.setHours(23, 59, 59, 999);
+      break;
+    case 'month':
+      // 设置为本月第一天到本月最后一天
+      start = new Date(now.getFullYear(), now.getMonth(), 1);
+      start.setHours(0, 0, 0, 0);
+      end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+      end.setHours(23, 59, 59, 999);
+      break;
+    default:
+      // 其他情况清空时间范围
+      timeRange.value = null;
+      return;
+  }
+
+  timeRange.value = [start, end];
+}, { immediate: true });
+
+const selectVersion = ref('1') // 选择版本
+
+const selectLineChannel = ref([]) // 选择线形图渠道
+
+const selectSanDian = ref('1') // 选择散点图纵坐标
+
+const radioTableDay = ref('1') // 选择表格时间
+const tableChannel = ref('1') // 选择表格渠道
+
+let qualityChart: echarts.ECharts | null = null;
+const qChartRef = ref(null);
+const lineChartUser = ref(null);
+onMounted(() => {
+  setTimeout(() => {
+    initQualityChart();
+    initScatterChart();
+  }, 500)
+});
+const qualityXAxis = ref<string[]>([
+  '2025-07-01',
+  '2025-07-08',
+  '2025-07-15',
+  '2025-07-22',
+  '2025-07-29',
+  '2025-08-05',
+  '2025-08-12',
+  '2025-08-19',
+  '2025-08-26',
+  '2025-09-02',
+  '2025-09-09',
+  '2025-09-16',
+]);
+const retentionSeries = ref<number[]>([20, 23, 27, 24, 22, 15, 5, 4, 16, 26, 25, 2]);
+const industryAvgSeries = ref<number[]>([16, 18, 20, 24, 25, 24, 16, 10, 15, 22, 21, 12]);
+const peerSameScaleSeries = ref<number[]>([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
+function initQualityChart(): void {
+  console.log(qChartRef.value, qChartRef);
+  if (!qChartRef.value) return;
+  if (qualityChart) qualityChart.dispose();
+  qualityChart = echarts.init(qChartRef.value);
+  const option: echarts.EChartsOption = {
+    tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
+    legend: { data: ['留存率', '同行业App', '同行业同规模App'] },
+    grid: { left: 40, right: 20, top: 30, bottom: 30 },
+    xAxis: { type: 'category', data: qualityXAxis.value },
+    yAxis: {
+      type: 'value',
+      min: 0,
+      max: 30,
+      axisLabel: { formatter: '{value}%' },
+      splitLine: { lineStyle: { color: '#f3f4f6' } },
+    },
+    series: [
+      { name: '留存率', type: 'line', smooth: true, data: retentionSeries.value },
+      { name: '同行业App', type: 'line', smooth: true, data: industryAvgSeries.value, color: '#f59e0b' },
+      { name: '同行业同规模App', type: 'line', smooth: true, data: peerSameScaleSeries.value, color: '#60a5fa' },
+    ],
+  };
+  qualityChart.setOption(option);
+}
+
+
+interface TableRow {
+  date: string;
+  newUsers: number;
+  ratio: string;
+}
+// 展开/收起明细
+const showDetail1 = ref(true);
+const pagedTableRows = computed(() => {
+  const startIndex = (currentPage.value - 1) * pageSize.value;
+  return tableRows.value.slice(startIndex, startIndex + pageSize.value);
+});
+// 表格相关(静态数据)
+const currentPage = ref(1);
+const pageSize = ref(5);
+const tableRows = ref<TableRow[]>(
+  Array.from({ length: 42 }).map((_, idx) => ({
+    date: `2025-08-${String(11).padStart(2, '0')}`,
+    newUsers: 727,
+    ratio: '97.45%',
+  }))
+);
+
+
+// 散点图相关代码
+const SDChartRef = ref(null);
+let scatterChart: echarts.ECharts | null = null;
+
+// 散点图数据
+const scatterData = ref([
+  { name: 'App Store', x: 120, y: 45.2, symbolSize: 80 },
+  { name: '应用宝', x: 210, y: 38.7, symbolSize: 120 },
+  { name: '华为应用市场', x: 98, y: 32.5, symbolSize: 65 },
+  { name: '小米应用商店', x: 180, y: 41.3, symbolSize: 110 },
+  { name: 'OPPO软件商店', x: 156, y: 36.8, symbolSize: 95 },
+  { name: 'vivo应用商店', x: 134, y: 34.2, symbolSize: 85 },
+  { name: '360手机助手', x: 178, y: 39.1, symbolSize: 105 },
+  { name: '应用汇', x: 89, y: 28.6, symbolSize: 60 },
+  { name: '安卓市场', x: 145, y: 35.7, symbolSize: 90 },
+  { name: '91助手', x: 112, y: 31.4, symbolSize: 70 }
+]);
+
+// 初始化散点图
+function initScatterChart(): void {
+  if (!SDChartRef.value) return;
+  if (scatterChart) scatterChart.dispose();
+
+  scatterChart = echarts.init(SDChartRef.value);
+
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'item',
+      formatter: (params: any) => {
+        return `${params.name}<br/>新增用户: ${params.data.x}<br/>留存率: ${params.data.y}%`;
+      }
+    },
+    grid: {
+      left: '7%',
+      right: '12%',
+      top: '10%',
+      bottom: '10%',
+      containLabel: true
+    },
+    xAxis: {
+      type: 'value',
+      name: '新增用户(日均)',
+      scale: true,
+      axisLine: {
+        show: true
+      },
+      splitLine: {
+        show: false
+      }
+    },
+    yAxis: {
+      type: 'value',
+      name: '次日留存率(日均)',
+      scale: true,
+      axisLine: {
+        show: true
+      },
+      splitLine: {
+        show: true
+      },
+      axisLabel: {
+        formatter: '{value}%'
+      }
+    },
+    series: [{
+      type: 'scatter',
+      symbolSize: (data: any) => data[2],
+      data: scatterData.value.map(item => [item.x, item.y, item.symbolSize, item.name]),
+      itemStyle: {
+        color: (params: any) => {
+          const colors = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc'];
+          return colors[params.dataIndex % colors.length];
+        }
+      },
+      label: {
+        show: true,
+        formatter: (params: any) => params.data[3],
+        position: 'inside',
+        fontSize: 10,
+        color: '#fff'
+      }
+    }]
+  };
+
+  scatterChart.setOption(option);
+}
+
+
+</script>
+<style lang="scss" scoped>
+.link {
+  color: #167af0;
+  cursor: pointer;
+}
+
+.box1 {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.line {
+  margin: 60px -30px 30px;
+  height: 1px;
+  background-color: #E6E6E6;
+}
+</style>

+ 10 - 0
src/views/count/channel/index.vue

@@ -0,0 +1,10 @@
+<template>
+  <AllChannel v-if="channel === '1'" v-model:channel="channel"></AllChannel>
+  <OtherChannel v-else v-model:channel="channel"></OtherChannel>
+</template>
+<script setup lang="ts" name="countMainTrend">
+const AllChannel = defineAsyncComponent(() => import('./allChannel.vue'));
+const OtherChannel = defineAsyncComponent(() => import('./otherChannel.vue'));
+
+const channel = ref('1')
+</script>

+ 1113 - 0
src/views/count/channel/otherChannel.vue

@@ -0,0 +1,1113 @@
+<template>
+  <div class="layout-padding">
+    <div class="!overflow-auto px-1">
+      <Lcard>
+        <div class="flex justify-start items-center">
+          <el-icon @click="() => selectChannel = '1'" class="ml-1" style="cursor: pointer; color: #333333; margin-right: 25px">
+            <ArrowLeftBold />
+          </el-icon>
+          <el-select v-model="selectChannel" style="width: 100px;">
+            <el-option label="全部渠道" value="1"></el-option>
+            <el-option label="安卓" value="2"></el-option>
+            <el-option label="苹果" value="3"></el-option>
+          </el-select>
+        </div>
+      </Lcard>
+      <Lcard>
+        <div class="flex justify-between items-center">
+          <Title left-line title="渠道趋势">
+          </Title>
+          <div class="box1">
+            <el-select v-model="selectVersion" style="width: 140px; margin-right: 30px;">
+              <el-option label="全部版本" value="1"></el-option>
+              <el-option label="1.0.1" value="2"></el-option>
+              <el-option label="1.02" value="3"></el-option>
+            </el-select>
+            <div class="box1-time">
+              <el-date-picker style="float: left; width: 240px; margin-right: 30px;" v-model="timeRange"
+                type="datetimerange" range-separator="To" start-placeholder="Start date" end-placeholder="End date" />
+              <el-radio-group v-model="timeGroup" style="width: 240px; margin-right: 30px;">
+                <el-radio-button label="week">过去七天</el-radio-button>
+                <el-radio-button label="month">过去三十天</el-radio-button>
+              </el-radio-group>
+            </div>
+            <div class="link">导出数据
+              <span style="display: inline-block; transform:translateY(3px);">
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  <g clip-path="url(#clip0_713_1665)">
+                    <path
+                      d="M14 1.33301H1.99992C1.63172 1.33301 1.33324 1.63149 1.33325 1.99969L1.33358 13.9997C1.33359 14.3679 1.63207 14.6663 2.00025 14.6663H14C14.3682 14.6663 14.6667 14.3679 14.6667 13.9997V1.99967C14.6667 1.63148 14.3682 1.33301 14 1.33301Z"
+                      stroke="#167AF0" />
+                    <path d="M6.66968 11.3366H11.1669V6.66992" stroke="#167AF0" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path d="M9.66675 8.16699L10.1667 7.66699L11.1667 6.66699L12.1667 7.66699L12.6667 8.16699"
+                      stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M4.66675 1.33301V14.6663" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M1.33325 4.67959L14.6666 4.66699" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M2.66675 1.33301H9.33341" stroke="#167AF0" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path d="M2.66675 14.667H9.33341" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M14.6667 2.66699V6.00033" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M1.33325 2.66699V6.00033" stroke="#167AF0" stroke-linecap="round" />
+                  </g>
+                  <defs>
+                    <clipPath id="clip0_713_1665">
+                      <rect width="16" height="16" fill="white" />
+                    </clipPath>
+                  </defs>
+                </svg>
+              </span>
+            </div>
+          </div>
+        </div>
+        <div class="line" style="margin: 35px -30px 40px;"></div>
+        <div class="flex items-center justify-between mb-2 mt-3" style="margin-top: 20px;margin-left: 60px;">
+          <div class="flex items-center">
+            <el-radio-group v-model="lineChartUser">
+              <el-radio-button label="1">新增用户</el-radio-button>
+              <el-radio-button label="2">活跃用户</el-radio-button>
+              <el-radio-button label="3">启动次数</el-radio-button>
+              <el-radio-button label="4">平均单次使用时长</el-radio-button>
+              <el-radio-button label="5">新增次日留存率</el-radio-button>
+            </el-radio-group>
+          </div>
+        </div>
+        <el-select placeholder="对比分析" v-model="selectLineChannel" multiple clearable
+          style="width: 140px;margin: 20px 60px;">
+          <el-option label="时段对比" value="1"></el-option>
+          <el-option label="渠道对比" value="2"></el-option>
+          <el-option label="版本对比" value="3"></el-option>
+        </el-select>
+        <div class="relative">
+          <div ref="qChartRef" style="margin-left: 60px; width: calc(100% - 80px); height: 320px"></div>
+        </div>
+        <div class="mt-3" style="margin-top: 20px;margin-left: 60px;">
+          <div class="flex items-center justify-between mb-2">
+            <div class="text-base font-medium cursor-pointer select-none" @click="showDetail1 = !showDetail1">
+              {{ showDetail1 ? '收起明细数据' : '展开明细数据' }}
+            </div>
+            <div>
+              <el-button>导出</el-button>
+            </div>
+          </div>
+          <el-table v-if="showDetail1" :data="pagedTableRows" border>
+            <el-table-column prop="date" label="日期" min-width="140" />
+            <el-table-column label="新增用户(占比)" min-width="220">
+              <template #default="scope">
+                <div class="flex items-center justify-between w-full">
+                  <span>{{ scope.row.newUsers }}</span>
+                  <span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
+                </div>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div v-if="showDetail1" class="flex justify-end mt-3">
+            <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" background
+              layout="total, prev, pager, next, sizes" :total="tableRows.length" :page-sizes="[5, 10, 20]" />
+          </div>
+        </div>
+      </Lcard>
+      <div class="flex justify-between">
+        <Lcard style="width: calc(50% - 5px);">
+          <div class="flex justify-between items-center">
+            <Title left-line title="渠道趋势">
+              <el-popover class="box-item" placement="right" trigger="hover" width="250">
+                <template #reference>
+                  <el-icon class="ml-1" style="color: #a4b8cf">
+                    <InfoFilled />
+                  </el-icon>
+                </template>
+                <template #default>
+                  <div class="ant-popover-inner-content">
+                    渠道活跃度,展示指定渠道用户的昨日活跃/过去7天活跃、昨日活跃/过去30天活跃的信息。<br>
+                    通过这两个指标,您可以了解到该渠道用户的粘着度。昨日活跃/过去30天活跃越接入100%,<br>
+                    用户越活跃,流失率越低,粘性越强这里的活跃用户是去重后的活跃用户
+                  </div>
+                </template>
+              </el-popover>
+            </Title>
+            <div style="font-size: 15px; color: #646464;">
+              2025/08/19
+            </div>
+          </div>
+          <div class="flex justify-between items-center">
+            <div ref="qdqsRef1" style="width: 48%; height: 220px;"></div>
+            <div ref="qdqsRef2" style="width: 48%; height: 220px;"></div>
+          </div>
+        </Lcard>
+        <Lcard style="width: calc(50% - 5px);">
+          <div class="flex justify-between items-center">
+            <Title left-line title="新增用户留存率">
+              <el-popover class="box-item" placement="right" trigger="hover" width="250">
+                <template #reference>
+                  <el-icon class="ml-1" style="color: #a4b8cf">
+                    <InfoFilled />
+                  </el-icon>
+                </template>
+                <template #default>
+                  <div class="ant-popover-inner-content">
+                    展示了渠道近期的留存率情况,可以帮助您了解该渠道用户的忠诚度<br>
+                    次日留存率: 某日的新增用户在次日启动过应用的比例<br>
+                    7日留存率: 某日的新增用户在7天后启动过应用的比例<br>
+                    14日留存率: 某日的新增用户在14天后启动过应用的比例<br>
+                    这里展示的是渠道前天在昨天的次日留存率、8天前在昨天的7日留存率和15天前在昨天的14日留存率
+                  </div>
+                </template>
+              </el-popover>
+            </Title>
+            <div style="font-size: 15px; color: #646464;">
+              2025/08/19
+            </div>
+          </div>
+          <Lprogress style="margin-top: 45px;" v-for="i in progressArray" :key="i.label" :label="i.label" :num="i.num"
+            :count="i.count"></Lprogress>
+        </Lcard>
+      </div>
+      <Lcard>
+        <div class="flex justify-between items-center">
+          <Title left-line title="渠道新增留存明细">
+          </Title>
+          <div class="box1">
+            <div class="box1-time">
+              <el-radio-group v-model="timeGranularity" style="width: 240px; margin-right: 30px;">
+                <el-radio-button label="week">过去七天</el-radio-button>
+                <el-radio-button label="month">过去三十天</el-radio-button>
+              </el-radio-group>
+              <el-radio-group v-model="timeGranularity" style="width: 240px; margin-right: 30px;">
+                <el-radio-button label="week">留存率</el-radio-button>
+                <el-radio-button label="month">留存数</el-radio-button>
+              </el-radio-group>
+            </div>
+            <div class="link">导出数据
+              <span style="display: inline-block; transform:translateY(3px);">
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  <g clip-path="url(#clip0_713_1665)">
+                    <path
+                      d="M14 1.33301H1.99992C1.63172 1.33301 1.33324 1.63149 1.33325 1.99969L1.33358 13.9997C1.33359 14.3679 1.63207 14.6663 2.00025 14.6663H14C14.3682 14.6663 14.6667 14.3679 14.6667 13.9997V1.99967C14.6667 1.63148 14.3682 1.33301 14 1.33301Z"
+                      stroke="#167AF0" />
+                    <path d="M6.66968 11.3366H11.1669V6.66992" stroke="#167AF0" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path d="M9.66675 8.16699L10.1667 7.66699L11.1667 6.66699L12.1667 7.66699L12.6667 8.16699"
+                      stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M4.66675 1.33301V14.6663" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M1.33325 4.67959L14.6666 4.66699" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M2.66675 1.33301H9.33341" stroke="#167AF0" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path d="M2.66675 14.667H9.33341" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M14.6667 2.66699V6.00033" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M1.33325 2.66699V6.00033" stroke="#167AF0" stroke-linecap="round" />
+                  </g>
+                  <defs>
+                    <clipPath id="clip0_713_1665">
+                      <rect width="16" height="16" fill="white" />
+                    </clipPath>
+                  </defs>
+                </svg>
+              </span>
+            </div>
+          </div>
+        </div>
+        <div class="line" style="margin: 35px -30px 40px;"></div>
+        <div class="relative">
+          <el-table :data="currentTableData" border>
+            <el-table-column v-for="column in currentTableColumns" :key="column.dataIndex" :prop="column.dataIndex"
+              :label="column.title" :align="column.align" :min-width="column.width">
+              <template #default="scope">
+                <span :class="getCellStyle(column.dataIndex, scope.row[column.dataIndex])">
+                  {{ scope.row[column.dataIndex] }}
+                </span>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+        <div class="flex justify-end mt-2">
+          <el-pagination v-model:current-page="currentPage1" v-model:page-size="pageSize1" background
+            layout="total, prev, pager, next, sizes" :total="tableRows1.length" :page-sizes="[5, 10, 20]" />
+        </div>
+      </Lcard>
+      <Lcard>
+        <div class="flex justify-between items-center">
+          <Title left-line title="渠道新增细分">
+          </Title>
+          <div class="box1">
+            <div class="box1-time">
+              <el-radio-group v-model="timeGroup" style="width: 240px; margin-right: 30px;">
+                <el-radio-button label="week">过去七天</el-radio-button>
+                <el-radio-button label="month">过去三十天</el-radio-button>
+              </el-radio-group>
+            </div>
+            <div class="link">导出数据
+              <span style="display: inline-block; transform:translateY(3px);">
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  <g clip-path="url(#clip0_713_1665)">
+                    <path
+                      d="M14 1.33301H1.99992C1.63172 1.33301 1.33324 1.63149 1.33325 1.99969L1.33358 13.9997C1.33359 14.3679 1.63207 14.6663 2.00025 14.6663H14C14.3682 14.6663 14.6667 14.3679 14.6667 13.9997V1.99967C14.6667 1.63148 14.3682 1.33301 14 1.33301Z"
+                      stroke="#167AF0" />
+                    <path d="M6.66968 11.3366H11.1669V6.66992" stroke="#167AF0" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path d="M9.66675 8.16699L10.1667 7.66699L11.1667 6.66699L12.1667 7.66699L12.6667 8.16699"
+                      stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M4.66675 1.33301V14.6663" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M1.33325 4.67959L14.6666 4.66699" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M2.66675 1.33301H9.33341" stroke="#167AF0" stroke-linecap="round"
+                      stroke-linejoin="round" />
+                    <path d="M2.66675 14.667H9.33341" stroke="#167AF0" stroke-linecap="round" stroke-linejoin="round" />
+                    <path d="M14.6667 2.66699V6.00033" stroke="#167AF0" stroke-linecap="round" />
+                    <path d="M1.33325 2.66699V6.00033" stroke="#167AF0" stroke-linecap="round" />
+                  </g>
+                  <defs>
+                    <clipPath id="clip0_713_1665">
+                      <rect width="16" height="16" fill="white" />
+                    </clipPath>
+                  </defs>
+                </svg>
+              </span>
+            </div>
+          </div>
+        </div>
+        <div class="line" style="margin: 35px -30px 40px;"></div>
+        <div class="flex items-center justify-between mb-2 mt-3" style="margin-top: 20px;margin-left: 60px;">
+          <div class="flex items-center">
+            <el-radio-group v-model="lineChartUser">
+              <el-radio-button label="1">设备</el-radio-button>
+              <el-radio-button label="2">国家/地区</el-radio-button>
+              <el-radio-button label="3">省市</el-radio-button>
+              <el-radio-button label="4">版本</el-radio-button>
+            </el-radio-group>
+          </div>
+        </div>
+        <div class="relative">
+          <div ref="horizontalBarChartRef" style="margin-left: 20px; width: calc(100% - 80px); height: 320px"></div>
+        </div>
+        <div class="mt-3" style="margin-top: 20px;margin-left: 60px;">
+          <div class="flex items-center justify-between mb-2">
+            <div class="text-base font-medium cursor-pointer select-none" @click="showDetail1 = !showDetail1">
+              <!-- {{ showDetail1 ? '收起明细数据' : '展开明细数据' }} -->
+                渠道明细
+            </div>
+            <div>
+              <el-button>导出</el-button>
+            </div>
+          </div>
+          <el-table v-if="showDetail1" :data="pagedTableRows" border>
+            <el-table-column prop="date" label="日期" min-width="140" />
+            <el-table-column label="新增用户(占比)" min-width="220">
+              <template #default="scope">
+                <div class="flex items-center justify-between w-full">
+                  <span>{{ scope.row.newUsers }}</span>
+                  <span class="text-gray-500 text-xs">{{ scope.row.ratio }}</span>
+                </div>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div v-if="showDetail1" class="flex justify-end mt-3">
+            <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" background
+              layout="total, prev, pager, next, sizes" :total="tableRows.length" :page-sizes="[5, 10, 20]" />
+          </div>
+        </div>
+      </Lcard>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+// import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+import { useI18n } from 'vue-i18n';
+import * as echarts from 'echarts';
+// 引入组件
+const Lcard = defineAsyncComponent(() => import('/@/components/LYcom/Lcard/index.vue'));
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const Lprogress = defineAsyncComponent(() => import('/@/components/LYcom/Lprogress/index.vue'));
+const { t } = useI18n();
+
+const props = defineProps({
+  channel: {
+    type: String,
+    default: '1',
+  },
+});
+const emit = defineEmits(['update:channel'])
+
+// 渠道选择
+const selectChannel = computed({
+  get() {
+    return props.channel;
+  },
+  set(val) {
+    emit('update:channel', val);
+    console.log(val);
+  },
+})
+const timeGroup = ref('1')
+const timeRange: any = ref(null);
+watch(timeGroup, (newVal) => {
+  const now = new Date();
+  let start: Date;
+  let end: Date = new Date(now);
+
+  switch (newVal) {
+    case 'day':
+      // 设置为今天 00:00:00 到 23:59:59
+      start = new Date(now);
+      start.setHours(0, 0, 0, 0);
+      end.setHours(23, 59, 59, 999);
+      break;
+    case 'week':
+      // 设置为本周第一天(周日)到本周最后一天(周六)
+      start = new Date(now);
+      const day = start.getDay();
+      const diff = start.getDate() - day;
+      start.setDate(diff);
+      start.setHours(0, 0, 0, 0);
+      end = new Date(start);
+      end.setDate(start.getDate() + 6);
+      end.setHours(23, 59, 59, 999);
+      break;
+    case 'month':
+      // 设置为本月第一天到本月最后一天
+      start = new Date(now.getFullYear(), now.getMonth(), 1);
+      start.setHours(0, 0, 0, 0);
+      end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
+      end.setHours(23, 59, 59, 999);
+      break;
+    default:
+      // 其他情况清空时间范围
+      timeRange.value = null;
+      return;
+  }
+
+  timeRange.value = [start, end];
+}, { immediate: true });
+
+const selectVersion = ref('1') // 选择版本
+
+const selectLineChannel = ref([]) // 选择线形图渠道
+
+const selectSanDian = ref('1') // 选择散点图纵坐标
+
+const radioTableDay = ref('1') // 选择表格时间
+const tableChannel = ref('1') // 选择表格渠道
+
+let qualityChart: echarts.ECharts | null = null;
+const qChartRef = ref(null);
+const lineChartUser = ref(null);
+onMounted(() => {
+  setTimeout(() => {
+    initQualityChart();
+    initChart1();
+    initChart2();
+    initHorizontalBarChart();
+  }, 500)
+});
+const qualityXAxis = ref<string[]>([
+  '2025-07-01',
+  '2025-07-08',
+  '2025-07-15',
+  '2025-07-22',
+  '2025-07-29',
+  '2025-08-05',
+  '2025-08-12',
+  '2025-08-19',
+  '2025-08-26',
+  '2025-09-02',
+  '2025-09-09',
+  '2025-09-16',
+]);
+const retentionSeries = ref<number[]>([20, 23, 27, 24, 22, 15, 5, 4, 16, 26, 25, 2]);
+const industryAvgSeries = ref<number[]>([16, 18, 20, 24, 25, 24, 16, 10, 15, 22, 21, 12]);
+const peerSameScaleSeries = ref<number[]>([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
+function initQualityChart(): void {
+  console.log(qChartRef.value, qChartRef);
+  if (!qChartRef.value) return;
+  if (qualityChart) qualityChart.dispose();
+  qualityChart = echarts.init(qChartRef.value);
+  const option: echarts.EChartsOption = {
+    tooltip: { trigger: 'axis', valueFormatter: (v) => `${v}%` },
+    legend: { data: ['留存率', '同行业App', '同行业同规模App'] },
+    grid: { left: 40, right: 20, top: 30, bottom: 30 },
+    xAxis: { type: 'category', data: qualityXAxis.value },
+    yAxis: {
+      type: 'value',
+      min: 0,
+      max: 30,
+      axisLabel: { formatter: '{value}%' },
+      splitLine: { lineStyle: { color: '#f3f4f6' } },
+    },
+    series: [
+      { name: '留存率', type: 'line', smooth: true, data: retentionSeries.value },
+      { name: '同行业App', type: 'line', smooth: true, data: industryAvgSeries.value, color: '#f59e0b' },
+      { name: '同行业同规模App', type: 'line', smooth: true, data: peerSameScaleSeries.value, color: '#60a5fa' },
+    ],
+  };
+  qualityChart.setOption(option);
+}
+
+
+interface TableRow {
+  date: string;
+  newUsers: number;
+  ratio: string;
+}
+// 展开/收起明细
+const showDetail1 = ref(true);
+const pagedTableRows = computed(() => {
+  const startIndex = (currentPage.value - 1) * pageSize.value;
+  return tableRows.value.slice(startIndex, startIndex + pageSize.value);
+});
+// 表格相关(静态数据)
+const currentPage = ref(1);
+const pageSize = ref(5);
+const tableRows = ref<TableRow[]>(
+  Array.from({ length: 42 }).map((_, idx) => ({
+    date: `2025-08-${String(11).padStart(2, '0')}`,
+    newUsers: 727,
+    ratio: '97.45%',
+  }))
+);
+
+
+// 饼图进度条相关代码
+const qdqsRef1 = ref(null);
+const qdqsCount1 = ref(85);
+const qdqsRef2 = ref(null);
+const qdqsCount2 = ref(65);
+let chart1: echarts.ECharts | null = null;
+let chart2: echarts.ECharts | null = null;
+
+// 初始化第一个饼图进度条
+function initChart1(): void {
+  if (!qdqsRef1.value) return;
+  if (chart1) chart1.dispose();
+
+  chart1 = echarts.init(qdqsRef1.value);
+
+  const option: echarts.EChartsOption = {
+    series: [
+      {
+        type: 'pie',
+        radius: ['60%', '70%'],
+        center: ['50%', '50%'],
+        avoidLabelOverlap: false,
+        startAngle: 270,
+        endAngle: 270 - (qdqsCount1.value / 100 * 360),
+        silent: true,
+        itemStyle: {
+          color: '#167AF0',
+          borderRadius: 10
+        },
+        data: [100],
+        label: {
+          show: false
+        },
+        emphasis: {
+          disabled: true
+        },
+      },
+      {
+        type: 'pie',
+        radius: ['60%', '70%'],
+        center: ['50%', '50%'],
+        avoidLabelOverlap: false,
+        startAngle: 360,
+        endAngle: 360,
+        itemStyle: {
+          color: '#409eff'
+        },
+        data: [65, 35],
+        label: {
+          show: false
+        },
+        emphasis: {
+          disabled: true
+        }
+      }
+    ],
+    graphic: {
+      elements: [
+        {
+          type: 'text',
+          key: 'percent-text',
+          z: 100,
+          left: 'center',
+          top: 'center',
+          style: {
+            text: qdqsCount1.value + '%',
+            fontSize: 24,
+            fontWeight: 'bold',
+            fill: '#121212'
+          }
+        },
+        {
+          type: 'text',
+          key: 'label-text',
+          z: 100,
+          left: 'center',
+          bottom: '3%',
+          style: {
+            text: '昨日活跃/过去7天活跃',
+            fontSize: 14,
+            fill: '#121212'
+          }
+        }
+      ]
+    }
+  };
+
+  chart1.setOption(option);
+}
+
+// 初始化第二个饼图进度条
+function initChart2(): void {
+  if (!qdqsRef2.value) return;
+  if (chart2) chart2.dispose();
+
+  chart2 = echarts.init(qdqsRef2.value);
+
+  const option: echarts.EChartsOption = {
+    series: [
+      {
+        type: 'pie',
+        radius: ['60%', '70%'],
+        center: ['50%', '50%'],
+        avoidLabelOverlap: false,
+        startAngle: 270,
+        endAngle: 270 - (qdqsCount2.value / 100 * 360),
+        silent: true,
+        itemStyle: {
+          color: '#00BC71',
+          borderRadius: 10
+        },
+        data: [100],
+        label: {
+          show: false
+        },
+        emphasis: {
+          disabled: true
+        },
+      },
+      {
+        type: 'pie',
+        radius: ['60%', '70%'],
+        center: ['50%', '50%'],
+        avoidLabelOverlap: false,
+        startAngle: 360,
+        endAngle: 360,
+        itemStyle: {
+          color: '#409eff'
+        },
+        data: [65, 35],
+        label: {
+          show: false
+        },
+        emphasis: {
+          disabled: true
+        }
+      }
+    ],
+    graphic: {
+      elements: [
+        {
+          type: 'text',
+          key: 'percent-text',
+          z: 100,
+          left: 'center',
+          top: 'center',
+          style: {
+            text: qdqsCount2.value + '%',
+            fontSize: 24,
+            fontWeight: 'bold',
+            fill: '#121212'
+          }
+        },
+        {
+          type: 'text',
+          key: 'label-text',
+          z: 100,
+          left: 'center',
+          bottom: '3%',
+          style: {
+            text: '昨日活跃/过去30天活跃',
+            fontSize: 14,
+            fill: '#121212'
+          }
+        }
+      ]
+    }
+  };
+
+  chart2.setOption(option);
+}
+
+
+// 横向进度条相关
+const progressArray = ref([
+  {
+    label: '次日留存率',
+    num: 80,
+    count: 200
+  },
+  {
+    label: '7日留存率',
+    num: 80,
+    count: 200
+  },
+  {
+    label: '14日留存率',
+    num: 40,
+    count: 200
+  },
+])
+
+
+const timeGranularity = ref('week')
+
+const weekColumns = ref<any[]>([
+  {
+    title: '时间',
+    width: '140px',
+    align: 'center',
+    dataIndex: 'date',
+  },
+  {
+    title: '新增用户',
+    width: '120px',
+    align: 'center',
+    dataIndex: 'newUsers',
+  },
+  {
+    title: '1天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day1',
+  },
+  {
+    title: '2天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day2',
+  },
+  {
+    title: '3天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day3',
+  },
+  {
+    title: '4天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day4',
+  },
+  {
+    title: '5天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day5',
+  },
+  {
+    title: '6天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day6',
+  },
+  {
+    title: '7天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day7',
+  },
+  {
+    title: '14天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day14',
+  },
+  {
+    title: '30天后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'day30',
+  },
+]);
+
+const dayColumns = ref<any[]>([
+  {
+    title: '时间',
+    width: '140px',
+    align: 'center',
+    dataIndex: 'date',
+  },
+  {
+    title: '新增用户',
+    width: '120px',
+    align: 'center',
+    dataIndex: 'newUsers',
+  },
+  {
+    title: '1周后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'week1',
+  },
+  {
+    title: '2周后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'week2',
+  },
+  {
+    title: '3周后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'week3',
+  },
+  {
+    title: '4周后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'week4',
+  },
+  {
+    title: '8周后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'week8',
+  },
+  {
+    title: '12周后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'week12',
+  },
+]);
+
+const monthColumns = ref<any[]>([
+  {
+    title: '时间',
+    width: '140px',
+    align: 'center',
+    dataIndex: 'date',
+  },
+  {
+    title: '新增用户',
+    width: '120px',
+    align: 'center',
+    dataIndex: 'newUsers',
+  },
+  {
+    title: '1月后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'month1',
+  },
+  {
+    title: '2月后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'month2',
+  },
+  {
+    title: '3月后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'month3',
+  },
+  {
+    title: '6月后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'month6',
+  },
+  {
+    title: '12月后',
+    width: '100px',
+    align: 'center',
+    dataIndex: 'month12',
+  },
+]);
+
+// 动态表格数据
+const weekTableData = ref<any[]>([
+  {
+    date: '2025-08-01',
+    newUsers: 1000,
+    day1: '45.2%',
+    day2: '32.1%',
+    day3: '28.5%',
+    day4: '25.8%',
+    day5: '23.4%',
+    day6: '21.7%',
+    day7: '20.3%',
+    day14: '15.6%',
+    day30: '12.8%',
+  },
+  {
+    date: '2025-08-02',
+    newUsers: 950,
+    day1: '46.8%',
+    day2: '33.2%',
+    day3: '29.1%',
+    day4: '26.5%',
+    day5: '24.2%',
+    day6: '22.8%',
+    day7: '21.5%',
+    day14: '16.3%',
+  },
+  {
+    date: '2025-08-02',
+    newUsers: 950,
+    day1: '46.8%',
+    day2: '33.2%',
+    day3: '29.1%',
+    day4: '26.5%',
+    day5: '24.2%',
+    day6: '22.8%',
+    day7: '21.5%',
+  },
+  {
+    date: '2025-08-02',
+    newUsers: 950,
+    day1: '46.8%',
+    day2: '33.2%',
+    day3: '29.1%',
+    day4: '26.5%',
+    day5: '24.2%',
+    day6: '22.8%',
+  },
+  {
+    date: '2025-08-02',
+    newUsers: 950,
+    day1: '46.8%',
+    day2: '33.2%',
+    day3: '29.1%',
+    day4: '26.5%',
+    day5: '24.2%',
+  },
+  {
+    date: '2025-08-02',
+    newUsers: 950,
+    day1: '46.8%',
+    day2: '33.2%',
+    day3: '29.1%',
+    day4: '26.5%',
+  },
+  {
+    date: '2025-08-02',
+    newUsers: 950,
+    day1: '46.8%',
+    day2: '33.2%',
+    day3: '29.1%',
+  },
+  {
+    date: '2025-08-02',
+    newUsers: 950,
+    day1: '46.8%',
+    day2: '33.2%',
+  },
+]);
+
+const dayTableData = ref<any[]>([
+  {
+    date: '2025-08-01',
+    newUsers: 7000,
+    week1: '35.2%',
+    week2: '28.1%',
+    week3: '24.5%',
+    week4: '22.8%',
+    week8: '18.4%',
+    week12: '15.7%',
+  },
+  {
+    date: '2025-08-08',
+    newUsers: 6800,
+    week1: '36.8%',
+    week2: '29.2%',
+    week3: '25.1%',
+    week4: '23.5%',
+    week8: '19.2%',
+    week12: '16.3%',
+  },
+]);
+
+const monthTableData = ref<any[]>([
+  {
+    date: '2025-08-01',
+    newUsers: 30000,
+    month1: '25.2%',
+    month2: '20.1%',
+    month3: '18.5%',
+    month6: '15.8%',
+    month12: '12.4%',
+  },
+  {
+    date: '2025-09-01',
+    newUsers: 32000,
+    month1: '26.8%',
+    month2: '21.2%',
+    month3: '19.1%',
+    month6: '16.5%',
+    month12: '13.2%',
+  },
+]);
+
+// 当前选中的表格数据
+const currentTableData = computed(() => {
+  switch (timeGranularity.value) {
+    case 'day':
+      return dayTableData.value;
+    case 'week':
+      return weekTableData.value;
+    case 'month':
+      return monthTableData.value;
+    default:
+      return dayTableData.value;
+  }
+});
+
+// 当前选中的表格列
+const currentTableColumns = computed(() => {
+  switch (timeGranularity.value) {
+    case 'day':
+      return dayColumns.value;
+    case 'week':
+      return weekColumns.value;
+    case 'month':
+      return monthColumns.value;
+    default:
+      return dayColumns.value;
+  }
+});
+
+const formData = ref<Record<string, any>>({});
+const query = () => {
+  console.log(formData.value);
+};
+
+// 表格相关(静态数据)
+const tableRows1 = ref<TableRow[]>(
+  Array.from({ length: 42 }).map((_, idx) => ({
+    date: `2025-08-${String(11).padStart(2, '0')}`,
+    newUsers: 727,
+    hyyh: '115',
+    ratio: '97.45%',
+  }))
+);
+
+const currentPage1 = ref(1);
+const pageSize1 = ref(5);
+
+function getCellStyle(dataIndex: string, rowText: any) {
+  if (dataIndex === 'date' || dataIndex === 'newUsers' || !rowText) {
+    return '';
+  }
+  return 'flex justify-center items-center absolute left-0 right-0 top-0 bottom-0 bg-[#e6f7ff]';
+}
+
+
+// 渠道新增横向柱状图
+// 横向柱状图相关
+const horizontalBarChartRef = ref(null);
+let horizontalBarChart: echarts.ECharts | null = null;
+
+// 初始化横向柱状图
+function initHorizontalBarChart(): void {
+  if (!horizontalBarChartRef.value) return;
+  if (horizontalBarChart) horizontalBarChart.dispose();
+  
+  horizontalBarChart = echarts.init(horizontalBarChartRef.value);
+  
+  // 数据
+  const channels = ['App Store', '应用宝', '华为应用市场', '小米应用商店', 'OPPO软件商店', 'vivo应用商店'];
+  const newUserCounts = [12500, 9800, 7600, 6800, 5400, 4200];
+  const retentionRates = [45.2, 38.7, 32.5, 41.3, 36.8, 34.2];
+  
+  const option: echarts.EChartsOption = {
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: {
+        type: 'shadow'
+      }
+    },
+    legend: {
+      data: ['新增用户数', '留存率(%)'],
+      top: '2%'
+    },
+    grid: {
+      left: '3%',
+      right: '4%',
+      bottom: '3%',
+      containLabel: true
+    },
+    xAxis: [
+      {
+        type: 'value',
+        name: '新增用户数',
+        position: 'top',
+        nameLocation: 'middle',
+        nameGap: 30
+      },
+      {
+        type: 'value',
+        name: '留存率(%)',
+        min: 0,
+        max: 50,
+        position: 'bottom',
+        nameLocation: 'middle',
+        nameGap: 30,
+        axisLabel: {
+          formatter: '{value} %'
+        }
+      }
+    ],
+    yAxis: {
+      type: 'category',
+      data: channels,
+      axisTick: {
+        alignWithLabel: true
+      }
+    },
+    series: [
+      {
+        name: '新增用户数',
+        type: 'bar',
+        barWidth: '20%',
+        data: newUserCounts,
+        itemStyle: {
+          color: '#409eff'
+        }
+      },
+      {
+        name: '留存率(%)',
+        type: 'bar',
+        barWidth: '20%',
+        xAxisIndex: 1,
+        data: retentionRates,
+        itemStyle: {
+          color: '#67c23a'
+        }
+      }
+    ]
+  };
+  
+  horizontalBarChart.setOption(option);
+}
+
+</script>
+<style lang="scss" scoped>
+.link {
+  color: #167af0;
+  cursor: pointer;
+}
+
+.box1 {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.line {
+  margin: 60px -30px 30px;
+  height: 1px;
+  background-color: #E6E6E6;
+}
+</style>

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

@@ -0,0 +1,381 @@
+<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">
+          <LayoutHeader title="卸载归因" :style="{ marginBottom: '0' }">
+            <template #aside>
+              <div class="data-source-status">数据源状态:Demo数据</div>
+            </template>
+            <template #tooltip-content>
+              卸载归因解读卸载设备在您应用中的最后活跃行为。<br/>
+              本模块功能展示周期内的卸载设备,在卸载前的最后7天(含当天)在您的应用中浏览次数TOP10的页面;<br/>
+              展示应用在全网设备中的卸载量,及是否在当前周期内新安装了您关注行业的头部竞品。<br/>
+              同时基于行业提供各周期的应用安装卸载比,辅助您判断行业的规模趋势。
+            </template>
+            <template #content>
+            </template>
+          </LayoutHeader>
+        </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">卸载设备全量预测<svg
+                width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
+                <path d="M0 13C0 5.8203 5.8203 0 13 0H22C23.1046 0 24 0.895431 24 2V9C24 16.1797 18.1797 22 11 22H2C0.89543 22 0 21.1046 0 20V13Z" fill="url(#paint0_linear_834_812)"/>
+                <path d="M6 16.5L9.63695 5.5H11.6343L15.2712 16.5H13.4379L12.5137 13.3806H8.6979L7.77376 16.5H6ZM9.11526 12.0075H12.0964L11.6641 10.5299C11.3064 9.33582 10.9784 8.11194 10.6356 6.87313H10.576C10.2481 8.12687 9.90525 9.33582 9.54752 10.5299L9.11526 12.0075Z" fill="white"/>
+                <path d="M16.271 16.5V5.5H18V16.5H16.271Z" fill="white"/>
+                <defs>
+                <linearGradient id="paint0_linear_834_812" x1="12" y1="0" x2="12" y2="22" gradientUnits="userSpaceOnUse">
+                <stop stop-color="#4CFFEA"/>
+                <stop offset="1" stop-color="#0081EB"/>
+                </linearGradient>
+                </defs>
+                </svg>
+              </div>
+              <div class="card-tabs">
+                <div class="card-tab" :class="{ active: activeTab === Type.CHURN }"
+                  @click="handleTabClick(Type.CHURN)">
+                  卸载流失设备(预测)
+                  <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 === Type.RECALL }"
+                  @click="handleTabClick(Type.RECALL)">卸载召回设备(预测)
+                  <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 === Type.CHURN ? '卸载流失设备' : '卸载召回设备'" :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="seriesNames" />
+              </div>
+              
+              <el-divider style="margin: 30px 0; background: rgba(230, 230, 230, 1);"/>
+
+              <!-- 表格 -->
+              <ExportToCSV :data="formatData" :columns="columns" :fileName="''" :hide-table="false" :tableStyle="{maxWidth: '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'
+import LayoutHeader from '/@/views/count/components/LayoutHeader.vue';
+import { uninstallPredict, predictDetail } from '/@/api/count/churn';
+import { formatDate } from '/@/utils/formatTime';
+
+// 类型枚举
+enum Type {
+  CHURN = 0,
+  RECALL = 1
+}
+
+const activeTab = ref(Type.CHURN);
+const currentChartData = ref([] as any[])
+
+const handleTabClick = (tab: number) => {
+  activeTab.value = tab
+  getUninstallPredict();
+}
+
+const data = ref([
+  // { date: '2025-01-01', values: [{ value: 1, seriesName: '当前应用' }, { value: 2, seriesName: '移动视频行业TOP5' }, { value: 5, seriesName: '医疗服务行业TOP5' }] },
+])
+
+const columns = ref([
+  {prop: 'date', label: '日期'},
+  // {prop: 'app1', label: '当前应用'},
+])
+
+const seriesNames = computed(() => {
+  return columns.value.filter((item: any) => item.prop !== 'date').map((item: any) => item.label)
+})
+
+const formatData = computed(() => {
+  return data.value.map((item: any) => {
+    const formattedItem: any = {
+      date: item.date
+    };
+    
+    // 动态添加每个系列的数据
+    item.values.forEach((valueItem: any, index: number) => {
+      const propName = `app${index + 1}`;
+      formattedItem[propName] = valueItem.value;
+    });
+    
+    return formattedItem;
+  })
+})
+
+const getUninstallPredict = (data?: any) => {
+  uninstallPredict({
+    type: activeTab.value,
+    cycle: 'week',
+    ...data
+  }).then((res) => {
+    if(res.code === 0 && res.data?.length > 0) {
+      currentChartData.value = res.data.map((item: any) => ({
+        date: formatDate(new Date(item.predictTime), 'YYYY-mm-dd'),
+        value: activeTab.value === Type.CHURN ? item.uninstallCount : item.recallCount
+      }))
+    }
+  })
+}
+
+const getPredictDetail = (params?: any) => {
+  predictDetail({
+    cycle: 'week',
+    ...params
+  }).then((res) => {
+    console.log(res);
+    const newColumns: any[] = []
+    if(res.code === 0 && res.data?.length > 0) {
+      columns.value = [];
+      data.value = res.data.map((item: any, index: number) => {
+        let rowData = {
+          date: formatDate(new Date(item.date), 'YYYY-mm-dd'),
+          values: [] as {value: number, seriesName: string}[]
+        }
+
+        item.type.forEach((type: any, _index: number) => {
+          const [key, _value] = Object.entries(type)[0];
+          console.log(key, _value);
+          rowData.values.push({
+            value: _value as number,
+            seriesName: key
+          })
+          newColumns.push({
+            prop: 'app' + (1+_index),
+            label: key
+          })
+        })
+
+        if(index === 0) {
+          columns.value = [
+            {prop: 'date', label: '日期'},
+            ...newColumns
+          ];
+        }
+
+        console.log(columns.value);
+        return rowData;
+      })
+      data.value = data.value.sort((a: any, b: any) => {
+        return new Date(a.date).getTime() - new Date(b.date).getTime()
+      })
+      console.log(data.value);
+    }
+  })
+}
+
+onMounted(() => {
+  getUninstallPredict();
+  getPredictDetail();
+})
+</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> 

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

@@ -0,0 +1,161 @@
+<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 === Last7DaysTab.CRASH }" @click="handleLast7DaysTabClick(Last7DaysTab.CRASH)">前7天崩溃次数</div>
+              <div class="card-tab" :class="{ active: last7DaysTab === Last7DaysTab.PUSH }" @click="handleLast7DaysTabClick(Last7DaysTab.PUSH)">前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 === ViewPagesTab.HIGH_FREQUENCY_PAGES }" @click="handleViewPagesTabClick(ViewPagesTab.HIGH_FREQUENCY_PAGES)">高频浏览页面TOP10</div>
+              <div class="card-tab" :class="{ active: viewPagesTab === ViewPagesTab.FINAL_VIEW_PAGES }" @click="handleViewPagesTabClick(ViewPagesTab.FINAL_VIEW_PAGES)">最终浏览页面TOP10</div>
+            </div>
+            <div class="content-item">
+              <HorizontalBarChart :data="ViewPagesData" :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'
+import { uninstallAction, uninstallInterfere } from '/@/api/count/churn'
+
+const chartData = [
+  { name: '0-7天', value: 45, percentage: '84.00%' },
+  { name: '8-15天', value: 23, percentage: '4.00%' },
+  { name: '16-30天', value: 23, percentage: '4.00%' },
+  { name: '31-45天', value: 23, percentage: '14.00%' },
+  { name: '46-60天', value: 23, percentage: '4.00%' },
+  { name: '61-90天', value: 23, percentage: '4.00%' },
+  { name: '90天以上', value: 23, percentage: '4.00%' }
+]
+
+const usageCountData = [
+  { name: '1次', value: 45, percentage: '45.0%' },
+  { name: '2次', value: 23, percentage: '23.0%' },
+  { name: '3次', value: 15, percentage: '15.0%' },
+  { name: '4次', value: 8, percentage: '8.0%' },
+  { name: '5次', value: 6, percentage: '6.0%' },
+  { name: '6次以上', value: 3, percentage: '3.0%' }
+]
+
+const experienceData = ref([] as any[])
+const ViewPagesData = ref([] as any[])
+
+enum Last7DaysTab {
+  CRASH = '1',
+  PUSH = '2'
+}
+
+enum ViewPagesTab {
+  HIGH_FREQUENCY_PAGES = '1',
+  FINAL_VIEW_PAGES = '2'
+}
+
+const last7DaysTab = ref(Last7DaysTab.CRASH);
+const viewPagesTab = ref(ViewPagesTab.HIGH_FREQUENCY_PAGES);
+
+const getUninstallInterfere = () => {
+  uninstallInterfere({
+    cycle: 'week',
+    type: last7DaysTab.value,
+  }).then((res) => {
+    if(res.code === 0 && res.data?.value?.length > 0) {
+      experienceData.value = []
+      res.data.value.forEach((item: any) => {
+        const [key, val] = Object.entries(item)[0];
+        experienceData.value.push({
+          name: key,
+          value: val
+        })
+      })
+    }
+  })
+}
+
+const getUninstallAction = () => {
+  uninstallAction({
+    cycle: 'week',
+    type: viewPagesTab.value,
+  }).then((res) => {
+    if(res.code === 0 && res.data?.value?.length > 0) {
+      ViewPagesData.value = []
+      res.data.value.forEach((item: any) => {
+        const [key, val] = Object.entries(item)[0];
+        ViewPagesData.value.push({
+          name: key,
+          value: val
+        })
+      })
+      ViewPagesData.value.sort((a: any, b: any) => b.value - a.value)
+    }
+  })
+}
+
+const handleLast7DaysTabClick = (tab: Last7DaysTab) => {
+  last7DaysTab.value = tab
+  getUninstallInterfere()
+}
+
+const handleViewPagesTabClick = (tab: ViewPagesTab) => {
+  viewPagesTab.value = tab
+  getUninstallAction()
+}
+
+onMounted(() => {
+  getUninstallInterfere();
+  getUninstallAction();
+})
+
+</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>
     </div>
     <div class="legend-wrapper">
     <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>
   </div>
   </div>
 </template>
 </template>
@@ -214,17 +218,29 @@ watch(displayData, (newData) => {
 
 
   .legend-wrapper {
   .legend-wrapper {
     padding: 0;
     padding: 0;
+    
+    .legend-table {
+      max-width: 300px;
+      border-collapse: collapse;
+      width: 100%;
+    }
+    
     .legend-title {
     .legend-title {
       max-width: 300px;
       max-width: 300px;
       font-size: 14px;
       font-size: 14px;
       font-weight: 500;
       font-weight: 500;
       color: rgba(100, 100, 100, 1);
       color: rgba(100, 100, 100, 1);
       margin-bottom: 18px;
       margin-bottom: 18px;
-      display: flex;
+      th {
+        height: 38px;
+      }
 
 
-      .item {
-        width: 50%;
+      th.item {
         text-wrap: nowrap;
         text-wrap: nowrap;
+        width: 50%;
+        text-align: left;
+        padding: 0;
+        border: none;
 
 
         &:nth-child(1) {
         &:nth-child(1) {
           width: 150px;
           width: 150px;
@@ -236,20 +252,24 @@ watch(displayData, (newData) => {
       max-width: 300px;
       max-width: 300px;
 
 
       .legend-item {
       .legend-item {
-        display: flex;
-        align-items: center;
         font-size: 14px;
         font-size: 14px;
         line-height: 20px;
         line-height: 20px;
-        margin-bottom: 20px;
+        cursor: pointer;
 
 
         &:last-child {
         &:last-child {
           margin-bottom: 0;
           margin-bottom: 0;
         }
         }
 
 
-        .item {
+        td {
+          height: 38px;
+        }
+        td.item {
           text-wrap: nowrap;
           text-wrap: nowrap;
           width: 50%;
           width: 50%;
           color: rgba(18, 18, 18, 1);
           color: rgba(18, 18, 18, 1);
+          padding: 0;
+          border: none;
+          vertical-align: middle;
 
 
           &:nth-child(1) {
           &:nth-child(1) {
             width: 150px;
             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>

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

@@ -0,0 +1,167 @@
+<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">
+          <LayoutHeader title="卸载洞察" :style="{ marginBottom: '0' }">
+            <template #aside>
+              <div class="data-source-status">数据源状态: Demo数据</div>
+            </template>
+            <template #tooltip-content>
+              卸载洞察展示您每周卸载设备的活跃特征,如从安装到卸载的生命周期时长分布、卸载前活跃情况、末次活跃至卸载行为的时间差分布、卸载设备终端特征。
+            </template>
+            <template #content>
+              <div class="content">
+              </div>
+            </template>
+          </LayoutHeader>
+        </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'
+import LayoutHeader from '/@/views/count/components/LayoutHeader.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>

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

@@ -0,0 +1,406 @@
+<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">
+          <LayoutHeader title="流失概况">
+            <template #aside>
+              <div class="data-source-status">数据源状态: Demo数据</div>
+            </template>
+            <template #content>
+              <div class="top-content">
+                <ProgressCard :title="'当周卸载流失设备数'" :counts="trendProgressData.uninstallCounts" :rates="trendProgressData.uninstallRates" />
+                <ProgressCard :title="'当周卸载召回设备数'" :counts="trendProgressData.recallCounts" :rates="trendProgressData.recallRates" />
+              </div>
+            </template>
+          </LayoutHeader>
+        </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 { 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'
+import LayoutHeader from '/@/views/count/components/LayoutHeader.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 0 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: 23px;">
+        <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>

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

@@ -0,0 +1,320 @@
+<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">
+        <LayoutHeader title="卸载画像" :style="{ marginBottom: '0' }">
+          <template #tooltip-content>
+            敬请期待
+          </template>
+          <template #content>
+            <div class="content">
+              <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 disabled :type="'daterange'" style="width: 242px;" v-model="form.dateArray" range-separator="至" @change="selectDateChange"
+                      start-placeholder="开始日期" end-placeholder="结束日期" :disabled-date="disabledDate" />
+                  </el-form-item>
+                  <el-form-item label="">
+                    {{ form.time == 'week' ? '一周内' : '一月内' }},卸载流失设备数<span>{{ uninstallCount.count }}</span>
+                  </el-form-item>
+                  <div class="tips">您可以根据下方的卸载画像报表,查看易卸载用户和高粘性用户的特征,了解什么样的用户留不住。</div>
+                </el-form>
+              </div>
+            </div>
+          </template>
+        </LayoutHeader>
+      </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">
+                <UserInfo title="易卸载用户特征" :type="true" :tags="easyTags" tips="卸载用户 vs. 活跃用户具备的明显特征,有以下特征的用户易卸载" style="margin-bottom: 24px;"></UserInfo>
+                <DataTable :data="easyData" :columns="columns"></DataTable>
+              </el-card>
+            </el-col>
+            <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+              <el-card shadow="none">
+                <UserInfo title="不易卸载用户特征" :type="false" :tags="hardTags" tips="活跃用户 vs. 卸载用户具备的明显差异点,有以下特征的人群卸载可能性低" style="margin-bottom: 24px;"></UserInfo>
+                <DataTable :data="hardData" :columns="columns"></DataTable>
+              </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: 380px;" :chartHeight="380" />
+              </div>
+            </el-col>
+            <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
+              <div class="">
+                <DataTable :data="usageTimeData" :columns="columns2"></DataTable>
+              </div>
+            </el-col>
+          </el-row>
+        </el-card>
+      </el-col>
+    </el-row>
+  </div>
+</template>
+<script setup lang="ts">
+import { ref, onMounted } 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';
+import LayoutHeader from '/@/views/count/components/LayoutHeader.vue';
+
+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() - 15)), 'YYYY-mm-dd'), formatDate(new Date(new Date().setDate(new Date().getDate() - 9)), '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 hardTags = ref<string[]>([])
+const easyTags = ref<string[]>([])
+
+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 = [];
+      easyTags.value = [];
+      hardTags.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),
+          });
+          easyTags.value = [...easyData.value].sort((a: any, b: any) => b.percent - a.percent).map((item: any) => item.name).slice(0, 3);
+        } else {
+          hardData.value.push({
+            ...item,
+            percent: item.percent.substring(0, item.percent.length - 1),
+          });
+          hardTags.value = [...hardData.value].sort((a: any, b: any) => b.percent - a.percent).map((item: any) => item.name).slice(0, 3);
+        }
+      })
+    // }
+  })
+}
+
+const selectTimeChange = () => {
+  console.log(form.value.time);
+  if(form.value.time == 'week'){
+    form.value.dateArray = [formatDate(new Date(new Date().setDate(new Date().getDate() - 15)), 'YYYY-mm-dd'), formatDate(new Date(new Date().setDate(new Date().getDate() - 9)), 'YYYY-mm-dd')]
+  } else {
+    form.value.dateArray = [formatDate(new Date(new Date().setDate(new Date().getDate() - 39)), 'YYYY-mm-dd'), formatDate(new Date(new Date().setDate(new Date().getDate() - 9)), 'YYYY-mm-dd')]
+  }
+  init();
+}
+
+const selectDateChange = () => {
+  console.log(form.value.dateArray);
+  init();
+}
+
+const disabledDate = (time: Date) => {
+  return time.getTime() > Date.now() - 86400000*9; 
+}
+
+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>

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

@@ -0,0 +1,337 @@
+<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: '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>

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

@@ -0,0 +1,218 @@
+<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-show="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; width: 100%; display: block;">
+        <el-table class="table" :data="paginatedData" row-key="name" style="width: 100%" fit 
+          :cell-style="cellStyle" :header-cell-style="headerCellStyle" v-show="!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-show="column.type === 'link'" class="link" 
+                @click="column.handleClick ? column.handleClick(scope.row[column.prop]) : null">
+                {{ scope.row[column.prop] }}
+              </span>
+              <!-- 其他列 -->
+              <span v-show="column.type !== 'link'">{{ 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;
+      type?: string;
+      handleClick?: (row: any) => void | undefined
+    }[]>,
+    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
+  }
+})
+
+// 表格数据
+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 handleExportData = (data: any, columns: any, fileName: string) => {
+  alert('导出');
+  // 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;
+  }
+}
+
+.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;
+}
+
+:deep(.el-table) {
+  table-layout: fixed;
+}
+</style>

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

@@ -0,0 +1,334 @@
+<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) {
+        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>

+ 79 - 0
src/views/count/components/LayoutHeader.vue

@@ -0,0 +1,79 @@
+<template>
+  <el-card shadow="none" :style="computedCardStyle">
+    <div class="top-info" :style="style">
+      <div class="title">{{ title }}<slot v-if="tooltip" name="tooltip">
+          <el-tooltip class="box-item" effect="light"
+            content="" placement="right-start">
+            <svg style="margin: 0 0 0 8px; vertical-align: baseline;" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+              <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;">
+                <slot name="tooltip-content"></slot>
+              </div>
+            </template>
+          </el-tooltip> 
+        </slot>
+      </div>
+      <div class="aside">
+        <slot name="aside"></slot>
+      </div>
+    </div>
+    <slot name="content"></slot>
+  </el-card>
+</template>
+<script lang="ts" setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+  title: {
+    type: String,
+    default: ''
+  },
+  tooltip: {
+    type: String,
+    default: ''
+  },
+  style: {
+    type: Object,
+    default: () => ({})
+  },
+  cardStyle: {
+    type: Object,
+    default: () => ({})
+  }
+})
+
+const computedCardStyle = computed(() => {
+  return Object.keys(props.cardStyle).length > 0 ? props.cardStyle : { padding: '10px 14px' }
+})
+</script>
+<style lang="scss" scoped>
+
+.top-info {
+  color: rgba(18, 18, 18, 1);
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 25px;
+  font-family: 'Source Han Sans SC';
+
+  .title {
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 20px;
+    padding: 4px 0;
+    vertical-align: baseline;
+  }
+
+  .aside {
+    display: flex;
+  }
+}
+
+</style>

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

@@ -3,16 +3,24 @@
 </template>
 </template>
 
 
 <script lang="ts" setup>
 <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'
 import * as echarts from 'echarts'
 
 
 interface ChartDataItem {
 interface ChartDataItem {
   date: string
   date: string
   value: number
   value: number
 }
 }
+interface ChartMulDataItem {
+  date: string
+  values: Array<{
+    value: number
+    percentage?: string
+    seriesName?: string
+  }>
+}
 
 
 interface Props {
 interface Props {
-  data: ChartDataItem[]
+  data: ChartDataItem[] | ChartMulDataItem[]
   width?: string
   width?: string
   height?: string
   height?: string
   title?: string
   title?: string
@@ -22,6 +30,8 @@ interface Props {
   showLegend?: boolean
   showLegend?: boolean
   smooth?: boolean
   smooth?: boolean
   areaStyle?: boolean
   areaStyle?: boolean
+  isMultiSeries?: boolean
+  seriesNames?: string[]
 }
 }
 
 
 const props = withDefaults(defineProps<Props>(), {
 const props = withDefaults(defineProps<Props>(), {
@@ -33,7 +43,9 @@ const props = withDefaults(defineProps<Props>(), {
   showTooltip: true,
   showTooltip: true,
   showLegend: false,
   showLegend: false,
   smooth: false,
   smooth: false,
-  areaStyle: false
+  areaStyle: false,
+  isMultiSeries: false,
+  seriesNames: () => ['系列1', '系列2']
 })
 })
 
 
 const chartRef = ref<HTMLElement>()
 const chartRef = ref<HTMLElement>()
@@ -47,12 +59,97 @@ const initChart = () => {
   updateChart()
   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 = () => {
 const updateChart = () => {
   if (!chartInstance) return
   if (!chartInstance) return
 
 
   const dates = props.data.map(item => item.date)
   const dates = props.data.map(item => item.date)
-  const values = props.data.map(item => item.value)
 
 
   const option: echarts.EChartsOption = {
   const option: echarts.EChartsOption = {
     tooltip: props.showTooltip ? {
     tooltip: props.showTooltip ? {
@@ -73,27 +170,49 @@ const updateChart = () => {
         }
         }
       },
       },
       formatter: function(params: any) {
       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,
     } : undefined,
     legend: props.showLegend ? {
     legend: props.showLegend ? {
-      data: [props.title || '数据'],
-      top: '40px',
+      data: legendData.value,
+      bottom: '0px',
       textStyle: {
       textStyle: {
         fontSize: 12,
         fontSize: 12,
         color: '#666'
         color: '#666'
-      }
+      },
+      icon: 'circle',
+      itemWidth: 8,
+      itemHeight: 8,
+      itemGap: 30,
+      selectedMode: true,
+      itemStyle: {
+        borderWidth: 0
+      },
     } : undefined,
     } : undefined,
     grid: {
     grid: {
       left: '0%',
       left: '0%',
       right: '0%',
       right: '0%',
-      bottom: '0%',
+      bottom: props.showLegend ? '60px' : '0',
       top: '10px',
       top: '10px',
       containLabel: true
       containLabel: true
     },
     },
@@ -135,32 +254,7 @@ const updateChart = () => {
         show: false
         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)
   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> 

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

@@ -0,0 +1,82 @@
+<template>
+  <div class="select-form-card">
+    <LayoutHeader :title="title" :style="{ marginBottom: '23px' }" :cardStyle="{ padding: '10px 14px 0px' }">
+      <template #content>
+        <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="结束日期" :disabled-date="disabledDate" />
+            </el-form-item>
+          </el-form>
+        </div>
+      </template>
+    </LayoutHeader>
+  </div>
+</template>
+<script lang="ts" name="selectForm" setup>
+import { ref, PropType } from 'vue'
+import LayoutHeader from '/@/views/count/components/LayoutHeader.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 handleTabChange = (tab: number) => {
+  props.form!.tab = tab;
+}
+
+const disabledDate = (time: Date) => {
+  return time.getTime() > Date.now(); 
+}
+
+</script>
+
+<style scoped lang="scss">
+@import '/@/views/count/styles/common.scss';
+
+.select-form-card {
+  .card-tabs {
+    margin-bottom: 0;
+  }
+}
+</style>

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

@@ -0,0 +1,265 @@
+<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天)内用户{{ 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>

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

@@ -0,0 +1,351 @@
+<template>
+  <div class="layout-padding location">
+    <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="location-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 + '- 地域'" />
+          </div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 省市详情弹窗 -->
+    <ProvinceDetailDialog :dialogVisible="dialogVisible" :selectedProvince="selectedProvince"
+      :provinceDetail="provinceDetail" @update:dialogVisible="dialogVisible = $event" />
+
+  </div>
+</template>
+
+<script lang="ts" name="location" 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: '省市', type: 'link', handleClick: handleProvinceClick },
+  { 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';
+
+.location {
+  .location-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>

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

@@ -0,0 +1,75 @@
+<template>
+  <LayoutHeader :title="title" :style="{ marginBottom: '0' }" :cardStyle="{ padding: '10px 14px 0px 14px' }">
+    <template #content>
+      <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="开始日期" :disabled-date="disabledDate" />
+            <el-date-picker v-if="type === 'daterange'" :type="type" style="width: 242px;" v-model="form.dateArray" range-separator="至"
+              start-placeholder="开始日期" end-placeholder="结束日期" :disabled-date="disabledDate" />
+          </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>
+    </template>
+  </LayoutHeader>
+</template>
+<script setup lang="ts">
+import LayoutHeader from '/@/views/count/components/LayoutHeader.vue';
+
+const props = defineProps({
+  type: {
+    type: String,
+    default: 'date'
+  },
+  form: {
+    type: Object,
+    default: () => ({})
+  },
+  title: {
+    type: String,
+    default: ''
+  }
+})
+
+const disabledDate = (time: Date) => {
+  return time.getTime() > Date.now(); 
+}
+
+</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>

+ 189 - 0
src/views/count/featureUsage/eventMannage/AddEventModal.vue

@@ -0,0 +1,189 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="添加事件"
+    width="500px"
+    :before-close="handleClose"
+  >
+    <el-form 
+      :model="form" 
+      :rules="rules" 
+      ref="formRef"
+      label-width="100px"
+    >
+      <el-form-item label="选择应用" prop="selectedApps" required>
+        <el-select 
+          v-model="form.selectedApps" 
+          multiple 
+          placeholder="请选择应用"
+          style="width: 100%"
+        >
+          <el-option
+            v-for="app in appOptions"
+            :key="app.value"
+            :label="app.label"
+            :value="app.value"
+          />
+        </el-select>
+      </el-form-item>
+      
+      <el-form-item label="事件ID" prop="eventId" required>
+        <el-input 
+          v-model="form.eventId" 
+          placeholder="请输入事件ID"
+        />
+      </el-form-item>
+      
+      <el-form-item label="显示名称" prop="displayName" required>
+        <el-input 
+          v-model="form.displayName" 
+          placeholder="请输入显示名称"
+        />
+      </el-form-item>
+      
+      <el-form-item label="事件类型" prop="eventType" required>
+        <el-select 
+          v-model="form.eventType" 
+          placeholder="请选择事件类型"
+          style="width: 100%"
+        >
+          <el-option
+            v-for="type in eventTypeOptions"
+            :key="type.value"
+            :label="type.label"
+            :value="type.value"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    
+    <div class="tips">
+      <div class="tips-content">
+        说明:<br>
+        事件标识符不可更改,与代码中"String event_id"保持一致:参数(key)无需手动注册,每个事件下最多支持100个参数同时计算。为保证数据准确性,请使用(英文、数字、下划线(_)、中划线(-)、小数点(.)及加号(+))定义事件标识符或属性标识符。
+      </div>
+    </div>
+    
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose">取消</el-button>
+        <el-button type="primary" @click="handleAdd">添加</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, reactive } from 'vue'
+import type { FormInstance, FormRules } from 'element-plus'
+
+interface Props {
+  modelValue: boolean
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: boolean): void
+  (e: 'add', data: any): void
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+// 表单引用
+const formRef = ref<FormInstance>()
+
+// 可见性绑定
+const visible = computed({
+  get: () => props.modelValue,
+  set: (value) => emit('update:modelValue', value)
+})
+
+// 表单数据
+const form = reactive({
+  selectedApps: [] as string[],
+  eventId: '',
+  displayName: '',
+  eventType: '' // 0: 多参数类型事件, 1: 计算事件
+})
+
+// 应用选项
+const appOptions = [
+  { label: '教育行业Demo', value: 'education' },
+  { label: '游戏行业Demo', value: 'game' },
+  { label: '通用行业Demo', value: 'general' }
+]
+
+// 事件类型选项
+const eventTypeOptions = [
+  { label: '多参数类型事件', value: 0 },
+  { label: '计算事件', value: 1 }
+]
+
+// 表单验证规则
+const rules = reactive<FormRules>({
+  selectedApps: [
+    { required: true, message: '请选择应用', trigger: 'change' }
+  ],
+  eventId: [
+    { required: true, message: '请输入事件ID', trigger: 'blur' },
+    { pattern: /^[a-zA-Z0-9_.\-+]+$/, message: '只能包含字母、数字、下划线(_)、中划线(-)、小数点(.)及加号(+)', trigger: 'blur' },
+    { max: 128, message: '长度不能超过128个字符', trigger: 'blur' }
+  ],
+  displayName: [
+    { required: true, message: '请输入显示名称', trigger: 'blur' }
+  ],
+  eventType: [
+    { required: true, message: '请选择事件类型', trigger: 'change' }
+  ]
+})
+
+// 关闭弹窗
+const handleClose = () => {
+  visible.value = false
+  // 重置表单
+  form.selectedApps = []
+  form.eventId = ''
+  form.displayName = ''
+  form.eventType = ''
+  // 清除表单验证
+  formRef.value?.resetFields()
+}
+
+// 处理添加事件
+const handleAdd = async () => {
+  if (!formRef.value) return
+  
+  await formRef.value.validate((valid) => {
+    if (valid) {
+      emit('add', {
+        apps: form.selectedApps,
+        eventId: form.eventId,
+        displayName: form.displayName,
+        eventType: form.eventType
+      })
+      handleClose()
+    }
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.tips {
+  background-color: #f5f7fa;
+  border-radius: 4px;
+  padding: 12px;
+  margin-top: 20px;
+  
+  .tips-content {
+    font-size: 12px;
+    color: #606266;
+    line-height: 1.5;
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style>

+ 184 - 0
src/views/count/featureUsage/eventMannage/BatchImportModal.vue

@@ -0,0 +1,184 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="批量导入事件"
+    width="500px"
+    :before-close="handleClose"
+  >
+    <el-form :model="form" label-width="80px">
+      <el-form-item label="选择应用" required>
+        <el-select 
+          v-model="form.selectedApps" 
+          multiple 
+          placeholder="请选择应用"
+          style="width: 100%"
+        >
+          <el-option
+            v-for="app in appOptions"
+            :key="app.value"
+            :label="app.label"
+            :value="app.value"
+          />
+        </el-select>
+      </el-form-item>
+      
+      <el-form-item label="选择文件">
+        <el-button @click="selectFile">
+          <el-icon><Upload /></el-icon>
+          选择Excel文件
+        </el-button>
+        <br>
+        <div v-if="form.fileName" class="file-name">{{ form.fileName }}</div>
+      </el-form-item>
+      
+      <el-form-item label=" ">
+        <el-button @click="downloadTemplate">
+          <el-icon><Download /></el-icon>
+          Excel模板下载
+        </el-button>
+      </el-form-item>
+    </el-form>
+    
+    <div class="tips">
+      <div class="tips-title">说明:</div>
+      <div class="tips-content">
+        <p>① 命名规范:事件标识符(event id)、事件属性标识符(key)命名支持字母(建议小写,避免使用中文)、数字、下划线 (_)、中划线 (-)、小数点 (.)及加号(+) 。event id和key长度不能超过128个字节。</p>
+        <p>② 事件类型,1表示计算事件,0表示多参数类型事件</p>
+        <p>③ 属性类型:number数值型(可计算),string字符串</p>
+      </div>
+    </div>
+    
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose">取消</el-button>
+        <el-button type="primary" @click="handleImport" :disabled="!canImport">导入</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, reactive } from 'vue'
+import { Upload, Download } from '@element-plus/icons-vue'
+
+interface Props {
+  modelValue: boolean
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: boolean): void
+  (e: 'import', data: any): void
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+// 可见性绑定
+const visible = computed({
+  get: () => props.modelValue,
+  set: (value) => emit('update:modelValue', value)
+})
+
+// 表单数据
+const form = reactive({
+  selectedApps: [] as string[],
+  fileName: '',
+  file: null as File | null
+})
+
+// 应用选项
+const appOptions = [
+  { label: '教育行业Demo', value: 'education' },
+  { label: '游戏行业Demo', value: 'game' },
+  { label: '通用行业Demo', value: 'general' }
+]
+
+// 是否可以导入(已选择应用和文件)
+const canImport = computed(() => {
+  return form.selectedApps.length > 0 && form.file !== null
+})
+
+// 关闭弹窗
+const handleClose = () => {
+  visible.value = false
+  // 重置表单
+  form.selectedApps = []
+  form.fileName = ''
+  form.file = null
+}
+
+// 选择文件
+const selectFile = () => {
+  const input = document.createElement('input')
+  input.type = 'file'
+  input.accept = '.xlsx,.xls'
+  input.onchange = (e) => {
+    const file = (e.target as HTMLInputElement).files?.[0]
+    if (file) {
+      form.file = file
+      form.fileName = file.name
+    }
+  }
+  input.click()
+}
+
+// 下载模板
+const downloadTemplate = () => {
+  // 这里可以实现实际的模板下载逻辑
+  console.log('下载模板')
+  // 示例:创建一个虚拟的下载链接
+  const link = document.createElement('a')
+  link.href = 'data:application/vnd.ms-excel;base64,UEsDBAoAAAAAALJdHFYAAAAA' // 示例base64
+  link.download = '事件导入模板.xlsx'
+  link.click()
+}
+
+// 处理导入
+const handleImport = () => {
+  if (!canImport.value) return
+  
+  emit('import', {
+    apps: form.selectedApps,
+    file: form.file
+  })
+  
+  handleClose()
+}
+</script>
+
+<style scoped lang="scss">
+.file-name {
+  margin-top: 8px;
+  font-size: 14px;
+  color: #606266;
+}
+
+.tips {
+  background-color: #f5f7fa;
+  border-radius: 4px;
+  padding: 12px;
+  margin-top: 20px;
+  
+  .tips-title {
+    font-weight: bold;
+    margin-bottom: 8px;
+    color: #303133;
+  }
+  
+  .tips-content {
+    font-size: 12px;
+    color: #606266;
+    line-height: 1.5;
+    
+    p {
+      margin: 0 0 4px 0;
+    }
+  }
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style>

+ 511 - 0
src/views/count/featureUsage/eventMannage/EventEdit.vue

@@ -0,0 +1,511 @@
+<template>
+  <div class="layout-padding">
+    <div class="!overflow-auto px-1">
+      <Lcard style=" margin-bottom: 0px;">
+        <div class="flex justify-start items-center">
+          <el-icon @click="back" class="ml-1" style="cursor: pointer; color: #333333; margin-right: 10px">
+            <ArrowLeftBold />
+          </el-icon>
+          <Title title="编辑事件详情">
+          </Title>
+        </div>
+        <div style="margin: 20px 60px;">
+          <!-- 添加事件详情展示区域 -->
+          <div class="event-detail-card">
+            <!-- <div class="event-detail-header">
+              <h3>事件基本信息</h3>
+            </div> -->
+            <div class="event-detail-content">
+              <div class="detail-row">
+                <div class="detail-item">
+                  <span class="detail-label">事件名称:</span>
+                  <span class="detail-value">用户注册</span>
+                </div>
+                <div class="detail-item">
+                  <span class="detail-label">事件标识符:</span>
+                  <span class="detail-value">user_register</span>
+                </div>
+              </div>
+              <div class="detail-row">
+                <div class="detail-item">
+                  <span class="detail-label">计算状态:</span>
+                  <span class="detail-value">
+                    <el-tag type="success">计算中</el-tag>
+                  </span>
+                </div>
+                <div class="detail-item">
+                  <span class="detail-label">近三十天上报数:</span>
+                  <span class="detail-value">12,560</span>
+                </div>
+              </div>
+              <div class="detail-row">
+                <div class="detail-item">
+                  <span class="detail-label">上报平台:</span>
+                  <span class="detail-value">Android、IOS</span>
+                </div>
+                <div class="detail-item">
+                  <span class="detail-label">应用:</span>
+                  <span class="detail-value">教育行业Demo</span>
+                </div>
+              </div>
+              <div class="detail-row">
+                <div class="detail-item">
+                  <span class="detail-label">埋点触发时机:</span>
+                  <span class="detail-value">用户完成注册流程时触发</span>
+                </div>
+                <div class="detail-item">
+                  <span class="detail-label">操作人:</span>
+                  <span class="detail-value">nina</span>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+        <div style="margin: 20px 60px;">
+          <el-radio-group v-model="eventTypeRadio">
+            <el-radio-button label="1">自定义属性</el-radio-button>
+            <el-radio-button label="2">预制全局属性</el-radio-button>
+          </el-radio-group>
+          <div class="line" style="margin: 0 0 20px 0;"></div>
+          <div class="flex search-box" style="margin-bottom: 20px;">
+            <el-radio-group v-model="eventTypeRadio">
+              <el-radio-button label="1">计算中事件</el-radio-button>
+              <el-radio-button label="2">暂停计算事件</el-radio-button>
+              <el-radio-button label="3">未注册事件</el-radio-button>
+            </el-radio-group>
+            <el-input placeholder="请输入事件名/事件标识符" style="width: 230px;"></el-input>
+            <el-button @click="handleAddProperty" style="margin-left: auto;" type="primary">新增属性</el-button>
+            <el-button>批量停止计算</el-button>
+          </div>
+          <el-table :height="700" :data="pagedTableRows" border>
+            <el-table-column prop="propertyName" label="属性名称" min-width="120" />
+            <el-table-column prop="propertyIdentifier" label="属性标识符" min-width="140" />
+            <el-table-column label="属性类型" min-width="100">
+              <template #default="scope">
+                <el-tag :type="getPropertyType(scope.row.propertyType)">{{ getPropertyTypeName(scope.row.propertyType)
+                }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="计算状态" min-width="100">
+              <template #default="scope">
+                <el-tag :type="getStatusType(scope.row.status)">{{ scope.row.status }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column prop="remark" label="属性备注" min-width="120" show-overflow-tooltip />
+            <el-table-column label="应用" min-width="120">
+              <template #default="scope">
+                {{ getAppText(scope.row.apps) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" min-width="160" fixed="right">
+              <template #default="scope">
+                <el-button @click="handleEditProperty(scope.row)" type="primary" link>编辑</el-button>
+                <!-- <el-button type="primary" link>详情</el-button> -->
+                <el-button v-if="scope.row.status === '计算中'" type="primary" link>暂停</el-button>
+                <el-button v-else-if="scope.row.status === '暂停计算'" type="primary" link>启用</el-button>
+                <el-button type="danger" link>删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div class="flex justify-end mt-3">
+            <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" background
+              layout="total, prev, pager, next, sizes" :total="tableRows.length" :page-sizes="[5, 10, 20]" />
+          </div>
+        </div>
+      </Lcard>
+      <EventPropEditModal v-model="showPropertyModal" :is-edit="isEditProperty" :data="currentPropertyData"
+        @submit="handleSubmitProperty" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" name="countMainTrend" setup>
+// import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+import { useI18n } from 'vue-i18n';
+import * as echarts from 'echarts';
+// 引入组件
+const Lcard = defineAsyncComponent(() => import('/@/components/LYcom/Lcard/index.vue'));
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const EventPropEditModal = defineAsyncComponent(() => import('./EventPropEditModal.vue'))
+const { t } = useI18n();
+const eventTypeRadio = ref('1')
+
+onMounted(() => {
+  setTimeout(() => {
+  }, 500);
+});
+
+// 编辑属性相关
+const showPropertyModal = ref(false)
+const isEditProperty = ref(false)
+const currentPropertyData = ref<any>(null)
+
+// 在 script 部分添加
+const handleAddProperty = () => {
+  isEditProperty.value = false
+  currentPropertyData.value = null
+  showPropertyModal.value = true
+}
+
+const handleEditProperty = (data: any) => {
+  isEditProperty.value = true
+  currentPropertyData.value = data
+  showPropertyModal.value = true
+}
+
+const handleSubmitProperty = (data: any) => {
+  console.log('提交属性数据:', data)
+  // 这里可以实现实际的新增或编辑逻辑
+  // 例如发送请求到后端
+}
+
+const emit = defineEmits(['update:eventId'])
+
+const back = () => {
+  emit('update:eventId', null)
+}
+
+const searchData = reactive({
+  eventType: '1',
+  channel: '',
+  eventName: '',
+  eventHas: '',
+  appId: '',
+  evnetUser: '',
+})
+
+// 表格相关(静态数据)
+const currentPage = ref(1);
+const pageSize = ref(10);
+
+const pagedTableRows = computed(() => {
+  const startIndex = (currentPage.value - 1) * pageSize.value;
+  return tableRows.value.slice(startIndex, startIndex + pageSize.value);
+});
+
+
+// 修改表格数据结构
+interface EventTableRow {
+  id: string;
+  propertyName: string; // 属性名称
+  propertyIdentifier: string; // 属性标识符
+  propertyType: string; // 属性类型
+  status: string; // 计算状态
+  remark: string; // 属性备注
+  apps: string[]; // 应用
+}
+
+// 更新表格静态数据
+const tableRows = ref<EventTableRow[]>([
+  {
+    id: '1',
+    propertyName: '用户ID',
+    propertyIdentifier: 'user_id',
+    propertyType: 'string',
+    status: '计算中',
+    remark: '用户的唯一标识符',
+    apps: ['教育行业Demo', '游戏行业Demo']
+  },
+  {
+    id: '2',
+    propertyName: '访问时长',
+    propertyIdentifier: 'visit_duration',
+    propertyType: 'number',
+    status: '计算中',
+    remark: '用户单次访问的时长',
+    apps: ['教育行业Demo']
+  },
+  {
+    id: '3',
+    propertyName: '设备型号',
+    propertyIdentifier: 'device_model',
+    propertyType: 'string',
+    status: '暂停计算',
+    remark: '用户使用的设备型号',
+    apps: ['游戏行业Demo', '通用行业Demo']
+  },
+  {
+    id: '4',
+    propertyName: '页面路径',
+    propertyIdentifier: 'page_path',
+    propertyType: 'string',
+    status: '计算中',
+    remark: '用户访问的页面路径',
+    apps: ['教育行业Demo', '游戏行业Demo', '通用行业Demo']
+  },
+  {
+    id: '5',
+    propertyName: '点击次数',
+    propertyIdentifier: 'click_count',
+    propertyType: 'number',
+    status: '计算中',
+    remark: '页面元素被点击的次数',
+    apps: ['游戏行业Demo']
+  },
+  {
+    id: '6',
+    propertyName: '会话ID',
+    propertyIdentifier: 'session_id',
+    propertyType: 'string',
+    status: '未注册',
+    remark: '用户会话的唯一标识',
+    apps: ['通用行业Demo']
+  },
+  {
+    id: '7',
+    propertyName: '地理位置',
+    propertyIdentifier: 'geo_location',
+    propertyType: 'string',
+    status: '计算中',
+    remark: '用户的地理位置信息',
+    apps: ['教育行业Demo']
+  },
+  {
+    id: '8',
+    propertyName: '购买金额',
+    propertyIdentifier: 'purchase_amount',
+    propertyType: 'number',
+    status: '计算中',
+    remark: '用户购买商品的金额',
+    apps: ['游戏行业Demo', '通用行业Demo']
+  },
+  {
+    id: '9',
+    propertyName: '分享平台',
+    propertyIdentifier: 'share_platform',
+    propertyType: 'string',
+    status: '暂停计算',
+    remark: '用户分享到的平台',
+    apps: ['教育行业Demo']
+  },
+  {
+    id: '10',
+    propertyName: '搜索关键词',
+    propertyIdentifier: 'search_keyword',
+    propertyType: 'string',
+    status: '计算中',
+    remark: '用户搜索时使用的关键词',
+    apps: ['游戏行业Demo', '通用行业Demo']
+  },
+  {
+    id: '11',
+    propertyName: '错误代码',
+    propertyIdentifier: 'error_code',
+    propertyType: 'number',
+    status: '未注册',
+    remark: '系统错误代码',
+    apps: ['通用行业Demo']
+  },
+  {
+    id: '12',
+    propertyName: '网络类型',
+    propertyIdentifier: 'network_type',
+    propertyType: 'string',
+    status: '计算中',
+    remark: '用户当前的网络连接类型',
+    apps: ['教育行业Demo', '游戏行业Demo']
+  },
+  {
+    id: '13',
+    propertyName: '用户年龄',
+    propertyIdentifier: 'user_age',
+    propertyType: 'number',
+    status: '计算中',
+    remark: '用户的年龄信息',
+    apps: ['教育行业Demo']
+  },
+  {
+    id: '14',
+    propertyName: '操作系统',
+    propertyIdentifier: 'os_version',
+    propertyType: 'string',
+    status: '暂停计算',
+    remark: '设备的操作系统版本',
+    apps: ['游戏行业Demo', '通用行业Demo']
+  },
+  {
+    id: '15',
+    propertyName: '页面停留时间',
+    propertyIdentifier: 'page_stay_time',
+    propertyType: 'number',
+    status: '计算中',
+    remark: '用户在页面停留的时间',
+    apps: ['教育行业Demo', '游戏行业Demo', '通用行业Demo']
+  }
+]);
+
+// 获取属性类型标签样式
+const getPropertyType = (type: string) => {
+  switch (type) {
+    case 'string':
+      return 'primary';
+    case 'number':
+      return 'success';
+    default:
+      return 'info';
+  }
+};
+
+// 获取属性类型名称
+const getPropertyTypeName = (type: string) => {
+  switch (type) {
+    case 'string':
+      return '字符串';
+    case 'number':
+      return '数值型';
+    default:
+      return type;
+  }
+};
+
+// 获取应用显示文本(复用原来的方法)
+const getAppText = (apps: string[]) => {
+  if (apps.length === 0) return '-';
+  if (apps.length > 2) return `${apps[0]}等${apps.length}个`;
+  return apps.join('、');
+};
+
+// 状态标签样式(保持不变)
+const getStatusType = (status: string) => {
+  switch (status) {
+    case '计算中':
+      return 'success';
+    case '暂停计算':
+      return 'warning';
+    case '未注册':
+      return 'info';
+    default:
+      return 'info';
+  }
+};
+</script>
+<style lang="scss" scoped>
+.search-box>div {
+  margin-right: 20px;
+}
+
+/* 添加事件详情样式 */
+.event-detail-card {
+  border: 1px solid #e4e7ed;
+  border-radius: 4px;
+  margin-bottom: 20px;
+
+  .event-detail-header {
+    padding: 12px 20px;
+    border-bottom: 1px solid #e4e7ed;
+    background-color: #f5f7fa;
+
+    h3 {
+      margin: 0;
+      font-size: 16px;
+      font-weight: 600;
+      color: #303133;
+    }
+  }
+
+  .event-detail-content {
+    padding: 20px;
+
+    .detail-row {
+      display: flex;
+      margin-bottom: 16px;
+
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    .detail-item {
+      flex: 1;
+      display: flex;
+
+      .detail-label {
+        width: 120px;
+        font-size: 14px;
+        color: #606266;
+        text-align: right;
+        margin-right: 12px;
+        flex-shrink: 0;
+      }
+
+      .detail-value {
+        flex: 1;
+        font-size: 14px;
+        color: #303133;
+
+        :deep(.el-tag) {
+          margin-right: 0;
+        }
+      }
+    }
+  }
+}
+
+/* 响应式处理 */
+@media (max-width: 768px) {
+  .event-detail-card {
+    .event-detail-content {
+      .detail-row {
+        flex-direction: column;
+        margin-bottom: 12px;
+      }
+
+      .detail-item {
+        margin-bottom: 8px;
+
+        &:last-child {
+          margin-bottom: 0;
+        }
+
+        .detail-label {
+          width: auto;
+          text-align: left;
+          margin-right: 8px;
+        }
+      }
+    }
+  }
+}
+
+.msg {
+  height: 30px;
+  background-color: #f4f5fa;
+  line-height: 30px;
+  font-size: 12px;
+}
+
+.link {
+  color: #167af0;
+  cursor: pointer;
+}
+
+.box1 {
+  display: flex;
+  margin-top: 30px;
+
+  .card-box1,
+  .card-box2 {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: start;
+  }
+
+  .card-box1 {
+    .info-card {
+      width: 48%;
+    }
+  }
+
+  .card-box2 {
+    .info-card {
+      width: 33%;
+    }
+  }
+}
+
+.line {
+  margin: 60px -30px 30px;
+  height: 1px;
+  background-color: #E6E6E6;
+}
+</style>

+ 184 - 0
src/views/count/featureUsage/eventMannage/EventPropEditModal.vue

@@ -0,0 +1,184 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    :title="isEdit ? '编辑属性' : '新增属性'"
+    width="500px"
+    :before-close="handleClose"
+  >
+    <el-form 
+      :model="form" 
+      :rules="rules" 
+      ref="formRef"
+      label-width="100px"
+    >
+      <el-form-item label="属性名称" prop="propertyName" required>
+        <el-input 
+          v-model="form.propertyName" 
+          :disabled="isEdit"
+          placeholder="请输入属性名称"
+        />
+      </el-form-item>
+      
+      <el-form-item label="属性标识符" prop="propertyIdentifier" required>
+        <el-input 
+          v-model="form.propertyIdentifier" 
+          :disabled="isEdit"
+          placeholder="请输入属性标识符"
+        />
+        <div class="form-item-tip">支持字母、数字、下划线(_)、中划线(-)、小数点(.)及加号(+)</div>
+      </el-form-item>
+      
+      <el-form-item label="属性类型" prop="propertyType" required>
+        <el-select 
+          v-model="form.propertyType" 
+          :disabled="isEdit"
+          placeholder="请选择属性类型"
+          style="width: 100%"
+        >
+          <el-option label="字符串" value="string" />
+          <el-option label="数值型" value="number" />
+        </el-select>
+      </el-form-item>
+      
+      <el-form-item label="属性备注" prop="remark">
+        <el-input 
+          v-model="form.remark" 
+          type="textarea"
+          :rows="3"
+          placeholder="请输入属性备注"
+        />
+      </el-form-item>
+      
+      <el-form-item label="应用" prop="apps" required>
+        <el-select 
+          v-model="form.apps" 
+          multiple
+          placeholder="请选择应用"
+          style="width: 100%"
+        >
+          <el-option label="教育行业Demo" value="education" />
+          <el-option label="游戏行业Demo" value="game" />
+          <el-option label="通用行业Demo" value="general" />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleClose">取消</el-button>
+        <el-button type="primary" @click="handleSubmit">确定</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, reactive, watch } from 'vue'
+import type { FormInstance, FormRules } from 'element-plus'
+
+interface Props {
+  modelValue: boolean
+  isEdit: boolean
+  data?: any
+}
+
+interface Emits {
+  (e: 'update:modelValue', value: boolean): void
+  (e: 'submit', data: any): void
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+// 表单引用
+const formRef = ref<FormInstance>()
+
+// 可见性绑定
+const visible = computed({
+  get: () => props.modelValue,
+  set: (value) => emit('update:modelValue', value)
+})
+
+// 表单数据
+const form = reactive({
+  propertyName: '',
+  propertyIdentifier: '',
+  propertyType: '',
+  remark: '',
+  apps: [] as string[]
+})
+
+// 表单验证规则
+const rules = reactive<FormRules>({
+  propertyName: [
+    { required: true, message: '请输入属性名称', trigger: 'blur' }
+  ],
+  propertyIdentifier: [
+    { required: true, message: '请输入属性标识符', trigger: 'blur' },
+    { pattern: /^[a-zA-Z0-9_.\-+]+$/, message: '只能包含字母、数字、下划线(_)、中划线(-)、小数点(.)及加号(+)', trigger: 'blur' },
+    { max: 128, message: '长度不能超过128个字符', trigger: 'blur' }
+  ],
+  propertyType: [
+    { required: true, message: '请选择属性类型', trigger: 'change' }
+  ],
+  apps: [
+    { required: true, message: '请选择应用', trigger: 'change' }
+  ]
+})
+
+// 监听数据变化,用于编辑时填充表单
+watch(() => props.data, (newVal) => {
+  if (newVal && props.isEdit) {
+    form.propertyName = newVal.propertyName || ''
+    form.propertyIdentifier = newVal.propertyIdentifier || ''
+    form.propertyType = newVal.propertyType || ''
+    form.remark = newVal.remark || ''
+    form.apps = newVal.apps || []
+  }
+}, { immediate: true })
+
+// 重置表单
+const resetForm = () => {
+  form.propertyName = ''
+  form.propertyIdentifier = ''
+  form.propertyType = ''
+  form.remark = ''
+  form.apps = []
+  formRef.value?.resetFields()
+}
+
+// 关闭弹窗
+const handleClose = () => {
+  visible.value = false
+  resetForm()
+}
+
+// 处理提交
+const handleSubmit = async () => {
+  if (!formRef.value) return
+  
+  await formRef.value.validate((valid) => {
+    if (valid) {
+      emit('submit', {
+        ...form,
+        isEdit: props.isEdit
+      })
+      handleClose()
+    }
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.form-item-tip {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+}
+
+.dialog-footer {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+}
+</style>

+ 399 - 0
src/views/count/featureUsage/eventMannage/EventTable.vue

@@ -0,0 +1,399 @@
+<template>
+  <div class="layout-padding">
+    <div class="!overflow-auto px-1">
+      <Lcard style="height: calc(100vh - 96px); margin-bottom: 0px;">
+        <div class="flex justify-between items-center">
+          <Title left-line title="事件管理">
+          </Title>
+          <div>
+            <el-button @click="() => showBatchImportModal = true" icon="Download">批量导入事件</el-button>
+            <el-button @click="() => showAddEventModal = true" icon="Plus" type="primary">添加事件</el-button>
+          </div>
+        </div>
+        <div style="margin: 20px 60px;">
+          <el-radio-group v-model="eventTypeRadio">
+            <el-radio-button label="1">计算中事件</el-radio-button>
+            <el-radio-button label="2">暂停计算事件</el-radio-button>
+            <el-radio-button label="3">未注册事件</el-radio-button>
+          </el-radio-group>
+          <div class="line" style="margin: 0 0 20px 0;"></div>
+          <div class="flex search-box" style="margin-bottom: 20px;">
+            <el-input placeholder="请输入事件名/事件标识符" style="width: 230px;"></el-input>
+            <el-select placeholder="选择平台" v-model="selectLineChannel" clearable style="width: 140px;">
+              <el-option label="Android" value="1"></el-option>
+              <el-option label="IOS" value="2"></el-option>
+              <el-option label="IPad" value="3"></el-option>
+              <el-option label="Harmony" value="4"></el-option>
+            </el-select>
+            <el-select placeholder="近三十天事件上报数" v-model="selectLineChannel" clearable style="width: 140px;">
+              <el-option label="有数据" value="1"></el-option>
+              <el-option label="无数据" value="2"></el-option>
+            </el-select>
+            <el-select placeholder="选择应用" v-model="selectLineChannel" clearable style="width: 140px;">
+              <el-option label="教育行业Demo" value="1"></el-option>
+              <el-option label="游戏行业Demo" value="2"></el-option>
+              <el-option label="通用行业Demo" value="3"></el-option>
+            </el-select>
+            <el-select placeholder="操作人" v-model="selectLineChannel" clearable style="width: 140px;">
+              <el-option label="nina" value="1"></el-option>
+              <el-option label="luoyu" value="2"></el-option>
+              <el-option label="jcq" value="3"></el-option>
+              <el-option label="lwh" value="4"></el-option>
+            </el-select>
+          </div>
+          <el-table :height="500" :data="pagedTableRows" border>
+            <el-table-column prop="eventName" label="事件名" min-width="100" />
+            <el-table-column prop="eventIdentifier" label="事件标识符" min-width="140" />
+            <el-table-column label="计算状态" min-width="100">
+              <template #default="scope">
+                <el-tag :type="getStatusType(scope.row.status)">{{ scope.row.status }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="近30天事件上报数" min-width="100" align="center">
+              <template #default="scope">
+                <span v-if="scope.row.reportCount > 0">{{ scope.row.reportCount.toLocaleString() }}</span>
+                <span v-else>-</span>
+              </template>
+            </el-table-column>
+            <el-table-column label="上报平台" min-width="100">
+              <template #default="scope">
+                {{ getPlatformText(scope.row.platforms) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="应用" min-width="100">
+              <template #default="scope">
+                {{ getAppText(scope.row.apps) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="操作" min-width="200" fixed="right">
+              <template #default="scope">
+                <el-button @click="eventEdit(scope.row)" type="primary" link>编辑</el-button>
+                <el-button type="primary" link>详情</el-button>
+                <el-button v-if="scope.row.status === '计算中'" type="primary" link>暂停</el-button>
+                <el-button v-else-if="scope.row.status === '暂停计算'" type="primary" link>启用</el-button>
+                <el-button v-else type="primary" link>注册</el-button>
+                <el-button type="danger" link>删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <div class="flex justify-end mt-3">
+            <el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" background
+              layout="total, prev, pager, next, sizes" :total="tableRows.length" :page-sizes="[5, 10, 20]" />
+          </div>
+        </div>
+      </Lcard>
+      <!-- 添加批量导入弹窗 -->
+      <BatchImportModal v-model="showBatchImportModal" @import="handleBatchImport" />
+      <!-- 添加事件弹窗 -->
+      <AddEventModal v-model="showAddEventModal" @add="handleAddEvent" />
+    </div>
+  </div>
+</template>
+
+<script lang="ts" name="countMainTrend" setup>
+// import { ref, onMounted, watch, computed, defineAsyncComponent } from 'vue';
+import { useI18n } from 'vue-i18n';
+import * as echarts from 'echarts';
+// 引入组件
+const Lcard = defineAsyncComponent(() => import('/@/components/LYcom/Lcard/index.vue'));
+const Title = defineAsyncComponent(() => import('/@/components/Title/index.vue'));
+const { t } = useI18n();
+const eventTypeRadio = ref('1')
+
+onMounted(() => {
+  setTimeout(() => {
+  }, 500);
+});
+
+const emit = defineEmits(['update:eventId'])
+
+// 编辑事件
+const eventEdit = (data: any) => {
+  console.log(data);
+  emit('update:eventId', data.id)
+}
+
+// 导入批量导入弹窗组件
+const BatchImportModal = defineAsyncComponent(() => import('./BatchImportModal.vue'))
+
+// 控制批量导入弹窗显示
+const showBatchImportModal = ref(false)
+
+// 处理批量导入
+const handleBatchImport = (data: any) => {
+  console.log('批量导入数据:', data)
+  // 这里可以实现实际的导入逻辑
+  // 例如发送请求到后端处理Excel文件
+}
+
+// 导入添加事件弹窗组件
+const AddEventModal = defineAsyncComponent(() => import('./AddEventModal.vue'))
+
+// 控制添加事件弹窗显示
+const showAddEventModal = ref(false)
+
+// 处理添加事件
+const handleAddEvent = (data: any) => {
+  console.log('添加事件数据:', data)
+  // 这里可以实现实际的添加事件逻辑
+  // 例如发送请求到后端创建新事件
+}
+
+const searchData = reactive({
+  eventType: '1',
+  channel: '',
+  eventName: '',
+  eventHas: '',
+  appId: '',
+  evnetUser: '',
+})
+
+// 表格相关数据
+interface EventTableRow {
+  id: string;
+  eventName: string;
+  eventIdentifier: string;
+  status: string;
+  reportCount: number;
+  platforms: string[];
+  apps: string[];
+  operator: string;
+}
+
+// 表格相关(静态数据)
+const currentPage = ref(1);
+const pageSize = ref(10);
+
+const tableRows = ref<EventTableRow[]>([
+  {
+    id: '1',
+    eventName: '用户注册',
+    eventIdentifier: 'user_register',
+    status: '计算中',
+    reportCount: 12560,
+    platforms: ['Android', 'IOS'],
+    apps: ['教育行业Demo'],
+    operator: 'nina'
+  },
+  {
+    id: '2',
+    eventName: '用户登录',
+    eventIdentifier: 'user_login',
+    status: '计算中',
+    reportCount: 45210,
+    platforms: ['Android', 'IOS', 'Harmony'],
+    apps: ['教育行业Demo', '游戏行业Demo'],
+    operator: 'luoyu'
+  },
+  {
+    id: '3',
+    eventName: '商品浏览',
+    eventIdentifier: 'product_view',
+    status: '暂停计算',
+    reportCount: 0,
+    platforms: ['Android'],
+    apps: ['游戏行业Demo'],
+    operator: 'jcq'
+  },
+  {
+    id: '4',
+    eventName: '加入购物车',
+    eventIdentifier: 'add_to_cart',
+    status: '计算中',
+    reportCount: 8650,
+    platforms: ['Android', 'IOS'],
+    apps: ['游戏行业Demo'],
+    operator: 'lwh'
+  },
+  {
+    id: '5',
+    eventName: '完成支付',
+    eventIdentifier: 'payment_complete',
+    status: '计算中',
+    reportCount: 3240,
+    platforms: ['Android', 'IOS', 'Harmony'],
+    apps: ['教育行业Demo', '通用行业Demo'],
+    operator: 'nina'
+  },
+  {
+    id: '6',
+    eventName: '分享应用',
+    eventIdentifier: 'share_app',
+    status: '未注册',
+    reportCount: 0,
+    platforms: [],
+    apps: [],
+    operator: 'luoyu'
+  },
+  {
+    id: '7',
+    eventName: '查看个人资料',
+    eventIdentifier: 'view_profile',
+    status: '计算中',
+    reportCount: 15680,
+    platforms: ['Android', 'IOS'],
+    apps: ['教育行业Demo', '游戏行业Demo', '通用行业Demo'],
+    operator: 'jcq'
+  },
+  {
+    id: '8',
+    eventName: '搜索功能',
+    eventIdentifier: 'search_function',
+    status: '暂停计算',
+    reportCount: 0,
+    platforms: ['IOS'],
+    apps: ['教育行业Demo'],
+    operator: 'lwh'
+  },
+  {
+    id: '9',
+    eventName: '视频播放',
+    eventIdentifier: 'video_play',
+    status: '计算中',
+    reportCount: 27890,
+    platforms: ['Android', 'IOS'],
+    apps: ['教育行业Demo'],
+    operator: 'nina'
+  },
+  {
+    id: '10',
+    eventName: '收藏商品',
+    eventIdentifier: 'favorite_product',
+    status: '计算中',
+    reportCount: 5620,
+    platforms: ['Android', 'IOS', 'Harmony'],
+    apps: ['游戏行业Demo', '通用行业Demo'],
+    operator: 'luoyu'
+  },
+  {
+    id: '11',
+    eventName: '评论功能',
+    eventIdentifier: 'comment_function',
+    status: '未注册',
+    reportCount: 0,
+    platforms: [],
+    apps: [],
+    operator: 'jcq'
+  },
+  {
+    id: '12',
+    eventName: '消息推送点击',
+    eventIdentifier: 'push_notification_click',
+    status: '计算中',
+    reportCount: 9850,
+    platforms: ['Android', 'IOS'],
+    apps: ['教育行业Demo', '游戏行业Demo'],
+    operator: 'lwh'
+  },
+  {
+    id: '13',
+    eventName: '页面停留',
+    eventIdentifier: 'page_stay',
+    status: '计算中',
+    reportCount: 31560,
+    platforms: ['Android', 'Harmony'],
+    apps: ['通用行业Demo'],
+    operator: 'nina'
+  },
+  {
+    id: '14',
+    eventName: '应用启动',
+    eventIdentifier: 'app_launch',
+    status: '暂停计算',
+    reportCount: 0,
+    platforms: ['Android'],
+    apps: ['教育行业Demo'],
+    operator: 'luoyu'
+  },
+  {
+    id: '15',
+    eventName: '退出应用',
+    eventIdentifier: 'app_exit',
+    status: '未注册',
+    reportCount: 0,
+    platforms: [],
+    apps: [],
+    operator: 'jcq'
+  }
+]);
+
+const pagedTableRows = computed(() => {
+  const startIndex = (currentPage.value - 1) * pageSize.value;
+  return tableRows.value.slice(startIndex, startIndex + pageSize.value);
+});
+
+// 获取平台显示文本
+const getPlatformText = (platforms: string[]) => {
+  if (platforms.length === 0) return '-';
+  if (platforms.length > 2) return `${platforms[0]}等${platforms.length}个`;
+  return platforms.join('、');
+};
+
+// 获取应用显示文本
+const getAppText = (apps: string[]) => {
+  if (apps.length === 0) return '-';
+  if (apps.length > 2) return `${apps[0]}等${apps.length}个`;
+  return apps.join('、');
+};
+
+// 状态标签样式
+const getStatusType = (status: string) => {
+  switch (status) {
+    case '计算中':
+      return 'success';
+    case '暂停计算':
+      return 'warning';
+    case '未注册':
+      return 'info';
+    default:
+      return 'info';
+  }
+};
+</script>
+<style lang="scss" scoped>
+.search-box>div {
+  margin-right: 20px;
+}
+
+.msg {
+  height: 30px;
+  background-color: #f4f5fa;
+  line-height: 30px;
+  font-size: 12px;
+}
+
+.link {
+  color: #167af0;
+  cursor: pointer;
+}
+
+.box1 {
+  display: flex;
+  margin-top: 30px;
+
+  .card-box1,
+  .card-box2 {
+    display: flex;
+    flex-wrap: wrap;
+    justify-content: start;
+  }
+
+  .card-box1 {
+    .info-card {
+      width: 48%;
+    }
+  }
+
+  .card-box2 {
+    .info-card {
+      width: 33%;
+    }
+  }
+}
+
+.line {
+  margin: 60px -30px 30px;
+  height: 1px;
+  background-color: #E6E6E6;
+}
+</style>

+ 10 - 0
src/views/count/featureUsage/eventMannage/index.vue

@@ -0,0 +1,10 @@
+<template>
+  <EventTable v-if="!eventId" v-model:eventId="eventId"></EventTable>
+  <EventEdit v-else v-model:eventId="eventId"></EventEdit>
+</template>
+<script setup lang="ts" name="countMainTrend">
+const EventTable = defineAsyncComponent(() => import('./EventTable.vue'));
+const EventEdit = defineAsyncComponent(() => import('./EventEdit.vue'));
+
+const eventId = ref(null)
+</script>

+ 9 - 0
src/views/count/main/trend/icons/icon1.svg

@@ -1,3 +1,12 @@
 <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
 <svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
 <rect width="48" height="48" rx="14" fill="#167AF0" fill-opacity="0.1"/>
 <rect width="48" height="48" rx="14" fill="#167AF0" fill-opacity="0.1"/>
+<g clip-path="url(#clip0_554_3349)">
+<path d="M24 23.2507C25.0652 23.2507 26.1064 22.9348 26.9921 22.3431C27.8778 21.7513 28.568 20.9102 28.9757 19.9261C29.3833 18.942 29.4899 17.8591 29.2821 16.8144C29.0743 15.7697 28.5614 14.8101 27.8082 14.0569C27.055 13.3037 26.0954 12.7908 25.0507 12.583C24.006 12.3752 22.9231 12.4818 21.939 12.8894C20.955 13.2971 20.1138 13.9874 19.5221 14.873C18.9303 15.7587 18.6144 16.7999 18.6144 17.8651C18.6144 19.2934 19.1818 20.6633 20.1918 21.6733C21.2018 22.6833 22.5717 23.2507 24 23.2507ZM30.7824 25.4395C29.0628 24.187 27.03 23.4351 24.9093 23.2672C22.7886 23.0993 20.6628 23.5219 18.7675 24.4882C16.8723 25.4544 15.2816 26.9266 14.1718 28.7416C13.062 30.5565 12.4764 32.6433 12.48 34.7707V35.5195H27.4272C26.5728 34.7725 25.958 33.7898 25.6602 32.6946C25.3623 31.5994 25.3947 30.4407 25.7531 29.3638C26.1115 28.287 26.7801 27.34 27.6749 26.6419C28.5697 25.9437 29.6507 25.5254 30.7824 25.4395Z" fill="#167AF0"/>
+<path d="M34.08 29.7594H32.64V28.3194C32.64 27.9375 32.4883 27.5712 32.2183 27.3012C31.9482 27.0311 31.5819 26.8794 31.2 26.8794C30.8181 26.8794 30.4519 27.0311 30.1818 27.3012C29.9117 27.5712 29.76 27.9375 29.76 28.3194V29.7594H28.32C27.9381 29.7594 27.5719 29.9111 27.3018 30.1812C27.0317 30.4512 26.88 30.8175 26.88 31.1994C26.88 31.5813 27.0317 31.9476 27.3018 32.2176C27.5719 32.4877 27.9381 32.6394 28.32 32.6394H29.76V34.0794C29.76 34.4613 29.9117 34.8276 30.1818 35.0976C30.4519 35.3677 30.8181 35.5194 31.2 35.5194C31.5819 35.5194 31.9482 35.3677 32.2183 35.0976C32.4883 34.8276 32.64 34.4613 32.64 34.0794V32.6394H34.08C34.4619 32.6394 34.8282 32.4877 35.0983 32.2176C35.3683 31.9476 35.52 31.5813 35.52 31.1994C35.52 30.8175 35.3683 30.4512 35.0983 30.1812C34.8282 29.9111 34.4619 29.7594 34.08 29.7594Z" fill="#167AF0"/>
+</g>
+<defs>
+<clipPath id="clip0_554_3349">
+<rect width="23.04" height="23.04" fill="white" transform="translate(12.48 12.4795)"/>
+</clipPath>
+</defs>
 </svg>
 </svg>

文件差异内容过多而无法显示
+ 1 - 0
src/views/count/main/trend/icons/icon2.svg


+ 5 - 4
src/views/count/main/trend/index.vue

@@ -1,7 +1,7 @@
 <template>
 <template>
    <div class="layout-padding">
    <div class="layout-padding">
       <div class="!overflow-auto px-1">
       <div class="!overflow-auto px-1">
-         <Lcard :height="440">
+         <Lcard>
             <template #default>
             <template #default>
                <Title style="margin-bottom: 10px;" title="整体趋势">
                <Title style="margin-bottom: 10px;" title="整体趋势">
                   <el-popover class="box-item" placement="right" trigger="hover" width="250">
                   <el-popover class="box-item" placement="right" trigger="hover" width="250">
@@ -36,7 +36,7 @@
                   <span class="link">支持返还的字段</span>
                   <span class="link">支持返还的字段</span>
                </div>
                </div>
                <div class="box1">
                <div class="box1">
-                  <Lcard :height="280" style="margin-right: 20px;">
+                  <Lcard style="margin-right: 20px;">
                      <div class="card-box1">
                      <div class="card-box1">
                         <InfoCard title="订单数量" value="1,234" description="较昨日减少 1.2%">
                         <InfoCard title="订单数量" value="1,234" description="较昨日减少 1.2%">
                            <template #icon>
                            <template #icon>
@@ -60,7 +60,7 @@
                         </InfoCard>
                         </InfoCard>
                      </div>
                      </div>
                   </Lcard>
                   </Lcard>
-                  <Lcard :height="280">
+                  <Lcard>
                      <div class="card-box2">
                      <div class="card-box2">
                         <InfoCard title="订单数量" value="1,234" description="较昨日减少 1.2%">
                         <InfoCard title="订单数量" value="1,234" description="较昨日减少 1.2%">
                            <template #icon>
                            <template #icon>
@@ -92,7 +92,7 @@
                </div>
                </div>
             </template>
             </template>
          </Lcard>
          </Lcard>
-         <Lcard :height="975">
+         <Lcard>
             <el-date-picker style="float: left;" v-model="timeRange" type="datetimerange" range-separator="To"
             <el-date-picker style="float: left;" v-model="timeRange" type="datetimerange" range-separator="To"
                start-placeholder="Start date" end-placeholder="End date" />
                start-placeholder="Start date" end-placeholder="End date" />
             <div class="link" style="float: right;">订阅</div>
             <div class="link" style="float: right;">订阅</div>
@@ -419,6 +419,7 @@ function initCircleChart2(): void {
    .card-box2 {
    .card-box2 {
       .info-card {
       .info-card {
          width: 33%;
          width: 33%;
+         min-width: 150px;
       }
       }
    }
    }
 }
 }

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

部分文件因为文件数量过多而无法显示