Files
ewoooc/templates/auto_import_index.html
ogt 6e65ef00bb
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
feat(pchome): automate authorized sales acquisition
2026-07-11 01:30:42 +08:00

368 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-arrows-rotate"></i></span>
<div class="ai-head__main">
<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-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>
<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-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-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 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 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 (error) {
console.error('Drive config read failed', error);
}
}
async function saveConfig() {
try {
const response = await fetch('/api/import_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
folder_path: document.getElementById('folderPath').value,
file_pattern: document.getElementById('filePattern').value
})
});
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() {
try {
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() {
try {
const response = await fetch('/api/list_drive_files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
folder_path: document.getElementById('folderPath').value,
file_pattern: document.getElementById('filePattern').value
})
});
const payload = await response.json();
setAlert('configAlert', payload.success ? 'success' : 'danger', payload.success ? `待處理檔案 ${payload.count}` : escapeHtml(payload.message));
} catch (error) {
setAlert('configAlert', 'danger', 'Drive 檔案讀取失敗');
}
}
function jobStatusText(status) {
return ({ pending: '等待', downloading: '取得', importing: '驗證', completed: '完成', failed: '失敗' })[status] || status;
}
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 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('');
}
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 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 {
empty.hidden = false;
}
} catch (error) {
empty.hidden = false;
} finally {
loading.hidden = true;
}
}
async function resetStuckJobs() {
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) {
await fetch(`/api/import_jobs/${jobId}/fail`, { method: 'POST' });
loadJobs();
}
async function loadAll() {
await Promise.all([loadReadiness(), loadConfig(), loadJobs()]);
}
loadAll();
setInterval(loadJobs, 10000);
setInterval(loadReadiness, 30000);
</script>
{% endblock %}