Files
ewoooc/dashboard.html
OoO 38756fca71 fix(routes): 更新 dashboard 根模板首頁 endpoint
ADR-017 Phase 3f-1 dashboard follow-up;移除 app.py 首頁 route 後,根目錄 fallback dashboard.html 也改用 dashboard.index。
2026-04-29 21:13:47 +08:00

1405 lines
64 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 href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
/* ==================== 全局樣式 ====================*/
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
min-height: 100vh;
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;
}
/* ==================== KPI 卡片 ==================== */
.stat-card {
position: relative;
border: none;
border-radius: 20px !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12) !important;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-6px) scale(1.02);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.18) !important;
}
.stat-card .card-body {
padding: 1.5rem;
padding-top: 70px;
}
.stat-card .card-title {
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
opacity: 0.95;
margin-bottom: 0.5rem;
}
.stat-card h2 {
font-size: 2.2rem;
font-weight: 800;
letter-spacing: -0.5px;
margin-bottom: 0.3rem;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.stat-card small {
opacity: 0.9;
font-size: 0.85rem;
}
.stat-icon {
position: absolute;
right: -15px;
bottom: -15px;
font-size: 6rem;
opacity: 0.2;
transform: rotate(-15deg);
pointer-events: none;
}
/* ==================== 控制面板與表格容器 ==================== */
.card {
border: none;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
margin-bottom: 1.5rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #fff;
}
.card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.card-body {
padding: 1.5rem;
padding-top: 70px;
}
/* ==================== 按鈕樣式 ==================== */
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 10px;
padding: 0.6rem 1.5rem;
font-weight: 600;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
background: linear-gradient(135deg, #5568d3 0%, #6a3e8b 100%);
}
.btn-success {
background: linear-gradient(135deg, #51cf66 0%, #37b24d 100%);
border: none;
border-radius: 10px;
padding: 0.6rem 1.5rem;
font-weight: 600;
box-shadow: 0 4px 12px rgba(81, 207, 102, 0.3);
transition: all 0.3s ease;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(81, 207, 102, 0.4);
background: linear-gradient(135deg, #40c057 0%, #2f9e44 100%);
}
.btn-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
}
.btn-group .btn {
border-radius: 8px !important;
margin: 0;
}
.btn-group .btn-outline-secondary,
.btn-group .btn-outline-primary,
.btn-group .btn-outline-danger,
.btn-group .btn-outline-success {
border-color: #dee2e6;
transition: all 0.2s ease;
font-weight: 500;
}
.btn-group .btn-outline-secondary {
color: #6c757d;
}
.btn-group .btn-outline-primary {
color: #0d6efd;
}
.btn-group .btn-outline-danger {
color: #dc3545;
}
.btn-group .btn-outline-success {
color: #198754;
}
.btn-group .btn-outline-secondary:hover,
.btn-group .btn-outline-primary:hover,
.btn-group .btn-outline-danger:hover,
.btn-group .btn-outline-success:hover {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-color: #667eea;
color: #667eea;
}
.btn-group .btn-outline-secondary.active,
.btn-group .btn-outline-primary.active,
.btn-group .btn-outline-danger.active,
.btn-group .btn-outline-success.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
color: #fff !important;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
/* ==================== 表格樣式 ==================== */
.table-responsive {
border-radius: 12px;
overflow: hidden;
}
.table {
margin-bottom: 0;
}
.table thead {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
color: #fff !important;
}
.table thead th {
border: none !important;
font-weight: 600;
padding: 1rem;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.5px;
color: #fff !important;
background: transparent !important;
}
.table tbody tr {
transition: all 0.2s ease;
border-bottom: 1px solid #f0f0f0;
}
.table tbody tr:hover {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
transform: scale(1.002);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.1);
}
.table tbody td {
padding: 1rem;
vertical-align: middle;
color: #2c3e50 !important;
}
.sort-link {
text-decoration: none;
color: #fff !important;
transition: all 0.2s ease;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
font-weight: 600;
}
.sort-link:hover {
color: #fff !important;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
transform: translateY(-1px);
}
.sort-link.active {
color: #fff !important;
font-weight: 700;
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}
.sort-link i {
color: #fff !important;
opacity: 1;
}
/* ==================== 商品顯示 ==================== */
.product-thumb {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 12px;
margin-right: 16px;
border: 2px solid #f0f0f0;
transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.product-thumb:hover {
transform: scale(1.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border-color: #667eea;
}
.product-info {
max-width: 100%;
}
.product-link {
text-decoration: none;
color: #2c3e50;
font-weight: 600;
transition: all 0.2s ease;
}
.product-link:hover {
color: #667eea;
}
.product-name {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.5;
font-size: 0.95rem;
}
/* ==================== 價格與變動 ==================== */
.price-up {
color: #ff6b6b;
font-weight: 700;
}
.price-down {
color: #51cf66;
font-weight: 700;
}
.badge {
border-radius: 8px;
padding: 0.4rem 0.8rem;
font-weight: 600;
font-size: 0.8rem;
}
.badge-status {
font-size: 0.7em;
vertical-align: middle;
margin-left: 8px;
padding: 0.3em 0.6em;
border-radius: 6px;
}
.bg-danger-soft {
background: rgba(255, 107, 107, 0.15);
color: #ff6b6b;
}
.bg-success-soft {
background: rgba(81, 207, 102, 0.15);
color: #51cf66;
}
.bg-secondary-soft {
background: rgba(108, 117, 125, 0.15);
color: #6c757d;
}
/* ==================== 分頁 ==================== */
.pagination {
margin-top: 2rem;
}
.pagination .page-link {
color: #667eea;
border: 1px solid #dee2e6;
border-radius: 8px;
margin: 0 4px;
padding: 0.5rem 1rem;
transition: all 0.2s ease;
}
.pagination .page-link:hover {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-color: #667eea;
color: #667eea;
}
.pagination .page-item.active .page-link {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-color: #667eea;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.pagination .page-item.disabled .page-link {
color: #c0c0c0;
background: #f8f9fa;
}
/* ==================== 工具類 ==================== */
.fs-08 {
font-size: 0.8em;
}
.cursor-pointer {
cursor: pointer;
}
.user-select-all {
user-select: all;
-webkit-user-select: all;
}
/* 商品 ID 樣式 */
.product-id {
font-size: 0.875rem !important;
font-weight: 600 !important;
color: #667eea !important;
}
.product-id:hover {
color: #764ba2 !important;
}
/* 價格顯示互動效果 */
.price-display {
cursor: pointer;
transition: all 0.3s ease;
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 8px;
}
.price-display:hover {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
/* 歷史圖表 Modal 增強 */
.modal-content {
border: none;
border-radius: 20px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}
.modal-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-radius: 20px 20px 0 0;
border: none;
}
.modal-header .btn-close {
filter: brightness(0) invert(1);
}
.modal-body {
padding: 2rem;
padding-top: 70px;
}
/* ==================== V9.2: 新版 KPI 卡片樣式 ==================== */
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-top: 1rem;
}
.kpi-item {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
border-radius: 12px;
padding: 1rem;
text-align: center;
transition: all 0.3s ease;
border: 2px solid transparent;
cursor: pointer;
}
.kpi-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.15);
border-color: #667eea;
}
.kpi-item-label {
font-size: 0.85rem;
color: #6c757d;
font-weight: 600;
margin-bottom: 0.5rem;
}
.kpi-item-value {
font-size: 1.5rem;
font-weight: 800;
color: #2c3e50;
}
.kpi-item-sub {
font-size: 0.75rem;
color: #6c757d;
margin-top: 0.25rem;
}
.kpi-item.increase .kpi-item-value {
color: #dc3545;
}
.kpi-item.decrease .kpi-item-value {
color: #28a745;
}
.kpi-item.neutral .kpi-item-value {
color: #6c757d;
}
.kpi-item-full {
grid-column: 1 / -1;
background: linear-gradient(135deg, #fff5f7 0%, #ffe8ed 100%);
text-align: left;
}
.kpi-item-full .kpi-item-value {
font-size: 1.2rem;
}
/* ==================== 響應式設計 ==================== */
@media (max-width: 768px) {
.product-thumb {
width: 60px;
height: 60px;
}
.stat-card h2 {
font-size: 1.8rem;
}
.table thead th {
font-size: 0.75rem;
padding: 0.75rem 0.5rem;
}
.table tbody td {
padding: 0.75rem 0.5rem;
font-size: 0.85rem;
}
/* V9.2: 手機版 KPI 網格調整 */
.kpi-grid {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.kpi-item-value {
font-size: 1.3rem;
}
/* V9.5: 手機版彈窗優化 */
#priceChangeModal .modal-dialog {
max-width: 95vw;
margin: 0.5rem auto;
}
#priceChangeModal .modal-body {
padding: 0.75rem;
padding-top: 70px;
}
#priceChangeModal .table {
font-size: 0.75rem;
}
#priceChangeModal .table thead th {
padding: 0.5rem 0.3rem;
font-size: 0.7rem;
}
#priceChangeModal .table tbody td {
padding: 0.5rem 0.3rem;
font-size: 0.75rem;
}
#priceChangeModal .product-thumb {
width: 50px !important;
height: 50px !important;
}
#priceChangeModal .btn-sm {
font-size: 0.75rem;
padding: 0.4rem 0.8rem;
}
/* 手機版隱藏較不重要的欄位以節省空間 */
#priceChangeModal .table th:nth-child(4),
#priceChangeModal .table td:nth-child(4) {
display: none;
/* 隱藏分類欄位 */
}
}
/* 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">
<!-- V9.2: 新版 2 張 KPI 卡片 -->
<div class="row g-4 mb-4">
<!-- 卡片 1: 商品監控概況 -->
<div class="col-lg-4">
<div class="card stat-card bg-primary text-white h-100">
<div class="card-body">
<h5 class="card-title fw-bold mb-3">
<i class="fas fa-chart-pie me-2"></i>商品監控概況
</h5>
<div class="row g-3">
<div class="col-6">
<div class="text-center p-3 rounded d-flex flex-column justify-content-between"
style="background: rgba(255,255,255,0.15); min-height: 100px;">
<div class="small text-white-50 mb-2">監控總數</div>
<h2 class="mb-0 fw-bold">{{ total_products | number_format }}</h2>
<div style="height: 1rem;"></div>
</div>
</div>
<div class="col-6">
<div class="text-center p-3 rounded d-flex flex-column justify-content-between"
style="background: rgba(255,255,255,0.15); min-height: 100px;">
<div class="small text-white-50 mb-2">今日新增</div>
<h2 class="mb-0 fw-bold">{{ today_new_products }}</h2>
<div style="height: 1rem;"></div>
</div>
</div>
<div class="col-6">
<div class="text-center p-3 rounded d-flex flex-column justify-content-between"
style="background: rgba(255,255,255,0.15); min-height: 100px;">
<div class="small text-white-50 mb-2">週增長</div>
<h2 class="mb-0 fw-bold">{{ week_new_products }}</h2>
<div style="height: 1rem;"></div>
</div>
</div>
<div class="col-6">
<div class="text-center p-3 rounded d-flex flex-column justify-content-between"
style="background: rgba(255,255,255,0.15); min-height: 100px;">
<div class="small text-white-50 mb-2">穩定商品</div>
<h2 class="mb-0 fw-bold">{{ stable_count }}</h2>
<div class="small text-white-50">7天未變價</div>
</div>
</div>
</div>
<i class="fas fa-boxes-stacked stat-icon"></i>
</div>
</div>
</div>
<!-- 卡片 2: 今日價格動態 -->
<div class="col-lg-8">
<div class="card h-100">
<div class="card-body">
<h5 class="fw-bold mb-3" style="color: #667eea;">
<i class="fas fa-chart-line me-2"></i>今日價格動態
</h5>
<!-- 上排:主要變動指標 -->
<div class="kpi-grid">
<div class="kpi-item increase" data-type="increase"
onclick="showPriceChangeModal('increase', '漲價商品')">
<div class="kpi-item-label"><i class="fas fa-arrow-up me-1"></i>漲價</div>
<div class="kpi-item-value">{{ cnt_increase }}</div>
<div class="kpi-item-sub">件商品</div>
</div>
<div class="kpi-item decrease" data-type="decrease"
onclick="showPriceChangeModal('decrease', '降價商品')">
<div class="kpi-item-label"><i class="fas fa-arrow-down me-1"></i>降價</div>
<div class="kpi-item-value">{{ cnt_decrease }}</div>
<div class="kpi-item-sub">件商品</div>
</div>
<div class="kpi-item neutral" data-type="delisted"
onclick="showPriceChangeModal('delisted', '下架商品')">
<div class="kpi-item-label"><i class="fas fa-eye-slash me-1"></i>下架</div>
<div class="kpi-item-value">{{ today_delisted_count }}</div>
<div class="kpi-item-sub">件商品</div>
</div>
<!-- 中排:價格變動分析 -->
<div class="kpi-item increase" data-type="increase"
onclick="showPriceChangeModal('increase', '漲價商品')">
<div class="kpi-item-label">平均漲幅</div>
<div class="kpi-item-value">+${{ avg_increase | abs | int | number_format }}</div>
<div class="kpi-item-sub">{{ cnt_increase }} 件平均</div>
</div>
<div class="kpi-item decrease" data-type="decrease"
onclick="showPriceChangeModal('decrease', '降價商品')">
<div class="kpi-item-label">平均跌幅</div>
<div class="kpi-item-value">-${{ avg_decrease | abs | int | number_format }}</div>
<div class="kpi-item-sub">{{ cnt_decrease }} 件平均</div>
</div>
<div class="kpi-item neutral" data-type="active"
onclick="showPriceChangeModal('active', '今日活躍商品')">
<div class="kpi-item-label"><i class="fas fa-bolt me-1"></i>活躍度</div>
<div class="kpi-item-value">{{ activity_rate | round(1) }}%</div>
<div class="kpi-item-sub">{{ active_count }} 件有變動</div>
</div>
<!-- 下排:最活躍分類與最大變動 -->
{% if most_active_category %}
<div class="kpi-item" style="grid-column: 1 / 3;" data-type="category"
data-category="{{ most_active_category }}"
onclick="showPriceChangeModal('category', '{{ most_active_category }}')">
<div class="kpi-item-label"><i class="fas fa-fire me-1"></i>最活躍分類</div>
<div class="kpi-item-value" style="font-size: 1.2rem;">{{ most_active_category }}</div>
<div class="kpi-item-sub">{{ most_active_count }} 件商品變動</div>
</div>
{% endif %}
{% if max_change_item %}
<div class="kpi-item {% if max_change_value > 0 %}increase{% else %}decrease{% endif %}"
data-type="maxchange" data-product-id="{{ max_change_item.record.product.i_code }}"
onclick="showPriceChangeModal('max_change', '最大變動商品', '{{ max_change_item.record.product.i_code }}')">
<div class="kpi-item-label"><i class="fas fa-medal me-1"></i>最大變動</div>
<div class="kpi-item-value">
{% if max_change_value > 0 %}+{% endif %}${{ max_change_value | abs | int |
number_format }}
</div>
<div class="kpi-item-sub" title="{{ max_change_item.record.product.name }}">
{{ max_change_item.record.product.name[:20] }}{% if
max_change_item.record.product.name|length > 20 %}...{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- 控制列 (搜尋與篩選) -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="/" class="row g-3 align-items-center">
<div class="col-md-4">
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="搜尋商品名稱或品號..."
value="{{ search_query }}">
</div>
</div>
<div class="col-md-3">
<select name="category" class="form-select" onchange="this.form.submit()">
<option value="all">所有分類 ({{unique_items|length}})</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if current_category==cat %}selected{% endif %}>{{ cat }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<div class="d-flex flex-wrap gap-2 justify-content-md-end">
<div class="btn-group flex-wrap" role="group">
<a href="/?filter=all"
class="btn btn-outline-secondary btn-sm {% if current_filter == 'all' %}active{% endif %}">全部</a>
<a href="/?filter=new"
class="btn btn-outline-primary btn-sm {% if current_filter == 'new' %}active{% endif %}">新上架</a>
<a href="/?filter=increase"
class="btn btn-outline-danger btn-sm {% if current_filter == 'increase' %}active{% endif %}">漲價</a>
<a href="/?filter=decrease"
class="btn btn-outline-success btn-sm {% if current_filter == 'decrease' %}active{% endif %}">降價</a>
<a href="/?filter=delisted"
class="btn btn-outline-secondary btn-sm {% if current_filter == 'delisted' %}active{% endif %}">下架</a>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="triggerTask()">
<i class="fas fa-sync-alt"></i> 更新
</button>
<button type="button" class="btn btn-success btn-sm" onclick="triggerNotification()">
<i class="fas fa-bell"></i> 發送通知
</button>
</div>
</div>
</form>
</div>
</div>
<!-- 商品列表 -->
<div class="card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="d-flex align-items-center gap-4">
<h5 class="mb-0 fw-bold">商品列表 ({{ total_items }}筆)</h5>
{% set momo_stats_list = scheduler_stats.get('momo_task', []) %}
{% if momo_stats_list %}
{% set latest_run = momo_stats_list[0] %}
{% set prev_run = momo_stats_list[1] if momo_stats_list|length > 1 else None %}
<div class="text-muted small" data-bs-toggle="tooltip" data-bs-html="true" title="
<strong>上次執行:</strong> {{ latest_run.last_run }}<br>
{% if prev_run %}<strong>前次執行:</strong> {{ prev_run.last_run }}{% endif %}
">
<i class="fas fa-history"></i>
<strong>上次排程 ({{ latest_run.last_run.split(' ')[1] }}):</strong>
{% if latest_run.status == 'Success' %}
<span class="ms-1 text-dark">掃描 {{ latest_run.scraped_count | default(0) }} 筆</span>
<span class="ms-1 text-dark">新增 {{ latest_run.new_products | default(0) }} 項</span>
<span class="ms-1 text-success fw-bold">成功</span>
{% else %}
<span class="ms-1 text-danger fw-bold" title="{{ latest_run.error }}">失敗</span>
{% endif %}
{% endif %}
</div>
</div>
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" id="exportMenu"
data-bs-toggle="dropdown">
<i class="fas fa-download"></i> 匯出報表
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="/api/export/excel/all">匯出全部商品 (Excel - 分類分頁)</a></li>
<li><a class="dropdown-item" href="/api/export/excel/changes">匯出漲跌商品 (Excel - 漲/跌分頁)</a>
</li>
<li><a class="dropdown-item" href="/api/export/excel/delisted">匯出下架商品 (Excel)</a></li>
</ul>
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr class="text-nowrap">
<th style="width: 10%;">
<a href="{% if current_sort == 'category' %}{% if current_order == 'asc' %}{{ url_for('dashboard.index', page=1, sort_by='category', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('dashboard.index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('dashboard.index', page=1, sort_by='category', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% endif %}"
class="sort-link {% if current_sort == 'category' %}active{% endif %}">分類 {% if
current_sort == 'category' %}<i
class="fas fa-sort-{{ 'up' if current_order == 'asc' else 'down' }}"></i>{% else
%}<i class="fas fa-sort text-muted opacity-50"></i>{% endif %}</a>
</th>
<th style="width: 36%;">
<a href="{% if current_sort == 'name' %}{% if current_order == 'asc' %}{{ url_for('dashboard.index', page=1, sort_by='name', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('dashboard.index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('dashboard.index', page=1, sort_by='name', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% endif %}"
class="sort-link {% if current_sort == 'name' %}active{% endif %}">商品名稱 {% if
current_sort == 'name' %}<i
class="fas fa-sort-{{ 'up' if current_order == 'asc' else 'down' }}"></i>{% else
%}<i class="fas fa-sort text-muted opacity-50"></i>{% endif %}</a>
</th>
<th style="width: 10%;" class="text-end">
<a href="{% if current_sort == 'price' %}{% if current_order == 'desc' %}{{ url_for('dashboard.index', page=1, sort_by='price', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('dashboard.index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('dashboard.index', page=1, sort_by='price', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% endif %}"
class="sort-link {% if current_sort == 'price' %}active{% endif %}">當天價格 {% if
current_sort == 'price' %}<i
class="fas fa-sort-{{ 'up' if current_order == 'asc' else 'down' }}"></i>{% else
%}<i class="fas fa-sort text-muted opacity-50"></i>{% endif %}</a>
</th>
<th style="width: 14%;" class="text-end">
<a href="{% if current_sort == 'yesterday_change' %}{% if current_order == 'desc' %}{{ url_for('dashboard.index', page=1, sort_by='yesterday_change', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('dashboard.index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('dashboard.index', page=1, sort_by='yesterday_change', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% endif %}"
class="sort-link {% if current_sort == 'yesterday_change' %}active{% endif %}">昨日漲跌
{% if current_sort == 'yesterday_change' %}<i
class="fas fa-sort-{{ 'up' if current_order == 'asc' else 'down' }}"></i>{% else
%}<i class="fas fa-sort text-muted opacity-50"></i>{% endif %}</a>
</th>
<th style="width: 10%;" class="text-end">
<a href="{% if current_sort == 'week_change' %}{% if current_order == 'desc' %}{{ url_for('dashboard.index', page=1, sort_by='week_change', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('dashboard.index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('dashboard.index', page=1, sort_by='week_change', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% endif %}"
class="sort-link {% if current_sort == 'week_change' %}active{% endif %}">週漲跌 {% if
current_sort == 'week_change' %}<i
class="fas fa-sort-{{ 'up' if current_order == 'asc' else 'down' }}"></i>{% else
%}<i class="fas fa-sort text-muted opacity-50"></i>{% endif %}</a>
</th>
<th style="width: 10%;" class="text-end">
<a href="{% if current_sort == 'timestamp' %}{% if current_order == 'desc' %}{{ url_for('dashboard.index', page=1, sort_by='timestamp', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('dashboard.index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('dashboard.index', page=1, sort_by='timestamp', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% endif %}"
class="sort-link {% if current_sort == 'timestamp' %}active{% endif %}">更新時間 {% if
current_sort == 'timestamp' %}<i
class="fas fa-sort-{{ 'up' if current_order == 'asc' else 'down' }}"></i>{% else
%}<i class="fas fa-sort text-muted opacity-50"></i>{% endif %}</a>
</th>
<th style="width: 10%;" class="text-end">
上架時間
</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr onclick="handleRowClick(event, this.dataset.id, this)"
data-id="{{ item.record.product.id }}" data-name="{{ item.record.product.name|e }}"
class="cursor-pointer" title="點擊查看歷史價格圖表">
{% set badge_attr = 'style="background-color: ' ~ item.category_color ~ '; color: #333;"' %}
<td><span class="badge" {{ badge_attr | safe }}>{{ item.record.product.category }}</span>
</td>
<td class="product-info">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
{% if item.record.product.image_url %}
<img src="{{ item.record.product.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.record.product.url }}" target="_blank"
class="product-link product-name" title="{{ item.record.product.name|e }}">
{{ item.record.product.name }}
</a>
<div class="d-flex align-items-center mt-1">
<span class="product-id cursor-pointer"
data-icode="{{ item.record.product.i_code }}"
onclick="copyToClipboard(event, this.dataset.icode, this)"
title="點擊複製品號">
ID: {{ item.record.product.i_code }} <i class="far fa-copy ms-1"></i>
</span>
{% if item.status == 'PRICE_UP' %}
<span class="badge bg-danger-soft text-danger badge-status">漲價</span>
{% elif item.status == 'PRICE_DOWN' %}
<span class="badge bg-success-soft text-success badge-status">降價</span>
{% elif item.status == 'DELISTED' %}
<span class="badge bg-secondary-soft text-secondary badge-status">下架</span>
{% endif %}
</div>
</div>
</div>
</td>
{% set tooltip_content %}
<div class='text-start'>
{% if item.today_changes %}
<strong>📅 今日變價歷程:</strong><br>
{% for change in item.today_changes|reverse %}
{% if change.diff > 0 %}
{% set color_class = 'text-danger' %}
{% set arrow = '▲' %}
{% else %}
{% set color_class = 'text-success' %}
{% set arrow = '▼' %}
{% endif %}
{% set diff_val = change.diff|abs %}
<small>{{ change.time }}</small> &nbsp; ${{ change.price | number_format }} <span
class='{{ color_class }} fs-08'>({{ arrow }}{{ diff_val }})</span><br>
{% endfor %}
{% else %}
今日價格無波動
{% endif %}
</div>
{% endset %}
<td class="text-end" data-bs-toggle="tooltip" data-bs-html="true"
title="{{ tooltip_content }}">
<span class="price-display fw-bold fs-5">${{ item.record.price | number_format }}</span>
</td>
<td class="text-end">
{% if item.yesterday_diff != 0 %}
{% set old_price = item.record.price - item.yesterday_diff %}
{% set percent_change = (item.yesterday_diff / old_price * 100) | round(1) if old_price
> 0 else 0 %}
<div class="{{ 'price-up' if item.yesterday_diff > 0 else 'price-down' }}">
<span class="fw-bold">{{ '▲' if item.yesterday_diff > 0 else '▼' }} {{
item.yesterday_diff | abs | number_format }}</span>
<small class="ms-1">({{ percent_change }}%)</small>
</div>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-end">
{% if item.stats['7d_diff'] > 0 %}
<span class="price-up">+{{ item.stats['7d_diff'] | number_format }}</span>
{% elif item.stats['7d_diff'] < 0 %} <span class="price-down">{{ item.stats['7d_diff'] |
number_format }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td class="text-muted small text-end">{{ item.record.timestamp.strftime('%m-%d %H:%M') }}
</td>
<td class="text-muted small text-end">
{% if item.safe_created_at %}
{{ item.safe_created_at.strftime('%m-%d %H:%M') }}
{% else %}
-
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-5 text-muted">
{% if search_query %}
找不到與「{{ search_query }}」相關的商品
{% else %}
沒有符合條件的商品
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- 分頁 -->
{% if total_pages > 1 %}
<nav class="mt-4">
<ul class="pagination justify-content-center">
<li class="page-item {% if current_page == 1 %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('dashboard.index', page=current_page - 1, category=current_category, filter=current_filter, q=search_query, sort_by=current_sort, order=current_order) }}">上一頁</a>
</li>
<li class="page-item disabled"><span class="page-link">第 {{ current_page }} / {{ total_pages }}
</span></li>
<li class="page-item {% if current_page == total_pages %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('dashboard.index', page=current_page + 1, category=current_category, filter=current_filter, q=search_query, sort_by=current_sort, order=current_order) }}">下一頁</a>
</li>
</ul>
</nav>
{% endif %}
</div>
</div>
<!-- V9.82: 歷史價格圖表 Modal -->
<div class="modal fade" id="historyModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="historyModalLabel">歷史價格走勢</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<canvas id="priceChart" style="max-height: 400px;"></canvas>
</div>
</div>
</div>
</div>
<!-- V9.3: 價格變動商品明細彈窗 -->
<div class="modal fade" id="priceChangeModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="priceChangeModalLabel">商品明細</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<span class="badge bg-primary" id="modalProductCount">0 件商品</span>
</div>
<button class="btn btn-success btn-sm" onclick="exportToExcel()">
<i class="fas fa-file-excel me-1"></i>匯出 Excel
</button>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead class="table-light">
<tr>
<th style="width: 80px;">商品圖</th>
<th style="width: 100px;">商品ID</th>
<th>商品名稱</th>
<th style="width: 120px;">分類</th>
<th style="width: 100px;">原價格</th>
<th style="width: 100px;">現價格</th>
<th style="width: 100px;">變動</th>
<th style="width: 120px;">更新時間</th>
</tr>
</thead>
<tbody id="modalProductList">
<tr>
<td colspan="8" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">載入中...</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// V9.80: 初始化 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 triggerTask() {
if (confirm('確定要手動執行全站爬蟲嗎?(可能需要一段時間)')) {
fetch('/api/run_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_momo_notification', {
method: 'POST',
headers: {
'X-CSRFToken': getCSRFToken()
}
})
.then(r => r.json())
.then(data => alert(data.message))
.catch(e => alert('錯誤: ' + e));
}
}
// V9.82: 處理商品列點擊與圖表繪製
let priceChartInstance = null;
function handleRowClick(event, productId, row) {
// 如果點擊的是連結或按鈕,不觸發圖表
if (event.target.closest('a') || event.target.closest('button')) return;
const productName = row.getAttribute('data-name');
showHistory(productId, productName);
}
function showHistory(productId, productName) {
if (typeof Chart === 'undefined') {
alert('圖表元件尚未載入完成,請檢查網路或重新整理頁面。');
return;
}
const modalEl = document.getElementById('historyModal');
// V-Fix: 優先獲取現有 Modal 實例,避免重複建立導致無法開啟或關閉
let modal = bootstrap.Modal.getInstance(modalEl);
if (!modal) {
modal = new bootstrap.Modal(modalEl);
}
document.getElementById('historyModalLabel').innerText = productName;
modal.show();
// 清除舊圖表
if (priceChartInstance) {
priceChartInstance.destroy();
priceChartInstance = null;
}
// 顯示載入中... (可選)
fetch(`/api/history/${productId}`)
.then(r => r.json())
.then(data => {
const ctx = document.getElementById('priceChart').getContext('2d');
// 建立漸變色
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, 'rgba(102, 126, 234, 0.3)');
gradient.addColorStop(1, 'rgba(118, 75, 162, 0.05)');
priceChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => d.t),
datasets: [{
label: '價格',
data: data.map(d => d.p),
borderColor: '#667eea',
backgroundColor: gradient,
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 8,
pointBackgroundColor: '#667eea',
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointHoverBackgroundColor: '#764ba2',
pointHoverBorderColor: '#fff',
pointHoverBorderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#667eea',
borderWidth: 2,
padding: 12,
displayColors: false,
callbacks: {
label: function (context) {
return ' 💰 $' + context.parsed.y.toLocaleString();
}
}
},
legend: { display: false }
},
scales: {
y: {
beginAtZero: false,
grid: {
color: 'rgba(0, 0, 0, 0.05)',
drawBorder: false
},
ticks: {
color: '#667eea',
font: {
weight: '600'
},
callback: function (value) { return '$' + value.toLocaleString(); }
}
},
x: {
grid: {
display: false
},
ticks: {
color: '#6c757d',
font: {
size: 11
}
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
}
}
});
})
.catch(err => console.error("圖表載入失敗:", err));
}
// V9.83: 複製品號功能 (增強視覺回饋)
function copyToClipboard(event, text, element) {
event.stopPropagation(); // 阻止觸發 tr 點擊 (避免打開圖表)
const showFeedback = () => {
const originalHtml = element.innerHTML;
element.innerHTML = '✅ 已複製!';
element.style.transform = 'scale(1.1)';
element.style.transition = 'all 0.3s ease';
setTimeout(() => {
element.innerHTML = originalHtml;
element.style.transform = 'scale(1)';
}, 1500);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(showFeedback);
} else {
// Fallback for HTTP (非安全連線時的備案)
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);
}
}
// V9.3: 顯示價格變動商品明細彈窗
let currentFilterType = '';
let currentFilterCategory = '';
function showPriceChangeModal(type, title, productId = '') {
currentFilterType = type;
currentFilterCategory = (type === 'category') ? title : '';
const modalEl = document.getElementById('priceChangeModal');
let modal = bootstrap.Modal.getInstance(modalEl);
if (!modal) {
modal = new bootstrap.Modal(modalEl);
}
document.getElementById('priceChangeModalLabel').innerText = title;
modal.show();
// 載入資料
const tbody = document.getElementById('modalProductList');
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4"><div class="spinner-border text-primary" role="status"><span class="visually-hidden">載入中...</span></div></td></tr>';
// 構建 API URL如果有 productId 就加上
let apiUrl = `/api/price_change_details?type=${type}&category=${encodeURIComponent(currentFilterCategory)}`;
if (productId) {
apiUrl += `&product_id=${encodeURIComponent(productId)}`;
}
fetch(apiUrl)
.then(r => r.json())
.then(data => {
if (data.products && data.products.length > 0) {
document.getElementById('modalProductCount').innerText = `${data.products.length} 件商品`;
// XSS 防護:對 API 回傳的字串欄位進行 HTML 轉義
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
let html = '';
data.products.forEach(p => {
const changeClass = p.change > 0 ? 'text-danger' : (p.change < 0 ? 'text-success' : 'text-muted');
const changeIcon = p.change > 0 ? '↑' : (p.change < 0 ? '↓' : '');
const changeText = p.change > 0 ? `+$${Math.abs(p.change).toLocaleString()}` : (p.change < 0 ? `-$${Math.abs(p.change).toLocaleString()}` : '$0');
const safeImageUrl = escapeHtml(p.image_url);
const safeUrl = escapeHtml(p.url);
const safeProductId = escapeHtml(p.product_id);
const safeName = escapeHtml(p.name);
const safeCategory = escapeHtml(p.category);
const safeUpdateTime = escapeHtml(p.update_time);
html += `
<tr>
<td><img src="${safeImageUrl}" class="product-thumb" onerror="this.src='/static/placeholder.png'" style="width: 60px; height: 60px; object-fit: cover; border-radius: 8px;"></td>
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none">${safeProductId}</a></td>
<td><a href="${safeUrl}" target="_blank" class="text-decoration-none" title="${safeName}">${safeName}</a></td>
<td><span class="badge bg-secondary">${safeCategory || '未分類'}</span></td>
<td>$${(p.old_price || 0).toLocaleString()}</td>
<td><strong>$${(p.current_price || 0).toLocaleString()}</strong></td>
<td><strong class="${changeClass}">${changeIcon} ${changeText}</strong></td>
<td class="small text-muted">${safeUpdateTime}</td>
</tr>
`;
});
tbody.innerHTML = html;
} else {
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">無資料</td></tr>';
}
})
.catch(e => {
console.error('載入資料失敗', e);
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-danger">載入失敗,請重試</td></tr>';
});
}
// V9.3: 匯出 Excel
function exportToExcel() {
if (!currentFilterType) {
alert('無法匯出,請先選擇要查看的項目');
return;
}
window.location.href = `/api/export/price_changes?type=${currentFilterType}&category=${encodeURIComponent(currentFilterCategory)}`;
}
</script>
</body>
</html>