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)} />
);
}