Parcourir la source

feat:终端属性

cmy il y a 20 heures
Parent
commit
b82e6dfbf6

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

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

+ 1 - 1
src/utils/exportExcel.ts

@@ -51,7 +51,7 @@ export function exportToExcel(data: any[], fileName: string, sheetName: string =
     const link = document.createElement('a');
     const url = URL.createObjectURL(blob);
     link.setAttribute('href', url);
-    link.setAttribute('download', `${fileName} ${formatDate(new Date(), 'YYYY-mm-dd HH:MM:SS WWW')}.csv`);
+    link.setAttribute('download', `${fileName}.csv`);
     link.style.visibility = 'hidden';
     document.body.appendChild(link);
     link.click();

+ 7 - 17
src/views/count/churn/ascribe/index.vue

@@ -35,8 +35,8 @@
           <el-card shadow="none">
             <div class="trend-container">
               <div class="title">卸载设备全量预测</div>
-              <div class="tabs">
-                <div class="tabs-item" :class="{ active: activeTab === 'churnTrend' }"
+              <div class="card-tabs">
+                <div class="card-tab" :class="{ active: activeTab === 'churnTrend' }"
                   @click="handleTabClick('churnTrend')">
                   卸载流失设备(预测)
                   <el-tooltip effect="light" content="" placement="right-start">
@@ -67,7 +67,7 @@
                   </el-tooltip>
 
                 </div>
-                <div class="tabs-item" :class="{ active: activeTab === 'recallTrend' }"
+                <div class="card-tab" :class="{ active: activeTab === 'recallTrend' }"
                   @click="handleTabClick('recallTrend')">卸载召回设备(预测)
                   <el-tooltip effect="light" content="" placement="right-start">
                     <svg width="14" height="14" viewBox="0 0 14 14" fill="none"
@@ -264,24 +264,14 @@ svg {
       }
     }
 
