527 lines
13 KiB
Vue
527 lines
13 KiB
Vue
<template>
|
|
<div class="aggregate-container">
|
|
<el-row :gutter="24">
|
|
<!-- 任务配置卡片 -->
|
|
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
|
<el-card class="config-card" shadow="hover">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<el-icon class="header-icon"><Setting /></el-icon>
|
|
<span class="card-title">任务配置</span>
|
|
</div>
|
|
</template>
|
|
|
|
<el-form :model="form" label-width="100px" label-position="left">
|
|
<el-form-item label="源目录" required>
|
|
<el-input
|
|
v-model="form.srcDir"
|
|
placeholder="选择需要递归扫描的起始目录"
|
|
clearable
|
|
>
|
|
<template #prefix>
|
|
<el-icon><Folder /></el-icon>
|
|
</template>
|
|
</el-input>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="目标目录" required>
|
|
<el-input
|
|
v-model="form.dstDir"
|
|
placeholder="汇聚后的扁平目录"
|
|
clearable
|
|
>
|
|
<template #prefix>
|
|
<el-icon><FolderOpened /></el-icon>
|
|
</template>
|
|
</el-input>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="模式">
|
|
<el-radio-group v-model="form.mode" size="default">
|
|
<el-radio-button value="copy">
|
|
<el-icon><DocumentCopy /></el-icon>
|
|
<span style="margin-left: 4px">复制模式</span>
|
|
</el-radio-button>
|
|
<el-radio-button value="move">
|
|
<el-icon><Right /></el-icon>
|
|
<span style="margin-left: 4px">移动模式</span>
|
|
</el-radio-button>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
<el-button
|
|
type="primary"
|
|
:loading="submitting"
|
|
:disabled="!canStart"
|
|
@click="startTask"
|
|
size="large"
|
|
style="width: 100%"
|
|
>
|
|
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
|
|
<span style="margin-left: 4px">{{ submitting ? '处理中...' : '开始汇聚' }}</span>
|
|
</el-button>
|
|
</el-form-item>
|
|
|
|
<el-form-item>
|
|
<el-button @click="reset" size="default" style="width: 100%">
|
|
<el-icon><Refresh /></el-icon>
|
|
<span style="margin-left: 4px">重置</span>
|
|
</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
</el-col>
|
|
|
|
<!-- 任务进度卡片 -->
|
|
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
|
<el-card class="progress-card" shadow="hover">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<el-icon class="header-icon"><DataLine /></el-icon>
|
|
<span class="card-title">任务进度</span>
|
|
<el-tag
|
|
v-if="progress.taskId && progress.completed"
|
|
type="info"
|
|
size="small"
|
|
effect="plain"
|
|
class="connection-tag"
|
|
>
|
|
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
|
<span class="connection-text">已结束</span>
|
|
</el-tag>
|
|
<el-tag
|
|
v-else-if="progress.taskId && wsConnected"
|
|
type="success"
|
|
size="small"
|
|
effect="plain"
|
|
class="connection-tag"
|
|
>
|
|
<el-icon class="connection-icon"><Connection /></el-icon>
|
|
<span class="connection-text">已连接</span>
|
|
</el-tag>
|
|
<el-tag
|
|
v-else-if="progress.taskId && !wsConnected"
|
|
type="warning"
|
|
size="small"
|
|
effect="plain"
|
|
class="connection-tag"
|
|
>
|
|
<el-icon class="connection-icon"><Loading /></el-icon>
|
|
<span class="connection-text">连接中</span>
|
|
</el-tag>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-if="!progress.taskId" class="empty-state">
|
|
<el-icon class="empty-icon"><Document /></el-icon>
|
|
<p class="empty-text">等待开始任务...</p>
|
|
</div>
|
|
|
|
<div v-else class="progress-content">
|
|
<!-- 统计信息 -->
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<div class="stat-label">总数</div>
|
|
<div class="stat-value">{{ progress.total }}</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">已处理</div>
|
|
<div class="stat-value success">{{ progress.processed }}</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">成功</div>
|
|
<div class="stat-value success">{{ progress.success }}</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-label">失败</div>
|
|
<div class="stat-value failed">{{ progress.failed }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 进度条 -->
|
|
<div class="progress-section">
|
|
<el-progress
|
|
:percentage="percentage"
|
|
:status="progress.completed ? 'success' : progress.failed > 0 ? 'exception' : undefined"
|
|
:stroke-width="12"
|
|
:show-text="true"
|
|
:format="formatProgress"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 当前文件 -->
|
|
<div v-if="progress.currentFile" class="current-file-section">
|
|
<el-icon class="file-icon"><Document /></el-icon>
|
|
<span class="file-text">{{ progress.currentFile }}</span>
|
|
</div>
|
|
|
|
<!-- 消息提示 -->
|
|
<div v-if="progress.message" class="message-section">
|
|
<el-alert
|
|
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
|
|
:closable="false"
|
|
show-icon
|
|
>
|
|
<template #title>
|
|
<span>{{ progress.message }}</span>
|
|
</template>
|
|
</el-alert>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
|
|
import { ElMessage } from 'element-plus';
|
|
import {
|
|
Setting,
|
|
Folder,
|
|
FolderOpened,
|
|
DocumentCopy,
|
|
Right,
|
|
VideoPlay,
|
|
Refresh,
|
|
DataLine,
|
|
Connection,
|
|
CircleCheck,
|
|
Loading,
|
|
Warning,
|
|
Document
|
|
} from '@element-plus/icons-vue';
|
|
import { startAggregate } from '../api/aggregate';
|
|
import { getProgress } from '../api/progress';
|
|
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
|
|
|
interface Progress {
|
|
taskId: string | null;
|
|
total: number;
|
|
processed: number;
|
|
success: number;
|
|
failed: number;
|
|
currentFile: string;
|
|
message: string;
|
|
completed: boolean;
|
|
}
|
|
|
|
const form = reactive({
|
|
srcDir: '',
|
|
dstDir: '',
|
|
mode: 'copy' as 'copy' | 'move'
|
|
});
|
|
|
|
const submitting = ref(false);
|
|
const wsConnected = ref(false);
|
|
|
|
const progress = reactive<Progress>({
|
|
taskId: null,
|
|
total: 0,
|
|
processed: 0,
|
|
success: 0,
|
|
failed: 0,
|
|
currentFile: '',
|
|
message: '',
|
|
completed: false
|
|
});
|
|
|
|
const canStart = computed(() => {
|
|
return form.srcDir.trim() && form.dstDir.trim() && !submitting.value;
|
|
});
|
|
|
|
const percentage = computed(() => {
|
|
if (!progress.total) return 0;
|
|
return Math.round((progress.processed / progress.total) * 100);
|
|
});
|
|
|
|
// WebSocket 连接
|
|
let wsDisconnect: (() => void) | null = null;
|
|
let pollTimer: number | null = null;
|
|
|
|
function startPolling(taskId: string) {
|
|
stopPolling();
|
|
pollTimer = window.setInterval(async () => {
|
|
try {
|
|
const latest = await getProgress(taskId);
|
|
if (latest) {
|
|
handleProgressMessage(latest);
|
|
if (latest.completed) {
|
|
stopPolling();
|
|
}
|
|
}
|
|
} catch {
|
|
// 忽略轮询错误
|
|
}
|
|
}, 800);
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => progress.taskId,
|
|
(newTaskId) => {
|
|
if (newTaskId) {
|
|
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
|
wsDisconnect = disconnect;
|
|
watch(connected, (val) => {
|
|
wsConnected.value = val;
|
|
});
|
|
connect();
|
|
// 兜底轮询:防止任务很快完成导致 WebSocket 消息丢失
|
|
startPolling(newTaskId);
|
|
}
|
|
}
|
|
);
|
|
|
|
function handleProgressMessage(msg: ProgressMessage) {
|
|
progress.total = msg.total;
|
|
progress.processed = msg.processed;
|
|
progress.success = msg.success;
|
|
progress.failed = msg.failed;
|
|
progress.currentFile = msg.currentFile || '';
|
|
progress.message = msg.message || '';
|
|
progress.completed = msg.completed;
|
|
|
|
if (msg.completed) {
|
|
submitting.value = false;
|
|
if (wsDisconnect) {
|
|
wsDisconnect();
|
|
wsDisconnect = null;
|
|
}
|
|
stopPolling();
|
|
}
|
|
}
|
|
|
|
async function startTask() {
|
|
if (!canStart.value) {
|
|
ElMessage.warning('请填写源目录和目标目录');
|
|
return;
|
|
}
|
|
|
|
submitting.value = true;
|
|
progress.completed = false;
|
|
progress.total = 0;
|
|
progress.processed = 0;
|
|
progress.success = 0;
|
|
progress.failed = 0;
|
|
progress.currentFile = '';
|
|
progress.message = '';
|
|
|
|
try {
|
|
const response = await startAggregate({
|
|
srcDir: form.srcDir.trim(),
|
|
dstDir: form.dstDir.trim(),
|
|
mode: form.mode
|
|
});
|
|
|
|
progress.taskId = response.taskId;
|
|
ElMessage.success('任务已启动,正在处理...');
|
|
} catch (error: any) {
|
|
submitting.value = false;
|
|
ElMessage.error(error.message || '启动任务失败');
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
if (wsDisconnect) {
|
|
wsDisconnect();
|
|
wsDisconnect = null;
|
|
}
|
|
stopPolling();
|
|
form.srcDir = '';
|
|
form.dstDir = '';
|
|
form.mode = 'copy';
|
|
progress.taskId = null;
|
|
progress.total = 0;
|
|
progress.processed = 0;
|
|
progress.success = 0;
|
|
progress.failed = 0;
|
|
progress.currentFile = '';
|
|
progress.message = '';
|
|
progress.completed = false;
|
|
submitting.value = false;
|
|
wsConnected.value = false;
|
|
}
|
|
|
|
function formatProgress(percentage: number): string {
|
|
return `${percentage}%`;
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (wsDisconnect) {
|
|
wsDisconnect();
|
|
}
|
|
stopPolling();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.aggregate-container {
|
|
width: 100%;
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.config-card,
|
|
.progress-card {
|
|
height: 100%;
|
|
min-height: 500px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.connection-tag {
|
|
margin-left: auto;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
white-space: nowrap;
|
|
padding: 2px 8px;
|
|
font-size: 12px;
|
|
line-height: 1.2;
|
|
height: auto;
|
|
}
|
|
|
|
.connection-icon {
|
|
font-size: 12px;
|
|
margin-right: 2px;
|
|
}
|
|
|
|
.connection-text {
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.header-icon {
|
|
font-size: 18px;
|
|
color: var(--el-color-primary);
|
|
}
|
|
|
|
.card-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
flex: 1;
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 60px 20px;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
|
|
.empty-icon {
|
|
font-size: 64px;
|
|
margin-bottom: 16px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.empty-text {
|
|
font-size: 14px;
|
|
margin: 0;
|
|
}
|
|
|
|
.progress-content {
|
|
padding: 8px 0;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.stat-item {
|
|
text-align: center;
|
|
padding: 16px;
|
|
background: var(--el-bg-color-page);
|
|
border-radius: 8px;
|
|
transition: all 0.3s;
|
|
}
|
|
|
|
.stat-item:hover {
|
|
background: var(--el-bg-color);
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 13px;
|
|
color: var(--el-text-color-secondary);
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
color: var(--el-text-color-primary);
|
|
}
|
|
|
|
.stat-value.success {
|
|
color: var(--el-color-success);
|
|
}
|
|
|
|
.stat-value.failed {
|
|
color: var(--el-color-danger);
|
|
}
|
|
|
|
.progress-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.current-file-section {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 12px;
|
|
background: var(--el-bg-color-page);
|
|
border-radius: 6px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.file-icon {
|
|
color: var(--el-color-primary);
|
|
font-size: 18px;
|
|
}
|
|
|
|
.file-text {
|
|
flex: 1;
|
|
font-size: 13px;
|
|
color: var(--el-text-color-regular);
|
|
word-break: break-all;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.message-section {
|
|
margin-top: 16px;
|
|
}
|
|
|
|
/* 响应式设计 */
|
|
@media (max-width: 768px) {
|
|
.stats-grid {
|
|
grid-template-columns: repeat(2, 1fr);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.stats-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
|