feat: 优化上传页布局和拖拽上传

This commit is contained in:
2026-05-15 14:26:11 +08:00
parent f8ed9199b0
commit 23ac949602
3 changed files with 752 additions and 70 deletions
+8
View File
@@ -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。
+340 -70
View File
@@ -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"
@keydown.enter.prevent="openFilePicker"
@keydown.space.prevent="openFilePicker"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragOver"
@dragleave="onDragLeave"
@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>
</div>
<div class="file-list-footer">
<span id="fileCount">{{ files.length }} 个文件</span>
<n-button type="primary" :disabled="files.length === 0" :loading="working" @click="uploadAndStart">
<template #icon><n-icon><CloudUploadOutline /></n-icon></template>
上传并处理
</n-button>
</div>
<div v-if="showUploadProgress" class="total-progress">
<div class="total-progress-header">
<span class="label">上传进度</span>
<span class="percent">{{ uploadProgress }}%</span>
</div>
<div class="total-progress-bar">
<div class="total-progress-bar-inner" :style="{ width: `${uploadProgress}%` }"></div>
</div>
</div>
<div v-if="showUploadProgress" class="upload-stats">
<div class="upload-stats-item">
<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> </n-button>
<n-button @click="folderInput?.click()"> </div>
<template #icon><n-icon><FolderOpenOutline /></n-icon></template> </div>
选择目录 <div class="card-body">
</n-button> <n-alert v-if="activeTask?.has_active" type="info" :bordered="false" class="task-alert">
<n-button tertiary :disabled="files.length === 0 || working" @click="clearFiles"> 当前任务:{{ activeTask.task_id }} / {{ activeTask.stage || 'processing' }}
清空
</n-button>
</n-space>
<input ref="fileInput" class="hidden-input" type="file" multiple @change="pickFiles" />
<input ref="folderInput" class="hidden-input" type="file" multiple @change="pickFiles" />
<n-statistic label="待上传文件" :value="files.length" />
<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-space>
</n-card>
</n-gi>
<n-gi :span="12" :m="7">
<n-card title="处理状态" size="small">
<template #header-extra>
<n-space>
<n-tag v-if="taskStatus" :type="statusType">{{ taskStatus.status }}</n-tag>
<n-button size="small" tertiary @click="checkActiveTask">刷新</n-button>
</n-space>
</template>
<n-space vertical>
<n-alert v-if="activeTask?.has_active" type="info" :bordered="false">
当前任务:{{ activeTask.task_id }} / {{ activeTask.stage }}
</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'];
+404
View File
@@ -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;