feat: 文件搜索、视频续播与目录文件过滤
- 前端 Header 新增搜索栏,接入已有 SearchFiles API,结果支持列表/网格视图 - 新增 PlaybackPosition 数据库列与 /api/files/progress 端点,播放进度存服务端 - 播放中每 5 秒自动保存进度,再次打开视频时弹出"继续播放"提示 - 目录浏览新增媒体类型过滤条(全部/视频/音频/文本),前端即时过滤 - 新增 4 种数据库迁移(AddPlaybackPosition)
This commit is contained in:
@@ -23,6 +23,22 @@ const viewMode = ref<'list' | 'grid'>('list')
|
||||
|
||||
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
|
||||
|
||||
// Search
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<FileRecordDto[]>([])
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
|
||||
// File filter
|
||||
const filterType = ref<'all' | 'video' | 'audio' | 'text'>('all')
|
||||
|
||||
// Video resume
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const resumePosition = ref(0)
|
||||
const showResumePrompt = ref(false)
|
||||
const resumeRequested = ref(false)
|
||||
let lastPositionSave = 0
|
||||
|
||||
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
|
||||
const selectedRoot = computed(() => roots.value.find((root) => root.id === selectedFile.value?.libraryRootId))
|
||||
const currentBrowsePath = computed(() => browsePath.value.join('/'))
|
||||
@@ -30,15 +46,21 @@ const currentBrowsePath = computed(() => browsePath.value.join('/'))
|
||||
const breadcrumbs = computed(() => {
|
||||
const root = roots.value.find((r) => r.id === rootId.value)
|
||||
const items = [{ label: root?.displayName ?? '文件库', path: '' }]
|
||||
for (let i = 0; i < browsePath.value.length; i++) {
|
||||
browsePath.value.forEach((label, index) => {
|
||||
items.push({
|
||||
label: browsePath.value[i],
|
||||
path: browsePath.value.slice(0, i + 1).join('/'),
|
||||
label,
|
||||
path: browsePath.value.slice(0, index + 1).join('/'),
|
||||
})
|
||||
}
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
if (!browseData.value) return []
|
||||
if (filterType.value === 'all') return browseData.value.files
|
||||
return browseData.value.files.filter((f) => f.mediaType === filterType.value)
|
||||
})
|
||||
|
||||
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
||||
const selectedThumbnailUrl = computed(() =>
|
||||
selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : ''
|
||||
@@ -158,11 +180,146 @@ async function enterSubdirectory(name: string) {
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
function changeFilter(type: 'all' | 'video' | 'audio' | 'text') {
|
||||
filterType.value = type
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
const keyword = searchQuery.value.trim()
|
||||
if (!keyword) return
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
searchLoading.value = true
|
||||
isSearching.value = true
|
||||
const response = await api.searchFiles({ page: 1, pageSize: 48, keyword })
|
||||
searchResults.value = response.items
|
||||
} catch (error) {
|
||||
setError(error)
|
||||
} finally {
|
||||
searchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exitSearch() {
|
||||
isSearching.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
}
|
||||
|
||||
function updatePlaybackPosition(id: number, position: number) {
|
||||
if (selectedFile.value?.id === id) {
|
||||
selectedFile.value.playbackPosition = position
|
||||
}
|
||||
|
||||
for (const files of [searchResults.value, recentFiles.value, browseData.value?.files]) {
|
||||
const file = files?.find((item) => item.id === id)
|
||||
if (file) {
|
||||
file.playbackPosition = position
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function saveVideoProgress(position?: number) {
|
||||
if (!selectedFile.value || selectedFile.value.mediaType !== 'video') return
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
|
||||
const nextPosition = position ?? Math.floor(video.currentTime)
|
||||
if (nextPosition < 0) return
|
||||
|
||||
const fileId = selectedFile.value.id
|
||||
api.saveFileProgress(fileId, nextPosition)
|
||||
.then(() => updatePlaybackPosition(fileId, nextPosition))
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function handleVideoTimeUpdate() {
|
||||
const video = videoRef.value
|
||||
if (!video || video.paused || video.currentTime === 0) return
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastPositionSave < 5000) return
|
||||
lastPositionSave = now
|
||||
saveVideoProgress()
|
||||
}
|
||||
|
||||
function handleVideoPause() {
|
||||
const video = videoRef.value
|
||||
if (!video || video.currentTime === 0 || video.ended) return
|
||||
saveVideoProgress()
|
||||
}
|
||||
|
||||
function handleVideoPlay() {
|
||||
seekToResumePosition()
|
||||
showResumePrompt.value = false
|
||||
}
|
||||
|
||||
function handleVideoEnded() {
|
||||
saveVideoProgress(0)
|
||||
}
|
||||
|
||||
function resetResume() {
|
||||
resumePosition.value = 0
|
||||
resumeRequested.value = false
|
||||
showResumePrompt.value = false
|
||||
}
|
||||
|
||||
function tryResume(file: FileRecordDto) {
|
||||
if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return
|
||||
resumePosition.value = file.playbackPosition
|
||||
resumeRequested.value = true
|
||||
showResumePrompt.value = true
|
||||
}
|
||||
|
||||
function seekToResumePosition() {
|
||||
const video = videoRef.value
|
||||
if (!video || !resumeRequested.value || resumePosition.value <= 0 || video.readyState < 1) return
|
||||
|
||||
const maxPosition = Number.isFinite(video.duration) && video.duration > 1
|
||||
? Math.max(0, video.duration - 1)
|
||||
: resumePosition.value
|
||||
video.currentTime = Math.min(resumePosition.value, maxPosition)
|
||||
resumeRequested.value = false
|
||||
}
|
||||
|
||||
function resumePlayback() {
|
||||
showResumePrompt.value = false
|
||||
resumeRequested.value = true
|
||||
seekToResumePosition()
|
||||
videoRef.value?.play().catch(() => {})
|
||||
}
|
||||
|
||||
function dismissResume() {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.currentTime = 0
|
||||
}
|
||||
resetResume()
|
||||
}
|
||||
|
||||
async function selectSearchFile(file: FileRecordDto) {
|
||||
const relativeParts = file.relativePath.split(/[\\/]/).filter(Boolean)
|
||||
relativeParts.pop()
|
||||
|
||||
rootId.value = file.libraryRootId
|
||||
browsePath.value = relativeParts
|
||||
isBrowsingRoots.value = false
|
||||
activeTab.value = 'libraries'
|
||||
exitSearch()
|
||||
await browseDirectory()
|
||||
|
||||
await selectFile(browseData.value?.files.find((item) => item.id === file.id) ?? file)
|
||||
}
|
||||
|
||||
async function selectFile(file: FileRecordDto) {
|
||||
resetResume()
|
||||
lastPositionSave = 0
|
||||
selectedFile.value = file
|
||||
textPreview.value = null
|
||||
|
||||
if (file.mediaType === 'video' || file.mediaType === 'audio') {
|
||||
if (file.mediaType === 'video') {
|
||||
api.markFilePlayed(file.id).catch(() => {})
|
||||
tryResume(file)
|
||||
} else if (file.mediaType === 'audio') {
|
||||
api.markFilePlayed(file.id).catch(() => {})
|
||||
}
|
||||
|
||||
@@ -203,6 +360,16 @@ onMounted(async () => {
|
||||
</div>
|
||||
<div class="mobile-header-actions">
|
||||
<button v-if="!isBrowsingRoots" type="button" class="back-button" @click="backToRoots">返回</button>
|
||||
<button v-if="isSearching" type="button" class="back-button" @click="exitSearch">返回</button>
|
||||
<form class="search-form" @submit.prevent="doSearch">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="搜索文件..."
|
||||
class="search-input"
|
||||
/>
|
||||
<button type="submit" class="search-submit">搜索</button>
|
||||
</form>
|
||||
<button type="button" class="qr-button" title="生成二维码" @click="qrModal?.open()">二维码</button>
|
||||
<a href="/admin" class="admin-link">管理</a>
|
||||
</div>
|
||||
@@ -211,7 +378,7 @@ onMounted(async () => {
|
||||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Root tabs -->
|
||||
<nav v-if="isBrowsingRoots" class="root-tabs">
|
||||
<nav v-if="isBrowsingRoots && !isSearching" class="root-tabs">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'recent-added' }"
|
||||
@@ -229,8 +396,48 @@ onMounted(async () => {
|
||||
>文件库</button>
|
||||
</nav>
|
||||
|
||||
<!-- Search results -->
|
||||
<section v-if="isSearching" class="recent-files">
|
||||
<div class="view-toggle-bar">
|
||||
<h3 class="search-results-title">
|
||||
搜索"{{ searchQuery }}" {{ searchResults.length }} 个结果
|
||||
</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>
|
||||
<p v-if="searchLoading" class="empty-state">搜索中...</p>
|
||||
<p v-else-if="searchResults.length === 0" class="empty-state">无匹配文件</p>
|
||||
<!-- Grid view -->
|
||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||
<button v-for="file in searchResults" :key="file.id" class="file-card" type="button" @click="selectSearchFile(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 searchResults" :key="file.id" class="mobile-file" type="button" @click="selectSearchFile(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) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recently added / played files -->
|
||||
<section v-if="isBrowsingRoots && activeTab !== 'libraries'" class="recent-files">
|
||||
<section v-if="isBrowsingRoots && !isSearching && activeTab !== 'libraries'" class="recent-files">
|
||||
<div class="view-toggle-bar">
|
||||
<div></div>
|
||||
<div class="view-toggle">
|
||||
@@ -302,7 +509,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<!-- Library root tiles -->
|
||||
<section v-if="isBrowsingRoots && activeTab === 'libraries'" class="root-picker">
|
||||
<section v-if="isBrowsingRoots && !isSearching && activeTab === 'libraries'" class="root-picker">
|
||||
<button
|
||||
v-for="root in activeRoots"
|
||||
:key="root.id"
|
||||
@@ -318,7 +525,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<!-- Directory browser -->
|
||||
<template v-else-if="!isBrowsingRoots">
|
||||
<template v-else-if="!isBrowsingRoots && !isSearching">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb-nav">
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
||||
@@ -368,15 +575,30 @@ onMounted(async () => {
|
||||
<span>{{ selectedFile.contentType }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="showResumePrompt" class="resume-banner">
|
||||
<span>从 {{ formatDuration(resumePosition) }} 继续播放?</span>
|
||||
<div class="resume-banner-actions">
|
||||
<button type="button" class="resume-yes" @click="resumePlayback">继续</button>
|
||||
<button type="button" class="resume-no" @click="dismissResume">从头播放</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="player-media-wrapper">
|
||||
<video
|
||||
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
|
||||
ref="videoRef"
|
||||
:key="selectedFile.id"
|
||||
:poster="selectedThumbnailUrl || undefined"
|
||||
controls
|
||||
playsinline
|
||||
webkit-playsinline
|
||||
preload="metadata"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
>
|
||||
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
|
||||
</video>
|
||||
@@ -417,7 +639,12 @@ onMounted(async () => {
|
||||
<!-- Files -->
|
||||
<section v-if="browseData.files.length > 0" class="browse-section">
|
||||
<div class="section-header">
|
||||
<h3>文件</h3>
|
||||
<div class="filter-chips">
|
||||
<button type="button" :class="{ active: filterType === 'all' }" @click="changeFilter('all')">全部</button>
|
||||
<button type="button" :class="{ active: filterType === 'video' }" @click="changeFilter('video')">视频</button>
|
||||
<button type="button" :class="{ active: filterType === 'audio' }" @click="changeFilter('audio')">音频</button>
|
||||
<button type="button" :class="{ active: filterType === 'text' }" @click="changeFilter('text')">文本</button>
|
||||
</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>
|
||||
@@ -427,7 +654,7 @@ onMounted(async () => {
|
||||
<!-- Grid view -->
|
||||
<div v-if="viewMode === 'grid'" class="file-grid">
|
||||
<button
|
||||
v-for="file in browseData.files"
|
||||
v-for="file in filteredFiles"
|
||||
:key="file.id"
|
||||
class="file-card"
|
||||
:class="{ active: selectedFile?.id === file.id }"
|
||||
@@ -455,7 +682,7 @@ onMounted(async () => {
|
||||
<!-- List view -->
|
||||
<div v-else class="file-list">
|
||||
<button
|
||||
v-for="file in browseData.files"
|
||||
v-for="file in filteredFiles"
|
||||
:key="file.id"
|
||||
class="mobile-file"
|
||||
:class="{ active: selectedFile?.id === file.id }"
|
||||
|
||||
Reference in New Issue
Block a user