From ba48bbc6afbc27ed89a92fbcc1b9f3216c2c27d7 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Mon, 20 Jul 2026 20:12:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0ASIN=E5=BA=93=E6=96=B0?= =?UTF-8?q?=E9=9C=80=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DedupeTotalDataController.java | 43 +- .../model/entity/DedupeTotalDataEntity.java | 2 + .../model/vo/DedupeTotalDataItemVo.java | 6 + .../service/DedupeTotalDataService.java | 214 ++++-- .../permission/mapper/AdminUserMapper.java | 7 + .../shopkey/mapper/ShopManageGroupMapper.java | 11 + .../db/V75__dedupe_total_data_uploader.sql | 86 +++ .../service/DedupeTotalDataServiceTest.java | 185 +++++ backend/blueprints/admin_api.py | 78 +- backend/static/admin.js | 59 +- backend/tests/test_dedupe_total_data_admin.py | 130 ++++ backend/web_source/admin.html | 704 +++++++++++++++--- frontend-vue/new_web_source.zip | Bin 567768 -> 0 bytes 13 files changed, 1313 insertions(+), 212 deletions(-) create mode 100644 backend-java/src/main/resources/db/V75__dedupe_total_data_uploader.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java create mode 100644 backend/tests/test_dedupe_total_data_admin.py delete mode 100644 frontend-vue/new_web_source.zip 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 25c48d0..6ca1ab0 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 @@ -44,8 +44,10 @@ public class DedupeTotalDataController { public ApiResponse page( @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize, - @Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword) { - return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, keyword)); + @Parameter(description = "数据值模糊搜索关键字") @RequestParam(required = false) String keyword, + @Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) { + return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, keyword, username, operatorId)); } @PostMapping @@ -54,8 +56,10 @@ public class DedupeTotalDataController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataItemVo.class))), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复") }) - public ApiResponse create(@Valid @RequestBody DedupeTotalDataCreateRequest request) { - return ApiResponse.success("创建成功", dedupeTotalDataService.create(request)); + public ApiResponse create( + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, + @Valid @RequestBody DedupeTotalDataCreateRequest request) { + return ApiResponse.success("创建成功", dedupeTotalDataService.create(request, operatorId)); } @PostMapping("/import") @@ -65,14 +69,17 @@ public class DedupeTotalDataController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列") }) public ApiResponse importExcel( - @Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file) { - return ApiResponse.success("开始导入", dedupeTotalDataService.startImport(file)); + @Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) { + return ApiResponse.success("开始导入", dedupeTotalDataService.startImport(file, operatorId)); } @GetMapping("/import/{importId}") @Operation(summary = "查询导入进度", description = "根据导入任务 ID 查询当前进度。") - public ApiResponse importProgress(@PathVariable String importId) { - return ApiResponse.success(dedupeTotalDataService.getImportProgress(importId)); + public ApiResponse importProgress( + @PathVariable String importId, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) { + return ApiResponse.success(dedupeTotalDataService.getImportProgress(importId, operatorId)); } @PostMapping("/delete-import") @@ -82,14 +89,17 @@ public class DedupeTotalDataController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列") }) public ApiResponse deleteImportExcel( - @Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file) { - return ApiResponse.success("开始删除", dedupeTotalDataService.startDeleteImport(file)); + @Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) { + return ApiResponse.success("开始删除", dedupeTotalDataService.startDeleteImport(file, operatorId)); } @GetMapping("/delete-import/{importId}") @Operation(summary = "查询删除导入进度", description = "根据删除导入任务 ID 查询当前进度。") - public ApiResponse deleteImportProgress(@PathVariable String importId) { - return ApiResponse.success(dedupeTotalDataService.getDeleteImportProgress(importId)); + public ApiResponse deleteImportProgress( + @PathVariable String importId, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) { + return ApiResponse.success(dedupeTotalDataService.getDeleteImportProgress(importId, operatorId)); } @PutMapping("/{id}") @@ -101,8 +111,9 @@ public class DedupeTotalDataController { }) public ApiResponse update( @Parameter(description = "主键ID", required = true) @PathVariable Long id, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId, @Valid @RequestBody DedupeTotalDataUpdateRequest request) { - return ApiResponse.success("更新成功", dedupeTotalDataService.update(id, request)); + return ApiResponse.success("更新成功", dedupeTotalDataService.update(id, request, operatorId)); } @DeleteMapping("/{id}") @@ -111,8 +122,10 @@ public class DedupeTotalDataController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在") }) - public ApiResponse delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) { - dedupeTotalDataService.delete(id); + public ApiResponse delete( + @Parameter(description = "主键ID", required = true) @PathVariable Long id, + @Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) { + dedupeTotalDataService.delete(id, operatorId); return ApiResponse.success("删除成功", null); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java index 9928220..b25af68 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java @@ -14,6 +14,8 @@ public class DedupeTotalDataEntity { @TableId(type = IdType.AUTO) private Long id; private String dataValue; + private Long uploaderUserId; + private String uploaderUsername; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java index 1b3fd6a..6dadce7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java @@ -15,6 +15,12 @@ public class DedupeTotalDataItemVo { @Schema(description = "总数据值") private String dataValue; + @Schema(description = "上传用户ID") + private Long uploaderUserId; + + @Schema(description = "上传用户名") + private String username; + @Schema(description = "创建时间") private LocalDateTime createdAt; } 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 8687a16..9e2f84c 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 @@ -12,6 +12,9 @@ import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportStartVo; import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportVo; 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; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper; import lombok.RequiredArgsConstructor; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.DataFormatter; @@ -24,15 +27,18 @@ import org.springframework.transaction.TransactionDefinition; 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.web.server.ResponseStatusException; -import java.io.ByteArrayInputStream; import java.io.File; import java.io.InputStream; import java.nio.file.Files; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @@ -44,9 +50,13 @@ public class DedupeTotalDataService { private static final int COMPARE_BATCH_SIZE = 5000; private final DedupeTotalDataMapper dedupeTotalDataMapper; + private final AdminUserMapper adminUserMapper; + private final ShopManageGroupMapper shopManageGroupMapper; private final PlatformTransactionManager transactionManager; private final Map importProgressMap = new ConcurrentHashMap<>(); private final Map deleteImportProgressMap = new ConcurrentHashMap<>(); + private final Map importOwnerMap = new ConcurrentHashMap<>(); + private final Map deleteImportOwnerMap = new ConcurrentHashMap<>(); private TransactionTemplate newRequiresNewTemplate() { TransactionTemplate template = new TransactionTemplate(transactionManager); @@ -54,12 +64,22 @@ public class DedupeTotalDataService { return template; } - public DedupeTotalDataPageVo page(long page, long pageSize, String keyword) { + public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username, Long operatorId) { long safePage = Math.max(page, 1); long safePageSize = Math.min(Math.max(pageSize, 1), 100); 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) .orderByDesc(DedupeTotalDataEntity::getId); Long total = dedupeTotalDataMapper.selectCount(query); List items = dedupeTotalDataMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize)) @@ -75,11 +95,14 @@ public class DedupeTotalDataService { } @Transactional - public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request) { + public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request, Long operatorId) { + AdminUserEntity uploader = getOperator(operatorId); String dataValue = normalizeComparableValue(request.getDataValue()); ensureUnique(dataValue, null); DedupeTotalDataEntity entity = new DedupeTotalDataEntity(); entity.setDataValue(dataValue); + entity.setUploaderUserId(uploader.getId()); + entity.setUploaderUsername(uploader.getUsername()); dedupeTotalDataMapper.insert(entity); return toItemVo(getById(entity.getId())); } @@ -111,10 +134,11 @@ public class DedupeTotalDataService { return existingValues; } - public DedupeTotalDataImportStartVo startImport(MultipartFile file) { + public DedupeTotalDataImportStartVo startImport(MultipartFile file, Long operatorId) { if (file == null || file.isEmpty()) { throw new BusinessException("请上传 xlsx 文件"); } + AdminUserEntity uploader = getOperator(operatorId); String importId = IdUtil.fastSimpleUUID(); DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo(); progress.setStatus("pending"); @@ -124,13 +148,16 @@ public class DedupeTotalDataService { progress.setInsertedCount(0); progress.setSkippedCount(0); importProgressMap.put(importId, progress); + importOwnerMap.put(importId, uploader.getId()); try { File tempFile = saveMultipartToTempFile(file); String filename = file.getOriginalFilename(); - Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, filename)); + Thread.ofVirtual().start(() -> runImportTask( + importId, tempFile, filename, uploader.getId(), uploader.getUsername())); } catch (Exception e) { importProgressMap.remove(importId); + importOwnerMap.remove(importId); throw new BusinessException("读取上传文件失败"); } @@ -139,18 +166,21 @@ public class DedupeTotalDataService { return vo; } - public DedupeTotalDataImportProgressVo getImportProgress(String importId) { + public DedupeTotalDataImportProgressVo getImportProgress(String importId, Long operatorId) { DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId); - if (progress == null) { + Long ownerId = importOwnerMap.get(importId); + if (progress == null || ownerId == null) { throw new BusinessException("导入任务不存在"); } + ensureUploaderAccess(ownerId, resolveAccessScope(operatorId)); return progress; } - public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file) { + public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file, Long operatorId) { if (file == null || file.isEmpty()) { throw new BusinessException("请上传 xlsx 文件"); } + AdminUserEntity operator = getOperator(operatorId); String importId = IdUtil.fastSimpleUUID(); DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo(); progress.setStatus("pending"); @@ -160,13 +190,15 @@ public class DedupeTotalDataService { progress.setInsertedCount(0); progress.setSkippedCount(0); deleteImportProgressMap.put(importId, progress); + deleteImportOwnerMap.put(importId, operator.getId()); try { File tempFile = saveMultipartToTempFile(file); String filename = file.getOriginalFilename(); - Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename)); + Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename, operator.getId())); } catch (Exception e) { deleteImportProgressMap.remove(importId); + deleteImportOwnerMap.remove(importId); throw new BusinessException("读取上传文件失败"); } @@ -175,62 +207,25 @@ public class DedupeTotalDataService { return vo; } - public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId) { + public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId, Long operatorId) { DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId); - if (progress == null) { + Long ownerId = deleteImportOwnerMap.get(importId); + if (progress == null || ownerId == null) { throw new BusinessException("删除任务不存在"); } + ensureUploaderAccess(ownerId, resolveAccessScope(operatorId)); return progress; } - private void runDeleteImportTask(String importId, byte[] fileBytes, String filename) { - DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId); - if (progress == null) { - return; - } - progress.setStatus("running"); - try (InputStream inputStream = new ByteArrayInputStream(fileBytes)) { - DedupeTotalDataImportVo result = deleteFromExcelInternal(inputStream, filename, progress); - progress.setTotalRows(result.getTotalRows()); - progress.setAsinCount(result.getAsinCount()); - progress.setInsertedCount(result.getInsertedCount()); - progress.setSkippedCount(result.getSkippedCount()); - progress.setProcessedRows(result.getTotalRows()); - progress.setStatus("success"); - } catch (Exception e) { - progress.setStatus("failed"); - progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "删除 Excel 匹配数据失败"); - } - } - - private void runImportTask(String importId, byte[] fileBytes, String filename) { - DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId); - if (progress == null) { - return; - } - progress.setStatus("running"); - try (InputStream inputStream = new ByteArrayInputStream(fileBytes)) { - DedupeTotalDataImportVo result = importFromExcelInternal(inputStream, filename, progress); - progress.setTotalRows(result.getTotalRows()); - progress.setAsinCount(result.getAsinCount()); - progress.setInsertedCount(result.getInsertedCount()); - progress.setSkippedCount(result.getSkippedCount()); - progress.setProcessedRows(result.getTotalRows()); - progress.setStatus("success"); - } catch (Exception e) { - progress.setStatus("failed"); - progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "导入 Excel 失败"); - } - } - - private void runDeleteImportTask(String importId, File tempFile, String filename) { + private void runDeleteImportTask(String importId, File tempFile, String filename, Long operatorId) { DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId); if (progress == null) { return; } progress.setStatus("running"); try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) { - DedupeTotalDataImportVo result = deleteFromExcelInternal(inputStream, filename, progress); + AccessScope scope = resolveAccessScope(operatorId); + DedupeTotalDataImportVo result = deleteFromExcelInternal(inputStream, filename, progress, scope); progress.setTotalRows(result.getTotalRows()); progress.setAsinCount(result.getAsinCount()); progress.setInsertedCount(result.getInsertedCount()); @@ -245,14 +240,16 @@ public class DedupeTotalDataService { } } - private void runImportTask(String importId, File tempFile, String filename) { + private void runImportTask(String importId, File tempFile, String filename, + Long uploaderUserId, String uploaderUsername) { DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId); if (progress == null) { return; } progress.setStatus("running"); try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) { - DedupeTotalDataImportVo result = importFromExcelInternal(inputStream, filename, progress); + DedupeTotalDataImportVo result = importFromExcelInternal( + inputStream, filename, progress, uploaderUserId, uploaderUsername); progress.setTotalRows(result.getTotalRows()); progress.setAsinCount(result.getAsinCount()); progress.setInsertedCount(result.getInsertedCount()); @@ -291,12 +288,14 @@ public class DedupeTotalDataService { } } - public DedupeTotalDataImportVo importFromExcel(MultipartFile file) { + public DedupeTotalDataImportVo importFromExcel(MultipartFile file, Long operatorId) { if (file == null || file.isEmpty()) { throw new BusinessException("请上传 xlsx 文件"); } + AdminUserEntity uploader = getOperator(operatorId); try (InputStream inputStream = file.getInputStream()) { - return importFromExcelInternal(inputStream, file.getOriginalFilename(), null); + return importFromExcelInternal( + inputStream, file.getOriginalFilename(), null, uploader.getId(), uploader.getUsername()); } catch (BusinessException e) { throw e; } catch (Exception e) { @@ -304,12 +303,13 @@ public class DedupeTotalDataService { } } - public DedupeTotalDataImportVo deleteFromExcel(MultipartFile file) { + public DedupeTotalDataImportVo deleteFromExcel(MultipartFile file, Long operatorId) { if (file == null || file.isEmpty()) { throw new BusinessException("请上传 xlsx 文件"); } + AccessScope scope = resolveAccessScope(operatorId); try (InputStream inputStream = file.getInputStream()) { - return deleteFromExcelInternal(inputStream, file.getOriginalFilename(), null); + return deleteFromExcelInternal(inputStream, file.getOriginalFilename(), null, scope); } catch (BusinessException e) { throw e; } catch (Exception e) { @@ -317,7 +317,9 @@ public class DedupeTotalDataService { } } - private DedupeTotalDataImportVo importFromExcelInternal(InputStream inputStream, String filename, DedupeTotalDataImportProgressVo progress) { + private DedupeTotalDataImportVo importFromExcelInternal(InputStream inputStream, String filename, + DedupeTotalDataImportProgressVo progress, + Long uploaderUserId, String uploaderUsername) { String lowerFilename = filename == null ? "" : filename.toLowerCase(); if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) { throw new BusinessException("仅支持 .xlsx 或 .xls 文件"); @@ -411,6 +413,8 @@ public class DedupeTotalDataService { } DedupeTotalDataEntity entity = new DedupeTotalDataEntity(); entity.setDataValue(pendingDataValue); + entity.setUploaderUserId(uploaderUserId); + entity.setUploaderUsername(uploaderUsername); dedupeTotalDataMapper.insert(entity); return true; }); @@ -440,7 +444,9 @@ public class DedupeTotalDataService { } } - private DedupeTotalDataImportVo deleteFromExcelInternal(InputStream inputStream, String filename, DedupeTotalDataImportProgressVo progress) { + private DedupeTotalDataImportVo deleteFromExcelInternal(InputStream inputStream, String filename, + DedupeTotalDataImportProgressVo progress, + AccessScope scope) { String lowerFilename = filename == null ? "" : filename.toLowerCase(); if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) { throw new BusinessException("仅支持 .xlsx 或 .xls 文件"); @@ -518,7 +524,7 @@ public class DedupeTotalDataService { continue; } - int deletedThisRow = newRequiresNewTemplate().execute(status -> deleteByDataValue(dataValue)); + int deletedThisRow = newRequiresNewTemplate().execute(status -> deleteByDataValue(dataValue, scope)); if (deletedThisRow > 0) { deletedCount += deletedThisRow; } else { @@ -546,8 +552,8 @@ public class DedupeTotalDataService { } @Transactional - public DedupeTotalDataItemVo update(Long id, DedupeTotalDataUpdateRequest request) { - DedupeTotalDataEntity entity = getById(id); + public DedupeTotalDataItemVo update(Long id, DedupeTotalDataUpdateRequest request, Long operatorId) { + DedupeTotalDataEntity entity = getAccessibleById(id, operatorId); String dataValue = normalizeComparableValue(request.getDataValue()); ensureUnique(dataValue, id); entity.setDataValue(dataValue); @@ -556,11 +562,17 @@ public class DedupeTotalDataService { } @Transactional - public void delete(Long id) { - DedupeTotalDataEntity entity = getById(id); + public void delete(Long id, Long operatorId) { + DedupeTotalDataEntity entity = getAccessibleById(id, operatorId); dedupeTotalDataMapper.deleteById(entity.getId()); } + private DedupeTotalDataEntity getAccessibleById(Long id, Long operatorId) { + DedupeTotalDataEntity entity = getById(id); + ensureUploaderAccess(entity.getUploaderUserId(), resolveAccessScope(operatorId)); + return entity; + } + private DedupeTotalDataEntity getById(Long id) { DedupeTotalDataEntity entity = dedupeTotalDataMapper.selectById(id); if (entity == null) { @@ -598,9 +610,70 @@ public class DedupeTotalDataService { return dedupeTotalDataMapper.selectOne(query) != null; } - private int deleteByDataValue(String dataValue) { + private int deleteByDataValue(String dataValue, AccessScope scope) { return dedupeTotalDataMapper.delete(new LambdaQueryWrapper() - .eq(DedupeTotalDataEntity::getDataValue, dataValue)); + .eq(DedupeTotalDataEntity::getDataValue, dataValue) + .in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds())); + } + + private AccessScope resolveAccessScope(Long operatorId) { + AdminUserEntity operator = getOperator(operatorId); + if (isSuperAdmin(operator)) { + return new AccessScope(true, Set.of()); + } + LinkedHashSet visibleUserIds = new LinkedHashSet<>(); + visibleUserIds.add(operator.getId()); + List memberUserIds = shopManageGroupMapper.selectManagedMemberUserIds(operator.getId()); + if (memberUserIds != null) { + memberUserIds.stream() + .filter(id -> id != null && id > 0) + .forEach(visibleUserIds::add); + } + return new AccessScope(false, Set.copyOf(visibleUserIds)); + } + + private AdminUserEntity getOperator(Long operatorId) { + if (operatorId == null || operatorId <= 0) { + throw new BusinessException("当前操作用户不能为空"); + } + AdminUserEntity operator = adminUserMapper.selectById(operatorId); + if (operator == null) { + throw new BusinessException("当前操作用户不存在"); + } + return operator; + } + + private boolean isSuperAdmin(AdminUserEntity user) { + String role = user.getRole() == null ? "" : user.getRole().trim().toLowerCase(Locale.ROOT); + return "super_admin".equals(role) + || (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 String normalizeExcelText(String value) { @@ -621,7 +694,12 @@ public class DedupeTotalDataService { DedupeTotalDataItemVo vo = new DedupeTotalDataItemVo(); vo.setId(entity.getId()); vo.setDataValue(entity.getDataValue()); + vo.setUploaderUserId(entity.getUploaderUserId()); + vo.setUsername(entity.getUploaderUsername()); vo.setCreatedAt(entity.getCreatedAt()); return vo; } + + private record AccessScope(boolean allUsers, Set userIds) { + } } 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 4e0c7ef..d32eacb 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,7 +3,14 @@ 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/mapper/ShopManageGroupMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopManageGroupMapper.java index 39858ea..ec88852 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopManageGroupMapper.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/mapper/ShopManageGroupMapper.java @@ -67,4 +67,15 @@ public interface ShopManageGroupMapper extends BaseMapper ORDER BY visible_user_id ASC """) List selectAccessibleCreatorUserIds(@Param("operatorId") Long operatorId); + + @Select(""" + SELECT DISTINCT gm.user_id + FROM biz_shop_manage_group g + INNER JOIN biz_shop_manage_group_member gm ON gm.group_id = g.id + INNER JOIN users u ON u.id = gm.user_id + WHERE g.created_by_id = #{operatorId} + OR g.user_id = #{operatorId} + ORDER BY gm.user_id ASC + """) + List selectManagedMemberUserIds(@Param("operatorId") Long operatorId); } diff --git a/backend-java/src/main/resources/db/V75__dedupe_total_data_uploader.sql b/backend-java/src/main/resources/db/V75__dedupe_total_data_uploader.sql new file mode 100644 index 0000000..0494f43 --- /dev/null +++ b/backend-java/src/main/resources/db/V75__dedupe_total_data_uploader.sql @@ -0,0 +1,86 @@ +SET @uploader_user_id_col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_dedupe_total_data' + AND COLUMN_NAME = 'uploader_user_id' +); + +SET @sql_add_uploader_user_id := IF( + @uploader_user_id_col_exists = 0, + 'ALTER TABLE biz_dedupe_total_data ADD COLUMN uploader_user_id BIGINT NULL COMMENT ''上传用户ID'' AFTER data_value', + 'SELECT 1' +); +PREPARE stmt_add_uploader_user_id FROM @sql_add_uploader_user_id; +EXECUTE stmt_add_uploader_user_id; +DEALLOCATE PREPARE stmt_add_uploader_user_id; + +SET @uploader_username_col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_dedupe_total_data' + AND COLUMN_NAME = 'uploader_username' +); + +SET @sql_add_uploader_username := IF( + @uploader_username_col_exists = 0, + 'ALTER TABLE biz_dedupe_total_data ADD COLUMN uploader_username VARCHAR(64) NULL COMMENT ''上传用户名'' AFTER uploader_user_id', + 'SELECT 1' +); +PREPARE stmt_add_uploader_username FROM @sql_add_uploader_username; +EXECUTE stmt_add_uploader_username; +DEALLOCATE PREPARE stmt_add_uploader_username; + +SET @dedupe_root_user_id := ( + SELECT id + FROM users + WHERE LOWER(COALESCE(role, '')) = 'super_admin' + OR (COALESCE(role, '') = '' AND is_admin = 1 AND created_by_id IS NULL) + ORDER BY CASE WHEN username = 'root' THEN 0 ELSE 1 END, id ASC + LIMIT 1 +); + +SET @dedupe_root_username := ( + SELECT username + FROM users + WHERE id = @dedupe_root_user_id + LIMIT 1 +); + +DROP TEMPORARY TABLE IF EXISTS dedupe_root_user_guard; +CREATE TEMPORARY TABLE dedupe_root_user_guard ( + user_id BIGINT NOT NULL, + username VARCHAR(64) NOT NULL +); +INSERT INTO dedupe_root_user_guard (user_id, username) +VALUES (@dedupe_root_user_id, @dedupe_root_username); +DROP TEMPORARY TABLE dedupe_root_user_guard; + +UPDATE biz_dedupe_total_data +SET uploader_user_id = @dedupe_root_user_id, + uploader_username = @dedupe_root_username +WHERE uploader_user_id IS NULL + OR uploader_username IS NULL + OR uploader_username = ''; + +ALTER TABLE biz_dedupe_total_data + MODIFY COLUMN uploader_user_id BIGINT NOT NULL COMMENT '上传用户ID', + MODIFY COLUMN uploader_username VARCHAR(64) NOT NULL COMMENT '上传用户名'; + +SET @uploader_user_id_idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_dedupe_total_data' + AND INDEX_NAME = 'idx_uploader_user_id_id' +); + +SET @sql_add_uploader_user_id_idx := IF( + @uploader_user_id_idx_exists = 0, + 'ALTER TABLE biz_dedupe_total_data ADD INDEX idx_uploader_user_id_id (uploader_user_id, id)', + 'SELECT 1' +); +PREPARE stmt_add_uploader_user_id_idx FROM @sql_add_uploader_user_id_idx; +EXECUTE stmt_add_uploader_user_id_idx; +DEALLOCATE PREPARE stmt_add_uploader_user_id_idx; 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 new file mode 100644 index 0000000..24ad3a8 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java @@ -0,0 +1,185 @@ +package com.nanri.aiimage.modules.dedupe.service; + +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.DedupeTotalDataItemVo; +import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo; +import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.ByteArrayOutputStream; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DedupeTotalDataServiceTest { + + @Mock + private DedupeTotalDataMapper dedupeTotalDataMapper; + @Mock + private AdminUserMapper adminUserMapper; + @Mock + private ShopManageGroupMapper shopManageGroupMapper; + @Mock + private PlatformTransactionManager transactionManager; + @InjectMocks + private DedupeTotalDataService service; + + @Test + void createRecordsUploaderIdentity() { + AdminUserEntity uploader = user(23L, "normal", "member-a"); + when(adminUserMapper.selectById(23L)).thenReturn(uploader); + when(dedupeTotalDataMapper.selectOne(any())).thenReturn(null); + when(dedupeTotalDataMapper.insert(any(DedupeTotalDataEntity.class))).thenAnswer(invocation -> { + DedupeTotalDataEntity entity = invocation.getArgument(0); + entity.setId(91L); + return 1; + }); + when(dedupeTotalDataMapper.selectById(91L)).thenAnswer(invocation -> { + DedupeTotalDataEntity entity = new DedupeTotalDataEntity(); + entity.setId(91L); + entity.setDataValue("B012345678"); + entity.setUploaderUserId(23L); + entity.setUploaderUsername("member-a"); + return entity; + }); + + DedupeTotalDataCreateRequest request = new DedupeTotalDataCreateRequest(); + 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("member-a", item.getUsername()); + } + + @Test + void leaderCanDeleteManagedMemberData() { + when(adminUserMapper.selectById(10L)).thenReturn(user(10L, "admin", "leader")); + when(shopManageGroupMapper.selectManagedMemberUserIds(10L)).thenReturn(List.of(23L)); + when(dedupeTotalDataMapper.selectById(91L)).thenReturn(data(91L, 23L)); + + service.delete(91L, 10L); + + verify(dedupeTotalDataMapper).deleteById(91L); + } + + @Test + void memberCannotDeleteAnotherUsersData() { + when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a")); + when(shopManageGroupMapper.selectManagedMemberUserIds(23L)).thenReturn(List.of()); + when(dedupeTotalDataMapper.selectById(91L)).thenReturn(data(91L, 24L)); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> service.delete(91L, 23L)); + + assertEquals(403, exception.getStatusCode().value()); + verify(dedupeTotalDataMapper, never()).deleteById(any(Long.class)); + } + + @Test + void superAdminCanDeleteAnyUsersData() { + when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root")); + when(dedupeTotalDataMapper.selectById(91L)).thenReturn(data(91L, 99L)); + + service.delete(91L, 1L); + + verify(dedupeTotalDataMapper).deleteById(91L); + verify(shopManageGroupMapper, never()).selectManagedMemberUserIds(any(Long.class)); + } + + @Test + void duplicateExcelValueKeepsOriginalUploader() throws Exception { + when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a")); + when(dedupeTotalDataMapper.selectOne(any())).thenReturn(data(91L, 99L)); + MockMultipartFile file = asinWorkbook("B012345678"); + + var result = service.importFromExcel(file, 23L); + + assertEquals(0, result.getInsertedCount()); + assertEquals(1, result.getSkippedCount()); + verify(dedupeTotalDataMapper, never()).insert(any(DedupeTotalDataEntity.class)); + } + + @Test + void usernameSearchCannotExpandLeaderScope() { + 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)); + + 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()); + } + + @Test + void comparableValueLookupRemainsGlobal() { + when(dedupeTotalDataMapper.selectExistingDataValues(List.of("B012345678"))) + .thenReturn(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)); + } + + private AdminUserEntity user(Long id, String role, String username) { + AdminUserEntity user = new AdminUserEntity(); + user.setId(id); + user.setRole(role); + user.setUsername(username); + user.setIsAdmin("normal".equals(role) ? 0 : 1); + return user; + } + + private DedupeTotalDataEntity data(Long id, Long uploaderUserId) { + DedupeTotalDataEntity entity = new DedupeTotalDataEntity(); + entity.setId(id); + entity.setDataValue("B012345678"); + entity.setUploaderUserId(uploaderUserId); + entity.setUploaderUsername("uploader-" + uploaderUserId); + return entity; + } + + private MockMultipartFile asinWorkbook(String asin) throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("data"); + sheet.createRow(0).createCell(0).setCellValue("ASIN"); + sheet.createRow(1).createCell(0).setCellValue(asin); + workbook.write(output); + return new MockMultipartFile( + "file", + "data.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + output.toByteArray()); + } + } +} diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 88b56f4..239ab1c 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -35,7 +35,7 @@ def _can_access_dedupe_total_data(role, current_row): def _ensure_dedupe_total_data_access(): - return _ensure_admin_menu_access('dedupe-total-data') + return _ensure_backend_menu_access('dedupe-total-data') role, current_row = get_current_admin_role() if not _can_access_dedupe_total_data(role, current_row): return None, None, (jsonify({'success': False, 'error': '无权访问数据去重汇总数据'}), 403) @@ -623,7 +623,7 @@ def _ensure_admin_menu_access(*menu_names): def _ensure_dedupe_total_data_access(): - return _ensure_admin_menu_access('dedupe-total-data') + return _ensure_backend_menu_access('dedupe-total-data') def _load_current_admin_menu_items(): @@ -802,7 +802,9 @@ def list_users(): if not role: return jsonify({'success': False, 'error': '需要登录'}), 403 page = max(1, int(request.args.get('page', 1))) - _, _, denied = _ensure_backend_menu_access('users', 'history', 'shop-manage', 'skip-price-asin', 'query-asin') + _, _, denied = _ensure_backend_menu_access( + 'users', 'history', 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data' + ) if denied: return denied page_size = min(999, max(5, int(request.args.get('page_size', 15)))) @@ -2301,18 +2303,25 @@ def delete_shop_key(item_id): # ---------- 数据去重总数据 ---------- @admin_api.route('/dedupe-total-data') -@admin_required +@login_required def list_dedupe_total_data(): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied page = max(1, int(request.args.get('page', 1))) page_size = min(100, max(1, int(request.args.get('page_size', 15)))) keyword = (request.args.get('keyword') or '').strip() + username = (request.args.get('username') or '').strip() data, error_response, status = _proxy_backend_java( 'GET', '/api/admin/dedupe-total-data', - params={'page': page, 'pageSize': page_size, 'keyword': keyword}, + params={ + 'page': page, + 'pageSize': page_size, + 'keyword': keyword, + 'username': username, + 'operatorId': current_row.get('id'), + }, ) if error_response is not None: return error_response, status @@ -2321,6 +2330,8 @@ def list_dedupe_total_data(): { 'id': item.get('id'), 'data_value': item.get('dataValue') or '', + 'uploader_user_id': item.get('uploaderUserId'), + 'username': item.get('username') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], } for item in (payload.get('items') or []) @@ -2335,14 +2346,15 @@ def list_dedupe_total_data(): @admin_api.route('/dedupe-total-data/import/') -@admin_required +@login_required def dedupe_total_data_import_progress(import_id): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied result, error_response, status = _proxy_backend_java( 'GET', f'/api/admin/dedupe-total-data/import/{import_id}', + params={'operatorId': current_row.get('id')}, ) if error_response is not None: return error_response, status @@ -2362,9 +2374,9 @@ def dedupe_total_data_import_progress(import_id): @admin_api.route('/dedupe-total-data/import', methods=['POST']) -@admin_required +@login_required def import_dedupe_total_data(): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied file_storage = request.files.get('file') @@ -2377,6 +2389,7 @@ def import_dedupe_total_data(): 'POST', '/api/admin/dedupe-total-data/import', files=files, + data={'operatorId': current_row.get('id')}, ) if error_response is not None: return error_response, status @@ -2389,14 +2402,15 @@ def import_dedupe_total_data(): @admin_api.route('/dedupe-total-data/delete-import/') -@admin_required +@login_required def dedupe_total_data_delete_import_progress(import_id): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied result, error_response, status = _proxy_backend_java( 'GET', f'/api/admin/dedupe-total-data/delete-import/{import_id}', + params={'operatorId': current_row.get('id')}, ) if error_response is not None: return error_response, status @@ -2416,9 +2430,9 @@ def dedupe_total_data_delete_import_progress(import_id): @admin_api.route('/dedupe-total-data/delete-import', methods=['POST']) -@admin_required +@login_required def delete_import_dedupe_total_data(): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied file_storage = request.files.get('file') @@ -2431,6 +2445,7 @@ def delete_import_dedupe_total_data(): 'POST', '/api/admin/dedupe-total-data/delete-import', files=files, + data={'operatorId': current_row.get('id')}, ) if error_response is not None: return error_response, status @@ -2443,9 +2458,9 @@ def delete_import_dedupe_total_data(): @admin_api.route('/dedupe-total-data', methods=['POST']) -@admin_required +@login_required def create_dedupe_total_data(): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied data = request.get_json() or {} @@ -2453,6 +2468,7 @@ def create_dedupe_total_data(): result, error_response, status = _proxy_backend_java( 'POST', '/api/admin/dedupe-total-data', + params={'operatorId': current_row.get('id')}, json_data=payload, ) if error_response is not None: @@ -2464,15 +2480,17 @@ def create_dedupe_total_data(): 'item': { 'id': item.get('id'), 'data_value': item.get('dataValue') or '', + 'uploader_user_id': item.get('uploaderUserId'), + 'username': item.get('username') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], }, }) @admin_api.route('/dedupe-total-data/', methods=['PUT']) -@admin_required +@login_required def update_dedupe_total_data(item_id): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied data = request.get_json() or {} @@ -2480,6 +2498,7 @@ def update_dedupe_total_data(item_id): result, error_response, status = _proxy_backend_java( 'PUT', f'/api/admin/dedupe-total-data/{item_id}', + params={'operatorId': current_row.get('id')}, json_data=payload, ) if error_response is not None: @@ -2491,20 +2510,23 @@ def update_dedupe_total_data(item_id): 'item': { 'id': item.get('id'), 'data_value': item.get('dataValue') or '', + 'uploader_user_id': item.get('uploaderUserId'), + 'username': item.get('username') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], }, }) @admin_api.route('/dedupe-total-data/', methods=['DELETE']) -@admin_required +@login_required def delete_dedupe_total_data(item_id): - _, _, denied = _ensure_dedupe_total_data_access() + _, current_row, denied = _ensure_dedupe_total_data_access() if denied: return denied result, error_response, status = _proxy_backend_java( 'DELETE', f'/api/admin/dedupe-total-data/{item_id}', + params={'operatorId': current_row.get('id')}, ) if error_response is not None: return error_response, status @@ -2824,7 +2846,9 @@ def delete_shop_manage(item_id): @admin_api.route('/shop-manage-groups') @login_required def list_shop_manage_groups(): - role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin') + role, current_row, denied = _ensure_backend_menu_access( + 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data' + ) if denied: return denied groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row) @@ -2836,7 +2860,9 @@ def list_shop_manage_groups(): @admin_api.route('/shop-manage-group', methods=['POST']) @login_required def create_shop_manage_group(): - role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin') + role, current_row, denied = _ensure_backend_menu_access( + 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data' + ) if denied: return denied data = request.get_json() or {} @@ -2881,7 +2907,9 @@ def create_shop_manage_group(): @admin_api.route('/shop-manage-group/', methods=['PUT']) @login_required def update_shop_manage_group(item_id): - role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin') + role, current_row, denied = _ensure_backend_menu_access( + 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data' + ) if denied: return denied data = request.get_json() or {} @@ -3126,7 +3154,9 @@ def delete_skip_price_asin_country(item_id, country): @admin_api.route('/shop-manage-group/', methods=['DELETE']) @login_required def delete_shop_manage_group(item_id): - role, current_row, denied = _ensure_backend_menu_access('shop-manage', 'skip-price-asin', 'query-asin') + role, current_row, denied = _ensure_backend_menu_access( + 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data' + ) if denied: return denied result, error_response, status = _proxy_backend_java( diff --git a/backend/static/admin.js b/backend/static/admin.js index c3cf306..bea7313 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -115,7 +115,10 @@ function runTabLoader(tabName) { if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); } else if (tabName === 'columns') loadColumns(); - else if (tabName === 'dedupe-total-data') loadDedupeTotalData(1); + else if (tabName === 'dedupe-total-data') { + loadDedupeTotalData(1); + loadDedupeGroupSummary(); + } else if (tabName === 'invalid-asin-data') loadInvalidAsinData(1); else if (tabName === 'shop-keys') loadShopKeys(1); else if (tabName === 'shop-manage') loadShopManage(1); @@ -1407,7 +1410,9 @@ function buildDedupeTotalDataQuery(page) { var q = 'page=' + (page || 1) + '&page_size=' + dedupeTotalDataPageSize; var keyword = (document.getElementById('searchDedupeTotalData').value || '').trim(); + var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim(); if (keyword) q += '&keyword=' + encodeURIComponent(keyword); + if (username) q += '&username=' + encodeURIComponent(username); return q; } function loadDedupeTotalData(page) { @@ -1417,17 +1422,17 @@ .then(function (res) { var tbody = document.getElementById('dedupeTotalDataListBody'); if (!res.success) { - tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + tbody.innerHTML = '加载失败: ' + escapeHtml(res.error || '') + ''; return; } var items = res.items || []; if (items.length === 0) { - tbody.innerHTML = '暂无总数据'; + tbody.innerHTML = '暂无总数据'; } else { tbody.innerHTML = items.map(function (item) { - return '' + item.id + '' + (item.data_value || '') + '' + (item.created_at || '') + '' + - ' ' + - '' + + return '' + escapeHtml(item.id) + '' + escapeHtml(item.data_value || '') + '' + escapeHtml(item.username || '') + '' + escapeHtml(item.created_at || '') + '' + + ' ' + + '' + ''; }).join(''); } @@ -1435,7 +1440,7 @@ bindDedupeTotalDataActions(); }) .catch(function () { - document.getElementById('dedupeTotalDataListBody').innerHTML = '请求失败'; + document.getElementById('dedupeTotalDataListBody').innerHTML = '请求失败'; }); } function bindDedupeTotalDataActions() { @@ -2114,6 +2119,38 @@ }); } + function renderDedupeGroupSummary() { + var summaryEl = document.getElementById('dedupeGroupSummary'); + if (!summaryEl) return; + if (currentUserRole === 'super_admin') { + summaryEl.innerHTML = '全部分组 · ' + + escapeHtml(shopManageGroups.length) + ' 个'; + return; + } + if (!shopManageGroups.length) { + summaryEl.textContent = '未加入分组'; + return; + } + var visibleGroups = shopManageGroups.slice(0, 4); + summaryEl.innerHTML = visibleGroups.map(function (group) { + var relation = String(group.leader_user_id || '') === String(currentUserId || '') ? '组长' : '组员'; + return '' + + escapeHtml(group.group_name || '') + ' · ' + relation + ''; + }).join('') + (shopManageGroups.length > visibleGroups.length + ? '另 ' + escapeHtml(shopManageGroups.length - visibleGroups.length) + ' 个' + : ''); + } + + function loadDedupeGroupSummary(force) { + var summaryEl = document.getElementById('dedupeGroupSummary'); + if (!summaryEl) return Promise.resolve([]); + summaryEl.textContent = '正在加载...'; + return loadShopManageGroups(null, null, force).then(function (groups) { + renderDedupeGroupSummary(); + return groups; + }); + } + function resetShopManageGroupForm() { document.getElementById('shopManageGroupEditId').value = ''; document.getElementById('shopManageGroupLeaderUserId').value = currentUserId || ''; @@ -2130,7 +2167,7 @@ function updateShopManageGroupButtonsAccess() { var canManageGroups = !!currentUserId; - ['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups'].forEach(function (id) { + ['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups', 'btnManageDedupeGroups'].forEach(function (id) { var btn = document.getElementById(id); if (btn) btn.style.display = canManageGroups ? '' : 'none'; }); @@ -2210,6 +2247,7 @@ } loadShopManageGroups(null, null, true).then(function () { renderShopManageGroupRows(); + renderDedupeGroupSummary(); loadShopManage(shopManagePage); loadSkipPriceAsin(skipPriceAsinPage); }); @@ -2234,6 +2272,10 @@ setShopManageGroupGrantRoutes(['query-asin']); openShopManageGroupModal(); }; + document.getElementById('btnManageDedupeGroups').onclick = function () { + setShopManageGroupGrantRoutes(['dedupe-total-data']); + openShopManageGroupModal(); + }; document.getElementById('btnSearchShopManage').onclick = function () { loadShopManage(1); }; @@ -2282,6 +2324,7 @@ msgEl.className = 'msg ok'; loadShopManageGroups(null, null, true).then(function () { renderShopManageGroupRows(); + renderDedupeGroupSummary(); loadShopManage(shopManagePage); loadSkipPriceAsin(skipPriceAsinPage); }); diff --git a/backend/tests/test_dedupe_total_data_admin.py b/backend/tests/test_dedupe_total_data_admin.py new file mode 100644 index 0000000..a2583b8 --- /dev/null +++ b/backend/tests/test_dedupe_total_data_admin.py @@ -0,0 +1,130 @@ +import io +import unittest +from unittest.mock import patch + +from flask import jsonify + +from app import app +from blueprints import admin_api as admin_module + + +class DedupeTotalDataAdminTest(unittest.TestCase): + def setUp(self): + app.config.update(TESTING=True, SECRET_KEY='test') + self.client = app.test_client() + + def _login(self, user_id=23): + with self.client.session_transaction() as session: + session['user_id'] = user_id + + def test_list_forwards_session_operator_and_username_search(self): + self._login() + java_response = { + 'success': True, + 'data': { + 'items': [{ + 'id': 91, + 'dataValue': 'B012345678', + 'uploaderUserId': 23, + 'username': 'member-a', + 'createdAt': '2026-07-20T12:30:00', + }], + 'total': 1, + 'page': 1, + 'pageSize': 15, + }, + } + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_dedupe_total_data_access', + return_value=('normal', {'id': 23, 'username': 'member-a'}, None)), \ + patch.object(admin_module, '_proxy_backend_java', + return_value=(java_response, None, 200)) as proxy: + response = self.client.get( + '/api/admin/dedupe-total-data?keyword=B01&username=member&page=1&page_size=15') + + self.assertEqual(200, response.status_code) + payload = response.get_json() + self.assertTrue(payload['success']) + self.assertEqual('member-a', payload['items'][0]['username']) + self.assertEqual(23, payload['items'][0]['uploader_user_id']) + self.assertEqual({ + 'page': 1, + 'pageSize': 15, + 'keyword': 'B01', + 'username': 'member', + 'operatorId': 23, + }, proxy.call_args.kwargs['params']) + + def test_import_binds_session_operator_to_java_request(self): + self._login(31) + java_response = {'success': True, 'message': '开始导入', 'data': {'importId': 'import-1'}} + 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, '_proxy_backend_java', + return_value=(java_response, None, 200)) as proxy: + response = self.client.post( + '/api/admin/dedupe-total-data/import', + data={'file': (io.BytesIO(b'excel'), 'data.xlsx')}, + content_type='multipart/form-data') + + self.assertEqual(200, response.status_code) + self.assertEqual('import-1', response.get_json()['import_id']) + self.assertEqual({'operatorId': 31}, proxy.call_args.kwargs['data']) + + def test_delete_ignores_client_operator_and_uses_session_user(self): + self._login(41) + java_response = {'success': True, 'message': '删除成功', 'data': None} + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_dedupe_total_data_access', + return_value=('admin', {'id': 41, 'username': 'leader'}, None)), \ + patch.object(admin_module, '_proxy_backend_java', + return_value=(java_response, None, 200)) as proxy: + response = self.client.delete('/api/admin/dedupe-total-data/91?operatorId=1') + + self.assertEqual(200, response.status_code) + self.assertEqual({'operatorId': 41}, proxy.call_args.kwargs['params']) + + def test_menu_permission_is_still_required_for_normal_user(self): + self._login() + + def deny_access(): + return 'normal', {'id': 23}, (jsonify({'success': False, 'error': '无权访问'}), 403) + + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_dedupe_total_data_access', side_effect=deny_access), \ + patch.object(admin_module, '_proxy_backend_java') as proxy: + response = self.client.get('/api/admin/dedupe-total-data') + + self.assertEqual(403, response.status_code) + self.assertFalse(response.get_json()['success']) + proxy.assert_not_called() + + def test_dedupe_access_uses_login_capable_backend_menu_check(self): + expected = ('normal', {'id': 23}, None) + with patch.object(admin_module, '_ensure_backend_menu_access', return_value=expected) as backend_access, \ + patch.object(admin_module, '_ensure_admin_menu_access') as admin_access: + result = admin_module._ensure_dedupe_total_data_access() + + self.assertEqual(expected, result) + backend_access.assert_called_once_with('dedupe-total-data') + admin_access.assert_not_called() + + def test_group_list_accepts_dedupe_menu_as_access_source(self): + self._login() + access_result = ('normal', {'id': 23, 'username': 'member-a'}, None) + with patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_module, '_ensure_backend_menu_access', + return_value=access_result) as backend_access, \ + patch.object(admin_module, '_load_expanded_shop_manage_groups', + return_value=([], None, 200)): + response = self.client.get('/api/admin/shop-manage-groups') + + self.assertEqual(200, response.status_code) + self.assertTrue(response.get_json()['success']) + backend_access.assert_called_once_with( + 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data') + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 6e44b00..4d47412 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -426,6 +426,55 @@ z-index: 1010; } + .dedupe-group-access { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; + padding: 0 0 14px; + border-bottom: 1px solid #edf0f5; + } + + .dedupe-group-access-main { + min-width: 0; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .dedupe-group-access-title { + flex: 0 0 auto; + font-size: 14px; + font-weight: 600; + color: #333; + } + + .dedupe-group-summary { + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + color: #707781; + font-size: 13px; + } + + .dedupe-group-summary-chip { + display: inline-flex; + align-items: center; + max-width: 220px; + padding: 4px 8px; + border: 1px solid #dfe4ea; + border-radius: 4px; + background: #f8f9fa; + color: #4f5965; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .modal { background: #fff; border-radius: 8px; @@ -602,6 +651,15 @@ } @media (max-width: 900px) { + .dedupe-group-access { + align-items: stretch; + flex-direction: column; + } + + .dedupe-group-access .btn { + align-self: flex-start; + } + #shopManageGroupModal .shop-group-modal { width: calc(100vw - 24px); max-height: calc(100vh - 24px); @@ -765,80 +823,500 @@ .column-permission-select option { padding: 4px 0; } - .image-video-panel-box { padding:0; } - .image-video-results { padding:20px 20px 0; } - .image-video-panel-box > .pagination { padding:0 20px 20px; } - .image-video-toolbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:20px; } - .image-video-toolbar-main { display:flex; align-items:center; gap:18px; flex-wrap:wrap; } - .image-video-batch-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:36px; padding:8px 14px; background:#27b38b; } - .image-video-batch-btn:disabled { cursor:not-allowed; opacity:.45; } + + .image-video-panel-box { + padding: 0; + } + + .image-video-results { + padding: 20px 20px 0; + } + + .image-video-panel-box>.pagination { + padding: 0 20px 20px; + } + + .image-video-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; + } + + .image-video-toolbar-main { + display: flex; + align-items: center; + gap: 18px; + flex-wrap: wrap; + } + + .image-video-batch-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 36px; + padding: 8px 14px; + background: #27b38b; + } + + .image-video-batch-btn:disabled { + cursor: not-allowed; + opacity: .45; + } + .image-video-batch-btn svg, - .image-video-card-action svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; } - .image-video-select-all { display:inline-flex; align-items:center; gap:8px; color:#333; font-size:14px; cursor:pointer; } - .image-video-select-all input { width:16px; height:16px; accent-color:#27b38b; } - .image-video-download-progress { min-height:18px; color:#527067; font-size:13px; } - .image-video-summary { color:#666; font-size:13px; text-align:right; } - .image-video-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:20px; } - .image-video-card { min-width:0; overflow:hidden; background:#fff; border:1px solid #e0e0e0; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.05); transition:border-color .2s ease,box-shadow .2s ease,transform .2s ease; } - .image-video-card:hover { border-color:#cbd7d3; box-shadow:0 6px 16px rgba(32,74,62,.1); transform:translateY(-1px); } - .image-video-card.selected { border-color:#27b38b; box-shadow:0 0 0 2px rgba(39,179,139,.12),0 6px 16px rgba(32,74,62,.1); } - .image-video-media { position:relative; width:100%; aspect-ratio:16/9; display:flex; align-items:center; justify-content:center; background:#0b0d0c; overflow:hidden; } - .image-video-media video { display:block; width:100%; height:100%; object-fit:contain; background:#000; } - .image-video-card-check { position:absolute; top:11px; left:11px; z-index:2; width:17px; height:17px; accent-color:#27b38b; cursor:pointer; filter:drop-shadow(0 1px 2px rgba(0,0,0,.55)); } - .image-video-unavailable { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; padding:20px; color:#b9c1be; text-align:center; } - .image-video-unavailable svg { width:34px; height:34px; fill:none; stroke:currentColor; stroke-width:1.5; } - .image-video-unavailable strong { color:#e0e4e2; font-size:14px; font-weight:500; } - .image-video-card-body { padding:15px 16px 16px; } - .image-video-card-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; } - .image-video-card-title { min-width:0; font-size:15px; font-weight:600; color:#2b3532; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } - .image-video-status { flex:0 0 auto; display:inline-block; padding:3px 8px; border-radius:4px; background:#eef1f0; color:#5b6461; font-size:11px; font-weight:600; } - .image-video-status.SUCCESS { color:#14733b; background:#e5f5eb; } - .image-video-status.FAILED { color:#a82d2d; background:#fbe9e9; } + .image-video-card-action svg { + width: 15px; + height: 15px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; + } + + .image-video-select-all { + display: inline-flex; + align-items: center; + gap: 8px; + color: #333; + font-size: 14px; + cursor: pointer; + } + + .image-video-select-all input { + width: 16px; + height: 16px; + accent-color: #27b38b; + } + + .image-video-download-progress { + min-height: 18px; + color: #527067; + font-size: 13px; + } + + .image-video-summary { + color: #666; + font-size: 13px; + text-align: right; + } + + .image-video-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + } + + .image-video-card { + min-width: 0; + overflow: hidden; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, .05); + transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease; + } + + .image-video-card:hover { + border-color: #cbd7d3; + box-shadow: 0 6px 16px rgba(32, 74, 62, .1); + transform: translateY(-1px); + } + + .image-video-card.selected { + border-color: #27b38b; + box-shadow: 0 0 0 2px rgba(39, 179, 139, .12), 0 6px 16px rgba(32, 74, 62, .1); + } + + .image-video-media { + position: relative; + width: 100%; + aspect-ratio: 16/9; + display: flex; + align-items: center; + justify-content: center; + background: #0b0d0c; + overflow: hidden; + } + + .image-video-media video { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + background: #000; + } + + .image-video-card-check { + position: absolute; + top: 11px; + left: 11px; + z-index: 2; + width: 17px; + height: 17px; + accent-color: #27b38b; + cursor: pointer; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .55)); + } + + .image-video-unavailable { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 20px; + color: #b9c1be; + text-align: center; + } + + .image-video-unavailable svg { + width: 34px; + height: 34px; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + } + + .image-video-unavailable strong { + color: #e0e4e2; + font-size: 14px; + font-weight: 500; + } + + .image-video-card-body { + padding: 15px 16px 16px; + } + + .image-video-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; + } + + .image-video-card-title { + min-width: 0; + font-size: 15px; + font-weight: 600; + color: #2b3532; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .image-video-status { + flex: 0 0 auto; + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + background: #eef1f0; + color: #5b6461; + font-size: 11px; + font-weight: 600; + } + + .image-video-status.SUCCESS { + color: #14733b; + background: #e5f5eb; + } + + .image-video-status.FAILED { + color: #a82d2d; + background: #fbe9e9; + } + .image-video-status.RUNNING, - .image-video-status.POLLING { color:#8a6200; background:#fff3cd; } - .image-video-card-info { display:grid; gap:7px; } - .image-video-info-row { display:grid; grid-template-columns:68px minmax(0,1fr); gap:8px; color:#777; font-size:13px; line-height:1.45; } + .image-video-status.POLLING { + color: #8a6200; + background: #fff3cd; + } + + .image-video-card-info { + display: grid; + gap: 7px; + } + + .image-video-info-row { + display: grid; + grid-template-columns: 68px minmax(0, 1fr); + gap: 8px; + color: #777; + font-size: 13px; + line-height: 1.45; + } + .image-video-info-row span, - .image-video-info-row a { min-width:0; color:#333; overflow-wrap:anywhere; } - .image-video-info-row a { color:#218a6d; text-decoration:none; } - .image-video-info-row a:hover { text-decoration:underline; } - .image-video-copy-link { min-width:0; padding:0; border:0; background:none; color:#218a6d; cursor:pointer; font:inherit; text-align:left; } - .image-video-copy-link:hover { text-decoration:underline; } - .image-video-copy-link.copied { color:#14733b; font-weight:600; } - .image-video-card-actions { display:flex; align-items:center; gap:9px; margin-top:15px; } - .image-video-card-action { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:32px; padding:6px 11px; border:1px solid #27b38b; border-radius:4px; background:#fff; color:#218a6d; cursor:pointer; font-size:13px; } - .image-video-card-action:hover { background:#27b38b; color:#fff; } - .image-video-card-action:disabled { cursor:not-allowed; opacity:.45; } - .image-video-empty { grid-column:1/-1; padding:64px 20px; border:1px dashed #d7ddda; border-radius:8px; color:#777; text-align:center; background:#fafbfb; } - .image-video-permission-btn { display:none; min-height:36px; padding:8px 14px; } - .image-video-permission-modal { width:min(620px,calc(100vw - 32px)); } - .image-video-permission-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; } - .image-video-permission-tools { display:flex; align-items:center; gap:12px; margin-bottom:12px; } - .image-video-permission-tabs { display:flex; gap:4px; margin-bottom:12px; padding:3px; border-radius:6px; background:#f1f4f3; } - .image-video-permission-tab { flex:1; min-height:34px; padding:7px 10px; border:0; border-radius:4px; background:transparent; color:#68736f; cursor:pointer; font:inherit; font-size:13px; } - .image-video-permission-tab.active { background:#fff; color:#16835e; box-shadow:0 1px 4px rgba(38,70,58,.12); font-weight:600; } - .image-video-permission-search { flex:1; min-width:0; padding:9px 10px; border:1px solid #ddd; border-radius:6px; font:inherit; } - .image-video-permission-list { max-height:52vh; overflow:auto; border:1px solid #e1e5e3; border-radius:6px; } - .image-video-permission-row { display:grid; grid-template-columns:24px minmax(0,1fr) auto; align-items:center; gap:10px; min-height:48px; padding:9px 12px; border-bottom:1px solid #edf0ef; } - .image-video-permission-row:last-child { border-bottom:0; } - .image-video-permission-row input { width:16px; height:16px; accent-color:#27b38b; } - .image-video-permission-user { min-width:0; display:flex; flex-direction:column; gap:2px; } - .image-video-permission-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#293330; } - .image-video-permission-role { color:#727b78; font-size:12px; } - .image-video-permission-state { min-width:68px; padding:4px 8px; border:1px solid #d8dfdc; border-radius:4px; background:#fff; color:#6d7773; cursor:pointer; font-size:12px; white-space:nowrap; } - .image-video-permission-state.granted { border-color:#a9ddca; background:#ecfaf4; color:#16835e; } - .image-video-permission-state.pending { border-color:#efd39b; background:#fff8e8; color:#a56b00; } - .image-video-permission-summary { margin:0 0 10px; color:#66716d; font-size:13px; } - .image-video-permission-empty { padding:36px 16px; color:#777; text-align:center; } - .image-video-permission-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; } + .image-video-info-row a { + min-width: 0; + color: #333; + overflow-wrap: anywhere; + } + + .image-video-info-row a { + color: #218a6d; + text-decoration: none; + } + + .image-video-info-row a:hover { + text-decoration: underline; + } + + .image-video-copy-link { + min-width: 0; + padding: 0; + border: 0; + background: none; + color: #218a6d; + cursor: pointer; + font: inherit; + text-align: left; + } + + .image-video-copy-link:hover { + text-decoration: underline; + } + + .image-video-copy-link.copied { + color: #14733b; + font-weight: 600; + } + + .image-video-card-actions { + display: flex; + align-items: center; + gap: 9px; + margin-top: 15px; + } + + .image-video-card-action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + padding: 6px 11px; + border: 1px solid #27b38b; + border-radius: 4px; + background: #fff; + color: #218a6d; + cursor: pointer; + font-size: 13px; + } + + .image-video-card-action:hover { + background: #27b38b; + color: #fff; + } + + .image-video-card-action:disabled { + cursor: not-allowed; + opacity: .45; + } + + .image-video-empty { + grid-column: 1/-1; + padding: 64px 20px; + border: 1px dashed #d7ddda; + border-radius: 8px; + color: #777; + text-align: center; + background: #fafbfb; + } + + .image-video-permission-btn { + display: none; + min-height: 36px; + padding: 8px 14px; + } + + .image-video-permission-modal { + width: min(620px, calc(100vw - 32px)); + } + + .image-video-permission-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; + } + + .image-video-permission-tools { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + } + + .image-video-permission-tabs { + display: flex; + gap: 4px; + margin-bottom: 12px; + padding: 3px; + border-radius: 6px; + background: #f1f4f3; + } + + .image-video-permission-tab { + flex: 1; + min-height: 34px; + padding: 7px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: #68736f; + cursor: pointer; + font: inherit; + font-size: 13px; + } + + .image-video-permission-tab.active { + background: #fff; + color: #16835e; + box-shadow: 0 1px 4px rgba(38, 70, 58, .12); + font-weight: 600; + } + + .image-video-permission-search { + flex: 1; + min-width: 0; + padding: 9px 10px; + border: 1px solid #ddd; + border-radius: 6px; + font: inherit; + } + + .image-video-permission-list { + max-height: 52vh; + overflow: auto; + border: 1px solid #e1e5e3; + border-radius: 6px; + } + + .image-video-permission-row { + display: grid; + grid-template-columns: 24px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + min-height: 48px; + padding: 9px 12px; + border-bottom: 1px solid #edf0ef; + } + + .image-video-permission-row:last-child { + border-bottom: 0; + } + + .image-video-permission-row input { + width: 16px; + height: 16px; + accent-color: #27b38b; + } + + .image-video-permission-user { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .image-video-permission-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #293330; + } + + .image-video-permission-role { + color: #727b78; + font-size: 12px; + } + + .image-video-permission-state { + min-width: 68px; + padding: 4px 8px; + border: 1px solid #d8dfdc; + border-radius: 4px; + background: #fff; + color: #6d7773; + cursor: pointer; + font-size: 12px; + white-space: nowrap; + } + + .image-video-permission-state.granted { + border-color: #a9ddca; + background: #ecfaf4; + color: #16835e; + } + + .image-video-permission-state.pending { + border-color: #efd39b; + background: #fff8e8; + color: #a56b00; + } + + .image-video-permission-summary { + margin: 0 0 10px; + color: #66716d; + font-size: 13px; + } + + .image-video-permission-empty { + padding: 36px 16px; + color: #777; + text-align: center; + } + + .image-video-permission-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; + } + @media (max-width: 760px) { - .admin-user-box { position:static !important; width:max-content; max-width:100%; margin-bottom:16px; } - .tabs { max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; } - .tab { flex:0 0 auto; padding:10px 14px; } - .image-video-results { padding:16px; } - .image-video-toolbar { align-items:flex-start; flex-direction:column; } - .image-video-summary { text-align:left; } - .image-video-grid { grid-template-columns:minmax(0,1fr); gap:16px; } + .admin-user-box { + position: static !important; + width: max-content; + max-width: 100%; + margin-bottom: 16px; + } + + .tabs { + max-width: 100%; + overflow-x: auto; + overscroll-behavior-x: contain; + } + + .tab { + flex: 0 0 auto; + padding: 10px 14px; + } + + .image-video-results { + padding: 16px; + } + + .image-video-toolbar { + align-items: flex-start; + flex-direction: column; + } + + .image-video-summary { + text-align: left; + } + + .image-video-grid { + grid-template-columns: minmax(0, 1fr); + gap: 16px; + } } @@ -877,8 +1355,8 @@

创建用户

-

- 当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。

+

+ 当前没有开放注册入口,仅管理员可在此创建用户。层级关系:超级管理员 -> 管理员 -> 普通账号。

@@ -1050,7 +1528,8 @@
- +
@@ -1083,9 +1562,16 @@
+
+
+ 数据权限分组 +
正在加载...
+
+ +
-

新增总数据

+

新增ASIN

@@ -1121,19 +1607,24 @@
-

总数据列表

+

ASIN列表

+
+ + +
- + + @@ -1332,7 +1823,8 @@

-
+

导入文件新增

@@ -1444,10 +1936,12 @@
请选择国家后输入对应 ASIN
- +

-
+

导入添加

@@ -1546,7 +2040,8 @@
- +

@@ -1558,7 +2053,8 @@
- +
@@ -1582,9 +2078,12 @@

视频任务筛选

-
-
-
+
+
+
@@ -1593,16 +2092,21 @@
- - + - +
@@ -1691,10 +2195,10 @@
@@ -2014,17 +2519,23 @@
ID总数据值ASIN值用户名 创建时间 操作