415 lines
16 KiB
HTML
415 lines
16 KiB
HTML
{% 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>ID</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 || 'N/A';
|
||
const errorMsg = job.error_message || '—';
|
||
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(errorMsg)}</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 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 %}
|