Files
ewoooc/edm_dashboard.html
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

431 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>MOMO 限時搶購</title>
<meta http-equiv="refresh" content="300">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding-top: 70px;
}
.navbar-dark.bg-primary {
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
}
.navbar-dark .navbar-brand {
color: #ffffff !important;
font-weight: 600;
}
.navbar-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
transition: all 0.3s;
}
.navbar-dark .navbar-nav .nav-link:hover {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.1);
border-radius: 6px;
}
.navbar-dark .navbar-nav .nav-link.active {
color: #ffffff !important;
background: rgba(255, 255, 255, 0.15);
border-radius: 6px;
font-weight: 600;
}
.navbar-dark .navbar-text {
color: rgba(255, 255, 255, 0.8) !important;
}
.navbar {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%) !important;
}
/* 統一使用 dashboard 的柔和色調與樣式 */
.bg-success-soft { background-color: rgba(25, 135, 84, 0.1) !important; }
.bg-secondary-soft { background-color: rgba(108, 117, 125, 0.15) !important; }
.bg-danger-soft { background-color: rgba(220, 53, 69, 0.1) !important; }
.table-container { background: white; border-radius: .5rem; box-shadow: 0 0 1px rgba(0,0,0,.125),0 1px 3px rgba(0,0,0,.2); padding: 1.25rem; margin-bottom: 1.5rem; }
.nav-pills .nav-link.active { background-color: #0d6efd; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
.nav-pills .nav-link { color: #495057; font-weight: 500; }
.product-link { text-decoration: none; color: #212529; font-weight: 600; display: block; line-height: 1.4; margin-bottom: 4px; }
.product-link:hover { color: #0d6efd; }
.product-thumb { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; border: 1px solid #dee2e6; margin-right: 12px; }
.price-up { color: #dc3545; font-weight: bold; }
.price-down { color: #198754; font-weight: bold; }
.price-current { font-weight: 700; font-size: 1.1em; }
.price-old { text-decoration: line-through; color: #999; font-size: 0.9em; margin-right: 4px; }
.table th { font-weight: 600; color: #495057; border-bottom-width: 1px; }
.badge-status { font-size: 0.75em; vertical-align: middle; margin-left: 6px; }
.cursor-pointer { cursor: pointer; }
/* Custom Dark Gray Navbar */
.navbar.bg-custom-dark {
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.navbar.bg-custom-dark .navbar-brand {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-nav .nav-link {
color: rgba(255, 255, 255, 0.85);
font-weight: 500;
}
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
color: #ffffff;
}
.navbar.bg-custom-dark .navbar-nav .nav-link.active {
color: #ffffff;
font-weight: 600;
}
.navbar.bg-custom-dark .navbar-text {
color: rgba(255, 255, 255, 0.75);
}
</style>
</head>
<body class="bg-body-tertiary">
{% include 'components/_navbar.html' %}
<div class="container mb-5">
<!-- V-New: 動態頁籤導覽 -->
<div class="sub-nav-tabs mb-3">
{% for page in promo_pages %}
<a href="{{ page.url }}" class="btn {% if page.id == current_promo_page %}btn-primary{% else %}btn-outline-secondary{% endif %}">
{{ page.name }}
</a>
{% endfor %}
</div>
<div class="row mb-4">
<div class="col-md-8">
{# V-New: 使用動態頁面標題 #}
<h2>🔥 {{ page_title }}</h2>
<p class="text-muted">
活動時間: {{ activity_time }} |
最後更新: {{ last_update }} |
商品總數: {{ total_edm_products }}
</p>
</div>
<div class="col-md-4 text-end">
{# V-New: 根據不同頁面顯示不同按鈕 #}
{% if current_promo_page == 'edm' %}
<button class="btn btn-outline-primary" onclick="triggerEdmTask()">🔄 手動更新</button>
<button class="btn btn-outline-success" onclick="triggerNotification()">📢 發送通知</button>
{% elif current_promo_page == 'festival' %}
<button class="btn btn-outline-primary" onclick="triggerFestivalTask()">🔄 手動更新</button>
{% endif %}
</div>
</div>
{# V-New: 根據頁面類型讀取對應的排程統計 #}
{% set task_key = 'festival_task' if current_promo_page == 'festival' else 'edm_task' %}
{% set edm_stats_list = scheduler_stats.get(task_key, []) %}
{% if edm_stats_list %}
{% set latest_run = edm_stats_list[0] %}
<div class="alert alert-info small p-2 mb-3">
<i class="fas fa-info-circle me-1"></i>
<strong>排程統計:</strong>
上次執行於 {{ latest_run.last_run }}
共記錄 {{ latest_run.get('changed_records', 0) }} 筆異動。
狀態:
{% if latest_run.status == 'Success' %}
<span class="badge bg-success">成功</span>
{% else %}
<span class="badge bg-danger" title="{{ latest_run.get('error', '未知錯誤') }}">失敗</span>
{% endif %}
</div>
{% endif %}
<!-- 時段頁籤 -->
<ul class="nav nav-pills mb-3" id="pills-tab" role="tablist">
{% for slot, stats in slot_stats.items() %}
{% set slot_id = slugify(slot) %}
{% set pane_id = "pills-" ~ slot_id %}
{% set tab_id = pane_id ~ "-tab" %}
{% set target_selector = "#" ~ pane_id %}
{% set is_selected = 'true' if slot == active_tab else 'false' %}
{% set active_class = 'active' if slot == active_tab else '' %}
<li class="nav-item" role="presentation">
<button class="nav-link {{ active_class }} px-3 py-2"
id="{{ tab_id }}"
data-bs-toggle="pill"
data-bs-target="{{ target_selector }}"
type="button"
role="tab"
aria-controls="{{ pane_id }}"
aria-selected="{{ is_selected }}">
{{ slot }}
{# V-New: 改用 on_shelf 數量顯示,更直觀 #}
<span class="badge bg-white text-primary rounded-pill ms-2 border">{{ stats.on_shelf }}</span>
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content" id="pills-tabContent">
{% for slot, stats in slot_stats.items() %}
{% set slot_id = slugify(slot) %}
{% set pane_id = "pills-" ~ slot_id %}
{% set items = grouped_items.get(slot, []) %}
<div class="tab-pane fade {% if slot == active_tab %}show active{% endif %}"
id="{{ pane_id }}" role="tabpanel">
<div class="table-container">
<!-- 該時段統計 -->
<div class="d-flex align-items-center mb-3 pb-2 border-bottom">
<h5 class="mb-0 me-3">商品列表 ({{ items|length }}筆)</h5>
<div class="small">
<span class="badge bg-primary me-1">新品: {{ stats['new'] }}</span>
<span class="badge bg-danger me-1">漲價: {{ stats['up'] }}</span>
<span class="badge bg-success me-1">降價: {{ stats['down'] }}</span>
<span class="badge bg-secondary">下架: {{ stats.get('delisted_last_run', 0) }}</span>
</div>
</div>
{% set current_endpoint = 'edm_dashboard' if current_promo_page == 'edm' else 'festival_dashboard' %}
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr class="text-nowrap">
<th style="width: 10%;">分類</th>
<th style="width: 50%;">
{% set next_order_name = 'asc' if current_sort == 'name' and current_order == 'desc' else ('default' if current_sort == 'name' and current_order == 'asc' else 'desc') %}
<a href="{{ url_for(current_endpoint, sort_by='name' if next_order_name != 'default' else 'default', order=next_order_name) }}" class="text-decoration-none text-muted">
商品資訊 {% if current_sort == 'name' %}<i class="fas fa-sort-{{ 'down' if current_order == 'desc' else 'up' }}"></i>{% else %}<i class="fas fa-sort text-muted opacity-25"></i>{% endif %}
</a>
</th>
<th style="width: 20%;" class="text-end">
{% set next_order_price = 'asc' if current_sort == 'price' and current_order == 'desc' else ('default' if current_sort == 'price' and current_order == 'asc' else 'desc') %}
<a href="{{ url_for(current_endpoint, sort_by='price' if next_order_price != 'default' else 'default', order=next_order_price) }}" class="text-decoration-none text-muted">
價格 {% if current_sort == 'price' %}<i class="fas fa-sort-{{ 'down' if current_order == 'desc' else 'up' }}"></i>{% else %}<i class="fas fa-sort text-muted opacity-25"></i>{% endif %}
</a>
</th>
<th style="width: 20%;" class="text-center">
{% if current_promo_page == 'edm' %}
{% set next_order_qty = 'asc' if current_sort == 'remain_qty' and current_order == 'desc' else ('default' if current_sort == 'remain_qty' and current_order == 'asc' else 'desc') %}
<a href="{{ url_for(current_endpoint, sort_by='remain_qty' if next_order_qty != 'default' else 'default', order=next_order_qty) }}" class="text-decoration-none text-muted">
倒數組數 {% if current_sort == 'remain_qty' %}<i class="fas fa-sort-{{ 'down' if current_order == 'desc' else 'up' }}"></i>{% else %}<i class="fas fa-sort text-muted opacity-25"></i>{% endif %}
</a>
{% else %}
狀態
{% endif %}
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{% if item.main_category %}
{% set badge_attr = 'style="background-color: ' ~ item.category_color ~ '; color: #333;"' %}
<span class="badge" {{ badge_attr | safe }}>{{ item.main_category }}</span>
{% else %}
<span class="badge bg-light text-muted">未分類</span>
{% endif %}
</td>
<td>
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
{% if item.image_url %}
<img src="{{ item.image_url }}" class="product-thumb" alt="商品圖" loading="lazy" referrerpolicy="no-referrer">
{% else %}
<div class="product-thumb d-flex align-items-center justify-content-center bg-light text-muted small">無圖</div>
{% endif %}
</div>
<div class="flex-grow-1">
<a href="{{ item.url or '#' }}" target="_blank" class="product-link" title="{{ item.name }}">
{{ item.name }}
</a>
<div class="d-flex align-items-center flex-wrap gap-2">
<small class="text-muted fw-bold cursor-pointer" data-icode="{{ item.i_code }}" onclick="copyToClipboard(event, this.dataset.icode, this)" title="點擊複製品號">
ID: {{ item.i_code }} <i class="far fa-copy ms-1"></i>
</small>
{% if item.status_change == 'NEW' %}
<span class="badge bg-primary badge-status">NEW</span>
{% elif item.status_change == 'PRICE_DOWN' %}
<span class="badge bg-success-soft text-success badge-status">降價</span>
{% elif item.status_change == 'PRICE_UP' %}
<span class="badge bg-danger-soft text-danger badge-status">漲價</span>
{% elif item.status_change == 'DELISTED' or item.status_change == 'SLOT_END' %}
<span class="badge bg-secondary-soft text-secondary badge-status">下架</span>
{% endif %}
{% if item.discount_text %}
<span class="badge bg-danger bg-opacity-75 badge-status">{{ item.discount_text }}</span>
{% endif %}
</div>
</div>
</div>
</td>
<td class="text-end">
{% if item.previous_price and item.price and item.previous_price != item.price %}
{% set diff = item.price - item.previous_price %}
{% set percent = ((diff|abs) / item.previous_price * 100) | round | int %}
{% if diff < 0 %}
<div>
<span class="badge bg-success-soft text-success mb-1">▼ {{ (diff|abs) | number_format }} ({{ percent }}%)</span><br>
<span class="price-old text-muted">${{ item.previous_price | number_format }}</span>
<span class="price-current text-success">${{ item.price | number_format }}</span>
</div>
{% else %}
<div>
<span class="badge bg-danger-soft text-danger mb-1">▲ {{ diff | number_format }} ({{ percent }}%)</span><br>
<span class="price-old text-muted">${{ item.previous_price | number_format }}</span>
<span class="price-current text-danger">${{ item.price | number_format }}</span>
</div>
{% endif %}
{% else %}
<span class="price-current text-dark">${{ (item.price | number_format) if item.price is not none else 'N/A' }}</span>
{% endif %}
</td>
<td class="text-center">
{% if current_promo_page == 'edm' %}
{% set tooltip_content %}
<div class='text-start p-1'>
<strong>📈 當日銷售歷程:</strong><br>
{% for h in item.qty_history %}
<small>{{ h.time }}</small> &nbsp; 剩 {{ h.qty }} 組<br>
{% endfor %}
</div>
{% endset %}
<div {% if item.qty_history and item.qty_history|length > 1 %}
data-bs-toggle="tooltip"
data-bs-html="true"
title="{{ tooltip_content }}"
{% endif %}>
{% if item.remain_qty is not none %}
<span class="badge bg-warning text-dark border border-warning">🔥 {{ item.remain_qty | number_format }}組</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</div>
{% else %}
<span class="badge bg-light text-dark border">活動中</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// V-New: 初始化 Bootstrap Tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
// Helper function to get CSRF token
function getCSRFToken() {
return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
}
function triggerEdmTask() {
if(confirm('確定要手動執行 EDM 爬蟲嗎?')) {
fetch('/api/run_edm_task', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
function triggerNotification() {
if(confirm('確定要發送比價通知嗎?')) {
fetch('/api/trigger_edm_notification', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
// V-New: 觸發 Festival 爬蟲
function triggerFestivalTask() {
if(confirm('確定要手動執行「1.1狂歡購物節」爬蟲嗎?')) {
fetch('/api/run_festival_task', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
// V-New: 複製品號功能
function copyToClipboard(event, text, element) {
event.stopPropagation();
const showFeedback = () => {
const originalHtml = element.innerHTML;
element.innerHTML = '已複製 <i class="fas fa-check"></i>';
element.classList.remove('text-muted');
element.classList.add('text-success');
setTimeout(() => {
element.innerHTML = originalHtml;
element.classList.remove('text-success');
element.classList.add('text-muted');
}, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(showFeedback);
} else {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
showFeedback();
} catch (err) { console.error('複製失敗', err); }
document.body.removeChild(textArea);
}
}
</script>
</body>
</html>