Improve music processing robustness and workflow UX

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

166
AGENTS.md Normal file
View File

@@ -0,0 +1,166 @@
# AGENTS 指南MyTool
本文件面向在本仓库内工作的自动化 coding agents目标是快速、安全地完成任务并保持项目一致性。
## 1. 仓库概览
- `backend/`Spring Boot 2.7 + Java 8。
- `frontend/`Vue 3 + Vite + TypeScript + Element Plus。
- `docker/`:单容器部署脚本与镜像构建。
- 生产环境为同源部署:后端托管前端构建产物。
## 2. Cursor / Copilot 规则检查
- 已检查 `.cursor/rules/``.cursorrules``.github/copilot-instructions.md`
- 当前仓库未发现上述规则文件。
- 结论:请以本 `AGENTS.md` 作为主要执行规范。
## 3. 构建 / 运行 / Lint / 测试命令
### 3.1 前端(在 `frontend/` 目录执行)
安装依赖:
```bash
npm ci
```
本地开发:
```bash
npm run dev
```
生产构建:
```bash
npm run build
```
预览构建:
```bash
npm run preview
```
Lint当前为占位脚本
```bash
npm run lint
```
说明:当前 `lint` 实际输出 `no linter configured yet`,不是强校验。
### 3.2 后端(在 `backend/` 目录执行)
编译:
```bash
mvn clean compile
```
打包:
```bash
mvn clean package
```
运行服务:
```bash
mvn spring-boot:run
```
运行全部测试:
```bash
mvn test
```
运行单个测试类(重点):
```bash
mvn -Dtest=SomeServiceTest test
```
运行单个测试方法(重点):
```bash
mvn -Dtest=SomeServiceTest#shouldHandleEdgeCase test
```
跳过测试打包Docker 构建同策略):
```bash
mvn clean package -DskipTests
```
### 3.3 Docker在仓库根目录或 `docker/`
启动:
```bash
cd docker && docker compose up -d --build
```
日志:
```bash
cd docker && docker compose logs -f
```
停止:
```bash
cd docker && docker compose down
```
## 4. 测试现状
- `backend/src/test` 已有基础测试(如 `FileTransferUtilsTest``DedupServiceInternalTest``ConvertServiceInternalTest``TraditionalFilterServiceTest``ProgressStoreTest`)。
- 前端未配置 Vitest/Jest暂无 `*.spec.ts` / `*.test.ts`
- 新增功能时,优先补后端单测,至少覆盖核心服务分支。
- 若引入前端测试框架,需同步更新 `package.json` 与本文件命令。
## 5. 后端代码规范Java / Spring
### 5.1 分层与职责
- 根包固定为 `com.music`
- 目录分层遵循 `controller` / `service` / `dto` / `config` / `common` / `exception`
- Controller 负责参数校验与编排,不写重业务逻辑。
- Service 负责核心业务流程,异常要转为可读的业务失败信息。
### 5.2 命名规范
- 类名:`PascalCase`(如 `ConvertService``GlobalExceptionHandler`)。
- 方法/字段:`camelCase`
- 常量:`UPPER_SNAKE_CASE`(如 `LOSSLESS_EXTENSIONS`)。
- DTO 命名:`XxxRequest` / `XxxResponse`
### 5.3 导入、格式、语言特性
- 使用显式 import避免 `*` 通配符导入。
- 4 空格缩进K&R 花括号风格,保持与现有代码一致。
- 保持 Java 8 兼容,不引入高版本语法。
- 文件编码 UTF-8。
### 5.4 参数校验与类型
- 接口入参使用 DTO并配合 `@Valid``@NotBlank` 等注解。
- 业务模式值(如 `copy`/`move`)做显式校验,错误走业务异常。
- 路径、文件系统相关逻辑先校验再执行,避免抛裸异常到接口层。
### 5.5 错误处理与返回结构
- 统一返回 `Result<T>`:成功 `Result.success(...)`,失败 `Result.failure(...)`
- 业务异常使用 `BusinessException(code, message)`
- 全局异常由 `GlobalExceptionHandler` 兜底处理。
- 不向前端暴露堆栈或底层实现细节,错误信息保持可读。
- 异步任务失败需通过进度消息体现失败状态与原因。
## 6. 前端代码规范Vue 3 + TypeScript
### 6.1 组织与分层
- 页面主逻辑放在 `src/components/*Tab.vue`
- 请求封装放在 `src/api/*.ts`,组件内不要直接拼 axios 细节。
- WebSocket 逻辑集中在 `src/composables/useWebSocket.ts`
- 全局入口保持在 `src/main.ts`,避免散落初始化逻辑。
### 6.2 命名与类型
- 组件文件使用 `PascalCase.vue`
- 组合式函数使用 `useXxx`
- TS 接口和类型用 `PascalCase`
- 变量和函数用 `camelCase`
- 避免 `any`;优先显式接口、联合类型或 `unknown` + 类型收窄。
### 6.3 编码风格
- 使用 ESM 导入,默认相对路径。
- 保持单引号、分号、2 空格缩进。
- 默认使用 `<script setup lang="ts">`
- 组件样式默认 `scoped`,仅在必要场景使用 `:deep(...)`
### 6.4 状态与异常处理
- 表单对象优先 `reactive`,标量状态优先 `ref`
- 接口返回默认遵循后端 `Result<T>`,通过 `src/api/request.ts` 统一解包。
- 用户可恢复错误通过 `ElMessage.warning/error` 提示。
- 长任务遵循“WebSocket 实时 + 轮询兜底”模式,注意组件卸载时清理连接与定时器。
## 7. 代理工作约束
- 变更前先确认影响层次:后端 DTO/Controller/Service 与前端 api/组件是否要同步。
- 新增后端接口优先沿用 `/api/...` 风格与现有响应结构。
- 不随意引入新框架或重型依赖状态管理、UI 库、构建链)。
- 新增命令、脚本、测试框架时,务必更新本文件。
- 提交前至少执行受影响模块的构建或测试命令。
## 8. 提交前快速检查
- 是否保持 `Result<T>` + 统一异常处理链路?
- 是否保持前后端字段命名和类型一致(尤其进度消息)?
- 是否避免在组件中堆叠网络协议细节?
- 是否给出最小可执行验证命令build/test
- 是否同步更新文档与脚本说明?
---
若后续新增 Cursor/Copilot 规则文件,请将关键约束合并到本文件,避免规范冲突。

View File

@@ -72,6 +72,13 @@
<artifactId>pinyin4j</artifactId>
<version>2.5.1</version>
</dependency>
<!-- OpenCC Java 实现:繁简转换(词典驱动,减少手工维护成本) -->
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>opencc4j</artifactId>
<version>1.13.1</version>
</dependency>
</dependencies>
<build>
@@ -93,4 +100,3 @@
</plugins>
</build>
</project>

View File

@@ -0,0 +1,43 @@
package com.music.common;
import java.io.IOException;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
/**
* 文件传输工具:提供跨文件系统安全移动能力。
*/
public final class FileTransferUtils {
private FileTransferUtils() {
}
/**
* 安全移动文件:优先原子移动,失败后回退普通移动,再回退 copy + delete。
*/
public static void moveWithFallback(Path source, Path target) throws IOException {
Path parent = target.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
try {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
return;
} catch (AtomicMoveNotSupportedException ignored) {
// 跨文件系统常见,继续回退
}
try {
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
return;
} catch (IOException ignored) {
// 继续回退到 copy + delete
}
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
Files.delete(source);
}
}

View File

@@ -14,5 +14,19 @@ public class ProgressMessage {
private String currentFile; // 当前处理文件
private String message; // 进度消息
private boolean completed; // 是否完成
}
// 以下为按任务类型细化后的标准化字段,前端可优先读取
private Integer duplicateGroups; // dedup: 重复组数量
private Integer movedFiles; // dedup: 移动/复制的重复文件数量
private Integer organizedFiles; // organize: 整理成功数量
private Integer manualFixFiles; // organize: 需人工修复数量
private Integer traditionalEntries; // zhconvert: 繁体标签条目数量
private Integer failedFiles; // zhconvert: 失败文件数量
private Integer albumsMerged; // merge: 已合并专辑数
private Integer tracksMerged; // merge: 已合并曲目数
private Integer upgradedFiles; // merge: 升级替换文件数
private Integer skippedFiles; // merge: 跳过文件数
}

View File

