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

@@ -5,6 +5,10 @@ import { Upload } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
@@ -24,28 +28,29 @@ const mValue = useVModel(props, 'value', emits, {
});
/**
* 自定义上传请求处理函数
*
*
* 该函数根据preferences配置的上传方式选择使用OSS直传或后端上传
* - 'direct': OSS直传模式文件直接从浏览器上传到阿里云OSS
* - 'backend': 后端上传模式文件先上传到后端服务器再由后端上传到OSS
*
*
* 上传方式说明:
* 1. OSS直传direct
* - 调用uploadToOss函数直接上传到OSS
* - 减少服务器负载,上传速度更快
*
*
* 2. 后端上传backend
* - 调用uploadFile API通过后端服务器上传
* - 兼容现有功能,所有上传逻辑由后端统一处理
*
*
* 兼容性处理:
* - 两种上传方式返回的数据结构保持一致:{ url: string }
* - 上传成功后将URL赋值给mValue触发组件更新
*
*
* @param e 上传事件对象包含file字段文件对象
*/
const customRequest = async (e: any) => {
try {
const actualFile = resolveUploadFile(e.file as File);
// 从preferences中读取上传方式配置
// uploadMethod可能的值'direct'OSS直传或 'backend'(后端上传)
const uploadMethod = preferences.app.uploadMethod || 'direct';
@@ -57,13 +62,13 @@ const customRequest = async (e: any) => {
// OSS直传模式文件直接从浏览器上传到OSS
// uploadToOss函数会处理签名获取、文件上传等所有逻辑
data = await uploadToOss({
file: e.file as File,
file: actualFile,
});
} else {
// 后端上传模式文件先上传到后端服务器再由后端上传到OSS
// uploadFile函数会将文件发送到后端API/upload/image
data = await uploadFile({
file: e.file,
file: actualFile,
});
}
@@ -83,6 +88,7 @@ const handleRemove = (e: Event) => {
</script>
<template>
<Upload
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:show-upload-list="false"
list-type="picture-card"
@@ -102,6 +108,7 @@ const handleRemove = (e: Event) => {
.m-avatar-wrap {
position: relative;
height: 102px;
overflow: hidden;
.m-avatar-icon-delete {
position: absolute;
top: 0;

View File

@@ -10,6 +10,7 @@ import { message } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import { prepareImageForUpload } from '#/utils/prepare-image-upload';
import { preferences } from '@vben/preferences';
const props = defineProps({
@@ -164,6 +165,12 @@ const insertImageToEditor = (quill: any, imageUrl: string): void => {
*/
const uploadImage = async (file: File): Promise<string> => {
try {
const { file: preparedFile } = await prepareImageForUpload(file);
if (!preparedFile) {
isUpload.value = false;
return '';
}
// 显示上传中的提示
message.loading({ content: '图片上传中...', key: 'imageUpload' });
@@ -178,7 +185,7 @@ const uploadImage = async (file: File): Promise<string> => {
// OSS直传模式文件直接从浏览器上传到OSS
// uploadToOss函数会处理签名获取、文件上传等所有逻辑
data = await uploadToOss({
file: file,
file: preparedFile,
// 如果需要显示上传进度可以添加onProgress回调
// onProgress: (percent) => {
// message.loading({ content: `图片上传中... ${percent}%`, key: 'imageUpload' });
@@ -188,7 +195,7 @@ const uploadImage = async (file: File): Promise<string> => {
// 后端上传模式文件先上传到后端服务器再由后端上传到OSS
// uploadFile函数会将文件发送到后端API/upload/image
data = await uploadFile({
file,
file: preparedFile,
});
}

View File

@@ -0,0 +1,709 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useVModel } from '@vueuse/core';
import { Input, Popover } from 'ant-design-vue';
import type {
AddressListRow,
AddressNode,
AddressSearchHit,
AddressValue,
BreadcrumbItem,
RegionLevel,
SelectionContext,
} from '#/util/address-index';
import {
applySearchHit,
buildAddressOutput,
buildBreadcrumbFromContext,
canCompleteAfterSelect,
formatAddressDisplay,
getInitialPickerLevel,
getLevelSearchPlaceholder,
getNextLevel,
isMunicipalityProvince,
levelOptionsToRows,
resolveAddressValue,
rowHasNextLevel,
searchAddressHits,
searchHitsToRows,
truncateContextToBreadcrumb,
} from '#/util/address-index';
defineOptions({
name: 'RegionAddressPicker',
inheritAttrs: false,
});
const props = defineProps<{
value?: AddressValue | null;
disabled?: boolean;
}>();
const emits = defineEmits<{
'update:value': [value: AddressValue | undefined];
}>();
const mValue = useVModel(props, 'value', emits, {
passive: true,
});
const open = ref(false);
const searchKeyword = ref('');
const currentLevel = ref<RegionLevel>('province');
const activeIndex = ref(0);
const listRef = ref<HTMLElement | null>(null);
const searchInputRef = ref<InstanceType<typeof Input> | null>(null);
const triggerWrapRef = ref<HTMLElement | null>(null);
const ctx = ref<SelectionContext>({
provinceId: undefined,
cityId: undefined,
districtId: undefined,
});
const displayText = computed(() => formatAddressDisplay(mValue.value));
const breadcrumbItems = computed(() => buildBreadcrumbFromContext(ctx.value));
const isSearching = computed(() => !!searchKeyword.value.trim());
const listRows = computed((): AddressListRow[] => {
if (isSearching.value) {
return searchHitsToRows(
searchAddressHits(searchKeyword.value, ctx.value),
);
}
return levelOptionsToRows(currentLevel.value, ctx.value);
});
const searchPlaceholder = computed(() =>
getLevelSearchPlaceholder(currentLevel.value),
);
function syncContextFromValue(val?: AddressValue | null) {
const resolved = resolveAddressValue(val ?? undefined);
ctx.value = {
provinceId: resolved.provinceId,
cityId: resolved.cityId,
districtId: resolved.districtId,
};
}
function resetPickerToValue(val?: AddressValue | null) {
syncContextFromValue(val);
const resolved = resolveAddressValue(val ?? undefined);
currentLevel.value = getInitialPickerLevel(resolved);
searchKeyword.value = '';
activeIndex.value = 0;
}
function resetActiveIndex() {
activeIndex.value = listRows.value.length > 0 ? 0 : -1;
}
function closePanel(output?: AddressValue) {
if (output !== undefined) {
mValue.value = output;
}
open.value = false;
searchKeyword.value = '';
}
function completeSelection() {
closePanel(
buildAddressOutput(
ctx.value.provinceId,
ctx.value.cityId,
ctx.value.districtId,
),
);
}
function applySelectionAtLevel(level: RegionLevel, item: AddressNode) {
if (level === 'province') {
ctx.value = {
provinceId: item.value,
cityId: undefined,
districtId: undefined,
};
} else if (level === 'city') {
ctx.value = {
...ctx.value,
cityId: item.value,
districtId: undefined,
};
} else {
ctx.value = {
...ctx.value,
districtId: item.value,
};
}
}
function searchHitFromRow(row: AddressListRow): AddressSearchHit {
return {
level: row.level,
node: row.node,
pathLabel: row.label,
complete: row.complete,
};
}
function advanceAfterRowSelect(row: AddressListRow) {
searchKeyword.value = '';
if (row.complete) {
completeSelection();
return;
}
const next = getNextLevel(row.level, ctx.value);
if (next) {
currentLevel.value = next;
return;
}
completeSelection();
}
function onConfirmRow(row: AddressListRow) {
if (isSearching.value) {
const hit = searchHitFromRow(row);
if (
hit.level === 'province' &&
(ctx.value.cityId || ctx.value.districtId)
) {
ctx.value = applySearchHit(hit, ctx.value);
currentLevel.value = isMunicipalityProvince(hit.node.value)
? 'district'
: 'city';
searchKeyword.value = '';
resetActiveIndex();
return;
}
ctx.value = applySearchHit(hit, ctx.value);
advanceAfterRowSelect(row);
return;
}
onSelectItem(row.node, row.level);
}
function onSelectItem(item: AddressNode, level = currentLevel.value) {
if (level === 'province' && (ctx.value.cityId || ctx.value.districtId)) {
ctx.value = {
provinceId: item.value,
cityId: undefined,
districtId: undefined,
};
searchKeyword.value = '';
currentLevel.value = isMunicipalityProvince(item.value)
? 'district'
: 'city';
resetActiveIndex();
return;
}
applySelectionAtLevel(level, item);
const row: AddressListRow = {
level,
node: item,
label: item.label,
complete: canCompleteAfterSelect(level, ctx.value, item),
};
advanceAfterRowSelect(row);
}
function confirmActiveItem() {
const rows = listRows.value;
if (activeIndex.value < 0 || activeIndex.value >= rows.length) {
return;
}
onConfirmRow(rows[activeIndex.value]!);
}
function drillActiveRow() {
const rows = listRows.value;
const row = rows[activeIndex.value];
if (!row || !rowHasNextLevel(row, ctx.value)) {
return;
}
if (isSearching.value) {
const hit = searchHitFromRow(row);
if (
hit.level === 'province' &&
(ctx.value.cityId || ctx.value.districtId)
) {
ctx.value = applySearchHit(hit, ctx.value);
} else {
ctx.value = applySearchHit(hit, ctx.value);
}
searchKeyword.value = '';
} else {
applySelectionAtLevel(row.level, row.node);
}
const next = getNextLevel(row.level, ctx.value);
if (next) {
currentLevel.value = next;
resetActiveIndex();
}
}
function goBackOneLevel() {
if (currentLevel.value === 'province') {
return;
}
if (currentLevel.value === 'district') {
if (isMunicipalityProvince(ctx.value.provinceId)) {
ctx.value = { provinceId: ctx.value.provinceId };
currentLevel.value = 'province';
} else {
ctx.value = {
provinceId: ctx.value.provinceId,
cityId: ctx.value.cityId,
};
currentLevel.value = 'city';
}
} else if (currentLevel.value === 'city') {
ctx.value = {};
currentLevel.value = 'province';
}
searchKeyword.value = '';
resetActiveIndex();
}
function scrollActiveIntoView() {
nextTick(() => {
const el = listRef.value?.querySelector(
`[data-index="${activeIndex.value}"]`,
);
el?.scrollIntoView({ block: 'nearest' });
});
}
function getPopupContainer(triggerNode: HTMLElement): HTMLElement {
return (
(triggerNode.closest('[role="dialog"]') as HTMLElement | null) ??
document.body
);
}
function resolveSearchInputEl(): HTMLInputElement | null {
const inst = searchInputRef.value as
| (InstanceType<typeof Input> & { input?: HTMLInputElement })
| null;
if (inst?.input instanceof HTMLInputElement) {
return inst.input;
}
const scope =
(triggerWrapRef.value?.closest('[role="dialog"]') as HTMLElement | null) ??
document.body;
return scope.querySelector(
'.region-address-picker-popover .region-address-search input',
);
}
function focusSearchInput() {
const tryFocus = (): boolean => {
const inst = searchInputRef.value;
if (inst?.focus) {
inst.focus();
return true;
}
const input = resolveSearchInputEl();
if (input) {
input.focus();
return true;
}
return false;
};
const schedule = (attempt: number) => {
if (!open.value || attempt > 8) {
return;
}
if (tryFocus()) {
return;
}
requestAnimationFrame(() => {
if (tryFocus()) {
return;
}
const delay = attempt < 3 ? 0 : attempt < 6 ? 50 : 100;
setTimeout(() => schedule(attempt + 1), delay);
});
};
nextTick(() => schedule(0));
}
function handleKeydown(e: KeyboardEvent) {
if (!open.value) {
return;
}
const rows = listRows.value;
switch (e.key) {
case 'ArrowDown': {
if (rows.length === 0) {
return;
}
e.preventDefault();
if (activeIndex.value < 0) {
activeIndex.value = 0;
} else {
activeIndex.value = (activeIndex.value + 1) % rows.length;
}
scrollActiveIntoView();
break;
}
case 'ArrowUp': {
if (rows.length === 0) {
return;
}
e.preventDefault();
activeIndex.value =
activeIndex.value <= 0 ? rows.length - 1 : activeIndex.value - 1;
scrollActiveIntoView();
break;
}
case 'Enter': {
if (rows.length === 0) {
return;
}
e.preventDefault();
confirmActiveItem();
break;
}
case 'ArrowLeft': {
e.preventDefault();
goBackOneLevel();
break;
}
case 'ArrowRight': {
if (rows.length === 0 || activeIndex.value < 0) {
return;
}
e.preventDefault();
drillActiveRow();
break;
}
default:
break;
}
}
function onBreadcrumbClick(item: BreadcrumbItem) {
ctx.value = truncateContextToBreadcrumb(ctx.value, item);
if (item.level === 'province') {
currentLevel.value = isMunicipalityProvince(item.id) ? 'district' : 'city';
} else if (item.level === 'city') {
currentLevel.value = 'district';
} else {
currentLevel.value = 'district';
}
searchKeyword.value = '';
resetActiveIndex();
}
function goProvinceLevel() {
ctx.value = {};
currentLevel.value = 'province';
searchKeyword.value = '';
resetActiveIndex();
}
function onClear() {
ctx.value = {};
mValue.value = undefined;
currentLevel.value = 'province';
searchKeyword.value = '';
resetActiveIndex();
}
function onOpenChange(v: boolean) {
open.value = v;
if (v) {
resetPickerToValue(mValue.value);
}
}
watch(open, (v) => {
if (v) {
focusSearchInput();
}
}, { flush: 'post' });
watch(listRows, () => {
if (open.value) {
resetActiveIndex();
}
});
watch(
() => props.value,
(val) => {
if (!open.value) {
syncContextFromValue(val ?? undefined);
}
},
{ immediate: true, deep: true },
);
</script>
<template>
<Popover
v-model:open="open"
:trigger="['click']"
placement="bottomLeft"
overlay-class-name="region-address-picker-popover"
:get-popup-container="getPopupContainer"
@open-change="onOpenChange"
>
<template #content>
<div class="region-address-panel" @click.stop>
<div v-if="breadcrumbItems.length" class="region-address-breadcrumb-row">
<div class="region-address-breadcrumb">
<template
v-for="(item, idx) in breadcrumbItems"
:key="`${item.level}-${item.id}`"
>
<span v-if="idx > 0" class="region-address-breadcrumb-sep">/</span>
<button
type="button"
class="region-address-breadcrumb-item"
@click="onBreadcrumbClick(item)"
>
{{ item.label }}
</button>
</template>
</div>
<button
type="button"
class="region-address-repick-province"
@click="goProvinceLevel"
>
重选省份
</button>
</div>
<Input
ref="searchInputRef"
v-model:value="searchKeyword"
:placeholder="searchPlaceholder"
allow-clear
class="region-address-search"
@keydown="handleKeydown"
/>
<ul ref="listRef" class="region-address-list">
<li
v-for="(row, index) in listRows"
:key="`${row.level}-${row.node.value}`"
:class="[
'region-address-list-item',
{ 'region-address-list-item-active': index === activeIndex },
]"
:data-index="index"
@click="onConfirmRow(row)"
@mouseenter="activeIndex = index"
>
{{ row.label }}
</li>
<li v-if="listRows.length === 0" class="region-address-list-empty">
暂无数据
</li>
</ul>
</div>
</template>
<div
ref="triggerWrapRef"
class="region-address-trigger-wrap"
@click="!disabled && (open = true)"
>
<Input
:disabled="disabled"
:value="displayText"
class="region-address-trigger"
placeholder="请选择省市区"
readonly
>
<template v-if="displayText && !disabled" #suffix>
<span
class="region-address-clear"
@click.stop="onClear"
>×</span>
</template>
</Input>
</div>
</Popover>
</template>
<style scoped>
.region-address-trigger-wrap {
width: 100%;
}
.region-address-trigger {
width: 100%;
cursor: pointer;
}
.region-address-clear {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: rgba(0, 0, 0, 0.35);
cursor: pointer;
font-size: 12px;
}
.region-address-clear:hover {
color: rgba(0, 0, 0, 0.65);
}
.region-address-panel {
width: 320px;
}
.region-address-breadcrumb-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.region-address-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
flex: 1;
font-size: 13px;
}
.region-address-repick-province {
flex-shrink: 0;
padding: 0;
border: none;
background: none;
color: rgba(0, 0, 0, 0.45);
cursor: pointer;
font-size: 12px;
white-space: nowrap;
}
.region-address-repick-province:hover {
color: #1677ff;
}
.region-address-breadcrumb-sep {
color: rgba(0, 0, 0, 0.35);
margin: 0 4px;
}
.region-address-breadcrumb-item {
padding: 0;
border: none;
background: none;
color: #1677ff;
cursor: pointer;
font-size: 13px;
}
.region-address-breadcrumb-item:hover {
color: #4096ff;
}
.region-address-search {
margin-bottom: 8px;
}
.region-address-list {
margin: 0;
padding: 0;
max-height: 240px;
overflow-y: auto;
list-style: none;
}
.region-address-list-item {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.region-address-list-item:hover {
background: rgba(0, 0, 0, 0.04);
}
.region-address-list-item-active {
background: #e6f4ff;
}
.region-address-list-item-active:hover {
background: #e6f4ff;
}
.region-address-list-empty {
padding: 16px 12px;
color: rgba(0, 0, 0, 0.45);
text-align: center;
}
/* 暗色模式适配(对齐 drug-search-select / menu-search-select */
.dark {
.region-address-clear {
color: rgba(255, 255, 255, 0.35);
}
.region-address-clear:hover {
color: rgba(255, 255, 255, 0.65);
}
.region-address-repick-province {
color: rgba(255, 255, 255, 0.45);
}
.region-address-repick-province:hover {
color: #60a5fa;
}
.region-address-breadcrumb-sep {
color: rgba(255, 255, 255, 0.35);
}
.region-address-breadcrumb-item {
color: #60a5fa;
}
.region-address-breadcrumb-item:hover {
color: #93c5fd;
}
.region-address-list-item {
color: #f3f4f6;
}
.region-address-list-item:hover {
background: #374151;
}
.region-address-list-item-active,
.region-address-list-item-active:hover {
background: #1e3a5f;
}
.region-address-list-empty {
color: #9ca3af;
}
}
</style>

View File

@@ -7,6 +7,10 @@ import type { UploadFile } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
@@ -163,6 +167,7 @@ const initSortable = () => {
*/
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
// 创建上传中的文件项用于在UI中显示上传状态
const uploadingFile: FileItem = {
@@ -192,7 +197,7 @@ const customRequest = async (options: any) => {
// 4. 处理上传进度和错误
// 5. 返回上传后的文件URL
res = await uploadToOss({
file: file as File,
file: actualFile,
onProgress: (percent) => {
// 将OSS上传进度传递给组件
// percent范围0-100表示上传百分比
@@ -208,7 +213,7 @@ const customRequest = async (options: any) => {
// 2. 后端接收文件后上传到OSS
// 3. 返回上传后的文件URL
res = await uploadFile({
file: file,
file: actualFile,
});
}
@@ -292,6 +297,7 @@ onMounted(() => {
<div ref="uploadListRef">
<Upload
:file-list="fileList"
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:multiple="multiple"
:max-count="maxCount"

View File

@@ -6,6 +6,10 @@ import type { UploadFile } from 'ant-design-vue';
// 导入上传相关API和工具函数
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
@@ -97,6 +101,7 @@ const updateModelValue = () => {
*/
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
// 创建上传中的文件项用于在UI中显示上传状态
const uploadingFile: FileItem = {
@@ -126,7 +131,7 @@ const customRequest = async (options: any) => {
// 4. 处理上传进度和错误
// 5. 返回上传后的文件URL
res = await uploadToOss({
file: file as File,
file: actualFile,
onProgress: (percent) => {
// 将OSS上传进度传递给组件
// percent范围0-100表示上传百分比
@@ -142,7 +147,7 @@ const customRequest = async (options: any) => {
// 2. 后端接收文件后上传到OSS
// 3. 返回上传后的文件URL
res = await uploadFile({
file: file,
file: actualFile,
});
}
@@ -209,6 +214,7 @@ const showUploadButton = computed(() =>
<div>
<Upload
:file-list="fileList"
:before-upload="beforeImageUpload"
:custom-request="customRequest"
:multiple="multiple"
:max-count="maxCount"

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>