From 672b9f9b46f4533ae8deb177e738ddcd44c48cfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Thu, 3 Sep 2026 09:32:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=BA=97=E9=93=BA=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=87=8D=E5=A4=8DASIN=EF=BC=9A=E5=85=A8=E9=80=89?= =?UTF-8?q?=E7=A7=BB=E8=87=B3=E8=A1=A8=E5=A4=B4=E3=80=81Tab=E6=80=BB?= =?UTF-8?q?=E6=95=B0=E3=80=81=E7=8B=AC=E7=AB=8B=E7=AD=9B=E9=80=89=E3=80=81?= =?UTF-8?q?=E6=8C=89ASIN=E5=88=86=E7=BB=84=E8=A1=A8=E6=A0=BC=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列表全选复选框从工具栏移到表格表头,事件委托处理动态元素 - 重复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 --- backend/blueprints/admin_api.py | 88 ++++++----- backend/static/admin.js | 140 ++++++++++++++---- backend/static/logo.jpg | Bin 0 -> 10772 bytes .../test_admin_shop_data_duplicate_asins.py | 56 ++++++- backend/web_source/admin.html | 94 +++++------- 5 files changed, 263 insertions(+), 115 deletions(-) create mode 100644 backend/static/logo.jpg diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index b4cacef5..cdff9425 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -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'])) diff --git a/backend/static/admin.js b/backend/static/admin.js index b01e10e8..db47d605 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -1784,7 +1784,7 @@ } return '' + '' + - '' + + '' + '' + '' + '' + @@ -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 = '
暂无重复 ASIN。请在左侧筛选条件后点击"查询"生效范围,再点击"重新分析"。
'; + list.innerHTML = '
暂无重复 ASIN。请在筛选条件后点击"查询",或"重新分析"。' + + '
'; 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 = '
店铺分组国家
' + shopDataDuplicateHeader(header) + '' + tbody + '
'; + } + + 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 '' + + '' + escapeHtml(item.asin) + '' + + '' + (item.shop_count || 1) + ' 家' + + '' + (item.record_count || occurrences.length) + ' 条' + + '' + escapeHtml(brand || '-') + '' + + '' + escapeHtml(shopList || '-') + '' + + '' + escapeHtml(countryLabel || '-') + '' + + '' + escapeHtml(dateList || '-') + '' + + '' + + '' + 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 '' + + '' + + '' + escapeHtml(occ.shop_name || '-') + '' + + '' + escapeHtml(occ.group_name || '-') + '' + + '' + escapeHtml(countries) + '' + + '' + escapeHtml(occ.date || '-') + '' + + '' + escapeHtml(occ.price || '-') + '' + + '' + escapeHtml(occ.brand || '-') + '' + + '' + + ''; + }).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; - var resultId = Number(checkbox.dataset.shopDataSelect); - if (!resultId) return; - if (checkbox.checked) selectedShopDataResultIds.add(resultId); - else selectedShopDataResultIds.delete(resultId); - syncShopDataSelectionUi(); + 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' ? '超级管理员' diff --git a/backend/static/logo.jpg b/backend/static/logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ab825d78d2d14fd25b4602fb19a8ccb814d9fcf1 GIT binary patch literal 10772 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 Q0eSltA}n%)Wgq7M2gHA@#Q*>R literal 0 HcmV?d00001 diff --git a/backend/tests/test_admin_shop_data_duplicate_asins.py b/backend/tests/test_admin_shop_data_duplicate_asins.py index 82d865ac..c9331adf 100644 --- a/backend/tests/test_admin_shop_data_duplicate_asins.py +++ b/backend/tests/test_admin_shop_data_duplicate_asins.py @@ -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() diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 3325d309..0d787af2 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -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 @@

- 加载中... @@ -4716,7 +4684,7 @@ + role="tab" aria-selected="false">重复ASIN(0)

筛选条件

@@ -4755,10 +4723,6 @@ 批量下载 -
@@ -4769,6 +4733,30 @@