diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java index 310df905..b852cba6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java @@ -71,6 +71,7 @@ public class PermissionMenuSchemaInitializer { new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50, "admin_group_shop"), new DefaultAdminMenu("最低价ASIN设置", "admin_skip_price_asin", "skip-price-asin", 60, "admin_group_shop"), new DefaultAdminMenu("店铺数据记录", "admin_shop_data_crawl_tasks", "shop-data-crawl-tasks", 82, "admin_group_shop"), + new DefaultAdminMenu("店铺数据重复检查", "admin_shop_data_duplicate_check", "shop-data-duplicate-check", 84, "admin_group_shop"), new DefaultAdminMenu("生成记录", "admin_history", "history", 70, "admin_group_record"), new DefaultAdminMenu("视频任务记录", "admin_image_video_tasks", "image-video-tasks", 75, "admin_group_record"), new DefaultAdminMenu("软件版本管理", "admin_version", "version", 80, "admin_group_record"), diff --git a/backend-java/src/main/resources/db/V106__shop_data_import_admin_menu.sql b/backend-java/src/main/resources/db/V106__shop_data_import_admin_menu.sql new file mode 100644 index 00000000..4dde9a33 --- /dev/null +++ b/backend-java/src/main/resources/db/V106__shop_data_import_admin_menu.sql @@ -0,0 +1,9 @@ +-- V106: 新增「店铺导入测试」后台菜单(用于重复 ASIN 结果模拟验证) +-- 幂等:仅当 column_key 不存在时插入,绑定到「店铺管理」分组(admin_group_shop)。 +INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id) +SELECT '店铺导入测试', 'admin_shop_data_import', 'admin', 'shop-data-import', 83, parent.id +FROM columns parent +WHERE parent.column_key = 'admin_group_shop' + AND NOT EXISTS ( + SELECT 1 FROM columns WHERE column_key = 'admin_shop_data_import' + ); diff --git a/backend-java/src/main/resources/db/V107__shop_data_duplicate_check_admin_menu.sql b/backend-java/src/main/resources/db/V107__shop_data_duplicate_check_admin_menu.sql new file mode 100644 index 00000000..398b6085 --- /dev/null +++ b/backend-java/src/main/resources/db/V107__shop_data_duplicate_check_admin_menu.sql @@ -0,0 +1,18 @@ +-- V107: 「店铺导入测试」菜单下线,新增「店铺数据重复检查」菜单 +-- 1) 删除「店铺导入测试」(admin_shop_data_import)后台菜单行与其授权记录(幂等) +DELETE FROM user_column_permission +WHERE column_id IN ( + SELECT id FROM columns WHERE column_key = 'admin_shop_data_import' +); + +DELETE FROM columns WHERE column_key = 'admin_shop_data_import'; + +-- 2) 新增「店铺数据重复检查」菜单,挂到「店铺管理」分组(admin_group_shop) +-- 幂等:仅当 column_key 不存在时插入。 +INSERT INTO columns (name, column_key, menu_type, route_path, sort_order, parent_id) +SELECT '店铺数据重复检查', 'admin_shop_data_duplicate_check', 'admin', 'shop-data-duplicate-check', 84, parent.id +FROM columns parent +WHERE parent.column_key = 'admin_group_shop' + AND NOT EXISTS ( + SELECT 1 FROM columns WHERE column_key = 'admin_shop_data_duplicate_check' + ); diff --git a/backend/app.py b/backend/app.py index 89f4474b..dae5a219 100644 --- a/backend/app.py +++ b/backend/app.py @@ -12,7 +12,7 @@ from flask_cors import CORS from utils.db import init_db from blueprints.auth import auth from blueprints.main import main -from blueprints.admin_api import admin_api +from blueprints.admin_api import admin_api, start_duplicate_scan_scheduler from blueprints.version import version_bp BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -50,6 +50,8 @@ app.register_blueprint(version_bp) def run_app(host='0.0.0.0', port=15124): init_db() + # 每日凌晨全量扫描重复 ASIN,结果缓存 MySQL,页面读取缓存不再实时拉取 Excel + start_duplicate_scan_scheduler(app) app.run(host=host, port=port, threaded=True, use_reloader=False) diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index cdff9425..42a567ee 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -8,8 +8,9 @@ import re import secrets import tempfile import threading +import time import zipfile -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from urllib.parse import quote @@ -52,6 +53,49 @@ IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data' SHOP_DATA_CRAWL_DATA_PERMISSION_KEY = 'admin_shop_data_crawl_task_data' +# ---------- 拼音首字母(分组管理通讯录用,无第三方依赖) ---------- +# GB2312 区位码按拼音音序排列,用码位区间即可精确判定首字母;仅覆盖 GB2312 汉字。 +_PYINYIN_BOUNDARIES = [ + ('A', 0xB0A1), ('B', 0xB0C5), ('C', 0xB2C1), ('D', 0xB4EE), ('E', 0xB6EA), + ('F', 0xB7A2), ('G', 0xB8C1), ('H', 0xB9FE), ('J', 0xBBF7), ('K', 0xBFA6), + ('L', 0xC0AC), ('M', 0xC2E8), ('N', 0xC4C3), ('O', 0xC5B6), ('P', 0xC5BE), + ('Q', 0xC6DA), ('R', 0xC8BB), ('S', 0xC8F6), ('T', 0xCBFA), ('W', 0xCDDA), + ('X', 0xCEF4), ('Y', 0xD1B9), ('Z', 0xD4D1), +] +_pinyin_boundaries_end = 0xF7FF + + +def _char_pinyin_initial(char): + """返回单个汉字 / 数字 / 字母的拼音首字母,非 GB2312 汉字返回 '#'。""" + code = ord(char) + if 0x30 <= code <= 0x39: # 0-9 + return str(char) + if 0x41 <= code <= 0x5A: # A-Z + return chr(code) + if 0x61 <= code <= 0x7A: # a-z + return chr(code - 32) + if 0x4E00 <= code <= 0x9FA5: # GBK 汉字范围(含 GB2312) + try: + gb = char.encode('gb2312') + except UnicodeEncodeError: + return '#' + low = (gb[0] << 8) | gb[1] + if low < 0xB0A1 or low >= _pinyin_boundaries_end: + return '#' + for letter, start in reversed(_PYINYIN_BOUNDARIES): + if low >= start: + return letter + return '#' + + +def _username_pinyin_abbr(username): + """返回用户名各字符的拼音首字母串('张伟恒' -> 'ZWH'),供通讯录分组与模糊搜索使用。""" + name = (username or '').strip() + if not name: + return '#' + return ''.join(_char_pinyin_initial(ch) for ch in name) + + def _internal_error(exc, status=500): """记录内部错误日志,仅向客户端返回通用消息,避免泄露内部信息。""" current_app.logger.error('[admin_api] internal error: %s', exc, exc_info=True) @@ -107,6 +151,11 @@ ADMIN_MENU_ACCESS_CONFIG = { 'route_path': 'shop-data-crawl-tasks', 'error': '无权访问店铺数据记录模块', }, + 'shop-data-duplicate-check': { + 'column_key': 'admin_shop_data_duplicate_check', + 'route_path': 'shop-data-duplicate-check', + 'error': '无权访问店铺数据重复检查模块', + }, } ADMIN_MENU_ACCESS_CONFIG.update({ @@ -183,16 +232,50 @@ def _backend_java_internal_headers(): return headers +def _resolve_system_operator_id(): + """无请求上下文(定时扫描线程/脚本)时,取一个系统级超级管理员作为内部操作员。 + + Java 内部端点(如结果下载)要求 operatorId 指向真实存在的管理员; + 传 0 会被 Java 判定「用户不存在」并返回 JSON 错误体。 + 优先超管(内部端点对超管直接放行);兜底任一管理员。 + """ + conn = get_db() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT id FROM users WHERE role = 'super_admin' " + "OR (is_admin = 1 AND created_by_id IS NULL) ORDER BY id LIMIT 1") + row = cur.fetchone() + if row and row.get('id'): + return int(row['id']) + cur.execute( + "SELECT id FROM users WHERE role = 'admin' OR is_admin = 1 " + "ORDER BY id LIMIT 1") + row = cur.fetchone() + if row and row.get('id'): + return int(row['id']) + finally: + conn.close() + return None + + def _backend_java_internal_request(): headers = _backend_java_internal_headers() - _, current_row = get_current_admin_role() - operator_id = _get_current_admin_id(current_row) - try: - operator_id = int(operator_id) - except (TypeError, ValueError) as exc: - raise ValueError('当前管理员身份无效') from exc - if operator_id <= 0: - raise ValueError('当前管理员身份无效') + # 无请求上下文(定时扫描线程/脚本)时没有当前管理员, + # 用系统级超级管理员代替内部操作员(传 0 会被 Java 端判定「用户不存在」) + if has_request_context(): + _, current_row = get_current_admin_role() + operator_id = _get_current_admin_id(current_row) + try: + operator_id = int(operator_id) + except (TypeError, ValueError) as exc: + raise ValueError('当前管理员身份无效') from exc + if operator_id <= 0: + raise ValueError('当前管理员身份无效') + else: + operator_id = _resolve_system_operator_id() + if not operator_id: + raise ValueError('系统管理员不存在,无法调起内部接口') return headers, {'operatorId': operator_id} @@ -824,6 +907,55 @@ def _ensure_shop_data_crawl_data_access(): return role, current_row, (jsonify({'success': False, 'error': '无权查看店铺数据任务'}), 403) +def _shop_data_managed_groups(role, current_row): + """当前主管可管理的分组 id 列表(超管返回 None 表示不限)。""" + if role == 'super_admin': + return None + admin_id = int(current_row['id']) if current_row and current_row.get('id') else 0 + if not admin_id: + return [] + conn = get_db() + try: + with conn.cursor() as cur: + cur.execute( + 'SELECT id FROM biz_shop_manage_group ' + 'WHERE created_by_id = %s OR user_id = %s', + (admin_id, admin_id), + ) + group_ids = [int(row['id']) for row in cur.fetchall() if row.get('id')] + finally: + conn.close() + return group_ids + + +def _shop_data_managed_shop_names(role, current_row): + """当前主管可见的店铺名集合(含自身创建/所属组下的店铺)。 + + 超管返回 None 表示全部;主管返回小写归一后的名字集合,用于过滤缓存明细。 + """ + group_ids = _shop_data_managed_groups(role, current_row) + if group_ids is None: + return None + if not group_ids: + return set() + conn = get_db() + try: + with conn.cursor() as cur: + placeholders = ','.join(['%s'] * len(group_ids)) + cur.execute( + f'SELECT TRIM(sm.shop_name) AS shop_name FROM biz_shop_manage sm WHERE sm.group_id IN ({placeholders})', + tuple(group_ids), + ) + shop_names = { + _shop_data_crawl_shop_key(row.get('shop_name')) + for row in cur.fetchall() + if row.get('shop_name') + } + finally: + conn.close() + return shop_names + + def _ensure_product_category_access(): role, current_row, items, denied = _load_current_backend_menu_items() if denied: @@ -1020,6 +1152,7 @@ def list_users(): 'created_by_id': r.get('created_by_id'), 'creator_username': r.get('creator_username') or '', 'created_at': r['created_at'].strftime('%Y-%m-%d %H:%M') if r.get('created_at') else '', + 'pinyin_abbr': _username_pinyin_abbr(r.get('username')), } for r in rows ] @@ -1696,6 +1829,109 @@ def _load_shop_data_crawl_download_rows(result_ids): _SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES = 256 * 1024 * 1024 _SHOP_DATA_CRAWL_ASIN_ANALYSIS_TIMEOUT = (10, 60) +# 重复 ASIN 扫描缓存:结果存 MySQL(shop_data_duplicate_scan 表), +# 定时任务每天凌晨执行一次全量扫描,页面读取最近一次结果,避免实时拉取 Excel 消耗资源。 +_DUPLICATE_SCAN_TABLE = 'shop_data_duplicate_scan' +_duplicate_scan_lock = threading.Lock() +_duplicate_scan_running = False + + +def _ensure_duplicate_scan_table(): + """按需建表:重复 ASIN 扫描结果缓存表。""" + conn = get_db() + try: + with conn.cursor() as cur: + cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {_DUPLICATE_SCAN_TABLE} ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + status VARCHAR(16) NOT NULL, + error_text TEXT NULL, + summary_json MEDIUMTEXT NULL, + payload_json MEDIUMTEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME NULL, + KEY idx_status_created (status, created_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ + ) + conn.commit() + finally: + conn.close() + + +def _save_duplicate_scan(status, summary=None, payload=None, error_text=None): + """保存一次扫描结果记录;status: SUCCESS / FAILED。""" + import json as _json + conn = get_db() + scan_id = None + try: + with conn.cursor() as cur: + cur.execute( + f'INSERT INTO {_DUPLICATE_SCAN_TABLE} (status, error_text, summary_json, payload_json, created_at, finished_at) ' + 'VALUES (%s, %s, %s, %s, NOW(), NOW())', + ( + status, + error_text, + _json.dumps(summary or {}, ensure_ascii=False), + _json.dumps(payload or [], ensure_ascii=False), + ), + ) + scan_id = cur.lastrowid + conn.commit() + return scan_id + finally: + conn.close() + + +def _latest_duplicate_scan(): + """最近一次成功扫描记录:{'scanned_at': str, 'summary': dict, 'items': list, 'shops': list}|None。 + + 兼容两种缓存格式: + - 新版(重复检查页):payload = {'shops': [...], 'items': [...]} + - 旧版(跨店重复明细):payload 直接为 items 列表,shops 由明细推导。 + """ + import json as _json + conn = get_db() + try: + with conn.cursor() as cur: + cur.execute( + f'SELECT id, summary_json, payload_json, created_at FROM {_DUPLICATE_SCAN_TABLE} ' + "WHERE status = 'SUCCESS' ORDER BY id DESC LIMIT 1" + ) + row = cur.fetchone() + finally: + conn.close() + if not row: + return None + summary_raw = row.get('summary_json') or {} + payload = row.get('payload_json') or '[]' + # summary_json / payload_json 在 MySQL 中为 JSON 字符串,读取后需反序列化 + summary = _json.loads(summary_raw) if isinstance(summary_raw, str) else (summary_raw or {}) + summary = summary if isinstance(summary, dict) else {} + payload = _json.loads(payload) if isinstance(payload, str) else (payload or []) + if isinstance(payload, dict): + shops = payload.get('shops') or [] + items = payload.get('items') or [] + else: + # 旧格式缓存:兼容旧页面,店铺概览由明细推导 + items = payload if isinstance(payload, list) else [] + shop_seen = {} + for item in items: + for occ in (item.get('occurrences') or []): + shop_name = occ.get('shop_name') or '' + shop_seen[shop_name] = shop_seen.get(shop_name, 0) + 1 + shops = [ + {'shop_name': shop_name, 'asin_count': 0, 'group_name': ''} + for shop_name in sorted(shop_seen, key=lambda name: (-shop_seen[name], name)) + ] + return { + 'scanned_at': str(row.get('created_at') or ''), + 'summary': summary, + 'items': items if isinstance(items, list) else [], + 'shops': shops if isinstance(shops, list) else [], + } + def _shop_data_crawl_fetch_result_bytes(row, timeout=None): """从 Java 下载接口拉取结果文件字节流(仅内存,不落盘)。""" @@ -1721,7 +1957,18 @@ def _shop_data_crawl_fetch_result_bytes(row, timeout=None): if total > _SHOP_DATA_CRAWL_ASIN_ANALYSIS_MAX_BYTES: raise ValueError('结果文件过大,无法分析') chunks.append(chunk) - return b''.join(chunks) + raw = b''.join(chunks) + if not raw: + raise ValueError(f'结果文件为空 result_id={result_id}') + # Java 内部端点鉴权/归属失败时返回 JSON 错误体(HTTP 200),解析报错更直观 + if raw.lstrip().startswith(b'{'): + try: + err_payload = json.loads(raw.decode('utf-8', 'replace')) + message = err_payload.get('message') or err_payload.get('error') or '未知错误' + except ValueError: + message = raw[:200].decode('utf-8', 'replace') + raise ValueError(f'结果文件下载失败 result_id={result_id}: {message}') + return raw finally: response.close() @@ -1763,8 +2010,26 @@ def _shop_data_date_key(date_text): return text +_SHOP_DATA_SHEET_COUNTRY_MAP = { + '英国': 'UK', 'uk': 'UK', 'u.k.': 'UK', 'united kingdom': 'UK', + '德国': 'DE', 'de': 'DE', 'germany': 'DE', + '法国': 'FR', 'fr': 'FR', 'france': 'FR', + '意大利': 'IT', 'it': 'IT', 'italy': 'IT', + '西班牙': 'ES', 'es': 'ES', 'spain': 'ES', +} + + +def _shop_data_sheet_country(sheet_name): + """结果文件 sheet 名 → 国家码(英国/德国/法国…);无法识别返回空串。""" + key = str(sheet_name or '').strip().lower() + return _SHOP_DATA_SHEET_COUNTRY_MAP.get(key, '') + + def _shop_data_crawl_parse_workbook(workbook): - """从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。""" + """从结果 Workbook 提取 ASIN 行:字典列表,表头按 日期/ASIN/…/价格/…/品牌 识别,跳过无 ASIN 行。 + + 每行附带所在 sheet 映射的国家码 country(记录级站点,供重复检查页按站点筛选/展示)。 + """ rows = [] for sheet in workbook.worksheets: header_cells = list(next(sheet.iter_rows(min_row=1, max_row=1), [])) @@ -1776,6 +2041,7 @@ def _shop_data_crawl_parse_workbook(workbook): date_col = header.index('日期') if '日期' in header else None price_col = header.index('价格') if '价格' in header else None brand_col = header.index('品牌') if '品牌' in header else None + sheet_country = _shop_data_sheet_country(sheet.title) for sheet_row in sheet.iter_rows(min_row=2): asin = _shop_data_crawl_cell_text(sheet_row[asin_col]) if not asin: @@ -1785,18 +2051,480 @@ def _shop_data_crawl_parse_workbook(workbook): 'date': _shop_data_crawl_cell_text(sheet_row[date_col]) if date_col is not None else '', 'price': _shop_data_crawl_cell_text(sheet_row[price_col]) if price_col is not None else '', 'brand': _shop_data_crawl_cell_text(sheet_row[brand_col]) if brand_col is not None else '', + 'country': sheet_country, }) return rows +def _shops_with_latest_results(): + """查询每家店铺最新结果行(用于重复 ASIN 扫描)。返回 shop_items 列表。""" + conditions = [ + "r.module_type = 'SHOP_DATA_CRAWL'", + "t.module_type = 'SHOP_DATA_CRAWL'", + "TRIM(COALESCE(r.result_file_url, '')) <> ''", + ] + where_sql = ' AND '.join(conditions) + conn = get_db() + try: + with conn.cursor() as cur: + shop_key_sql = "TRIM(COALESCE(r.source_filename, ''))" + grouped_from_sql = ( + ' FROM biz_file_result r ' + 'JOIN biz_file_task t ON t.id = r.task_id ' + 'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id ' + 'LEFT JOIN users u ON u.id = r.user_id ' + 'WHERE ' + where_sql + ) + cur.execute( + 'SELECT ' + shop_key_sql + ' AS shop_name, MAX(' + + _SHOP_DATA_CRAWL_LATEST_TIME_SQL + ') AS latest_created_at' + + grouped_from_sql + + ' GROUP BY ' + shop_key_sql + + ' ORDER BY latest_created_at DESC, shop_name ASC', + ) + group_rows = cur.fetchall() + group_names = _shop_data_crawl_group_names(cur, group_rows) + result_rows_by_shop = {} + selected_shop_names = [row.get('shop_name') for row in group_rows] + if selected_shop_names: + placeholders = ','.join(['%s'] * len(selected_shop_names)) + cur.execute( + 'SELECT ranked.* FROM (SELECT ' + _SHOP_DATA_CRAWL_ADMIN_COLUMNS + + ', ROW_NUMBER() OVER (PARTITION BY ' + shop_key_sql + + ' ORDER BY ' + _SHOP_DATA_CRAWL_LATEST_TIME_SQL + + ' DESC, r.id DESC) AS shop_row_number ' + ' FROM biz_file_result r ' + 'JOIN biz_file_task t ON t.id = r.task_id ' + 'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id ' + 'LEFT JOIN users u ON u.id = r.user_id ' + 'WHERE ' + where_sql + + f' AND {shop_key_sql} IN ({placeholders})' + + ') ranked WHERE ranked.shop_row_number <= 1 ' + 'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC', + tuple(selected_shop_names), + ) + for row in cur.fetchall(): + shop_key = _shop_data_crawl_shop_key(row.get('shop_name')) + result_rows_by_shop.setdefault(shop_key, []).append(row) + finally: + conn.close() + + # 逐店读取结果文件并解析 + shop_items = [] + for rows in result_rows_by_shop.values(): + for row in rows: + result_id = int(row.get('result_id') or 0) + if result_id <= 0: + continue + try: + raw = _shop_data_crawl_fetch_result_bytes(row) + try: + parsed = _shop_data_crawl_parse_workbook( + load_workbook(io.BytesIO(raw), read_only=True, data_only=True)) + except (InvalidFileException, KeyError, ValueError, zipfile.BadZipFile) as exc: + current_app.logger.warning( + '[shop-data-crawl] 解析结果文件失败 result_id=%s: %s', result_id, exc) + continue + shop_items.append({ + 'shop_name': row.get('shop_name') or '未命名', + 'group_name': _shop_data_crawl_group_name(group_names, row.get('shop_name')), + 'country_codes': _shop_data_crawl_country_codes_from_json(row.get('country_codes_json')) + or _shop_data_crawl_country_codes(row.get('request_json')), + 'rows': parsed, + }) + except (requests.RequestException, ValueError) as exc: + current_app.logger.warning( + '[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc) + return shop_items + + +def _build_duplicate_scan_cache(shop_items, source='job'): + """由 shop_items 构建全量重复检查缓存:shops 概览 + 明细 items + 指标统计。 + + 返回 {'shops': [...], 'items': [...], 'summary': {唯一ASIN/上架记录/重复ASIN…}} + items 内 occurrences 记录带 country(结果文件 sheet 映射的站点)。 + """ + asin_occurrences = {} + shop_agg = {} + for shop_item in shop_items: + shop_name = shop_item['shop_name'] or '未命名' + group_name = shop_item['group_name'] or '' + country_codes = shop_item['country_codes'] or [] + shop_agg[shop_name] = {'group_name': group_name, 'country_codes': country_codes} + for row in shop_item['rows']: + asin = row['asin'].strip().upper() + if not asin: + continue + asin_occurrences.setdefault(asin, []).append({ + 'asin': asin, + 'date': row['date'], + 'price': row['price'], + 'brand': row['brand'], + 'shop_name': shop_name, + 'group_name': group_name, + 'country_codes': country_codes, + 'country': row.get('country') or '', + }) + items = [] + total_records = 0 + for asin, occurrences in asin_occurrences.items(): + shop_count = len({item['shop_name'] for item in occurrences}) + total_records += len(occurrences) + items.append({ + 'asin': asin, + 'shop_count': shop_count, + 'record_count': len(occurrences), + 'occurrences': occurrences, + }) + items.sort(key=lambda item: (-item['shop_count'], item['asin'])) + repeated = sum(1 for item in items if item['shop_count'] >= 2) + + shops = [] + for shop_name, agg in sorted(shop_agg.items()): + shop_asin_set = { + occ['asin'] + for occ_list in (oo for oo in asin_occurrences.values()) + for occ in occ_list + if occ['shop_name'] == shop_name + } + shops.append({ + 'shop_name': shop_name, + 'group_name': agg['group_name'], + 'country_codes': agg['country_codes'], + 'asin_count': len(shop_asin_set), + 'record_count': sum(1 for occ_list in asin_occurrences.values() for occ in occ_list if occ['shop_name'] == shop_name), + }) + shops.sort(key=lambda shop: (-shop['asin_count'], shop['shop_name'])) + + summary = { + 'shop_count': len(shops), + 'asin_total': len(items), + 'record_total': total_records, + 'duplicate_asin_total': repeated, + 'duplicate_shop_count': len({occ['shop_name'] for item in items if item['shop_count'] >= 2 for occ in item['occurrences']}), + 'site_count': len({occ['country'] for occ_list in asin_occurrences.values() for occ in occ_list if occ.get('country')}), + 'asin_per_shop': round(float(total_records) / len(shops), 1) if shops else 0.0, + 'source': source, + } + return {'shops': shops, 'items': items, 'summary': summary} + + +def _build_duplicate_scan_items(shop_items): + """由 shop_items 聚合跨店重复 ASIN 完整结果(不含筛选,返回全量列表)。 + + 保持旧接口语义(仅跨店重复),供导入模拟重分析等旧逻辑复用; + 新页面走 _build_duplicate_scan_cache 全量缓存。 + """ + cache = _build_duplicate_scan_cache(shop_items) + return cache['items'] + + +def _run_duplicate_scan_job(): + """执行一次全量重复检查扫描并落库(新格式:shops + items + summary)。返回 (ok, scanned_at, summary)。""" + global _duplicate_scan_running + if _duplicate_scan_running: + return False, '', '扫描进行中' + _duplicate_scan_running = True + try: + shop_items = _shops_with_latest_results() + cache = _build_duplicate_scan_cache(shop_items, source='job') + _save_duplicate_scan('SUCCESS', summary=cache['summary'], payload={ + 'shops': cache['shops'], + 'items': cache['items'], + }) + return True, datetime.now().strftime('%Y-%m-%d %H:%M:%S'), cache['summary'] + except Exception as exc: + current_app.logger.exception('[shop-data-crawl] 重复检查定时扫描失败: %s', exc) + try: + _save_duplicate_scan('FAILED', error_text=str(exc)) + except Exception: + current_app.logger.exception('[shop-data-crawl] 重复检查扫描失败记录落库失败') + return False, '', '执行失败:%s' % exc + finally: + _duplicate_scan_running = False + + +def start_duplicate_scan_scheduler(app): + """每天凌晨 03:10 执行一次重复 ASIN 全量扫描(后台守护线程)。""" + _ensure_duplicate_scan_table() + + def _loop(): + while True: + now = datetime.now() + # 下一个 03:10(含今天,若已过则明天) + next_run = now.replace(hour=3, minute=10, second=0, microsecond=0) + if next_run <= now: + next_run = next_run.replace(day=next_run.day) + timedelta(days=1) + delay = (next_run - now).total_seconds() + app.logger.info('[shop-data-crawl] 重复ASIN定时扫描将于 %s 执行(%d 秒后)', next_run, int(delay)) + time.sleep(delay) + try: + with app.app_context(): + ok, scanned_at, summary = _run_duplicate_scan_job() + app.logger.info('[shop-data-crawl] 重复ASIN定时扫描结束 ok=%s summary=%s', ok, summary) + except Exception as exc: + app.logger.exception('[shop-data-crawl] 重复ASIN定时扫描异常: %s', exc) + + threading.Thread(target=_loop, daemon=True, name='duplicate-scan-scheduler').start() + + +def _duplicate_check_filter_cache(cache, role, current_row): + """按角色数据范围裁剪重复检查缓存:超管返回全量,主管只返回自己组的店铺。 + + 返回 (shops, items):shops 为可见店铺概览;items 为裁剪 occurrences 后的明细 + (裁剪后同一 ASIN 只剩 1 家店时也会保留 —— 展示完整台账,是否重复由 shop_count>=2 标记)。 + """ + visible_shops = _shop_data_managed_shop_names(role, current_row) + if visible_shops is None: + return cache.get('shops') or [], cache.get('items') or [] + shops = [] + for shop in (cache.get('shops') or []): + if _shop_data_crawl_shop_key(shop.get('shop_name')) in visible_shops: + shops.append(shop) + items = [] + for item in (cache.get('items') or []): + occurrences = [ + occ for occ in (item.get('occurrences') or []) + if _shop_data_crawl_shop_key(occ.get('shop_name')) in visible_shops + ] + if occurrences: + items.append({ + 'asin': item.get('asin'), + 'shop_count': len({occ.get('shop_name') for occ in occurrences}), + 'record_count': len(occurrences), + 'occurrences': occurrences, + }) + return shops, items + + +@admin_api.route('/shop-data-crawl/duplicate-check-overview') +@login_required +def shop_data_crawl_duplicate_check_overview(): + """店铺数据重复检查:指标卡 + 店铺上架分布(按当前角色数据范围裁剪)。""" + role, current_row, denied = _ensure_backend_menu_access('shop-data-crawl-tasks') + if not denied: + _, _, denied = _ensure_shop_data_crawl_data_access() + if denied: + return denied + try: + force = (request.args.get('force') or '').strip() in ('1', 'true', 'yes') + if force: + if not _duplicate_scan_lock.acquire(blocking=False): + return jsonify({'success': False, 'error': '扫描进行中,请稍后刷新'}), 409 + try: + ok, scanned_at, summary = _run_duplicate_scan_job() + if not ok: + return jsonify({'success': False, 'error': summary}), 500 + cache = _latest_duplicate_scan() + finally: + _duplicate_scan_lock.release() + else: + cache = _latest_duplicate_scan() + if not cache: + return jsonify({ + 'success': True, 'pending': True, + 'scanned_at': '', 'source': '', + 'summary': {}, 'shops': [], + }) + shops, items = _duplicate_check_filter_cache(cache, role, current_row) + asin_total = len(items) + record_total = sum(item.get('record_count') or 0 for item in items) + repeated = [item for item in items if item['shop_count'] >= 2] + shop_asin_totals = {} + duplicate_shops = set() + site_codes = set() + for item in items: + occs = item.get('occurrences') or [] + for occ in occs: + shop_asin_totals[occ.get('shop_name')] = shop_asin_totals.get(occ.get('shop_name'), set()) + shop_asin_totals[occ.get('shop_name')].add(item['asin']) + if item['shop_count'] >= 2: + duplicate_shops.add(occ.get('shop_name')) + if occ.get('country'): + site_codes.add(occ['country']) + summary = { + 'shop_count': len(shops), + 'asin_total': asin_total, + 'record_total': record_total, + 'duplicate_asin_total': len(repeated), + 'duplicate_shop_count': len(duplicate_shops), + 'site_count': len(site_codes), + 'asin_per_shop': round(float(record_total) / len(shops), 1) if shops else 0.0, + 'source': (cache.get('summary') or {}).get('source', ''), + } + return jsonify({ + 'success': True, + 'pending': False, + 'scanned_at': cache['scanned_at'], + 'summary': summary, + 'shops': shops, + }) + except Exception as exc: + return _internal_error(exc) + + +@admin_api.route('/shop-data-crawl/duplicate-check-items') +@login_required +def shop_data_crawl_duplicate_check_items(): + """店铺数据重复检查矩阵/台账明细:行 ASIN、列店铺,支持筛选与分页。""" + _, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks') + if not denied: + _, _, denied = _ensure_shop_data_crawl_data_access() + if denied: + return denied + try: + role, current_row = get_current_admin_role() + page = max(1, int(request.args.get('page', 1))) + page_size = min(100, max(10, int(request.args.get('page_size', 20)))) + view = (request.args.get('view') or 'monitor').strip().lower() + asin_filter = (request.args.get('asin') or '').strip().upper() + shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip() + country_filter = (request.args.get('country') or '').strip().upper() + site_filter = (request.args.get('site') or '').strip().upper() + date_from = (request.args.get('date_from') or '').strip()[:10] + date_to = (request.args.get('date_to') or '').strip()[:10] + + cache = _latest_duplicate_scan() + if not cache: + return jsonify({ + 'success': True, 'pending': True, + 'items': [], 'shops': [], + 'total': 0, 'page': page, 'page_size': page_size, + 'scanned_at': '', + }) + shops, all_items = _duplicate_check_filter_cache(cache, role, current_row) + has_filter = bool(asin_filter or shop_name_filter or country_filter or site_filter or date_from or date_to) + matched = [] + for item in all_items: + if asin_filter and asin_filter not in item['asin']: + continue + if view == 'monitor' and item['shop_count'] < 2: + continue + if not has_filter: + matched.append(item) + continue + hit = False + for occ in item['occurrences']: + if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \ + and shop_name_filter.lower() not in (occ.get('group_name') or '').lower(): + continue + if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]: + continue + if site_filter and site_filter not in [c.upper() for c in [occ.get('country') or '']]: + continue + row_date_key = _shop_data_date_key((occ.get('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 + hit = True + break + if hit: + matched.append(item) + total = len(matched) + offset = (page - 1) * page_size + return jsonify({ + 'success': True, + 'pending': False, + 'items': matched[offset:offset + page_size], + 'shops': shops, + 'total': total, + 'page': page, + 'page_size': page_size, + 'scanned_at': cache['scanned_at'], + }) + except ValueError as exc: + return jsonify({'success': False, 'error': str(exc)}), 400 + except Exception as exc: + return _internal_error(exc) + + +def _duplicate_check_effective_site(country, country_codes): + """导出用站点:优先记录级 country,其次店铺 country_codes 汇总。""" + if country: + return country.upper() + codes = [c.upper() for c in (country_codes or []) if c] + return '、'.join(codes) if codes else '' + + +@admin_api.route('/shop-data-crawl/duplicate-check-export') +@login_required +def shop_data_crawl_duplicate_check_export(): + """店铺数据重复检查导出 CSV:筛选逻辑与矩阵接口一致,导出全量匹配行。""" + _, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks') + if not denied: + _, _, denied = _ensure_shop_data_crawl_data_access() + if denied: + return denied + try: + role, current_row = get_current_admin_role() + view = (request.args.get('view') or 'monitor').strip().lower() + asin_filter = (request.args.get('asin') or '').strip().upper() + shop_name_filter = (request.args.get('shop_name') or request.args.get('shop') or '').strip() + country_filter = (request.args.get('country') or '').strip().upper() + site_filter = (request.args.get('site') or '').strip().upper() + date_from = (request.args.get('date_from') or '').strip()[:10] + date_to = (request.args.get('date_to') or '').strip()[:10] + + cache = _latest_duplicate_scan() + if not cache: + return jsonify({'success': False, 'error': '暂无扫描结果,请先点击「重新分析」'}), 400 + shops, all_items = _duplicate_check_filter_cache(cache, role, current_row) + has_filter = bool(asin_filter or shop_name_filter or country_filter or site_filter or date_from or date_to) + # 展平为逐行记录(每行=一条上架记录),筛选逻辑与矩阵接口一致 + flat = [] + for item in all_items: + if asin_filter and asin_filter not in item['asin']: + continue + if view == 'monitor' and item['shop_count'] < 2: + continue + for occ in item['occurrences']: + if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \ + and shop_name_filter.lower() not in (occ.get('group_name') or '').lower(): + continue + if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]: + continue + if site_filter and site_filter not in [c.upper() for c in [occ.get('country') or '']]: + continue + row_date_key = _shop_data_date_key((occ.get('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 + flat.append({ + 'asin': item['asin'], + 'shop_count': item['shop_count'], + 'shop_name': occ.get('shop_name') or '', + 'group_name': occ.get('group_name') or '', + 'country': _duplicate_check_effective_site(occ.get('country'), occ.get('country_codes')), + 'date': occ.get('date') or '', + 'price': occ.get('price') or '', + 'brand': occ.get('brand') or '', + }) + flat.sort(key=lambda r: (-r['shop_count'], r['asin'], r['shop_name'], r['date'])) + import csv + stream = io.StringIO() + writer = csv.writer(stream) + writer.writerow(['ASIN', '店铺数', '店铺', '分组', '站点', '上架时间', '价格', '品牌']) + for row in flat: + writer.writerow([row['asin'], row['shop_count'], row['shop_name'], row['group_name'], + row['country'], row['date'], row['price'], row['brand']]) + payload = ('' + stream.getvalue()).encode('utf-8') + response = Response(payload, mimetype='text/csv; charset=utf-8') + response.headers['Content-Disposition'] = 'attachment; filename="shop-data-duplicate-check.csv"' + response.headers['Cache-Control'] = 'no-store' + return response + except Exception as exc: + return _internal_error(exc) + + @admin_api.route('/shop-data-crawl/duplicate-asins') @login_required def shop_data_crawl_duplicate_asins(): - """按当前店铺列表筛选条件,分析跨店铺重复的 ASIN 明细。 + """按当前筛选条件展示跨店铺重复的 ASIN 明细。 - 读取当前页每家店铺最新结果 Excel(经 Java 下载接口拉取),按 ASIN 聚合 - 其出现的店铺、国家与「日期/价格/品牌」细节,仅返回出现在 2 家及以上店铺 - 的 ASIN(按店铺数降序、ASIN 升序),空 pagination 参数时返回全量用于导出。 + 默认读取最近一次成功扫描的缓存结果(定时任务每天凌晨全量扫描), + 页面点「重新分析」携带 force=1 触发一次实时扫描(锁防并发,重复触发返回 409)。 """ _, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks') if not denied: @@ -1806,155 +2534,72 @@ 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)))) + force = (request.args.get('force') or '').strip() in ('1', 'true', 'yes') 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'", - "t.module_type = 'SHOP_DATA_CRAWL'", - "TRIM(COALESCE(r.result_file_url, '')) <> ''", - ] - params = [] - where_sql = ' AND '.join(conditions) - offset = (page - 1) * page_size + if force: + # 实时扫描:持锁执行,避免与定时任务/其他请求并发 + if not _duplicate_scan_lock.acquire(blocking=False): + return jsonify({'success': False, 'error': '扫描进行中,请稍后刷新'}), 409 + try: + ok, scanned_at, summary = _run_duplicate_scan_job() + if not ok: + return jsonify({'success': False, 'error': summary}), 500 + cache = _latest_duplicate_scan() + finally: + _duplicate_scan_lock.release() + else: + cache = _latest_duplicate_scan() - conn = get_db() - try: - with conn.cursor() as cur: - shop_key_sql = "TRIM(COALESCE(r.source_filename, ''))" - grouped_from_sql = ( - ' FROM biz_file_result r ' - 'JOIN biz_file_task t ON t.id = r.task_id ' - 'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id ' - 'LEFT JOIN users u ON u.id = r.user_id ' - 'WHERE ' + where_sql - ) - cur.execute( - 'SELECT COUNT(*) AS total FROM (' - 'SELECT ' + shop_key_sql + ' AS shop_key' + grouped_from_sql + - ' GROUP BY ' + shop_key_sql + - ') shop_groups', - tuple(params), - ) - total = int((cur.fetchone() or {}).get('total') or 0) + if not cache: + return jsonify({ + 'success': True, + 'items': [], + 'total': 0, + 'page': page, + 'page_size': page_size, + 'analyzed_shop_count': 0, + 'analyzed_result_count': 0, + 'scanned_at': '', + 'pending': True, + }) - cur.execute( - 'SELECT ' + shop_key_sql + ' AS shop_name, MAX(' - + _SHOP_DATA_CRAWL_LATEST_TIME_SQL + ') AS latest_created_at' - + grouped_from_sql + - ' GROUP BY ' + shop_key_sql + - ' ORDER BY latest_created_at DESC, shop_name ASC LIMIT %s OFFSET %s', - tuple(params + [page_size, offset]), - ) - group_rows = cur.fetchall() - group_names = _shop_data_crawl_group_names(cur, group_rows) - - result_rows_by_shop = {} - selected_shop_names = [row.get('shop_name') for row in group_rows] - if selected_shop_names: - placeholders = ','.join(['%s'] * len(selected_shop_names)) - cur.execute( - 'SELECT ranked.* FROM (SELECT ' + _SHOP_DATA_CRAWL_ADMIN_COLUMNS + - ', ROW_NUMBER() OVER (PARTITION BY ' + shop_key_sql + - ' ORDER BY ' + _SHOP_DATA_CRAWL_LATEST_TIME_SQL - + ' DESC, r.id DESC) AS shop_row_number ' - ' FROM biz_file_result r ' - 'JOIN biz_file_task t ON t.id = r.task_id ' - 'LEFT JOIN biz_shop_data_crawl_daily_file df ON df.latest_result_id = r.id ' - 'LEFT JOIN users u ON u.id = r.user_id ' - 'WHERE ' + where_sql + - f' AND {shop_key_sql} IN ({placeholders})' + - ') ranked WHERE ranked.shop_row_number <= 1 ' - 'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC', - tuple(params + selected_shop_names), - ) - for row in cur.fetchall(): - shop_key = _shop_data_crawl_shop_key(row.get('shop_name')) - result_rows_by_shop.setdefault(shop_key, []).append(row) - finally: - conn.close() - - # 逐店读取结果文件并解析(每店最多一个结果文件,共 page_size 个) - shop_items = [] - for rows in result_rows_by_shop.values(): - for row in rows: - result_id = int(row.get('result_id') or 0) - if result_id <= 0: + all_items = cache['items'] or [] + # 旧接口只展示跨店重复:由全量缓存裁剪出 shop_count>=2 的 ASIN + all_items = [item for item in all_items if (item.get('shop_count') or 0) >= 2] + has_filter = bool(asin_filter or shop_name_filter or country_filter or date_from or date_to) + matched = [] + for item in all_items: + if asin_filter and asin_filter not in item['asin']: + continue + if not has_filter: + matched.append(item) + continue + # 有筛选时:任一 occurrence 命中即保留该 ASIN(展示完整记录) + hit = False + for occ in item['occurrences']: + if shop_name_filter and shop_name_filter.lower() not in (occ.get('shop_name') or '').lower() \ + and shop_name_filter.lower() not in (occ.get('group_name') or '').lower(): continue - try: - raw = _shop_data_crawl_fetch_result_bytes(row) - try: - parsed = _shop_data_crawl_parse_workbook( - load_workbook(io.BytesIO(raw), read_only=True, data_only=True)) - except (InvalidFileException, KeyError, ValueError) as exc: - current_app.logger.warning( - '[shop-data-crawl] 解析结果文件失败 result_id=%s: %s', result_id, exc) - continue - shop_items.append({ - 'shop_name': row.get('shop_name') or '未命名', - 'group_name': _shop_data_crawl_group_name(group_names, row.get('shop_name')), - 'country_codes': _shop_data_crawl_country_codes_from_json(row.get('country_codes_json')) - or _shop_data_crawl_country_codes(row.get('request_json')), - 'rows': parsed, - }) - except (requests.RequestException, ValueError) as exc: - current_app.logger.warning( - '[shop-data-crawl] 拉取结果文件失败 result_id=%s: %s', result_id, exc) - - # 先全量聚合所有行,再用筛选条件圈定「命中 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: + if country_filter and country_filter not in [c.upper() for c in (occ.get('country_codes') or [])]: continue - asin_occurrences.setdefault(asin, []).append({ - 'asin': asin, - 'date': row['date'], - 'price': row['price'], - 'brand': row['brand'], - 'shop_name': shop_name, - '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()) + row_date_key = _shop_data_date_key((occ.get('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) + hit = True + break + if hit: + matched.append(item) - occurrences_list = [] - 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'])) - - total_details = len(occurrences_list) - paged_details = occurrences_list[offset:offset + page_size] + total_details = len(matched) + offset = (page - 1) * page_size + paged_details = matched[offset:offset + page_size] return jsonify({ 'success': True, @@ -1962,8 +2607,10 @@ def shop_data_crawl_duplicate_asins(): 'total': total_details, 'page': page, 'page_size': page_size, - 'analyzed_shop_count': len(shop_items), - 'analyzed_result_count': len(shop_items), + 'analyzed_shop_count': cache['summary'].get('shop_count', 0) if cache.get('summary') else 0, + 'analyzed_result_count': len(all_items), + 'scanned_at': cache['scanned_at'], + 'source': cache.get('summary', {}).get('source', ''), }) except ValueError as exc: return jsonify({'success': False, 'error': str(exc)}), 400 diff --git a/backend/static/admin.js b/backend/static/admin.js index db47d605..8de1a3d8 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -97,7 +97,7 @@ var ADMIN_MENU_GROUPS = [ { key: 'account', title: '账号与权限', items: ['users', 'columns', 'group-manage'] }, { key: 'data', title: '数据管理', items: ['dedupe-total-data', 'invalid-asin-data', 'query-asin', 'product-categories'] }, - { key: 'shop', title: '店铺管理', items: ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks'] }, + { key: 'shop', title: '店铺管理', items: ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks', 'shop-data-duplicate-check'] }, { key: 'record', title: '记录与版本', items: ['history', 'version', 'digital-human-version', 'image-video-tasks'] } ]; var ADMIN_MENU_ICONS = { @@ -113,6 +113,7 @@ 'product-categories': '', 'image-video-tasks': '', 'shop-data-crawl-tasks': '', + 'shop-data-duplicate-check': '', 'history': '', 'version': '', 'digital-human-version': '' @@ -139,6 +140,7 @@ 'product-categories': 'panel-product-categories', 'image-video-tasks': 'panel-image-video-tasks', 'shop-data-crawl-tasks': 'panel-shop-data-crawl-tasks', + 'shop-data-duplicate-check': 'panel-shop-data-duplicate-check', 'history': 'panel-history', 'version': 'panel-version', 'digital-human-version': 'panel-digital-human-version' @@ -162,6 +164,7 @@ else if (tabName === 'product-categories') loadProductCategories(); else if (tabName === 'image-video-tasks') loadImageVideoTasks(1); else if (tabName === 'shop-data-crawl-tasks') loadShopDataCrawlTasks(1); + else if (tabName === 'shop-data-duplicate-check') loadShopDataDuplicateCheckOverview(); else if (tabName === 'history') loadHistory(1); else if (tabName === 'version') loadSoftwareVersions(); else if (tabName === 'digital-human-version') loadDigitalHumanVersions(); @@ -1778,22 +1781,45 @@ } function renderShopDataRecordTable() { - var rows = shopDataTasks.map(renderShopDataRecordRow).join(''); if (!shopDataTasks.length) { return '
暂无符合条件的店铺数据任务
'; } - return '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + rows + '' + - '
店铺分组国家状态更新时间操作
'; + // 卡片网格:每店铺一张卡片,按截图样式(店铺名、当日累计文件、分组、最新时间、任务号+状态、国家、文件名、下载/删除) + var cards = shopDataTasks.map(function (item) { + var resultId = shopDataResultId(item); + var selected = resultId > 0 && selectedShopDataResultIds.has(resultId); + var status = String(item.status || item.file_status || '').toUpperCase(); + var terminal = ['SUCCESS', 'FAILED', 'CANCELLED'].indexOf(status) >= 0; + var countryCodes = item.country_codes != null ? item.country_codes : item.countryCodes; + var updatedAt = item.updated_at || item.latest_created_at || item.created_at || item.finished_at || '-'; + var taskNo = item.task_id || item.taskId || '-'; + var errorTitle = item.error ? ' title="' + escapeHtml(item.error) + '"' : ''; + return '
' + + '
' + + '' + escapeHtml(item.shop_name || '-') + '' + + '
' + + '
' + + ' 0 ? '' : ' disabled') + '>' + + '任务 ' + escapeHtml(String(taskNo)) + '' + + '' + + escapeHtml(imageVideoStatusLabel(status)) + '' + + '
' + + '
分组' + + '' + escapeHtml(item.group_name || '-') + '
' + + '
最新' + + '' + escapeHtml(updatedAt) + '
' + + '
国家' + + '' + escapeHtml(countryListLabel(countryCodes)) + '
' + + '
文件' + + '' + escapeHtml(item.output_filename || '-') + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }).join(''); + return '
' + cards + '
'; } function renderShopDataTasks() { @@ -1858,21 +1884,27 @@ }); } - // ========== 重复 ASIN 分析 ========== - var shopDataDuplicatePage = 1, shopDataDuplicatePageSize = 10; + // ========== 店铺数据重复检查(指标卡 + 分布 + 矩阵表格 + 抽屉)========== + var shopDataDuplicatePage = 1, shopDataDuplicatePageSize = 20; var shopDataDuplicateItems = []; + var shopDataDuplicateShops = []; var shopDataDuplicateTotal = 0; - var shopDataDuplicateAnalyzed = { shopCount: 0, resultCount: 0 }; + var shopDataDuplicateView = 'monitor'; var shopDataDuplicateLoading = false; + var shopDataDuplicateScannedAt = ''; + var shopDataDuplicateOverviewPending = false; + var shopDataDuplicateExporting = false; function buildShopDataDuplicateQuery(page) { var params = new URLSearchParams(); params.set('page', String(page || 1)); params.set('page_size', String(shopDataDuplicatePageSize)); + params.set('view', shopDataDuplicateView); var values = { asin: document.getElementById('shopDataDupFilterAsin').value.trim(), shop_name: document.getElementById('shopDataDupFilterShop').value.trim(), country: document.getElementById('shopDataDupFilterCountry').value.trim(), + site: document.getElementById('shopDataDupFilterSite').value.trim(), date_from: document.getElementById('shopDataDupFilterDateFrom').value, date_to: document.getElementById('shopDataDupFilterDateTo').value }; @@ -1882,166 +1914,372 @@ return params.toString(); } - function shopDataDuplicateHeader(header) { - return '' + header.map(function (col) { - return '' + col.label + ''; - }).join('') + ''; + function pad2(value) { + var n = parseInt(value, 10); + if (isNaN(n)) return String(value); + return n < 10 ? '0' + n : String(n); } - function renderShopDataDuplicateCard(item) { - var occurrences = Array.isArray(item.occurrences) ? item.occurrences : []; - var brand = ''; - occurrences.forEach(function (occ) { if (occ.brand && !brand) brand = occ.brand; }); - 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 || '-') + '' + - ''; + // 中文日期(如 2026年8月18日 上午4:34)归一化为 YYYY-MM-DD HH:MM[:SS],便于展示与排序 + function normalizeDuplicateTime(raw) { + if (!raw) return ''; + var s = String(raw).trim(); + var m = s.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日\s*(.*)$/); + if (m) { + var md = m[1] + '-' + pad2(m[2]) + '-' + pad2(m[3]); + var tm = m[4].match(/(上午|下午|晚上|凌晨)?\s*(\d{1,2})[::](\d{2})(?:[::](\d{1,2}))?/); + if (!tm) return md; + var hour = parseInt(tm[2], 10); + var period = tm[1] || ''; + if (period === '下午' || period === '晚上') hour = hour % 12 + 12; + if ((period === '上午' || period === '凌晨') && hour === 12) hour = 0; + return md + ' ' + pad2(hour) + ':' + tm[3] + (tm[4] ? ':' + pad2(tm[4]) : ''); + } + var dm = s.replace(/\//g, '-').match(/^(\d{4})-(\d{1,2})-(\d{1,2})(.*)$/); + if (dm) return dm[1] + '-' + pad2(dm[2]) + '-' + pad2(dm[3]) + (dm[4] || ''); + return s; + } + + function renderShopDataDuplicateMetrics(summary) { + var metricsEl = document.getElementById('shopDataDuplicateMetrics'); + if (!summary || summary.asin_total == null) { + metricsEl.innerHTML = '
暂无扫描结果' + + '-
'; + return; + } + var defs = [ + { label: '唯一ASIN', value: summary.asin_total, accent: true }, + { label: '上架记录', value: summary.record_total, accent: true }, + { label: '在线店铺', value: summary.shop_count, accent: true }, + { label: '重复ASIN', value: summary.duplicate_asin_total, warn: true }, + { label: '重复店铺', value: summary.duplicate_shop_count, warn: true }, + { label: '店铺平均ASIN', value: summary.asin_per_shop } + ]; + metricsEl.innerHTML = defs.map(function (def) { + return '
' + + '' + def.label + '' + + '' + def.value + '
'; }).join(''); - return '
' + - '
' + - '' + escapeHtml(item.asin) + '' + - '' + item.shop_count + ' 家店铺' + - '' + escapeHtml(brand || '') + '' + - '
' + - '' + - shopDataDuplicateHeader([ - { label: '店铺', width: '18%' }, - { label: '分组', width: '14%' }, - { label: '国家', width: '16%' }, - { label: '日期', width: '14%' }, - { label: '价格', width: '12%' } - ]) + - '' + rows + '' + - '
' + - '
'; + } + + function renderShopDataDuplicateDistribution(shops) { + var barsEl = document.getElementById('shopDataDuplicateDistribution'); + var noteEl = document.getElementById('shopDataDuplicateDistributionNote'); + if (!shops || !shops.length) { + barsEl.innerHTML = '
暂无店铺数据
'; + noteEl.textContent = ''; + return; + } + var max = 0; + shops.forEach(function (shop) { if (shop.asin_count > max) max = shop.asin_count; }); + barsEl.innerHTML = shops.map(function (shop) { + var width = max ? Math.max(3, Math.round(shop.asin_count / max * 100)) : 0; + return '
' + + '' + escapeHtml(shop.shop_name) + '' + + '' + + '' + shop.asin_count + '
'; + }).join(''); + noteEl.textContent = '共 ' + shops.length + ' 家店铺 · 按 ASIN 数量排序'; + } + + function shopDataDuplicateCellCount(item, shopName) { + var count = 0; + (item.occurrences || []).forEach(function (occ) { + if ((occ.shop_name || '') === shopName) count++; + }); + return count; + } + + function renderShopDataDuplicateMatrix() { + var list = document.getElementById('shopDataDuplicateList'); + if (!shopDataDuplicateItems.length) { + list.innerHTML = '
暂无数据' + + (shopDataDuplicateView === 'monitor' && !shopDataDuplicateTotal ? ':当前没有跨店铺重复的 ASIN' : '') + + '。可点击右上角「重新分析」重新扫描。
'; + return; + } + var shops = shopDataDuplicateShops; + var thead = 'ASIN店铺数' + + shops.map(function (shop) { + return '' + escapeHtml(shop.shop_name) + ''; + }).join('') + ''; + var tbody = shopDataDuplicateItems.map(function (item) { + var cells = shops.map(function (shop) { + var count = shopDataDuplicateCellCount(item, shop.shop_name); + var cls = count === 0 ? 'zero' : (item.shop_count >= 2 ? 'danger' : 'has'); + return '' + (count === 0 ? '0' : count) + ''; + }).join(''); + return '' + + '' + escapeHtml(item.asin) + '' + + '' + item.shop_count + '' + + cells + ''; + }).join(''); + list.innerHTML = '' + thead + '' + tbody + '
'; } function renderShopDataDuplicateList() { - var list = document.getElementById('shopDataDuplicateList'); - if (!shopDataDuplicateItems.length) { - list.innerHTML = '
暂无重复 ASIN。请在筛选条件后点击"查询",或"重新分析"。' + - '
'; + renderShopDataDuplicateMatrix(); + } + + // 主时间戳(转数值失败时按字符串比较) + function dupTimeRank(raw) { + var t = Date.parse(normalizeDuplicateTime(raw)); + return isNaN(t) ? -1 : t; + } + + // 「查看明细」按钮点击开抽屉(动态元素,事件委托) + function handleDuplicateDetailClick(event) { + var button = event.target.closest('[data-open-asin-detail]'); + if (button) openShopDataDuplicateDrawer(button.dataset.openAsinDetail); + } + + // 撞款详情卡片区:同一 ASIN 在多条店铺的上架明细(仅跨店铺重复,按店铺数倒序) + function renderShopDataDuplicateDetailCards() { + var block = document.getElementById('dupCheckDetailBlock'); + var cardsEl = document.getElementById('dupCheckDetailCards'); + if (!block || !cardsEl) return; + var repeated = (shopDataDuplicateItems || []).filter(function (item) { + return Number(item.shop_count) >= 2; + }).slice().sort(function (a, b) { + return (Number(b.shop_count) - Number(a.shop_count)) || dupTimeRank(b.first_date || '') - dupTimeRank(a.first_date || ''); + }); + if (!repeated.length) { + block.style.display = 'none'; + cardsEl.innerHTML = ''; return; } - 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); + block.style.display = ''; + cardsEl.innerHTML = repeated.map(function (item) { + // 按店铺分组:名称 + 次数 + 站点 + 上架时间(倒序) + var shopsMap = {}; + (item.occurrences || []).forEach(function (occ) { + var key = occ.shop_name || '-'; + if (!shopsMap[key]) shopsMap[key] = { shop: key, sites: {}, times: [], count: 0 }; + var info = shopsMap[key]; + info.count++; + if (occ.country) info.sites[occ.country] = true; + var d = normalizeDuplicateTime(occ.date); + if (d) info.times.push(d); + }); + var rows = Object.keys(shopsMap).sort().map(function (key) { + var info = shopsMap[key]; + info.times.sort(function (a, b) { return (a < b ? 1 : (a > b ? -1 : 0)); }); + info.times = Array.from(new Set(info.times)); + var rowSites = Object.keys(info.sites).sort().map(function (site) { + return '' + escapeHtml(site) + ''; + }).join(''); + return '' + + '' + escapeHtml(info.shop) + '' + info.count + ' 次' + + '' + (rowSites || '-') + '' + + '' + info.times.map(escapeHtml).join('、') + '' + + ''; + }).join(''); + return '
' + + '
' + + '' + escapeHtml(item.asin) + '' + + '' + item.shop_count + ' 家店铺' + + '' + escapeHtml(item.brand || '') + '' + + '' + + '
' + + '' + + '' + + '' + rows + '
店铺站点上架时间
' + + '
'; }).join(''); - list.innerHTML = '' + shopDataDuplicateHeader(header) + '' + tbody + '
'; } - function renderShopDataDuplicateRow(item) { - var occurrences = Array.isArray(item.occurrences) ? item.occurrences : []; - var brand = ''; - var countries = {}; - var shops = {}; + function openShopDataDuplicateDrawer(asin) { + var item = null; + (shopDataDuplicateItems || []).forEach(function (it) { if (it.asin === asin) item = it; }); + if (!item) return; + var occurrences = item.occurrences || []; + var brand = '', dateMin = '', dateMax = '', sites = {}, prices = []; + var shopsMap = {}; 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; + if (!brand && occ.brand) brand = occ.brand; + var d = normalizeDuplicateTime(occ.date); + if (d) { + if (!dateMin || d < dateMin) dateMin = d; + if (!dateMax || d > dateMax) dateMax = d; + } + if (occ.country) sites[occ.country] = true; + if (occ.price && prices.indexOf(occ.price) < 0) prices.push(occ.price); + var shopKey = occ.shop_name || '-'; + if (!shopsMap[shopKey]) { + shopsMap[shopKey] = { shop: occ.shop_name || '-', group: occ.group_name || '', sites: {}, times: {}, price: '', count: 0 }; + } + var info = shopsMap[shopKey]; + info.count++; + if (occ.country) info.sites[occ.country] = true; + if (d) info.times[d] = true; + if (occ.price && !info.price) info.price = occ.price; }); - 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 || '-') + '' + - '' + - ''; + var dateRange = dateMin && dateMax ? (dateMin === dateMax ? dateMin : dateMin + ' ~ ' + dateMax) : '-'; + var priceRange = prices.length ? prices.slice(0, 4).join(' / ') + (prices.length > 4 ? ' 等' : '') : '-'; + var siteBadges = Object.keys(sites).sort().map(function (site) { + return '' + escapeHtml(site) + ''; }).join(''); - return rows; + var rows = Object.keys(shopsMap).sort().map(function (key) { + var info = shopsMap[key]; + var times = Object.keys(info.times).sort().reverse(); + var rowSites = Object.keys(info.sites).sort().map(function (site) { + return '' + escapeHtml(site) + ''; + }).join(''); + return '' + + '' + escapeHtml(info.shop) + '' + + '' + escapeHtml(info.group || '-') + '' + + '' + (rowSites || '-') + '' + + '' + times.map(escapeHtml).join('、') + '' + + '' + escapeHtml(info.price || '-') + '' + + '' + info.count + ''; + }).join(''); + document.getElementById('dupCheckDrawerAsin').textContent = item.asin; + document.getElementById('dupCheckDrawerSubtitle').textContent = + item.shop_count + ' 家店铺 · ' + item.record_count + ' 条上架记录'; + document.getElementById('dupCheckDrawerBody').innerHTML = + '
' + + '
品牌
' + escapeHtml(brand || '-') + '
' + + '
价格
' + escapeHtml(priceRange) + '
' + + '
日期范围
' + escapeHtml(dateRange) + '
' + + '
站点
' + (siteBadges || '-') + '
' + + '

