620 lines
22 KiB
HTML
620 lines
22 KiB
HTML
{% extends "ewoooc_base.html" %}
|
|
|
|
{% block title %}PChome 商品監控 - EwoooC{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
.pchome-tool-page {
|
|
color: var(--momo-text-primary);
|
|
}
|
|
|
|
.pchome-tool-head {
|
|
padding: var(--momo-space-4) var(--momo-space-5);
|
|
background: var(--momo-bg-surface);
|
|
border: 1px solid var(--momo-border-light);
|
|
border-radius: var(--momo-radius-md);
|
|
}
|
|
|
|
.pchome-tool-head h2 {
|
|
margin: 0;
|
|
color: var(--momo-text-primary);
|
|
font-family: var(--momo-font-display);
|
|
font-size: var(--momo-text-headline);
|
|
font-weight: 800;
|
|
letter-spacing: 0;
|
|
}
|
|
|
|
.pchome-tool-head p,
|
|
.pchome-tool-page .text-muted {
|
|
color: var(--momo-text-secondary) !important;
|
|
}
|
|
|
|
.pchome-card-head {
|
|
background: var(--momo-bg-paper);
|
|
border-bottom: 1px solid var(--momo-border-light);
|
|
color: var(--momo-text-primary);
|
|
font-family: var(--momo-font-display);
|
|
font-weight: 800;
|
|
}
|
|
|
|
.pchome-table-head th {
|
|
background: var(--momo-bg-paper) !important;
|
|
color: var(--momo-text-secondary) !important;
|
|
font-family: var(--momo-font-mono, monospace);
|
|
font-size: var(--momo-text-label);
|
|
font-weight: 800;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
|
|
.pchome-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 22px;
|
|
padding: 3px 8px;
|
|
border: 1px solid var(--momo-border-light);
|
|
border-radius: var(--momo-radius-sm);
|
|
font-family: var(--momo-font-mono, monospace);
|
|
font-size: var(--momo-text-label);
|
|
font-weight: 800;
|
|
line-height: 1;
|
|
}
|
|
|
|
.pchome-badge.is-danger {
|
|
background: var(--momo-danger-bg);
|
|
border-color: var(--momo-danger-border);
|
|
color: var(--momo-danger-text);
|
|
}
|
|
|
|
.pchome-badge.is-success {
|
|
background: var(--momo-success-bg);
|
|
border-color: var(--momo-success-border);
|
|
color: var(--momo-success-text);
|
|
}
|
|
|
|
.pchome-badge.is-muted {
|
|
background: var(--momo-tag-muted-bg);
|
|
border-color: var(--momo-tag-muted-border);
|
|
color: var(--momo-tag-muted-text);
|
|
}
|
|
|
|
.pchome-product-title {
|
|
color: var(--momo-text-primary);
|
|
font-weight: 800;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.pchome-product-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-top: 6px;
|
|
color: var(--momo-text-secondary);
|
|
font-size: var(--momo-text-small);
|
|
}
|
|
|
|
.pchome-product-meta span {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
min-height: 22px;
|
|
padding: 2px 7px;
|
|
border: 1px solid var(--momo-border-light);
|
|
border-radius: var(--momo-radius-sm);
|
|
background: var(--momo-bg-paper);
|
|
}
|
|
|
|
.pchome-toast {
|
|
top: 20px;
|
|
right: 20px;
|
|
z-index: 9999;
|
|
min-width: min(300px, calc(100vw - 40px));
|
|
padding: 12px 14px;
|
|
border: 1px solid var(--momo-border-light);
|
|
border-radius: var(--momo-radius-md);
|
|
background: var(--momo-bg-elevated);
|
|
color: var(--momo-text-primary);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.pchome-toast--success {
|
|
background: var(--momo-success-bg);
|
|
border-color: var(--momo-success-border);
|
|
color: var(--momo-success-text);
|
|
}
|
|
|
|
.pchome-toast--danger {
|
|
background: var(--momo-danger-bg);
|
|
border-color: var(--momo-danger-border);
|
|
color: var(--momo-danger-text);
|
|
}
|
|
|
|
.pchome-toast--warning {
|
|
background: var(--momo-warning-bg);
|
|
border-color: var(--momo-warning-border);
|
|
color: var(--momo-warning-text);
|
|
}
|
|
|
|
@media (max-width: 760px) {
|
|
.pchome-tool-head {
|
|
padding: var(--momo-space-4);
|
|
}
|
|
|
|
.pchome-tool-page .card-header {
|
|
align-items: flex-start !important;
|
|
flex-direction: column;
|
|
gap: var(--momo-space-2);
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid py-4 pchome-tool-page">
|
|
<header class="pchome-tool-head mb-4">
|
|
<h2><i class="fas fa-magnifying-glass-chart me-2"></i>PChome 24h 商品監控</h2>
|
|
<p class="text-muted">補齊 PChome 商品、售價、庫存與賣場連結,支援同款確認、價差與促銷監控。</p>
|
|
</header>
|
|
|
|
<!-- 資料取得方式選擇 -->
|
|
<div class="row mb-4">
|
|
<div class="col-lg-6">
|
|
<div class="card">
|
|
<div class="card-header pchome-card-head">
|
|
<i class="fas fa-folder-open me-2"></i>館別商品整理
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">選擇館別分類</label>
|
|
<select class="form-select" id="categorySelect">
|
|
<option value="">-- 選擇分類 --</option>
|
|
</select>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">選擇館別</label>
|
|
<select class="form-select" id="regionSelect" disabled>
|
|
<option value="">-- 先選擇分類 --</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-primary" id="crawlRegionBtn" disabled>
|
|
<i class="fas fa-download me-1"></i>開始整理
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-lg-6">
|
|
<div class="card">
|
|
<div class="card-header pchome-card-head">
|
|
<i class="fas fa-search me-2"></i>關鍵字搜尋
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">搜尋關鍵字</label>
|
|
<input type="text" class="form-control" id="searchKeyword" placeholder="輸入商品關鍵字...">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">最多筆數</label>
|
|
<select class="form-select" id="searchLimit">
|
|
<option value="20">20 筆</option>
|
|
<option value="50" selected>50 筆</option>
|
|
<option value="100">100 筆</option>
|
|
</select>
|
|
</div>
|
|
<button class="btn btn-primary" id="searchBtn">
|
|
<i class="fas fa-search me-1"></i>搜尋
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 自訂 URL -->
|
|
<div class="row mb-4">
|
|
<div class="col">
|
|
<div class="card">
|
|
<div class="card-header pchome-card-head">
|
|
<i class="fas fa-link me-2"></i>自訂賣場整理
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row align-items-end">
|
|
<div class="col-lg-9 mb-2 mb-lg-0">
|
|
<label class="form-label">PChome 24h 頁面 URL</label>
|
|
<input type="url" class="form-control" id="customUrl"
|
|
placeholder="https://24h.pchome.com.tw/region/DDAB">
|
|
</div>
|
|
<div class="col-lg-3">
|
|
<button class="btn btn-primary w-100" id="crawlCustomBtn">
|
|
<i class="fas fa-download me-1"></i>取得商品
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 資料整理進度 -->
|
|
<div class="row mb-4" id="progressSection" style="display: none;">
|
|
<div class="col">
|
|
<div class="card">
|
|
<div class="card-body">
|
|
<div class="d-flex align-items-center">
|
|
<div class="spinner-border text-primary me-3" role="status">
|
|
<span class="visually-hidden">載入中...</span>
|
|
</div>
|
|
<div>
|
|
<strong id="progressText">正在整理商品...</strong>
|
|
<br>
|
|
<small class="text-muted" id="progressDetail">請稍候</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 結果區域 -->
|
|
<div class="row" id="resultSection" style="display: none;">
|
|
<div class="col">
|
|
<div class="card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span><i class="fas fa-list me-2"></i>商品清單 (<span id="resultCount">0</span> 筆)</span>
|
|
<div>
|
|
<button class="btn btn-sm btn-outline-primary me-2" id="exportExcelBtn">
|
|
<i class="fas fa-file-excel me-1"></i>下載表格
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" id="exportStoreLinksBtn">
|
|
<i class="fas fa-link me-1"></i>下載賣場清單
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-hover mb-0" id="resultTable">
|
|
<thead class="pchome-table-head">
|
|
<tr>
|
|
<th style="width: 80px;">圖片</th>
|
|
<th>商品名稱</th>
|
|
<th style="width: 100px;">售價</th>
|
|
<th style="width: 100px;">原價</th>
|
|
<th style="width: 80px;">折扣</th>
|
|
<th style="width: 80px;">庫存</th>
|
|
<th style="width: 100px;">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="resultBody">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script>
|
|
let regionsData = {};
|
|
let categoriesData = {};
|
|
let currentProducts = [];
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
loadRegions();
|
|
bindEvents();
|
|
});
|
|
|
|
async function loadRegions() {
|
|
try {
|
|
const response = await fetch('/api/pchome/regions');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
regionsData = data.data.regions;
|
|
categoriesData = data.data.categories;
|
|
|
|
// 填充分類下拉選單
|
|
const categorySelect = document.getElementById('categorySelect');
|
|
for (const cat of Object.keys(categoriesData)) {
|
|
const option = document.createElement('option');
|
|
option.value = cat;
|
|
option.textContent = cat;
|
|
categorySelect.appendChild(option);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('載入館別失敗:', error);
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
// 分類選擇變更
|
|
document.getElementById('categorySelect').addEventListener('change', function() {
|
|
const category = this.value;
|
|
const regionSelect = document.getElementById('regionSelect');
|
|
const crawlBtn = document.getElementById('crawlRegionBtn');
|
|
|
|
regionSelect.innerHTML = '<option value="">-- 選擇館別 --</option>';
|
|
|
|
if (category && categoriesData[category]) {
|
|
for (const region of categoriesData[category]) {
|
|
const option = document.createElement('option');
|
|
option.value = region.code;
|
|
option.textContent = `${region.code} - ${region.name}`;
|
|
regionSelect.appendChild(option);
|
|
}
|
|
regionSelect.disabled = false;
|
|
} else {
|
|
regionSelect.disabled = true;
|
|
}
|
|
crawlBtn.disabled = true;
|
|
});
|
|
|
|
// 館別選擇變更
|
|
document.getElementById('regionSelect').addEventListener('change', function() {
|
|
document.getElementById('crawlRegionBtn').disabled = !this.value;
|
|
});
|
|
|
|
// 館別商品整理
|
|
document.getElementById('crawlRegionBtn').addEventListener('click', crawlRegion);
|
|
|
|
// 搜尋
|
|
document.getElementById('searchBtn').addEventListener('click', searchProducts);
|
|
document.getElementById('searchKeyword').addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') searchProducts();
|
|
});
|
|
|
|
// 自訂賣場整理
|
|
document.getElementById('crawlCustomBtn').addEventListener('click', crawlCustomUrl);
|
|
|
|
// 匯出
|
|
document.getElementById('exportExcelBtn').addEventListener('click', exportExcel);
|
|
document.getElementById('exportStoreLinksBtn').addEventListener('click', exportStoreLinks);
|
|
}
|
|
|
|
async function crawlRegion() {
|
|
const regionCode = document.getElementById('regionSelect').value;
|
|
if (!regionCode) return;
|
|
|
|
const regionName = regionsData[regionCode]?.name || regionCode;
|
|
showProgress(`正在整理 ${regionName} 商品...`, '取得商品列表中');
|
|
|
|
try {
|
|
const response = await fetch('/api/pchome/crawl/region', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ region_code: regionCode })
|
|
});
|
|
|
|
const data = await response.json();
|
|
hideProgress();
|
|
|
|
if (data.success) {
|
|
currentProducts = data.data.products;
|
|
showResults(currentProducts);
|
|
showToast(`已整理 ${currentProducts.length} 個商品`, 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
hideProgress();
|
|
showToast('商品整理失敗: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
async function searchProducts() {
|
|
const keyword = document.getElementById('searchKeyword').value.trim();
|
|
if (!keyword) {
|
|
showToast('先填搜尋關鍵字。', 'warning');
|
|
return;
|
|
}
|
|
|
|
const limit = parseInt(document.getElementById('searchLimit').value);
|
|
showProgress(`搜尋 "${keyword}"...`, '查詢中');
|
|
|
|
try {
|
|
const response = await fetch('/api/pchome/search', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ keyword, limit })
|
|
});
|
|
|
|
const data = await response.json();
|
|
hideProgress();
|
|
|
|
if (data.success) {
|
|
currentProducts = data.data.products;
|
|
showResults(currentProducts);
|
|
showToast(`找到 ${currentProducts.length} 個商品`, 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
hideProgress();
|
|
showToast('搜尋失敗: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
async function crawlCustomUrl() {
|
|
const url = document.getElementById('customUrl').value.trim();
|
|
if (!url) {
|
|
showToast('先貼上 PChome 24h 連結。', 'warning');
|
|
return;
|
|
}
|
|
|
|
if (!url.startsWith('https://24h.pchome.com.tw/')) {
|
|
showToast('僅支援 PChome 24h 網站', 'warning');
|
|
return;
|
|
}
|
|
|
|
showProgress(`正在取得賣場資料...`, url);
|
|
|
|
try {
|
|
const response = await fetch('/api/pchome/crawl/custom', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ url })
|
|
});
|
|
|
|
const data = await response.json();
|
|
hideProgress();
|
|
|
|
if (data.success) {
|
|
currentProducts = data.data.products;
|
|
showResults(currentProducts);
|
|
showToast(`已整理 ${currentProducts.length} 個商品`, 'success');
|
|
} else {
|
|
showToast(data.message, 'danger');
|
|
}
|
|
} catch (error) {
|
|
hideProgress();
|
|
showToast('商品整理失敗: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
function showProgress(title, detail) {
|
|
document.getElementById('progressText').textContent = title;
|
|
document.getElementById('progressDetail').textContent = detail;
|
|
document.getElementById('progressSection').style.display = 'block';
|
|
document.getElementById('resultSection').style.display = 'none';
|
|
}
|
|
|
|
function hideProgress() {
|
|
document.getElementById('progressSection').style.display = 'none';
|
|
}
|
|
|
|
function showResults(products) {
|
|
document.getElementById('resultCount').textContent = products.length;
|
|
document.getElementById('resultSection').style.display = 'block';
|
|
|
|
const tbody = document.getElementById('resultBody');
|
|
tbody.innerHTML = '';
|
|
|
|
for (const p of products) {
|
|
const productName = String(p.name || 'PChome 商品');
|
|
const productId = String(p.product_id || '').trim();
|
|
const productUrl = String(p.product_url || '').trim();
|
|
const imageUrl = String(p.image_url || '').trim();
|
|
const price = toNumber(p.price);
|
|
const originalPrice = toNumber(p.original_price);
|
|
const discountValue = toNumber(p.discount);
|
|
const stock = toNumber(p.stock);
|
|
const discount = discountValue > 0 ? `<span class="pchome-badge is-danger">-${discountValue}%</span>` : '-';
|
|
const stockBadge = stock > 0
|
|
? `<span class="pchome-badge is-success">${stock}</span>`
|
|
: `<span class="pchome-badge is-muted">缺貨</span>`;
|
|
const imageSrc = imageUrl ? `${escapeHtml(imageUrl)}?width=80` : '/static/images/no-image.png';
|
|
const productTitle = `${escapeHtml(productName.substring(0, 50))}${productName.length > 50 ? '...' : ''}`;
|
|
const productLink = productUrl
|
|
? `<a href="${escapeHtml(productUrl)}" target="_blank" rel="noopener noreferrer" class="text-decoration-none pchome-product-title">${productTitle}</a>`
|
|
: `<span class="pchome-product-title">${productTitle}</span>`;
|
|
const storeAction = productUrl
|
|
? `<a href="${escapeHtml(productUrl)}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-primary"><i class="fas fa-up-right-from-square me-1"></i>賣場</a>`
|
|
: `<button type="button" class="btn btn-sm btn-outline-secondary" disabled>待補</button>`;
|
|
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>
|
|
<img src="${imageSrc}" alt="${escapeHtml(productName)}" class="img-thumbnail"
|
|
style="width: 60px; height: 60px; object-fit: cover;"
|
|
onerror="this.src='/static/images/no-image.png'">
|
|
</td>
|
|
<td>
|
|
${productLink}
|
|
<div class="pchome-product-meta">
|
|
<span>商品編號 ${escapeHtml(productId || '待補')}</span>
|
|
<span>PChome 24h 官方賣場</span>
|
|
</div>
|
|
</td>
|
|
<td class="text-danger fw-bold">${formatMoney(price)}</td>
|
|
<td class="text-muted">${originalPrice ? `<s>${formatMoney(originalPrice)}</s>` : '-'}</td>
|
|
<td>${discount}</td>
|
|
<td>${stockBadge}</td>
|
|
<td>
|
|
${storeAction}
|
|
</td>
|
|
`;
|
|
tbody.appendChild(row);
|
|
}
|
|
}
|
|
|
|
function exportExcel() {
|
|
if (!currentProducts.length) {
|
|
showToast('沒有資料可匯出', 'warning');
|
|
return;
|
|
}
|
|
|
|
const headers = ['商品編號', '商品名稱', 'PChome 售價', 'PChome 原價', '折扣', '庫存狀態', '商品圖片', 'PChome 賣場'];
|
|
const rows = currentProducts.map(p => [
|
|
csvCell(p.product_id || ''),
|
|
csvCell(p.name || ''),
|
|
toNumber(p.price),
|
|
toNumber(p.original_price),
|
|
p.discount || '',
|
|
toNumber(p.stock),
|
|
csvCell(p.image_url || ''),
|
|
csvCell(p.product_url || '')
|
|
]);
|
|
|
|
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
|
downloadFile(csv, 'pchome_products.csv', 'text/csv;charset=utf-8');
|
|
}
|
|
|
|
function exportStoreLinks() {
|
|
if (!currentProducts.length) {
|
|
showToast('沒有資料可匯出', 'warning');
|
|
return;
|
|
}
|
|
|
|
const headers = ['商品編號', '商品名稱', 'PChome 賣場'];
|
|
const rows = currentProducts.map(p => [
|
|
csvCell(p.product_id || ''),
|
|
csvCell(p.name || ''),
|
|
csvCell(p.product_url || '')
|
|
]);
|
|
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
|
downloadFile(csv, 'pchome_store_links.csv', 'text/csv;charset=utf-8');
|
|
}
|
|
|
|
function downloadFile(content, filename, type) {
|
|
const blob = new Blob([content], { type });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function toNumber(value) {
|
|
const number = Number(value);
|
|
return Number.isFinite(number) ? number : 0;
|
|
}
|
|
|
|
function formatMoney(value) {
|
|
return `$${toNumber(value).toLocaleString()}`;
|
|
}
|
|
|
|
function csvCell(value) {
|
|
return `"${String(value).replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
// 簡易 Toast
|
|
const toast = document.createElement('div');
|
|
toast.className = `pchome-toast pchome-toast--${type} position-fixed`;
|
|
toast.innerHTML = `
|
|
<button type="button" class="btn-close float-end" onclick="this.parentElement.remove()"></button>
|
|
${message}
|
|
`;
|
|
document.body.appendChild(toast);
|
|
setTimeout(() => toast.remove(), 5000);
|
|
}
|
|
</script>
|
|
{% endblock %}
|