fix(web): 恢复视频继续播放和进度保存
- 将播放器收敛为单个共享实例,避免列表/网格内多个 video ref 导致进度保存失效 - 恢复视频播放器上方的继续播放提示 - 在播放、暂停、拖动、结束、切换页面和离开页面时保存播放位置 - 保留文件浏览分页和排序参数的前端调用
This commit is contained in:
@@ -38,6 +38,7 @@ export interface FileRecordDto {
|
||||
extension: string
|
||||
sizeBytes: number
|
||||
lastWriteTimeUtc: string
|
||||
fileCreationTimeUtc: string | null
|
||||
mediaType: 'text' | 'video' | 'audio'
|
||||
contentType: string
|
||||
streamUrl: string
|
||||
@@ -53,6 +54,10 @@ export interface BrowseDirectoryResponse {
|
||||
currentPath: string
|
||||
subdirectories: string[]
|
||||
files: FileRecordDto[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface TextPreviewDto {
|
||||
@@ -95,10 +100,10 @@ export const api = {
|
||||
request('library/roots/delete', { method: 'POST', body: { id } }),
|
||||
scanRoot: (id: number) =>
|
||||
request<LibraryRootDto>('library/roots/scan', { method: 'POST', body: { id } }),
|
||||
searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number }) =>
|
||||
searchFiles: (params: { page: number; pageSize: number; mediaType?: MediaType; keyword?: string; rootId?: number; sortBy?: string; sortDirection?: string }) =>
|
||||
request<PagedResponse<FileRecordDto>>(`files${qs(params)}`),
|
||||
browseDirectory: (rootId: number, path: string) =>
|
||||
request<BrowseDirectoryResponse>(`files/browse${qs({ rootId, path })}`),
|
||||
browseDirectory: (params: { rootId: number; path: string; page?: number; pageSize?: number; mediaType?: MediaType; sortBy?: string; sortDirection?: string }) =>
|
||||
request<BrowseDirectoryResponse>(`files/browse${qs(params)}`),
|
||||
getTextPreview: (id: number) =>
|
||||
request<TextPreviewDto>(`files/text${qs({ id })}`),
|
||||
mediaUrl: (path: string) => apiUrl(path),
|
||||
|
||||
@@ -768,6 +768,8 @@ a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -778,6 +780,35 @@ a {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.file-toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sort-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sort-control select {
|
||||
width: auto;
|
||||
min-width: 118px;
|
||||
min-height: 32px;
|
||||
padding: 4px 28px 4px 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sort-control.compact select {
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
/* File grid / card view */
|
||||
.file-grid {
|
||||
display: grid;
|
||||
@@ -1132,6 +1163,10 @@ a {
|
||||
.view-toggle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.file-toolbar-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api, type BrowseDirectoryResponse, type FileRecordDto, type LibraryRootDto, type TextPreviewDto } from '../api'
|
||||
import QrCodeModal from './QrCodeModal.vue'
|
||||
|
||||
@@ -31,6 +31,10 @@ const isSearching = ref(false)
|
||||
|
||||
// File filter
|
||||
const filterType = ref<'all' | 'video' | 'audio' | 'text'>('all')
|
||||
const sortBy = ref<'name' | 'size' | 'created' | 'type'>('name')
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||||
const browsePage = ref(1)
|
||||
const browsePageSize = ref(48)
|
||||
|
||||
// Video resume
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
@@ -55,11 +59,7 @@ const breadcrumbs = computed(() => {
|
||||
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 displayedFiles = computed(() => browseData.value?.files ?? [])
|
||||
|
||||
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
||||
const selectedThumbnailUrl = computed(() =>
|
||||
@@ -99,6 +99,10 @@ function formatDate(value: string | null) {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
function formatCreatedTime(file: FileRecordDto) {
|
||||
return file.fileCreationTimeUtc ? `创建 ${formatDate(file.fileCreationTimeUtc)}` : ''
|
||||
}
|
||||
|
||||
function setError(error: unknown) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '操作失败'
|
||||
}
|
||||
@@ -112,7 +116,15 @@ async function browseDirectory() {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
browseData.value = await api.browseDirectory(rootId.value, currentBrowsePath.value)
|
||||
browseData.value = await api.browseDirectory({
|
||||
rootId: rootId.value,
|
||||
path: currentBrowsePath.value,
|
||||
page: browsePage.value,
|
||||
pageSize: browsePageSize.value,
|
||||
mediaType: filterType.value,
|
||||
sortBy: sortBy.value,
|
||||
sortDirection: sortDirection.value,
|
||||
})
|
||||
} catch (error) {
|
||||
setError(error)
|
||||
} finally {
|
||||
@@ -133,6 +145,7 @@ async function loadRecentFiles(type: string) {
|
||||
}
|
||||
|
||||
function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
|
||||
flushVideoProgress()
|
||||
activeTab.value = tab
|
||||
isBrowsingRoots.value = true
|
||||
browseData.value = null
|
||||
@@ -143,9 +156,11 @@ function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
|
||||
}
|
||||
|
||||
async function enterRoot(id: number) {
|
||||
flushVideoProgress()
|
||||
rootId.value = id
|
||||
isBrowsingRoots.value = false
|
||||
browsePath.value = []
|
||||
browsePage.value = 1
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
activeTab.value = 'libraries'
|
||||
@@ -153,16 +168,19 @@ async function enterRoot(id: number) {
|
||||
}
|
||||
|
||||
function backToRoots() {
|
||||
flushVideoProgress()
|
||||
isBrowsingRoots.value = true
|
||||
rootId.value = undefined
|
||||
browseData.value = null
|
||||
browsePath.value = []
|
||||
browsePage.value = 1
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
activeTab.value = 'libraries'
|
||||
}
|
||||
|
||||
async function navigateTo(path: string) {
|
||||
flushVideoProgress()
|
||||
if (path === '') {
|
||||
browsePath.value = []
|
||||
} else {
|
||||
@@ -170,18 +188,45 @@ async function navigateTo(path: string) {
|
||||
}
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
browsePage.value = 1
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
async function enterSubdirectory(name: string) {
|
||||
flushVideoProgress()
|
||||
browsePath.value.push(name)
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
browsePage.value = 1
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
async function changeFilter(type: 'all' | 'video' | 'audio' | 'text') {
|
||||
flushVideoProgress()
|
||||
filterType.value = type
|
||||
browsePage.value = 1
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
function changeFilter(type: 'all' | 'video' | 'audio' | 'text') {
|
||||
filterType.value = type
|
||||
async function changeSort() {
|
||||
flushVideoProgress()
|
||||
browsePage.value = 1
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
async function changeBrowsePage(page: number) {
|
||||
if (!browseData.value) return
|
||||
const nextPage = Math.min(Math.max(page, 1), Math.max(browseData.value.totalPages, 1))
|
||||
if (nextPage === browsePage.value) return
|
||||
flushVideoProgress()
|
||||
browsePage.value = nextPage
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
@@ -201,6 +246,7 @@ async function doSearch() {
|
||||
}
|
||||
|
||||
function exitSearch() {
|
||||
flushVideoProgress()
|
||||
isSearching.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
@@ -224,8 +270,11 @@ function saveVideoProgress(position?: number) {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
|
||||
const nextPosition = position ?? Math.floor(video.currentTime)
|
||||
if (nextPosition < 0) return
|
||||
const rawPosition = position ?? video.currentTime
|
||||
if (!Number.isFinite(rawPosition)) return
|
||||
|
||||
const nextPosition = Math.floor(rawPosition)
|
||||
if (!Number.isFinite(nextPosition) || nextPosition < 0) return
|
||||
|
||||
const fileId = selectedFile.value.id
|
||||
api.saveFileProgress(fileId, nextPosition)
|
||||
@@ -233,9 +282,15 @@ function saveVideoProgress(position?: number) {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function flushVideoProgress() {
|
||||
const video = videoRef.value
|
||||
if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return
|
||||
saveVideoProgress()
|
||||
}
|
||||
|
||||
function handleVideoTimeUpdate() {
|
||||
const video = videoRef.value
|
||||
if (!video || video.paused || video.currentTime === 0) return
|
||||
if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return
|
||||
|
||||
const now = Date.now()
|
||||
if (now - lastPositionSave < 5000) return
|
||||
@@ -244,12 +299,13 @@ function handleVideoTimeUpdate() {
|
||||
}
|
||||
|
||||
function handleVideoPause() {
|
||||
const video = videoRef.value
|
||||
if (!video || video.currentTime === 0 || video.ended) return
|
||||
saveVideoProgress()
|
||||
flushVideoProgress()
|
||||
}
|
||||
|
||||
function handleVideoPlay() {
|
||||
if (showResumePrompt.value && resumePosition.value > 0) {
|
||||
resumeRequested.value = true
|
||||
}
|
||||
seekToResumePosition()
|
||||
showResumePrompt.value = false
|
||||
}
|
||||
@@ -267,7 +323,7 @@ function resetResume() {
|
||||
function tryResume(file: FileRecordDto) {
|
||||
if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return
|
||||
resumePosition.value = file.playbackPosition
|
||||
resumeRequested.value = true
|
||||
resumeRequested.value = false
|
||||
showResumePrompt.value = true
|
||||
}
|
||||
|
||||
@@ -293,6 +349,7 @@ function dismissResume() {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.currentTime = 0
|
||||
}
|
||||
saveVideoProgress(0)
|
||||
resetResume()
|
||||
}
|
||||
|
||||
@@ -302,6 +359,7 @@ async function selectSearchFile(file: FileRecordDto) {
|
||||
|
||||
rootId.value = file.libraryRootId
|
||||
browsePath.value = relativeParts
|
||||
browsePage.value = 1
|
||||
isBrowsingRoots.value = false
|
||||
activeTab.value = 'libraries'
|
||||
exitSearch()
|
||||
@@ -311,6 +369,7 @@ async function selectSearchFile(file: FileRecordDto) {
|
||||
}
|
||||
|
||||
async function selectFile(file: FileRecordDto) {
|
||||
flushVideoProgress()
|
||||
resetResume()
|
||||
lastPositionSave = 0
|
||||
selectedFile.value = file
|
||||
@@ -333,6 +392,7 @@ async function selectFile(file: FileRecordDto) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('beforeunload', flushVideoProgress)
|
||||
loading.value = true
|
||||
try {
|
||||
await loadRoots()
|
||||
@@ -342,6 +402,11 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', flushVideoProgress)
|
||||
flushVideoProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -354,7 +419,7 @@ onMounted(async () => {
|
||||
<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 }} 个文件
|
||||
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.total }} 个文件
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
@@ -377,6 +442,84 @@ onMounted(async () => {
|
||||
|
||||
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||||
|
||||
<!-- Media player -->
|
||||
<section v-if="selectedFile" class="player-panel">
|
||||
<div class="player-title">
|
||||
<div>
|
||||
<h2>{{ selectedFile.fileName }}</h2>
|
||||
<p>{{ selectedRoot?.displayName ?? '文件库' }} · {{ selectedFile.relativePath }}</p>
|
||||
</div>
|
||||
<span>{{ selectedFile.extension }}</span>
|
||||
</div>
|
||||
|
||||
<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 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"
|
||||
@seeked="handleVideoPause"
|
||||
>
|
||||
<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"
|
||||
:href="selectedMediaUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
新窗口打开原视频/音频
|
||||
</a>
|
||||
<p v-if="textPreview?.truncated" class="hint">文本超过 1 MB,已截断显示。</p>
|
||||
</section>
|
||||
|
||||
<!-- Root tabs -->
|
||||
<nav v-if="isBrowsingRoots && !isSearching" class="root-tabs">
|
||||
<button
|
||||
@@ -420,6 +563,7 @@ onMounted(async () => {
|
||||
{{ formatSize(file.sizeBytes) }}
|
||||
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
|
||||
</small>
|
||||
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -431,6 +575,7 @@ onMounted(async () => {
|
||||
<span>
|
||||
<strong>{{ file.fileName }}</strong>
|
||||
<small>{{ formatSize(file.sizeBytes) }}</small>
|
||||
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -559,85 +704,8 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Media player -->
|
||||
<section v-if="selectedFile" class="player-panel">
|
||||
<div class="player-title">
|
||||
<div>
|
||||
<h2>{{ selectedFile.fileName }}</h2>
|
||||
<p>{{ selectedRoot?.displayName ?? '文件库' }} · {{ selectedFile.relativePath }}</p>
|
||||
</div>
|
||||
<span>{{ selectedFile.extension }}</span>
|
||||
</div>
|
||||
|
||||
<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 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>
|
||||
<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"
|
||||
:href="selectedMediaUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
新窗口打开原视频/音频
|
||||
</a>
|
||||
<p v-if="textPreview?.truncated" class="hint">文本超过 1 MB,已截断显示。</p>
|
||||
</section>
|
||||
|
||||
<!-- Files -->
|
||||
<section v-if="browseData.files.length > 0" class="browse-section">
|
||||
<section class="browse-section">
|
||||
<div class="section-header">
|
||||
<div class="filter-chips">
|
||||
<button type="button" :class="{ active: filterType === 'all' }" @click="changeFilter('all')">全部</button>
|
||||
@@ -645,70 +713,97 @@ onMounted(async () => {
|
||||
<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>
|
||||
<div class="file-toolbar-right">
|
||||
<label class="sort-control">
|
||||
<span>排序</span>
|
||||
<select v-model="sortBy" @change="changeSort">
|
||||
<option value="name">名称</option>
|
||||
<option value="size">大小</option>
|
||||
<option value="created">创建时间</option>
|
||||
<option value="type">文件类型</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="sort-control compact">
|
||||
<span>方向</span>
|
||||
<select v-model="sortDirection" @change="changeSort">
|
||||
<option value="asc">升序</option>
|
||||
<option value="desc">降序</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Grid view -->
|
||||
<div v-if="viewMode === 'grid'" class="file-grid">
|
||||
<button
|
||||
v-for="file in filteredFiles"
|
||||
: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 v-if="displayedFiles.length > 0 && viewMode === 'grid'" class="file-grid">
|
||||
<template v-for="file in displayedFiles" :key="file.id">
|
||||
<button
|
||||
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>
|
||||
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<div v-else class="file-list">
|
||||
<button
|
||||
v-for="file in filteredFiles"
|
||||
:key="file.id"
|
||||
class="mobile-file"
|
||||
:class="{ active: selectedFile?.id === file.id }"
|
||||
type="button"
|
||||
@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>
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
<div v-else-if="displayedFiles.length > 0" class="file-list">
|
||||
<template v-for="file in displayedFiles" :key="file.id">
|
||||
<button
|
||||
class="mobile-file"
|
||||
:class="{ active: selectedFile?.id === file.id }"
|
||||
type="button"
|
||||
@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>
|
||||
</small>
|
||||
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="browseData.totalPages > 1" class="mobile-pager">
|
||||
<button type="button" :disabled="browsePage <= 1" @click="changeBrowsePage(browsePage - 1)">上一页</button>
|
||||
<span>第 {{ browseData.page }} / {{ browseData.totalPages }} 页 · 共 {{ browseData.total }} 个</span>
|
||||
<button type="button" :disabled="browsePage >= browseData.totalPages" @click="changeBrowsePage(browsePage + 1)">下一页</button>
|
||||
</div>
|
||||
<p v-else-if="displayedFiles.length === 0 && filterType !== 'all'" class="empty-state">当前分类没有文件</p>
|
||||
</section>
|
||||
|
||||
<p v-if="browseData.subdirectories.length === 0 && browseData.files.length === 0" class="empty-state">
|
||||
<p v-if="browseData.subdirectories.length === 0 && browseData.total === 0 && filterType === 'all'" class="empty-state">
|
||||
此目录下没有支持的文件
|
||||
</p>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user