|
|
@@ -0,0 +1,315 @@
|
|
|
+<template>
|
|
|
+ <div class="file-uploader">
|
|
|
+ <!-- 文件拖拽区域 -->
|
|
|
+ <el-upload class="upload-area" drag action="#" :auto-upload="false" :on-change="handleFileChange"
|
|
|
+ :on-remove="handleFileRemove" :file-list="displayFileList" multiple>
|
|
|
+ <el-icon class="upload-icon"><upload-filled /></el-icon>
|
|
|
+ <div class="upload-text">
|
|
|
+ <em>拖拽文件到此处或</em>
|
|
|
+ <br />
|
|
|
+ <el-button type="primary" class="select-button">
|
|
|
+ 点击选择文件
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ </el-upload>
|
|
|
+
|
|
|
+ <!-- 文件类型选择 (仅测试,不直接用于上传API,后续可改) -->
|
|
|
+ <div class="type-selection">
|
|
|
+ <el-select v-model="selectedType" placeholder="请选择文件类型" class="type-select">
|
|
|
+ <el-option v-for="item in fileTypes" :key="item.value" :label="item.label" :value="item.value" />
|
|
|
+ </el-select>
|
|
|
+ <el-tooltip content="此类型选择仅用于应用逻辑,上传时将使用文件的实际MIME类型。" placement="top">
|
|
|
+ <el-icon style="margin-left: 8px; vertical-align: middle">
|
|
|
+ <QuestionFilled />
|
|
|
+ </el-icon>
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 已选文件列表 -->
|
|
|
+ <div v-if="fileList.length > 0" class="file-list">
|
|
|
+ <h3>待上传文件:</h3>
|
|
|
+ <el-scrollbar height="200px">
|
|
|
+ <el-table :data="fileList" style="width: 100%">
|
|
|
+ <el-table-column prop="name" label="文件名" />
|
|
|
+ <el-table-column prop="size" label="大小" width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ {{ formatFileSize(row.size || 0) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column label="状态/进度" width="180">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <div v-if="uploadStates[row.uid]">
|
|
|
+ <el-progress v-if="uploadStates[ossId]?.status === 'uploading'"
|
|
|
+ :percentage="uploadStates[ossId]?.progress || 0" :stroke-width="8" striped striped-flow
|
|
|
+ :duration="10" />
|
|
|
+ <el-tag v-else-if="uploadStates[ossId]?.status === 'success'" type="success">上传成功</el-tag>
|
|
|
+ <span v-else-if="uploadStates[ossId]?.status === 'error'">
|
|
|
+ <el-tag type="danger">上传失败</el-tag>
|
|
|
+ <el-tooltip v-if="uploadStates[ossId]?.errorMessage"
|
|
|
+ :content="uploadStates[ossId]?.errorMessage || '未知错误'" placement="top">
|
|
|
+ <el-icon style="margin-left: 4px;vertical-align: middle;cursor: help;">
|
|
|
+ <QuestionFilled />
|
|
|
+ </el-icon>
|
|
|
+ </el-tooltip>
|
|
|
+ </span>
|
|
|
+ <el-tag v-else-if="uploadStates[ossId]?.status === 'merging'" type="warning" effect="light">合并中...</el-tag>
|
|
|
+ <el-tag v-else-if="uploadStates[ossId]?.status === 'pending'" type="info">待上传</el-tag>
|
|
|
+ <el-tag v-else type="info" effect="plain">未知状态 ({{ uploadStates[ossId]?.status }})</el-tag>
|
|
|
+ </div>
|
|
|
+ <el-tag v-else type="info">待上传</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column fixed="right" label="操作" width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-button type="danger" link @click="handleManualRemove(row)"
|
|
|
+ :disabled="uploadStates[ossId]?.status === 'uploading'">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-scrollbar>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 上传按钮和历史记录 -->
|
|
|
+ <div class="upload-actions">
|
|
|
+ <el-button type="primary" :disabled="isUploading || fileList.length === 0" @click="handleUpload">上传</el-button>
|
|
|
+ <el-button @click="clearHistory" type="info" plain v-if="uploadHistory.length > 0">清空历史</el-button>
|
|
|
+ </div>
|
|
|
+ <div v-if="uploadHistory.length > 0" class="history-list">
|
|
|
+ <h3>上传历史记录 (最近 {{ MAX_HISTORY_ITEMS }} 条):</h3>
|
|
|
+ <el-scrollbar height="250px">
|
|
|
+ <el-table :data="uploadHistory" style="width: 100%">
|
|
|
+ <el-table-column prop="name" label="文件名" />
|
|
|
+ <el-table-column prop="size" label="大小" width="120">
|
|
|
+ <template #default="{ row }">
|
|
|
+ {{ formatFileSize(row.size || 0) }}
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="status" label="状态" width="100">
|
|
|
+ <template #default="{ row }">
|
|
|
+ <el-tag v-if="row.status === 'success'" type="success">成功</el-tag>
|
|
|
+ <el-tag v-else-if="row.status === 'error'" type="danger">失败</el-tag>
|
|
|
+ <el-tag v-else type="info">{{ row.status }}</el-tag>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ <el-table-column prop="uploadTime" label="上传时间" width="180" />
|
|
|
+ <el-table-column label="操作" width="80">
|
|
|
+ <template #default="{ $index }">
|
|
|
+ <el-button type="danger" link @click="removeHistoryItem($index)">删除</el-button>
|
|
|
+ </template>
|
|
|
+ </el-table-column>
|
|
|
+ </el-table>
|
|
|
+ </el-scrollbar>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, reactive, onMounted, watch, defineProps, defineEmits } from "vue";
|
|
|
+import { UploadFilled, QuestionFilled } from "@element-plus/icons-vue";
|
|
|
+
|
|
|
+// ================= 类型声明 =================
|
|
|
+interface UploadState {
|
|
|
+ status: string;
|
|
|
+ progress?: number;
|
|
|
+ errorMessage?: string;
|
|
|
+}
|
|
|
+interface UploadHistoryEntry {
|
|
|
+ name: string;
|
|
|
+ size: number;
|
|
|
+ status: string;
|
|
|
+ uploadTime: string;
|
|
|
+}
|
|
|
+
|
|
|
+// ================= props, emits, 变量声明 =================
|
|
|
+const props = defineProps({
|
|
|
+ modelValue: {
|
|
|
+ type: [String, Number, null],
|
|
|
+ default: null
|
|
|
+ }
|
|
|
+}); // only declared once
|
|
|
+const emit = defineEmits(["update:modelValue"]); // only declared once
|
|
|
+
|
|
|
+const fileTypes = ref([
|
|
|
+ { label: "图片", value: "image" },
|
|
|
+ { label: "视频", value: "video" },
|
|
|
+ { label: "文档", value: "document" },
|
|
|
+ { label: "其它", value: "other" },
|
|
|
+]);
|
|
|
+const selectedType = ref("");
|
|
|
+
|
|
|
+const fileList = ref<any[]>([]);
|
|
|
+const displayFileList = computed(() => fileList.value);
|
|
|
+
|
|
|
+// ossId 用于文件唯一标识
|
|
|
+const ossId = computed({
|
|
|
+ get: () => props.modelValue,
|
|
|
+ set: (val) => emit("update:modelValue", val)
|
|
|
+});
|
|
|
+
|
|
|
+const uploadStates = reactive<Record<string | number, UploadState>>({});
|
|
|
+const uploadHistory = ref<UploadHistoryEntry[]>([]);
|
|
|
+const MAX_HISTORY_ITEMS = 10;
|
|
|
+
|
|
|
+const isUploading = computed(() => {
|
|
|
+ return Object.values(uploadStates).some(
|
|
|
+ (state) => state.status === "uploading"
|
|
|
+ );
|
|
|
+});
|
|
|
+
|
|
|
+// ================= 监听器 =================
|
|
|
+watch(
|
|
|
+ () => ossId.value,
|
|
|
+ (newId) => {
|
|
|
+ // 可扩展:根据新的 ossId 加载状态/历史等
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+
|
|
|
+function formatFileSize(bytes: number): string {
|
|
|
+ if (bytes < 1024) return bytes + " B";
|
|
|
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
|
+ if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(1) + " MB";
|
|
|
+ return (bytes / 1024 / 1024 / 1024).toFixed(1) + " GB";
|
|
|
+}
|
|
|
+
|
|
|
+// TODO: 上传历史的持久化与回显逻辑可根据业务扩展
|
|
|
+function loadHistory() {
|
|
|
+ const raw = localStorage.getItem("smsb-upload-history");
|
|
|
+ if (raw) {
|
|
|
+ try {
|
|
|
+ uploadHistory.value = JSON.parse(raw);
|
|
|
+ } catch {
|
|
|
+ uploadHistory.value = [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+function saveHistory() {
|
|
|
+ localStorage.setItem("smsb-upload-history", JSON.stringify(uploadHistory.value.slice(0, MAX_HISTORY_ITEMS)));
|
|
|
+}
|
|
|
+// TODO: 可扩展:支持多文件上传/批量操作时的历史记录批量追加
|
|
|
+function addHistoryEntry(file: any, status: string) {
|
|
|
+ uploadHistory.value.unshift({
|
|
|
+ name: file.name,
|
|
|
+ size: file.size,
|
|
|
+ status,
|
|
|
+ uploadTime: new Date().toLocaleString(),
|
|
|
+ });
|
|
|
+ uploadHistory.value = uploadHistory.value.slice(0, MAX_HISTORY_ITEMS);
|
|
|
+ saveHistory();
|
|
|
+}
|
|
|
+function clearHistory() {
|
|
|
+ uploadHistory.value = [];
|
|
|
+ saveHistory();
|
|
|
+}
|
|
|
+function removeHistoryItem(index: number) {
|
|
|
+ uploadHistory.value.splice(index, 1);
|
|
|
+ saveHistory();
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ loadHistory();
|
|
|
+});
|
|
|
+
|
|
|
+// 上传成功逻辑示例(请根据实际上传API修改)
|
|
|
+// TODO: 对接实际上传API,处理后端返回、进度、错误、合并等流程
|
|
|
+function handleUploadSuccess(response: any) {
|
|
|
+ // 假设 response.data.ossId 为后端返回的文件唯一ID
|
|
|
+ if (response && response.data && response.data.ossId) {
|
|
|
+ ossId.value = response.data.ossId;
|
|
|
+ uploadStates[response.data.ossId] = { status: 'success', progress: 100 };
|
|
|
+ // 追加到历史
|
|
|
+ addHistoryEntry({ ...fileList.value[0], ossId: response.data.ossId }, 'success');
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function handleFileChange(uploadFile: any, uploadFiles: any[]) {
|
|
|
+ // TODO: 文件类型、大小等前端校验
|
|
|
+
|
|
|
+ // 避免重复添加
|
|
|
+ if (!fileList.value.some((f) => f.uid === uploadFile.uid)) {
|
|
|
+ fileList.value.push(uploadFile);
|
|
|
+ uploadStates[uploadFile.uid] = { status: "pending" };
|
|
|
+ }
|
|
|
+}
|
|
|
+function handleFileRemove(uploadFile: any, uploadFiles: any[]) {
|
|
|
+ fileList.value = fileList.value.filter((f) => f.uid !== uploadFile.uid);
|
|
|
+ delete uploadStates[uploadFile.uid];
|
|
|
+}
|
|
|
+function handleManualRemove(fileToRemove: any) {
|
|
|
+ fileList.value = fileList.value.filter((f) => f.uid !== fileToRemove.uid);
|
|
|
+ delete uploadStates[fileToRemove.uid];
|
|
|
+}
|
|
|
+
|
|
|
+// TODO: 对接实际上传API,完善错误处理与用户提示,支持多文件上传/批量操作
|
|
|
+async function handleUpload() {
|
|
|
+ for (const file of fileList.value) {
|
|
|
+ uploadStates[file.uid] = { status: "uploading", progress: 0 };
|
|
|
+ try {
|
|
|
+ // 这里只是模拟上传,实际应替换为后端API
|
|
|
+ await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
|
+ uploadStates[file.uid] = { status: "success", progress: 100 };
|
|
|
+ addHistoryEntry(file, "success");
|
|
|
+ } catch (e: any) {
|
|
|
+ uploadStates[file.uid] = { status: "error", errorMessage: e?.message || "上传失败" };
|
|
|
+ addHistoryEntry(file, "error");
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.file-uploader {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ justify-content: center;
|
|
|
+ box-sizing: border-box;
|
|
|
+ padding: 0 0 16px 0;
|
|
|
+}
|
|
|
+.upload-area, .file-list, .upload-actions, .history-list {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+
|
|
|
+.upload-area {
|
|
|
+ border: 2px dashed #d9d9d9;
|
|
|
+ border-radius: 8px;
|
|
|
+ padding: 24px 0;
|
|
|
+ margin-bottom: 16px;
|
|
|
+ background: #fafafa;
|
|
|
+ text-align: center;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+.upload-icon {
|
|
|
+ font-size: 40px;
|
|
|
+ color: #409eff;
|
|
|
+ margin-bottom: 10px;
|
|
|
+}
|
|
|
+.upload-text {
|
|
|
+ font-size: 16px;
|
|
|
+ color: #606266;
|
|
|
+}
|
|
|
+.select-button {
|
|
|
+ margin-top: 10px;
|
|
|
+}
|
|
|
+.type-selection {
|
|
|
+ margin-bottom: 12px;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.type-select {
|
|
|
+ width: 180px;
|
|
|
+}
|
|
|
+.file-list {
|
|
|
+ margin-bottom: 16px;
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+.upload-actions {
|
|
|
+ margin: 18px 0 12px 0;
|
|
|
+ display: flex;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+.history-list {
|
|
|
+ margin-top: 18px;
|
|
|
+}
|
|
|
+</style>
|