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-debug.log*
frontend-vue/yarn-error.log* frontend-vue/yarn-error.log*
frontend-vue/pnpm-debug.log* frontend-vue/pnpm-debug.log*
# backend-java build output
backend-java/target/
# backend-java runtime data
backend-java/data/
backend-java/*.log
backend-java/logs/
# backend-java local config
backend-java/src/main/resources/application-local.yml
# backend-java IDE files
backend-java/.idea/
backend-java/*.iml

125
backend-java/pom.xml Normal file
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: class DesktopApi:
def __init__(self, app_base_url: str) -> None: def __init__(self, backend_base_url: str) -> None:
self.app_base_url = app_base_url.rstrip("/") self.app_base_url = backend_base_url.rstrip("/")
self.session = requests.Session() self.session = requests.Session()
def select_brand_xlsx_files(self) -> list[str]: def select_brand_xlsx_files(self) -> list[str]:
@@ -347,17 +347,38 @@ class DesktopApi:
return {"success": False, "error": str(exc)} return {"success": False, "error": str(exc)}
def list_txt_templates(self) -> list[dict]: def list_txt_templates(self) -> list[dict]:
templates, _default_template_id = build_effective_templates() try:
return [ response = self.session.get(
{ resolve_url(self.app_base_url, "/api/convert/templates"),
"id": template["id"], timeout=60,
"label": template["label"], )
"output_filename": template["output_filename"], response.raise_for_status()
"is_default": template.get("is_default", False), payload = response.json()
"built_in": template.get("built_in", False), if not payload.get("success"):
} return []
for template in templates.values() items = payload.get("data") or []
] return [
{
"id": str(item.get("templateCode") or item.get("id") or ""),
"label": item.get("templateName") or "",
"output_filename": item.get("outputFilename") or "",
"is_default": bool(item.get("isDefault")),
"built_in": bool(item.get("builtIn")),
}
for item in items
]
except Exception:
templates, _default_template_id = build_effective_templates()
return [
{
"id": template["id"],
"label": template["label"],
"output_filename": template["output_filename"],
"is_default": template.get("is_default", False),
"built_in": template.get("built_in", False),
}
for template in templates.values()
]
def import_txt_template(self, name: str | None = None) -> dict: def import_txt_template(self, name: str | None = None) -> dict:
file_path = run_file_dialog( file_path = run_file_dialog(
@@ -370,39 +391,60 @@ class DesktopApi:
return {"success": False, "error": "用户取消"} return {"success": False, "error": "用户取消"}
try: try:
template_text = Path(file_path).read_text(encoding="utf-8") try:
except UnicodeDecodeError: template_text = Path(file_path).read_text(encoding="utf-8")
template_text = Path(file_path).read_text(encoding="utf-8-sig") except UnicodeDecodeError:
template_text = Path(file_path).read_text(encoding="utf-8-sig")
response = self.session.post(
resolve_url(self.app_base_url, "/api/convert/templates/import"),
json={
"templateName": (name or "").strip() or Path(file_path).stem,
"templateContent": template_text,
},
timeout=120,
)
response.raise_for_status()
payload = response.json()
if not payload.get("success") or not payload.get("data"):
return {"success": False, "error": payload.get("message") or "导入失败"}
item = payload.get("data") or {}
return {
"success": True,
"template": {
"id": str(item.get("templateCode") or item.get("id") or ""),
"label": item.get("templateName") or "",
"output_filename": item.get("outputFilename") or "",
"is_default": bool(item.get("isDefault")),
"built_in": bool(item.get("builtIn")),
},
}
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)} return {"success": False, "error": str(exc)}
template = recognize_txt_template(template_text, (name or "").strip() or None)
if not template:
return {"success": False, "error": "无法识别模板"}
user_templates, default_template_id = load_user_templates()
user_templates[template["id"]] = template
save_user_templates(user_templates, default_template_id)
return {
"success": True,
"template": {
"id": template["id"],
"label": template["label"],
"output_filename": template["output_filename"],
"is_default": False,
"built_in": False,
},
}
def set_default_txt_template(self, template_id: str) -> dict: def set_default_txt_template(self, template_id: str) -> dict:
templates, _default_template_id = build_effective_templates() try:
if template_id not in templates: response = self.session.post(
return {"success": False, "error": "模板不存在"} resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}/default"),
timeout=60,
)
response.raise_for_status()
payload = response.json()
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
user_templates, _ = load_user_templates() def delete_txt_template(self, template_id: str) -> dict:
save_user_templates(user_templates, template_id) try:
return {"success": True} response = self.session.delete(
resolve_url(self.app_base_url, f"/api/convert/templates/{template_id}"),
timeout=60,
)
response.raise_for_status()
payload = response.json()
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
def split_excel_files(self, payload: dict) -> dict: def split_excel_files(self, payload: dict) -> dict:
paths = payload.get("paths") or [] paths = payload.get("paths") or []
@@ -410,7 +452,6 @@ class DesktopApi:
split_mode = payload.get("split_mode") or "rows_per_file" split_mode = payload.get("split_mode") or "rows_per_file"
rows_per_file = payload.get("rows_per_file") rows_per_file = payload.get("rows_per_file")
parts = payload.get("parts") parts = payload.get("parts")
output_dir = payload.get("output_dir") or ""
if not isinstance(paths, list) or not paths: if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待拆分文件"} return {"success": False, "error": "未选择待拆分文件"}
@@ -422,128 +463,171 @@ class DesktopApi:
return {"success": False, "error": "每份条数不合法"} return {"success": False, "error": "每份条数不合法"}
if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0): if split_mode == "parts" and (not isinstance(parts, int) or parts <= 0):
return {"success": False, "error": "拆分份数不合法"} return {"success": False, "error": "拆分份数不合法"}
if not output_dir:
return {"success": False, "error": "未选择输出目录"}
output_dir_path = Path(output_dir) try:
if not output_dir_path.exists() or not output_dir_path.is_dir(): uploaded_files = []
return {"success": False, "error": "输出目录不存在"} for path in paths:
with open(path, "rb") as file_obj:
items: list[dict] = [] response = self.session.post(
success_count = 0 resolve_url(self.app_base_url, "/api/files/upload"),
failed_count = 0 files={"file": (Path(path).name, file_obj)},
timeout=180,
for path in paths: )
try: response.raise_for_status()
workbook = load_workbook(path) upload_payload = response.json()
sheet = workbook[workbook.sheetnames[0]] if not upload_payload.get("success") or not upload_payload.get("data"):
header_cells = next(sheet.iter_rows(min_row=1, max_row=1)) return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
header_map: dict[str, int] = {} upload_data = upload_payload.get("data") or {}
for index, cell in enumerate(header_cells): uploaded_files.append({
value = normalize_cell_text(cell.value) "fileKey": upload_data.get("fileKey"),
if not value: "originalFilename": upload_data.get("originalFilename") or Path(path).name,
continue
value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value
if value in header_map:
continue
header_map[value] = index
if value == "缩略图地址8":
break
missing = [column for column in selected_columns if column not in header_map]
if missing:
workbook.close()
items.append({
"source_path": path,
"success": False,
"error": f"缺少列:{''.join(missing)}",
})
failed_count += 1
continue
rows_data: list[list[str]] = []
for row in sheet.iter_rows(min_row=2, values_only=True):
projected = [
normalize_cell_text(row[header_map[column]]) if header_map[column] < len(row) else ""
for column in selected_columns
]
rows_data.append(projected)
total_rows = len(rows_data)
if total_rows == 0:
workbook.close()
items.append({
"source_path": path,
"success": False,
"error": "没有可拆分的数据行",
})
failed_count += 1
continue
if split_mode == "rows_per_file":
chunk_size = rows_per_file
chunks = [rows_data[i : i + chunk_size] for i in range(0, total_rows, chunk_size)]
else:
actual_parts = min(parts, total_rows)
base = total_rows // actual_parts
remainder = total_rows % actual_parts
chunks = []
start = 0
for idx in range(actual_parts):
size = base + (1 if idx < remainder else 0)
end = start + size
chunks.append(rows_data[start:end])
start = end
from openpyxl import Workbook
source = Path(path)
for index, chunk in enumerate(chunks, start=1):
output_workbook = Workbook()
output_sheet = output_workbook.active
output_sheet.title = sheet.title
output_sheet.append(selected_columns)
for row_values in chunk:
output_sheet.append(row_values)
output_path = build_named_output_path(output_dir, f"{source.stem}_split_{index}.xlsx")
output_workbook.save(output_path)
output_workbook.close()
items.append({
"source_path": path,
"output_path": output_path,
"success": True,
"row_count": len(chunk),
})
workbook.close()
success_count += 1
except Exception as exc: # noqa: BLE001
items.append({
"source_path": path,
"success": False,
"error": str(exc),
}) })
failed_count += 1
return { run_response = self.session.post(
"success": True, resolve_url(self.app_base_url, "/api/split/run"),
"summary": { json={
"total": len(paths), "files": uploaded_files,
"success_count": success_count, "selectedColumns": selected_columns,
"failed_count": failed_count, "splitMode": split_mode,
"output_dir": str(output_dir_path), "rowsPerFile": rows_per_file,
}, "parts": parts,
"items": items, },
} timeout=600,
)
run_response.raise_for_status()
run_payload = run_response.json()
if not run_payload.get("success") or not run_payload.get("data"):
return {"success": False, "error": run_payload.get("message") or "数据拆分失败"}
data = run_payload.get("data") or {}
result_items = []
for item in data.get("items") or []:
if item.get("success") and item.get("downloadUrl"):
result_items.append({
"result_id": item.get("resultId"),
"source_path": item.get("sourceFilename") or "",
"output_path": item.get("downloadUrl") or "",
"success": True,
"row_count": item.get("rowCount"),
})
else:
result_items.append({
"source_path": item.get("sourceFilename") or "",
"success": False,
"error": item.get("error") or "数据拆分失败",
})
return {
"success": True,
"summary": {
"total": data.get("total") or len(paths),
"success_count": data.get("successCount") or 0,
"failed_count": data.get("failedCount") or 0,
},
"items": result_items,
}
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
def delete_history_item(self, module_type: str, result_id: int) -> dict:
try:
response = self.session.delete(
resolve_url(self.app_base_url, f"/api/{module_type}/history/{result_id}"),
timeout=60,
)
response.raise_for_status()
payload = response.json()
return {"success": bool(payload.get("success")), "error": payload.get("message") if not payload.get("success") else None}
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
def get_convert_history(self) -> dict:
try:
response = self.session.get(
resolve_url(self.app_base_url, "/api/convert/history"),
timeout=60,
)
response.raise_for_status()
payload = response.json()
if not payload.get("success") or not payload.get("data"):
return {"success": False, "error": payload.get("message") or "获取历史失败"}
items = payload.get("data", {}).get("items") or []
return {
"success": True,
"items": [
{
"result_id": item.get("resultId"),
"source_path": item.get("sourceFilename") or "",
"output_path": item.get("downloadUrl") or "",
"success": bool(item.get("success", True)),
}
for item in items
],
}
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
def get_split_history(self) -> dict:
try:
response = self.session.get(
resolve_url(self.app_base_url, "/api/split/history"),
timeout=60,
)
response.raise_for_status()
payload = response.json()
if not payload.get("success") or not payload.get("data"):
return {"success": False, "error": payload.get("message") or "获取历史失败"}
items = payload.get("data", {}).get("items") or []
return {
"success": True,
"items": [
{
"result_id": item.get("resultId"),
"source_path": item.get("sourceFilename") or "",
"output_path": item.get("downloadUrl") or "",
"success": bool(item.get("success", True)),
"row_count": item.get("rowCount"),
}
for item in items
],
}
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
def get_dedupe_history(self) -> dict:
try:
response = self.session.get(
resolve_url(self.app_base_url, "/api/dedupe/history"),
timeout=60,
)
response.raise_for_status()
payload = response.json()
if not payload.get("success") or not payload.get("data"):
return {"success": False, "error": payload.get("message") or "获取历史失败"}
items = payload.get("data", {}).get("items") or []
return {
"success": True,
"items": [
{
"result_id": item.get("resultId"),
"source_path": item.get("sourceFilename") or "",
"output_path": item.get("downloadUrl") or "",
"success": bool(item.get("success", True)),
}
for item in items
],
}
except Exception as exc: # noqa: BLE001
return {"success": False, "error": str(exc)}
def clean_excel_files(self, payload: dict) -> dict: def clean_excel_files(self, payload: dict) -> dict:
paths = payload.get("paths") or [] paths = payload.get("paths") or []
selected_columns = payload.get("selected_columns") or [] selected_columns = payload.get("selected_columns") or []
keep_integer_ids = bool(payload.get("keep_integer_ids", True)) keep_integer_ids = bool(payload.get("keep_integer_ids", True))
keep_underscore_ids = bool(payload.get("keep_underscore_ids", True)) keep_underscore_ids = bool(payload.get("keep_underscore_ids", True))
output_dir = payload.get("output_dir") or ""
if not isinstance(paths, list) or not paths: if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待处理文件"} return {"success": False, "error": "未选择待处理文件"}
@@ -551,213 +635,140 @@ class DesktopApi:
return {"success": False, "error": "请至少选择一列"} return {"success": False, "error": "请至少选择一列"}
if not keep_integer_ids and not keep_underscore_ids: if not keep_integer_ids and not keep_underscore_ids:
return {"success": False, "error": "请至少选择一种 ID 保留规则"} return {"success": False, "error": "请至少选择一种 ID 保留规则"}
if not output_dir:
return {"success": False, "error": "未选择输出目录"}
output_dir_path = Path(output_dir) try:
if not output_dir_path.exists() or not output_dir_path.is_dir(): uploaded_files = []
return {"success": False, "error": "输出目录不存在"} for path in paths:
with open(path, "rb") as file_obj:
response = self.session.post(
resolve_url(self.app_base_url, "/api/files/upload"),
files={"file": (Path(path).name, file_obj)},
timeout=180,
)
response.raise_for_status()
upload_payload = response.json()
if not upload_payload.get("success") or not upload_payload.get("data"):
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
upload_data = upload_payload.get("data") or {}
uploaded_files.append({
"fileKey": upload_data.get("fileKey"),
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
})
items: list[dict] = [] run_response = self.session.post(
success_count = 0 resolve_url(self.app_base_url, "/api/dedupe/run"),
failed_count = 0 json={
"files": uploaded_files,
"selectedColumns": selected_columns,
"keepIntegerIds": keep_integer_ids,
"keepUnderscoreIds": keep_underscore_ids,
},
timeout=600,
)
run_response.raise_for_status()
run_payload = run_response.json()
if not run_payload.get("success") or not run_payload.get("data"):
return {"success": False, "error": run_payload.get("message") or "去重失败"}
for path in paths: data = run_payload.get("data") or {}
try: result_items = []
workbook = load_workbook(path) for item in data.get("items") or []:
sheet = workbook[workbook.sheetnames[0]] if item.get("success") and item.get("downloadUrl"):
header_cells = next(sheet.iter_rows(min_row=1, max_row=1)) result_items.append({
headers: list[str] = [] "source_path": item.get("sourceFilename") or "",
header_map: dict[str, int] = {} "output_path": item.get("downloadUrl") or "",
for index, cell in enumerate(header_cells): "success": True,
value = normalize_cell_text(cell.value) })
if not value: else:
continue result_items.append({
value = value.split("idASIN国家状态价格变体数量", 1)[0].strip() or value "source_path": item.get("sourceFilename") or "",
if value in header_map: "success": False,
continue "error": item.get("error") or "去重失败",
header_map[value] = index
headers.append(value)
if value == "缩略图地址8":
break
missing = [column for column in selected_columns if column not in header_map]
if missing:
workbook.close()
items.append({
"source_path": path,
"success": False,
"error": f"缺少列:{''.join(missing)}",
}) })
failed_count += 1
continue
from openpyxl import Workbook return {
"success": True,
output_workbook = Workbook() "summary": {
output_sheet = output_workbook.active "total": data.get("total") or len(paths),
output_sheet.title = sheet.title "success_count": data.get("successCount") or 0,
output_sheet.append(selected_columns) "failed_count": data.get("failedCount") or 0,
},
id_column_index = header_map.get("id") "items": result_items,
}
for row in sheet.iter_rows(min_row=2, values_only=True): except Exception as exc: # noqa: BLE001
original_values = list(row) return {"success": False, "error": str(exc)}
if id_column_index is not None:
id_value = original_values[id_column_index] if id_column_index < len(original_values) else None
if not should_keep_id(id_value, keep_integer_ids, keep_underscore_ids):
continue
cleaned_values = [
normalize_cell_text(original_values[header_map[column]])
if header_map[column] < len(original_values)
else ""
for column in selected_columns
]
output_sheet.append(cleaned_values)
output_path = build_output_path(output_dir, path)
output_workbook.save(output_path)
output_workbook.close()
workbook.close()
items.append({
"source_path": path,
"output_path": output_path,
"success": True,
})
success_count += 1
except Exception as exc: # noqa: BLE001
items.append({
"source_path": path,
"success": False,
"error": str(exc),
})
failed_count += 1
return {
"success": True,
"summary": {
"total": len(paths),
"success_count": success_count,
"failed_count": failed_count,
"output_dir": str(output_dir_path),
},
"items": items,
}
def convert_excel_to_uk_txt(self, payload: dict) -> dict: def convert_excel_to_uk_txt(self, payload: dict) -> dict:
paths = payload.get("paths") or [] paths = payload.get("paths") or []
output_dir = payload.get("output_dir") or ""
template_id = payload.get("template_id") or "uk_offer" template_id = payload.get("template_id") or "uk_offer"
if not isinstance(paths, list) or not paths: if not isinstance(paths, list) or not paths:
return {"success": False, "error": "未选择待转换文件"} return {"success": False, "error": "未选择待转换文件"}
if not output_dir:
return {"success": False, "error": "未选择输出目录"}
templates, _default_template_id = build_effective_templates() try:
template = templates.get(template_id) templates = self.list_txt_templates()
if not template: selected_template = next((item for item in templates if item.get("id") == template_id), None)
return {"success": False, "error": "模板不存在"} if not selected_template:
return {"success": False, "error": "模板不存在"}
output_dir_path = Path(output_dir) uploaded_files = []
if not output_dir_path.exists() or not output_dir_path.is_dir(): for path in paths:
return {"success": False, "error": "输出目录不存在"} with open(path, "rb") as file_obj:
response = self.session.post(
resolve_url(self.app_base_url, "/api/files/upload"),
files={"file": (Path(path).name, file_obj)},
timeout=180,
)
response.raise_for_status()
upload_payload = response.json()
if not upload_payload.get("success") or not upload_payload.get("data"):
return {"success": False, "error": upload_payload.get("message") or "上传文件失败"}
upload_data = upload_payload.get("data") or {}
uploaded_files.append({
"fileKey": upload_data.get("fileKey"),
"originalFilename": upload_data.get("originalFilename") or Path(path).name,
})
items: list[dict] = [] run_response = self.session.post(
success_count = 0 resolve_url(self.app_base_url, "/api/convert/run"),
failed_count = 0 json={
"files": uploaded_files,
"templateId": str(template_id),
},
timeout=600,
)
run_response.raise_for_status()
run_payload = run_response.json()
if not run_payload.get("success") or not run_payload.get("data"):
return {"success": False, "error": run_payload.get("message") or "格式转换失败"}
for path in paths: data = run_payload.get("data") or {}
try: result_items = []
workbook = load_workbook(path, read_only=True, data_only=True) for item in data.get("items") or []:
sheet = workbook[workbook.sheetnames[0]] if item.get("success") and item.get("downloadUrl"):
header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) result_items.append({
if not header_row: "source_path": item.get("sourceFilename") or "",
workbook.close() "output_path": item.get("downloadUrl") or "",
items.append({"source_path": path, "success": False, "error": 'Excel 表头为空'}) "success": True,
failed_count += 1 })
continue else:
result_items.append({
header_map: dict[str, int] = {} "source_path": item.get("sourceFilename") or "",
for index, cell in enumerate(header_row): "success": False,
value = normalize_cell_text(cell) "error": item.get("error") or "格式转换失败",
if not value or value in header_map:
continue
header_map[value] = index
required = template["required_source_columns"]
missing = [column for column in required if column not in header_map]
if missing:
workbook.close()
items.append({
"source_path": path,
"success": False,
"error": f"缺少列:{''.join(missing)}",
}) })
failed_count += 1
continue
rows: list[str] = [] return {
rows.extend(template["preamble_lines"]) "success": True,
rows.append('\t'.join(template["header_columns"])) "summary": {
for _ in range(template.get("blank_header_rows_after_schema", 0)): "total": data.get("total") or len(paths),
rows.append('\t' * (len(template["header_columns"]) - 1)) "success_count": data.get("successCount") or 0,
"failed_count": data.get("failedCount") or 0,
batch_id = generate_snowflake_like_id() },
row_index = 1 "items": result_items,
for row in sheet.iter_rows(min_row=2, values_only=True): }
line_values: list[str] = [] except Exception as exc: # noqa: BLE001
asin = normalize_cell_text(row[header_map['ASIN']] if header_map['ASIN'] < len(row) else '') return {"success": False, "error": str(exc)}
if not asin:
continue
for column in template["header_columns"]:
if column == 'sku':
line_values.append(f'{batch_id}-{row_index}')
continue
if column in template["field_mapping"]:
source_column = template["field_mapping"][column]
value = normalize_cell_text(row[header_map[source_column]] if header_map[source_column] < len(row) else '')
line_values.append(value)
continue
if column in template["defaults"]:
line_values.append(str(template["defaults"][column]))
continue
line_values.append('')
rows.append('\t'.join(line_values))
row_index += 1
output_path = build_named_output_path(output_dir, template["output_filename"])
Path(output_path).write_text('\n'.join(rows), encoding='utf-8')
workbook.close()
items.append({
"source_path": path,
"output_path": output_path,
"success": True,
})
success_count += 1
except Exception as exc: # noqa: BLE001
items.append({
"source_path": path,
"success": False,
"error": str(exc),
})
failed_count += 1
return {
"success": True,
"summary": {
"total": len(paths),
"success_count": success_count,
"failed_count": failed_count,
"output_dir": str(output_dir_path),
},
"items": items,
}
def open_path(self, path: str) -> dict: def open_path(self, path: str) -> dict:
try: try:
@@ -939,7 +950,7 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--port", type=int, default=0) parser.add_argument("--port", type=int, default=0)
parser.add_argument("--width", type=int, default=1440) parser.add_argument("--width", type=int, default=1440)
parser.add_argument("--height", type=int, default=960) parser.add_argument("--height", type=int, default=960)
parser.add_argument("--title", default="南日AI") parser.add_argument("--title", default="数富AI")
return parser return parser
@@ -985,7 +996,7 @@ def main() -> None:
ensure_dev_server_ready(config.frontend_url) ensure_dev_server_ready(config.frontend_url)
app_url = config.frontend_url app_url = config.frontend_url
api = DesktopApi(app_url) api = DesktopApi(config.backend_url)
window = webview.create_window( window = webview.create_window(
title=config.title, title=config.title,
url=app_url, url=app_url,

View File

@@ -64,4 +64,4 @@ Write-Host "Ensuring desktop dependencies..."
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt") Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "pip", "install", "-r", ".\desktop\requirements.txt")
Write-Host "Starting desktop app..." Write-Host "Starting desktop app..."
Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:8000") Invoke-Python -PythonCommand $pythonCmd -Arguments @("-m", "desktop.app", "--mode", "dev", "--frontend-url", "http://127.0.0.1:5173", "--backend-url", "http://127.0.0.1:18080")

View File

@@ -90,6 +90,51 @@
"price": "价格", "price": "价格",
"product-id": "ASIN" "product-id": "ASIN"
} }
},
"user_1773982097249": {
"id": "user_1773982097249",
"label": "默认",
"output_filename": "默认.txt",
"is_default": false,
"built_in": false,
"preamble_lines": [
"TemplateType=Offer\tVersion=1.4\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t"
],
"header_columns": [
"sku",
"price",
"quantity",
"product-id",
"product-id-type",
"condition-type",
"condition-note",
"ASIN-hint",
"title",
"product-tax-code",
"operation-type",
"sale-price",
"sale-start-date",
"sale-end-date",
"leadtime-to-ship",
"launch-date",
"is-giftwrap-available",
"is-gift-message-available"
],
"blank_header_rows_after_schema": 1,
"required_source_columns": [
"ASIN",
"价格"
],
"defaults": {
"quantity": "100",
"product-id-type": "ASIN",
"condition-type": "new",
"leadtime-to-ship": "4"
},
"field_mapping": {
"price": "价格",
"product-id": "ASIN"
}
} }
} }
} }

12
frontend-vue/convert.html Normal file
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> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>南日AI</title> <title>数富AI</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

12
frontend-vue/split.html Normal file
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 }" :class="{ active: convertTemplateId === template.id }"
> >
<input v-model="convertTemplateId" type="radio" :value="template.id" name="convert-template" /> <input v-model="convertTemplateId" type="radio" :value="template.id" name="convert-template" />
<div> <div class="template-item-content">
<span class="label"> <div>
{{ template.label }} <span class="label">
<span v-if="template.is_default" class="template-tag">默认</span> {{ template.label }}
<span v-else-if="template.built_in" class="template-tag muted">内置</span> <span v-if="template.is_default" class="template-tag">默认</span>
</span> <span v-else-if="template.built_in" class="template-tag muted">内置</span>
<div class="desc">输出文件{{ template.output_filename }}</div> </span>
<div class="desc">输出文件{{ template.output_filename }}</div>
</div>
<button
v-if="!template.built_in"
type="button"
class="template-delete-btn"
@click.stop.prevent="deleteConvertTemplate(template.id)"
>
删除
</button>
</div> </div>
</label> </label>
</div> </div>
@@ -81,7 +91,7 @@
{{ convertRunning ? '转换中...' : '开始转换' }} {{ convertRunning ? '转换中...' : '开始转换' }}
</button> </button>
<span class="loading-msg"> <span class="loading-msg">
{{ convertRunning ? '正在生成英国.txt,请稍候…' : '选择文件后即可执行格式转换' }} {{ convertRunning ? '正在生成并上传结果,请稍候…' : '选择文件后即可执行格式转换,结果会显示在右侧供下载' }}
</span> </span>
</div> </div>
</aside> </aside>
@@ -108,9 +118,6 @@
<div class="result-list-wrap"> <div class="result-list-wrap">
<div class="result-list-header"> <div class="result-list-header">
<span>生成的 txt 列表</span> <span>生成的 txt 列表</span>
<button type="button" class="download-all" :disabled="!convertOutputDir" @click="openConvertOutputDir">
打开输出目录
</button>
</div> </div>
<div v-if="convertResultItems.length === 0" class="empty-tasks"> <div v-if="convertResultItems.length === 0" class="empty-tasks">
@@ -120,12 +127,12 @@
<ul v-else class="task-list clean-result-list"> <ul v-else class="task-list clean-result-list">
<li <li
v-for="item in convertResultItems" v-for="item in convertResultItems"
:key="`${item.source_path}-${item.output_path || item.error || 'convert'}`" :key="`${item.output_path || item.source_path || item.error || 'convert'}`"
class="task-item" class="task-item"
> >
<div class="left"> <div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span> <span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div> <div v-if="item.output_path" class="files">结果下载地址已生成</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div> <div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div> </div>
@@ -137,9 +144,17 @@
v-if="item.success && item.output_path" v-if="item.success && item.output_path"
type="button" type="button"
class="download" class="download"
@click="openConvertResult(item)" @click="downloadConvertResult(item)"
> >
打开文件 下载文件
</button>
<button
v-if="item.result_id"
type="button"
class="btn-delete"
@click="deleteConvertHistory(item.result_id)"
>
删除
</button> </button>
</div> </div>
</li> </li>
@@ -175,7 +190,6 @@ function shorten(value: string, maxLength: number) {
} }
function resetConvertResults() { function resetConvertResults() {
convertResultItems.value = []
convertSummary.value = { total: 0, success_count: 0, failed_count: 0 } convertSummary.value = { total: 0, success_count: 0, failed_count: 0 }
convertOutputDir.value = '' convertOutputDir.value = ''
} }
@@ -217,6 +231,26 @@ async function importConvertTemplate() {
ElMessage.success('模板导入成功') ElMessage.success('模板导入成功')
} }
async function deleteConvertTemplate(templateId: string) {
const api = getPywebviewApi()
if (!api?.delete_txt_template) {
ElMessage.warning('当前环境不支持删除模板')
return
}
const result = await api.delete_txt_template(templateId)
if (!result.success) {
ElMessage.error(result.error || '删除模板失败')
return
}
if (convertTemplateId.value === templateId) {
convertTemplateId.value = ''
}
await loadConvertTemplates()
ElMessage.success('模板已删除')
}
async function setConvertDefaultTemplate() { async function setConvertDefaultTemplate() {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.set_default_txt_template || !convertTemplateId.value) { if (!api?.set_default_txt_template || !convertTemplateId.value) {
@@ -279,7 +313,7 @@ async function selectConvertFolder() {
async function submitConvertRun() { async function submitConvertRun() {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.convert_excel_to_uk_txt || !api?.select_folder) { if (!api?.convert_excel_to_uk_txt) {
ElMessage.warning('当前环境不支持格式转换,请在桌面端打开') ElMessage.warning('当前环境不支持格式转换,请在桌面端打开')
return return
} }
@@ -289,13 +323,9 @@ async function submitConvertRun() {
} }
try { try {
const outputDir = await api.select_folder()
if (!outputDir) return
convertRunning.value = true convertRunning.value = true
const result = await api.convert_excel_to_uk_txt({ const result = await api.convert_excel_to_uk_txt({
paths: convertSelectedPaths.value, paths: convertSelectedPaths.value,
output_dir: outputDir,
template_id: convertTemplateId.value, template_id: convertTemplateId.value,
}) })
@@ -304,13 +334,13 @@ async function submitConvertRun() {
return return
} }
convertOutputDir.value = result.summary?.output_dir || outputDir convertOutputDir.value = ''
convertSummary.value = { convertSummary.value = {
total: result.summary?.total || 0, total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0, success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0, failed_count: result.summary?.failed_count || 0,
} }
convertResultItems.value = result.items || [] await loadConvertHistory()
ElMessage.success('格式转换完成') ElMessage.success('格式转换完成')
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '格式转换失败') ElMessage.error(error instanceof Error ? error.message : '格式转换失败')
@@ -319,34 +349,68 @@ async function submitConvertRun() {
} }
} }
async function openConvertOutputDir() { async function deleteConvertHistory(resultId: number) {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.open_path || !convertOutputDir.value) { if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持打开输出目录') ElMessage.warning('当前环境不支持删除历史')
return return
} }
const result = await api.open_path(convertOutputDir.value) const result = await api.delete_history_item('convert', resultId)
if (!result.success) { if (!result.success) {
ElMessage.error(result.error || '打开目录失败') ElMessage.error(result.error || '删除失败')
return
} }
await loadConvertHistory()
ElMessage.success('已删除')
} }
async function openConvertResult(item: ConvertTxtResultItem) { async function downloadConvertResult(item: ConvertTxtResultItem) {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.open_path || !item.output_path) { if (!item.output_path) {
ElMessage.warning('当前环境不支持打开结果文件') ElMessage.warning('当前结果没有下载地址')
return return
} }
const result = await api.open_path(item.output_path) const today = new Date()
if (!result.success) { const yyyy = String(today.getFullYear())
ElMessage.error(result.error || '打开文件失败') const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const filename = `${yyyy}${mm}${dd}.txt`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.output_path, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(item.output_path, '_blank')
}
async function loadConvertHistory() {
const api = getPywebviewApi()
if (!api?.get_convert_history) return
try {
const response = await api.get_convert_history()
if (!response.success || !response.items) return
convertResultItems.value = response.items
convertSummary.value = {
total: response.items.length,
success_count: response.items.filter((item) => item.success).length,
failed_count: response.items.filter((item) => !item.success).length,
}
} catch {
// ignore history load errors
} }
} }
onMounted(() => { onMounted(() => {
loadConvertTemplates().catch(() => undefined) loadConvertTemplates().catch(() => undefined)
loadConvertHistory().catch(() => undefined)
}) })
</script> </script>
@@ -374,7 +438,10 @@ onMounted(() => {
.template-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #8fd0ff; background: rgba(52, 152, 219, 0.14); } .template-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; color: #8fd0ff; background: rgba(52, 152, 219, 0.14); }
.template-tag.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); } .template-tag.muted { color: #b8b8b8; background: rgba(255, 255, 255, 0.08); }
.compact-row { margin-top: 4px; } .compact-row { margin-top: 4px; }
.radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; } .radio-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; background: #252525; border: 1px solid #2a2a2a; margin-bottom: 8px; transition: all 0.2s ease; }
.template-item-content { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; }
.template-delete-btn { padding: 6px 10px; border: 1px solid #5c2f2f; border-radius: 6px; background: rgba(231, 76, 60, 0.12); color: #ff8f8f; font-size: 12px; cursor: pointer; }
.template-delete-btn:hover { background: rgba(231, 76, 60, 0.2); }
.radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; } .radio-item:hover, .radio-item.active { background: #2a2a2a; border-color: #3a3a3a; }
.radio-item input { margin-top: 3px; } .radio-item input { margin-top: 3px; }
.label { font-weight: 500; color: #e0e0e0; font-size: 13px; } .label { font-weight: 500; color: #e0e0e0; font-size: 13px; }

View File

@@ -79,7 +79,7 @@
{{ cleanRunning ? '清洗中...' : '开始清洗' }} {{ cleanRunning ? '清洗中...' : '开始清洗' }}
</button> </button>
<span class="loading-msg"> <span class="loading-msg">
{{ cleanRunning ? '正在处理文件,请稍候…' : '选择文件、列和 ID 规则后即可执行本地清洗' }} {{ cleanRunning ? '正在处理文件并上传结果,请稍候…' : '选择文件、列和 ID 规则后即可执行清洗,结果会显示在右侧供下载' }}
</span> </span>
</div> </div>
</aside> </aside>
@@ -106,9 +106,6 @@
<div class="result-list-wrap"> <div class="result-list-wrap">
<div class="result-list-header"> <div class="result-list-header">
<span>处理后的 Excel 列表</span> <span>处理后的 Excel 列表</span>
<button type="button" class="download-all" :disabled="!cleanOutputDir" @click="openCleanOutputDir">
打开输出目录
</button>
</div> </div>
<div v-if="cleanResultItems.length === 0" class="empty-tasks"> <div v-if="cleanResultItems.length === 0" class="empty-tasks">
@@ -118,8 +115,8 @@
<ul v-else class="task-list clean-result-list"> <ul v-else class="task-list clean-result-list">
<li v-for="item in cleanResultItems" :key="`${item.source_path}-${item.output_path || item.error || 'result'}`" class="task-item"> <li v-for="item in cleanResultItems" :key="`${item.source_path}-${item.output_path || item.error || 'result'}`" class="task-item">
<div class="left"> <div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span> <span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div> <div v-if="item.output_path" class="files">结果下载地址已生成</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div> <div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div> </div>
@@ -131,9 +128,17 @@
v-if="item.success && item.output_path" v-if="item.success && item.output_path"
type="button" type="button"
class="download" class="download"
@click="openCleanResult(item)" @click="downloadCleanResult(item)"
> >
打开文件 下载文件
</button>
<button
v-if="item.result_id"
type="button"
class="btn-delete"
@click="deleteCleanHistory(item.result_id)"
>
删除
</button> </button>
</div> </div>
</li> </li>
@@ -145,7 +150,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { expandBrandFolder } from '@/shared/api/brand' import { expandBrandFolder } from '@/shared/api/brand'
import { type CleanExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview' import { type CleanExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
@@ -181,7 +186,6 @@ async function loadCleanHeaders(filePath: string) {
return return
} }
cleanResultItems.value = []
cleanSummary.value = { total: 0, success_count: 0, failed_count: 0 } cleanSummary.value = { total: 0, success_count: 0, failed_count: 0 }
cleanOutputDir.value = '' cleanOutputDir.value = ''
@@ -248,7 +252,7 @@ async function selectCleanFolder() {
async function submitCleanRun() { async function submitCleanRun() {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.clean_excel_files || !api?.select_folder) { if (!api?.clean_excel_files) {
ElMessage.warning('当前环境不支持数据去重,请在桌面端打开') ElMessage.warning('当前环境不支持数据去重,请在桌面端打开')
return return
} }
@@ -266,16 +270,12 @@ async function submitCleanRun() {
} }
try { try {
const outputDir = await api.select_folder()
if (!outputDir) return
cleanRunning.value = true cleanRunning.value = true
const result = await api.clean_excel_files({ const result = await api.clean_excel_files({
paths: cleanSelectedPaths.value, paths: cleanSelectedPaths.value,
selected_columns: cleanSelectedColumns.value, selected_columns: cleanSelectedColumns.value,
keep_integer_ids: cleanKeepIntegerIds.value, keep_integer_ids: cleanKeepIntegerIds.value,
keep_underscore_ids: cleanKeepUnderscoreIds.value, keep_underscore_ids: cleanKeepUnderscoreIds.value,
output_dir: outputDir,
}) })
if (!result.success) { if (!result.success) {
@@ -283,13 +283,13 @@ async function submitCleanRun() {
return return
} }
cleanOutputDir.value = result.summary?.output_dir || outputDir cleanOutputDir.value = ''
cleanSummary.value = { cleanSummary.value = {
total: result.summary?.total || 0, total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0, success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0, failed_count: result.summary?.failed_count || 0,
} }
cleanResultItems.value = result.items || [] await loadCleanHistory()
ElMessage.success('数据去重完成') ElMessage.success('数据去重完成')
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '去重失败') ElMessage.error(error instanceof Error ? error.message : '去重失败')
@@ -298,30 +298,69 @@ async function submitCleanRun() {
} }
} }
async function openCleanOutputDir() { onMounted(() => {
loadCleanHistory().catch(() => undefined)
})
async function loadCleanHistory() {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.open_path || !cleanOutputDir.value) { if (!api?.get_dedupe_history) {
ElMessage.warning('当前环境不支持打开输出目录')
return return
} }
const result = await api.open_path(cleanOutputDir.value) try {
if (!result.success) { const response = await api.get_dedupe_history()
ElMessage.error(result.error || '打开目录失败') if (!response.success || !response.items) return
cleanResultItems.value = response.items
cleanSummary.value = {
total: response.items.length,
success_count: response.items.filter((item) => item.success).length,
failed_count: response.items.filter((item) => !item.success).length,
}
} catch {
// ignore history load errors
} }
} }
async function openCleanResult(item: CleanExcelResultItem) { async function deleteCleanHistory(resultId: number) {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.open_path || !item.output_path) { if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持打开结果文件') ElMessage.warning('当前环境不支持删除历史')
return return
} }
const result = await api.open_path(item.output_path) const result = await api.delete_history_item('dedupe', resultId)
if (!result.success) { if (!result.success) {
ElMessage.error(result.error || '打开文件失败') ElMessage.error(result.error || '删除失败')
return
} }
await loadCleanHistory()
ElMessage.success('已删除')
}
async function downloadCleanResult(item: CleanExcelResultItem) {
const api = getPywebviewApi()
if (!item.output_path) {
ElMessage.warning('当前结果没有下载地址')
return
}
const today = new Date()
const yyyy = String(today.getFullYear())
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const filename = `${yyyy}${mm}${dd}_cleaned.xlsx`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.output_path, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(item.output_path, '_blank')
} }
</script> </script>

View File

@@ -90,7 +90,7 @@
{{ splitRunning ? '拆分中...' : '开始拆分' }} {{ splitRunning ? '拆分中...' : '开始拆分' }}
</button> </button>
<span class="loading-msg"> <span class="loading-msg">
{{ splitRunning ? '正在拆分文件,请稍候…' : '选择文件、保留列和拆分规则后即可执行本地拆分' }} {{ splitRunning ? '正在拆分并上传结果,请稍候…' : '选择文件、保留列和拆分规则后即可执行拆分,结果会显示在右侧供下载' }}
</span> </span>
</div> </div>
</aside> </aside>
@@ -116,9 +116,6 @@
<div class="result-list-wrap"> <div class="result-list-wrap">
<div class="result-list-header"> <div class="result-list-header">
<span>拆分后的 Excel 列表</span> <span>拆分后的 Excel 列表</span>
<button type="button" class="download-all" :disabled="!splitOutputDir" @click="openSplitOutputDir">
打开输出目录
</button>
</div> </div>
<div v-if="splitResultItems.length === 0" class="empty-tasks"> <div v-if="splitResultItems.length === 0" class="empty-tasks">
@@ -128,8 +125,8 @@
<ul v-else class="task-list clean-result-list"> <ul v-else class="task-list clean-result-list">
<li v-for="item in splitResultItems" :key="`${item.output_path || item.source_path}-${item.row_count || 0}`" class="task-item"> <li v-for="item in splitResultItems" :key="`${item.output_path || item.source_path}-${item.row_count || 0}`" class="task-item">
<div class="left"> <div class="left">
<span class="id" :title="item.source_path">{{ shorten(item.source_path, 42) }}</span> <span class="id" :title="item.source_path">{{ item.source_path || '-' }}</span>
<div v-if="item.output_path" class="files">输出文件{{ item.output_path }}</div> <div v-if="item.output_path" class="files">结果下载地址已生成</div>
<div v-if="item.row_count !== undefined" class="time">包含 {{ item.row_count }} 条数据</div> <div v-if="item.row_count !== undefined" class="time">包含 {{ item.row_count }} 条数据</div>
<div v-else-if="item.error" class="files">错误信息{{ item.error }}</div> <div v-else-if="item.error" class="files">错误信息{{ item.error }}</div>
</div> </div>
@@ -142,9 +139,17 @@
v-if="item.success && item.output_path" v-if="item.success && item.output_path"
type="button" type="button"
class="download" class="download"
@click="openSplitResult(item)" @click="downloadSplitResult(item)"
> >
打开文件 下载文件
</button>
<button
v-if="item.result_id"
type="button"
class="btn-delete"
@click="deleteSplitHistory(item.result_id)"
>
删除
</button> </button>
</div> </div>
</li> </li>
@@ -156,7 +161,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { expandBrandFolder } from '@/shared/api/brand' import { expandBrandFolder } from '@/shared/api/brand'
import { type SplitExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview' import { type SplitExcelResultItem, getPywebviewApi, hasPywebview } from '@/shared/bridges/pywebview'
@@ -179,7 +184,6 @@ function shorten(value: string, maxLength: number) {
} }
function resetSplitResults() { function resetSplitResults() {
splitResultItems.value = []
splitSummary.value = { total: 0, success_count: 0, failed_count: 0 } splitSummary.value = { total: 0, success_count: 0, failed_count: 0 }
splitOutputDir.value = '' splitOutputDir.value = ''
} }
@@ -251,7 +255,7 @@ async function selectSplitFolder() {
async function submitSplitRun() { async function submitSplitRun() {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.split_excel_files || !api?.select_folder) { if (!api?.split_excel_files) {
ElMessage.warning('当前环境不支持数据拆分,请在桌面端打开') ElMessage.warning('当前环境不支持数据拆分,请在桌面端打开')
return return
} }
@@ -273,9 +277,6 @@ async function submitSplitRun() {
} }
try { try {
const outputDir = await api.select_folder()
if (!outputDir) return
splitRunning.value = true splitRunning.value = true
const result = await api.split_excel_files({ const result = await api.split_excel_files({
paths: splitSelectedPaths.value, paths: splitSelectedPaths.value,
@@ -283,7 +284,6 @@ async function submitSplitRun() {
split_mode: splitMode.value, split_mode: splitMode.value,
rows_per_file: splitRowsPerFile.value || undefined, rows_per_file: splitRowsPerFile.value || undefined,
parts: splitParts.value || undefined, parts: splitParts.value || undefined,
output_dir: outputDir,
}) })
if (!result.success) { if (!result.success) {
@@ -291,13 +291,13 @@ async function submitSplitRun() {
return return
} }
splitOutputDir.value = result.summary?.output_dir || outputDir splitOutputDir.value = ''
splitSummary.value = { splitSummary.value = {
total: result.summary?.total || 0, total: result.summary?.total || 0,
success_count: result.summary?.success_count || 0, success_count: result.summary?.success_count || 0,
failed_count: result.summary?.failed_count || 0, failed_count: result.summary?.failed_count || 0,
} }
splitResultItems.value = result.items || [] await loadSplitHistory()
ElMessage.success('数据拆分完成') ElMessage.success('数据拆分完成')
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '数据拆分失败') ElMessage.error(error instanceof Error ? error.message : '数据拆分失败')
@@ -306,30 +306,67 @@ async function submitSplitRun() {
} }
} }
async function openSplitOutputDir() { async function loadSplitHistory() {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.open_path || !splitOutputDir.value) { if (!api?.get_split_history) return
ElMessage.warning('当前环境不支持打开输出目录')
return
}
const result = await api.open_path(splitOutputDir.value) try {
if (!result.success) { const response = await api.get_split_history()
ElMessage.error(result.error || '打开目录失败') if (!response.success || !response.items) return
splitResultItems.value = response.items
splitSummary.value = {
total: response.items.length,
success_count: response.items.filter((item) => item.success).length,
failed_count: response.items.filter((item) => !item.success).length,
}
} catch {
// ignore history load errors
} }
} }
async function openSplitResult(item: SplitExcelResultItem) { onMounted(() => {
loadSplitHistory().catch(() => undefined)
})
async function deleteSplitHistory(resultId: number) {
const api = getPywebviewApi() const api = getPywebviewApi()
if (!api?.open_path || !item.output_path) { if (!api?.delete_history_item) {
ElMessage.warning('当前环境不支持打开结果文件') ElMessage.warning('当前环境不支持删除历史')
return return
} }
const result = await api.open_path(item.output_path) const result = await api.delete_history_item('split', resultId)
if (!result.success) { if (!result.success) {
ElMessage.error(result.error || '打开文件失败') ElMessage.error(result.error || '删除失败')
return
} }
await loadSplitHistory()
ElMessage.success('已删除')
}
async function downloadSplitResult(item: SplitExcelResultItem) {
const api = getPywebviewApi()
if (!item.output_path) {
ElMessage.warning('当前结果没有下载地址')
return
}
const today = new Date()
const yyyy = String(today.getFullYear())
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const filename = `${yyyy}${mm}${dd}.xlsx`
if (api?.save_file_from_url) {
const result = await api.save_file_from_url(item.output_path, filename)
if (result.success) {
ElMessage.success(`已保存:${result.path || filename}`)
} else if (result.error && result.error !== '用户取消') {
ElMessage.error(result.error)
}
return
}
window.open(item.output_path, '_blank')
} }
</script> </script>

File diff suppressed because it is too large Load Diff

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' split_mode: 'rows_per_file' | 'parts'
rows_per_file?: number rows_per_file?: number
parts?: number parts?: number
output_dir: string output_dir?: string
} }
export interface SplitExcelResultItem { export interface SplitExcelResultItem {
result_id?: number
source_path: string source_path: string
output_path?: string output_path?: string
success: boolean success: boolean
@@ -36,7 +37,16 @@ export interface ExcelInfoResponse {
error?: string error?: string
} }
export interface CleanExcelPayload {
paths: string[]
selected_columns: string[]
keep_integer_ids?: boolean
keep_underscore_ids?: boolean
output_dir?: string
}
export interface CleanExcelResultItem { export interface CleanExcelResultItem {
result_id?: number
source_path: string source_path: string
output_path?: string output_path?: string
success: boolean success: boolean
@@ -45,7 +55,7 @@ export interface CleanExcelResultItem {
export interface ConvertTxtPayload { export interface ConvertTxtPayload {
paths: string[] paths: string[]
output_dir: string output_dir?: string
template_id: string template_id: string
} }
@@ -58,6 +68,7 @@ export interface ConvertTxtTemplateItem {
} }
export interface ConvertTxtResultItem { export interface ConvertTxtResultItem {
result_id?: number
source_path: string source_path: string
output_path?: string output_path?: string
success: boolean success: boolean
@@ -92,6 +103,24 @@ export interface CleanExcelResponse {
error?: string error?: string
} }
export interface ConvertTxtHistoryResponse {
success: boolean
items?: ConvertTxtResultItem[]
error?: string
}
export interface SplitExcelHistoryResponse {
success: boolean
items?: SplitExcelResultItem[]
error?: string
}
export interface CleanExcelHistoryResponse {
success: boolean
items?: CleanExcelResultItem[]
error?: string
}
export interface PywebviewApi { export interface PywebviewApi {
select_brand_xlsx_files?: () => Promise<string[]> select_brand_xlsx_files?: () => Promise<string[]>
select_clean_xlsx_files?: () => Promise<string[]> select_clean_xlsx_files?: () => Promise<string[]>
@@ -99,12 +128,17 @@ export interface PywebviewApi {
select_clean_folder?: () => Promise<string | null> select_clean_folder?: () => Promise<string | null>
select_folder?: () => Promise<string | null> select_folder?: () => Promise<string | null>
get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }> get_excel_headers?: (filePath: string) => Promise<{ success: boolean; headers?: string[]; error?: string }>
get_dedupe_history?: () => Promise<CleanExcelHistoryResponse>
get_convert_history?: () => Promise<ConvertTxtHistoryResponse>
get_split_history?: () => Promise<SplitExcelHistoryResponse>
delete_history_item?: (moduleType: string, resultId: number) => Promise<{ success: boolean; error?: string }>
get_excel_info?: (filePath: string) => Promise<ExcelInfoResponse> get_excel_info?: (filePath: string) => Promise<ExcelInfoResponse>
clean_excel_files?: (payload: CleanExcelPayload) => Promise<CleanExcelResponse> clean_excel_files?: (payload: CleanExcelPayload) => Promise<CleanExcelResponse>
split_excel_files?: (payload: SplitExcelPayload) => Promise<SplitExcelResponse> split_excel_files?: (payload: SplitExcelPayload) => Promise<SplitExcelResponse>
list_txt_templates?: () => Promise<ConvertTxtTemplateItem[]> list_txt_templates?: () => Promise<ConvertTxtTemplateItem[]>
import_txt_template?: (name?: string) => Promise<{ success: boolean; template?: ConvertTxtTemplateItem; error?: string }> import_txt_template?: (name?: string) => Promise<{ success: boolean; template?: ConvertTxtTemplateItem; error?: string }>
set_default_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }> set_default_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }>
delete_txt_template?: (templateId: string) => Promise<{ success: boolean; error?: string }>
convert_excel_to_uk_txt?: (payload: ConvertTxtPayload) => Promise<ConvertTxtResponse> convert_excel_to_uk_txt?: (payload: ConvertTxtPayload) => Promise<ConvertTxtResponse>
open_path?: (path: string) => Promise<{ success: boolean; error?: string }> open_path?: (path: string) => Promise<{ success: boolean; error?: string }>
save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }> save_template_xlsx?: () => Promise<{ success: boolean; path?: string; error?: string }>

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 ElementPlus from 'element-plus'
import 'element-plus/dist/index.css' import 'element-plus/dist/index.css'
import '@/styles/main.css' import '@/styles/main.css'
import App from '@/App.vue' import BrandSplitTab from '@/pages/brand/components/BrandSplitTab.vue'
import router from '@/router'
createApp(App).use(ElementPlus).use(router).mount('#app') createApp(BrandSplitTab).use(ElementPlus).mount('#app')

View File

@@ -1,4 +1,5 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import { resolve } from 'node:path'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite' import AutoImport from 'unplugin-auto-import/vite'
@@ -6,6 +7,7 @@ import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({ export default defineConfig({
base: './',
plugins: [ plugins: [
vue(), vue(),
AutoImport({ AutoImport({
@@ -47,5 +49,19 @@ export default defineConfig({
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
emptyOutDir: true,
cssCodeSplit: true,
rollupOptions: {
input: {
dedupe: resolve(__dirname, 'dedupe.html'),
convert: resolve(__dirname, 'convert.html'),
split: resolve(__dirname, 'split.html'),
},
output: {
entryFileNames: 'assets/[name].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
},
},
}, },
}) })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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