feat: 添加文件上传功能,集成 S3 上传,优化上传进度显示和错误处理
This commit is contained in:
3
auto-imports.d.ts
vendored
3
auto-imports.d.ts
vendored
@@ -366,6 +366,9 @@ declare global {
|
|||||||
export type { HapticsOptions } from './src/composables/useVibrate'
|
export type { HapticsOptions } from './src/composables/useVibrate'
|
||||||
import('./src/composables/useVibrate')
|
import('./src/composables/useVibrate')
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
export type { UploadFetchOptions } from './src/utils/aws/s3'
|
||||||
|
import('./src/utils/aws/s3')
|
||||||
|
// @ts-ignore
|
||||||
export type { PageInstance, InputInstance, ModalInstance, FormInstance, ContentInstance } from './src/utils/ionic-helper'
|
export type { PageInstance, InputInstance, ModalInstance, FormInstance, ContentInstance } from './src/utils/ionic-helper'
|
||||||
import('./src/utils/ionic-helper')
|
import('./src/utils/ionic-helper')
|
||||||
}
|
}
|
||||||
|
|||||||
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -41,6 +41,7 @@ declare module 'vue' {
|
|||||||
IonModal: typeof import('@ionic/vue')['IonModal']
|
IonModal: typeof import('@ionic/vue')['IonModal']
|
||||||
IonNote: typeof import('@ionic/vue')['IonNote']
|
IonNote: typeof import('@ionic/vue')['IonNote']
|
||||||
IonPage: typeof import('@ionic/vue')['IonPage']
|
IonPage: typeof import('@ionic/vue')['IonPage']
|
||||||
|
IonProgressBar: typeof import('@ionic/vue')['IonProgressBar']
|
||||||
IonRadio: typeof import('@ionic/vue')['IonRadio']
|
IonRadio: typeof import('@ionic/vue')['IonRadio']
|
||||||
IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
|
IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
|
||||||
IonRange: typeof import('@ionic/vue')['IonRange']
|
IonRange: typeof import('@ionic/vue')['IonRange']
|
||||||
@@ -99,6 +100,7 @@ declare global {
|
|||||||
const IonModal: typeof import('@ionic/vue')['IonModal']
|
const IonModal: typeof import('@ionic/vue')['IonModal']
|
||||||
const IonNote: typeof import('@ionic/vue')['IonNote']
|
const IonNote: typeof import('@ionic/vue')['IonNote']
|
||||||
const IonPage: typeof import('@ionic/vue')['IonPage']
|
const IonPage: typeof import('@ionic/vue')['IonPage']
|
||||||
|
const IonProgressBar: typeof import('@ionic/vue')['IonProgressBar']
|
||||||
const IonRadio: typeof import('@ionic/vue')['IonRadio']
|
const IonRadio: typeof import('@ionic/vue')['IonRadio']
|
||||||
const IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
|
const IonRadioGroup: typeof import('@ionic/vue')['IonRadioGroup']
|
||||||
const IonRange: typeof import('@ionic/vue')['IonRange']
|
const IonRange: typeof import('@ionic/vue')['IonRange']
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
"@elysiajs/eden": "^1.4.5",
|
"@elysiajs/eden": "^1.4.5",
|
||||||
"@ionic/vue": "^8.7.11",
|
"@ionic/vue": "^8.7.11",
|
||||||
"@ionic/vue-router": "^8.7.11",
|
"@ionic/vue-router": "^8.7.11",
|
||||||
"@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz",
|
"@riwa/api-types": "http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@vee-validate/yup": "^4.15.1",
|
"@vee-validate/yup": "^4.15.1",
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -57,8 +57,8 @@ importers:
|
|||||||
specifier: ^8.7.11
|
specifier: ^8.7.11
|
||||||
version: 8.7.11(@stencil/core@4.39.0)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
|
version: 8.7.11(@stencil/core@4.39.0)(vue-router@4.6.3(vue@3.5.25(typescript@5.9.3)))(vue@3.5.25(typescript@5.9.3))
|
||||||
'@riwa/api-types':
|
'@riwa/api-types':
|
||||||
specifier: http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz
|
specifier: http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz
|
||||||
version: '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))'
|
version: '@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))'
|
||||||
'@tailwindcss/vite':
|
'@tailwindcss/vite':
|
||||||
specifier: ^4.1.18
|
specifier: ^4.1.18
|
||||||
version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
|
version: 4.1.18(vite@7.2.7(@types/node@24.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(yaml@2.8.2))
|
||||||
@@ -2772,9 +2772,9 @@ packages:
|
|||||||
'@remirror/core-constants@3.0.0':
|
'@remirror/core-constants@3.0.0':
|
||||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||||
|
|
||||||
'@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz':
|
'@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz':
|
||||||
resolution: {tarball: http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz}
|
resolution: {tarball: http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz}
|
||||||
version: 0.0.92
|
version: 0.0.100
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@elysiajs/eden': ^1.4.5
|
'@elysiajs/eden': ^1.4.5
|
||||||
|
|
||||||
@@ -12099,7 +12099,7 @@ snapshots:
|
|||||||
|
|
||||||
'@remirror/core-constants@3.0.0': {}
|
'@remirror/core-constants@3.0.0': {}
|
||||||
|
|
||||||
'@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.92.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
|
'@riwa/eden@http://192.168.1.7:9527/api/riwa-eden-0.0.100.tgz(@elysiajs/eden@1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3)))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@elysiajs/eden': 1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3))
|
'@elysiajs/eden': 1.4.5(elysia@1.4.18(@sinclair/typebox@0.34.41)(exact-mirror@0.2.5(@sinclair/typebox@0.34.41))(file-type@21.1.1)(openapi-types@12.1.3)(typescript@5.9.3))
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CapacitorBarcodeScanner, CapacitorBarcodeScannerCameraDirection, CapacitorBarcodeScannerTypeHint } from "@capacitor/barcode-scanner";
|
import { CapacitorBarcodeScanner, CapacitorBarcodeScannerCameraDirection, CapacitorBarcodeScannerScanOrientation, CapacitorBarcodeScannerTypeHint } from "@capacitor/barcode-scanner";
|
||||||
import { toastController } from "@ionic/vue";
|
import { toastController } from "@ionic/vue";
|
||||||
|
|
||||||
export interface QRScanResult {
|
export interface QRScanResult {
|
||||||
@@ -24,6 +24,7 @@ export function useQRScanner() {
|
|||||||
hint: CapacitorBarcodeScannerTypeHint.QR_CODE,
|
hint: CapacitorBarcodeScannerTypeHint.QR_CODE,
|
||||||
scanInstructions: options?.title || t("scanner.hint"),
|
scanInstructions: options?.title || t("scanner.hint"),
|
||||||
cameraDirection: CapacitorBarcodeScannerCameraDirection.BACK,
|
cameraDirection: CapacitorBarcodeScannerCameraDirection.BACK,
|
||||||
|
scanOrientation: CapacitorBarcodeScannerScanOrientation.PORTRAIT,
|
||||||
});
|
});
|
||||||
|
|
||||||
vibrate();
|
vibrate();
|
||||||
|
|||||||
@@ -385,6 +385,13 @@
|
|||||||
"yesterday": "Yesterday",
|
"yesterday": "Yesterday",
|
||||||
"items": "items"
|
"items": "items"
|
||||||
},
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"uploadFile": "Upload File",
|
||||||
|
"files": "files",
|
||||||
|
"maxFilesError": "Maximum {max} files allowed",
|
||||||
|
"fileSizeError": "File {name} exceeds {max}MB limit",
|
||||||
|
"uploadError": "File {name} upload failed"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": {
|
"login": {
|
||||||
"title": "Log in",
|
"title": "Log in",
|
||||||
|
|||||||
@@ -391,6 +391,13 @@
|
|||||||
"yesterday": "昨天",
|
"yesterday": "昨天",
|
||||||
"items": "项"
|
"items": "项"
|
||||||
},
|
},
|
||||||
|
"fileUpload": {
|
||||||
|
"uploadFile": "上传文件",
|
||||||
|
"files": "个文件",
|
||||||
|
"maxFilesError": "最多只能上传 {max} 个文件",
|
||||||
|
"fileSizeError": "文件 {name} 超过 {max}MB 限制",
|
||||||
|
"uploadError": "文件 {name} 上传失败"
|
||||||
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": {
|
"login": {
|
||||||
"title": "登录",
|
"title": "登录",
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<script lang='ts' setup>
|
<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 {
|
interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
size: number;
|
size: number;
|
||||||
url?: string;
|
url?: string;
|
||||||
|
fileId?: string; // S3 文件 ID
|
||||||
|
uploading?: boolean;
|
||||||
|
progress?: number;
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -15,6 +23,7 @@ const props = withDefaults(defineProps<{
|
|||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
maxSize?: number; // MB
|
maxSize?: number; // MB
|
||||||
maxFiles?: number;
|
maxFiles?: number;
|
||||||
|
fetchOptions: Partial<UploadFetchOptions>;
|
||||||
}>(), {
|
}>(), {
|
||||||
modelValue: () => [],
|
modelValue: () => [],
|
||||||
accept: "*/*",
|
accept: "*/*",
|
||||||
@@ -30,19 +39,24 @@ const emit = defineEmits<{
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const fileInput = ref<HTMLInputElement>();
|
const fileInput = ref<HTMLInputElement>();
|
||||||
const files = ref<FileItem[]>([]);
|
const files = ref<FileItem[]>([]);
|
||||||
|
const abortControllers = new Map<number, AbortController>();
|
||||||
|
|
||||||
// 初始化已有文件
|
// 初始化已有文件
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (props.modelValue && props.modelValue.length > 0) {
|
if (props.modelValue && props.modelValue.length > 0) {
|
||||||
files.value = props.modelValue.map(url => ({
|
files.value = props.modelValue.map((fileId) => {
|
||||||
name: url.split("/").pop() || "file",
|
// 如果是完整 URL,提取文件名
|
||||||
size: 0,
|
const name = fileId.includes("/") ? fileId.split("/").pop() || "file" : fileId;
|
||||||
url,
|
return {
|
||||||
}));
|
name,
|
||||||
|
size: 0,
|
||||||
|
fileId,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleFileSelect(event: Event) {
|
async function handleFileSelect(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const selectedFiles = Array.from(input.files || []);
|
const selectedFiles = Array.from(input.files || []);
|
||||||
|
|
||||||
@@ -51,29 +65,71 @@ function handleFileSelect(event: Event) {
|
|||||||
|
|
||||||
// 检查文件数量限制
|
// 检查文件数量限制
|
||||||
if (files.value.length + selectedFiles.length > props.maxFiles) {
|
if (files.value.length + selectedFiles.length > props.maxFiles) {
|
||||||
// TODO: 显示错误提示
|
await showToast(t("fileUpload.maxFilesError", { max: props.maxFiles }), "warning");
|
||||||
console.warn(`最多只能上传 ${props.maxFiles} 个文件`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedFiles.forEach((file) => {
|
// 批量上传文件
|
||||||
|
for (const file of selectedFiles) {
|
||||||
// 检查文件大小
|
// 检查文件大小
|
||||||
if (file.size > props.maxSize * 1024 * 1024) {
|
if (file.size > props.maxSize * 1024 * 1024) {
|
||||||
console.warn(`文件 ${file.name} 超过 ${props.maxSize}MB 限制`);
|
await showToast(t("fileUpload.fileSizeError", { name: file.name, max: props.maxSize }), "warning");
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建预览 URL
|
// 添加到列表(显示上传中状态)
|
||||||
const url = URL.createObjectURL(file);
|
const fileItem: FileItem = {
|
||||||
files.value.push({
|
|
||||||
name: file.name,
|
name: file.name,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
url,
|
uploading: true,
|
||||||
});
|
progress: 0,
|
||||||
});
|
};
|
||||||
|
files.value.push(fileItem);
|
||||||
|
const index = files.value.length - 1;
|
||||||
|
|
||||||
// 更新值
|
// 创建 AbortController
|
||||||
updateValue();
|
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
|
// 重置 input
|
||||||
if (fileInput.value) {
|
if (fileInput.value) {
|
||||||
@@ -82,17 +138,31 @@ function handleFileSelect(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(index: number) {
|
function removeFile(index: number) {
|
||||||
const file = files.value[index];
|
// 如果正在上传,取消上传
|
||||||
if (file.url && file.url.startsWith("blob:")) {
|
const controller = abortControllers.get(index);
|
||||||
URL.revokeObjectURL(file.url);
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
abortControllers.delete(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
files.value.splice(index, 1);
|
files.value.splice(index, 1);
|
||||||
updateValue();
|
updateValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelUpload(index: number) {
|
||||||
|
const controller = abortControllers.get(index);
|
||||||
|
if (controller) {
|
||||||
|
controller.abort();
|
||||||
|
abortControllers.delete(index);
|
||||||
|
}
|
||||||
|
removeFile(index);
|
||||||
|
}
|
||||||
|
|
||||||
function updateValue() {
|
function updateValue() {
|
||||||
const urls = files.value.map(f => f.url || "").filter(Boolean);
|
const fileIds = files.value
|
||||||
emit("update:modelValue", urls);
|
.filter(f => f.fileId && !f.uploading && !f.error)
|
||||||
|
.map(f => f.fileId!);
|
||||||
|
emit("update:modelValue", fileIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatFileSize(bytes: number): string {
|
function formatFileSize(bytes: number): string {
|
||||||
@@ -107,6 +177,22 @@ function formatFileSize(bytes: number): string {
|
|||||||
function triggerFileInput() {
|
function triggerFileInput() {
|
||||||
fileInput.value?.click();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -133,7 +219,7 @@ function triggerFileInput() {
|
|||||||
@click="triggerFileInput"
|
@click="triggerFileInput"
|
||||||
>
|
>
|
||||||
<ion-icon slot="start" :icon="documentAttachOutline" />
|
<ion-icon slot="start" :icon="documentAttachOutline" />
|
||||||
{{ placeholder || t('common.uploadFile') }}
|
{{ placeholder || t('fileUpload.uploadFile') }}
|
||||||
</ion-button>
|
</ion-button>
|
||||||
|
|
||||||
<!-- 文件列表 -->
|
<!-- 文件列表 -->
|
||||||
@@ -145,19 +231,49 @@ function triggerFileInput() {
|
|||||||
<div class="file-name">
|
<div class="file-name">
|
||||||
{{ file.name }}
|
{{ file.name }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="file.size" class="file-size">
|
<div v-if="file.size && !file.uploading" class="file-size">
|
||||||
{{ formatFileSize(file.size) }}
|
{{ formatFileSize(file.size) }}
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div v-if="files.length > 0" class="file-count">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -239,6 +355,38 @@ function triggerFileInput() {
|
|||||||
margin-top: 2px;
|
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 {
|
.file-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ion-color-medium);
|
color: var(--ion-color-medium);
|
||||||
|
|||||||
@@ -1,19 +1,77 @@
|
|||||||
|
import type { TreatyBody } from "@/api/types";
|
||||||
import { client, safeClient } from "@/api";
|
import { client, safeClient } from "@/api";
|
||||||
|
|
||||||
interface PresignedUrlResponse {
|
|
||||||
uploadUrl: string;
|
|
||||||
fileUrl: string;
|
|
||||||
key: string;
|
|
||||||
expiresIn: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadOptions {
|
interface UploadOptions {
|
||||||
|
fetchOptions: UploadFetchOptions;
|
||||||
onProgress?: (progress: number) => void;
|
onProgress?: (progress: number) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadToS3(file: File, options: UploadOptions = {}) {
|
export type UploadFetchOptions = TreatyBody<typeof client.api.file_storage.upload_url.post>;
|
||||||
const { onProgress, signal } = options || {};
|
|
||||||
|
export async function uploadToS3(file: File, options: UploadOptions) {
|
||||||
|
const { onProgress, signal, fetchOptions } = options;
|
||||||
|
|
||||||
// 1. 获取预签名 URL
|
// 1. 获取预签名 URL
|
||||||
|
const { data, error } = await safeClient(client.api.file_storage.upload_url.post({
|
||||||
|
...fetchOptions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (error.value || !data.value) {
|
||||||
|
throw new Error("获取上传 URL 失败");
|
||||||
|
}
|
||||||
|
const { fileId, uploadUrl, method, headers } = toRefs(data.value);
|
||||||
|
|
||||||
|
// 2. 上传文件到 S3
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
// 监听上传进度
|
||||||
|
xhr.upload.addEventListener("progress", (e: ProgressEvent) => {
|
||||||
|
if (e.lengthComputable && onProgress) {
|
||||||
|
const progress = Math.round((e.loaded / e.total) * 100);
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传成功
|
||||||
|
xhr.addEventListener("load", () => {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve(fileId.value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
reject(new Error(`上传失败: ${xhr.status} ${xhr.statusText}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 网络错误
|
||||||
|
xhr.addEventListener("error", () => {
|
||||||
|
reject(new Error("网络错误,上传失败"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传取消
|
||||||
|
xhr.addEventListener("abort", () => {
|
||||||
|
reject(new Error("上传已取消"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 超时
|
||||||
|
xhr.addEventListener("timeout", () => {
|
||||||
|
reject(new Error("上传超时"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 支持外部取消
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", () => {
|
||||||
|
xhr.abort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始上传
|
||||||
|
xhr.open(method.value, uploadUrl.value, true);
|
||||||
|
for (const [key, value] of Object.entries(headers.value)) {
|
||||||
|
xhr.setRequestHeader(key, value);
|
||||||
|
}
|
||||||
|
xhr.timeout = 300000; // 5分钟超时
|
||||||
|
xhr.send(file);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,11 +128,15 @@ function handleSubmit(values: GenericObject) {
|
|||||||
<Field v-slot="{ field }" name="proofDocuments">
|
<Field v-slot="{ field }" name="proofDocuments">
|
||||||
<ui-file-upload
|
<ui-file-upload
|
||||||
v-bind="field"
|
v-bind="field"
|
||||||
|
:fetch-options="{
|
||||||
|
businessType: 'rwa_proof',
|
||||||
|
}"
|
||||||
:label="t('asset.issue.apply.assetProof')"
|
:label="t('asset.issue.apply.assetProof')"
|
||||||
:placeholder="t('asset.issue.apply.uploadAssetProof')"
|
:placeholder="t('asset.issue.apply.uploadAssetProof')"
|
||||||
:max-files="5"
|
:max-files="5"
|
||||||
:max-size="10"
|
:max-size="10"
|
||||||
accept="application/pdf,image/*,.doc,.docx"
|
accept="application/pdf,image/*,.doc,.docx"
|
||||||
|
@update:model-value="field.onChange($event.join(','))"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<ErrorMessage name="proofDocuments" />
|
<ErrorMessage name="proofDocuments" />
|
||||||
|
|||||||
Reference in New Issue
Block a user