- 撞款详情抽屉:上架时间默认降序(晚到早)、去掉列头(按时间升序)小括号、加宽至 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:
@@ -123,3 +123,6 @@ backend-java/docs/*audit*.md
|
|||||||
check_progress.py
|
check_progress.py
|
||||||
progress.json
|
progress.json
|
||||||
progress_*.json
|
progress_*.json
|
||||||
|
|
||||||
|
# ===== 本地预览工具(不入库)=====
|
||||||
|
backend/_preview_mock.py
|
||||||
|
|||||||
+38
-1
@@ -6,6 +6,7 @@ import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
|||||||
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
import com.nanri.aiimage.modules.admin.support.AdminAuthSupport;
|
||||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||||
import com.nanri.aiimage.modules.permission.service.PermissionMenuService;
|
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.ShopDataDuplicateCheckQueryService;
|
||||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService;
|
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService;
|
||||||
import com.nanri.aiimage.modules.shopduplicatecheck.service.ShopDataDuplicateCheckScanService.DuplicateScanView;
|
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.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -31,7 +34,7 @@ import java.util.Map;
|
|||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 店铺数据重复检查(撞款):overview / items / detail / export 四端点,契约与 Flask 页面一致。
|
* 店铺数据重复检查(撞款):overview / items / detail / export / import 五端点,契约与 Flask 页面一致。
|
||||||
* JWT 管理员或 Flask 内部代理(X-Internal-Token + operatorId)均可调用;统一菜单+数据权限校验。
|
* JWT 管理员或 Flask 内部代理(X-Internal-Token + operatorId)均可调用;统一菜单+数据权限校验。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -51,6 +54,7 @@ public class AdminShopDataDuplicateCheckController {
|
|||||||
private final PermissionMenuService permissionMenuService;
|
private final PermissionMenuService permissionMenuService;
|
||||||
private final ShopDataDuplicateCheckScanService scanService;
|
private final ShopDataDuplicateCheckScanService scanService;
|
||||||
private final ShopDataDuplicateCheckQueryService queryService;
|
private final ShopDataDuplicateCheckQueryService queryService;
|
||||||
|
private final ShopDataDuplicateCheckImportService importService;
|
||||||
|
|
||||||
@GetMapping("/duplicate-check-overview")
|
@GetMapping("/duplicate-check-overview")
|
||||||
@Operation(summary = "撞款指标与店铺分布(force=1 触发同步重扫)")
|
@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) {
|
private record RequestOperator(Long id, boolean superAdmin) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+170
@@ -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 { 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-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; }
|
.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; } }
|
@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 { 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); }
|
.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 class="admin-brand-sub">电商运营管理后台</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 加载后走接口渲染,避免闪出全量菜单 #}
|
{# 服务端不可用时的轻量占位:JS 加载后走接口渲染,避免闪出全量菜单 #}
|
||||||
<div class="menu-empty">菜单加载中...</div>
|
<div class="menu-empty">菜单加载中...</div>
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="admin-sidebar-foot">数富AI · 管理控制台</div>
|
<div class="admin-sidebar-foot">数富AI · 管理控制台</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -5622,6 +5676,8 @@
|
|||||||
<button class="btn shop-data-refresh-btn" id="btnRefreshShopDataDuplicates"
|
<button class="btn shop-data-refresh-btn" id="btnRefreshShopDataDuplicates"
|
||||||
type="button">重新分析</button>
|
type="button">重新分析</button>
|
||||||
<button class="btn btn-secondary" id="btnExportShopDataDuplicates" 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"
|
<span class="image-video-download-progress" id="shopDataDuplicateProgress"
|
||||||
aria-live="polite"></span>
|
aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -5638,7 +5694,7 @@
|
|||||||
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
|
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
|
||||||
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
|
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
|
||||||
</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 详情">
|
<aside class="drawer" role="dialog" aria-modal="true" aria-label="ASIN 详情">
|
||||||
<div class="drawer-header">
|
<div class="drawer-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -5651,6 +5707,30 @@
|
|||||||
<div class="drawer-body" id="dupCheckDrawerBody"></div>
|
<div class="drawer-body" id="dupCheckDrawerBody"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,83 +49,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Java 统一响应体 ApiResponse{success,message,data,code} 转成旧前端形状:
|
|
||||||
// 成功 -> {success:true, ...data};失败 -> error=message;code 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) {
|
if (window.fetch) {
|
||||||
var nativeFetch = window.fetch.bind(window);
|
var nativeFetch = window.fetch.bind(window);
|
||||||
window.fetch = function (input, init) {
|
window.fetch = function (input, init) {
|
||||||
@@ -138,18 +61,7 @@
|
|||||||
beginRequest();
|
beginRequest();
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
var urlInput = (typeof input === 'string') ? adminApiUrlRewrite(input) : input;
|
return nativeFetch(input, options).finally(function () {
|
||||||
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 () {
|
|
||||||
if (!skipLoading) endRequest();
|
if (!skipLoading) endRequest();
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -163,12 +75,8 @@
|
|||||||
var nativeOpen = XMLHttpRequest.prototype.open;
|
var nativeOpen = XMLHttpRequest.prototype.open;
|
||||||
var nativeSend = XMLHttpRequest.prototype.send;
|
var nativeSend = XMLHttpRequest.prototype.send;
|
||||||
XMLHttpRequest.prototype.open = function () {
|
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;
|
this.__adminSkipLoading = false;
|
||||||
return nativeOpen.apply(this, args);
|
return nativeOpen.apply(this, arguments);
|
||||||
};
|
};
|
||||||
XMLHttpRequest.prototype.send = function () {
|
XMLHttpRequest.prototype.send = function () {
|
||||||
if (!this.__adminSkipLoading) {
|
if (!this.__adminSkipLoading) {
|
||||||
@@ -2288,8 +2196,61 @@
|
|||||||
return '<span class="dup-drawer-badge' + (cls ? ' ' + cls : '') + '">' + escapeHtml(text) + '</span>';
|
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) {
|
function openShopDataDuplicateDrawer(asin) {
|
||||||
|
mountDupDrawerMaskToBody();
|
||||||
var item = null;
|
var item = null;
|
||||||
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
|
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -2298,19 +2259,15 @@
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
var occurrences = (item.occurrences || []).slice();
|
var occurrences = (item.occurrences || []).slice();
|
||||||
var brand = item.brand || '';
|
var brand = item.brand || '';
|
||||||
// 上架时间归一化并升序排列(按时间正序展示)
|
// 上架时间归一化并按时间降序排列(晚到早)
|
||||||
occurrences.forEach(function (occ) { occ._dupTime = normalizeDuplicateTime(occ.date); });
|
occurrences.forEach(function (occ) { occ._dupTime = normalizeDuplicateTime(occ.date); });
|
||||||
occurrences.sort(function (a, b) {
|
occurrences.sort(dupTimeDesc);
|
||||||
var ta = a._dupTime || '', tb = b._dupTime || '';
|
var sites = {}, lastDate = '';
|
||||||
if (ta === tb) return 0;
|
|
||||||
return ta < tb ? -1 : 1;
|
|
||||||
});
|
|
||||||
var sites = {}, firstDate = '';
|
|
||||||
occurrences.forEach(function (occ) {
|
occurrences.forEach(function (occ) {
|
||||||
if (occ.country) sites[occ.country] = true;
|
if (occ.country) sites[occ.country] = true;
|
||||||
if (!brand && occ.brand) brand = occ.brand;
|
if (!brand && occ.brand) brand = occ.brand;
|
||||||
var t = occ._dupTime;
|
var t = occ._dupTime;
|
||||||
if (t && (!firstDate || t < firstDate)) firstDate = t;
|
if (t && t > lastDate) lastDate = t;
|
||||||
});
|
});
|
||||||
// 店铺 + 国家两级聚合:次数 = 该店铺在该国家的上架记录数
|
// 店铺 + 国家两级聚合:次数 = 该店铺在该国家的上架记录数
|
||||||
var countsMap = {};
|
var countsMap = {};
|
||||||
@@ -2345,7 +2302,7 @@
|
|||||||
return dupDrawerBadge(dupSiteLabel(site), 'dup-site-' + site.toUpperCase());
|
return dupDrawerBadge(dupSiteLabel(site), 'dup-site-' + site.toUpperCase());
|
||||||
}).join('') +
|
}).join('') +
|
||||||
dupDrawerBadge(recordCount + ' 次上架', 'dup-drawer-badge-green') +
|
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').textContent = item.asin;
|
||||||
document.getElementById('dupCheckDrawerAsin').className = 'dup-drawer-asin';
|
document.getElementById('dupCheckDrawerAsin').className = 'dup-drawer-asin';
|
||||||
var subtitleEl = document.getElementById('dupCheckDrawerSubtitle');
|
var subtitleEl = document.getElementById('dupCheckDrawerSubtitle');
|
||||||
@@ -2354,9 +2311,10 @@
|
|||||||
document.getElementById('dupCheckDrawerBody').innerHTML =
|
document.getElementById('dupCheckDrawerBody').innerHTML =
|
||||||
'<div class="dup-drawer-brand-line">品牌:' + escapeHtml(brand || '-') + '</div>' +
|
'<div class="dup-drawer-brand-line">品牌:' + escapeHtml(brand || '-') + '</div>' +
|
||||||
'<div class="table-scroll"><table class="dup-check-drawer-table">' +
|
'<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>';
|
'<tbody>' + (rows || '<tr><td colspan="4" class="shop-data-empty-hint">暂无明细</td></tr>') + '</tbody></table></div>';
|
||||||
document.getElementById('dupCheckDrawerMask').classList.add('show');
|
document.getElementById('dupCheckDrawerMask').classList.add('show');
|
||||||
|
positionDupDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeShopDataDuplicateDrawer() {
|
function closeShopDataDuplicateDrawer() {
|
||||||
@@ -2694,6 +2652,92 @@
|
|||||||
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
|
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
|
||||||
if (event.target === this) closeShopDataDuplicateDrawer();
|
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 / 数字徽标点击打开抽屉(动态元素,事件委托)
|
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
|
||||||
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
||||||
var target = event.target.closest('[data-open-drawer]');
|
var target = event.target.closest('[data-open-drawer]');
|
||||||
@@ -6238,11 +6282,10 @@
|
|||||||
var pagination = document.getElementById('versionPagination');
|
var pagination = document.getElementById('versionPagination');
|
||||||
if (pagination) pagination.innerHTML = '';
|
if (pagination) pagination.innerHTML = '';
|
||||||
if (!res.success) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
var data = res.data || {};
|
var items = res.items || [];
|
||||||
var items = Array.isArray(data) ? data : (data.items || []);
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="empty-tip">暂无版本记录</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="4" class="empty-tip">暂无版本记录</td></tr>';
|
||||||
return;
|
return;
|
||||||
@@ -6432,8 +6475,7 @@
|
|||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
var data = res.data || {};
|
msgEl.textContent = '发布成功。版本:' + res.version + ',链接:' + (res.file_url || '');
|
||||||
msgEl.textContent = '发布成功。版本:' + (data.version || '') + ',链接:' + (data.file_url || '');
|
|
||||||
msgEl.classList.add('ok');
|
msgEl.classList.add('ok');
|
||||||
document.getElementById('versionNumber').value = '';
|
document.getElementById('versionNumber').value = '';
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
@@ -7126,14 +7168,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('btnAdminLogout').onclick = function () {
|
document.getElementById('btnAdminLogout').onclick = function () {
|
||||||
// Java auth 模块 POST /logout 清 cookie;无论成败均回登录页(GET /login 由页面控制器转发到 login.html)。
|
fetch('/api/admin/logout', {
|
||||||
fetch('/logout', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||||
})
|
})
|
||||||
.catch(function () { /* 忽略网络错误,仍跳登录页 */ })
|
.then(function (r) {
|
||||||
.finally(function () {
|
if (r.status === 404) {
|
||||||
window.location.replace('/login?logout=1');
|
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');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+126
@@ -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
@@ -2196,8 +2196,61 @@
|
|||||||
return '<span class="dup-drawer-badge' + (cls ? ' ' + cls : '') + '">' + escapeHtml(text) + '</span>';
|
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) {
|
function openShopDataDuplicateDrawer(asin) {
|
||||||
|
mountDupDrawerMaskToBody();
|
||||||
var item = null;
|
var item = null;
|
||||||
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
|
(shopDataDuplicateDetailItems || []).forEach(function (it) { if (it.asin === asin) item = it; });
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -2206,19 +2259,15 @@
|
|||||||
if (!item) return;
|
if (!item) return;
|
||||||
var occurrences = (item.occurrences || []).slice();
|
var occurrences = (item.occurrences || []).slice();
|
||||||
var brand = item.brand || '';
|
var brand = item.brand || '';
|
||||||
// 上架时间归一化并升序排列(按时间正序展示)
|
// 上架时间归一化并按时间降序排列(晚到早)
|
||||||
occurrences.forEach(function (occ) { occ._dupTime = normalizeDuplicateTime(occ.date); });
|
occurrences.forEach(function (occ) { occ._dupTime = normalizeDuplicateTime(occ.date); });
|
||||||
occurrences.sort(function (a, b) {
|
occurrences.sort(dupTimeDesc);
|
||||||
var ta = a._dupTime || '', tb = b._dupTime || '';
|
var sites = {}, lastDate = '';
|
||||||
if (ta === tb) return 0;
|
|
||||||
return ta < tb ? -1 : 1;
|
|
||||||
});
|
|
||||||
var sites = {}, firstDate = '';
|
|
||||||
occurrences.forEach(function (occ) {
|
occurrences.forEach(function (occ) {
|
||||||
if (occ.country) sites[occ.country] = true;
|
if (occ.country) sites[occ.country] = true;
|
||||||
if (!brand && occ.brand) brand = occ.brand;
|
if (!brand && occ.brand) brand = occ.brand;
|
||||||
var t = occ._dupTime;
|
var t = occ._dupTime;
|
||||||
if (t && (!firstDate || t < firstDate)) firstDate = t;
|
if (t && t > lastDate) lastDate = t;
|
||||||
});
|
});
|
||||||
// 店铺 + 国家两级聚合:次数 = 该店铺在该国家的上架记录数
|
// 店铺 + 国家两级聚合:次数 = 该店铺在该国家的上架记录数
|
||||||
var countsMap = {};
|
var countsMap = {};
|
||||||
@@ -2253,7 +2302,7 @@
|
|||||||
return dupDrawerBadge(dupSiteLabel(site), 'dup-site-' + site.toUpperCase());
|
return dupDrawerBadge(dupSiteLabel(site), 'dup-site-' + site.toUpperCase());
|
||||||
}).join('') +
|
}).join('') +
|
||||||
dupDrawerBadge(recordCount + ' 次上架', 'dup-drawer-badge-green') +
|
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').textContent = item.asin;
|
||||||
document.getElementById('dupCheckDrawerAsin').className = 'dup-drawer-asin';
|
document.getElementById('dupCheckDrawerAsin').className = 'dup-drawer-asin';
|
||||||
var subtitleEl = document.getElementById('dupCheckDrawerSubtitle');
|
var subtitleEl = document.getElementById('dupCheckDrawerSubtitle');
|
||||||
@@ -2262,9 +2311,10 @@
|
|||||||
document.getElementById('dupCheckDrawerBody').innerHTML =
|
document.getElementById('dupCheckDrawerBody').innerHTML =
|
||||||
'<div class="dup-drawer-brand-line">品牌:' + escapeHtml(brand || '-') + '</div>' +
|
'<div class="dup-drawer-brand-line">品牌:' + escapeHtml(brand || '-') + '</div>' +
|
||||||
'<div class="table-scroll"><table class="dup-check-drawer-table">' +
|
'<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>';
|
'<tbody>' + (rows || '<tr><td colspan="4" class="shop-data-empty-hint">暂无明细</td></tr>') + '</tbody></table></div>';
|
||||||
document.getElementById('dupCheckDrawerMask').classList.add('show');
|
document.getElementById('dupCheckDrawerMask').classList.add('show');
|
||||||
|
positionDupDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeShopDataDuplicateDrawer() {
|
function closeShopDataDuplicateDrawer() {
|
||||||
@@ -2602,6 +2652,92 @@
|
|||||||
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
|
document.getElementById('dupCheckDrawerMask').onclick = function (event) {
|
||||||
if (event.target === this) closeShopDataDuplicateDrawer();
|
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 / 数字徽标点击打开抽屉(动态元素,事件委托)
|
// 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托)
|
||||||
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
||||||
var target = event.target.closest('[data-open-drawer]');
|
var target = event.target.closest('[data-open-drawer]');
|
||||||
|
|||||||
@@ -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 { 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-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; }
|
.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; } }
|
@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 { 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); }
|
.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"
|
<button class="btn shop-data-refresh-btn" id="btnRefreshShopDataDuplicates"
|
||||||
type="button">重新分析</button>
|
type="button">重新分析</button>
|
||||||
<button class="btn btn-secondary" id="btnExportShopDataDuplicates" 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"
|
<span class="image-video-download-progress" id="shopDataDuplicateProgress"
|
||||||
aria-live="polite"></span>
|
aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -5689,7 +5694,7 @@
|
|||||||
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
|
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
|
||||||
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
|
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
|
||||||
</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 详情">
|
<aside class="drawer" role="dialog" aria-modal="true" aria-label="ASIN 详情">
|
||||||
<div class="drawer-header">
|
<div class="drawer-header">
|
||||||
<div>
|
<div>
|
||||||
@@ -5702,6 +5707,30 @@
|
|||||||
<div class="drawer-body" id="dupCheckDrawerBody"></div>
|
<div class="drawer-body" id="dupCheckDrawerBody"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user