Files
ewoooc/templates/ai_intelligence.html
ogt f8eb7f6a99
All checks were successful
CD Pipeline / deploy (push) Successful in 1m1s
fix: improve growth comparison UX
2026-06-25 15:39:52 +08:00

5621 lines
210 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>
.ai-intel-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.ai-intel-hero,
.growth-executive-strip,
#growthActionBoard,
.ops-flow {
display: none !important;
}
.growth-command-pro {
display: grid;
gap: 14px;
width: 100%;
max-width: 1480px;
margin: 0 auto;
}
.growth-command-pro-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: var(--momo-shadow-soft);
padding: 16px 18px;
}
.growth-command-pro-title {
margin: 0;
color: var(--momo-text-strong);
font-family: var(--momo-font-display);
font-size: 1.3rem;
font-weight: 900;
letter-spacing: 0;
}
.growth-command-pro-subtitle {
margin: 5px 0 0;
color: var(--momo-text-muted);
font-size: 0.82rem;
font-weight: 760;
}
.growth-command-pro-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
max-width: 680px;
}
.growth-command-status-pill {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 34px;
border: 1px solid rgba(40, 128, 80, 0.22);
border-radius: 999px;
background: rgba(232, 247, 238, 0.9);
color: #216542;
font-size: 0.78rem;
font-weight: 900;
padding: 7px 12px;
}
.growth-command-kpi-grid {
display: grid;
grid-template-columns: 1.28fr repeat(4, minmax(0, 1fr));
gap: 10px;
}
.growth-command-card {
min-height: 136px;
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: var(--momo-shadow-soft);
padding: 13px;
}
.growth-command-card.is-clickable {
cursor: pointer;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
}
.growth-command-card.is-clickable:hover,
.growth-command-card.is-clickable:focus {
border-color: rgba(49, 113, 234, 0.28);
box-shadow: 0 0 0 3px rgba(49, 113, 234, 0.08), var(--momo-shadow-soft);
outline: none;
transform: translateY(-1px);
}
.growth-command-kpi-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--momo-text-muted);
font-size: 0.74rem;
font-weight: 900;
}
.growth-command-kpi-label i {
color: var(--momo-warm-rust);
}
.growth-command-kpi-value {
display: block;
margin-top: 10px;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.75rem;
font-weight: 900;
line-height: 1;
}
.growth-command-card.is-sales .growth-command-kpi-value {
font-size: 2.05rem;
}
.growth-command-kpi-note {
display: block;
margin-top: 8px;
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 800;
line-height: 1.35;
}
.growth-command-spark {
display: block;
width: 100%;
height: 34px;
margin-top: 9px;
}
.growth-command-progress {
overflow: hidden;
height: 8px;
margin-top: 12px;
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
}
.growth-command-progress span {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: #3bb273;
transition: width 0.24s ease;
}
.growth-command-focus {
display: grid;
grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr) minmax(260px, 0.85fr);
gap: 12px;
}
.growth-command-panel {
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: var(--momo-shadow-soft);
padding: 14px;
}
.growth-command-panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.growth-command-panel-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.92rem;
font-weight: 900;
}
.growth-command-panel-link {
border: 0;
background: transparent;
color: #3171ea;
font-size: 0.74rem;
font-weight: 900;
padding: 0;
}
.growth-command-table {
width: 100%;
border-collapse: collapse;
}
.growth-command-table td {
border-top: 1px solid rgba(42, 37, 32, 0.08);
padding: 9px 4px;
vertical-align: middle;
}
.growth-command-product {
display: block;
max-width: 280px;
overflow: hidden;
color: var(--momo-text-strong);
font-size: 0.82rem;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.growth-command-meta {
display: block;
margin-top: 3px;
color: var(--momo-text-muted);
font-size: 0.72rem;
font-weight: 780;
}
.growth-command-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
border-radius: 999px;
background: rgba(255, 239, 232, 0.88);
color: #b94f3a;
font-size: 0.72rem;
font-weight: 900;
padding: 5px 8px;
}
.growth-command-donut-wrap {
display: grid;
grid-template-columns: 168px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.growth-command-donut {
--ready: 0;
--review: 0;
width: 152px;
aspect-ratio: 1;
border-radius: 50%;
background:
conic-gradient(#3bb273 0 calc(var(--ready) * 1%), #f2b25a 0 calc((var(--ready) + var(--review)) * 1%), #d9d5cc 0 100%);
display: grid;
place-items: center;
}
.growth-command-donut strong {
display: grid;
place-items: center;
width: 92px;
aspect-ratio: 1;
border-radius: 50%;
background: #fff;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.35rem;
font-weight: 900;
text-align: center;
line-height: 1.1;
}
.growth-command-legend {
display: grid;
gap: 8px;
}
.growth-command-legend-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
color: var(--momo-text-muted);
font-size: 0.78rem;
font-weight: 850;
}
.growth-command-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #d9d5cc;
}
.growth-command-dot.is-ready { background: #3bb273; }
.growth-command-dot.is-review { background: #f2b25a; }
.growth-command-mini-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px;
}
.growth-command-mini-card {
min-height: 88px;
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
background: rgba(250, 247, 240, 0.58);
padding: 10px;
}
.growth-command-mini-card span {
color: var(--momo-text-muted);
font-size: 0.72rem;
font-weight: 900;
}
.growth-command-mini-card strong {
display: block;
margin-top: 8px;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.45rem;
font-weight: 900;
line-height: 1;
}
.growth-command-mini-card em {
display: block;
margin-top: 7px;
color: var(--momo-text-muted);
font-size: 0.72rem;
font-style: normal;
font-weight: 780;
}
.growth-command-alert {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
justify-self: start;
width: min(100%, 980px);
min-height: 72px;
border: 1px solid rgba(42, 37, 32, 0.14);
border-left: 4px solid #3171ea;
border-radius: 8px;
background: rgba(255, 255, 255, 0.94);
box-shadow: var(--momo-shadow-soft);
padding: 12px 14px;
}
.growth-command-alert i {
display: inline-grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid rgba(49, 113, 234, 0.18);
border-radius: 50%;
background: rgba(235, 243, 255, 0.95);
color: #3171ea;
}
.growth-command-alert-copy {
min-width: 0;
}
.growth-command-alert strong {
color: var(--momo-text-strong);
font-size: 0.92rem;
font-weight: 900;
line-height: 1.25;
}
.growth-command-alert span {
display: block;
max-width: 66ch;
margin-top: 3px;
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 780;
line-height: 1.45;
}
#commandTaskButton.growth-command-alert-action {
min-width: 108px;
justify-self: end;
border-color: #8f442b !important;
background-color: #8f442b !important;
background-image: none !important;
color: #fff !important;
box-shadow: 0 8px 18px rgba(143, 68, 43, 0.18) !important;
}
#commandTaskButton.growth-command-alert-action:hover,
#commandTaskButton.growth-command-alert-action:focus {
border-color: #743620 !important;
background-color: #743620 !important;
color: #fff !important;
}
.ai-intel-hero {
position: relative;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding: 22px;
border: 1px solid var(--momo-border-strong);
border-radius: 8px;
background:
radial-gradient(circle at 18px 18px, rgba(42, 37, 32, 0.12) 1px, transparent 1px),
linear-gradient(135deg, rgba(242, 178, 90, 0.28), rgba(255, 255, 255, 0.92) 42%, rgba(172, 92, 58, 0.12));
background-size: 18px 18px, auto;
box-shadow: var(--momo-shadow-soft);
}
.ai-intel-hero::after {
content: "";
position: absolute;
inset: auto 20px 18px auto;
width: 132px;
height: 132px;
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 50%;
background: repeating-linear-gradient(
90deg,
rgba(42, 37, 32, 0.08) 0,
rgba(42, 37, 32, 0.08) 1px,
transparent 1px,
transparent 8px
);
opacity: 0.72;
pointer-events: none;
}
.ai-intel-title {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin: 0;
color: var(--momo-text-strong);
font-family: var(--momo-font-display);
font-size: clamp(1.45rem, 2vw, 2.15rem);
font-weight: 800;
letter-spacing: 0;
}
.ai-intel-title i {
color: var(--momo-warm-rust);
}
.ai-intel-badge,
.ai-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(42, 37, 32, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.68);
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 0.78rem;
font-weight: 800;
padding: 5px 10px;
}
.ai-status-badge.is-success {
border-color: rgba(40, 128, 80, 0.24);
background: rgba(232, 247, 238, 0.88);
color: #216542;
}
.ai-status-badge.is-error {
border-color: rgba(188, 75, 49, 0.26);
background: rgba(255, 241, 237, 0.9);
color: #9b3d2b;
}
.ai-intel-subtitle {
position: relative;
z-index: 1;
margin: 8px 0 0;
color: var(--momo-text-muted);
font-size: 0.93rem;
}
.ai-intel-actions {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
max-width: 620px;
}
.ai-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 38px;
border-radius: 8px;
font-weight: 800;
white-space: nowrap;
}
.ai-action-btn.btn-outline-danger {
color: var(--momo-warm-rust);
border-color: rgba(172, 92, 58, 0.44);
}
.ai-action-btn.btn-outline-primary {
color: var(--momo-accent-strong);
border-color: rgba(42, 37, 32, 0.24);
}
.ai-action-btn.btn-outline-warning {
color: #805313;
border-color: rgba(242, 178, 90, 0.66);
}
.ai-intel-page #kpiRow .card,
.ai-panel {
border: 1px solid var(--momo-border-subtle) !important;
border-radius: 8px;
background: rgba(255, 255, 255, 0.84);
box-shadow: var(--momo-shadow-soft);
}
.ai-intel-page #kpiRow .card-body {
display: flex;
flex-direction: column;
justify-content: center;
min-height: 116px;
}
.ai-intel-page #kpiRow .fs-2 {
color: var(--momo-text-strong) !important;
font-family: var(--momo-font-mono);
font-size: 2rem !important;
line-height: 1.05;
}
.ai-intel-page #kpiRow .small {
color: var(--momo-text-muted) !important;
font-weight: 700;
}
.ai-intel-page #kpiHighRiskCard.border-danger {
background: linear-gradient(160deg, rgba(255, 245, 240, 0.98), rgba(255, 255, 255, 0.9));
border-color: rgba(188, 75, 49, 0.48) !important;
}
.ai-panel .card-header,
.ai-panel .card-footer {
border-color: var(--momo-border-subtle) !important;
background: rgba(255, 255, 255, 0.78) !important;
}
.ai-panel-title {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--momo-text-strong);
font-family: var(--momo-font-display);
font-size: 0.95rem;
font-weight: 800;
}
.ai-panel-title i {
color: var(--momo-warm-caramel) !important;
}
.ai-panel .form-select,
.ai-panel .form-control {
border-color: var(--momo-border-subtle);
border-radius: 8px;
color: var(--momo-text-strong);
font-size: 0.82rem;
}
.ai-legend {
border-bottom: 1px solid var(--momo-border-subtle);
}
.ai-table-scroll {
overflow-x: auto;
overflow-y: auto;
max-height: 520px;
}
.ai-intel-page .table {
--bs-table-hover-bg: rgba(242, 178, 90, 0.12);
color: var(--momo-text-strong);
}
.ai-intel-page .table thead th {
border-bottom: 1px solid var(--momo-border-strong);
background: rgba(250, 247, 240, 0.96) !important;
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 800;
}
.ai-intel-page .table tbody td {
border-color: rgba(42, 37, 32, 0.08);
}
.ai-recs-scroll {
overflow-y: auto;
max-height: 568px;
}
.ai-intel-page #aiRecsList > .border {
border-color: var(--momo-border-subtle) !important;
border-radius: 8px !important;
background: rgba(250, 247, 240, 0.54);
}
.growth-ops-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.7fr);
gap: 14px;
}
.growth-executive-strip {
display: grid;
grid-template-columns: minmax(0, 1.25fr) repeat(3, minmax(0, 0.75fr));
gap: 10px;
}
.growth-exec-card {
min-height: 118px;
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
background: rgba(255, 255, 255, 0.82);
box-shadow: var(--momo-shadow-soft);
padding: 13px;
}
.growth-exec-card.is-primary {
border-color: rgba(172, 92, 58, 0.22);
background: rgba(242, 178, 90, 0.15);
}
.growth-exec-card.is-ready {
border-color: rgba(42, 134, 96, 0.24);
background: rgba(235, 248, 241, 0.76);
}
.growth-exec-card.is-gap {
border-color: rgba(188, 78, 67, 0.22);
background: rgba(255, 244, 239, 0.78);
}
.growth-exec-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--momo-text-muted);
font-size: 0.74rem;
font-weight: 900;
}
.growth-exec-label i {
color: var(--momo-warm-rust);
}
.growth-exec-value {
margin-top: 11px;
color: var(--momo-text-strong);
font-family: var(--momo-font-display);
font-size: 1.05rem;
font-weight: 900;
line-height: 1.28;
}
.growth-exec-card:not(.is-primary) .growth-exec-value {
font-family: var(--momo-font-mono);
font-size: 1.72rem;
line-height: 1;
}
.growth-exec-card.is-clickable,
.ops-dashboard-tile.is-clickable,
.growth-metric.is-clickable,
#kpiRow .card.is-clickable {
cursor: pointer;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.growth-exec-card.is-clickable:hover,
.growth-exec-card.is-clickable:focus,
.ops-dashboard-tile.is-clickable:hover,
.ops-dashboard-tile.is-clickable:focus,
.growth-metric.is-clickable:hover,
.growth-metric.is-clickable:focus,
#kpiRow .card.is-clickable:hover,
#kpiRow .card.is-clickable:focus {
border-color: rgba(172, 92, 58, 0.34);
box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.1), var(--momo-shadow-soft);
outline: none;
transform: translateY(-1px);
}
.growth-exec-detail {
margin-top: 7px;
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 760;
line-height: 1.4;
}
.drilldown-hint {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 6px;
color: var(--momo-warm-rust);
font-size: 0.7rem;
font-weight: 900;
white-space: nowrap;
}
.ops-flow {
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
background: rgba(255, 255, 255, 0.82);
box-shadow: var(--momo-shadow-soft);
padding: 14px;
}
.ops-flow-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.ops-flow-note {
color: var(--momo-text-muted);
font-size: 0.78rem;
font-weight: 800;
}
.next-action-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
margin-bottom: 12px;
border: 1px solid rgba(172, 92, 58, 0.2);
border-radius: 8px;
background: rgba(242, 178, 90, 0.16);
padding: 12px;
}
.next-action-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 999px;
background: rgba(172, 92, 58, 0.14);
color: var(--momo-warm-rust);
}
.next-action-title {
display: block;
color: var(--momo-text-strong);
font-size: 0.98rem;
font-weight: 900;
line-height: 1.25;
}
.next-action-reason {
display: block;
margin-top: 2px;
color: var(--momo-text-muted);
font-size: 0.78rem;
font-weight: 800;
line-height: 1.35;
}
.ops-flow-grid {
display: grid;
grid-template-columns: minmax(0, 1.12fr) minmax(0, 1fr) minmax(260px, 0.82fr);
gap: 12px;
}
.ops-dashboard-tile {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(250, 247, 240, 0.58);
padding: 12px;
min-height: 168px;
}
.ops-dashboard-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 0 10px;
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 900;
}
.ops-dashboard-value {
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.72rem;
font-weight: 900;
line-height: 1;
}
.ops-flow-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
width: 100%;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(250, 247, 240, 0.52);
color: var(--momo-text-strong);
padding: 11px;
text-align: left;
transition: transform 0.16s ease, border-color 0.16s ease, background-color 0.16s ease;
}
.ops-action-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.ops-action-grid .ops-flow-item {
grid-template-columns: auto minmax(0, 1fr);
min-height: 70px;
padding: 10px;
}
.ops-funnel,
.ops-source-bars,
.price-risk-bars {
display: grid;
gap: 9px;
}
.ops-funnel-row,
.ops-source-row,
.price-risk-row {
display: grid;
gap: 5px;
}
.price-risk-row[role="button"] {
border-radius: 8px;
cursor: pointer;
padding: 6px;
transition: background-color 0.16s ease, transform 0.16s ease;
}
.price-risk-row[role="button"]:hover,
.price-risk-row[role="button"]:focus {
background: rgba(255, 255, 255, 0.82);
outline: 0;
transform: translateY(-1px);
}
.ops-funnel-meta,
.ops-source-meta,
.price-risk-meta {
display: flex;
justify-content: space-between;
gap: 10px;
color: var(--momo-text-muted);
font-size: 0.74rem;
font-weight: 900;
}
.ops-funnel-track,
.ops-source-track,
.price-risk-track {
overflow: hidden;
height: 8px;
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
}
.ops-funnel-bar,
.ops-source-bar,
.price-risk-bar {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--momo-warm-caramel);
transition: width 0.28s ease;
}
.ops-funnel-bar.is-ready,
.price-risk-bar.is-low {
background: #2f8f66;
}
.ops-funnel-bar.is-gap,
.price-risk-bar.is-high {
background: #b94f3a;
}
.ops-source-bar.is-secondary,
.price-risk-bar.is-medium {
background: #d8a13a;
}
.ops-flow-item:hover,
.ops-flow-item:focus {
border-color: rgba(172, 92, 58, 0.34);
background: rgba(255, 255, 255, 0.86);
transform: translateY(-1px);
}
.ops-flow-step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 999px;
background: rgba(172, 92, 58, 0.12);
color: var(--momo-warm-rust);
font-family: var(--momo-font-mono);
font-size: 0.76rem;
font-weight: 900;
}
.ops-flow-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.86rem;
font-weight: 900;
line-height: 1.25;
}
.ops-flow-copy,
.ops-flow-target {
margin: 4px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.ops-flow-target {
color: var(--momo-warm-rust);
font-weight: 900;
}
.growth-metric-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.growth-metric {
border: 1px solid var(--momo-border-subtle);
border-radius: 8px;
background: rgba(250, 247, 240, 0.62);
padding: 10px;
}
.growth-metric strong {
display: block;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.35rem;
line-height: 1.1;
}
.growth-metric span {
color: var(--momo-text-muted);
font-size: 0.75rem;
font-weight: 800;
}
.growth-source-note {
margin-top: 10px;
color: var(--momo-text-muted);
font-size: 0.82rem;
line-height: 1.55;
}
.growth-source-note.is-compact {
margin-top: 8px;
font-weight: 800;
line-height: 1.35;
}
.growth-action-hint {
margin: 10px 0 0;
border: 1px solid rgba(172, 92, 58, 0.16);
border-radius: 8px;
background: rgba(242, 178, 90, 0.14);
color: var(--momo-text-strong);
font-size: 0.82rem;
font-weight: 800;
line-height: 1.5;
padding: 9px 10px;
}
.growth-data-summary {
margin: 8px 0 0;
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
color: var(--momo-text-muted);
font-size: 0.78rem;
line-height: 1.45;
padding: 8px 10px;
}
.growth-detail-panel {
margin-top: 12px;
}
.growth-detail-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.growth-detail-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.95rem;
font-weight: 900;
line-height: 1.3;
}
.growth-detail-meta {
color: var(--momo-text-muted);
font-size: 0.76rem;
font-weight: 800;
}
.growth-detail-tabs {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.growth-detail-tab {
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 999px;
background: rgba(250, 247, 240, 0.66);
color: var(--momo-text-muted);
font-size: 0.74rem;
font-weight: 900;
padding: 6px 10px;
}
.growth-detail-tab.is-active {
border-color: rgba(172, 92, 58, 0.34);
background: rgba(242, 178, 90, 0.18);
color: var(--momo-text-strong);
}
.growth-strategy-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 10px;
}
.growth-strategy-card {
display: grid;
gap: 6px;
min-height: 104px;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(250, 247, 240, 0.7);
color: var(--momo-text-strong);
padding: 10px;
text-align: left;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.growth-strategy-card:hover,
.growth-strategy-card:focus,
.growth-strategy-card.is-active {
border-color: rgba(172, 92, 58, 0.36);
box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.1), var(--momo-shadow-soft);
outline: 0;
transform: translateY(-1px);
}
.growth-strategy-card.is-risk {
background: rgba(255, 244, 239, 0.9);
border-color: rgba(185, 79, 58, 0.22);
}
.growth-strategy-card.is-advantage {
background: rgba(238, 249, 241, 0.92);
border-color: rgba(47, 143, 102, 0.22);
}
.growth-strategy-card.is-review {
background: rgba(255, 249, 236, 0.92);
border-color: rgba(216, 161, 58, 0.24);
}
.growth-strategy-card.is-needs {
background: rgba(250, 247, 240, 0.92);
border-color: rgba(42, 37, 32, 0.12);
}
.growth-strategy-label {
color: var(--momo-text-muted);
font-size: 0.72rem;
font-weight: 900;
}
.growth-strategy-value {
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.55rem;
font-weight: 900;
line-height: 1;
}
.growth-strategy-note {
color: var(--momo-text-muted);
font-size: 0.72rem;
font-weight: 760;
line-height: 1.35;
}
.growth-strategy-track {
overflow: hidden;
height: 6px;
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
}
.growth-strategy-bar {
display: block;
width: 0;
height: 100%;
border-radius: inherit;
background: var(--momo-warm-caramel);
}
.growth-strategy-card.is-risk .growth-strategy-bar {
background: #b94f3a;
}
.growth-strategy-card.is-advantage .growth-strategy-bar {
background: #2f8f66;
}
.growth-strategy-card.is-review .growth-strategy-bar {
background: #d8a13a;
}
.growth-category-board {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
margin-bottom: 10px;
padding: 10px;
}
.growth-category-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.growth-category-title {
color: var(--momo-text-strong);
font-size: 0.84rem;
font-weight: 950;
margin: 0;
}
.growth-category-note {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 820;
text-align: right;
}
.growth-category-list {
display: grid;
gap: 7px;
}
.growth-category-row {
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
background: rgba(250, 247, 240, 0.58);
color: var(--momo-text-strong);
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(260px, 1.25fr) minmax(88px, auto);
gap: 10px;
align-items: center;
padding: 9px 10px;
text-align: left;
width: 100%;
}
.growth-category-row:hover,
.growth-category-row:focus {
border-color: rgba(172, 92, 58, 0.3);
background: rgba(255, 248, 232, 0.8);
outline: 0;
}
.growth-category-row.is-active {
border-color: rgba(172, 92, 58, 0.38);
background: rgba(255, 248, 232, 0.9);
box-shadow: inset 3px 0 0 rgba(172, 92, 58, 0.48);
}
.growth-category-name {
display: block;
font-size: 0.8rem;
font-weight: 950;
line-height: 1.25;
}
.growth-category-meta,
.growth-category-action {
color: var(--momo-text-muted);
display: block;
font-size: 0.68rem;
font-weight: 850;
line-height: 1.3;
margin-top: 3px;
}
.growth-category-bars {
display: grid;
gap: 5px;
}
.growth-category-track {
background: rgba(42, 37, 32, 0.08);
border-radius: 999px;
height: 7px;
overflow: hidden;
}
.growth-category-bar {
background: #ac5c3a;
border-radius: inherit;
display: block;
height: 100%;
}
.growth-category-stats {
color: var(--momo-text-muted);
display: flex;
flex-wrap: wrap;
gap: 5px;
font-size: 0.66rem;
font-weight: 850;
line-height: 1.25;
}
.growth-category-stats strong {
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
}
.growth-category-cta {
color: #8f4d33;
font-size: 0.72rem;
font-weight: 950;
text-align: right;
}
.growth-playbook-board {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.7);
margin-bottom: 10px;
padding: 10px;
}
.growth-playbook-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.growth-playbook-title {
color: var(--momo-text-strong);
font-size: 0.84rem;
font-weight: 950;
margin: 0;
}
.growth-playbook-note {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 820;
text-align: right;
}
.growth-playbook-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.growth-playbook-card {
border: 1px solid rgba(42, 37, 32, 0.09);
border-radius: 8px;
background: rgba(250, 247, 240, 0.58);
color: var(--momo-text-strong);
display: grid;
gap: 7px;
min-height: 142px;
padding: 10px;
text-align: left;
width: 100%;
}
.growth-playbook-card:hover,
.growth-playbook-card:focus,
.growth-playbook-card.is-active {
border-color: rgba(172, 92, 58, 0.3);
background: rgba(255, 248, 232, 0.82);
outline: 0;
}
.growth-playbook-card.is-defense {
border-color: rgba(185, 79, 58, 0.2);
}
.growth-playbook-card.is-boost {
border-color: rgba(47, 143, 102, 0.2);
}
.growth-playbook-card.is-bundle {
border-color: rgba(216, 161, 58, 0.22);
}
.growth-playbook-card.is-data {
border-color: rgba(92, 111, 135, 0.2);
}
.growth-playbook-label {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 950;
line-height: 1.2;
}
.growth-playbook-value {
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 1.22rem;
font-weight: 950;
line-height: 1;
}
.growth-playbook-sales,
.growth-playbook-product,
.growth-playbook-action {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 820;
line-height: 1.35;
}
.growth-playbook-action {
color: #8f4d33;
font-weight: 950;
}
.growth-playbook-track {
background: rgba(42, 37, 32, 0.08);
border-radius: 999px;
height: 7px;
overflow: hidden;
}
.growth-playbook-bar {
background: #ac5c3a;
border-radius: inherit;
display: block;
height: 100%;
}
.growth-action-board {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
margin-bottom: 10px;
padding: 10px;
}
.growth-action-board-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.growth-action-board-title {
color: var(--momo-text-strong);
font-size: 0.84rem;
font-weight: 950;
margin: 0;
}
.growth-action-board-note {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 820;
text-align: right;
}
.growth-action-list {
display: grid;
gap: 7px;
}
.growth-action-row {
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
background: rgba(250, 247, 240, 0.58);
display: grid;
grid-template-columns: auto minmax(0, 1.1fr) minmax(170px, 0.55fr) auto;
gap: 10px;
align-items: center;
padding: 9px 10px;
}
.growth-action-rank {
align-items: center;
background: rgba(172, 92, 58, 0.12);
border-radius: 999px;
color: #8f4d33;
display: inline-flex;
font-family: var(--momo-font-mono);
font-size: 0.72rem;
font-weight: 950;
height: 34px;
justify-content: center;
width: 34px;
}
.growth-action-title {
color: var(--momo-text-strong);
font-size: 0.8rem;
font-weight: 950;
line-height: 1.3;
margin: 0;
}
.growth-action-meta,
.growth-action-reason {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 820;
line-height: 1.35;
margin: 3px 0 0;
}
.growth-action-pill {
border: 1px solid rgba(172, 92, 58, 0.18);
border-radius: 999px;
color: #8f4d33;
display: inline-flex;
font-size: 0.68rem;
font-weight: 950;
justify-content: center;
padding: 4px 8px;
white-space: nowrap;
}
.growth-action-buttons {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.growth-action-evidence {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 5px;
margin-top: 7px;
}
.growth-action-evidence-chip {
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.64);
min-width: 0;
padding: 5px 6px;
}
.growth-action-evidence-label {
color: var(--momo-text-muted);
display: block;
font-size: 0.6rem;
font-weight: 950;
line-height: 1.2;
}
.growth-action-evidence-value {
color: var(--momo-text-strong);
display: block;
font-family: var(--momo-font-mono);
font-size: 0.7rem;
font-weight: 950;
line-height: 1.25;
margin-top: 2px;
overflow-wrap: anywhere;
}
.growth-decision-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
border: 1px solid rgba(172, 92, 58, 0.18);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
margin-bottom: 10px;
padding: 10px 12px;
}
.growth-decision-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.86rem;
font-weight: 900;
line-height: 1.3;
}
.growth-decision-copy {
margin: 4px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
font-weight: 760;
line-height: 1.4;
}
.growth-decision-metrics {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.growth-decision-metric {
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 999px;
background: rgba(250, 247, 240, 0.72);
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
padding: 4px 8px;
}
.growth-decision-metric strong {
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
}
.growth-detail-controls {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.42fr) auto;
gap: 8px;
align-items: end;
margin-bottom: 10px;
}
.growth-detail-field {
display: grid;
gap: 5px;
}
.growth-detail-field span {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
}
.growth-detail-control {
width: 100%;
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.86);
color: var(--momo-text-strong);
font-size: 0.78rem;
font-weight: 800;
min-height: 34px;
padding: 7px 10px;
}
.growth-detail-control:focus {
border-color: rgba(172, 92, 58, 0.36);
box-shadow: 0 0 0 3px rgba(172, 92, 58, 0.1);
outline: 0;
}
.growth-product-panel {
border: 1px solid rgba(172, 92, 58, 0.2);
border-radius: 8px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(250, 247, 240, 0.82));
box-shadow: 0 10px 28px rgba(42, 37, 32, 0.08);
margin-bottom: 10px;
padding: 12px;
}
.growth-product-panel.is-hidden {
display: none;
}
.growth-product-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding-bottom: 10px;
}
.growth-product-kicker {
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0;
margin-bottom: 4px;
}
.growth-product-title {
color: var(--momo-text-strong);
font-size: 0.98rem;
font-weight: 950;
line-height: 1.35;
margin: 0;
}
.growth-product-meta {
color: var(--momo-text-muted);
font-size: 0.72rem;
font-weight: 760;
line-height: 1.45;
margin: 5px 0 0;
}
.growth-product-close {
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.74);
color: var(--momo-text-muted);
font-size: 0.74rem;
font-weight: 900;
min-height: 34px;
padding: 6px 10px;
}
.growth-product-body {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(280px, 1.05fr);
gap: 12px;
margin-top: 12px;
}
.growth-product-evidence-grid,
.growth-product-reason-list {
display: grid;
gap: 8px;
}
.growth-product-evidence-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.growth-product-reason-list {
margin: 0;
padding: 0;
list-style: none;
}
.growth-product-reason-list li {
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.66);
color: var(--momo-text-strong);
font-size: 0.75rem;
font-weight: 800;
line-height: 1.45;
padding: 8px 10px;
}
.growth-product-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
}
.growth-detail-result {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
min-height: 190px;
max-height: 390px;
overflow: auto;
}
.growth-detail-row {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
gap: 12px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 11px 12px;
}
.growth-detail-row:last-child {
border-bottom: 0;
}
.growth-detail-row.is-active {
background: rgba(255, 248, 232, 0.7);
box-shadow: inset 3px 0 0 rgba(172, 92, 58, 0.5);
}
.growth-detail-name {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.86rem;
font-weight: 900;
line-height: 1.35;
}
.growth-detail-name-button {
border: 0;
background: transparent;
color: inherit;
display: inline;
font: inherit;
padding: 0;
text-align: left;
text-decoration: underline;
text-decoration-color: rgba(172, 92, 58, 0.34);
text-underline-offset: 3px;
}
.growth-detail-name-button:hover,
.growth-detail-name-button:focus {
color: #8f4d33;
outline: 0;
text-decoration-color: rgba(172, 92, 58, 0.76);
}
.growth-detail-line {
margin: 4px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.growth-detail-action {
display: grid;
gap: 6px;
align-content: start;
justify-items: stretch;
}
.growth-detail-action .table-row-action {
justify-self: end;
}
.growth-detail-action-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.growth-detail-price-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.growth-price-chip {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(250, 247, 240, 0.62);
padding: 7px 8px;
}
.growth-price-chip.is-risk {
border-color: rgba(185, 79, 58, 0.22);
background: rgba(255, 244, 239, 0.86);
}
.growth-price-chip.is-good {
border-color: rgba(47, 143, 102, 0.2);
background: rgba(238, 249, 241, 0.88);
}
.growth-price-label {
display: block;
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
line-height: 1.2;
}
.growth-price-value {
display: block;
margin-top: 3px;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 0.9rem;
font-weight: 900;
line-height: 1.15;
}
.growth-price-note {
display: block;
margin-top: 2px;
color: var(--momo-text-muted);
font-size: 0.66rem;
font-weight: 760;
line-height: 1.25;
}
.growth-source-list {
display: grid;
gap: 8px;
margin-top: 10px;
}
.growth-source-chip {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
padding: 8px 10px;
}
.growth-source-chip.is-active {
border-color: rgba(42, 134, 96, 0.24);
background: rgba(235, 248, 241, 0.78);
}
.growth-source-name {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--momo-text-strong);
font-size: 0.8rem;
font-weight: 900;
}
.growth-source-status {
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
padding: 3px 7px;
white-space: nowrap;
}
.growth-source-chip.is-active .growth-source-status {
background: rgba(42, 134, 96, 0.14);
color: #1f6d4c;
}
.growth-source-detail {
margin: 5px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.growth-list {
display: grid;
gap: 8px;
max-height: 292px;
overflow-y: auto;
padding-right: 4px;
}
.growth-ops-table-wrap {
overflow: auto;
max-height: 328px;
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
}
.growth-ops-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.growth-ops-table th,
.growth-ops-table td {
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 9px 10px;
vertical-align: top;
}
.growth-ops-table th {
position: sticky;
top: 0;
z-index: 1;
background: rgba(250, 247, 240, 0.96);
color: var(--momo-text-muted);
font-size: 0.72rem;
font-weight: 900;
white-space: nowrap;
}
.growth-ops-table td {
color: var(--momo-text-strong);
}
.growth-ops-table tr:last-child td {
border-bottom: 0;
}
.growth-ops-name {
max-width: 280px;
font-weight: 900;
line-height: 1.35;
}
.growth-ops-muted {
color: var(--momo-text-muted);
font-size: 0.72rem;
line-height: 1.35;
}
.growth-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
padding: 10px 12px;
}
.growth-item-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.9rem;
font-weight: 800;
line-height: 1.35;
}
.growth-item-meta,
.growth-item-reason {
margin: 4px 0 0;
color: var(--momo-text-muted);
font-size: 0.78rem;
line-height: 1.45;
}
.growth-action-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 92px;
border: 1px solid rgba(42, 37, 32, 0.12);
border-radius: 999px;
background: rgba(242, 178, 90, 0.2);
color: var(--momo-text-strong);
font-size: 0.74rem;
font-weight: 900;
padding: 5px 8px;
text-align: center;
white-space: nowrap;
}
.price-risk-board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
border-bottom: 1px solid var(--momo-border-subtle);
padding: 10px 12px;
background: rgba(250, 247, 240, 0.46);
}
.offer-dryrun-grid {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.55fr);
gap: 14px;
}
.offer-dryrun-field {
display: grid;
gap: 8px;
}
.offer-dryrun-field label {
color: var(--momo-text-strong);
font-size: 0.78rem;
font-weight: 900;
}
.offer-dryrun-field textarea {
min-height: 132px;
resize: vertical;
border-color: var(--momo-border-subtle);
border-radius: 8px;
font-size: 0.8rem;
}
.offer-dryrun-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 10px;
}
.offer-dryrun-result {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
min-height: 216px;
padding: 10px;
}
.offer-dryrun-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 8px 0;
}
.offer-dryrun-row:last-child {
border-bottom: 0;
}
.offer-dryrun-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.82rem;
font-weight: 900;
line-height: 1.35;
}
.offer-dryrun-meta,
.offer-dryrun-reason {
margin: 3px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.offer-status-pill {
align-self: start;
border-radius: 999px;
background: rgba(42, 37, 32, 0.08);
color: var(--momo-text-muted);
font-size: 0.68rem;
font-weight: 900;
padding: 4px 8px;
white-space: nowrap;
}
.offer-status-pill.is-ready {
background: rgba(42, 134, 96, 0.14);
color: #1f6d4c;
}
.offer-status-pill.is-blocked {
background: rgba(188, 78, 67, 0.14);
color: #94372d;
}
.review-candidate-panel {
display: grid;
grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.6fr);
gap: 14px;
}
.review-candidate-summary {
display: grid;
gap: 8px;
}
.review-candidate-result {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.76);
min-height: 212px;
max-height: 360px;
overflow: auto;
padding: 10px;
}
.review-candidate-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(148px, auto);
gap: 14px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 12px 0;
}
.review-candidate-row:last-child {
border-bottom: 0;
}
.review-candidate-row.is-highlight {
border-radius: 8px;
background: rgba(242, 178, 90, 0.16);
box-shadow: inset 3px 0 0 var(--momo-warm-caramel);
padding-left: 10px;
}
.review-candidate-title {
margin: 0;
color: var(--momo-text-strong);
font-size: 0.84rem;
font-weight: 900;
line-height: 1.35;
}
.review-candidate-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 7px 0 0;
}
.review-candidate-reason {
margin: 8px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.review-candidate-compare {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 8px;
}
.review-candidate-store {
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.68);
padding: 10px;
min-width: 0;
}
.review-candidate-store strong {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--momo-text-strong);
font-size: 0.72rem;
line-height: 1.2;
}
.review-candidate-store-head {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
}
.review-candidate-thumb {
display: grid;
flex: 0 0 48px;
width: 48px;
height: 48px;
overflow: hidden;
place-items: center;
color: var(--momo-text-tertiary);
background: rgba(245, 241, 234, 0.92);
border: 1px solid rgba(42, 37, 32, 0.08);
border-radius: 8px;
font-size: 1rem;
}
.review-candidate-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.review-candidate-store-price {
display: block;
color: var(--momo-text-strong);
font-family: var(--momo-font-mono);
font-size: 0.95rem;
font-weight: 900;
margin-top: 4px;
}
.review-candidate-store-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 2.5em;
margin: 4px 0 0;
overflow: hidden;
color: var(--momo-text-muted);
font-size: 0.72rem;
line-height: 1.25;
}
.review-candidate-store a {
white-space: nowrap;
font-size: 0.7rem;
font-weight: 800;
}
.review-candidate-pill {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 3px 8px;
color: var(--momo-text-secondary);
background: rgba(245, 241, 234, 0.88);
border: 1px solid rgba(42, 37, 32, 0.1);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 900;
line-height: 1.1;
}
.review-candidate-pill.is-risk {
color: #94372d;
background: rgba(255, 244, 239, 0.92);
border-color: rgba(188, 78, 67, 0.2);
}
.review-candidate-pill.is-win {
color: #1f6d4c;
background: rgba(235, 248, 241, 0.92);
border-color: rgba(42, 134, 96, 0.2);
}
.review-candidate-pill.is-review {
color: #8a5a0a;
background: rgba(255, 248, 231, 0.95);
border-color: rgba(210, 158, 58, 0.24);
}
.review-candidate-reason-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.review-candidate-actions {
display: grid;
gap: 7px;
align-content: start;
min-width: 148px;
}
.review-candidate-actions .btn {
white-space: nowrap;
}
@media (max-width: 1320px) {
.growth-command-kpi-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.growth-command-card.is-sales {
grid-column: span 2;
}
.growth-command-focus {
grid-template-columns: minmax(0, 1fr) minmax(300px, 0.82fr);
}
.growth-command-focus .growth-command-panel:first-child {
grid-column: 1 / -1;
}
}
@media (max-width: 992px) {
.growth-command-pro-head {
display: grid;
grid-template-columns: 1fr;
}
.growth-command-pro-actions {
justify-content: flex-start;
max-width: none;
}
.growth-command-kpi-grid,
.growth-command-focus {
grid-template-columns: 1fr;
}
.growth-command-card.is-sales,
.growth-command-focus .growth-command-panel:first-child {
grid-column: auto;
}
.growth-command-alert {
width: 100%;
}
.ai-intel-hero {
grid-template-columns: 1fr;
}
.ai-intel-actions {
justify-content: flex-start;
max-width: none;
}
}
@media (max-width: 576px) {
.growth-command-alert {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.growth-command-alert-action {
grid-column: 1 / -1;
justify-self: stretch;
width: 100%;
}
.growth-command-pro-actions .ai-action-btn,
.growth-command-status-pill {
width: 100%;
}
.ai-intel-hero {
padding: 18px;
}
.ai-intel-hero .ai-action-btn {
display: none;
}
}
@media (max-width: 768px) {
.ai-panel .card-header {
align-items: flex-start !important;
flex-direction: column;
gap: 10px;
}
.ai-panel .card-header > .d-flex {
display: grid !important;
grid-template-columns: 1fr;
width: 100%;
}
.ai-panel .card-header .form-select,
.ai-panel .card-header .form-control {
width: 100% !important;
}
.ai-table-scroll {
max-height: none;
overflow: visible;
}
#competitorTable,
#competitorTable tbody,
#competitorTable tr,
#competitorTable td {
display: block;
width: 100% !important;
}
#competitorTable thead {
display: none;
}
#competitorTable tr {
padding: 0.85rem 0.95rem;
border-top: 1px solid rgba(42, 37, 32, 0.08);
}
.growth-ops-table,
.growth-ops-table tbody,
.growth-ops-table tr,
.growth-ops-table td {
display: block;
width: 100% !important;
}
.growth-ops-table thead {
display: none;
}
.growth-ops-table-wrap {
max-height: none;
overflow: visible;
}
.growth-ops-table tr {
padding: 0.85rem 0.95rem;
border-top: 1px solid rgba(42, 37, 32, 0.08);
}
.growth-ops-table tr:first-child {
border-top: 0;
}
.growth-ops-table td {
display: grid;
grid-template-columns: 6rem minmax(0, 1fr);
gap: 0.65rem;
align-items: start;
padding: 0.36rem 0 !important;
border: 0 !important;
text-align: left !important;
overflow-wrap: anywhere;
white-space: normal;
}
.growth-ops-table td::before {
content: attr(data-label);
color: var(--momo-text-muted);
font-family: var(--momo-font-mono);
font-size: 0.68rem;
font-weight: 800;
}
.growth-ops-table .table-row-action,
#competitorTable .table-row-action {
width: 100%;
}
#competitorTable tr:first-child {
border-top: 0;
}
#competitorTable td {
display: grid;
grid-template-columns: 5.8rem minmax(0, 1fr);
gap: 0.65rem;
align-items: start;
padding: 0.36rem 0 !important;
border: 0 !important;
text-align: left !important;
overflow-wrap: anywhere;
white-space: normal;
}
#competitorTable td::before {
content: attr(data-label);
color: var(--momo-text-muted);
font-family: var(--momo-font-mono);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.06em;
}
#competitorTable td[colspan] {
display: block;
}
#competitorTable td[colspan]::before {
content: none;
display: none;
}
.ai-panel .card-footer {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.next-action-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.next-action-banner .ai-action-btn {
grid-column: 1 / -1;
width: 100%;
}
.growth-ops-grid,
.review-candidate-panel,
.growth-executive-strip,
.offer-dryrun-grid,
.growth-metric-row,
.growth-strategy-grid,
.ops-flow-grid,
.offer-dryrun-summary,
.price-risk-board {
grid-template-columns: 1fr;
}
.growth-item,
.growth-detail-row,
.offer-dryrun-row {
grid-template-columns: 1fr;
}
.review-candidate-row,
.review-candidate-compare {
grid-template-columns: 1fr;
}
.review-candidate-actions {
grid-template-columns: 1fr 1fr;
min-width: 0;
width: 100%;
}
.review-candidate-actions .btn:first-child {
grid-column: 1 / -1;
}
.growth-detail-action {
justify-items: stretch;
}
.growth-detail-action .table-row-action {
justify-self: stretch;
}
.growth-decision-panel {
grid-template-columns: 1fr;
}
.growth-decision-panel .table-row-action {
width: 100%;
}
.growth-detail-controls {
grid-template-columns: 1fr;
}
.growth-product-head,
.growth-product-body,
.growth-product-evidence-grid {
grid-template-columns: 1fr;
}
.growth-product-actions,
.growth-detail-action-row {
justify-content: stretch;
}
.growth-product-actions .table-row-action,
.growth-detail-action-row .table-row-action {
flex: 1 1 100%;
}
.growth-category-head {
align-items: flex-start;
flex-direction: column;
}
.growth-category-note {
text-align: left;
}
.growth-category-row {
grid-template-columns: 1fr;
}
.growth-category-cta {
text-align: left;
}
.growth-playbook-head {
align-items: flex-start;
flex-direction: column;
}
.growth-playbook-note {
text-align: left;
}
.growth-playbook-grid {
grid-template-columns: 1fr;
}
.growth-playbook-card {
min-height: 0;
}
.growth-action-board-head {
align-items: flex-start;
flex-direction: column;
}
.growth-action-board-note {
text-align: left;
}
.growth-action-row {
grid-template-columns: 1fr;
}
.growth-action-buttons {
justify-content: stretch;
}
.growth-action-buttons .table-row-action {
flex: 1 1 100%;
}
.growth-action-evidence {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.growth-detail-price-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block ewooo_content %}
<div class="ai-intel-page">
<section class="growth-command-pro" aria-label="PChome 業績成長系統">
<div class="growth-command-pro-head">
<div>
<h1 class="growth-command-pro-title">PChome 業績成長系統</h1>
<p class="growth-command-pro-subtitle">評估業績、分析價差、決定今天的解法。</p>
</div>
<div class="growth-command-pro-actions">
<span id="growthCommandStatus" class="growth-command-status-pill">
<i class="fas fa-circle-check"></i>
<span>讀取中</span>
</span>
<button class="btn btn-primary btn-sm ai-action-btn" id="btnTrigger" data-action="trigger-analysis" onclick="triggerAnalysis()">
<i class="fas fa-bolt me-1"></i>更新今日建議
</button>
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="btnPickList" data-action="generate-picks" onclick="generatePickList()">
<i class="fas fa-wand-magic-sparkles me-1"></i>產生今日清單
</button>
<button class="btn btn-outline-warning btn-sm ai-action-btn" id="btnBackfill" data-action="backfill" onclick="backfillPchomeMatches()">
<i class="fas fa-magnifying-glass-chart me-1"></i>補齊比價資料
</button>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadDashboard(); loadGrowthOps(true);">
<i class="fas fa-rotate me-1"></i>重新整理
</button>
</div>
</div>
<div class="growth-command-kpi-grid" aria-label="業績重點數字">
<article class="growth-command-card is-sales is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="growth-command-kpi-label">
<span>PChome 近 7 天業績</span>
<i class="fas fa-chart-line"></i>
</div>
<strong class="growth-command-kpi-value" id="commandSales7d"></strong>
<svg class="growth-command-spark" viewBox="0 0 140 34" role="img" aria-label="近 7 天趨勢">
<polyline points="3,25 24,20 45,23 66,12 87,16 108,8 137,11" fill="none" stroke="#3171ea" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></polyline>
</svg>
<span class="growth-command-kpi-note" id="commandSalesDelta">等待資料</span>
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<div class="growth-command-kpi-label">
<span>比價可用率</span>
<i class="fas fa-link"></i>
</div>
<strong class="growth-command-kpi-value" id="commandMatchRate">—%</strong>
<div class="growth-command-progress"><span id="commandMatchRateBar"></span></div>
<span class="growth-command-kpi-note" id="commandMappedRatio">等待 MOMO 對應</span>
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="growth-command-kpi-label">
<span>下滑商品</span>
<i class="fas fa-arrow-trend-down"></i>
</div>
<strong class="growth-command-kpi-value" id="commandDecliningProducts"></strong>
<span class="growth-command-kpi-note" id="commandActiveProducts">等待業績商品數</span>
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('needs')" onkeydown="handleGrowthDetailKey(event, 'needs')">
<div class="growth-command-kpi-label">
<span>待補比價</span>
<i class="fas fa-magnifying-glass-chart"></i>
</div>
<strong class="growth-command-kpi-value" id="commandNeedMapping"></strong>
<span class="growth-command-kpi-note"><span id="commandReviewCount"></span> 筆候選待確認</span>
</article>
<article class="growth-command-card is-clickable" role="button" tabindex="0" onclick="showGrowthCategoryDetail('')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="growth-command-kpi-label">
<span>最大業績分類</span>
<i class="fas fa-layer-group"></i>
</div>
<strong class="growth-command-kpi-value" id="commandTopCategory"></strong>
<span class="growth-command-kpi-note" id="commandTopCategorySales">等待分類業績</span>
</article>
</div>
<div class="growth-command-alert" aria-live="polite">
<i class="fas fa-bullseye"></i>
<div class="growth-command-alert-copy">
<strong id="commandTaskTitle">正在整理今天第一件事</strong>
<span id="commandTaskCopy">讀取 PChome 業績與 MOMO 比價。</span>
</div>
<button type="button" class="btn btn-sm btn-primary ai-action-btn growth-command-alert-action" id="commandTaskButton" onclick="scrollToPanel('growthOpsPanel')">查看清單</button>
</div>
<div class="growth-command-focus">
<section class="growth-command-panel" aria-label="下滑商品 TOP 5">
<div class="growth-command-panel-head">
<h2 class="growth-command-panel-title">下滑商品 TOP 5</h2>
<button class="growth-command-panel-link" type="button" onclick="showGrowthDetail('all')">看全部</button>
</div>
<table class="growth-command-table">
<tbody id="commandTopDecliners">
<tr><td class="text-muted">整理商品中...</td></tr>
</tbody>
</table>
</section>
<section class="growth-command-panel" aria-label="PChome 與 MOMO 價格狀態">
<div class="growth-command-panel-head">
<h2 class="growth-command-panel-title">PChome 與 MOMO 價格狀態</h2>
<button class="growth-command-panel-link" type="button" onclick="showGrowthDetail('risk')">看價格壓力</button>
</div>
<div class="growth-command-donut-wrap">
<div class="growth-command-donut" id="commandPriceDonut">
<strong id="commandDonutText">—%</strong>
</div>
<div class="growth-command-legend">
<div class="growth-command-legend-row">
<span class="growth-command-dot is-ready"></span>
<span>PChome 有優勢</span>
<strong id="commandPriceAdvantage"></strong>
</div>
<div class="growth-command-legend-row">
<span class="growth-command-dot is-review"></span>
<span>MOMO 更便宜</span>
<strong id="commandPricePressure"></strong>
</div>
<div class="growth-command-legend-row">
<span class="growth-command-dot"></span>
<span>待確認 / 待補</span>
<strong id="commandPricePending"></strong>
</div>
</div>
</div>
</section>
<section class="growth-command-panel" aria-label="處理狀態與活動機會">
<div class="growth-command-panel-head">
<h2 class="growth-command-panel-title">處理狀態</h2>
<button class="growth-command-panel-link" type="button" onclick="scrollToPanel('growthReviewPanel')">確認候選</button>
</div>
<div class="growth-command-mini-grid">
<div class="growth-command-mini-card">
<span>可立即處理</span>
<strong id="commandStatusReady"></strong>
<em>已有外部價格可判斷</em>
</div>
<div class="growth-command-mini-card">
<span>待確認</span>
<strong id="commandStatusReview"></strong>
<em>先確認同款</em>
</div>
<div class="growth-command-mini-card">
<span>待補比價</span>
<strong id="commandStatusNeeds"></strong>
<em>需要補抓 MOMO</em>
</div>
<div class="growth-command-mini-card">
<span>最新業績日</span>
<strong id="commandLatestSalesDate"></strong>
<em>資料更新狀態</em>
</div>
</div>
</section>
</div>
</section>
<!-- ── 頁首 ── -->
<section class="ai-intel-hero">
<div>
<h1 class="ai-intel-title">
<i class="fas fa-brain"></i>
PChome 業績成長指揮台
<span class="ai-intel-badge">今日業績助手</span>
</h1>
<p class="ai-intel-subtitle">一進來先看:今天該處理哪些商品、哪裡價格偏高、哪些資料還不完整。</p>
</div>
<div class="ai-intel-actions">
<span id="lastUpdateBadge" class="ai-status-badge">
<i class="fas fa-sync me-1"></i>載入中...
</span>
<button class="btn btn-outline-danger btn-sm ai-action-btn" id="legacyBtnTrigger" data-action="trigger-analysis" onclick="triggerAnalysis()">
<i class="fas fa-bolt me-1"></i>更新今日建議
</button>
<button class="btn btn-outline-primary btn-sm ai-action-btn" id="legacyBtnPickList" data-action="generate-picks" onclick="generatePickList()" title="依目前業績與比價資料整理商品處理清單">
<i class="fas fa-wand-magic-sparkles me-1"></i>產生今日清單
</button>
<button class="btn btn-outline-warning btn-sm ai-action-btn" id="legacyBtnBackfill" data-action="backfill" onclick="backfillPchomeMatches()" title="替還不能比價的 PChome 商品尋找 MOMO 參考">
<i class="fas fa-magnifying-glass-chart me-1"></i>補齊比價資料
</button>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadDashboard()" title="重新載入畫面資料">
<i class="fas fa-redo me-1"></i>重新整理
</button>
</div>
</section>
<section class="growth-executive-strip" aria-label="今日任務摘要">
<article class="growth-exec-card is-primary is-clickable" id="growthExecTaskCard" role="button" tabindex="0" onclick="showGrowthDetail('task')" onkeydown="handleGrowthDetailKey(event, 'task')">
<div class="growth-exec-label">
<span>今日任務</span>
<i class="fas fa-location-arrow"></i>
</div>
<div class="growth-exec-value" id="growthExecTask">整理中</div>
<div class="growth-exec-detail"><span id="growthExecTaskDetail">正在讀取 PChome 業績與 MOMO 外部價格。</span><span class="drilldown-hint">看明細</span></div>
</article>
<article class="growth-exec-card is-ready is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<div class="growth-exec-label">
<span>可立即處理</span>
<i class="fas fa-circle-check"></i>
</div>
<div class="growth-exec-value" id="growthExecReady"></div>
<div class="growth-exec-detail">已有可用比價資料<span class="drilldown-hint">看清單</span></div>
</article>
<article class="growth-exec-card is-gap is-clickable" id="growthExecGapCard" role="button" tabindex="0" onclick="showGrowthDetail('needs')" onkeydown="handleGrowthDetailKey(event, 'needs')">
<div class="growth-exec-label">
<span>待補比價</span>
<i class="fas fa-link-slash"></i>
</div>
<div class="growth-exec-value" id="growthExecGap"></div>
<div class="growth-exec-detail">有業績但缺外部參考<span class="drilldown-hint">看商品</span></div>
</article>
<article class="growth-exec-card is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="growth-exec-label">
<span>最新業績日</span>
<i class="fas fa-calendar-day"></i>
</div>
<div class="growth-exec-value" id="growthExecLatestDate"></div>
<div class="growth-exec-detail"><span id="growthExecLatestDetail">等待資料</span><span class="drilldown-hint">看清單</span></div>
</article>
</section>
<section class="growth-action-board" id="growthActionBoard" aria-label="今日策略動作">
<div class="growth-action-board-head">
<h3 class="growth-action-board-title">今日策略動作</h3>
<span class="growth-action-board-note">照順序處理最可能影響業績的商品</span>
</div>
<div class="growth-action-list">
<div class="text-center py-3 text-muted">整理動作中...</div>
</div>
</section>
<!-- ── 今日重點總覽 ── -->
<section class="ops-flow" aria-label="今日重點總覽">
<div class="ops-flow-head">
<span class="ai-panel-title">
<i class="fas fa-chart-simple"></i>今日重點總覽
</span>
<span class="ops-flow-note" id="opsDashboardStatus">資料整理中</span>
</div>
<div class="next-action-banner" aria-live="polite">
<span class="next-action-icon"><i class="fas fa-location-arrow"></i></span>
<span>
<strong class="next-action-title" id="nextActionTitle">正在找出今天最該先做的事</strong>
<span class="next-action-reason" id="nextActionReason">系統正在整理業績、比價與商品對應狀態。</span>
</span>
<button type="button" class="btn btn-sm btn-primary ai-action-btn" id="nextActionButton" onclick="scrollToPanel('growthOpsPanel')">
查看今日清單
</button>
</div>
<div class="ops-flow-grid">
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="ops-dashboard-title">
<span>商品處理進度</span>
<strong class="ops-dashboard-value" id="opsReadyRate">—%</strong>
</div>
<div class="ops-funnel" aria-label="商品處理進度">
<div class="ops-funnel-row">
<div class="ops-funnel-meta"><span>追蹤商品</span><strong id="opsFunnelCandidateText"></strong></div>
<div class="ops-funnel-track"><span class="ops-funnel-bar" id="opsFunnelCandidateBar"></span></div>
</div>
<div class="ops-funnel-row">
<div class="ops-funnel-meta"><span>可立即處理</span><strong id="opsFunnelMappedText"></strong></div>
<div class="ops-funnel-track"><span class="ops-funnel-bar is-ready" id="opsFunnelMappedBar"></span></div>
</div>
<div class="ops-funnel-row">
<div class="ops-funnel-meta"><span>無法比價</span><strong id="opsFunnelNeedsText"></strong></div>
<div class="ops-funnel-track"><span class="ops-funnel-bar is-gap" id="opsFunnelNeedsBar"></span></div>
</div>
</div>
</div>
<div class="ops-dashboard-tile is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('source')" onkeydown="handleGrowthDetailKey(event, 'source')">
<div class="ops-dashboard-title">
<span>外部價格來源</span>
<strong class="ops-dashboard-value" id="opsSourceTotal"></strong>
</div>
<div class="ops-source-bars" id="opsSourceBars" aria-label="外部價格來源">
<div class="ops-source-row">
<div class="ops-source-meta"><span>整理中</span><strong></strong></div>
<div class="ops-source-track"><span class="ops-source-bar"></span></div>
</div>
</div>
</div>
<div class="ops-dashboard-tile">
<div class="ops-dashboard-title">
<span>操作順序</span>
<span>4 步</span>
</div>
<div class="ops-action-grid">
<button type="button" class="ops-flow-item" onclick="scrollToPanel('growthOpsPanel')">
<span class="ops-flow-step">1</span>
<span class="ops-flow-title">今日清單</span>
</button>
<button type="button" class="ops-flow-item" data-action="backfill" onclick="backfillPchomeMatches()">
<span class="ops-flow-step">2</span>
<span class="ops-flow-title">補齊比價</span>
</button>
<button type="button" class="ops-flow-item" onclick="scrollToPanel('externalPricePanel')">
<span class="ops-flow-step">3</span>
<span class="ops-flow-title">檢查價格</span>
</button>
<button type="button" class="ops-flow-item" onclick="scrollToPanel('externalOfferDryRunPanel')">
<span class="ops-flow-step">4</span>
<span class="ops-flow-title">檢查備用資料</span>
</button>
</div>
</div>
</div>
</section>
<!-- ── 點擊摘要後的商品明細 ── -->
<section class="card shadow-sm ai-panel growth-detail-panel" id="growthDrilldownPanel">
<div class="card-body">
<div class="growth-detail-toolbar">
<div>
<h2 class="growth-detail-title" id="growthDrilldownTitle">今日商品明細</h2>
<div class="growth-detail-meta" id="growthDrilldownMeta">整理中</div>
</div>
<div class="growth-detail-tabs" aria-label="明細切換">
<button type="button" class="growth-detail-tab is-active" data-detail-kind="all" onclick="showGrowthDetail('all')">全部</button>
<button type="button" class="growth-detail-tab" data-detail-kind="risk" onclick="showGrowthDetail('risk')">價格壓力</button>
<button type="button" class="growth-detail-tab" data-detail-kind="advantage" onclick="showGrowthDetail('advantage')">價格優勢</button>
<button type="button" class="growth-detail-tab" data-detail-kind="review" onclick="showGrowthDetail('review')">待確認</button>
<button type="button" class="growth-detail-tab" data-detail-kind="needs" onclick="showGrowthDetail('needs')">缺比價</button>
<button type="button" class="growth-detail-tab" data-detail-kind="source" onclick="showGrowthDetail('source')">有外部價</button>
</div>
</div>
<div class="growth-strategy-grid" id="growthStrategyGrid" aria-label="商品策略分流">
<button type="button" class="growth-strategy-card is-risk" onclick="showGrowthDetail('risk')">
<span class="growth-strategy-label">價格壓力</span>
<span class="growth-strategy-value"></span>
<span class="growth-strategy-note">檢查售價、券或組合</span>
<span class="growth-strategy-track"><span class="growth-strategy-bar"></span></span>
</button>
<button type="button" class="growth-strategy-card is-advantage" onclick="showGrowthDetail('advantage')">
<span class="growth-strategy-label">價格優勢</span>
<span class="growth-strategy-value"></span>
<span class="growth-strategy-note">放大曝光與主推位置</span>
<span class="growth-strategy-track"><span class="growth-strategy-bar"></span></span>
</button>
<button type="button" class="growth-strategy-card is-review" onclick="showGrowthDetail('review')">
<span class="growth-strategy-label">待確認</span>
<span class="growth-strategy-value"></span>
<span class="growth-strategy-note">先確認同款再判斷價格</span>
<span class="growth-strategy-track"><span class="growth-strategy-bar"></span></span>
</button>
<button type="button" class="growth-strategy-card is-needs" onclick="showGrowthDetail('needs')">
<span class="growth-strategy-label">缺比價</span>
<span class="growth-strategy-value"></span>
<span class="growth-strategy-note">補抓 MOMO 候選</span>
<span class="growth-strategy-track"><span class="growth-strategy-bar"></span></span>
</button>
</div>
<div class="growth-category-board" id="growthCategoryBoard" aria-label="分類策略看板">
<div class="growth-category-head">
<h3 class="growth-category-title">分類策略看板</h3>
<span class="growth-category-note">依近 7 天業績排序,點分類看商品</span>
</div>
<div class="growth-category-list">
<div class="text-center py-3 text-muted">整理分類中...</div>
</div>
</div>
<div class="growth-playbook-board" id="growthPlaybookBoard" aria-label="銷售策略建議">
<div class="growth-playbook-head">
<h3 class="growth-playbook-title">銷售策略建議</h3>
<span class="growth-playbook-note">把比價結果轉成可執行動作</span>
</div>
<div class="growth-playbook-grid">
<div class="text-center py-3 text-muted">整理策略中...</div>
</div>
</div>
<div class="growth-decision-panel" id="growthDecisionSummary">
<div>
<h3 class="growth-decision-title">正在整理處理建議</h3>
<p class="growth-decision-copy">先整理業績、比價與資料狀態,再決定下一步。</p>
</div>
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" onclick="showGrowthDetail('all')">查看明細</button>
</div>
<div class="growth-detail-controls" aria-label="商品明細工具">
<label class="growth-detail-field">
<span>搜尋商品</span>
<input type="search" class="growth-detail-control" id="growthDetailSearch" placeholder="商品、分類、編號" oninput="setGrowthDetailSearch(this.value)">
</label>
<label class="growth-detail-field">
<span>排序</span>
<select class="growth-detail-control" id="growthDetailSort" onchange="setGrowthDetailSort(this.value)">
<option value="priority">優先級高到低</option>
<option value="sales">近 7 天業績高到低</option>
<option value="gap">價差大到小</option>
<option value="decline">下滑多到少</option>
<option value="quality">可信度高到低</option>
</select>
</label>
<button type="button" class="btn btn-sm btn-outline-secondary table-row-action" onclick="clearGrowthDetailFilters()">清除</button>
</div>
<div class="growth-product-panel is-hidden" id="growthProductDecisionPanel" aria-live="polite"></div>
<div class="growth-detail-result" id="growthDrilldownResult">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>整理商品明細中...
</div>
</div>
</div>
</section>
<!-- ── 今日處理清單 ── -->
<section class="card shadow-sm ai-panel" id="growthOpsPanel">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-compass"></i>今日處理清單
<small class="text-muted fw-normal ms-2">先處理最可能影響業績的商品</small>
</span>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadGrowthOps(true)">
<i class="fas fa-redo me-1"></i>更新清單
</button>
</div>
<div class="card-body">
<div class="growth-ops-grid">
<div>
<div class="growth-metric-row">
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<strong id="growthCandidateCount"></strong>
<span>追蹤商品</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<strong id="growthMappedCount"></strong>
<span>可立即處理</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('needs')" onkeydown="handleGrowthDetailKey(event, 'needs')">
<strong id="growthNeedsMapping"></strong>
<span>無法比價</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('review')" onkeydown="handleGrowthDetailKey(event, 'review')">
<strong id="growthReviewCandidateCount"></strong>
<span>待確認</span>
</div>
</div>
<p class="growth-action-hint" id="growthActionHint">正在判斷今天優先處理順序...</p>
<p class="growth-data-summary" id="growthDataSourceSummary">來源整理中...</p>
<p class="growth-source-note is-compact" id="growthSourceNote">業績來源整理中</p>
<div class="growth-source-list" id="growthSourceReadiness">
<div class="growth-source-chip">
<div class="growth-source-name">
<span>外部資料來源</span>
<span class="growth-source-status">整理中</span>
</div>
<p class="growth-source-detail">正在確認哪些來源可用。</p>
</div>
</div>
</div>
<div>
<div class="growth-list" id="growthOpsList">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>整理今日清單中...
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ── MOMO 待確認候選 ── -->
<section class="card shadow-sm ai-panel" id="growthReviewPanel">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-list-check"></i>MOMO 待確認候選
<small class="text-muted fw-normal ms-2">先確認同款,再進價格判斷</small>
</span>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="loadGrowthReviewCandidates(true)">
<i class="fas fa-redo me-1"></i>更新候選
</button>
</div>
<div class="card-body">
<div class="review-candidate-panel">
<div class="review-candidate-summary">
<div class="growth-metric">
<strong id="reviewCandidateTotal"></strong>
<span>待確認</span>
</div>
<div class="growth-metric">
<strong id="reviewCandidateReadyHint">0</strong>
<span>確認後可進作戰</span>
</div>
<div class="growth-action-hint">
確認同款後才會進入 MOMO 價格參考;不確定色號、容量或組合時請先排除。
</div>
</div>
<div class="review-candidate-result" id="growthReviewCandidateList">
<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>整理待確認候選中...
</div>
</div>
</div>
</div>
</section>
<!-- ── KPI 卡片 ── -->
<div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100 is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('all')" onkeydown="handleGrowthDetailKey(event, 'all')">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-primary" id="kpiSkus"></div>
<div class="small text-muted mt-1"><i class="fas fa-box me-1"></i>監控商品數</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100 is-clickable" role="button" tabindex="0" onclick="showGrowthDetail('ready')" onkeydown="handleGrowthDetailKey(event, 'ready')">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-success" id="kpiCompetitors"></div>
<div class="small text-muted mt-1">
<i class="fas fa-store me-1"></i>可立即處理
<span id="kpiMatchRate" class="text-muted" style="font-size:0.7rem"></span>
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<!-- 高風險卡 — 數值來自全量 CTE非前端截斷的 200 筆 -->
<div class="card border-0 shadow-sm h-100 is-clickable" id="kpiHighRiskCard" role="button" tabindex="0" onclick="showPriceRiskDetail('HIGH')" onkeydown="handlePriceRiskKey(event, 'HIGH')">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-danger" id="kpiHighRisk"></div>
<div class="small text-muted mt-1">
<i class="fas fa-exclamation-triangle me-1"></i>需檢查價格
</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100 is-clickable" role="button" tabindex="0" onclick="scrollToPanel('aiRecsPanel')" onkeydown="handleDrilldownKey(event, 'aiRecsPanel')">
<div class="card-body text-center py-3">
<div class="fs-2 fw-bold text-info" id="kpiAiRecs"></div>
<div class="small text-muted mt-1"><i class="fas fa-clipboard-check me-1"></i>處理紀錄</div>
</div>
</div>
</div>
</div>
<!-- ── 主體分兩欄(競品比價 + AI 決策) ── -->
<div class="row g-3">
<!-- ── 左:外部價格參考 ── -->
<div class="col-xl-7">
<div class="card shadow-sm h-100 ai-panel" id="externalPricePanel">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-balance-scale text-warning me-2"></i>MOMO 外部價格參考
</span>
<div class="d-flex gap-2 align-items-center">
<select class="form-select form-select-sm" id="riskFilter" onchange="filterTable()" style="width:100px">
<option value="all">全部</option>
<option value="HIGH">高風險</option>
<option value="MED">中風險</option>
<option value="LOW">低風險</option>
</select>
<input type="text" class="form-control form-control-sm" id="searchInput"
placeholder="搜尋商品..." oninput="filterTable()" style="width:130px">
</div>
</div>
<div class="price-risk-board" id="priceRiskBoard" aria-label="價格風險分佈">
<div class="price-risk-row" role="button" tabindex="0" onclick="showPriceRiskDetail('HIGH')" onkeydown="handlePriceRiskKey(event, 'HIGH')">
<div class="price-risk-meta"><span>需檢查價格</span><strong id="priceRiskHighText"></strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-high" id="priceRiskHighBar"></span></div>
</div>
<div class="price-risk-row" role="button" tabindex="0" onclick="showPriceRiskDetail('MED')" onkeydown="handlePriceRiskKey(event, 'MED')">
<div class="price-risk-meta"><span>留意價差</span><strong id="priceRiskMediumText"></strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-medium" id="priceRiskMediumBar"></span></div>
</div>
<div class="price-risk-row" role="button" tabindex="0" onclick="showPriceRiskDetail('LOW')" onkeydown="handlePriceRiskKey(event, 'LOW')">
<div class="price-risk-meta"><span>價格有利</span><strong id="priceRiskLowText"></strong></div>
<div class="price-risk-track"><span class="price-risk-bar is-low" id="priceRiskLowBar"></span></div>
</div>
</div>
<!-- 熱力圖圖例 -->
<div class="px-3 pt-2 pb-1 d-flex gap-3 small text-muted ai-legend" style="font-size:0.73rem">
<span><span style="display:inline-block;width:12px;height:12px;background:#fee2e2;border-radius:2px" class="me-1"></span>PChome 貴 &gt;15%</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#fef9c3;border-radius:2px" class="me-1"></span>PChome 貴 5~15%</span>
<span><span style="display:inline-block;width:12px;height:12px;background:#dcfce7;border-radius:2px" class="me-1"></span>PChome 便宜</span>
</div>
<div class="card-body p-0 ai-table-scroll">
<table class="table table-sm table-hover mb-0 align-middle" id="competitorTable">
<thead class="table-light sticky-top" style="font-size:0.78rem;">
<tr>
<th class="ps-3" style="min-width:86px">風險</th>
<th style="min-width:210px">商品</th>
<th class="text-end" style="min-width:82px">PChome</th>
<th class="text-end" style="min-width:82px">MOMO</th>
<th class="text-end" style="min-width:106px">差距</th>
<th class="text-center" style="min-width:86px">可信度</th>
<th class="text-muted" style="min-width:80px">更新</th>
<th class="text-end" style="min-width:96px">下一步</th>
</tr>
</thead>
<tbody id="competitorTbody">
<tr><td colspan="8" class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>載入中...
</td></tr>
</tbody>
</table>
</div>
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
<span id="compCount"></span>
<span id="compSourceSummary">僅顯示已確認同款的商品</span>
</div>
</div>
</div>
<!-- ── 右:最近處理紀錄 ── -->
<div class="col-xl-5">
<div class="card shadow-sm h-100 ai-panel" id="aiRecsPanel">
<div class="card-header py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-clipboard-check text-danger me-2"></i>最近處理紀錄
<small class="text-muted fw-normal ms-2">挑品、比價與人工覆核</small>
</span>
</div>
<div class="card-body p-0 ai-recs-scroll">
<div id="aiRecsList" class="p-2">
<div class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>載入中...
</div>
</div>
</div>
<div class="card-footer bg-white py-2 d-flex justify-content-between small text-muted">
<span id="aiRecsCount"></span>
<span>可手動產生今日清單</span>
</div>
</div>
</div>
</div><!-- /row -->
<!-- ── 備援資料檢查 ── -->
<section class="card shadow-sm ai-panel" id="externalOfferDryRunPanel">
<div class="card-header d-flex justify-content-between align-items-center py-2 bg-white border-bottom">
<span class="ai-panel-title">
<i class="fas fa-file-circle-check"></i>備援資料檢查
<small class="text-muted fw-normal ms-2">自動來源不足時才使用</small>
</span>
<button class="btn btn-outline-secondary btn-sm ai-action-btn" onclick="fillExternalOfferSample()">
<i class="fas fa-table me-1"></i>填入範例
</button>
</div>
<div class="card-body">
<div class="offer-dryrun-grid">
<div class="offer-dryrun-field">
<label for="externalOfferCsvFile">備用資料檔案</label>
<input class="form-control form-control-sm" type="file" id="externalOfferCsvFile" accept=".csv,text/csv">
<label for="externalOfferCsvText">或貼上備援資料</label>
<textarea class="form-control" id="externalOfferCsvText" spellcheck="false"
placeholder="來源,平台商品編號,商品名稱,售價,資料時間,取得方式,PChome商品編號,是否同款,可信度"></textarea>
<button class="btn btn-primary btn-sm ai-action-btn" id="btnExternalOfferDryRun" onclick="previewExternalOfferCsv()">
<i class="fas fa-magnifying-glass me-1"></i>檢查備援資料
</button>
</div>
<div>
<div class="offer-dryrun-summary">
<div class="growth-metric">
<strong id="offerDryRunReady"></strong>
<span>可使用</span>
</div>
<div class="growth-metric">
<strong id="offerDryRunReview"></strong>
<span>待確認</span>
</div>
<div class="growth-metric">
<strong id="offerDryRunBlocked"></strong>
<span>不能使用</span>
</div>
</div>
<div class="offer-dryrun-result" id="offerDryRunResult">
<div class="text-center py-4 text-muted">
<i class="fas fa-circle-info d-block mb-2"></i>
自動來源不足時,先檢查備援資料品質。
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ── Trigger 進度 Toast ── -->
<div class="position-fixed bottom-0 end-0 p-3" style="z-index:9999">
<div id="triggerToast" class="toast align-items-center text-white border-0" role="alert">
<div class="d-flex">
<div class="toast-body" id="triggerToastMsg">
<i class="fas fa-bolt me-1"></i>分析已啟動...
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
</div>
</div><!-- /ai-intel-page -->
<script>
// ── 全域資料 ────────────────────────────────────────
let allCompetitors = [];
let latestGrowthStats = {};
let latestGrowthRows = [];
let activeGrowthDetailKind = 'all';
let growthDetailSearchText = '';
let activeGrowthDetailSort = 'priority';
let activeGrowthProductKey = '';
let activeGrowthCategoryFilter = '';
// ── 頁面載入 ────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
loadGrowthOps();
bindActionDelegation();
});
function bindActionDelegation() {
document.addEventListener('click', (event) => {
const priceButton = event.target.closest('[data-table-action="focus"]');
if (priceButton) {
focusPriceTable(priceButton.dataset.productKey || '');
return;
}
const growthButton = event.target.closest('[data-growth-action]');
if (!growthButton) return;
if (growthButton.dataset.growthAction === 'backfill') {
backfillPchomeMatches();
return;
}
if (growthButton.dataset.growthAction === 'show-product-detail') {
showGrowthProductDetail(growthButton.dataset.productKey || '');
return;
}
if (growthButton.dataset.growthAction === 'show-category-detail') {
showGrowthCategoryDetail(growthButton.dataset.categoryName || '');
return;
}
if (growthButton.dataset.growthAction === 'show-playbook-detail') {
showGrowthPlaybookDetail(growthButton.dataset.playbookKind || '');
return;
}
if (growthButton.dataset.growthAction === 'review-candidate') {
focusReviewCandidate(growthButton.dataset.productKey || '');
return;
}
focusPriceTable(growthButton.dataset.productKey || '');
});
}
function focusPriceTable(keyword) {
const input = document.getElementById('searchInput');
if (input && keyword) {
input.value = keyword;
filterTable();
}
scrollToPanel('externalPricePanel');
}
function focusReviewCandidate(productKey) {
scrollToPanel('growthReviewPanel');
const key = String(productKey || '').trim();
if (!key) return;
setTimeout(() => {
document.querySelectorAll('.review-candidate-row.is-highlight')
.forEach((row) => row.classList.remove('is-highlight'));
const target = Array.from(document.querySelectorAll('.review-candidate-row'))
.find((row) => row.dataset.pchomeId === key);
if (target) {
target.classList.add('is-highlight');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 220);
}
async function loadDashboard() {
try {
const res = await fetch('/api/ai/icaim/dashboard');
const data = await readJsonResponse(res);
if (!data.success) throw new Error(data.error || '載入失敗');
const stats = data.stats || {};
const competitors = Array.isArray(data.competitors) ? data.competitors : [];
const aiRecs = Array.isArray(data.ai_recs) ? data.ai_recs : [];
renderKPIs(stats);
allCompetitors = competitors;
renderCompetitorTable(allCompetitors);
renderAiRecs(aiRecs);
document.getElementById('lastUpdateBadge').innerHTML =
'<i class="fas fa-check-circle me-1"></i>上次更新 ' + new Date().toLocaleTimeString('zh-TW');
document.getElementById('lastUpdateBadge').className = 'ai-status-badge is-success';
} catch (e) {
document.getElementById('lastUpdateBadge').innerHTML =
'<i class="fas fa-exclamation-circle me-1"></i>載入失敗';
document.getElementById('lastUpdateBadge').className = 'ai-status-badge is-error';
console.error(e);
}
}
// ── KPIhigh_risk_count 來自後端全量 CTE─────────
function renderKPIs(stats) {
document.getElementById('kpiSkus').textContent = (stats.total_skus || 0).toLocaleString();
document.getElementById('kpiCompetitors').textContent = (stats.valid_competitor_prices || 0).toLocaleString();
document.getElementById('kpiAiRecs').textContent = (stats.total_ai_recs || 0).toLocaleString();
document.getElementById('kpiMatchRate').textContent = stats.match_rate ? `(${stats.match_rate}%)` : '';
renderCompetitorSourceSummary(stats);
const hr = stats.high_risk_count || 0;
document.getElementById('kpiHighRisk').textContent = hr;
// 高風險卡:數值 > 0 加紅底強調
document.getElementById('kpiHighRiskCard').className =
hr > 0
? 'card border-2 border-danger shadow-sm h-100 is-clickable'
: 'card border-0 shadow-sm h-100 is-clickable';
}
function formatMoney(value) {
const num = Number(value || 0);
return 'NT$ ' + Math.round(num).toLocaleString();
}
function formatPriceAmount(value, options = {}) {
if (value === null || value === undefined || value === '') return '待補';
const num = Number(value);
if (!Number.isFinite(num) || num <= 0) return '待補';
const decimals = options.decimals ?? (num < 100 ? 2 : 0);
return 'NT$ ' + num.toLocaleString('zh-TW', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
function formatGrowthDetailPrice(price, side) {
if (!price) return '待補';
const isUnit = price.price_basis === 'unit_price';
const unit = price.unit_label ? ` / ${price.unit_label}` : '';
if (isUnit) {
const value = side === 'pchome' ? price.pchome_unit_price : price.momo_unit_price;
return `${formatPriceAmount(value, { decimals: 2 })}${unit}`;
}
const value = side === 'pchome' ? price.pchome_price : price.momo_price;
return formatPriceAmount(value);
}
function formatGapDisplay(gap) {
if (gap === null || gap === undefined || !Number.isFinite(Number(gap))) return '待判斷';
const num = Number(gap);
if (num < 0) return `PChome 貴 ${Math.abs(num).toFixed(1)}%`;
if (num > 0) return `PChome 便宜 ${num.toFixed(1)}%`;
return '價格接近';
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[ch]));
}
function safeHttpUrl(value) {
try {
const url = new URL(String(value || ''), window.location.origin);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '';
} catch (_) {
return '';
}
}
function formatDataSourceLabel(value) {
const sourceMap = Object.fromEntries([
['momo' + '_reference', 'MOMO 參考價'],
['external' + '_offers', '外部參考價'],
['legacy' + '_competitor' + '_cache', '既有比價資料'],
['targeted' + '_momo' + '_search', 'MOMO 補抓'],
['targeted' + '_momo' + '_review', 'MOMO 待確認'],
['manual' + '_csv', '手動匯入'],
['official' + '_api', '官方來源'],
['provider' + '_api', '供應商來源'],
]);
const key = String(value || '').trim();
if (!key) return '未填來源';
return sourceMap[key] || key.replace(/[_-]+/g, ' ');
}
function formatReviewCandidateGap(gapPct) {
if (gapPct === null || gapPct === undefined || !Number.isFinite(Number(gapPct))) {
return { label: '待比價', className: 'is-review' };
}
const num = Number(gapPct);
if (num > 5) return { label: `PChome 便宜 ${num.toFixed(1)}%`, className: 'is-win' };
if (num < -5) return { label: `PChome 貴 ${Math.abs(num).toFixed(1)}%`, className: 'is-risk' };
return { label: '價格接近', className: 'is-review' };
}
function renderReasonChips(labels) {
const clean = (Array.isArray(labels) ? labels : [])
.map((label) => String(label || '').trim())
.filter(Boolean)
.slice(0, 3);
const chips = clean.length ? clean : ['確認品名、容量、色號與組合'];
return chips.map((label) => `<span class="review-candidate-pill is-review">${escapeHtml(label)}</span>`).join('');
}
function scrollToPanel(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function handleDrilldownKey(event, panelId) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
scrollToPanel(panelId);
}
function handleGrowthDetailKey(event, kind) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
showGrowthDetail(kind);
}
function handlePriceRiskKey(event, risk) {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
showPriceRiskDetail(risk);
}
function clampPercent(value) {
return Math.max(0, Math.min(100, Number(value || 0)));
}
function setWidth(elementId, pct) {
const element = document.getElementById(elementId);
if (!element) return;
element.style.width = `${clampPercent(pct)}%`;
}
function formatCount(value) {
return Number(value || 0).toLocaleString();
}
function setActionBusy(actionName, busy) {
document.querySelectorAll(`[data-action="${actionName}"]`).forEach((button) => {
button.disabled = busy;
button.classList.toggle('is-busy', busy);
});
}
function showToast(type, message, delay = 5000) {
const toast = document.getElementById('triggerToast');
const msg = document.getElementById('triggerToastMsg');
if (!toast || !msg) return;
const isSuccess = type === 'success';
toast.className = 'toast align-items-center text-white border-0 ' + (isSuccess ? 'bg-success' : 'bg-danger');
msg.textContent = message || (isSuccess ? '已完成' : '操作失敗,請稍後再試。');
if (window.bootstrap && window.bootstrap.Toast) {
new window.bootstrap.Toast(toast, { delay }).show();
}
}
async function readJsonResponse(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return response.json();
}
await response.text();
if (response.status === 401 || response.status === 403) {
throw new Error('登入狀態已過期,請重新登入。');
}
throw new Error(`伺服器回傳非預期內容,狀態碼 ${response.status || '未知'}`);
}
function renderOpsCommandDashboard(stats, scope = {}) {
latestGrowthStats = stats || {};
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const readyRate = candidateCount ? Math.round((mappedCount / candidateCount) * 100) : 0;
document.getElementById('opsReadyRate').textContent = `${readyRate}%`;
document.getElementById('opsFunnelCandidateText').textContent = `${formatCount(candidateCount)}`;
document.getElementById('opsFunnelMappedText').textContent = `${formatCount(mappedCount)}`;
document.getElementById('opsFunnelNeedsText').textContent = `${formatCount(needsMapping)}`;
document.getElementById('opsDashboardStatus').textContent = candidateCount
? `資料就緒率 ${readyRate}%`
: '等待 PChome 業績資料';
renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount);
setWidth('opsFunnelCandidateBar', candidateCount ? 100 : 0);
setWidth('opsFunnelMappedBar', candidateCount ? (mappedCount / candidateCount) * 100 : 0);
setWidth('opsFunnelNeedsBar', candidateCount ? (needsMapping / candidateCount) * 100 : 0);
renderOpsSourceBars(stats.external_data_source_counts || {}, scope);
renderGrowthExecutiveSummary(stats);
renderGrowthCommandCenter(stats, latestGrowthRows);
}
function setText(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = value;
}
function setCommandTask(title, copy, label, action) {
setText('commandTaskTitle', title);
setText('commandTaskCopy', copy);
const button = document.getElementById('commandTaskButton');
if (!button) return;
button.textContent = label || '查看清單';
button.onclick = action || (() => scrollToPanel('growthOpsPanel'));
}
function renderGrowthCommandCenter(stats = {}, rows = []) {
const safeRows = Array.isArray(rows) ? rows : [];
const candidateCount = Number(stats.candidate_count || safeRows.length || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const decliningCount = Number(stats.declining_product_count || 0);
const activeCount = Number(stats.active_product_count || 0);
const sales7d = Number(stats.overall_sales_7d || stats.total_sales_7d || 0);
const prevSales7d = Number(stats.overall_sales_prev_7d || 0);
const salesDeltaPct = Number(stats.overall_sales_delta_pct || 0);
const matchRate = Number(stats.mapping_rate || (candidateCount ? (mappedCount / candidateCount) * 100 : 0));
const latestSalesDate = String(stats.overall_latest_sales_date || stats.latest_sales_date || '').slice(0, 10);
const actionCounts = stats.action_code_counts || {};
const pressureCount = Number(actionCounts.review_price_or_promo || 0);
const advantageCount = Number(actionCounts.amplify_price_advantage || 0);
const pendingCount = Math.max(0, needsMapping + reviewCandidateCount);
const readyShare = candidateCount ? (advantageCount / candidateCount) * 100 : 0;
const pressureShare = candidateCount ? (pressureCount / candidateCount) * 100 : 0;
setText('commandSales7d', sales7d ? formatMoney(sales7d) : '—');
setText('commandSalesDelta', prevSales7d
? `較前 7 天 ${salesDeltaPct >= 0 ? '+' : ''}${salesDeltaPct.toFixed(1)}%`
: '等待前期比較');
setText('commandMatchRate', `${Math.round(matchRate * 10) / 10}%`);
setText('commandMappedRatio', `${formatCount(mappedCount)} / ${formatCount(candidateCount)} 個高業績商品已對應`);
setText('commandDecliningProducts', formatCount(decliningCount));
setText('commandActiveProducts', `${formatCount(activeCount)} 個有銷售商品`);
setText('commandNeedMapping', formatCount(needsMapping));
setText('commandReviewCount', formatCount(reviewCandidateCount));
setText('commandTopCategory', stats.top_category || '—');
setText('commandTopCategorySales', stats.top_category_sales_7d ? formatMoney(stats.top_category_sales_7d) : '等待分類業績');
setText('commandStatusReady', formatCount(mappedCount));
setText('commandStatusReview', formatCount(reviewCandidateCount));
setText('commandStatusNeeds', formatCount(needsMapping));
setText('commandLatestSalesDate', latestSalesDate || '—');
setText('commandPriceAdvantage', `${formatCount(advantageCount)}`);
setText('commandPricePressure', `${formatCount(pressureCount)}`);
setText('commandPricePending', `${formatCount(pendingCount)}`);
setText('commandDonutText', `${Math.round(matchRate)}%`);
const status = document.getElementById('growthCommandStatus');
if (status) {
const text = latestSalesDate ? `上次更新 ${latestSalesDate}` : '等待資料';
status.innerHTML = `<i class="fas fa-circle-check"></i><span>${escapeHtml(text)}</span>`;
}
const rateBar = document.getElementById('commandMatchRateBar');
if (rateBar) rateBar.style.width = `${clampPercent(matchRate)}%`;
const donut = document.getElementById('commandPriceDonut');
if (donut) {
donut.style.setProperty('--ready', clampPercent(readyShare));
donut.style.setProperty('--review', clampPercent(pressureShare));
}
if (!candidateCount) {
setCommandTask(
'今天先做:更新 PChome 業績',
'先確認最新業績已匯入。',
'查看業績',
() => { window.location.href = '/daily_sales'; }
);
} else if (reviewCandidateCount > 0) {
setCommandTask(
`今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`,
'確認同款後才進價格判斷。',
'確認候選',
() => scrollToPanel('growthReviewPanel')
);
} else if (needsMapping > mappedCount) {
setCommandTask(
`今天先做:補齊 ${formatCount(needsMapping)} 件比價`,
'先補 MOMO 對應,再判斷價格。',
'補齊比價',
() => backfillPchomeMatches()
);
} else {
setCommandTask(
'今天先做:檢查價格壓力商品',
`可處理 ${formatCount(mappedCount)} 件,先看 MOMO 更便宜。`,
'檢查價格',
() => showGrowthDetail('risk')
);
}
renderGrowthCommandTopDecliners(safeRows);
}
function renderGrowthCommandTopDecliners(rows) {
const tbody = document.getElementById('commandTopDecliners');
if (!tbody) return;
const sortedRows = [...rows]
.filter((row) => Number(row.sales_7d || 0) > 0)
.sort((a, b) => Number(b.sales_7d || 0) - Number(a.sales_7d || 0))
.slice(0, 5);
if (!sortedRows.length) {
tbody.innerHTML = '<tr><td class="text-muted">目前沒有可顯示的下滑商品。</td></tr>';
return;
}
tbody.innerHTML = sortedRows.map((row, index) => {
const delta = Number(row.sales_delta_pct || 0);
const action = row.recommended_action?.label || '查看商品';
const trendText = delta
? `${delta > 0 ? '+' : ''}${delta.toFixed(1)}%`
: '趨勢待補';
return `<tr>
<td>
<span class="growth-command-meta momo-mono">#${index + 1} · ${escapeHtml(row.category || '未分類')}</span>
<button type="button" class="growth-command-panel-link growth-command-product" data-growth-action="show-product-detail" data-product-key="${escapeHtml(row.pchome_product_id || '')}">
${escapeHtml(row.product_name || row.pchome_product_id || '未命名商品')}
</button>
</td>
<td class="text-end">
<strong class="momo-mono">${escapeHtml(formatMoney(row.sales_7d || 0))}</strong>
<span class="growth-command-meta">${escapeHtml(trendText)}</span>
</td>
<td class="text-end"><span class="growth-command-badge">${escapeHtml(action)}</span></td>
</tr>`;
}).join('');
}
function renderNextAction(candidateCount, mappedCount, needsMapping, reviewCandidateCount = 0) {
const title = document.getElementById('nextActionTitle');
const reason = document.getElementById('nextActionReason');
const button = document.getElementById('nextActionButton');
if (!title || !reason || !button) return;
if (!candidateCount) {
title.textContent = '今天先做:更新 PChome 業績';
reason.textContent = '還不能產生今日清單,請先確認最新 PChome 業績是否已更新。';
button.textContent = '查看業績頁';
delete button.dataset.action;
button.onclick = () => { window.location.href = '/daily_sales'; };
return;
}
if (needsMapping > 0 && mappedCount === 0) {
if (reviewCandidateCount > 0) {
title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`;
reason.textContent = '候選已找到,先確認同款或排除,確認後才會進入價格判斷。';
button.textContent = '確認候選';
delete button.dataset.action;
button.onclick = () => scrollToPanel('growthReviewPanel');
return;
}
title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`;
reason.textContent = '這些商品有業績,但目前還看不到 MOMO 參考價,請先補齊比價資料。';
button.textContent = '補齊比價資料';
button.dataset.action = 'backfill';
button.onclick = () => backfillPchomeMatches();
return;
}
if (needsMapping > mappedCount) {
if (reviewCandidateCount > 0) {
title.textContent = `今天先做:確認 ${formatCount(reviewCandidateCount)} 筆 MOMO 候選`;
reason.textContent = '先把已找到的候選變成可用資料,再補剩下找不到同款的商品。';
button.textContent = '確認候選';
delete button.dataset.action;
button.onclick = () => scrollToPanel('growthReviewPanel');
return;
}
title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`;
reason.textContent = '目前無法比價的商品比可處理商品多,請先補齊商品對應。';
button.textContent = '補齊比價資料';
button.dataset.action = 'backfill';
button.onclick = () => backfillPchomeMatches();
return;
}
title.textContent = '今天先做:檢查價格風險';
reason.textContent = `已有 ${formatCount(mappedCount)} 件商品可處理,先看 PChome 價格偏高的商品,再看可以主推的價格優勢商品。`;
button.textContent = '檢查價格';
delete button.dataset.action;
button.onclick = () => scrollToPanel('externalPricePanel');
}
function renderGrowthExecutiveSummary(stats = {}) {
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
const latestSalesDate = String(stats.latest_sales_date || '').slice(0, 10);
document.getElementById('growthExecReady').textContent = formatCount(mappedCount);
document.getElementById('growthExecGap').textContent = formatCount(needsMapping);
document.getElementById('growthExecLatestDate').textContent = latestSalesDate || '—';
document.getElementById('growthExecLatestDetail').textContent = latestSalesDate ? '已接到 PChome 業績' : '尚未確認業績日期';
const task = document.getElementById('growthExecTask');
const detail = document.getElementById('growthExecTaskDetail');
const gapCard = document.getElementById('growthExecGapCard');
if (!task || !detail) return;
if (!candidateCount) {
task.textContent = '先更新 PChome 業績';
detail.textContent = '目前還沒有足夠資料,請確認最新業績檔是否已匯入。';
gapCard?.classList.remove('is-gap');
return;
}
if (needsMapping > 0 && mappedCount === 0) {
if (reviewCandidateCount > 0) {
task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`;
detail.textContent = '候選商品已找到,確認同款後才能進入價格判斷。';
gapCard?.classList.add('is-gap');
return;
}
task.textContent = `先補 ${formatCount(needsMapping)} 件 MOMO 參考`;
detail.textContent = '高業績商品還不能比價,先補對應資料才會有可行動建議。';
gapCard?.classList.add('is-gap');
return;
}
if (needsMapping > mappedCount) {
if (reviewCandidateCount > 0) {
task.textContent = `先確認 ${formatCount(reviewCandidateCount)} 筆候選`;
detail.textContent = '先處理候選清單,再補剩下找不到同款的商品。';
gapCard?.classList.add('is-gap');
return;
}
task.textContent = `先補比價,再處理 ${formatCount(mappedCount)}`;
detail.textContent = '待補比價比可處理商品多,先擴大 MOMO 對應覆蓋率。';
gapCard?.classList.add('is-gap');
return;
}
task.textContent = '先檢查價格風險';
detail.textContent = `已有 ${formatCount(mappedCount)} 件商品可直接處理,先看 PChome 是否被外部低價壓住。`;
gapCard?.classList.toggle('is-gap', needsMapping > 0);
}
function renderOpsSourceBars(counts, scope = {}) {
const box = document.getElementById('opsSourceBars');
const totalBox = document.getElementById('opsSourceTotal');
if (!box || !totalBox) return;
const entries = Object.entries(counts || {})
.map(([label, count]) => [label, Number(count || 0)])
.filter(([, count]) => count > 0);
const total = entries.reduce((sum, [, count]) => sum + count, 0);
totalBox.textContent = formatCount(total);
if (!entries.length) {
const active = (scope.active_external_sources || []).join('、') || '尚未接入';
box.innerHTML = `<div class="ops-source-row">
<div class="ops-source-meta"><span>${escapeHtml(active)}</span><strong>0 筆</strong></div>
<div class="ops-source-track"><span class="ops-source-bar"></span></div>
</div>`;
return;
}
box.innerHTML = entries.slice(0, 4).map(([label, count], index) => {
const pct = total ? (count / total) * 100 : 0;
const cls = index % 2 ? ' is-secondary' : '';
return `<div class="ops-source-row">
<div class="ops-source-meta"><span>${escapeHtml(label)}</span><strong>${formatCount(count)} 筆</strong></div>
<div class="ops-source-track"><span class="ops-source-bar${cls}" style="width:${clampPercent(pct)}%"></span></div>
</div>`;
}).join('');
}
async function loadGrowthOps(forceRefresh = false) {
const list = document.getElementById('growthOpsList');
if (forceRefresh) {
list.innerHTML = `<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>更新今日清單中...
</div>`;
}
try {
const url = '/api/ai/pchome-growth/opportunities?limit=50' + (forceRefresh ? '&refresh=1' : '');
const res = await fetch(url);
const data = await readJsonResponse(res);
if (!data.success) throw new Error(data.error || '讀取失敗');
const stats = data.stats || {};
const scope = data.source_scope || {};
document.getElementById('growthCandidateCount').textContent = (stats.candidate_count || 0).toLocaleString();
document.getElementById('growthMappedCount').textContent = (stats.mapped_count || 0).toLocaleString();
document.getElementById('growthNeedsMapping').textContent = (stats.needs_mapping_count || 0).toLocaleString();
document.getElementById('growthReviewCandidateCount').textContent = (stats.review_candidate_count || 0).toLocaleString();
renderOpsCommandDashboard(stats, scope);
renderGrowthActionHint(stats);
renderGrowthDataSourceSummary(stats);
const active = (scope.active_external_sources || []).join('、') || '尚未接入';
const paused = (scope.paused_external_sources || []).join('、') || '無';
document.getElementById('growthSourceNote').textContent =
`業績:${scope.primary_sales_source || 'PChome 後台業績'} · 外部:${active} · 暫停:${paused}`;
renderGrowthSourceReadiness((scope.source_readiness || {}).sources || []);
latestGrowthRows = data.opportunities || [];
renderGrowthCommandCenter(stats, latestGrowthRows);
renderGrowthOps(latestGrowthRows);
renderGrowthDetail(activeGrowthDetailKind);
loadGrowthReviewCandidates();
} catch (error) {
console.error(error);
latestGrowthRows = [];
renderGrowthCommandCenter({}, []);
renderOpsCommandDashboard({}, {});
renderGrowthActionHint({ candidate_count: 0, mapped_count: 0, needs_mapping_count: 0 });
renderGrowthDataSourceSummary({});
renderGrowthDetail('all');
renderGrowthReviewCandidates([]);
list.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
今日處理清單暫時讀不到,請重新整理;若仍失敗,請檢查業績資料。
</div>`;
}
}
function renderCompetitorSourceSummary(stats) {
const target = document.getElementById('compSourceSummary');
if (!target) return;
const counts = stats.competitor_data_source_counts || {};
const entries = Object.entries(counts)
.filter(([, count]) => Number(count || 0) > 0)
.map(([label, count]) => `${formatDataSourceLabel(label)} ${Number(count).toLocaleString()}`);
target.textContent = entries.length
? `資料來源:${entries.join('、')}`
: '僅顯示已確認同款的商品';
}
function renderGrowthActionHint(stats) {
const hint = document.getElementById('growthActionHint');
if (!hint) return;
const candidateCount = Number(stats.candidate_count || 0);
const mappedCount = Number(stats.mapped_count || 0);
const needsMapping = Number(stats.needs_mapping_count || 0);
const reviewCandidateCount = Number(stats.review_candidate_count || 0);
if (!candidateCount) {
hint.textContent = '還不能產生今日清單,原因是缺少最新 PChome 業績。';
return;
}
if (needsMapping > 0 && mappedCount === 0) {
hint.textContent = `今天先補齊比價資料:目前 ${needsMapping.toLocaleString()} 件高業績商品還不能直接比價。`;
return;
}
if (needsMapping > 0 && reviewCandidateCount > 0) {
hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品;另有 ${reviewCandidateCount.toLocaleString()} 筆候選等人工確認。`;
return;
}
if (needsMapping > 0) {
hint.textContent = `先處理 ${mappedCount.toLocaleString()} 件可立即處理商品,再補 ${needsMapping.toLocaleString()} 件比價資料。`;
return;
}
hint.textContent = '目前商品都可比價,先檢查售價偏高或可以放大價格優勢的項目。';
}
function renderGrowthDataSourceSummary(stats) {
const summary = document.getElementById('growthDataSourceSummary');
if (!summary) return;
const counts = stats.external_data_source_counts || {};
const entries = Object.entries(counts)
.filter(([, count]) => Number(count || 0) > 0)
.map(([label, count]) => `${formatDataSourceLabel(label)} ${Number(count).toLocaleString()}`);
if (!entries.length) {
summary.textContent = '來源:尚未接到外部參考價';
return;
}
summary.textContent = `來源:${entries.join('、')}`;
}
function renderGrowthSourceReadiness(sources) {
const box = document.getElementById('growthSourceReadiness');
if (!box) return;
if (!sources.length) {
box.innerHTML = `<div class="growth-source-chip">
<div class="growth-source-name">
<span>外部資料來源</span>
<span class="growth-source-status">未確認</span>
</div>
<p class="growth-source-detail">目前還沒有可顯示的來源狀態。</p>
</div>`;
return;
}
box.innerHTML = sources.slice(0, 3).map((source) => {
const usable = Number(source.usable_offer_count || 0);
const detail = usable > 0
? `${source.data_quality_label || '資料可用'} · ${usable.toLocaleString()}`
: `${source.plain_state || source.plain_note || '等待資料接入'}`;
const activeClass = source.status_code === 'active' ? ' is-active' : '';
return `<div class="growth-source-chip${activeClass}">
<div class="growth-source-name">
<span>${escapeHtml(source.display_name || '未命名來源')}</span>
<span class="growth-source-status">${escapeHtml(source.status_label || '待確認')}</span>
</div>
<p class="growth-source-detail">${escapeHtml(detail)}</p>
</div>`;
}).join('');
}
function resolveGrowthDetailKind(kind) {
if (kind !== 'task') return kind || 'all';
const reviewCandidateCount = Number(latestGrowthStats.review_candidate_count || 0);
const needsMapping = Number(latestGrowthStats.needs_mapping_count || 0);
const mappedCount = Number(latestGrowthStats.mapped_count || 0);
if (reviewCandidateCount > 0) return 'review';
if (needsMapping > mappedCount) return 'needs';
return 'risk';
}
function growthDetailConfig(kind) {
const topCategory = latestGrowthStats.top_category || '';
const selectedCategory = activeGrowthCategoryFilter || topCategory;
const configs = {
all: ['今日商品明細', '依優先級排序,先看高業績、下滑或價格有壓力的商品。'],
ready: ['可立即處理商品', '已有 MOMO 參考價,可以直接檢查售價、活動或曝光。'],
needs: ['缺比價商品', '有 PChome 業績,但還沒有可用 MOMO 參考。'],
review: ['MOMO 候選待確認', '候選已找到,確認同款後才會進入價格判斷。'],
risk: ['MOMO 更便宜商品', 'MOMO 參考價較低,優先檢查 PChome 售價、券或組合。'],
advantage: ['PChome 價格優勢商品', 'PChome 目前較有價格優勢,適合檢查曝光與主推位置。'],
bundle: ['組合 / 單位價商品', '需要檢查組合包、入數、容量或單位價,避免只看總價誤判。'],
source: ['外部價格來源明細', '只列出已接到 MOMO 外部參考價的商品。'],
decline: ['業績下滑商品', '近 7 天比前 7 天下滑的商品。'],
category: [selectedCategory ? `${selectedCategory} 商品明細` : '分類商品明細', '這個分類內的商品與價格狀態。'],
};
return configs[kind] || configs.all;
}
function growthDetailRows(kind) {
const rows = Array.isArray(latestGrowthRows) ? [...latestGrowthRows] : [];
const selectedCategory = activeGrowthCategoryFilter || latestGrowthStats.top_category || '';
let filtered = rows.filter((row) => {
const actionCode = row.recommended_action?.code || '';
const price = row.external_price || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
if (kind === 'ready') return Boolean(price);
if (kind === 'needs') return !price && !row.review_candidate;
if (kind === 'review') return Boolean(row.review_candidate) || actionCode === 'review_external_candidate';
if (kind === 'risk') return Boolean(price) && (actionCode === 'review_price_or_promo' || (gap !== null && gap < -5));
if (kind === 'advantage') return Boolean(price) && (actionCode === 'amplify_price_advantage' || (gap !== null && gap > 5));
if (kind === 'bundle') return isBundleOrUnitRow(row);
if (kind === 'source') return Boolean(price);
if (kind === 'decline') return Number(row.sales_delta_pct || 0) < 0;
if (kind === 'category') return selectedCategory && row.category === selectedCategory;
return true;
});
const search = String(growthDetailSearchText || '').trim().toLowerCase();
if (search) {
filtered = filtered.filter((row) => {
const price = row.external_price || {};
const candidate = row.review_candidate || {};
const haystack = [
row.product_name,
row.category,
row.vendor,
row.pchome_product_id,
row.recommended_action?.label,
price.momo_name,
price.momo_sku,
candidate.momo_name,
candidate.momo_sku,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(search);
});
}
const gapValue = (row) => {
const gap = row.external_price?.gap_pct;
return gap === null || gap === undefined || !Number.isFinite(Number(gap)) ? null : Number(gap);
};
const declineValue = (row) => {
const value = row.sales_delta_pct;
return value === null || value === undefined || !Number.isFinite(Number(value)) ? null : Number(value);
};
const nullLast = (a, b, getter, direction = 'desc') => {
const av = getter(a);
const bv = getter(b);
if (av === null && bv === null) return Number(b.priority_score || 0) - Number(a.priority_score || 0);
if (av === null) return 1;
if (bv === null) return -1;
return direction === 'asc' ? av - bv : bv - av;
};
return filtered.sort((a, b) => {
if (activeGrowthDetailSort === 'sales') return Number(b.sales_7d || 0) - Number(a.sales_7d || 0);
if (activeGrowthDetailSort === 'gap') return nullLast(a, b, (row) => {
const gap = gapValue(row);
return gap === null ? null : Math.abs(gap);
});
if (activeGrowthDetailSort === 'decline') return nullLast(a, b, declineValue, 'asc');
if (activeGrowthDetailSort === 'quality') return rowQualityScore(b) - rowQualityScore(a);
return Number(b.priority_score || 0) - Number(a.priority_score || 0);
});
}
function showGrowthDetail(kind, shouldScroll = true) {
activeGrowthDetailKind = resolveGrowthDetailKind(kind);
if (activeGrowthDetailKind !== 'category') {
activeGrowthCategoryFilter = '';
}
renderGrowthDetail(activeGrowthDetailKind);
if (shouldScroll) scrollToPanel('growthDrilldownPanel');
}
function setGrowthDetailSearch(value) {
growthDetailSearchText = String(value || '').trim().toLowerCase();
renderGrowthDetail(activeGrowthDetailKind);
}
function setGrowthDetailSort(value) {
const allowed = new Set(['priority', 'sales', 'gap', 'decline', 'quality']);
activeGrowthDetailSort = allowed.has(value) ? value : 'priority';
renderGrowthDetail(activeGrowthDetailKind);
}
function clearGrowthDetailFilters() {
growthDetailSearchText = '';
activeGrowthDetailSort = 'priority';
const search = document.getElementById('growthDetailSearch');
const sort = document.getElementById('growthDetailSort');
if (search) search.value = '';
if (sort) sort.value = 'priority';
renderGrowthDetail(activeGrowthDetailKind);
}
function growthRowKey(row) {
return String(row?.pchome_product_id || row?.product_name || '').trim();
}
function findGrowthRowByKey(productKey) {
const key = String(productKey || '').trim();
if (!key) return null;
return (Array.isArray(latestGrowthRows) ? latestGrowthRows : [])
.find((row) => growthRowKey(row) === key) || null;
}
function growthRowNextAction(row) {
const code = row?.recommended_action?.code || '';
if (code === 'review_external_candidate') {
return { action: 'review-candidate', label: '確認候選' };
}
if (code === 'map_external_product') {
return { action: 'backfill', label: '補齊比價' };
}
return { action: 'focus-price', label: '檢查價格' };
}
function hideGrowthProductDecision() {
activeGrowthProductKey = '';
const panel = document.getElementById('growthProductDecisionPanel');
if (!panel) return;
panel.classList.add('is-hidden');
panel.innerHTML = '';
}
function productEvidenceTile(label, value, note, className = '') {
return `<span class="growth-price-chip${className}">
<span class="growth-price-label">${escapeHtml(label)}</span>
<span class="growth-price-value">${escapeHtml(value)}</span>
<span class="growth-price-note">${escapeHtml(note)}</span>
</span>`;
}
function showGrowthProductDetail(productKey) {
const row = findGrowthRowByKey(productKey);
const panel = document.getElementById('growthProductDecisionPanel');
if (!row || !panel) return;
activeGrowthProductKey = growthRowKey(row);
const price = row.external_price || null;
const reviewCandidate = row.review_candidate || null;
const action = row.recommended_action || {};
const next = growthRowNextAction(row);
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
const delta = row.sales_delta_pct === null || row.sales_delta_pct === undefined
? '前期不足'
: `${Number(row.sales_delta_pct).toFixed(1)}%`;
const qualityScore = Math.round(rowQualityScore(row));
const qualityLabel = row.data_quality?.label || (price ? '資料可用' : reviewCandidate ? '候選待確認' : '資料待補');
const pchomeDisplay = price
? formatGrowthDetailPrice(price, 'pchome')
: reviewCandidate
? formatPriceAmount(reviewCandidate.pchome_price)
: '待補';
const momoDisplay = price
? formatGrowthDetailPrice(price, 'momo')
: reviewCandidate
? formatPriceAmount(reviewCandidate.momo_price)
: '待補';
const gapText = reviewCandidate
? '候選待確認'
: gap === null
? '缺 MOMO 參考'
: formatGapDisplay(gap);
const gapClass = gap !== null && gap < 0
? ' is-risk'
: gap !== null && gap > 0
? ' is-good'
: '';
const reasons = Array.isArray(row.reason_lines) && row.reason_lines.length
? row.reason_lines.slice(0, 5)
: [
action.label || '依業績、比價與資料可信度排序',
price ? '已有 MOMO 參考價,可直接檢查售價與活動' : '尚缺完整外部參考,先補齊同款資料',
];
const productKeySafe = escapeHtml(activeGrowthProductKey);
const nextButtonAttrs = next.action === 'backfill'
? 'data-growth-action="backfill"'
: `data-growth-action="${next.action}" data-product-key="${productKeySafe}"`;
panel.innerHTML = `<div class="growth-product-head">
<div>
<div class="growth-product-kicker">單品作戰詳情</div>
<h3 class="growth-product-title">${escapeHtml(row.product_name || '未命名商品')}</h3>
<p class="growth-product-meta">
${escapeHtml(row.category || '未分類')} · 近 7 天 ${escapeHtml(formatMoney(row.sales_7d))} · 業績變化 ${escapeHtml(delta)} · ${escapeHtml(action.label || '待判斷')}
</p>
</div>
<button type="button" class="growth-product-close" onclick="hideGrowthProductDecision()">關閉</button>
</div>
<div class="growth-product-body">
<div class="growth-product-evidence-grid">
${productEvidenceTile('PChome', pchomeDisplay, price?.price_basis_label || (reviewCandidate ? '候選價' : '待補資料'))}
${productEvidenceTile('MOMO', momoDisplay, price?.data_source_label || (reviewCandidate ? '候選待確認' : '等待補抓'))}
${productEvidenceTile('差距', gapText, action.label || '待判斷', gapClass)}
${productEvidenceTile('可信度', qualityScore ? `${qualityScore}%` : '待補', qualityLabel)}
</div>
<ul class="growth-product-reason-list">
${reasons.map((reason) => `<li>${escapeHtml(reason)}</li>`).join('')}
</ul>
</div>
<div class="growth-product-actions">
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" ${nextButtonAttrs}>${escapeHtml(next.label)}</button>
<button type="button" class="btn btn-sm btn-outline-secondary table-row-action" data-growth-action="focus-price" data-product-key="${productKeySafe}">查看比價表</button>
</div>`;
panel.classList.remove('is-hidden');
document.querySelectorAll('.growth-detail-row.is-active')
.forEach((item) => item.classList.remove('is-active'));
Array.from(document.querySelectorAll('.growth-detail-row'))
.find((rowElement) => Array.from(rowElement.querySelectorAll('[data-growth-action="show-product-detail"]'))
.some((item) => item.dataset.productKey === activeGrowthProductKey))
?.classList.add('is-active');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function classifyGrowthRow(row) {
const actionCode = row?.recommended_action?.code || '';
const price = row?.external_price || null;
const candidate = row?.review_candidate || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
return {
hasPrice: Boolean(price),
needs: !price && !candidate,
review: Boolean(candidate) || actionCode === 'review_external_candidate',
risk: Boolean(price) && (actionCode === 'review_price_or_promo' || (gap !== null && gap < -5)),
advantage: Boolean(price) && (actionCode === 'amplify_price_advantage' || (gap !== null && gap > 5)),
};
}
function summarizeGrowthCategories() {
const groups = new Map();
(Array.isArray(latestGrowthRows) ? latestGrowthRows : []).forEach((row) => {
const category = String(row.category || '未分類').trim() || '未分類';
if (!groups.has(category)) {
groups.set(category, {
category,
count: 0,
sales: 0,
risk: 0,
advantage: 0,
needs: 0,
review: 0,
});
}
const item = groups.get(category);
const flags = classifyGrowthRow(row);
item.count += 1;
item.sales += Number(row.sales_7d || 0);
if (flags.risk) item.risk += 1;
if (flags.advantage) item.advantage += 1;
if (flags.needs) item.needs += 1;
if (flags.review) item.review += 1;
});
return Array.from(groups.values())
.sort((a, b) => b.sales - a.sales || b.count - a.count)
.slice(0, 6);
}
function categoryActionText(item) {
if (item.risk > 0) return `先檢查 ${formatCount(item.risk)} 件價格壓力`;
if (item.advantage > 0) return `放大 ${formatCount(item.advantage)} 件價格優勢`;
if (item.review > 0) return `先確認 ${formatCount(item.review)} 筆候選`;
if (item.needs > 0) return `補齊 ${formatCount(item.needs)} 件比價`;
return '維持追蹤';
}
function renderGrowthCategoryBoard() {
const board = document.getElementById('growthCategoryBoard');
if (!board) return;
const list = board.querySelector('.growth-category-list');
if (!list) return;
const categories = summarizeGrowthCategories();
if (!categories.length) {
list.innerHTML = `<div class="text-center py-3 text-muted">還沒有可整理的分類資料。</div>`;
return;
}
const maxSales = Math.max(1, ...categories.map((item) => item.sales));
list.innerHTML = categories.map((item) => {
const pct = clampPercent((item.sales / maxSales) * 100);
const activeClass = activeGrowthDetailKind === 'category' && item.category === activeGrowthCategoryFilter
? ' is-active'
: '';
return `<button type="button" class="growth-category-row${activeClass}" data-growth-action="show-category-detail" data-category-name="${escapeHtml(item.category)}">
<span>
<span class="growth-category-name">${escapeHtml(item.category)}</span>
<span class="growth-category-meta">${formatCount(item.count)} 件 · 近 7 天 ${escapeHtml(formatMoney(item.sales))}</span>
</span>
<span class="growth-category-bars">
<span class="growth-category-track"><span class="growth-category-bar" style="width:${pct}%"></span></span>
<span class="growth-category-stats">
<span>壓力 <strong>${formatCount(item.risk)}</strong></span>
<span>優勢 <strong>${formatCount(item.advantage)}</strong></span>
<span>缺比價 <strong>${formatCount(item.needs)}</strong></span>
<span>待確認 <strong>${formatCount(item.review)}</strong></span>
</span>
</span>
<span class="growth-category-cta">${escapeHtml(categoryActionText(item))}</span>
</button>`;
}).join('');
}
function showGrowthCategoryDetail(category) {
activeGrowthCategoryFilter = String(category || '').trim();
if (!activeGrowthCategoryFilter) return;
showGrowthDetail('category');
}
function isBundleOrUnitRow(row) {
const price = row?.external_price || null;
const name = String(row?.product_name || '');
return Boolean(price) && (
price.price_basis === 'unit_price'
|| /組|入|套|盒|包|ml|g|kg|公升|毫升/.test(name)
);
}
function growthPlaybookRows(kind) {
const rows = Array.isArray(latestGrowthRows) ? latestGrowthRows : [];
if (kind === 'defense') return rows.filter((row) => classifyGrowthRow(row).risk);
if (kind === 'boost') return rows.filter((row) => classifyGrowthRow(row).advantage);
if (kind === 'bundle') return rows.filter(isBundleOrUnitRow);
if (kind === 'data') return rows.filter((row) => {
const flags = classifyGrowthRow(row);
return flags.needs || flags.review;
});
return [];
}
function playbookTargetKind(kind) {
if (kind === 'defense') return 'risk';
if (kind === 'boost') return 'advantage';
if (kind === 'bundle') return 'bundle';
if (kind === 'data') return 'needs';
return 'all';
}
function renderGrowthPlaybookBoard() {
const board = document.getElementById('growthPlaybookBoard');
if (!board) return;
const grid = board.querySelector('.growth-playbook-grid');
if (!grid) return;
const playbooks = [
{
kind: 'defense',
className: 'is-defense',
label: '價格防守',
action: '檢查售價 / 券 / 活動',
},
{
kind: 'boost',
className: 'is-boost',
label: '主推曝光',
action: '加強主推位置與文案',
},
{
kind: 'bundle',
className: 'is-bundle',
label: '組合 / 單位價',
action: '檢查組合包與單位價',
},
{
kind: 'data',
className: 'is-data',
label: '資料補齊',
action: '補候選或確認同款',
},
];
const summaries = playbooks.map((item) => {
const rows = growthPlaybookRows(item.kind).sort((a, b) => Number(b.sales_7d || 0) - Number(a.sales_7d || 0));
const sales = rows.reduce((sum, row) => sum + Number(row.sales_7d || 0), 0);
return { ...item, rows, sales, topProduct: rows[0]?.product_name || '目前沒有商品' };
});
const maxSales = Math.max(1, ...summaries.map((item) => item.sales));
grid.innerHTML = summaries.map((item) => {
const pct = clampPercent((item.sales / maxSales) * 100);
const targetKind = playbookTargetKind(item.kind);
const activeClass = activeGrowthDetailKind === targetKind ? ' is-active' : '';
return `<button type="button" class="growth-playbook-card ${item.className}${activeClass}" data-growth-action="show-playbook-detail" data-playbook-kind="${escapeHtml(item.kind)}">
<span class="growth-playbook-label">${escapeHtml(item.label)}</span>
<span class="growth-playbook-value">${formatCount(item.rows.length)}</span>
<span class="growth-playbook-sales">近 7 天 ${escapeHtml(formatMoney(item.sales))}</span>
<span class="growth-playbook-track"><span class="growth-playbook-bar" style="width:${pct}%"></span></span>
<span class="growth-playbook-product">${escapeHtml(item.topProduct)}</span>
<span class="growth-playbook-action">${escapeHtml(item.action)}</span>
</button>`;
}).join('');
}
function showGrowthPlaybookDetail(kind) {
const target = playbookTargetKind(kind);
showGrowthDetail(target);
}
function growthActionPlanForRow(row) {
const flags = classifyGrowthRow(row);
const next = growthRowNextAction(row);
const price = row?.external_price || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
if (flags.risk) {
return {
label: '價格防守',
title: '檢查售價、券或活動',
reason: gap === null ? 'MOMO 參考價較低,先檢查價格壓力。' : formatGapDisplay(gap),
next,
};
}
if (flags.advantage) {
return {
label: '主推曝光',
title: '放大價格優勢',
reason: gap === null ? 'PChome 有外部價格優勢,適合提高曝光。' : formatGapDisplay(gap),
next,
};
}
if (isBundleOrUnitRow(row)) {
return {
label: '組合 / 單位價',
title: '檢查組合包與單位價',
reason: '先確認容量、入數與單位價,再決定組合或券。',
next,
};
}
if (flags.review) {
return {
label: '候選確認',
title: '確認 MOMO 候選是否同款',
reason: '同款確認後才會進入價格判斷。',
next,
};
}
if (flags.needs) {
return {
label: '補資料',
title: '補齊 MOMO 參考商品',
reason: '目前缺外部參考價,先補資料才有可行動建議。',
next,
};
}
if (Number(row?.sales_delta_pct || 0) < 0) {
return {
label: '找回動能',
title: '檢查下滑商品',
reason: `近 7 天業績變化 ${Number(row.sales_delta_pct || 0).toFixed(1)}%。`,
next,
};
}
return {
label: '追蹤',
title: '維持追蹤',
reason: '目前沒有明確價格或資料風險。',
next,
};
}
function growthActionEvidence(row) {
const price = row?.external_price || null;
const candidate = row?.review_candidate || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
const quality = Math.round(rowQualityScore(row));
return [
{
label: 'PChome',
value: price ? formatGrowthDetailPrice(price, 'pchome') : candidate ? formatPriceAmount(candidate.pchome_price) : '待補',
},
{
label: 'MOMO',
value: price ? formatGrowthDetailPrice(price, 'momo') : candidate ? formatPriceAmount(candidate.momo_price) : '待補',
},
{
label: '差距',
value: candidate ? '候選待確認' : gap === null ? '待判斷' : formatGapDisplay(gap),
},
{
label: '可信度',
value: quality ? `${quality}%` : '待補',
},
];
}
function renderGrowthActionEvidence(row) {
return `<div class="growth-action-evidence">
${growthActionEvidence(row).map((item) => `<span class="growth-action-evidence-chip">
<span class="growth-action-evidence-label">${escapeHtml(item.label)}</span>
<span class="growth-action-evidence-value">${escapeHtml(item.value)}</span>
</span>`).join('')}
</div>`;
}
function renderGrowthActionBoard() {
const board = document.getElementById('growthActionBoard');
if (!board) return;
const list = board.querySelector('.growth-action-list');
if (!list) return;
const rows = (Array.isArray(latestGrowthRows) ? latestGrowthRows : [])
.map((row) => ({ row, plan: growthActionPlanForRow(row) }))
.filter((item) => item.plan.label !== '追蹤')
.sort((a, b) => Number(b.row.priority_score || 0) - Number(a.row.priority_score || 0))
.slice(0, 5);
if (!rows.length) {
list.innerHTML = `<div class="text-center py-3 text-muted">目前沒有需要立即處理的商品。</div>`;
return;
}
list.innerHTML = rows.map((item, index) => {
const row = item.row;
const plan = item.plan;
const key = escapeHtml(growthRowKey(row));
const nextAttrs = plan.next.action === 'backfill'
? 'data-growth-action="backfill"'
: `data-growth-action="${plan.next.action}" data-product-key="${key}"`;
return `<article class="growth-action-row">
<span class="growth-action-rank">${String(index + 1).padStart(2, '0')}</span>
<div>
<p class="growth-action-title">${escapeHtml(plan.title)}</p>
<p class="growth-action-meta">${escapeHtml(row.product_name || '未命名商品')}</p>
<p class="growth-action-reason">${escapeHtml(plan.reason)}</p>
</div>
<div>
<span class="growth-action-pill">${escapeHtml(plan.label)}</span>
<p class="growth-action-meta">近 7 天 ${escapeHtml(formatMoney(row.sales_7d))}</p>
${renderGrowthActionEvidence(row)}
</div>
<div class="growth-action-buttons">
<button type="button" class="btn btn-sm btn-outline-secondary table-row-action" data-growth-action="show-product-detail" data-product-key="${key}">詳情</button>
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" ${nextAttrs}>${escapeHtml(plan.next.label)}</button>
</div>
</article>`;
}).join('');
}
function renderGrowthStrategySummary() {
const box = document.getElementById('growthStrategyGrid');
if (!box) return;
const total = Math.max(1, (Array.isArray(latestGrowthRows) ? latestGrowthRows.length : 0));
const strategies = [
{
kind: 'risk',
className: 'is-risk',
label: '價格壓力',
note: '檢查售價、券或組合',
},
{
kind: 'advantage',
className: 'is-advantage',
label: '價格優勢',
note: '放大曝光與主推位置',
},
{
kind: 'review',
className: 'is-review',
label: '待確認',
note: '先確認同款再判斷價格',
},
{
kind: 'needs',
className: 'is-needs',
label: '缺比價',
note: '補抓 MOMO 候選',
},
];
box.innerHTML = strategies.map((strategy) => {
const rows = growthDetailRows(strategy.kind);
const sales = rows.reduce((sum, row) => sum + Number(row.sales_7d || 0), 0);
const pct = clampPercent((rows.length / total) * 100);
const activeClass = activeGrowthDetailKind === strategy.kind ? ' is-active' : '';
return `<button type="button" class="growth-strategy-card ${strategy.className}${activeClass}" onclick="showGrowthDetail('${strategy.kind}')">
<span class="growth-strategy-label">${escapeHtml(strategy.label)}</span>
<span class="growth-strategy-value">${formatCount(rows.length)}</span>
<span class="growth-strategy-note">${escapeHtml(strategy.note)} · ${escapeHtml(formatMoney(sales))}</span>
<span class="growth-strategy-track"><span class="growth-strategy-bar" style="width:${pct}%"></span></span>
</button>`;
}).join('');
}
function growthDecisionConfig(kind) {
const configs = {
all: {
title: '先處理最有機會影響業績的商品',
copy: '先看價格壓力與價格優勢商品,再補待確認與缺比價資料。',
actionLabel: '看第一筆',
},
ready: {
title: '這批商品可以直接進入銷售判斷',
copy: '已有 MOMO 參考價,先處理價差大且近 7 天業績有波動的商品。',
actionLabel: '檢查價格',
},
risk: {
title: '先檢查 PChome 價格壓力',
copy: 'MOMO 參考價較低,優先檢查售價、折扣券、組合包與頁面曝光。',
actionLabel: '看價格',
},
advantage: {
title: '放大 PChome 價格優勢',
copy: 'PChome 有價格優勢,適合排主推、加強文案與提高曝光位置。',
actionLabel: '看主推清單',
},
bundle: {
title: '檢查組合包與單位價',
copy: '先確認容量、入數與單位價,再決定要做組合、加券或調整主圖文案。',
actionLabel: '看組合商品',
},
review: {
title: '先確認候選是否同款',
copy: '確認同款後才會進入價格判斷;色號、容量或組合不一致就排除。',
actionLabel: '確認候選',
action: 'review-candidate',
},
needs: {
title: '先補齊 MOMO 對應商品',
copy: '這批商品有 PChome 業績但缺外部參考價,先補抓候選再判斷價格。',
actionLabel: '補齊比價',
action: 'backfill',
},
source: {
title: '檢查已接到外部價的商品',
copy: '這些商品已有 MOMO 參考價,可直接比較價格與銷售變化。',
actionLabel: '檢查價格',
},
decline: {
title: '先找回下滑商品的銷售動能',
copy: '近 7 天轉弱的商品,優先檢查價格、曝光、庫存與商品頁內容。',
actionLabel: '看下滑商品',
},
};
return configs[kind] || configs.all;
}
function rowQualityScore(row) {
if (row?.data_quality?.score !== null && row?.data_quality?.score !== undefined) {
return Number(row.data_quality.score || 0);
}
if (row?.review_candidate?.quality_score !== null && row?.review_candidate?.quality_score !== undefined) {
return Number(row.review_candidate.quality_score || 0);
}
if (row?.external_price?.match_score) return Number(row.external_price.match_score || 0) * 100;
return 0;
}
function renderGrowthDecisionSummary(rows, kind) {
const box = document.getElementById('growthDecisionSummary');
if (!box) return;
rows = Array.isArray(rows) ? rows : [];
const config = growthDecisionConfig(kind);
if (!rows.length) {
box.innerHTML = `<div>
<h3 class="growth-decision-title">${escapeHtml(config.title)}</h3>
<p class="growth-decision-copy">目前沒有符合這個條件的商品。</p>
<div class="growth-decision-metrics">
<span class="growth-decision-metric">商品 <strong>0</strong> 件</span>
<span class="growth-decision-metric">近 7 天 <strong>NT$ 0</strong></span>
<span class="growth-decision-metric">可信度 <strong>待補</strong></span>
<span class="growth-decision-metric">最大價差 <strong>待判斷</strong></span>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary table-row-action" disabled>無需處理</button>`;
return;
}
const sales = rows.reduce((sum, row) => sum + Number(row.sales_7d || 0), 0);
const qualityRows = rows.map(rowQualityScore).filter((score) => Number.isFinite(score) && score > 0);
const avgQuality = qualityRows.length
? Math.round(qualityRows.reduce((sum, score) => sum + score, 0) / qualityRows.length)
: 0;
const gapValues = rows
.map((row) => row.external_price?.gap_pct)
.filter((gap) => gap !== null && gap !== undefined && Number.isFinite(Number(gap)))
.map(Number);
const largestGap = gapValues.length
? gapValues.reduce((best, gap) => Math.abs(gap) > Math.abs(best) ? gap : best, gapValues[0])
: null;
const topRow = rows[0] || {};
const topName = topRow.product_name ? `代表商品:${topRow.product_name}` : '目前沒有符合條件的商品。';
const action = config.action || (
topRow.recommended_action?.code === 'review_external_candidate'
? 'review-candidate'
: topRow.recommended_action?.code === 'map_external_product'
? 'backfill'
: 'focus-price'
);
const productKey = escapeHtml(topRow.pchome_product_id || topRow.product_name || '');
const buttonAttrs = action === 'backfill'
? 'data-growth-action="backfill"'
: action === 'review-candidate'
? `data-growth-action="review-candidate" data-product-key="${productKey}"`
: `data-growth-action="focus-price" data-product-key="${productKey}"`;
box.innerHTML = `<div>
<h3 class="growth-decision-title">${escapeHtml(config.title)}</h3>
<p class="growth-decision-copy">${escapeHtml(config.copy)} ${escapeHtml(topName)}</p>
<div class="growth-decision-metrics">
<span class="growth-decision-metric">商品 <strong>${formatCount(rows.length)}</strong> 件</span>
<span class="growth-decision-metric">近 7 天 <strong>${escapeHtml(formatMoney(sales))}</strong></span>
<span class="growth-decision-metric">可信度 <strong>${avgQuality ? avgQuality + '%' : '待補'}</strong></span>
<span class="growth-decision-metric">最大價差 <strong>${escapeHtml(largestGap === null ? '待判斷' : formatGapDisplay(largestGap))}</strong></span>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" ${buttonAttrs}>${escapeHtml(config.actionLabel)}</button>`;
}
function renderGrowthDetail(kind = activeGrowthDetailKind) {
const titleBox = document.getElementById('growthDrilldownTitle');
const metaBox = document.getElementById('growthDrilldownMeta');
const resultBox = document.getElementById('growthDrilldownResult');
if (!titleBox || !metaBox || !resultBox) return;
activeGrowthDetailKind = resolveGrowthDetailKind(kind);
document.querySelectorAll('.growth-detail-tab').forEach((tab) => {
tab.classList.toggle('is-active', tab.dataset.detailKind === activeGrowthDetailKind);
});
renderGrowthStrategySummary();
renderGrowthCategoryBoard();
renderGrowthPlaybookBoard();
renderGrowthActionBoard();
const [title, subtitle] = growthDetailConfig(activeGrowthDetailKind);
const rows = growthDetailRows(activeGrowthDetailKind);
if (activeGrowthProductKey && !rows.some((row) => growthRowKey(row) === activeGrowthProductKey)) {
hideGrowthProductDecision();
}
const visibleRows = rows.slice(0, 50);
const salesTotal = rows.reduce((sum, row) => sum + Number(row.sales_7d || 0), 0);
titleBox.textContent = title;
metaBox.textContent = `${rows.length.toLocaleString()} 件 · 近 7 天業績 ${formatMoney(salesTotal)} · ${subtitle} · 先列 ${visibleRows.length.toLocaleString()}`;
renderGrowthDecisionSummary(rows, activeGrowthDetailKind);
if (!rows.length) {
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-info d-block mb-2"></i>
目前沒有符合這個條件的商品。
</div>`;
return;
}
resultBox.innerHTML = visibleRows.map((row) => {
const action = row.recommended_action || {};
const price = row.external_price || null;
const reviewCandidate = row.review_candidate || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
const gapText = reviewCandidate
? '候選待確認'
: gap === null
? '缺 MOMO 參考'
: formatGapDisplay(gap);
const delta = row.sales_delta_pct === null || row.sales_delta_pct === undefined
? '前期不足'
: `${Number(row.sales_delta_pct).toFixed(1)}%`;
const qualityScore = row.data_quality?.score !== null && row.data_quality?.score !== undefined
? Math.round(Number(row.data_quality.score || 0))
: reviewCandidate?.quality_score !== null && reviewCandidate?.quality_score !== undefined
? Math.round(Number(reviewCandidate.quality_score || 0))
: price?.match_score
? Math.round(Number(price.match_score || 0) * 100)
: 0;
const pchomeDisplay = price
? formatGrowthDetailPrice(price, 'pchome')
: reviewCandidate
? formatPriceAmount(reviewCandidate.pchome_price)
: '待補';
const momoDisplay = price
? formatGrowthDetailPrice(price, 'momo')
: reviewCandidate
? formatPriceAmount(reviewCandidate.momo_price)
: '待補';
const priceBasis = price?.price_basis_label || (reviewCandidate ? '候選價' : '待補資料');
const gapChipClass = gap !== null && gap < 0
? ' is-risk'
: gap !== null && gap > 0
? ' is-good'
: '';
const productKey = escapeHtml(row.pchome_product_id || row.product_name || '');
const nextAction = action.code === 'review_external_candidate'
? 'review-candidate'
: action.code === 'map_external_product'
? 'backfill'
: 'focus-price';
const nextLabel = action.code === 'review_external_candidate'
? '確認候選'
: action.code === 'map_external_product'
? '補齊比價'
: '看價格';
const activeClass = productKey && productKey === escapeHtml(activeGrowthProductKey) ? ' is-active' : '';
return `<article class="growth-detail-row${activeClass}">
<div>
<h3 class="growth-detail-name">
<button type="button" class="growth-detail-name-button" data-growth-action="show-product-detail" data-product-key="${productKey}">
${escapeHtml(row.product_name || '未命名商品')}
</button>
</h3>
<p class="growth-detail-line">
${escapeHtml(row.category || '未分類')} · 近 7 天 ${escapeHtml(formatMoney(row.sales_7d))} · 變化 ${escapeHtml(delta)}
</p>
<p class="growth-detail-line">
${escapeHtml(action.label || '待判斷')} · ${escapeHtml(gapText)}
</p>
</div>
<div class="growth-detail-action">
<div class="growth-detail-price-grid">
<span class="growth-price-chip">
<span class="growth-price-label">PChome</span>
<span class="growth-price-value">${escapeHtml(pchomeDisplay)}</span>
<span class="growth-price-note">${escapeHtml(priceBasis)}</span>
</span>
<span class="growth-price-chip">
<span class="growth-price-label">MOMO</span>
<span class="growth-price-value">${escapeHtml(momoDisplay)}</span>
<span class="growth-price-note">${escapeHtml(price?.data_source_label || (reviewCandidate ? '候選待確認' : '等待補抓'))}</span>
</span>
<span class="growth-price-chip${gapChipClass}">
<span class="growth-price-label">差距</span>
<span class="growth-price-value">${escapeHtml(gapText)}</span>
<span class="growth-price-note">${escapeHtml(action.label || '待判斷')}</span>
</span>
<span class="growth-price-chip">
<span class="growth-price-label">可信度</span>
<span class="growth-price-value">${qualityScore ? `${qualityScore}%` : '待補'}</span>
<span class="growth-price-note">${escapeHtml(row.data_quality?.label || '資料待確認')}</span>
</span>
</div>
<div class="growth-detail-action-row">
<button type="button" class="btn btn-sm btn-outline-secondary table-row-action" data-growth-action="show-product-detail" data-product-key="${productKey}">查看判斷</button>
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" data-growth-action="${nextAction}" data-product-key="${productKey}">${nextLabel}</button>
</div>
</div>
</article>`;
}).join('');
}
function renderGrowthOps(rows) {
const list = document.getElementById('growthOpsList');
if (!rows.length) {
list.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-info d-block mb-2"></i>
目前沒有足夠資料,請先確認 PChome 業績檔已匯入。
</div>`;
return;
}
const visibleRows = rows.slice(0, 12);
const body = visibleRows.map((row, index) => {
const action = row.recommended_action || {};
const reason = (row.reason_lines || []).slice(0, 2).join(' ');
const price = row.external_price;
const reviewCandidate = row.review_candidate || null;
const gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
const basisLabel = price?.price_basis_label || '商品總價';
const priceText = reviewCandidate
? '候選待確認'
: gap === null
? '資料不足,先補比價'
: gap < 0
? `${basisLabel} PChome 貴 ${Math.abs(gap).toFixed(1)}%`
: gap > 0
? `${basisLabel} PChome 便宜 ${gap.toFixed(1)}%`
: `${basisLabel}差不多`;
const priorityScore = Number(row.priority_score || 0);
const priority = priorityScore >= 55 ? 'P1' : priorityScore >= 35 ? 'P2' : 'P3';
const priorityLabel = priority === 'P1' ? '今天先做' : priority === 'P2' ? '接著處理' : '排程追蹤';
const priorityClass = priority === 'P1' ? 'bg-danger' : priority === 'P2' ? 'bg-warning text-dark' : 'bg-secondary';
const quality = row.data_quality || {};
const qualityScore = quality.score !== undefined && quality.score !== null ? Number(quality.score) : null;
const qualityLabel = quality.label || (price ? '可直接參考' : '資料不足');
const qualityIssues = Array.isArray(quality.issues) ? quality.issues.join('、') : '';
const productKey = escapeHtml(row.pchome_product_id || row.product_name || '');
const nextLabel = action.code === 'review_external_candidate'
? '確認候選'
: action.code === 'map_external_product'
? '補齊比價'
: '檢查價格';
const nextAction = action.code === 'review_external_candidate'
? 'review-candidate'
: action.code === 'map_external_product'
? 'backfill'
: 'focus-price';
return `<tr>
<td data-label="優先級">
<span class="badge ${priorityClass}">${priority}</span>
<div class="growth-ops-muted">${priorityLabel} · ${priorityScore.toFixed(0)}分</div>
</td>
<td data-label="建議動作">
<span class="growth-action-pill">${escapeHtml(action.label || '待判斷')}</span>
</td>
<td data-label="商品">
<div class="growth-ops-name">${escapeHtml(row.product_name)}</div>
<div class="growth-ops-muted">${escapeHtml(reason || '依業績與外部價格排序')}</div>
</td>
<td class="text-end fw-bold" data-label="近 7 天業績">${formatMoney(row.sales_7d)}</td>
<td class="text-end" data-label="比價結果">${escapeHtml(priceText)}</td>
<td data-label="資料可信度">
<span class="${price ? 'text-success' : 'text-warning'} fw-bold">${escapeHtml(qualityLabel)}</span>
<div class="growth-ops-muted">${qualityScore === null ? escapeHtml(qualityIssues || '等待補齊資料') : `${qualityScore.toFixed(0)}%${qualityIssues ? ' · ' + escapeHtml(qualityIssues) : ''}`}</div>
</td>
<td class="text-end" data-label="下一步">
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" data-growth-action="${nextAction}" data-product-key="${productKey}">${nextLabel}</button>
</td>
</tr>`;
}).join('');
list.innerHTML = `<div class="growth-ops-table-wrap">
<table class="growth-ops-table">
<thead>
<tr>
<th>優先級</th>
<th>建議動作</th>
<th>商品</th>
<th class="text-end">近 7 天業績</th>
<th class="text-end">比價結果</th>
<th>資料可信度</th>
<th class="text-end">下一步</th>
</tr>
</thead>
<tbody>${body}</tbody>
</table>
</div>`;
}
async function loadGrowthReviewCandidates(forceRefresh = false) {
const box = document.getElementById('growthReviewCandidateList');
if (!box) return;
if (forceRefresh) {
box.innerHTML = `<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>更新待確認候選中...
</div>`;
}
try {
const response = await fetch('/api/ai/pchome-growth/review-candidates?limit=20');
const data = await readJsonResponse(response);
if (!data.success) throw new Error(data.error || data.message || '讀取失敗');
renderGrowthReviewCandidates(data.rows || []);
} catch (error) {
console.error(error);
box.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
待確認候選暫時讀不到,請稍後再試。
</div>`;
}
}
function renderGrowthReviewCandidates(rows) {
const box = document.getElementById('growthReviewCandidateList');
const total = document.getElementById('reviewCandidateTotal');
const readyHint = document.getElementById('reviewCandidateReadyHint');
if (!box) return;
rows = Array.isArray(rows) ? rows : [];
if (total) total.textContent = rows.length.toLocaleString();
if (readyHint) readyHint.textContent = rows.length.toLocaleString();
if (!rows.length) {
box.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-check d-block mb-2"></i>
目前沒有待確認候選。
</div>`;
return;
}
box.innerHTML = rows.map((row) => {
const reasonLabels = Array.isArray(row.match_reason_labels) && row.match_reason_labels.length
? row.match_reason_labels.slice(0, 3)
: ['請比對兩個賣場的品名、容量、色號與組合'];
const gap = formatReviewCandidateGap(row.gap_pct);
const score = Number(row.quality_score || 0).toFixed(0);
const momoPrice = row.momo_price ? formatMoney(row.momo_price) : '未取得 MOMO 價格';
const pchomePrice = row.pchome_price ? formatMoney(row.pchome_price) : '未取得 PChome 價格';
const pchomeUrl = safeHttpUrl(row.pchome_url);
const momoUrl = safeHttpUrl(row.momo_url || row.product_url);
const momoImageUrl = safeHttpUrl(row.image_url);
const pchomeLink = pchomeUrl ? `<a href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener noreferrer">PChome 賣場</a>` : '<span class="text-muted">待補連結</span>';
const momoLink = momoUrl ? `<a href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener noreferrer">MOMO 賣場</a>` : '<span class="text-muted">待補連結</span>';
const momoThumb = momoImageUrl
? `<img src="${escapeHtml(momoImageUrl)}" alt="MOMO 商品圖" loading="lazy">`
: '<i class="fas fa-store"></i>';
const compareButton = pchomeUrl && momoUrl
? `<button type="button" class="btn btn-sm btn-outline-primary" data-pchome-url="${escapeHtml(pchomeUrl)}" data-momo-url="${escapeHtml(momoUrl)}" onclick="openReviewCandidateStores(this)"><i class="fas fa-up-right-from-square me-1"></i>雙開賣場</button>`
: '';
return `<article class="review-candidate-row" data-pchome-id="${escapeHtml(row.pchome_product_id || '')}">
<div>
<h3 class="review-candidate-title">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</h3>
<div class="review-candidate-meta">
<span class="review-candidate-pill ${escapeHtml(gap.className)}">${escapeHtml(gap.label)}</span>
<span class="review-candidate-pill">同款可信度 ${score}%</span>
</div>
<div class="review-candidate-compare" aria-label="兩家賣場比對">
<section class="review-candidate-store">
<div class="review-candidate-store-head">
<span class="review-candidate-thumb"><i class="fas fa-store"></i></span>
<div>
<strong>PChome ${pchomeLink}</strong>
<span class="review-candidate-store-price">${escapeHtml(pchomePrice)}</span>
</div>
</div>
<p class="review-candidate-store-title" title="${escapeHtml(row.pchome_product_name || '')}">${escapeHtml(row.pchome_product_name || row.pchome_product_id || 'PChome 商品')}</p>
</section>
<section class="review-candidate-store">
<div class="review-candidate-store-head">
<span class="review-candidate-thumb">${momoThumb}</span>
<div>
<strong>MOMO ${momoLink}</strong>
<span class="review-candidate-store-price">${escapeHtml(momoPrice)}</span>
</div>
</div>
<p class="review-candidate-store-title" title="${escapeHtml(row.momo_title || '')}">${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')}</p>
</section>
</div>
<div class="review-candidate-reason-chips" aria-label="需要確認的重點">
${renderReasonChips(reasonLabels)}
</div>
</div>
<div class="review-candidate-actions">
${compareButton}
${pchomeUrl ? `<a class="btn btn-sm btn-outline-secondary" href="${escapeHtml(pchomeUrl)}" target="_blank" rel="noopener noreferrer">開 PChome</a>` : ''}
${momoUrl ? `<a class="btn btn-sm btn-outline-secondary" href="${escapeHtml(momoUrl)}" target="_blank" rel="noopener noreferrer">開 MOMO</a>` : ''}
<button type="button" class="btn btn-sm btn-success" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'confirm', this)">確認同款</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="updateGrowthReviewCandidate(${Number(row.id)}, 'reject', this)">不是同款</button>
</div>
</article>`;
}).join('');
}
function openReviewCandidateStores(button) {
const pchomeUrl = safeHttpUrl(button?.dataset?.pchomeUrl);
const momoUrl = safeHttpUrl(button?.dataset?.momoUrl);
const openStoreWindow = (url, name) => {
const win = window.open(url, name);
if (win) {
try { win.opener = null; } catch (_) {}
}
return win;
};
const opened = [];
if (pchomeUrl) opened.push(openStoreWindow(pchomeUrl, `pchome_${Date.now()}`));
if (momoUrl) opened.push(openStoreWindow(momoUrl, `momo_${Date.now()}`));
if (opened.some((win) => !win)) {
showToast('warning', '瀏覽器擋住了其中一個視窗;請用旁邊的 PChome / MOMO 按鈕分別開啟。', 4500);
}
}
async function updateGrowthReviewCandidate(id, action, button) {
if (!id || !action) return;
const actionText = action === 'confirm' ? '確認同款' : '排除候選';
const originalText = button ? button.textContent : '';
if (button) {
button.disabled = true;
button.textContent = '處理中';
}
try {
const response = await fetch(`/api/ai/pchome-growth/review-candidates/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
});
const data = await readJsonResponse(response);
if (!data.success) throw new Error(data.error || data.message || `${actionText}失敗`);
showToast('success', data.message || `${actionText}完成`, 3500);
await loadGrowthReviewCandidates(true);
await loadGrowthOps(true);
loadDashboard();
} catch (error) {
console.error(error);
showToast('error', `${actionText}失敗:${error.message}`, 5000);
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText;
}
}
}
function fillExternalOfferSample() {
const sample = [
'來源,平台商品編號,商品名稱,售價,資料時間,取得方式,PChome商品編號,是否同款,可信度',
'MOMO,MOMO-1001,範例保養商品,899,2026-06-15T10:00:00,備用資料,PCH-1001,是,88',
'MOMO,MOMO-2001,待確認商品,790,2026-06-15T10:05:00,備用資料,,待確認,60'
].join('\\n');
document.getElementById('externalOfferCsvText').value = sample;
}
async function previewExternalOfferCsv() {
const fileInput = document.getElementById('externalOfferCsvFile');
const textInput = document.getElementById('externalOfferCsvText');
const resultBox = document.getElementById('offerDryRunResult');
const button = document.getElementById('btnExternalOfferDryRun');
const formData = new FormData();
if (fileInput.files && fileInput.files[0]) {
formData.append('file', fileInput.files[0]);
}
if (textInput.value.trim()) {
formData.append('csv_text', textInput.value.trim());
}
if (!formData.has('file') && !formData.has('csv_text')) {
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
請先上傳備援檔案或貼上內容。
</div>`;
return;
}
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>檢查中';
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>檢查資料品質中...
</div>`;
try {
const response = await fetch('/api/ai/pchome-growth/external-offers/csv-dry-run', {
method: 'POST',
body: formData,
});
const data = await readJsonResponse(response);
renderExternalOfferDryRun(data);
} catch (error) {
console.error(error);
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
備援資料檢查暫時失敗,請稍後再試。
</div>`;
} finally {
button.disabled = false;
button.innerHTML = '<i class="fas fa-magnifying-glass me-1"></i>檢查備援資料';
}
}
function renderExternalOfferDryRun(data) {
const resultBox = document.getElementById('offerDryRunResult');
const summary = data.summary || {};
document.getElementById('offerDryRunReady').textContent = (summary.ready_count || 0).toLocaleString();
document.getElementById('offerDryRunReview').textContent = (summary.review_count || 0).toLocaleString();
document.getElementById('offerDryRunBlocked').textContent = (summary.blocked_count || 0).toLocaleString();
if (!data.success) {
const errors = (data.errors || [data.error || '備援資料格式需要修正']).slice(0, 4);
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-exclamation d-block mb-2"></i>
${escapeHtml(errors.join(''))}
</div>`;
return;
}
const rows = (data.rows || []).slice(0, 8);
if (!rows.length) {
resultBox.innerHTML = `<div class="text-center py-4 text-muted">
<i class="fas fa-circle-info d-block mb-2"></i>
備援資料裡沒有可檢查的資料列。
</div>`;
return;
}
resultBox.innerHTML = rows.map((row) => {
const statusClass = row.status_code === 'ready'
? ' is-ready'
: row.status_code === 'blocked'
? ' is-blocked'
: '';
const reasons = (row.reasons || []).join('、');
const price = row.price ? formatMoney(row.price) : '未填價格';
const sourceLabel = formatDataSourceLabel(row['source' + '_code']);
return `<article class="offer-dryrun-row">
<div>
<h3 class="offer-dryrun-title">${escapeHtml(row.title || '未命名商品')}</h3>
<p class="offer-dryrun-meta">
${escapeHtml(row.row_number || '-')} 列 · ${escapeHtml(sourceLabel)} · ${escapeHtml(price)}
</p>
<p class="offer-dryrun-reason">${escapeHtml(reasons)}</p>
</div>
<span class="offer-status-pill${statusClass}">${escapeHtml(row.status_label || '待確認')}</span>
</article>`;
}).join('');
}
// ── 競品比價表格(熱力圖底色)──────────────────────
function renderPriceRiskBoard(rows) {
rows = Array.isArray(rows) ? rows : [];
const counts = rows.reduce((acc, row) => {
if (row.gap_pct > 15 || row.risk === 'HIGH') acc.high += 1;
else if (row.gap_pct > 5 || row.risk === 'MED') acc.medium += 1;
else acc.low += 1;
return acc;
}, { high: 0, medium: 0, low: 0 });
const total = Math.max(1, counts.high + counts.medium + counts.low);
document.getElementById('priceRiskHighText').textContent = `${formatCount(counts.high)}`;
document.getElementById('priceRiskMediumText').textContent = `${formatCount(counts.medium)}`;
document.getElementById('priceRiskLowText').textContent = `${formatCount(counts.low)}`;
setWidth('priceRiskHighBar', (counts.high / total) * 100);
setWidth('priceRiskMediumBar', (counts.medium / total) * 100);
setWidth('priceRiskLowBar', (counts.low / total) * 100);
}
function showPriceRiskDetail(risk) {
const select = document.getElementById('riskFilter');
if (select) {
select.value = risk || 'all';
filterTable();
}
scrollToPanel('externalPricePanel');
}
function renderCompetitorTable(rows) {
rows = Array.isArray(rows) ? rows : [];
const tbody = document.getElementById('competitorTbody');
renderPriceRiskBoard(rows);
if (!rows.length) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5 text-muted">
<i class="fas fa-info-circle me-2"></i>暫無競品比價資料
</td></tr>`;
document.getElementById('compCount').textContent = '0 筆';
return;
}
tbody.innerHTML = rows.map(r => {
// 行底色
let rowBg = '';
if (r.gap_pct > 15) rowBg = 'background:#fee2e2'; // 淺紅 — 嚴重貴
else if (r.gap_pct > 5) rowBg = 'background:#fef9c3'; // 淺黃 — 有風險
else if (r.gap_pct < 0) rowBg = 'background:#f0fdf4'; // 淺綠 — 我便宜
// 價差文字顏色
const gapClass = r.gap_pct > 15 ? 'text-danger fw-bold'
: r.gap_pct > 5 ? 'text-warning fw-bold'
: r.gap_pct < 0 ? 'text-success fw-bold'
: 'text-muted';
const gapSign = r.gap_pct > 0 ? '+' : '';
const pchomePrice = Number(r.pchome_price || 0);
const momoPrice = Number(r.momo_price || 0);
const gapAmount = pchomePrice - momoPrice;
const gapAmountText = gapAmount > 0
? `${formatMoney(gapAmount)}`
: gapAmount < 0
? `便宜 ${formatMoney(Math.abs(gapAmount))}`
: '差不多';
const riskLabel = r.gap_pct > 15 || r.risk === 'HIGH'
? '需檢查'
: r.gap_pct > 5 || r.risk === 'MED'
? '留意'
: r.gap_pct < 0
? '價格有利'
: '觀察';
const riskBadge = r.gap_pct > 15 || r.risk === 'HIGH'
? 'bg-danger text-white'
: r.gap_pct > 5 || r.risk === 'MED'
? 'bg-warning text-dark'
: r.gap_pct < 0
? 'bg-success text-white'
: 'bg-secondary text-white';
const tagHtml = (r.tags || []).map(t => {
const tagMap = {
'on_sale': ['bg-info text-dark', '促銷中'],
'discount_30pct': ['bg-danger text-white', '折30%+'],
'discount_20pct': ['bg-warning text-dark', '折20%+'],
'discount_10pct': ['bg-secondary text-white','折10%+'],
'low_stock': ['bg-dark text-white', '低庫存'],
'high_rating': ['bg-primary text-white', '高評分'],
'identity_v2': ['bg-success text-white', '同款確認'],
'match_type_exact':['bg-success text-white', '同款確認'],
'price_alert_exact':['bg-danger text-white', '價差告警'],
'external_offers': ['bg-success text-white', '自動同步'],
'legacy_competitor_cache':['bg-light text-dark','舊資料'],
'evidence_brand': ['bg-light text-dark', '品牌一致'],
'evidence_identity':['bg-light text-dark', '同款證據'],
'match_shared_model_token':['bg-light text-dark','型號一致'],
'match_product_line':['bg-light text-dark', '品線一致'],
'alert_tier_price':['bg-warning text-dark', '優先追蹤'],
};
const mapped = tagMap[t];
if (!mapped) return '';
const [cls, label] = mapped;
return `<span class="badge ${cls} me-1" style="font-size:0.68rem">${label}</span>`;
}).filter(Boolean).slice(0, 3).join('');
const matchScore = Number(r.match_score || 0);
const scoreColor = matchScore >= 0.7 ? 'text-success'
: matchScore >= 0.55 ? 'text-warning'
: 'text-danger';
const scorePct = Math.round(matchScore * 100);
const scoreLabel = scorePct >= 70 ? '高' : scorePct >= 55 ? '中' : '低';
const name = escapeHtml(r.name || '未命名商品');
const category = escapeHtml(r.category || '未分類');
const sku = escapeHtml(r.sku || '');
const dataSource = escapeHtml(r.data_source || '');
const crawledAt = escapeHtml(r.crawled_at || '未更新');
return `<tr data-risk="${escapeHtml(r.risk || '')}" data-source="${dataSource}" data-name="${escapeHtml(String(r.name || '').toLowerCase())}" style="${rowBg}">
<td class="ps-3" data-label="風險"><span class="badge ${riskBadge}">${riskLabel}</span></td>
<td data-label="商品">
<div style="font-size:0.82rem;font-weight:700" title="${name}">${name}</div>
<small class="text-muted">${category} · ${sku}</small>
</td>
<td class="text-end text-dark fw-bold" data-label="PChome">${formatMoney(pchomePrice)}</td>
<td class="text-end text-secondary" data-label="MOMO">${formatMoney(momoPrice)}</td>
<td class="text-end ${gapClass}" data-label="差距">
<strong>${escapeHtml(gapAmountText)}</strong>
<div class="growth-ops-muted">${gapSign}${Number(r.gap_pct || 0).toFixed(1)}%</div>
</td>
<td class="text-center ${scoreColor}" style="font-size:0.8rem" data-label="可信度">
${scoreLabel} · ${scorePct}%
<div>${tagHtml || '<span class="text-muted small">—</span>'}</div>
</td>
<td class="text-muted" style="font-size:0.75rem" data-label="更新">${crawledAt}</td>
<td class="text-end" data-label="下一步">
<button type="button" class="btn btn-sm btn-outline-primary table-row-action" data-table-action="focus" data-product-key="${sku}">鎖定商品</button>
</td>
</tr>`;
}).join('');
document.getElementById('compCount').textContent = `${rows.length}`;
}
// ── 篩選 ─────────────────────────────────────────────
function filterTable() {
const risk = document.getElementById('riskFilter').value;
const search = document.getElementById('searchInput').value.toLowerCase().trim();
const filtered = allCompetitors.filter(r => {
const riskOk = risk === 'all' || r.risk === risk;
const searchText = `${r.name || ''} ${r.sku || ''}`.toLowerCase();
const searchOk = !search || searchText.includes(search);
return riskOk && searchOk;
});
renderCompetitorTable(filtered);
}
// ── 最近處理紀錄 ────────────────────────
function renderAiRecs(recs) {
recs = Array.isArray(recs) ? recs : [];
const container = document.getElementById('aiRecsList');
document.getElementById('aiRecsCount').textContent =
recs.length ? `${recs.length} 筆紀錄` : '尚無紀錄';
if (!recs.length) {
container.innerHTML = `
<div class="text-center py-5">
<i class="fas fa-clipboard-check fa-3x text-muted mb-3 d-block"></i>
<p class="text-muted mb-1">目前還沒有處理紀錄</p>
<p class="small text-muted mb-3">
系統會定期整理,也可以手動更新。
</p>
<button class="btn btn-sm btn-outline-danger" onclick="triggerAnalysis()">
<i class="fas fa-bolt me-1"></i>更新今日建議
</button>
</div>`;
return;
}
const strategyMap = {
'price_cut': ['bg-danger', '降價'],
'promote': ['bg-primary', '主推'],
'product_pick':['bg-success', 'AI挑品'],
'monitor': ['bg-secondary', '觀察'],
'flag': ['bg-warning text-dark', '覆核'],
};
container.innerHTML = recs.map(r => {
const [sBg, sLabel] = strategyMap[r.strategy] || ['bg-secondary', r.strategy || '觀察'];
const confidence = Number(r.confidence || 0);
const gapPct = Number(r.gap_pct || 0);
const confPct = Math.max(0, Math.min(100, Math.round(confidence * 100)));
const confColor = confPct >= 80 ? 'bg-success' : confPct >= 60 ? 'bg-warning' : 'bg-danger';
const gapSign = gapPct > 0 ? '+' : '';
const name = escapeHtml(r.name || '未命名商品');
const reason = String(r.reason || '');
const trimmedReason = reason.substring(0, 90) + (reason.length > 90 ? '…' : '');
const createdAt = escapeHtml(r.created_at || '未更新');
return `<div class="border rounded mb-2 p-2" style="font-size:0.83rem">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="fw-bold text-truncate me-2" style="max-width:200px" title="${name}">${name}</span>
<span class="badge ${sBg} flex-shrink-0">${escapeHtml(sLabel)}</span>
</div>
<div class="d-flex gap-3 mb-1 text-muted small">
<span>MOMO <strong class="text-dark">${formatMoney(r.momo_price)}</strong></span>
<span>PChome <strong class="text-secondary">${formatMoney(r.pchome_price)}</strong></span>
<span class="${gapPct > 10 ? 'text-danger fw-bold' : 'text-muted'}">${gapSign}${gapPct.toFixed(1)}%</span>
</div>
<div class="mb-1 text-muted" style="font-size:0.78rem;line-height:1.4">
${escapeHtml(trimmedReason)}
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-1">
<div class="progress" style="width:60px;height:6px">
<div class="progress-bar ${confColor}" style="width:${confPct}%"></div>
</div>
<small class="text-muted">${confPct}%</small>
</div>
<small class="text-muted">
${createdAt}
</small>
</div>
</div>`;
}).join('');
}
// ── 產生 AI 建議挑品清單 ───────────────────────────
async function generatePickList() {
const btn = document.getElementById('btnPickList');
if (btn && btn.disabled) return;
btn.disabled = true;
setActionBusy('generate-picks', true);
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
try {
const res = await fetch('/api/ai/product-picks/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: 50 })
});
const data = await readJsonResponse(res);
showToast(
data.success ? 'success' : 'error',
data.success ? (data.message || '已開始整理今日清單。完成後請先看第一步。') : (data.error || '產生失敗,資料沒有變更。'),
6000
);
if (data.success) loadDashboard();
} catch (e) {
showToast('error', `整理失敗:${e.message}`, 4000);
} finally {
btn.disabled = false;
setActionBusy('generate-picks', false);
btn.innerHTML = '<i class="fas fa-wand-magic-sparkles me-1"></i>產生今日清單';
}
}
// ── 補抓 PChome 尚未搜尋商品 ───────────────────────
async function backfillPchomeMatches() {
const btn = document.getElementById('btnBackfill');
if (btn && btn.disabled) return;
btn.disabled = true;
setActionBusy('backfill', true);
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
try {
const res = await fetch('/api/ai/pchome-match/backfill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ limit: 60 })
});
const data = await readJsonResponse(res);
showToast(
data.success ? 'success' : 'error',
data.success ? (data.message || '已開始補齊比價資料,約 60 秒後更新。') : (data.error || '商品對應啟動失敗'),
6000
);
if (data.success) setTimeout(loadDashboard, 90000);
} catch (e) {
showToast('error', `商品對應失敗:${e.message}`, 4000);
} finally {
btn.disabled = false;
setActionBusy('backfill', false);
btn.innerHTML = '<i class="fas fa-magnifying-glass-chart me-1"></i>補齊比價資料';
}
}
// ── 手動觸發分析 ────────────────────────────────────
async function triggerAnalysis() {
const btn = document.getElementById('btnTrigger');
if (btn && btn.disabled) return;
btn.disabled = true;
setActionBusy('trigger-analysis', true);
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>整理中...';
try {
const res = await fetch('/api/ai/icaim/trigger', { method: 'POST' });
const data = await readJsonResponse(res);
showToast(
data.success ? 'success' : 'error',
data.success ? (data.message || '已開始更新今日建議,約 60 秒後更新。') : (data.error || '整理失敗,資料沒有變更。'),
6000
);
if (data.success) {
// 60 秒後自動重新整理儀表板
setTimeout(loadDashboard, 60000);
}
} catch (e) {
showToast('error', `整理失敗:${e.message}`, 4000);
} finally {
btn.disabled = false;
setActionBusy('trigger-analysis', false);
btn.innerHTML = '<i class="fas fa-bolt me-1"></i>更新今日建議';
}
}
</script>
{% endblock %}