fromis_9/frontend/src/pages/pc/admin/AdminScheduleBots.jsx
caadiq 59e5a1d47b feat: X 봇 구현 및 봇 관리 기능 개선
- X 봇 서비스 추가 (x-bot.js)
  - Nitter를 통한 @realfromis_9 트윗 수집
  - 트윗을 일정으로 자동 저장 (카테고리 12)
  - 관리 채널 외 유튜브 링크 감지 시 별도 일정 추가
  - 1분 간격 동기화 지원

- DB 스키마 변경
  - bots.type enum 수정 (vlive, weverse 제거, x 추가)
  - bot_x_config 테이블 추가

- 봇 스케줄러 수정 (youtube-scheduler.js)
  - 봇 타입별 동기화 함수 분기 (syncBot)
  - X 봇 지원 추가

- 관리자 페이지 개선 (AdminScheduleBots.jsx)
  - 봇 타입별 아이콘 표시 (YouTube/X)
  - X 아이콘 SVG 컴포넌트 추가

- last_added_count 로직 수정
  - 추가 항목 없으면 이전 값 유지 (0으로 초기화 방지)

- 기존 X 일정에서 유튜브 영상 추출 스크립트 추가
2026-01-10 17:06:23 +09:00

417 lines
20 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import {
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';
import AdminHeader from '../../../components/admin/AdminHeader';
import useToast from '../../../hooks/useToast';
import * as botsApi from '../../../api/admin/bots';
// X 아이콘 컴포넌트
const XIcon = ({ size = 20, fill = "currentColor" }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
function AdminScheduleBots() {
const navigate = useNavigate();
const [user, setUser] = useState(null);
const { toast, setToast } = useToast();
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); // 할당량 경고 상태
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 data = await botsApi.getBots();
setBots(data);
} catch (error) {
console.error('봇 목록 조회 오류:', error);
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
} finally {
setLoading(false);
}
};
// 할당량 경고 상태 조회
const fetchQuotaWarning = async () => {
try {
const data = await botsApi.getQuotaWarning();
if (data.active) {
setQuotaWarning(data);
}
} catch (error) {
console.error('할당량 경고 조회 오류:', error);
}
};
// 할당량 경고 해제
const handleDismissQuotaWarning = async () => {
try {
await botsApi.dismissQuotaWarning();
setQuotaWarning(null);
} catch (error) {
console.error('할당량 경고 해제 오류:', error);
}
};
// 봇 시작/정지 토글
const toggleBot = async (botId, currentStatus, botName) => {
try {
const action = currentStatus === 'running' ? 'stop' : 'start';
if (action === 'start') {
await botsApi.startBot(botId);
} else {
await botsApi.stopBot(botId);
}
// 로컬 상태만 업데이트 (전체 목록 새로고침 대신)
setBots(prev => prev.map(bot =>
bot.id === botId
? { ...bot, status: action === 'start' ? 'running' : 'stopped' }
: bot
));
setToast({
type: 'success',
message: action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`
});
} catch (error) {
console.error('봇 토글 오류:', error);
setToast({ type: 'error', message: error.message || '작업 중 오류가 발생했습니다.' });
}
};
// 전체 동기화
const handleSyncAllVideos = async (botId) => {
setSyncing(botId);
try {
const data = await botsApi.syncAllVideos(botId);
setToast({
type: 'success',
message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`
});
fetchBots();
} catch (error) {
console.error('전체 동기화 오류:', error);
setToast({ type: 'error', message: 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)} />
{/* 헤더 */}
<AdminHeader user={user} />
{/* 메인 콘텐츠 */}
<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={handleDismissQuotaWarning}
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 flex items-center justify-center ${
bot.type === 'x' ? 'bg-black' : 'bg-red-50'
}`}>
{bot.type === 'x' ? (
<XIcon size={20} fill="white" />
) : (
<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={() => handleSyncAllVideos(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;