feat: redesign responsive control room ui

This commit is contained in:
2026-04-26 02:18:03 +08:00
parent ff3e1fae70
commit 94f6e08dea
16 changed files with 4189 additions and 926 deletions
@@ -0,0 +1,714 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useViewport } from "@/composables/useViewport";
import apiClient, { getApiErrorMessage } from "@/api/client";
import type {
RecordSessionDetail,
RecordSessionTimelineEvent,
RecordSessionHeatBucket,
RecordSessionTimelineSegment
} from "@/types";
import {
logLevelLabelMap,
outputFormatLabelMap,
platformLabelMap,
saveModeLabelMap,
sessionStatusLabelMap,
taskStatusLabelMap
} from "@/types";
const props = defineProps<{
id: string;
}>();
const router = useRouter();
const { isMobile } = useViewport();
const loading = ref(false);
const loadError = ref("");
const detail = ref<RecordSessionDetail | null>(null);
const visibleLayers = ref(["session", "segments", "processing", "danmaku", "automation"]);
const logTableHeight = computed(() => (isMobile.value ? undefined : 360));
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;
}
}
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 class="header-actions">
<el-button @click="router.push({ name: 'record-tasks' })">返回列表</el-button>
<el-button @click="loadDetail">刷新</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)">
{{ sessionStatusLabelMap[detail.session.status] }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="直播间">
{{ detail.session.liveRoomTitle }}
</el-descriptions-item>
<el-descriptions-item label="平台">
{{ 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 class="monospace">{{ 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 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="120">
<template #default="{ row }">
<el-button size="small" @click="openTaskDetail(row.recordTaskId)">查看分片</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" :height="logTableHeight" class="premium-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>
</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);
}
@media (max-width: 768px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-button) {
flex: 1 1 0;
margin: 0;
}
}
</style>