提交代码
This commit is contained in:
@@ -65,6 +65,13 @@
|
||||
<artifactId>jaudiotagger</artifactId>
|
||||
<version>2.2.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 中文拼音首字母(A-Z 分组) -->
|
||||
<dependency>
|
||||
<groupId>com.belerweb</groupId>
|
||||
<artifactId>pinyin4j</artifactId>
|
||||
<version>2.5.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
104
backend/src/main/java/com/music/common/PathUtils.java
Normal file
104
backend/src/main/java/com/music/common/PathUtils.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package com.music.common;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
/**
|
||||
* 路径工具类,用于处理跨平台路径兼容性
|
||||
*/
|
||||
public class PathUtils {
|
||||
|
||||
/**
|
||||
* 规范化路径,去除多余的路径分隔符和相对路径部分
|
||||
*/
|
||||
public static String normalizePath(String path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return path;
|
||||
}
|
||||
// 使用Java的Path API进行规范化
|
||||
Path normalized = Paths.get(path).normalize();
|
||||
return normalized.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将路径转换为POSIX格式(使用/作为分隔符)
|
||||
* 用于前端和后端之间的路径传输
|
||||
*/
|
||||
public static String toPosixPath(String path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return path;
|
||||
}
|
||||
// 统一使用 / 作为分隔符
|
||||
return path.replace('\\', '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 将路径转换为平台特定格式
|
||||
* 用于实际的文件系统操作
|
||||
*/
|
||||
public static String toPlatformPath(String path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return path;
|
||||
}
|
||||
// Java的Path API会自动处理平台差异
|
||||
return Paths.get(path).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证路径是否合法
|
||||
* 注意:Windows路径中的冒号(:)在盘符后是合法的,需要特殊处理
|
||||
*/
|
||||
public static boolean isValidPath(String path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 提取路径中需要验证的部分
|
||||
String pathToValidate = path;
|
||||
|
||||
// 如果是Windows路径(如 C:\ 或 C:/),移除盘符部分
|
||||
if (pathToValidate.matches("^[A-Za-z]:[/\\\\].*")) {
|
||||
pathToValidate = pathToValidate.substring(2);
|
||||
}
|
||||
|
||||
// 检查是否包含非法字符:< > " | ? *
|
||||
// 注意:冒号(:)在Windows盘符后是合法的,已在上一步移除
|
||||
String invalidChars = "[<>\"|?*]";
|
||||
if (pathToValidate.matches(".*" + invalidChars + ".*")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接路径,使用平台无关的方式
|
||||
* 返回POSIX格式的路径(用于前后端传输)
|
||||
*/
|
||||
public static String joinPath(String parent, String child) {
|
||||
if (parent == null || parent.isEmpty()) {
|
||||
return child;
|
||||
}
|
||||
if (child == null || child.isEmpty()) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
// 统一转换为POSIX格式
|
||||
String parentPosix = toPosixPath(parent).replaceAll("/+$", "");
|
||||
String childPosix = toPosixPath(child).replaceAll("^/+", "");
|
||||
|
||||
return parentPosix + "/" + childPosix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化并转换为POSIX格式
|
||||
* 用于保存配置时统一格式
|
||||
*/
|
||||
public static String normalizeAndToPosix(String path) {
|
||||
if (path == null || path.isEmpty()) {
|
||||
return path;
|
||||
}
|
||||
String normalized = normalizePath(path);
|
||||
return toPosixPath(normalized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.ConfigRequest;
|
||||
import com.music.dto.ConfigResponse;
|
||||
import com.music.service.ConfigService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/config")
|
||||
@Validated
|
||||
public class ConfigController {
|
||||
|
||||
private final ConfigService configService;
|
||||
|
||||
public ConfigController(ConfigService configService) {
|
||||
this.configService = configService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存基础路径配置
|
||||
*/
|
||||
@PostMapping("/base-path")
|
||||
public Result<Void> saveBasePath(@Valid @RequestBody ConfigRequest request) {
|
||||
configService.saveBasePath(request.getBasePath());
|
||||
return Result.success("配置保存成功", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整配置(包含所有派生路径)
|
||||
*/
|
||||
@GetMapping("/base-path")
|
||||
public Result<ConfigResponse> getConfig() {
|
||||
ConfigResponse config = configService.getConfig();
|
||||
return Result.success(config);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.MergeRequest;
|
||||
import com.music.exception.BusinessException;
|
||||
import com.music.service.LibraryMergeService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 整理入库任务控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/merge")
|
||||
@Validated
|
||||
public class MergeController {
|
||||
|
||||
private final LibraryMergeService libraryMergeService;
|
||||
|
||||
public MergeController(LibraryMergeService libraryMergeService) {
|
||||
this.libraryMergeService = libraryMergeService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动整理入库任务
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public Result<StartResponse> start(@Valid @RequestBody MergeRequest request) {
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
libraryMergeService.merge(
|
||||
taskId,
|
||||
request.getSrcDir(),
|
||||
request.getDstDir(),
|
||||
request.isSmartUpgrade(),
|
||||
request.isKeepBackup()
|
||||
);
|
||||
|
||||
return Result.success(new StartResponse(taskId));
|
||||
}
|
||||
|
||||
public static class StartResponse {
|
||||
private String taskId;
|
||||
|
||||
public StartResponse(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.OrganizeRequest;
|
||||
import com.music.exception.BusinessException;
|
||||
import com.music.service.OrganizeService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 音乐整理任务控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/organize")
|
||||
@Validated
|
||||
public class OrganizeController {
|
||||
|
||||
private final OrganizeService organizeService;
|
||||
|
||||
public OrganizeController(OrganizeService organizeService) {
|
||||
this.organizeService = organizeService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音乐整理任务
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public Result<OrganizeController.StartResponse> start(@Valid @RequestBody OrganizeRequest request) {
|
||||
if (!"strict".equalsIgnoreCase(request.getMode()) && !"lenient".equalsIgnoreCase(request.getMode())) {
|
||||
throw new BusinessException(400, "模式参数错误,必须是 strict 或 lenient");
|
||||
}
|
||||
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
organizeService.organize(
|
||||
taskId,
|
||||
request.getSrcDir(),
|
||||
request.getDstDir(),
|
||||
request.getMode(),
|
||||
request.isExtractCover(),
|
||||
request.isExtractLyrics(),
|
||||
request.isGenerateReport()
|
||||
);
|
||||
|
||||
return Result.success(new OrganizeController.StartResponse(taskId));
|
||||
}
|
||||
|
||||
public static class StartResponse {
|
||||
private String taskId;
|
||||
|
||||
public StartResponse(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
12
backend/src/main/java/com/music/dto/ConfigRequest.java
Normal file
12
backend/src/main/java/com/music/dto/ConfigRequest.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@Data
|
||||
public class ConfigRequest {
|
||||
|
||||
@NotBlank(message = "根路径不能为空")
|
||||
private String basePath;
|
||||
}
|
||||
19
backend/src/main/java/com/music/dto/ConfigResponse.java
Normal file
19
backend/src/main/java/com/music/dto/ConfigResponse.java
Normal file
@@ -0,0 +1,19 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ConfigResponse {
|
||||
private String basePath;
|
||||
private String inputDir;
|
||||
private String aggregatedDir;
|
||||
private String formatIssuesDir;
|
||||
private String duplicatesDir;
|
||||
private String zhOutputDir;
|
||||
private String organizedDir;
|
||||
private String libraryFinalDir;
|
||||
}
|
||||
34
backend/src/main/java/com/music/dto/MergeRequest.java
Normal file
34
backend/src/main/java/com/music/dto/MergeRequest.java
Normal file
@@ -0,0 +1,34 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 整理入库任务请求
|
||||
*/
|
||||
@Data
|
||||
public class MergeRequest {
|
||||
|
||||
/**
|
||||
* 源目录(staging,已整理完成的目录)
|
||||
*/
|
||||
@NotBlank(message = "源目录不能为空")
|
||||
private String srcDir;
|
||||
|
||||
/**
|
||||
* 目标目录(Navidrome 主库根目录)
|
||||
*/
|
||||
@NotBlank(message = "目标目录不能为空")
|
||||
private String dstDir;
|
||||
|
||||
/**
|
||||
* 是否启用智能升级(文件大小优先策略)
|
||||
*/
|
||||
private boolean smartUpgrade = true;
|
||||
|
||||
/**
|
||||
* 是否保留旧版本备份
|
||||
*/
|
||||
private boolean keepBackup = false;
|
||||
}
|
||||
44
backend/src/main/java/com/music/dto/OrganizeRequest.java
Normal file
44
backend/src/main/java/com/music/dto/OrganizeRequest.java
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 音乐整理任务请求
|
||||
*/
|
||||
@Data
|
||||
public class OrganizeRequest {
|
||||
|
||||
/**
|
||||
* 源目录(staging,已汇聚/转码/去重后的目录)
|
||||
*/
|
||||
@NotBlank(message = "源目录不能为空")
|
||||
private String srcDir;
|
||||
|
||||
/**
|
||||
* 目标目录(规范输出目录或正式库)
|
||||
*/
|
||||
@NotBlank(message = "目标目录不能为空")
|
||||
private String dstDir;
|
||||
|
||||
/**
|
||||
* 标签完整度模式:strict 严格(需 Title+Artist+Album),lenient 宽松(仅需 Title)
|
||||
*/
|
||||
private String mode = "strict";
|
||||
|
||||
/**
|
||||
* 是否提取封面到专辑目录 cover.jpg
|
||||
*/
|
||||
private boolean extractCover = true;
|
||||
|
||||
/**
|
||||
* 是否提取内嵌歌词到 .lrc
|
||||
*/
|
||||
private boolean extractLyrics = true;
|
||||
|
||||
/**
|
||||
* 是否生成整理报告到 _Reports/
|
||||
*/
|
||||
private boolean generateReport = true;
|
||||
}
|
||||
213
backend/src/main/java/com/music/service/ConfigService.java
Normal file
213
backend/src/main/java/com/music/service/ConfigService.java
Normal file
@@ -0,0 +1,213 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.common.PathUtils;
|
||||
import com.music.dto.ConfigResponse;
|
||||
import com.music.exception.BusinessException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Properties;
|
||||
|
||||
/**
|
||||
* 配置服务类,负责保存和读取全局路径配置
|
||||
*/
|
||||
@Service
|
||||
public class ConfigService {
|
||||
|
||||
private static final String CONFIG_FILE_NAME = "config.properties";
|
||||
private static final String BASE_PATH_KEY = "basePath";
|
||||
|
||||
// 子目录名称常量
|
||||
private static final String INPUT_DIR = "Input";
|
||||
private static final String STAGING_AGGREGATED = "Staging_Aggregated";
|
||||
private static final String STAGING_FORMAT_ISSUES = "Staging_Format_Issues";
|
||||
private static final String STAGING_DUPLICATES = "Staging_Duplicates";
|
||||
private static final String STAGING_T2S_OUTPUT = "Staging_T2S_Output";
|
||||
private static final String STAGING_ORGANIZED = "Staging_Organized";
|
||||
private static final String LIBRARY_FINAL = "Library_Final";
|
||||
|
||||
/**
|
||||
* 获取配置文件路径
|
||||
*/
|
||||
private Path getConfigFilePath() {
|
||||
// 配置文件保存在用户目录下的 .mangtool 文件夹中
|
||||
String userHome = System.getProperty("user.home");
|
||||
Path configDir = Paths.get(userHome, ".mangtool");
|
||||
try {
|
||||
Files.createDirectories(configDir);
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException(500, "无法创建配置目录: " + e.getMessage());
|
||||
}
|
||||
return configDir.resolve(CONFIG_FILE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存基础路径配置
|
||||
*/
|
||||
public void saveBasePath(String basePath) {
|
||||
// 验证路径
|
||||
if (!PathUtils.isValidPath(basePath)) {
|
||||
throw new BusinessException(400, "路径包含非法字符,请检查");
|
||||
}
|
||||
|
||||
// 规范化路径并转换为POSIX格式(用于存储)
|
||||
String normalizedPath = PathUtils.normalizeAndToPosix(basePath);
|
||||
|
||||
// 验证路径是否存在(如果不存在,尝试创建)
|
||||
// 使用原始路径进行文件系统操作(Java会自动处理平台差异)
|
||||
Path pathObj = Paths.get(basePath);
|
||||
if (!Files.exists(pathObj)) {
|
||||
try {
|
||||
Files.createDirectories(pathObj);
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException(400, "无法创建根目录: " + e.getMessage());
|
||||
}
|
||||
} else if (!Files.isDirectory(pathObj)) {
|
||||
throw new BusinessException(400, "指定路径不是目录");
|
||||
}
|
||||
|
||||
// 创建所有子目录(使用原始路径)
|
||||
createSubDirectories(basePath);
|
||||
|
||||
// 保存配置到文件
|
||||
Properties props = new Properties();
|
||||
props.setProperty(BASE_PATH_KEY, normalizedPath);
|
||||
|
||||
Path configFile = getConfigFilePath();
|
||||
try {
|
||||
props.store(Files.newOutputStream(configFile), "MangTool Configuration");
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException(500, "保存配置失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础路径
|
||||
*/
|
||||
public String getBasePath() {
|
||||
Path configFile = getConfigFilePath();
|
||||
if (!Files.exists(configFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Properties props = new Properties();
|
||||
try {
|
||||
props.load(Files.newInputStream(configFile));
|
||||
return props.getProperty(BASE_PATH_KEY);
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException(500, "读取配置失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整配置响应(包含所有派生路径)
|
||||
*/
|
||||
public ConfigResponse getConfig() {
|
||||
String basePath = getBasePath();
|
||||
if (basePath == null || basePath.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
ConfigResponse response = new ConfigResponse();
|
||||
response.setBasePath(basePath);
|
||||
response.setInputDir(PathUtils.joinPath(basePath, INPUT_DIR));
|
||||
response.setAggregatedDir(PathUtils.joinPath(basePath, STAGING_AGGREGATED));
|
||||
response.setFormatIssuesDir(PathUtils.joinPath(basePath, STAGING_FORMAT_ISSUES));
|
||||
response.setDuplicatesDir(PathUtils.joinPath(basePath, STAGING_DUPLICATES));
|
||||
response.setZhOutputDir(PathUtils.joinPath(basePath, STAGING_T2S_OUTPUT));
|
||||
response.setOrganizedDir(PathUtils.joinPath(basePath, STAGING_ORGANIZED));
|
||||
response.setLibraryFinalDir(PathUtils.joinPath(basePath, LIBRARY_FINAL));
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建所有子目录
|
||||
*/
|
||||
private void createSubDirectories(String basePath) {
|
||||
String[] subDirs = {
|
||||
INPUT_DIR,
|
||||
STAGING_AGGREGATED,
|
||||
STAGING_FORMAT_ISSUES,
|
||||
STAGING_DUPLICATES,
|
||||
STAGING_T2S_OUTPUT,
|
||||
STAGING_ORGANIZED,
|
||||
LIBRARY_FINAL
|
||||
};
|
||||
|
||||
// 使用Java的Path API处理路径,自动适配平台
|
||||
Path basePathObj = Paths.get(basePath);
|
||||
for (String subDir : subDirs) {
|
||||
Path dirPath = basePathObj.resolve(subDir);
|
||||
try {
|
||||
Files.createDirectories(dirPath);
|
||||
} catch (IOException e) {
|
||||
throw new BusinessException(500,
|
||||
String.format("无法创建子目录 %s: %s", subDir, e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Input目录路径
|
||||
*/
|
||||
public String getInputDir() {
|
||||
return getDerivedPath(INPUT_DIR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Staging_Aggregated目录路径
|
||||
*/
|
||||
public String getAggregatedDir() {
|
||||
return getDerivedPath(STAGING_AGGREGATED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Staging_Format_Issues目录路径
|
||||
*/
|
||||
public String getFormatIssuesDir() {
|
||||
return getDerivedPath(STAGING_FORMAT_ISSUES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Staging_Duplicates目录路径
|
||||
*/
|
||||
public String getDuplicatesDir() {
|
||||
return getDerivedPath(STAGING_DUPLICATES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Staging_T2S_Output目录路径
|
||||
*/
|
||||
public String getZhOutputDir() {
|
||||
return getDerivedPath(STAGING_T2S_OUTPUT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Staging_Organized目录路径
|
||||
*/
|
||||
public String getOrganizedDir() {
|
||||
return getDerivedPath(STAGING_ORGANIZED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Library_Final目录路径
|
||||
*/
|
||||
public String getLibraryFinalDir() {
|
||||
return getDerivedPath(LIBRARY_FINAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取派生路径的通用方法
|
||||
*/
|
||||
private String getDerivedPath(String subDir) {
|
||||
String basePath = getBasePath();
|
||||
if (basePath == null || basePath.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return PathUtils.joinPath(basePath, subDir);
|
||||
}
|
||||
}
|
||||
350
backend/src/main/java/com/music/service/LibraryMergeService.java
Normal file
350
backend/src/main/java/com/music/service/LibraryMergeService.java
Normal file
@@ -0,0 +1,350 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 整理入库服务:将整理好的 staging 目录智能合并到 Navidrome 主库中
|
||||
*/
|
||||
@Service
|
||||
public class LibraryMergeService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LibraryMergeService.class);
|
||||
|
||||
private static final Set<String> AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus"
|
||||
));
|
||||
|
||||
private static final Set<String> LYRICS_EXTENSIONS = new HashSet<>(Arrays.asList("lrc"));
|
||||
private static final Set<String> COVER_NAMES = new HashSet<>(Arrays.asList("cover.jpg", "cover.png", "folder.jpg", "folder.png"));
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ProgressStore progressStore;
|
||||
|
||||
public LibraryMergeService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void merge(String taskId, String srcDir, String dstDir, boolean smartUpgrade, boolean keepBackup) {
|
||||
Path srcPath = Paths.get(srcDir);
|
||||
Path dstPath = Paths.get(dstDir);
|
||||
|
||||
try {
|
||||
// 基本校验
|
||||
if (srcDir == null || srcDir.trim().isEmpty()) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不能为空", true);
|
||||
return;
|
||||
}
|
||||
if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
|
||||
return;
|
||||
}
|
||||
if (dstDir == null || dstDir.trim().isEmpty()) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, null, "目标目录不能为空", true);
|
||||
return;
|
||||
}
|
||||
if (srcPath.normalize().equals(dstPath.normalize())) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Files.exists(dstPath)) {
|
||||
Files.createDirectories(dstPath);
|
||||
}
|
||||
|
||||
// 收集源目录中的所有文件(音频、歌词、封面)
|
||||
List<Path> audioFiles = new ArrayList<>();
|
||||
Map<Path, Path> lyricsMap = new HashMap<>(); // 音频文件 -> 歌词文件
|
||||
Map<Path, Path> coverMap = new HashMap<>(); // 专辑目录 -> 封面文件
|
||||
|
||||
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
String fileName = file.getFileName().toString().toLowerCase();
|
||||
String ext = getExtension(fileName);
|
||||
|
||||
if (isAudioFile(file)) {
|
||||
audioFiles.add(file);
|
||||
// 查找同目录下的歌词文件
|
||||
Path lyrics = findLyricsFile(file);
|
||||
if (lyrics != null) {
|
||||
lyricsMap.put(file, lyrics);
|
||||
}
|
||||
} else if (isCoverFile(fileName)) {
|
||||
// 封面文件,关联到父目录
|
||||
Path albumDir = file.getParent();
|
||||
if (albumDir != null) {
|
||||
coverMap.put(albumDir, file);
|
||||
}
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
int total = audioFiles.size();
|
||||
if (total == 0) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, null, "未在源目录中找到音频文件", true);
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicInteger processed = new AtomicInteger(0);
|
||||
AtomicInteger albumsMerged = new AtomicInteger(0);
|
||||
AtomicInteger tracksMerged = new AtomicInteger(0);
|
||||
AtomicInteger upgraded = new AtomicInteger(0);
|
||||
AtomicInteger skipped = new AtomicInteger(0);
|
||||
|
||||
Set<String> processedAlbums = new HashSet<>();
|
||||
|
||||
sendProgress(taskId, total, 0, 0, 0, 0, null, "开始合并...", false);
|
||||
|
||||
for (Path audioFile : audioFiles) {
|
||||
try {
|
||||
// 计算相对路径(相对于源目录)
|
||||
Path relativePath = srcPath.relativize(audioFile);
|
||||
Path targetPath = dstPath.resolve(relativePath);
|
||||
|
||||
// 获取专辑目录(用于统计和封面处理)
|
||||
Path albumDir = targetPath.getParent();
|
||||
String albumKey = albumDir != null ? albumDir.toString() : "";
|
||||
|
||||
boolean isNewAlbum = !processedAlbums.contains(albumKey);
|
||||
if (isNewAlbum) {
|
||||
processedAlbums.add(albumKey);
|
||||
albumsMerged.incrementAndGet();
|
||||
}
|
||||
|
||||
// 处理音频文件
|
||||
boolean wasUpgraded = false;
|
||||
if (Files.exists(targetPath)) {
|
||||
// 目标文件已存在,需要判断是否升级
|
||||
if (smartUpgrade) {
|
||||
long srcSize = Files.size(audioFile);
|
||||
long dstSize = Files.size(targetPath);
|
||||
if (srcSize > dstSize * 1.1) { // 新文件明显更大(10%阈值)
|
||||
// 备份旧文件(如果需要)
|
||||
if (keepBackup) {
|
||||
Path backupPath = targetPath.resolveSibling(
|
||||
targetPath.getFileName().toString() + ".backup");
|
||||
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
Files.copy(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
wasUpgraded = true;
|
||||
upgraded.incrementAndGet();
|
||||
} else {
|
||||
skipped.incrementAndGet();
|
||||
int p = processed.incrementAndGet();
|
||||
if (p % 20 == 0 || p == total) {
|
||||
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
|
||||
upgraded.get(), audioFile.getFileName().toString(),
|
||||
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
|
||||
}
|
||||
continue; // 跳过,不升级
|
||||
}
|
||||
} else {
|
||||
// 不启用智能升级,直接跳过已存在的文件
|
||||
skipped.incrementAndGet();
|
||||
int p = processed.incrementAndGet();
|
||||
if (p % 20 == 0 || p == total) {
|
||||
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
|
||||
upgraded.get(), audioFile.getFileName().toString(),
|
||||
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// 新文件,直接复制
|
||||
Files.createDirectories(targetPath.getParent());
|
||||
Files.copy(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
tracksMerged.incrementAndGet();
|
||||
|
||||
// 同步关联的歌词文件
|
||||
Path lyricsFile = lyricsMap.get(audioFile);
|
||||
if (lyricsFile != null && Files.exists(lyricsFile)) {
|
||||
Path lyricsTarget = targetPath.resolveSibling(lyricsFile.getFileName().toString());
|
||||
if (!Files.exists(lyricsTarget) || wasUpgraded) {
|
||||
Files.copy(lyricsFile, lyricsTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理封面(每个专辑目录只处理一次)
|
||||
if (isNewAlbum && albumDir != null) {
|
||||
Path srcAlbumDir = audioFile.getParent();
|
||||
Path coverFile = coverMap.get(srcAlbumDir);
|
||||
if (coverFile != null && Files.exists(coverFile)) {
|
||||
Path coverTarget = albumDir.resolve("cover.jpg");
|
||||
if (!Files.exists(coverTarget)) {
|
||||
// 目标目录没有封面,直接复制
|
||||
Files.copy(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
} else {
|
||||
// 比较封面,保留更好的版本
|
||||
if (isBetterCover(coverFile, coverTarget)) {
|
||||
if (keepBackup) {
|
||||
Path backupPath = coverTarget.resolveSibling("cover.jpg.backup");
|
||||
Files.copy(coverTarget, backupPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
Files.copy(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("合并文件失败: {} - {}", audioFile, e.getMessage());
|
||||
skipped.incrementAndGet();
|
||||
}
|
||||
|
||||
int p = processed.incrementAndGet();
|
||||
if (p % 20 == 0 || p == total) {
|
||||
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
|
||||
upgraded.get(), audioFile.getFileName().toString(),
|
||||
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(taskId, total, processed.get(), albumsMerged.get(), tracksMerged.get(),
|
||||
upgraded.get(), null,
|
||||
String.format("合并完成!专辑: %d, 曲目: %d, 升级: %d, 跳过: %d",
|
||||
albumsMerged.get(), tracksMerged.get(), upgraded.get(), skipped.get()),
|
||||
true);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("合并任务执行失败", e);
|
||||
sendProgress(taskId, 0, 0, 0, 0, 0, null, "任务执行失败: " + e.getMessage(), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
*/
|
||||
private boolean isAudioFile(Path file) {
|
||||
String ext = getExtension(file.getFileName().toString());
|
||||
return ext != null && AUDIO_EXTENSIONS.contains(ext.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为歌词文件
|
||||
*/
|
||||
private boolean isLyricsFile(String fileName) {
|
||||
String ext = getExtension(fileName);
|
||||
return ext != null && LYRICS_EXTENSIONS.contains(ext.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为封面文件
|
||||
*/
|
||||
private boolean isCoverFile(String fileName) {
|
||||
return COVER_NAMES.contains(fileName.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找音频文件对应的歌词文件
|
||||
*/
|
||||
private Path findLyricsFile(Path audioFile) {
|
||||
Path parent = audioFile.getParent();
|
||||
if (parent == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String baseName = getBaseName(audioFile.getFileName().toString());
|
||||
for (String ext : LYRICS_EXTENSIONS) {
|
||||
Path lyricsPath = parent.resolve(baseName + "." + ext);
|
||||
if (Files.exists(lyricsPath)) {
|
||||
return lyricsPath;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个封面文件,判断新封面是否更好
|
||||
* 策略:优先比较分辨率,其次比较文件大小
|
||||
*/
|
||||
private boolean isBetterCover(Path newCover, Path existingCover) {
|
||||
try {
|
||||
BufferedImage newImg = ImageIO.read(newCover.toFile());
|
||||
BufferedImage existingImg = ImageIO.read(existingCover.toFile());
|
||||
|
||||
if (newImg == null || existingImg == null) {
|
||||
// 无法读取图片,比较文件大小
|
||||
return Files.size(newCover) > Files.size(existingCover);
|
||||
}
|
||||
|
||||
int newPixels = newImg.getWidth() * newImg.getHeight();
|
||||
int existingPixels = existingImg.getWidth() * existingImg.getHeight();
|
||||
|
||||
if (newPixels > existingPixels * 1.1) {
|
||||
// 新封面分辨率明显更高(10%阈值)
|
||||
return true;
|
||||
} else if (newPixels < existingPixels * 0.9) {
|
||||
// 新封面分辨率明显更低
|
||||
return false;
|
||||
} else {
|
||||
// 分辨率相近,比较文件大小
|
||||
return Files.size(newCover) > Files.size(existingCover);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("比较封面失败: {} vs {}", newCover, existingCover, e);
|
||||
// 比较失败,使用文件大小作为后备策略
|
||||
try {
|
||||
return Files.size(newCover) > Files.size(existingCover);
|
||||
} catch (IOException ex) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
private String getExtension(String fileName) {
|
||||
int i = fileName.lastIndexOf('.');
|
||||
return (i > 0 && i < fileName.length() - 1) ? fileName.substring(i + 1) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件名(不含扩展名)
|
||||
*/
|
||||
private String getBaseName(String fileName) {
|
||||
int i = fileName.lastIndexOf('.');
|
||||
return i > 0 ? fileName.substring(0, i) : fileName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送进度消息
|
||||
*/
|
||||
private void sendProgress(String taskId, int total, int processed, int albumsMerged,
|
||||
int tracksMerged, int upgraded, String currentFile, String message, boolean completed) {
|
||||
try {
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId(taskId);
|
||||
pm.setType("merge");
|
||||
pm.setTotal(total);
|
||||
pm.setProcessed(processed);
|
||||
pm.setSuccess(albumsMerged); // 使用 success 字段存储专辑数
|
||||
pm.setFailed(tracksMerged); // 使用 failed 字段存储曲目数
|
||||
pm.setCurrentFile(currentFile);
|
||||
pm.setMessage(message);
|
||||
pm.setCompleted(completed);
|
||||
progressStore.put(pm);
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
|
||||
} catch (Exception e) {
|
||||
log.error("发送进度失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
473
backend/src/main/java/com/music/service/OrganizeService.java
Normal file
473
backend/src/main/java/com/music/service/OrganizeService.java
Normal file
@@ -0,0 +1,473 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import net.sourceforge.pinyin4j.PinyinHelper;
|
||||
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
|
||||
import net.sourceforge.pinyin4j.format.HanyuPinyinOutputFormat;
|
||||
import net.sourceforge.pinyin4j.format.HanyuPinyinToneType;
|
||||
import net.sourceforge.pinyin4j.format.exception.BadHanyuPinyinOutputFormatCombination;
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.jaudiotagger.audio.flac.metadatablock.MetadataBlockDataPicture;
|
||||
import org.jaudiotagger.tag.Tag;
|
||||
import org.jaudiotagger.tag.FieldKey;
|
||||
import org.jaudiotagger.tag.flac.FlacTag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 音乐整理服务:按 Navidrome 规范重命名、A-Z 分组、封面/歌词提取、整理报告。
|
||||
*/
|
||||
@Service
|
||||
public class OrganizeService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OrganizeService.class);
|
||||
|
||||
private static final Set<String> AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus"
|
||||
));
|
||||
|
||||
private static final String VARIOUS_ARTISTS = "Various Artists";
|
||||
|
||||
/** 视为合辑(Various Artists)的 Album Artist / Artist 等价词,不区分大小写 */
|
||||
private static final Set<String> VARIOUS_ARTISTS_ALIASES = new HashSet<>(Arrays.asList(
|
||||
"Various Artists", "Various", "VA", "合辑", "群星", "群星荟萃", "杂锦", "合輯", "群星薈萃"
|
||||
));
|
||||
|
||||
private static final Pattern INVALID_FILENAME = Pattern.compile("[\\\\/:*?\"<>|]");
|
||||
private static final Pattern ARTIST_SEP = Pattern.compile("[;;]");
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ProgressStore progressStore;
|
||||
private final HanyuPinyinOutputFormat pinyinFormat;
|
||||
|
||||
public OrganizeService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
this.pinyinFormat = new HanyuPinyinOutputFormat();
|
||||
this.pinyinFormat.setCaseType(HanyuPinyinCaseType.UPPERCASE);
|
||||
this.pinyinFormat.setToneType(HanyuPinyinToneType.WITHOUT_TONE);
|
||||
}
|
||||
|
||||
@Async
|
||||
public void organize(String taskId, String srcDir, String dstDir, String mode,
|
||||
boolean extractCover, boolean extractLyrics, boolean generateReport) {
|
||||
Path srcPath = Paths.get(srcDir);
|
||||
Path dstPath = Paths.get(dstDir);
|
||||
boolean strict = "strict".equalsIgnoreCase(mode);
|
||||
|
||||
try {
|
||||
if (srcDir == null || srcDir.trim().isEmpty()) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "源目录不能为空", true);
|
||||
return;
|
||||
}
|
||||
if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
|
||||
return;
|
||||
}
|
||||
if (dstDir == null || dstDir.trim().isEmpty()) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "目标目录不能为空", true);
|
||||
return;
|
||||
}
|
||||
if (srcPath.normalize().equals(dstPath.normalize())) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Files.exists(dstPath)) {
|
||||
Files.createDirectories(dstPath);
|
||||
}
|
||||
|
||||
List<Path> audioFiles = new ArrayList<>();
|
||||
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (isAudioFile(file)) audioFiles.add(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
int total = audioFiles.size();
|
||||
if (total == 0) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "未在源目录中找到音频文件", true);
|
||||
return;
|
||||
}
|
||||
|
||||
Path manualRoot = dstPath.resolve("_Manual_Fix_Required_");
|
||||
Path reportsDir = dstPath.resolve("_Reports");
|
||||
Files.createDirectories(manualRoot);
|
||||
if (generateReport) Files.createDirectories(reportsDir);
|
||||
|
||||
AtomicInteger processed = new AtomicInteger(0);
|
||||
AtomicInteger organized = new AtomicInteger(0);
|
||||
AtomicInteger manualFix = new AtomicInteger(0);
|
||||
|
||||
Set<String> albumsWithoutCover = new HashSet<>();
|
||||
Set<String> albumsWithCover = new HashSet<>();
|
||||
List<String> filesWithoutLyrics = new ArrayList<>();
|
||||
Set<String> albumKeys = new HashSet<>();
|
||||
|
||||
sendProgress(taskId, total, 0, 0, 0, null, "开始整理...", false);
|
||||
|
||||
for (Path file : audioFiles) {
|
||||
String fileName = file.getFileName().toString();
|
||||
try {
|
||||
AudioFile af = AudioFileIO.read(file.toFile());
|
||||
Tag tag = af.getTag();
|
||||
if (tag == null) {
|
||||
moveToManualFix(file, manualRoot, "Missing_Tags", fileName, manualFix);
|
||||
int p = processed.incrementAndGet();
|
||||
if (p % 20 == 0 || p == total) {
|
||||
sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName,
|
||||
String.format("已处理 %d/%d", p, total), false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
String title = trim(tag.getFirst(FieldKey.TITLE));
|
||||
String artist = trim(tag.getFirst(FieldKey.ARTIST));
|
||||
String album = trim(tag.getFirst(FieldKey.ALBUM));
|
||||
String albumArtist = trim(tag.getFirst(FieldKey.ALBUM_ARTIST));
|
||||
String date = trim(tag.getFirst(FieldKey.YEAR));
|
||||
|
||||
if (strict && (title.isEmpty() || artist.isEmpty() || album.isEmpty())) {
|
||||
moveToManualFix(file, manualRoot, "Missing_Incomplete", fileName, manualFix);
|
||||
int p = processed.incrementAndGet();
|
||||
if (p % 20 == 0 || p == total) {
|
||||
sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName,
|
||||
String.format("已处理 %d/%d", p, total), false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (title.isEmpty()) {
|
||||
moveToManualFix(file, manualRoot, "Missing_Title", fileName, manualFix);
|
||||
int p = processed.incrementAndGet();
|
||||
if (p % 20 == 0 || p == total) {
|
||||
sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName,
|
||||
String.format("已处理 %d/%d", p, total), false);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
String effectiveArtist = firstNonEmpty(albumArtist, artist);
|
||||
boolean isVarious = isVariousArtist(effectiveArtist);
|
||||
if (isVarious) effectiveArtist = VARIOUS_ARTISTS;
|
||||
if (artist.isEmpty() && !isVarious) artist = effectiveArtist;
|
||||
if (album.isEmpty()) album = "Unknown Album";
|
||||
|
||||
// 多艺术家(A;B;C)时仅用第一个做 A-Z 与目录名,避免过长路径
|
||||
String artistForPath = isVarious ? VARIOUS_ARTISTS : firstArtist(effectiveArtist);
|
||||
String index = isVarious ? "" : indexLetter(artistForPath);
|
||||
String year = yearFromDate(date);
|
||||
String track = trackFromTag(tag);
|
||||
String ext = ext(fileName);
|
||||
String safeTitle = sanitize(title);
|
||||
String safeArtist = sanitize(artistForPath);
|
||||
String safeAlbum = sanitize(album);
|
||||
|
||||
Path destDir;
|
||||
String destFileName;
|
||||
if (isVarious) {
|
||||
destDir = dstPath.resolve(VARIOUS_ARTISTS).resolve(safeAlbum + (year.isEmpty() ? "" : "(" + year + ")"));
|
||||
destFileName = track + " - " + safeTitle + "." + ext;
|
||||
} else {
|
||||
Path indexPath = index.isEmpty() ? dstPath : dstPath.resolve(index);
|
||||
destDir = indexPath.resolve(safeArtist).resolve(safeAlbum + (year.isEmpty() ? "" : "(" + year + ")"));
|
||||
destFileName = track + " - " + safeTitle + "." + ext;
|
||||
}
|
||||
|
||||
Files.createDirectories(destDir);
|
||||
Path destFile = resolveTargetFile(destDir, destFileName);
|
||||
Files.move(file, destFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
organized.incrementAndGet();
|
||||
|
||||
String albumKey = (isVarious ? VARIOUS_ARTISTS : index + "|" + safeArtist) + "|" + safeAlbum + "|" + year;
|
||||
albumKeys.add(albumKey);
|
||||
|
||||
if (extractCover) {
|
||||
if (!albumsWithCover.contains(albumKey)) {
|
||||
byte[] cover = extractCoverData(af, tag);
|
||||
if (cover != null && cover.length > 0) {
|
||||
Path coverPath = destDir.resolve("cover.jpg");
|
||||
writeCoverImage(cover, coverPath);
|
||||
albumsWithCover.add(albumKey);
|
||||
albumsWithoutCover.remove(albumKey);
|
||||
} else {
|
||||
albumsWithoutCover.add(albumKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (extractLyrics) {
|
||||
String lyrics = extractLyrics(tag, af);
|
||||
if (lyrics != null && !lyrics.trim().isEmpty()) {
|
||||
String base = baseName(destFileName);
|
||||
Path lrcPath = destDir.resolve(base + ".lrc");
|
||||
Files.write(lrcPath, lyrics.getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
filesWithoutLyrics.add(destDir.relativize(destFile).toString());
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("整理失败: {} - {}", file, e.getMessage());
|
||||
moveToManualFix(file, manualRoot, "Error", fileName, manualFix);
|
||||
}
|
||||
|
||||
int p = processed.incrementAndGet();
|
||||
if (p % 20 == 0 || p == total) {
|
||||
sendProgress(taskId, total, p, organized.get(), manualFix.get(), fileName,
|
||||
String.format("已处理 %d/%d", p, total), false);
|
||||
}
|
||||
}
|
||||
|
||||
if (generateReport) {
|
||||
Path reportPath = reportsDir.resolve("report_" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + ".txt");
|
||||
writeReport(reportPath, total, organized.get(), manualFix.get(), albumKeys.size(),
|
||||
albumsWithoutCover, filesWithoutLyrics, extractCover, extractLyrics);
|
||||
}
|
||||
|
||||
sendProgress(taskId, total, processed.get(), organized.get(), manualFix.get(), null,
|
||||
String.format("整理完成!成功: %d, 需人工修复: %d", organized.get(), manualFix.get()), true);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("整理任务执行失败", e);
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "任务执行失败: " + e.getMessage(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private void moveToManualFix(Path file, Path manualRoot, String reason, String fileName,
|
||||
AtomicInteger manualFix) {
|
||||
try {
|
||||
Path sub = manualRoot.resolve(reason);
|
||||
Files.createDirectories(sub);
|
||||
Path target = resolveTargetFile(sub, fileName);
|
||||
Files.move(file, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
manualFix.incrementAndGet();
|
||||
} catch (IOException e) {
|
||||
log.warn("移动至人工修复目录失败: {} - {}", file, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String indexLetter(String name) {
|
||||
if (name == null || name.isEmpty()) return "#";
|
||||
String s = name.trim();
|
||||
char first = s.charAt(0);
|
||||
if (Character.isLetter(first)) {
|
||||
if (first >= 'a' && first <= 'z') return String.valueOf((char) (first - 32));
|
||||
if (first >= 'A' && first <= 'Z') return String.valueOf(first);
|
||||
}
|
||||
if (Character.isDigit(first) || !Character.isLetterOrDigit(first)) return "#";
|
||||
if (first >= 0x4E00 && first <= 0x9FA5) {
|
||||
try {
|
||||
String[] py = PinyinHelper.toHanyuPinyinStringArray(first, pinyinFormat);
|
||||
if (py != null && py[0] != null && !py[0].isEmpty()) {
|
||||
char c = py[0].charAt(0);
|
||||
return String.valueOf(Character.toUpperCase(c));
|
||||
}
|
||||
} catch (BadHanyuPinyinOutputFormatCombination ignored) { }
|
||||
}
|
||||
return "#";
|
||||
}
|
||||
|
||||
private String yearFromDate(String date) {
|
||||
if (date == null || date.isEmpty()) return "";
|
||||
for (int i = 0; i < date.length(); i++) {
|
||||
if (Character.isDigit(date.charAt(i))) {
|
||||
int end = Math.min(i + 4, date.length());
|
||||
String y = date.substring(i, end);
|
||||
if (y.length() == 4) return y;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String trackFromTag(Tag tag) {
|
||||
String t = trim(tag.getFirst(FieldKey.TRACK));
|
||||
if (t.isEmpty()) return "01";
|
||||
int n;
|
||||
try {
|
||||
n = Integer.parseInt(t.replaceAll("\\D", ""));
|
||||
} catch (NumberFormatException e) {
|
||||
return "01";
|
||||
}
|
||||
if (n < 1) return "01";
|
||||
return n < 10 ? "0" + n : (n < 100 ? String.valueOf(n) : String.format("%03d", n));
|
||||
}
|
||||
|
||||
private byte[] extractCoverData(AudioFile af, Tag tag) {
|
||||
try {
|
||||
if (tag instanceof FlacTag) {
|
||||
FlacTag ft = (FlacTag) tag;
|
||||
List<MetadataBlockDataPicture> imgs = ft.getImages();
|
||||
if (imgs != null && !imgs.isEmpty()) {
|
||||
return imgs.get(0).getImageData();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Object a = tag.getFirstArtwork();
|
||||
if (a != null) {
|
||||
java.lang.reflect.Method m = a.getClass().getMethod("getBinaryData");
|
||||
Object raw = m.invoke(a);
|
||||
if (raw instanceof byte[]) {
|
||||
byte[] data = (byte[]) raw;
|
||||
if (data.length > 0) return data;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("提取封面失败: {}", af.getFile().getName(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void writeCoverImage(byte[] data, Path path) {
|
||||
try (ByteArrayInputStream bis = new ByteArrayInputStream(data)) {
|
||||
BufferedImage img = ImageIO.read(bis);
|
||||
if (img != null) ImageIO.write(img, "jpg", path.toFile());
|
||||
} catch (IOException e) {
|
||||
log.warn("写入封面失败: {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
private String extractLyrics(Tag tag, AudioFile af) {
|
||||
try {
|
||||
for (String key : new String[] { "LYRICS", "UNSYNCED LYRICS" }) {
|
||||
if (tag.hasField(key)) {
|
||||
String v = tag.getFirst(key);
|
||||
if (v != null && !v.trim().isEmpty()) return v;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("读取歌词失败: {}", af.getFile().getName(), e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void writeReport(Path path, int scanned, int organized, int manualFix, int albumCount,
|
||||
Set<String> noCover, List<String> noLyrics, boolean extractCover, boolean extractLyrics) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("音乐整理报告\n");
|
||||
sb.append("生成时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("\n");
|
||||
sb.append("扫描文件数: ").append(scanned).append("\n");
|
||||
sb.append("整理成功: ").append(organized).append("\n");
|
||||
sb.append("需人工修复: ").append(manualFix).append("\n");
|
||||
sb.append("总专辑数: ").append(albumCount).append("\n");
|
||||
if (extractCover && !noCover.isEmpty()) {
|
||||
sb.append("\n缺失封面的专辑:\n");
|
||||
for (String a : new TreeSet<>(noCover)) sb.append(" - ").append(a).append("\n");
|
||||
sb.append("无封面专辑数: ").append(noCover.size()).append(" (").append(albumCount > 0 ? String.format("%.1f", 100.0 * noCover.size() / albumCount) : "0").append("%)\n");
|
||||
}
|
||||
if (extractLyrics && !noLyrics.isEmpty()) {
|
||||
sb.append("\n缺失歌词的文件:\n");
|
||||
for (String f : noLyrics) sb.append(" - ").append(f).append("\n");
|
||||
sb.append("无歌词文件数: ").append(noLyrics.size()).append(" (").append(organized > 0 ? String.format("%.1f", 100.0 * noLyrics.size() / organized) : "0").append("%)\n");
|
||||
}
|
||||
try {
|
||||
Files.write(path, sb.toString().getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
log.warn("写入整理报告失败: {}", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitize(String s) {
|
||||
if (s == null) return "";
|
||||
return INVALID_FILENAME.matcher(s.trim()).replaceAll("_").replaceAll("\\s+", " ").trim();
|
||||
}
|
||||
|
||||
private String trim(String s) {
|
||||
return s == null ? "" : s.trim();
|
||||
}
|
||||
|
||||
private String firstNonEmpty(String... ss) {
|
||||
for (String s : ss) if (s != null && !s.trim().isEmpty()) return s.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
/** 是否为合辑(Various Artists):与等价词集合匹配,不区分大小写 */
|
||||
private boolean isVariousArtist(String s) {
|
||||
if (s == null || s.trim().isEmpty()) return false;
|
||||
String n = s.trim();
|
||||
for (String alias : VARIOUS_ARTISTS_ALIASES) {
|
||||
if (alias.equalsIgnoreCase(n)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 多艺术家(A;B;C 或 A;B;C)时取第一个,用于 A-Z 索引与目录名 */
|
||||
private String firstArtist(String s) {
|
||||
if (s == null || s.trim().isEmpty()) return "";
|
||||
String[] parts = ARTIST_SEP.split(s.trim());
|
||||
for (String p : parts) {
|
||||
String t = p.trim();
|
||||
if (!t.isEmpty()) return t;
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
private boolean isAudioFile(Path f) {
|
||||
String ext = ext(f.getFileName().toString());
|
||||
return ext != null && AUDIO_EXTENSIONS.contains(ext.toLowerCase());
|
||||
}
|
||||
|
||||
private String ext(String name) {
|
||||
int i = name.lastIndexOf('.');
|
||||
return (i > 0 && i < name.length() - 1) ? name.substring(i + 1) : "";
|
||||
}
|
||||
|
||||
private String baseName(String name) {
|
||||
int i = name.lastIndexOf('.');
|
||||
return i > 0 ? name.substring(0, i) : name;
|
||||
}
|
||||
|
||||
private Path resolveTargetFile(Path dir, String fileName) throws IOException {
|
||||
Path p = dir.resolve(fileName);
|
||||
if (!Files.exists(p)) return p;
|
||||
String base = baseName(fileName);
|
||||
String ext = ext(fileName);
|
||||
String suf = ext.isEmpty() ? "" : "." + ext;
|
||||
int n = 1;
|
||||
while (Files.exists(p)) {
|
||||
p = dir.resolve(base + " (" + n + ")" + suf);
|
||||
n++;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private void sendProgress(String taskId, int total, int processed, int success, int failed,
|
||||
String currentFile, String message, boolean completed) {
|
||||
try {
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId(taskId);
|
||||
pm.setType("organize");
|
||||
pm.setTotal(total);
|
||||
pm.setProcessed(processed);
|
||||
pm.setSuccess(success);
|
||||
pm.setFailed(failed);
|
||||
pm.setCurrentFile(currentFile);
|
||||
pm.setMessage(message);
|
||||
pm.setCompleted(completed);
|
||||
progressStore.put(pm);
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
|
||||
} catch (Exception e) {
|
||||
log.error("发送进度失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
@@ -115,11 +116,19 @@ public class ZhConvertService {
|
||||
for (Path srcFile : audioFiles) {
|
||||
String fileName = srcFile.getFileName().toString();
|
||||
try {
|
||||
Path targetFile = resolveTargetFileForExecute(srcFile, outputPath, execute);
|
||||
|
||||
int addedEntries = handleSingleFile(targetFile, thresholdRatio, execute);
|
||||
if (addedEntries > 0) {
|
||||
traditionalEntries.addAndGet(addedEntries);
|
||||
// 先检查文件是否包含繁体(预览模式检测)
|
||||
int previewEntries = checkFileHasTraditional(srcFile, thresholdRatio);
|
||||
|
||||
// 只有包含繁体的文件才需要处理
|
||||
if (previewEntries > 0) {
|
||||
// 执行模式下,需要移动到输出目录(如果配置了输出目录)
|
||||
Path targetFile = resolveTargetFileForExecute(srcFile, scanPath, outputPath, execute);
|
||||
|
||||
// 处理文件(转换繁体为简体)
|
||||
int addedEntries = handleSingleFile(targetFile, thresholdRatio, execute);
|
||||
if (addedEntries > 0) {
|
||||
traditionalEntries.addAndGet(addedEntries);
|
||||
}
|
||||
}
|
||||
|
||||
int currentProcessed = processed.incrementAndGet();
|
||||
@@ -162,6 +171,50 @@ public class ZhConvertService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否包含繁体(仅检测,不修改)
|
||||
*
|
||||
* @return 检测到的繁体标签条目数量(字段级)
|
||||
*/
|
||||
private int checkFileHasTraditional(Path file, double thresholdRatio) throws Exception {
|
||||
AudioFile audioFile = AudioFileIO.read(file.toFile());
|
||||
Tag tag = audioFile.getTag();
|
||||
if (tag == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int entries = 0;
|
||||
entries += checkFieldHasTraditional(tag, FieldKey.TITLE, thresholdRatio);
|
||||
entries += checkFieldHasTraditional(tag, FieldKey.ARTIST, thresholdRatio);
|
||||
entries += checkFieldHasTraditional(tag, FieldKey.ALBUM, thresholdRatio);
|
||||
entries += checkFieldHasTraditional(tag, FieldKey.ALBUM_ARTIST, thresholdRatio);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个标签字段是否包含繁体(仅检测,不修改)
|
||||
*
|
||||
* @return 如果该字段触发阈值,则返回 1,否则返回 0
|
||||
*/
|
||||
private int checkFieldHasTraditional(Tag tag, FieldKey fieldKey, double thresholdRatio) {
|
||||
String original = tag.getFirst(fieldKey);
|
||||
if (original == null || original.trim().isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ZhStats stats = traditionalFilterService.analyze(original);
|
||||
if (stats.getTraditionalCount() <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (stats.getRatio() < thresholdRatio) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个音频文件
|
||||
*
|
||||
@@ -241,18 +294,36 @@ public class ZhConvertService {
|
||||
* - 预览模式:始终返回原文件路径
|
||||
* - 执行模式:
|
||||
* - 未配置输出目录:在原文件上就地修改
|
||||
* - 配置了输出目录:先复制到输出目录,再在副本上修改
|
||||
* - 配置了输出目录:移动到输出目录(保持相对路径结构),再在移动后的文件上修改
|
||||
*
|
||||
* 注意:此方法只在确认文件包含繁体后才调用,确保只有包含繁体的文件才会被移动到输出目录
|
||||
*/
|
||||
private Path resolveTargetFileForExecute(Path srcFile,
|
||||
Path scanPath,
|
||||
Path outputPath,
|
||||
boolean execute) throws Exception {
|
||||
if (!execute || outputPath == null) {
|
||||
return srcFile;
|
||||
}
|
||||
|
||||
Path targetFile = outputPath.resolve(srcFile.getFileName());
|
||||
if (!Files.exists(targetFile)) {
|
||||
Files.copy(srcFile, targetFile, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
// 计算源文件相对于扫描目录的相对路径,保持目录结构
|
||||
Path relativePath = scanPath.relativize(srcFile);
|
||||
Path targetFile = outputPath.resolve(relativePath);
|
||||
|
||||
// 确保目标文件的父目录存在
|
||||
Path targetParent = targetFile.getParent();
|
||||
if (targetParent != null && !Files.exists(targetParent)) {
|
||||
Files.createDirectories(targetParent);
|
||||
}
|
||||
|
||||
// 移动源文件到目标位置(因为已经确认文件包含繁体,需要过滤出来处理)
|
||||
// 如果目标文件已存在,则覆盖(可能是重复处理的情况)
|
||||
try {
|
||||
// 尝试原子移动(在同一文件系统上更快更安全)
|
||||
Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException e) {
|
||||
// 如果原子移动失败(例如跨分区),回退到普通移动
|
||||
Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
30
frontend/src/api/config.ts
Normal file
30
frontend/src/api/config.ts
Normal 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
19
frontend/src/api/merge.ts
Normal 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);
|
||||
}
|
||||
21
frontend/src/api/organize.ts
Normal file
21
frontend/src/api/organize.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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% < 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;
|
||||
|
||||
Reference in New Issue
Block a user