Files
ewoooc/auto_import_index.html
ogt 237d3af76f
Some checks failed
CD Pipeline / deploy (push) Failing after 2m59s
fix: Phase 2 P0 全清零 — 14 項安全與功能修復完成
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>
2026-04-27 21:11:52 +08:00

738 lines
27 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.
<!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>