pharmacy-pos-qr-system/backend/templates/admin_product_images.html
thug0bin ee28f97c11 feat: 제품 이미지 수동 크롤링 - MSSQL 검색 인터페이스 추가
- OTC 라벨처럼 제품명 검색 → 선택 → 크롤링
- 바코드 직접 입력 불필요
- MSSQL 검색 API 재사용
2026-03-02 23:37:31 +09:00

734 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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="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-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, '&quot;');
}
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');
}
}
// ESC로 모달 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeModal();
closeManualCrawl();
}
});
// 모달 외부 클릭으로 닫기
document.getElementById('detailModal').addEventListener('click', (e) => {
if (e.target.id === 'detailModal') closeModal();
});
document.getElementById('manualCrawlModal').addEventListener('click', (e) => {
if (e.target.id === 'manualCrawlModal') closeManualCrawl();
});
</script>
</body>
</html>