重构客户端页面并补充前端分页
- 拆分 ClientPage 为多个客户端子组件 - 将媒体播放器显示在当前选中文件下方 - 抽取并复用分页组件 - 为搜索结果补充分页状态和翻页控件 - 保持最近文件逻辑不变
This commit is contained in:
parent
c6b05c12e5
commit
8d9c7f17ff
@ -141,7 +141,7 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
[property: JsonPropertyName("rootId")] int RootId = 0,
|
||||
[property: JsonPropertyName("path")] string? Path = null,
|
||||
[property: JsonPropertyName("page")] int Page = 1,
|
||||
[property: JsonPropertyName("pageSize")] int PageSize = 48,
|
||||
[property: JsonPropertyName("pageSize")] int PageSize = 10,
|
||||
[property: JsonPropertyName("mediaType")] string? MediaType = null,
|
||||
[property: JsonPropertyName("sortBy")] string? SortBy = "name",
|
||||
[property: JsonPropertyName("sortDirection")] string? SortDirection = "asc");
|
||||
|
||||
@ -463,7 +463,7 @@ namespace FileShare_Services.Services.FileLibrary
|
||||
: query.OrderByDescending(file => file.CreatedAt);
|
||||
|
||||
return await query
|
||||
.Take(Math.Clamp(count, 1, 48))
|
||||
.Take(Math.Clamp(count, 1, 10))
|
||||
.Select(file => ToFileDto(file))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@ -479,6 +479,18 @@ a {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.player-host {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-grid .player-host {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.player-host .player-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.player-title {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
@ -1,8 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { api, type BrowseDirectoryResponse, type FileRecordDto, type LibraryRootDto, type TextPreviewDto } from '../api'
|
||||
import BreadcrumbNav from './client/BreadcrumbNav.vue'
|
||||
import BrowseToolbar from './client/BrowseToolbar.vue'
|
||||
import ClientHeader from './client/ClientHeader.vue'
|
||||
import FileCard from './client/FileCard.vue'
|
||||
import FileListItem from './client/FileListItem.vue'
|
||||
import MobilePager from './client/MobilePager.vue'
|
||||
import RootPicker from './client/RootPicker.vue'
|
||||
import RootTabs from './client/RootTabs.vue'
|
||||
import SelectedMediaPlayerHost from './client/SelectedMediaPlayerHost.vue'
|
||||
import ViewToggle from './client/ViewToggle.vue'
|
||||
import QrCodeModal from './QrCodeModal.vue'
|
||||
|
||||
type MediaPlayerHandle = {
|
||||
getVideoElement: () => HTMLVideoElement | null
|
||||
playVideo: () => Promise<void> | undefined
|
||||
resetVideo: () => void
|
||||
}
|
||||
|
||||
const roots = ref<LibraryRootDto[]>([])
|
||||
const browseData = ref<BrowseDirectoryResponse | null>(null)
|
||||
const selectedFile = ref<FileRecordDto | null>(null)
|
||||
@ -10,34 +26,33 @@ 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)
|
||||
|
||||
// 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 mediaPlayer = ref<MediaPlayerHandle | MediaPlayerHandle[] | null>(null)
|
||||
|
||||
// Search
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref<FileRecordDto[]>([])
|
||||
const searchLoading = ref(false)
|
||||
const isSearching = ref(false)
|
||||
const searchPage = ref(1)
|
||||
const searchPageSize = ref(10)
|
||||
const searchTotal = ref(0)
|
||||
const searchTotalPages = ref(0)
|
||||
|
||||
// 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)
|
||||
const browsePageSize = ref(10)
|
||||
|
||||
// Video resume
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const resumePosition = ref(0)
|
||||
const showResumePrompt = ref(false)
|
||||
const resumeRequested = ref(false)
|
||||
@ -75,32 +90,13 @@ const clientTitle = computed(() => {
|
||||
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]}`
|
||||
function getVideoElement() {
|
||||
const player = Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value
|
||||
return player?.getVideoElement() ?? null
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
function formatCreatedTime(file: FileRecordDto) {
|
||||
return file.fileCreationTimeUtc ? `创建 ${formatDate(file.fileCreationTimeUtc)}` : ''
|
||||
function getMediaPlayer() {
|
||||
return Array.isArray(mediaPlayer.value) ? mediaPlayer.value[0] : mediaPlayer.value
|
||||
}
|
||||
|
||||
function setError(error: unknown) {
|
||||
@ -181,11 +177,7 @@ function backToRoots() {
|
||||
|
||||
async function navigateTo(path: string) {
|
||||
flushVideoProgress()
|
||||
if (path === '') {
|
||||
browsePath.value = []
|
||||
} else {
|
||||
browsePath.value = path.split('/')
|
||||
}
|
||||
browsePath.value = path === '' ? [] : path.split('/')
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
browsePage.value = 1
|
||||
@ -210,6 +202,16 @@ async function changeFilter(type: 'all' | 'video' | 'audio' | 'text') {
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
async function changeSortBy(value: 'name' | 'size' | 'created' | 'type') {
|
||||
sortBy.value = value
|
||||
await changeSort()
|
||||
}
|
||||
|
||||
async function changeSortDirection(value: 'asc' | 'desc') {
|
||||
sortDirection.value = value
|
||||
await changeSort()
|
||||
}
|
||||
|
||||
async function changeSort() {
|
||||
flushVideoProgress()
|
||||
browsePage.value = 1
|
||||
@ -229,15 +231,18 @@ async function changeBrowsePage(page: number) {
|
||||
await browseDirectory()
|
||||
}
|
||||
|
||||
async function doSearch() {
|
||||
async function doSearch(page = 1) {
|
||||
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 })
|
||||
searchPage.value = Math.max(page, 1)
|
||||
const response = await api.searchFiles({ page: searchPage.value, pageSize: searchPageSize.value, keyword })
|
||||
searchResults.value = response.items
|
||||
searchTotal.value = response.total
|
||||
searchTotalPages.value = response.totalPages
|
||||
} catch (error) {
|
||||
setError(error)
|
||||
} finally {
|
||||
@ -245,11 +250,23 @@ async function doSearch() {
|
||||
}
|
||||
}
|
||||
|
||||
async function changeSearchPage(page: number) {
|
||||
const nextPage = Math.min(Math.max(page, 1), Math.max(searchTotalPages.value, 1))
|
||||
if (nextPage === searchPage.value) return
|
||||
flushVideoProgress()
|
||||
selectedFile.value = null
|
||||
textPreview.value = null
|
||||
await doSearch(nextPage)
|
||||
}
|
||||
|
||||
function exitSearch() {
|
||||
flushVideoProgress()
|
||||
isSearching.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
searchPage.value = 1
|
||||
searchTotal.value = 0
|
||||
searchTotalPages.value = 0
|
||||
}
|
||||
|
||||
function updatePlaybackPosition(id: number, position: number) {
|
||||
@ -267,7 +284,7 @@ function updatePlaybackPosition(id: number, position: number) {
|
||||
|
||||
function saveVideoProgress(position?: number) {
|
||||
if (!selectedFile.value || selectedFile.value.mediaType !== 'video') return
|
||||
const video = videoRef.value
|
||||
const video = getVideoElement()
|
||||
if (!video) return
|
||||
|
||||
const rawPosition = position ?? video.currentTime
|
||||
@ -283,13 +300,13 @@ function saveVideoProgress(position?: number) {
|
||||
}
|
||||
|
||||
function flushVideoProgress() {
|
||||
const video = videoRef.value
|
||||
const video = getVideoElement()
|
||||
if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return
|
||||
saveVideoProgress()
|
||||
}
|
||||
|
||||
function handleVideoTimeUpdate() {
|
||||
const video = videoRef.value
|
||||
const video = getVideoElement()
|
||||
if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return
|
||||
|
||||
const now = Date.now()
|
||||
@ -328,7 +345,7 @@ function tryResume(file: FileRecordDto) {
|
||||
}
|
||||
|
||||
function seekToResumePosition() {
|
||||
const video = videoRef.value
|
||||
const video = getVideoElement()
|
||||
if (!video || !resumeRequested.value || resumePosition.value <= 0 || video.readyState < 1) return
|
||||
|
||||
const maxPosition = Number.isFinite(video.duration) && video.duration > 1
|
||||
@ -342,13 +359,11 @@ function resumePlayback() {
|
||||
showResumePrompt.value = false
|
||||
resumeRequested.value = true
|
||||
seekToResumePosition()
|
||||
videoRef.value?.play().catch(() => {})
|
||||
getMediaPlayer()?.playVideo()?.catch(() => {})
|
||||
}
|
||||
|
||||
function dismissResume() {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.currentTime = 0
|
||||
}
|
||||
getMediaPlayer()?.resetVideo()
|
||||
saveVideoProgress(0)
|
||||
resetResume()
|
||||
}
|
||||
@ -411,283 +426,173 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<main class="client-shell">
|
||||
<header class="mobile-header">
|
||||
<div>
|
||||
<h1>{{ clientTitle }}</h1>
|
||||
<p>
|
||||
<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.total }} 个文件
|
||||
</template>
|
||||
</p>
|
||||
</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>
|
||||
</header>
|
||||
<ClientHeader
|
||||
v-model:search-query="searchQuery"
|
||||
:title="clientTitle"
|
||||
:active-tab="activeTab"
|
||||
:recent-count="recentFiles.length"
|
||||
:active-roots-count="activeRoots.length"
|
||||
:is-browsing-roots="isBrowsingRoots"
|
||||
:is-searching="isSearching"
|
||||
:browse-data="browseData"
|
||||
@search="doSearch"
|
||||
@back="backToRoots"
|
||||
@exit-search="exitSearch"
|
||||
@open-qr="qrModal?.open()"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<RootTabs
|
||||
v-if="isBrowsingRoots && !isSearching"
|
||||
:active-tab="activeTab"
|
||||
@switch-tab="switchTab"
|
||||
/>
|
||||
|
||||
<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
|
||||
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>
|
||||
|
||||
<!-- Search results -->
|
||||
<section v-if="isSearching" class="recent-files">
|
||||
<div class="view-toggle-bar">
|
||||
<h3 class="search-results-title">
|
||||
搜索"{{ searchQuery }}" {{ searchResults.length }} 个结果
|
||||
搜索"{{ searchQuery }}" {{ searchTotal }} 个结果
|
||||
</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>
|
||||
<ViewToggle v-model:view-mode="viewMode" />
|
||||
</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>
|
||||
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</div>
|
||||
</button>
|
||||
<template v-for="file in searchResults" :key="file.id">
|
||||
<FileCard :file="file" show-created-time @select="selectSearchFile" />
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</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>
|
||||
<small v-if="formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
<template v-for="file in searchResults" :key="file.id">
|
||||
<FileListItem :file="file" show-created-time @select="selectSearchFile" />
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<MobilePager
|
||||
:page="searchPage"
|
||||
:total-pages="searchTotalPages"
|
||||
:total="searchTotal"
|
||||
@change-page="changeSearchPage"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Recently added / played files -->
|
||||
<section v-if="isBrowsingRoots && !isSearching && 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>
|
||||
<ViewToggle v-model:view-mode="viewMode" />
|
||||
</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"
|
||||
<template v-for="file in recentFiles" :key="file.id">
|
||||
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" />
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
<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 && !isSearching && activeTab === 'libraries'" class="root-picker">
|
||||
<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 -->
|
||||
<template v-else-if="!isBrowsingRoots && !isSearching">
|
||||
<!-- 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>
|
||||
</div>
|
||||
<div v-else class="file-list">
|
||||
<template v-for="file in recentFiles" :key="file.id">
|
||||
<FileListItem
|
||||
:file="file"
|
||||
:selected="selectedFile?.id === file.id"
|
||||
:show-last-played="activeTab === 'recent-played'"
|
||||
@select="selectFile"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RootPicker
|
||||
v-if="isBrowsingRoots && !isSearching && activeTab === 'libraries'"
|
||||
:roots="activeRoots"
|
||||
@enter-root="enterRoot"
|
||||
/>
|
||||
|
||||
<template v-else-if="!isBrowsingRoots && !isSearching">
|
||||
<BreadcrumbNav :breadcrumbs="breadcrumbs" @navigate-to="navigateTo" />
|
||||
|
||||
<section v-if="browseData" class="browse-content">
|
||||
<!-- Subdirectories -->
|
||||
<section v-if="browseData.subdirectories.length > 0" class="browse-section">
|
||||
<h3>文件夹</h3>
|
||||
<div class="folder-grid">
|
||||
@ -704,103 +609,86 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Files -->
|
||||
<section class="browse-section">
|
||||
<div class="section-header">
|
||||
<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="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>
|
||||
<BrowseToolbar
|
||||
v-model:view-mode="viewMode"
|
||||
:filter-type="filterType"
|
||||
:sort-by="sortBy"
|
||||
:sort-direction="sortDirection"
|
||||
@change-filter="changeFilter"
|
||||
@change-sort-by="changeSortBy"
|
||||
@change-sort-direction="changeSortDirection"
|
||||
/>
|
||||
|
||||
<!-- Grid view -->
|
||||
<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>
|
||||
<FileCard
|
||||
:file="file"
|
||||
:selected="selectedFile?.id === file.id"
|
||||
show-created-time
|
||||
@select="selectFile"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<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>
|
||||
<FileListItem
|
||||
:file="file"
|
||||
:selected="selectedFile?.id === file.id"
|
||||
show-created-time
|
||||
@select="selectFile"
|
||||
/>
|
||||
<SelectedMediaPlayerHost
|
||||
v-if="selectedFile?.id === file.id"
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="resumePlayback"
|
||||
@dismiss-resume="dismissResume"
|
||||
@loadedmetadata="seekToResumePosition"
|
||||
@canplay="seekToResumePosition"
|
||||
@play="handleVideoPlay"
|
||||
@timeupdate="handleVideoTimeUpdate"
|
||||
@pause="handleVideoPause"
|
||||
@ended="handleVideoEnded"
|
||||
@seeked="handleVideoPause"
|
||||
/>
|
||||
</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>
|
||||
<MobilePager
|
||||
:page="browseData.page"
|
||||
:total-pages="browseData.totalPages"
|
||||
:total="browseData.total"
|
||||
@change-page="changeBrowsePage"
|
||||
/>
|
||||
<p v-if="displayedFiles.length === 0 && filterType !== 'all'" class="empty-state">当前分类没有文件</p>
|
||||
</section>
|
||||
|
||||
<p v-if="browseData.subdirectories.length === 0 && browseData.total === 0 && filterType === 'all'" class="empty-state">
|
||||
|
||||
25
FileShare-Web-VUE/src/components/client/BreadcrumbNav.vue
Normal file
25
FileShare-Web-VUE/src/components/client/BreadcrumbNav.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
breadcrumbs: { label: string, path: string }[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
navigateTo: [path: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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="$emit('navigateTo', crumb.path)"
|
||||
>
|
||||
{{ crumb.label }}
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
53
FileShare-Web-VUE/src/components/client/BrowseToolbar.vue
Normal file
53
FileShare-Web-VUE/src/components/client/BrowseToolbar.vue
Normal file
@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import ViewToggle from './ViewToggle.vue'
|
||||
|
||||
defineProps<{
|
||||
filterType: 'all' | 'video' | 'audio' | 'text'
|
||||
sortBy: 'name' | 'size' | 'created' | 'type'
|
||||
sortDirection: 'asc' | 'desc'
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
changeFilter: [type: 'all' | 'video' | 'audio' | 'text']
|
||||
changeSortBy: [value: 'name' | 'size' | 'created' | 'type']
|
||||
changeSortDirection: [value: 'asc' | 'desc']
|
||||
'update:viewMode': [value: 'list' | 'grid']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-header">
|
||||
<div class="filter-chips">
|
||||
<button type="button" :class="{ active: filterType === 'all' }" @click="$emit('changeFilter', 'all')">全部</button>
|
||||
<button type="button" :class="{ active: filterType === 'video' }" @click="$emit('changeFilter', 'video')">视频</button>
|
||||
<button type="button" :class="{ active: filterType === 'audio' }" @click="$emit('changeFilter', 'audio')">音频</button>
|
||||
<button type="button" :class="{ active: filterType === 'text' }" @click="$emit('changeFilter', 'text')">文本</button>
|
||||
</div>
|
||||
<div class="file-toolbar-right">
|
||||
<label class="sort-control">
|
||||
<span>排序</span>
|
||||
<select
|
||||
:value="sortBy"
|
||||
@change="$emit('changeSortBy', ($event.target as HTMLSelectElement).value as 'name' | 'size' | 'created' | 'type')"
|
||||
>
|
||||
<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
|
||||
:value="sortDirection"
|
||||
@change="$emit('changeSortDirection', ($event.target as HTMLSelectElement).value as 'asc' | 'desc')"
|
||||
>
|
||||
<option value="asc">升序</option>
|
||||
<option value="desc">降序</option>
|
||||
</select>
|
||||
</label>
|
||||
<ViewToggle :view-mode="viewMode" @update:view-mode="$emit('update:viewMode', $event)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
54
FileShare-Web-VUE/src/components/client/ClientHeader.vue
Normal file
54
FileShare-Web-VUE/src/components/client/ClientHeader.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { BrowseDirectoryResponse } from '../../api'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
activeTab: 'recent-added' | 'recent-played' | 'libraries'
|
||||
recentCount: number
|
||||
activeRootsCount: number
|
||||
isBrowsingRoots: boolean
|
||||
isSearching: boolean
|
||||
browseData: BrowseDirectoryResponse | null
|
||||
searchQuery: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:searchQuery': [value: string]
|
||||
search: []
|
||||
back: []
|
||||
exitSearch: []
|
||||
openQr: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="mobile-header">
|
||||
<div>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>
|
||||
<template v-if="activeTab === 'recent-added'">{{ recentCount }} 个文件</template>
|
||||
<template v-else-if="activeTab === 'recent-played'">{{ recentCount }} 个文件</template>
|
||||
<template v-else-if="isBrowsingRoots">{{ activeRootsCount }} 个目录</template>
|
||||
<template v-else-if="browseData">
|
||||
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.total }} 个文件
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="mobile-header-actions">
|
||||
<button v-if="!isBrowsingRoots" type="button" class="back-button" @click="$emit('back')">返回</button>
|
||||
<button v-if="isSearching" type="button" class="back-button" @click="$emit('exitSearch')">返回</button>
|
||||
<form class="search-form" @submit.prevent="$emit('search')">
|
||||
<input
|
||||
:value="searchQuery"
|
||||
type="search"
|
||||
placeholder="搜索文件..."
|
||||
class="search-input"
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button type="submit" class="search-submit">搜索</button>
|
||||
</form>
|
||||
<button type="button" class="qr-button" title="生成二维码" @click="$emit('openQr')">二维码</button>
|
||||
<a href="/admin" class="admin-link">管理</a>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
118
FileShare-Web-VUE/src/components/client/ClientMediaPlayer.vue
Normal file
118
FileShare-Web-VUE/src/components/client/ClientMediaPlayer.vue
Normal file
@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { FileRecordDto, LibraryRootDto, TextPreviewDto } from '../../api'
|
||||
import { formatDuration, formatSize } from '../../utils/formatters'
|
||||
|
||||
defineProps<{
|
||||
selectedFile: FileRecordDto
|
||||
selectedRoot?: LibraryRootDto
|
||||
selectedMediaUrl: string
|
||||
selectedThumbnailUrl: string
|
||||
showResumePrompt: boolean
|
||||
resumePosition: number
|
||||
textPreview: TextPreviewDto | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
resumePlayback: []
|
||||
dismissResume: []
|
||||
loadedmetadata: []
|
||||
canplay: []
|
||||
play: []
|
||||
timeupdate: []
|
||||
pause: []
|
||||
ended: []
|
||||
seeked: []
|
||||
}>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
defineExpose({
|
||||
getVideoElement: () => videoRef.value,
|
||||
playVideo: () => videoRef.value?.play(),
|
||||
resetVideo: () => {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.currentTime = 0
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section 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="$emit('resumePlayback')">继续</button>
|
||||
<button type="button" class="resume-no" @click="$emit('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="$emit('loadedmetadata')"
|
||||
@canplay="$emit('canplay')"
|
||||
@play="$emit('play')"
|
||||
@timeupdate="$emit('timeupdate')"
|
||||
@pause="$emit('pause')"
|
||||
@ended="$emit('ended')"
|
||||
@seeked="$emit('seeked')"
|
||||
>
|
||||
<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>
|
||||
</template>
|
||||
40
FileShare-Web-VUE/src/components/client/FileCard.vue
Normal file
40
FileShare-Web-VUE/src/components/client/FileCard.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { api, type FileRecordDto } from '../../api'
|
||||
import { formatCreatedTime, formatDuration, formatSize } from '../../utils/formatters'
|
||||
|
||||
defineProps<{
|
||||
file: FileRecordDto
|
||||
selected?: boolean
|
||||
showCreatedTime?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [file: FileRecordDto]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="file-card"
|
||||
:class="{ active: selected }"
|
||||
type="button"
|
||||
@click="$emit('select', 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="showCreatedTime && formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
42
FileShare-Web-VUE/src/components/client/FileListItem.vue
Normal file
42
FileShare-Web-VUE/src/components/client/FileListItem.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { api, type FileRecordDto } from '../../api'
|
||||
import { formatCreatedTime, formatDate, formatDuration, formatSize } from '../../utils/formatters'
|
||||
|
||||
defineProps<{
|
||||
file: FileRecordDto
|
||||
selected?: boolean
|
||||
showCreatedTime?: boolean
|
||||
showLastPlayed?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [file: FileRecordDto]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="mobile-file"
|
||||
:class="{ active: selected }"
|
||||
type="button"
|
||||
@click="$emit('select', 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="showLastPlayed && file.lastPlayedAt"> · {{ formatDate(file.lastPlayedAt) }}</template>
|
||||
</small>
|
||||
<small v-if="showCreatedTime && formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
19
FileShare-Web-VUE/src/components/client/MobilePager.vue
Normal file
19
FileShare-Web-VUE/src/components/client/MobilePager.vue
Normal file
@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
changePage: [page: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="mobile-pager">
|
||||
<button type="button" :disabled="page <= 1" @click="$emit('changePage', page - 1)">上一页</button>
|
||||
<span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ total }} 个</span>
|
||||
<button type="button" :disabled="page >= totalPages" @click="$emit('changePage', page + 1)">下一页</button>
|
||||
</div>
|
||||
</template>
|
||||
28
FileShare-Web-VUE/src/components/client/RootPicker.vue
Normal file
28
FileShare-Web-VUE/src/components/client/RootPicker.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { LibraryRootDto } from '../../api'
|
||||
|
||||
defineProps<{
|
||||
roots: LibraryRootDto[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
enterRoot: [id: number]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="root-picker">
|
||||
<button
|
||||
v-for="root in roots"
|
||||
:key="root.id"
|
||||
class="root-tile"
|
||||
type="button"
|
||||
@click="$emit('enterRoot', root.id)"
|
||||
>
|
||||
<span>{{ root.displayName }}</span>
|
||||
<strong>{{ root.fileCount }}</strong>
|
||||
<small>{{ root.path }}</small>
|
||||
</button>
|
||||
<p v-if="roots.length === 0" class="empty-state">暂无可访问目录</p>
|
||||
</section>
|
||||
</template>
|
||||
29
FileShare-Web-VUE/src/components/client/RootTabs.vue
Normal file
29
FileShare-Web-VUE/src/components/client/RootTabs.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
activeTab: 'recent-added' | 'recent-played' | 'libraries'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
switchTab: [tab: 'recent-added' | 'recent-played' | 'libraries']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="root-tabs">
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'recent-added' }"
|
||||
@click="$emit('switchTab', 'recent-added')"
|
||||
>最近添加</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'recent-played' }"
|
||||
@click="$emit('switchTab', 'recent-played')"
|
||||
>最近播放</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="{ active: activeTab === 'libraries' }"
|
||||
@click="$emit('switchTab', 'libraries')"
|
||||
>文件库</button>
|
||||
</nav>
|
||||
</template>
|
||||
@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { FileRecordDto, LibraryRootDto, TextPreviewDto } from '../../api'
|
||||
import ClientMediaPlayer from './ClientMediaPlayer.vue'
|
||||
|
||||
type MediaPlayerHandle = {
|
||||
getVideoElement: () => HTMLVideoElement | null
|
||||
playVideo: () => Promise<void> | undefined
|
||||
resetVideo: () => void
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
selectedFile: FileRecordDto
|
||||
selectedRoot?: LibraryRootDto
|
||||
selectedMediaUrl: string
|
||||
selectedThumbnailUrl: string
|
||||
showResumePrompt: boolean
|
||||
resumePosition: number
|
||||
textPreview: TextPreviewDto | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
resumePlayback: []
|
||||
dismissResume: []
|
||||
loadedmetadata: []
|
||||
canplay: []
|
||||
play: []
|
||||
timeupdate: []
|
||||
pause: []
|
||||
ended: []
|
||||
seeked: []
|
||||
}>()
|
||||
|
||||
const mediaPlayer = ref<MediaPlayerHandle | null>(null)
|
||||
|
||||
defineExpose({
|
||||
getVideoElement: () => mediaPlayer.value?.getVideoElement() ?? null,
|
||||
playVideo: () => mediaPlayer.value?.playVideo(),
|
||||
resetVideo: () => mediaPlayer.value?.resetVideo(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="player-host">
|
||||
<ClientMediaPlayer
|
||||
ref="mediaPlayer"
|
||||
:selected-file="selectedFile"
|
||||
:selected-root="selectedRoot"
|
||||
:selected-media-url="selectedMediaUrl"
|
||||
:selected-thumbnail-url="selectedThumbnailUrl"
|
||||
:show-resume-prompt="showResumePrompt"
|
||||
:resume-position="resumePosition"
|
||||
:text-preview="textPreview"
|
||||
@resume-playback="$emit('resumePlayback')"
|
||||
@dismiss-resume="$emit('dismissResume')"
|
||||
@loadedmetadata="$emit('loadedmetadata')"
|
||||
@canplay="$emit('canplay')"
|
||||
@play="$emit('play')"
|
||||
@timeupdate="$emit('timeupdate')"
|
||||
@pause="$emit('pause')"
|
||||
@ended="$emit('ended')"
|
||||
@seeked="$emit('seeked')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
16
FileShare-Web-VUE/src/components/client/ViewToggle.vue
Normal file
16
FileShare-Web-VUE/src/components/client/ViewToggle.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
viewMode: 'list' | 'grid'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:viewMode': [value: 'list' | 'grid']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="view-toggle">
|
||||
<button type="button" :class="{ active: viewMode === 'list' }" @click="$emit('update:viewMode', 'list')">列表</button>
|
||||
<button type="button" :class="{ active: viewMode === 'grid' }" @click="$emit('update:viewMode', 'grid')">网格</button>
|
||||
</div>
|
||||
</template>
|
||||
29
FileShare-Web-VUE/src/utils/formatters.ts
Normal file
29
FileShare-Web-VUE/src/utils/formatters.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { FileRecordDto } from '../api'
|
||||
|
||||
export 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]}`
|
||||
}
|
||||
|
||||
export 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')}`
|
||||
}
|
||||
|
||||
export function formatDate(value: string | null) {
|
||||
if (!value) return ''
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
export function formatCreatedTime(file: FileRecordDto) {
|
||||
return file.fileCreationTimeUtc ? `创建 ${formatDate(file.fileCreationTimeUtc)}` : ''
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user