Files
ewoooc/templates/ai_intelligence.html
ogt e6deaa4711
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
feat: link growth dashboard metrics to details
2026-06-24 14:28:17 +08:00

2649 lines
101 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 {
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 {
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 {
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;
}
.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-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) auto;
gap: 12px;
border-bottom: 1px solid rgba(42, 37, 32, 0.08);
padding: 10px 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,
.review-candidate-reason {
margin: 4px 0 0;
color: var(--momo-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.review-candidate-actions {
display: grid;
gap: 7px;
align-content: start;
min-width: 104px;
}
@media (max-width: 992px) {
.ai-intel-hero {
grid-template-columns: 1fr;
}
.ai-intel-actions {
justify-content: flex-start;
max-width: none;
}
}
@media (max-width: 576px) {
.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,
.ops-flow-grid,
.offer-dryrun-summary,
.price-risk-board {
grid-template-columns: 1fr;
}
.growth-item,
.offer-dryrun-row {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block ewooo_content %}
<div class="ai-intel-page">
<!-- ── 頁首 ── -->
<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="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()" title="依目前業績與比價資料整理商品處理清單">
<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()" 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="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<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="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<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="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<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="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<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="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="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<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="scrollToPanel('externalPricePanel')" onkeydown="handleDrilldownKey(event, 'externalPricePanel')">
<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" 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="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthCandidateCount"></strong>
<span>追蹤商品</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthMappedCount"></strong>
<span>可立即處理</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthOpsPanel')" onkeydown="handleDrilldownKey(event, 'growthOpsPanel')">
<strong id="growthNeedsMapping"></strong>
<span>無法比價</span>
</div>
<div class="growth-metric is-clickable" role="button" tabindex="0" onclick="scrollToPanel('growthReviewPanel')" onkeydown="handleDrilldownKey(event, 'growthReviewPanel')">
<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">
<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">
<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" id="kpiHighRiskCard">
<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">
<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">
<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">
<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">
<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">
<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 = {};
// ── 頁面載入 ────────────────────────────────────────
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 === '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'
: 'card border-0 shadow-sm h-100';
}
function formatMoney(value) {
const num = Number(value || 0);
return 'NT$ ' + Math.round(num).toLocaleString();
}
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 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 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);
}
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=8' + (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 || []);
renderGrowthOps(data.opportunities || []);
loadGrowthReviewCandidates();
} catch (error) {
console.error(error);
renderOpsCommandDashboard({}, {});
renderGrowthActionHint({ candidate_count: 0, mapped_count: 0, needs_mapping_count: 0 });
renderGrowthDataSourceSummary({});
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]) => `${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]) => `${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 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 body = rows.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 reasons = (row.match_reasons || []).slice(0, 3).join('、') || '候選已找到,需確認同款、色號或組合';
const gap = row.gap_pct === null || row.gap_pct === undefined ? '' : ` · 參考差距 ${Number(row.gap_pct).toFixed(1)}%`;
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 safeUrl = safeHttpUrl(row.product_url);
const url = safeUrl ? `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener">看 MOMO</a>` : '';
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>
<p class="review-candidate-meta">
PChome ${escapeHtml(pchomePrice)} · MOMO ${escapeHtml(momoPrice)}${escapeHtml(gap)}
</p>
<p class="review-candidate-meta">
MOMO${escapeHtml(row.momo_title || row.momo_sku || '未命名候選')} ${url}
</p>
<p class="review-candidate-reason">
可信度 ${score}% · ${escapeHtml(reasons)}
</p>
</div>
<div class="review-candidate-actions">
<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('');
}
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) : '未填價格';
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(row.source_code || '未填來源')} · ${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 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 %}