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

<template>
<div class="user-login-stats-container">
<div class="tab-header">
<div
class="tab-item"
:class="{ active: activeTab === 'loginData' }"
@click="activeTab = 'loginData'"
>
登录数据
</div>
<!-- <div
class="tab-item"
:class="{ active: activeTab === 'regionalData' }"
@click="activeTab = 'regionalData'"
>
各地区登录数据
</div> -->
</div>
<!-- 登录数据 Tab -->
<div v-show="activeTab === 'loginData'" class="tab-content" v-loading="loading">
<!-- 搜索栏 -->
<div class="search-bar">
<div class="search-label">地区</div>
<el-select v-model="selectedRegion" placeholder="请选择所属地区" style="width: 200px; margin-right: 20px;">
<el-option label="全部" value="all" />
<el-option
v-for="item in regionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="search-label">时间段查询</div>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
size="default"
/>
<el-button type="primary" class="search-btn" @click="handleSearch">搜索</el-button>
<el-button type="primary" class="reset-btn" @click="handleReset">重置</el-button>
<el-button type="danger" class="export-btn" @click="handleExportLogin">数据导出</el-button>
</div>
<!-- 统计卡片 -->
<div class="stats-row">
<!-- 今日登录用户数 -->
<div class="stat-card purple-gradient big-card">
<div class="card-title">
<el-icon><UserFilled /></el-icon> {{ statsTitle }}登录用户数
</div>
<div class="big-card-content">
<div class="card-value">{{ loginStats.total }}</div>
<div v-if="loginStats.total_growth" class="card-tag" :class="getGrowthClass(loginStats.total_growth)">
{{ getGrowthText(loginStats.total_growth) }}
</div>
</div>
</div>
<div class="right-stats-col">
<!-- 今日登录会员用户数 -->
<div class="stat-card orange-gradient small-card">
<div class="top-row">
<div class="card-title">
<el-icon><Trophy /></el-icon> {{ statsTitle }}登录会员用户数
</div>
<div class="card-value-small">{{ loginStats.member }}</div>
</div>
<div class="card-tag-wrapper">
<div v-if="loginStats.member_growth" class="card-tag" :class="getGrowthClass(loginStats.member_growth)">
{{ getGrowthText(loginStats.member_growth) }}
</div>
</div>
</div>
<!-- 今日登录非网用户数 -->
<div class="stat-card blue-gradient small-card">
<div class="top-row">
<div class="card-title">
<el-icon><User /></el-icon> {{ statsTitle }}登录非网用户数
</div>
<div class="card-value-small">{{ loginStats.normal }}</div>
</div>
<div class="card-tag-wrapper">
<div v-if="loginStats.normal_growth" class="card-tag" :class="getGrowthClass(loginStats.normal_growth)">
{{ getGrowthText(loginStats.normal_growth) }}
</div>
</div>
</div>
</div>
</div>
<!-- 近7天登录趋势 -->
<div class="chart-section">
<div class="section-title"><el-icon><TrendCharts /></el-icon> {{ chartTrendTitle }}</div>
<div ref="chartTrendRef" class="chart-box-large"></div>
</div>
<!-- 表格1: 今日登录数据 -->
<div class="detail-section">
<div class="section-title"><el-icon><DataLine /></el-icon> {{ statsTitle }}总用户登录数据</div>
<el-table :data="loginTableData1" style="width: 100%" :header-cell-style="headerCellStyle">
<el-table-column prop="channel" label="来源渠道" align="center" />
<el-table-column prop="total" :label="statsTitle + '登录总数'" align="center" />
<el-table-column prop="dailyNew" label="较昨日新增" align="center">
<template #default="scope">
<span :class="getValueColorClass(scope.row.dailyNew)">{{ scope.row.dailyNew }}</span>
</template>
</el-table-column>
<el-table-column prop="percent" label="占比" align="center" />
</el-table>
</div>
<!-- 表格2: 会员登录数据 -->
<div class="detail-section">
<div class="section-title"><el-icon><Trophy /></el-icon> {{ statsTitle }}会员登录数据</div>
<el-table :data="loginTableData2" style="width: 100%" :header-cell-style="headerCellStyle">
<el-table-column prop="channel" label="来源渠道" align="center" />
<el-table-column prop="total" :label="statsTitle + '登录会员数'" align="center" />
<el-table-column prop="dailyNew" label="较昨日新增" align="center">
<template #default="scope">
<span :class="getValueColorClass(scope.row.dailyNew)">{{ scope.row.dailyNew }}</span>
</template>
</el-table-column>
<el-table-column prop="rate" label="会员登录率" align="center" />
</el-table>
</div>
<!-- 表格3: 非网登录数据 -->
<div class="detail-section">
<div class="section-title"><el-icon><User /></el-icon> {{ statsTitle }}非网登录数据</div>
<el-table :data="loginTableData3" style="width: 100%" :header-cell-style="headerCellStyle">
<el-table-column prop="channel" label="来源渠道" align="center" />
<el-table-column prop="total" :label="statsTitle + '登录非网数'" align="center" />
<el-table-column prop="dailyNew" label="较昨日新增" align="center">
<template #default="scope">
<span :class="getValueColorClass(scope.row.dailyNew)">{{ scope.row.dailyNew }}</span>
</template>
</el-table-column>
<el-table-column prop="rate" label="非网登录率" align="center" />
</el-table>
</div>
</div>
<!-- 各地区登录数据 Tab -->
<div v-show="activeTab === 'regionalData'" class="tab-content" v-loading="loadingRegion">
<!-- 搜索栏 -->
<div class="search-bar">
<!-- 移除地区查询 -->
<div class="search-label">时间段查询</div>
<el-date-picker
v-model="dateRangeRegion"
type="daterange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
size="default"
/>
<el-button type="primary" class="search-btn" @click="handleSearchRegion">搜索</el-button>
<el-button type="primary" class="reset-btn" @click="handleResetRegion">重置</el-button>
<el-button type="danger" class="export-btn" @click="handleExportRegion">数据导出</el-button>
</div>
<!-- 表格1: 各地区活跃数据 -->
<div class="detail-section">
<div class="section-title"><el-icon><Location /></el-icon> 各地区活跃数据</div>
<el-table :data="regionalTableData1" style="width: 100%" :header-cell-style="headerCellStyle">
<el-table-column prop="region" label="地区" align="center" />
<el-table-column prop="dailyActive" label="日活跃用户" align="center" />
<el-table-column prop="weeklyActive" label="周活跃用户" align="center" />
<el-table-column prop="monthlyActive" label="月活跃用户" align="center" />
<el-table-column prop="periodActive" label="区间活跃用户" align="center" />
<el-table-column prop="percent" label="活跃度占比" align="center" />
</el-table>
</div>
<!-- 表格2: 各地区会员活跃数据 -->
<div class="detail-section">
<div class="section-title"><el-icon><Trophy /></el-icon> 各地区会员活跃数据</div>
<el-table :data="regionalTableData2" style="width: 100%" :header-cell-style="headerCellStyle">
<el-table-column prop="region" label="地区" align="center" />
<el-table-column prop="dailyActive" label="日活跃用户" align="center" />
<el-table-column prop="weeklyActive" label="周活跃用户" align="center" />
<el-table-column prop="monthlyActive" label="月活跃用户" align="center" />
<el-table-column prop="periodActive" label="区间活跃用户" align="center" />
<el-table-column prop="percent" label="活跃度占比" align="center" />
</el-table>
</div>
<!-- 表格3: 各地区非网活跃数据 -->
<div class="detail-section">
<div class="section-title"><el-icon><User /></el-icon> 各地区非网活跃数据</div>
<el-table :data="regionalTableData3" style="width: 100%" :header-cell-style="headerCellStyle">
<el-table-column prop="region" label="地区" align="center" />
<el-table-column prop="dailyActive" label="日活跃用户" align="center" />
<el-table-column prop="weeklyActive" label="周活跃用户" align="center" />
<el-table-column prop="monthlyActive" label="月活跃用户" align="center" />
<el-table-column prop="periodActive" label="区间活跃用户" align="center" />
<el-table-column prop="percent" label="活跃度占比" align="center" />
</el-table>
</div>
<!-- 图表: 各地区日活跃用户 -->
<div class="chart-section">
<div class="section-title"><el-icon><BarChart /></el-icon> 各地区日活跃用户
<el-button size="small" style="margin-left: auto;" @click="toggleBarChartMode">切换</el-button>
</div>
<div ref="chartRegionBarRef" class="chart-box-large"></div>
</div>
<!-- 图表: 地区分布 -->
<div class="chart-section">
<div class="section-title"><el-icon><PieChart /></el-icon> 各地区用户分布</div>
<div ref="chartRegionPieRef" class="chart-box-large" style="height: 400px;"></div>
</div>
<div class="charts-row">
<div class="chart-section half-width">
<div class="section-title">各地区会员用户分布</div>
<div ref="chartRegionMemberPieRef" class="chart-box-medium"></div>
</div>
<div class="chart-section half-width">
<div class="section-title">各地区非网用户分布</div>
<div ref="chartRegionNonMemberPieRef" class="chart-box-medium"></div>
</div>
</div>
</div>
<!-- 悬浮刷新时间 -->
<div class="refresh-time" v-if="lastUpdateTime">
数据刷新时间{{ lastUpdateTime }}
</div>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import * as echarts from 'echarts';
import { getUserLoginList, getUserLoginTrend, getRegionActiveData, getRegionActiveDataHistogram, getUserLoginChannel, getUserLoginChannelMember, getUserLoginChannelNoMember, getRegionUserDistribution, exportRegionActiveData, getRegionsList, exportUserLoginPDF } from '../../api/platformData';
import { ElMessage } from 'element-plus';
const route = useRoute();
const router = useRouter();
const activeTab = ref(route.query.tab || 'loginData');
const dateRange = ref('');
const selectedRegion = ref('');
const searchRegion = ref('');
const dateRangeRegion = ref('');
const loading = ref(false);
const loadingRegion = ref(false);
const lastUpdateTime = ref('');
const chartTrendRef = ref(null);
let chartTrendInstance = null;
const chartRegionBarRef = ref(null);
let chartRegionBarInstance = null;
const chartRegionPieRef = ref(null);
const chartRegionMemberPieRef = ref(null);
const chartRegionNonMemberPieRef = ref(null);
// 柱状图显示模式:'all' (全部用户), 'detail' (分身份用户)
const barChartMode = ref('all');
// 响应式数据:地区活跃数据
const regionalTableData1 = ref([]);
const regionalTableData2 = ref([]);
const regionalTableData3 = ref([]);
const regionOptions = ref([]);
// 获取地区列表
const fetchRegionOptions = async () => {
try {
const res = await getRegionsList();
console.log("获取地区列表响应:", res);
const data = res.data || res;
if (data && data.list) {
regionOptions.value = data.list.map(region => ({ label: region, value: region }));
}
} catch (e) {
console.error('获取地区列表失败:', e);
}
};
// 初始化查询参数
const initQueryParams = () => {
const { start_time, end_time, region, r_start_time, r_end_time } = route.query;
// Tab 1 Params
if (start_time && end_time) {
dateRange.value = [new Date(start_time), new Date(end_time)];
}
if (region) {
selectedRegion.value = region;
}
// Tab 2 Params
if (r_start_time && r_end_time) {
dateRangeRegion.value = [new Date(r_start_time), new Date(r_end_time)];
}
};
initQueryParams();
// 响应式数据:登录数据统计
const loginStats = ref({
total: 0,
total_growth: '0%',
member: 0,
member_growth: '0%',
normal: 0,
normal_growth: '0%'
});
// 获取增长率的样式类
const chartTrendTitle = ref('近7天登录趋势');
const statsTitle = ref('今日');
const getGrowthClass = (growthStr) => {
if (!growthStr) return '';
return growthStr.startsWith('-') ? 'down' : 'up';
};
// 获取增长率的显示文本(添加箭头)
const getGrowthText = (growthStr) => {
if (!growthStr) return '';
const isDown = growthStr.startsWith('-');
const arrow = isDown ? '↓' : '↑';
const prefix = isDown ? '较昨日减少' : '较昨日增加';
const value = growthStr.replace('-', '');
return `${prefix}${arrow} ${value}`;
};
// 获取表格数值颜色样式
const getValueColorClass = (val) => {
if (!val || val === '-') return '';
const strVal = String(val);
if (strVal.startsWith('+')) return 'text-green';
if (strVal.startsWith('-')) return 'text-red';
if (strVal === '0') return 'text-black';
// 如果是数字且大于0(不带+号的情况)
if (!isNaN(parseFloat(strVal)) && parseFloat(strVal) > 0) return 'text-green';
return '';
};
// 格式化日期
const formatDate = (date) => {
if (!date) return '';
const d = new Date(date);
const pad = (n) => n < 10 ? '0' + n : n;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
};
const formatDateTime = (date) => {
if (!date) return '';
const d = new Date(date);
const pad = (n) => n < 10 ? '0' + n : n;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const fetchLoginData = async () => {
let params = {};
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = formatDate(dateRange.value[0]);
params.end_time = formatDate(dateRange.value[1]);
statsTitle.value = `${params.start_time}${params.end_time}`;
} else {
statsTitle.value = '今日';
}
if (selectedRegion.value && selectedRegion.value !== 'all') {
params.region = selectedRegion.value;
}
try {
const res = await getUserLoginList(params);
console.log("获取用户登录数据响应:", res);
// 兼容处理拦截器
const data = res.list ? res : (res.data && res.data.list ? res.data : null);
if (data && data.list) {
loginStats.value = data.list;
}
} catch (e) {
console.error('获取用户登录数据失败:', e);
}
};
const fetchTrendData = async () => {
let params = {};
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = formatDate(dateRange.value[0]);
params.end_time = formatDate(dateRange.value[1]);
chartTrendTitle.value = `${params.start_time}${params.end_time} 登录趋势`;
} else {
chartTrendTitle.value = '近7天登录趋势';
}
try {
const res = await getUserLoginTrend(params);
console.log("获取用户登录趋势响应:", res);
// 兼容处理拦截器
const data = res.list ? res : (res.data && res.data.list ? res.data : null);
if (data && data.list) {
updateTrendChart(data.list);
}
} catch (e) {
console.error('获取用户登录趋势失败:', e);
}
};
const updateTrendChart = (list) => {
if (!chartTrendRef.value) return;
if (!chartTrendInstance) {
chartTrendInstance = echarts.init(chartTrendRef.value);
}
const dates = list.map(item => item.date);
const allUser = list.map(item => item.all_user);
const member = list.map(item => item.member);
const option = {
tooltip: { trigger: 'axis' },
legend: { data: ['所有用户', '会员用户'], top: 'top' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: dates
},
yAxis: { type: 'value' },
series: [
{
name: '所有用户',
type: 'line',
data: allUser,
smooth: true,
itemStyle: { color: '#40a9ff' }
},
{
name: '会员用户',
type: 'line',
data: member,
smooth: true,
itemStyle: { color: '#52c41a' }
}
]
};
chartTrendInstance.setOption(option);
};
const fetchLoginChannelData = async () => {
let params = {};
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = formatDate(dateRange.value[0]);
params.end_time = formatDate(dateRange.value[1]);
}
if (selectedRegion.value && selectedRegion.value !== 'all') {
params.region = selectedRegion.value;
}
try {
const res = await getUserLoginChannel(params);
console.log("获取今日登录渠道数据响应:", res);
// 兼容处理拦截器
const data = res.list ? res : (res.data && res.data.list ? res.data : null);
if (data && data.list) {
loginTableData1.value = data.list.map(item => ({
channel: item.source,
total: item.today_count,
dailyNew: item.growth_value,
percent: item.ratio + '%'
}));
}
} catch (e) {
console.error('获取今日登录渠道数据失败:', e);
}
};
const fetchLoginChannelMemberData = async () => {
let params = {};
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = formatDate(dateRange.value[0]);
params.end_time = formatDate(dateRange.value[1]);
}
if (selectedRegion.value && selectedRegion.value !== 'all') {
params.region = selectedRegion.value;
}
try {
const res = await getUserLoginChannelMember(params);
console.log("获取会员登录渠道数据响应:", res);
// 兼容处理拦截器
const data = res.list ? res : (res.data && res.data.list ? res.data : null);
if (data && data.list) {
loginTableData2.value = data.list.map(item => ({
channel: item.source,
total: item.today_count,
dailyNew: item.growth_value,
rate: item.ratio + '%'
}));
}
} catch (e) {
console.error('获取会员登录渠道数据失败:', e);
}
};
const fetchLoginChannelNoMemberData = async () => {
let params = {};
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = formatDate(dateRange.value[0]);
params.end_time = formatDate(dateRange.value[1]);
}
if (selectedRegion.value && selectedRegion.value !== 'all') {
params.region = selectedRegion.value;
}
try {
const res = await getUserLoginChannelNoMember(params);
console.log("获取非网登录渠道数据响应:", res);
// 兼容处理拦截器
const data = res.list ? res : (res.data && res.data.list ? res.data : null);
if (data && data.list) {
loginTableData3.value = data.list.map(item => ({
channel: item.source,
total: item.today_count,
dailyNew: item.growth_value,
rate: item.ratio + '%'
}));
}
} catch (e) {
console.error('获取非网登录渠道数据失败:', e);
}
};
// 统一获取登录数据 Tab 的所有数据
const fetchAllLoginData = async () => {
loading.value = true;
try {
// 使用 Promise.all 并行请求所有接口
await Promise.all([
fetchTrendData(),
fetchLoginData(),
fetchLoginChannelData(),
fetchLoginChannelMemberData(),
fetchLoginChannelNoMemberData()
]);
lastUpdateTime.value = formatDateTime(new Date());
} catch (error) {
console.error('获取登录数据失败:', error);
} finally {
loading.value = false;
}
};
const handleExportLogin = async () => {
loading.value = true;
let params = {};
if (dateRange.value && dateRange.value.length === 2) {
params.start_time = formatDate(dateRange.value[0]);
params.end_time = formatDate(dateRange.value[1]);
}
if (selectedRegion.value && selectedRegion.value !== 'all') {
params.region = selectedRegion.value;
}
try {
const res = await exportUserLoginPDF(params);
console.log("导出登录数据PDF响应(Blob):", res);
// 创建Blob对象,处理二进制流下载
const blob = new Blob([res], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 设置下载文件名,添加时间戳防止重名
const fileName = `用户登录数据_${formatDate(new Date())}.pdf`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// 清理资源
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('导出成功');
} catch (e) {
console.error('导出登录数据失败:', e);
ElMessage.error('导出请求发生错误');
} finally {
loading.value = false;
}
};
const handleSearch = () => {
// 更新 URL 参数
const query = { ...route.query };
if (dateRange.value && dateRange.value.length === 2) {
query.start_time = formatDate(dateRange.value[0]);
query.end_time = formatDate(dateRange.value[1]);
} else {
delete query.start_time;
delete query.end_time;
}
if (selectedRegion.value && selectedRegion.value !== 'all') {
query.region = selectedRegion.value;
} else {
delete query.region;
}
router.replace({ query });
fetchAllLoginData();
};
const handleReset = () => {
dateRange.value = '';
selectedRegion.value = '';
// 清除 URL 参数
const query = { ...route.query };
delete query.start_time;
delete query.end_time;
delete query.region;
router.replace({ query });
fetchAllLoginData();
};
// 统一获取地区数据 Tab 的所有数据
const fetchAllRegionData = async () => {
loadingRegion.value = true;
try {
await Promise.all([
fetchRegionActiveData(),
fetchRegionHistogramData(),
fetchRegionDistributionData()
]);
lastUpdateTime.value = formatDateTime(new Date());
} catch (error) {
console.error('获取地区数据失败:', error);
} finally {
loadingRegion.value = false;
}
};
const fetchRegionActiveData = async () => {
let params = {};
if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
params.start_time = formatDate(dateRangeRegion.value[0]);
params.end_time = formatDate(dateRangeRegion.value[1]);
}
try {
// 并行请求三种身份的数据
const [resAll, resMember, resNormal] = await Promise.all([
getRegionActiveData({ ...params, identity: 0 }),
getRegionActiveData({ ...params, identity: 1 }),
getRegionActiveData({ ...params, identity: 2 })
]);
console.log("获取地区活跃数据响应:", { resAll, resMember, resNormal });
// 检查是否选择了时间段
const isRangeSelected = !!(params.start_time && params.end_time);
const processData = (res) => {
// 兼容处理拦截器
const data = res.region_active_data ? res : (res.data && res.data.region_active_data ? res.data : null);
if (data && data.region_active_data) {
return data.region_active_data.map(item => ({
region: item.region,
dailyActive: item.daily_active_user,
weeklyActive: item.weekly_active_user,
monthlyActive: item.monthly_active_user,
// 如果没有选择时间段,则显示 '-',否则显示数据
periodActive: isRangeSelected ? item.range_active_user : '-',
percent: item.active_rate + '%'
}));
}
return [];
};
regionalTableData1.value = processData(resAll);
regionalTableData2.value = processData(resMember);
regionalTableData3.value = processData(resNormal);
} catch (e) {
console.error('获取地区活跃数据失败:', e);
}
};
const fetchRegionHistogramData = async () => {
let params = {};
if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
params.start_time = formatDate(dateRangeRegion.value[0]);
params.end_time = formatDate(dateRangeRegion.value[1]);
}
try {
const res = await getRegionActiveDataHistogram(params);
console.log("获取地区活跃柱状图数据响应:", res);
// 兼容处理拦截器
const data = res.region_active_data_histogram ? res : (res.data && res.data.region_active_data_histogram ? res.data : null);
if (data && data.region_active_data_histogram) {
updateRegionBarChart(data.region_active_data_histogram);
}
} catch (e) {
console.error('获取地区活跃柱状图数据失败:', e);
}
};
const updateRegionBarChart = (list) => {
if (!chartRegionBarRef.value) return;
if (!chartRegionBarInstance) {
chartRegionBarInstance = echarts.init(chartRegionBarRef.value);
}
const regions = list.map(item => item.region);
const series = [];
const legendData = [];
if (barChartMode.value === 'all') {
legendData.push('日活跃用户', '周活跃用户', '月活跃用户');
series.push(
{ name: '日活跃用户', type: 'bar', data: list.map(item => item.daily_active_user), itemStyle: { color: '#40a9ff' } },
{ name: '周活跃用户', type: 'bar', data: list.map(item => item.weekly_active_user), itemStyle: { color: '#52c41a' } },
{ name: '月活跃用户', type: 'bar', data: list.map(item => item.monthly_active_user), itemStyle: { color: '#BC943D' } }
);
} else {
legendData.push('日活跃会员', '日活跃非网', '周活跃会员', '周活跃非网', '月活跃会员', '月活跃非网');
// 使用 stack 属性将会员和非会员数据堆叠在一起
// 日活跃
series.push(
{
name: '日活跃会员',
type: 'bar',
stack: 'daily',
data: list.map(item => item.member_daily_active),
itemStyle: { color: '#91D5FF' } // 浅蓝
},
{
name: '日活跃非网',
type: 'bar',
stack: 'daily',
data: list.map(item => item.no_member_daily_active),
itemStyle: { color: '#40a9ff' } // 深蓝
}
);
// 周活跃
series.push(
{
name: '周活跃会员',
type: 'bar',
stack: 'weekly',
data: list.map(item => item.member_weekly_active),
itemStyle: { color: '#95de64' } // 浅绿
},
{
name: '周活跃非网',
type: 'bar',
stack: 'weekly',
data: list.map(item => item.no_member_weekly_active),
itemStyle: { color: '#52c41a' } // 深绿
}
);
// 月活跃
series.push(
{
name: '月活跃会员',
type: 'bar',
stack: 'monthly',
data: list.map(item => item.member_monthly_active),
itemStyle: { color: '#FFC53D' } // 浅橙
},
{
name: '月活跃非网',
type: 'bar',
stack: 'monthly',
data: list.map(item => item.no_member_monthly_active),
itemStyle: { color: '#BC943D' } // 深棕
}
);
}
const option = {
tooltip: { trigger: 'item' }, // 之前需求:取消十字线显示,改为item
legend: { data: legendData, top: 'top' },
xAxis: {
type: 'category',
data: regions,
axisLabel: { interval: 0, rotate: 30 } // 防止标签重叠
},
yAxis: { type: 'value' },
series: series
};
chartRegionBarInstance.setOption(option, true); // true: not merge, reset
};
const toggleBarChartMode = () => {
barChartMode.value = barChartMode.value === 'all' ? 'detail' : 'all';
fetchRegionHistogramData();
};
const fetchRegionDistributionData = async () => {
let params = {};
if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
params.start_time = formatDate(dateRangeRegion.value[0]);
params.end_time = formatDate(dateRangeRegion.value[1]);
}
try {
const [resAll, resMember, resNormal] = await Promise.all([
getRegionUserDistribution({ ...params, identity: 0 }),
getRegionUserDistribution({ ...params, identity: 1 }),
getRegionUserDistribution({ ...params, identity: 2 })
]);
console.log("获取地区分布数据响应:", { resAll, resMember, resNormal });
const processData = (res) => {
const data = res.region_user_distribution ? res : (res.data && res.data.region_user_distribution ? res.data : null);
if (data && data.region_user_distribution) {
return data.region_user_distribution.map(item => ({
name: item.region,
value: item.total
})).filter(item => item.value > 0); // 过滤掉值为0的项,使饼图更美观
}
return [];
};
updatePieChart(chartRegionPieRef, chartRegionPieInstance, processData(resAll));
updatePieChart(chartRegionMemberPieRef, chartRegionMemberPieInstance, processData(resMember));
updatePieChart(chartRegionNonMemberPieRef, chartRegionNonMemberPieInstance, processData(resNormal));
} catch (e) {
console.error('获取地区分布数据失败:', e);
}
};
let chartRegionPieInstance = null;
let chartRegionMemberPieInstance = null;
let chartRegionNonMemberPieInstance = null;
const updatePieChart = (chartRef, chartInstance, data) => {
if (!chartRef.value) return;
let instance = chartInstance;
if (!instance) {
// 如果没有传入实例,尝试获取或初始化 (注意:这里需要正确维护全局实例变量)
// 简单起见,这里总是重新获取echarts实例,或者通过DOM属性判断
instance = echarts.getInstanceByDom(chartRef.value);
if (!instance) {
instance = echarts.init(chartRef.value);
}
}
const option = {
color: regionColors,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
series: [
{
type: 'pie',
radius: '70%',
center: ['50%', '50%'], // 改为居中显示
data: data,
label: { show: false },
itemStyle: {
borderRadius: 0,
borderColor: '#fff',
borderWidth: 2
},
}
]
};
instance.setOption(option);
};
const handleExportRegion = async () => {
let params = {};
if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
params.start_time = formatDate(dateRangeRegion.value[0]);
params.end_time = formatDate(dateRangeRegion.value[1]);
}
try {
const res = await exportRegionActiveData(params);
console.log("导出地区活跃数据响应(Blob):", res);
// 创建Blob对象,处理二进制流下载
const blob = new Blob([res], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// 设置下载文件名,添加时间戳防止重名
const fileName = `各地区登录活跃数据_${formatDate(new Date())}.xlsx`;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
// 清理资源
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
ElMessage.success('导出成功');
} catch (e) {
console.error('导出地区活跃数据失败:', e);
ElMessage.error('导出请求发生错误');
}
};
const handleSearchRegion = () => {
// 更新 URL 参数
const query = { ...route.query };
if (dateRangeRegion.value && dateRangeRegion.value.length === 2) {
query.r_start_time = formatDate(dateRangeRegion.value[0]);
query.r_end_time = formatDate(dateRangeRegion.value[1]);
} else {
delete query.r_start_time;
delete query.r_end_time;
}
// 移除 region 参数处理
router.replace({ query });
fetchAllRegionData();
};
const handleResetRegion = () => {
dateRangeRegion.value = '';
searchRegion.value = ''; // 保持变量重置,但不影响UI
// 清除 URL 参数
const query = { ...route.query };
delete query.r_start_time;
delete query.r_end_time;
// delete query.r_region; // 已移除参数
router.replace({ query });
fetchAllRegionData();
};
// Tab 1 数据
const loginTableData1 = ref([]);
const loginTableData2 = ref([]);
const loginTableData3 = ref([]);
// Tab 2 数据:regionalTableData1, 2, 3 已改为响应式变量并在 fetchRegionActiveData 中更新
const headerCellStyle = {
background: '#fff0f0',
color: '#333',
fontWeight: 'bold'
};
const regionColors = ['#68B2FF', '#D94F41', '#69D2AF', '#FFD360', '#ADADAD', '#BE71DD'];
const initCharts = () => {
nextTick(() => {
if (activeTab.value === 'loginData') {
// 趋势图已经在 fetchTrendData -> updateTrendChart 中初始化和更新
// 这里只需要处理初始无数据时的状态,或者等待 fetchTrendData 调用
fetchAllLoginData();
} else if (activeTab.value === 'regionalData') {
fetchAllRegionData();
// 柱状图
if (chartRegionBarRef.value) {
const chart = echarts.init(chartRegionBarRef.value);
chart.setOption({
tooltip: { trigger: 'item' },
legend: { data: ['日活跃用户', '周活跃用户', '月活跃用户'], top: 'top' },
xAxis: {
type: 'category',
data: ['香港', '新加坡', '泰国', '越南', '马来西亚', '加拿大', '美国']
},
yAxis: { type: 'value' },
series: [
{ name: '日活跃用户', type: 'bar', data: [400, 400, 400, 500, 400, 400, 400], itemStyle: { color: '#40a9ff' } },
{ name: '周活跃用户', type: 'bar', data: [500, 500, 500, 600, 500, 500, 500], itemStyle: { color: '#52c41a' } },
{ name: '月活跃用户', type: 'bar', data: [600, 600, 600, 700, 600, 600, 600], itemStyle: { color: '#BC943D' } }
]
});
}
// 饼图 - 地区分布
if (chartRegionPieRef.value) {
const chart = echarts.init(chartRegionPieRef.value);
chart.setOption({
color: regionColors,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
series: [
{
type: 'pie',
radius: '70%',
center: ['50%', '50%'],
data: [
{ value: 1048, name: '香港地区' },
{ value: 735, name: '新加坡地区' },
{ value: 580, name: '泰国地区' },
{ value: 484, name: '越南地区' },
{ value: 300, name: '马来西亚地区' },
{ value: 300, name: '其他地区' }
],
itemStyle: {
borderRadius: 0,
borderColor: '#fff',
borderWidth: 2
},
label: { show: false }
}
]
});
}
// 饼图 - 会员分布
if (chartRegionMemberPieRef.value) {
const chart = echarts.init(chartRegionMemberPieRef.value);
chart.setOption({
color: regionColors,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
series: [
{
type: 'pie',
radius: '70%',
center: ['50%', '50%'],
data: [
{ value: 1048, name: '香港地区' },
{ value: 735, name: '新加坡地区' },
{ value: 580, name: '泰国地区' },
{ value: 484, name: '越南地区' },
{ value: 300, name: '马来西亚地区' },
{ value: 300, name: '其他地区' }
],
label: { show: false }
}
]
});
}
// 饼图 - 非网分布
if (chartRegionNonMemberPieRef.value) {
const chart = echarts.init(chartRegionNonMemberPieRef.value);
chart.setOption({
color: regionColors,
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical', right: '15%', top: 'center', itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 14, fontWeight: 'bold' } },
series: [
{
type: 'pie',
radius: '70%',
center: ['50%', '50%'],
data: [
{ value: 1048, name: '香港地区' },
{ value: 735, name: '新加坡地区' },
{ value: 580, name: '泰国地区' },
{ value: 484, name: '越南地区' },
{ value: 300, name: '马来西亚地区' },
{ value: 300, name: '其他地区' }
],
label: { show: false }
}
]
});
}
}
});
};
watch(activeTab, (newVal) => {
router.replace({ query: { ...route.query, tab: newVal } });
initCharts();
});
onMounted(() => {
if (activeTab.value === 'loginData') {
fetchAllLoginData();
}
fetchRegionOptions();
initCharts();
});
</script>
<style scoped>
.user-login-stats-container {
padding: 20px;
background-color: #fee6e6;
min-height: calc(100vh - 40px);
}
/* Tabs */
.tab-header {
display: flex;
margin-bottom: 20px;
}
.tab-item {
padding: 6px 16px;
margin-right: 10px;
background-color: #fff;
border: 1px solid #ffcccc;
border-radius: 4px;
cursor: pointer;
color: #ff4d4f;
font-size: 14px;
}
.tab-item.active {
background-color: #ff4d4f;
color: #fff;
}
/* Search Bar */
.search-bar {
background: #fff;
padding: 15px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
border: 1px solid #f0f0f0;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.search-label {
font-weight: bold;
margin-right: 5px;
}
.search-btn, .reset-btn {
width: 80px;
}
.search-btn {
background-color: #409eff;
}
.reset-btn {
background-color: #409eff;
border-color: #409eff;
}
.export-btn {
margin-left: auto;
background-color: #ff7875;
border-color: #ff7875;
}
/* Cards */
.stats-row {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.big-card {
flex: 1;
height: 360px;
border-radius: 12px;
padding: 24px;
display: flex;
flex-direction: column;
justify-content: flex-start;
color: #fff;
position: relative;
}
.right-stats-col {
flex: 2;
display: flex;
flex-direction: column;
gap: 20px;
}
.small-card {
flex: 1;
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
justify-content: flex-start;
color: #fff;
position: relative;
}
.purple-gradient { background: linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%); }
.orange-gradient { background: linear-gradient(90deg, #ff8c6d 0%, #ffcba4 100%); }
.blue-gradient { background: linear-gradient(135deg, #9BB7FC 0%, #66a6ff 100%); }
.card-title {
font-size: 34px;
font-weight: bold;
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
width: 100%;
}
.big-card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
}
.card-value {
font-size: 64px;
font-weight: bold;
margin: 0;
text-align: center;
}
.card-value-small {
font-size: 68px;
font-weight: bold;
margin-left: auto;
}
.top-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.card-tag-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.card-tag {
background-color: #fff;
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
display: inline-block;
}
.big-card .card-tag { font-size: 18px; }
.card-tag.up { color: #52c41a; }
.card-tag.down { color: #ff4d4f; }
.text-red { color: #ff4d4f; font-weight: bold; }
.text-green { color: #52c41a; font-weight: bold; }
.text-black { color: #333; font-weight: bold; }
/* Sections */
.chart-section, .detail-section {
background: #fff;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
border: 1px solid #f0f0f0;
}
.section-title {
color: #409eff;
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 5px;
}
.chart-box-large {
width: 100%;
height: 400px;
}
.charts-row {
display: flex;
gap: 20px;
}
.half-width {
flex: 1;
}
.chart-box-medium {
width: 100%;
height: 300px;
}
.refresh-time {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 8px 15px;
border-radius: 20px;
font-size: 12px;
z-index: 2000;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
</style>