feat: 新增文件压缩功能和通用确认弹窗
后端新增 POST api/files/compress 接口,使用 DotNetZip 将单个文件压缩为 ZIP, 密码固定为文件名去掉后缀,压缩前自动删除同名旧 ZIP。 前端新增 ConfirmModal 通用确认弹窗,替代所有 window.confirm/alert, 在文件列表中增加压缩按钮,压缩前显示确认弹窗。
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user