refactor: 重构UI组件库

This commit is contained in:
2025-12-22 05:45:09 +07:00
parent de18ebf370
commit 66e2222c48
18 changed files with 38 additions and 54 deletions

View 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>