feat: 新增永久删除文件接口和前端删除按钮
后端新增 POST api/files/delete 接口,同时删除物理文件、缩略图和数据库记录。 前端在 FileCard 和 FileListItem 中增加 hover 可见的删除按钮,点击后弹出确认提示。
This commit is contained in:
@@ -82,6 +82,10 @@ 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.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");
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ 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>
|
||||||
/// 查询服务器子目录的请求。
|
/// 查询服务器子目录的请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ 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("文件已永久删除。");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 验证文件 ID 是否有效,无效时抛出 <see cref="ArgumentException"/>。
|
/// 验证文件 ID 是否有效,无效时抛出 <see cref="ArgumentException"/>。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -545,6 +545,54 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
|
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
|
||||||
/// 遇到无权限的目录时跳过该分支继续遍历。
|
/// 遇到无权限的目录时跳过该分支继续遍历。
|
||||||
|
|||||||
@@ -105,5 +105,12 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,5 +121,12 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,5 +114,7 @@ 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 } }),
|
||||||
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
|
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -272,6 +272,24 @@ function exitSearch() {
|
|||||||
searchTotalPages.value = 0
|
searchTotalPages.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteFile(file: FileRecordDto) {
|
||||||
|
if (!window.confirm(`确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.deleteFile(file.id)
|
||||||
|
await browseDirectory()
|
||||||
|
if (isSearching.value) {
|
||||||
|
await doSearch(searchPage.value)
|
||||||
|
}
|
||||||
|
if (selectedFile.value?.id === file.id) {
|
||||||
|
selectedFile.value = null
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updatePlaybackPosition(id: number, position: number) {
|
function updatePlaybackPosition(id: number, position: number) {
|
||||||
if (selectedFile.value?.id === id) {
|
if (selectedFile.value?.id === id) {
|
||||||
selectedFile.value.playbackPosition = position
|
selectedFile.value.playbackPosition = position
|
||||||
@@ -463,7 +481,7 @@ onBeforeUnmount(() => {
|
|||||||
<p v-else-if="searchResults.length === 0" class="empty-state">无匹配文件</p>
|
<p v-else-if="searchResults.length === 0" class="empty-state">无匹配文件</p>
|
||||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||||
<template v-for="file in searchResults" :key="file.id">
|
<template v-for="file in searchResults" :key="file.id">
|
||||||
<FileCard :file="file" show-created-time @select="selectSearchFile" />
|
<FileCard :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
:ref="setMediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
@@ -488,7 +506,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="file-list">
|
<div v-else class="file-list">
|
||||||
<template v-for="file in searchResults" :key="file.id">
|
<template v-for="file in searchResults" :key="file.id">
|
||||||
<FileListItem :file="file" show-created-time @select="selectSearchFile" />
|
<FileListItem :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
:ref="setMediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
@@ -531,7 +549,7 @@ onBeforeUnmount(() => {
|
|||||||
</p>
|
</p>
|
||||||
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
<div v-else-if="viewMode === 'grid'" class="file-grid">
|
||||||
<template v-for="file in recentFiles" :key="file.id">
|
<template v-for="file in recentFiles" :key="file.id">
|
||||||
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" />
|
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" @delete="deleteFile" />
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
:ref="setMediaPlayer"
|
:ref="setMediaPlayer"
|
||||||
@@ -561,6 +579,7 @@ onBeforeUnmount(() => {
|
|||||||
:selected="selectedFile?.id === file.id"
|
:selected="selectedFile?.id === file.id"
|
||||||
:show-last-played="activeTab === 'recent-played'"
|
:show-last-played="activeTab === 'recent-played'"
|
||||||
@select="selectFile"
|
@select="selectFile"
|
||||||
|
@delete="deleteFile"
|
||||||
/>
|
/>
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
@@ -630,6 +649,7 @@ onBeforeUnmount(() => {
|
|||||||
:selected="selectedFile?.id === file.id"
|
:selected="selectedFile?.id === file.id"
|
||||||
show-created-time
|
show-created-time
|
||||||
@select="selectFile"
|
@select="selectFile"
|
||||||
|
@delete="deleteFile"
|
||||||
/>
|
/>
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
@@ -661,6 +681,7 @@ onBeforeUnmount(() => {
|
|||||||
:selected="selectedFile?.id === file.id"
|
:selected="selectedFile?.id === file.id"
|
||||||
show-created-time
|
show-created-time
|
||||||
@select="selectFile"
|
@select="selectFile"
|
||||||
|
@delete="deleteFile"
|
||||||
/>
|
/>
|
||||||
<SelectedMediaPlayerHost
|
<SelectedMediaPlayerHost
|
||||||
v-if="selectedFile?.id === file.id"
|
v-if="selectedFile?.id === file.id"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ defineProps<{
|
|||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
select: [file: FileRecordDto]
|
select: [file: FileRecordDto]
|
||||||
|
delete: [file: FileRecordDto]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ defineEmits<{
|
|||||||
type="button"
|
type="button"
|
||||||
@click="$emit('select', file)"
|
@click="$emit('select', file)"
|
||||||
>
|
>
|
||||||
|
<span class="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">删除</span>
|
||||||
<img
|
<img
|
||||||
v-if="file.thumbnailUrl"
|
v-if="file.thumbnailUrl"
|
||||||
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
:src="api.thumbnailUrl(file.thumbnailUrl)"
|
||||||
@@ -38,3 +40,26 @@ defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.file-card:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #e53e3e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ defineProps<{
|
|||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
select: [file: FileRecordDto]
|
select: [file: FileRecordDto]
|
||||||
|
delete: [file: FileRecordDto]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -38,5 +39,29 @@ 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="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">删除</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-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;
|
||||||
|
}
|
||||||
|
.mobile-file:hover .delete-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.delete-btn:hover {
|
||||||
|
background: #e53e3e;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user