refactor(admin): 활동 로그 컴포넌트 분리 및 빈 상세정보 처리
Logs.jsx에서 상수/유틸과 다이얼로그를 components/pc/admin/log/로 분리하여
프로젝트 구조 패턴에 맞춤. 빈 객체 {} details가 표시되던 버그 수정.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
abf71d97d7
commit
aa95f737ba
6 changed files with 227 additions and 76 deletions
|
|
@ -156,10 +156,13 @@ fromis_9/
|
||||||
│ │ │ │ │ ├── PhotoPreviewModal.jsx
|
│ │ │ │ │ ├── PhotoPreviewModal.jsx
|
||||||
│ │ │ │ │ ├── PendingFileItem.jsx
|
│ │ │ │ │ ├── PendingFileItem.jsx
|
||||||
│ │ │ │ │ └── BulkEditPanel.jsx
|
│ │ │ │ │ └── BulkEditPanel.jsx
|
||||||
│ │ │ │ └── bot/
|
│ │ │ │ ├── bot/
|
||||||
│ │ │ │ ├── BotCard.jsx
|
│ │ │ │ │ ├── BotCard.jsx
|
||||||
│ │ │ │ ├── YouTubeBotDialog.jsx
|
│ │ │ │ │ ├── YouTubeBotDialog.jsx
|
||||||
│ │ │ │ └── XBotDialog.jsx
|
│ │ │ │ │ └── XBotDialog.jsx
|
||||||
|
│ │ │ │ └── log/
|
||||||
|
│ │ │ │ ├── constants.js
|
||||||
|
│ │ │ │ └── LogDetailDialog.jsx
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ │ └── mobile/ # 모바일 컴포넌트
|
│ │ │ └── mobile/ # 모바일 컴포넌트
|
||||||
│ │ │ ├── layout/
|
│ │ │ ├── layout/
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,6 @@ export * from './album';
|
||||||
|
|
||||||
// 봇 관련
|
// 봇 관련
|
||||||
export * from './bot';
|
export * from './bot';
|
||||||
|
|
||||||
|
// 로그 관련
|
||||||
|
export * from './log';
|
||||||
|
|
|
||||||
124
frontend/src/components/pc/admin/log/LogDetailDialog.jsx
Normal file
124
frontend/src/components/pc/admin/log/LogDetailDialog.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-full">
|
||||||
|
<User size={12} />
|
||||||
|
관리자
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-medium rounded-full">
|
||||||
|
<Bot size={12} />
|
||||||
|
{actor}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// summary 렌더링
|
||||||
|
function Summary({ summary }) {
|
||||||
|
const { prefix, detail } = parseSummary(summary);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-primary font-medium">[{prefix}]</span>
|
||||||
|
{detail && <span className="ml-1.5">{detail}</span>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ActorBadge, Summary };
|
||||||
|
|
||||||
|
export default function LogDetailDialog({ log, onClose }) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{log && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/40"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4"
|
||||||
|
>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between p-5 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`inline-block px-2.5 py-1 text-xs font-medium rounded-full ${ACTION_STYLES[log.action] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{ACTION_LABELS[log.action] || log.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
{CATEGORY_LABELS[log.category] || log.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<X size={18} className="text-gray-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 본문 */}
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
{/* 내용 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400 mb-1.5">내용</div>
|
||||||
|
<div className="text-sm text-gray-900 leading-relaxed">
|
||||||
|
<Summary summary={log.summary} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 행위자 + 시간 */}
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400 mb-1.5">행위자</div>
|
||||||
|
<ActorBadge actor={log.actor} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400 mb-1.5">시간</div>
|
||||||
|
<span className="text-sm text-gray-700 tabular-nums">{formatDateTime(log.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 대상 */}
|
||||||
|
{(log.target_type || log.target_id) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400 mb-1.5">대상</div>
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{log.target_type && <span>{log.target_type}</span>}
|
||||||
|
{log.target_id && <span className="ml-1.5 text-gray-400">#{log.target_id}</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상세 정보 */}
|
||||||
|
{hasDetails(log.details) && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-gray-400 mb-1.5">상세 정보</div>
|
||||||
|
<pre className="text-xs text-gray-600 bg-gray-50 rounded-lg p-3 overflow-auto max-h-40">
|
||||||
|
{JSON.stringify(log.details, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/components/pc/admin/log/constants.js
Normal file
73
frontend/src/components/pc/admin/log/constants.js
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
2
frontend/src/components/pc/admin/log/index.js
Normal file
2
frontend/src/components/pc/admin/log/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './constants';
|
||||||
|
export { default as LogDetailDialog, ActorBadge, Summary } from './LogDetailDialog';
|
||||||
|
|
@ -7,62 +7,29 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Home, ChevronRight, Search, ChevronLeft, ChevronDown,
|
Home, ChevronRight, Search, ChevronLeft, ChevronDown,
|
||||||
User, Bot, ScrollText, X, Loader2, Check,
|
X, Loader2, Check, ScrollText,
|
||||||
} from 'lucide-react';
|
} 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 { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
import { adminLogApi } from '@/api/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() {
|
function Logs() {
|
||||||
const { user } = useAdminAuth();
|
const { user } = useAdminAuth();
|
||||||
|
|
||||||
// 필터 상태
|
// 필터 상태
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategories, setSelectedCategories] = useState([]);
|
const [selectedCategories, setSelectedCategories] = useState([]);
|
||||||
const [actorFilter, setActorFilter] = useState('all'); // all, admin, bot
|
const [actorFilter, setActorFilter] = useState('all');
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
|
const [actorDropdownOpen, setActorDropdownOpen] = useState(false);
|
||||||
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
||||||
|
const [selectedLog, setSelectedLog] = useState(null);
|
||||||
|
|
||||||
// 검색어 디바운스
|
// 검색어 디바운스
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
|
@ -113,35 +80,6 @@ function Logs() {
|
||||||
return `카테고리 (${selectedCategories.length})`;
|
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 (
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 text-gray-700 text-xs font-medium rounded-full">
|
|
||||||
<User size={12} />
|
|
||||||
관리자
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-indigo-50 text-indigo-700 text-xs font-medium rounded-full">
|
|
||||||
<Bot size={12} />
|
|
||||||
{actor}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필터 초기화
|
// 필터 초기화
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
|
|
@ -361,7 +299,7 @@ function Logs() {
|
||||||
{formatDateTime(log.created_at)}
|
{formatDateTime(log.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3.5 whitespace-nowrap">
|
<td className="px-3 py-3.5 whitespace-nowrap">
|
||||||
{renderActorBadge(log.actor)}
|
<ActorBadge actor={log.actor} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3.5 whitespace-nowrap">
|
<td className="px-3 py-3.5 whitespace-nowrap">
|
||||||
<span className={`inline-block px-2.5 py-1 text-xs font-medium rounded-full ${ACTION_STYLES[log.action] || 'bg-gray-100 text-gray-600'}`}>
|
<span className={`inline-block px-2.5 py-1 text-xs font-medium rounded-full ${ACTION_STYLES[log.action] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
|
@ -374,7 +312,12 @@ function Logs() {
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="pl-3 pr-6 py-3.5 text-sm text-gray-700">
|
<td className="pl-3 pr-6 py-3.5 text-sm text-gray-700">
|
||||||
{log.summary}
|
<div
|
||||||
|
onClick={() => setSelectedLog(log)}
|
||||||
|
className="truncate cursor-pointer hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
<Summary summary={log.summary} />
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</motion.tr>
|
</motion.tr>
|
||||||
))}
|
))}
|
||||||
|
|
@ -449,6 +392,9 @@ function Logs() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 로그 상세 다이얼로그 */}
|
||||||
|
<LogDetailDialog log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue