Merge branch 'master' of https://gitee.com/TeaCodeNice/crawler-plugin
This commit is contained in:
15
.gitignore
vendored
15
.gitignore
vendored
@@ -13,3 +13,18 @@ frontend-vue/npm-debug.log*
|
|||||||
frontend-vue/yarn-debug.log*
|
frontend-vue/yarn-debug.log*
|
||||||
frontend-vue/yarn-error.log*
|
frontend-vue/yarn-error.log*
|
||||||
frontend-vue/pnpm-debug.log*
|
frontend-vue/pnpm-debug.log*
|
||||||
|
|
||||||
|
# backend-java build output
|
||||||
|
backend-java/target/
|
||||||
|
|
||||||
|
# backend-java runtime data
|
||||||
|
backend-java/data/
|
||||||
|
backend-java/*.log
|
||||||
|
backend-java/logs/
|
||||||
|
|
||||||
|
# backend-java local config
|
||||||
|
backend-java/src/main/resources/application-local.yml
|
||||||
|
|
||||||
|
# backend-java IDE files
|
||||||
|
backend-java/.idea/
|
||||||
|
backend-java/*.iml
|
||||||
|
|||||||
125
backend-java/pom.xml
Normal file
125
backend-java/pom.xml
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.3.9</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.nanri</groupId>
|
||||||
|
<artifactId>aiimage-backend</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>aiimage-backend</name>
|
||||||
|
<description>AI image backend service</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>21</java.version>
|
||||||
|
<knife4j.version>4.5.0</knife4j.version>
|
||||||
|
<mybatis-plus.version>3.5.7</mybatis-plus.version>
|
||||||
|
<mapstruct.version>1.5.5.Final</mapstruct.version>
|
||||||
|
<easyexcel.version>4.0.3</easyexcel.version>
|
||||||
|
<aliyun.oss.version>3.17.4</aliyun.oss.version>
|
||||||
|
<hutool.version>5.8.36</hutool.version>
|
||||||
|
</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-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
<version>${mybatis-plus.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba</groupId>
|
||||||
|
<artifactId>easyexcel</artifactId>
|
||||||
|
<version>${easyexcel.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.aliyun.oss</groupId>
|
||||||
|
<artifactId>aliyun-sdk-oss</artifactId>
|
||||||
|
<version>${aliyun.oss.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.xiaoymin</groupId>
|
||||||
|
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
|
||||||
|
<version>${knife4j.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>${hutool.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<source>${java.version}</source>
|
||||||
|
<target>${java.version}</target>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.36</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.nanri.aiimage;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class AiImageApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(AiImageApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.nanri.aiimage.common.api;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Schema(description = "统一响应体")
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
|
@Schema(description = "是否成功")
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
@Schema(description = "提示信息")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@Schema(description = "响应数据")
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return new ApiResponse<>(true, "操作成功", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> success(String message, T data) {
|
||||||
|
return new ApiResponse<>(true, message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> fail(String message) {
|
||||||
|
return new ApiResponse<>(false, message, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.nanri.aiimage.common.exception;
|
||||||
|
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
public BusinessException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.nanri.aiimage.common.exception;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
|
||||||
|
return ApiResponse.fail(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ApiResponse<Void> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
|
||||||
|
String message = ex.getBindingResult().getFieldError() != null
|
||||||
|
? ex.getBindingResult().getFieldError().getDefaultMessage()
|
||||||
|
: "参数校验失败";
|
||||||
|
return ApiResponse.fail(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public ApiResponse<Void> handleConstraintViolationException(ConstraintViolationException ex) {
|
||||||
|
return ApiResponse.fail(ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ApiResponse<Void> handleException(Exception ex) {
|
||||||
|
log.error("Unhandled exception", ex);
|
||||||
|
return ApiResponse.fail("服务异常:" + ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.ExternalDocumentation;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Contact;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.info.License;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("AI Image Backend API")
|
||||||
|
.description("""
|
||||||
|
文件处理后端接口文档。
|
||||||
|
|
||||||
|
当前迁移范围:仅迁移数据去重(dedupe)、格式转换(convert)、数据拆分(split)三块处理逻辑到 Java;
|
||||||
|
Python 桌面端壳、pywebview 调用方式、前端页面交互保持不变。
|
||||||
|
|
||||||
|
建议联调顺序:
|
||||||
|
1. 先启动 Java 后端;
|
||||||
|
2. 在 Python 桌面端中选择文件并执行 dedupe/convert/split;
|
||||||
|
3. Python 壳会先调用 /api/files/upload 上传临时文件;
|
||||||
|
4. 再调用对应的 /api/dedupe/run、/api/convert/run、/api/split/run;
|
||||||
|
5. Java 生成结果文件后,Python 壳再通过下载接口取回并保存到用户本地目录。
|
||||||
|
|
||||||
|
本地启动说明:
|
||||||
|
- 默认配置读取 application.yml;
|
||||||
|
- 建议复制 application-local.example.yml 为本地配置,并在 IDE 中通过环境变量覆盖数据库与 OSS 参数;
|
||||||
|
- Knife4j 地址:/doc.html
|
||||||
|
""")
|
||||||
|
.version("v0.0.1")
|
||||||
|
.contact(new Contact().name("Nanri AI"))
|
||||||
|
.license(new License().name("Internal Use")))
|
||||||
|
.externalDocs(new ExternalDocumentation()
|
||||||
|
.description("Knife4j 文档")
|
||||||
|
.url("/doc.html"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@ConfigurationProperties(prefix = "aiimage.oss")
|
||||||
|
public class OssProperties {
|
||||||
|
private String region;
|
||||||
|
private String endpoint;
|
||||||
|
private String bucket;
|
||||||
|
private String accessKeyId;
|
||||||
|
private String accessKeySecret;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties({OssProperties.class, StorageProperties.class})
|
||||||
|
public class PropertiesConfig {
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
return http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
|
||||||
|
.httpBasic(Customizer.withDefaults())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.nanri.aiimage.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@ConfigurationProperties(prefix = "aiimage.storage")
|
||||||
|
public class StorageProperties {
|
||||||
|
private String localTempDir;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.controller;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.dto.ConvertRunRequest;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.vo.ConvertHistoryVo;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.vo.ConvertRunVo;
|
||||||
|
import com.nanri.aiimage.modules.convert.service.ConvertRunService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/convert")
|
||||||
|
@Tag(name = "格式转换执行", description = "负责根据已上传文件和模板执行格式转换,并返回结果下载地址。当前先打通接口链路,下一步再把旧 Python 的模板生成逻辑完整迁移到 Java。")
|
||||||
|
public class ConvertRunController {
|
||||||
|
|
||||||
|
private final ConvertRunService convertRunService;
|
||||||
|
|
||||||
|
@PostMapping("/run")
|
||||||
|
@Operation(
|
||||||
|
summary = "执行格式转换",
|
||||||
|
description = """
|
||||||
|
使用上传接口返回的 fileKey 和选中的模板执行格式转换。
|
||||||
|
|
||||||
|
当前已按旧 Python 桌面端规则迁移以下逻辑:
|
||||||
|
- required_source_columns 缺失校验;
|
||||||
|
- preamble_lines 输出;
|
||||||
|
- header_columns 输出;
|
||||||
|
- blank_header_rows_after_schema 空白行补齐;
|
||||||
|
- field_mapping 字段映射;
|
||||||
|
- defaults 默认值填充;
|
||||||
|
- ASIN 为空的行跳过;
|
||||||
|
- sku 按 时间戳-序号 生成。
|
||||||
|
|
||||||
|
输出文件名遵循模板配置的 outputFilename,若临时目录中重名则自动追加序号。
|
||||||
|
""")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功,返回转换结果统计", content = @Content(schema = @Schema(implementation = ConvertRunVo.class))),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数不合法或模板不存在"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "执行格式转换失败")
|
||||||
|
})
|
||||||
|
public ApiResponse<ConvertRunVo> run(@Valid @RequestBody ConvertRunRequest request) {
|
||||||
|
return ApiResponse.success(convertRunService.run(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history")
|
||||||
|
@Operation(summary = "查询格式转换历史", description = "查询最近的格式转换成功记录,用于页面右侧历史下载列表展示。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = ConvertHistoryVo.class)))
|
||||||
|
})
|
||||||
|
public ApiResponse<ConvertHistoryVo> history() {
|
||||||
|
ConvertHistoryVo vo = new ConvertHistoryVo();
|
||||||
|
vo.setItems(convertRunService.listHistory());
|
||||||
|
return ApiResponse.success(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/history/{resultId}")
|
||||||
|
@Operation(summary = "删除格式转换历史", description = "删除一条格式转换历史记录。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在")
|
||||||
|
})
|
||||||
|
public ApiResponse<Void> deleteHistory(@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId) {
|
||||||
|
convertRunService.deleteHistory(resultId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/results/{resultId}/download")
|
||||||
|
@Operation(summary = "下载格式转换结果", description = "根据结果记录 ID 下载已生成的转换结果文件。桌面端前端可继续通过 save_file_from_url 选择保存位置。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
|
||||||
|
})
|
||||||
|
public ResponseEntity<Resource> download(@Parameter(description = "格式转换结果记录 ID", required = true) @PathVariable Long resultId) {
|
||||||
|
File resultFile = convertRunService.getResultFile(resultId);
|
||||||
|
String encodedFilename = URLEncoder.encode(resultFile.getName(), StandardCharsets.UTF_8).replaceAll("\\+", "%20");
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encodedFilename)
|
||||||
|
.body(new FileSystemResource(resultFile));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.controller;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.dto.ConvertTemplateImportRequest;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.vo.ConvertTemplateVo;
|
||||||
|
import com.nanri.aiimage.modules.convert.service.ConvertTemplateService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/convert/templates")
|
||||||
|
@Tag(name = "格式转换模板", description = "用于维护和查询格式转换模板,支持查询、导入、设默认、删除。")
|
||||||
|
public class ConvertTemplateController {
|
||||||
|
|
||||||
|
private final ConvertTemplateService convertTemplateService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "查询启用模板", description = "返回当前启用的格式转换模板列表,供前端‘格式转换’模块展示。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功,返回模板列表")
|
||||||
|
})
|
||||||
|
public ApiResponse<List<ConvertTemplateVo>> listTemplates() {
|
||||||
|
return ApiResponse.success(convertTemplateService.listEnabledTemplates());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/import")
|
||||||
|
@Operation(summary = "导入模板", description = "导入自定义 txt 模板,成功后返回新模板并可立即刷新模板列表。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "导入成功,返回新模板信息", content = @Content(schema = @Schema(implementation = ConvertTemplateVo.class))),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "模板内容不合法或必填字段缺失")
|
||||||
|
})
|
||||||
|
public ApiResponse<ConvertTemplateVo> importTemplate(@RequestBody ConvertTemplateImportRequest request) {
|
||||||
|
return ApiResponse.success(convertTemplateService.importTemplate(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{templateCode}/default")
|
||||||
|
@Operation(summary = "设为默认模板", description = "将指定模板设为默认模板。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "设置成功"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "模板不存在")
|
||||||
|
})
|
||||||
|
public ApiResponse<Void> setDefault(@Parameter(description = "模板编码", required = true) @PathVariable String templateCode) {
|
||||||
|
convertTemplateService.setDefaultTemplate(templateCode);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{templateCode}")
|
||||||
|
@Operation(summary = "删除模板", description = "删除指定模板。内置模板不允许删除。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "内置模板不允许删除"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "模板不存在")
|
||||||
|
})
|
||||||
|
public ApiResponse<Void> delete(@Parameter(description = "模板编码", required = true) @PathVariable String templateCode) {
|
||||||
|
convertTemplateService.deleteTemplate(templateCode);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface ConvertTemplateMapper extends BaseMapper<ConvertTemplateEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "格式转换执行请求")
|
||||||
|
public class ConvertRunRequest {
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotEmpty(message = "请先上传待转换文件")
|
||||||
|
@Schema(description = "已上传的源文件列表")
|
||||||
|
private List<UploadedSourceFileDto> files;
|
||||||
|
|
||||||
|
@NotBlank(message = "请选择模板")
|
||||||
|
@Schema(description = "模板编码,例如 uk_offer")
|
||||||
|
private String templateId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "格式转换模板导入请求")
|
||||||
|
public class ConvertTemplateImportRequest {
|
||||||
|
|
||||||
|
@Schema(description = "模板显示名称")
|
||||||
|
private String templateName;
|
||||||
|
|
||||||
|
@Schema(description = "模板文本内容")
|
||||||
|
private String templateContent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "已上传源文件")
|
||||||
|
public class UploadedSourceFileDto {
|
||||||
|
|
||||||
|
@NotBlank(message = "fileKey 不能为空")
|
||||||
|
@Schema(description = "上传接口返回的临时文件键")
|
||||||
|
private String fileKey;
|
||||||
|
|
||||||
|
@Schema(description = "原始文件名")
|
||||||
|
private String originalFilename;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("biz_convert_template")
|
||||||
|
public class ConvertTemplateEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private String templateCode;
|
||||||
|
private String templateName;
|
||||||
|
private String outputFilename;
|
||||||
|
private String requiredSourceColumnsJson;
|
||||||
|
private String headerColumnsJson;
|
||||||
|
private String fieldMappingJson;
|
||||||
|
private String defaultsJson;
|
||||||
|
private String preambleLinesJson;
|
||||||
|
private Integer blankHeaderRowsAfterSchema;
|
||||||
|
private Integer isDefault;
|
||||||
|
private Integer builtIn;
|
||||||
|
private Integer enabled;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "格式转换历史列表")
|
||||||
|
public class ConvertHistoryVo {
|
||||||
|
|
||||||
|
@Schema(description = "历史记录")
|
||||||
|
private List<ConvertResultItemVo> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "格式转换结果项")
|
||||||
|
public class ConvertResultItemVo {
|
||||||
|
|
||||||
|
@Schema(description = "结果记录ID")
|
||||||
|
private Long resultId;
|
||||||
|
|
||||||
|
@Schema(description = "源文件名")
|
||||||
|
private String sourceFilename;
|
||||||
|
|
||||||
|
@Schema(description = "结果文件名")
|
||||||
|
private String outputFilename;
|
||||||
|
|
||||||
|
@Schema(description = "是否成功")
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
@Schema(description = "错误信息")
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
@Schema(description = "下载地址")
|
||||||
|
private String downloadUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "格式转换执行结果")
|
||||||
|
public class ConvertRunVo {
|
||||||
|
|
||||||
|
@Schema(description = "处理文件总数")
|
||||||
|
private Integer total;
|
||||||
|
|
||||||
|
@Schema(description = "成功文件数")
|
||||||
|
private Integer successCount;
|
||||||
|
|
||||||
|
@Schema(description = "失败文件数")
|
||||||
|
private Integer failedCount;
|
||||||
|
|
||||||
|
@Schema(description = "结果列表")
|
||||||
|
private List<ConvertResultItemVo> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "格式转换模板")
|
||||||
|
public class ConvertTemplateVo {
|
||||||
|
|
||||||
|
@Schema(description = "模板编码")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Schema(description = "模板编码")
|
||||||
|
private String templateCode;
|
||||||
|
|
||||||
|
@Schema(description = "模板名称")
|
||||||
|
private String templateName;
|
||||||
|
|
||||||
|
@Schema(description = "输出文件名")
|
||||||
|
private String outputFilename;
|
||||||
|
|
||||||
|
@Schema(description = "是否默认模板")
|
||||||
|
private Boolean isDefault;
|
||||||
|
|
||||||
|
@Schema(description = "是否内置模板")
|
||||||
|
private Boolean builtIn;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.io.FileUtil;
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.dto.ConvertRunRequest;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.dto.UploadedSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.vo.ConvertResultItemVo;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.vo.ConvertRunVo;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ConvertRunService {
|
||||||
|
|
||||||
|
private final FileTaskMapper fileTaskMapper;
|
||||||
|
private final FileResultMapper fileResultMapper;
|
||||||
|
private final StorageProperties storageProperties;
|
||||||
|
private final ConvertTemplateService convertTemplateService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final OssStorageService ossStorageService;
|
||||||
|
|
||||||
|
public ConvertRunVo run(ConvertRunRequest request) {
|
||||||
|
ConvertTemplateEntity template = convertTemplateService.getByCode(request.getTemplateId());
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setTaskNo("CONVERT-" + IdUtil.getSnowflakeNextIdStr());
|
||||||
|
task.setModuleType("CONVERT");
|
||||||
|
task.setTaskMode("IMMEDIATE");
|
||||||
|
task.setStatus("SUCCESS");
|
||||||
|
task.setSourceFileCount(request.getFiles().size());
|
||||||
|
task.setCreatedBy("system");
|
||||||
|
task.setRequestJson(JSONUtil.toJsonStr(request));
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
task.setFinishedAt(LocalDateTime.now());
|
||||||
|
fileTaskMapper.insert(task);
|
||||||
|
|
||||||
|
List<ConvertResultItemVo> items = new ArrayList<>();
|
||||||
|
int successCount = 0;
|
||||||
|
int failedCount = 0;
|
||||||
|
|
||||||
|
for (UploadedSourceFileDto sourceFile : request.getFiles()) {
|
||||||
|
ConvertResultItemVo item = new ConvertResultItemVo();
|
||||||
|
item.setSourceFilename(sourceFile.getOriginalFilename());
|
||||||
|
try {
|
||||||
|
File inputFile = findLocalSourceFile(sourceFile.getFileKey());
|
||||||
|
if (inputFile == null || !inputFile.exists()) {
|
||||||
|
throw new BusinessException("上传文件不存在,请重新上传");
|
||||||
|
}
|
||||||
|
|
||||||
|
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
|
||||||
|
String outputFilename = template.getOutputFilename() == null || template.getOutputFilename().isBlank()
|
||||||
|
? "结果.txt"
|
||||||
|
: template.getOutputFilename();
|
||||||
|
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "convert-result"));
|
||||||
|
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
|
||||||
|
|
||||||
|
writeTxtByLegacyRules(inputFile, outputFile, template);
|
||||||
|
|
||||||
|
String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "CONVERT");
|
||||||
|
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||||
|
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setOutputFilename(outputFilename);
|
||||||
|
item.setDownloadUrl(downloadUrl);
|
||||||
|
successCount++;
|
||||||
|
|
||||||
|
FileResultEntity resultEntity = new FileResultEntity();
|
||||||
|
resultEntity.setTaskId(task.getId());
|
||||||
|
resultEntity.setModuleType("CONVERT");
|
||||||
|
resultEntity.setSourceFilename(inputName);
|
||||||
|
resultEntity.setResultFilename(outputFilename);
|
||||||
|
resultEntity.setResultFileUrl(downloadUrl);
|
||||||
|
resultEntity.setResultFileSize(outputFile.length());
|
||||||
|
resultEntity.setResultContentType("text/plain");
|
||||||
|
resultEntity.setSuccess(1);
|
||||||
|
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(resultEntity);
|
||||||
|
item.setResultId(resultEntity.getId());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
item.setSuccess(false);
|
||||||
|
item.setError(ex.getMessage());
|
||||||
|
failedCount++;
|
||||||
|
|
||||||
|
FileResultEntity resultEntity = new FileResultEntity();
|
||||||
|
resultEntity.setTaskId(task.getId());
|
||||||
|
resultEntity.setModuleType("CONVERT");
|
||||||
|
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
|
||||||
|
resultEntity.setSuccess(0);
|
||||||
|
resultEntity.setErrorMessage(ex.getMessage());
|
||||||
|
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(resultEntity);
|
||||||
|
}
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.setSuccessFileCount(successCount);
|
||||||
|
task.setFailedFileCount(failedCount);
|
||||||
|
task.setResultJson(JSONUtil.toJsonStr(items));
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
fileTaskMapper.updateById(task);
|
||||||
|
|
||||||
|
ConvertRunVo vo = new ConvertRunVo();
|
||||||
|
vo.setTotal(request.getFiles().size());
|
||||||
|
vo.setSuccessCount(successCount);
|
||||||
|
vo.setFailedCount(failedCount);
|
||||||
|
vo.setItems(items);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ConvertResultItemVo> listHistory() {
|
||||||
|
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
|
.eq(FileResultEntity::getModuleType, "CONVERT")
|
||||||
|
.eq(FileResultEntity::getSuccess, 1)
|
||||||
|
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||||
|
.last("limit 50"))
|
||||||
|
.stream()
|
||||||
|
.map(entity -> {
|
||||||
|
ConvertResultItemVo vo = new ConvertResultItemVo();
|
||||||
|
vo.setResultId(entity.getId());
|
||||||
|
vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", ""));
|
||||||
|
vo.setOutputFilename(entity.getResultFilename());
|
||||||
|
vo.setDownloadUrl(entity.getResultFileUrl());
|
||||||
|
vo.setSuccess(true);
|
||||||
|
return vo;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteHistory(Long resultId) {
|
||||||
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
|
if (entity == null || !"CONVERT".equals(entity.getModuleType())) {
|
||||||
|
throw new BusinessException("记录不存在");
|
||||||
|
}
|
||||||
|
fileResultMapper.deleteById(resultId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public File getResultFile(Long resultId) {
|
||||||
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
|
if (entity == null || entity.getResultFileUrl() == null || !"CONVERT".equals(entity.getModuleType())) {
|
||||||
|
throw new BusinessException("结果文件不存在");
|
||||||
|
}
|
||||||
|
File file = new File(entity.getResultFileUrl());
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new BusinessException("结果文件不存在");
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeTxtByLegacyRules(File inputFile, File outputFile, ConvertTemplateEntity templateEntity) throws IOException {
|
||||||
|
List<String> requiredColumns = readStringList(templateEntity.getRequiredSourceColumnsJson());
|
||||||
|
List<String> headerColumns = readStringList(templateEntity.getHeaderColumnsJson());
|
||||||
|
Map<String, String> fieldMapping = readStringMap(templateEntity.getFieldMappingJson());
|
||||||
|
Map<String, String> defaults = readStringMap(templateEntity.getDefaultsJson());
|
||||||
|
List<String> preambleLines = readStringList(templateEntity.getPreambleLinesJson());
|
||||||
|
int blankHeaderRowsAfterSchema = templateEntity.getBlankHeaderRowsAfterSchema() == null ? 0 : templateEntity.getBlankHeaderRowsAfterSchema();
|
||||||
|
|
||||||
|
DataFormatter formatter = new DataFormatter();
|
||||||
|
try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) {
|
||||||
|
Sheet sheet = workbook.getSheetAt(0);
|
||||||
|
Row headerRow = sheet.getRow(0);
|
||||||
|
if (headerRow == null) {
|
||||||
|
throw new BusinessException("Excel 表头为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Integer> headerMap = new HashMap<>();
|
||||||
|
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
||||||
|
String value = normalizeCellText(formatter.formatCellValue(headerRow.getCell(i)));
|
||||||
|
if (!value.isBlank() && !headerMap.containsKey(value)) {
|
||||||
|
headerMap.put(value, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> missing = requiredColumns.stream().filter(column -> !headerMap.containsKey(column)).toList();
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
throw new BusinessException("缺少列:" + String.join("、", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> rows = new ArrayList<>();
|
||||||
|
rows.addAll(preambleLines);
|
||||||
|
rows.add(String.join("\t", headerColumns));
|
||||||
|
for (int i = 0; i < blankHeaderRowsAfterSchema; i++) {
|
||||||
|
rows.add("\t".repeat(Math.max(0, headerColumns.size() - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
long batchId = System.currentTimeMillis();
|
||||||
|
int rowIndex = 1;
|
||||||
|
int asinIndex = headerMap.getOrDefault("ASIN", -1);
|
||||||
|
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||||
|
Row row = sheet.getRow(rowNum);
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String asin = asinIndex >= 0 ? normalizeCellText(formatter.formatCellValue(row.getCell(asinIndex))) : "";
|
||||||
|
if (asin.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> lineValues = new ArrayList<>();
|
||||||
|
for (String column : headerColumns) {
|
||||||
|
if ("sku".equals(column)) {
|
||||||
|
lineValues.add(batchId + "-" + rowIndex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fieldMapping.containsKey(column)) {
|
||||||
|
String sourceColumn = fieldMapping.get(column);
|
||||||
|
Integer sourceIndex = headerMap.get(sourceColumn);
|
||||||
|
String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
||||||
|
lineValues.add(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (defaults.containsKey(column)) {
|
||||||
|
lineValues.add(defaults.get(column));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lineValues.add("");
|
||||||
|
}
|
||||||
|
rows.add(String.join("\t", lineValues));
|
||||||
|
rowIndex++;
|
||||||
|
}
|
||||||
|
FileUtil.writeLines(rows, outputFile, StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> readStringList(String json) {
|
||||||
|
try {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("模板配置解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> readStringMap(String json) {
|
||||||
|
try {
|
||||||
|
if (json == null || json.isBlank()) {
|
||||||
|
return new HashMap<>();
|
||||||
|
}
|
||||||
|
return objectMapper.readValue(json, new TypeReference<Map<String, String>>() {});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new BusinessException("模板配置解析失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private File findLocalSourceFile(String fileKey) {
|
||||||
|
File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
|
||||||
|
if (!baseDir.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
|
||||||
|
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildNamedOutputFile(File outputDir, String filename) {
|
||||||
|
File candidate = FileUtil.file(outputDir, filename);
|
||||||
|
if (!candidate.exists()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
String mainName = FileUtil.mainName(filename);
|
||||||
|
String extName = FileUtil.extName(filename);
|
||||||
|
int index = 2;
|
||||||
|
while (true) {
|
||||||
|
String nextFilename = extName == null || extName.isBlank()
|
||||||
|
? mainName + "_" + index
|
||||||
|
: mainName + "_" + index + "." + extName;
|
||||||
|
File nextCandidate = FileUtil.file(outputDir, nextFilename);
|
||||||
|
if (!nextCandidate.exists()) {
|
||||||
|
return nextCandidate;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeCellText(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.replace("\ufeff", "")
|
||||||
|
.replace("\u3000", " ")
|
||||||
|
.replace("\r\n", " ")
|
||||||
|
.replace("\r", " ")
|
||||||
|
.replace("\n", " ")
|
||||||
|
.replace("\t", " ")
|
||||||
|
.trim()
|
||||||
|
.replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.nanri.aiimage.modules.convert.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.modules.convert.mapper.ConvertTemplateMapper;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.dto.ConvertTemplateImportRequest;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.entity.ConvertTemplateEntity;
|
||||||
|
import com.nanri.aiimage.modules.convert.model.vo.ConvertTemplateVo;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ConvertTemplateService {
|
||||||
|
|
||||||
|
private final ConvertTemplateMapper convertTemplateMapper;
|
||||||
|
|
||||||
|
public List<ConvertTemplateVo> listEnabledTemplates() {
|
||||||
|
return convertTemplateMapper.selectList(new LambdaQueryWrapper<ConvertTemplateEntity>()
|
||||||
|
.eq(ConvertTemplateEntity::getEnabled, 1)
|
||||||
|
.orderByDesc(ConvertTemplateEntity::getIsDefault)
|
||||||
|
.orderByAsc(ConvertTemplateEntity::getId))
|
||||||
|
.stream()
|
||||||
|
.map(this::toVo)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConvertTemplateEntity getByCode(String templateCode) {
|
||||||
|
ConvertTemplateEntity entity = convertTemplateMapper.selectOne(new LambdaQueryWrapper<ConvertTemplateEntity>()
|
||||||
|
.eq(ConvertTemplateEntity::getTemplateCode, templateCode)
|
||||||
|
.eq(ConvertTemplateEntity::getEnabled, 1)
|
||||||
|
.last("limit 1"));
|
||||||
|
if (entity == null) {
|
||||||
|
throw new BusinessException("模板不存在或已禁用");
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ConvertTemplateVo importTemplate(ConvertTemplateImportRequest request) {
|
||||||
|
if (request.getTemplateContent() == null || request.getTemplateContent().isBlank()) {
|
||||||
|
throw new BusinessException("模板内容不能为空");
|
||||||
|
}
|
||||||
|
ConvertTemplateEntity entity = new ConvertTemplateEntity();
|
||||||
|
entity.setTemplateCode("user_" + System.currentTimeMillis());
|
||||||
|
entity.setTemplateName((request.getTemplateName() == null || request.getTemplateName().isBlank()) ? "自定义 Offer 模板" : request.getTemplateName().trim());
|
||||||
|
entity.setOutputFilename(entity.getTemplateName().endsWith(".txt") ? entity.getTemplateName() : entity.getTemplateName() + ".txt");
|
||||||
|
entity.setRequiredSourceColumnsJson("[\"ASIN\",\"价格\"]");
|
||||||
|
entity.setHeaderColumnsJson("[\"sku\",\"price\",\"quantity\",\"product-id\",\"product-id-type\",\"condition-type\",\"condition-note\",\"ASIN-hint\",\"title\",\"product-tax-code\",\"operation-type\",\"sale-price\",\"sale-start-date\",\"sale-end-date\",\"leadtime-to-ship\",\"launch-date\",\"is-giftwrap-available\",\"is-gift-message-available\"]");
|
||||||
|
entity.setFieldMappingJson("{\"price\":\"价格\",\"product-id\":\"ASIN\"}");
|
||||||
|
entity.setDefaultsJson("{\"quantity\":\"100\",\"product-id-type\":\"ASIN\",\"condition-type\":\"new\",\"leadtime-to-ship\":\"4\"}");
|
||||||
|
entity.setPreambleLinesJson("[\"TemplateType=Offer\\tVersion=1.4\"]");
|
||||||
|
entity.setBlankHeaderRowsAfterSchema(1);
|
||||||
|
entity.setIsDefault(0);
|
||||||
|
entity.setBuiltIn(0);
|
||||||
|
entity.setEnabled(1);
|
||||||
|
entity.setCreatedAt(LocalDateTime.now());
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
convertTemplateMapper.insert(entity);
|
||||||
|
return toVo(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void setDefaultTemplate(String templateCode) {
|
||||||
|
ConvertTemplateEntity entity = getByCode(templateCode);
|
||||||
|
convertTemplateMapper.selectList(new LambdaQueryWrapper<ConvertTemplateEntity>().eq(ConvertTemplateEntity::getEnabled, 1))
|
||||||
|
.forEach(item -> {
|
||||||
|
item.setIsDefault(item.getId().equals(entity.getId()) ? 1 : 0);
|
||||||
|
item.setUpdatedAt(LocalDateTime.now());
|
||||||
|
convertTemplateMapper.updateById(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteTemplate(String templateCode) {
|
||||||
|
ConvertTemplateEntity entity = getByCode(templateCode);
|
||||||
|
if (Integer.valueOf(1).equals(entity.getBuiltIn())) {
|
||||||
|
throw new BusinessException("内置模板不允许删除");
|
||||||
|
}
|
||||||
|
entity.setEnabled(0);
|
||||||
|
entity.setUpdatedAt(LocalDateTime.now());
|
||||||
|
convertTemplateMapper.updateById(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ConvertTemplateVo toVo(ConvertTemplateEntity entity) {
|
||||||
|
ConvertTemplateVo vo = new ConvertTemplateVo();
|
||||||
|
vo.setId(entity.getTemplateCode());
|
||||||
|
vo.setTemplateCode(entity.getTemplateCode());
|
||||||
|
vo.setTemplateName(entity.getTemplateName());
|
||||||
|
vo.setOutputFilename(entity.getOutputFilename());
|
||||||
|
vo.setIsDefault(Integer.valueOf(1).equals(entity.getIsDefault()));
|
||||||
|
vo.setBuiltIn(Integer.valueOf(1).equals(entity.getBuiltIn()));
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.nanri.aiimage.modules.dedupe.controller;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeRunRequest;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeHistoryVo;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeRunVo;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.service.DedupeRunService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/dedupe")
|
||||||
|
@Tag(name = "数据去重", description = "按照旧 Python 桌面端规则执行列保留和 ID 过滤。桌面端交互不变,只将处理逻辑迁到 Java。")
|
||||||
|
public class DedupeRunController {
|
||||||
|
|
||||||
|
private final DedupeRunService dedupeRunService;
|
||||||
|
|
||||||
|
@PostMapping("/run")
|
||||||
|
@Operation(
|
||||||
|
summary = "执行数据去重",
|
||||||
|
description = """
|
||||||
|
根据上传文件、保留列、ID 保留规则执行数据去重,生成清洗后的 Excel 结果文件。
|
||||||
|
|
||||||
|
规则与旧 Python 桌面端保持一致:
|
||||||
|
- 表头会先做文本清洗;
|
||||||
|
- 遇到“缩略图地址8”停止继续识别表头;
|
||||||
|
- 仅保留 selectedColumns 中指定的列;
|
||||||
|
- keepIntegerIds=true 时保留纯数字 ID;
|
||||||
|
- keepUnderscoreIds=true 时保留类似 1_1 的 ID;
|
||||||
|
- 输出文件命名遵循 原文件名_cleaned.xlsx,若重名自动追加序号。
|
||||||
|
""")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功,返回处理统计与结果列表", content = @Content(schema = @Schema(implementation = DedupeRunVo.class))),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数不合法或缺少必要字段"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "执行数据去重失败")
|
||||||
|
})
|
||||||
|
public ApiResponse<DedupeRunVo> run(@Valid @RequestBody DedupeRunRequest request) {
|
||||||
|
return ApiResponse.success(dedupeRunService.run(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history")
|
||||||
|
@Operation(summary = "查询去重历史", description = "查询最近的去重成功记录,用于页面右侧历史下载列表展示。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = DedupeHistoryVo.class)))
|
||||||
|
})
|
||||||
|
public ApiResponse<DedupeHistoryVo> history() {
|
||||||
|
DedupeHistoryVo vo = new DedupeHistoryVo();
|
||||||
|
vo.setItems(dedupeRunService.listHistory());
|
||||||
|
return ApiResponse.success(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/history/{resultId}")
|
||||||
|
@Operation(summary = "删除去重历史", description = "删除一条去重历史记录。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在")
|
||||||
|
})
|
||||||
|
public ApiResponse<Void> deleteHistory(@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId) {
|
||||||
|
dedupeRunService.deleteHistory(resultId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/results/{resultId}/download")
|
||||||
|
@Operation(summary = "下载数据去重结果", description = "根据结果记录 ID 下载清洗后的 Excel 文件。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
|
||||||
|
})
|
||||||
|
public ResponseEntity<Resource> download(@Parameter(description = "去重结果记录 ID", required = true) @PathVariable Long resultId) {
|
||||||
|
Resource resource = dedupeRunService.getResultFile(resultId);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.nanri.aiimage.modules.dedupe.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据去重执行请求")
|
||||||
|
public class DedupeRunRequest {
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotEmpty(message = "请先上传待处理文件")
|
||||||
|
@Schema(description = "已上传源文件列表")
|
||||||
|
private List<DedupeSourceFileDto> files;
|
||||||
|
|
||||||
|
@NotEmpty(message = "请至少选择一列")
|
||||||
|
@Schema(description = "保留列")
|
||||||
|
private List<String> selectedColumns;
|
||||||
|
|
||||||
|
@Schema(description = "是否保留纯数字 ID")
|
||||||
|
private boolean keepIntegerIds = true;
|
||||||
|
|
||||||
|
@Schema(description = "是否保留带下划线 ID")
|
||||||
|
private boolean keepUnderscoreIds = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.nanri.aiimage.modules.dedupe.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据去重源文件")
|
||||||
|
public class DedupeSourceFileDto {
|
||||||
|
|
||||||
|
@NotBlank(message = "fileKey 不能为空")
|
||||||
|
@Schema(description = "上传接口返回的临时文件键")
|
||||||
|
private String fileKey;
|
||||||
|
|
||||||
|
@Schema(description = "原始文件名")
|
||||||
|
private String originalFilename;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.nanri.aiimage.modules.dedupe.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据去重历史列表")
|
||||||
|
public class DedupeHistoryVo {
|
||||||
|
|
||||||
|
@Schema(description = "历史记录")
|
||||||
|
private List<DedupeResultItemVo> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.nanri.aiimage.modules.dedupe.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据去重结果项")
|
||||||
|
public class DedupeResultItemVo {
|
||||||
|
|
||||||
|
@Schema(description = "结果记录ID")
|
||||||
|
private Long resultId;
|
||||||
|
|
||||||
|
@Schema(description = "源文件名")
|
||||||
|
private String sourceFilename;
|
||||||
|
|
||||||
|
@Schema(description = "结果文件名")
|
||||||
|
private String outputFilename;
|
||||||
|
|
||||||
|
@Schema(description = "是否成功")
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
@Schema(description = "错误信息")
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
@Schema(description = "下载地址")
|
||||||
|
private String downloadUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.nanri.aiimage.modules.dedupe.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据去重执行结果")
|
||||||
|
public class DedupeRunVo {
|
||||||
|
|
||||||
|
@Schema(description = "处理文件总数")
|
||||||
|
private Integer total;
|
||||||
|
|
||||||
|
@Schema(description = "成功文件数")
|
||||||
|
private Integer successCount;
|
||||||
|
|
||||||
|
@Schema(description = "失败文件数")
|
||||||
|
private Integer failedCount;
|
||||||
|
|
||||||
|
@Schema(description = "结果列表")
|
||||||
|
private List<DedupeResultItemVo> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package com.nanri.aiimage.modules.dedupe.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.io.FileUtil;
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeRunRequest;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeResultItemVo;
|
||||||
|
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeRunVo;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class DedupeRunService {
|
||||||
|
|
||||||
|
private final FileTaskMapper fileTaskMapper;
|
||||||
|
private final FileResultMapper fileResultMapper;
|
||||||
|
private final StorageProperties storageProperties;
|
||||||
|
private final OssStorageService ossStorageService;
|
||||||
|
|
||||||
|
public DedupeRunVo run(DedupeRunRequest request) {
|
||||||
|
if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds()) {
|
||||||
|
throw new BusinessException("请至少选择一种 ID 保留规则");
|
||||||
|
}
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setTaskNo("DEDUPE-" + IdUtil.getSnowflakeNextIdStr());
|
||||||
|
task.setModuleType("DEDUPE");
|
||||||
|
task.setTaskMode("IMMEDIATE");
|
||||||
|
task.setStatus("SUCCESS");
|
||||||
|
task.setSourceFileCount(request.getFiles().size());
|
||||||
|
task.setCreatedBy("system");
|
||||||
|
task.setRequestJson(JSONUtil.toJsonStr(request));
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
task.setFinishedAt(LocalDateTime.now());
|
||||||
|
fileTaskMapper.insert(task);
|
||||||
|
|
||||||
|
List<DedupeResultItemVo> items = new ArrayList<>();
|
||||||
|
int successCount = 0;
|
||||||
|
int failedCount = 0;
|
||||||
|
|
||||||
|
for (DedupeSourceFileDto sourceFile : request.getFiles()) {
|
||||||
|
DedupeResultItemVo item = new DedupeResultItemVo();
|
||||||
|
item.setSourceFilename(sourceFile.getOriginalFilename());
|
||||||
|
try {
|
||||||
|
File inputFile = findLocalSourceFile(sourceFile.getFileKey());
|
||||||
|
if (inputFile == null || !inputFile.exists()) {
|
||||||
|
throw new BusinessException("上传文件不存在,请重新上传");
|
||||||
|
}
|
||||||
|
|
||||||
|
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
|
||||||
|
String outputFilename = buildOutputFilename(inputName);
|
||||||
|
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "dedupe-result"));
|
||||||
|
File outputFile = buildNamedOutputFile(outputDir, outputFilename);
|
||||||
|
|
||||||
|
cleanExcelByLegacyRules(inputFile, outputFile, request.getSelectedColumns(), request.isKeepIntegerIds(), request.isKeepUnderscoreIds());
|
||||||
|
|
||||||
|
String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE");
|
||||||
|
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||||
|
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setOutputFilename(outputFile.getName());
|
||||||
|
item.setDownloadUrl(downloadUrl);
|
||||||
|
successCount++;
|
||||||
|
|
||||||
|
FileResultEntity resultEntity = new FileResultEntity();
|
||||||
|
resultEntity.setTaskId(task.getId());
|
||||||
|
resultEntity.setModuleType("DEDUPE");
|
||||||
|
resultEntity.setSourceFilename(inputName);
|
||||||
|
resultEntity.setResultFilename(outputFile.getName());
|
||||||
|
resultEntity.setResultFileUrl(downloadUrl);
|
||||||
|
resultEntity.setResultFileSize(outputFile.length());
|
||||||
|
resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
resultEntity.setSuccess(1);
|
||||||
|
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(resultEntity);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
item.setSuccess(false);
|
||||||
|
item.setError(ex.getMessage());
|
||||||
|
failedCount++;
|
||||||
|
|
||||||
|
FileResultEntity resultEntity = new FileResultEntity();
|
||||||
|
resultEntity.setTaskId(task.getId());
|
||||||
|
resultEntity.setModuleType("DEDUPE");
|
||||||
|
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
|
||||||
|
resultEntity.setSuccess(0);
|
||||||
|
resultEntity.setErrorMessage(ex.getMessage());
|
||||||
|
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(resultEntity);
|
||||||
|
}
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
task.setSuccessFileCount(successCount);
|
||||||
|
task.setFailedFileCount(failedCount);
|
||||||
|
task.setResultJson(JSONUtil.toJsonStr(items));
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
fileTaskMapper.updateById(task);
|
||||||
|
|
||||||
|
DedupeRunVo vo = new DedupeRunVo();
|
||||||
|
vo.setTotal(request.getFiles().size());
|
||||||
|
vo.setSuccessCount(successCount);
|
||||||
|
vo.setFailedCount(failedCount);
|
||||||
|
vo.setItems(items);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DedupeResultItemVo> listHistory() {
|
||||||
|
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
|
.eq(FileResultEntity::getModuleType, "DEDUPE")
|
||||||
|
.eq(FileResultEntity::getSuccess, 1)
|
||||||
|
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||||
|
.last("limit 50"))
|
||||||
|
.stream()
|
||||||
|
.map(entity -> {
|
||||||
|
DedupeResultItemVo vo = new DedupeResultItemVo();
|
||||||
|
vo.setResultId(entity.getId());
|
||||||
|
vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", ""));
|
||||||
|
vo.setOutputFilename(entity.getResultFilename());
|
||||||
|
vo.setDownloadUrl(entity.getResultFileUrl());
|
||||||
|
vo.setSuccess(true);
|
||||||
|
return vo;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteHistory(Long resultId) {
|
||||||
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
|
if (entity == null || !"DEDUPE".equals(entity.getModuleType())) {
|
||||||
|
throw new BusinessException("记录不存在");
|
||||||
|
}
|
||||||
|
fileResultMapper.deleteById(resultId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Resource getResultFile(Long resultId) {
|
||||||
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
|
if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType())) {
|
||||||
|
throw new BusinessException("结果文件不存在");
|
||||||
|
}
|
||||||
|
File file = new File(entity.getResultFileUrl());
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new BusinessException("结果文件不存在");
|
||||||
|
}
|
||||||
|
return new FileSystemResource(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanExcelByLegacyRules(File inputFile, File outputFile, List<String> selectedColumns,
|
||||||
|
boolean keepIntegerIds, boolean keepUnderscoreIds) throws Exception {
|
||||||
|
DataFormatter formatter = new DataFormatter();
|
||||||
|
try (FileInputStream fis = new FileInputStream(inputFile);
|
||||||
|
Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis);
|
||||||
|
XSSFWorkbook outputWorkbook = new XSSFWorkbook()) {
|
||||||
|
Sheet sheet = workbook.getSheetAt(0);
|
||||||
|
Row headerRow = sheet.getRow(0);
|
||||||
|
if (headerRow == null) {
|
||||||
|
throw new BusinessException("Excel 表头为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Integer> headerMap = new HashMap<>();
|
||||||
|
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
||||||
|
Cell cell = headerRow.getCell(i);
|
||||||
|
String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell));
|
||||||
|
if (value.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty()
|
||||||
|
? value
|
||||||
|
: value.split("idASIN国家状态价格变体数量", 2)[0].trim();
|
||||||
|
if (headerMap.containsKey(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headerMap.put(value, i);
|
||||||
|
if ("缩略图地址8".equals(value)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> missing = selectedColumns.stream().filter(column -> !headerMap.containsKey(column)).toList();
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
throw new BusinessException("缺少列:" + String.join("、", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
Sheet outputSheet = outputWorkbook.createSheet(sheet.getSheetName());
|
||||||
|
Row outputHeaderRow = outputSheet.createRow(0);
|
||||||
|
for (int i = 0; i < selectedColumns.size(); i++) {
|
||||||
|
outputHeaderRow.createCell(i).setCellValue(selectedColumns.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
Integer idColumnIndex = headerMap.get("id");
|
||||||
|
int outputRowIndex = 1;
|
||||||
|
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||||
|
Row row = sheet.getRow(rowNum);
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (idColumnIndex != null) {
|
||||||
|
String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex)));
|
||||||
|
if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row outputRow = outputSheet.createRow(outputRowIndex++);
|
||||||
|
for (int i = 0; i < selectedColumns.size(); i++) {
|
||||||
|
Integer sourceIndex = headerMap.get(selectedColumns.get(i));
|
||||||
|
String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
||||||
|
outputRow.createCell(i).setCellValue(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
|
||||||
|
outputWorkbook.write(fos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldKeepId(String text, boolean keepIntegerIds, boolean keepUnderscoreIds) {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (keepIntegerIds && text.matches("\\d+")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return keepUnderscoreIds && text.matches("\\d+_\\d+");
|
||||||
|
}
|
||||||
|
|
||||||
|
private File findLocalSourceFile(String fileKey) {
|
||||||
|
File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
|
||||||
|
if (!baseDir.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
|
||||||
|
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildNamedOutputFile(File outputDir, String filename) {
|
||||||
|
File candidate = FileUtil.file(outputDir, filename);
|
||||||
|
if (!candidate.exists()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
String mainName = FileUtil.mainName(filename);
|
||||||
|
String extName = FileUtil.extName(filename);
|
||||||
|
int index = 2;
|
||||||
|
while (true) {
|
||||||
|
String nextFilename = extName == null || extName.isBlank()
|
||||||
|
? mainName + "_" + index
|
||||||
|
: mainName + "_" + index + "." + extName;
|
||||||
|
File nextCandidate = FileUtil.file(outputDir, nextFilename);
|
||||||
|
if (!nextCandidate.exists()) {
|
||||||
|
return nextCandidate;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildOutputFilename(String originalFilename) {
|
||||||
|
String mainName = originalFilename == null || originalFilename.isBlank() ? "source" : FileUtil.mainName(originalFilename);
|
||||||
|
String extName = originalFilename == null || originalFilename.isBlank() ? "xlsx" : FileUtil.extName(originalFilename);
|
||||||
|
return mainName + "_cleaned." + (extName == null || extName.isBlank() ? "xlsx" : extName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeCellText(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.replace("\ufeff", "")
|
||||||
|
.replace("\u3000", " ")
|
||||||
|
.replace("\r\n", " ")
|
||||||
|
.replace("\r", " ")
|
||||||
|
.replace("\n", " ")
|
||||||
|
.replace("\t", " ")
|
||||||
|
.trim()
|
||||||
|
.replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.nanri.aiimage.modules.file.controller;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.modules.file.model.vo.UploadFileVo;
|
||||||
|
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/files")
|
||||||
|
@Tag(name = "文件上传", description = "用于前端将本地文件上传到后端临时目录,后续任务接口基于 fileKey 进行处理")
|
||||||
|
public class FileUploadController {
|
||||||
|
|
||||||
|
private final LocalFileStorageService localFileStorageService;
|
||||||
|
|
||||||
|
@PostMapping("/upload")
|
||||||
|
@Operation(
|
||||||
|
summary = "上传临时文件",
|
||||||
|
description = """
|
||||||
|
上传本地 Excel 文件到后端临时目录,返回 fileKey。
|
||||||
|
|
||||||
|
该接口不是给浏览器页面直接使用的最终业务接口,而是给 Python 桌面端壳做文件中转:
|
||||||
|
- 桌面端先选本地文件;
|
||||||
|
- 再调用本接口上传到 Java 临时目录;
|
||||||
|
- 然后把 fileKey 传给 dedupe/convert/split 执行接口;
|
||||||
|
- 处理完成后,桌面端再调用下载接口把结果文件保存回用户本地目录。
|
||||||
|
""")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "上传成功,返回临时文件信息", content = @Content(schema = @Schema(implementation = UploadFileVo.class))),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "未传文件或文件不合法"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "服务器保存临时文件失败")
|
||||||
|
})
|
||||||
|
public ApiResponse<UploadFileVo> upload(
|
||||||
|
@Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY)
|
||||||
|
MultipartFile file) throws Exception {
|
||||||
|
return ApiResponse.success(localFileStorageService.saveTempFile(file));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.nanri.aiimage.modules.file.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "上传文件返回信息")
|
||||||
|
public class UploadFileVo {
|
||||||
|
|
||||||
|
@Schema(description = "临时文件键")
|
||||||
|
private String fileKey;
|
||||||
|
|
||||||
|
@Schema(description = "原始文件名")
|
||||||
|
private String originalFilename;
|
||||||
|
|
||||||
|
@Schema(description = "本地暂存路径")
|
||||||
|
private String localPath;
|
||||||
|
|
||||||
|
@Schema(description = "文件大小")
|
||||||
|
private Long size;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.nanri.aiimage.modules.file.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.io.FileUtil;
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
|
import com.nanri.aiimage.modules.file.model.vo.UploadFileVo;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class LocalFileStorageService {
|
||||||
|
|
||||||
|
private final StorageProperties storageProperties;
|
||||||
|
|
||||||
|
public UploadFileVo saveTempFile(MultipartFile file) throws IOException {
|
||||||
|
File tempDir = FileUtil.file(storageProperties.getLocalTempDir());
|
||||||
|
File parentDir = tempDir.getParentFile();
|
||||||
|
if (parentDir != null) {
|
||||||
|
FileUtil.mkdir(parentDir);
|
||||||
|
}
|
||||||
|
FileUtil.mkdir(tempDir);
|
||||||
|
String fileKey = IdUtil.fastSimpleUUID();
|
||||||
|
String extName = FileUtil.extName(file.getOriginalFilename());
|
||||||
|
File target = FileUtil.file(tempDir, fileKey + (extName.isEmpty() ? "" : "." + extName));
|
||||||
|
file.transferTo(target);
|
||||||
|
|
||||||
|
UploadFileVo vo = new UploadFileVo();
|
||||||
|
vo.setFileKey(fileKey);
|
||||||
|
vo.setOriginalFilename(file.getOriginalFilename());
|
||||||
|
vo.setLocalPath(target.getAbsolutePath());
|
||||||
|
vo.setSize(file.getSize());
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.nanri.aiimage.modules.file.service.oss;
|
||||||
|
|
||||||
|
import com.aliyun.oss.OSS;
|
||||||
|
import com.aliyun.oss.OSSClientBuilder;
|
||||||
|
import com.nanri.aiimage.config.OssProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OssStorageService {
|
||||||
|
|
||||||
|
private final OssProperties ossProperties;
|
||||||
|
|
||||||
|
public String uploadResultFile(File file, String moduleType) {
|
||||||
|
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
|
||||||
|
OSS ossClient = new OSSClientBuilder().build(
|
||||||
|
"https://" + ossProperties.getEndpoint(),
|
||||||
|
ossProperties.getAccessKeyId(),
|
||||||
|
ossProperties.getAccessKeySecret()
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
ossClient.putObject(ossProperties.getBucket(), objectKey, file);
|
||||||
|
return objectKey;
|
||||||
|
} finally {
|
||||||
|
ossClient.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateDownloadUrl(String objectKey) {
|
||||||
|
OSS ossClient = new OSSClientBuilder().build(
|
||||||
|
"https://" + ossProperties.getEndpoint(),
|
||||||
|
ossProperties.getAccessKeyId(),
|
||||||
|
ossProperties.getAccessKeySecret()
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
Date expiration = new Date(System.currentTimeMillis() + 3600_000L);
|
||||||
|
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
|
||||||
|
return url.toString();
|
||||||
|
} finally {
|
||||||
|
ossClient.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.nanri.aiimage.modules.split.controller;
|
||||||
|
|
||||||
|
import com.nanri.aiimage.common.api.ApiResponse;
|
||||||
|
import com.nanri.aiimage.modules.split.model.dto.SplitRunRequest;
|
||||||
|
import com.nanri.aiimage.modules.split.model.vo.SplitHistoryVo;
|
||||||
|
import com.nanri.aiimage.modules.split.model.vo.SplitRunVo;
|
||||||
|
import com.nanri.aiimage.modules.split.service.SplitRunService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequestMapping("/api/split")
|
||||||
|
@Tag(name = "数据拆分", description = "按照旧 Python 桌面端规则执行数据拆分。桌面端交互不变,只将处理逻辑迁到 Java。")
|
||||||
|
public class SplitRunController {
|
||||||
|
|
||||||
|
private final SplitRunService splitRunService;
|
||||||
|
|
||||||
|
@PostMapping("/run")
|
||||||
|
@Operation(
|
||||||
|
summary = "执行数据拆分",
|
||||||
|
description = """
|
||||||
|
根据上传文件、保留列、拆分模式和参数执行 Excel 拆分。
|
||||||
|
|
||||||
|
规则与旧 Python 桌面端保持一致:
|
||||||
|
- 表头做文本清洗并兼容历史脏串;
|
||||||
|
- 遇到“缩略图地址8”停止继续识别表头;
|
||||||
|
- 仅保留 selectedColumns 中指定的列;
|
||||||
|
- splitMode=rows_per_file 时按固定条数切分;
|
||||||
|
- splitMode=parts 时按总条数尽量均分;
|
||||||
|
- 输出文件命名遵循 原文件名_split_序号.xlsx;
|
||||||
|
- 返回结果中 rowCount 表示该结果文件包含的数据行数。
|
||||||
|
""")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "执行成功,返回拆分结果统计", content = @Content(schema = @Schema(implementation = SplitRunVo.class))),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "请求参数不合法或拆分模式错误"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "执行数据拆分失败")
|
||||||
|
})
|
||||||
|
public ApiResponse<SplitRunVo> run(@Valid @RequestBody SplitRunRequest request) {
|
||||||
|
return ApiResponse.success(splitRunService.run(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/history")
|
||||||
|
@Operation(summary = "查询数据拆分历史", description = "查询最近的数据拆分成功记录,用于页面右侧历史下载列表展示。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = SplitHistoryVo.class)))
|
||||||
|
})
|
||||||
|
public ApiResponse<SplitHistoryVo> history() {
|
||||||
|
SplitHistoryVo vo = new SplitHistoryVo();
|
||||||
|
vo.setItems(splitRunService.listHistory());
|
||||||
|
return ApiResponse.success(vo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/history/{resultId}")
|
||||||
|
@Operation(summary = "删除数据拆分历史", description = "删除一条数据拆分历史记录。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果记录不存在")
|
||||||
|
})
|
||||||
|
public ApiResponse<Void> deleteHistory(@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId) {
|
||||||
|
splitRunService.deleteHistory(resultId);
|
||||||
|
return ApiResponse.success(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/results/{resultId}/download")
|
||||||
|
@Operation(summary = "下载数据拆分结果", description = "根据结果记录 ID 下载拆分后的 Excel 文件。")
|
||||||
|
@ApiResponses({
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "返回结果文件流"),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "结果文件不存在")
|
||||||
|
})
|
||||||
|
public ResponseEntity<Resource> download(@Parameter(description = "数据拆分结果记录 ID", required = true) @PathVariable Long resultId) {
|
||||||
|
Resource resource = splitRunService.getResultFile(resultId);
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")
|
||||||
|
.body(resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.nanri.aiimage.modules.split.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据拆分执行请求")
|
||||||
|
public class SplitRunRequest {
|
||||||
|
|
||||||
|
@Valid
|
||||||
|
@NotEmpty(message = "请先上传待拆分文件")
|
||||||
|
@Schema(description = "已上传源文件列表")
|
||||||
|
private List<SplitSourceFileDto> files;
|
||||||
|
|
||||||
|
@NotEmpty(message = "请至少选择一列")
|
||||||
|
@Schema(description = "保留列")
|
||||||
|
private List<String> selectedColumns;
|
||||||
|
|
||||||
|
@NotBlank(message = "拆分模式不能为空")
|
||||||
|
@Schema(description = "拆分模式:rows_per_file / parts")
|
||||||
|
private String splitMode;
|
||||||
|
|
||||||
|
@Schema(description = "按每份条数拆分时的条数")
|
||||||
|
private Integer rowsPerFile;
|
||||||
|
|
||||||
|
@Schema(description = "按份数拆分时的份数")
|
||||||
|
private Integer parts;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.nanri.aiimage.modules.split.model.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据拆分源文件")
|
||||||
|
public class SplitSourceFileDto {
|
||||||
|
|
||||||
|
@NotBlank(message = "fileKey 不能为空")
|
||||||
|
@Schema(description = "上传接口返回的临时文件键")
|
||||||
|
private String fileKey;
|
||||||
|
|
||||||
|
@Schema(description = "原始文件名")
|
||||||
|
private String originalFilename;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.nanri.aiimage.modules.split.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据拆分历史列表")
|
||||||
|
public class SplitHistoryVo {
|
||||||
|
|
||||||
|
@Schema(description = "历史记录")
|
||||||
|
private List<SplitResultItemVo> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.nanri.aiimage.modules.split.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据拆分结果项")
|
||||||
|
public class SplitResultItemVo {
|
||||||
|
|
||||||
|
@Schema(description = "结果记录ID")
|
||||||
|
private Long resultId;
|
||||||
|
|
||||||
|
@Schema(description = "源文件名")
|
||||||
|
private String sourceFilename;
|
||||||
|
|
||||||
|
@Schema(description = "结果文件名")
|
||||||
|
private String outputFilename;
|
||||||
|
|
||||||
|
@Schema(description = "是否成功")
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
@Schema(description = "错误信息")
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
@Schema(description = "结果行数")
|
||||||
|
private Integer rowCount;
|
||||||
|
|
||||||
|
@Schema(description = "下载地址")
|
||||||
|
private String downloadUrl;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.nanri.aiimage.modules.split.model.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Schema(description = "数据拆分执行结果")
|
||||||
|
public class SplitRunVo {
|
||||||
|
|
||||||
|
@Schema(description = "处理文件总数")
|
||||||
|
private Integer total;
|
||||||
|
|
||||||
|
@Schema(description = "成功文件数")
|
||||||
|
private Integer successCount;
|
||||||
|
|
||||||
|
@Schema(description = "失败文件数")
|
||||||
|
private Integer failedCount;
|
||||||
|
|
||||||
|
@Schema(description = "结果列表")
|
||||||
|
private List<SplitResultItemVo> items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package com.nanri.aiimage.modules.split.service;
|
||||||
|
|
||||||
|
import cn.hutool.core.io.FileUtil;
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import cn.hutool.json.JSONUtil;
|
||||||
|
import com.nanri.aiimage.common.exception.BusinessException;
|
||||||
|
import com.nanri.aiimage.config.StorageProperties;
|
||||||
|
import com.nanri.aiimage.modules.split.model.dto.SplitRunRequest;
|
||||||
|
import com.nanri.aiimage.modules.split.model.dto.SplitSourceFileDto;
|
||||||
|
import com.nanri.aiimage.modules.split.model.vo.SplitResultItemVo;
|
||||||
|
import com.nanri.aiimage.modules.split.model.vo.SplitRunVo;
|
||||||
|
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
|
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||||
|
import org.apache.poi.ss.usermodel.Row;
|
||||||
|
import org.apache.poi.ss.usermodel.Sheet;
|
||||||
|
import org.apache.poi.ss.usermodel.Workbook;
|
||||||
|
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SplitRunService {
|
||||||
|
|
||||||
|
private final FileTaskMapper fileTaskMapper;
|
||||||
|
private final FileResultMapper fileResultMapper;
|
||||||
|
private final StorageProperties storageProperties;
|
||||||
|
private final OssStorageService ossStorageService;
|
||||||
|
|
||||||
|
public SplitRunVo run(SplitRunRequest request) {
|
||||||
|
validateRequest(request);
|
||||||
|
|
||||||
|
FileTaskEntity task = new FileTaskEntity();
|
||||||
|
task.setTaskNo("SPLIT-" + IdUtil.getSnowflakeNextIdStr());
|
||||||
|
task.setModuleType("SPLIT");
|
||||||
|
task.setTaskMode("IMMEDIATE");
|
||||||
|
task.setStatus("SUCCESS");
|
||||||
|
task.setSourceFileCount(request.getFiles().size());
|
||||||
|
task.setCreatedBy("system");
|
||||||
|
task.setRequestJson(JSONUtil.toJsonStr(request));
|
||||||
|
task.setCreatedAt(LocalDateTime.now());
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
task.setFinishedAt(LocalDateTime.now());
|
||||||
|
fileTaskMapper.insert(task);
|
||||||
|
|
||||||
|
List<SplitResultItemVo> items = new ArrayList<>();
|
||||||
|
int successCount = 0;
|
||||||
|
int failedCount = 0;
|
||||||
|
|
||||||
|
for (SplitSourceFileDto sourceFile : request.getFiles()) {
|
||||||
|
try {
|
||||||
|
File inputFile = findLocalSourceFile(sourceFile.getFileKey());
|
||||||
|
if (inputFile == null || !inputFile.exists()) {
|
||||||
|
throw new BusinessException("上传文件不存在,请重新上传");
|
||||||
|
}
|
||||||
|
String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename();
|
||||||
|
List<SplitChunkResult> chunkResults = splitExcelByLegacyRules(inputFile, inputName, request);
|
||||||
|
for (SplitChunkResult chunkResult : chunkResults) {
|
||||||
|
SplitResultItemVo item = new SplitResultItemVo();
|
||||||
|
item.setSourceFilename(inputName);
|
||||||
|
item.setOutputFilename(chunkResult.outputFile().getName());
|
||||||
|
item.setSuccess(true);
|
||||||
|
item.setRowCount(chunkResult.rowCount());
|
||||||
|
|
||||||
|
String ossObjectKey = ossStorageService.uploadResultFile(chunkResult.outputFile(), "SPLIT");
|
||||||
|
String downloadUrl = ossStorageService.generateDownloadUrl(ossObjectKey);
|
||||||
|
|
||||||
|
FileResultEntity resultEntity = new FileResultEntity();
|
||||||
|
resultEntity.setTaskId(task.getId());
|
||||||
|
resultEntity.setModuleType("SPLIT");
|
||||||
|
resultEntity.setSourceFilename(inputName);
|
||||||
|
resultEntity.setResultFilename(chunkResult.outputFile().getName());
|
||||||
|
resultEntity.setResultFileUrl(downloadUrl);
|
||||||
|
resultEntity.setResultFileSize(chunkResult.outputFile().length());
|
||||||
|
resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
resultEntity.setRowCount(chunkResult.rowCount());
|
||||||
|
resultEntity.setSuccess(1);
|
||||||
|
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(resultEntity);
|
||||||
|
item.setDownloadUrl(downloadUrl);
|
||||||
|
item.setResultId(resultEntity.getId());
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
successCount++;
|
||||||
|
} catch (Exception ex) {
|
||||||
|
SplitResultItemVo item = new SplitResultItemVo();
|
||||||
|
item.setSourceFilename(sourceFile.getOriginalFilename());
|
||||||
|
item.setSuccess(false);
|
||||||
|
item.setError(ex.getMessage());
|
||||||
|
items.add(item);
|
||||||
|
failedCount++;
|
||||||
|
|
||||||
|
FileResultEntity resultEntity = new FileResultEntity();
|
||||||
|
resultEntity.setTaskId(task.getId());
|
||||||
|
resultEntity.setModuleType("SPLIT");
|
||||||
|
resultEntity.setSourceFilename(sourceFile.getOriginalFilename());
|
||||||
|
resultEntity.setSuccess(0);
|
||||||
|
resultEntity.setErrorMessage(ex.getMessage());
|
||||||
|
resultEntity.setCreatedAt(LocalDateTime.now());
|
||||||
|
fileResultMapper.insert(resultEntity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
task.setSuccessFileCount(successCount);
|
||||||
|
task.setFailedFileCount(failedCount);
|
||||||
|
task.setResultJson(JSONUtil.toJsonStr(items));
|
||||||
|
task.setUpdatedAt(LocalDateTime.now());
|
||||||
|
fileTaskMapper.updateById(task);
|
||||||
|
|
||||||
|
SplitRunVo vo = new SplitRunVo();
|
||||||
|
vo.setTotal(request.getFiles().size());
|
||||||
|
vo.setSuccessCount(successCount);
|
||||||
|
vo.setFailedCount(failedCount);
|
||||||
|
vo.setItems(items);
|
||||||
|
return vo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<SplitResultItemVo> listHistory() {
|
||||||
|
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||||
|
.eq(FileResultEntity::getModuleType, "SPLIT")
|
||||||
|
.eq(FileResultEntity::getSuccess, 1)
|
||||||
|
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||||
|
.last("limit 100"))
|
||||||
|
.stream()
|
||||||
|
.map(entity -> {
|
||||||
|
SplitResultItemVo vo = new SplitResultItemVo();
|
||||||
|
vo.setResultId(entity.getId());
|
||||||
|
vo.setSourceFilename(entity.getCreatedAt() == null ? "" : entity.getCreatedAt().toLocalDate().toString().replace("-", ""));
|
||||||
|
vo.setOutputFilename(entity.getResultFilename());
|
||||||
|
vo.setDownloadUrl(entity.getResultFileUrl());
|
||||||
|
vo.setRowCount(entity.getRowCount());
|
||||||
|
vo.setSuccess(true);
|
||||||
|
return vo;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteHistory(Long resultId) {
|
||||||
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
|
if (entity == null || !"SPLIT".equals(entity.getModuleType())) {
|
||||||
|
throw new BusinessException("记录不存在");
|
||||||
|
}
|
||||||
|
fileResultMapper.deleteById(resultId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Resource getResultFile(Long resultId) {
|
||||||
|
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||||
|
if (entity == null || entity.getResultFileUrl() == null || !"SPLIT".equals(entity.getModuleType())) {
|
||||||
|
throw new BusinessException("结果文件不存在");
|
||||||
|
}
|
||||||
|
File file = new File(entity.getResultFileUrl());
|
||||||
|
if (!file.exists()) {
|
||||||
|
throw new BusinessException("结果文件不存在");
|
||||||
|
}
|
||||||
|
return new FileSystemResource(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SplitChunkResult> splitExcelByLegacyRules(File inputFile, String originalFilename, SplitRunRequest request) throws Exception {
|
||||||
|
DataFormatter formatter = new DataFormatter();
|
||||||
|
try (FileInputStream fis = new FileInputStream(inputFile); Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) {
|
||||||
|
Sheet sheet = workbook.getSheetAt(0);
|
||||||
|
Row headerRow = sheet.getRow(0);
|
||||||
|
if (headerRow == null) {
|
||||||
|
throw new BusinessException("Excel 表头为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Integer> headerMap = new HashMap<>();
|
||||||
|
for (int i = 0; i < headerRow.getLastCellNum(); i++) {
|
||||||
|
Cell cell = headerRow.getCell(i);
|
||||||
|
String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell));
|
||||||
|
if (value.isBlank()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty()
|
||||||
|
? value
|
||||||
|
: value.split("idASIN国家状态价格变体数量", 2)[0].trim();
|
||||||
|
if (headerMap.containsKey(value)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headerMap.put(value, i);
|
||||||
|
if ("缩略图地址8".equals(value)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> missing = request.getSelectedColumns().stream().filter(column -> !headerMap.containsKey(column)).toList();
|
||||||
|
if (!missing.isEmpty()) {
|
||||||
|
throw new BusinessException("缺少列:" + String.join("、", missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<String>> rowsData = new ArrayList<>();
|
||||||
|
for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) {
|
||||||
|
Row row = sheet.getRow(rowNum);
|
||||||
|
if (row == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
List<String> projected = new ArrayList<>();
|
||||||
|
for (String column : request.getSelectedColumns()) {
|
||||||
|
Integer sourceIndex = headerMap.get(column);
|
||||||
|
String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex)));
|
||||||
|
projected.add(value);
|
||||||
|
}
|
||||||
|
rowsData.add(projected);
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalRows = rowsData.size();
|
||||||
|
if (totalRows == 0) {
|
||||||
|
throw new BusinessException("没有可拆分的数据行");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<List<String>>> chunks = new ArrayList<>();
|
||||||
|
if ("rows_per_file".equals(request.getSplitMode())) {
|
||||||
|
int chunkSize = request.getRowsPerFile();
|
||||||
|
for (int i = 0; i < totalRows; i += chunkSize) {
|
||||||
|
chunks.add(rowsData.subList(i, Math.min(i + chunkSize, totalRows)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
int actualParts = Math.min(request.getParts(), totalRows);
|
||||||
|
int base = totalRows / actualParts;
|
||||||
|
int remainder = totalRows % actualParts;
|
||||||
|
int start = 0;
|
||||||
|
for (int i = 0; i < actualParts; i++) {
|
||||||
|
int size = base + (i < remainder ? 1 : 0);
|
||||||
|
int end = start + size;
|
||||||
|
chunks.add(rowsData.subList(start, end));
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "split-result"));
|
||||||
|
String sourceStem = FileUtil.mainName(originalFilename);
|
||||||
|
List<SplitChunkResult> results = new ArrayList<>();
|
||||||
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
|
File outputFile = buildNamedOutputFile(outputDir, sourceStem + "_split_" + (i + 1) + ".xlsx");
|
||||||
|
try (XSSFWorkbook outputWorkbook = new XSSFWorkbook()) {
|
||||||
|
Sheet outputSheet = outputWorkbook.createSheet(sheet.getSheetName());
|
||||||
|
Row outputHeaderRow = outputSheet.createRow(0);
|
||||||
|
for (int colIndex = 0; colIndex < request.getSelectedColumns().size(); colIndex++) {
|
||||||
|
outputHeaderRow.createCell(colIndex).setCellValue(request.getSelectedColumns().get(colIndex));
|
||||||
|
}
|
||||||
|
int outputRowIndex = 1;
|
||||||
|
for (List<String> rowValues : chunks.get(i)) {
|
||||||
|
Row outputRow = outputSheet.createRow(outputRowIndex++);
|
||||||
|
for (int colIndex = 0; colIndex < rowValues.size(); colIndex++) {
|
||||||
|
outputRow.createCell(colIndex).setCellValue(rowValues.get(colIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
|
||||||
|
outputWorkbook.write(fos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.add(new SplitChunkResult(outputFile, chunks.get(i).size()));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRequest(SplitRunRequest request) {
|
||||||
|
if (!List.of("rows_per_file", "parts").contains(request.getSplitMode())) {
|
||||||
|
throw new BusinessException("拆分模式不合法");
|
||||||
|
}
|
||||||
|
if ("rows_per_file".equals(request.getSplitMode()) && (request.getRowsPerFile() == null || request.getRowsPerFile() <= 0)) {
|
||||||
|
throw new BusinessException("每份条数不合法");
|
||||||
|
}
|
||||||
|
if ("parts".equals(request.getSplitMode()) && (request.getParts() == null || request.getParts() <= 0)) {
|
||||||
|
throw new BusinessException("拆分份数不合法");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private File findLocalSourceFile(String fileKey) {
|
||||||
|
File baseDir = FileUtil.file(storageProperties.getLocalTempDir());
|
||||||
|
if (!baseDir.exists()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<File> matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey));
|
||||||
|
return matchedFiles.isEmpty() ? null : matchedFiles.getFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File buildNamedOutputFile(File outputDir, String filename) {
|
||||||
|
File candidate = FileUtil.file(outputDir, filename);
|
||||||
|
if (!candidate.exists()) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
String mainName = FileUtil.mainName(filename);
|
||||||
|
String extName = FileUtil.extName(filename);
|
||||||
|
int index = 2;
|
||||||
|
while (true) {
|
||||||
|
String nextFilename = extName == null || extName.isBlank()
|
||||||
|
? mainName + "_" + index
|
||||||
|
: mainName + "_" + index + "." + extName;
|
||||||
|
File nextCandidate = FileUtil.file(outputDir, nextFilename);
|
||||||
|
if (!nextCandidate.exists()) {
|
||||||
|
return nextCandidate;
|
||||||
|
}
|
||||||
|
index++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeCellText(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.replace("\ufeff", "")
|
||||||
|
.replace("\u3000", " ")
|
||||||
|
.replace("\r\n", " ")
|
||||||
|
.replace("\r", " ")
|
||||||
|
.replace("\n", " ")
|
||||||
|
.replace("\t", " ")
|
||||||
|
.trim()
|
||||||
|
.replaceAll("\\s+", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record SplitChunkResult(File outputFile, int rowCount) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.nanri.aiimage.modules.task.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface FileResultMapper extends BaseMapper<FileResultEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.nanri.aiimage.modules.task.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface FileTaskMapper extends BaseMapper<FileTaskEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.nanri.aiimage.modules.task.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("biz_file_result")
|
||||||
|
public class FileResultEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private Long taskId;
|
||||||
|
private String moduleType;
|
||||||
|
private String sourceFilename;
|
||||||
|
private String sourceFileUrl;
|
||||||
|
private String resultFilename;
|
||||||
|
private String resultFileUrl;
|
||||||
|
private Long resultFileSize;
|
||||||
|
private String resultContentType;
|
||||||
|
private Integer rowCount;
|
||||||
|
private Integer success;
|
||||||
|
private String errorMessage;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.nanri.aiimage.modules.task.model.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@TableName("biz_file_task")
|
||||||
|
public class FileTaskEntity {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
private String taskNo;
|
||||||
|
private String moduleType;
|
||||||
|
private String taskMode;
|
||||||
|
private String status;
|
||||||
|
private Integer sourceFileCount;
|
||||||
|
private Integer successFileCount;
|
||||||
|
private Integer failedFileCount;
|
||||||
|
private String requestJson;
|
||||||
|
private String resultJson;
|
||||||
|
private String errorMessage;
|
||||||
|
private String createdBy;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
private LocalDateTime finishedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# 这个文件只是给你对照看的“环境变量配置示例”,不是 Spring Boot 自动加载的配置文件。
|
||||||
|
# 你现在最简单的做法:把下面这些键值复制到 IDEA 的 Run/Debug Configuration -> Environment variables。
|
||||||
|
|
||||||
|
AIIMAGE_SERVER_PORT=18080
|
||||||
|
|
||||||
|
AIIMAGE_DB_URL=jdbc:mysql://159.75.121.33:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
|
||||||
|
AIIMAGE_DB_USERNAME=AIimage
|
||||||
|
AIIMAGE_DB_PASSWORD=WTFrb5y6hNLz6hNy
|
||||||
|
|
||||||
|
AIIMAGE_OSS_REGION=cn-hangzhou
|
||||||
|
AIIMAGE_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||||
|
AIIMAGE_OSS_BUCKET=nanri-ai-images
|
||||||
|
AIIMAGE_OSS_ACCESS_KEY_ID=LTAI5tNpyvzMNz9f2dHarsm8
|
||||||
|
AIIMAGE_OSS_ACCESS_KEY_SECRET=bQSZnFH455i8tzyOgeahJmUzwmhynz
|
||||||
|
|
||||||
|
AIIMAGE_STORAGE_LOCAL_TEMP_DIR=F:/project/crawler-plugin/backend-java/data/tmp
|
||||||
47
backend-java/src/main/resources/application.yml
Normal file
47
backend-java/src/main/resources/application.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
server:
|
||||||
|
port: ${AIIMAGE_SERVER_PORT:18080}
|
||||||
|
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: aiimage-backend
|
||||||
|
config:
|
||||||
|
import: optional:classpath:application-local.yml
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 200MB
|
||||||
|
max-request-size: 500MB
|
||||||
|
datasource:
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: ${AIIMAGE_DB_URL:jdbc:mysql://127.0.0.1:3306/aiimage?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false}
|
||||||
|
username: ${AIIMAGE_DB_USERNAME:root}
|
||||||
|
password: ${AIIMAGE_DB_PASSWORD:change-me}
|
||||||
|
|
||||||
|
management:
|
||||||
|
health:
|
||||||
|
db:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
mybatis-plus:
|
||||||
|
mapper-locations: classpath*:mapper/**/*.xml
|
||||||
|
configuration:
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
knife4j:
|
||||||
|
enable: true
|
||||||
|
setting:
|
||||||
|
language: zh_cn
|
||||||
|
|
||||||
|
aiimage:
|
||||||
|
oss:
|
||||||
|
region: ${AIIMAGE_OSS_REGION:cn-hangzhou}
|
||||||
|
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
|
||||||
|
bucket: ${AIIMAGE_OSS_BUCKET:change-me}
|
||||||
|
access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:change-me}
|
||||||
|
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:change-me}
|
||||||
|
storage:
|
||||||
|
local-temp-dir: ${AIIMAGE_STORAGE_LOCAL_TEMP_DIR:./data/tmp}
|
||||||
76
backend-java/src/main/resources/db/V1__init.sql
Normal file
76
backend-java/src/main/resources/db/V1__init.sql
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS biz_file_task (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||||
|
task_no VARCHAR(64) NOT NULL COMMENT '任务编号',
|
||||||
|
module_type VARCHAR(32) NOT NULL COMMENT '模块类型:DEDUPE/CONVERT/SPLIT',
|
||||||
|
task_mode VARCHAR(32) DEFAULT 'IMMEDIATE' COMMENT '任务模式:IMMEDIATE/TASK',
|
||||||
|
status VARCHAR(32) NOT NULL COMMENT '任务状态:PENDING/RUNNING/SUCCESS/FAILED/CANCELLED',
|
||||||
|
source_file_count INT DEFAULT 0 COMMENT '源文件数量',
|
||||||
|
success_file_count INT DEFAULT 0 COMMENT '成功文件数量',
|
||||||
|
failed_file_count INT DEFAULT 0 COMMENT '失败文件数量',
|
||||||
|
request_json JSON NULL COMMENT '请求参数快照',
|
||||||
|
result_json JSON NULL COMMENT '结果快照',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT '错误信息',
|
||||||
|
created_by VARCHAR(64) DEFAULT 'system' COMMENT '创建人',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
finished_at DATETIME NULL COMMENT '完成时间',
|
||||||
|
UNIQUE KEY uk_task_no (task_no),
|
||||||
|
KEY idx_module_status (module_type, status),
|
||||||
|
KEY idx_created_at (created_at)
|
||||||
|
) COMMENT='文件处理任务表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS biz_file_result (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||||
|
task_id BIGINT NOT NULL COMMENT '任务ID',
|
||||||
|
module_type VARCHAR(32) NOT NULL COMMENT '模块类型:DEDUPE/CONVERT/SPLIT',
|
||||||
|
source_filename VARCHAR(255) NULL COMMENT '源文件名',
|
||||||
|
source_file_url VARCHAR(1000) NULL COMMENT '源文件地址',
|
||||||
|
result_filename VARCHAR(255) NULL COMMENT '结果文件名',
|
||||||
|
result_file_url VARCHAR(1000) NULL COMMENT '结果文件地址',
|
||||||
|
result_file_size BIGINT NULL COMMENT '结果文件大小',
|
||||||
|
result_content_type VARCHAR(128) NULL COMMENT '结果文件类型',
|
||||||
|
row_count INT NULL COMMENT '结果行数',
|
||||||
|
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功',
|
||||||
|
error_message VARCHAR(1000) NULL COMMENT '错误信息',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
KEY idx_task_id (task_id),
|
||||||
|
KEY idx_module_type (module_type)
|
||||||
|
) COMMENT='文件处理结果表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS biz_convert_template (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||||
|
template_code VARCHAR(64) NOT NULL COMMENT '模板编码',
|
||||||
|
template_name VARCHAR(128) NOT NULL COMMENT '模板名称',
|
||||||
|
output_filename VARCHAR(255) NOT NULL COMMENT '输出文件名',
|
||||||
|
required_source_columns_json JSON NOT NULL COMMENT '源文件必需列',
|
||||||
|
header_columns_json JSON NOT NULL COMMENT '输出表头列',
|
||||||
|
field_mapping_json JSON NOT NULL COMMENT '源字段映射',
|
||||||
|
defaults_json JSON NULL COMMENT '默认值配置',
|
||||||
|
preamble_lines_json JSON NULL COMMENT '前导说明行',
|
||||||
|
blank_header_rows_after_schema INT DEFAULT 0 COMMENT '表头后空白行数',
|
||||||
|
is_default TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否默认模板',
|
||||||
|
built_in TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否内置模板',
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
UNIQUE KEY uk_template_code (template_code)
|
||||||
|
) COMMENT='格式转换模板表';
|
||||||
|
|
||||||
|
INSERT INTO biz_convert_template (
|
||||||
|
template_code, template_name, output_filename,
|
||||||
|
required_source_columns_json, header_columns_json, field_mapping_json,
|
||||||
|
defaults_json, preamble_lines_json, blank_header_rows_after_schema,
|
||||||
|
is_default, built_in, enabled
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'uk_offer', '英国模板', '英国.txt',
|
||||||
|
JSON_ARRAY('ASIN', '价格'),
|
||||||
|
JSON_ARRAY('sku', 'price', 'quantity', 'product-id', 'product-id-type', 'condition-type', 'condition-note', 'ASIN-hint', 'title', 'product-tax-code', 'operation-type', 'sale-price', 'sale-start-date', 'sale-end-date', 'leadtime-to-ship', 'launch-date', 'is-giftwrap-available', 'is-gift-message-available'),
|
||||||
|
JSON_OBJECT('price', '价格', 'product-id', 'ASIN'),
|
||||||
|
JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '4'),
|
||||||
|
JSON_ARRAY('TemplateType=Offer\tVersion=1.4'),
|
||||||
|
1,
|
||||||
|
1, 1, 1
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM biz_convert_template WHERE template_code = 'uk_offer'
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
UPDATE biz_convert_template
|
||||||
|
SET required_source_columns_json = JSON_ARRAY('ASIN', '价格'),
|
||||||
|
header_columns_json = JSON_ARRAY(
|
||||||
|
'sku', 'price', 'quantity', 'product-id', 'product-id-type', 'condition-type', 'condition-note',
|
||||||
|
'ASIN-hint', 'title', 'product-tax-code', 'operation-type', 'sale-price', 'sale-start-date',
|
||||||
|
'sale-end-date', 'leadtime-to-ship', 'launch-date', 'is-giftwrap-available', 'is-gift-message-available'
|
||||||
|
),
|
||||||
|
field_mapping_json = JSON_OBJECT('price', '价格', 'product-id', 'ASIN'),
|
||||||
|
defaults_json = JSON_OBJECT('quantity', '100', 'product-id-type', 'ASIN', 'condition-type', 'new', 'leadtime-to-ship', '4'),
|
||||||
|
preamble_lines_json = JSON_ARRAY('TemplateType=Offer\tVersion=1.4'),
|
||||||
|
blank_header_rows_after_schema = 1,
|
||||||
|
output_filename = '英国.txt',
|
||||||
|
template_name = '英国模板',
|
||||||
|
built_in = 1,
|
||||||
|
enabled = 1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE template_code = 'uk_offer';
|
||||||
Binary file not shown.
703
desktop/app.py
703
desktop/app.py
@@ -250,8 +250,8 @@ class DesktopConfig:
|
|||||||
|
|
||||||
|
|
||||||
class DesktopApi:
|
class DesktopApi:
|
||||||
def __init__(self, app_base_url: str) -> None:
|
def __init__(self, backend_base_url: str) -> None:
|
||||||
self.app_base_url = app_base_url.rstrip("/")
|
self.app_base_url = backend_base_url.rstrip("/")
|
||||||
self.session = requests.Session()
|
self.session = requests.Session()
|
||||||
|
|
||||||
def select_brand_xlsx_files(self) -> list[str]:
|
def select_brand_xlsx_files(self) -> list[str]:
|
||||||
@@ -347,17 +347,38 @@ class DesktopApi:
|
|||||||
return {"success": False, "error": str(exc)}
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
def list_txt_templates(self) -> list[dict]:
|
def list_txt_templates(self) -> list[dict]:
|
||||||
templates, _default_template_id = build_effective_templates()
|
try:
|
||||||
return [
|
response = self.session.get(
|
||||||
{
|
resolve_url(self.app_base_url, "/api/convert/templates"),
|
||||||
"id": template["id"],
|
timeout=60,
|
||||||
"label": template["label"],
|
)
|
||||||
"output_filename": template["output_filename"],
|
response.raise_for_status()
|
||||||
"is_default": template.get("is_default", False),
|
payload = response.json()
|
||||||
"built_in": template.get("built_in", False),
|
if not payload.get("success"):
|
||||||
}
|
return []
|
||||||
for template in templates.values()
|
items = payload.get("data") or []
|
||||||
]
|
return [
|
||||||
|
{
|
||||||
|
"id": str(item.get("templateCode") or item.get("id") or ""),
|
||||||
|
"label": item.get("templateName") or "",
|
||||||
|
"output_filename": item.get("outputFilename") or "",
|
||||||
|
"is_default": bool(item.get("isDefault")),
|
||||||
|
"built_in": bool(item.get("builtIn")),
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
templates, _default_template_id = build_effective_templates()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": template["id"],
|
||||||
|
"label": template["label"],
|
||||||
|
"output_filename": template["output_filename"],
|
||||||
|
"is_default": template.get("is_default", False),
|
||||||
|
"built_in": template.get("built_in", False),
|
||||||
|
}
|
||||||
|
for template in templates.values()
|
||||||
|
]
|
||||||
|
|
||||||
def import_txt_template(self, name: str | None = None) -> dict:
|
def import_txt_template(self, name: str | None = None) -> dict:
|
||||||
file_path = run_file_dialog(
|
file_path = run_file_dialog(
|
||||||
@@ -370,39 +391,60 @@ class DesktopApi:
|
|||||||
return {"success": False, "error": "用户取消"}
|
return {"success": False, "error": "用户取消"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
template_text = Path(file_path).read_text(encoding="utf-8")
|
try:
|
||||||
except UnicodeDecodeError:
|
template_text = Path(file_path).read_text(encoding="utf-8")
|
||||||
template_text = Path(file_path).read_text(encoding="utf-8-sig")
|
except UnicodeDecodeError:
|
||||||
|
template_text = Path(file_path).read_text(encoding="utf-8-sig")
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
resolve_url(self.app_base_url, "/api/convert/templates/import"),
|
||||||
|
json={
|
||||||
|
"templateName": (name or "").strip() or Path(file_path).stem,
|
||||||
|
"templateContent": template_text,
|
||||||
|
},
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not payload.get("success") or not payload.get("data"):
|
||||||
|
return {"success": False, "error": payload.get("message") or "导入失败"}
|
||||||
|
item = payload.get("data") or {}
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"template": {
|
||||||
|
"id": str(item.get("templateCode") or item.get("id") or ""),
|
||||||
|
"label": item.get("templateName") or "",
|
||||||
|
"output_filename": item.get("outputFilename") or "",
|
||||||
|
"is_default": bool(item.get("isDefault")),
|
||||||
|
"built_in": bool(item.get("builtIn")),
|
||||||
|
},
|
||||||
|
}
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
return {"success": False, "error": str(exc)}
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
template = recognize_txt_template(template_text, (name or "").strip() or None)
|
|
||||||
if not template:
|
|
||||||
return {"success": False, "error": "无法识别模板"}
|
|
||||||
|
|
||||||
user_templates, default_template_id = load_user_templates()
|
|
||||||
user_templates[template["id"]] = template
|
|
||||||
save_user_templates(user_templates, default_template_id)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"template": {
|
|
||||||
"id": template["id"],
|
|
||||||
"label": template["label"],
|
|
||||||
"output_filename": template["output_filename"],
|
|
||||||
"is_default": False,
|
|
||||||
"built_in": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def set_default_txt_template(self, template_id: str) -> dict:
|
def set_default_txt_template(self, template_id: str) -> dict:
|
||||||
templates, _default_template_id = build_effective_templates()
|
try:
|
||||||
if template_id not in templates:
|
response = self.session.post(
|
||||||
return {"success": False, "error": "模板不存在"}
|
resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}/default"),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
user_templates, _ = load_user_templates()
|
def delete_txt_template(self, template_id: str) -> dict:
|
||||||
save_user_templates(user_templates, template_id)
|
try:
|
||||||
return {"success": True}
|
response = self.session.delete(
|
||||||
|
resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}"),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
def split_excel_files(self, payload: dict) -> dict:
|
def split_excel_files(self, payload: dict) -> dict:
|
||||||
paths = payload.get("paths") or []
|
paths = payload.get("paths") or []
|
||||||
@@ -410,7 +452,6 @@ class DesktopApi:
|
|||||||
split_mode = payload.get("split_mode") or "rows_per_file"
|
split_mode = payload.get("split_mode") or "rows_per_file"
|
||||||
rows_per_file = payload.get("rows_per_file")
|
rows_per_file = payload.get("rows_per_file")
|
||||||
parts = payload.get("parts")
|
parts = payload.get("parts")
|
||||||
output_dir = payload.get("output_dir") or ""
|
|
||||||
|
|
||||||
if not isinstance(paths, list) or not paths:
|
if not isinstance(paths, list) or not paths:
|
||||||
return {"success": False, "error": "未选择待拆分文件"}
|
return {"success": False, "error": "未选择待拆分文件"}
|
||||||
@@ -422,128 +463,171 @@ class DesktopApi:
|
|||||||
return {"success": False, "error": "每份条数不合法"}
|
return {"success": False, "error": "每份条数不合法"}
|
||||||
if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0):
|
if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0):
|
||||||
return {"success": False, "error": "拆分份数不合法"}
|
return {"success": False, "error": "拆分份数不合法"}
|
||||||
if not output_dir:
|
|
||||||
return {"success": False, "error": "未选择输出目录"}
|
|
||||||
|
|
||||||
output_dir_path = Path(output_dir)
|
try:
|
||||||
if not output_dir_path.exists() or not output_dir_path.is_dir():
|
uploaded_files = []
|
||||||
return {"success": False, "error": "输出目录不存在"}
|
for path in paths:
|
||||||
|
with open(path, "rb") as file_obj:
|
||||||
items: list[dict] = []
|
response = self.session.post(
|
||||||
success_count = 0
|
resolve_url(self.app_base_url, "/api/files/upload"),
|
||||||
failed_count = 0
|
files={"file": (Path(path).name, file_obj)},
|
||||||
|
timeout=180,
|
||||||
for path in paths:
|
)
|
||||||
try:
|
response.raise_for_status()
|
||||||
workbook = load_workbook(path)
|
upload_payload = response.json()
|
||||||
sheet = workbook[workbook.sheetnames[0]]
|
if not upload_payload.get("success") or not upload_payload.get("data"):
|
||||||
header_cells = next(sheet.iter_rows(min_row=1, max_row=1))
|
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
|
||||||
header_map: dict[str, int] = {}
|
upload_data = upload_payload.get("data") or {}
|
||||||
for index, cell in enumerate(header_cells):
|
uploaded_files.append({
|
||||||
value = normalize_cell_text(cell.value)
|
"fileKey": upload_data.get("fileKey"),
|
||||||
if not value:
|
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
|
||||||
continue
|
|
||||||
value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value
|
|
||||||
if value in header_map:
|
|
||||||
continue
|
|
||||||
header_map[value] = index
|
|
||||||
if value == "缩略图地址8":
|
|
||||||
break
|
|
||||||
|
|
||||||
missing = [column for column in selected_columns if column not in header_map]
|
|
||||||
if missing:
|
|
||||||
workbook.close()
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"success": False,
|
|
||||||
"error": f"缺少列:{'、'.join(missing)}",
|
|
||||||
})
|
|
||||||
failed_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
rows_data: list[list[str]] = []
|
|
||||||
for row in sheet.iter_rows(min_row=2, values_only=True):
|
|
||||||
projected = [
|
|
||||||
normalize_cell_text(row[header_map[column]]) if header_map[column] < len(row) else ""
|
|
||||||
for column in selected_columns
|
|
||||||
]
|
|
||||||
rows_data.append(projected)
|
|
||||||
|
|
||||||
total_rows = len(rows_data)
|
|
||||||
if total_rows == 0:
|
|
||||||
workbook.close()
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"success": False,
|
|
||||||
"error": "没有可拆分的数据行",
|
|
||||||
})
|
|
||||||
failed_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if split_mode == "rows_per_file":
|
|
||||||
chunk_size = rows_per_file
|
|
||||||
chunks = [rows_data[i : i + chunk_size] for i in range(0, total_rows, chunk_size)]
|
|
||||||
else:
|
|
||||||
actual_parts = min(parts, total_rows)
|
|
||||||
base = total_rows // actual_parts
|
|
||||||
remainder = total_rows % actual_parts
|
|
||||||
chunks = []
|
|
||||||
start = 0
|
|
||||||
for idx in range(actual_parts):
|
|
||||||
size = base + (1 if idx < remainder else 0)
|
|
||||||
end = start + size
|
|
||||||
chunks.append(rows_data[start:end])
|
|
||||||
start = end
|
|
||||||
|
|
||||||
from openpyxl import Workbook
|
|
||||||
|
|
||||||
source = Path(path)
|
|
||||||
for index, chunk in enumerate(chunks, start=1):
|
|
||||||
output_workbook = Workbook()
|
|
||||||
output_sheet = output_workbook.active
|
|
||||||
output_sheet.title = sheet.title
|
|
||||||
output_sheet.append(selected_columns)
|
|
||||||
for row_values in chunk:
|
|
||||||
output_sheet.append(row_values)
|
|
||||||
|
|
||||||
output_path = build_named_output_path(output_dir, f"{source.stem}_split_{index}.xlsx")
|
|
||||||
output_workbook.save(output_path)
|
|
||||||
output_workbook.close()
|
|
||||||
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"output_path": output_path,
|
|
||||||
"success": True,
|
|
||||||
"row_count": len(chunk),
|
|
||||||
})
|
|
||||||
|
|
||||||
workbook.close()
|
|
||||||
success_count += 1
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"success": False,
|
|
||||||
"error": str(exc),
|
|
||||||
})
|
})
|
||||||
failed_count += 1
|
|
||||||
|
|
||||||
return {
|
run_response = self.session.post(
|
||||||
"success": True,
|
resolve_url(self.app_base_url, "/api/split/run"),
|
||||||
"summary": {
|
json={
|
||||||
"total": len(paths),
|
"files": uploaded_files,
|
||||||
"success_count": success_count,
|
"selectedColumns": selected_columns,
|
||||||
"failed_count": failed_count,
|
"splitMode": split_mode,
|
||||||
"output_dir": str(output_dir_path),
|
"rowsPerFile": rows_per_file,
|
||||||
},
|
"parts": parts,
|
||||||
"items": items,
|
},
|
||||||
}
|
timeout=600,
|
||||||
|
)
|
||||||
|
run_response.raise_for_status()
|
||||||
|
run_payload = run_response.json()
|
||||||
|
if not run_payload.get("success") or not run_payload.get("data"):
|
||||||
|
return {"success": False, "error": run_payload.get("message") or "数据拆分失败"}
|
||||||
|
|
||||||
|
data = run_payload.get("data") or {}
|
||||||
|
result_items = []
|
||||||
|
for item in data.get("items") or []:
|
||||||
|
if item.get("success") and item.get("downloadUrl"):
|
||||||
|
result_items.append({
|
||||||
|
"result_id": item.get("resultId"),
|
||||||
|
"source_path": item.get("sourceFilename") or "",
|
||||||
|
"output_path": item.get("downloadUrl") or "",
|
||||||
|
"success": True,
|
||||||
|
"row_count": item.get("rowCount"),
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result_items.append({
|
||||||
|
"source_path": item.get("sourceFilename") or "",
|
||||||
|
"success": False,
|
||||||
|
"error": item.get("error") or "数据拆分失败",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"summary": {
|
||||||
|
"total": data.get("total") or len(paths),
|
||||||
|
"success_count": data.get("successCount") or 0,
|
||||||
|
"failed_count": data.get("failedCount") or 0,
|
||||||
|
},
|
||||||
|
"items": result_items,
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
def delete_history_item(self, module_type: str, result_id: int) -> dict:
|
||||||
|
try:
|
||||||
|
response = self.session.delete(
|
||||||
|
resolve_url(self.app_base_url, f"/api/{module_type}/history/{result_id}"),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
def get_convert_history(self) -> dict:
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
resolve_url(self.app_base_url, "/api/convert/history"),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not payload.get("success") or not payload.get("data"):
|
||||||
|
return {"success": False, "error": payload.get("message") or "获取历史失败"}
|
||||||
|
|
||||||
|
items = payload.get("data", {}).get("items") or []
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"result_id": item.get("resultId"),
|
||||||
|
"source_path": item.get("sourceFilename") or "",
|
||||||
|
"output_path": item.get("downloadUrl") or "",
|
||||||
|
"success": bool(item.get("success", True)),
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
def get_split_history(self) -> dict:
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
resolve_url(self.app_base_url, "/api/split/history"),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not payload.get("success") or not payload.get("data"):
|
||||||
|
return {"success": False, "error": payload.get("message") or "获取历史失败"}
|
||||||
|
|
||||||
|
items = payload.get("data", {}).get("items") or []
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"result_id": item.get("resultId"),
|
||||||
|
"source_path": item.get("sourceFilename") or "",
|
||||||
|
"output_path": item.get("downloadUrl") or "",
|
||||||
|
"success": bool(item.get("success", True)),
|
||||||
|
"row_count": item.get("rowCount"),
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
def get_dedupe_history(self) -> dict:
|
||||||
|
try:
|
||||||
|
response = self.session.get(
|
||||||
|
resolve_url(self.app_base_url, "/api/dedupe/history"),
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
if not payload.get("success") or not payload.get("data"):
|
||||||
|
return {"success": False, "error": payload.get("message") or "获取历史失败"}
|
||||||
|
|
||||||
|
items = payload.get("data", {}).get("items") or []
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"result_id": item.get("resultId"),
|
||||||
|
"source_path": item.get("sourceFilename") or "",
|
||||||
|
"output_path": item.get("downloadUrl") or "",
|
||||||
|
"success": bool(item.get("success", True)),
|
||||||
|
}
|
||||||
|
for item in items
|
||||||
|
],
|
||||||
|
}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"success": False, "error": str(exc)}
|
||||||
|
|
||||||
def clean_excel_files(self, payload: dict) -> dict:
|
def clean_excel_files(self, payload: dict) -> dict:
|
||||||
paths = payload.get("paths") or []
|
paths = payload.get("paths") or []
|
||||||
selected_columns = payload.get("selected_columns") or []
|
selected_columns = payload.get("selected_columns") or []
|
||||||
keep_integer_ids = bool(payload.get("keep_integer_ids", True))
|
keep_integer_ids = bool(payload.get("keep_integer_ids", True))
|
||||||
keep_underscore_ids = bool(payload.get("keep_underscore_ids", True))
|
keep_underscore_ids = bool(payload.get("keep_underscore_ids", True))
|
||||||
output_dir = payload.get("output_dir") or ""
|
|
||||||
|
|
||||||
if not isinstance(paths, list) or not paths:
|
if not isinstance(paths, list) or not paths:
|
||||||
return {"success": False, "error": "未选择待处理文件"}
|
return {"success": False, "error": "未选择待处理文件"}
|
||||||
@@ -551,213 +635,140 @@ class DesktopApi:
|
|||||||
return {"success": False, "error": "请至少选择一列"}
|
return {"success": False, "error": "请至少选择一列"}
|
||||||
if not keep_integer_ids and not keep_underscore_ids:
|
if not keep_integer_ids and not keep_underscore_ids:
|
||||||
return {"success": False, "error": "请至少选择一种 ID 保留规则"}
|
return {"success": False, "error": "请至少选择一种 ID 保留规则"}
|
||||||
if not output_dir:
|
|
||||||
return {"success": False, "error": "未选择输出目录"}
|
|
||||||
|
|
||||||
output_dir_path = Path(output_dir)
|
try:
|
||||||
if not output_dir_path.exists() or not output_dir_path.is_dir():
|
uploaded_files = []
|
||||||
return {"success": False, "error": "输出目录不存在"}
|
for path in paths:
|
||||||
|
with open(path, "rb") as file_obj:
|
||||||
|
response = self.session.post(
|
||||||
|
resolve_url(self.app_base_url, "/api/files/upload"),
|
||||||
|
files={"file": (Path(path).name, file_obj)},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
upload_payload = response.json()
|
||||||
|
if not upload_payload.get("success") or not upload_payload.get("data"):
|
||||||
|
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
|
||||||
|
upload_data = upload_payload.get("data") or {}
|
||||||
|
uploaded_files.append({
|
||||||
|
"fileKey": upload_data.get("fileKey"),
|
||||||
|
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
|
||||||
|
})
|
||||||
|
|
||||||
items: list[dict] = []
|
run_response = self.session.post(
|
||||||
success_count = 0
|
resolve_url(self.app_base_url, "/api/dedupe/run"),
|
||||||
failed_count = 0
|
json={
|
||||||
|
"files": uploaded_files,
|
||||||
|
"selectedColumns": selected_columns,
|
||||||
|
"keepIntegerIds": keep_integer_ids,
|
||||||
|
"keepUnderscoreIds": keep_underscore_ids,
|
||||||
|
},
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
run_response.raise_for_status()
|
||||||
|
run_payload = run_response.json()
|
||||||
|
if not run_payload.get("success") or not run_payload.get("data"):
|
||||||
|
return {"success": False, "error": run_payload.get("message") or "去重失败"}
|
||||||
|
|
||||||
for path in paths:
|
data = run_payload.get("data") or {}
|
||||||
try:
|
result_items = []
|
||||||
workbook = load_workbook(path)
|
for item in data.get("items") or []:
|
||||||
sheet = workbook[workbook.sheetnames[0]]
|
if item.get("success") and item.get("downloadUrl"):
|
||||||
header_cells = next(sheet.iter_rows(min_row=1, max_row=1))
|
result_items.append({
|
||||||
headers: list[str] = []
|
"source_path": item.get("sourceFilename") or "",
|
||||||
header_map: dict[str, int] = {}
|
"output_path": item.get("downloadUrl") or "",
|
||||||
for index, cell in enumerate(header_cells):
|
"success": True,
|
||||||
value = normalize_cell_text(cell.value)
|
})
|
||||||
if not value:
|
else:
|
||||||
continue
|
result_items.append({
|
||||||
value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value
|
"source_path": item.get("sourceFilename") or "",
|
||||||
if value in header_map:
|
"success": False,
|
||||||
continue
|
"error": item.get("error") or "去重失败",
|
||||||
header_map[value] = index
|
|
||||||
headers.append(value)
|
|
||||||
if value == "缩略图地址8":
|
|
||||||
break
|
|
||||||
|
|
||||||
missing = [column for column in selected_columns if column not in header_map]
|
|
||||||
if missing:
|
|
||||||
workbook.close()
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"success": False,
|
|
||||||
"error": f"缺少列:{'、'.join(missing)}",
|
|
||||||
})
|
})
|
||||||
failed_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
from openpyxl import Workbook
|
return {
|
||||||
|
"success": True,
|
||||||
output_workbook = Workbook()
|
"summary": {
|
||||||
output_sheet = output_workbook.active
|
"total": data.get("total") or len(paths),
|
||||||
output_sheet.title = sheet.title
|
"success_count": data.get("successCount") or 0,
|
||||||
output_sheet.append(selected_columns)
|
"failed_count": data.get("failedCount") or 0,
|
||||||
|
},
|
||||||
id_column_index = header_map.get("id")
|
"items": result_items,
|
||||||
|
}
|
||||||
for row in sheet.iter_rows(min_row=2, values_only=True):
|
except Exception as exc: # noqa: BLE001
|
||||||
original_values = list(row)
|
return {"success": False, "error": str(exc)}
|
||||||
if id_column_index is not None:
|
|
||||||
id_value = original_values[id_column_index] if id_column_index < len(original_values) else None
|
|
||||||
if not should_keep_id(id_value, keep_integer_ids, keep_underscore_ids):
|
|
||||||
continue
|
|
||||||
|
|
||||||
cleaned_values = [
|
|
||||||
normalize_cell_text(original_values[header_map[column]])
|
|
||||||
if header_map[column] < len(original_values)
|
|
||||||
else ""
|
|
||||||
for column in selected_columns
|
|
||||||
]
|
|
||||||
output_sheet.append(cleaned_values)
|
|
||||||
|
|
||||||
output_path = build_output_path(output_dir, path)
|
|
||||||
output_workbook.save(output_path)
|
|
||||||
output_workbook.close()
|
|
||||||
workbook.close()
|
|
||||||
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"output_path": output_path,
|
|
||||||
"success": True,
|
|
||||||
})
|
|
||||||
success_count += 1
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"success": False,
|
|
||||||
"error": str(exc),
|
|
||||||
})
|
|
||||||
failed_count += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"summary": {
|
|
||||||
"total": len(paths),
|
|
||||||
"success_count": success_count,
|
|
||||||
"failed_count": failed_count,
|
|
||||||
"output_dir": str(output_dir_path),
|
|
||||||
},
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
|
|
||||||
def convert_excel_to_uk_txt(self, payload: dict) -> dict:
|
def convert_excel_to_uk_txt(self, payload: dict) -> dict:
|
||||||
paths = payload.get("paths") or []
|
paths = payload.get("paths") or []
|
||||||
output_dir = payload.get("output_dir") or ""
|
|
||||||
template_id = payload.get("template_id") or "uk_offer"
|
template_id = payload.get("template_id") or "uk_offer"
|
||||||
|
|
||||||
if not isinstance(paths, list) or not paths:
|
if not isinstance(paths, list) or not paths:
|
||||||
return {"success": False, "error": "未选择待转换文件"}
|
return {"success": False, "error": "未选择待转换文件"}
|
||||||
if not output_dir:
|
|
||||||
return {"success": False, "error": "未选择输出目录"}
|
|
||||||
|
|
||||||
templates, _default_template_id = build_effective_templates()
|
try:
|
||||||
template = templates.get(template_id)
|
templates = self.list_txt_templates()
|
||||||
if not template:
|
selected_template = next((item for item in templates if item.get("id") == template_id), None)
|
||||||
return {"success": False, "error": "模板不存在"}
|
if not selected_template:
|
||||||
|
return {"success": False, "error": "模板不存在"}
|
||||||
|
|
||||||
output_dir_path = Path(output_dir)
|
uploaded_files = []
|
||||||
if not output_dir_path.exists() or not output_dir_path.is_dir():
|
for path in paths:
|
||||||
return {"success": False, "error": "输出目录不存在"}
|
with open(path, "rb") as file_obj:
|
||||||
|
response = self.session.post(
|
||||||
|
resolve_url(self.app_base_url, "/api/files/upload"),
|
||||||
|
files={"file": (Path(path).name, file_obj)},
|
||||||
|
timeout=180,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
upload_payload = response.json()
|
||||||
|
if not upload_payload.get("success") or not upload_payload.get("data"):
|
||||||
|
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
|
||||||
|
upload_data = upload_payload.get("data") or {}
|
||||||
|
uploaded_files.append({
|
||||||
|
"fileKey": upload_data.get("fileKey"),
|
||||||
|
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
|
||||||
|
})
|
||||||
|
|
||||||
items: list[dict] = []
|
run_response = self.session.post(
|
||||||
success_count = 0
|
resolve_url(self.app_base_url, "/api/convert/run"),
|
||||||
failed_count = 0
|
json={
|
||||||
|
"files": uploaded_files,
|
||||||
|
"templateId": str(template_id),
|
||||||
|
},
|
||||||
|
timeout=600,
|
||||||
|
)
|
||||||
|
run_response.raise_for_status()
|
||||||
|
run_payload = run_response.json()
|
||||||
|
if not run_payload.get("success") or not run_payload.get("data"):
|
||||||
|
return {"success": False, "error": run_payload.get("message") or "格式转换失败"}
|
||||||
|
|
||||||
for path in paths:
|
data = run_payload.get("data") or {}
|
||||||
try:
|
result_items = []
|
||||||
workbook = load_workbook(path, read_only=True, data_only=True)
|
for item in data.get("items") or []:
|
||||||
sheet = workbook[workbook.sheetnames[0]]
|
if item.get("success") and item.get("downloadUrl"):
|
||||||
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
|
result_items.append({
|
||||||
if not header_row:
|
"source_path": item.get("sourceFilename") or "",
|
||||||
workbook.close()
|
"output_path": item.get("downloadUrl") or "",
|
||||||
items.append({"source_path": path, "success": False, "error": 'Excel 表头为空'})
|
"success": True,
|
||||||
failed_count += 1
|
})
|
||||||
continue
|
else:
|
||||||
|
result_items.append({
|
||||||
header_map: dict[str, int] = {}
|
"source_path": item.get("sourceFilename") or "",
|
||||||
for index, cell in enumerate(header_row):
|
"success": False,
|
||||||
value = normalize_cell_text(cell)
|
"error": item.get("error") or "格式转换失败",
|
||||||
if not value or value in header_map:
|
|
||||||
continue
|
|
||||||
header_map[value] = index
|
|
||||||
|
|
||||||
required = template["required_source_columns"]
|
|
||||||
missing = [column for column in required if column not in header_map]
|
|
||||||
if missing:
|
|
||||||
workbook.close()
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"success": False,
|
|
||||||
"error": f"缺少列:{'、'.join(missing)}",
|
|
||||||
})
|
})
|
||||||
failed_count += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
rows: list[str] = []
|
return {
|
||||||
rows.extend(template["preamble_lines"])
|
"success": True,
|
||||||
rows.append('\t'.join(template["header_columns"]))
|
"summary": {
|
||||||
for _ in range(template.get("blank_header_rows_after_schema", 0)):
|
"total": data.get("total") or len(paths),
|
||||||
rows.append('\t' * (len(template["header_columns"]) - 1))
|
"success_count": data.get("successCount") or 0,
|
||||||
|
"failed_count": data.get("failedCount") or 0,
|
||||||
batch_id = generate_snowflake_like_id()
|
},
|
||||||
row_index = 1
|
"items": result_items,
|
||||||
for row in sheet.iter_rows(min_row=2, values_only=True):
|
}
|
||||||
line_values: list[str] = []
|
except Exception as exc: # noqa: BLE001
|
||||||
asin = normalize_cell_text(row[header_map['ASIN']] if header_map['ASIN'] < len(row) else '')
|
return {"success": False, "error": str(exc)}
|
||||||
if not asin:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for column in template["header_columns"]:
|
|
||||||
if column == 'sku':
|
|
||||||
line_values.append(f'{batch_id}-{row_index}')
|
|
||||||
continue
|
|
||||||
if column in template["field_mapping"]:
|
|
||||||
source_column = template["field_mapping"][column]
|
|
||||||
value = normalize_cell_text(row[header_map[source_column]] if header_map[source_column] < len(row) else '')
|
|
||||||
line_values.append(value)
|
|
||||||
continue
|
|
||||||
if column in template["defaults"]:
|
|
||||||
line_values.append(str(template["defaults"][column]))
|
|
||||||
continue
|
|
||||||
line_values.append('')
|
|
||||||
|
|
||||||
rows.append('\t'.join(line_values))
|
|
||||||
row_index += 1
|
|
||||||
|
|
||||||
output_path = build_named_output_path(output_dir, template["output_filename"])
|
|
||||||
Path(output_path).write_text('\n'.join(rows), encoding='utf-8')
|
|
||||||
workbook.close()
|
|
||||||
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"output_path": output_path,
|
|
||||||
"success": True,
|
|
||||||
})
|
|
||||||
success_count += 1
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
items.append({
|
|
||||||
"source_path": path,
|
|
||||||
"success": False,
|
|
||||||
"error": str(exc),
|
|
||||||
})
|
|
||||||
failed_count += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"summary": {
|
|
||||||
"total": len(paths),
|
|
||||||
"success_count": success_count,
|
|
||||||
"failed_count": failed_count,
|
|
||||||
"output_dir": str(output_dir_path),
|
|
||||||
},
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
|
|
||||||
def open_path(self, path: str) -> dict:
|
def open_path(self, path: str) -> dict:
|
||||||
try:
|
try:
|
||||||
@@ -939,7 +950,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
parser.add_argument("--port", type=int, default=0)
|
parser.add_argument("--port", type=int, default=0)
|
||||||
parser.add_argument("--width", type=int, default=1440)
|
parser.add_argument("--width", type=int, default=1440)
|
||||||
parser.add_argument("--height", type=int, default=960)
|
parser.add_argument("--height", type=int, default=960)
|
||||||
parser.add_argument("--title", default="南日AI")
|
parser.add_argument("--title", default="数富AI")
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
@@ -985,7 +996,7 @@ def main() -> None:
|
|||||||
ensure_dev_server_ready(config.frontend_url)
|
ensure_dev_server_ready(config.frontend_url)
|
||||||
app_url = config.frontend_url
|
app_url = config.frontend_url
|
||||||
|
|
||||||
api = DesktopApi(app_url)
|
api = DesktopApi(config.backend_url)
|
||||||
window = webview.create_window(
|
window = webview.create_window(
|
||||||
title=config.title,
|
title=config.title,
|
||||||
url=app_url,
|
url=app_url,
|
||||||
|
|||||||
@@ -64,4 +64,4 @@ Write-Host "Ensuring desktop dependencies..."
|
|||||||
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
|
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
|
||||||
|
|
||||||
Write-Host "Starting desktop app..."
|
Write-Host "Starting desktop app..."
|
||||||
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:8000")
|
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:18080")
|
||||||
|
|||||||
@@ -90,6 +90,51 @@
|
|||||||
"price": "价格",
|
"price": "价格",
|
||||||
"product-id": "ASIN"
|
"product-id": "ASIN"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"user_1773982097249": {
|
||||||
|
"id": "user_1773982097249",
|
||||||
|
"label": "默认",
|
||||||
|
"output_filename": "默认.txt",
|
||||||
|
"is_default": false,
|
||||||
|
"built_in": false,
|
||||||
|
"preamble_lines": [
|
||||||
|
"TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t"
|
||||||
|
],
|
||||||
|
"header_columns": [
|
||||||
|
"sku",
|
||||||
|
"price",
|
||||||
|
"quantity",
|
||||||
|
"product-id",
|
||||||
|
"product-id-type",
|
||||||
|
"condition-type",
|
||||||
|
"condition-note",
|
||||||
|
"ASIN-hint",
|
||||||
|
"title",
|
||||||
|
"product-tax-code",
|
||||||
|
"operation-type",
|
||||||
|
"sale-price",
|
||||||
|
"sale-start-date",
|
||||||
|
"sale-end-date",
|
||||||
|
"leadtime-to-ship",
|
||||||
|
"launch-date",
|
||||||
|
"is-giftwrap-available",
|
||||||
|
"is-gift-message-available"
|
||||||
|
],
|
||||||
|
"blank_header_rows_after_schema": 1,
|
||||||
|
"required_source_columns": [
|
||||||
|
"ASIN",
|
||||||
|
"价格"
|
||||||
|
],
|
||||||
|
"defaults": {
|
||||||
|
"quantity": "100",
|
||||||
|
"product-id-type": "ASIN",
|
||||||
|
"condition-type": "new",
|
||||||
|
"leadtime-to-ship": "4"
|
||||||
|
},
|
||||||
|
"field_mapping": {
|
||||||
|
"price": "价格",
|
||||||
|
"product-id": "ASIN"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
12
frontend-vue/convert.html
Normal file
12
frontend-vue/convert.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>格式转换 - 数富AI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/convert-main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
12
frontend-vue/dedupe.html
Normal file
12
frontend-vue/dedupe.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>数据去重 - 数富AI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/dedupe-main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>南日AI</title>
|
<title>数富AI</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
12
frontend-vue/split.html
Normal file
12
frontend-vue/split.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>数据拆分 - 数富AI</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/split-main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<router-view />
|
|
||||||
</template>
|
|
||||||
7
frontend-vue/src/convert-main.ts
Normal file
7
frontend-vue/src/convert-main.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import '@/styles/main.css'
|
||||||
|
import BrandConvertTab from '@/pages/brand/components/BrandConvertTab.vue'
|
||||||
|
|
||||||
|
createApp(BrandConvertTab).use(ElementPlus).mount('#app')
|
||||||
7
frontend-vue/src/dedupe-main.ts
Normal file
7
frontend-vue/src/dedupe-main.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import '@/styles/main.css'
|
||||||
|
import BrandDedupeTab from '@/pages/brand/components/BrandDedupeTab.vue'
|
||||||
|
|
||||||
|
createApp(BrandDedupeTab).use(ElementPlus).mount('#app')
|
||||||
@@ -1,584 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PageShell theme="light">
|
|
||||||
<div class="admin-page">
|
|
||||||
<div class="admin-nav">
|
|
||||||
<router-link to="/home">← 返回首页</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 class="page-title">管理后台</h1>
|
|
||||||
|
|
||||||
<el-tabs v-model="activeTab" class="admin-tabs" @tab-change="handleTabChange">
|
|
||||||
<el-tab-pane label="用户管理" name="users">
|
|
||||||
<div class="admin-grid">
|
|
||||||
<el-card class="form-card">
|
|
||||||
<template #header>创建用户</template>
|
|
||||||
|
|
||||||
<p class="card-tip">无注册入口,仅管理员可在此创建用户。层级:超级管理员 → 管理员 → 普通号。</p>
|
|
||||||
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="用户名">
|
|
||||||
<el-input v-model="createForm.username" placeholder="用户名(至少2个字符)" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="密码">
|
|
||||||
<el-input
|
|
||||||
v-model="createForm.password"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="密码(至少6个字符)"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="showRoleSelect" label="角色">
|
|
||||||
<el-select v-model="createForm.role">
|
|
||||||
<el-option label="普通号" value="normal" />
|
|
||||||
<el-option label="管理员" value="admin" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="showCreatedBySelect" label="所属管理员">
|
|
||||||
<el-select v-model="createForm.createdById" placeholder="请选择管理员">
|
|
||||||
<el-option
|
|
||||||
v-for="admin in adminsList"
|
|
||||||
:key="admin.id"
|
|
||||||
:label="admin.username"
|
|
||||||
:value="admin.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-button type="primary" :loading="createLoading" @click="handleCreateUser">创建用户</el-button>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card class="table-card">
|
|
||||||
<template #header>用户列表</template>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<el-input
|
|
||||||
v-model="searchUsername"
|
|
||||||
placeholder="输入用户名关键字"
|
|
||||||
clearable
|
|
||||||
@keyup.enter="loadUsers(1)"
|
|
||||||
/>
|
|
||||||
<el-select
|
|
||||||
v-if="isSuperAdmin"
|
|
||||||
v-model="filterCreatedBy"
|
|
||||||
placeholder="所属管理员"
|
|
||||||
clearable
|
|
||||||
>
|
|
||||||
<el-option
|
|
||||||
v-for="admin in adminsList"
|
|
||||||
:key="admin.id"
|
|
||||||
:label="admin.username"
|
|
||||||
:value="String(admin.id)"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-button type="primary" @click="loadUsers(1)">查询</el-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<el-table :data="users" border>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
|
||||||
<el-table-column prop="username" label="用户名" min-width="140" />
|
|
||||||
<el-table-column label="角色" width="120">
|
|
||||||
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="creator_username" label="所属管理员" min-width="140">
|
|
||||||
<template #default="{ row }">{{ row.creator_username || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
|
||||||
<el-table-column label="操作" width="180" fixed="right">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="row-actions">
|
|
||||||
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
|
|
||||||
<el-button size="small" type="danger" plain @click="removeUser(row)">删除</el-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-pagination
|
|
||||||
class="pagination"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
:current-page="userPage"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:total="userTotal"
|
|
||||||
@current-change="loadUsers"
|
|
||||||
/>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane label="查看生成记录" name="history">
|
|
||||||
<el-card class="table-card">
|
|
||||||
<template #header>筛选条件</template>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<el-select v-model="filterUserId" placeholder="全部用户" clearable>
|
|
||||||
<el-option
|
|
||||||
v-for="option in userOptions"
|
|
||||||
:key="option.id"
|
|
||||||
:label="`${option.username} (${roleLabel(option.role)})`"
|
|
||||||
:value="String(option.id)"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
<el-date-picker
|
|
||||||
v-model="filterTimeStart"
|
|
||||||
type="datetime"
|
|
||||||
placeholder="开始时间"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
/>
|
|
||||||
<el-date-picker
|
|
||||||
v-model="filterTimeEnd"
|
|
||||||
type="datetime"
|
|
||||||
placeholder="结束时间"
|
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
|
||||||
/>
|
|
||||||
<el-button type="primary" @click="loadHistory(1)">查询</el-button>
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card class="table-card history-card">
|
|
||||||
<template #header>生成记录</template>
|
|
||||||
|
|
||||||
<el-table :data="historyRows" border>
|
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
|
||||||
<el-table-column prop="username" label="用户" min-width="120">
|
|
||||||
<template #default="{ row }">{{ row.username || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="panel_type" label="类型" min-width="140">
|
|
||||||
<template #default="{ row }">{{ row.panel_type || '-' }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="created_at" label="创建时间" min-width="180" />
|
|
||||||
<el-table-column label="结果预览" min-width="220">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div v-if="getHistoryPreviewUrls(row).length" class="history-preview">
|
|
||||||
<el-image
|
|
||||||
v-for="(url, index) in getHistoryPreviewUrls(row)"
|
|
||||||
:key="`${row.id}-${index}`"
|
|
||||||
:src="url"
|
|
||||||
fit="cover"
|
|
||||||
:preview-src-list="getHistoryPreviewUrls(row)"
|
|
||||||
class="preview-image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
|
|
||||||
<el-pagination
|
|
||||||
class="pagination"
|
|
||||||
layout="total, prev, pager, next"
|
|
||||||
:current-page="historyPage"
|
|
||||||
:page-size="pageSize"
|
|
||||||
:total="historyTotal"
|
|
||||||
@current-change="loadHistory"
|
|
||||||
/>
|
|
||||||
</el-card>
|
|
||||||
</el-tab-pane>
|
|
||||||
</el-tabs>
|
|
||||||
|
|
||||||
<el-dialog v-model="editVisible" title="编辑用户" width="420px">
|
|
||||||
<el-form label-position="top">
|
|
||||||
<el-form-item label="用户名">
|
|
||||||
<el-input :model-value="editForm.username" readonly />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="新密码(不修改留空)">
|
|
||||||
<el-input
|
|
||||||
v-model="editForm.password"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="留空则不修改密码"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="showEditRoleSelect" label="角色">
|
|
||||||
<el-select v-model="editForm.role">
|
|
||||||
<el-option label="普通号" value="normal" />
|
|
||||||
<el-option label="管理员" value="admin" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item v-if="editForm.creatorUsername" label="所属管理员">
|
|
||||||
<el-input :model-value="editForm.creatorUsername" readonly />
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="editVisible = false">取消</el-button>
|
|
||||||
<el-button type="primary" :loading="editLoading" @click="saveUser">保存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
import {
|
|
||||||
createAdminUser,
|
|
||||||
deleteAdminUser,
|
|
||||||
getAdminHistory,
|
|
||||||
getAdminUsers,
|
|
||||||
updateAdminUser,
|
|
||||||
type AdminHistoryItem,
|
|
||||||
type AdminUserItem,
|
|
||||||
} from '@/shared/api/admin'
|
|
||||||
|
|
||||||
interface UserOption extends AdminUserItem {}
|
|
||||||
|
|
||||||
const activeTab = ref<'users' | 'history'>('users')
|
|
||||||
const pageSize = 15
|
|
||||||
|
|
||||||
const currentUserRole = ref<'super_admin' | 'admin' | 'normal' | string>('admin')
|
|
||||||
const adminsList = ref<{ id: number; username: string }[]>([])
|
|
||||||
const users = ref<AdminUserItem[]>([])
|
|
||||||
const userTotal = ref(0)
|
|
||||||
const userPage = ref(1)
|
|
||||||
const searchUsername = ref('')
|
|
||||||
const filterCreatedBy = ref('')
|
|
||||||
const createLoading = ref(false)
|
|
||||||
|
|
||||||
const createForm = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
role: 'normal',
|
|
||||||
createdById: undefined as number | undefined,
|
|
||||||
})
|
|
||||||
|
|
||||||
const userOptions = ref<UserOption[]>([])
|
|
||||||
const filterUserId = ref('')
|
|
||||||
const filterTimeStart = ref('')
|
|
||||||
const filterTimeEnd = ref('')
|
|
||||||
const historyRows = ref<AdminHistoryItem[]>([])
|
|
||||||
const historyTotal = ref(0)
|
|
||||||
const historyPage = ref(1)
|
|
||||||
|
|
||||||
const editVisible = ref(false)
|
|
||||||
const editLoading = ref(false)
|
|
||||||
const editForm = reactive({
|
|
||||||
id: 0,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
role: 'normal',
|
|
||||||
creatorUsername: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const isSuperAdmin = computed(() => currentUserRole.value === 'super_admin')
|
|
||||||
const showRoleSelect = computed(() => isSuperAdmin.value)
|
|
||||||
const showCreatedBySelect = computed(
|
|
||||||
() => isSuperAdmin.value && createForm.role === 'normal' && adminsList.value.length > 0,
|
|
||||||
)
|
|
||||||
const showEditRoleSelect = computed(() => isSuperAdmin.value)
|
|
||||||
|
|
||||||
function roleLabel(role?: string) {
|
|
||||||
if (role === 'super_admin') return '超级管理员'
|
|
||||||
if (role === 'admin') return '管理员'
|
|
||||||
return '普通号'
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUsersQuery(page: number) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(page),
|
|
||||||
page_size: String(pageSize),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (searchUsername.value.trim()) {
|
|
||||||
params.set('username', searchUsername.value.trim())
|
|
||||||
}
|
|
||||||
if (filterCreatedBy.value) {
|
|
||||||
params.set('created_by_id', filterCreatedBy.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return params.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers(page = 1) {
|
|
||||||
userPage.value = page
|
|
||||||
try {
|
|
||||||
const result = await getAdminUsers(buildUsersQuery(page))
|
|
||||||
if (!result.success) {
|
|
||||||
ElMessage.error(result.error || '加载用户失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUserRole.value = result.current_user_role || 'admin'
|
|
||||||
adminsList.value = result.admins || []
|
|
||||||
users.value = result.items || []
|
|
||||||
userTotal.value = result.total || 0
|
|
||||||
|
|
||||||
if (!isSuperAdmin.value) {
|
|
||||||
createForm.role = 'normal'
|
|
||||||
createForm.createdById = undefined
|
|
||||||
filterCreatedBy.value = ''
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUserOptions() {
|
|
||||||
try {
|
|
||||||
const result = await getAdminUsers('page=1&page_size=999')
|
|
||||||
if (result.success) {
|
|
||||||
userOptions.value = result.items || []
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
userOptions.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildHistoryQuery(page: number) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(page),
|
|
||||||
page_size: String(pageSize),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (filterUserId.value) {
|
|
||||||
params.set('user_id', filterUserId.value)
|
|
||||||
}
|
|
||||||
if (filterTimeStart.value) {
|
|
||||||
params.set('time_start', filterTimeStart.value)
|
|
||||||
}
|
|
||||||
if (filterTimeEnd.value) {
|
|
||||||
params.set('time_end', filterTimeEnd.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return params.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadHistory(page = 1) {
|
|
||||||
historyPage.value = page
|
|
||||||
try {
|
|
||||||
const result = await getAdminHistory(buildHistoryQuery(page))
|
|
||||||
if (!result.success) {
|
|
||||||
ElMessage.error(result.error || '加载记录失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
historyRows.value = result.items || []
|
|
||||||
historyTotal.value = result.total || 0
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCreateUser() {
|
|
||||||
const username = createForm.username.trim()
|
|
||||||
if (username.length < 2) {
|
|
||||||
ElMessage.warning('用户名至少2个字符')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (createForm.password.length < 6) {
|
|
||||||
ElMessage.warning('密码至少6个字符')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCreatedBySelect.value && !createForm.createdById) {
|
|
||||||
ElMessage.warning('请选择所属管理员')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
createLoading.value = true
|
|
||||||
try {
|
|
||||||
const payload: {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
role: string
|
|
||||||
created_by_id?: number
|
|
||||||
} = {
|
|
||||||
username,
|
|
||||||
password: createForm.password,
|
|
||||||
role: isSuperAdmin.value ? createForm.role : 'normal',
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCreatedBySelect.value && createForm.createdById) {
|
|
||||||
payload.created_by_id = createForm.createdById
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createAdminUser(payload)
|
|
||||||
if (!result.success) {
|
|
||||||
ElMessage.error(result.error || '创建失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ElMessage.success(result.msg || '创建成功')
|
|
||||||
createForm.username = ''
|
|
||||||
createForm.password = ''
|
|
||||||
createForm.role = 'normal'
|
|
||||||
createForm.createdById = undefined
|
|
||||||
await loadUsers(1)
|
|
||||||
await loadUserOptions()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
|
||||||
} finally {
|
|
||||||
createLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditDialog(user: AdminUserItem) {
|
|
||||||
editForm.id = user.id
|
|
||||||
editForm.username = user.username || ''
|
|
||||||
editForm.password = ''
|
|
||||||
editForm.role = user.role === 'admin' ? 'admin' : 'normal'
|
|
||||||
editForm.creatorUsername = user.creator_username || ''
|
|
||||||
editVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveUser() {
|
|
||||||
editLoading.value = true
|
|
||||||
try {
|
|
||||||
const payload: { password?: string; role?: string } = {}
|
|
||||||
if (editForm.password) {
|
|
||||||
if (editForm.password.length < 6) {
|
|
||||||
ElMessage.warning('密码至少6个字符')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payload.password = editForm.password
|
|
||||||
}
|
|
||||||
if (showEditRoleSelect.value) {
|
|
||||||
payload.role = editForm.role
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await updateAdminUser(editForm.id, payload)
|
|
||||||
if (!result.success) {
|
|
||||||
ElMessage.error(result.error || '保存失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
editVisible.value = false
|
|
||||||
ElMessage.success(result.msg || '保存成功')
|
|
||||||
await loadUsers(userPage.value)
|
|
||||||
await loadUserOptions()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
|
||||||
} finally {
|
|
||||||
editLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeUser(user: AdminUserItem) {
|
|
||||||
try {
|
|
||||||
await ElMessageBox.confirm(`确定删除用户 "${user.username || ''}" 吗?`, '删除确认', {
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await deleteAdminUser(user.id)
|
|
||||||
if (!result.success) {
|
|
||||||
ElMessage.error(result.error || '删除失败')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ElMessage.success('删除成功')
|
|
||||||
await loadUsers(userPage.value)
|
|
||||||
await loadUserOptions()
|
|
||||||
} catch (error) {
|
|
||||||
ElMessage.error(error instanceof Error ? error.message : '请求失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHistoryPreviewUrls(row: AdminHistoryItem) {
|
|
||||||
const urls = row.long_image_url ? [row.long_image_url] : []
|
|
||||||
return urls.concat(row.result_urls || []).slice(0, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTabChange(name: string | number) {
|
|
||||||
if (name === 'history') {
|
|
||||||
loadHistory(1).catch(() => undefined)
|
|
||||||
loadUserOptions().catch(() => undefined)
|
|
||||||
} else {
|
|
||||||
loadUsers(1).catch(() => undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadUsers(1)
|
|
||||||
await loadUserOptions()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.admin-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 24px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-nav {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-title {
|
|
||||||
margin: 0 0 20px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #303133;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-tabs {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 360px 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-card,
|
|
||||||
.table-card {
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-tip {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: #666;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar > * {
|
|
||||||
min-width: 180px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination {
|
|
||||||
margin-top: 16px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-card {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.history-preview {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-image {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
|
||||||
.admin-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -41,13 +41,23 @@
|
|||||||
:class="{ active: convertTemplateId === template.id }"
|
:class="{ active: convertTemplateId === template.id }"
|
||||||
>
|
>
|
||||||
<input v-model="convertTemplateId" type="radio" :value="template.id" name="convert-template" />
|
<input v-model="convertTemplateId" type="radio" :value="template.id" name="convert-template" />
|
||||||
<div>
|
<div class="template-item-content">
|
||||||
<span class="label">
|
<div>
|
||||||
{{ template.label }}
|
<span class="label">
|
||||||
<span v-if="template.is_default" class="template-tag">默认</span>
|
{{ template.label }}
|
||||||
<span v-else-if="template.built_in" class="template-tag muted">内置</span>
|
<span v-if="template.is_default" class="template-tag">默认</span>
|
||||||
</span>
|
<span v-else-if="template.built_in" class="template-tag muted">内置</span>
|
||||||
<div class="desc">输出文件:{{ template.output_filename }}</div>
|
</span>
|
||||||
|
<div class="desc">输出文件:{{ template.output_filename }}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="!template.built_in"
|
||||||
|
type="button"
|
||||||
|
class="template-delete-btn"
|
||||||
|
@click.stop.prevent="deleteConvertTemplate(template.id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +91,7 @@
|
|||||||
{{ convertRunning ? '转换中...' : '开始转换' }}
|
{{ convertRunning ? '转换中...' : '开始转换' }}
|
||||||
</button>
|
</button>
|
||||||
<span class="loading-msg">
|
<span class="loading-msg">
|
||||||
{{ convertRunning ? '正在生成英国.txt,请稍候…' : '选择文件后即可执行格式转换' }}
|
{{ convertRunning ? '正在生成并上传结果,请稍候…' : '选择文件后即可执行格式转换,结果会显示在右侧供下载' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -108,9 +118,6 @@
|
|||||||
<div class="result-list-wrap">
|
<div class="result-list-wrap">
|
||||||
<div class="result-list-header">
|
<div class="result-list-header">
|
||||||
<span>生成的 txt 列表</span>
|
<span>生成的 txt 列表</span>
|
||||||
<button type="button" class="download-all" :disabled="!convertOutputDir" @click="openConvertOutputDir">
|
|
||||||
打开输出目录
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="convertResultItems.length === 0" class="empty-tasks">
|
<div v-if="convertResultItems.length === 0" class="empty-tasks">
|
||||||
@@ -120,12 +127,12 @@
|
|||||||
<ul v-else class="task-list clean-result-list">
|
<ul v-else class="task-list clean-result-list">
|
||||||
<li
|
<li
|
||||||
v-for="item in convertResultItems"
|
v-for="item in convertResultItems"
|
||||||
:key="`${item.source_path}-${item.output_path || item.error || 'convert'}`"
|
:key="`${item.output_path || item.source_path || item.error || 'convert'}`"
|
||||||
class="task-item"
|
class="task-item"
|
||||||
>
|
>
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
|
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
|
||||||
<div v-if="item.output_path" class="files">输出文件:{{ item.output_path }}</div>
|
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
|
||||||
<div v-else-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
<div v-else-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,9 +144,17 @@
|
|||||||
v-if="item.success && item.output_path"
|
v-if="item.success && item.output_path"
|
||||||
type="button"
|
type="button"
|
||||||
class="download"
|
class="download"
|
||||||
@click="openConvertResult(item)"
|
@click="downloadConvertResult(item)"
|
||||||
>
|
>
|
||||||
打开文件
|
下载文件
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="item.result_id"
|
||||||
|
type="button"
|
||||||
|
class="btn-delete"
|
||||||
|
@click="deleteConvertHistory(item.result_id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -175,7 +190,6 @@ function shorten(value: string, maxLength: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetConvertResults() {
|
function resetConvertResults() {
|
||||||
convertResultItems.value = []
|
|
||||||
convertSummary.value = { total: 0, success_count: 0, failed_count: 0 }
|
convertSummary.value = { total: 0, success_count: 0, failed_count: 0 }
|
||||||
convertOutputDir.value = ''
|
convertOutputDir.value = ''
|
||||||
}
|
}
|
||||||
@@ -217,6 +231,26 @@ async function importConvertTemplate() {
|
|||||||
ElMessage.success('模板导入成功')
|
ElMessage.success('模板导入成功')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteConvertTemplate(templateId: string) {
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!api?.delete_txt_template) {
|
||||||
|
ElMessage.warning('当前环境不支持删除模板')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await api.delete_txt_template(templateId)
|
||||||
|
if (!result.success) {
|
||||||
|
ElMessage.error(result.error || '删除模板失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (convertTemplateId.value === templateId) {
|
||||||
|
convertTemplateId.value = ''
|
||||||
|
}
|
||||||
|
await loadConvertTemplates()
|
||||||
|
ElMessage.success('模板已删除')
|
||||||
|
}
|
||||||
|
|
||||||
async function setConvertDefaultTemplate() {
|
async function setConvertDefaultTemplate() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.set_default_txt_template || !convertTemplateId.value) {
|
if (!api?.set_default_txt_template || !convertTemplateId.value) {
|
||||||
@@ -279,7 +313,7 @@ async function selectConvertFolder() {
|
|||||||
|
|
||||||
async function submitConvertRun() {
|
async function submitConvertRun() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.convert_excel_to_uk_txt || !api?.select_folder) {
|
if (!api?.convert_excel_to_uk_txt) {
|
||||||
ElMessage.warning('当前环境不支持格式转换,请在桌面端打开')
|
ElMessage.warning('当前环境不支持格式转换,请在桌面端打开')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -289,13 +323,9 @@ async function submitConvertRun() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const outputDir = await api.select_folder()
|
|
||||||
if (!outputDir) return
|
|
||||||
|
|
||||||
convertRunning.value = true
|
convertRunning.value = true
|
||||||
const result = await api.convert_excel_to_uk_txt({
|
const result = await api.convert_excel_to_uk_txt({
|
||||||
paths: convertSelectedPaths.value,
|
paths: convertSelectedPaths.value,
|
||||||
output_dir: outputDir,
|
|
||||||
template_id: convertTemplateId.value,
|
template_id: convertTemplateId.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -304,13 +334,13 @@ async function submitConvertRun() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
convertOutputDir.value = result.summary?.output_dir || outputDir
|
convertOutputDir.value = ''
|
||||||
convertSummary.value = {
|
convertSummary.value = {
|
||||||
total: result.summary?.total || 0,
|
total: result.summary?.total || 0,
|
||||||
success_count: result.summary?.success_count || 0,
|
success_count: result.summary?.success_count || 0,
|
||||||
failed_count: result.summary?.failed_count || 0,
|
failed_count: result.summary?.failed_count || 0,
|
||||||
}
|
}
|
||||||
convertResultItems.value = result.items || []
|
await loadConvertHistory()
|
||||||
ElMessage.success('格式转换完成')
|
ElMessage.success('格式转换完成')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '格式转换失败')
|
ElMessage.error(error instanceof Error ? error.message : '格式转换失败')
|
||||||
@@ -319,34 +349,68 @@ async function submitConvertRun() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openConvertOutputDir() {
|
async function deleteConvertHistory(resultId: number) {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.open_path || !convertOutputDir.value) {
|
if (!api?.delete_history_item) {
|
||||||
ElMessage.warning('当前环境不支持打开输出目录')
|
ElMessage.warning('当前环境不支持删除历史')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.open_path(convertOutputDir.value)
|
const result = await api.delete_history_item('convert', resultId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
ElMessage.error(result.error || '打开目录失败')
|
ElMessage.error(result.error || '删除失败')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
await loadConvertHistory()
|
||||||
|
ElMessage.success('已删除')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openConvertResult(item: ConvertTxtResultItem) {
|
async function downloadConvertResult(item: ConvertTxtResultItem) {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.open_path || !item.output_path) {
|
if (!item.output_path) {
|
||||||
ElMessage.warning('当前环境不支持打开结果文件')
|
ElMessage.warning('当前结果没有下载地址')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.open_path(item.output_path)
|
const today = new Date()
|
||||||
if (!result.success) {
|
const yyyy = String(today.getFullYear())
|
||||||
ElMessage.error(result.error || '打开文件失败')
|
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dd = String(today.getDate()).padStart(2, '0')
|
||||||
|
const filename = `${yyyy}${mm}${dd}.txt`
|
||||||
|
if (api?.save_file_from_url) {
|
||||||
|
const result = await api.save_file_from_url(item.output_path, filename)
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(`已保存:${result.path || filename}`)
|
||||||
|
} else if (result.error && result.error !== '用户取消') {
|
||||||
|
ElMessage.error(result.error)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(item.output_path, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConvertHistory() {
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!api?.get_convert_history) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get_convert_history()
|
||||||
|
if (!response.success || !response.items) return
|
||||||
|
convertResultItems.value = response.items
|
||||||
|
convertSummary.value = {
|
||||||
|
total: response.items.length,
|
||||||
|
success_count: response.items.filter((item) => item.success).length,
|
||||||
|
failed_count: response.items.filter((item) => !item.success).length,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore history load errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConvertTemplates().catch(() => undefined)
|
loadConvertTemplates().catch(() => undefined)
|
||||||
|
loadConvertHistory().catch(() => undefined)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -374,7 +438,10 @@ onMounted(() => {
|
|||||||
.template-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #8fd0ff; background: rgba(52, 152, 219, 0.14); }
|
.template-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #8fd0ff; background: rgba(52, 152, 219, 0.14); }
|
||||||
.template-tag.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); }
|
.template-tag.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); }
|
||||||
.compact-row { margin-top: 4px; }
|
.compact-row { margin-top: 4px; }
|
||||||
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
|
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
|
||||||
|
.template-item-content { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
|
||||||
|
.template-delete-btn { padding: 6px 10px; border: 1px solid #5c2f2f; border-radius: 6px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; font-size: 12px; cursor: pointer; }
|
||||||
|
.template-delete-btn:hover { background: rgba(231, 76, 60, 0.2); }
|
||||||
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
|
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
|
||||||
.radio-item input { margin-top: 3px; }
|
.radio-item input { margin-top: 3px; }
|
||||||
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
|
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
|
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
|
||||||
</button>
|
</button>
|
||||||
<span class="loading-msg">
|
<span class="loading-msg">
|
||||||
{{ cleanRunning ? '正在处理文件,请稍候…' : '选择文件、列和 ID 规则后即可执行本地清洗' }}
|
{{ cleanRunning ? '正在处理文件并上传结果,请稍候…' : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -106,9 +106,6 @@
|
|||||||
<div class="result-list-wrap">
|
<div class="result-list-wrap">
|
||||||
<div class="result-list-header">
|
<div class="result-list-header">
|
||||||
<span>处理后的 Excel 列表</span>
|
<span>处理后的 Excel 列表</span>
|
||||||
<button type="button" class="download-all" :disabled="!cleanOutputDir" @click="openCleanOutputDir">
|
|
||||||
打开输出目录
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
|
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
|
||||||
@@ -118,8 +115,8 @@
|
|||||||
<ul v-else class="task-list clean-result-list">
|
<ul v-else class="task-list clean-result-list">
|
||||||
<li v-for="item in cleanResultItems" :key="`${item.source_path}-${item.output_path || item.error || 'result'}`" class="task-item">
|
<li v-for="item in cleanResultItems" :key="`${item.source_path}-${item.output_path || item.error || 'result'}`" class="task-item">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
|
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
|
||||||
<div v-if="item.output_path" class="files">输出文件:{{ item.output_path }}</div>
|
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
|
||||||
<div v-else-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
<div v-else-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,9 +128,17 @@
|
|||||||
v-if="item.success && item.output_path"
|
v-if="item.success && item.output_path"
|
||||||
type="button"
|
type="button"
|
||||||
class="download"
|
class="download"
|
||||||
@click="openCleanResult(item)"
|
@click="downloadCleanResult(item)"
|
||||||
>
|
>
|
||||||
打开文件
|
下载文件
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="item.result_id"
|
||||||
|
type="button"
|
||||||
|
class="btn-delete"
|
||||||
|
@click="deleteCleanHistory(item.result_id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -145,7 +150,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { expandBrandFolder } from '@/shared/api/brand'
|
import { expandBrandFolder } from '@/shared/api/brand'
|
||||||
import { type CleanExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
|
import { type CleanExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
|
||||||
@@ -181,7 +186,6 @@ async function loadCleanHeaders(filePath: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanResultItems.value = []
|
|
||||||
cleanSummary.value = { total: 0, success_count: 0, failed_count: 0 }
|
cleanSummary.value = { total: 0, success_count: 0, failed_count: 0 }
|
||||||
cleanOutputDir.value = ''
|
cleanOutputDir.value = ''
|
||||||
|
|
||||||
@@ -248,7 +252,7 @@ async function selectCleanFolder() {
|
|||||||
|
|
||||||
async function submitCleanRun() {
|
async function submitCleanRun() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.clean_excel_files || !api?.select_folder) {
|
if (!api?.clean_excel_files) {
|
||||||
ElMessage.warning('当前环境不支持数据去重,请在桌面端打开')
|
ElMessage.warning('当前环境不支持数据去重,请在桌面端打开')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -266,16 +270,12 @@ async function submitCleanRun() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const outputDir = await api.select_folder()
|
|
||||||
if (!outputDir) return
|
|
||||||
|
|
||||||
cleanRunning.value = true
|
cleanRunning.value = true
|
||||||
const result = await api.clean_excel_files({
|
const result = await api.clean_excel_files({
|
||||||
paths: cleanSelectedPaths.value,
|
paths: cleanSelectedPaths.value,
|
||||||
selected_columns: cleanSelectedColumns.value,
|
selected_columns: cleanSelectedColumns.value,
|
||||||
keep_integer_ids: cleanKeepIntegerIds.value,
|
keep_integer_ids: cleanKeepIntegerIds.value,
|
||||||
keep_underscore_ids: cleanKeepUnderscoreIds.value,
|
keep_underscore_ids: cleanKeepUnderscoreIds.value,
|
||||||
output_dir: outputDir,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -283,13 +283,13 @@ async function submitCleanRun() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanOutputDir.value = result.summary?.output_dir || outputDir
|
cleanOutputDir.value = ''
|
||||||
cleanSummary.value = {
|
cleanSummary.value = {
|
||||||
total: result.summary?.total || 0,
|
total: result.summary?.total || 0,
|
||||||
success_count: result.summary?.success_count || 0,
|
success_count: result.summary?.success_count || 0,
|
||||||
failed_count: result.summary?.failed_count || 0,
|
failed_count: result.summary?.failed_count || 0,
|
||||||
}
|
}
|
||||||
cleanResultItems.value = result.items || []
|
await loadCleanHistory()
|
||||||
ElMessage.success('数据去重完成')
|
ElMessage.success('数据去重完成')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '去重失败')
|
ElMessage.error(error instanceof Error ? error.message : '去重失败')
|
||||||
@@ -298,30 +298,69 @@ async function submitCleanRun() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCleanOutputDir() {
|
onMounted(() => {
|
||||||
|
loadCleanHistory().catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCleanHistory() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.open_path || !cleanOutputDir.value) {
|
if (!api?.get_dedupe_history) {
|
||||||
ElMessage.warning('当前环境不支持打开输出目录')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.open_path(cleanOutputDir.value)
|
try {
|
||||||
if (!result.success) {
|
const response = await api.get_dedupe_history()
|
||||||
ElMessage.error(result.error || '打开目录失败')
|
if (!response.success || !response.items) return
|
||||||
|
cleanResultItems.value = response.items
|
||||||
|
cleanSummary.value = {
|
||||||
|
total: response.items.length,
|
||||||
|
success_count: response.items.filter((item) => item.success).length,
|
||||||
|
failed_count: response.items.filter((item) => !item.success).length,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore history load errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openCleanResult(item: CleanExcelResultItem) {
|
async function deleteCleanHistory(resultId: number) {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.open_path || !item.output_path) {
|
if (!api?.delete_history_item) {
|
||||||
ElMessage.warning('当前环境不支持打开结果文件')
|
ElMessage.warning('当前环境不支持删除历史')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.open_path(item.output_path)
|
const result = await api.delete_history_item('dedupe', resultId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
ElMessage.error(result.error || '打开文件失败')
|
ElMessage.error(result.error || '删除失败')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
await loadCleanHistory()
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCleanResult(item: CleanExcelResultItem) {
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!item.output_path) {
|
||||||
|
ElMessage.warning('当前结果没有下载地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const yyyy = String(today.getFullYear())
|
||||||
|
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dd = String(today.getDate()).padStart(2, '0')
|
||||||
|
const filename = `${yyyy}${mm}${dd}_cleaned.xlsx`
|
||||||
|
if (api?.save_file_from_url) {
|
||||||
|
const result = await api.save_file_from_url(item.output_path, filename)
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(`已保存:${result.path || filename}`)
|
||||||
|
} else if (result.error && result.error !== '用户取消') {
|
||||||
|
ElMessage.error(result.error)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(item.output_path, '_blank')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@
|
|||||||
{{ splitRunning ? '拆分中...' : '开始拆分' }}
|
{{ splitRunning ? '拆分中...' : '开始拆分' }}
|
||||||
</button>
|
</button>
|
||||||
<span class="loading-msg">
|
<span class="loading-msg">
|
||||||
{{ splitRunning ? '正在拆分文件,请稍候…' : '选择文件、保留列和拆分规则后即可执行本地拆分' }}
|
{{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -116,9 +116,6 @@
|
|||||||
<div class="result-list-wrap">
|
<div class="result-list-wrap">
|
||||||
<div class="result-list-header">
|
<div class="result-list-header">
|
||||||
<span>拆分后的 Excel 列表</span>
|
<span>拆分后的 Excel 列表</span>
|
||||||
<button type="button" class="download-all" :disabled="!splitOutputDir" @click="openSplitOutputDir">
|
|
||||||
打开输出目录
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="splitResultItems.length === 0" class="empty-tasks">
|
<div v-if="splitResultItems.length === 0" class="empty-tasks">
|
||||||
@@ -128,8 +125,8 @@
|
|||||||
<ul v-else class="task-list clean-result-list">
|
<ul v-else class="task-list clean-result-list">
|
||||||
<li v-for="item in splitResultItems" :key="`${item.output_path || item.source_path}-${item.row_count || 0}`" class="task-item">
|
<li v-for="item in splitResultItems" :key="`${item.output_path || item.source_path}-${item.row_count || 0}`" class="task-item">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
|
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
|
||||||
<div v-if="item.output_path" class="files">输出文件:{{ item.output_path }}</div>
|
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
|
||||||
<div v-if="item.row_count !== undefined" class="time">包含 {{ item.row_count }} 条数据</div>
|
<div v-if="item.row_count !== undefined" class="time">包含 {{ item.row_count }} 条数据</div>
|
||||||
<div v-else-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
<div v-else-if="item.error" class="files">错误信息:{{ item.error }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,9 +139,17 @@
|
|||||||
v-if="item.success && item.output_path"
|
v-if="item.success && item.output_path"
|
||||||
type="button"
|
type="button"
|
||||||
class="download"
|
class="download"
|
||||||
@click="openSplitResult(item)"
|
@click="downloadSplitResult(item)"
|
||||||
>
|
>
|
||||||
打开文件
|
下载文件
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="item.result_id"
|
||||||
|
type="button"
|
||||||
|
class="btn-delete"
|
||||||
|
@click="deleteSplitHistory(item.result_id)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -156,7 +161,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { expandBrandFolder } from '@/shared/api/brand'
|
import { expandBrandFolder } from '@/shared/api/brand'
|
||||||
import { type SplitExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
|
import { type SplitExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
|
||||||
@@ -179,7 +184,6 @@ function shorten(value: string, maxLength: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetSplitResults() {
|
function resetSplitResults() {
|
||||||
splitResultItems.value = []
|
|
||||||
splitSummary.value = { total: 0, success_count: 0, failed_count: 0 }
|
splitSummary.value = { total: 0, success_count: 0, failed_count: 0 }
|
||||||
splitOutputDir.value = ''
|
splitOutputDir.value = ''
|
||||||
}
|
}
|
||||||
@@ -251,7 +255,7 @@ async function selectSplitFolder() {
|
|||||||
|
|
||||||
async function submitSplitRun() {
|
async function submitSplitRun() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.split_excel_files || !api?.select_folder) {
|
if (!api?.split_excel_files) {
|
||||||
ElMessage.warning('当前环境不支持数据拆分,请在桌面端打开')
|
ElMessage.warning('当前环境不支持数据拆分,请在桌面端打开')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -273,9 +277,6 @@ async function submitSplitRun() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const outputDir = await api.select_folder()
|
|
||||||
if (!outputDir) return
|
|
||||||
|
|
||||||
splitRunning.value = true
|
splitRunning.value = true
|
||||||
const result = await api.split_excel_files({
|
const result = await api.split_excel_files({
|
||||||
paths: splitSelectedPaths.value,
|
paths: splitSelectedPaths.value,
|
||||||
@@ -283,7 +284,6 @@ async function submitSplitRun() {
|
|||||||
split_mode: splitMode.value,
|
split_mode: splitMode.value,
|
||||||
rows_per_file: splitRowsPerFile.value || undefined,
|
rows_per_file: splitRowsPerFile.value || undefined,
|
||||||
parts: splitParts.value || undefined,
|
parts: splitParts.value || undefined,
|
||||||
output_dir: outputDir,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -291,13 +291,13 @@ async function submitSplitRun() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
splitOutputDir.value = result.summary?.output_dir || outputDir
|
splitOutputDir.value = ''
|
||||||
splitSummary.value = {
|
splitSummary.value = {
|
||||||
total: result.summary?.total || 0,
|
total: result.summary?.total || 0,
|
||||||
success_count: result.summary?.success_count || 0,
|
success_count: result.summary?.success_count || 0,
|
||||||
failed_count: result.summary?.failed_count || 0,
|
failed_count: result.summary?.failed_count || 0,
|
||||||
}
|
}
|
||||||
splitResultItems.value = result.items || []
|
await loadSplitHistory()
|
||||||
ElMessage.success('数据拆分完成')
|
ElMessage.success('数据拆分完成')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '数据拆分失败')
|
ElMessage.error(error instanceof Error ? error.message : '数据拆分失败')
|
||||||
@@ -306,30 +306,67 @@ async function submitSplitRun() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSplitOutputDir() {
|
async function loadSplitHistory() {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.open_path || !splitOutputDir.value) {
|
if (!api?.get_split_history) return
|
||||||
ElMessage.warning('当前环境不支持打开输出目录')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await api.open_path(splitOutputDir.value)
|
try {
|
||||||
if (!result.success) {
|
const response = await api.get_split_history()
|
||||||
ElMessage.error(result.error || '打开目录失败')
|
if (!response.success || !response.items) return
|
||||||
|
splitResultItems.value = response.items
|
||||||
|
splitSummary.value = {
|
||||||
|
total: response.items.length,
|
||||||
|
success_count: response.items.filter((item) => item.success).length,
|
||||||
|
failed_count: response.items.filter((item) => !item.success).length,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore history load errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSplitResult(item: SplitExcelResultItem) {
|
onMounted(() => {
|
||||||
|
loadSplitHistory().catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function deleteSplitHistory(resultId: number) {
|
||||||
const api = getPywebviewApi()
|
const api = getPywebviewApi()
|
||||||
if (!api?.open_path || !item.output_path) {
|
if (!api?.delete_history_item) {
|
||||||
ElMessage.warning('当前环境不支持打开结果文件')
|
ElMessage.warning('当前环境不支持删除历史')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await api.open_path(item.output_path)
|
const result = await api.delete_history_item('split', resultId)
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
ElMessage.error(result.error || '打开文件失败')
|
ElMessage.error(result.error || '删除失败')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
await loadSplitHistory()
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSplitResult(item: SplitExcelResultItem) {
|
||||||
|
const api = getPywebviewApi()
|
||||||
|
if (!item.output_path) {
|
||||||
|
ElMessage.warning('当前结果没有下载地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const yyyy = String(today.getFullYear())
|
||||||
|
const mm = String(today.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dd = String(today.getDate()).padStart(2, '0')
|
||||||
|
const filename = `${yyyy}${mm}${dd}.xlsx`
|
||||||
|
if (api?.save_file_from_url) {
|
||||||
|
const result = await api.save_file_from_url(item.output_path, filename)
|
||||||
|
if (result.success) {
|
||||||
|
ElMessage.success(`已保存:${result.path || filename}`)
|
||||||
|
} else if (result.error && result.error !== '用户取消') {
|
||||||
|
ElMessage.error(result.error)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.open(item.output_path, '_blank')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,171 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PageShell theme="light">
|
|
||||||
<div class="home-page">
|
|
||||||
<header class="home-header">
|
|
||||||
<div class="brand">南日AI</div>
|
|
||||||
<div class="header-actions">
|
|
||||||
<span>{{ username }}</span>
|
|
||||||
<el-popover placement="bottom-end" :width="320" trigger="click">
|
|
||||||
<template #reference>
|
|
||||||
<el-button circle>↻</el-button>
|
|
||||||
</template>
|
|
||||||
<div class="update-panel">
|
|
||||||
<div class="update-title">软件更新</div>
|
|
||||||
<div class="update-version">当前版本: v{{ version }}</div>
|
|
||||||
<el-button type="primary" text :loading="loading" @click="check">检测更新</el-button>
|
|
||||||
<div class="update-hint">{{ hint }}</div>
|
|
||||||
<el-button
|
|
||||||
v-if="downloadVisible"
|
|
||||||
type="success"
|
|
||||||
:loading="downloadLoading"
|
|
||||||
@click="handleUpdate"
|
|
||||||
>
|
|
||||||
立即更新
|
|
||||||
</el-button>
|
|
||||||
</div>
|
|
||||||
</el-popover>
|
|
||||||
<a href="/login" @click.prevent="handleLogout">退出</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="home-main">
|
|
||||||
<div class="entrances">
|
|
||||||
<router-link v-if="isVisible('brand')" class="entrance-card" to="/brand">亚马逊</router-link>
|
|
||||||
<button v-if="isVisible('wb')" class="entrance-card is-disabled" type="button" @click="showComingSoon">
|
|
||||||
wildberries
|
|
||||||
</button>
|
|
||||||
<router-link v-if="isVisible('image')" class="entrance-card" to="/image">图片</router-link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
import { logout } from '@/shared/api/auth'
|
|
||||||
import { useUpdateCheck } from '@/shared/composables/useUpdateCheck'
|
|
||||||
import { getBootstrapState } from '@/shared/utils/bootstrap'
|
|
||||||
import { getUserColumnPermissions } from '@/shared/api/admin'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const bootstrap = getBootstrapState()
|
|
||||||
const username = computed(() => bootstrap.username || '用户')
|
|
||||||
const userId = computed(() => bootstrap.userId || '')
|
|
||||||
const visibleKeys = ref<string[]>(['brand', 'wb', 'image'])
|
|
||||||
const { loading, version, hint, downloadVisible, downloadLoading, check, runUpdate } = useUpdateCheck()
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!userId.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await getUserColumnPermissions(userId.value)
|
|
||||||
if (result.success && Array.isArray(result.items)) {
|
|
||||||
visibleKeys.value = result.items
|
|
||||||
.map((item) => (item.column_key || '').toLowerCase())
|
|
||||||
.filter(Boolean)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// keep default entrances if permissions cannot be loaded
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const isVisible = (key: string) => visibleKeys.value.includes(key)
|
|
||||||
|
|
||||||
const showComingSoon = () => {
|
|
||||||
ElMessage.info('暂未开通,敬请期待')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout()
|
|
||||||
localStorage.removeItem('maixiang_api_key')
|
|
||||||
window.__BOOTSTRAP__ = {}
|
|
||||||
await router.replace('/login')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpdate = async () => {
|
|
||||||
const confirmed = window.confirm('有更新,是否现在更新?\n更新将下载安装包并重启程序。')
|
|
||||||
if (!confirmed) return
|
|
||||||
await runUpdate()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.home-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background:
|
|
||||||
linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5)),
|
|
||||||
url('/static/bg.jpg') center/cover no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 16px 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.55);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-main {
|
|
||||||
min-height: calc(100vh - 72px);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entrances {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entrance-card {
|
|
||||||
width: 180px;
|
|
||||||
height: 110px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
background: rgba(255, 255, 255, 0.85);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-disabled {
|
|
||||||
color: #909399;
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.update-version,
|
|
||||||
.update-hint {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PageShell theme="dark">
|
|
||||||
<div class="image-page">
|
|
||||||
<header class="top-bar">
|
|
||||||
<div class="logo-area">
|
|
||||||
<span class="app-name">南日AI</span>
|
|
||||||
<router-link to="/home" class="btn-home">返回首页</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="nav-tabs">
|
|
||||||
<div class="nav-tab-group">
|
|
||||||
<button type="button" class="nav-tab active">图片</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="page-body"></main>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
document.title = '图片 - 南日AI'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.image-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
background: rgba(13, 13, 13, 1);
|
|
||||||
color: #e0e0e0;
|
|
||||||
font-family: "Microsoft YaHei", "Noto Serif SC", "SimSun", "Songti SC", serif;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-bar {
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 16px;
|
|
||||||
border-bottom: 1px solid #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-area {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-home {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #999;
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-home:hover {
|
|
||||||
color: #fff;
|
|
||||||
background: #2a2a2a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-tab-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: rgba(77, 72, 72, 0.99);
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-tab {
|
|
||||||
padding: 8px 14px;
|
|
||||||
font-size: 13px;
|
|
||||||
color: #3498db;
|
|
||||||
background: rgba(52, 152, 219, 0.2);
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-body {
|
|
||||||
height: calc(100vh - 56px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<template>
|
|
||||||
<PageShell theme="light">
|
|
||||||
<div class="login-page">
|
|
||||||
<div class="login-header">南日AI</div>
|
|
||||||
<el-card class="login-card" shadow="never">
|
|
||||||
<template #header>
|
|
||||||
<div class="login-title">登录</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<el-alert
|
|
||||||
v-if="errorMessage"
|
|
||||||
:title="errorMessage"
|
|
||||||
type="error"
|
|
||||||
:closable="false"
|
|
||||||
class="login-alert"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<el-form label-width="72px" @submit.prevent="handleSubmit">
|
|
||||||
<el-form-item label="用户名">
|
|
||||||
<el-input v-model="form.username" placeholder="请输入用户名" autofocus />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="密码">
|
|
||||||
<el-input
|
|
||||||
v-model="form.password"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
placeholder="请输入密码"
|
|
||||||
@keydown.enter="handleSubmit"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-button type="primary" :loading="submitting" class="login-button" @click="handleSubmit">
|
|
||||||
登录
|
|
||||||
</el-button>
|
|
||||||
</el-form>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { checkAuth, login } from '@/shared/api/auth'
|
|
||||||
import { getBootstrapState } from '@/shared/utils/bootstrap'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const bootstrap = getBootstrapState()
|
|
||||||
const submitting = ref(false)
|
|
||||||
const errorMessage = ref(bootstrap.error || '')
|
|
||||||
const form = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const result = await checkAuth()
|
|
||||||
if (!result.logged_in) return
|
|
||||||
|
|
||||||
const target = result.redirect || '/home'
|
|
||||||
if (target.startsWith('/')) {
|
|
||||||
await router.push(target)
|
|
||||||
} else {
|
|
||||||
window.location.href = target
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore auth check failures during bootstrap
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!form.username || !form.password) {
|
|
||||||
errorMessage.value = '请输入用户名和密码'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
submitting.value = true
|
|
||||||
errorMessage.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await login(form.username, form.password)
|
|
||||||
if (result.success) {
|
|
||||||
const target = result.redirect || '/home'
|
|
||||||
if (target.startsWith('/')) {
|
|
||||||
await router.push(target)
|
|
||||||
} else {
|
|
||||||
window.location.href = target
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
errorMessage.value = result.error || '登录失败'
|
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = error instanceof Error ? error.message : '登录失败'
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-page {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 40px 16px;
|
|
||||||
background: linear-gradient(180deg, #dce8f1 0%, #eef4f8 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-header {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 24px;
|
|
||||||
background: rgba(255, 255, 255, 0.6);
|
|
||||||
color: #303133;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-card {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 420px;
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
border: 1px solid var(--color-border-light);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-title {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-alert {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-button {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
|
||||||
|
|
||||||
const routes = [
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
redirect: '/home',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
component: () => import('@/pages/login/index.vue'),
|
|
||||||
meta: { title: '登录 - 南日AI' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/home',
|
|
||||||
component: () => import('@/pages/home/index.vue'),
|
|
||||||
meta: { title: '首页 - 南日AI' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/brand',
|
|
||||||
component: () => import('@/pages/brand/index.vue'),
|
|
||||||
meta: { title: '亚马逊 - 南日AI' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin',
|
|
||||||
component: () => import('@/pages/admin/index.vue'),
|
|
||||||
meta: { title: '管理后台 - 南日AI' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/image',
|
|
||||||
component: () => import('@/pages/image/index.vue'),
|
|
||||||
meta: { title: '图片 - 南日AI' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes,
|
|
||||||
})
|
|
||||||
|
|
||||||
router.afterEach((to) => {
|
|
||||||
if (to.meta.title) {
|
|
||||||
document.title = String(to.meta.title)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import { requestDeleteJson, requestGetJson, requestPostJson, requestPutJson } from '@/shared/api/http'
|
|
||||||
|
|
||||||
export interface AdminUserItem {
|
|
||||||
id: number
|
|
||||||
username?: string
|
|
||||||
role?: 'super_admin' | 'admin' | 'normal' | string
|
|
||||||
creator_username?: string
|
|
||||||
created_at?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminSimpleUser {
|
|
||||||
id: number
|
|
||||||
username: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminUsersResponse {
|
|
||||||
success: boolean
|
|
||||||
current_user_role?: 'super_admin' | 'admin' | 'normal' | string
|
|
||||||
admins?: AdminSimpleUser[]
|
|
||||||
items?: AdminUserItem[]
|
|
||||||
total?: number
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminMutationResponse {
|
|
||||||
success: boolean
|
|
||||||
msg?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminHistoryItem {
|
|
||||||
id: number
|
|
||||||
username?: string
|
|
||||||
panel_type?: string
|
|
||||||
created_at?: string
|
|
||||||
long_image_url?: string | null
|
|
||||||
result_urls?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminHistoryResponse {
|
|
||||||
success: boolean
|
|
||||||
items?: AdminHistoryItem[]
|
|
||||||
total?: number
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminColumnPermissionItem {
|
|
||||||
column_key?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminColumnPermissionResponse {
|
|
||||||
success: boolean
|
|
||||||
items?: AdminColumnPermissionItem[]
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminUsers(query = '') {
|
|
||||||
const suffix = query ? `?${query}` : ''
|
|
||||||
return requestGetJson<AdminUsersResponse>(`/api/admin/users${suffix}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createAdminUser(payload: {
|
|
||||||
username: string
|
|
||||||
password: string
|
|
||||||
role: string
|
|
||||||
created_by_id?: number
|
|
||||||
}) {
|
|
||||||
return requestPostJson<AdminMutationResponse>('/api/admin/user', payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateAdminUser(
|
|
||||||
userId: number | string,
|
|
||||||
payload: {
|
|
||||||
password?: string
|
|
||||||
role?: string
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return requestPutJson<AdminMutationResponse>(`/api/admin/user/${userId}`, payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteAdminUser(userId: number | string) {
|
|
||||||
return requestDeleteJson<AdminMutationResponse>(`/api/admin/user/${userId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAdminHistory(query = '') {
|
|
||||||
const suffix = query ? `?${query}` : ''
|
|
||||||
return requestGetJson<AdminHistoryResponse>(`/api/admin/history${suffix}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUserColumnPermissions(userId: string | number) {
|
|
||||||
return requestGetJson<AdminColumnPermissionResponse>(`/api/admin/user/${userId}/column-permissions`)
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { requestGetJson, requestPostJson } from '@/shared/api/http'
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
success: boolean
|
|
||||||
redirect?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthCheckResponse {
|
|
||||||
logged_in: boolean
|
|
||||||
redirect?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkAuth() {
|
|
||||||
return requestGetJson<AuthCheckResponse>('/api/auth/check')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(username: string, password: string) {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.set('username', username)
|
|
||||||
formData.set('password', password)
|
|
||||||
|
|
||||||
return requestPostJson<LoginResponse>('/login', formData, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
try {
|
|
||||||
await requestGetJson('/logout', {
|
|
||||||
validateStatus: () => true,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// logout should still continue on the client even if backend responds abnormally
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import { requestGetJson, requestPostJson } from '@/shared/api/http'
|
|
||||||
|
|
||||||
export interface VersionResponse {
|
|
||||||
version?: string
|
|
||||||
has_update?: boolean
|
|
||||||
latest_version?: string
|
|
||||||
desc?: string
|
|
||||||
file_url?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateStartResponse {
|
|
||||||
success: boolean
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchVersion() {
|
|
||||||
return requestGetJson<VersionResponse>('/api/version')
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startUpdate(fileUrl: string) {
|
|
||||||
return requestPostJson<UpdateStartResponse>('/api/update/do', { file_url: fileUrl })
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,11 @@ export interface SplitExcelPayload {
|
|||||||
split_mode: 'rows_per_file' | 'parts'
|
split_mode: 'rows_per_file' | 'parts'
|
||||||
rows_per_file?: number
|
rows_per_file?: number
|
||||||
parts?: number
|
parts?: number
|
||||||
output_dir: string
|
output_dir?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitExcelResultItem {
|
export interface SplitExcelResultItem {
|
||||||
|
result_id?: number
|
||||||
source_path: string
|
source_path: string
|
||||||
output_path?: string
|
output_path?: string
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -36,7 +37,16 @@ export interface ExcelInfoResponse {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CleanExcelPayload {
|
||||||
|
paths: string[]
|
||||||
|
selected_columns: string[]
|
||||||
|
keep_integer_ids?: boolean
|
||||||
|
keep_underscore_ids?: boolean
|
||||||
|
output_dir?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CleanExcelResultItem {
|
export interface CleanExcelResultItem {
|
||||||
|
result_id?: number
|
||||||
source_path: string
|
source_path: string
|
||||||
output_path?: string
|
output_path?: string
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -45,7 +55,7 @@ export interface CleanExcelResultItem {
|
|||||||
|
|
||||||
export interface ConvertTxtPayload {
|
export interface ConvertTxtPayload {
|
||||||
paths: string[]
|
paths: string[]
|
||||||
output_dir: string
|
output_dir?: string
|
||||||
template_id: string
|
template_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +68,7 @@ export interface ConvertTxtTemplateItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConvertTxtResultItem {
|
export interface ConvertTxtResultItem {
|
||||||
|
result_id?: number
|
||||||
source_path: string
|
source_path: string
|
||||||
output_path?: string
|
output_path?: string
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -92,6 +103,24 @@ export interface CleanExcelResponse {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConvertTxtHistoryResponse {
|
||||||
|
success: boolean
|
||||||
|
items?: ConvertTxtResultItem[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SplitExcelHistoryResponse {
|
||||||
|
success: boolean
|
||||||
|
items?: SplitExcelResultItem[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CleanExcelHistoryResponse {
|
||||||
|
success: boolean
|
||||||
|
items?: CleanExcelResultItem[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface PywebviewApi {
|
export interface PywebviewApi {
|
||||||
select_brand_xlsx_files?: () => Promise<string[]>
|
select_brand_xlsx_files?: () => Promise<string[]>
|
||||||
select_clean_xlsx_files?: () => Promise<string[]>
|
select_clean_xlsx_files?: () => Promise<string[]>
|
||||||
@@ -99,12 +128,17 @@ export interface PywebviewApi {
|
|||||||
select_clean_folder?: () => Promise<string | null>
|
select_clean_folder?: () => Promise<string | null>
|
||||||
select_folder?: () => Promise<string | null>
|
select_folder?: () => Promise<string | null>
|
||||||
get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }>
|
get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }>
|
||||||
|
get_dedupe_history?: () => Promise<CleanExcelHistoryResponse>
|
||||||
|
get_convert_history?: () => Promise<ConvertTxtHistoryResponse>
|
||||||
|
get_split_history?: () => Promise<SplitExcelHistoryResponse>
|
||||||
|
delete_history_item?: (moduleType: string, resultId: number) => Promise<{ success: boolean; error?: string }>
|
||||||
get_excel_info?: (filePath: string) => Promise<ExcelInfoResponse>
|
get_excel_info?: (filePath: string) => Promise<ExcelInfoResponse>
|
||||||
clean_excel_files?: (payload: CleanExcelPayload) => Promise<CleanExcelResponse>
|
clean_excel_files?: (payload: CleanExcelPayload) => Promise<CleanExcelResponse>
|
||||||
split_excel_files?: (payload: SplitExcelPayload) => Promise<SplitExcelResponse>
|
split_excel_files?: (payload: SplitExcelPayload) => Promise<SplitExcelResponse>
|
||||||
list_txt_templates?: () => Promise<ConvertTxtTemplateItem[]>
|
list_txt_templates?: () => Promise<ConvertTxtTemplateItem[]>
|
||||||
import_txt_template?: (name?: string) => Promise<{ success: boolean; template?: ConvertTxtTemplateItem; error?: string }>
|
import_txt_template?: (name?: string) => Promise<{ success: boolean; template?: ConvertTxtTemplateItem; error?: string }>
|
||||||
set_default_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }>
|
set_default_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
|
delete_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }>
|
||||||
convert_excel_to_uk_txt?: (payload: ConvertTxtPayload) => Promise<ConvertTxtResponse>
|
convert_excel_to_uk_txt?: (payload: ConvertTxtPayload) => Promise<ConvertTxtResponse>
|
||||||
open_path?: (path: string) => Promise<{ success: boolean; error?: string }>
|
open_path?: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||||
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>
|
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { fetchVersion, startUpdate } from '@/shared/api/update'
|
|
||||||
|
|
||||||
export function useUpdateCheck() {
|
|
||||||
const loading = ref(false)
|
|
||||||
const version = ref('1.0.0')
|
|
||||||
const hint = ref('')
|
|
||||||
const downloadVisible = ref(false)
|
|
||||||
const downloadLoading = ref(false)
|
|
||||||
const fileUrl = ref('')
|
|
||||||
|
|
||||||
const check = async () => {
|
|
||||||
loading.value = true
|
|
||||||
hint.value = '正在检测更新...'
|
|
||||||
downloadVisible.value = false
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await fetchVersion()
|
|
||||||
version.value = (result.version || version.value).replace(/^v/i, '')
|
|
||||||
|
|
||||||
if (result.has_update && result.latest_version) {
|
|
||||||
hint.value = `发现新版本 v${result.latest_version}${result.desc ? `:${result.desc}` : ''}`
|
|
||||||
fileUrl.value = result.file_url || ''
|
|
||||||
downloadVisible.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hint.value = '已是最新版本'
|
|
||||||
} catch (error) {
|
|
||||||
hint.value = error instanceof Error ? error.message : '检测更新失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const runUpdate = async () => {
|
|
||||||
if (!fileUrl.value) {
|
|
||||||
hint.value = '暂无下载地址,请关注官方渠道。'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadLoading.value = true
|
|
||||||
hint.value = '正在下载并准备更新,程序将自动退出...'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await startUpdate(fileUrl.value)
|
|
||||||
if (result.success) {
|
|
||||||
hint.value = '更新已启动,程序即将退出...'
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
hint.value = result.error || '更新启动失败'
|
|
||||||
return false
|
|
||||||
} catch (error) {
|
|
||||||
hint.value = error instanceof Error ? error.message : '请求更新失败,请重试'
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
downloadLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading,
|
|
||||||
version,
|
|
||||||
hint,
|
|
||||||
downloadVisible,
|
|
||||||
downloadLoading,
|
|
||||||
check,
|
|
||||||
runUpdate,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { createApp } from 'vue'
|
|||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
import '@/styles/main.css'
|
import '@/styles/main.css'
|
||||||
import App from '@/App.vue'
|
import BrandSplitTab from '@/pages/brand/components/BrandSplitTab.vue'
|
||||||
import router from '@/router'
|
|
||||||
|
|
||||||
createApp(App).use(ElementPlus).use(router).mount('#app')
|
createApp(BrandSplitTab).use(ElementPlus).mount('#app')
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import AutoImport from 'unplugin-auto-import/vite'
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
@@ -6,6 +7,7 @@ import Components from 'unplugin-vue-components/vite'
|
|||||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
@@ -47,5 +49,19 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
dedupe: resolve(__dirname, 'dedupe.html'),
|
||||||
|
convert: resolve(__dirname, 'convert.html'),
|
||||||
|
split: resolve(__dirname, 'split.html'),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'assets/[name].js',
|
||||||
|
chunkFileNames: 'assets/[name]-[hash].js',
|
||||||
|
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ def brand_page():
|
|||||||
return _render_html('brand.html')
|
return _render_html('brand.html')
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/brand-tools')
|
||||||
|
@login_required
|
||||||
|
def brand_tools_page():
|
||||||
|
html_path = os.path.join(STATIC_DIR, 'brand-tools', 'brand-tools.html')
|
||||||
|
if os.path.isfile(html_path):
|
||||||
|
return send_file(html_path)
|
||||||
|
return _render_html('brand_tools.html')
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route('/static/<path:filename>')
|
@main_bp.route('/static/<path:filename>')
|
||||||
def serve_static(filename):
|
def serve_static(filename):
|
||||||
"""提供 static 目录及子目录下的静态文件访问。"""
|
"""提供 static 目录及子目录下的静态文件访问。"""
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user