index.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. <template>
  2. <div class="platform-analytics">
  3. <!-- 顶部筛选栏 -->
  4. <div class="filter-bar">
  5. <div class="filter-left">
  6. <span class="filter-label">开始时间</span>
  7. <el-date-picker
  8. v-model="startDate"
  9. type="date"
  10. placeholder="选择日期"
  11. format="YYYY-MM-DD"
  12. value-format="YYYY-MM-DD"
  13. style="width: 140px"
  14. :disabled-date="(date: Date) => endDate ? date > new Date(endDate) : false"
  15. @change="handleQuery"
  16. />
  17. <span class="filter-label">结束时间</span>
  18. <el-date-picker
  19. v-model="endDate"
  20. type="date"
  21. placeholder="选择日期"
  22. format="YYYY-MM-DD"
  23. value-format="YYYY-MM-DD"
  24. style="width: 140px"
  25. :disabled-date="(date: Date) => startDate ? date < new Date(startDate) : false"
  26. @change="handleQuery"
  27. />
  28. <div class="quick-btns">
  29. <el-button
  30. v-for="btn in quickDateBtns"
  31. :key="btn.value"
  32. :type="activeQuickBtn === btn.value ? 'primary' : 'default'"
  33. size="small"
  34. @click="handleQuickDate(btn.value)"
  35. >
  36. {{ btn.label }}
  37. </el-button>
  38. </div>
  39. </div>
  40. <div class="filter-right">
  41. <el-button @click="handleExport">导出数据</el-button>
  42. </div>
  43. </div>
  44. <!-- 数据表格 -->
  45. <div class="data-table">
  46. <el-table :data="platformData" v-loading="loading" stripe>
  47. <el-table-column label="平台" min-width="150">
  48. <template #default="{ row }">
  49. <div class="platform-cell">
  50. <img :src="getPlatformIcon(row.platform)" class="platform-icon" :alt="row.platform" />
  51. <span class="platform-name">{{ getPlatformName(row.platform) }}</span>
  52. </div>
  53. </template>
  54. </el-table-column>
  55. <el-table-column prop="viewsCount" label="播放(阅读)量" width="140" align="center">
  56. <template #default="{ row }">
  57. <span>{{ row.viewsCount ?? '未支持' }}</span>
  58. </template>
  59. </el-table-column>
  60. <el-table-column prop="commentsCount" label="评论量" width="100" align="center">
  61. <template #default="{ row }">
  62. <span>{{ row.commentsCount ?? 0 }}</span>
  63. </template>
  64. </el-table-column>
  65. <el-table-column prop="likesCount" label="点赞量" width="100" align="center">
  66. <template #default="{ row }">
  67. <span>{{ row.likesCount ?? 0 }}</span>
  68. </template>
  69. </el-table-column>
  70. <el-table-column prop="fansIncrease" label="涨粉量" width="100" align="center">
  71. <template #default="{ row }">
  72. <span :class="{ 'increase': row.fansIncrease > 0, 'decrease': row.fansIncrease < 0 }">
  73. {{ row.fansIncrease ?? 0 }}
  74. </span>
  75. </template>
  76. </el-table-column>
  77. <el-table-column prop="updateTime" label="更新时间" width="140" align="center">
  78. <template #default="{ row }">
  79. <span class="update-time">{{ formatTime(row.updateTime) }}</span>
  80. </template>
  81. </el-table-column>
  82. <el-table-column label="操作" width="100" align="center" fixed="right">
  83. <template #default="{ row }">
  84. <el-button
  85. type="primary"
  86. link
  87. @click="handleDetail(row)"
  88. >
  89. 详情
  90. </el-button>
  91. </template>
  92. </el-table-column>
  93. </el-table>
  94. </div>
  95. <!-- 平台详情抽屉 -->
  96. <el-drawer v-model="drawerVisible" :title="drawerTitle" size="60%">
  97. <div v-if="selectedPlatform" class="platform-detail">
  98. <!-- 统计概览 -->
  99. <div class="detail-stats">
  100. <div class="stat-item">
  101. <div class="stat-value">{{ formatNumber(selectedPlatform.fansCount || 0) }}</div>
  102. <div class="stat-label">总粉丝</div>
  103. </div>
  104. <div class="stat-item">
  105. <div class="stat-value">{{ formatNumber(selectedPlatform.viewsCount || 0) }}</div>
  106. <div class="stat-label">总播放</div>
  107. </div>
  108. <div class="stat-item">
  109. <div class="stat-value">{{ formatNumber(selectedPlatform.likesCount || 0) }}</div>
  110. <div class="stat-label">总点赞</div>
  111. </div>
  112. <div class="stat-item">
  113. <div class="stat-value">{{ formatNumber(selectedPlatform.commentsCount || 0) }}</div>
  114. <div class="stat-label">总评论</div>
  115. </div>
  116. </div>
  117. <!-- 趋势图 -->
  118. <div class="detail-chart">
  119. <h4>数据趋势</h4>
  120. <div ref="detailChartRef" style="height: 300px" v-loading="chartLoading"></div>
  121. </div>
  122. <!-- 该平台账号列表 -->
  123. <div class="detail-accounts">
  124. <h4>平台账号</h4>
  125. <el-table :data="platformAccounts" size="small">
  126. <el-table-column label="账号" min-width="150">
  127. <template #default="{ row }">
  128. <div class="account-cell">
  129. <el-avatar :size="28" :src="row.avatarUrl">{{ row.nickname?.[0] }}</el-avatar>
  130. <span>{{ row.nickname || row.username }}</span>
  131. </div>
  132. </template>
  133. </el-table-column>
  134. <el-table-column prop="fansCount" label="粉丝" width="100" align="center">
  135. <template #default="{ row }">{{ formatNumber(row.fansCount || 0) }}</template>
  136. </el-table-column>
  137. <el-table-column prop="viewsCount" label="播放" width="100" align="center">
  138. <template #default="{ row }">{{ formatNumber(row.viewsCount || 0) }}</template>
  139. </el-table-column>
  140. <el-table-column prop="likesCount" label="点赞" width="100" align="center">
  141. <template #default="{ row }">{{ formatNumber(row.likesCount || 0) }}</template>
  142. </el-table-column>
  143. </el-table>
  144. </div>
  145. </div>
  146. </el-drawer>
  147. </div>
  148. </template>
  149. <script setup lang="ts">
  150. import { ref, computed, onMounted, watch, nextTick } from 'vue';
  151. import { useRouter } from 'vue-router';
  152. import * as echarts from 'echarts';
  153. import { PLATFORMS } from '@media-manager/shared';
  154. import type { PlatformType } from '@media-manager/shared';
  155. import { useAuthStore } from '@/stores/auth';
  156. import { useServerStore } from '@/stores/server';
  157. import { useTaskQueueStore } from '@/stores/taskQueue';
  158. import { ElMessage } from 'element-plus';
  159. import dayjs from 'dayjs';
  160. import request from '@/api/request';
  161. import iconDefaultUrl from '@/assets/platforms/default.svg?url';
  162. import douyinIconUrl from '@/assets/platforms/douyin.svg?url';
  163. import xhsIconUrl from '@/assets/platforms/xiaohongshu.svg?url';
  164. import bilibiliIconUrl from '@/assets/platforms/bilibili.svg?url';
  165. import kuaishouIconUrl from '@/assets/platforms/kuaishou.svg?url';
  166. import weixinVideoIconUrl from '@/assets/platforms/weixin_video.svg?url';
  167. import baijiahaoIconUrl from '@/assets/platforms/baijiahao.svg?url';
  168. const router = useRouter();
  169. const authStore = useAuthStore();
  170. const serverStore = useServerStore();
  171. const taskStore = useTaskQueueStore();
  172. const loading = ref(false);
  173. const chartLoading = ref(false);
  174. // 日期筛选(默认昨天)
  175. const startDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
  176. const endDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
  177. const activeQuickBtn = ref('yesterday');
  178. // 快捷日期按钮(去掉“今天”)
  179. const quickDateBtns = [
  180. { label: '昨天', value: 'yesterday' },
  181. { label: '前天', value: 'beforeYesterday' },
  182. { label: '近三天', value: 'last3days' },
  183. { label: '近七天', value: 'last7days' },
  184. { label: '近一个月', value: 'lastMonth' },
  185. ];
  186. // 平台图标映射(使用本地 SVG,避免 Electron/CSP 阻止外链图标)
  187. const iconDefault = iconDefaultUrl;
  188. const platformIcons: Record<string, string> = {
  189. douyin: douyinIconUrl,
  190. xiaohongshu: xhsIconUrl,
  191. bilibili: bilibiliIconUrl,
  192. kuaishou: kuaishouIconUrl,
  193. weixin: weixinVideoIconUrl,
  194. weixin_video: weixinVideoIconUrl,
  195. baijiahao: baijiahaoIconUrl,
  196. };
  197. // 平台数据
  198. interface PlatformData {
  199. platform: PlatformType;
  200. viewsCount: number | null;
  201. commentsCount: number;
  202. likesCount: number;
  203. fansIncrease: number;
  204. fansCount: number;
  205. updateTime: string;
  206. }
  207. const platformData = ref<PlatformData[]>([]);
  208. // 抽屉相关
  209. const drawerVisible = ref(false);
  210. const selectedPlatform = ref<PlatformData | null>(null);
  211. const platformAccounts = ref<any[]>([]);
  212. const detailChartRef = ref<HTMLElement>();
  213. let detailChart: echarts.ECharts | null = null;
  214. const drawerTitle = computed(() => {
  215. if (!selectedPlatform.value) return '平台详情';
  216. return `${getPlatformName(selectedPlatform.value.platform)} - 数据详情`;
  217. });
  218. function getPlatformName(platform: PlatformType) {
  219. return PLATFORMS[platform]?.name || platform;
  220. }
  221. function getPlatformIcon(platform: PlatformType) {
  222. return platformIcons[platform] || iconDefault;
  223. }
  224. function formatNumber(num: number) {
  225. if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
  226. return num.toString();
  227. }
  228. function formatTime(time: string) {
  229. if (!time) return '-';
  230. const d = dayjs(time);
  231. if (!d.isValid()) return time;
  232. const nowYear = dayjs().year();
  233. return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
  234. }
  235. // 快捷日期选择
  236. function handleQuickDate(type: string) {
  237. activeQuickBtn.value = type;
  238. const today = dayjs();
  239. switch (type) {
  240. case 'yesterday':
  241. startDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  242. endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  243. break;
  244. case 'beforeYesterday':
  245. startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  246. endDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  247. break;
  248. case 'last3days':
  249. // 从昨天往前推3天(今天数据往往未统计)
  250. startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  251. endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  252. break;
  253. case 'last7days':
  254. // 从昨天往前推7天
  255. startDate.value = today.subtract(6, 'day').format('YYYY-MM-DD');
  256. endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  257. break;
  258. case 'lastMonth':
  259. // 从昨天往前推30天
  260. startDate.value = today.subtract(29, 'day').format('YYYY-MM-DD');
  261. endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  262. break;
  263. }
  264. // 点击时间选项后直接刷新列表(无需再点“查询”)
  265. loadData();
  266. }
  267. // 查询
  268. function handleQuery() {
  269. loadData();
  270. }
  271. // 加载数据(通过 Node 转发调用 Python 接口)
  272. async function loadData() {
  273. loading.value = true;
  274. try {
  275. const data = await request.get('/api/analytics/platforms-from-python', {
  276. params: {
  277. startDate: startDate.value,
  278. endDate: endDate.value,
  279. },
  280. });
  281. if (data) {
  282. platformData.value = data;
  283. }
  284. } catch (error) {
  285. console.error('加载平台数据失败:', error);
  286. } finally {
  287. loading.value = false;
  288. }
  289. }
  290. // 查看详情
  291. function handleDetail(row: PlatformData) {
  292. console.log('[Platform] handleDetail called, row:', row);
  293. console.log('[Platform] platform:', row.platform);
  294. console.log('[Platform] startDate:', startDate.value, 'endDate:', endDate.value);
  295. if (!row.platform) {
  296. ElMessage.error('缺少平台参数,无法查看详情');
  297. return;
  298. }
  299. try {
  300. // 直接按完整路径跳转,避免动态路由参数解析问题
  301. const path = `/analytics/platform-detail/${row.platform}`;
  302. router.push({
  303. path,
  304. query: {
  305. startDate: startDate.value,
  306. endDate: endDate.value,
  307. },
  308. }).catch((error) => {
  309. // 仅在真正的错误时提示,重复导航等忽略
  310. console.error('[Platform] 路由跳转失败:', error);
  311. if (error && error.name !== 'NavigationDuplicated') {
  312. ElMessage.error('跳转详情页失败: ' + (error?.message || '未知错误'));
  313. }
  314. });
  315. } catch (error: any) {
  316. console.error('[Platform] handleDetail 异常:', error);
  317. ElMessage.error('查看详情失败: ' + (error?.message || '未知错误'));
  318. }
  319. }
  320. // 加载平台详情
  321. async function loadPlatformDetail(platform: PlatformType) {
  322. const userId = authStore.user?.id;
  323. if (!userId) return;
  324. chartLoading.value = true;
  325. try {
  326. const queryParams = new URLSearchParams({
  327. user_id: userId.toString(),
  328. platform: platform,
  329. start_date: startDate.value,
  330. end_date: endDate.value,
  331. });
  332. const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/platform_detail?${queryParams}`);
  333. const result = await response.json();
  334. if (result.success && result.data) {
  335. platformAccounts.value = result.data.accounts || [];
  336. // 更新图表
  337. if (result.data.trend) {
  338. updateDetailChart(result.data.trend);
  339. }
  340. }
  341. } catch (error) {
  342. console.error('加载平台详情失败:', error);
  343. } finally {
  344. chartLoading.value = false;
  345. }
  346. }
  347. // 更新详情图表
  348. function updateDetailChart(trendData: { dates: string[]; fans: number[]; views: number[]; likes: number[] }) {
  349. if (!detailChartRef.value) return;
  350. if (!detailChart) {
  351. detailChart = echarts.init(detailChartRef.value);
  352. }
  353. detailChart.setOption({
  354. tooltip: { trigger: 'axis' },
  355. legend: { data: ['粉丝', '播放', '点赞'], bottom: 0 },
  356. grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
  357. xAxis: {
  358. type: 'category',
  359. data: trendData.dates,
  360. axisLabel: { color: '#6b7280' },
  361. },
  362. yAxis: {
  363. type: 'value',
  364. axisLabel: {
  365. color: '#6b7280',
  366. formatter: (value: number) => value >= 10000 ? (value / 10000).toFixed(1) + '万' : value.toString(),
  367. },
  368. },
  369. series: [
  370. { name: '粉丝', type: 'line', data: trendData.fans, smooth: true },
  371. { name: '播放', type: 'line', data: trendData.views, smooth: true },
  372. { name: '点赞', type: 'line', data: trendData.likes, smooth: true },
  373. ],
  374. });
  375. }
  376. // 导出数据
  377. async function handleExport() {
  378. try {
  379. const baseUrl = serverStore.currentServer?.url;
  380. if (!baseUrl) {
  381. ElMessage.error('未连接服务器');
  382. return;
  383. }
  384. if (!authStore.accessToken) {
  385. ElMessage.error('未连接服务器或未登录');
  386. return;
  387. }
  388. const buildUrl = () => {
  389. const params = new URLSearchParams();
  390. if (startDate.value) params.set('startDate', startDate.value);
  391. if (endDate.value) params.set('endDate', endDate.value);
  392. return `${baseUrl}/api/work-day-statistics/platforms/export?${params.toString()}`;
  393. };
  394. const doFetch = async (token: string) => {
  395. const url = buildUrl();
  396. return await fetch(url, {
  397. method: 'GET',
  398. headers: {
  399. Authorization: `Bearer ${token}`,
  400. },
  401. });
  402. };
  403. let resp = await doFetch(authStore.accessToken!);
  404. if (resp.status === 401) {
  405. const refreshed = await authStore.refreshAccessToken();
  406. if (!refreshed || !authStore.accessToken) {
  407. ElMessage.error('登录已过期,请重新登录');
  408. return;
  409. }
  410. resp = await doFetch(authStore.accessToken);
  411. }
  412. if (!resp.ok) {
  413. const text = await resp.text().catch(() => '');
  414. throw new Error(text || `导出失败,状态码:${resp.status}`);
  415. }
  416. const blob = await resp.blob();
  417. const downloadUrl = window.URL.createObjectURL(blob);
  418. const a = document.createElement('a');
  419. a.href = downloadUrl;
  420. a.download = `平台数据_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
  421. document.body.appendChild(a);
  422. a.click();
  423. a.remove();
  424. window.URL.revokeObjectURL(downloadUrl);
  425. } catch (error: any) {
  426. console.error('导出失败:', error);
  427. ElMessage.error(error?.message || '导出失败');
  428. }
  429. }
  430. // 监听抽屉关闭
  431. watch(drawerVisible, (visible) => {
  432. if (!visible && detailChart) {
  433. detailChart.dispose();
  434. detailChart = null;
  435. }
  436. });
  437. // Bug #6070: 监听账号数据变更事件,新增账号后自动刷新数据
  438. watch(() => taskStore.accountRefreshTrigger, () => {
  439. console.log('[Analytics/Platform] Account data changed, reloading...');
  440. loadData();
  441. });
  442. onMounted(() => {
  443. // 默认选择昨天
  444. handleQuickDate('yesterday');
  445. loadData();
  446. });
  447. </script>
  448. <style lang="scss" scoped>
  449. @use '@/styles/variables.scss' as *;
  450. .platform-analytics {
  451. .filter-bar {
  452. display: flex;
  453. align-items: center;
  454. justify-content: space-between;
  455. margin-bottom: 20px;
  456. padding: 16px 20px;
  457. background: #fff;
  458. border-radius: $radius-lg;
  459. box-shadow: $shadow-sm;
  460. .filter-left {
  461. display: flex;
  462. align-items: center;
  463. gap: 12px;
  464. .filter-label {
  465. font-size: 14px;
  466. color: $text-regular;
  467. }
  468. .quick-btns {
  469. display: flex;
  470. gap: 8px;
  471. margin-left: 8px;
  472. }
  473. }
  474. }
  475. .data-table {
  476. background: #fff;
  477. border-radius: $radius-lg;
  478. box-shadow: $shadow-sm;
  479. overflow: hidden;
  480. .platform-cell {
  481. display: flex;
  482. align-items: center;
  483. gap: 10px;
  484. .platform-icon {
  485. width: 24px;
  486. height: 24px;
  487. border-radius: 4px;
  488. }
  489. .platform-name {
  490. font-weight: 500;
  491. color: $text-primary;
  492. }
  493. }
  494. .increase {
  495. color: #10b981;
  496. }
  497. .decrease {
  498. color: #ef4444;
  499. }
  500. .update-time {
  501. font-size: 12px;
  502. color: $text-secondary;
  503. }
  504. }
  505. }
  506. .platform-detail {
  507. .detail-stats {
  508. display: grid;
  509. grid-template-columns: repeat(4, 1fr);
  510. gap: 16px;
  511. margin-bottom: 24px;
  512. .stat-item {
  513. background: #f8fafc;
  514. border-radius: 12px;
  515. padding: 16px;
  516. text-align: center;
  517. .stat-value {
  518. font-size: 24px;
  519. font-weight: 600;
  520. color: $primary-color;
  521. }
  522. .stat-label {
  523. font-size: 13px;
  524. color: $text-secondary;
  525. margin-top: 4px;
  526. }
  527. }
  528. }
  529. .detail-chart {
  530. margin-bottom: 24px;
  531. h4 {
  532. margin: 0 0 16px 0;
  533. font-size: 15px;
  534. color: $text-primary;
  535. }
  536. }
  537. .detail-accounts {
  538. h4 {
  539. margin: 0 0 16px 0;
  540. font-size: 15px;
  541. color: $text-primary;
  542. }
  543. .account-cell {
  544. display: flex;
  545. align-items: center;
  546. gap: 8px;
  547. }
  548. }
  549. }
  550. @media (max-width: 1200px) {
  551. .platform-analytics {
  552. .filter-bar {
  553. flex-direction: column;
  554. align-items: flex-start;
  555. gap: 12px;
  556. .filter-left {
  557. flex-wrap: wrap;
  558. .quick-btns {
  559. width: 100%;
  560. margin-left: 0;
  561. margin-top: 8px;
  562. }
  563. }
  564. }
  565. }
  566. }
  567. </style>