517 lines
13 KiB
Vue
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>
|
|
|