Build lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,717 @@
|
||||
<script setup lang="ts">
|
||||
import type { ImageSelectorEmits, ImageSelectorFile, ImageSelectorProps } from './types';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Check,
|
||||
CloudUploadOutlined,
|
||||
DeleteOutlined,
|
||||
EyeOutlined,
|
||||
FileImageOutlined,
|
||||
} from '@vben/icons';
|
||||
import { $t } from '@vben/locales';
|
||||
|
||||
import { ElButton, ElDialog, ElImageViewer, ElMessage, ElProgress } from 'element-plus';
|
||||
|
||||
import {
|
||||
getFilesInfo,
|
||||
getRecentImages,
|
||||
uploadFile as uploadFileApi,
|
||||
} from '#/api/core/file';
|
||||
import { getFileUrl } from '#/composables/useFileUrl';
|
||||
|
||||
defineOptions({
|
||||
name: 'ImageSelector',
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
interface Props extends ImageSelectorProps {}
|
||||
|
||||
type UploadingImage = ImageSelectorFile & {
|
||||
failed?: boolean;
|
||||
progress?: number;
|
||||
uploading?: boolean;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
multiple: false,
|
||||
placeholder: () => $t('ui.placeholder.select') || '?????',
|
||||
disabled: false,
|
||||
clearable: true,
|
||||
showImageInfo: true,
|
||||
maxSize: 10,
|
||||
maxWidth: 0,
|
||||
maxHeight: 0,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
accept: () => ['image/*'],
|
||||
gridColumns: 4,
|
||||
sortable: false,
|
||||
enableCrop: false,
|
||||
cropAspectRatio: undefined,
|
||||
cropShape: 'rect',
|
||||
aspectRatioPreset: '',
|
||||
aspectRatioWidth: 16,
|
||||
aspectRatioHeight: 9,
|
||||
aspectRatioMode: 'validate',
|
||||
aspectRatioTolerance: 0.02,
|
||||
size: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
source: 'form',
|
||||
});
|
||||
|
||||
const emit = defineEmits<ImageSelectorEmits>();
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const uploadInputRef = ref<HTMLInputElement>();
|
||||
const confirmedImages = ref<UploadingImage[]>([]);
|
||||
const uploadedImages = ref<UploadingImage[]>([]);
|
||||
const recentImages = ref<UploadingImage[]>([]);
|
||||
const selectedImages = ref<Set<string>>(new Set());
|
||||
const recentLoading = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const previewImageUrls = ref<string[]>([]);
|
||||
const previewInitialIndex = ref(0);
|
||||
|
||||
const acceptText = computed(() => props.accept?.join(',') || 'image/*');
|
||||
const triggerWidth = computed(() => props.width ?? props.size ?? undefined);
|
||||
const triggerHeight = computed(() => props.height ?? props.size ?? undefined);
|
||||
const triggerStyle = computed(() => ({
|
||||
'--image-selector-width': triggerWidth.value ? `${triggerWidth.value}px` : undefined,
|
||||
'--image-selector-height': triggerHeight.value ? `${triggerHeight.value}px` : undefined,
|
||||
}));
|
||||
|
||||
const selectedCount = computed(() => selectedImages.value.size);
|
||||
const hasSelection = computed(() => selectedCount.value > 0);
|
||||
const allImages = computed(() => [...uploadedImages.value, ...recentImages.value]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (value) => {
|
||||
const ids = normalizeModelValue(value);
|
||||
if (ids.length === 0) {
|
||||
confirmedImages.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
confirmedImages.value = ids.map((id) => ({ id, name: '', path: '', url: '' }));
|
||||
|
||||
try {
|
||||
const infos = await getFilesInfo(ids);
|
||||
const infoMap = new Map(infos.filter(Boolean).map((item: any) => [String(item.id), item]));
|
||||
confirmedImages.value = await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const info = infoMap.get(id) as any;
|
||||
return {
|
||||
id,
|
||||
name: info?.name || info?.file_name || '',
|
||||
path: info?.path || '',
|
||||
size: info?.file_size ?? info?.size,
|
||||
mime_type: info?.mime_type,
|
||||
sys_create_datetime: info?.sys_create_datetime,
|
||||
url: await resolveImageUrl(id),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
confirmedImages.value = await Promise.all(
|
||||
ids.map(async (id) => ({ id, name: '', path: '', url: await resolveImageUrl(id) })),
|
||||
);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function normalizeModelValue(value: Props['modelValue']): string[] {
|
||||
if (!value) return [];
|
||||
return (Array.isArray(value) ? value : [value]).filter(Boolean).map(String);
|
||||
}
|
||||
|
||||
async function resolveImageUrl(id: string) {
|
||||
try {
|
||||
return await getFileUrl(id);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
if (props.disabled) return;
|
||||
modalVisible.value = true;
|
||||
selectedImages.value = new Set(confirmedImages.value.map((item) => item.id));
|
||||
loadRecentImages();
|
||||
}
|
||||
|
||||
async function loadRecentImages() {
|
||||
recentLoading.value = true;
|
||||
try {
|
||||
const items = await getRecentImages(20);
|
||||
recentImages.value = await Promise.all(
|
||||
(items || []).map(async (item: any) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || item.file_name || '',
|
||||
path: item.path || '',
|
||||
size: item.file_size ?? item.size,
|
||||
mime_type: item.mime_type,
|
||||
sys_create_datetime: item.sys_create_datetime,
|
||||
url: await resolveImageUrl(String(item.id)),
|
||||
})),
|
||||
);
|
||||
} finally {
|
||||
recentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleImage(id: string) {
|
||||
const next = new Set(selectedImages.value);
|
||||
if (props.multiple) {
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
} else {
|
||||
next.clear();
|
||||
next.add(id);
|
||||
}
|
||||
selectedImages.value = next;
|
||||
}
|
||||
|
||||
function removeConfirmed(id: string) {
|
||||
const next = confirmedImages.value.filter((item) => item.id !== id);
|
||||
confirmedImages.value = next;
|
||||
emitValue(next.map((item) => item.id));
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
confirmedImages.value = [];
|
||||
emitValue([]);
|
||||
}
|
||||
|
||||
function emitValue(ids: string[]) {
|
||||
const value = props.multiple ? ids : (ids[0] ?? null);
|
||||
emit('update:modelValue', value);
|
||||
emit('change', value);
|
||||
}
|
||||
|
||||
function confirmSelection() {
|
||||
const selectedIds = [...selectedImages.value];
|
||||
const imageMap = new Map(allImages.value.map((item) => [item.id, item]));
|
||||
confirmedImages.value = selectedIds.map(
|
||||
(id) => imageMap.get(id) || confirmedImages.value.find((item) => item.id === id) || { id, name: '', path: '' },
|
||||
);
|
||||
emitValue(selectedIds);
|
||||
modalVisible.value = false;
|
||||
}
|
||||
|
||||
function handleUploadClick() {
|
||||
uploadInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
await uploadFiles(Array.from(input.files || []));
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
const validFiles = await filterValidFiles(files);
|
||||
if (validFiles.length === 0) return;
|
||||
|
||||
for (const file of validFiles) {
|
||||
const tempId = `local-${crypto.randomUUID?.() || Date.now()}`;
|
||||
const item: UploadingImage = {
|
||||
id: tempId,
|
||||
name: file.name,
|
||||
path: '',
|
||||
previewUrl: URL.createObjectURL(file),
|
||||
url: URL.createObjectURL(file),
|
||||
progress: 0,
|
||||
uploading: true,
|
||||
};
|
||||
uploadedImages.value.unshift(item);
|
||||
|
||||
try {
|
||||
const result: any = await uploadFileApi(file, {
|
||||
isPublic: false,
|
||||
source: props.source,
|
||||
onProgress: ({ percentage }) => {
|
||||
item.progress = percentage;
|
||||
},
|
||||
});
|
||||
const data = result?.data ?? result;
|
||||
const id = String(data?.id ?? data?.file_id ?? data?.fileId);
|
||||
if (!id || id === 'undefined') throw new Error('upload response missing id');
|
||||
item.id = id;
|
||||
item.path = data?.path || '';
|
||||
item.size = data?.file_size ?? data?.size ?? file.size;
|
||||
item.mime_type = data?.mime_type || file.type;
|
||||
item.url = await resolveImageUrl(id);
|
||||
item.uploading = false;
|
||||
item.progress = 100;
|
||||
selectedImages.value = props.multiple
|
||||
? new Set([...selectedImages.value, id])
|
||||
: new Set([id]);
|
||||
} catch (error: any) {
|
||||
item.uploading = false;
|
||||
item.failed = true;
|
||||
ElMessage.error(error?.message || '??????');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function filterValidFiles(files: File[]) {
|
||||
const result: File[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
ElMessage.warning(`${file.name} ??????`);
|
||||
continue;
|
||||
}
|
||||
if (props.maxSize && file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.warning(`${file.name} ?? ${props.maxSize}MB`);
|
||||
continue;
|
||||
}
|
||||
if (!(await validateImageSize(file))) continue;
|
||||
result.push(file);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateImageSize(file: File) {
|
||||
if (!props.maxWidth && !props.maxHeight && !props.minWidth && !props.minHeight) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const { width, height } = image;
|
||||
if (props.maxWidth && width > props.maxWidth) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.maxWidth}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (props.maxHeight && height > props.maxHeight) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.maxHeight}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (props.minWidth && width < props.minWidth) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.minWidth}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (props.minHeight && height < props.minHeight) {
|
||||
ElMessage.warning(`${file.name} ?????? ${props.minHeight}px`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(true);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(false);
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
if (!props.disabled) isDragging.value = true;
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
isDragging.value = false;
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
if (props.disabled) return;
|
||||
await uploadFiles(Array.from(event.dataTransfer?.files || []));
|
||||
}
|
||||
|
||||
function openPreview(images: UploadingImage[], index: number) {
|
||||
const urls = images.map((item) => item.url || item.previewUrl || '').filter(Boolean);
|
||||
if (urls.length === 0) return;
|
||||
previewImageUrls.value = urls;
|
||||
previewInitialIndex.value = index;
|
||||
previewVisible.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="image-selector" :style="triggerStyle">
|
||||
<div
|
||||
class="image-selector-trigger-grid"
|
||||
:class="{ disabled: disabled, 'has-size': !!(size || width || height) }"
|
||||
>
|
||||
<div
|
||||
v-for="(image, index) in confirmedImages"
|
||||
:key="image.id"
|
||||
class="image-grid-item image-preview"
|
||||
:class="{ 'is-circle': cropShape === 'circle' }"
|
||||
@click="openPreview(confirmedImages, index)"
|
||||
>
|
||||
<img v-if="image.url || image.previewUrl" class="preview-image" :src="image.url || image.previewUrl" />
|
||||
<FileImageOutlined v-else class="placeholder-icon" />
|
||||
<button
|
||||
v-if="clearable && !disabled"
|
||||
class="image-remove-btn"
|
||||
type="button"
|
||||
@click.stop="removeConfirmed(image.id)"
|
||||
>
|
||||
<DeleteOutlined class="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!disabled && (multiple || confirmedImages.length === 0)"
|
||||
class="image-grid-item upload-placeholder"
|
||||
:class="{ 'is-circle': cropShape === 'circle' }"
|
||||
type="button"
|
||||
@click="openModal"
|
||||
>
|
||||
<CloudUploadOutlined class="placeholder-icon" />
|
||||
<span class="placeholder-text">{{ placeholder }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="clearable && !disabled && confirmedImages.length > 0"
|
||||
class="clear-btn"
|
||||
type="button"
|
||||
@click="clearValue"
|
||||
>
|
||||
<DeleteOutlined class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ElDialog
|
||||
v-model="modalVisible"
|
||||
append-to-body
|
||||
class="image-selector-dialog"
|
||||
title="????"
|
||||
width="860px"
|
||||
>
|
||||
<div class="image-selector-layout" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
||||
<div class="upload-area" :class="{ 'is-dragging': isDragging }" @click="handleUploadClick">
|
||||
<CloudUploadOutlined class="upload-area-icon" />
|
||||
<div class="upload-area-title">????</div>
|
||||
<div class="upload-area-hint">???????????? {{ maxSize }}MB</div>
|
||||
<input
|
||||
ref="uploadInputRef"
|
||||
class="hidden-input"
|
||||
:accept="acceptText"
|
||||
:multiple="multiple"
|
||||
type="file"
|
||||
@change="handleFileInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="image-selector-content">
|
||||
<div class="toolbar">
|
||||
<span>??? {{ selectedCount }} ?</span>
|
||||
<ElButton size="small" :loading="recentLoading" @click="loadRecentImages">??</ElButton>
|
||||
</div>
|
||||
|
||||
<div class="image-grid">
|
||||
<div
|
||||
v-for="(image, index) in allImages"
|
||||
:key="image.id"
|
||||
class="image-card"
|
||||
:class="{ selected: selectedImages.has(image.id), uploading: image.uploading, failed: image.failed }"
|
||||
@click="!image.uploading && !image.failed && toggleImage(image.id)"
|
||||
>
|
||||
<div class="image-card-preview">
|
||||
<img v-if="image.url || image.previewUrl" class="image-card-img" :src="image.url || image.previewUrl" />
|
||||
<FileImageOutlined v-else class="image-card-empty" />
|
||||
<div v-if="selectedImages.has(image.id)" class="image-card-check">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div v-if="image.uploading" class="image-card-mask">
|
||||
<ElProgress type="circle" :percentage="image.progress || 0" :width="56" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-card-info">
|
||||
<span class="image-card-name">{{ image.name || image.id }}</span>
|
||||
<ElButton text size="small" @click.stop="openPreview(allImages, index)">
|
||||
<EyeOutlined class="h-4 w-4" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<ElButton @click="modalVisible = false">??</ElButton>
|
||||
<ElButton type="primary" :disabled="!hasSelection" @click="confirmSelection">??</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElImageViewer
|
||||
v-if="previewVisible"
|
||||
:initial-index="previewInitialIndex"
|
||||
:url-list="previewImageUrls"
|
||||
@close="previewVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.image-selector {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-selector-trigger-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||
gap: 10px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.image-selector-trigger-grid.has-size {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.image-selector-trigger-grid.disabled {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-grid-item {
|
||||
position: relative;
|
||||
width: var(--image-selector-width, 100%);
|
||||
height: var(--image-selector-height, auto);
|
||||
min-width: var(--image-selector-width, 96px);
|
||||
min-height: var(--image-selector-height, 76px);
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.image-grid-item.is-circle,
|
||||
.image-grid-item.is-circle .preview-image {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-placeholder:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
max-width: 90%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-image,
|
||||
.image-card-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.image-remove-btn,
|
||||
.clear-btn {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-remove-btn {
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.image-selector-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
display: flex;
|
||||
min-height: 320px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 24px;
|
||||
border: 1px dashed hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--muted) / 40%);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-area.is-dragging,
|
||||
.upload-area:hover {
|
||||
border-color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 8%);
|
||||
}
|
||||
|
||||
.upload-area-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.upload-area-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.upload-area-hint {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-selector-content {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.image-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||
gap: 12px;
|
||||
overflow: auto;
|
||||
max-height: 472px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.image-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-card.selected {
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary) / 18%);
|
||||
}
|
||||
|
||||
.image-card-preview {
|
||||
position: relative;
|
||||
aspect-ratio: 1 / 1;
|
||||
background: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.image-card-empty {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin: calc(50% - 16px);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.image-card-check {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.image-card-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
.image-card-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.image-card-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.image-selector-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as ImageSelector } from './image-selector.vue';
|
||||
export type * from './types';
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SelectProps } from 'element-plus';
|
||||
|
||||
export interface ImageSelectorFile {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
mime_type?: string;
|
||||
sys_create_datetime?: string;
|
||||
// 图片特有属性
|
||||
width?: number;
|
||||
height?: number;
|
||||
previewUrl?: string; // 本地预览URL
|
||||
}
|
||||
|
||||
// 继承 ElSelect 的所有属性,但排除我们自定义处理的属性
|
||||
export interface ImageSelectorProps extends Partial<
|
||||
Omit<SelectProps, 'modelValue' | 'onChange' | 'size'>
|
||||
> {
|
||||
modelValue?: string | string[];
|
||||
multiple?: boolean;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
/** 最大文件大小(MB) */
|
||||
maxSize?: number;
|
||||
/** 最大图片宽度(像素),0表示不限制 */
|
||||
maxWidth?: number;
|
||||
/** 最大图片高度(像素),0表示不限制 */
|
||||
maxHeight?: number;
|
||||
/** 最小图片宽度(像素),0表示不限制 */
|
||||
minWidth?: number;
|
||||
/** 最小图片高度(像素),0表示不限制 */
|
||||
minHeight?: number;
|
||||
/** 允许的图片格式 */
|
||||
accept?: string[];
|
||||
/** 网格列数 */
|
||||
gridColumns?: number;
|
||||
/** 是否显示图片信息 */
|
||||
showImageInfo?: boolean;
|
||||
/** 是否支持拖拽排序(仅多选时有效) */
|
||||
sortable?: boolean;
|
||||
/** 是否启用裁剪功能 */
|
||||
enableCrop?: boolean;
|
||||
/** 裁剪宽高比,例如 16/9, 4/3, 1 (正方形), undefined (自由裁剪) */
|
||||
cropAspectRatio?: number;
|
||||
/** 比例预设(表单设计器配置) */
|
||||
aspectRatioPreset?: '' | '1:1' | '16:9' | '3:4' | '4:3' | '9:16' | 'custom';
|
||||
/** 自定义比例宽 */
|
||||
aspectRatioWidth?: number;
|
||||
/** 自定义比例高 */
|
||||
aspectRatioHeight?: number;
|
||||
/** 比例限制方式:validate-上传校验 crop-裁剪强制 */
|
||||
aspectRatioMode?: 'crop' | 'validate';
|
||||
/** 比例容差(相对误差),默认 0.02 */
|
||||
aspectRatioTolerance?: number;
|
||||
/** 裁剪形状:'rect' - 矩形 | 'circle' - 圆形 */
|
||||
cropShape?: 'circle' | 'rect';
|
||||
/** 触发器大小(像素),用于控制图片预览和上传按钮的尺寸(正方形) */
|
||||
size?: number;
|
||||
/** 触发器宽度(像素),优先级高于 size */
|
||||
width?: number;
|
||||
/** 触发器高度(像素),优先级高于 size */
|
||||
height?: number;
|
||||
/** 上传文件的来源标识,用于文件自动归类,默认 'form' */
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface ImageSelectorEmits {
|
||||
(e: 'update:modelValue', value: string | string[] | null | undefined): void;
|
||||
(e: 'change', value: string | string[] | null | undefined): void;
|
||||
}
|
||||
Reference in New Issue
Block a user