feat: 添加文件上传功能,集成 S3 上传,优化上传进度显示和错误处理

This commit is contained in:
2026-01-08 16:56:41 +07:00
parent 04a5beed89
commit edc81c5234
10 changed files with 278 additions and 48 deletions

View File

@@ -1,10 +1,18 @@
<script lang='ts' setup>
import { documentAttachOutline, trashOutline } from "ionicons/icons";
import type { UploadFetchOptions } from "@/utils/aws/s3";
import { toastController } from "@ionic/vue";
import { closeCircleOutline, documentAttachOutline, trashOutline } from "ionicons/icons";
import { merge } from "lodash-es";
import { uploadToS3 } from "@/utils/aws/s3";
interface FileItem {
name: string;
size: number;
url?: string;
fileId?: string; // S3 文件 ID
uploading?: boolean;
progress?: number;
error?: string;
}
const props = withDefaults(defineProps<{
@@ -15,6 +23,7 @@ const props = withDefaults(defineProps<{
multiple?: boolean;
maxSize?: number; // MB
maxFiles?: number;
fetchOptions: Partial<UploadFetchOptions>;
}>(), {
modelValue: () => [],
accept: "*/*",
@@ -30,19 +39,24 @@ const emit = defineEmits<{
const { t } = useI18n();
const fileInput = ref<HTMLInputElement>();
const files = ref<FileItem[]>([]);
const abortControllers = new Map<number, AbortController>();
// 初始化已有文件
watchEffect(() => {
if (props.modelValue && props.modelValue.length > 0) {
files.value = props.modelValue.map(url => ({
name: url.split("/").pop() || "file",
size: 0,
url,
}));
files.value = props.modelValue.map((fileId) => {
// 如果是完整 URL提取文件名
const name = fileId.includes("/") ? fileId.split("/").pop() || "file" : fileId;
return {
name,
size: 0,
fileId,
};
});
}
});
function handleFileSelect(event: Event) {
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = Array.from(input.files || []);
@@ -51,29 +65,71 @@ function handleFileSelect(event: Event) {
// 检查文件数量限制
if (files.value.length + selectedFiles.length > props.maxFiles) {
// TODO: 显示错误提示
console.warn(`最多只能上传 ${props.maxFiles} 个文件`);
await showToast(t("fileUpload.maxFilesError", { max: props.maxFiles }), "warning");
return;
}
selectedFiles.forEach((file) => {
// 批量上传文件
for (const file of selectedFiles) {
// 检查文件大小
if (file.size > props.maxSize * 1024 * 1024) {
console.warn(`文件 ${file.name} 超过 ${props.maxSize}MB 限制`);
return;
await showToast(t("fileUpload.fileSizeError", { name: file.name, max: props.maxSize }), "warning");
continue;
}
// 创建预览 URL
const url = URL.createObjectURL(file);
files.value.push({
// 添加到列表(显示上传中状态)
const fileItem: FileItem = {
name: file.name,
size: file.size,
url,
});
});
uploading: true,
progress: 0,
};
files.value.push(fileItem);
const index = files.value.length - 1;
// 更新值
updateValue();
// 创建 AbortController
const controller = new AbortController();
abortControllers.set(index, controller);
try {
// 上传到 S3
const options = merge({
fileName: file.name,
fileSize: file.size,
businessType: props.fetchOptions.businessType,
}, props.fetchOptions) as UploadFetchOptions;
const fileId = await uploadToS3(file, {
fetchOptions: options,
onProgress: (progress) => {
if (files.value[index]) {
files.value[index].progress = progress;
}
},
signal: controller.signal,
});
// 上传成功
if (files.value[index]) {
files.value[index].fileId = String(fileId);
files.value[index].uploading = false;
files.value[index].progress = 100;
abortControllers.delete(index);
updateValue();
}
}
catch (error: any) {
// 上传失败或取消
if (files.value[index]) {
if (error.message !== "上传已取消") {
files.value[index].error = error.message;
files.value[index].uploading = false;
await showToast(t("fileUpload.uploadError", { name: file.name }), "danger");
}
abortControllers.delete(index);
}
}
}
// 重置 input
if (fileInput.value) {
@@ -82,17 +138,31 @@ function handleFileSelect(event: Event) {
}
function removeFile(index: number) {
const file = files.value[index];
if (file.url && file.url.startsWith("blob:")) {
URL.revokeObjectURL(file.url);
// 如果正在上传,取消上传
const controller = abortControllers.get(index);
if (controller) {
controller.abort();
abortControllers.delete(index);
}
files.value.splice(index, 1);
updateValue();
}
function cancelUpload(index: number) {
const controller = abortControllers.get(index);
if (controller) {
controller.abort();
abortControllers.delete(index);
}
removeFile(index);
}
function updateValue() {
const urls = files.value.map(f => f.url || "").filter(Boolean);
emit("update:modelValue", urls);
const fileIds = files.value
.filter(f => f.fileId && !f.uploading && !f.error)
.map(f => f.fileId!);
emit("update:modelValue", fileIds);
}
function formatFileSize(bytes: number): string {
@@ -107,6 +177,22 @@ function formatFileSize(bytes: number): string {
function triggerFileInput() {
fileInput.value?.click();
}
async function showToast(message: string, color: "success" | "warning" | "danger" = "warning") {
const toast = await toastController.create({
message,
duration: 2000,
position: "top",
color,
});
await toast.present();
}
// 组件卸载时清理
onUnmounted(() => {
abortControllers.forEach(controller => controller.abort());
abortControllers.clear();
});
</script>
<template>
@@ -133,7 +219,7 @@ function triggerFileInput() {
@click="triggerFileInput"
>
<ion-icon slot="start" :icon="documentAttachOutline" />
{{ placeholder || t('common.uploadFile') }}
{{ placeholder || t('fileUpload.uploadFile') }}
</ion-button>
<!-- 文件列表 -->
@@ -145,19 +231,49 @@ function triggerFileInput() {
<div class="file-name">
{{ file.name }}
</div>
<div v-if="file.size" class="file-size">
<div v-if="file.size && !file.uploading" class="file-size">
{{ formatFileSize(file.size) }}
</div>
<!-- 上传进度 -->
<div v-if="file.uploading" class="upload-progress">
<ion-progress-bar :value="(file.progress || 0) / 100" color="primary" />
<span class="progress-text">{{ file.progress }}%</span>
</div>
<!-- 错误信息 -->
<div v-if="file.error" class="error-text">
{{ file.error }}
</div>
</div>
</div>
<ion-button fill="clear" size="small" color="danger" @click="removeFile(index)">
<ion-icon slot="icon-only" :icon="trashOutline" />
</ion-button>
<!-- 操作按钮 -->
<div class="file-actions">
<ion-button
v-if="file.uploading"
fill="clear"
size="small"
color="medium"
@click="cancelUpload(index)"
>
<ion-icon slot="icon-only" :icon="closeCircleOutline" />
</ion-button>
<ion-button
v-else
fill="clear"
size="small"
color="danger"
@click="removeFile(index)"
>
<ion-icon slot="icon-only" :icon="trashOutline" />
</ion-button>
</div>
</div>
</div>
<div v-if="files.length > 0" class="file-count">
{{ files.length }} / {{ maxFiles }} {{ t('common.files') }}
{{ files.filter(f => f.fileId).length }} / {{ maxFiles }} {{ t('fileUpload.files') }}
</div>
</div>
</template>
@@ -239,6 +355,38 @@ function triggerFileInput() {
margin-top: 2px;
}
.upload-progress {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.upload-progress ion-progress-bar {
flex: 1;
height: 4px;
}
.progress-text {
font-size: 11px;
color: var(--ion-color-primary);
font-weight: 500;
min-width: 35px;
text-align: right;
}
.error-text {
font-size: 12px;
color: var(--ion-color-danger);
margin-top: 2px;
}
.file-actions {
display: flex;
align-items: center;
flex-shrink: 0;
}
.file-count {
font-size: 12px;
color: var(--ion-color-medium);