diff --git a/docs/architecture.md b/docs/architecture.md index d7cea7d..7d69c6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -156,10 +156,13 @@ fromis_9/ │ │ │ │ │ ├── PhotoPreviewModal.jsx │ │ │ │ │ ├── PendingFileItem.jsx │ │ │ │ │ └── BulkEditPanel.jsx -│ │ │ │ └── bot/ -│ │ │ │ ├── BotCard.jsx -│ │ │ │ ├── YouTubeBotDialog.jsx -│ │ │ │ └── XBotDialog.jsx +│ │ │ │ ├── bot/ +│ │ │ │ │ ├── BotCard.jsx +│ │ │ │ │ ├── YouTubeBotDialog.jsx +│ │ │ │ │ └── XBotDialog.jsx +│ │ │ │ └── log/ +│ │ │ │ ├── constants.js +│ │ │ │ └── LogDetailDialog.jsx │ │ │ │ │ │ │ └── mobile/ # 모바일 컴포넌트 │ │ │ ├── layout/ diff --git a/frontend/src/components/pc/admin/index.js b/frontend/src/components/pc/admin/index.js index 47cf754..d74d5b3 100644 --- a/frontend/src/components/pc/admin/index.js +++ b/frontend/src/components/pc/admin/index.js @@ -12,3 +12,6 @@ export * from './album'; // 봇 관련 export * from './bot'; + +// 로그 관련 +export * from './log'; diff --git a/frontend/src/components/pc/admin/log/LogDetailDialog.jsx b/frontend/src/components/pc/admin/log/LogDetailDialog.jsx new file mode 100644 index 0000000..71f0502 --- /dev/null +++ b/frontend/src/components/pc/admin/log/LogDetailDialog.jsx @@ -0,0 +1,124 @@ +/** + * 로그 상세 다이얼로그 + */ +import { motion, AnimatePresence } from 'framer-motion'; +import { X, User, Bot } from 'lucide-react'; +import { ACTION_STYLES, ACTION_LABELS, CATEGORY_LABELS, parseSummary, formatDateTime, hasDetails } from './constants'; + +// 행위자 뱃지 +function ActorBadge({ actor }) { + if (actor === 'admin') { + return ( + + + 관리자 + + ); + } + return ( + + + {actor} + + ); +} + +// summary 렌더링 +function Summary({ summary }) { + const { prefix, detail } = parseSummary(summary); + return ( + <> + [{prefix}] + {detail && {detail}} + + ); +} + +export { ActorBadge, Summary }; + +export default function LogDetailDialog({ log, onClose }) { + return ( + + {log && ( +
+ + + {/* 헤더 */} +
+
+ + {ACTION_LABELS[log.action] || log.action} + + + {CATEGORY_LABELS[log.category] || log.category} + +
+ +
+ + {/* 본문 */} +
+ {/* 내용 */} +
+
내용
+
+ +
+
+ + {/* 행위자 + 시간 */} +
+
+
행위자
+ +
+
+
시간
+ {formatDateTime(log.created_at)} +
+
+ + {/* 대상 */} + {(log.target_type || log.target_id) && ( +
+
대상
+ + {log.target_type && {log.target_type}} + {log.target_id && #{log.target_id}} + +
+ )} + + {/* 상세 정보 */} + {hasDetails(log.details) && ( +
+
상세 정보
+
+                    {JSON.stringify(log.details, null, 2)}
+                  
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/pc/admin/log/constants.js b/frontend/src/components/pc/admin/log/constants.js new file mode 100644 index 0000000..1b377f4 --- /dev/null +++ b/frontend/src/components/pc/admin/log/constants.js @@ -0,0 +1,73 @@ +/** + * 활동 로그 상수 및 유틸리티 + */ + +// 카테고리 한글 라벨 매핑 +export const CATEGORY_LABELS = { + album: '앨범', + schedule: '일정', + member: '멤버', + bot: '봇', + category: '카테고리', + dict: '사전', + concert: '콘서트', + sync: '동기화', +}; + +// 액션 뱃지 색상 +export const ACTION_STYLES = { + create: 'bg-emerald-100 text-emerald-700', + upload: 'bg-emerald-100 text-emerald-700', + update: 'bg-blue-100 text-blue-700', + delete: 'bg-red-100 text-red-700', + sync_complete: 'bg-purple-100 text-purple-700', + error: 'bg-red-100 text-red-700', + start: 'bg-amber-100 text-amber-700', + stop: 'bg-amber-100 text-amber-700', +}; + +// 액션 한글 라벨 +export const ACTION_LABELS = { + create: '생성', + upload: '업로드', + update: '수정', + delete: '삭제', + sync_complete: '동기화', + error: '에러', + start: '시작', + stop: '정지', +}; + +export const ITEMS_PER_PAGE = 15; + +// HTML 엔티티 디코딩 +export function decodeHtml(str) { + if (!str) return ''; + const el = document.createElement('textarea'); + el.innerHTML = str; + return el.value; +} + +// summary를 prefix와 detail로 분리 +export function parseSummary(summary) { + const decoded = decodeHtml(summary); + const idx = decoded.indexOf(': '); + if (idx === -1) return { prefix: decoded, detail: '' }; + return { prefix: decoded.substring(0, idx), detail: decoded.substring(idx + 2) }; +} + +// 날짜/시간 포맷 +export function formatDateTime(dateStr) { + const date = new Date(dateStr); + const y = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${y}.${month}.${day} ${hours}:${minutes}`; +} + +// details가 유효한 데이터인지 확인 +export function hasDetails(details) { + return details && typeof details === 'object' && Object.keys(details).length > 0; +} diff --git a/frontend/src/components/pc/admin/log/index.js b/frontend/src/components/pc/admin/log/index.js new file mode 100644 index 0000000..1fc29d8 --- /dev/null +++ b/frontend/src/components/pc/admin/log/index.js @@ -0,0 +1,2 @@ +export * from './constants'; +export { default as LogDetailDialog, ActorBadge, Summary } from './LogDetailDialog'; diff --git a/frontend/src/pages/pc/admin/logs/Logs.jsx b/frontend/src/pages/pc/admin/logs/Logs.jsx index 7104c4f..27bacce 100644 --- a/frontend/src/pages/pc/admin/logs/Logs.jsx +++ b/frontend/src/pages/pc/admin/logs/Logs.jsx @@ -7,62 +7,29 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; import { Home, ChevronRight, Search, ChevronLeft, ChevronDown, - User, Bot, ScrollText, X, Loader2, Check, + X, Loader2, Check, ScrollText, } from 'lucide-react'; -import { AdminLayout, DatePicker } from '@/components/pc/admin'; +import { + AdminLayout, DatePicker, + CATEGORY_LABELS, ACTION_STYLES, ACTION_LABELS, ITEMS_PER_PAGE, formatDateTime, + LogDetailDialog, ActorBadge, Summary, +} from '@/components/pc/admin'; import { useAdminAuth } from '@/hooks/pc/admin'; import { adminLogApi } from '@/api/admin'; -// 카테고리 한글 라벨 매핑 -const CATEGORY_LABELS = { - album: '앨범', - schedule: '일정', - member: '멤버', - bot: '봇', - category: '카테고리', - dict: '사전', - concert: '콘서트', - sync: '동기화', -}; - -// 액션 뱃지 색상 -const ACTION_STYLES = { - create: 'bg-emerald-100 text-emerald-700', - upload: 'bg-emerald-100 text-emerald-700', - update: 'bg-blue-100 text-blue-700', - delete: 'bg-red-100 text-red-700', - sync_complete: 'bg-purple-100 text-purple-700', - error: 'bg-red-100 text-red-700', - start: 'bg-amber-100 text-amber-700', - stop: 'bg-amber-100 text-amber-700', -}; - -// 액션 한글 라벨 -const ACTION_LABELS = { - create: '생성', - upload: '업로드', - update: '수정', - delete: '삭제', - sync_complete: '동기화', - error: '에러', - start: '시작', - stop: '정지', -}; - -const ITEMS_PER_PAGE = 15; - function Logs() { const { user } = useAdminAuth(); // 필터 상태 const [searchQuery, setSearchQuery] = useState(''); const [selectedCategories, setSelectedCategories] = useState([]); - const [actorFilter, setActorFilter] = useState('all'); // all, admin, bot + const [actorFilter, setActorFilter] = useState('all'); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [currentPage, setCurrentPage] = useState(1); const [actorDropdownOpen, setActorDropdownOpen] = useState(false); const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false); + const [selectedLog, setSelectedLog] = useState(null); // 검색어 디바운스 const [debouncedSearch, setDebouncedSearch] = useState(''); @@ -113,35 +80,6 @@ function Logs() { return `카테고리 (${selectedCategories.length})`; }; - // 날짜/시간 포맷 - const formatDateTime = (dateStr) => { - const date = new Date(dateStr); - const y = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${y}.${month}.${day} ${hours}:${minutes}`; - }; - - // 행위자 아이콘 - const renderActorBadge = (actor) => { - if (actor === 'admin') { - return ( - - - 관리자 - - ); - } - return ( - - - {actor} - - ); - }; - // 필터 초기화 const clearFilters = () => { setSearchQuery(''); @@ -361,7 +299,7 @@ function Logs() { {formatDateTime(log.created_at)} - {renderActorBadge(log.actor)} + @@ -374,7 +312,12 @@ function Logs() { - {log.summary} +
setSelectedLog(log)} + className="truncate cursor-pointer hover:text-gray-900 transition-colors" + > + +
))} @@ -449,6 +392,9 @@ function Logs() { )} + + {/* 로그 상세 다이얼로그 */} + setSelectedLog(null)} /> ); }