Some checks failed
CD Pipeline / deploy (push) Failing after 2m59s
P0-06: google_drive_service.py — pickle.load() 改 JSON token(消除 RCE 風險) P0-07: bot_api_routes.py:30 — BOT_API_TOKEN 移除硬編碼預設值 clawdbot_momo_2026 P0-08: auto_import_index.html — showAlert innerHTML 改 createTextNode(XSS 修復) P0-09: abc_analysis_detail.html + dashboard.html + daily_sales.html — Jinja2 | e 轉義 P0-10: openclaw_bot_routes.py:2634 — vendor PPT 補 return ppt_path(廠商報告恢復) P0-11: telegram_bot_service.py:177-214 — cmd_start/cmd_help 補 try/except P0-12: app.py:689-712 — 10 個 Blueprint 補齊 register(消滅 404 路由) P0-13: auto_heal_service.py — 實作 _write_heal_log(),AIOps 稽核閉環補完 P0-14: monitoring/prometheus.yml — 取消 alert_rules comment;新增 alert_rules.yml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1405 lines
63 KiB
HTML
1405 lines
63 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 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('index', page=1, sort_by='category', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('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('index', page=1, sort_by='name', order='desc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('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('index', page=1, sort_by='price', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('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('index', page=1, sort_by='yesterday_change', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('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('index', page=1, sort_by='week_change', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('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('index', page=1, sort_by='timestamp', order='asc', category=current_category, filter=current_filter, q=search_query) }}{% else %}{{ url_for('index', page=1, category=current_category, filter=current_filter, q=search_query) }}{% endif %}{% else %}{{ url_for('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> ${{ 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('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('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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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> |