feat(frontend): 補齊活動看板篩選與價格歷史區間
All checks were successful
CD Pipeline / deploy (push) Successful in 1m44s
All checks were successful
CD Pipeline / deploy (push) Successful in 1m44s
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> 本文件定義專案開發的核心準則與不可違反的規範
|
||||
> **建立日期**: 2026-01-12
|
||||
> **當前版本**: V10.41 (Dashboard V2 price history chart restored)
|
||||
> **當前版本**: V10.42 (Campaign V2 filters and ranged price history charts)
|
||||
> **最後更新**: 2026-05-01
|
||||
|
||||
---
|
||||
|
||||
4
app.py
4
app.py
@@ -95,8 +95,8 @@ except Exception as e:
|
||||
sys_log.error(f"無法檢測磁碟空間: {e}")
|
||||
|
||||
# 🚩 系統版本定義 (備份與顯示用)
|
||||
# 🚩 2026-05-01 V10.41: Restore dashboard v2 price history chart
|
||||
SYSTEM_VERSION = "V10.41"
|
||||
# 🚩 2026-05-01 V10.42: Campaign v2 filters and ranged price history charts
|
||||
SYSTEM_VERSION = "V10.42"
|
||||
|
||||
# ==========================================
|
||||
# 🔒 SQL Injection 防護函數
|
||||
|
||||
@@ -286,27 +286,62 @@ def test_notification():
|
||||
# 歷史查詢 API
|
||||
# ==========================================
|
||||
|
||||
PRICE_HISTORY_RANGES = {
|
||||
'week': {'days': 7, 'label': '近 7 天'},
|
||||
'month': {'days': 30, 'label': '近 30 天'},
|
||||
'quarter': {'days': 90, 'label': '近 90 天'},
|
||||
'year': {'days': 365, 'label': '近 365 天'},
|
||||
}
|
||||
|
||||
|
||||
def _resolve_history_range():
|
||||
range_key = request.args.get('range', 'month')
|
||||
if range_key not in PRICE_HISTORY_RANGES:
|
||||
range_key = 'month'
|
||||
return range_key, PRICE_HISTORY_RANGES[range_key]
|
||||
|
||||
|
||||
def _build_price_history_payload(session, product):
|
||||
range_key, range_meta = _resolve_history_range()
|
||||
start_date = datetime.now(TAIPEI_TZ) - timedelta(days=range_meta['days'])
|
||||
|
||||
records = session.query(PriceRecord).filter(
|
||||
PriceRecord.product_id == product.id,
|
||||
PriceRecord.timestamp >= start_date
|
||||
).order_by(PriceRecord.timestamp).all()
|
||||
|
||||
data = [{
|
||||
't': r.timestamp.strftime('%Y-%m-%d %H:%M'),
|
||||
'p': r.price
|
||||
} for r in records]
|
||||
|
||||
return {
|
||||
'range': range_key,
|
||||
'range_label': range_meta['label'],
|
||||
'product': {
|
||||
'id': product.id,
|
||||
'i_code': product.i_code,
|
||||
'name': product.name,
|
||||
},
|
||||
'data': data
|
||||
}
|
||||
|
||||
|
||||
@api_bp.route('/api/history/<int:product_id>')
|
||||
@login_required
|
||||
def get_price_history(product_id):
|
||||
"""API: 取得商品過去 180 天的價格歷史"""
|
||||
"""API: 取得商品價格歷史,支援 week/month/quarter/year 區間"""
|
||||
db = DatabaseManager()
|
||||
session = db.get_session()
|
||||
try:
|
||||
# 計算 180 天前的日期 (保持台北時區)
|
||||
start_date = datetime.now(TAIPEI_TZ) - timedelta(days=180)
|
||||
product = session.query(Product).filter(Product.id == product_id).first()
|
||||
if not product:
|
||||
return jsonify({'data': [], 'message': '找不到商品'}), 404
|
||||
|
||||
records = session.query(PriceRecord).filter(
|
||||
PriceRecord.product_id == product_id,
|
||||
PriceRecord.timestamp >= start_date
|
||||
).order_by(PriceRecord.timestamp).all()
|
||||
|
||||
data = [{
|
||||
't': r.timestamp.strftime('%Y-%m-%d %H:%M'),
|
||||
'p': r.price
|
||||
} for r in records]
|
||||
|
||||
return jsonify(data)
|
||||
payload = _build_price_history_payload(session, product)
|
||||
if request.args.get('format') == 'v2':
|
||||
return jsonify(payload)
|
||||
return jsonify(payload['data'])
|
||||
except Exception as e:
|
||||
sys_log.error(f"[Web] [History] 獲取歷史價格失敗 | ProductID: {product_id} | Error: {e}")
|
||||
return jsonify([]), 500
|
||||
@@ -314,6 +349,25 @@ def get_price_history(product_id):
|
||||
session.close()
|
||||
|
||||
|
||||
@api_bp.route('/api/history/i-code/<path:i_code>')
|
||||
@login_required
|
||||
def get_price_history_by_i_code(i_code):
|
||||
"""API: 以 MOMO 商品 i_code 取得主商品價格歷史"""
|
||||
db = DatabaseManager()
|
||||
session = db.get_session()
|
||||
try:
|
||||
product = session.query(Product).filter(Product.i_code == str(i_code)).first()
|
||||
if not product:
|
||||
return jsonify({'data': [], 'message': '找不到商品'})
|
||||
|
||||
return jsonify(_build_price_history_payload(session, product))
|
||||
except Exception as e:
|
||||
sys_log.error(f"[Web] [History] 以 i_code 獲取歷史價格失敗 | ICode: {i_code} | Error: {e}")
|
||||
return jsonify({'data': []}), 500
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@api_bp.route('/api/price_change_details')
|
||||
@login_required
|
||||
def get_price_change_details():
|
||||
|
||||
@@ -475,6 +475,31 @@
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
.dashboard-history-range {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
margin-top: 10px;
|
||||
gap: 0;
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.dashboard-history-range button {
|
||||
padding: 5px 10px;
|
||||
color: var(--momo-text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dashboard-history-range button.is-active {
|
||||
color: var(--momo-text-inverse);
|
||||
background: var(--momo-ink);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.dashboard-kpi-grid,
|
||||
.dashboard-focus-grid {
|
||||
@@ -766,7 +791,13 @@
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title" id="historyModalLabel">歷史價格走勢</h5>
|
||||
<div class="dashboard-history-subtitle momo-mono" id="historyModalSubtitle">近 180 天真實價格紀錄</div>
|
||||
<div class="dashboard-history-subtitle momo-mono" id="historyModalSubtitle">真實價格紀錄</div>
|
||||
<div class="dashboard-history-range" aria-label="價格歷史區間">
|
||||
<button type="button" data-history-range="week">週</button>
|
||||
<button class="is-active" type="button" data-history-range="month">月</button>
|
||||
<button type="button" data-history-range="quarter">季</button>
|
||||
<button type="button" data-history-range="year">年</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="關閉"></button>
|
||||
</div>
|
||||
@@ -785,6 +816,9 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
let priceChartInstance = null;
|
||||
let activeHistoryRange = 'month';
|
||||
let currentHistoryProductId = null;
|
||||
let currentHistoryProductName = '';
|
||||
|
||||
function getCSRFToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
@@ -810,7 +844,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showHistory(productId, productName) {
|
||||
function updateHistoryRangeButtons() {
|
||||
document.querySelectorAll('[data-history-range]').forEach(button => {
|
||||
button.classList.toggle('is-active', button.dataset.historyRange === activeHistoryRange);
|
||||
});
|
||||
}
|
||||
|
||||
function showHistory(productId, productName, range = activeHistoryRange) {
|
||||
const modalEl = document.getElementById('historyModal');
|
||||
const title = document.getElementById('historyModalLabel');
|
||||
const subtitle = document.getElementById('historyModalSubtitle');
|
||||
@@ -818,8 +858,13 @@
|
||||
|
||||
if (!modalEl || !title || !subtitle || !canvas) return;
|
||||
|
||||
currentHistoryProductId = productId;
|
||||
currentHistoryProductName = productName || '歷史價格走勢';
|
||||
activeHistoryRange = range;
|
||||
updateHistoryRangeButtons();
|
||||
|
||||
title.textContent = productName || '歷史價格走勢';
|
||||
subtitle.textContent = `商品 ID ${productId} · 近 180 天真實價格紀錄`;
|
||||
subtitle.textContent = `商品 ID ${productId} · 讀取真實價格紀錄`;
|
||||
destroyHistoryChart();
|
||||
setHistoryChartState('載入價格歷史中...');
|
||||
|
||||
@@ -831,7 +876,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/history/${productId}`)
|
||||
fetch(`/api/history/${productId}?range=${activeHistoryRange}&format=v2`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
@@ -839,7 +884,13 @@
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
const points = Array.isArray(data) ? data : (data.data || []);
|
||||
const rangeLabel = Array.isArray(data) ? '' : (data.range_label || '');
|
||||
if (rangeLabel) {
|
||||
subtitle.textContent = `商品 ID ${productId} · ${rangeLabel}真實價格紀錄`;
|
||||
}
|
||||
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
setHistoryChartState('目前沒有可顯示的歷史價格紀錄。');
|
||||
return;
|
||||
}
|
||||
@@ -853,10 +904,10 @@
|
||||
priceChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.map(point => point.t),
|
||||
labels: points.map(point => point.t),
|
||||
datasets: [{
|
||||
label: '價格',
|
||||
data: data.map(point => point.p),
|
||||
data: points.map(point => point.p),
|
||||
borderColor: '#be6a2d',
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 3,
|
||||
@@ -928,6 +979,13 @@
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-history-range]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!currentHistoryProductId) return;
|
||||
showHistory(currentHistoryProductId, currentHistoryProductName, button.dataset.historyRange);
|
||||
});
|
||||
});
|
||||
|
||||
function triggerTask() {
|
||||
if (confirm('確定要手動執行全站爬蟲嗎?可能需要一段時間。')) {
|
||||
fetch('/api/run_task', {
|
||||
|
||||
@@ -321,6 +321,36 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.campaign-filterbar {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
gap: 0;
|
||||
background: var(--momo-bg-paper);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.campaign-filter-chip {
|
||||
padding: 5px 10px;
|
||||
color: var(--momo-text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.campaign-filter-chip:hover {
|
||||
color: var(--momo-text-primary);
|
||||
background: var(--momo-bg-subtle);
|
||||
}
|
||||
|
||||
.campaign-filter-chip.is-active {
|
||||
color: var(--momo-text-inverse);
|
||||
background: var(--momo-ink);
|
||||
}
|
||||
|
||||
.campaign-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -419,6 +449,29 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.campaign-history-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
color: var(--momo-text-primary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.campaign-history-button:hover {
|
||||
color: var(--momo-accent-strong);
|
||||
}
|
||||
|
||||
.campaign-history-button i {
|
||||
color: var(--momo-accent-strong);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.campaign-old-price {
|
||||
color: var(--momo-text-tertiary);
|
||||
text-decoration: line-through;
|
||||
@@ -440,6 +493,89 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.campaign-filter-empty.is-hidden,
|
||||
.campaign-row.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.campaign-history-modal .modal-content {
|
||||
overflow: hidden;
|
||||
color: var(--momo-text-primary);
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--momo-shadow-lg);
|
||||
}
|
||||
|
||||
.campaign-history-modal .modal-header {
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 18px 20px;
|
||||
background: var(--momo-bg-paper);
|
||||
border-bottom: 1px solid var(--momo-border-light);
|
||||
}
|
||||
|
||||
.campaign-history-modal .modal-title {
|
||||
color: var(--momo-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.campaign-history-subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--momo-text-tertiary);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.campaign-history-range {
|
||||
display: inline-flex;
|
||||
padding: 2px;
|
||||
margin-top: 10px;
|
||||
gap: 0;
|
||||
background: var(--momo-bg-surface);
|
||||
border: 1px solid var(--momo-border-light);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.campaign-history-range button {
|
||||
padding: 5px 10px;
|
||||
color: var(--momo-text-secondary);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.campaign-history-range button.is-active {
|
||||
color: var(--momo-text-inverse);
|
||||
background: var(--momo-ink);
|
||||
}
|
||||
|
||||
.campaign-chart-shell {
|
||||
position: relative;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.campaign-chart-state {
|
||||
display: grid;
|
||||
min-height: 360px;
|
||||
color: var(--momo-text-secondary);
|
||||
place-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.campaign-chart-state.is-hidden,
|
||||
.campaign-chart-canvas.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.campaign-chart-canvas {
|
||||
max-height: 380px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.campaign-hero-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -592,12 +728,13 @@
|
||||
<div class="campaign-table-head">
|
||||
<span class="momo-mono" style="font-size:11px;font-weight:800;color:var(--momo-text-tertiary);letter-spacing:.08em;">03</span>
|
||||
<strong>商品列表</strong>
|
||||
<span class="momo-mono" style="color:var(--momo-text-tertiary);font-size:12px;">{{ items|length }} 筆</span>
|
||||
<div class="campaign-stat-badges">
|
||||
<span class="campaign-badge">新品 {{ stats.get('new', 0) }}</span>
|
||||
<span class="campaign-badge">漲價 {{ stats.get('up', 0) }}</span>
|
||||
<span class="campaign-badge">降價 {{ stats.get('down', 0) }}</span>
|
||||
<span class="campaign-badge">下架 {{ stats.get('delisted_last_run', 0) }}</span>
|
||||
<span class="momo-mono" data-campaign-visible-count style="color:var(--momo-text-tertiary);font-size:12px;">{{ items|length }} 筆</span>
|
||||
<div class="campaign-filterbar" aria-label="{{ slot }} 商品狀態篩選">
|
||||
<button class="campaign-filter-chip is-active" type="button" data-campaign-filter="all">全部 {{ items|length }}</button>
|
||||
<button class="campaign-filter-chip" type="button" data-campaign-filter="new">新品 {{ stats.get('new', 0) }}</button>
|
||||
<button class="campaign-filter-chip" type="button" data-campaign-filter="up">漲價 {{ stats.get('up', 0) }}</button>
|
||||
<button class="campaign-filter-chip" type="button" data-campaign-filter="down">降價 {{ stats.get('down', 0) }}</button>
|
||||
<button class="campaign-filter-chip" type="button" data-campaign-filter="delisted">下架 {{ stats.get('delisted_last_run', 0) }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="campaign-table-wrap">
|
||||
@@ -625,7 +762,8 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
{% set campaign_filter = 'new' if item.status_change == 'NEW' else ('up' if item.status_change == 'PRICE_UP' else ('down' if item.status_change == 'PRICE_DOWN' else ('delisted' if item.status_change in ['DELISTED', 'SLOT_END'] else 'active'))) %}
|
||||
<tr class="campaign-row" data-campaign-row data-campaign-filter="{{ campaign_filter }}" data-i-code="{{ item.i_code }}" data-product-name="{{ item.name|e }}">
|
||||
<td>
|
||||
<span class="campaign-category">{{ item.main_category or '未分類' }}</span>
|
||||
</td>
|
||||
@@ -665,9 +803,31 @@
|
||||
<div class="campaign-change-up">▲ {{ diff | number_format }}</div>
|
||||
{% endif %}
|
||||
<div><span class="campaign-old-price">${{ item.previous_price | number_format }}</span></div>
|
||||
<div class="campaign-price">${{ item.price | number_format }}</div>
|
||||
<button
|
||||
class="campaign-history-button"
|
||||
type="button"
|
||||
data-campaign-history-trigger
|
||||
data-i-code="{{ item.i_code }}"
|
||||
data-product-name="{{ item.name|e }}"
|
||||
onclick="event.stopPropagation(); showCampaignHistory(this.dataset.iCode, this.dataset.productName);"
|
||||
aria-label="查看 {{ item.name|e }} 的歷史價格圖表"
|
||||
>
|
||||
<span class="campaign-price">${{ item.price | number_format }}</span>
|
||||
<i class="fas fa-chart-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% elif item.price is not none %}
|
||||
<span class="campaign-price">${{ item.price | number_format }}</span>
|
||||
<button
|
||||
class="campaign-history-button"
|
||||
type="button"
|
||||
data-campaign-history-trigger
|
||||
data-i-code="{{ item.i_code }}"
|
||||
data-product-name="{{ item.name|e }}"
|
||||
onclick="event.stopPropagation(); showCampaignHistory(this.dataset.iCode, this.dataset.productName);"
|
||||
aria-label="查看 {{ item.name|e }} 的歷史價格圖表"
|
||||
>
|
||||
<span class="campaign-price">${{ item.price | number_format }}</span>
|
||||
<i class="fas fa-chart-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="color:var(--momo-text-tertiary);">--</span>
|
||||
{% endif %}
|
||||
@@ -691,6 +851,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="campaign-filter-empty is-hidden">
|
||||
<td colspan="4">
|
||||
<div class="campaign-empty">此篩選條件目前沒有商品資料</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -700,14 +865,232 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal fade campaign-history-modal" id="campaignHistoryModal" tabindex="-1" aria-labelledby="campaignHistoryModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title" id="campaignHistoryModalLabel">歷史價格走勢</h5>
|
||||
<div class="campaign-history-subtitle momo-mono" id="campaignHistoryModalSubtitle">真實價格紀錄</div>
|
||||
<div class="campaign-history-range" aria-label="價格歷史區間">
|
||||
<button type="button" data-campaign-history-range="week">週</button>
|
||||
<button class="is-active" type="button" data-campaign-history-range="month">月</button>
|
||||
<button type="button" data-campaign-history-range="quarter">季</button>
|
||||
<button type="button" data-campaign-history-range="year">年</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="關閉"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="campaign-chart-shell">
|
||||
<div class="campaign-chart-state" id="campaignHistoryChartState">載入價格歷史中...</div>
|
||||
<canvas class="campaign-chart-canvas is-hidden" id="campaignPriceChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
let campaignPriceChartInstance = null;
|
||||
let activeCampaignHistoryRange = 'month';
|
||||
let currentCampaignICode = null;
|
||||
let currentCampaignProductName = '';
|
||||
|
||||
function getCSRFToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
|
||||
}
|
||||
|
||||
function formatCampaignPriceTick(value) {
|
||||
return '$' + Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
function setCampaignHistoryChartState(message, showCanvas = false) {
|
||||
const state = document.getElementById('campaignHistoryChartState');
|
||||
const canvas = document.getElementById('campaignPriceChart');
|
||||
if (!state || !canvas) return;
|
||||
state.textContent = message;
|
||||
state.classList.toggle('is-hidden', showCanvas);
|
||||
canvas.classList.toggle('is-hidden', !showCanvas);
|
||||
}
|
||||
|
||||
function destroyCampaignPriceChart() {
|
||||
if (campaignPriceChartInstance) {
|
||||
campaignPriceChartInstance.destroy();
|
||||
campaignPriceChartInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateCampaignHistoryRangeButtons() {
|
||||
document.querySelectorAll('[data-campaign-history-range]').forEach(button => {
|
||||
button.classList.toggle('is-active', button.dataset.campaignHistoryRange === activeCampaignHistoryRange);
|
||||
});
|
||||
}
|
||||
|
||||
function showCampaignHistory(iCode, productName, range = activeCampaignHistoryRange) {
|
||||
const modalEl = document.getElementById('campaignHistoryModal');
|
||||
const title = document.getElementById('campaignHistoryModalLabel');
|
||||
const subtitle = document.getElementById('campaignHistoryModalSubtitle');
|
||||
const canvas = document.getElementById('campaignPriceChart');
|
||||
|
||||
if (!modalEl || !title || !subtitle || !canvas) return;
|
||||
|
||||
currentCampaignICode = iCode;
|
||||
currentCampaignProductName = productName || '歷史價格走勢';
|
||||
activeCampaignHistoryRange = range;
|
||||
updateCampaignHistoryRangeButtons();
|
||||
|
||||
title.textContent = currentCampaignProductName;
|
||||
subtitle.textContent = `商品 ID ${iCode} · 讀取真實價格紀錄`;
|
||||
destroyCampaignPriceChart();
|
||||
setCampaignHistoryChartState('載入價格歷史中...');
|
||||
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
modal.show();
|
||||
|
||||
if (typeof Chart === 'undefined') {
|
||||
setCampaignHistoryChartState('圖表元件尚未載入完成,請重新整理後再試。');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/history/i-code/${encodeURIComponent(iCode)}?range=${activeCampaignHistoryRange}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(payload => {
|
||||
const points = payload.data || [];
|
||||
if (payload.range_label) {
|
||||
subtitle.textContent = `商品 ID ${iCode} · ${payload.range_label}真實價格紀錄`;
|
||||
}
|
||||
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
setCampaignHistoryChartState('目前沒有可顯示的歷史價格紀錄。');
|
||||
return;
|
||||
}
|
||||
|
||||
setCampaignHistoryChartState('', true);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 380);
|
||||
gradient.addColorStop(0, 'rgba(190, 106, 45, 0.26)');
|
||||
gradient.addColorStop(1, 'rgba(190, 106, 45, 0.04)');
|
||||
|
||||
campaignPriceChartInstance = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: points.map(point => point.t),
|
||||
datasets: [{
|
||||
label: '價格',
|
||||
data: points.map(point => point.p),
|
||||
borderColor: '#be6a2d',
|
||||
backgroundColor: gradient,
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 7,
|
||||
pointBackgroundColor: '#be6a2d',
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(55, 45, 35, 0.94)',
|
||||
titleColor: '#faf7f0',
|
||||
bodyColor: '#faf7f0',
|
||||
borderColor: '#be6a2d',
|
||||
borderWidth: 1,
|
||||
displayColors: false,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: context => '價格 ' + formatCampaignPriceTick(context.parsed.y)
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
grid: {
|
||||
color: 'rgba(71, 61, 49, 0.08)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#7f715f',
|
||||
callback: formatCampaignPriceTick
|
||||
}
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: '#9b8a77',
|
||||
maxRotation: 0,
|
||||
autoSkip: true,
|
||||
maxTicksLimit: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('活動商品價格歷史載入失敗:', error);
|
||||
setCampaignHistoryChartState('價格歷史載入失敗,請稍後再試。');
|
||||
});
|
||||
}
|
||||
|
||||
function applyCampaignFilter(card, filter) {
|
||||
const rows = Array.from(card.querySelectorAll('[data-campaign-row]'));
|
||||
const empty = card.querySelector('.campaign-filter-empty');
|
||||
const counter = card.querySelector('[data-campaign-visible-count]');
|
||||
let visibleCount = 0;
|
||||
|
||||
rows.forEach(row => {
|
||||
const matches = filter === 'all' || row.dataset.campaignFilter === filter;
|
||||
row.classList.toggle('is-hidden', !matches);
|
||||
if (matches) visibleCount += 1;
|
||||
});
|
||||
|
||||
if (empty) {
|
||||
empty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||
}
|
||||
|
||||
if (counter) {
|
||||
counter.textContent = `${visibleCount.toLocaleString()} 筆`;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.campaign-table-card').forEach(card => {
|
||||
card.querySelectorAll('[data-campaign-filter]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
card.querySelectorAll('[data-campaign-filter]').forEach(item => {
|
||||
item.classList.toggle('is-active', item === button);
|
||||
});
|
||||
applyCampaignFilter(card, button.dataset.campaignFilter);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-campaign-history-range]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
if (!currentCampaignICode) return;
|
||||
showCampaignHistory(currentCampaignICode, currentCampaignProductName, button.dataset.campaignHistoryRange);
|
||||
});
|
||||
});
|
||||
|
||||
function triggerEdmTask() {
|
||||
if (confirm('確定要手動執行 EDM 爬蟲嗎?')) {
|
||||
fetch('/api/run_edm_task', {
|
||||
|
||||
@@ -60,15 +60,24 @@ def test_dashboard_v2_restores_real_price_history_chart():
|
||||
dashboard = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "@api_bp.route('/api/history/<int:product_id>')" in route_source
|
||||
assert "PRICE_HISTORY_RANGES" in route_source
|
||||
assert "'week': {'days': 7" in route_source
|
||||
assert "'month': {'days': 30" in route_source
|
||||
assert "'quarter': {'days': 90" in route_source
|
||||
assert "'year': {'days': 365" in route_source
|
||||
assert "session.query(PriceRecord)" in route_source
|
||||
assert "PriceRecord.product_id == product_id" in route_source
|
||||
assert "PriceRecord.product_id == product.id" in route_source
|
||||
assert "https://cdn.jsdelivr.net/npm/chart.js" in dashboard
|
||||
assert 'id="historyModal"' in dashboard
|
||||
assert 'id="priceChart"' in dashboard
|
||||
assert "data-product-id=\"{{ product.id }}\"" in dashboard
|
||||
assert "data-history-trigger" in dashboard
|
||||
assert "onclick=\"event.stopPropagation(); showHistory(this.dataset.productId, this.dataset.productName);\"" in dashboard
|
||||
assert "fetch(`/api/history/${productId}`)" in dashboard
|
||||
assert "data-history-range=\"week\"" in dashboard
|
||||
assert "data-history-range=\"month\"" in dashboard
|
||||
assert "data-history-range=\"quarter\"" in dashboard
|
||||
assert "data-history-range=\"year\"" in dashboard
|
||||
assert "fetch(`/api/history/${productId}?range=${activeHistoryRange}&format=v2`)" in dashboard
|
||||
assert "priceChartInstance = new Chart" in dashboard
|
||||
assert "目前沒有可顯示的歷史價格紀錄" in dashboard
|
||||
|
||||
@@ -82,6 +91,19 @@ def test_edm_dashboard_v2_is_production_default_and_uses_real_campaign_data():
|
||||
assert "{% for slot, stats in slot_stats.items() %}" in template
|
||||
assert "{% for item in items %}" in template
|
||||
assert "scheduler_stats.get(task_key, [])" in template
|
||||
assert "@api_bp.route('/api/history/i-code/<path:i_code>')" in (ROOT / "routes/api_routes.py").read_text(encoding="utf-8")
|
||||
assert "data-campaign-filter=\"new\"" in template
|
||||
assert "data-campaign-filter=\"up\"" in template
|
||||
assert "data-campaign-filter=\"down\"" in template
|
||||
assert "data-campaign-filter=\"delisted\"" in template
|
||||
assert "data-campaign-history-trigger" in template
|
||||
assert "showCampaignHistory(this.dataset.iCode, this.dataset.productName)" in template
|
||||
assert "fetch(`/api/history/i-code/${encodeURIComponent(iCode)}?range=${activeCampaignHistoryRange}`)" in template
|
||||
assert "data-campaign-history-range=\"week\"" in template
|
||||
assert "data-campaign-history-range=\"month\"" in template
|
||||
assert "data-campaign-history-range=\"quarter\"" in template
|
||||
assert "data-campaign-history-range=\"year\"" in template
|
||||
assert "applyCampaignFilter(card, button.dataset.campaignFilter)" in template
|
||||
assert "?ui=v2" not in template
|
||||
assert "ui='v2'" not in template
|
||||
assert "mock" not in template.lower()
|
||||
|
||||
Reference in New Issue
Block a user