提交代码
This commit is contained in:
93
.gitignore
vendored
93
.gitignore
vendored
@@ -1,17 +1,45 @@
|
||||
# ---> Java
|
||||
# Compiled class file
|
||||
#
|
||||
# 通用
|
||||
#
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 依赖缓存归项目外管理
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
*.tgz
|
||||
|
||||
#
|
||||
# IDE / 编辑器
|
||||
#
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# JetBrains / IDEA
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# 其他编辑器
|
||||
*.swp
|
||||
*.swo
|
||||
*.tmp
|
||||
|
||||
#
|
||||
# Java / Maven / Gradle
|
||||
#
|
||||
# 编译输出
|
||||
*.class
|
||||
|
||||
# Log file
|
||||
# Java 日志
|
||||
*.log
|
||||
|
||||
# BlueJ files
|
||||
*.ctxt
|
||||
|
||||
# Mobile Tools for Java (J2ME)
|
||||
# J2ME 临时目录
|
||||
.mtj.tmp/
|
||||
|
||||
# Package Files #
|
||||
# 构建产物(不入库)
|
||||
*.jar
|
||||
*.war
|
||||
*.nar
|
||||
@@ -20,18 +48,51 @@
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
# JVM 崩溃日志
|
||||
hs_err_pid*
|
||||
replay_pid*
|
||||
|
||||
# ---> Vue
|
||||
# gitignore template for Vue.js projects
|
||||
# Maven 目标目录
|
||||
target/
|
||||
backend/target/
|
||||
|
||||
# Gradle 目录(如果用得到)
|
||||
.gradle/
|
||||
build/
|
||||
|
||||
#
|
||||
# Recommended template: Node.gitignore
|
||||
# Node / Vue / Vite
|
||||
#
|
||||
# 通用前端依赖与构建输出
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
docs/_book
|
||||
# Vite / Rollup 缓存
|
||||
frontend/.vite/
|
||||
.vite/
|
||||
|
||||
# TODO: where does this rule come from?
|
||||
test/
|
||||
# Vue / 前端其它构建目录(按需)
|
||||
.cache/
|
||||
coverage/
|
||||
*.local
|
||||
|
||||
#
|
||||
# 操作系统杂项
|
||||
#
|
||||
# macOS
|
||||
*.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
#
|
||||
# 其他可选忽略(按需开启)
|
||||
#
|
||||
# 环境变量
|
||||
#.env
|
||||
#.env.*.local
|
||||
|
||||
# 本地工具配置
|
||||
#.idea/
|
||||
#.vscode/
|
||||
89
backend/pom.xml
Normal file
89
backend/pom.xml
Normal file
@@ -0,0 +1,89 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>com.music</groupId>
|
||||
<artifactId>mangtool-backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>MangTool Backend</name>
|
||||
<description>Music toolbox backend (Spring Boot)</description>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>2.7.18</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<java.version>1.8</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- 音频元数据解析(标签读取) -->
|
||||
<dependency>
|
||||
<groupId>net.jthink</groupId>
|
||||
<artifactId>jaudiotagger</artifactId>
|
||||
<version>2.2.5</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<version>3.11.0</version>
|
||||
<configuration>
|
||||
<source>1.8</source>
|
||||
<target>1.8</target>
|
||||
<encoding>UTF-8</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
15
backend/src/main/java/com/music/MangToolApplication.java
Normal file
15
backend/src/main/java/com/music/MangToolApplication.java
Normal file
@@ -0,0 +1,15 @@
|
||||
package com.music;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableAsync
|
||||
public class MangToolApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(MangToolApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
39
backend/src/main/java/com/music/common/Result.java
Normal file
39
backend/src/main/java/com/music/common/Result.java
Normal file
@@ -0,0 +1,39 @@
|
||||
package com.music.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Result<T> {
|
||||
|
||||
/**
|
||||
* 0 = 成功,非 0 = 业务错误代码
|
||||
*/
|
||||
private int code;
|
||||
|
||||
/**
|
||||
* 提示信息
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 业务数据
|
||||
*/
|
||||
private T data;
|
||||
|
||||
public static <T> Result<T> success(T data) {
|
||||
return new Result<>(0, "success", data);
|
||||
}
|
||||
|
||||
public static <T> Result<T> success(String message, T data) {
|
||||
return new Result<>(0, message, data);
|
||||
}
|
||||
|
||||
public static <T> Result<T> failure(int code, String message) {
|
||||
return new Result<>(code, message, null);
|
||||
}
|
||||
}
|
||||
|
||||
25
backend/src/main/java/com/music/config/CorsConfig.java
Normal file
25
backend/src/main/java/com/music/config/CorsConfig.java
Normal file
@@ -0,0 +1,25 @@
|
||||
package com.music.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import org.springframework.web.filter.CorsFilter;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig {
|
||||
|
||||
@Bean
|
||||
public CorsFilter corsFilter() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.addAllowedOriginPattern("*");
|
||||
config.addAllowedHeader("*");
|
||||
config.addAllowedMethod("*");
|
||||
config.setAllowCredentials(true);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return new CorsFilter(source);
|
||||
}
|
||||
}
|
||||
|
||||
26
backend/src/main/java/com/music/config/WebSocketConfig.java
Normal file
26
backend/src/main/java/com/music/config/WebSocketConfig.java
Normal file
@@ -0,0 +1,26 @@
|
||||
package com.music.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.withSockJS();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry registry) {
|
||||
registry.enableSimpleBroker("/topic");
|
||||
registry.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.AggregateRequest;
|
||||
import com.music.exception.BusinessException;
|
||||
import com.music.service.AggregatorService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/aggregate")
|
||||
@Validated
|
||||
public class AggregateController {
|
||||
|
||||
private final AggregatorService aggregatorService;
|
||||
|
||||
public AggregateController(AggregatorService aggregatorService) {
|
||||
this.aggregatorService = aggregatorService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音频文件汇聚任务
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public Result<StartResponse> start(@Valid @RequestBody AggregateRequest request) {
|
||||
// 验证模式参数
|
||||
if (!"copy".equalsIgnoreCase(request.getMode()) &&
|
||||
!"move".equalsIgnoreCase(request.getMode())) {
|
||||
throw new BusinessException(400, "模式参数错误,必须是 copy 或 move");
|
||||
}
|
||||
|
||||
// 生成任务ID
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
|
||||
// 异步执行任务
|
||||
aggregatorService.aggregate(
|
||||
taskId,
|
||||
request.getSrcDir(),
|
||||
request.getDstDir(),
|
||||
request.getMode()
|
||||
);
|
||||
|
||||
return Result.success(new StartResponse(taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动响应
|
||||
*/
|
||||
public static class StartResponse {
|
||||
private String taskId;
|
||||
|
||||
public StartResponse(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.ConvertRequest;
|
||||
import com.music.exception.BusinessException;
|
||||
import com.music.service.ConvertService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.UUID;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/convert")
|
||||
@Validated
|
||||
public class ConvertController {
|
||||
|
||||
private final ConvertService convertService;
|
||||
|
||||
public ConvertController(ConvertService convertService) {
|
||||
this.convertService = convertService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音频格式智能处理(转码)任务
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public Result<StartResponse> start(@Valid @RequestBody ConvertRequest request) {
|
||||
if (!"copy".equalsIgnoreCase(request.getMode()) && !"move".equalsIgnoreCase(request.getMode())) {
|
||||
throw new BusinessException(400, "模式参数错误,必须是 copy 或 move");
|
||||
}
|
||||
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
convertService.convert(
|
||||
taskId,
|
||||
request.getSrcDir(),
|
||||
request.getDstDir(),
|
||||
request.getMode()
|
||||
);
|
||||
|
||||
return Result.success(new StartResponse(taskId));
|
||||
}
|
||||
|
||||
public static class StartResponse {
|
||||
private String taskId;
|
||||
|
||||
public StartResponse(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.DedupRequest;
|
||||
import com.music.exception.BusinessException;
|
||||
import com.music.service.DedupService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 音乐去重任务控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/dedup")
|
||||
@Validated
|
||||
public class DedupController {
|
||||
|
||||
private final DedupService dedupService;
|
||||
|
||||
public DedupController(DedupService dedupService) {
|
||||
this.dedupService = dedupService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音乐去重任务
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public Result<StartResponse> start(@Valid @RequestBody DedupRequest request) {
|
||||
// 模式校验
|
||||
if (!"copy".equalsIgnoreCase(request.getMode()) &&
|
||||
!"move".equalsIgnoreCase(request.getMode())) {
|
||||
throw new BusinessException(400, "模式参数错误,必须是 copy 或 move");
|
||||
}
|
||||
|
||||
// 至少启用一种策略
|
||||
if (!request.isUseMd5() && !request.isUseMetadata()) {
|
||||
throw new BusinessException(400, "至少需要启用一种去重策略(MD5 或元数据匹配)");
|
||||
}
|
||||
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
dedupService.dedup(
|
||||
taskId,
|
||||
request.getLibraryDir(),
|
||||
request.getTrashDir(),
|
||||
request.isUseMd5(),
|
||||
request.isUseMetadata(),
|
||||
request.getMode()
|
||||
);
|
||||
|
||||
return Result.success(new StartResponse(taskId));
|
||||
}
|
||||
|
||||
public static class StartResponse {
|
||||
private String taskId;
|
||||
|
||||
public StartResponse(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping("/api/health")
|
||||
public Result<String> health() {
|
||||
return Result.success("OK");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.ProgressMessage;
|
||||
import com.music.service.ProgressStore;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/progress")
|
||||
public class ProgressController {
|
||||
|
||||
private final ProgressStore progressStore;
|
||||
|
||||
public ProgressController(ProgressStore progressStore) {
|
||||
this.progressStore = progressStore;
|
||||
}
|
||||
|
||||
@GetMapping("/{taskId}")
|
||||
public Result<ProgressMessage> get(@PathVariable("taskId") String taskId) {
|
||||
return Result.success(progressStore.get(taskId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.music.controller;
|
||||
|
||||
import com.music.common.Result;
|
||||
import com.music.dto.ZhConvertRequest;
|
||||
import com.music.exception.BusinessException;
|
||||
import com.music.service.ZhConvertService;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 音乐元数据繁体转简体任务控制器
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/zhconvert")
|
||||
@Validated
|
||||
public class ZhConvertController {
|
||||
|
||||
private final ZhConvertService zhConvertService;
|
||||
|
||||
public ZhConvertController(ZhConvertService zhConvertService) {
|
||||
this.zhConvertService = zhConvertService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动繁体检测 / 转换任务
|
||||
*/
|
||||
@PostMapping("/start")
|
||||
public Result<StartResponse> start(@Valid @RequestBody ZhConvertRequest request) {
|
||||
String mode = request.getMode();
|
||||
if (!"preview".equalsIgnoreCase(mode) && !"execute".equalsIgnoreCase(mode)) {
|
||||
throw new BusinessException(400, "处理模式错误,必须是 preview 或 execute");
|
||||
}
|
||||
|
||||
int threshold = request.getThreshold();
|
||||
if (threshold < 1 || threshold > 100) {
|
||||
throw new BusinessException(400, "繁体占比阈值必须在 1-100 之间");
|
||||
}
|
||||
|
||||
String taskId = UUID.randomUUID().toString();
|
||||
double thresholdRatio = threshold / 100.0;
|
||||
|
||||
zhConvertService.process(
|
||||
taskId,
|
||||
request.getScanDir(),
|
||||
request.getOutputDir(),
|
||||
mode,
|
||||
thresholdRatio
|
||||
);
|
||||
|
||||
return Result.success(new StartResponse(taskId));
|
||||
}
|
||||
|
||||
public static class StartResponse {
|
||||
private String taskId;
|
||||
|
||||
public StartResponse(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
|
||||
public String getTaskId() {
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public void setTaskId(String taskId) {
|
||||
this.taskId = taskId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
21
backend/src/main/java/com/music/dto/AggregateRequest.java
Normal file
21
backend/src/main/java/com/music/dto/AggregateRequest.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@Data
|
||||
public class AggregateRequest {
|
||||
|
||||
@NotBlank(message = "源目录不能为空")
|
||||
private String srcDir;
|
||||
|
||||
@NotBlank(message = "目标目录不能为空")
|
||||
private String dstDir;
|
||||
|
||||
/**
|
||||
* 模式:copy(复制模式)或 move(移动模式)
|
||||
*/
|
||||
@NotBlank(message = "模式不能为空")
|
||||
private String mode;
|
||||
}
|
||||
21
backend/src/main/java/com/music/dto/ConvertRequest.java
Normal file
21
backend/src/main/java/com/music/dto/ConvertRequest.java
Normal file
@@ -0,0 +1,21 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
@Data
|
||||
public class ConvertRequest {
|
||||
|
||||
@NotBlank(message = "输入目录不能为空")
|
||||
private String srcDir;
|
||||
|
||||
@NotBlank(message = "输出目录不能为空")
|
||||
private String dstDir;
|
||||
|
||||
/**
|
||||
* 模式:copy(复制模式)或 move(移动模式)
|
||||
*/
|
||||
@NotBlank(message = "模式不能为空")
|
||||
private String mode;
|
||||
}
|
||||
41
backend/src/main/java/com/music/dto/DedupRequest.java
Normal file
41
backend/src/main/java/com/music/dto/DedupRequest.java
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 音乐去重任务请求
|
||||
*/
|
||||
@Data
|
||||
public class DedupRequest {
|
||||
|
||||
/**
|
||||
* 音乐库根目录
|
||||
*/
|
||||
@NotBlank(message = "音乐库目录不能为空")
|
||||
private String libraryDir;
|
||||
|
||||
/**
|
||||
* 回收站目录(重复文件移动/复制到此目录)
|
||||
*/
|
||||
@NotBlank(message = "回收站目录不能为空")
|
||||
private String trashDir;
|
||||
|
||||
/**
|
||||
* 是否启用 MD5 哈希去重
|
||||
*/
|
||||
private boolean useMd5 = true;
|
||||
|
||||
/**
|
||||
* 是否启用元数据匹配去重
|
||||
*/
|
||||
private boolean useMetadata = true;
|
||||
|
||||
/**
|
||||
* 执行模式:copy(复制)或 move(移动)
|
||||
*/
|
||||
@NotBlank(message = "执行模式不能为空")
|
||||
private String mode;
|
||||
}
|
||||
|
||||
18
backend/src/main/java/com/music/dto/ProgressMessage.java
Normal file
18
backend/src/main/java/com/music/dto/ProgressMessage.java
Normal file
@@ -0,0 +1,18 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ProgressMessage {
|
||||
|
||||
private String taskId; // 任务 ID
|
||||
private String type; // 任务类型,如 aggregate/convert/dedup 等
|
||||
private int total; // 总数
|
||||
private int processed; // 已处理
|
||||
private int success; // 成功数
|
||||
private int failed; // 失败数
|
||||
private String currentFile; // 当前处理文件
|
||||
private String message; // 进度消息
|
||||
private boolean completed; // 是否完成
|
||||
}
|
||||
|
||||
44
backend/src/main/java/com/music/dto/ZhConvertRequest.java
Normal file
44
backend/src/main/java/com/music/dto/ZhConvertRequest.java
Normal file
@@ -0,0 +1,44 @@
|
||||
package com.music.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.Max;
|
||||
import javax.validation.constraints.Min;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* 音乐元数据繁体转简体任务请求
|
||||
*/
|
||||
@Data
|
||||
public class ZhConvertRequest {
|
||||
|
||||
/**
|
||||
* 待扫描标签的根目录
|
||||
*/
|
||||
@NotBlank(message = "扫描目录不能为空")
|
||||
private String scanDir;
|
||||
|
||||
/**
|
||||
* 输出目录(可选)
|
||||
*
|
||||
* - 预览模式:忽略该字段
|
||||
* - 执行模式:
|
||||
* - 为空:在原文件上就地修改标签
|
||||
* - 非空:在该目录下生成一份带简体标签的副本
|
||||
*/
|
||||
private String outputDir;
|
||||
|
||||
/**
|
||||
* 繁体占比阈值(百分比 1-100)
|
||||
*/
|
||||
@Min(value = 1, message = "繁体占比阈值不能小于 1%")
|
||||
@Max(value = 100, message = "繁体占比阈值不能大于 100%")
|
||||
private int threshold;
|
||||
|
||||
/**
|
||||
* 处理模式:preview(仅预览) 或 execute(执行转换)
|
||||
*/
|
||||
@NotBlank(message = "处理模式不能为空")
|
||||
private String mode;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.music.exception;
|
||||
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final int code;
|
||||
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.music.exception;
|
||||
|
||||
import com.music.common.Result;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public Result<Void> handleBusinessException(BusinessException ex) {
|
||||
return Result.failure(ex.getCode(), ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MethodArgumentNotValidException.class,
|
||||
BindException.class,
|
||||
HttpMessageNotReadableException.class
|
||||
})
|
||||
public Result<Void> handleValidationException(Exception ex) {
|
||||
return Result.failure(400, "请求参数错误:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public Result<Void> handleOther(Exception ex) {
|
||||
return Result.failure(500, "服务器内部错误:" + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
202
backend/src/main/java/com/music/service/AggregatorService.java
Normal file
202
backend/src/main/java/com/music/service/AggregatorService.java
Normal file
@@ -0,0 +1,202 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Service
|
||||
public class AggregatorService {
|
||||
|
||||
private static final Set<String> AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape"
|
||||
));
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ProgressStore progressStore;
|
||||
|
||||
public AggregatorService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步执行音频文件汇聚任务
|
||||
*/
|
||||
@Async
|
||||
public void aggregate(String taskId, String srcDir, String dstDir, String mode) {
|
||||
Path sourcePath = Paths.get(srcDir);
|
||||
Path targetPath = Paths.get(dstDir);
|
||||
|
||||
try {
|
||||
// 验证源目录
|
||||
if (!Files.exists(sourcePath) || !Files.isDirectory(sourcePath)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "源目录不存在或不是目录", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建目标目录
|
||||
if (!Files.exists(targetPath)) {
|
||||
Files.createDirectories(targetPath);
|
||||
}
|
||||
|
||||
// 收集所有音频文件
|
||||
List<Path> audioFiles = new ArrayList<>();
|
||||
Files.walkFileTree(sourcePath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (isAudioFile(file)) {
|
||||
audioFiles.add(file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
int total = audioFiles.size();
|
||||
AtomicInteger processed = new AtomicInteger(0);
|
||||
AtomicInteger success = new AtomicInteger(0);
|
||||
AtomicInteger failed = new AtomicInteger(0);
|
||||
|
||||
// 发送初始进度
|
||||
sendProgress(taskId, total, 0, 0, 0, null, "开始汇聚任务...", false);
|
||||
|
||||
// 处理每个文件
|
||||
for (Path sourceFile : audioFiles) {
|
||||
String fileName = sourceFile.getFileName().toString();
|
||||
try {
|
||||
Path targetFile = resolveTargetFile(targetPath, fileName);
|
||||
|
||||
if ("move".equalsIgnoreCase(mode)) {
|
||||
// 移动模式
|
||||
Files.move(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
} else {
|
||||
// 复制模式
|
||||
Files.copy(sourceFile, targetFile, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
success.incrementAndGet();
|
||||
sendProgress(taskId, total, processed.incrementAndGet(),
|
||||
success.get(), failed.get(), fileName,
|
||||
String.format("已处理: %s", fileName), false);
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
sendProgress(taskId, total, processed.incrementAndGet(),
|
||||
success.get(), failed.get(), fileName,
|
||||
String.format("处理失败: %s - %s", fileName, e.getMessage()), false);
|
||||
}
|
||||
}
|
||||
|
||||
// 移动模式下清理空目录
|
||||
if ("move".equalsIgnoreCase(mode)) {
|
||||
cleanupEmptyDirectories(sourcePath);
|
||||
}
|
||||
|
||||
// 发送完成消息
|
||||
sendProgress(taskId, total, processed.get(), success.get(), failed.get(),
|
||||
null, String.format("任务完成!成功: %d, 失败: %d", success.get(), failed.get()), true);
|
||||
|
||||
} catch (Exception e) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null,
|
||||
"任务执行失败: " + e.getMessage(), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为音频文件
|
||||
*/
|
||||
private boolean isAudioFile(Path file) {
|
||||
String fileName = file.getFileName().toString().toLowerCase();
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
if (lastDot == -1) {
|
||||
return false;
|
||||
}
|
||||
String extension = fileName.substring(lastDot + 1);
|
||||
return AUDIO_EXTENSIONS.contains(extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析目标文件路径,处理文件名冲突
|
||||
*/
|
||||
private Path resolveTargetFile(Path targetDir, String fileName) {
|
||||
Path targetFile = targetDir.resolve(fileName);
|
||||
|
||||
// 如果文件不存在,直接返回
|
||||
if (!Files.exists(targetFile)) {
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
// 处理文件名冲突:文件名 (1).ext, 文件名 (2).ext ...
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
String baseName;
|
||||
String extension;
|
||||
|
||||
if (lastDot == -1) {
|
||||
baseName = fileName;
|
||||
extension = "";
|
||||
} else {
|
||||
baseName = fileName.substring(0, lastDot);
|
||||
extension = fileName.substring(lastDot);
|
||||
}
|
||||
|
||||
int counter = 1;
|
||||
while (Files.exists(targetFile)) {
|
||||
String newFileName = String.format("%s (%d)%s", baseName, counter, extension);
|
||||
targetFile = targetDir.resolve(newFileName);
|
||||
counter++;
|
||||
}
|
||||
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理空目录(移动模式下使用)
|
||||
*/
|
||||
private void cleanupEmptyDirectories(Path rootPath) {
|
||||
try {
|
||||
Files.walkFileTree(rootPath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
|
||||
// 不删除根目录本身
|
||||
if (dir.equals(rootPath)) {
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(dir);
|
||||
} catch (DirectoryNotEmptyException e) {
|
||||
// 目录不为空,忽略
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
// 清理失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送进度消息
|
||||
*/
|
||||
private void sendProgress(String taskId, int total, int processed, int success,
|
||||
int failed, String currentFile, String message, boolean completed) {
|
||||
ProgressMessage progress = new ProgressMessage();
|
||||
progress.setTaskId(taskId);
|
||||
progress.setType("aggregate");
|
||||
progress.setTotal(total);
|
||||
progress.setProcessed(processed);
|
||||
progress.setSuccess(success);
|
||||
progress.setFailed(failed);
|
||||
progress.setCurrentFile(currentFile);
|
||||
progress.setMessage(message);
|
||||
progress.setCompleted(completed);
|
||||
|
||||
progressStore.put(progress);
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + taskId, progress);
|
||||
}
|
||||
}
|
||||
219
backend/src/main/java/com/music/service/ConvertService.java
Normal file
219
backend/src/main/java/com/music/service/ConvertService.java
Normal file
@@ -0,0 +1,219 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Service
|
||||
public class ConvertService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ConvertService.class);
|
||||
|
||||
/** 无损格式,需转换为 FLAC */
|
||||
private static final Set<String> LOSSLESS_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"wav", "ape", "aiff", "aif", "wv", "tta"
|
||||
));
|
||||
|
||||
/** 有损格式及 FLAC:跳过,不处理 */
|
||||
private static final Set<String> SKIP_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"flac", "mp3", "m4a", "aac", "ogg", "opus", "wma"
|
||||
));
|
||||
|
||||
private static final int FFMPEG_COMPRESSION_LEVEL = 5;
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ProgressStore progressStore;
|
||||
|
||||
public ConvertService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
}
|
||||
|
||||
@Async
|
||||
public void convert(String taskId, String srcDir, String dstDir, String mode) {
|
||||
Path srcPath = Paths.get(srcDir);
|
||||
Path dstPath = Paths.get(dstDir);
|
||||
|
||||
try {
|
||||
// 验证输入目录路径是否填写
|
||||
if (srcDir == null || srcDir.trim().isEmpty()) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "输入目录路径不能为空", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证输入目录是否存在
|
||||
if (!Files.exists(srcPath) || !Files.isDirectory(srcPath)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "输入目录不存在或不是目录", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证输出目录路径是否填写
|
||||
if (dstDir == null || dstDir.trim().isEmpty()) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "输出目录路径不能为空", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 移动模式下,输入和输出目录不能相同
|
||||
if ("move".equalsIgnoreCase(mode) && srcPath.equals(dstPath)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null, "移动模式下,输入目录和输出目录不能相同", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建输出目录(如果不存在)
|
||||
if (!Files.exists(dstPath)) {
|
||||
Files.createDirectories(dstPath);
|
||||
}
|
||||
|
||||
// 扫描输入目录,查找需要转换的文件
|
||||
List<Path> toConvert = new ArrayList<>();
|
||||
Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (shouldConvert(file)) {
|
||||
toConvert.add(file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
int total = toConvert.size();
|
||||
AtomicInteger processed = new AtomicInteger(0);
|
||||
AtomicInteger success = new AtomicInteger(0);
|
||||
AtomicInteger failed = new AtomicInteger(0);
|
||||
|
||||
log.info("转码任务开始,扫描目录: {}, 待转码文件数: {}", srcPath, total);
|
||||
sendProgress(taskId, total, 0, 0, 0, null, "扫描完成,开始转码任务...", false);
|
||||
|
||||
// 如果目录存在但没有需要转换的文件,正常完成任务
|
||||
if (total == 0) {
|
||||
log.info("目录 {} 中未找到需要转码的无损音频文件", srcPath);
|
||||
sendProgress(taskId, 0, 0, 0, 0, null,
|
||||
"目录扫描完成,未找到需要转码的无损音频文件(WAV/APE/AIFF/WV/TTA)。目录中可能只有 FLAC 或有损格式文件,已自动跳过。", true);
|
||||
return;
|
||||
}
|
||||
|
||||
for (Path srcFile : toConvert) {
|
||||
String fileName = srcFile.getFileName().toString();
|
||||
String baseName = getBaseName(fileName);
|
||||
String outFileName = baseName + ".flac";
|
||||
Path outFile = resolveTargetFile(dstPath, outFileName);
|
||||
|
||||
try {
|
||||
runFfmpeg(srcFile, outFile);
|
||||
success.incrementAndGet();
|
||||
|
||||
if ("move".equalsIgnoreCase(mode)) {
|
||||
try {
|
||||
Files.deleteIfExists(srcFile);
|
||||
} catch (IOException e) {
|
||||
log.warn("移动模式删除源文件失败: {}", srcFile, e);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(taskId, total, processed.incrementAndGet(),
|
||||
success.get(), failed.get(), fileName,
|
||||
"已处理: " + fileName, false);
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
log.warn("转码失败: {} - {}", fileName, e.getMessage());
|
||||
sendProgress(taskId, total, processed.incrementAndGet(),
|
||||
success.get(), failed.get(), fileName,
|
||||
"转码失败: " + fileName + " - " + e.getMessage(), false);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(taskId, total, processed.get(), success.get(), failed.get(),
|
||||
null, String.format("任务完成!成功: %d, 失败: %d", success.get(), failed.get()), true);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("转码任务执行失败", e);
|
||||
sendProgress(taskId, 0, 0, 0, 0, null,
|
||||
"任务执行失败: " + e.getMessage(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldConvert(Path file) {
|
||||
String ext = getExtension(file.getFileName().toString());
|
||||
if (ext == null) return false;
|
||||
return LOSSLESS_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
private String getExtension(String fileName) {
|
||||
int i = fileName.lastIndexOf('.');
|
||||
if (i == -1 || i == fileName.length() - 1) return null;
|
||||
return fileName.substring(i + 1).toLowerCase();
|
||||
}
|
||||
|
||||
private String getBaseName(String fileName) {
|
||||
int i = fileName.lastIndexOf('.');
|
||||
if (i <= 0) return fileName;
|
||||
return fileName.substring(0, i);
|
||||
}
|
||||
|
||||
private void runFfmpeg(Path input, Path output) throws IOException, InterruptedException {
|
||||
ProcessBuilder pb = new ProcessBuilder(
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i", input.toAbsolutePath().toString(),
|
||||
"-compression_level", String.valueOf(FFMPEG_COMPRESSION_LEVEL),
|
||||
output.toAbsolutePath().toString()
|
||||
);
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
int exit = p.waitFor();
|
||||
if (exit != 0) {
|
||||
throw new RuntimeException("ffmpeg 退出码: " + exit);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveTargetFile(Path targetDir, String fileName) throws IOException {
|
||||
Path target = targetDir.resolve(fileName);
|
||||
if (!Files.exists(target)) return target;
|
||||
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
String base = lastDot > 0 ? fileName.substring(0, lastDot) : fileName;
|
||||
String ext = lastDot > 0 ? fileName.substring(lastDot) : "";
|
||||
int n = 1;
|
||||
while (Files.exists(target)) {
|
||||
String next = base + " (" + n + ")" + ext;
|
||||
target = targetDir.resolve(next);
|
||||
n++;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
private void sendProgress(String taskId, int total, int processed, int success,
|
||||
int failed, String currentFile, String message, boolean completed) {
|
||||
try {
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId(taskId);
|
||||
pm.setType("convert");
|
||||
pm.setTotal(total);
|
||||
pm.setProcessed(processed);
|
||||
pm.setSuccess(success);
|
||||
pm.setFailed(failed);
|
||||
pm.setCurrentFile(currentFile);
|
||||
pm.setMessage(message);
|
||||
pm.setCompleted(completed);
|
||||
progressStore.put(pm);
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
|
||||
log.debug("发送进度消息: taskId={}, total={}, processed={}, success={}, failed={}, completed={}",
|
||||
taskId, total, processed, success, failed, completed);
|
||||
} catch (Exception e) {
|
||||
log.error("发送进度消息失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
454
backend/src/main/java/com/music/service/DedupService.java
Normal file
454
backend/src/main/java/com/music/service/DedupService.java
Normal file
@@ -0,0 +1,454 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.jaudiotagger.tag.FieldKey;
|
||||
import org.jaudiotagger.tag.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 音乐去重服务(首版:仅实现 MD5 去重)
|
||||
*
|
||||
* 说明:
|
||||
* - 目前实现的是基于 MD5 的二进制级别去重,用于识别完全相同的文件拷贝。
|
||||
* - 元数据匹配与智能评分策略后续迭代中补充。
|
||||
*/
|
||||
@Service
|
||||
public class DedupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DedupService.class);
|
||||
|
||||
private static final Set<String> AUDIO_EXTENSIONS = new HashSet<>(Arrays.asList(
|
||||
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus"
|
||||
));
|
||||
|
||||
/** 元数据匹配允许的时长误差(秒) */
|
||||
private static final int DURATION_TOLERANCE_SECONDS = 5;
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ProgressStore progressStore;
|
||||
|
||||
public DedupService(SimpMessagingTemplate messagingTemplate, ProgressStore progressStore) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步执行去重任务
|
||||
*/
|
||||
@Async
|
||||
public void dedup(String taskId,
|
||||
String libraryDir,
|
||||
String trashDir,
|
||||
boolean useMd5,
|
||||
boolean useMetadata,
|
||||
String mode) {
|
||||
|
||||
Path libraryPath = Paths.get(libraryDir);
|
||||
Path trashPath = Paths.get(trashDir);
|
||||
|
||||
try {
|
||||
// 基本校验
|
||||
if (!Files.exists(libraryPath) || !Files.isDirectory(libraryPath)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0,
|
||||
"音乐库目录不存在或不是目录", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Files.exists(trashPath)) {
|
||||
Files.createDirectories(trashPath);
|
||||
}
|
||||
|
||||
if (!"copy".equalsIgnoreCase(mode) && !"move".equalsIgnoreCase(mode)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0,
|
||||
"执行模式错误,必须是 copy 或 move", true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!useMd5 && !useMetadata) {
|
||||
sendProgress(taskId, 0, 0, 0, 0,
|
||||
"至少需要启用一种去重策略(MD5 或元数据匹配)", true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 收集所有音频文件
|
||||
List<Path> audioFiles = new ArrayList<>();
|
||||
Files.walkFileTree(libraryPath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (isAudioFile(file)) {
|
||||
audioFiles.add(file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
int total = audioFiles.size();
|
||||
if (total == 0) {
|
||||
sendProgress(taskId, 0, 0, 0, 0,
|
||||
"未在音乐库中找到音频文件", true);
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicInteger scanned = new AtomicInteger(0);
|
||||
AtomicInteger duplicateGroups = new AtomicInteger(0);
|
||||
AtomicInteger moved = new AtomicInteger(0);
|
||||
AtomicInteger failed = new AtomicInteger(0);
|
||||
|
||||
sendProgress(taskId, total, 0, 0, 0,
|
||||
"开始扫描音乐库...", false);
|
||||
|
||||
Map<String, List<Path>> md5Groups = new HashMap<>();
|
||||
Map<MetadataKey, List<Path>> metadataGroups = new HashMap<>();
|
||||
|
||||
// 第一阶段:扫描并根据配置构建分组
|
||||
for (Path file : audioFiles) {
|
||||
try {
|
||||
if (useMd5) {
|
||||
String md5 = calculateMd5(file);
|
||||
md5Groups.computeIfAbsent(md5, k -> new ArrayList<>()).add(file);
|
||||
}
|
||||
|
||||
if (useMetadata) {
|
||||
Optional<MetadataKey> keyOpt = readMetadataKey(file);
|
||||
keyOpt.ifPresent(key -> metadataGroups
|
||||
.computeIfAbsent(key, k -> new ArrayList<>())
|
||||
.add(file));
|
||||
}
|
||||
|
||||
int currentScanned = scanned.incrementAndGet();
|
||||
if (currentScanned % 50 == 0) {
|
||||
sendProgress(taskId, total, currentScanned,
|
||||
duplicateGroups.get(), moved.get(),
|
||||
String.format("扫描中(%d/%d)", currentScanned, total),
|
||||
false);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
log.warn("扫描文件失败: {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
// 第二阶段:处理 MD5 去重结果(完全二进制重复)
|
||||
if (useMd5) {
|
||||
for (Map.Entry<String, List<Path>> entry : md5Groups.entrySet()) {
|
||||
List<Path> group = entry.getValue();
|
||||
if (group.size() <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
duplicateGroups.incrementAndGet();
|
||||
|
||||
Path keep = chooseBestFileByScore(group);
|
||||
List<Path> duplicates = new ArrayList<>(group);
|
||||
duplicates.remove(keep);
|
||||
|
||||
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
|
||||
scanned, duplicateGroups, failed));
|
||||
}
|
||||
}
|
||||
|
||||
if (useMetadata) {
|
||||
// 第三阶段:处理元数据匹配去重结果
|
||||
for (Map.Entry<MetadataKey, List<Path>> entry : metadataGroups.entrySet()) {
|
||||
List<Path> group = entry.getValue();
|
||||
if (group.size() <= 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
duplicateGroups.incrementAndGet();
|
||||
|
||||
Path keep = chooseBestFileByScore(group);
|
||||
List<Path> duplicates = new ArrayList<>(group);
|
||||
duplicates.remove(keep);
|
||||
|
||||
moved.addAndGet(handleDuplicates(duplicates, keep, trashPath, mode, taskId, total,
|
||||
scanned, duplicateGroups, failed));
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(taskId, total, scanned.get(),
|
||||
duplicateGroups.get(), moved.get(),
|
||||
String.format("任务完成!扫描文件: %d, 重复组: %d, 移动/复制文件: %d",
|
||||
scanned.get(), duplicateGroups.get(), moved.get()),
|
||||
true);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("去重任务执行失败", e);
|
||||
sendProgress(taskId, 0, 0, 0, 0,
|
||||
"任务执行失败: " + e.getMessage(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isAudioFile(Path file) {
|
||||
String name = file.getFileName().toString().toLowerCase();
|
||||
int idx = name.lastIndexOf('.');
|
||||
if (idx <= 0 || idx == name.length() - 1) {
|
||||
return false;
|
||||
}
|
||||
String ext = name.substring(idx + 1);
|
||||
return AUDIO_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
private String calculateMd5(Path file) throws IOException, NoSuchAlgorithmException {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
try (InputStream is = Files.newInputStream(file);
|
||||
DigestInputStream dis = new DigestInputStream(is, md)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
// 读取整个文件,结果自动更新到 md 中
|
||||
while (dis.read(buffer) != -1) {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
byte[] digest = md.digest();
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据分组键:艺术家 + 标题 + 专辑 + 时长(按 5 秒误差归一)
|
||||
*/
|
||||
private static class MetadataKey {
|
||||
private final String artist;
|
||||
private final String title;
|
||||
private final String album;
|
||||
private final int normalizedDuration;
|
||||
|
||||
private MetadataKey(String artist, String title, String album, int normalizedDuration) {
|
||||
this.artist = artist;
|
||||
this.title = title;
|
||||
this.album = album;
|
||||
this.normalizedDuration = normalizedDuration;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof MetadataKey)) return false;
|
||||
MetadataKey that = (MetadataKey) o;
|
||||
return normalizedDuration == that.normalizedDuration &&
|
||||
Objects.equals(artist, that.artist) &&
|
||||
Objects.equals(title, that.title) &&
|
||||
Objects.equals(album, that.album);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(artist, title, album, normalizedDuration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从音频文件读取用于匹配的元数据键
|
||||
*/
|
||||
private Optional<MetadataKey> readMetadataKey(Path file) {
|
||||
try {
|
||||
AudioFile audioFile = AudioFileIO.read(file.toFile());
|
||||
Tag tag = audioFile.getTag();
|
||||
if (tag == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String artist = normalize(tag.getFirst(FieldKey.ARTIST));
|
||||
String title = normalize(tag.getFirst(FieldKey.TITLE));
|
||||
String album = normalize(tag.getFirst(FieldKey.ALBUM));
|
||||
int lengthSec = audioFile.getAudioHeader().getTrackLength();
|
||||
|
||||
if (artist.isEmpty() || title.isEmpty()) {
|
||||
// 核心标签缺失则跳过元数据分组
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
// 将时长按 5 秒误差容忍度归一化
|
||||
int normalizedDuration = lengthSec / DURATION_TOLERANCE_SECONDS;
|
||||
|
||||
return Optional.of(new MetadataKey(artist, title, album, normalizedDuration));
|
||||
} catch (Exception e) {
|
||||
// 标签损坏或不支持的格式时,忽略元数据去重
|
||||
log.debug("读取元数据失败: {}", file, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一组候选文件进行综合评分,选择最佳保留文件
|
||||
*
|
||||
* 评分策略:
|
||||
* - 格式优先:FLAC > 其他无损 > 有损
|
||||
* - 码率优先:高码率得分更高(如果可获取)
|
||||
* - 文件大小:极小文件减分
|
||||
* - 文件名噪声惩罚:含样本/preview 等噪声词减分
|
||||
*/
|
||||
private Path chooseBestFileByScore(List<Path> candidates) {
|
||||
if (candidates.size() == 1) {
|
||||
return candidates.get(0);
|
||||
}
|
||||
|
||||
return candidates.stream()
|
||||
.max(Comparator.comparingDouble(this::scoreFile))
|
||||
.orElse(candidates.get(0));
|
||||
}
|
||||
|
||||
private double scoreFile(Path file) {
|
||||
double score = 0.0;
|
||||
|
||||
String name = file.getFileName().toString().toLowerCase();
|
||||
String ext = "";
|
||||
int idx = name.lastIndexOf('.');
|
||||
if (idx > 0 && idx < name.length() - 1) {
|
||||
ext = name.substring(idx + 1);
|
||||
}
|
||||
|
||||
// 格式权重
|
||||
if ("flac".equals(ext)) {
|
||||
score += 100;
|
||||
} else if (Arrays.asList("wav", "ape", "aiff", "aif", "wv", "tta").contains(ext)) {
|
||||
score += 80;
|
||||
} else {
|
||||
score += 50; // 有损格式
|
||||
}
|
||||
|
||||
// 文件大小(KB)加权:更大的通常音质更好,但极大文件不再线性加分
|
||||
try {
|
||||
long size = Files.size(file);
|
||||
double sizeKB = size / 1024.0;
|
||||
if (sizeKB < 128) {
|
||||
score -= 30; // 极小文件,疑似样本/损坏
|
||||
} else {
|
||||
score += Math.min(sizeKB / 100.0, 40.0);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// 忽略大小获取失败
|
||||
}
|
||||
|
||||
// 文件名噪声惩罚
|
||||
if (name.contains("sample") || name.contains("preview") || name.contains("demo")) {
|
||||
score -= 20;
|
||||
}
|
||||
if (name.matches(".*\\b(live|remix|karaoke)\\b.*")) {
|
||||
// 某些版本可能不是首选,略微扣分(具体偏好可根据需要调整)
|
||||
score -= 5;
|
||||
}
|
||||
|
||||
// TODO:如有需要,可从音频头中读取比特率,进一步加权
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将重复文件移动/复制到回收站,并更新统计与进度
|
||||
*
|
||||
* @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;
|
||||
for (Path dup : duplicates) {
|
||||
try {
|
||||
Path target = resolveTargetFile(trashPath, dup.getFileName().toString());
|
||||
if ("move".equalsIgnoreCase(mode)) {
|
||||
Files.move(dup, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
} else {
|
||||
Files.copy(dup, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
movedCount++;
|
||||
sendProgress(taskId, total, scanned.get(),
|
||||
duplicateGroups.get(), movedCount,
|
||||
String.format("重复文件: %s (保留: %s)",
|
||||
dup.getFileName(), keep.getFileName()),
|
||||
false);
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
log.warn("处理重复文件失败: {}", dup, e);
|
||||
}
|
||||
}
|
||||
return movedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析回收站中的目标文件名,处理重名冲突
|
||||
*/
|
||||
private Path resolveTargetFile(Path targetDir, String fileName) throws IOException {
|
||||
Path target = targetDir.resolve(fileName);
|
||||
if (!Files.exists(target)) {
|
||||
return target;
|
||||
}
|
||||
|
||||
int lastDot = fileName.lastIndexOf('.');
|
||||
String base = lastDot > 0 ? fileName.substring(0, lastDot) : fileName;
|
||||
String ext = lastDot > 0 ? fileName.substring(lastDot) : "";
|
||||
int n = 1;
|
||||
while (Files.exists(target)) {
|
||||
String next = base + " (" + n + ")" + ext;
|
||||
target = targetDir.resolve(next);
|
||||
n++;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送进度消息
|
||||
*
|
||||
* 字段语义(供前端展示用):
|
||||
* - total:扫描到的音频文件总数
|
||||
* - processed:已扫描文件数
|
||||
* - success:重复组数量
|
||||
* - failed:移动/复制的重复文件数量
|
||||
*
|
||||
* 由于进度字段在不同任务中的含义略有差异,前端可根据 type === "dedup" 做专门映射。
|
||||
*/
|
||||
private void sendProgress(String taskId,
|
||||
int total,
|
||||
int processed,
|
||||
int success,
|
||||
int failed,
|
||||
String message,
|
||||
boolean completed) {
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId(taskId);
|
||||
pm.setType("dedup");
|
||||
pm.setTotal(total);
|
||||
pm.setProcessed(processed);
|
||||
pm.setSuccess(success);
|
||||
pm.setFailed(failed);
|
||||
pm.setCurrentFile(null);
|
||||
pm.setMessage(message);
|
||||
pm.setCompleted(completed);
|
||||
|
||||
progressStore.put(pm);
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
|
||||
}
|
||||
}
|
||||
|
||||
30
backend/src/main/java/com/music/service/ProgressStore.java
Normal file
30
backend/src/main/java/com/music/service/ProgressStore.java
Normal file
@@ -0,0 +1,30 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 进度缓存:用于 WebSocket 消息丢失时的兜底查询
|
||||
*/
|
||||
@Service
|
||||
public class ProgressStore {
|
||||
|
||||
private final ConcurrentHashMap<String, ProgressMessage> latest = new ConcurrentHashMap<>();
|
||||
|
||||
public void put(ProgressMessage message) {
|
||||
if (message == null || message.getTaskId() == null) {
|
||||
return;
|
||||
}
|
||||
latest.put(message.getTaskId(), message);
|
||||
}
|
||||
|
||||
public ProgressMessage get(String taskId) {
|
||||
if (taskId == null) {
|
||||
return null;
|
||||
}
|
||||
return latest.get(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.music.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 繁体检测与转换工具服务
|
||||
*
|
||||
* <p>说明:
|
||||
* - 这里实现的是一个轻量级的基于字典的繁简转换和检测
|
||||
* - 仅覆盖常见的繁体字,后续可接入更完整的第三方词库或服务
|
||||
*/
|
||||
@Service
|
||||
public class TraditionalFilterService {
|
||||
|
||||
/**
|
||||
* 常见繁体字符映射表(可按需扩展)
|
||||
*/
|
||||
private static final Map<Character, Character> TRAD_TO_SIMP_MAP = new HashMap<>();
|
||||
|
||||
/**
|
||||
* 常见繁体字符集合,加速检测
|
||||
*/
|
||||
private static final Set<Character> TRADITIONAL_SET = new HashSet<>();
|
||||
|
||||
static {
|
||||
// 人名 / 常用字
|
||||
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('聲', '声');
|
||||
|
||||
// 可以根据需要逐步补充更多常见映射
|
||||
}
|
||||
|
||||
private static void addMapping(char trad, char simp) {
|
||||
TRAD_TO_SIMP_MAP.put(trad, simp);
|
||||
TRADITIONAL_SET.add(trad);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测字符串中的中文/繁体字数量
|
||||
*/
|
||||
public ZhStats analyze(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return new ZhStats(0, 0);
|
||||
}
|
||||
|
||||
int chineseCount = 0;
|
||||
int 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串中的常见繁体字转换为简体
|
||||
*/
|
||||
public String toSimplified(String text) {
|
||||
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);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为 CJK 统一表意文字(大致范围)
|
||||
*/
|
||||
private boolean isCjk(char c) {
|
||||
// 基本汉字 + 扩展区的一部分
|
||||
return (c >= 0x4E00 && c <= 0x9FFF) // CJK Unified Ideographs
|
||||
|| (c >= 0x3400 && c <= 0x4DBF); // CJK Unified Ideographs Extension A
|
||||
}
|
||||
|
||||
/**
|
||||
* 繁体检测统计结果
|
||||
*/
|
||||
public static class ZhStats {
|
||||
private final int chineseCount;
|
||||
private final int traditionalCount;
|
||||
|
||||
public ZhStats(int chineseCount, int traditionalCount) {
|
||||
this.chineseCount = chineseCount;
|
||||
this.traditionalCount = traditionalCount;
|
||||
}
|
||||
|
||||
public int getChineseCount() {
|
||||
return chineseCount;
|
||||
}
|
||||
|
||||
public int getTraditionalCount() {
|
||||
return traditionalCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 繁体占比(0.0 - 1.0)
|
||||
*/
|
||||
public double getRatio() {
|
||||
if (chineseCount <= 0) {
|
||||
return 0.0;
|
||||
}
|
||||
return (double) traditionalCount / (double) chineseCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
286
backend/src/main/java/com/music/service/ZhConvertService.java
Normal file
286
backend/src/main/java/com/music/service/ZhConvertService.java
Normal file
@@ -0,0 +1,286 @@
|
||||
package com.music.service;
|
||||
|
||||
import com.music.dto.ProgressMessage;
|
||||
import com.music.service.TraditionalFilterService.ZhStats;
|
||||
import org.jaudiotagger.audio.AudioFile;
|
||||
import org.jaudiotagger.audio.AudioFileIO;
|
||||
import org.jaudiotagger.tag.FieldKey;
|
||||
import org.jaudiotagger.tag.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 音乐元数据繁体转简体服务
|
||||
*
|
||||
* <p>进度字段语义(type = "zhconvert"):
|
||||
* - total:扫描到的音频文件总数
|
||||
* - processed:已扫描文件数
|
||||
* - success:检测到的繁体标签条目数量(字段级:Title/Artist/Album/AlbumArtist)
|
||||
* - failed:处理失败的文件数量
|
||||
* - currentFile:当前正在处理的文件名
|
||||
*/
|
||||
@Service
|
||||
public class ZhConvertService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ZhConvertService.class);
|
||||
|
||||
private static final Set<String> AUDIO_EXTENSIONS = ConcurrentHashMap.newKeySet();
|
||||
|
||||
static {
|
||||
AUDIO_EXTENSIONS.addAll(Arrays.asList(
|
||||
"mp3", "flac", "wav", "m4a", "aac", "ogg", "wma", "ape", "aiff", "aif", "wv", "tta", "opus"
|
||||
));
|
||||
}
|
||||
|
||||
private final SimpMessagingTemplate messagingTemplate;
|
||||
private final ProgressStore progressStore;
|
||||
private final TraditionalFilterService traditionalFilterService;
|
||||
|
||||
public ZhConvertService(SimpMessagingTemplate messagingTemplate,
|
||||
ProgressStore progressStore,
|
||||
TraditionalFilterService traditionalFilterService) {
|
||||
this.messagingTemplate = messagingTemplate;
|
||||
this.progressStore = progressStore;
|
||||
this.traditionalFilterService = traditionalFilterService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步执行繁体检测与转换任务
|
||||
*
|
||||
* @param mode preview / execute
|
||||
* @param thresholdRatio 触发阈值(0.0-1.0)
|
||||
*/
|
||||
@Async
|
||||
public void process(String taskId,
|
||||
String scanDir,
|
||||
String outputDir,
|
||||
String mode,
|
||||
double thresholdRatio) {
|
||||
Path scanPath = Paths.get(scanDir);
|
||||
|
||||
try {
|
||||
if (!Files.exists(scanPath) || !Files.isDirectory(scanPath)) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null,
|
||||
"扫描目录不存在或不是目录", true);
|
||||
return;
|
||||
}
|
||||
|
||||
Path outputPath = null;
|
||||
boolean execute = "execute".equalsIgnoreCase(mode);
|
||||
if (execute && outputDir != null && !outputDir.trim().isEmpty()) {
|
||||
outputPath = Paths.get(outputDir.trim());
|
||||
if (!Files.exists(outputPath)) {
|
||||
Files.createDirectories(outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
// 收集音频文件
|
||||
List<Path> audioFiles = new ArrayList<>();
|
||||
Files.walkFileTree(scanPath, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) {
|
||||
if (isAudioFile(file)) {
|
||||
audioFiles.add(file);
|
||||
}
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
|
||||
int total = audioFiles.size();
|
||||
if (total == 0) {
|
||||
sendProgress(taskId, 0, 0, 0, 0, null,
|
||||
"目录中未找到音频文件", true);
|
||||
return;
|
||||
}
|
||||
|
||||
AtomicInteger processed = new AtomicInteger(0);
|
||||
AtomicInteger traditionalEntries = new AtomicInteger(0);
|
||||
AtomicInteger failed = new AtomicInteger(0);
|
||||
|
||||
sendProgress(taskId, total, 0, 0, 0, null,
|
||||
execute ? "开始执行繁体→简体转换任务..." : "开始繁体检测预览任务...", false);
|
||||
|
||||
for (Path srcFile : audioFiles) {
|
||||
String fileName = srcFile.getFileName().toString();
|
||||
try {
|
||||
Path targetFile = resolveTargetFileForExecute(srcFile, outputPath, execute);
|
||||
|
||||
int addedEntries = handleSingleFile(targetFile, thresholdRatio, execute);
|
||||
if (addedEntries > 0) {
|
||||
traditionalEntries.addAndGet(addedEntries);
|
||||
}
|
||||
|
||||
int currentProcessed = processed.incrementAndGet();
|
||||
sendProgress(taskId,
|
||||
total,
|
||||
currentProcessed,
|
||||
traditionalEntries.get(),
|
||||
failed.get(),
|
||||
fileName,
|
||||
String.format("已扫描 (%d/%d):%s", currentProcessed, total, fileName),
|
||||
false);
|
||||
} catch (Exception e) {
|
||||
failed.incrementAndGet();
|
||||
int currentProcessed = processed.incrementAndGet();
|
||||
log.warn("处理文件失败: {}", srcFile, e);
|
||||
sendProgress(taskId,
|
||||
total,
|
||||
currentProcessed,
|
||||
traditionalEntries.get(),
|
||||
failed.get(),
|
||||
fileName,
|
||||
String.format("处理失败: %s - %s", fileName, e.getMessage()),
|
||||
false);
|
||||
}
|
||||
}
|
||||
|
||||
sendProgress(taskId,
|
||||
total,
|
||||
processed.get(),
|
||||
traditionalEntries.get(),
|
||||
failed.get(),
|
||||
null,
|
||||
String.format("任务完成!检测到繁体标签条目: %d,失败文件: %d",
|
||||
traditionalEntries.get(), failed.get()),
|
||||
true);
|
||||
} catch (Exception e) {
|
||||
log.error("繁体转换任务执行失败", e);
|
||||
sendProgress(taskId, 0, 0, 0, 0, null,
|
||||
"任务执行失败: " + e.getMessage(), true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个音频文件
|
||||
*
|
||||
* @return 新增的“繁体标签条目”数量(字段级)
|
||||
*/
|
||||
private int handleSingleFile(Path file, double thresholdRatio, boolean execute) throws Exception {
|
||||
AudioFile audioFile = AudioFileIO.read(file.toFile());
|
||||
Tag tag = audioFile.getTag();
|
||||
if (tag == null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int entries = 0;
|
||||
|
||||
entries += handleSingleField(tag, FieldKey.TITLE, thresholdRatio, execute);
|
||||
entries += handleSingleField(tag, FieldKey.ARTIST, thresholdRatio, execute);
|
||||
entries += handleSingleField(tag, FieldKey.ALBUM, thresholdRatio, execute);
|
||||
entries += handleSingleField(tag, FieldKey.ALBUM_ARTIST, thresholdRatio, execute);
|
||||
|
||||
// 如果是执行模式且有字段被修改,则写回文件
|
||||
if (execute && entries > 0) {
|
||||
audioFile.commit();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个标签字段:检测 + 可选转换
|
||||
*
|
||||
* @return 如果该字段触发阈值,则返回 1,否则返回 0
|
||||
*/
|
||||
private int handleSingleField(Tag tag,
|
||||
FieldKey fieldKey,
|
||||
double thresholdRatio,
|
||||
boolean execute) {
|
||||
String original = tag.getFirst(fieldKey);
|
||||
if (original == null || original.trim().isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
ZhStats stats = traditionalFilterService.analyze(original);
|
||||
if (stats.getTraditionalCount() <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (stats.getRatio() < thresholdRatio) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (execute) {
|
||||
String converted = traditionalFilterService.toSimplified(original);
|
||||
try {
|
||||
tag.setField(fieldKey, converted);
|
||||
} catch (Exception e) {
|
||||
// 单个字段失败不影响整体流程
|
||||
log.debug("更新标签字段失败: {} - {}", fieldKey.name(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 该字段计为 1 个“繁体标签条目”
|
||||
return 1;
|
||||
}
|
||||
|
||||
private boolean isAudioFile(Path file) {
|
||||
String name = file.getFileName().toString().toLowerCase();
|
||||
int idx = name.lastIndexOf('.');
|
||||
if (idx <= 0 || idx == name.length() - 1) {
|
||||
return false;
|
||||
}
|
||||
String ext = name.substring(idx + 1);
|
||||
return AUDIO_EXTENSIONS.contains(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定实际处理的目标文件:
|
||||
* - 预览模式:始终返回原文件路径
|
||||
* - 执行模式:
|
||||
* - 未配置输出目录:在原文件上就地修改
|
||||
* - 配置了输出目录:先复制到输出目录,再在副本上修改
|
||||
*/
|
||||
private Path resolveTargetFileForExecute(Path srcFile,
|
||||
Path outputPath,
|
||||
boolean execute) throws Exception {
|
||||
if (!execute || outputPath == null) {
|
||||
return srcFile;
|
||||
}
|
||||
|
||||
Path targetFile = outputPath.resolve(srcFile.getFileName());
|
||||
if (!Files.exists(targetFile)) {
|
||||
Files.copy(srcFile, targetFile, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
}
|
||||
return targetFile;
|
||||
}
|
||||
|
||||
private void sendProgress(String taskId,
|
||||
int total,
|
||||
int processed,
|
||||
int entries,
|
||||
int failed,
|
||||
String currentFile,
|
||||
String message,
|
||||
boolean completed) {
|
||||
try {
|
||||
ProgressMessage pm = new ProgressMessage();
|
||||
pm.setTaskId(taskId);
|
||||
pm.setType("zhconvert");
|
||||
pm.setTotal(total);
|
||||
pm.setProcessed(processed);
|
||||
pm.setSuccess(entries);
|
||||
pm.setFailed(failed);
|
||||
pm.setCurrentFile(currentFile);
|
||||
pm.setMessage(message);
|
||||
pm.setCompleted(completed);
|
||||
progressStore.put(pm);
|
||||
messagingTemplate.convertAndSend("/topic/progress/" + taskId, pm);
|
||||
} catch (Exception e) {
|
||||
log.error("发送繁体转换进度消息失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
backend/src/main/resources/application.yml
Normal file
16
backend/src/main/resources/application.yml
Normal file
@@ -0,0 +1,16 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: mangtool-backend
|
||||
|
||||
jackson:
|
||||
serialization:
|
||||
write-dates-as-timestamps: false
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.music: DEBUG
|
||||
|
||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>MangTool 音乐工具箱</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<script>
|
||||
// Polyfill for sockjs-client
|
||||
if (typeof global === 'undefined') {
|
||||
var global = globalThis;
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
2085
frontend/package-lock.json
generated
Normal file
2085
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "mangtool-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo \"no linter configured yet\""
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"element-plus": "^2.8.8",
|
||||
"pinia": "^2.2.6",
|
||||
"sockjs-client": "^1.6.1",
|
||||
"stompjs": "^2.3.3",
|
||||
"vue": "^3.5.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
183
frontend/src/App.vue
Normal file
183
frontend/src/App.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<el-container class="app-root">
|
||||
<el-aside width="220px" class="app-aside">
|
||||
<div class="app-logo">
|
||||
<span class="app-logo-title">MangTool</span>
|
||||
<span class="app-logo-sub">音乐工具箱</span>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeKey"
|
||||
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>
|
||||
</el-aside>
|
||||
|
||||
<el-container>
|
||||
<el-header class="app-header">
|
||||
<div class="app-header-title">
|
||||
<h1>{{ currentTitle }}</h1>
|
||||
<p>{{ currentSubtitle }}</p>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="app-main">
|
||||
<component :is="currentComponent" />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</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';
|
||||
|
||||
type TabKey =
|
||||
| 'aggregate'
|
||||
| 'convert'
|
||||
| 'dedup'
|
||||
| 'zhconvert'
|
||||
| 'organize'
|
||||
| 'merge'
|
||||
| 'settings';
|
||||
|
||||
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 '';
|
||||
}
|
||||
});
|
||||
|
||||
function handleSelect(key: string) {
|
||||
activeKey.value = key as TabKey;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.app-aside {
|
||||
border-right: 1px solid #e5e7eb;
|
||||
padding: 16px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-logo {
|
||||
padding: 0 20px 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.app-logo-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.app-logo-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.app-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.app-header-title h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.app-header-title p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 16px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
</style>
|
||||
|
||||
18
frontend/src/api/aggregate.ts
Normal file
18
frontend/src/api/aggregate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from './request';
|
||||
|
||||
export interface AggregateRequest {
|
||||
srcDir: string;
|
||||
dstDir: string;
|
||||
mode: 'copy' | 'move';
|
||||
}
|
||||
|
||||
export interface AggregateResponse {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音频文件汇聚任务
|
||||
*/
|
||||
export function startAggregate(params: AggregateRequest): Promise<AggregateResponse> {
|
||||
return request.post('/api/aggregate/start', params);
|
||||
}
|
||||
18
frontend/src/api/convert.ts
Normal file
18
frontend/src/api/convert.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from './request';
|
||||
|
||||
export interface ConvertRequest {
|
||||
srcDir: string;
|
||||
dstDir: string;
|
||||
mode: 'copy' | 'move';
|
||||
}
|
||||
|
||||
export interface ConvertResponse {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音频格式智能处理(转码)任务
|
||||
*/
|
||||
export function startConvert(params: ConvertRequest): Promise<ConvertResponse> {
|
||||
return request.post('/api/convert/start', params);
|
||||
}
|
||||
21
frontend/src/api/dedup.ts
Normal file
21
frontend/src/api/dedup.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import request from './request';
|
||||
|
||||
export interface DedupRequest {
|
||||
libraryDir: string;
|
||||
trashDir: string;
|
||||
useMd5: boolean;
|
||||
useMetadata: boolean;
|
||||
mode: 'copy' | 'move';
|
||||
}
|
||||
|
||||
export interface DedupResponse {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音乐去重任务
|
||||
*/
|
||||
export function startDedup(params: DedupRequest): Promise<DedupResponse> {
|
||||
return request.post('/api/dedup/start', params);
|
||||
}
|
||||
|
||||
18
frontend/src/api/progress.ts
Normal file
18
frontend/src/api/progress.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import request from './request';
|
||||
|
||||
export interface ProgressMessage {
|
||||
taskId: string;
|
||||
type: string;
|
||||
total: number;
|
||||
processed: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
currentFile: string;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function getProgress(taskId: string): Promise<ProgressMessage | null> {
|
||||
return request.get(`/api/progress/${taskId}`);
|
||||
}
|
||||
|
||||
36
frontend/src/api/request.ts
Normal file
36
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'axios';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: 'http://localhost:8080',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// 响应拦截器:统一处理 Result<T> 格式
|
||||
request.interceptors.response.use(
|
||||
(response) => {
|
||||
const result = response.data;
|
||||
// 如果后端返回的是 Result<T> 格式
|
||||
if (result && typeof result === 'object' && 'code' in result) {
|
||||
if (result.code === 0) {
|
||||
// 成功,直接返回 data
|
||||
return result.data;
|
||||
} else {
|
||||
// 失败,显示错误信息并 reject
|
||||
ElMessage.error(result.message || '请求失败');
|
||||
return Promise.reject(new Error(result.message || '请求失败'));
|
||||
}
|
||||
}
|
||||
// 如果不是 Result 格式,直接返回
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
ElMessage.error(error.message || '网络错误');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
20
frontend/src/api/zhconvert.ts
Normal file
20
frontend/src/api/zhconvert.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import request from './request';
|
||||
|
||||
export interface ZhConvertRequest {
|
||||
scanDir: string;
|
||||
outputDir?: string;
|
||||
threshold: number;
|
||||
mode: 'preview' | 'execute';
|
||||
}
|
||||
|
||||
export interface ZhConvertResponse {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动音乐元数据繁体转简体任务
|
||||
*/
|
||||
export function startZhConvert(params: ZhConvertRequest): Promise<ZhConvertResponse> {
|
||||
return request.post('/api/zhconvert/start', params);
|
||||
}
|
||||
|
||||
526
frontend/src/components/AggregateTab.vue
Normal file
526
frontend/src/components/AggregateTab.vue
Normal file
@@ -0,0 +1,526 @@
|
||||
<template>
|
||||
<div class="aggregate-container">
|
||||
<el-row :gutter="24">
|
||||
<!-- 任务配置卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="100px" label-position="left">
|
||||
<el-form-item label="源目录" required>
|
||||
<el-input
|
||||
v-model="form.srcDir"
|
||||
placeholder="选择需要递归扫描的起始目录"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Folder /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="目标目录" required>
|
||||
<el-input
|
||||
v-model="form.dstDir"
|
||||
placeholder="汇聚后的扁平目录"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模式">
|
||||
<el-radio-group v-model="form.mode" size="default">
|
||||
<el-radio-button value="copy">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span style="margin-left: 4px">复制模式</span>
|
||||
</el-radio-button>
|
||||
<el-radio-button value="move">
|
||||
<el-icon><Right /></el-icon>
|
||||
<span style="margin-left: 4px">移动模式</span>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canStart"
|
||||
@click="startTask"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
|
||||
<span style="margin-left: 4px">{{ submitting ? '处理中...' : '开始汇聚' }}</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button @click="reset" size="default" style="width: 100%">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span style="margin-left: 4px">重置</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 任务进度卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">等待开始任务...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">总数</div>
|
||||
<div class="stat-value">{{ progress.total }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-value 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>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:status="progress.completed ? 'success' : progress.failed > 0 ? 'exception' : undefined"
|
||||
:stroke-width="12"
|
||||
:show-text="true"
|
||||
:format="formatProgress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前文件 -->
|
||||
<div v-if="progress.currentFile" class="current-file-section">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<span class="file-text">{{ progress.currentFile }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="progress.message" class="message-section">
|
||||
<el-alert
|
||||
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ progress.message }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
Setting,
|
||||
Folder,
|
||||
FolderOpened,
|
||||
DocumentCopy,
|
||||
Right,
|
||||
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';
|
||||
|
||||
interface Progress {
|
||||
taskId: string | null;
|
||||
total: number;
|
||||
processed: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
currentFile: string;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
srcDir: '',
|
||||
dstDir: '',
|
||||
mode: 'copy' as 'copy' | 'move'
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const wsConnected = ref(false);
|
||||
|
||||
const progress = reactive<Progress>({
|
||||
taskId: null,
|
||||
total: 0,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
currentFile: '',
|
||||
message: '',
|
||||
completed: false
|
||||
});
|
||||
|
||||
const canStart = computed(() => {
|
||||
return form.srcDir.trim() && form.dstDir.trim() && !submitting.value;
|
||||
});
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!progress.total) return 0;
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
// WebSocket 连接
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const latest = await getProgress(taskId);
|
||||
if (latest) {
|
||||
handleProgressMessage(latest);
|
||||
if (latest.completed) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
watch(connected, (val) => {
|
||||
wsConnected.value = val;
|
||||
});
|
||||
connect();
|
||||
// 兜底轮询:防止任务很快完成导致 WebSocket 消息丢失
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleProgressMessage(msg: ProgressMessage) {
|
||||
progress.total = msg.total;
|
||||
progress.processed = msg.processed;
|
||||
progress.success = msg.success;
|
||||
progress.failed = msg.failed;
|
||||
progress.currentFile = msg.currentFile || '';
|
||||
progress.message = msg.message || '';
|
||||
progress.completed = msg.completed;
|
||||
|
||||
if (msg.completed) {
|
||||
submitting.value = false;
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
async function startTask() {
|
||||
if (!canStart.value) {
|
||||
ElMessage.warning('请填写源目录和目标目录');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
progress.completed = false;
|
||||
progress.total = 0;
|
||||
progress.processed = 0;
|
||||
progress.success = 0;
|
||||
progress.failed = 0;
|
||||
progress.currentFile = '';
|
||||
progress.message = '';
|
||||
|
||||
try {
|
||||
const response = await startAggregate({
|
||||
srcDir: form.srcDir.trim(),
|
||||
dstDir: form.dstDir.trim(),
|
||||
mode: form.mode
|
||||
});
|
||||
|
||||
progress.taskId = response.taskId;
|
||||
ElMessage.success('任务已启动,正在处理...');
|
||||
} catch (error: any) {
|
||||
submitting.value = false;
|
||||
ElMessage.error(error.message || '启动任务失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
stopPolling();
|
||||
form.srcDir = '';
|
||||
form.dstDir = '';
|
||||
form.mode = 'copy';
|
||||
progress.taskId = null;
|
||||
progress.total = 0;
|
||||
progress.processed = 0;
|
||||
progress.success = 0;
|
||||
progress.failed = 0;
|
||||
progress.currentFile = '';
|
||||
progress.message = '';
|
||||
progress.completed = false;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
function formatProgress(percentage: number): string {
|
||||
return `${percentage}%`;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
}
|
||||
stopPolling();
|
||||
});
|
||||
</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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
638
frontend/src/components/ConvertTab.vue
Normal file
638
frontend/src/components/ConvertTab.vue
Normal file
@@ -0,0 +1,638 @@
|
||||
<template>
|
||||
<div class="convert-container">
|
||||
<el-row :gutter="24">
|
||||
<!-- 任务配置卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="100px" label-position="left">
|
||||
<el-form-item label="输入目录" required>
|
||||
<el-input
|
||||
v-model="form.srcDir"
|
||||
placeholder="待转换音频目录(递归扫描)"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Folder /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="输出目录" required>
|
||||
<el-input
|
||||
v-model="form.dstDir"
|
||||
placeholder="FLAC 目标目录(可与输入目录相同)"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模式">
|
||||
<el-radio-group v-model="form.mode" size="default">
|
||||
<el-radio-button value="copy">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span style="margin-left: 4px">复制模式</span>
|
||||
</el-radio-button>
|
||||
<el-radio-button value="move">
|
||||
<el-icon><Right /></el-icon>
|
||||
<span style="margin-left: 4px">移动模式</span>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canStart"
|
||||
@click="startTask"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
|
||||
<span style="margin-left: 4px">{{ submitting ? '转码中...' : '开始转码' }}</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button @click="reset" size="default" style="width: 100%">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span style="margin-left: 4px">重置</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 任务进度卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">等待开始任务...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">待转码</div>
|
||||
<div class="stat-value">{{ progress.total }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已处理</div>
|
||||
<div class="stat-value 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>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:status="progress.completed ? 'success' : progress.failed > 0 ? 'exception' : undefined"
|
||||
:stroke-width="12"
|
||||
:show-text="true"
|
||||
:format="formatProgress"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前文件 -->
|
||||
<div v-if="progress.currentFile" class="current-file-section">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<span class="file-text">{{ progress.currentFile }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="progress.message" class="message-section">
|
||||
<el-alert
|
||||
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ progress.message }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
Setting,
|
||||
Folder,
|
||||
FolderOpened,
|
||||
DocumentCopy,
|
||||
Right,
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Warning,
|
||||
Document
|
||||
} from '@element-plus/icons-vue';
|
||||
import { startConvert } from '../api/convert';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
|
||||
interface Progress {
|
||||
taskId: string | null;
|
||||
total: number;
|
||||
processed: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
currentFile: string;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
srcDir: '',
|
||||
dstDir: '',
|
||||
mode: 'copy' as 'copy' | 'move'
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const wsConnected = ref(false);
|
||||
const wsErrorShown = ref(false);
|
||||
|
||||
const progress = reactive<Progress>({
|
||||
taskId: null,
|
||||
total: 0,
|
||||
processed: 0,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
currentFile: '',
|
||||
message: '',
|
||||
completed: false
|
||||
});
|
||||
|
||||
const canStart = computed(() => {
|
||||
return form.srcDir.trim() !== '' && form.dstDir.trim() !== '' && !submitting.value;
|
||||
});
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!progress.total) return 0;
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let stopWatchingConnected: (() => void) | null = null;
|
||||
let stopWatchingError: (() => void) | null = null;
|
||||
let progressTimeout: number | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
// 清理之前的连接和 watch
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
if (stopWatchingConnected) {
|
||||
stopWatchingConnected();
|
||||
stopWatchingConnected = null;
|
||||
}
|
||||
if (stopWatchingError) {
|
||||
stopWatchingError();
|
||||
stopWatchingError = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected, error } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
|
||||
// 立即设置初始连接状态
|
||||
wsConnected.value = connected.value;
|
||||
|
||||
// 监听连接状态变化
|
||||
stopWatchingConnected = watch(connected, (val) => {
|
||||
wsConnected.value = val;
|
||||
if (!val && progress.taskId && !progress.completed) {
|
||||
// 连接失败时的处理(不在这里显示错误,由错误监听器处理)
|
||||
console.warn('WebSocket 连接状态变为未连接');
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 监听错误
|
||||
stopWatchingError = watch(error, (err) => {
|
||||
if (err && progress.taskId && !progress.completed) {
|
||||
console.error('WebSocket 错误:', err);
|
||||
// 只在首次错误时提示,避免重复提示
|
||||
if (!wsErrorShown.value) {
|
||||
wsErrorShown.value = true;
|
||||
ElMessage.warning('WebSocket 连接失败,请检查后端服务是否运行。进度可能无法实时更新。');
|
||||
}
|
||||
} else if (!err) {
|
||||
wsErrorShown.value = false;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 立即连接
|
||||
connect();
|
||||
|
||||
// 启动兜底轮询:防止任务很快完成导致 WebSocket 消息丢失
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const latest = await getProgress(taskId);
|
||||
if (latest) {
|
||||
handleProgressMessage(latest);
|
||||
if (latest.completed) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误,等待下一次
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressMessage(msg: ProgressMessage) {
|
||||
// 清除超时定时器
|
||||
if (progressTimeout) {
|
||||
clearTimeout(progressTimeout);
|
||||
progressTimeout = null;
|
||||
}
|
||||
|
||||
progress.total = msg.total;
|
||||
progress.processed = msg.processed;
|
||||
progress.success = msg.success;
|
||||
progress.failed = msg.failed;
|
||||
progress.currentFile = msg.currentFile ?? '';
|
||||
progress.message = msg.message ?? '';
|
||||
progress.completed = msg.completed;
|
||||
|
||||
if (msg.completed) {
|
||||
submitting.value = false;
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
if (stopWatchingConnected) {
|
||||
stopWatchingConnected();
|
||||
stopWatchingConnected = null;
|
||||
}
|
||||
stopPolling();
|
||||
} else {
|
||||
// 如果任务未完成,设置超时保护(30秒无更新则重置状态)
|
||||
progressTimeout = window.setTimeout(() => {
|
||||
if (!progress.completed && submitting.value) {
|
||||
console.warn('任务进度超时,可能后端任务已失败');
|
||||
ElMessage.warning('任务进度更新超时,请检查后端日志');
|
||||
submitting.value = false;
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
||||
async function startTask() {
|
||||
// 验证输入目录和输出目录路径是否填写(不验证目录是否存在或是否有文件)
|
||||
const srcDir = form.srcDir.trim();
|
||||
const dstDir = form.dstDir.trim();
|
||||
|
||||
if (!srcDir || !dstDir) {
|
||||
ElMessage.warning('请填写输入目录和输出目录路径');
|
||||
return;
|
||||
}
|
||||
|
||||
if (srcDir === dstDir && form.mode === 'move') {
|
||||
ElMessage.warning('移动模式下,输入目录和输出目录不能相同');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理之前的超时定时器
|
||||
if (progressTimeout) {
|
||||
clearTimeout(progressTimeout);
|
||||
progressTimeout = null;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
wsConnected.value = false;
|
||||
progress.completed = false;
|
||||
progress.taskId = null;
|
||||
progress.total = 0;
|
||||
progress.processed = 0;
|
||||
progress.success = 0;
|
||||
progress.failed = 0;
|
||||
progress.currentFile = '';
|
||||
progress.message = '';
|
||||
|
||||
try {
|
||||
const response = await startConvert({
|
||||
srcDir: srcDir,
|
||||
dstDir: dstDir,
|
||||
mode: form.mode
|
||||
});
|
||||
|
||||
progress.taskId = response.taskId;
|
||||
ElMessage.success('转码任务已启动,正在处理...');
|
||||
|
||||
// 设置初始超时保护(如果5秒内没有收到任何进度消息,提示用户)
|
||||
progressTimeout = window.setTimeout(() => {
|
||||
if (progress.total === 0 && !progress.completed && submitting.value) {
|
||||
ElMessage.warning('任务已启动,但尚未收到进度更新,请稍候...');
|
||||
}
|
||||
}, 5000);
|
||||
} catch (err: unknown) {
|
||||
submitting.value = false;
|
||||
progress.taskId = null;
|
||||
wsConnected.value = false;
|
||||
if (progressTimeout) {
|
||||
clearTimeout(progressTimeout);
|
||||
progressTimeout = null;
|
||||
}
|
||||
ElMessage.error(err instanceof Error ? err.message : '启动任务失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
if (stopWatchingConnected) {
|
||||
stopWatchingConnected();
|
||||
stopWatchingConnected = null;
|
||||
}
|
||||
if (stopWatchingError) {
|
||||
stopWatchingError();
|
||||
stopWatchingError = null;
|
||||
}
|
||||
if (progressTimeout) {
|
||||
clearTimeout(progressTimeout);
|
||||
progressTimeout = null;
|
||||
}
|
||||
stopPolling();
|
||||
form.srcDir = '';
|
||||
form.dstDir = '';
|
||||
form.mode = 'copy';
|
||||
progress.taskId = null;
|
||||
progress.total = 0;
|
||||
progress.processed = 0;
|
||||
progress.success = 0;
|
||||
progress.failed = 0;
|
||||
progress.currentFile = '';
|
||||
progress.message = '';
|
||||
progress.completed = false;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
wsErrorShown.value = false;
|
||||
}
|
||||
|
||||
function formatProgress(percentage: number): string {
|
||||
return `${percentage}%`;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
}
|
||||
if (stopWatchingConnected) {
|
||||
stopWatchingConnected();
|
||||
}
|
||||
if (stopWatchingError) {
|
||||
stopWatchingError();
|
||||
}
|
||||
if (progressTimeout) {
|
||||
clearTimeout(progressTimeout);
|
||||
}
|
||||
stopPolling();
|
||||
});
|
||||
</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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
516
frontend/src/components/DedupTab.vue
Normal file
516
frontend/src/components/DedupTab.vue
Normal file
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<div class="dedup-container">
|
||||
<el-row :gutter="24">
|
||||
<!-- 任务配置卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="100px" label-position="left">
|
||||
<el-form-item label="音乐库目录" required>
|
||||
<el-input
|
||||
v-model="form.libraryDir"
|
||||
placeholder="音乐库根目录(递归扫描)"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Folder /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="回收站目录" required>
|
||||
<el-input
|
||||
v-model="form.trashDir"
|
||||
placeholder="重复文件移动/复制目录"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="去重策略">
|
||||
<el-checkbox v-model="form.useMd5">启用 MD5 去重</el-checkbox>
|
||||
<el-checkbox v-model="form.useMetadata">启用元数据匹配</el-checkbox>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="执行模式">
|
||||
<el-radio-group v-model="form.mode" size="default">
|
||||
<el-radio-button value="copy">
|
||||
<el-icon><DocumentCopy /></el-icon>
|
||||
<span style="margin-left: 4px">复制模式</span>
|
||||
</el-radio-button>
|
||||
<el-radio-button value="move">
|
||||
<el-icon><Right /></el-icon>
|
||||
<span style="margin-left: 4px">移动模式</span>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canStart"
|
||||
@click="startTask"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
|
||||
<span style="margin-left: 4px">{{ submitting ? '去重中...' : '开始去重' }}</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button @click="reset" size="default" style="width: 100%">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span style="margin-left: 4px">重置</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 任务进度卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">等待开始任务...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">扫描文件数</div>
|
||||
<div class="stat-value">{{ progress.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>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:status="progress.completed ? 'success' : undefined"
|
||||
:stroke-width="12"
|
||||
:show-text="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="progress.message" class="message-section">
|
||||
<el-alert
|
||||
:type="progress.completed ? 'success' : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ progress.message }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
Setting,
|
||||
Folder,
|
||||
FolderOpened,
|
||||
DocumentCopy,
|
||||
Right,
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Document
|
||||
} from '@element-plus/icons-vue';
|
||||
import { startDedup } from '../api/dedup';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
|
||||
const form = reactive({
|
||||
libraryDir: '',
|
||||
trashDir: '',
|
||||
useMd5: true,
|
||||
useMetadata: true,
|
||||
mode: 'copy' as 'copy' | 'move'
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const wsConnected = ref(false);
|
||||
|
||||
const progress = reactive({
|
||||
taskId: '' as string | null,
|
||||
scanned: 0,
|
||||
duplicateGroups: 0,
|
||||
moved: 0,
|
||||
completed: false,
|
||||
message: ''
|
||||
});
|
||||
|
||||
const canStart = computed(() => {
|
||||
return (
|
||||
form.libraryDir.trim() !== '' &&
|
||||
form.trashDir.trim() !== '' &&
|
||||
(form.useMd5 || form.useMetadata) &&
|
||||
!submitting.value
|
||||
);
|
||||
});
|
||||
|
||||
const scannedTotal = ref(0);
|
||||
const scannedProcessed = ref(0);
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!scannedTotal.value) return 0;
|
||||
return Math.round((scannedProcessed.value / scannedTotal.value) * 100);
|
||||
});
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const latest = await getProgress(taskId);
|
||||
if (latest) {
|
||||
handleProgressMessage(latest);
|
||||
if (latest.completed) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressMessage(msg: ProgressMessage) {
|
||||
if (msg.type !== 'dedup') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 字段映射:见后端 DedupService 注释
|
||||
scannedTotal.value = msg.total;
|
||||
scannedProcessed.value = msg.processed;
|
||||
|
||||
progress.scanned = msg.total;
|
||||
progress.duplicateGroups = msg.success;
|
||||
progress.moved = msg.failed;
|
||||
progress.completed = msg.completed;
|
||||
progress.message = msg.message ?? '';
|
||||
}
|
||||
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connect();
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function startTask() {
|
||||
const libraryDir = form.libraryDir.trim();
|
||||
const trashDir = form.trashDir.trim();
|
||||
|
||||
if (!libraryDir || !trashDir) {
|
||||
ElMessage.warning('请填写音乐库目录和回收站目录');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.useMd5 && !form.useMetadata) {
|
||||
ElMessage.warning('请至少选择一种去重策略');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
progress.taskId = null;
|
||||
progress.scanned = 0;
|
||||
progress.duplicateGroups = 0;
|
||||
progress.moved = 0;
|
||||
progress.completed = false;
|
||||
progress.message = '';
|
||||
scannedTotal.value = 0;
|
||||
scannedProcessed.value = 0;
|
||||
|
||||
try {
|
||||
const res = await startDedup({
|
||||
libraryDir,
|
||||
trashDir,
|
||||
useMd5: form.useMd5,
|
||||
useMetadata: form.useMetadata,
|
||||
mode: form.mode
|
||||
});
|
||||
|
||||
progress.taskId = res.taskId;
|
||||
ElMessage.success('去重任务已启动');
|
||||
} catch (e: unknown) {
|
||||
submitting.value = false;
|
||||
ElMessage.error(e instanceof Error ? e.message : '启动去重任务失败');
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => progress.completed,
|
||||
(done) => {
|
||||
if (done) {
|
||||
submitting.value = false;
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
});
|
||||
|
||||
function reset() {
|
||||
stopPolling();
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
form.libraryDir = '';
|
||||
form.trashDir = '';
|
||||
form.useMd5 = true;
|
||||
form.useMetadata = true;
|
||||
form.mode = 'copy';
|
||||
progress.taskId = null;
|
||||
progress.scanned = 0;
|
||||
progress.duplicateGroups = 0;
|
||||
progress.moved = 0;
|
||||
progress.completed = false;
|
||||
progress.message = '';
|
||||
scannedTotal.value = 0;
|
||||
scannedProcessed.value = 0;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
96
frontend/src/components/MergeTab.vue
Normal file
96
frontend/src/components/MergeTab.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<el-space direction="vertical" :size="16" class="tab-root">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>任务配置</span>
|
||||
</template>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="源目录 (staging)">
|
||||
<el-input v-model="form.srcDir" placeholder="整理完成后的 staging 目录" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标目录 (主库)">
|
||||
<el-input v-model="form.dstDir" placeholder="Navidrome 主库根目录" />
|
||||
</el-form-item>
|
||||
<el-form-item label="合并策略">
|
||||
<el-checkbox v-model="form.smartUpgrade">启用智能升级</el-checkbox>
|
||||
<el-checkbox v-model="form.keepBackup">保留旧版本备份</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="startTask">
|
||||
开始合并
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>任务进度</span>
|
||||
</template>
|
||||
<div class="progress-row">
|
||||
<span>已合并专辑数</span>
|
||||
<span>{{ progress.albums }}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span>已合并曲目数</span>
|
||||
<span>{{ progress.tracks }}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span>升级替换文件数</span>
|
||||
<span>{{ progress.upgraded }}</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:status="progress.completed ? 'success' : 'active'"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</el-card>
|
||||
</el-space>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
const form = reactive({
|
||||
srcDir: '',
|
||||
dstDir: '',
|
||||
smartUpgrade: true,
|
||||
keepBackup: false
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const progress = reactive({
|
||||
albums: 0,
|
||||
tracks: 0,
|
||||
upgraded: 0,
|
||||
completed: false
|
||||
});
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!progress.albums && !progress.tracks) return 0;
|
||||
return 0;
|
||||
});
|
||||
|
||||
function startTask() {
|
||||
submitting.value = true;
|
||||
// TODO: 调用 /merge 接口并订阅进度
|
||||
setTimeout(() => {
|
||||
submitting.value = false;
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
|
||||
105
frontend/src/components/RenameTab.vue
Normal file
105
frontend/src/components/RenameTab.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<el-space direction="vertical" :size="16" class="tab-root">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>任务配置</span>
|
||||
</template>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="源目录">
|
||||
<el-input v-model="form.srcDir" placeholder="staging 源目录" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标目录">
|
||||
<el-input v-model="form.dstDir" placeholder="规范化输出目录" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标签完整度">
|
||||
<el-radio-group v-model="form.mode">
|
||||
<el-radio value="strict">严格模式</el-radio>
|
||||
<el-radio value="lenient">宽松模式</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="附加选项">
|
||||
<el-checkbox v-model="form.extractCover">提取封面</el-checkbox>
|
||||
<el-checkbox v-model="form.extractLyrics">提取歌词</el-checkbox>
|
||||
<el-checkbox v-model="form.generateReport">生成整理报告</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="startTask">
|
||||
开始整理
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>任务进度</span>
|
||||
</template>
|
||||
<div class="progress-row">
|
||||
<span>扫描文件数</span>
|
||||
<span>{{ progress.scanned }}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span>整理成功文件数</span>
|
||||
<span>{{ progress.organized }}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span>需要人工修复文件数</span>
|
||||
<span>{{ progress.manualFix }}</span>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:status="progress.completed ? 'success' : 'active'"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</el-card>
|
||||
</el-space>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
const form = reactive({
|
||||
srcDir: '',
|
||||
dstDir: '',
|
||||
mode: 'strict',
|
||||
extractCover: true,
|
||||
extractLyrics: true,
|
||||
generateReport: true
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const progress = reactive({
|
||||
scanned: 0,
|
||||
organized: 0,
|
||||
manualFix: 0,
|
||||
completed: false
|
||||
});
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!progress.scanned) return 0;
|
||||
return 0;
|
||||
});
|
||||
|
||||
function startTask() {
|
||||
submitting.value = true;
|
||||
// TODO: 调用 /organize 接口并订阅进度
|
||||
setTimeout(() => {
|
||||
submitting.value = false;
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tab-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.progress-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
}
|
||||
</style>
|
||||
|
||||
90
frontend/src/components/SettingsTab.vue
Normal file
90
frontend/src/components/SettingsTab.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<el-card class="settings-root">
|
||||
<template #header>
|
||||
<span>全局路径配置</span>
|
||||
</template>
|
||||
<el-form label-width="140px">
|
||||
<el-form-item label="工作根目录 (BasePath)">
|
||||
<el-input v-model="basePath" placeholder="例如:D:/MusicWork" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="save">
|
||||
保存配置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<h4>派生目录预览</h4>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="Input (SRC_ACC_DIR)">
|
||||
{{ preview.input }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Staging_Aggregated (DST_ACC_DIR)">
|
||||
{{ preview.aggregated }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Staging_Format_Issues (DST_CONV_ISSUE)">
|
||||
{{ preview.formatIssues }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Staging_Duplicates (DST_DEDUP_TRASH)">
|
||||
{{ preview.duplicates }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Staging_T2S_Output (DST_ZH_CONV)">
|
||||
{{ preview.zhOutput }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Staging_Organized (DST_ORG_DIR)">
|
||||
{{ preview.organized }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Library_Final (DST_LIB_FINAL)">
|
||||
{{ preview.libraryFinal }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const basePath = ref('');
|
||||
const saving = ref(false);
|
||||
|
||||
const preview = computed(() => {
|
||||
const root = basePath.value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!root) {
|
||||
return {
|
||||
input: '',
|
||||
aggregated: '',
|
||||
formatIssues: '',
|
||||
duplicates: '',
|
||||
zhOutput: '',
|
||||
organized: '',
|
||||
libraryFinal: ''
|
||||
};
|
||||
}
|
||||
return {
|
||||
input: `${root}/Input`,
|
||||
aggregated: `${root}/Staging_Aggregated`,
|
||||
formatIssues: `${root}/Staging_Format_Issues`,
|
||||
duplicates: `${root}/Staging_Duplicates`,
|
||||
zhOutput: `${root}/Staging_T2S_Output`,
|
||||
organized: `${root}/Staging_Organized`,
|
||||
libraryFinal: `${root}/Library_Final`
|
||||
};
|
||||
});
|
||||
|
||||
function save() {
|
||||
saving.value = true;
|
||||
// TODO: 调用 /config/base-path 接口保存配置
|
||||
setTimeout(() => {
|
||||
saving.value = false;
|
||||
}, 500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-root {
|
||||
max-width: 800px;
|
||||
}
|
||||
</style>
|
||||
|
||||
554
frontend/src/components/TraditionalFilterTab.vue
Normal file
554
frontend/src/components/TraditionalFilterTab.vue
Normal file
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<div class="zhconvert-container">
|
||||
<el-row :gutter="24">
|
||||
<!-- 任务配置卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="10">
|
||||
<el-card class="config-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><Setting /></el-icon>
|
||||
<span class="card-title">任务配置</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="110px" label-position="left">
|
||||
<el-form-item label="扫描目录" required>
|
||||
<el-input
|
||||
v-model="form.scanDir"
|
||||
placeholder="待检测标签的根目录"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Folder /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="输出目录">
|
||||
<el-input
|
||||
v-model="form.outputDir"
|
||||
placeholder="可选:执行模式下输出到新目录(留空则原地修改)"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="繁体占比阈值">
|
||||
<div class="inline-field">
|
||||
<el-input-number
|
||||
v-model="form.threshold"
|
||||
:min="1"
|
||||
:max="100"
|
||||
:step="1"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="suffix">%</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="处理模式">
|
||||
<el-radio-group v-model="form.mode" size="default">
|
||||
<el-radio-button value="preview">
|
||||
预览(仅检测)
|
||||
</el-radio-button>
|
||||
<el-radio-button value="execute">
|
||||
执行转换
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
:disabled="!canStart"
|
||||
@click="startTask"
|
||||
size="large"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-icon v-if="!submitting"><VideoPlay /></el-icon>
|
||||
<span style="margin-left: 4px">
|
||||
{{ submitting ? '处理中...' : form.mode === 'preview' ? '开始检测' : '开始转换' }}
|
||||
</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button @click="reset" size="default" style="width: 100%">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
<span style="margin-left: 4px">重置</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 任务进度卡片 -->
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="14">
|
||||
<el-card class="progress-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon class="header-icon"><DataLine /></el-icon>
|
||||
<span class="card-title">任务进度</span>
|
||||
<el-tag
|
||||
v-if="progress.taskId && progress.completed"
|
||||
type="info"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><CircleCheck /></el-icon>
|
||||
<span class="connection-text">已结束</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && wsConnected"
|
||||
type="success"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Connection /></el-icon>
|
||||
<span class="connection-text">已连接</span>
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else-if="progress.taskId && !wsConnected && !progress.completed"
|
||||
type="warning"
|
||||
size="small"
|
||||
effect="plain"
|
||||
class="connection-tag"
|
||||
>
|
||||
<el-icon class="connection-icon"><Loading /></el-icon>
|
||||
<span class="connection-text">连接中</span>
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!progress.taskId" class="empty-state">
|
||||
<el-icon class="empty-icon"><Document /></el-icon>
|
||||
<p class="empty-text">等待开始任务...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="progress-content">
|
||||
<!-- 统计信息 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">总文件数</div>
|
||||
<div class="stat-value">{{ progress.total }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">已扫描</div>
|
||||
<div class="stat-value 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>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="progress-section">
|
||||
<el-progress
|
||||
:percentage="percentage"
|
||||
:status="progress.completed ? 'success' : progress.failed > 0 ? 'exception' : undefined"
|
||||
:stroke-width="12"
|
||||
:show-text="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 当前文件 -->
|
||||
<div v-if="progress.currentFile" class="current-file-section">
|
||||
<el-icon class="file-icon"><Document /></el-icon>
|
||||
<span class="file-text">{{ progress.currentFile }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div v-if="progress.message" class="message-section">
|
||||
<el-alert
|
||||
:type="progress.completed ? (progress.failed > 0 ? 'warning' : 'success') : 'info'"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
<span>{{ progress.message }}</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch, onUnmounted } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
Setting,
|
||||
Folder,
|
||||
FolderOpened,
|
||||
VideoPlay,
|
||||
Refresh,
|
||||
DataLine,
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Loading,
|
||||
Document
|
||||
} from '@element-plus/icons-vue';
|
||||
import { startZhConvert } from '../api/zhconvert';
|
||||
import { getProgress } from '../api/progress';
|
||||
import { useWebSocket, type ProgressMessage } from '../composables/useWebSocket';
|
||||
|
||||
interface ProgressState {
|
||||
taskId: string | null;
|
||||
total: number;
|
||||
processed: number;
|
||||
entries: number;
|
||||
failed: number;
|
||||
currentFile: string;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
scanDir: '',
|
||||
outputDir: '',
|
||||
threshold: 10,
|
||||
mode: 'preview' as 'preview' | 'execute'
|
||||
});
|
||||
|
||||
const submitting = ref(false);
|
||||
const wsConnected = ref(false);
|
||||
|
||||
const progress = reactive<ProgressState>({
|
||||
taskId: null,
|
||||
total: 0,
|
||||
processed: 0,
|
||||
entries: 0,
|
||||
failed: 0,
|
||||
currentFile: '',
|
||||
message: '',
|
||||
completed: false
|
||||
});
|
||||
|
||||
const canStart = computed(() => {
|
||||
return form.scanDir.trim() !== '' && form.threshold >= 1 && form.threshold <= 100 && !submitting.value;
|
||||
});
|
||||
|
||||
const percentage = computed(() => {
|
||||
if (!progress.total) return 0;
|
||||
return Math.round((progress.processed / progress.total) * 100);
|
||||
});
|
||||
|
||||
let wsDisconnect: (() => void) | null = null;
|
||||
let pollTimer: number | null = null;
|
||||
|
||||
function startPolling(taskId: string) {
|
||||
stopPolling();
|
||||
pollTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const latest = await getProgress(taskId);
|
||||
if (latest && latest.type === 'zhconvert') {
|
||||
handleProgressMessage(latest);
|
||||
if (latest.completed) {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略轮询错误
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgressMessage(msg: ProgressMessage) {
|
||||
if (msg.type !== 'zhconvert') {
|
||||
return;
|
||||
}
|
||||
progress.total = msg.total;
|
||||
progress.processed = msg.processed;
|
||||
progress.entries = msg.success;
|
||||
progress.failed = msg.failed;
|
||||
progress.currentFile = msg.currentFile || '';
|
||||
progress.message = msg.message || '';
|
||||
progress.completed = msg.completed;
|
||||
|
||||
if (msg.completed) {
|
||||
submitting.value = false;
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => progress.taskId,
|
||||
(newTaskId) => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
wsConnected.value = false;
|
||||
|
||||
if (newTaskId) {
|
||||
const { connect, disconnect, connected } = useWebSocket(newTaskId, handleProgressMessage);
|
||||
wsDisconnect = disconnect;
|
||||
watch(connected, (val) => (wsConnected.value = val), { immediate: true });
|
||||
connect();
|
||||
startPolling(newTaskId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function startTask() {
|
||||
const scanDir = form.scanDir.trim();
|
||||
if (!scanDir) {
|
||||
ElMessage.warning('请填写扫描目录');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.threshold < 1 || form.threshold > 100) {
|
||||
ElMessage.warning('繁体占比阈值必须在 1-100 之间');
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
progress.taskId = null;
|
||||
progress.total = 0;
|
||||
progress.processed = 0;
|
||||
progress.entries = 0;
|
||||
progress.failed = 0;
|
||||
progress.currentFile = '';
|
||||
progress.message = '';
|
||||
progress.completed = false;
|
||||
|
||||
try {
|
||||
const res = await startZhConvert({
|
||||
scanDir,
|
||||
outputDir: form.outputDir.trim() || undefined,
|
||||
threshold: form.threshold,
|
||||
mode: form.mode
|
||||
});
|
||||
progress.taskId = res.taskId;
|
||||
ElMessage.success('任务已启动,正在处理...');
|
||||
} catch (e: unknown) {
|
||||
submitting.value = false;
|
||||
ElMessage.error(e instanceof Error ? e.message : '启动任务失败');
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
stopPolling();
|
||||
form.scanDir = '';
|
||||
form.outputDir = '';
|
||||
form.threshold = 10;
|
||||
form.mode = 'preview';
|
||||
progress.taskId = null;
|
||||
progress.total = 0;
|
||||
progress.processed = 0;
|
||||
progress.entries = 0;
|
||||
progress.failed = 0;
|
||||
progress.currentFile = '';
|
||||
progress.message = '';
|
||||
progress.completed = false;
|
||||
submitting.value = false;
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (wsDisconnect) {
|
||||
wsDisconnect();
|
||||
wsDisconnect = null;
|
||||
}
|
||||
stopPolling();
|
||||
});
|
||||
</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;
|
||||
}
|
||||
|
||||
.inline-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 13px;
|
||||
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>
|
||||
100
frontend/src/composables/useWebSocket.ts
Normal file
100
frontend/src/composables/useWebSocket.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ref } from 'vue';
|
||||
import SockJS from 'sockjs-client';
|
||||
import Stomp from 'stompjs';
|
||||
|
||||
export interface ProgressMessage {
|
||||
taskId: string;
|
||||
type: string;
|
||||
total: number;
|
||||
processed: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
currentFile: string;
|
||||
message: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export function useWebSocket(taskId: string | null, onMessage: (msg: ProgressMessage) => void) {
|
||||
const connected = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let stompClient: Stomp.Client | null = null;
|
||||
|
||||
const connect = () => {
|
||||
if (!taskId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已有连接,先断开
|
||||
if (stompClient) {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
try {
|
||||
const socket = new SockJS('http://localhost:8080/ws');
|
||||
stompClient = Stomp.over(socket);
|
||||
|
||||
// 禁用调试日志
|
||||
stompClient.debug = () => {};
|
||||
|
||||
stompClient.connect(
|
||||
{},
|
||||
() => {
|
||||
// 连接成功
|
||||
connected.value = true;
|
||||
error.value = null;
|
||||
|
||||
// 订阅任务进度
|
||||
if (stompClient) {
|
||||
stompClient.subscribe(`/topic/progress/${taskId}`, (message) => {
|
||||
try {
|
||||
const progress: ProgressMessage = JSON.parse(message.body);
|
||||
onMessage(progress);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse progress message:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
(errorFrame) => {
|
||||
// 连接失败
|
||||
const errorMsg = errorFrame.headers?.['message'] || errorFrame.toString() || 'WebSocket 连接错误';
|
||||
error.value = errorMsg;
|
||||
connected.value = false;
|
||||
console.error('WebSocket 连接失败:', errorMsg, errorFrame);
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : 'WebSocket 初始化失败';
|
||||
error.value = errorMsg;
|
||||
connected.value = false;
|
||||
console.error('WebSocket 初始化失败:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (stompClient) {
|
||||
try {
|
||||
if (connected.value) {
|
||||
stompClient.disconnect(() => {
|
||||
connected.value = false;
|
||||
});
|
||||
} else {
|
||||
// 如果连接未成功,直接清理
|
||||
connected.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('断开 WebSocket 连接时出错:', e);
|
||||
connected.value = false;
|
||||
}
|
||||
stompClient = null;
|
||||
}
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
connected,
|
||||
error,
|
||||
connect,
|
||||
disconnect
|
||||
};
|
||||
}
|
||||
14
frontend/src/main.ts
Normal file
14
frontend/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import ElementPlus from 'element-plus';
|
||||
import 'element-plus/dist/index.css';
|
||||
import './styles/reset.css';
|
||||
import App from './App.vue';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(ElementPlus);
|
||||
|
||||
app.mount('#app');
|
||||
|
||||
26
frontend/src/styles/reset.css
Normal file
26
frontend/src/styles/reset.css
Normal file
@@ -0,0 +1,26 @@
|
||||
/* 重置 body 默认样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif, 'Noto Sans SC', 'HarmonyOS Sans SC';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
24
frontend/vite.config.mts
Normal file
24
frontend/vite.config.mts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/ws': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user