diff --git a/config.py b/config.py index 0db4bd1..453c705 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.163" +SYSTEM_VERSION = "V10.164" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 9fb6530..7284ed5 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -3,979 +3,7 @@ {% block title %}EwoooC 商品看板{% endblock %} {% block extra_css %} - + {% endblock %} {% block ewooo_content %} @@ -1348,8 +376,7 @@ data-history-trigger data-product-id="{{ product.id }}" data-product-name="{{ product.name|e }}" - onclick="event.stopPropagation(); showHistory(this.dataset.productId, this.dataset.productName);" - aria-label="查看 {{ product.name|e }} 的歷史價格圖表" + aria-label="查看歷史價格圖表" > ${{ item.record.price | int | number_format }} @@ -1493,376 +520,5 @@ {% endblock %} {% block extra_js %} - - + {% endblock %} diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css new file mode 100644 index 0000000..fa25cc5 --- /dev/null +++ b/web/static/css/page-dashboard-v2.css @@ -0,0 +1,972 @@ +/* Dashboard V2 page-specific styles extracted from dashboard_v2.html. */ +.dashboard-v2-stack { + display: grid; + gap: 24px; + min-width: 0; + max-width: 100%; + } + + .dashboard-v2-stack > section, + .dashboard-filter-card, + .dashboard-table-card, + .dashboard-table-wrap { + min-width: 0; + max-width: 100%; + } + + .dashboard-section-label { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 12px; + min-width: 0; + flex-wrap: wrap; + } + + .dashboard-section-label .num { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--momo-text-tertiary); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + } + + .dashboard-section-label .num::after { + display: inline-block; + width: 56px; + height: 6px; + content: ""; + background-image: radial-gradient(circle, var(--momo-text-tertiary) 1px, transparent 1px); + background-size: 6px 6px; + opacity: 0.5; + } + + .dashboard-section-label .title { + color: var(--momo-text-primary); + font-size: 13px; + font-weight: 800; + letter-spacing: 0; + } + + .dashboard-section-label .meta { + margin-left: auto; + color: var(--momo-text-tertiary); + font-size: 10px; + min-width: 0; + overflow-wrap: anywhere; + } + + .dashboard-kpi-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + overflow: hidden; + background: var(--momo-bg-surface); + border: 1px solid var(--momo-border-light); + border-radius: 8px; + } + + .dashboard-kpi { + position: relative; + min-width: 0; + padding: 20px 24px; + overflow: hidden; + border-right: 1px solid var(--momo-border-light); + } + + .dashboard-kpi::before { + position: absolute; + inset: 0; + content: ""; + background-image: radial-gradient(circle, rgba(42, 37, 32, 0.12) 1px, transparent 1px); + background-size: 8px 8px; + opacity: 0.28; + pointer-events: none; + } + + .dashboard-kpi > * { + position: relative; + } + + .dashboard-kpi:last-child { + border-right: 0; + } + + .dashboard-kpi.is-accent { + color: var(--momo-text-inverse); + background: var(--momo-ink); + } + + .dashboard-kpi.is-accent::before { + background-image: radial-gradient(circle, rgba(250, 247, 240, 0.18) 1px, transparent 1px); + opacity: 0.55; + } + + .dashboard-kpi-label { + margin-bottom: 10px; + color: var(--momo-text-tertiary); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.10em; + text-transform: uppercase; + } + + .dashboard-kpi.is-accent .dashboard-kpi-label, + .dashboard-kpi.is-accent .dashboard-kpi-sub { + color: rgba(250, 247, 240, 0.68); + } + + .dashboard-kpi-value { + margin-bottom: 8px; + color: var(--momo-text-primary); + font-size: 34px; + font-weight: 800; + letter-spacing: 0; + line-height: 1; + } + + .dashboard-kpi-value.is-small { + font-size: 20px; + letter-spacing: 0; + line-height: 1.15; + } + + .dashboard-kpi-value.is-danger { + color: var(--momo-danger); + } + + .dashboard-kpi-value.is-success { + color: var(--momo-success); + } + + .dashboard-kpi-value.is-warning { + color: var(--momo-warning-text); + } + + .dashboard-kpi.is-accent .dashboard-kpi-value { + color: var(--momo-text-inverse); + } + + .dashboard-kpi-sub { + color: var(--momo-text-secondary); + font-size: 11px; + } + + .dashboard-kpi-sub-link { + color: inherit; + font-weight: 800; + text-decoration: none; + } + + .dashboard-kpi-sub-link:hover { + color: var(--momo-accent-strong); + } + + .dashboard-focus-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + + .dashboard-focus-card, + .dashboard-filter-card, + .dashboard-table-card { + background: var(--momo-bg-surface); + border: 1px solid var(--momo-border-light); + border-radius: 8px; + } + + .dashboard-focus-card { + min-width: 0; + padding: 18px; + } + + .dashboard-focus-label { + margin-bottom: 8px; + color: var(--momo-text-tertiary); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.10em; + text-transform: uppercase; + } + + .dashboard-focus-title { + margin-bottom: 4px; + color: var(--momo-text-primary); + font-size: 16px; + font-weight: 800; + line-height: 1.35; + } + + .dashboard-focus-number { + margin-bottom: 6px; + color: var(--momo-danger); + font-size: 24px; + font-weight: 800; + letter-spacing: 0; + line-height: 1; + } + + .dashboard-focus-sub { + color: var(--momo-text-secondary); + font-size: 11px; + } + + .dashboard-focus-list { + display: grid; + gap: 10px; + } + + .dashboard-focus-row { + display: grid; + gap: 5px; + padding: 10px 0; + border-top: 1px solid var(--momo-border-light); + } + + .dashboard-focus-row:first-child { + padding-top: 0; + border-top: 0; + } + + .dashboard-focus-row-title { + display: -webkit-box; + overflow: hidden; + color: var(--momo-text-primary); + font-size: 13px; + font-weight: 800; + line-height: 1.35; + text-decoration: none; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .dashboard-focus-row-title:hover { + color: var(--momo-accent-strong); + } + + .dashboard-focus-row-meta, + .dashboard-focus-row-links { + display: flex; + align-items: center; + gap: 7px; + flex-wrap: wrap; + color: var(--momo-text-secondary); + font-size: 11px; + } + + .dashboard-focus-chip { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: var(--momo-radius-pill); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + white-space: nowrap; + } + + .dashboard-focus-chip.is-win { + color: var(--momo-success); + background: rgba(55, 136, 88, 0.10); + border: 1px solid rgba(55, 136, 88, 0.18); + } + + .dashboard-focus-chip.is-risk { + color: var(--momo-danger); + background: rgba(191, 72, 61, 0.10); + border: 1px solid rgba(191, 72, 61, 0.18); + } + + .dashboard-focus-chip.is-neutral { + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + } + + .dashboard-filter-card { + padding: 12px 16px; + } + + .dashboard-filter-form { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + } + + .dashboard-search, + .dashboard-select { + min-height: 34px; + color: var(--momo-text-primary); + background: var(--momo-bg-surface); + border: 1px solid var(--momo-border); + border-radius: 4px; + font-size: 12px; + } + + .dashboard-search { + width: min(320px, 100%); + padding: 7px 12px; + } + + .dashboard-select { + min-width: 160px; + padding: 7px 12px; + } + + .dashboard-segmented { + display: inline-flex; + padding: 2px; + gap: 0; + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + border-radius: 4px; + } + + .dashboard-segmented a { + padding: 5px 12px; + color: var(--momo-text-secondary); + border-radius: 3px; + font-size: 12px; + font-weight: 800; + text-decoration: none; + transition: var(--momo-transition-base); + } + + .dashboard-segmented a:hover { + color: var(--momo-text-primary); + background: var(--momo-bg-subtle); + } + + .dashboard-segmented a.is-active { + color: var(--momo-text-inverse); + background: var(--momo-ink); + } + + .dashboard-action-link, + .dashboard-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 30px; + padding: 6px 12px; + color: var(--momo-text-primary); + background: var(--momo-bg-surface); + border: 1px solid var(--momo-border); + border-radius: 4px; + font-size: 12px; + font-weight: 800; + text-decoration: none; + transition: var(--momo-transition-base); + } + + .dashboard-action-button.is-primary { + color: var(--momo-text-inverse); + background: var(--momo-ink); + border-color: var(--momo-ink); + } + + .dashboard-action-link:hover, + .dashboard-action-button:hover { + color: var(--momo-text-primary); + background: var(--momo-bg-subtle); + } + + .dashboard-action-button.is-primary:hover { + color: var(--momo-text-inverse); + background: var(--momo-ink-soft); + } + + .dashboard-table-head { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid var(--momo-border-light); + flex-wrap: wrap; + } + + .dashboard-table-title { + color: var(--momo-text-primary); + font-size: 14px; + font-weight: 800; + } + + .dashboard-table-meta { + color: var(--momo-text-secondary); + font-size: 11px; + } + + .dashboard-ai-summary-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0; + border-bottom: 1px solid var(--momo-border-light); + } + + .dashboard-ai-summary-item { + min-width: 0; + padding: 14px 18px; + border-right: 1px solid var(--momo-border-light); + } + + .dashboard-ai-summary-item:last-child { + border-right: 0; + } + + .dashboard-ai-summary-label { + margin-bottom: 5px; + color: var(--momo-text-tertiary); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.08em; + } + + .dashboard-ai-summary-value { + color: var(--momo-text-primary); + font-size: 18px; + font-weight: 800; + line-height: 1.15; + } + + .dashboard-ai-summary-sub { + margin-top: 4px; + color: var(--momo-text-secondary); + font-size: 11px; + } + + .dashboard-table-wrap { + width: 100%; + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .dashboard-table { + width: 100%; + min-width: 1260px; + border-collapse: collapse; + font-size: var(--momo-font-size-sm); + } + + .dashboard-table.is-ai-picks { + min-width: 1460px; + } + + .dashboard-table th { + padding: 11px 14px; + color: var(--momo-text-tertiary); + background: var(--momo-bg-paper); + border-bottom: 1px solid var(--momo-border-light); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.10em; + text-transform: uppercase; + white-space: nowrap; + } + + .dashboard-table th a { + color: inherit; + text-decoration: none; + } + + .dashboard-table td { + padding: 14px; + border-bottom: 1px solid var(--momo-border-light); + vertical-align: middle; + } + + .dashboard-table tbody tr { + transition: var(--momo-transition-base); + } + + .dashboard-table tbody tr:hover { + background: var(--momo-bg-paper); + } + + .dashboard-table tbody tr.is-history-enabled { + cursor: pointer; + } + + .dashboard-category { + display: inline-flex; + max-width: 120px; + padding: 3px 8px; + overflow: hidden; + color: var(--momo-text-primary); + background: var(--momo-bg-subtle); + border: 1px solid var(--momo-border-light); + border-radius: var(--momo-radius-pill); + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dashboard-product-cell { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + } + + .dashboard-product-thumb { + width: 52px; + height: 52px; + flex: 0 0 auto; + object-fit: cover; + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + border-radius: 6px; + } + + .dashboard-product-name { + display: -webkit-box; + overflow: hidden; + color: var(--momo-text-primary); + font-weight: 800; + line-height: 1.35; + text-decoration: none; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + + .dashboard-product-name:hover { + color: var(--momo-accent); + } + + .dashboard-product-id { + margin-top: 4px; + color: var(--momo-text-tertiary); + font-size: 11px; + } + + .dashboard-platform-links { + display: flex; + gap: 6px; + margin-top: 6px; + flex-wrap: wrap; + } + + .dashboard-platform-link, + .dashboard-platform-muted { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + border-radius: var(--momo-radius-pill); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + text-decoration: none; + } + + .dashboard-platform-link.is-momo { + color: var(--momo-text-primary); + background: var(--momo-bg-subtle); + border: 1px solid var(--momo-border-light); + } + + .dashboard-platform-link.is-pchome { + color: var(--momo-accent-strong); + background: var(--momo-accent-soft); + border: 1px solid rgba(190, 106, 45, 0.24); + } + + .dashboard-platform-muted { + color: var(--momo-text-tertiary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + } + + .dashboard-price { + color: var(--momo-text-primary); + font-size: 16px; + font-weight: 800; + } + + .dashboard-price-sub { + margin-top: 3px; + color: var(--momo-text-tertiary); + font-size: 10px; + } + + .dashboard-pchome-price { + color: var(--momo-accent-strong); + font-size: 16px; + font-weight: 800; + } + + .dashboard-competition-card { + display: grid; + gap: 4px; + min-width: 130px; + } + + .dashboard-competition-badge { + display: inline-flex; + width: fit-content; + align-items: center; + padding: 3px 8px; + border-radius: var(--momo-radius-pill); + font-size: 11px; + font-weight: 800; + } + + .dashboard-competition-badge.is-win { + color: var(--momo-success); + background: rgba(55, 136, 88, 0.10); + border: 1px solid rgba(55, 136, 88, 0.18); + } + + .dashboard-competition-badge.is-risk { + color: var(--momo-danger); + background: rgba(191, 72, 61, 0.10); + border: 1px solid rgba(191, 72, 61, 0.18); + } + + .dashboard-competition-badge.is-watch { + color: var(--momo-warning-text); + background: var(--momo-warning-bg); + border: 1px solid rgba(161, 111, 35, 0.18); + } + + .dashboard-competition-badge.is-neutral { + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + } + + .dashboard-competition-meta { + color: var(--momo-text-tertiary); + font-size: 10px; + line-height: 1.5; + } + + .dashboard-ai-pick-card { + display: grid; + min-width: 170px; + gap: 6px; + } + + .dashboard-ai-pick-head { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .dashboard-ai-pick-rank { + display: inline-flex; + align-items: center; + padding: 3px 8px; + color: var(--momo-text-inverse); + background: var(--momo-ink); + border-radius: var(--momo-radius-pill); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + } + + .dashboard-ai-pick-confidence { + color: var(--momo-success); + font-family: var(--momo-font-family-mono); + font-size: 11px; + font-weight: 800; + } + + .dashboard-ai-pick-confidence.is-needs-evidence { + color: var(--momo-warning-text); + } + + .dashboard-ai-pick-confidence.is-medium { + color: var(--momo-accent-strong); + } + + .dashboard-ai-evidence-line { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + color: var(--momo-text-tertiary); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + } + + .dashboard-ai-evidence-chip { + display: inline-flex; + max-width: 180px; + align-items: center; + padding: 2px 7px; + overflow: hidden; + color: var(--momo-warning-text); + background: var(--momo-warning-bg); + border: 1px solid rgba(161, 111, 35, 0.18); + border-radius: var(--momo-radius-pill); + text-overflow: ellipsis; + white-space: nowrap; + } + + .dashboard-ai-pick-reason { + display: -webkit-box; + overflow: hidden; + color: var(--momo-text-secondary); + font-size: 11px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + + .dashboard-history-button { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + width: 100%; + padding: 0; + color: var(--momo-text-primary); + background: transparent; + border: 0; + font: inherit; + text-align: right; + } + + .dashboard-history-button:hover { + color: var(--momo-accent-strong); + } + + .dashboard-history-button i { + color: var(--momo-accent-strong); + font-size: 12px; + } + + .dashboard-change-up { + color: var(--momo-danger); + font-weight: 800; + } + + .dashboard-change-down { + color: var(--momo-success); + font-weight: 800; + } + + .dashboard-empty { + padding: 48px 16px; + color: var(--momo-text-secondary); + text-align: center; + } + + .dashboard-pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 18px 20px; + } + + .dashboard-history-modal .modal-content { + overflow: hidden; + color: var(--momo-text-primary); + background: var(--momo-bg-surface); + border: 1px solid var(--momo-border-light); + border-radius: 8px; + box-shadow: var(--momo-shadow-lg); + } + + .dashboard-history-modal .modal-header { + align-items: flex-start; + gap: 14px; + padding: 18px 20px; + background: var(--momo-bg-paper); + border-bottom: 1px solid var(--momo-border-light); + } + + .dashboard-history-modal .modal-title { + color: var(--momo-text-primary); + font-size: 18px; + font-weight: 800; + line-height: 1.45; + } + + .dashboard-history-subtitle { + margin-top: 4px; + color: var(--momo-text-tertiary); + font-size: 11px; + font-weight: 700; + } + + .dashboard-history-modal .modal-body { + padding: 20px; + } + + .dashboard-chart-shell { + position: relative; + min-height: 360px; + } + + .dashboard-chart-state { + display: grid; + min-height: 360px; + color: var(--momo-text-secondary); + place-items: center; + text-align: center; + } + + .dashboard-chart-state.is-hidden, + .dashboard-chart-canvas.is-hidden { + display: none; + } + + .dashboard-chart-canvas { + max-height: 380px; + } + + .dashboard-history-range { + display: inline-flex; + padding: 2px; + margin-top: 10px; + gap: 0; + background: var(--momo-bg-surface); + border: 1px solid var(--momo-border-light); + border-radius: 4px; + } + + .dashboard-history-range button { + padding: 5px 10px; + color: var(--momo-text-secondary); + background: transparent; + border: 0; + border-radius: 3px; + font-size: 12px; + font-weight: 800; + } + + .dashboard-history-range button.is-active { + color: var(--momo-text-inverse); + background: var(--momo-ink); + } + + @media (max-width: 980px) { + .dashboard-kpi-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .dashboard-ai-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-ai-summary-item:nth-child(2n) { + border-right: 0; + } + + .dashboard-focus-grid { + grid-template-columns: 1fr 1fr; + } + + .dashboard-kpi:nth-child(3), + .dashboard-kpi:nth-child(6) { + border-right: 0; + } + } + + @media (max-width: 640px) { + .dashboard-kpi-grid, + .dashboard-ai-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .dashboard-focus-grid { + grid-template-columns: 1fr; + } + + .dashboard-ai-summary-item { + border-right: 0; + border-bottom: 1px solid var(--momo-border-light); + } + + .dashboard-ai-summary-item:last-child { + border-bottom: 0; + } + + .dashboard-kpi { + padding: 14px; + border-right: 1px solid var(--momo-border-light); + border-bottom: 1px solid var(--momo-border-light); + } + + .dashboard-kpi:nth-child(2n) { + border-right: 0; + } + + .dashboard-kpi:nth-last-child(-n + 2) { + border-bottom: 0; + } + + .dashboard-kpi.is-accent { + color: var(--momo-text-primary); + background: var(--momo-bg-surface); + border-left: 3px solid var(--momo-ink); + } + + .dashboard-kpi.is-accent .dashboard-kpi-label, + .dashboard-kpi.is-accent .dashboard-kpi-sub { + color: var(--momo-text-tertiary); + } + + .dashboard-kpi.is-accent .dashboard-kpi-value { + color: var(--momo-text-primary); + } + + .dashboard-kpi-label { + margin-bottom: 7px; + font-size: 9px; + letter-spacing: 0.06em; + } + + .dashboard-kpi-value { + margin-bottom: 6px; + font-size: 24px; + } + + .dashboard-kpi-value.is-small { + font-size: 14px; + } + + .dashboard-kpi-sub { + font-size: 10px; + line-height: 1.35; + } + + .dashboard-search, + .dashboard-select, + .dashboard-segmented { + width: 100%; + } + + .dashboard-segmented { + overflow-x: auto; + } + + .dashboard-table-wrap::before { + content: '左右滑動查看完整商品列表'; + position: sticky; + left: 0; + display: block; + width: fit-content; + max-width: calc(100vw - 28px); + margin: 0 0 8px; + padding: 6px 9px; + color: var(--momo-text-secondary); + background: var(--momo-bg-paper); + border: 1px solid var(--momo-border-light); + border-radius: 4px; + font-size: 12px; + font-weight: 700; + } + } diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js new file mode 100644 index 0000000..2f5cd1e --- /dev/null +++ b/web/static/js/page-dashboard-v2.js @@ -0,0 +1,392 @@ +/* Dashboard V2 page interactions extracted from dashboard_v2.html. */ +let priceChartInstance = null; + let activeHistoryRange = 'month'; + let currentHistoryProductId = null; + let currentHistoryProductName = ''; + let dashboardChartLoader = null; + + function getCSRFToken() { + return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + } + + function formatPriceTick(value) { + return '$' + Number(value || 0).toLocaleString(); + } + + function setHistoryChartState(message, showCanvas = false) { + const state = document.getElementById('historyChartState'); + const canvas = document.getElementById('priceChart'); + if (!state || !canvas) return; + state.textContent = message; + state.classList.toggle('is-hidden', showCanvas); + canvas.classList.toggle('is-hidden', !showCanvas); + } + + function destroyHistoryChart() { + if (priceChartInstance) { + priceChartInstance.destroy(); + priceChartInstance = null; + } + } + + function ensureDashboardChart() { + if (typeof Chart !== 'undefined') { + return Promise.resolve(); + } + if (dashboardChartLoader) { + return dashboardChartLoader; + } + dashboardChartLoader = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/npm/chart.js'; + script.async = true; + script.onload = resolve; + script.onerror = () => reject(new Error('Chart.js 載入失敗')); + document.head.appendChild(script); + }); + return dashboardChartLoader; + } + + function updateHistoryRangeButtons() { + document.querySelectorAll('[data-history-range]').forEach(button => { + button.classList.toggle('is-active', button.dataset.historyRange === activeHistoryRange); + }); + } + + function showHistory(productId, productName, range = activeHistoryRange) { + const modalEl = document.getElementById('historyModal'); + const title = document.getElementById('historyModalLabel'); + const subtitle = document.getElementById('historyModalSubtitle'); + const canvas = document.getElementById('priceChart'); + + if (!modalEl || !title || !subtitle || !canvas) return; + + currentHistoryProductId = productId; + currentHistoryProductName = productName || '歷史價格走勢'; + activeHistoryRange = range; + updateHistoryRangeButtons(); + + title.textContent = productName || '歷史價格走勢'; + subtitle.textContent = `商品 ID ${productId} · 讀取真實價格紀錄`; + destroyHistoryChart(); + setHistoryChartState('載入價格歷史中...'); + + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + + ensureDashboardChart() + .then(() => fetch(`/api/history/${productId}?range=${activeHistoryRange}&format=v2`)) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + }) + .then(data => { + const series = Array.isArray(data) ? { momo: data, pchome: [] } : (data.series || {}); + const momoPoints = Array.isArray(series.momo) ? series.momo : (Array.isArray(data.data) ? data.data : []); + const pchomePoints = Array.isArray(series.pchome) ? series.pchome : []; + const rangeLabel = Array.isArray(data) ? '' : (data.range_label || ''); + if (rangeLabel) { + const pchomeNote = pchomePoints.length > 0 ? ' · 含 PChome 歷史快照' : ''; + subtitle.textContent = `商品 ID ${productId} · ${rangeLabel}真實價格紀錄${pchomeNote}`; + } + + if (momoPoints.length === 0 && pchomePoints.length === 0) { + setHistoryChartState('目前沒有可顯示的歷史價格紀錄。'); + return; + } + + setHistoryChartState('', true); + const ctx = canvas.getContext('2d'); + const momoGradient = ctx.createLinearGradient(0, 0, 0, 380); + momoGradient.addColorStop(0, 'rgba(190, 106, 45, 0.26)'); + momoGradient.addColorStop(1, 'rgba(190, 106, 45, 0.04)'); + const pchomeGradient = ctx.createLinearGradient(0, 0, 0, 380); + pchomeGradient.addColorStop(0, 'rgba(70, 127, 181, 0.18)'); + pchomeGradient.addColorStop(1, 'rgba(70, 127, 181, 0.03)'); + const labels = Array.from(new Set([ + ...momoPoints.map(point => point.t), + ...pchomePoints.map(point => point.t) + ])).sort(); + const toPriceMap = points => points.reduce((acc, point) => { + acc[point.t] = point.p; + return acc; + }, {}); + const momoMap = toPriceMap(momoPoints); + const pchomeMap = toPriceMap(pchomePoints); + const datasets = [{ + label: 'MOMO', + data: labels.map(label => momoMap[label] ?? null), + borderColor: '#be6a2d', + backgroundColor: momoGradient, + borderWidth: 3, + fill: true, + tension: 0.35, + spanGaps: true, + pointRadius: 3, + pointHoverRadius: 7, + pointBackgroundColor: '#be6a2d', + pointBorderColor: '#fff', + pointBorderWidth: 2 + }]; + if (pchomePoints.length > 0) { + datasets.push({ + label: 'PChome', + data: labels.map(label => pchomeMap[label] ?? null), + borderColor: '#467fb5', + backgroundColor: pchomeGradient, + borderWidth: 2, + fill: false, + tension: 0.28, + spanGaps: true, + pointRadius: 3, + pointHoverRadius: 7, + pointBackgroundColor: '#467fb5', + pointBorderColor: '#fff', + pointBorderWidth: 2 + }); + } + + priceChartInstance = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { + display: pchomePoints.length > 0, + labels: { + color: '#6d604f', + usePointStyle: true, + boxWidth: 8, + boxHeight: 8 + } + }, + tooltip: { + backgroundColor: 'rgba(55, 45, 35, 0.94)', + titleColor: '#faf7f0', + bodyColor: '#faf7f0', + borderColor: '#be6a2d', + borderWidth: 1, + displayColors: true, + padding: 12, + callbacks: { + label: context => `${context.dataset.label} ${formatPriceTick(context.parsed.y)}` + } + } + }, + scales: { + y: { + beginAtZero: false, + grid: { + color: 'rgba(71, 61, 49, 0.08)' + }, + ticks: { + color: '#7f715f', + callback: formatPriceTick + } + }, + x: { + grid: { display: false }, + ticks: { + color: '#9b8a77', + maxRotation: 0, + autoSkip: true, + maxTicksLimit: 8 + } + } + } + } + }); + }) + .catch(error => { + console.error('圖表載入失敗:', error); + setHistoryChartState('價格歷史載入失敗,請稍後再試。'); + }); + } + + document.querySelectorAll('.dashboard-table tbody tr[data-product-id]').forEach(row => { + row.addEventListener('click', event => { + if (event.target.closest('a')) return; + showHistory(row.dataset.productId, row.dataset.productName); + }); + }); + + document.querySelectorAll('[data-history-trigger]').forEach(button => { + button.addEventListener('click', event => { + event.stopPropagation(); + showHistory(button.dataset.productId, button.dataset.productName); + }); + }); + + document.querySelectorAll('[data-history-range]').forEach(button => { + button.addEventListener('click', () => { + if (!currentHistoryProductId) return; + showHistory(currentHistoryProductId, currentHistoryProductName, button.dataset.historyRange); + }); + }); + + function triggerTask() { + if (confirm('確定要手動執行全站爬蟲嗎?可能需要一段時間。')) { + fetch('/api/run_task', { + method: 'POST', + headers: { 'X-CSRFToken': getCSRFToken() } + }) + .then(response => response.json()) + .then(data => alert(data.message)) + .catch(error => alert('錯誤: ' + error)); + } + } + + function triggerNotification() { + if (confirm('確定要發送今日商品異動通知嗎?')) { + fetch('/api/trigger_momo_notification', { + method: 'POST', + headers: { 'X-CSRFToken': getCSRFToken() } + }) + .then(response => response.json()) + .then(data => alert(data.message)) + .catch(error => alert('錯誤: ' + error)); + } + } + + function trackMomoLinkClick(event) { + const link = event.target.closest('.momo-tracked-link'); + if (!link) { + return; + } + + const href = link.getAttribute('href') || ''; + const originalHref = link.dataset.momoOriginalUrl || href; + if (!href || href === '#') { + return; + } + + const isBlocked = isBlockedMomoUrl(href); + + const payload = { + url: originalHref, + page: location.pathname, + source: link.dataset.trackSource || 'unknown', + platform: link.dataset.trackPlatform || 'momo', + product_id: link.dataset.trackProductId || '', + i_code: link.dataset.trackIcode || '', + product_name: link.dataset.trackProductName || '', + label: (link.textContent || '').trim(), + effective_url: href + }; + + if (isBlocked) { + console.warn('[DashboardV2] 嘗試打開 MOMO 404 網址', payload); + event.preventDefault(); + + const fallbackUrl = link.dataset.momoFallbackUrl || getSafeMomoFallbackUrl(link); + if (fallbackUrl && fallbackUrl !== '#' && fallbackUrl !== href) { + link.dataset.momoFallbackUrl = fallbackUrl; + link.setAttribute('href', fallbackUrl); + payload.effective_url = fallbackUrl; + openMomoUrl(link, fallbackUrl); + fetch('/api/track_momo_link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken() + }, + body: JSON.stringify(payload) + }).catch(() => {}); + return; + } + } + + fetch('/api/track_momo_link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken() + }, + body: JSON.stringify(payload) + }).catch(() => {}); + } + + function getSafeMomoFallbackUrl(link) { + const iCode = (link.dataset.trackIcode || link.dataset.trackProductId || '').trim(); + if (!isLikelyMomoProductCode(iCode)) { + return '#'; + } + return `https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=${encodeURIComponent(iCode)}`; + } + + function openMomoUrl(link, url) { + if (!url || url === '#') { + return; + } + + const target = (link.getAttribute('target') || '_self').toLowerCase(); + if (target === '_blank') { + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + + if (target === '_self' || target === '') { + window.location.href = url; + return; + } + + window.open(url, target); + } + + function isBlockedMomoUrl(url) { + const lowered = (url || '').toLowerCase(); + if (lowered.includes('EC404.html') || lowered.includes('ec404')) { + return true; + } + try { + const parsed = new URL(url, location.origin); + const path = (parsed.pathname || '').toLowerCase(); + if (!path.includes('goodsdetail')) { + return false; + } + const code = (parsed.searchParams.get('i_code') || '').trim(); + if (code) { + return !isLikelyMomoProductCode(code); + } + return !/\/goodsdetail\/[^/?#]+/i.test(path); + } catch (error) { + if (!/goodsdetail\.jsp/i.test(lowered)) { + return false; + } + const hasCode = /[?&]i_code=([^&#]+)/i.test(lowered); + if (!hasCode) { + return true; + } + const match = /[?&]i_code=([^&#]+)/i.exec(lowered); + const code = match ? (match[1] || '').trim() : ''; + return !isLikelyMomoProductCode(code); + } + } + + function isLikelyMomoProductCode(value) { + const cleaned = (value || '').trim(); + if (!cleaned) { + return false; + } + const lowered = cleaned.toLowerCase(); + if (lowered === 'nan' || lowered === 'none' || lowered === 'null' || lowered === 'undefined') { + return false; + } + if (lowered.startsWith('momo_') || lowered.startsWith('manual_') || lowered.startsWith('pchome_')) { + return false; + } + return /^[A-Za-z0-9_-]{4,}$/.test(cleaned); + } + + document.addEventListener('click', trackMomoLinkClick);