feat(pchome): automate authorized sales acquisition
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{% extends "ewoooc_base.html" %}
|
||||
|
||||
{% block title %}當日業績報表匯入{% endblock %}
|
||||
{% block title %}業績資料自動化{% endblock %}
|
||||
{% block page_attrs %}data-page-group="monitor" data-page-id="auto-import"{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
@@ -11,429 +11,357 @@
|
||||
{% 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>
|
||||
<span class="ai-head__icon" aria-hidden="true"><i class="fas fa-arrows-rotate"></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>
|
||||
<h1 class="ai-head__title">業績資料自動化</h1>
|
||||
<p class="ai-head__subtitle" id="pageRuntimeSummary">正在讀取正式資料狀態</p>
|
||||
</div>
|
||||
<div class="ai-head__actions">
|
||||
<button class="ai-icon-button" type="button" onclick="loadAll()" title="重新整理" aria-label="重新整理">
|
||||
<i class="fas fa-rotate"></i>
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" id="runAutomationButton" onclick="runAutomation()">
|
||||
<i class="fas fa-play me-1" aria-hidden="true"></i>執行自動取得
|
||||
</button>
|
||||
</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>
|
||||
<section class="ai-cockpit" aria-label="業績資料狀態">
|
||||
<div class="ai-metric">
|
||||
<span class="ai-metric__label">最新業績</span>
|
||||
<strong class="ai-metric__value" id="freshnessValue">--</strong>
|
||||
<span class="ai-metric__meta" id="freshnessMeta">讀取中</span>
|
||||
</div>
|
||||
<div class="ai-metric">
|
||||
<span class="ai-metric__label">決策狀態</span>
|
||||
<strong class="ai-metric__value" id="closureValue">--</strong>
|
||||
<span class="ai-metric__meta" id="closureMeta">讀取中</span>
|
||||
</div>
|
||||
<div class="ai-metric">
|
||||
<span class="ai-metric__label">來源覆蓋</span>
|
||||
<strong class="ai-metric__value" id="sourceCoverageValue">--</strong>
|
||||
<span class="ai-metric__meta" id="sourceCoverageMeta">讀取中</span>
|
||||
</div>
|
||||
<div class="ai-metric">
|
||||
<span class="ai-metric__label">最近執行</span>
|
||||
<strong class="ai-metric__value ai-metric__value--compact" id="lastRunValue">--</strong>
|
||||
<span class="ai-metric__meta" id="lastRunMeta">尚無 receipt</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ─────────── 手動上傳 ─────────── #}
|
||||
<section class="ai-card">
|
||||
<header class="ai-card__head">
|
||||
<h2 class="ai-card__title">
|
||||
<i class="fas fa-upload"></i>手動上傳匯入
|
||||
</h2>
|
||||
<div class="ai-inline-status" id="automationAlert" role="status" hidden></div>
|
||||
|
||||
<section class="ai-section" aria-labelledby="sourcesTitle">
|
||||
<header class="ai-section__head">
|
||||
<div>
|
||||
<span class="ai-section__eyebrow">授權來源</span>
|
||||
<h2 class="ai-section__title" id="sourcesTitle">資料來源</h2>
|
||||
</div>
|
||||
<span class="ai-mode-badge"><i class="fas fa-shield-halved" aria-hidden="true"></i>自動安全執行</span>
|
||||
</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 class="ai-source-list" id="sourceList" aria-live="polite">
|
||||
<div class="ai-source-row ai-source-row--loading">正在讀取來源狀態</div>
|
||||
</div>
|
||||
|
||||
<details class="ai-disclosure">
|
||||
<summary><i class="fab fa-google-drive" aria-hidden="true"></i>Drive 來源設定</summary>
|
||||
<div class="ai-disclosure__body">
|
||||
<div id="configAlert"></div>
|
||||
<div class="ai-form-grid">
|
||||
<div>
|
||||
<label class="ai-field__label" for="folderPath">資料夾路徑</label>
|
||||
<input type="text" class="ai-field__input form-control" id="folderPath" autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label class="ai-field__label" for="filePattern">檔名模式</label>
|
||||
<input type="text" class="ai-field__input form-control" id="filePattern" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="ai-toolbar">
|
||||
<button class="btn btn-primary" type="button" onclick="saveConfig()"><i class="fas fa-floppy-disk me-1"></i>儲存</button>
|
||||
<button class="btn btn-secondary" type="button" onclick="testConnection()"><i class="fas fa-plug me-1"></i>測試</button>
|
||||
<button class="btn btn-secondary" type="button" onclick="listFiles()"><i class="fas fa-list me-1"></i>檔案</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</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>重置卡住的任務
|
||||
<section class="ai-section" aria-labelledby="jobsTitle">
|
||||
<header class="ai-section__head">
|
||||
<div>
|
||||
<span class="ai-section__eyebrow">驗證紀錄</span>
|
||||
<h2 class="ai-section__title" id="jobsTitle">匯入紀錄</h2>
|
||||
</div>
|
||||
<button class="ai-icon-button" type="button" onclick="resetStuckJobs()" title="重置逾時任務" aria-label="重置逾時任務">
|
||||
<i class="fas fa-clock-rotate-left"></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 id="jobsLoading" class="ai-loading" hidden>
|
||||
<div class="spinner-border ai-loading__spinner" role="status"><span class="visually-hidden">載入中</span></div>
|
||||
</div>
|
||||
<div id="jobsEmpty" class="ai-empty" hidden>
|
||||
<i class="fas fa-inbox ai-empty__icon" aria-hidden="true"></i>
|
||||
<p class="ai-empty__text">尚無匯入紀錄</p>
|
||||
</div>
|
||||
<div id="jobsTableContainer" class="table-responsive" hidden>
|
||||
<table class="ai-jobtable" id="jobsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>來源檔案</th>
|
||||
<th>狀態</th>
|
||||
<th>進度</th>
|
||||
<th>筆數</th>
|
||||
<th>完成時間</th>
|
||||
<th>驗證結果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="jobsTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// ───── 配置:載入 / 儲存 ─────
|
||||
const SOURCE_LABELS = {
|
||||
google_drive: 'Google Drive',
|
||||
authorized_https: '授權 HTTPS',
|
||||
authorized_imap: '授權信箱',
|
||||
controlled_local_drop: '受控落地目錄'
|
||||
};
|
||||
|
||||
function escapeHtml(value) {
|
||||
const node = document.createElement('div');
|
||||
node.textContent = String(value || '');
|
||||
return node.innerHTML;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '--';
|
||||
return new Date(value).toLocaleString('zh-TW', {
|
||||
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function setAlert(elementId, type, message) {
|
||||
const element = document.getElementById(elementId);
|
||||
element.className = `ai-inline-status ai-inline-status--${type}`;
|
||||
element.innerHTML = message;
|
||||
element.hidden = false;
|
||||
}
|
||||
|
||||
function sourceStatusLabel(source) {
|
||||
if (source.ready) return '可用';
|
||||
if (!source.enabled) return '未啟用';
|
||||
return source.status === 'incomplete' ? '設定未完整' : source.status === 'path_missing' ? '路徑不存在' : '不可用';
|
||||
}
|
||||
|
||||
function receiptStatusLabel(status) {
|
||||
return ({
|
||||
completed: '完成',
|
||||
completed_no_write: '無需更新',
|
||||
partial: '部分完成',
|
||||
blocked_with_safe_next_action: '等待來源',
|
||||
degraded_no_write: '來源異常',
|
||||
failed_rolled_back: '已回滾'
|
||||
})[status] || '已記錄';
|
||||
}
|
||||
|
||||
function renderReadiness(payload) {
|
||||
const freshness = payload.freshness || {};
|
||||
const coverage = payload.asset_coverage || {};
|
||||
const receipt = payload.last_receipt || null;
|
||||
const lag = freshness.data_lag_days;
|
||||
document.getElementById('freshnessValue').textContent = freshness.latest_sales_date || '無資料';
|
||||
document.getElementById('freshnessMeta').textContent = lag === null || lag === undefined ? '尚未建立基準' : lag === 0 ? '今天已更新' : `落後 ${lag} 天`;
|
||||
document.getElementById('closureValue').textContent = freshness.decision_ready ? '可決策' : '受阻';
|
||||
document.getElementById('closureValue').dataset.state = freshness.decision_ready ? 'ready' : 'blocked';
|
||||
document.getElementById('closureMeta').textContent = freshness.decision_ready ? '新鮮度守門通過' : '等待最新業績';
|
||||
document.getElementById('sourceCoverageValue').textContent = `${coverage.ready || 0}/${coverage.total || 0}`;
|
||||
document.getElementById('sourceCoverageMeta').textContent = `${coverage.percent || 0}% 來源可用`;
|
||||
document.getElementById('lastRunValue').textContent = receipt ? formatTime(receipt.completed_at) : '尚無執行';
|
||||
document.getElementById('lastRunMeta').textContent = receipt ? receiptStatusLabel(receipt.status) : '尚無執行紀錄';
|
||||
document.getElementById('pageRuntimeSummary').textContent = freshness.decision_ready
|
||||
? '正式業績可供分析與決策'
|
||||
: `正式業績${lag === null || lag === undefined ? '尚未建立' : `已落後 ${lag} 天`}`;
|
||||
|
||||
const list = document.getElementById('sourceList');
|
||||
list.innerHTML = (payload.sources || []).map(source => `
|
||||
<div class="ai-source-row">
|
||||
<span class="ai-source-row__indicator ai-source-row__indicator--${source.ready ? 'ready' : source.enabled ? 'degraded' : 'disabled'}" aria-hidden="true"></span>
|
||||
<span class="ai-source-row__name">${escapeHtml(SOURCE_LABELS[source.id] || source.id)}</span>
|
||||
<span class="ai-source-row__state">${escapeHtml(sourceStatusLabel(source))}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadReadiness() {
|
||||
try {
|
||||
const response = await fetch('/api/auto_import/readiness');
|
||||
const payload = await response.json();
|
||||
if (!response.ok || !payload.success) throw new Error(payload.message || '狀態讀取失敗');
|
||||
renderReadiness(payload);
|
||||
} catch (error) {
|
||||
document.getElementById('pageRuntimeSummary').textContent = '正式狀態暫時無法讀取';
|
||||
setAlert('automationAlert', 'danger', '<i class="fas fa-triangle-exclamation" aria-hidden="true"></i>狀態讀取失敗');
|
||||
}
|
||||
}
|
||||
|
||||
async function runAutomation() {
|
||||
const button = document.getElementById('runAutomationButton');
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<i class="fas fa-spinner fa-spin me-1" aria-hidden="true"></i>執行中';
|
||||
setAlert('automationAlert', 'info', '<i class="fas fa-arrows-rotate fa-spin" aria-hidden="true"></i>正在探測授權來源並執行交易式驗證');
|
||||
try {
|
||||
const response = await fetch('/api/auto_import/run', { method: 'POST' });
|
||||
const payload = await response.json();
|
||||
const type = payload.success ? 'success' : payload.status === 'blocked_with_safe_next_action' ? 'warning' : 'danger';
|
||||
setAlert('automationAlert', type, `<i class="fas fa-${payload.success ? 'circle-check' : 'circle-exclamation'}" aria-hidden="true"></i>${escapeHtml(payload.message)}`);
|
||||
await Promise.all([loadReadiness(), loadJobs()]);
|
||||
} catch (error) {
|
||||
setAlert('automationAlert', 'danger', '<i class="fas fa-triangle-exclamation" aria-hidden="true"></i>自動取得執行失敗');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.innerHTML = '<i class="fas fa-play me-1" aria-hidden="true"></i>執行自動取得';
|
||||
}
|
||||
}
|
||||
|
||||
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 || '';
|
||||
const response = await fetch('/api/import_config');
|
||||
const payload = await response.json();
|
||||
if (payload.success) {
|
||||
document.getElementById('folderPath').value = payload.data.folder_path || '';
|
||||
document.getElementById('filePattern').value = payload.data.file_pattern || '';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('載入配置失敗:', e);
|
||||
} catch (error) {
|
||||
console.error('Drive config read failed', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const folderPath = document.getElementById('folderPath').value;
|
||||
const filePattern = document.getElementById('filePattern').value;
|
||||
try {
|
||||
const r = await fetch('/api/import_config', {
|
||||
const response = await fetch('/api/import_config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath, file_pattern: filePattern })
|
||||
body: JSON.stringify({
|
||||
folder_path: document.getElementById('folderPath').value,
|
||||
file_pattern: document.getElementById('filePattern').value
|
||||
})
|
||||
});
|
||||
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);
|
||||
const payload = await response.json();
|
||||
setAlert('configAlert', payload.success ? 'success' : 'danger', escapeHtml(payload.message));
|
||||
if (payload.success) loadReadiness();
|
||||
} catch (error) {
|
||||
setAlert('configAlert', 'danger', '設定儲存失敗');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
const response = await fetch('/api/test_drive_connection', { method: 'POST' });
|
||||
const payload = await response.json();
|
||||
setAlert('configAlert', payload.success ? 'success' : 'danger', escapeHtml(payload.message));
|
||||
loadReadiness();
|
||||
} catch (error) {
|
||||
setAlert('configAlert', 'danger', 'Drive 測試失敗');
|
||||
}
|
||||
}
|
||||
|
||||
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', {
|
||||
const response = await fetch('/api/list_drive_files', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath, file_pattern: filePattern })
|
||||
body: JSON.stringify({
|
||||
folder_path: document.getElementById('folderPath').value,
|
||||
file_pattern: document.getElementById('filePattern').value
|
||||
})
|
||||
});
|
||||
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);
|
||||
const payload = await response.json();
|
||||
setAlert('configAlert', payload.success ? 'success' : 'danger', payload.success ? `待處理檔案 ${payload.count} 個` : escapeHtml(payload.message));
|
||||
} catch (error) {
|
||||
setAlert('configAlert', 'danger', 'Drive 檔案讀取失敗');
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
function jobStatusText(status) {
|
||||
return ({ pending: '等待', downloading: '取得', importing: '驗證', completed: '完成', failed: '失敗' })[status] || status;
|
||||
}
|
||||
|
||||
// ───── 任務列表 ─────
|
||||
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 jobResult(job) {
|
||||
if (job.status === 'completed') return '雙表已驗證';
|
||||
if (job.status === 'failed') return job.display_error_message || '正式資料未變更';
|
||||
return job.current_step || '執行中';
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
const body = document.getElementById('jobsTableBody');
|
||||
body.innerHTML = jobs.map(job => {
|
||||
const running = ['pending', 'downloading', 'importing'].includes(job.status);
|
||||
const progress = Math.max(0, Math.min(100, Number(job.progress_percent || 0)));
|
||||
return `
|
||||
<tr>
|
||||
<td><span class="ai-jobtable__file"><i class="fas fa-file-excel" aria-hidden="true"></i>${escapeHtml(job.drive_file_name || '未記錄檔名')}</span></td>
|
||||
<td><span class="ai-status ai-status--${escapeHtml(job.status)}">${escapeHtml(jobStatusText(job.status))}</span></td>
|
||||
<td>
|
||||
<div class="ai-progress"><div class="ai-progress__bar" style="width:${progress}%" role="progressbar" aria-valuenow="${progress}" aria-valuemin="0" aria-valuemax="100"></div></div>
|
||||
<span class="ai-progress__label">${Math.round(progress)}%</span>
|
||||
</td>
|
||||
<td><span class="ai-count ai-count--ok">${job.success_rows || 0}</span><span class="ai-count-separator">/</span><span class="ai-count ai-count--total">${job.total_rows || 0}</span></td>
|
||||
<td><span class="ai-jobtable__time">${formatTime(job.completed_at)}</span></td>
|
||||
<td class="ai-jobtable__error">${escapeHtml(jobResult(job))}${running ? `<button class="ai-row-action" onclick="failJob(${job.id})" title="取消任務" aria-label="取消任務"><i class="fas fa-xmark"></i></button>` : ''}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
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>正在上傳並匯入檔案,請稍候…');
|
||||
async function loadJobs() {
|
||||
const loading = document.getElementById('jobsLoading');
|
||||
const empty = document.getElementById('jobsEmpty');
|
||||
const table = document.getElementById('jobsTableContainer');
|
||||
loading.hidden = false;
|
||||
empty.hidden = true;
|
||||
table.hidden = true;
|
||||
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);
|
||||
const response = await fetch('/api/import_jobs?limit=20');
|
||||
const payload = await response.json();
|
||||
const jobs = payload.success ? payload.data : [];
|
||||
if (jobs.length) {
|
||||
renderJobs(jobs);
|
||||
table.hidden = false;
|
||||
} else {
|
||||
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||||
empty.hidden = false;
|
||||
}
|
||||
} catch (e) {
|
||||
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>上傳失敗:' + e.message);
|
||||
} catch (error) {
|
||||
empty.hidden = false;
|
||||
} finally {
|
||||
loading.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const response = await fetch('/api/reset_stuck_jobs', { method: 'POST' });
|
||||
const payload = await response.json();
|
||||
setAlert('automationAlert', payload.success ? 'success' : 'danger', escapeHtml(payload.message));
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
await fetch(`/api/import_jobs/${jobId}/fail`, { method: 'POST' });
|
||||
loadJobs();
|
||||
}
|
||||
|
||||
// ───── 初始化 ─────
|
||||
loadConfig();
|
||||
loadJobs();
|
||||
async function loadAll() {
|
||||
await Promise.all([loadReadiness(), loadConfig(), loadJobs()]);
|
||||
}
|
||||
|
||||
loadAll();
|
||||
setInterval(loadJobs, 10000);
|
||||
setInterval(loadReadiness, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user