This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user