feat: 新增文件压缩功能和通用确认弹窗

后端新增 POST api/files/compress 接口,使用 DotNetZip 将单个文件压缩为 ZIP,
密码固定为文件名去掉后缀,压缩前自动删除同名旧 ZIP。
前端新增 ConfirmModal 通用确认弹窗,替代所有 window.confirm/alert,
在文件列表中增加压缩按钮,压缩前显示确认弹窗。
This commit is contained in:
2026-07-10 10:35:32 +08:00
parent 60878f9b6e
commit 87f5c1ffb8
13 changed files with 686 additions and 8 deletions
@@ -86,6 +86,10 @@ namespace FileShare_Services.Endpoints
.WithOpenApi("FileLibrary", "永久删除文件(物理文件+数据库记录)。")
.WithName("DeleteFile");
endpoints.MapPost<IFileLibraryEndpointService, CompressFileRequest>("api/files/compress", (service, request, _) => service.CompressFileAsync(request))
.WithOpenApi("FileLibrary", "压缩文件为 ZIP(可选密码)。")
.WithName("CompressFile");
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
.WithOpenApi("FileLibrary", "查询文件详情。")
.WithName("GetFileDetail");
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
<PackageReference Include="DotNetZip" Version="1.16.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
@@ -40,6 +40,12 @@ namespace FileShare_Services.Services.FileLibrary
public sealed record DeleteFileRequest(
[property: JsonPropertyName("id")] int Id);
/// <summary>
/// 压缩文件的请求。
/// </summary>
public sealed record CompressFileRequest(
[property: JsonPropertyName("id")] int Id);
/// <summary>
/// 查询服务器子目录的请求。
/// </summary>
@@ -121,6 +121,14 @@ namespace FileShare_Services.Services.FileLibrary
return ResponseHelper.Succeed("文件已永久删除。");
}
/// <inheritdoc />
public async Task<IApiResponse> CompressFileAsync(CompressFileRequest request)
{
ValidateFileId(request.Id);
await fileLibrary.CompressFileAsync(request.Id);
return ResponseHelper.Succeed("文件已压缩。");
}
/// <summary>
/// 验证文件 ID 是否有效,无效时抛出 <see cref="ArgumentException"/>。
/// </summary>
@@ -2,6 +2,7 @@ using FileShare_Common.Core;
using FileShare_EFCore.Database;
using FileShare_EFCore.Models;
using FileShare_Services.Core;
using Ionic.Zip;
using Microsoft.EntityFrameworkCore;
using System.Text;
@@ -593,6 +594,36 @@ namespace FileShare_Services.Services.FileLibrary
await db.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public async Task CompressFileAsync(int id, 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 = nameWithoutExt.ToLower();
// 创建 ZIP
using var zip = new Ionic.Zip.ZipFile();
zip.Password = zipPassword;
zip.AddFile(record.AbsolutePath, "");
zip.Save(zipPath);
}
/// <summary>
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
/// 遇到无权限的目录时跳过该分支继续遍历。
@@ -112,5 +112,12 @@ namespace FileShare_Services.Services.FileLibrary
/// <param name="request">包含文件 ID 的请求。</param>
/// <returns>API 响应。</returns>
Task<IApiResponse> DeleteFileAsync(DeleteFileRequest request);
/// <summary>
/// 压缩指定文件为 ZIP(密码为文件名去掉后缀)。
/// </summary>
/// <param name="request">包含文件 ID 的请求。</param>
/// <returns>API 响应。</returns>
Task<IApiResponse> CompressFileAsync(CompressFileRequest request);
}
}
@@ -128,5 +128,12 @@ namespace FileShare_Services.Services.FileLibrary
/// <param name="id">文件记录 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
Task DeleteFileAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// 将指定文件压缩为 ZIP(密码为文件名去掉后缀),存储在源文件同级目录。
/// </summary>
/// <param name="id">文件记录 ID。</param>
/// <param name="cancellationToken">取消令牌。</param>
Task CompressFileAsync(int id, CancellationToken cancellationToken = default);
}
}
+2
View File
@@ -116,5 +116,7 @@ export const api = {
request('files/progress', { method: 'POST', body: { id, position } }),
deleteFile: (id: number) =>
request('files/delete', { method: 'POST', body: { id } }),
compressFile: (id: number) =>
request('files/compress', { method: 'POST', body: { id } }),
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
}
@@ -12,6 +12,7 @@ import RootTabs from './client/RootTabs.vue'
import SelectedMediaPlayerHost from './client/SelectedMediaPlayerHost.vue'
import ViewToggle from './client/ViewToggle.vue'
import QrCodeModal from './QrCodeModal.vue'
import ConfirmModal from './ConfirmModal.vue'
type MediaPlayerHandle = {
getVideoElement: () => HTMLVideoElement | null
@@ -36,7 +37,9 @@ const recentLoading = ref(false)
const viewMode = ref<'list' | 'grid'>('list')
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
const confirmModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const mediaPlayer = ref<MediaPlayerHandle | null>(null)
const compressingIds = ref(new Set<number>())
function setMediaPlayer(el: unknown) {
mediaPlayer.value = (el as MediaPlayerHandle) ?? null
@@ -273,9 +276,13 @@ function exitSearch() {
}
async function deleteFile(file: FileRecordDto) {
if (!window.confirm(`确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`)) {
return
}
const confirmed = await confirmModal.value?.open({
title: '确认删除',
message: `确定要永久删除 "${file.fileName}" 吗?\n\n删除后无法恢复!`,
confirmText: '删除',
danger: true,
})
if (!confirmed) return
try {
await api.deleteFile(file.id)
await browseDirectory()
@@ -290,6 +297,30 @@ async function deleteFile(file: FileRecordDto) {
}
}
async function compressFile(file: FileRecordDto) {
const nameWithoutExt = file.fileName.replace(/\.[^.]+$/, '')
const confirmed = await confirmModal.value?.open({
title: '确认压缩',
message: `确定要将 "${file.fileName}" 压缩为 ZIP 吗?\n\n密码将自动设置为:${nameWithoutExt.toLocaleLowerCase()}`,
confirmText: '压缩',
})
if (!confirmed) return
compressingIds.value.add(file.id)
try {
await api.compressFile(file.id)
await confirmModal.value?.open({
title: '压缩完成',
message: `"${file.fileName}" 已压缩为 "${nameWithoutExt}.zip"。`,
showCancel: false,
confirmText: '确定',
})
} catch (error) {
setError(error)
} finally {
compressingIds.value.delete(file.id)
}
}
function updatePlaybackPosition(id: number, position: number) {
if (selectedFile.value?.id === id) {
selectedFile.value.playbackPosition = position
@@ -481,7 +512,7 @@ onBeforeUnmount(() => {
<p v-else-if="searchResults.length === 0" class="empty-state">无匹配文件</p>
<div v-else-if="viewMode === 'grid'" class="file-grid">
<template v-for="file in searchResults" :key="file.id">
<FileCard :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />
<FileCard :file="file" show-created-time :compressing="compressingIds.has(file.id)" @select="selectSearchFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
@@ -506,7 +537,7 @@ onBeforeUnmount(() => {
</div>
<div v-else class="file-list">
<template v-for="file in searchResults" :key="file.id">
<FileListItem :file="file" show-created-time @select="selectSearchFile" @delete="deleteFile" />
<FileListItem :file="file" show-created-time :compressing="compressingIds.has(file.id)" @select="selectSearchFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
@@ -549,7 +580,7 @@ onBeforeUnmount(() => {
</p>
<div v-else-if="viewMode === 'grid'" class="file-grid">
<template v-for="file in recentFiles" :key="file.id">
<FileCard :file="file" :selected="selectedFile?.id === file.id" @select="selectFile" @delete="deleteFile" />
<FileCard :file="file" :selected="selectedFile?.id === file.id" :compressing="compressingIds.has(file.id)" @select="selectFile" @delete="deleteFile" @compress="compressFile" />
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
:ref="setMediaPlayer"
@@ -578,8 +609,10 @@ onBeforeUnmount(() => {
:file="file"
:selected="selectedFile?.id === file.id"
:show-last-played="activeTab === 'recent-played'"
:compressing="compressingIds.has(file.id)"
@select="selectFile"
@delete="deleteFile"
@compress="compressFile"
/>
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
@@ -647,9 +680,11 @@ onBeforeUnmount(() => {
<FileCard
:file="file"
:selected="selectedFile?.id === file.id"
:compressing="compressingIds.has(file.id)"
show-created-time
@select="selectFile"
@delete="deleteFile"
@compress="compressFile"
/>
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
@@ -679,9 +714,11 @@ onBeforeUnmount(() => {
<FileListItem
:file="file"
:selected="selectedFile?.id === file.id"
:compressing="compressingIds.has(file.id)"
show-created-time
@select="selectFile"
@delete="deleteFile"
@compress="compressFile"
/>
<SelectedMediaPlayerHost
v-if="selectedFile?.id === file.id"
@@ -726,4 +763,5 @@ onBeforeUnmount(() => {
</main>
<QrCodeModal ref="qrModal" />
<ConfirmModal ref="confirmModal" />
</template>
@@ -0,0 +1,60 @@
<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; white-space: pre-line; }
.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>
@@ -6,11 +6,13 @@ defineProps<{
file: FileRecordDto
selected?: boolean
showCreatedTime?: boolean
compressing?: boolean
}>()
defineEmits<{
select: [file: FileRecordDto]
delete: [file: FileRecordDto]
compress: [file: FileRecordDto]
}>()
</script>
@@ -22,6 +24,12 @@ defineEmits<{
@click="$emit('select', file)"
>
<span class="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">删除</span>
<span
class="compress-btn"
:class="{ disabled: compressing }"
@click.stop="!compressing && $emit('compress', file)"
:title="compressing ? '压缩中...' : '压缩为ZIP'"
>{{ compressing ? '压缩中...' : '压缩' }}</span>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
@@ -56,10 +64,33 @@ defineEmits<{
opacity: 0;
transition: opacity 0.15s;
}
.file-card:hover .delete-btn {
.compress-btn {
position: absolute;
top: 4px;
right: 52px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 12px;
line-height: 18px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.compress-btn.disabled {
opacity: 1;
background: rgba(0, 0, 0, 0.4);
cursor: not-allowed;
}
.file-card:hover .delete-btn,
.file-card:hover .compress-btn {
opacity: 1;
}
.delete-btn:hover {
background: #e53e3e;
}
.compress-btn:hover:not(.disabled) {
background: #3182ce;
}
</style>
@@ -7,11 +7,13 @@ defineProps<{
selected?: boolean
showCreatedTime?: boolean
showLastPlayed?: boolean
compressing?: boolean
}>()
defineEmits<{
select: [file: FileRecordDto]
delete: [file: FileRecordDto]
compress: [file: FileRecordDto]
}>()
</script>
@@ -39,12 +41,17 @@ defineEmits<{
</small>
<small v-if="showCreatedTime && formatCreatedTime(file)">{{ formatCreatedTime(file) }}</small>
</span>
<span
class="compress-btn"
:class="{ disabled: compressing }"
@click.stop="!compressing && $emit('compress', file)"
>{{ compressing ? '压缩中...' : '压缩' }}</span>
<span class="delete-btn" @click.stop="$emit('delete', file)" title="永久删除">删除</span>
</button>
</template>
<style scoped>
.delete-btn {
.compress-btn {
margin-left: auto;
padding: 2px 10px;
border-radius: 4px;
@@ -57,9 +64,31 @@ defineEmits<{
opacity: 0;
transition: opacity 0.15s;
}
.compress-btn.disabled {
opacity: 1;
color: #bbb;
cursor: not-allowed;
}
.delete-btn {
padding: 2px 10px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.06);
color: #999;
font-size: 12px;
line-height: 20px;
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s;
}
.mobile-file:hover .compress-btn,
.mobile-file:hover .delete-btn {
opacity: 1;
}
.compress-btn:hover:not(.disabled) {
background: #3182ce;
color: #fff;
}
.delete-btn:hover {
background: #e53e3e;
color: #fff;
@@ -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
```