feat: 어드민 사전 관리 기능 추가
- 형태소 분석기 사용자 사전 관리 페이지 추가 - 단어 추가/삭제/수정 시 즉시 저장 및 형태소 분석기 리로드 - 품사별 통계 및 필터링 기능 - 검색 기능 추가 백엔드: - GET/PUT /api/schedules/suggestions/dict API 추가 - morpheme.js에 reloadMorpheme(), getUserDictPath() 함수 추가 프론트엔드: - AdminScheduleDict.jsx 페이지 추가 - AdminSchedule.jsx에 사전 관리 버튼 추가 - 라우트 추가 (/admin/schedule/dict) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
c201de203e
commit
5521c44fa9
7 changed files with 754 additions and 60 deletions
|
|
@ -1,15 +1,8 @@
|
||||||
# 사용자 정의 사전
|
|
||||||
# 형식: 단어\t품사\t점수(선택)
|
|
||||||
# 품사: NNP(고유명사), NNG(일반명사), SL(외국어)
|
|
||||||
|
|
||||||
# 그룹명
|
|
||||||
프로미스나인 NNP
|
프로미스나인 NNP
|
||||||
프미나 NNP
|
프미나 NNP
|
||||||
프나 NNP
|
프나 NNP
|
||||||
fromis_9 SL
|
fromis_9 SL
|
||||||
fromis SL
|
fromis SL
|
||||||
|
|
||||||
# 멤버 이름
|
|
||||||
이새롬 NNP
|
이새롬 NNP
|
||||||
새롬 NNP
|
새롬 NNP
|
||||||
송하영 NNP
|
송하영 NNP
|
||||||
|
|
@ -28,70 +21,20 @@ fromis SL
|
||||||
나경 NNP
|
나경 NNP
|
||||||
백지헌 NNP
|
백지헌 NNP
|
||||||
지헌 NNP
|
지헌 NNP
|
||||||
|
|
||||||
# 팬덤
|
|
||||||
플로버 NNP
|
플로버 NNP
|
||||||
flover SL
|
flover SL
|
||||||
|
|
||||||
# 음악 프로그램
|
|
||||||
뮤직뱅크 NNP
|
뮤직뱅크 NNP
|
||||||
인기가요 NNP
|
인기가요 NNP
|
||||||
음악중심 NNP
|
음악중심 NNP
|
||||||
엠카운트다운 NNP
|
엠카운트다운 NNP
|
||||||
쇼챔피언 NNP
|
쇼챔피언 NNP
|
||||||
더쇼 NNP
|
더쇼 NNP
|
||||||
|
|
||||||
# 앨범/곡명
|
|
||||||
하얀그리움 NNP
|
|
||||||
LIKE_YOU_BETTER SL
|
|
||||||
|
|
||||||
# 콘텐츠/채널명
|
|
||||||
스프 NNP
|
스프 NNP
|
||||||
성수기 NNP
|
성수기 NNP
|
||||||
이단장 NNP
|
이단장 NNP
|
||||||
슈퍼E나경 NNP
|
슈퍼E나경 NNP
|
||||||
FM_1.24 SL
|
FM_1.24 SL
|
||||||
벌거벗은한국사 NNP
|
|
||||||
꿈친구 NNP
|
|
||||||
미미미누 NNP
|
|
||||||
비밀전학생 NNP
|
|
||||||
하일병 NNP
|
|
||||||
동네스타K쇼 NNP
|
|
||||||
밥사효 NNP
|
|
||||||
워크맨 NNP
|
|
||||||
영업중 NNP
|
|
||||||
개그콘서트 NNP
|
|
||||||
|
|
||||||
# 방송/행사
|
|
||||||
워터밤 NNP
|
|
||||||
서든어택 NNP
|
|
||||||
NPOP SL
|
|
||||||
하이록스 NNP
|
|
||||||
|
|
||||||
# 관련 용어
|
|
||||||
팬미팅 NNG
|
팬미팅 NNG
|
||||||
직캠 NNG
|
직캠 NNG
|
||||||
시구 NNG
|
|
||||||
시타 NNG
|
|
||||||
컴백 NNG
|
컴백 NNG
|
||||||
호캉스 NNG
|
|
||||||
팬캠 NNG
|
|
||||||
응원법 NNG
|
응원법 NNG
|
||||||
브이라이브 NNG
|
|
||||||
브이앱 NNG
|
|
||||||
|
|
||||||
# 다른 아이돌/연예인
|
|
||||||
류진 NNP
|
|
||||||
RYUJIN SL
|
|
||||||
ITZY SL
|
|
||||||
이즈나 NNP
|
|
||||||
izna SL
|
|
||||||
아일릿 NNP
|
|
||||||
ILLIT SL
|
|
||||||
키스오브라이프 NNP
|
|
||||||
하이키 NNP
|
|
||||||
H1KEY SL
|
|
||||||
아이들 NNP
|
|
||||||
미연 NNP
|
|
||||||
효연 NNP
|
|
||||||
T1 SL
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* 추천 검색어 API 라우트
|
* 추천 검색어 API 라우트
|
||||||
*/
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
import { SuggestionService } from '../../services/suggestions/index.js';
|
import { SuggestionService } from '../../services/suggestions/index.js';
|
||||||
|
import { reloadMorpheme, getUserDictPath } from '../../services/suggestions/morpheme.js';
|
||||||
|
|
||||||
let suggestionService = null;
|
let suggestionService = null;
|
||||||
|
|
||||||
|
|
@ -113,4 +115,83 @@ export default async function suggestionsRoutes(fastify) {
|
||||||
await suggestionService.saveSearchQuery(query);
|
await suggestionService.saveSearchQuery(query);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/schedules/suggestions/dict
|
||||||
|
* 사용자 사전 조회 (관리자 전용)
|
||||||
|
*/
|
||||||
|
fastify.get('/dict', {
|
||||||
|
schema: {
|
||||||
|
tags: ['suggestions'],
|
||||||
|
summary: '사용자 사전 조회',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
content: { type: 'string', description: '사전 내용' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const dictPath = getUserDictPath();
|
||||||
|
const content = readFileSync(dictPath, 'utf-8');
|
||||||
|
return { content };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return { content: '' };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/schedules/suggestions/dict
|
||||||
|
* 사용자 사전 저장 및 리로드 (관리자 전용)
|
||||||
|
*/
|
||||||
|
fastify.put('/dict', {
|
||||||
|
schema: {
|
||||||
|
tags: ['suggestions'],
|
||||||
|
summary: '사용자 사전 저장',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['content'],
|
||||||
|
properties: {
|
||||||
|
content: { type: 'string', description: '사전 내용' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
success: { type: 'boolean' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { content } = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dictPath = getUserDictPath();
|
||||||
|
writeFileSync(dictPath, content, 'utf-8');
|
||||||
|
|
||||||
|
// 형태소 분석기 리로드
|
||||||
|
await reloadMorpheme();
|
||||||
|
|
||||||
|
return { success: true, message: '사전이 저장되었습니다.' };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Suggestions] 사전 저장 오류:', error.message);
|
||||||
|
return reply.code(500).send({
|
||||||
|
success: false,
|
||||||
|
message: '사전 저장 중 오류가 발생했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,3 +162,22 @@ function fallbackExtract(text) {
|
||||||
export function isReady() {
|
export function isReady() {
|
||||||
return isInitialized && kiwi !== null;
|
return isInitialized && kiwi !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 형태소 분석기 리로드 (사전 변경 시 호출)
|
||||||
|
*/
|
||||||
|
export async function reloadMorpheme() {
|
||||||
|
console.log('[Morpheme] 리로드 시작...');
|
||||||
|
isInitialized = false;
|
||||||
|
kiwi = null;
|
||||||
|
initPromise = null;
|
||||||
|
await initMorpheme();
|
||||||
|
console.log('[Morpheme] 리로드 완료');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 사전 파일 경로 반환
|
||||||
|
*/
|
||||||
|
export function getUserDictPath() {
|
||||||
|
return join(__dirname, '../../../models/kiwi', USER_DICT);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
||||||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
||||||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||||
|
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||||
|
|
||||||
// 레이아웃
|
// 레이아웃
|
||||||
import PCLayout from './components/pc/Layout';
|
import PCLayout from './components/pc/Layout';
|
||||||
|
|
@ -75,6 +76,7 @@ function App() {
|
||||||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||||
|
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||||
|
|
||||||
{/* 일반 페이지 (레이아웃 포함) */}
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
<Route path="/*" element={
|
<Route path="/*" element={
|
||||||
|
|
|
||||||
17
frontend/src/api/admin/suggestions.js
Normal file
17
frontend/src/api/admin/suggestions.js
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* 어드민 추천 검색어 API
|
||||||
|
*/
|
||||||
|
import { fetchAdminApi } from "../index";
|
||||||
|
|
||||||
|
// 사전 내용 조회
|
||||||
|
export async function getDict() {
|
||||||
|
return fetchAdminApi("/api/schedules/suggestions/dict");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사전 저장
|
||||||
|
export async function saveDict(content) {
|
||||||
|
return fetchAdminApi("/api/schedules/suggestions/dict", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@ import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
|
Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
|
||||||
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2
|
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
|
@ -657,6 +657,13 @@ function AdminSchedule() {
|
||||||
<p className="text-gray-500">fromis_9의 일정을 관리합니다</p>
|
<p className="text-gray-500">fromis_9의 일정을 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/schedule/dict')}
|
||||||
|
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
|
||||||
|
>
|
||||||
|
<Book size={20} />
|
||||||
|
사전 관리
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/admin/schedule/bots')}
|
onClick={() => navigate('/admin/schedule/bots')}
|
||||||
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
|
className="flex items-center gap-2 px-5 py-3 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors font-medium"
|
||||||
|
|
|
||||||
625
frontend/src/pages/pc/admin/AdminScheduleDict.jsx
Normal file
625
frontend/src/pages/pc/admin/AdminScheduleDict.jsx
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
|
||||||
|
import Toast from '../../../components/Toast';
|
||||||
|
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||||
|
import ConfirmDialog from '../../../components/admin/ConfirmDialog';
|
||||||
|
import useAdminAuth from '../../../hooks/useAdminAuth';
|
||||||
|
import useToast from '../../../hooks/useToast';
|
||||||
|
import * as suggestionsApi from '../../../api/admin/suggestions';
|
||||||
|
|
||||||
|
// 품사 태그 옵션
|
||||||
|
const POS_TAGS = [
|
||||||
|
{ value: 'NNP', label: '고유명사 (NNP)', description: '사람, 그룹, 프로그램 이름 등', examples: '프로미스나인, 송하영, 뮤직뱅크' },
|
||||||
|
{ value: 'NNG', label: '일반명사 (NNG)', description: '일반적인 명사', examples: '직캠, 팬미팅, 콘서트' },
|
||||||
|
{ value: 'SL', label: '외국어 (SL)', description: '영어 등 외국어 단어', examples: 'fromis_9, YouTube, fromm' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 단어 항목 컴포넌트
|
||||||
|
function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editWord, setEditWord] = useState(word);
|
||||||
|
const [editPos, setEditPos] = useState(pos);
|
||||||
|
const [showPosDropdown, setShowPosDropdown] = useState(false);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setShowPosDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showPosDropdown) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showPosDropdown]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (editWord.trim() && (editWord.trim() !== word || editPos !== pos)) {
|
||||||
|
onUpdate(id, editWord.trim(), editPos);
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditWord(word);
|
||||||
|
setEditPos(pos);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const posLabel = POS_TAGS.find(t => t.value === pos)?.label || pos;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.tr
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="group hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400 w-16">{index + 1}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editWord}
|
||||||
|
onChange={(e) => setEditWord(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-1.5 border border-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="cursor-pointer hover:text-primary transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-48">
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPosDropdown(!showPosDropdown)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors w-full justify-between"
|
||||||
|
>
|
||||||
|
<span>{POS_TAGS.find(t => t.value === (isEditing ? editPos : pos))?.label.split(' ')[0] || pos}</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform ${showPosDropdown ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showPosDropdown && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -5 }}
|
||||||
|
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||||
|
>
|
||||||
|
{POS_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setEditPos(tag.value);
|
||||||
|
} else {
|
||||||
|
onUpdate(id, word, tag.value);
|
||||||
|
}
|
||||||
|
setShowPosDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||||
|
(isEditing ? editPos : pos) === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{tag.label}</div>
|
||||||
|
<div className="text-xs text-gray-400">{tag.description}</div>
|
||||||
|
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-20">
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(index)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminScheduleDict() {
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterPos, setFilterPos] = useState('all');
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
|
||||||
|
|
||||||
|
// 새 단어 입력
|
||||||
|
const [newWord, setNewWord] = useState('');
|
||||||
|
const [newPos, setNewPos] = useState('NNP');
|
||||||
|
const [showNewPosDropdown, setShowNewPosDropdown] = useState(false);
|
||||||
|
|
||||||
|
// 드롭다운 refs
|
||||||
|
const newPosDropdownRef = useRef(null);
|
||||||
|
const filterDropdownRef = useRef(null);
|
||||||
|
|
||||||
|
// 다이얼로그 상태
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [wordToDelete, setWordToDelete] = useState(null); // { index, word, id }
|
||||||
|
|
||||||
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (newPosDropdownRef.current && !newPosDropdownRef.current.contains(event.target)) {
|
||||||
|
setShowNewPosDropdown(false);
|
||||||
|
}
|
||||||
|
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target)) {
|
||||||
|
setShowFilterDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showNewPosDropdown || showFilterDropdown) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showNewPosDropdown, showFilterDropdown]);
|
||||||
|
|
||||||
|
// 필터링된 항목
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
return entries.filter((entry, index) => {
|
||||||
|
if (entry.isComment) return true; // 주석은 항상 포함 (but 표시 안함)
|
||||||
|
|
||||||
|
const matchesSearch = !searchQuery ||
|
||||||
|
entry.word.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesPos = filterPos === 'all' || entry.pos === filterPos;
|
||||||
|
|
||||||
|
return matchesSearch && matchesPos;
|
||||||
|
});
|
||||||
|
}, [entries, searchQuery, filterPos]);
|
||||||
|
|
||||||
|
// 실제 단어 항목만 (주석 제외)
|
||||||
|
const wordEntries = useMemo(() => {
|
||||||
|
return filteredEntries.filter(e => !e.isComment);
|
||||||
|
}, [filteredEntries]);
|
||||||
|
|
||||||
|
// 품사별 통계
|
||||||
|
const posStats = useMemo(() => {
|
||||||
|
const stats = { total: 0 };
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (!e.isComment) {
|
||||||
|
stats.total++;
|
||||||
|
stats[e.pos] = (stats[e.pos] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return stats;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
fetchDict();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// 고유 ID 생성
|
||||||
|
const generateId = () => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
// 사전 파일 파싱
|
||||||
|
const parseDict = (content) => {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
return lines.map(line => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
return { isComment: true, raw: line, id: generateId() };
|
||||||
|
}
|
||||||
|
const parts = trimmed.split('\t');
|
||||||
|
return {
|
||||||
|
word: parts[0] || '',
|
||||||
|
pos: parts[1] || 'NNP',
|
||||||
|
isComment: false,
|
||||||
|
id: generateId(),
|
||||||
|
};
|
||||||
|
}).filter(e => e.isComment || e.word); // 빈 줄 제거하되 주석은 유지
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사전 파일 생성
|
||||||
|
const serializeDict = (entries) => {
|
||||||
|
return entries.map(e => {
|
||||||
|
if (e.isComment) return e.raw;
|
||||||
|
return `${e.word}\t${e.pos}`;
|
||||||
|
}).join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사전 내용 조회
|
||||||
|
const fetchDict = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await suggestionsApi.getDict();
|
||||||
|
const parsed = parseDict(data.content || '');
|
||||||
|
setEntries(parsed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사전 조회 오류:', error);
|
||||||
|
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사전 저장 (entries 배열을 받아서 저장)
|
||||||
|
const saveDict = async (newEntries) => {
|
||||||
|
try {
|
||||||
|
const content = serializeDict(newEntries);
|
||||||
|
await suggestionsApi.saveDict(content);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사전 저장 오류:', error);
|
||||||
|
setToast({ type: 'error', message: error.message || '저장 중 오류가 발생했습니다.' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 추가 다이얼로그 열기
|
||||||
|
const openAddDialog = () => {
|
||||||
|
if (!newWord.trim()) return;
|
||||||
|
|
||||||
|
// 중복 확인
|
||||||
|
const isDuplicate = entries.some(e => !e.isComment && e.word.toLowerCase() === newWord.trim().toLowerCase());
|
||||||
|
if (isDuplicate) {
|
||||||
|
setToast({ type: 'error', message: '이미 존재하는 단어입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 추가 확인
|
||||||
|
const handleAddWord = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
const wordToAdd = newWord.trim();
|
||||||
|
const newEntry = { word: wordToAdd, pos: newPos, isComment: false, id: generateId() };
|
||||||
|
const newEntries = [...entries, newEntry];
|
||||||
|
|
||||||
|
const success = await saveDict(newEntries);
|
||||||
|
if (success) {
|
||||||
|
setEntries(newEntries);
|
||||||
|
setNewWord('');
|
||||||
|
setToast({ type: 'success', message: `"${wordToAdd}" 단어가 추가되었습니다.` });
|
||||||
|
}
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 수정 (id 기반)
|
||||||
|
const handleUpdateWord = async (id, word, pos) => {
|
||||||
|
const entryIndex = entries.findIndex(e => e.id === id);
|
||||||
|
if (entryIndex === -1) return;
|
||||||
|
|
||||||
|
const newEntries = [...entries];
|
||||||
|
newEntries[entryIndex] = { ...newEntries[entryIndex], word, pos };
|
||||||
|
|
||||||
|
const success = await saveDict(newEntries);
|
||||||
|
if (success) {
|
||||||
|
setEntries(newEntries);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 삭제 다이얼로그 열기
|
||||||
|
const openDeleteDialog = (id, word) => {
|
||||||
|
setWordToDelete({ id, word });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 삭제 확인
|
||||||
|
const handleDeleteWord = async () => {
|
||||||
|
if (!wordToDelete) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
const deletedWord = wordToDelete.word;
|
||||||
|
const newEntries = entries.filter(e => e.id !== wordToDelete.id);
|
||||||
|
|
||||||
|
const success = await saveDict(newEntries);
|
||||||
|
if (success) {
|
||||||
|
setEntries(newEntries);
|
||||||
|
setToast({ type: 'success', message: `"${deletedWord}" 단어가 삭제되었습니다.` });
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setWordToDelete(null);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엔터키로 추가 다이얼로그 열기
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
openAddDialog();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
{/* 단어 추가 확인 다이얼로그 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={addDialogOpen}
|
||||||
|
onClose={() => !saving && setAddDialogOpen(false)}
|
||||||
|
onConfirm={handleAddWord}
|
||||||
|
title="단어 추가"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600 mb-2">다음 단어를 추가하시겠습니까?</p>
|
||||||
|
<div className="p-3 bg-gray-50 rounded-lg">
|
||||||
|
<p className="font-medium text-gray-900">{newWord}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{POS_TAGS.find(t => t.value === newPos)?.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText="추가"
|
||||||
|
loadingText="추가 중..."
|
||||||
|
loading={saving}
|
||||||
|
variant="primary"
|
||||||
|
icon={Plus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 단어 삭제 확인 다이얼로그 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!saving) {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setWordToDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={handleDeleteWord}
|
||||||
|
title="단어 삭제"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600 mb-2">다음 단어를 삭제하시겠습니까?</p>
|
||||||
|
<p className="font-medium text-gray-900 p-3 bg-gray-50 rounded-lg">
|
||||||
|
{wordToDelete?.word}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText="삭제"
|
||||||
|
loadingText="삭제 중..."
|
||||||
|
loading={saving}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-8">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||||
|
<Home size={16} />
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link to="/admin/schedule" className="hover:text-primary transition-colors">
|
||||||
|
일정 관리
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-700">사전 관리</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
|
||||||
|
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{posStats.total || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">전체 단어</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">고유명사</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">일반명사</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">외국어</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단어 추가 영역 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
|
||||||
|
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newWord}
|
||||||
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="추가할 단어 입력..."
|
||||||
|
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-48" ref={newPosDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewPosDropdown(!showNewPosDropdown)}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors w-full justify-between"
|
||||||
|
>
|
||||||
|
<span>{POS_TAGS.find(t => t.value === newPos)?.label.split(' ')[0]}</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform ${showNewPosDropdown ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showNewPosDropdown && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -5 }}
|
||||||
|
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||||
|
>
|
||||||
|
{POS_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.value}
|
||||||
|
onClick={() => {
|
||||||
|
setNewPos(tag.value);
|
||||||
|
setShowNewPosDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||||
|
newPos === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{tag.label}</div>
|
||||||
|
<div className="text-xs text-gray-400">{tag.description}</div>
|
||||||
|
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAddDialog}
|
||||||
|
disabled={!newWord.trim()}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단어 목록 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
{/* 검색 및 필터 */}
|
||||||
|
<div className="p-4 border-b border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="단어 검색..."
|
||||||
|
className="w-full pl-11 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative" ref={filterDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span>{filterPos === 'all' ? '전체 품사' : POS_TAGS.find(t => t.value === filterPos)?.label.split(' ')[0]}</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform ${showFilterDropdown ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showFilterDropdown && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -5 }}
|
||||||
|
className="absolute top-full right-0 mt-1 w-48 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFilterPos('all');
|
||||||
|
setShowFilterDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
|
||||||
|
filterPos === 'all' ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체 품사
|
||||||
|
</button>
|
||||||
|
{POS_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.value}
|
||||||
|
onClick={() => {
|
||||||
|
setFilterPos(tag.value);
|
||||||
|
setShowFilterDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
|
||||||
|
filterPos === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.label.split(' ')[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-20">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : wordEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-gray-400">
|
||||||
|
<Book size={48} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p>{searchQuery || filterPos !== 'all' ? '검색 결과가 없습니다' : '등록된 단어가 없습니다'}</p>
|
||||||
|
<p className="text-sm mt-1">위의 입력창에서 단어를 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">#</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">단어</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-48">품사</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
<AnimatePresence>
|
||||||
|
{wordEntries.map((entry, index) => (
|
||||||
|
<WordItem
|
||||||
|
key={entry.id}
|
||||||
|
id={entry.id}
|
||||||
|
word={entry.word}
|
||||||
|
pos={entry.pos}
|
||||||
|
index={index}
|
||||||
|
onUpdate={handleUpdateWord}
|
||||||
|
onDelete={() => openDeleteDialog(entry.id, entry.word)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
{wordEntries.length > 0 && (
|
||||||
|
<div className="px-4 py-3 bg-gray-50 border-t border-gray-100 text-sm text-gray-500">
|
||||||
|
{searchQuery || filterPos !== 'all' ? (
|
||||||
|
<span>{wordEntries.length}개 검색됨 (전체 {posStats.total}개)</span>
|
||||||
|
) : (
|
||||||
|
<span>총 {posStats.total}개 단어</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminScheduleDict;
|
||||||
Loading…
Add table
Reference in a new issue