feat(frontend): 補齊活動看板篩選與價格歷史區間
All checks were successful
CD Pipeline / deploy (push) Successful in 1m44s

This commit is contained in:
OoO
2026-05-01 00:43:38 +08:00
parent 22b27d19df
commit 6b8e511246
6 changed files with 552 additions and 35 deletions

View File

@@ -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
View File

@@ -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 防護函數

View File

@@ -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():

View File

@@ -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', {

View File

@@ -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', {

View File

@@ -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()