diff --git a/frontend/src/api/admin/index.js b/frontend/src/api/admin/index.js index ca980c8..8f7cacf 100644 --- a/frontend/src/api/admin/index.js +++ b/frontend/src/api/admin/index.js @@ -8,6 +8,7 @@ export * as adminCategoryApi from './categories'; export * as adminBotApi from './bots'; export * as adminStatsApi from './stats'; export * as adminSuggestionApi from './suggestions'; +export * as adminLogApi from './logs'; export * as adminAuthApi from './auth'; // 개별 함수 export diff --git a/frontend/src/api/admin/logs.js b/frontend/src/api/admin/logs.js new file mode 100644 index 0000000..f1d78d1 --- /dev/null +++ b/frontend/src/api/admin/logs.js @@ -0,0 +1,27 @@ +/** + * 관리자 활동 로그 API + */ +import { fetchAuthApi } from '@/api/client'; + +/** + * 활동 로그 목록 조회 + * @param {object} params - 쿼리 파라미터 + * @param {number} [params.page] - 페이지 번호 + * @param {number} [params.limit] - 페이지당 개수 + * @param {string} [params.category] - 카테고리 필터 (콤마 구분) + * @param {string} [params.actor] - 행위자 필터 (admin 또는 bot) + * @param {string} [params.search] - summary 검색 + * @param {string} [params.from] - 시작 날짜 (YYYY-MM-DD) + * @param {string} [params.to] - 종료 날짜 (YYYY-MM-DD) + * @returns {Promise<{logs: Array, total: number, page: number, limit: number, totalPages: number}>} + */ +export async function getLogs(params = {}) { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== '') { + query.set(key, value); + } + } + const qs = query.toString(); + return fetchAuthApi(`/admin/logs${qs ? `?${qs}` : ''}`); +} diff --git a/frontend/src/pages/pc/admin/logs/Logs.jsx b/frontend/src/pages/pc/admin/logs/Logs.jsx index 2bcf395..af0cca3 100644 --- a/frontend/src/pages/pc/admin/logs/Logs.jsx +++ b/frontend/src/pages/pc/admin/logs/Logs.jsx @@ -1,44 +1,17 @@ /** * 관리자 활동 로그 페이지 */ -import { useState, useMemo } from 'react'; +import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; +import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; import { Home, ChevronRight, Search, ChevronLeft, ChevronDown, - User, Bot, ScrollText, X, + User, Bot, ScrollText, X, Loader2, } from 'lucide-react'; import { AdminLayout, DatePicker } from '@/components/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin'; - -// 더미 데이터 -const DUMMY_LOGS = [ - { id: 1, actor: 'admin', action: 'create', category: 'album', target_type: 'album', target_id: 12, summary: '앨범 생성: Unlock My World', details: null, created_at: '2026-03-02T14:30:00' }, - { id: 2, actor: 'admin', action: 'upload', category: 'album', target_type: 'photo', target_id: 45, summary: '사진 업로드: Unlock My World (3장)', details: { count: 3 }, created_at: '2026-03-02T14:25:00' }, - { id: 3, actor: 'youtube-3', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 3, summary: '동기화 완료: 스프 채널 (2개 추가)', details: { addedCount: 2, channelName: '스프' }, created_at: '2026-03-02T14:20:00' }, - { id: 4, actor: 'admin', action: 'create', category: 'schedule', target_type: 'youtube_schedule', target_id: 789, summary: 'YouTube 일정 생성: fromis_9 컴백 티저', details: { videoId: 'abc123' }, created_at: '2026-03-02T14:15:00' }, - { id: 5, actor: 'admin', action: 'update', category: 'member', target_type: 'member', target_id: 1, summary: '멤버 수정: 이서연 프로필 업데이트', details: null, created_at: '2026-03-02T14:10:00' }, - { id: 6, actor: 'x-1', action: 'sync_complete', category: 'sync', target_type: 'x_bot', target_id: 1, summary: '동기화 완료: fromis_9 공식 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T14:05:00' }, - { id: 7, actor: 'admin', action: 'delete', category: 'schedule', target_type: 'youtube_schedule', target_id: 456, summary: 'YouTube 일정 삭제: 이전 영상', details: null, created_at: '2026-03-02T14:00:00' }, - { id: 8, actor: 'admin', action: 'create', category: 'concert', target_type: 'concert', target_id: 5, summary: '콘서트 일정 생성: fromis_9 팬미팅', details: null, created_at: '2026-03-02T13:55:00' }, - { id: 9, actor: 'admin', action: 'update', category: 'category', target_type: 'category', target_id: 3, summary: '카테고리 수정: 음악방송', details: null, created_at: '2026-03-02T13:50:00' }, - { id: 10, actor: 'admin', action: 'update', category: 'category', target_type: 'category', target_id: null, summary: '카테고리 순서 변경', details: null, created_at: '2026-03-02T13:45:00' }, - { id: 11, actor: 'youtube-1', action: 'error', category: 'sync', target_type: 'youtube_bot', target_id: 1, summary: '동기화 에러: API 할당량 초과', details: { error: 'quotaExceeded' }, created_at: '2026-03-02T13:40:00' }, - { id: 12, actor: 'admin', action: 'start', category: 'bot', target_type: 'youtube_bot', target_id: 3, summary: 'YouTube 봇 시작: 스프', details: null, created_at: '2026-03-02T13:35:00' }, - { id: 13, actor: 'admin', action: 'stop', category: 'bot', target_type: 'youtube_bot', target_id: 2, summary: 'YouTube 봇 정지: 채널 비활성화', details: null, created_at: '2026-03-02T13:30:00' }, - { id: 14, actor: 'admin', action: 'create', category: 'dict', target_type: 'dict', target_id: 10, summary: '사전 저장: fromis_9 → 프로미스나인', details: null, created_at: '2026-03-02T13:25:00' }, - { id: 15, actor: 'admin', action: 'delete', category: 'album', target_type: 'teaser', target_id: 8, summary: '티저 삭제: Unlock My World 티저 1', details: null, created_at: '2026-03-02T13:20:00' }, - { id: 16, actor: 'admin', action: 'create', category: 'schedule', target_type: 'x_schedule', target_id: 100, summary: 'X 일정 생성: fromis_9 공식 트윗', details: null, created_at: '2026-03-02T13:15:00' }, - { id: 17, actor: 'youtube-3', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 3, summary: '동기화 완료: 스프 채널 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T13:10:00' }, - { id: 18, actor: 'admin', action: 'update', category: 'schedule', target_type: 'youtube_schedule', target_id: 780, summary: 'YouTube 일정 수정: 제목 변경', details: null, created_at: '2026-03-02T13:05:00' }, - { id: 19, actor: 'admin', action: 'create', category: 'bot', target_type: 'youtube_bot', target_id: 4, summary: 'YouTube 봇 생성: 새 채널', details: null, created_at: '2026-03-02T13:00:00' }, - { id: 20, actor: 'admin', action: 'delete', category: 'bot', target_type: 'x_bot', target_id: 2, summary: 'X 봇 삭제: 비활성 계정', details: null, created_at: '2026-03-02T12:55:00' }, - { id: 21, actor: 'x-1', action: 'sync_complete', category: 'sync', target_type: 'x_bot', target_id: 1, summary: '동기화 완료: fromis_9 공식 (3개 추가)', details: { addedCount: 3 }, created_at: '2026-03-02T12:50:00' }, - { id: 22, actor: 'admin', action: 'upload', category: 'album', target_type: 'photo', target_id: 50, summary: '사진 업로드: My Little Society (5장)', details: { count: 5 }, created_at: '2026-03-02T12:45:00' }, - { id: 23, actor: 'admin', action: 'update', category: 'album', target_type: 'album', target_id: 5, summary: '앨범 수정: My Little Society 정보 변경', details: null, created_at: '2026-03-02T12:40:00' }, - { id: 24, actor: 'youtube-1', action: 'sync_complete', category: 'sync', target_type: 'youtube_bot', target_id: 1, summary: '동기화 완료: 공식 채널 (1개 추가)', details: { addedCount: 1 }, created_at: '2026-03-02T12:35:00' }, - { id: 25, actor: 'admin', action: 'create', category: 'schedule', target_type: 'youtube_schedule', target_id: 791, summary: 'YouTube 일정 생성: 연습 영상', details: null, created_at: '2026-03-02T12:30:00' }, -]; +import { adminLogApi } from '@/api/admin'; // 카테고리 목록 const CATEGORIES = [ @@ -90,6 +63,32 @@ function Logs() { const [currentPage, setCurrentPage] = useState(1); const [actorDropdownOpen, setActorDropdownOpen] = useState(false); + // 검색어 디바운스 + const [debouncedSearch, setDebouncedSearch] = useState(''); + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearch(searchQuery), 300); + return () => clearTimeout(timer); + }, [searchQuery]); + + // API 호출 + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'logs', { page: currentPage, category: selectedCategories.join(','), actor: actorFilter === 'all' ? '' : actorFilter, search: debouncedSearch, from: dateFrom, to: dateTo }], + queryFn: () => adminLogApi.getLogs({ + page: currentPage, + limit: ITEMS_PER_PAGE, + category: selectedCategories.join(',') || undefined, + actor: actorFilter === 'all' ? undefined : actorFilter, + search: debouncedSearch || undefined, + from: dateFrom || undefined, + to: dateTo || undefined, + }), + placeholderData: keepPreviousData, + }); + + const logs = data?.logs || []; + const total = data?.total || 0; + const totalPages = data?.totalPages || 0; + // 카테고리 토글 const toggleCategory = (cat) => { setSelectedCategories((prev) => @@ -98,40 +97,6 @@ function Logs() { setCurrentPage(1); }; - // 필터링된 로그 - const filteredLogs = useMemo(() => { - return DUMMY_LOGS.filter((log) => { - // 카테고리 필터 - if (selectedCategories.length > 0 && !selectedCategories.includes(log.category)) { - return false; - } - // 행위자 필터 - if (actorFilter === 'admin' && log.actor !== 'admin') return false; - if (actorFilter === 'bot' && log.actor === 'admin') return false; - // 텍스트 검색 - if (searchQuery && !log.summary.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; - } - // 날짜 필터 - if (dateFrom) { - const logDate = log.created_at.split('T')[0]; - if (logDate < dateFrom) return false; - } - if (dateTo) { - const logDate = log.created_at.split('T')[0]; - if (logDate > dateTo) return false; - } - return true; - }); - }, [searchQuery, selectedCategories, actorFilter, dateFrom, dateTo]); - - // 페이지네이션 - const totalPages = Math.ceil(filteredLogs.length / ITEMS_PER_PAGE); - const paginatedLogs = filteredLogs.slice( - (currentPage - 1) * ITEMS_PER_PAGE, - currentPage * ITEMS_PER_PAGE - ); - // 날짜/시간 포맷 const formatDateTime = (dateStr) => { const date = new Date(dateStr); @@ -303,7 +268,7 @@ function Logs() { {/* 결과 개수 */}

- 총 {filteredLogs.length}개의 로그 + 총 {total}개의 로그

@@ -325,7 +290,7 @@ function Logs() { - {paginatedLogs.map((log, index) => ( + {logs.map((log, index) => ( - {paginatedLogs.length === 0 && ( + {isLoading && logs.length === 0 && ( +
+ +

로그를 불러오는 중...

+
+ )} + + {!isLoading && logs.length === 0 && (

@@ -377,19 +349,39 @@ function Logs() { > - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - ))} + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((page) => { + // 페이지가 많을 때 현재 페이지 주변만 표시 + if (totalPages <= 7) return true; + if (page === 1 || page === totalPages) return true; + if (Math.abs(page - currentPage) <= 2) return true; + return false; + }) + .reduce((acc, page, i, arr) => { + // 생략 부호(...) 추가 + if (i > 0 && page - arr[i - 1] > 1) { + acc.push({ type: 'ellipsis', key: `e-${page}` }); + } + acc.push({ type: 'page', value: page, key: page }); + return acc; + }, []) + .map((item) => + item.type === 'ellipsis' ? ( + ... + ) : ( + + ) + )}