Files
ewoooc/templates/auto_import_index.html
ogt c6c18f1c48
All checks were successful
CD Pipeline / deploy (push) Successful in 1m6s
fix: hide raw import errors in UI
2026-06-26 18:25:12 +08:00

440 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "ewoooc_base.html" %}
{% block title %}當日業績報表匯入{% endblock %}
{% block page_attrs %}data-page-group="monitor" data-page-id="auto-import"{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-auto-import.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/page-auto-import-bem.css') }}">
{% endblock %}
{% block content %}
<div class="auto-import-page">
<div class="container">
{# ─────────── 頁首 ─────────── #}
<header class="ai-head">
<span class="ai-head__icon" aria-hidden="true"><i class="fas fa-cloud-download-alt"></i></span>
<div class="ai-head__main">
<h1 class="ai-head__title">當日業績報表匯入</h1>
<p class="ai-head__subtitle">
<i class="fas fa-info-circle" aria-hidden="true"></i>
保持 PChome 業績新鮮,讓評估、分析與建議有可靠資料。
</p>
</div>
</header>
{# ─────────── 配置區 ─────────── #}
<section class="ai-card">
<header class="ai-card__head">
<h2 class="ai-card__title">
<i class="fas fa-cog"></i>Google Drive 自動匯入配置
</h2>
</header>
<div class="ai-card__body">
<div id="configAlert"></div>
<div class="ai-notice">
<i class="fas fa-sync-alt ai-notice__icon" aria-hidden="true"></i>
<p class="ai-notice__body">每 30 分鐘檢查雲端業績檔,讓日報、成長分析與作戰清單保持新鮮。</p>
</div>
<div class="ai-form-grid">
<div>
<label class="ai-field__label" for="folderPath">Google Drive 資料夾路徑</label>
<input type="text" class="ai-field__input form-control" id="folderPath" placeholder="例如:業績報表/當日業績">
<small class="ai-field__hint">設定要監控的 Google Drive 資料夾路徑</small>
</div>
<div>
<label class="ai-field__label" for="filePattern">檔案名稱模式(選填)</label>
<input type="text" class="ai-field__input form-control" id="filePattern" placeholder="例如即時業績_當日">
<small class="ai-field__hint">用於過濾特定名稱的檔案</small>
</div>
</div>
<div class="ai-toolbar">
<button class="btn btn-primary" onclick="saveConfig()">
<i class="fas fa-save me-1"></i>儲存配置
</button>
<button class="btn btn-secondary" onclick="testConnection()">
<i class="fas fa-plug me-1"></i>測試連接
</button>
<button class="btn btn-secondary" onclick="listFiles()">
<i class="fas fa-list me-1"></i>列出檔案
</button>
<button class="btn btn-success" onclick="manualImport()">
<i class="fas fa-play me-1"></i>立即匯入
</button>
</div>
</div>
</section>
{# ─────────── 手動上傳 ─────────── #}
<section class="ai-card">
<header class="ai-card__head">
<h2 class="ai-card__title">
<i class="fas fa-upload"></i>手動上傳匯入
</h2>
</header>
<div class="ai-card__body">
<div id="uploadAlert"></div>
<div class="ai-notice ai-notice--info">
<i class="fas fa-info-circle ai-notice__icon" aria-hidden="true"></i>
<div>
<p class="ai-notice__title">每日業績快照</p>
<p class="ai-notice__body" style="margin-bottom: var(--momo-space-1);">
上傳當日業績檔,更新日報、成長分析與今日作戰清單。
</p>
<p class="ai-notice__body">
<small>檔名建議:<code>即時業績_當日_YYYYMMDD.xlsx</code>;送出後更新日報、成長分析與今日作戰清單。</small>
</p>
</div>
</div>
<div class="ai-uploadrow">
<div>
<label class="ai-field__label" for="manualUploadFile">選擇檔案</label>
<input type="file" class="ai-field__input form-control" id="manualUploadFile" accept=".xlsx,.xls">
</div>
<div>
<button class="btn btn-primary" onclick="uploadManualFile()" style="height: calc(var(--momo-space-2) * 2 + var(--momo-text-sm) * 1.4 + 2px);">
<i class="fas fa-upload me-1"></i>上傳並匯入
</button>
</div>
</div>
</div>
</section>
{# ─────────── 任務歷史 ─────────── #}
<section class="ai-card">
<header class="ai-card__head">
<h2 class="ai-card__title">
<i class="fas fa-tasks"></i>匯入任務歷史
</h2>
<button class="btn btn-warning btn-sm" onclick="resetStuckJobs()">
<i class="fas fa-redo me-1"></i>重置卡住的任務
</button>
</header>
<div class="ai-card__body">
<div id="jobsLoading" class="ai-loading" style="display: none;">
<div class="spinner-border ai-loading__spinner" role="status">
<span class="visually-hidden">載入中…</span>
</div>
<p class="ai-loading__text">載入中…</p>
</div>
<div id="jobsEmpty" class="ai-empty" style="display: none;">
<i class="fas fa-inbox ai-empty__icon" aria-hidden="true"></i>
<p class="ai-empty__text">尚無匯入記錄</p>
</div>
<div id="jobsTableContainer" style="display: none;">
<div class="table-responsive">
<table class="ai-jobtable" id="jobsTable">
<thead>
<tr>
<th>任務</th>
<th>檔案名稱</th>
<th>狀態</th>
<th>進度</th>
<th>成功 / 總</th>
<th>開始時間</th>
<th>完成時間</th>
<th>處置提醒</th>
<th>操作</th>
</tr>
</thead>
<tbody id="jobsTableBody"></tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// ───── 配置:載入 / 儲存 ─────
async function loadConfig() {
try {
const r = await fetch('/api/import_config');
const result = await r.json();
if (result.success) {
document.getElementById('folderPath').value = result.data.folder_path || '';
document.getElementById('filePattern').value = result.data.file_pattern || '';
}
} catch (e) {
console.error('載入配置失敗:', e);
}
}
async function saveConfig() {
const folderPath = document.getElementById('folderPath').value;
const filePattern = document.getElementById('filePattern').value;
try {
const r = await fetch('/api/import_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath, file_pattern: filePattern })
});
const result = await r.json();
if (result.success) {
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>配置已儲存');
} else {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
}
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>儲存配置失敗:' + e.message);
}
}
async function testConnection() {
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在測試 Google Drive 連接…');
try {
const r = await fetch('/api/test_drive_connection', { method: 'POST' });
const result = await r.json();
showAlert('configAlert', result.success ? 'success' : 'danger',
'<i class="fas fa-' + (result.success ? 'check' : 'exclamation') + '-circle me-2"></i>' + result.message);
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>測試失敗:' + e.message);
}
}
async function listFiles() {
const folderPath = document.getElementById('folderPath').value;
const filePattern = document.getElementById('filePattern').value;
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在列出 Google Drive 檔案…');
try {
const r = await fetch('/api/list_drive_files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath, file_pattern: filePattern })
});
const result = await r.json();
if (result.success) {
const list = result.data.map(f => `<li class="mb-1"><i class="fas fa-file-excel me-1"></i>${f.name}</li>`).join('');
showAlert('configAlert', 'success',
`<i class="fas fa-check-circle me-2"></i>找到 ${result.count} 個檔案:<ul class="mt-2 mb-0">${list || '<li>無</li>'}</ul>`);
} else {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
}
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>列出檔案失敗:' + e.message);
}
}
async function manualImport() {
if (!confirm('確定要立即執行匯入嗎?')) return;
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在執行匯入,請稍候…');
try {
const r = await fetch('/api/manual_import', { method: 'POST' });
const result = await r.json();
if (result.success) {
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>' + result.message);
setTimeout(() => loadJobs(), 1000);
} else {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
}
} catch (e) {
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>匯入失敗:' + e.message);
}
}
// ───── 任務列表 ─────
async function loadJobs() {
document.getElementById('jobsLoading').style.display = 'block';
document.getElementById('jobsEmpty').style.display = 'none';
document.getElementById('jobsTableContainer').style.display = 'none';
try {
const r = await fetch('/api/import_jobs?limit=50');
const result = await r.json();
document.getElementById('jobsLoading').style.display = 'none';
if (result.success && result.data.length > 0) {
document.getElementById('jobsTableContainer').style.display = 'block';
renderJobs(result.data);
} else {
document.getElementById('jobsEmpty').style.display = 'block';
}
} catch (e) {
document.getElementById('jobsLoading').style.display = 'none';
document.getElementById('jobsEmpty').style.display = 'block';
console.error('載入任務列表失敗:', e);
}
}
function renderJobs(jobs) {
const tbody = document.getElementById('jobsTableBody');
tbody.innerHTML = '';
jobs.forEach(job => {
const tr = document.createElement('tr');
const fileName = job.drive_file_name || '未記錄檔名';
const actionHint = buildImportActionHint(job);
const isRunning = job.status === 'downloading' || job.status === 'importing';
tr.innerHTML = `
<td><span class="ai-jobtable__id">#${job.id}</span></td>
<td><span class="ai-jobtable__file"><i class="fas fa-file-excel"></i>${escapeHtml(fileName)}</span></td>
<td><span class="ai-status ai-status--${job.status}">${getStatusText(job.status)}</span></td>
<td style="min-width: 160px;">
<div class="ai-progress">
<div class="ai-progress__bar" style="width: ${job.progress_percent}%"
role="progressbar" aria-valuenow="${job.progress_percent}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<span class="ai-progress__label">${Math.round(job.progress_percent)}% · ${escapeHtml(job.current_step || '')}</span>
</td>
<td class="text-center">
<span class="ai-count ai-count--ok">${job.success_rows || 0}</span>
<span class="text-muted mx-1">/</span>
<span class="ai-count ai-count--total">${job.total_rows || 0}</span>
</td>
<td><span class="ai-jobtable__time">${formatTime(job.started_at)}</span></td>
<td><span class="ai-jobtable__time">${formatTime(job.completed_at)}</span></td>
<td class="ai-jobtable__error">${escapeHtml(actionHint)}</td>
<td class="text-center">
${isRunning
? `<button class="btn btn-sm btn-outline-danger" onclick="failJob(${job.id})" title="取消任務"><i class="fas fa-times"></i></button>`
: '<span class="text-muted">—</span>'}
</td>
`;
tbody.appendChild(tr);
});
}
function buildImportActionHint(job) {
const status = String(job?.status || '');
const raw = String(job?.display_error_message || job?.error_message || '').trim();
const text = raw.toLowerCase();
if (status === 'completed') {
return '已更新業績資料,可回到今日作戰看建議。';
}
if (status === 'downloading' || status === 'importing' || status === 'pending') {
return '匯入進行中,完成後會更新日報與今日作戰。';
}
if (status === 'failed') {
if (text.includes('google drive') || raw.includes('雲端') || raw.includes('授權')) {
return '重新確認 Google Drive 授權,確認後再執行立即匯入。';
}
if (raw.includes('格式') || raw.includes('日期') || raw.includes('商品') || raw.includes('業績') || text.includes('excel')) {
return '改用當日業績明細檔,確認包含日期、商品與業績金額後重新匯入。';
}
if (raw.includes('同步') || raw.includes('未完整') || raw.includes('重啟')) {
return '重新匯入最新檔案;若重複失敗,請通知維護人員檢查同步流程。';
}
return '檢查檔案後重新匯入;若重複失敗,請通知維護人員。';
}
return '等待系統更新任務狀態;若重複停在異常,請通知維護人員。';
}
// ───── 工具 ─────
function escapeHtml(text) {
if (!text) return '';
const d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function getStatusText(status) {
return ({
'pending': '⏳ 等待中',
'downloading': '⬇️ 下載中',
'importing': '📥 匯入中',
'completed': '✅ 已完成',
'failed': '❌ 失敗'
})[status] || status;
}
function formatTime(timeStr) {
if (!timeStr) return '—';
return new Date(timeStr).toLocaleString('zh-TW', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit'
});
}
function showAlert(elementId, type, message) {
const el = document.getElementById(elementId);
el.innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>`;
if (type === 'success' || type === 'danger') {
setTimeout(() => {
const a = el.querySelector('.alert');
if (a) bootstrap.Alert.getOrCreateInstance(a).close();
}, 5000);
}
}
// ───── 手動上傳 ─────
async function uploadManualFile() {
const input = document.getElementById('manualUploadFile');
const file = input.files[0];
if (!file) {
showAlert('uploadAlert', 'warning', '<i class="fas fa-exclamation-triangle me-2"></i>請先選擇檔案');
return;
}
if (!file.name.includes('即時業績') || !file.name.includes('當日')) {
if (!confirm('檔名似乎不符合格式(應包含「即時業績」和「當日」),確定要繼續嗎?')) return;
}
const formData = new FormData();
formData.append('file', file);
showAlert('uploadAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在上傳並匯入檔案,請稍候…');
try {
const r = await fetch('/api/import_excel', { method: 'POST', body: formData });
const result = await r.json();
if (result.status === 'success') {
showAlert('uploadAlert', 'success',
`<i class="fas fa-check-circle me-2"></i>${result.message}<br><small>共更新 ${result.rows} 筆業績資料</small>`);
input.value = '';
setTimeout(() => loadJobs(), 1000);
} else {
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
}
} catch (e) {
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>上傳失敗:' + e.message);
}
}
async function resetStuckJobs() {
if (!confirm('確定要重置所有卡住超過 1 小時的任務嗎?')) return;
try {
const r = await fetch('/api/reset_stuck_jobs', { method: 'POST' });
const result = await r.json();
if (result.success) {
alert(result.message);
loadJobs();
} else {
alert('重置失敗:' + result.message);
}
} catch (e) {
alert('重置失敗:' + e.message);
}
}
async function failJob(jobId) {
if (!confirm(`確定要取消任務 #${jobId} 嗎?`)) return;
try {
const r = await fetch(`/api/import_jobs/${jobId}/fail`, { method: 'POST' });
const result = await r.json();
if (result.success) { alert(result.message); loadJobs(); }
else { alert('取消失敗:' + result.message); }
} catch (e) {
alert('取消失敗:' + e.message);
}
}
// ───── 初始化 ─────
loadConfig();
loadJobs();
setInterval(loadJobs, 10000);
</script>
{% endblock %}