Improve music processing robustness and workflow UX

Unify safe file-move behavior and richer progress semantics across backend tasks, while upgrading traditional-to-simplified conversion and refining the frontend multi-step panels for clearer execution feedback.
This commit is contained in:
2026-03-08 04:26:18 +08:00
parent 20a70270c7
commit 81977a157e
39 changed files with 2131 additions and 1511 deletions

View File

@@ -885,7 +885,6 @@
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/lodash": "*"
}
@@ -1571,15 +1570,13 @@
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
@@ -1909,7 +1906,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -1969,7 +1965,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz",
"integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.27",
"@vue/compiler-sfc": "3.5.27",

View File

@@ -10,13 +10,13 @@
class="app-menu"
@select="handleSelect"
>
<el-menu-item index="aggregate">01 音频文件汇聚</el-menu-item>
<el-menu-item index="convert">02 音频格式智能处理</el-menu-item>
<el-menu-item index="dedup">03 音乐去重</el-menu-item>
<el-menu-item index="zhconvert">04 元数据繁简转换</el-menu-item>
<el-menu-item index="organize">05 音乐整理</el-menu-item>
<el-menu-item index="merge">06 整理入库</el-menu-item>
<el-menu-item index="settings">全局设置</el-menu-item>
<el-menu-item
v-for="tab in tabs"
:key="tab.key"
:index="tab.key"
>
{{ tab.menuLabel }}
</el-menu-item>
</el-menu>
</el-aside>
@@ -26,6 +26,10 @@
<h1>{{ currentTitle }}</h1>
<p>{{ currentSubtitle }}</p>
</div>
<div class="app-header-meta">
<span class="app-header-index">{{ currentTab.badge }}</span>
<span class="app-header-helper">执行面板</span>
</div>
</el-header>
<el-main class="app-main">
<component :is="currentComponent" />
@@ -35,14 +39,16 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import AggregateTab from './components/AggregateTab.vue';
import ConvertTab from './components/ConvertTab.vue';
import DedupTab from './components/DedupTab.vue';
import TraditionalFilterTab from './components/TraditionalFilterTab.vue';
import RenameTab from './components/RenameTab.vue';
import MergeTab from './components/MergeTab.vue';
import SettingsTab from './components/SettingsTab.vue';
import { computed, defineAsyncComponent, ref } from 'vue';
import type { Component } from 'vue';
const AggregateTab = defineAsyncComponent(() => import('./components/AggregateTab.vue'));
const ConvertTab = defineAsyncComponent(() => import('./components/ConvertTab.vue'));
const DedupTab = defineAsyncComponent(() => import('./components/DedupTab.vue'));
const TraditionalFilterTab = defineAsyncComponent(() => import('./components/TraditionalFilterTab.vue'));
const RenameTab = defineAsyncComponent(() => import('./components/RenameTab.vue'));
const MergeTab = defineAsyncComponent(() => import('./components/MergeTab.vue'));
const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue'));
type TabKey =
| 'aggregate'
@@ -53,70 +59,85 @@ type TabKey =
| 'merge'
| 'settings';
interface TabDefinition {
key: TabKey;
menuLabel: string;
badge: string;
title: string;
subtitle: string;
component: Component;
}
const tabs: TabDefinition[] = [
{
key: 'aggregate',
menuLabel: '01 音频文件汇聚',
badge: 'STEP 01',
title: '01 · 音频文件汇聚',
subtitle: '将分散音频扁平化汇聚,为后续处理统一入口。',
component: AggregateTab
},
{
key: 'convert',
menuLabel: '02 音频格式智能处理',
badge: 'STEP 02',
title: '02 · 音频格式智能处理',
subtitle: '智能识别无损/有损格式并统一转码为 FLAC。',
component: ConvertTab
},
{
key: 'dedup',
menuLabel: '03 音乐去重',
badge: 'STEP 03',
title: '03 · 音乐去重',
subtitle: '基于 MD5 与元数据的双重策略进行音乐去重。',
component: DedupTab
},
{
key: 'zhconvert',
menuLabel: '04 元数据繁简转换',
badge: 'STEP 04',
title: '04 · 音乐元数据繁体转简体',
subtitle: '批量检测并转换标签中的繁体中文。',
component: TraditionalFilterTab
},
{
key: 'organize',
menuLabel: '05 音乐整理',
badge: 'STEP 05',
title: '05 · 音乐整理',
subtitle: '按 Navidrome 规范重命名与整理目录结构。',
component: RenameTab
},
{
key: 'merge',
menuLabel: '06 整理入库',
badge: 'STEP 06',
title: '06 · 整理入库',
subtitle: '将整理好的 staging 目录智能合并入主库。',
component: MergeTab
},
{
key: 'settings',
menuLabel: '全局设置',
badge: 'CONFIG',
title: '全局配置与路径设置',
subtitle: '配置全局工作目录与各阶段标准子目录。',
component: SettingsTab
}
];
const tabMap = tabs.reduce((acc, tab) => {
acc[tab.key] = tab;
return acc;
}, {} as Record<TabKey, TabDefinition>);
const activeKey = ref<TabKey>('aggregate');
const currentComponent = computed(() => {
switch (activeKey.value) {
case 'aggregate':
return AggregateTab;
case 'convert':
return ConvertTab;
case 'dedup':
return DedupTab;
case 'zhconvert':
return TraditionalFilterTab;
case 'organize':
return RenameTab;
case 'merge':
return MergeTab;
case 'settings':
return SettingsTab;
default:
return AggregateTab;
}
});
const currentTitle = computed(() => {
switch (activeKey.value) {
case 'aggregate':
return '01 · 音频文件汇聚';
case 'convert':
return '02 · 音频格式智能处理';
case 'dedup':
return '03 · 音乐去重';
case 'zhconvert':
return '04 · 音乐元数据繁体转简体';
case 'organize':
return '05 · 音乐整理';
case 'merge':
return '06 · 整理入库';
case 'settings':
return '全局配置与路径设置';
default:
return '';
}
});
const currentSubtitle = computed(() => {
switch (activeKey.value) {
case 'aggregate':
return '将分散音频扁平化汇聚,为后续处理统一入口。';
case 'convert':
return '智能识别无损/有损格式并统一转码为 FLAC。';
case 'dedup':
return '基于 MD5 与元数据的双重策略进行音乐去重。';
case 'zhconvert':
return '批量检测并转换标签中的繁体中文。';
case 'organize':
return '按 Navidrome 规范重命名与整理目录结构。';
case 'merge':
return '将整理好的 staging 目录智能合并入主库。';
case 'settings':
return '配置全局工作目录与各阶段标准子目录。';
default:
return '';
}
});
const currentTab = computed(() => tabMap[activeKey.value] || tabs[0]);
const currentComponent = computed(() => currentTab.value.component);
const currentTitle = computed(() => currentTab.value.title);
const currentSubtitle = computed(() => currentTab.value.subtitle);
function handleSelect(key: string) {
activeKey.value = key as TabKey;
@@ -125,59 +146,157 @@ function handleSelect(key: string) {
<style scoped>
.app-root {
height: 100vh;
min-height: 100vh;
padding: 18px;
background:
radial-gradient(circle at 12% 12%, rgba(15, 118, 110, 0.13), transparent 45%),
radial-gradient(circle at 78% 4%, rgba(14, 116, 144, 0.16), transparent 40%),
linear-gradient(145deg, #f8fbff 0%, #f3f7fd 42%, #eef5fa 100%);
}
.app-aside {
border-right: 1px solid #e5e7eb;
padding: 16px 0;
margin-right: 14px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 20px;
padding: 18px 12px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(12px);
box-shadow:
0 20px 45px -30px rgba(15, 23, 42, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
display: flex;
flex-direction: column;
}
.app-logo {
padding: 0 20px 12px;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 12px;
padding: 2px 14px 16px;
border-bottom: 1px solid rgba(148, 163, 184, 0.26);
margin-bottom: 14px;
}
.app-logo-title {
display: block;
font-weight: 600;
font-size: 18px;
font-size: 24px;
font-weight: 700;
letter-spacing: 0.4px;
color: #0f172a;
}
.app-logo-sub {
display: block;
font-size: 12px;
color: #6b7280;
margin-top: 4px;
font-size: 13px;
letter-spacing: 1.3px;
text-transform: uppercase;
color: #475569;
}
.app-menu {
flex: 1;
border-right: none;
}
.app-menu :deep(.el-menu-item) {
height: 44px;
line-height: 44px;
border-radius: 11px;
margin: 2px 6px;
color: #334155;
transition: all 0.2s ease;
}
.app-menu :deep(.el-menu-item:hover) {
color: #0f172a;
background: rgba(15, 118, 110, 0.08);
}
.app-menu :deep(.el-menu-item.is-active) {
color: #0f766e;
font-weight: 600;
background: linear-gradient(95deg, rgba(15, 118, 110, 0.14), rgba(14, 116, 144, 0.07));
}
.app-header {
display: flex;
align-items: center;
border-bottom: 1px solid #e5e7eb;
justify-content: space-between;
min-height: 88px;
padding: 0 26px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 18px;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(10px);
box-shadow: 0 16px 35px -30px rgba(15, 23, 42, 0.45);
}
.app-header-title h1 {
margin: 0;
font-size: 18px;
font-weight: 600;
font-size: 22px;
font-weight: 700;
color: #0f172a;
}
.app-header-title p {
margin: 4px 0 0;
margin: 6px 0 0;
font-size: 14px;
color: #475569;
}
.app-header-meta {
display: flex;
align-items: center;
gap: 10px;
}
.app-header-index {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.8px;
color: #0b5f59;
background: rgba(15, 118, 110, 0.12);
}
.app-header-helper {
font-size: 13px;
color: #6b7280;
color: #64748b;
}
.app-main {
padding: 16px;
background: #f3f4f6;
margin-top: 14px;
border-radius: 18px;
padding: 20px;
background: rgba(255, 255, 255, 0.66);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
@media (max-width: 992px) {
.app-root {
padding: 12px;
}
.app-aside {
width: 100% !important;
margin-right: 0;
margin-bottom: 12px;
}
.app-header {
padding: 16px;
min-height: auto;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.app-main {
margin-top: 10px;
padding: 14px;
}
}
</style>

View File

@@ -10,9 +10,18 @@ export interface ProgressMessage {
currentFile: string;
message: string;
completed: boolean;
duplicateGroups?: number;
movedFiles?: number;
organizedFiles?: number;
manualFixFiles?: number;
traditionalEntries?: number;
failedFiles?: number;
albumsMerged?: number;
tracksMerged?: number;
upgradedFiles?: number;
skippedFiles?: number;
}
export function getProgress(taskId: string): Promise<ProgressMessage | null> {
return request.get(`/api/progress/${taskId}`);
}

View File

@@ -5,10 +5,7 @@
<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>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="100px" label-position="left">
@@ -77,40 +74,7 @@
<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>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -120,24 +84,7 @@
<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>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -187,16 +134,14 @@ import {
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';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
interface Progress {
taskId: string | null;
@@ -238,6 +183,23 @@ const percentage = computed(() => {
return Math.round((progress.processed / progress.total) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '总数', value: progress.total },
{ label: '已处理', value: progress.processed, tone: 'success' },
{ label: '成功', value: progress.success, tone: 'success' },
{ label: '失败', value: progress.failed, tone: 'failed' }
]);
// WebSocket 连接
let wsDisconnect: (() => void) | null = null;
let pollTimer: number | null = null;
@@ -381,163 +343,5 @@ onUnmounted(() => {
</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;
}
}
@import '../styles/panel-shared.css';
</style>

View File

@@ -5,10 +5,7 @@
<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>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="100px" label-position="left">
@@ -186,40 +183,7 @@
<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>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -229,24 +193,7 @@
<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>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -296,9 +243,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Warning,
Document,
InfoFilled,
@@ -308,6 +253,8 @@ import { startConvert } from '../api/convert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
interface Progress {
taskId: string | null;
@@ -351,6 +298,23 @@ const percentage = computed(() => {
return Math.round((progress.processed / progress.total) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '待转码', value: progress.total },
{ label: '已处理', value: progress.processed, tone: 'success' },
{ label: '成功', value: progress.success, tone: 'success' },
{ label: '失败', value: progress.failed, tone: 'failed' }
]);
let wsDisconnect: (() => void) | null = null;
let stopWatchingConnected: (() => void) | null = null;
let stopWatchingError: (() => void) | null = null;
@@ -618,150 +582,7 @@ onUnmounted(() => {
</script>
<style scoped>
.convert-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);
}
.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;
}
@import '../styles/panel-shared.css';
.mode-help-collapse {
border: none;
@@ -973,15 +794,4 @@ onUnmounted(() => {
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,10 +5,7 @@
<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>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="100px" label-position="left">
@@ -311,40 +308,7 @@
<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>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -354,26 +318,7 @@
<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>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -416,9 +361,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
ArrowDown,
@@ -430,6 +373,8 @@ import { startDedup } from '../api/dedup';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
const form = reactive({
libraryDir: '',
@@ -470,9 +415,39 @@ const percentage = computed(() => {
return Math.round((scannedProcessed.value / scannedTotal.value) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '扫描文件数', value: progress.scanned },
{ label: '重复组数量', value: progress.duplicateGroups, tone: 'success' },
{ label: '移动/复制文件数', value: progress.moved, tone: 'success' },
{ label: '完成状态', value: progress.completed ? '已完成' : '进行中', tone: progress.completed ? 'success' : 'default' }
]);
let wsDisconnect: (() => void) | null = null;
let connectedWatchStop: (() => void) | null = null;
let pollTimer: number | null = null;
function cleanupRealtime() {
if (connectedWatchStop) {
connectedWatchStop();
connectedWatchStop = null;
}
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
}
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
@@ -507,8 +482,8 @@ function handleProgressMessage(msg: ProgressMessage) {
scannedProcessed.value = msg.processed;
progress.scanned = msg.total;
progress.duplicateGroups = msg.success;
progress.moved = msg.failed;
progress.duplicateGroups = msg.duplicateGroups ?? msg.success;
progress.moved = msg.movedFiles ?? msg.failed;
progress.completed = msg.completed;
progress.message = msg.message ?? '';
}
@@ -516,16 +491,12 @@ function handleProgressMessage(msg: ProgressMessage) {
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
cleanupRealtime();
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
@@ -579,10 +550,7 @@ watch(
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
}
}
);
@@ -593,10 +561,7 @@ onMounted(() => {
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
});
async function loadDefaultPaths() {
@@ -613,10 +578,7 @@ async function loadDefaultPaths() {
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
form.useMd5 = false;
form.useMetadata = true;
form.mode = 'move';
@@ -629,130 +591,13 @@ function reset() {
scannedTotal.value = 0;
scannedProcessed.value = 0;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</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;
}
@import '../styles/panel-shared.css';
/* 去重策略说明样式 */
.strategy-help-collapse,
@@ -1043,16 +888,4 @@ function reset() {
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,10 +5,7 @@
<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>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="120px" label-position="left">
@@ -220,40 +217,7 @@
<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>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -263,26 +227,7 @@
<div v-else class="progress-content">
<!-- 统计信息 -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">已合并专辑数</div>
<div class="stat-value success">{{ progress.albums }}</div>
</div>
<div class="stat-item">
<div class="stat-label">已合并曲目数</div>
<div class="stat-value success">{{ progress.tracks }}</div>
</div>
<div class="stat-item">
<div class="stat-label">升级替换文件数</div>
<div class="stat-value">{{ progress.upgraded }}</div>
</div>
<div class="stat-item">
<div class="stat-label">完成状态</div>
<div class="stat-value" :class="{ success: progress.completed }">
{{ progress.completed ? '已完成' : '进行中' }}
</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -323,9 +268,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
TrendCharts,
@@ -339,6 +282,8 @@ import { startMerge } from '../api/merge';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
const form = reactive({
srcDir: '',
@@ -376,9 +321,39 @@ const percentage = computed(() => {
return Math.round((processedFiles.value / totalFiles.value) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '已合并专辑数', value: progress.albums, tone: 'success' },
{ label: '已合并曲目数', value: progress.tracks, tone: 'success' },
{ label: '升级替换文件数', value: progress.upgraded },
{ label: '完成状态', value: progress.completed ? '已完成' : '进行中', tone: progress.completed ? 'success' : 'default' }
]);
let wsDisconnect: (() => void) | null = null;
let connectedWatchStop: (() => void) | null = null;
let pollTimer: number | null = null;
function cleanupRealtime() {
if (connectedWatchStop) {
connectedWatchStop();
connectedWatchStop = null;
}
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
}
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
@@ -409,44 +384,25 @@ function handleProgressMessage(msg: ProgressMessage) {
}
// 字段映射:见后端 LibraryMergeService 注释
// success 字段存储专辑数failed 字段存储曲目数
totalFiles.value = msg.total;
processedFiles.value = msg.processed;
progress.albums = msg.success;
progress.tracks = msg.failed;
progress.albums = msg.albumsMerged ?? msg.success;
progress.tracks = msg.tracksMerged ?? msg.failed;
progress.upgraded = msg.upgradedFiles ?? progress.upgraded;
progress.completed = msg.completed;
progress.message = msg.message ?? '';
// 从消息中提取升级数量(如果消息中包含)
if (msg.message) {
// 匹配 "升级: 5" 或 "升级5" 或 "(升级: 5)" 等格式
const upgradeMatch = msg.message.match(/升级[:]\s*(\d+)/);
if (upgradeMatch) {
progress.upgraded = parseInt(upgradeMatch[1], 10);
} else {
// 如果没有找到,尝试从完成消息中提取
const finalMatch = msg.message.match(/升级[:]\s*(\d+)/);
if (finalMatch) {
progress.upgraded = parseInt(finalMatch[1], 10);
}
}
}
}
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
cleanupRealtime();
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
@@ -494,10 +450,7 @@ watch(
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
}
}
);
@@ -508,10 +461,7 @@ onMounted(() => {
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
});
async function loadDefaultPaths() {
@@ -528,10 +478,7 @@ async function loadDefaultPaths() {
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
form.smartUpgrade = true;
form.keepBackup = false;
progress.taskId = null;
@@ -543,130 +490,13 @@ function reset() {
totalFiles.value = 0;
processedFiles.value = 0;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</script>
<style scoped>
.merge-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;
}
@import '../styles/panel-shared.css';
/* 合并策略说明样式 */
.strategy-help-collapse {
@@ -922,16 +752,4 @@ function reset() {
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,10 +5,7 @@
<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>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="100px" label-position="left">
@@ -208,40 +205,7 @@
<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>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -250,24 +214,7 @@
</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">{{ progress.processed }}</div>
</div>
<div class="stat-item">
<div class="stat-label">整理成功</div>
<div class="stat-value success">{{ progress.organized }}</div>
</div>
<div class="stat-item">
<div class="stat-label">需人工修复</div>
<div class="stat-value" :class="{ failed: progress.manualFix > 0 }">{{ progress.manualFix }}</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<div class="progress-section">
<el-progress
@@ -311,9 +258,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
Warning
@@ -322,6 +267,8 @@ import { startOrganize } from '../api/organize';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
const form = reactive({
srcDir: '',
@@ -361,9 +308,39 @@ const percentage = computed(() => {
return Math.round((progress.processed / progress.total) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '扫描文件数', value: progress.total },
{ label: '已处理', value: progress.processed },
{ label: '整理成功', value: progress.organized, tone: 'success' },
{ label: '需人工修复', value: progress.manualFix, tone: progress.manualFix > 0 ? 'warning' : 'default' }
]);
let wsDisconnect: (() => void) | null = null;
let connectedWatchStop: (() => void) | null = null;
let pollTimer: number | null = null;
function cleanupRealtime() {
if (connectedWatchStop) {
connectedWatchStop();
connectedWatchStop = null;
}
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
}
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
@@ -393,8 +370,8 @@ function handleProgressMessage(msg: ProgressMessage) {
progress.total = msg.total;
progress.processed = msg.processed;
progress.organized = msg.success;
progress.manualFix = msg.failed;
progress.organized = msg.organizedFiles ?? msg.success;
progress.manualFix = msg.manualFixFiles ?? msg.failed;
progress.currentFile = msg.currentFile ?? '';
progress.message = msg.message ?? '';
progress.completed = msg.completed;
@@ -412,16 +389,12 @@ function handleProgressMessage(msg: ProgressMessage) {
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
cleanupRealtime();
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
@@ -476,10 +449,7 @@ watch(
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
}
}
);
@@ -490,10 +460,7 @@ onMounted(() => {
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
});
async function loadDefaultPaths() {
@@ -510,10 +477,7 @@ async function loadDefaultPaths() {
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
form.mode = 'strict';
form.extractCover = true;
form.extractLyrics = true;
@@ -527,62 +491,13 @@ function reset() {
progress.message = '';
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</script>
<style scoped>
.organize-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;
}
@import '../styles/panel-shared.css';
.form-tip {
margin-top: -8px;
@@ -728,62 +643,6 @@ function reset() {
color: var(--el-color-warning-dark-2);
}
.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);
}
@@ -792,19 +651,6 @@ function reset() {
color: var(--el-color-warning);
}
.progress-section {
margin-bottom: 20px;
}
.current-file-section {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.file-icon {
font-size: 14px;
flex-shrink: 0;
@@ -816,23 +662,9 @@ function reset() {
white-space: nowrap;
}
.message-section {
margin-top: 16px;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.form-tip {
padding-left: 0;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,10 +1,7 @@
<template>
<el-card class="settings-root">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span class="card-title">全局路径配置</span>
</div>
<task-card-header :icon="Setting" title="全局路径配置" />
</template>
<el-form label-width="140px">
<el-form-item label="工作根目录 (BasePath)" required>
@@ -78,6 +75,7 @@ import { computed, ref, onMounted } from 'vue';
import { ElMessage } from 'element-plus';
import { Setting, Folder, Check, Refresh } from '@element-plus/icons-vue';
import { saveBasePath, getConfig } from '../api/config';
import TaskCardHeader from './common/TaskCardHeader.vue';
const basePath = ref('');
const saving = ref(false);
@@ -206,27 +204,13 @@ onMounted(() => {
</script>
<style scoped>
@import '../styles/panel-shared.css';
.settings-root {
max-width: 800px;
margin: 0 auto;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 18px;
color: var(--el-color-primary);
}
.card-title {
font-size: 16px;
font-weight: 600;
}
.preview-title {
font-size: 14px;
font-weight: 600;
@@ -245,4 +229,3 @@ onMounted(() => {
margin-top: 20px;
}
</style>

View File

@@ -5,10 +5,7 @@
<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>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="110px" label-position="left">
@@ -245,7 +242,7 @@
<div class="example-scenario">
<div class="scenario-title">
<el-icon class="scenario-icon"><FolderOpened /></el-icon>
<strong>场景二输出目录不为空复制到新目录</strong>
<strong>场景二输出目录不为空移动到新目录</strong>
</div>
<div class="scenario-content">
<div class="example-item">
@@ -267,7 +264,7 @@
<div class="mode-tip">
<el-icon class="tip-icon"><Promotion /></el-icon>
<div class="tip-content">
<strong>使用建议</strong>建议先用预览模式确认需要转换的文件然后再使用执行模式如果输出目录不为空系统会将包含繁体的文件移动到输出目录这样可以保留原始文件作为备份
<strong>使用建议</strong>建议先用预览模式确认需要转换的文件然后再使用执行模式如果输出目录不为空系统会将包含繁体的文件移动到输出目录如需保留原始文件请先手动备份源目录
</div>
</div>
</div>
@@ -307,40 +304,7 @@
<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>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -350,24 +314,7 @@
<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.entries }}</div>
</div>
<div class="stat-item">
<div class="stat-label">失败文件数</div>
<div class="stat-value failed">{{ progress.failed }}</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -414,9 +361,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
ArrowDown,
@@ -430,6 +375,8 @@ import { startZhConvert } from '../api/zhconvert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
interface ProgressState {
taskId: string | null;
@@ -474,9 +421,39 @@ const percentage = computed(() => {
return Math.round((progress.processed / progress.total) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '总文件数', value: progress.total },
{ label: '已扫描', value: progress.processed, tone: 'success' },
{ label: '繁体标签条目', value: progress.entries, tone: 'success' },
{ label: '失败文件数', value: progress.failed, tone: 'failed' }
]);
let wsDisconnect: (() => void) | null = null;
let connectedWatchStop: (() => void) | null = null;
let pollTimer: number | null = null;
function cleanupRealtime() {
if (connectedWatchStop) {
connectedWatchStop();
connectedWatchStop = null;
}
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
}
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
@@ -507,8 +484,8 @@ function handleProgressMessage(msg: ProgressMessage) {
}
progress.total = msg.total;
progress.processed = msg.processed;
progress.entries = msg.success;
progress.failed = msg.failed;
progress.entries = msg.traditionalEntries ?? msg.success;
progress.failed = msg.failedFiles ?? msg.failed;
progress.currentFile = msg.currentFile || '';
progress.message = msg.message || '';
progress.completed = msg.completed;
@@ -526,16 +503,12 @@ function handleProgressMessage(msg: ProgressMessage) {
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
cleanupRealtime();
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
@@ -592,11 +565,8 @@ async function loadDefaultPaths() {
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
cleanupRealtime();
form.threshold = 10;
form.mode = 'preview';
progress.taskId = null;
@@ -608,7 +578,6 @@ function reset() {
progress.message = '';
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
@@ -618,159 +587,13 @@ onMounted(() => {
});
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
cleanupRealtime();
});
</script>
<style scoped>
.zhconvert-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;
}
.header-icon {
font-size: 18px;
color: var(--el-color-primary);
}
.card-title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.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;
}
.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);
}
.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;
}
@import '../styles/panel-shared.css';
/* 繁体占比阈值说明样式 */
.threshold-help-collapse,
@@ -1101,15 +924,4 @@ onUnmounted(() => {
color: #6b7280;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<div class="card-header">
<el-icon class="header-icon"><component :is="icon" /></el-icon>
<span class="card-title">{{ title }}</span>
<el-tag
v-if="status"
:type="tagType"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><component :is="statusIcon" /></el-icon>
<span class="connection-text">{{ statusText }}</span>
</el-tag>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { CircleCheck, Connection, Loading } from '@element-plus/icons-vue';
export type CardHeaderStatus = 'completed' | 'connected' | 'connecting';
interface Props {
icon: Component;
title: string;
status?: CardHeaderStatus | null;
}
const props = defineProps<Props>();
const statusTextMap: Record<CardHeaderStatus, string> = {
completed: '已结束',
connected: '已连接',
connecting: '连接中'
};
const statusTagTypeMap: Record<CardHeaderStatus, 'info' | 'success' | 'warning'> = {
completed: 'info',
connected: 'success',
connecting: 'warning'
};
const statusIconMap: Record<CardHeaderStatus, Component> = {
completed: CircleCheck,
connected: Connection,
connecting: Loading
};
const statusText = computed(() => {
if (!props.status) {
return '';
}
return statusTextMap[props.status];
});
const tagType = computed(() => {
if (!props.status) {
return 'info';
}
return statusTagTypeMap[props.status];
});
const statusIcon = computed(() => {
if (!props.status) {
return CircleCheck;
}
return statusIconMap[props.status];
});
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="stats-grid">
<div v-for="(item, index) in items" :key="`${item.label}-${index}`" class="stat-item">
<div class="stat-label">{{ item.label }}</div>
<div class="stat-value" :class="item.tone && item.tone !== 'default' ? item.tone : ''">
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
export interface StatItem {
label: string;
value: number | string;
tone?: 'default' | 'success' | 'failed' | 'warning';
}
interface Props {
items: StatItem[];
}
defineProps<Props>();
</script>

View File

@@ -12,6 +12,16 @@ export interface ProgressMessage {
currentFile: string;
message: string;
completed: boolean;
duplicateGroups?: number;
movedFiles?: number;
organizedFiles?: number;
manualFixFiles?: number;
traditionalEntries?: number;
failedFiles?: number;
albumsMerged?: number;
tracksMerged?: number;
upgradedFiles?: number;
skippedFiles?: number;
}
export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMessage) => void) {

View File

@@ -3,6 +3,7 @@ import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import './styles/reset.css';
import './styles/theme.css';
import App from './App.vue';
const app = createApp(App);
@@ -11,4 +12,3 @@ app.use(createPinia());
app.use(ElementPlus);
app.mount('#app');

View File

@@ -0,0 +1,185 @@
.aggregate-container,
.convert-container,
.dedup-container,
.zhconvert-container,
.organize-container,
.filter-container,
.rename-container,
.merge-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
animation: panelFadeIn 0.42s ease-out;
}
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
border-radius: 16px;
}
.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 {
flex: 1;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.2px;
color: #0f172a;
}
.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.45;
}
.empty-text {
margin: 0;
font-size: 14px;
}
.progress-content {
padding: 8px 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 24px;
}
.stat-item {
text-align: center;
padding: 16px;
border-radius: 10px;
border: 1px solid rgba(148, 163, 184, 0.2);
background: linear-gradient(155deg, rgba(255, 255, 255, 0.95), rgba(241, 247, 252, 0.85));
transition: all 0.22s ease;
}
.stat-item:hover {
transform: translateY(-2px);
border-color: rgba(13, 148, 136, 0.32);
box-shadow: 0 14px 30px -28px rgba(15, 23, 42, 0.55);
}
.stat-label {
margin-bottom: 8px;
font-size: 13px;
color: #64748b;
}
.stat-value {
font-size: 24px;
font-weight: 700;
color: #0f172a;
}
.stat-value.success {
color: #0f766e;
}
.stat-value.failed {
color: #dc2626;
}
.stat-value.warning {
color: #b45309;
}
.progress-section {
margin-bottom: 20px;
}
.current-file-section {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.24);
background: rgba(244, 249, 253, 0.84);
margin-bottom: 16px;
}
.file-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.file-text {
flex: 1;
font-size: 13px;
color: #334155;
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;
}
}
@keyframes panelFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,76 @@
:root {
--brand-50: #f0fdfa;
--brand-100: #ccfbf1;
--brand-200: #99f6e4;
--brand-500: #14b8a6;
--brand-600: #0d9488;
--brand-700: #0f766e;
--ink-900: #0f172a;
--ink-700: #334155;
--ink-500: #64748b;
--el-color-primary: var(--brand-600);
--el-color-primary-light-3: #2fb9ae;
--el-color-primary-light-5: #5ac7bf;
--el-color-primary-light-7: #8ad9d3;
--el-color-primary-light-8: #a9e5e1;
--el-color-primary-light-9: #daf4f2;
--el-color-primary-dark-2: #0c857a;
--el-bg-color: rgba(255, 255, 255, 0.92);
--el-bg-color-page: #f3f7fb;
--el-text-color-primary: var(--ink-900);
--el-text-color-regular: var(--ink-700);
--el-text-color-secondary: var(--ink-500);
--el-border-color: #d6dee8;
--el-border-color-light: #e5ebf2;
--el-border-color-lighter: #edf2f7;
--el-border-radius-base: 12px;
--el-border-radius-small: 10px;
}
body {
color: var(--ink-900);
font-family: 'Avenir Next', 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
}
#app {
isolation: isolate;
}
.el-card {
border: 1px solid rgba(148, 163, 184, 0.24) !important;
box-shadow: 0 20px 40px -36px rgba(15, 23, 42, 0.5) !important;
background: rgba(255, 255, 255, 0.88) !important;
}
.el-card:hover {
box-shadow: 0 26px 48px -36px rgba(15, 23, 42, 0.56) !important;
}
.el-button--primary {
background: linear-gradient(120deg, #0d9488, #0e7490) !important;
border-color: transparent !important;
}
.el-button--primary:hover {
opacity: 0.93;
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
background-color: rgba(255, 255, 255, 0.82) !important;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}