This commit is contained in:
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user