fromis_9/frontend/src/pages/pc/admin/AdminScheduleBots.jsx

473 lines
23 KiB
React
Raw Normal View History

import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
LogOut, Home, ChevronRight, Bot, Play, Square,
Youtube, Calendar, Clock, CheckCircle, XCircle, RefreshCw, Download
} from 'lucide-react';
import Toast from '../../../components/Toast';
import Tooltip from '../../../components/Tooltip';
function AdminScheduleBots() {
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [toast, setToast] = useState(null);
const [bots, setBots] = useState([]);
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
// Toast 자동 숨김
useEffect(() => {
if (toast) {
const timer = setTimeout(() => setToast(null), 3000);
return () => clearTimeout(timer);
}
}, [toast]);
useEffect(() => {
const token = localStorage.getItem('adminToken');
const userData = localStorage.getItem('adminUser');
if (!token || !userData) {
navigate('/admin');
return;
}
setUser(JSON.parse(userData));
fetchBots();
fetchQuotaWarning();
}, [navigate]);
// 봇 목록 조회
const fetchBots = async () => {
setLoading(true);
try {
const token = localStorage.getItem('adminToken');
const response = await fetch('/api/admin/bots', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setBots(data);
}
} catch (error) {
console.error('봇 목록 조회 오류:', error);
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
} finally {
setLoading(false);
}
};
// 할당량 경고 상태 조회
const fetchQuotaWarning = async () => {
try {
const token = localStorage.getItem('adminToken');
const response = await fetch('/api/admin/quota-warning', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
if (data.active) {
setQuotaWarning(data);
}
}
} catch (error) {
console.error('할당량 경고 조회 오류:', error);
}
};
// 할당량 경고 해제
const dismissQuotaWarning = async () => {
try {
const token = localStorage.getItem('adminToken');
await fetch('/api/admin/quota-warning', {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
setQuotaWarning(null);
} catch (error) {
console.error('할당량 경고 해제 오류:', error);
}
};
const handleLogout = () => {
localStorage.removeItem('adminToken');
localStorage.removeItem('adminUser');
navigate('/admin');
};
// 봇 시작/정지 토글
const toggleBot = async (botId, currentStatus, botName) => {
try {
const token = localStorage.getItem('adminToken');
const action = currentStatus === 'running' ? 'stop' : 'start';
const response = await fetch(`/api/admin/bots/${botId}/${action}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
// 로컬 상태만 업데이트 (전체 목록 새로고침 대신)
setBots(prev => prev.map(bot =>
bot.id === botId
? { ...bot, status: action === 'start' ? 'running' : 'stopped' }
: bot
));
setToast({
type: 'success',
message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`
});
} else {
const data = await response.json();
setToast({ type: 'error', message: data.error || '작업 실패' });
}
} catch (error) {
console.error('봇 토글 오류:', error);
setToast({ type: 'error', message: '작업 중 오류가 발생했습니다.' });
}
};
// 전체 동기화
const syncAllVideos = async (botId) => {
setSyncing(botId);
try {
const token = localStorage.getItem('adminToken');
const response = await fetch(`/api/admin/bots/${botId}/sync-all`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
setToast({
type: 'success',
message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`
});
} else {
const data = await response.json();
setToast({ type: 'error', message: data.error || '동기화 실패' });
}
// 성공/실패 모두 목록 갱신
fetchBots();
} catch (error) {
console.error('전체 동기화 오류:', error);
setToast({ type: 'error', message: '동기화 중 오류가 발생했습니다.' });
fetchBots(); // 오류에도 목록 갱신
} finally {
setSyncing(null);
}
};
// 상태 아이콘 및 색상
const getStatusInfo = (status) => {
switch (status) {
case 'running':
return {
icon: <CheckCircle size={16} />,
text: '실행 중',
color: 'text-green-500',
bg: 'bg-green-50',
dot: 'bg-green-500',
};
case 'stopped':
return {
icon: <XCircle size={16} />,
text: '정지됨',
color: 'text-gray-400',
bg: 'bg-gray-50',
dot: 'bg-gray-400',
};
case 'error':
return {
icon: <XCircle size={16} />,
text: '오류',
color: 'text-red-500',
bg: 'bg-red-50',
dot: 'bg-red-500',
};
default:
return {
icon: null,
text: '알 수 없음',
color: 'text-gray-400',
bg: 'bg-gray-50',
dot: 'bg-gray-400',
};
}
};
// 시간 포맷 (DB에 KST로 저장되어 있으므로 그대로 표시)
const formatTime = (dateString) => {
if (!dateString) return '-';
// DB의 KST 시간을 UTC로 재해석하지 않도록 Z 접미사 제거
const cleanDateString = dateString.replace('Z', '').replace('T', ' ');
const date = new Date(cleanDateString);
return date.toLocaleString('ko-KR', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
// 간격 포맷 (분 → 분/시간/일)
const formatInterval = (minutes) => {
if (!minutes) return '-';
if (minutes >= 1440) {
const days = Math.floor(minutes / 1440);
return `${days}`;
} else if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
return `${hours}시간`;
}
return `${minutes}`;
};
return (
<div className="min-h-screen bg-gray-50">
<Toast toast={toast} onClose={() => setToast(null)} />
{/* 헤더 */}
<header className="bg-white shadow-sm border-b border-gray-100">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link to="/admin/dashboard" className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity">
fromis_9
</Link>
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
Admin
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-500 text-sm">
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>
</span>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<LogOut size={18} />
<span>로그아웃</span>
</button>
</div>
</div>
</header>
{/* 메인 콘텐츠 */}
<main className="max-w-7xl mx-auto px-6 py-8">
{/* 브레드크럼 */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
<Home size={16} />
</Link>
<ChevronRight size={14} />
<Link to="/admin/schedule" className="hover:text-primary transition-colors">
일정 관리
</Link>
<ChevronRight size={14} />
<span className="text-gray-700"> 관리</span>
</div>
{/* 타이틀 */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2"> 관리</h1>
<p className="text-gray-500">일정 자동화 봇을 관리합니다</p>
</div>
{/* 봇 통계 */}
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">전체 </div>
<div className="text-2xl font-bold text-gray-900">{bots.length}</div>
</div>
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">실행 </div>
<div className="text-2xl font-bold text-green-500">
{bots.filter(b => b.status === 'running').length}
</div>
</div>
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">정지됨</div>
<div className="text-2xl font-bold text-gray-400">
{bots.filter(b => b.status === 'stopped').length}
</div>
</div>
<div className="bg-white rounded-xl p-5 border border-gray-100">
<div className="text-sm text-gray-500 mb-1">오류</div>
<div className="text-2xl font-bold text-red-500">
{bots.filter(b => b.status === 'error').length}
</div>
</div>
</div>
{/* API 할당량 경고 배너 */}
{quotaWarning && (
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<XCircle size={18} className="text-red-500" />
</div>
<div>
<h3 className="font-bold text-red-700">YouTube API 할당량 경고</h3>
<p className="text-sm text-red-600 mt-0.5">
{quotaWarning.message}
</p>
</div>
</div>
<button
onClick={dismissQuotaWarning}
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
>
닫기
</button>
</div>
)}
{/* 봇 목록 */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 className="font-bold text-gray-900"> 목록</h2>
<Tooltip text="새로고침">
<button
onClick={() => { setIsInitialLoad(true); fetchBots(); }}
disabled={loading}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700 disabled:opacity-50"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</Tooltip>
</div>
{loading ? (
<div className="flex justify-center items-center py-20">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
</div>
) : bots.length === 0 ? (
<div className="text-center py-20 text-gray-400">
<Bot size={48} className="mx-auto mb-4 opacity-30" />
<p>등록된 봇이 없습니다</p>
<p className="text-sm mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
</div>
) : (
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
{bots.map((bot, index) => {
const statusInfo = getStatusInfo(bot.status);
return (
<motion.div
key={bot.id}
initial={isInitialLoad ? { opacity: 0, scale: 0.95 } : false}
animate={{ opacity: 1, scale: 1 }}
transition={isInitialLoad ? { delay: index * 0.05 } : { duration: 0.15 }}
onAnimationComplete={() => isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)}
className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
>
{/* 상단 헤더 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center">
<Youtube size={20} className="text-red-500" />
</div>
<div>
<h3 className="font-bold text-gray-900">{bot.name}</h3>
<p className="text-xs text-gray-400">
{bot.last_check_at ? `${formatTime(bot.last_check_at)}에 업데이트됨` : '아직 업데이트 없음'}
</p>
</div>
</div>
<span className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}></span>
{statusInfo.text}
</span>
</div>
{/* 통계 정보 */}
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
<div className="text-xs text-gray-400"> 추가</div>
</div>
<div className="p-3 text-center">
<div className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}>
+{bot.last_added_count || 0}
</div>
<div className="text-xs text-gray-400">마지막</div>
</div>
<div className="p-3 text-center">
<div className="text-lg font-bold text-gray-900">{formatInterval(bot.check_interval)}</div>
<div className="text-xs text-gray-400">업데이트 간격</div>
</div>
</div>
{/* 오류 메시지 */}
{bot.status === 'error' && bot.error_message && (
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs border-t border-red-100">
{bot.error_message}
</div>
)}
{/* 액션 버튼 */}
<div className="p-4 border-t border-gray-100">
<div className="flex gap-2">
<button
onClick={() => syncAllVideos(bot.id)}
disabled={syncing === bot.id}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
>
{syncing === bot.id ? (
<>
<RefreshCw size={16} className="animate-spin" />
<span>동기화 ...</span>
</>
) : (
<>
<Download size={16} />
<span>전체 동기화</span>
</>
)}
</button>
<button
onClick={() => toggleBot(bot.id, bot.status, bot.name)}
className={`flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
bot.status === 'running'
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
: 'bg-green-500 text-white hover:bg-green-600'
}`}
>
{bot.status === 'running' ? (
<>
<Square size={16} />
<span>정지</span>
</>
) : (
<>
<Play size={16} />
<span>시작</span>
</>
)}
</button>
</div>
</div>
</motion.div>
);
})}
</div>
)}
</div>
</main>
</div>
);
}
export default AdminScheduleBots;