diff --git a/.gitignore b/.gitignore index 759f59f..61625e6 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ get-docker.sh flask.log *.log PROJECT_STATUS.md + +# Database +backend/data/ diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000..c32f42a --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,69 @@ +import sqlite3 +import os +from werkzeug.security import generate_password_hash + +DB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data') +DB_PATH = os.path.join(DB_DIR, 'voice.db') + + +def get_db(): + """获取数据库连接""" + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + return conn + + +def init_db(): + """初始化数据库表 + 创建默认 admin 账号""" + os.makedirs(DB_DIR, exist_ok=True) + conn = get_db() + cursor = conn.cursor() + + cursor.executescript(''' + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + display_name TEXT NOT NULL, + password_hash TEXT NOT NULL, + role TEXT DEFAULT 'editor', + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS invite_codes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT UNIQUE NOT NULL, + editor_name TEXT NOT NULL, + is_used INTEGER DEFAULT 0, + used_by_user_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + used_at DATETIME + ); + + CREATE TABLE IF NOT EXISTS usage_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + username TEXT NOT NULL, + display_name TEXT NOT NULL, + text_content TEXT NOT NULL, + text_length INTEGER NOT NULL, + engine TEXT NOT NULL, + emotion TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + ); + ''') + + # 创建默认 admin 账号(如不存在) + existing = cursor.execute( + 'SELECT id FROM users WHERE username = ?', ('simonkoson',) + ).fetchone() + if not existing: + cursor.execute( + 'INSERT INTO users (username, display_name, password_hash, role) VALUES (?, ?, ?, ?)', + ('simonkoson', '蓝皓', generate_password_hash('liutong65'), 'admin') + ) + + conn.commit() + conn.close() diff --git a/backend/app/main.py b/backend/app/main.py index 831dbda..a457b88 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,14 +1,40 @@ from flask import Flask, jsonify from flask_cors import CORS +from dotenv import load_dotenv +import os + +load_dotenv() + def create_app(): app = Flask(__name__) - CORS(app, resources={r"/api/*": {"origins": "*"}}) + + # Session 配置 + app.secret_key = os.getenv('FLASK_SECRET_KEY', 'military-tech-voice-secret-2026') + app.config['SESSION_COOKIE_HTTPONLY'] = True + app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' + app.config['PERMANENT_SESSION_LIFETIME'] = 86400 * 7 # 7天免重登 + + CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=True) + + # 初始化数据库 + from app.db import init_db + init_db() + + # 注册蓝图 from app.routes.tts_synthesize import bp as synthesize_bp + from app.routes.auth import bp as auth_bp + from app.routes.admin import bp as admin_bp app.register_blueprint(synthesize_bp) + app.register_blueprint(auth_bp) + app.register_blueprint(admin_bp) + @app.route('/api/health') - def health(): return jsonify({'status': 'ok', 'service': '军事科技AI配音系统'}) + def health(): + return jsonify({'status': 'ok', 'service': '军事科技AI配音系统'}) + return app + if __name__ == '__main__': create_app().run(host='0.0.0.0', port=5000, debug=True) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py new file mode 100644 index 0000000..839eb1d --- /dev/null +++ b/backend/app/routes/admin.py @@ -0,0 +1,200 @@ +from flask import Blueprint, request, jsonify +from app.db import get_db +from app.routes.auth import admin_required +from werkzeug.security import generate_password_hash +import secrets +import string + +bp = Blueprint('admin', __name__, url_prefix='/api/admin') + + +def generate_invite_code(): + """生成8位邀请码(大写字母+数字,去掉易混淆字符 O/I/0/1)""" + alphabet = ( + string.ascii_uppercase.replace('O', '').replace('I', '') + + string.digits.replace('0', '').replace('1', '') + ) + return ''.join(secrets.choice(alphabet) for _ in range(8)) + + +# ========== 邀请码管理 ========== + +@bp.route('/invites', methods=['GET']) +@admin_required +def list_invites(): + conn = get_db() + invites = conn.execute(''' + SELECT ic.*, u.username as used_by_username + FROM invite_codes ic + LEFT JOIN users u ON ic.used_by_user_id = u.id + ORDER BY ic.created_at DESC + ''').fetchall() + conn.close() + return jsonify({'invites': [dict(row) for row in invites]}) + + +@bp.route('/invites', methods=['POST']) +@admin_required +def create_invite(): + data = request.get_json() + editor_name = data.get('editor_name', '').strip() + if not editor_name: + return jsonify({'error': '请填写编导姓名'}), 400 + + code = generate_invite_code() + conn = get_db() + try: + conn.execute( + 'INSERT INTO invite_codes (code, editor_name) VALUES (?, ?)', + (code, editor_name) + ) + conn.commit() + return jsonify({'success': True, 'code': code, 'editor_name': editor_name}) + finally: + conn.close() + + +@bp.route('/invites/', methods=['DELETE']) +@admin_required +def delete_invite(invite_id): + conn = get_db() + try: + invite = conn.execute( + 'SELECT * FROM invite_codes WHERE id = ? AND is_used = 0', (invite_id,) + ).fetchone() + if not invite: + return jsonify({'error': '邀请码不存在或已被使用,无法删除'}), 400 + conn.execute('DELETE FROM invite_codes WHERE id = ?', (invite_id,)) + conn.commit() + return jsonify({'success': True}) + finally: + conn.close() + + +# ========== 用户管理 ========== + +@bp.route('/users', methods=['GET']) +@admin_required +def list_users(): + conn = get_db() + users = conn.execute(''' + SELECT u.id, u.username, u.display_name, u.role, u.is_active, u.created_at, + COUNT(ul.id) as usage_count, + COALESCE(SUM(ul.text_length), 0) as total_chars + FROM users u + LEFT JOIN usage_logs ul ON u.id = ul.user_id + GROUP BY u.id + ORDER BY u.created_at DESC + ''').fetchall() + conn.close() + return jsonify({'users': [dict(row) for row in users]}) + + +@bp.route('/users/', methods=['PUT']) +@admin_required +def update_user(user_id): + data = request.get_json() + conn = get_db() + try: + user = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone() + if not user: + return jsonify({'error': '用户不存在'}), 404 + + if 'is_active' in data: + conn.execute( + 'UPDATE users SET is_active = ? WHERE id = ?', + (1 if data['is_active'] else 0, user_id) + ) + + if 'password' in data and data['password']: + conn.execute( + 'UPDATE users SET password_hash = ? WHERE id = ?', + (generate_password_hash(data['password']), user_id) + ) + + if 'display_name' in data and data['display_name'].strip(): + conn.execute( + 'UPDATE users SET display_name = ? WHERE id = ?', + (data['display_name'].strip(), user_id) + ) + + conn.commit() + return jsonify({'success': True}) + finally: + conn.close() + + +# ========== 用量日志 ========== + +@bp.route('/logs', methods=['GET']) +@admin_required +def list_logs(): + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 50, type=int) + user_id = request.args.get('user_id', type=int) + date_from = request.args.get('date_from', '') + date_to = request.args.get('date_to', '') + + conn = get_db() + + where_clauses = [] + params = [] + + if user_id: + where_clauses.append('user_id = ?') + params.append(user_id) + if date_from: + where_clauses.append('created_at >= ?') + params.append(date_from) + if date_to: + where_clauses.append('created_at <= ?') + params.append(date_to + ' 23:59:59') + + where_sql = (' WHERE ' + ' AND '.join(where_clauses)) if where_clauses else '' + + total = conn.execute( + f'SELECT COUNT(*) FROM usage_logs{where_sql}', params + ).fetchone()[0] + + offset = (page - 1) * per_page + logs = conn.execute( + f'SELECT * FROM usage_logs{where_sql} ORDER BY created_at DESC LIMIT ? OFFSET ?', + params + [per_page, offset] + ).fetchall() + conn.close() + + return jsonify({ + 'logs': [dict(row) for row in logs], + 'total': total, + 'page': page, + 'per_page': per_page, + 'total_pages': (total + per_page - 1) // per_page + }) + + +@bp.route('/stats', methods=['GET']) +@admin_required +def stats(): + conn = get_db() + user_stats = conn.execute(''' + SELECT username, display_name, + COUNT(*) as call_count, + SUM(text_length) as total_chars, + MAX(created_at) as last_used + FROM usage_logs + GROUP BY user_id + ORDER BY call_count DESC + ''').fetchall() + + totals = conn.execute(''' + SELECT COUNT(*) as total_calls, + COALESCE(SUM(text_length), 0) as total_chars, + COUNT(DISTINCT user_id) as active_users + FROM usage_logs + ''').fetchone() + + conn.close() + return jsonify({ + 'user_stats': [dict(row) for row in user_stats], + 'totals': dict(totals) + }) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py new file mode 100644 index 0000000..0ab1ed2 --- /dev/null +++ b/backend/app/routes/auth.py @@ -0,0 +1,150 @@ +from flask import Blueprint, request, jsonify, session +from werkzeug.security import generate_password_hash, check_password_hash +from app.db import get_db +import functools + +bp = Blueprint('auth', __name__, url_prefix='/api') + + +def login_required(f): + """登录验证装饰器""" + @functools.wraps(f) + def wrapped(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'error': '未登录,请先登录'}), 401 + return f(*args, **kwargs) + return wrapped + + +def admin_required(f): + """管理员验证装饰器""" + @functools.wraps(f) + def wrapped(*args, **kwargs): + if 'user_id' not in session: + return jsonify({'error': '未登录'}), 401 + if session.get('role') != 'admin': + return jsonify({'error': '无权限,仅管理员可操作'}), 403 + return f(*args, **kwargs) + return wrapped + + +@bp.route('/login', methods=['POST']) +def login(): + data = request.get_json() + username = data.get('username', '').strip() + password = data.get('password', '') + + if not username or not password: + return jsonify({'error': '用户名和密码不能为空'}), 400 + + conn = get_db() + user = conn.execute( + 'SELECT * FROM users WHERE username = ?', (username,) + ).fetchone() + conn.close() + + if not user or not check_password_hash(user['password_hash'], password): + return jsonify({'error': '用户名或密码错误'}), 401 + + if not user['is_active']: + return jsonify({'error': '账号已被停用,请联系管理员'}), 403 + + session.clear() + session['user_id'] = user['id'] + session['username'] = user['username'] + session['display_name'] = user['display_name'] + session['role'] = user['role'] + session.permanent = True + + return jsonify({ + 'success': True, + 'user': { + 'id': user['id'], + 'username': user['username'], + 'display_name': user['display_name'], + 'role': user['role'] + } + }) + + +@bp.route('/logout', methods=['POST']) +def logout(): + session.clear() + return jsonify({'success': True}) + + +@bp.route('/me', methods=['GET']) +@login_required +def me(): + return jsonify({ + 'user': { + 'id': session['user_id'], + 'username': session['username'], + 'display_name': session['display_name'], + 'role': session['role'] + } + }) + + +@bp.route('/register', methods=['POST']) +def register(): + """邀请码注册""" + data = request.get_json() + invite_code = data.get('invite_code', '').strip() + username = data.get('username', '').strip() + password = data.get('password', '') + display_name = data.get('display_name', '').strip() + + if not invite_code or not username or not password: + return jsonify({'error': '邀请码、用户名、密码均不能为空'}), 400 + + if len(username) < 2 or len(username) > 20: + return jsonify({'error': '用户名长度2-20个字符'}), 400 + + if len(password) < 6: + return jsonify({'error': '密码至少6位'}), 400 + + conn = get_db() + try: + # 验证邀请码 + invite = conn.execute( + 'SELECT * FROM invite_codes WHERE code = ? AND is_used = 0', + (invite_code,) + ).fetchone() + if not invite: + return jsonify({'error': '邀请码无效或已被使用'}), 400 + + # 检查用户名是否已存在 + existing = conn.execute( + 'SELECT id FROM users WHERE username = ?', (username,) + ).fetchone() + if existing: + return jsonify({'error': '用户名已存在,请换一个'}), 400 + + # 如果用户没填显示名,用邀请码绑定的编导姓名 + if not display_name: + display_name = invite['editor_name'] + + # 创建用户 + cursor = conn.execute( + 'INSERT INTO users (username, display_name, password_hash, role) VALUES (?, ?, ?, ?)', + (username, display_name, generate_password_hash(password), 'editor') + ) + user_id = cursor.lastrowid + + # 标记邀请码已使用 + conn.execute( + 'UPDATE invite_codes SET is_used = 1, used_by_user_id = ?, used_at = CURRENT_TIMESTAMP WHERE id = ?', + (user_id, invite['id']) + ) + conn.commit() + + return jsonify({ + 'success': True, + 'message': f'注册成功!欢迎 {display_name}' + }) + except Exception as e: + conn.rollback() + return jsonify({'error': f'注册失败: {str(e)}'}), 500 + finally: + conn.close() diff --git a/backend/app/routes/tts_synthesize.py b/backend/app/routes/tts_synthesize.py index fff4d86..ffe5da2 100644 --- a/backend/app/routes/tts_synthesize.py +++ b/backend/app/routes/tts_synthesize.py @@ -1,56 +1,63 @@ -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, session import base64 import os -import requests from app.services.minimax_service import synthesize_minimax from app.services.cosyvoice_service import synthesize_cosyvoice +from app.routes.auth import login_required +from app.db import get_db bp = Blueprint('tts', __name__, url_prefix='/api') -MINIMAX_API_KEY = os.getenv('MINIMAX_API_KEY', '') -MINIMAX_API_URL = 'https://api.minimax.chat/v1/t2a_v2' - -COSYVOICE_API_URL = os.getenv('COSYVOICE_API_URL', 'http://127.0.0.1:5001/v1/audio/synthesis') @bp.route('/synthesize', methods=['POST']) +@login_required def synthesize(): data = request.get_json() - + if not data or 'text' not in data: return jsonify({'error': '缺少text参数'}), 400 - + text = data.get('text') engine = data.get('engine', 'minimax') speed = data.get('speed', 1.0) pitch = data.get('pitch', 0) volume = data.get('volume', 1.0) - instruction = data.get('instruction') # 用于CosyVoice的情绪指令 - emotion = data.get('emotion', 'neutral') # 用于MiniMax的情绪参数 - + instruction = data.get('instruction') + emotion = data.get('emotion', 'neutral') + if not text.strip(): return jsonify({'error': '文本不能为空'}), 400 - + if len(text) > 2000: return jsonify({'error': '文本不能超过2000字'}), 400 - + try: if engine == 'cosyvoice': audio_data = synthesize_cosyvoice( - text=text, - speed=speed, - pitch=pitch, - volume=volume, - instruction=instruction + text=text, speed=speed, pitch=pitch, + volume=volume, instruction=instruction ) else: audio_data = synthesize_minimax( - text=text, - speed=speed, - pitch=pitch, - volume=volume, - emotion=emotion + text=text, speed=speed, pitch=pitch, + volume=volume, emotion=emotion ) - + + # 记录使用日志 + try: + conn = get_db() + conn.execute( + '''INSERT INTO usage_logs + (user_id, username, display_name, text_content, text_length, engine, emotion) + VALUES (?, ?, ?, ?, ?, ?, ?)''', + (session['user_id'], session['username'], session['display_name'], + text, len(text), engine, emotion) + ) + conn.commit() + conn.close() + except Exception: + pass # 日志写入失败不影响正常合成 + return jsonify({ 'success': True, 'audio': base64.b64encode(audio_data).decode('utf-8') diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..0fba2bf --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,313 @@ + + + + + + 管理后台 - 军事科技AI配音 + + + +
+
+

