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:
166
AGENTS.md
Normal file
166
AGENTS.md
Normal 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 规则文件,请将关键约束合并到本文件,避免规范冲突。
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: 跳过文件数
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ public class ZhConvertRequest {
|
||||
* - 预览模式:忽略该字段
|
||||
* - 执行模式:
|
||||
* - 为空:在原文件上就地修改标签
|
||||
* - 非空:在该目录下生成一份带简体标签的副本
|
||||
* - 非空:将包含繁体标签的文件移动到该目录(保留相对路径)后再修改标签
|
||||
*/
|
||||
private String outputDir;
|
||||
|
||||
@@ -41,4 +41,3 @@ public class ZhConvertRequest {
|
||||
@NotBlank(message = "处理模式不能为空")
|
||||
private String mode;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 退出码"));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
71
frontend/src/components/common/TaskCardHeader.vue
Normal file
71
frontend/src/components/common/TaskCardHeader.vue
Normal 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>
|
||||
24
frontend/src/components/common/TaskStatsGrid.vue
Normal file
24
frontend/src/components/common/TaskStatsGrid.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
185
frontend/src/styles/panel-shared.css
Normal file
185
frontend/src/styles/panel-shared.css
Normal 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);
|
||||
}
|
||||
}
|
||||
76
frontend/src/styles/theme.css
Normal file
76
frontend/src/styles/theme.css
Normal 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
40
优化发布说明.md
Normal 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
108
优化计划.md
Normal 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 多次切页、繁简真实样本准确率等场景仍需手工回归。
|
||||
Reference in New Issue
Block a user