pharmacy-pos-qr-system/backend/templates/admin_product_images.html
thug0bin acf8e44aa5 fix: 이미지 상태 필터와 통계 일관성 수정
- '실패' 필터 선택 시 failed + no_result 둘 다 검색되도록 수정
- 통계 라벨: '실패' → '실패/없음'
- 필터 옵션: '실패' → '실패/검색없음', 별도 'no_result' 옵션 제거
- 상단 통계와 필터 결과가 일치하도록 UX 개선
2026-03-04 10:25:53 +09:00

1084 lines
43 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; }
}
/* 탭 스타일 */
.tab-btn {
flex: 1;
padding: 10px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #9ca3af;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
}
.tab-btn:hover {
background: rgba(255,255,255,0.1);
}
.tab-btn.active {
background: linear-gradient(135deg, #8b5cf6, #6366f1);
color: white;
border-color: transparent;
}
.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">
<input type="date" id="crawlDate" class="filter-select" style="padding: 8px 12px;">
<button class="btn btn-primary" onclick="crawlByDate()">
🔄 해당일 판매 제품 크롤링
</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="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>
<!-- 이미지 교체 모달 (탭 방식: URL / 촬영) -->
<div class="modal" id="replaceModal">
<div class="modal-content" style="max-width: 500px;">
<h3>🔄 이미지 교체</h3>
<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 class="replace-tabs" style="display: flex; gap: 8px; margin-bottom: 16px;">
<button class="tab-btn active" onclick="switchReplaceTab('url')" id="tabBtnUrl">
🔗 URL 입력
</button>
<button class="tab-btn" onclick="switchReplaceTab('camera')" id="tabBtnCamera">
📷 촬영
</button>
</div>
<!-- URL 입력 탭 -->
<div id="tabUrl" class="tab-content">
<p style="color: #9ca3af; margin-bottom: 8px; font-size: 13px;">
구글 이미지 등에서 찾은 URL을 붙여넣으세요
</p>
<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 id="tabCamera" class="tab-content" style="display: none;">
<!-- 카메라 뷰 -->
<div id="cameraContainer" style="position: relative; width: 100%; aspect-ratio: 1; background: #000; border-radius: 8px; overflow: hidden; margin-bottom: 12px;">
<video id="cameraVideo" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover;"></video>
<!-- 1:1 가이드 오버레이 -->
<div id="cameraGuide" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none;">
<svg width="100%" height="100%" viewBox="0 0 100 100" preserveAspectRatio="none">
<!-- 반투명 외곽 -->
<rect x="0" y="0" width="100" height="100" fill="rgba(0,0,0,0.4)"/>
<!-- 중앙 투명 영역 -->
<rect x="10" y="10" width="80" height="80" fill="transparent" stroke="#a855f7" stroke-width="0.5" stroke-dasharray="2,2"/>
<!-- 모서리 강조 -->
<path d="M10,20 L10,10 L20,10" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M80,10 L90,10 L90,20" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M90,80 L90,90 L80,90" fill="none" stroke="#a855f7" stroke-width="1"/>
<path d="M20,90 L10,90 L10,80" fill="none" stroke="#a855f7" stroke-width="1"/>
</svg>
</div>
<!-- 촬영된 이미지 미리보기 -->
<canvas id="captureCanvas" style="display: none; position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></canvas>
</div>
<p style="color: #9ca3af; font-size: 12px; text-align: center; margin-bottom: 12px;">
📦 제품을 보라색 가이드 안에 맞춰주세요
</p>
<!-- 버튼들 -->
<div id="cameraButtons" style="display: flex; gap: 8px; justify-content: center;">
<button class="btn btn-secondary" onclick="closeReplaceModal()">취소</button>
<button class="btn btn-primary" id="captureBtn" onclick="capturePhoto()">📸 촬영</button>
</div>
<div id="previewButtons" style="display: none; gap: 8px; justify-content: center;">
<button class="btn btn-secondary" onclick="retakePhoto()">↩️ 다시 촬영</button>
<button class="btn btn-primary" onclick="submitCapture()">✅ 저장</button>
</div>
</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', () => {
// 오늘 날짜로 기본값 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('crawlDate').value = today;
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 crawlByDate() {
const dateInput = document.getElementById('crawlDate').value;
if (!dateInput) {
showToast('날짜를 선택하세요', 'error');
return;
}
const dateStr = dateInput.replace(/-/g, '');
const displayDate = `${dateStr.slice(4,6)}/${dateStr.slice(6,8)}`;
if (!confirm(`${displayDate} 판매 제품 이미지를 크롤링합니다. 진행할까요?`)) return;
showToast(`${displayDate} 크롤링 시작... 잠시 기다려주세요`, 'info');
try {
const res = await fetch('/api/admin/product-images/crawl-today', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: dateInput })
});
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');
}
}
// 이미지 교체
let replaceTargetBarcode = null;
let replaceTargetName = null;
let cameraStream = null;
let capturedImageData = null;
function openReplaceModal(barcode, productName) {
console.log('openReplaceModal called with:', barcode, productName);
if (!barcode || barcode === 'null' || barcode === 'undefined') {
showToast('바코드 정보가 없습니다', 'error');
return;
}
replaceTargetBarcode = barcode;
replaceTargetName = productName || barcode;
document.getElementById('replaceProductName').textContent = replaceTargetName;
document.getElementById('replaceBarcode').textContent = barcode;
document.getElementById('replaceImageUrl').value = '';
// 탭 초기화 (URL 탭으로)
switchReplaceTab('url');
document.getElementById('replaceModal').classList.add('show');
document.getElementById('replaceImageUrl').focus();
}
function closeReplaceModal() {
stopCamera();
document.getElementById('replaceModal').classList.remove('show');
replaceTargetBarcode = null;
replaceTargetName = null;
capturedImageData = null;
}
function switchReplaceTab(tab) {
// 탭 버튼 활성화
document.getElementById('tabBtnUrl').classList.toggle('active', tab === 'url');
document.getElementById('tabBtnCamera').classList.toggle('active', tab === 'camera');
// 탭 콘텐츠 표시
document.getElementById('tabUrl').style.display = tab === 'url' ? 'block' : 'none';
document.getElementById('tabCamera').style.display = tab === 'camera' ? 'block' : 'none';
// 카메라 탭이면 카메라 시작
if (tab === 'camera') {
startCamera();
} else {
stopCamera();
}
}
async function startCamera() {
try {
// 이전 스트림 정리
stopCamera();
// 후면 카메라 우선 (모바일)
const constraints = {
video: {
facingMode: { ideal: 'environment' }, // 후면 카메라
width: { ideal: 1920 },
height: { ideal: 1920 }
},
audio: false
};
cameraStream = await navigator.mediaDevices.getUserMedia(constraints);
const video = document.getElementById('cameraVideo');
video.srcObject = cameraStream;
video.style.display = 'block';
// 캡처 상태 초기화
document.getElementById('captureCanvas').style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('cameraButtons').style.display = 'flex';
document.getElementById('previewButtons').style.display = 'none';
capturedImageData = null;
} catch (err) {
console.error('카메라 접근 오류:', err);
showToast('카메라에 접근할 수 없습니다: ' + err.message, 'error');
}
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(track => track.stop());
cameraStream = null;
}
const video = document.getElementById('cameraVideo');
video.srcObject = null;
}
function capturePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
const ctx = canvas.getContext('2d');
// 비디오 크기
const vw = video.videoWidth;
const vh = video.videoHeight;
// 1:1 영역 계산 (가이드 박스: 80% 영역)
const minDim = Math.min(vw, vh);
const cropSize = minDim * 0.8;
const sx = (vw - cropSize) / 2;
const sy = (vh - cropSize) / 2;
// 캔버스를 800x800으로 설정 (최종 해상도)
canvas.width = 800;
canvas.height = 800;
// 크롭하여 그리기
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
// base64 저장
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
// UI 전환 (미리보기 모드)
video.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('cameraGuide').style.display = 'none';
document.getElementById('cameraButtons').style.display = 'none';
document.getElementById('previewButtons').style.display = 'flex';
// 카메라 스트림은 유지 (다시 촬영 위해)
}
function retakePhoto() {
const video = document.getElementById('cameraVideo');
const canvas = document.getElementById('captureCanvas');
// UI 전환 (카메라 모드)
video.style.display = 'block';
canvas.style.display = 'none';
document.getElementById('cameraGuide').style.display = 'block';
document.getElementById('cameraButtons').style.display = 'flex';
document.getElementById('previewButtons').style.display = 'none';
capturedImageData = null;
}
async function submitCapture() {
if (!capturedImageData) {
showToast('촬영된 이미지가 없습니다', 'error');
return;
}
if (!replaceTargetBarcode) {
showToast('바코드 정보가 없습니다', 'error');
return;
}
// 모달 닫기 전에 값 복사 (closeReplaceModal에서 null로 리셋되므로)
const barcode = replaceTargetBarcode;
const productName = replaceTargetName;
const imageData = capturedImageData;
closeReplaceModal();
showToast(`"${productName}" 이미지 저장 중...`, 'info');
try {
const res = await fetch(`/api/admin/product-images/${barcode}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
image_data: imageData,
product_name: productName
})
});
const data = await res.json();
if (data.success) {
showToast('✅ 촬영 이미지 저장 완료!', 'success');
loadStats();
loadImages();
} else {
showToast(data.error || '저장 실패', 'error');
}
} catch (err) {
showToast('오류: ' + err.message, 'error');
}
}
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>