432 lines
12 KiB
Markdown
432 lines
12 KiB
Markdown
# 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.
|