提交代码

This commit is contained in:
liu
2026-01-30 00:04:31 +08:00
parent 7531b6c466
commit 89be3ba0bd
23 changed files with 4934 additions and 179 deletions

View File

@@ -0,0 +1,30 @@
import request from './request';
export interface ConfigResponse {
basePath: string;
inputDir: string;
aggregatedDir: string;
formatIssuesDir: string;
duplicatesDir: string;
zhOutputDir: string;
organizedDir: string;
libraryFinalDir: string;
}
export interface ConfigRequest {
basePath: string;
}
/**
* 保存基础路径配置
*/
export function saveBasePath(data: ConfigRequest): Promise<void> {
return request.post('/api/config/base-path', data);
}
/**
* 获取完整配置(包含所有派生路径)
*/
export function getConfig(): Promise<ConfigResponse | null> {
return request.get('/api/config/base-path');
}

19
frontend/src/api/merge.ts Normal file
View File

@@ -0,0 +1,19 @@
import request from './request';
export interface MergeRequest {
srcDir: string;
dstDir: string;
smartUpgrade: boolean;
keepBackup: boolean;
}
export interface MergeResponse {
taskId: string;
}
/**
* 启动整理入库任务
*/
export function startMerge(params: MergeRequest): Promise<MergeResponse> {
return request.post('/api/merge/start', params);
}

View File

@@ -0,0 +1,21 @@
import request from './request';
export interface OrganizeRequest {
srcDir: string;
dstDir: string;
mode: 'strict' | 'lenient';
extractCover: boolean;
extractLyrics: boolean;
generateReport: boolean;
}
export interface OrganizeResponse {
taskId: string;
}
/**
* 启动音乐整理任务
*/
export function startOrganize(params: OrganizeRequest): Promise<OrganizeResponse> {
return request.post('/api/organize/start', params);
}

View File

