feat: 优化上传页布局和拖拽上传
This commit is contained in:
@@ -1,5 +1,13 @@
|
|||||||
# 项目上下文记录
|
# 项目上下文记录
|
||||||
|
|
||||||
|
## 2026-05-15:新版上传页对齐旧版布局并恢复拖拽上传
|
||||||
|
|
||||||
|
- `frontend/src/components/FileWorkflow.vue` 的上传页内容区已按旧版 `frontend_old/index.html` 的上传结构重排,保留新版侧边导航和顶部标题栏,只对齐页面主体中的上传区、文件列表、上传进度和处理日志布局。
|
||||||
|
- 上传区支持点击选择文件,也支持把文件或文件夹直接拖拽到页面;目录拖拽使用 `DataTransferItem.webkitGetAsEntry()` 递归读取,`readEntries()` 会循环读取完整批次,兼容 Chrome 目录拖拽一次只返回部分条目的情况。
|
||||||
|
- 上传文件统一过滤 `.zip`、`.xlsx`、`.xls`、`.csv`,路径会归一化为 `/`,并按相对路径去重;文件状态显示为等待上传、上传中、已完成或失败。
|
||||||
|
- 旧版对照端口仍为 `9082`,新版端口仍为 `9081`;已用 headless Chrome 对比 `9082/` 与 `9081/upload`,新版上传框尺寸、虚线边框、圆角和文案与旧版基本一致,并通过模拟拖拽 `drag-test.csv` 验证文件列表能正常出现。
|
||||||
|
- 构建验证命令为 `cd frontend && npm run build`;当前只存在 Vite 大 chunk 体积警告,构建本身通过,`frontend/dist/` 仍按 `.gitignore` 作为本地构建产物处理。
|
||||||
|
|
||||||
## 2026-05-15:拆分新旧前端访问端口
|
## 2026-05-15:拆分新旧前端访问端口
|
||||||
|
|
||||||
- `python -m app.main` 现在由同一个 FastAPI 进程同时监听 `9081` 和 `9082`,共享后端运行状态、任务锁和 API。
|
- `python -m app.main` 现在由同一个 FastAPI 进程同时监听 `9081` 和 `9082`,共享后端运行状态、任务锁和 API。
|
||||||
|
|||||||
@@ -1,78 +1,159 @@
|
|||||||
<template>
|
<template>
|
||||||
<n-grid :cols="12" :x-gap="16" :y-gap="16" responsive="screen">
|
<div class="upload-page-body">
|
||||||
<n-gi :span="12" :m="5">
|
<section
|
||||||
<n-card title="文件上传" size="small">
|
class="upload-zone"
|
||||||
<n-space vertical size="large">
|
:class="{ dragover: isDragging, disabled: working }"
|
||||||
<n-space>
|
role="button"
|
||||||
<n-button @click="fileInput?.click()">
|
tabindex="0"
|
||||||
<template #icon><n-icon><DocumentAttachOutline /></n-icon></template>
|
:aria-disabled="working"
|
||||||
选择文件
|
@click="openFilePicker"
|
||||||
</n-button>
|
@keydown.enter.prevent="openFilePicker"
|
||||||
<n-button @click="folderInput?.click()">
|
@keydown.space.prevent="openFilePicker"
|
||||||
<template #icon><n-icon><FolderOpenOutline /></n-icon></template>
|
@dragenter.prevent="onDragEnter"
|
||||||
选择目录
|
@dragover.prevent="onDragOver"
|
||||||
</n-button>
|
@dragleave="onDragLeave"
|
||||||
<n-button tertiary :disabled="files.length === 0 || working" @click="clearFiles">
|
@drop="onDrop"
|
||||||
|
>
|
||||||
|
<div class="upload-icon">
|
||||||
|
<n-icon size="24"><FolderOpenOutline /></n-icon>
|
||||||
|
</div>
|
||||||
|
<h3>拖拽文件夹或文件到这里</h3>
|
||||||
|
<p>支持 .zip, .xlsx, .xls, .csv 格式</p>
|
||||||
|
<p class="upload-hint">或者点击选择文件</p>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
class="hidden-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".zip,.xlsx,.xls,.csv"
|
||||||
|
@change="pickFiles"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref="folderInput"
|
||||||
|
class="hidden-input"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
webkitdirectory
|
||||||
|
directory
|
||||||
|
@change="pickFiles"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="files.length > 0" class="file-list">
|
||||||
|
<div class="file-list-header">
|
||||||
|
<h4>已选择文件</h4>
|
||||||
|
<n-button size="small" text :disabled="working" @click="clearFiles">
|
||||||
|
<template #icon><n-icon><TrashOutline /></n-icon></template>
|
||||||
清空
|
清空
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-space>
|
</div>
|
||||||
|
|
||||||
<input ref="fileInput" class="hidden-input" type="file" multiple @change="pickFiles" />
|
<div class="file-list-footer">
|
||||||
<input ref="folderInput" class="hidden-input" type="file" multiple @change="pickFiles" />
|
<span id="fileCount">{{ files.length }} 个文件</span>
|
||||||
|
<n-button type="primary" :disabled="files.length === 0" :loading="working" @click="uploadAndStart">
|
||||||
<n-statistic label="待上传文件" :value="files.length" />
|
<template #icon><n-icon><CloudUploadOutline /></n-icon></template>
|
||||||
<n-progress v-if="uploadProgress > 0" type="line" :percentage="uploadProgress" />
|
|
||||||
|
|
||||||
<n-scrollbar class="file-list">
|
|
||||||
<n-empty v-if="files.length === 0" description="未选择文件" />
|
|
||||||
<n-list v-else bordered>
|
|
||||||
<n-list-item v-for="item in files" :key="item.path">
|
|
||||||
<n-thing :title="item.path" :description="formatBytes(item.file.size)" />
|
|
||||||
</n-list-item>
|
|
||||||
</n-list>
|
|
||||||
</n-scrollbar>
|
|
||||||
|
|
||||||
<n-button type="primary" block :disabled="files.length === 0" :loading="working" @click="uploadAndStart">
|
|
||||||
上传并处理
|
上传并处理
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-space>
|
</div>
|
||||||
</n-card>
|
|
||||||
</n-gi>
|
|
||||||
|
|
||||||
<n-gi :span="12" :m="7">
|
<div v-if="showUploadProgress" class="total-progress">
|
||||||
<n-card title="处理状态" size="small">
|
<div class="total-progress-header">
|
||||||
<template #header-extra>
|
<span class="label">上传进度</span>
|
||||||
<n-space>
|
<span class="percent">{{ uploadProgress }}%</span>
|
||||||
<n-tag v-if="taskStatus" :type="statusType">{{ taskStatus.status }}</n-tag>
|
</div>
|
||||||
<n-button size="small" tertiary @click="checkActiveTask">刷新</n-button>
|
<div class="total-progress-bar">
|
||||||
</n-space>
|
<div class="total-progress-bar-inner" :style="{ width: `${uploadProgress}%` }"></div>
|
||||||
</template>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<n-space vertical>
|
<div v-if="showUploadProgress" class="upload-stats">
|
||||||
<n-alert v-if="activeTask?.has_active" type="info" :bordered="false">
|
<div class="upload-stats-item">
|
||||||
当前任务:{{ activeTask.task_id }} / {{ activeTask.stage }}
|
<span>总计:</span>
|
||||||
|
<span class="count">{{ files.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="upload-stats-item success">
|
||||||
|
<span>已完成:</span>
|
||||||
|
<span class="count">{{ uploadedCount }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="upload-stats-item error">
|
||||||
|
<span>失败:</span>
|
||||||
|
<span class="count">{{ errorCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="file-items">
|
||||||
|
<div v-for="item in files" :key="item.path" class="file-item" :class="item.status">
|
||||||
|
<span class="file-item-icon">
|
||||||
|
<n-icon><DocumentTextOutline /></n-icon>
|
||||||
|
</span>
|
||||||
|
<span class="file-item-name" :title="item.path">{{ item.path }}</span>
|
||||||
|
<span class="file-item-size">{{ formatBytes(item.file.size) }}</span>
|
||||||
|
<span class="file-item-status" :class="item.status">
|
||||||
|
{{ fileStatusText(item) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="item.status !== 'pending'" class="file-progress">
|
||||||
|
<div class="file-progress-bar" :style="{ width: `${item.progress}%` }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="processVisible" class="process-section">
|
||||||
|
<div class="upload-process-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-header-left">
|
||||||
|
<span class="card-title">处理进度</span>
|
||||||
|
<span class="process-status" :class="statusClass">{{ processStatusText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-header-right">
|
||||||
|
<n-button size="small" secondary @click="checkActiveTask">
|
||||||
|
<template #icon><n-icon><RefreshOutline /></n-icon></template>
|
||||||
|
刷新
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<n-alert v-if="activeTask?.has_active" type="info" :bordered="false" class="task-alert">
|
||||||
|
当前任务:{{ activeTask.task_id }} / {{ activeTask.stage || 'processing' }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-alert v-if="taskStatus?.error" type="error" :bordered="false">
|
<n-alert v-if="taskStatus?.error" type="error" :bordered="false" class="task-alert">
|
||||||
{{ taskStatus.error }}
|
{{ taskStatus.error }}
|
||||||
</n-alert>
|
</n-alert>
|
||||||
<n-log :log="logText" language="text" trim class="log-panel" />
|
<div class="log-container">
|
||||||
</n-space>
|
<pre class="log-content">{{ logText }}</pre>
|
||||||
</n-card>
|
</div>
|
||||||
</n-gi>
|
</div>
|
||||||
</n-grid>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
import { useMessage } from 'naive-ui';
|
import { useMessage } from 'naive-ui';
|
||||||
import { DocumentAttachOutline, FolderOpenOutline } from '@vicons/ionicons5';
|
import {
|
||||||
|
CloudUploadOutline,
|
||||||
|
DocumentTextOutline,
|
||||||
|
FolderOpenOutline,
|
||||||
|
RefreshOutline,
|
||||||
|
TrashOutline
|
||||||
|
} from '@vicons/ionicons5';
|
||||||
|
|
||||||
import { apiGet, apiPost, upload } from '../api/client';
|
import { apiGet, apiPost, upload } from '../api/client';
|
||||||
import type { ActiveTask, TaskStatus } from '../types';
|
import type { ActiveTask, TaskStatus } from '../types';
|
||||||
|
|
||||||
|
type FileStatus = 'pending' | 'uploading' | 'uploaded' | 'error';
|
||||||
|
|
||||||
interface PickedFile {
|
interface PickedFile {
|
||||||
file: File;
|
file: File;
|
||||||
path: string;
|
path: string;
|
||||||
|
status: FileStatus;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DroppedFile {
|
||||||
|
file: File;
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadResponse {
|
interface UploadResponse {
|
||||||
@@ -81,21 +162,33 @@ interface UploadResponse {
|
|||||||
file_count: number;
|
file_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validExtensions = new Set(['.zip', '.xlsx', '.xls', '.csv']);
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const folderInput = ref<HTMLInputElement | null>(null);
|
const folderInput = ref<HTMLInputElement | null>(null);
|
||||||
const files = ref<PickedFile[]>([]);
|
const files = ref<PickedFile[]>([]);
|
||||||
const uploadProgress = ref(0);
|
const uploadProgress = ref(0);
|
||||||
const working = ref(false);
|
const working = ref(false);
|
||||||
|
const isDragging = ref(false);
|
||||||
const taskStatus = ref<TaskStatus | null>(null);
|
const taskStatus = ref<TaskStatus | null>(null);
|
||||||
const activeTask = ref<ActiveTask | null>(null);
|
const activeTask = ref<ActiveTask | null>(null);
|
||||||
let timer: number | undefined;
|
let timer: number | undefined;
|
||||||
|
|
||||||
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待任务开始');
|
const logText = computed(() => taskStatus.value?.logs?.join('\n') || '等待任务开始');
|
||||||
const statusType = computed(() => {
|
const processVisible = computed(() => Boolean(taskStatus.value || activeTask.value?.has_active));
|
||||||
if (taskStatus.value?.status === 'completed') return 'success';
|
const showUploadProgress = computed(() => working.value || uploadProgress.value > 0);
|
||||||
if (taskStatus.value?.status === 'failed') return 'error';
|
const uploadedCount = computed(() => files.value.filter(item => item.status === 'uploaded').length);
|
||||||
return 'info';
|
const errorCount = computed(() => files.value.filter(item => item.status === 'error').length);
|
||||||
|
const statusClass = computed(() => {
|
||||||
|
if (taskStatus.value?.status === 'completed') return 'completed';
|
||||||
|
if (taskStatus.value?.status === 'failed') return 'failed';
|
||||||
|
return 'processing';
|
||||||
|
});
|
||||||
|
const processStatusText = computed(() => {
|
||||||
|
if (taskStatus.value?.status === 'completed') return '处理完成';
|
||||||
|
if (taskStatus.value?.status === 'failed') return '处理失败';
|
||||||
|
if (activeTask.value?.stage === 'uploading') return '上传中...';
|
||||||
|
return '处理中...';
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -110,26 +203,177 @@ onBeforeUnmount(() => {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function openFilePicker() {
|
||||||
|
if (working.value) return;
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
function pickFiles(event: Event) {
|
function pickFiles(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const selected = Array.from(input.files || []);
|
const selected = Array.from(input.files || []).map(toDroppedFile);
|
||||||
for (const file of selected) {
|
addFiles(selected);
|
||||||
const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
|
||||||
if (!files.value.some(item => item.path === path)) {
|
|
||||||
files.value.push({ file, path });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDragEnter() {
|
||||||
|
if (!working.value) {
|
||||||
|
isDragging.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver() {
|
||||||
|
if (!working.value) {
|
||||||
|
isDragging.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(event: DragEvent) {
|
||||||
|
const target = event.currentTarget as HTMLElement;
|
||||||
|
const related = event.relatedTarget as Node | null;
|
||||||
|
if (!related || !target.contains(related)) {
|
||||||
|
isDragging.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(event: DragEvent) {
|
||||||
|
isDragging.value = false;
|
||||||
|
if (working.value || !event.dataTransfer) return;
|
||||||
|
|
||||||
|
const dropped = await readDroppedFiles(event.dataTransfer);
|
||||||
|
addFiles(dropped);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDroppedFiles(dataTransfer: DataTransfer): Promise<DroppedFile[]> {
|
||||||
|
const result: DroppedFile[] = [];
|
||||||
|
const items = Array.from(dataTransfer.items || []);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.kind !== 'file') continue;
|
||||||
|
const entry = typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null;
|
||||||
|
if (entry) {
|
||||||
|
result.push(...(await traverseEntry(entry, '')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(dataTransfer.files || []).map(toDroppedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function traverseEntry(entry: FileSystemEntry, parentPath: string): Promise<DroppedFile[]> {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const fileEntry = entry as FileSystemFileEntry;
|
||||||
|
const file = await readEntryFile(fileEntry);
|
||||||
|
const path = parentPath ? `${parentPath}/${file.name}` : file.name;
|
||||||
|
return [{ file, path }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const directoryEntry = entry as FileSystemDirectoryEntry;
|
||||||
|
const nextPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
|
||||||
|
const children = await readAllDirectoryEntries(directoryEntry);
|
||||||
|
const nested = await Promise.all(children.map(child => traverseEntry(child, nextPath)));
|
||||||
|
return nested.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readEntryFile(entry: FileSystemFileEntry): Promise<File> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
entry.file(resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAllDirectoryEntries(entry: FileSystemDirectoryEntry): Promise<FileSystemEntry[]> {
|
||||||
|
const reader = entry.createReader();
|
||||||
|
const entries: FileSystemEntry[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||||
|
reader.readEntries(resolve, reject);
|
||||||
|
});
|
||||||
|
if (batch.length === 0) break;
|
||||||
|
entries.push(...batch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDroppedFile(file: File): DroppedFile {
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
path: normalizePath(file.webkitRelativePath || file.name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(newFiles: DroppedFile[]) {
|
||||||
|
if (working.value || newFiles.length === 0) return;
|
||||||
|
|
||||||
|
let ignored = 0;
|
||||||
|
let duplicated = 0;
|
||||||
|
const existingPaths = new Set(files.value.map(item => item.path));
|
||||||
|
const accepted: PickedFile[] = [];
|
||||||
|
|
||||||
|
for (const item of newFiles) {
|
||||||
|
const path = normalizePath(item.path);
|
||||||
|
if (!isSupportedFile(path)) {
|
||||||
|
ignored += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingPaths.has(path)) {
|
||||||
|
duplicated += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existingPaths.add(path);
|
||||||
|
accepted.push({
|
||||||
|
file: item.file,
|
||||||
|
path,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted.length > 0) {
|
||||||
|
files.value.push(...accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignored > 0) {
|
||||||
|
message.warning(`已忽略 ${ignored} 个不支持的文件`);
|
||||||
|
}
|
||||||
|
if (duplicated > 0) {
|
||||||
|
message.warning(`已忽略 ${duplicated} 个重复文件`);
|
||||||
|
}
|
||||||
|
if (accepted.length === 0 && ignored === 0 && duplicated === 0) {
|
||||||
|
message.warning('未读取到可上传文件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSupportedFile(path: string): boolean {
|
||||||
|
const dotIndex = path.lastIndexOf('.');
|
||||||
|
if (dotIndex < 0) return false;
|
||||||
|
return validExtensions.has(path.slice(dotIndex).toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(path: string): string {
|
||||||
|
return path.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
function clearFiles() {
|
function clearFiles() {
|
||||||
|
if (working.value) return;
|
||||||
files.value = [];
|
files.value = [];
|
||||||
uploadProgress.value = 0;
|
uploadProgress.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadAndStart() {
|
async function uploadAndStart() {
|
||||||
|
if (files.value.length === 0 || working.value) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
for (const item of files.value) {
|
for (const item of files.value) {
|
||||||
|
item.status = 'uploading';
|
||||||
|
item.progress = 0;
|
||||||
formData.append('files', item.file, item.path);
|
formData.append('files', item.file, item.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,18 +382,37 @@ async function uploadAndStart() {
|
|||||||
try {
|
try {
|
||||||
const result = await upload<UploadResponse>('/api/upload', formData, value => {
|
const result = await upload<UploadResponse>('/api/upload', formData, value => {
|
||||||
uploadProgress.value = value;
|
uploadProgress.value = value;
|
||||||
|
updateUploadingFiles(value);
|
||||||
|
});
|
||||||
|
uploadProgress.value = 100;
|
||||||
|
files.value.forEach(item => {
|
||||||
|
item.status = 'uploaded';
|
||||||
|
item.progress = 100;
|
||||||
});
|
});
|
||||||
message.success(`上传完成:${result.file_count} 个文件`);
|
message.success(`上传完成:${result.file_count} 个文件`);
|
||||||
await apiPost('/api/process/start', { task_id: result.task_id });
|
await apiPost('/api/process/start', { task_id: result.task_id });
|
||||||
taskStatus.value = { task_id: result.task_id, status: 'processing', logs: [] };
|
taskStatus.value = { task_id: result.task_id, status: 'processing', logs: ['任务已提交,等待处理日志...'] };
|
||||||
startPolling(result.task_id);
|
startPolling(result.task_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
files.value.forEach(item => {
|
||||||
|
if (item.status === 'uploading') {
|
||||||
|
item.status = 'error';
|
||||||
|
}
|
||||||
|
});
|
||||||
message.error(error instanceof Error ? error.message : '上传或启动任务失败');
|
message.error(error instanceof Error ? error.message : '上传或启动任务失败');
|
||||||
} finally {
|
} finally {
|
||||||
working.value = false;
|
working.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateUploadingFiles(progress: number) {
|
||||||
|
files.value.forEach(item => {
|
||||||
|
if (item.status === 'uploading') {
|
||||||
|
item.progress = progress;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function checkActiveTask() {
|
async function checkActiveTask() {
|
||||||
try {
|
try {
|
||||||
activeTask.value = await apiGet<ActiveTask>('/api/task/status');
|
activeTask.value = await apiGet<ActiveTask>('/api/task/status');
|
||||||
@@ -187,6 +450,13 @@ async function poll(taskId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileStatusText(item: PickedFile): string {
|
||||||
|
if (item.status === 'uploading') return `上传中 ${item.progress}%`;
|
||||||
|
if (item.status === 'uploaded') return '已完成';
|
||||||
|
if (item.status === 'error') return '失败';
|
||||||
|
return '等待上传';
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(size: number): string {
|
function formatBytes(size: number): string {
|
||||||
if (size === 0) return '0 B';
|
if (size === 0) return '0 B';
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
|||||||
@@ -190,6 +190,410 @@ body {
|
|||||||
max-height: 360px;
|
max-height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.upload-page-body {
|
||||||
|
--upload-brand-color: #0052d9;
|
||||||
|
--upload-brand-color-hover: #0034b5;
|
||||||
|
--upload-brand-color-light: #ecf2fe;
|
||||||
|
--upload-success-color: #00a870;
|
||||||
|
--upload-success-color-light: #e8f8f2;
|
||||||
|
--upload-error-color: #e34d59;
|
||||||
|
--upload-error-color-light: #fdecee;
|
||||||
|
--upload-text-primary: rgba(0, 0, 0, 0.9);
|
||||||
|
--upload-text-secondary: rgba(0, 0, 0, 0.6);
|
||||||
|
--upload-text-placeholder: rgba(0, 0, 0, 0.4);
|
||||||
|
--upload-bg-container: #ffffff;
|
||||||
|
--upload-bg-container-hover: #f3f3f3;
|
||||||
|
--upload-bg-secondary: #f3f3f3;
|
||||||
|
--upload-border-color: #dcdcdc;
|
||||||
|
--upload-border-color-light: #e7e7e7;
|
||||||
|
--upload-radius: 6px;
|
||||||
|
--upload-radius-large: 12px;
|
||||||
|
--upload-transition: 0.2s cubic-bezier(0.38, 0, 0.24, 1);
|
||||||
|
padding: 10px 18px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-zone {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 180px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--upload-bg-container);
|
||||||
|
border: 1px dashed var(--upload-border-color);
|
||||||
|
border-radius: var(--upload-radius-large);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--upload-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-zone:hover,
|
||||||
|
.upload-page-body .upload-zone.dragover {
|
||||||
|
background: var(--upload-brand-color-light);
|
||||||
|
border-color: var(--upload-brand-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-zone.dragover {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-zone.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto 12px;
|
||||||
|
color: var(--upload-brand-color);
|
||||||
|
background: var(--upload-brand-color-light);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-zone h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--upload-text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-zone p {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--upload-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-hint {
|
||||||
|
margin-top: 12px !important;
|
||||||
|
color: var(--upload-brand-color) !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-list {
|
||||||
|
max-height: none;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-list-header,
|
||||||
|
.upload-page-body .file-list-footer,
|
||||||
|
.upload-page-body .card-header,
|
||||||
|
.upload-page-body .card-header-left,
|
||||||
|
.upload-page-body .card-header-right,
|
||||||
|
.upload-page-body .total-progress-header,
|
||||||
|
.upload-page-body .upload-stats,
|
||||||
|
.upload-page-body .upload-stats-item,
|
||||||
|
.upload-page-body .file-item,
|
||||||
|
.upload-page-body .file-item-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-list-header,
|
||||||
|
.upload-page-body .file-list-footer,
|
||||||
|
.upload-page-body .card-header,
|
||||||
|
.upload-page-body .total-progress-header {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-list-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-list-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--upload-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-list-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body #fileCount {
|
||||||
|
color: var(--upload-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .total-progress {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .total-progress-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .total-progress-header .label {
|
||||||
|
color: var(--upload-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .total-progress-header .percent {
|
||||||
|
color: var(--upload-brand-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .total-progress-bar {
|
||||||
|
height: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--upload-bg-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .total-progress-bar-inner {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--upload-brand-color), #618dff);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-stats {
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0 16px 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--upload-bg-secondary);
|
||||||
|
border-radius: var(--upload-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-stats-item {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-stats-item .count {
|
||||||
|
color: var(--upload-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-stats-item.success .count {
|
||||||
|
color: var(--upload-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-stats-item.error .count {
|
||||||
|
color: var(--upload-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-items {
|
||||||
|
max-height: 240px;
|
||||||
|
margin-top: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--upload-bg-container);
|
||||||
|
border: 1px solid var(--upload-border-color-light);
|
||||||
|
border-radius: var(--upload-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--upload-text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid var(--upload-border-color-light);
|
||||||
|
transition: background var(--upload-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item:hover {
|
||||||
|
background: var(--upload-bg-container-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item.uploading {
|
||||||
|
background: var(--upload-brand-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item.uploaded {
|
||||||
|
background: var(--upload-success-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item.error {
|
||||||
|
background: var(--upload-error-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-right: 10px;
|
||||||
|
color: var(--upload-brand-color);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-name {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family:
|
||||||
|
"Cascadia Code",
|
||||||
|
Consolas,
|
||||||
|
"Courier New",
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-size {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--upload-text-placeholder);
|
||||||
|
font-family:
|
||||||
|
"Cascadia Code",
|
||||||
|
Consolas,
|
||||||
|
"Courier New",
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 6px;
|
||||||
|
margin-left: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-status.pending {
|
||||||
|
color: var(--upload-text-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-status.uploading {
|
||||||
|
color: var(--upload-brand-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-status.uploaded {
|
||||||
|
color: var(--upload-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item-status.error {
|
||||||
|
color: var(--upload-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-progress {
|
||||||
|
width: 100%;
|
||||||
|
height: 3px;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--upload-bg-secondary);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--upload-brand-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item.uploaded .file-progress-bar {
|
||||||
|
background: var(--upload-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .file-item.error .file-progress-bar {
|
||||||
|
background: var(--upload-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .process-section {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .upload-process-card {
|
||||||
|
background: var(--upload-bg-container);
|
||||||
|
border: 1px solid var(--upload-border-color-light);
|
||||||
|
border-radius: var(--upload-radius-large);
|
||||||
|
transition: all var(--upload-transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .card-header {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
color: var(--upload-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--upload-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .card-header-left,
|
||||||
|
.upload-page-body .card-header-right {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .card-title {
|
||||||
|
color: var(--upload-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .process-status {
|
||||||
|
padding: 4px 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--upload-brand-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .process-status.processing {
|
||||||
|
animation: upload-status-pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .process-status.completed {
|
||||||
|
background: var(--upload-success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .process-status.failed {
|
||||||
|
background: var(--upload-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .task-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .log-container {
|
||||||
|
min-height: 300px;
|
||||||
|
height: calc(100vh - 360px);
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #eef1f4;
|
||||||
|
border: 1px solid var(--upload-border-color-light);
|
||||||
|
border-radius: var(--upload-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-page-body .log-content {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--upload-text-secondary);
|
||||||
|
font-family:
|
||||||
|
"Cascadia Code",
|
||||||
|
Consolas,
|
||||||
|
"Courier New",
|
||||||
|
monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes upload-status-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.log-panel,
|
.log-panel,
|
||||||
.script-log {
|
.script-log {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
|
|||||||
Reference in New Issue
Block a user