|
|
@@ -0,0 +1,524 @@
|
|
|
+<template>
|
|
|
+ <div class="page-container">
|
|
|
+ <!-- 搜索区域 -->
|
|
|
+ <div class="search-form">
|
|
|
+ <el-form :model="queryParams" inline>
|
|
|
+ <el-form-item label="视频名称">
|
|
|
+ <el-input v-model="queryParams.search" placeholder="请输入视频名称" clearable @keyup.enter="handleQuery" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="状态">
|
|
|
+ <el-select v-model="queryParams.status" placeholder="请选择" clearable style="width: 120px">
|
|
|
+ <el-option label="就绪" value="ready" />
|
|
|
+ <el-option label="处理中" value="inprogress" />
|
|
|
+ <el-option label="排队中" value="queued" />
|
|
|
+ <el-option label="错误" value="error" />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item>
|
|
|
+ <el-button type="primary" :icon="Search" @click="handleQuery">搜索</el-button>
|
|
|
+ <el-button :icon="Refresh" @click="resetQuery">重置</el-button>
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 操作按钮 -->
|
|
|
+ <div class="table-actions">
|
|
|
+ <el-button type="primary" :icon="Upload" @click="showUploadDialog">上传视频</el-button>
|
|
|
+ <el-button type="success" :icon="Link" @click="showImportDialog">从URL导入</el-button>
|
|
|
+ <el-button :icon="Refresh" @click="getList">刷新列表</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 视频列表 -->
|
|
|
+ <el-table v-loading="loading" :data="videoList" border>
|
|
|
+ <el-table-column label="缩略图" width="160" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-image
|
|
|
+ :src="getThumbnailUrl(row.uid)"
|
|
|
+ :preview-src-list="[getThumbnailUrl(row.uid)]"
|
|
|
+ fit="cover"
|
|
|
+ style="width: 140px; height: 80px; border-radius: 4px;"
|
|
|
+ :preview-teleported="true"
|
|
|
+ >
|
|
|
+ <template #error>
|
|
|
+ <div class="image-error">
|
|
|
+ <el-icon><Picture /></el-icon>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </el-image>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="uid" label="Video ID" min-width="280" show-overflow-tooltip>
|
|
|
+ <template #default="{ row }">
|
|
|
+ <div class="video-info">
|
|
|
+ <span class="video-id">{{ row.uid }}</span>
|
|
|
+ <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(row.uid)" />
|
|
|
+ </div>
|
|
|
+ <div v-if="row.meta?.name" class="video-name">{{ row.meta.name }}</div>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="duration" label="时长" width="100" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ {{ formatDuration(row.duration) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="size" label="大小" width="100" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ {{ formatSize(row.size) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="分辨率" width="100" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <span v-if="row.input">{{ row.input.width }}x{{ row.input.height }}</span>
|
|
|
+ <span v-else>-</span>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="status" label="状态" width="100" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag :type="getStatusType(row.status?.state)">
|
|
|
+ {{ getStatusText(row.status?.state) }}
|
|
|
+ </el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="created" label="创建时间" width="170" align="center">
|
|
|
+ <template #default="{ row }">
|
|
|
+ {{ formatTime(row.created) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="操作" width="200" align="center" fixed="right">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button type="primary" link :icon="VideoPlay" @click="handlePlay(row)" :disabled="!row.readyToStream">
|
|
|
+ 播放
|
|
|
+ </el-button>
|
|
|
+ <el-button type="info" link :icon="View" @click="handleDetail(row)">
|
|
|
+ 详情
|
|
|
+ </el-button>
|
|
|
+ <el-button type="danger" link :icon="Delete" @click="handleDelete(row)">
|
|
|
+ 删除
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+
|
|
|
+ <!-- 分页 -->
|
|
|
+ <el-pagination
|
|
|
+ v-model:current-page="queryParams.pageNum"
|
|
|
+ v-model:page-size="queryParams.pageSize"
|
|
|
+ :page-sizes="[10, 20, 50, 100]"
|
|
|
+ :total="total"
|
|
|
+ layout="total, sizes, prev, pager, next, jumper"
|
|
|
+ class="pagination"
|
|
|
+ @size-change="getList"
|
|
|
+ @current-change="getList"
|
|
|
+ />
|
|
|
+
|
|
|
+ <!-- 上传弹窗 -->
|
|
|
+ <el-dialog v-model="uploadDialogVisible" title="上传视频" width="500px" destroy-on-close>
|
|
|
+ <el-upload
|
|
|
+ drag
|
|
|
+ :auto-upload="false"
|
|
|
+ :limit="1"
|
|
|
+ accept="video/*"
|
|
|
+ :on-change="handleFileChange"
|
|
|
+ >
|
|
|
+ <el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
|
|
+ <div class="el-upload__text">拖拽视频文件到此处,或 <em>点击上传</em></div>
|
|
|
+ <template #tip>
|
|
|
+ <div class="el-upload__tip">支持 MP4、MOV、MKV、AVI 等常见视频格式</div>
|
|
|
+ </template>
|
|
|
+ </el-upload>
|
|
|
+ <el-form v-if="uploadFile" :model="uploadForm" label-width="80px" style="margin-top: 20px;">
|
|
|
+ <el-form-item label="视频名称">
|
|
|
+ <el-input v-model="uploadForm.name" placeholder="请输入视频名称" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <el-progress v-if="uploadProgress > 0" :percentage="uploadProgress" :status="uploadProgress === 100 ? 'success' : undefined" />
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="uploadDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="uploading" :disabled="!uploadFile" @click="handleUpload">
|
|
|
+ 上传
|
|
|
+ </el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- URL导入弹窗 -->
|
|
|
+ <el-dialog v-model="importDialogVisible" title="从URL导入视频" width="500px">
|
|
|
+ <el-form :model="importForm" label-width="80px">
|
|
|
+ <el-form-item label="视频URL" required>
|
|
|
+ <el-input v-model="importForm.url" placeholder="请输入视频URL" />
|
|
|
+ </el-form-item>
|
|
|
+ <el-form-item label="视频名称">
|
|
|
+ <el-input v-model="importForm.name" placeholder="请输入视频名称(可选)" />
|
|
|
+ </el-form-item>
|
|
|
+ </el-form>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="importDialogVisible = false">取消</el-button>
|
|
|
+ <el-button type="primary" :loading="importing" @click="handleImport">导入</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 详情弹窗 -->
|
|
|
+ <el-dialog v-model="detailDialogVisible" title="视频详情" width="700px">
|
|
|
+ <el-descriptions :column="2" border v-if="currentVideo">
|
|
|
+ <el-descriptions-item label="Video ID" :span="2">
|
|
|
+ {{ currentVideo.uid }}
|
|
|
+ <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.uid)" />
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="状态">
|
|
|
+ <el-tag :type="getStatusType(currentVideo.status?.state)">
|
|
|
+ {{ getStatusText(currentVideo.status?.state) }}
|
|
|
+ </el-tag>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="时长">{{ formatDuration(currentVideo.duration) }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="大小">{{ formatSize(currentVideo.size) }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="分辨率">
|
|
|
+ {{ currentVideo.input?.width }}x{{ currentVideo.input?.height }}
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="创建时间" :span="2">{{ formatTime(currentVideo.created) }}</el-descriptions-item>
|
|
|
+ <el-descriptions-item label="HLS 地址" :span="2">
|
|
|
+ <div class="url-item">
|
|
|
+ <span class="url-text">{{ currentVideo.playback?.hls }}</span>
|
|
|
+ <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.playback?.hls)" />
|
|
|
+ </div>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="DASH 地址" :span="2">
|
|
|
+ <div class="url-item">
|
|
|
+ <span class="url-text">{{ currentVideo.playback?.dash }}</span>
|
|
|
+ <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.playback?.dash)" />
|
|
|
+ </div>
|
|
|
+ </el-descriptions-item>
|
|
|
+ <el-descriptions-item label="预览地址" :span="2">
|
|
|
+ <div class="url-item">
|
|
|
+ <span class="url-text">{{ currentVideo.preview }}</span>
|
|
|
+ <el-button link type="primary" :icon="CopyDocument" @click="copyToClipboard(currentVideo.preview)" />
|
|
|
+ </div>
|
|
|
+ </el-descriptions-item>
|
|
|
+ </el-descriptions>
|
|
|
+ </el-dialog>
|
|
|
+
|
|
|
+ <!-- 播放弹窗 -->
|
|
|
+ <el-dialog v-model="playDialogVisible" title="视频播放" width="900px" destroy-on-close>
|
|
|
+ <div class="player-container">
|
|
|
+ <VideoPlayer
|
|
|
+ v-if="playDialogVisible && playVideoId"
|
|
|
+ :player-type="'hls'"
|
|
|
+ :src="getHlsUrl(playVideoId)"
|
|
|
+ :autoplay="true"
|
|
|
+ :controls="true"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </el-dialog>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, reactive, onMounted } from 'vue'
|
|
|
+import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
|
|
|
+import {
|
|
|
+ Search, Refresh, Upload, Link, VideoPlay, View, Delete,
|
|
|
+ CopyDocument, Picture, UploadFilled
|
|
|
+} from '@element-plus/icons-vue'
|
|
|
+import VideoPlayer from '@/components/VideoPlayer.vue'
|
|
|
+import { useStreamStore } from '@/store/stream'
|
|
|
+import type { CloudflareVideo } from '@/types/cloudflare'
|
|
|
+
|
|
|
+const streamStore = useStreamStore()
|
|
|
+
|
|
|
+const loading = ref(false)
|
|
|
+const videoList = ref<CloudflareVideo[]>([])
|
|
|
+const total = ref(0)
|
|
|
+
|
|
|
+const queryParams = reactive({
|
|
|
+ pageNum: 1,
|
|
|
+ pageSize: 10,
|
|
|
+ search: '',
|
|
|
+ status: ''
|
|
|
+})
|
|
|
+
|
|
|
+// 上传相关
|
|
|
+const uploadDialogVisible = ref(false)
|
|
|
+const uploadFile = ref<File | null>(null)
|
|
|
+const uploadForm = reactive({ name: '' })
|
|
|
+const uploading = ref(false)
|
|
|
+const uploadProgress = ref(0)
|
|
|
+
|
|
|
+// 导入相关
|
|
|
+const importDialogVisible = ref(false)
|
|
|
+const importForm = reactive({ url: '', name: '' })
|
|
|
+const importing = ref(false)
|
|
|
+
|
|
|
+// 详情相关
|
|
|
+const detailDialogVisible = ref(false)
|
|
|
+const currentVideo = ref<CloudflareVideo | null>(null)
|
|
|
+
|
|
|
+// 播放相关
|
|
|
+const playDialogVisible = ref(false)
|
|
|
+const playVideoId = ref('')
|
|
|
+
|
|
|
+function getThumbnailUrl(videoId: string): string {
|
|
|
+ return streamStore.getThumbnailUrl(videoId, { width: 280, height: 160 })
|
|
|
+}
|
|
|
+
|
|
|
+function getHlsUrl(videoId: string): string {
|
|
|
+ return streamStore.getHlsUrl(videoId)
|
|
|
+}
|
|
|
+
|
|
|
+function formatDuration(seconds: number): string {
|
|
|
+ if (!seconds) return '-'
|
|
|
+ const h = Math.floor(seconds / 3600)
|
|
|
+ const m = Math.floor((seconds % 3600) / 60)
|
|
|
+ const s = Math.floor(seconds % 60)
|
|
|
+ if (h > 0) {
|
|
|
+ return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
|
+ }
|
|
|
+ return `${m}:${s.toString().padStart(2, '0')}`
|
|
|
+}
|
|
|
+
|
|
|
+function formatSize(bytes: number): string {
|
|
|
+ if (!bytes) return '-'
|
|
|
+ const units = ['B', 'KB', 'MB', 'GB']
|
|
|
+ let i = 0
|
|
|
+ while (bytes >= 1024 && i < units.length - 1) {
|
|
|
+ bytes /= 1024
|
|
|
+ i++
|
|
|
+ }
|
|
|
+ return `${bytes.toFixed(1)} ${units[i]}`
|
|
|
+}
|
|
|
+
|
|
|
+function formatTime(time: string): string {
|
|
|
+ if (!time) return '-'
|
|
|
+ return new Date(time).toLocaleString('zh-CN')
|
|
|
+}
|
|
|
+
|
|
|
+function getStatusType(state: string): string {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ ready: 'success',
|
|
|
+ inprogress: 'warning',
|
|
|
+ queued: 'info',
|
|
|
+ error: 'danger'
|
|
|
+ }
|
|
|
+ return map[state] || 'info'
|
|
|
+}
|
|
|
+
|
|
|
+function getStatusText(state: string): string {
|
|
|
+ const map: Record<string, string> = {
|
|
|
+ ready: '就绪',
|
|
|
+ inprogress: '处理中',
|
|
|
+ queued: '排队中',
|
|
|
+ downloading: '下载中',
|
|
|
+ pendingupload: '待上传',
|
|
|
+ error: '错误'
|
|
|
+ }
|
|
|
+ return map[state] || state || '-'
|
|
|
+}
|
|
|
+
|
|
|
+async function copyToClipboard(text: string) {
|
|
|
+ if (!text) return
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(text)
|
|
|
+ ElMessage.success('已复制到剪贴板')
|
|
|
+ } catch {
|
|
|
+ ElMessage.error('复制失败')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function getList() {
|
|
|
+ loading.value = true
|
|
|
+ try {
|
|
|
+ // 模拟数据,实际项目中调用 API
|
|
|
+ // const res = await listVideos(queryParams)
|
|
|
+ // videoList.value = res.data.rows
|
|
|
+ // total.value = res.data.total
|
|
|
+
|
|
|
+ // 示例数据
|
|
|
+ videoList.value = []
|
|
|
+ total.value = 0
|
|
|
+ ElMessage.info('请配置 Cloudflare Stream 并连接后端 API')
|
|
|
+ } finally {
|
|
|
+ loading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleQuery() {
|
|
|
+ queryParams.pageNum = 1
|
|
|
+ getList()
|
|
|
+}
|
|
|
+
|
|
|
+function resetQuery() {
|
|
|
+ queryParams.pageNum = 1
|
|
|
+ queryParams.search = ''
|
|
|
+ queryParams.status = ''
|
|
|
+ getList()
|
|
|
+}
|
|
|
+
|
|
|
+function showUploadDialog() {
|
|
|
+ uploadFile.value = null
|
|
|
+ uploadForm.name = ''
|
|
|
+ uploadProgress.value = 0
|
|
|
+ uploadDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function showImportDialog() {
|
|
|
+ importForm.url = ''
|
|
|
+ importForm.name = ''
|
|
|
+ importDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function handleFileChange(file: UploadFile) {
|
|
|
+ uploadFile.value = file.raw || null
|
|
|
+ if (uploadFile.value) {
|
|
|
+ uploadForm.name = file.name.replace(/\.[^/.]+$/, '')
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handleUpload() {
|
|
|
+ if (!uploadFile.value) return
|
|
|
+
|
|
|
+ uploading.value = true
|
|
|
+ uploadProgress.value = 0
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. 获取上传 URL
|
|
|
+ // const urlRes = await getUploadUrl({ meta: { name: uploadForm.name } })
|
|
|
+ // const { uploadURL, uid } = urlRes.data
|
|
|
+
|
|
|
+ // 2. 使用 TUS 协议上传
|
|
|
+ // await streamApi.uploadWithTus(uploadFile.value, uploadURL, (progress) => {
|
|
|
+ // uploadProgress.value = progress
|
|
|
+ // })
|
|
|
+
|
|
|
+ ElMessage.success('上传成功')
|
|
|
+ uploadDialogVisible.value = false
|
|
|
+ getList()
|
|
|
+ } catch (error) {
|
|
|
+ console.error('上传失败:', error)
|
|
|
+ ElMessage.error('上传失败')
|
|
|
+ } finally {
|
|
|
+ uploading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function handleImport() {
|
|
|
+ if (!importForm.url) {
|
|
|
+ ElMessage.warning('请输入视频URL')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ importing.value = true
|
|
|
+ try {
|
|
|
+ // await importVideoFromUrl({
|
|
|
+ // url: importForm.url,
|
|
|
+ // name: importForm.name,
|
|
|
+ // meta: { name: importForm.name }
|
|
|
+ // })
|
|
|
+ ElMessage.success('导入成功,视频正在处理中')
|
|
|
+ importDialogVisible.value = false
|
|
|
+ getList()
|
|
|
+ } catch (error) {
|
|
|
+ console.error('导入失败:', error)
|
|
|
+ ElMessage.error('导入失败')
|
|
|
+ } finally {
|
|
|
+ importing.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handlePlay(row: CloudflareVideo) {
|
|
|
+ playVideoId.value = row.uid
|
|
|
+ playDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+function handleDetail(row: CloudflareVideo) {
|
|
|
+ currentVideo.value = row
|
|
|
+ detailDialogVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
+async function handleDelete(row: CloudflareVideo) {
|
|
|
+ try {
|
|
|
+ await ElMessageBox.confirm(`确定要删除视频 "${row.meta?.name || row.uid}" 吗?`, '提示', {
|
|
|
+ type: 'warning'
|
|
|
+ })
|
|
|
+ // await deleteVideo(row.uid)
|
|
|
+ ElMessage.success('删除成功')
|
|
|
+ getList()
|
|
|
+ } catch (error) {
|
|
|
+ if (error !== 'cancel') {
|
|
|
+ console.error('删除失败:', error)
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ streamStore.loadConfig()
|
|
|
+ getList()
|
|
|
+})
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.page-container {
|
|
|
+ padding: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.search-form {
|
|
|
+ margin-bottom: 20px;
|
|
|
+ padding: 20px;
|
|
|
+ background-color: #fff;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+
|
|
|
+.table-actions {
|
|
|
+ margin-bottom: 15px;
|
|
|
+}
|
|
|
+
|
|
|
+.pagination {
|
|
|
+ margin-top: 20px;
|
|
|
+ justify-content: flex-end;
|
|
|
+}
|
|
|
+
|
|
|
+.video-info {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
+
|
|
|
+ .video-id {
|
|
|
+ font-family: monospace;
|
|
|
+ font-size: 12px;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.video-name {
|
|
|
+ margin-top: 4px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+
|
|
|
+.image-error {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 140px;
|
|
|
+ height: 80px;
|
|
|
+ background-color: #f5f7fa;
|
|
|
+ color: #c0c4cc;
|
|
|
+ font-size: 24px;
|
|
|
+}
|
|
|
+
|
|
|
+.url-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+
|
|
|
+ .url-text {
|
|
|
+ flex: 1;
|
|
|
+ font-size: 12px;
|
|
|
+ word-break: break-all;
|
|
|
+ color: #606266;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.player-container {
|
|
|
+ width: 100%;
|
|
|
+ height: 480px;
|
|
|
+ background-color: #000;
|
|
|
+ border-radius: 4px;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+</style>
|