Some checks failed
CD Pipeline / deploy (push) Failing after 2m59s
P0-06: google_drive_service.py — pickle.load() 改 JSON token(消除 RCE 風險) P0-07: bot_api_routes.py:30 — BOT_API_TOKEN 移除硬編碼預設值 clawdbot_momo_2026 P0-08: auto_import_index.html — showAlert innerHTML 改 createTextNode(XSS 修復) P0-09: abc_analysis_detail.html + dashboard.html + daily_sales.html — Jinja2 | e 轉義 P0-10: openclaw_bot_routes.py:2634 — vendor PPT 補 return ppt_path(廠商報告恢復) P0-11: telegram_bot_service.py:177-214 — cmd_start/cmd_help 補 try/except P0-12: app.py:689-712 — 10 個 Blueprint 補齊 register(消滅 404 路由) P0-13: auto_heal_service.py — 實作 _write_heal_log(),AIOps 稽核閉環補完 P0-14: monitoring/prometheus.yml — 取消 alert_rules comment;新增 alert_rules.yml Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
738 lines
27 KiB
HTML
738 lines
27 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-TW">
|
||
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||
<title>當日業績報表匯入 - WOOO TECH</title>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||
<style>
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf1 100%);
|
||
min-height: 100vh;
|
||
padding-top: 70px;
|
||
}
|
||
|
||
.navbar-dark.bg-primary {
|
||
background: linear-gradient(135deg, #4F46E5 0%, #6366F1 100%) !important;
|
||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.3);
|
||
}
|
||
|
||
.navbar-dark .navbar-brand {
|
||
color: #ffffff !important;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.navbar-dark .navbar-nav .nav-link {
|
||
color: rgba(255, 255, 255, 0.9) !important;
|
||
font-weight: 500;
|
||
transition: all 0.3s;
|
||
}
|
||
|
||
.navbar-dark .navbar-nav .nav-link:hover {
|
||
color: #ffffff !important;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.navbar-dark .navbar-nav .nav-link.active {
|
||
color: #ffffff !important;
|
||
background: rgba(255, 255, 255, 0.15);
|
||
border-radius: 6px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.navbar-dark .navbar-text {
|
||
color: rgba(255, 255, 255, 0.8) !important;
|
||
}
|
||
|
||
.navbar {
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%) !important;
|
||
}
|
||
|
||
.card {
|
||
border: none;
|
||
border-radius: 16px;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
|
||
margin-bottom: 1.5rem;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.card:hover {
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.card-header {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 16px 16px 0 0 !important;
|
||
padding: 1.2rem 1.5rem;
|
||
}
|
||
|
||
.card-header h5 {
|
||
margin: 0;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border: none;
|
||
border-radius: 10px;
|
||
padding: 0.6rem 1.5rem;
|
||
font-weight: 600;
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
|
||
background: linear-gradient(135deg, #5568d3 0%, #6a3e8b 100%);
|
||
}
|
||
|
||
.btn-secondary {
|
||
border-radius: 10px;
|
||
padding: 0.6rem 1.5rem;
|
||
font-weight: 600;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn-success {
|
||
background: linear-gradient(135deg, #51cf66 0%, #37b24d 100%);
|
||
border: none;
|
||
border-radius: 10px;
|
||
padding: 0.6rem 1.5rem;
|
||
font-weight: 600;
|
||
box-shadow: 0 4px 12px rgba(81, 207, 102, 0.3);
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.btn-success:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 6px 16px rgba(81, 207, 102, 0.4);
|
||
background: linear-gradient(135deg, #40c057 0%, #2f9e44 100%);
|
||
}
|
||
|
||
.table {
|
||
background: white;
|
||
width: 100%;
|
||
}
|
||
|
||
.table thead th {
|
||
background: #f8f9fa;
|
||
border-bottom: 2px solid #dee2e6;
|
||
color: #495057;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
font-size: 0.75rem;
|
||
letter-spacing: 0.5px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.table tbody td {
|
||
vertical-align: middle;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.table-responsive {
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
|
||
/* 錯誤訊息可換行 */
|
||
.table tbody td.error-cell {
|
||
white-space: normal;
|
||
max-width: 250px;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.badge {
|
||
padding: 0.4rem 0.8rem;
|
||
font-weight: 500;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.badge-pending {
|
||
background: #fff3cd;
|
||
color: #856404;
|
||
}
|
||
|
||
.badge-downloading {
|
||
background: #d1ecf1;
|
||
color: #0c5460;
|
||
}
|
||
|
||
.badge-importing {
|
||
background: #cce5ff;
|
||
color: #004085;
|
||
}
|
||
|
||
.badge-completed {
|
||
background: #d4edda;
|
||
color: #155724;
|
||
}
|
||
|
||
.badge-failed {
|
||
background: #f8d7da;
|
||
color: #721c24;
|
||
}
|
||
|
||
.progress {
|
||
height: 8px;
|
||
border-radius: 4px;
|
||
background: #e9ecef;
|
||
}
|
||
|
||
.progress-bar {
|
||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||
}
|
||
|
||
.form-label {
|
||
font-weight: 600;
|
||
color: #495057;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
|
||
.form-control:focus {
|
||
border-color: #667eea;
|
||
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||
}
|
||
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 3rem 1rem;
|
||
color: #6c757d;
|
||
}
|
||
|
||
.empty-state i {
|
||
font-size: 3rem;
|
||
margin-bottom: 1rem;
|
||
opacity: 0.3;
|
||
}
|
||
|
||
/* Custom Dark Gray Navbar */
|
||
.navbar.bg-custom-dark {
|
||
background: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-brand {
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-nav .nav-link {
|
||
color: rgba(255, 255, 255, 0.85);
|
||
font-weight: 500;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-nav .nav-link:hover {
|
||
color: #ffffff;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-nav .nav-link.active {
|
||
color: #ffffff;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.navbar.bg-custom-dark .navbar-text {
|
||
color: rgba(255, 255, 255, 0.75);
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body class="bg-body-tertiary">
|
||
<!-- 導航列 -->
|
||
{% include 'components/_navbar.html' %}
|
||
|
||
<div class="container">
|
||
<!-- 頁面標題 -->
|
||
<div class="row mb-4">
|
||
<div class="col">
|
||
<h2><i class="fas fa-cloud-download-alt me-2"></i>當日業績報表匯入</h2>
|
||
<p class="text-muted">
|
||
<i class="fas fa-info-circle me-1"></i>
|
||
支援兩種匯入方式:<strong>Google Drive 自動匯入</strong>(每 30 分鐘檢查)或 <strong>手動上傳</strong>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 配置區 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5><i class="fas fa-cog me-2"></i>Google Drive 自動匯入配置</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="configAlert"></div>
|
||
|
||
<div class="alert alert-light border">
|
||
<p class="mb-0 small">
|
||
<i class="fas fa-sync-alt me-1"></i>
|
||
系統每 30 分鐘自動檢查 Google Drive → 下載檔案 → 匯入資料庫 → 刪除雲端原檔
|
||
</p>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div class="col-md-6 mb-3">
|
||
<label for="folderPath" class="form-label">Google Drive 資料夾路徑</label>
|
||
<input type="text" class="form-control" id="folderPath" placeholder="例如: 業績報表/當日業績">
|
||
<small class="text-muted">設定要監控的 Google Drive 資料夾路徑</small>
|
||
</div>
|
||
<div class="col-md-6 mb-3">
|
||
<label for="filePattern" class="form-label">檔案名稱模式(選填)</label>
|
||
<input type="text" class="form-control" id="filePattern" placeholder="例如: 即時業績_當日">
|
||
<small class="text-muted">用於過濾特定名稱的檔案</small>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="d-flex flex-wrap gap-2">
|
||
<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>
|
||
</div>
|
||
|
||
<!-- 手動上傳區 -->
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<h5><i class="fas fa-upload me-2"></i>手動上傳匯入</h5>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="uploadAlert"></div>
|
||
|
||
<div class="alert alert-info">
|
||
<h6 class="fw-bold mb-2"><i class="fas fa-info-circle me-2"></i>每日業績快照</h6>
|
||
<p class="mb-1">匯入格式:<code>即時業績_當日_YYYYMMDD.xlsx</code>(例如:即時業績_當日_20260113.xlsx)</p>
|
||
<p class="mb-0 small">資料將會<strong>累加寫入</strong>至 <code>daily_sales_snapshot</code> 資料表,並自動去重。</p>
|
||
</div>
|
||
|
||
<div class="row align-items-end">
|
||
<div class="col-md-8 mb-3 mb-md-0">
|
||
<label for="manualUploadFile" class="form-label">選擇檔案</label>
|
||
<input type="file" class="form-control" id="manualUploadFile" accept=".xlsx,.xls">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<button class="btn btn-primary w-100" onclick="uploadManualFile()">
|
||
<i class="fas fa-upload me-1"></i>上傳並匯入
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 匯入任務清單 -->
|
||
<div class="card">
|
||
<div class="card-header d-flex justify-content-between align-items-center">
|
||
<h5 class="mb-0"><i class="fas fa-tasks me-2"></i>匯入任務歷史</h5>
|
||
<button class="btn btn-warning btn-sm" onclick="resetStuckJobs()">
|
||
<i class="fas fa-redo me-1"></i>重置卡住的任務
|
||
</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div id="jobsLoading" class="text-center py-4" style="display: none;">
|
||
<div class="spinner-border text-primary" role="status">
|
||
<span class="visually-hidden">載入中...</span>
|
||
</div>
|
||
<p class="mt-2 text-muted">載入中...</p>
|
||
</div>
|
||
|
||
<div id="jobsEmpty" class="empty-state" style="display: none;">
|
||
<i class="fas fa-inbox"></i>
|
||
<p>尚無匯入記錄</p>
|
||
</div>
|
||
|
||
<div id="jobsTableContainer" style="display: none;">
|
||
<div class="table-responsive">
|
||
<table class="table table-hover align-middle" 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>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script>
|
||
// 載入配置
|
||
async function loadConfig() {
|
||
try {
|
||
const response = await fetch('/api/import_config');
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
document.getElementById('folderPath').value = result.data.folder_path || '';
|
||
document.getElementById('filePattern').value = result.data.file_pattern || '';
|
||
}
|
||
} catch (error) {
|
||
console.error('載入配置失敗:', error);
|
||
}
|
||
}
|
||
|
||
// 儲存配置
|
||
async function saveConfig() {
|
||
const folderPath = document.getElementById('folderPath').value;
|
||
const filePattern = document.getElementById('filePattern').value;
|
||
|
||
try {
|
||
const response = await fetch('/api/import_config', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
folder_path: folderPath,
|
||
file_pattern: filePattern
|
||
})
|
||
});
|
||
|
||
const result = await response.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 (error) {
|
||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>儲存配置失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 測試連接
|
||
async function testConnection() {
|
||
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在測試 Google Drive 連接...');
|
||
|
||
try {
|
||
const response = await fetch('/api/test_drive_connection', {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showAlert('configAlert', 'success', '<i class="fas fa-check-circle me-2"></i>' + result.message);
|
||
} else {
|
||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||
}
|
||
} catch (error) {
|
||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>測試失敗: ' + error.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 response = 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 response.json();
|
||
|
||
if (result.success) {
|
||
const fileList = 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">${fileList || '<li>無</li>'}</ul>`);
|
||
} else {
|
||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||
}
|
||
} catch (error) {
|
||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>列出檔案失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 手動匯入
|
||
async function manualImport() {
|
||
if (!confirm('確定要立即執行匯入嗎?')) {
|
||
return;
|
||
}
|
||
|
||
showAlert('configAlert', 'info', '<i class="fas fa-spinner fa-spin me-2"></i>正在執行匯入,請稍候...');
|
||
|
||
try {
|
||
const response = await fetch('/api/manual_import', {
|
||
method: 'POST'
|
||
});
|
||
|
||
const result = await response.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 (error) {
|
||
showAlert('configAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>匯入失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 載入任務列表
|
||
async function loadJobs() {
|
||
document.getElementById('jobsLoading').style.display = 'block';
|
||
document.getElementById('jobsEmpty').style.display = 'none';
|
||
document.getElementById('jobsTableContainer').style.display = 'none';
|
||
|
||
try {
|
||
const response = await fetch('/api/import_jobs?limit=50');
|
||
const result = await response.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 (error) {
|
||
document.getElementById('jobsLoading').style.display = 'none';
|
||
document.getElementById('jobsEmpty').style.display = 'block';
|
||
console.error('載入任務列表失敗:', error);
|
||
}
|
||
}
|
||
|
||
// 渲染任務列表
|
||
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 || '-';
|
||
|
||
tr.innerHTML = `
|
||
<td class="fw-bold">#${job.id}</td>
|
||
<td>
|
||
<i class="fas fa-file-excel text-success me-1"></i>${escapeHtml(fileName)}
|
||
</td>
|
||
<td>
|
||
<span class="badge badge-${job.status}">${getStatusText(job.status)}</span>
|
||
</td>
|
||
<td style="min-width: 150px;">
|
||
<div class="progress mb-1">
|
||
<div class="progress-bar" role="progressbar" style="width: ${job.progress_percent}%"
|
||
aria-valuenow="${job.progress_percent}" aria-valuemin="0" aria-valuemax="100">
|
||
</div>
|
||
</div>
|
||
<small class="text-muted">${Math.round(job.progress_percent)}% - ${escapeHtml(job.current_step || '')}</small>
|
||
</td>
|
||
<td class="text-center">
|
||
<span class="badge bg-success">${job.success_rows || 0}</span> /
|
||
<span class="badge bg-secondary">${job.total_rows || 0}</span>
|
||
</td>
|
||
<td><small>${formatTime(job.started_at)}</small></td>
|
||
<td><small>${formatTime(job.completed_at)}</small></td>
|
||
<td class="error-cell"><small class="text-danger">${escapeHtml(errorMsg)}</small></td>
|
||
<td class="text-center">
|
||
${(job.status === 'downloading' || job.status === 'importing')
|
||
? `<button class="btn btn-sm btn-outline-danger" onclick="failJob(${job.id})">
|
||
<i class="fas fa-times"></i>
|
||
</button>`
|
||
: '-'}
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
// 轉義 HTML 特殊字元
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// 取得狀態文字
|
||
function getStatusText(status) {
|
||
const statusMap = {
|
||
'pending': '⏳ 等待中',
|
||
'downloading': '⬇️ 下載中',
|
||
'importing': '📥 匯入中',
|
||
'completed': '✅ 已完成',
|
||
'failed': '❌ 失敗'
|
||
};
|
||
return statusMap[status] || status;
|
||
}
|
||
|
||
// 格式化時間
|
||
function formatTime(timeStr) {
|
||
if (!timeStr) return '-';
|
||
const date = new Date(timeStr);
|
||
return date.toLocaleString('zh-TW', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
}
|
||
|
||
// 顯示提示
|
||
function showAlert(elementId, type, message) {
|
||
const alertDiv = document.getElementById(elementId);
|
||
// 使用 DOM API 建構元素,避免 XSS(禁止直接 innerHTML 插入 message)
|
||
alertDiv.innerHTML = '';
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = `alert alert-${type} alert-dismissible fade show`;
|
||
wrapper.setAttribute('role', 'alert');
|
||
const msgNode = document.createTextNode(message);
|
||
wrapper.appendChild(msgNode);
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.type = 'button';
|
||
closeBtn.className = 'btn-close';
|
||
closeBtn.setAttribute('data-bs-dismiss', 'alert');
|
||
closeBtn.setAttribute('aria-label', 'Close');
|
||
wrapper.appendChild(closeBtn);
|
||
alertDiv.appendChild(wrapper);
|
||
|
||
if (type === 'success' || type === 'danger') {
|
||
setTimeout(() => {
|
||
const alert = alertDiv.querySelector('.alert');
|
||
if (alert) {
|
||
const bsAlert = new bootstrap.Alert(alert);
|
||
bsAlert.close();
|
||
}
|
||
}, 5000);
|
||
}
|
||
}
|
||
|
||
// 手動上傳檔案
|
||
async function uploadManualFile() {
|
||
const fileInput = document.getElementById('manualUploadFile');
|
||
const file = fileInput.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 response = await fetch('/api/import_excel', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'success') {
|
||
showAlert('uploadAlert', 'success',
|
||
`<i class="fas fa-check-circle me-2"></i>${result.message}<br>` +
|
||
`<small>資料表: ${result.table} | 共 ${result.rows} 筆資料</small>`
|
||
);
|
||
fileInput.value = '';
|
||
setTimeout(() => loadJobs(), 1000);
|
||
} else {
|
||
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>' + result.message);
|
||
}
|
||
} catch (error) {
|
||
showAlert('uploadAlert', 'danger', '<i class="fas fa-exclamation-circle me-2"></i>上傳失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 初始化
|
||
loadConfig();
|
||
loadJobs();
|
||
|
||
// 每 10 秒自動刷新任務列表
|
||
setInterval(loadJobs, 10000);
|
||
|
||
// 重置卡住的任務
|
||
async function resetStuckJobs() {
|
||
if (!confirm('確定要重置所有卡住超過 1 小時的任務嗎?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/reset_stuck_jobs', { method: 'POST' });
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
alert(result.message);
|
||
loadJobs();
|
||
} else {
|
||
alert('重置失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('重置失敗: ' + error.message);
|
||
}
|
||
}
|
||
|
||
// 手動將任務標記為失敗
|
||
async function failJob(jobId) {
|
||
if (!confirm(`確定要取消任務 #${jobId} 嗎?`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`/api/import_jobs/${jobId}/fail`, { method: 'POST' });
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
alert(result.message);
|
||
loadJobs();
|
||
} else {
|
||
alert('取消失敗: ' + result.message);
|
||
}
|
||
} catch (error) {
|
||
alert('取消失敗: ' + error.message);
|
||
}
|
||
}
|
||
</script>
|
||
</body>
|
||
|
||
</html> |