- 撞款详情抽屉:上架时间默认降序(晚到早)、去掉列头(按时间升序)小括号、加宽至 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]');
|
||||
|
||||
@@ -4647,6 +4647,9 @@
|
||||
.drawer-mask { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(45, 66, 86, 0.36); backdrop-filter: blur(4px); }
|
||||
.drawer-mask.show { display: block; animation: modal-fade-in 0.16s ease; }
|
||||
.drawer { position: absolute; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(460px, 100%); background: #ffffff; border-left: 1px solid #c7d7e5; box-shadow: var(--shadow-pop); animation: drawer-slide-in 0.18s ease; }
|
||||
/* 撞款明细抽屉:无遮罩图层(底层仍可点击) + 加宽 + 从撞款详情块位置开始 */
|
||||
.dup-drawer-mask { background: none; backdrop-filter: none; pointer-events: none; z-index: 990; }
|
||||
.dup-drawer-mask .drawer { pointer-events: auto; top: 24px; right: 24px; bottom: 24px; width: min(760px, calc(100% - 48px)); max-width: 860px; border-radius: 12px; border: 1px solid #dbe4ec; box-shadow: 0 14px 44px rgba(15, 42, 65, 0.22); }
|
||||
@keyframes drawer-slide-in { from { transform: translateX(26px); opacity: 0.4; } to { transform: none; opacity: 1; } }
|
||||
.drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 22px 14px; border-bottom: 1px solid #e6edf4; }
|
||||
.drawer-header h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--c-text); }
|
||||
@@ -5673,6 +5676,8 @@
|
||||
<button class="btn shop-data-refresh-btn" id="btnRefreshShopDataDuplicates"
|
||||
type="button">重新分析</button>
|
||||
<button class="btn btn-secondary" id="btnExportShopDataDuplicates" type="button">导出</button>
|
||||
<button class="btn btn-secondary" id="btnOpenDupCheckImportAdd" type="button">导入(新增-测试)</button>
|
||||
<button class="btn btn-secondary" id="btnOpenDupCheckImportDelete" type="button">导入(删除-测试)</button>
|
||||
<span class="image-video-download-progress" id="shopDataDuplicateProgress"
|
||||
aria-live="polite"></span>
|
||||
</div>
|
||||
@@ -5689,7 +5694,7 @@
|
||||
<div class="dup-check-detail-cards" id="dupCheckDetailCards"></div>
|
||||
<div class="pagination" id="dupCheckDetailPagination" style="margin-top:12px;"></div>
|
||||
</div>
|
||||
<div class="drawer-mask" id="dupCheckDrawerMask">
|
||||
<div class="drawer-mask dup-drawer-mask" id="dupCheckDrawerMask" data-drawer-anchor="dupCheckDetailBlock">
|
||||
<aside class="drawer" role="dialog" aria-modal="true" aria-label="ASIN 详情">
|
||||
<div class="drawer-header">
|
||||
<div>
|
||||
@@ -5702,6 +5707,30 @@
|
||||
<div class="drawer-body" id="dupCheckDrawerBody"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- 撞款:导入(新增-测试)/导入(删除-测试)弹窗 -->
|
||||
<div class="modal-mask" id="dupCheckImportModal">
|
||||
<div class="modal">
|
||||
<h3 id="dupCheckImportTitle">导入(新增-测试)</h3>
|
||||
<div class="form-group">
|
||||
<label>店铺名称(新增=以导入文件替换该店记录;删除=从基线移除该店)</label>
|
||||
<input type="text" id="dupCheckImportShopName" placeholder="如:小曾">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>国家代码(可选,逗号分隔,如 UK,DE。缺失时按 Excel 的 sheet 名推断)</label>
|
||||
<input type="text" id="dupCheckImportCountryCodes" placeholder="如:UK,DE">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>上传 Excel 文件(读取 ASIN 列,可含 日期/价格/品牌 列)</label>
|
||||
<input type="file" id="dupCheckImportFile" accept=".xlsx,.xls">
|
||||
</div>
|
||||
<p class="msg" id="msgDupCheckImport"></p>
|
||||
<div style="margin-top:16px;display:flex;gap:8px;">
|
||||
<button class="btn" id="btnDupCheckImportSubmit">上传并导入</button>
|
||||
<button class="btn btn-secondary" id="btnCloseDupCheckImportModal" type="button">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user