新需求更新 同步更新

This commit is contained in:
supernijia
2026-08-06 01:11:54 +08:00
parent 9048bbb7f8
commit 28e7fce11c
112 changed files with 7739 additions and 637 deletions
+579 -8
View File
@@ -23,6 +23,7 @@ from flask import (
g,
Response,
send_file,
stream_with_context,
has_request_context,
)
@@ -44,6 +45,7 @@ admin_api = Blueprint('admin_api', __name__, url_prefix='/api/admin')
_backend_java_session_local = threading.local()
_internal_token_lock = threading.Lock()
IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data'
SHOP_DATA_CRAWL_DATA_PERMISSION_KEY = 'admin_shop_data_crawl_task_data'
ADMIN_MENU_ACCESS_CONFIG = {
'dedupe-total-data': {
@@ -81,6 +83,11 @@ ADMIN_MENU_ACCESS_CONFIG = {
'route_path': 'image-video-tasks',
'error': '无权访问视频任务管理模块',
},
'shop-data-crawl-tasks': {
'column_key': 'admin_shop_data_crawl_tasks',
'route_path': 'shop-data-crawl-tasks',
'error': '无权访问店铺数据任务管理模块',
},
}
ADMIN_MENU_ACCESS_CONFIG.update({
@@ -147,6 +154,29 @@ def _backend_java_forward_headers():
return headers
def _backend_java_internal_headers():
"""Headers for Java routes that are callable only from the Flask admin service."""
internal_token = _resolve_internal_token()
if not internal_token:
raise ValueError('内部凭据服务未配置')
headers = _backend_java_forward_headers()
headers['X-Internal-Token'] = internal_token
return headers
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('当前管理员身份无效')
return headers, {'operatorId': operator_id}
def _proxy_backend_java(
method,
path,
@@ -751,6 +781,27 @@ def _ensure_image_video_data_access():
return role, current_row, (jsonify({'success': False, 'error': '无权查看视频任务数据'}), 403)
def _ensure_shop_data_crawl_data_access():
role, current_row = get_current_admin_role()
if role == 'super_admin':
return role, current_row, None
if not role or not current_row:
return role, current_row, (jsonify({'success': False, 'error': '需要登录'}), 403)
try:
_, key_set, route_set = _effective_permission_sets(
_get_current_admin_id(current_row),
menu_type=None,
current_row=current_row,
role=role,
)
except _PermissionProxyError as exc:
return role, current_row, (exc.response, exc.status)
if (SHOP_DATA_CRAWL_DATA_PERMISSION_KEY in key_set
or 'shop-data-crawl-task-data' in route_set):
return role, current_row, None
return role, current_row, (jsonify({'success': False, 'error': '无权查看店铺数据任务'}), 403)
def _ensure_product_category_access():
role, current_row, items, denied = _load_current_backend_menu_items()
if denied:
@@ -1283,6 +1334,458 @@ def _parse_admin_datetime_arg(name):
raise ValueError(f'{name} 时间格式无效') from exc
_SHOP_DATA_CRAWL_ADMIN_COLUMNS = """
r.id AS result_id, r.task_id, r.user_id, r.source_filename AS shop_name,
r.source_file_url AS shop_id, r.result_filename, r.result_file_url,
r.result_file_size, r.result_content_type, r.row_count,
r.success AS result_success, r.error_message AS result_error,
r.created_at AS result_created_at,
t.task_no, t.status AS task_status, t.request_json, t.result_json,
t.error_message AS task_error, t.created_at, t.updated_at, t.finished_at,
u.username,
(SELECT j.id FROM biz_task_file_job j
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
AND j.job_type = 'ASSEMBLE_RESULT'
ORDER BY j.id DESC LIMIT 1) AS file_job_id,
(SELECT j.status FROM biz_task_file_job j
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
AND j.job_type = 'ASSEMBLE_RESULT'
ORDER BY j.id DESC LIMIT 1) AS file_status,
(SELECT j.error_message FROM biz_task_file_job j
WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id
AND j.job_type = 'ASSEMBLE_RESULT'
ORDER BY j.id DESC LIMIT 1) AS file_error
"""
def _shop_data_crawl_country_codes(request_json):
payload = _parse_json_value(request_json, {})
if not isinstance(payload, dict):
return []
raw = payload.get('countryCodes')
if raw is None:
raw = payload.get('country_codes')
if not isinstance(raw, list):
return []
return [str(value).strip().upper() for value in raw if str(value or '').strip()]
def _shop_data_crawl_group_names(cursor, rows):
shop_names = sorted({
_shop_data_crawl_shop_key(row.get('shop_name'))
for row in rows
if _shop_data_crawl_shop_key(row.get('shop_name'))
})
if not shop_names:
return {}
placeholders = ','.join(['%s'] * len(shop_names))
cursor.execute(
"SELECT TRIM(sm.shop_name) AS shop_name, "
"GROUP_CONCAT(DISTINCT COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, '')) "
"ORDER BY sm.id SEPARATOR '') AS group_name "
"FROM biz_shop_manage sm "
"LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id "
f"WHERE TRIM(sm.shop_name) IN ({placeholders}) GROUP BY TRIM(sm.shop_name)",
tuple(shop_names),
)
return {
_shop_data_crawl_shop_key(row.get('shop_name')): row.get('group_name') or ''
for row in cursor.fetchall()
}
def _shop_data_crawl_shop_key(value):
"""Normalize a shop name for grouping while preserving the display value."""
return str(value or '').strip().casefold()
def _shop_data_crawl_group_name(group_names, shop_name):
"""Resolve a group label from either normalized or legacy exact-key maps."""
if not group_names:
return ''
normalized = _shop_data_crawl_shop_key(shop_name)
return group_names.get(normalized, group_names.get(str(shop_name or '').strip(), '')) or ''
def _shop_data_crawl_admin_item(row, group_names=None):
result_success = row.get('result_success')
file_ready = bool((row.get('result_file_url') or '').strip())
if result_success is None or int(result_success) < 0:
success = None
else:
success = bool(int(result_success))
group_names = group_names or {}
shop_name = row.get('shop_name') or ''
file_status = row.get('file_status') or ('SUCCESS' if file_ready else '')
return {
'task_id': row.get('task_id'),
'task_no': row.get('task_no') or '',
'result_id': row.get('result_id'),
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'shop_name': shop_name,
'shop_id': row.get('shop_id') or '',
'group_name': _shop_data_crawl_group_name(group_names, shop_name),
'status': row.get('task_status') or '',
'success': success,
'error': row.get('result_error') or row.get('task_error') or row.get('file_error') or '',
'country_codes': _shop_data_crawl_country_codes(row.get('request_json')),
'output_filename': row.get('result_filename') or '',
'result_file_url': row.get('result_file_url') or '',
'file_ready': file_ready,
'file_job_id': row.get('file_job_id'),
'file_status': file_status,
'file_error': row.get('file_error') or '',
'file_size': int(row.get('result_file_size') or 0),
'row_count': int(row.get('row_count') or 0),
'created_at': _format_admin_datetime(row.get('created_at') or row.get('result_created_at')),
'updated_at': _format_admin_datetime(row.get('updated_at')),
'finished_at': _format_admin_datetime(row.get('finished_at')),
}
def _shop_data_crawl_group_item(group_row, result_rows, group_names):
"""Build one shop group and cap its children to the newest three results."""
raw_shop_name = group_row.get('shop_name') or ''
display_shop_name = raw_shop_name or '未命名'
group_key = _shop_data_crawl_shop_key(raw_shop_name)
children = result_rows.get(group_key)
if children is None:
children = result_rows.get(str(raw_shop_name).strip(), [])
children = children[:3]
result_items = [_shop_data_crawl_admin_item(row, group_names) for row in children]
latest_created_at = group_row.get('latest_created_at')
if latest_created_at is None and result_items:
latest_created_at = result_items[0].get('created_at')
return {
'shop_name': display_shop_name,
'shop_id': result_items[0].get('shop_id', '') if result_items else '',
'group_name': _shop_data_crawl_group_name(group_names, raw_shop_name),
'latest_created_at': _format_admin_datetime(latest_created_at),
'results': result_items,
}
@admin_api.route('/shop-data-crawl-task-permissions', methods=['GET', 'PUT'])
@login_required
def manage_shop_data_crawl_task_permissions():
json_data = None
if request.method == 'PUT':
data = request.get_json(silent=True) or {}
raw_user_ids = data.get('user_ids') if 'user_ids' in data else data.get('userIds')
json_data = {'userIds': raw_user_ids}
result, error_response, status = _proxy_permission_java(
request.method,
'/api/admin/shop-data-crawl-task-permissions',
json_data=json_data,
)
if error_response is not None:
return error_response, status
if request.method == 'GET':
return jsonify({'success': True, 'items': _permission_response_items(result)})
return jsonify({
'success': True,
'granted_count': result.get('data'),
'msg': result.get('message') or '店铺数据任务权限已更新',
})
@admin_api.route('/shop-data-crawl-tasks')
@login_required
def list_shop_data_crawl_tasks():
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
try:
page = max(1, int(request.args.get('page', 1)))
page_size = min(100, max(10, int(request.args.get('page_size', 20))))
shop_name = (request.args.get('shop_name') or request.args.get('shop') or '').strip()
group_name = (request.args.get('group_name') or request.args.get('group') or '').strip()
created_from = _parse_admin_datetime_arg('created_from')
created_to = _parse_admin_datetime_arg('created_to')
conditions = [
"r.module_type = 'SHOP_DATA_CRAWL'",
"t.module_type = 'SHOP_DATA_CRAWL'",
"TRIM(COALESCE(r.result_file_url, '')) <> ''",
]
params = []
if shop_name:
conditions.append('r.source_filename LIKE %s')
params.append('%' + shop_name + '%')
if group_name:
conditions.append(
'EXISTS (SELECT 1 FROM biz_shop_manage sm '
'LEFT JOIN biz_shop_manage_group g ON g.id = sm.group_id '
'WHERE TRIM(COALESCE(sm.shop_name, \'\')) = '
'TRIM(COALESCE(r.source_filename, \'\')) '
"AND COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, '')) LIKE %s)"
)
params.append('%' + group_name + '%')
if created_from:
conditions.append('t.created_at >= %s')
params.append(created_from)
if created_to:
conditions.append('t.created_at <= %s')
params.append(created_to)
where_sql = ' AND '.join(conditions)
offset = (page - 1) * page_size
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 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)
cur.execute(
'SELECT ' + shop_key_sql + ' AS shop_name, MAX(t.created_at) 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 t.created_at 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 users u ON u.id = r.user_id '
'WHERE ' + where_sql +
f' AND {shop_key_sql} IN ({placeholders})' +
') ranked WHERE ranked.shop_row_number <= 3 '
'ORDER BY ranked.created_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()
payload = {
'items': [
_shop_data_crawl_group_item(group, result_rows_by_shop, group_names)
for group in group_rows
],
'total': total,
'page': page,
'page_size': page_size,
}
# Keep the existing admin response shape while exposing the grouped
# payload for clients that use the newer data envelope.
return jsonify({'success': True, **payload, 'data': payload})
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 400
except Exception as exc:
return jsonify({'success': False, 'error': str(exc)}), 500
def _load_shop_data_crawl_download_rows(result_ids):
normalized = sorted({int(result_id) for result_id in result_ids if int(result_id) > 0})
if not normalized:
return {}
placeholders = ','.join(['%s'] * len(normalized))
conn = get_db()
try:
with conn.cursor() as cur:
cur.execute(
'SELECT r.id, r.task_id, r.user_id, r.result_filename, r.source_filename, '
'r.result_file_url, t.status AS task_status '
'FROM biz_file_result r JOIN biz_file_task t ON t.id = r.task_id '
'WHERE r.module_type = %s AND t.module_type = %s '
f'AND r.id IN ({placeholders})',
tuple(['SHOP_DATA_CRAWL', 'SHOP_DATA_CRAWL'] + normalized),
)
return {int(row['id']): row for row in cur.fetchall()}
finally:
conn.close()
def _open_shop_data_crawl_download(row):
url = f"{backend_java_base_url}/api/admin/shop-data-crawl/results/{int(row['id'])}/download"
headers, params = _backend_java_internal_request()
return _get_backend_java_session().get(
url,
params=params,
headers=headers,
stream=True,
timeout=(10, 180),
)
@admin_api.route('/shop-data-crawl-tasks/<int:result_id>/download')
@login_required
def download_shop_data_crawl_task(result_id):
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
rows = _load_shop_data_crawl_download_rows([result_id])
row = rows.get(result_id)
if not row or not (row.get('result_file_url') or '').strip():
return jsonify({'success': False, 'error': '结果文件不存在或尚未生成'}), 404
try:
remote = _open_shop_data_crawl_download(row)
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 503
except requests.RequestException as exc:
return jsonify({'success': False, 'error': f'结果文件下载失败: {exc}'}), 502
if remote.status_code != 200:
message = remote.text[:500] if remote.content else ''
remote.close()
return jsonify({'success': False, 'error': message or '结果文件下载失败'}), remote.status_code
def generate():
try:
for chunk in remote.iter_content(chunk_size=1024 * 1024):
if chunk:
yield chunk
finally:
remote.close()
response = Response(
stream_with_context(generate()),
content_type=remote.headers.get('Content-Type') or
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
)
disposition = remote.headers.get('Content-Disposition')
if disposition:
response.headers['Content-Disposition'] = disposition
else:
filename = row.get('result_filename') or f"{row.get('source_filename') or result_id}.xlsx"
response.headers['Content-Disposition'] = "attachment; filename*=UTF-8''" + quote(filename)
response.headers['Cache-Control'] = 'no-store'
return response
@admin_api.route('/shop-data-crawl-tasks/<int:result_id>', methods=['DELETE'])
@login_required
def delete_shop_data_crawl_task(result_id):
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
rows = _load_shop_data_crawl_download_rows([result_id])
row = rows.get(result_id)
if not row:
return jsonify({'success': False, 'error': '店铺数据任务不存在'}), 404
if (row.get('task_status') or '').upper() not in {'SUCCESS', 'FAILED', 'CANCELLED'}:
return jsonify({'success': False, 'error': '任务仍在处理中,不能删除'}), 409
try:
headers, params = _backend_java_internal_request()
except ValueError as exc:
return jsonify({'success': False, 'error': str(exc)}), 503
result, error_response, status = _proxy_backend_java(
'DELETE',
f'/api/admin/shop-data-crawl/history/{result_id}',
params=params,
headers=headers,
)
if error_response is not None:
return error_response, status
return jsonify({'success': True, 'msg': result.get('message') or '删除成功'})
@admin_api.route('/shop-data-crawl-tasks/download-zip', methods=['POST'])
@login_required
def download_shop_data_crawl_tasks_zip():
_, _, denied = _ensure_backend_menu_access('shop-data-crawl-tasks')
if not denied:
_, _, denied = _ensure_shop_data_crawl_data_access()
if denied:
return denied
data = request.get_json(silent=True) or {}
raw_ids = data.get('result_ids') if 'result_ids' in data else data.get('resultIds')
if not isinstance(raw_ids, list) or not raw_ids:
return jsonify({'success': False, 'error': '请至少选择一个结果文件'}), 400
if len(raw_ids) > 100:
return jsonify({'success': False, 'error': '单次最多打包 100 个结果文件'}), 400
try:
result_ids = []
for raw_id in raw_ids:
result_id = int(raw_id)
if result_id <= 0:
raise ValueError
if result_id not in result_ids:
result_ids.append(result_id)
except (TypeError, ValueError):
return jsonify({'success': False, 'error': '结果文件参数无效'}), 400
rows = _load_shop_data_crawl_download_rows(result_ids)
archive = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024, mode='w+b')
errors = []
file_count = 0
used_names = set()
try:
with zipfile.ZipFile(archive, mode='w', compression=zipfile.ZIP_STORED, allowZip64=True) as output_zip:
for result_id in result_ids:
row = rows.get(result_id)
if not row or not (row.get('result_file_url') or '').strip():
errors.append(f'result-{result_id}: 结果文件不存在或尚未生成')
continue
filename = row.get('result_filename') or f"{row.get('source_filename') or result_id}.xlsx"
filename = re.sub(r'[\\/:*?"<>|]+', '_', filename).strip() or f'result-{result_id}.xlsx'
if filename in used_names:
stem, extension = os.path.splitext(filename)
filename = f'{stem}-{result_id}{extension or ".xlsx"}'
used_names.add(filename)
remote = None
try:
remote = _open_shop_data_crawl_download(row)
remote.raise_for_status()
with output_zip.open(filename, mode='w', force_zip64=True) as target:
for chunk in remote.iter_content(chunk_size=1024 * 1024):
if chunk:
target.write(chunk)
file_count += 1
except (requests.RequestException, ValueError) as exc:
errors.append(f'{filename}: 下载失败 ({exc})')
finally:
if remote is not None:
remote.close()
if errors:
output_zip.writestr('download-errors.txt', '\n'.join(errors).encode('utf-8'))
archive.seek(0)
response = send_file(
archive,
mimetype='application/zip',
as_attachment=True,
download_name=f"shop-data-tasks-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip",
max_age=0,
)
response.headers['X-Archive-File-Count'] = str(file_count)
response.headers['X-Archive-Error-Count'] = str(len(errors))
response.call_on_close(archive.close)
return response
except Exception:
archive.close()
raise
@admin_api.route('/image-video-task-permissions', methods=['GET', 'PUT'])
@login_required
def manage_image_video_task_permissions():
@@ -1525,7 +2028,10 @@ def list_columns():
return error_response, status
items = [
item for item in items
if (item.get('column_key') or '').strip() != IMAGE_VIDEO_DATA_PERMISSION_KEY
if (item.get('column_key') or '').strip() not in {
IMAGE_VIDEO_DATA_PERMISSION_KEY,
SHOP_DATA_CRAWL_DATA_PERMISSION_KEY,
}
]
# Keep the legacy `items` field; some Java-aware callers use `data`/`columns`.
return jsonify({'success': True, 'items': items, 'columns': items, 'data': items})
@@ -2250,6 +2756,9 @@ def list_shop_keys():
'remark_name': item.get('remarkName') or '',
'ziniao_account_name': item.get('ziniaoAccountName') or '',
'ziniao_token': item.get('ziniaoToken') or '',
'ip_whitelist_status': item.get('ipWhitelistStatus') or 'UNKNOWN',
'ip_whitelist_checked_at': (item.get('ipWhitelistCheckedAt') or '').replace('T', ' ')[:19],
'ip_whitelist_message': item.get('ipWhitelistMessage') or '',
'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16],
'updated_at': (item.get('updatedAt') or '').replace('T', ' ')[:16],
}
@@ -2362,16 +2871,23 @@ def list_dedupe_total_data():
page_size = min(100, max(1, int(request.args.get('page_size', 15))))
keyword = (request.args.get('keyword') or '').strip()
username = (request.args.get('username') or '').strip()
start_date = (request.args.get('start_date') or request.args.get('startDate') or '').strip()
end_date = (request.args.get('end_date') or request.args.get('endDate') or '').strip()
params = {
'page': page,
'pageSize': page_size,
'keyword': keyword,
'username': username,
'operatorId': current_row.get('id'),
}
if start_date:
params['startDate'] = start_date
if end_date:
params['endDate'] = end_date
data, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/dedupe-total-data',
params={
'page': page,
'pageSize': page_size,
'keyword': keyword,
'username': username,
'operatorId': current_row.get('id'),
},
params=params,
)
if error_response is not None:
return error_response, status
@@ -2830,6 +3346,61 @@ def list_shop_manages():
})
@admin_api.route('/shop-manage/<int:item_id>/credential')
@login_required
def get_shop_manage_credential(item_id):
role, current_row, denied = _ensure_backend_menu_access('shop-manage')
if denied:
return denied
shop_name = (request.args.get('shop_name') or '').strip()
if not shop_name:
return jsonify({'success': False, 'error': '店铺名不能为空'}), 400
access_params = {
'page': 1,
'pageSize': 100,
'shopName': shop_name,
'superAdmin': 'true' if role == 'super_admin' else 'false',
}
if current_row and current_row.get('id'):
access_params['operatorId'] = current_row.get('id')
access_result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages',
params=access_params,
)
if error_response is not None:
return error_response, status
accessible_items = ((access_result.get('data') or {}).get('items') or [])
accessible_item = next((
item for item in accessible_items
if str(item.get('id')) == str(item_id) and (item.get('shopName') or '') == shop_name
), None)
if accessible_item is None:
return jsonify({'success': False, 'error': '店铺不存在或无权访问'}), 404
internal_token = _resolve_internal_token()
if not internal_token:
return jsonify({'success': False, 'error': '内部凭据服务未配置'}), 503
credential_result, error_response, status = _proxy_backend_java(
'GET',
'/api/admin/shop-manages/credential',
params={'shopName': shop_name},
headers={'X-Internal-Token': internal_token},
)
if error_response is not None:
return error_response, status
credential = credential_result.get('data') or {}
if str(credential.get('id')) != str(item_id):
return jsonify({'success': False, 'error': '店铺凭据不匹配'}), 409
response = jsonify({'success': True, 'password': credential.get('password') or ''})
response.headers['Cache-Control'] = 'no-store'
return response
@admin_api.route('/shop-manage', methods=['POST'])
@login_required
def create_shop_manage():
+637 -13
View File
@@ -108,6 +108,7 @@
'query-asin': 'panel-query-asin',
'product-categories': 'panel-product-categories',
'image-video-tasks': 'panel-image-video-tasks',
'shop-data-crawl-tasks': 'panel-shop-data-crawl-tasks',
'history': 'panel-history',
'version': 'panel-version',
'digital-human-version': 'panel-digital-human-version'
@@ -126,6 +127,7 @@
else if (tabName === 'query-asin') loadQueryAsin(1);
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 === 'history') loadHistory(1);
else if (tabName === 'version') loadSoftwareVersions();
else if (tabName === 'digital-human-version') loadDigitalHumanVersions();
@@ -438,7 +440,8 @@
.then(function (res) {
if (!res.success) return;
var availableColumns = (res.items || []).filter(function (item) {
if (item.column_key === 'admin_image_video_task_data') return false;
if (item.column_key === 'admin_image_video_task_data' ||
item.column_key === 'admin_shop_data_crawl_task_data') return false;
return true;
});
if (currentUserRole === 'admin') {
@@ -880,6 +883,8 @@
function updateImageVideoPermissionAccess() {
var button = document.getElementById('btnOpenImageVideoPermissions');
if (button) button.style.display = currentUserRole === 'super_admin' ? 'inline-flex' : 'none';
var shopButton = document.getElementById('btnOpenShopDataTaskPermissions');
if (shopButton) shopButton.style.display = currentUserRole === 'super_admin' ? 'inline-flex' : 'none';
}
function imageVideoPermissionUsersForView() {
if (imageVideoPermissionView === 'granted') {
@@ -1386,6 +1391,523 @@
}
};
// ========== 店铺数据任务管理 ==========
var shopDataTaskPage = 1, shopDataTaskPageSize = 20;
var shopDataTaskGroups = [];
var shopDataTasks = [];
var selectedShopDataResultIds = new Set();
var shopDataDownloadInProgress = false;
var shopDataPermissionUsers = [];
var shopDataPermissionInitialUserIds = new Set();
var selectedShopDataPermissionUserIds = new Set();
var shopDataPermissionView = 'granted';
function buildShopDataTaskQuery(page) {
var params = new URLSearchParams();
params.set('page', String(page || 1));
params.set('page_size', String(shopDataTaskPageSize));
var values = {
shop_name: document.getElementById('shopDataTaskFilterShop').value.trim(),
group_name: document.getElementById('shopDataTaskFilterGroup').value.trim(),
created_from: document.getElementById('shopDataTaskFilterFrom').value,
created_to: document.getElementById('shopDataTaskFilterTo').value
};
Object.keys(values).forEach(function (key) {
if (values[key]) params.set(key, values[key]);
});
return params.toString();
}
function shopDataResultId(item) {
if (!item) return 0;
var value = item.result_id != null ? item.result_id : item.resultId;
var id = Number(value);
return isFinite(id) && id > 0 ? id : 0;
}
function shopDataBoolean(value) {
if (typeof value === 'string') {
return ['1', 'true', 'yes', 'y'].indexOf(value.toLowerCase()) >= 0;
}
return !!value;
}
function shopDataDateValue(value) {
if (!value) return 0;
var timestamp = Date.parse(String(value).replace(' ', 'T'));
return isNaN(timestamp) ? 0 : timestamp;
}
function shopDataResultSort(a, b) {
var dateDiff = shopDataDateValue(b.created_at || b.finished_at) - shopDataDateValue(a.created_at || a.finished_at);
if (dateDiff) return dateDiff;
return shopDataResultId(b) - shopDataResultId(a);
}
function shopDataGroupKey(item) {
var name = String((item && (item.shop_name || item.shop || item.source_filename)) || '').trim();
// Keep the same trimmed, case-insensitive key as the Flask grouping query.
return 'name:' + name.toLowerCase();
}
function shopDataNormalizeResult(group, raw) {
var result = {};
Object.keys(group || {}).forEach(function (key) {
if (key !== 'results' && key !== 'group_results') result[key] = group[key];
});
Object.keys(raw || {}).forEach(function (key) {
if (key !== 'results' && key !== 'group_results') result[key] = raw[key];
});
result.shop_name = result.shop_name || result.shop || result.source_filename || '';
result.shop_id = result.shop_id || result.shopId || result.source_file_url || '';
result.group_name = result.group_name || result.group || '';
result.status = result.status || result.task_status || result.file_status || '';
result.error = result.error || result.result_error || result.error_message || result.task_error || result.file_error || '';
result.output_filename = result.output_filename || result.result_filename || result.filename || '';
result.country_codes = result.country_codes || result.countryCodes || [];
if (result.file_size == null) result.file_size = result.result_file_size;
if (result.row_count == null) result.row_count = result.rows;
if (result.finished_at == null) result.finished_at = result.completed_at;
if (result.result_id == null && raw && raw.resultId != null) result.result_id = raw.resultId;
if (result.result_id == null && raw && raw.id != null && !Array.isArray(raw.results) && !Array.isArray(raw.group_results)) result.result_id = raw.id;
if (result.file_ready == null) {
result.file_ready = !!String(result.result_file_url || result.resultFileUrl || '').trim();
} else {
result.file_ready = shopDataBoolean(result.file_ready);
}
return result;
}
// The admin API now returns one group per shop. Keep a flat result list
// for selection/actions while rendering the grouped view.
function normalizeShopDataTaskGroups(items) {
var groups = [];
var byKey = Object.create(null);
(Array.isArray(items) ? items : []).forEach(function (rawGroup) {
if (!rawGroup || typeof rawGroup !== 'object') return;
var rawResults = Array.isArray(rawGroup.results)
? rawGroup.results
: (Array.isArray(rawGroup.group_results) ? rawGroup.group_results : [rawGroup]);
var groupBase = {};
Object.keys(rawGroup).forEach(function (key) {
if (key !== 'results' && key !== 'group_results') groupBase[key] = rawGroup[key];
});
if (!groupBase.shop_name && rawResults.length) {
groupBase.shop_name = rawResults[0].shop_name || rawResults[0].shop || rawResults[0].source_filename || '';
}
if (!groupBase.shop_id && rawResults.length) {
groupBase.shop_id = rawResults[0].shop_id || rawResults[0].shopId || rawResults[0].source_file_url || '';
}
var key = shopDataGroupKey(groupBase);
var group = byKey[key];
if (!group) {
group = {
key: key,
shop_name: groupBase.shop_name || '',
shop_id: groupBase.shop_id || '',
group_name: groupBase.group_name || groupBase.group || '',
latest_created_at: groupBase.latest_created_at || '',
results: []
};
byKey[key] = group;
groups.push(group);
}
rawResults.forEach(function (rawResult) {
if (!rawResult || typeof rawResult !== 'object') return;
var result = shopDataNormalizeResult(groupBase, rawResult);
var resultId = shopDataResultId(result);
if (!resultId || !result.file_ready) return;
if (resultId && group.results.some(function (existing) { return shopDataResultId(existing) === resultId; })) return;
group.results.push(result);
if (!group.shop_name) group.shop_name = result.shop_name || '';
if (!group.shop_id) group.shop_id = result.shop_id || '';
if (!group.group_name) group.group_name = result.group_name || '';
});
});
groups.forEach(function (group) {
group.results.sort(shopDataResultSort);
group.results = group.results.slice(0, 3);
if (!group.latest_created_at && group.results.length) {
group.latest_created_at = group.results[0].created_at || group.results[0].finished_at || '';
}
});
return groups.filter(function (group) { return group.results.length > 0; });
}
function shopDataDeleteIcon() {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6h18"></path><path d="M8 6V4h8v2"></path><path d="M19 6l-1 15H6L5 6"></path><path d="M10 11v6m4-6v6"></path></svg>';
}
function renderShopDataStatus(item, status) {
var normalized = String(status || '-').toUpperCase();
var errorTitle = item && item.error ? ' title="' + escapeHtml(item.error) + '"' : '';
return '<span class="image-video-status ' + escapeHtml(normalized) + '"' + errorTitle + '>' + escapeHtml(normalized) + '</span>';
}
function renderShopDataTaskResult(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 countries = Array.isArray(countryCodes) ? countryCodes.join('、') : (String(countryCodes || '') || '-');
var filename = item.output_filename || '-';
var checkbox = '<input type="checkbox" data-shop-data-select="' + (resultId || '') + '"' +
(selected ? ' checked' : '') + (item.file_ready && resultId > 0 ? '' : ' disabled') + '>';
return '<div class="shop-data-result' + (selected ? ' selected' : '') + '" data-shop-data-card="' + (resultId || '') + '">' +
'<div class="shop-data-result-head">' +
'<label class="shop-data-task-title">' + checkbox +
'<span title="任务 #' + escapeHtml(item.task_id || item.task_no || '-') + '">任务 #' + escapeHtml(item.task_id || item.task_no || '-') + '</span>' +
'</label>' +
renderShopDataStatus(item, status || item.file_status) +
'</div>' +
'<div class="image-video-card-info">' +
'<div class="image-video-info-row"><label>国家</label><span>' + escapeHtml(countries) + '</span></div>' +
'<div class="image-video-info-row"><label>文件</label><span title="' + escapeHtml(filename) + '">' + escapeHtml(filename) + '</span></div>' +
'</div>' +
'<div class="image-video-card-actions">' +
'<button class="image-video-card-action" type="button" data-shop-data-download="' + (resultId || '') + '"' + (item.file_ready && resultId > 0 ? '' : ' disabled') + '>' + imageVideoDownloadIcon() + '下载文件</button>' +
'<button class="image-video-card-action shop-data-delete-action" type="button" data-shop-data-delete="' + (resultId || '') + '"' + (terminal && resultId > 0 ? '' : ' disabled') + '>' + shopDataDeleteIcon() + '删除</button>' +
'</div>' +
'</div>';
}
function renderShopDataTaskCard(group) {
var results = Array.isArray(group.results) ? group.results : [];
var latest = group.latest_created_at || (results[0] && (results[0].created_at || results[0].finished_at)) || '-';
return '<article class="image-video-card shop-data-task-card" data-shop-data-group="' + escapeHtml(group.key || '') + '">' +
'<div class="image-video-card-body">' +
'<div class="image-video-card-head shop-data-group-head">' +
'<div class="shop-data-task-title"><span title="' + escapeHtml(group.shop_name || '-') + '">' + escapeHtml(group.shop_name || '-') + '</span></div>' +
'<span class="shop-data-group-meta">' + results.length + '/3 份结果</span>' +
'</div>' +
'<div class="image-video-card-info">' +
'<div class="image-video-info-row"><label>分组</label><span>' + escapeHtml(group.group_name || '-') + '</span></div>' +
'<div class="image-video-info-row"><label>最新</label><span>' + escapeHtml(latest) + '</span></div>' +
'</div>' +
'<div class="shop-data-result-list">' +
(results.length ? results.map(renderShopDataTaskResult).join('') : '<div class="image-video-empty">暂无结果</div>') +
'</div>' +
'</div>' +
'</article>';
}
function syncShopDataSelectionUi() {
document.querySelectorAll('[data-shop-data-card]').forEach(function (card) {
var resultId = Number(card.dataset.shopDataCard);
var selected = selectedShopDataResultIds.has(resultId);
card.classList.toggle('selected', selected);
var checkbox = card.querySelector('[data-shop-data-select]');
if (checkbox) checkbox.checked = selected && !checkbox.disabled;
});
var selectable = shopDataTasks.filter(function (item) { return !!item.file_ready && shopDataResultId(item) > 0; });
var selectedCount = selectable.filter(function (item) {
return selectedShopDataResultIds.has(shopDataResultId(item));
}).length;
var selectAll = document.getElementById('shopDataTaskSelectAll');
selectAll.checked = selectable.length > 0 && selectedCount === selectable.length;
selectAll.indeterminate = selectedCount > 0 && selectedCount < selectable.length;
selectAll.disabled = shopDataDownloadInProgress || selectable.length === 0;
var batch = document.getElementById('btnBatchDownloadShopDataTasks');
batch.disabled = shopDataDownloadInProgress || selectedCount === 0;
batch.innerHTML = imageVideoDownloadIcon() + (shopDataDownloadInProgress
? '处理中'
: '批量下载' + (selectedCount ? ' (' + selectedCount + ')' : ''));
}
function renderShopDataTasks() {
var grid = document.getElementById('shopDataTaskGrid');
grid.innerHTML = shopDataTaskGroups.length
? shopDataTaskGroups.map(renderShopDataTaskCard).join('')
: '<div class="image-video-empty">暂无符合条件的店铺数据任务</div>';
syncShopDataSelectionUi();
}
function loadShopDataCrawlTasks(page) {
shopDataTaskPage = page || 1;
selectedShopDataResultIds.clear();
var grid = document.getElementById('shopDataTaskGrid');
grid.innerHTML = '<div class="image-video-empty">加载中...</div>';
document.getElementById('shopDataTaskDownloadProgress').textContent = '';
fetch('/api/admin/shop-data-crawl-tasks?' + buildShopDataTaskQuery(shopDataTaskPage))
.then(function (response) { return response.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '加载失败');
var payload = res.data && typeof res.data === 'object' && !Array.isArray(res.data) ? res.data : res;
shopDataTaskGroups = normalizeShopDataTaskGroups(payload.items || []);
shopDataTasks = shopDataTaskGroups.reduce(function (all, group) {
return all.concat(group.results || []);
}, []);
var total = payload.total != null ? Number(payload.total) : shopDataTaskGroups.length;
var responsePage = payload.page || page;
var responsePageSize = payload.page_size || shopDataTaskPageSize;
document.getElementById('shopDataTaskTotal').textContent = '共 ' + (total || 0) + ' 家店铺 · 每家店铺保留最新 3 份任务结果';
renderShopDataTasks();
renderPagination('shopDataTaskPagination', total, responsePage, responsePageSize, loadShopDataCrawlTasks);
})
.catch(function (error) {
shopDataTaskGroups = [];
shopDataTasks = [];
grid.innerHTML = '<div class="image-video-empty">加载失败:' + escapeHtml(error.message || '') + '</div>';
document.getElementById('shopDataTaskTotal').textContent = '';
syncShopDataSelectionUi();
});
}
function downloadShopDataTask(item) {
var resultId = shopDataResultId(item);
if (!item || !item.file_ready || !resultId) return;
var filename = item.output_filename || ('shop-data-task-' + resultId + '.xlsx');
triggerImageVideoLink('/api/admin/shop-data-crawl-tasks/' + resultId + '/download', filename, false);
}
function deleteShopDataTask(item) {
var resultId = shopDataResultId(item);
if (!item || !resultId || ['SUCCESS', 'FAILED', 'CANCELLED'].indexOf(String(item.status || item.file_status || '').toUpperCase()) < 0) return;
if (!window.confirm('确认删除店铺“' + (item.shop_name || '-') + '”的任务 #' + item.task_id + ' 及结果文件?')) return;
var progress = document.getElementById('shopDataTaskDownloadProgress');
progress.textContent = '正在删除任务 #' + item.task_id + '...';
fetch('/api/admin/shop-data-crawl-tasks/' + resultId, { method: 'DELETE' })
.then(function (response) { return response.json().then(function (data) { return { ok: response.ok, data: data }; }); })
.then(function (result) {
if (!result.ok || !result.data.success) throw new Error(result.data.error || '删除失败');
progress.textContent = result.data.msg || '删除成功';
loadShopDataCrawlTasks(shopDataTaskPage);
})
.catch(function (error) {
progress.textContent = error.message || '删除失败';
});
}
function downloadShopDataTasksZip() {
var resultIds = Array.from(selectedShopDataResultIds);
if (shopDataDownloadInProgress || !resultIds.length) return;
shopDataDownloadInProgress = true;
syncShopDataSelectionUi();
var progress = document.getElementById('shopDataTaskDownloadProgress');
progress.textContent = '正在打包 ' + resultIds.length + ' 个文件...';
fetch('/api/admin/shop-data-crawl-tasks/download-zip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result_ids: resultIds }),
__skipLoading: true
}).then(function (response) {
if (!response.ok) {
return response.json().catch(function () { return {}; }).then(function (data) {
throw new Error(data.error || '压缩包生成失败');
});
}
var filename = imageVideoZipFilename(response);
var errorCount = Number(response.headers.get('X-Archive-Error-Count') || 0);
return response.blob().then(function (blob) {
return { blob: blob, filename: filename, errorCount: errorCount };
});
}).then(function (result) {
var objectUrl = URL.createObjectURL(result.blob);
triggerImageVideoLink(objectUrl, result.filename, false);
setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 1000);
progress.textContent = result.errorCount
? '压缩包已下载,' + result.errorCount + ' 个文件失败,详见包内清单'
: '压缩包下载已开始';
}).catch(function (error) {
progress.textContent = error.message || '批量下载失败';
}).finally(function () {
shopDataDownloadInProgress = false;
syncShopDataSelectionUi();
});
}
function shopDataPermissionUsersForView() {
return shopDataPermissionView === 'granted'
? shopDataPermissionUsers.filter(function (user) {
return shopDataPermissionInitialUserIds.has(Number(user.id));
})
: shopDataPermissionUsers;
}
function filteredShopDataPermissionUsers() {
var users = shopDataPermissionUsersForView();
var keyword = (document.getElementById('shopDataTaskPermissionSearch').value || '').trim().toLowerCase();
return keyword ? users.filter(function (user) {
return String(user.username || '').toLowerCase().indexOf(keyword) >= 0;
}) : users;
}
function renderShopDataPermissionUsers() {
var visibleUsers = filteredShopDataPermissionUsers();
document.getElementById('shopDataTaskPermissionGrantedCount').textContent = '(' + shopDataPermissionInitialUserIds.size + ')';
document.getElementById('shopDataTaskPermissionAllCount').textContent = '(' + shopDataPermissionUsers.length + ')';
document.querySelectorAll('[data-shop-data-permission-view]').forEach(function (tab) {
var active = tab.dataset.shopDataPermissionView === shopDataPermissionView;
tab.classList.toggle('active', active);
tab.setAttribute('aria-selected', active ? 'true' : 'false');
});
var pendingCount = shopDataPermissionUsers.filter(function (user) {
var userId = Number(user.id);
return shopDataPermissionInitialUserIds.has(userId) !== selectedShopDataPermissionUserIds.has(userId);
}).length;
document.getElementById('shopDataTaskPermissionSummary').textContent =
(shopDataPermissionView === 'granted' ? '当前显示已分配用户,共 ' + visibleUsers.length + ' 人' : '当前显示全部用户,已分配 ' + shopDataPermissionInitialUserIds.size + ' 人') +
(pendingCount ? ' · 待保存变更 ' + pendingCount + ' 项' : '');
document.getElementById('shopDataTaskPermissionList').innerHTML = visibleUsers.length
? visibleUsers.map(function (user) {
var userId = Number(user.id);
var saved = shopDataPermissionInitialUserIds.has(userId);
var selected = selectedShopDataPermissionUserIds.has(userId);
var changed = saved !== selected;
return '<div class="image-video-permission-row">' +
'<input type="checkbox" data-shop-data-permission-user="' + userId + '"' + (selected ? ' checked' : '') + '>' +
'<span class="image-video-permission-user"><span class="image-video-permission-name">' + escapeHtml(user.username || '-') + '</span><span class="image-video-permission-role">' + escapeHtml(roleLabel(user.role)) + '</span></span>' +
'<button class="image-video-permission-state' + (changed ? ' pending' : (saved ? ' granted' : '')) + '" type="button" data-shop-data-permission-toggle="' + userId + '">' +
(changed ? (selected ? '待保存分配' : '待保存取消') : (saved ? '取消分配' : '分配')) +
'</button></div>';
}).join('')
: '<div class="image-video-permission-empty">暂无匹配用户</div>';
var selectedVisibleCount = visibleUsers.filter(function (user) {
return selectedShopDataPermissionUserIds.has(Number(user.id));
}).length;
var selectAll = document.getElementById('shopDataTaskPermissionSelectAll');
selectAll.checked = visibleUsers.length > 0 && selectedVisibleCount === visibleUsers.length;
selectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleUsers.length;
selectAll.disabled = visibleUsers.length === 0;
}
function openShopDataTaskPermissions() {
if (currentUserRole !== 'super_admin') return;
var modal = document.getElementById('shopDataTaskPermissionModal');
var saveButton = document.getElementById('btnSaveShopDataTaskPermissions');
var permissionLoaded = false;
modal.classList.add('show');
shopDataPermissionView = 'granted';
document.getElementById('shopDataTaskPermissionSearch').value = '';
document.getElementById('shopDataTaskPermissionMessage').textContent = '';
document.getElementById('shopDataTaskPermissionList').innerHTML = '<div class="image-video-permission-empty">加载中...</div>';
saveButton.disabled = true;
fetch('/api/admin/shop-data-crawl-task-permissions')
.then(function (response) { return response.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '权限加载失败');
shopDataPermissionUsers = res.items || [];
shopDataPermissionInitialUserIds = new Set(shopDataPermissionUsers.filter(function (user) { return !!user.granted; }).map(function (user) { return Number(user.id); }));
selectedShopDataPermissionUserIds = new Set(shopDataPermissionInitialUserIds);
permissionLoaded = true;
renderShopDataPermissionUsers();
}).catch(function (error) {
shopDataPermissionUsers = [];
shopDataPermissionInitialUserIds = new Set();
selectedShopDataPermissionUserIds = new Set();
document.getElementById('shopDataTaskPermissionList').innerHTML = '<div class="image-video-permission-empty">' + escapeHtml(error.message || '权限加载失败') + '</div>';
document.getElementById('shopDataTaskPermissionMessage').textContent = '权限加载失败,请关闭后重试';
}).finally(function () {
saveButton.disabled = !permissionLoaded;
});
}
function closeShopDataTaskPermissions() {
document.getElementById('shopDataTaskPermissionModal').classList.remove('show');
}
function saveShopDataTaskPermissions() {
var message = document.getElementById('shopDataTaskPermissionMessage');
message.textContent = '保存中...';
message.className = 'msg';
document.getElementById('btnSaveShopDataTaskPermissions').disabled = true;
fetch('/api/admin/shop-data-crawl-task-permissions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_ids: Array.from(selectedShopDataPermissionUserIds).sort(function (a, b) { return a - b; }) })
}).then(function (response) { return response.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '权限保存失败');
shopDataPermissionInitialUserIds = new Set(selectedShopDataPermissionUserIds);
renderShopDataPermissionUsers();
message.textContent = res.msg || '保存成功';
message.className = 'msg ok';
}).catch(function (error) {
message.textContent = error.message || '权限保存失败';
message.className = 'msg err';
}).finally(function () {
document.getElementById('btnSaveShopDataTaskPermissions').disabled = false;
});
}
document.getElementById('btnFilterShopDataTasks').onclick = function () { loadShopDataCrawlTasks(1); };
document.getElementById('btnResetShopDataTasks').onclick = function () {
['shopDataTaskFilterShop', 'shopDataTaskFilterGroup', 'shopDataTaskFilterFrom', 'shopDataTaskFilterTo']
.forEach(function (id) { document.getElementById(id).value = ''; });
loadShopDataCrawlTasks(1);
};
document.getElementById('shopDataTaskSelectAll').onchange = function (event) {
shopDataTasks.forEach(function (item) {
var resultId = shopDataResultId(item);
if (!item.file_ready || !resultId) return;
if (event.target.checked) selectedShopDataResultIds.add(resultId);
else selectedShopDataResultIds.delete(resultId);
});
syncShopDataSelectionUi();
};
document.getElementById('btnBatchDownloadShopDataTasks').onclick = downloadShopDataTasksZip;
document.getElementById('shopDataTaskGrid').onchange = function (event) {
var checkbox = event.target.closest('[data-shop-data-select]');
if (!checkbox) return;
var resultId = Number(checkbox.dataset.shopDataSelect);
if (!resultId) return;
if (checkbox.checked) selectedShopDataResultIds.add(resultId);
else selectedShopDataResultIds.delete(resultId);
syncShopDataSelectionUi();
};
document.getElementById('shopDataTaskGrid').onclick = function (event) {
var downloadButton = event.target.closest('[data-shop-data-download]');
if (downloadButton) {
var downloadItem = shopDataTasks.find(function (task) { return shopDataResultId(task) === Number(downloadButton.dataset.shopDataDownload); });
downloadShopDataTask(downloadItem);
return;
}
var deleteButton = event.target.closest('[data-shop-data-delete]');
if (deleteButton) {
var deleteItem = shopDataTasks.find(function (task) { return shopDataResultId(task) === Number(deleteButton.dataset.shopDataDelete); });
deleteShopDataTask(deleteItem);
}
};
document.getElementById('btnOpenShopDataTaskPermissions').onclick = openShopDataTaskPermissions;
document.getElementById('btnCloseShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
document.getElementById('btnCancelShopDataTaskPermissions').onclick = closeShopDataTaskPermissions;
document.getElementById('btnSaveShopDataTaskPermissions').onclick = saveShopDataTaskPermissions;
document.querySelectorAll('[data-shop-data-permission-view]').forEach(function (tab) {
tab.onclick = function () {
shopDataPermissionView = tab.dataset.shopDataPermissionView || 'granted';
document.getElementById('shopDataTaskPermissionSearch').value = '';
renderShopDataPermissionUsers();
};
});
document.getElementById('shopDataTaskPermissionSearch').oninput = renderShopDataPermissionUsers;
document.getElementById('shopDataTaskPermissionSelectAll').onchange = function (event) {
filteredShopDataPermissionUsers().forEach(function (user) {
if (event.target.checked) selectedShopDataPermissionUserIds.add(Number(user.id));
else selectedShopDataPermissionUserIds.delete(Number(user.id));
});
renderShopDataPermissionUsers();
};
document.getElementById('shopDataTaskPermissionList').onclick = function (event) {
var button = event.target.closest('[data-shop-data-permission-toggle]');
if (!button) return;
var userId = Number(button.dataset.shopDataPermissionToggle);
if (selectedShopDataPermissionUserIds.has(userId)) selectedShopDataPermissionUserIds.delete(userId);
else selectedShopDataPermissionUserIds.add(userId);
renderShopDataPermissionUsers();
};
document.getElementById('shopDataTaskPermissionList').onchange = function (event) {
var checkbox = event.target.closest('[data-shop-data-permission-user]');
if (!checkbox) return;
var userId = Number(checkbox.dataset.shopDataPermissionUser);
if (checkbox.checked) selectedShopDataPermissionUserIds.add(userId);
else selectedShopDataPermissionUserIds.delete(userId);
renderShopDataPermissionUsers();
};
document.getElementById('shopDataTaskPermissionModal').onclick = function (event) {
if (event.target === event.currentTarget) closeShopDataTaskPermissions();
};
var historyPage = 1, historyPageSize = 15;
function toSqlDatetime(val) {
if (!val) return '';
@@ -1636,15 +2158,33 @@
// ========== 数据去重总数据 ==========
var dedupeTotalDataPage = 1, dedupeTotalDataPageSize = 15;
function getDedupeTotalDataDateRange() {
return {
startDate: document.getElementById('exportDedupeTotalDataStartDate').value || '',
endDate: document.getElementById('exportDedupeTotalDataEndDate').value || ''
};
}
function validateDedupeTotalDataDateRange() {
var dateRange = getDedupeTotalDataDateRange();
if (dateRange.startDate && dateRange.endDate && dateRange.startDate > dateRange.endDate) {
alert('开始日期不能晚于结束日期');
return false;
}
return true;
}
function buildDedupeTotalDataQuery(page) {
var q = 'page=' + (page || 1) + '&page_size=' + dedupeTotalDataPageSize;
var keyword = (document.getElementById('searchDedupeTotalData').value || '').trim();
var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim();
var dateRange = getDedupeTotalDataDateRange();
if (keyword) q += '&keyword=' + encodeURIComponent(keyword);
if (username) q += '&username=' + encodeURIComponent(username);
if (dateRange.startDate) q += '&start_date=' + encodeURIComponent(dateRange.startDate);
if (dateRange.endDate) q += '&end_date=' + encodeURIComponent(dateRange.endDate);
return q;
}
function loadDedupeTotalData(page) {
if (!validateDedupeTotalDataDateRange()) return;
dedupeTotalDataPage = page || 1;
fetch('/api/admin/dedupe-total-data?' + buildDedupeTotalDataQuery(dedupeTotalDataPage))
.then(function (r) { return r.json(); })
@@ -1698,16 +2238,12 @@
document.getElementById('btnSearchDedupeTotalData').onclick = function () { loadDedupeTotalData(1); };
document.getElementById('btnExportDedupeTotalData').onclick = function () {
var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim();
var startDate = document.getElementById('exportDedupeTotalDataStartDate').value || '';
var endDate = document.getElementById('exportDedupeTotalDataEndDate').value || '';
if (startDate && endDate && startDate > endDate) {
alert('开始日期不能晚于结束日期');
return;
}
var dateRange = getDedupeTotalDataDateRange();
if (!validateDedupeTotalDataDateRange()) return;
var params = [];
if (username) params.push('username=' + encodeURIComponent(username));
if (startDate) params.push('start_date=' + encodeURIComponent(startDate));
if (endDate) params.push('end_date=' + encodeURIComponent(endDate));
if (dateRange.startDate) params.push('start_date=' + encodeURIComponent(dateRange.startDate));
if (dateRange.endDate) params.push('end_date=' + encodeURIComponent(dateRange.endDate));
fetch('/api/admin/dedupe-total-data/export' + (params.length ? ('?' + params.join('&')) : ''))
.then(function (response) {
var contentType = response.headers.get('content-type') || '';
@@ -2112,6 +2648,20 @@
function buildShopKeyQuery(page) {
return 'page=' + (page || 1) + '&page_size=' + shopKeyPageSize;
}
function renderShopKeyWhitelistStatus(item) {
var status = String(item.ip_whitelist_status || 'UNKNOWN').toUpperCase();
var statusMeta = {
ALLOWED: { label: '正常', className: 'is-allowed' },
BLOCKED: { label: '未加白名单', className: 'is-blocked' },
UNKNOWN: { label: '未检测', className: 'is-unknown' }
};
var meta = statusMeta[status] || statusMeta.UNKNOWN;
var details = [];
if (item.ip_whitelist_checked_at) details.push('检测时间:' + item.ip_whitelist_checked_at);
if (item.ip_whitelist_message) details.push(item.ip_whitelist_message);
return '<span class="shop-key-whitelist-status ' + meta.className + '" title="' +
escapeHtml(details.join('\n')) + '">' + escapeHtml(meta.label) + '</span>';
}
function loadShopKeys(page) {
shopKeyPage = page || 1;
fetch('/api/admin/shop-keys?' + buildShopKeyQuery(shopKeyPage))
@@ -2124,11 +2674,11 @@
}
var items = res.items || [];
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-tip">暂无店铺密钥</td></tr>';
tbody.innerHTML = '<tr><td colspan="8" class="empty-tip">暂无店铺密钥</td></tr>';
} else {
tbody.innerHTML = items.map(function (item, index) {
var rowNo = (shopKeyPage - 1) * shopKeyPageSize + index + 1;
return '<tr><td>' + rowNo + '</td><td>' + (item.remark_name || '') + '</td><td>' + (item.ziniao_account_name || '') + '</td><td>' + (item.ziniao_token || '') + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
return '<tr><td>' + rowNo + '</td><td>' + escapeHtml(item.remark_name || '') + '</td><td>' + escapeHtml(item.ziniao_account_name || '') + '</td><td>' + escapeHtml(item.ziniao_token || '') + '</td><td>' + renderShopKeyWhitelistStatus(item) + '</td><td>' + escapeHtml(item.created_at || '') + '</td><td>' + escapeHtml(item.updated_at || '') + '</td><td>' +
'<button class="btn btn-sm" data-shop-key-edit="' + item.id + '" data-shop-key="' + (JSON.stringify(item).replace(/"/g, '&quot;')) + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-shop-key-delete="' + item.id + '" data-ziniao-account-name="' + (item.ziniao_account_name || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</td></tr>';
@@ -2138,7 +2688,7 @@
bindShopKeyActions();
})
.catch(function () {
document.getElementById('shopKeyListBody').innerHTML = '<tr><td colspan="7" class="empty-tip">请求失败</td></tr>';
document.getElementById('shopKeyListBody').innerHTML = '<tr><td colspan="8" class="empty-tip">请求失败</td></tr>';
});
}
function bindShopKeyActions() {
@@ -2264,6 +2814,21 @@
return query;
}
function shopPasswordIcon(revealed) {
if (revealed) {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m2 2 20 20"></path><path d="M6.71 6.71C4.7 8.1 3.34 10.08 2 12c2.12 3.04 5.5 6 10 6 1.67 0 3.17-.41 4.47-1.05"></path><path d="M10.73 5.08A9.36 9.36 0 0 1 12 5c4.5 0 7.88 2.96 10 7a15.82 15.82 0 0 1-2.12 2.91"></path><path d="M14.12 14.12A3 3 0 0 1 9.88 9.88"></path></svg>';
}
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2.06 12.35a1 1 0 0 1 0-.7C3.54 8.04 7.06 5.5 12 5.5s8.46 2.54 9.94 6.15a1 1 0 0 1 0 .7C20.46 15.96 16.94 18.5 12 18.5S3.54 15.96 2.06 12.35"></path><circle cx="12" cy="12" r="3"></circle></svg>';
}
function renderShopPasswordCell(item) {
var maskedPassword = item.password || '******';
return '<span class="shop-password-cell">' +
'<span class="shop-password-value" data-shop-password-value>' + escapeHtml(maskedPassword) + '</span>' +
'<button type="button" class="shop-password-toggle" data-shop-password-toggle="' + escapeHtml(item.id) + '" data-shop-name="' + escapeHtml(item.shop_name || '') + '" data-masked-password="' + escapeHtml(maskedPassword) + '" aria-label="显示密码" aria-pressed="false" title="显示密码">' +
shopPasswordIcon(false) + '</button></span>';
}
function loadShopManage(page) {
shopManagePage = page || 1;
fetch('/api/admin/shop-manages?' + buildShopManageQuery(shopManagePage))
@@ -2280,7 +2845,7 @@
} else {
tbody.innerHTML = items.map(function (item, index) {
var rowNo = (shopManagePage - 1) * shopManagePageSize + index + 1;
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td><td>' + (item.mall_name || '') + '</td><td>' + (item.account || '') + '</td><td>' + (item.password || '') + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
return '<tr><td>' + rowNo + '</td><td>' + (item.group_name || '') + '</td><td>' + (item.shop_name || '') + '</td><td>' + (item.mall_name || '') + '</td><td>' + (item.account || '') + '</td><td>' + renderShopPasswordCell(item) + '</td><td>' + (item.created_at || '') + '</td><td>' + (item.updated_at || '') + '</td><td>' +
'<button class="btn btn-sm" data-shop-manage-edit="' + item.id + '" data-shop-manage="' + (JSON.stringify(item).replace(/"/g, '&quot;')) + '">编辑</button> ' +
'<button class="btn btn-sm btn-danger" data-shop-manage-delete="' + item.id + '" data-shop-manage-name="' + (item.shop_name || '').replace(/"/g, '&quot;') + '">删除</button>' +
'</td></tr>';
@@ -2295,6 +2860,42 @@
}
function bindShopManageActions() {
document.querySelectorAll('[data-shop-password-toggle]').forEach(function (btn) {
btn.onclick = function () {
var valueEl = btn.parentElement.querySelector('[data-shop-password-value]');
var revealed = btn.dataset.revealed === 'true';
if (revealed) {
valueEl.textContent = btn.dataset.maskedPassword || '******';
btn.dataset.revealed = 'false';
btn.setAttribute('aria-label', '显示密码');
btn.setAttribute('aria-pressed', 'false');
btn.title = '显示密码';
btn.innerHTML = shopPasswordIcon(false);
return;
}
btn.disabled = true;
valueEl.textContent = '读取中...';
fetch('/api/admin/shop-manage/' + encodeURIComponent(btn.dataset.shopPasswordToggle) + '/credential?shop_name=' + encodeURIComponent(btn.dataset.shopName || ''))
.then(function (r) { return r.json(); })
.then(function (res) {
if (!res.success) throw new Error(res.error || '读取密码失败');
valueEl.textContent = res.password || '';
btn.dataset.revealed = 'true';
btn.setAttribute('aria-label', '隐藏密码');
btn.setAttribute('aria-pressed', 'true');
btn.title = '隐藏密码';
btn.innerHTML = shopPasswordIcon(true);
})
.catch(function (error) {
valueEl.textContent = btn.dataset.maskedPassword || '******';
alert(error.message || '读取密码失败');
})
.finally(function () {
btn.disabled = false;
});
};
});
document.querySelectorAll('[data-shop-manage-edit]').forEach(function (btn) {
btn.onclick = function () {
var item = {};
@@ -4472,6 +5073,7 @@
html += '<span class="page-item' + active + '" onclick="loadDigitalHumanVersions(' + i + ')">' + i + '</span>';
}
pagination.innerHTML = html;
appendPaginationQuickJump(pagination, pages, current, loadDigitalHumanVersions);
} else {
pagination.innerHTML = '';
}
@@ -4918,6 +5520,27 @@
if (document.getElementById('editColumnMenuType')) document.getElementById('editColumnMenuType').onchange = populateColumnParentSelects;
// ========== 分页 ==========
function appendPaginationQuickJump(el, totalPages, page, onPage) {
el.insertAdjacentHTML('beforeend',
'<label class="pagination-jump">跳至<input type="number" min="1" max="' + totalPages + '" step="1" inputmode="numeric" aria-label="跳转页码" data-page-jump-input>页</label>' +
'<button type="button" data-page-jump>跳转</button>');
var jumpInput = el.querySelector('[data-page-jump-input]');
var jumpToPage = function () {
var targetPage = parseInt(jumpInput.value, 10);
if (isNaN(targetPage)) {
jumpInput.focus();
return;
}
targetPage = Math.min(Math.max(targetPage, 1), totalPages);
jumpInput.value = targetPage;
if (targetPage !== page) onPage(targetPage);
};
el.querySelector('[data-page-jump]').onclick = jumpToPage;
jumpInput.onkeydown = function (event) {
if (event.key === 'Enter') jumpToPage();
};
}
function renderPagination(elId, total, page, pageSize, onPage) {
var el = document.getElementById(elId);
if (!el) return;
@@ -4929,6 +5552,7 @@
el.querySelectorAll('[data-p]').forEach(function (b) {
if (!b.disabled) b.onclick = function () { onPage(parseInt(b.dataset.p, 10)); };
});
appendPaginationQuickJump(el, totalPages, page, onPage);
}
// 初始化
@@ -0,0 +1,199 @@
import sys
import unittest
from datetime import datetime
from pathlib import Path
from unittest.mock import patch
from flask import Flask
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from blueprints import admin_api
class _FakeCursor:
def __init__(self, group_rows, result_rows, group_names):
self.group_rows = group_rows
self.result_rows = result_rows
self.group_names = group_names
self.kind = None
self.current_shop = None
self.windowed_results = False
self.group_limit = None
self.group_offset = 0
self.calls = []
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def execute(self, sql, params=()):
self.calls.append((sql, tuple(params)))
if 'COUNT(*) AS total' in sql:
self.kind = 'count'
elif 'MAX(t.created_at)' in sql:
self.kind = 'groups'
self.group_limit = int(params[-2])
self.group_offset = int(params[-1])
elif 'GROUP_CONCAT' in sql:
self.kind = 'group_names'
else:
self.kind = 'results'
self.windowed_results = 'ROW_NUMBER() OVER' in sql
self.current_shop = None if self.windowed_results else (str(params[-1]) if params else None)
def fetchone(self):
return {'total': len(self.group_rows)}
def fetchall(self):
if self.kind == 'groups':
end = self.group_offset + self.group_limit
return self.group_rows[self.group_offset:end]
if self.kind == 'group_names':
return self.group_names
if self.kind == 'results':
rows = [row for row in self.result_rows if str(row.get('result_file_url') or '').strip()]
if self.windowed_results:
counts = {}
limited = []
for row in rows:
shop_key = row['shop_name'].strip().casefold()
if counts.get(shop_key, 0) >= 3:
continue
counts[shop_key] = counts.get(shop_key, 0) + 1
limited.append(row)
return limited
if self.current_shop is not None:
rows = [row for row in rows if row['shop_name'].strip() == self.current_shop]
return rows[:3]
return []
class _FakeConnection:
def __init__(self, cursor):
self.cursor_value = cursor
def cursor(self):
return self.cursor_value
def close(self):
pass
class AdminShopDataGroupTest(unittest.TestCase):
def setUp(self):
self.app = Flask(__name__)
self.group_rows = [
{'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 5, 12, 0)},
{'shop_name': '', 'latest_created_at': datetime(2026, 8, 4, 12, 0)},
]
self.result_rows = [
self._result_row(6, 'Shop A', '2026-08-05T13:00:00', result_file_url=''),
self._result_row(5, 'Shop A', '2026-08-05T12:00:00'),
self._result_row(4, 'Shop A', '2026-08-04T12:00:00'),
self._result_row(3, 'Shop A', '2026-08-03T12:00:00'),
self._result_row(2, 'Shop A', '2026-08-02T12:00:00'),
self._result_row(1, '', '2026-08-04T11:00:00'),
]
@staticmethod
def _result_row(result_id, shop_name, created_at, result_file_url=None):
return {
'result_id': result_id,
'task_id': result_id + 100,
'user_id': 7,
'shop_name': shop_name,
'shop_id': shop_name.lower(),
'task_no': f'task-{result_id}',
'task_status': 'SUCCESS',
'result_success': 1,
'result_error': None,
'task_error': None,
'file_error': None,
'result_file_url': f'object-{result_id}' if result_file_url is None else result_file_url,
'result_filename': f'result-{result_id}.xlsx',
'result_file_size': 10,
'row_count': 2,
'request_json': '{}',
'created_at': created_at,
'updated_at': created_at,
'finished_at': created_at,
'file_job_id': None,
'file_status': 'SUCCESS',
'username': 'operator',
}
def test_group_item_caps_children_and_preserves_child_result_ids(self):
group = admin_api._shop_data_crawl_group_item(
{'shop_name': 'Shop A', 'latest_created_at': datetime(2026, 8, 5, 12, 0)},
{'shop a': [row for row in self.result_rows if row['result_file_url']][:4]},
{'shop a': 'Group 1'},
)
self.assertEqual(group['shop_name'], 'Shop A')
self.assertEqual(group['group_name'], 'Group 1')
self.assertEqual([item['result_id'] for item in group['results']], [5, 4, 3])
self.assertEqual(group['results'][0]['result_file_url'], 'object-5')
def test_list_paginates_groups_and_ignores_removed_user_status_filters(self):
cursor = _FakeCursor(
self.group_rows,
self.result_rows,
[{'shop_name': 'Shop A', 'group_name': 'Group 1'}],
)
connection = _FakeConnection(cursor)
with self.app.test_request_context(
'/api/admin/shop-data-crawl-tasks?page=1&page_size=10'
'&username=should-not-filter&status=FAILED&shop_name=Shop&group_name=Group'
'&created_from=2026-08-01T00:00'
):
with 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)):
response = admin_api.list_shop_data_crawl_tasks.__wrapped__()
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertEqual(body['items'], body['data']['items'])
payload = body['data']
self.assertEqual(payload['total'], 2)
self.assertEqual(payload['page'], 1)
self.assertEqual(payload['items'][0]['shop_name'], 'Shop A')
self.assertEqual(len(payload['items'][0]['results']), 3)
self.assertEqual(
[item['result_id'] for item in payload['items'][0]['results']],
[5, 4, 3],
)
self.assertEqual(payload['items'][1]['shop_name'], '未命名')
self.assertEqual(len(payload['items'][1]['results']), 1)
params = [param for _sql, call_params in cursor.calls for param in call_params]
self.assertNotIn('should-not-filter', params)
self.assertNotIn('FAILED', params)
self.assertTrue(any('GROUP BY TRIM(COALESCE(r.source_filename, ' in sql for sql, _ in cursor.calls))
self.assertTrue(any('ROW_NUMBER() OVER' in sql for sql, _ in cursor.calls))
self.assertTrue(any('shop_row_number <= 3' in sql for sql, _ in cursor.calls))
self.assertTrue(any('TRIM(COALESCE(sm.shop_name' in sql for sql, _ in cursor.calls))
self.assertTrue(any("TRIM(COALESCE(r.result_file_url, '')) <> ''" in sql for sql, _ in cursor.calls))
def test_list_returns_empty_items_when_group_page_is_out_of_range(self):
cursor = _FakeCursor(self.group_rows, self.result_rows, [])
connection = _FakeConnection(cursor)
with self.app.test_request_context(
'/api/admin/shop-data-crawl-tasks?page=2&page_size=10'
):
with 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)):
response = admin_api.list_shop_data_crawl_tasks.__wrapped__()
payload = response.get_json()['data']
self.assertEqual(payload['total'], 2)
self.assertEqual(payload['page'], 2)
self.assertEqual(payload['items'], [])
self.assertFalse(any('ROW_NUMBER() OVER' in sql for sql, _ in cursor.calls))
if __name__ == '__main__':
unittest.main()
+323 -17
View File
@@ -354,6 +354,31 @@
cursor: not-allowed;
}
.pagination-jump {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #666;
white-space: nowrap;
}
.pagination-jump input {
width: 64px;
height: 32px;
padding: 4px 6px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
text-align: center;
}
.pagination-jump input:focus {
border-color: #667eea;
outline: none;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
}
.thumb {
width: 60px;
height: 60px;
@@ -446,6 +471,100 @@
z-index: 1010;
}
.shop-key-whitelist-status {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 8px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.shop-key-whitelist-status.is-allowed {
color: #18794e;
background: #eaf8f0;
border-color: #b9e3ca;
}
.shop-key-whitelist-status.is-blocked {
color: #b42318;
background: #fff0ee;
border-color: #f2c0ba;
}
.shop-key-whitelist-status.is-unknown {
color: #667085;
background: #f2f4f7;
border-color: #d0d5dd;
}
.shop-key-table-scroll {
width: 100%;
overflow-x: auto;
}
.shop-key-table-scroll table {
min-width: 900px;
}
.shop-password-cell {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 86px;
}
.shop-password-value {
min-width: 48px;
max-width: 220px;
overflow-wrap: anywhere;
font-family: Consolas, "Courier New", monospace;
}
.shop-password-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 28px;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 4px;
color: #667085;
background: transparent;
cursor: pointer;
}
.shop-password-toggle:hover:not(:disabled) {
color: #5268d9;
background: #eef1ff;
}
.shop-password-toggle:focus-visible {
outline: 2px solid #667eea;
outline-offset: 2px;
}
.shop-password-toggle:disabled {
cursor: wait;
opacity: 0.5;
}
.shop-password-toggle svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
pointer-events: none;
}
.dedupe-group-access {
display: flex;
align-items: center;
@@ -1286,6 +1405,113 @@
background: #fafbfb;
}
.shop-data-task-card .image-video-card-body {
min-height: 0;
display: flex;
flex-direction: column;
}
.shop-data-task-card .image-video-card-info {
flex: none;
}
.shop-data-group-head {
margin-bottom: 8px;
}
.shop-data-group-meta {
display: flex;
align-items: center;
gap: 8px;
color: #777;
font-size: 12px;
white-space: nowrap;
}
.shop-data-result-list {
margin-top: 8px;
border-top: 1px solid #edf0ef;
}
.shop-data-result {
padding: 13px 0 12px;
border-bottom: 1px solid #edf0ef;
}
.shop-data-result:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.shop-data-result.selected {
margin-left: -8px;
margin-right: -8px;
padding-left: 8px;
padding-right: 8px;
border-radius: 4px;
background: #f3fbf7;
}
.shop-data-result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.shop-data-result .shop-data-task-title {
font-size: 13px;
}
.shop-data-result .image-video-card-info {
margin-top: 9px;
}
.shop-data-result .image-video-card-actions {
margin-top: 11px;
}
.shop-data-task-title {
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
color: #2b3532;
font-size: 15px;
font-weight: 600;
cursor: pointer;
}
.shop-data-task-title input {
flex: 0 0 auto;
width: 17px;
height: 17px;
accent-color: #27b38b;
}
.shop-data-task-title span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shop-data-result .image-video-status[title] {
cursor: help;
}
.shop-data-delete-action {
margin-left: auto;
border-color: #d96b6b;
color: #b33a3a;
}
.shop-data-delete-action:hover:not(:disabled) {
border-color: #b33a3a;
background: #b33a3a;
color: #fff;
}
.image-video-permission-btn {
display: none;
min-height: 36px;
@@ -1790,11 +2016,11 @@
<input type="text" id="searchDedupeTotalDataUsername" placeholder="输入用户名">
</div>
<div class="form-group">
<label>导出开始日期</label>
<label>开始日期</label>
<input type="date" id="exportDedupeTotalDataStartDate">
</div>
<div class="form-group">
<label>导出结束日期</label>
<label>结束日期</label>
<input type="date" id="exportDedupeTotalDataEndDate">
</div>
<div class="dedupe-filter-actions">
@@ -1883,20 +2109,23 @@
</div>
<div class="panel-box">
<h3 style="margin-bottom:16px;font-size:15px;">店铺密钥列表</h3>
<table>
<thead>
<tr>
<th>序号</th>
<th>备注名</th>
<th>紫鸟账号名称</th>
<th>紫鸟令牌</th>
<th>创建时间</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="shopKeyListBody"></tbody>
</table>
<div class="shop-key-table-scroll">
<table>
<thead>
<tr>
<th>序号</th>
<th>备注名</th>
<th>紫鸟账号名称</th>
<th>紫鸟令牌</th>
<th>白名单状态</th>
<th>创建时间</th>
<th>修改时间</th>
<th>操作</th>
</tr>
</thead>
<tbody id="shopKeyListBody"></tbody>
</table>
</div>
<div class="pagination" id="shopKeyPagination"></div>
</div>
</div>
@@ -2304,6 +2533,50 @@
</div>
</div>
<div id="panel-shop-data-crawl-tasks" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">店铺数据任务筛选</h3>
<div class="form-row">
<div class="form-group" style="min-width:150px;"><label>店铺</label><input type="text"
id="shopDataTaskFilterShop" placeholder="模糊搜索店铺名"></div>
<div class="form-group" style="min-width:140px;"><label>分组</label><input type="text"
id="shopDataTaskFilterGroup" placeholder="模糊搜索分组"></div>
<div class="form-group" style="min-width:170px;"><label>创建开始</label><input
type="datetime-local" id="shopDataTaskFilterFrom"></div>
<div class="form-group" style="min-width:170px;"><label>创建结束</label><input
type="datetime-local" id="shopDataTaskFilterTo"></div>
<button class="btn" id="btnFilterShopDataTasks" type="button">查询</button>
<button class="btn btn-secondary" id="btnResetShopDataTasks" type="button">重置</button>
</div>
</div>
<div class="panel-box image-video-panel-box">
<div class="image-video-results">
<div class="image-video-toolbar">
<div class="image-video-toolbar-main">
<button class="btn btn-secondary image-video-permission-btn"
id="btnOpenShopDataTaskPermissions" type="button">权限配置</button>
<button class="btn image-video-batch-btn" id="btnBatchDownloadShopDataTasks"
type="button" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"></path>
</svg>
批量下载
</button>
<label class="image-video-select-all">
<input type="checkbox" id="shopDataTaskSelectAll">
全选当前页
</label>
<span class="image-video-download-progress" id="shopDataTaskDownloadProgress"
aria-live="polite"></span>
</div>
<span class="image-video-summary" id="shopDataTaskTotal"></span>
</div>
<div class="image-video-grid" id="shopDataTaskGrid" aria-live="polite"></div>
</div>
<div class="pagination" id="shopDataTaskPagination"></div>
</div>
</div>
<div id="panel-history" class="tab-panel">
<div class="form-box">
<h3 style="margin-bottom:16px;font-size:15px;">筛选条件</h3>
@@ -2746,7 +3019,40 @@
</div>
</div>
</div>
<script src="/static/admin.js?v=permission-tree-groups-1"></script>
<div class="modal-mask" id="shopDataTaskPermissionModal">
<div class="modal image-video-permission-modal" role="dialog" aria-modal="true"
aria-labelledby="shopDataTaskPermissionTitle">
<div class="image-video-permission-head">
<h3 id="shopDataTaskPermissionTitle">店铺数据任务权限配置</h3>
<button class="btn btn-secondary btn-sm" id="btnCloseShopDataTaskPermissions"
type="button">关闭</button>
</div>
<div class="image-video-permission-tabs" role="tablist" aria-label="店铺数据任务权限用户范围">
<button class="image-video-permission-tab active" type="button" role="tab"
aria-selected="true" data-shop-data-permission-view="granted">已分配用户 <span
id="shopDataTaskPermissionGrantedCount">(0)</span></button>
<button class="image-video-permission-tab" type="button" role="tab" aria-selected="false"
data-shop-data-permission-view="all">全部用户 <span
id="shopDataTaskPermissionAllCount">(0)</span></button>
</div>
<div class="image-video-permission-tools">
<input class="image-video-permission-search" id="shopDataTaskPermissionSearch" type="search"
placeholder="搜索用户名">
<label class="image-video-select-all">
<input type="checkbox" id="shopDataTaskPermissionSelectAll">
全选搜索结果
</label>
</div>
<p class="image-video-permission-summary" id="shopDataTaskPermissionSummary"></p>
<div class="image-video-permission-list" id="shopDataTaskPermissionList" aria-live="polite"></div>
<p class="msg" id="shopDataTaskPermissionMessage" aria-live="polite"></p>
<div class="image-video-permission-actions">
<button class="btn btn-secondary" id="btnCancelShopDataTaskPermissions" type="button">取消</button>
<button class="btn" id="btnSaveShopDataTaskPermissions" type="button">保存</button>
</div>
</div>
</div>
<script src="/static/admin.js?v=shop-data-task-admin-1"></script>
</body>
</html>