视频任务管理更新
This commit is contained in:
@@ -4,13 +4,15 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from flask import Blueprint, request, jsonify, session, current_app, g, Response
|
||||
from flask import Blueprint, request, jsonify, session, current_app, g, Response, send_file
|
||||
|
||||
import pymysql
|
||||
from werkzeug.security import generate_password_hash
|
||||
@@ -52,6 +54,8 @@ _column_sort_schema_checked = False
|
||||
_column_sort_schema_lock = threading.Lock()
|
||||
_product_category_schema_checked = False
|
||||
_product_category_schema_lock = threading.Lock()
|
||||
IMAGE_VIDEO_PERMISSION_KEY = 'admin_image_video_tasks'
|
||||
IMAGE_VIDEO_DATA_PERMISSION_KEY = 'admin_image_video_task_data'
|
||||
|
||||
ADMIN_MENU_ACCESS_CONFIG = {
|
||||
'dedupe-total-data': {
|
||||
@@ -288,10 +292,12 @@ def _image_video_admin_item(row, include_json=False):
|
||||
'task_id': row.get('id'),
|
||||
'user_id': row.get('user_id'),
|
||||
'username': row.get('username') or '',
|
||||
'group_name': row.get('group_name') or '',
|
||||
'mode': _image_video_mode(row.get('request_json')),
|
||||
'status': row.get('status') or '',
|
||||
'coze_status': row.get('coze_status') or '',
|
||||
'coze_execute_id': row.get('coze_execute_id') or '',
|
||||
'videos': videos,
|
||||
'video_url': videos[0]['display_url'] if videos else '',
|
||||
'debug_url': row.get('debug_url') or '',
|
||||
'archive_status': row.get('archive_status') or '',
|
||||
@@ -302,7 +308,6 @@ def _image_video_admin_item(row, include_json=False):
|
||||
}
|
||||
if include_json:
|
||||
item.update({
|
||||
'videos': videos,
|
||||
'error_message': row.get('error_message') or '',
|
||||
'archived_at': _format_admin_datetime(row.get('archived_at')),
|
||||
'request': _mask_image_video_secrets(_parse_json_value(row.get('request_json'), {})),
|
||||
@@ -625,17 +630,17 @@ def _load_current_admin_menu_items():
|
||||
role, current_row = get_current_admin_role()
|
||||
if not role or not current_row:
|
||||
return None, None, None, (jsonify({'success': False, 'error': '需要管理员权限'}), 403)
|
||||
if role == 'super_admin':
|
||||
items = [
|
||||
_format_permission_item(item)
|
||||
for item in _load_local_columns('admin')
|
||||
if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
|
||||
]
|
||||
return role, current_row, items, None
|
||||
user_id = _get_current_admin_id(current_row)
|
||||
if not user_id:
|
||||
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/permission-users/{user_id}/column-permissions',
|
||||
params={'menuType': 'admin'},
|
||||
)
|
||||
if error_response is not None:
|
||||
return role, current_row, None, (error_response, status)
|
||||
items = [_format_permission_item(item) for item in (result.get('data') or [])]
|
||||
items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
|
||||
return role, current_row, items, None
|
||||
|
||||
|
||||
@@ -643,17 +648,17 @@ def _load_current_backend_menu_items():
|
||||
role, current_row = get_current_admin_role()
|
||||
if not role or not current_row:
|
||||
return None, None, None, (jsonify({'success': False, 'error': '需要登录'}), 403)
|
||||
if role == 'super_admin':
|
||||
items = [
|
||||
_format_permission_item(item)
|
||||
for item in _load_local_columns('admin')
|
||||
if (item.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
|
||||
]
|
||||
return role, current_row, items, None
|
||||
user_id = _get_current_admin_id(current_row)
|
||||
if not user_id:
|
||||
return role, current_row, None, (jsonify({'success': False, 'error': '当前登录用户缺少有效ID'}), 400)
|
||||
result, error_response, status = _proxy_backend_java(
|
||||
'GET',
|
||||
f'/api/admin/permission-users/{user_id}/column-permissions',
|
||||
params={'menuType': 'admin'},
|
||||
)
|
||||
if error_response is not None:
|
||||
return role, current_row, None, (error_response, status)
|
||||
items = [_format_permission_item(item) for item in (result.get('data') or [])]
|
||||
items = [_format_permission_item(item) for item in _get_local_user_column_permission_items(user_id, 'admin')]
|
||||
return role, current_row, items, None
|
||||
|
||||
|
||||
@@ -676,6 +681,30 @@ def _ensure_backend_menu_access(*menu_names):
|
||||
return role, current_row, (jsonify({'success': False, 'error': error_message}), 403)
|
||||
|
||||
|
||||
def _ensure_image_video_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)
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
data_column_id = _image_video_permission_column_id(cur)
|
||||
cur.execute(
|
||||
"SELECT 1 FROM user_column_permission ucp "
|
||||
"WHERE ucp.user_id = %s AND ucp.column_id = %s LIMIT 1",
|
||||
(int(current_row['id']), data_column_id),
|
||||
)
|
||||
granted = cur.fetchone()
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
if granted:
|
||||
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:
|
||||
@@ -781,7 +810,8 @@ def list_users():
|
||||
search_username = (request.args.get('username') or request.args.get('search') or '').strip()
|
||||
created_by_id_arg = request.args.get('created_by_id') or request.args.get('admin_id')
|
||||
created_by_id = int(created_by_id_arg) if created_by_id_arg and str(created_by_id_arg).isdigit() else None
|
||||
can_view_all_users = role == 'super_admin' or (current_row and (current_row.get('username') or '').strip().lower() == 'admin')
|
||||
# 用户范围只由角色决定,用户名不能绕过权限模型。
|
||||
can_view_all_users = role == 'super_admin'
|
||||
can_view_child_users = role == 'admin' and not can_view_all_users
|
||||
if not can_view_all_users:
|
||||
created_by_id = None
|
||||
@@ -935,10 +965,23 @@ def create_user():
|
||||
(username, pwd_hash, is_admin, want_role, want_created_by),
|
||||
)
|
||||
new_uid = cur.lastrowid
|
||||
_set_user_column_permissions(cur, new_uid, column_ids)
|
||||
_validate_admin_granted_columns(cur, role, current_row, column_ids)
|
||||
_set_user_column_permissions(
|
||||
cur,
|
||||
new_uid,
|
||||
column_ids,
|
||||
preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '用户创建成功'})
|
||||
except PermissionError as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'success': False, 'error': str(exc)}), 403
|
||||
except pymysql.IntegrityError:
|
||||
return jsonify({'success': False, 'error': '用户名已存在'})
|
||||
except Exception as e:
|
||||
@@ -994,10 +1037,23 @@ def update_user(uid):
|
||||
(is_admin, want_role, uid),
|
||||
)
|
||||
if 'column_ids' in data:
|
||||
_set_user_column_permissions(cur, uid, data.get('column_ids'))
|
||||
_validate_admin_granted_columns(cur, role, current_row, data.get('column_ids'))
|
||||
_set_user_column_permissions(
|
||||
cur,
|
||||
uid,
|
||||
data.get('column_ids'),
|
||||
preserve_column_keys=(IMAGE_VIDEO_DATA_PERMISSION_KEY,),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'success': True, 'msg': '更新成功'})
|
||||
except PermissionError as exc:
|
||||
try:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({'success': False, 'error': str(exc)}), 403
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
@@ -1118,7 +1174,15 @@ _IMAGE_VIDEO_ADMIN_COLUMNS = """
|
||||
t.id, t.user_id, t.status, t.request_json, t.submit_response_json, t.result_json,
|
||||
t.video_urls_json, t.debug_url, t.archived_videos_json, t.archive_status,
|
||||
t.archive_error, t.archive_attempt_count, t.archived_at, t.error_message,
|
||||
t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username
|
||||
t.coze_execute_id, t.coze_status, t.submitted_at, t.completed_at, u.username,
|
||||
COALESCE((
|
||||
SELECT GROUP_CONCAT(DISTINCT g.group_name ORDER BY g.id SEPARATOR '、')
|
||||
FROM biz_shop_manage_group g
|
||||
LEFT JOIN biz_shop_manage_group_member gm ON gm.group_id = g.id
|
||||
WHERE g.created_by_id = t.user_id
|
||||
OR g.user_id = t.user_id
|
||||
OR gm.user_id = t.user_id
|
||||
), '') AS group_name
|
||||
"""
|
||||
|
||||
|
||||
@@ -1132,10 +1196,220 @@ def _parse_admin_datetime_arg(name):
|
||||
raise ValueError(f'{name} 时间格式无效') from exc
|
||||
|
||||
|
||||
def _image_video_permission_column_id(cur):
|
||||
cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,))
|
||||
row = cur.fetchone()
|
||||
if not row or row.get('id') is None:
|
||||
# 兼容 V74 尚未执行的旧库:创建数据权限栏目,并继承已有菜单授权。
|
||||
cur.execute(
|
||||
"INSERT INTO columns (name, column_key, menu_type, route_path, sort_order) "
|
||||
"SELECT %s, %s, 'internal', 'image-video-task-data', 0 "
|
||||
"WHERE NOT EXISTS (SELECT 1 FROM columns WHERE column_key = %s)",
|
||||
('视频任务数据查看', IMAGE_VIDEO_DATA_PERMISSION_KEY, IMAGE_VIDEO_DATA_PERMISSION_KEY),
|
||||
)
|
||||
cur.execute("SELECT id FROM columns WHERE column_key = %s LIMIT 1", (IMAGE_VIDEO_DATA_PERMISSION_KEY,))
|
||||
row = cur.fetchone()
|
||||
if not row or row.get('id') is None:
|
||||
raise ValueError('视频任务数据权限栏目不存在')
|
||||
cur.execute(
|
||||
"INSERT IGNORE INTO user_column_permission (user_id, column_id) "
|
||||
"SELECT old_perm.user_id, %s FROM user_column_permission old_perm "
|
||||
"INNER JOIN columns old_col ON old_col.id = old_perm.column_id "
|
||||
"WHERE old_col.column_key = %s",
|
||||
(int(row['id']), IMAGE_VIDEO_PERMISSION_KEY),
|
||||
)
|
||||
return int(row['id'])
|
||||
|
||||
|
||||
def _is_stored_super_admin_sql(alias='u'):
|
||||
return (
|
||||
f"(LOWER(COALESCE({alias}.role, '')) = 'super_admin' OR "
|
||||
f"((COALESCE({alias}.role, '') = '') AND {alias}.is_admin = 1 AND {alias}.created_by_id IS NULL))"
|
||||
)
|
||||
|
||||
|
||||
@admin_api.route('/image-video-task-permissions', methods=['GET', 'PUT'])
|
||||
@login_required
|
||||
def manage_image_video_task_permissions():
|
||||
role, _ = get_current_admin_role()
|
||||
if role != 'super_admin':
|
||||
return jsonify({'success': False, 'error': '仅超级管理员可以配置视频任务权限'}), 403
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
column_id = _image_video_permission_column_id(cur)
|
||||
if request.method == 'GET':
|
||||
cur.execute(
|
||||
"SELECT u.id, u.username, u.role, u.is_admin, u.created_by_id, "
|
||||
"CASE WHEN ucp.user_id IS NULL THEN 0 ELSE 1 END AS granted "
|
||||
"FROM users u LEFT JOIN user_column_permission ucp "
|
||||
"ON ucp.user_id = u.id AND ucp.column_id = %s "
|
||||
f"WHERE NOT {_is_stored_super_admin_sql('u')} "
|
||||
"ORDER BY u.username ASC, u.id ASC",
|
||||
(column_id,),
|
||||
)
|
||||
items = []
|
||||
for row in cur.fetchall():
|
||||
stored_role = (row.get('role') or '').strip().lower()
|
||||
effective_role = stored_role or ('admin' if row.get('is_admin') else 'normal')
|
||||
items.append({
|
||||
'id': int(row['id']),
|
||||
'username': row.get('username') or '',
|
||||
'role': effective_role,
|
||||
'granted': bool(row.get('granted')),
|
||||
})
|
||||
return jsonify({'success': True, 'items': items})
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_user_ids = data.get('user_ids')
|
||||
if not isinstance(raw_user_ids, list):
|
||||
return jsonify({'success': False, 'error': 'user_ids 必须是数组'}), 400
|
||||
try:
|
||||
user_ids = sorted({int(value) for value in raw_user_ids if not isinstance(value, bool) and int(value) > 0})
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'success': False, 'error': 'user_ids 包含无效用户 ID'}), 400
|
||||
if len(user_ids) != len(raw_user_ids):
|
||||
return jsonify({'success': False, 'error': 'user_ids 包含重复或无效用户 ID'}), 400
|
||||
|
||||
if user_ids:
|
||||
placeholders = ','.join(['%s'] * len(user_ids))
|
||||
cur.execute(
|
||||
f"SELECT id FROM users u WHERE id IN ({placeholders}) AND NOT {_is_stored_super_admin_sql('u')}",
|
||||
tuple(user_ids),
|
||||
)
|
||||
valid_ids = {int(row['id']) for row in cur.fetchall()}
|
||||
if valid_ids != set(user_ids):
|
||||
return jsonify({'success': False, 'error': '包含不存在或不可授权的用户'}), 400
|
||||
|
||||
cur.execute(
|
||||
"DELETE ucp FROM user_column_permission ucp "
|
||||
"INNER JOIN users u ON u.id = ucp.user_id "
|
||||
f"WHERE ucp.column_id = %s AND NOT {_is_stored_super_admin_sql('u')}",
|
||||
(column_id,),
|
||||
)
|
||||
for user_id in user_ids:
|
||||
cur.execute(
|
||||
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
|
||||
(user_id, column_id),
|
||||
)
|
||||
conn.commit()
|
||||
return jsonify({'success': True, 'granted_count': len(user_ids), 'msg': '视频任务权限已更新'})
|
||||
except ValueError as exc:
|
||||
return jsonify({'success': False, 'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
if request.method == 'PUT':
|
||||
conn.rollback()
|
||||
return jsonify({'success': False, 'error': str(exc)}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@admin_api.route('/image-video-tasks/download-zip', methods=['POST'])
|
||||
@login_required
|
||||
def download_image_video_tasks_zip():
|
||||
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
|
||||
if not denied:
|
||||
_, _, denied = _ensure_image_video_data_access()
|
||||
if denied:
|
||||
return denied
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_items = data.get('items')
|
||||
if not isinstance(raw_items, list) or not raw_items:
|
||||
return jsonify({'success': False, 'error': '请至少选择一个视频'}), 400
|
||||
if len(raw_items) > 100:
|
||||
return jsonify({'success': False, 'error': '单次最多打包 100 个视频'}), 400
|
||||
|
||||
selections = []
|
||||
seen = set()
|
||||
try:
|
||||
for item in raw_items:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError
|
||||
task_id = int(item.get('task_id'))
|
||||
video_index = int(item.get('video_index'))
|
||||
if task_id <= 0 or video_index < 0:
|
||||
raise ValueError
|
||||
key = (task_id, video_index)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
selections.append(key)
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({'success': False, 'error': '视频选择参数无效'}), 400
|
||||
|
||||
task_ids = sorted({task_id for task_id, _ in selections})
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
placeholders = ','.join(['%s'] * len(task_ids))
|
||||
cur.execute(
|
||||
'SELECT ' + _IMAGE_VIDEO_ADMIN_COLUMNS +
|
||||
' FROM biz_image_video_async_task t LEFT JOIN users u ON u.id = t.user_id '
|
||||
f"WHERE t.task_type = 'IMAGE_VIDEO_WORKFLOW' "
|
||||
f"AND t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY) "
|
||||
f"AND t.id IN ({placeholders})",
|
||||
tuple(task_ids),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
tasks = {int(row['id']): row for row in rows}
|
||||
|
||||
archive = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024, mode='w+b')
|
||||
errors = []
|
||||
file_count = 0
|
||||
try:
|
||||
with zipfile.ZipFile(archive, mode='w', compression=zipfile.ZIP_STORED, allowZip64=True) as output_zip:
|
||||
for task_id, video_index in selections:
|
||||
row = tasks.get(task_id)
|
||||
videos = _image_video_urls(row) if row else []
|
||||
if video_index >= len(videos) or not videos[video_index].get('display_url'):
|
||||
errors.append(f'task-{task_id}-video-{video_index + 1}: 视频地址不存在')
|
||||
continue
|
||||
url = videos[video_index]['display_url']
|
||||
extension_match = re.search(r'\.([a-z0-9]{2,5})(?:[?#]|$)', url, re.IGNORECASE)
|
||||
extension = extension_match.group(1).lower() if extension_match else 'mp4'
|
||||
filename = f'task-{task_id}-video-{video_index + 1}.{extension}'
|
||||
remote_response = None
|
||||
try:
|
||||
remote_response = requests.get(url, stream=True, timeout=(10, 120))
|
||||
remote_response.raise_for_status()
|
||||
with output_zip.open(filename, mode='w', force_zip64=True) as target:
|
||||
for chunk in remote_response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
target.write(chunk)
|
||||
file_count += 1
|
||||
except requests.RequestException as exc:
|
||||
errors.append(f'{filename}: 下载失败 ({exc})')
|
||||
finally:
|
||||
if remote_response is not None:
|
||||
remote_response.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"video-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-tasks')
|
||||
@admin_required
|
||||
@login_required
|
||||
def list_image_video_tasks():
|
||||
role, current_row, denied = _ensure_admin_menu_access('image-video-tasks')
|
||||
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
|
||||
if not denied:
|
||||
_, _, denied = _ensure_image_video_data_access()
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
@@ -1148,8 +1422,11 @@ def list_image_video_tasks():
|
||||
submitted_from = _parse_admin_datetime_arg('submitted_from')
|
||||
submitted_to = _parse_admin_datetime_arg('submitted_to')
|
||||
|
||||
access_sql, params = _image_video_access_sql(role, current_row)
|
||||
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'"]
|
||||
params = []
|
||||
conditions = [
|
||||
"t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
|
||||
"t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
|
||||
]
|
||||
if user_id:
|
||||
conditions.append('t.user_id = %s')
|
||||
params.append(user_id)
|
||||
@@ -1203,14 +1480,19 @@ def list_image_video_tasks():
|
||||
|
||||
|
||||
@admin_api.route('/image-video-tasks/<int:task_id>')
|
||||
@admin_required
|
||||
@login_required
|
||||
def get_image_video_task(task_id):
|
||||
role, current_row, denied = _ensure_admin_menu_access('image-video-tasks')
|
||||
_, _, denied = _ensure_backend_menu_access('image-video-tasks')
|
||||
if not denied:
|
||||
_, _, denied = _ensure_image_video_data_access()
|
||||
if denied:
|
||||
return denied
|
||||
access_sql, params = _image_video_access_sql(role, current_row)
|
||||
conditions = [access_sql, "t.task_type = 'IMAGE_VIDEO_WORKFLOW'", 't.id = %s']
|
||||
params.append(task_id)
|
||||
conditions = [
|
||||
"t.task_type = 'IMAGE_VIDEO_WORKFLOW'",
|
||||
"t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)",
|
||||
't.id = %s',
|
||||
]
|
||||
params = [task_id]
|
||||
conn = get_db()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
@@ -1248,6 +1530,7 @@ def list_columns():
|
||||
'created_at': str(row.get('created_at') or '')[:16].replace('T', ' '),
|
||||
}
|
||||
for row in local_rows
|
||||
if (row.get('column_key') or '') != IMAGE_VIDEO_DATA_PERMISSION_KEY
|
||||
]
|
||||
return jsonify({'success': True, 'items': items})
|
||||
|
||||
@@ -1405,16 +1688,50 @@ def get_user_column_permissions(uid):
|
||||
return jsonify({'success': True, 'items': [_format_permission_item(item) for item in items]})
|
||||
|
||||
|
||||
def _set_user_column_permissions(cur, user_id, column_ids):
|
||||
def _set_user_column_permissions(cur, user_id, column_ids, preserve_column_keys=()):
|
||||
"""设置用户栏目权限:先删后插。cur 为已打开的游标。"""
|
||||
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,))
|
||||
if column_ids:
|
||||
column_ids = [int(x) for x in column_ids if x]
|
||||
for cid in column_ids:
|
||||
requested_ids = {int(x) for x in (column_ids or []) if x}
|
||||
preserved_ids = set()
|
||||
if preserve_column_keys:
|
||||
placeholders = ','.join(['%s'] * len(preserve_column_keys))
|
||||
cur.execute(
|
||||
f"SELECT id FROM columns WHERE column_key IN ({placeholders})",
|
||||
tuple(preserve_column_keys),
|
||||
)
|
||||
protected_ids = {int(row['id']) for row in cur.fetchall() if row.get('id') is not None}
|
||||
requested_ids.difference_update(protected_ids)
|
||||
if protected_ids:
|
||||
id_placeholders = ','.join(['%s'] * len(protected_ids))
|
||||
cur.execute(
|
||||
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
|
||||
(user_id, cid),
|
||||
f"SELECT column_id FROM user_column_permission WHERE user_id = %s AND column_id IN ({id_placeholders})",
|
||||
(user_id, *sorted(protected_ids)),
|
||||
)
|
||||
preserved_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None}
|
||||
cur.execute("DELETE FROM user_column_permission WHERE user_id = %s", (user_id,))
|
||||
for cid in sorted(requested_ids | preserved_ids):
|
||||
cur.execute(
|
||||
"INSERT INTO user_column_permission (user_id, column_id) VALUES (%s, %s)",
|
||||
(user_id, cid),
|
||||
)
|
||||
|
||||
|
||||
def _validate_admin_granted_columns(cur, role, current_row, column_ids):
|
||||
"""普通管理员只能把自己已拥有的栏目授予其直接管理的普通用户。"""
|
||||
if role != 'admin' or not current_row or not column_ids:
|
||||
return
|
||||
requested_ids = {int(value) for value in column_ids if str(value).isdigit() and int(value) > 0}
|
||||
if not requested_ids:
|
||||
return
|
||||
placeholders = ','.join(['%s'] * len(requested_ids))
|
||||
cur.execute(
|
||||
f"SELECT column_id FROM user_column_permission "
|
||||
f"WHERE user_id = %s AND column_id IN ({placeholders})",
|
||||
(int(current_row['id']), *sorted(requested_ids)),
|
||||
)
|
||||
owned_ids = {int(row['column_id']) for row in cur.fetchall() if row.get('column_id') is not None}
|
||||
denied_ids = requested_ids - owned_ids
|
||||
if denied_ids:
|
||||
raise PermissionError('普通管理员只能分配自己已有的菜单权限')
|
||||
|
||||
|
||||
# ---------- 版本管理(web_config) ----------
|
||||
|
||||
@@ -59,8 +59,8 @@ file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/"
|
||||
backend_java_base_url, backend_java_base_url_source = _get_env(
|
||||
"java_api_base",
|
||||
"BACKEND_JAVA_BASE_URL",
|
||||
default="http://api.aishufu.top:18080/",
|
||||
# default="http://127.0.0.1:18080/",
|
||||
# default="http://api.aishufu.top:18080/",
|
||||
default="http://127.0.0.1:18080/",
|
||||
)
|
||||
backend_java_base_url = backend_java_base_url.rstrip("/")
|
||||
os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId
|
||||
|
||||
@@ -380,11 +380,30 @@
|
||||
}
|
||||
var allColumnsList = [];
|
||||
function loadColumnsForPermission() {
|
||||
fetch('/api/admin/columns')
|
||||
.then(function (r) { return r.json(); })
|
||||
var ownedKeys = {};
|
||||
var ownedRoutes = {};
|
||||
var ownPermissionRequest = (currentUserRole === 'admin' && currentUserId)
|
||||
? fetch('/api/admin/user/' + currentUserId + '/column-permissions')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
(res.items || []).forEach(function (item) {
|
||||
if (item.column_key) ownedKeys[String(item.column_key).trim()] = true;
|
||||
if (item.route_path) ownedRoutes[String(item.route_path).trim()] = true;
|
||||
});
|
||||
})
|
||||
.catch(function () { })
|
||||
: Promise.resolve();
|
||||
ownPermissionRequest.then(function () {
|
||||
return fetch('/api/admin/columns');
|
||||
}).then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) return;
|
||||
allColumnsList = res.items || [];
|
||||
allColumnsList = (res.items || []).filter(function (item) {
|
||||
if (item.column_key === 'admin_image_video_task_data') return false;
|
||||
if (currentUserRole !== 'admin') return true;
|
||||
return !!ownedKeys[String(item.column_key || '').trim()] ||
|
||||
!!ownedRoutes[String(item.route_path || '').trim()];
|
||||
});
|
||||
renderColumnPermissionWrap('createColumnPermissionWrap');
|
||||
renderColumnPermissionWrap('editColumnPermissionWrap');
|
||||
var selCreate = document.getElementById('createColumnPermissionWrap');
|
||||
@@ -617,17 +636,161 @@
|
||||
document.getElementById('editUserModal').classList.remove('show');
|
||||
};
|
||||
|
||||
// ========== 生成记录 ==========
|
||||
// ========== 视频任务管理 ==========
|
||||
var imageVideoTaskPage = 1, imageVideoTaskPageSize = 20;
|
||||
var imageVideoCards = [];
|
||||
var selectedImageVideoKeys = new Set();
|
||||
var imageVideoDownloadInProgress = false;
|
||||
var imageVideoPermissionUsers = [];
|
||||
var imageVideoPermissionInitialUserIds = new Set();
|
||||
var selectedImageVideoPermissionUserIds = new Set();
|
||||
var imageVideoPermissionView = 'granted';
|
||||
function updateImageVideoPermissionAccess() {
|
||||
var button = document.getElementById('btnOpenImageVideoPermissions');
|
||||
if (button) button.style.display = currentUserRole === 'super_admin' ? 'inline-flex' : 'none';
|
||||
}
|
||||
function imageVideoPermissionUsersForView() {
|
||||
if (imageVideoPermissionView === 'granted') {
|
||||
return imageVideoPermissionUsers.filter(function (user) {
|
||||
return imageVideoPermissionInitialUserIds.has(Number(user.id));
|
||||
});
|
||||
}
|
||||
return imageVideoPermissionUsers;
|
||||
}
|
||||
function syncImageVideoPermissionTabs() {
|
||||
document.getElementById('imageVideoPermissionGrantedCount').textContent = '(' + imageVideoPermissionInitialUserIds.size + ')';
|
||||
document.getElementById('imageVideoPermissionAllCount').textContent = '(' + imageVideoPermissionUsers.length + ')';
|
||||
document.querySelectorAll('[data-image-video-permission-view]').forEach(function (tab) {
|
||||
var active = tab.dataset.imageVideoPermissionView === imageVideoPermissionView;
|
||||
tab.classList.toggle('active', active);
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
function filteredImageVideoPermissionUsers() {
|
||||
var users = imageVideoPermissionUsersForView();
|
||||
var keyword = (document.getElementById('imageVideoPermissionSearch').value || '').trim().toLowerCase();
|
||||
if (!keyword) return users;
|
||||
return users.filter(function (user) {
|
||||
return String(user.username || '').toLowerCase().indexOf(keyword) >= 0;
|
||||
});
|
||||
}
|
||||
function syncImageVideoPermissionSelectAll() {
|
||||
var visibleUsers = filteredImageVideoPermissionUsers();
|
||||
var selectedCount = visibleUsers.filter(function (user) {
|
||||
return selectedImageVideoPermissionUserIds.has(Number(user.id));
|
||||
}).length;
|
||||
var selectAll = document.getElementById('imageVideoPermissionSelectAll');
|
||||
selectAll.checked = visibleUsers.length > 0 && selectedCount === visibleUsers.length;
|
||||
selectAll.indeterminate = selectedCount > 0 && selectedCount < visibleUsers.length;
|
||||
selectAll.disabled = visibleUsers.length === 0;
|
||||
}
|
||||
function renderImageVideoPermissionUsers() {
|
||||
var list = document.getElementById('imageVideoPermissionList');
|
||||
var summary = document.getElementById('imageVideoPermissionSummary');
|
||||
var visibleUsers = filteredImageVideoPermissionUsers();
|
||||
syncImageVideoPermissionTabs();
|
||||
var pendingCount = imageVideoPermissionUsers.filter(function (user) {
|
||||
return imageVideoPermissionInitialUserIds.has(Number(user.id)) !== selectedImageVideoPermissionUserIds.has(Number(user.id));
|
||||
}).length;
|
||||
summary.textContent = imageVideoPermissionView === 'granted'
|
||||
? '当前显示已保存分配用户,共 ' + visibleUsers.length + ' 人' + (pendingCount ? ' · 待保存变更 ' + pendingCount + ' 项' : '')
|
||||
: '当前显示全部用户,已保存分配 ' + imageVideoPermissionInitialUserIds.size + ' 人' + (pendingCount ? ' · 待保存变更 ' + pendingCount + ' 项' : '');
|
||||
list.innerHTML = visibleUsers.length ? visibleUsers.map(function (user) {
|
||||
var userId = Number(user.id);
|
||||
var persistedGranted = imageVideoPermissionInitialUserIds.has(userId);
|
||||
var pendingGranted = selectedImageVideoPermissionUserIds.has(userId);
|
||||
var pendingChanged = persistedGranted !== pendingGranted;
|
||||
var actionText = pendingChanged
|
||||
? (pendingGranted ? '待保存分配' : '待保存取消')
|
||||
: (persistedGranted ? '取消分配' : '分配');
|
||||
return '<div class="image-video-permission-row">' +
|
||||
'<input type="checkbox" aria-label="' + escapeHtml(user.username || '-') + (persistedGranted ? ' 已保存分配' : ' 未分配') + '" data-image-video-permission-user="' + userId + '"' +
|
||||
(pendingGranted ? ' checked' : '') + '>' +
|
||||
'<span class="image-video-permission-user">' +
|
||||
'<span class="image-video-permission-name" title="' + escapeHtml(user.username || '') + '">' + escapeHtml(user.username || '-') + '</span>' +
|
||||
'<span class="image-video-permission-role">' + escapeHtml(roleLabel(user.role)) + '</span>' +
|
||||
'</span>' +
|
||||
'<button class="image-video-permission-state' + (pendingChanged ? ' pending' : (persistedGranted ? ' granted' : '')) + '" type="button" data-image-video-permission-toggle="' + userId + '">' +
|
||||
actionText +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
}).join('') : '<div class="image-video-permission-empty">暂无匹配用户</div>';
|
||||
syncImageVideoPermissionSelectAll();
|
||||
}
|
||||
function setImageVideoPermissionLoading(loading) {
|
||||
document.getElementById('btnSaveImageVideoPermissions').disabled = loading;
|
||||
document.getElementById('imageVideoPermissionSearch').disabled = loading;
|
||||
if (loading) document.getElementById('imageVideoPermissionSelectAll').disabled = true;
|
||||
}
|
||||
function openImageVideoPermissions() {
|
||||
if (currentUserRole !== 'super_admin') return;
|
||||
var modal = document.getElementById('imageVideoPermissionModal');
|
||||
var message = document.getElementById('imageVideoPermissionMessage');
|
||||
modal.classList.add('show');
|
||||
message.textContent = '';
|
||||
message.className = 'msg';
|
||||
imageVideoPermissionView = 'granted';
|
||||
syncImageVideoPermissionTabs();
|
||||
document.getElementById('imageVideoPermissionSearch').value = '';
|
||||
document.getElementById('imageVideoPermissionList').innerHTML = '<div class="image-video-permission-empty">加载中...</div>';
|
||||
setImageVideoPermissionLoading(true);
|
||||
fetch('/api/admin/image-video-task-permissions')
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) throw new Error(res.error || '权限加载失败');
|
||||
imageVideoPermissionUsers = res.items || [];
|
||||
imageVideoPermissionInitialUserIds = new Set(imageVideoPermissionUsers.filter(function (user) {
|
||||
return !!user.granted;
|
||||
}).map(function (user) { return Number(user.id); }));
|
||||
selectedImageVideoPermissionUserIds = new Set(imageVideoPermissionInitialUserIds);
|
||||
renderImageVideoPermissionUsers();
|
||||
})
|
||||
.catch(function (error) {
|
||||
imageVideoPermissionUsers = [];
|
||||
selectedImageVideoPermissionUserIds.clear();
|
||||
document.getElementById('imageVideoPermissionList').innerHTML = '<div class="image-video-permission-empty">' + escapeHtml(error.message || '权限加载失败') + '</div>';
|
||||
})
|
||||
.finally(function () { setImageVideoPermissionLoading(false); });
|
||||
}
|
||||
function closeImageVideoPermissions() {
|
||||
document.getElementById('imageVideoPermissionModal').classList.remove('show');
|
||||
}
|
||||
function saveImageVideoPermissions() {
|
||||
var message = document.getElementById('imageVideoPermissionMessage');
|
||||
message.textContent = '保存中...';
|
||||
message.className = 'msg';
|
||||
setImageVideoPermissionLoading(true);
|
||||
fetch('/api/admin/image-video-task-permissions', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_ids: Array.from(selectedImageVideoPermissionUserIds).sort(function (a, b) { return a - b; }) })
|
||||
})
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) throw new Error(res.error || '权限保存失败');
|
||||
imageVideoPermissionUsers.forEach(function (user) {
|
||||
user.granted = selectedImageVideoPermissionUserIds.has(Number(user.id));
|
||||
});
|
||||
imageVideoPermissionInitialUserIds = new Set(selectedImageVideoPermissionUserIds);
|
||||
renderImageVideoPermissionUsers();
|
||||
message.textContent = res.msg || '保存成功';
|
||||
message.className = 'msg ok';
|
||||
})
|
||||
.catch(function (error) {
|
||||
message.textContent = error.message || '权限保存失败';
|
||||
message.className = 'msg err';
|
||||
})
|
||||
.finally(function () {
|
||||
setImageVideoPermissionLoading(false);
|
||||
syncImageVideoPermissionSelectAll();
|
||||
});
|
||||
}
|
||||
function buildImageVideoTaskQuery(page) {
|
||||
var params = new URLSearchParams();
|
||||
params.set('page', String(page || 1));
|
||||
params.set('page_size', String(imageVideoTaskPageSize));
|
||||
var values = {
|
||||
user_id: document.getElementById('imageVideoFilterUserId').value,
|
||||
username: document.getElementById('imageVideoFilterUsername').value.trim(),
|
||||
status: document.getElementById('imageVideoFilterStatus').value,
|
||||
coze_execute_id: document.getElementById('imageVideoFilterExecuteId').value.trim(),
|
||||
submitted_from: document.getElementById('imageVideoFilterFrom').value,
|
||||
submitted_to: document.getElementById('imageVideoFilterTo').value
|
||||
};
|
||||
@@ -642,107 +805,353 @@
|
||||
var url = String(value || '').trim();
|
||||
return /^https?:\/\//i.test(url) ? url : '';
|
||||
}
|
||||
function imageVideoDownloadIcon() {
|
||||
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"></path></svg>';
|
||||
}
|
||||
function imageVideoUnavailableHtml(message) {
|
||||
return '<div class="image-video-unavailable">' +
|
||||
'<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 10 4.5-2.5v9L15 14"></path><rect x="3" y="6" width="12" height="12" rx="2"></rect><path d="m4 4 16 16"></path></svg>' +
|
||||
'<strong>' + escapeHtml(message || '暂无可用视频') + '</strong>' +
|
||||
'</div>';
|
||||
}
|
||||
function imageVideoCardKey(taskId, videoIndex) {
|
||||
return String(taskId) + ':' + String(videoIndex);
|
||||
}
|
||||
function flattenImageVideoTasks(items) {
|
||||
var cards = [];
|
||||
(items || []).forEach(function (task) {
|
||||
var videos = Array.isArray(task.videos) ? task.videos : [];
|
||||
if (!videos.length) {
|
||||
cards.push({ key: imageVideoCardKey(task.task_id, 'empty'), task: task, video: null, videoIndex: 0 });
|
||||
return;
|
||||
}
|
||||
videos.forEach(function (video, index) {
|
||||
cards.push({
|
||||
key: imageVideoCardKey(task.task_id, index),
|
||||
task: task,
|
||||
video: {
|
||||
sourceUrl: safeAdminUrl(video.source_url),
|
||||
archivedUrl: safeAdminUrl(video.archived_url),
|
||||
displayUrl: safeAdminUrl(video.display_url),
|
||||
objectKey: String(video.object_key || ''),
|
||||
archiveStatus: String(video.archive_status || ''),
|
||||
archiveError: String(video.archive_error || '')
|
||||
},
|
||||
videoIndex: index
|
||||
});
|
||||
});
|
||||
});
|
||||
return cards;
|
||||
}
|
||||
function renderImageVideoCard(card) {
|
||||
var task = card.task;
|
||||
var video = card.video;
|
||||
var hasVideo = !!(video && video.displayUrl);
|
||||
var debugUrl = safeAdminUrl(task.debug_url);
|
||||
var generatedAt = task.completed_at || task.submitted_at || '-';
|
||||
var mediaHtml = hasVideo
|
||||
? '<input class="image-video-card-check" type="checkbox" data-image-video-select="' + escapeHtml(card.key) + '" aria-label="选择任务 ' + escapeHtml(task.task_id) + ' 的视频 ' + (card.videoIndex + 1) + '">' +
|
||||
'<video src="' + escapeHtml(video.displayUrl) + '" controls playsinline preload="metadata"></video>'
|
||||
: imageVideoUnavailableHtml(task.status === 'FAILED' ? '任务失败,未生成视频' : '视频生成中或暂无结果');
|
||||
return '<article class="image-video-card" data-image-video-card="' + escapeHtml(card.key) + '">' +
|
||||
'<div class="image-video-media">' + mediaHtml + '</div>' +
|
||||
'<div class="image-video-card-body">' +
|
||||
'<div class="image-video-card-head">' +
|
||||
'<div class="image-video-card-title" title="任务 ' + escapeHtml(task.task_id) + '">任务 #' + escapeHtml(task.task_id) + (video ? ' · 视频 ' + (card.videoIndex + 1) : '') + '</div>' +
|
||||
renderImageVideoStatus(task.status) +
|
||||
'</div>' +
|
||||
'<div class="image-video-card-info">' +
|
||||
'<div class="image-video-info-row"><label>用户名</label><span>' + escapeHtml(task.username || '-') + '</span></div>' +
|
||||
'<div class="image-video-info-row"><label>所属分组</label><span>' + escapeHtml(task.group_name || '-') + '</span></div>' +
|
||||
'<div class="image-video-info-row"><label>任务模式</label><span>' + escapeHtml(task.mode || '-') + '</span></div>' +
|
||||
'<div class="image-video-info-row"><label>生成时间</label><span>' + escapeHtml(generatedAt) + '</span></div>' +
|
||||
'<div class="image-video-info-row"><label>调试链接</label>' + (debugUrl
|
||||
? '<button class="image-video-copy-link" type="button" data-image-video-copy-debug="' + escapeHtml(card.key) + '">复制链接</button>'
|
||||
: '<span>-</span>') + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="image-video-card-actions">' +
|
||||
'<button class="image-video-card-action" type="button" data-image-video-download="' + escapeHtml(card.key) + '"' + (hasVideo ? '' : ' disabled') + '>' + imageVideoDownloadIcon() + '下载</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</article>';
|
||||
}
|
||||
function syncImageVideoSelectionUi() {
|
||||
var selectable = imageVideoCards.filter(function (card) { return !!(card.video && card.video.displayUrl); });
|
||||
document.querySelectorAll('[data-image-video-card]').forEach(function (cardEl) {
|
||||
var selected = selectedImageVideoKeys.has(cardEl.dataset.imageVideoCard);
|
||||
cardEl.classList.toggle('selected', selected);
|
||||
var checkbox = cardEl.querySelector('[data-image-video-select]');
|
||||
if (checkbox) checkbox.checked = selected;
|
||||
});
|
||||
var selectedCount = selectable.filter(function (card) { return selectedImageVideoKeys.has(card.key); }).length;
|
||||
var selectAll = document.getElementById('imageVideoSelectAll');
|
||||
selectAll.checked = selectable.length > 0 && selectedCount === selectable.length;
|
||||
selectAll.indeterminate = selectedCount > 0 && selectedCount < selectable.length;
|
||||
selectAll.disabled = imageVideoDownloadInProgress || selectable.length === 0;
|
||||
var batchButton = document.getElementById('btnBatchDownloadImageVideos');
|
||||
batchButton.disabled = imageVideoDownloadInProgress || selectedCount === 0;
|
||||
batchButton.innerHTML = imageVideoDownloadIcon() + (imageVideoDownloadInProgress ? '处理中' : '批量下载' + (selectedCount ? ' (' + selectedCount + ')' : ''));
|
||||
document.querySelectorAll('[data-image-video-download]').forEach(function (button) {
|
||||
var card = imageVideoCards.find(function (item) { return item.key === button.dataset.imageVideoDownload; });
|
||||
button.disabled = imageVideoDownloadInProgress || !card || !card.video || !card.video.displayUrl;
|
||||
});
|
||||
}
|
||||
function renderImageVideoCards() {
|
||||
var grid = document.getElementById('imageVideoTaskGrid');
|
||||
grid.innerHTML = imageVideoCards.length
|
||||
? imageVideoCards.map(renderImageVideoCard).join('')
|
||||
: '<div class="image-video-empty">暂无符合条件的视频任务</div>';
|
||||
grid.querySelectorAll('video').forEach(function (video) {
|
||||
video.addEventListener('error', function () {
|
||||
var media = video.closest('.image-video-media');
|
||||
if (!media || media.querySelector('.image-video-unavailable')) return;
|
||||
video.remove();
|
||||
media.insertAdjacentHTML('beforeend', imageVideoUnavailableHtml('视频加载失败,可尝试下载'));
|
||||
}, { once: true });
|
||||
});
|
||||
syncImageVideoSelectionUi();
|
||||
}
|
||||
function resetImageVideoSelection() {
|
||||
selectedImageVideoKeys.clear();
|
||||
document.getElementById('imageVideoDownloadProgress').textContent = '';
|
||||
syncImageVideoSelectionUi();
|
||||
}
|
||||
function loadImageVideoTasks(page) {
|
||||
imageVideoTaskPage = page || 1;
|
||||
var tbody = document.getElementById('imageVideoTaskListBody');
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">加载中...</td></tr>';
|
||||
selectedImageVideoKeys.clear();
|
||||
var grid = document.getElementById('imageVideoTaskGrid');
|
||||
grid.innerHTML = '<div class="image-video-empty">加载中...</div>';
|
||||
document.getElementById('imageVideoDownloadProgress').textContent = '';
|
||||
fetch('/api/admin/image-video-tasks?' + buildImageVideoTaskQuery(imageVideoTaskPage))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
if (!res.success) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">加载失败:' + escapeHtml(res.error || '') + '</td></tr>';
|
||||
imageVideoCards = [];
|
||||
grid.innerHTML = '<div class="image-video-empty">加载失败:' + escapeHtml(res.error || '') + '</div>';
|
||||
document.getElementById('imageVideoTaskTotal').textContent = '';
|
||||
syncImageVideoSelectionUi();
|
||||
return;
|
||||
}
|
||||
var items = res.items || [];
|
||||
document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 条';
|
||||
if (!items.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">暂无视频任务</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = items.map(function (item) {
|
||||
var videoUrl = safeAdminUrl(item.video_url);
|
||||
var videoLink = videoUrl ? '<a href="' + escapeHtml(videoUrl) + '" target="_blank" rel="noreferrer">查看视频</a>' : '-';
|
||||
return '<tr>' +
|
||||
'<td>' + escapeHtml(item.task_id) + '</td>' +
|
||||
'<td>' + escapeHtml(item.username || '-') + '<div style="color:#888;font-size:12px;">ID ' + escapeHtml(item.user_id) + '</div></td>' +
|
||||
'<td>' + escapeHtml(item.mode || '-') + '</td>' +
|
||||
'<td>' + renderImageVideoStatus(item.status) + '</td>' +
|
||||
'<td>' + escapeHtml(item.coze_status || '-') + '</td>' +
|
||||
'<td title="' + escapeHtml(item.coze_execute_id || '') + '">' + escapeHtml(item.coze_execute_id || '-').slice(0, 18) + '</td>' +
|
||||
'<td>' + videoLink + '</td>' +
|
||||
'<td>' + escapeHtml(item.archive_status || '-') + '</td>' +
|
||||
'<td>' + escapeHtml(item.submitted_at || '-') + '</td>' +
|
||||
'<td><button class="btn btn-sm" data-image-video-detail="' + escapeHtml(item.task_id) + '">详情</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
imageVideoCards = flattenImageVideoTasks(items);
|
||||
var videoCount = imageVideoCards.filter(function (card) { return !!card.video; }).length;
|
||||
document.getElementById('imageVideoTaskTotal').textContent = '共 ' + (res.total || 0) + ' 个任务 · 本页 ' + videoCount + ' 个视频';
|
||||
renderImageVideoCards();
|
||||
renderPagination('imageVideoTaskPagination', res.total, res.page, res.page_size, loadImageVideoTasks);
|
||||
})
|
||||
.catch(function () {
|
||||
tbody.innerHTML = '<tr><td colspan="10" class="empty-tip">请求失败</td></tr>';
|
||||
imageVideoCards = [];
|
||||
grid.innerHTML = '<div class="image-video-empty">请求失败,请稍后重试</div>';
|
||||
document.getElementById('imageVideoTaskTotal').textContent = '';
|
||||
syncImageVideoSelectionUi();
|
||||
});
|
||||
}
|
||||
function formatImageVideoJson(value) {
|
||||
try { return JSON.stringify(value == null ? {} : value, null, 2); }
|
||||
catch (error) { return String(value || ''); }
|
||||
function imageVideoFilename(card) {
|
||||
var url = card.video.displayUrl || '';
|
||||
var path = url.split(/[?#]/)[0];
|
||||
var match = path.match(/\.([a-z0-9]{2,5})$/i);
|
||||
return 'task-' + card.task.task_id + '-video-' + (card.videoIndex + 1) + '.' + (match ? match[1].toLowerCase() : 'mp4');
|
||||
}
|
||||
function renderImageVideoTaskDetail(item) {
|
||||
var videos = item.videos || [];
|
||||
var videoHtml = videos.length ? videos.map(function (video, index) {
|
||||
var displayUrl = safeAdminUrl(video.display_url);
|
||||
var sourceUrl = safeAdminUrl(video.source_url);
|
||||
var archivedUrl = safeAdminUrl(video.archived_url);
|
||||
return '<div class="image-video-video-item">' +
|
||||
'<strong>视频 ' + (index + 1) + '</strong>' +
|
||||
(displayUrl ? '<video src="' + escapeHtml(displayUrl) + '" controls playsinline preload="metadata"></video>' : '<p class="empty-tip">无可用视频地址</p>') +
|
||||
'<div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap;">' +
|
||||
(archivedUrl ? '<a href="' + escapeHtml(archivedUrl) + '" target="_blank" rel="noreferrer">OSS 视频</a>' : '') +
|
||||
(sourceUrl ? '<a href="' + escapeHtml(sourceUrl) + '" target="_blank" rel="noreferrer">Coze 原始视频</a>' : '') +
|
||||
'</div><div style="font-size:12px;color:#777;margin-top:6px;">归档:' + escapeHtml(video.archive_status || '-') +
|
||||
(video.archive_error ? ' · ' + escapeHtml(video.archive_error) : '') + '</div></div>';
|
||||
}).join('') : '<p class="empty-tip">当前没有解析到视频地址</p>';
|
||||
var debugUrl = safeAdminUrl(item.debug_url);
|
||||
return '<div class="image-video-detail-meta">' +
|
||||
'<div><span>任务 ID</span>' + escapeHtml(item.task_id) + '</div>' +
|
||||
'<div><span>用户</span>' + escapeHtml(item.username || '-') + '(' + escapeHtml(item.user_id) + ')</div>' +
|
||||
'<div><span>任务模式</span>' + escapeHtml(item.mode || '-') + '</div>' +
|
||||
'<div><span>状态</span>' + escapeHtml(item.status || '-') + ' / ' + escapeHtml(item.coze_status || '-') + '</div>' +
|
||||
'<div><span>Coze 执行 ID</span>' + escapeHtml(item.coze_execute_id || '-') + '</div>' +
|
||||
'<div><span>提交时间</span>' + escapeHtml(item.submitted_at || '-') + '</div>' +
|
||||
'<div><span>完成时间</span>' + escapeHtml(item.completed_at || '-') + '</div>' +
|
||||
'<div><span>归档状态</span>' + escapeHtml(item.archive_status || '-') + '</div>' +
|
||||
'</div>' +
|
||||
(debugUrl ? '<p><strong>调试链接:</strong><a href="' + escapeHtml(debugUrl) + '" target="_blank" rel="noreferrer">' + escapeHtml(debugUrl) + '</a></p>' : '') +
|
||||
(item.error_message ? '<p class="msg err">任务错误:' + escapeHtml(item.error_message) + '</p>' : '') +
|
||||
(item.archive_error ? '<p class="msg err">归档错误:' + escapeHtml(item.archive_error) + '</p>' : '') +
|
||||
'<div class="image-video-video-grid">' + videoHtml + '</div>' +
|
||||
'<details class="image-video-json"><summary>请求参数(密钥已脱敏)</summary><pre>' + escapeHtml(formatImageVideoJson(item.request)) + '</pre></details>' +
|
||||
'<details class="image-video-json"><summary>Coze 首次提交响应</summary><pre>' + escapeHtml(formatImageVideoJson(item.submit_response)) + '</pre></details>' +
|
||||
'<details class="image-video-json"><summary>Coze 最终响应</summary><pre>' + escapeHtml(formatImageVideoJson(item.result)) + '</pre></details>';
|
||||
function triggerImageVideoLink(url, filename, newWindow) {
|
||||
var link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
if (newWindow) {
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
}
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
function openImageVideoTaskDetail(taskId) {
|
||||
var content = document.getElementById('imageVideoTaskDetailContent');
|
||||
content.innerHTML = '<p class="empty-tip">加载中...</p>';
|
||||
document.getElementById('imageVideoTaskDetailModal').classList.add('show');
|
||||
fetch('/api/admin/image-video-tasks/' + encodeURIComponent(taskId))
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
content.innerHTML = res.success ? renderImageVideoTaskDetail(res.item || {}) : '<p class="msg err">' + escapeHtml(res.error || '加载失败') + '</p>';
|
||||
function downloadImageVideo(card) {
|
||||
var url = card && card.video ? card.video.displayUrl : '';
|
||||
if (!url) return Promise.resolve({ fallback: false, skipped: true });
|
||||
var filename = imageVideoFilename(card);
|
||||
return fetch(url, { __skipLoading: true })
|
||||
.then(function (response) {
|
||||
if (!response.ok) throw new Error('HTTP ' + response.status);
|
||||
return response.blob();
|
||||
})
|
||||
.catch(function () { content.innerHTML = '<p class="msg err">请求失败</p>'; });
|
||||
.then(function (blob) {
|
||||
var objectUrl = URL.createObjectURL(blob);
|
||||
triggerImageVideoLink(objectUrl, filename, false);
|
||||
setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 1000);
|
||||
return { fallback: false, skipped: false };
|
||||
})
|
||||
.catch(function () {
|
||||
triggerImageVideoLink(url, filename, true);
|
||||
return { fallback: true, skipped: false };
|
||||
});
|
||||
}
|
||||
function runImageVideoDownloads(cards) {
|
||||
if (imageVideoDownloadInProgress || !cards.length) return;
|
||||
imageVideoDownloadInProgress = true;
|
||||
syncImageVideoSelectionUi();
|
||||
var progress = document.getElementById('imageVideoDownloadProgress');
|
||||
progress.textContent = '正在下载';
|
||||
downloadImageVideo(cards[0]).then(function (result) {
|
||||
progress.textContent = result.fallback ? '已在新窗口打开视频' : '下载已开始';
|
||||
}).finally(function () {
|
||||
imageVideoDownloadInProgress = false;
|
||||
syncImageVideoSelectionUi();
|
||||
});
|
||||
}
|
||||
function imageVideoZipFilename(response) {
|
||||
var disposition = response.headers.get('Content-Disposition') || '';
|
||||
var encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (encoded) {
|
||||
try { return decodeURIComponent(encoded[1]); } catch (error) { }
|
||||
}
|
||||
var plain = disposition.match(/filename="?([^";]+)"?/i);
|
||||
return plain ? plain[1] : 'video-tasks.zip';
|
||||
}
|
||||
function downloadImageVideoZip(cards) {
|
||||
if (imageVideoDownloadInProgress || !cards.length) return;
|
||||
imageVideoDownloadInProgress = true;
|
||||
syncImageVideoSelectionUi();
|
||||
var progress = document.getElementById('imageVideoDownloadProgress');
|
||||
progress.textContent = '正在打包 ' + cards.length + ' 个视频...';
|
||||
fetch('/api/admin/image-video-tasks/download-zip', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
items: cards.map(function (card) {
|
||||
return { task_id: Number(card.task.task_id), video_index: card.videoIndex };
|
||||
})
|
||||
}),
|
||||
__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 () {
|
||||
imageVideoDownloadInProgress = false;
|
||||
syncImageVideoSelectionUi();
|
||||
});
|
||||
}
|
||||
function copyImageVideoDebugUrl(card, button) {
|
||||
var url = card ? safeAdminUrl(card.task.debug_url) : '';
|
||||
if (!url) return;
|
||||
var copyPromise;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
copyPromise = navigator.clipboard.writeText(url);
|
||||
} else {
|
||||
copyPromise = new Promise(function (resolve, reject) {
|
||||
var textarea = document.createElement('textarea');
|
||||
textarea.value = url;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
var copied = document.execCommand('copy');
|
||||
textarea.remove();
|
||||
if (copied) resolve();
|
||||
else reject(new Error('copy failed'));
|
||||
});
|
||||
}
|
||||
copyPromise.then(function () {
|
||||
button.textContent = '已复制';
|
||||
button.classList.add('copied');
|
||||
setTimeout(function () {
|
||||
button.textContent = '复制链接';
|
||||
button.classList.remove('copied');
|
||||
}, 1600);
|
||||
}).catch(function () {
|
||||
button.textContent = '复制失败';
|
||||
setTimeout(function () { button.textContent = '复制链接'; }, 1600);
|
||||
});
|
||||
}
|
||||
document.getElementById('btnFilterImageVideoTasks').onclick = function () { loadImageVideoTasks(1); };
|
||||
document.getElementById('btnOpenImageVideoPermissions').onclick = openImageVideoPermissions;
|
||||
document.getElementById('btnCloseImageVideoPermissions').onclick = closeImageVideoPermissions;
|
||||
document.getElementById('btnCancelImageVideoPermissions').onclick = closeImageVideoPermissions;
|
||||
document.getElementById('btnSaveImageVideoPermissions').onclick = saveImageVideoPermissions;
|
||||
document.querySelectorAll('[data-image-video-permission-view]').forEach(function (tab) {
|
||||
tab.onclick = function () {
|
||||
imageVideoPermissionView = tab.dataset.imageVideoPermissionView || 'granted';
|
||||
document.getElementById('imageVideoPermissionSearch').value = '';
|
||||
renderImageVideoPermissionUsers();
|
||||
};
|
||||
});
|
||||
document.getElementById('imageVideoPermissionSearch').oninput = renderImageVideoPermissionUsers;
|
||||
document.getElementById('imageVideoPermissionSelectAll').onchange = function (event) {
|
||||
filteredImageVideoPermissionUsers().forEach(function (user) {
|
||||
var userId = Number(user.id);
|
||||
if (event.target.checked) selectedImageVideoPermissionUserIds.add(userId);
|
||||
else selectedImageVideoPermissionUserIds.delete(userId);
|
||||
});
|
||||
renderImageVideoPermissionUsers();
|
||||
};
|
||||
document.getElementById('imageVideoPermissionList').onclick = function (event) {
|
||||
var toggle = event.target.closest('[data-image-video-permission-toggle]');
|
||||
if (!toggle) return;
|
||||
var userId = Number(toggle.dataset.imageVideoPermissionToggle);
|
||||
if (selectedImageVideoPermissionUserIds.has(userId)) selectedImageVideoPermissionUserIds.delete(userId);
|
||||
else selectedImageVideoPermissionUserIds.add(userId);
|
||||
renderImageVideoPermissionUsers();
|
||||
};
|
||||
document.getElementById('imageVideoPermissionList').onchange = function (event) {
|
||||
var checkbox = event.target.closest('[data-image-video-permission-user]');
|
||||
if (!checkbox) return;
|
||||
var userId = Number(checkbox.dataset.imageVideoPermissionUser);
|
||||
if (checkbox.checked) selectedImageVideoPermissionUserIds.add(userId);
|
||||
else selectedImageVideoPermissionUserIds.delete(userId);
|
||||
renderImageVideoPermissionUsers();
|
||||
};
|
||||
document.getElementById('imageVideoPermissionModal').onclick = function (event) {
|
||||
if (event.target === event.currentTarget) closeImageVideoPermissions();
|
||||
};
|
||||
document.getElementById('btnResetImageVideoTasks').onclick = function () {
|
||||
['imageVideoFilterUserId', 'imageVideoFilterUsername', 'imageVideoFilterStatus', 'imageVideoFilterExecuteId', 'imageVideoFilterFrom', 'imageVideoFilterTo']
|
||||
['imageVideoFilterUsername', 'imageVideoFilterFrom', 'imageVideoFilterTo']
|
||||
.forEach(function (id) { document.getElementById(id).value = ''; });
|
||||
loadImageVideoTasks(1);
|
||||
};
|
||||
document.getElementById('imageVideoTaskListBody').onclick = function (event) {
|
||||
var button = event.target.closest('[data-image-video-detail]');
|
||||
if (button) openImageVideoTaskDetail(button.dataset.imageVideoDetail);
|
||||
document.getElementById('imageVideoSelectAll').onchange = function (event) {
|
||||
imageVideoCards.forEach(function (card) {
|
||||
if (!card.video || !card.video.displayUrl) return;
|
||||
if (event.target.checked) selectedImageVideoKeys.add(card.key);
|
||||
else selectedImageVideoKeys.delete(card.key);
|
||||
});
|
||||
syncImageVideoSelectionUi();
|
||||
};
|
||||
document.getElementById('btnCloseImageVideoTaskDetail').onclick = function () {
|
||||
document.getElementById('imageVideoTaskDetailModal').classList.remove('show');
|
||||
document.getElementById('imageVideoTaskDetailContent').innerHTML = '';
|
||||
document.getElementById('btnBatchDownloadImageVideos').onclick = function () {
|
||||
downloadImageVideoZip(imageVideoCards.filter(function (card) { return selectedImageVideoKeys.has(card.key); }));
|
||||
};
|
||||
document.getElementById('imageVideoTaskGrid').onchange = function (event) {
|
||||
var checkbox = event.target.closest('[data-image-video-select]');
|
||||
if (!checkbox) return;
|
||||
if (checkbox.checked) selectedImageVideoKeys.add(checkbox.dataset.imageVideoSelect);
|
||||
else selectedImageVideoKeys.delete(checkbox.dataset.imageVideoSelect);
|
||||
syncImageVideoSelectionUi();
|
||||
};
|
||||
document.getElementById('imageVideoTaskGrid').onclick = function (event) {
|
||||
var copyButton = event.target.closest('[data-image-video-copy-debug]');
|
||||
if (copyButton) {
|
||||
var copyCard = imageVideoCards.find(function (item) { return item.key === copyButton.dataset.imageVideoCopyDebug; });
|
||||
if (copyCard) copyImageVideoDebugUrl(copyCard, copyButton);
|
||||
}
|
||||
var downloadButton = event.target.closest('[data-image-video-download]');
|
||||
if (downloadButton) {
|
||||
var card = imageVideoCards.find(function (item) { return item.key === downloadButton.dataset.imageVideoDownload; });
|
||||
if (card) runImageVideoDownloads([card]);
|
||||
}
|
||||
};
|
||||
|
||||
var historyPage = 1, historyPageSize = 15;
|
||||
@@ -4348,6 +4757,7 @@
|
||||
roleEl.style.display = 'none';
|
||||
}
|
||||
updateShopManageGroupButtonsAccess();
|
||||
updateImageVideoPermissionAccess();
|
||||
})
|
||||
.catch(function () {
|
||||
return fetch('/api/auth/check')
|
||||
@@ -4356,11 +4766,13 @@
|
||||
document.getElementById('adminCurrentUsername').textContent = res && res.logged_in ? '当前用户' : '未登录';
|
||||
document.getElementById('adminCurrentUserRole').style.display = 'none';
|
||||
updateShopManageGroupButtonsAccess();
|
||||
updateImageVideoPermissionAccess();
|
||||
})
|
||||
.catch(function () {
|
||||
document.getElementById('adminCurrentUsername').textContent = '当前用户';
|
||||
document.getElementById('adminCurrentUserRole').style.display = 'none';
|
||||
updateShopManageGroupButtonsAccess();
|
||||
updateImageVideoPermissionAccess();
|
||||
});
|
||||
})
|
||||
.finally(function () {
|
||||
@@ -4398,7 +4810,6 @@
|
||||
|
||||
loadAdminCurrentUser().finally(function () {
|
||||
loadUserOptions();
|
||||
loadColumnsForPermission();
|
||||
loadShopManageGroups();
|
||||
loadAdminMenus();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app import app
|
||||
from blueprints import admin_api as admin_module
|
||||
@@ -43,6 +45,23 @@ class FakeConnection:
|
||||
pass
|
||||
|
||||
|
||||
class FakeDownloadResponse:
|
||||
def __init__(self, content=b'video-bytes', error=None):
|
||||
self.content = content
|
||||
self.error = error
|
||||
self.closed = False
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.error:
|
||||
raise self.error
|
||||
|
||||
def iter_content(self, chunk_size):
|
||||
yield self.content
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ImageVideoAdminTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
app.config.update(TESTING=True, SECRET_KEY='test')
|
||||
@@ -72,6 +91,7 @@ class ImageVideoAdminTest(unittest.TestCase):
|
||||
'id': 91,
|
||||
'user_id': 21,
|
||||
'username': 'demo',
|
||||
'group_name': '测试分组、第二分组',
|
||||
'status': 'SUCCESS',
|
||||
'request_json': json.dumps({'parameters': {'video_info': {'mode': '1'}}}),
|
||||
'submit_response_json': '{}',
|
||||
@@ -99,7 +119,8 @@ class ImageVideoAdminTest(unittest.TestCase):
|
||||
session['user_id'] = 17
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch('utils.auth.get_current_admin_role', return_value=('admin', {'id': 17})), \
|
||||
patch.object(admin_module, '_ensure_admin_menu_access', return_value=('admin', {'id': 17}, None)), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('admin', {'id': 17}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('admin', {'id': 17}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.get('/api/admin/image-video-tasks?page=2&page_size=20&status=success')
|
||||
|
||||
@@ -107,12 +128,212 @@ class ImageVideoAdminTest(unittest.TestCase):
|
||||
data = response.get_json()
|
||||
self.assertTrue(data['success'])
|
||||
self.assertEqual(2, data['page'])
|
||||
self.assertEqual('测试分组、第二分组', data['items'][0]['group_name'])
|
||||
self.assertEqual('图生视频', data['items'][0]['mode'])
|
||||
self.assertEqual('https://oss.example/video.mp4', data['items'][0]['video_url'])
|
||||
self.assertEqual([{
|
||||
'source_url': 'https://coze.example/video.mp4',
|
||||
'archived_url': 'https://oss.example/video.mp4',
|
||||
'object_key': 'result/image_video/1/video.mp4',
|
||||
'archive_status': 'SUCCESS',
|
||||
'archive_error': '',
|
||||
'display_url': 'https://oss.example/video.mp4',
|
||||
}], data['items'][0]['videos'])
|
||||
self.assertNotIn('request', data['items'][0])
|
||||
self.assertNotIn('submit_response', data['items'][0])
|
||||
self.assertNotIn('result', data['items'][0])
|
||||
list_sql, list_params = connection.cursor_value.executions[0]
|
||||
self.assertIn("t.task_type = 'IMAGE_VIDEO_WORKFLOW'", list_sql)
|
||||
self.assertIn('created_by_id = %s', list_sql)
|
||||
self.assertEqual((17, 17, 'SUCCESS', 20, 20), list_params)
|
||||
self.assertIn('t.submitted_at >= DATE_SUB(NOW(), INTERVAL 3 DAY)', list_sql)
|
||||
self.assertNotIn('created_by_id = %s', list_sql)
|
||||
self.assertEqual(('SUCCESS', 20, 20), list_params)
|
||||
|
||||
def test_authorized_normal_user_receives_all_video_tasks(self):
|
||||
connection = FakeConnection([])
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 23
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.get('/api/admin/image-video-tasks')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
list_sql, list_params = connection.cursor_value.executions[0]
|
||||
self.assertNotIn('t.user_id = %s', list_sql)
|
||||
self.assertEqual((20, 0), list_params)
|
||||
|
||||
def test_batch_download_returns_single_zip_with_selected_video(self):
|
||||
row = {
|
||||
'id': 91,
|
||||
'video_urls_json': json.dumps(['https://coze.example/video.mp4']),
|
||||
'archived_videos_json': json.dumps([{'url': 'https://oss.example/video.mp4'}]),
|
||||
}
|
||||
connection = FakeConnection([row])
|
||||
remote_response = FakeDownloadResponse()
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 23
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection), \
|
||||
patch.object(admin_module.requests, 'get', return_value=remote_response):
|
||||
response = self.client.post('/api/admin/image-video-tasks/download-zip', json={
|
||||
'items': [{'task_id': 91, 'video_index': 0}],
|
||||
})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('application/zip', response.mimetype)
|
||||
self.assertEqual('1', response.headers['X-Archive-File-Count'])
|
||||
with zipfile.ZipFile(io.BytesIO(response.get_data())) as archive:
|
||||
self.assertEqual(['task-91-video-1.mp4'], archive.namelist())
|
||||
self.assertEqual(b'video-bytes', archive.read('task-91-video-1.mp4'))
|
||||
response.close()
|
||||
self.assertTrue(remote_response.closed)
|
||||
|
||||
def test_batch_download_writes_failures_into_zip(self):
|
||||
row = {
|
||||
'id': 91,
|
||||
'video_urls_json': json.dumps(['https://coze.example/video.mp4']),
|
||||
'archived_videos_json': '[]',
|
||||
}
|
||||
connection = FakeConnection([row])
|
||||
remote_response = FakeDownloadResponse(error=admin_module.requests.RequestException('upstream failed'))
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 23
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, '_ensure_image_video_data_access', return_value=('normal', {'id': 23}, None)), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection), \
|
||||
patch.object(admin_module.requests, 'get', return_value=remote_response):
|
||||
response = self.client.post('/api/admin/image-video-tasks/download-zip', json={
|
||||
'items': [{'task_id': 91, 'video_index': 0}],
|
||||
})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual('1', response.headers['X-Archive-Error-Count'])
|
||||
with zipfile.ZipFile(io.BytesIO(response.get_data())) as archive:
|
||||
self.assertEqual(['download-errors.txt'], archive.namelist())
|
||||
self.assertIn('下载失败', archive.read('download-errors.txt').decode('utf-8'))
|
||||
response.close()
|
||||
|
||||
def test_unassigned_normal_user_is_denied_video_tasks(self):
|
||||
denied = (app.response_class('{"success":false}', content_type='application/json'), 403)
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 24
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, '_ensure_backend_menu_access', return_value=('normal', {'id': 24}, denied)):
|
||||
response = self.client.get('/api/admin/image-video-tasks')
|
||||
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
def test_only_super_admin_can_manage_image_video_permissions(self):
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 17
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, 'get_current_admin_role', return_value=('admin', {'id': 17})):
|
||||
response = self.client.get('/api/admin/image-video-task-permissions')
|
||||
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
def test_super_admin_lists_image_video_permission_users(self):
|
||||
cursor = MagicMock()
|
||||
cursor.__enter__.return_value = cursor
|
||||
cursor.__exit__.return_value = False
|
||||
cursor.fetchone.return_value = {'id': 75}
|
||||
cursor.fetchall.return_value = [
|
||||
{'id': 2, 'username': 'admin-a', 'role': 'admin', 'is_admin': 1, 'created_by_id': 1, 'granted': 1},
|
||||
{'id': 3, 'username': 'user-a', 'role': 'normal', 'is_admin': 0, 'created_by_id': 2, 'granted': 0},
|
||||
]
|
||||
connection = MagicMock()
|
||||
connection.cursor.return_value = cursor
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 1
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, 'get_current_admin_role', return_value=('super_admin', {'id': 1})), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.get('/api/admin/image-video-task-permissions')
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual([True, False], [item['granted'] for item in response.get_json()['items']])
|
||||
|
||||
def test_super_admin_replaces_only_image_video_permissions(self):
|
||||
cursor = MagicMock()
|
||||
cursor.__enter__.return_value = cursor
|
||||
cursor.__exit__.return_value = False
|
||||
cursor.fetchone.return_value = {'id': 75}
|
||||
cursor.fetchall.return_value = [{'id': 2}, {'id': 3}]
|
||||
connection = MagicMock()
|
||||
connection.cursor.return_value = cursor
|
||||
with self.client.session_transaction() as session:
|
||||
session['user_id'] = 1
|
||||
with patch('utils.auth.is_session_user_valid', return_value=True), \
|
||||
patch.object(admin_module, 'get_current_admin_role', return_value=('super_admin', {'id': 1})), \
|
||||
patch.object(admin_module, 'get_db', return_value=connection):
|
||||
response = self.client.put('/api/admin/image-video-task-permissions', json={'user_ids': [2, 3]})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertEqual(2, response.get_json()['granted_count'])
|
||||
statements = [call.args[0] for call in cursor.execute.call_args_list]
|
||||
self.assertTrue(any('DELETE ucp FROM user_column_permission' in sql for sql in statements))
|
||||
self.assertEqual(2, sum('INSERT INTO user_column_permission' in sql for sql in statements))
|
||||
connection.commit.assert_called_once()
|
||||
|
||||
def test_generic_permission_update_preserves_video_permission(self):
|
||||
cursor = MagicMock()
|
||||
cursor.fetchall.side_effect = [[{'id': 75}], [{'column_id': 75}]]
|
||||
|
||||
admin_module._set_user_column_permissions(
|
||||
cursor,
|
||||
7,
|
||||
[2, 75],
|
||||
preserve_column_keys=(admin_module.IMAGE_VIDEO_PERMISSION_KEY,),
|
||||
)
|
||||
|
||||
inserts = [call.args[1] for call in cursor.execute.call_args_list if call.args[0].startswith('INSERT INTO')]
|
||||
self.assertEqual([(7, 2), (7, 75)], inserts)
|
||||
|
||||
def test_video_items_fall_back_to_source_and_handle_empty_values(self):
|
||||
row = {
|
||||
'video_urls_json': json.dumps([
|
||||
'https://coze.example/first.mp4',
|
||||
'https://coze.example/second.mp4',
|
||||
]),
|
||||
'archived_videos_json': json.dumps([{
|
||||
'sourceUrl': 'https://coze.example/first.mp4',
|
||||
'status': 'FAILED',
|
||||
'error': 'archive failed',
|
||||
}]),
|
||||
}
|
||||
|
||||
videos = admin_module._image_video_urls(row)
|
||||
|
||||
self.assertEqual(2, len(videos))
|
||||
self.assertEqual('https://coze.example/first.mp4', videos[0]['display_url'])
|
||||
self.assertEqual('FAILED', videos[0]['archive_status'])
|
||||
self.assertEqual('archive failed', videos[0]['archive_error'])
|
||||
self.assertEqual('https://coze.example/second.mp4', videos[1]['display_url'])
|
||||
self.assertEqual([], admin_module._image_video_urls({
|
||||
'video_urls_json': None,
|
||||
'archived_videos_json': 'not-json',
|
||||
}))
|
||||
|
||||
def test_detail_item_returns_masked_request_and_responses(self):
|
||||
row = {
|
||||
'id': 8,
|
||||
'request_json': json.dumps({'token': 'secret', 'name': 'demo'}),
|
||||
'submit_response_json': json.dumps({'execute_id': 'exec-8'}),
|
||||
'result_json': json.dumps({'status': 'SUCCESS'}),
|
||||
'video_urls_json': '[]',
|
||||
'archived_videos_json': '[]',
|
||||
}
|
||||
|
||||
item = admin_module._image_video_admin_item(row, include_json=True)
|
||||
|
||||
self.assertEqual('******', item['request']['token'])
|
||||
self.assertEqual('demo', item['request']['name'])
|
||||
self.assertEqual({'execute_id': 'exec-8'}, item['submit_response'])
|
||||
self.assertEqual({'status': 'SUCCESS'}, item['result'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -754,8 +754,9 @@
|
||||
.column-permission-select {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 80px;
|
||||
max-height: 140px;
|
||||
min-height: 120px;
|
||||
height: 220px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
@@ -764,21 +765,81 @@
|
||||
.column-permission-select option {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.image-video-table-wrap { overflow-x: auto; }
|
||||
.image-video-table { min-width: 1260px; }
|
||||
.image-video-status { display:inline-block; padding:3px 8px; border-radius:4px; background:#eef1f4; font-size:12px; }
|
||||
.image-video-panel-box { padding:0; }
|
||||
.image-video-results { padding:20px 20px 0; }
|
||||
.image-video-panel-box > .pagination { padding:0 20px 20px; }
|
||||
.image-video-toolbar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:20px; }
|
||||
.image-video-toolbar-main { display:flex; align-items:center; gap:18px; flex-wrap:wrap; }
|
||||
.image-video-batch-btn { display:inline-flex; align-items:center; justify-content:center; gap:7px; min-height:36px; padding:8px 14px; background:#27b38b; }
|
||||
.image-video-batch-btn:disabled { cursor:not-allowed; opacity:.45; }
|
||||
.image-video-batch-btn svg,
|
||||
.image-video-card-action svg { width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; }
|
||||
.image-video-select-all { display:inline-flex; align-items:center; gap:8px; color:#333; font-size:14px; cursor:pointer; }
|
||||
.image-video-select-all input { width:16px; height:16px; accent-color:#27b38b; }
|
||||
.image-video-download-progress { min-height:18px; color:#527067; font-size:13px; }
|
||||
.image-video-summary { color:#666; font-size:13px; text-align:right; }
|
||||
.image-video-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:20px; }
|
||||
.image-video-card { min-width:0; overflow:hidden; background:#fff; border:1px solid #e0e0e0; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.05); transition:border-color .2s ease,box-shadow .2s ease,transform .2s ease; }
|
||||
.image-video-card:hover { border-color:#cbd7d3; box-shadow:0 6px 16px rgba(32,74,62,.1); transform:translateY(-1px); }
|
||||
.image-video-card.selected { border-color:#27b38b; box-shadow:0 0 0 2px rgba(39,179,139,.12),0 6px 16px rgba(32,74,62,.1); }
|
||||
.image-video-media { position:relative; width:100%; aspect-ratio:16/9; display:flex; align-items:center; justify-content:center; background:#0b0d0c; overflow:hidden; }
|
||||
.image-video-media video { display:block; width:100%; height:100%; object-fit:contain; background:#000; }
|
||||
.image-video-card-check { position:absolute; top:11px; left:11px; z-index:2; width:17px; height:17px; accent-color:#27b38b; cursor:pointer; filter:drop-shadow(0 1px 2px rgba(0,0,0,.55)); }
|
||||
.image-video-unavailable { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8px; padding:20px; color:#b9c1be; text-align:center; }
|
||||
.image-video-unavailable svg { width:34px; height:34px; fill:none; stroke:currentColor; stroke-width:1.5; }
|
||||
.image-video-unavailable strong { color:#e0e4e2; font-size:14px; font-weight:500; }
|
||||
.image-video-card-body { padding:15px 16px 16px; }
|
||||
.image-video-card-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:12px; }
|
||||
.image-video-card-title { min-width:0; font-size:15px; font-weight:600; color:#2b3532; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.image-video-status { flex:0 0 auto; display:inline-block; padding:3px 8px; border-radius:4px; background:#eef1f0; color:#5b6461; font-size:11px; font-weight:600; }
|
||||
.image-video-status.SUCCESS { color:#14733b; background:#e5f5eb; }
|
||||
.image-video-status.FAILED { color:#a82d2d; background:#fbe9e9; }
|
||||
.image-video-detail-modal { width:min(1080px, calc(100vw - 48px)); max-height:88vh; overflow:auto; }
|
||||
.image-video-detail-meta { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; }
|
||||
.image-video-detail-meta div { padding:10px; background:#f6f7f8; border-radius:4px; min-width:0; }
|
||||
.image-video-detail-meta span { display:block; color:#777; font-size:12px; margin-bottom:4px; }
|
||||
.image-video-video-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:14px; }
|
||||
.image-video-video-item { border:1px solid #ddd; border-radius:6px; padding:10px; }
|
||||
.image-video-video-item video { display:block; width:100%; aspect-ratio:16/9; background:#111; }
|
||||
.image-video-json { margin-top:14px; }
|
||||
.image-video-json pre { max-height:300px; overflow:auto; padding:12px; background:#172026; color:#e8edf0; border-radius:4px; white-space:pre-wrap; word-break:break-word; font-size:12px; }
|
||||
@media (max-width: 760px) { .image-video-detail-meta { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||||
.image-video-status.RUNNING,
|
||||
.image-video-status.POLLING { color:#8a6200; background:#fff3cd; }
|
||||
.image-video-card-info { display:grid; gap:7px; }
|
||||
.image-video-info-row { display:grid; grid-template-columns:68px minmax(0,1fr); gap:8px; color:#777; font-size:13px; line-height:1.45; }
|
||||
.image-video-info-row span,
|
||||
.image-video-info-row a { min-width:0; color:#333; overflow-wrap:anywhere; }
|
||||
.image-video-info-row a { color:#218a6d; text-decoration:none; }
|
||||
.image-video-info-row a:hover { text-decoration:underline; }
|
||||
.image-video-copy-link { min-width:0; padding:0; border:0; background:none; color:#218a6d; cursor:pointer; font:inherit; text-align:left; }
|
||||
.image-video-copy-link:hover { text-decoration:underline; }
|
||||
.image-video-copy-link.copied { color:#14733b; font-weight:600; }
|
||||
.image-video-card-actions { display:flex; align-items:center; gap:9px; margin-top:15px; }
|
||||
.image-video-card-action { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:32px; padding:6px 11px; border:1px solid #27b38b; border-radius:4px; background:#fff; color:#218a6d; cursor:pointer; font-size:13px; }
|
||||
.image-video-card-action:hover { background:#27b38b; color:#fff; }
|
||||
.image-video-card-action:disabled { cursor:not-allowed; opacity:.45; }
|
||||
.image-video-empty { grid-column:1/-1; padding:64px 20px; border:1px dashed #d7ddda; border-radius:8px; color:#777; text-align:center; background:#fafbfb; }
|
||||
.image-video-permission-btn { display:none; min-height:36px; padding:8px 14px; }
|
||||
.image-video-permission-modal { width:min(620px,calc(100vw - 32px)); }
|
||||
.image-video-permission-head { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:16px; }
|
||||
.image-video-permission-tools { display:flex; align-items:center; gap:12px; margin-bottom:12px; }
|
||||
.image-video-permission-tabs { display:flex; gap:4px; margin-bottom:12px; padding:3px; border-radius:6px; background:#f1f4f3; }
|
||||
.image-video-permission-tab { flex:1; min-height:34px; padding:7px 10px; border:0; border-radius:4px; background:transparent; color:#68736f; cursor:pointer; font:inherit; font-size:13px; }
|
||||
.image-video-permission-tab.active { background:#fff; color:#16835e; box-shadow:0 1px 4px rgba(38,70,58,.12); font-weight:600; }
|
||||
.image-video-permission-search { flex:1; min-width:0; padding:9px 10px; border:1px solid #ddd; border-radius:6px; font:inherit; }
|
||||
.image-video-permission-list { max-height:52vh; overflow:auto; border:1px solid #e1e5e3; border-radius:6px; }
|
||||
.image-video-permission-row { display:grid; grid-template-columns:24px minmax(0,1fr) auto; align-items:center; gap:10px; min-height:48px; padding:9px 12px; border-bottom:1px solid #edf0ef; }
|
||||
.image-video-permission-row:last-child { border-bottom:0; }
|
||||
.image-video-permission-row input { width:16px; height:16px; accent-color:#27b38b; }
|
||||
.image-video-permission-user { min-width:0; display:flex; flex-direction:column; gap:2px; }
|
||||
.image-video-permission-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#293330; }
|
||||
.image-video-permission-role { color:#727b78; font-size:12px; }
|
||||
.image-video-permission-state { min-width:68px; padding:4px 8px; border:1px solid #d8dfdc; border-radius:4px; background:#fff; color:#6d7773; cursor:pointer; font-size:12px; white-space:nowrap; }
|
||||
.image-video-permission-state.granted { border-color:#a9ddca; background:#ecfaf4; color:#16835e; }
|
||||
.image-video-permission-state.pending { border-color:#efd39b; background:#fff8e8; color:#a56b00; }
|
||||
.image-video-permission-summary { margin:0 0 10px; color:#66716d; font-size:13px; }
|
||||
.image-video-permission-empty { padding:36px 16px; color:#777; text-align:center; }
|
||||
.image-video-permission-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:16px; }
|
||||
@media (max-width: 760px) {
|
||||
.admin-user-box { position:static !important; width:max-content; max-width:100%; margin-bottom:16px; }
|
||||
.tabs { max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; }
|
||||
.tab { flex:0 0 auto; padding:10px 14px; }
|
||||
.image-video-results { padding:16px; }
|
||||
.image-video-toolbar { align-items:flex-start; flex-direction:column; }
|
||||
.image-video-summary { text-align:left; }
|
||||
.image-video-grid { grid-template-columns:minmax(0,1fr); gap:16px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -1521,26 +1582,31 @@
|
||||
<div class="form-box">
|
||||
<h3 style="margin-bottom:16px;font-size:15px;">视频任务筛选</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="min-width:130px;"><label>用户 ID</label><input type="number" id="imageVideoFilterUserId" min="1"></div>
|
||||
<div class="form-group" style="min-width:150px;"><label>用户名</label><input type="text" id="imageVideoFilterUsername" placeholder="模糊搜索"></div>
|
||||
<div class="form-group" style="min-width:130px;"><label>任务状态</label><select id="imageVideoFilterStatus"><option value="">全部</option><option value="PENDING">PENDING</option><option value="RUNNING">RUNNING</option><option value="WAITING">WAITING</option><option value="POLLING">POLLING</option><option value="SUCCESS">SUCCESS</option><option value="FAILED">FAILED</option></select></div>
|
||||
<div class="form-group" style="min-width:180px;"><label>Coze 执行 ID</label><input type="text" id="imageVideoFilterExecuteId"></div>
|
||||
<div class="form-group" style="min-width:170px;"><label>提交开始</label><input type="datetime-local" id="imageVideoFilterFrom"></div>
|
||||
<div class="form-group" style="min-width:170px;"><label>提交结束</label><input type="datetime-local" id="imageVideoFilterTo"></div>
|
||||
<button class="btn" id="btnFilterImageVideoTasks" type="button">查询</button>
|
||||
<button class="btn btn-secondary" id="btnResetImageVideoTasks" type="button">重置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-box">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;gap:12px;">
|
||||
<h3 style="font-size:15px;">视频任务记录</h3>
|
||||
<span id="imageVideoTaskTotal" style="font-size:13px;color:#666;"></span>
|
||||
</div>
|
||||
<div class="image-video-table-wrap">
|
||||
<table class="image-video-table">
|
||||
<thead><tr><th>任务 ID</th><th>用户</th><th>模式</th><th>状态</th><th>Coze 状态</th><th>执行 ID</th><th>视频</th><th>归档</th><th>提交时间</th><th>操作</th></tr></thead>
|
||||
<tbody id="imageVideoTaskListBody"></tbody>
|
||||
</table>
|
||||
<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="btnOpenImageVideoPermissions" type="button">权限配置</button>
|
||||
<button class="btn image-video-batch-btn" id="btnBatchDownloadImageVideos" 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="imageVideoSelectAll">
|
||||
全选当前页
|
||||
</label>
|
||||
<span class="image-video-download-progress" id="imageVideoDownloadProgress" aria-live="polite"></span>
|
||||
</div>
|
||||
<span class="image-video-summary" id="imageVideoTaskTotal"></span>
|
||||
</div>
|
||||
<div class="image-video-grid" id="imageVideoTaskGrid" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="pagination" id="imageVideoTaskPagination"></div>
|
||||
</div>
|
||||
@@ -1947,16 +2013,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-mask" id="imageVideoTaskDetailModal">
|
||||
<div class="modal image-video-detail-modal">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;">
|
||||
<h3>视频任务详情</h3>
|
||||
<button class="btn btn-secondary btn-sm" id="btnCloseImageVideoTaskDetail" type="button">关闭</button>
|
||||
<div class="modal-mask" id="imageVideoPermissionModal">
|
||||
<div class="modal image-video-permission-modal" role="dialog" aria-modal="true" aria-labelledby="imageVideoPermissionTitle">
|
||||
<div class="image-video-permission-head">
|
||||
<h3 id="imageVideoPermissionTitle">视频任务权限配置</h3>
|
||||
<button class="btn btn-secondary btn-sm" id="btnCloseImageVideoPermissions" 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-image-video-permission-view="granted">已分配用户 <span id="imageVideoPermissionGrantedCount">(0)</span></button>
|
||||
<button class="image-video-permission-tab" type="button" role="tab" aria-selected="false" data-image-video-permission-view="all">全部用户 <span id="imageVideoPermissionAllCount">(0)</span></button>
|
||||
</div>
|
||||
<div class="image-video-permission-tools">
|
||||
<input class="image-video-permission-search" id="imageVideoPermissionSearch" type="search" placeholder="搜索用户名">
|
||||
<label class="image-video-select-all">
|
||||
<input type="checkbox" id="imageVideoPermissionSelectAll">
|
||||
全选搜索结果
|
||||
</label>
|
||||
</div>
|
||||
<p class="image-video-permission-summary" id="imageVideoPermissionSummary"></p>
|
||||
<div class="image-video-permission-list" id="imageVideoPermissionList" aria-live="polite"></div>
|
||||
<p class="msg" id="imageVideoPermissionMessage" aria-live="polite"></p>
|
||||
<div class="image-video-permission-actions">
|
||||
<button class="btn btn-secondary" id="btnCancelImageVideoPermissions" type="button">取消</button>
|
||||
<button class="btn" id="btnSaveImageVideoPermissions" type="button">保存</button>
|
||||
</div>
|
||||
<div id="imageVideoTaskDetailContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/admin.js"></script>
|
||||
<script src="/static/admin.js?v=permission-tabs-3"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user