Files
FileShare/docs/compose/plans/2026-07-09-file-delete.md
admin777 60878f9b6e feat: 新增永久删除文件接口和前端删除按钮
后端新增 POST api/files/delete 接口,同时删除物理文件、缩略图和数据库记录。
前端在 FileCard 和 FileListItem 中增加 hover 可见的删除按钮,点击后弹出确认提示。
2026-07-09 17:42:27 +08:00

12 KiB

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:

    /// <summary>
    /// 永久删除文件的请求。
    /// </summary>
    public sealed record DeleteFileRequest(
        [property: JsonPropertyName("id")] int Id);
  • Step 2: Add DeleteFileAsync to IFileLibraryService.cs

After line 123 (SaveFileProgressAsync), add:

        /// <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:

        /// <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:

        /// <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:

        /// <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:

                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:

  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:

<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="永久删除">&times;</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:

<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="永久删除">&times;</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:

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):

<FileCard
  :file="file"
  :selected="selectedFile?.id === file.id"
  show-created-time
  @select="selectFile"
  @delete="deleteFile"
/>

For the displayedFiles list (around line 659):

<FileListItem
  :file="file"
  :selected="selectedFile?.id === file.id"
  show-created-time
  @select="selectFile"
  @delete="deleteFile"
/>

For the searchResults grid (around line 466):

<FileCard :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />

For the searchResults list (around line 491):

<FileListItem :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />

For the recentFiles grid (around line 534):

<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" @delete="deleteFile" />

For the recentFiles list (around line 559):

<FileListItem :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" @delete="deleteFile" />

Task 4: Verify

Covers: S4, S5

  • Step 1: Build backend
dotnet build FileShare-API/FileShare-API.csproj
  • Step 2: Build frontend
cd FileShare-Web-VUE && npx vue-tsc --noEmit
  • Step 3: Verify no regressions

Ensure existing functionality still works by checking the build passes.