index.vue 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216
  1. <template>
  2. <div class="account-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. <el-select v-model="selectedGroup" placeholder="全部分组" clearable style="width: 120px" @change="handleQuery">
  40. <el-option label="全部分组" value="" />
  41. <el-option
  42. v-for="group in accountGroups"
  43. :key="group.id"
  44. :label="group.name"
  45. :value="group.id"
  46. />
  47. </el-select>
  48. <el-select v-model="selectedPlatform" placeholder="全部平台" clearable style="width: 120px" @change="handleQuery">
  49. <el-option label="全部平台" value="" />
  50. <el-option
  51. v-for="platform in availablePlatforms"
  52. :key="platform.value"
  53. :label="platform.label"
  54. :value="platform.value"
  55. />
  56. </el-select>
  57. </div>
  58. <div class="filter-right">
  59. <el-button @click="handleExport">导出数据</el-button>
  60. </div>
  61. </div>
  62. <!-- 统计卡片 -->
  63. <div class="stats-row">
  64. <div class="stat-card" v-for="(item, index) in summaryStats" :key="index">
  65. <div class="stat-icon">
  66. <el-icon :size="18"><component :is="item.icon" /></el-icon>
  67. </div>
  68. <div class="stat-info">
  69. <div class="stat-label">{{ item.label }}</div>
  70. <div class="stat-value">{{ item.value }}</div>
  71. </div>
  72. </div>
  73. </div>
  74. <!-- 搜索框 -->
  75. <div class="search-bar">
  76. <el-input
  77. v-model="searchKeyword"
  78. placeholder="请输入要搜索的账号"
  79. clearable
  80. style="width: 300px"
  81. >
  82. <template #prefix>
  83. <el-icon><Search /></el-icon>
  84. </template>
  85. </el-input>
  86. </div>
  87. <!-- 数据表格 -->
  88. <div class="data-table">
  89. <el-table :data="filteredAccounts" v-loading="loading" stripe>
  90. <el-table-column label="账号" min-width="150">
  91. <template #default="{ row }">
  92. <div class="account-cell">
  93. <el-avatar :size="36" :src="row.avatarUrl">
  94. {{ row.nickname?.[0] || row.username?.[0] }}
  95. </el-avatar>
  96. <span class="account-name">{{ row.nickname || row.username }}</span>
  97. </div>
  98. </template>
  99. </el-table-column>
  100. <el-table-column label="平台" width="120" align="center">
  101. <template #default="{ row }">
  102. <div class="platform-cell">
  103. <img :src="getPlatformIcon(row.platform)" class="platform-icon" :alt="row.platform" />
  104. <span>{{ getPlatformName(row.platform) }}</span>
  105. </div>
  106. </template>
  107. </el-table-column>
  108. <!-- 收益与推荐量暂未接入,先隐藏
  109. <el-table-column prop="income" label="收益" width="100" align="center">
  110. <template #default="{ row }">
  111. <span>{{ row.income ?? '未支持' }}</span>
  112. </template>
  113. </el-table-column>
  114. <el-table-column prop="recommendCount" label="推荐量" width="100" align="center">
  115. <template #default="{ row }">
  116. <span>{{ row.recommendCount ?? '未支持' }}</span>
  117. </template>
  118. </el-table-column>
  119. -->
  120. <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
  121. <template #default="{ row }">
  122. <span>{{ row.viewsCount ?? 0 }}</span>
  123. </template>
  124. </el-table-column>
  125. <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
  126. <template #default="{ row }">
  127. <span>{{ row.commentsCount ?? 0 }}</span>
  128. </template>
  129. </el-table-column>
  130. <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
  131. <template #default="{ row }">
  132. <span>{{ row.likesCount ?? 0 }}</span>
  133. </template>
  134. </el-table-column>
  135. <el-table-column prop="fansIncrease" label="涨粉量" width="90" align="center">
  136. <template #default="{ row }">
  137. <span :class="{ 'increase': row.fansIncrease > 0, 'decrease': row.fansIncrease < 0 }">
  138. {{ row.fansIncrease ?? 0 }}
  139. </span>
  140. </template>
  141. </el-table-column>
  142. <el-table-column prop="updateTime" label="更新时间" width="120" align="center">
  143. <template #default="{ row }">
  144. <span class="update-time">{{ formatTime(row.updateTime) }}</span>
  145. </template>
  146. </el-table-column>
  147. <el-table-column prop="status" label="状态" width="80" align="center">
  148. <template #default="{ row }">
  149. <span :class="['status-tag', row.status === 'active' ? 'active' : 'inactive']">
  150. {{ row.status === 'active' ? '正常' : '异常' }}
  151. </span>
  152. </template>
  153. </el-table-column>
  154. <el-table-column label="操作" width="80" align="center" fixed="right">
  155. <template #default="{ row }">
  156. <el-button type="primary" link @click="handleDetail(row)">
  157. 详情
  158. </el-button>
  159. </template>
  160. </el-table-column>
  161. </el-table>
  162. </div>
  163. <!-- 账号详情抽屉 -->
  164. <el-drawer v-model="drawerVisible" :title="drawerTitle" size="70%">
  165. <div v-if="selectedAccount" class="account-detail">
  166. <!-- 账号基本信息 -->
  167. <div class="detail-header">
  168. <el-avatar :size="64" :src="selectedAccount.avatarUrl">
  169. {{ selectedAccount.nickname?.[0] }}
  170. </el-avatar>
  171. <div class="header-info">
  172. <h3>{{ selectedAccount.nickname }}</h3>
  173. <div class="platform-info">
  174. <img :src="getPlatformIcon(selectedAccount.platform)" class="platform-icon" />
  175. <span>{{ getPlatformName(selectedAccount.platform) }}</span>
  176. </div>
  177. </div>
  178. </div>
  179. <!-- 顶部日期筛选 -->
  180. <div class="detail-filter-bar">
  181. <span class="filter-label">开始时间</span>
  182. <el-date-picker
  183. v-model="detailStartDate"
  184. type="date"
  185. placeholder="选择日期"
  186. format="YYYY-MM-DD"
  187. value-format="YYYY-MM-DD"
  188. style="width: 140px"
  189. :disabled-date="(date: Date) => detailEndDate ? date > new Date(detailEndDate) : false"
  190. @change="loadAccountDetailData"
  191. />
  192. <span class="filter-label">结束时间</span>
  193. <el-date-picker
  194. v-model="detailEndDate"
  195. type="date"
  196. placeholder="选择日期"
  197. format="YYYY-MM-DD"
  198. value-format="YYYY-MM-DD"
  199. style="width: 140px"
  200. :disabled-date="(date: Date) => detailStartDate ? date < new Date(detailStartDate) : false"
  201. @change="loadAccountDetailData"
  202. />
  203. <div class="quick-btns">
  204. <el-button
  205. v-for="btn in quickDateBtns"
  206. :key="btn.value"
  207. :type="detailActiveQuickBtn === btn.value ? 'primary' : 'default'"
  208. size="small"
  209. @click="handleDetailQuickDate(btn.value)"
  210. >
  211. {{ btn.label }}
  212. </el-button>
  213. </div>
  214. <div class="detail-export-wrap">
  215. <el-button type="primary" plain size="small" :disabled="!detailDailyData.length" @click="exportDetailDailyData">
  216. 导出数据
  217. </el-button>
  218. </div>
  219. </div>
  220. <!-- 详情 Tab -->
  221. <el-tabs v-model="detailActiveTab">
  222. <el-tab-pane label="数据" name="data">
  223. <!-- 汇总统计 -->
  224. <div class="detail-summary-cards">
  225. <div class="stat-item">
  226. <div class="stat-value">{{ detailSummary.viewsCount || 0 }}</div>
  227. <div class="stat-label">播放(阅读)量</div>
  228. </div>
  229. <div class="stat-item">
  230. <div class="stat-value">{{ detailSummary.commentsCount || 0 }}</div>
  231. <div class="stat-label">评论量</div>
  232. </div>
  233. <div class="stat-item">
  234. <div class="stat-value">{{ detailSummary.likesCount || 0 }}</div>
  235. <div class="stat-label">点赞量</div>
  236. </div>
  237. <div class="stat-item">
  238. <div class="stat-value">{{ detailSummary.fansIncrease || 0 }}</div>
  239. <div class="stat-label">涨粉量</div>
  240. </div>
  241. </div>
  242. <!-- 每日数据表格:时间倒序;收益、推荐量暂未接入先注释 -->
  243. <el-table :data="detailDailyData" v-loading="detailLoading" stripe>
  244. <el-table-column prop="date" label="时间" width="120" align="center" />
  245. <!-- 收益与推荐量暂未接入,先隐藏
  246. <el-table-column prop="income" label="收益" width="90" align="center">
  247. <template #default="{ row }">
  248. <span>{{ row.income ?? 0 }}</span>
  249. </template>
  250. </el-table-column>
  251. <el-table-column prop="recommendationCount" label="推荐量" width="90" align="center">
  252. <template #default="{ row }">
  253. <span>{{ row.recommendationCount ?? 0 }}</span>
  254. </template>
  255. </el-table-column>
  256. -->
  257. <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
  258. <template #default="{ row }">
  259. <span>{{ row.viewsCount ?? 0 }}</span>
  260. </template>
  261. </el-table-column>
  262. <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
  263. <template #default="{ row }">
  264. <span>{{ row.commentsCount ?? 0 }}</span>
  265. </template>
  266. </el-table-column>
  267. <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
  268. <template #default="{ row }">
  269. <span>{{ row.likesCount ?? 0 }}</span>
  270. </template>
  271. </el-table-column>
  272. <el-table-column prop="fansIncrease" label="涨粉量" width="90" align="center">
  273. <template #default="{ row }">
  274. <span>{{ row.fansIncrease ?? 0 }}</span>
  275. </template>
  276. </el-table-column>
  277. </el-table>
  278. </el-tab-pane>
  279. <el-tab-pane label="作品" name="works">
  280. <el-table :data="pagedDetailWorks" v-loading="detailLoading" stripe>
  281. <el-table-column label="标题" min-width="260">
  282. <template #default="{ row }">
  283. <div class="work-title-cell">
  284. <div class="work-title-text">{{ row.title }}</div>
  285. </div>
  286. </template>
  287. </el-table-column>
  288. <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
  289. <template #default="{ row }">
  290. <span>{{ row.viewsCount ?? 0 }}</span>
  291. </template>
  292. </el-table-column>
  293. <el-table-column prop="commentsCount" label="评论量" width="90" align="center">
  294. <template #default="{ row }">
  295. <span>{{ row.commentsCount ?? 0 }}</span>
  296. </template>
  297. </el-table-column>
  298. <el-table-column prop="likesCount" label="点赞量" width="90" align="center">
  299. <template #default="{ row }">
  300. <span>{{ row.likesCount ?? 0 }}</span>
  301. </template>
  302. </el-table-column>
  303. <el-table-column prop="sharesCount" label="分享量" width="90" align="center">
  304. <template #default="{ row }">
  305. <span>{{ row.sharesCount ?? 0 }}</span>
  306. </template>
  307. </el-table-column>
  308. <el-table-column prop="collectsCount" label="收藏量" width="90" align="center">
  309. <template #default="{ row }">
  310. <span>{{ row.collectsCount ?? 0 }}</span>
  311. </template>
  312. </el-table-column>
  313. <el-table-column prop="publishTime" label="发布时间" width="160" align="center">
  314. <template #default="{ row }">
  315. <span class="publish-time">{{ formatTime(row.publishTime) }}</span>
  316. </template>
  317. </el-table-column>
  318. </el-table>
  319. <div
  320. class="pagination-wrapper"
  321. v-if="detailWorksTotal > detailWorksPageSize"
  322. >
  323. <el-pagination
  324. v-model:current-page="detailWorksPage"
  325. :page-size="detailWorksPageSize"
  326. :total="detailWorksTotal"
  327. layout="total, prev, pager, next"
  328. small
  329. @current-change="handleDetailWorksPageChange"
  330. />
  331. </div>
  332. </el-tab-pane>
  333. </el-tabs>
  334. </div>
  335. </el-drawer>
  336. </div>
  337. </template>
  338. <script setup lang="ts">
  339. import { ref, computed, onMounted, watch, nextTick } from 'vue';
  340. import { useRoute } from 'vue-router';
  341. import * as echarts from 'echarts';
  342. import { Search, User, View, ChatDotRound, Star, TrendCharts } from '@element-plus/icons-vue';
  343. import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
  344. import type { PlatformType } from '@media-manager/shared';
  345. import { ElMessage } from 'element-plus';
  346. import * as XLSX from 'xlsx';
  347. import dayjs from 'dayjs';
  348. import request from '@/api/request';
  349. import { useTaskQueueStore } from '@/stores/taskQueue';
  350. import iconDefaultUrl from '@/assets/platforms/default.svg?url';
  351. import douyinIconUrl from '@/assets/platforms/douyin.svg?url';
  352. import xhsIconUrl from '@/assets/platforms/xiaohongshu.svg?url';
  353. import bilibiliIconUrl from '@/assets/platforms/bilibili.svg?url';
  354. import kuaishouIconUrl from '@/assets/platforms/kuaishou.svg?url';
  355. import weixinVideoIconUrl from '@/assets/platforms/weixin_video.svg?url';
  356. import baijiahaoIconUrl from '@/assets/platforms/baijiahao.svg?url';
  357. const route = useRoute();
  358. const taskStore = useTaskQueueStore();
  359. const loading = ref(false);
  360. const chartLoading = ref(false);
  361. // 日期筛选
  362. const startDate = ref(dayjs().format('YYYY-MM-DD'));
  363. const endDate = ref(dayjs().format('YYYY-MM-DD'));
  364. const activeQuickBtn = ref('yesterday');
  365. // 快捷日期按钮
  366. const quickDateBtns = [
  367. { label: '昨天', value: 'yesterday' },
  368. { label: '前天', value: 'beforeYesterday' },
  369. { label: '近三天', value: 'last3days' },
  370. { label: '近七天', value: 'last7days' },
  371. { label: '近一个月', value: 'lastMonth' },
  372. ];
  373. // 分组和平台筛选
  374. const selectedGroup = ref<number | ''>('');
  375. const selectedPlatform = ref<PlatformType | ''>('');
  376. const searchKeyword = ref('');
  377. // 分组列表
  378. interface AccountGroup {
  379. id: number;
  380. name: string;
  381. }
  382. const accountGroups = ref<AccountGroup[]>([]);
  383. // 可用平台(统一配置:小红书、抖音、视频号、百家号)
  384. const availablePlatforms = computed(() => {
  385. return AVAILABLE_PLATFORM_TYPES.map(key => ({
  386. value: key,
  387. label: PLATFORMS[key].name,
  388. }));
  389. });
  390. // 平台图标映射(与平台数据页保持一致,使用本地 SVG,避免外链被拦截)
  391. const iconDefault = iconDefaultUrl;
  392. const platformIcons: Record<string, string> = {
  393. douyin: douyinIconUrl,
  394. xiaohongshu: xhsIconUrl,
  395. bilibili: bilibiliIconUrl,
  396. kuaishou: kuaishouIconUrl,
  397. weixin: weixinVideoIconUrl,
  398. weixin_video: weixinVideoIconUrl,
  399. baijiahao: baijiahaoIconUrl,
  400. };
  401. // 汇总统计
  402. const summaryData = ref({
  403. totalAccounts: 0,
  404. income: 0,
  405. recommendCount: 0,
  406. viewsCount: 0,
  407. commentsCount: 0,
  408. likesCount: 0,
  409. fansIncrease: 0,
  410. });
  411. // 统计卡片数据
  412. const summaryStats = computed(() => [
  413. { label: '账号总数', value: summaryData.value.totalAccounts, icon: User },
  414. // 后端暂未支持收益统计,先隐藏
  415. // { label: '收益', value: summaryData.value.income, icon: Coin },
  416. // 后端暂未支持推荐量统计,先隐藏
  417. // { label: '推荐量', value: summaryData.value.recommendCount, icon: Pointer },
  418. { label: '播放(阅读)量', value: summaryData.value.viewsCount, icon: View },
  419. { label: '评论量', value: summaryData.value.commentsCount, icon: ChatDotRound },
  420. { label: '点赞量', value: summaryData.value.likesCount, icon: Star },
  421. { label: '涨粉量', value: summaryData.value.fansIncrease, icon: TrendCharts },
  422. ]);
  423. // 账号数据
  424. interface AccountData {
  425. id: number;
  426. nickname: string;
  427. username: string;
  428. avatarUrl: string;
  429. platform: PlatformType;
  430. groupId?: number;
  431. fansCount: number;
  432. income: number | null;
  433. recommendCount: number | null;
  434. viewsCount: number;
  435. commentsCount: number;
  436. likesCount: number;
  437. fansIncrease: number;
  438. updateTime: string;
  439. status: string;
  440. }
  441. const accounts = ref<AccountData[]>([]);
  442. // 过滤后的账号列表
  443. const filteredAccounts = computed(() => {
  444. let result = accounts.value;
  445. if (selectedGroup.value) {
  446. result = result.filter(a => a.groupId === selectedGroup.value);
  447. }
  448. if (selectedPlatform.value) {
  449. result = result.filter(a => a.platform === selectedPlatform.value);
  450. }
  451. if (searchKeyword.value) {
  452. const keyword = searchKeyword.value.toLowerCase();
  453. result = result.filter(a =>
  454. a.nickname?.toLowerCase().includes(keyword) ||
  455. a.username?.toLowerCase().includes(keyword)
  456. );
  457. }
  458. return result;
  459. });
  460. // 抽屉相关
  461. const drawerVisible = ref(false);
  462. const selectedAccount = ref<AccountData | null>(null);
  463. const accountChartRef = ref<HTMLElement>();
  464. let accountChart: echarts.ECharts | null = null;
  465. const drawerTitle = computed(() => {
  466. if (!selectedAccount.value) return '账号详情';
  467. return `${selectedAccount.value.nickname} - 数据详情`;
  468. });
  469. // 详情 Tab 与数据
  470. const detailActiveTab = ref<'data' | 'works'>('data');
  471. const detailLoading = ref(false);
  472. const detailStartDate = ref(startDate.value);
  473. const detailEndDate = ref(endDate.value);
  474. const detailActiveQuickBtn = ref(activeQuickBtn.value);
  475. const detailSummary = ref({
  476. income: 0,
  477. recommendationCount: 0,
  478. viewsCount: 0,
  479. commentsCount: 0,
  480. likesCount: 0,
  481. fansIncrease: 0,
  482. });
  483. const detailDailyData = ref<Array<{
  484. date: string;
  485. income: number;
  486. recommendationCount: number;
  487. viewsCount: number;
  488. commentsCount: number;
  489. likesCount: number;
  490. fansIncrease: number;
  491. }>>([]);
  492. const detailWorks = ref<Array<{
  493. id: number;
  494. title: string;
  495. coverUrl: string;
  496. platform: string;
  497. publishTime: string | null;
  498. recommendCount: number;
  499. viewsCount: number;
  500. commentsCount: number;
  501. sharesCount: number;
  502. collectsCount: number;
  503. likesCount: number;
  504. }>>([]);
  505. // 账号详情 - 作品列表分页
  506. const detailWorksPage = ref(1);
  507. const detailWorksPageSize = ref(15);
  508. const pagedDetailWorks = computed(() => {
  509. const start = (detailWorksPage.value - 1) * detailWorksPageSize.value;
  510. const end = start + detailWorksPageSize.value;
  511. return detailWorks.value.slice(start, end);
  512. });
  513. const detailWorksTotal = computed(() => detailWorks.value.length);
  514. function handleDetailWorksPageChange(page: number) {
  515. detailWorksPage.value = page;
  516. }
  517. function getPlatformName(platform: PlatformType) {
  518. return PLATFORMS[platform]?.name || platform;
  519. }
  520. function getPlatformIcon(platform: PlatformType) {
  521. return platformIcons[platform] || iconDefault;
  522. }
  523. function formatNumber(num: number) {
  524. if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
  525. return num.toString();
  526. }
  527. function formatTime(time: string) {
  528. if (!time) return '-';
  529. const d = dayjs(time);
  530. if (!d.isValid()) return time;
  531. const nowYear = dayjs().year();
  532. return d.year() === nowYear ? d.format('MM-DD HH:mm') : d.format('YYYY-MM-DD HH:mm');
  533. }
  534. // 快捷日期选择
  535. function handleQuickDate(type: string) {
  536. activeQuickBtn.value = type;
  537. const today = dayjs();
  538. switch (type) {
  539. case 'yesterday':
  540. startDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  541. endDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  542. break;
  543. case 'beforeYesterday':
  544. startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  545. endDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  546. break;
  547. case 'last3days':
  548. // 含今天,共 3 天:今天/昨天/前天
  549. startDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  550. endDate.value = today.format('YYYY-MM-DD');
  551. break;
  552. case 'last7days':
  553. // 含今天,共 7 天
  554. startDate.value = today.subtract(6, 'day').format('YYYY-MM-DD');
  555. endDate.value = today.format('YYYY-MM-DD');
  556. break;
  557. case 'lastMonth':
  558. // 含今天,共 30 天
  559. startDate.value = today.subtract(29, 'day').format('YYYY-MM-DD');
  560. endDate.value = today.format('YYYY-MM-DD');
  561. break;
  562. }
  563. // 选择快捷日期后,直接刷新数据列表,无需再点击“查询”
  564. loadData();
  565. }
  566. // 查询
  567. function handleQuery() {
  568. loadData();
  569. }
  570. // 加载分组列表
  571. async function loadGroups() {
  572. try {
  573. const res = await request.get('/api/accounts/groups');
  574. // 新接口:request 已经解包,res 就是分组数组
  575. if (Array.isArray(res)) {
  576. accountGroups.value = res as AccountGroup[];
  577. } else if ((res as any)?.data?.data) {
  578. // 兼容旧返回格式 { data: { success, data } }
  579. accountGroups.value = (res as any).data.data || [];
  580. }
  581. } catch (error) {
  582. console.error('加载分组失败:', error);
  583. }
  584. }
  585. // 加载数据
  586. async function loadData() {
  587. loading.value = true;
  588. try {
  589. const params: Record<string, any> = {
  590. startDate: startDate.value,
  591. endDate: endDate.value,
  592. };
  593. if (selectedPlatform.value) {
  594. params.platform = selectedPlatform.value;
  595. }
  596. if (selectedGroup.value) {
  597. params.groupId = selectedGroup.value;
  598. }
  599. const data = await request.get('/api/work-day-statistics/accounts', {
  600. params,
  601. });
  602. if (data) {
  603. accounts.value = (data.accounts || []) as AccountData[];
  604. if (data.summary) {
  605. summaryData.value = {
  606. totalAccounts: data.summary.totalAccounts || 0,
  607. income: 0,
  608. recommendCount: null,
  609. viewsCount: data.summary.viewsCount || 0,
  610. commentsCount: data.summary.commentsCount || 0,
  611. likesCount: data.summary.likesCount || 0,
  612. fansIncrease: data.summary.fansIncrease || 0,
  613. };
  614. }
  615. }
  616. } catch (error) {
  617. console.error('加载账号数据失败:', error);
  618. ElMessage.error('加载账号数据失败,请稍后重试');
  619. } finally {
  620. loading.value = false;
  621. }
  622. }
  623. // 查看详情
  624. async function handleDetail(row: AccountData) {
  625. selectedAccount.value = row;
  626. drawerVisible.value = true;
  627. // 初始化详情的时间范围与快捷按钮,与主筛选保持一致
  628. detailStartDate.value = startDate.value;
  629. detailEndDate.value = endDate.value;
  630. detailActiveQuickBtn.value = activeQuickBtn.value;
  631. detailActiveTab.value = 'data';
  632. await nextTick();
  633. // 加载账号详情(数据 + 作品)
  634. loadAccountDetailData();
  635. // 同时加载趋势图
  636. loadAccountTrend(row.id);
  637. }
  638. // 加载账号趋势数据
  639. async function loadAccountTrend(accountId: number) {
  640. if (!accountChartRef.value) return;
  641. chartLoading.value = true;
  642. try {
  643. const trend = await request.get('/api/analytics/trend', {
  644. params: {
  645. startDate: startDate.value,
  646. endDate: endDate.value,
  647. accountId,
  648. },
  649. });
  650. if (trend) {
  651. const dates = (trend.views || []).map((p: { date: string; value: number }) =>
  652. (p.date || '').slice(5)
  653. );
  654. const fans = (trend.fans || []).map((p: { date: string; value: number }) => p.value || 0);
  655. const views = (trend.views || []).map((p: { date: string; value: number }) => p.value || 0);
  656. const likes = (trend.likes || []).map((p: { date: string; value: number }) => p.value || 0);
  657. updateAccountChart({ dates, fans, views, likes });
  658. }
  659. } catch (error) {
  660. console.error('加载账号趋势失败:', error);
  661. } finally {
  662. chartLoading.value = false;
  663. }
  664. }
  665. // 账号详情:快捷日期选择
  666. function handleDetailQuickDate(type: string) {
  667. detailActiveQuickBtn.value = type;
  668. const today = dayjs();
  669. switch (type) {
  670. case 'yesterday':
  671. detailStartDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  672. detailEndDate.value = today.subtract(1, 'day').format('YYYY-MM-DD');
  673. break;
  674. case 'beforeYesterday':
  675. detailStartDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  676. detailEndDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  677. break;
  678. case 'last3days':
  679. // 含今天,共 3 天:今天/昨天/前天
  680. detailStartDate.value = today.subtract(2, 'day').format('YYYY-MM-DD');
  681. detailEndDate.value = today.format('YYYY-MM-DD');
  682. break;
  683. case 'last7days':
  684. // 含今天,共 7 天
  685. detailStartDate.value = today.subtract(6, 'day').format('YYYY-MM-DD');
  686. detailEndDate.value = today.format('YYYY-MM-DD');
  687. break;
  688. case 'lastMonth':
  689. // 含今天,共 30 天
  690. detailStartDate.value = today.subtract(29, 'day').format('YYYY-MM-DD');
  691. detailEndDate.value = today.format('YYYY-MM-DD');
  692. break;
  693. }
  694. loadAccountDetailData();
  695. }
  696. // 导出当前时间范围内的每日数据为 xlsx(表头与列表一致)
  697. function exportDetailDailyData() {
  698. if (!detailDailyData.value.length) return;
  699. const headers = ['时间', '播放(阅读)量', '评论量', '点赞量', '涨粉量'];
  700. const rows = detailDailyData.value.map((row) => [
  701. row.date ?? '',
  702. row.viewsCount ?? 0,
  703. row.commentsCount ?? 0,
  704. row.likesCount ?? 0,
  705. row.fansIncrease ?? 0,
  706. ]);
  707. const data = [headers, ...rows];
  708. const ws = XLSX.utils.aoa_to_sheet(data);
  709. const wb = XLSX.utils.book_new();
  710. XLSX.utils.book_append_sheet(wb, ws, '数据详情');
  711. const arrayBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
  712. const blob = new Blob([arrayBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
  713. const name = (selectedAccount.value?.nickname || '账号').replace(/[/\\?*:"|]/g, '_');
  714. const url = URL.createObjectURL(blob);
  715. const a = document.createElement('a');
  716. a.href = url;
  717. a.download = `${name}_数据详情_${detailStartDate.value}_${detailEndDate.value}.xlsx`;
  718. a.click();
  719. URL.revokeObjectURL(url);
  720. ElMessage.success('导出成功');
  721. }
  722. // 加载账号详情(汇总 + 每日 + 作品)
  723. async function loadAccountDetailData() {
  724. if (!selectedAccount.value) return;
  725. detailLoading.value = true;
  726. try {
  727. const data = await request.get('/api/work-day-statistics/account-detail', {
  728. params: {
  729. accountId: selectedAccount.value.id,
  730. startDate: detailStartDate.value,
  731. endDate: detailEndDate.value,
  732. },
  733. });
  734. if (data) {
  735. detailSummary.value = {
  736. income: data.summary?.income ?? 0,
  737. recommendationCount: data.summary?.recommendationCount ?? 0,
  738. viewsCount: data.summary?.viewsCount ?? 0,
  739. commentsCount: data.summary?.commentsCount ?? 0,
  740. likesCount: data.summary?.likesCount ?? 0,
  741. fansIncrease: data.summary?.fansIncrease ?? 0,
  742. };
  743. const rawDaily = Array.isArray(data.dailyData) ? data.dailyData : [];
  744. detailDailyData.value = [...rawDaily].sort((a, b) => (b.date || '').localeCompare(a.date || ''));
  745. detailWorks.value = Array.isArray(data.works) ? data.works : [];
  746. // 重新加载详情数据时,重置作品分页
  747. detailWorksPage.value = 1;
  748. }
  749. } catch (error) {
  750. console.error('加载账号详情失败:', error);
  751. ElMessage.error('加载账号详情失败,请稍后重试');
  752. } finally {
  753. detailLoading.value = false;
  754. }
  755. }
  756. // 更新账号趋势图
  757. function updateAccountChart(trendData: { dates: string[]; fans: number[]; views: number[]; likes: number[] }) {
  758. if (!accountChartRef.value) return;
  759. if (!accountChart) {
  760. accountChart = echarts.init(accountChartRef.value);
  761. }
  762. accountChart.setOption({
  763. tooltip: { trigger: 'axis' },
  764. legend: { data: ['粉丝', '播放', '点赞'], bottom: 0 },
  765. grid: { left: '3%', right: '4%', bottom: '15%', top: '10%', containLabel: true },
  766. xAxis: {
  767. type: 'category',
  768. data: trendData.dates,
  769. axisLabel: { color: '#6b7280' },
  770. },
  771. yAxis: {
  772. type: 'value',
  773. axisLabel: {
  774. color: '#6b7280',
  775. formatter: (value: number) => value >= 10000 ? (value / 10000).toFixed(1) + '万' : value.toString(),
  776. },
  777. },
  778. series: [
  779. { name: '粉丝', type: 'line', data: trendData.fans, smooth: true },
  780. { name: '播放', type: 'line', data: trendData.views, smooth: true },
  781. { name: '点赞', type: 'line', data: trendData.likes, smooth: true },
  782. ],
  783. });
  784. }
  785. // 导出数据(客户端生成 xlsx,无需后端支持)
  786. async function handleExport() {
  787. if (!filteredAccounts.value.length) {
  788. ElMessage.warning('暂无数据可导出');
  789. return;
  790. }
  791. try {
  792. const headers = ['账号', '平台', '播放(阅读)量', '评论量', '点赞量', '涨粉量', '更新时间', '状态'];
  793. const rows = filteredAccounts.value.map((row) => [
  794. row.nickname || row.username || '',
  795. getPlatformName(row.platform),
  796. row.viewsCount ?? 0,
  797. row.commentsCount ?? 0,
  798. row.likesCount ?? 0,
  799. row.fansIncrease ?? 0,
  800. formatTime(row.updateTime),
  801. row.status === 'active' ? '正常' : '异常',
  802. ]);
  803. const data = [headers, ...rows];
  804. const ws = XLSX.utils.aoa_to_sheet(data);
  805. const wb = XLSX.utils.book_new();
  806. XLSX.utils.book_append_sheet(wb, ws, '账号数据');
  807. const arrayBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
  808. const blob = new Blob([arrayBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
  809. const url = URL.createObjectURL(blob);
  810. const a = document.createElement('a');
  811. a.href = url;
  812. a.download = `账号数据_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
  813. a.click();
  814. URL.revokeObjectURL(url);
  815. ElMessage.success('导出成功');
  816. } catch (error) {
  817. console.error('导出失败:', error);
  818. ElMessage.error('导出失败,请稍后重试');
  819. }
  820. }
  821. // 监听抽屉关闭
  822. watch(drawerVisible, (visible) => {
  823. if (!visible && accountChart) {
  824. accountChart.dispose();
  825. accountChart = null;
  826. }
  827. });
  828. // Bug #6070: 监听账号数据变更事件,新增账号后自动刷新数据
  829. watch(() => taskStore.accountRefreshTrigger, () => {
  830. console.log('[Analytics/Account] Account data changed, reloading...');
  831. loadData();
  832. });
  833. onMounted(() => {
  834. // 如果从平台详情页跳转过来,优先使用路由上的时间范围
  835. if (route.query.startDate) {
  836. startDate.value = String(route.query.startDate);
  837. }
  838. if (route.query.endDate) {
  839. endDate.value = String(route.query.endDate);
  840. }
  841. // 默认选择昨天(如果路由未指定时间)
  842. if (!route.query.startDate && !route.query.endDate) {
  843. handleQuickDate('yesterday');
  844. }
  845. loadGroups();
  846. // 加载列表后,如有 accountId 参数,则自动打开对应账号详情
  847. loadData().then(() => {
  848. const accountIdParam = route.query.accountId;
  849. if (accountIdParam) {
  850. const targetId = Number(accountIdParam);
  851. const target = accounts.value.find(a => a.id === targetId);
  852. if (target) {
  853. handleDetail(target);
  854. }
  855. }
  856. });
  857. });
  858. </script>
  859. <style lang="scss" scoped>
  860. @use '@/styles/variables.scss' as *;
  861. .account-analytics {
  862. .filter-bar {
  863. display: flex;
  864. align-items: center;
  865. justify-content: space-between;
  866. margin-bottom: 20px;
  867. padding: 16px 20px;
  868. background: #fff;
  869. border-radius: $radius-lg;
  870. box-shadow: $shadow-sm;
  871. .filter-left {
  872. display: flex;
  873. align-items: center;
  874. gap: 12px;
  875. .filter-label {
  876. font-size: 14px;
  877. color: $text-regular;
  878. }
  879. .quick-btns {
  880. display: flex;
  881. gap: 8px;
  882. }
  883. }
  884. }
  885. .stats-row {
  886. display: grid;
  887. grid-template-columns: repeat(7, 1fr);
  888. gap: 0;
  889. margin-bottom: 20px;
  890. background: #fff;
  891. border-radius: $radius-lg;
  892. box-shadow: $shadow-sm;
  893. overflow: hidden;
  894. .stat-card {
  895. padding: 20px 16px;
  896. display: flex;
  897. align-items: center;
  898. gap: 12px;
  899. border-right: 1px solid #f0f0f0;
  900. &:last-child {
  901. border-right: none;
  902. }
  903. .stat-icon {
  904. width: 36px;
  905. height: 36px;
  906. background: $primary-color-light;
  907. border-radius: 8px;
  908. display: flex;
  909. align-items: center;
  910. justify-content: center;
  911. color: $primary-color;
  912. flex-shrink: 0;
  913. }
  914. .stat-info {
  915. .stat-label {
  916. font-size: 12px;
  917. color: $text-secondary;
  918. margin-bottom: 4px;
  919. white-space: nowrap;
  920. }
  921. .stat-value {
  922. font-size: 20px;
  923. font-weight: 600;
  924. color: $text-primary;
  925. }
  926. }
  927. }
  928. }
  929. .search-bar {
  930. margin-bottom: 16px;
  931. }
  932. .data-table {
  933. background: #fff;
  934. border-radius: $radius-lg;
  935. box-shadow: $shadow-sm;
  936. overflow: hidden;
  937. .account-cell {
  938. display: flex;
  939. align-items: center;
  940. gap: 10px;
  941. .account-name {
  942. font-weight: 500;
  943. color: $text-primary;
  944. }
  945. }
  946. .platform-cell {
  947. display: flex;
  948. align-items: center;
  949. justify-content: center;
  950. gap: 6px;
  951. .platform-icon {
  952. width: 20px;
  953. height: 20px;
  954. border-radius: 4px;
  955. }
  956. }
  957. .increase {
  958. color: #10b981;
  959. }
  960. .decrease {
  961. color: #ef4444;
  962. }
  963. .update-time {
  964. font-size: 12px;
  965. color: $text-secondary;
  966. }
  967. .status-tag {
  968. font-size: 12px;
  969. padding: 2px 8px;
  970. border-radius: 4px;
  971. &.active {
  972. color: #10b981;
  973. background: rgba(16, 185, 129, 0.1);
  974. }
  975. &.inactive {
  976. color: #ef4444;
  977. background: rgba(239, 68, 68, 0.1);
  978. }
  979. }
  980. }
  981. }
  982. .account-detail {
  983. .detail-header {
  984. display: flex;
  985. align-items: center;
  986. gap: 16px;
  987. margin-bottom: 24px;
  988. .header-info {
  989. h3 {
  990. margin: 0 0 8px 0;
  991. font-size: 18px;
  992. }
  993. .platform-info {
  994. display: flex;
  995. align-items: center;
  996. gap: 6px;
  997. font-size: 14px;
  998. color: $text-secondary;
  999. .platform-icon {
  1000. width: 18px;
  1001. height: 18px;
  1002. border-radius: 4px;
  1003. }
  1004. }
  1005. }
  1006. }
  1007. .detail-filter-bar {
  1008. display: flex;
  1009. align-items: center;
  1010. gap: 12px;
  1011. margin: 16px 0 12px 0;
  1012. .filter-label {
  1013. font-size: 14px;
  1014. color: $text-regular;
  1015. }
  1016. .quick-btns {
  1017. display: flex;
  1018. gap: 8px;
  1019. margin-left: 8px;
  1020. }
  1021. }
  1022. .detail-summary-cards {
  1023. display: grid;
  1024. grid-template-columns: repeat(4, 1fr);
  1025. gap: 16px;
  1026. margin-bottom: 16px;
  1027. .stat-item {
  1028. background: #f8fafc;
  1029. border-radius: 12px;
  1030. padding: 16px;
  1031. text-align: center;
  1032. .stat-value {
  1033. font-size: 20px;
  1034. font-weight: 600;
  1035. color: $primary-color;
  1036. }
  1037. .stat-label {
  1038. font-size: 13px;
  1039. color: $text-secondary;
  1040. margin-top: 4px;
  1041. }
  1042. }
  1043. }
  1044. .detail-export-wrap {
  1045. margin-left: 12px;
  1046. }
  1047. .work-title-cell {
  1048. .work-title-text {
  1049. font-weight: 500;
  1050. color: $text-primary;
  1051. }
  1052. }
  1053. .pagination-wrapper {
  1054. padding-top: 12px;
  1055. display: flex;
  1056. justify-content: flex-end;
  1057. }
  1058. }
  1059. @media (max-width: 1400px) {
  1060. .account-analytics {
  1061. .stats-row {
  1062. grid-template-columns: repeat(4, 1fr);
  1063. .stat-card {
  1064. &:nth-child(4) {
  1065. border-right: none;
  1066. }
  1067. &:nth-child(n+5) {
  1068. border-top: 1px solid #f0f0f0;
  1069. }
  1070. }
  1071. }
  1072. }
  1073. }
  1074. @media (max-width: 1200px) {
  1075. .account-analytics {
  1076. .filter-bar {
  1077. flex-direction: column;
  1078. align-items: flex-start;
  1079. gap: 12px;
  1080. .filter-left {
  1081. flex-wrap: wrap;
  1082. }
  1083. }
  1084. .stats-row {
  1085. grid-template-columns: repeat(3, 1fr);
  1086. .stat-card {
  1087. &:nth-child(3n) {
  1088. border-right: none;
  1089. }
  1090. &:nth-child(n+4) {
  1091. border-top: 1px solid #f0f0f0;
  1092. }
  1093. }
  1094. }
  1095. }
  1096. }
  1097. </style>