|
@@ -770,13 +770,20 @@ function formatDurationSeconds(secLike: any): string {
|
|
|
return `${s}秒`;
|
|
return `${s}秒`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** 平均观看时长:保留 2 位小数,不取整 */
|
|
|
|
|
|
|
+/** 平均观看时长:保留 2 位小数(非小红书口径) */
|
|
|
function formatAvgWatchDurationSeconds(secLike: any): string {
|
|
function formatAvgWatchDurationSeconds(secLike: any): string {
|
|
|
const s = Math.max(0, parseFloat(String(secLike ?? '0')) || 0);
|
|
const s = Math.max(0, parseFloat(String(secLike ?? '0')) || 0);
|
|
|
if (s >= 60) return `${Math.floor(s / 60)}分${(s % 60).toFixed(2)}秒`;
|
|
if (s >= 60) return `${Math.floor(s / 60)}分${(s % 60).toFixed(2)}秒`;
|
|
|
return `${s.toFixed(2)}秒`;
|
|
return `${s.toFixed(2)}秒`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** 小红书平均观看时长:取整(秒) */
|
|
|
|
|
+function formatAvgWatchDurationSecondsInt(secLike: any): string {
|
|
|
|
|
+ const s = Math.max(0, Math.round(parseFloat(String(secLike ?? '0')) || 0));
|
|
|
|
|
+ if (s >= 60) return `${Math.floor(s / 60)}分${s % 60}秒`;
|
|
|
|
|
+ return `${s}秒`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function formatRate(rateLike: any): string {
|
|
function formatRate(rateLike: any): string {
|
|
|
const raw = String(rateLike ?? '0').trim();
|
|
const raw = String(rateLike ?? '0').trim();
|
|
|
if (!raw) return '0%';
|
|
if (!raw) return '0%';
|
|
@@ -789,9 +796,18 @@ function formatRate(rateLike: any): string {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
|
|
function calcDetailRangeDatesFixed14Days(): { start: string; end: string } {
|
|
|
|
|
+ const publish = dayjs(selectedWork.value?.publishTime);
|
|
|
|
|
+
|
|
|
|
|
+ // 小红书:趋势固定为「发布后 14 天(含发布日)」,与页面时间范围无关
|
|
|
|
|
+ if (selectedWork.value?.platform === 'xiaohongshu' && publish.isValid()) {
|
|
|
|
|
+ const start = publish.startOf('day');
|
|
|
|
|
+ const end = start.add(13, 'day'); // 发布日 + 13 天 = 共 14 天
|
|
|
|
|
+ return { start: start.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 其他平台:保持原逻辑(以页面筛选 endDate 为截止的近 14 天,并且不早于发布时间)
|
|
|
const end = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
|
|
const end = dayjs(endDate.value || dayjs().format('YYYY-MM-DD'));
|
|
|
const start = end.subtract(13, 'day'); // 近14天(含当天)
|
|
const start = end.subtract(13, 'day'); // 近14天(含当天)
|
|
|
- const publish = dayjs(selectedWork.value?.publishTime);
|
|
|
|
|
const clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
|
|
const clampedStart = publish.isValid() && publish.isAfter(start) ? publish : start;
|
|
|
return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
|
|
return { start: clampedStart.format('YYYY-MM-DD'), end: end.format('YYYY-MM-DD') };
|
|
|
}
|
|
}
|
|
@@ -812,7 +828,7 @@ const xhsMetricCards = computed<MetricCardConfig[]>(() => {
|
|
|
{ key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || selectedWork.value?.collectsCount || 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: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || selectedWork.value?.sharesCount || 0) },
|
|
|
{ key: 'coverClickRate' as const, label: '封面点击率', value: formatRate(d.coverClickRate) },
|
|
{ key: 'coverClickRate' as const, label: '封面点击率', value: formatRate(d.coverClickRate) },
|
|
|
- { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
|
|
|
|
|
|
|
+ { key: 'avgWatchDuration' as const, label: '平均观看时长', value: formatAvgWatchDurationSecondsInt(d.avgWatchDuration) },
|
|
|
{ key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
|
|
{ key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
|
|
|
{ key: 'twoSecondExitRate' as const, label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
|
|
{ key: 'twoSecondExitRate' as const, label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
|
|
|
{ key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
|
|
{ key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
|
|
@@ -1012,9 +1028,13 @@ function updatePlayTrendChart(stats: Array<any>) {
|
|
|
const metric = activeTrendMetric.value;
|
|
const metric = activeTrendMetric.value;
|
|
|
const seriesName = trendTitle.value.replace('趋势', '');
|
|
const seriesName = trendTitle.value.replace('趋势', '');
|
|
|
|
|
|
|
|
|
|
+ const isRateMetric =
|
|
|
|
|
+ metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate';
|
|
|
|
|
+ const isDurationMetric = metric === 'avgWatchDuration' || metric === 'totalWatchDuration';
|
|
|
|
|
+
|
|
|
const values = sortedStats.map((s) => {
|
|
const values = sortedStats.map((s) => {
|
|
|
const v = s?.[metric];
|
|
const v = s?.[metric];
|
|
|
- if (metric === 'coverClickRate' || metric === 'completionRate' || metric === 'twoSecondExitRate') {
|
|
|
|
|
|
|
+ if (isRateMetric) {
|
|
|
const raw = String(v ?? '0').trim();
|
|
const raw = String(v ?? '0').trim();
|
|
|
if (raw.includes('%')) return Number(raw.replace('%', '')) || 0;
|
|
if (raw.includes('%')) return Number(raw.replace('%', '')) || 0;
|
|
|
const n = Number(raw);
|
|
const n = Number(raw);
|
|
@@ -1022,7 +1042,7 @@ function updatePlayTrendChart(stats: Array<any>) {
|
|
|
return n <= 1 ? n * 100 : n;
|
|
return n <= 1 ? n * 100 : n;
|
|
|
}
|
|
}
|
|
|
if (metric === 'avgWatchDuration') {
|
|
if (metric === 'avgWatchDuration') {
|
|
|
- return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
|
|
|
|
|
|
|
+ return Math.max(0, Math.round(parseFloat(String(v ?? '0')) || 0));
|
|
|
}
|
|
}
|
|
|
if (metric === 'totalWatchDuration') {
|
|
if (metric === 'totalWatchDuration') {
|
|
|
return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
|
|
return Math.max(0, parseInt(String(v ?? '0'), 10) || 0);
|
|
@@ -1036,6 +1056,24 @@ function updatePlayTrendChart(stats: Array<any>) {
|
|
|
axisPointer: {
|
|
axisPointer: {
|
|
|
type: 'cross',
|
|
type: 'cross',
|
|
|
},
|
|
},
|
|
|
|
|
+ formatter: (params: any) => {
|
|
|
|
|
+ const arr = Array.isArray(params) ? params : [params];
|
|
|
|
|
+ if (arr.length === 0) return '';
|
|
|
|
|
+ const dateLabel = arr[0]?.axisValueLabel || arr[0]?.axisValue || '';
|
|
|
|
|
+ const lines: string[] = [`${dateLabel}`];
|
|
|
|
|
+ for (const p of arr) {
|
|
|
|
|
+ const name = p?.seriesName ?? '';
|
|
|
|
|
+ const val = typeof p?.data === 'number' ? p.data : Number(p?.data ?? 0);
|
|
|
|
|
+ if (isRateMetric) {
|
|
|
|
|
+ lines.push(`${name}:${(Number.isFinite(val) ? val : 0).toFixed(2).replace(/\.00$/, '')}%`);
|
|
|
|
|
+ } else if (isDurationMetric) {
|
|
|
|
|
+ lines.push(`${name}:${formatDurationSeconds(Number.isFinite(val) ? Math.round(val) : 0)}`);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ lines.push(`${name}:${formatNumber(Number.isFinite(val) ? val : 0)}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return lines.join('<br/>');
|
|
|
|
|
+ },
|
|
|
},
|
|
},
|
|
|
grid: {
|
|
grid: {
|
|
|
left: '3%',
|
|
left: '3%',
|
|
@@ -1057,9 +1095,14 @@ function updatePlayTrendChart(stats: Array<any>) {
|
|
|
type: 'value',
|
|
type: 'value',
|
|
|
axisLabel: {
|
|
axisLabel: {
|
|
|
formatter: (value: number) => {
|
|
formatter: (value: number) => {
|
|
|
- if (value >= 10000) {
|
|
|
|
|
- return (value / 10000).toFixed(1) + '万';
|
|
|
|
|
|
|
+ if (isRateMetric) {
|
|
|
|
|
+ const n = Number.isFinite(value) ? value : 0;
|
|
|
|
|
+ return `${n.toFixed(2).replace(/\.00$/, '')}%`;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isDurationMetric) {
|
|
|
|
|
+ return formatDurationSeconds(Number.isFinite(value) ? Math.round(value) : 0);
|
|
|
}
|
|
}
|
|
|
|
|
+ if (value >= 10000) return (value / 10000).toFixed(1) + '万';
|
|
|
return String(value);
|
|
return String(value);
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|