视频任务管理更新

This commit is contained in:
super
2026-07-19 22:23:26 +08:00
parent 69784b8d32
commit cbde22ec79
62 changed files with 2164 additions and 280 deletions

View File

@@ -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 ----------

View File

@@ -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

View File

@@ -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();
});

View File

@@ -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__':

View File

@@ -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>