feat: 新增文件压缩功能和通用确认弹窗
后端新增 POST api/files/compress 接口,使用 DotNetZip 将单个文件压缩为 ZIP, 密码固定为文件名去掉后缀,压缩前自动删除同名旧 ZIP。 前端新增 ConfirmModal 通用确认弹窗,替代所有 window.confirm/alert, 在文件列表中增加压缩按钮,压缩前显示确认弹窗。
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
# 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:
|
||||
```xml
|
||||
<PackageReference Include="DotNetZip" Version="1.16.0" />
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add CompressFileRequest to FileLibraryContracts.cs**
|
||||
|
||||
After `DeleteFileRequest`, add:
|
||||
```csharp
|
||||
/// <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**
|
||||
|
||||
```csharp
|
||||
namespace FileShare_Services.Services.FileLibrary
|
||||
{
|
||||
public interface IFileCompressionService
|
||||
{
|
||||
Task CompressFileAsync(int id, string? password, bool useDefaultPassword, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create IFileCompressionEndpointService.cs**
|
||||
|
||||
```csharp
|
||||
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**
|
||||
|
||||
```csharp
|
||||
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**
|
||||
|
||||
```csharp
|
||||
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**
|
||||
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
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:
|
||||
```csharp
|
||||
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:
|
||||
```typescript
|
||||
compressFile: (id: number, password?: string | null, useDefaultPassword = true) =>
|
||||
request('files/compress', { method: 'POST', body: { id, password, useDefaultPassword } }),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create ConfirmModal.vue**
|
||||
|
||||
```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**
|
||||
|
||||
```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<number> 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**
|
||||
|
||||
```bash
|
||||
dotnet build FileShare-API/FileShare-API.csproj
|
||||
cd FileShare-Web-VUE && npx vue-tsc --noEmit
|
||||
```
|
||||
Reference in New Issue
Block a user