Compare commits
4
Commits
8c92d2fbac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
809ca92464 | ||
|
|
6bdcb48c97 | ||
|
|
87f5c1ffb8 | ||
|
|
60878f9b6e |
@@ -82,6 +82,14 @@ namespace FileShare_Services.Endpoints
|
|||||||
.WithOpenApi("FileLibrary", "保存文件播放进度。")
|
.WithOpenApi("FileLibrary", "保存文件播放进度。")
|
||||||
.WithName("SaveFileProgress");
|
.WithName("SaveFileProgress");
|
||||||
|
|
||||||
|
endpoints.MapPost<IFileLibraryEndpointService, DeleteFileRequest>("api/files/delete", (service, request, _) => service.DeleteFileAsync(request))
|
||||||
|
.WithOpenApi("FileLibrary", "永久删除文件(物理文件+数据库记录)。")
|
||||||
|
.WithName("DeleteFile");
|
||||||
|
|
||||||
|
endpoints.MapPost<IFileLibraryEndpointService, CompressFileRequest>("api/files/compress", (service, request, _) => service.CompressFileAsync(request))
|
||||||
|
.WithOpenApi("FileLibrary", "压缩文件为 ZIP(可选密码)。")
|
||||||
|
.WithName("CompressFile");
|
||||||
|
|
||||||
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
|
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
|
||||||
.WithOpenApi("FileLibrary", "查询文件详情。")
|
.WithOpenApi("FileLibrary", "查询文件详情。")
|
||||||
.WithName("GetFileDetail");
|
.WithName("GetFileDetail");
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||||
|
<PackageReference Include="SharpZipLib" Version="1.4.2" />
|
||||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||||
<PackageReference Include="Serilog" Version="4.2.0" />
|
<PackageReference Include="Serilog" Version="4.2.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
|||||||
@@ -34,6 +34,18 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
public sealed record DeleteLibraryRootRequest(
|
public sealed record DeleteLibraryRootRequest(
|
||||||
[property: JsonPropertyName("id")] int Id);
|
[property: JsonPropertyName("id")] int Id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 永久删除文件的请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeleteFileRequest(
|
||||||
|
[property: JsonPropertyName("id")] int Id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 压缩文件的请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CompressFileRequest(
|
||||||
|
[property: JsonPropertyName("id")] int Id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 查询服务器子目录的请求。
|
/// 查询服务器子目录的请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -113,6 +113,22 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
return ResponseHelper.Succeed();
|
return ResponseHelper.Succeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IApiResponse> DeleteFileAsync(DeleteFileRequest request)
|
||||||
|
{
|
||||||
|
ValidateFileId(request.Id);
|
||||||
|
await fileLibrary.DeleteFileAsync(request.Id);
|
||||||
|
return ResponseHelper.Succeed("文件已永久删除。");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IApiResponse> CompressFileAsync(CompressFileRequest request)
|
||||||
|
{
|
||||||
|
ValidateFileId(request.Id);
|
||||||
|
await fileLibrary.CompressFileAsync(request.Id);
|
||||||
|
return ResponseHelper.Succeed("文件已压缩。");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证文件 ID 是否有效,无效时抛出 <see cref="ArgumentException"/>。
|
/// 验证文件 ID 是否有效,无效时抛出 <see cref="ArgumentException"/>。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using FileShare_Common.Core;
|
|||||||
using FileShare_EFCore.Database;
|
using FileShare_EFCore.Database;
|
||||||
using FileShare_EFCore.Models;
|
using FileShare_EFCore.Models;
|
||||||
using FileShare_Services.Core;
|
using FileShare_Services.Core;
|
||||||
|
using ICSharpCode.SharpZipLib.Zip;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
@@ -188,7 +189,8 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
}
|
}
|
||||||
|
|
||||||
var absolutePath = info.FullName;
|
var absolutePath = info.FullName;
|
||||||
seen.Add(absolutePath);
|
if (!seen.Add(absolutePath))
|
||||||
|
continue;
|
||||||
|
|
||||||
if (!existing.TryGetValue(absolutePath, out var record))
|
if (!existing.TryGetValue(absolutePath, out var record))
|
||||||
{
|
{
|
||||||
@@ -545,6 +547,95 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DeleteFileAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var record = await db.ManagedFileRecords
|
||||||
|
.Include(f => f.Thumbnail)
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
if (record is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除物理文件
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(record.AbsolutePath))
|
||||||
|
{
|
||||||
|
File.Delete(record.AbsolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning(ex, "删除物理文件失败 FilePath={FilePath}", record.AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除缩略图文件
|
||||||
|
if (record.Thumbnail is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var thumbnailPath = thumbnailService.GetAbsolutePath(record.Thumbnail.RelativePath);
|
||||||
|
if (File.Exists(thumbnailPath))
|
||||||
|
{
|
||||||
|
File.Delete(thumbnailPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning(ex, "删除缩略图文件失败 ThumbnailId={ThumbnailId}", record.ThumbnailId);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ManagedThumbnailMaps.Remove(record.Thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ManagedFileRecords.Remove(record);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task CompressFileAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var record = await db.ManagedFileRecords
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == id && f.Exists, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("文件不存在。");
|
||||||
|
|
||||||
|
if (!File.Exists(record.AbsolutePath))
|
||||||
|
throw new FileNotFoundException("源文件不存在于磁盘。");
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(record.AbsolutePath)!;
|
||||||
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(record.FileName);
|
||||||
|
var zipPath = Path.Combine(dir, nameWithoutExt + ".zip");
|
||||||
|
|
||||||
|
// 删除已存在的同名 ZIP
|
||||||
|
if (File.Exists(zipPath))
|
||||||
|
{
|
||||||
|
File.Delete(zipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码为文件名去掉后缀(中文转拼音,转小写)
|
||||||
|
var zipPassword = ConvertToAsciiPassword(nameWithoutExt);
|
||||||
|
|
||||||
|
// 使用 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>
|
/// <summary>
|
||||||
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
|
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
|
||||||
/// 遇到无权限的目录时跳过该分支继续遍历。
|
/// 遇到无权限的目录时跳过该分支继续遍历。
|
||||||
@@ -802,6 +893,22 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
|| sortDirection?.Trim().Equals("descending", StringComparison.OrdinalIgnoreCase) == true;
|
|| 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>
|
/// <summary>
|
||||||
/// 安全获取驱动器属性值,驱动器未就绪或访问异常时返回 null。
|
/// 安全获取驱动器属性值,驱动器未就绪或访问异常时返回 null。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -105,5 +105,19 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
/// <param name="request">包含文件 ID 和播放位置的请求。</param>
|
/// <param name="request">包含文件 ID 和播放位置的请求。</param>
|
||||||
/// <returns>API 响应。</returns>
|
/// <returns>API 响应。</returns>
|
||||||
Task<IApiResponse> SaveFileProgressAsync(SaveFileProgressRequest request);
|
Task<IApiResponse> SaveFileProgressAsync(SaveFileProgressRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 永久删除指定文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">包含文件 ID 的请求。</param>
|
||||||
|
/// <returns>API 响应。</returns>
|
||||||
|
Task<IApiResponse> DeleteFileAsync(DeleteFileRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 压缩指定文件为 ZIP(密码为文件名去掉后缀)。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">包含文件 ID 的请求。</param>
|
||||||
|
/// <returns>API 响应。</returns>
|
||||||
|
Task<IApiResponse> CompressFileAsync(CompressFileRequest request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,5 +121,19 @@ namespace FileShare_Services.Services.FileLibrary
|
|||||||
/// <param name="position">播放位置(秒)。</param>
|
/// <param name="position">播放位置(秒)。</param>
|
||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
Task SaveFileProgressAsync(int id, double position, CancellationToken cancellationToken = default);
|
Task SaveFileProgressAsync(int id, double position, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 永久删除指定文件,同时删除物理文件和缩略图。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">文件记录 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
Task DeleteFileAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将指定文件压缩为 ZIP(密码为文件名去掉后缀),存储在源文件同级目录。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">文件记录 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
Task CompressFileAsync(int id, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+523
-148
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"vue": "^3.5.32"
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AdminPage v-if="isAdminPage" />
|
<router-view />
|
||||||
<ClientPage v-else />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -114,5 +114,9 @@ export const api = {
|
|||||||
request('files/played', { method: 'POST', body: { id } }),
|
request('files/played', { method: 'POST', body: { id } }),
|
||||||
saveFileProgress: (id: number, position: number) =>
|
saveFileProgress: (id: number, position: number) =>
|
||||||
request('files/progress', { method: 'POST', body: { id, position } }),
|
request('files/progress', { method: 'POST', body: { id, position } }),
|
||||||
|
deleteFile: (id: number) =>
|
||||||
|
request('files/delete', { method: 'POST', body: { id } }),
|
||||||
|
compressFile: (id: number) =>
|
||||||
|
request('files/compress', { method: 'POST', body: { id } }),
|
||||||
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
|
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ onMounted(async () => {
|
|||||||
<h2>添加扫描目录</h2>
|
<h2>添加扫描目录</h2>
|
||||||
<p>选择服务器路径,或直接输入绝对路径。</p>
|
<p>选择服务器路径,或直接输入绝对路径。</p>
|
||||||
</div>
|
</div>
|
||||||
<a href="/" class="client-link">客户端</a>
|
<router-link to="/" class="client-link">客户端</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
|
|||||||
@@ -1,708 +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'
|
|
||||||
|
|
||||||
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 mediaPlayer = ref<MediaPlayerHandle | null>(null)
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 @select="selectSearchFile" />
|
|
||||||
<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 @select="selectSearchFile" />
|
|
||||||
<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" @select="selectFile" />
|
|
||||||
<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'"
|
|
||||||
@select="selectFile"
|
|
||||||
/>
|
|
||||||
<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">📁</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"
|
|
||||||
show-created-time
|
|
||||||
@select="selectFile"
|
|
||||||
/>
|
|
||||||
<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"
|
|
||||||
show-created-time
|
|
||||||
@select="selectFile"
|
|
||||||
/>
|
|
||||||
<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" />
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const title = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const confirmText = ref('确定')
|
||||||
|
const cancelText = ref('取消')
|
||||||
|
const showCancel = ref(true)
|
||||||
|
const danger = ref(false)
|
||||||
|
let resolvePromise: (value: boolean) => void = () => {}
|
||||||
|
|
||||||
|
function open(options: { title: string; message: string; confirmText?: string; cancelText?: string; showCancel?: boolean; danger?: boolean }) {
|
||||||
|
title.value = options.title
|
||||||
|
message.value = options.message
|
||||||
|
confirmText.value = options.confirmText ?? '确定'
|
||||||
|
cancelText.value = options.cancelText ?? '取消'
|
||||||
|
showCancel.value = options.showCancel ?? true
|
||||||
|
danger.value = options.danger ?? false
|
||||||
|
visible.value = true
|
||||||
|
return new Promise<boolean>((resolve) => { resolvePromise = resolve })
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() { visible.value = false; resolvePromise(true) }
|
||||||
|
function cancel() { visible.value = false; resolvePromise(false) }
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="modal-overlay" @click.self="cancel">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button v-if="showCancel" type="button" class="secondary-button" @click="cancel">{{ cancelText }}</button>
|
||||||
|
<button type="button" :class="danger ? 'danger-button' : 'primary-button'" @click="confirm">{{ confirmText }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; max-width: 420px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.modal-box h3 { margin: 0 0 12px; font-size: 16px; }
|
||||||
|
.modal-box p { margin: 0 0 20px; color: #555; line-height: 1.5; white-space: pre-line; }
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.primary-button { padding: 6px 16px; border: none; border-radius: 4px; background: #3182ce; color: #fff; cursor: pointer; }
|
||||||
|
.danger-button { padding: 6px 16px; border: none; border-radius: 4px; background: #e53e3e; color: #fff; cursor: pointer; }
|
||||||
|
.secondary-button { padding: 6px 16px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
|
||||||
|
</style>
|
||||||
@@ -48,7 +48,7 @@ defineEmits<{
|
|||||||
<button type="submit" class="search-submit">搜索</button>
|
<button type="submit" class="search-submit">搜索</button>
|
||||||
</form>
|
</form>
|
||||||
<button type="button" class="qr-button" title="生成二维码" @click="$emit('openQr')">二维码</button>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ defineProps<{
|
|||||||
file: FileRecordDto
|
file: FileRecordDto
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
showCreatedTime?: boolean
|
showCreatedTime?: boolean
|
||||||
|
compressing?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
select: [file: FileRecordDto]
|
select: [file: FileRecordDto]
|
||||||
|
delete: [file: FileRecordDto]
|
||||||
|
compress: [file: FileRecordDto]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -20,6 +23,13 @@ defineEmits<{
|
|||||||
type="button"
|
type="button"
|
||||||
@click="$emit('select', file)"
|
@click="$emit('select', file)"
|
||||||
>
|
>
|
||||||
|
<span class="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">删除</span>
|
||||||
|
<span
|
||||||
|
class="compress-btn"
|
||||||
|
:class="{ disabled: compressing }"
|
||||||
|
@click.stop="!compressing && $emit('compress', file)"
|
||||||
|
:title="compressing ? '压缩中...' : '压缩为ZIP'"
|
||||||
|
>{{ compressing ? '压缩中...' : '压缩' }}</span>
|
||||||
<img
|
<img
|
||||||
v-if="file.thumbnailUrl"
|
v-if="file.thumbnailUrl"
|
||||||
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
||||||
@@ -38,3 +48,53 @@ defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-card {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.compress-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 52px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.compress-btn.disabled {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.file-card:hover .delete-btn,
|
||||||
|
.file-card:hover .compress-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #e53e3e;
|
||||||
|
}
|
||||||
|
.compress-btn:hover:not(.disabled) {
|
||||||
|
background: #3182ce;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ defineProps<{
|
|||||||
selected?: boolean
|
selected?: boolean
|
||||||
showCreatedTime?: boolean
|
showCreatedTime?: boolean
|
||||||
showLastPlayed?: boolean
|
showLastPlayed?: boolean
|
||||||
|
compressing?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
select: [file: FileRecordDto]
|
select: [file: FileRecordDto]
|
||||||
|
delete: [file: FileRecordDto]
|
||||||
|
compress: [file: FileRecordDto]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -38,5 +41,56 @@ defineEmits<{
|
|||||||
</small>
|
</small>
|
||||||
<small v-if="showCreatedTime && formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
<small v-if="showCreatedTime && formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
class="compress-btn"
|
||||||
|
:class="{ disabled: compressing }"
|
||||||
|
@click.stop="!compressing && $emit('compress', file)"
|
||||||
|
>{{ compressing ? '压缩中...' : '压缩' }}</span>
|
||||||
|
<span class="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">删除</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.compress-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.compress-btn.disabled {
|
||||||
|
opacity: 1;
|
||||||
|
color: #bbb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.delete-btn {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.mobile-file:hover .compress-btn,
|
||||||
|
.mobile-file:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.compress-btn:hover:not(.disabled) {
|
||||||
|
background: #3182ce;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #e53e3e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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,5 +2,6 @@ import './assets/main.css'
|
|||||||
|
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
createApp(App).mount('#app')
|
createApp(App).use(router).mount('#app')
|
||||||
|
|||||||
@@ -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/:path(.*)', 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
|
||||||
@@ -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(wildcard)
|
||||||
|
})
|
||||||
|
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">📁</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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
# File Compress Implementation 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 single-file ZIP compression with password support and custom modal dialogs.
|
||||||
|
|
||||||
|
**Architecture:** Backend adds `POST api/files/compress` using DotNetZip for encrypted ZIP. Frontend adds ConfirmModal + CompressModal components, replaces all system dialogs.
|
||||||
|
|
||||||
|
**Tech Stack:** ASP.NET Core 10, DotNetZip, Vue 3.5, TypeScript 6
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Do not edit `FileShare-EFCore/Migrations/**`
|
||||||
|
- All endpoints use POST (project convention)
|
||||||
|
- Follow existing patterns: sealed record DTOs, ResponseHelper, Teleport modal pattern
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `FileShare-Services/FileShare-Services.csproj` | Modify | Add DotNetZip package |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/FileLibraryContracts.cs` | Modify | Add CompressFileRequest DTO |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/IFileCompressionService.cs` | Create | Compression service interface |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/FileCompressionService.cs` | Create | Compression implementation |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/IFileCompressionEndpointService.cs` | Create | Endpoint interface |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/FileCompressionEndpointService.cs` | Create | Endpoint adapter |
|
||||||
|
| `FileShare-Services/Endpoints/AppEndpoints.cs` | Modify | Register route |
|
||||||
|
| `FileShare-API/Configuration/ServicesConfiguration.cs` | Modify | Register DI |
|
||||||
|
| `FileShare-Web-VUE/src/api/index.ts` | Modify | Add compressFile API |
|
||||||
|
| `FileShare-Web-VUE/src/components/ConfirmModal.vue` | Create | Generic confirm dialog |
|
||||||
|
| `FileShare-Web-VUE/src/components/CompressModal.vue` | Create | Compress password dialog |
|
||||||
|
| `FileShare-Web-VUE/src/components/client/FileCard.vue` | Modify | Add compress button |
|
||||||
|
| `FileShare-Web-VUE/src/components/client/FileListItem.vue` | Modify | Add compress button |
|
||||||
|
| `FileShare-Web-VUE/src/components/ClientPage.vue` | Modify | Handle compress + replace system dialogs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — Package, DTO, Service Interface
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DotNetZip to FileShare-Services.csproj**
|
||||||
|
|
||||||
|
Add to `<ItemGroup>` with other PackageReferences:
|
||||||
|
```xml
|
||||||
|
<PackageReference Include="DotNetZip" Version="1.16.0" />
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add CompressFileRequest to FileLibraryContracts.cs**
|
||||||
|
|
||||||
|
After `DeleteFileRequest`, add:
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// 压缩文件的请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record CompressFileRequest(
|
||||||
|
[property: JsonPropertyName("id")] int Id,
|
||||||
|
[property: JsonPropertyName("password")] string? Password = null,
|
||||||
|
[property: JsonPropertyName("useDefaultPassword")] bool UseDefaultPassword = true);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create IFileCompressionService.cs**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
namespace FileShare_Services.Services.FileLibrary
|
||||||
|
{
|
||||||
|
public interface IFileCompressionService
|
||||||
|
{
|
||||||
|
Task CompressFileAsync(int id, string? password, bool useDefaultPassword, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create IFileCompressionEndpointService.cs**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FileShare_Common.Core;
|
||||||
|
|
||||||
|
namespace FileShare_Services.Services.FileLibrary
|
||||||
|
{
|
||||||
|
public interface IFileCompressionEndpointService
|
||||||
|
{
|
||||||
|
Task<IApiResponse> CompressFileAsync(CompressFileRequest request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — Service Implementation
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create FileCompressionService.cs**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FileShare_EFCore.Database;
|
||||||
|
using FileShare_EFCore.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.IO.Compression;
|
||||||
|
|
||||||
|
namespace FileShare_Services.Services.FileLibrary
|
||||||
|
{
|
||||||
|
public sealed class FileCompressionService(AppDataContext db) : IFileCompressionService
|
||||||
|
{
|
||||||
|
public async Task CompressFileAsync(int id, string? password, bool useDefaultPassword, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var record = await db.ManagedFileRecords
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == id && f.Exists, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("文件不存在。");
|
||||||
|
|
||||||
|
if (!File.Exists(record.AbsolutePath))
|
||||||
|
throw new FileNotFoundException("源文件不存在于磁盘。");
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(record.AbsolutePath)!;
|
||||||
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(record.FileName);
|
||||||
|
var zipPath = Path.Combine(dir, nameWithoutExt + ".zip");
|
||||||
|
|
||||||
|
// 删除已存在的同名 ZIP
|
||||||
|
if (File.Exists(zipPath))
|
||||||
|
{
|
||||||
|
File.Delete(zipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定密码
|
||||||
|
string? zipPassword = ResolvePassword(record.FileName, password, useDefaultPassword);
|
||||||
|
|
||||||
|
// 创建 ZIP
|
||||||
|
using (var archive = ZipFile.Open(zipPath, ZipArchiveMode.Create))
|
||||||
|
{
|
||||||
|
archive.CreateEntryFromFile(record.AbsolutePath, record.FileName, CompressionLevel.Optimal);
|
||||||
|
// DotNetZip 不支持 ZipArchive 密码,改用 DotNetZip 的 ZipFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 DotNetZip 重新创建(支持密码)
|
||||||
|
System.IO.Compression.ZipFile.Delete(zipPath);
|
||||||
|
using (var zip = new Ionic.Zip.ZipFile())
|
||||||
|
{
|
||||||
|
zip.Password = zipPassword;
|
||||||
|
zip.AddFile(record.AbsolutePath, "");
|
||||||
|
zip.Save(zipPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolvePassword(string fileName, string? password, bool useDefaultPassword)
|
||||||
|
{
|
||||||
|
if (useDefaultPassword)
|
||||||
|
return Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
return string.IsNullOrEmpty(password) ? null : password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait — this mixes `System.IO.Compression` and `Ionic.Zip`. Let me use only DotNetZip.
|
||||||
|
|
||||||
|
- [ ] **Step 1 (revised): Create FileCompressionService.cs**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FileShare_EFCore.Database;
|
||||||
|
using FileShare_EFCore.Models;
|
||||||
|
using Ionic.Zip;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FileShare_Services.Services.FileLibrary
|
||||||
|
{
|
||||||
|
public sealed class FileCompressionService(AppDataContext db) : IFileCompressionService
|
||||||
|
{
|
||||||
|
public async Task CompressFileAsync(int id, string? password, bool useDefaultPassword, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var record = await db.ManagedFileRecords
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == id && f.Exists, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException("文件不存在。");
|
||||||
|
|
||||||
|
if (!File.Exists(record.AbsolutePath))
|
||||||
|
throw new FileNotFoundException("源文件不存在于磁盘。");
|
||||||
|
|
||||||
|
var dir = Path.GetDirectoryName(record.AbsolutePath)!;
|
||||||
|
var nameWithoutExt = Path.GetFileNameWithoutExtension(record.FileName);
|
||||||
|
var zipPath = Path.Combine(dir, nameWithoutExt + ".zip");
|
||||||
|
|
||||||
|
// 删除已存在的同名 ZIP
|
||||||
|
if (File.Exists(zipPath))
|
||||||
|
{
|
||||||
|
File.Delete(zipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定密码
|
||||||
|
var zipPassword = ResolvePassword(record.FileName, password, useDefaultPassword);
|
||||||
|
|
||||||
|
// 创建 ZIP
|
||||||
|
using var zip = new ZipFile();
|
||||||
|
if (zipPassword is not null)
|
||||||
|
{
|
||||||
|
zip.Password = zipPassword;
|
||||||
|
}
|
||||||
|
zip.AddFile(record.AbsolutePath, "");
|
||||||
|
zip.Save(zipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolvePassword(string fileName, string? password, bool useDefaultPassword)
|
||||||
|
{
|
||||||
|
if (useDefaultPassword)
|
||||||
|
return Path.GetFileNameWithoutExtension(fileName);
|
||||||
|
return string.IsNullOrEmpty(password) ? null : password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Backend — Endpoint + Route + DI
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create FileCompressionEndpointService.cs**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using FileShare_Common.Core;
|
||||||
|
|
||||||
|
namespace FileShare_Services.Services.FileLibrary
|
||||||
|
{
|
||||||
|
public sealed class FileCompressionEndpointService(IFileCompressionService compressionService) : IFileCompressionEndpointService
|
||||||
|
{
|
||||||
|
public async Task<IApiResponse> CompressFileAsync(CompressFileRequest request)
|
||||||
|
{
|
||||||
|
if (request.Id <= 0)
|
||||||
|
return ResponseHelper.Failure(400, "文件 ID 无效。");
|
||||||
|
|
||||||
|
await compressionService.CompressFileAsync(request.Id, request.Password, request.UseDefaultPassword);
|
||||||
|
return ResponseHelper.Succeed("文件已压缩。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Register route in AppEndpoints.cs**
|
||||||
|
|
||||||
|
After the `DeleteFile` endpoint, add:
|
||||||
|
```csharp
|
||||||
|
endpoints.MapPost<IFileCompressionEndpointService, CompressFileRequest>("api/files/compress", (service, request, _) => service.CompressFileAsync(request))
|
||||||
|
.WithOpenApi("FileLibrary", "压缩文件为 ZIP(可选密码)。")
|
||||||
|
.WithName("CompressFile");
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register DI in ServicesConfiguration.cs**
|
||||||
|
|
||||||
|
After `IFileLibraryEndpointService` registration, add:
|
||||||
|
```csharp
|
||||||
|
services.AddScoped<IFileCompressionService, FileCompressionService>();
|
||||||
|
services.AddScoped<IFileCompressionEndpointService, FileCompressionEndpointService>();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Frontend — API + Modal Components
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add compressFile API method**
|
||||||
|
|
||||||
|
In `api/index.ts`, add:
|
||||||
|
```typescript
|
||||||
|
compressFile: (id: number, password?: string | null, useDefaultPassword = true) =>
|
||||||
|
request('files/compress', { method: 'POST', body: { id, password, useDefaultPassword } }),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create ConfirmModal.vue**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const title = ref('')
|
||||||
|
const message = ref('')
|
||||||
|
const confirmText = ref('确定')
|
||||||
|
const cancelText = ref('取消')
|
||||||
|
const showCancel = ref(true)
|
||||||
|
const danger = ref(false)
|
||||||
|
let resolvePromise: (value: boolean) => void = () => {}
|
||||||
|
|
||||||
|
function open(options: { title: string; message: string; confirmText?: string; cancelText?: string; showCancel?: boolean; danger?: boolean }) {
|
||||||
|
title.value = options.title
|
||||||
|
message.value = options.message
|
||||||
|
confirmText.value = options.confirmText ?? '确定'
|
||||||
|
cancelText.value = options.cancelText ?? '取消'
|
||||||
|
showCancel.value = options.showCancel ?? true
|
||||||
|
danger.value = options.danger ?? false
|
||||||
|
visible.value = true
|
||||||
|
return new Promise<boolean>((resolve) => { resolvePromise = resolve })
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() { visible.value = false; resolvePromise(true) }
|
||||||
|
function cancel() { visible.value = false; resolvePromise(false) }
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="modal-overlay" @click.self="cancel">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>{{ title }}</h3>
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button v-if="showCancel" type="button" class="secondary-button" @click="cancel">{{ cancelText }}</button>
|
||||||
|
<button type="button" :class="danger ? 'danger-button' : 'primary-button'" @click="confirm">{{ confirmText }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
background: #fff; border-radius: 8px; padding: 24px; min-width: 320px; max-width: 420px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.modal-box h3 { margin: 0 0 12px; font-size: 16px; }
|
||||||
|
.modal-box p { margin: 0 0 20px; color: #555; line-height: 1.5; }
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.primary-button { padding: 6px 16px; border: none; border-radius: 4px; background: #3182ce; color: #fff; cursor: pointer; }
|
||||||
|
.danger-button { padding: 6px 16px; border: none; border-radius: 4px; background: #e53e3e; color: #fff; cursor: pointer; }
|
||||||
|
.secondary-button { padding: 6px 16px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create CompressModal.vue**
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const fileName = ref('')
|
||||||
|
const mode = ref<'none' | 'default' | 'custom'>('default')
|
||||||
|
const customPassword = ref('')
|
||||||
|
let resolvePromise: (value: { password: string | null; useDefaultPassword: boolean } | null) => void = () => {}
|
||||||
|
|
||||||
|
const defaultPassword = computed(() => {
|
||||||
|
const dotIndex = fileName.value.lastIndexOf('.')
|
||||||
|
return dotIndex > 0 ? fileName.value.substring(0, dotIndex) : fileName.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function open(name: string) {
|
||||||
|
fileName.value = name
|
||||||
|
mode.value = 'default'
|
||||||
|
customPassword.value = ''
|
||||||
|
visible.value = true
|
||||||
|
return new Promise<{ password: string | null; useDefaultPassword: boolean } | null>((resolve) => {
|
||||||
|
resolvePromise = resolve
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
visible.value = false
|
||||||
|
if (mode.value === 'none') {
|
||||||
|
resolvePromise({ password: null, useDefaultPassword: false })
|
||||||
|
} else if (mode.value === 'default') {
|
||||||
|
resolvePromise({ password: null, useDefaultPassword: true })
|
||||||
|
} else {
|
||||||
|
resolvePromise({ password: customPassword.value || null, useDefaultPassword: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
visible.value = false
|
||||||
|
resolvePromise(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="modal-overlay" @click.self="cancel">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3>压缩文件</h3>
|
||||||
|
<p class="file-name">{{ fileName }}</p>
|
||||||
|
<div class="password-options">
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" v-model="mode" value="none" />
|
||||||
|
<span>不设置密码</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" v-model="mode" value="default" />
|
||||||
|
<span>使用默认密码:<code>{{ defaultPassword }}</code></span>
|
||||||
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" v-model="mode" value="custom" />
|
||||||
|
<span>自定义密码</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-if="mode === 'custom'"
|
||||||
|
v-model="customPassword"
|
||||||
|
type="text"
|
||||||
|
class="password-input"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="secondary-button" @click="cancel">取消</button>
|
||||||
|
<button type="button" class="primary-button" @click="confirm">压缩</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
background: #fff; border-radius: 8px; padding: 24px; min-width: 360px; max-width: 440px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||||
|
}
|
||||||
|
.modal-box h3 { margin: 0 0 8px; font-size: 16px; }
|
||||||
|
.file-name { margin: 0 0 16px; color: #555; font-size: 14px; }
|
||||||
|
.password-options { display: flex; flex-direction: column; gap: 10px; margin-bottom: 20px; }
|
||||||
|
.radio-item { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
|
||||||
|
.radio-item code { background: #f0f0f0; padding: 1px 6px; border-radius: 3px; font-size: 13px; }
|
||||||
|
.password-input {
|
||||||
|
margin-left: 24px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px;
|
||||||
|
font-size: 14px; width: 200px;
|
||||||
|
}
|
||||||
|
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||||
|
.primary-button { padding: 6px 16px; border: none; border-radius: 4px; background: #3182ce; color: #fff; cursor: pointer; }
|
||||||
|
.secondary-button { padding: 6px 16px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Frontend — Wire Components + Replace System Dialogs
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add compress buttons to FileCard.vue and FileListItem.vue**
|
||||||
|
|
||||||
|
Add `compress` emit and button to both components (similar to delete button pattern).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update ClientPage.vue**
|
||||||
|
|
||||||
|
- Import ConfirmModal and CompressModal
|
||||||
|
- Add refs for modal instances
|
||||||
|
- Add `compressingIds` ref (Set<number> tracking)
|
||||||
|
- Replace `window.confirm` in `deleteFile` with ConfirmModal
|
||||||
|
- Add `compressFile` handler using CompressModal
|
||||||
|
- Wire `@compress` events on all FileCard/FileListItem instances
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build FileShare-API/FileShare-API.csproj
|
||||||
|
cd FileShare-Web-VUE && npx vue-tsc --noEmit
|
||||||
|
```
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
# File Delete Implementation 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 a permanent file delete API endpoint and frontend delete button with confirmation dialog.
|
||||||
|
|
||||||
|
**Architecture:** Backend adds `POST api/files/delete` that deletes physical file + thumbnail + DB record. Frontend adds delete button to FileCard and FileListItem, with `window.confirm()` guard.
|
||||||
|
|
||||||
|
**Tech Stack:** ASP.NET Core 10, EF Core 10, Vue 3.5, TypeScript 6
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Do not edit `FileShare-EFCore/Migrations/**` — schema changes require user to run migration scripts
|
||||||
|
- All endpoints use POST (project convention)
|
||||||
|
- Frontend response interceptor auto-unwraps `{ success, data }` envelope
|
||||||
|
- Follow existing code patterns (sealed record DTOs, `ResponseHelper`, `defineProps`/`defineEmits`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `FileShare-Services/Services/FileLibrary/FileLibraryContracts.cs` | Modify | Add `DeleteFileRequest` DTO |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/IFileLibraryService.cs` | Modify | Add `DeleteFileAsync` method |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/FileLibraryService.cs` | Modify | Implement delete logic |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/IFileLibraryEndpointService.cs` | Modify | Add `DeleteFileAsync` endpoint method |
|
||||||
|
| `FileShare-Services/Services/FileLibrary/FileLibraryEndpointService.cs` | Modify | Implement endpoint adapter |
|
||||||
|
| `FileShare-Services/Endpoints/AppEndpoints.cs` | Modify | Register route |
|
||||||
|
| `FileShare-Web-VUE/src/api/index.ts` | Modify | Add `deleteFile` API method |
|
||||||
|
| `FileShare-Web-VUE/src/components/client/FileCard.vue` | Modify | Add delete button |
|
||||||
|
| `FileShare-Web-VUE/src/components/client/FileListItem.vue` | Modify | Add delete button |
|
||||||
|
| `FileShare-Web-VUE/src/components/ClientPage.vue` | Modify | Handle delete event |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Backend — Contract, Service Interface, Service Implementation
|
||||||
|
|
||||||
|
**Covers:** S2 (backend delete logic)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `FileShare-Services/Services/FileLibrary/FileLibraryContracts.cs:35`
|
||||||
|
- Modify: `FileShare-Services/Services/FileLibrary/IFileLibraryService.cs:123`
|
||||||
|
- Modify: `FileShare-Services/Services/FileLibrary/FileLibraryService.cs:546`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DeleteFileRequest to FileLibraryContracts.cs**
|
||||||
|
|
||||||
|
After line 35 (the `DeleteLibraryRootRequest` record), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// 永久删除文件的请求。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record DeleteFileRequest(
|
||||||
|
[property: JsonPropertyName("id")] int Id);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add DeleteFileAsync to IFileLibraryService.cs**
|
||||||
|
|
||||||
|
After line 123 (`SaveFileProgressAsync`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// 永久删除指定文件,同时删除物理文件和缩略图。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">文件记录 ID。</param>
|
||||||
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
|
Task DeleteFileAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement DeleteFileAsync in FileLibraryService.cs**
|
||||||
|
|
||||||
|
After line 546 (end of `SaveFileProgressAsync`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task DeleteFileAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var record = await db.ManagedFileRecords
|
||||||
|
.Include(f => f.Thumbnail)
|
||||||
|
.FirstOrDefaultAsync(f => f.Id == id, cancellationToken);
|
||||||
|
|
||||||
|
if (record is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除物理文件
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(record.AbsolutePath))
|
||||||
|
{
|
||||||
|
File.Delete(record.AbsolutePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning(ex, "删除物理文件失败 FilePath={FilePath}", record.AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除缩略图文件
|
||||||
|
if (record.Thumbnail is not null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var thumbnailPath = thumbnailService.GetAbsolutePath(record.Thumbnail.RelativePath);
|
||||||
|
if (File.Exists(thumbnailPath))
|
||||||
|
{
|
||||||
|
File.Delete(thumbnailPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Serilog.Log.Warning(ex, "删除缩略图文件失败 ThumbnailId={ThumbnailId}", record.ThumbnailId);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ManagedThumbnailMaps.Remove(record.Thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ManagedFileRecords.Remove(record);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Backend — Endpoint Service + Route Registration
|
||||||
|
|
||||||
|
**Covers:** S2 (endpoint and routing)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `FileShare-Services/Services/FileLibrary/IFileLibraryEndpointService.cs:107`
|
||||||
|
- Modify: `FileShare-Services/Services/FileLibrary/FileLibraryEndpointService.cs:114`
|
||||||
|
- Modify: `FileShare-Services/Endpoints/AppEndpoints.cs:92`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DeleteFileAsync to IFileLibraryEndpointService.cs**
|
||||||
|
|
||||||
|
After line 107 (`SaveFileProgressAsync`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// 永久删除指定文件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">包含文件 ID 的请求。</param>
|
||||||
|
/// <returns>API 响应。</returns>
|
||||||
|
Task<IApiResponse> DeleteFileAsync(DeleteFileRequest request);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement DeleteFileAsync in FileLibraryEndpointService.cs**
|
||||||
|
|
||||||
|
After line 114 (end of `SaveFileProgressAsync`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IApiResponse> DeleteFileAsync(DeleteFileRequest request)
|
||||||
|
{
|
||||||
|
ValidateFileId(request.Id);
|
||||||
|
await fileLibrary.DeleteFileAsync(request.Id);
|
||||||
|
return ResponseHelper.Succeed("文件已永久删除。");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register route in AppEndpoints.cs**
|
||||||
|
|
||||||
|
After line 92 (the `SaveFileProgress` endpoint), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
endpoints.MapPost<IFileLibraryEndpointService, DeleteFileRequest>("api/files/delete", (service, request, _) => service.DeleteFileAsync(request))
|
||||||
|
.WithOpenApi("FileLibrary", "永久删除文件(物理文件+数据库记录)。")
|
||||||
|
.WithName("DeleteFile");
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Frontend — API Method + Components
|
||||||
|
|
||||||
|
**Covers:** S3 (frontend delete button and API)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `FileShare-Web-VUE/src/api/index.ts:117`
|
||||||
|
- Modify: `FileShare-Web-VUE/src/components/client/FileCard.vue`
|
||||||
|
- Modify: `FileShare-Web-VUE/src/components/client/FileListItem.vue`
|
||||||
|
- Modify: `FileShare-Web-VUE/src/components/ClientPage.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add deleteFile API method in index.ts**
|
||||||
|
|
||||||
|
After line 117 (`saveFileProgress`), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
deleteFile: (id: number) =>
|
||||||
|
request('files/delete', { method: 'POST', body: { id } }),
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add delete button to FileCard.vue**
|
||||||
|
|
||||||
|
Replace the entire `FileCard.vue` content with:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<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]
|
||||||
|
delete: [file: FileRecordDto]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
class="file-card"
|
||||||
|
:class="{ active: selected }"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('select', file)"
|
||||||
|
>
|
||||||
|
<span class="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">×</span>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.file-card:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #e53e3e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add delete button to FileListItem.vue**
|
||||||
|
|
||||||
|
Replace the entire `FileListItem.vue` content with:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<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]
|
||||||
|
delete: [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>
|
||||||
|
<span class="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">×</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.mobile-file:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
color: #e53e3e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add delete handler and wire events in ClientPage.vue**
|
||||||
|
|
||||||
|
In `<script setup>`, after the existing `saveFileProgress` function (search for `async function saveFileProgress`), add:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function deleteFile(file: FileRecordDto) {
|
||||||
|
if (!window.confirm(`确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteFile(file.id)
|
||||||
|
// 刷新当前浏览数据
|
||||||
|
if (browseData.value) {
|
||||||
|
await loadBrowse()
|
||||||
|
}
|
||||||
|
// 刷新搜索结果
|
||||||
|
if (searchResults.value.length > 0) {
|
||||||
|
await loadSearch()
|
||||||
|
}
|
||||||
|
// 清除选中状态
|
||||||
|
if (selectedFile.value?.id === file.id) {
|
||||||
|
selectedFile.value = null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error ? error.message : '删除失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the template, find each `<FileCard` and `<FileListItem` usage and add `@delete="deleteFile"`:
|
||||||
|
|
||||||
|
For the `displayedFiles` grid (around line 628):
|
||||||
|
```vue
|
||||||
|
<FileCard
|
||||||
|
:file="file"
|
||||||
|
:selected="selectedFile?.id === file.id"
|
||||||
|
show-created-time
|
||||||
|
@select="selectFile"
|
||||||
|
@delete="deleteFile"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
For the `displayedFiles` list (around line 659):
|
||||||
|
```vue
|
||||||
|
<FileListItem
|
||||||
|
:file="file"
|
||||||
|
:selected="selectedFile?.id === file.id"
|
||||||
|
show-created-time
|
||||||
|
@select="selectFile"
|
||||||
|
@delete="deleteFile"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
For the `searchResults` grid (around line 466):
|
||||||
|
```vue
|
||||||
|
<FileCard :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />
|
||||||
|
```
|
||||||
|
|
||||||
|
For the `searchResults` list (around line 491):
|
||||||
|
```vue
|
||||||
|
<FileListItem :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />
|
||||||
|
```
|
||||||
|
|
||||||
|
For the `recentFiles` grid (around line 534):
|
||||||
|
```vue
|
||||||
|
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" @delete="deleteFile" />
|
||||||
|
```
|
||||||
|
|
||||||
|
For the `recentFiles` list (around line 559):
|
||||||
|
```vue
|
||||||
|
<FileListItem :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" @delete="deleteFile" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Verify
|
||||||
|
|
||||||
|
**Covers:** S4, S5
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build backend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet build FileShare-API/FileShare-API.csproj
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build frontend**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd FileShare-Web-VUE && npx vue-tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify no regressions**
|
||||||
|
|
||||||
|
Ensure existing functionality still works by checking the build passes.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user