feat: Vue Router 集成 + 压缩密码编码修复

前端:
- 引入 vue-router,HTML5 History 模式,干净 URL
- ClientPage.vue 拆分为 5 个独立路由视图组件
- 提取共享状态到 composables(useLibraryRoots/useMediaPlayer/useViewMode/useModals)
- 新增 DefaultLayout 统一布局
- 导航链接改为 router-link
- FileCard 网格视图修复删除/压缩按钮 hover 不显示问题

后端:
- 压缩密码编码修复:DotNetZip/SharpZipLib 不支持 UTF-8 密码
- 替换为 SharpZipLib + 中文字符替换为 # 的 ASCII 安全密码方案
This commit is contained in:
2026-07-10 15:22:09 +08:00
parent 87f5c1ffb8
commit 6bdcb48c97
22 changed files with 2001 additions and 935 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageReference Include="DotNetZip" Version="1.16.0" />
<PackageReference Include="SharpZipLib" Version="1.4.2" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
@@ -2,7 +2,7 @@ using FileShare_Common.Core;
using FileShare_EFCore.Database;
using FileShare_EFCore.Models;
using FileShare_Services.Core;
using Ionic.Zip;
using ICSharpCode.SharpZipLib.Zip;
using Microsoft.EntityFrameworkCore;
using System.Text;
@@ -614,14 +614,25 @@ namespace FileShare_Services.Services.FileLibrary
File.Delete(zipPath);
}
// 密码为文件名去掉后缀
var zipPassword = nameWithoutExt.ToLower();
// 密码为文件名去掉后缀(中文转拼音,转小写)
var zipPassword = ConvertToAsciiPassword(nameWithoutExt);
// 创建 ZIP
using var zip = new Ionic.Zip.ZipFile();
zip.Password = zipPassword;
zip.AddFile(record.AbsolutePath, "");
zip.Save(zipPath);
// 使用 SharpZipLib 创建加密 ZIP(支持 UTF-8 密码)
using var outputStream = File.Create(zipPath);
using var zipStream = new ZipOutputStream(outputStream);
zipStream.Password = zipPassword;
zipStream.UseZip64 = UseZip64.Dynamic;
var entry = new ZipEntry(Path.GetFileName(record.AbsolutePath));
entry.DateTime = File.GetLastWriteTime(record.AbsolutePath);
zipStream.PutNextEntry(entry);
using (var fileStream = File.OpenRead(record.AbsolutePath))
{
await fileStream.CopyToAsync(zipStream, cancellationToken);
}
zipStream.CloseEntry();
}
/// <summary>
@@ -881,6 +892,22 @@ namespace FileShare_Services.Services.FileLibrary
|| sortDirection?.Trim().Equals("descending", StringComparison.OrdinalIgnoreCase) == true;
}
/// <summary>
/// 将文件名转换为 ASCII 安全的密码。中文字符替换为 #,其他字符保留原样。
/// </summary>
private static string ConvertToAsciiPassword(string fileName)
{
var sb = new StringBuilder(fileName.Length);
foreach (var ch in fileName)
{
if (ch >= 0x4E00 && ch <= 0x9FFF)
sb.Append('#');
else
sb.Append(ch);
}
return sb.ToString().ToLower();
}
/// <summary>
/// 安全获取驱动器属性值,驱动器未就绪或访问异常时返回 null。
/// </summary>
+523 -148
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -15,7 +15,8 @@
},
"dependencies": {
"axios": "^1.15.2",
"vue": "^3.5.32"
"vue": "^3.5.32",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
+1 -7
View File
@@ -1,12 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import AdminPage from './components/AdminPage.vue'
import ClientPage from './components/ClientPage.vue'
const isAdminPage = computed(() => window.location.pathname.toLowerCase().startsWith('/admin'))
</script>
<template>
<AdminPage v-if="isAdminPage" />
<ClientPage v-else />
<router-view />
</template>
@@ -151,7 +151,7 @@ onMounted(async () => {
<h2>添加扫描目录</h2>
<p>选择服务器路径或直接输入绝对路径</p>
</div>
<a href="/" class="client-link">客户端</a>
<router-link to="/" class="client-link">客户端</router-link>
</div>
<label class="field">
@@ -1,767 +0,0 @@
<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'
import ConfirmModal from './ConfirmModal.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)
const textPreview = ref<TextPreviewDto | null>(null)
const loading = ref(false)
const errorMessage = ref('')
const rootId = ref<number | undefined>()
const browsePath = ref<string[]>([])
const isBrowsingRoots = ref(true)
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 confirmModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const mediaPlayer = ref<MediaPlayerHandle | null>(null)
const compressingIds = ref(new Set<number>())
function setMediaPlayer(el: unknown) {
mediaPlayer.value = (el as MediaPlayerHandle) ?? null
}
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)
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(10)
const resumePosition = ref(0)
const showResumePrompt = ref(false)
const resumeRequested = ref(false)
let lastPositionSave = 0
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
const selectedRoot = computed(() => roots.value.find((root) => root.id === selectedFile.value?.libraryRootId))
const currentBrowsePath = computed(() => browsePath.value.join('/'))
const breadcrumbs = computed(() => {
const root = roots.value.find((r) => r.id === rootId.value)
const items = [{ label: root?.displayName ?? '文件库', path: '' }]
browsePath.value.forEach((label, index) => {
items.push({
label,
path: browsePath.value.slice(0, index + 1).join('/'),
})
})
return items
})
const displayedFiles = computed(() => browseData.value?.files ?? [])
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
const selectedThumbnailUrl = computed(() =>
selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : ''
)
const clientTitle = computed(() => {
if (activeTab.value === 'recent-added') return '最近添加'
if (activeTab.value === 'recent-played') return '最近播放'
if (isBrowsingRoots.value) return '文件库'
const root = roots.value.find((r) => r.id === rootId.value)
const dir = browsePath.value.length > 0 ? browsePath.value[browsePath.value.length - 1] : ''
return dir || (root?.displayName ?? '文件')
})
function getVideoElement() {
return mediaPlayer.value?.getVideoElement() ?? null
}
function getMediaPlayer() {
return mediaPlayer.value
}
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
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 {
loading.value = false
}
}
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') {
flushVideoProgress()
activeTab.value = tab
isBrowsingRoots.value = true
browseData.value = null
selectedFile.value = null
textPreview.value = null
if (tab === 'recent-added') loadRecentFiles('added')
else if (tab === 'recent-played') loadRecentFiles('played')
}
async function enterRoot(id: number) {
flushVideoProgress()
rootId.value = id
isBrowsingRoots.value = false
browsePath.value = []
browsePage.value = 1
selectedFile.value = null
textPreview.value = null
activeTab.value = 'libraries'
await browseDirectory()
}
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()
browsePath.value = path === '' ? [] : path.split('/')
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()
}
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
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(page = 1) {
const keyword = searchQuery.value.trim()
if (!keyword) return
try {
errorMessage.value = ''
searchLoading.value = true
isSearching.value = true
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 {
searchLoading.value = false
}
}
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
}
async function deleteFile(file: FileRecordDto) {
const confirmed = await confirmModal.value?.open({
title: '确认删除',
message: `确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`,
confirmText: '删除',
danger: true,
})
if (!confirmed) return
try {
await api.deleteFile(file.id)
await browseDirectory()
if (isSearching.value) {
await doSearch(searchPage.value)
}
if (selectedFile.value?.id === file.id) {
selectedFile.value = null
}
} catch (error) {
setError(error)
}
}
async function compressFile(file: FileRecordDto) {
const nameWithoutExt = file.fileName.replace(/\.[^.]+$/, '')
const confirmed = await confirmModal.value?.open({
title: '确认压缩',
message: `确定要将 "${file.fileName}" 压缩为 ZIP 吗?\n\n密码将自动设置为:${nameWithoutExt.toLocaleLowerCase()}`,
confirmText: '压缩',
})
if (!confirmed) return
compressingIds.value.add(file.id)
try {
await api.compressFile(file.id)
await confirmModal.value?.open({
title: '压缩完成',
message: `"${file.fileName}" 已压缩为 "${nameWithoutExt}.zip"。`,
showCancel: false,
confirmText: '确定',
})
} catch (error) {
setError(error)
} finally {
compressingIds.value.delete(file.id)
}
}
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 = getVideoElement()
if (!video) 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)
.then(() => updatePlaybackPosition(fileId, nextPosition))
.catch(() => {})
}
function flushVideoProgress() {
const video = getVideoElement()
if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return
saveVideoProgress()
}
function handleVideoTimeUpdate() {
const video = getVideoElement()
if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return
const now = Date.now()
if (now - lastPositionSave < 5000) return
lastPositionSave = now
saveVideoProgress()
}
function handleVideoPause() {
flushVideoProgress()
}
function handleVideoPlay() {
if (showResumePrompt.value && resumePosition.value > 0) {
resumeRequested.value = true
}
seekToResumePosition()
showResumePrompt.value = false
}
function handleVideoEnded() {
saveVideoProgress(0)
}
function resetResume() {
resumePosition.value = 0
resumeRequested.value = false
showResumePrompt.value = false
}
function tryResume(file: FileRecordDto) {
if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return
resumePosition.value = file.playbackPosition
resumeRequested.value = false
showResumePrompt.value = true
}
function seekToResumePosition() {
const video = getVideoElement()
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()
getMediaPlayer()?.playVideo()?.catch(() => {})
}
function dismissResume() {
getMediaPlayer()?.resetVideo()
saveVideoProgress(0)
resetResume()
}
async function selectSearchFile(file: FileRecordDto) {
const relativeParts = file.relativePath.split(/[\\/]/).filter(Boolean)
relativeParts.pop()
rootId.value = file.libraryRootId
browsePath.value = relativeParts
browsePage.value = 1
isBrowsingRoots.value = false
activeTab.value = 'libraries'
exitSearch()
await browseDirectory()
await selectFile(browseData.value?.files.find((item) => item.id === file.id) ?? file)
}
async function selectFile(file: FileRecordDto) {
flushVideoProgress()
resetResume()
lastPositionSave = 0
selectedFile.value = file
textPreview.value = null
if (file.mediaType === 'video') {
api.markFilePlayed(file.id).catch(() => {})
tryResume(file)
} else if (file.mediaType === 'audio') {
api.markFilePlayed(file.id).catch(() => {})
}
if (file.mediaType !== 'text') return
try {
textPreview.value = await api.getTextPreview(file.id)
} catch (error) {
setError(error)
}
}
onMounted(async () => {
window.addEventListener('beforeunload', flushVideoProgress)
loading.value = true
try {
await loadRoots()
} catch (error) {
setError(error)
} finally {
loading.value = false
}
})
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', flushVideoProgress)
flushVideoProgress()
})
</script>
<template>
<main class="client-shell">
<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>
<RootTabs
v-if="isBrowsingRoots && !isSearching"
:active-tab="activeTab"
@switch-tab="switchTab"
/>
<section v-if="isSearching" class="recent-files">
<div class="view-toggle-bar">
<h3 class="search-results-title">
搜索"{{ searchQuery }}" {{ searchTotal }} 个结果
</h3>
<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>
<div v-else-if="viewMode === 'grid'" class="file-grid">
<template v-for="file in searchResults" :key="file.id">
<FileCard :file="file" show-created-time :compressing="compressingIds.has(file.id)" @select="selectSearchFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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-else class="file-list">
<template v-for="file in searchResults" :key="file.id">
<FileListItem :file="file" show-created-time :compressing="compressingIds.has(file.id)" @select="selectSearchFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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>
<section v-if="isBrowsingRoots && !isSearching && activeTab !== 'libraries'" class="recent-files">
<div class="view-toggle-bar">
<div></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>
<div v-else-if="viewMode === 'grid'" class="file-grid">
<template v-for="file in recentFiles" :key="file.id">
<FileCard :file="file" :selected="selectedFile?.id === file.id" :compressing="compressingIds.has(file.id)" @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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-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'"
:compressing="compressingIds.has(file.id)"
@select="selectFile"
@delete="deleteFile"
@compress="compressFile"
/>
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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">
<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">&#128193;</span>
<span>{{ dir }}</span>
</button>
</div>
</section>
<section class="browse-section">
<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"
/>
<div v-if="displayedFiles.length > 0 && viewMode === 'grid'" class="file-grid">
<template v-for="file in displayedFiles" :key="file.id">
<FileCard
:file="file"
:selected="selectedFile?.id === file.id"
:compressing="compressingIds.has(file.id)"
show-created-time
@select="selectFile"
@delete="deleteFile"
@compress="compressFile"
/>
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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-else-if="displayedFiles.length > 0" class="file-list">
<template v-for="file in displayedFiles" :key="file.id">
<FileListItem
:file="file"
:selected="selectedFile?.id === file.id"
:compressing="compressingIds.has(file.id)"
show-created-time
@select="selectFile"
@delete="deleteFile"
@compress="compressFile"
/>
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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="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">
此目录下没有支持的文件
</p>
</section>
<p v-else-if="!loading" class="empty-state">加载中...</p>
</template>
</main>
<QrCodeModal ref="qrModal" />
<ConfirmModal ref="confirmModal" />
</template>
@@ -48,7 +48,7 @@ defineEmits<{
<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>
<router-link to="/admin" class="admin-link">管理</router-link>
</div>
</header>
</template>
@@ -50,6 +50,10 @@ defineEmits<{
</template>
<style scoped>
.file-card {
position: relative;
overflow: visible;
}
.delete-btn {
position: absolute;
top: 4px;
@@ -0,0 +1,20 @@
import { ref, computed } from 'vue'
import { api, type LibraryRootDto } from '../api'
const roots = ref<LibraryRootDto[]>([])
const loading = ref(false)
const activeRoots = computed(() => roots.value.filter(r => r.isEnabled && r.isAvailable))
async function loadRoots() {
loading.value = true
try {
roots.value = await api.getRoots()
} finally {
loading.value = false
}
}
export function useLibraryRoots() {
return { roots, loading, activeRoots, loadRoots }
}
@@ -0,0 +1,138 @@
import { ref, computed } from 'vue'
import { api, type FileRecordDto, type TextPreviewDto } from '../api'
type MediaPlayerHandle = {
getVideoElement: () => HTMLVideoElement | null
playVideo: () => Promise<void> | undefined
resetVideo: () => void
}
const selectedFile = ref<FileRecordDto | null>(null)
const textPreview = ref<TextPreviewDto | null>(null)
const mediaPlayer = ref<MediaPlayerHandle | null>(null)
const resumePosition = ref(0)
const showResumePrompt = ref(false)
const resumeRequested = ref(false)
let lastPositionSave = 0
function getVideoElement() {
return mediaPlayer.value?.getVideoElement() ?? null
}
function setMediaPlayer(el: unknown) {
mediaPlayer.value = (el as MediaPlayerHandle) ?? null
}
function updatePlaybackPosition(id: number, position: number) {
if (selectedFile.value?.id === id) {
selectedFile.value.playbackPosition = position
}
}
function saveVideoProgress(position?: number) {
if (!selectedFile.value || selectedFile.value.mediaType !== 'video') return
const video = getVideoElement()
if (!video) 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)
.then(() => updatePlaybackPosition(fileId, nextPosition))
.catch(() => {})
}
function flushVideoProgress() {
const video = getVideoElement()
if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return
saveVideoProgress()
}
function resetResume() {
resumePosition.value = 0
resumeRequested.value = false
showResumePrompt.value = false
}
function tryResume(file: FileRecordDto) {
if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return
resumePosition.value = file.playbackPosition
resumeRequested.value = false
showResumePrompt.value = true
}
function seekToResumePosition() {
const video = getVideoElement()
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()
mediaPlayer.value?.playVideo()?.catch(() => {})
}
function dismissResume() {
mediaPlayer.value?.resetVideo()
saveVideoProgress(0)
resetResume()
}
function handleVideoPlay() {
if (showResumePrompt.value && resumePosition.value > 0) {
resumeRequested.value = true
}
seekToResumePosition()
showResumePrompt.value = false
}
function handleVideoTimeUpdate() {
const video = getVideoElement()
if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return
const now = Date.now()
if (now - lastPositionSave < 5000) return
lastPositionSave = now
saveVideoProgress()
}
function handleVideoPause() { flushVideoProgress() }
function handleVideoEnded() { saveVideoProgress(0) }
async function selectFile(file: FileRecordDto) {
flushVideoProgress()
resetResume()
lastPositionSave = 0
selectedFile.value = file
textPreview.value = null
if (file.mediaType === 'video' || file.mediaType === 'audio') {
api.markFilePlayed(file.id).catch(() => {})
if (file.mediaType === 'video') tryResume(file)
}
if (file.mediaType === 'text') {
try { textPreview.value = await api.getTextPreview(file.id) } catch {}
}
}
function clearSelection() {
flushVideoProgress()
selectedFile.value = null
textPreview.value = null
}
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
const selectedThumbnailUrl = computed(() => selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : '')
export function useMediaPlayer() {
return {
selectedFile, textPreview, mediaPlayer, resumePosition, showResumePrompt, resumeRequested,
setMediaPlayer, flushVideoProgress, clearSelection, selectFile, seekToResumePosition,
resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded,
selectedMediaUrl, selectedThumbnailUrl,
}
}
@@ -0,0 +1,10 @@
import { ref } from 'vue'
import type ConfirmModal from '../components/ConfirmModal.vue'
import type QrCodeModal from '../components/QrCodeModal.vue'
const confirmModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
export function useModals() {
return { confirmModal, qrModal }
}
@@ -0,0 +1,7 @@
import { ref } from 'vue'
const viewMode = ref<'list' | 'grid'>('list')
export function useViewMode() {
return { viewMode }
}
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ClientHeader from '../components/client/ClientHeader.vue'
import QrCodeModal from '../components/QrCodeModal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import { useLibraryRoots } from '../composables/useLibraryRoots'
import { useMediaPlayer } from '../composables/useMediaPlayer'
import { useModals } from '../composables/useModals'
const route = useRoute()
const router = useRouter()
const { activeRoots, loadRoots } = useLibraryRoots()
const { selectedFile, flushVideoProgress } = useMediaPlayer()
const { confirmModal, qrModal } = useModals()
const searchQuery = ref('')
const errorMessage = ref('')
const isSearching = computed(() => route.name === 'search')
const isBrowsingRoots = computed(() => {
const name = route.name as string
return name === 'home' || name === 'recent-added' || name === 'recent-played'
})
const activeTab = computed<'recent-added' | 'recent-played' | 'libraries'>(() => {
const name = route.name as string
if (name === 'recent-added') return 'recent-added'
if (name === 'recent-played') return 'recent-played'
return 'libraries'
})
const clientTitle = computed(() => {
if (activeTab.value === 'recent-added') return '最近添加'
if (activeTab.value === 'recent-played') return '最近播放'
if (isBrowsingRoots.value) return '文件库'
return '文件'
})
async function doSearch() {
const keyword = searchQuery.value.trim()
if (!keyword) return
flushVideoProgress()
router.push({ name: 'search', query: { q: keyword } })
}
function backToRoots() {
flushVideoProgress()
router.push('/')
}
function exitSearch() {
flushVideoProgress()
searchQuery.value = ''
router.push('/')
}
onMounted(() => {
window.addEventListener('beforeunload', flushVideoProgress)
if (activeRoots.value.length === 0) loadRoots()
})
</script>
<template>
<main class="client-shell">
<ClientHeader
v-model:search-query="searchQuery"
:title="clientTitle"
:active-tab="activeTab"
:recent-count="0"
:active-roots-count="activeRoots.length"
:is-browsing-roots="isBrowsingRoots"
:is-searching="isSearching"
:browse-data="null"
@search="doSearch"
@back="backToRoots"
@exit-search="exitSearch"
@open-qr="qrModal?.open()"
/>
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<router-view />
</main>
<QrCodeModal ref="qrModal" />
<ConfirmModal ref="confirmModal" />
</template>
+2 -1
View File
@@ -2,5 +2,6 @@ import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).mount('#app')
createApp(App).use(router).mount('#app')
+26
View File
@@ -0,0 +1,26 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('../layouts/DefaultLayout.vue'),
children: [
{ path: '', name: 'home', component: () => import('../views/RootPickerView.vue') },
{ path: 'recent/added', name: 'recent-added', component: () => import('../views/RecentAddedView.vue') },
{ path: 'recent/played', name: 'recent-played', component: () => import('../views/RecentPlayedView.vue') },
{ path: 'browse/:rootId', name: 'browse', component: () => import('../views/BrowseView.vue'), props: true },
{ path: 'browse/:rootId/*', name: 'browse-path', component: () => import('../views/BrowseView.vue'), props: true },
{ path: 'search', name: 'search', component: () => import('../views/SearchView.vue') },
],
},
{
path: '/admin',
name: 'admin',
component: () => import('../components/AdminPage.vue'),
},
],
})
export default router
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type BrowseDirectoryResponse, type FileRecordDto } from '../api'
import BreadcrumbNav from '../components/client/BreadcrumbNav.vue'
import BrowseToolbar from '../components/client/BrowseToolbar.vue'
import FileCard from '../components/client/FileCard.vue'
import FileListItem from '../components/client/FileListItem.vue'
import SelectedMediaPlayerHost from '../components/client/SelectedMediaPlayerHost.vue'
import MobilePager from '../components/client/MobilePager.vue'
import { useMediaPlayer } from '../composables/useMediaPlayer'
import { useViewMode } from '../composables/useViewMode'
import { useModals } from '../composables/useModals'
import { useLibraryRoots } from '../composables/useLibraryRoots'
const route = useRoute()
const router = useRouter()
const { roots } = useLibraryRoots()
const { selectedFile, selectedMediaUrl, selectedThumbnailUrl, showResumePrompt, resumePosition, textPreview, setMediaPlayer, flushVideoProgress, clearSelection, selectFile, seekToResumePosition, resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded } = useMediaPlayer()
const selectedRoot = computed(() => roots.value.find(r => r.id === selectedFile.value?.libraryRootId))
const { viewMode } = useViewMode()
const { confirmModal } = useModals()
const browseData = ref<BrowseDirectoryResponse | null>(null)
const loading = ref(false)
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(10)
const compressingIds = ref(new Set<number>())
const rootId = computed(() => Number(route.params.rootId))
const pathFromRoute = computed(() => {
const wildcard = route.params.path as string | undefined
if (!wildcard) return ''
return decodeURIComponent(wirecard)
})
const browsePathArray = computed(() => pathFromRoute.value ? pathFromRoute.value.split('/') : [])
const currentBrowsePath = computed(() => browsePathArray.value.join('/'))
const displayedFiles = computed(() => browseData.value?.files ?? [])
const breadcrumbs = computed(() => {
const root = roots.value.find(r => r.id === rootId.value)
const items = [{ label: root?.displayName ?? '文件库', path: '' }]
browsePathArray.value.forEach((label, index) => {
items.push({ label, path: browsePathArray.value.slice(0, index + 1).join('/') })
})
return items
})
async function browseDirectory() {
if (!rootId.value) return
loading.value = true
try {
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,
})
} finally { loading.value = false }
}
function navigateTo(path: string) {
flushVideoProgress()
clearSelection()
browsePage.value = 1
router.push(`/browse/${rootId.value}/${path}`)
}
function enterSubdirectory(name: string) {
flushVideoProgress()
clearSelection()
browsePage.value = 1
const newPath = currentBrowsePath.value ? `${currentBrowsePath.value}/${name}` : name
router.push(`/browse/${rootId.value}/${newPath}`)
}
async function changeFilter(type: 'all' | 'video' | 'audio' | 'text') {
flushVideoProgress()
filterType.value = type
browsePage.value = 1
clearSelection()
await browseDirectory()
}
async function changeSortBy(value: 'name' | 'size' | 'created' | 'type') {
sortBy.value = value
flushVideoProgress()
browsePage.value = 1
clearSelection()
await browseDirectory()
}
async function changeSortDirection(value: 'asc' | 'desc') {
sortDirection.value = value
flushVideoProgress()
browsePage.value = 1
clearSelection()
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
clearSelection()
await browseDirectory()
}
async function deleteFile(file: FileRecordDto) {
const confirmed = await confirmModal.value?.open({
title: '确认删除', message: `确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`, confirmText: '删除', danger: true,
})
if (!confirmed) return
try { await api.deleteFile(file.id); await browseDirectory(); clearSelection() } catch {}
}
async function compressFile(file: FileRecordDto) {
const nameWithoutExt = file.fileName.replace(/\.[^.]+$/, '')
const confirmed = await confirmModal.value?.open({
title: '确认压缩', message: `确定要将 "${file.fileName}" 压缩为 ZIP 吗?\n\n密码为文件名(中文替换为 #`, confirmText: '压缩',
})
if (!confirmed) return
compressingIds.value.add(file.id)
try {
await api.compressFile(file.id)
await confirmModal.value?.open({ title: '压缩完成', message: `"${file.fileName}" 已压缩为 "${nameWithoutExt}.zip"。`, showCancel: false, confirmText: '确定' })
} finally { compressingIds.value.delete(file.id) }
}
watch([() => route.params.rootId, () => route.params.path], () => {
browsePage.value = 1
filterType.value = 'all'
sortBy.value = 'name'
sortDirection.value = 'asc'
clearSelection()
browseDirectory()
}, { immediate: true })
</script>
<template>
<BreadcrumbNav :breadcrumbs="breadcrumbs" @navigate-to="navigateTo" />
<section v-if="browseData" class="browse-content">
<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">&#128193;</span>
<span>{{ dir }}</span>
</button>
</div>
</section>
<section class="browse-section">
<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"
/>
<div v-if="displayedFiles.length > 0 && viewMode === 'grid'" class="file-grid">
<template v-for="file in displayedFiles" :key="file.id">
<FileCard :file="file" :selected="selectedFile?.id === file.id" :compressing="compressingIds.has(file.id)" show-created-time @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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-else-if="displayedFiles.length > 0" class="file-list">
<template v-for="file in displayedFiles" :key="file.id">
<FileListItem :file="file" :selected="selectedFile?.id === file.id" :compressing="compressingIds.has(file.id)" show-created-time @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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="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">
此目录下没有支持的文件
</p>
</section>
<p v-else-if="!loading" class="empty-state">加载中...</p>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, type FileRecordDto } from '../api'
import FileCard from '../components/client/FileCard.vue'
import FileListItem from '../components/client/FileListItem.vue'
import SelectedMediaPlayerHost from '../components/client/SelectedMediaPlayerHost.vue'
import ViewToggle from '../components/client/ViewToggle.vue'
import RootTabs from '../components/client/RootTabs.vue'
import { useMediaPlayer } from '../composables/useMediaPlayer'
import { useViewMode } from '../composables/useViewMode'
import { useModals } from '../composables/useModals'
import { useLibraryRoots } from '../composables/useLibraryRoots'
const router = useRouter()
const { activeRoots, loadRoots, roots } = useLibraryRoots()
const { selectedFile, selectedMediaUrl, selectedThumbnailUrl, showResumePrompt, resumePosition, textPreview, setMediaPlayer, flushVideoProgress, clearSelection, selectFile, seekToResumePosition, resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded } = useMediaPlayer()
const selectedRoot = computed(() => roots.value.find(r => r.id === selectedFile.value?.libraryRootId))
const { viewMode } = useViewMode()
const { confirmModal } = useModals()
const recentFiles = ref<FileRecordDto[]>([])
const recentLoading = ref(false)
const compressingIds = ref(new Set<number>())
async function loadRecentFiles() {
recentLoading.value = true
try { recentFiles.value = await api.getRecentFiles('added', 24) } finally { recentLoading.value = false }
}
async function deleteFile(file: FileRecordDto) {
const confirmed = await confirmModal.value?.open({
title: '确认删除', message: `确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`, confirmText: '删除', danger: true,
})
if (!confirmed) return
try { await api.deleteFile(file.id); await loadRecentFiles(); clearSelection() } catch {}
}
async function compressFile(file: FileRecordDto) {
const nameWithoutExt = file.fileName.replace(/\.[^.]+$/, '')
const confirmed = await confirmModal.value?.open({
title: '确认压缩', message: `确定要将 "${file.fileName}" 压缩为 ZIP 吗?\n\n密码为文件名(中文替换为 #`, confirmText: '压缩',
})
if (!confirmed) return
compressingIds.value.add(file.id)
try {
await api.compressFile(file.id)
await confirmModal.value?.open({ title: '压缩完成', message: `"${file.fileName}" 已压缩为 "${nameWithoutExt}.zip"。`, showCancel: false, confirmText: '确定' })
} finally { compressingIds.value.delete(file.id) }
}
function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
if (tab === 'recent-added') return
if (tab === 'recent-played') router.push('/recent/played')
else if (tab === 'libraries') router.push('/')
}
onMounted(loadRecentFiles)
</script>
<template>
<RootTabs active-tab="recent-added" @switch-tab="switchTab" />
<section class="recent-files">
<div class="view-toggle-bar">
<div></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">暂无最近添加的文件</p>
<div v-else-if="viewMode === 'grid'" class="file-grid">
<template v-for="file in recentFiles" :key="file.id">
<FileCard :file="file" :selected="selectedFile?.id === file.id" :compressing="compressingIds.has(file.id)" @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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-else class="file-list">
<template v-for="file in recentFiles" :key="file.id">
<FileListItem :file="file" :selected="selectedFile?.id === file.id" :compressing="compressingIds.has(file.id)" @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, type FileRecordDto } from '../api'
import FileCard from '../components/client/FileCard.vue'
import FileListItem from '../components/client/FileListItem.vue'
import SelectedMediaPlayerHost from '../components/client/SelectedMediaPlayerHost.vue'
import ViewToggle from '../components/client/ViewToggle.vue'
import RootTabs from '../components/client/RootTabs.vue'
import { useMediaPlayer } from '../composables/useMediaPlayer'
import { useViewMode } from '../composables/useViewMode'
import { useModals } from '../composables/useModals'
import { useLibraryRoots } from '../composables/useLibraryRoots'
const router = useRouter()
const { activeRoots, loadRoots, roots } = useLibraryRoots()
const { selectedFile, selectedMediaUrl, selectedThumbnailUrl, showResumePrompt, resumePosition, textPreview, setMediaPlayer, flushVideoProgress, clearSelection, selectFile, seekToResumePosition, resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded } = useMediaPlayer()
const selectedRoot = computed(() => roots.value.find(r => r.id === selectedFile.value?.libraryRootId))
const { viewMode } = useViewMode()
const { confirmModal } = useModals()
const recentFiles = ref<FileRecordDto[]>([])
const recentLoading = ref(false)
const compressingIds = ref(new Set<number>())
async function loadRecentFiles() {
recentLoading.value = true
try { recentFiles.value = await api.getRecentFiles('played', 24) } finally { recentLoading.value = false }
}
async function deleteFile(file: FileRecordDto) {
const confirmed = await confirmModal.value?.open({
title: '确认删除', message: `确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`, confirmText: '删除', danger: true,
})
if (!confirmed) return
try { await api.deleteFile(file.id); await loadRecentFiles(); clearSelection() } catch {}
}
async function compressFile(file: FileRecordDto) {
const nameWithoutExt = file.fileName.replace(/\.[^.]+$/, '')
const confirmed = await confirmModal.value?.open({
title: '确认压缩', message: `确定要将 "${file.fileName}" 压缩为 ZIP 吗?\n\n密码为文件名(中文替换为 #`, confirmText: '压缩',
})
if (!confirmed) return
compressingIds.value.add(file.id)
try {
await api.compressFile(file.id)
await confirmModal.value?.open({ title: '压缩完成', message: `"${file.fileName}" 已压缩为 "${nameWithoutExt}.zip"。`, showCancel: false, confirmText: '确定' })
} finally { compressingIds.value.delete(file.id) }
}
function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
if (tab === 'recent-played') return
if (tab === 'recent-added') router.push('/recent/added')
else if (tab === 'libraries') router.push('/')
}
onMounted(loadRecentFiles)
</script>
<template>
<RootTabs active-tab="recent-played" @switch-tab="switchTab" />
<section class="recent-files">
<div class="view-toggle-bar">
<div></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">暂无播放记录</p>
<div v-else-if="viewMode === 'grid'" class="file-grid">
<template v-for="file in recentFiles" :key="file.id">
<FileCard :file="file" :selected="selectedFile?.id === file.id" :compressing="compressingIds.has(file.id)" @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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-else class="file-list">
<template v-for="file in recentFiles" :key="file.id">
<FileListItem :file="file" :selected="selectedFile?.id === file.id" :show-last-played="true" :compressing="compressingIds.has(file.id)" @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import RootTabs from '../components/client/RootTabs.vue'
import RootPicker from '../components/client/RootPicker.vue'
import { useLibraryRoots } from '../composables/useLibraryRoots'
const router = useRouter()
const { activeRoots, loadRoots } = useLibraryRoots()
onMounted(() => {
if (activeRoots.value.length === 0) loadRoots()
})
function enterRoot(id: number) {
router.push(`/browse/${id}`)
}
function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
if (tab === 'recent-added') router.push('/recent/added')
else if (tab === 'recent-played') router.push('/recent/played')
}
</script>
<template>
<RootTabs active-tab="libraries" @switch-tab="switchTab" />
<RootPicker :roots="activeRoots" @enter-root="enterRoot" />
</template>
+167
View File
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api, type FileRecordDto } from '../api'
import FileCard from '../components/client/FileCard.vue'
import FileListItem from '../components/client/FileListItem.vue'
import SelectedMediaPlayerHost from '../components/client/SelectedMediaPlayerHost.vue'
import ViewToggle from '../components/client/ViewToggle.vue'
import MobilePager from '../components/client/MobilePager.vue'
import { useMediaPlayer } from '../composables/useMediaPlayer'
import { useViewMode } from '../composables/useViewMode'
import { useModals } from '../composables/useModals'
import { useLibraryRoots } from '../composables/useLibraryRoots'
const route = useRoute()
const router = useRouter()
const { roots } = useLibraryRoots()
const { selectedFile, selectedMediaUrl, selectedThumbnailUrl, showResumePrompt, resumePosition, textPreview, setMediaPlayer, flushVideoProgress, clearSelection, selectFile, seekToResumePosition, resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded } = useMediaPlayer()
const selectedRoot = computed(() => roots.value.find(r => r.id === selectedFile.value?.libraryRootId))
const { viewMode } = useViewMode()
const { confirmModal } = useModals()
const searchResults = ref<FileRecordDto[]>([])
const searchLoading = ref(false)
const searchPage = ref(1)
const searchPageSize = ref(10)
const searchTotal = ref(0)
const searchTotalPages = ref(0)
const compressingIds = ref(new Set<number>())
const searchQuery = ref('')
async function doSearch(page = 1) {
const keyword = searchQuery.value.trim()
if (!keyword) return
searchLoading.value = true
searchPage.value = Math.max(page, 1)
try {
const response = await api.searchFiles({ page: searchPage.value, pageSize: searchPageSize.value, keyword })
searchResults.value = response.items
searchTotal.value = response.total
searchTotalPages.value = response.totalPages
} finally { searchLoading.value = false }
}
async function changeSearchPage(page: number) {
const nextPage = Math.min(Math.max(page, 1), Math.max(searchTotalPages.value, 1))
if (nextPage === searchPage.value) return
flushVideoProgress()
clearSelection()
await doSearch(nextPage)
}
async function selectSearchFile(file: FileRecordDto) {
const relativeParts = file.relativePath.split(/[\\/]/).filter(Boolean)
relativeParts.pop()
const path = relativeParts.join('/')
flushVideoProgress()
clearSelection()
router.push(`/browse/${file.libraryRootId}/${path}`)
}
async function deleteFile(file: FileRecordDto) {
const confirmed = await confirmModal.value?.open({
title: '确认删除', message: `确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`, confirmText: '删除', danger: true,
})
if (!confirmed) return
try { await api.deleteFile(file.id); await doSearch(searchPage.value); clearSelection() } catch {}
}
async function compressFile(file: FileRecordDto) {
const nameWithoutExt = file.fileName.replace(/\.[^.]+$/, '')
const confirmed = await confirmModal.value?.open({
title: '确认压缩', message: `确定要将 "${file.fileName}" 压缩为 ZIP 吗?\n\n密码为文件名(中文替换为 #`, confirmText: '压缩',
})
if (!confirmed) return
compressingIds.value.add(file.id)
try {
await api.compressFile(file.id)
await confirmModal.value?.open({ title: '压缩完成', message: `"${file.fileName}" 已压缩为 "${nameWithoutExt}.zip"。`, showCancel: false, confirmText: '确定' })
} finally { compressingIds.value.delete(file.id) }
}
onMounted(() => {
const q = route.query.q as string
if (q) {
searchQuery.value = q
doSearch()
}
})
watch(() => route.query.q, (q) => {
if (q && typeof q === 'string') {
searchQuery.value = q
doSearch()
}
})
</script>
<template>
<section class="recent-files">
<div class="view-toggle-bar">
<h3 class="search-results-title">
搜索"{{ searchQuery }}" {{ searchTotal }} 个结果
</h3>
<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>
<div v-else-if="viewMode === 'grid'" class="file-grid">
<template v-for="file in searchResults" :key="file.id">
<FileCard :file="file" show-created-time :compressing="compressingIds.has(file.id)" @select="selectSearchFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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-else class="file-list">
<template v-for="file in searchResults" :key="file.id">
<FileListItem :file="file" show-created-time :compressing="compressingIds.has(file.id)" @select="selectSearchFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
: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>
</template>
+465
View File
@@ -0,0 +1,465 @@
# Vue Router Integration Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add vue-router with HTML5 history mode, splitting ClientPage.vue into independent route components while preserving all UI and behavior.
**Architecture:** Extract shared state into composables, create route view components from ClientPage.vue template sections, wrap in DefaultLayout with shared header/modals.
**Tech Stack:** Vue 3.5, vue-router 4, TypeScript 6
## Global Constraints
- No changes to existing child component styles or logic (FileCard, FileListItem, BrowseToolbar, etc.)
- HTML5 history mode (clean URLs like `/browse/1`)
- Backend already has `MapFallbackToFile("index.html")` — no backend changes needed
## File Map
**New files (~12):**
- `src/router/index.ts`
- `src/layouts/DefaultLayout.vue`
- `src/views/RootPickerView.vue`
- `src/views/RecentAddedView.vue`
- `src/views/RecentPlayedView.vue`
- `src/views/BrowseView.vue`
- `src/views/SearchView.vue`
- `src/composables/useLibraryRoots.ts`
- `src/composables/useMediaPlayer.ts`
- `src/composables/useViewMode.ts`
- `src/composables/useModals.ts`
**Modify files (~5):**
- `package.json` — add vue-router
- `src/main.ts` — register router
- `src/App.vue` — router-view
- `src/components/client/ClientHeader.vue` — router-link
- `src/components/AdminPage.vue` — router-link
**Delete files (1):**
- `src/components/ClientPage.vue`
---
## Task 1: Install vue-router + Create Router Config
- [ ] **Step 1: Install vue-router**
```bash
cd FileShare-Web-VUE && npm install vue-router
```
- [ ] **Step 2: Create src/router/index.ts**
```typescript
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('../layouts/DefaultLayout.vue'),
children: [
{ path: '', name: 'home', component: () => import('../views/RootPickerView.vue') },
{ path: 'recent/added', name: 'recent-added', component: () => import('../views/RecentAddedView.vue') },
{ path: 'recent/played', name: 'recent-played', component: () => import('../views/RecentPlayedView.vue') },
{ path: 'browse/:rootId', name: 'browse', component: () => import('../views/BrowseView.vue'), props: true },
{ path: 'browse/:rootId/*', name: 'browse-path', component: () => import('../views/BrowseView.vue'), props: true },
{ path: 'search', name: 'search', component: () => import('../views/SearchView.vue') },
],
},
{
path: '/admin',
name: 'admin',
component: () => import('../components/AdminPage.vue'),
},
],
})
export default router
```
- [ ] **Step 3: Register router in src/main.ts**
```typescript
import './assets/main.css'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App).use(router).mount('#app')
```
- [ ] **Step 4: Update src/App.vue**
```vue
<script setup lang="ts">
</script>
<template>
<router-view />
</template>
```
- [ ] **Step 5: Verify build**
```bash
npx vue-tsc --noEmit
```
---
## Task 2: Create Composables
- [ ] **Step 1: Create src/composables/useLibraryRoots.ts**
Singleton module-level state for roots list.
```typescript
import { ref, computed } from 'vue'
import { api, type LibraryRootDto } from '../api'
const roots = ref<LibraryRootDto[]>([])
const loading = ref(false)
const errorMessage = ref('')
const activeRoots = computed(() => roots.value.filter(r => r.isEnabled && r.isAvailable))
export function useLibraryRoots() {
async function loadRoots() {
loading.value = true
errorMessage.value = ''
try {
roots.value = await api.getRoots()
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '加载失败'
} finally {
loading.value = false
}
}
return { roots, loading, errorMessage, activeRoots, loadRoots }
}
```
- [ ] **Step 2: Create src/composables/useMediaPlayer.ts**
Singleton state for selected file, media player, playback control. Contains all the video progress saving, resume logic from ClientPage lines 324-458.
```typescript
import { ref, computed, onBeforeUnmount } from 'vue'
import { api, type FileRecordDto } from '../api'
type MediaPlayerHandle = {
getVideoElement: () => HTMLVideoElement | null
playVideo: () => Promise<void> | undefined
resetVideo: () => void
}
const selectedFile = ref<FileRecordDto | null>(null)
const textPreview = ref<import('../api').TextPreviewDto | null>(null)
const mediaPlayer = ref<MediaPlayerHandle | null>(null)
const resumePosition = ref(0)
const showResumePrompt = ref(false)
const resumeRequested = ref(false)
let lastPositionSave = 0
function getVideoElement() {
return mediaPlayer.value?.getVideoElement() ?? null
}
function setMediaPlayer(el: unknown) {
mediaPlayer.value = (el as MediaPlayerHandle) ?? null
}
function setError(error: unknown) {
// Will be wired to layout error state
}
function updatePlaybackPosition(id: number, position: number) {
if (selectedFile.value?.id === id) {
selectedFile.value.playbackPosition = position
}
}
function saveVideoProgress(position?: number) {
if (!selectedFile.value || selectedFile.value.mediaType !== 'video') return
const video = getVideoElement()
if (!video) 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)
.then(() => updatePlaybackPosition(fileId, nextPosition))
.catch(() => {})
}
function flushVideoProgress() {
const video = getVideoElement()
if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return
saveVideoProgress()
}
function resetResume() {
resumePosition.value = 0
resumeRequested.value = false
showResumePrompt.value = false
}
function tryResume(file: FileRecordDto) {
if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return
resumePosition.value = file.playbackPosition
resumeRequested.value = false
showResumePrompt.value = true
}
function seekToResumePosition() {
const video = getVideoElement()
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()
mediaPlayer.value?.playVideo()?.catch(() => {})
}
function dismissResume() {
mediaPlayer.value?.resetVideo()
saveVideoProgress(0)
resetResume()
}
function handleVideoPlay() {
if (showResumePrompt.value && resumePosition.value > 0) {
resumeRequested.value = true
}
seekToResumePosition()
showResumePrompt.value = false
}
function handleVideoTimeUpdate() {
const video = getVideoElement()
if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return
const now = Date.now()
if (now - lastPositionSave < 5000) return
lastPositionSave = now
saveVideoProgress()
}
function handleVideoPause() { flushVideoProgress() }
function handleVideoEnded() { saveVideoProgress(0) }
async function selectFile(file: FileRecordDto) {
flushVideoProgress()
resetResume()
lastPositionSave = 0
selectedFile.value = file
textPreview.value = null
if (file.mediaType === 'video' || file.mediaType === 'audio') {
api.markFilePlayed(file.id).catch(() => {})
if (file.mediaType === 'video') tryResume(file)
}
if (file.mediaType === 'text') {
try { textPreview.value = await api.getTextPreview(file.id) } catch {}
}
}
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
const selectedThumbnailUrl = computed(() => selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : '')
export function useMediaPlayer() {
return {
selectedFile, textPreview, mediaPlayer, resumePosition, showResumePrompt, resumeRequested,
setMediaPlayer, flushVideoProgress, selectFile, seekToResumePosition,
resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded,
selectedMediaUrl, selectedThumbnailUrl,
}
}
```
- [ ] **Step 3: Create src/composables/useViewMode.ts**
```typescript
import { ref } from 'vue'
const viewMode = ref<'list' | 'grid'>('list')
export function useViewMode() {
return { viewMode }
}
```
- [ ] **Step 4: Create src/composables/useModals.ts**
```typescript
import { ref } from 'vue'
import type ConfirmModal from '../components/ConfirmModal.vue'
import type QrCodeModal from '../components/QrCodeModal.vue'
const confirmModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
export function useModals() {
return { confirmModal, qrModal }
}
```
- [ ] **Step 5: Verify build**
```bash
npx vue-tsc --noEmit
```
---
## Task 3: Create DefaultLayout + View Components
- [ ] **Step 1: Create src/layouts/DefaultLayout.vue**
Contains ClientHeader + router-view + ConfirmModal + QrCodeModal + error banner. Handles search submission (navigates to /search), back navigation, root loading.
```vue
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ClientHeader from '../components/client/ClientHeader.vue'
import QrCodeModal from '../components/QrCodeModal.vue'
import ConfirmModal from '../components/ConfirmModal.vue'
import { useLibraryRoots } from '../composables/useLibraryRoots'
import { useModals } from '../composables/useModals'
const route = useRoute()
const router = useRouter()
const { activeRoots, loadRoots } = useLibraryRoots()
const { confirmModal, qrModal } = useModals()
const searchQuery = ref('')
const errorMessage = ref('')
const isSearching = ref(false)
const isBrowsingRoots = ref(true)
function updateTitle() {
isBrowsingRoots.value = route.path === '/' || route.path.startsWith('/recent')
isSearching.value = route.name === 'search'
}
watch(() => route.path, updateTitle, { immediate: true })
// ... clientTitle computed, doSearch, backToRoots, exitSearch
// All navigation uses router.push
onMounted(loadRoots)
</script>
<template>
<main class="client-shell">
<ClientHeader
v-model:search-query="searchQuery"
:title="clientTitle"
:active-tab="..."
:recent-count="..."
:active-roots-count="activeRoots.length"
:is-browsing-roots="isBrowsingRoots"
:is-searching="isSearching"
:browse-data="null"
@search="doSearch"
@back="backToRoots"
@exit-search="exitSearch"
@open-qr="qrModal?.open()"
/>
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<router-view />
</main>
<QrCodeModal ref="qrModal" />
<ConfirmModal ref="confirmModal" />
</template>
```
- [ ] **Step 2: Create src/views/RootPickerView.vue**
```vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import RootTabs from '../components/client/RootTabs.vue'
import RootPicker from '../components/client/RootPicker.vue'
import { useLibraryRoots } from '../composables/useLibraryRoots'
const router = useRouter()
const { activeRoots, loadRoots } = useLibraryRoots()
onMounted(() => { if (activeRoots.value.length === 0) loadRoots() })
function enterRoot(id: number) {
router.push(`/browse/${id}`)
}
</script>
<template>
<RootTabs active-tab="libraries" @switch-tab="(tab) => {
if (tab === 'recent-added') router.push('/recent/added')
else if (tab === 'recent-played') router.push('/recent/played')
}" />
<RootPicker :roots="activeRoots" @enter-root="enterRoot" />
</template>
```
- [ ] **Step 3: Create src/views/RecentAddedView.vue**
Extracts the recent-added section from ClientPage.vue (lines 571-639 with activeTab='recent-added').
- [ ] **Step 4: Create src/views/RecentPlayedView.vue**
Same pattern, for recent-played.
- [ ] **Step 5: Create src/views/BrowseView.vue**
Extracts the browse section (lines 647-761). Uses route params for rootId and path.
- [ ] **Step 6: Create src/views/SearchView.vue**
Extracts the search section (lines 504-569). Reads `route.query.q` on mount to trigger search.
---
## Task 4: Update Navigation Links
- [ ] **Step 1: Update ClientHeader.vue**
Replace `<a href="/admin">` with `<router-link to="/admin">`.
- [ ] **Step 2: Update AdminPage.vue**
Replace `<a href="/">` with `<router-link to="/">`.
- [ ] **Step 3: Verify build**
```bash
dotnet build FileShare-API/FileShare-API.csproj
cd FileShare-Web-VUE && npx vue-tsc --noEmit
```
---
## Task 5: Cleanup
- [ ] **Step 1: Delete ClientPage.vue**
```bash
rm src/components/ClientPage.vue
```
- [ ] **Step 2: Final verification**
```bash
npx vue-tsc --noEmit
```