后端新增 POST api/files/compress 接口,使用 DotNetZip 将单个文件压缩为 ZIP, 密码固定为文件名去掉后缀,压缩前自动删除同名旧 ZIP。 前端新增 ConfirmModal 通用确认弹窗,替代所有 window.confirm/alert, 在文件列表中增加压缩按钮,压缩前显示确认弹窗。
16 KiB
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
compressingIdsref (Set tracking) -
Replace
window.confirmindeleteFilewith ConfirmModal -
Add
compressFilehandler using CompressModal -
Wire
@compressevents on all FileCard/FileListItem instances -
Step 3: Verify build
dotnet build FileShare-API/FileShare-API.csproj
cd FileShare-Web-VUE && npx vue-tsc --noEmit