2026-01-05 22:16:02 +09:00
|
|
|
|
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';
|
|
|
|
|
|
|
|
|
|
|
|
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 [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
|
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
|
}, [navigate]);
|
|
|
|
|
|
|
|
|
|
|
|
// 봇 목록 조회
|
|
|
|
|
|
const fetchBots = async () => {
|
|
|
|
|
|
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 handleLogout = () => {
|
|
|
|
|
|
localStorage.removeItem('adminToken');
|
|
|
|
|
|
localStorage.removeItem('adminUser');
|
|
|
|
|
|
navigate('/admin');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 봇 시작/정지 토글
|
2026-01-06 00:27:35 +09:00
|
|
|
|
const toggleBot = async (botId, currentStatus, botName) => {
|
2026-01-05 22:16:02 +09:00
|
|
|
|
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) {
|
|
|
|
|
|
setToast({
|
|
|
|
|
|
type: 'success',
|
2026-01-06 00:27:35 +09:00
|
|
|
|
message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`
|
2026-01-05 22:16:02 +09:00
|
|
|
|
});
|
|
|
|
|
|
fetchBots(); // 목록 새로고침
|
|
|
|
|
|
} 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 || '동기화 실패' });
|
|
|
|
|
|
}
|
2026-01-05 22:31:56 +09:00
|
|
|
|
// 성공/실패 모두 목록 갱신
|
|
|
|
|
|
fetchBots();
|
2026-01-05 22:16:02 +09:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('전체 동기화 오류:', error);
|
|
|
|
|
|
setToast({ type: 'error', message: '동기화 중 오류가 발생했습니다.' });
|
2026-01-05 22:31:56 +09:00
|
|
|
|
fetchBots(); // 오류에도 목록 갱신
|
2026-01-05 22:16:02 +09:00
|
|
|
|
} 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'
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
|
// 간격 포맷 (분 → 분/시간/일)
|
|
|
|
|
|
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}분`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
|
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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 봇 목록 */}
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<h2 className="font-bold text-gray-900">봇 목록</h2>
|
|
|
|
|
|
</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="divide-y divide-gray-100">
|
|
|
|
|
|
{bots.map((bot, index) => {
|
|
|
|
|
|
const statusInfo = getStatusInfo(bot.status);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
key={bot.id}
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ delay: index * 0.05 }}
|
|
|
|
|
|
className="p-6 hover:bg-gray-50 transition-colors"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex items-start gap-4">
|
|
|
|
|
|
{/* 아이콘 */}
|
|
|
|
|
|
<div className={`w-12 h-12 rounded-xl ${statusInfo.bg} flex items-center justify-center flex-shrink-0`}>
|
|
|
|
|
|
<Youtube size={24} className="text-red-500" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 정보 */}
|
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
|
<div className="flex items-center gap-3 mb-1">
|
|
|
|
|
|
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
|
|
|
|
|
<span className={`flex items-center gap-1 text-xs font-medium ${statusInfo.color}`}>
|
|
|
|
|
|
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}></span>
|
|
|
|
|
|
{statusInfo.text}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="px-2 py-0.5 text-xs font-medium bg-red-50 text-red-600 rounded-full">
|
|
|
|
|
|
YouTube
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-sm text-gray-500 mb-3">
|
2026-01-06 00:27:35 +09:00
|
|
|
|
채널: {bot.channel_name || bot.channel_id} | {formatInterval(bot.check_interval)} 간격
|
2026-01-05 22:16:02 +09:00
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 메타 정보 */}
|
|
|
|
|
|
<div className="flex items-center gap-6 text-xs text-gray-400">
|
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
|
<Clock size={12} />
|
|
|
|
|
|
마지막 체크: {formatTime(bot.last_check_at)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
|
<Calendar size={12} />
|
|
|
|
|
|
추가된 일정: {bot.schedules_added}개
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 오류 메시지 */}
|
|
|
|
|
|
{bot.status === 'error' && bot.error_message && (
|
|
|
|
|
|
<div className="mt-3 px-3 py-2 bg-red-50 text-red-600 text-xs rounded-lg">
|
|
|
|
|
|
⚠️ {bot.error_message}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼 */}
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={() => syncAllVideos(bot.id)}
|
|
|
|
|
|
disabled={syncing === bot.id}
|
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 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" />
|
|
|
|
|
|
동기화 중...
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Download size={16} />
|
|
|
|
|
|
전체 동기화
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
2026-01-06 00:27:35 +09:00
|
|
|
|
onClick={() => toggleBot(bot.id, bot.status, bot.name)}
|
2026-01-05 22:16:02 +09:00
|
|
|
|
className={`flex items-center gap-2 px-4 py-2 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} />
|
|
|
|
|
|
정지
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Play size={16} />
|
|
|
|
|
|
시작
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default AdminScheduleBots;
|