@@ -176,7 +176,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { computed, reactive, ref, watch, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
@@ -196,6 +196,7 @@ import {
import { startAggregate } from '../api/aggregate';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
interface Progress {
taskId: string | null;
@@ -211,7 +212,7 @@ interface Progress {
const form = reactive({
srcDir: '',
dstDir: '',
mode: 'copy' as 'copy' | 'move'
mode: 'move' as 'copy' | 'move'
});
const submitting = ref(false);
@@ -330,15 +331,25 @@ async function startTask() {
}
}
async function loadDefaultPaths() {
try {
const config = await getConfig();
if (config) {
form.srcDir = config.inputDir || '';
form.dstDir = config.aggregatedDir || '';
}
} catch {
// 忽略错误,使用空值
}
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
form.srcDir = '';
form.dstDir = '';
form.mode = 'copy';
form.mode = 'move';
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
@@ -349,12 +360,18 @@ function reset() {
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
function formatProgress(percentage: number): string {
return `${percentage}%`;
}
onMounted(() => {
loadDefaultPaths();
});
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();

View File

@@ -49,6 +49,115 @@
</el-radio-group>
</el-form-item>
<!-- 模式说明 -->
<el-form-item>
<el-collapse v-model="activeModeHelp" class="mode-help-collapse">
<el-collapse-item name="help" :title="null">
<template #title>
<div class="mode-help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>模式说明</span>
</div>
</template>
<div class="mode-help-content">
<!-- 复制模式说明 -->
<div class="mode-description" :class="{ active: form.mode === 'copy' }">
<div class="mode-header">
<el-icon class="mode-icon copy-icon"><DocumentCopy /></el-icon>
<h4 class="mode-title">复制模式</h4>
<el-tag v-if="form.mode === 'copy'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<div class="mode-body">
<p class="mode-summary">转换后保留源文件在输出目录生成 FLAC 副本</p>
<ul class="mode-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>源文件保留</strong>原始文件WAV/APE/AIFF等不会被删除</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>FLAC 副本</strong>转换后的 FLAC 文件保存在输出目录</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>目录限制</strong>输入目录和输出目录可以相同</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>适用场景</strong>试运行备份或需要保留原始文件的场景</span>
</li>
</ul>
<div class="mode-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<span class="example-path">输入目录</span>
<code>D:\Music\Source\song.wav</code>
</div>
<div class="example-item">
<span class="example-path">输出目录</span>
<code>D:\Music\Output\song.flac</code>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果两个文件都存在</span>
</div>
</div>
</div>
</div>
</div>
<!-- 移动模式说明 -->
<div class="mode-description" :class="{ active: form.mode === 'move' }">
<div class="mode-header">
<el-icon class="mode-icon move-icon"><Right /></el-icon>
<h4 class="mode-title">移动模式</h4>
<el-tag v-if="form.mode === 'move'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<div class="mode-body">
<p class="mode-summary">转换后删除源文件仅保留 FLAC 文件在输出目录</p>
<ul class="mode-features">
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>源文件删除</strong>转换成功后原始文件会被删除</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>FLAC 保存</strong>转换后的 FLAC 文件保存在输出目录</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>目录限制</strong>输入目录和输出目录不能相同防止误删</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>适用场景</strong>确认不再需要原始格式节省存储空间</span>
</li>
</ul>
<div class="mode-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<span class="example-path">输入目录</span>
<code>D:\Music\Source\song.wav</code>
</div>
<div class="example-item">
<span class="example-path">输出目录</span>
<code>D:\Music\Output\song.flac</code>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果源文件被删除仅保留 FLAC</span>
</div>
</div>
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@@ -176,7 +285,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { computed, reactive, ref, watch, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
@@ -191,11 +300,14 @@ import {
CircleCheck,
Loading,
Warning,
Document
Document,
InfoFilled,
ArrowDown
} from '@element-plus/icons-vue';
import { startConvert } from '../api/convert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
interface Progress {
taskId: string | null;
@@ -211,12 +323,13 @@ interface Progress {
const form = reactive({
srcDir: '',
dstDir: '',
mode: 'copy' as 'copy' | 'move'
mode: 'move' as 'copy' | 'move'
});
const submitting = ref(false);
const wsConnected = ref(false);
const wsErrorShown = ref(false);
const activeModeHelp = ref<string[]>([]);
const progress = reactive<Progress>({
taskId: null,
@@ -423,6 +536,28 @@ async function startTask() {
}
}
async function loadDefaultPaths() {
try {
const config = await getConfig();
if (config) {
const aggregated = (config.aggregatedDir || '').trim();
form.srcDir = aggregated;
if (aggregated) {
const normalized = aggregated.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
const parent = lastSlash > 0 ? normalized.slice(0, lastSlash) : normalized;
form.dstDir = parent ? `${parent}/Staging_Format_Issues` : 'Staging_Format_Issues';
} else {
form.dstDir = 'Staging_Format_Issues';
}
} else {
form.dstDir = 'Staging_Format_Issues';
}
} catch {
form.dstDir = 'Staging_Format_Issues';
}
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
@@ -441,9 +576,7 @@ function reset() {
progressTimeout = null;
}
stopPolling();
form.srcDir = '';
form.dstDir = '';
form.mode = 'copy';
form.mode = 'move';
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
@@ -455,12 +588,18 @@ function reset() {
submitting.value = false;
wsConnected.value = false;
wsErrorShown.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
function formatProgress(percentage: number): string {
return `${percentage}%`;
}
onMounted(() => {
loadDefaultPaths();
});
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();
@@ -624,6 +763,216 @@ onUnmounted(() => {
margin-top: 16px;
}
.mode-help-collapse {
border: none;
background: transparent;
}
.mode-help-collapse :deep(.el-collapse-item__header) {
padding: 0;
border: none;
background: transparent;
height: auto;
line-height: 1.5;
}
.mode-help-collapse :deep(.el-collapse-item__wrap) {
border: none;
background: transparent;
}
.mode-help-collapse :deep(.el-collapse-item__content) {
padding: 16px 0 0 0;
}
.mode-help-title {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-color-primary);
font-size: 13px;
cursor: pointer;
transition: color 0.2s;
}
.mode-help-title:hover {
color: var(--el-color-primary-light-3);
}
.help-icon {
font-size: 16px;
}
.mode-help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.mode-description {
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
transition: all 0.3s;
}
.mode-description.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.mode-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.mode-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.copy-icon {
color: var(--el-color-info);
}
.move-icon {
color: var(--el-color-warning);
}
.mode-title {
flex: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.mode-body {
margin-left: 30px;
}
.mode-summary {
margin: 0 0 12px 0;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.6;
}
.mode-features {
margin: 0;
padding: 0;
list-style: none;
}
.mode-features li {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.feature-icon {
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
color: var(--el-color-success);
}
.feature-icon.warning-icon {
color: var(--el-color-warning);
}
.mode-features li span {
flex: 1;
}
.mode-example {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.example-label {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.example-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.example-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.example-path {
color: var(--el-text-color-secondary);
font-weight: 500;
min-width: 70px;
}
.example-item code {
padding: 4px 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 11px;
color: var(--el-color-primary);
word-break: break-all;
}
.example-result {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 8px 12px;
background: var(--el-color-info-light-9);
border-radius: 4px;
font-size: 12px;
color: var(--el-color-info-dark-2);
}
.example-result .el-icon {
font-size: 14px;
color: var(--el-color-info);
}
@media (max-width: 768px) {
.mode-body {
margin-left: 0;
}
.mode-header {
flex-wrap: wrap;
}
.example-item {
flex-direction: column;
align-items: flex-start;
}
.example-path {
min-width: auto;
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);

View File

@@ -41,6 +41,124 @@
<el-checkbox v-model="form.useMetadata">启用元数据匹配</el-checkbox>
</el-form-item>
<!-- 去重策略说明 -->
<el-form-item>
<el-collapse v-model="activeStrategyHelp" class="strategy-help-collapse">
<el-collapse-item name="strategy" :title="null">
<template #title>
<div class="help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>去重策略说明</span>
</div>
</template>
<div class="strategy-help-content">
<!-- MD5 去重说明 -->
<div class="strategy-description" :class="{ active: form.useMd5 }">
<div class="strategy-header">
<el-icon class="strategy-icon md5-icon"><Lock /></el-icon>
<h4 class="strategy-title">MD5 去重</h4>
<el-tag v-if="form.useMd5" type="success" size="small" effect="plain">已启用</el-tag>
</div>
<div class="strategy-body">
<p class="strategy-summary">通过计算文件 MD5 哈希值识别完全相同的二进制文件</p>
<ul class="strategy-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>精确匹配</strong>相同 MD5 值的文件视为完全相同的文件</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>适用场景</strong>识别不同目录下的完全拷贝文件</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>处理速度</strong>需要读取完整文件内容计算哈希速度较慢</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>准确性</strong>100% 准确不会误判</span>
</li>
</ul>
<div class="strategy-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<code>D:\Music\Album1\song.mp3</code>
<span class="example-separator"></span>
<code>D:\Music\Album2\song.mp3</code>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>如果 MD5 相同则识别为重复文件</span>
</div>
</div>
</div>
</div>
</div>
<!-- 元数据匹配说明 -->
<div class="strategy-description" :class="{ active: form.useMetadata }">
<div class="strategy-header">
<el-icon class="strategy-icon metadata-icon"><Document /></el-icon>
<h4 class="strategy-title">元数据匹配</h4>
<el-tag v-if="form.useMetadata" type="success" size="small" effect="plain">已启用</el-tag>
</div>
<div class="strategy-body">
<p class="strategy-summary">通过音频标签信息艺术家标题专辑时长识别重复歌曲</p>
<ul class="strategy-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>匹配字段</strong>艺术家 + 标题 + 专辑 + 时长允许 ±5 秒误差</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>智能评分</strong>自动选择最佳质量文件保留</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>评分规则</strong>FLAC > 其他无损 > 有损格式文件大小越大越好</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>前置条件</strong>需要音频文件包含完整的标签信息推荐使用 MusicBrainz Picard 整理</span>
</li>
</ul>
<div class="strategy-example">
<p class="example-label">评分示例</p>
<div class="example-content">
<div class="example-item">
<code>song.flac</code>
<span class="example-score">+100 FLAC格式</span>
</div>
<div class="example-item">
<code>song.wav</code>
<span class="example-score">+80 无损格式</span>
</div>
<div class="example-item">
<code>song.mp3</code>
<span class="example-score">+50 有损格式</span>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>系统会自动保留得分最高的文件</span>
</div>
</div>
</div>
</div>
</div>
<!-- 组合使用提示 -->
<div class="strategy-tip" v-if="form.useMd5 && form.useMetadata">
<el-icon class="tip-icon"><Promotion /></el-icon>
<div class="tip-content">
<strong>组合使用建议</strong>同时启用两种策略可以更全面地识别重复文件MD5 去重识别完全相同的文件元数据匹配识别相同歌曲的不同版本
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-form-item>
<el-form-item label="执行模式">
<el-radio-group v-model="form.mode" size="default">
<el-radio-button value="copy">
@@ -54,6 +172,117 @@
</el-radio-group>
</el-form-item>
<!-- 执行模式说明 -->
<el-form-item>
<el-collapse v-model="activeModeHelp" class="mode-help-collapse">
<el-collapse-item name="mode" :title="null">
<template #title>
<div class="help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>执行模式说明</span>
</div>
</template>
<div class="mode-help-content">
<!-- 复制模式说明 -->
<div class="mode-description" :class="{ active: form.mode === 'copy' }">
<div class="mode-header">
<el-icon class="mode-icon copy-icon"><DocumentCopy /></el-icon>
<h4 class="mode-title">复制模式</h4>
<el-tag v-if="form.mode === 'copy'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<div class="mode-body">
<p class="mode-summary">将重复文件复制到回收站目录原音乐库保持不变</p>
<ul class="mode-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>原库保留</strong>音乐库中的文件不会被删除或移动</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>安全验证</strong>适合试运行和验证去重规则是否正确</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>回收站副本</strong>重复文件会复制到回收站目录可随时检查</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>存储占用</strong>会占用额外的存储空间原文件 + 副本</span>
</li>
</ul>
<div class="mode-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<span class="example-path">音乐库</span>
<code>D:\Music\song.mp3</code>
</div>
<div class="example-item">
<span class="example-path">回收站</span>
<code>D:\Trash\song.mp3</code>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果两个文件都存在原库文件保留</span>
</div>
</div>
</div>
</div>
</div>
<!-- 移动模式说明 -->
<div class="mode-description" :class="{ active: form.mode === 'move' }">
<div class="mode-header">
<el-icon class="mode-icon move-icon"><Right /></el-icon>
<h4 class="mode-title">移动模式</h4>
<el-tag v-if="form.mode === 'move'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<div class="mode-body">
<p class="mode-summary">将重复文件移动到回收站目录从原音乐库中删除</p>
<ul class="mode-features">
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>原库删除</strong>重复文件会从音乐库中删除移动到回收站</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>可恢复性</strong>文件保存在回收站可后续人工检查与恢复</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>节省空间</strong>减少音乐库的存储占用</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>谨慎使用</strong>建议先用复制模式验证规则确认无误后再使用移动模式</span>
</li>
</ul>
<div class="mode-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<span class="example-path">音乐库</span>
<code>D:\Music\song.mp3</code>
<span class="example-status">删除</span>
</div>
<div class="example-item">
<span class="example-path">回收站</span>
<code>D:\Trash\song.mp3</code>
<span class="example-status">移动</span>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果文件从音乐库移动到回收站</span>
</div>
</div>
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@@ -176,7 +405,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { computed, reactive, ref, watch, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
@@ -190,22 +419,30 @@ import {
Connection,
CircleCheck,
Loading,
Document
Document,
InfoFilled,
ArrowDown,
Lock,
Warning,
Promotion
} from '@element-plus/icons-vue';
import { startDedup } from '../api/dedup';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
const form = reactive({
libraryDir: '',
trashDir: '',
useMd5: true,
useMd5: false,
useMetadata: true,
mode: 'copy' as 'copy' | 'move'
mode: 'move' as 'copy' | 'move'
});
const submitting = ref(false);
const wsConnected = ref(false);
const activeStrategyHelp = ref<string[]>([]);
const activeModeHelp = ref<string[]>([]);
const progress = reactive({
taskId: '' as string | null,
@@ -350,6 +587,10 @@ watch(
}
);
onMounted(() => {
loadDefaultPaths();
});
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
@@ -358,17 +599,27 @@ onUnmounted(() => {
}
});
async function loadDefaultPaths() {
try {
const config = await getConfig();
if (config) {
form.libraryDir = config.aggregatedDir || '';
form.trashDir = config.duplicatesDir || '';
}
} catch {
// 忽略错误,使用空值
}
}
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
form.libraryDir = '';
form.trashDir = '';
form.useMd5 = true;
form.useMd5 = false;
form.useMetadata = true;
form.mode = 'copy';
form.mode = 'move';
progress.taskId = null;
progress.scanned = 0;
progress.duplicateGroups = 0;
@@ -379,6 +630,8 @@ function reset() {
scannedProcessed.value = 0;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</script>
@@ -501,6 +754,295 @@ function reset() {
margin-top: 16px;
}
/* 去重策略说明样式 */
.strategy-help-collapse,
.mode-help-collapse {
border: none;
background: transparent;
}
.strategy-help-collapse :deep(.el-collapse-item__header),
.mode-help-collapse :deep(.el-collapse-item__header) {
padding: 0;
border: none;
background: transparent;
height: auto;
line-height: 1.5;
}
.strategy-help-collapse :deep(.el-collapse-item__wrap),
.mode-help-collapse :deep(.el-collapse-item__wrap) {
border: none;
background: transparent;
}
.strategy-help-collapse :deep(.el-collapse-item__content),
.mode-help-collapse :deep(.el-collapse-item__content) {
padding: 16px 0 0 0;
}
.help-title {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-color-primary);
font-size: 13px;
cursor: pointer;
transition: color 0.2s;
}
.help-title:hover {
color: var(--el-color-primary-light-3);
}
.help-icon {
font-size: 16px;
}
.strategy-help-content,
.mode-help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.strategy-description,
.mode-description {
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
transition: all 0.3s;
}
.strategy-description.active,
.mode-description.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.strategy-header,
.mode-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.strategy-icon,
.mode-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.md5-icon {
color: var(--el-color-info);
}
.metadata-icon {
color: var(--el-color-primary);
}
.copy-icon {
color: var(--el-color-info);
}
.move-icon {
color: var(--el-color-warning);
}
.strategy-title,
.mode-title {
flex: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.strategy-body,
.mode-body {
margin-left: 30px;
}
.strategy-summary,
.mode-summary {
margin: 0 0 12px 0;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.6;
}
.strategy-features,
.mode-features {
margin: 0;
padding: 0;
list-style: none;
}
.strategy-features li,
.mode-features li {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.feature-icon {
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
color: var(--el-color-success);
}
.feature-icon.warning-icon {
color: var(--el-color-warning);
}
.strategy-features li span,
.mode-features li span {
flex: 1;
}
.strategy-example,
.mode-example {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.example-label {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.example-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.example-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.example-path {
color: var(--el-text-color-secondary);
font-weight: 500;
min-width: 70px;
}
.example-separator {
color: var(--el-text-color-secondary);
margin: 0 4px;
}
.example-score {
color: var(--el-color-success);
font-weight: 500;
margin-left: auto;
}
.example-status {
color: var(--el-color-warning);
font-weight: 500;
margin-left: 8px;
}
.example-item code {
padding: 4px 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 11px;
color: var(--el-color-primary);
word-break: break-all;
}
.example-result {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
padding: 8px 12px;
background: var(--el-color-info-light-9);
border-radius: 4px;
font-size: 12px;
color: var(--el-color-info-dark-2);
}
.example-result .el-icon {
font-size: 14px;
color: var(--el-color-info);
}
.strategy-tip {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
border-radius: 8px;
margin-top: 8px;
}
.tip-icon {
font-size: 20px;
color: var(--el-color-warning);
flex-shrink: 0;
margin-top: 2px;
}
.tip-content {
flex: 1;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.tip-content strong {
color: var(--el-color-warning-dark-2);
}
@media (max-width: 768px) {
.strategy-body,
.mode-body {
margin-left: 0;
}
.strategy-header,
.mode-header {
flex-wrap: wrap;
}
.example-item {
flex-direction: column;
align-items: flex-start;
}
.example-path {
min-width: auto;
}
.example-score {
margin-left: 0;
margin-top: 4px;
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);

View File

@@ -1,55 +1,337 @@
<template>
<el-space direction="vertical" :size="16" class="tab-root">
<el-card>
<template #header>
<span>任务配置</span>
</template>
<el-form label-width="120px">
<el-form-item label="源目录 (staging)">
<el-input v-model="form.srcDir" placeholder="整理完成后的 staging 目录" />
</el-form-item>
<el-form-item label="目标目录 (主库)">
<el-input v-model="form.dstDir" placeholder="Navidrome 主库根目录" />
</el-form-item>
<el-form-item label="合并策略">
<el-checkbox v-model="form.smartUpgrade">启用智能升级</el-checkbox>
<el-checkbox v-model="form.keepBackup">保留旧版本备份</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="startTask">
开始合并
</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="merge-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-card>
<template #header>
<span>任务进度</span>
</template>
<div class="progress-row">
<span>已合并专辑数</span>
<span>{{ progress.albums }}</span>
</div>
<div class="progress-row">
<span>已合并曲目数</span>
<span>{{ progress.tracks }}</span>
</div>
<div class="progress-row">
<span>升级替换文件数</span>
<span>{{ progress.upgraded }}</span>
</div>
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : 'active'"
style="margin-top: 8px"
/>
</el-card>
</el-space>
<el-form :model="form" label-width="120px" label-position="left">
<el-form-item label="源目录 (staging)" required>
<el-input
v-model="form.srcDir"
placeholder="整理完成后的 staging 目录"
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="Navidrome 主库根目录"
clearable
>
<template #prefix>
<el-icon><FolderOpened /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="合并策略">
<el-checkbox v-model="form.smartUpgrade">启用智能升级</el-checkbox>
<el-checkbox v-model="form.keepBackup">保留旧版本备份</el-checkbox>
</el-form-item>
<!-- 合并策略说明 -->
<el-form-item>
<el-collapse v-model="activeStrategyHelp" class="strategy-help-collapse">
<el-collapse-item name="strategy" :title="null">
<template #title>
<div class="help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>合并策略说明</span>
</div>
</template>
<div class="strategy-help-content">
<!-- 智能升级说明 -->
<div class="strategy-description" :class="{ active: form.smartUpgrade }">
<div class="strategy-header">
<el-icon class="strategy-icon upgrade-icon"><TrendCharts /></el-icon>
<h4 class="strategy-title">智能升级</h4>
<el-tag v-if="form.smartUpgrade" type="success" size="small" effect="plain">已启用</el-tag>
</div>
<div class="strategy-body">
<p class="strategy-summary">当目标库已存在同名文件时自动判断是否用新文件替换旧文件</p>
<ul class="strategy-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>升级条件</strong>新文件大小 > 旧文件大小 × 110%10% 阈值</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>升级逻辑</strong>文件越大通常音质越好自动替换为更高质量的版本</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>未启用时</strong>目标库已存在的文件会被跳过不会替换</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>关联资源</strong>音频文件升级时对应的歌词文件也会同步更新</span>
</li>
</ul>
<div class="strategy-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<span class="example-path">目标库文件</span>
<code>song.mp3</code>
<span class="example-size">5 MB</span>
</div>
<div class="example-item">
<span class="example-path">源目录文件</span>
<code>song.mp3</code>
<span class="example-size">6 MB</span>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果6 MB > 5 MB × 1.1 = 5.5 MB满足升级条件文件会被替换</span>
</div>
</div>
</div>
</div>
</div>
<!-- 保留旧版本备份说明 -->
<div class="strategy-description" :class="{ active: form.keepBackup }">
<div class="strategy-header">
<el-icon class="strategy-icon backup-icon"><DocumentCopy /></el-icon>
<h4 class="strategy-title">保留旧版本备份</h4>
<el-tag v-if="form.keepBackup" type="success" size="small" effect="plain">已启用</el-tag>
</div>
<div class="strategy-body">
<p class="strategy-summary">在替换文件前将旧文件备份为 .backup 后缀便于恢复</p>
<ul class="strategy-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>备份时机</strong>仅在智能升级启用且需要替换文件时才会备份</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>备份位置</strong>与目标文件同目录文件名添加 <code>.backup</code> 后缀</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>备份范围</strong>音频文件和封面文件都会被备份</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>存储占用</strong>会占用额外的存储空间原文件 + 备份文件</span>
</li>
</ul>
<div class="strategy-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<span class="example-path">目标文件</span>
<code>D:\Music\Album\song.mp3</code>
</div>
<div class="example-item">
<span class="example-path">备份文件</span>
<code>D:\Music\Album\song.mp3.backup</code>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果替换前会先创建备份替换后可通过备份文件恢复</span>
</div>
</div>
</div>
</div>
</div>
<!-- 封面处理说明 -->
<div class="strategy-description">
<div class="strategy-header">
<el-icon class="strategy-icon cover-icon"><Picture /></el-icon>
<h4 class="strategy-title">封面处理策略</h4>
</div>
<div class="strategy-body">
<p class="strategy-summary">自动比较并保留更高质量的封面图片</p>
<ul class="strategy-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>比较规则</strong>优先比较分辨率像素数其次比较文件大小</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>替换条件</strong>新封面分辨率 > 旧封面分辨率 × 110%或分辨率相近但文件更大</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>备份支持</strong>如果启用保留旧版本备份封面替换时也会创建备份</span>
</li>
</ul>
</div>
</div>
<!-- 组合使用提示 -->
<div class="strategy-tip" v-if="form.smartUpgrade && form.keepBackup">
<el-icon class="tip-icon"><Promotion /></el-icon>
<div class="tip-content">
<strong>组合使用建议</strong>同时启用智能升级和备份功能可以在自动升级到更高质量文件的同时保留旧版本确保数据安全建议首次合并时启用备份确认无误后可关闭以节省存储空间
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</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 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>
<!-- 进度条 -->
<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 } from 'vue';
import { computed, reactive, ref, watch, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
Folder,
FolderOpened,
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
TrendCharts,
DocumentCopy,
Picture,
Warning,
Promotion,
ArrowDown
} from '@element-plus/icons-vue';
import { startMerge } from '../api/merge';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
const form = reactive({
srcDir: '',
@@ -59,38 +341,590 @@ const form = reactive({
});
const submitting = ref(false);
const wsConnected = ref(false);
const activeStrategyHelp = ref<string[]>([]);
const progress = reactive({
taskId: '' as string | null,
albums: 0,
tracks: 0,
upgraded: 0,
completed: false
completed: false,
message: ''
});
const canStart = computed(() => {
return (
form.srcDir.trim() !== '' &&
form.dstDir.trim() !== '' &&
!submitting.value
);
});
const totalFiles = ref(0);
const processedFiles = ref(0);
const percentage = computed(() => {
if (!progress.albums && !progress.tracks) return 0;
return 0;
if (!totalFiles.value) return 0;
return Math.round((processedFiles.value / totalFiles.value) * 100);
});
function startTask() {
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 !== 'merge') {
return;
}
// 字段映射:见后端 LibraryMergeService 注释
// success 字段存储专辑数failed 字段存储曲目数
totalFiles.value = msg.total;
processedFiles.value = msg.processed;
progress.albums = msg.success;
progress.tracks = msg.failed;
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;
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 srcDir = form.srcDir.trim();
const dstDir = form.dstDir.trim();
if (!srcDir || !dstDir) {
ElMessage.warning('请填写源目录和目标目录');
return;
}
submitting.value = true;
// TODO: 调用 /merge 接口并订阅进度
setTimeout(() => {
progress.taskId = null;
progress.albums = 0;
progress.tracks = 0;
progress.upgraded = 0;
progress.completed = false;
progress.message = '';
totalFiles.value = 0;
processedFiles.value = 0;
try {
const res = await startMerge({
srcDir,
dstDir,
smartUpgrade: form.smartUpgrade,
keepBackup: form.keepBackup
});
progress.taskId = res.taskId;
ElMessage.success('合并任务已启动');
} catch (e: unknown) {
submitting.value = false;
}, 500);
ElMessage.error(e instanceof Error ? e.message : '启动合并任务失败');
}
}
watch(
() => progress.completed,
(done) => {
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
}
}
);
onMounted(() => {
loadDefaultPaths();
});
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
});
async function loadDefaultPaths() {
try {
const config = await getConfig();
if (config) {
form.srcDir = config.organizedDir || '';
form.dstDir = config.libraryFinalDir || '';
}
} catch {
// 忽略错误,使用空值
}
}
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
form.smartUpgrade = true;
form.keepBackup = false;
progress.taskId = null;
progress.albums = 0;
progress.tracks = 0;
progress.upgraded = 0;
progress.completed = false;
progress.message = '';
totalFiles.value = 0;
processedFiles.value = 0;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</script>
<style scoped>
.tab-root {
.merge-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.progress-row {
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
}
.card-header {
display: flex;
justify-content: space-between;
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: #4b5563;
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;
}
/* 合并策略说明样式 */
.strategy-help-collapse {
border: none;
background: transparent;
}
.strategy-help-collapse :deep(.el-collapse-item__header) {
padding: 0;
border: none;
background: transparent;
height: auto;
line-height: 1.5;
}
.strategy-help-collapse :deep(.el-collapse-item__wrap) {
border: none;
background: transparent;
}
.strategy-help-collapse :deep(.el-collapse-item__content) {
padding: 16px 0 0 0;
}
.help-title {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-color-primary);
font-size: 13px;
cursor: pointer;
transition: color 0.2s;
}
.help-title:hover {
color: var(--el-color-primary-light-3);
}
.help-icon {
font-size: 16px;
}
.strategy-help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.strategy-description {
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
transition: all 0.3s;
}
.strategy-description.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.strategy-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.strategy-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.upgrade-icon {
color: var(--el-color-success);
}
.backup-icon {
color: var(--el-color-info);
}
.cover-icon {
color: var(--el-color-warning);
}
.strategy-title {
flex: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.strategy-body {
margin-left: 30px;
}
.strategy-summary {
margin: 0 0 12px 0;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.6;
}
.strategy-features {
margin: 0;
padding: 0;
list-style: none;
}
.strategy-features li {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.feature-icon {
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
color: var(--el-color-success);
}
.feature-icon.warning-icon {
color: var(--el-color-warning);
}
.strategy-features li span {
flex: 1;
}
.strategy-example {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.example-label {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.example-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.example-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.example-path {
color: var(--el-text-color-secondary);
font-weight: 500;
min-width: 80px;
}
.example-size {
color: var(--el-color-primary);
font-weight: 500;
margin-left: 4px;
}
.example-item code {
padding: 4px 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 11px;
color: var(--el-color-primary);
word-break: break-all;
}
.example-result {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 4px;
padding: 8px 12px;
background: var(--el-color-info-light-9);
border-radius: 4px;
font-size: 12px;
color: var(--el-color-info-dark-2);
line-height: 1.5;
}
.example-result .el-icon {
font-size: 14px;
color: var(--el-color-info);
flex-shrink: 0;
margin-top: 2px;
}
.strategy-tip {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
border-radius: 8px;
margin-top: 8px;
}
.tip-icon {
font-size: 20px;
color: var(--el-color-warning);
flex-shrink: 0;
margin-top: 2px;
}
.tip-content {
flex: 1;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.tip-content strong {
color: var(--el-color-warning-dark-2);
}
@media (max-width: 768px) {
.strategy-body {
margin-left: 0;
}
.strategy-header {
flex-wrap: wrap;
}
.example-item {
flex-direction: column;
align-items: flex-start;
}
.example-path {
min-width: auto;
}
}
@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

@@ -1,105 +1,838 @@
<template>
<el-space direction="vertical" :size="16" class="tab-root">
<el-card>
<template #header>
<span>任务配置</span>
</template>
<el-form label-width="120px">
<el-form-item label="源目录">
<el-input v-model="form.srcDir" placeholder="staging 源目录" />
</el-form-item>
<el-form-item label="目标目录">
<el-input v-model="form.dstDir" placeholder="规范化输出目录" />
</el-form-item>
<el-form-item label="标签完整度">
<el-radio-group v-model="form.mode">
<el-radio value="strict">严格模式</el-radio>
<el-radio value="lenient">宽松模式</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="附加选项">
<el-checkbox v-model="form.extractCover">提取封面</el-checkbox>
<el-checkbox v-model="form.extractLyrics">提取歌词</el-checkbox>
<el-checkbox v-model="form.generateReport">生成整理报告</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="startTask">
开始整理
</el-button>
</el-form-item>
</el-form>
</el-card>
<div class="organize-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-card>
<template #header>
<span>任务进度</span>
</template>
<div class="progress-row">
<span>扫描文件数</span>
<span>{{ progress.scanned }}</span>
</div>
<div class="progress-row">
<span>整理成功文件数</span>
<span>{{ progress.organized }}</span>
</div>
<div class="progress-row">
<span>需要人工修复文件数</span>
<span>{{ progress.manualFix }}</span>
</div>
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : 'active'"
style="margin-top: 8px"
/>
</el-card>
</el-space>
<el-form :model="form" label-width="100px" label-position="left">
<el-form-item label="源目录" required>
<el-input
v-model="form.srcDir"
placeholder="staging 源目录(汇聚/转码/去重后)"
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="strict">
<span>严格模式</span>
</el-radio-button>
<el-radio-button value="lenient">
<span>宽松模式</span>
</el-radio-button>
</el-radio-group>
</el-form-item>
<div class="form-tip">
<span>严格 Title+Artist+Album宽松仅需 Title</span>
</div>
<!-- 标签完整度说明 -->
<el-form-item>
<el-collapse v-model="activeTagHelp" class="help-collapse">
<el-collapse-item name="tag" :title="null">
<template #title>
<div class="help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>标签完整度说明</span>
</div>
</template>
<div class="help-content">
<div class="help-card" :class="{ active: form.mode === 'strict' }">
<div class="help-header">
<h4 class="help-card-title">严格模式strict</h4>
<el-tag v-if="form.mode === 'strict'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<p class="help-summary">要求标签尽量完整适合要直接入库/对目录结构要求严格的场景</p>
<ul class="help-list">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>必须具备</strong>Title + Artist + Album缺一不可</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>不满足会怎样</strong>该文件会被归入目标目录的 <code>_Manual_Fix_Required_</code>按原因分子目录并计入需人工修复</span>
</li>
</ul>
</div>
<div class="help-card" :class="{ active: form.mode === 'lenient' }">
<div class="help-header">
<h4 class="help-card-title">宽松模式lenient</h4>
<el-tag v-if="form.mode === 'lenient'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<p class="help-summary">尽量把文件整理到规范结构里适合标签还在修但想先跑一遍的场景</p>
<ul class="help-list">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>必须具备</strong>Title缺失 Title 仍会进入人工修复目录</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>Artist/Album</strong>缺失时会使用兜底值例如 Unknown Album目录与文件名可能不如严格模式干净</span>
</li>
</ul>
</div>
<div class="help-tip">
<el-icon class="tip-icon"><Warning /></el-icon>
<div class="tip-content">
<strong>重要提示</strong>整理成功的文件会<strong>移动</strong>到目标目录源目录原文件会被移走不保留需人工修复的文件也会被<strong>移动</strong>到目标目录的 <code>_Manual_Fix_Required_</code>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-form-item>
<el-form-item label="附加选项">
<el-checkbox v-model="form.extractCover">提取封面</el-checkbox>
<el-checkbox v-model="form.extractLyrics">提取歌词</el-checkbox>
<el-checkbox v-model="form.generateReport">生成整理报告</el-checkbox>
</el-form-item>
<!-- 附加选项说明 -->
<el-form-item>
<el-collapse v-model="activeOptionsHelp" class="help-collapse">
<el-collapse-item name="options" :title="null">
<template #title>
<div class="help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>附加选项说明</span>
</div>
</template>
<div class="help-content">
<div class="help-card">
<div class="help-header">
<h4 class="help-card-title">提取封面</h4>
<el-tag v-if="form.extractCover" type="success" size="small" effect="plain">已启用</el-tag>
</div>
<ul class="help-list">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span>尝试从音频内嵌封面写出到专辑目录<code>cover.jpg</code>每个专辑目录通常只写一次</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span>若源文件无内嵌封面则不会生成 cover可在报告中看到缺失清单若启用生成整理报告</span>
</li>
</ul>
</div>
<div class="help-card">
<div class="help-header">
<h4 class="help-card-title">提取歌词</h4>
<el-tag v-if="form.extractLyrics" type="success" size="small" effect="plain">已启用</el-tag>
</div>
<ul class="help-list">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span>若标签含内嵌歌词 LYRICS / UNSYNCED LYRICS会输出到同目录<code>曲名.lrc</code></span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span>未检测到内嵌歌词时不会生成 .lrc可在报告中看到缺失清单若启用生成整理报告</span>
</li>
</ul>
</div>
<div class="help-card">
<div class="help-header">
<h4 class="help-card-title">生成整理报告</h4>
<el-tag v-if="form.generateReport" type="success" size="small" effect="plain">已启用</el-tag>
</div>
<ul class="help-list">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span>在目标目录生成<code>_Reports/report_yyyyMMdd_HHmmss.txt</code>包含统计与缺失清单</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span>建议保留开启便于回溯缺封面/缺歌词/人工修复的具体条目</span>
</li>
</ul>
</div>
</div>
</el-collapse-item>
</el-collapse>
</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.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>
<div class="progress-section">
<el-progress
:percentage="percentage"
:status="progress.completed ? 'success' : undefined"
:stroke-width="12"
:show-text="true"
/>
</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.manualFix > 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 } from 'vue';
import { computed, reactive, ref, watch, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
Folder,
FolderOpened,
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
Warning
} from '@element-plus/icons-vue';
import { startOrganize } from '../api/organize';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
const form = reactive({
srcDir: '',
dstDir: '',
mode: 'strict',
mode: 'strict' as 'strict' | 'lenient',
extractCover: true,
extractLyrics: true,
generateReport: true
});
const submitting = ref(false);
const wsConnected = ref(false);
const activeTagHelp = ref<string[]>([]);
const activeOptionsHelp = ref<string[]>([]);
const progress = reactive({
scanned: 0,
taskId: '' as string | null,
total: 0,
processed: 0,
organized: 0,
manualFix: 0,
currentFile: '',
message: '',
completed: false
});
const percentage = computed(() => {
if (!progress.scanned) return 0;
return 0;
const canStart = computed(() => {
return (
form.srcDir.trim() !== '' &&
form.dstDir.trim() !== '' &&
!submitting.value
);
});
function startTask() {
submitting.value = true;
// TODO: 调用 /organize 接口并订阅进度
setTimeout(() => {
const percentage = computed(() => {
if (!progress.total) return 0;
return Math.round((progress.processed / progress.total) * 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 {
/* ignore */
}
}, 1000);
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
function handleProgressMessage(msg: ProgressMessage) {
if (msg.type !== 'organize') return;
progress.total = msg.total;
progress.processed = msg.processed;
progress.organized = msg.success;
progress.manualFix = msg.failed;
progress.currentFile = msg.currentFile ?? '';
progress.message = msg.message ?? '';
progress.completed = msg.completed;
if (msg.completed) {
submitting.value = false;
}, 500);
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
}
}
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 srcDir = form.srcDir.trim();
const dstDir = form.dstDir.trim();
if (!srcDir || !dstDir) {
ElMessage.warning('请填写源目录和目标目录');
return;
}
if (srcDir === dstDir) {
ElMessage.warning('源目录与目标目录不能相同');
return;
}
submitting.value = true;
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
progress.organized = 0;
progress.manualFix = 0;
progress.currentFile = '';
progress.message = '';
progress.completed = false;
try {
const res = await startOrganize({
srcDir,
dstDir,
mode: form.mode,
extractCover: form.extractCover,
extractLyrics: form.extractLyrics,
generateReport: form.generateReport
});
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;
}
}
}
);
onMounted(() => {
loadDefaultPaths();
});
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
});
async function loadDefaultPaths() {
try {
const config = await getConfig();
if (config) {
form.srcDir = config.aggregatedDir || '';
form.dstDir = config.organizedDir || '';
}
} catch {
// 忽略错误,使用空值
}
}
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
form.mode = 'strict';
form.extractCover = true;
form.extractLyrics = true;
form.generateReport = true;
progress.taskId = null;
progress.total = 0;
progress.processed = 0;
progress.organized = 0;
progress.manualFix = 0;
progress.currentFile = '';
progress.message = '';
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</script>
<style scoped>
.tab-root {
.organize-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.progress-row {
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
}
.card-header {
display: flex;
justify-content: space-between;
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;
}
.form-tip {
margin-top: -8px;
margin-bottom: 12px;
padding-left: 100px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.help-collapse {
border: none;
background: transparent;
}
.help-collapse :deep(.el-collapse-item__header) {
padding: 0;
border: none;
background: transparent;
height: auto;
line-height: 1.5;
}
.help-collapse :deep(.el-collapse-item__wrap) {
border: none;
background: transparent;
}
.help-collapse :deep(.el-collapse-item__content) {
padding: 14px 0 0 0;
}
.help-title {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-color-primary);
font-size: 13px;
color: #4b5563;
cursor: pointer;
transition: color 0.2s;
}
.help-title:hover {
color: var(--el-color-primary-light-3);
}
.help-icon {
font-size: 16px;
}
.help-content {
display: flex;
flex-direction: column;
gap: 14px;
}
.help-card {
padding: 14px;
background: var(--el-bg-color-page);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
transition: all 0.3s;
}
.help-card.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.help-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.help-card-title {
flex: 1;
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.help-summary {
margin: 0 0 10px 0;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.6;
}
.help-list {
margin: 0;
padding: 0;
list-style: none;
}
.help-list li {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.feature-icon {
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
color: var(--el-color-success);
}
.feature-icon.warning-icon {
color: var(--el-color-warning);
}
.help-tip {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
border-radius: 8px;
}
.tip-icon {
font-size: 20px;
color: var(--el-color-warning);
flex-shrink: 0;
margin-top: 2px;
}
.tip-content {
flex: 1;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.tip-content strong {
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);
}
.stat-value.failed {
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;
}
.file-text {
overflow: hidden;
text-overflow: ellipsis;
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,53 +1,91 @@
<template>
<el-card class="settings-root">
<template #header>
<span>全局路径配置</span>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span class="card-title">全局路径配置</span>
</div>
</template>
<el-form label-width="140px">
<el-form-item label="工作根目录 (BasePath)">
<el-input v-model="basePath" placeholder="例如D:/MusicWork" />
<el-form-item label="工作根目录 (BasePath)" required>
<el-input
v-model="basePath"
placeholder="例如D:/MusicWork 或 /home/user/MusicWork"
clearable
>
<template #prefix>
<el-icon><Folder /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="save">
保存配置
<el-button type="primary" :loading="saving" @click="save" size="default">
<el-icon v-if="!saving"><Check /></el-icon>
<span style="margin-left: 4px">{{ saving ? '保存中...' : '保存配置' }}</span>
</el-button>
<el-button @click="loadConfig" size="default" style="margin-left: 8px">
<el-icon><Refresh /></el-icon>
<span style="margin-left: 4px">刷新</span>
</el-button>
</el-form-item>
</el-form>
<el-divider />
<h4>派生目录预览</h4>
<h4 class="preview-title">派生目录预览</h4>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="Input (SRC_ACC_DIR)">
{{ preview.input }}
<span class="path-text">{{ preview.input || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Staging_Aggregated (DST_ACC_DIR)">
{{ preview.aggregated }}
<span class="path-text">{{ preview.aggregated || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Staging_Format_Issues (DST_CONV_ISSUE)">
{{ preview.formatIssues }}
<span class="path-text">{{ preview.formatIssues || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Staging_Duplicates (DST_DEDUP_TRASH)">
{{ preview.duplicates }}
<span class="path-text">{{ preview.duplicates || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Staging_T2S_Output (DST_ZH_CONV)">
{{ preview.zhOutput }}
<span class="path-text">{{ preview.zhOutput || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Staging_Organized (DST_ORG_DIR)">
{{ preview.organized }}
<span class="path-text">{{ preview.organized || '未配置' }}</span>
</el-descriptions-item>
<el-descriptions-item label="Library_Final (DST_LIB_FINAL)">
{{ preview.libraryFinal }}
<span class="path-text">{{ preview.libraryFinal || '未配置' }}</span>
</el-descriptions-item>
</el-descriptions>
<!-- 消息提示 -->
<div v-if="message.text" class="message-section">
<el-alert
:type="message.type"
:closable="true"
show-icon
@close="message.text = ''"
>
<template #title>
<span>{{ message.text }}</span>
</template>
</el-alert>
</div>
</el-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
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';
const basePath = ref('');
const saving = ref(false);
const loading = ref(false);
const message = ref<{ text: string; type: 'success' | 'error' | 'warning' | 'info' }>({
text: '',
type: 'info'
});
const preview = computed(() => {
const root = basePath.value.replace(/\\/g, '/').replace(/\/+$/, '');
@@ -73,18 +111,138 @@ const preview = computed(() => {
};
});
function save() {
saving.value = true;
// TODO: 调用 /config/base-path 接口保存配置
setTimeout(() => {
saving.value = false;
}, 500);
/**
* 验证路径是否合法
* 根据文档08的要求Windows路径中的冒号:)在盘符后是合法的
*/
function validatePath(path: string): boolean {
if (!path || !path.trim()) {
return false;
}
// 提取路径中需要验证的部分
let pathToValidate = path.trim();
// 如果是Windows路径如 C:\ 或 C:/),移除盘符部分
if (pathToValidate.match(/^[A-Za-z]:[/\\]/)) {
pathToValidate = pathToValidate.substring(2);
}
// 检查是否包含非法字符:< > " | ? *
// 注意:冒号(:在Windows盘符后是合法的已在上一步移除
const invalidChars = /[<>"|?*]/;
if (invalidChars.test(pathToValidate)) {
return false;
}
return true;
}
async function save() {
const pathToSave = basePath.value.trim();
if (!pathToSave) {
message.value = { text: '请输入工作根目录', type: 'warning' };
return;
}
// 验证路径
if (!validatePath(pathToSave)) {
message.value = { text: '路径包含非法字符,请检查', type: 'error' };
return;
}
saving.value = true;
message.value = { text: '', type: 'info' };
try {
// 确保路径使用平台无关的表示方式,统一使用 / 作为分隔符
const normalizedRoot = pathToSave.replace(/\\/g, '/');
await saveBasePath({ basePath: normalizedRoot });
message.value = { text: '配置保存成功!所有子目录已自动创建', type: 'success' };
ElMessage.success('配置保存成功');
// 重新加载配置以更新预览
await loadConfig();
} catch (error: any) {
message.value = {
text: error.message || '保存配置失败,请检查路径是否正确',
type: 'error'
};
} finally {
saving.value = false;
}
}
async function loadConfig() {
loading.value = true;
message.value = { text: '', type: 'info' };
try {
const config = await getConfig();
if (config && config.basePath) {
basePath.value = config.basePath;
message.value = { text: '配置加载成功', type: 'success' };
} else {
basePath.value = '';
message.value = { text: '未找到已保存的配置', type: 'info' };
}
} catch (error: any) {
message.value = {
text: error.message || '加载配置失败',
type: 'error'
};
} finally {
loading.value = false;
}
}
// 页面加载时自动获取配置
onMounted(() => {
loadConfig();
});
</script>
<style scoped>
.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;
margin-bottom: 12px;
color: var(--el-text-color-primary);
}
.path-text {
font-family: 'Courier New', monospace;
font-size: 12px;
color: var(--el-text-color-regular);
word-break: break-all;
}
.message-section {
margin-top: 20px;
}
</style>

View File

@@ -49,6 +49,80 @@
</div>
</el-form-item>
<!-- 繁体占比阈值说明 -->
<el-form-item>
<el-collapse v-model="activeThresholdHelp" class="threshold-help-collapse">
<el-collapse-item name="threshold" :title="null">
<template #title>
<div class="help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>繁体占比阈值说明</span>
</div>
</template>
<div class="threshold-help-content">
<div class="threshold-description">
<div class="threshold-header">
<el-icon class="threshold-icon"><DataAnalysis /></el-icon>
<h4 class="threshold-title">阈值工作原理</h4>
</div>
<div class="threshold-body">
<p class="threshold-summary">系统会计算每个标签字段中繁体字符的占比只有超过阈值的字段才会被处理</p>
<ul class="threshold-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>计算公式</strong>繁体占比 = 繁体字符数 ÷ 中文字符总数 × 100%</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>检测范围</strong>仅统计中文字符CJK统一表意文字忽略英文数字符号等</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>触发条件</strong>只有当繁体占比 设定阈值时该字段才会被转换</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>支持字段</strong>标题Title艺术家Artist专辑Album专辑艺人AlbumArtist</span>
</li>
</ul>
<div class="threshold-example">
<p class="example-label">示例</p>
<div class="example-content">
<div class="example-item">
<span class="example-label-text">标签内容</span>
<code>周杰倫 - 七里香</code>
</div>
<div class="example-item">
<span class="example-label-text">中文字符</span>
<span>5 </span>
</div>
<div class="example-item">
<span class="example-label-text">繁体字符</span>
<span>1 </span>
</div>
<div class="example-item">
<span class="example-label-text">繁体占比</span>
<span class="example-value">1 ÷ 5 × 100% = 20%</span>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>如果阈值设为 10%则会被转换20% 10%如果阈值设为 30%则不会被转换20% &lt; 30%</span>
</div>
</div>
</div>
<div class="threshold-tip">
<el-icon class="tip-icon"><Promotion /></el-icon>
<div class="tip-content">
<strong>推荐设置</strong>默认 10% 适合大多数场景如果希望更严格地过滤只处理明显繁体的内容可以设置为 30-50%如果希望更宽松处理所有包含繁体的内容可以设置为 1-5%
</div>
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-form-item>
<el-form-item label="处理模式">
<el-radio-group v-model="form.mode" size="default">
<el-radio-button value="preview">
@@ -60,6 +134,149 @@
</el-radio-group>
</el-form-item>
<!-- 处理模式说明 -->
<el-form-item>
<el-collapse v-model="activeModeHelp" class="mode-help-collapse">
<el-collapse-item name="mode" :title="null">
<template #title>
<div class="help-title">
<el-icon class="help-icon"><InfoFilled /></el-icon>
<span>处理模式说明</span>
</div>
</template>
<div class="mode-help-content">
<!-- 预览模式说明 -->
<div class="mode-description" :class="{ active: form.mode === 'preview' }">
<div class="mode-header">
<el-icon class="mode-icon preview-icon"><View /></el-icon>
<h4 class="mode-title">预览模式仅检测</h4>
<el-tag v-if="form.mode === 'preview'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<div class="mode-body">
<p class="mode-summary">仅扫描并列出检测到繁体的文件和字段不对原文件做任何修改</p>
<ul class="mode-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>安全检测</strong>只读取文件标签不修改任何内容</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>评估影响</strong>适合先评估影响范围确认需要转换的文件数量</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>输出目录</strong>预览模式下输出目录设置会被忽略</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>统计信息</strong>显示总文件数已扫描数繁体标签条目数</span>
</li>
</ul>
<div class="mode-example">
<p class="example-label">使用场景</p>
<div class="example-content">
<div class="example-item">
<el-icon class="example-icon"><CircleCheck /></el-icon>
<span>首次使用想了解音乐库中有多少文件包含繁体标签</span>
</div>
<div class="example-item">
<el-icon class="example-icon"><CircleCheck /></el-icon>
<span>调整阈值后想确认新的阈值会处理多少文件</span>
</div>
<div class="example-item">
<el-icon class="example-icon"><CircleCheck /></el-icon>
<span>执行转换前想先预览转换结果</span>
</div>
</div>
</div>
</div>
</div>
<!-- 执行模式说明 -->
<div class="mode-description" :class="{ active: form.mode === 'execute' }">
<div class="mode-header">
<el-icon class="mode-icon execute-icon"><Edit /></el-icon>
<h4 class="mode-title">执行模式执行转换</h4>
<el-tag v-if="form.mode === 'execute'" type="success" size="small" effect="plain">当前选择</el-tag>
</div>
<div class="mode-body">
<p class="mode-summary">对检测到的繁体标签进行转换可选择原地修改或输出到新目录</p>
<ul class="mode-features">
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>转换操作</strong>将繁体字符转换为对应的简体字符</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>输出目录为空</strong>在原文件上直接修改标签原地修改</span>
</li>
<li>
<el-icon class="feature-icon"><CircleCheck /></el-icon>
<span><strong>输出目录不为空</strong>将文件移动到输出目录保持相对路径结构然后在移动后的文件上修改标签</span>
</li>
<li>
<el-icon class="feature-icon warning-icon"><Warning /></el-icon>
<span><strong>文件移动</strong>只有包含繁体的文件才会被移动到输出目录其他文件保持不变</span>
</li>
</ul>
<div class="mode-example">
<p class="example-label">输出目录设置</p>
<div class="example-content">
<div class="example-scenario">
<div class="scenario-title">
<el-icon class="scenario-icon"><Document /></el-icon>
<strong>场景一输出目录为空原地修改</strong>
</div>
<div class="scenario-content">
<div class="example-item">
<span class="example-path">扫描目录</span>
<code>D:\Music\song.mp3</code>
</div>
<div class="example-item">
<span class="example-path">输出目录</span>
<span class="example-empty">留空</span>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果直接在 D:\Music\song.mp3 上修改标签文件位置不变</span>
</div>
</div>
</div>
<div class="example-scenario">
<div class="scenario-title">
<el-icon class="scenario-icon"><FolderOpened /></el-icon>
<strong>场景二输出目录不为空复制到新目录</strong>
</div>
<div class="scenario-content">
<div class="example-item">
<span class="example-path">扫描目录</span>
<code>D:\Music\Album\song.mp3</code>
</div>
<div class="example-item">
<span class="example-path">输出目录</span>
<code>D:\Music_Simplified\Album\song.mp3</code>
</div>
<div class="example-result">
<el-icon><ArrowDown /></el-icon>
<span>结果文件移动到输出目录保持 Album 子目录结构然后在移动后的文件上修改标签</span>
</div>
</div>
</div>
</div>
</div>
<div class="mode-tip">
<el-icon class="tip-icon"><Promotion /></el-icon>
<div class="tip-content">
<strong>使用建议</strong>建议先用预览模式确认需要转换的文件然后再使用执行模式如果输出目录不为空系统会将包含繁体的文件移动到输出目录这样可以保留原始文件作为备份
</div>
</div>
</div>
</div>
</div>
</el-collapse-item>
</el-collapse>
</el-form-item>
<el-form-item>
<el-button
type="primary"
@@ -188,7 +405,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
import { computed, reactive, ref, watch, onMounted, onUnmounted } from 'vue';
import { ElMessage } from 'element-plus';
import {
Setting,
@@ -200,11 +417,19 @@ import {
Connection,
CircleCheck,
Loading,
Document
Document,
InfoFilled,
ArrowDown,
DataAnalysis,
View,
Edit,
Warning,
Promotion
} from '@element-plus/icons-vue';
import { startZhConvert } from '../api/zhconvert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
interface ProgressState {
taskId: string | null;
@@ -226,6 +451,8 @@ const form = reactive({
const submitting = ref(false);
const wsConnected = ref(false);
const activeThresholdHelp = ref<string[]>([]);
const activeModeHelp = ref<string[]>([]);
const progress = reactive<ProgressState>({
taskId: null,
@@ -352,14 +579,24 @@ async function startTask() {
}
}
async function loadDefaultPaths() {
try {
const config = await getConfig();
if (config) {
form.scanDir = config.aggregatedDir || '';
form.outputDir = config.zhOutputDir || '';
}
} catch {
// 忽略错误,使用空值
}
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
form.scanDir = '';
form.outputDir = '';
form.threshold = 10;
form.mode = 'preview';
progress.taskId = null;
@@ -372,8 +609,14 @@ function reset() {
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
onMounted(() => {
loadDefaultPaths();
});
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();
@@ -529,6 +772,324 @@ onUnmounted(() => {
margin-top: 16px;
}
/* 繁体占比阈值说明样式 */
.threshold-help-collapse,
.mode-help-collapse {
border: none;
background: transparent;
}
.threshold-help-collapse :deep(.el-collapse-item__header),
.mode-help-collapse :deep(.el-collapse-item__header) {
padding: 0;
border: none;
background: transparent;
height: auto;
line-height: 1.5;
}
.threshold-help-collapse :deep(.el-collapse-item__wrap),
.mode-help-collapse :deep(.el-collapse-item__wrap) {
border: none;
background: transparent;
}
.threshold-help-collapse :deep(.el-collapse-item__content),
.mode-help-collapse :deep(.el-collapse-item__content) {
padding: 16px 0 0 0;
}
.help-title {
display: flex;
align-items: center;
gap: 6px;
color: var(--el-color-primary);
font-size: 13px;
cursor: pointer;
transition: color 0.2s;
}
.help-title:hover {
color: var(--el-color-primary-light-3);
}
.help-icon {
font-size: 16px;
}
.threshold-help-content,
.mode-help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.threshold-description,
.mode-description {
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
transition: all 0.3s;
}
.mode-description.active {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-5);
}
.threshold-header,
.mode-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.threshold-icon,
.mode-icon {
font-size: 20px;
color: var(--el-color-primary);
}
.threshold-icon {
color: var(--el-color-info);
}
.preview-icon {
color: var(--el-color-info);
}
.execute-icon {
color: var(--el-color-warning);
}
.threshold-title,
.mode-title {
flex: 1;
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.threshold-body,
.mode-body {
margin-left: 30px;
}
.threshold-summary,
.mode-summary {
margin: 0 0 12px 0;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.6;
}
.threshold-features,
.mode-features {
margin: 0;
padding: 0;
list-style: none;
}
.threshold-features li,
.mode-features li {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.feature-icon {
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
color: var(--el-color-success);
}
.feature-icon.warning-icon {
color: var(--el-color-warning);
}
.threshold-features li span,
.mode-features li span {
flex: 1;
}
.threshold-example,
.mode-example {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
.example-label {
margin: 0 0 10px 0;
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.example-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.example-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.example-label-text {
color: var(--el-text-color-secondary);
font-weight: 500;
min-width: 80px;
}
.example-value {
color: var(--el-color-primary);
font-weight: 600;
}
.example-empty {
color: var(--el-text-color-placeholder);
font-style: italic;
}
.example-item code {
padding: 4px 8px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 4px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 11px;
color: var(--el-color-primary);
word-break: break-all;
}
.example-result {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 4px;
padding: 8px 12px;
background: var(--el-color-info-light-9);
border-radius: 4px;
font-size: 12px;
color: var(--el-color-info-dark-2);
line-height: 1.5;
}
.example-result .el-icon {
font-size: 14px;
color: var(--el-color-info);
flex-shrink: 0;
margin-top: 2px;
}
.example-scenario {
margin-top: 12px;
padding: 12px;
background: var(--el-bg-color);
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
}
.scenario-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 13px;
color: var(--el-text-color-primary);
}
.scenario-icon {
font-size: 16px;
color: var(--el-color-primary);
}
.scenario-content {
margin-left: 24px;
}
.example-icon {
font-size: 14px;
color: var(--el-color-success);
flex-shrink: 0;
}
.example-path {
color: var(--el-text-color-secondary);
font-weight: 500;
min-width: 80px;
}
.threshold-tip,
.mode-tip {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
border-radius: 8px;
margin-top: 12px;
}
.tip-icon {
font-size: 20px;
color: var(--el-color-warning);
flex-shrink: 0;
margin-top: 2px;
}
.tip-content {
flex: 1;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.tip-content strong {
color: var(--el-color-warning-dark-2);
}
@media (max-width: 768px) {
.threshold-body,
.mode-body {
margin-left: 0;
}
.threshold-header,
.mode-header {
flex-wrap: wrap;
}
.example-item {
flex-direction: column;
align-items: flex-start;
}
.example-label-text {
min-width: auto;
}
.scenario-content {
margin-left: 0;
}
}
.inline-field {
display: inline-flex;
align-items: center;