feat: 视频缩略图生成、最近文件面板与前端视图重构

- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
  - 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
  - 新增 /api/thumbnails/{id} 缩略图流端点
  - 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
  - FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
  - 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
  - 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
This commit is contained in:
2026-05-22 17:01:49 +08:00
parent 6acc92ca27
commit 2c20f9bb54
47 changed files with 5975 additions and 64 deletions
+233 -27
View File
@@ -15,6 +15,12 @@ const rootId = ref<number | undefined>()
const browsePath = ref<string[]>([])
const isBrowsingRoots = ref(true)
// Recent files & tabs
const activeTab = ref<'recent-added' | 'recent-played' | 'libraries'>('libraries')
const recentFiles = ref<FileRecordDto[]>([])
const recentLoading = ref(false)
const viewMode = ref<'list' | 'grid'>('list')
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
@@ -34,8 +40,13 @@ const breadcrumbs = computed(() => {
})
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
const selectedThumbnailUrl = computed(() =>
selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : ''
)
const clientTitle = computed(() => {
if (activeTab.value === 'recent-added') return '最近添加'
if (activeTab.value === 'recent-played') return '最近播放'
if (isBrowsingRoots.value) return '文件库'
const root = roots.value.find((r) => r.id === rootId.value)
const dir = browsePath.value.length > 0 ? browsePath.value[browsePath.value.length - 1] : ''
@@ -54,6 +65,13 @@ function formatSize(bytes: number) {
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
}
function formatDuration(seconds: number | null) {
if (!seconds) return ''
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function formatDate(value: string | null) {
if (!value) return ''
return new Date(value).toLocaleString()
@@ -80,12 +98,35 @@ async function browseDirectory() {
}
}
async function loadRecentFiles(type: string) {
try {
errorMessage.value = ''
recentLoading.value = true
recentFiles.value = await api.getRecentFiles(type, 24)
} catch (error) {
setError(error)
} finally {
recentLoading.value = false
}
}
function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
activeTab.value = tab
isBrowsingRoots.value = true
browseData.value = null
selectedFile.value = null
textPreview.value = null
if (tab === 'recent-added') loadRecentFiles('added')
else if (tab === 'recent-played') loadRecentFiles('played')
}
async function enterRoot(id: number) {
rootId.value = id
isBrowsingRoots.value = false
browsePath.value = []
selectedFile.value = null
textPreview.value = null
activeTab.value = 'libraries'
await browseDirectory()
}
@@ -96,6 +137,7 @@ function backToRoots() {
browsePath.value = []
selectedFile.value = null
textPreview.value = null
activeTab.value = 'libraries'
}
async function navigateTo(path: string) {
@@ -119,6 +161,11 @@ async function enterSubdirectory(name: string) {
async function selectFile(file: FileRecordDto) {
selectedFile.value = file
textPreview.value = null
if (file.mediaType === 'video' || file.mediaType === 'audio') {
api.markFilePlayed(file.id).catch(() => {})
}
if (file.mediaType !== 'text') return
try {
@@ -146,7 +193,9 @@ onMounted(async () => {
<div>
<h1>{{ clientTitle }}</h1>
<p>
<template v-if="isBrowsingRoots">{{ activeRoots.length }} 目录</template>
<template v-if="activeTab === 'recent-added'">{{ recentFiles.length }} 文件</template>
<template v-else-if="activeTab === 'recent-played'">{{ recentFiles.length }} 个文件</template>
<template v-else-if="isBrowsingRoots">{{ activeRoots.length }} 个目录</template>
<template v-else-if="browseData">
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.files.length }} 个文件
</template>
@@ -161,8 +210,99 @@ onMounted(async () => {
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<!-- Root tabs -->
<nav v-if="isBrowsingRoots" class="root-tabs">
<button
type="button"
:class="{ active: activeTab === 'recent-added' }"
@click="switchTab('recent-added')"
>最近添加</button>
<button
type="button"
:class="{ active: activeTab === 'recent-played' }"
@click="switchTab('recent-played')"
>最近播放</button>
<button
type="button"
:class="{ active: activeTab === 'libraries' }"
@click="switchTab('libraries')"
>文件库</button>
</nav>
<!-- Recently added / played files -->
<section v-if="isBrowsingRoots && activeTab !== 'libraries'" class="recent-files">
<div class="view-toggle-bar">
<div></div>
<div class="view-toggle">
<button type="button" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">列表</button>
<button type="button" :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'">网格</button>
</div>
</div>
<p v-if="recentLoading" class="empty-state">加载中...</p>
<p v-else-if="recentFiles.length === 0" class="empty-state">
{{ activeTab === 'recent-added' ? '暂无最近添加的文件' : '暂无播放记录' }}
</p>
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="file-grid">
<button
v-for="file in recentFiles"
:key="file.id"
class="file-card"
type="button"
@click="selectFile(file)"
>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="card-thumb"
alt=""
loading="lazy"
/>
<div v-else class="card-thumb-placeholder">{{ file.mediaType }}</div>
<div class="card-body">
<strong>{{ file.fileName }}</strong>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
</small>
</div>
</button>
</div>
<!-- List view -->
<div v-else class="file-list">
<button
v-for="file in recentFiles"
:key="file.id"
class="mobile-file"
@click="selectFile(file)"
>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="file-thumb"
alt=""
loading="lazy"
/>
<span v-else class="type-badge">{{ file.mediaType }}</span>
<span>
<strong>{{ file.fileName }}</strong>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
<template v-if="file.lastPlayedAt && activeTab === 'recent-played'">
· {{ formatDate(file.lastPlayedAt) }}
</template>
</small>
</span>
</button>
</div>
</section>
<!-- Library root tiles -->
<section v-if="isBrowsingRoots" class="root-picker">
<section v-if="isBrowsingRoots && activeTab === 'libraries'" class="root-picker">
<button
v-for="root in activeRoots"
:key="root.id"
@@ -178,7 +318,7 @@ onMounted(async () => {
</section>
<!-- Directory browser -->
<template v-else>
<template v-else-if="!isBrowsingRoots">
<!-- Breadcrumb -->
<nav class="breadcrumb-nav">
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
@@ -222,26 +362,46 @@ onMounted(async () => {
<span>{{ selectedFile.extension }}</span>
</div>
<video
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
playsinline
webkit-playsinline
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</video>
<audio
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</audio>
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
<p v-else class="unsupported">浏览器不支持在线播放此格式</p>
<div v-if="selectedFile.mediaType === 'video'" class="video-info-bar">
<span v-if="selectedFile.videoDuration">时长 {{ formatDuration(selectedFile.videoDuration) }}</span>
<span>{{ formatSize(selectedFile.sizeBytes) }}</span>
<span>{{ selectedFile.contentType }}</span>
</div>
<div class="player-media-wrapper">
<video
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
:key="selectedFile.id"
:poster="selectedThumbnailUrl || undefined"
controls
playsinline
webkit-playsinline
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</video>
<div
v-else-if="selectedFile.mediaType === 'video' && !selectedFile.browserPlayable"
class="unsupported-player"
>
<img
v-if="selectedThumbnailUrl"
:src="selectedThumbnailUrl"
class="unsupported-thumb"
alt=""
/>
<p class="unsupported">浏览器不支持在线播放此格式</p>
</div>
<audio
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</audio>
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
</div>
<a
v-if="selectedFile.mediaType !== 'text'"
class="open-media-link"
@@ -256,8 +416,44 @@ onMounted(async () => {
<!-- Files -->
<section v-if="browseData.files.length > 0" class="browse-section">
<h3>文件</h3>
<div class="file-list">
<div class="section-header">
<h3>文件</h3>
<div class="view-toggle">
<button type="button" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">列表</button>
<button type="button" :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'">网格</button>
</div>
</div>
<!-- Grid view -->
<div v-if="viewMode === 'grid'" class="file-grid">
<button
v-for="file in browseData.files"
:key="file.id"
class="file-card"
:class="{ active: selectedFile?.id === file.id }"
type="button"
@click="selectFile(file)"
>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="card-thumb"
alt=""
loading="lazy"
/>
<div v-else class="card-thumb-placeholder">{{ file.mediaType }}</div>
<div class="card-body">
<strong>{{ file.fileName }}</strong>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
</small>
</div>
</button>
</div>
<!-- List view -->
<div v-else class="file-list">
<button
v-for="file in browseData.files"
:key="file.id"
@@ -266,10 +462,20 @@ onMounted(async () => {
type="button"
@click="selectFile(file)"
>
<span class="type-badge">{{ file.mediaType }}</span>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="file-thumb"
alt=""
loading="lazy"
/>
<span v-else class="type-badge">{{ file.mediaType }}</span>
<span>
<strong>{{ file.fileName }}</strong>
<small>{{ formatSize(file.sizeBytes) }} · {{ formatDate(file.lastWriteTimeUtc) }}</small>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
</small>
</span>
</button>
</div>