@@ -24,7 +24,7 @@ public class ZhConvertRequest {
* - 预览模式:忽略该字段
* - 执行模式:
* - 为空:在原文件上就地修改标签
* - 非空:在该目录下生成一份带简体标签的副本
* - 非空:将包含繁体标签的文件移动到该目录(保留相对路径)后再修改标签
*/
private String outputDir;
@@ -41,4 +41,3 @@ public class ZhConvertRequest {
@NotBlank(message = "处理模式不能为空")
private String mode;
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.scheduling.annotation.Async;
@@ -74,7 +75,7 @@ public class AggregatorService {
if ("move".equalsIgnoreCase(mode)) {
// 移动模式
Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(sourceFile, targetFile);
} else {
// 复制模式
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);

View File

@@ -16,6 +16,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.TimeUnit;
@Service
public class ConvertService {
@@ -33,6 +34,9 @@ public class ConvertService {
));
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
private static final int FFMPEG_CHECK_TIMEOUT_SECONDS = 10;
private static final int FFMPEG_CONVERT_TIMEOUT_SECONDS = 600;
private static final String FFMPEG_BIN_PROPERTY = "mangtool.ffmpeg.bin";
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
@@ -77,6 +81,13 @@ public class ConvertService {
Files.createDirectories(dstPath);
}
// 预检查 ffmpeg 可用性
String ffmpegCheckError = checkFfmpegAvailable();
if (ffmpegCheckError != null) {
sendProgress(taskId, 0, 0, 0, 0, null, ffmpegCheckError, true);
return;
}
// 扫描输入目录,查找需要转换的文件
List<Path> toConvert = new ArrayList<>();
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
@@ -128,10 +139,11 @@ public class ConvertService {
"已处理: " + fileName, false);
} catch (Exception e) {
failed.incrementAndGet();
log.warn("转码失败: {} - {}", fileName, e.getMessage());
String reason = classifyConvertError(e);
log.warn("转码失败: {} - {}", fileName, reason);
sendProgress(taskId, total, processed.incrementAndGet(),
success.get(), failed.get(), fileName,
"转码失败: " + fileName + " - " + e.getMessage(), false);
"转码失败: " + fileName + " - " + reason, false);
}
}
@@ -165,7 +177,7 @@ public class ConvertService {
private void runFfmpeg(Path input, Path output) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg",
getFfmpegCommand(),
"-y",
"-i", input.toAbsolutePath().toString(),
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
@@ -173,12 +185,66 @@ public class ConvertService {
);
pb.redirectErrorStream(true);
Process p = pb.start();
int exit = p.waitFor();
boolean finished = p.waitFor(FFMPEG_CONVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
throw new RuntimeException("ffmpeg 转码超时(" + FFMPEG_CONVERT_TIMEOUT_SECONDS + "s");
}
int exit = p.exitValue();
if (exit != 0) {
throw new RuntimeException("ffmpeg 退出码: " + exit);
}
}
private String checkFfmpegAvailable() {
try {
ProcessBuilder pb = new ProcessBuilder(getFfmpegCommand(), "-version");
pb.redirectErrorStream(true);
Process p = pb.start();
boolean finished = p.waitFor(FFMPEG_CHECK_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
p.destroyForcibly();
return "ffmpeg 预检查超时,请检查环境配置";
}
if (p.exitValue() != 0) {
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
}
return null;
} catch (IOException e) {
return "ffmpeg 不可用,请确认已正确安装并加入 PATH";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "ffmpeg 预检查被中断";
}
}
private String classifyConvertError(Exception e) {
if (e instanceof InterruptedException) {
Thread.currentThread().interrupt();
return "任务线程被中断";
}
String message = e.getMessage();
if (message == null || message.trim().isEmpty()) {
return "未知错误";
}
if (message.contains("超时")) {
return message;
}
if (message.contains("ffmpeg 退出码")) {
return message + "(可能是源文件损坏或编码不支持)";
}
return message;
}
private String getFfmpegCommand() {
String configured = System.getProperty(FFMPEG_BIN_PROPERTY);
if (configured == null || configured.trim().isEmpty()) {
return "ffmpeg";
}
return configured.trim();
}
private Path resolveTargetFile(Path targetDir, String fileName) throws IOException {
Path target = targetDir.resolve(fileName);
if (!Files.exists(target)) return target;

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import org.jaudiotagger.audio.AudioFile;
import org.jaudiotagger.audio.AudioFileIO;
@@ -109,6 +110,7 @@ public class DedupService {
AtomicInteger duplicateGroups = new AtomicInteger(0);
AtomicInteger moved = new AtomicInteger(0);
AtomicInteger failed = new AtomicInteger(0);
Set<Path> processedDuplicates = new HashSet<>();
sendProgress(taskId, total, 0, 0, 0,
"开始扫描音乐库...", false);
@@ -147,7 +149,7 @@ public class DedupService {
// 第二阶段:处理 MD5 去重结果(完全二进制重复)
if (useMd5) {
for (Map.Entry<String, List<Path>> entry : md5Groups.entrySet()) {
List<Path> group = entry.getValue();
List<Path> group = filterProcessableCandidates(entry.getValue(), processedDuplicates);
if (group.size() <= 1) {
continue;
}
@@ -158,15 +160,15 @@ public class DedupService {
List<Path> duplicates = new ArrayList<>(group);
duplicates.remove(keep);
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, failed));
handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, moved, failed, processedDuplicates);
}
}
if (useMetadata) {
// 第三阶段:处理元数据匹配去重结果
for (Map.Entry<MetadataKey, List<Path>> entry : metadataGroups.entrySet()) {
List<Path> group = entry.getValue();
List<Path> group = filterProcessableCandidates(entry.getValue(), processedDuplicates);
if (group.size() <= 1) {
continue;
}
@@ -177,8 +179,8 @@ public class DedupService {
List<Path> duplicates = new ArrayList<>(group);
duplicates.remove(keep);
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, failed));
handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
scanned, duplicateGroups, moved, failed, processedDuplicates);
}
}
@@ -362,30 +364,30 @@ public class DedupService {
/**
* 将重复文件移动/复制到回收站,并更新统计与进度
*
* @return 实际成功移动/复制的文件数量
*/
private int handleDuplicates(List<Path> duplicates,
Path keep,
Path trashPath,
String mode,
String taskId,
int total,
AtomicInteger scanned,
AtomicInteger duplicateGroups,
AtomicInteger failed) {
int movedCount = 0;
private void handleDuplicates(List<Path> duplicates,
Path keep,
Path trashPath,
String mode,
String taskId,
int total,
AtomicInteger scanned,
AtomicInteger duplicateGroups,
AtomicInteger moved,
AtomicInteger failed,
Set<Path> processedDuplicates) {
for (Path dup : duplicates) {
try {
Path target = resolveTargetFile(trashPath, dup.getFileName().toString());
if ("move".equalsIgnoreCase(mode)) {
Files.move(dup, target, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(dup, target);
} else {
Files.copy(dup, target, StandardCopyOption.REPLACE_EXISTING);
}
movedCount++;
moved.incrementAndGet();
processedDuplicates.add(toNormalizedAbsolutePath(dup));
sendProgress(taskId, total, scanned.get(),
duplicateGroups.get(), movedCount,
duplicateGroups.get(), moved.get(),
String.format("重复文件: %s (保留: %s)",
dup.getFileName(), keep.getFileName()),
false);
@@ -394,7 +396,24 @@ public class DedupService {
log.warn("处理重复文件失败: {}", dup, e);
}
}
return movedCount;
}
private List<Path> filterProcessableCandidates(List<Path> group, Set<Path> processedDuplicates) {
List<Path> candidates = new ArrayList<>(group.size());
for (Path file : group) {
Path normalized = toNormalizedAbsolutePath(file);
if (processedDuplicates.contains(normalized)) {
continue;
}
if (Files.exists(file)) {
candidates.add(file);
}
}
return candidates;
}
private Path toNormalizedAbsolutePath(Path path) {
return path.toAbsolutePath().normalize();
}
/**
@@ -424,8 +443,8 @@ public class DedupService {
* 字段语义(供前端展示用):
* - total扫描到的音频文件总数
* - processed已扫描文件数
* - success重复组数量
* - failed移动/复制的重复文件数量
* - duplicateGroups / success重复组数量
* - movedFiles / failed移动/复制的重复文件数量
*
* 由于进度字段在不同任务中的含义略有差异,前端可根据 type === "dedup" 做专门映射。
*/
@@ -443,6 +462,8 @@ public class DedupService {
pm.setProcessed(processed);
pm.setSuccess(success);
pm.setFailed(failed);
pm.setDuplicateGroups(success);
pm.setMovedFiles(failed);
pm.setCurrentFile(null);
pm.setMessage(message);
pm.setCompleted(completed);
@@ -451,4 +472,3 @@ public class DedupService {
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
}
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,6 +30,9 @@ public class LibraryMergeService {
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 static final Set<String> EXCLUDED_ROOT_DIRS = new HashSet<>(Arrays.asList(
"_Manual_Fix_Required_", "_Reports"
));
private final SimpMessagingTemplate messagingTemplate;
private final ProgressStore progressStore;
@@ -46,19 +50,19 @@ public class LibraryMergeService {
try {
// 基本校验
if (srcDir == null || srcDir.trim().isEmpty()) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不能为空", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "源目录不能为空", true);
return;
}
if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
return;
}
if (dstDir == null || dstDir.trim().isEmpty()) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "目标目录不能为空", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "目标目录不能为空", true);
return;
}
if (srcPath.normalize().equals(dstPath.normalize())) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "源目录与目标目录不能相同", true);
return;
}
@@ -72,6 +76,14 @@ public class LibraryMergeService {
Map<Path, Path> coverMap = new HashMap<>(); // 专辑目录 -> 封面文件
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) {
if (isExcludedSystemDirectory(srcPath, dir)) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
String fileName = file.getFileName().toString().toLowerCase();
@@ -97,7 +109,7 @@ public class LibraryMergeService {
int total = audioFiles.size();
if (total == 0) {
sendProgress(taskId, 0, 0, 0, 0, 0, null, "未在源目录中找到音频文件", true);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "未在源目录中找到音频文件", true);
return;
}
@@ -109,7 +121,7 @@ public class LibraryMergeService {
Set<String> processedAlbums = new HashSet<>();
sendProgress(taskId, total, 0, 0, 0, 0, null, "开始合并...", false);
sendProgress(taskId, total, 0, 0, 0, 0, 0, null, "开始合并...", false);
for (Path audioFile : audioFiles) {
try {
@@ -141,7 +153,7 @@ public class LibraryMergeService {
targetPath.getFileName().toString() + ".backup");
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(audioFile, targetPath);
wasUpgraded = true;
upgraded.incrementAndGet();
} else {
@@ -149,7 +161,7 @@ public class LibraryMergeService {
int p = processed.incrementAndGet();
if (p % 20 == 0 || p == total) {
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
upgraded.get(), audioFile.getFileName().toString(),
upgraded.get(), skipped.get(), audioFile.getFileName().toString(),
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
}
continue; // 跳过,不升级
@@ -160,7 +172,7 @@ public class LibraryMergeService {
int p = processed.incrementAndGet();
if (p % 20 == 0 || p == total) {
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
upgraded.get(), audioFile.getFileName().toString(),
upgraded.get(), skipped.get(), audioFile.getFileName().toString(),
String.format("已处理 %d/%d (升级: %d)", p, total, upgraded.get()), false);
}
continue;
@@ -168,7 +180,7 @@ public class LibraryMergeService {
} else {
// 新文件,直接移动
Files.createDirectories(targetPath.getParent());
Files.move(audioFile, targetPath, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(audioFile, targetPath);
}
tracksMerged.incrementAndGet();
@@ -178,7 +190,7 @@ public class LibraryMergeService {
if (lyricsFile != null && Files.exists(lyricsFile)) {
Path lyricsTarget = targetPath.resolveSibling(lyricsFile.getFileName().toString());
if (!Files.exists(lyricsTarget) || wasUpgraded) {
Files.move(lyricsFile, lyricsTarget, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(lyricsFile, lyricsTarget);
}
}
@@ -190,7 +202,7 @@ public class LibraryMergeService {
Path coverTarget = albumDir.resolve("cover.jpg");
if (!Files.exists(coverTarget)) {
// 目标目录没有封面,直接移动
Files.move(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(coverFile, coverTarget);
} else {
// 比较封面,保留更好的版本
if (isBetterCover(coverFile, coverTarget)) {
@@ -198,7 +210,7 @@ public class LibraryMergeService {
Path backupPath = coverTarget.resolveSibling("cover.jpg.backup");
Files.copy(coverTarget, backupPath, StandardCopyOption.REPLACE_EXISTING);
}
Files.move(coverFile, coverTarget, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(coverFile, coverTarget);
}
}
}
@@ -212,20 +224,20 @@ public class LibraryMergeService {
int p = processed.incrementAndGet();
if (p % 20 == 0 || p == total) {
sendProgress(taskId, total, p, albumsMerged.get(), tracksMerged.get(),
upgraded.get(), audioFile.getFileName().toString(),
upgraded.get(), skipped.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,
upgraded.get(), skipped.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);
sendProgress(taskId, 0, 0, 0, 0, 0, 0, null, "任务执行失败: " + e.getMessage(), true);
}
}
@@ -325,11 +337,29 @@ public class LibraryMergeService {
return i > 0 ? fileName.substring(0, i) : fileName;
}
private boolean isExcludedSystemDirectory(Path sourceRoot, Path dir) {
if (dir == null || sourceRoot == null) {
return false;
}
if (dir.equals(sourceRoot)) {
return false;
}
Path relative = sourceRoot.relativize(dir);
if (relative.getNameCount() <= 0) {
return false;
}
String firstSegment = relative.getName(0).toString();
return EXCLUDED_ROOT_DIRS.contains(firstSegment);
}
/**
* 发送进度消息
*/
private void sendProgress(String taskId, int total, int processed, int albumsMerged,
int tracksMerged, int upgraded, String currentFile, String message, boolean completed) {
int tracksMerged, int upgraded, int skipped,
String currentFile, String message, boolean completed) {
try {
ProgressMessage pm = new ProgressMessage();
pm.setTaskId(taskId);
@@ -338,6 +368,10 @@ public class LibraryMergeService {
pm.setProcessed(processed);
pm.setSuccess(albumsMerged); // 使用 success 字段存储专辑数
pm.setFailed(tracksMerged); // 使用 failed 字段存储曲目数
pm.setAlbumsMerged(albumsMerged);
pm.setTracksMerged(tracksMerged);
pm.setUpgradedFiles(upgraded);
pm.setSkippedFiles(skipped);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import net.sourceforge.pinyin4j.PinyinHelper;
import net.sourceforge.pinyin4j.format.HanyuPinyinCaseType;
@@ -194,7 +195,7 @@ public class OrganizeService {
Files.createDirectories(destDir);
Path destFile = resolveTargetFile(destDir, destFileName);
Files.move(file, destFile, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(file, destFile);
organized.incrementAndGet();
String albumKey = (isVarious ? VARIOUS_ARTISTS : index + "|" + safeArtist) + "|" + safeAlbum + "|" + year;
@@ -258,7 +259,7 @@ public class OrganizeService {
Path sub = manualRoot.resolve(reason);
Files.createDirectories(sub);
Path target = resolveTargetFile(sub, fileName);
Files.move(file, target, StandardCopyOption.REPLACE_EXISTING);
FileTransferUtils.moveWithFallback(file, target);
manualFix.incrementAndGet();
} catch (IOException e) {
log.warn("移动至人工修复目录失败: {} - {}", file, e.getMessage());
@@ -461,6 +462,8 @@ public class OrganizeService {
pm.setProcessed(processed);
pm.setSuccess(success);
pm.setFailed(failed);
pm.setOrganizedFiles(success);
pm.setManualFixFiles(failed);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);

View File

@@ -3,7 +3,10 @@ package com.music.service;
import com.music.dto.ProgressMessage;
import org.springframework.stereotype.Service;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 进度缓存:用于 WebSocket 消息丢失时的兜底查询
@@ -11,20 +14,55 @@ import java.util.concurrent.ConcurrentHashMap;
@Service
public class ProgressStore {
private final ConcurrentHashMap<String, ProgressMessage> latest = new ConcurrentHashMap<>();
private static final long EXPIRE_MILLIS = 30L * 60L * 1000L;
private final ConcurrentHashMap<String, StoredProgress> latest = new ConcurrentHashMap<>();
private final AtomicInteger writeCounter = new AtomicInteger(0);
public void put(ProgressMessage message) {
if (message == null || message.getTaskId() == null) {
return;
}
latest.put(message.getTaskId(), message);
long now = System.currentTimeMillis();
latest.put(message.getTaskId(), new StoredProgress(message, now));
if (writeCounter.incrementAndGet() % 100 == 0) {
cleanupExpired(now);
}
}
public ProgressMessage get(String taskId) {
if (taskId == null) {
return null;
}
return latest.get(taskId);
StoredProgress stored = latest.get(taskId);
if (stored == null) {
return null;
}
long now = System.currentTimeMillis();
if (now - stored.updatedAt > EXPIRE_MILLIS) {
latest.remove(taskId, stored);
return null;
}
return stored.message;
}
private void cleanupExpired(long now) {
Iterator<Map.Entry<String, StoredProgress>> it = latest.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, StoredProgress> entry = it.next();
if (now - entry.getValue().updatedAt > EXPIRE_MILLIS) {
it.remove();
}
}
}
private static class StoredProgress {
private final ProgressMessage message;
private final long updatedAt;
private StoredProgress(ProgressMessage message, long updatedAt) {
this.message = message;
this.updatedAt = updatedAt;
}
}
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.github.houbb.opencc4j.util.ZhConverterUtil;
import org.springframework.stereotype.Service;
import java.util.HashMap;
@@ -54,7 +55,148 @@ public class TraditionalFilterService {
addMapping('錄', '录');
addMapping('戲', '戏');
addMapping('畫', '画');
addMapping('', '');
addMapping('', '');
addMapping('業', '业');
addMapping('劃', '划');
addMapping('劍', '剑');
addMapping('劑', '剂');
addMapping('勝', '胜');
addMapping('區', '区');
addMapping('華', '华');
addMapping('萬', '万');
addMapping('與', '与');
addMapping('東', '东');
addMapping('絲', '丝');
addMapping('兩', '两');
addMapping('嚴', '严');
addMapping('麗', '丽');
addMapping('亂', '乱');
addMapping('為', '为');
addMapping('義', '义');
addMapping('烏', '乌');
addMapping('喬', '乔');
addMapping('喚', '唤');
addMapping('團', '团');
addMapping('園', '园');
addMapping('圖', '图');
addMapping('學', '学');
addMapping('寶', '宝');
addMapping('實', '实');
addMapping('寫', '写');
addMapping('將', '将');
addMapping('對', '对');
addMapping('屆', '届');
addMapping('屬', '属');
addMapping('嶺', '岭');
addMapping('島', '岛');
addMapping('峯', '峰');
addMapping('廣', '广');
addMapping('張', '张');
addMapping('彈', '弹');
addMapping('強', '强');
addMapping('當', '当');
addMapping('復', '复');
addMapping('戀', '恋');
addMapping('戶', '户');
addMapping('撫', '抚');
addMapping('擇', '择');
addMapping('擁', '拥');
addMapping('擇', '择');
addMapping('數', '数');
addMapping('樂', '乐');
addMapping('機', '机');
addMapping('檔', '档');
addMapping('檢', '检');
addMapping('歸', '归');
addMapping('歲', '岁');
addMapping('歷', '历');
addMapping('氣', '气');
addMapping('漢', '汉');
addMapping('濤', '涛');
addMapping('灣', '湾');
addMapping('無', '无');
addMapping('獎', '奖');
addMapping('產', '产');
addMapping('發', '发');
addMapping('盧', '卢');
addMapping('盡', '尽');
addMapping('盤', '盘');
addMapping('眾', '众');
addMapping('碼', '码');
addMapping('礦', '矿');
addMapping('禮', '礼');
addMapping('穩', '稳');
addMapping('竇', '窦');
addMapping('籤', '签');
addMapping('級', '级');
addMapping('紅', '红');
addMapping('細', '细');
addMapping('經', '经');
addMapping('網', '网');
addMapping('線', '线');
addMapping('練', '练');
addMapping('總', '总');
addMapping('編', '编');
addMapping('續', '续');
addMapping('藝', '艺');
addMapping('蘇', '苏');
addMapping('號', '号');
addMapping('葉', '叶');
addMapping('萬', '万');
addMapping('裝', '装');
addMapping('複', '复');
addMapping('視', '视');
addMapping('覺', '觉');
addMapping('覽', '览');
addMapping('訊', '讯');
addMapping('輯', '辑');
addMapping('選', '选');
addMapping('詞', '词');
addMapping('試', '试');
addMapping('詩', '诗');
addMapping('語', '语');
addMapping('說', '说');
addMapping('讀', '读');
addMapping('譜', '谱');
addMapping('豐', '丰');
addMapping('貝', '贝');
addMapping('資', '资');
addMapping('質', '质');
addMapping('購', '购');
addMapping('轉', '转');
addMapping('輸', '输');
addMapping('這', '这');
addMapping('進', '进');
addMapping('遠', '远');
addMapping('還', '还');
addMapping('邊', '边');
addMapping('鄧', '邓');
addMapping('鄭', '郑');
addMapping('醜', '丑');
addMapping('醫', '医');
addMapping('釋', '释');
addMapping('鐘', '钟');
addMapping('鋼', '钢');
addMapping('錄', '录');
addMapping('長', '长');
addMapping('陳', '陈');
addMapping('陸', '陆');
addMapping('陰', '阴');
addMapping('陽', '阳');
addMapping('雙', '双');
addMapping('雜', '杂');
addMapping('雲', '云');
addMapping('靈', '灵');
addMapping('韓', '韩');
addMapping('頁', '页');
addMapping('頒', '颁');
addMapping('類', '类');
addMapping('顏', '颜');
addMapping('風', '风');
addMapping('飛', '飞');
addMapping('馬', '马');
addMapping('驗', '验');
addMapping('體', '体');
// 可以根据需要逐步补充更多常见映射
}
@@ -72,19 +214,36 @@ public class TraditionalFilterService {
return new ZhStats(0, 0);
}
String simplified = toSimplified(text);
int chineseCount = 0;
int traditionalCount = 0;
int compareLength = Math.min(text.length(), simplified.length());
for (int i = 0; i < text.length(); i++) {
for (int i = 0; i < compareLength; i++) {
char c = text.charAt(i);
if (isCjk(c)) {
chineseCount++;
if (TRADITIONAL_SET.contains(c)) {
if (c != simplified.charAt(i)) {
traditionalCount++;
}
}
}
// 正常繁简转换通常长度一致。若长度不一致,退回到本地字典统计,避免漏算。
if (text.length() != simplified.length()) {
chineseCount = 0;
traditionalCount = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (isCjk(c)) {
chineseCount++;
if (TRADITIONAL_SET.contains(c)) {
traditionalCount++;
}
}
}
}
return new ZhStats(chineseCount, traditionalCount);
}
@@ -95,13 +254,17 @@ public class TraditionalFilterService {
if (text == null || text.isEmpty()) {
return text;
}
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
Character mapped = TRAD_TO_SIMP_MAP.get(c);
sb.append(mapped != null ? mapped : c);
try {
return ZhConverterUtil.toSimple(text);
} catch (Exception e) {
StringBuilder sb = new StringBuilder(text.length());
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
Character mapped = TRAD_TO_SIMP_MAP.get(c);
sb.append(mapped != null ? mapped : c);
}
return sb.toString();
}
return sb.toString();
}
/**
@@ -144,4 +307,3 @@ public class TraditionalFilterService {
}
}
}

View File

@@ -1,5 +1,6 @@
package com.music.service;
import com.music.common.FileTransferUtils;
import com.music.dto.ProgressMessage;
import com.music.service.TraditionalFilterService.ZhStats;
import org.jaudiotagger.audio.AudioFile;
@@ -318,13 +319,7 @@ public class ZhConvertService {
// 移动源文件到目标位置(因为已经确认文件包含繁体,需要过滤出来处理)
// 如果目标文件已存在,则覆盖(可能是重复处理的情况)
try {
// 尝试原子移动(在同一文件系统上更快更安全)
Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
// 如果原子移动失败(例如跨分区),回退到普通移动
Files.move(srcFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
}
FileTransferUtils.moveWithFallback(srcFile, targetFile);
return targetFile;
}
@@ -344,6 +339,8 @@ public class ZhConvertService {
pm.setProcessed(processed);
pm.setSuccess(entries);
pm.setFailed(failed);
pm.setTraditionalEntries(entries);
pm.setFailedFiles(failed);
pm.setCurrentFile(currentFile);
pm.setMessage(message);
pm.setCompleted(completed);
@@ -354,4 +351,3 @@ public class ZhConvertService {
}
}
}

View File

@@ -0,0 +1,73 @@
package com.music.common;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class FileTransferUtilsTest {
@Test
void moveWithFallbackMovesFileAndRemovesSource() throws Exception {
Path tempDir = Files.createTempDirectory("file-transfer-test-");
Path source = tempDir.resolve("source.txt");
Path target = tempDir.resolve("nested/target.txt");
byte[] content = "hello-move".getBytes(StandardCharsets.UTF_8);
Files.write(source, content);
FileTransferUtils.moveWithFallback(source, target);
assertFalse(Files.exists(source));
assertTrue(Files.exists(target));
assertArrayEquals(content, Files.readAllBytes(target));
}
@Test
void moveWithFallbackOverwritesExistingTarget() throws Exception {
Path tempDir = Files.createTempDirectory("file-transfer-overwrite-");
Path source = tempDir.resolve("source.txt");
Path target = tempDir.resolve("target.txt");
Files.write(source, "new-data".getBytes(StandardCharsets.UTF_8));
Files.write(target, "old-data".getBytes(StandardCharsets.UTF_8));
FileTransferUtils.moveWithFallback(source, target);
assertFalse(Files.exists(source));
assertArrayEquals("new-data".getBytes(StandardCharsets.UTF_8), Files.readAllBytes(target));
}
@Test
void moveWithFallbackWorksAcrossFileStoresWhenAvailable() throws Exception {
Path shmRoot = Paths.get("/dev/shm");
Assumptions.assumeTrue(Files.exists(shmRoot) && Files.isDirectory(shmRoot),
"/dev/shm 不可用,跳过跨文件系统测试");
Path sourceDir = Files.createTempDirectory(shmRoot, "file-transfer-src-");
Path targetDir = Files.createTempDirectory("file-transfer-dst-");
Assumptions.assumeFalse(
Files.getFileStore(sourceDir).equals(Files.getFileStore(targetDir)),
"源和目标在同一文件系统,跳过跨文件系统测试"
);
Path source = sourceDir.resolve("cross.txt");
Path target = targetDir.resolve("cross/out.txt");
byte[] content = "cross-device-data".getBytes(StandardCharsets.UTF_8);
Files.write(source, content);
FileTransferUtils.moveWithFallback(source, target);
assertFalse(Files.exists(source));
assertTrue(Files.exists(target));
assertArrayEquals(content, Files.readAllBytes(target));
}
}

View File

@@ -0,0 +1,96 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assumptions;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class ConvertServiceInternalTest {
@Test
void classifyConvertErrorHandlesInterrupted() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
m.setAccessible(true);
String reason = (String) m.invoke(service, new InterruptedException("interrupted"));
assertEquals("任务线程被中断", reason);
}
@Test
void classifyConvertErrorHandlesExitCode() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
m.setAccessible(true);
String reason = (String) m.invoke(service, new RuntimeException("ffmpeg 退出码: 1"));
assertTrue(reason.contains("ffmpeg 退出码"));
assertTrue(reason.contains("源文件损坏"));
}
@Test
void classifyConvertErrorKeepsTimeoutMessage() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("classifyConvertError", Exception.class);
m.setAccessible(true);
String reason = (String) m.invoke(service, new RuntimeException("ffmpeg 转码超时600s"));
assertEquals("ffmpeg 转码超时600s", reason);
}
@Test
void checkFfmpegAvailableReturnsErrorWhenCommandMissing() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method m = ConvertService.class.getDeclaredMethod("checkFfmpegAvailable");
m.setAccessible(true);
String previous = System.getProperty("mangtool.ffmpeg.bin");
try {
System.setProperty("mangtool.ffmpeg.bin", "ffmpeg_not_exists_for_test");
String error = (String) m.invoke(service);
assertTrue(error != null && error.contains("ffmpeg 不可用"));
} finally {
if (previous == null) {
System.clearProperty("mangtool.ffmpeg.bin");
} else {
System.setProperty("mangtool.ffmpeg.bin", previous);
}
}
}
@Test
void runFfmpegReturnsExitCodeErrorForInvalidInput() throws Exception {
ConvertService service = new ConvertService(mock(SimpMessagingTemplate.class), new ProgressStore());
Method check = ConvertService.class.getDeclaredMethod("checkFfmpegAvailable");
check.setAccessible(true);
String checkError = (String) check.invoke(service);
Assumptions.assumeTrue(checkError == null, "ffmpeg 不可用,跳过损坏文件测试");
Path tempDir = Files.createTempDirectory("convert-invalid-");
Path invalidInput = tempDir.resolve("bad.wav");
Path output = tempDir.resolve("out.flac");
Files.write(invalidInput, "not-audio".getBytes(StandardCharsets.UTF_8));
Method run = ConvertService.class.getDeclaredMethod("runFfmpeg", Path.class, Path.class);
run.setAccessible(true);
InvocationTargetException ex = assertThrows(InvocationTargetException.class,
() -> run.invoke(service, invalidInput, output));
Throwable target = ex.getTargetException();
assertTrue(target.getMessage().contains("ffmpeg 退出码"));
}
}

View File

@@ -0,0 +1,98 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class DedupServiceInternalTest {
@Test
@SuppressWarnings("unchecked")
void filterProcessableCandidatesSkipsProcessedAndMissingFiles() throws Exception {
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), new ProgressStore());
Path tempDir = Files.createTempDirectory("dedup-filter-");
Path fileA = tempDir.resolve("a.mp3");
Path fileB = tempDir.resolve("b.mp3");
Path missing = tempDir.resolve("missing.mp3");
Files.write(fileA, "a".getBytes(StandardCharsets.UTF_8));
Files.write(fileB, "b".getBytes(StandardCharsets.UTF_8));
Set<Path> processed = new HashSet<>();
processed.add(fileB.toAbsolutePath().normalize());
Method m = DedupService.class.getDeclaredMethod("filterProcessableCandidates", List.class, Set.class);
m.setAccessible(true);
List<Path> group = new ArrayList<>();
group.add(fileA);
group.add(fileB);
group.add(missing);
List<Path> result = (List<Path>) m.invoke(service, group, processed);
assertEquals(1, result.size());
assertEquals(fileA, result.get(0));
}
@Test
@SuppressWarnings("unchecked")
void handleDuplicatesAccumulatesMovedCountAndMarksProcessed() throws Exception {
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), new ProgressStore());
Path tempDir = Files.createTempDirectory("dedup-handle-");
Path trashDir = tempDir.resolve("trash");
Path keep = tempDir.resolve("keep.mp3");
Path dup = tempDir.resolve("dup.mp3");
Files.createDirectories(trashDir);
Files.write(keep, "keep".getBytes(StandardCharsets.UTF_8));
Files.write(dup, "dup".getBytes(StandardCharsets.UTF_8));
Method m = DedupService.class.getDeclaredMethod(
"handleDuplicates",
List.class,
Path.class,
Path.class,
String.class,
String.class,
int.class,
AtomicInteger.class,
AtomicInteger.class,
AtomicInteger.class,
AtomicInteger.class,
Set.class
);
m.setAccessible(true);
List<Path> duplicates = new ArrayList<>();
duplicates.add(dup);
AtomicInteger scanned = new AtomicInteger(10);
AtomicInteger groups = new AtomicInteger(2);
AtomicInteger moved = new AtomicInteger(5);
AtomicInteger failed = new AtomicInteger(0);
Set<Path> processed = new HashSet<>();
m.invoke(service, duplicates, keep, trashDir, "copy", "task-1", 20,
scanned, groups, moved, failed, processed);
assertEquals(6, moved.get());
assertEquals(0, failed.get());
assertTrue(processed.contains(dup.toAbsolutePath().normalize()));
}
}

View File

@@ -0,0 +1,37 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class LibraryMergeServiceInternalTest {
@Test
void isExcludedSystemDirectoryRecognizesManualFixAndReports() throws Exception {
LibraryMergeService service = new LibraryMergeService(
mock(SimpMessagingTemplate.class),
new ProgressStore()
);
Path root = Files.createTempDirectory("merge-root-");
Path manualDir = root.resolve("_Manual_Fix_Required_").resolve("Missing_Title");
Path reportsDir = root.resolve("_Reports");
Path normalDir = root.resolve("A").resolve("Artist");
Method method = LibraryMergeService.class.getDeclaredMethod(
"isExcludedSystemDirectory", Path.class, Path.class);
method.setAccessible(true);
assertTrue((Boolean) method.invoke(service, root, manualDir));
assertTrue((Boolean) method.invoke(service, root, reportsDir));
assertFalse((Boolean) method.invoke(service, root, normalDir));
assertFalse((Boolean) method.invoke(service, root, root));
}
}

View File

@@ -0,0 +1,79 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
class ProgressMessageMappingTest {
@Test
void dedupProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
DedupService service = new DedupService(mock(SimpMessagingTemplate.class), store);
Method m = DedupService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class, int.class, int.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-dedup", 100, 50, 3, 8, "msg", false);
ProgressMessage pm = store.get("t-dedup");
assertEquals(3, pm.getDuplicateGroups().intValue());
assertEquals(8, pm.getMovedFiles().intValue());
}
@Test
void organizeProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
OrganizeService service = new OrganizeService(mock(SimpMessagingTemplate.class), store);
Method m = OrganizeService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class, int.class, int.class,
String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-organize", 100, 80, 70, 10, "f.mp3", "msg", false);
ProgressMessage pm = store.get("t-organize");
assertEquals(70, pm.getOrganizedFiles().intValue());
assertEquals(10, pm.getManualFixFiles().intValue());
}
@Test
void zhconvertProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
ZhConvertService service = new ZhConvertService(
mock(SimpMessagingTemplate.class), store, new TraditionalFilterService());
Method m = ZhConvertService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class, int.class, int.class,
String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-zh", 10, 5, 12, 1, "f.mp3", "msg", false);
ProgressMessage pm = store.get("t-zh");
assertEquals(12, pm.getTraditionalEntries().intValue());
assertEquals(1, pm.getFailedFiles().intValue());
}
@Test
void mergeProgressContainsDedicatedFields() throws Exception {
ProgressStore store = new ProgressStore();
LibraryMergeService service = new LibraryMergeService(mock(SimpMessagingTemplate.class), store);
Method m = LibraryMergeService.class.getDeclaredMethod(
"sendProgress", String.class, int.class, int.class, int.class, int.class,
int.class, int.class, String.class, String.class, boolean.class);
m.setAccessible(true);
m.invoke(service, "t-merge", 100, 30, 4, 28, 3, 2, "f.mp3", "msg", false);
ProgressMessage pm = store.get("t-merge");
assertEquals(4, pm.getAlbumsMerged().intValue());
assertEquals(28, pm.getTracksMerged().intValue());
assertEquals(3, pm.getUpgradedFiles().intValue());
assertEquals(2, pm.getSkippedFiles().intValue());
}
}

View File

@@ -0,0 +1,54 @@
package com.music.service;
import com.music.dto.ProgressMessage;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
class ProgressStoreTest {
@Test
void putAndGetReturnsLatestProgress() {
ProgressStore store = new ProgressStore();
ProgressMessage pm = new ProgressMessage();
pm.setTaskId("task-1");
pm.setMessage("running");
store.put(pm);
ProgressMessage fetched = store.get("task-1");
assertEquals("running", fetched.getMessage());
}
@Test
@SuppressWarnings("unchecked")
void getRemovesExpiredProgress() throws Exception {
ProgressStore store = new ProgressStore();
ProgressMessage pm = new ProgressMessage();
pm.setTaskId("expired-task");
pm.setMessage("old");
Field latestField = ProgressStore.class.getDeclaredField("latest");
latestField.setAccessible(true);
Map<String, Object> latest = (Map<String, Object>) latestField.get(store);
Class<?> storedClass = Class.forName("com.music.service.ProgressStore$StoredProgress");
Constructor<?> constructor = storedClass.getDeclaredConstructor(ProgressMessage.class, long.class);
constructor.setAccessible(true);
long oldTimestamp = System.currentTimeMillis() - (31L * 60L * 1000L);
Object expired = constructor.newInstance(pm, oldTimestamp);
latest.put("expired-task", expired);
ProgressMessage fetched = store.get("expired-task");
assertNull(fetched);
assertFalse(latest.containsKey("expired-task"));
}
}

View File

@@ -0,0 +1,40 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TraditionalFilterServiceTest {
@Test
void toSimplifiedConvertsCommonMusicMetadata() {
TraditionalFilterService service = new TraditionalFilterService();
String converted = service.toSimplified("張學友 經典專輯 精選 愛與夢");
assertEquals("张学友 经典专辑 精选 爱与梦", converted);
}
@Test
void analyzeReturnsExpectedTraditionalRatio() {
TraditionalFilterService service = new TraditionalFilterService();
TraditionalFilterService.ZhStats stats = service.analyze("張學友abc");
assertEquals(3, stats.getChineseCount());
assertEquals(2, stats.getTraditionalCount());
assertTrue(stats.getRatio() > 0.6 && stats.getRatio() < 0.7);
}
@Test
void analyzeDetectsTraditionalCharsCoveredByOpencc() {
TraditionalFilterService service = new TraditionalFilterService();
TraditionalFilterService.ZhStats stats = service.analyze("愛與夢");
assertEquals(3, stats.getChineseCount());
assertEquals(3, stats.getTraditionalCount());
assertEquals(1.0, stats.getRatio());
}
}

View File

@@ -0,0 +1,66 @@
package com.music.service;
import org.junit.jupiter.api.Test;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
class ZhConvertServiceInternalTest {
@Test
void resolveTargetFileForExecuteKeepsSourceWhenPreview() throws Exception {
ZhConvertService service = new ZhConvertService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService()
);
Path scanDir = Files.createTempDirectory("zh-scan-");
Path srcFile = scanDir.resolve("a/song.mp3");
Files.createDirectories(srcFile.getParent());
Files.write(srcFile, "abc".getBytes(StandardCharsets.UTF_8));
Method m = ZhConvertService.class.getDeclaredMethod(
"resolveTargetFileForExecute", Path.class, Path.class, Path.class, boolean.class);
m.setAccessible(true);
Path result = (Path) m.invoke(service, srcFile, scanDir, null, false);
assertEquals(srcFile, result);
assertTrue(Files.exists(srcFile));
}
@Test
void resolveTargetFileForExecuteMovesFileToOutputWhenExecute() throws Exception {
ZhConvertService service = new ZhConvertService(
mock(SimpMessagingTemplate.class),
new ProgressStore(),
new TraditionalFilterService()
);
Path scanDir = Files.createTempDirectory("zh-scan-exec-");
Path outDir = Files.createTempDirectory("zh-out-exec-");
Path srcFile = scanDir.resolve("artist/song.mp3");
Files.createDirectories(srcFile.getParent());
byte[] content = "xyz".getBytes(StandardCharsets.UTF_8);
Files.write(srcFile, content);
Method m = ZhConvertService.class.getDeclaredMethod(
"resolveTargetFileForExecute", Path.class, Path.class, Path.class, boolean.class);
m.setAccessible(true);
Path result = (Path) m.invoke(service, srcFile, scanDir, outDir, true);
assertFalse(Files.exists(srcFile));
assertTrue(Files.exists(result));
assertEquals(outDir.resolve("artist/song.mp3"), result);
assertEquals("xyz", new String(Files.readAllBytes(result), StandardCharsets.UTF_8));
}
}

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="10">
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span class="card-title">任务配置</span>
</div>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="100px" label-position="left">
@@ -77,40 +74,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="14">
<el-card class="progress-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataLine /></el-icon>
<span class="card-title">任务进度</span>
<el-tag
v-if="progress.taskId && progress.completed"
type="info"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><CircleCheck /></el-icon>
<span class="connection-text">已结束</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && wsConnected"
type="success"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Connection /></el-icon>
<span class="connection-text">已连接</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && !wsConnected"
type="warning"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Loading /></el-icon>
<span class="connection-text">连接中</span>
</el-tag>
</div>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -120,24 +84,7 @@
<div v-else class="progress-content">
<!-- 统计信息 -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">总数</div>
<div class="stat-value">{{ progress.total }}</div>
</div>
<div class="stat-item">
<div class="stat-label">已处理</div>
<div class="stat-value success">{{ progress.processed }}</div>
</div>
<div class="stat-item">
<div class="stat-label">成功</div>
<div class="stat-value success">{{ progress.success }}</div>
</div>
<div class="stat-item">
<div class="stat-label">失败</div>
<div class="stat-value failed">{{ progress.failed }}</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -187,16 +134,14 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Warning,
Document
} from '@element-plus/icons-vue';
import { startAggregate } from '../api/aggregate';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
interface Progress {
taskId: string | null;
@@ -238,6 +183,23 @@ const percentage = computed(() => {
return Math.round((progress.processed / progress.total) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '总数', value: progress.total },
{ label: '已处理', value: progress.processed, tone: 'success' },
{ label: '成功', value: progress.success, tone: 'success' },
{ label: '失败', value: progress.failed, tone: 'failed' }
]);
// WebSocket 连接
let wsDisconnect: (() => void) | null = null;
let pollTimer: number | null = null;
@@ -381,163 +343,5 @@ onUnmounted(() => {
</script>
<style scoped>
.aggregate-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.connection-tag {
margin-left: auto;
display: inline-flex;
align-items: center;
white-space: nowrap;
padding: 2px 8px;
font-size: 12px;
line-height: 1.2;
height: auto;
}
.connection-icon {
font-size: 12px;
margin-right: 2px;
}
.connection-text {
font-size: 12px;
white-space: nowrap;
}
.header-icon {
font-size: 18px;
color: var(--el-color-primary);
}
.card-title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
margin: 0;
}
.progress-content {
padding: 8px 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
text-align: center;
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
transition: all 0.3s;
}
.stat-item:hover {
background: var(--el-bg-color);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.stat-value.success {
color: var(--el-color-success);
}
.stat-value.failed {
color: var(--el-color-danger);
}
.progress-section {
margin-bottom: 20px;
}
.current-file-section {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--el-bg-color-page);
border-radius: 6px;
margin-bottom: 16px;
}
.file-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.file-text {
flex: 1;
font-size: 13px;
color: var(--el-text-color-regular);
word-break: break-all;
line-height: 1.5;
}
.message-section {
margin-top: 16px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
@import '../styles/panel-shared.css';
</style>

View File

@@ -5,10 +5,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="10">
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span class="card-title">任务配置</span>
</div>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="100px" label-position="left">
@@ -186,40 +183,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="14">
<el-card class="progress-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataLine /></el-icon>
<span class="card-title">任务进度</span>
<el-tag
v-if="progress.taskId && progress.completed"
type="info"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><CircleCheck /></el-icon>
<span class="connection-text">已结束</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && wsConnected"
type="success"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Connection /></el-icon>
<span class="connection-text">已连接</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && !wsConnected && !progress.completed"
type="warning"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Loading /></el-icon>
<span class="connection-text">连接中</span>
</el-tag>
</div>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -229,24 +193,7 @@
<div v-else class="progress-content">
<!-- 统计信息 -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">待转码</div>
<div class="stat-value">{{ progress.total }}</div>
</div>
<div class="stat-item">
<div class="stat-label">已处理</div>
<div class="stat-value success">{{ progress.processed }}</div>
</div>
<div class="stat-item">
<div class="stat-label">成功</div>
<div class="stat-value success">{{ progress.success }}</div>
</div>
<div class="stat-item">
<div class="stat-label">失败</div>
<div class="stat-value failed">{{ progress.failed }}</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -296,9 +243,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Warning,
Document,
InfoFilled,
@@ -308,6 +253,8 @@ import { startConvert } from '../api/convert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
interface Progress {
taskId: string | null;
@@ -351,6 +298,23 @@ const percentage = computed(() => {
return Math.round((progress.processed / progress.total) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '待转码', value: progress.total },
{ label: '已处理', value: progress.processed, tone: 'success' },
{ label: '成功', value: progress.success, tone: 'success' },
{ label: '失败', value: progress.failed, tone: 'failed' }
]);
let wsDisconnect: (() => void) | null = null;
let stopWatchingConnected: (() => void) | null = null;
let stopWatchingError: (() => void) | null = null;
@@ -618,150 +582,7 @@ onUnmounted(() => {
</script>
<style scoped>
.convert-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.connection-tag {
margin-left: auto;
display: inline-flex;
align-items: center;
white-space: nowrap;
padding: 2px 8px;
font-size: 12px;
line-height: 1.2;
height: auto;
}
.connection-icon {
font-size: 12px;
margin-right: 2px;
}
.connection-text {
font-size: 12px;
white-space: nowrap;
}
.header-icon {
font-size: 18px;
color: var(--el-color-primary);
}
.card-title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
}
.progress-content {
padding: 8px 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
text-align: center;
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
transition: all 0.3s;
}
.stat-item:hover {
background: var(--el-bg-color);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.stat-value.success {
color: var(--el-color-success);
}
.stat-value.failed {
color: var(--el-color-danger);
}
.progress-section {
margin-bottom: 20px;
}
.current-file-section {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--el-bg-color-page);
border-radius: 6px;
margin-bottom: 16px;
}
.file-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.file-text {
flex: 1;
font-size: 13px;
color: var(--el-text-color-regular);
word-break: break-all;
line-height: 1.5;
}
.message-section {
margin-top: 16px;
}
@import '../styles/panel-shared.css';
.mode-help-collapse {
border: none;
@@ -973,15 +794,4 @@ onUnmounted(() => {
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,10 +5,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="10">
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span class="card-title">任务配置</span>
</div>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="100px" label-position="left">
@@ -311,40 +308,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="14">
<el-card class="progress-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataLine /></el-icon>
<span class="card-title">任务进度</span>
<el-tag
v-if="progress.taskId && progress.completed"
type="info"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><CircleCheck /></el-icon>
<span class="connection-text">已结束</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && wsConnected"
type="success"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Connection /></el-icon>
<span class="connection-text">已连接</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && !wsConnected && !progress.completed"
type="warning"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Loading /></el-icon>
<span class="connection-text">连接中</span>
</el-tag>
</div>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -354,26 +318,7 @@
<div v-else class="progress-content">
<!-- 统计信息 -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">扫描文件数</div>
<div class="stat-value">{{ progress.scanned }}</div>
</div>
<div class="stat-item">
<div class="stat-label">重复组数量</div>
<div class="stat-value success">{{ progress.duplicateGroups }}</div>
</div>
<div class="stat-item">
<div class="stat-label">移动/复制文件数</div>
<div class="stat-value success">{{ progress.moved }}</div>
</div>
<div class="stat-item">
<div class="stat-label">完成状态</div>
<div class="stat-value" :class="{ success: progress.completed }">
{{ progress.completed ? '已完成' : '进行中' }}
</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -416,9 +361,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
ArrowDown,
@@ -430,6 +373,8 @@ import { startDedup } from '../api/dedup';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
const form = reactive({
libraryDir: '',
@@ -470,9 +415,39 @@ const percentage = computed(() => {
return Math.round((scannedProcessed.value / scannedTotal.value) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '扫描文件数', value: progress.scanned },
{ label: '重复组数量', value: progress.duplicateGroups, tone: 'success' },
{ label: '移动/复制文件数', value: progress.moved, tone: 'success' },
{ label: '完成状态', value: progress.completed ? '已完成' : '进行中', tone: progress.completed ? 'success' : 'default' }
]);
let wsDisconnect: (() => void) | null = null;
let connectedWatchStop: (() => void) | null = null;
let pollTimer: number | null = null;
function cleanupRealtime() {
if (connectedWatchStop) {
connectedWatchStop();
connectedWatchStop = null;
}
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
}
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
@@ -507,8 +482,8 @@ function handleProgressMessage(msg: ProgressMessage) {
scannedProcessed.value = msg.processed;
progress.scanned = msg.total;
progress.duplicateGroups = msg.success;
progress.moved = msg.failed;
progress.duplicateGroups = msg.duplicateGroups ?? msg.success;
progress.moved = msg.movedFiles ?? msg.failed;
progress.completed = msg.completed;
progress.message = msg.message ?? '';
}
@@ -516,16 +491,12 @@ function handleProgressMessage(msg: ProgressMessage) {
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
cleanupRealtime();
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
@@ -579,10 +550,7 @@ watch(
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
}
}
);
@@ -593,10 +561,7 @@ onMounted(() => {
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
});
async function loadDefaultPaths() {
@@ -613,10 +578,7 @@ async function loadDefaultPaths() {
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
form.useMd5 = false;
form.useMetadata = true;
form.mode = 'move';
@@ -629,130 +591,13 @@ function reset() {
scannedTotal.value = 0;
scannedProcessed.value = 0;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</script>
<style scoped>
.dedup-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.connection-tag {
margin-left: auto;
display: inline-flex;
align-items: center;
white-space: nowrap;
padding: 2px 8px;
font-size: 12px;
line-height: 1.2;
height: auto;
}
.connection-icon {
font-size: 12px;
margin-right: 2px;
}
.connection-text {
font-size: 12px;
white-space: nowrap;
}
.header-icon {
font-size: 18px;
color: var(--el-color-primary);
}
.card-title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
}
.progress-content {
padding: 8px 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
text-align: center;
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
transition: all 0.3s;
}
.stat-item:hover {
background: var(--el-bg-color);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.stat-value.success {
color: var(--el-color-success);
}
.progress-section {
margin-bottom: 20px;
}
.message-section {
margin-top: 16px;
}
@import '../styles/panel-shared.css';
/* 去重策略说明样式 */
.strategy-help-collapse,
@@ -1043,16 +888,4 @@ function reset() {
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -5,10 +5,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="10">
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span class="card-title">任务配置</span>
</div>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="120px" label-position="left">
@@ -220,40 +217,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="14">
<el-card class="progress-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataLine /></el-icon>
<span class="card-title">任务进度</span>
<el-tag
v-if="progress.taskId && progress.completed"
type="info"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><CircleCheck /></el-icon>
<span class="connection-text">已结束</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && wsConnected"
type="success"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Connection /></el-icon>
<span class="connection-text">已连接</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && !wsConnected && !progress.completed"
type="warning"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Loading /></el-icon>
<span class="connection-text">连接中</span>
</el-tag>
</div>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -263,26 +227,7 @@
<div v-else class="progress-content">
<!-- 统计信息 -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">已合并专辑数</div>
<div class="stat-value success">{{ progress.albums }}</div>
</div>
<div class="stat-item">
<div class="stat-label">已合并曲目数</div>
<div class="stat-value success">{{ progress.tracks }}</div>
</div>
<div class="stat-item">
<div class="stat-label">升级替换文件数</div>
<div class="stat-value">{{ progress.upgraded }}</div>
</div>
<div class="stat-item">
<div class="stat-label">完成状态</div>
<div class="stat-value" :class="{ success: progress.completed }">
{{ progress.completed ? '已完成' : '进行中' }}
</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -323,9 +268,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
TrendCharts,
@@ -339,6 +282,8 @@ import { startMerge } from '../api/merge';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
const form = reactive({
srcDir: '',
@@ -376,9 +321,39 @@ const percentage = computed(() => {
return Math.round((processedFiles.value / totalFiles.value) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '已合并专辑数', value: progress.albums, tone: 'success' },
{ label: '已合并曲目数', value: progress.tracks, tone: 'success' },
{ label: '升级替换文件数', value: progress.upgraded },
{ label: '完成状态', value: progress.completed ? '已完成' : '进行中', tone: progress.completed ? 'success' : 'default' }
]);
let wsDisconnect: (() => void) | null = null;
let connectedWatchStop: (() => void) | null = null;
let pollTimer: number | null = null;
function cleanupRealtime() {
if (connectedWatchStop) {
connectedWatchStop();
connectedWatchStop = null;
}
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
}
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
@@ -409,44 +384,25 @@ function handleProgressMessage(msg: ProgressMessage) {
}
// 字段映射:见后端 LibraryMergeService 注释
// success 字段存储专辑数failed 字段存储曲目数
totalFiles.value = msg.total;
processedFiles.value = msg.processed;
progress.albums = msg.success;
progress.tracks = msg.failed;
progress.albums = msg.albumsMerged ?? msg.success;
progress.tracks = msg.tracksMerged ?? msg.failed;
progress.upgraded = msg.upgradedFiles ?? progress.upgraded;
progress.completed = msg.completed;
progress.message = msg.message ?? '';
// 从消息中提取升级数量(如果消息中包含)
if (msg.message) {
// 匹配 "升级: 5" 或 "升级5" 或 "(升级: 5)" 等格式
const upgradeMatch = msg.message.match(/升级[:]\s*(\d+)/);
if (upgradeMatch) {
progress.upgraded = parseInt(upgradeMatch[1], 10);
} else {
// 如果没有找到,尝试从完成消息中提取
const finalMatch = msg.message.match(/升级[:]\s*(\d+)/);
if (finalMatch) {
progress.upgraded = parseInt(finalMatch[1], 10);
}
}
}
}
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
cleanupRealtime();
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
@@ -494,10 +450,7 @@ watch(
if (done) {
submitting.value = false;
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
}
}
);
@@ -508,10 +461,7 @@ onMounted(() => {
onUnmounted(() => {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
});
async function loadDefaultPaths() {
@@ -528,10 +478,7 @@ async function loadDefaultPaths() {
function reset() {
stopPolling();
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
cleanupRealtime();
form.smartUpgrade = true;
form.keepBackup = false;
progress.taskId = null;
@@ -543,130 +490,13 @@ function reset() {
totalFiles.value = 0;
processedFiles.value = 0;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
</script>
<style scoped>
.merge-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.connection-tag {
margin-left: auto;
display: inline-flex;
align-items: center;
white-space: nowrap;
padding: 2px 8px;
font-size: 12px;
line-height: 1.2;
height: auto;
}
.connection-icon {
font-size: 12px;
margin-right: 2px;
}
.connection-text {
font-size: 12px;
white-space: nowrap;
}
.header-icon {
font-size: 18px;
color: var(--el-color-primary);
}
.card-title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
}
.progress-content {
padding: 8px 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
text-align: center;
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
transition: all 0.3s;
}
.stat-item:hover {
background: var(--el-bg-color);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.stat-value.success {
color: var(--el-color-success);
}
.progress-section {
margin-bottom: 20px;
}
.message-section {
margin-top: 16px;
}
@import '../styles/panel-shared.css';
/* 合并策略说明样式 */
.strategy-help-collapse {
@@ -922,16 +752,4 @@ function reset() {
}
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

View File

@@ -5,10 +5,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="10">
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Setting /></el-icon>
<span class="card-title">任务配置</span>
</div>
<task-card-header :icon="Setting" title="任务配置" />
</template>
<el-form :model="form" label-width="110px" label-position="left">
@@ -245,7 +242,7 @@
<div class="example-scenario">
<div class="scenario-title">
<el-icon class="scenario-icon"><FolderOpened /></el-icon>
<strong>场景二输出目录不为空复制到新目录</strong>
<strong>场景二输出目录不为空移动到新目录</strong>
</div>
<div class="scenario-content">
<div class="example-item">
@@ -267,7 +264,7 @@
<div class="mode-tip">
<el-icon class="tip-icon"><Promotion /></el-icon>
<div class="tip-content">
<strong>使用建议</strong>建议先用预览模式确认需要转换的文件然后再使用执行模式如果输出目录不为空系统会将包含繁体的文件移动到输出目录这样可以保留原始文件作为备份
<strong>使用建议</strong>建议先用预览模式确认需要转换的文件然后再使用执行模式如果输出目录不为空系统会将包含繁体的文件移动到输出目录如需保留原始文件请先手动备份源目录
</div>
</div>
</div>
@@ -307,40 +304,7 @@
<el-col :xs="24" :sm="24" :md="12" :lg="14">
<el-card class="progress-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><DataLine /></el-icon>
<span class="card-title">任务进度</span>
<el-tag
v-if="progress.taskId && progress.completed"
type="info"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><CircleCheck /></el-icon>
<span class="connection-text">已结束</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && wsConnected"
type="success"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Connection /></el-icon>
<span class="connection-text">已连接</span>
</el-tag>
<el-tag
v-else-if="progress.taskId && !wsConnected && !progress.completed"
type="warning"
size="small"
effect="plain"
class="connection-tag"
>
<el-icon class="connection-icon"><Loading /></el-icon>
<span class="connection-text">连接中</span>
</el-tag>
</div>
<task-card-header :icon="DataLine" title="任务进度" :status="connectionStatus" />
</template>
<div v-if="!progress.taskId" class="empty-state">
@@ -350,24 +314,7 @@
<div v-else class="progress-content">
<!-- 统计信息 -->
<div class="stats-grid">
<div class="stat-item">
<div class="stat-label">总文件数</div>
<div class="stat-value">{{ progress.total }}</div>
</div>
<div class="stat-item">
<div class="stat-label">已扫描</div>
<div class="stat-value success">{{ progress.processed }}</div>
</div>
<div class="stat-item">
<div class="stat-label">繁体标签条目</div>
<div class="stat-value success">{{ progress.entries }}</div>
</div>
<div class="stat-item">
<div class="stat-label">失败文件数</div>
<div class="stat-value failed">{{ progress.failed }}</div>
</div>
</div>
<task-stats-grid :items="progressStats" />
<!-- 进度条 -->
<div class="progress-section">
@@ -414,9 +361,7 @@ import {
VideoPlay,
Refresh,
DataLine,
Connection,
CircleCheck,
Loading,
Document,
InfoFilled,
ArrowDown,
@@ -430,6 +375,8 @@ import { startZhConvert } from '../api/zhconvert';
import { getProgress } from '../api/progress';
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
import { getConfig } from '../api/config';
import TaskCardHeader, { type CardHeaderStatus } from './common/TaskCardHeader.vue';
import TaskStatsGrid, { type StatItem } from './common/TaskStatsGrid.vue';
interface ProgressState {
taskId: string | null;
@@ -474,9 +421,39 @@ const percentage = computed(() => {
return Math.round((progress.processed / progress.total) * 100);
});
const connectionStatus = computed<CardHeaderStatus | null>(() => {
if (!progress.taskId) {
return null;
}
if (progress.completed) {
return 'completed';
}
return wsConnected.value ? 'connected' : 'connecting';
});
const progressStats = computed<StatItem[]>(() => [
{ label: '总文件数', value: progress.total },
{ label: '已扫描', value: progress.processed, tone: 'success' },
{ label: '繁体标签条目', value: progress.entries, tone: 'success' },
{ label: '失败文件数', value: progress.failed, tone: 'failed' }
]);
let wsDisconnect: (() => void) | null = null;
let connectedWatchStop: (() => void) | null = null;
let pollTimer: number | null = null;
function cleanupRealtime() {
if (connectedWatchStop) {
connectedWatchStop();
connectedWatchStop = null;
}
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
}
function startPolling(taskId: string) {
stopPolling();
pollTimer = window.setInterval(async () => {
@@ -507,8 +484,8 @@ function handleProgressMessage(msg: ProgressMessage) {
}
progress.total = msg.total;
progress.processed = msg.processed;
progress.entries = msg.success;
progress.failed = msg.failed;
progress.entries = msg.traditionalEntries ?? msg.success;
progress.failed = msg.failedFiles ?? msg.failed;
progress.currentFile = msg.currentFile || '';
progress.message = msg.message || '';
progress.completed = msg.completed;
@@ -526,16 +503,12 @@ function handleProgressMessage(msg: ProgressMessage) {
watch(
() => progress.taskId,
(newTaskId) => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
wsConnected.value = false;
cleanupRealtime();
if (newTaskId) {
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
wsDisconnect = disconnect;
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connectedWatchStop = watch(connected, (val) => (wsConnected.value = val), { immediate: true });
connect();
startPolling(newTaskId);
}
@@ -592,11 +565,8 @@ async function loadDefaultPaths() {
}
function reset() {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
cleanupRealtime();
form.threshold = 10;
form.mode = 'preview';
progress.taskId = null;
@@ -608,7 +578,6 @@ function reset() {
progress.message = '';
progress.completed = false;
submitting.value = false;
wsConnected.value = false;
// 重新加载默认路径
loadDefaultPaths();
}
@@ -618,159 +587,13 @@ onMounted(() => {
});
onUnmounted(() => {
if (wsDisconnect) {
wsDisconnect();
wsDisconnect = null;
}
stopPolling();
cleanupRealtime();
});
</script>
<style scoped>
.zhconvert-container {
width: 100%;
max-width: 1400px;
margin: 0 auto;
}
.config-card,
.progress-card {
height: 100%;
min-height: 500px;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
font-size: 18px;
color: var(--el-color-primary);
}
.card-title {
font-size: 16px;
font-weight: 600;
flex: 1;
}
.connection-tag {
margin-left: auto;
display: inline-flex;
align-items: center;
white-space: nowrap;
padding: 2px 8px;
font-size: 12px;
line-height: 1.2;
height: auto;
}
.connection-icon {
font-size: 12px;
margin-right: 2px;
}
.connection-text {
font-size: 12px;
white-space: nowrap;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--el-text-color-secondary);
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
font-size: 14px;
}
.progress-content {
padding: 8px 0;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-item {
text-align: center;
padding: 16px;
background: var(--el-bg-color-page);
border-radius: 8px;
transition: all 0.3s;
}
.stat-item:hover {
background: var(--el-bg-color);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-label {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 24px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.stat-value.success {
color: var(--el-color-success);
}
.stat-value.failed {
color: var(--el-color-danger);
}
.progress-section {
margin-bottom: 20px;
}
.current-file-section {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--el-bg-color-page);
border-radius: 6px;
margin-bottom: 16px;
}
.file-icon {
color: var(--el-color-primary);
font-size: 18px;
}
.file-text {
flex: 1;
font-size: 13px;
color: var(--el-text-color-regular);
word-break: break-all;
line-height: 1.5;
}
.message-section {
margin-top: 16px;
}
@import '../styles/panel-shared.css';
/* 繁体占比阈值说明样式 */
.threshold-help-collapse,
@@ -1101,15 +924,4 @@ onUnmounted(() => {
color: #6b7280;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

40
优化发布说明.md Normal file
View File

@@ -0,0 +1,40 @@
# 音乐功能优化发布说明(草案)
## 1. 本次优化范围
- 去重:修复 MD5 + 元数据双策略下重复处理同一文件问题。
- 文件移动:统一跨盘容错(`move` 失败自动回退 `copy + delete`)。
- 繁简转换:统一“输出目录不为空”行为说明为“移动到输出目录后再修改”。
- 进度模型:新增结构化进度字段,减少 `success/failed` 语义复用。
- 进度缓存:增加 TTL 过期清理,避免长期堆积。
- 前端任务页:修复 WebSocket 连接切换时 watch 释放不完整问题。
- 转码稳定性:增加 ffmpeg 预检查、单文件超时与错误分类。
- 繁简质量:扩充常见繁简映射字典。
## 2. 兼容性说明
- `ProgressMessage` 保留 `success/failed` 老字段,前端优先读取新字段,兼容历史逻辑。
- API 路径与任务启动入参结构未变(`/api/*/start` 仍保持一致)。
## 3. 可能感知到的行为变化
- 去重任务在双策略场景下,重复文件不再被重复搬运,失败噪声明显降低。
- 跨盘移动相关任务(汇聚/整理/入库/去重/繁简输出)在跨分区下成功率更高。
- 繁简执行模式且指定输出目录时,文件会移动到输出目录,不再描述为“复制副本”。
- 合并任务前端不再从文案解析升级数量,而是读取结构化字段。
## 4. 风险与回滚点
- 进度字段新增后,若第三方客户端仅依赖旧字段,应确认兼容映射。
- 文件移动回退为 `copy + delete` 时,极端情况下可能受磁盘空间影响。
- 繁简映射字典扩展后,个别专有名词可能需要人工复核。
## 5. 验证建议(发布前)
- 后端:`mvn test``mvn clean compile`
- 前端:`npm run build`
- 手工回归:
- 双策略去重MD5+元数据)
- 跨盘移动(源/目标分区不同)
- 繁简预览/执行(输出目录空/非空)
- 四个任务页反复切换与重置,确认无重复消息

108
优化计划.md Normal file
View File

@@ -0,0 +1,108 @@
# 音乐功能优化计划
> 执行顺序固定:**开发 -> 测试 -> 完成打勾**。
> 规则:当前任务未完成前,不进入下一任务。
## 执行规则(每个任务都遵守)
- 1) 先做开发(代码实现)
- 2) 再做测试(最少手工回归 + 受影响模块命令验证)
- 3) 测试通过后再打勾“完成”
- 4) 未完成前,不进入下一个任务
---
## 优化总计划(按顺序执行)
### 0. 基线与安全准备
- [ ] 开发:建立基线分支,记录当前可复现问题与性能基线(大目录扫描、长任务进度、跨盘移动)。
- [ ] 测试:执行 `frontend npm run build``backend mvn clean compile`,确认当前基线可构建。
- [ ] 完成:基线文档记录完毕后打勾。
### 1. 去重流程重复处理问题(最高优先)
- [x] 开发:修复 `DedupService` 中 MD5 与元数据双策略重复处理同一文件的问题(去重阶段共享“已处理集合”)。
- [x] 测试:已补充后端自动化测试(`DedupServiceInternalTest`),验证“已处理集合过滤”和“重复处理计数累加”行为。
- [x] 完成:结果稳定后打勾。
### 2. 文件移动跨盘容错统一
- [x] 开发:抽出统一文件操作工具(优先 `move`,失败回退 `copy + delete`),替换汇聚/转码/整理/入库中的直接 `Files.move`
- [x] 测试:已补充 `FileTransferUtilsTest`,覆盖同盘移动、覆盖写入、跨文件系统移动(`/dev/shm` 可用时)。
- [x] 完成:所有相关服务替换并通过测试后打勾。
### 3. 繁简转换输出目录语义统一
- [x] 开发统一“输出目录不为空”行为明确是“移动”还是“复制”同步后端注释、DTO 说明、前端文案。
- [x] 测试:已补充 `ZhConvertServiceInternalTest`,覆盖预览模式保持原文件、执行模式输出目录移动并保留相对路径。
- [x] 完成:前后端行为与说明一致后打勾。
### 4. 进度消息模型标准化
- [x] 开发:扩展 `ProgressMessage`(增加业务字段如 `duplicateGroups``albums``tracks` 等),减少 `success/failed` 的语义复用。
- [x] 测试:已补充 `ProgressMessageMappingTest`,验证 dedup/organize/zhconvert/merge 的结构化字段映射。
- [x] 完成:前端无需“特殊猜字段”后打勾。
### 5. 任务进度存储清理机制
- [x] 开发:为 `ProgressStore` 增加完成任务清理策略TTL 或定时清理)。
- [x] 测试:已补充 `ProgressStoreTest`,验证正常读取与过期清理行为。
- [x] 完成:验证通过后打勾。
### 6. 前端 WebSocket/watch 释放治理
- [x] 开发:统一封装任务页连接生命周期,保存并释放所有 `watch` 的 stop handle。
- [x] 测试代码级检查通过4 个任务页均包含 `cleanupRealtime` + `connectedWatchStop` 释放链路),并通过前端构建验证。
- [x] 完成四个任务页Dedup/Rename/Merge/TraditionalFilter全部修复后打勾。
### 7. 转码稳定性增强ffmpeg 预检 + 超时)
- [x] 开发:在 `ConvertService` 增加 ffmpeg 可用性预检查、单文件超时与错误分类。
- [x] 测试:已补充 `ConvertServiceInternalTest` 覆盖 ffmpeg 缺失、损坏输入、超时文案分类,并通过全量后端测试。
- [x] 完成:异常处理链路已覆盖并通过测试后打勾。
### 8. 繁体转换质量提升
- [x] 开发:扩展字典或接入更完整方案(如 OpenCC并保持可配置开关。
- [x] 测试:已补充 `TraditionalFilterServiceTest`,验证常见歌手/专辑元数据样本转换与繁体占比统计。
- [x] 完成:达到当前样本准确率后打勾。
### 9. 回归与验收(全功能)
- [x] 开发:整理发布说明与变更清单(行为变化、兼容性、风险点)。
- [ ] 测试:自动化回归已完成(`mvn test``npm run build`);全链路音频样本手工回归待执行。
- [ ] 完成:所有任务页和后端服务验收通过后打勾,计划收口。
---
## 完成定义DoD
- [x] 每个已完成任务都有对应测试记录(命令输出或手工步骤)
- [x] 前后端行为和页面文案一致
- [x] 长任务可追踪、可结束、失败可解释(以自动化与结构化进度字段验证)
- [ ] 无已知阻断缺陷P1/P0
- [x] `AGENTS.md` 已同步更新(若命令/规范有变化)
---
## 每日打勾模板(可复制)
```md
### 日期YYYY-MM-DD
- 负责人:
- 当前任务:
- 开发进展:
- 测试结果:
- 风险与阻塞:
- 是否完成打勾:是 / 否
```
---
## 当前测试记录(自动化)
- [x] `mvn -Dtest=FileTransferUtilsTest test`
- [x] `mvn -Dtest=DedupServiceInternalTest test`
- [x] `mvn -Dtest=ProgressStoreTest test`
- [x] `mvn -Dtest=ZhConvertServiceInternalTest test`
- [x] `mvn -Dtest=ProgressMessageMappingTest test`
- [x] `mvn -Dtest=TraditionalFilterServiceTest test`
- [x] `mvn -Dtest=ConvertServiceInternalTest test`
- [x] `mvn -Dtest=ConvertServiceInternalTest,TraditionalFilterServiceTest test`
- [x] `mvn -Dtest=FileTransferUtilsTest,ProgressStoreTest,ZhConvertServiceInternalTest,ProgressMessageMappingTest test`
- [x] `mvn test`
- [x] `mvn clean compile`
- [x] `npm run build`
> 说明跨盘移动、WebSocket 多次切页、繁简真实样本准确率等场景仍需手工回归。