feat(admin): 店铺数据重复检查撞款抽屉样式优化与导入测试按钮
Build Backend JAR / build (push) Has been cancelled

- 撞款详情抽屉:上架时间默认降序(晚到早)、去掉列头(按时间升序)小括号、加宽至 760px、
  去掉全屏遮罩(底层仍可点击)、抽屉贴到「撞款详情」区块位置而非浏览器顶部
- 修复抽屉 fixed 定位失效:.tab-panel.active 入场动画带 transform 会成为包含块,
  打开时把 drawer mask 挂到 body 下恢复视口定位
- 新增导入(新增-测试)/ 导入(删除-测试)按钮与上传弹窗,对应后端端点
  /api/admin/shop-data-crawl/duplicate-check-import(ADD=按店名替换基线记录、DELETE=移除该店记录)
- 新增 ShopDataDuplicateCheckImportService 导入服务(复用 WorkbookParser/Aggregator,与扫描共用 Redis 锁)
- 新增 3 个单测:导入替换重聚合、删除移除、无基线报错
- _preview_mock.py 加入 .gitignore(本地预览工具不入库)
This commit is contained in:
2026-09-04 12:25:31 +08:00
parent 8241cd704e
commit 05bda8915a
8 changed files with 766 additions and 130 deletions
+3
View File
@@ -123,3 +123,6 @@ backend-java/docs/*audit*.md
check_progress.py
progress.json
progress_*.json
# ===== 本地预览工具(不入库)=====
backend/_preview_mock.py
@@ -6,6 +6,7 @@ import com.nanri.aiimage.common.util.DownloadHeaderUtil;
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckImportService;
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckQueryService;
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService;
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService.DuplicateScanView;
@@ -18,9 +19,11 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
@@ -31,7 +34,7 @@ import java.util.Map;
import java.util.Set;
/**
* 店铺数据重复检查(撞款):overview / items / detail / export 端点,契约与 Flask 页面一致。
* 店铺数据重复检查(撞款):overview / items / detail / export / import 五端点,契约与 Flask 页面一致。
* JWT 管理员或 Flask 内部代理(X-Internal-Token + operatorId)均可调用;统一菜单+数据权限校验。
*/
@Slf4j
@@ -51,6 +54,7 @@ public class AdminShopDataDuplicateCheckController {
private final PermissionMenuService permissionMenuService;
private final ShopDataDuplicateCheckScanService scanService;
private final ShopDataDuplicateCheckQueryService queryService;
private final ShopDataDuplicateCheckImportService importService;
@GetMapping("/duplicate-check-overview")
@Operation(summary = "撞款指标与店铺分布(force=1 触发同步重扫)")
@@ -156,6 +160,39 @@ public class AdminShopDataDuplicateCheckController {
}
}
@PostMapping("/duplicate-check-import")
@Operation(summary = "撞款导入测试:新增(mode=add)或删除(mode=delete)指定店铺记录")
public ApiResponse<Object> importExcel(
HttpServletRequest request,
@RequestParam(name = "mode", defaultValue = "add") String mode,
@RequestParam(name = "shop_name", required = false) String shopName,
@RequestParam(name = "country_codes", required = false) String countryCodes,
@RequestParam(name = "file") MultipartFile file) {
RequestOperator operator = requireDuplicateCheckAccess(request);
if (file == null || file.isEmpty()) {
throw new BusinessException(400, "请上传 Excel 文件");
}
String m = mode == null ? "add" : mode.trim().toLowerCase();
boolean deleteMode = "delete".equals(m) || "del".equals(m) || "remove".equals(m);
try {
ShopDataDuplicateCheckImportService.ImportSummary summary = importService.importExcel(
shopName, countryCodes, file.getBytes(), deleteMode);
log.info("[shop-duplicate-check-import] 端点完成 mode={} operator_id={} 解析行数={} 聚合ASIN数={}",
deleteMode ? "DELETE" : "ADD", operator.id(), summary.totalRows(), summary.asinCount());
return ApiResponse.success(Map.of(
"total_rows", summary.totalRows(),
"asin_count", summary.asinCount(),
"shop_count", summary.shopCount(),
"record_count", summary.recordCount(),
"message", (deleteMode ? "删除" : "新增") + "导入成功"));
} catch (BusinessException ex) {
throw ex;
} catch (Exception ex) {
log.error("[shop-duplicate-check-import] 导入端点异常 operator_id={}", operator.id(), ex);
throw new BusinessException(500, "导入失败:" + (ex.getMessage() == null ? "未知错误" : ex.getMessage()));
}
}
private record RequestOperator(Long id, boolean superAdmin) {
}
@@ -0,0 +1,170 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDataDuplicateScanMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.model.entity.ShopDataDuplicateScanEntity;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateItem;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateOccurrence;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateShop;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckAggregator;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckWorkbookParser;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.RawRow;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.ShopParsed;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* 撞款导入(新增-测试 / 删除-测试):上传店铺结果 xlsx,对最新扫描 payload 做
* 店铺级新增/删除后重新聚合,落库成功后前端「重新分析」即读到包含导入数据的视图。
* 导入只写新增的扫描结果行,不触碰原始结果文件/扫描源,避免污染真实扫描。
*/
@Service
@Slf4j
public class ShopDataDuplicateCheckImportService {
/** 与扫描共用同一把锁,避免导入落库与定时扫描交叉写。 */
static final String IMPORT_LOCK = ShopDataDuplicateCheckScanService.SCAN_LOCK;
static final Duration IMPORT_LOCK_TTL = Duration.ofMinutes(10);
private final ShopDataDuplicateCheckScanService scanService;
private final ShopDataDuplicateScanMapper scanMapper;
private final DistributedJobLockService distributedJobLockService;
private final ObjectMapper objectMapper;
@Autowired
public ShopDataDuplicateCheckImportService(ShopDataDuplicateCheckScanService scanService,
ShopDataDuplicateScanMapper scanMapper,
DistributedJobLockService distributedJobLockService,
ObjectMapper objectMapper) {
this.scanService = scanService;
this.scanMapper = scanMapper;
this.distributedJobLockService = distributedJobLockService;
this.objectMapper = objectMapper;
}
/** 导入结果摘要:解析行数 / 聚合后 ASIN 数 / 店铺数 / 记录数。 */
public record ImportSummary(int totalRows, int asinCount, int shopCount, int recordCount) {
}
/**
* 执行一次导入:ADD=按 shop_name 加入/替换该店记录,DELETE=从聚合中移除该店全部记录。
* 无最新扫描结果时直接失败(导入无基线可重组)。
*/
public ImportSummary importExcel(String shopName, String countryCodes, byte[] bytes, boolean deleteMode) {
if (shopName == null || shopName.isBlank()) {
throw new BusinessException(400, "请填写店铺名称");
}
String name = shopName.trim();
List<String> codes = normalizeCountryCodes(countryCodes);
List<RawRow> rows;
try {
rows = DuplicateCheckWorkbookParser.parse(bytes);
} catch (Exception ex) {
log.warn("[shop-duplicate-check-import] 解析导入 Excel 失败 shop={} msg={}", name, ex.getMessage());
throw new BusinessException(400, "Excel 解析失败:" + (ex.getMessage() == null ? "格式错误" : ex.getMessage()));
}
if (rows.isEmpty()) {
throw new BusinessException(400, "Excel 中未读取到 ASIN 数据(需包含 ASIN 列)");
}
try (DistributedJobLockService.LockHandle lock = distributedJobLockService.tryLock(IMPORT_LOCK, IMPORT_LOCK_TTL)) {
if (lock == null) {
throw new BusinessException(409, "扫描/导入进行中,请稍后重试");
}
return applyToLatest(name, codes, rows, deleteMode);
}
}
private ImportSummary applyToLatest(String shopName, List<String> countryCodes,
List<RawRow> rows, boolean deleteMode) {
ShopDataDuplicateCheckScanService.DuplicateScanView view = scanService.loadLatest();
if (view == null || view.payload() == null) {
throw new BusinessException(400, "暂无扫描结果,请先点击「重新分析」");
}
DuplicateScanPayload base = view.payload();
// 以最新扫描的 payload 重建各店解析行(occurrences 是行级明细)
Map<String, List<RawRow>> rowsByShop = new LinkedHashMap<>();
Map<String, String> groupByShop = new LinkedHashMap<>();
Map<String, List<String>> codesByShop = new LinkedHashMap<>();
for (DuplicateShop shop : base.shops()) {
rowsByShop.put(shop.shopName(), new ArrayList<>());
groupByShop.put(shop.shopName(), shop.groupName());
codesByShop.put(shop.shopName(), shop.countryCodes());
}
for (DuplicateItem item : base.items()) {
for (DuplicateOccurrence occ : item.occurrences()) {
rowsByShop.computeIfAbsent(occ.shopName(), k -> new ArrayList<>())
.add(new RawRow(occ.asin(), occ.date(), occ.price(), occ.brand(), occ.country()));
}
}
List<ShopParsed> baseParsed = new ArrayList<>();
boolean touched = false;
for (Map.Entry<String, List<RawRow>> entry : rowsByShop.entrySet()) {
if (entry.getKey().equalsIgnoreCase(shopName)) {
touched = true;
if (deleteMode) {
continue;
}
// 新增-测试:该店以导入文件为准,替换基线旧记录
continue;
}
baseParsed.add(new ShopParsed(entry.getKey(), groupByShop.getOrDefault(entry.getKey(), ""),
codesByShop.getOrDefault(entry.getKey(), List.of()), entry.getValue()));
}
if (deleteMode && !touched) {
throw new BusinessException(400, "店铺「" + shopName + "」不在当前扫描基线中,无法删除");
}
// 导入行 ASIN 归一化大写,与扫描聚合口径一致
List<RawRow> normalized = new ArrayList<>(rows.size());
for (RawRow row : rows) {
normalized.add(new RawRow(
row.asin() == null ? "" : row.asin().trim().toUpperCase(Locale.ROOT),
row.date(), row.price(), row.brand(), row.country()));
}
if (!deleteMode) {
baseParsed.add(new ShopParsed(shopName, "", countryCodes, normalized));
}
DuplicateCheckAggregator.Aggregate aggregate =
DuplicateCheckAggregator.aggregate(baseParsed, "job");
ShopDataDuplicateScanEntity row = new ShopDataDuplicateScanEntity();
row.setStatus("SUCCESS");
row.setFinishedAt(LocalDateTime.now());
try {
row.setSummaryJson(objectMapper.writeValueAsString(aggregate.summary()));
row.setPayloadJson(objectMapper.writeValueAsString(aggregate.payload()));
} catch (Exception ex) {
throw new BusinessException(500, "导入结果序列化失败", ex);
}
scanMapper.insert(row);
log.info("[shop-duplicate-check-import] 导入完成 mode={} shop={} 解析行数={} 聚合ASIN数={} 落库行 id={}",
deleteMode ? "DELETE" : "ADD", shopName, normalized.size(),
aggregate.summary().asinTotal(), row.getId());
return new ImportSummary(normalized.size(), aggregate.summary().asinTotal(),
aggregate.summary().shopCount(), aggregate.summary().recordTotal());
}
private List<String> normalizeCountryCodes(String raw) {
List<String> codes = new ArrayList<>();
if (raw == null || raw.isBlank()) {
return codes;
}
for (String part : raw.split("[,;\\s]+")) {
String code = part.trim().toUpperCase(Locale.ROOT);
if (!code.isEmpty()) {
codes.add(code);
}
}
return codes;
}
}
@@ -4647,6 +4647,9 @@
.drawer-mask { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(45, 66, 86, 0.36); backdrop-filter: blur(4px); }
.drawer-mask.show { display: block; animation: modal-fade-in 0.16s ease; }
.drawer { position: absolute; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(460px, 100%); background: #ffffff; border-left: 1px solid #c7d7e5; box-shadow: var(--shadow-pop); animation: drawer-slide-in 0.18s ease; }
/* 撞款明细抽屉:无遮罩图层(底层仍可点击) + 加宽 + 从撞款详情块位置开始 */
.dup-drawer-mask { background: none; backdrop-filter: none; pointer-events: none; z-index: 990; }
.dup-drawer-mask .drawer { pointer-events: auto; top: 24px; right: 24px; bottom: 24px; width: min(760px, calc(100% - 48px)); max-width: 860px; border-radius: 12px; border: 1px solid #dbe4ec; box-shadow: 0 14px 44px rgba(15, 42, 65, 0.22); }
@keyframes drawer-slide-in { from { transform: translateX(26px); opacity: 0.4; } to { transform: none; opacity: 1; } }
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 22px 14px; border-bottom: 1px solid #e6edf4; }
.drawer-header h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--c-text); }
@@ -4858,9 +4861,60 @@
<span class="admin-brand-sub">电商运营管理后台</span>
</span>
</div>
<nav class="admin-menu" id="adminMenu" aria-label="管理菜单">
<nav class="admin-menu" id="adminMenu" aria-label="管理菜单"{% if admin_menu_rendered %} data-server-rendered="true"{% endif %}>
{% if admin_menu_rendered %}
{# 服务端按当前用户权限直接渲染菜单,避免客户端二次渲染造成"全量菜单→折叠"的闪烁 #}
{% set __adm_chevron %}<span class="menu-group-chevron" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"></path></svg></span>{% endset %}
{% set __adm_icons = {
'users': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>',
'group-manage': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M22 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg>',
'columns': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="7" height="7" x="3" y="3" rx="1"></rect><rect width="7" height="7" x="14" y="3" rx="1"></rect><rect width="7" height="7" x="14" y="14" rx="1"></rect><rect width="7" height="7" x="3" y="14" rx="1"></rect></svg>',
'dedupe-total-data': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"></path><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"></path><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"></path></svg>',
'invalid-asin-data': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>',
'shop-keys': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"></path><path d="m21 2-9.6 9.6"></path><circle cx="7.5" cy="15.5" r="5.5"></circle></svg>',
'shop-manage': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m2 7 4.41-4.41A2 2 0 0 1 7.83 2h8.34a2 2 0 0 1 1.42.59L22 7"></path><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><path d="M15 22v-4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v4"></path><path d="M2 7h20"></path><path d="M22 7v3a2 2 0 0 1-2 2 2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 16 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 12 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 8 12a2.7 2.7 0 0 1-1.59-.63.7.7 0 0 0-.82 0A2.7 2.7 0 0 1 4 12a2 2 0 0 1-2-2V7"></path></svg>',
'skip-price-asin': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="m4.9 4.9 14.2 14.2"></path></svg>',
'query-asin': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path></svg>',
'product-categories': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path></svg>',
'image-video-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 8-6 4 6 4V8Z"></path><rect width="14" height="12" x="2" y="6" rx="2" ry="2"></rect></svg>',
'shop-data-crawl-tasks': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M3 5v14a9 3 0 0 0 18 0V5"></path><path d="M3 12a9 3 0 0 0 18 0"></path></svg>',
'shop-data-duplicate-check': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.35 11.1h-9.17a2 2 0 0 1-1.75-2.98l1.67-2.79a2 2 0 0 0-1.75-2.98l-5.98-.01a2 2 0 0 0-2 2v1.5a2 2 0 0 0 2 2h3.36l-2.38 3.97a2 2 0 0 0 1.75 2.98h11.5a2 2 0 0 0 2-2v-1.5a2 2 0 0 0-2-2Z"></path><path d="M3 21h18"></path></svg>',
'history': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M12 7v5l4 2"></path></svg>',
'version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7.5 4.27 9 5.15"></path><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"></path><path d="M3.3 7 12 12l8.7-5"></path><path d="M12 22V12"></path></svg>',
'digital-human-version': '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 8V4H8"></path><rect width="16" height="12" x="4" y="8" rx="2"></rect><path d="M2 14h2"></path><path d="M20 14h2"></path><path d="M15 13v2"></path><path d="M9 13v2"></path></svg>',
} %}
{% set __adm_fallback_icon %}<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"></circle><path d="M12 8v8"></path><path d="M8 12h8"></path></svg>{% endset %}
{% set __adm_groups = [
('account', '账号与权限', ['users', 'columns', 'group-manage']),
('data', '数据管理', ['dedupe-total-data', 'invalid-asin-data', 'query-asin', 'product-categories']),
('shop', '店铺管理', ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks', 'shop-data-duplicate-check']),
('record', '记录与版本', ['history', 'version', 'digital-human-version', 'image-video-tasks']),
] %}
{% set __adm_allowed = admin_menu_items | map(attribute='route_path') | list %}
{% set __adm_has = namespace(v=False) %}
{% for __gk, __gt, __routes in __adm_groups %}
{% set __visible_routes = __routes | select('in', __adm_allowed) | list %}
{% if __visible_routes %}
{% set __adm_has.v = True %}
<div class="menu-group" data-menu-group="{{ __gk }}">
<button class="menu-group-title" type="button" aria-expanded="true">{{ __gt }}{{ __adm_chevron | safe }}</button>
<div class="menu-group-body">
{% for __r in __visible_routes %}
{% set __it = admin_menu_items | selectattr('route_path', 'equalto', __r) | first %}
{% set __label = ((__it.get('name') if __it and __it.get('name') else __r) | e) %}
<button class="tab" type="button" data-tab="{{ __r }}" aria-controls="panel-{{ __r }}" title="{{ __label }}"><span class="adm-icon">{{ __adm_icons.get(__r, __adm_fallback_icon) | safe }}</span><span class="adm-label">{{ __label }}</span></button>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
{% if not __adm_has.v %}
<div class="menu-empty">暂无可用菜单</div>
{% endif %}
{% else %}
{# 服务端不可用时的轻量占位:JS 加载后走接口渲染,避免闪出全量菜单 #}
<div class="menu-empty">菜单加载中...</div>
{% endif %}
</nav>
<div class="admin-sidebar-foot">数富AI · 管理控制台</div>
</aside>
@@ -5622,6 +5676,8 @@
<button class="btn shop-data-refresh-btn" id="btnRefreshShopDataDuplicates"
type="button">重新分析</button>
<button class="btn btn-secondary" id="btnExportShopDataDuplicates" type="button">导出</button>
<button class="btn btn-secondary" id="btnOpenDupCheckImportAdd" type="button">导入(新增-测试)</button>
<button class="btn btn-secondary" id="btnOpenDupCheckImportDelete" type="button">导入(删除-测试)</button>
<span class="image-video-download-progress" id="shopDataDuplicateProgress"
aria-live="polite"></span>
</div>
@@ -5638,7 +5694,7 @@
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
</div>
<div class="drawer-mask" id="dupCheckDrawerMask">
<div class="drawer-mask dup-drawer-mask" id="dupCheckDrawerMask" data-drawer-anchor="dupCheckDetailBlock">
<aside class="drawer" role="dialog" aria-modal="true" aria-label="ASIN 详情">
<div class="drawer-header">
<div>
@@ -5651,6 +5707,30 @@
<div class="drawer-body" id="dupCheckDrawerBody"></div>
</aside>
</div>
<!-- 撞款:导入(新增-测试)/导入(删除-测试)弹窗 -->
<div class="modal-mask" id="dupCheckImportModal">
<div class="modal">
<h3 id="dupCheckImportTitle">导入(新增-测试)</h3>
<div class="form-group">
<label>店铺名称(新增=以导入文件替换该店记录;删除=从基线移除该店)</label>
<input type="text" id="dupCheckImportShopName" placeholder="如:小曾">
</div>
<div class="form-group">
<label>国家代码(可选,逗号分隔,如 UK,DE。缺失时按 Excel 的 sheet 名推断)</label>
<input type="text" id="dupCheckImportCountryCodes" placeholder="如:UK,DE">
</div>
<div class="form-group">
<label>上传 Excel 文件(读取 ASIN 列,可含 日期/价格/品牌 列)</label>
<input type="file" id="dupCheckImportFile" accept=".xlsx,.xls">
</div>
<p class="msg" id="msgDupCheckImport"></p>
<div style="margin-top:16px;display:flex;gap:8px;">
<button class="btn" id="btnDupCheckImportSubmit">上传并导入</button>
<button class="btn btn-secondary" id="btnCloseDupCheckImportModal" type="button">取消</button>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -49,83 +49,6 @@
}
}
// Java 统一响应体 ApiResponse{success,message,data,code} 转成旧前端形状:
// 成功 -> {success:true, ...data};失败 -> error=messagecode 401 跳登录页。
function normalizeJavaResponseBody(body) {
if (!body || typeof body !== 'object') return body;
if (body.success === false) {
if (!('error' in body) && body.message) body.error = body.message;
if (body.code === 401 && window.location.pathname.indexOf('/login') !== 0) {
var now = Date.now();
if (!normalizeJavaResponseBody.__last401 || now - normalizeJavaResponseBody.__last401 > 1500) {
normalizeJavaResponseBody.__last401 = now;
window.location.replace('/login');
}
}
return body;
}
if (body.success === true && body.data && typeof body.data === 'object') {
// 保留 data 键兼容 res.data.xxx 读取,同时把对象 data 展开到顶层兼容 res.xxx
// 数组 data 原样保留在 res.data(不展开)。
var merged = {};
Object.keys(body).forEach(function (k) { merged[k] = body[k]; });
if (!Array.isArray(body.data)) {
Object.keys(body.data).forEach(function (k) { merged[k] = body.data[k]; });
}
return merged;
}
return body;
}
// 后台历史 URL 兼容映射:把 Flask 时代的别名/单复数字段统一到 Java 规范路径,
// GET 查询参数原样透传。命中规则外的 /api/admin 路径保持不动。
function adminApiUrlRewrite(input) {
if (typeof input !== 'string') return input;
if (input.indexOf('/api/admin') !== 0) return input;
var qIndex = input.indexOf('?');
var path = qIndex >= 0 ? input.slice(0, qIndex) : input;
var suffix = qIndex >= 0 ? input.slice(qIndex) : '';
var rest = path.slice('/api/admin'.length);
var out = null;
function map(root, tail) { out = '/api/admin' + root + tail; }
if (rest === '/column/reorder') {
// 已在 Java 原生提供
} else if (rest === '/columns' || rest === '/column') {
map('/permission-menus', '');
} else if (rest.indexOf('/column/') === 0) {
map('/permission-menus', rest.slice('/column'.length));
} else if (/^\/user\/[^/]+\/columns$/.test(rest)) {
map('/permission-users', rest.slice('/user'.length));
} else if (/^\/user\/[^/]+\/column-permissions$/.test(rest)) {
map('/permission-users', rest.slice('/user'.length));
} else if (rest.indexOf('/digital-human-versions') === 0) {
out = '/api/digital-human/versions' + rest.slice('/digital-human-versions'.length);
} else if (rest.indexOf('/shop-manage-groups') === 0) {
map('/shop-manages/groups', rest.slice('/shop-manage-groups'.length));
} else if (rest.indexOf('/shop-manage-group') === 0) {
map('/shop-manages/groups', rest.slice('/shop-manage-group'.length));
} else if (rest === '/shop-data-crawl-tasks'
|| rest.indexOf('/shop-data-crawl-tasks/download-zip') === 0) {
// Java 已同路径提供
} else if (/^\/shop-data-crawl-tasks\/[^/]+\/download$/.test(rest)) {
map('/shop-data-crawl/results', rest.slice('/shop-data-crawl-tasks'.length));
} else if (/^\/shop-data-crawl-tasks\/[^/]+$/.test(rest)) {
map('/shop-data-crawl/history', rest.slice('/shop-data-crawl-tasks'.length));
} else if (rest === '/shop-key' || rest.indexOf('/shop-key/') === 0) {
map('/shop-keys', rest.slice('/shop-key'.length));
} else if (rest === '/shop-manage' || rest.indexOf('/shop-manage/') === 0) {
map('/shop-manages', rest.slice('/shop-manage'.length));
} else if (/^\/skip-price-asin($|\/)/.test(rest)) {
map('/skip-price-asins', rest.slice('/skip-price-asin'.length).replace('/country/', '/countries/'));
} else if (/^\/query-asin($|\/)/.test(rest)) {
map('/query-asins', rest.slice('/query-asin'.length).replace('/country/', '/countries/'));
} else {
return input;
}
if (out === null) return input;
return out + suffix;
}
if (window.fetch) {
var nativeFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
@@ -138,18 +61,7 @@
beginRequest();
}
try {
var urlInput = (typeof input === 'string') ? adminApiUrlRewrite(input) : input;
var pending = nativeFetch(urlInput, options).then(function (resp) {
if (!resp || !resp.headers) return resp;
var contentType = String(resp.headers.get('content-type') || '');
if (contentType.indexOf('json') < 0) return resp;
var nativeJson = resp.json.bind(resp);
resp.json = function () {
return nativeJson().then(normalizeJavaResponseBody);
};
return resp;
});
return pending.finally(function () {
return nativeFetch(input, options).finally(function () {
if (!skipLoading) endRequest();
});
} catch (err) {
@@ -163,12 +75,8 @@
var nativeOpen = XMLHttpRequest.prototype.open;
var nativeSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function () {
var args = Array.prototype.slice.call(arguments);
if (typeof args[1] === 'string' && args[1].indexOf('/api/admin') === 0) {
args[1] = adminApiUrlRewrite(args[1]);
}
this.__adminSkipLoading = false;
return nativeOpen.apply(this, args);
return nativeOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function () {
if (!this.__adminSkipLoading) {
@@ -2288,8 +2196,61 @@
return '<span class="dup-drawer-badge' + (cls ? ' ' + cls : '') + '">' + escapeHtml(text) + '</span>';
}
// 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间序明细 + 次数列
// 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间序明细 + 次数列
// 块内定位:抽屉贴到「撞款详情」区块顶部(anchorRect),而不是浏览器左侧顶部;
// 区块滚出视口时钳制到视口内可见区域(顶部矩阵点 ASIN 打开也要能看到抽屉)
function positionDupDrawer() {
var mask = document.getElementById('dupCheckDrawerMask');
var aside = mask ? mask.querySelector('.drawer') : null;
var anchor = document.getElementById('dupCheckDetailBlock');
if (!aside || !anchor) return;
var rect = anchor.getBoundingClientRect();
var bodyHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
var padTop = 24, padRight = 24, padBottom = 24;
var maxHeight = Math.max(260, bodyHeight - padTop - padBottom);
var left = Math.max(12, Math.min(rect.left + 12, Math.max(12, bodyWidth() - 860)));
var top = Math.max(padTop, rect.top - padTop);
// 区块整体在视口外(下方/上方)时回退到视口顶部,避免抽屉出现在视口外
if (rect.top >= bodyHeight - 120 || rect.bottom <= padTop) top = padTop;
// 顶部再钳制一次,保证抽屉头部可见
top = Math.min(top, Math.max(padTop, bodyHeight - 260));
aside.style.left = left + 'px';
aside.style.top = top + 'px';
aside.style.bottom = 'auto';
aside.style.maxHeight = (maxHeight - (top - padTop)) + 'px';
var body = document.getElementById('dupCheckDrawerBody');
if (body) body.style.maxHeight = (maxHeight - (top - padTop) - 96) + 'px';
}
function bodyWidth() {
var w = document.documentElement.clientWidth || window.innerWidth;
return w;
}
// 上架时间降序排列(晚到早;同时间再按店铺名稳定排序)
function dupTimeDesc(a, b) {
var ta = a._dupTime || '', tb = b._dupTime || '';
if (ta !== tb) {
if (ta === '') return 1;
if (tb === '') return -1;
return ta < tb ? 1 : -1;
}
var sa = (a.shop_name || '').toLowerCase(), sb = (b.shop_name || '').toLowerCase();
return sa < sb ? -1 : (sa > sb ? 1 : 0);
}
// 打开前把 mask 挂到 body 下。.tab-panel.active 的入场动画带 transform
// 会成为 fixed 定位的包含块,导致 mask/drawer 定位错误(未移出时 top 会算到面板外面)
function mountDupDrawerMaskToBody() {
var mask = document.getElementById('dupCheckDrawerMask');
if (mask && mask.parentElement !== document.body) {
document.body.appendChild(mask);
}
return mask;
}
function openShopDataDuplicateDrawer(asin) {
mountDupDrawerMaskToBody();
var item = null;
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
if (!item) {
@@ -2298,19 +2259,15 @@
if (!item) return;
var occurrences = (item.occurrences || []).slice();
var brand = item.brand || '';
// 上架时间归一化并升序排列(按时间正序展示
// 上架时间归一化并按时间降序排列(晚到早
occurrences.forEach(function (occ) { occ._dupTime = normalizeDuplicateTime(occ.date); });
occurrences.sort(function (a, b) {
var ta = a._dupTime || '', tb = b._dupTime || '';
if (ta === tb) return 0;
return ta < tb ? -1 : 1;
});
var sites = {}, firstDate = '';
occurrences.sort(dupTimeDesc);
var sites = {}, lastDate = '';
occurrences.forEach(function (occ) {
if (occ.country) sites[occ.country] = true;
if (!brand && occ.brand) brand = occ.brand;
var t = occ._dupTime;
if (t && (!firstDate || t < firstDate)) firstDate = t;
if (t && t > lastDate) lastDate = t;
});
// 店铺 + 国家两级聚合:次数 = 该店铺在该国家的上架记录数
var countsMap = {};
@@ -2345,7 +2302,7 @@
return dupDrawerBadge(dupSiteLabel(site), 'dup-site-' + site.toUpperCase());
}).join('') +
dupDrawerBadge(recordCount + ' 次上架', 'dup-drawer-badge-green') +
(firstDate ? dupDrawerBadge('最上架 ' + firstDate.slice(0, 16), 'dup-drawer-badge-cyan') : '');
(lastDate ? dupDrawerBadge('最上架 ' + lastDate.slice(0, 16), 'dup-drawer-badge-cyan') : '');
document.getElementById('dupCheckDrawerAsin').textContent = item.asin;
document.getElementById('dupCheckDrawerAsin').className = 'dup-drawer-asin';
var subtitleEl = document.getElementById('dupCheckDrawerSubtitle');
@@ -2354,9 +2311,10 @@
document.getElementById('dupCheckDrawerBody').innerHTML =
'<div class="dup-drawer-brand-line">品牌:' + escapeHtml(brand || '-') + '</div>' +
'<div class="table-scroll"><table class="dup-check-drawer-table">' +
'<thead><tr><th>店铺</th><th>国家</th><th>上架时间(按时间升序)</th><th>次数</th></tr></thead>' +
'<thead><tr><th>店铺</th><th>国家</th><th>上架时间</th><th>次数</th></tr></thead>' +
'<tbody>' + (rows || '<tr><td colspan="4" class="shop-data-empty-hint">暂无明细</td></tr>') + '</tbody></table></div>';
document.getElementById('dupCheckDrawerMask').classList.add('show');
positionDupDrawer();
}
function closeShopDataDuplicateDrawer() {
@@ -2694,6 +2652,92 @@
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
if (event.target === this) closeShopDataDuplicateDrawer();
};
// ========== 撞款导入(新增-测试 / 删除-测试)==========
var dupCheckImportDeleteMode = false;
function openDupCheckImport(deleteMode) {
dupCheckImportDeleteMode = !!deleteMode;
document.getElementById('dupCheckImportTitle').textContent = deleteMode ? '导入(删除-测试)' : '导入(新增-测试)';
document.getElementById('dupCheckImportShopName').value = '';
document.getElementById('dupCheckImportCountryCodes').value = '';
document.getElementById('dupCheckImportFile').value = '';
var msg = document.getElementById('msgDupCheckImport');
msg.textContent = '';
msg.className = 'msg';
document.getElementById('dupCheckImportModal').classList.add('show');
}
function closeDupCheckImportModal() {
document.getElementById('dupCheckImportModal').classList.remove('show');
}
function submitDupCheckImport() {
var shopName = document.getElementById('dupCheckImportShopName').value.trim();
var fileInput = document.getElementById('dupCheckImportFile');
var msgEl = document.getElementById('msgDupCheckImport');
var submitBtn = document.getElementById('btnDupCheckImportSubmit');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!shopName) {
msgEl.textContent = '请填写店铺名称';
msgEl.className = 'msg err';
return;
}
if (!fileInput.files || fileInput.files.length === 0) {
msgEl.textContent = '请选择 Excel 文件';
msgEl.className = 'msg err';
return;
}
var file = fileInput.files[0];
var lowerName = (file.name || '').toLowerCase();
if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) {
msgEl.textContent = '仅支持 .xlsx 或 .xls 文件';
msgEl.className = 'msg err';
return;
}
var formData = new FormData();
formData.append('file', file);
formData.append('mode', dupCheckImportDeleteMode ? 'delete' : 'add');
formData.append('shop_name', shopName);
var codes = document.getElementById('dupCheckImportCountryCodes').value.trim();
if (codes) formData.append('country_codes', codes);
submitBtn.disabled = true;
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/admin/shop-data-crawl/duplicate-check-import', true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
submitBtn.disabled = false;
if (xhr.status < 200 || xhr.status >= 300) {
msgEl.textContent = '请求失败:HTTP ' + xhr.status;
msgEl.className = 'msg err';
return;
}
var res;
try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, message: '返回格式错误' }; }
if (!res.success) {
msgEl.textContent = res.message || res.error || '导入失败';
msgEl.className = 'msg err';
return;
}
var data = res.data || {};
msgEl.textContent = (data.message || (dupCheckImportDeleteMode ? '删除' : '新增') + '导入成功')
+ ':解析 ' + (data.total_rows != null ? data.total_rows : '?') + ' 行';
msgEl.className = 'msg ok';
fileInput.value = '';
// 导入落库新扫描行,重新加载视图让页面立即反映
loadShopDataDuplicateCheckOverview(false);
};
xhr.onerror = function () {
submitBtn.disabled = false;
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
};
xhr.send(formData);
}
document.getElementById('btnOpenDupCheckImportAdd').onclick = function () { openDupCheckImport(false); };
document.getElementById('btnOpenDupCheckImportDelete').onclick = function () { openDupCheckImport(true); };
document.getElementById('btnCloseDupCheckImportModal').onclick = closeDupCheckImportModal;
document.getElementById('btnDupCheckImportSubmit').onclick = submitDupCheckImport;
document.getElementById('dupCheckImportModal').onclick = function (event) {
if (event.target === this) closeDupCheckImportModal();
};
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
document.getElementById('shopDataDuplicateList').onclick = function (event) {
var target = event.target.closest('[data-open-drawer]');
@@ -6238,11 +6282,10 @@
var pagination = document.getElementById('versionPagination');
if (pagination) pagination.innerHTML = '';
if (!res.success) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-tip">加载失败: ' + (res.error || res.message || '') + '</td></tr>';
tbody.innerHTML = '<tr><td colspan="4" class="empty-tip">加载失败: ' + (res.error || '') + '</td></tr>';
return;
}
var data = res.data || {};
var items = Array.isArray(data) ? data : (data.items || []);
var items = res.items || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="4" class="empty-tip">暂无版本记录</td></tr>';
return;
@@ -6432,8 +6475,7 @@
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.success) {
var data = res.data || {};
msgEl.textContent = '发布成功。版本:' + (data.version || '') + ',链接:' + (data.file_url || '');
msgEl.textContent = '发布成功。版本:' + res.version + ',链接:' + (res.file_url || '');
msgEl.classList.add('ok');
document.getElementById('versionNumber').value = '';
fileInput.value = '';
@@ -7126,14 +7168,27 @@
}
document.getElementById('btnAdminLogout').onclick = function () {
// Java auth 模块 POST /logout 清 cookie;无论成败均回登录页(GET /login 由页面控制器转发到 login.html)。
fetch('/logout', {
fetch('/api/admin/logout', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.catch(function () { /* 忽略网络错误,仍跳登录页 */ })
.finally(function () {
window.location.replace('/login?logout=1');
.then(function (r) {
if (r.status === 404) {
window.location.href = '/logout';
return null;
}
return r.json();
})
.then(function (res) {
if (!res) return;
if (!res.success) {
alert(res.error || '退出失败');
return;
}
window.location.replace(res.redirect || '/login?logout=1');
})
.catch(function () {
window.location.replace('/logout');
});
};
@@ -0,0 +1,126 @@
package com.nanri.aiimage.modules.shopduplicatecheck.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDataDuplicateScanMapper;
import com.nanri.aiimage.modules.shopduplicatecheck.model.entity.ShopDataDuplicateScanEntity;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateItem;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateOccurrence;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateScanPayload;
import com.nanri.aiimage.modules.shopduplicatecheck.model.payload.DuplicateShop;
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService.DuplicateScanView;
import com.nanri.aiimage.modules.shopduplicatecheck.service.support.DuplicateCheckWorkbookParser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 撞款导入(新增-测试/删除-测试)单测:Excel 解析、ADDR 替换、DELETE 移除、基线缺失报错。
*/
class ShopDataDuplicateCheckImportServiceTest {
private ShopDataDuplicateCheckScanService scanService;
private ShopDataDuplicateScanMapper scanMapper;
private ShopDataDuplicateCheckImportService service;
@BeforeEach
void setUp() {
scanService = mock(ShopDataDuplicateCheckScanService.class);
scanMapper = mock(ShopDataDuplicateScanMapper.class);
DistributedJobLockService lockService = mock(DistributedJobLockService.class);
DistributedJobLockService.LockHandle lock = mock(DistributedJobLockService.LockHandle.class);
when(lockService.tryLock(anyString(), any())).thenReturn(lock);
service = new ShopDataDuplicateCheckImportService(
scanService, scanMapper, lockService, new ObjectMapper());
}
private DuplicateScanView baseView() {
DuplicateOccurrence occA = new DuplicateOccurrence("B09PNTZM4L", "2026-08-18 04:34", "12.3", "HANYTON", "小曾", "G1", List.of("UK"), "UK");
DuplicateOccurrence occB = new DuplicateOccurrence("B09PNTZM4L", "2026-08-19 05:48", "10.1", "HANYTON", "郭悦晴", "G2", List.of("DE"), "DE");
DuplicateShop shopA = new DuplicateShop("小曾", "G1", List.of("UK"), 1, 1);
DuplicateShop shopB = new DuplicateShop("郭悦晴", "G2", List.of("DE"), 1, 1);
DuplicateItem item = new DuplicateItem("B09PNTZM4L", 2, 2, List.of(occA, occB));
DuplicateScanPayload payload = new DuplicateScanPayload(List.of(shopA, shopB), List.of(item));
return new DuplicateScanView("2026-09-04 00:00:00", null, payload);
}
@Test
void addImportReplacesShopRowsAndReaggregates() throws Exception {
when(scanService.loadLatest()).thenReturn(baseView());
when(scanMapper.insert(any(ShopDataDuplicateScanEntity.class))).thenAnswer(invocation -> {
invocation.<ShopDataDuplicateScanEntity>getArgument(0).setId(99L);
return 1;
});
ShopDataDuplicateCheckImportService.ImportSummary summary =
service.importExcel("小曾", "UK,FR", workbook(), false);
assertThat(summary.totalRows()).isEqualTo(2);
assertThat(summary.shopCount()).isEqualTo(2);
assertThat(summary.recordCount()).isEqualTo(3);
verify(scanMapper).insert(any(ShopDataDuplicateScanEntity.class));
}
@Test
void deleteImportRemovesShopRows() throws Exception {
when(scanService.loadLatest()).thenReturn(baseView());
when(scanMapper.insert(any(ShopDataDuplicateScanEntity.class))).thenAnswer(invocation -> {
invocation.<ShopDataDuplicateScanEntity>getArgument(0).setId(99L);
return 1;
});
ShopDataDuplicateCheckImportService.ImportSummary summary =
service.importExcel("小曾", "UK", workbook(), true);
assertThat(summary.shopCount()).isEqualTo(1);
assertThat(summary.recordCount()).isEqualTo(1);
verify(scanMapper).insert(any(ShopDataDuplicateScanEntity.class));
}
@Test
void importWithoutBaselineFails() {
when(scanService.loadLatest()).thenReturn(null);
assertThatThrownBy(() -> service.importExcel("小曾", "UK", workbook(), false))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("暂无扫描结果");
verify(scanMapper, never()).insert(any(ShopDataDuplicateScanEntity.class));
}
/** 构造含 ASIN/日期两行的最小 xlsx。 */
private byte[] workbook() throws Exception {
var wb = new org.apache.poi.xssf.usermodel.XSSFWorkbook();
var sheet = wb.createSheet("英国");
var header = sheet.createRow(0);
header.createCell(0).setCellValue("ASIN");
header.createCell(1).setCellValue("日期");
header.createCell(2).setCellValue("价格");
header.createCell(3).setCellValue("品牌");
var r1 = sheet.createRow(1);
r1.createCell(0).setCellValue("B09PNTZM4L");
r1.createCell(1).setCellValue("2026-09-02 10:00");
r1.createCell(2).setCellValue("15.5");
r1.createCell(3).setCellValue("HANYTON");
var r2 = sheet.createRow(2);
r2.createCell(0).setCellValue("B0TEST1234");
r2.createCell(1).setCellValue("2026-09-03 11:30");
r2.createCell(2).setCellValue("9.9");
r2.createCell(3).setCellValue("OTHER");
try (var out = new java.io.ByteArrayOutputStream()) {
wb.write(out);
return out.toByteArray();
}
}
}
+147 -11
View File
@@ -2196,8 +2196,61 @@
return '<span class="dup-drawer-badge' + (cls ? ' ' + cls : '') + '">' + escapeHtml(text) + '</span>';
}
// 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间序明细 + 次数列
// 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间序明细 + 次数列
// 块内定位:抽屉贴到「撞款详情」区块顶部(anchorRect),而不是浏览器左侧顶部;
// 区块滚出视口时钳制到视口内可见区域(顶部矩阵点 ASIN 打开也要能看到抽屉)
function positionDupDrawer() {
var mask = document.getElementById('dupCheckDrawerMask');
var aside = mask ? mask.querySelector('.drawer') : null;
var anchor = document.getElementById('dupCheckDetailBlock');
if (!aside || !anchor) return;
var rect = anchor.getBoundingClientRect();
var bodyHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
var padTop = 24, padRight = 24, padBottom = 24;
var maxHeight = Math.max(260, bodyHeight - padTop - padBottom);
var left = Math.max(12, Math.min(rect.left + 12, Math.max(12, bodyWidth() - 860)));
var top = Math.max(padTop, rect.top - padTop);
// 区块整体在视口外(下方/上方)时回退到视口顶部,避免抽屉出现在视口外
if (rect.top >= bodyHeight - 120 || rect.bottom <= padTop) top = padTop;
// 顶部再钳制一次,保证抽屉头部可见
top = Math.min(top, Math.max(padTop, bodyHeight - 260));
aside.style.left = left + 'px';
aside.style.top = top + 'px';
aside.style.bottom = 'auto';
aside.style.maxHeight = (maxHeight - (top - padTop)) + 'px';
var body = document.getElementById('dupCheckDrawerBody');
if (body) body.style.maxHeight = (maxHeight - (top - padTop) - 96) + 'px';
}
function bodyWidth() {
var w = document.documentElement.clientWidth || window.innerWidth;
return w;
}
// 上架时间降序排列(晚到早;同时间再按店铺名稳定排序)
function dupTimeDesc(a, b) {
var ta = a._dupTime || '', tb = b._dupTime || '';
if (ta !== tb) {
if (ta === '') return 1;
if (tb === '') return -1;
return ta < tb ? 1 : -1;
}
var sa = (a.shop_name || '').toLowerCase(), sb = (b.shop_name || '').toLowerCase();
return sa < sb ? -1 : (sa > sb ? 1 : 0);
}
// 打开前把 mask 挂到 body 下。.tab-panel.active 的入场动画带 transform
// 会成为 fixed 定位的包含块,导致 mask/drawer 定位错误(未移出时 top 会算到面板外面)
function mountDupDrawerMaskToBody() {
var mask = document.getElementById('dupCheckDrawerMask');
if (mask && mask.parentElement !== document.body) {
document.body.appendChild(mask);
}
return mask;
}
function openShopDataDuplicateDrawer(asin) {
mountDupDrawerMaskToBody();
var item = null;
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
if (!item) {
@@ -2206,19 +2259,15 @@
if (!item) return;
var occurrences = (item.occurrences || []).slice();
var brand = item.brand || '';
// 上架时间归一化并升序排列(按时间正序展示
// 上架时间归一化并按时间降序排列(晚到早
occurrences.forEach(function (occ) { occ._dupTime = normalizeDuplicateTime(occ.date); });
occurrences.sort(function (a, b) {
var ta = a._dupTime || '', tb = b._dupTime || '';
if (ta === tb) return 0;
return ta < tb ? -1 : 1;
});
var sites = {}, firstDate = '';
occurrences.sort(dupTimeDesc);
var sites = {}, lastDate = '';
occurrences.forEach(function (occ) {
if (occ.country) sites[occ.country] = true;
if (!brand && occ.brand) brand = occ.brand;
var t = occ._dupTime;
if (t && (!firstDate || t < firstDate)) firstDate = t;
if (t && t > lastDate) lastDate = t;
});
// 店铺 + 国家两级聚合:次数 = 该店铺在该国家的上架记录数
var countsMap = {};
@@ -2253,7 +2302,7 @@
return dupDrawerBadge(dupSiteLabel(site), 'dup-site-' + site.toUpperCase());
}).join('') +
dupDrawerBadge(recordCount + ' 次上架', 'dup-drawer-badge-green') +
(firstDate ? dupDrawerBadge('最上架 ' + firstDate.slice(0, 16), 'dup-drawer-badge-cyan') : '');
(lastDate ? dupDrawerBadge('最上架 ' + lastDate.slice(0, 16), 'dup-drawer-badge-cyan') : '');
document.getElementById('dupCheckDrawerAsin').textContent = item.asin;
document.getElementById('dupCheckDrawerAsin').className = 'dup-drawer-asin';
var subtitleEl = document.getElementById('dupCheckDrawerSubtitle');
@@ -2262,9 +2311,10 @@
document.getElementById('dupCheckDrawerBody').innerHTML =
'<div class="dup-drawer-brand-line">品牌:' + escapeHtml(brand || '-') + '</div>' +
'<div class="table-scroll"><table class="dup-check-drawer-table">' +
'<thead><tr><th>店铺</th><th>国家</th><th>上架时间(按时间升序)</th><th>次数</th></tr></thead>' +
'<thead><tr><th>店铺</th><th>国家</th><th>上架时间</th><th>次数</th></tr></thead>' +
'<tbody>' + (rows || '<tr><td colspan="4" class="shop-data-empty-hint">暂无明细</td></tr>') + '</tbody></table></div>';
document.getElementById('dupCheckDrawerMask').classList.add('show');
positionDupDrawer();
}
function closeShopDataDuplicateDrawer() {
@@ -2602,6 +2652,92 @@
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
if (event.target === this) closeShopDataDuplicateDrawer();
};
// ========== 撞款导入(新增-测试 / 删除-测试)==========
var dupCheckImportDeleteMode = false;
function openDupCheckImport(deleteMode) {
dupCheckImportDeleteMode = !!deleteMode;
document.getElementById('dupCheckImportTitle').textContent = deleteMode ? '导入(删除-测试)' : '导入(新增-测试)';
document.getElementById('dupCheckImportShopName').value = '';
document.getElementById('dupCheckImportCountryCodes').value = '';
document.getElementById('dupCheckImportFile').value = '';
var msg = document.getElementById('msgDupCheckImport');
msg.textContent = '';
msg.className = 'msg';
document.getElementById('dupCheckImportModal').classList.add('show');
}
function closeDupCheckImportModal() {
document.getElementById('dupCheckImportModal').classList.remove('show');
}
function submitDupCheckImport() {
var shopName = document.getElementById('dupCheckImportShopName').value.trim();
var fileInput = document.getElementById('dupCheckImportFile');
var msgEl = document.getElementById('msgDupCheckImport');
var submitBtn = document.getElementById('btnDupCheckImportSubmit');
msgEl.textContent = '';
msgEl.className = 'msg';
if (!shopName) {
msgEl.textContent = '请填写店铺名称';
msgEl.className = 'msg err';
return;
}
if (!fileInput.files || fileInput.files.length === 0) {
msgEl.textContent = '请选择 Excel 文件';
msgEl.className = 'msg err';
return;
}
var file = fileInput.files[0];
var lowerName = (file.name || '').toLowerCase();
if (!(lowerName.endsWith('.xlsx') || lowerName.endsWith('.xls'))) {
msgEl.textContent = '仅支持 .xlsx 或 .xls 文件';
msgEl.className = 'msg err';
return;
}
var formData = new FormData();
formData.append('file', file);
formData.append('mode', dupCheckImportDeleteMode ? 'delete' : 'add');
formData.append('shop_name', shopName);
var codes = document.getElementById('dupCheckImportCountryCodes').value.trim();
if (codes) formData.append('country_codes', codes);
submitBtn.disabled = true;
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/admin/shop-data-crawl/duplicate-check-import', true);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
submitBtn.disabled = false;
if (xhr.status < 200 || xhr.status >= 300) {
msgEl.textContent = '请求失败:HTTP ' + xhr.status;
msgEl.className = 'msg err';
return;
}
var res;
try { res = JSON.parse(xhr.responseText || '{}'); } catch (e) { res = { success: false, message: '返回格式错误' }; }
if (!res.success) {
msgEl.textContent = res.message || res.error || '导入失败';
msgEl.className = 'msg err';
return;
}
var data = res.data || {};
msgEl.textContent = (data.message || (dupCheckImportDeleteMode ? '删除' : '新增') + '导入成功')
+ ':解析 ' + (data.total_rows != null ? data.total_rows : '?') + ' 行';
msgEl.className = 'msg ok';
fileInput.value = '';
// 导入落库新扫描行,重新加载视图让页面立即反映
loadShopDataDuplicateCheckOverview(false);
};
xhr.onerror = function () {
submitBtn.disabled = false;
msgEl.textContent = '请求失败';
msgEl.className = 'msg err';
};
xhr.send(formData);
}
document.getElementById('btnOpenDupCheckImportAdd').onclick = function () { openDupCheckImport(false); };
document.getElementById('btnOpenDupCheckImportDelete').onclick = function () { openDupCheckImport(true); };
document.getElementById('btnCloseDupCheckImportModal').onclick = closeDupCheckImportModal;
document.getElementById('btnDupCheckImportSubmit').onclick = submitDupCheckImport;
document.getElementById('dupCheckImportModal').onclick = function (event) {
if (event.target === this) closeDupCheckImportModal();
};
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
document.getElementById('shopDataDuplicateList').onclick = function (event) {
var target = event.target.closest('[data-open-drawer]');
+30 -1
View File
@@ -4647,6 +4647,9 @@
.drawer-mask { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(45, 66, 86, 0.36); backdrop-filter: blur(4px); }
.drawer-mask.show { display: block; animation: modal-fade-in 0.16s ease; }
.drawer { position: absolute; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(460px, 100%); background: #ffffff; border-left: 1px solid #c7d7e5; box-shadow: var(--shadow-pop); animation: drawer-slide-in 0.18s ease; }
/* 撞款明细抽屉:无遮罩图层(底层仍可点击) + 加宽 + 从撞款详情块位置开始 */
.dup-drawer-mask { background: none; backdrop-filter: none; pointer-events: none; z-index: 990; }
.dup-drawer-mask .drawer { pointer-events: auto; top: 24px; right: 24px; bottom: 24px; width: min(760px, calc(100% - 48px)); max-width: 860px; border-radius: 12px; border: 1px solid #dbe4ec; box-shadow: 0 14px 44px rgba(15, 42, 65, 0.22); }
@keyframes drawer-slide-in { from { transform: translateX(26px); opacity: 0.4; } to { transform: none; opacity: 1; } }
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 22px 14px; border-bottom: 1px solid #e6edf4; }
.drawer-header h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--c-text); }
@@ -5673,6 +5676,8 @@
<button class="btn shop-data-refresh-btn" id="btnRefreshShopDataDuplicates"
type="button">重新分析</button>
<button class="btn btn-secondary" id="btnExportShopDataDuplicates" type="button">导出</button>
<button class="btn btn-secondary" id="btnOpenDupCheckImportAdd" type="button">导入(新增-测试)</button>
<button class="btn btn-secondary" id="btnOpenDupCheckImportDelete" type="button">导入(删除-测试)</button>
<span class="image-video-download-progress" id="shopDataDuplicateProgress"
aria-live="polite"></span>
</div>
@@ -5689,7 +5694,7 @@
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
</div>
<div class="drawer-mask" id="dupCheckDrawerMask">
<div class="drawer-mask dup-drawer-mask" id="dupCheckDrawerMask" data-drawer-anchor="dupCheckDetailBlock">
<aside class="drawer" role="dialog" aria-modal="true" aria-label="ASIN 详情">
<div class="drawer-header">
<div>
@@ -5702,6 +5707,30 @@
<div class="drawer-body" id="dupCheckDrawerBody"></div>
</aside>
</div>
<!-- 撞款:导入(新增-测试)/导入(删除-测试)弹窗 -->
<div class="modal-mask" id="dupCheckImportModal">
<div class="modal">
<h3 id="dupCheckImportTitle">导入(新增-测试)</h3>
<div class="form-group">
<label>店铺名称(新增=以导入文件替换该店记录;删除=从基线移除该店)</label>
<input type="text" id="dupCheckImportShopName" placeholder="如:小曾">
</div>
<div class="form-group">
<label>国家代码(可选,逗号分隔,如 UK,DE。缺失时按 Excel 的 sheet 名推断)</label>
<input type="text" id="dupCheckImportCountryCodes" placeholder="如:UK,DE">
</div>
<div class="form-group">
<label>上传 Excel 文件(读取 ASIN 列,可含 日期/价格/品牌 列)</label>
<input type="file" id="dupCheckImportFile" accept=".xlsx,.xls">
</div>
<p class="msg" id="msgDupCheckImport"></p>
<div style="margin-top:16px;display:flex;gap:8px;">
<button class="btn" id="btnDupCheckImportSubmit">上传并导入</button>
<button class="btn btn-secondary" id="btnCloseDupCheckImportModal" type="button">取消</button>
</div>
</div>
</div>
</div>
</div>
</div>