deepchart后台管理系统
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1320 lines
44 KiB

  1. <template>
  2. <div class="user-login-stats-container">
  3. <div class="tab-header">
  4. <div
  5. class="tab-item"
  6. :class="{ active: activeTab === 'loginData' }"
  7. @click="activeTab = 'loginData'"
  8. >
  9. 登录数据
  10. </div>
  11. <!-- <div
  12. class="tab-item"
  13. :class="{ active: activeTab === 'regionalData' }"
  14. @click="activeTab = 'regionalData'"
  15. >
  16. 各地区登录数据
  17. </div> -->
  18. </div>
  19. <!-- 登录数据 Tab -->
  20. <div v-show="activeTab === 'loginData'" class="tab-content" v-loading="loading">
  21. <!-- 搜索栏 -->
  22. <div class="search-bar">
  23. <div class="search-label">地区</div>
  24. <el-select v-model="selectedRegion" placeholder="请选择所属地区" style="width: 200px; margin-right: 20px;">
  25. <el-option label="全部" value="all" />
  26. <el-option
  27. v-for="item in regionOptions"
  28. :key="item.value"
  29. :label="item.label"
  30. :value="item.value"
  31. />
  32. </el-select>
  33. <div class="search-label">时间段查询</div>
  34. <el-date-picker
  35. v-model="dateRange"
  36. type="daterange"
  37. range-separator="至"
  38. start-placeholder="开始时间"
  39. end-placeholder="结束时间"
  40. size="default"
  41. />
  42. <el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
  43. <el-button type="primary" class="reset-btn" @click="handleReset">重置</el-button>
  44. <el-button type="danger" class="export-btn" @click="handleExportLogin">数据导出</el-button>
  45. </div>
  46. <!-- 统计卡片 -->
  47. <div class="stats-row">
  48. <!-- 今日登录用户数 -->
  49. <div class="stat-card purple-gradient big-card">
  50. <div class="card-title">
  51. <el-icon><UserFilled /></el-icon> {{ statsTitle }}
  52. </div>
  53. <div class="big-card-content">
  54. <div class="card-value">{{ loginStats.total }}</div>
  55. <div v-if="loginStats.total_growth" class="card-tag" :class="getGrowthClass(loginStats.total_growth)">
  56. {{ getGrowthText(loginStats.total_growth) }}
  57. </div>
  58. </div>
  59. </div>
  60. <div class="right-stats-col">
  61. <!-- 今日登录会员用户数 -->
  62. <div class="stat-card orange-gradient small-card">
  63. <div class="top-row">
  64. <div class="card-title">
  65. <el-icon><Trophy /></el-icon> {{ statsTitle }}
  66. </div>
  67. <div class="card-value-small">{{ loginStats.member }}</div>
  68. </div>
  69. <div class="card-tag-wrapper">
  70. <div v-if="loginStats.member_growth" class="card-tag" :class="getGrowthClass(loginStats.member_growth)">
  71. {{ getGrowthText(loginStats.member_growth) }}
  72. </div>
  73. </div>
  74. </div>
  75. <!-- 今日登录非网用户数 -->
  76. <div class="stat-card blue-gradient small-card">
  77. <div class="top-row">
  78. <div class="card-title">
  79. <el-icon><User /></el-icon> {{ statsTitle }}
  80. </div>
  81. <div class="card-value-small">{{ loginStats.normal }}</div>
  82. </div>
  83. <div class="card-tag-wrapper">
  84. <div v-if="loginStats.normal_growth" class="card-tag" :class="getGrowthClass(loginStats.normal_growth)">
  85. {{ getGrowthText(loginStats.normal_growth) }}
  86. </div>
  87. </div>
  88. </div>
  89. </div>
  90. </div>
  91. <!-- 近7天登录趋势 -->
  92. <div class="chart-section">
  93. <div class="section-title"><el-icon><TrendCharts /></el-icon> {{ chartTrendTitle }}</div>
  94. <div ref="chartTrendRef" class="chart-box-large"></div>
  95. </div>
  96. <!-- 表格1: 今日登录数据 -->
  97. <div class="detail-section">
  98. <div class="section-title"><el-icon><DataLine /></el-icon> {{ statsTitle }}</div>
  99. <el-table :data="loginTableData1" style="width: 100%" :header-cell-style="headerCellStyle">
  100. <el-table-column prop="channel" label="来源渠道" align="center" />
  101. <el-table-column prop="total" :label="statsTitle + '登录总数'" align="center" />
  102. <el-table-column prop="dailyNew" label="较昨日新增" align="center">
  103. <template #default="scope">
  104. <span :class="getValueColorClass(scope.row.dailyNew)">{{ scope.row.dailyNew }}</span>
  105. </template>
  106. </el-table-column>
  107. <el-table-column prop="percent" label="占比" align="center" />
  108. </el-table>
  109. </div>
  110. <!-- 表格2: 会员登录数据 -->
  111. <div class="detail-section">
  112. <div class="section-title"><el-icon><Trophy /></el-icon> {{ statsTitle }}</div>
  113. <el-table :data="loginTableData2" style="width: 100%" :header-cell-style="headerCellStyle">
  114. <el-table-column prop="channel" label="来源渠道" align="center" />
  115. <el-table-column prop="total" :label="statsTitle + '登录会员数'" align="center" />
  116. <el-table-column prop="dailyNew" label="较昨日新增" align="center">
  117. <template #default="scope">
  118. <span :class="getValueColorClass(scope.row.dailyNew)">{{ scope.row.dailyNew }}</span>
  119. </template>
  120. </el-table-column>
  121. <el-table-column prop="rate" label="会员登录率" align="center" />
  122. </el-table>
  123. </div>
  124. <!-- 表格3: 非网登录数据 -->
  125. <div class="detail-section">
  126. <div class="section-title"><el-icon><User /></el-icon> {{ statsTitle }}</div>
  127. <el-table :data="loginTableData3" style="width: 100%" :header-cell-style="headerCellStyle">
  128. <el-table-column prop="channel" label="来源渠道" align="center" />
  129. <el-table-column prop="total" :label="statsTitle + '登录非网数'" align="center" />
  130. <el-table-column prop="dailyNew" label="较昨日新增" align="center">
  131. <template #default="scope">
  132. <span :class="getValueColorClass(scope.row.dailyNew)">{{ scope.row.dailyNew }}</span>
  133. </template>
  134. </el-table-column>
  135. <el-table-column prop="rate" label="非网登录率" align="center" />
  136. </el-table>
  137. </div>
  138. </div>
  139. <!-- 各地区登录数据 Tab -->
  140. <div v-show="activeTab === 'regionalData'" class="tab-content" v-loading="loadingRegion">
  141. <!-- 搜索栏 -->
  142. <div class="search-bar">
  143. <!-- 移除地区查询 -->
  144. <div class="search-label">时间段查询</div>
  145. <el-date-picker
  146. v-model="dateRangeRegion"
  147. type="daterange"
  148. range-separator="至"
  149. start-placeholder="开始时间"
  150. end-placeholder="结束时间"
  151. size="default"
  152. />
  153. <el-button type="primary" class="search-btn" @click="handleSearchRegion">搜索</el-button>
  154. <el-button type="primary" class="reset-btn" @click="handleResetRegion">重置</el-button>
  155. <el-button type="danger" class="export-btn" @click="handleExportRegion">数据导出</el-button>
  156. </div>
  157. <!-- 表格1: 各地区活跃数据 -->
  158. <div class="detail-section">
  159. <div class="section-title"><el-icon><Location /></el-icon> </div>
  160. <el-table :data="regionalTableData1" style="width: 100%" :header-cell-style="headerCellStyle">
  161. <el-table-column prop="region" label="地区" align="center" />
  162. <el-table-column prop="dailyActive" label="日活跃用户" align="center" />
  163. <el-table-column prop="weeklyActive" label="周活跃用户" align="center" />
  164. <el-table-column prop="monthlyActive" label="月活跃用户" align="center" />
  165. <el-table-column prop="periodActive" label="区间活跃用户" align="center" />
  166. <el-table-column prop="percent" label="活跃度占比" align="center" />
  167. </el-table>
  168. </div>
  169. <!-- 表格2: 各地区会员活跃数据 -->
  170. <div class="detail-section">
  171. <div class="section-title"><el-icon><Trophy /></el-icon> </div>
  172. <el-table :data="regionalTableData2" style="width: 100%" :header-cell-style="headerCellStyle">
  173. <el-table-column prop="region" label="地区" align="center" />
  174. <el-table-column prop="dailyActive" label="日活跃用户" align="center" />
  175. <el-table-column prop="weeklyActive" label="周活跃用户" align="center" />
  176. <el-table-column prop="monthlyActive" label="月活跃用户" align="center" />
  177. <el-table-column prop="periodActive" label="区间活跃用户" align="center" />
  178. <el-table-column prop="percent" label="活跃度占比" align="center" />
  179. </el-table>
  180. </div>
  181. <!-- 表格3: 各地区非网活跃数据 -->
  182. <div class="detail-section">
  183. <div class="section-title"><el-icon><User /></el-icon> </div>
  184. <el-table :data="regionalTableData3" style="width: 100%" :header-cell-style="headerCellStyle">
  185. <el-table-column prop="region" label="地区" align="center" />
  186. <el-table-column prop="dailyActive" label="日活跃用户" align="center" />
  187. <el-table-column prop="weeklyActive" label="周活跃用户" align="center" />
  188. <el-table-column prop="monthlyActive" label="月活跃用户" align="center" />
  189. <el-table-column prop="periodActive" label="区间活跃用户" align="center" />
  190. <el-table-column prop="percent" label="活跃度占比" align="center" />
  191. </el-table>
  192. </div>
  193. <!-- 图表: 各地区日活跃用户 -->
  194. <div class="chart-section">
  195. <div class="section-title"><el-icon><BarChart /></el-icon>
  196. <el-button size="small" style="margin-left: auto;" @click="toggleBarChartMode">切换</el-button>
  197. </div>
  198. <div ref="chartRegionBarRef" class="chart-box-large"></div>
  199. </div>
  200. <!-- 图表: 地区分布 -->
  201. <div class="chart-section">
  202. <div class="section-title"><el-icon><PieChart /></el-icon> </div>
  203. <div ref="chartRegionPieRef" class="chart-box-large" style="height: 400px;"></div>
  204. </div>
  205. <div class="charts-row">
  206. <div class="chart-section half-width">
  207. <div class="section-title">各地区会员用户分布</div>
  208. <div ref="chartRegionMemberPieRef" class="chart-box-medium"></div>
  209. </div>
  210. <div class="chart-section half-width">
  211. <div class="section-title">各地区非网用户分布</div>
  212. <div ref="chartRegionNonMemberPieRef" class="chart-box-medium"></div>
  213. </div>
  214. </div>
  215. </div>
  216. <!-- 悬浮刷新时间 -->
  217. <div class="refresh-time" v-if="lastUpdateTime">
  218. 数据刷新时间{{ lastUpdateTime }}
  219. </div>
  220. </div>
  221. </template>
  222. <script setup>
  223. import { ref, onMounted, nextTick, watch } from 'vue';
  224. import { useRoute, useRouter } from 'vue-router';
  225. import * as echarts from 'echarts';
  226. import { getUserLoginList, getUserLoginTrend, getRegionActiveData, getRegionActiveDataHistogram, getUserLoginChannel, getUserLoginChannelMember, getUserLoginChannelNoMember, getRegionUserDistribution, exportRegionActiveData, getRegionsList, exportUserLoginPDF } from '../../api/platformData';
  227. import { ElMessage } from 'element-plus';
  228. const route = useRoute();
  229. const router = useRouter();
  230. const activeTab = ref(route.query.tab || 'loginData');
  231. const dateRange = ref('');
  232. const selectedRegion = ref('');
  233. const searchRegion = ref('');
  234. const dateRangeRegion = ref('');
  235. const loading = ref(false);
  236. const loadingRegion = ref(false);
  237. const lastUpdateTime = ref('');
  238. const chartTrendRef = ref(null);
  239. let chartTrendInstance = null;
  240. const chartRegionBarRef = ref(null);
  241. let chartRegionBarInstance = null;
  242. const chartRegionPieRef = ref(null);
  243. const chartRegionMemberPieRef = ref(null);
  244. const chartRegionNonMemberPieRef = ref(null);
  245. // 柱状图显示模式:'all' (全部用户), 'detail' (分身份用户)
  246. const barChartMode = ref('all');
  247. // 响应式数据:地区活跃数据
  248. const regionalTableData1 = ref([]);
  249. const regionalTableData2 = ref([]);
  250. const regionalTableData3 = ref([]);
  251. const regionOptions = ref([]);
  252. // 获取地区列表
  253. const fetchRegionOptions = async () => {
  254. try {
  255. const res = await getRegionsList();
  256. console.log("获取地区列表响应:", res);
  257. const data = res.data || res;
  258. if (data && data.list) {
  259. regionOptions.value = data.list.map(region => ({ label: region, value: region }));
  260. }
  261. } catch (e) {
  262. console.error('获取地区列表失败:', e);
  263. }
  264. };
  265. // 初始化查询参数
  266. const initQueryParams = () => {
  267. const { start_time, end_time, region, r_start_time, r_end_time } = route.query;
  268. // Tab 1 Params
  269. if (start_time && end_time) {
  270. dateRange.value = [new Date(start_time), new Date(end_time)];
  271. }
  272. if (region) {
  273. selectedRegion.value = region;
  274. }
  275. // Tab 2 Params
  276. if (r_start_time && r_end_time) {
  277. dateRangeRegion.value = [new Date(r_start_time), new Date(r_end_time)];
  278. }
  279. };
  280. initQueryParams();
  281. // 响应式数据:登录数据统计
  282. const loginStats = ref({
  283. total: 0,
  284. total_growth: '0%',
  285. member: 0,
  286. member_growth: '0%',
  287. normal: 0,
  288. normal_growth: '0%'
  289. });
  290. // 获取增长率的样式类
  291. const chartTrendTitle = ref('近7天登录趋势');
  292. const statsTitle = ref('今日');
  293. const getGrowthClass = (growthStr) => {
  294. if (!growthStr) return '';
  295. return growthStr.startsWith('-') ? 'down' : 'up';
  296. };
  297. // 获取增长率的显示文本(添加箭头)
  298. const getGrowthText = (growthStr) => {
  299. if (!growthStr) return '';
  300. const isDown = growthStr.startsWith('-');
  301. const arrow = isDown ? '↓' : '↑';
  302. const prefix = isDown ? '较昨日减少' : '较昨日增加';
  303. const value = growthStr.replace('-', '');
  304. return `${prefix}${arrow} ${value}`;
  305. };
  306. // 获取表格数值颜色样式
  307. const getValueColorClass = (val) => {
  308. if (!val || val === '-') return '';
  309. const strVal = String(val);
  310. if (strVal.startsWith('+')) return 'text-green';
  311. if (strVal.startsWith('-')) return 'text-red';
  312. if (strVal === '0') return 'text-black';
  313. // 如果是数字且大于0(不带+号的情况)
  314. if (!isNaN(parseFloat(strVal)) && parseFloat(strVal) > 0) return 'text-green';
  315. return '';
  316. };
  317. // 格式化日期
  318. const formatDate = (date) => {
  319. if (!date) return '';
  320. const d = new Date(date);
  321. const pad = (n) => n < 10 ? '0' + n : n;
  322. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
  323. };
  324. const formatDateTime = (date) => {
  325. if (!date) return '';
  326. const d = new Date(date);
  327. const pad = (n) => n < 10 ? '0' + n : n;
  328. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
  329. };
  330. const fetchLoginData = async () => {
  331. let params = {};
  332. if (dateRange.value && dateRange.value.length === 2) {
  333. params.start_time = formatDate(dateRange.value[0]);
  334. params.end_time = formatDate(dateRange.value[1]);
  335. statsTitle.value = `${params.start_time}${params.end_time}`;
  336. } else {
  337. statsTitle.value = '今日';
  338. }
  339. if (selectedRegion.value && selectedRegion.value !== 'all') {
  340. params.region = selectedRegion.value;
  341. }
  342. try {
  343. const res = await getUserLoginList(params);
  344. console.log("获取用户登录数据响应:", res);
  345. // 兼容处理拦截器
  346. const data = res.list ? res : (res.data && res.data.list ? res.data : null);
  347. if (data && data.list) {
  348. loginStats.value = data.list;
  349. }
  350. } catch (e) {
  351. console.error('获取用户登录数据失败:', e);
  352. }
  353. };
  354. const fetchTrendData = async () => {
  355. let params = {};
  356. if (dateRange.value && dateRange.value.length === 2) {
  357. params.start_time = formatDate(dateRange.value[0]);
  358. params.end_time = formatDate(dateRange.value[1]);
  359. chartTrendTitle.value = `${params.start_time}${params.end_time} 登录趋势`;
  360. } else {
  361. chartTrendTitle.value = '近7天登录趋势';
  362. }
  363. try {
  364. const res = await getUserLoginTrend(params);
  365. console.log("获取用户登录趋势响应:", res);
  366. // 兼容处理拦截器
  367. const data = res.list ? res : (res.data && res.data.list ? res.data : null);
  368. if (data && data.list) {
  369. updateTrendChart(data.list);
  370. }
  371. } catch (e) {
  372. console.error('获取用户登录趋势失败:', e);
  373. }
  374. };
  375. const updateTrendChart = (list) => {
  376. if (!chartTrendRef.value) return;
  377. if (!chartTrendInstance) {
  378. chartTrendInstance = echarts.init(chartTrendRef.value);
  379. }
  380. const dates = list.map(item => item.date);
  381. const allUser = list.map(item => item.all_user);
  382. const member = list.map(item => item.member);
  383. const option = {
  384. tooltip: { trigger: 'axis' },
  385. legend: { data: ['所有用户', '会员用户'], top: 'top' },
  386. grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
  387. xAxis: {
  388. type: 'category',
  389. boundaryGap: false,
  390. data: dates
  391. },
  392. yAxis: { type: 'value' },
  393. series: [
  394. {
  395. name: '所有用户',
  396. type: 'line',
  397. data: allUser,
  398. smooth: true,
  399. itemStyle: { color: '#40a9ff' }
  400. },
  401. {
  402. name: '会员用户',
  403. type: 'line',
  404. data: member,
  405. smooth: true,
  406. itemStyle: { color: '#52c41a' }
  407. }
  408. ]
  409. };
  410. chartTrendInstance.setOption(option);
  411. };
  412. const fetchLoginChannelData = async () => {
  413. let params = {};
  414. if (dateRange.value && dateRange.value.length === 2) {
  415. params.start_time = formatDate(dateRange.value[0]);
  416. params.end_time = formatDate(dateRange.value[1]);
  417. }
  418. if (selectedRegion.value && selectedRegion.value !== 'all') {
  419. params.region = selectedRegion.value;
  420. }
  421. try {
  422. const res = await getUserLoginChannel(params);
  423. console.log("获取今日登录渠道数据响应:", res);
  424. // 兼容处理拦截器
  425. const data = res.list ? res : (res.data && res.data.list ? res.data : null);
  426. if (data && data.list) {
  427. loginTableData1.value = data.list.map(item => ({
  428. channel: item.source,
  429. total: item.today_count,
  430. dailyNew: item.growth_value,
  431. percent: item.ratio + '%'
  432. }));
  433. }
  434. } catch (e) {
  435. console.error('获取今日登录渠道数据失败:', e);
  436. }
  437. };
  438. const fetchLoginChannelMemberData = async () => {
  439. let params = {};
  440. if (dateRange.value && dateRange.value.length === 2) {
  441. params.start_time = formatDate(dateRange.value[0]);
  442. params.end_time = formatDate(dateRange.value[1]);
  443. }
  444. if (selectedRegion.value && selectedRegion.value !== 'all') {
  445. params.region = selectedRegion.value;
  446. }
  447. try {
  448. const res = await getUserLoginChannelMember(params);
  449. console.log("获取会员登录渠道数据响应:", res);
  450. // 兼容处理拦截器
  451. const data = res.list ? res : (res.data && res.data.list ? res.data : null);
  452. if (data && data.list) {
  453. loginTableData2.value = data.list.map(item => ({
  454. channel: item.source,
  455. total: item.today_count,
  456. dailyNew: item.growth_value,
  457. rate: item.ratio + '%'
  458. }));
  459. }
  460. } catch (e) {
  461. console.error('获取会员登录渠道数据失败:', e);
  462. }
  463. };
  464. const fetchLoginChannelNoMemberData = async () => {
  465. let params = {};
  466. if (dateRange.value && dateRange.value.length === 2) {
  467. params.start_time = formatDate(dateRange.value[0]);
  468. params.end_time = formatDate(dateRange.value[1]);
  469. }
  470. if (selectedRegion.value && selectedRegion.value !== 'all') {
  471. params.region = selectedRegion.value;
  472. }
  473. try {
  474. const res = await getUserLoginChannelNoMember(params);
  475. console.log("获取非网登录渠道数据响应:", res);
  476. // 兼容处理拦截器
  477. const data = res.list ? res : (res.data && res.data.list ? res.data : null);
  478. if (data && data.list) {
  479. loginTableData3.value = data.list.map(item => ({
  480. channel: item.source,
  481. total: item.today_count,
  482. dailyNew: item.growth_value,
  483. rate: item.ratio + '%'
  484. }));
  485. }
  486. } catch (e) {
  487. console.error('获取非网登录渠道数据失败:', e);
  488. }
  489. };
  490. // 统一获取登录数据 Tab 的所有数据
  491. const fetchAllLoginData = async () => {
  492. loading.value = true;
  493. try {
  494. // 使用 Promise.all 并行请求所有接口
  495. await Promise.all([
  496. fetchTrendData(),
  497. fetchLoginData(),
  498. fetchLoginChannelData(),
  499. fetchLoginChannelMemberData(),
  500. fetchLoginChannelNoMemberData()
  501. ]);
  502. lastUpdateTime.value = formatDateTime(new Date());
  503. } catch (error) {
  504. console.error('获取登录数据失败:', error);
  505. } finally {
  506. loading.value = false;
  507. }
  508. };
  509. const handleExportLogin = async () => {
  510. loading.value = true;
  511. let params = {};
  512. if (dateRange.value && dateRange.value.length === 2) {
  513. params.start_time = formatDate(dateRange.value[0]);
  514. params.end_time = formatDate(dateRange.value[1]);
  515. }
  516. if (selectedRegion.value && selectedRegion.value !== 'all') {
  517. params.region = selectedRegion.value;
  518. }
  519. try {
  520. const res = await exportUserLoginPDF(params);
  521. console.log("导出登录数据PDF响应(Blob):", res);
  522. // 创建Blob对象,处理二进制流下载
  523. const blob = new Blob([res], { type: 'application/pdf' });
  524. const url = window.URL.createObjectURL(blob);
  525. const link = document.createElement('a');
  526. link.href = url;
  527. // 设置下载文件名,添加时间戳防止重名
  528. const fileName = `用户登录数据_${formatDate(new Date())}.pdf`;
  529. link.setAttribute('download', fileName);
  530. document.body.appendChild(link);
  531. link.click();
  532. // 清理资源
  533. document.body.removeChild(link);
  534. window.URL.revokeObjectURL(url);
  535. ElMessage.success('导出成功');
  536. } catch (e) {
  537. console.error('导出登录数据失败:', e);
  538. ElMessage.error('导出请求发生错误');
  539. } finally {
  540. loading.value = false;
  541. }
  542. };
  543. const handleSearch = () => {
  544. // 更新 URL 参数
  545. const query = { ...route.query };
  546. if (dateRange.value && dateRange.value.length === 2) {
  547. query.start_time = formatDate(dateRange.value[0]);
  548. query.end_time = formatDate(dateRange.value[1]);
  549. } else {
  550. delete query.start_time;
  551. delete query.end_time;
  552. }
  553. if (selectedRegion.value && selectedRegion.value !== 'all') {
  554. query.region = selectedRegion.value;
  555. } else {
  556. delete query.region;
  557. }
  558. router.replace({ query });
  559. fetchAllLoginData();
  560. };
  561. const handleReset = () => {
  562. dateRange.value = '';
  563. selectedRegion.value = '';
  564. // 清除 URL 参数
  565. const query = { ...route.query };
  566. delete query.start_time;
  567. delete query.end_time;
  568. delete query.region;
  569. router.replace({ query });
  570. fetchAllLoginData();
  571. };
  572. // 统一获取地区数据 Tab 的所有数据
  573. const fetchAllRegionData = async () => {
  574. loadingRegion.value = true;
  575. try {
  576. await Promise.all([
  577. fetchRegionActiveData(),
  578. fetchRegionHistogramData(),
  579. fetchRegionDistributionData()
  580. ]);
  581. lastUpdateTime.value = formatDateTime(new Date());
  582. } catch (error) {
  583. console.error('获取地区数据失败:', error);
  584. } finally {
  585. loadingRegion.value = false;
  586. }
  587. };
  588. const fetchRegionActiveData = async () => {
  589. let params = {};
  590. if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
  591. params.start_time = formatDate(dateRangeRegion.value[0]);
  592. params.end_time = formatDate(dateRangeRegion.value[1]);
  593. }
  594. try {
  595. // 并行请求三种身份的数据
  596. const [resAll, resMember, resNormal] = await Promise.all([
  597. getRegionActiveData({ ...params, identity: 0 }),
  598. getRegionActiveData({ ...params, identity: 1 }),
  599. getRegionActiveData({ ...params, identity: 2 })
  600. ]);
  601. console.log("获取地区活跃数据响应:", { resAll, resMember, resNormal });
  602. // 检查是否选择了时间段
  603. const isRangeSelected = !!(params.start_time && params.end_time);
  604. const processData = (res) => {
  605. // 兼容处理拦截器
  606. const data = res.region_active_data ? res : (res.data && res.data.region_active_data ? res.data : null);
  607. if (data && data.region_active_data) {
  608. return data.region_active_data.map(item => ({
  609. region: item.region,
  610. dailyActive: item.daily_active_user,
  611. weeklyActive: item.weekly_active_user,
  612. monthlyActive: item.monthly_active_user,
  613. // 如果没有选择时间段,则显示 '-',否则显示数据
  614. periodActive: isRangeSelected ? item.range_active_user : '-',
  615. percent: item.active_rate + '%'
  616. }));
  617. }
  618. return [];
  619. };
  620. regionalTableData1.value = processData(resAll);
  621. regionalTableData2.value = processData(resMember);
  622. regionalTableData3.value = processData(resNormal);
  623. } catch (e) {
  624. console.error('获取地区活跃数据失败:', e);
  625. }
  626. };
  627. const fetchRegionHistogramData = async () => {
  628. let params = {};
  629. if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
  630. params.start_time = formatDate(dateRangeRegion.value[0]);
  631. params.end_time = formatDate(dateRangeRegion.value[1]);
  632. }
  633. try {
  634. const res = await getRegionActiveDataHistogram(params);
  635. console.log("获取地区活跃柱状图数据响应:", res);
  636. // 兼容处理拦截器
  637. const data = res.region_active_data_histogram ? res : (res.data && res.data.region_active_data_histogram ? res.data : null);
  638. if (data && data.region_active_data_histogram) {
  639. updateRegionBarChart(data.region_active_data_histogram);
  640. }
  641. } catch (e) {
  642. console.error('获取地区活跃柱状图数据失败:', e);
  643. }
  644. };
  645. const updateRegionBarChart = (list) => {
  646. if (!chartRegionBarRef.value) return;
  647. if (!chartRegionBarInstance) {
  648. chartRegionBarInstance = echarts.init(chartRegionBarRef.value);
  649. }
  650. const regions = list.map(item => item.region);
  651. const series = [];
  652. const legendData = [];
  653. if (barChartMode.value === 'all') {
  654. legendData.push('日活跃用户', '周活跃用户', '月活跃用户');
  655. series.push(
  656. { name: '日活跃用户', type: 'bar', data: list.map(item => item.daily_active_user), itemStyle: { color: '#40a9ff' } },
  657. { name: '周活跃用户', type: 'bar', data: list.map(item => item.weekly_active_user), itemStyle: { color: '#52c41a' } },
  658. { name: '月活跃用户', type: 'bar', data: list.map(item => item.monthly_active_user), itemStyle: { color: '#BC943D' } }
  659. );
  660. } else {
  661. legendData.push('日活跃会员', '日活跃非网', '周活跃会员', '周活跃非网', '月活跃会员', '月活跃非网');
  662. // 使用 stack 属性将会员和非会员数据堆叠在一起
  663. // 日活跃
  664. series.push(
  665. {
  666. name: '日活跃会员',
  667. type: 'bar',
  668. stack: 'daily',
  669. data: list.map(item => item.member_daily_active),
  670. itemStyle: { color: '#91D5FF' } // 浅蓝
  671. },
  672. {
  673. name: '日活跃非网',
  674. type: 'bar',
  675. stack: 'daily',
  676. data: list.map(item => item.no_member_daily_active),
  677. itemStyle: { color: '#40a9ff' } // 深蓝
  678. }
  679. );
  680. // 周活跃
  681. series.push(
  682. {
  683. name: '周活跃会员',
  684. type: 'bar',
  685. stack: 'weekly',
  686. data: list.map(item => item.member_weekly_active),
  687. itemStyle: { color: '#95de64' } // 浅绿
  688. },
  689. {
  690. name: '周活跃非网',
  691. type: 'bar',
  692. stack: 'weekly',
  693. data: list.map(item => item.no_member_weekly_active),
  694. itemStyle: { color: '#52c41a' } // 深绿
  695. }
  696. );
  697. // 月活跃
  698. series.push(
  699. {
  700. name: '月活跃会员',
  701. type: 'bar',
  702. stack: 'monthly',
  703. data: list.map(item => item.member_monthly_active),
  704. itemStyle: { color: '#FFC53D' } // 浅橙
  705. },
  706. {
  707. name: '月活跃非网',
  708. type: 'bar',
  709. stack: 'monthly',
  710. data: list.map(item => item.no_member_monthly_active),
  711. itemStyle: { color: '#BC943D' } // 深棕
  712. }
  713. );
  714. }
  715. const option = {
  716. tooltip: { trigger: 'item' }, // 之前需求:取消十字线显示,改为item
  717. legend: { data: legendData, top: 'top' },
  718. xAxis: {
  719. type: 'category',
  720. data: regions,
  721. axisLabel: { interval: 0, rotate: 30 } // 防止标签重叠
  722. },
  723. yAxis: { type: 'value' },
  724. series: series
  725. };
  726. chartRegionBarInstance.setOption(option, true); // true: not merge, reset
  727. };
  728. const toggleBarChartMode = () => {
  729. barChartMode.value = barChartMode.value === 'all' ? 'detail' : 'all';
  730. fetchRegionHistogramData();
  731. };
  732. const fetchRegionDistributionData = async () => {
  733. let params = {};
  734. if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
  735. params.start_time = formatDate(dateRangeRegion.value[0]);
  736. params.end_time = formatDate(dateRangeRegion.value[1]);
  737. }
  738. try {
  739. const [resAll, resMember, resNormal] = await Promise.all([
  740. getRegionUserDistribution({ ...params, identity: 0 }),
  741. getRegionUserDistribution({ ...params, identity: 1 }),
  742. getRegionUserDistribution({ ...params, identity: 2 })
  743. ]);
  744. console.log("获取地区分布数据响应:", { resAll, resMember, resNormal });
  745. const processData = (res) => {
  746. const data = res.region_user_distribution ? res : (res.data && res.data.region_user_distribution ? res.data : null);
  747. if (data && data.region_user_distribution) {
  748. return data.region_user_distribution.map(item => ({
  749. name: item.region,
  750. value: item.total
  751. })).filter(item => item.value > 0); // 过滤掉值为0的项,使饼图更美观
  752. }
  753. return [];
  754. };
  755. updatePieChart(chartRegionPieRef, chartRegionPieInstance, processData(resAll));
  756. updatePieChart(chartRegionMemberPieRef, chartRegionMemberPieInstance, processData(resMember));
  757. updatePieChart(chartRegionNonMemberPieRef, chartRegionNonMemberPieInstance, processData(resNormal));
  758. } catch (e) {
  759. console.error('获取地区分布数据失败:', e);
  760. }
  761. };
  762. let chartRegionPieInstance = null;
  763. let chartRegionMemberPieInstance = null;
  764. let chartRegionNonMemberPieInstance = null;
  765. const updatePieChart = (chartRef, chartInstance, data) => {
  766. if (!chartRef.value) return;
  767. let instance = chartInstance;
  768. if (!instance) {
  769. // 如果没有传入实例,尝试获取或初始化 (注意:这里需要正确维护全局实例变量)
  770. // 简单起见,这里总是重新获取echarts实例,或者通过DOM属性判断
  771. instance = echarts.getInstanceByDom(chartRef.value);
  772. if (!instance) {
  773. instance = echarts.init(chartRef.value);
  774. }
  775. }
  776. const option = {
  777. color: regionColors,
  778. tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
  779. legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
  780. series: [
  781. {
  782. type: 'pie',
  783. radius: '70%',
  784. center: ['50%', '50%'], // 改为居中显示
  785. data: data,
  786. label: { show: false },
  787. itemStyle: {
  788. borderRadius: 0,
  789. borderColor: '#fff',
  790. borderWidth: 2
  791. },
  792. }
  793. ]
  794. };
  795. instance.setOption(option);
  796. };
  797. const handleExportRegion = async () => {
  798. let params = {};
  799. if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
  800. params.start_time = formatDate(dateRangeRegion.value[0]);
  801. params.end_time = formatDate(dateRangeRegion.value[1]);
  802. }
  803. try {
  804. const res = await exportRegionActiveData(params);
  805. console.log("导出地区活跃数据响应(Blob):", res);
  806. // 创建Blob对象,处理二进制流下载
  807. const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
  808. const url = window.URL.createObjectURL(blob);
  809. const link = document.createElement('a');
  810. link.href = url;
  811. // 设置下载文件名,添加时间戳防止重名
  812. const fileName = `各地区登录活跃数据_${formatDate(new Date())}.xlsx`;
  813. link.setAttribute('download', fileName);
  814. document.body.appendChild(link);
  815. link.click();
  816. // 清理资源
  817. document.body.removeChild(link);
  818. window.URL.revokeObjectURL(url);
  819. ElMessage.success('导出成功');
  820. } catch (e) {
  821. console.error('导出地区活跃数据失败:', e);
  822. ElMessage.error('导出请求发生错误');
  823. }
  824. };
  825. const handleSearchRegion = () => {
  826. // 更新 URL 参数
  827. const query = { ...route.query };
  828. if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
  829. query.r_start_time = formatDate(dateRangeRegion.value[0]);
  830. query.r_end_time = formatDate(dateRangeRegion.value[1]);
  831. } else {
  832. delete query.r_start_time;
  833. delete query.r_end_time;
  834. }
  835. // 移除 region 参数处理
  836. router.replace({ query });
  837. fetchAllRegionData();
  838. };
  839. const handleResetRegion = () => {
  840. dateRangeRegion.value = '';
  841. searchRegion.value = ''; // 保持变量重置,但不影响UI
  842. // 清除 URL 参数
  843. const query = { ...route.query };
  844. delete query.r_start_time;
  845. delete query.r_end_time;
  846. // delete query.r_region; // 已移除参数
  847. router.replace({ query });
  848. fetchAllRegionData();
  849. };
  850. // Tab 1 数据
  851. const loginTableData1 = ref([]);
  852. const loginTableData2 = ref([]);
  853. const loginTableData3 = ref([]);
  854. // Tab 2 数据:regionalTableData1, 2, 3 已改为响应式变量并在 fetchRegionActiveData 中更新
  855. const headerCellStyle = {
  856. background: '#fff0f0',
  857. color: '#333',
  858. fontWeight: 'bold'
  859. };
  860. const regionColors = ['#68B2FF', '#D94F41', '#69D2AF', '#FFD360', '#ADADAD', '#BE71DD'];
  861. const initCharts = () => {
  862. nextTick(() => {
  863. if (activeTab.value === 'loginData') {
  864. // 趋势图已经在 fetchTrendData -> updateTrendChart 中初始化和更新
  865. // 这里只需要处理初始无数据时的状态,或者等待 fetchTrendData 调用
  866. fetchAllLoginData();
  867. } else if (activeTab.value === 'regionalData') {
  868. fetchAllRegionData();
  869. // 柱状图
  870. if (chartRegionBarRef.value) {
  871. const chart = echarts.init(chartRegionBarRef.value);
  872. chart.setOption({
  873. tooltip: { trigger: 'item' },
  874. legend: { data: ['日活跃用户', '周活跃用户', '月活跃用户'], top: 'top' },
  875. xAxis: {
  876. type: 'category',
  877. data: ['香港', '新加坡', '泰国', '越南', '马来西亚', '加拿大', '美国']
  878. },
  879. yAxis: { type: 'value' },
  880. series: [
  881. { name: '日活跃用户', type: 'bar', data: [400, 400, 400, 500, 400, 400, 400], itemStyle: { color: '#40a9ff' } },
  882. { name: '周活跃用户', type: 'bar', data: [500, 500, 500, 600, 500, 500, 500], itemStyle: { color: '#52c41a' } },
  883. { name: '月活跃用户', type: 'bar', data: [600, 600, 600, 700, 600, 600, 600], itemStyle: { color: '#BC943D' } }
  884. ]
  885. });
  886. }
  887. // 饼图 - 地区分布
  888. if (chartRegionPieRef.value) {
  889. const chart = echarts.init(chartRegionPieRef.value);
  890. chart.setOption({
  891. color: regionColors,
  892. tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
  893. legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
  894. series: [
  895. {
  896. type: 'pie',
  897. radius: '70%',
  898. center: ['50%', '50%'],
  899. data: [
  900. { value: 1048, name: '香港地区' },
  901. { value: 735, name: '新加坡地区' },
  902. { value: 580, name: '泰国地区' },
  903. { value: 484, name: '越南地区' },
  904. { value: 300, name: '马来西亚地区' },
  905. { value: 300, name: '其他地区' }
  906. ],
  907. itemStyle: {
  908. borderRadius: 0,
  909. borderColor: '#fff',
  910. borderWidth: 2
  911. },
  912. label: { show: false }
  913. }
  914. ]
  915. });
  916. }
  917. // 饼图 - 会员分布
  918. if (chartRegionMemberPieRef.value) {
  919. const chart = echarts.init(chartRegionMemberPieRef.value);
  920. chart.setOption({
  921. color: regionColors,
  922. tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
  923. legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
  924. series: [
  925. {
  926. type: 'pie',
  927. radius: '70%',
  928. center: ['50%', '50%'],
  929. data: [
  930. { value: 1048, name: '香港地区' },
  931. { value: 735, name: '新加坡地区' },
  932. { value: 580, name: '泰国地区' },
  933. { value: 484, name: '越南地区' },
  934. { value: 300, name: '马来西亚地区' },
  935. { value: 300, name: '其他地区' }
  936. ],
  937. label: { show: false }
  938. }
  939. ]
  940. });
  941. }
  942. // 饼图 - 非网分布
  943. if (chartRegionNonMemberPieRef.value) {
  944. const chart = echarts.init(chartRegionNonMemberPieRef.value);
  945. chart.setOption({
  946. color: regionColors,
  947. tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
  948. legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
  949. series: [
  950. {
  951. type: 'pie',
  952. radius: '70%',
  953. center: ['50%', '50%'],
  954. data: [
  955. { value: 1048, name: '香港地区' },
  956. { value: 735, name: '新加坡地区' },
  957. { value: 580, name: '泰国地区' },
  958. { value: 484, name: '越南地区' },
  959. { value: 300, name: '马来西亚地区' },
  960. { value: 300, name: '其他地区' }
  961. ],
  962. label: { show: false }
  963. }
  964. ]
  965. });
  966. }
  967. }
  968. });
  969. };
  970. watch(activeTab, (newVal) => {
  971. router.replace({ query: { ...route.query, tab: newVal } });
  972. initCharts();
  973. });
  974. onMounted(() => {
  975. if (activeTab.value === 'loginData') {
  976. fetchAllLoginData();
  977. }
  978. fetchRegionOptions();
  979. initCharts();
  980. });
  981. </script>
  982. <style scoped>
  983. .user-login-stats-container {
  984. padding: 20px;
  985. background-color: #fee6e6;
  986. min-height: calc(100vh - 40px);
  987. }
  988. /* Tabs */
  989. .tab-header {
  990. display: flex;
  991. margin-bottom: 20px;
  992. }
  993. .tab-item {
  994. padding: 6px 16px;
  995. margin-right: 10px;
  996. background-color: #fff;
  997. border: 1px solid #ffcccc;
  998. border-radius: 4px;
  999. cursor: pointer;
  1000. color: #ff4d4f;
  1001. font-size: 14px;
  1002. }
  1003. .tab-item.active {
  1004. background-color: #ff4d4f;
  1005. color: #fff;
  1006. }
  1007. /* Search Bar */
  1008. .search-bar {
  1009. background: #fff;
  1010. padding: 15px;
  1011. border-radius: 8px;
  1012. display: flex;
  1013. align-items: center;
  1014. gap: 10px;
  1015. margin-bottom: 20px;
  1016. border: 1px solid #f0f0f0;
  1017. position: sticky;
  1018. top: 0;
  1019. z-index: 1000;
  1020. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  1021. }
  1022. .search-label {
  1023. font-weight: bold;
  1024. margin-right: 5px;
  1025. }
  1026. .search-btn, .reset-btn {
  1027. width: 80px;
  1028. }
  1029. .search-btn {
  1030. background-color: #409eff;
  1031. }
  1032. .reset-btn {
  1033. background-color: #409eff;
  1034. border-color: #409eff;
  1035. }
  1036. .export-btn {
  1037. margin-left: auto;
  1038. background-color: #ff7875;
  1039. border-color: #ff7875;
  1040. }
  1041. /* Cards */
  1042. .stats-row {
  1043. display: flex;
  1044. gap: 20px;
  1045. margin-bottom: 20px;
  1046. }
  1047. .big-card {
  1048. flex: 1;
  1049. height: 360px;
  1050. border-radius: 12px;
  1051. padding: 24px;
  1052. display: flex;
  1053. flex-direction: column;
  1054. justify-content: flex-start;
  1055. color: #fff;
  1056. position: relative;
  1057. }
  1058. .right-stats-col {
  1059. flex: 2;
  1060. display: flex;
  1061. flex-direction: column;
  1062. gap: 20px;
  1063. }
  1064. .small-card {
  1065. flex: 1;
  1066. border-radius: 12px;
  1067. padding: 20px;
  1068. display: flex;
  1069. flex-direction: column;
  1070. justify-content: flex-start;
  1071. color: #fff;
  1072. position: relative;
  1073. }
  1074. .purple-gradient { background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); }
  1075. .orange-gradient { background: linear-gradient(90deg, #ff8c6d 0%, #ffcba4 100%); }
  1076. .blue-gradient { background: linear-gradient(135deg, #9BB7FC 0%, #66a6ff 100%); }
  1077. .card-title {
  1078. font-size: 34px;
  1079. font-weight: bold;
  1080. display: flex;
  1081. align-items: center;
  1082. gap: 8px;
  1083. margin-bottom: 20px;
  1084. width: 100%;
  1085. }
  1086. .big-card-content {
  1087. flex: 1;
  1088. display: flex;
  1089. flex-direction: column;
  1090. justify-content: center;
  1091. align-items: center;
  1092. gap: 10px;
  1093. }
  1094. .card-value {
  1095. font-size: 64px;
  1096. font-weight: bold;
  1097. margin: 0;
  1098. text-align: center;
  1099. }
  1100. .card-value-small {
  1101. font-size: 68px;
  1102. font-weight: bold;
  1103. margin-left: auto;
  1104. }
  1105. .top-row {
  1106. display: flex;
  1107. align-items: center;
  1108. justify-content: space-between;
  1109. }
  1110. .card-tag-wrapper {
  1111. display: flex;
  1112. justify-content: flex-end;
  1113. margin-top: 10px;
  1114. }
  1115. .card-tag {
  1116. background-color: #fff;
  1117. padding: 8px 16px;
  1118. border-radius: 4px;
  1119. font-weight: bold;
  1120. display: inline-block;
  1121. }
  1122. .big-card .card-tag { font-size: 18px; }
  1123. .card-tag.up { color: #52c41a; }
  1124. .card-tag.down { color: #ff4d4f; }
  1125. .text-red { color: #ff4d4f; font-weight: bold; }
  1126. .text-green { color: #52c41a; font-weight: bold; }
  1127. .text-black { color: #333; font-weight: bold; }
  1128. /* Sections */
  1129. .chart-section, .detail-section {
  1130. background: #fff;
  1131. border-radius: 8px;
  1132. padding: 15px;
  1133. margin-bottom: 20px;
  1134. border: 1px solid #f0f0f0;
  1135. }
  1136. .section-title {
  1137. color: #409eff;
  1138. font-size: 16px;
  1139. font-weight: bold;
  1140. margin-bottom: 15px;
  1141. display: flex;
  1142. align-items: center;
  1143. gap: 5px;
  1144. }
  1145. .chart-box-large {
  1146. width: 100%;
  1147. height: 400px;
  1148. }
  1149. .charts-row {
  1150. display: flex;
  1151. gap: 20px;
  1152. }
  1153. .half-width {
  1154. flex: 1;
  1155. }
  1156. .chart-box-medium {
  1157. width: 100%;
  1158. height: 300px;
  1159. }
  1160. .refresh-time {
  1161. position: fixed;
  1162. bottom: 20px;
  1163. right: 20px;
  1164. background: rgba(0, 0, 0, 0.6);
  1165. color: #fff;
  1166. padding: 8px 15px;
  1167. border-radius: 20px;
  1168. font-size: 12px;
  1169. z-index: 2000;
  1170. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
  1171. }
  1172. </style>