355 lines
8.6 KiB
Vue
355 lines
8.6 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||
|
||
import { Button, Image, Modal, Spin } from 'ant-design-vue';
|
||
|
||
import {
|
||
compressImageFile,
|
||
formatFileSize,
|
||
formatSavingsPercent,
|
||
} from '#/utils/image-compress';
|
||
|
||
export type ImageCompressChoice = 'compressed' | 'original' | 'cancel';
|
||
|
||
export interface ImageCompressModalResult {
|
||
choice: ImageCompressChoice;
|
||
file: File | null;
|
||
meta?: {
|
||
originalSize: number;
|
||
compressedSize?: number;
|
||
usedCompress: boolean;
|
||
};
|
||
}
|
||
|
||
const props = defineProps<{
|
||
file: File;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
resolve: [result: ImageCompressModalResult];
|
||
}>();
|
||
|
||
type ModalStatus = 'compressing' | 'ready' | 'gifSkipped' | 'error';
|
||
|
||
const visible = ref(true);
|
||
const status = ref<ModalStatus>('compressing');
|
||
const progressText = ref('正在压缩,请稍候…');
|
||
const errorMessage = ref('');
|
||
const originalPreviewUrl = ref('');
|
||
const compressedPreviewUrl = ref('');
|
||
const compressedFile = ref<File | null>(null);
|
||
const originalSize = ref(0);
|
||
const compressedSize = ref(0);
|
||
|
||
const sizeText = computed(() => {
|
||
if (!originalSize.value) return '';
|
||
const originalText = formatFileSize(originalSize.value);
|
||
if (status.value === 'gifSkipped') {
|
||
return `原图 ${originalText}`;
|
||
}
|
||
if (compressedSize.value && status.value === 'ready') {
|
||
const savings = formatSavingsPercent(
|
||
originalSize.value,
|
||
compressedSize.value,
|
||
);
|
||
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}${savings}`;
|
||
}
|
||
if (compressedSize.value) {
|
||
return `原图 ${originalText} → 压缩后 ${formatFileSize(compressedSize.value)}`;
|
||
}
|
||
return `原图 ${originalText}`;
|
||
});
|
||
|
||
const originalButtonLabel = computed(() => {
|
||
if (!originalSize.value) return '使用原图';
|
||
return `使用原图 (${formatFileSize(originalSize.value)})`;
|
||
});
|
||
|
||
const compressedButtonLabel = computed(() => {
|
||
if (!compressedSize.value) return '使用压缩图';
|
||
return `使用压缩图 (${formatFileSize(compressedSize.value)})`;
|
||
});
|
||
|
||
function revokePreviewUrls() {
|
||
if (originalPreviewUrl.value) {
|
||
URL.revokeObjectURL(originalPreviewUrl.value);
|
||
originalPreviewUrl.value = '';
|
||
}
|
||
if (compressedPreviewUrl.value) {
|
||
URL.revokeObjectURL(compressedPreviewUrl.value);
|
||
compressedPreviewUrl.value = '';
|
||
}
|
||
}
|
||
|
||
function finish(choice: ImageCompressChoice) {
|
||
const useCompressed = choice === 'compressed' && status.value === 'ready';
|
||
const selectedFile =
|
||
choice === 'cancel'
|
||
? null
|
||
: useCompressed && compressedFile.value
|
||
? compressedFile.value
|
||
: props.file;
|
||
|
||
emit('resolve', {
|
||
choice,
|
||
file: selectedFile,
|
||
meta:
|
||
choice === 'cancel'
|
||
? undefined
|
||
: {
|
||
originalSize: originalSize.value,
|
||
compressedSize: compressedSize.value || undefined,
|
||
usedCompress: useCompressed,
|
||
},
|
||
});
|
||
visible.value = false;
|
||
}
|
||
|
||
async function startCompress() {
|
||
originalSize.value = props.file.size;
|
||
revokePreviewUrls();
|
||
originalPreviewUrl.value = URL.createObjectURL(props.file);
|
||
|
||
if (props.file.type === 'image/gif') {
|
||
status.value = 'gifSkipped';
|
||
progressText.value = '';
|
||
return;
|
||
}
|
||
|
||
status.value = 'compressing';
|
||
progressText.value = '正在压缩,请稍候…';
|
||
|
||
try {
|
||
const result = await compressImageFile(props.file, {
|
||
onProgress: (message) => {
|
||
progressText.value = message;
|
||
},
|
||
});
|
||
|
||
compressedFile.value = result.file;
|
||
compressedSize.value = result.file.size;
|
||
|
||
if (result.skipped) {
|
||
status.value = 'gifSkipped';
|
||
return;
|
||
}
|
||
|
||
compressedPreviewUrl.value = URL.createObjectURL(result.file);
|
||
status.value = 'ready';
|
||
progressText.value = '';
|
||
} catch (error) {
|
||
status.value = 'error';
|
||
errorMessage.value =
|
||
error instanceof Error ? error.message : '图片压缩失败,请使用原图或取消';
|
||
progressText.value = '';
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
startCompress();
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
revokePreviewUrls();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Modal
|
||
v-model:visible="visible"
|
||
title="图片较大"
|
||
:width="640"
|
||
:mask-closable="false"
|
||
:closable="false"
|
||
:footer="null"
|
||
@cancel="finish('cancel')"
|
||
>
|
||
<div class="image-compress-modal">
|
||
<p v-if="sizeText" class="size-text">{{ sizeText }}</p>
|
||
|
||
<div v-if="status === 'compressing'" class="compressing-panel">
|
||
<div class="preview-single">
|
||
<Image
|
||
v-if="originalPreviewUrl"
|
||
:src="originalPreviewUrl"
|
||
alt="原图预览"
|
||
class="preview-image"
|
||
/>
|
||
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
|
||
</div>
|
||
<div class="compressing-status">
|
||
<Spin />
|
||
<span class="progress-text">{{ progressText }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="status === 'ready'" class="compare-panel">
|
||
<div class="preview-column">
|
||
<div class="preview-label">原图</div>
|
||
<Image
|
||
:src="originalPreviewUrl"
|
||
alt="原图预览"
|
||
class="preview-image"
|
||
/>
|
||
<div class="preview-size">{{ formatFileSize(originalSize) }}</div>
|
||
<span class="preview-hint">点击预览</span>
|
||
</div>
|
||
<div class="preview-column">
|
||
<div class="preview-label">压缩后</div>
|
||
<Image
|
||
:src="compressedPreviewUrl"
|
||
alt="压缩图预览"
|
||
class="preview-image"
|
||
/>
|
||
<div class="preview-size">{{ formatFileSize(compressedSize) }}</div>
|
||
<span class="preview-hint">点击预览</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="status === 'gifSkipped'" class="gif-panel">
|
||
<div class="preview-single">
|
||
<Image
|
||
v-if="originalPreviewUrl"
|
||
:src="originalPreviewUrl"
|
||
alt="原图预览"
|
||
class="preview-image"
|
||
/>
|
||
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
|
||
</div>
|
||
<p class="hint-text">GIF 不支持压缩,将上传原图。</p>
|
||
</div>
|
||
|
||
<div v-else class="error-panel">
|
||
<div class="preview-single">
|
||
<Image
|
||
v-if="originalPreviewUrl"
|
||
:src="originalPreviewUrl"
|
||
alt="原图预览"
|
||
class="preview-image"
|
||
/>
|
||
<span v-if="originalPreviewUrl" class="preview-hint">点击预览</span>
|
||
</div>
|
||
<p class="error-text">{{ errorMessage }}</p>
|
||
</div>
|
||
|
||
<div class="footer-actions">
|
||
<Button @click="finish('cancel')">取消</Button>
|
||
<Button
|
||
v-if="status === 'ready' || status === 'error'"
|
||
@click="finish('original')"
|
||
>
|
||
{{ originalButtonLabel }}
|
||
</Button>
|
||
<Button
|
||
v-if="status === 'ready'"
|
||
type="primary"
|
||
@click="finish('compressed')"
|
||
>
|
||
{{ compressedButtonLabel }}
|
||
</Button>
|
||
<Button
|
||
v-if="status === 'gifSkipped'"
|
||
type="primary"
|
||
@click="finish('original')"
|
||
>
|
||
{{ originalButtonLabel }}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
</template>
|
||
|
||
<style lang="less" scoped>
|
||
.image-compress-modal {
|
||
.size-text {
|
||
margin-bottom: 16px;
|
||
color: rgba(0, 0, 0, 0.65);
|
||
text-align: center;
|
||
}
|
||
|
||
.preview-single,
|
||
.preview-column {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.preview-image {
|
||
max-width: 100%;
|
||
max-height: 220px;
|
||
cursor: pointer;
|
||
|
||
:deep(.ant-image-img) {
|
||
max-width: 100%;
|
||
max-height: 220px;
|
||
object-fit: contain;
|
||
border: 1px solid #f0f0f0;
|
||
border-radius: 8px;
|
||
background: #fafafa;
|
||
}
|
||
}
|
||
|
||
.preview-label {
|
||
margin-bottom: 8px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.preview-size {
|
||
margin-top: 8px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.preview-hint {
|
||
margin-top: 4px;
|
||
color: rgba(0, 0, 0, 0.35);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.compressing-panel,
|
||
.gif-panel,
|
||
.error-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
.compressing-status {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.progress-text {
|
||
color: rgba(0, 0, 0, 0.65);
|
||
}
|
||
|
||
.compare-panel {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 16px;
|
||
}
|
||
|
||
.hint-text,
|
||
.error-text {
|
||
margin: 0;
|
||
color: rgba(0, 0, 0, 0.65);
|
||
text-align: center;
|
||
}
|
||
|
||
.error-text {
|
||
color: #ff4d4f;
|
||
}
|
||
|
||
.footer-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 24px;
|
||
}
|
||
}
|
||
</style>
|