331 lines
8.9 KiB
Vue
331 lines
8.9 KiB
Vue
<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>
|