|
|
@@ -2,7 +2,7 @@
|
|
|
<div class="analytics-page">
|
|
|
<div class="page-header">
|
|
|
<h2>数据分析</h2>
|
|
|
- <div class="date-range">
|
|
|
+ <div class="header-actions">
|
|
|
<el-date-picker
|
|
|
v-model="dateRange"
|
|
|
type="daterange"
|
|
|
@@ -11,18 +11,24 @@
|
|
|
end-placeholder="结束日期"
|
|
|
@change="loadData"
|
|
|
/>
|
|
|
+ <el-button
|
|
|
+ type="primary"
|
|
|
+ :icon="Refresh"
|
|
|
+ :loading="refreshing"
|
|
|
+ @click="handleRefresh"
|
|
|
+ >
|
|
|
+ 刷新数据
|
|
|
+ </el-button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- 统计摘要 -->
|
|
|
- <el-row :gutter="20">
|
|
|
- <el-col :span="4" v-for="(item, index) in summaryItems" :key="index">
|
|
|
- <div class="stat-card">
|
|
|
- <div class="stat-value">{{ formatNumber(item.value) }}</div>
|
|
|
- <div class="stat-label">{{ item.label }}</div>
|
|
|
- </div>
|
|
|
- </el-col>
|
|
|
- </el-row>
|
|
|
+ <div class="stats-grid">
|
|
|
+ <div class="stat-card" v-for="(item, index) in summaryItems" :key="index">
|
|
|
+ <div class="stat-value">{{ formatNumber(item.value) }}</div>
|
|
|
+ <div class="stat-label">{{ item.label }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
|
|
|
<!-- 趋势图 -->
|
|
|
<div class="page-card" style="margin-top: 20px">
|
|
|
@@ -69,36 +75,87 @@
|
|
|
<script setup lang="ts">
|
|
|
import { ref, onMounted, computed } from 'vue';
|
|
|
import * as echarts from 'echarts';
|
|
|
-import request from '@/api/request';
|
|
|
+import { Refresh } from '@element-plus/icons-vue';
|
|
|
import { PLATFORMS } from '@media-manager/shared';
|
|
|
-import type { AnalyticsSummary, AnalyticsTrend, PlatformComparison, PlatformType } from '@media-manager/shared';
|
|
|
+import type { PlatformComparison, PlatformType } from '@media-manager/shared';
|
|
|
+import { useAuthStore } from '@/stores/auth';
|
|
|
import dayjs from 'dayjs';
|
|
|
|
|
|
+const PYTHON_API_URL = 'http://localhost:5005';
|
|
|
+
|
|
|
+// 趋势数据类型
|
|
|
+interface TrendData {
|
|
|
+ dates: string[];
|
|
|
+ fans: number[];
|
|
|
+ views: number[];
|
|
|
+ likes: number[];
|
|
|
+ comments: number[];
|
|
|
+ shares: number[];
|
|
|
+ collects: number[];
|
|
|
+}
|
|
|
+
|
|
|
+const authStore = useAuthStore();
|
|
|
const loading = ref(false);
|
|
|
+const refreshing = ref(false);
|
|
|
const trendChartRef = ref<HTMLElement>();
|
|
|
const platformChartRef = ref<HTMLElement>();
|
|
|
|
|
|
let trendChart: echarts.ECharts | null = null;
|
|
|
let platformChart: echarts.ECharts | null = null;
|
|
|
|
|
|
+// 刷新数据
|
|
|
+async function handleRefresh() {
|
|
|
+ refreshing.value = true;
|
|
|
+ try {
|
|
|
+ await loadData();
|
|
|
+ } finally {
|
|
|
+ refreshing.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
const dateRange = ref<[Date, Date]>([
|
|
|
dayjs().subtract(30, 'day').toDate(),
|
|
|
dayjs().toDate(),
|
|
|
]);
|
|
|
|
|
|
-const trendMetric = ref('fans');
|
|
|
-const summary = ref<AnalyticsSummary | null>(null);
|
|
|
-const trend = ref<AnalyticsTrend | null>(null);
|
|
|
+const trendMetric = ref<'fans' | 'views' | 'likes' | 'comments'>('fans');
|
|
|
+const trendData = ref<TrendData | null>(null);
|
|
|
const platformComparison = ref<PlatformComparison[]>([]);
|
|
|
|
|
|
-const summaryItems = computed(() => [
|
|
|
- { label: '总粉丝', value: summary.value?.totalFans || 0 },
|
|
|
- { label: '粉丝增量', value: summary.value?.totalFansIncrease || 0 },
|
|
|
- { label: '总播放', value: summary.value?.totalViews || 0 },
|
|
|
- { label: '总点赞', value: summary.value?.totalLikes || 0 },
|
|
|
- { label: '总评论', value: summary.value?.totalComments || 0 },
|
|
|
- { label: '总收益', value: summary.value?.totalIncome || 0 },
|
|
|
-]);
|
|
|
+// 从趋势数据计算统计摘要
|
|
|
+const summaryItems = computed(() => {
|
|
|
+ const data = trendData.value;
|
|
|
+ if (!data || data.dates.length === 0) {
|
|
|
+ return [
|
|
|
+ { label: '总粉丝', value: 0 },
|
|
|
+ { label: '粉丝增量', value: 0 },
|
|
|
+ { label: '总播放', value: 0 },
|
|
|
+ { label: '总点赞', value: 0 },
|
|
|
+ { label: '总评论', value: 0 },
|
|
|
+ { label: '总收藏', value: 0 },
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 取最后一天的粉丝数作为当前粉丝数
|
|
|
+ const latestFans = data.fans[data.fans.length - 1] || 0;
|
|
|
+ // 计算粉丝增量(最后一天 - 第一天)
|
|
|
+ const firstFans = data.fans[0] || 0;
|
|
|
+ const fansIncrease = latestFans - firstFans;
|
|
|
+ // 计算区间内的总和
|
|
|
+ const totalViews = data.views.reduce((sum, v) => sum + v, 0);
|
|
|
+ const totalLikes = data.likes.reduce((sum, v) => sum + v, 0);
|
|
|
+ const totalComments = data.comments.reduce((sum, v) => sum + v, 0);
|
|
|
+ const totalCollects = data.collects.reduce((sum, v) => sum + v, 0);
|
|
|
+
|
|
|
+ return [
|
|
|
+ { label: '总粉丝', value: latestFans },
|
|
|
+ { label: '粉丝增量', value: fansIncrease },
|
|
|
+ { label: '总播放', value: totalViews },
|
|
|
+ { label: '总点赞', value: totalLikes },
|
|
|
+ { label: '总评论', value: totalComments },
|
|
|
+ { label: '总收藏', value: totalCollects },
|
|
|
+ ];
|
|
|
+});
|
|
|
|
|
|
function getPlatformName(platform: PlatformType) {
|
|
|
return PLATFORMS[platform]?.name || platform;
|
|
|
@@ -109,26 +166,91 @@ function formatNumber(num: number) {
|
|
|
return num.toString();
|
|
|
}
|
|
|
|
|
|
+// 获取趋势数据(调用 Python API)
|
|
|
+async function loadTrendData() {
|
|
|
+ const userId = authStore.user?.id;
|
|
|
+ if (!userId || !dateRange.value) return;
|
|
|
+
|
|
|
+ // 计算天数
|
|
|
+ const [start, end] = dateRange.value;
|
|
|
+ const days = dayjs(end).diff(dayjs(start), 'day') + 1;
|
|
|
+
|
|
|
+ const queryParams = new URLSearchParams({
|
|
|
+ user_id: userId.toString(),
|
|
|
+ days: Math.min(days, 30).toString(), // 最大30天
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/trend?${queryParams}`);
|
|
|
+ const result = await response.json();
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ throw new Error(result.error || '获取数据趋势失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ return result.data as TrendData;
|
|
|
+}
|
|
|
+
|
|
|
+// 平台统计数据类型
|
|
|
+interface PlatformStats {
|
|
|
+ platform: string;
|
|
|
+ fansCount: number;
|
|
|
+ fansIncrease: number;
|
|
|
+ viewsCount: number;
|
|
|
+ likesCount: number;
|
|
|
+ commentsCount: number;
|
|
|
+ collectsCount: number;
|
|
|
+}
|
|
|
+
|
|
|
+// 获取平台统计数据(调用 Python API)
|
|
|
+async function loadPlatformData() {
|
|
|
+ const userId = authStore.user?.id;
|
|
|
+ if (!userId || !dateRange.value) return;
|
|
|
+
|
|
|
+ // 计算天数
|
|
|
+ const [start, end] = dateRange.value;
|
|
|
+ const days = dayjs(end).diff(dayjs(start), 'day') + 1;
|
|
|
+
|
|
|
+ const queryParams = new URLSearchParams({
|
|
|
+ user_id: userId.toString(),
|
|
|
+ days: Math.min(days, 30).toString(),
|
|
|
+ });
|
|
|
+
|
|
|
+ const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/platforms?${queryParams}`);
|
|
|
+ const result = await response.json();
|
|
|
+
|
|
|
+ if (!result.success) {
|
|
|
+ throw new Error(result.error || '获取平台数据失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ return result.data as PlatformStats[];
|
|
|
+}
|
|
|
+
|
|
|
async function loadData() {
|
|
|
if (!dateRange.value) return;
|
|
|
|
|
|
loading.value = true;
|
|
|
- const [start, end] = dateRange.value;
|
|
|
- const params = {
|
|
|
- startDate: dayjs(start).format('YYYY-MM-DD'),
|
|
|
- endDate: dayjs(end).format('YYYY-MM-DD'),
|
|
|
- };
|
|
|
|
|
|
try {
|
|
|
- const [summaryData, trendData, platformData] = await Promise.all([
|
|
|
- request.get('/api/analytics/summary', { params }),
|
|
|
- request.get('/api/analytics/trend', { params }),
|
|
|
- request.get('/api/analytics/platforms', { params }),
|
|
|
+ // 并行加载数据(全部从 Python API 获取)
|
|
|
+ const [pythonTrendData, pythonPlatformData] = await Promise.all([
|
|
|
+ loadTrendData().catch(() => null),
|
|
|
+ loadPlatformData().catch(() => null),
|
|
|
]);
|
|
|
|
|
|
- summary.value = summaryData;
|
|
|
- trend.value = trendData;
|
|
|
- platformComparison.value = platformData;
|
|
|
+ if (pythonTrendData) {
|
|
|
+ trendData.value = pythonTrendData;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (pythonPlatformData) {
|
|
|
+ // 转换为 PlatformComparison 格式
|
|
|
+ platformComparison.value = pythonPlatformData.map(p => ({
|
|
|
+ platform: p.platform as PlatformType,
|
|
|
+ fansCount: p.fansCount,
|
|
|
+ fansIncrease: p.fansIncrease,
|
|
|
+ viewsCount: p.viewsCount,
|
|
|
+ likesCount: p.likesCount,
|
|
|
+ }));
|
|
|
+ }
|
|
|
|
|
|
updateTrendChart();
|
|
|
updatePlatformChart();
|
|
|
@@ -139,30 +261,97 @@ async function loadData() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// 获取图表颜色配置
|
|
|
+function getChartColor(type: 'fans' | 'views' | 'likes' | 'comments') {
|
|
|
+ const colors: Record<string, { main: string; gradient: string[] }> = {
|
|
|
+ fans: { main: '#4facfe', gradient: ['#4facfe', 'rgba(79, 172, 254, 0)'] },
|
|
|
+ views: { main: '#11998e', gradient: ['#11998e', 'rgba(17, 153, 142, 0)'] },
|
|
|
+ likes: { main: '#f5576c', gradient: ['#f5576c', 'rgba(245, 87, 108, 0)'] },
|
|
|
+ comments: { main: '#fa709a', gradient: ['#fa709a', 'rgba(250, 112, 154, 0)'] },
|
|
|
+ };
|
|
|
+ return colors[type] || colors.fans;
|
|
|
+}
|
|
|
+
|
|
|
+// 获取图表标题
|
|
|
+function getChartTitle(type: 'fans' | 'views' | 'likes' | 'comments') {
|
|
|
+ const titles: Record<string, string> = {
|
|
|
+ fans: '粉丝数',
|
|
|
+ views: '播放量',
|
|
|
+ likes: '点赞数',
|
|
|
+ comments: '评论数',
|
|
|
+ };
|
|
|
+ return titles[type] || '';
|
|
|
+}
|
|
|
+
|
|
|
function updateTrendChart() {
|
|
|
- if (!trendChartRef.value || !trend.value) return;
|
|
|
+ if (!trendChartRef.value) return;
|
|
|
|
|
|
if (!trendChart) {
|
|
|
trendChart = echarts.init(trendChartRef.value);
|
|
|
}
|
|
|
|
|
|
- const data = trend.value[trendMetric.value as keyof AnalyticsTrend] || [];
|
|
|
+ // 使用 Python API 返回的数据
|
|
|
+ const dates = trendData.value?.dates || [];
|
|
|
+ const data = trendData.value?.[trendMetric.value] || [];
|
|
|
+ const colorConfig = getChartColor(trendMetric.value);
|
|
|
|
|
|
trendChart.setOption({
|
|
|
- tooltip: { trigger: 'axis' },
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
|
+ borderColor: '#e5e7eb',
|
|
|
+ borderWidth: 1,
|
|
|
+ textStyle: { color: '#374151' },
|
|
|
+ formatter: (params: unknown) => {
|
|
|
+ const p = params as { name: string; value: number }[];
|
|
|
+ if (Array.isArray(p) && p.length > 0) {
|
|
|
+ return `${p[0].name}<br/>${getChartTitle(trendMetric.value)}: <b>${p[0].value.toLocaleString()}</b>`;
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ },
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ right: '4%',
|
|
|
+ bottom: '3%',
|
|
|
+ containLabel: true,
|
|
|
+ },
|
|
|
xAxis: {
|
|
|
type: 'category',
|
|
|
- data: data.map(d => d.date),
|
|
|
+ data: dates,
|
|
|
+ axisLine: { lineStyle: { color: '#e5e7eb' } },
|
|
|
+ axisLabel: { color: '#6b7280' },
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ axisLine: { show: false },
|
|
|
+ axisTick: { show: false },
|
|
|
+ splitLine: { lineStyle: { color: '#f3f4f6' } },
|
|
|
+ axisLabel: {
|
|
|
+ color: '#6b7280',
|
|
|
+ formatter: (value: number) => {
|
|
|
+ if (value >= 10000) return (value / 10000).toFixed(1) + '万';
|
|
|
+ return value.toString();
|
|
|
+ },
|
|
|
+ },
|
|
|
},
|
|
|
- yAxis: { type: 'value' },
|
|
|
series: [{
|
|
|
- data: data.map(d => d.value),
|
|
|
+ data: data,
|
|
|
type: 'line',
|
|
|
smooth: true,
|
|
|
- areaStyle: { opacity: 0.3 },
|
|
|
+ symbol: 'circle',
|
|
|
+ symbolSize: 6,
|
|
|
+ lineStyle: { width: 3 },
|
|
|
+ areaStyle: {
|
|
|
+ opacity: 0.1,
|
|
|
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
|
+ { offset: 0, color: colorConfig.gradient[0] },
|
|
|
+ { offset: 1, color: colorConfig.gradient[1] },
|
|
|
+ ]),
|
|
|
+ },
|
|
|
}],
|
|
|
- color: ['#409eff'],
|
|
|
- });
|
|
|
+ color: [colorConfig.main],
|
|
|
+ }, true);
|
|
|
}
|
|
|
|
|
|
function updatePlatformChart() {
|
|
|
@@ -205,6 +394,19 @@ onMounted(() => {
|
|
|
margin-bottom: 20px;
|
|
|
|
|
|
h2 { margin: 0; }
|
|
|
+
|
|
|
+ .header-actions {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 12px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.stats-grid {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(6, 1fr);
|
|
|
+ gap: 20px;
|
|
|
+ margin-bottom: 20px;
|
|
|
}
|
|
|
|
|
|
.stat-card {
|
|
|
@@ -227,6 +429,18 @@ onMounted(() => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+@media (max-width: 1200px) {
|
|
|
+ .stats-grid {
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 768px) {
|
|
|
+ .stats-grid {
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
.card-header {
|
|
|
display: flex;
|
|
|
align-items: center;
|