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