1. 管理员管理模块拆分

2. 图片上传自动压缩(大于850kb)
3. 省市区选择组件重构,支持搜索
This commit is contained in:
李琦
2026-05-27 09:34:08 +08:00
parent 68fd7b0045
commit 402da17e3d
56 changed files with 4188 additions and 13664 deletions

View 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>