📱 PDF 미리보기 (PDF.js): - 실제 PDF 렌더링 (Canvas 기반) - 줌 인/아웃 (50% ~ 300%) - 페이지 네비게이션 (이전/다음/직접입력) - 고해상도 디스플레이 지원 - 검색어 위치로 뷰어 연동 📄 HTML 문서 미리보기: - iframe 렌더링 뷰 / 소스 코드 뷰 토글 - 검색어 자동 하이라이트 (iframe 내부) - 문법 하이라이트된 소스 코드 표시 - 안전한 sandbox 모드 📝 노트 문서 미리보기: - 제목, 생성일시 표시 - 검색어 하이라이트 - 편집기에서 열기 버튼 - 깔끔한 카드 스타일 UI 🌳 메모 트리 노드 미리보기: - 메모 제목과 내용 표시 - 트리 정보 (소속 트리명) - 검색어 하이라이트 - 구조화된 레이아웃 🎯 UX 개선: - 타입별 아이콘과 색상 구분 - ESC 키 / 배경 클릭으로 닫기 - 로딩 상태 표시 - 에러 처리 및 fallback - 반응형 디자인
679 lines
25 KiB
JavaScript
679 lines
25 KiB
JavaScript
/**
|
|
* 통합 검색 JavaScript
|
|
*/
|
|
|
|
// 검색 애플리케이션 Alpine.js 컴포넌트
|
|
window.searchApp = function() {
|
|
return {
|
|
// 상태 관리
|
|
searchQuery: '',
|
|
searchResults: [],
|
|
filteredResults: [],
|
|
loading: false,
|
|
hasSearched: false,
|
|
searchTime: 0,
|
|
|
|
// 필터링
|
|
typeFilter: '', // '', 'document', 'note', 'memo', 'highlight'
|
|
sortBy: 'relevance', // 'relevance', 'date_desc', 'date_asc', 'title'
|
|
|
|
// 검색 디바운스
|
|
searchTimeout: null,
|
|
|
|
// 미리보기 모달
|
|
showPreviewModal: false,
|
|
previewResult: null,
|
|
previewLoading: false,
|
|
pdfError: false,
|
|
|
|
// PDF 뷰어 상태
|
|
pdfLoading: false,
|
|
pdfDoc: null,
|
|
pdfCurrentPage: 1,
|
|
pdfTotalPages: 0,
|
|
pdfZoom: 1.0,
|
|
|
|
// HTML 뷰어 상태
|
|
htmlLoading: false,
|
|
htmlRawMode: false,
|
|
htmlSourceCode: '',
|
|
|
|
// 인증 상태
|
|
isAuthenticated: false,
|
|
currentUser: null,
|
|
|
|
// API 클라이언트
|
|
api: null,
|
|
|
|
// 초기화
|
|
async init() {
|
|
console.log('🔍 검색 앱 초기화 시작');
|
|
|
|
try {
|
|
// API 클라이언트 초기화
|
|
this.api = new DocumentServerAPI();
|
|
|
|
// 헤더 로드
|
|
await this.loadHeader();
|
|
|
|
// 인증 상태 확인
|
|
await this.checkAuthStatus();
|
|
|
|
// URL 파라미터에서 검색어 확인
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const query = urlParams.get('q');
|
|
if (query) {
|
|
this.searchQuery = query;
|
|
await this.performSearch();
|
|
}
|
|
|
|
console.log('✅ 검색 앱 초기화 완료');
|
|
} catch (error) {
|
|
console.error('❌ 검색 앱 초기화 실패:', error);
|
|
}
|
|
},
|
|
|
|
// 인증 상태 확인
|
|
async checkAuthStatus() {
|
|
try {
|
|
const user = await this.api.getCurrentUser();
|
|
this.isAuthenticated = true;
|
|
this.currentUser = user;
|
|
console.log('✅ 인증됨:', user.username || user.email);
|
|
} catch (error) {
|
|
console.log('❌ 인증되지 않음');
|
|
this.isAuthenticated = false;
|
|
this.currentUser = null;
|
|
// 검색은 로그인 없이도 가능하도록 허용
|
|
}
|
|
},
|
|
|
|
// 헤더 로드
|
|
async loadHeader() {
|
|
try {
|
|
if (typeof loadHeaderComponent === 'function') {
|
|
await loadHeaderComponent();
|
|
} else if (typeof window.loadHeaderComponent === 'function') {
|
|
await window.loadHeaderComponent();
|
|
} else {
|
|
console.warn('헤더 로더 함수를 찾을 수 없습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('헤더 로드 실패:', error);
|
|
}
|
|
},
|
|
|
|
// 검색 디바운스
|
|
debounceSearch() {
|
|
clearTimeout(this.searchTimeout);
|
|
this.searchTimeout = setTimeout(() => {
|
|
if (this.searchQuery.trim()) {
|
|
this.performSearch();
|
|
}
|
|
}, 500);
|
|
},
|
|
|
|
// 검색 수행
|
|
async performSearch() {
|
|
if (!this.searchQuery.trim()) {
|
|
this.searchResults = [];
|
|
this.filteredResults = [];
|
|
this.hasSearched = false;
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
console.log('🔍 검색 시작:', this.searchQuery);
|
|
|
|
// 검색 API 호출
|
|
const response = await this.api.search({
|
|
q: this.searchQuery,
|
|
type_filter: this.typeFilter || undefined,
|
|
limit: 50
|
|
});
|
|
|
|
this.searchResults = response.results || [];
|
|
this.hasSearched = true;
|
|
this.searchTime = Date.now() - startTime;
|
|
|
|
// 필터 적용
|
|
this.applyFilters();
|
|
|
|
// URL 업데이트
|
|
this.updateURL();
|
|
|
|
console.log('✅ 검색 완료:', this.searchResults.length, '개 결과');
|
|
|
|
} catch (error) {
|
|
console.error('❌ 검색 실패:', error);
|
|
this.searchResults = [];
|
|
this.filteredResults = [];
|
|
this.hasSearched = true;
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
// 필터 적용
|
|
applyFilters() {
|
|
let results = [...this.searchResults];
|
|
|
|
// 타입 필터
|
|
if (this.typeFilter) {
|
|
results = results.filter(result => result.type === this.typeFilter);
|
|
}
|
|
|
|
// 정렬
|
|
results.sort((a, b) => {
|
|
switch (this.sortBy) {
|
|
case 'relevance':
|
|
return (b.relevance_score || 0) - (a.relevance_score || 0);
|
|
case 'date_desc':
|
|
return new Date(b.created_at) - new Date(a.created_at);
|
|
case 'date_asc':
|
|
return new Date(a.created_at) - new Date(b.created_at);
|
|
case 'title':
|
|
return a.title.localeCompare(b.title);
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
this.filteredResults = results;
|
|
console.log('🔧 필터 적용 완료:', this.filteredResults.length, '개 결과');
|
|
},
|
|
|
|
// URL 업데이트
|
|
updateURL() {
|
|
const url = new URL(window.location);
|
|
if (this.searchQuery.trim()) {
|
|
url.searchParams.set('q', this.searchQuery);
|
|
} else {
|
|
url.searchParams.delete('q');
|
|
}
|
|
window.history.replaceState({}, '', url);
|
|
},
|
|
|
|
// 미리보기 표시
|
|
async showPreview(result) {
|
|
console.log('👁️ 미리보기 표시:', result);
|
|
|
|
this.previewResult = result;
|
|
this.showPreviewModal = true;
|
|
this.previewLoading = true;
|
|
|
|
try {
|
|
// 타입별 미리보기 로드
|
|
if (result.type === 'document_content' && result.highlight_info?.has_pdf) {
|
|
// PDF 미리보기
|
|
await this.loadPdfPreview(result.document_id);
|
|
} else if ((result.type === 'document' || result.type === 'document_content') && !result.highlight_info?.has_pdf) {
|
|
// HTML 문서 미리보기
|
|
await this.loadHtmlPreview(result.document_id);
|
|
} else if (result.type === 'note') {
|
|
// 노트 미리보기 - 전체 내용 로드
|
|
const fullContent = await this.loadFullContent(result);
|
|
if (fullContent) {
|
|
this.previewResult = { ...result, content: fullContent };
|
|
}
|
|
} else if (result.type === 'memo') {
|
|
// 메모 미리보기 - 전체 내용 로드
|
|
const fullContent = await this.loadFullContent(result);
|
|
if (fullContent) {
|
|
this.previewResult = { ...result, content: fullContent };
|
|
}
|
|
} else {
|
|
// 기타 타입 - 기본 내용 로드
|
|
const fullContent = await this.loadFullContent(result);
|
|
if (fullContent) {
|
|
this.previewResult = { ...result, content: fullContent };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('미리보기 로드 실패:', error);
|
|
} finally {
|
|
this.previewLoading = false;
|
|
}
|
|
},
|
|
|
|
// 전체 내용 로드
|
|
async loadFullContent(result) {
|
|
try {
|
|
let content = '';
|
|
|
|
switch (result.type) {
|
|
case 'document':
|
|
case 'document_content':
|
|
try {
|
|
// 문서 내용 API 호출 (HTML 응답)
|
|
const response = await fetch(`/api/documents/${result.document_id}/content`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const htmlContent = await response.text();
|
|
// HTML에서 텍스트만 추출
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(htmlContent, 'text/html');
|
|
content = doc.body.textContent || doc.body.innerText || '';
|
|
// 너무 길면 자르기
|
|
if (content.length > 2000) {
|
|
content = content.substring(0, 2000) + '...';
|
|
}
|
|
} else {
|
|
content = result.content;
|
|
}
|
|
} catch (err) {
|
|
console.warn('문서 내용 로드 실패, 기본 내용 사용:', err);
|
|
content = result.content;
|
|
}
|
|
break;
|
|
|
|
case 'note':
|
|
try {
|
|
// 노트 내용 API 호출
|
|
const noteContent = await this.api.get(`/note-documents/${result.id}/content`);
|
|
content = noteContent;
|
|
} catch (err) {
|
|
console.warn('노트 내용 로드 실패, 기본 내용 사용:', err);
|
|
content = result.content;
|
|
}
|
|
break;
|
|
|
|
case 'memo':
|
|
try {
|
|
// 메모 노드 상세 정보 로드
|
|
const memoNode = await this.api.get(`/memo-trees/nodes/${result.id}`);
|
|
content = memoNode.content || result.content;
|
|
} catch (err) {
|
|
console.warn('메모 내용 로드 실패, 기본 내용 사용:', err);
|
|
content = result.content;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
content = result.content;
|
|
}
|
|
|
|
return content;
|
|
} catch (error) {
|
|
console.error('내용 로드 실패:', error);
|
|
return result.content;
|
|
}
|
|
},
|
|
|
|
// 미리보기 닫기
|
|
closePreview() {
|
|
this.showPreviewModal = false;
|
|
this.previewResult = null;
|
|
this.previewLoading = false;
|
|
this.pdfError = false;
|
|
|
|
// PDF 리소스 정리
|
|
this.pdfDoc = null;
|
|
this.pdfCurrentPage = 1;
|
|
this.pdfTotalPages = 0;
|
|
this.pdfZoom = 1.0;
|
|
this.pdfLoading = false;
|
|
|
|
// HTML 리소스 정리
|
|
this.htmlLoading = false;
|
|
this.htmlRawMode = false;
|
|
this.htmlSourceCode = '';
|
|
},
|
|
|
|
// PDF 미리보기 로드
|
|
async loadPdfPreview(documentId) {
|
|
this.pdfLoading = true;
|
|
this.pdfError = false;
|
|
|
|
try {
|
|
// PDF.js 워커 설정
|
|
if (typeof pdfjsLib !== 'undefined') {
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
|
}
|
|
|
|
const pdfUrl = `/api/documents/${documentId}/pdf`;
|
|
|
|
// PDF 문서 로드
|
|
const loadingTask = pdfjsLib.getDocument({
|
|
url: pdfUrl,
|
|
httpHeaders: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
this.pdfDoc = await loadingTask.promise;
|
|
this.pdfTotalPages = this.pdfDoc.numPages;
|
|
this.pdfCurrentPage = 1;
|
|
|
|
console.log('PDF 로드 완료:', this.pdfTotalPages, '페이지');
|
|
|
|
// 첫 페이지 렌더링
|
|
await this.renderPdfPage(1);
|
|
|
|
} catch (error) {
|
|
console.error('PDF 로드 실패:', error);
|
|
this.pdfError = true;
|
|
} finally {
|
|
this.pdfLoading = false;
|
|
}
|
|
},
|
|
|
|
// PDF 페이지 렌더링
|
|
async renderPdfPage(pageNum) {
|
|
if (!this.pdfDoc) return;
|
|
|
|
try {
|
|
const page = await this.pdfDoc.getPage(pageNum);
|
|
const canvas = document.getElementById('pdfCanvas');
|
|
const context = canvas.getContext('2d');
|
|
|
|
// 뷰포트 설정 (줌 적용)
|
|
const viewport = page.getViewport({ scale: this.pdfZoom });
|
|
|
|
// 캔버스 크기 설정
|
|
canvas.height = viewport.height;
|
|
canvas.width = viewport.width;
|
|
|
|
// 고해상도 디스플레이 지원
|
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
|
canvas.style.width = viewport.width + 'px';
|
|
canvas.style.height = viewport.height + 'px';
|
|
canvas.width = viewport.width * devicePixelRatio;
|
|
canvas.height = viewport.height * devicePixelRatio;
|
|
context.scale(devicePixelRatio, devicePixelRatio);
|
|
|
|
// 페이지 렌더링
|
|
const renderContext = {
|
|
canvasContext: context,
|
|
viewport: viewport
|
|
};
|
|
|
|
await page.render(renderContext).promise;
|
|
console.log('페이지 렌더링 완료:', pageNum);
|
|
|
|
} catch (error) {
|
|
console.error('페이지 렌더링 실패:', error);
|
|
this.pdfError = true;
|
|
}
|
|
},
|
|
|
|
// 줌 인
|
|
async zoomIn() {
|
|
if (this.pdfZoom < 3.0) {
|
|
this.pdfZoom += 0.25;
|
|
await this.renderPdfPage(this.pdfCurrentPage);
|
|
}
|
|
},
|
|
|
|
// 줌 아웃
|
|
async zoomOut() {
|
|
if (this.pdfZoom > 0.5) {
|
|
this.pdfZoom -= 0.25;
|
|
await this.renderPdfPage(this.pdfCurrentPage);
|
|
}
|
|
},
|
|
|
|
// 이전 페이지
|
|
async prevPage() {
|
|
if (this.pdfCurrentPage > 1) {
|
|
this.pdfCurrentPage--;
|
|
await this.renderPdfPage(this.pdfCurrentPage);
|
|
}
|
|
},
|
|
|
|
// 다음 페이지
|
|
async nextPage() {
|
|
if (this.pdfCurrentPage < this.pdfTotalPages) {
|
|
this.pdfCurrentPage++;
|
|
await this.renderPdfPage(this.pdfCurrentPage);
|
|
}
|
|
},
|
|
|
|
// 특정 페이지로 이동
|
|
async goToPage(pageNum) {
|
|
const page = parseInt(pageNum);
|
|
if (page >= 1 && page <= this.pdfTotalPages) {
|
|
this.pdfCurrentPage = page;
|
|
await this.renderPdfPage(this.pdfCurrentPage);
|
|
}
|
|
},
|
|
|
|
// HTML 미리보기 로드
|
|
async loadHtmlPreview(documentId) {
|
|
this.htmlLoading = true;
|
|
|
|
try {
|
|
// HTML 내용 가져오기
|
|
const response = await fetch(`/api/documents/${documentId}/content`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const htmlContent = await response.text();
|
|
this.htmlSourceCode = this.escapeHtml(htmlContent);
|
|
|
|
// iframe에 HTML 로드
|
|
const iframe = document.getElementById('htmlPreviewFrame');
|
|
if (iframe) {
|
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
|
doc.open();
|
|
doc.write(htmlContent);
|
|
doc.close();
|
|
|
|
// 검색어 하이라이트 (iframe 내부)
|
|
if (this.searchQuery) {
|
|
this.highlightInIframe(iframe, this.searchQuery);
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error('HTML 로드 실패');
|
|
}
|
|
} catch (error) {
|
|
console.error('HTML 미리보기 로드 실패:', error);
|
|
} finally {
|
|
this.htmlLoading = false;
|
|
}
|
|
},
|
|
|
|
// HTML 소스/렌더링 모드 토글
|
|
toggleHtmlRaw() {
|
|
this.htmlRawMode = !this.htmlRawMode;
|
|
},
|
|
|
|
// iframe 내부 검색어 하이라이트
|
|
highlightInIframe(iframe, query) {
|
|
try {
|
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
|
const walker = doc.createTreeWalker(
|
|
doc.body,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
const textNodes = [];
|
|
let node;
|
|
while (node = walker.nextNode()) {
|
|
if (node.textContent.toLowerCase().includes(query.toLowerCase())) {
|
|
textNodes.push(node);
|
|
}
|
|
}
|
|
|
|
textNodes.forEach(textNode => {
|
|
const parent = textNode.parentNode;
|
|
const text = textNode.textContent;
|
|
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
|
|
const highlightedHTML = text.replace(regex, '<mark style="background: yellow; color: black;">$1</mark>');
|
|
|
|
const wrapper = doc.createElement('span');
|
|
wrapper.innerHTML = highlightedHTML;
|
|
parent.replaceChild(wrapper, textNode);
|
|
});
|
|
} catch (error) {
|
|
console.error('iframe 하이라이트 실패:', error);
|
|
}
|
|
},
|
|
|
|
// HTML 이스케이프
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
},
|
|
|
|
// 노트 편집기에서 열기
|
|
toggleNoteEdit() {
|
|
if (this.previewResult && this.previewResult.type === 'note') {
|
|
const url = `/note-editor.html?id=${this.previewResult.id}`;
|
|
window.open(url, '_blank');
|
|
}
|
|
},
|
|
|
|
// PDF에서 검색
|
|
async searchInPdf() {
|
|
if (!this.previewResult || !this.searchQuery) return;
|
|
|
|
try {
|
|
const searchResults = await this.api.get(
|
|
`/documents/${this.previewResult.document_id}/search-in-content?q=${encodeURIComponent(this.searchQuery)}`
|
|
);
|
|
|
|
if (searchResults.total_matches > 0) {
|
|
// 첫 번째 매치로 이동하여 뷰어에서 열기
|
|
const firstMatch = searchResults.matches[0];
|
|
let url = `/viewer.html?id=${this.previewResult.document_id}`;
|
|
|
|
if (firstMatch.page > 1) {
|
|
url += `&page=${firstMatch.page}`;
|
|
}
|
|
|
|
// 검색어 하이라이트를 위한 파라미터 추가
|
|
url += `&search=${encodeURIComponent(this.searchQuery)}`;
|
|
|
|
window.open(url, '_blank');
|
|
this.closePreview();
|
|
} else {
|
|
alert('PDF에서 검색 결과를 찾을 수 없습니다.');
|
|
}
|
|
} catch (error) {
|
|
console.error('PDF 검색 실패:', error);
|
|
alert('PDF 검색 중 오류가 발생했습니다.');
|
|
}
|
|
},
|
|
|
|
// 검색 결과 열기
|
|
openResult(result) {
|
|
console.log('📂 검색 결과 열기:', result);
|
|
|
|
let url = '';
|
|
|
|
switch (result.type) {
|
|
case 'document':
|
|
case 'document_content':
|
|
url = `/viewer.html?id=${result.document_id}`;
|
|
if (result.highlight_info) {
|
|
// 하이라이트 위치로 이동
|
|
const { start_offset, end_offset, selected_text } = result.highlight_info;
|
|
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
|
|
}
|
|
break;
|
|
|
|
case 'note':
|
|
url = `/viewer.html?id=${result.id}&contentType=note`;
|
|
break;
|
|
|
|
case 'memo':
|
|
// 메모 트리에서 해당 노드로 이동
|
|
url = `/memo-tree.html?node_id=${result.id}`;
|
|
break;
|
|
|
|
case 'highlight':
|
|
case 'highlight_note':
|
|
url = `/viewer.html?id=${result.document_id}`;
|
|
if (result.highlight_info) {
|
|
const { start_offset, end_offset, selected_text } = result.highlight_info;
|
|
url += `&highlight_text=${encodeURIComponent(selected_text)}&start_offset=${start_offset}&end_offset=${end_offset}`;
|
|
}
|
|
break;
|
|
|
|
default:
|
|
console.warn('알 수 없는 결과 타입:', result.type);
|
|
return;
|
|
}
|
|
|
|
// 새 탭에서 열기
|
|
window.open(url, '_blank');
|
|
},
|
|
|
|
// 타입별 결과 개수
|
|
getResultCount(type) {
|
|
return this.searchResults.filter(result => result.type === type).length;
|
|
},
|
|
|
|
// 타입 라벨
|
|
getTypeLabel(type) {
|
|
const labels = {
|
|
document: '문서',
|
|
document_content: '본문',
|
|
note: '노트',
|
|
memo: '메모',
|
|
highlight: '하이라이트',
|
|
highlight_note: '메모'
|
|
};
|
|
return labels[type] || type;
|
|
},
|
|
|
|
// 텍스트 하이라이트
|
|
highlightText(text, query) {
|
|
if (!text || !query) return text;
|
|
|
|
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
|
|
return text.replace(regex, '<span class="highlight-text">$1</span>');
|
|
},
|
|
|
|
// 정규식 이스케이프
|
|
escapeRegExp(string) {
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
},
|
|
|
|
// 텍스트 자르기
|
|
truncateText(text, maxLength) {
|
|
if (!text || text.length <= maxLength) return text;
|
|
return text.substring(0, maxLength) + '...';
|
|
},
|
|
|
|
// 날짜 포맷팅
|
|
formatDate(dateString) {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays === 1) {
|
|
return '오늘';
|
|
} else if (diffDays === 2) {
|
|
return '어제';
|
|
} else if (diffDays <= 7) {
|
|
return `${diffDays - 1}일 전`;
|
|
} else if (diffDays <= 30) {
|
|
return `${Math.ceil(diffDays / 7)}주 전`;
|
|
} else if (diffDays <= 365) {
|
|
return `${Math.ceil(diffDays / 30)}개월 전`;
|
|
} else {
|
|
return date.toLocaleDateString('ko-KR');
|
|
}
|
|
}
|
|
};
|
|
};
|
|
|
|
console.log('🔍 검색 JavaScript 로드 완료');
|