1. 管理员管理模块拆分
2. 图片上传自动压缩(大于850kb) 3. 省市区选择组件重构,支持搜索
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
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