|
|
@@ -137,8 +137,10 @@
|
|
|
<div class="title-cell">
|
|
|
<div class="work-title">{{ row.title }}</div>
|
|
|
<div class="work-stats">
|
|
|
+ <!-- 推荐暂不展示
|
|
|
<span class="stat-item">推荐 <em>{{ row.recommendCount ?? '--' }}</em></span>
|
|
|
- <span class="stat-item">阅读 <em>{{ row.viewsCount ?? 0 }}</em></span>
|
|
|
+ -->
|
|
|
+ <span class="stat-item">播放 <em>{{ row.viewsCount ?? 0 }}</em></span>
|
|
|
<span class="stat-item">评论 <em>{{ row.commentsCount ?? 0 }}</em></span>
|
|
|
<span class="stat-item">分享 <em>{{ row.sharesCount ?? 0 }}</em></span>
|
|
|
<span class="stat-item">收藏 <em>{{ row.collectsCount ?? 0 }}</em></span>
|
|
|
@@ -159,15 +161,13 @@
|
|
|
<span class="publish-time">{{ formatTime(row.publishTime) }}</span>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
- <!-- 操作列暂时注释
|
|
|
- <el-table-column label="操作" width="80" align="center" fixed="right">
|
|
|
+ <el-table-column label="操作" width="100" align="center" fixed="right">
|
|
|
<template #default="{ row }">
|
|
|
<el-button type="primary" link @click="handleView(row)">
|
|
|
查看
|
|
|
</el-button>
|
|
|
</template>
|
|
|
</el-table-column>
|
|
|
- -->
|
|
|
</el-table>
|
|
|
|
|
|
<!-- 分页 -->
|
|
|
@@ -182,76 +182,171 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- <!-- 作品详情抽屉 -->
|
|
|
- <el-drawer v-model="drawerVisible" title="作品详情" size="50%">
|
|
|
+ <!-- 作品详情弹窗 -->
|
|
|
+ <el-dialog
|
|
|
+ v-model="drawerVisible"
|
|
|
+ title="作品详情"
|
|
|
+ width="80%"
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ destroy-on-close
|
|
|
+ class="work-detail-dialog"
|
|
|
+ >
|
|
|
<div v-if="selectedWork" class="work-detail">
|
|
|
- <!-- 作品基本信息 -->
|
|
|
+ <!-- 标题和发布时间 -->
|
|
|
<div class="detail-header">
|
|
|
- <el-image
|
|
|
- :src="selectedWork.coverUrl"
|
|
|
- class="work-cover"
|
|
|
- fit="cover"
|
|
|
- >
|
|
|
- <template #error>
|
|
|
- <div class="cover-placeholder">
|
|
|
- <el-icon :size="32"><Picture /></el-icon>
|
|
|
+ <h3 class="work-title">{{ selectedWork.title }}</h3>
|
|
|
+ <div class="publish-time">{{ formatTime(selectedWork.publishTime) }}</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 标签页 -->
|
|
|
+ <el-tabs v-model="activeTab" class="detail-tabs">
|
|
|
+ <!-- 核心数据标签页 -->
|
|
|
+ <el-tab-pane label="核心数据" name="core">
|
|
|
+ <div class="core-data-content">
|
|
|
+ <!-- 流量数据卡片 -->
|
|
|
+ <div class="traffic-data">
|
|
|
+ <div class="section-title-row">
|
|
|
+ <h4 class="section-title">流量数据</h4>
|
|
|
+ </div>
|
|
|
+ <div class="data-cards">
|
|
|
+ <!-- 小红书:指标更多 + 支持点选联动趋势图 -->
|
|
|
+ <template v-if="selectedWork.platform === 'xiaohongshu'">
|
|
|
+ <div
|
|
|
+ v-for="item in xhsMetricCards"
|
|
|
+ :key="item.key"
|
|
|
+ class="data-card"
|
|
|
+ :class="{ highlight: activeTrendMetric === item.key }"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric(item.key)"
|
|
|
+ @keyup.enter="setTrendMetric(item.key)"
|
|
|
+ >
|
|
|
+ <div class="card-label">{{ item.label }}</div>
|
|
|
+ <div class="card-value">{{ item.value }}</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 抖音:按小红书样式展示可用字段 -->
|
|
|
+ <template v-else-if="selectedWork.platform === 'douyin'">
|
|
|
+ <div
|
|
|
+ v-for="item in douyinMetricCards"
|
|
|
+ :key="item.label"
|
|
|
+ class="data-card"
|
|
|
+ :class="{ highlight: item.key && activeTrendMetric === item.key }"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="item.key && setTrendMetric(item.key)"
|
|
|
+ @keyup.enter="item.key && setTrendMetric(item.key)"
|
|
|
+ >
|
|
|
+ <div class="card-label">{{ item.label }}</div>
|
|
|
+ <div class="card-value">{{ item.value }}</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+
|
|
|
+ <!-- 其他平台:保持原口径 -->
|
|
|
+ <template v-else>
|
|
|
+ <div
|
|
|
+ class="data-card highlight"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric('playCount')"
|
|
|
+ @keyup.enter="setTrendMetric('playCount')"
|
|
|
+ >
|
|
|
+ <div class="card-label">播放量</div>
|
|
|
+ <div class="card-value">{{ formatNumber(workDetailData.playCount || 0) }}</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="data-card"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric('totalWatchDuration')"
|
|
|
+ @keyup.enter="setTrendMetric('totalWatchDuration')"
|
|
|
+ >
|
|
|
+ <div class="card-label">播放总时长</div>
|
|
|
+ <div class="card-value">{{ workDetailData.totalWatchDuration || '0秒' }}</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="data-card"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric('likeCount')"
|
|
|
+ @keyup.enter="setTrendMetric('likeCount')"
|
|
|
+ >
|
|
|
+ <div class="card-label">点赞量</div>
|
|
|
+ <div class="card-value">{{ formatNumber(workDetailData.likeCount || 0) }}</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="data-card"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric('commentCount')"
|
|
|
+ @keyup.enter="setTrendMetric('commentCount')"
|
|
|
+ >
|
|
|
+ <div class="card-label">评论量</div>
|
|
|
+ <div class="card-value">{{ formatNumber(workDetailData.commentCount || 0) }}</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="data-card"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric('collectCount')"
|
|
|
+ @keyup.enter="setTrendMetric('collectCount')"
|
|
|
+ >
|
|
|
+ <div class="card-label">收藏量</div>
|
|
|
+ <div class="card-value">{{ formatNumber(workDetailData.collectCount || 0) }}</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="data-card"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric('shareCount')"
|
|
|
+ @keyup.enter="setTrendMetric('shareCount')"
|
|
|
+ >
|
|
|
+ <div class="card-label">分享量</div>
|
|
|
+ <div class="card-value">{{ formatNumber(workDetailData.shareCount || 0) }}</div>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ class="data-card"
|
|
|
+ role="button"
|
|
|
+ tabindex="0"
|
|
|
+ @click="setTrendMetric('fansIncrease')"
|
|
|
+ @keyup.enter="setTrendMetric('fansIncrease')"
|
|
|
+ >
|
|
|
+ <div class="card-label">涨粉量</div>
|
|
|
+ <div class="card-value">{{ formatNumber(workDetailData.fansIncrease || 0) }}</div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 播放量趋势 -->
|
|
|
+ <div class="trend-section">
|
|
|
+ <h4 class="section-title">{{ trendTitle }}</h4>
|
|
|
+ <div ref="playTrendChartRef" style="height: 300px" v-loading="detailLoading"></div>
|
|
|
</div>
|
|
|
- </template>
|
|
|
- </el-image>
|
|
|
- <div class="header-info">
|
|
|
- <h3>{{ selectedWork.title }}</h3>
|
|
|
- <div class="meta-info">
|
|
|
- <el-tag size="small">{{ getPlatformName(selectedWork.platform) }}</el-tag>
|
|
|
- <span class="publish-time">发布于 {{ formatTime(selectedWork.publishTime) }}</span>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 数据统计 -->
|
|
|
- <div class="detail-stats">
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-value">{{ selectedWork.viewsCount || 0 }}</div>
|
|
|
- <div class="stat-label">阅读</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-value">{{ selectedWork.likesCount || 0 }}</div>
|
|
|
- <div class="stat-label">点赞</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-value">{{ selectedWork.commentsCount || 0 }}</div>
|
|
|
- <div class="stat-label">评论</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-value">{{ selectedWork.collectsCount || 0 }}</div>
|
|
|
- <div class="stat-label">收藏</div>
|
|
|
- </div>
|
|
|
- <div class="stat-item">
|
|
|
- <div class="stat-value">{{ selectedWork.sharesCount || 0 }}</div>
|
|
|
- <div class="stat-label">分享</div>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- <!-- 作品内容 -->
|
|
|
- <div class="detail-content" v-if="selectedWork.content">
|
|
|
- <h4>作品内容</h4>
|
|
|
- <div class="content-text">{{ selectedWork.content }}</div>
|
|
|
- </div>
|
|
|
+ </el-tab-pane>
|
|
|
+
|
|
|
+ </el-tabs>
|
|
|
</div>
|
|
|
- </el-drawer>
|
|
|
+ </el-dialog>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { ref, computed, onMounted } from 'vue';
|
|
|
+import { ref, computed, onMounted, watch, nextTick } from 'vue';
|
|
|
import { Search, Picture, Document, View, ChatDotRound, Share, Star, Pointer } from '@element-plus/icons-vue';
|
|
|
import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } 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 * as echarts from 'echarts';
|
|
|
|
|
|
const authStore = useAuthStore();
|
|
|
+const serverStore = useServerStore();
|
|
|
const loading = ref(false);
|
|
|
|
|
|
// 日期筛选
|
|
|
@@ -311,7 +406,7 @@ const summaryData = ref({
|
|
|
// 统计卡片数据
|
|
|
const summaryStats = computed(() => [
|
|
|
{ label: '作品总数', value: summaryData.value.totalWorks, icon: Document },
|
|
|
- { label: '推荐量', value: summaryData.value.recommendCount, icon: Pointer },
|
|
|
+ // { label: '推荐量', value: summaryData.value.recommendCount, icon: Pointer },
|
|
|
{ label: '播放(阅读)量', value: summaryData.value.viewsCount, icon: View },
|
|
|
{ label: '评论量', value: summaryData.value.commentsCount, icon: ChatDotRound },
|
|
|
{ label: '分享量', value: summaryData.value.sharesCount, icon: Share },
|
|
|
@@ -341,9 +436,117 @@ interface WorkData {
|
|
|
|
|
|
const workList = ref<WorkData[]>([]);
|
|
|
|
|
|
-// 抽屉相关
|
|
|
+// 详情弹窗相关
|
|
|
const drawerVisible = ref(false);
|
|
|
const selectedWork = ref<WorkData | null>(null);
|
|
|
+// 作品基础信息(来自 works 表 /api/works/:id)
|
|
|
+const selectedWorkBase = ref<any | null>(null);
|
|
|
+const activeTab = ref('core');
|
|
|
+const detailLoading = ref(false);
|
|
|
+
|
|
|
+// 作品详情数据
|
|
|
+interface WorkDetailData {
|
|
|
+ playCount: number;
|
|
|
+ exposureCount: number;
|
|
|
+ totalWatchDuration: string;
|
|
|
+ likeCount: number;
|
|
|
+ commentCount: number;
|
|
|
+ collectCount: number;
|
|
|
+ shareCount: number;
|
|
|
+ fansIncrease: number;
|
|
|
+ coverClickRate: string;
|
|
|
+ avgWatchDuration: string;
|
|
|
+ completionRate: string;
|
|
|
+ twoSecondExitRate: string;
|
|
|
+ // 抖音:5s 完播率(仅昨日快照,无趋势)
|
|
|
+ completionRate5s: string;
|
|
|
+}
|
|
|
+
|
|
|
+const workDetailData = ref<WorkDetailData>({
|
|
|
+ playCount: 0,
|
|
|
+ exposureCount: 0,
|
|
|
+ totalWatchDuration: '0秒',
|
|
|
+ likeCount: 0,
|
|
|
+ commentCount: 0,
|
|
|
+ collectCount: 0,
|
|
|
+ shareCount: 0,
|
|
|
+ fansIncrease: 0,
|
|
|
+ coverClickRate: '0',
|
|
|
+ avgWatchDuration: '0',
|
|
|
+ completionRate: '0',
|
|
|
+ twoSecondExitRate: '0',
|
|
|
+ completionRate5s: '0',
|
|
|
+});
|
|
|
+
|
|
|
+// 播放量趋势图
|
|
|
+const playTrendChartRef = ref<HTMLElement>();
|
|
|
+let playTrendChart: echarts.ECharts | null = null;
|
|
|
+
|
|
|
+type TrendMetricKey =
|
|
|
+ | 'exposureCount'
|
|
|
+ | 'playCount'
|
|
|
+ | 'likeCount'
|
|
|
+ | 'commentCount'
|
|
|
+ | 'collectCount'
|
|
|
+ | 'shareCount'
|
|
|
+ | 'coverClickRate'
|
|
|
+ | 'avgWatchDuration'
|
|
|
+ | 'completionRate'
|
|
|
+ | 'twoSecondExitRate'
|
|
|
+ | 'fansIncrease'
|
|
|
+ | 'totalWatchDuration';
|
|
|
+
|
|
|
+const activeTrendMetric = ref<TrendMetricKey>('playCount');
|
|
|
+const workStatsHistory = ref<any[]>([]);
|
|
|
+
|
|
|
+function toIntSafe(v: any): number {
|
|
|
+ const n = Number(String(v ?? '0').replace(/[^\d.-]/g, ''));
|
|
|
+ if (!Number.isFinite(n)) return 0;
|
|
|
+ return Math.max(0, Math.trunc(n));
|
|
|
+}
|
|
|
+
|
|
|
+const trendTitle = computed(() => {
|
|
|
+ if (!selectedWork.value) return '趋势';
|
|
|
+ if (selectedWork.value.platform !== 'xiaohongshu') {
|
|
|
+ const map: Record<TrendMetricKey, string> = {
|
|
|
+ playCount: '播放(阅读)量趋势',
|
|
|
+ totalWatchDuration: '播放总时长趋势',
|
|
|
+ likeCount: '点赞量趋势',
|
|
|
+ commentCount: '评论量趋势',
|
|
|
+ collectCount: '收藏量趋势',
|
|
|
+ shareCount: '分享量趋势',
|
|
|
+ fansIncrease: '涨粉量趋势',
|
|
|
+ exposureCount: '曝光量趋势',
|
|
|
+ coverClickRate: '封面点击率趋势',
|
|
|
+ avgWatchDuration: '平均观看时长趋势',
|
|
|
+ completionRate: '完播率趋势',
|
|
|
+ twoSecondExitRate: '2s退出率趋势',
|
|
|
+ };
|
|
|
+ return map[activeTrendMetric.value] || '趋势';
|
|
|
+ }
|
|
|
+ const map: Record<TrendMetricKey, string> = {
|
|
|
+ exposureCount: '曝光量趋势',
|
|
|
+ playCount: '播放(阅读)量趋势',
|
|
|
+ likeCount: '点赞量趋势',
|
|
|
+ commentCount: '评论量趋势',
|
|
|
+ collectCount: '收藏量趋势',
|
|
|
+ shareCount: '分享量趋势',
|
|
|
+ coverClickRate: '封面点击率趋势',
|
|
|
+ avgWatchDuration: '平均观看时长趋势',
|
|
|
+ completionRate: '完播率趋势',
|
|
|
+ twoSecondExitRate: '2s退出率趋势',
|
|
|
+ fansIncrease: '涨粉量趋势',
|
|
|
+ totalWatchDuration: '播放总时长趋势',
|
|
|
+ };
|
|
|
+ return map[activeTrendMetric.value] || '趋势';
|
|
|
+});
|
|
|
+
|
|
|
+function setTrendMetric(key: TrendMetricKey) {
|
|
|
+ activeTrendMetric.value = key;
|
|
|
+ if (workStatsHistory.value.length > 0) {
|
|
|
+ updatePlayTrendChart(workStatsHistory.value);
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
function getPlatformName(platform: PlatformType) {
|
|
|
return PLATFORMS[platform]?.name || platform;
|
|
|
@@ -511,15 +714,359 @@ async function loadData() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+// 格式化数字
|
|
|
+function formatNumber(num: number | null | undefined): string {
|
|
|
+ if (num === null || num === undefined) return '0';
|
|
|
+ if (num >= 10000) {
|
|
|
+ return (num / 10000).toFixed(1) + '万';
|
|
|
+ }
|
|
|
+ return String(num);
|
|
|
+}
|
|
|
+
|
|
|
+function formatDurationSeconds(secLike: any): string {
|
|
|
+ const s = Math.max(0, parseInt(String(secLike ?? '0'), 10) || 0);
|
|
|
+ if (s >= 60) return `${Math.floor(s / 60)}分${s % 60}秒`;
|
|
|
+ return `${s}秒`;
|
|
|
+}
|
|
|
+
|
|
|
+/** 平均观看时长:保留 2 位小数,不取整 */
|
|
|
+function formatAvgWatchDurationSeconds(secLike: any): string {
|
|
|
+ const s = Math.max(0, parseFloat(String(secLike ?? '0')) || 0);
|
|
|
+ if (s >= 60) return `${Math.floor(s / 60)}分${(s % 60).toFixed(2)}秒`;
|
|
|
+ return `${s.toFixed(2)}秒`;
|
|
|
+}
|
|
|
+
|
|
|
+function formatRate(rateLike: any): string {
|
|
|
+ const raw = String(rateLike ?? '0').trim();
|
|
|
+ if (!raw) return '0%';
|
|
|
+ if (raw.includes('%')) return raw;
|
|
|
+ const n = Number(raw);
|
|
|
+ if (Number.isNaN(n)) return raw;
|
|
|
+ // 兼容:有的入库是 0.12(表示 12%),有的是 12(表示 12%)
|
|
|
+ const pct = n <= 1 ? n * 100 : n;
|
|
|
+ return `${pct.toFixed(2).replace(/\.00$/, '')}%`;
|
|
|
+}
|
|
|
+
|
|
|
+function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
|
|
|
+ const end = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
|
|
|
+ const start = end.subtract(13, 'day'); // 近14天(含当天)
|
|
|
+ const publish = dayjs(selectedWork.value?.publishTime);
|
|
|
+ const clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
|
|
|
+ return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
|
|
|
+}
|
|
|
+
|
|
|
+interface MetricCardConfig {
|
|
|
+ key?: TrendMetricKey;
|
|
|
+ label: string;
|
|
|
+ value: string;
|
|
|
+}
|
|
|
+
|
|
|
+const xhsMetricCards = computed<MetricCardConfig[]>(() => {
|
|
|
+ const d = workDetailData.value;
|
|
|
+ return [
|
|
|
+ { key: 'exposureCount' as const, label: '曝光量', value: formatNumber(d.exposureCount || 0) },
|
|
|
+ { key: 'playCount' as const, label: '播放(阅读)量', value: formatNumber(d.playCount || selectedWork.value?.viewsCount || 0) },
|
|
|
+ { key: 'likeCount' as const, label: '点赞量', value: formatNumber(d.likeCount || selectedWork.value?.likesCount || 0) },
|
|
|
+ { key: 'commentCount' as const, label: '评论量', value: formatNumber(d.commentCount || selectedWork.value?.commentsCount || 0) },
|
|
|
+ { key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || selectedWork.value?.collectsCount || 0) },
|
|
|
+ { key: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || selectedWork.value?.sharesCount || 0) },
|
|
|
+ { key: 'coverClickRate' as const, label: '封面点击率', value: formatRate(d.coverClickRate) },
|
|
|
+ { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
|
|
|
+ { key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
|
|
|
+ { key: 'twoSecondExitRate' as const, label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
|
|
|
+ { key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
|
|
|
+ ];
|
|
|
+});
|
|
|
+
|
|
|
+// 抖音:按照小红书样式展示已有字段(不包含曝光量,新增 5s 完播率与播放总时长)
|
|
|
+const douyinMetricCards = computed<MetricCardConfig[]>(() => {
|
|
|
+ const d = workDetailData.value;
|
|
|
+ const base = selectedWork.value;
|
|
|
+ return [
|
|
|
+ { key: 'playCount', label: '播放量', value: formatNumber(d.playCount || base?.viewsCount || 0) },
|
|
|
+ { key: 'likeCount', label: '点赞量', value: formatNumber(d.likeCount || base?.likesCount || 0) },
|
|
|
+ { key: 'commentCount', label: '评论量', value: formatNumber(d.commentCount || base?.commentsCount || 0) },
|
|
|
+ { key: 'collectCount', label: '收藏量', value: formatNumber(d.collectCount || base?.collectsCount || 0) },
|
|
|
+ { key: 'shareCount', label: '分享量', value: formatNumber(d.shareCount || base?.sharesCount || 0) },
|
|
|
+ { key: 'fansIncrease', label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
|
|
|
+ { label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
|
|
|
+ { key: 'completionRate', label: '完播率', value: formatRate(d.completionRate) },
|
|
|
+ { key: 'twoSecondExitRate', label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
|
|
|
+ // 5s 完播率仅为昨日快照,不参与趋势联动
|
|
|
+ { label: '5s完播率', value: formatRate(d.completionRate5s) },
|
|
|
+ ];
|
|
|
+});
|
|
|
+
|
|
|
// 查看详情
|
|
|
-function handleView(row: WorkData) {
|
|
|
+async function handleView(row: WorkData) {
|
|
|
selectedWork.value = row;
|
|
|
+ selectedWorkBase.value = null;
|
|
|
+ activeTrendMetric.value = row.platform === 'xiaohongshu' ? 'exposureCount' : 'playCount';
|
|
|
+ workStatsHistory.value = [];
|
|
|
drawerVisible.value = true;
|
|
|
+ activeTab.value = 'core';
|
|
|
+
|
|
|
+ // 先用列表行做“瞬时占位”(列表来自区间汇总,可能不等于 works 表累计值)
|
|
|
+ workDetailData.value = {
|
|
|
+ playCount: row.viewsCount || 0,
|
|
|
+ exposureCount: 0,
|
|
|
+ totalWatchDuration: '0秒',
|
|
|
+ likeCount: row.likesCount || 0,
|
|
|
+ commentCount: row.commentsCount || 0,
|
|
|
+ collectCount: row.collectsCount || 0,
|
|
|
+ shareCount: row.sharesCount || 0,
|
|
|
+ fansIncrease: 0,
|
|
|
+ coverClickRate: '0',
|
|
|
+ avgWatchDuration: '0',
|
|
|
+ completionRate: '0',
|
|
|
+ twoSecondExitRate: '0',
|
|
|
+ };
|
|
|
+
|
|
|
+ // 1) 加载 works 表基础信息(标题、发布时间、累计播放/点赞等)
|
|
|
+ await loadWorkBase(row.id);
|
|
|
+ // 2) 加载 work_day_statistics 历史快照(用于“最新累计值”与趋势)
|
|
|
+ await loadWorkDetail(row.id);
|
|
|
+}
|
|
|
+
|
|
|
+// 加载作品基础信息(works 表)
|
|
|
+async function loadWorkBase(workId: number) {
|
|
|
+ try {
|
|
|
+ const data = await request.get(`/api/works/${workId}`);
|
|
|
+ if (!data) return;
|
|
|
+ selectedWorkBase.value = data;
|
|
|
+
|
|
|
+ // 基础信息补齐:以 works 表为准(如果 works 缺失字段则回退到列表行)
|
|
|
+ if (selectedWork.value) {
|
|
|
+ selectedWork.value = {
|
|
|
+ ...selectedWork.value,
|
|
|
+ title: data.title || selectedWork.value.title,
|
|
|
+ publishTime: data.publishTime || selectedWork.value.publishTime,
|
|
|
+ coverUrl: data.coverUrl || selectedWork.value.coverUrl,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 顶部卡片:按需求展示 works.yesterday_*(昨日快照)
|
|
|
+ workDetailData.value = {
|
|
|
+ playCount: toIntSafe(data.yesterdayPlayCount ?? 0),
|
|
|
+ exposureCount: toIntSafe(data.yesterdayExposureCount ?? 0),
|
|
|
+ totalWatchDuration: formatDurationSeconds(data.yesterdayTotalWatchDuration ?? 0),
|
|
|
+ likeCount: toIntSafe(data.yesterdayLikeCount ?? 0),
|
|
|
+ commentCount: toIntSafe(data.yesterdayCommentCount ?? 0),
|
|
|
+ collectCount: toIntSafe(data.yesterdayCollectCount ?? 0),
|
|
|
+ shareCount: toIntSafe(data.yesterdayShareCount ?? 0),
|
|
|
+ fansIncrease: toIntSafe(data.yesterdayFansIncrease ?? 0),
|
|
|
+ coverClickRate: String(data.yesterdayCoverClickRate ?? '0'),
|
|
|
+ avgWatchDuration: String(data.yesterdayAvgWatchDuration ?? '0'),
|
|
|
+ completionRate: String(data.yesterdayCompletionRate ?? '0'),
|
|
|
+ twoSecondExitRate: String(data.yesterdayTwoSecondExitRate ?? '0'),
|
|
|
+ completionRate5s: String(data.yesterdayCompletionRate5s ?? '0'),
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ // works 表请求失败不影响后续趋势展示
|
|
|
+ console.warn('加载作品基础信息失败:', error);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 加载作品详情数据(历史统计数据)
|
|
|
+async function loadWorkDetail(workId: number) {
|
|
|
+ detailLoading.value = true;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 趋势固定:近 14 天(按 work_day_statistics 日新增量口径)
|
|
|
+ const { start: startDateStr, end: endDateStr } = calcDetailRangeDatesFixed14Days();
|
|
|
+
|
|
|
+ // 调用接口获取该作品的历史统计数据(work_day_statistics 快照)
|
|
|
+ const data = await request.get(`/api/work-day-statistics/work/${workId}`, {
|
|
|
+ params: {
|
|
|
+ // 注意:后端校验参数名为 startDate/endDate
|
|
|
+ startDate: startDateStr,
|
|
|
+ endDate: endDateStr,
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (data && Array.isArray(data)) {
|
|
|
+ const workStats = data;
|
|
|
+ workStatsHistory.value = workStats;
|
|
|
+
|
|
|
+ // 绘制趋势图(按天新增量)
|
|
|
+ await nextTick();
|
|
|
+ updatePlayTrendChart(workStats);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ console.error('加载作品详情失败:', error);
|
|
|
+ ElMessage.error('加载作品详情失败,请稍后重试');
|
|
|
+ } finally {
|
|
|
+ detailLoading.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 更新播放量趋势图
|
|
|
+function updatePlayTrendChart(stats: Array<any>) {
|
|
|
+ if (!playTrendChartRef.value) return;
|
|
|
+
|
|
|
+ if (!playTrendChart) {
|
|
|
+ playTrendChart = echarts.init(playTrendChartRef.value);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 按日期排序
|
|
|
+ const sortedStats = [...stats].sort((a, b) =>
|
|
|
+ dayjs(a.recordDate).valueOf() - dayjs(b.recordDate).valueOf()
|
|
|
+ );
|
|
|
+
|
|
|
+ const dates = sortedStats.map(s => dayjs(s.recordDate).format('YYYY-MM-DD'));
|
|
|
+
|
|
|
+ const metric = activeTrendMetric.value;
|
|
|
+ const seriesName = trendTitle.value.replace('趋势', '');
|
|
|
+
|
|
|
+ const values = sortedStats.map((s) => {
|
|
|
+ const v = s?.[metric];
|
|
|
+ if (metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate') {
|
|
|
+ const raw = String(v ?? '0').trim();
|
|
|
+ if (raw.includes('%')) return Number(raw.replace('%', '')) || 0;
|
|
|
+ const n = Number(raw);
|
|
|
+ if (Number.isNaN(n)) return 0;
|
|
|
+ return n <= 1 ? n * 100 : n;
|
|
|
+ }
|
|
|
+ if (metric === 'avgWatchDuration') {
|
|
|
+ return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
|
|
|
+ }
|
|
|
+ if (metric === 'totalWatchDuration') {
|
|
|
+ return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
|
|
|
+ }
|
|
|
+ return Number(v) || 0;
|
|
|
+ });
|
|
|
+
|
|
|
+ const option: echarts.EChartsOption = {
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: {
|
|
|
+ type: 'cross',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ left: '3%',
|
|
|
+ right: '4%',
|
|
|
+ bottom: '3%',
|
|
|
+ containLabel: true,
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ type: 'category',
|
|
|
+ boundaryGap: false,
|
|
|
+ data: dates,
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (value: string) => {
|
|
|
+ return dayjs(value).format('MM-DD');
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'value',
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (value: number) => {
|
|
|
+ if (value >= 10000) {
|
|
|
+ return (value / 10000).toFixed(1) + '万';
|
|
|
+ }
|
|
|
+ return String(value);
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ series: [
|
|
|
+ {
|
|
|
+ name: seriesName || '趋势',
|
|
|
+ type: 'line',
|
|
|
+ smooth: true,
|
|
|
+ data: values,
|
|
|
+ itemStyle: {
|
|
|
+ color: '#ff6b9d',
|
|
|
+ },
|
|
|
+ areaStyle: {
|
|
|
+ color: {
|
|
|
+ type: 'linear',
|
|
|
+ x: 0,
|
|
|
+ y: 0,
|
|
|
+ x2: 0,
|
|
|
+ y2: 1,
|
|
|
+ colorStops: [
|
|
|
+ { offset: 0, color: 'rgba(255, 107, 157, 0.3)' },
|
|
|
+ { offset: 1, color: 'rgba(255, 107, 157, 0.05)' },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+
|
|
|
+ playTrendChart.setOption(option, true);
|
|
|
}
|
|
|
|
|
|
-// 导出数据
|
|
|
-function handleExport() {
|
|
|
- ElMessage.info('导出功能开发中');
|
|
|
+// 监听弹窗关闭,清理图表
|
|
|
+watch(drawerVisible, (visible) => {
|
|
|
+ if (!visible && playTrendChart) {
|
|
|
+ playTrendChart.dispose();
|
|
|
+ playTrendChart = null;
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 导出数据(按当前筛选条件导出作品列表)
|
|
|
+async function handleExport() {
|
|
|
+ try {
|
|
|
+ const baseUrl = serverStore.currentServer?.url;
|
|
|
+ if (!baseUrl) {
|
|
|
+ ElMessage.error('未连接服务器');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!authStore.accessToken) {
|
|
|
+ ElMessage.error('未连接服务器或未登录');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const params = new URLSearchParams();
|
|
|
+ params.set('startDate', startDate.value);
|
|
|
+ params.set('endDate', endDate.value);
|
|
|
+ if (selectedPlatform.value) params.set('platform', selectedPlatform.value);
|
|
|
+ if (selectedAccounts.value.length > 0) params.set('accountIds', selectedAccounts.value.join(','));
|
|
|
+ if (selectedGroup.value) params.set('groupId', String(selectedGroup.value));
|
|
|
+ if (searchKeyword.value) params.set('keyword', searchKeyword.value);
|
|
|
+ params.set('sortBy', sortBy.value);
|
|
|
+
|
|
|
+ const url = `${baseUrl}/api/work-day-statistics/works/export?${params.toString()}`;
|
|
|
+
|
|
|
+ const doFetch = async (token: string) => {
|
|
|
+ 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);
|
|
|
+ ElMessage.success('导出成功');
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error('导出失败:', error);
|
|
|
+ ElMessage.error(error?.message || '导出失败');
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
onMounted(() => {
|
|
|
@@ -673,91 +1220,133 @@ onMounted(() => {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+:deep(.work-detail-dialog) {
|
|
|
+ .el-dialog__header {
|
|
|
+ padding-bottom: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .el-dialog__body {
|
|
|
+ padding-top: 20px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
.work-detail {
|
|
|
.detail-header {
|
|
|
- display: flex;
|
|
|
- gap: 16px;
|
|
|
margin-bottom: 24px;
|
|
|
+ padding-bottom: 16px;
|
|
|
+ border-bottom: 1px solid #f0f0f0;
|
|
|
+
|
|
|
+ .work-title {
|
|
|
+ margin: 0 0 8px 0;
|
|
|
+ font-size: 18px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: $text-primary;
|
|
|
+ }
|
|
|
|
|
|
- .work-cover {
|
|
|
- width: 120px;
|
|
|
- height: 120px;
|
|
|
- border-radius: 8px;
|
|
|
- flex-shrink: 0;
|
|
|
+ .publish-time {
|
|
|
+ font-size: 14px;
|
|
|
+ color: $text-secondary;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .detail-tabs {
|
|
|
+ :deep(.el-tabs__header) {
|
|
|
+ margin-bottom: 24px;
|
|
|
+ }
|
|
|
+
|
|
|
+ :deep(.el-tabs__item) {
|
|
|
+ font-size: 15px;
|
|
|
+ font-weight: 500;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .core-data-content {
|
|
|
+ .traffic-data {
|
|
|
+ margin-bottom: 32px;
|
|
|
|
|
|
- .cover-placeholder {
|
|
|
- width: 100%;
|
|
|
- height: 100%;
|
|
|
- background: #f3f4f6;
|
|
|
+ .section-title-row {
|
|
|
display: flex;
|
|
|
align-items: center;
|
|
|
- justify-content: center;
|
|
|
- color: #9ca3af;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ margin-bottom: 16px;
|
|
|
}
|
|
|
- }
|
|
|
-
|
|
|
- .header-info {
|
|
|
- flex: 1;
|
|
|
-
|
|
|
- h3 {
|
|
|
- margin: 0 0 12px 0;
|
|
|
+
|
|
|
+ .section-title {
|
|
|
+ margin: 0;
|
|
|
font-size: 16px;
|
|
|
- line-height: 1.5;
|
|
|
+ font-weight: 600;
|
|
|
+ color: $text-primary;
|
|
|
}
|
|
|
-
|
|
|
- .meta-info {
|
|
|
+
|
|
|
+ .detail-range {
|
|
|
display: flex;
|
|
|
- align-items: center;
|
|
|
- gap: 12px;
|
|
|
+ gap: 8px;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ justify-content: flex-end;
|
|
|
+ }
|
|
|
+
|
|
|
+ .data-cards {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: repeat(7, 1fr);
|
|
|
+ gap: 16px;
|
|
|
|
|
|
- .publish-time {
|
|
|
- font-size: 13px;
|
|
|
- color: $text-secondary;
|
|
|
+ @media (max-width: 1400px) {
|
|
|
+ grid-template-columns: repeat(4, 1fr);
|
|
|
+ }
|
|
|
+
|
|
|
+ @media (max-width: 900px) {
|
|
|
+ grid-template-columns: repeat(3, 1fr);
|
|
|
+ }
|
|
|
+
|
|
|
+ @media (max-width: 600px) {
|
|
|
+ grid-template-columns: repeat(2, 1fr);
|
|
|
+ }
|
|
|
+
|
|
|
+ .data-card {
|
|
|
+ background: #f8fafc;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 20px 16px;
|
|
|
+ text-align: center;
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
+ cursor: pointer;
|
|
|
+
|
|
|
+ &.highlight {
|
|
|
+ background: #fff5f7;
|
|
|
+ border-color: #ff6b9d;
|
|
|
+
|
|
|
+ .card-value {
|
|
|
+ color: #ff6b9d;
|
|
|
+ font-weight: 700;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .card-label {
|
|
|
+ font-size: 13px;
|
|
|
+ color: $text-secondary;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .card-value {
|
|
|
+ font-size: 24px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: $text-primary;
|
|
|
+ line-height: 1.2;
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
- }
|
|
|
-
|
|
|
- .detail-stats {
|
|
|
- display: grid;
|
|
|
- grid-template-columns: repeat(5, 1fr);
|
|
|
- gap: 16px;
|
|
|
- margin-bottom: 24px;
|
|
|
|
|
|
- .stat-item {
|
|
|
- background: #f8fafc;
|
|
|
- border-radius: 12px;
|
|
|
- padding: 16px;
|
|
|
- text-align: center;
|
|
|
-
|
|
|
- .stat-value {
|
|
|
- font-size: 24px;
|
|
|
+ .trend-section {
|
|
|
+ .section-title {
|
|
|
+ margin: 0 0 16px 0;
|
|
|
+ font-size: 16px;
|
|
|
font-weight: 600;
|
|
|
- color: $primary-color;
|
|
|
- }
|
|
|
-
|
|
|
- .stat-label {
|
|
|
- font-size: 13px;
|
|
|
- color: $text-secondary;
|
|
|
- margin-top: 4px;
|
|
|
+ color: $text-primary;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- .detail-content {
|
|
|
- h4 {
|
|
|
- margin: 0 0 12px 0;
|
|
|
- font-size: 15px;
|
|
|
- color: $text-primary;
|
|
|
- }
|
|
|
-
|
|
|
- .content-text {
|
|
|
- font-size: 14px;
|
|
|
- line-height: 1.8;
|
|
|
- color: $text-regular;
|
|
|
- white-space: pre-wrap;
|
|
|
- }
|
|
|
- }
|
|
|
}
|
|
|
|
|
|
@media (max-width: 1400px) {
|