Files
live_recorder/frontend/src/views/DailyReviewsView.vue
T

448 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import axios from "axios";
import apiClient, { getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import PlatformMark from "@/components/ui/PlatformMark.vue";
import { useViewport } from "@/composables/useViewport";
import type {
DailyReviewPushResult,
DailyReviewReport,
PushDailyReviewRequest
} from "@/types";
import { Bell, Calendar, Clock, DataAnalysis, VideoCamera } from "@element-plus/icons-vue";
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const pushing = ref(false);
const pushDialogVisible = ref(false);
const loadError = ref("");
const selectedDate = ref(defaultReviewDate());
const report = ref<DailyReviewReport | null>(null);
const roomTableHeight = computed(() => (isMobile.value ? undefined : 420));
const momentTableHeight = computed(() => (isMobile.value ? undefined : 360));
const pushChannels = reactive({
webhook: true,
email: false
});
async function loadReport() {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<DailyReviewReport>("/reports/daily", {
params: {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes()
}
});
report.value = data;
} catch (error) {
loadError.value =
axios.isAxiosError(error) && error.response?.status === 404
? "当前后端还没有部署回顾日报接口。请先同步更新并重启后端,再打开这个页面。"
: getApiErrorMessage(error, "回顾日报加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function openPushDialog() {
pushDialogVisible.value = true;
}
async function pushReport() {
const channels = Object.entries(pushChannels)
.filter(([, enabled]) => enabled)
.map(([channel]) => channel);
if (channels.length === 0) {
ElMessage.warning("请至少选择一种推送方式。");
return;
}
pushing.value = true;
try {
const payload: PushDailyReviewRequest = {
date: selectedDate.value,
utcOffsetMinutes: getLocalUtcOffsetMinutes(),
channels
};
const { data } = await apiClient.post<DailyReviewPushResult>("/reports/daily/push", payload);
const successCount = data.results.filter((item) => item.success).length;
const summary = data.results.map((item) => `${item.channel}: ${item.message}`).join(" | ");
if (successCount === data.results.length) {
ElMessage.success(summary);
} else if (successCount > 0) {
ElMessage.warning(summary);
} else {
ElMessage.error(summary);
}
pushDialogVisible.value = false;
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "日报推送失败。"));
} finally {
pushing.value = false;
}
}
function openSession(recordSessionId: string) {
router.push({ name: "record-session-detail", params: { id: recordSessionId } });
}
function openTask(recordTaskId: string) {
router.push({ name: "record-task-detail", params: { id: recordTaskId } });
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatDuration(seconds?: number) {
return typeof seconds === "number" && Number.isFinite(seconds)
? `${seconds.toFixed(0)}s`
: "-";
}
function getLocalUtcOffsetMinutes() {
return -new Date().getTimezoneOffset();
}
function defaultReviewDate() {
const current = new Date();
current.setDate(current.getDate() - 1);
return formatDateOnly(current);
}
function formatDateOnly(value: Date) {
const year = value.getFullYear();
const month = `${value.getMonth() + 1}`.padStart(2, "0");
const day = `${value.getDate()}`.padStart(2, "0");
return `${year}-${month}-${day}`;
}
function disableFutureDates(value: Date) {
const endOfToday = new Date();
endOfToday.setHours(23, 59, 59, 999);
return value.getTime() > endOfToday.getTime();
}
onMounted(loadReport);
</script>
<template>
<div class="page-stack">
<div class="page-header">
<div>
<div class="page-kicker">回顾分析</div>
<h1 class="page-title">回顾日报</h1>
<p class="page-subtitle">
按天聚合录制时长异常和弹幕热度支持手动推送到已配置的 Webhook 或邮件
</p>
</div>
<el-space wrap class="header-actions">
<el-date-picker
v-model="selectedDate"
type="date"
value-format="YYYY-MM-DD"
format="YYYY-MM-DD"
:clearable="false"
:disabled-date="disableFutureDates"
@change="loadReport"
/>
<el-button @click="loadReport">刷新日报</el-button>
<el-button type="primary" :disabled="!report" @click="openPushDialog">推送日报</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<el-skeleton v-if="loading && !report" animated :rows="10" />
<template v-else-if="report">
<div class="stats-grid daily-review-metrics">
<MetricCard label="活跃直播间" :value="report.summary.activeLiveRoomCount" :description="`${report.date} 当天有录制重叠的房间数`" :icon="Calendar" />
<MetricCard label="录制会话" :value="report.summary.sessionCount" description="按整场直播会话聚合统计" :icon="VideoCamera" />
<MetricCard label="分片总数" :value="report.summary.segmentCount" description="只统计与当天有时间重叠的分片" :icon="DataAnalysis" />
<MetricCard label="录制时长" :value="formatDuration(report.summary.totalDurationSeconds)" description="跨天会话按日报窗口裁剪" :icon="Clock" />
<MetricCard label="警告 / 错误" :value="`${report.summary.warningCount} / ${report.summary.errorCount}`" description="来自当天警告和错误系统日志" :icon="Bell" />
<MetricCard label="弹幕事件" :value="report.summary.totalDanmakuCount" description="优先按弹幕 XML 分钟桶统计" :icon="Bell" />
</div>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">直播间汇总</h3>
<p class="section-subtitle">每个房间当天的录制规模异常数量和弹幕活跃度都会聚合在这里</p>
</div>
</div>
<EmptyState v-if="report.rooms.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="table-scroll-shell">
<el-table :data="report.rooms" :height="roomTableHeight" class="premium-table" table-layout="auto">
<el-table-column label="直播间" min-width="260">
<template #default="{ row }">
<div class="cell-title">{{ row.title || row.anchorName || row.roomId }}</div>
<div class="cell-subtitle">{{ row.anchorName || row.roomId }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="140">
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
</el-table-column>
<el-table-column label="会话" width="90" prop="sessionCount" />
<el-table-column label="分片" width="90" prop="segmentCount" />
<el-table-column label="录制时长" width="120">
<template #default="{ row }">
{{ formatDuration(row.totalDurationSeconds) }}
</template>
</el-table-column>
<el-table-column label="警告 / 错误" width="130">
<template #default="{ row }">
{{ row.warningCount }} / {{ row.errorCount }}
</template>
</el-table-column>
<el-table-column label="弹幕" width="90" prop="danmakuCount" />
</el-table>
</div>
</el-card>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">重点会话</h3>
<p class="section-subtitle">最长最热和异常最多的会话会集中展示在这里</p>
</div>
</div>
<EmptyState v-if="report.highlights.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="highlight-grid">
<section
v-for="item in report.highlights"
:key="item.key"
class="highlight-item"
>
<div class="highlight-item__label">{{ item.label }}</div>
<div class="highlight-item__title">{{ item.liveRoomTitle }}</div>
<div class="highlight-item__meta">
<PlatformMark :name="item.platformName" />
<span>{{ item.roomId }}</span>
<span>{{ formatDuration(item.durationSeconds) }}</span>
</div>
<div class="highlight-item__stats">
<span>分片 {{ item.segmentCount }}</span>
<span>弹幕 {{ item.danmakuCount }}</span>
<span>警告 {{ item.warningCount }}</span>
<span>错误 {{ item.errorCount }}</span>
</div>
<div class="highlight-item__summary">{{ item.summary || "-" }}</div>
<div class="highlight-item__footer">
<span>{{ formatDate(item.startedAt || item.endedAt) }}</span>
<el-button size="small" @click="openSession(item.recordSessionId)">查看会话</el-button>
</div>
</section>
</div>
</el-card>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">热点时刻</h3>
<p class="section-subtitle">基于弹幕 XML 的分钟级活跃度帮助快速定位当天最热的时间窗口</p>
</div>
</div>
<EmptyState v-if="report.moments.length === 0" description="当前筛选条件下没有可展示内容" />
<div v-else class="table-scroll-shell">
<el-table :data="report.moments" :height="momentTableHeight" class="premium-table" table-layout="auto">
<el-table-column label="热度窗口" width="190">
<template #default="{ row }">
{{ formatDate(row.bucketStartedAt) }}
</template>
</el-table-column>
<el-table-column label="直播间" min-width="250">
<template #default="{ row }">
<div class="cell-title">{{ row.liveRoomTitle }}</div>
<div class="cell-subtitle">分片 #{{ row.segmentIndex }}</div>
</template>
</el-table-column>
<el-table-column label="平台" width="140">
<template #default="{ row }"><PlatformMark :name="row.platformName" /></template>
</el-table-column>
<el-table-column label="弹幕数" width="100" prop="danmakuCount" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<div class="action-row">
<el-button size="small" @click="openSession(row.recordSessionId)">查看会话</el-button>
<el-button size="small" @click="openTask(row.recordTaskId)">查看分片</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
</template>
<el-dialog
v-model="pushDialogVisible"
title="推送日报"
width="420px"
destroy-on-close
>
<div class="push-dialog">
<p class="push-dialog__hint">选择要推送的通道第一版支持 Webhook 和邮件可同时勾选</p>
<el-checkbox v-model="pushChannels.webhook">Webhook</el-checkbox>
<el-checkbox v-model="pushChannels.email">邮件</el-checkbox>
</div>
<template #footer>
<el-button @click="pushDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="pushing" @click="pushReport">开始推送</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.daily-review-metrics :deep(.metric-card__value) {
font-size: clamp(24px, 1.9vw, 34px);
}
.highlight-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.highlight-item {
display: grid;
gap: 10px;
min-width: 0;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--border-subtle);
background: var(--surface-muted);
}
.highlight-item__label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-muted);
}
.highlight-item__title {
font-size: 16px;
font-weight: 700;
line-height: 1.5;
color: var(--text-primary);
}
.highlight-item__meta,
.highlight-item__stats,
.highlight-item__footer {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
color: var(--text-secondary);
font-size: 13px;
}
.highlight-item__summary {
color: var(--text-secondary);
font-size: 13px;
line-height: 1.7;
}
.highlight-item__footer {
justify-content: space-between;
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
}
.action-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: nowrap;
}
.push-dialog {
display: grid;
gap: 14px;
}
.push-dialog__hint {
margin: 0;
color: var(--text-secondary);
line-height: 1.7;
}
@media (max-width: 1100px) {
.highlight-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.highlight-grid {
grid-template-columns: 1fr;
}
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-date-editor),
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.highlight-item__footer {
align-items: flex-start;
flex-direction: column;
}
}
</style>