refactor: 重构UI组件库
This commit is contained in:
248
src/ui/file-upload/index.vue
Normal file
248
src/ui/file-upload/index.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script lang='ts' setup>
|
||||
import { documentAttachOutline, trashOutline } from "ionicons/icons";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
size: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: string[];
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
maxSize?: number; // MB
|
||||
maxFiles?: number;
|
||||
}>(), {
|
||||
modelValue: () => [],
|
||||
accept: "*/*",
|
||||
multiple: true,
|
||||
maxSize: 10,
|
||||
maxFiles: 5,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string[]): void;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const files = ref<FileItem[]>([]);
|
||||
|
||||
// 初始化已有文件
|
||||
watchEffect(() => {
|
||||
if (props.modelValue && props.modelValue.length > 0) {
|
||||
files.value = props.modelValue.map(url => ({
|
||||
name: url.split("/").pop() || "file",
|
||||
size: 0,
|
||||
url,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const selectedFiles = Array.from(input.files || []);
|
||||
|
||||
if (!selectedFiles.length)
|
||||
return;
|
||||
|
||||
// 检查文件数量限制
|
||||
if (files.value.length + selectedFiles.length > props.maxFiles) {
|
||||
// TODO: 显示错误提示
|
||||
console.warn(`最多只能上传 ${props.maxFiles} 个文件`);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFiles.forEach((file) => {
|
||||
// 检查文件大小
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
console.warn(`文件 ${file.name} 超过 ${props.maxSize}MB 限制`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建预览 URL
|
||||
const url = URL.createObjectURL(file);
|
||||
files.value.push({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
url,
|
||||
});
|
||||
});
|
||||
|
||||
// 更新值
|
||||
updateValue();
|
||||
|
||||
// 重置 input
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
const file = files.value[index];
|
||||
if (file.url && file.url.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(file.url);
|
||||
}
|
||||
files.value.splice(index, 1);
|
||||
updateValue();
|
||||
}
|
||||
|
||||
function updateValue() {
|
||||
const urls = files.value.map(f => f.url || "").filter(Boolean);
|
||||
emit("update:modelValue", urls);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0)
|
||||
return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<div v-if="label" class="file-upload-label">
|
||||
{{ label }}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
class="file-input-hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<ion-button
|
||||
v-if="files.length < maxFiles"
|
||||
expand="block"
|
||||
fill="outline"
|
||||
class="upload-button"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<ion-icon slot="start" :icon="documentAttachOutline" />
|
||||
{{ placeholder || t('common.uploadFile') }}
|
||||
</ion-button>
|
||||
|
||||
<!-- 文件列表 -->
|
||||
<div v-if="files.length > 0" class="file-list">
|
||||
<div v-for="(file, index) in files" :key="index" class="file-item">
|
||||
<div class="file-info">
|
||||
<ion-icon :icon="documentAttachOutline" class="file-icon" />
|
||||
<div class="file-details">
|
||||
<div class="file-name">
|
||||
{{ file.name }}
|
||||
</div>
|
||||
<div v-if="file.size" class="file-size">
|
||||
{{ formatFileSize(file.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ion-button fill="clear" size="small" color="danger" @click="removeFile(index)">
|
||||
<ion-icon slot="icon-only" :icon="trashOutline" />
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="files.length > 0" class="file-count">
|
||||
{{ files.length }} / {{ maxFiles }} {{ t('common.files') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-upload-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
color: var(--ion-text-color);
|
||||
}
|
||||
|
||||
.file-input-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
--border-style: dashed;
|
||||
--border-width: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: var(--ion-color-light);
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover {
|
||||
background: var(--ion-color-light-shade);
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 24px;
|
||||
color: var(--ion-color-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--ion-text-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 12px;
|
||||
color: var(--ion-color-medium);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
font-size: 12px;
|
||||
color: var(--ion-color-medium);
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user