feat: 제품 검색 페이지에 제품 이미지 표시 및 등록 기능 추가
- API에 thumbnail 반환 추가 (product_images.db 조회) - 테이블에 이미지 컬럼 추가 (40x40 썸네일) - 이미지/플레이스홀더 클릭 → 등록 모달 (URL/촬영) - 판매내역과 동일한 UX
This commit is contained in:
parent
2859dc43cc
commit
01f0df9294
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user