| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621 |
- <template>
- <div class="platform-analytics">
- <!-- 顶部筛选栏 -->
- <div class="filter-bar">
- <div class="filter-left">
- <span class="filter-label">开始时间</span>
- <el-date-picker
- v-model="startDate"
- type="date"
- placeholder="选择日期"
- format="YYYY-MM-DD"
- value-format="YYYY-MM-DD"
- style="width: 140px"
- :disabled-date="(date: Date) => endDate ? date > new Date(endDate) : false"
- @change="handleQuery"
- />
- <span class="filter-label">结束时间</span>
- <el-date-picker
- v-model="endDate"
- type="date"
- placeholder="选择日期"
- format="YYYY-MM-DD"
- value-format="YYYY-MM-DD"
- style="width: 140px"
- :disabled-date="(date: Date) => startDate ? date < new Date(startDate) : false"
- @change="handleQuery"
- />
- <div class="quick-btns">
- <el-button
- v-for="btn in quickDateBtns"
- :key="btn.value"
- :type="activeQuickBtn === btn.value ? 'primary' : 'default'"
- size="small"
- @click="handleQuickDate(btn.value)"
- >
- {{ btn.label }}
- </el-button>
- </div>
- </div>
- <div class="filter-right">
- <el-button @click="handleExport">导出数据</el-button>
- </div>
- </div>
-
- <!-- 数据表格 -->
- <div class="data-table">
- <el-table :data="platformData" v-loading="loading" stripe>
- <el-table-column label="平台" min-width="150">
- <template #default="{ row }">
- <div class="platform-cell">
- <img :src="getPlatformIcon(row.platform)" class="platform-icon" :alt="row.platform" />
- <span class="platform-name">{{ getPlatformName(row.platform) }}</span>
- </div>
- </template>
- </el-table-column>
- <el-table-column prop="viewsCount" label="播放(阅读)量" width="140" align="center">
- <template #default="{ row }">
- <span>{{ row.viewsCount ?? '未支持' }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="commentsCount" label="评论量" width="100" align="center">
- <template #default="{ row }">
- <span>{{ row.commentsCount ?? 0 }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="likesCount" label="点赞量" width="100" align="center">
- <template #default="{ row }">
- <span>{{ row.likesCount ?? 0 }}</span>
- </template>
- </el-table-column>
- <el-table-column prop="fansIncrease" label="涨粉量" width="100" align="center">
- <template #default="{ row }">
- <span :class="{ 'increase': row.fansIncrease > 0, 'decrease': row.fansIncrease < 0 }">
- {{ row.fansIncrease ?? 0 }}
- </span>
- </template>
- </el-table-column>
- <el-table-column prop="updateTime" label="更新时间" width="140" align="center">
- <template #default="{ row }">
- <span class="update-time">{{ formatTime(row.updateTime) }}</span>
- </template>
- </el-table-column>
- <el-table-column label="操作" width="100" align="center" fixed="right">
- <template #default="{ row }">
- <el-button
- type="primary"
- link
- @click.stop.prevent="handleDetail(row)"
- >
- 详情
- </el-button>
- </template>
- </el-table-column>
- </el-table>
- </div>
-
- <!-- 平台详情抽屉 -->
- <el-drawer v-model="drawerVisible" :title="drawerTitle" size="60%">
- <div v-if="selectedPlatform" class="platform-detail">
- <!-- 统计概览 -->
- <div class="detail-stats">
- <div class="stat-item">
- <div class="stat-value">{{ formatNumber(selectedPlatform.fansCount || 0) }}</div>
- <div class="stat-label">总粉丝</div>
- </div>
- <div class="stat-item">
- <div class="stat-value">{{ formatNumber(selectedPlatform.viewsCount || 0) }}</div>
- <div class="stat-label">总播放</div>
- </div>
- <div class="stat-item">
- <div class="stat-value">{{ formatNumber(selectedPlatform.likesCount || 0) }}</div>
- <div class="stat-label">总点赞</div>
- </div>
- <div class="stat-item">
- <div class="stat-value">{{ formatNumber(selectedPlatform.commentsCount || 0) }}</div>
- <div class="stat-label">总评论</div>
- </div>
- </div>
-
- <!-- 趋势图 -->
- <div class="detail-chart">
- <h4>数据趋势</h4>
- <div ref="detailChartRef" style="height: 300px" v-loading="chartLoading"></div>
- </div>
-
- <!-- 该平台账号列表 -->
- <div class="detail-accounts">
- <h4>平台账号</h4>
- <el-table :data="platformAccounts" size="small">
- <el-table-column label="账号" min-width="150">
- <template #default="{ row }">
- <div class="account-cell">
- <el-avatar :size="28" :src="row.avatarUrl">{{ row.nickname?.[0] }}</el-avatar>
- <span>{{ row.nickname || row.username }}</span>
- </div>
- </template>
- </el-table-column>
- <el-table-column prop="fansCount" label="粉丝" width="100" align="center">
- <template #default="{ row }">{{ formatNumber(row.fansCount || 0) }}</template>
- </el-table-column>
- <el-table-column prop="viewsCount" label="播放" width="100" align="center">
- <template #default="{ row }">{{ formatNumber(row.viewsCount || 0) }}</template>
- </el-table-column>
- <el-table-column prop="likesCount" label="点赞" width="100" align="center">
- <template #default="{ row }">{{ formatNumber(row.likesCount || 0) }}</template>
- </el-table-column>
- </el-table>
- </div>
- </div>
- </el-drawer>
- </div>
- </template>
- <script setup lang="ts">
- import { ref, computed, onMounted, watch, nextTick } from 'vue';
- import { useRouter } from 'vue-router';
- import * as echarts from 'echarts';
- import { PLATFORMS } from '@media-manager/shared';
- import type { PlatformType } from '@media-manager/shared';
- import { useAuthStore } from '@/stores/auth';
- import { useServerStore } from '@/stores/server';
- import { ElMessage } from 'element-plus';
- import dayjs from 'dayjs';
- import request from '@/api/request';
- import iconDefaultUrl from '@/assets/platforms/default.svg?url';
- import douyinIconUrl from '@/assets/platforms/douyin.svg?url';
- import xhsIconUrl from '@/assets/platforms/xiaohongshu.svg?url';
- import bilibiliIconUrl from '@/assets/platforms/bilibili.svg?url';
- import kuaishouIconUrl from '@/assets/platforms/kuaishou.svg?url';
- import weixinVideoIconUrl from '@/assets/platforms/weixin_video.svg?url';
- import baijiahaoIconUrl from '@/assets/platforms/baijiahao.svg?url';
- const router = useRouter();
- const authStore = useAuthStore();
- const serverStore = useServerStore();
- const loading = ref(false);
- const chartLoading = ref(false);
- // 日期筛选(默认昨天)
- const startDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
- const endDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
- const activeQuickBtn = ref('yesterday');
- // 快捷日期按钮(去掉“今天”)
- const quickDateBtns = [
- { label: '昨天', value: 'yesterday' },
- { label: '前天', value: 'beforeYesterday' },
- { label: '近三天', value: 'last3days' },
- { label: '近七天', value: 'last7days' },
- { label: '近一个月', value: 'lastMonth' },
- ];
- // 平台图标映射(使用本地 SVG,避免 Electron/CSP 阻止外链图标)
- const iconDefault = iconDefaultUrl;
- const platformIcons: Record<string, string> = {
- douyin: douyinIconUrl,
- xiaohongshu: xhsIconUrl,
- bilibili: bilibiliIconUrl,
- kuaishou: kuaishouIconUrl,
- weixin: weixinVideoIconUrl,
- weixin_video: weixinVideoIconUrl,
- baijiahao: baijiahaoIconUrl,
- };
- // 平台数据
- interface PlatformData {
- platform: PlatformType;
- viewsCount: number | null;
- commentsCount: number;
- likesCount: number;
- fansIncrease: number;
- fansCount: number;
- updateTime: string;
- }
- const platformData = ref<PlatformData[]>([]);
- // 抽屉相关
- const drawerVisible = ref(false);
- const selectedPlatform = ref<PlatformData | null>(null);
- const platformAccounts = ref<any[]>([]);
- const detailChartRef = ref<HTMLElement>();
- let detailChart: echarts.ECharts | null = null;
- const drawerTitle = computed(() => {
- if (!selectedPlatform.value) return '平台详情';
- return `${getPlatformName(selectedPlatform.value.platform)} - 数据详情`;
- });
- function getPlatformName(platform: PlatformType) {
- return PLATFORMS[platform]?.name || platform;
- }
- function getPlatformIcon(platform: PlatformType) {
- return platformIcons[platform] || iconDefault;
- }
- function formatNumber(num: number) {
- if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
- return num.toString();
- }
- function formatTime(time: string) {
- if (!time) return '-';
- const d = dayjs(time);
- if (!d.isValid()) return time;
- const nowYear = dayjs().year();
- return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
- }
- // 快捷日期选择
- function handleQuickDate(type: string) {
- activeQuickBtn.value = type;
- const today = dayjs();
-
- switch (type) {
- case 'yesterday':
- startDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
- endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
- break;
- case 'beforeYesterday':
- startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
- endDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
- break;
- case 'last3days':
- // 今日 - 区间最早(含今天,共 3 天:今天/昨天/前天)
- startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
- endDate.value = today.format('YYYY-MM-DD');
- break;
- case 'last7days':
- // 含今天,共 7 天
- startDate.value = today.subtract(6, 'day').format('YYYY-MM-DD');
- endDate.value = today.format('YYYY-MM-DD');
- break;
- case 'lastMonth':
- // 含今天,共 30 天
- startDate.value = today.subtract(29, 'day').format('YYYY-MM-DD');
- endDate.value = today.format('YYYY-MM-DD');
- break;
- }
- // 点击时间选项后直接刷新列表(无需再点“查询”)
- loadData();
- }
- // 查询
- function handleQuery() {
- loadData();
- }
- // 加载数据(通过 Node 转发调用 Python 接口)
- async function loadData() {
- loading.value = true;
-
- try {
- const data = await request.get('/api/analytics/platforms-from-python', {
- params: {
- startDate: startDate.value,
- endDate: endDate.value,
- },
- });
- if (data) {
- platformData.value = data;
- }
- } catch (error) {
- console.error('加载平台数据失败:', error);
- } finally {
- loading.value = false;
- }
- }
- // 查看详情
- function handleDetail(row: PlatformData) {
- console.log('[Platform] handleDetail called, row:', row);
- console.log('[Platform] platform:', row.platform);
- console.log('[Platform] startDate:', startDate.value, 'endDate:', endDate.value);
- // 直接按完整路径跳转,避免动态路由参数解析问题
- const path = `/analytics/platform-detail/${row.platform}`;
- router.push({
- path,
- query: {
- startDate: startDate.value,
- endDate: endDate.value,
- },
- }).then(() => {
- console.log('[Platform] 路由跳转成功, path:', path);
- }).catch((error) => {
- // 仅在真正的错误时提示,重复导航等忽略
- console.error('[Platform] 路由跳转失败:', error);
- if (error && error.name !== 'NavigationDuplicated') {
- ElMessage.error('跳转失败: ' + (error?.message || '未知错误'));
- }
- });
- }
- // 加载平台详情
- async function loadPlatformDetail(platform: PlatformType) {
- const userId = authStore.user?.id;
- if (!userId) return;
-
- chartLoading.value = true;
-
- try {
- const queryParams = new URLSearchParams({
- user_id: userId.toString(),
- platform: platform,
- start_date: startDate.value,
- end_date: endDate.value,
- });
-
- const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/platform_detail?${queryParams}`);
- const result = await response.json();
-
- if (result.success && result.data) {
- platformAccounts.value = result.data.accounts || [];
-
- // 更新图表
- if (result.data.trend) {
- updateDetailChart(result.data.trend);
- }
- }
- } catch (error) {
- console.error('加载平台详情失败:', error);
- } finally {
- chartLoading.value = false;
- }
- }
- // 更新详情图表
- function updateDetailChart(trendData: { dates: string[]; fans: number[]; views: number[]; likes: number[] }) {
- if (!detailChartRef.value) return;
-
- if (!detailChart) {
- detailChart = echarts.init(detailChartRef.value);
- }
-
- detailChart.setOption({
- tooltip: { trigger: 'axis' },
- legend: { data: ['粉丝', '播放', '点赞'], bottom: 0 },
- grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
- xAxis: {
- type: 'category',
- data: trendData.dates,
- axisLabel: { color: '#6b7280' },
- },
- yAxis: {
- type: 'value',
- axisLabel: {
- color: '#6b7280',
- formatter: (value: number) => value >= 10000 ? (value / 10000).toFixed(1) + '万' : value.toString(),
- },
- },
- series: [
- { name: '粉丝', type: 'line', data: trendData.fans, smooth: true },
- { name: '播放', type: 'line', data: trendData.views, smooth: true },
- { name: '点赞', type: 'line', data: trendData.likes, smooth: true },
- ],
- });
- }
- // 导出数据
- async function handleExport() {
- try {
- const baseUrl = serverStore.currentServer?.url;
- if (!baseUrl) {
- ElMessage.error('未连接服务器');
- return;
- }
- if (!authStore.accessToken) {
- ElMessage.error('未连接服务器或未登录');
- return;
- }
- const buildUrl = () => {
- const params = new URLSearchParams();
- if (startDate.value) params.set('startDate', startDate.value);
- if (endDate.value) params.set('endDate', endDate.value);
- return `${baseUrl}/api/work-day-statistics/platforms/export?${params.toString()}`;
- };
- const doFetch = async (token: string) => {
- const url = buildUrl();
- return await fetch(url, {
- method: 'GET',
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
- };
- let resp = await doFetch(authStore.accessToken!);
- if (resp.status === 401) {
- const refreshed = await authStore.refreshAccessToken();
- if (!refreshed || !authStore.accessToken) {
- ElMessage.error('登录已过期,请重新登录');
- return;
- }
- resp = await doFetch(authStore.accessToken);
- }
- if (!resp.ok) {
- const text = await resp.text().catch(() => '');
- throw new Error(text || `导出失败,状态码:${resp.status}`);
- }
- const blob = await resp.blob();
- const downloadUrl = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = downloadUrl;
- a.download = `平台数据_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
- document.body.appendChild(a);
- a.click();
- a.remove();
- window.URL.revokeObjectURL(downloadUrl);
- } catch (error: any) {
- console.error('导出失败:', error);
- ElMessage.error(error?.message || '导出失败');
- }
- }
- // 监听抽屉关闭
- watch(drawerVisible, (visible) => {
- if (!visible && detailChart) {
- detailChart.dispose();
- detailChart = null;
- }
- });
- onMounted(() => {
- // 默认选择昨天
- handleQuickDate('yesterday');
- loadData();
- });
- </script>
- <style lang="scss" scoped>
- @use '@/styles/variables.scss' as *;
- .platform-analytics {
- .filter-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 20px;
- padding: 16px 20px;
- background: #fff;
- border-radius: $radius-lg;
- box-shadow: $shadow-sm;
-
- .filter-left {
- display: flex;
- align-items: center;
- gap: 12px;
-
- .filter-label {
- font-size: 14px;
- color: $text-regular;
- }
-
- .quick-btns {
- display: flex;
- gap: 8px;
- margin-left: 8px;
- }
- }
- }
-
- .data-table {
- background: #fff;
- border-radius: $radius-lg;
- box-shadow: $shadow-sm;
- overflow: hidden;
-
- .platform-cell {
- display: flex;
- align-items: center;
- gap: 10px;
-
- .platform-icon {
- width: 24px;
- height: 24px;
- border-radius: 4px;
- }
-
- .platform-name {
- font-weight: 500;
- color: $text-primary;
- }
- }
-
- .increase {
- color: #10b981;
- }
-
- .decrease {
- color: #ef4444;
- }
-
- .update-time {
- font-size: 12px;
- color: $text-secondary;
- }
- }
- }
- .platform-detail {
- .detail-stats {
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: 16px;
- margin-bottom: 24px;
-
- .stat-item {
- background: #f8fafc;
- border-radius: 12px;
- padding: 16px;
- text-align: center;
-
- .stat-value {
- font-size: 24px;
- font-weight: 600;
- color: $primary-color;
- }
-
- .stat-label {
- font-size: 13px;
- color: $text-secondary;
- margin-top: 4px;
- }
- }
- }
-
- .detail-chart {
- margin-bottom: 24px;
-
- h4 {
- margin: 0 0 16px 0;
- font-size: 15px;
- color: $text-primary;
- }
- }
-
- .detail-accounts {
- h4 {
- margin: 0 0 16px 0;
- font-size: 15px;
- color: $text-primary;
- }
-
- .account-cell {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- }
- }
- @media (max-width: 1200px) {
- .platform-analytics {
- .filter-bar {
- flex-direction: column;
- align-items: flex-start;
- gap: 12px;
-
- .filter-left {
- flex-wrap: wrap;
-
- .quick-btns {
- width: 100%;
- margin-left: 0;
- margin-top: 8px;
- }
- }
- }
- }
- }
- </style>
|