feat: 实现视频分片上传与断点续传

- 后端:init/upload/status/complete 四个分片上传接口
- 后端:基于 Redis 的上传会话管理,支持断点续传
- 后端:分片 MD5 校验、幂等上传、顺序合并
- 测试:覆盖完整上传流程、断点续传、幂等性、哈希校验、未完成合并、状态查询
- 前端:Vue 3 分片上传组件,支持并发上传与进度展示
This commit is contained in:
yiyiis
2026-05-11 20:49:55 +08:00
parent 2d3521dec1
commit 6487f03c4a
10 changed files with 1461 additions and 376 deletions

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"pinia": "^3.0.4",
"spark-md5": "^3.0.2",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
@@ -1173,6 +1174,12 @@
"node": ">=0.10.0"
}
},
"node_modules/spark-md5": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/spark-md5/-/spark-md5-3.0.2.tgz",
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==",
"license": "(WTFPL OR MIT)"
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"pinia": "^3.0.4",
"spark-md5": "^3.0.2",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},

View File

@@ -1,30 +1,68 @@
import { postForm, postJson } from './client'
import { normalizeVideoList } from './normalize'
import type { Video } from './types'
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
return postJson<Video>('/video/publish', input, { authRequired: true })
}
export type UploadResponse = { url: string; play_url?: string; cover_url?: string }
export function uploadVideo(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
}
export function uploadCover(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
}
export async function listByAuthorId(authorId: number) {
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
return normalizeVideoList(videos)
}
export function getDetail(id: number) {
return postJson<Video>('/video/getDetail', { id })
}
import { postForm, postJson } from './client'
import { normalizeVideoList } from './normalize'
import type { Video } from './types'
export function publishVideo(input: { title: string; description: string; play_url: string; cover_url: string }) {
return postJson<Video>('/video/publish', input, { authRequired: true })
}
export type UploadResponse = { url: string; play_url?: string; cover_url?: string }
export function uploadVideo(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<UploadResponse>('/video/uploadVideo', fd, { authRequired: true })
}
export function uploadCover(file: File) {
const fd = new FormData()
fd.append('file', file)
return postForm<UploadResponse>('/video/uploadCover', fd, { authRequired: true })
}
export async function listByAuthorId(authorId: number) {
const videos = await postJson<Video[] | null>('/video/listByAuthorID', { author_id: authorId })
return normalizeVideoList(videos)
}
export function getDetail(id: number) {
return postJson<Video>('/video/getDetail', { id })
}
// --- Chunk Upload API ---
export type InitChunkUploadResponse = {
upload_id: string
uploaded_chunks: number[]
}
export function initChunkUpload(input: {
filename: string
file_size: number
chunk_size: number
total_chunks: number
file_hash: string
}) {
return postJson<InitChunkUploadResponse>('/video/chunk/init', input, { authRequired: true })
}
export function uploadChunk(uploadId: string, chunkIndex: number, chunkHash: string, blob: Blob) {
const fd = new FormData()
fd.append('upload_id', uploadId)
fd.append('chunk_index', String(chunkIndex))
fd.append('chunk_hash', chunkHash)
fd.append('file', blob)
return postForm<{ chunk_index: number }>('/video/chunk/upload', fd, { authRequired: true })
}
export function chunkStatus(uploadId: string) {
return postJson<{ upload_id: string; uploaded_chunks: number[]; total_chunks: number }>(
'/video/chunk/status',
{ upload_id: uploadId },
{ authRequired: true },
)
}
export function completeChunkUpload(uploadId: string) {
return postJson<UploadResponse>('/video/chunk/complete', { upload_id: uploadId }, { authRequired: true })
}

33
frontend/src/types/spark-md5.d.ts vendored Normal file
View File

@@ -0,0 +1,33 @@
declare module 'spark-md5' {
class SparkMD5 {
append(str: string): SparkMD5
end(raw?: boolean): string
reset(): SparkMD5
getState(): SparkMD5.State
setState(state: SparkMD5.State): SparkMD5
destroy(): void
static hash(str: string, raw?: boolean): string
static hashArray(arr: ArrayLike<number>, raw?: boolean): string
}
namespace SparkMD5 {
interface State {
buff: Uint8Array
length: number
hash: number[]
}
class ArrayBuffer {
append(arr: globalThis.ArrayBuffer): ArrayBuffer
end(raw?: boolean): string
reset(): ArrayBuffer
getState(): State
setState(state: State): ArrayBuffer
destroy(): void
static hash(arr: globalThis.ArrayBuffer, raw?: boolean): string
}
}
export default SparkMD5
export { SparkMD5 }
}

