833 lines
32 KiB
HTML
833 lines
32 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>제품 이미지 관리 - yakkok 크롤러</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Noto Sans KR', sans-serif;
|
||
background: linear-gradient(135deg, #0f0f23 0%, #1a1a3e 50%, #2d1b4e 100%);
|
||
min-height: 100vh;
|
||
color: #e0e0e0;
|
||
}
|
||
|
||
.container {
|
||
max-width: 1400px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 24px;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 24px;
|
||
background: linear-gradient(135deg, #a855f7, #6366f1);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
}
|
||
|
||
.actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.btn {
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #8b5cf6, #6366f1);
|
||
color: white;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 15px rgba(139, 92, 246, 0.4);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: rgba(255,255,255,0.1);
|
||
color: #e0e0e0;
|
||
border: 1px solid rgba(255,255,255,0.2);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: rgba(255,255,255,0.2);
|
||
}
|
||
|
||
.btn-danger {
|
||
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||
color: white;
|
||
}
|
||
|
||
.stats-bar {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.stat-card {
|
||
background: rgba(255,255,255,0.05);
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 12px;
|
||
padding: 16px 24px;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.stat-card .value {
|
||
font-size: 28px;
|
||
font-weight: 700;
|
||
color: #a855f7;
|
||
}
|
||
|
||
.stat-card .label {
|
||
font-size: 12px;
|
||
color: #9ca3af;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.stat-card.success .value { color: #10b981; }
|
||
.stat-card.failed .value { color: #ef4444; }
|
||
.stat-card.pending .value { color: #f59e0b; }
|
||
|
||
.filters {
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.search-box {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
padding: 10px 16px;
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 8px;
|
||
color: white;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.search-box::placeholder { color: #6b7280; }
|
||
|
||
.filter-select {
|
||
padding: 10px 16px;
|
||
background: rgba(255,255,255,0.05);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 8px;
|
||
color: white;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.filter-select option { background: #1a1a3e; }
|
||
|
||
.image-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.image-card {
|
||
background: rgba(255,255,255,0.05);
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(255,255,255,0.1);
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.image-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.2);
|
||
}
|
||
|
||
.image-card .thumb {
|
||
width: 100%;
|
||
height: 150px;
|
||
background: rgba(0,0,0,0.3);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.image-card .thumb img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.image-card .thumb.no-image {
|
||
color: #6b7280;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.image-card .info {
|
||
padding: 12px;
|
||
}
|
||
|
||
.image-card .name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #e0e0e0;
|
||
margin-bottom: 4px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.image-card .barcode {
|
||
font-size: 11px;
|
||
color: #9ca3af;
|
||
font-family: monospace;
|
||
}
|
||
|
||
.image-card .status {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.status.success { background: rgba(16,185,129,0.2); color: #10b981; }
|
||
.status.failed { background: rgba(239,68,68,0.2); color: #ef4444; }
|
||
.status.pending { background: rgba(245,158,11,0.2); color: #f59e0b; }
|
||
.status.no_result { background: rgba(107,114,128,0.2); color: #9ca3af; }
|
||
.status.manual { background: rgba(59,130,246,0.2); color: #3b82f6; }
|
||
|
||
.image-card .actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.image-card .btn-sm {
|
||
padding: 4px 8px;
|
||
font-size: 11px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0,0,0,0.8);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.modal.show { display: flex; }
|
||
|
||
.modal-content {
|
||
background: #1a1a3e;
|
||
border-radius: 16px;
|
||
padding: 24px;
|
||
max-width: 500px;
|
||
width: 90%;
|
||
max-height: 80vh;
|
||
overflow: auto;
|
||
}
|
||
|
||
.modal-content h3 {
|
||
margin-bottom: 16px;
|
||
color: #a855f7;
|
||
}
|
||
|
||
.modal-content img {
|
||
max-width: 100%;
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.loading::after {
|
||
content: '';
|
||
display: inline-block;
|
||
width: 20px;
|
||
height: 20px;
|
||
border: 2px solid #a855f7;
|
||
border-top-color: transparent;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.toast {
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
padding: 12px 24px;
|
||
border-radius: 8px;
|
||
color: white;
|
||
font-weight: 500;
|
||
z-index: 2000;
|
||
animation: slideIn 0.3s ease;
|
||
}
|
||
|
||
.toast.success { background: linear-gradient(135deg, #10b981, #059669); }
|
||
.toast.error { background: linear-gradient(135deg, #ef4444, #dc2626); }
|
||
.toast.info { background: linear-gradient(135deg, #3b82f6, #2563eb); }
|
||
|
||
@keyframes slideIn {
|
||
from { transform: translateX(100%); opacity: 0; }
|
||
to { transform: translateX(0); opacity: 1; }
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.empty-state .icon {
|
||
font-size: 48px;
|
||
margin-bottom: 16px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<header>
|
||
<h1>🖼️ 제품 이미지 관리</h1>
|
||
<div class="actions">
|
||
<button class="btn btn-primary" onclick="crawlToday()">
|
||
🔄 오늘 판매 제품 크롤링
|
||
</button>
|
||
<button class="btn btn-secondary" onclick="openManualCrawl()">
|
||
➕ 수동 크롤링
|
||
</button>
|
||
<a href="/admin" class="btn btn-secondary">← 어드민</a>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="stats-bar" id="statsBar">
|
||
<div class="stat-card">
|
||
<div class="value" id="statTotal">-</div>
|
||
<div class="label">전체</div>
|
||
</div>
|
||
<div class="stat-card success">
|
||
<div class="value" id="statSuccess">-</div>
|
||
<div class="label">성공</div>
|
||
</div>
|
||
<div class="stat-card failed">
|
||
<div class="value" id="statFailed">-</div>
|
||
<div class="label">실패</div>
|
||
</div>
|
||
<div class="stat-card pending">
|
||
<div class="value" id="statPending">-</div>
|
||
<div class="label">대기</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<input type="text" class="search-box" id="searchBox"
|
||
placeholder="제품명 또는 바코드 검색..."
|
||
onkeyup="debounceSearch()">
|
||
<select class="filter-select" id="statusFilter" onchange="loadImages()">
|
||
<option value="">전체 상태</option>
|
||
<option value="success">성공</option>
|
||
<option value="failed">실패</option>
|
||
<option value="no_result">검색결과없음</option>
|
||
<option value="pending">대기</option>
|
||
<option value="manual">수동등록</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="image-grid" id="imageGrid">
|
||
<div class="loading">이미지 로딩 중...</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 수동 크롤링 모달 -->
|
||
<div class="modal" id="manualCrawlModal">
|
||
<div class="modal-content" style="max-width: 600px;">
|
||
<h3>➕ 제품 검색 & 크롤링</h3>
|
||
<p style="color: #9ca3af; margin-bottom: 16px; font-size: 13px;">
|
||
약국 DB에서 제품을 검색하고, 선택하면 yakkok.com에서 이미지를 가져옵니다
|
||
</p>
|
||
|
||
<!-- 검색 영역 -->
|
||
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
|
||
<input type="text" id="mssqlSearchInput" class="search-box" style="flex: 1;"
|
||
placeholder="제품명 검색 (예: 타이레놀, 센스큐탐...)"
|
||
onkeypress="if(event.key==='Enter') searchMssqlProducts()">
|
||
<button class="btn btn-primary" onclick="searchMssqlProducts()">검색</button>
|
||
</div>
|
||
|
||
<!-- 검색 결과 -->
|
||
<div id="mssqlSearchResults" style="max-height: 300px; overflow-y: auto; margin-bottom: 16px;">
|
||
<div style="color: #6b7280; text-align: center; padding: 20px;">
|
||
제품명을 검색하세요
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 선택된 제품 -->
|
||
<div id="selectedProduct" style="display: none; background: rgba(139,92,246,0.1); border: 1px solid rgba(139,92,246,0.3); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
||
<div style="font-size: 12px; color: #9ca3af; margin-bottom: 4px;">선택된 제품</div>
|
||
<div id="selectedProductName" style="font-weight: 600;"></div>
|
||
<div id="selectedProductBarcode" style="font-size: 12px; color: #a855f7; font-family: monospace;"></div>
|
||
</div>
|
||
|
||
<div style="text-align: right;">
|
||
<button class="btn btn-secondary" onclick="closeManualCrawl()">취소</button>
|
||
<button class="btn btn-primary" id="crawlSelectedBtn" onclick="crawlSelected()" disabled>🔄 크롤링 시작</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 이미지 교체 모달 -->
|
||
<div class="modal" id="replaceModal">
|
||
<div class="modal-content" style="max-width: 500px;">
|
||
<h3>🔄 이미지 교체</h3>
|
||
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
|
||
구글 이미지 등에서 찾은 URL을 붙여넣으세요
|
||
</p>
|
||
<div id="replaceProductInfo" style="background: rgba(139,92,246,0.1); border-radius: 8px; padding: 12px; margin-bottom: 16px;">
|
||
<div style="font-weight: 600;" id="replaceProductName"></div>
|
||
<div style="font-size: 12px; color: #a855f7; font-family: monospace;" id="replaceBarcode"></div>
|
||
</div>
|
||
<div style="margin-bottom: 16px;">
|
||
<label style="display: block; margin-bottom: 4px; font-size: 13px;">이미지 URL *</label>
|
||
<input type="text" id="replaceImageUrl" class="search-box" style="width: 100%;"
|
||
placeholder="https://example.com/image.jpg">
|
||
<div style="font-size: 11px; color: #6b7280; margin-top: 4px;">
|
||
💡 이미지 우클릭 → "이미지 주소 복사"로 URL을 가져오세요
|
||
</div>
|
||
</div>
|
||
<div style="text-align: right;">
|
||
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
|
||
<button class="btn btn-primary" onclick="submitReplace()">교체하기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 상세 모달 -->
|
||
<div class="modal" id="detailModal">
|
||
<div class="modal-content">
|
||
<h3 id="modalTitle">제품 상세</h3>
|
||
<img id="modalImage" src="" alt="">
|
||
<div id="modalInfo"></div>
|
||
<div style="margin-top: 16px; text-align: right;">
|
||
<button class="btn btn-secondary" onclick="closeModal()">닫기</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let debounceTimer;
|
||
|
||
// 초기 로드
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
loadStats();
|
||
loadImages();
|
||
});
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const res = await fetch('/api/admin/product-images/stats');
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
document.getElementById('statTotal').textContent = data.total || 0;
|
||
document.getElementById('statSuccess').textContent = data.stats.success || 0;
|
||
document.getElementById('statFailed').textContent =
|
||
(data.stats.failed || 0) + (data.stats.no_result || 0);
|
||
document.getElementById('statPending').textContent = data.stats.pending || 0;
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
}
|
||
}
|
||
|
||
async function loadImages() {
|
||
const grid = document.getElementById('imageGrid');
|
||
const search = document.getElementById('searchBox').value;
|
||
const status = document.getElementById('statusFilter').value;
|
||
|
||
grid.innerHTML = '<div class="loading">이미지 로딩 중...</div>';
|
||
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (search) params.append('search', search);
|
||
if (status) params.append('status', status);
|
||
params.append('limit', 100);
|
||
|
||
const res = await fetch(`/api/admin/product-images?${params}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success && data.items.length > 0) {
|
||
grid.innerHTML = data.items.map(item => `
|
||
<div class="image-card" data-barcode="${item.barcode}">
|
||
<div class="thumb ${item.thumbnail_base64 ? '' : 'no-image'}">
|
||
${item.thumbnail_base64
|
||
? `<img src="data:image/jpeg;base64,${item.thumbnail_base64}" alt="${item.product_name}">`
|
||
: '이미지 없음'}
|
||
</div>
|
||
<div class="info">
|
||
<div class="name" title="${item.product_name}">${item.product_name}</div>
|
||
<div class="barcode">${item.barcode}</div>
|
||
<span class="status ${item.status}">${getStatusText(item.status)}</span>
|
||
<div class="actions">
|
||
<button class="btn btn-secondary btn-sm" onclick="viewDetail('${item.barcode}')">상세</button>
|
||
<button class="btn btn-primary btn-sm" data-barcode="${item.barcode}" data-name="${item.product_name || ''}" onclick="openReplaceModal(this.dataset.barcode, this.dataset.name)">교체</button>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteImage('${item.barcode}')">삭제</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
grid.innerHTML = `
|
||
<div class="empty-state" style="grid-column: 1/-1;">
|
||
<div class="icon">📷</div>
|
||
<div>등록된 이미지가 없습니다</div>
|
||
<div style="margin-top: 8px; font-size: 13px;">
|
||
"오늘 판매 제품 크롤링" 버튼을 눌러 시작하세요
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
} catch (err) {
|
||
grid.innerHTML = `<div class="empty-state" style="grid-column: 1/-1;">오류: ${err.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function getStatusText(status) {
|
||
const map = {
|
||
'success': '성공',
|
||
'failed': '실패',
|
||
'pending': '대기',
|
||
'no_result': '검색없음',
|
||
'manual': '수동등록'
|
||
};
|
||
return map[status] || status;
|
||
}
|
||
|
||
function debounceSearch() {
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(loadImages, 300);
|
||
}
|
||
|
||
async function crawlToday() {
|
||
if (!confirm('오늘 판매된 제품 이미지를 크롤링합니다. 진행할까요?')) return;
|
||
|
||
showToast('크롤링 시작... 잠시 기다려주세요', 'info');
|
||
|
||
try {
|
||
const res = await fetch('/api/admin/product-images/crawl-today', {
|
||
method: 'POST'
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
const r = data.result;
|
||
showToast(`완료! 성공: ${r.success}, 실패: ${r.failed}, 스킵: ${r.skipped}`, 'success');
|
||
loadStats();
|
||
loadImages();
|
||
} else {
|
||
showToast(data.error || '크롤링 실패', 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('오류: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function viewDetail(barcode) {
|
||
try {
|
||
const res = await fetch(`/api/admin/product-images/${barcode}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
const img = data.image;
|
||
document.getElementById('modalTitle').textContent = img.product_name;
|
||
document.getElementById('modalImage').src = img.image_base64
|
||
? `data:image/jpeg;base64,${img.image_base64}`
|
||
: '';
|
||
document.getElementById('modalImage').style.display = img.image_base64 ? 'block' : 'none';
|
||
document.getElementById('modalInfo').innerHTML = `
|
||
<p><strong>바코드:</strong> ${img.barcode}</p>
|
||
<p><strong>DrugCode:</strong> ${img.drug_code || '-'}</p>
|
||
<p><strong>검색어:</strong> ${img.search_name || '-'}</p>
|
||
<p><strong>상태:</strong> ${getStatusText(img.status)}</p>
|
||
<p><strong>원본 URL:</strong> <a href="${img.image_url}" target="_blank" style="color:#a855f7;">${img.image_url || '-'}</a></p>
|
||
<p><strong>등록일:</strong> ${img.created_at}</p>
|
||
${img.error_message ? `<p><strong>에러:</strong> ${img.error_message}</p>` : ''}
|
||
`;
|
||
document.getElementById('detailModal').classList.add('show');
|
||
}
|
||
} catch (err) {
|
||
showToast('상세 조회 실패', 'error');
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('detailModal').classList.remove('show');
|
||
}
|
||
|
||
async function deleteImage(barcode) {
|
||
if (!confirm('이 이미지를 삭제할까요?')) return;
|
||
|
||
try {
|
||
const res = await fetch(`/api/admin/product-images/${barcode}`, {
|
||
method: 'DELETE'
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
showToast('삭제 완료', 'success');
|
||
loadStats();
|
||
loadImages();
|
||
} else {
|
||
showToast(data.error || '삭제 실패', 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('오류: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
function showToast(message, type = 'info') {
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
toast.textContent = message;
|
||
document.body.appendChild(toast);
|
||
|
||
setTimeout(() => toast.remove(), 4000);
|
||
}
|
||
|
||
// 수동 크롤링
|
||
let selectedProductData = null;
|
||
|
||
function openManualCrawl() {
|
||
selectedProductData = null;
|
||
document.getElementById('mssqlSearchInput').value = '';
|
||
document.getElementById('mssqlSearchResults').innerHTML = '<div style="color: #6b7280; text-align: center; padding: 20px;">제품명을 검색하세요</div>';
|
||
document.getElementById('selectedProduct').style.display = 'none';
|
||
document.getElementById('crawlSelectedBtn').disabled = true;
|
||
document.getElementById('manualCrawlModal').classList.add('show');
|
||
document.getElementById('mssqlSearchInput').focus();
|
||
}
|
||
|
||
function closeManualCrawl() {
|
||
document.getElementById('manualCrawlModal').classList.remove('show');
|
||
}
|
||
|
||
async function searchMssqlProducts() {
|
||
const query = document.getElementById('mssqlSearchInput').value.trim();
|
||
if (!query) {
|
||
showToast('검색어를 입력하세요', 'error');
|
||
return;
|
||
}
|
||
|
||
const resultsDiv = document.getElementById('mssqlSearchResults');
|
||
resultsDiv.innerHTML = '<div style="color: #9ca3af; text-align: center; padding: 20px;">검색 중...</div>';
|
||
|
||
try {
|
||
// OTC 라벨 검색 API 재사용
|
||
const res = await fetch(`/api/admin/otc-labels/search-mssql?q=${encodeURIComponent(query)}`);
|
||
const data = await res.json();
|
||
|
||
if (data.success && data.drugs.length > 0) {
|
||
resultsDiv.innerHTML = data.drugs.map(drug => `
|
||
<div class="search-result-item" onclick="selectProduct('${drug.barcode}', '${drug.drug_code}', '${escapeHtml(drug.goods_name)}')"
|
||
style="padding: 12px; border-bottom: 1px solid rgba(255,255,255,0.1); cursor: pointer; transition: background 0.2s;"
|
||
onmouseover="this.style.background='rgba(139,92,246,0.1)'"
|
||
onmouseout="this.style.background='transparent'">
|
||
<div style="font-weight: 500;">${drug.goods_name}</div>
|
||
<div style="font-size: 12px; color: #9ca3af;">
|
||
<span style="color: #a855f7; font-family: monospace;">${drug.barcode || '바코드없음'}</span>
|
||
${drug.sale_price ? ` · ₩${Math.floor(drug.sale_price).toLocaleString()}` : ''}
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
} else {
|
||
resultsDiv.innerHTML = '<div style="color: #6b7280; text-align: center; padding: 20px;">검색 결과가 없습니다</div>';
|
||
}
|
||
} catch (err) {
|
||
resultsDiv.innerHTML = `<div style="color: #ef4444; text-align: center; padding: 20px;">오류: ${err.message}</div>`;
|
||
}
|
||
}
|
||
|
||
function selectProduct(barcode, drugCode, productName) {
|
||
if (!barcode) {
|
||
showToast('바코드가 없는 제품은 크롤링할 수 없습니다', 'error');
|
||
return;
|
||
}
|
||
|
||
selectedProductData = { barcode, drugCode, productName };
|
||
document.getElementById('selectedProductName').textContent = productName;
|
||
document.getElementById('selectedProductBarcode').textContent = barcode;
|
||
document.getElementById('selectedProduct').style.display = 'block';
|
||
document.getElementById('crawlSelectedBtn').disabled = false;
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
if (!str) return '';
|
||
return str.replace(/'/g, "\\'").replace(/"/g, '"');
|
||
}
|
||
|
||
async function crawlSelected() {
|
||
if (!selectedProductData) {
|
||
showToast('제품을 선택하세요', 'error');
|
||
return;
|
||
}
|
||
|
||
const { barcode, drugCode, productName } = selectedProductData;
|
||
|
||
closeManualCrawl();
|
||
showToast(`"${productName}" 크롤링 시작...`, 'info');
|
||
|
||
try {
|
||
const res = await fetch('/api/admin/product-images/crawl', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
products: [[barcode, drugCode || null, productName]]
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
const r = data.result;
|
||
if (r.success > 0) {
|
||
showToast(`✅ "${productName}" 이미지 수집 성공!`, 'success');
|
||
} else if (r.skipped > 0) {
|
||
showToast(`이미 등록된 바코드입니다`, 'info');
|
||
} else {
|
||
showToast(`❌ 이미지를 찾지 못했습니다`, 'error');
|
||
}
|
||
loadStats();
|
||
loadImages();
|
||
} else {
|
||
showToast(data.error || '크롤링 실패', 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('오류: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// 이미지 교체
|
||
let replaceTargetBarcode = null;
|
||
|
||
function openReplaceModal(barcode, productName) {
|
||
console.log('openReplaceModal called with:', barcode, productName);
|
||
|
||
if (!barcode || barcode === 'null' || barcode === 'undefined') {
|
||
showToast('바코드 정보가 없습니다', 'error');
|
||
return;
|
||
}
|
||
|
||
replaceTargetBarcode = barcode;
|
||
document.getElementById('replaceProductName').textContent = productName || barcode;
|
||
document.getElementById('replaceBarcode').textContent = barcode;
|
||
document.getElementById('replaceImageUrl').value = '';
|
||
document.getElementById('replaceModal').classList.add('show');
|
||
document.getElementById('replaceImageUrl').focus();
|
||
}
|
||
|
||
function closeReplaceModal() {
|
||
document.getElementById('replaceModal').classList.remove('show');
|
||
replaceTargetBarcode = null;
|
||
}
|
||
|
||
async function submitReplace() {
|
||
const imageUrl = document.getElementById('replaceImageUrl').value.trim();
|
||
|
||
if (!imageUrl) {
|
||
showToast('이미지 URL을 입력하세요', 'error');
|
||
return;
|
||
}
|
||
|
||
if (!imageUrl.startsWith('http')) {
|
||
showToast('올바른 URL을 입력하세요 (http:// 또는 https://)', 'error');
|
||
return;
|
||
}
|
||
|
||
// 바코드 검증
|
||
if (!replaceTargetBarcode || replaceTargetBarcode === 'null' || replaceTargetBarcode === 'undefined') {
|
||
showToast('바코드 정보가 없습니다. 다시 시도해주세요.', 'error');
|
||
return;
|
||
}
|
||
|
||
const barcode = replaceTargetBarcode;
|
||
closeReplaceModal();
|
||
showToast(`"${barcode}" 이미지 다운로드 중...`, 'info');
|
||
|
||
try {
|
||
const res = await fetch(`/api/admin/product-images/${barcode}/replace`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ image_url: imageUrl })
|
||
});
|
||
const data = await res.json();
|
||
|
||
if (data.success) {
|
||
showToast('✅ 이미지 교체 완료!', 'success');
|
||
loadStats();
|
||
loadImages();
|
||
} else {
|
||
showToast(data.error || '교체 실패', 'error');
|
||
}
|
||
} catch (err) {
|
||
showToast('오류: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// ESC로 모달 닫기
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Escape') {
|
||
closeModal();
|
||
closeManualCrawl();
|
||
closeReplaceModal();
|
||
}
|
||
});
|
||
|
||
// 모달 외부 클릭으로 닫기
|
||
document.getElementById('detailModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'detailModal') closeModal();
|
||
});
|
||
|
||
document.getElementById('manualCrawlModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'manualCrawlModal') closeManualCrawl();
|
||
});
|
||
|
||
document.getElementById('replaceModal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'replaceModal') closeReplaceModal();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|