feat(festival-bot): 축제 봇 관리 UI 추가 (2단계)
- bot_festival, festival_crawl_log 테이블 SQL - FestivalBotDialog: 봇 이름/크롤링 URL/동기화 간격(1~24시간) 입력 - 봇 관리 페이지에 '축제' 섹션 추가 (emerald, PartyPopper) - BotCard: festival 타입 수정/삭제 버튼 표시 - API 클라이언트 함수 추가 (백엔드 라우트는 3단계) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2e459995c3
commit
3827a23d75
6 changed files with 393 additions and 7 deletions
20
backend/sql/bot_festival.sql
Normal file
20
backend/sql/bot_festival.sql
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
-- 대학 축제 크롤러 봇 설정
|
||||
CREATE TABLE IF NOT EXISTS bot_festival (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL COMMENT '봇 이름',
|
||||
search_url VARCHAR(500) NOT NULL COMMENT '크롤링할 검색 페이지 URL',
|
||||
cron_interval INT NOT NULL DEFAULT 360 COMMENT '동기화 간격 (분)',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='대학 축제 크롤러 봇 설정';
|
||||
|
||||
-- 축제 크롤러 처리 로그 (memogipost 글 URL 중복 방지)
|
||||
CREATE TABLE IF NOT EXISTS festival_crawl_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
post_url VARCHAR(500) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'processed' COMMENT 'processed | no_event | error',
|
||||
result_count INT DEFAULT 0 COMMENT '추출된 행사 수',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_post_url (post_url)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='축제 크롤러 처리 로그';
|
||||
|
|
@ -121,6 +121,49 @@ export async function deleteXBot(id) {
|
|||
return fetchAuthApi(`/admin/x-bots/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 축제 봇 상세 조회
|
||||
* @param {number} id - 축제 봇 DB ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function getFestivalBot(id) {
|
||||
return fetchAuthApi(`/admin/festival-bots/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 축제 봇 추가
|
||||
* @param {object} data - 봇 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function createFestivalBot(data) {
|
||||
return fetchAuthApi('/admin/festival-bots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 축제 봇 수정
|
||||
* @param {number} id - 축제 봇 DB ID
|
||||
* @param {object} data - 업데이트할 데이터
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function updateFestivalBot(id, data) {
|
||||
return fetchAuthApi(`/admin/festival-bots/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 축제 봇 삭제
|
||||
* @param {number} id - 축제 봇 DB ID
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
export async function deleteFestivalBot(id) {
|
||||
return fetchAuthApi(`/admin/festival-bots/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 봇 시작
|
||||
* @param {string} id - 봇 ID
|
||||
|
|
|
|||
|
|
@ -270,8 +270,8 @@ export const BotTableRow = memo(function BotTableRow({
|
|||
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* 수정 (YouTube, X) */}
|
||||
{(bot.type === 'youtube' || bot.type === 'x') && onEdit && (
|
||||
{/* 수정 (YouTube, X, 축제) */}
|
||||
{(bot.type === 'youtube' || bot.type === 'x' || bot.type === 'festival') && onEdit && (
|
||||
<Tooltip text="수정">
|
||||
<button
|
||||
onClick={() => onEdit(bot)}
|
||||
|
|
@ -281,8 +281,8 @@ export const BotTableRow = memo(function BotTableRow({
|
|||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* 삭제 (YouTube, X) */}
|
||||
{(bot.type === 'youtube' || bot.type === 'x') && onDelete && (
|
||||
{/* 삭제 (YouTube, X, 축제) */}
|
||||
{(bot.type === 'youtube' || bot.type === 'x' || bot.type === 'festival') && onDelete && (
|
||||
<Tooltip text="삭제">
|
||||
<button
|
||||
onClick={() => onDelete(bot)}
|
||||
|
|
|
|||
295
frontend/src/components/pc/admin/bot/FestivalBotDialog.jsx
Normal file
295
frontend/src/components/pc/admin/bot/FestivalBotDialog.jsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* 축제 봇 추가/수정 다이얼로그
|
||||
*/
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronDown, Loader2, PartyPopper } from 'lucide-react';
|
||||
import { getFestivalBot, createFestivalBot, updateFestivalBot } from '@/api/admin/bots';
|
||||
|
||||
// 동기화 간격 옵션 (분 단위)
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 60, label: '1시간' },
|
||||
{ value: 180, label: '3시간' },
|
||||
{ value: 360, label: '6시간' },
|
||||
{ value: 720, label: '12시간' },
|
||||
{ value: 1440, label: '24시간' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 커스텀 드롭다운 (Portal 사용)
|
||||
*/
|
||||
function Dropdown({ value, options, onChange, placeholder = '선택' }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const buttonRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target) &&
|
||||
menuRef.current &&
|
||||
!menuRef.current.contains(event.target)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
setPosition({ top: rect.bottom + 4, left: rect.left, width: rect.width });
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 w-full px-4 py-2.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors justify-between"
|
||||
>
|
||||
<span className={selectedOption ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{selectedOption?.label || placeholder}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
ref={menuRef}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
width: position.width,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="bg-white rounded-xl shadow-lg border border-gray-200 py-1 max-h-60 overflow-y-auto"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(opt.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
|
||||
value === opt.value ? 'bg-emerald-50 text-emerald-600' : ''
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FestivalBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
|
||||
const queryClient = useQueryClient();
|
||||
const isEdit = !!botId;
|
||||
|
||||
// 폼 상태
|
||||
const [name, setName] = useState('');
|
||||
const [searchUrl, setSearchUrl] = useState('');
|
||||
const [interval, setInterval] = useState(360);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// 축제 봇 상세 조회 (수정 모드)
|
||||
const { data: bot, isLoading: botLoading } = useQuery({
|
||||
queryKey: ['admin', 'festival-bot', botId],
|
||||
queryFn: () => getFestivalBot(botId),
|
||||
enabled: isOpen && !!botId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// 다이얼로그 열릴 때 데이터 설정
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (bot) {
|
||||
// 수정 모드
|
||||
setName(bot.name || '');
|
||||
setSearchUrl(bot.search_url || '');
|
||||
setInterval(bot.cron_interval || 360);
|
||||
} else if (!botId) {
|
||||
// 추가 모드
|
||||
setName('');
|
||||
setSearchUrl('');
|
||||
setInterval(360);
|
||||
}
|
||||
}, [isOpen, bot, botId]);
|
||||
|
||||
// 제출
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !searchUrl.trim()) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
search_url: searchUrl.trim(),
|
||||
cron_interval: interval,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateFestivalBot(botId, data);
|
||||
} else {
|
||||
await createFestivalBot(data);
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'festival-bot'] });
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('봇 저장 실패:', error);
|
||||
alert(error.message || '봇 저장에 실패했습니다.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white rounded-2xl w-full max-w-lg mx-4 shadow-xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-emerald-50 flex items-center justify-center">
|
||||
<PartyPopper size={20} className="text-emerald-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-gray-900">
|
||||
{isEdit ? '축제 봇 수정' : '축제 봇 추가'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
{botLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center p-12">
|
||||
<Loader2 size={32} className="animate-spin text-emerald-500" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-5">
|
||||
{/* 봇 이름 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
봇 이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="대학 축제 봇"
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 검색 URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
크롤링 URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={searchUrl}
|
||||
onChange={(e) => setSearchUrl(e.target.value)}
|
||||
placeholder="https://memogipost.tistory.com/search/프로미스나인"
|
||||
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
축제 정보를 수집할 검색 페이지 URL을 입력하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 동기화 간격 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
동기화 간격
|
||||
</label>
|
||||
<Dropdown
|
||||
value={interval}
|
||||
options={INTERVAL_OPTIONS}
|
||||
onChange={setInterval}
|
||||
placeholder="간격 선택"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || !searchUrl.trim() || submitting || botLoading}
|
||||
className="px-4 py-2.5 bg-emerald-500 text-white rounded-lg hover:bg-emerald-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{submitting && <Loader2 size={16} className="animate-spin" />}
|
||||
{isEdit ? '수정' : '추가'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
export default FestivalBotDialog;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { default as BotCard, XIcon, MeilisearchIcon, BotListItem, BotMiniCard, BotTableRow, BotTable } from './BotCard';
|
||||
export { default as YouTubeBotDialog } from './YouTubeBotDialog';
|
||||
export { default as XBotDialog } from './XBotDialog';
|
||||
export { default as FestivalBotDialog } from './FestivalBotDialog';
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@ import { useState, useEffect, useMemo } from 'react';
|
|||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube } from 'lucide-react';
|
||||
import { Home, ChevronRight, Bot, CheckCircle, XCircle, RefreshCw, Plus, Youtube, PartyPopper } from 'lucide-react';
|
||||
import { Toast, Tooltip, AnimatedNumber } from '@/components/common';
|
||||
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog } from '@/components/pc/admin';
|
||||
import { AdminLayout, XIcon, MeilisearchIcon, BotTableRow, BotTable, YouTubeBotDialog, XBotDialog, FestivalBotDialog } from '@/components/pc/admin';
|
||||
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||
import { useToast } from '@/hooks/common';
|
||||
import * as botsApi from '@/api/admin/bots';
|
||||
|
|
@ -34,6 +34,14 @@ const SECTIONS = {
|
|||
borderColor: 'border-gray-200',
|
||||
canAdd: true,
|
||||
},
|
||||
festival: {
|
||||
title: '축제',
|
||||
icon: PartyPopper,
|
||||
color: 'text-emerald-500',
|
||||
bgColor: 'bg-emerald-50',
|
||||
borderColor: 'border-emerald-100',
|
||||
canAdd: true,
|
||||
},
|
||||
};
|
||||
|
||||
// 애니메이션 variants
|
||||
|
|
@ -63,6 +71,7 @@ function ScheduleBots() {
|
|||
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||
const [youtubeDialogOpen, setYoutubeDialogOpen] = useState(false); // YouTube 봇 다이얼로그
|
||||
const [xDialogOpen, setXDialogOpen] = useState(false); // X 봇 다이얼로그
|
||||
const [festivalDialogOpen, setFestivalDialogOpen] = useState(false); // 축제 봇 다이얼로그
|
||||
const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID
|
||||
const [editingBotType, setEditingBotType] = useState(null); // 수정 중인 봇 타입
|
||||
const [deletingBot, setDeletingBot] = useState(null); // 삭제할 봇
|
||||
|
|
@ -168,6 +177,8 @@ function ScheduleBots() {
|
|||
await botsApi.deleteYouTubeBot(deletingBot.db_id);
|
||||
} else if (deletingBot.type === 'x') {
|
||||
await botsApi.deleteXBot(deletingBot.db_id);
|
||||
} else if (deletingBot.type === 'festival') {
|
||||
await botsApi.deleteFestivalBot(deletingBot.db_id);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] });
|
||||
setToast({ type: 'success', message: `${deletingBot.name} 봇이 삭제되었습니다.` });
|
||||
|
|
@ -249,7 +260,7 @@ function ScheduleBots() {
|
|||
|
||||
// 봇을 타입별로 그룹화
|
||||
const botsByType = useMemo(() => {
|
||||
const grouped = { meilisearch: [], youtube: [], x: [] };
|
||||
const grouped = { meilisearch: [], youtube: [], x: [], festival: [] };
|
||||
bots.forEach((bot) => {
|
||||
if (grouped[bot.type]) {
|
||||
grouped[bot.type].push(bot);
|
||||
|
|
@ -285,6 +296,18 @@ function ScheduleBots() {
|
|||
setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
|
||||
}}
|
||||
/>
|
||||
<FestivalBotDialog
|
||||
isOpen={festivalDialogOpen}
|
||||
onClose={() => {
|
||||
setFestivalDialogOpen(false);
|
||||
setEditingBotId(null);
|
||||
setEditingBotType(null);
|
||||
}}
|
||||
botId={editingBotId}
|
||||
onSuccess={() => {
|
||||
setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' });
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AnimatePresence>
|
||||
|
|
@ -457,6 +480,8 @@ function ScheduleBots() {
|
|||
setYoutubeDialogOpen(true);
|
||||
} else if (type === 'x') {
|
||||
setXDialogOpen(true);
|
||||
} else if (type === 'festival') {
|
||||
setFestivalDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white border border-gray-200 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
|
|
@ -508,6 +533,8 @@ function ScheduleBots() {
|
|||
setYoutubeDialogOpen(true);
|
||||
} else if (bot.type === 'x') {
|
||||
setXDialogOpen(true);
|
||||
} else if (bot.type === 'festival') {
|
||||
setFestivalDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
onDelete={(bot) => setDeletingBot(bot)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue