diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java index b1fa2644..f8bceede 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java @@ -183,13 +183,13 @@ public class ShopDataDuplicateCheckQueryService { List cropped = cropItems(view.payload().items(), visibleKeys); List matched = filterItems(cropped, monitor, filters); List> rows = new ArrayList<>(); - rows.add(List.of("ASIN", "店铺数", "店铺", "分组", "站点", "上架时间", "价格", "品牌")); + rows.add(List.of("ASIN", "店铺数", "店铺", "分组", "国家", "上架时间", "价格", "品牌")); List flat = new ArrayList<>(); for (DuplicateItem item : matched) { String shopCount = String.valueOf(item.shopCount()); for (DuplicateOccurrence occ : item.occurrences()) { flat.add(new Object[]{item.asin(), shopCount, occ.shopName(), occ.groupName(), - effectiveSite(occ), safe(occ.date()), safe(occ.price()), safe(occ.brand())}); + effectiveCountryCn(occ), safe(occ.date()), safe(occ.price()), safe(occ.brand())}); } } flat.sort(flatExportComparator()); @@ -358,9 +358,18 @@ public class ShopDataDuplicateCheckQueryService { source == null ? "" : source); } - private String effectiveSite(DuplicateOccurrence occ) { + /** 站点代码 → 中文国家名(导出「国家」列,与页面 DUP_SITE_LABELS 一致),未识别回退原值。 */ + private static final Map SITE_CN_LABELS = Map.of( + "DE", "德国", "UK", "英国", "FR", "法国", "IT", "意大利", "ES", "西班牙"); + + private static String siteCnLabel(String code) { + String key = safe(code).trim().toUpperCase(Locale.ROOT); + return SITE_CN_LABELS.containsKey(key) ? SITE_CN_LABELS.get(key) : safe(code); + } + + private String effectiveCountryCn(DuplicateOccurrence occ) { if (!safe(occ.country()).isEmpty()) { - return occ.country().toUpperCase(Locale.ROOT); + return siteCnLabel(occ.country()); } List codes = occ.countryCodes() == null ? List.of() : occ.countryCodes(); if (codes.isEmpty()) { @@ -371,7 +380,7 @@ public class ShopDataDuplicateCheckQueryService { if (sb.length() > 0) { sb.append('、'); } - sb.append(safe(code).toUpperCase(Locale.ROOT)); + sb.append(siteCnLabel(code)); } return sb.toString(); } diff --git a/backend-java/src/main/resources/static/static/admin.js b/backend-java/src/main/resources/static/static/admin.js index 08b7deba..6474ac51 100644 --- a/backend-java/src/main/resources/static/static/admin.js +++ b/backend-java/src/main/resources/static/static/admin.js @@ -2084,10 +2084,10 @@ return isNaN(t) ? -1 : t; } - // 「查看明细」按钮点击开抽屉(动态元素,事件委托) + // 「查看明细」按钮点击开抽屉(动态元素,事件委托),并把被点按钮作为抽屉的垂直锚点 function handleDuplicateDetailClick(event) { var button = event.target.closest('[data-open-asin-detail]'); - if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail); + if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail, button); } // 站点徽章:中文国家(未识别回退原值) @@ -2197,21 +2197,26 @@ } // 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间降序明细 + 次数列 - // 块内定位:抽屉贴到「撞款详情」区块顶部(anchorRect),而不是浏览器左侧顶部; - // 区块滚出视口时钳制到视口内可见区域(顶部矩阵点 ASIN 打开也要能看到抽屉) + // 块内定位:抽屉横向贴到「撞款详情」区块,纵向以触发元素(被点的 ASIN/「查看明细」按钮)为锚点、 + // 钳制在视口内,保证弹窗出现在点击处附近而非页面/滚动区顶部(顶部弹出会被当前滚动位置盖住看不见)。 + var dupDrawerTriggerEl = null; + 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 block = document.getElementById('dupCheckDetailBlock'); + if (!aside || !block) return; 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 blockRect = block.getBoundingClientRect(); + var left = Math.max(12, Math.min(blockRect.left + 12, Math.max(12, bodyWidth() - 860))); + // 垂直锚点优先取触发元素;未传入或已脱离文档时回退到「撞款详情」区块顶部 + var anchor = (dupDrawerTriggerEl && dupDrawerTriggerEl.isConnected) ? dupDrawerTriggerEl : block; + var rect = anchor.getBoundingClientRect(); var top = Math.max(padTop, rect.top - padTop); - // 区块整体在视口外(下方/上方)时回退到视口顶部,避免抽屉出现在视口外 - if (rect.top >= bodyHeight - 120 || rect.bottom <= padTop) top = padTop; + // 点击处整体已滚出视口下方时,改放到视口底部上方留出可读高度 + if (rect.top >= bodyHeight - padBottom) top = Math.max(padTop, bodyHeight - 300); // 顶部再钳制一次,保证抽屉头部可见 top = Math.min(top, Math.max(padTop, bodyHeight - 260)); aside.style.left = left + 'px'; @@ -2249,8 +2254,9 @@ return mask; } - function openShopDataDuplicateDrawer(asin) { + function openShopDataDuplicateDrawer(asin, triggerEl) { mountDupDrawerMaskToBody(); + dupDrawerTriggerEl = triggerEl || null; var item = null; (shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; }); if (!item) { @@ -2738,10 +2744,10 @@ document.getElementById('dupCheckImportModal').onclick = function (event) { if (event.target === this) closeDupCheckImportModal(); }; - // 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托) + // 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托),并传入被点元素作为锚点 document.getElementById('shopDataDuplicateList').onclick = function (event) { var target = event.target.closest('[data-open-drawer]'); - if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer); + if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer, target); }; // 撞款详情卡片区「查看明细」按钮(动态元素,事件委托) document.getElementById('dupCheckDetailCards').onclick = handleDuplicateDetailClick; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java index 99c7ab8f..bb287353 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryServiceTest.java @@ -147,10 +147,14 @@ class ShopDataDuplicateCheckQueryServiceTest { assertThat(bytes[2]).isEqualTo((byte) 0xBF); String text = new String(bytes, StandardCharsets.UTF_8); String[] lines = text.split("\r\n"); - assertThat(lines[0]).endsWith("ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌"); + assertThat(lines[0]).endsWith("ASIN,店铺数,店铺,分组,国家,上架时间,价格,品牌"); // 数据行 = E(3 记录) + A(2) + F(2) = 7,非空行总数为 8(尾随 CRLF 会产生一个空元素) long nonEmpty = java.util.Arrays.stream(lines).filter(line -> !line.isEmpty()).count(); assertThat(nonEmpty).isEqualTo(8); assertThat(lines[1]).startsWith("E0000001,3,"); + // 站点代码列导出为中文国家名(UK→英国、FR→法国),与页面「国家」展示一致 + assertThat(lines[1]).startsWith("E0000001,3,ShopA,GroupA,英国,"); + assertThat(text).contains(",法国,"); + assertThat(lines[1]).doesNotContain(",UK,"); } } diff --git a/backend/static/admin.js b/backend/static/admin.js index 08b7deba..6474ac51 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -2084,10 +2084,10 @@ return isNaN(t) ? -1 : t; } - // 「查看明细」按钮点击开抽屉(动态元素,事件委托) + // 「查看明细」按钮点击开抽屉(动态元素,事件委托),并把被点按钮作为抽屉的垂直锚点 function handleDuplicateDetailClick(event) { var button = event.target.closest('[data-open-asin-detail]'); - if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail); + if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail, button); } // 站点徽章:中文国家(未识别回退原值) @@ -2197,21 +2197,26 @@ } // 图3 样式抽屉:ASIN + 店在售徽章 / 品牌行 / 彩色徽章组 / 按时间降序明细 + 次数列 - // 块内定位:抽屉贴到「撞款详情」区块顶部(anchorRect),而不是浏览器左侧顶部; - // 区块滚出视口时钳制到视口内可见区域(顶部矩阵点 ASIN 打开也要能看到抽屉) + // 块内定位:抽屉横向贴到「撞款详情」区块,纵向以触发元素(被点的 ASIN/「查看明细」按钮)为锚点、 + // 钳制在视口内,保证弹窗出现在点击处附近而非页面/滚动区顶部(顶部弹出会被当前滚动位置盖住看不见)。 + var dupDrawerTriggerEl = null; + 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 block = document.getElementById('dupCheckDetailBlock'); + if (!aside || !block) return; 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 blockRect = block.getBoundingClientRect(); + var left = Math.max(12, Math.min(blockRect.left + 12, Math.max(12, bodyWidth() - 860))); + // 垂直锚点优先取触发元素;未传入或已脱离文档时回退到「撞款详情」区块顶部 + var anchor = (dupDrawerTriggerEl && dupDrawerTriggerEl.isConnected) ? dupDrawerTriggerEl : block; + var rect = anchor.getBoundingClientRect(); var top = Math.max(padTop, rect.top - padTop); - // 区块整体在视口外(下方/上方)时回退到视口顶部,避免抽屉出现在视口外 - if (rect.top >= bodyHeight - 120 || rect.bottom <= padTop) top = padTop; + // 点击处整体已滚出视口下方时,改放到视口底部上方留出可读高度 + if (rect.top >= bodyHeight - padBottom) top = Math.max(padTop, bodyHeight - 300); // 顶部再钳制一次,保证抽屉头部可见 top = Math.min(top, Math.max(padTop, bodyHeight - 260)); aside.style.left = left + 'px'; @@ -2249,8 +2254,9 @@ return mask; } - function openShopDataDuplicateDrawer(asin) { + function openShopDataDuplicateDrawer(asin, triggerEl) { mountDupDrawerMaskToBody(); + dupDrawerTriggerEl = triggerEl || null; var item = null; (shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; }); if (!item) { @@ -2738,10 +2744,10 @@ document.getElementById('dupCheckImportModal').onclick = function (event) { if (event.target === this) closeDupCheckImportModal(); }; - // 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托) + // 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托),并传入被点元素作为锚点 document.getElementById('shopDataDuplicateList').onclick = function (event) { var target = event.target.closest('[data-open-drawer]'); - if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer); + if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer, target); }; // 撞款详情卡片区「查看明细」按钮(动态元素,事件委托) document.getElementById('dupCheckDetailCards').onclick = handleDuplicateDetailClick; diff --git a/backend/tests/test_admin_shop_data_duplicate_check.py b/backend/tests/test_admin_shop_data_duplicate_check.py index 4c834224..37335095 100644 --- a/backend/tests/test_admin_shop_data_duplicate_check.py +++ b/backend/tests/test_admin_shop_data_duplicate_check.py @@ -252,8 +252,8 @@ class DuplicateCheckProxyTest(unittest.TestCase): self.assertEqual(body['items'], []) def test_export_streams_java_csv(self): - csv_bytes = ('' + 'ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌\r\n' - 'E0000001,3,ShopA,GroupA,UK,2026年8月29日 上午4:34,GBP 12.00,BrandE\r\n').encode('utf-8') + csv_bytes = ('' + 'ASIN,店铺数,店铺,分组,国家,上架时间,价格,品牌\r\n' + 'E0000001,3,ShopA,GroupA,英国,2026年8月29日 上午4:34,GBP 12.00,BrandE\r\n').encode('utf-8') class FakeResp: status_code = 200