fix(admin): 활동 로그 필터 UI 개선
- 카테고리를 하드코딩 대신 DB에서 조회하도록 변경 (GET /admin/logs/categories) - 카테고리 칩을 체크박스 멀티셀렉트 드롭다운으로 교체 - 카테고리가 없을 때 드롭다운 비활성화 - DatePicker에 min/max/compact prop 추가 (날짜 범위 제한, 높이 통일) - 날짜 선택 칸 너비 축소, 초기화 버튼 여백 축소 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
aa6c05e6b5
commit
607a652c2b
4 changed files with 162 additions and 50 deletions
|
|
@ -7,6 +7,39 @@ import { serverError } from '../../utils/error.js';
|
||||||
export default async function logsRoutes(fastify) {
|
export default async function logsRoutes(fastify) {
|
||||||
const { db } = fastify;
|
const { db } = fastify;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/admin/logs/categories
|
||||||
|
* 로그에 존재하는 카테고리 목록 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/categories', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/logs'],
|
||||||
|
summary: '로그 카테고리 목록 조회',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
categories: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: errorResponse,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query('SELECT DISTINCT category FROM logs ORDER BY category');
|
||||||
|
return { categories: rows.map(r => r.category) };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`로그 카테고리 조회 오류: ${err.message}`);
|
||||||
|
return serverError(reply, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/admin/logs
|
* GET /api/admin/logs
|
||||||
* 활동 로그 목록 조회
|
* 활동 로그 목록 조회
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,14 @@ import { fetchAuthApi } from '@/api/client';
|
||||||
* @param {string} [params.to] - 종료 날짜 (YYYY-MM-DD)
|
* @param {string} [params.to] - 종료 날짜 (YYYY-MM-DD)
|
||||||
* @returns {Promise<{logs: Array, total: number, page: number, limit: number, totalPages: number}>}
|
* @returns {Promise<{logs: Array, total: number, page: number, limit: number, totalPages: number}>}
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 로그 카테고리 목록 조회
|
||||||
|
* @returns {Promise<{categories: string[]}>}
|
||||||
|
*/
|
||||||
|
export async function getLogCategories() {
|
||||||
|
return fetchAuthApi('/admin/logs/categories');
|
||||||
|
}
|
||||||
|
|
||||||
export async function getLogs(params = {}) {
|
export async function getLogs(params = {}) {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ function DatePicker({
|
||||||
placeholder = '날짜 선택',
|
placeholder = '날짜 선택',
|
||||||
showDayOfWeek = false,
|
showDayOfWeek = false,
|
||||||
minYear = 2000,
|
minYear = 2000,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
compact = false,
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState('days');
|
const [viewMode, setViewMode] = useState('days');
|
||||||
|
|
@ -132,6 +135,14 @@ function DatePicker({
|
||||||
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDisabledDate = (day) => {
|
||||||
|
if (!day) return true;
|
||||||
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
if (min && dateStr < min) return true;
|
||||||
|
if (max && dateStr > max) return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const currentMonth = new Date().getMonth();
|
const currentMonth = new Date().getMonth();
|
||||||
const isCurrentYear = (y) => currentYear === y;
|
const isCurrentYear = (y) => currentYear === y;
|
||||||
|
|
@ -179,12 +190,14 @@ function DatePicker({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleButtonClick(e, () => setIsOpen(!isOpen))}
|
onClick={(e) => handleButtonClick(e, () => setIsOpen(!isOpen))}
|
||||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
className={`w-full border border-gray-200 bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent ${
|
||||||
|
compact ? 'px-4 py-2 rounded-lg' : 'px-4 py-3 rounded-xl'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
<span className={`${compact ? 'text-sm' : ''} ${value ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||||
{value ? formatDisplayDate(value) : placeholder}
|
{value ? formatDisplayDate(value) : placeholder}
|
||||||
</span>
|
</span>
|
||||||
<Calendar size={18} className="text-gray-400" />
|
<Calendar size={compact ? 16 : 18} className="text-gray-400" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -303,19 +316,20 @@ function DatePicker({
|
||||||
<div className="grid grid-cols-7 gap-1">
|
<div className="grid grid-cols-7 gap-1">
|
||||||
{days.map((day, i) => {
|
{days.map((day, i) => {
|
||||||
const dayOfWeek = i % 7;
|
const dayOfWeek = i % 7;
|
||||||
|
const disabled = isDisabledDate(day);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!day}
|
disabled={!day || disabled}
|
||||||
onClick={(e) => day && handleButtonClick(e, () => selectDate(day))}
|
onClick={(e) => day && !disabled && handleButtonClick(e, () => selectDate(day))}
|
||||||
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
|
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
|
||||||
${!day ? '' : 'hover:bg-gray-100'}
|
${!day ? '' : disabled ? 'opacity-30 cursor-not-allowed' : 'hover:bg-gray-100'}
|
||||||
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
|
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
|
||||||
${isToday(day) && !isSelected(day) ? 'text-primary font-bold' : ''}
|
${isToday(day) && !isSelected(day) && !disabled ? 'text-primary font-bold' : ''}
|
||||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 0 ? 'text-red-500' : ''}
|
${day && !isSelected(day) && !isToday(day) && !disabled && dayOfWeek === 0 ? 'text-red-500' : ''}
|
||||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 6 ? 'text-blue-500' : ''}
|
${day && !isSelected(day) && !isToday(day) && !disabled && dayOfWeek === 6 ? 'text-blue-500' : ''}
|
||||||
${day && !isSelected(day) && !isToday(day) && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
|
${day && !isSelected(day) && !isToday(day) && !disabled && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,23 @@ 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,
|
User, Bot, ScrollText, X, Loader2, Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AdminLayout, DatePicker } from '@/components/pc/admin';
|
import { AdminLayout, DatePicker } 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 CATEGORIES = [
|
const CATEGORY_LABELS = {
|
||||||
{ value: 'album', label: '앨범' },
|
album: '앨범',
|
||||||
{ value: 'schedule', label: '일정' },
|
schedule: '일정',
|
||||||
{ value: 'member', label: '멤버' },
|
member: '멤버',
|
||||||
{ value: 'bot', label: '봇' },
|
bot: '봇',
|
||||||
{ value: 'category', label: '카테고리' },
|
category: '카테고리',
|
||||||
{ value: 'dict', label: '사전' },
|
dict: '사전',
|
||||||
{ value: 'concert', label: '콘서트' },
|
concert: '콘서트',
|
||||||
{ value: 'sync', label: '동기화' },
|
sync: '동기화',
|
||||||
];
|
};
|
||||||
|
|
||||||
// 액션 뱃지 색상
|
// 액션 뱃지 색상
|
||||||
const ACTION_STYLES = {
|
const ACTION_STYLES = {
|
||||||
|
|
@ -62,6 +62,7 @@ function Logs() {
|
||||||
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 [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
|
@ -70,7 +71,15 @@ function Logs() {
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchQuery]);
|
}, [searchQuery]);
|
||||||
|
|
||||||
// API 호출
|
// 카테고리 목록 조회
|
||||||
|
const { data: categoryData } = useQuery({
|
||||||
|
queryKey: ['admin', 'logs', 'categories'],
|
||||||
|
queryFn: () => adminLogApi.getLogCategories(),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
const categories = categoryData?.categories || [];
|
||||||
|
|
||||||
|
// 로그 API 호출
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'logs', { page: currentPage, category: selectedCategories.join(','), actor: actorFilter === 'all' ? '' : actorFilter, search: debouncedSearch, from: dateFrom, to: dateTo }],
|
queryKey: ['admin', 'logs', { page: currentPage, category: selectedCategories.join(','), actor: actorFilter === 'all' ? '' : actorFilter, search: debouncedSearch, from: dateFrom, to: dateTo }],
|
||||||
queryFn: () => adminLogApi.getLogs({
|
queryFn: () => adminLogApi.getLogs({
|
||||||
|
|
@ -97,6 +106,13 @@ function Logs() {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 카테고리 드롭다운 버튼 텍스트
|
||||||
|
const getCategoryButtonText = () => {
|
||||||
|
if (selectedCategories.length === 0) return '전체 카테고리';
|
||||||
|
if (selectedCategories.length === 1) return CATEGORY_LABELS[selectedCategories[0]] || selectedCategories[0];
|
||||||
|
return `카테고리 (${selectedCategories.length})`;
|
||||||
|
};
|
||||||
|
|
||||||
// 날짜/시간 포맷
|
// 날짜/시간 포맷
|
||||||
const formatDateTime = (dateStr) => {
|
const formatDateTime = (dateStr) => {
|
||||||
const date = new Date(dateStr);
|
const date = new Date(dateStr);
|
||||||
|
|
@ -158,8 +174,7 @@ function Logs() {
|
||||||
|
|
||||||
{/* 필터 영역 */}
|
{/* 필터 영역 */}
|
||||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 mb-6">
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5 mb-6">
|
||||||
{/* 상단: 검색 + 행위자 + 날짜 */}
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
|
@ -175,7 +190,7 @@ function Logs() {
|
||||||
{/* 행위자 드롭다운 */}
|
{/* 행위자 드롭다운 */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActorDropdownOpen(!actorDropdownOpen)}
|
onClick={() => { setActorDropdownOpen(!actorDropdownOpen); setCategoryDropdownOpen(false); }}
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg text-sm hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-gray-600">
|
<span className="text-gray-600">
|
||||||
|
|
@ -215,21 +230,83 @@ function Logs() {
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 드롭다운 */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => categories.length > 0 && (setCategoryDropdownOpen(!categoryDropdownOpen), setActorDropdownOpen(false))}
|
||||||
|
disabled={categories.length === 0}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${
|
||||||
|
categories.length === 0
|
||||||
|
? 'border-gray-200 text-gray-400 cursor-not-allowed'
|
||||||
|
: selectedCategories.length > 0
|
||||||
|
? 'border-primary text-primary hover:bg-gray-50'
|
||||||
|
: 'border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>{getCategoryButtonText()}</span>
|
||||||
|
<ChevronDown size={16} className={selectedCategories.length > 0 ? 'text-primary' : 'text-gray-400'} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{categoryDropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-10" onClick={() => setCategoryDropdownOpen(false)} />
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -8 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute top-full left-0 mt-1 w-44 bg-white border border-gray-200 rounded-lg shadow-lg z-20 py-1"
|
||||||
|
>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<button
|
||||||
|
key={cat}
|
||||||
|
onClick={() => toggleCategory(cat)}
|
||||||
|
className="w-full flex items-center gap-2.5 px-4 py-2 text-sm hover:bg-gray-50 transition-colors text-gray-700"
|
||||||
|
>
|
||||||
|
<span className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 ${
|
||||||
|
selectedCategories.includes(cat) ? 'bg-primary border-primary' : 'border-gray-300'
|
||||||
|
}`}>
|
||||||
|
{selectedCategories.includes(cat) && <Check size={12} className="text-white" />}
|
||||||
|
</span>
|
||||||
|
{CATEGORY_LABELS[cat] || cat}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{selectedCategories.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="border-t border-gray-100 my-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedCategories([]); setCurrentPage(1); }}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-400 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
선택 해제
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 날짜 필터 */}
|
{/* 날짜 필터 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-44">
|
<div className="w-40">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={dateFrom}
|
value={dateFrom}
|
||||||
onChange={(v) => { setDateFrom(v); setCurrentPage(1); }}
|
onChange={(v) => { setDateFrom(v); setCurrentPage(1); }}
|
||||||
placeholder="시작일"
|
placeholder="시작일"
|
||||||
|
max={dateTo || undefined}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-gray-400 text-sm">~</span>
|
<span className="text-gray-400 text-sm">~</span>
|
||||||
<div className="w-44">
|
<div className="w-40">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={dateTo}
|
value={dateTo}
|
||||||
onChange={(v) => { setDateTo(v); setCurrentPage(1); }}
|
onChange={(v) => { setDateTo(v); setCurrentPage(1); }}
|
||||||
placeholder="종료일"
|
placeholder="종료일"
|
||||||
|
min={dateFrom || undefined}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -238,31 +315,13 @@ function Logs() {
|
||||||
{hasActiveFilters && (
|
{hasActiveFilters && (
|
||||||
<button
|
<button
|
||||||
onClick={clearFilters}
|
onClick={clearFilters}
|
||||||
className="flex items-center gap-1.5 px-3 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
className="flex items-center gap-1 px-2 py-2 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
초기화
|
초기화
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단: 카테고리 칩 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-gray-400 mr-1">카테고리</span>
|
|
||||||
{CATEGORIES.map((cat) => (
|
|
||||||
<button
|
|
||||||
key={cat.value}
|
|
||||||
onClick={() => toggleCategory(cat.value)}
|
|
||||||
className={`px-3 py-1.5 text-xs font-medium rounded-full transition-colors ${
|
|
||||||
selectedCategories.includes(cat.value)
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cat.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 결과 개수 */}
|
{/* 결과 개수 */}
|
||||||
|
|
@ -311,7 +370,7 @@ function Logs() {
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3.5 whitespace-nowrap">
|
<td className="px-3 py-3.5 whitespace-nowrap">
|
||||||
<span className="text-xs text-gray-500">
|
<span className="text-xs text-gray-500">
|
||||||
{CATEGORIES.find((c) => c.value === log.category)?.label || log.category}
|
{CATEGORY_LABELS[log.category] || log.category}
|
||||||
</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">
|
||||||
|
|
@ -351,14 +410,12 @@ function Logs() {
|
||||||
</button>
|
</button>
|
||||||
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
.filter((page) => {
|
.filter((page) => {
|
||||||
// 페이지가 많을 때 현재 페이지 주변만 표시
|
|
||||||
if (totalPages <= 7) return true;
|
if (totalPages <= 7) return true;
|
||||||
if (page === 1 || page === totalPages) return true;
|
if (page === 1 || page === totalPages) return true;
|
||||||
if (Math.abs(page - currentPage) <= 2) return true;
|
if (Math.abs(page - currentPage) <= 2) return true;
|
||||||
return false;
|
return false;
|
||||||
})
|
})
|
||||||
.reduce((acc, page, i, arr) => {
|
.reduce((acc, page, i, arr) => {
|
||||||
// 생략 부호(...) 추가
|
|
||||||
if (i > 0 && page - arr[i - 1] > 1) {
|
if (i > 0 && page - arr[i - 1] > 1) {
|
||||||
acc.push({ type: 'ellipsis', key: `e-${page}` });
|
acc.push({ type: 'ellipsis', key: `e-${page}` });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue