Files
FileShare/docs/compose/plans/2026-07-09-file-compress.md
T
admin777 87f5c1ffb8 feat: 新增文件压缩功能和通用确认弹窗
后端新增 POST api/files/compress 接口,使用 DotNetZip 将单个文件压缩为 ZIP,
密码固定为文件名去掉后缀,压缩前自动删除同名旧 ZIP。
前端新增 ConfirmModal 通用确认弹窗,替代所有 window.confirm/alert,
在文件列表中增加压缩按钮,压缩前显示确认弹窗。
2026-07-10 10:35:32 +08:00

16 KiB
Raw Blame History

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:

<PackageReference Include="DotNetZip" Version="1.16.0" />
  • Step 2: Add CompressFileRequest to FileLibraryContracts.cs

After DeleteFileRequest, add:

    /// <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
namespace FileShare_Services.Services.FileLibrary
{
    public interface IFileCompressionService
    {
        Task CompressFileAsync(int id, string? password, bool useDefaultPassword, CancellationToken cancellationToken = default);
    }
}
  • Step 4: Create IFileCompressionEndpointService.cs
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
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
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
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:

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:

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:

compressFile: (id: number, password?: string | null, useDefaultPassword = true) =>
    request('files/compress', { method: 'POST', body: { id, password, useDefaultPassword } }),
  • Step 2: Create ConfirmModal.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
<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 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

dotnet build FileShare-API/FileShare-API.csproj
cd FileShare-Web-VUE && npx vue-tsc --noEmit