Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 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>
431 lines
23 KiB
HTML
431 lines
23 KiB
HTML
<!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> 剩 {{ 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> |