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

331 lines
8.9 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 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 || "平台目录");
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" />
<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" />
<el-empty v-else-if="browser && browser.items.length === 0" description="当前目录为空" />
<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;
}
.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>