Files
MyTool/frontend/src/components/DedupTab.vue
2026-01-29 18:26:02 +08:00

517 lines
13 KiB
Vue

<template>
<div class="dedup-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.libraryDir"
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.trashDir"
placeholder="重复文件移动/复制目录"
clearable
>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="去重策略">
<el-checkbox v-model="form.useMd5">启用 MD5 去重</el-checkbox>
<el-checkbox v-model="form.useMetadata">启用元数据匹配</el-checkbox>
</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 && !progress.completed"
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.scanned }}</div>
</div>
<div class="stat-item">
<div class="stat-label">重复组数量</div>
<div class="stat-value success">{{ progress.duplicateGroups }}</div>
</div>
<div class="stat-item">
<div class="stat-label">移动/复制文件数</div>
<div class="stat-value success">{{ progress.moved }}</div>
</div>
<div class="stat-item">
<div class="stat-label">完成状态</div>
<div class="stat-value" :class="{ success: progress.completed }">
{{ progress.completed ? '已完成' : '进行中' }}
</div>
</div>
</div>
<!-- 进度条 -->
<div class="progress-section">
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : undefined"
:stroke-width="12"
:show-text="true"
/>
</div>
<!-- 消息提示 -->
<div v-if="progress.message" class="message-section">
<el-alert
:type="progress.completed ? '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,
Document
} from '@element-plus/icons-vue';
import { startDedup } from '../api/dedup';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
const form = reactive({
libraryDir: '',
trashDir: '',
useMd5: true,
useMetadata: true,
mode: 'copy' as 'copy' | 'move'
});
const submitting = ref(false);
const wsConnected = ref(false);
const progress = reactive({
taskId: '' as string | null,
scanned: 0,
duplicateGroups: 0,
moved: 0,
completed: false,
message: ''
});
const canStart = computed(() => {
return (
form.libraryDir.trim() !== '' &&
form.trashDir.trim() !== '' &&
(form.useMd5 || form.useMetadata) &&
!submitting.value
);
});
const scannedTotal = ref(0);
const scannedProcessed = ref(0);
const percentage = computed(() => {
if (!scannedTotal.value) return 0;
return Math.round((scannedProcessed.value / scannedTotal.value) * 100);
});
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 {
// 忽略轮询错误
}
}, 1000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function handleProgressMessage(msg: ProgressMessage) {
if (msg.type !== 'dedup') {
return;
}
// 字段映射:见后端 DedupService 注释
scannedTotal.value = msg.total;
scannedProcessed.value = msg.processed;
progress.scanned = msg.total;
progress.duplicateGroups = msg.success;
progress.moved = msg.failed;
progress.completed = msg.completed;
progress.message = msg.message ?? '';
}
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
}
);
async function startTask() {
const libraryDir = form.libraryDir.trim();
const trashDir = form.trashDir.trim();
if (!libraryDir || !trashDir) {
ElMessage.warning('请填写音乐库目录和回收站目录');
return;
}
if (!form.useMd5 && !form.useMetadata) {
ElMessage.warning('请至少选择一种去重策略');
return;
}
submitting.value = true;
progress.taskId = null;
progress.scanned = 0;
progress.duplicateGroups = 0;
progress.moved = 0;
progress.completed = false;
progress.message = '';
scannedTotal.value = 0;
scannedProcessed.value = 0;
try {
const res = await startDedup({
libraryDir,
trashDir,
useMd5: form.useMd5,
useMetadata: form.useMetadata,
mode: form.mode
});
progress.taskId = res.taskId;
ElMessage.success('去重任务已启动');
} catch (e: unknown) {
submitting.value = false;
ElMessage.error(e instanceof Error ? e.message : '启动去重任务失败');
}
}
watch(
() => progress.completed,
(done) => {
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
}
}
);
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
});
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
form.libraryDir = '';
form.trashDir = '';
form.useMd5 = true;
form.useMetadata = true;
form.mode = 'copy';
progress.taskId = null;
progress.scanned = 0;
progress.duplicateGroups = 0;
progress.moved = 0;
progress.completed = false;
progress.message = '';
scannedTotal.value = 0;
scannedProcessed.value = 0;
submitting.value = false;
wsConnected.value = false;
}
</script>
<style scoped>
.dedup-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;
}
.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);
}
.progress-section {
margin-bottom: 20px;
}
.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>