feat: 视频缩略图生成、最近文件面板与前端视图重构
- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
- 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
- 新增 /api/thumbnails/{id} 缩略图流端点
- 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
- FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
- 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
- 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user