index.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  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.stop.prevent="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 { ElMessage } from 'element-plus';
  158. import dayjs from 'dayjs';
  159. import request from '@/api/request';
  160. import iconDefaultUrl from '@/assets/platforms/default.svg?url';
  161. import douyinIconUrl from '@/assets/platforms/douyin.svg?url';
  162. import xhsIconUrl from '@/assets/platforms/xiaohongshu.svg?url';
  163. import bilibiliIconUrl from '@/assets/platforms/bilibili.svg?url';
  164. import kuaishouIconUrl from '@/assets/platforms/kuaishou.svg?url';
  165. import weixinVideoIconUrl from '@/assets/platforms/weixin_video.svg?url';
  166. import baijiahaoIconUrl from '@/assets/platforms/baijiahao.svg?url';
  167. const router = useRouter();
  168. const authStore = useAuthStore();
  169. const serverStore = useServerStore();
  170. const loading = ref(false);
  171. const chartLoading = ref(false);
  172. // 日期筛选(默认昨天)
  173. const startDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
  174. const endDate = ref(dayjs().subtract(1, 'day').format('YYYY-MM-DD'));
  175. const activeQuickBtn = ref('yesterday');
  176. // 快捷日期按钮(去掉“今天”)
  177. const quickDateBtns = [
  178. { label: '昨天', value: 'yesterday' },
  179. { label: '前天', value: 'beforeYesterday' },
  180. { label: '近三天', value: 'last3days' },
  181. { label: '近七天', value: 'last7days' },
  182. { label: '近一个月', value: 'lastMonth' },
  183. ];
  184. // 平台图标映射(使用本地 SVG,避免 Electron/CSP 阻止外链图标)
  185. const iconDefault = iconDefaultUrl;
  186. const platformIcons: Record<string, string> = {
  187. douyin: douyinIconUrl,
  188. xiaohongshu: xhsIconUrl,
  189. bilibili: bilibiliIconUrl,
  190. kuaishou: kuaishouIconUrl,
  191. weixin: weixinVideoIconUrl,
  192. weixin_video: weixinVideoIconUrl,
  193. baijiahao: baijiahaoIconUrl,
  194. };
  195. // 平台数据
  196. interface PlatformData {
  197. platform: PlatformType;
  198. viewsCount: number | null;
  199. commentsCount: number;
  200. likesCount: number;
  201. fansIncrease: number;
  202. fansCount: number;
  203. updateTime: string;
  204. }
  205. const platformData = ref<PlatformData[]>([]);
  206. // 抽屉相关
  207. const drawerVisible = ref(false);
  208. const selectedPlatform = ref<PlatformData | null>(null);
  209. const platformAccounts = ref<any[]>([]);
  210. const detailChartRef = ref<HTMLElement>();
  211. let detailChart: echarts.ECharts | null = null;
  212. const drawerTitle = computed(() => {
  213. if (!selectedPlatform.value) return '平台详情';
  214. return `${getPlatformName(selectedPlatform.value.platform)} - 数据详情`;
  215. });
  216. function getPlatformName(platform: PlatformType) {
  217. return PLATFORMS[platform]?.name || platform;
  218. }
  219. function getPlatformIcon(platform: PlatformType) {
  220. return platformIcons[platform] || iconDefault;
  221. }
  222. function formatNumber(num: number) {
  223. if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
  224. return num.toString();
  225. }
  226. function formatTime(time: string) {
  227. if (!time) return '-';
  228. const d = dayjs(time);
  229. if (!d.isValid()) return time;
  230. const nowYear = dayjs().year();
  231. return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
  232. }
  233. // 快捷日期选择
  234. function handleQuickDate(type: string) {
  235. activeQuickBtn.value = type;
  236. const today = dayjs();
  237. switch (type) {
  238. case 'yesterday':
  239. startDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  240. endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  241. break;
  242. case 'beforeYesterday':
  243. startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  244. endDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  245. break;
  246. case 'last3days':
  247. // 今日 - 区间最早(含今天,共 3 天:今天/昨天/前天)
  248. startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  249. endDate.value = today.format('YYYY-MM-DD');
  250. break;
  251. case 'last7days':
  252. // 含今天,共 7 天
  253. startDate.value = today.subtract(6, 'day').format('YYYY-MM-DD');
  254. endDate.value = today.format('YYYY-MM-DD');
  255. break;
  256. case 'lastMonth':
  257. // 含今天,共 30 天
  258. startDate.value = today.subtract(29, 'day').format('YYYY-MM-DD');
  259. endDate.value = today.format('YYYY-MM-DD');
  260. break;
  261. }
  262. // 点击时间选项后直接刷新列表(无需再点“查询”)
  263. loadData();
  264. }
  265. // 查询
  266. function handleQuery() {
  267. loadData();
  268. }
  269. // 加载数据(通过 Node 转发调用 Python 接口)
  270. async function loadData() {
  271. loading.value = true;
  272. try {
  273. const data = await request.get('/api/analytics/platforms-from-python', {
  274. params: {
  275. startDate: startDate.value,
  276. endDate: endDate.value,
  277. },
  278. });
  279. if (data) {
  280. platformData.value = data;
  281. }
  282. } catch (error) {
  283. console.error('加载平台数据失败:', error);
  284. } finally {
  285. loading.value = false;
  286. }
  287. }
  288. // 查看详情
  289. function handleDetail(row: PlatformData) {
  290. console.log('[Platform] handleDetail called, row:', row);
  291. console.log('[Platform] platform:', row.platform);
  292. console.log('[Platform] startDate:', startDate.value, 'endDate:', endDate.value);
  293. // 直接按完整路径跳转,避免动态路由参数解析问题
  294. const path = `/analytics/platform-detail/${row.platform}`;
  295. router.push({
  296. path,
  297. query: {
  298. startDate: startDate.value,
  299. endDate: endDate.value,
  300. },
  301. }).then(() => {
  302. console.log('[Platform] 路由跳转成功, path:', path);
  303. }).catch((error) => {
  304. // 仅在真正的错误时提示,重复导航等忽略
  305. console.error('[Platform] 路由跳转失败:', error);
  306. if (error && error.name !== 'NavigationDuplicated') {
  307. ElMessage.error('跳转失败: ' + (error?.message || '未知错误'));
  308. }
  309. });
  310. }
  311. // 加载平台详情
  312. async function loadPlatformDetail(platform: PlatformType) {
  313. const userId = authStore.user?.id;
  314. if (!userId) return;
  315. chartLoading.value = true;
  316. try {
  317. const queryParams = new URLSearchParams({
  318. user_id: userId.toString(),
  319. platform: platform,
  320. start_date: startDate.value,
  321. end_date: endDate.value,
  322. });
  323. const response = await fetch(`${PYTHON_API_URL}/work_day_statistics/platform_detail?${queryParams}`);
  324. const result = await response.json();
  325. if (result.success && result.data) {
  326. platformAccounts.value = result.data.accounts || [];
  327. // 更新图表
  328. if (result.data.trend) {
  329. updateDetailChart(result.data.trend);
  330. }
  331. }
  332. } catch (error) {
  333. console.error('加载平台详情失败:', error);
  334. } finally {
  335. chartLoading.value = false;
  336. }
  337. }
  338. // 更新详情图表
  339. function updateDetailChart(trendData: { dates: string[]; fans: number[]; views: number[]; likes: number[] }) {
  340. if (!detailChartRef.value) return;
  341. if (!detailChart) {
  342. detailChart = echarts.init(detailChartRef.value);
  343. }
  344. detailChart.setOption({
  345. tooltip: { trigger: 'axis' },
  346. legend: { data: ['粉丝', '播放', '点赞'], bottom: 0 },
  347. grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
  348. xAxis: {
  349. type: 'category',
  350. data: trendData.dates,
  351. axisLabel: { color: '#6b7280' },
  352. },
  353. yAxis: {
  354. type: 'value',
  355. axisLabel: {
  356. color: '#6b7280',
  357. formatter: (value: number) => value >= 10000 ? (value / 10000).toFixed(1) + '万' : value.toString(),
  358. },
  359. },
  360. series: [
  361. { name: '粉丝', type: 'line', data: trendData.fans, smooth: true },
  362. { name: '播放', type: 'line', data: trendData.views, smooth: true },
  363. { name: '点赞', type: 'line', data: trendData.likes, smooth: true },
  364. ],
  365. });
  366. }
  367. // 导出数据
  368. async function handleExport() {
  369. try {
  370. const baseUrl = serverStore.currentServer?.url;
  371. if (!baseUrl) {
  372. ElMessage.error('未连接服务器');
  373. return;
  374. }
  375. if (!authStore.accessToken) {
  376. ElMessage.error('未连接服务器或未登录');
  377. return;
  378. }
  379. const buildUrl = () => {
  380. const params = new URLSearchParams();
  381. if (startDate.value) params.set('startDate', startDate.value);
  382. if (endDate.value) params.set('endDate', endDate.value);
  383. return `${baseUrl}/api/work-day-statistics/platforms/export?${params.toString()}`;
  384. };
  385. const doFetch = async (token: string) => {
  386. const url = buildUrl();
  387. return await fetch(url, {
  388. method: 'GET',
  389. headers: {
  390. Authorization: `Bearer ${token}`,
  391. },
  392. });
  393. };
  394. let resp = await doFetch(authStore.accessToken!);
  395. if (resp.status === 401) {
  396. const refreshed = await authStore.refreshAccessToken();
  397. if (!refreshed || !authStore.accessToken) {
  398. ElMessage.error('登录已过期,请重新登录');
  399. return;
  400. }
  401. resp = await doFetch(authStore.accessToken);
  402. }
  403. if (!resp.ok) {
  404. const text = await resp.text().catch(() => '');
  405. throw new Error(text || `导出失败,状态码:${resp.status}`);
  406. }
  407. const blob = await resp.blob();
  408. const downloadUrl = window.URL.createObjectURL(blob);
  409. const a = document.createElement('a');
  410. a.href = downloadUrl;
  411. a.download = `平台数据_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
  412. document.body.appendChild(a);
  413. a.click();
  414. a.remove();
  415. window.URL.revokeObjectURL(downloadUrl);
  416. } catch (error: any) {
  417. console.error('导出失败:', error);
  418. ElMessage.error(error?.message || '导出失败');
  419. }
  420. }
  421. // 监听抽屉关闭
  422. watch(drawerVisible, (visible) => {
  423. if (!visible && detailChart) {
  424. detailChart.dispose();
  425. detailChart = null;
  426. }
  427. });
  428. onMounted(() => {
  429. // 默认选择昨天
  430. handleQuickDate('yesterday');
  431. loadData();
  432. });
  433. </script>
  434. <style lang="scss" scoped>
  435. @use '@/styles/variables.scss' as *;
  436. .platform-analytics {
  437. .filter-bar {
  438. display: flex;
  439. align-items: center;
  440. justify-content: space-between;
  441. margin-bottom: 20px;
  442. padding: 16px 20px;
  443. background: #fff;
  444. border-radius: $radius-lg;
  445. box-shadow: $shadow-sm;
  446. .filter-left {
  447. display: flex;
  448. align-items: center;
  449. gap: 12px;
  450. .filter-label {
  451. font-size: 14px;
  452. color: $text-regular;
  453. }
  454. .quick-btns {
  455. display: flex;
  456. gap: 8px;
  457. margin-left: 8px;
  458. }
  459. }
  460. }
  461. .data-table {
  462. background: #fff;
  463. border-radius: $radius-lg;
  464. box-shadow: $shadow-sm;
  465. overflow: hidden;
  466. .platform-cell {
  467. display: flex;
  468. align-items: center;
  469. gap: 10px;
  470. .platform-icon {
  471. width: 24px;
  472. height: 24px;
  473. border-radius: 4px;
  474. }
  475. .platform-name {
  476. font-weight: 500;
  477. color: $text-primary;
  478. }
  479. }
  480. .increase {
  481. color: #10b981;
  482. }
  483. .decrease {
  484. color: #ef4444;
  485. }
  486. .update-time {
  487. font-size: 12px;
  488. color: $text-secondary;
  489. }
  490. }
  491. }
  492. .platform-detail {
  493. .detail-stats {
  494. display: grid;
  495. grid-template-columns: repeat(4, 1fr);
  496. gap: 16px;
  497. margin-bottom: 24px;
  498. .stat-item {
  499. background: #f8fafc;
  500. border-radius: 12px;
  501. padding: 16px;
  502. text-align: center;
  503. .stat-value {
  504. font-size: 24px;
  505. font-weight: 600;
  506. color: $primary-color;
  507. }
  508. .stat-label {
  509. font-size: 13px;
  510. color: $text-secondary;
  511. margin-top: 4px;
  512. }
  513. }
  514. }
  515. .detail-chart {
  516. margin-bottom: 24px;
  517. h4 {
  518. margin: 0 0 16px 0;
  519. font-size: 15px;
  520. color: $text-primary;
  521. }
  522. }
  523. .detail-accounts {
  524. h4 {
  525. margin: 0 0 16px 0;
  526. font-size: 15px;
  527. color: $text-primary;
  528. }
  529. .account-cell {
  530. display: flex;
  531. align-items: center;
  532. gap: 8px;
  533. }
  534. }
  535. }
  536. @media (max-width: 1200px) {
  537. .platform-analytics {
  538. .filter-bar {
  539. flex-direction: column;
  540. align-items: flex-start;
  541. gap: 12px;
  542. .filter-left {
  543. flex-wrap: wrap;
  544. .quick-btns {
  545. width: 100%;
  546. margin-left: 0;
  547. margin-top: 8px;
  548. }
  549. }
  550. }
  551. }
  552. }
  553. </style>