V10.608 新增外部報價 CSV 預檢
All checks were successful
CD Pipeline / deploy (push) Successful in 1m7s

This commit is contained in:
OoO
2026-06-15 20:39:32 +08:00
parent 9260cc1740
commit df6714c3f7
9 changed files with 527 additions and 5 deletions

View File

@@ -374,6 +374,95 @@
white-space: nowrap;
}
.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;
@@ -484,11 +573,13 @@
}
.growth-ops-grid,
.offer-dryrun-grid,
.growth-metric-row {
grid-template-columns: 1fr;
}
.growth-item {
.growth-item,
.offer-dryrun-row {
grid-template-columns: 1fr;
}
}
@@ -577,6 +668,55 @@
</div>
</section>
<!-- ── 外部報價 CSV 預檢 ── -->
<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">CSV 檔案</label>
<input class="form-control form-control-sm" type="file" id="externalOfferCsvFile" accept=".csv,text/csv">
<label for="externalOfferCsvText">或貼上 CSV 內容</label>
<textarea class="form-control" id="externalOfferCsvText" spellcheck="false"
placeholder="資料來源,外部商品ID,商品名稱,售價,資料時間,取得方式,PChome商品ID,同款狀態,資料可信度"></textarea>
<button class="btn btn-primary btn-sm ai-action-btn" id="btnExternalOfferDryRun" onclick="previewExternalOfferCsv()">
<i class="fas fa-magnifying-glass me-1"></i>預檢 CSV
</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>
上傳或貼上 CSV 後,先檢查資料品質。
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ── KPI 卡片 ── -->
<div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-md-3">
@@ -871,6 +1011,108 @@ function renderGrowthOps(rows) {
}).join('');
}
function fillExternalOfferSample() {
const sample = [
'資料來源,外部商品ID,商品名稱,售價,資料時間,取得方式,PChome商品ID,同款狀態,資料可信度',
'momo_reference,MOMO-1001,範例保養商品,899,2026-06-15T10:00:00,manual_csv,PCH-1001,verified,88',
'shopee,SHP-2001,待確認商品,790,2026-06-15T10:05:00,manual_csv,,unmatched,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>
請先上傳 CSV 或貼上內容。
</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 response.json();
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>
CSV 預檢暫時失敗,請稍後再試。
</div>`;
} finally {
button.disabled = false;
button.innerHTML = '<i class="fas fa-magnifying-glass me-1"></i>預檢 CSV';
}
}
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 || 'CSV 格式需要修正']).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>
CSV 裡沒有可檢查的資料列。
</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 renderCompetitorTable(rows) {
const tbody = document.getElementById('competitorTbody');