feat: add transcode workspace and media browser

This commit is contained in:
2026-05-05 02:14:52 +08:00
parent f0f9ed3456
commit 56432a9ece
26 changed files with 2013 additions and 267 deletions
+330
View File
@@ -0,0 +1,330 @@
<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>