feat(frontend): 활동 로그 API 연동 및 더미데이터 제거
더미데이터를 실제 API 호출(React Query)로 교체. 서버 사이드 필터링/페이지네이션, 검색 디바운스(300ms), keepPreviousData로 페이지 전환 시 깜빡임 방지, 페이지 수가 많을 때 생략 부호(...) 페이지네이션 추가. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
1f1d6987d1
commit
414b798914
3 changed files with 101 additions and 81 deletions
|
|
@ -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
|
||||
|
|
|
|||
27
frontend/src/api/admin/logs.js
Normal file
27
frontend/src/api/admin/logs.js
Normal file
|
|
@ -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}` : ''}`);
|
||||
}
|
||||
|
|
@ -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() {
|
|||
{/* 결과 개수 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
총 <span className="font-medium text-gray-900">{filteredLogs.length}</span>개의 로그
|
||||
총 <span className="font-medium text-gray-900">{total}</span>개의 로그
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -325,7 +290,7 @@ function Logs() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{paginatedLogs.map((log, index) => (
|
||||
{logs.map((log, index) => (
|
||||
<motion.tr
|
||||
key={log.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
|
|
@ -357,7 +322,14 @@ function Logs() {
|
|||
</tbody>
|
||||
</table>
|
||||
|
||||
{paginatedLogs.length === 0 && (
|
||||
{isLoading && logs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<Loader2 size={32} className="animate-spin mb-4" />
|
||||
<p className="text-sm">로그를 불러오는 중...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && logs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
|
||||
<ScrollText size={48} strokeWidth={1} className="mb-4" />
|
||||
<p className="text-sm">
|
||||
|
|
@ -377,19 +349,39 @@ function Logs() {
|
|||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
{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' ? (
|
||||
<span key={item.key} className="w-9 h-9 flex items-center justify-center text-sm text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => setCurrentPage(item.value)}
|
||||
className={`w-9 h-9 rounded-lg text-sm font-medium transition-colors ${
|
||||
currentPage === item.value
|
||||
? 'bg-primary text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{item.value}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue