- 撞款详情抽屉:上架时间默认降序(晚到早)、去掉列头(按时间升序)小括号、加宽至 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:
+147
-11
@@ -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]');
|
||||
|
||||
Reference in New Issue
Block a user