feat: 제품 검색 페이지에 제품 이미지 표시 및 등록 기능 추가

- API에 thumbnail 반환 추가 (product_images.db 조회)
- 테이블에 이미지 컬럼 추가 (40x40 썸네일)
- 이미지/플레이스홀더 클릭 → 등록 모달 (URL/촬영)
- 판매내역과 동일한 UX
This commit is contained in:
thug0bin 2026-03-04 14:15:29 +09:00
parent 2859dc43cc
commit 01f0df9294
2 changed files with 369 additions and 6 deletions

View File

@ -3447,7 +3447,8 @@ def api_products():
'stock': int(row.stock) if row.stock else 0,
'apc': apc,
'category': None, # PostgreSQL에서 lazy fetch
'wholesaler_stock': None
'wholesaler_stock': None,
'thumbnail': None # 아래에서 채움
})
# 동물약 분류 Lazy Fetch (PostgreSQL) - 실패해도 무시
@ -3482,6 +3483,47 @@ def api_products():
logging.warning(f"PostgreSQL 분류 조회 실패 (무시): {pg_err}")
# PostgreSQL 실패해도 MSSQL 데이터는 정상 반환
# 제품 이미지 조회 (product_images.db)
try:
images_db_path = Path(__file__).parent / 'db' / 'product_images.db'
if images_db_path.exists():
img_conn = sqlite3.connect(str(images_db_path))
img_cursor = img_conn.cursor()
barcodes = [item['barcode'] for item in items if item['barcode']]
drug_codes = [item['drug_code'] for item in items if item['drug_code']]
image_map = {}
if barcodes:
placeholders = ','.join(['?' for _ in barcodes])
img_cursor.execute(f'''
SELECT barcode, thumbnail_base64
FROM product_images
WHERE barcode IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
''', barcodes)
for row in img_cursor.fetchall():
image_map[f'bc:{row[0]}'] = row[1]
if drug_codes:
placeholders = ','.join(['?' for _ in drug_codes])
img_cursor.execute(f'''
SELECT drug_code, thumbnail_base64
FROM product_images
WHERE drug_code IN ({placeholders}) AND thumbnail_base64 IS NOT NULL
''', drug_codes)
for row in img_cursor.fetchall():
if f'dc:{row[0]}' not in image_map:
image_map[f'dc:{row[0]}'] = row[1]
img_conn.close()
for item in items:
thumb = image_map.get(f'bc:{item["barcode"]}') or image_map.get(f'dc:{item["drug_code"]}')
if thumb:
item['thumbnail'] = thumb
except Exception as img_err:
logging.warning(f"제품 이미지 조회 오류: {img_err}")
return jsonify({
'success': True,
'items': items,

View File

@ -547,6 +547,129 @@
.modal-btn.confirm { background: #8b5cf6; color: #fff; }
.modal-btn.confirm:hover { background: #7c3aed; }
/* ── 제품 이미지 ── */
.product-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.product-thumb:hover {
transform: scale(1.15);
box-shadow: 0 4px 12px rgba(139,92,246,0.3);
}
.product-thumb-placeholder {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%);
border-radius: 8px;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s;
border: 1px solid #e2e8f0;
margin: 0 auto;
}
.product-thumb-placeholder:hover {
transform: scale(1.15);
border-color: #8b5cf6;
}
.product-thumb-placeholder svg {
width: 20px;
height: 20px;
fill: #94a3b8;
}
/* ── 이미지 모달 ── */
.image-modal {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 2000;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
}
.image-modal.show { display: flex; }
.image-modal-content {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 420px;
width: 90%;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.image-modal-content h3 {
margin: 0 0 16px 0;
color: #7c3aed;
font-size: 18px;
}
.image-modal-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.img-tab-btn {
flex: 1;
padding: 10px;
border: 1px solid #e2e8f0;
background: #fff;
color: #64748b;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
}
.img-tab-btn.active {
background: #8b5cf6;
color: #fff;
border-color: #8b5cf6;
}
.img-tab-content { display: none; }
.img-tab-content.active { display: block; }
.img-input {
width: 100%;
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: 12px;
}
.img-input:focus {
outline: none;
border-color: #8b5cf6;
}
.camera-box {
position: relative;
width: 100%;
aspect-ratio: 1;
background: #000;
border-radius: 8px;
overflow: hidden;
margin-bottom: 12px;
}
.camera-box video, .camera-box canvas {
width: 100%;
height: 100%;
object-fit: cover;
}
.img-modal-btns {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.img-modal-btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
}
.img-modal-btn.secondary { background: #f1f5f9; color: #64748b; }
.img-modal-btn.primary { background: #8b5cf6; color: #fff; }
/* ── 반응형 ── */
@media (max-width: 768px) {
.search-box { flex-direction: column; }
@ -607,6 +730,7 @@
<table>
<thead>
<tr>
<th style="width:50px;">이미지</th>
<th>상품명</th>
<th>상품코드</th>
<th>바코드</th>
@ -617,7 +741,7 @@
</thead>
<tbody id="productsTableBody">
<tr>
<td colspan="6" class="empty-state">
<td colspan="7" class="empty-state">
<div class="icon">🔍</div>
<p>상품명, 바코드, 상품코드로 검색하세요</p>
</td>
@ -710,7 +834,7 @@
}
const tbody = document.getElementById('productsTableBody');
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><p>검색 중...</p></td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><p>검색 중...</p></td></tr>';
const inStockOnly = document.getElementById('inStockOnly').checked;
let url = `/api/products?search=${encodeURIComponent(search)}`;
@ -725,11 +849,11 @@
document.getElementById('resultNum').textContent = productsData.length;
renderTable();
} else {
tbody.innerHTML = `<tr><td colspan="6" class="empty-state"><p>오류: ${data.error}</p></td></tr>`;
tbody.innerHTML = `<tr><td colspan="7" class="empty-state"><p>오류: ${data.error}</p></td></tr>`;
}
})
.catch(err => {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><p>검색 실패</p></td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><p>검색 실패</p></td></tr>';
});
}
@ -737,7 +861,7 @@
const tbody = document.getElementById('productsTableBody');
if (productsData.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="icon">📭</div><p>검색 결과가 없습니다</p></td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="empty-state"><div class="icon">📭</div><p>검색 결과가 없습니다</p></td></tr>';
return;
}
@ -753,6 +877,12 @@
return `
<tr>
<td style="text-align:center;">
${item.thumbnail
? `<img src="data:image/jpeg;base64,${item.thumbnail}" class="product-thumb" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')" alt="">`
: `<div class="product-thumb-placeholder" onclick="openImageModal('${item.barcode || ''}', '${item.drug_code || ''}', '${escapeHtml(item.product_name)}')"><svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-7l-3 3.72L9 13l-3 4h12l-4-5z"/></svg></div>`
}
</td>
<td>
<div class="product-name">
${escapeHtml(item.product_name)}
@ -1069,6 +1199,197 @@
document.getElementById('chatbotPanel').classList.remove('open');
}
}
// ── 이미지 등록 모달 ──
let imgModalBarcode = null;
let imgModalDrugCode = null;
let imgModalName = null;
let cameraStream = null;
let capturedImageData = null;
function openImageModal(barcode, drugCode, productName) {
if (!barcode && !drugCode) {
alert('제품 코드 정보가 없습니다');
return;
}
imgModalBarcode = barcode || null;
imgModalDrugCode = drugCode || null;
imgModalName = productName || (barcode || drugCode);
document.getElementById('imgModalProductName').textContent = imgModalName;
document.getElementById('imgModalCode').textContent = barcode || drugCode;
document.getElementById('imgUrlInput').value = '';
switchImageTab('url');
document.getElementById('imageModal').classList.add('show');
document.getElementById('imgUrlInput').focus();
}
function closeImageModal() {
stopCamera();
document.getElementById('imageModal').classList.remove('show');
imgModalBarcode = null;
imgModalDrugCode = null;
imgModalName = null;
capturedImageData = null;
}
function switchImageTab(tab) {
document.querySelectorAll('.img-tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tab));
document.querySelectorAll('.img-tab-content').forEach(c => c.classList.toggle('active', c.id === 'imgTab' + tab.charAt(0).toUpperCase() + tab.slice(1)));
if (tab === 'camera') startCamera(); else stopCamera();
}
async function startCamera() {
try {
stopCamera();
cameraStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1920 } },
audio: false
});
const video = document.getElementById('camVideo');
video.srcObject = cameraStream;
video.style.display = 'block';
document.getElementById('camCanvas').style.display = 'none';
document.getElementById('camGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'flex';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
} catch (err) {
alert('카메라에 접근할 수 없습니다');
}
}
function stopCamera() {
if (cameraStream) {
cameraStream.getTracks().forEach(t => t.stop());
cameraStream = null;
}
const video = document.getElementById('camVideo');
if (video) video.srcObject = null;
}
function capturePhoto() {
const video = document.getElementById('camVideo');
const canvas = document.getElementById('camCanvas');
const ctx = canvas.getContext('2d');
const vw = video.videoWidth, vh = video.videoHeight;
const minDim = Math.min(vw, vh);
const cropSize = minDim * 0.8;
const sx = (vw - cropSize) / 2, sy = (vh - cropSize) / 2;
canvas.width = 800; canvas.height = 800;
ctx.drawImage(video, sx, sy, cropSize, cropSize, 0, 0, 800, 800);
capturedImageData = canvas.toDataURL('image/jpeg', 0.92);
video.style.display = 'none';
canvas.style.display = 'block';
document.getElementById('camGuide').style.display = 'none';
document.getElementById('captureBtn').style.display = 'none';
document.getElementById('previewBtns').style.display = 'flex';
}
function retakePhoto() {
document.getElementById('camVideo').style.display = 'block';
document.getElementById('camCanvas').style.display = 'none';
document.getElementById('camGuide').style.display = 'block';
document.getElementById('captureBtn').style.display = 'flex';
document.getElementById('previewBtns').style.display = 'none';
capturedImageData = null;
}
async function submitCapturedImage() {
if (!capturedImageData) { alert('촬영된 이미지가 없습니다'); return; }
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" 이미지 저장 중...`);
try {
const res = await fetch(`/api/admin/product-images/${code}/upload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_data: capturedImageData, product_name: name, drug_code: imgModalDrugCode })
});
const data = await res.json();
if (data.success) { showToast('✅ 이미지 저장 완료!', 'success'); searchProducts(); }
else showToast(data.error || '저장 실패', 'error');
} catch (err) { showToast('오류: ' + err.message, 'error'); }
}
async function submitImageUrl() {
const url = document.getElementById('imgUrlInput').value.trim();
if (!url) { alert('이미지 URL을 입력하세요'); return; }
if (!url.startsWith('http')) { alert('올바른 URL을 입력하세요'); return; }
const code = imgModalBarcode || imgModalDrugCode;
const name = imgModalName;
closeImageModal();
showToast(`"${name}" 이미지 다운로드 중...`);
try {
const res = await fetch(`/api/admin/product-images/${code}/replace`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_url: url, product_name: name, drug_code: imgModalDrugCode })
});
const data = await res.json();
if (data.success) { showToast('✅ 이미지 등록 완료!', 'success'); searchProducts(); }
else showToast(data.error || '등록 실패', 'error');
} catch (err) { showToast('오류: ' + err.message, 'error'); }
}
function showToast(msg, type = 'info') {
const t = document.createElement('div');
t.style.cssText = `position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 24px;background:${type==='success'?'#10b981':type==='error'?'#ef4444':'#8b5cf6'};color:#fff;border-radius:8px;font-size:14px;z-index:3000;`;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
document.getElementById('imageModal')?.addEventListener('click', e => {
if (e.target.id === 'imageModal') closeImageModal();
});
</script>
<!-- 이미지 등록 모달 -->
<div class="image-modal" id="imageModal">
<div class="image-modal-content">
<h3>📷 제품 이미지 등록</h3>
<div style="background:#f8fafc;border-radius:8px;padding:12px;margin-bottom:16px;">
<div style="font-weight:600;" id="imgModalProductName">제품명</div>
<div style="font-size:12px;color:#94a3b8;font-family:monospace;" id="imgModalCode">코드</div>
</div>
<div class="image-modal-tabs">
<button class="img-tab-btn active" data-tab="url" onclick="switchImageTab('url')">🔗 URL 입력</button>
<button class="img-tab-btn" data-tab="camera" onclick="switchImageTab('camera')">📸 촬영</button>
</div>
<div class="img-tab-content active" id="imgTabUrl">
<input type="text" class="img-input" id="imgUrlInput" placeholder="이미지 URL을 입력하세요...">
<div class="img-modal-btns">
<button class="img-modal-btn secondary" onclick="closeImageModal()">취소</button>
<button class="img-modal-btn primary" onclick="submitImageUrl()">등록하기</button>
</div>
</div>
<div class="img-tab-content" id="imgTabCamera">
<div class="camera-box">
<video id="camVideo" autoplay playsinline></video>
<canvas id="camCanvas" style="display:none;"></canvas>
<div id="camGuide" 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="10" y="10" width="80" height="80" fill="none" stroke="rgba(139,92,246,0.5)" stroke-width="0.5" stroke-dasharray="2,2"/>
</svg>
</div>
</div>
<div class="img-modal-btns" id="captureBtn">
<button class="img-modal-btn secondary" onclick="closeImageModal()">취소</button>
<button class="img-modal-btn primary" onclick="capturePhoto()">📸 촬영</button>
</div>
<div class="img-modal-btns" id="previewBtns" style="display:none;">
<button class="img-modal-btn secondary" onclick="retakePhoto()">다시 촬영</button>
<button class="img-modal-btn primary" onclick="submitCapturedImage()">저장하기</button>
</div>
</div>
</div>
</div>
</body>
</html>