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.

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