- 列表全选复选框从工具栏移到表格表头,事件委托处理动态元素 - 重复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):
|
||||
"""从 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()
|
||||
response = _get_backend_java_session().get(
|
||||
url,
|
||||
@@ -1738,6 +1740,29 @@ def _shop_data_crawl_cell_text(cell):
|
||||
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):
|
||||
"""从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。"""
|
||||
rows = []
|
||||
@@ -1781,11 +1806,11 @@ def shop_data_crawl_duplicate_asins():
|
||||
try:
|
||||
page = max(1, int(request.args.get('page', 1)))
|
||||
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()
|
||||
group_name = (request.args.get('group_name') or request.args.get('group') or '').strip()
|
||||
country = (request.args.get('country') or '').strip().upper()
|
||||
created_from = _parse_admin_datetime_arg('created_from')
|
||||
created_to = _parse_admin_datetime_arg('created_to')
|
||||
shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
|
||||
country_filter = (request.args.get('country') or '').strip().upper()
|
||||
asin_filter = (request.args.get('asin') or '').strip().upper()
|
||||
date_from = (request.args.get('date_from') or '').strip()[:10]
|
||||
date_to = (request.args.get('date_to') or '').strip()[:10]
|
||||
|
||||
conditions = [
|
||||
"r.module_type = 'SHOP_DATA_CRAWL'",
|
||||
@@ -1793,34 +1818,6 @@ def shop_data_crawl_duplicate_asins():
|
||||
"TRIM(COALESCE(r.result_file_url, '')) <> ''",
|
||||
]
|
||||
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)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
@@ -1907,10 +1904,13 @@ def shop_data_crawl_duplicate_asins():
|
||||
current_app.logger.warning(
|
||||
'[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc)
|
||||
|
||||
# 按 ASIN 聚合其出现的店铺/分组/国家与行细节
|
||||
# 先全量聚合所有行,再用筛选条件圈定「命中 ASIN」;
|
||||
# 命中且跨店重复的 ASIN 展示完整 occurrences(方便看该 ASIN 与哪些店重复)。
|
||||
asin_occurrences = {}
|
||||
matched_asins = set()
|
||||
for shop_item in shop_items:
|
||||
shop_name = shop_item['shop_name'] or '未命名'
|
||||
country_codes = shop_item['country_codes'] or []
|
||||
for row in shop_item['rows']:
|
||||
asin = row['asin'].strip().upper()
|
||||
if not asin:
|
||||
@@ -1924,15 +1924,31 @@ def shop_data_crawl_duplicate_asins():
|
||||
'group_name': shop_item['group_name'],
|
||||
'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 = []
|
||||
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})
|
||||
if shop_count < 2:
|
||||
continue
|
||||
occurrences_list.append({
|
||||
'asin': asin,
|
||||
'shop_count': shop_count,
|
||||
'record_count': len(occurrences),
|
||||
'occurrences': occurrences,
|
||||
})
|
||||
occurrences_list.sort(key=lambda item: (-item['shop_count'], item['asin']))
|
||||
|
||||
+110
-20
@@ -1784,7 +1784,7 @@
|
||||
}
|
||||
return '<table>' +
|
||||
'<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:14%;">分组</th>' +
|
||||
'<th style="width:16%;">国家</th>' +
|
||||
@@ -1870,11 +1870,11 @@
|
||||
params.set('page', String(page || 1));
|
||||
params.set('page_size', String(shopDataDuplicatePageSize));
|
||||
var values = {
|
||||
shop_name: document.getElementById('shopDataTaskFilterShop').value.trim(),
|
||||
group_name: document.getElementById('shopDataTaskFilterGroup').value.trim(),
|
||||
country: document.getElementById('shopDataTaskFilterCountry').value.trim(),
|
||||
created_from: document.getElementById('shopDataTaskFilterFrom').value,
|
||||
created_to: document.getElementById('shopDataTaskFilterTo').value
|
||||
asin: document.getElementById('shopDataDupFilterAsin').value.trim(),
|
||||
shop_name: document.getElementById('shopDataDupFilterShop').value.trim(),
|
||||
country: document.getElementById('shopDataDupFilterCountry').value.trim(),
|
||||
date_from: document.getElementById('shopDataDupFilterDateFrom').value,
|
||||
date_to: document.getElementById('shopDataDupFilterDateTo').value
|
||||
};
|
||||
Object.keys(values).forEach(function (key) {
|
||||
if (values[key]) params.set(key, values[key]);
|
||||
@@ -1924,10 +1924,69 @@
|
||||
function renderShopDataDuplicateList() {
|
||||
var list = document.getElementById('shopDataDuplicateList');
|
||||
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;
|
||||
}
|
||||
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) {
|
||||
@@ -1950,6 +2009,8 @@
|
||||
shopDataDuplicateAnalyzed.resultCount = Number(res.analyzed_result_count) || 0;
|
||||
var totalEl = document.getElementById('shopDataDuplicateTotal');
|
||||
totalEl.textContent = '共 ' + shopDataDuplicateTotal + ' 个重复 ASIN · 已分析 ' + shopDataDuplicateAnalyzed.shopCount + ' 家店铺';
|
||||
var tabCount = document.getElementById('shopDataDuplicateTabCount');
|
||||
if (tabCount) tabCount.textContent = shopDataDuplicateTotal;
|
||||
renderShopDataDuplicateList();
|
||||
renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal, shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateAsins);
|
||||
})
|
||||
@@ -2177,24 +2238,32 @@
|
||||
document.getElementById('shopDataSubTabRecords').onclick = function () { switchShopDataSubTab('records'); };
|
||||
document.getElementById('shopDataSubTabDuplicates').onclick = function () { switchShopDataSubTab('duplicates'); };
|
||||
document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
|
||||
document.getElementById('shopDataTaskSelectAll').onchange = function (event) {
|
||||
shopDataTasks.forEach(function (item) {
|
||||
var resultId = shopDataResultId(item);
|
||||
if (!item.file_ready || !resultId) return;
|
||||
if (event.target.checked) selectedShopDataResultIds.add(resultId);
|
||||
else selectedShopDataResultIds.delete(resultId);
|
||||
});
|
||||
syncShopDataSelectionUi();
|
||||
document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); };
|
||||
document.getElementById('btnResetShopDataDuplicates').onclick = function () {
|
||||
['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo']
|
||||
.forEach(function (id) { document.getElementById(id).value = ''; });
|
||||
loadShopDataDuplicateAsins(1);
|
||||
};
|
||||
document.getElementById('btnBatchDownloadShopDataTasks').onclick = downloadShopDataTasksZip;
|
||||
// 全选移到表格表头后为动态元素,用事件委托
|
||||
document.getElementById('shopDataTaskGrid').onchange = function (event) {
|
||||
var checkbox = event.target.closest('[data-shop-data-select]');
|
||||
if (!checkbox) return;
|
||||
if (checkbox) {
|
||||
var resultId = Number(checkbox.dataset.shopDataSelect);
|
||||
if (!resultId) return;
|
||||
if (checkbox.checked) selectedShopDataResultIds.add(resultId);
|
||||
else selectedShopDataResultIds.delete(resultId);
|
||||
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) {
|
||||
var downloadButton = event.target.closest('[data-shop-data-download]');
|
||||
@@ -2209,6 +2278,29 @@
|
||||
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('btnCloseShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
||||
document.getElementById('btnCancelShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
|
||||
@@ -6408,8 +6500,6 @@
|
||||
currentUserRole = item.role || currentUserRole;
|
||||
currentUserUsername = item.username || currentUserUsername;
|
||||
nameEl.textContent = item.username || '管理员';
|
||||
var avatarEl = document.getElementById('adminUserAvatar');
|
||||
if (avatarEl) avatarEl.textContent = (item.username || '管').charAt(0).toUpperCase();
|
||||
if (item.role) {
|
||||
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 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 = {
|
||||
'Shop A': _make_workbook({
|
||||
'英国': [('2026-08-30', 'B0ABC111', 'GBP 9.99', 'BrandA'),
|
||||
@@ -130,7 +136,8 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
||||
def _run_request(self, query=''):
|
||||
cursor = self._FakeCursor(
|
||||
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)
|
||||
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]
|
||||
self.assertEqual(item['asin'], 'B0ABC111')
|
||||
self.assertEqual(item['shop_count'], 2)
|
||||
self.assertEqual(item['record_count'], 3) # ShopA 英国/德国 + ShopB 英国 共 3 条
|
||||
shops = {occ['shop_name'] for occ in item['occurrences']}
|
||||
self.assertEqual(shops, {'Shop A', 'Shop B'})
|
||||
# 国家与日期从行/表头正确映射
|
||||
@@ -158,6 +166,38 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
||||
self.assertIn('UK', shop_a['country_codes'])
|
||||
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):
|
||||
response = self._run_request('page=2&page_size=10')
|
||||
body = response.get_json()
|
||||
@@ -173,6 +213,20 @@ class ShopDataDuplicateAsinTest(unittest.TestCase):
|
||||
rows = admin_api._shop_data_crawl_parse_workbook(wb)
|
||||
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__':
|
||||
unittest.main()
|
||||
|
||||
@@ -75,16 +75,18 @@
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, #6366f1, #7a8bf0);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
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 {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
@@ -302,21 +304,6 @@
|
||||
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 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
@@ -2532,8 +2519,7 @@
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 11px;
|
||||
background: linear-gradient(135deg, #818cf8, #4f46e5);
|
||||
color: #eef2ff;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(79, 70, 229, 0.36);
|
||||
}
|
||||
|
||||
@@ -2680,19 +2666,6 @@
|
||||
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 {
|
||||
color: #c7d2fe;
|
||||
background: rgba(129, 140, 248, 0.15);
|
||||
@@ -3484,10 +3457,8 @@
|
||||
}
|
||||
|
||||
.admin-brand { border-bottom-color: #d5ded7; }
|
||||
.admin-brand-mark,
|
||||
.admin-user-avatar {
|
||||
background: linear-gradient(135deg, #7f998a, #607a6d);
|
||||
color: #ffffff;
|
||||
.admin-brand-mark {
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 20px rgba(96, 122, 109, 0.25);
|
||||
}
|
||||
.admin-brand-name { color: var(--c-text); }
|
||||
@@ -3685,10 +3656,8 @@
|
||||
box-shadow: 14px 0 34px -28px rgba(39, 67, 94, 0.34);
|
||||
}
|
||||
.admin-brand { border-bottom-color: #d4e0eb; }
|
||||
.admin-brand-mark,
|
||||
.admin-user-avatar {
|
||||
background: linear-gradient(135deg, #7094ba, #4f78a5);
|
||||
color: #ffffff;
|
||||
.admin-brand-mark {
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 20px rgba(79, 120, 165, 0.24);
|
||||
}
|
||||
.admin-brand-name { color: var(--c-text); }
|
||||
@@ -4022,7 +3991,7 @@
|
||||
<div class="admin-layout">
|
||||
<aside class="admin-sidebar">
|
||||
<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-name">数富AI</span>
|
||||
<span class="admin-brand-sub">电商运营管理后台</span>
|
||||
@@ -4091,7 +4060,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<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-role" id="adminCurrentUserRole" style="display:none;"></span>
|
||||
<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"
|
||||
role="tab" aria-selected="true">店铺数据</button>
|
||||
<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>
|
||||
<h4 style="margin-bottom:14px;">筛选条件</h4>
|
||||
<div class="form-row">
|
||||
@@ -4755,10 +4723,6 @@
|
||||
</svg>
|
||||
批量下载
|
||||
</button>
|
||||
<label class="image-video-select-all">
|
||||
<input type="checkbox" id="shopDataTaskSelectAll">
|
||||
全选当前页
|
||||
</label>
|
||||
<span class="image-video-download-progress" id="shopDataTaskDownloadProgress"
|
||||
aria-live="polite"></span>
|
||||
</div>
|
||||
@@ -4769,6 +4733,30 @@
|
||||
<div class="pagination" id="shopDataTaskPagination"></div>
|
||||
</div>
|
||||
<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-toolbar">
|
||||
<div class="image-video-toolbar-main">
|
||||
@@ -4779,7 +4767,7 @@
|
||||
</div>
|
||||
<span class="image-video-summary" id="shopDataDuplicateTotal"></span>
|
||||
</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 class="pagination" id="shopDataDuplicatePagination"></div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user