View File

@@ -1,346 +1,517 @@
<script setup lang="ts">
import { onUnmounted, reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as videoApi from '../api/video'
import type { Video } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
const busy = ref(false)
const stage = ref('')
const published = ref<Video | null>(null)
const videoInput = ref<HTMLInputElement | null>(null)
const coverInput = ref<HTMLInputElement | null>(null)
const publishForm = reactive({
title: '',
description: '',
video: null as File | null,
cover: null as File | null,
})
const preview = reactive({
videoUrl: '',
coverUrl: '',
})
function setPreviewVideo(file: File | null) {
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
preview.videoUrl = file ? URL.createObjectURL(file) : ''
}
function setPreviewCover(file: File | null) {
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
preview.coverUrl = file ? URL.createObjectURL(file) : ''
}
watch(
() => publishForm.video,
(f) => setPreviewVideo(f),
)
watch(
() => publishForm.cover,
(f) => setPreviewCover(f),
)
onUnmounted(() => {
setPreviewVideo(null)
setPreviewCover(null)
})
function pickVideo(e: Event) {
const input = e.target as HTMLInputElement
publishForm.video = input.files?.[0] ?? null
}
function pickCover(e: Event) {
const input = e.target as HTMLInputElement
publishForm.cover = input.files?.[0] ?? null
}
function openVideoPicker() {
videoInput.value?.click()
}
function openCoverPicker() {
coverInput.value?.click()
}
function clearVideo() {
publishForm.video = null
if (videoInput.value) videoInput.value.value = ''
}
function clearCover() {
publishForm.cover = null
if (coverInput.value) coverInput.value.value = ''
}
async function onPublish() {
if (busy.value) return
if (!auth.isLoggedIn) {
toast.error('请先登录')
await router.push('/account')
return
}
const title = publishForm.title.trim()
const description = publishForm.description.trim()
if (!title) {
toast.error('请输入 title')
return
}
if (!publishForm.video) {
toast.error('请选择视频文件(.mp4')
return
}
if (!publishForm.cover) {
toast.error('请选择封面图片jpg/png/webp')
return
}
busy.value = true
stage.value = ''
published.value = null
try {
stage.value = '上传封面'
const coverRes = await videoApi.uploadCover(publishForm.cover!)
stage.value = '上传视频'
const videoRes = await videoApi.uploadVideo(publishForm.video!)
const coverUrl = coverRes.url || coverRes.cover_url || ''
const playUrl = videoRes.url || videoRes.play_url || ''
if (!coverUrl || !playUrl) {
toast.error('上传成功但缺少 url')
return
}
stage.value = '发布视频'
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
published.value = res
toast.success('已发布')
publishForm.title = ''
publishForm.description = ''
clearVideo()
clearCover()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
stage.value = ''
}
}
</script>
<template>
<AppShell>
<div class="publish-wrap">
<div class="card publish-card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<p class="title" style="margin: 0">发布视频</p>
<div v-if="busy" class="pill">进行中{{ stage || '' }}</div>
</div>
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片上传到本机后自动生成 URL再写入 `/video/publish`</p>
<div class="grid form-grid" style="margin-top: 16px">
<div>
<label>title</label>
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
</div>
<div>
<label>description</label>
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
</div>
<div class="grid two">
<div>
<label>video (.mp4)</label>
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
<div class="file-box">
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
</div>
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
</div>
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
已选择{{ publishForm.video.name }}{{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB
</div>
</div>
<div>
<label>cover (jpg/png/webp)</label>
<input
ref="coverInput"
class="file-native"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="busy"
@change="pickCover"
/>
<div class="file-box">
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
</div>
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
</div>
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择{{ publishForm.cover.name }}</div>
</div>
</div>
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
<div v-if="preview.coverUrl" class="preview-card">
<div class="subtle">封面预览</div>
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
</div>
<div v-if="preview.videoUrl" class="preview-card">
<div class="subtle">视频预览</div>
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
</div>
</div>
<div class="row" style="justify-content: flex-end; margin-top: 8px">
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
</div>
</div>
<div v-if="published" class="card" style="margin-top: 14px">
<p class="title">已发布</p>
<div class="row" style="justify-content: space-between">
<div>
<div class="title" style="margin: 0">{{ published.title }}</div>
<div class="subtle mono">#{{ published.id }}</div>
</div>
<div class="row">
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
</div>
</div>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.publish-wrap {
display: grid;
justify-items: center;
}
.publish-card {
width: min(980px, 100%);
padding: 22px;
}
.form-grid {
gap: 16px;
}
.form-grid .grid.two {
gap: 20px;
}
.form-grid .grid.two > * {
min-width: 0;
}
.form-grid input[type='file'] {
max-width: 100%;
}
.file-native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.file-box {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
border-radius: 14px;
min-height: 46px;
}
.file-box button {
padding: 8px 10px;
border-radius: 12px;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.88);
}
.muted {
color: rgba(255, 255, 255, 0.55);
}
.big-input {
box-sizing: border-box;
width: 100%;
max-width: 100%;
padding: 12px 14px;
font-size: 14px;
border-radius: 14px;
}
.big-btn {
padding: 12px 18px;
font-size: 14px;
border-radius: 14px;
}
.preview-card {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 12px;
display: grid;
gap: 10px;
}
.cover {
width: 100%;
aspect-ratio: 9/12;
object-fit: cover;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
.video {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
</style>
<script setup lang="ts">
import { onUnmounted, reactive, ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import AppShell from '../components/AppShell.vue'
import { ApiError } from '../api/client'
import * as videoApi from '../api/video'
import type { Video } from '../api/types'
import { useAuthStore } from '../stores/auth'
import { useToastStore } from '../stores/toast'
import SparkMD5 from 'spark-md5'
const router = useRouter()
const auth = useAuthStore()
const toast = useToastStore()
const busy = ref(false)
const stage = ref('')
const published = ref<Video | null>(null)
const videoInput = ref<HTMLInputElement | null>(null)
const coverInput = ref<HTMLInputElement | null>(null)
const publishForm = reactive({
title: '',
description: '',
video: null as File | null,
cover: null as File | null,
})
const preview = reactive({
videoUrl: '',
coverUrl: '',
})
// chunk upload progress
const uploadProgress = reactive({
uploadedBytes: 0,
totalBytes: 0,
percent: 0,
})
function setPreviewVideo(file: File | null) {
if (preview.videoUrl) URL.revokeObjectURL(preview.videoUrl)
preview.videoUrl = file ? URL.createObjectURL(file) : ''
}
function setPreviewCover(file: File | null) {
if (preview.coverUrl) URL.revokeObjectURL(preview.coverUrl)
preview.coverUrl = file ? URL.createObjectURL(file) : ''
}
watch(
() => publishForm.video,
(f) => setPreviewVideo(f),
)
watch(
() => publishForm.cover,
(f) => setPreviewCover(f),
)
onUnmounted(() => {
setPreviewVideo(null)
setPreviewCover(null)
})
function pickVideo(e: Event) {
const input = e.target as HTMLInputElement
publishForm.video = input.files?.[0] ?? null
}
function pickCover(e: Event) {
const input = e.target as HTMLInputElement
publishForm.cover = input.files?.[0] ?? null
}
function openVideoPicker() {
videoInput.value?.click()
}
function openCoverPicker() {
coverInput.value?.click()
}
function clearVideo() {
publishForm.video = null
if (videoInput.value) videoInput.value.value = ''
}
function clearCover() {
publishForm.cover = null
if (coverInput.value) coverInput.value.value = ''
}
function resetProgress() {
uploadProgress.uploadedBytes = 0
uploadProgress.totalBytes = 0
uploadProgress.percent = 0
}
// Compute file md5 by reading in 2MB chunks
async function computeFileMD5(file: File): Promise<string> {
const chunkSize = 2 << 20
const spark = new SparkMD5.ArrayBuffer()
for (let offset = 0; offset < file.size; offset += chunkSize) {
const end = Math.min(offset + chunkSize, file.size)
const buf = await file.slice(offset, end).arrayBuffer()
spark.append(buf)
}
return spark.end()
}
// Compute md5 for a single chunk blob
async function computeChunkMD5(blob: Blob): Promise<string> {
const buf = await blob.arrayBuffer()
const spark = new SparkMD5.ArrayBuffer()
spark.append(buf)
return spark.end()
}
const CHUNK_SIZE = 5 << 20 // 5 MB
const MAX_CONCURRENT = 3
const MAX_RETRIES = 3
async function uploadVideoChunked(file: File): Promise<videoApi.UploadResponse> {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
const fileHash = await computeFileMD5(file)
stage.value = '初始化上传'
const initRes = await videoApi.initChunkUpload({
filename: file.name,
file_size: file.size,
chunk_size: CHUNK_SIZE,
total_chunks: totalChunks,
file_hash: fileHash,
})
const uploadId = initRes.upload_id
const uploadedSet = new Set(initRes.uploaded_chunks)
uploadProgress.totalBytes = file.size
uploadProgress.uploadedBytes = uploadedSet.size * CHUNK_SIZE
// Last chunk might be smaller
if (uploadedSet.has(totalChunks - 1)) {
uploadProgress.uploadedBytes -= CHUNK_SIZE
uploadProgress.uploadedBytes += file.size - (totalChunks - 1) * CHUNK_SIZE
}
uploadProgress.percent = uploadProgress.totalBytes > 0
? Math.round((uploadProgress.uploadedBytes / uploadProgress.totalBytes) * 100)
: 0
// Build list of chunks that still need uploading
const pending: number[] = []
for (let i = 0; i < totalChunks; i++) {
if (!uploadedSet.has(i)) {
pending.push(i)
}
}
if (pending.length === 0) {
stage.value = '合并文件'
return videoApi.completeChunkUpload(uploadId)
}
stage.value = '上传视频'
// Upload chunks with concurrency limit
let idx = 0
const advanceProgress = (chunkIndex: number) => {
const chunkBytes = chunkIndex === totalChunks - 1
? file.size - chunkIndex * CHUNK_SIZE
: CHUNK_SIZE
uploadProgress.uploadedBytes += chunkBytes
uploadProgress.percent = Math.round((uploadProgress.uploadedBytes / uploadProgress.totalBytes) * 100)
}
const uploadOne = async (chunkIndex: number): Promise<void> => {
const start = chunkIndex * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const blob = file.slice(start, end)
const chunkHash = await computeChunkMD5(blob)
let lastErr: unknown
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
await videoApi.uploadChunk(uploadId, chunkIndex, chunkHash, blob)
advanceProgress(chunkIndex)
return
} catch (e) {
lastErr = e
}
}
throw lastErr
}
await new Promise<void>((resolve, reject) => {
let active = 0
let done = false
const next = () => {
if (done) return
if (idx >= pending.length && active === 0) {
resolve()
return
}
while (active < MAX_CONCURRENT && idx < pending.length) {
const ci = pending[idx++] as number
active++
uploadOne(ci)
.then(() => { active--; next() })
.catch((e) => { done = true; reject(e) })
}
}
next()
})
stage.value = '合并文件'
return videoApi.completeChunkUpload(uploadId)
}
async function onPublish() {
if (busy.value) return
if (!auth.isLoggedIn) {
toast.error('请先登录')
await router.push('/account')
return
}
const title = publishForm.title.trim()
const description = publishForm.description.trim()
if (!title) {
toast.error('请输入 title')
return
}
if (!publishForm.video) {
toast.error('请选择视频文件(.mp4')
return
}
if (!publishForm.cover) {
toast.error('请选择封面图片jpg/png/webp')
return
}
busy.value = true
stage.value = ''
published.value = null
resetProgress()
try {
const videoRes = await uploadVideoChunked(publishForm.video!)
stage.value = '上传封面'
const coverRes = await videoApi.uploadCover(publishForm.cover!)
const coverUrl = coverRes.url || coverRes.cover_url || ''
const playUrl = videoRes.url || videoRes.play_url || ''
if (!coverUrl || !playUrl) {
toast.error('上传成功但缺少 url')
return
}
stage.value = '发布视频'
const res = await videoApi.publishVideo({ title, description, play_url: playUrl, cover_url: coverUrl })
published.value = res
toast.success('已发布')
publishForm.title = ''
publishForm.description = ''
clearVideo()
clearCover()
} catch (e) {
const msg = e instanceof ApiError ? e.message : String(e)
toast.error(msg)
} finally {
busy.value = false
stage.value = ''
resetProgress()
}
}
</script>
<template>
<AppShell>
<div class="publish-wrap">
<div class="card publish-card">
<div class="row" style="justify-content: space-between; align-items: baseline">
<p class="title" style="margin: 0">发布视频</p>
<div v-if="busy" class="pill">进行中{{ stage || '' }}</div>
</div>
<p class="subtle" style="margin-top: 10px">选择视频文件与封面图片上传到本机后自动生成 URL再写入 `/video/publish`</p>
<div class="grid form-grid" style="margin-top: 16px">
<div>
<label>title</label>
<input v-model.trim="publishForm.title" class="big-input" :disabled="busy" />
</div>
<div>
<label>description</label>
<textarea v-model.trim="publishForm.description" class="big-input" :disabled="busy" />
</div>
<div class="grid two">
<div>
<label>video (.mp4)</label>
<input ref="videoInput" class="file-native" type="file" accept="video/mp4" :disabled="busy" @change="pickVideo" />
<div class="file-box">
<button type="button" :disabled="busy" @click="openVideoPicker">选择视频</button>
<div class="file-name" :class="publishForm.video ? '' : 'muted'">
{{ publishForm.video ? publishForm.video.name : '未选择文件' }}
</div>
<button v-if="publishForm.video" type="button" :disabled="busy" @click="clearVideo">清除</button>
</div>
<div v-if="publishForm.video" class="subtle" style="margin-top: 6px">
已选择{{ publishForm.video.name }}{{ Math.ceil(publishForm.video.size / 1024 / 1024) }} MB
</div>
</div>
<div>
<label>cover (jpg/png/webp)</label>
<input
ref="coverInput"
class="file-native"
type="file"
accept="image/jpeg,image/png,image/webp"
:disabled="busy"
@change="pickCover"
/>
<div class="file-box">
<button type="button" :disabled="busy" @click="openCoverPicker">选择封面</button>
<div class="file-name" :class="publishForm.cover ? '' : 'muted'">
{{ publishForm.cover ? publishForm.cover.name : '未选择文件' }}
</div>
<button v-if="publishForm.cover" type="button" :disabled="busy" @click="clearCover">清除</button>
</div>
<div v-if="publishForm.cover" class="subtle" style="margin-top: 6px">已选择{{ publishForm.cover.name }}</div>
</div>
</div>
<!-- Upload progress bar -->
<div v-if="busy && uploadProgress.totalBytes > 0" class="progress-wrap">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: uploadProgress.percent + '%' }"></div>
</div>
<div class="progress-text">
{{ (uploadProgress.uploadedBytes / 1024 / 1024).toFixed(1) }} MB /
{{ (uploadProgress.totalBytes / 1024 / 1024).toFixed(1) }} MB
({{ uploadProgress.percent }}%)
</div>
</div>
<div v-if="preview.coverUrl || preview.videoUrl" class="grid two">
<div v-if="preview.videoUrl" class="preview-card">
<div class="subtle">视频预览</div>
<video class="video" :src="preview.videoUrl" controls playsinline preload="metadata" />
</div>
<div v-if="preview.coverUrl" class="preview-card">
<div class="subtle">封面预览</div>
<img class="cover" :src="preview.coverUrl" alt="cover preview" />
</div>
</div>
<div class="row" style="justify-content: flex-end; margin-top: 8px">
<button class="primary big-btn" type="button" :disabled="busy" @click="onPublish">发布</button>
</div>
</div>
<div v-if="published" class="card" style="margin-top: 14px">
<p class="title">已发布</p>
<div class="row" style="justify-content: space-between">
<div>
<div class="title" style="margin: 0">{{ published.title }}</div>
<div class="subtle mono">#{{ published.id }}</div>
</div>
<div class="row">
<RouterLink class="pill" :to="`/video/${published.id}`">去播放</RouterLink>
<a class="pill mono" :href="published.play_url" target="_blank" rel="noreferrer">play_url</a>
<a class="pill mono" :href="published.cover_url" target="_blank" rel="noreferrer">cover_url</a>
</div>
</div>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.publish-wrap {
display: grid;
justify-items: center;
}
.publish-card {
width: min(980px, 100%);
padding: 22px;
}
.form-grid {
gap: 16px;
}
.form-grid .grid.two {
gap: 20px;
}
.form-grid .grid.two > * {
min-width: 0;
}
.form-grid input[type='file'] {
max-width: 100%;
}
.file-native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.file-box {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
border-radius: 14px;
min-height: 46px;
}
.file-box button {
padding: 8px 10px;
border-radius: 12px;
}
.file-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.88);
}
.muted {
color: rgba(255, 255, 255, 0.55);
}
.big-input {
box-sizing: border-box;
width: 100%;
max-width: 100%;
padding: 12px 14px;
font-size: 14px;
border-radius: 14px;
}
.big-btn {
padding: 12px 18px;
font-size: 14px;
border-radius: 14px;
}
.preview-card {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 12px;
display: grid;
gap: 10px;
}
.cover {
width: 100%;
aspect-ratio: 9/12;
object-fit: cover;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
.video {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.35);
}
.progress-wrap {
display: grid;
gap: 6px;
}
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: #4a9eff;
border-radius: 4px;
transition: width 0.2s ease;
}
.progress-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
}
</style>