2026-05-22 11:59:45 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-05-23 11:03:51 +08:00
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
2026-05-22 11:59:45 +08:00
|
|
|
|
import { api, type BrowseDirectoryResponse, type FileRecordDto, type LibraryRootDto, type TextPreviewDto } from '../api'
|
|
|
|
|
|
import QrCodeModal from './QrCodeModal.vue'
|
|
|
|
|
|
|
|
|
|
|
|
const roots = ref<LibraryRootDto[]>([])
|
|
|
|
|
|
const browseData = ref<BrowseDirectoryResponse | null>(null)
|
|
|
|
|
|
const selectedFile = ref<FileRecordDto | null>(null)
|
|
|
|
|
|
const textPreview = ref<TextPreviewDto | null>(null)
|
|
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const errorMessage = ref('')
|
|
|
|
|
|
|
|
|
|
|
|
// Navigation state
|
|
|
|
|
|
const rootId = ref<number | undefined>()
|
|
|
|
|
|
const browsePath = ref<string[]>([])
|
|
|
|
|
|
const isBrowsingRoots = ref(true)
|
|
|
|
|
|
|
2026-05-22 17:01:49 +08:00
|
|
|
|
// 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')
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
|
|
|
|
|
|
|
2026-05-22 17:44:04 +08:00
|
|
|
|
// 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')
|
2026-05-23 11:03:51 +08:00
|
|
|
|
const sortBy = ref<'name' | 'size' | 'created' | 'type'>('name')
|
|
|
|
|
|
const sortDirection = ref<'asc' | 'desc'>('asc')
|
|
|
|
|
|
const browsePage = ref(1)
|
|
|
|
|
|
const browsePageSize = ref(48)
|
2026-05-22 17:44:04 +08:00
|
|
|
|
|
|
|
|
|
|
// Video resume
|
|
|
|
|
|
const videoRef = ref<HTMLVideoElement | null>(null)
|
|
|
|
|
|
const resumePosition = ref(0)
|
|
|
|
|
|
const showResumePrompt = ref(false)
|
|
|
|
|
|
const resumeRequested = ref(false)
|
|
|
|
|
|
let lastPositionSave = 0
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
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('/'))
|
|
|
|
|
|
|
|
|
|
|
|
const breadcrumbs = computed(() => {
|
|
|
|
|
|
const root = roots.value.find((r) => r.id === rootId.value)
|
|
|
|
|
|
const items = [{ label: root?.displayName ?? '文件库', path: '' }]
|
2026-05-22 17:44:04 +08:00
|
|
|
|
browsePath.value.forEach((label, index) => {
|
2026-05-22 11:59:45 +08:00
|
|
|
|
items.push({
|
2026-05-22 17:44:04 +08:00
|
|
|
|
label,
|
|
|
|
|
|
path: browsePath.value.slice(0, index + 1).join('/'),
|
2026-05-22 11:59:45 +08:00
|
|
|
|
})
|
2026-05-22 17:44:04 +08:00
|
|
|
|
})
|
2026-05-22 11:59:45 +08:00
|
|
|
|
return items
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
const displayedFiles = computed(() => browseData.value?.files ?? [])
|
2026-05-22 17:44:04 +08:00
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
2026-05-22 17:01:49 +08:00
|
|
|
|
const selectedThumbnailUrl = computed(() =>
|
|
|
|
|
|
selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : ''
|
|
|
|
|
|
)
|
2026-05-22 11:59:45 +08:00
|
|
|
|
|
|
|
|
|
|
const clientTitle = computed(() => {
|
2026-05-22 17:01:49 +08:00
|
|
|
|
if (activeTab.value === 'recent-added') return '最近添加'
|
|
|
|
|
|
if (activeTab.value === 'recent-played') return '最近播放'
|
2026-05-22 11:59:45 +08:00
|
|
|
|
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] : ''
|
|
|
|
|
|
return dir || (root?.displayName ?? '文件')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
function formatSize(bytes: number) {
|
|
|
|
|
|
if (bytes < 1024) return `${bytes} B`
|
|
|
|
|
|
const units = ['KB', 'MB', 'GB', 'TB']
|
|
|
|
|
|
let value = bytes / 1024
|
|
|
|
|
|
let index = 0
|
|
|
|
|
|
while (value >= 1024 && index < units.length - 1) {
|
|
|
|
|
|
value /= 1024
|
|
|
|
|
|
index += 1
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 17:01:49 +08:00
|
|
|
|
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')}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
function formatDate(value: string | null) {
|
|
|
|
|
|
if (!value) return ''
|
|
|
|
|
|
return new Date(value).toLocaleString()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
function formatCreatedTime(file: FileRecordDto) {
|
|
|
|
|
|
return file.fileCreationTimeUtc ? `创建 ${formatDate(file.fileCreationTimeUtc)}` : ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
function setError(error: unknown) {
|
|
|
|
|
|
errorMessage.value = error instanceof Error ? error.message : '操作失败'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRoots() {
|
|
|
|
|
|
roots.value = await api.getRoots()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function browseDirectory() {
|
|
|
|
|
|
if (!rootId.value) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
errorMessage.value = ''
|
|
|
|
|
|
loading.value = true
|
2026-05-23 11:03:51 +08:00
|
|
|
|
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,
|
|
|
|
|
|
})
|
2026-05-22 11:59:45 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setError(error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 17:01:49 +08:00
|
|
|
|
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') {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 17:01:49 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
async function enterRoot(id: number) {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 11:59:45 +08:00
|
|
|
|
rootId.value = id
|
|
|
|
|
|
isBrowsingRoots.value = false
|
|
|
|
|
|
browsePath.value = []
|
2026-05-23 11:03:51 +08:00
|
|
|
|
browsePage.value = 1
|
2026-05-22 11:59:45 +08:00
|
|
|
|
selectedFile.value = null
|
|
|
|
|
|
textPreview.value = null
|
2026-05-22 17:01:49 +08:00
|
|
|
|
activeTab.value = 'libraries'
|
2026-05-22 11:59:45 +08:00
|
|
|
|
await browseDirectory()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function backToRoots() {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 11:59:45 +08:00
|
|
|
|
isBrowsingRoots.value = true
|
|
|
|
|
|
rootId.value = undefined
|
|
|
|
|
|
browseData.value = null
|
|
|
|
|
|
browsePath.value = []
|
2026-05-23 11:03:51 +08:00
|
|
|
|
browsePage.value = 1
|
2026-05-22 11:59:45 +08:00
|
|
|
|
selectedFile.value = null
|
|
|
|
|
|
textPreview.value = null
|
2026-05-22 17:01:49 +08:00
|
|
|
|
activeTab.value = 'libraries'
|
2026-05-22 11:59:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function navigateTo(path: string) {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 11:59:45 +08:00
|
|
|
|
if (path === '') {
|
|
|
|
|
|
browsePath.value = []
|
|
|
|
|
|
} else {
|
|
|
|
|
|
browsePath.value = path.split('/')
|
|
|
|
|
|
}
|
|
|
|
|
|
selectedFile.value = null
|
|
|
|
|
|
textPreview.value = null
|
2026-05-23 11:03:51 +08:00
|
|
|
|
browsePage.value = 1
|
2026-05-22 11:59:45 +08:00
|
|
|
|
await browseDirectory()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function enterSubdirectory(name: string) {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 11:59:45 +08:00
|
|
|
|
browsePath.value.push(name)
|
|
|
|
|
|
selectedFile.value = null
|
|
|
|
|
|
textPreview.value = null
|
2026-05-23 11:03:51 +08:00
|
|
|
|
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
|
2026-05-22 11:59:45 +08:00
|
|
|
|
await browseDirectory()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
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()
|
2026-05-22 17:44:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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() {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 17:44:04 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
const rawPosition = position ?? video.currentTime
|
|
|
|
|
|
if (!Number.isFinite(rawPosition)) return
|
|
|
|
|
|
|
|
|
|
|
|
const nextPosition = Math.floor(rawPosition)
|
|
|
|
|
|
if (!Number.isFinite(nextPosition) || nextPosition < 0) return
|
2026-05-22 17:44:04 +08:00
|
|
|
|
|
|
|
|
|
|
const fileId = selectedFile.value.id
|
|
|
|
|
|
api.saveFileProgress(fileId, nextPosition)
|
|
|
|
|
|
.then(() => updatePlaybackPosition(fileId, nextPosition))
|
|
|
|
|
|
.catch(() => {})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
function flushVideoProgress() {
|
|
|
|
|
|
const video = videoRef.value
|
|
|
|
|
|
if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return
|
|
|
|
|
|
saveVideoProgress()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 17:44:04 +08:00
|
|
|
|
function handleVideoTimeUpdate() {
|
|
|
|
|
|
const video = videoRef.value
|
2026-05-23 11:03:51 +08:00
|
|
|
|
if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return
|
2026-05-22 17:44:04 +08:00
|
|
|
|
|
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
|
if (now - lastPositionSave < 5000) return
|
|
|
|
|
|
lastPositionSave = now
|
|
|
|
|
|
saveVideoProgress()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleVideoPause() {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 17:44:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleVideoPlay() {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
if (showResumePrompt.value && resumePosition.value > 0) {
|
|
|
|
|
|
resumeRequested.value = true
|
|
|
|
|
|
}
|
2026-05-22 17:44:04 +08:00
|
|
|
|
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
|
2026-05-23 11:03:51 +08:00
|
|
|
|
resumeRequested.value = false
|
2026-05-22 17:44:04 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-23 11:03:51 +08:00
|
|
|
|
saveVideoProgress(0)
|
2026-05-22 17:44:04 +08:00
|
|
|
|
resetResume()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function selectSearchFile(file: FileRecordDto) {
|
|
|
|
|
|
const relativeParts = file.relativePath.split(/[\\/]/).filter(Boolean)
|
|
|
|
|
|
relativeParts.pop()
|
|
|
|
|
|
|
|
|
|
|
|
rootId.value = file.libraryRootId
|
|
|
|
|
|
browsePath.value = relativeParts
|
2026-05-23 11:03:51 +08:00
|
|
|
|
browsePage.value = 1
|
2026-05-22 17:44:04 +08:00
|
|
|
|
isBrowsingRoots.value = false
|
|
|
|
|
|
activeTab.value = 'libraries'
|
|
|
|
|
|
exitSearch()
|
|
|
|
|
|
await browseDirectory()
|
|
|
|
|
|
|
|
|
|
|
|
await selectFile(browseData.value?.files.find((item) => item.id === file.id) ?? file)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
async function selectFile(file: FileRecordDto) {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
flushVideoProgress()
|
2026-05-22 17:44:04 +08:00
|
|
|
|
resetResume()
|
|
|
|
|
|
lastPositionSave = 0
|
2026-05-22 11:59:45 +08:00
|
|
|
|
selectedFile.value = file
|
|
|
|
|
|
textPreview.value = null
|
2026-05-22 17:01:49 +08:00
|
|
|
|
|
2026-05-22 17:44:04 +08:00
|
|
|
|
if (file.mediaType === 'video') {
|
|
|
|
|
|
api.markFilePlayed(file.id).catch(() => {})
|
|
|
|
|
|
tryResume(file)
|
|
|
|
|
|
} else if (file.mediaType === 'audio') {
|
2026-05-22 17:01:49 +08:00
|
|
|
|
api.markFilePlayed(file.id).catch(() => {})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
if (file.mediaType !== 'text') return
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
textPreview.value = await api.getTextPreview(file.id)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setError(error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
2026-05-23 11:03:51 +08:00
|
|
|
|
window.addEventListener('beforeunload', flushVideoProgress)
|
2026-05-22 11:59:45 +08:00
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await loadRoots()
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setError(error)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-05-23 11:03:51 +08:00
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
window.removeEventListener('beforeunload', flushVideoProgress)
|
|
|
|
|
|
flushVideoProgress()
|
|
|
|
|
|
})
|
2026-05-22 11:59:45 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<main class="client-shell">
|
|
|
|
|
|
<header class="mobile-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h1>{{ clientTitle }}</h1>
|
|
|
|
|
|
<p>
|
2026-05-22 17:01:49 +08:00
|
|
|
|
<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>
|
2026-05-22 11:59:45 +08:00
|
|
|
|
<template v-else-if="browseData">
|
2026-05-23 11:03:51 +08:00
|
|
|
|
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.total }} 个文件
|
2026-05-22 11:59:45 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="mobile-header-actions">
|
|
|
|
|
|
<button v-if="!isBrowsingRoots" type="button" class="back-button" @click="backToRoots">返回</button>
|
2026-05-22 17:44:04 +08:00
|
|
|
|
<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>
|
2026-05-22 11:59:45 +08:00
|
|
|
|
<button type="button" class="qr-button" title="生成二维码" @click="qrModal?.open()">二维码</button>
|
|
|
|
|
|
<a href="/admin" class="admin-link">管理</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<!-- 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>
|
|
|
|
|
|
|
2026-05-22 17:01:49 +08:00
|
|
|
|
<!-- Root tabs -->
|
2026-05-22 17:44:04 +08:00
|
|
|
|
<nav v-if="isBrowsingRoots && !isSearching" class="root-tabs">
|
2026-05-22 17:01:49 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-22 17:44:04 +08:00
|
|
|
|
<!-- 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>
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
2026-05-22 17:44:04 +08:00
|
|
|
|
</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>
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
2026-05-22 17:44:04 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-05-22 17:01:49 +08:00
|
|
|
|
<!-- Recently added / played files -->
|
2026-05-22 17:44:04 +08:00
|
|
|
|
<section v-if="isBrowsingRoots && !isSearching && activeTab !== 'libraries'" class="recent-files">
|
2026-05-22 17:01:49 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-22 11:59:45 +08:00
|
|
|
|
<!-- Library root tiles -->
|
2026-05-22 17:44:04 +08:00
|
|
|
|
<section v-if="isBrowsingRoots && !isSearching && activeTab === 'libraries'" class="root-picker">
|
2026-05-22 11:59:45 +08:00
|
|
|
|
<button
|
|
|
|
|
|
v-for="root in activeRoots"
|
|
|
|
|
|
:key="root.id"
|
|
|
|
|
|
class="root-tile"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
@click="enterRoot(root.id)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>{{ root.displayName }}</span>
|
|
|
|
|
|
<strong>{{ root.fileCount }}</strong>
|
|
|
|
|
|
<small>{{ root.path }}</small>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<p v-if="activeRoots.length === 0" class="empty-state">暂无可访问目录</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Directory browser -->
|
2026-05-22 17:44:04 +08:00
|
|
|
|
<template v-else-if="!isBrowsingRoots && !isSearching">
|
2026-05-22 11:59:45 +08:00
|
|
|
|
<!-- Breadcrumb -->
|
|
|
|
|
|
<nav class="breadcrumb-nav">
|
|
|
|
|
|
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
|
|
|
|
|
|
<span v-if="index > 0" class="breadcrumb-sep">/</span>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="breadcrumb-item"
|
|
|
|
|
|
:class="{ active: index === breadcrumbs.length - 1 }"
|
|
|
|
|
|
@click="navigateTo(crumb.path)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ crumb.label }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
<section v-if="browseData" class="browse-content">
|
|
|
|
|
|
<!-- Subdirectories -->
|
|
|
|
|
|
<section v-if="browseData.subdirectories.length > 0" class="browse-section">
|
|
|
|
|
|
<h3>文件夹</h3>
|
|
|
|
|
|
<div class="folder-grid">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="dir in browseData.subdirectories"
|
|
|
|
|
|
:key="dir"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="folder-item"
|
|
|
|
|
|
@click="enterSubdirectory(dir)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="folder-icon">📁</span>
|
|
|
|
|
|
<span>{{ dir }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Files -->
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<section class="browse-section">
|
2026-05-22 17:01:49 +08:00
|
|
|
|
<div class="section-header">
|
2026-05-22 17:44:04 +08:00
|
|
|
|
<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>
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<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>
|
2026-05-22 17:01:49 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Grid view -->
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<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>
|
2026-05-22 17:01:49 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- List view -->
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<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>
|
2026-05-22 11:59:45 +08:00
|
|
|
|
</div>
|
2026-05-23 11:03:51 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-05-22 11:59:45 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-05-23 11:03:51 +08:00
|
|
|
|
<p v-if="browseData.subdirectories.length === 0 && browseData.total === 0 && filterType === 'all'" class="empty-state">
|
2026-05-22 11:59:45 +08:00
|
|
|
|
此目录下没有支持的文件
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-else-if="!loading" class="empty-state">加载中...</p>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<QrCodeModal ref="qrModal" />
|
|
|
|
|
|
</template>
|