-    .tabs {
-      display: flex;
+    .card-tabs {
       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;
+      .card-tab {
+        svg {
+          margin: 0;
         }
       }
     }

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

+ 21 - 74
src/views/count/churn/overview/index.vue

@@ -19,69 +19,8 @@
               </div>
             </div>
             <div class="top-content">
-              <div class="el-card" style="flex: 1; overflow: visible;">
-                <div class="content-item">
-                  <div class="content-item-left">
-                    <ProgressRing 
-                      :progress="0.7223"
-                      :size="111"
-                      background-color="rgba(239, 239, 239, 1)"
-                      progress-color="rgba(0, 188, 113, 1)"
-                      :stroke-width="15"
-                      :soft-corner="true"
-                    />
-                  </div>
-                  <div class="content-item-right">
-                    <div class="content-item-title">当周卸载流失设备数</div>
-                    <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">
-                    <ProgressRing 
-                      :progress="0.38"
-                      :size="111"
-                      background-color="rgba(239, 239, 239, 1)"
-                      progress-color="rgba(230, 66, 66, 1)"
-                      :stroke-width="15"
-                      :soft-corner="true"
-                    />
-                  </div>
-                  <div class="content-item-right">
-                    <div class="content-item-title">当周卸载召回设备数</div>
-                    <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>
+              <ProgressCard :title="'当周卸载流失设备数'" :counts="trendProgressData.uninstallCounts" :rates="trendProgressData.uninstallRates" />
+              <ProgressCard :title="'当周卸载召回设备数'" :counts="trendProgressData.recallCounts" :rates="trendProgressData.recallRates" />
             </div>
           </el-card>
         </el-col>
@@ -142,6 +81,9 @@ import LineChart from '/@/views/count/components/LineChart.vue'
 import ProgressRing from '/@/views/count/components/ProgressRing.vue'
 import { BasicTableProps, useTable } from '/@/hooks/table';
 import { ref, computed, reactive } from 'vue'
+import { uninstallTrend } from '/@/api/count/churn'
+import { formatDate } from '/@/utils/formatTime';
+import ProgressCard from './ProgressCard.vue'
 
 const cellStyle = ref({
   textAlign: 'center',
@@ -159,17 +101,12 @@ const headerCellStyle = ref({
   fontSize: '14px',
 })
 
-// 流失数据
-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 trendProgressData = ref({
+  recallCounts: 0,  
+  recallRates: 0,
+  uninstallCounts: 0,
+  uninstallRates: 0,
+})
 
 // 流失趋势数据
 const churnTrendData = ref([
@@ -264,6 +201,16 @@ 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 {

+ 7 - 1
src/views/count/churn/portrait/components/DataTable.vue

@@ -11,7 +11,7 @@
         <div class="percent-box" v-if="item.type === 'percentDom'">
           <div class="inner" :style="{ width: scope.row[item.prop] + '%' }"></div>
         </div>
-        <div v-else>{{ scope.row[item.prop] }}</div>
+        <div v-else class="ellipsis-box">{{ scope.row[item.prop] + (item.prop == 'percent' ? '%' : '') }}</div>
       </template>
     </el-table-column>
   </el-table>
@@ -80,4 +80,10 @@ const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTa
     background: rgba(109, 173, 249, 1);
   }
 }
+.ellipsis-box {
+  width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
 </style>

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

@@ -30,7 +30,7 @@
         </svg>
       </div>
       <div class="title">{{ title }}</div>
-      <div class="tags">
+      <div class="tags" style="min-height: 20px;">
         <div class="tag" v-for="tag in tags" :key="tag">{{ tag }}</div>
       </div>
     </div>

+ 84 - 95
src/views/count/churn/portrait/index.vue

@@ -26,17 +26,17 @@
             </svg>
             <el-form :inline="true" :model="form" label-width="0">
               <el-form-item label="">
-                <el-select v-model="form.time" placeholder="">
-                  <el-option label="按周查看" value="1" />
-                  <el-option label="按月查看" value="2" />
+                <el-select v-model="form.time" placeholder="" @change="selectTimeChange">
+                  <el-option label="按周查看" value="week" />
+                  <el-option label="按月查看" value="month" />
                 </el-select>
               </el-form-item>
               <el-form-item label="">
-                <el-date-picker :type="'daterange'" style="width: 242px;" v-model="form.dateArray" range-separator="至"
+                <el-date-picker :type="'daterange'" style="width: 242px;" v-model="form.dateArray" range-separator="至" @change="selectDateChange"
                   start-placeholder="开始日期" end-placeholder="结束日期" />
               </el-form-item>
               <el-form-item label="">
-                一周内,卸载流失设备数<span>510</span>
+                一周内,卸载流失设备数<span>{{ uninstallCount.count }}</span>
               </el-form-item>
               <div class="tips">您可以根据下方的卸载画像报表,查看易卸载用户和高粘性用户的特征,了解什么样的用户留不住。</div>
             </el-form>
@@ -51,25 +51,14 @@
           <el-row :gutter="12" style="padding: 0 12px 0; row-gap: 12px;">
             <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
               <el-card shadow="none">
-                <user-info title="不易卸载用户画像" :type="true" :tags="[
-                  '专科',
-                  '便携生活',
-                  '社交通讯',
-                  '同城本地社区'
-                ]" tips="卸载用户 vs. 活跃用户具备的明显特征,有以下特征的用户易卸载" style="margin-bottom: 24px;">
-
-                </user-info>
-                <data-table :data="data" :columns="columns"></data-table>
+                <user-info title="不易卸载用户画像" :type="true" :tags="hardData.length > 0 ? hardData.map((item: any) => item.name) : []" tips="卸载用户 vs. 活跃用户具备的明显特征,有以下特征的用户易卸载" style="margin-bottom: 24px;"></user-info>
+                <data-table :data="hardData" :columns="columns"></data-table>
               </el-card>
             </el-col>
             <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
               <el-card shadow="none">
-                <user-info title="不易卸载用户特征" :type="false" :tags="[
-                  '25-29岁',
-                  '三线城市',
-                  '普通消费'
-                ]" tips="活跃用户 vs. 卸载用户具备的明显差异点,有以下特征的人群卸载可能性低" style="margin-bottom: 24px;"></user-info>
-                <data-table :data="data" :columns="columns"></data-table>
+                <user-info title="不易卸载用户特征" :type="false" :tags="easyData.length > 0 ? easyData.map((item: any) => item.name) : []" tips="活跃用户 vs. 卸载用户具备的明显差异点,有以下特征的人群卸载可能性低" style="margin-bottom: 24px;"></user-info>
+                <data-table :data="easyData" :columns="columns"></data-table>
               </el-card>
             </el-col>
           </el-row>
@@ -100,14 +89,14 @@
           </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;">
-                <BarChart :data="usageTimeData" :is-multi-series="true" :series-names="seriesNames" title=""
+              <div style="padding-top: 8px;" v-if="echartsData.length > 0">
+                <BarChart :data="echartsData" :is-multi-series="true" :series-names="seriesNames" title=""
                   style="height: 320px;" :chartHeight="320" />
               </div>
             </el-col>
             <el-col :xs="24" :sm="24" :md="24" :lg="12" :xl="12">
               <div class="">
-                <data-table :data="data2" :columns="columns2"></data-table>
+                <data-table :data="usageTimeData" :columns="columns2"></data-table>
               </div>
             </el-col>
           </el-row>
@@ -121,100 +110,100 @@ import { ref } from 'vue';
 import UserInfo from './components/UserInfo.vue';
 import DataTable from './components/DataTable.vue';
 import BarChart from '/@/views/count/components/BarChart.vue';
+import { formatDate } from '/@/utils/formatTime';
+import { uninstallPortrait } from '/@/api/count/churn';
 
 const columns = ref([
-  { label: '用户特征', prop: 'feature', width: '100px' },
+  { 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: 'usageTime', width: '150px' },
+  { label: '卸载前使用App次数', prop: 'phase', width: '150px' },
   { prop: 'percent', type: 'percentDom' },
   { label: '百分比', prop: 'percent', width: '100px' },
   { label: '卸载用户数', prop: 'count', width: '100px' },
 ])
 
 const form = ref({
-  time: '1',
-  dateArray: []
+  time: 'week',
+  dateArray: [formatDate(new Date(new Date().setDate(new Date().getDate() - 7)), 'YYYY-mm-dd'), formatDate(new Date(), 'YYYY-mm-dd')]
 })
 
-const data = ref([
-  { feature: '专科', tgi: 3.74, percent: 3.22 },
-  { feature: '便携生活智能生...', tgi: 3.74, percent: 3.22 },
-  { feature: '社交通讯', tgi: 3.74, percent: 3.22 },
-  { feature: '同城本地社区', tgi: 3.74, percent: 3.22 },
-  { feature: '普通消费', tgi: 3.74, percent: 3.22 },
-])
-
-const data2 = ref([
-  { usageTime: '1次', percent: 3.22, count: 3.22 },
-  { usageTime: '2次', percent: 3.22, count: 3.22 },
-  { usageTime: '3次', percent: 3.22, count: 3.22 },
-  { usageTime: '4次', percent: 3.22, count: 3.22 },
-  { usageTime: '5次', percent: 3.22, count: 3.22 },
-  { usageTime: '6次以上', percent: 3.22, count: 3.22 },
-])
-
-// 多系列数据对比
-const usageTimeData = ref([
-  {
-    name: '未使用',
-    values: []
-  },
-  {
-    name: '使用1次',
-    values: []
-  },
-  {
-    name: '使用2次',
-    values: []
-  },
-  {
-    name: '使用3次',
-    values: []
-  },
-  {
-    name: '使用4次',
-    values: []
-  },
-  {
-    name: '使用5次',
-    values: []
-  },
-  {
-    name: '6次以上',
-    values: []
-  }
-])
-
-const seriesNames = ref<string[]>([]);
-
-const handleDateChange = (val: string[]) => {
-  // 强制触发响应式更新
-  seriesNames.value = [...val];
+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 newData = usageTimeData.value.map((item: any) => ({
-    ...item,
-    values: val.map((date: string) => {
-      const value = Math.floor(Math.random() * 100);
-      return {
-        value: value,
-        percentage: `${value}%`
-      }
-    })
-  }));
+const init = () => {
+  uninstallPortrait({
+    startDate: formatDate(new Date(form.value.dateArray[0]), 'YYYY-mm-dd'),
+    endDate: formatDate(new Date(form.value.dateArray[1]), 'YYYY-mm-dd'),
+    timeUnit: form.value.time,
+  }).then(res => {
+    uninstallCount.value = {
+      count: res.data?.uninstallCount.count,
+    }
+    // if (res.data?.uninstallPortraitPhaseList) {
+      const newData: any[] = [];
+      easyData.value = [];
+      hardData.value = [];
+      usageTimeData.value = [];
+      res.data?.uninstallPortraitPhaseList.forEach((item: any) => {
+        newData.push({
+          name: item.phase,
+          values: [
+            {
+              value: item.count,
+              percentage: item.rate + "%",
+            }
+          ]
+        });
+      });
+      echartsData.value = newData;
+      res.data?.uninstallPortraitPhaseList.forEach((item: any) => {
+        usageTimeData.value.push({
+          ...item,
+          percent: item.rate,
+        });
+      })
+    // }
+    // if (res.data?.uninstallPortraitCharactList) {
+      res.data?.uninstallPortraitCharactList.forEach((item: any) => {
+        if(item.status == 0){
+          easyData.value.push({
+            ...item,
+            percent: item.percent.substring(0, item.percent.length - 1),
+          });
+        } else {
+          hardData.value.push({
+            ...item,
+            percent: item.percent.substring(0, item.percent.length - 1),
+          });
+        }
+      })
+    // }
+  })
+}
 
-  console.log(newData);
+const selectTimeChange = () => {
+  console.log(form.value.time);
+  init();
+}
 
-  usageTimeData.value = newData;
+const selectDateChange = () => {
+  console.log(form.value.dateArray);
+  init();
 }
 
 onMounted(() => {
-  handleDateChange(['卸载用户数']);
+  init();
 })
 
 </script>

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

@@ -196,20 +196,21 @@ const chartOption = computed(() => {
         // return 'right'; // 固定在右侧
         // return [10, 10]; // 固定坐标位置
       },
-      // formatter: function (params: any) {
-      //   if (!props.isMultiSeries) {
-      //     const data = params[0]
-      //     const percentageText = data.data.percentage ? ` (${data.data.percentage})` : ''
-      //     return `${data.name}<br/>设备数:${data.value}${percentageText}`
-      //   } 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>'
-      //   }
-      // },
+      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,

+ 46 - 11
src/views/count/components/ExportToCSV.vue

@@ -1,7 +1,7 @@
 <template>
   <div>
     <div class="table-container" :style="props.tableStyle">
-      <div class="btn-toggle-table" :class="{ 'hide-table': hideTable }" @click="hideTable = !hideTable">
+      <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"
@@ -15,15 +15,24 @@
         </span>
       </div>
 
-      <el-table class="table" :data="paginatedData" row-key="name" style="width: 100%"
-        :cell-style="cellStyle" :header-cell-style="headerCellStyle" v-if="!hideTable">
-        <el-table-column v-for="(column, index) in columns" :key="column.prop" :label="column.label"
-          :prop="column.prop" show-overflow-tooltip :formatter="statusFormatter">
-          <template #default="scope">
-            {{ scope.row[column.prop] }}
-          </template>
-        </el-table-column>
-      </el-table>
+        <div style="overflow-x: auto;">
+          <el-table class="table" :data="paginatedData" row-key="name" style="width: 100%"
+            :cell-style="cellStyle" :header-cell-style="headerCellStyle" v-if="!hideTable">
+            <el-table-column v-for="(column, index) in columns" :key="column.prop" :label="column.label"
+              :prop="column.prop" show-overflow-tooltip :formatter="statusFormatter">
+              <template #default="scope">
+                <!-- 省市列特殊处理 -->
+                <span v-if="column.prop === 'province'" 
+                      class="province-link" 
+                      @click="handleProvinceClick(scope.row[column.prop])">
+                  {{ scope.row[column.prop] }}
+                </span>
+                <!-- 其他列正常显示 -->
+                <span v-else>{{ scope.row[column.prop] }}</span>
+              </template>
+            </el-table-column>
+          </el-table>
+      </div>
 
       <pagination v-if="!hideTable && !hidePage" @current-change="handleCurrentChange" @size-change="handleSizeChange"
         v-bind="state.pagination">
@@ -79,9 +88,22 @@ const props = defineProps({
   hidePage: {
     type: Boolean,
     default: false
+  },
+  hideToggle: {
+    type: Boolean,
+    default: false
+  },
+  size: {
+    type: Number,
+    default: 5
   }
 })
 
+// 定义 emit 事件
+const emit = defineEmits<{
+  provinceClick: [province: string]
+}>()
+
 // 表格数据
 const dataList = ref(props.data);
 const hideTable = ref(props.hideTable);
@@ -92,7 +114,7 @@ const state: BasicTableProps = reactive<BasicTableProps>({
   pageList: () => Promise.resolve([]),
   pagination: {
     current: 1,
-    size: 5,
+    size: props.size,
     total: 0,
     pageSizes: [5, 10, 20, 50, 100]
   },
@@ -132,6 +154,11 @@ const statusFormatter = (row: any, column: any, cellValue: any, index: any) => {
   return cellValue || '--';
 }
 
+// 处理省市点击事件
+const handleProvinceClick = (province: string) => {
+  emit('provinceClick', province);
+}
+
 const handleExportData = (data: any, columns: any, fileName: string) => {
   try {
     // 检查是否有数据
@@ -177,6 +204,14 @@ svg {
   }
 }
 
+.province-link {
+  color: rgba(22, 122, 240, 1);
+  cursor: pointer;
+  &:hover {
+    color: rgba(22, 122, 240, 1);
+  }
+}
+
 :deep(.el-input__inner),
 :deep(.el-date-editor--dates .el-input__wrapper) {
   cursor: pointer;

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -44,11 +44,15 @@
     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 {