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

953 lines
28 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, 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>