Files
ewoooc/templates/dashboard_v2.html
OoO e401cb6034
All checks were successful
CD Pipeline / deploy (push) Successful in 57s
Polish dashboard mobile KPI layout
2026-05-12 20:38:33 +08:00

1869 lines
75 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.
{% extends 'ewoooc_base.html' %}
{% block title %}EwoooC 商品看板{% endblock %}
{% block extra_css %}
<style>
.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;
}
}
</style>
{% endblock %}
{% block ewooo_content %}
<div class="dashboard-v2-stack">
{% set overview = competitor_overview | default({}) %}
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">01</span>
<span class="title">比價監控總覽</span>
<span class="meta momo-mono">LIVE · 更新於 {{ datetime_now }}</span>
</div>
<div class="dashboard-kpi-grid">
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">比對覆蓋率</div>
<div class="dashboard-kpi-value momo-mono">{{ overview.match_rate | default(0) }}%</div>
<div class="dashboard-kpi-sub momo-mono">{{ overview.matched_count | default(0) | number_format }} / {{ overview.total_active | default(total_products) | number_format }} ACTIVE</div>
</div>
<div class="dashboard-kpi is-accent">
<div class="dashboard-kpi-label momo-mono">PChome 優勢</div>
<div class="dashboard-kpi-value momo-mono">{{ overview.pchome_advantage_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">平均價差 +{{ overview.avg_advantage_gap | default(0) }}%</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">MOMO 威脅</div>
<div class="dashboard-kpi-value momo-mono is-danger">{{ overview.momo_threat_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">MOMO 價格低於 PChome</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">AI 挑品</div>
<div class="dashboard-kpi-value momo-mono is-success">{{ overview.ai_pick_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">
<a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">查看 {{ ai_pick_list_limit }} 品清單</a>
</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">待比對</div>
<div class="dashboard-kpi-value momo-mono is-warning">{{ overview.pending_match_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">高價品項優先補抓</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">資料新鮮度</div>
<div class="dashboard-kpi-value momo-mono is-small">{{ '已更新' if overview.last_pchome_crawled else '待更新' }}</div>
<div class="dashboard-kpi-sub momo-mono">{{ overview.last_pchome_crawled or '尚無 PChome 抓取紀錄' }}</div>
</div>
</div>
</section>
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">02</span>
<span class="title">比價決策焦點</span>
<span class="meta momo-mono">{{ today_date }}</span>
</div>
<div class="dashboard-focus-grid">
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">今日優先銷售</div>
{% if overview.top_picks %}
<div class="dashboard-focus-list">
{% for pick in overview.top_picks %}
<div class="dashboard-focus-row">
<a class="dashboard-focus-row-title momo-tracked-link" href="{{ pick.momo_url or '#' }}" data-momo-original-url="{{ pick.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-track-platform="momo"
data-track-source="dashboard-v2-overview-top-picks"
data-track-product-id="{{ pick.sku }}"
data-track-icode="{{ pick.sku }}"
data-track-product-name="{{ pick.name|e }}">{{ pick.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-win">AI {{ (pick.confidence * 100) | round(0) | int if pick.confidence else 0 }}%</span>
<span>證據 {{ pick.evidence_quality | round(0) | int }}%</span>
<span>機會 {{ pick.opportunity_score | round(0) | int }}</span>
<span>MOMO ${{ pick.momo_price | int | number_format }}</span>
<span>PChome ${{ pick.pchome_price | int | number_format }}</span>
<span>+{{ pick.gap_pct | round(1) }}%</span>
</div>
{% if pick.missing_evidence %}
<div class="dashboard-ai-evidence-line">
{% for evidence in pick.missing_evidence[:2] %}
<span class="dashboard-ai-evidence-chip">{{ evidence }}</span>
{% endfor %}
</div>
{% endif %}
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ pick.momo_url or '#' }}" data-momo-original-url="{{ pick.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-track-platform="momo"
data-track-source="dashboard-v2-overview-top-picks"
data-track-product-id="{{ pick.sku }}"
data-track-icode="{{ pick.sku }}"
data-track-product-name="{{ pick.name|e }}">MOMO {{ pick.sku }}</a>
{% if pick.pchome_url %}
<a class="dashboard-platform-link is-pchome" href="{{ pick.pchome_url }}" target="_blank" rel="noopener noreferrer">PChome {{ pick.pchome_id }}</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="dashboard-focus-title">尚無 AI 挑品</div>
<div class="dashboard-focus-sub momo-mono">請先讓 PChome 比對與挑品 Agent 累積資料</div>
{% endif %}
</div>
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">價格威脅</div>
{% if overview.top_momo_threats %}
<div class="dashboard-focus-list">
{% for item in overview.top_momo_threats %}
<div class="dashboard-focus-row">
<a class="dashboard-focus-row-title momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-track-platform="momo"
data-track-source="dashboard-v2-overview-top-momo-threats"
data-track-product-id="{{ item.sku }}"
data-track-icode="{{ item.sku }}"
data-track-product-name="{{ item.name|e }}">{{ item.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-risk">{{ item.gap_pct | round(1) }}%</span>
<span>MOMO ${{ item.momo_price | int | number_format }}</span>
<span>PChome ${{ item.pchome_price | int | number_format }}</span>
</div>
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-track-platform="momo"
data-track-source="dashboard-v2-overview-top-momo-threats"
data-track-product-id="{{ item.sku }}"
data-track-icode="{{ item.sku }}"
data-track-product-name="{{ item.name|e }}">MOMO {{ item.sku }}</a>
{% if item.pchome_url %}
<a class="dashboard-platform-link is-pchome" href="{{ item.pchome_url }}" target="_blank" rel="noopener noreferrer">PChome {{ item.pchome_id }}</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="dashboard-focus-title">尚無明顯威脅</div>
<div class="dashboard-focus-sub momo-mono">目前沒有 MOMO 低於 PChome 5% 以上的配對商品</div>
{% endif %}
</div>
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">補資料優先</div>
{% if overview.pending_priority %}
<div class="dashboard-focus-list">
{% for item in overview.pending_priority %}
<div class="dashboard-focus-row">
<a class="dashboard-focus-row-title momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-track-platform="momo"
data-track-source="dashboard-v2-overview-pending-priority"
data-track-product-id="{{ item.sku }}"
data-track-icode="{{ item.sku }}"
data-track-product-name="{{ item.name|e }}">{{ item.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-neutral">待比對</span>
<span>MOMO ${{ item.momo_price | int | number_format }}</span>
<span>{{ item.category or '未分類' }}</span>
</div>
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ item.momo_url or '#' }}" data-momo-original-url="{{ item.momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-track-platform="momo"
data-track-source="dashboard-v2-overview-pending-priority"
data-track-product-id="{{ item.sku }}"
data-track-icode="{{ item.sku }}"
data-track-product-name="{{ item.name|e }}">MOMO {{ item.sku }}</a>
<span class="dashboard-platform-muted">PChome 待比對</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="dashboard-focus-title">待比對清單已清空</div>
<div class="dashboard-focus-sub momo-mono">目前 ACTIVE 商品都有有效 PChome 配對或尚無最新 MOMO 價格</div>
{% endif %}
</div>
</div>
</section>
<section>
<div class="dashboard-section-label">
<span class="num momo-mono">03</span>
<span class="title">篩選</span>
</div>
<div class="dashboard-filter-card">
<form class="dashboard-filter-form" method="GET" action="/">
<input class="dashboard-search" type="text" name="q" value="{{ search_query }}" placeholder="搜尋商品名稱或品號...">
<select class="dashboard-select" name="category" onchange="this.form.submit()">
<option value="all">所有分類</option>
{% for cat in categories %}
<option value="{{ cat }}" {% if current_category == cat %}selected{% endif %}>{{ cat }}</option>
{% endfor %}
</select>
<input type="hidden" name="filter" value="{{ current_filter }}">
<input type="hidden" name="sort_by" value="{{ current_sort }}">
<input type="hidden" name="order" value="{{ current_order }}">
<button class="dashboard-action-button" type="submit">
<i class="fas fa-search"></i> 搜尋
</button>
<div class="dashboard-segmented">
<a class="{% if current_filter == 'all' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='all', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">全部</a>
<a class="{% if current_filter == 'ai_picks' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">AI挑品</a>
<a class="{% if current_filter == 'new' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='new', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">新上架</a>
<a class="{% if current_filter == 'increase' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='increase', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">漲價</a>
<a class="{% if current_filter == 'decrease' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='decrease', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">降價</a>
<a class="{% if current_filter == 'delisted' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='delisted', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">下架</a>
</div>
<button class="dashboard-action-button" type="button" onclick="triggerTask()">
<i class="fas fa-rotate"></i> 更新
</button>
<button class="dashboard-action-button is-primary" type="button" onclick="triggerNotification()">
<i class="fas fa-bell"></i> 發送通知
</button>
</form>
</div>
</section>
<section>
<div class="dashboard-table-card">
<div class="dashboard-table-head">
<span class="momo-mono" style="font-size:11px;font-weight:800;color:var(--momo-text-tertiary);letter-spacing:.08em;">04</span>
<span class="dashboard-table-title">{{ 'AI 挑品清單' if current_filter == 'ai_picks' else '商品列表' }}</span>
<span class="dashboard-table-meta momo-mono">
{% if current_filter == 'ai_picks' %}
{{ total_items | number_format }} / {{ ai_pick_list_limit }} 品
{% else %}
{{ total_items | number_format }} 筆
{% endif %}
</span>
<div class="momo-topbar-spacer"></div>
<a class="dashboard-action-link" href="/api/export/excel/all">
<i class="fas fa-download"></i> 匯出全部
</a>
<a class="dashboard-action-link" href="/api/export/excel/changes">
<i class="fas fa-arrow-trend-up"></i> 匯出漲跌
</a>
{% if current_filter == 'ai_picks' %}
<a class="dashboard-action-link" href="/api/export/excel/ai-picks">
<i class="fas fa-file-excel"></i> 匯出 AI 挑品
</a>
{% endif %}
</div>
{% if current_filter == 'ai_picks' and ai_pick_summary %}
<div class="dashboard-ai-summary-grid">
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">PICK COUNT</div>
<div class="dashboard-ai-summary-value momo-mono">{{ ai_pick_summary.count | number_format }}</div>
<div class="dashboard-ai-summary-sub">目前清單上限 {{ ai_pick_list_limit }} 品</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">AVG CONFIDENCE</div>
<div class="dashboard-ai-summary-value momo-mono">{{ (ai_pick_summary.avg_confidence * 100) | round(0) | int }}%</div>
<div class="dashboard-ai-summary-sub">高信心 {{ ai_pick_summary.high_confidence_count | number_format }} 品</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">EVIDENCE</div>
<div class="dashboard-ai-summary-value momo-mono">{{ ai_pick_summary.avg_evidence_quality | round(0) | int }}%</div>
<div class="dashboard-ai-summary-sub">需補證據 {{ ai_pick_summary.needs_evidence_count | number_format }} 品</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">AVG GAP</div>
<div class="dashboard-ai-summary-value momo-mono">+{{ ai_pick_summary.avg_gap_pct | round(1) }}%</div>
<div class="dashboard-ai-summary-sub">PChome 相對 MOMO 價差</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">BEST GAP</div>
<div class="dashboard-ai-summary-value momo-mono">+{{ ai_pick_summary.max_gap_pct | round(1) }}%</div>
<div class="dashboard-ai-summary-sub">清單內最大價格優勢</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">EVIDENCE GAP</div>
<div class="dashboard-ai-summary-value momo-mono">
{% if ai_pick_summary.top_missing_evidence %}
{{ ai_pick_summary.top_missing_evidence[0].count | number_format }}
{% else %}
0
{% endif %}
</div>
<div class="dashboard-ai-summary-sub">
{% if ai_pick_summary.top_missing_evidence %}
{{ ai_pick_summary.top_missing_evidence[0].label }}
{% else %}
暫無待補證據
{% endif %}
</div>
</div>
</div>
{% endif %}
<div class="dashboard-table-wrap">
<table class="dashboard-table {% if current_filter == 'ai_picks' %}is-ai-picks{% endif %}">
<thead>
<tr>
<th>分類</th>
<th>商品名稱</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', page=1, sort_by='price', order='asc' if current_sort == 'price' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">MOMO 價格</a>
</th>
<th class="text-end">PChome 價格</th>
<th>競價判讀</th>
{% if current_filter == 'ai_picks' %}
<th>AI 建議</th>
{% endif %}
<th class="text-end">
<a href="{{ url_for('dashboard.index', page=1, sort_by='yesterday_change', order='asc' if current_sort == 'yesterday_change' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">昨日漲跌</a>
</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', page=1, sort_by='week_change', order='asc' if current_sort == 'week_change' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">週漲跌</a>
</th>
<th class="text-end">
<a href="{{ url_for('dashboard.index', page=1, sort_by='timestamp', order='asc' if current_sort == 'timestamp' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">更新時間</a>
</th>
<th class="text-end">上架時間</th>
</tr>
</thead>
<tbody>
{% for item in items %}
{% set product = item.record.product %}
{% set competitor = item.pchome_competitor %}
{% set decision = item.competitor_decision %}
{% set image_url = product.image_url or ('https://m.momoshop.com.tw/moscdn/goods/' ~ product.i_code ~ '_m.webp') %}
<tr class="is-history-enabled" data-product-id="{{ product.id }}" data-product-name="{{ product.name|e }}" title="點擊查看歷史價格圖表">
<td><span class="dashboard-category">{{ product.category or '未分類' }}</span></td>
<td>
<div class="dashboard-product-cell">
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer">
{% set safe_product_url = item.safe_momo_url or '#' %}
<a class="dashboard-product-name momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-momo-original-url="{{ safe_product_url or '#' }}"
data-track-platform="momo"
data-track-source="dashboard-v2-table-main"
data-track-product-id="{{ product.id }}"
data-track-icode="{{ product.i_code }}"
data-track-product-name="{{ product.name|e }}">{{ product.name }}</a>
<div>
<div class="dashboard-platform-links">
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-momo-original-url="{{ safe_product_url or '#' }}"
data-track-platform="momo"
data-track-source="dashboard-v2-table-main"
data-track-product-id="{{ product.id }}"
data-track-icode="{{ product.i_code }}"
data-track-product-name="{{ product.name|e }}">
MOMO {{ product.i_code }}
</a>
{% if competitor and competitor.product_url %}
<a class="dashboard-platform-link is-pchome" href="{{ competitor.product_url }}" target="_blank" rel="noopener noreferrer">
PChome {{ competitor.product_id }}
</a>
{% elif competitor and competitor.product_id %}
<span class="dashboard-platform-muted">PChome {{ competitor.product_id }}</span>
{% else %}
<span class="dashboard-platform-muted">PChome 待比對</span>
{% endif %}
</div>
{% if competitor and competitor.product_name %}
<div class="dashboard-product-id momo-mono" title="{{ competitor.product_name }}">PChome{{ competitor.product_name }}</div>
{% endif %}
{% if item.ai_pick %}
<div class="dashboard-product-id momo-mono" title="{{ item.ai_pick.reason }}">
AI挑品 #{{ item.ai_pick.rank }} · 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}% · 證據 {{ item.ai_pick.evidence_quality | round(0) | int }}% · 價差 {{ item.ai_pick.gap_pct | round(1) }}%
</div>
{% endif %}
</div>
</div>
</td>
<td class="text-end">
<button
class="dashboard-history-button"
type="button"
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 }} 的歷史價格圖表"
>
<span class="dashboard-price momo-mono">${{ item.record.price | int | number_format }}</span>
<i class="fas fa-chart-line" aria-hidden="true"></i>
</button>
</td>
<td class="text-end momo-mono">
{% if competitor and competitor.price %}
<div class="dashboard-pchome-price">${{ competitor.price | int | number_format }}</div>
{% if competitor.match_score %}
<div class="dashboard-price-sub">match {{ (competitor.match_score * 100) | round(0) | int }}%</div>
{% endif %}
{% else %}
<span style="color:var(--momo-text-tertiary);">待比對</span>
{% endif %}
</td>
<td>
<div class="dashboard-competition-card">
<span class="dashboard-competition-badge is-{{ decision.tone }}">{{ decision.label }}</span>
{% if decision.gap_pct is not none %}
<span class="dashboard-competition-meta momo-mono">
MOMO - PChome
{% if decision.gap_amount > 0 %}+{% endif %}${{ decision.gap_amount | round(0) | int | number_format }}
/ {% if decision.gap_pct > 0 %}+{% endif %}{{ decision.gap_pct | round(1) }}%
</span>
{% endif %}
<span class="dashboard-competition-meta">{{ decision.summary }}</span>
</div>
</td>
{% if current_filter == 'ai_picks' %}
<td>
{% if item.ai_pick %}
<div class="dashboard-ai-pick-card">
<div class="dashboard-ai-pick-head">
<span class="dashboard-ai-pick-rank">#{{ item.ai_pick.rank }}</span>
<span class="dashboard-ai-pick-confidence is-{{ item.ai_pick.confidence_band | replace('_', '-') }}">信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}%</span>
</div>
<div class="dashboard-ai-evidence-line">
<span>機會 {{ item.ai_pick.opportunity_score | round(0) | int }}</span>
<span>證據 {{ item.ai_pick.evidence_quality | round(0) | int }}%</span>
{% if item.ai_pick.margin_rate is not none %}
<span>毛利 {{ item.ai_pick.margin_rate | round(1) }}%</span>
{% endif %}
</div>
<div class="dashboard-ai-pick-reason">{{ item.ai_pick.reason }}</div>
{% if item.ai_pick.missing_evidence %}
<div class="dashboard-ai-evidence-line" title="{{ item.ai_pick.missing_evidence_text }}">
{% for evidence in item.ai_pick.missing_evidence[:3] %}
<span class="dashboard-ai-evidence-chip">{{ evidence }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<span style="color:var(--momo-text-tertiary);">尚無建議理由</span>
{% endif %}
</td>
{% endif %}
<td class="text-end momo-mono">
{% if item.yesterday_diff > 0 %}
<span class="dashboard-change-up">▲ +{{ item.yesterday_diff | abs | int | number_format }}</span>
{% elif item.yesterday_diff < 0 %}
<span class="dashboard-change-down">▼ -{{ item.yesterday_diff | abs | int | number_format }}</span>
{% else %}
<span style="color:var(--momo-text-tertiary);">--</span>
{% endif %}
</td>
<td class="text-end momo-mono">
{% set week_diff = item.stats.get('7d_diff', 0) %}
{% if week_diff > 0 %}
<span class="dashboard-change-up">+{{ week_diff | int | number_format }}</span>
{% elif week_diff < 0 %}
<span class="dashboard-change-down">-{{ week_diff | abs | int | number_format }}</span>
{% else %}
<span style="color:var(--momo-text-tertiary);">--</span>
{% endif %}
</td>
<td class="text-end momo-mono" style="color:var(--momo-text-secondary);">
{{ item.record.timestamp.strftime('%m-%d %H:%M') if item.record.timestamp else '--' }}
</td>
<td class="text-end momo-mono" style="color:var(--momo-text-secondary);">
{{ item.safe_created_at.strftime('%m-%d %H:%M') if item.safe_created_at else '--' }}
</td>
</tr>
{% else %}
<tr>
<td colspan="{{ 10 if current_filter == 'ai_picks' else 9 }}">
<div class="dashboard-empty">
{% if search_query %}
找不到與「{{ search_query }}」相關的商品
{% else %}
目前沒有符合條件的商品
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if total_pages > 1 %}
<div class="dashboard-pagination">
{% if current_page > 1 %}
<a class="dashboard-action-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>
{% endif %}
<span class="dashboard-table-meta momo-mono">第 {{ current_page }} / {{ total_pages }} 頁</span>
{% if current_page < total_pages %}
<a class="dashboard-action-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>
{% endif %}
</div>
{% endif %}
</div>
</section>
</div>
<div class="modal fade dashboard-history-modal" id="historyModal" tabindex="-1" aria-labelledby="historyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title" id="historyModalLabel">歷史價格走勢</h5>
<div class="dashboard-history-subtitle momo-mono" id="historyModalSubtitle">真實價格紀錄</div>
<div class="dashboard-history-range" aria-label="價格歷史區間">
<button type="button" data-history-range="week"></button>
<button class="is-active" type="button" data-history-range="month"></button>
<button type="button" data-history-range="quarter"></button>
<button type="button" data-history-range="year"></button>
</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="關閉"></button>
</div>
<div class="modal-body">
<div class="dashboard-chart-shell">
<div class="dashboard-chart-state" id="historyChartState">載入價格歷史中...</div>
<canvas class="dashboard-chart-canvas is-hidden" id="priceChart"></canvas>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let priceChartInstance = null;
let activeHistoryRange = 'month';
let currentHistoryProductId = null;
let currentHistoryProductName = '';
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 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();
if (typeof Chart === 'undefined') {
setHistoryChartState('圖表元件尚未載入完成,請重新整理後再試。');
return;
}
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-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);
</script>
{% endblock %}