- 列表全选复选框从工具栏移到表格表头,事件委托处理动态元素 - 重复ASIN Tab 标题加总数(XX 为重复 ASIN 数量) - 重复ASIN 筛选独立:ASIN/店铺/国家/上架时间(开始+结束),与原店铺数据筛选互不影响 - 后端支持 asin/shop_name/country/date_from/date_to 参数: 行级筛选命中 ASIN 后展示完整记录(含其他店铺,跨店语义更直观) - 日期文本归一化(支持 2026年8月19日 上午4:34 中文格式) - 按 ASIN 分组表格展示:ASIN/店铺数/记录条数/品牌/对应店铺/国家/上架时间/操作, 详情展开显示每条记录的店铺/分组/国家/日期/价格/品牌 - 修复 _shop_data_crawl_fetch_result_bytes 兼容 result_id 列别名(生产首个真实调用报 KeyError) - 新增单测:筛选语义、日期归一化、record_count
This commit is contained in:
@@ -1699,7 +1699,9 @@ _SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT = (10, 60)
|
|||||||
|
|
||||||
def _shop_data_crawl_fetch_result_bytes(row, timeout=None):
|
def _shop_data_crawl_fetch_result_bytes(row, timeout=None):
|
||||||
"""从 Java 下载接口拉取结果文件字节流(仅内存,不落盘)。"""
|
"""从 Java 下载接口拉取结果文件字节流(仅内存,不落盘)。"""
|
||||||
url = f"{backend_java_base_url}/api/admin/shop-data-crawl/results/{int(row['id'])}/download"
|
# row 支持 id(下载行)或 result_id(管理列集别名)
|
||||||
|
result_id = row.get('id') or row.get('result_id') or 0
|
||||||
|
url = f"{backend_java_base_url}/api/admin/shop-data-crawl/results/{int(result_id)}/download"
|
||||||
headers, params = _backend_java_internal_request()
|
headers, params = _backend_java_internal_request()
|
||||||
response = _get_backend_java_session().get(
|
response = _get_backend_java_session().get(
|
||||||
url,
|
url,
|
||||||
@@ -1738,6 +1740,29 @@ def _shop_data_crawl_cell_text(cell):
|
|||||||
return str(value).strip()
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _shop_data_date_key(date_text):
|
||||||
|
"""把各种日期文本归一化为 YYYY-MM-DD,供范围比较。
|
||||||
|
|
||||||
|
支持:ISO(2026-08-19)、中文(2026年8月19日 上午4:34)、
|
||||||
|
yyyy.m.d / yyyy/m/d 等常见格式;无法识别时返回原字符串。
|
||||||
|
"""
|
||||||
|
text = (date_text or '').strip()
|
||||||
|
if not text:
|
||||||
|
return ''
|
||||||
|
if len(text) >= 10 and text[4] == '-' and text[7] == '-':
|
||||||
|
return text[:10]
|
||||||
|
year = month = day = None
|
||||||
|
m = re.search(r'(\d{4})\s*[年./-]\s*(\d{1,2})\s*[月./-]\s*(\d{1,2})', text)
|
||||||
|
if m:
|
||||||
|
year, month, day = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||||
|
if year and month and day:
|
||||||
|
try:
|
||||||
|
return f'{year:04d}-{month:02d}-{day:02d}'
|
||||||
|
except ValueError:
|
||||||
|
return text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _shop_data_crawl_parse_workbook(workbook):
|
def _shop_data_crawl_parse_workbook(workbook):
|
||||||
"""从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。"""
|
"""从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。"""
|
||||||
rows = []
|
rows = []
|
||||||
@@ -1781,11 +1806,11 @@ def shop_data_crawl_duplicate_asins():
|
|||||||
try:
|
try:
|
||||||
page = max(1, int(request.args.get('page', 1)))
|
page = max(1, int(request.args.get('page', 1)))
|
||||||
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
|
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
|
||||||
shop_name = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
|
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
|
||||||
group_name = (request.args.get('group_name') or request.args.get('group') or '').strip()
|
country_filter = (request.args.get('country') or '').strip().upper()
|
||||||
country = (request.args.get('country') or '').strip().upper()
|
asin_filter = (request.args.get('asin') or '').strip().upper()
|
||||||
created_from = _parse_admin_datetime_arg('created_from')
|
date_from = (request.args.get('date_from') or '').strip()[:10]
|
||||||
created_to = _parse_admin_datetime_arg('created_to')
|
date_to = (request.args.get('date_to') or '').strip()[:10]
|
||||||
|
|
||||||
conditions = [
|
conditions = [
|
||||||
"r.module_type = 'SHOP_DATA_CRAWL'",
|
"r.module_type = 'SHOP_DATA_CRAWL'",
|
||||||
@@ -1793,34 +1818,6 @@ def shop_data_crawl_duplicate_asins():
|
|||||||
"TRIM(COALESCE(r.result_file_url, '')) <> ''",
|
"TRIM(COALESCE(r.result_file_url, '')) <> ''",
|
||||||
]
|
]
|
||||||
params = []
|
params = []
|
||||||
if shop_name:
|
|
||||||
conditions.append('r.source_filename LIKE %s')
|
|
||||||
params.append('%' + shop_name + '%')
|
|
||||||
if group_name:
|
|
||||||
conditions.append(
|
|
||||||
'EXISTS (SELECT 1 FROM biz_shop_manage sm '
|
|
||||||
'LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id '
|
|
||||||
'WHERE TRIM(COALESCE(sm.shop_name, \'\')) = '
|
|
||||||
'TRIM(COALESCE(r.source_filename, \'\')) '
|
|
||||||
"AND COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, '')) LIKE %s)"
|
|
||||||
)
|
|
||||||
params.append('%' + group_name + '%')
|
|
||||||
if country:
|
|
||||||
if country not in ('DE', 'UK', 'FR', 'IT', 'ES'):
|
|
||||||
raise ValueError('不支持的国家代码: ' + country)
|
|
||||||
conditions.append(
|
|
||||||
'JSON_CONTAINS('
|
|
||||||
'COALESCE(df.country_codes_json, '
|
|
||||||
'JSON_EXTRACT(t.request_json, \'$.countryCodes\'), '
|
|
||||||
'JSON_EXTRACT(t.request_json, \'$.country_codes\'), \'[]\'), %s)'
|
|
||||||
)
|
|
||||||
params.append('"' + country + '"')
|
|
||||||
if created_from:
|
|
||||||
conditions.append('t.created_at >= %s')
|
|
||||||
params.append(created_from)
|
|
||||||
if created_to:
|
|
||||||
conditions.append('t.created_at <= %s')
|
|
||||||
params.append(created_to)
|
|
||||||
where_sql = ' AND '.join(conditions)
|
where_sql = ' AND '.join(conditions)
|
||||||
offset = (page - 1) * page_size
|
offset = (page - 1) * page_size
|
||||||
|
|
||||||
@@ -1907,10 +1904,13 @@ def shop_data_crawl_duplicate_asins():
|
|||||||
current_app.logger.warning(
|
current_app.logger.warning(
|
||||||
'[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc)
|
'[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc)
|
||||||
|
|
||||||
# 按 ASIN 聚合其出现的店铺/分组/国家与行细节
|
# 先全量聚合所有行,再用筛选条件圈定「命中 ASIN」;
|
||||||
|
# 命中且跨店重复的 ASIN 展示完整 occurrences(方便看该 ASIN 与哪些店重复)。
|
||||||
asin_occurrences = {}
|
asin_occurrences = {}
|
||||||
|
matched_asins = set()
|
||||||
for shop_item in shop_items:
|
for shop_item in shop_items:
|
||||||
shop_name = shop_item['shop_name'] or '未命名'
|
shop_name = shop_item['shop_name'] or '未命名'
|
||||||
|
country_codes = shop_item['country_codes'] or []
|
||||||
for row in shop_item['rows']:
|
for row in shop_item['rows']:
|
||||||
asin = row['asin'].strip().upper()
|
asin = row['asin'].strip().upper()
|
||||||
if not asin:
|
if not asin:
|
||||||
@@ -1924,15 +1924,31 @@ def shop_data_crawl_duplicate_asins():
|
|||||||
'group_name': shop_item['group_name'],
|
'group_name': shop_item['group_name'],
|
||||||
'country_codes': shop_item['country_codes'],
|
'country_codes': shop_item['country_codes'],
|
||||||
})
|
})
|
||||||
|
# 行级筛选:命中则将此 ASIN 加入待展示集合
|
||||||
|
if asin_filter and asin_filter not in asin:
|
||||||
|
continue
|
||||||
|
if shop_name_filter and shop_name_filter.lower() not in (shop_name or '').lower() \
|
||||||
|
and shop_name_filter.lower() not in (shop_item['group_name'] or '').lower():
|
||||||
|
continue
|
||||||
|
if country_filter and country_filter not in [c.upper() for c in country_codes]:
|
||||||
|
continue
|
||||||
|
row_date_key = _shop_data_date_key((row['date'] or '').strip())
|
||||||
|
if date_from and row_date_key and row_date_key < date_from:
|
||||||
|
continue
|
||||||
|
if date_to and row_date_key and row_date_key > date_to:
|
||||||
|
continue
|
||||||
|
matched_asins.add(asin)
|
||||||
|
|
||||||
occurrences_list = []
|
occurrences_list = []
|
||||||
for asin, occurrences in asin_occurrences.items():
|
for asin in matched_asins:
|
||||||
|
occurrences = asin_occurrences.get(asin, [])
|
||||||
shop_count = len({item['shop_name'] for item in occurrences})
|
shop_count = len({item['shop_name'] for item in occurrences})
|
||||||
if shop_count < 2:
|
if shop_count < 2:
|
||||||
continue
|
continue
|
||||||
occurrences_list.append({
|
occurrences_list.append({
|
||||||
'asin': asin,
|
'asin': asin,
|
||||||
'shop_count': shop_count,
|
'shop_count': shop_count,
|
||||||
|
'record_count': len(occurrences),
|
||||||
'occurrences': occurrences,
|
'occurrences': occurrences,
|
||||||
})
|
})
|
||||||
occurrences_list.sort(key=lambda item: (-item['shop_count'], item['asin']))
|
occurrences_list.sort(key=lambda item: (-item['shop_count'], item['asin']))
|
||||||
|
|||||||
+110
-20
@@ -1784,7 +1784,7 @@
|
|||||||
}
|
}
|
||||||
return '<table>' +
|
return '<table>' +
|
||||||
'<thead><tr>' +
|
'<thead><tr>' +
|
||||||
'<th style="width:36px;"></th>' +
|
'<th style="width:36px;"><input type="checkbox" id="shopDataTaskSelectAll"></th>' +
|
||||||
'<th style="width:18%;">店铺</th>' +
|
'<th style="width:18%;">店铺</th>' +
|
||||||
'<th style="width:14%;">分组</th>' +
|
'<th style="width:14%;">分组</th>' +
|
||||||
'<th style="width:16%;">国家</th>' +
|
'<th style="width:16%;">国家</th>' +
|
||||||
@@ -1870,11 +1870,11 @@
|
|||||||
params.set('page', String(page || 1));
|
params.set('page', String(page || 1));
|
||||||
params.set('page_size', String(shopDataDuplicatePageSize));
|
params.set('page_size', String(shopDataDuplicatePageSize));
|
||||||
var values = {
|
var values = {
|
||||||
shop_name: document.getElementById('shopDataTaskFilterShop').value.trim(),
|
asin: document.getElementById('shopDataDupFilterAsin').value.trim(),
|
||||||
group_name: document.getElementById('shopDataTaskFilterGroup').value.trim(),
|
shop_name: document.getElementById('shopDataDupFilterShop').value.trim(),
|
||||||
country: document.getElementById('shopDataTaskFilterCountry').value.trim(),
|
country: document.getElementById('shopDataDupFilterCountry').value.trim(),
|
||||||
created_from: document.getElementById('shopDataTaskFilterFrom').value,
|
date_from: document.getElementById('shopDataDupFilterDateFrom').value,
|
||||||
created_to: document.getElementById('shopDataTaskFilterTo').value
|
date_to: document.getElementById('shopDataDupFilterDateTo').value
|
||||||
};
|
};
|
||||||
Object.keys(values).forEach(function (key) {
|
Object.keys(values).forEach(function (key) {
|
||||||
if (values[key]) params.set(key, values[key]);
|
if (values[key]) params.set(key, values[key]);
|
||||||
@@ -1924,10 +1924,69 @@
|
|||||||
function renderShopDataDuplicateList() {
|
function renderShopDataDuplicateList() {
|
||||||
var list = document.getElementById('shopDataDuplicateList');
|
var list = document.getElementById('shopDataDuplicateList');
|
||||||
if (!shopDataDuplicateItems.length) {
|
if (!shopDataDuplicateItems.length) {
|
||||||
list.innerHTML = '<div class="shop-data-empty-hint">暂无重复 ASIN。请在左侧筛选条件后点击"查询"生效范围,再点击"重新分析"。</div>';
|
list.innerHTML = '<div class="shop-data-empty-hint">暂无重复 ASIN。请在筛选条件后点击"查询",或"重新分析"。' +
|
||||||
|
'<span id="dupEmptyHint"></span></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = shopDataDuplicateItems.map(renderShopDataDuplicateCard).join('');
|
var header = [
|
||||||
|
{ label: 'ASIN', width: '14%' },
|
||||||
|
{ label: '店铺数', width: '8%' },
|
||||||
|
{ label: '记录条数', width: '10%' },
|
||||||
|
{ label: '品牌', width: '12%' },
|
||||||
|
{ label: '对应店铺', width: '22%' },
|
||||||
|
{ label: '国家', width: '14%' },
|
||||||
|
{ label: '上架时间', width: '12%' },
|
||||||
|
{ label: '操作', width: '8%' }
|
||||||
|
];
|
||||||
|
var tbody = shopDataDuplicateItems.map(function (item) {
|
||||||
|
return renderShopDataDuplicateRow(item);
|
||||||
|
}).join('');
|
||||||
|
list.innerHTML = '<table>' + shopDataDuplicateHeader(header) + '<tbody>' + tbody + '</tbody></table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShopDataDuplicateRow(item) {
|
||||||
|
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : [];
|
||||||
|
var brand = '';
|
||||||
|
var countries = {};
|
||||||
|
var shops = {};
|
||||||
|
occurrences.forEach(function (occ) {
|
||||||
|
if (occ.brand && !brand) brand = occ.brand;
|
||||||
|
(occ.country_codes || []).forEach(function (c) { countries[c] = true; });
|
||||||
|
shops[occ.shop_name || '-'] = true;
|
||||||
|
});
|
||||||
|
var countryLabel = countryListLabel(Object.keys(countries));
|
||||||
|
var shopList = Object.keys(shops).join('、');
|
||||||
|
var dateList = occurrences.map(function (occ) { return occ.date; })
|
||||||
|
.filter(function (d) { return d; }).join('、');
|
||||||
|
return '<tr data-duplicate-asin="' + escapeHtml(item.asin) + '">' +
|
||||||
|
'<td><span class="dup-asin">' + escapeHtml(item.asin) + '</span></td>' +
|
||||||
|
'<td><span class="dup-count">' + (item.shop_count || 1) + ' 家</span></td>' +
|
||||||
|
'<td>' + (item.record_count || occurrences.length) + ' 条</td>' +
|
||||||
|
'<td>' + escapeHtml(brand || '-') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(shopList || '-') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(countryLabel || '-') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(dateList || '-') + '</td>' +
|
||||||
|
'<td><button class="btn btn-sm" type="button" data-dup-toggle>详情</button></td>' +
|
||||||
|
'</tr>' + renderShopDataDuplicateDetailRows(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShopDataDuplicateDetailRows(item) {
|
||||||
|
var occurrences = Array.isArray(item.occurrences) ? item.occurrences : [];
|
||||||
|
if (!occurrences.length) return '';
|
||||||
|
var rows = occurrences.map(function (occ) {
|
||||||
|
var countries = countryListLabel(occ.country_codes);
|
||||||
|
return '<tr class="dup-detail-row" style="display:none;">' +
|
||||||
|
'<td></td>' +
|
||||||
|
'<td class="dup-shop">' + escapeHtml(occ.shop_name || '-') + '</td>' +
|
||||||
|
'<td class="dup-country">' + escapeHtml(occ.group_name || '-') + '</td>' +
|
||||||
|
'<td class="dup-country">' + escapeHtml(countries) + '</td>' +
|
||||||
|
'<td class="dup-date">' + escapeHtml(occ.date || '-') + '</td>' +
|
||||||
|
'<td class="dup-date">' + escapeHtml(occ.price || '-') + '</td>' +
|
||||||
|
'<td>' + escapeHtml(occ.brand || '-') + '</td>' +
|
||||||
|
'<td></td>' +
|
||||||
|
'</tr>';
|
||||||
|
}).join('');
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadShopDataDuplicateAsins(page) {
|
function loadShopDataDuplicateAsins(page) {
|
||||||
@@ -1950,6 +2009,8 @@
|
|||||||
shopDataDuplicateAnalyzed.resultCount = Number(res.analyzed_result_count) || 0;
|
shopDataDuplicateAnalyzed.resultCount = Number(res.analyzed_result_count) || 0;
|
||||||
var totalEl = document.getElementById('shopDataDuplicateTotal');
|
var totalEl = document.getElementById('shopDataDuplicateTotal');
|
||||||
totalEl.textContent = '共 ' + shopDataDuplicateTotal + ' 个重复 ASIN · 已分析 ' + shopDataDuplicateAnalyzed.shopCount + ' 家店铺';
|
totalEl.textContent = '共 ' + shopDataDuplicateTotal + ' 个重复 ASIN · 已分析 ' + shopDataDuplicateAnalyzed.shopCount + ' 家店铺';
|
||||||
|
var tabCount = document.getElementById('shopDataDuplicateTabCount');
|
||||||
|
if (tabCount) tabCount.textContent = shopDataDuplicateTotal;
|
||||||
renderShopDataDuplicateList();
|
renderShopDataDuplicateList();
|
||||||
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal, shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateAsins);
|
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal, shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateAsins);
|
||||||
})
|
})
|
||||||
@@ -2177,24 +2238,32 @@
|
|||||||
document.getElementById('shopDataSubTabRecords').onclick = function () { switchShopDataSubTab('records'); };
|
document.getElementById('shopDataSubTabRecords').onclick = function () { switchShopDataSubTab('records'); };
|
||||||
document.getElementById('shopDataSubTabDuplicates').onclick = function () { switchShopDataSubTab('duplicates'); };
|
document.getElementById('shopDataSubTabDuplicates').onclick = function () { switchShopDataSubTab('duplicates'); };
|
||||||
document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
|
document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
|
||||||
document.getElementById('shopDataTaskSelectAll').onchange = function (event) {
|
document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
|
||||||
shopDataTasks.forEach(function (item) {
|
document.getElementById('btnResetShopDataDuplicates').onclick = function () {
|
||||||
var resultId = shopDataResultId(item);
|
['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo']
|
||||||
if (!item.file_ready || !resultId) return;
|
.forEach(function (id) { document.getElementById(id).value = ''; });
|
||||||
if (event.target.checked) selectedShopDataResultIds.add(resultId);
|
loadShopDataDuplicateAsins(1);
|
||||||
else selectedShopDataResultIds.delete(resultId);
|
|
||||||
});
|
|
||||||
syncShopDataSelectionUi();
|
|
||||||
};
|
};
|
||||||
document.getElementById('btnBatchDownloadShopDataTasks').onclick = downloadShopDataTasksZip;
|
// 全选移到表格表头后为动态元素,用事件委托
|
||||||
document.getElementById('shopDataTaskGrid').onchange = function (event) {
|
document.getElementById('shopDataTaskGrid').onchange = function (event) {
|
||||||
var checkbox = event.target.closest('[data-shop-data-select]');
|
var checkbox = event.target.closest('[data-shop-data-select]');
|
||||||
if (!checkbox) return;
|
if (checkbox) {
|
||||||
var resultId = Number(checkbox.dataset.shopDataSelect);
|
var resultId = Number(checkbox.dataset.shopDataSelect);
|
||||||
if (!resultId) return;
|
if (!resultId) return;
|
||||||
if (checkbox.checked) selectedShopDataResultIds.add(resultId);
|
if (checkbox.checked) selectedShopDataResultIds.add(resultId);
|
||||||
else selectedShopDataResultIds.delete(resultId);
|
else selectedShopDataResultIds.delete(resultId);
|
||||||
syncShopDataSelectionUi();
|
syncShopDataSelectionUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.target.closest('#shopDataTaskSelectAll')) {
|
||||||
|
shopDataTasks.forEach(function (item) {
|
||||||
|
var sid = shopDataResultId(item);
|
||||||
|
if (!item.file_ready || !sid) return;
|
||||||
|
if (event.target.checked) selectedShopDataResultIds.add(sid);
|
||||||
|
else selectedShopDataResultIds.delete(sid);
|
||||||
|
});
|
||||||
|
syncShopDataSelectionUi();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.getElementById('shopDataTaskGrid').onclick = function (event) {
|
document.getElementById('shopDataTaskGrid').onclick = function (event) {
|
||||||
var downloadButton = event.target.closest('[data-shop-data-download]');
|
var downloadButton = event.target.closest('[data-shop-data-download]');
|
||||||
@@ -2209,6 +2278,29 @@
|
|||||||
deleteShopDataTask(deleteItem);
|
deleteShopDataTask(deleteItem);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
document.getElementById('btnBatchDownloadShopDataTasks').onclick = downloadShopDataTasksZip;
|
||||||
|
document.getElementById('shopDataDuplicateList').onclick = function (event) {
|
||||||
|
var toggle = event.target.closest('[data-dup-toggle]');
|
||||||
|
if (!toggle) return;
|
||||||
|
var row = toggle.closest('tr');
|
||||||
|
var allDetailRows = document.querySelectorAll('.dup-detail-row');
|
||||||
|
// 若当前 ASIN 的详情行已展开,则本次点击收起
|
||||||
|
var cur = row ? row.nextElementSibling : null;
|
||||||
|
var wasExpanded = false;
|
||||||
|
while (cur && cur.classList && cur.classList.contains('dup-detail-row')) {
|
||||||
|
if (cur.style.display !== 'none') { wasExpanded = true; break; }
|
||||||
|
cur = cur.nextElementSibling;
|
||||||
|
}
|
||||||
|
allDetailRows.forEach(function (dr) { dr.style.display = 'none'; });
|
||||||
|
if (row && !wasExpanded) {
|
||||||
|
// 展开当前 ASIN 的详情行(紧随其后)
|
||||||
|
cur = row.nextElementSibling;
|
||||||
|
while (cur && cur.classList && cur.classList.contains('dup-detail-row')) {
|
||||||
|
cur.style.display = '';
|
||||||
|
cur = cur.nextElementSibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
document.getElementById('btnOpenShopDataTaskPermissions').onclick = openShopDataTaskPermissions;
|
document.getElementById('btnOpenShopDataTaskPermissions').onclick = openShopDataTaskPermissions;
|
||||||
document.getElementById('btnCloseShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
document.getElementById('btnCloseShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
||||||
document.getElementById('btnCancelShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
document.getElementById('btnCancelShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
||||||
@@ -6408,8 +6500,6 @@
|
|||||||
currentUserRole = item.role || currentUserRole;
|
currentUserRole = item.role || currentUserRole;
|
||||||
currentUserUsername = item.username || currentUserUsername;
|
currentUserUsername = item.username || currentUserUsername;
|
||||||
nameEl.textContent = item.username || '管理员';
|
nameEl.textContent = item.username || '管理员';
|
||||||
var avatarEl = document.getElementById('adminUserAvatar');
|
|
||||||
if (avatarEl) avatarEl.textContent = (item.username || '管').charAt(0).toUpperCase();
|
|
||||||
if (item.role) {
|
if (item.role) {
|
||||||
roleEl.textContent = item.role === 'super_admin'
|
roleEl.textContent = item.role === 'super_admin'
|
||||||
? '超级管理员'
|
? '超级管理员'
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -36,6 +36,12 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
|||||||
{'shop_name': 'Shop C', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 3)},
|
{'shop_name': 'Shop C', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 3)},
|
||||||
]
|
]
|
||||||
# 每家店一个结果文件:Shop A 与 Shop B 共享 ASIN1;Shop C 单独 ASIN3
|
# 每家店一个结果文件:Shop A 与 Shop B 共享 ASIN1;Shop C 单独 ASIN3
|
||||||
|
# 国家码按店铺实际 sheet:A=UK+DE、B=仅UK、C=FR
|
||||||
|
self.shop_country = {
|
||||||
|
'Shop A': '["UK","DE"]',
|
||||||
|
'Shop B': '["UK"]',
|
||||||
|
'Shop C': '["FR"]',
|
||||||
|
}
|
||||||
self.shop_files = {
|
self.shop_files = {
|
||||||
'Shop A': _make_workbook({
|
'Shop A': _make_workbook({
|
||||||
'英国': [('2026-08-30', 'B0ABC111', 'GBP 9.99', 'BrandA'),
|
'英国': [('2026-08-30', 'B0ABC111', 'GBP 9.99', 'BrandA'),
|
||||||
@@ -130,7 +136,8 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
|||||||
def _run_request(self, query=''):
|
def _run_request(self, query=''):
|
||||||
cursor = self._FakeCursor(
|
cursor = self._FakeCursor(
|
||||||
self.group_rows,
|
self.group_rows,
|
||||||
[self._result_row(i + 1, row['shop_name'], '["UK","DE"]') for i, row in enumerate(self.group_rows)],
|
[self._result_row(i + 1, row['shop_name'], self.shop_country[row['shop_name']])
|
||||||
|
for i, row in enumerate(self.group_rows)],
|
||||||
)
|
)
|
||||||
connection = self._FakeConnection(cursor)
|
connection = self._FakeConnection(cursor)
|
||||||
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?' + query):
|
with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?' + query):
|
||||||
@@ -151,6 +158,7 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
|||||||
item = body['items'][0]
|
item = body['items'][0]
|
||||||
self.assertEqual(item['asin'], 'B0ABC111')
|
self.assertEqual(item['asin'], 'B0ABC111')
|
||||||
self.assertEqual(item['shop_count'], 2)
|
self.assertEqual(item['shop_count'], 2)
|
||||||
|
self.assertEqual(item['record_count'], 3) # ShopA 英国/德国 + ShopB 英国 共 3 条
|
||||||
shops = {occ['shop_name'] for occ in item['occurrences']}
|
shops = {occ['shop_name'] for occ in item['occurrences']}
|
||||||
self.assertEqual(shops, {'Shop A', 'Shop B'})
|
self.assertEqual(shops, {'Shop A', 'Shop B'})
|
||||||
# 国家与日期从行/表头正确映射
|
# 国家与日期从行/表头正确映射
|
||||||
@@ -158,6 +166,38 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
|||||||
self.assertIn('UK', shop_a['country_codes'])
|
self.assertIn('UK', shop_a['country_codes'])
|
||||||
self.assertEqual(shop_a['date'], '2026-08-30')
|
self.assertEqual(shop_a['date'], '2026-08-30')
|
||||||
|
|
||||||
|
def test_filters_asin_and_date_range(self):
|
||||||
|
# asin 模糊:匹配 B0ABC222 的只有 Shop B 一家,不构成跨店重复 → total 0
|
||||||
|
response = self._run_request('page=1&page_size=10&asin=B0ABC222')
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body['total'], 0)
|
||||||
|
# 日期范围 08-31:B0ABC111 在 Shop B 有该日记录 → 命中,展示完整记录(跨店仍 2 家)
|
||||||
|
response = self._run_request('page=1&page_size=10&date_from=2026-08-31&date_to=2026-08-31')
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body['total'], 1)
|
||||||
|
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||||
|
self.assertEqual(body['items'][0]['shop_count'], 2) # 完整记录仍跨店
|
||||||
|
# 日期范围 08-30:Shop A 两条命中,同样返回完整记录
|
||||||
|
response = self._run_request('page=1&page_size=10&date_from=2026-08-30T00:00&date_to=2026-08-30T23:59')
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body['total'], 1)
|
||||||
|
|
||||||
|
def test_filters_shop_and_country(self):
|
||||||
|
# 店铺过滤 Shop B:命中含 Shop B 记录的 ASIN → B0ABC111,展示完整记录跨店
|
||||||
|
response = self._run_request('page=1&page_size=10&shop_name=Shop+B')
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body['total'], 1)
|
||||||
|
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||||
|
# 国家过滤 FR:只有 Shop C 有 FR,其 ASIN 仅单店 → 不构成重复 → total 0
|
||||||
|
response = self._run_request('page=1&page_size=10&country=FR')
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body['total'], 0)
|
||||||
|
# 国家过滤 DE:Shop A 命中 → B0ABC111 完整记录 → total 1
|
||||||
|
response = self._run_request('page=1&page_size=10&country=DE')
|
||||||
|
body = response.get_json()
|
||||||
|
self.assertEqual(body['total'], 1)
|
||||||
|
self.assertEqual(body['items'][0]['asin'], 'B0ABC111')
|
||||||
|
|
||||||
def test_pagination_when_page_out_of_range(self):
|
def test_pagination_when_page_out_of_range(self):
|
||||||
response = self._run_request('page=2&page_size=10')
|
response = self._run_request('page=2&page_size=10')
|
||||||
body = response.get_json()
|
body = response.get_json()
|
||||||
@@ -173,6 +213,20 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
|||||||
rows = admin_api._shop_data_crawl_parse_workbook(wb)
|
rows = admin_api._shop_data_crawl_parse_workbook(wb)
|
||||||
self.assertEqual([row['asin'] for row in rows], ['B0TEST01'])
|
self.assertEqual([row['asin'] for row in rows], ['B0TEST01'])
|
||||||
|
|
||||||
|
def test_date_key_normalizes_chinese_datetime(self):
|
||||||
|
# 中文日期归一化为 ISO 供范围比较
|
||||||
|
cases = {
|
||||||
|
'2026年8月18日 上午4:34': '2026-08-18',
|
||||||
|
'2026年8月19日 05:48': '2026-08-19',
|
||||||
|
'2026-08-19': '2026-08-19',
|
||||||
|
'2026.8.19': '2026-08-19',
|
||||||
|
'2026/8/19': '2026-08-19',
|
||||||
|
'': '',
|
||||||
|
'abc': 'abc', # 无法识别时原样返回,范围比较自然失败
|
||||||
|
}
|
||||||
|
for raw, expected in cases.items():
|
||||||
|
self.assertEqual(admin_api._shop_data_date_key(raw), expected)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -75,16 +75,18 @@
|
|||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
background: linear-gradient(135deg, #6366f1, #7a8bf0);
|
overflow: hidden;
|
||||||
color: #fff;
|
background: #fff;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.35);
|
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-brand-text {
|
.admin-brand-text {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -302,21 +304,6 @@
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-avatar {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #6366f1, #7a8bf0);
|
|
||||||
color: #fff;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
box-shadow: 0 2px 6px rgba(99, 102, 241, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-name {
|
.admin-user-name {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -2532,8 +2519,7 @@
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: linear-gradient(135deg, #818cf8, #4f46e5);
|
background: #fff;
|
||||||
color: #eef2ff;
|
|
||||||
box-shadow: 0 8px 22px rgba(79, 70, 229, 0.36);
|
box-shadow: 0 8px 22px rgba(79, 70, 229, 0.36);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2680,19 +2666,6 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-user-avatar {
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
background: linear-gradient(135deg, #818cf8, #4f46e5);
|
|
||||||
color: #eef2ff;
|
|
||||||
box-shadow: 0 6px 16px rgba(79, 70, 229, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-name {
|
|
||||||
color: #dbe4f1;
|
|
||||||
font-weight: 550;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-role {
|
.admin-user-role {
|
||||||
color: #c7d2fe;
|
color: #c7d2fe;
|
||||||
background: rgba(129, 140, 248, 0.15);
|
background: rgba(129, 140, 248, 0.15);
|
||||||
@@ -3484,10 +3457,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.admin-brand { border-bottom-color: #d5ded7; }
|
.admin-brand { border-bottom-color: #d5ded7; }
|
||||||
.admin-brand-mark,
|
.admin-brand-mark {
|
||||||
.admin-user-avatar {
|
background: #fff;
|
||||||
background: linear-gradient(135deg, #7f998a, #607a6d);
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 8px 20px rgba(96, 122, 109, 0.25);
|
box-shadow: 0 8px 20px rgba(96, 122, 109, 0.25);
|
||||||
}
|
}
|
||||||
.admin-brand-name { color: var(--c-text); }
|
.admin-brand-name { color: var(--c-text); }
|
||||||
@@ -3685,10 +3656,8 @@
|
|||||||
box-shadow: 14px 0 34px -28px rgba(39, 67, 94, 0.34);
|
box-shadow: 14px 0 34px -28px rgba(39, 67, 94, 0.34);
|
||||||
}
|
}
|
||||||
.admin-brand { border-bottom-color: #d4e0eb; }
|
.admin-brand { border-bottom-color: #d4e0eb; }
|
||||||
.admin-brand-mark,
|
.admin-brand-mark {
|
||||||
.admin-user-avatar {
|
background: #fff;
|
||||||
background: linear-gradient(135deg, #7094ba, #4f78a5);
|
|
||||||
color: #ffffff;
|
|
||||||
box-shadow: 0 8px 20px rgba(79, 120, 165, 0.24);
|
box-shadow: 0 8px 20px rgba(79, 120, 165, 0.24);
|
||||||
}
|
}
|
||||||
.admin-brand-name { color: var(--c-text); }
|
.admin-brand-name { color: var(--c-text); }
|
||||||
@@ -4022,7 +3991,7 @@
|
|||||||
<div class="admin-layout">
|
<div class="admin-layout">
|
||||||
<aside class="admin-sidebar">
|
<aside class="admin-sidebar">
|
||||||
<div class="admin-brand">
|
<div class="admin-brand">
|
||||||
<span class="admin-brand-mark" aria-hidden="true">数</span>
|
<span class="admin-brand-mark"><img class="admin-brand-logo" src="/static/logo.jpg" alt="数富AI"></span>
|
||||||
<span class="admin-brand-text">
|
<span class="admin-brand-text">
|
||||||
<span class="admin-brand-name">数富AI</span>
|
<span class="admin-brand-name">数富AI</span>
|
||||||
<span class="admin-brand-sub">电商运营管理后台</span>
|
<span class="admin-brand-sub">电商运营管理后台</span>
|
||||||
@@ -4091,7 +4060,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-user-box">
|
<div class="admin-user-box">
|
||||||
<span class="admin-user-avatar" id="adminUserAvatar" aria-hidden="true">管</span>
|
|
||||||
<span class="admin-user-name" id="adminCurrentUsername">加载中...</span>
|
<span class="admin-user-name" id="adminCurrentUsername">加载中...</span>
|
||||||
<span class="admin-user-role" id="adminCurrentUserRole" style="display:none;"></span>
|
<span class="admin-user-role" id="adminCurrentUserRole" style="display:none;"></span>
|
||||||
<button class="admin-user-logout" id="btnAdminLogout" type="button">退出登录</button>
|
<button class="admin-user-logout" id="btnAdminLogout" type="button">退出登录</button>
|
||||||
@@ -4716,7 +4684,7 @@
|
|||||||
<button class="shop-data-sub-tab active" type="button" id="shopDataSubTabRecords"
|
<button class="shop-data-sub-tab active" type="button" id="shopDataSubTabRecords"
|
||||||
role="tab" aria-selected="true">店铺数据</button>
|
role="tab" aria-selected="true">店铺数据</button>
|
||||||
<button class="shop-data-sub-tab" type="button" id="shopDataSubTabDuplicates"
|
<button class="shop-data-sub-tab" type="button" id="shopDataSubTabDuplicates"
|
||||||
role="tab" aria-selected="false">重复 ASIN</button>
|
role="tab" aria-selected="false">重复ASIN(<span id="shopDataDuplicateTabCount">0</span>)</button>
|
||||||
</div>
|
</div>
|
||||||
<h4 style="margin-bottom:14px;">筛选条件</h4>
|
<h4 style="margin-bottom:14px;">筛选条件</h4>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
@@ -4755,10 +4723,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
批量下载
|
批量下载
|
||||||
</button>
|
</button>
|
||||||
<label class="image-video-select-all">
|
|
||||||
<input type="checkbox" id="shopDataTaskSelectAll">
|
|
||||||
全选当前页
|
|
||||||
</label>
|
|
||||||
<span class="image-video-download-progress" id="shopDataTaskDownloadProgress"
|
<span class="image-video-download-progress" id="shopDataTaskDownloadProgress"
|
||||||
aria-live="polite"></span>
|
aria-live="polite"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -4769,6 +4733,30 @@
|
|||||||
<div class="pagination" id="shopDataTaskPagination"></div>
|
<div class="pagination" id="shopDataTaskPagination"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="shopDataDuplicatesView" style="display:none;">
|
<div id="shopDataDuplicatesView" style="display:none;">
|
||||||
|
<div class="form-box" style="margin-bottom:14px;">
|
||||||
|
<h4 style="margin-bottom:12px;">筛选条件</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="min-width:150px;"><label>ASIN</label><input type="text"
|
||||||
|
id="shopDataDupFilterAsin" placeholder="模糊搜索 ASIN"></div>
|
||||||
|
<div class="form-group" style="min-width:150px;"><label>店铺</label><input type="text"
|
||||||
|
id="shopDataDupFilterShop" placeholder="模糊搜索店铺名"></div>
|
||||||
|
<div class="form-group" style="min-width:120px;"><label>国家</label><select
|
||||||
|
id="shopDataDupFilterCountry">
|
||||||
|
<option value="">全部国家</option>
|
||||||
|
<option value="DE">德国</option>
|
||||||
|
<option value="UK">英国</option>
|
||||||
|
<option value="FR">法国</option>
|
||||||
|
<option value="IT">意大利</option>
|
||||||
|
<option value="ES">西班牙</option>
|
||||||
|
</select></div>
|
||||||
|
<div class="form-group" style="min-width:170px;"><label>上架时间开始</label><input
|
||||||
|
type="datetime-local" id="shopDataDupFilterDateFrom"></div>
|
||||||
|
<div class="form-group" style="min-width:170px;"><label>上架时间结束</label><input
|
||||||
|
type="datetime-local" id="shopDataDupFilterDateTo"></div>
|
||||||
|
<button class="btn" id="btnFilterShopDataDuplicates" type="button">查询</button>
|
||||||
|
<button class="btn btn-secondary" id="btnResetShopDataDuplicates" type="button">重置</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="image-video-results">
|
<div class="image-video-results">
|
||||||
<div class="image-video-toolbar">
|
<div class="image-video-toolbar">
|
||||||
<div class="image-video-toolbar-main">
|
<div class="image-video-toolbar-main">
|
||||||
@@ -4779,7 +4767,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="image-video-summary" id="shopDataDuplicateTotal"></span>
|
<span class="image-video-summary" id="shopDataDuplicateTotal"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="duplicate-asin-list" id="shopDataDuplicateList" aria-live="polite"></div>
|
<div class="table-scroll shop-data-record-table-scroll" id="shopDataDuplicateList" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pagination" id="shopDataDuplicatePagination"></div>
|
<div class="pagination" id="shopDataDuplicatePagination"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user