This commit is contained in:
铭坤
2026-03-20 23:16:13 +08:00
120 changed files with 5095 additions and 4601 deletions

15
.gitignore vendored
View File

@@ -13,3 +13,18 @@ frontend-vue/npm-debug.log*
frontend-vue/yarn-debug.log*
frontend-vue/yarn-error.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
View 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>

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,8 @@
package com.nanri.aiimage.common.exception;
public class BusinessException extends RuntimeException {
public BusinessException(String message) {
super(message);
}
}

View File

@@ -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());
}
}

View File

@@ -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"));
}
}

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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> {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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+", " ");
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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+", " ");
}
}

View File

@@ -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));
}
}

View 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;
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) {
}
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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

View 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}

View 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'
);

View File

@@ -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';

View File

@@ -250,8 +250,8 @@ class DesktopConfig:
class DesktopApi:
def __init__(self, app_base_url: str) -> None:
self.app_base_url = app_base_url.rstrip("/")
def __init__(self, backend_base_url: str) -> None:
self.app_base_url = backend_base_url.rstrip("/")
self.session = requests.Session()
def select_brand_xlsx_files(self) -> list[str]:
@@ -347,17 +347,38 @@ class DesktopApi:
return {"success": False, "error": str(exc)}
def list_txt_templates(self) -> list[dict]:
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()
]
try:
response = self.session.get(
resolve_url(self.app_base_url, "/api/convert/templates"),
timeout=60,
)
response.raise_for_status()
payload = response.json()
if not payload.get("success"):
return []
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:
file_path = run_file_dialog(
@@ -370,39 +391,60 @@ class DesktopApi:
return {"success": False, "error": "用户取消"}
try:
template_text = Path(file_path).read_text(encoding="utf-8")
except UnicodeDecodeError:
template_text = Path(file_path).read_text(encoding="utf-8-sig")
try:
template_text = Path(file_path).read_text(encoding="utf-8")
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
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:
templates, _default_template_id = build_effective_templates()
if template_id not in templates:
return {"success": False, "error": "模板不存在"}
try:
response = self.session.post(
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()
save_user_templates(user_templates, template_id)
return {"success": True}
def delete_txt_template(self, template_id: str) -> dict:
try:
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:
paths = payload.get("paths") or []
@@ -410,7 +452,6 @@ class DesktopApi:
split_mode = payload.get("split_mode") or "rows_per_file"
rows_per_file = payload.get("rows_per_file")
parts = payload.get("parts")
output_dir = payload.get("output_dir") or ""
if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待拆分文件"}
@@ -422,128 +463,171 @@ class DesktopApi:
return {"success": False, "error": "每份条数不合法"}
if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0):
return {"success": False, "error": "拆分份数不合法"}
if not output_dir:
return {"success": False, "error": "未选择输出目录"}
output_dir_path = Path(output_dir)
if not output_dir_path.exists() or not output_dir_path.is_dir():
return {"success": False, "error": "输出目录不存在"}
items: list[dict] = []
success_count = 0
failed_count = 0
for path in paths:
try:
workbook = load_workbook(path)
sheet = workbook[workbook.sheetnames[0]]
header_cells = next(sheet.iter_rows(min_row=1, max_row=1))
header_map: dict[str, int] = {}
for index, cell in enumerate(header_cells):
value = normalize_cell_text(cell.value)
if not value:
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),
try:
uploaded_files = []
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,
})
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,
}
run_response = self.session.post(
resolve_url(self.app_base_url, "/api/split/run"),
json={
"files": uploaded_files,
"selectedColumns": selected_columns,
"splitMode": split_mode,
"rowsPerFile": rows_per_file,
"parts": parts,
},
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:
paths = payload.get("paths") or []
selected_columns = payload.get("selected_columns") or []
keep_integer_ids = bool(payload.get("keep_integer_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:
return {"success": False, "error": "未选择待处理文件"}
@@ -551,213 +635,140 @@ class DesktopApi:
return {"success": False, "error": "请至少选择一列"}
if not keep_integer_ids and not keep_underscore_ids:
return {"success": False, "error": "请至少选择一种 ID 保留规则"}
if not output_dir:
return {"success": False, "error": "未选择输出目录"}
output_dir_path = Path(output_dir)
if not output_dir_path.exists() or not output_dir_path.is_dir():
return {"success": False, "error": "输出目录不存在"}
try:
uploaded_files = []
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] = []
success_count = 0
failed_count = 0
run_response = self.session.post(
resolve_url(self.app_base_url, "/api/dedupe/run"),
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:
try:
workbook = load_workbook(path)
sheet = workbook[workbook.sheetnames[0]]
header_cells = next(sheet.iter_rows(min_row=1, max_row=1))
headers: list[str] = []
header_map: dict[str, int] = {}
for index, cell in enumerate(header_cells):
value = normalize_cell_text(cell.value)
if not value:
continue
value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value
if value in header_map:
continue
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)}",
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({
"source_path": item.get("sourceFilename") or "",
"output_path": item.get("downloadUrl") or "",
"success": True,
})
else:
result_items.append({
"source_path": item.get("sourceFilename") or "",
"success": False,
"error": item.get("error") or "去重失败",
})
failed_count += 1
continue
from openpyxl import Workbook
output_workbook = Workbook()
output_sheet = output_workbook.active
output_sheet.title = sheet.title
output_sheet.append(selected_columns)
id_column_index = header_map.get("id")
for row in sheet.iter_rows(min_row=2, values_only=True):
original_values = list(row)
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,
}
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 convert_excel_to_uk_txt(self, payload: dict) -> dict:
paths = payload.get("paths") or []
output_dir = payload.get("output_dir") or ""
template_id = payload.get("template_id") or "uk_offer"
if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待转换文件"}
if not output_dir:
return {"success": False, "error": "未选择输出目录"}
templates, _default_template_id = build_effective_templates()
template = templates.get(template_id)
if not template:
return {"success": False, "error": "模板不存在"}
try:
templates = self.list_txt_templates()
selected_template = next((item for item in templates if item.get("id") == template_id), None)
if not selected_template:
return {"success": False, "error": "模板不存在"}
output_dir_path = Path(output_dir)
if not output_dir_path.exists() or not output_dir_path.is_dir():
return {"success": False, "error": "输出目录不存在"}
uploaded_files = []
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] = []
success_count = 0
failed_count = 0
run_response = self.session.post(
resolve_url(self.app_base_url, "/api/convert/run"),
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:
try:
workbook = load_workbook(path, read_only=True, data_only=True)
sheet = workbook[workbook.sheetnames[0]]
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
if not header_row:
workbook.close()
items.append({"source_path": path, "success": False, "error": 'Excel 表头为空'})
failed_count += 1
continue
header_map: dict[str, int] = {}
for index, cell in enumerate(header_row):
value = normalize_cell_text(cell)
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)}",
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({
"source_path": item.get("sourceFilename") or "",
"output_path": item.get("downloadUrl") or "",
"success": True,
})
else:
result_items.append({
"source_path": item.get("sourceFilename") or "",
"success": False,
"error": item.get("error") or "格式转换失败",
})
failed_count += 1
continue
rows: list[str] = []
rows.extend(template["preamble_lines"])
rows.append('\t'.join(template["header_columns"]))
for _ in range(template.get("blank_header_rows_after_schema", 0)):
rows.append('\t' * (len(template["header_columns"]) - 1))
batch_id = generate_snowflake_like_id()
row_index = 1
for row in sheet.iter_rows(min_row=2, values_only=True):
line_values: list[str] = []
asin = normalize_cell_text(row[header_map['ASIN']] if header_map['ASIN'] < len(row) else '')
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,
}
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 open_path(self, path: str) -> dict:
try:
@@ -939,7 +950,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--port", type=int, default=0)
parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=960)
parser.add_argument("--title", default="南日AI")
parser.add_argument("--title", default="数富AI")
return parser
@@ -985,7 +996,7 @@ def main() -> None:
ensure_dev_server_ready(config.frontend_url)
app_url = config.frontend_url
api = DesktopApi(app_url)
api = DesktopApi(config.backend_url)
window = webview.create_window(
title=config.title,
url=app_url,

View File

@@ -64,4 +64,4 @@ Write-Host "Ensuring desktop dependencies..."
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
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")

View File

@@ -90,6 +90,51 @@
"price": "价格",
"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
View 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
View 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>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>南日AI</title>
<title>数富AI</title>
</head>
<body>
<div id="app"></div>

12
frontend-vue/split.html Normal file
View 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>

View File

@@ -1,3 +0,0 @@
<template>
<router-view />
</template>

View 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')

View 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')

View File

@@ -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>

View File

@@ -41,13 +41,23 @@
:class="{ active: convertTemplateId === template.id }"
>
<input v-model="convertTemplateId" type="radio" :value="template.id" name="convert-template" />
<div>
<span class="label">
{{ template.label }}
<span v-if="template.is_default" class="template-tag">默认</span>
<span v-else-if="template.built_in" class="template-tag muted">内置</span>
</span>
<div class="desc">输出文件{{ template.output_filename }}</div>
<div class="template-item-content">
<div>
<span class="label">
{{ template.label }}
<span v-if="template.is_default" class="template-tag">默认</span>
<span v-else-if="template.built_in" class="template-tag muted">内置</span>
</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>
</label>
</div>
@@ -81,7 +91,7 @@
{{ convertRunning ? '转换中...' : '开始转换' }}
</button>
<span class="loading-msg">
{{ convertRunning ? '正在生成英国.txt,请稍候…' : '选择文件后即可执行格式转换' }}
{{ convertRunning ? '正在生成并上传结果,请稍候…' : '选择文件后即可执行格式转换,结果会显示在右侧供下载' }}
</span>
</div>
</aside>
@@ -108,9 +118,6 @@
<div class="result-list-wrap">
<div class="result-list-header">
<span>生成的 txt 列表</span>
<button type="button" class="download-all" :disabled="!convertOutputDir" @click="openConvertOutputDir">
打开输出目录
</button>
</div>
<div v-if="convertResultItems.length === 0" class="empty-tasks">
@@ -120,12 +127,12 @@
<ul v-else class="task-list clean-result-list">
<li
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"
>
<div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div>
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
@@ -137,9 +144,17 @@
v-if="item.success && item.output_path"
type="button"
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>
</div>
</li>
@@ -175,7 +190,6 @@ function shorten(value: string, maxLength: number) {
}
function resetConvertResults() {
convertResultItems.value = []
convertSummary.value = { total: 0, success_count: 0, failed_count: 0 }
convertOutputDir.value = ''
}
@@ -217,6 +231,26 @@ async function importConvertTemplate() {
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() {
const api = getPywebviewApi()
if (!api?.set_default_txt_template || !convertTemplateId.value) {
@@ -279,7 +313,7 @@ async function selectConvertFolder() {
async function submitConvertRun() {
const api = getPywebviewApi()
if (!api?.convert_excel_to_uk_txt || !api?.select_folder) {
if (!api?.convert_excel_to_uk_txt) {
ElMessage.warning('当前环境不支持格式转换,请在桌面端打开')
return
}
@@ -289,13 +323,9 @@ async function submitConvertRun() {
}
try {
const outputDir = await api.select_folder()
if (!outputDir) return
convertRunning.value = true
const result = await api.convert_excel_to_uk_txt({
paths: convertSelectedPaths.value,
output_dir: outputDir,
template_id: convertTemplateId.value,
})
@@ -304,13 +334,13 @@ async function submitConvertRun() {
return
}
convertOutputDir.value = result.summary?.output_dir || outputDir
convertOutputDir.value = ''
convertSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
convertResultItems.value = result.items || []
await loadConvertHistory()
ElMessage.success('格式转换完成')
} catch (error) {
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()
if (!api?.open_path || !convertOutputDir.value) {
ElMessage.warning('当前环境不支持打开输出目录')
if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持删除历史')
return
}
const result = await api.open_path(convertOutputDir.value)
const result = await api.delete_history_item('convert', resultId)
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()
if (!api?.open_path || !item.output_path) {
ElMessage.warning('当前环境不支持打开结果文件')
if (!item.output_path) {
ElMessage.warning('当前结果没有下载地址')
return
}
const result = await api.open_path(item.output_path)
if (!result.success) {
ElMessage.error(result.error || '打开文件失败')
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}.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(() => {
loadConvertTemplates().catch(() => undefined)
loadConvertHistory().catch(() => undefined)
})
</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.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); }
.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 input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; }

View File

@@ -79,7 +79,7 @@
{{ cleanRunning ? '清洗中...' : '开始清洗' }}
</button>
<span class="loading-msg">
{{ cleanRunning ? '正在处理文件,请稍候…' : '选择文件、列和 ID 规则后即可执行本地清洗' }}
{{ cleanRunning ? '正在处理文件并上传结果,请稍候…' : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
</span>
</div>
</aside>
@@ -106,9 +106,6 @@
<div class="result-list-wrap">
<div class="result-list-header">
<span>处理后的 Excel 列表</span>
<button type="button" class="download-all" :disabled="!cleanOutputDir" @click="openCleanOutputDir">
打开输出目录
</button>
</div>
<div v-if="cleanResultItems.length === 0" class="empty-tasks">
@@ -118,8 +115,8 @@
<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">
<div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div>
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">结果下载地址已生成</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div>
@@ -131,9 +128,17 @@
v-if="item.success && item.output_path"
type="button"
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>
</div>
</li>
@@ -145,7 +150,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { expandBrandFolder } from '@/shared/api/brand'
import { type CleanExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
@@ -181,7 +186,6 @@ async function loadCleanHeaders(filePath: string) {
return
}
cleanResultItems.value = []
cleanSummary.value = { total: 0, success_count: 0, failed_count: 0 }
cleanOutputDir.value = ''
@@ -248,7 +252,7 @@ async function selectCleanFolder() {
async function submitCleanRun() {
const api = getPywebviewApi()
if (!api?.clean_excel_files || !api?.select_folder) {
if (!api?.clean_excel_files) {
ElMessage.warning('当前环境不支持数据去重,请在桌面端打开')
return
}
@@ -266,16 +270,12 @@ async function submitCleanRun() {
}
try {
const outputDir = await api.select_folder()
if (!outputDir) return
cleanRunning.value = true
const result = await api.clean_excel_files({
paths: cleanSelectedPaths.value,
selected_columns: cleanSelectedColumns.value,
keep_integer_ids: cleanKeepIntegerIds.value,
keep_underscore_ids: cleanKeepUnderscoreIds.value,
output_dir: outputDir,
})
if (!result.success) {
@@ -283,13 +283,13 @@ async function submitCleanRun() {
return
}
cleanOutputDir.value = result.summary?.output_dir || outputDir
cleanOutputDir.value = ''
cleanSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
cleanResultItems.value = result.items || []
await loadCleanHistory()
ElMessage.success('数据去重完成')
} catch (error) {
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()
if (!api?.open_path || !cleanOutputDir.value) {
ElMessage.warning('当前环境不支持打开输出目录')
if (!api?.get_dedupe_history) {
return
}
const result = await api.open_path(cleanOutputDir.value)
if (!result.success) {
ElMessage.error(result.error || '打开目录失败')
try {
const response = await api.get_dedupe_history()
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()
if (!api?.open_path || !item.output_path) {
ElMessage.warning('当前环境不支持打开结果文件')
if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持删除历史')
return
}
const result = await api.open_path(item.output_path)
const result = await api.delete_history_item('dedupe', resultId)
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>

View File

@@ -90,7 +90,7 @@
{{ splitRunning ? '拆分中...' : '开始拆分' }}
</button>
<span class="loading-msg">
{{ splitRunning ? '正在拆分文件,请稍候…' : '选择文件、保留列和拆分规则后即可执行本地拆分' }}
{{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
</span>
</div>
</aside>
@@ -116,9 +116,6 @@
<div class="result-list-wrap">
<div class="result-list-header">
<span>拆分后的 Excel 列表</span>
<button type="button" class="download-all" :disabled="!splitOutputDir" @click="openSplitOutputDir">
打开输出目录
</button>
</div>
<div v-if="splitResultItems.length === 0" class="empty-tasks">
@@ -128,8 +125,8 @@
<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">
<div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div>
<span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">结果下载地址已生成</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>
@@ -142,9 +139,17 @@
v-if="item.success && item.output_path"
type="button"
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>
</div>
</li>
@@ -156,7 +161,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { expandBrandFolder } from '@/shared/api/brand'
import { type SplitExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
@@ -179,7 +184,6 @@ function shorten(value: string, maxLength: number) {
}
function resetSplitResults() {
splitResultItems.value = []
splitSummary.value = { total: 0, success_count: 0, failed_count: 0 }
splitOutputDir.value = ''
}
@@ -251,7 +255,7 @@ async function selectSplitFolder() {
async function submitSplitRun() {
const api = getPywebviewApi()
if (!api?.split_excel_files || !api?.select_folder) {
if (!api?.split_excel_files) {
ElMessage.warning('当前环境不支持数据拆分,请在桌面端打开')
return
}
@@ -273,9 +277,6 @@ async function submitSplitRun() {
}
try {
const outputDir = await api.select_folder()
if (!outputDir) return
splitRunning.value = true
const result = await api.split_excel_files({
paths: splitSelectedPaths.value,
@@ -283,7 +284,6 @@ async function submitSplitRun() {
split_mode: splitMode.value,
rows_per_file: splitRowsPerFile.value || undefined,
parts: splitParts.value || undefined,
output_dir: outputDir,
})
if (!result.success) {
@@ -291,13 +291,13 @@ async function submitSplitRun() {
return
}
splitOutputDir.value = result.summary?.output_dir || outputDir
splitOutputDir.value = ''
splitSummary.value = {
total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0,
}
splitResultItems.value = result.items || []
await loadSplitHistory()
ElMessage.success('数据拆分完成')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据拆分失败')
@@ -306,30 +306,67 @@ async function submitSplitRun() {
}
}
async function openSplitOutputDir() {
async function loadSplitHistory() {
const api = getPywebviewApi()
if (!api?.open_path || !splitOutputDir.value) {
ElMessage.warning('当前环境不支持打开输出目录')
return
}
if (!api?.get_split_history) return
const result = await api.open_path(splitOutputDir.value)
if (!result.success) {
ElMessage.error(result.error || '打开目录失败')
try {
const response = await api.get_split_history()
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()
if (!api?.open_path || !item.output_path) {
ElMessage.warning('当前环境不支持打开结果文件')
if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持删除历史')
return
}
const result = await api.open_path(item.output_path)
const result = await api.delete_history_item('split', resultId)
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>

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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`)
}

View File

@@ -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
}
}

View File

@@ -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 })
}

View File

@@ -4,10 +4,11 @@ export interface SplitExcelPayload {
split_mode: 'rows_per_file' | 'parts'
rows_per_file?: number
parts?: number
output_dir: string
output_dir?: string
}
export interface SplitExcelResultItem {
result_id?: number
source_path: string
output_path?: string
success: boolean
@@ -36,7 +37,16 @@ export interface ExcelInfoResponse {
error?: string
}
export interface CleanExcelPayload {
paths: string[]
selected_columns: string[]
keep_integer_ids?: boolean
keep_underscore_ids?: boolean
output_dir?: string
}
export interface CleanExcelResultItem {
result_id?: number
source_path: string
output_path?: string
success: boolean
@@ -45,7 +55,7 @@ export interface CleanExcelResultItem {
export interface ConvertTxtPayload {
paths: string[]
output_dir: string
output_dir?: string
template_id: string
}
@@ -58,6 +68,7 @@ export interface ConvertTxtTemplateItem {
}
export interface ConvertTxtResultItem {
result_id?: number
source_path: string
output_path?: string
success: boolean
@@ -92,6 +103,24 @@ export interface CleanExcelResponse {
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 {
select_brand_xlsx_files?: () => Promise<string[]>
select_clean_xlsx_files?: () => Promise<string[]>
@@ -99,12 +128,17 @@ export interface PywebviewApi {
select_clean_folder?: () => Promise<string | null>
select_folder?: () => Promise<string | null>
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>
clean_excel_files?: (payload: CleanExcelPayload) => Promise<CleanExcelResponse>
split_excel_files?: (payload: SplitExcelPayload) => Promise<SplitExcelResponse>
list_txt_templates?: () => Promise<ConvertTxtTemplateItem[]>
import_txt_template?: (name?: string) => Promise<{ success: boolean; template?: ConvertTxtTemplateItem; 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>
open_path?: (path: string) => Promise<{ success: boolean; error?: string }>
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>

View File

@@ -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,
}
}

View File

@@ -2,7 +2,6 @@ import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import '@/styles/main.css'
import App from '@/App.vue'
import router from '@/router'
import BrandSplitTab from '@/pages/brand/components/BrandSplitTab.vue'
createApp(App).use(ElementPlus).use(router).mount('#app')
createApp(BrandSplitTab).use(ElementPlus).mount('#app')

View File

@@ -1,4 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
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'
export default defineConfig({
base: './',
plugins: [
vue(),
AutoImport({
@@ -47,5 +49,19 @@ export default defineConfig({
},
build: {
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]',
},
},
},
})

View File

@@ -1,73 +1,73 @@
import argparse
import base64
import re
import time
import alibabacloud_oss_v2 as oss
import requests
from config import region, endpoint, bucket, file_url_pre, bucket_path
def upload_file(file_content: bytes, key: str):
credentials_provider = oss.credentials.EnvironmentVariableCredentialsProvider()
cfg = oss.config.load_default()
cfg.credentials_provider = credentials_provider
cfg.region = region
cfg.endpoint = endpoint
cfg.retry_max_attempts = 3
client = oss.Client(cfg)
result = client.put_object(
oss.PutObjectRequest(
bucket=bucket, # 存储空间名称
key=key, # 对象名称
body=file_content # 读取文件内容
)
)
# print(result)
return file_url_pre + key
def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str:
"""
base64 data URL 上传到 OSS返回图片链接
data_url: data:image/png;base64,xxxx data:image/jpeg;base64,xxxx
prefix: OSS key 前缀
key_hint: 可选后缀避免重名 "_0", "_1"
"""
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
raise ValueError('无效的 data URL 格式')
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
file_content = base64.b64decode(match.group(2))
ts = int(time.time() * 1000)
key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}"
return upload_file(file_content, key)
def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
"""批量上传 base64 图片到 OSS返回图片链接列表"""
urls = []
ts = int(time.time() * 1000)
for i, data_url in enumerate(data_urls or []):
if not data_url or not isinstance(data_url, str):
continue
if not data_url.startswith("http"):
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
continue
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
file_content = base64.b64decode(match.group(2))
else:
file_content = requests.get(data_url).content
ext = "png"
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
urls.append(upload_file(file_content, key))
return urls
# 脚本入口当文件被直接运行时调用main函数
if __name__ == "__main__":
with open("测试图片数据/IMG_2685.JPG", "rb") as f:
file_content = f.read()
import argparse
import base64
import re
import time
import alibabacloud_oss_v2 as oss
import requests
from config import region, endpoint, bucket, file_url_pre, bucket_path
def upload_file(file_content: bytes, key: str):
credentials_provider = oss.credentials.EnvironmentVariableCredentialsProvider()
cfg = oss.config.load_default()
cfg.credentials_provider = credentials_provider
cfg.region = region
cfg.endpoint = endpoint
cfg.retry_max_attempts = 3
client = oss.Client(cfg)
result = client.put_object(
oss.PutObjectRequest(
bucket=bucket, # 存储空间名称
key=key, # 对象名称
body=file_content # 读取文件内容
)
)
# print(result)
return file_url_pre + key
def upload_data_url(data_url: str, prefix: str = "history", key_hint: str = "") -> str:
"""
base64 data URL 上传到 OSS返回图片链接
data_url: data:image/png;base64,xxxx data:image/jpeg;base64,xxxx
prefix: OSS key 前缀
key_hint: 可选后缀避免重名 "_0", "_1"
"""
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
raise ValueError('无效的 data URL 格式')
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
file_content = base64.b64decode(match.group(2))
ts = int(time.time() * 1000)
key = f"{bucket_path}{prefix}/{ts}{key_hint}.{ext}"
return upload_file(file_content, key)
def upload_data_urls(data_urls: list, prefix: str = "history") -> list:
"""批量上传 base64 图片到 OSS返回图片链接列表"""
urls = []
ts = int(time.time() * 1000)
for i, data_url in enumerate(data_urls or []):
if not data_url or not isinstance(data_url, str):
continue
if not data_url.startswith("http"):
match = re.match(r'data:image/(\w+);base64,(.+)', data_url)
if not match:
continue
ext = 'png' if match.group(1).lower() in ('png', 'webp') else 'jpg'
file_content = base64.b64decode(match.group(2))
else:
file_content = requests.get(data_url).content
ext = "png"
key = f"{bucket_path}{prefix}/{ts}_{i}.{ext}"
urls.append(upload_file(file_content, key))
return urls
# 脚本入口当文件被直接运行时调用main函数
if __name__ == "__main__":
with open("测试图片数据/IMG_2685.JPG", "rb") as f:
file_content = f.read()
upload_file(file_content,key=bucket_path+"test.png")

View File

@@ -1,261 +1,261 @@
"""
公共模块数据库连接初始化会话校验装饰器模板渲染
供各蓝图复用
"""
import os
import secrets
from datetime import timedelta
from functools import wraps
import pymysql
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
from config import mysql_host, mysql_user, mysql_password, mysql_database
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, 'static')
def get_db():
return pymysql.connect(
host=mysql_host,
user=mysql_user,
password=mysql_password,
database=mysql_database,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
def _render_html(template_name: str, **context):
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
path = os.path.join(BASE_DIR, "web_source", template_name)
if not os.path.isfile(path):
return render_template(template_name, **context)
with open(path, "rb") as f:
raw = f.read()
try:
from html_crypto import decrypt
content = decrypt(raw).decode("utf-8")
except Exception:
content = raw.decode("utf-8", errors="replace")
return render_template_string(content, **context)
def _is_session_user_valid():
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
uid = session.get('user_id')
if not uid:
return False
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
row = cur.fetchone()
conn.close()
if not row:
session.clear()
return False
return True
except Exception:
session.clear()
return False
def _get_current_admin_role():
"""获取当前登录用户的管理角色super_admin / admin / None非管理员"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(session['user_id'],)
)
row = cur.fetchone()
conn.close()
if not row or not row.get('is_admin'):
return None, None
return row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin'), row
except Exception:
return None, None
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id') or not _is_session_user_valid():
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
if not row or not row.get('is_admin'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
return redirect(url_for('main.home'))
except Exception as e:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': str(e)}), 500
return redirect(url_for('main.home'))
return f(*args, **kwargs)
return decorated
def init_db():
"""初始化数据库表,若不存在则创建"""
from werkzeug.security import generate_password_hash
conn = pymysql.connect(
host=mysql_host,
user=mysql_user,
password=mysql_password,
charset='utf8mb4'
)
try:
with conn.cursor() as cur:
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4")
cur.execute(f"USE `{mysql_database}`")
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(256) NOT NULL,
is_admin TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS image_history (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
panel_type VARCHAR(64) DEFAULT '',
original_urls JSON,
params JSON,
result_urls JSON,
long_image_url VARCHAR(1024) DEFAULT NULL,
INDEX idx_user_created (user_id, created_at DESC)
)
""")
try:
cur.execute("ALTER TABLE image_history ADD COLUMN long_image_url VARCHAR(1024) DEFAULT NULL")
except Exception:
pass
try:
cur.execute("ALTER TABLE users ADD COLUMN machine VARCHAR(64) DEFAULT NULL")
except Exception:
pass
try:
cur.execute("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'normal'")
except Exception:
pass
try:
cur.execute("ALTER TABLE users ADD COLUMN created_by_id INT NULL")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS brand_crawl_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
file_paths JSON NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行',
result_paths JSON NULL,
error_message TEXT NULL,
progress_current INT DEFAULT 0,
progress_total INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_status (user_id, status)
)
""")
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_current INT DEFAULT 0")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_total INT DEFAULT 0")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN `desc` VARCHAR(500) NULL COMMENT '上传文件名描述'")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN strategy VARCHAR(20) DEFAULT 'Terms' COMMENT '品牌匹配方式: Terms/Simple'")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行'")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS columns (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL COMMENT '栏目名',
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_column_key (column_key)
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,
column_id INT NOT NULL,
PRIMARY KEY (user_id, column_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
)
""")
try:
cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)")
cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1")
row = cur.fetchone()
if row and row.get('mid'):
mid = row['mid']
cur.execute("UPDATE users SET role = 'super_admin' WHERE id = %s", (mid,))
cur.execute("UPDATE users SET role = 'admin' WHERE is_admin = 1 AND id != %s", (mid,))
cur.execute("UPDATE users SET created_by_id = %s WHERE role = 'admin' AND (created_by_id IS NULL)", (mid,))
except Exception:
pass
conn.commit()
finally:
conn.close()
_create_initial_admin()
def _create_initial_admin():
"""若没有任何管理员,则创建默认超级管理员(首次启动时,仅一个)"""
from werkzeug.security import generate_password_hash
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
if cur.fetchone():
conn.close()
return
admin_user = os.environ.get('ADMIN_USER', 'admin')
admin_pwd = os.environ.get('ADMIN_PASSWORD', 'admin123')
pwd_hash = generate_password_hash(admin_pwd, method='pbkdf2:sha256')
cur.execute(
"INSERT INTO users (username, password_hash, is_admin, role) VALUES (%s, %s, 1, 'super_admin')",
(admin_user, pwd_hash)
)
conn.commit()
conn.close()
except Exception:
pass
"""
公共模块数据库连接初始化会话校验装饰器模板渲染
供各蓝图复用
"""
import os
import secrets
from datetime import timedelta
from functools import wraps
import pymysql
from flask import request, redirect, url_for, session, jsonify, render_template, render_template_string
from config import mysql_host, mysql_user, mysql_password, mysql_database
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_DIR = os.path.join(BASE_DIR, 'static')
def get_db():
return pymysql.connect(
host=mysql_host,
user=mysql_user,
password=mysql_password,
database=mysql_database,
charset='utf8mb4',
cursorclass=pymysql.cursors.DictCursor
)
def _render_html(template_name: str, **context):
"""读取 HTML 模板:若为加密文件则先解密,再渲染。未加密或解密失败时按明文渲染。"""
path = os.path.join(BASE_DIR, "web_source", template_name)
if not os.path.isfile(path):
return render_template(template_name, **context)
with open(path, "rb") as f:
raw = f.read()
try:
from html_crypto import decrypt
content = decrypt(raw).decode("utf-8")
except Exception:
content = raw.decode("utf-8", errors="replace")
return render_template_string(content, **context)
def _is_session_user_valid():
"""校验 session 中的 user_id 是否在数据库中仍存在;不存在则清除 session 并返回 False"""
uid = session.get('user_id')
if not uid:
return False
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE id = %s", (uid,))
row = cur.fetchone()
conn.close()
if not row:
session.clear()
return False
return True
except Exception:
session.clear()
return False
def _get_current_admin_role():
"""获取当前登录用户的管理角色super_admin / admin / None非管理员"""
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, username, is_admin, role, created_by_id FROM users WHERE id = %s",
(session['user_id'],)
)
row = cur.fetchone()
conn.close()
if not row or not row.get('is_admin'):
return None, None
return row.get('role') or ('super_admin' if row.get('created_by_id') is None else 'admin'), row
except Exception:
return None, None
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id') or not _is_session_user_valid():
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('user_id'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '未登录'}), 401
return redirect(url_for('auth.login'))
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT is_admin, role FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
if not row or not row.get('is_admin'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
return redirect(url_for('main.home'))
except Exception as e:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'success': False, 'error': str(e)}), 500
return redirect(url_for('main.home'))
return f(*args, **kwargs)
return decorated
def init_db():
"""初始化数据库表,若不存在则创建"""
from werkzeug.security import generate_password_hash
conn = pymysql.connect(
host=mysql_host,
user=mysql_user,
password=mysql_password,
charset='utf8mb4'
)
try:
with conn.cursor() as cur:
cur.execute(f"CREATE DATABASE IF NOT EXISTS `{mysql_database}` DEFAULT CHARSET utf8mb4")
cur.execute(f"USE `{mysql_database}`")
cur.execute("""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(256) NOT NULL,
is_admin TINYINT(1) DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS image_history (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
panel_type VARCHAR(64) DEFAULT '',
original_urls JSON,
params JSON,
result_urls JSON,
long_image_url VARCHAR(1024) DEFAULT NULL,
INDEX idx_user_created (user_id, created_at DESC)
)
""")
try:
cur.execute("ALTER TABLE image_history ADD COLUMN long_image_url VARCHAR(1024) DEFAULT NULL")
except Exception:
pass
try:
cur.execute("ALTER TABLE users ADD COLUMN machine VARCHAR(64) DEFAULT NULL")
except Exception:
pass
try:
cur.execute("ALTER TABLE users ADD COLUMN role VARCHAR(20) DEFAULT 'normal'")
except Exception:
pass
try:
cur.execute("ALTER TABLE users ADD COLUMN created_by_id INT NULL")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS brand_crawl_tasks (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
file_paths JSON NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行',
result_paths JSON NULL,
error_message TEXT NULL,
progress_current INT DEFAULT 0,
progress_total INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_status (user_id, status)
)
""")
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_current INT DEFAULT 0")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN progress_total INT DEFAULT 0")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN `desc` VARCHAR(500) NULL COMMENT '上传文件名描述'")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN strategy VARCHAR(20) DEFAULT 'Terms' COMMENT '品牌匹配方式: Terms/Simple'")
except Exception:
pass
try:
cur.execute("ALTER TABLE brand_crawl_tasks ADD COLUMN task_type TINYINT DEFAULT 1 COMMENT '1=立即执行,2=添加任务执行'")
except Exception:
pass
cur.execute("""
CREATE TABLE IF NOT EXISTS columns (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL COMMENT '栏目名',
column_key VARCHAR(64) NOT NULL COMMENT '栏目标识',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_column_key (column_key)
)
""")
cur.execute("""
CREATE TABLE IF NOT EXISTS user_column_permission (
user_id INT NOT NULL,
column_id INT NOT NULL,
PRIMARY KEY (user_id, column_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
)
""")
try:
cur.execute("UPDATE users SET role = 'normal' WHERE (role IS NULL OR role = '') AND (is_admin = 0 OR is_admin IS NULL)")
cur.execute("SELECT MIN(id) AS mid FROM users WHERE is_admin = 1")
row = cur.fetchone()
if row and row.get('mid'):
mid = row['mid']
cur.execute("UPDATE users SET role = 'super_admin' WHERE id = %s", (mid,))
cur.execute("UPDATE users SET role = 'admin' WHERE is_admin = 1 AND id != %s", (mid,))
cur.execute("UPDATE users SET created_by_id = %s WHERE role = 'admin' AND (created_by_id IS NULL)", (mid,))
except Exception:
pass
conn.commit()
finally:
conn.close()
_create_initial_admin()
def _create_initial_admin():
"""若没有任何管理员,则创建默认超级管理员(首次启动时,仅一个)"""
from werkzeug.security import generate_password_hash
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE role = 'super_admin' LIMIT 1")
if cur.fetchone():
conn.close()
return
admin_user = os.environ.get('ADMIN_USER', 'admin')
admin_pwd = os.environ.get('ADMIN_PASSWORD', 'admin123')
pwd_hash = generate_password_hash(admin_pwd, method='pbkdf2:sha256')
cur.execute(
"INSERT INTO users (username, password_hash, is_admin, role) VALUES (%s, %s, 1, 'super_admin')",
(admin_user, pwd_hash)
)
conn.commit()
conn.close()
except Exception:
pass

View File

@@ -1 +1 @@
# 蓝图包
# 蓝图包

View File

@@ -1,362 +1,362 @@
"""
管理员蓝图用户管理列表/创建/更新/删除生成历史管理页
"""
import json
import pymysql
from flask import Blueprint, request, jsonify
from werkzeug.security import generate_password_hash
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
from flask import session
admin_bp = Blueprint('admin', __name__)
def _parse_json(val, default=None):
if val is None:
return default if default is not None else []
if isinstance(val, (list, dict)):
return val
try:
return json.loads(val)
except Exception:
return default if default is not None else []
@admin_bp.route('/admin')
@login_required
@admin_required
def admin_page():
return _render_html('admin.html')
@admin_bp.route('/api/admin/users')
@admin_required
def admin_list_users():
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
if role != 'super_admin':
created_by_id = None
try:
conn = get_db()
with conn.cursor() as cur:
if role == 'super_admin':
where_parts = ["1=1"]
params = []
if search_username:
where_parts.append("u.username LIKE %s")
params.append("%" + search_username + "%")
if created_by_id is not None:
where_parts.append("u.created_by_id = %s")
params.append(created_by_id)
where_sql = " AND ".join(where_parts)
cur.execute(
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
creator.username AS creator_username
FROM users u
LEFT JOIN users creator ON creator.id = u.created_by_id
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
tuple(params) + (page_size, offset),
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
total = cur.fetchone()['total']
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
else:
admin_id = current_row['id']
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
params = [admin_id, admin_id]
if search_username:
where_parts.append("u.username LIKE %s")
params.append("%" + search_username + "%")
where_sql = " AND ".join(where_parts)
cur.execute(
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
creator.username AS creator_username
FROM users u
LEFT JOIN users creator ON creator.id = u.created_by_id
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
tuple(params) + (page_size, offset),
)
rows = cur.fetchall()
cur.execute(
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
tuple(params),
)
total = cur.fetchone()['total']
admins = []
items = [
{
'id': r['id'],
'username': r['username'],
'is_admin': bool(r.get('is_admin')),
'role': r.get('role') or 'normal',
'created_by_id': r.get('created_by_id'),
'creator_username': r.get('creator_username') or '',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
for r in rows
]
conn.close()
payload = {
'success': True,
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'current_user_role': role,
'admins': admins,
}
return jsonify(payload)
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user', methods=['POST'])
@admin_required
def admin_create_user():
data = request.get_json() or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
want_role = (data.get('role') or 'normal').strip() or 'normal'
if want_role not in ('admin', 'normal'):
want_role = 'normal'
if role == 'admin' and want_role == 'admin':
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
if want_role == 'admin':
want_created_by = current_row['id']
elif role == 'super_admin':
want_created_by = data.get('created_by_id')
else:
want_created_by = current_row['id']
if not username or not password:
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
if len(username) < 2:
return jsonify({'success': False, 'error': '用户名至少2个字符'})
if len(password) < 6:
return jsonify({'success': False, 'error': '密码至少6个字符'})
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
r = cur.fetchone()
conn.close()
want_created_by = r['id'] if r else current_row['id']
except Exception:
want_created_by = current_row['id']
if want_role == 'normal' and want_created_by is None:
want_created_by = current_row['id']
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
(username, pwd_hash, is_admin, want_role, want_created_by),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '用户创建成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
@admin_required
def admin_update_user(uid):
data = request.get_json() or {}
password = data.get('password')
want_role = (data.get('role') or '').strip() or data.get('role')
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
if want_role is None and not password:
return jsonify({'success': False, 'error': '请提供要修改的内容'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
target = cur.fetchone()
if not target:
conn.close()
return jsonify({'success': False, 'error': '用户不存在'})
if role == 'admin':
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
conn.close()
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
want_role = None
else:
if target.get('role') == 'super_admin':
conn.close()
return jsonify({'success': False, 'error': '不能修改超级管理员'})
if want_role == 'super_admin':
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
if want_role not in ('admin', 'normal', None, ''):
want_role = None
if password:
if len(password) < 6:
conn.close()
return jsonify({'success': False, 'error': '密码至少6个字符'})
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
if want_role is not None and want_role != '':
is_admin = 1 if want_role == 'admin' else 0
cur.execute(
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
(is_admin, want_role, uid),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
@admin_required
def admin_delete_user(uid):
from flask import session
if session.get('user_id') == uid:
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
target = cur.fetchone()
if not target:
conn.close()
return jsonify({'success': False, 'error': '用户不存在'})
if target.get('role') == 'super_admin':
conn.close()
return jsonify({'success': False, 'error': '不能删除超级管理员'})
if role == 'admin':
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
conn.close()
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
affected = cur.rowcount
conn.commit()
conn.close()
if affected == 0:
return jsonify({'success': False, 'error': '用户不存在'})
return jsonify({'success': True, 'msg': '删除成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
@login_required
def admin_user_column_permissions(uid):
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
current_uid = session.get('user_id')
if current_uid != uid:
role, _ = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
user_row = cur.fetchone()
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
cur.execute("""
SELECT id, name, column_key, created_at FROM columns ORDER BY id
""")
rows = cur.fetchall()
else:
cur.execute("""
SELECT c.id, c.name, c.column_key, c.created_at
FROM columns c
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
WHERE ucp.user_id = %s
ORDER BY c.id
""", (uid,))
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'name': r['name'],
'column_key': r['column_key'],
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
for r in rows
]
return jsonify({'success': True, 'items': items})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/history')
@admin_required
def admin_history():
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
user_id = request.args.get('user_id', type=int)
time_start = (request.args.get('time_start') or '').strip()
time_end = (request.args.get('time_end') or '').strip()
conditions, params = [], []
if user_id:
conditions.append("h.user_id = %s")
params.append(user_id)
if time_start:
conditions.append("h.created_at >= %s")
params.append(time_start)
if time_end:
conditions.append("h.created_at <= %s")
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
where_clause = " AND ".join(conditions) if conditions else "1=1"
params_count = params[:]
params.extend([page_size, offset])
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
h.long_image_url, u.username
FROM image_history h
LEFT JOIN users u ON h.user_id = u.id
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
params,
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
total = cur.fetchone()['total']
conn.close()
items = []
for r in rows:
items.append({
'id': r['id'],
'user_id': r['user_id'],
'username': r.get('username') or '-',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
'panel_type': r['panel_type'] or '',
'original_urls': _parse_json(r['original_urls'], []),
'params': _parse_json(r['params'], {}),
'result_urls': _parse_json(r['result_urls'], []),
'long_image_url': (r.get('long_image_url') or '').strip() or None,
})
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
"""
管理员蓝图用户管理列表/创建/更新/删除生成历史管理页
"""
import json
import pymysql
from flask import Blueprint, request, jsonify
from werkzeug.security import generate_password_hash
from app_common import get_db, _render_html, _get_current_admin_role, admin_required, login_required
from flask import session
admin_bp = Blueprint('admin', __name__)
def _parse_json(val, default=None):
if val is None:
return default if default is not None else []
if isinstance(val, (list, dict)):
return val
try:
return json.loads(val)
except Exception:
return default if default is not None else []
@admin_bp.route('/admin')
@login_required
@admin_required
def admin_page():
return _render_html('admin.html')
@admin_bp.route('/api/admin/users')
@admin_required
def admin_list_users():
"""分页获取用户列表;支持用户名模糊搜索、指定管理员所属普通用户筛选"""
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(5, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
if role != 'super_admin':
created_by_id = None
try:
conn = get_db()
with conn.cursor() as cur:
if role == 'super_admin':
where_parts = ["1=1"]
params = []
if search_username:
where_parts.append("u.username LIKE %s")
params.append("%" + search_username + "%")
if created_by_id is not None:
where_parts.append("u.created_by_id = %s")
params.append(created_by_id)
where_sql = " AND ".join(where_parts)
cur.execute(
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
creator.username AS creator_username
FROM users u
LEFT JOIN users creator ON creator.id = u.created_by_id
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
tuple(params) + (page_size, offset),
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM users u WHERE " + where_sql, tuple(params))
total = cur.fetchone()['total']
cur.execute("SELECT id, username FROM users WHERE role = 'admin' ORDER BY id")
admins = [{'id': r['id'], 'username': r['username']} for r in cur.fetchall()]
else:
admin_id = current_row['id']
where_parts = ["(u.id = %s OR (u.role = 'normal' AND u.created_by_id = %s))"]
params = [admin_id, admin_id]
if search_username:
where_parts.append("u.username LIKE %s")
params.append("%" + search_username + "%")
where_sql = " AND ".join(where_parts)
cur.execute(
"""SELECT u.id, u.username, u.is_admin, u.role, u.created_at, u.created_by_id,
creator.username AS creator_username
FROM users u
LEFT JOIN users creator ON creator.id = u.created_by_id
WHERE """ + where_sql + """ ORDER BY u.id LIMIT %s OFFSET %s""",
tuple(params) + (page_size, offset),
)
rows = cur.fetchall()
cur.execute(
"SELECT COUNT(*) as total FROM users u WHERE " + where_sql,
tuple(params),
)
total = cur.fetchone()['total']
admins = []
items = [
{
'id': r['id'],
'username': r['username'],
'is_admin': bool(r.get('is_admin')),
'role': r.get('role') or 'normal',
'created_by_id': r.get('created_by_id'),
'creator_username': r.get('creator_username') or '',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
for r in rows
]
conn.close()
payload = {
'success': True,
'items': items,
'total': total,
'page': page,
'page_size': page_size,
'current_user_role': role,
'admins': admins,
}
return jsonify(payload)
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user', methods=['POST'])
@admin_required
def admin_create_user():
data = request.get_json() or {}
username = (data.get('username') or '').strip()
password = data.get('password') or ''
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
want_role = (data.get('role') or 'normal').strip() or 'normal'
if want_role not in ('admin', 'normal'):
want_role = 'normal'
if role == 'admin' and want_role == 'admin':
return jsonify({'success': False, 'error': '仅超级管理员可创建管理员'})
if want_role == 'admin':
want_created_by = current_row['id']
elif role == 'super_admin':
want_created_by = data.get('created_by_id')
else:
want_created_by = current_row['id']
if not username or not password:
return jsonify({'success': False, 'error': '用户名和密码不能为空'})
if len(username) < 2:
return jsonify({'success': False, 'error': '用户名至少2个字符'})
if len(password) < 6:
return jsonify({'success': False, 'error': '密码至少6个字符'})
if want_role == 'normal' and role == 'super_admin' and want_created_by is None:
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id FROM users WHERE role = 'admin' ORDER BY id LIMIT 1")
r = cur.fetchone()
conn.close()
want_created_by = r['id'] if r else current_row['id']
except Exception:
want_created_by = current_row['id']
if want_role == 'normal' and want_created_by is None:
want_created_by = current_row['id']
is_admin = 1 if want_role in ('super_admin', 'admin') else 0
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"INSERT INTO users (username, password_hash, is_admin, role, created_by_id) VALUES (%s, %s, %s, %s, %s)",
(username, pwd_hash, is_admin, want_role, want_created_by),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '用户创建成功'})
except pymysql.IntegrityError:
return jsonify({'success': False, 'error': '用户名已存在'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>', methods=['PUT'])
@admin_required
def admin_update_user(uid):
data = request.get_json() or {}
password = data.get('password')
want_role = (data.get('role') or '').strip() or data.get('role')
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
if want_role is None and not password:
return jsonify({'success': False, 'error': '请提供要修改的内容'})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
target = cur.fetchone()
if not target:
conn.close()
return jsonify({'success': False, 'error': '用户不存在'})
if role == 'admin':
if target['role'] != 'normal' or target.get('created_by_id') != current_row['id']:
conn.close()
return jsonify({'success': False, 'error': '只能编辑自己创建的普通用户'}), 403
want_role = None
else:
if target.get('role') == 'super_admin':
conn.close()
return jsonify({'success': False, 'error': '不能修改超级管理员'})
if want_role == 'super_admin':
return jsonify({'success': False, 'error': '不能将用户设为超级管理员'})
if want_role not in ('admin', 'normal', None, ''):
want_role = None
if password:
if len(password) < 6:
conn.close()
return jsonify({'success': False, 'error': '密码至少6个字符'})
pwd_hash = generate_password_hash(password, method='pbkdf2:sha256')
cur.execute("UPDATE users SET password_hash = %s WHERE id = %s", (pwd_hash, uid))
if want_role is not None and want_role != '':
is_admin = 1 if want_role == 'admin' else 0
cur.execute(
"UPDATE users SET is_admin = %s, role = %s WHERE id = %s",
(is_admin, want_role, uid),
)
conn.commit()
conn.close()
return jsonify({'success': True, 'msg': '更新成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>', methods=['DELETE'])
@admin_required
def admin_delete_user(uid):
from flask import session
if session.get('user_id') == uid:
return jsonify({'success': False, 'error': '不能删除当前登录账号'})
role, current_row = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '需要管理员权限'}), 403
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT id, role, created_by_id FROM users WHERE id = %s", (uid,))
target = cur.fetchone()
if not target:
conn.close()
return jsonify({'success': False, 'error': '用户不存在'})
if target.get('role') == 'super_admin':
conn.close()
return jsonify({'success': False, 'error': '不能删除超级管理员'})
if role == 'admin':
if target.get('role') != 'normal' or target.get('created_by_id') != current_row['id']:
conn.close()
return jsonify({'success': False, 'error': '只能删除自己创建的普通用户'}), 403
cur.execute("DELETE FROM users WHERE id = %s", (uid,))
affected = cur.rowcount
conn.commit()
conn.close()
if affected == 0:
return jsonify({'success': False, 'error': '用户不存在'})
return jsonify({'success': True, 'msg': '删除成功'})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/user/<int:uid>/column-permissions')
@login_required
def admin_user_column_permissions(uid):
"""获取指定用户的栏目权限列表:当前用户只能查自己,管理员可查任意用户。超级管理员返回全部栏目。"""
current_uid = session.get('user_id')
if current_uid != uid:
role, _ = _get_current_admin_role()
if not role:
return jsonify({'success': False, 'error': '无权查看该用户的栏目权限'}), 403
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT role FROM users WHERE id = %s", (uid,))
user_row = cur.fetchone()
if user_row and (user_row.get('role') or '').strip() == 'super_admin':
cur.execute("""
SELECT id, name, column_key, created_at FROM columns ORDER BY id
""")
rows = cur.fetchall()
else:
cur.execute("""
SELECT c.id, c.name, c.column_key, c.created_at
FROM columns c
INNER JOIN user_column_permission ucp ON ucp.column_id = c.id
WHERE ucp.user_id = %s
ORDER BY c.id
""", (uid,))
rows = cur.fetchall()
conn.close()
items = [
{
'id': r['id'],
'name': r['name'],
'column_key': r['column_key'],
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '',
}
for r in rows
]
return jsonify({'success': True, 'items': items})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@admin_bp.route('/api/admin/history')
@admin_required
def admin_history():
"""管理员分页获取所有生成记录,支持按用户和时间筛选"""
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(10, int(request.args.get('page_size', 15))))
offset = (page - 1) * page_size
user_id = request.args.get('user_id', type=int)
time_start = (request.args.get('time_start') or '').strip()
time_end = (request.args.get('time_end') or '').strip()
conditions, params = [], []
if user_id:
conditions.append("h.user_id = %s")
params.append(user_id)
if time_start:
conditions.append("h.created_at >= %s")
params.append(time_start)
if time_end:
conditions.append("h.created_at <= %s")
params.append(time_end + ' 23:59:59' if len(time_end) <= 10 else time_end)
where_clause = " AND ".join(conditions) if conditions else "1=1"
params_count = params[:]
params.extend([page_size, offset])
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"""SELECT h.id, h.user_id, h.created_at, h.panel_type, h.original_urls, h.params, h.result_urls,
h.long_image_url, u.username
FROM image_history h
LEFT JOIN users u ON h.user_id = u.id
WHERE """ + where_clause + """ ORDER BY h.created_at DESC LIMIT %s OFFSET %s""",
params,
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM image_history h WHERE " + where_clause, params_count)
total = cur.fetchone()['total']
conn.close()
items = []
for r in rows:
items.append({
'id': r['id'],
'user_id': r['user_id'],
'username': r.get('username') or '-',
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
'panel_type': r['panel_type'] or '',
'original_urls': _parse_json(r['original_urls'], []),
'params': _parse_json(r['params'], {}),
'result_urls': _parse_json(r['result_urls'], []),
'long_image_url': (r.get('long_image_url') or '').strip() or None,
})
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})

View File

@@ -1,107 +1,107 @@
"""
认证蓝图登录登出登录状态校验
"""
from flask import Blueprint, request, redirect, url_for, session, jsonify
from werkzeug.security import check_password_hash
from app_common import (
get_db,
_render_html,
_is_session_user_valid,
login_required,
BASE_DIR,
)
from tool.devices import DeviceIDGenerator
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if session.get('user_id') and _is_session_user_valid():
return redirect(url_for('main.home'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
if request.is_json:
return jsonify({'success': False, 'error': '请输入用户名和密码'})
return _render_html('login.html', error='请输入用户名和密码')
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
(username,)
)
row = cur.fetchone()
if row and check_password_hash(row['password_hash'], password):
current_machine = DeviceIDGenerator().get_device_id()
stored_machine = (row.get('machine') or '').strip()
if not stored_machine:
with conn.cursor() as cur:
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
conn.commit()
conn.close()
session.permanent = True
session['user_id'] = row['id']
session['username'] = username
if request.is_json:
return jsonify({'success': True, 'redirect': url_for('main.home')})
return redirect(url_for('main.home'))
print("验证设备",stored_machine)
print("当前设备",current_machine)
if stored_machine != current_machine and row.get("is_admin") != 1:
conn.close()
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
if request.is_json:
return jsonify({'success': False, 'error': err_msg})
return _render_html('login.html', error=err_msg)
conn.close()
session.permanent = True
session['user_id'] = row['id']
session['username'] = username
if request.is_json:
return jsonify({'success': True, 'redirect': url_for('main.home')})
return redirect(url_for('main.home'))
conn.close()
except Exception as e:
if request.is_json:
return jsonify({'success': False, 'error': str(e)})
return _render_html('login.html', error='登录失败,请稍后重试')
if request.is_json:
return jsonify({'success': False, 'error': '用户名或密码错误'})
return _render_html('login.html', error='用户名或密码错误')
return _render_html('login.html')
@auth_bp.route('/api/auth/check')
@login_required
def api_auth_check():
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
if not session.get('user_id'):
return jsonify({'logged_in': False})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
if not row:
return jsonify({'logged_in': False})
stored_machine = (row.get('machine') or '').strip()
if stored_machine:
current_machine = DeviceIDGenerator().get_device_id()
if stored_machine != current_machine and row.get("is_admin") != 1:
session.clear()
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
except Exception:
return jsonify({'logged_in': False})
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
@auth_bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('auth.login'))
"""
认证蓝图登录登出登录状态校验
"""
from flask import Blueprint, request, redirect, url_for, session, jsonify
from werkzeug.security import check_password_hash
from app_common import (
get_db,
_render_html,
_is_session_user_valid,
login_required,
BASE_DIR,
)
from tool.devices import DeviceIDGenerator
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if session.get('user_id') and _is_session_user_valid():
return redirect(url_for('main.home'))
if request.method == 'POST':
data = request.get_json() if request.is_json else request.form
username = (data.get('username') or '').strip()
password = data.get('password') or ''
if not username or not password:
if request.is_json:
return jsonify({'success': False, 'error': '请输入用户名和密码'})
return _render_html('login.html', error='请输入用户名和密码')
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute(
"SELECT id, password_hash, machine, is_admin FROM users WHERE username = %s",
(username,)
)
row = cur.fetchone()
if row and check_password_hash(row['password_hash'], password):
current_machine = DeviceIDGenerator().get_device_id()
stored_machine = (row.get('machine') or '').strip()
if not stored_machine:
with conn.cursor() as cur:
cur.execute("UPDATE users SET machine = %s WHERE id = %s", (current_machine, row['id']))
conn.commit()
conn.close()
session.permanent = True
session['user_id'] = row['id']
session['username'] = username
if request.is_json:
return jsonify({'success': True, 'redirect': url_for('main.home')})
return redirect(url_for('main.home'))
print("验证设备",stored_machine)
print("当前设备",current_machine)
if stored_machine != current_machine and row.get("is_admin") != 1:
conn.close()
err_msg = '当前设备与首次登录设备不一致,请在原设备上登录'
if request.is_json:
return jsonify({'success': False, 'error': err_msg})
return _render_html('login.html', error=err_msg)
conn.close()
session.permanent = True
session['user_id'] = row['id']
session['username'] = username
if request.is_json:
return jsonify({'success': True, 'redirect': url_for('main.home')})
return redirect(url_for('main.home'))
conn.close()
except Exception as e:
if request.is_json:
return jsonify({'success': False, 'error': str(e)})
return _render_html('login.html', error='登录失败,请稍后重试')
if request.is_json:
return jsonify({'success': False, 'error': '用户名或密码错误'})
return _render_html('login.html', error='用户名或密码错误')
return _render_html('login.html')
@auth_bp.route('/api/auth/check')
@login_required
def api_auth_check():
"""校验登录状态,用于页面加载时判断是否已登录;同时校验机器码是否与首次登录设备一致"""
if not session.get('user_id'):
return jsonify({'logged_in': False})
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT machine, is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
if not row:
return jsonify({'logged_in': False})
stored_machine = (row.get('machine') or '').strip()
if stored_machine:
current_machine = DeviceIDGenerator().get_device_id()
if stored_machine != current_machine and row.get("is_admin") != 1:
session.clear()
return jsonify({'logged_in': False, 'error': '当前设备与首次登录设备不一致'})
except Exception:
return jsonify({'logged_in': False})
return jsonify({'logged_in': True, 'redirect': url_for('main.home')})
@auth_bp.route('/logout')
def logout():
session.clear()
return redirect(url_for('auth.login'))

View File

@@ -1,411 +1,411 @@
"""
图片生成蓝图生成历史下载拼接版本
"""
import os
import sys
import json
import re
import io
import base64
import tempfile
import threading
import subprocess
import requests
from urllib.parse import urlparse, quote
from flask import Blueprint, request, jsonify, Response, session, send_file
from PIL import Image
from app_common import get_db, login_required, BASE_DIR
from config import STITCH_WORKFLOW_ID,client_name
image_bp = Blueprint('image', __name__)
def _load_image_from_url_or_data(url_or_data):
"""从 http(s) URL 或 data URL 加载为 PIL Image失败返回 None"""
if not url_or_data or not isinstance(url_or_data, str):
return None
try:
if url_or_data.startswith('data:'):
m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL)
if not m:
return None
raw = base64.b64decode(m.group(1).strip())
img = Image.open(io.BytesIO(raw))
elif url_or_data.startswith(('http://', 'https://')):
resp = requests.get(url_or_data, timeout=15)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
else:
return None
if img.mode != 'RGB':
img = img.convert('RGB')
return img
except Exception:
return None
def _stitch_and_upload_long_image(urls):
"""将多张图片 URL 先上传获取 file_id再调用 workflow_run 拼接长图,返回 data.merged_image_url失败返回 None。"""
if not urls or not isinstance(urls, (list, tuple)):
return None
from coze import upload_file as coze_upload_file, workflow_run
file_ids = []
temp_paths = []
try:
for u in urls:
img = _load_image_from_url_or_data(u)
if img is None:
continue
fd, path = tempfile.mkstemp(suffix='.png')
try:
os.close(fd)
img.save(path)
temp_paths.append(path)
resp = coze_upload_file(path)
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
file_ids.append(resp['data']['id'])
except Exception:
pass
if not file_ids:
return None
parameters = {"images": [{"file_id": fid} for fid in file_ids]}
resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False)
if resp.get('code') != 0:
return None
data = resp.get('data') or {}
data = json.loads(data)
merged_image_url = data.get('merged_image_url')
if merged_image_url:
return merged_image_url
output_str = data.get('output') or ''
if output_str:
try:
outer = json.loads(output_str)
inner_str = outer.get('Output', '{}')
inner = json.loads(inner_str)
data_str = inner.get('data', '[]')
inner_data = json.loads(data_str)
merged_image_url = inner_data.get('merged_image_url')
return merged_image_url
except Exception:
pass
return None
except Exception:
return None
finally:
for p in temp_paths:
try:
os.unlink(p)
except Exception:
pass
def _sanitize_params_for_history(params):
"""移除 base64 大字段及敏感字段,仅保留可存储的请求参数"""
exclude = ('ref_images', 'proc_images', 'layout_image')
out = {}
for k, v in (params or {}).items():
if k == 'api_key':
continue
if k in exclude:
if isinstance(v, list):
out[f'{k}_count'] = len(v)
else:
out[f'{k}_count'] = 1 if v else 0
elif isinstance(v, (str, int, float, bool, type(None))):
out[k] = v
elif isinstance(v, list) and not v:
out[k] = []
elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)):
out[k] = v
else:
out[k] = str(v)[:200] if v else None
return out
@image_bp.route('/api/generate', methods=['POST'])
@login_required
def api_generate():
"""生成图片:调用 generate_api上传原图到 OSS保存历史记录"""
try:
params = request.get_json() or {}
from generate_api import generate
result = generate(params)
if result.get('success') and result.get('urls'):
long_image_url = result.get("long_image_url")
result["long_image_url"] = long_image_url
import json as _json
history_id = None
try:
hid = params.get('history_id')
if hid is not None:
try:
hid = int(hid)
except (TypeError, ValueError):
hid = None
conn = get_db()
with conn.cursor() as cur:
if hid is not None and hid > 0:
cur.execute(
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
(hid, session['user_id']),
)
row = cur.fetchone()
existing_urls = []
if row and row.get('result_urls'):
try:
existing_urls = _json.loads(row['result_urls'])
except Exception:
existing_urls = []
new_urls = result.get('urls') or []
new_url = new_urls[0] if new_urls else None
idx = params.get('history_index', 0)
try:
idx = int(idx)
except (TypeError, ValueError):
idx = 0
if new_url:
if not isinstance(existing_urls, list):
existing_urls = []
while len(existing_urls) <= idx:
existing_urls.append(existing_urls[-1] if existing_urls else new_url)
existing_urls[idx] = new_url
merged_result_urls = existing_urls or new_urls
cur.execute(
"""UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s
WHERE id=%s AND user_id=%s""",
(
params.get('panel_type', ''),
_json.dumps(result.get('original_urls') or []),
_json.dumps(_sanitize_params_for_history(params)),
_json.dumps(merged_result_urls),
hid,
session['user_id'],
),
)
if cur.rowcount > 0:
history_id = hid
else:
cur.execute(
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
VALUES (%s, %s, %s, %s, %s, %s)""",
(
session['user_id'],
params.get('panel_type', ''),
_json.dumps(result.get('original_urls') or []),
_json.dumps(_sanitize_params_for_history(params)),
_json.dumps(result.get('urls') or []),
long_image_url,
),
)
history_id = cur.lastrowid
conn.commit()
conn.close()
except Exception:
pass
if history_id is not None:
result['history_id'] = history_id
return jsonify(result)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'success': False, 'urls': [], 'error': str(e)})
@image_bp.route('/api/version')
def api_version():
"""检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较"""
current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip()
update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip()
result = {
'version': current_version,
'desc': '',
'url': '',
'has_update': False,
'latest_version': current_version,
'file_url': '',
}
if not update_url:
return jsonify(result)
try:
resp = requests.get(update_url, timeout=10)
resp.raise_for_status()
data = resp.json() or {}
latest_version = (data.get('version') or '').strip()
file_url = (data.get('file_url') or '').strip()
result['latest_version'] = latest_version
result['file_url'] = file_url
result['url'] = file_url
# 版本不一致则视为有更新
if latest_version and latest_version != current_version:
result['has_update'] = True
except Exception:
pass
return jsonify(result)
def _run_update_and_exit(zip_path, target_dir):
"""在后台延迟后启动 update.exe脱离当前进程然后退出当前程序"""
def _do():
import time
time.sleep(1.5) # 确保 HTTP 响应已发送
# exe_dir = target_dir
# exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist"
exe_dir = os.path.join(BASE_DIR,"update")
update_exe = os.path.join(exe_dir, 'update.exe')
if not os.path.isfile(update_exe):
return
try:
creationflags = 0
if sys.platform == 'win32':
creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen(
[update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name],
cwd=exe_dir,
creationflags=creationflags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
except Exception:
pass
os._exit(0)
t = threading.Thread(target=_do, daemon=False)
t.start()
@image_bp.route('/api/update/do', methods=['POST'])
def api_update_do():
"""执行更新:下载 zip 到 tmp启动 update.exe 后退出程序"""
data = request.get_json() or {}
file_url = (data.get('file_url') or '').strip()
if not file_url:
return jsonify({'success': False, 'error': '缺少 file_url'}), 400
parsed = urlparse(file_url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'success': False, 'error': '无效的下载地址'}), 400
tmp_dir = os.path.join(BASE_DIR, 'tmp')
try:
os.makedirs(tmp_dir, exist_ok=True)
except Exception as e:
return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500
# 使用 URL 中的文件名或默认版本名
filename = os.path.basename(parsed.path) or 'update.zip'
zip_path = os.path.join(tmp_dir, filename)
try:
resp = requests.get(file_url, timeout=300, stream=True)
resp.raise_for_status()
with open(zip_path, 'wb') as f:
for chunk in resp.iter_content(chunk_size=65536):
if chunk:
f.write(chunk)
except requests.RequestException as e:
return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502
_run_update_and_exit(zip_path, BASE_DIR)
return jsonify({'success': True, 'message': '更新已启动,程序即将退出'})
@image_bp.route('/api/download')
@login_required
def api_download():
"""代理下载图片,解决跨域 fetch 无法下载的问题"""
url = request.args.get('url', '').strip()
filename = request.args.get('filename', 'image.png')
if not url:
return jsonify({'success': False, 'error': '缺少 url 参数'}), 400
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400
try:
resp = requests.get(url, timeout=30, stream=True)
resp.raise_for_status()
content_type = resp.headers.get('Content-Type', 'image/png')
encoded = quote(filename, safe='')
disposition = f"attachment; filename*=UTF-8''{encoded}"
return Response(
resp.iter_content(chunk_size=8192),
mimetype=content_type,
headers={'Content-Disposition': disposition}
)
except requests.RequestException as e:
return jsonify({'success': False, 'error': str(e)}), 502
@image_bp.route('/api/stitch/save', methods=['POST'])
@login_required
def api_stitch_save():
"""手动拼接:接收图片 URL 列表(支持 http 或 data URL拼接并上传返回长图 URL"""
try:
data = request.get_json() or {}
urls = data.get('urls')
if not urls or not isinstance(urls, list):
return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400
urls = [u for u in urls if u and isinstance(u, str)]
if not urls:
return jsonify({'success': False, 'error': '没有有效的图片'}), 400
long_image_url = _stitch_and_upload_long_image(urls)
if not long_image_url:
return jsonify({'success': False, 'error': '拼接或上传失败'}), 500
return jsonify({'success': True, 'long_image_url': long_image_url})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@image_bp.route('/api/history')
@login_required
def api_history():
"""分页获取当前用户的历史图库,支持按 panel_type 栏目筛选"""
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(10, int(request.args.get('page_size', 20))))
panel_type = (request.args.get('panel_type') or '').strip()
offset = (page - 1) * page_size
try:
conn = get_db()
with conn.cursor() as cur:
where_user = "user_id = %s"
params_where = [session['user_id']]
if panel_type:
where_user += " AND panel_type = %s"
params_where.append(panel_type)
cur.execute(
"""SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url
FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""",
params_where + [page_size, offset],
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where)
total = cur.fetchone()['total']
conn.close()
def _parse_json(val, default=None):
if val is None:
return default if default is not None else []
if isinstance(val, (list, dict)):
return val
try:
return json.loads(val)
except Exception:
return default if default is not None else []
items = []
for r in rows:
items.append({
'id': r['id'],
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
'panel_type': r['panel_type'] or '',
'original_urls': _parse_json(r['original_urls'], []),
'params': _parse_json(r['params'], {}),
'result_urls': _parse_json(r['result_urls'], []),
'long_image_url': (r.get('long_image_url') or '').strip() or None,
})
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
"""
图片生成蓝图生成历史下载拼接版本
"""
import os
import sys
import json
import re
import io
import base64
import tempfile
import threading
import subprocess
import requests
from urllib.parse import urlparse, quote
from flask import Blueprint, request, jsonify, Response, session, send_file
from PIL import Image
from app_common import get_db, login_required, BASE_DIR
from config import STITCH_WORKFLOW_ID,client_name
image_bp = Blueprint('image', __name__)
def _load_image_from_url_or_data(url_or_data):
"""从 http(s) URL 或 data URL 加载为 PIL Image失败返回 None"""
if not url_or_data or not isinstance(url_or_data, str):
return None
try:
if url_or_data.startswith('data:'):
m = re.match(r'data:image/[^;]+;base64,(.+)', url_or_data, re.DOTALL)
if not m:
return None
raw = base64.b64decode(m.group(1).strip())
img = Image.open(io.BytesIO(raw))
elif url_or_data.startswith(('http://', 'https://')):
resp = requests.get(url_or_data, timeout=15)
resp.raise_for_status()
img = Image.open(io.BytesIO(resp.content))
else:
return None
if img.mode != 'RGB':
img = img.convert('RGB')
return img
except Exception:
return None
def _stitch_and_upload_long_image(urls):
"""将多张图片 URL 先上传获取 file_id再调用 workflow_run 拼接长图,返回 data.merged_image_url失败返回 None。"""
if not urls or not isinstance(urls, (list, tuple)):
return None
from coze import upload_file as coze_upload_file, workflow_run
file_ids = []
temp_paths = []
try:
for u in urls:
img = _load_image_from_url_or_data(u)
if img is None:
continue
fd, path = tempfile.mkstemp(suffix='.png')
try:
os.close(fd)
img.save(path)
temp_paths.append(path)
resp = coze_upload_file(path)
if resp.get('code') == 0 and resp.get('data', {}).get('id'):
file_ids.append(resp['data']['id'])
except Exception:
pass
if not file_ids:
return None
parameters = {"images": [{"file_id": fid} for fid in file_ids]}
resp = workflow_run(STITCH_WORKFLOW_ID, parameters, is_async=False)
if resp.get('code') != 0:
return None
data = resp.get('data') or {}
data = json.loads(data)
merged_image_url = data.get('merged_image_url')
if merged_image_url:
return merged_image_url
output_str = data.get('output') or ''
if output_str:
try:
outer = json.loads(output_str)
inner_str = outer.get('Output', '{}')
inner = json.loads(inner_str)
data_str = inner.get('data', '[]')
inner_data = json.loads(data_str)
merged_image_url = inner_data.get('merged_image_url')
return merged_image_url
except Exception:
pass
return None
except Exception:
return None
finally:
for p in temp_paths:
try:
os.unlink(p)
except Exception:
pass
def _sanitize_params_for_history(params):
"""移除 base64 大字段及敏感字段,仅保留可存储的请求参数"""
exclude = ('ref_images', 'proc_images', 'layout_image')
out = {}
for k, v in (params or {}).items():
if k == 'api_key':
continue
if k in exclude:
if isinstance(v, list):
out[f'{k}_count'] = len(v)
else:
out[f'{k}_count'] = 1 if v else 0
elif isinstance(v, (str, int, float, bool, type(None))):
out[k] = v
elif isinstance(v, list) and not v:
out[k] = []
elif isinstance(v, list) and isinstance(v[0], (str, int, float, bool)):
out[k] = v
else:
out[k] = str(v)[:200] if v else None
return out
@image_bp.route('/api/generate', methods=['POST'])
@login_required
def api_generate():
"""生成图片:调用 generate_api上传原图到 OSS保存历史记录"""
try:
params = request.get_json() or {}
from generate_api import generate
result = generate(params)
if result.get('success') and result.get('urls'):
long_image_url = result.get("long_image_url")
result["long_image_url"] = long_image_url
import json as _json
history_id = None
try:
hid = params.get('history_id')
if hid is not None:
try:
hid = int(hid)
except (TypeError, ValueError):
hid = None
conn = get_db()
with conn.cursor() as cur:
if hid is not None and hid > 0:
cur.execute(
"SELECT result_urls FROM image_history WHERE id=%s AND user_id=%s",
(hid, session['user_id']),
)
row = cur.fetchone()
existing_urls = []
if row and row.get('result_urls'):
try:
existing_urls = _json.loads(row['result_urls'])
except Exception:
existing_urls = []
new_urls = result.get('urls') or []
new_url = new_urls[0] if new_urls else None
idx = params.get('history_index', 0)
try:
idx = int(idx)
except (TypeError, ValueError):
idx = 0
if new_url:
if not isinstance(existing_urls, list):
existing_urls = []
while len(existing_urls) <= idx:
existing_urls.append(existing_urls[-1] if existing_urls else new_url)
existing_urls[idx] = new_url
merged_result_urls = existing_urls or new_urls
cur.execute(
"""UPDATE image_history SET panel_type=%s, original_urls=%s, params=%s, result_urls=%s
WHERE id=%s AND user_id=%s""",
(
params.get('panel_type', ''),
_json.dumps(result.get('original_urls') or []),
_json.dumps(_sanitize_params_for_history(params)),
_json.dumps(merged_result_urls),
hid,
session['user_id'],
),
)
if cur.rowcount > 0:
history_id = hid
else:
cur.execute(
"""INSERT INTO image_history (user_id, panel_type, original_urls, params, result_urls, long_image_url)
VALUES (%s, %s, %s, %s, %s, %s)""",
(
session['user_id'],
params.get('panel_type', ''),
_json.dumps(result.get('original_urls') or []),
_json.dumps(_sanitize_params_for_history(params)),
_json.dumps(result.get('urls') or []),
long_image_url,
),
)
history_id = cur.lastrowid
conn.commit()
conn.close()
except Exception:
pass
if history_id is not None:
result['history_id'] = history_id
return jsonify(result)
except Exception as e:
import traceback
traceback.print_exc()
return jsonify({'success': False, 'urls': [], 'error': str(e)})
@image_bp.route('/api/version')
def api_version():
"""检测更新:请求 APP_UPDATE_URL 获取最新版本信息,与当前版本比较"""
current_version = (os.environ.get('APP_VERSION', '1.0.0') or '1.0.0').strip()
update_url = (os.environ.get('APP_UPDATE_URL', '') or '').strip()
result = {
'version': current_version,
'desc': '',
'url': '',
'has_update': False,
'latest_version': current_version,
'file_url': '',
}
if not update_url:
return jsonify(result)
try:
resp = requests.get(update_url, timeout=10)
resp.raise_for_status()
data = resp.json() or {}
latest_version = (data.get('version') or '').strip()
file_url = (data.get('file_url') or '').strip()
result['latest_version'] = latest_version
result['file_url'] = file_url
result['url'] = file_url
# 版本不一致则视为有更新
if latest_version and latest_version != current_version:
result['has_update'] = True
except Exception:
pass
return jsonify(result)
def _run_update_and_exit(zip_path, target_dir):
"""在后台延迟后启动 update.exe脱离当前进程然后退出当前程序"""
def _do():
import time
time.sleep(1.5) # 确保 HTTP 响应已发送
# exe_dir = target_dir
# exe_dir = "D:\\pack\\nanri\\update_exe\\main.dist"
exe_dir = os.path.join(BASE_DIR,"update")
update_exe = os.path.join(exe_dir, 'update.exe')
if not os.path.isfile(update_exe):
return
try:
creationflags = 0
if sys.platform == 'win32':
creationflags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
subprocess.Popen(
[update_exe, '--zip', zip_path, '--target', target_dir, '--process',client_name],
cwd=exe_dir,
creationflags=creationflags,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)
except Exception:
pass
os._exit(0)
t = threading.Thread(target=_do, daemon=False)
t.start()
@image_bp.route('/api/update/do', methods=['POST'])
def api_update_do():
"""执行更新:下载 zip 到 tmp启动 update.exe 后退出程序"""
data = request.get_json() or {}
file_url = (data.get('file_url') or '').strip()
if not file_url:
return jsonify({'success': False, 'error': '缺少 file_url'}), 400
parsed = urlparse(file_url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'success': False, 'error': '无效的下载地址'}), 400
tmp_dir = os.path.join(BASE_DIR, 'tmp')
try:
os.makedirs(tmp_dir, exist_ok=True)
except Exception as e:
return jsonify({'success': False, 'error': f'创建 tmp 目录失败: {e}'}), 500
# 使用 URL 中的文件名或默认版本名
filename = os.path.basename(parsed.path) or 'update.zip'
zip_path = os.path.join(tmp_dir, filename)
try:
resp = requests.get(file_url, timeout=300, stream=True)
resp.raise_for_status()
with open(zip_path, 'wb') as f:
for chunk in resp.iter_content(chunk_size=65536):
if chunk:
f.write(chunk)
except requests.RequestException as e:
return jsonify({'success': False, 'error': f'下载失败: {e}'}), 502
_run_update_and_exit(zip_path, BASE_DIR)
return jsonify({'success': True, 'message': '更新已启动,程序即将退出'})
@image_bp.route('/api/download')
@login_required
def api_download():
"""代理下载图片,解决跨域 fetch 无法下载的问题"""
url = request.args.get('url', '').strip()
filename = request.args.get('filename', 'image.png')
if not url:
return jsonify({'success': False, 'error': '缺少 url 参数'}), 400
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https'):
return jsonify({'success': False, 'error': '仅支持 http/https 链接'}), 400
try:
resp = requests.get(url, timeout=30, stream=True)
resp.raise_for_status()
content_type = resp.headers.get('Content-Type', 'image/png')
encoded = quote(filename, safe='')
disposition = f"attachment; filename*=UTF-8''{encoded}"
return Response(
resp.iter_content(chunk_size=8192),
mimetype=content_type,
headers={'Content-Disposition': disposition}
)
except requests.RequestException as e:
return jsonify({'success': False, 'error': str(e)}), 502
@image_bp.route('/api/stitch/save', methods=['POST'])
@login_required
def api_stitch_save():
"""手动拼接:接收图片 URL 列表(支持 http 或 data URL拼接并上传返回长图 URL"""
try:
data = request.get_json() or {}
urls = data.get('urls')
if not urls or not isinstance(urls, list):
return jsonify({'success': False, 'error': '请提供 urls 数组'}), 400
urls = [u for u in urls if u and isinstance(u, str)]
if not urls:
return jsonify({'success': False, 'error': '没有有效的图片'}), 400
long_image_url = _stitch_and_upload_long_image(urls)
if not long_image_url:
return jsonify({'success': False, 'error': '拼接或上传失败'}), 500
return jsonify({'success': True, 'long_image_url': long_image_url})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@image_bp.route('/api/history')
@login_required
def api_history():
"""分页获取当前用户的历史图库,支持按 panel_type 栏目筛选"""
page = max(1, int(request.args.get('page', 1)))
page_size = min(50, max(10, int(request.args.get('page_size', 20))))
panel_type = (request.args.get('panel_type') or '').strip()
offset = (page - 1) * page_size
try:
conn = get_db()
with conn.cursor() as cur:
where_user = "user_id = %s"
params_where = [session['user_id']]
if panel_type:
where_user += " AND panel_type = %s"
params_where.append(panel_type)
cur.execute(
"""SELECT id, created_at, panel_type, original_urls, params, result_urls, long_image_url
FROM image_history WHERE """ + where_user + """ ORDER BY created_at DESC LIMIT %s OFFSET %s""",
params_where + [page_size, offset],
)
rows = cur.fetchall()
cur.execute("SELECT COUNT(*) as total FROM image_history WHERE " + where_user, params_where)
total = cur.fetchone()['total']
conn.close()
def _parse_json(val, default=None):
if val is None:
return default if default is not None else []
if isinstance(val, (list, dict)):
return val
try:
return json.loads(val)
except Exception:
return default if default is not None else []
items = []
for r in rows:
items.append({
'id': r['id'],
'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r['created_at'] else '',
'panel_type': r['panel_type'] or '',
'original_urls': _parse_json(r['original_urls'], []),
'params': _parse_json(r['params'], {}),
'result_urls': _parse_json(r['result_urls'], []),
'long_image_url': (r.get('long_image_url') or '').strip() or None,
})
return jsonify({'success': True, 'items': items, 'total': total, 'page': page, 'page_size': page_size})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})

View File

@@ -1,69 +1,78 @@
"""
主页面蓝图首页home图片工作台品牌页静态文件Logo
"""
import os
from flask import Blueprint, send_file
from app_common import (
get_db,
_render_html,
_is_session_user_valid,
login_required,
admin_required,
STATIC_DIR,
BASE_DIR,
)
from flask import redirect, url_for, session
from config import base_url,version
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
if session.get('user_id') and _is_session_user_valid():
return redirect(url_for('main.home'))
return redirect(url_for('auth.login'))
@main_bp.route('/home')
@login_required
def home():
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
except Exception:
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
@main_bp.route('/image')
@login_required
def wb():
return _render_html('index.html')
@main_bp.route('/brand')
@login_required
def brand_page():
return _render_html('brand.html')
@main_bp.route('/static/<path:filename>')
def serve_static(filename):
"""提供 static 目录及子目录下的静态文件访问。"""
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
static_abs = os.path.abspath(STATIC_DIR)
file_abs = os.path.abspath(filepath)
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
return '', 404
return send_file(file_abs, as_attachment=False)
@main_bp.route('/logo.jpg', methods=['GET'])
def get_logo_image():
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')
"""
主页面蓝图首页home图片工作台品牌页静态文件Logo
"""
import os
from flask import Blueprint, send_file
from app_common import (
get_db,
_render_html,
_is_session_user_valid,
login_required,
admin_required,
STATIC_DIR,
BASE_DIR,
)
from flask import redirect, url_for, session
from config import base_url,version
main_bp = Blueprint('main', __name__)
@main_bp.route('/')
def index():
if session.get('user_id') and _is_session_user_valid():
return redirect(url_for('main.home'))
return redirect(url_for('auth.login'))
@main_bp.route('/home')
@login_required
def home():
try:
conn = get_db()
with conn.cursor() as cur:
cur.execute("SELECT username, is_admin FROM users WHERE id = %s", (session['user_id'],))
row = cur.fetchone()
conn.close()
return _render_html('home.html', username=row.get('username', ''), is_admin=bool(row.get('is_admin')), user_id=session.get('user_id'),baseUrl=base_url,version=version)
except Exception:
return _render_html('home.html', username=session.get('username', ''), is_admin=False, user_id=session.get('user_id'),baseUrl=base_url,version=version)
@main_bp.route('/image')
@login_required
def wb():
return _render_html('index.html')
@main_bp.route('/brand')
@login_required
def brand_page():
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>')
def serve_static(filename):
"""提供 static 目录及子目录下的静态文件访问。"""
filepath = os.path.normpath(os.path.join(STATIC_DIR, filename))
static_abs = os.path.abspath(STATIC_DIR)
file_abs = os.path.abspath(filepath)
if not file_abs.startswith(static_abs) or not os.path.isfile(file_abs):
return '', 404
return send_file(file_abs, as_attachment=False)
@main_bp.route('/logo.jpg', methods=['GET'])
def get_logo_image():
return send_file(os.path.join(BASE_DIR, "logo.jpg"), mimetype='image/jpeg')

Some files were not shown because too many files have changed in this diff Show More