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
+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;