From ec3839bcc73ccca099b78028fbc8eb8a60a68da4 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 7 Feb 2026 10:43:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20YouTube=20=EB=B4=87=20CRUD=20API?= =?UTF-8?q?=20=EB=B0=8F=20=EC=88=98=EC=A0=95=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YouTube 봇 전용 API 라우트 추가 (GET/POST/PUT/DELETE /api/admin/youtube-bots) - 봇 목록 API에 YouTube 봇 상세 정보 포함 (db_id, channel_id 등) - 수정 다이얼로그에서 useQuery로 봇 데이터 조회 - 채널 배너 이미지 표시 추가 - Fastify 스키마에 additionalProperties 설정으로 auto_schedule_config 정상 반환 Co-Authored-By: Claude --- backend/src/plugins/scheduler.js | 2 +- backend/src/routes/admin/bots.js | 35 +- backend/src/routes/admin/youtube-bots.js | 358 ++++++++++++++++++ backend/src/routes/index.js | 4 + frontend/src/api/admin/bots.js | 43 +++ .../pc/admin/bot/YouTubeBotDialog.jsx | 151 ++++++-- .../pages/pc/admin/schedules/ScheduleBots.jsx | 19 +- 7 files changed, 571 insertions(+), 41 deletions(-) create mode 100644 backend/src/routes/admin/youtube-bots.js diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index 8945b22..ae25c83 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -239,7 +239,7 @@ async function schedulerPlugin(fastify, opts) { startAll, stopAll, getStatus, - getBots: () => getAllBots(), + getBots: (forceRefresh = false) => getAllBots(forceRefresh), invalidateCache, }); diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js index 6202f2a..1a8429d 100644 --- a/backend/src/routes/admin/bots.js +++ b/backend/src/routes/admin/bots.js @@ -18,6 +18,17 @@ const botResponse = { check_interval: { type: 'integer' }, error_message: { type: 'string' }, enabled: { type: 'boolean' }, + // YouTube 봇 전용 필드 + db_id: { type: 'integer' }, + channel_id: { type: 'string' }, + channel_handle: { type: 'string' }, + channel_name: { type: 'string' }, + banner_url: { type: 'string' }, + cron_interval: { type: 'integer' }, + title_filters: { type: 'array', items: { type: 'string' } }, + default_member_ids: { type: 'array', items: { type: 'integer' } }, + extract_members_from_desc: { type: 'boolean' }, + auto_schedule_config: { type: ['object', 'null'], additionalProperties: true }, }, }; @@ -56,7 +67,8 @@ export default async function botsRoutes(fastify) { }, preHandler: [fastify.authenticate], }, async (request, reply) => { - const allBots = await scheduler.getBots(); + // API 호출 시에는 항상 fresh한 데이터 반환 + const allBots = await scheduler.getBots(true); const result = []; for (const bot of allBots) { @@ -72,7 +84,7 @@ export default async function botsRoutes(fastify) { checkInterval = 1440; // 24시간 = 1440분 } - result.push({ + const botData = { id: bot.id, name: bot.name || bot.channelName || bot.username || bot.id, type: bot.type, @@ -84,7 +96,24 @@ export default async function botsRoutes(fastify) { check_interval: checkInterval, error_message: status.errorMessage, enabled: bot.enabled, - }); + }; + + // YouTube 봇인 경우 상세 정보 추가 + if (bot.type === 'youtube') { + fastify.log.info(`YouTube bot dbId: ${bot.dbId}`); + botData.db_id = bot.dbId; + botData.channel_id = bot.channelId; + botData.channel_handle = bot.channelHandle; + botData.channel_name = bot.channelName; + botData.banner_url = bot.bannerUrl; + botData.cron_interval = checkInterval; + botData.title_filters = bot.titleFilters || []; + botData.default_member_ids = bot.defaultMemberIds || []; + botData.extract_members_from_desc = bot.extractMembersFromDesc || false; + botData.auto_schedule_config = bot.autoScheduleNext || null; + } + + result.push(botData); } return result; diff --git a/backend/src/routes/admin/youtube-bots.js b/backend/src/routes/admin/youtube-bots.js new file mode 100644 index 0000000..4544761 --- /dev/null +++ b/backend/src/routes/admin/youtube-bots.js @@ -0,0 +1,358 @@ +import { errorResponse } from '../../schemas/index.js'; +import { badRequest, notFound, serverError } from '../../utils/error.js'; + +/** + * YouTube 봇 스키마 + */ +const youtubeBotResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + channel_id: { type: 'string' }, + channel_handle: { type: 'string' }, + channel_name: { type: 'string' }, + banner_url: { type: 'string' }, + cron_interval: { type: 'integer' }, + enabled: { type: 'boolean' }, + title_filters: { type: 'array', items: { type: 'string' } }, + default_member_ids: { type: 'array', items: { type: 'integer' } }, + extract_members_from_desc: { type: 'boolean' }, + auto_schedule_config: { type: ['object', 'null'], additionalProperties: true }, + }, +}; + +const youtubeBotIdParam = { + type: 'object', + properties: { + id: { type: 'integer', description: 'YouTube 봇 DB ID' }, + }, + required: ['id'], +}; + +/** + * DB row를 API 응답 형식으로 변환 + */ +function formatBotResponse(row, fastify) { + if (fastify) { + fastify.log.info(`DB row.auto_schedule_config: ${JSON.stringify(row.auto_schedule_config)}, type: ${typeof row.auto_schedule_config}`); + } + return { + id: row.id, + channel_id: row.channel_id, + channel_handle: row.channel_handle, + channel_name: row.channel_name, + banner_url: row.banner_url, + cron_interval: row.cron_interval, + enabled: row.enabled === 1, + title_filters: row.title_filters + ? (typeof row.title_filters === 'string' + ? JSON.parse(row.title_filters) + : row.title_filters) + : [], + default_member_ids: row.default_member_ids + ? (typeof row.default_member_ids === 'string' + ? JSON.parse(row.default_member_ids) + : row.default_member_ids) + : [], + extract_members_from_desc: row.extract_members_from_desc === 1, + auto_schedule_config: row.auto_schedule_config + ? (typeof row.auto_schedule_config === 'string' + ? JSON.parse(row.auto_schedule_config) + : row.auto_schedule_config) + : null, + }; +} + +/** + * YouTube 봇 관리 라우트 + */ +export default async function youtubeBotsRoutes(fastify) { + const { db, scheduler } = fastify; + + /** + * GET /api/admin/youtube-bots + * YouTube 봇 목록 조회 + */ + fastify.get('/', { + schema: { + tags: ['admin/youtube-bots'], + summary: 'YouTube 봇 목록 조회', + security: [{ bearerAuth: [] }], + response: { + 200: { + type: 'array', + items: youtubeBotResponse, + }, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const [rows] = await db.query('SELECT * FROM youtube_bots ORDER BY id'); + return rows.map(formatBotResponse); + }); + + /** + * GET /api/admin/youtube-bots/:id + * YouTube 봇 상세 조회 + */ + fastify.get('/:id', { + schema: { + tags: ['admin/youtube-bots'], + summary: 'YouTube 봇 상세 조회', + security: [{ bearerAuth: [] }], + params: youtubeBotIdParam, + response: { + 200: youtubeBotResponse, + 404: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const [rows] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]); + + if (rows.length === 0) { + return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.'); + } + + return formatBotResponse(rows[0], fastify); + }); + + /** + * POST /api/admin/youtube-bots + * YouTube 봇 추가 + */ + fastify.post('/', { + schema: { + tags: ['admin/youtube-bots'], + summary: 'YouTube 봇 추가', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + properties: { + channel_id: { type: 'string' }, + channel_handle: { type: 'string' }, + channel_name: { type: 'string' }, + banner_url: { type: 'string' }, + cron_interval: { type: 'integer', default: 2 }, + title_filters: { type: 'array', items: { type: 'string' } }, + default_member_ids: { type: 'array', items: { type: 'integer' } }, + extract_members_from_desc: { type: 'boolean', default: false }, + auto_schedule_config: { type: 'object' }, + }, + required: ['channel_id', 'channel_name'], + }, + response: { + 201: youtubeBotResponse, + 400: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { + channel_id, + channel_handle, + channel_name, + banner_url, + cron_interval = 2, + title_filters, + default_member_ids, + extract_members_from_desc = false, + auto_schedule_config, + } = request.body; + + // 중복 체크 + const [existing] = await db.query( + 'SELECT id FROM youtube_bots WHERE channel_id = ?', + [channel_id] + ); + if (existing.length > 0) { + return badRequest(reply, '이미 등록된 채널입니다.'); + } + + const [result] = await db.query( + `INSERT INTO youtube_bots + (channel_id, channel_handle, channel_name, banner_url, cron_interval, + title_filters, default_member_ids, extract_members_from_desc, auto_schedule_config, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`, + [ + channel_id, + channel_handle || null, + channel_name, + banner_url || null, + cron_interval, + title_filters ? JSON.stringify(title_filters) : null, + default_member_ids ? JSON.stringify(default_member_ids) : null, + extract_members_from_desc ? 1 : 0, + auto_schedule_config ? JSON.stringify(auto_schedule_config) : null, + ] + ); + + // 스케줄러 캐시 무효화 및 봇 시작 + scheduler.invalidateCache(); + const botId = `youtube-${result.insertId}`; + try { + await scheduler.startBot(botId); + } catch (err) { + fastify.log.error(`[${botId}] 봇 시작 실패:`, err); + } + + const [newBot] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [result.insertId]); + reply.code(201); + return formatBotResponse(newBot[0]); + }); + + /** + * PUT /api/admin/youtube-bots/:id + * YouTube 봇 수정 + */ + fastify.put('/:id', { + schema: { + tags: ['admin/youtube-bots'], + summary: 'YouTube 봇 수정', + security: [{ bearerAuth: [] }], + params: youtubeBotIdParam, + body: { + type: 'object', + properties: { + channel_handle: { type: 'string' }, + channel_name: { type: 'string' }, + banner_url: { type: 'string' }, + cron_interval: { type: 'integer' }, + title_filters: { type: 'array', items: { type: 'string' } }, + default_member_ids: { type: 'array', items: { type: 'integer' } }, + extract_members_from_desc: { type: 'boolean' }, + auto_schedule_config: { type: 'object' }, + enabled: { type: 'boolean' }, + }, + }, + response: { + 200: youtubeBotResponse, + 404: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const updates = request.body; + + // 존재 확인 + const [existing] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]); + if (existing.length === 0) { + return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.'); + } + + // 동적 업데이트 쿼리 생성 + const fields = []; + const values = []; + + if (updates.channel_handle !== undefined) { + fields.push('channel_handle = ?'); + values.push(updates.channel_handle); + } + if (updates.channel_name !== undefined) { + fields.push('channel_name = ?'); + values.push(updates.channel_name); + } + if (updates.banner_url !== undefined) { + fields.push('banner_url = ?'); + values.push(updates.banner_url); + } + if (updates.cron_interval !== undefined) { + fields.push('cron_interval = ?'); + values.push(updates.cron_interval); + } + if (updates.title_filters !== undefined) { + fields.push('title_filters = ?'); + values.push(JSON.stringify(updates.title_filters)); + } + if (updates.default_member_ids !== undefined) { + fields.push('default_member_ids = ?'); + values.push(JSON.stringify(updates.default_member_ids)); + } + if (updates.extract_members_from_desc !== undefined) { + fields.push('extract_members_from_desc = ?'); + values.push(updates.extract_members_from_desc ? 1 : 0); + } + if (updates.auto_schedule_config !== undefined) { + fields.push('auto_schedule_config = ?'); + values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null); + } + if (updates.enabled !== undefined) { + fields.push('enabled = ?'); + values.push(updates.enabled ? 1 : 0); + } + + if (fields.length > 0) { + values.push(id); + await db.query( + `UPDATE youtube_bots SET ${fields.join(', ')} WHERE id = ?`, + values + ); + + // 스케줄러 캐시 무효화 및 봇 재시작 + scheduler.invalidateCache(); + const botId = `youtube-${id}`; + try { + await scheduler.stopBot(botId); + if (updates.enabled !== false && existing[0].enabled === 1) { + await scheduler.startBot(botId); + } else if (updates.enabled === true) { + await scheduler.startBot(botId); + } + } catch (err) { + fastify.log.error(`[${botId}] 봇 재시작 실패:`, err); + } + } + + const [updatedBot] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]); + return formatBotResponse(updatedBot[0]); + }); + + /** + * DELETE /api/admin/youtube-bots/:id + * YouTube 봇 삭제 + */ + fastify.delete('/:id', { + schema: { + tags: ['admin/youtube-bots'], + summary: 'YouTube 봇 삭제', + security: [{ bearerAuth: [] }], + params: youtubeBotIdParam, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + }, + }, + 404: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + // 존재 확인 + const [existing] = await db.query('SELECT * FROM youtube_bots WHERE id = ?', [id]); + if (existing.length === 0) { + return notFound(reply, 'YouTube 봇을 찾을 수 없습니다.'); + } + + // 봇 정지 + const botId = `youtube-${id}`; + try { + await scheduler.stopBot(botId); + } catch (err) { + // 이미 정지된 경우 무시 + } + + // DB에서 삭제 + await db.query('DELETE FROM youtube_bots WHERE id = ?', [id]); + + // 스케줄러 캐시 무효화 + scheduler.invalidateCache(); + + return { success: true }; + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 85dca86..a701184 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -4,6 +4,7 @@ import albumsRoutes from './albums/index.js'; import schedulesRoutes from './schedules/index.js'; import statsRoutes from './stats/index.js'; import botsRoutes from './admin/bots.js'; +import youtubeBotsRoutes from './admin/youtube-bots.js'; import youtubeAdminRoutes from './admin/youtube.js'; import xAdminRoutes from './admin/x.js'; import concertAdminRoutes from './admin/concert.js'; @@ -32,6 +33,9 @@ export default async function routes(fastify) { // 관리자 - 봇 라우트 fastify.register(botsRoutes, { prefix: '/admin/bots' }); + // 관리자 - YouTube 봇 라우트 + fastify.register(youtubeBotsRoutes, { prefix: '/admin/youtube-bots' }); + // 관리자 - YouTube 라우트 fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' }); diff --git a/frontend/src/api/admin/bots.js b/frontend/src/api/admin/bots.js index 1aff9cb..88dad5f 100644 --- a/frontend/src/api/admin/bots.js +++ b/frontend/src/api/admin/bots.js @@ -11,6 +11,49 @@ export async function getBots() { return fetchAuthApi('/admin/bots'); } +/** + * YouTube 봇 상세 조회 + * @param {number} id - YouTube 봇 DB ID + * @returns {Promise} + */ +export async function getYouTubeBot(id) { + return fetchAuthApi(`/admin/youtube-bots/${id}`); +} + +/** + * YouTube 봇 추가 + * @param {object} data - 봇 데이터 + * @returns {Promise} + */ +export async function createYouTubeBot(data) { + return fetchAuthApi('/admin/youtube-bots', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * YouTube 봇 수정 + * @param {number} id - YouTube 봇 DB ID + * @param {object} data - 업데이트할 데이터 + * @returns {Promise} + */ +export async function updateYouTubeBot(id, data) { + return fetchAuthApi(`/admin/youtube-bots/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +/** + * YouTube 봇 삭제 + * @param {number} id - YouTube 봇 DB ID + * @returns {Promise} + */ +export async function deleteYouTubeBot(id) { + return fetchAuthApi(`/admin/youtube-bots/${id}`, { method: 'DELETE' }); +} + /** * 봇 시작 * @param {string} id - 봇 ID diff --git a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx index 400b430..c94b4df 100644 --- a/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx +++ b/frontend/src/components/pc/admin/bot/YouTubeBotDialog.jsx @@ -3,9 +3,11 @@ */ 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 { Youtube, Search, X, ChevronDown, ChevronUp } from 'lucide-react'; +import { Youtube, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; import { getMembers } from '@/api/public/members'; +import { getYouTubeBot, createYouTubeBot, updateYouTubeBot } from '@/api/admin/bots'; // 동기화 간격 옵션 const INTERVAL_OPTIONS = [ @@ -252,14 +254,16 @@ function MultiSelect({ values = [], options, onChange, placeholder = '선택', c ); } -function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { - const isEdit = !!bot; +function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) { + const queryClient = useQueryClient(); + const isEdit = !!botId; // 폼 상태 const [handle, setHandle] = useState(''); const [channelInfo, setChannelInfo] = useState(null); const [lookupLoading, setLookupLoading] = useState(false); const [interval, setInterval] = useState(2); + const [submitting, setSubmitting] = useState(false); // 예정 일정 설정 const [autoScheduleEnabled, setAutoScheduleEnabled] = useState(false); @@ -278,6 +282,14 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { // 멤버 목록 (탈퇴 멤버 제외) const [members, setMembers] = useState([]); + // YouTube 봇 상세 조회 (수정 모드) + const { data: bot, isLoading: botLoading } = useQuery({ + queryKey: ['admin', 'youtube-bot', botId], + queryFn: () => getYouTubeBot(botId), + enabled: isOpen && !!botId, + staleTime: 0, // 항상 fresh 데이터 가져오기 + }); + // 멤버 목록 로드 useEffect(() => { if (isOpen) { @@ -287,36 +299,62 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { } }, [isOpen]); - // 수정 모드일 때 기존 데이터 로드 + // 다이얼로그 열릴 때 데이터 설정 (수정/추가 모드) useEffect(() => { + if (!isOpen) { + return; // 닫혀있으면 아무것도 안 함 + } + if (bot) { + // 수정 모드: 기존 데이터 로드 setHandle(bot.channel_handle || ''); setChannelInfo({ channelId: bot.channel_id, title: bot.channel_name, + bannerUrl: bot.banner_url, }); setInterval(bot.cron_interval || 2); - if (bot.auto_schedule_config) { - const config = typeof bot.auto_schedule_config === 'string' - ? JSON.parse(bot.auto_schedule_config) - : bot.auto_schedule_config; + console.log('bot.auto_schedule_config:', bot.auto_schedule_config); + console.log('typeof:', typeof bot.auto_schedule_config); + + const config = bot.auto_schedule_config + ? (typeof bot.auto_schedule_config === 'string' + ? JSON.parse(bot.auto_schedule_config) + : bot.auto_schedule_config) + : null; + + console.log('parsed config:', config); + + // config가 존재하고 dayOfWeek가 정의되어 있으면 활성화 + if (config && config.dayOfWeek !== undefined) { setAutoScheduleEnabled(true); - setScheduleDayOfWeek(config.dayOfWeek ?? 4); + setScheduleDayOfWeek(config.dayOfWeek); setScheduleTime(config.time?.slice(0, 5) || '18:00'); setTitleTemplate(config.titleTemplate || '{channelName} {episode}화'); setDeadlineDayOfWeek(config.deadlineDayOfWeek ?? 5); + } else { + setAutoScheduleEnabled(false); + setScheduleDayOfWeek(4); + setScheduleTime('18:00'); + setTitleTemplate('{channelName} {episode}화'); + setDeadlineDayOfWeek(5); } setTitleFilters(bot.title_filters || []); setDefaultMemberIds(bot.default_member_ids || []); setExtractMembers(bot.extract_members_from_desc || false); - } - }, [bot]); - // 다이얼로그 닫힐 때 초기화 - useEffect(() => { - if (!isOpen) { + // 고급 설정이 있으면 펼침 + if ((bot.title_filters && bot.title_filters.length > 0) || + (bot.default_member_ids && bot.default_member_ids.length > 0) || + bot.extract_members_from_desc) { + setShowAdvanced(true); + } else { + setShowAdvanced(false); + } + } else if (!botId) { + // 추가 모드: 초기값으로 리셋 setHandle(''); setChannelInfo(null); setInterval(2); @@ -331,7 +369,7 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { setDefaultMemberIds([]); setExtractMembers(false); } - }, [isOpen]); + }, [isOpen, bot, botId]); // 채널 조회 const handleLookup = async () => { @@ -349,10 +387,48 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { }; // 제출 - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); - // TODO: onSubmit 호출 - onClose(); + if (!channelInfo) return; + + setSubmitting(true); + try { + const data = { + channel_handle: handle || null, + channel_name: channelInfo.title, + cron_interval: interval, + title_filters: titleFilters.length > 0 ? titleFilters : null, + default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null, + extract_members_from_desc: extractMembers, + auto_schedule_config: autoScheduleEnabled + ? { + dayOfWeek: scheduleDayOfWeek, + time: `${scheduleTime}:00`, + titleTemplate, + deadlineDayOfWeek, + } + : null, + }; + + if (isEdit) { + await updateYouTubeBot(botId, data); + } else { + data.channel_id = channelInfo.channelId; + await createYouTubeBot(data); + } + + // 캐시 무효화 + queryClient.invalidateQueries({ queryKey: ['admin', 'bots'] }); + queryClient.invalidateQueries({ queryKey: ['admin', 'youtube-bot'] }); + + onSuccess?.(); + onClose(); + } catch (error) { + console.error('봇 저장 실패:', error); + alert(error.message || '봇 저장에 실패했습니다.'); + } finally { + setSubmitting(false); + } }; return createPortal( @@ -390,6 +466,11 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { {/* 본문 */} + {botLoading ? ( +
+ +
+ ) : (
{/* 채널 핸들 */}
@@ -426,13 +507,24 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) {
{/* 채널 정보 표시 */} {channelInfo && ( -
-
- -
-
-

{channelInfo.title}

-

{channelInfo.channelId}

+
+ {channelInfo.bannerUrl && ( +
+ 채널 배너 +
+ )} +
+
+ +
+
+

{channelInfo.title}

+

{channelInfo.channelId}

+
)} @@ -627,22 +719,25 @@ function YouTubeBotDialog({ isOpen, onClose, bot = null, onSubmit }) { )}
+ )} {/* 푸터 */}
diff --git a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx index d05a261..055bf5a 100644 --- a/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx +++ b/frontend/src/pages/pc/admin/schedules/ScheduleBots.jsx @@ -61,7 +61,7 @@ function ScheduleBots() { const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태 const [botDialogOpen, setBotDialogOpen] = useState(false); // 봇 추가/수정 다이얼로그 - const [editingBot, setEditingBot] = useState(null); // 수정 중인 봇 + const [editingBotId, setEditingBotId] = useState(null); // 수정 중인 봇 DB ID // 봇 목록 조회 const { @@ -73,7 +73,7 @@ function ScheduleBots() { queryKey: ['admin', 'bots'], queryFn: botsApi.getBots, enabled: isAuthenticated, - staleTime: 30000, + staleTime: 0, // 항상 fresh 데이터 }); // 할당량 경고 상태 조회 @@ -241,12 +241,11 @@ function ScheduleBots() { isOpen={botDialogOpen} onClose={() => { setBotDialogOpen(false); - setEditingBot(null); + setEditingBotId(null); }} - bot={editingBot} - onSubmit={(data) => { - // TODO: API 호출 - console.log('submit', data); + botId={editingBotId} + onSuccess={() => { + setToast({ type: 'success', message: editingBotId ? '봇이 수정되었습니다.' : '봇이 추가되었습니다.' }); }} /> @@ -369,7 +368,7 @@ function ScheduleBots() { {section.canAdd && (