新需求更新 同步更新
This commit is contained in:
@@ -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
@@ -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, '"')) + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-shop-key-delete="' + item.id + '" data-ziniao-account-name="' + (item.ziniao_account_name || '').replace(/"/g, '"') + '">删除</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, '"')) + '">编辑</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" data-shop-manage-delete="' + item.id + '" data-shop-manage-name="' + (item.shop_name || '').replace(/"/g, '"') + '">删除</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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user