店铺 / 站点 / 上架时间 / 次数

' + + '
' + + '' + + '' + rows + '
店铺分组站点上架时间价格次数
'; + document.getElementById('dupCheckDrawerMask').classList.add('show'); } - function loadShopDataDuplicateAsins(page) { + function closeShopDataDuplicateDrawer() { + document.getElementById('dupCheckDrawerMask').classList.remove('show'); + } + + function loadShopDataDuplicateCheckOverview(force) { + var progress = document.getElementById('shopDataDuplicateProgress'); + var button = document.getElementById('btnRefreshShopDataDuplicates'); + if (button) button.disabled = true; + if (force) progress.textContent = '正在全量扫描各店铺结果文件,请稍候...'; + fetch('/api/admin/shop-data-crawl/duplicate-check-overview' + (force ? '?force=1' : '')) + .then(function (response) { return response.json(); }) + .then(function (res) { + if (!res.success) throw new Error(res.error || '加载失败'); + shopDataDuplicateOverviewPending = !!res.pending; + shopDataDuplicateScannedAt = res.scanned_at || ''; + var summary = res.summary || {}; + renderShopDataDuplicateMetrics(summary); + renderShopDataDuplicateDistribution(res.shops || []); + var dupTabCount = document.getElementById('dupCheckMonitorCount'); + var allTabCount = document.getElementById('dupCheckAllCount'); + if (dupTabCount) dupTabCount.textContent = summary.duplicate_asin_total != null ? summary.duplicate_asin_total : 0; + if (allTabCount) allTabCount.textContent = summary.asin_total != null ? summary.asin_total : 0; + loadShopDataDuplicateCheckItems(1); + }) + .catch(function (error) { + var metricsEl = document.getElementById('shopDataDuplicateMetrics'); + if (metricsEl) { + metricsEl.innerHTML = '
加载失败:' + + escapeHtml(error.message || '') + '
'; + } + }) + .finally(function () { + if (button) button.disabled = false; + if (progress) progress.textContent = ''; + }); + } + + function loadShopDataDuplicateCheckItems(page) { if (shopDataDuplicateLoading) return; shopDataDuplicatePage = page || 1; shopDataDuplicateLoading = true; var list = document.getElementById('shopDataDuplicateList'); var progress = document.getElementById('shopDataDuplicateProgress'); - var button = document.getElementById('btnRefreshShopDataDuplicates'); - progress.textContent = '正在读取各店铺结果文件并分析,请稍候...'; - button.disabled = true; - list.innerHTML = '
分析中...
'; - fetch('/api/admin/shop-data-crawl/duplicate-asins?' + buildShopDataDuplicateQuery(shopDataDuplicatePage)) + var exportButton = document.getElementById('btnExportShopDataDuplicates'); + list.innerHTML = '
加载中...
'; + if (exportButton) exportButton.disabled = true; + fetch('/api/admin/shop-data-crawl/duplicate-check-items?' + buildShopDataDuplicateQuery(shopDataDuplicatePage)) .then(function (response) { return response.json(); }) .then(function (res) { - if (!res.success) throw new Error(res.error || '分析失败'); + if (!res.success) throw new Error(res.error || '加载失败'); shopDataDuplicateItems = res.items || []; + shopDataDuplicateShops = res.shops || []; shopDataDuplicateTotal = Number(res.total) || 0; - shopDataDuplicateAnalyzed.shopCount = Number(res.analyzed_shop_count) || 0; - shopDataDuplicateAnalyzed.resultCount = Number(res.analyzed_result_count) || 0; + if (res.scanned_at) shopDataDuplicateScannedAt = res.scanned_at; 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); + totalEl.textContent = (shopDataDuplicateView === 'monitor' ? '重复监控' : '全部ASIN台账') + + ' · 共 ' + shopDataDuplicateTotal + ' 个 ASIN' + + (shopDataDuplicateScannedAt ? ' · 扫描时间 ' + shopDataDuplicateScannedAt + : (shopDataDuplicateOverviewPending ? ' · 尚无扫描结果(点击「重新分析」立即扫描)' : '')); + renderShopDataDuplicateMatrix(); + renderShopDataDuplicateDetailCards(); + renderPagination('shopDataDuplicatePagination', shopDataDuplicateTotal, + shopDataDuplicatePage, shopDataDuplicatePageSize, loadShopDataDuplicateCheckItems); }) .catch(function (error) { shopDataDuplicateItems = []; shopDataDuplicateTotal = 0; - list.innerHTML = '
分析失败:' + escapeHtml(error.message || '') + '
'; + list.innerHTML = '
加载失败:' + escapeHtml(error.message || '') + '
'; document.getElementById('shopDataDuplicateTotal').textContent = ''; + renderShopDataDuplicateDetailCards(); }) .finally(function () { shopDataDuplicateLoading = false; - progress.textContent = ''; - button.disabled = false; + if (progress) progress.textContent = ''; + if (exportButton) exportButton.disabled = false; }); } - function switchShopDataSubTab(view) { - var recordsView = document.getElementById('shopDataRecordsView'); - var duplicatesView = document.getElementById('shopDataDuplicatesView'); - var recordsTab = document.getElementById('shopDataSubTabRecords'); - var duplicatesTab = document.getElementById('shopDataSubTabDuplicates'); - var recordsActive = view === 'records'; - recordsView.style.display = recordsActive ? '' : 'none'; - duplicatesView.style.display = recordsActive ? 'none' : ''; - recordsTab.classList.toggle('active', recordsActive); - recordsTab.setAttribute('aria-selected', recordsActive ? 'true' : 'false'); - duplicatesTab.classList.toggle('active', !recordsActive); - duplicatesTab.setAttribute('aria-selected', !recordsActive ? 'true' : 'false'); - if (!recordsActive) { - loadShopDataDuplicateAsins(1); - } + function switchDupCheckView(view) { + if (view === shopDataDuplicateView) return; + shopDataDuplicateView = view; + var monitorTab = document.getElementById('dupCheckTabMonitor'); + var allTab = document.getElementById('dupCheckTabAll'); + var monitorActive = view === 'monitor'; + monitorTab.classList.toggle('active', monitorActive); + monitorTab.setAttribute('aria-selected', monitorActive ? 'true' : 'false'); + allTab.classList.toggle('active', !monitorActive); + allTab.setAttribute('aria-selected', !monitorActive ? 'true' : 'false'); + loadShopDataDuplicateCheckItems(1); + } + + function exportShopDataDuplicates() { + if (shopDataDuplicateExporting) return; + shopDataDuplicateExporting = true; + var progress = document.getElementById('shopDataDuplicateProgress'); + var button = document.getElementById('btnExportShopDataDuplicates'); + if (button) button.disabled = true; + if (progress) progress.textContent = '正在导出当前筛选结果...'; + var query = new URLSearchParams(buildShopDataDuplicateQuery(1)); + query.delete('page'); + query.delete('page_size'); + fetch('/api/admin/shop-data-crawl/duplicate-check-export?' + query.toString()) + .then(function (response) { + if (!response.ok) { + return response.json().then(function (data) { throw new Error(data.error || '导出失败'); }); + } + return response.blob(); + }) + .then(function (blob) { + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + var now = new Date(); + function pad(n) { return n < 10 ? '0' + n : String(n); } + a.download = '店铺数据重复检查_' + now.getFullYear() + pad(now.getMonth() + 1) + pad(now.getDate()) + + '_' + pad(now.getHours()) + pad(now.getMinutes()) + '.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(function () { URL.revokeObjectURL(url); }, 1000); + }) + .catch(function (error) { + if (progress) progress.textContent = '导出失败:' + (error.message || ''); + }) + .finally(function () { + shopDataDuplicateExporting = false; + if (button) button.disabled = false; + if (progress) progress.textContent = ''; + }); } function downloadShopDataTask(item) { @@ -2223,27 +2461,33 @@ document.getElementById('btnFilterShopDataTasks').onclick = function () { loadShopDataCrawlTasks(1); - if (document.getElementById('shopDataDuplicatesView').style.display !== 'none') { - loadShopDataDuplicateAsins(1); - } }; document.getElementById('btnResetShopDataTasks').onclick = function () { ['shopDataTaskFilterShop', 'shopDataTaskFilterGroup', 'shopDataTaskFilterCountry', 'shopDataTaskFilterFrom', 'shopDataTaskFilterTo'] .forEach(function (id) { document.getElementById(id).value = ''; }); loadShopDataCrawlTasks(1); - if (document.getElementById('shopDataDuplicatesView').style.display !== 'none') { - loadShopDataDuplicateAsins(1); - } }; - document.getElementById('shopDataSubTabRecords').onclick = function () { switchShopDataSubTab('records'); }; - document.getElementById('shopDataSubTabDuplicates').onclick = function () { switchShopDataSubTab('duplicates'); }; - document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); }; - document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateAsins(1); }; + document.getElementById('btnRefreshShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckOverview(true); }; + document.getElementById('btnFilterShopDataDuplicates').onclick = function () { loadShopDataDuplicateCheckItems(1); }; document.getElementById('btnResetShopDataDuplicates').onclick = function () { - ['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo'] + ['shopDataDupFilterAsin', 'shopDataDupFilterShop', 'shopDataDupFilterCountry', 'shopDataDupFilterSite', 'shopDataDupFilterDateFrom', 'shopDataDupFilterDateTo'] .forEach(function (id) { document.getElementById(id).value = ''; }); - loadShopDataDuplicateAsins(1); + loadShopDataDuplicateCheckItems(1); }; + document.getElementById('dupCheckTabMonitor').onclick = function () { switchDupCheckView('monitor'); }; + document.getElementById('dupCheckTabAll').onclick = function () { switchDupCheckView('all'); }; + document.getElementById('btnExportShopDataDuplicates').onclick = exportShopDataDuplicates; + document.getElementById('btnCloseDupCheckDrawer').onclick = closeShopDataDuplicateDrawer; + document.getElementById('dupCheckDrawerMask').onclick = function (event) { + if (event.target === this) closeShopDataDuplicateDrawer(); + }; + // 矩阵表格内 ASIN / 数字徽标点击打开抽屉(动态元素,事件委托) + document.getElementById('shopDataDuplicateList').onclick = function (event) { + var target = event.target.closest('[data-open-drawer]'); + if (target) openShopDataDuplicateDrawer(target.dataset.openDrawer); + }; + // 撞款详情卡片区「查看明细」按钮(动态元素,事件委托) + document.getElementById('dupCheckDetailCards').onclick = handleDuplicateDetailClick; // 全选移到表格表头后为动态元素,用事件委托 document.getElementById('shopDataTaskGrid').onchange = function (event) { var checkbox = event.target.closest('[data-shop-data-select]'); @@ -2253,18 +2497,18 @@ 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(); } }; + // 全选移到工具栏后不在 grid 容器内,单独绑定 change 事件 + document.getElementById('shopDataTaskSelectAll').onchange = function (event) { + 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]'); if (downloadButton) { @@ -2279,28 +2523,6 @@ } }; 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; @@ -2541,30 +2763,160 @@ String(u.created_by_id || '') === String(leaderUserId || ''); }); } + + // ========== 分组管理通讯录:按拼音分组 + 模糊搜索 + 字母索引 ========== + // 隐藏 select(shopManageGroupMemberSelect)仅作为数据交换层,保存逻辑不变。 + var shopManageGroupEligibleUsers = []; + var shopManageGroupSelectedIds = {}; + var shopManageGroupSearchKeyword = ''; + var shopManageGroupContactGroupsCache = []; + var SHOP_GROUP_AVATAR_COLORS = ['#f97316', '#0ea5e9', '#8b5cf6', '#10b981', '#ef4444', '#eab308', '#14b8a6', '#6366f1']; + + function shopGroupAvatarColor(name) { + var code = String(name || '#').charCodeAt(0) || 0; + return SHOP_GROUP_AVATAR_COLORS[code % SHOP_GROUP_AVATAR_COLORS.length]; + } + function shopGroupUserInitial(user) { + var abbr = String(user.pinyin_abbr || '').trim(); + if (abbr) { + var first = abbr.charAt(0).toUpperCase(); + return /^[A-Z0-9]$/.test(first) ? first : '#'; + } + var name = String(user.username || '').trim(); + if (!name) return '#'; + var first = name.charAt(0); + if (/[A-Za-z0-9]/.test(first)) return first.toUpperCase(); + return '#'; + } + function shopGroupMatchSearch(user, keyword) { + if (!keyword) return true; + var kw = keyword.toLowerCase(); + if (String(user.username || '').toLowerCase().indexOf(kw) !== -1) return true; + if (String(user.pinyin_abbr || '').toLowerCase().indexOf(kw) !== -1) return true; + return false; + } + function buildShopManageGroupContactGroups(users) { + var groups = {}; + users.forEach(function (u) { + var initial = shopGroupUserInitial(u); + var key = /^[A-Z]$/.test(initial) ? initial : '#'; + (groups[key] = groups[key] || []).push(u); + }); + var keys = Object.keys(groups).sort(function (a, b) { + if (a === '#') return 1; + if (b === '#') return -1; + return a < b ? -1 : 1; + }); + return keys.map(function (key) { + var list = groups[key].slice().sort(function (a, b) { + return String(a.username || '').localeCompare(String(b.username || ''), 'zh'); + }); + return { key: key, items: list }; + }); + } + function renderShopManageGroupContact() { + var contactEl = document.getElementById('shopManageGroupContact'); + var indexEl = document.getElementById('shopManageGroupMemberIndex'); + if (!contactEl) return; + var keyword = (shopManageGroupSearchKeyword || '').trim(); + var filtered = shopManageGroupEligibleUsers.filter(function (u) { + return shopGroupMatchSearch(u, keyword); + }); + if (!shopManageGroupEligibleUsers.length) { + contactEl.innerHTML = '
当前组长名下暂无可添加的普通员工账号。
'; + if (indexEl) indexEl.innerHTML = ''; + return; + } + if (!filtered.length) { + contactEl.innerHTML = '
未找到与「' + escapeHtml(keyword) + '」匹配的用户,可尝试输入拼音首字母,如 zwh。
'; + if (indexEl) indexEl.innerHTML = ''; + return; + } + shopManageGroupContactGroupsCache = buildShopManageGroupContactGroups(filtered); + contactEl.innerHTML = shopManageGroupContactGroupsCache.map(function (group) { + var itemsHtml = group.items.map(function (u) { + var selected = !!shopManageGroupSelectedIds[String(u.id)]; + var name = u.username || ''; + return '
' + + '' + escapeHtml(shopGroupUserInitial(u)) + '' + + '' + escapeHtml(name) + '' + + '' + + '
'; + }).join(''); + return '
' + escapeHtml(group.key) + '
' + + '
' + itemsHtml + '
'; + }).join(''); + if (indexEl) { + indexEl.innerHTML = shopManageGroupContactGroupsCache.map(function (group) { + return ''; + }).join(''); + } + } + function syncShopManageGroupMemberSelect() { + var sel = document.getElementById('shopManageGroupMemberSelect'); + if (!sel) return; + sel.innerHTML = shopManageGroupEligibleUsers.map(function (u) { + var selected = !!shopManageGroupSelectedIds[String(u.id)]; + return ''; + }).join(''); + } + function updateShopManageGroupMemberCount() { + var countEl = document.getElementById('shopManageGroupMemberCount'); + if (countEl) { + countEl.textContent = '已选 ' + Object.keys(shopManageGroupSelectedIds).length + ' 人'; + } + } + function toggleShopManageGroupMember(userId) { + var key = String(userId || ''); + if (!key) return; + var contactEl = document.getElementById('shopManageGroupContact'); + var itemEl = contactEl ? Array.prototype.find.call( + contactEl.querySelectorAll('.shop-group-contact-item'), + function (el) { return el.dataset.userId === key; } + ) : null; + if (shopManageGroupSelectedIds[key]) { + delete shopManageGroupSelectedIds[key]; + } else { + shopManageGroupSelectedIds[key] = true; + } + if (itemEl) itemEl.classList.toggle('is-selected', !!shopManageGroupSelectedIds[key]); + syncShopManageGroupMemberSelect(); + updateShopManageGroupMemberCount(); + } + function shopGroupScrollToLetter(letter) { + var contactEl = document.getElementById('shopManageGroupContact'); + if (!contactEl) return; + var groupEl = Array.prototype.find.call( + contactEl.querySelectorAll('.shop-group-contact-group'), + function (el) { return el.dataset.letter === letter; } + ); + if (groupEl) groupEl.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + function resetShopManageGroupSearch() { + shopManageGroupSearchKeyword = ''; + var searchInput = document.getElementById('shopManageGroupMemberSearch'); + if (searchInput) searchInput.value = ''; + } + function refreshShopManageGroupMemberSelect(leaderUserId, selectedUserIds) { var sel = document.getElementById('shopManageGroupMemberSelect'); var helpEl = document.getElementById('shopManageGroupMemberHelp'); if (!sel) return; - var selectedMap = {}; + resetShopManageGroupSearch(); + shopManageGroupEligibleUsers = getEligibleShopManageGroupUsers(leaderUserId); + shopManageGroupSelectedIds = {}; (selectedUserIds || []).forEach(function (id) { - selectedMap[String(id)] = true; + shopManageGroupSelectedIds[String(id)] = true; }); - var eligibleUsers = getEligibleShopManageGroupUsers(leaderUserId); - var options = eligibleUsers.map(function (u) { - return ''; - }); - if (options.length) { - sel.innerHTML = options.join(''); - if (helpEl) { - helpEl.textContent = '可添加当前组长创建的普通员工账号,按住 Ctrl 或 Command 可多选。'; - } - return; - } - sel.innerHTML = ''; + syncShopManageGroupMemberSelect(); + renderShopManageGroupContact(); + updateShopManageGroupMemberCount(); if (helpEl) { - helpEl.textContent = currentUserRole === 'normal' - ? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。' - : '当前组长名下暂无可添加的普通员工账号。'; + helpEl.textContent = shopManageGroupEligibleUsers.length + ? '可添加当前组长创建的普通员工账号,点击用户即可选中 / 取消;顶部支持按用户名或拼音首字母搜索。' + : (currentUserRole === 'normal' + ? '普通账号没有下属普通员工时,这里会为空;当前账号只能作为组长使用。' + : '当前组长名下暂无可添加的普通员工账号。'); } } function setShopManageGroupLeader(leaderUserId, leaderUsername, selectedUserIds) { @@ -2595,7 +2947,8 @@ id: u.id, username: u.username || '', role: u.role || 'normal', - created_by_id: u.created_by_id || null + created_by_id: u.created_by_id || null, + pinyin_abbr: u.pinyin_abbr || '' }; }); setShopManageGroupLeader( @@ -3891,6 +4244,28 @@ document.getElementById('btnCloseShopManageGroupModal').onclick = function () { document.getElementById('shopManageGroupModal').classList.remove('show'); }; + // 组员通讯录:搜索 / 字母索引 / 点击选中(打开弹窗时 refresh 会重置搜索词) + var groupMemberSearchInput = document.getElementById('shopManageGroupMemberSearch'); + if (groupMemberSearchInput) { + groupMemberSearchInput.addEventListener('input', function () { + shopManageGroupSearchKeyword = groupMemberSearchInput.value || ''; + renderShopManageGroupContact(); + }); + } + var groupMemberIndexEl = document.getElementById('shopManageGroupMemberIndex'); + if (groupMemberIndexEl) { + groupMemberIndexEl.addEventListener('click', function (e) { + var btn = e.target.closest('button[data-letter]'); + if (btn) shopGroupScrollToLetter(btn.dataset.letter); + }); + } + var groupMemberContactEl = document.getElementById('shopManageGroupContact'); + if (groupMemberContactEl) { + groupMemberContactEl.addEventListener('click', function (e) { + var item = e.target.closest('.shop-group-contact-item'); + if (item) toggleShopManageGroupMember(item.dataset.userId); + }); + } document.getElementById('btnSearchShopManage').onclick = function () { loadShopManage(1); }; diff --git a/backend/tests/test_admin_shop_data_duplicate_asins.py b/backend/tests/test_admin_shop_data_duplicate_asins.py index c9331adf..c094baeb 100644 --- a/backend/tests/test_admin_shop_data_duplicate_asins.py +++ b/backend/tests/test_admin_shop_data_duplicate_asins.py @@ -30,6 +30,7 @@ def _make_workbook(rows_by_sheet): class ShopDataDuplicateAsinTest(unittest.TestCase): def setUp(self): self.app = Flask(__name__) + self.app.config['SECRET_KEY'] = 'test-secret' self.group_rows = [ {'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 1)}, {'shop_name': 'Shop B', 'latest_created_at': datetime(2026, 8, 31, 1, 15, 2)}, @@ -133,23 +134,38 @@ class ShopDataDuplicateAsinTest(unittest.TestCase): self.shop_files[shop_name].save(stream) return stream.getvalue() - def _run_request(self, query=''): - cursor = self._FakeCursor( - 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) + def _build_cache(self): + """构造与旧扫描一致的缓存:3 家店铺、跨店重复 B0ABC111。""" + return { + 'scanned_at': '2026-09-03 03:10:00', + 'summary': {'shop_count': 3, 'total': 1}, + 'items': [{ + 'asin': 'B0ABC111', + 'shop_count': 2, + 'record_count': 3, + 'occurrences': [ + {'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA', + 'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['UK', 'DE']}, + {'asin': 'B0ABC111', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA', + 'shop_name': 'Shop A', 'group_name': 'Group-Shop A', 'country_codes': ['DE']}, + {'asin': 'B0ABC111', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA', + 'shop_name': 'Shop B', 'group_name': 'Group-Shop B', 'country_codes': ['UK']}, + ], + }], + } + + def _run_request(self, query='', cache=None): + # 界面默认读缓存;force 走实时扫描(需 mock 扫描核心) with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?' + query): - with patch.object(admin_api, 'get_db', return_value=connection), \ + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_api, '_latest_duplicate_scan', return_value=cache), \ patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \ - patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \ - patch.object(admin_api, '_shop_data_crawl_fetch_result_bytes', - side_effect=lambda row: self._make_workbook_bytes(row['shop_name'])): - return admin_api.shop_data_crawl_duplicate_asins.__wrapped__() + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)): + return admin_api.shop_data_crawl_duplicate_asins() def test_detects_duplicate_asins_across_shops(self): - response = self._run_request('page=1&page_size=10') + response = self._run_request('page=1&page_size=10', cache=self._build_cache()) self.assertEqual(response.status_code, 200) body = response.get_json() self.assertTrue(body['success']) @@ -165,44 +181,103 @@ class ShopDataDuplicateAsinTest(unittest.TestCase): shop_a = next(occ for occ in item['occurrences'] if occ['shop_name'] == 'Shop A') self.assertIn('UK', shop_a['country_codes']) self.assertEqual(shop_a['date'], '2026-08-30') + self.assertEqual(body['scanned_at'], '2026-09-03 03:10:00') - 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') + def test_no_cache_returns_pending_empty(self): + # 无缓存时(定时任务尚未执行):返回空 + pending 提示 + response = self._run_request('page=1&page_size=10', cache=None) body = response.get_json() + self.assertTrue(body['success']) 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() - self.assertEqual(body['total'], 1) self.assertEqual(body['items'], []) + self.assertTrue(body['pending']) + + def test_cache_filters_match_live_semantics(self): + cache = self._build_cache() + # 店铺过滤 Shop B:命中 → 完整记录 + response = self._run_request('page=1&page_size=10&shop_name=Shop+B', cache=cache) + body = response.get_json() + self.assertEqual(body['total'], 1) + self.assertEqual(body['items'][0]['asin'], 'B0ABC111') + # 日期范围 08-31 命中 + response = self._run_request('page=1&page_size=10&date_from=2026-08-31&date_to=2026-08-31', cache=cache) + body = response.get_json() + self.assertEqual(body['total'], 1) + # 国家 FR 无命中 + response = self._run_request('page=1&page_size=10&country=FR', cache=cache) + body = response.get_json() + self.assertEqual(body['total'], 0) + + def test_force_runs_live_scan(self): + # force=1 触发实时扫描并保存缓存后返回 + cursor = self._FakeCursor( + 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?page=1&page_size=10&force=1'): + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_api, 'get_db', return_value=connection), \ + patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \ + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \ + patch.object(admin_api, '_save_duplicate_scan', return_value=1), \ + patch.object(admin_api, '_latest_duplicate_scan', + return_value=self._build_cache()), \ + patch.object(admin_api, '_shop_data_crawl_fetch_result_bytes', + side_effect=lambda row: self._make_workbook_bytes(row['shop_name'])): + response = admin_api.shop_data_crawl_duplicate_asins() + self.assertIsNotNone(response) + body = response.get_json() + self.assertTrue(body['success']) + self.assertEqual(body['total'], 1) + self.assertEqual(body['items'][0]['asin'], 'B0ABC111') + + def test_force_conflict_when_lock_held(self): + # 锁被占(定时任务/他请求在扫)时 force 返回 409 + cursor = self._FakeCursor(self.group_rows[:0], []) + connection = self._FakeConnection(cursor) + with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-asins?page=1&page_size=10&force=1'): + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_api, 'get_db', return_value=connection), \ + patch.object(admin_api, '_ensure_backend_menu_access', return_value=(None, None, None)), \ + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=(None, None, None)), \ + patch.object(admin_api, '_duplicate_scan_lock'): + admin_api._duplicate_scan_lock.acquire.return_value = False + response = admin_api.shop_data_crawl_duplicate_asins() + # 409 返回 (jsonify, status) tuple + self.assertEqual(response[1], 409) + + def test_latest_duplicate_scan_deserializes_json_columns(self): + """真实读库:summary_json / payload_json 为 JSON 字符串,需反序列化为 dict/list。""" + class _ScanCursor(self._FakeCursor): + def __init__(self): + self.kind = None + + def execute(self, sql, params=()): + self.kind = 'scan' + + def fetchone(self): + return { + 'id': 9, + 'summary_json': '{"shop_count": 3, "total": 1, "source": "import"}', + 'payload_json': '[{"asin": "B0ABC111", "shop_count": 2}]', + 'created_at': datetime(2026, 9, 3, 13, 35, 25), + } + + class _ScanConnection(self._FakeConnection): + def cursor(self): + return _ScanCursor() + + with patch.object(admin_api, 'get_db', return_value=_ScanConnection(None)): + cache = admin_api._latest_duplicate_scan() + self.assertIsNotNone(cache) + self.assertEqual(cache['summary']['shop_count'], 3) + self.assertEqual(cache['summary']['source'], 'import') + self.assertEqual(cache['items'][0]['asin'], 'B0ABC111') + self.assertEqual(cache['scanned_at'], '2026-09-03 13:35:25') def test_parse_workbook_skips_unknown_sheets(self): wb = _make_workbook({'英国': [('2026-08-30', 'B0TEST01', 'GBP 1.00', '')]}) diff --git a/backend/tests/test_admin_shop_data_duplicate_check.py b/backend/tests/test_admin_shop_data_duplicate_check.py new file mode 100644 index 00000000..016f40ab --- /dev/null +++ b/backend/tests/test_admin_shop_data_duplicate_check.py @@ -0,0 +1,225 @@ +"""店铺数据重复检查接口单元测试:全量缓存格式、矩阵分页、主管/超管数据范围过滤。""" +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from flask import Flask + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from blueprints import admin_api + + +def _shop(name, group, country_codes=None): + return { + 'shop_name': name, + 'group_name': group, + 'country_codes': country_codes or [], + 'rows': [], + } + + +class DuplicateCheckApiTest(unittest.TestCase): + """直接对接口函数做单元测试:缓存通过 _latest_duplicate_scan mock 注入。""" + + def setUp(self): + self.app = Flask(__name__) + self.app.config['SECRET_KEY'] = 'test-secret' + # 4 家店 / 3 个组;ASIN 分布: + # A1: ShopA(UK) + ShopB(UK) —— 跨店重复 + # B1: ShopA(DE) 唯一 + # C1: ShopC(FR) 唯一 + # D1: ShopD(UK) 唯一 + self.cache = { + 'scanned_at': '2026-09-04 03:10:00', + 'summary': { + 'shop_count': 4, 'asin_total': 4, 'record_total': 5, + 'duplicate_asin_total': 1, 'duplicate_shop_count': 2, + 'site_count': 3, 'asin_per_shop': 1.2, 'source': 'job', + }, + 'shops': [ + {'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'asin_count': 2, 'record_count': 3}, + {'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1}, + {'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'asin_count': 1, 'record_count': 1}, + {'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'asin_count': 1, 'record_count': 1}, + ], + 'items': [ + {'asin': 'A0000001', 'shop_count': 2, 'record_count': 2, 'occurrences': [ + {'asin': 'A0000001', 'date': '2026-08-30', 'price': 'GBP 9.99', 'brand': 'BrandA', + 'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['UK', 'DE'], 'country': 'UK'}, + {'asin': 'A0000001', 'date': '2026-08-31', 'price': 'GBP 8.50', 'brand': 'BrandA', + 'shop_name': 'ShopB', 'group_name': 'GroupA', 'country_codes': ['UK'], 'country': 'UK'}, + ]}, + {'asin': 'B0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [ + {'asin': 'B0000001', 'date': '2026-08-30', 'price': 'EUR 10.99', 'brand': 'BrandA', + 'shop_name': 'ShopA', 'group_name': 'GroupA', 'country_codes': ['DE'], 'country': 'DE'}, + ]}, + {'asin': 'C0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [ + {'asin': 'C0000001', 'date': '2026-08-29', 'price': 'EUR 7.50', 'brand': 'BrandC', + 'shop_name': 'ShopC', 'group_name': 'GroupC', 'country_codes': ['FR'], 'country': 'FR'}, + ]}, + {'asin': 'D0000001', 'shop_count': 1, 'record_count': 1, 'occurrences': [ + {'asin': 'D0000001', 'date': '2026-08-28', 'price': 'GBP 5.00', 'brand': 'BrandD', + 'shop_name': 'ShopD', 'group_name': 'GroupD', 'country_codes': ['UK'], 'country': 'UK'}, + ]}, + ], + } + + def _access_patches(self, role='super_admin', current_row=None): + return [ + patch.object(admin_api, '_ensure_backend_menu_access', + return_value=(role, current_row or {'id': 1}, None)), + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', + return_value=(role, current_row or {'id': 1}, None)), + patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache), + ] + + def _call(self, url, role='super_admin', current_row=None): + with self.app.test_request_context(url): + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True): + for p in self._access_patches(role, current_row): + p.start() + try: + return admin_api.shop_data_crawl_duplicate_check_items() + finally: + for p in reversed(self._access_patches(role, current_row)): + p.stop() + + def test_overview_super_admin_sees_all(self): + with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'): + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \ + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \ + patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache): + response = admin_api.shop_data_crawl_duplicate_check_overview() + body = response.get_json() + self.assertTrue(body['success']) + self.assertFalse(body['pending']) + self.assertEqual(body['summary']['asin_total'], 4) + self.assertEqual(body['summary']['record_total'], 5) + self.assertEqual(body['summary']['duplicate_asin_total'], 1) + self.assertEqual(len(body['shops']), 4) + + def test_items_matix_columns_are_shops(self): + response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=monitor') + body = response.get_json() + self.assertTrue(body['success']) + self.assertEqual(body['total'], 1) # monitor 只保留跨店重复 + self.assertEqual(body['items'][0]['asin'], 'A0000001') + self.assertEqual([shop['shop_name'] for shop in body['shops']], + ['ShopA', 'ShopB', 'ShopC', 'ShopD']) + + def test_items_all_view_includes_unique_asins(self): + response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all') + body = response.get_json() + self.assertEqual(body['total'], 4) + + def test_items_filter_by_asin_and_site(self): + response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&asin=A0000001&site=UK') + body = response.get_json() + self.assertEqual(body['total'], 1) + self.assertEqual(body['items'][0]['asin'], 'A0000001') + + def test_leader_sees_only_own_group_shops(self): + # 主管 id=653 只管理 GroupA(含 ShopA/ShopB) + with patch.object(admin_api, '_shop_data_managed_shop_names', + return_value={'shopa', 'shopb'}): + response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all', + role='admin', current_row={'id': 653}) + body = response.get_json() + self.assertEqual([shop['shop_name'] for shop in body['shops']], ['ShopA', 'ShopB']) + self.assertEqual(body['total'], 2) # A0000001(跨店)+ B0000001(唯一) + # 跨店 ASIN 在主管范围内仍是 2 家店 + item = next(i for i in body['items'] if i['asin'] == 'A0000001') + self.assertEqual(item['shop_count'], 2) + + def test_leader_overview_stats_recomputed_after_filter(self): + # 主管见 2 家店:唯一ASIN 2、上架记录 3、重复ASIN 1、重复店铺 2 + with patch.object(admin_api, '_shop_data_managed_shop_names', + return_value={'shopa', 'shopb'}): + with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-overview'): + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_api, '_ensure_backend_menu_access', return_value=('admin', {'id': 653}, None)), \ + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('admin', {'id': 653}, None)), \ + patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache): + response = admin_api.shop_data_crawl_duplicate_check_overview() + body = response.get_json() + self.assertEqual(body['summary']['shop_count'], 2) + self.assertEqual(body['summary']['asin_total'], 2) + self.assertEqual(body['summary']['record_total'], 3) + self.assertEqual(body['summary']['duplicate_asin_total'], 1) + + def test_leader_sees_nothing_when_no_group(self): + with patch.object(admin_api, '_shop_data_managed_shop_names', return_value=set()): + response = self._call('/api/admin/shop-data-crawl/duplicate-check-items?page=1&page_size=20&view=all', + role='admin', current_row={'id': 999999}) + body = response.get_json() + self.assertEqual(body['shops'], []) + self.assertEqual(body['total'], 0) + + def test_export_csv_contains_filtered_rows(self): + """导出 CSV:行=上架记录,含 BOM,按筛选裁剪。""" + with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?'): + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \ + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \ + patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache): + response = admin_api.shop_data_crawl_duplicate_check_export() + self.assertEqual(response.status_code, 200) + text = response.get_data(as_text=True) + self.assertTrue(text.startswith('')) + lines = text.lstrip('').strip().splitlines() + self.assertEqual(lines[0], 'ASIN,店铺数,店铺,分组,站点,上架时间,价格,品牌') + self.assertEqual(len(lines), 3) # header + 两条上架记录(A0000001 两店各一条) + + def test_export_monitor_view_excludes_unique_asins(self): + with self.app.test_request_context('/api/admin/shop-data-crawl/duplicate-check-export?view=monitor'): + with patch('utils.auth.session', {'user_id': 1}), \ + patch('utils.auth.is_session_user_valid', return_value=True), \ + patch.object(admin_api, '_ensure_backend_menu_access', return_value=('super_admin', {'id': 1}, None)), \ + patch.object(admin_api, '_ensure_shop_data_crawl_data_access', return_value=('super_admin', {'id': 1}, None)), \ + patch.object(admin_api, '_latest_duplicate_scan', return_value=self.cache): + response = admin_api.shop_data_crawl_duplicate_check_export() + lines = response.get_data(as_text=True).lstrip('').strip().splitlines() + self.assertEqual(len(lines), 3) + self.assertIn('A0000001', lines[1]) + + def test_internal_request_falls_back_to_system_operator(self): + """无请求上下文(定时扫描线程)时,内部请求用系统级超管作为 operatorId。""" + with patch('utils.auth.session', {'user_id': 1}): # 仅用于模拟无异常环境 + # 无请求上下文:has_request_context() 为 False,直接走 _resolve_system_operator_id + with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \ + patch.object(admin_api, '_resolve_system_operator_id', return_value=7): + headers, params = admin_api._backend_java_internal_request() + self.assertEqual(headers.get('X-Internal-Token'), 'test-token') + self.assertEqual(params, {'operatorId': 7}) + + def test_internal_request_system_operator_missing_fails(self): + """无请求上下文且系统中没有任何管理员时,直接报错而不是传 0。""" + with patch.object(admin_api, '_resolve_internal_token', return_value='test-token'), \ + patch.object(admin_api, '_resolve_system_operator_id', return_value=None): + with self.assertRaises(ValueError): + admin_api._backend_java_internal_request() + + def test_fetch_result_bytes_rejects_json_error_body(self): + """Java 内部端点返回 JSON 错误体(如鉴权失败)时,抛出业务错误而不是 BadZipFile。""" + fake_response = Mock() + fake_response.raise_for_status = Mock() + fake_response.iter_content = Mock(return_value=[ + '{"success":false,"message":"用户不存在","data":null,"code":401}'.encode('utf-8')]) + fake_response.close = Mock() + with patch.object(admin_api, '_backend_java_internal_request', + return_value=({'X-Internal-Token': 'test-token'}, {'operatorId': 7})), \ + patch.object(admin_api, '_get_backend_java_session') as fake_session: + fake_session.return_value.get = Mock(return_value=fake_response) + with self.assertRaises(ValueError) as ctx: + admin_api._shop_data_crawl_fetch_result_bytes({'result_id': 123}) + self.assertIn('用户不存在', str(ctx.exception)) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 0d787af2..1ae0489d 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -829,38 +829,6 @@ } /* ===== 店铺数据记录 ===== */ - .shop-data-sub-tabs { - display: inline-flex; - gap: 6px; - padding: 4px; - margin-bottom: 18px; - background: #eef0f6; - border-radius: 10px; - } - - .shop-data-sub-tab { - padding: 8px 22px; - border: 0; - border-radius: 8px; - background: transparent; - color: var(--c-text-2); - font: inherit; - font-size: 14px; - cursor: pointer; - transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; - } - - .shop-data-sub-tab:hover { - color: var(--c-text); - } - - .shop-data-sub-tab.active { - background: #fff; - color: var(--c-primary); - font-weight: 700; - box-shadow: var(--shadow-card); - } - .shop-data-record-table-scroll { margin-top: 4px; max-height: none; @@ -955,15 +923,143 @@ stroke-linejoin: round; } - /* ===== 重复 ASIN 列表 ===== */ - .duplicate-asin-list { - display: flex; - flex-direction: column; + /* ===== 店铺数据卡片网格 ===== */ + .shop-data-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 14px; + padding: 4px 2px; } + .shop-data-card { + min-width: 0; + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid var(--c-border); + border-radius: 10px; + box-shadow: var(--shadow-card); + overflow: hidden; + } + + .shop-data-card.selected { + border-color: var(--c-primary); + box-shadow: 0 0 0 1px var(--c-primary), var(--shadow-card); + } + + .shop-data-card-head { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--c-primary-soft); + border-bottom: 1px solid var(--c-border); + } + + .shop-data-card-shop { + font-weight: 700; + color: var(--c-text); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .shop-data-card-select-row { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 16px; + border-bottom: 1px solid var(--c-border); + background: #fff; + } + + .shop-data-card-select-row input[type="checkbox"] { + flex: 0 0 auto; + width: 15px; + height: 15px; + accent-color: var(--c-primary); + cursor: pointer; + margin: 0; + } + + .shop-data-card-select-row .shop-data-card-task-no { + flex: 0 1 auto; + font-size: 13px; + font-weight: 600; + color: var(--c-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .shop-data-card-select-row .shop-data-status { + margin-left: auto; + } + + .shop-data-card-row { + display: flex; + align-items: baseline; + gap: 10px; + padding: 7px 16px; + font-size: 13px; + } + + .shop-data-card-label { + flex: 0 0 auto; + min-width: 38px; + color: var(--c-text-2); + } + + .shop-data-card-value { + min-width: 0; + color: var(--c-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .shop-data-card-value.shop-data-card-file { + color: var(--c-primary-strong); + word-break: break-all; + white-space: normal; + } + + .shop-data-card-actions { + display: flex; + gap: 8px; + padding: 10px 16px 14px; + border-top: 1px solid var(--c-border); + margin-top: auto; + } + + .shop-data-card-actions .shop-data-record-action { + flex: 1; + justify-content: center; + } + + .image-video-select-all { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--c-text); + font-size: 13px; + cursor: pointer; + user-select: none; + margin-left: 10px; + } + + .image-video-select-all input { + width: 15px; + height: 15px; + accent-color: var(--c-primary); + } + + /* ===== 重复 ASIN 卡片(卡内「店铺 | 上架时间」列表)===== */ .duplicate-asin-card { min-width: 0; + display: flex; + flex-direction: column; background: #fff; border: 1px solid var(--c-border); border-radius: 10px; @@ -986,9 +1082,13 @@ font-weight: 800; color: var(--c-primary-strong); letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .duplicate-asin-card-head .dup-count { + flex: 0 0 auto; padding: 2px 10px; border-radius: 999px; background: #fff; @@ -1044,6 +1144,24 @@ font-variant-numeric: tabular-nums; } + /* 卡内两列表格:内边距略收紧,无外边框(卡片自带圆角边框) */ + .dup-card-table { + border: 0; + } + + .dup-card-table th, + .dup-card-table td { + padding: 8px 16px; + } + + .dup-card-table th { + background: #fbfcfe; + } + + .dup-card-table tr:last-child td { + border-bottom: 0; + } + .shop-data-empty-hint { padding: 28px 16px; text-align: center; @@ -1066,6 +1184,422 @@ background: var(--c-primary-soft); } + .dup-asin-cell { + font-family: Consolas, Menlo, monospace; + font-size: 12.5px; + font-weight: 700; + color: var(--c-primary-strong); + } + + /* ===== 店铺数据重复检查(指标卡 + 分布 + 矩阵表格 + 抽屉)===== */ + .dup-check-detail-block { + margin-top: 16px; + } + + .dup-check-detail-head { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 10px; + } + + .dup-check-detail-title { + font-size: 14px; + font-weight: 800; + color: var(--c-text); + } + + .dup-check-detail-sub { + color: var(--c-text-3); + font-size: 12.5px; + } + + .dup-check-detail-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 14px; + max-height: 460px; + overflow-y: auto; + padding: 2px; + } + + .dup-detail-count { + display: inline-block; + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: var(--c-danger-soft); + color: var(--c-danger); + font-size: 11.5px; + font-weight: 700; + line-height: 1.6; + } + + .dup-detail-view-btn { + flex: 0 0 auto; + margin-left: 6px; + } + + .dup-check-overview { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(320px, 1.5fr); + gap: 14px; + margin-bottom: 14px; + } + + .dup-check-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + .dup-check-metric { + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 16px; + background: #fff; + border: 1px solid var(--c-border); + border-radius: 10px; + box-shadow: var(--shadow-card); + } + + .dup-check-metric-label { + color: var(--c-text-2); + font-size: 12.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .dup-check-metric-value { + color: var(--c-text); + font-size: 26px; + font-weight: 800; + font-variant-numeric: tabular-nums; + line-height: 1.1; + } + + .dup-check-metric.accent { + background: linear-gradient(120deg, var(--c-primary-soft), #fff 72%); + border-color: #d9dcfb; + } + + .dup-check-metric.accent .dup-check-metric-value { + color: var(--c-primary-strong); + } + + .dup-check-metric.warn { + background: linear-gradient(120deg, var(--c-danger-soft), #fff 72%); + border-color: #f4cdce; + } + + .dup-check-metric.warn .dup-check-metric-value { + color: var(--c-danger); + } + + .dup-check-distribution { + min-width: 0; + display: flex; + flex-direction: column; + padding: 14px 16px; + background: #fff; + border: 1px solid var(--c-border); + border-radius: 10px; + box-shadow: var(--shadow-card); + } + + .dup-check-distribution-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + } + + .dup-check-distribution-title { + font-size: 13.5px; + font-weight: 700; + color: var(--c-text); + } + + .dup-check-distribution-sub { + color: var(--c-text-3); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .dup-check-distribution-bars { + flex: 1; + min-height: 120px; + max-height: 250px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 7px; + padding-right: 2px; + } + + .dup-chart-row { + display: grid; + grid-template-columns: minmax(70px, 96px) minmax(0, 1fr) 44px; + align-items: center; + gap: 9px; + } + + .dup-chart-name { + font-size: 12px; + color: var(--c-text-2); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dup-chart-track { + height: 16px; + background: #eef0f6; + border-radius: 5px; + overflow: hidden; + } + + .dup-chart-fill { + height: 100%; + min-width: 2px; + background: linear-gradient(90deg, var(--c-primary), #8b8ff3); + border-radius: 5px; + transition: width 0.3s ease; + } + + .dup-chart-num { + font-size: 12px; + font-weight: 700; + color: var(--c-text); + text-align: right; + font-variant-numeric: tabular-nums; + } + + .dup-check-table-wrap { + background: #fff; + border: 1px solid var(--c-border); + border-radius: 10px; + box-shadow: var(--shadow-card); + padding: 14px 16px; + } + + .dup-check-toolbar { + min-height: 38px; + margin-bottom: 10px; + } + + .dup-check-view-tabs { + display: inline-flex; + gap: 4px; + padding: 3px; + background: #eef0f6; + border-radius: 9px; + } + + .dup-check-view-tab { + padding: 6px 16px; + border: 0; + border-radius: 7px; + background: transparent; + color: var(--c-text-2); + font: inherit; + font-size: 13px; + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + } + + .dup-check-view-tab:hover { + color: var(--c-text); + } + + .dup-check-view-tab.active { + background: #fff; + color: var(--c-primary); + font-weight: 700; + box-shadow: var(--shadow-card); + } + + .dup-check-table-scroll { + max-height: none; + } + + .dup-check-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + + .dup-check-table th, + .dup-check-table td { + padding: 9px 12px; + text-align: left; + border-bottom: 1px solid var(--c-border); + white-space: nowrap; + } + + .dup-check-table thead th { + background: #fbfcfe; + color: var(--c-text-2); + font-size: 12.5px; + font-weight: 700; + position: sticky; + top: 0; + z-index: 2; + border-bottom: 1px solid var(--c-border); + } + + .dup-check-table tbody tr:hover td { + background: #f8f9fd; + } + + .dup-check-table tbody tr:last-child td { + border-bottom: 0; + } + + .dup-check-table td.dup-cell { + min-width: 96px; + text-align: center; + padding: 5px 12px; + } + + .dup-cell-num { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 56px; + padding: 3px 10px; + border: 1px solid transparent; + border-radius: 6px; + font-weight: 700; + font-variant-numeric: tabular-nums; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; + } + + .dup-cell-num.zero { + color: var(--c-text-3); + background: #f4f6fa; + cursor: default; + font-weight: 400; + } + + .dup-cell-num.has { + color: var(--c-primary-strong); + background: var(--c-primary-soft); + } + + .dup-cell-num.has:hover { + border-color: #b9bcf3; + background: #e3e6fd; + } + + .dup-cell-num.danger { + color: var(--c-danger); + background: var(--c-danger-soft); + } + + .dup-cell-num.danger:hover { + border-color: #f3b4b6; + background: #fbdcdd; + } + + .dup-check-table .dup-asin-col { + font-family: Consolas, Menlo, monospace; + font-weight: 700; + color: var(--c-primary-strong); + cursor: pointer; + } + + .dup-check-table .dup-asin-col:hover { + text-decoration: underline; + } + + .dup-check-site { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 999px; + background: #fff; + color: var(--c-text-2); + font-size: 11.5px; + font-weight: 700; + line-height: 1.6; + border: 1px solid var(--c-border); + } + + .dup-check-drawer-meta { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 8px 12px; + padding: 14px 0; + border-bottom: 1px solid var(--c-border); + font-size: 13px; + } + + .dup-check-drawer-meta dt { + color: var(--c-text-2); + } + + .dup-check-drawer-meta dd { + margin: 0; + color: var(--c-text); + overflow-wrap: anywhere; + } + + .dup-check-drawer-table { + width: 100%; + border-collapse: collapse; + font-size: 12.5px; + margin-top: 8px; + } + + .dup-check-drawer-table th, + .dup-check-drawer-table td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid var(--c-border); + } + + .dup-check-drawer-table thead th { + background: #fbfcfe; + color: var(--c-text-2); + font-weight: 700; + white-space: nowrap; + } + + .dup-check-drawer-table tbody tr:hover td { + background: #f8f9fd; + } + + .dup-check-drawer-table tbody tr:last-child td { + border-bottom: 0; + } + + @media (max-width: 1400px) { + .dup-check-overview { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dup-check-distribution { + grid-column: 1 / -1; + } + } + + @media (max-width: 760px) { + .dup-check-overview { + grid-template-columns: minmax(0, 1fr); + } + + .dup-check-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + .thumb { width: 56px; height: 56px; @@ -1340,10 +1874,10 @@ } .shop-group-editor { - padding: 18px 24px 14px; + padding: 18px 0 4px; display: grid; - grid-template-columns: minmax(180px, 240px) minmax(240px, 320px) minmax(360px, 1fr); - gap: 16px; + grid-template-columns: minmax(190px, 1fr) minmax(340px, 1.6fr); + gap: 24px; align-items: start; flex: 0 0 auto; } @@ -1352,16 +1886,233 @@ margin-bottom: 0; } + .shop-group-members-panel { + position: relative; + border: 1px solid var(--c-border); + border-radius: 10px; + background: var(--c-card-raised, #f9fbfd); + overflow: hidden; + display: flex; + flex-direction: column; + } + + .shop-group-members-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 10px 14px 8px; + } + + .shop-group-members-title { + font-size: 13px; + font-weight: 600; + color: var(--c-text); + } + + .shop-group-members-count { + font-size: 12px; + color: var(--c-text-3); + } + + .shop-group-member-search { + display: flex; + align-items: center; + gap: 8px; + margin: 0 14px 10px; + padding: 7px 10px; + border: 1px solid var(--c-border); + border-radius: 8px; + background: var(--c-card, #fff); + transition: border-color 0.16s ease, box-shadow 0.16s ease; + } + + .shop-group-member-search:focus-within { + border-color: var(--c-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-primary) 18%, transparent); + } + + .shop-group-member-search svg { + flex: 0 0 15px; + width: 15px; + height: 15px; + color: var(--c-text-3); + } + + .shop-group-member-search input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + padding: 0; + background: transparent; + font-size: 13px; + color: var(--c-text); + } + + .shop-group-member-search input::placeholder { + color: var(--c-text-3); + } + + .shop-group-contact { + flex: 1 1 220px; + min-height: 200px; + max-height: 300px; + overflow-y: auto; + overflow-x: hidden; + padding: 0 14px 10px; + scrollbar-width: thin; + } + + .shop-group-contact-empty { + padding: 34px 10px; + text-align: center; + color: var(--c-text-3); + font-size: 13px; + line-height: 1.6; + } + + .shop-group-contact-group { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 2px 4px; + font-size: 12px; + color: var(--c-text-3); + font-weight: 600; + position: sticky; + top: 0; + z-index: 1; + background: var(--c-card-raised, #f9fbfd); + } + + .shop-group-contact-group::after { + content: ""; + flex: 1; + height: 1px; + background: var(--c-border); + } + + .shop-group-contact-items { + display: flex; + flex-direction: column; + } + + .shop-group-contact-item { + display: flex; + align-items: center; + gap: 9px; + padding: 6px 8px; + border-radius: 7px; + cursor: pointer; + user-select: none; + transition: background 0.14s ease; + } + + .shop-group-contact-item:hover { + background: var(--c-primary-soft); + } + + .shop-group-item-avatar { + flex: 0 0 26px; + width: 26px; + height: 26px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + color: #fff; + background: #9aa7c0; + } + + .shop-group-item-name { + flex: 1; + min-width: 0; + font-size: 13px; + color: var(--c-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .shop-group-item-check { + flex: 0 0 16px; + width: 16px; + height: 16px; + border: 1.5px solid var(--c-border-strong); + border-radius: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + color: transparent; + transition: background 0.14s ease, border-color 0.14s ease; + } + + .shop-group-item-check svg { + width: 11px; + height: 11px; + stroke-width: 3; + } + + .shop-group-contact-item.is-selected .shop-group-item-avatar { + background: var(--c-primary); + } + + .shop-group-contact-item.is-selected .shop-group-item-check { + background: var(--c-primary); + border-color: var(--c-primary); + color: #fff; + } + + .shop-group-contact-index { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 1px; + z-index: 2; + padding: 4px 2px; + } + + .shop-group-contact-index button { + border: 0; + background: transparent; + padding: 0; + min-width: 16px; + height: 16px; + line-height: 16px; + font-size: 10px; + font-weight: 600; + text-align: center; + color: var(--c-text-3); + cursor: pointer; + border-radius: 3px; + } + + .shop-group-contact-index button:hover, + .shop-group-contact-index button.is-active { + color: var(--c-primary-strong); + } + + .shop-group-contact-index button:disabled { + color: var(--c-text-3); + opacity: 0.35; + cursor: default; + } + .shop-group-member-select { - min-height: 128px; - max-height: 180px; + display: none; } .shop-group-help { color: var(--c-text-2); font-size: 12px; line-height: 1.5; - margin-top: 6px; + margin-top: 10px; } .shop-group-action-bar { @@ -1493,6 +2244,14 @@ grid-template-columns: 1fr; } + .shop-group-member-search { + margin: 0 10px 10px; + } + + .shop-group-contact { + padding: 0 28px 10px 10px; + } + .shop-group-table-wrap { margin: 0 16px; } @@ -4032,6 +4791,7 @@ +