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

455 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```