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 '';
+ // 卡片网格:每店铺一张卡片,按截图样式(店铺名、当日累计文件、分组、最新时间、任务号+状态、国家、文件名、下载/删除)
+ 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 || '-') + '
' +
+ '' +
+ ' 0 ? '' : ' disabled') + '>' + imageVideoDownloadIcon() + '下载文件 ' +
+ ' 0 ? '' : ' disabled') + '>' + shopDataDeleteIcon() + '删除 ' +
+ '
' +
+ ' ';
+ }).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 || '-') + ' ' +
+ '店铺 / 站点 / 上架时间 / 次数 ' +
+ '';
+ 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 '';
+ }).join('');
+ return '' + escapeHtml(group.key) + '
' +
+ '' + itemsHtml + '
';
+ }).join('');
+ if (indexEl) {
+ indexEl.innerHTML = shopManageGroupContactGroupsCache.map(function (group) {
+ return '' + escapeHtml(group.key) + ' ';
+ }).join('');
+ }
+ }
+ function syncShopManageGroupMemberSelect() {
+ var sel = document.getElementById('shopManageGroupMemberSelect');
+ if (!sel) return;
+ sel.innerHTML = shopManageGroupEligibleUsers.map(function (u) {
+ var selected = !!shopManageGroupSelectedIds[String(u.id)];
+ return '' + escapeHtml(u.username || '') + ' ';
+ }).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 '' + escapeHtml(u.username || '') + ' ';
- });
- 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 @@
店铺管理
跳过跟价 ASIN
店铺数据记录
+ 店铺数据重复检查