Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -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;
}