perf: 減輕活動看板首屏載入
All checks were successful
CD Pipeline / deploy (push) Successful in 1m3s

This commit is contained in:
OoO
2026-05-17 23:38:06 +08:00
parent ff49d31f73
commit 4736a7f7df
3 changed files with 130 additions and 19 deletions

View File

@@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.158"
SYSTEM_VERSION = "V10.159"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -6,6 +6,7 @@ EDM 與節慶促銷路由模組
"""
import hashlib
import math
from datetime import datetime, timezone, timedelta
from flask import Blueprint, request, render_template, url_for
from auth import login_required
@@ -29,6 +30,8 @@ edm_bp = Blueprint('edm', __name__)
_PROMO_DASHBOARD_CACHE = {}
_PROMO_DASHBOARD_CACHE_MAX = 32
_PROMO_PAGE_SIZE_DEFAULT = 120
_PROMO_PAGE_SIZE_MAX = 200
# ==========================================
@@ -100,6 +103,36 @@ def _remember_promo_dashboard_data(cache_key, data):
_PROMO_DASHBOARD_CACHE[cache_key] = data
def _get_promo_page_window_args():
"""讀取促銷商品清單分頁參數,限制首屏 HTML 重量。"""
page = request.args.get('page', 1, type=int) or 1
per_page = request.args.get('per_page', _PROMO_PAGE_SIZE_DEFAULT, type=int) or _PROMO_PAGE_SIZE_DEFAULT
return max(1, page), max(20, min(per_page, _PROMO_PAGE_SIZE_MAX))
def _paginate_active_slot(data, page, per_page):
"""只裁切目前顯示時段;其他統計仍保留完整資料。"""
active_tab = data.get('active_tab')
grouped_items = dict(data.get('sorted_grouped_items') or {})
active_items = list(grouped_items.get(active_tab, []))
total_items = len(active_items)
total_pages = max(1, math.ceil(total_items / per_page)) if total_items else 1
page = min(max(1, page), total_pages)
start_idx = (page - 1) * per_page
end_idx = min(start_idx + per_page, total_items)
grouped_items[active_tab] = active_items[start_idx:end_idx]
return grouped_items, {
'page': page,
'per_page': per_page,
'total_pages': total_pages,
'total_items': total_items,
'start_item': start_idx + 1 if total_items else 0,
'end_item': end_idx,
'has_prev': page > 1,
'has_next': page < total_pages,
}
def _build_promo_dashboard_data(session, page_type, page_name, sort_by, order, requested_slot=None):
"""
通用的促銷儀表板數據建構函數
@@ -358,9 +391,11 @@ def edm_dashboard():
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
requested_slot = request.args.get('slot')
page, per_page = _get_promo_page_window_args()
try:
data = _build_promo_dashboard_data(session, 'edm', '限時搶購', sort_by, order, requested_slot)
grouped_items, page_window = _paginate_active_slot(data, page, per_page)
# 建立儀表板頁籤
promo_pages = [
@@ -380,8 +415,9 @@ def edm_dashboard():
promo_pages=promo_pages,
current_promo_page='edm',
page_title='MOMO 限時搶購',
grouped_items=data['sorted_grouped_items'],
grouped_items=grouped_items,
slot_stats=data['slot_stats'],
page_window=page_window,
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
@@ -414,9 +450,11 @@ def festival_dashboard():
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
requested_slot = request.args.get('slot')
page, per_page = _get_promo_page_window_args()
try:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot)
grouped_items, page_window = _paginate_active_slot(data, page, per_page)
# 建立儀表板頁籤
promo_pages = [
@@ -435,8 +473,9 @@ def festival_dashboard():
promo_pages=promo_pages,
current_promo_page='festival',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
grouped_items=grouped_items,
slot_stats=data['slot_stats'],
page_window=page_window,
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
@@ -467,9 +506,11 @@ def mothers_day_dashboard():
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
requested_slot = request.args.get('slot')
page, per_page = _get_promo_page_window_args()
try:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot)
grouped_items, page_window = _paginate_active_slot(data, page, per_page)
# 建立儀表板頁籤
promo_pages = [
@@ -488,8 +529,9 @@ def mothers_day_dashboard():
promo_pages=promo_pages,
current_promo_page='mothers_day',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
grouped_items=grouped_items,
slot_stats=data['slot_stats'],
page_window=page_window,
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
@@ -520,9 +562,11 @@ def valentine_520_dashboard():
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
requested_slot = request.args.get('slot')
page, per_page = _get_promo_page_window_args()
try:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot)
grouped_items, page_window = _paginate_active_slot(data, page, per_page)
# 建立儀表板頁籤
promo_pages = [
@@ -541,8 +585,9 @@ def valentine_520_dashboard():
promo_pages=promo_pages,
current_promo_page='valentine_520',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
grouped_items=grouped_items,
slot_stats=data['slot_stats'],
page_window=page_window,
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],
@@ -573,9 +618,11 @@ def labor_day_dashboard():
sort_by = request.args.get('sort_by', 'default')
order = request.args.get('order', 'desc')
requested_slot = request.args.get('slot')
page, per_page = _get_promo_page_window_args()
try:
data = _build_promo_dashboard_data(session, PAGE_TYPE, PAGE_NAME, sort_by, order, requested_slot)
grouped_items, page_window = _paginate_active_slot(data, page, per_page)
# 建立儀表板頁籤
promo_pages = [
@@ -594,8 +641,9 @@ def labor_day_dashboard():
promo_pages=promo_pages,
current_promo_page='labor_day',
page_title=PAGE_NAME,
grouped_items=data['sorted_grouped_items'],
grouped_items=grouped_items,
slot_stats=data['slot_stats'],
page_window=page_window,
total_edm_products=len(data['items_in_batch']),
last_update=data['last_update_str'],
activity_time=data['activity_time'],

View File

@@ -412,6 +412,36 @@
background: var(--momo-ink);
}
.campaign-pagination {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: auto;
color: var(--momo-text-secondary);
font-family: var(--momo-font-family-mono);
font-size: 11px;
font-weight: 800;
}
.campaign-page-link {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 30px;
padding: 5px 9px;
color: var(--momo-text-primary);
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: 6px;
text-decoration: none;
}
.campaign-page-link[aria-disabled="true"] {
pointer-events: none;
color: var(--momo-text-disabled);
background: var(--momo-bg-paper);
}
.campaign-table-wrap {
overflow-x: auto;
}
@@ -708,6 +738,12 @@
.campaign-kpi-grid {
grid-template-columns: 1fr 1fr;
}
.campaign-pagination {
width: 100%;
margin-left: 0;
justify-content: space-between;
}
}
</style>
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-edm.css') }}">
@@ -825,7 +861,7 @@
<div class="campaign-slot-tabs" role="tablist">
{% for slot, stats in slot_stats.items() %}
{% set slot_id = slugify(slot) %}
<a class="campaign-slot-tab {% if slot == active_tab %}active{% endif %}" id="slot-{{ slot_id }}-tab" href="{{ url_for(current_endpoint, slot=slot, sort_by=current_sort, order=current_order) }}" role="tab" aria-selected="{{ 'true' if slot == active_tab else 'false' }}">
<a class="campaign-slot-tab {% if slot == active_tab %}active{% endif %}" id="slot-{{ slot_id }}-tab" href="{{ url_for(current_endpoint, slot=slot, sort_by=current_sort, order=current_order, page=1, per_page=page_window.per_page) }}" role="tab" aria-selected="{{ 'true' if slot == active_tab else 'false' }}">
<span class="momo-mono">{{ slot }}</span>
<span class="campaign-slot-count momo-mono">{{ stats.get('on_shelf', 0) }} 件</span>
</a>
@@ -849,14 +885,27 @@
<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" data-campaign-visible-count style="color:var(--momo-text-tertiary);font-size:12px;">{{ items|length }} 筆</span>
<span class="momo-mono" data-campaign-visible-count style="color:var(--momo-text-tertiary);font-size:12px;">
{{ page_window.start_item }}-{{ page_window.end_item }} / {{ page_window.total_items }} 筆
</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 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>
<nav class="campaign-pagination" aria-label="{{ slot }} 商品分頁">
<a class="campaign-page-link" href="{{ url_for(current_endpoint, slot=active_tab, sort_by=current_sort, order=current_order, page=page_window.page - 1, per_page=page_window.per_page) }}" aria-disabled="{{ 'false' if page_window.has_prev else 'true' }}">
<i class="fas fa-chevron-left" aria-hidden="true"></i>
<span>上一頁</span>
</a>
<span>第 {{ page_window.page }} / {{ page_window.total_pages }} 頁</span>
<a class="campaign-page-link" href="{{ url_for(current_endpoint, slot=active_tab, sort_by=current_sort, order=current_order, page=page_window.page + 1, per_page=page_window.per_page) }}" aria-disabled="{{ 'false' if page_window.has_next else 'true' }}">
<span>下一頁</span>
<i class="fas fa-chevron-right" aria-hidden="true"></i>
</a>
</nav>
</div>
<div class="campaign-table-wrap">
<table class="campaign-table">
@@ -865,16 +914,16 @@
<th>分類 / 狀態</th>
<th>
{% set next_order_name = 'asc' if current_sort == 'name' and current_order == 'desc' else 'desc' %}
<a href="{{ url_for(current_endpoint, slot=active_tab, sort_by='name', order=next_order_name) }}">商品資訊</a>
<a href="{{ url_for(current_endpoint, slot=active_tab, sort_by='name', order=next_order_name, page=1, per_page=page_window.per_page) }}">商品資訊</a>
</th>
<th class="text-end">
{% set next_order_price = 'asc' if current_sort == 'price' and current_order == 'desc' else 'desc' %}
<a href="{{ url_for(current_endpoint, slot=active_tab, sort_by='price', order=next_order_price) }}">價格</a>
<a href="{{ url_for(current_endpoint, slot=active_tab, sort_by='price', order=next_order_price, page=1, per_page=page_window.per_page) }}">價格</a>
</th>
<th class="text-center">
{% if current_promo_page == 'edm' %}
{% set next_order_qty = 'asc' if current_sort == 'remain_qty' and current_order == 'desc' else 'desc' %}
<a href="{{ url_for(current_endpoint, slot=active_tab, sort_by='remain_qty', order=next_order_qty) }}">銷售 / 庫存</a>
<a href="{{ url_for(current_endpoint, slot=active_tab, sort_by='remain_qty', order=next_order_qty, page=1, per_page=page_window.per_page) }}">銷售 / 庫存</a>
{% else %}
狀態
{% endif %}
@@ -1061,12 +1110,12 @@
{% 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 = '';
let campaignChartLoader = null;
function getCSRFToken() {
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
@@ -1122,6 +1171,24 @@
}
}
function ensureCampaignChart() {
if (typeof Chart !== 'undefined') {
return Promise.resolve();
}
if (campaignChartLoader) {
return campaignChartLoader;
}
campaignChartLoader = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js';
script.async = true;
script.onload = resolve;
script.onerror = () => reject(new Error('Chart.js 載入失敗'));
document.head.appendChild(script);
});
return campaignChartLoader;
}
function updateCampaignHistoryRangeButtons() {
document.querySelectorAll('[data-campaign-history-range]').forEach(button => {
button.classList.toggle('is-active', button.dataset.campaignHistoryRange === activeCampaignHistoryRange);
@@ -1149,12 +1216,8 @@
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
if (typeof Chart === 'undefined') {
setCampaignHistoryChartState('圖表元件尚未載入完成,請重新整理後再試。');
return;
}
fetch(`/api/history/i-code/${encodeURIComponent(iCode)}?range=${activeCampaignHistoryRange}`)
ensureCampaignChart()
.then(() => fetch(`/api/history/i-code/${encodeURIComponent(iCode)}?range=${activeCampaignHistoryRange}`))
.then(response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);