1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb) 3. 省市区选择组件重构,支持搜索
This commit is contained in:
354
apps/web-antd/src/components/modal/ImageCompressModal.vue
Normal file
354
apps/web-antd/src/components/modal/ImageCompressModal.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user