管理后台

+
+ 返回配音 + +
+
+ + +
+ + + + +
+ + +
+
+

邀请码管理

+
+ + +
+
+ + + + + + + + + + + + +
邀请码对应编导状态注册用户名创建时间操作
+
+ + +
+

用户管理

+ + + + + + + + + + + + + + +
用户名显示名角色状态调用次数总字数注册时间操作
+
+ + +
+
+

使用记录

+
+ + + + +
+
+ + + + + + + + + + + +
时间用户引擎字数稿件内容
+ +
+ + +
+

用量统计

+
+ + + + + + + + + + + +
用户显示名调用次数总字数最后使用
+
+
+ + + + diff --git a/frontend/app.js b/frontend/app.js index 2910d59..8476c9c 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -358,7 +358,39 @@ elements.audioDisplay.addEventListener('click', () => { if (state.generatedAudio && !state.isPlaying) { togglePlay(); } }); -function init() { +async function init() { + // 登录检测 + try { + const res = await fetch('/api/me'); + if (!res.ok) { + window.location.href = '/login.html'; + return; + } + const data = await res.json(); + const user = data.user; + + // 在 header 右侧显示用户信息 + const headerInfo = document.querySelector('.header-info'); + headerInfo.innerHTML = ` + 蓝皓 + + `; + + document.getElementById('logoutLink').addEventListener('click', async (e) => { + e.preventDefault(); + await fetch('/api/logout', { method: 'POST' }); + window.location.href = '/login.html'; + }); + + } catch (e) { + window.location.href = '/login.html'; + return; + } + updateCharCount(); updateSliderDisplay(); const savedHistory = localStorage.getItem('tts_history'); diff --git a/frontend/login.html b/frontend/login.html new file mode 100644 index 0000000..c463b66 --- /dev/null +++ b/frontend/login.html @@ -0,0 +1,135 @@ + + + + + + 登录 - 军事科技AI配音 + + + +
+
+
+ +

军事科技 AI配音

+
+ + +
+

登录

+
+ + +
+
+ + +
+ +

有邀请码?点击注册

+
+
+ + + +
+
+ + + + diff --git a/frontend/styles.css b/frontend/styles.css index a5d89a4..d03ddb2 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -370,4 +370,346 @@ body { @media (max-width: 768px) { .toolbar { flex-direction: column; gap: 12px; } .param-controls { grid-template-columns: 1fr; } +} + +/* ========== 登录页 ========== */ +.auth-container { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #0a0e1a 0%, #1a1f35 100%); +} + +.auth-card { + background: #1e2438; + border-radius: 16px; + padding: 40px; + width: 100%; + max-width: 400px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.auth-header { + text-align: center; + margin-bottom: 30px; +} + +.auth-header h1 { + color: #fff; + font-size: 24px; + margin-top: 12px; +} + +.auth-header h1 span { + color: #00d4aa; +} + +.auth-logo { + width: 48px; + height: 48px; + color: #00d4aa; +} + +.auth-form h2 { + color: #ccc; + font-size: 18px; + margin-bottom: 20px; + text-align: center; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group label { + display: block; + color: #999; + font-size: 13px; + margin-bottom: 6px; +} + +.form-group input { + width: 100%; + padding: 10px 14px; + background: #151929; + border: 1px solid #333; + border-radius: 8px; + color: #fff; + font-size: 14px; + outline: none; + box-sizing: border-box; +} + +.form-group input:focus { + border-color: #00d4aa; +} + +.btn-full { + width: 100%; + margin-top: 10px; +} + +.auth-switch { + text-align: center; + margin-top: 16px; + color: #999; + font-size: 13px; +} + +.auth-switch a { + color: #00d4aa; + text-decoration: none; +} + +.auth-error { + color: #ff6b6b; + text-align: center; + margin-top: 12px; + font-size: 13px; + min-height: 20px; +} + +/* ========== 用户信息 ========== */ +.user-info { + display: inline-flex; + align-items: center; + gap: 12px; + color: #ccc; + font-size: 13px; + margin-left: 12px; +} + +.admin-link, .logout-link { + color: #00d4aa; + text-decoration: none; + font-size: 12px; +} + +.logout-link { + color: #999; +} + +.admin-link:hover, .logout-link:hover { + text-decoration: underline; +} + +/* ========== 管理后台 ========== */ +.admin-container { + min-height: 100vh; + background: #0d1117; + color: #e6e6e6; + padding: 20px 30px; +} + +.admin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #2a2f3f; +} + +.admin-header h1 { + font-size: 22px; + color: #fff; +} + +.admin-nav { + display: flex; + gap: 10px; +} + +.admin-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 2px solid #2a2f3f; +} + +.tab-btn { + padding: 10px 20px; + background: transparent; + border: none; + color: #999; + cursor: pointer; + font-size: 14px; + border-bottom: 2px solid transparent; + margin-bottom: -2px; +} + +.tab-btn.active { + color: #00d4aa; + border-bottom-color: #00d4aa; +} + +.tab-panel { + display: none; +} + +.tab-panel.active { + display: block; +} + +.panel-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + flex-wrap: wrap; + gap: 10px; +} + +.panel-toolbar h2 { + font-size: 18px; + color: #fff; +} + +.invite-create { + display: flex; + gap: 10px; +} + +.invite-create input { + padding: 8px 12px; + background: #151929; + border: 1px solid #333; + border-radius: 6px; + color: #fff; + font-size: 14px; + outline: none; +} + +.invite-code { + background: #00d4aa22; + color: #00d4aa; + padding: 3px 8px; + border-radius: 4px; + font-size: 14px; + letter-spacing: 1px; + user-select: all; +} + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.admin-table th { + background: #1a1f35; + color: #999; + padding: 10px 12px; + text-align: left; + font-weight: normal; + white-space: nowrap; +} + +.admin-table td { + padding: 10px 12px; + border-bottom: 1px solid #1e2438; + vertical-align: top; +} + +.admin-table tr:hover td { + background: #151929; +} + +.text-cell { + max-width: 400px; + white-space: pre-wrap; + word-break: break-all; + font-size: 12px; + color: #ccc; + line-height: 1.5; +} + +.nowrap { + white-space: nowrap; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + font-size: 12px; +} + +.badge-active { + background: #00d4aa22; + color: #00d4aa; +} + +.badge-used { + background: #ff6b6b22; + color: #ff6b6b; +} + +.btn-sm { + padding: 4px 10px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + color: #fff; + background: #333; +} + +.btn-sm.btn-primary { + background: #00d4aa; + color: #000; +} + +.btn-sm.btn-danger { + background: #ff6b6b; +} + +.log-filters { + display: flex; + gap: 10px; + align-items: center; +} + +.log-filters select, +.log-filters input[type="date"] { + padding: 6px 10px; + background: #151929; + border: 1px solid #333; + border-radius: 6px; + color: #fff; + font-size: 13px; + outline: none; +} + +.pagination { + margin-top: 16px; + display: flex; + gap: 4px; +} + +.stats-summary { + display: flex; + gap: 20px; + margin-bottom: 24px; +} + +.stat-card { + background: #1a1f35; + border-radius: 12px; + padding: 20px 30px; + text-align: center; + flex: 1; +} + +.stat-card h3 { + font-size: 32px; + color: #00d4aa; + margin: 0; +} + +.stat-card p { + color: #999; + margin: 8px 0 0; + font-size: 13px; } \ No newline at end of file