更新ASIN库新需求
This commit is contained in:
@@ -44,8 +44,10 @@ public class DedupeTotalDataController {
|
|||||||
public ApiResponse<DedupeTotalDataPageVo> page(
|
public ApiResponse<DedupeTotalDataPageVo> page(
|
||||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||||
@Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword) {
|
@Parameter(description = "数据值模糊搜索关键字") @RequestParam(required = false) String keyword,
|
||||||
return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, 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
|
@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 = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataItemVo.class))),
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复")
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复")
|
||||||
})
|
})
|
||||||
public ApiResponse<DedupeTotalDataItemVo> create(@Valid @RequestBody DedupeTotalDataCreateRequest request) {
|
public ApiResponse<DedupeTotalDataItemVo> create(
|
||||||
return ApiResponse.success("创建成功", dedupeTotalDataService.create(request));
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||||
|
@Valid @RequestBody DedupeTotalDataCreateRequest request) {
|
||||||
|
return ApiResponse.success("创建成功", dedupeTotalDataService.create(request, operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/import")
|
@PostMapping("/import")
|
||||||
@@ -65,14 +69,17 @@ public class DedupeTotalDataController {
|
|||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列")
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列")
|
||||||
})
|
})
|
||||||
public ApiResponse<DedupeTotalDataImportStartVo> importExcel(
|
public ApiResponse<DedupeTotalDataImportStartVo> importExcel(
|
||||||
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file) {
|
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||||
return ApiResponse.success("开始导入", dedupeTotalDataService.startImport(file));
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||||
|
return ApiResponse.success("开始导入", dedupeTotalDataService.startImport(file, operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/import/{importId}")
|
@GetMapping("/import/{importId}")
|
||||||
@Operation(summary = "查询导入进度", description = "根据导入任务 ID 查询当前进度。")
|
@Operation(summary = "查询导入进度", description = "根据导入任务 ID 查询当前进度。")
|
||||||
public ApiResponse<DedupeTotalDataImportProgressVo> importProgress(@PathVariable String importId) {
|
public ApiResponse<DedupeTotalDataImportProgressVo> importProgress(
|
||||||
return ApiResponse.success(dedupeTotalDataService.getImportProgress(importId));
|
@PathVariable String importId,
|
||||||
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||||
|
return ApiResponse.success(dedupeTotalDataService.getImportProgress(importId, operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/delete-import")
|
@PostMapping("/delete-import")
|
||||||
@@ -82,14 +89,17 @@ public class DedupeTotalDataController {
|
|||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列")
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列")
|
||||||
})
|
})
|
||||||
public ApiResponse<DedupeTotalDataImportStartVo> deleteImportExcel(
|
public ApiResponse<DedupeTotalDataImportStartVo> deleteImportExcel(
|
||||||
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file) {
|
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||||
return ApiResponse.success("开始删除", dedupeTotalDataService.startDeleteImport(file));
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||||
|
return ApiResponse.success("开始删除", dedupeTotalDataService.startDeleteImport(file, operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/delete-import/{importId}")
|
@GetMapping("/delete-import/{importId}")
|
||||||
@Operation(summary = "查询删除导入进度", description = "根据删除导入任务 ID 查询当前进度。")
|
@Operation(summary = "查询删除导入进度", description = "根据删除导入任务 ID 查询当前进度。")
|
||||||
public ApiResponse<DedupeTotalDataImportProgressVo> deleteImportProgress(@PathVariable String importId) {
|
public ApiResponse<DedupeTotalDataImportProgressVo> deleteImportProgress(
|
||||||
return ApiResponse.success(dedupeTotalDataService.getDeleteImportProgress(importId));
|
@PathVariable String importId,
|
||||||
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||||
|
return ApiResponse.success(dedupeTotalDataService.getDeleteImportProgress(importId, operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
@@ -101,8 +111,9 @@ public class DedupeTotalDataController {
|
|||||||
})
|
})
|
||||||
public ApiResponse<DedupeTotalDataItemVo> update(
|
public ApiResponse<DedupeTotalDataItemVo> update(
|
||||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||||
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||||
@Valid @RequestBody DedupeTotalDataUpdateRequest request) {
|
@Valid @RequestBody DedupeTotalDataUpdateRequest request) {
|
||||||
return ApiResponse.success("更新成功", dedupeTotalDataService.update(id, request));
|
return ApiResponse.success("更新成功", dedupeTotalDataService.update(id, request, operatorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@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 = "200", description = "删除成功"),
|
||||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
|
||||||
})
|
})
|
||||||
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
public ApiResponse<Void> delete(
|
||||||
dedupeTotalDataService.delete(id);
|
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||||
|
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||||
|
dedupeTotalDataService.delete(id, operatorId);
|
||||||
return ApiResponse.success("删除成功", null);
|
return ApiResponse.success("删除成功", null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public class DedupeTotalDataEntity {
|
|||||||
@TableId(type = IdType.AUTO)
|
@TableId(type = IdType.AUTO)
|
||||||
private Long id;
|
private Long id;
|
||||||
private String dataValue;
|
private String dataValue;
|
||||||
|
private Long uploaderUserId;
|
||||||
|
private String uploaderUsername;
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
private LocalDateTime updatedAt;
|
private LocalDateTime updatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ public class DedupeTotalDataItemVo {
|
|||||||
@Schema(description = "总数据值")
|
@Schema(description = "总数据值")
|
||||||
private String dataValue;
|
private String dataValue;
|
||||||
|
|
||||||
|
@Schema(description = "上传用户ID")
|
||||||
|
private Long uploaderUserId;
|
||||||
|
|
||||||
|
@Schema(description = "上传用户名")
|
||||||
|
private String username;
|
||||||
|
|
||||||
@Schema(description = "创建时间")
|
@Schema(description = "创建时间")
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.DedupeTotalDataImportVo;
|
||||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataItemVo;
|
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataItemVo;
|
||||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo;
|
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 lombok.RequiredArgsConstructor;
|
||||||
import org.apache.poi.ss.usermodel.Cell;
|
import org.apache.poi.ss.usermodel.Cell;
|
||||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
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.annotation.Transactional;
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
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.File;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -44,9 +50,13 @@ public class DedupeTotalDataService {
|
|||||||
private static final int COMPARE_BATCH_SIZE = 5000;
|
private static final int COMPARE_BATCH_SIZE = 5000;
|
||||||
|
|
||||||
private final DedupeTotalDataMapper dedupeTotalDataMapper;
|
private final DedupeTotalDataMapper dedupeTotalDataMapper;
|
||||||
|
private final AdminUserMapper adminUserMapper;
|
||||||
|
private final ShopManageGroupMapper shopManageGroupMapper;
|
||||||
private final PlatformTransactionManager transactionManager;
|
private final PlatformTransactionManager transactionManager;
|
||||||
private final Map<String, DedupeTotalDataImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
|
private final Map<String, DedupeTotalDataImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
|
||||||
private final Map<String, DedupeTotalDataImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
|
private final Map<String, DedupeTotalDataImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Long> importOwnerMap = new ConcurrentHashMap<>();
|
||||||
|
private final Map<String, Long> deleteImportOwnerMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
private TransactionTemplate newRequiresNewTemplate() {
|
private TransactionTemplate newRequiresNewTemplate() {
|
||||||
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||||
@@ -54,12 +64,22 @@ public class DedupeTotalDataService {
|
|||||||
return template;
|
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 safePage = Math.max(page, 1);
|
||||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||||
String safeKeyword = keyword == null ? "" : keyword.trim();
|
String safeKeyword = keyword == null ? "" : keyword.trim();
|
||||||
|
String safeUsername = username == null ? "" : username.trim();
|
||||||
|
AccessScope scope = resolveAccessScope(operatorId);
|
||||||
|
Set<Long> usernameUserIds = safeUsername.isEmpty()
|
||||||
|
? Set.of()
|
||||||
|
: findUserIdsByUsername(safeUsername, scope);
|
||||||
|
if (!safeUsername.isEmpty() && usernameUserIds.isEmpty()) {
|
||||||
|
return emptyPage(safePage, safePageSize);
|
||||||
|
}
|
||||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||||
.like(!safeKeyword.isEmpty(), DedupeTotalDataEntity::getDataValue, safeKeyword)
|
.like(!safeKeyword.isEmpty(), DedupeTotalDataEntity::getDataValue, safeKeyword)
|
||||||
|
.in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds())
|
||||||
|
.in(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUserId, usernameUserIds)
|
||||||
.orderByDesc(DedupeTotalDataEntity::getId);
|
.orderByDesc(DedupeTotalDataEntity::getId);
|
||||||
Long total = dedupeTotalDataMapper.selectCount(query);
|
Long total = dedupeTotalDataMapper.selectCount(query);
|
||||||
List<DedupeTotalDataItemVo> items = dedupeTotalDataMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
|
List<DedupeTotalDataItemVo> items = dedupeTotalDataMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
|
||||||
@@ -75,11 +95,14 @@ public class DedupeTotalDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request) {
|
public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request, Long operatorId) {
|
||||||
|
AdminUserEntity uploader = getOperator(operatorId);
|
||||||
String dataValue = normalizeComparableValue(request.getDataValue());
|
String dataValue = normalizeComparableValue(request.getDataValue());
|
||||||
ensureUnique(dataValue, null);
|
ensureUnique(dataValue, null);
|
||||||
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
||||||
entity.setDataValue(dataValue);
|
entity.setDataValue(dataValue);
|
||||||
|
entity.setUploaderUserId(uploader.getId());
|
||||||
|
entity.setUploaderUsername(uploader.getUsername());
|
||||||
dedupeTotalDataMapper.insert(entity);
|
dedupeTotalDataMapper.insert(entity);
|
||||||
return toItemVo(getById(entity.getId()));
|
return toItemVo(getById(entity.getId()));
|
||||||
}
|
}
|
||||||
@@ -111,10 +134,11 @@ public class DedupeTotalDataService {
|
|||||||
return existingValues;
|
return existingValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DedupeTotalDataImportStartVo startImport(MultipartFile file) {
|
public DedupeTotalDataImportStartVo startImport(MultipartFile file, Long operatorId) {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BusinessException("请上传 xlsx 文件");
|
throw new BusinessException("请上传 xlsx 文件");
|
||||||
}
|
}
|
||||||
|
AdminUserEntity uploader = getOperator(operatorId);
|
||||||
String importId = IdUtil.fastSimpleUUID();
|
String importId = IdUtil.fastSimpleUUID();
|
||||||
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
|
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
|
||||||
progress.setStatus("pending");
|
progress.setStatus("pending");
|
||||||
@@ -124,13 +148,16 @@ public class DedupeTotalDataService {
|
|||||||
progress.setInsertedCount(0);
|
progress.setInsertedCount(0);
|
||||||
progress.setSkippedCount(0);
|
progress.setSkippedCount(0);
|
||||||
importProgressMap.put(importId, progress);
|
importProgressMap.put(importId, progress);
|
||||||
|
importOwnerMap.put(importId, uploader.getId());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
File tempFile = saveMultipartToTempFile(file);
|
File tempFile = saveMultipartToTempFile(file);
|
||||||
String filename = file.getOriginalFilename();
|
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) {
|
} catch (Exception e) {
|
||||||
importProgressMap.remove(importId);
|
importProgressMap.remove(importId);
|
||||||
|
importOwnerMap.remove(importId);
|
||||||
throw new BusinessException("读取上传文件失败");
|
throw new BusinessException("读取上传文件失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,18 +166,21 @@ public class DedupeTotalDataService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DedupeTotalDataImportProgressVo getImportProgress(String importId) {
|
public DedupeTotalDataImportProgressVo getImportProgress(String importId, Long operatorId) {
|
||||||
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
|
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
|
||||||
if (progress == null) {
|
Long ownerId = importOwnerMap.get(importId);
|
||||||
|
if (progress == null || ownerId == null) {
|
||||||
throw new BusinessException("导入任务不存在");
|
throw new BusinessException("导入任务不存在");
|
||||||
}
|
}
|
||||||
|
ensureUploaderAccess(ownerId, resolveAccessScope(operatorId));
|
||||||
return progress;
|
return progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file) {
|
public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file, Long operatorId) {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BusinessException("请上传 xlsx 文件");
|
throw new BusinessException("请上传 xlsx 文件");
|
||||||
}
|
}
|
||||||
|
AdminUserEntity operator = getOperator(operatorId);
|
||||||
String importId = IdUtil.fastSimpleUUID();
|
String importId = IdUtil.fastSimpleUUID();
|
||||||
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
|
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
|
||||||
progress.setStatus("pending");
|
progress.setStatus("pending");
|
||||||
@@ -160,13 +190,15 @@ public class DedupeTotalDataService {
|
|||||||
progress.setInsertedCount(0);
|
progress.setInsertedCount(0);
|
||||||
progress.setSkippedCount(0);
|
progress.setSkippedCount(0);
|
||||||
deleteImportProgressMap.put(importId, progress);
|
deleteImportProgressMap.put(importId, progress);
|
||||||
|
deleteImportOwnerMap.put(importId, operator.getId());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
File tempFile = saveMultipartToTempFile(file);
|
File tempFile = saveMultipartToTempFile(file);
|
||||||
String filename = file.getOriginalFilename();
|
String filename = file.getOriginalFilename();
|
||||||
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename));
|
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename, operator.getId()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
deleteImportProgressMap.remove(importId);
|
deleteImportProgressMap.remove(importId);
|
||||||
|
deleteImportOwnerMap.remove(importId);
|
||||||
throw new BusinessException("读取上传文件失败");
|
throw new BusinessException("读取上传文件失败");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,62 +207,25 @@ public class DedupeTotalDataService {
|
|||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId) {
|
public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId, Long operatorId) {
|
||||||
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
|
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
|
||||||
if (progress == null) {
|
Long ownerId = deleteImportOwnerMap.get(importId);
|
||||||
|
if (progress == null || ownerId == null) {
|
||||||
throw new BusinessException("删除任务不存在");
|
throw new BusinessException("删除任务不存在");
|
||||||
}
|
}
|
||||||
|
ensureUploaderAccess(ownerId, resolveAccessScope(operatorId));
|
||||||
return progress;
|
return progress;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void runDeleteImportTask(String importId, byte[] fileBytes, 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 = 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) {
|
|
||||||
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
|
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
|
||||||
if (progress == null) {
|
if (progress == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
progress.setStatus("running");
|
progress.setStatus("running");
|
||||||
try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) {
|
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.setTotalRows(result.getTotalRows());
|
||||||
progress.setAsinCount(result.getAsinCount());
|
progress.setAsinCount(result.getAsinCount());
|
||||||
progress.setInsertedCount(result.getInsertedCount());
|
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);
|
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
|
||||||
if (progress == null) {
|
if (progress == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
progress.setStatus("running");
|
progress.setStatus("running");
|
||||||
try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) {
|
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.setTotalRows(result.getTotalRows());
|
||||||
progress.setAsinCount(result.getAsinCount());
|
progress.setAsinCount(result.getAsinCount());
|
||||||
progress.setInsertedCount(result.getInsertedCount());
|
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()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BusinessException("请上传 xlsx 文件");
|
throw new BusinessException("请上传 xlsx 文件");
|
||||||
}
|
}
|
||||||
|
AdminUserEntity uploader = getOperator(operatorId);
|
||||||
try (InputStream inputStream = file.getInputStream()) {
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
return importFromExcelInternal(inputStream, file.getOriginalFilename(), null);
|
return importFromExcelInternal(
|
||||||
|
inputStream, file.getOriginalFilename(), null, uploader.getId(), uploader.getUsername());
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception 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()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BusinessException("请上传 xlsx 文件");
|
throw new BusinessException("请上传 xlsx 文件");
|
||||||
}
|
}
|
||||||
|
AccessScope scope = resolveAccessScope(operatorId);
|
||||||
try (InputStream inputStream = file.getInputStream()) {
|
try (InputStream inputStream = file.getInputStream()) {
|
||||||
return deleteFromExcelInternal(inputStream, file.getOriginalFilename(), null);
|
return deleteFromExcelInternal(inputStream, file.getOriginalFilename(), null, scope);
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (Exception 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();
|
String lowerFilename = filename == null ? "" : filename.toLowerCase();
|
||||||
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
|
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
|
||||||
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
||||||
@@ -411,6 +413,8 @@ public class DedupeTotalDataService {
|
|||||||
}
|
}
|
||||||
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
||||||
entity.setDataValue(pendingDataValue);
|
entity.setDataValue(pendingDataValue);
|
||||||
|
entity.setUploaderUserId(uploaderUserId);
|
||||||
|
entity.setUploaderUsername(uploaderUsername);
|
||||||
dedupeTotalDataMapper.insert(entity);
|
dedupeTotalDataMapper.insert(entity);
|
||||||
return true;
|
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();
|
String lowerFilename = filename == null ? "" : filename.toLowerCase();
|
||||||
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
|
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
|
||||||
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
||||||
@@ -518,7 +524,7 @@ public class DedupeTotalDataService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
int deletedThisRow = newRequiresNewTemplate().execute(status -> deleteByDataValue(dataValue));
|
int deletedThisRow = newRequiresNewTemplate().execute(status -> deleteByDataValue(dataValue, scope));
|
||||||
if (deletedThisRow > 0) {
|
if (deletedThisRow > 0) {
|
||||||
deletedCount += deletedThisRow;
|
deletedCount += deletedThisRow;
|
||||||
} else {
|
} else {
|
||||||
@@ -546,8 +552,8 @@ public class DedupeTotalDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DedupeTotalDataItemVo update(Long id, DedupeTotalDataUpdateRequest request) {
|
public DedupeTotalDataItemVo update(Long id, DedupeTotalDataUpdateRequest request, Long operatorId) {
|
||||||
DedupeTotalDataEntity entity = getById(id);
|
DedupeTotalDataEntity entity = getAccessibleById(id, operatorId);
|
||||||
String dataValue = normalizeComparableValue(request.getDataValue());
|
String dataValue = normalizeComparableValue(request.getDataValue());
|
||||||
ensureUnique(dataValue, id);
|
ensureUnique(dataValue, id);
|
||||||
entity.setDataValue(dataValue);
|
entity.setDataValue(dataValue);
|
||||||
@@ -556,11 +562,17 @@ public class DedupeTotalDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void delete(Long id) {
|
public void delete(Long id, Long operatorId) {
|
||||||
DedupeTotalDataEntity entity = getById(id);
|
DedupeTotalDataEntity entity = getAccessibleById(id, operatorId);
|
||||||
dedupeTotalDataMapper.deleteById(entity.getId());
|
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) {
|
private DedupeTotalDataEntity getById(Long id) {
|
||||||
DedupeTotalDataEntity entity = dedupeTotalDataMapper.selectById(id);
|
DedupeTotalDataEntity entity = dedupeTotalDataMapper.selectById(id);
|
||||||
if (entity == null) {
|
if (entity == null) {
|
||||||
@@ -598,9 +610,70 @@ public class DedupeTotalDataService {
|
|||||||
return dedupeTotalDataMapper.selectOne(query) != null;
|
return dedupeTotalDataMapper.selectOne(query) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int deleteByDataValue(String dataValue) {
|
private int deleteByDataValue(String dataValue, AccessScope scope) {
|
||||||
return dedupeTotalDataMapper.delete(new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
return dedupeTotalDataMapper.delete(new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||||
.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<Long> visibleUserIds = new LinkedHashSet<>();
|
||||||
|
visibleUserIds.add(operator.getId());
|
||||||
|
List<Long> 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<Long> findUserIdsByUsername(String username, AccessScope scope) {
|
||||||
|
Set<Long> 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) {
|
private String normalizeExcelText(String value) {
|
||||||
@@ -621,7 +694,12 @@ public class DedupeTotalDataService {
|
|||||||
DedupeTotalDataItemVo vo = new DedupeTotalDataItemVo();
|
DedupeTotalDataItemVo vo = new DedupeTotalDataItemVo();
|
||||||
vo.setId(entity.getId());
|
vo.setId(entity.getId());
|
||||||
vo.setDataValue(entity.getDataValue());
|
vo.setDataValue(entity.getDataValue());
|
||||||
|
vo.setUploaderUserId(entity.getUploaderUserId());
|
||||||
|
vo.setUsername(entity.getUploaderUsername());
|
||||||
vo.setCreatedAt(entity.getCreatedAt());
|
vo.setCreatedAt(entity.getCreatedAt());
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record AccessScope(boolean allUsers, Set<Long> userIds) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ package com.nanri.aiimage.modules.permission.mapper;
|
|||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||||
import org.apache.ibatis.annotations.Mapper;
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Mapper
|
@Mapper
|
||||||
public interface AdminUserMapper extends BaseMapper<AdminUserEntity> {
|
public interface AdminUserMapper extends BaseMapper<AdminUserEntity> {
|
||||||
|
|
||||||
|
@Select("SELECT id FROM users WHERE username LIKE CONCAT('%', #{username}, '%') ORDER BY id ASC")
|
||||||
|
List<Long> selectIdsByUsernameLike(@Param("username") String username);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,4 +67,15 @@ public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity>
|
|||||||
ORDER BY visible_user_id ASC
|
ORDER BY visible_user_id ASC
|
||||||
""")
|
""")
|
||||||
List<Long> selectAccessibleCreatorUserIds(@Param("operatorId") Long operatorId);
|
List<Long> 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<Long> selectManagedMemberUserIds(@Param("operatorId") Long operatorId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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<DedupeTotalDataEntity> 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<String> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ def _can_access_dedupe_total_data(role, current_row):
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_dedupe_total_data_access():
|
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()
|
role, current_row = get_current_admin_role()
|
||||||
if not _can_access_dedupe_total_data(role, current_row):
|
if not _can_access_dedupe_total_data(role, current_row):
|
||||||
return None, None, (jsonify({'success': False, 'error': '无权访问数据去重汇总数据'}), 403)
|
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():
|
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():
|
def _load_current_admin_menu_items():
|
||||||
@@ -802,7 +802,9 @@ def list_users():
|
|||||||
if not role:
|
if not role:
|
||||||
return jsonify({'success': False, 'error': '需要登录'}), 403
|
return jsonify({'success': False, 'error': '需要登录'}), 403
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
_, _, 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:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
page_size = min(999, max(5, int(request.args.get('page_size', 15))))
|
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_api.route('/dedupe-total-data')
|
||||||
@admin_required
|
@login_required
|
||||||
def list_dedupe_total_data():
|
def list_dedupe_total_data():
|
||||||
_, _, denied = _ensure_dedupe_total_data_access()
|
_, current_row, denied = _ensure_dedupe_total_data_access()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
|
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
|
||||||
keyword = (request.args.get('keyword') or '').strip()
|
keyword = (request.args.get('keyword') or '').strip()
|
||||||
|
username = (request.args.get('username') or '').strip()
|
||||||
data, error_response, status = _proxy_backend_java(
|
data, error_response, status = _proxy_backend_java(
|
||||||
'GET',
|
'GET',
|
||||||
'/api/admin/dedupe-total-data',
|
'/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:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -2321,6 +2330,8 @@ def list_dedupe_total_data():
|
|||||||
{
|
{
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'data_value': item.get('dataValue') or '',
|
'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],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
}
|
}
|
||||||
for item in (payload.get('items') or [])
|
for item in (payload.get('items') or [])
|
||||||
@@ -2335,14 +2346,15 @@ def list_dedupe_total_data():
|
|||||||
|
|
||||||
|
|
||||||
@admin_api.route('/dedupe-total-data/import/<import_id>')
|
@admin_api.route('/dedupe-total-data/import/<import_id>')
|
||||||
@admin_required
|
@login_required
|
||||||
def dedupe_total_data_import_progress(import_id):
|
def dedupe_total_data_import_progress(import_id):
|
||||||
_, _, denied = _ensure_dedupe_total_data_access()
|
_, current_row, denied = _ensure_dedupe_total_data_access()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'GET',
|
'GET',
|
||||||
f'/api/admin/dedupe-total-data/import/{import_id}',
|
f'/api/admin/dedupe-total-data/import/{import_id}',
|
||||||
|
params={'operatorId': current_row.get('id')},
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
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_api.route('/dedupe-total-data/import', methods=['POST'])
|
||||||
@admin_required
|
@login_required
|
||||||
def import_dedupe_total_data():
|
def import_dedupe_total_data():
|
||||||
_, _, denied = _ensure_dedupe_total_data_access()
|
_, current_row, denied = _ensure_dedupe_total_data_access()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
file_storage = request.files.get('file')
|
file_storage = request.files.get('file')
|
||||||
@@ -2377,6 +2389,7 @@ def import_dedupe_total_data():
|
|||||||
'POST',
|
'POST',
|
||||||
'/api/admin/dedupe-total-data/import',
|
'/api/admin/dedupe-total-data/import',
|
||||||
files=files,
|
files=files,
|
||||||
|
data={'operatorId': current_row.get('id')},
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -2389,14 +2402,15 @@ def import_dedupe_total_data():
|
|||||||
|
|
||||||
|
|
||||||
@admin_api.route('/dedupe-total-data/delete-import/<import_id>')
|
@admin_api.route('/dedupe-total-data/delete-import/<import_id>')
|
||||||
@admin_required
|
@login_required
|
||||||
def dedupe_total_data_delete_import_progress(import_id):
|
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:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'GET',
|
'GET',
|
||||||
f'/api/admin/dedupe-total-data/delete-import/{import_id}',
|
f'/api/admin/dedupe-total-data/delete-import/{import_id}',
|
||||||
|
params={'operatorId': current_row.get('id')},
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
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_api.route('/dedupe-total-data/delete-import', methods=['POST'])
|
||||||
@admin_required
|
@login_required
|
||||||
def delete_import_dedupe_total_data():
|
def delete_import_dedupe_total_data():
|
||||||
_, _, denied = _ensure_dedupe_total_data_access()
|
_, current_row, denied = _ensure_dedupe_total_data_access()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
file_storage = request.files.get('file')
|
file_storage = request.files.get('file')
|
||||||
@@ -2431,6 +2445,7 @@ def delete_import_dedupe_total_data():
|
|||||||
'POST',
|
'POST',
|
||||||
'/api/admin/dedupe-total-data/delete-import',
|
'/api/admin/dedupe-total-data/delete-import',
|
||||||
files=files,
|
files=files,
|
||||||
|
data={'operatorId': current_row.get('id')},
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -2443,9 +2458,9 @@ def delete_import_dedupe_total_data():
|
|||||||
|
|
||||||
|
|
||||||
@admin_api.route('/dedupe-total-data', methods=['POST'])
|
@admin_api.route('/dedupe-total-data', methods=['POST'])
|
||||||
@admin_required
|
@login_required
|
||||||
def create_dedupe_total_data():
|
def create_dedupe_total_data():
|
||||||
_, _, denied = _ensure_dedupe_total_data_access()
|
_, current_row, denied = _ensure_dedupe_total_data_access()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -2453,6 +2468,7 @@ def create_dedupe_total_data():
|
|||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'POST',
|
'POST',
|
||||||
'/api/admin/dedupe-total-data',
|
'/api/admin/dedupe-total-data',
|
||||||
|
params={'operatorId': current_row.get('id')},
|
||||||
json_data=payload,
|
json_data=payload,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
@@ -2464,15 +2480,17 @@ def create_dedupe_total_data():
|
|||||||
'item': {
|
'item': {
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'data_value': item.get('dataValue') or '',
|
'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],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['PUT'])
|
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['PUT'])
|
||||||
@admin_required
|
@login_required
|
||||||
def update_dedupe_total_data(item_id):
|
def update_dedupe_total_data(item_id):
|
||||||
_, _, denied = _ensure_dedupe_total_data_access()
|
_, current_row, denied = _ensure_dedupe_total_data_access()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -2480,6 +2498,7 @@ def update_dedupe_total_data(item_id):
|
|||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'PUT',
|
'PUT',
|
||||||
f'/api/admin/dedupe-total-data/{item_id}',
|
f'/api/admin/dedupe-total-data/{item_id}',
|
||||||
|
params={'operatorId': current_row.get('id')},
|
||||||
json_data=payload,
|
json_data=payload,
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
@@ -2491,20 +2510,23 @@ def update_dedupe_total_data(item_id):
|
|||||||
'item': {
|
'item': {
|
||||||
'id': item.get('id'),
|
'id': item.get('id'),
|
||||||
'data_value': item.get('dataValue') or '',
|
'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],
|
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['DELETE'])
|
@admin_api.route('/dedupe-total-data/<int:item_id>', methods=['DELETE'])
|
||||||
@admin_required
|
@login_required
|
||||||
def delete_dedupe_total_data(item_id):
|
def delete_dedupe_total_data(item_id):
|
||||||
_, _, denied = _ensure_dedupe_total_data_access()
|
_, current_row, denied = _ensure_dedupe_total_data_access()
|
||||||
if denied:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
'DELETE',
|
'DELETE',
|
||||||
f'/api/admin/dedupe-total-data/{item_id}',
|
f'/api/admin/dedupe-total-data/{item_id}',
|
||||||
|
params={'operatorId': current_row.get('id')},
|
||||||
)
|
)
|
||||||
if error_response is not None:
|
if error_response is not None:
|
||||||
return error_response, status
|
return error_response, status
|
||||||
@@ -2824,7 +2846,9 @@ def delete_shop_manage(item_id):
|
|||||||
@admin_api.route('/shop-manage-groups')
|
@admin_api.route('/shop-manage-groups')
|
||||||
@login_required
|
@login_required
|
||||||
def list_shop_manage_groups():
|
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:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
groups, error_response, status = _load_expanded_shop_manage_groups(role, current_row)
|
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'])
|
@admin_api.route('/shop-manage-group', methods=['POST'])
|
||||||
@login_required
|
@login_required
|
||||||
def create_shop_manage_group():
|
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:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -2881,7 +2907,9 @@ def create_shop_manage_group():
|
|||||||
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
|
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['PUT'])
|
||||||
@login_required
|
@login_required
|
||||||
def update_shop_manage_group(item_id):
|
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:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
@@ -3126,7 +3154,9 @@ def delete_skip_price_asin_country(item_id, country):
|
|||||||
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
|
@admin_api.route('/shop-manage-group/<int:item_id>', methods=['DELETE'])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_shop_manage_group(item_id):
|
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:
|
if denied:
|
||||||
return denied
|
return denied
|
||||||
result, error_response, status = _proxy_backend_java(
|
result, error_response, status = _proxy_backend_java(
|
||||||
|
|||||||
@@ -115,7 +115,10 @@
|
|||||||
function runTabLoader(tabName) {
|
function runTabLoader(tabName) {
|
||||||
if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); }
|
if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); }
|
||||||
else if (tabName === 'columns') loadColumns();
|
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 === 'invalid-asin-data') loadInvalidAsinData(1);
|
||||||
else if (tabName === 'shop-keys') loadShopKeys(1);
|
else if (tabName === 'shop-keys') loadShopKeys(1);
|
||||||
else if (tabName === 'shop-manage') loadShopManage(1);
|
else if (tabName === 'shop-manage') loadShopManage(1);
|
||||||
@@ -1407,7 +1410,9 @@
|
|||||||
function buildDedupeTotalDataQuery(page) {
|
function buildDedupeTotalDataQuery(page) {
|
||||||
var q = 'page=' + (page || 1) + '&page_size=' + dedupeTotalDataPageSize;
|
var q = 'page=' + (page || 1) + '&page_size=' + dedupeTotalDataPageSize;
|
||||||
var keyword = (document.getElementById('searchDedupeTotalData').value || '').trim();
|
var keyword = (document.getElementById('searchDedupeTotalData').value || '').trim();
|
||||||
|
var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim();
|
||||||
if (keyword) q += '&keyword=' + encodeURIComponent(keyword);
|
if (keyword) q += '&keyword=' + encodeURIComponent(keyword);
|
||||||
|
if (username) q += '&username=' + encodeURIComponent(username);
|
||||||
return q;
|
return q;
|
||||||
}
|
}
|
||||||
function loadDedupeTotalData(page) {
|
function loadDedupeTotalData(page) {
|
||||||
@@ -1417,17 +1422,17 @@
|
|||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
var tbody = document.getElementById('dedupeTotalDataListBody');
|
var tbody = document.getElementById('dedupeTotalDataListBody');
|
||||||
if (!res.success) {
|
if (!res.success) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">加载失败: ' + escapeHtml(res.error || '') + '</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var items = res.items || [];
|
var items = res.items || [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-tip">暂无总数据</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="5" class="empty-tip">暂无总数据</td></tr>';
|
||||||
} else {
|
} else {
|
||||||
tbody.innerHTML = items.map(function (item) {
|
tbody.innerHTML = items.map(function (item) {
|
||||||
return '<tr><td>' + item.id + '</td><td>' + (item.data_value || '') + '</td><td>' + (item.created_at || '') + '</td><td>' +
|
return '<tr><td>' + escapeHtml(item.id) + '</td><td>' + escapeHtml(item.data_value || '') + '</td><td>' + escapeHtml(item.username || '') + '</td><td>' + escapeHtml(item.created_at || '') + '</td><td>' +
|
||||||
'<button class="btn btn-sm" data-dedupe-total-edit="' + item.id + '" data-value="' + (item.data_value || '').replace(/"/g, '"') + '">编辑</button> ' +
|
'<button class="btn btn-sm" data-dedupe-total-edit="' + escapeHtml(item.id) + '" data-value="' + escapeHtml(item.data_value || '') + '">编辑</button> ' +
|
||||||
'<button class="btn btn-sm btn-danger" data-dedupe-total-delete="' + item.id + '" data-value="' + (item.data_value || '').replace(/"/g, '"') + '">删除</button>' +
|
'<button class="btn btn-sm btn-danger" data-dedupe-total-delete="' + escapeHtml(item.id) + '" data-value="' + escapeHtml(item.data_value || '') + '">删除</button>' +
|
||||||
'</td></tr>';
|
'</td></tr>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -1435,7 +1440,7 @@
|
|||||||
bindDedupeTotalDataActions();
|
bindDedupeTotalDataActions();
|
||||||
})
|
})
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
document.getElementById('dedupeTotalDataListBody').innerHTML = '<tr><td colspan="4" class="empty-tip">请求失败</td></tr>';
|
document.getElementById('dedupeTotalDataListBody').innerHTML = '<tr><td colspan="5" class="empty-tip">请求失败</td></tr>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function bindDedupeTotalDataActions() {
|
function bindDedupeTotalDataActions() {
|
||||||
@@ -2114,6 +2119,38 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDedupeGroupSummary() {
|
||||||
|
var summaryEl = document.getElementById('dedupeGroupSummary');
|
||||||
|
if (!summaryEl) return;
|
||||||
|
if (currentUserRole === 'super_admin') {
|
||||||
|
summaryEl.innerHTML = '<span class="dedupe-group-summary-chip">全部分组 · ' +
|
||||||
|
escapeHtml(shopManageGroups.length) + ' 个</span>';
|
||||||
|
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 '<span class="dedupe-group-summary-chip" title="' + escapeHtml(group.group_name || '') + '">' +
|
||||||
|
escapeHtml(group.group_name || '') + ' · ' + relation + '</span>';
|
||||||
|
}).join('') + (shopManageGroups.length > visibleGroups.length
|
||||||
|
? '<span>另 ' + escapeHtml(shopManageGroups.length - visibleGroups.length) + ' 个</span>'
|
||||||
|
: '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
function resetShopManageGroupForm() {
|
||||||
document.getElementById('shopManageGroupEditId').value = '';
|
document.getElementById('shopManageGroupEditId').value = '';
|
||||||
document.getElementById('shopManageGroupLeaderUserId').value = currentUserId || '';
|
document.getElementById('shopManageGroupLeaderUserId').value = currentUserId || '';
|
||||||
@@ -2130,7 +2167,7 @@
|
|||||||
|
|
||||||
function updateShopManageGroupButtonsAccess() {
|
function updateShopManageGroupButtonsAccess() {
|
||||||
var canManageGroups = !!currentUserId;
|
var canManageGroups = !!currentUserId;
|
||||||
['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups'].forEach(function (id) {
|
['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups', 'btnManageDedupeGroups'].forEach(function (id) {
|
||||||
var btn = document.getElementById(id);
|
var btn = document.getElementById(id);
|
||||||
if (btn) btn.style.display = canManageGroups ? '' : 'none';
|
if (btn) btn.style.display = canManageGroups ? '' : 'none';
|
||||||
});
|
});
|
||||||
@@ -2210,6 +2247,7 @@
|
|||||||
}
|
}
|
||||||
loadShopManageGroups(null, null, true).then(function () {
|
loadShopManageGroups(null, null, true).then(function () {
|
||||||
renderShopManageGroupRows();
|
renderShopManageGroupRows();
|
||||||
|
renderDedupeGroupSummary();
|
||||||
loadShopManage(shopManagePage);
|
loadShopManage(shopManagePage);
|
||||||
loadSkipPriceAsin(skipPriceAsinPage);
|
loadSkipPriceAsin(skipPriceAsinPage);
|
||||||
});
|
});
|
||||||
@@ -2234,6 +2272,10 @@
|
|||||||
setShopManageGroupGrantRoutes(['query-asin']);
|
setShopManageGroupGrantRoutes(['query-asin']);
|
||||||
openShopManageGroupModal();
|
openShopManageGroupModal();
|
||||||
};
|
};
|
||||||
|
document.getElementById('btnManageDedupeGroups').onclick = function () {
|
||||||
|
setShopManageGroupGrantRoutes(['dedupe-total-data']);
|
||||||
|
openShopManageGroupModal();
|
||||||
|
};
|
||||||
document.getElementById('btnSearchShopManage').onclick = function () {
|
document.getElementById('btnSearchShopManage').onclick = function () {
|
||||||
loadShopManage(1);
|
loadShopManage(1);
|
||||||
};
|
};
|
||||||
@@ -2282,6 +2324,7 @@
|
|||||||
msgEl.className = 'msg ok';
|
msgEl.className = 'msg ok';
|
||||||
loadShopManageGroups(null, null, true).then(function () {
|
loadShopManageGroups(null, null, true).then(function () {
|
||||||
renderShopManageGroupRows();
|
renderShopManageGroupRows();
|
||||||
|
renderDedupeGroupSummary();
|
||||||
loadShopManage(shopManagePage);
|
loadShopManage(shopManagePage);
|
||||||
loadSkipPriceAsin(skipPriceAsinPage);
|
loadSkipPriceAsin(skipPriceAsinPage);
|
||||||
});
|
});
|
||||||
|
|||||||
130
backend/tests/test_dedupe_total_data_admin.py
Normal file
130
backend/tests/test_dedupe_total_data_admin.py
Normal file
@@ -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()
|
||||||
@@ -426,6 +426,55 @@
|
|||||||
z-index: 1010;
|
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 {
|
.modal {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -602,6 +651,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@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 {
|
#shopManageGroupModal .shop-group-modal {
|
||||||
width: calc(100vw - 24px);
|
width: calc(100vw - 24px);
|
||||||
max-height: calc(100vh - 24px);
|
max-height: calc(100vh - 24px);
|
||||||
@@ -765,80 +823,500 @@
|
|||||||
.column-permission-select option {
|
.column-permission-select option {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
.image-video-panel-box { padding:0; }
|
|
||||||
.image-video-results { padding:20px 20px 0; }
|
.image-video-panel-box {
|
||||||
.image-video-panel-box > .pagination { padding:0 20px 20px; }
|
padding: 0;
|
||||||
.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-results {
|
||||||
.image-video-batch-btn:disabled { cursor:not-allowed; opacity:.45; }
|
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-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-card-action svg {
|
||||||
.image-video-select-all { display:inline-flex; align-items:center; gap:8px; color:#333; font-size:14px; cursor:pointer; }
|
width: 15px;
|
||||||
.image-video-select-all input { width:16px; height:16px; accent-color:#27b38b; }
|
height: 15px;
|
||||||
.image-video-download-progress { min-height:18px; color:#527067; font-size:13px; }
|
fill: none;
|
||||||
.image-video-summary { color:#666; font-size:13px; text-align:right; }
|
stroke: currentColor;
|
||||||
.image-video-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:20px; }
|
stroke-width: 2;
|
||||||
.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; }
|
stroke-linecap: round;
|
||||||
.image-video-card:hover { border-color:#cbd7d3; box-shadow:0 6px 16px rgba(32,74,62,.1); transform:translateY(-1px); }
|
stroke-linejoin: round;
|
||||||
.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-select-all {
|
||||||
.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)); }
|
display: inline-flex;
|
||||||
.image-video-unavailable { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; padding:20px; color:#b9c1be; text-align:center; }
|
align-items: center;
|
||||||
.image-video-unavailable svg { width:34px; height:34px; fill:none; stroke:currentColor; stroke-width:1.5; }
|
gap: 8px;
|
||||||
.image-video-unavailable strong { color:#e0e4e2; font-size:14px; font-weight:500; }
|
color: #333;
|
||||||
.image-video-card-body { padding:15px 16px 16px; }
|
font-size: 14px;
|
||||||
.image-video-card-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
|
cursor: pointer;
|
||||||
.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-select-all input {
|
||||||
.image-video-status.FAILED { color:#a82d2d; background:#fbe9e9; }
|
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.RUNNING,
|
||||||
.image-video-status.POLLING { color:#8a6200; background:#fff3cd; }
|
.image-video-status.POLLING {
|
||||||
.image-video-card-info { display:grid; gap:7px; }
|
color: #8a6200;
|
||||||
.image-video-info-row { display:grid; grid-template-columns:68px minmax(0,1fr); gap:8px; color:#777; font-size:13px; line-height:1.45; }
|
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 span,
|
||||||
.image-video-info-row a { min-width:0; color:#333; overflow-wrap:anywhere; }
|
.image-video-info-row a {
|
||||||
.image-video-info-row a { color:#218a6d; text-decoration:none; }
|
min-width: 0;
|
||||||
.image-video-info-row a:hover { text-decoration:underline; }
|
color: #333;
|
||||||
.image-video-copy-link { min-width:0; padding:0; border:0; background:none; color:#218a6d; cursor:pointer; font:inherit; text-align:left; }
|
overflow-wrap: anywhere;
|
||||||
.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-info-row a {
|
||||||
.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; }
|
color: #218a6d;
|
||||||
.image-video-card-action:hover { background:#27b38b; color:#fff; }
|
text-decoration: none;
|
||||||
.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-info-row a:hover {
|
||||||
.image-video-permission-modal { width:min(620px,calc(100vw - 32px)); }
|
text-decoration: underline;
|
||||||
.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-copy-link {
|
||||||
.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; }
|
min-width: 0;
|
||||||
.image-video-permission-tab.active { background:#fff; color:#16835e; box-shadow:0 1px 4px rgba(38,70,58,.12); font-weight:600; }
|
padding: 0;
|
||||||
.image-video-permission-search { flex:1; min-width:0; padding:9px 10px; border:1px solid #ddd; border-radius:6px; font:inherit; }
|
border: 0;
|
||||||
.image-video-permission-list { max-height:52vh; overflow:auto; border:1px solid #e1e5e3; border-radius:6px; }
|
background: none;
|
||||||
.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; }
|
color: #218a6d;
|
||||||
.image-video-permission-row:last-child { border-bottom:0; }
|
cursor: pointer;
|
||||||
.image-video-permission-row input { width:16px; height:16px; accent-color:#27b38b; }
|
font: inherit;
|
||||||
.image-video-permission-user { min-width:0; display:flex; flex-direction:column; gap:2px; }
|
text-align: left;
|
||||||
.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-copy-link:hover {
|
||||||
.image-video-permission-state.granted { border-color:#a9ddca; background:#ecfaf4; color:#16835e; }
|
text-decoration: underline;
|
||||||
.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-copy-link.copied {
|
||||||
.image-video-permission-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
|
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) {
|
@media (max-width: 760px) {
|
||||||
.admin-user-box { position:static !important; width:max-content; max-width:100%; margin-bottom:16px; }
|
.admin-user-box {
|
||||||
.tabs { max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; }
|
position: static !important;
|
||||||
.tab { flex:0 0 auto; padding:10px 14px; }
|
width: max-content;
|
||||||
.image-video-results { padding:16px; }
|
max-width: 100%;
|
||||||
.image-video-toolbar { align-items:flex-start; flex-direction:column; }
|
margin-bottom: 16px;
|
||||||
.image-video-summary { text-align:left; }
|
}
|
||||||
.image-video-grid { grid-template-columns:minmax(0,1fr); gap: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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -1050,7 +1528,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>更新日志</label>
|
<label>更新日志</label>
|
||||||
<textarea id="digitalHumanVersionChangelog" rows="3" placeholder="例如:修复数字人口型同步问题,优化启动速度" style="width:100%;padding:10px;border:1px solid #ddd;border-radius:6px;resize:vertical;"></textarea>
|
<textarea id="digitalHumanVersionChangelog" rows="3" placeholder="例如:修复数字人口型同步问题,优化启动速度"
|
||||||
|
style="width:100%;padding:10px;border:1px solid #ddd;border-radius:6px;resize:vertical;"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>ZIP 压缩包 *</label>
|
<label>ZIP 压缩包 *</label>
|
||||||
@@ -1083,9 +1562,16 @@
|
|||||||
<!-- 数据去重总数据 -->
|
<!-- 数据去重总数据 -->
|
||||||
<div id="panel-dedupe-total-data" class="tab-panel">
|
<div id="panel-dedupe-total-data" class="tab-panel">
|
||||||
<div class="form-box">
|
<div class="form-box">
|
||||||
|
<div class="dedupe-group-access">
|
||||||
|
<div class="dedupe-group-access-main">
|
||||||
|
<span class="dedupe-group-access-title">数据权限分组</span>
|
||||||
|
<div class="dedupe-group-summary" id="dedupeGroupSummary">正在加载...</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" id="btnManageDedupeGroups" type="button">管理分组</button>
|
||||||
|
</div>
|
||||||
<div class="form-row" style="align-items:flex-start;gap:16px;">
|
<div class="form-row" style="align-items:flex-start;gap:16px;">
|
||||||
<div style="flex:1;min-width:320px;">
|
<div style="flex:1;min-width:320px;">
|
||||||
<h3 style="margin-bottom:16px;font-size:15px;">新增总数据</h3>
|
<h3 style="margin-bottom:16px;font-size:15px;">新增ASIN</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="min-width:320px;">
|
<div class="form-group" style="min-width:320px;">
|
||||||
<label>上传 Excel 文件(读取 ASIN 列)</label>
|
<label>上传 Excel 文件(读取 ASIN 列)</label>
|
||||||
@@ -1121,19 +1607,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-box">
|
<div class="panel-box">
|
||||||
<h3 style="margin-bottom:16px;font-size:15px;">总数据列表</h3>
|
<h3 style="margin-bottom:16px;font-size:15px;">ASIN列表</h3>
|
||||||
<div class="form-row" style="margin-bottom:16px;">
|
<div class="form-row" style="margin-bottom:16px;">
|
||||||
<div class="form-group" style="min-width:220px;">
|
<div class="form-group" style="min-width:220px;">
|
||||||
<label>数据值(模糊搜索)</label>
|
<label>数据值(模糊搜索)</label>
|
||||||
<input type="text" id="searchDedupeTotalData" placeholder="输入关键字">
|
<input type="text" id="searchDedupeTotalData" placeholder="输入关键字">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="min-width:220px;">
|
||||||
|
<label>用户名(模糊搜索)</label>
|
||||||
|
<input type="text" id="searchDedupeTotalDataUsername" placeholder="输入用户名">
|
||||||
|
</div>
|
||||||
<button class=" btn" id="btnSearchDedupeTotalData">查询</button>
|
<button class=" btn" id="btnSearchDedupeTotalData">查询</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>总数据值</th>
|
<th>ASIN值</th>
|
||||||
|
<th>用户名</th>
|
||||||
<th>创建时间</th>
|
<th>创建时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1332,7 +1823,8 @@
|
|||||||
<button class="btn" id="btnCreateSkipPriceAsin">新增 ASIN</button>
|
<button class="btn" id="btnCreateSkipPriceAsin">新增 ASIN</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="msg" id="msgSkipPriceAsin"></p>
|
<p class="msg" id="msgSkipPriceAsin"></p>
|
||||||
<div class="form-row" style="align-items:flex-start;gap:16px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px;">
|
<div class="form-row"
|
||||||
|
style="align-items:flex-start;gap:16px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px;">
|
||||||
<div style="flex:1;min-width:320px;">
|
<div style="flex:1;min-width:320px;">
|
||||||
<h3 style="margin-bottom:12px;font-size:14px;">导入文件新增</h3>
|
<h3 style="margin-bottom:12px;font-size:14px;">导入文件新增</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -1444,10 +1936,12 @@
|
|||||||
<div style="color:#999;font-size:13px;">请选择国家后输入对应 ASIN</div>
|
<div style="color:#999;font-size:13px;">请选择国家后输入对应 ASIN</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" id="btnCreateQueryAsin" style="align-self:flex-end;margin-bottom:40px;">新增 ASIN</button>
|
<button class="btn" id="btnCreateQueryAsin" style="align-self:flex-end;margin-bottom:40px;">新增
|
||||||
|
ASIN</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="msg" id="msgQueryAsin"></p>
|
<p class="msg" id="msgQueryAsin"></p>
|
||||||
<div class="form-row" style="align-items:flex-start;gap:16px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px;">
|
<div class="form-row"
|
||||||
|
style="align-items:flex-start;gap:16px;margin-top:18px;border-top:1px solid #f0f0f0;padding-top:16px;">
|
||||||
<div style="flex:1;min-width:320px;">
|
<div style="flex:1;min-width:320px;">
|
||||||
<h3 style="margin-bottom:12px;font-size:14px;">导入添加</h3>
|
<h3 style="margin-bottom:12px;font-size:14px;">导入添加</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -1546,7 +2040,8 @@
|
|||||||
<input type="text" id="productCategoryDescription" placeholder="可填写风险说明或使用场景">
|
<input type="text" id="productCategoryDescription" placeholder="可填写风险说明或使用场景">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" id="btnCreateProductCategory">新增类目</button>
|
<button class="btn" id="btnCreateProductCategory">新增类目</button>
|
||||||
<button class="btn btn-secondary" id="btnCancelProductCategoryEdit" type="button" style="display:none;">取消编辑</button>
|
<button class="btn btn-secondary" id="btnCancelProductCategoryEdit" type="button"
|
||||||
|
style="display:none;">取消编辑</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="msg" id="msgProductCategory"></p>
|
<p class="msg" id="msgProductCategory"></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1558,7 +2053,8 @@
|
|||||||
<input type="text" id="productCategoryKeyword" placeholder="搜索类目名称、编码或备注">
|
<input type="text" id="productCategoryKeyword" placeholder="搜索类目名称、编码或备注">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm" id="btnSearchProductCategory" type="button">搜索</button>
|
<button class="btn btn-sm" id="btnSearchProductCategory" type="button">搜索</button>
|
||||||
<button class="btn btn-sm btn-secondary" id="btnClearProductCategorySearch" type="button">清空</button>
|
<button class="btn btn-sm btn-secondary" id="btnClearProductCategorySearch"
|
||||||
|
type="button">清空</button>
|
||||||
<button class="btn btn-sm btn-secondary" id="btnExportProductCategory" type="button">导出</button>
|
<button class="btn btn-sm btn-secondary" id="btnExportProductCategory" type="button">导出</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1582,9 +2078,12 @@
|
|||||||
<div class="form-box">
|
<div class="form-box">
|
||||||
<h3 style="margin-bottom:16px;font-size:15px;">视频任务筛选</h3>
|
<h3 style="margin-bottom:16px;font-size:15px;">视频任务筛选</h3>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group" style="min-width:150px;"><label>用户名</label><input type="text" id="imageVideoFilterUsername" placeholder="模糊搜索"></div>
|
<div class="form-group" style="min-width:150px;"><label>用户名</label><input type="text"
|
||||||
<div class="form-group" style="min-width:170px;"><label>提交开始</label><input type="datetime-local" id="imageVideoFilterFrom"></div>
|
id="imageVideoFilterUsername" placeholder="模糊搜索"></div>
|
||||||
<div class="form-group" style="min-width:170px;"><label>提交结束</label><input type="datetime-local" id="imageVideoFilterTo"></div>
|
<div class="form-group" style="min-width:170px;"><label>提交开始</label><input type="datetime-local"
|
||||||
|
id="imageVideoFilterFrom"></div>
|
||||||
|
<div class="form-group" style="min-width:170px;"><label>提交结束</label><input type="datetime-local"
|
||||||
|
id="imageVideoFilterTo"></div>
|
||||||
<button class="btn" id="btnFilterImageVideoTasks" type="button">查询</button>
|
<button class="btn" id="btnFilterImageVideoTasks" type="button">查询</button>
|
||||||
<button class="btn btn-secondary" id="btnResetImageVideoTasks" type="button">重置</button>
|
<button class="btn btn-secondary" id="btnResetImageVideoTasks" type="button">重置</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1593,16 +2092,21 @@
|
|||||||
<div class="image-video-results">
|
<div class="image-video-results">
|
||||||
<div class="image-video-toolbar">
|
<div class="image-video-toolbar">
|
||||||
<div class="image-video-toolbar-main">
|
<div class="image-video-toolbar-main">
|
||||||
<button class="btn btn-secondary image-video-permission-btn" id="btnOpenImageVideoPermissions" type="button">权限配置</button>
|
<button class="btn btn-secondary image-video-permission-btn" id="btnOpenImageVideoPermissions"
|
||||||
<button class="btn image-video-batch-btn" id="btnBatchDownloadImageVideos" type="button" disabled>
|
type="button">权限配置</button>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"></path></svg>
|
<button class="btn image-video-batch-btn" id="btnBatchDownloadImageVideos" type="button"
|
||||||
|
disabled>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"></path>
|
||||||
|
</svg>
|
||||||
批量下载
|
批量下载
|
||||||
</button>
|
</button>
|
||||||
<label class="image-video-select-all">
|
<label class="image-video-select-all">
|
||||||
<input type="checkbox" id="imageVideoSelectAll">
|
<input type="checkbox" id="imageVideoSelectAll">
|
||||||
全选当前页
|
全选当前页
|
||||||
</label>
|
</label>
|
||||||
<span class="image-video-download-progress" id="imageVideoDownloadProgress" aria-live="polite"></span>
|
<span class="image-video-download-progress" id="imageVideoDownloadProgress"
|
||||||
|
aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
<span class="image-video-summary" id="imageVideoTaskTotal"></span>
|
<span class="image-video-summary" id="imageVideoTaskTotal"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1691,10 +2195,10 @@
|
|||||||
<!-- 编辑总数据弹窗 -->
|
<!-- 编辑总数据弹窗 -->
|
||||||
<div class="modal-mask" id="editDedupeTotalDataModal">
|
<div class="modal-mask" id="editDedupeTotalDataModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
<h3>编辑总数据</h3>
|
<h3>编辑ASIN</h3>
|
||||||
<input type="hidden" id="editDedupeTotalDataId">
|
<input type="hidden" id="editDedupeTotalDataId">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>总数据值</label>
|
<label>ASIN值</label>
|
||||||
<input type="text" id="editDedupeTotalDataValue" placeholder="请输入总数据值">
|
<input type="text" id="editDedupeTotalDataValue" placeholder="请输入总数据值">
|
||||||
</div>
|
</div>
|
||||||
<p class=" msg" id="msgEditDedupeTotalData"></p>
|
<p class=" msg" id="msgEditDedupeTotalData"></p>
|
||||||
@@ -1784,7 +2288,8 @@
|
|||||||
<label>组员</label>
|
<label>组员</label>
|
||||||
<select id="shopManageGroupMemberSelect" class="shop-group-member-select" multiple size="6">
|
<select id="shopManageGroupMemberSelect" class="shop-group-member-select" multiple size="6">
|
||||||
</select>
|
</select>
|
||||||
<div id="shopManageGroupMemberHelp" class="shop-group-help">可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。</div>
|
<div id="shopManageGroupMemberHelp" class="shop-group-help">可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="shop-group-action-bar">
|
<div class="shop-group-action-bar">
|
||||||
@@ -2014,17 +2519,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-mask" id="imageVideoPermissionModal">
|
<div class="modal-mask" id="imageVideoPermissionModal">
|
||||||
<div class="modal image-video-permission-modal" role="dialog" aria-modal="true" aria-labelledby="imageVideoPermissionTitle">
|
<div class="modal image-video-permission-modal" role="dialog" aria-modal="true"
|
||||||
|
aria-labelledby="imageVideoPermissionTitle">
|
||||||
<div class="image-video-permission-head">
|
<div class="image-video-permission-head">
|
||||||
<h3 id="imageVideoPermissionTitle">视频任务权限配置</h3>
|
<h3 id="imageVideoPermissionTitle">视频任务权限配置</h3>
|
||||||
<button class="btn btn-secondary btn-sm" id="btnCloseImageVideoPermissions" type="button">关闭</button>
|
<button class="btn btn-secondary btn-sm" id="btnCloseImageVideoPermissions" type="button">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-video-permission-tabs" role="tablist" aria-label="视频权限用户范围">
|
<div class="image-video-permission-tabs" role="tablist" aria-label="视频权限用户范围">
|
||||||
<button class="image-video-permission-tab active" type="button" role="tab" aria-selected="true" data-image-video-permission-view="granted">已分配用户 <span id="imageVideoPermissionGrantedCount">(0)</span></button>
|
<button class="image-video-permission-tab active" type="button" role="tab" aria-selected="true"
|
||||||
<button class="image-video-permission-tab" type="button" role="tab" aria-selected="false" data-image-video-permission-view="all">全部用户 <span id="imageVideoPermissionAllCount">(0)</span></button>
|
data-image-video-permission-view="granted">已分配用户 <span
|
||||||
|
id="imageVideoPermissionGrantedCount">(0)</span></button>
|
||||||
|
<button class="image-video-permission-tab" type="button" role="tab" aria-selected="false"
|
||||||
|
data-image-video-permission-view="all">全部用户 <span
|
||||||
|
id="imageVideoPermissionAllCount">(0)</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-video-permission-tools">
|
<div class="image-video-permission-tools">
|
||||||
<input class="image-video-permission-search" id="imageVideoPermissionSearch" type="search" placeholder="搜索用户名">
|
<input class="image-video-permission-search" id="imageVideoPermissionSearch" type="search"
|
||||||
|
placeholder="搜索用户名">
|
||||||
<label class="image-video-select-all">
|
<label class="image-video-select-all">
|
||||||
<input type="checkbox" id="imageVideoPermissionSelectAll">
|
<input type="checkbox" id="imageVideoPermissionSelectAll">
|
||||||
全选搜索结果
|
全选搜索结果
|
||||||
@@ -2039,8 +2550,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/admin.js?v=permission-tabs-3"></script>
|
<script src="/static/admin.js?v=dedupe-groups-1"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user