2340 lines
88 KiB
HTML
2340 lines
88 KiB
HTML
{% 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-detail {
|
||
margin-top: 7px;
|
||
color: var(--momo-text-muted);
|
||
font-size: 0.76rem;
|
||
font-weight: 760;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.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(3, 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;
|
||
}
|
||
|
||
@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,
|
||
.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" id="growthExecTaskCard">
|
||
<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" id="growthExecTaskDetail">正在讀取 PChome 業績與 MOMO 外部價格。</div>
|
||
</article>
|
||
<article class="growth-exec-card is-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">已有可用比價資料</div>
|
||
</article>
|
||
<article class="growth-exec-card is-gap" id="growthExecGapCard">
|
||
<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">有業績但缺外部參考</div>
|
||
</article>
|
||
<article class="growth-exec-card">
|
||
<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" id="growthExecLatestDetail">等待資料</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">
|
||
<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">
|
||
<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">
|
||
<strong id="growthCandidateCount">—</strong>
|
||
<span>追蹤商品</span>
|
||
</div>
|
||
<div class="growth-metric">
|
||
<strong id="growthMappedCount">—</strong>
|
||
<span>可立即處理</span>
|
||
</div>
|
||
<div class="growth-metric">
|
||
<strong id="growthNeedsMapping">—</strong>
|
||
<span>無法比價</span>
|
||
</div>
|
||
<div class="growth-metric">
|
||
<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>
|
||
|
||
<!-- ── 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 貴 >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;
|
||
}
|
||
focusPriceTable(growthButton.dataset.productKey || '');
|
||
});
|
||
}
|
||
|
||
function focusPriceTable(keyword) {
|
||
const input = document.getElementById('searchInput');
|
||
if (input && keyword) {
|
||
input.value = keyword;
|
||
filterTable();
|
||
}
|
||
scrollToPanel('externalPricePanel');
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ── KPI(high_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) => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": ''',
|
||
}[ch]));
|
||
}
|
||
|
||
function scrollToPanel(panelId) {
|
||
const panel = document.getElementById(panelId);
|
||
if (!panel) return;
|
||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
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 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);
|
||
|
||
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) {
|
||
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) {
|
||
title.textContent = `今天先做:補齊 ${formatCount(needsMapping)} 件比價資料`;
|
||
reason.textContent = '這些商品有業績,但目前還看不到 MOMO 參考價,請先補齊比價資料。';
|
||
button.textContent = '補齊比價資料';
|
||
button.dataset.action = 'backfill';
|
||
button.onclick = () => backfillPchomeMatches();
|
||
return;
|
||
}
|
||
|
||
if (needsMapping > mappedCount) {
|
||
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 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) {
|
||
task.textContent = `先補 ${formatCount(needsMapping)} 件 MOMO 參考`;
|
||
detail.textContent = '高業績商品還不能比價,先補對應資料才會有可行動建議。';
|
||
gapCard?.classList.add('is-gap');
|
||
return;
|
||
}
|
||
|
||
if (needsMapping > mappedCount) {
|
||
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 || []);
|
||
} catch (error) {
|
||
console.error(error);
|
||
renderOpsCommandDashboard({}, {});
|
||
renderGrowthActionHint({ candidate_count: 0, mapped_count: 0, needs_mapping_count: 0 });
|
||
renderGrowthDataSourceSummary({});
|
||
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 gap = price && price.gap_pct !== null && price.gap_pct !== undefined ? Number(price.gap_pct) : null;
|
||
const basisLabel = price?.price_basis_label || '商品總價';
|
||
const priceText = 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 === 'map_external_product' ? '補齊比價' : '檢查價格';
|
||
const nextAction = 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>`;
|
||
}
|
||
|
||
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 %}
|