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
|
||||
│ │ │ │ │ ├── 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/
|
||||
|
|
|
|||
|
|
@ -12,3 +12,6 @@ export * from './album';
|
|||
|
||||
// 봇 관련
|
||||
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 {
|
||||
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 (
|
||||
<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 = () => {
|
||||
setSearchQuery('');
|
||||
|
|
@ -361,7 +299,7 @@ function Logs() {
|
|||
{formatDateTime(log.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3.5 whitespace-nowrap">
|
||||
{renderActorBadge(log.actor)}
|
||||
<ActorBadge actor={log.actor} />
|
||||
</td>
|
||||
<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'}`}>
|
||||
|
|
@ -374,7 +312,12 @@ function Logs() {
|
|||
</span>
|
||||
</td>
|
||||
<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>
|
||||
</motion.tr>
|
||||
))}
|
||||
|
|
@ -449,6 +392,9 @@ function Logs() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 로그 상세 다이얼로그 */}
|
||||
<LogDetailDialog log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue