Files
ewoooc/templates/price_comparison.html
ogt 2888bac597
All checks were successful
CD Pipeline / deploy (push) Successful in 1m9s
fix: align pchome growth comparison and promo watch
2026-06-26 11:53:14 +08:00

2049 lines
77 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 %}PChome 商品比價決策台 - EwoooC{% endblock %}
{% block extra_css %}
<style>
.price-tool-page {
color: var(--momo-text-primary);
}
.price-tool-head {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.82fr);
gap: 18px;
align-items: stretch;
padding: var(--momo-space-4) var(--momo-space-5);
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
}
.price-tool-head h2 {
margin: 0;
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: var(--momo-text-headline);
font-weight: 800;
letter-spacing: 0;
}
.price-tool-head p,
.price-tool-page .text-muted {
color: var(--momo-text-secondary) !important;
}
.price-hero-copy {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 0;
}
.price-hero-kpis {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.price-hero-kpi {
min-height: 92px;
padding: 12px;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: var(--momo-radius-md);
background: rgba(250, 247, 240, 0.72);
}
.price-hero-kpi strong {
display: block;
color: var(--momo-text-primary);
font-family: var(--momo-font-mono, monospace);
font-size: 1.55rem;
font-weight: 900;
line-height: 1;
}
.price-hero-kpi span {
display: block;
margin-top: 7px;
color: var(--momo-text-secondary);
font-size: 0.76rem;
font-weight: 850;
line-height: 1.35;
}
.price-command-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(280px, 0.9fr);
gap: var(--momo-space-4);
margin-bottom: var(--momo-space-4);
}
.price-next-action,
.price-readiness-panel,
.price-result-panel {
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
}
.price-next-action {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 14px;
align-items: center;
padding: 16px;
background: rgba(242, 178, 90, 0.16);
border-color: rgba(172, 92, 58, 0.2);
}
.price-next-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 999px;
background: rgba(172, 92, 58, 0.14);
color: var(--momo-warm-rust);
}
.price-next-title {
display: block;
color: var(--momo-text-primary);
font-size: 1rem;
font-weight: 900;
line-height: 1.25;
}
.price-next-reason {
display: block;
margin-top: 3px;
color: var(--momo-text-secondary);
font-size: 0.82rem;
font-weight: 700;
line-height: 1.4;
}
.price-readiness-panel,
.price-result-panel {
padding: 14px;
}
.price-decision-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: var(--momo-space-4);
}
.price-decision-card {
display: grid;
gap: 8px;
min-height: 126px;
padding: 13px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
background: rgba(255, 255, 255, 0.82);
box-shadow: var(--momo-shadow-soft);
}
.price-decision-card.is-active {
border-color: rgba(172, 92, 58, 0.34);
background: rgba(242, 178, 90, 0.16);
}
.price-decision-card.is-ready {
border-color: rgba(46, 125, 91, 0.24);
background: rgba(235, 248, 241, 0.72);
}
.price-decision-card.is-blocked {
border-color: rgba(200, 81, 58, 0.24);
background: rgba(255, 244, 239, 0.78);
}
.price-decision-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--momo-text-secondary);
font-size: 0.73rem;
font-weight: 900;
}
.price-decision-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
color: var(--momo-warm-rust);
}
.price-decision-value {
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-size: 0.98rem;
font-weight: 900;
line-height: 1.3;
}
.price-decision-detail {
color: var(--momo-text-secondary);
font-size: 0.76rem;
font-weight: 760;
line-height: 1.42;
}
.price-workflow-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: var(--momo-space-4);
}
.price-workflow-step {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
min-height: 70px;
padding: 11px;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: var(--momo-radius-md);
background: rgba(250, 247, 240, 0.6);
}
.price-workflow-step.is-current {
border-color: rgba(172, 92, 58, 0.34);
background: rgba(242, 178, 90, 0.16);
}
.price-workflow-step.is-done {
border-color: rgba(46, 125, 91, 0.24);
background: rgba(235, 248, 241, 0.72);
}
.price-workflow-step strong {
display: block;
color: var(--momo-text-primary);
font-size: 0.82rem;
font-weight: 900;
line-height: 1.25;
}
.price-workflow-step span:last-child {
display: block;
margin-top: 2px;
color: var(--momo-text-secondary);
font-size: 0.7rem;
font-weight: 780;
}
.price-workflow-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 999px;
background: rgba(172, 92, 58, 0.13);
color: var(--momo-warm-rust);
font-family: var(--momo-font-mono, monospace);
font-size: 0.78rem;
font-weight: 900;
}
.price-panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
color: var(--momo-text-secondary);
font-size: 0.78rem;
font-weight: 900;
}
.price-readiness-row,
.price-risk-row {
display: grid;
gap: 6px;
margin-bottom: 10px;
}
.price-readiness-row:last-child,
.price-risk-row:last-child {
margin-bottom: 0;
}
.price-readiness-meta,
.price-risk-meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--momo-text-secondary);
font-size: 0.78rem;
font-weight: 850;
}
.price-readiness-track,
.price-risk-track {
overflow: hidden;
height: 8px;
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
}
.price-readiness-bar,
.price-risk-bar {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--momo-warm-caramel);
transition: width 0.2s ease;
}
.price-readiness-bar.is-pchome,
.price-risk-bar.is-good {
background: #2e7d5b;
}
.price-readiness-bar.is-momo,
.price-risk-bar.is-watch {
background: #d19a2a;
}
.price-risk-bar.is-urgent {
background: #c8513a;
}
.price-operation-card {
height: 100%;
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
}
.price-step-head {
background: var(--momo-bg-paper);
border-bottom: 1px solid var(--momo-border-light);
color: var(--momo-text-primary);
font-family: var(--momo-font-display);
font-weight: 800;
}
.price-count-badge,
.price-result-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 22px;
padding: 3px 8px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-sm);
font-family: var(--momo-font-mono, monospace);
font-size: var(--momo-text-label);
font-weight: 800;
line-height: 1;
}
.price-count-badge.is-muted,
.price-result-badge.is-muted {
background: var(--momo-tag-muted-bg);
border-color: var(--momo-tag-muted-border);
color: var(--momo-tag-muted-text);
}
.price-count-badge.is-pchome,
.price-result-badge.is-pchome {
background: var(--momo-info-bg);
border-color: var(--momo-info-border);
color: var(--momo-info-text);
}
.price-count-badge.is-momo,
.price-result-badge.is-momo {
background: var(--momo-warning-bg);
border-color: var(--momo-warning-border);
color: var(--momo-warning-text);
}
.price-stat-card {
background: var(--momo-bg-surface);
border: 1px solid var(--momo-border-light);
}
.price-stat-card h4 {
color: var(--momo-text-primary);
font-family: var(--momo-font-mono, monospace);
font-weight: 800;
}
.price-stat-card.is-pchome h4 {
color: var(--momo-info-text);
}
.price-stat-card.is-momo h4 {
color: var(--momo-warning-text);
}
.price-stat-card.is-diff h4 {
color: var(--momo-text-primary);
}
.price-result-summary-grid {
display: grid;
grid-template-columns: minmax(260px, 0.92fr) minmax(0, 1.35fr);
gap: 12px;
margin-bottom: 14px;
}
.price-result-callout {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid rgba(172, 92, 58, 0.22);
border-radius: var(--momo-radius-md);
background: rgba(242, 178, 90, 0.14);
}
.price-result-callout strong {
color: var(--momo-text-primary);
font-size: 1rem;
font-weight: 900;
line-height: 1.35;
}
.price-result-callout span {
color: var(--momo-text-secondary);
font-size: 0.8rem;
font-weight: 760;
line-height: 1.45;
}
.price-result-matrix {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.price-result-matrix-card {
min-height: 92px;
padding: 11px;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: var(--momo-radius-md);
background: rgba(255, 255, 255, 0.72);
}
.price-result-matrix-card strong {
display: block;
color: var(--momo-text-primary);
font-family: var(--momo-font-mono, monospace);
font-size: 1.45rem;
font-weight: 900;
line-height: 1;
}
.price-result-matrix-card span {
display: block;
margin-top: 7px;
color: var(--momo-text-secondary);
font-size: 0.74rem;
font-weight: 850;
line-height: 1.35;
}
.price-note {
padding: 10px 12px;
background: var(--momo-info-bg);
border: 1px solid var(--momo-info-border);
border-radius: var(--momo-radius-md);
color: var(--momo-info-text);
}
.price-note.is-unit {
background: var(--momo-success-bg);
border-color: var(--momo-success-border);
color: var(--momo-success-text);
}
.price-note.is-review {
background: rgba(255, 248, 231, 0.95);
border-color: rgba(210, 158, 58, 0.28);
color: #7a5209;
}
.price-unit-list,
.price-review-list {
display: grid;
gap: 8px;
margin-top: 8px;
}
.price-unit-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 8px 10px;
border: 1px solid rgba(46, 125, 91, 0.2);
border-radius: var(--momo-radius-sm);
background: rgba(255, 255, 255, 0.45);
}
.price-unit-name {
min-width: 0;
overflow: hidden;
color: var(--momo-text-primary);
font-size: 0.82rem;
font-weight: 850;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.price-unit-metric {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--momo-success-text);
font-family: var(--momo-font-mono, monospace);
font-size: var(--momo-text-label);
font-weight: 900;
white-space: nowrap;
}
.price-review-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 10px;
border: 1px solid rgba(210, 158, 58, 0.24);
border-radius: var(--momo-radius-sm);
background: rgba(255, 255, 255, 0.58);
}
.price-review-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
min-width: 0;
}
.price-review-store {
min-width: 0;
padding: 8px;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: var(--momo-radius-sm);
background: rgba(250, 247, 240, 0.64);
}
.price-review-store.is-pchome {
border-color: rgba(46, 125, 91, 0.2);
background: rgba(235, 248, 241, 0.62);
}
.price-review-store.is-momo {
border-color: rgba(201, 100, 66, 0.2);
background: rgba(255, 244, 239, 0.58);
}
.price-review-platform {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--momo-text-secondary);
font-size: 0.68rem;
font-weight: 900;
}
.price-review-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 2.5em;
margin-top: 5px;
overflow: hidden;
color: var(--momo-text-primary);
font-size: 0.78rem;
font-weight: 850;
line-height: 1.25;
}
.price-review-price {
display: block;
margin-top: 6px;
color: var(--momo-text-primary);
font-family: var(--momo-font-mono, monospace);
font-size: 0.88rem;
font-weight: 900;
}
.price-review-actions {
display: grid;
gap: 7px;
min-width: 128px;
}
.price-table-head th {
background: var(--momo-bg-paper) !important;
color: var(--momo-text-secondary) !important;
font-family: var(--momo-font-mono, monospace);
font-size: var(--momo-text-label);
font-weight: 800;
letter-spacing: 0.04em;
}
.price-action-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 4px 8px;
border-radius: var(--momo-radius-sm);
font-size: 0.72rem;
font-weight: 900;
line-height: 1.2;
white-space: normal;
}
.price-action-pill.is-urgent {
background: var(--momo-danger-bg);
color: var(--momo-danger-text);
border: 1px solid var(--momo-danger-border);
}
.price-action-pill.is-good {
background: var(--momo-success-bg);
color: var(--momo-success-text);
border: 1px solid var(--momo-success-border);
}
.price-action-pill.is-watch {
background: var(--momo-info-bg);
color: var(--momo-info-text);
border: 1px solid var(--momo-info-border);
}
.price-toast {
top: 20px;
right: 20px;
z-index: 9999;
min-width: min(300px, calc(100vw - 40px));
padding: 12px 14px;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-md);
background: var(--momo-bg-elevated);
color: var(--momo-text-primary);
font-weight: 700;
}
.price-toast--success {
background: var(--momo-success-bg);
border-color: var(--momo-success-border);
color: var(--momo-success-text);
}
.price-toast--danger {
background: var(--momo-danger-bg);
border-color: var(--momo-danger-border);
color: var(--momo-danger-text);
}
.price-toast--warning {
background: var(--momo-warning-bg);
border-color: var(--momo-warning-border);
color: var(--momo-warning-text);
}
@media (max-width: 760px) {
.price-tool-head {
grid-template-columns: 1fr;
padding: var(--momo-space-4);
}
.price-hero-kpis,
.price-decision-grid,
.price-workflow-strip,
.price-result-summary-grid,
.price-result-matrix {
grid-template-columns: 1fr;
}
.price-command-grid,
.price-next-action {
grid-template-columns: 1fr;
}
.price-next-action .btn {
width: 100%;
}
.price-unit-item {
grid-template-columns: 1fr;
}
.price-review-item,
.price-review-compare,
.price-review-actions {
grid-template-columns: 1fr;
}
.price-unit-metric {
justify-content: flex-start;
white-space: normal;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 price-tool-page">
<header class="price-tool-head mb-4">
<div class="price-hero-copy">
<h2><i class="fas fa-balance-scale me-2"></i>PChome 商品比價決策台</h2>
<p class="text-muted mb-0">PChome 反查 MOMO確認同款、判斷價差、決定下一步。</p>
</div>
<div class="price-hero-kpis" aria-label="比價核心狀態">
<div class="price-hero-kpi">
<strong id="heroPchomeCount">0</strong>
<span>PChome 商品</span>
</div>
<div class="price-hero-kpi">
<strong id="heroMomoCount">0</strong>
<span>MOMO 可用候選</span>
</div>
<div class="price-hero-kpi">
<strong id="heroDecisionCount">0</strong>
<span>已產生決策</span>
</div>
</div>
</header>
<section class="price-command-grid" aria-label="比價操作總覽">
<div class="price-next-action">
<span class="price-next-icon"><i class="fas fa-location-arrow"></i></span>
<span>
<strong class="price-next-title" id="priceNextActionTitle">今天先做:選擇要檢查的商品範圍</strong>
<span class="price-next-reason" id="priceNextActionReason">請先選品牌或輸入關鍵字,系統才知道要抓哪一批 PChome 商品。</span>
</span>
<button type="button" class="btn btn-primary btn-sm" id="priceNextActionButton">
輸入關鍵字
</button>
</div>
<div class="price-readiness-panel" aria-label="資料準備狀態">
<div class="price-panel-title">
<span><i class="fas fa-clipboard-check me-1"></i>資料準備狀態</span>
<span id="priceReadySummary">尚未開始</span>
</div>
<div class="price-readiness-row">
<div class="price-readiness-meta"><span>PChome 商品</span><strong id="pricePchomeReadyText">0 筆</strong></div>
<div class="price-readiness-track"><span class="price-readiness-bar is-pchome" id="pricePchomeReadyBar"></span></div>
</div>
<div class="price-readiness-row">
<div class="price-readiness-meta"><span>MOMO 商品</span><strong id="priceMomoReadyText">0 筆</strong></div>
<div class="price-readiness-track"><span class="price-readiness-bar is-momo" id="priceMomoReadyBar"></span></div>
</div>
</div>
</section>
<section class="price-decision-grid" id="priceDecisionGrid" aria-label="目前卡點與下一步">
<article class="price-decision-card is-active" id="decisionScopeCard">
<div class="price-decision-top">
<span>檢查範圍</span>
<span class="price-decision-icon"><i class="fas fa-magnifying-glass"></i></span>
</div>
<div class="price-decision-value" id="decisionScopeValue">尚未選擇</div>
<div class="price-decision-detail" id="decisionScopeDetail">先選品牌或輸入商品關鍵字。</div>
</article>
<article class="price-decision-card" id="decisionPchomeCard">
<div class="price-decision-top">
<span>PChome</span>
<span class="price-decision-icon"><i class="fas fa-cart-shopping"></i></span>
</div>
<div class="price-decision-value" id="decisionPchomeValue">等待商品</div>
<div class="price-decision-detail" id="decisionPchomeDetail">取得商品後,系統才知道要比哪一批。</div>
</article>
<article class="price-decision-card" id="decisionMomoCard">
<div class="price-decision-top">
<span>MOMO</span>
<span class="price-decision-icon"><i class="fas fa-store"></i></span>
</div>
<div class="price-decision-value" id="decisionMomoValue">等待候選</div>
<div class="price-decision-detail" id="decisionMomoDetail">會自動分成同款、單位價、需確認。</div>
</article>
<article class="price-decision-card" id="decisionResultCard">
<div class="price-decision-top">
<span>比價結果</span>
<span class="price-decision-icon"><i class="fas fa-chart-simple"></i></span>
</div>
<div class="price-decision-value" id="decisionResultValue">尚未判讀</div>
<div class="price-decision-detail" id="decisionResultDetail">結果會分成檢查售價、主推曝光、觀察賣點。</div>
</article>
</section>
<section class="price-workflow-strip" aria-label="比價流程">
<div class="price-workflow-step is-current" id="workflowStepScope">
<span class="price-workflow-index">1</span>
<span><strong>選範圍</strong><span>品牌或關鍵字</span></span>
</div>
<div class="price-workflow-step" id="workflowStepPchome">
<span class="price-workflow-index">2</span>
<span><strong>抓 PChome</strong><span>取得主場商品</span></span>
</div>
<div class="price-workflow-step" id="workflowStepMomo">
<span class="price-workflow-index">3</span>
<span><strong>找 MOMO</strong><span>同款與單位價</span></span>
</div>
<div class="price-workflow-step" id="workflowStepResult">
<span class="price-workflow-index">4</span>
<span><strong>做決策</strong><span>價格與曝光</span></span>
</div>
</section>
<!-- 操作區 -->
<div class="row mb-4">
<!-- 選擇檢查範圍 -->
<div class="col-lg-4 mb-3">
<div class="card price-operation-card">
<div class="card-header price-step-head">
<i class="fas fa-tag me-2"></i>選擇要檢查的範圍
</div>
<div class="card-body">
<div class="mb-3">
<select class="form-select" id="brandSelect">
<option value="">選擇品牌</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">或輸入品牌/關鍵字</label>
<input type="text" class="form-control" id="customKeyword" placeholder="如: 理膚寶水、雅漾">
</div>
</div>
</div>
</div>
<!-- 取得 PChome 商品 -->
<div class="col-lg-4 mb-3">
<div class="card price-operation-card">
<div class="card-header price-step-head">
<i class="fas fa-shopping-cart me-2"></i>補齊 PChome 商品
</div>
<div class="card-body">
<button class="btn btn-primary w-100 mb-2" id="fetchPchomeBtn">
<i class="fas fa-download me-1"></i>取得 PChome 商品
</button>
<div class="text-center my-2"><small class="text-muted"></small></div>
<button class="btn btn-outline-info w-100" id="manualPchomeBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="pchome">
<i class="fas fa-keyboard me-1"></i>手動輸入
</button>
<div class="mt-3">
<span class="price-count-badge is-muted" id="pchomeCount">0 筆商品</span>
</div>
</div>
</div>
</div>
<!-- 取得 MOMO 商品 -->
<div class="col-lg-4 mb-3">
<div class="card price-operation-card">
<div class="card-header price-step-head">
<i class="fas fa-store me-2"></i>補齊 MOMO 商品
</div>
<div class="card-body">
<button class="btn btn-primary w-100 mb-2" id="fetchTargetedMomoBtn" disabled>
<i class="fas fa-magnifying-glass-dollar me-1"></i>自動找 MOMO 候選
</button>
<div class="text-center my-2"><small class="text-muted"></small></div>
<button class="btn btn-primary w-100 mb-2" id="uploadMomoBtn" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="fas fa-file-excel me-1"></i>匯入 MOMO 商品
</button>
<div class="text-center my-2"><small class="text-muted"></small></div>
<button class="btn btn-outline-warning w-100" id="manualMomoBtn" data-bs-toggle="modal" data-bs-target="#manualInputModal" data-source="momo">
<i class="fas fa-keyboard me-1"></i>手動輸入
</button>
<div class="mt-3">
<span class="price-count-badge is-muted" id="momoCount">0 筆商品</span>
<span class="price-count-badge is-muted ms-1" id="momoUnitCompareCount">0 筆單位價</span>
<span class="price-count-badge is-muted ms-1" id="momoReviewCount">0 筆需確認</span>
</div>
<div class="price-note is-unit mt-3 py-2" id="momoUnitComparePanel" style="display:none;"></div>
<div class="price-note is-review mt-3 py-2" id="momoReviewPanel" style="display:none;"></div>
</div>
</div>
</div>
</div>
<!-- 比價按鈕 -->
<div class="row mb-4">
<div class="col text-center">
<button class="btn btn-lg btn-primary px-5" id="compareBtn" disabled>
<i class="fas fa-balance-scale me-2"></i>開始檢查價差
</button>
</div>
</div>
<!-- 進度顯示 -->
<div class="row mb-4" id="progressSection" style="display: none;">
<div class="col">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="spinner-border text-primary me-3" role="status"></div>
<div>
<strong id="progressText">處理中...</strong>
<br><small class="text-muted" id="progressDetail"></small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 比價結果 -->
<div class="row" id="resultSection" style="display: none;">
<div class="col">
<section class="price-result-panel mb-4" aria-label="比價結果判讀">
<div class="price-panel-title">
<span><i class="fas fa-chart-bar me-1"></i>比價結果判讀</span>
<span id="priceResultSummary">等待比價結果</span>
</div>
<div class="price-result-summary-grid">
<div class="price-result-callout">
<strong id="priceResultHeadline">尚未產生判讀</strong>
<span id="priceResultAdvice">取得 PChome 與 MOMO 商品後,會直接整理下一步。</span>
</div>
<div class="price-result-matrix" aria-label="比價決策分佈">
<div class="price-result-matrix-card">
<strong id="priceUrgentMetric">0</strong>
<span>需檢查售價或活動</span>
</div>
<div class="price-result-matrix-card">
<strong id="priceGoodMetric">0</strong>
<span>可主推曝光</span>
</div>
<div class="price-result-matrix-card">
<strong id="priceWatchMetric">0</strong>
<span>觀察賣點</span>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-4">
<div class="price-risk-row">
<div class="price-risk-meta"><span>需檢查價格</span><strong id="priceUrgentText">0 筆</strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-urgent" id="priceUrgentBar"></span></div>
</div>
</div>
<div class="col-lg-4">
<div class="price-risk-row">
<div class="price-risk-meta"><span>可主推曝光</span><strong id="priceGoodText">0 筆</strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-good" id="priceGoodBar"></span></div>
</div>
</div>
<div class="col-lg-4">
<div class="price-risk-row">
<div class="price-risk-meta"><span>價格接近</span><strong id="priceWatchText">0 筆</strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-watch" id="priceWatchBar"></span></div>
</div>
</div>
</div>
</section>
<!-- 統計卡片 -->
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card price-stat-card">
<div class="card-body text-center">
<h4 class="mb-0" id="matchedCount">0</h4>
<small class="text-muted">找到同款</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card price-stat-card is-pchome">
<div class="card-body text-center">
<h4 class="mb-0" id="pchomeCheaperCount">0</h4>
<small>PChome 較便宜</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card price-stat-card is-momo">
<div class="card-body text-center">
<h4 class="mb-0" id="momoCheaperCount">0</h4>
<small>MOMO 較便宜</small>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card price-stat-card is-diff">
<div class="card-body text-center">
<h4 class="mb-0" id="avgPriceDiff">$0</h4>
<small>平均價差</small>
</div>
</div>
</div>
</div>
<!-- 結果表格 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-list me-2"></i>比價結果</span>
<div>
<button class="btn btn-sm btn-outline-primary" id="exportResultBtn">
<i class="fas fa-file-excel me-1"></i>匯出 Excel
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped mb-0">
<thead class="price-table-head">
<tr>
<th style="width: 120px;">下一步</th>
<th style="width: 35%;">PChome 商品</th>
<th style="width: 35%;">MOMO 商品</th>
<th class="text-center">相似度</th>
<th class="text-end">PChome 價</th>
<th class="text-end">MOMO 價</th>
<th class="text-end">價差</th>
</tr>
</thead>
<tbody id="resultBody">
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 上傳 Excel Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-file-excel me-2"></i>上傳 MOMO 商品 Excel</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">選擇 Excel 檔案</label>
<input type="file" class="form-control" id="momoExcelFile" accept=".xlsx,.xls,.csv">
</div>
<div class="price-note">
<i class="fas fa-info-circle me-1"></i>
Excel 至少要有商品名稱與售價;有賣場連結時會一起帶入雙開比對。
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="parseExcelBtn">
<i class="fas fa-upload me-1"></i>上傳並解析
</button>
</div>
</div>
</div>
</div>
<!-- 手動輸入 Modal -->
<div class="modal fade" id="manualInputModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-keyboard me-2"></i>手動輸入商品</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="price-note py-2">
<i class="fas fa-bullseye me-1"></i>
貼上 MOMO 商品、售價與賣場連結;會整理成可比價清單。
</div>
<div class="mb-3">
<label class="form-label">商品資料</label>
<textarea class="form-control" id="manualInput" rows="10" placeholder="理膚寶水 多容安舒緩濕潤乳液 40ml,850,https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12345
理膚寶水 B5全面修復霜 100ml,680
La Roche-Posay 安得利防曬液 50ml,920
也可以直接從 Excel 複製貼上 (Tab 分隔)"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" id="parseManualBtn">
<i class="fas fa-check me-1"></i>確認
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let pchomeProducts = [];
let momoProducts = [];
let momoUnitCompareCandidates = [];
let momoReviewCandidates = [];
let comparisonResult = null;
let currentManualSource = null;
document.addEventListener('DOMContentLoaded', function() {
loadBrands();
bindEvents();
renderPriceCommandDashboard();
});
async function loadBrands() {
try {
const response = await fetchWithCSRF('/api/price_comparison/brands');
const data = await response.json();
if (data.success) {
const select = document.getElementById('brandSelect');
for (const brand of data.data.brands) {
const option = document.createElement('option');
option.value = brand.code;
option.textContent = brand.name;
select.appendChild(option);
}
}
} catch (error) {
console.error('載入品牌失敗:', error);
}
}
function bindEvents() {
// 品牌選擇
document.getElementById('brandSelect').addEventListener('change', function() {
const selected = this.options[this.selectedIndex];
if (selected.value) {
document.getElementById('customKeyword').value = selected.textContent;
}
renderPriceCommandDashboard();
});
document.getElementById('customKeyword').addEventListener('input', renderPriceCommandDashboard);
// 爬取 PChome
document.getElementById('fetchPchomeBtn').addEventListener('click', fetchPchome);
// 上傳 Excel
document.getElementById('parseExcelBtn').addEventListener('click', parseMomoExcel);
document.getElementById('fetchTargetedMomoBtn').addEventListener('click', fetchTargetedMomoCandidates);
// 手動輸入來源
document.querySelectorAll('[data-bs-target="#manualInputModal"]').forEach(btn => {
btn.addEventListener('click', function() {
currentManualSource = this.dataset.source;
});
});
// 解析手動輸入
document.getElementById('parseManualBtn').addEventListener('click', parseManualInput);
// 比價
document.getElementById('compareBtn').addEventListener('click', runComparison);
// 匯出
document.getElementById('exportResultBtn').addEventListener('click', exportResult);
document.getElementById('priceNextActionButton').addEventListener('click', runPriceNextAction);
}
function getKeyword() {
const custom = document.getElementById('customKeyword').value.trim();
if (custom) return custom;
const select = document.getElementById('brandSelect');
if (!select.value) return '';
return select.options[select.selectedIndex]?.textContent || '';
}
async function fetchPchome() {
const keyword = getKeyword();
if (!keyword) {
showToast('請先選擇品牌或輸入關鍵字', 'warning');
return;
}
showProgress('取得 PChome 商品中...', `搜尋:${keyword}`);
try {
const response = await fetchWithCSRF('/api/price_comparison/fetch_pchome', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword, limit: 100 })
});
const data = await response.json();
hideProgress();
if (data.success) {
pchomeProducts = data.data.products;
momoProducts = [];
momoUnitCompareCandidates = [];
momoReviewCandidates = [];
resetComparisonResult();
document.getElementById('pchomeCount').textContent = `${pchomeProducts.length} 筆商品`;
document.getElementById('pchomeCount').className = 'price-count-badge is-pchome';
document.getElementById('momoCount').textContent = '0 筆同款';
document.getElementById('momoCount').className = 'price-count-badge is-muted';
renderMomoUnitComparePanel();
renderMomoReviewPanel();
updateCompareButton();
updateTargetedMomoButton();
renderPriceCommandDashboard();
showToast(`成功取得 ${pchomeProducts.length} 筆 PChome 商品`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
hideProgress();
showToast('取得失敗:' + error.message, 'danger');
}
}
async function fetchTargetedMomoCandidates() {
if (!pchomeProducts.length) {
showToast('請先取得 PChome 商品,再搜尋 MOMO 候選', 'warning');
return;
}
const btn = document.getElementById('fetchTargetedMomoBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>搜尋中...';
showProgress('搜尋 MOMO 候選中...', '正在用 PChome 商品名稱找單品與組合候選');
try {
const response = await fetchWithCSRF('/api/price_comparison/fetch_momo_for_pchome', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pchome_products: pchomeProducts,
sync_external_offers: true
})
});
const data = await response.json();
hideProgress();
const payload = data.data || {};
momoProducts = Array.isArray(payload.products) ? payload.products : [];
momoUnitCompareCandidates = Array.isArray(payload.unit_compare_candidates) ? payload.unit_compare_candidates : [];
momoReviewCandidates = Array.isArray(payload.review_candidates) ? payload.review_candidates : [];
resetComparisonResult();
document.getElementById('momoCount').textContent = `${momoProducts.length} 筆同款`;
document.getElementById('momoCount').className = momoProducts.length ? 'price-count-badge is-momo' : 'price-count-badge is-muted';
renderMomoUnitComparePanel();
renderMomoReviewPanel();
updateCompareButton();
renderPriceCommandDashboard();
const syncResult = payload.external_offer_sync || {};
const syncedCount = Number(syncResult.written_count || 0);
if (data.success) {
const syncText = syncedCount ? `,已同步 ${syncedCount} 筆到作戰清單` : '';
showToast(`MOMO 候選完成:同款 ${momoProducts.length} 筆、單位價 ${momoUnitCompareCandidates.length} 筆、需確認 ${momoReviewCandidates.length}${syncText}`, 'success');
} else if (momoUnitCompareCandidates.length) {
showToast(`已自動換算 ${momoUnitCompareCandidates.length} 筆單位價候選`, 'success');
} else if (momoReviewCandidates.length) {
showToast(`找到 ${momoReviewCandidates.length} 筆需人工確認候選,暫不進自動比價`, 'warning');
} else {
showToast(data.message || '目前沒有找到 MOMO 候選', 'warning');
}
} catch (error) {
hideProgress();
showToast('搜尋 MOMO 候選失敗:' + error.message, 'danger');
} finally {
updateTargetedMomoButton();
btn.innerHTML = '<i class="fas fa-magnifying-glass-dollar me-1"></i>自動找 MOMO 候選';
}
}
function renderMomoUnitComparePanel() {
const countBadge = document.getElementById('momoUnitCompareCount');
const panel = document.getElementById('momoUnitComparePanel');
countBadge.textContent = `${momoUnitCompareCandidates.length} 筆單位價`;
countBadge.className = momoUnitCompareCandidates.length
? 'price-count-badge is-pchome ms-1'
: 'price-count-badge is-muted ms-1';
if (!momoUnitCompareCandidates.length) {
panel.style.display = 'none';
panel.replaceChildren();
return;
}
panel.style.display = 'block';
const heading = document.createElement('strong');
heading.textContent = `已自動換算 ${momoUnitCompareCandidates.length} 筆單位價`;
const list = document.createElement('div');
list.className = 'price-unit-list';
momoUnitCompareCandidates.slice(0, 3).forEach(item => {
const evidence = item.target_unit_price_comparison || {};
const row = document.createElement('div');
row.className = 'price-unit-item';
const name = document.createElement('span');
name.className = 'price-unit-name';
name.textContent = item.name || item.title || '未命名商品';
const metric = document.createElement('span');
metric.className = 'price-unit-metric';
const gap = Number(evidence.unit_gap_pct ?? item.target_gap_pct ?? 0);
const unit = evidence.unit_label ? `/${evidence.unit_label}` : '';
const momoUnitPrice = Number(evidence.momo_unit_price || 0);
const pchomeUnitPrice = Number(evidence.competitor_unit_price || 0);
metric.textContent = momoUnitPrice && pchomeUnitPrice
? `MOMO $${momoUnitPrice.toFixed(2)}${unit}PChome $${pchomeUnitPrice.toFixed(2)}${unit}${gap >= 0 ? '+' : ''}${gap.toFixed(1)}%`
: `${gap >= 0 ? '+' : ''}${gap.toFixed(1)}%`;
row.append(name, metric);
list.appendChild(row);
});
panel.replaceChildren(heading, list);
}
function buildPchomeProductUrl(productId) {
const id = String(productId || '').trim();
return id ? `https://24h.pchome.com.tw/prod/${encodeURIComponent(id)}` : '';
}
function reviewCandidateReasonLabels(item) {
const labels = Array.isArray(item?.target_match_reason_labels)
? item.target_match_reason_labels.filter(Boolean)
: [];
return labels.length ? labels : ['需人工確認同款'];
}
function renderMomoReviewPanel() {
const countBadge = document.getElementById('momoReviewCount');
const panel = document.getElementById('momoReviewPanel');
countBadge.textContent = `${momoReviewCandidates.length} 筆需確認`;
countBadge.className = momoReviewCandidates.length
? 'price-count-badge is-momo ms-1'
: 'price-count-badge is-muted ms-1';
if (!momoReviewCandidates.length) {
panel.style.display = 'none';
panel.replaceChildren();
return;
}
panel.style.display = 'block';
const heading = document.createElement('strong');
heading.textContent = `待確認 ${momoReviewCandidates.length} 筆:先並排看兩家賣場`;
const list = document.createElement('div');
list.className = 'price-review-list';
momoReviewCandidates.slice(0, 5).forEach(item => {
const pchomeId = String(item.target_pchome_product_id || '').trim();
const pchomeName = item.target_pchome_name || pchomeId || 'PChome 商品';
const momoName = item.name || item.title || item.product_id || 'MOMO 候選';
const pchomeUrl = toSafeUrl(item.target_pchome_url || buildPchomeProductUrl(pchomeId));
const momoUrl = toSafeUrl(item.product_url || item.url || '');
const pchomePrice = item.target_pchome_price ? formatMoney(item.target_pchome_price) : '待補價格';
const momoPrice = item.price ? formatMoney(item.price) : '待補價格';
const reasons = reviewCandidateReasonLabels(item).slice(0, 2).join('、') || '請確認規格';
const row = document.createElement('div');
row.className = 'price-review-item';
row.innerHTML = `
<div class="price-review-compare">
<section class="price-review-store is-pchome">
<div class="price-review-platform">
<span>PChome</span>
${pchomeUrl ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener noreferrer">賣場</a>` : '<span>待補連結</span>'}
</div>
<div class="price-review-name" title="${escapeHtml(pchomeName)}">${escapeHtml(pchomeName)}</div>
<span class="price-review-price">${escapeHtml(pchomePrice)}</span>
</section>
<section class="price-review-store is-momo">
<div class="price-review-platform">
<span>MOMO</span>
${momoUrl ? `<a href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener noreferrer">賣場</a>` : '<span>待補連結</span>'}
</div>
<div class="price-review-name" title="${escapeHtml(momoName)}">${escapeHtml(momoName)}</div>
<span class="price-review-price">${escapeHtml(momoPrice)}</span>
</section>
</div>
<div class="price-review-actions">
${pchomeUrl && momoUrl ? `<button type="button" class="btn btn-sm btn-outline-primary" data-pchome-url="${escapeHtml(pchomeUrl)}" data-momo-url="${escapeHtml(momoUrl)}" onclick="openComparisonStores(this)"><i class="fas fa-up-right-from-square me-1"></i>同時開兩家賣場</button>` : ''}
<span class="price-action-pill is-watch">${escapeHtml(reasons)}</span>
</div>
`;
list.appendChild(row);
});
panel.replaceChildren(heading, list);
}
async function parseMomoExcel() {
const fileInput = document.getElementById('momoExcelFile');
if (!fileInput.files.length) {
showToast('先選擇 MOMO 商品檔案。', 'warning');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
const response = await fetch('/api/price_comparison/parse_momo_excel', {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken },
body: formData
});
const data = await response.json();
if (data.success) {
momoProducts = data.data.products;
momoUnitCompareCandidates = [];
momoReviewCandidates = [];
resetComparisonResult();
document.getElementById('momoCount').textContent = `${momoProducts.length} 筆商品`;
document.getElementById('momoCount').className = 'price-count-badge is-momo';
renderMomoUnitComparePanel();
renderMomoReviewPanel();
updateCompareButton();
renderPriceCommandDashboard();
bootstrap.Modal.getInstance(document.getElementById('uploadModal')).hide();
showToast(`成功解析 ${momoProducts.length} 筆 MOMO 商品`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
showToast('解析失敗: ' + error.message, 'danger');
}
}
function parseManualInput() {
const input = document.getElementById('manualInput').value.trim();
if (!input) {
showToast('先貼上商品、售價與賣場連結。', 'warning');
return;
}
const lines = input.split('\n');
const products = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// 支援逗號或 Tab 分隔
let parts = line.split(',');
if (parts.length < 2) {
parts = line.split('\t');
}
if (parts.length < 2) {
showToast(`${i + 1} 行缺商品名稱或售價,請補齊後再加入比價清單。`, 'warning');
return;
}
// 驗證價格是否為數字
const price = parseInt(parts[1].trim());
if (isNaN(price) || price <= 0) {
showToast(`${i + 1} 行售價需為數字例如680。`, 'warning');
return;
}
const rawUrl = parts[2]?.trim() || '';
const productId = currentManualSource === 'momo'
? extractMomoCodeFromUrl(rawUrl)
: `manual_${i}`;
products.push({
name: parts[0].trim(),
price: price,
product_id: productId,
url: rawUrl
});
}
if (currentManualSource === 'pchome') {
pchomeProducts = products;
momoProducts = [];
momoUnitCompareCandidates = [];
momoReviewCandidates = [];
resetComparisonResult();
document.getElementById('pchomeCount').textContent = `${products.length} 筆商品`;
document.getElementById('pchomeCount').className = 'price-count-badge is-pchome';
document.getElementById('momoCount').textContent = '0 筆同款';
document.getElementById('momoCount').className = 'price-count-badge is-muted';
renderMomoUnitComparePanel();
renderMomoReviewPanel();
updateTargetedMomoButton();
} else {
momoProducts = products;
momoUnitCompareCandidates = [];
momoReviewCandidates = [];
resetComparisonResult();
document.getElementById('momoCount').textContent = `${products.length} 筆商品`;
document.getElementById('momoCount').className = 'price-count-badge is-momo';
renderMomoUnitComparePanel();
renderMomoReviewPanel();
}
updateCompareButton();
renderPriceCommandDashboard();
bootstrap.Modal.getInstance(document.getElementById('manualInputModal')).hide();
document.getElementById('manualInput').value = '';
showToast(`成功新增 ${products.length} 筆商品`, 'success');
}
function updateCompareButton() {
const btn = document.getElementById('compareBtn');
btn.disabled = !(pchomeProducts.length > 0 && momoProducts.length > 0);
}
function updateTargetedMomoButton() {
const btn = document.getElementById('fetchTargetedMomoBtn');
if (btn) btn.disabled = pchomeProducts.length === 0;
}
function resetComparisonResult() {
comparisonResult = null;
document.getElementById('resultSection').style.display = 'none';
setText('priceResultSummary', '等待比價結果');
setText('priceResultHeadline', '尚未產生判讀');
setText('priceResultAdvice', '取得 PChome 與 MOMO 商品後,會直接整理下一步。');
setText('priceUrgentMetric', '0');
setText('priceGoodMetric', '0');
setText('priceWatchMetric', '0');
}
function renderPriceCommandDashboard() {
const keyword = getKeyword();
const pchomeCount = pchomeProducts.length;
const momoCount = momoProducts.length;
const unitCount = momoUnitCompareCandidates.length;
const reviewCount = momoReviewCandidates.length;
const hasResult = Boolean(comparisonResult && Array.isArray(comparisonResult.matches));
const matchedCount = hasResult ? comparisonResult.matches.length : 0;
const stats = comparisonResult?.stats || {};
const urgentCount = Number(stats.momo_cheaper_count || 0);
const goodCount = Number(stats.pchome_cheaper_count || 0);
const watchCount = Math.max(0, matchedCount - urgentCount - goodCount);
const usableMomoCount = momoCount + unitCount;
setText('heroPchomeCount', pchomeCount.toLocaleString());
setText('heroMomoCount', usableMomoCount.toLocaleString());
setText('heroDecisionCount', matchedCount.toLocaleString());
setText('pricePchomeReadyText', `${pchomeCount}`);
setText(
'priceMomoReadyText',
reviewCount || unitCount
? `${momoCount} 筆同款,${unitCount} 筆單位價,${reviewCount} 筆確認`
: `${momoCount}`
);
setWidth('pricePchomeReadyBar', Math.min(100, pchomeCount));
setWidth('priceMomoReadyBar', Math.min(100, momoCount + unitCount));
renderPriceDecisionCards({ keyword, pchomeCount, momoCount, unitCount, reviewCount, matchedCount, urgentCount, goodCount, watchCount });
renderPriceWorkflow({ keyword, pchomeCount, momoCount, unitCount, matchedCount });
if (!keyword && !pchomeCount && !momoCount && !unitCount) {
setText('priceReadySummary', '請先選範圍');
setNextAction('今天先做:選擇要檢查的商品範圍', '請先選品牌或輸入關鍵字,系統才知道要抓哪一批 PChome 商品。', '輸入關鍵字', 'keyword');
return;
}
if (!pchomeCount) {
setText('priceReadySummary', '缺 PChome 商品');
const scopeText = keyword ? `已選「${keyword}` : '已補 MOMO 商品';
setNextAction('今天先做:取得 PChome 商品', `${scopeText},先取得 PChome 商品,才有業績主場可以比。`, keyword ? '取得 PChome 商品' : '輸入關鍵字', keyword ? 'fetch-pchome' : 'keyword');
return;
}
if (!momoCount && unitCount) {
setText('priceReadySummary', '已有單位價比較');
setNextAction('今天先看:自動單位價比較', `系統已自動換算 ${unitCount} 筆候選,不需要先人工確認。`, '查看單位價', 'focus-momo-unit');
return;
}
if (!momoCount && reviewCount) {
setText('priceReadySummary', 'MOMO 候選待確認');
setNextAction('今天先做:確認 MOMO 單品/組合候選', `已找到 ${reviewCount} 筆可能候選,但需要確認單品、組合或單位價後才能比價。`, '查看候選提醒', 'focus-momo-review');
return;
}
if (!momoCount) {
setText('priceReadySummary', '缺 MOMO 商品');
setNextAction('今天先做:自動找 MOMO 候選', 'PChome 商品已準備好,先用 PChome 商品名稱反查 MOMO同時保留單品與組合候選。', '自動找 MOMO 候選', 'fetch-momo');
return;
}
setText('priceReadySummary', '可以比價');
if (!hasResult) {
setNextAction('今天先做:開始檢查價差', `兩邊資料已齊PChome ${pchomeCount} 筆、MOMO ${momoCount} 筆。`, '開始檢查價差', 'compare');
return;
}
if (urgentCount > 0) {
setNextAction('今天先做:處理 PChome 價格偏高商品', `已找到 ${urgentCount} 筆 MOMO 較便宜商品,先檢查售價、活動組合或曝光策略。`, '查看需檢查價格', 'focus-results');
return;
}
if (goodCount > 0) {
setNextAction('今天先做:主推 PChome 有價格優勢商品', `已找到 ${goodCount} 筆 PChome 較便宜商品,適合安排曝光、文案或活動位置。`, '查看可主推商品', 'focus-results');
return;
}
setNextAction('今天先做:檢查商品賣點與活動位置', '目前價格接近,差異不大,下一步看曝光、文案和活動組合。', '查看比價結果', 'focus-results');
}
function renderPriceDecisionCards(state) {
const keyword = state.keyword || '';
setDecisionCard(
'decisionScopeCard',
'decisionScopeValue',
'decisionScopeDetail',
keyword ? `檢查「${keyword}` : '尚未選擇',
keyword ? '範圍已確認,可以取得 PChome 商品。' : '先選品牌或輸入商品關鍵字。',
keyword ? 'ready' : 'active'
);
setDecisionCard(
'decisionPchomeCard',
'decisionPchomeValue',
'decisionPchomeDetail',
state.pchomeCount ? `${state.pchomeCount.toLocaleString()} 筆商品` : '等待商品',
state.pchomeCount ? '已可用這批商品反查 MOMO。' : '取得商品後,系統才知道要比哪一批。',
state.pchomeCount ? 'ready' : (keyword ? 'active' : '')
);
const momoValue = state.momoCount
? `${state.momoCount.toLocaleString()} 筆同款`
: state.unitCount
? `${state.unitCount.toLocaleString()} 筆單位價`
: state.reviewCount
? `${state.reviewCount.toLocaleString()} 筆待確認`
: '等待候選';
const momoDetail = state.momoCount
? '同款可直接進入總價比對。'
: state.unitCount
? '單品與組合不同,已改用單位價判讀。'
: state.reviewCount
? '需要確認單品、組合或容量後再使用。'
: '會自動分成同款、單位價、需確認。';
setDecisionCard(
'decisionMomoCard',
'decisionMomoValue',
'decisionMomoDetail',
momoValue,
momoDetail,
state.momoCount || state.unitCount ? 'ready' : (state.pchomeCount ? 'active' : '')
);
const resultValue = state.matchedCount
? `${state.matchedCount.toLocaleString()} 筆判讀`
: '尚未判讀';
let resultDetail = '結果會分成檢查售價、主推曝光、觀察賣點。';
let resultState = state.momoCount ? 'active' : '';
if (state.matchedCount) {
if (state.urgentCount) {
resultDetail = `${state.urgentCount.toLocaleString()} 筆需先檢查售價或活動。`;
resultState = 'blocked';
} else if (state.goodCount) {
resultDetail = `${state.goodCount.toLocaleString()} 筆可主推曝光。`;
resultState = 'ready';
} else {
resultDetail = '價格接近,改看賣點、活動位置與庫存。';
resultState = 'ready';
}
}
setDecisionCard('decisionResultCard', 'decisionResultValue', 'decisionResultDetail', resultValue, resultDetail, resultState);
}
function setDecisionCard(cardId, valueId, detailId, value, detail, state) {
const card = document.getElementById(cardId);
if (card) {
card.className = 'price-decision-card' + (
state === 'ready' ? ' is-ready' :
state === 'blocked' ? ' is-blocked' :
state === 'active' ? ' is-active' : ''
);
}
setText(valueId, value);
setText(detailId, detail);
}
function renderPriceWorkflow(state) {
setWorkflowState('workflowStepScope', state.keyword ? 'done' : 'current');
setWorkflowState('workflowStepPchome', state.pchomeCount ? 'done' : (state.keyword ? 'current' : ''));
setWorkflowState('workflowStepMomo', state.momoCount || state.unitCount ? 'done' : (state.pchomeCount ? 'current' : ''));
setWorkflowState('workflowStepResult', state.matchedCount ? 'done' : (state.momoCount ? 'current' : ''));
}
function setWorkflowState(id, state) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'price-workflow-step' + (
state === 'done' ? ' is-done' :
state === 'current' ? ' is-current' : ''
);
}
function setNextAction(title, reason, label, action) {
setText('priceNextActionTitle', title);
setText('priceNextActionReason', reason);
const btn = document.getElementById('priceNextActionButton');
btn.textContent = label;
btn.dataset.action = action;
}
function runPriceNextAction() {
const action = document.getElementById('priceNextActionButton').dataset.action;
if (action === 'keyword') {
document.getElementById('customKeyword').focus();
return;
}
if (action === 'fetch-pchome') {
fetchPchome();
return;
}
if (action === 'upload-momo') {
bootstrap.Modal.getOrCreateInstance(document.getElementById('uploadModal')).show();
return;
}
if (action === 'fetch-momo') {
fetchTargetedMomoCandidates();
return;
}
if (action === 'compare') {
runComparison();
return;
}
if (action === 'focus-results') {
document.getElementById('resultSection')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
return;
}
if (action === 'focus-momo-unit') {
document.getElementById('momoUnitComparePanel')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
if (action === 'focus-momo-review') {
document.getElementById('momoReviewPanel')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function setWidth(id, value) {
const el = document.getElementById(id);
if (el) el.style.width = `${Math.max(0, Math.min(100, value))}%`;
}
async function runComparison() {
showProgress('檢查價差中...', '正在找兩邊可確認的同款商品');
try {
const response = await fetchWithCSRF('/api/price_comparison/quick_compare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pchome_products: pchomeProducts,
momo_products: momoProducts
})
});
const data = await response.json();
hideProgress();
if (data.success) {
comparisonResult = data.data;
displayResult(comparisonResult);
renderPriceCommandDashboard();
showToast(`比價完成,找到 ${comparisonResult.matched_count} 筆同款`, 'success');
} else {
showToast(data.message, 'danger');
}
} catch (error) {
hideProgress();
showToast('比價失敗:' + error.message, 'danger');
}
}
function displayResult(result) {
result = result || {};
const stats = result.stats || {};
const matches = Array.isArray(result.matches) ? result.matches : [];
const urgentCount = Number(stats.momo_cheaper_count || 0);
const goodCount = Number(stats.pchome_cheaper_count || 0);
const watchCount = Math.max(0, matches.length - urgentCount - goodCount);
// 更新統計
document.getElementById('matchedCount').textContent = result.matched_count || matches.length;
document.getElementById('pchomeCheaperCount').textContent = goodCount;
document.getElementById('momoCheaperCount').textContent = urgentCount;
document.getElementById('avgPriceDiff').textContent = formatMoney(stats.avg_price_diff || 0);
setText('priceUrgentText', `${urgentCount}`);
setText('priceGoodText', `${goodCount}`);
setText('priceWatchText', `${watchCount}`);
setText('priceUrgentMetric', urgentCount.toLocaleString());
setText('priceGoodMetric', goodCount.toLocaleString());
setText('priceWatchMetric', watchCount.toLocaleString());
setText('priceResultSummary', matches.length ? `共找到 ${matches.length} 筆同款` : '尚未找到同款');
if (urgentCount > 0) {
setText('priceResultHeadline', `先處理 ${urgentCount.toLocaleString()} 筆 PChome 價格偏高商品`);
setText('priceResultAdvice', '建議先檢查售價、折扣、組合內容與活動曝光,避免高業績商品被外部低價壓住。');
} else if (goodCount > 0) {
setText('priceResultHeadline', `可主推 ${goodCount.toLocaleString()} 筆 PChome 價格有利商品`);
setText('priceResultAdvice', '建議安排首頁曝光、搜尋關鍵字、活動文案或 EDM把價格優勢轉成流量。');
} else if (matches.length) {
setText('priceResultHeadline', '價格差距不大,改看內容與曝光');
setText('priceResultAdvice', '下一步檢查商品頁賣點、圖片、庫存與活動位置,找出非價格因素。');
} else {
setText('priceResultHeadline', '尚未找到可判讀同款');
setText('priceResultAdvice', '請改用更精準的商品名稱、型號或容量搜尋,或先檢查 MOMO 候選。');
}
const denominator = Math.max(matches.length, 1);
setWidth('priceUrgentBar', (urgentCount / denominator) * 100);
setWidth('priceGoodBar', (goodCount / denominator) * 100);
setWidth('priceWatchBar', (watchCount / denominator) * 100);
// 建立表格
const tbody = document.getElementById('resultBody');
tbody.innerHTML = '';
for (const m of matches) {
const row = document.createElement('tr');
const similarityClass = m.similarity >= 0.8 ? 'text-success' : (m.similarity >= 0.6 ? 'text-warning' : 'text-danger');
const actionBadge = getResultActionBadge(m);
const gapText = formatPriceGap(m);
const pchomeName = String(m.pchome?.name || '未命名商品');
const momoName = String(m.momo?.name || '未命名商品');
const pchomeId = escapeHtml(String(m.pchome?.product_id || ''));
const momoId = escapeHtml(String(m.momo?.product_id || ''));
// 處理 URL (可能是 url 或 product_url)
const pchomeUrl = toSafeUrl(m.pchome?.url || m.pchome?.product_url || '');
const momoUrl = toSafeUrl(m.momo?.url || m.momo?.product_url || '');
const dualOpenButton = pchomeUrl && momoUrl
? `<button type="button"
class="btn btn-sm btn-outline-primary mt-2"
data-pchome-url="${escapeHtml(pchomeUrl)}"
data-momo-url="${escapeHtml(momoUrl)}"
onclick="openComparisonStores(this)">
<i class="fas fa-up-right-from-square me-1"></i>雙開賣場
</button>`
: '';
row.innerHTML = `
<td>
${actionBadge}
${dualOpenButton}
</td>
<td>
<div class="d-flex align-items-start">
<div class="flex-grow-1">
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(pchomeName)}">
${escapeHtml(pchomeName)}
</small>
<small class="text-muted">${pchomeId}</small>
</div>
${pchomeUrl ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" class="btn btn-sm btn-outline-info ms-1" title="前往 PChome 查看"><i class="fas fa-external-link-alt"></i></a>` : ''}
</div>
</td>
<td>
<div class="d-flex align-items-start">
<div class="flex-grow-1">
<small class="text-truncate d-block" style="max-width: 220px;" title="${escapeHtml(momoName)}">
${escapeHtml(momoName)}
</small>
<small class="text-muted">${momoId}</small>
</div>
${momoUrl ? `<a
href="${escapeHtml(momoUrl)}"
target="_blank"
class="btn btn-sm btn-outline-warning ms-1 momo-tracked-link"
title="前往 MOMO 查看"
data-momo-original-url="${escapeHtml(momoUrl)}"
data-track-platform="momo"
data-track-source="price-comparison"
data-track-product-id="${momoId}"
data-track-icode="${momoId}"
data-track-product-name="${escapeHtml(momoName)}">
<i class="fas fa-external-link-alt"></i>
</a>` : ''}
</div>
</td>
<td class="text-center ${similarityClass}">
<strong>${Math.round(m.similarity * 100)}%</strong>
</td>
<td class="text-end ${m.cheaper_at === 'pchome' ? 'text-success fw-bold' : ''}">
${formatMoney(m.pchome?.price)}
</td>
<td class="text-end ${m.cheaper_at === 'momo' ? 'text-warning fw-bold' : ''}">
${formatMoney(m.momo?.price)}
</td>
<td class="text-end ${m.price_diff < 0 ? 'text-success' : (m.price_diff > 0 ? 'text-danger' : '')}">
${gapText}
</td>
`;
tbody.appendChild(row);
}
document.getElementById('resultSection').style.display = 'block';
}
function openComparisonStores(button) {
const pchomeUrl = toSafeUrl(button?.dataset?.pchomeUrl || '');
const momoUrl = toSafeUrl(button?.dataset?.momoUrl || '');
const opened = [];
if (pchomeUrl) opened.push(window.open(pchomeUrl, `pchome_compare_${Date.now()}`));
if (momoUrl) opened.push(window.open(momoUrl, `momo_compare_${Date.now()}`));
for (const win of opened) {
if (win) {
try { win.opener = null; } catch (_) {}
}
}
if (opened.some((win) => !win)) {
showToast('瀏覽器擋住了其中一個視窗;請用商品旁的 PChome / MOMO 按鈕分別開啟。', 'warning');
}
}
function getResultActionBadge(match) {
if (match.cheaper_at === 'momo') {
return '<span class="price-action-pill is-urgent">檢查售價</span>';
}
if (match.cheaper_at === 'pchome') {
return '<span class="price-action-pill is-good">主推曝光</span>';
}
return '<span class="price-action-pill is-watch">觀察賣點</span>';
}
function formatPriceGap(match) {
const diff = Number(match.price_diff || 0);
const pct = Number(match.price_diff_percent || 0);
const label = diff > 0 ? 'PChome 貴' : (diff < 0 ? 'PChome 便宜' : '價格相同');
const amount = diff === 0 ? '$0' : formatMoney(Math.abs(diff));
const pctText = Number.isFinite(pct) ? `${Math.abs(pct).toFixed(1)}%` : '0.0%';
return `<strong>${label} ${amount}</strong><br><small>${pctText}</small>`;
}
function exportResult() {
if (!comparisonResult || !comparisonResult.matches.length) {
showToast('沒有資料可匯出', 'warning');
return;
}
const headers = ['下一步', 'PChome商品', 'MOMO商品', '相似度%', 'PChome價', 'MOMO價', '價差'];
const rows = comparisonResult.matches.map(m => [
`"${actionLabelForExport(m).replace(/"/g, '""')}"`,
`"${m.pchome.name.replace(/"/g, '""')}"`,
`"${m.momo.name.replace(/"/g, '""')}"`,
Math.round(m.similarity * 100),
m.pchome.price,
m.momo.price,
m.price_diff,
]);
const csv = '\uFEFF' + [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
downloadFile(csv, 'pchome_momo_price_actions.csv', 'text/csv;charset=utf-8');
}
function actionLabelForExport(match) {
if (match.cheaper_at === 'momo') return '檢查 PChome 售價或活動';
if (match.cheaper_at === 'pchome') return '安排 PChome 曝光或文案';
return '觀察賣點與活動位置';
}
function showProgress(title, detail) {
document.getElementById('progressText').textContent = title;
document.getElementById('progressDetail').textContent = detail;
document.getElementById('progressSection').style.display = 'block';
}
function hideProgress() {
document.getElementById('progressSection').style.display = 'none';
}
function downloadFile(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function formatMoney(value) {
const number = Number(value || 0);
return `$${Math.round(number).toLocaleString('zh-TW')}`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function toSafeUrl(value) {
const target = String(value || '').trim();
if (!target) return '';
try {
const parsed = new URL(target, location.origin);
if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
return parsed.href;
}
} catch (error) {
return '';
}
return '';
}
function extractMomoCodeFromUrl(url) {
const target = (url || '').trim();
if (!target) {
return '';
}
try {
const parsed = new URL(target, location.origin);
const iCode = parsed.searchParams.get('i_code');
if (iCode) {
return iCode.trim();
}
} catch (error) {
// ignore
}
const match = /[?&]i_code=([^&#]+)/i.exec(target);
return match ? decodeURIComponent(match[1] || '').trim() : '';
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `price-toast price-toast--${type} position-fixed`;
const closeButton = document.createElement('button');
closeButton.type = 'button';
closeButton.className = 'btn-close float-end';
closeButton.addEventListener('click', () => toast.remove());
const text = document.createElement('span');
text.textContent = message;
toast.appendChild(closeButton);
toast.appendChild(text);
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
</script>
{% endblock %}