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