Spaces:
Sleeping
Sleeping
| import React from 'react'; | |
| import { Settings, Trash2, RefreshCw } from 'lucide-react'; | |
| export default function AdminTab({ | |
| adminSelectedStreamId, | |
| setAdminSelectedStreamId, | |
| streams, | |
| editTitle, | |
| setEditTitle, | |
| editCategory, | |
| setEditCategory, | |
| savingMetadata, | |
| setSavingMetadata, | |
| deletingStream, | |
| setDeletingStream, | |
| syncingVods, | |
| setSyncingVods, | |
| cleaningStreams, | |
| setCleaningStreams, | |
| loadingAdminStats, | |
| adminStats, | |
| fetchAdminStats, | |
| fetchStreams, | |
| API_BASE | |
| }) { | |
| return ( | |
| <div className="tab-content"> | |
| <h2 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}> | |
| <Settings size={24} style={{ color: 'var(--color-brand)' }} /> Панель администратора | |
| </h2> | |
| <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem', marginBottom: '2rem' }}> | |
| {/* Left: Stream management */} | |
| <div className="panel" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> | |
| <div className="panel-header"> | |
| <h3 className="panel-title">Управление стримами</h3> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> | |
| <label style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--color-text-muted)' }}>Выбрать стрим для редактирования или удаления:</label> | |
| <select | |
| className="dark-input" | |
| value={adminSelectedStreamId} | |
| onChange={(e) => setAdminSelectedStreamId(e.target.value)} | |
| > | |
| <option value="">-- Выберите стрим --</option> | |
| {streams.map((stream) => ( | |
| <option key={stream.id} value={stream.id}> | |
| {stream.title || 'Без названия'} ({new Date(stream.start_time).toLocaleDateString('ru-RU')} {new Date(stream.start_time).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}) | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| {(() => { | |
| const activeStream = streams.find(s => s.id === parseInt(adminSelectedStreamId)); | |
| if (!activeStream) return ( | |
| <div style={{ padding: '1.5rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}> | |
| Выберите стрим в выпадающем списке выше для выполнения действий. | |
| </div> | |
| ); | |
| const statusLabel = | |
| activeStream.backfill_status === 'completed' ? 'Импортирован полностью' : | |
| activeStream.backfill_status === 'pending' ? 'В очереди воркера (ожидание)' : | |
| activeStream.backfill_status === 'live' ? 'В эфире (live)' : activeStream.backfill_status; | |
| const statusColor = | |
| activeStream.backfill_status === 'completed' ? 'var(--color-success, #00f2fe)' : | |
| activeStream.backfill_status === 'pending' ? 'var(--color-warning, #f1c40f)' : 'var(--color-primary)'; | |
| return ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}> | |
| <div style={{ fontSize: '0.8rem', padding: '0.75rem', background: 'rgba(255,255,255,0.02)', borderRadius: '6px', border: '1px solid var(--color-border)' }}> | |
| <p style={{ margin: '0 0 0.4rem 0' }}><strong>ID сессии:</strong> {activeStream.id}</p> | |
| <p style={{ margin: '0 0 0.4rem 0' }}><strong>Статус импорта VOD:</strong> <span style={{ color: statusColor, fontWeight: 'bold' }}>{statusLabel}</span></p> | |
| <p style={{ margin: '0 0 0.4rem 0' }}><strong>Twitch VOD ID:</strong> {activeStream.twitch_vod_id || 'Отсутствует'}</p> | |
| <p style={{ margin: 0 }}><strong>Начало:</strong> {new Date(activeStream.start_time).toLocaleString('ru-RU')}</p> | |
| </div> | |
| {/* Edit Metadata */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', padding: '1rem', background: 'rgba(255,255,255,0.01)', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.03)' }}> | |
| <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Редактирование названия / категории</h4> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> | |
| <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Название стрима:</label> | |
| <input | |
| type="text" | |
| className="dark-input" | |
| value={editTitle} | |
| onChange={(e) => setEditTitle(e.target.value)} | |
| /> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}> | |
| <label style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>Категория (игра):</label> | |
| <input | |
| type="text" | |
| className="dark-input" | |
| value={editCategory} | |
| onChange={(e) => setEditCategory(e.target.value)} | |
| /> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ width: '100%', padding: '0.5rem', marginTop: '0.25rem' }} | |
| disabled={savingMetadata || !editTitle.trim()} | |
| onClick={async () => { | |
| setSavingMetadata(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ title: editTitle, category: editCategory }), | |
| credentials: 'include' | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| alert('Метаданные стрима успешно обновлены!'); | |
| fetchStreams(); | |
| } else { | |
| alert(`Ошибка: ${data.error}`); | |
| } | |
| } catch (e) { | |
| alert('Ошибка при обновлении метаданных.'); | |
| } finally { | |
| setSavingMetadata(false); | |
| } | |
| }} | |
| > | |
| {savingMetadata ? 'Сохранение...' : 'Сохранить изменения'} | |
| </button> | |
| </div> | |
| {/* VOD Actions (Only if VOD is present) */} | |
| {activeStream.twitch_vod_id && ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}> | |
| <h4 style={{ margin: 0, fontSize: '0.9rem', fontWeight: 600 }}>Действия импорта VOD:</h4> | |
| <div style={{ display: 'flex', gap: '0.5rem' }}> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }} | |
| onClick={async () => { | |
| if (!confirm(`Запустить заполнение пропусков для стрима "${activeStream.title}"? Воркер докачает пропущенный чат и Whisper-речь.`)) return; | |
| try { | |
| const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ streamId: activeStream.id, mode: 'gap_fill' }), | |
| credentials: 'include' | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| alert('Статус сброшен на "ожидание". Воркер скоро начнет дозаполнение!'); | |
| fetchStreams(); | |
| } else { | |
| alert(`Ошибка: ${data.error}`); | |
| } | |
| } catch (e) { | |
| alert('Ошибка при запуске дозаполнения VOD.'); | |
| } | |
| }} | |
| > | |
| Заполнить пропуски VOD | |
| </button> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ flex: 1, fontSize: '0.75rem', padding: '0.5rem' }} | |
| onClick={async () => { | |
| if (!confirm(`Внимание: это полностью удалит сохраненные сообщения чата и слова Whisper для стрима "${activeStream.title}" и заново запустит весь импорт VOD с 0-й секунды. Вы уверены?`)) return; | |
| try { | |
| const res = await fetch(`${API_BASE}/api/streams/reset-backfill`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ streamId: activeStream.id, mode: 'full_rebuild' }), | |
| credentials: 'include' | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| alert('Данные очищены. Стрим поставлен на полный переимпорт воркером!'); | |
| fetchStreams(); | |
| } else { | |
| alert(`Ошибка: ${data.error}`); | |
| } | |
| } catch (e) { | |
| alert('Ошибка при запуске переимпорта.'); | |
| } | |
| }} | |
| > | |
| Полный переимпорт VOD | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Delete Button */} | |
| <div style={{ borderTop: '1px solid var(--color-border)', paddingTop: '1rem', marginTop: '0.5rem' }}> | |
| <button | |
| className="btn btn-danger" | |
| style={{ | |
| width: '100%', | |
| display: 'flex', | |
| alignItems: 'center', | |
| justifyContent: 'center', | |
| gap: '0.4rem', | |
| backgroundColor: 'rgba(231, 76, 60, 0.15)', | |
| color: '#e74c3c', | |
| border: '1px solid rgba(231, 76, 60, 0.3)', | |
| borderRadius: '4px', | |
| cursor: 'pointer', | |
| padding: '0.6rem' | |
| }} | |
| disabled={deletingStream} | |
| onClick={async () => { | |
| if (!confirm(`ВНИМАНИЕ: Вы уверены, что хотите полностью УДАЛИТЬ стрим "${activeStream.title}"? Это действие сотрет всю связанную статистику (чат, слова Whisper, модерацию) из базы данных навсегда и безвозвратно!`)) return; | |
| if (!confirm(`ПОСЛЕДНЕЕ ПРЕДУПРЕЖДЕНИЕ: Вы действительно хотите стереть стрим ID ${activeStream.id} из базы? Восстановление невозможно.`)) return; | |
| setDeletingStream(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/streams/${activeStream.id}`, { | |
| method: 'DELETE', | |
| credentials: 'include' | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| alert('Стрим успешно удален из базы данных!'); | |
| setAdminSelectedStreamId(''); | |
| fetchStreams(); | |
| } else { | |
| alert(`Ошибка: ${data.error}`); | |
| } | |
| } catch (e) { | |
| alert('Ошибка при удалении стрима.'); | |
| } finally { | |
| setDeletingStream(false); | |
| } | |
| }} | |
| > | |
| <Trash2 size={16} /> Удалить стрим полностью | |
| </button> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| {/* Right: Global actions and Stats */} | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> | |
| {/* Global Actions Panel */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title">Глобальные действия</h3> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}> | |
| {/* Twitch VOD Sync */} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '0.75rem', borderBottom: '1px solid var(--color-border)' }}> | |
| <div> | |
| <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Синхронизация Twitch VOD</h4> | |
| <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Запросить последние 20 архивов из Twitch API.</p> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }} | |
| disabled={syncingVods} | |
| onClick={async () => { | |
| setSyncingVods(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/streams/sync-vods`, { method: 'POST', credentials: 'include' }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| alert(`Успешно импортировано ${data.count} стримов!`); | |
| fetchStreams(); | |
| } else { | |
| alert(`Ошибка: ${data.error}`); | |
| } | |
| } catch (e) { | |
| alert('Ошибка при синхронизации VOD.'); | |
| } finally { | |
| setSyncingVods(false); | |
| } | |
| }} | |
| > | |
| <RefreshCw size={12} className={syncingVods ? "spin" : ""} /> {syncingVods ? 'Синхронизация...' : 'Синхронизировать VOD'} | |
| </button> | |
| </div> | |
| {/* Database Cleanup */} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> | |
| <div> | |
| <h4 style={{ margin: 0, fontSize: '0.85rem', fontWeight: 600 }}>Очистить пустые стримы</h4> | |
| <p style={{ margin: 0, fontSize: '0.7rem', color: 'var(--color-text-muted)' }}>Удалить сессии с 0 сообщениями и 0 слов.</p> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ fontSize: '0.75rem', padding: '0.4rem 0.8rem' }} | |
| disabled={cleaningStreams} | |
| onClick={async () => { | |
| if (!confirm('Вы действительно хотите удалить все пустые стримы (в которых нет ни сообщений в чате, ни распознанных слов)? Это очистит тестовый мусор из списка.')) return; | |
| setCleaningStreams(true); | |
| try { | |
| const res = await fetch(`${API_BASE}/api/admin/cleanup`, { method: 'POST', credentials: 'include' }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| alert(`Успешно очищено. Удалено пустых стримов: ${data.count}`); | |
| fetchStreams(); | |
| fetchAdminStats(); | |
| } else { | |
| alert(`Ошибка: ${data.error}`); | |
| } | |
| } catch (e) { | |
| alert('Ошибка при очистке стримов.'); | |
| } finally { | |
| setCleaningStreams(false); | |
| } | |
| }} | |
| > | |
| Очистить пустые стримы | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| {/* System Statistics Panel */} | |
| <div className="panel"> | |
| <div className="panel-header"> | |
| <h3 className="panel-title">Системная статистика</h3> | |
| </div> | |
| {loadingAdminStats || !adminStats ? ( | |
| <div style={{ padding: '1rem', textAlign: 'center', color: 'var(--color-text-muted)', fontSize: '0.85rem' }}> | |
| Загрузка статистики... | |
| </div> | |
| ) : ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', fontSize: '0.8rem' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Режим базы данных:</span> | |
| <strong style={{ color: 'var(--color-brand)' }}>{adminStats.dbMode.toUpperCase()}</strong> | |
| </div> | |
| {adminStats.dbMode === 'sqlite' && ( | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Размер файла SQLite:</span> | |
| <strong>{adminStats.dbSizeMb} МБ</strong> | |
| </div> | |
| )} | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Всего стримов в базе:</span> | |
| <strong>{adminStats.totalStreams}</strong> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', paddingBottom: '0.4rem', borderBottom: '1px solid rgba(255,255,255,0.03)' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Всего сообщений чата:</span> | |
| <strong>{adminStats.totalMessages.toLocaleString()}</strong> | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'space-between' }}> | |
| <span style={{ color: 'var(--color-text-muted)' }}>Всего голосовых слов:</span> | |
| <strong>{adminStats.totalVoiceWords.toLocaleString()}</strong> | |
| </div> | |
| <button | |
| className="btn btn-secondary" | |
| style={{ width: '100%', fontSize: '0.75rem', padding: '0.4rem', marginTop: '0.5rem' }} | |
| onClick={fetchAdminStats} | |
| > | |
| Обновить статистику | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |