pharmacy-pos-qr-system/backend/templates/admin_product_images.html
thug0bin 29648e3a7d feat: yakkok.com 제품 이미지 크롤러 + 어드민 페이지
크롤러 (utils/yakkok_crawler.py):
- yakkok.com에서 제품 검색 및 이미지 추출
- MSSQL 오늘 판매 품목 자동 조회
- base64 변환 후 SQLite 저장
- CLI 지원 (--today, --product)

DB (product_images.db):
- 바코드, 제품명, 이미지(base64), 상태 저장
- 크롤링 로그 테이블

어드민 페이지 (/admin/product-images):
- 이미지 목록/검색/필터
- 통계 (성공/실패/대기)
- 상세 보기/삭제
- 오늘 판매 제품 일괄 크롤링

API:
- GET /api/admin/product-images
- GET /api/admin/product-images/<barcode>
- POST /api/admin/product-images/crawl-today
- DELETE /api/admin/product-images/<barcode>
2026-03-02 23:19:52 +09:00

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