feat: 新增永久删除文件接口和前端删除按钮
后端新增 POST api/files/delete 接口,同时删除物理文件、缩略图和数据库记录。 前端在 FileCard 和 FileListItem 中增加 hover 可见的删除按钮,点击后弹出确认提示。
This commit is contained in:
@@ -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