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

356 lines
10 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 } from "vue";
import { ElMessage } from "element-plus";
import { FolderOpened, VideoPlay, Document, RefreshRight } from "@element-plus/icons-vue";
import apiClient, { buildApiUrl, getApiErrorMessage } from "@/api/client";
import EmptyState from "@/components/ui/EmptyState.vue";
import MetricCard from "@/components/ui/MetricCard.vue";
import type { MediaBrowserItem, MediaBrowserResponse, TranscodeMediaFileResult } from "@/types";
const loading = ref(false);
const transcodePath = ref<string | null>(null);
const loadError = ref("");
const browser = ref<MediaBrowserResponse | null>(null);
const previewVisible = ref(false);
const previewTitle = ref("");
const previewUrl = ref("");
const currentPathLabel = computed(() => browser.value?.currentPath || "平台目录");
const directoryCount = computed(() => browser.value?.items.filter((item) => item.type === "directory").length ?? 0);
const mediaFileCount = computed(() => browser.value?.items.filter((item) => item.type !== "directory").length ?? 0);
const transcodeReadyCount = computed(() => browser.value?.items.filter((item) => item.canTranscode).length ?? 0);
async function loadDirectory(path = "") {
loading.value = true;
loadError.value = "";
try {
const { data } = await apiClient.get<MediaBrowserResponse>("/media/browser", {
params: { path }
});
browser.value = data;
} catch (error) {
loadError.value = getApiErrorMessage(error, "录制目录加载失败,请稍后重试。");
} finally {
loading.value = false;
}
}
function openDirectory(path: string) {
loadDirectory(path);
}
function refreshCurrentDirectory() {
loadDirectory(browser.value?.currentPath ?? "");
}
async function transcodeFile(item: MediaBrowserItem) {
transcodePath.value = item.relativePath;
try {
const { data } = await apiClient.post<TranscodeMediaFileResult>("/media/transcode-file", {
relativePath: item.relativePath
});
ElMessage[data.success ? "success" : "warning"](data.message);
} catch (error) {
ElMessage.error(getApiErrorMessage(error, "目录转码启动失败。"));
} finally {
transcodePath.value = null;
}
}
function previewVideo(item: MediaBrowserItem) {
previewTitle.value = item.name;
previewUrl.value = buildApiUrl("/media/file", { path: item.relativePath });
previewVisible.value = true;
}
function openFile(item: MediaBrowserItem, download = false) {
const url = buildApiUrl("/media/file", {
path: item.relativePath,
download: download ? "true" : undefined
});
window.open(url, "_blank", "noopener,noreferrer");
}
function iconForItem(item: MediaBrowserItem) {
switch (item.type) {
case "directory":
return FolderOpened;
case "mp4":
return VideoPlay;
default:
return Document;
}
}
function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
function formatFileSize(bytes?: number) {
if (typeof bytes !== "number") {
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 typeLabel(type: string) {
switch (type) {
case "directory":
return "目录";
case "mp4":
return "MP4";
case "ts":
return "TS";
case "xml":
return "XML";
default:
return "其他";
}
}
onMounted(() => {
loadDirectory();
});
</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">
从平台目录开始浏览录制文件这里可以直接预览 MP4查看 XML并对单个 .ts 文件发起转码
</p>
</div>
<el-space wrap class="header-actions">
<el-button :icon="RefreshRight" @click="refreshCurrentDirectory">刷新目录</el-button>
</el-space>
</div>
<el-alert v-if="loadError" class="page-error-alert" type="error" :closable="false" show-icon :title="loadError" />
<div class="stats-grid media-browser-metrics">
<MetricCard label="当前目录" :value="currentPathLabel" description="录制归档目录仅在后端允许的路径范围内浏览" />
<MetricCard label="子目录数" :value="directoryCount" description="当前目录下的可进入目录数量" :icon="FolderOpened" />
<MetricCard label="媒体文件" :value="mediaFileCount" description="当前目录下可直接预览或下载的文件数" :icon="Document" />
<MetricCard label="待转码" :value="transcodeReadyCount" description="当前目录下支持补转码为 MP4 的文件数量" :icon="VideoPlay" />
</div>
<el-card class="surface-card" shadow="never">
<div class="section-header">
<div>
<h3 class="section-title">{{ currentPathLabel }}</h3>
<p class="section-subtitle">目录浏览严格限制在录制输出根目录内不会暴露系统其它路径</p>
</div>
</div>
<div class="breadcrumb-row">
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="breadcrumb in browser?.breadcrumbs ?? []"
:key="breadcrumb.relativePath || 'root'"
>
<button class="breadcrumb-button" type="button" @click="openDirectory(breadcrumb.relativePath)">
{{ breadcrumb.label }}
</button>
</el-breadcrumb-item>
</el-breadcrumb>
<el-button
v-if="browser?.parentPath !== undefined && browser?.parentPath !== null"
size="small"
@click="openDirectory(browser?.parentPath || '')"
>
返回上级
</el-button>
</div>
<el-skeleton v-if="loading && !browser" animated :rows="8" />
<EmptyState
v-else-if="browser && browser.items.length === 0"
title="暂无数据"
description="当前筛选条件下没有可展示内容"
action-text="刷新目录"
@action="refreshCurrentDirectory"
/>
<div v-else-if="browser" class="table-scroll-shell">
<el-table :data="browser.items" class="premium-table" table-layout="auto">
<el-table-column label="名称" min-width="260">
<template #default="{ row }">
<div class="file-cell">
<el-icon class="file-cell__icon"><component :is="iconForItem(row)" /></el-icon>
<div>
<div class="cell-title">{{ row.name }}</div>
<div class="cell-subtitle">{{ row.relativePath }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="110">
<template #default="{ row }">
{{ typeLabel(row.type) }}
</template>
</el-table-column>
<el-table-column label="大小" width="120">
<template #default="{ row }">
{{ formatFileSize(row.sizeBytes) }}
</template>
</el-table-column>
<el-table-column label="修改时间" width="180">
<template #default="{ row }">
{{ formatDate(row.modifiedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" min-width="320" fixed="right">
<template #default="{ row }">
<div class="action-row">
<el-button v-if="row.type === 'directory'" size="small" @click="openDirectory(row.relativePath)">进入目录</el-button>
<el-button v-if="row.type === 'mp4'" size="small" @click="previewVideo(row)">预览视频</el-button>
<el-button v-if="row.type === 'xml'" size="small" @click="openFile(row)">查看 XML</el-button>
<el-button
v-if="row.type !== 'directory'"
size="small"
plain
@click="openFile(row, true)"
>
下载
</el-button>
<el-button
v-if="row.canTranscode"
size="small"
type="primary"
plain
:loading="transcodePath === row.relativePath"
@click="transcodeFile(row)"
>
转码为 MP4
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-card>
<el-dialog v-model="previewVisible" :title="previewTitle" width="min(980px, 92vw)" destroy-on-close>
<video v-if="previewUrl" class="preview-player" :src="previewUrl" controls preload="metadata" />
</el-dialog>
</div>
</template>
<style scoped>
.page-stack {
display: grid;
gap: 24px;
}
.header-actions {
align-self: center;
}
.page-error-alert {
border-radius: 14px;
}
.media-browser-metrics :deep(.metric-card__value) {
overflow: hidden;
font-size: clamp(22px, 1.8vw, 30px);
text-overflow: ellipsis;
white-space: nowrap;
}
.breadcrumb-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding-bottom: 18px;
border-bottom: 1px solid var(--border-subtle);
}
.breadcrumb-button {
padding: 0;
border: 0;
background: transparent;
color: var(--accent);
cursor: pointer;
}
.file-cell {
display: flex;
align-items: flex-start;
gap: 12px;
}
.file-cell__icon {
margin-top: 3px;
color: var(--accent);
}
.cell-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.cell-subtitle {
margin-top: 6px;
color: var(--text-secondary);
font-size: 13px;
overflow-wrap: anywhere;
}
.action-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.preview-player {
width: 100%;
max-height: 72vh;
border-radius: 12px;
background: #09121d;
}
@media (max-width: 720px) {
.header-actions {
width: 100%;
justify-content: stretch;
}
.header-actions :deep(.el-space__item) {
width: 100%;
}
.header-actions :deep(.el-button) {
width: 100%;
margin: 0;
}
.breadcrumb-row {
flex-direction: column;
align-items: flex-start;
}
}
</style>