diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java index 6ca1ab0..11eda8a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.dedupe.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataCreateRequest; import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataUpdateRequest; import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportProgressVo; @@ -17,6 +18,10 @@ 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.format.annotation.DateTimeFormat; +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; @@ -28,12 +33,18 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + @RestController @RequiredArgsConstructor @RequestMapping("/api/admin/dedupe-total-data") @Tag(name = "数据去重总数据", description = "维护数据去重模块的总数据列表,支持增删改查。") public class DedupeTotalDataController { + private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private final DedupeTotalDataService dedupeTotalDataService; @GetMapping @@ -50,6 +61,24 @@ public class DedupeTotalDataController { return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, keyword, username, operatorId)); } + @GetMapping("/export") + @Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据。") + public ResponseEntity export( + @Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username, + @Parameter(description = "开始日期(包含)") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @Parameter(description = "结束日期(包含)") + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) { + byte[] bytes = dedupeTotalDataService.export(username, startDate, endDate, operatorId); + String filename = "dedupe-total-data-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .contentLength(bytes.length) + .body(bytes); + } + @PostMapping @Operation(summary = "新增总数据", description = "新增一条数据去重总数据。") @ApiResponses({ diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java index 9e2f84c..d1b1030 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java @@ -21,6 +21,7 @@ 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.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; @@ -28,11 +29,16 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; import org.springframework.http.HttpStatus; +import org.springframework.dao.DuplicateKeyException; import org.springframework.web.server.ResponseStatusException; +import java.io.ByteArrayOutputStream; import java.io.File; import java.io.InputStream; import java.nio.file.Files; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; @@ -48,6 +54,8 @@ import java.util.concurrent.ConcurrentHashMap; public class DedupeTotalDataService { private static final int COMPARE_BATCH_SIZE = 5000; + private static final long COMPLETED_PROGRESS_RETENTION_MILLIS = 60 * 60 * 1000L; + private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final DedupeTotalDataMapper dedupeTotalDataMapper; private final AdminUserMapper adminUserMapper; @@ -57,6 +65,8 @@ public class DedupeTotalDataService { private final Map deleteImportProgressMap = new ConcurrentHashMap<>(); private final Map importOwnerMap = new ConcurrentHashMap<>(); private final Map deleteImportOwnerMap = new ConcurrentHashMap<>(); + private final Map importCompletedAtMap = new ConcurrentHashMap<>(); + private final Map deleteImportCompletedAtMap = new ConcurrentHashMap<>(); private TransactionTemplate newRequiresNewTemplate() { TransactionTemplate template = new TransactionTemplate(transactionManager); @@ -70,16 +80,10 @@ public class DedupeTotalDataService { String safeKeyword = keyword == null ? "" : keyword.trim(); String safeUsername = username == null ? "" : username.trim(); AccessScope scope = resolveAccessScope(operatorId); - Set usernameUserIds = safeUsername.isEmpty() - ? Set.of() - : findUserIdsByUsername(safeUsername, scope); - if (!safeUsername.isEmpty() && usernameUserIds.isEmpty()) { - return emptyPage(safePage, safePageSize); - } LambdaQueryWrapper query = new LambdaQueryWrapper() .like(!safeKeyword.isEmpty(), DedupeTotalDataEntity::getDataValue, safeKeyword) .in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds()) - .in(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUserId, usernameUserIds) + .like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername) .orderByDesc(DedupeTotalDataEntity::getId); Long total = dedupeTotalDataMapper.selectCount(query); List items = dedupeTotalDataMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)) @@ -94,6 +98,57 @@ public class DedupeTotalDataService { return vo; } + public byte[] export(String username, LocalDate startDate, LocalDate endDate, Long operatorId) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new BusinessException("开始日期不能晚于结束日期"); + } + String safeUsername = username == null ? "" : username.trim(); + AccessScope scope = resolveAccessScope(operatorId); + LambdaQueryWrapper query = new LambdaQueryWrapper() + .in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds()) + .like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername) + .ge(startDate != null, DedupeTotalDataEntity::getCreatedAt, + startDate == null ? null : startDate.atStartOfDay()) + .lt(endDate != null, DedupeTotalDataEntity::getCreatedAt, + endDate == null ? null : endDate.plusDays(1).atStartOfDay()) + .orderByDesc(DedupeTotalDataEntity::getId); + List rows = dedupeTotalDataMapper.selectList(query); + return buildExportWorkbook(rows); + } + + private byte[] buildExportWorkbook(List rows) { + try (SXSSFWorkbook workbook = new SXSSFWorkbook(100); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + Sheet sheet = workbook.createSheet("DedupeTotalData"); + Row header = sheet.createRow(0); + header.createCell(0).setCellValue("ID"); + header.createCell(1).setCellValue("ASIN值"); + header.createCell(2).setCellValue("用户名"); + header.createCell(3).setCellValue("创建时间"); + for (int index = 0; index < rows.size(); index++) { + DedupeTotalDataEntity entity = rows.get(index); + Row row = sheet.createRow(index + 1); + row.createCell(0).setCellValue(entity.getId() == null ? "" : String.valueOf(entity.getId())); + row.createCell(1).setCellValue(entity.getDataValue() == null ? "" : entity.getDataValue()); + row.createCell(2).setCellValue(entity.getUploaderUsername() == null ? "" : entity.getUploaderUsername()); + row.createCell(3).setCellValue(formatExportTime(entity.getCreatedAt())); + } + sheet.setColumnWidth(0, 3600); + sheet.setColumnWidth(1, 5200); + sheet.setColumnWidth(2, 5200); + sheet.setColumnWidth(3, 5600); + workbook.write(outputStream); + workbook.dispose(); + return outputStream.toByteArray(); + } catch (Exception ex) { + throw new BusinessException("导出数据去重总数据失败"); + } + } + + private String formatExportTime(LocalDateTime value) { + return value == null ? "" : value.format(EXPORT_TIME_FORMATTER); + } + @Transactional public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request, Long operatorId) { AdminUserEntity uploader = getOperator(operatorId); @@ -129,12 +184,19 @@ public class DedupeTotalDataService { Set existingValues = new HashSet<>(); for (int start = 0; start < normalizedValues.size(); start += COMPARE_BATCH_SIZE) { int end = Math.min(start + COMPARE_BATCH_SIZE, normalizedValues.size()); - existingValues.addAll(dedupeTotalDataMapper.selectExistingDataValues(normalizedValues.subList(start, end))); + List batch = dedupeTotalDataMapper.selectExistingDataValues(normalizedValues.subList(start, end)); + if (batch != null) { + batch.stream() + .map(this::normalizeComparableValueOrBlank) + .filter(value -> !value.isEmpty()) + .forEach(existingValues::add); + } } return existingValues; } public DedupeTotalDataImportStartVo startImport(MultipartFile file, Long operatorId) { + cleanupExpiredProgress(); if (file == null || file.isEmpty()) { throw new BusinessException("请上传 xlsx 文件"); } @@ -167,6 +229,7 @@ public class DedupeTotalDataService { } public DedupeTotalDataImportProgressVo getImportProgress(String importId, Long operatorId) { + cleanupExpiredProgress(); DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId); Long ownerId = importOwnerMap.get(importId); if (progress == null || ownerId == null) { @@ -177,6 +240,7 @@ public class DedupeTotalDataService { } public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file, Long operatorId) { + cleanupExpiredProgress(); if (file == null || file.isEmpty()) { throw new BusinessException("请上传 xlsx 文件"); } @@ -208,6 +272,7 @@ public class DedupeTotalDataService { } public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId, Long operatorId) { + cleanupExpiredProgress(); DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId); Long ownerId = deleteImportOwnerMap.get(importId); if (progress == null || ownerId == null) { @@ -237,6 +302,7 @@ public class DedupeTotalDataService { progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "删除 Excel 匹配数据失败"); } finally { deleteQuietly(tempFile); + deleteImportCompletedAtMap.put(importId, System.currentTimeMillis()); } } @@ -261,6 +327,7 @@ public class DedupeTotalDataService { progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "导入 Excel 失败"); } finally { deleteQuietly(tempFile); + importCompletedAtMap.put(importId, System.currentTimeMillis()); } } @@ -407,17 +474,22 @@ public class DedupeTotalDataService { continue; } final String pendingDataValue = dataValue; - Boolean inserted = newRequiresNewTemplate().execute(status -> { - if (existsDataValue(pendingDataValue)) { - return false; - } - DedupeTotalDataEntity entity = new DedupeTotalDataEntity(); - entity.setDataValue(pendingDataValue); - entity.setUploaderUserId(uploaderUserId); - entity.setUploaderUsername(uploaderUsername); - dedupeTotalDataMapper.insert(entity); - return true; - }); + Boolean inserted; + try { + inserted = newRequiresNewTemplate().execute(status -> { + if (existsDataValue(pendingDataValue)) { + return false; + } + DedupeTotalDataEntity entity = new DedupeTotalDataEntity(); + entity.setDataValue(pendingDataValue); + entity.setUploaderUserId(uploaderUserId); + entity.setUploaderUsername(uploaderUsername); + dedupeTotalDataMapper.insert(entity); + return true; + }); + } catch (DuplicateKeyException ignored) { + inserted = false; + } if (Boolean.TRUE.equals(inserted)) { insertedCount++; } else { @@ -590,7 +662,7 @@ public class DedupeTotalDataService { } public String normalizeComparableValueOrBlank(String value) { - return normalizeExcelText(value); + return normalizeExcelText(value).toUpperCase(Locale.ROOT); } private void ensureUnique(String dataValue, Long excludeId) { @@ -649,31 +721,29 @@ public class DedupeTotalDataService { || (role.isEmpty() && Integer.valueOf(1).equals(user.getIsAdmin()) && user.getCreatedById() == null); } - private Set findUserIdsByUsername(String username, AccessScope scope) { - Set matched = adminUserMapper.selectIdsByUsernameLike(username) - .stream() - .filter(id -> id != null && id > 0) - .collect(java.util.stream.Collectors.toSet()); - if (scope.allUsers()) { - return matched; - } - matched.retainAll(scope.userIds()); - return matched; - } - private void ensureUploaderAccess(Long uploaderUserId, AccessScope scope) { if (!scope.allUsers() && (uploaderUserId == null || !scope.userIds().contains(uploaderUserId))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权操作该总数据"); } } - private DedupeTotalDataPageVo emptyPage(long page, long pageSize) { - DedupeTotalDataPageVo vo = new DedupeTotalDataPageVo(); - vo.setItems(List.of()); - vo.setTotal(0L); - vo.setPage(page); - vo.setPageSize(pageSize); - return vo; + private void cleanupExpiredProgress() { + long cutoff = System.currentTimeMillis() - COMPLETED_PROGRESS_RETENTION_MILLIS; + cleanupExpiredProgressEntries(importCompletedAtMap, importProgressMap, importOwnerMap, cutoff); + cleanupExpiredProgressEntries(deleteImportCompletedAtMap, deleteImportProgressMap, deleteImportOwnerMap, cutoff); + } + + private void cleanupExpiredProgressEntries( + Map completedAtMap, + Map progressMap, + Map ownerMap, + long cutoff) { + completedAtMap.forEach((id, completedAt) -> { + if (completedAt != null && completedAt < cutoff && completedAtMap.remove(id, completedAt)) { + progressMap.remove(id); + ownerMap.remove(id); + } + }); } private String normalizeExcelText(String value) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/mapper/AdminUserMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/mapper/AdminUserMapper.java index d32eacb..4e0c7ef 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/mapper/AdminUserMapper.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/mapper/AdminUserMapper.java @@ -3,14 +3,7 @@ package com.nanri.aiimage.modules.permission.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -import java.util.List; @Mapper public interface AdminUserMapper extends BaseMapper { - - @Select("SELECT id FROM users WHERE username LIKE CONCAT('%', #{username}, '%') ORDER BY id ASC") - List selectIdsByUsernameLike(@Param("username") String username); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java index c86f727..f26c98f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyCreateRequest.java @@ -9,6 +9,10 @@ import lombok.Data; @Schema(description = "店铺密钥新增请求") public class ShopKeyCreateRequest { + @Size(max = 128, message = "备注名长度不能超过128个字符") + @Schema(description = "备注名") + private String remarkName; + @NotBlank(message = "紫鸟账号名称不能为空") @Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符") @Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java index 15a744b..0469d05 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopKeyUpdateRequest.java @@ -9,6 +9,10 @@ import lombok.Data; @Schema(description = "店铺密钥更新请求") public class ShopKeyUpdateRequest { + @Size(max = 128, message = "备注名长度不能超过128个字符") + @Schema(description = "备注名") + private String remarkName; + @NotBlank(message = "紫鸟账号名称不能为空") @Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符") @Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java index 44c26d8..277f52e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java @@ -13,6 +13,7 @@ public class ShopKeyEntity { @TableId(type = IdType.AUTO) private Long id; + private String remarkName; private String ziniaoAccountName; private String ziniaoToken; private LocalDateTime createdAt; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java index 382993c..cd8574c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopKeyItemVo.java @@ -12,6 +12,9 @@ public class ShopKeyItemVo { @Schema(description = "主键ID") private Long id; + @Schema(description = "备注名") + private String remarkName; + @Schema(description = "紫鸟账号名称") private String ziniaoAccountName; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java index 8724bec..2305995 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java @@ -47,6 +47,7 @@ public class ShopKeyService { String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); ShopKeyEntity entity = new ShopKeyEntity(); + entity.setRemarkName(normalizeOptional(request.getRemarkName())); entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoToken(ziniaoToken); shopKeyMapper.insert(entity); @@ -59,6 +60,7 @@ public class ShopKeyService { ShopKeyEntity entity = getById(id); String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); + entity.setRemarkName(normalizeOptional(request.getRemarkName())); entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoToken(ziniaoToken); shopKeyMapper.updateById(entity); @@ -89,9 +91,14 @@ public class ShopKeyService { return normalized; } + private String normalizeOptional(String value) { + return value == null ? "" : value.trim(); + } + private ShopKeyItemVo toItemVo(ShopKeyEntity entity) { ShopKeyItemVo vo = new ShopKeyItemVo(); vo.setId(entity.getId()); + vo.setRemarkName(entity.getRemarkName()); vo.setZiniaoAccountName(entity.getZiniaoAccountName()); vo.setZiniaoToken(entity.getZiniaoToken()); vo.setCreatedAt(entity.getCreatedAt()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java index f9a4787..a804720 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java @@ -115,6 +115,7 @@ public class TaskResultFileJobWorker { private void processInternal(TaskFileJobEntity job) { long startedAt = System.currentTimeMillis(); + boolean finalizeWithdraw = false; try { TaskFileJobEntity latest = taskFileJobService.findById(job.getId()); if (latest != null && "SUCCESS".equals(latest.getStatus())) { @@ -162,10 +163,14 @@ public class TaskResultFileJobWorker { String resultFileUrl = resolveResultFileUrl(job); taskFileJobService.markSuccess(job, resultFileUrl); cleanupAfterSuccess(job); + finalizeWithdraw = "WITHDRAW".equals(job.getModuleType()); log.info("[task-file-job] process success jobId={} taskId={} moduleType={} resultId={} elapsedMs={} resultFileUrl={}", job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), System.currentTimeMillis() - startedAt, resultFileUrl); } + if (finalizeWithdraw) { + withdrawTaskService.tryFinalizeTask(job.getTaskId(), false); + } } catch (Exception ex) { TaskFileJobEntity latest = taskFileJobService.findById(job.getId()); if (latest != null && "SUCCESS".equals(latest.getStatus())) { diff --git a/backend-java/src/main/resources/db/V76__shop_key_remark_name.sql b/backend-java/src/main/resources/db/V76__shop_key_remark_name.sql new file mode 100644 index 0000000..ce663bd --- /dev/null +++ b/backend-java/src/main/resources/db/V76__shop_key_remark_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE biz_shop_key + ADD COLUMN remark_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '备注名' AFTER id; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java index 24ad3a8..41d7584 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java @@ -1,8 +1,10 @@ package com.nanri.aiimage.modules.dedupe.service; +import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.dedupe.mapper.DedupeTotalDataMapper; import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataCreateRequest; import com.nanri.aiimage.modules.dedupe.model.entity.DedupeTotalDataEntity; +import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportProgressVo; import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataItemVo; import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo; import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; @@ -15,15 +17,23 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionStatus; import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.server.ResponseStatusException; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -42,6 +52,8 @@ class DedupeTotalDataServiceTest { private ShopManageGroupMapper shopManageGroupMapper; @Mock private PlatformTransactionManager transactionManager; + @Mock + private TransactionStatus transactionStatus; @InjectMocks private DedupeTotalDataService service; @@ -65,13 +77,14 @@ class DedupeTotalDataServiceTest { }); DedupeTotalDataCreateRequest request = new DedupeTotalDataCreateRequest(); - request.setDataValue(" B012345678 "); + request.setDataValue(" b012345678 "); DedupeTotalDataItemVo item = service.create(request, 23L); ArgumentCaptor captor = ArgumentCaptor.forClass(DedupeTotalDataEntity.class); verify(dedupeTotalDataMapper).insert(captor.capture()); assertEquals(23L, captor.getValue().getUploaderUserId()); assertEquals("member-a", captor.getValue().getUploaderUsername()); + assertEquals("B012345678", captor.getValue().getDataValue()); assertEquals("member-a", item.getUsername()); } @@ -125,31 +138,106 @@ class DedupeTotalDataServiceTest { } @Test - void usernameSearchCannotExpandLeaderScope() { + void concurrentDuplicateDuringImportIsSkipped() throws Exception { + when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a")); + when(dedupeTotalDataMapper.selectOne(any())).thenReturn(null); + when(transactionManager.getTransaction(any())).thenReturn(transactionStatus); + when(dedupeTotalDataMapper.insert(any(DedupeTotalDataEntity.class))) + .thenThrow(new DuplicateKeyException("duplicate")); + + var result = service.importFromExcel(asinWorkbook("b012345678"), 23L); + + assertEquals(0, result.getInsertedCount()); + assertEquals(1, result.getSkippedCount()); + verify(transactionManager).rollback(transactionStatus); + } + + @Test + void usernameSearchKeepsLeaderScope() { when(adminUserMapper.selectById(10L)).thenReturn(user(10L, "admin", "leader")); when(shopManageGroupMapper.selectManagedMemberUserIds(10L)).thenReturn(List.of(23L)); - when(adminUserMapper.selectIdsByUsernameLike("other")).thenReturn(List.of(99L)); + when(dedupeTotalDataMapper.selectCount(any())).thenReturn(0L); + when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of()); DedupeTotalDataPageVo page = service.page(1, 15, "", "other", 10L); assertEquals(0L, page.getTotal()); assertTrue(page.getItems().isEmpty()); - verify(dedupeTotalDataMapper, never()).selectCount(any()); - verify(dedupeTotalDataMapper, never()).selectList(any()); + verify(shopManageGroupMapper).selectManagedMemberUserIds(10L); + verify(dedupeTotalDataMapper).selectCount(any()); + verify(dedupeTotalDataMapper).selectList(any()); } @Test void comparableValueLookupRemainsGlobal() { when(dedupeTotalDataMapper.selectExistingDataValues(List.of("B012345678"))) - .thenReturn(List.of("B012345678")); + .thenReturn(List.of("b012345678")); - Set values = service.findExistingComparableValues(List.of(" B012345678 ")); + Set values = service.findExistingComparableValues(List.of(" b012345678 ")); assertEquals(Set.of("B012345678"), values); verify(adminUserMapper, never()).selectById(any(Long.class)); verify(shopManageGroupMapper, never()).selectManagedMemberUserIds(any(Long.class)); } + @Test + void exportUsesScopedUsernameAndProducesWorkbook() throws Exception { + when(adminUserMapper.selectById(10L)).thenReturn(user(10L, "admin", "leader")); + when(shopManageGroupMapper.selectManagedMemberUserIds(10L)).thenReturn(List.of(23L)); + DedupeTotalDataEntity entity = data(91L, 23L); + entity.setUploaderUsername("member-a"); + entity.setCreatedAt(LocalDateTime.of(2026, 7, 20, 12, 30)); + when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of(entity)); + + byte[] bytes = service.export( + "member", + LocalDate.of(2026, 7, 1), + LocalDate.of(2026, 7, 20), + 10L); + + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + var sheet = workbook.getSheetAt(0); + assertEquals("ASIN值", sheet.getRow(0).getCell(1).getStringCellValue()); + assertEquals("B012345678", sheet.getRow(1).getCell(1).getStringCellValue()); + assertEquals("member-a", sheet.getRow(1).getCell(2).getStringCellValue()); + assertEquals("2026-07-20 12:30:00", sheet.getRow(1).getCell(3).getStringCellValue()); + } + + verify(shopManageGroupMapper).selectManagedMemberUserIds(10L); + verify(dedupeTotalDataMapper).selectList(any()); + } + + @Test + void exportRejectsReversedDateRange() { + assertThrows(BusinessException.class, () -> service.export( + "", + LocalDate.of(2026, 7, 20), + LocalDate.of(2026, 7, 1), + 10L)); + verify(dedupeTotalDataMapper, never()).selectList(any()); + } + + @Test + @SuppressWarnings("unchecked") + void expiredCompletedProgressIsRemovedOnNextLookup() { + Map progressMap = + (Map) ReflectionTestUtils.getField(service, "importProgressMap"); + Map ownerMap = (Map) ReflectionTestUtils.getField(service, "importOwnerMap"); + Map completedAtMap = + (Map) ReflectionTestUtils.getField(service, "importCompletedAtMap"); + DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo(); + progress.setStatus("success"); + progressMap.put("expired", progress); + ownerMap.put("expired", 23L); + completedAtMap.put("expired", System.currentTimeMillis() - (2 * 60 * 60 * 1000L)); + + assertThrows(BusinessException.class, () -> service.getImportProgress("expired", 23L)); + + assertFalse(progressMap.containsKey("expired")); + assertFalse(ownerMap.containsKey("expired")); + assertFalse(completedAtMap.containsKey("expired")); + } + private AdminUserEntity user(Long id, String role, String username) { AdminUserEntity user = new AdminUserEntity(); user.setId(id); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java new file mode 100644 index 0000000..1f25747 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java @@ -0,0 +1,82 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TaskResultFileJobWorkerTest { + + @Mock private TaskFileJobService taskFileJobService; + @Mock private TaskDistributedLockService taskDistributedLockService; + @Mock private TaskResultPayloadService taskResultPayloadService; + @Mock private FileResultMapper fileResultMapper; + @Mock private TaskFileJobLocalDispatcher taskFileJobLocalDispatcher; + @Mock private InstanceMetadata instanceMetadata; + @Mock private ShopMatchTaskService shopMatchTaskService; + @Mock private PriceTrackTaskService priceTrackTaskService; + @Mock private ProductRiskTaskService productRiskTaskService; + @Mock private QueryAsinTaskService queryAsinTaskService; + @Mock private WithdrawTaskService withdrawTaskService; + @Mock private PatrolDeleteTaskService patrolDeleteTaskService; + @Mock private AppearancePatentTaskService appearancePatentTaskService; + @Mock private SimilarAsinTaskService similarAsinTaskService; + @Mock private DeleteBrandRunService deleteBrandRunService; + @Mock private BrandTaskService brandTaskService; + @Mock private CollectDataService collectDataService; + + @InjectMocks private TaskResultFileJobWorker worker; + + @Test + void withdrawFileSuccessFinalizesTaskAfterReleasingLock() { + long jobId = 13640L; + long taskId = 20140L; + long resultId = 22928L; + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setId(jobId); + job.setTaskId(taskId); + job.setResultId(resultId); + job.setModuleType("WITHDRAW"); + job.setScopeKey("withdraw:20140"); + + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/withdraw/20140.xlsx"); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + + when(taskFileJobService.markRunning(jobId)).thenReturn(true); + when(taskDistributedLockService.acquire("WITHDRAW", taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS)) + .thenReturn(lock); + when(fileResultMapper.selectById(resultId)).thenReturn(result); + + worker.process(job); + + verify(taskFileJobService).markSuccess(job, "result/withdraw/20140.xlsx"); + verify(taskResultPayloadService).deleteLatest(taskId, "WITHDRAW", "withdraw:20140"); + InOrder order = inOrder(lock, withdrawTaskService); + order.verify(lock).close(); + order.verify(withdrawTaskService).tryFinalizeTask(taskId, false); + } +} diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 239ab1c..d147ae0 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -2202,6 +2202,7 @@ def list_shop_keys(): items = [ { 'id': item.get('id'), + 'remark_name': item.get('remarkName') or '', 'ziniao_account_name': item.get('ziniaoAccountName') or '', 'ziniao_token': item.get('ziniaoToken') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], @@ -2226,6 +2227,7 @@ def create_shop_key(): return denied data = request.get_json() or {} payload = { + 'remarkName': (data.get('remark_name') or '').strip(), 'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(), 'ziniaoToken': (data.get('ziniao_token') or '').strip(), } @@ -2242,6 +2244,7 @@ def create_shop_key(): 'msg': result.get('message') or '创建成功', 'item': { 'id': item.get('id'), + 'remark_name': item.get('remarkName') or '', 'ziniao_account_name': item.get('ziniaoAccountName') or '', 'ziniao_token': item.get('ziniaoToken') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], @@ -2258,6 +2261,7 @@ def update_shop_key(item_id): return denied data = request.get_json() or {} payload = { + 'remarkName': (data.get('remark_name') or '').strip(), 'ziniaoAccountName': (data.get('ziniao_account_name') or '').strip(), 'ziniaoToken': (data.get('ziniao_token') or '').strip(), } @@ -2274,6 +2278,7 @@ def update_shop_key(item_id): 'msg': result.get('message') or '更新成功', 'item': { 'id': item.get('id'), + 'remark_name': item.get('remarkName') or '', 'ziniao_account_name': item.get('ziniaoAccountName') or '', 'ziniao_token': item.get('ziniaoToken') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], @@ -2345,6 +2350,51 @@ def list_dedupe_total_data(): }) +@admin_api.route('/dedupe-total-data/export') +@login_required +def export_dedupe_total_data(): + _, current_row, denied = _ensure_dedupe_total_data_access() + if denied: + return denied + params = {'operatorId': current_row.get('id')} + username = (request.args.get('username') or '').strip() + start_date = (request.args.get('start_date') or request.args.get('startDate') or '').strip() + end_date = (request.args.get('end_date') or request.args.get('endDate') or '').strip() + if username: + params['username'] = username + if start_date: + params['startDate'] = start_date + if end_date: + params['endDate'] = end_date + + url = f"{backend_java_base_url}/api/admin/dedupe-total-data/export" + try: + resp = _get_backend_java_session().get(url, params=params, timeout=60) + except requests.RequestException: + return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502 + if resp.status_code >= 400: + try: + data = resp.json() + error = data.get('message') or data.get('error') or '导出失败' + except ValueError: + error = '导出失败' + return jsonify({'success': False, 'error': error}), resp.status_code + + headers = {} + disposition = resp.headers.get('Content-Disposition') + if disposition: + headers['Content-Disposition'] = disposition + return Response( + resp.content, + status=resp.status_code, + headers=headers, + content_type=resp.headers.get( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ), + ) + + @admin_api.route('/dedupe-total-data/import/') @login_required def dedupe_total_data_import_progress(import_id): diff --git a/backend/static/admin.js b/backend/static/admin.js index bea7313..3402e27 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -1467,6 +1467,43 @@ }); } document.getElementById('btnSearchDedupeTotalData').onclick = function () { loadDedupeTotalData(1); }; + document.getElementById('btnExportDedupeTotalData').onclick = function () { + var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim(); + var startDate = document.getElementById('exportDedupeTotalDataStartDate').value || ''; + var endDate = document.getElementById('exportDedupeTotalDataEndDate').value || ''; + if (startDate && endDate && startDate > endDate) { + alert('开始日期不能晚于结束日期'); + return; + } + var params = []; + if (username) params.push('username=' + encodeURIComponent(username)); + if (startDate) params.push('start_date=' + encodeURIComponent(startDate)); + if (endDate) params.push('end_date=' + encodeURIComponent(endDate)); + fetch('/api/admin/dedupe-total-data/export' + (params.length ? ('?' + params.join('&')) : '')) + .then(function (response) { + var contentType = response.headers.get('content-type') || ''; + if (!response.ok || contentType.indexOf('application/json') >= 0) { + return response.json().then(function (res) { + throw new Error((res && (res.error || res.msg)) || '导出失败'); + }); + } + return response.blob().then(function (blob) { + return { + blob: blob, + filename: extractDownloadFilename( + response.headers.get('content-disposition'), + 'dedupe-total-data.xlsx' + ) + }; + }); + }) + .then(function (payload) { + triggerBrowserDownload(payload.blob, payload.filename); + }) + .catch(function (err) { + alert((err && err.message) || '导出失败'); + }); + }; var dedupeImportPollTimer = null; var dedupeDeleteImportPollTimer = null; function stopDedupeImportProgress() { @@ -1858,11 +1895,11 @@ } var items = res.items || []; if (items.length === 0) { - tbody.innerHTML = '暂无店铺密钥'; + tbody.innerHTML = '暂无店铺密钥'; } else { tbody.innerHTML = items.map(function (item, index) { var rowNo = (shopKeyPage - 1) * shopKeyPageSize + index + 1; - return '' + rowNo + '' + (item.ziniao_account_name || '') + '' + (item.ziniao_token || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + + return '' + rowNo + '' + (item.remark_name || '') + '' + (item.ziniao_account_name || '') + '' + (item.ziniao_token || '') + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + ' ' + '' + ''; @@ -1881,6 +1918,7 @@ var item = {}; try { item = JSON.parse((btn.dataset.shopKey || '').replace(/"/g, '"')); } catch (e) { item = {}; } document.getElementById('editShopKeyId').value = item.id || ''; + document.getElementById('editShopKeyRemarkName').value = item.remark_name || ''; document.getElementById('editShopKeyZiniaoAccountName').value = item.ziniao_account_name || ''; document.getElementById('editShopKeyZiniaoToken').value = item.ziniao_token || ''; document.getElementById('msgEditShopKey').textContent = ''; @@ -1902,6 +1940,7 @@ }); } document.getElementById('btnCreateShopKey').onclick = function () { + var remarkName = (document.getElementById('shopKeyRemarkName').value || '').trim(); var ziniaoAccountName = (document.getElementById('shopKeyZiniaoAccountName').value || '').trim(); var ziniaoToken = (document.getElementById('shopKeyZiniaoToken').value || '').trim(); var msgEl = document.getElementById('msgShopKey'); @@ -1916,6 +1955,7 @@ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + remark_name: remarkName, ziniao_account_name: ziniaoAccountName, ziniao_token: ziniaoToken }) @@ -1923,6 +1963,7 @@ .then(function (r) { return r.json(); }) .then(function (res) { if (res.success) { + document.getElementById('shopKeyRemarkName').value = ''; document.getElementById('shopKeyZiniaoAccountName').value = ''; document.getElementById('shopKeyZiniaoToken').value = ''; msgEl.textContent = res.msg || '创建成功'; @@ -1940,6 +1981,7 @@ }; document.getElementById('btnSaveShopKey').onclick = function () { var itemId = document.getElementById('editShopKeyId').value; + var remarkName = (document.getElementById('editShopKeyRemarkName').value || '').trim(); var ziniaoAccountName = (document.getElementById('editShopKeyZiniaoAccountName').value || '').trim(); var ziniaoToken = (document.getElementById('editShopKeyZiniaoToken').value || '').trim(); var msgEl = document.getElementById('msgEditShopKey'); @@ -1954,6 +1996,7 @@ method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + remark_name: remarkName, ziniao_account_name: ziniaoAccountName, ziniao_token: ziniaoToken }) diff --git a/backend/tests/test_dedupe_total_data_admin.py b/backend/tests/test_dedupe_total_data_admin.py index a2583b8..45000cd 100644 --- a/backend/tests/test_dedupe_total_data_admin.py +++ b/backend/tests/test_dedupe_total_data_admin.py @@ -1,6 +1,6 @@ import io import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from flask import jsonify @@ -72,6 +72,36 @@ class DedupeTotalDataAdminTest(unittest.TestCase): self.assertEqual('import-1', response.get_json()['import_id']) self.assertEqual({'operatorId': 31}, proxy.call_args.kwargs['data']) + def test_export_forwards_filters_and_session_operator(self): + self._login(31) + java_response = Mock( + status_code=200, + content=b'xlsx-data', + headers={ + 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition': 'attachment; filename="dedupe-total-data.xlsx"', + }, + ) + java_session = Mock() + java_session.get.return_value = java_response + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_dedupe_total_data_access', + return_value=('normal', {'id': 31, 'username': 'member-b'}, None)), \ + patch.object(admin_module, '_get_backend_java_session', return_value=java_session): + response = self.client.get( + '/api/admin/dedupe-total-data/export' + '?username=member&start_date=2026-07-01&end_date=2026-07-20') + + self.assertEqual(200, response.status_code) + self.assertEqual(b'xlsx-data', response.data) + self.assertEqual({ + 'operatorId': 31, + 'username': 'member', + 'startDate': '2026-07-01', + 'endDate': '2026-07-20', + }, java_session.get.call_args.kwargs['params']) + self.assertEqual(60, java_session.get.call_args.kwargs['timeout']) + def test_delete_ignores_client_operator_and_uses_session_user(self): self._login(41) java_response = {'success': True, 'message': '删除成功', 'data': None} diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 4d47412..77ce0d3 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -162,6 +162,26 @@ min-width: 120px; } + .dedupe-filter-row { + display: grid; + grid-template-columns: repeat(4, minmax(160px, 1fr)) auto; + gap: 12px; + align-items: end; + margin-bottom: 16px; + } + + .dedupe-filter-row .form-group { + min-width: 0; + margin-bottom: 0; + } + + .dedupe-filter-actions { + display: flex; + gap: 8px; + align-items: center; + white-space: nowrap; + } + .btn { padding: 10px 20px; background: #667eea; @@ -651,6 +671,14 @@ } @media (max-width: 900px) { + .dedupe-filter-row { + grid-template-columns: repeat(2, minmax(180px, 1fr)); + } + + .dedupe-filter-actions { + grid-column: 1 / -1; + } + .dedupe-group-access { align-items: stretch; flex-direction: column; @@ -1282,6 +1310,15 @@ } @media (max-width: 760px) { + .dedupe-filter-row { + grid-template-columns: minmax(0, 1fr); + } + + .dedupe-filter-actions { + grid-column: auto; + flex-wrap: wrap; + } + .admin-user-box { position: static !important; width: max-content; @@ -1608,16 +1645,27 @@

ASIN列表

-
-
+
+
-
+
- +
+ + +
+
+ + +
+
+ + +
@@ -1682,6 +1730,10 @@

新增店铺密钥

+
+ + +
@@ -1700,6 +1752,7 @@
+ @@ -2503,6 +2556,10 @@ - +
序号备注名 紫鸟账号名称 紫鸟令牌 创建时间