953 lines
28 KiB
Vue
953 lines
28 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from "vue";
|
||
import { useRouter } from "vue-router";
|
||
import { ElMessage } from "element-plus";
|
||
import apiClient, { getApiErrorMessage, buildApiUrl } from "@/api/client";
|
||
import { useDanmakuPlayer } from "@/composables/useDanmakuPlayer";
|
||
import { useViewport } from "@/composables/useViewport";
|
||
import DanmakuPlayer from "@/components/player/DanmakuPlayer.vue";
|
||
import PlatformMark from "@/components/ui/PlatformMark.vue";
|
||
import type {
|
||
RecordArtifactUploadBatchResult,
|
||
RecordPreviewTicket,
|
||
RecordSessionDetail,
|
||
RecordSessionTimelineEvent,
|
||
RecordSessionHeatBucket,
|
||
RecordSessionTimelineSegment
|
||
} from "@/types";
|
||
import {
|
||
formatQualityLabel,
|
||
logLevelLabelMap,
|
||
outputFormatLabelMap,
|
||
platformLabelMap,
|
||
saveModeLabelMap,
|
||
sessionStatusLabelMap,
|
||
taskStatusLabelMap
|
||
} from "@/types";
|
||
|
||
const props = defineProps<{
|
||
id: string;
|
||
}>();
|
||
|
||
const router = useRouter();
|
||
const { isMobile } = useViewport();
|
||
|
||
// Danmaku replay dialog
|
||
const { danmakuEvents: replayDanmakuEvents, loading: danmakuLoading, error: danmakuError, loadTaskDanmaku, clear: clearDanmaku } = useDanmakuPlayer();
|
||
const danmakuDialogVisible = ref(false);
|
||
const danmakuDialogTitle = ref("");
|
||
const danmakuPreviewUrl = ref("");
|
||
const danmakuPreviewLoading = ref(false);
|
||
const danmakuPreviewMessage = ref("");
|
||
|
||
async function openDanmakuReplay(recordTaskId: string, segmentIndex: number) {
|
||
danmakuDialogVisible.value = true;
|
||
danmakuDialogTitle.value = `弹幕回放 — 分片 #${segmentIndex}`;
|
||
danmakuPreviewUrl.value = "";
|
||
danmakuPreviewMessage.value = "";
|
||
clearDanmaku();
|
||
|
||
// Load preview ticket and danmaku in parallel
|
||
danmakuPreviewLoading.value = true;
|
||
try {
|
||
const [ticketResult] = await Promise.allSettled([
|
||
apiClient.post<RecordPreviewTicket>(`/record-tasks/${recordTaskId}/preview-ticket`),
|
||
loadTaskDanmaku(recordTaskId)
|
||
]);
|
||
|
||
if (ticketResult.status === "fulfilled") {
|
||
danmakuPreviewUrl.value = ticketResult.value.data.url;
|
||
} else {
|
||
danmakuPreviewMessage.value = "无法获取视频预览票据,请稍后重试。";
|
||
}
|
||
} catch {
|
||
danmakuPreviewMessage.value = "加载预览资源失败。";
|
||
} finally {
|
||
danmakuPreviewLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function closeDanmakuDialog() {
|
||
danmakuDialogVisible.value = false;
|
||
danmakuPreviewUrl.value = "";
|
||
clearDanmaku();
|
||
}
|
||
|
||
const loading = ref(false);
|
||
const uploadLoading = ref(false);
|
||
const loadError = ref("");
|
||
const detail = ref<RecordSessionDetail | null>(null);
|
||
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
|
||
|
||
const timelineDurationSeconds = computed(() => {
|
||
const total = detail.value?.timeline.totalDurationSeconds ?? 0;
|
||
return total > 0 ? total : 60;
|
||
});
|
||
|
||
const segmentItems = computed(() => detail.value?.timeline.segments ?? []);
|
||
const heatBuckets = computed(() => detail.value?.timeline.heatBuckets ?? []);
|
||
const sessionEvents = computed(() => filterTimelineEvents("session"));
|
||
const processingEvents = computed(() => filterTimelineEvents("processing"));
|
||
const danmakuEvents = computed(() => filterTimelineEvents("danmaku"));
|
||
const automationEvents = computed(() => filterTimelineEvents("automation"));
|
||
const maxHeatCount = computed(() =>
|
||
heatBuckets.value.reduce((max, bucket) => Math.max(max, bucket.messageCount), 0)
|
||
);
|
||
|
||
async function loadDetail() {
|
||
loading.value = true;
|
||
loadError.value = "";
|
||
|
||
try {
|
||
const { data } = await apiClient.get<RecordSessionDetail>(`/record-sessions/${props.id}`);
|
||
detail.value = data;
|
||
} catch (error) {
|
||
loadError.value = getApiErrorMessage(error, "会话详情加载失败,请稍后重试。");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
async function uploadSessionArtifacts() {
|
||
uploadLoading.value = true;
|
||
|
||
try {
|
||
const { data } = await apiClient.post<RecordArtifactUploadBatchResult>(`/record-sessions/${props.id}/upload`);
|
||
const queued = data.items.some(item => item.uploadStatus === 4 || item.uploadStatus === 5);
|
||
const message = `${queued ? "会话上传已加入队列" : "会话上传完成"}:已受理 ${data.successCount},失败 ${data.failedCount}。`;
|
||
ElMessage[data.failedCount === 0 ? "success" : "warning"](message);
|
||
await loadDetail();
|
||
} catch (error) {
|
||
ElMessage.error(getApiErrorMessage(error, "会话上传失败,请稍后重试。"));
|
||
} finally {
|
||
uploadLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function filterTimelineEvents(layer: string) {
|
||
if (!detail.value || !visibleLayers.value.includes(layer)) {
|
||
return [];
|
||
}
|
||
|
||
return detail.value.timeline.events.filter((item) => item.layer === layer);
|
||
}
|
||
|
||
function toggleLayerVisibility(layer: string) {
|
||
const current = new Set(visibleLayers.value);
|
||
if (current.has(layer)) {
|
||
current.delete(layer);
|
||
} else {
|
||
current.add(layer);
|
||
}
|
||
|
||
visibleLayers.value = Array.from(current);
|
||
}
|
||
|
||
function openTaskDetail(recordTaskId?: string) {
|
||
if (!recordTaskId) {
|
||
return;
|
||
}
|
||
|
||
router.push({ name: "record-task-detail", params: { id: recordTaskId } });
|
||
}
|
||
|
||
function toLeftStyle(offsetSeconds: number) {
|
||
return {
|
||
left: `${Math.min(100, Math.max(0, (offsetSeconds / timelineDurationSeconds.value) * 100))}%`
|
||
};
|
||
}
|
||
|
||
function toSegmentStyle(segment: RecordSessionTimelineSegment) {
|
||
const left = Math.min(100, Math.max(0, (segment.offsetSeconds / timelineDurationSeconds.value) * 100));
|
||
const width = Math.max(1.2, (segment.durationSeconds / timelineDurationSeconds.value) * 100);
|
||
return {
|
||
left: `${left}%`,
|
||
width: `${Math.min(100 - left, width)}%`
|
||
};
|
||
}
|
||
|
||
function toHeatStyle(bucket: RecordSessionHeatBucket) {
|
||
const left = Math.min(100, Math.max(0, (bucket.offsetSeconds / timelineDurationSeconds.value) * 100));
|
||
const width = Math.max(1.2, (bucket.durationSeconds / timelineDurationSeconds.value) * 100);
|
||
const height = maxHeatCount.value > 0
|
||
? `${Math.max(12, Math.round((bucket.messageCount / maxHeatCount.value) * 100))}%`
|
||
: "12%";
|
||
return {
|
||
left: `${left}%`,
|
||
width: `${Math.min(100 - left, width)}%`,
|
||
height
|
||
};
|
||
}
|
||
|
||
function sessionStatusTagType(status: number) {
|
||
if (status === 2) {
|
||
return "success";
|
||
}
|
||
|
||
if (status === 5) {
|
||
return "danger";
|
||
}
|
||
|
||
if (status === 4 || status === 6) {
|
||
return "info";
|
||
}
|
||
|
||
return "warning";
|
||
}
|
||
|
||
function logTagType(level?: number) {
|
||
if (level === 3) {
|
||
return "danger";
|
||
}
|
||
|
||
if (level === 2) {
|
||
return "warning";
|
||
}
|
||
|
||
return "info";
|
||
}
|
||
|
||
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 formatFileSize(bytes?: number) {
|
||
if (typeof bytes !== "number" || Number.isNaN(bytes)) {
|
||
return "-";
|
||
}
|
||
|
||
if (bytes < 1024) {
|
||
return `${bytes} B`;
|
||
}
|
||
|
||
if (bytes < 1024 * 1024) {
|
||
return `${(bytes / 1024).toFixed(1)} KB`;
|
||
}
|
||
|
||
if (bytes < 1024 * 1024 * 1024) {
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||
}
|
||
|
||
function formatEventTooltip(event: RecordSessionTimelineEvent) {
|
||
return [
|
||
event.title,
|
||
event.detail,
|
||
event.segmentIndex ? `分片 #${event.segmentIndex}` : null,
|
||
event.level !== undefined ? `级别 ${logLevelLabelMap[event.level]}` : null,
|
||
formatDate(event.occurredAt)
|
||
]
|
||
.filter((item) => Boolean(item))
|
||
.join("\n");
|
||
}
|
||
|
||
function formatSegmentTooltip(segment: RecordSessionTimelineSegment) {
|
||
return [
|
||
`分片 #${segment.segmentIndex}`,
|
||
taskStatusLabelMap[segment.status],
|
||
segment.label,
|
||
segment.detail,
|
||
`${formatDate(segment.startedAt)} - ${formatDate(segment.endedAt)}`
|
||
]
|
||
.filter((item) => Boolean(item))
|
||
.join("\n");
|
||
}
|
||
|
||
function formatHeatTooltip(bucket: RecordSessionHeatBucket) {
|
||
return [
|
||
`分片 #${bucket.segmentIndex}`,
|
||
formatDate(bucket.bucketStartedAt),
|
||
`弹幕 ${bucket.messageCount}`
|
||
].join("\n");
|
||
}
|
||
|
||
watch(() => props.id, loadDetail);
|
||
onMounted(loadDetail);
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page-stack">
|
||
<div class="page-header">
|
||
<div>
|
||
<h1 class="page-title">会话详情</h1>
|
||
<p class="page-subtitle">
|
||
以整场直播会话为单位查看分片区间、脚本和 webhook 事件,以及弹幕热度叠层。
|
||
</p>
|
||
</div>
|
||
|
||
<el-space wrap class="header-actions">
|
||
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
|
||
<el-button @click="loadDetail">刷新</el-button>
|
||
<el-button :loading="uploadLoading" @click="uploadSessionArtifacts">上传会话</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 && !detail" animated :rows="10" />
|
||
|
||
<template v-else-if="detail">
|
||
<el-row :gutter="18">
|
||
<el-col :lg="12" :sm="24">
|
||
<el-card class="surface-card" shadow="never">
|
||
<h3 class="section-title">会话摘要</h3>
|
||
<p class="section-subtitle">会话状态、时长、分片数量和基础输出配置。</p>
|
||
|
||
<el-descriptions :column="1" border>
|
||
<el-descriptions-item label="会话状态">
|
||
<el-tag :type="sessionStatusTagType(detail.session.status)">
|
||
{{ detail.session.isRecovering ? "恢复中" : sessionStatusLabelMap[detail.session.status] }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item v-if="detail.session.isRecovering" label="恢复进度">
|
||
<span class="recovery-status-copy">{{ detail.session.recoveryReason || "正在等待下一次自动重试" }}</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="直播间">
|
||
{{ detail.session.liveRoomTitle }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="主播">
|
||
{{ detail.session.anchorName || "未知主播" }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="平台">
|
||
<PlatformMark :name="platformLabelMap[detail.session.platform]" />
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="Room ID">
|
||
<span class="monospace">{{ detail.session.roomId }}</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="保存模式">
|
||
{{ saveModeLabelMap[detail.session.saveMode] }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="输出格式">
|
||
{{ outputFormatLabelMap[detail.session.outputFormat] }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="清晰度">
|
||
<span>{{ formatQualityLabel(detail.session.preferredQuality) }}</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="分片数量">
|
||
{{ detail.session.segmentCount }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="总文件大小">
|
||
{{ formatFileSize(detail.session.totalFileSizeBytes) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="总弹幕事件">
|
||
{{ detail.session.totalDanmakuMessageCount }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="开始时间">
|
||
{{ formatDate(detail.session.startedAt || detail.session.createdAt) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="结束时间">
|
||
{{ formatDate(detail.session.endedAt) }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="错误信息">
|
||
{{ detail.session.errorMessage || "-" }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</el-card>
|
||
</el-col>
|
||
|
||
<el-col :lg="12" :sm="24">
|
||
<el-card class="surface-card" shadow="never">
|
||
<h3 class="section-title">时间轴说明</h3>
|
||
<p class="section-subtitle">时间轴以会话开始时间为零点,按层叠加关键事件和热度数据。</p>
|
||
|
||
<div class="legend-grid">
|
||
<button
|
||
v-for="item in [
|
||
{ key: 'session', label: '会话' },
|
||
{ key: 'segments', label: '分片' },
|
||
{ key: 'processing', label: '转码' },
|
||
{ key: 'danmaku', label: '弹幕' },
|
||
{ key: 'automation', label: '脚本 / Webhook' }
|
||
]"
|
||
:key="item.key"
|
||
type="button"
|
||
class="legend-chip"
|
||
:class="{ 'legend-chip--active': visibleLayers.includes(item.key) }"
|
||
@click="toggleLayerVisibility(item.key)"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="timeline-meta">
|
||
<span>锚点 {{ formatDate(detail.timeline.anchorAt) }}</span>
|
||
<span>总跨度 {{ formatDuration(detail.timeline.totalDurationSeconds) }}</span>
|
||
</div>
|
||
</el-card>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-card class="surface-card" shadow="never">
|
||
<div class="section-header">
|
||
<div>
|
||
<h3 class="section-title">整场事件时间轴</h3>
|
||
<p class="section-subtitle">用于快速判断这一场直播在什么时候开始、切片、转码、出错以及弹幕最热。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="timeline-shell">
|
||
<section v-if="visibleLayers.includes('session')" class="timeline-track">
|
||
<div class="timeline-track__label">会话</div>
|
||
<div class="timeline-track__body">
|
||
<div class="timeline-track__line"></div>
|
||
<el-tooltip
|
||
v-for="event in sessionEvents"
|
||
:key="event.id"
|
||
placement="top"
|
||
:content="formatEventTooltip(event)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="timeline-marker timeline-marker--session"
|
||
:style="toLeftStyle(event.offsetSeconds)"
|
||
></button>
|
||
</el-tooltip>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="visibleLayers.includes('segments')" class="timeline-track">
|
||
<div class="timeline-track__label">分片</div>
|
||
<div class="timeline-track__body">
|
||
<div class="timeline-track__line"></div>
|
||
<el-tooltip
|
||
v-for="segment in segmentItems"
|
||
:key="segment.recordTaskId"
|
||
placement="top"
|
||
:content="formatSegmentTooltip(segment)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="timeline-segment"
|
||
:class="`timeline-segment--${sessionStatusTagType(segment.status)}`"
|
||
:style="toSegmentStyle(segment)"
|
||
@click="openTaskDetail(segment.recordTaskId)"
|
||
>
|
||
#{{ segment.segmentIndex }}
|
||
</button>
|
||
</el-tooltip>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="visibleLayers.includes('processing')" class="timeline-track">
|
||
<div class="timeline-track__label">转码</div>
|
||
<div class="timeline-track__body">
|
||
<div class="timeline-track__line"></div>
|
||
<el-tooltip
|
||
v-for="event in processingEvents"
|
||
:key="event.id"
|
||
placement="top"
|
||
:content="formatEventTooltip(event)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="timeline-marker timeline-marker--processing"
|
||
:style="toLeftStyle(event.offsetSeconds)"
|
||
@click="openTaskDetail(event.recordTaskId)"
|
||
></button>
|
||
</el-tooltip>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="visibleLayers.includes('danmaku')" class="timeline-track">
|
||
<div class="timeline-track__label">弹幕</div>
|
||
<div class="timeline-track__body timeline-track__body--heat">
|
||
<div class="timeline-track__line"></div>
|
||
<el-tooltip
|
||
v-for="bucket in heatBuckets"
|
||
:key="`${bucket.recordTaskId}-${bucket.bucketStartedAt}`"
|
||
placement="top"
|
||
:content="formatHeatTooltip(bucket)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="timeline-heat"
|
||
:style="toHeatStyle(bucket)"
|
||
@click="openTaskDetail(bucket.recordTaskId)"
|
||
></button>
|
||
</el-tooltip>
|
||
<el-tooltip
|
||
v-for="event in danmakuEvents"
|
||
:key="event.id"
|
||
placement="top"
|
||
:content="formatEventTooltip(event)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="timeline-marker timeline-marker--danmaku"
|
||
:style="toLeftStyle(event.offsetSeconds)"
|
||
@click="openTaskDetail(event.recordTaskId)"
|
||
></button>
|
||
</el-tooltip>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="visibleLayers.includes('automation')" class="timeline-track">
|
||
<div class="timeline-track__label">脚本 / Webhook</div>
|
||
<div class="timeline-track__body">
|
||
<div class="timeline-track__line"></div>
|
||
<el-tooltip
|
||
v-for="event in automationEvents"
|
||
:key="event.id"
|
||
placement="top"
|
||
:content="formatEventTooltip(event)"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="timeline-marker timeline-marker--automation"
|
||
:style="toLeftStyle(event.offsetSeconds)"
|
||
@click="openTaskDetail(event.recordTaskId)"
|
||
></button>
|
||
</el-tooltip>
|
||
</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">每个分片仍然可以继续跳转到现有分片详情页。</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isMobile" class="segment-card-list">
|
||
<article v-for="row in detail.timeline.segments" :key="row.recordTaskId" class="segment-card">
|
||
<div class="segment-card__head">
|
||
<span>
|
||
<strong class="monospace">分片 #{{ row.segmentIndex }}</strong>
|
||
<small>{{ formatDuration(row.durationSeconds) }}</small>
|
||
</span>
|
||
<el-tag :type="sessionStatusTagType(row.status)">{{ taskStatusLabelMap[row.status] }}</el-tag>
|
||
</div>
|
||
|
||
<dl class="segment-card__facts">
|
||
<div class="segment-card__file">
|
||
<dt>文件</dt>
|
||
<dd class="monospace">{{ row.label || "-" }}</dd>
|
||
</div>
|
||
<div><dt>开始</dt><dd>{{ formatDate(row.startedAt) }}</dd></div>
|
||
<div><dt>结束</dt><dd>{{ formatDate(row.endedAt) }}</dd></div>
|
||
</dl>
|
||
|
||
<div class="segment-card__actions">
|
||
<el-button size="small" type="primary" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
||
<el-button
|
||
size="small"
|
||
plain
|
||
:disabled="row.status !== 4 && row.status !== 6"
|
||
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
|
||
>
|
||
弹幕回放
|
||
</el-button>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else class="table-scroll-shell">
|
||
<el-table :data="detail.timeline.segments" class="premium-table" table-layout="auto">
|
||
<el-table-column label="分片" width="90">
|
||
<template #default="{ row }">
|
||
<span class="monospace">#{{ row.segmentIndex }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">
|
||
<el-tag :type="sessionStatusTagType(row.status)">
|
||
{{ taskStatusLabelMap[row.status] }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="文件" min-width="320">
|
||
<template #default="{ row }">
|
||
<span class="monospace segment-label">{{ row.label || "-" }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="开始" width="180">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.startedAt) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="结束" width="180">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.endedAt) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="时长" width="100">
|
||
<template #default="{ row }">
|
||
{{ formatDuration(row.durationSeconds) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="220" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</el-button>
|
||
<el-button
|
||
size="small"
|
||
type="primary"
|
||
plain
|
||
:disabled="row.status !== 4 && row.status !== 6"
|
||
@click="openDanmakuReplay(row.recordTaskId, row.segmentIndex)"
|
||
>
|
||
弹幕回放
|
||
</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</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>
|
||
|
||
<div class="table-scroll-shell">
|
||
<el-table :data="detail.logs" class="premium-table detail-logs-table" table-layout="auto">
|
||
<el-table-column label="级别" width="100">
|
||
<template #default="{ row }">
|
||
<el-tag :type="logTagType(row.level)">
|
||
{{ logLevelLabelMap[row.level] }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="分类" width="120" prop="category" />
|
||
<el-table-column label="消息" min-width="260" prop="message" />
|
||
<el-table-column label="详情" min-width="320">
|
||
<template #default="{ row }">
|
||
<span class="monospace log-detail">{{ row.detail || "-" }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="时间" width="180">
|
||
<template #default="{ row }">
|
||
{{ formatDate(row.createdAt) }}
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</div>
|
||
</el-card>
|
||
</template>
|
||
|
||
<!-- Danmaku Replay Dialog -->
|
||
<el-dialog
|
||
v-model="danmakuDialogVisible"
|
||
:title="danmakuDialogTitle"
|
||
width="90%"
|
||
:close-on-click-modal="false"
|
||
@close="closeDanmakuDialog"
|
||
>
|
||
<div v-if="danmakuPreviewLoading" class="preview-empty">正在准备预览资源…</div>
|
||
<div v-else-if="danmakuPreviewMessage" class="preview-empty">{{ danmakuPreviewMessage }}</div>
|
||
<DanmakuPlayer
|
||
v-else-if="danmakuPreviewUrl"
|
||
:video-src="danmakuPreviewUrl"
|
||
:danmaku-events="replayDanmakuEvents"
|
||
/>
|
||
<div v-else class="preview-empty">无法加载该分片的预览。</div>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-stack {
|
||
display: grid;
|
||
gap: 24px;
|
||
}
|
||
|
||
.header-actions {
|
||
align-self: center;
|
||
}
|
||
|
||
.page-error-alert {
|
||
border-radius: 14px;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 18px;
|
||
padding-bottom: 18px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.legend-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.legend-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 36px;
|
||
padding: 0 14px;
|
||
border: 1px solid var(--border-base);
|
||
border-radius: 10px;
|
||
background: var(--surface);
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition:
|
||
border-color 0.18s ease,
|
||
background-color 0.18s ease,
|
||
color 0.18s ease;
|
||
}
|
||
|
||
.legend-chip--active {
|
||
border-color: rgba(47, 111, 180, 0.28);
|
||
background: var(--accent-soft);
|
||
color: var(--accent);
|
||
}
|
||
|
||
.timeline-meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
color: var(--text-secondary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.timeline-shell {
|
||
display: grid;
|
||
gap: 18px;
|
||
}
|
||
|
||
.timeline-track {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.timeline-track__label {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--text-primary);
|
||
}
|
||
|
||
.timeline-track__body {
|
||
position: relative;
|
||
min-height: 50px;
|
||
padding: 8px 0;
|
||
}
|
||
|
||
.timeline-track__body--heat {
|
||
min-height: 94px;
|
||
}
|
||
|
||
.timeline-track__line {
|
||
position: absolute;
|
||
inset: 50% 0 auto;
|
||
height: 2px;
|
||
transform: translateY(-50%);
|
||
background: var(--border-base);
|
||
}
|
||
|
||
.timeline-marker,
|
||
.timeline-segment,
|
||
.timeline-heat {
|
||
position: absolute;
|
||
border: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.timeline-marker {
|
||
top: 50%;
|
||
width: 12px;
|
||
height: 12px;
|
||
margin-left: -6px;
|
||
border-radius: 999px;
|
||
transform: translateY(-50%);
|
||
box-shadow: var(--shadow-soft);
|
||
}
|
||
|
||
.timeline-marker--session {
|
||
background: var(--accent);
|
||
}
|
||
|
||
.timeline-marker--processing {
|
||
background: var(--warning);
|
||
}
|
||
|
||
.timeline-marker--danmaku {
|
||
background: var(--success);
|
||
}
|
||
|
||
.timeline-marker--automation {
|
||
background: #7c5db0;
|
||
}
|
||
|
||
.timeline-segment {
|
||
top: 50%;
|
||
min-width: 14px;
|
||
height: 24px;
|
||
margin-top: -12px;
|
||
border-radius: 8px;
|
||
color: var(--text-inverse);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 24px;
|
||
text-align: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.timeline-segment--success {
|
||
background: var(--success);
|
||
}
|
||
|
||
.timeline-segment--danger {
|
||
background: var(--danger);
|
||
}
|
||
|
||
.timeline-segment--info {
|
||
background: var(--text-muted);
|
||
}
|
||
|
||
.timeline-segment--warning {
|
||
background: var(--warning);
|
||
}
|
||
|
||
.timeline-heat {
|
||
bottom: 0;
|
||
min-width: 6px;
|
||
border-radius: 8px 8px 0 0;
|
||
background: linear-gradient(180deg, rgba(53, 139, 109, 0.92), rgba(53, 139, 109, 0.48));
|
||
}
|
||
|
||
.segment-label,
|
||
.log-detail {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.segment-card-list {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.segment-card {
|
||
display: grid;
|
||
gap: 13px;
|
||
min-width: 0;
|
||
padding: 14px;
|
||
border: 1px solid var(--border-subtle);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--surface-muted);
|
||
}
|
||
|
||
.segment-card__head {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.segment-card__head > span {
|
||
display: grid;
|
||
min-width: 0;
|
||
gap: 4px;
|
||
}
|
||
|
||
.segment-card__head strong {
|
||
color: var(--text-primary);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.segment-card__head small {
|
||
color: var(--text-muted);
|
||
font-size: 10px;
|
||
}
|
||
|
||
.segment-card__facts {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px 12px;
|
||
margin: 0;
|
||
}
|
||
|
||
.segment-card__facts > div {
|
||
display: grid;
|
||
min-width: 0;
|
||
gap: 4px;
|
||
}
|
||
|
||
.segment-card__facts dt {
|
||
color: var(--text-muted);
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.segment-card__facts dd {
|
||
min-width: 0;
|
||
margin: 0;
|
||
overflow-wrap: anywhere;
|
||
color: var(--text-secondary);
|
||
font-size: 11px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.segment-card__file {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.segment-card__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-start;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
padding-top: 11px;
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
|
||
.segment-card__actions :deep(.el-button) {
|
||
flex: 0 0 auto;
|
||
margin: 0;
|
||
}
|
||
|
||
.segment-label,
|
||
.log-detail {
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.preview-empty {
|
||
display: grid;
|
||
place-items: center;
|
||
min-height: 260px;
|
||
padding: 24px;
|
||
border-radius: 12px;
|
||
border: 1px dashed var(--border-base);
|
||
color: var(--text-muted);
|
||
background: var(--surface-muted);
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.header-actions {
|
||
width: 100%;
|
||
justify-content: stretch;
|
||
}
|
||
|
||
.header-actions :deep(.el-button) {
|
||
flex: 1 1 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.preview-empty {
|
||
min-height: 180px;
|
||
padding: 18px;
|
||
}
|
||
}
|
||
</style>
|