3000 lines
112 KiB
JavaScript
3000 lines
112 KiB
JavaScript
/**
|
||
* CapacityReport - 容量报表处理程序
|
||
* 前端 JavaScript - TDesign 风格
|
||
*/
|
||
|
||
// ==================== 工具函数 ====================
|
||
|
||
function $(selector) {
|
||
return document.querySelector(selector);
|
||
}
|
||
|
||
function $$(selector) {
|
||
return document.querySelectorAll(selector);
|
||
}
|
||
|
||
function formatFileSize(bytes) {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||
}
|
||
|
||
function formatDateTime(isoString) {
|
||
const date = new Date(isoString);
|
||
return date.toLocaleString('zh-CN');
|
||
}
|
||
|
||
function getFileIcon(filename) {
|
||
const ext = filename.split('.').pop().toLowerCase();
|
||
const icons = {
|
||
'xlsx': '📊',
|
||
'xls': '📊',
|
||
'csv': '📋',
|
||
'zip': '📦'
|
||
};
|
||
return icons[ext] || '📄';
|
||
}
|
||
|
||
// Toast 通知
|
||
function showToast(message, type = 'info') {
|
||
const container = $('#toastContainer');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
|
||
const icons = {
|
||
'success': '✓',
|
||
'error': '✕',
|
||
'warning': '!',
|
||
'info': 'i'
|
||
};
|
||
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${icons[type] || 'i'}</span>
|
||
<span class="toast-message">${message}</span>
|
||
<button class="toast-close" aria-label="关闭">×</button>
|
||
`;
|
||
container.appendChild(toast);
|
||
|
||
// 触发重排,确保动画生效
|
||
toast.offsetHeight;
|
||
|
||
// 关闭函数
|
||
const closeToast = () => {
|
||
if (toast.timeoutId) {
|
||
clearTimeout(toast.timeoutId);
|
||
}
|
||
toast.style.animation = 'toastOut 0.3s ease forwards';
|
||
setTimeout(() => toast.remove(), 300);
|
||
};
|
||
|
||
// 绑定关闭按钮事件
|
||
const closeBtn = toast.querySelector('.toast-close');
|
||
closeBtn.addEventListener('click', closeToast);
|
||
|
||
// 自动关闭
|
||
toast.timeoutId = setTimeout(closeToast, 3000);
|
||
}
|
||
|
||
// 确认对话框
|
||
function showConfirm(title, message) {
|
||
return new Promise((resolve) => {
|
||
const modal = $('#confirmModal');
|
||
$('#confirmTitle').textContent = title;
|
||
$('#confirmMessage').textContent = message;
|
||
modal.classList.add('active');
|
||
|
||
const handleOk = () => {
|
||
modal.classList.remove('active');
|
||
cleanup();
|
||
resolve(true);
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
modal.classList.remove('active');
|
||
cleanup();
|
||
resolve(false);
|
||
};
|
||
|
||
const cleanup = () => {
|
||
$('#confirmOk').removeEventListener('click', handleOk);
|
||
$('#confirmCancel').removeEventListener('click', handleCancel);
|
||
modal.querySelector('.modal-backdrop').removeEventListener('click', handleCancel);
|
||
};
|
||
|
||
$('#confirmOk').addEventListener('click', handleOk);
|
||
$('#confirmCancel').addEventListener('click', handleCancel);
|
||
modal.querySelector('.modal-backdrop').addEventListener('click', handleCancel);
|
||
});
|
||
}
|
||
|
||
// API 调用
|
||
async function api(endpoint, options = {}) {
|
||
// 如果没有指定 method 且没有 body,默认使用 GET
|
||
const method = options.method || (options.body ? 'POST' : 'GET');
|
||
|
||
const token = localStorage.getItem('token');
|
||
|
||
const fetchOptions = {
|
||
method: method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||
...options.headers
|
||
},
|
||
...options
|
||
};
|
||
|
||
// GET 请求不应该有 body
|
||
if (method === 'GET' && fetchOptions.body) {
|
||
delete fetchOptions.body;
|
||
}
|
||
|
||
const response = await fetch(`/api${endpoint}`, fetchOptions);
|
||
|
||
if (response.status === 401 && endpoint !== '/login') {
|
||
localStorage.removeItem('token');
|
||
showLoginModal();
|
||
throw new Error('未授权或登录已过期,请重新登录');
|
||
}
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({ detail: response.statusText }));
|
||
throw new Error(error.detail || '请求失败');
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
function showLoginModal() {
|
||
localStorage.removeItem('token');
|
||
document.cookie = 'token=; path=/; max-age=0';
|
||
window.location.href = '/';
|
||
}
|
||
|
||
function logout() {
|
||
localStorage.removeItem('token');
|
||
document.cookie = 'token=; path=/; max-age=0';
|
||
window.location.href = '/';
|
||
}
|
||
|
||
|
||
// ==================== 主题管理 ====================
|
||
|
||
class ThemeManager {
|
||
constructor() {
|
||
this.theme = localStorage.getItem('theme') || 'light';
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
// 应用保存的主题
|
||
this.apply(this.theme);
|
||
|
||
// 使用事件委托绑定所有主题切换按钮
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.closest('#themeToggle') || e.target.closest('#toggleThemeBtn')) {
|
||
this.toggle();
|
||
}
|
||
});
|
||
|
||
// 监听系统主题变化
|
||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
|
||
if (!localStorage.getItem('theme')) {
|
||
this.apply(e.matches ? 'dark' : 'light');
|
||
}
|
||
});
|
||
}
|
||
|
||
apply(theme) {
|
||
this.theme = theme;
|
||
document.documentElement.setAttribute('data-theme', theme);
|
||
|
||
// 更新设置页面显示
|
||
const themeText = $('#currentTheme');
|
||
if (themeText) {
|
||
themeText.textContent = theme === 'dark' ? '深色模式' : '浅色模式';
|
||
}
|
||
}
|
||
|
||
toggle() {
|
||
const newTheme = this.theme === 'dark' ? 'light' : 'dark';
|
||
this.apply(newTheme);
|
||
localStorage.setItem('theme', newTheme);
|
||
showToast(`已切换到${newTheme === 'dark' ? '深色' : '浅色'}模式`, 'success');
|
||
}
|
||
}
|
||
|
||
|
||
// ==================== 页面导航 ====================
|
||
|
||
class Navigation {
|
||
constructor() {
|
||
this.currentPage = 'upload';
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
$$('.nav-item').forEach(item => {
|
||
item.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const page = item.dataset.page;
|
||
this.navigate(page);
|
||
});
|
||
});
|
||
}
|
||
|
||
navigate(page) {
|
||
// 更新导航状态
|
||
$$('.nav-item').forEach(item => {
|
||
item.classList.toggle('active', item.dataset.page === page);
|
||
});
|
||
|
||
// 切换页面
|
||
$$('.page').forEach(p => {
|
||
p.classList.toggle('active', p.id === `page-${page}`);
|
||
});
|
||
|
||
this.currentPage = page;
|
||
|
||
// 保存当前页面到 localStorage
|
||
localStorage.setItem('currentPage', page);
|
||
|
||
// 触发页面加载事件
|
||
window.dispatchEvent(new CustomEvent('pagechange', { detail: { page } }));
|
||
|
||
// 如果导航到上传页面,检查活动任务
|
||
if (page === 'upload') {
|
||
const uploader = window.fileUploader;
|
||
if (uploader && uploader.checkActiveTask) {
|
||
uploader.checkActiveTask();
|
||
}
|
||
}
|
||
}
|
||
|
||
restorePage() {
|
||
// 从 localStorage 恢复页面
|
||
const savedPage = localStorage.getItem('currentPage');
|
||
console.log('恢复页面:', savedPage);
|
||
if (savedPage) {
|
||
// 验证页面是否存在
|
||
const pageEl = $(`#page-${savedPage}`);
|
||
if (pageEl) {
|
||
this.navigate(savedPage);
|
||
return;
|
||
}
|
||
}
|
||
// 如果没有保存的页面或页面不存在,确保默认显示上传页面
|
||
// HTML 中已经设置了 upload 为默认 active,这里不需要额外操作
|
||
}
|
||
}
|
||
|
||
|
||
// ==================== 文件上传 ====================
|
||
|
||
class FileUploader {
|
||
constructor() {
|
||
this.files = [];
|
||
this.taskId = null;
|
||
this.pollInterval = null;
|
||
this.isUploading = false;
|
||
this.uploadStats = {
|
||
total: 0,
|
||
uploading: 0,
|
||
success: 0,
|
||
error: 0
|
||
};
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
const uploadZone = $('#uploadZone');
|
||
const fileInput = $('#fileInput');
|
||
const fileInputSingle = $('#fileInputSingle');
|
||
|
||
// 拖拽事件
|
||
uploadZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
uploadZone.classList.add('dragover');
|
||
});
|
||
|
||
uploadZone.addEventListener('dragleave', () => {
|
||
uploadZone.classList.remove('dragover');
|
||
});
|
||
|
||
uploadZone.addEventListener('drop', async (e) => {
|
||
e.preventDefault();
|
||
uploadZone.classList.remove('dragover');
|
||
|
||
const items = e.dataTransfer.items;
|
||
const files = [];
|
||
|
||
for (let item of items) {
|
||
if (item.kind === 'file') {
|
||
const entry = item.webkitGetAsEntry();
|
||
if (entry) {
|
||
await this.traverseEntry(entry, files, '');
|
||
}
|
||
}
|
||
}
|
||
|
||
this.addFiles(files);
|
||
});
|
||
|
||
// 文件选择(目录)
|
||
fileInput.addEventListener('change', (e) => {
|
||
const files = Array.from(e.target.files).map(f => ({
|
||
file: f,
|
||
path: f.webkitRelativePath || f.name
|
||
}));
|
||
this.addFiles(files);
|
||
fileInput.value = '';
|
||
});
|
||
|
||
// 点击上传区域时选择文件
|
||
uploadZone.addEventListener('click', (e) => {
|
||
if (e.target === uploadZone || e.target.closest('.upload-icon') || e.target.closest('h3') || e.target.closest('p')) {
|
||
fileInputSingle.click();
|
||
}
|
||
});
|
||
|
||
fileInputSingle.addEventListener('change', (e) => {
|
||
const files = Array.from(e.target.files).map(f => ({
|
||
file: f,
|
||
path: f.name
|
||
}));
|
||
this.addFiles(files);
|
||
fileInputSingle.value = '';
|
||
});
|
||
|
||
// 清空按钮
|
||
$('#clearFiles').addEventListener('click', () => {
|
||
if (!this.isUploading) {
|
||
this.clearFiles();
|
||
}
|
||
});
|
||
|
||
// 上传按钮
|
||
$('#startUpload').addEventListener('click', () => {
|
||
this.upload();
|
||
});
|
||
|
||
// 不在页面加载时自动检查任务,只在用户导航到上传页面时检查
|
||
// 避免无意义的轮询请求
|
||
|
||
// 新建任务
|
||
$('#newProcess').addEventListener('click', () => {
|
||
this.reset();
|
||
});
|
||
}
|
||
|
||
async traverseEntry(entry, files, path) {
|
||
if (entry.isFile) {
|
||
return new Promise((resolve) => {
|
||
entry.file((file) => {
|
||
const fullPath = path ? `${path}/${file.name}` : file.name;
|
||
files.push({ file, path: fullPath });
|
||
resolve();
|
||
});
|
||
});
|
||
} else if (entry.isDirectory) {
|
||
const reader = entry.createReader();
|
||
return new Promise((resolve) => {
|
||
reader.readEntries(async (entries) => {
|
||
const newPath = path ? `${path}/${entry.name}` : entry.name;
|
||
for (let subEntry of entries) {
|
||
await this.traverseEntry(subEntry, files, newPath);
|
||
}
|
||
resolve();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
addFiles(newFiles) {
|
||
if (this.isUploading) return;
|
||
|
||
// 过滤有效文件
|
||
const validExtensions = ['.zip', '.xlsx', '.xls', '.csv'];
|
||
const filtered = newFiles.filter(f => {
|
||
const ext = '.' + f.path.split('.').pop().toLowerCase();
|
||
return validExtensions.includes(ext);
|
||
}).map(f => ({
|
||
...f,
|
||
status: 'pending', // pending, uploading, uploaded, error
|
||
progress: 0
|
||
}));
|
||
|
||
this.files.push(...filtered);
|
||
this.updateFileList();
|
||
}
|
||
|
||
updateFileList() {
|
||
const fileList = $('#fileList');
|
||
const fileItems = $('#fileItems');
|
||
const fileCount = $('#fileCount');
|
||
|
||
if (this.files.length === 0) {
|
||
fileList.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
fileList.style.display = 'block';
|
||
fileItems.innerHTML = this.files.map((f, i) => `
|
||
<div class="file-item ${f.status}" data-index="${i}" id="file-item-${i}">
|
||
<span class="file-item-icon">${getFileIcon(f.path)}</span>
|
||
<span class="file-item-name" title="${f.path}">${f.path}</span>
|
||
<span class="file-item-size">${formatFileSize(f.file.size)}</span>
|
||
<span class="file-item-status ${f.status}">
|
||
${this.getStatusText(f.status, f.progress)}
|
||
</span>
|
||
${f.status !== 'pending' ? `
|
||
<div class="file-progress">
|
||
<div class="file-progress-bar" style="width: ${f.progress}%"></div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`).join('');
|
||
|
||
fileCount.textContent = `${this.files.length} 个文件`;
|
||
}
|
||
|
||
getStatusText(status, progress) {
|
||
switch (status) {
|
||
case 'pending': return '等待上传';
|
||
case 'uploading': return `上传中 ${progress}%`;
|
||
case 'uploaded': return '✓ 已完成';
|
||
case 'error': return '✕ 失败';
|
||
default: return '';
|
||
}
|
||
}
|
||
|
||
updateFileStatus(index, status, progress = 0) {
|
||
if (this.files[index]) {
|
||
this.files[index].status = status;
|
||
this.files[index].progress = progress;
|
||
|
||
const fileItem = $(`#file-item-${index}`);
|
||
if (fileItem) {
|
||
// 更新样式类
|
||
fileItem.className = `file-item ${status}`;
|
||
fileItem.dataset.index = index;
|
||
fileItem.id = `file-item-${index}`;
|
||
|
||
// 更新状态文本
|
||
const statusEl = fileItem.querySelector('.file-item-status');
|
||
if (statusEl) {
|
||
statusEl.className = `file-item-status ${status}`;
|
||
statusEl.textContent = this.getStatusText(status, progress);
|
||
}
|
||
|
||
// 更新或创建进度条
|
||
let progressEl = fileItem.querySelector('.file-progress');
|
||
if (status !== 'pending') {
|
||
if (!progressEl) {
|
||
progressEl = document.createElement('div');
|
||
progressEl.className = 'file-progress';
|
||
progressEl.innerHTML = '<div class="file-progress-bar"></div>';
|
||
fileItem.appendChild(progressEl);
|
||
}
|
||
progressEl.querySelector('.file-progress-bar').style.width = `${progress}%`;
|
||
}
|
||
|
||
// 自动滚动到当前上传的文件
|
||
if (status === 'uploading') {
|
||
fileItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
updateUploadStats() {
|
||
const stats = {
|
||
total: this.files.length,
|
||
success: this.files.filter(f => f.status === 'uploaded').length,
|
||
error: this.files.filter(f => f.status === 'error').length
|
||
};
|
||
|
||
$('#statsTotal').textContent = stats.total;
|
||
$('#statsSuccess').textContent = stats.success;
|
||
$('#statsError').textContent = stats.error;
|
||
|
||
// 更新总进度
|
||
const totalProgress = Math.round((stats.success + stats.error) / stats.total * 100);
|
||
$('#totalPercent').textContent = `${totalProgress}%`;
|
||
$('#totalProgressBar').style.width = `${totalProgress}%`;
|
||
}
|
||
|
||
clearFiles() {
|
||
this.files = [];
|
||
this.updateFileList();
|
||
$('#totalProgress').style.display = 'none';
|
||
$('#uploadStats').style.display = 'none';
|
||
}
|
||
|
||
async upload() {
|
||
if (this.files.length === 0) {
|
||
showToast('请先选择文件', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (this.isUploading) {
|
||
showToast('正在上传中,请稍候', 'warning');
|
||
return;
|
||
}
|
||
|
||
// 检查是否有其他任务在运行
|
||
try {
|
||
const statusCheck = await api('/task/status');
|
||
if (statusCheck.has_active) {
|
||
const stageText = statusCheck.stage === 'uploading' ? '上传' : '处理';
|
||
showToast(`已有任务在运行(${stageText}中)`, 'warning');
|
||
// 显示当前任务状态
|
||
this.taskId = statusCheck.task_id;
|
||
$('#uploadZone').style.display = 'none';
|
||
$('#fileList').style.display = 'none';
|
||
$('#processSection').style.display = 'block';
|
||
$('#newProcess').style.display = 'none';
|
||
$('#processStatus').className = 'process-status processing';
|
||
$('#processStatus').textContent = statusCheck.stage === 'uploading' ? '上传中...' : '处理中...';
|
||
if (statusCheck.logs && statusCheck.logs.length > 0) {
|
||
const logContent = $('#logContent');
|
||
logContent.innerHTML = statusCheck.logs.map(log => {
|
||
let level = 'info';
|
||
if (log.includes('[SUCCESS]')) level = 'success';
|
||
else if (log.includes('[ERROR]')) level = 'error';
|
||
else if (log.includes('[WARN]')) level = 'warn';
|
||
return `<div class="log-line ${level}">${log}</div>`;
|
||
}).join('');
|
||
}
|
||
if (statusCheck.stage === 'processing') {
|
||
this.pollStatus();
|
||
} else {
|
||
this.pollGlobalStatus();
|
||
}
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error('检查任务状态失败:', error);
|
||
}
|
||
|
||
// 立即更新UI状态(在开始上传前)
|
||
// 隐藏上传区域和文件列表
|
||
$('#uploadZone').style.display = 'none';
|
||
$('#fileList').style.display = 'none';
|
||
$('#totalProgress').style.display = 'none';
|
||
$('#uploadStats').style.display = 'none';
|
||
|
||
// 隐藏处理区域(上传完成前不显示)
|
||
$('#processSection').style.display = 'none';
|
||
|
||
// 显示文件列表和进度信息
|
||
$('#fileList').style.display = 'block';
|
||
$('#totalProgress').style.display = 'block';
|
||
$('#uploadStats').style.display = 'flex';
|
||
|
||
// 初始化进度显示
|
||
$('#totalPercent').textContent = '0%';
|
||
$('#totalProgressBar').style.width = '0%';
|
||
|
||
// 保持所有文件为等待上传状态(不立即改为上传中)
|
||
this.files.forEach((fileData, index) => {
|
||
this.updateFileStatus(index, 'pending', 0);
|
||
});
|
||
this.updateUploadStats();
|
||
|
||
this.isUploading = true;
|
||
$('#startUpload').disabled = true;
|
||
$('#clearFiles').disabled = true;
|
||
|
||
showToast('开始批量上传文件...', 'info');
|
||
|
||
try {
|
||
// 创建 FormData,添加所有文件
|
||
const formData = new FormData();
|
||
this.files.forEach(fileData => {
|
||
formData.append('files', fileData.file, fileData.path);
|
||
});
|
||
|
||
// 使用 XMLHttpRequest 来跟踪上传进度
|
||
const result = await new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest();
|
||
|
||
// 上传进度事件
|
||
xhr.upload.addEventListener('progress', (e) => {
|
||
if (e.lengthComputable) {
|
||
const totalProgress = Math.round((e.loaded / e.total) * 100);
|
||
const fileCount = this.files.length;
|
||
|
||
// 更新总进度条
|
||
$('#totalPercent').textContent = `${totalProgress}%`;
|
||
$('#totalProgressBar').style.width = `${totalProgress}%`;
|
||
|
||
// 根据总进度计算当前应该处理到第几个文件
|
||
// 每个文件占 100/fileCount 的进度
|
||
const progressPerFile = 100 / fileCount;
|
||
const currentFileIndex = Math.floor(totalProgress / progressPerFile);
|
||
const currentFileProgress = (totalProgress % progressPerFile) / progressPerFile * 100;
|
||
|
||
// 更新每个文件的状态
|
||
this.files.forEach((fileData, index) => {
|
||
if (index < currentFileIndex) {
|
||
// 已完成的文件
|
||
this.updateFileStatus(index, 'uploaded', 100);
|
||
} else if (index === currentFileIndex) {
|
||
// 当前正在上传的文件
|
||
const fileProgress = Math.min(100, Math.max(0, Math.round(currentFileProgress)));
|
||
this.updateFileStatus(index, 'uploading', fileProgress);
|
||
|
||
// 滚动到当前正在上传的文件
|
||
const fileItem = $(`#file-item-${index}`);
|
||
if (fileItem) {
|
||
fileItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
} else {
|
||
// 还未开始的文件
|
||
this.updateFileStatus(index, 'pending', 0);
|
||
}
|
||
});
|
||
this.updateUploadStats();
|
||
}
|
||
});
|
||
|
||
// 完成事件
|
||
xhr.addEventListener('load', () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
try {
|
||
const result = JSON.parse(xhr.responseText);
|
||
resolve(result);
|
||
} catch {
|
||
resolve({ success: true });
|
||
}
|
||
} else if (xhr.status === 401) {
|
||
localStorage.removeItem('token');
|
||
showLoginModal();
|
||
reject(new Error('未授权或登录已过期,请重新登录'));
|
||
} else {
|
||
reject(new Error('上传失败'));
|
||
}
|
||
});
|
||
|
||
// 错误事件
|
||
xhr.addEventListener('error', () => {
|
||
reject(new Error('网络错误'));
|
||
});
|
||
|
||
// 开始上传
|
||
xhr.open('POST', '/api/upload');
|
||
const token = localStorage.getItem('token');
|
||
if (token) {
|
||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||
}
|
||
xhr.send(formData);
|
||
});
|
||
|
||
// 上传成功,获取后端返回的任务ID
|
||
const taskId = result.task_id || result.session_id;
|
||
if (!taskId) {
|
||
throw new Error('未获取到任务ID');
|
||
}
|
||
|
||
this.taskId = taskId;
|
||
console.log('上传完成,任务ID:', taskId);
|
||
|
||
// 将所有文件状态改为已完成
|
||
this.files.forEach((fileData, index) => {
|
||
this.updateFileStatus(index, 'uploaded', 100);
|
||
});
|
||
this.updateUploadStats();
|
||
|
||
// 注意:锁定已在后端上传接口中完成,这里不需要再次锁定
|
||
|
||
// 隐藏上传进度,显示处理区域
|
||
$('#totalProgress').style.display = 'none';
|
||
$('#uploadStats').style.display = 'none';
|
||
$('#fileList').style.display = 'none';
|
||
|
||
// 显示处理区域(上传完成后才显示)
|
||
$('#processSection').style.display = 'block';
|
||
const processActions = $('#processActions');
|
||
if (processActions) processActions.style.display = 'none';
|
||
$('#logContent').innerHTML = `<div class="log-line success">上传完成: ${result.file_count || this.files.length} 个文件</div>`;
|
||
$('#processStatus').className = 'process-status processing';
|
||
$('#processStatus').textContent = '准备开始处理...';
|
||
showToast(`上传完成: ${result.file_count || this.files.length} 个文件`, 'success');
|
||
|
||
// 立即开始处理(不等待)
|
||
console.log('开始启动处理任务...');
|
||
this.startProcessing().catch(error => {
|
||
console.error('启动处理失败:', error);
|
||
showToast(`启动处理失败: ${error.message}`, 'error');
|
||
// 如果启动失败,恢复显示上传区域
|
||
$('#processSection').style.display = 'none';
|
||
this.restoreUploadUI();
|
||
});
|
||
|
||
} catch (error) {
|
||
showToast(`上传失败: ${error.message}`, 'error');
|
||
console.error('上传失败:', error);
|
||
|
||
// 将所有文件状态改为失败
|
||
this.files.forEach((fileData, index) => {
|
||
this.updateFileStatus(index, 'error', 0);
|
||
});
|
||
this.updateUploadStats();
|
||
|
||
// 上传失败,解锁全局任务(后端会自动处理,但这里也尝试解锁)
|
||
if (this.taskId) {
|
||
try {
|
||
await api('/task/unlock', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ task_id: this.taskId })
|
||
});
|
||
} catch (unlockError) {
|
||
console.error('解锁任务失败:', unlockError);
|
||
}
|
||
}
|
||
// 恢复显示上传区域
|
||
$('#processSection').style.display = 'none';
|
||
this.restoreUploadUI();
|
||
} finally {
|
||
this.isUploading = false;
|
||
$('#startUpload').disabled = false;
|
||
$('#clearFiles').disabled = false;
|
||
}
|
||
}
|
||
|
||
async startProcessing() {
|
||
if (!this.taskId) {
|
||
console.error('startProcessing: taskId 为空');
|
||
return;
|
||
}
|
||
|
||
console.log('startProcessing: 开始启动处理,taskId =', this.taskId);
|
||
|
||
try {
|
||
// 更新UI状态
|
||
$('#processStatus').textContent = '正在启动处理...';
|
||
$('#logContent').innerHTML = '<div class="log-line info">正在启动数据处理任务...</div>';
|
||
|
||
const response = await api('/process/start', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ task_id: this.taskId })
|
||
});
|
||
|
||
console.log('startProcessing: 处理任务已启动,响应:', response);
|
||
|
||
// 显示处理区域
|
||
$('#processSection').style.display = 'block';
|
||
const processActions = $('#processActions');
|
||
if (processActions) processActions.style.display = 'none';
|
||
$('#processStatus').className = 'process-status processing';
|
||
$('#processStatus').textContent = '处理中...';
|
||
$('#logContent').innerHTML = '<div class="log-line info">处理任务已启动,等待日志...</div>';
|
||
|
||
// 确保上传区域和文件列表保持隐藏
|
||
$('#uploadZone').style.display = 'none';
|
||
$('#fileList').style.display = 'none';
|
||
|
||
// 开始轮询状态(延迟一点,确保后端已经开始处理)
|
||
setTimeout(() => {
|
||
this.pollStatus();
|
||
}, 500);
|
||
|
||
} catch (error) {
|
||
console.error('startProcessing: 启动处理失败:', error);
|
||
showToast(`启动处理失败: ${error.message}`, 'error');
|
||
// 如果启动失败,恢复显示上传区域
|
||
$('#processSection').style.display = 'none';
|
||
this.restoreUploadUI();
|
||
// 解锁全局任务
|
||
if (this.taskId) {
|
||
try {
|
||
await api('/task/unlock', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ task_id: this.taskId })
|
||
});
|
||
} catch (unlockError) {
|
||
console.error('解锁任务失败:', unlockError);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pollStatus() {
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
}
|
||
|
||
if (!this.taskId) {
|
||
console.error('轮询状态失败: taskId 为空');
|
||
this.restoreUploadUI();
|
||
return;
|
||
}
|
||
|
||
console.log('开始轮询任务状态:', this.taskId);
|
||
|
||
// 绑定手动刷新按钮
|
||
const manualRefreshBtn = $('#manualRefreshLog');
|
||
if (manualRefreshBtn) {
|
||
manualRefreshBtn.onclick = () => {
|
||
this.refreshLogOnce();
|
||
};
|
||
}
|
||
|
||
// 监听自动刷新开关变化
|
||
const autoRefreshCheckbox = $('#autoRefreshLog');
|
||
if (autoRefreshCheckbox) {
|
||
autoRefreshCheckbox.addEventListener('change', (e) => {
|
||
if (e.target.checked) {
|
||
// 开启自动刷新,重新开始轮询
|
||
if (!this.pollInterval) {
|
||
this.pollStatus();
|
||
}
|
||
} else {
|
||
// 关闭自动刷新,停止轮询
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
console.log('已停止自动刷新轮询');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
const poll = async () => {
|
||
try {
|
||
// 检查自动刷新开关
|
||
const autoRefreshCheckbox = $('#autoRefreshLog');
|
||
if (autoRefreshCheckbox && !autoRefreshCheckbox.checked) {
|
||
// 如果关闭了自动刷新,停止轮询
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
return;
|
||
}
|
||
const status = await api('/process/status', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ task_id: this.taskId })
|
||
});
|
||
|
||
// 更新日志
|
||
const logContent = $('#logContent');
|
||
if (status.logs && status.logs.length > 0) {
|
||
logContent.innerHTML = status.logs.map(log => {
|
||
let level = 'info';
|
||
if (log.includes('[SUCCESS]')) level = 'success';
|
||
else if (log.includes('[ERROR]')) level = 'error';
|
||
else if (log.includes('[WARN]')) level = 'warn';
|
||
return `<div class="log-line ${level}">${log}</div>`;
|
||
}).join('');
|
||
} else if (status.status === 'processing') {
|
||
// 处理中但还没有日志,显示等待信息
|
||
if (!logContent.innerHTML || logContent.innerHTML.trim() === '') {
|
||
logContent.innerHTML = '<div class="log-line info">等待处理开始...</div>';
|
||
}
|
||
}
|
||
|
||
// 自动滚动到底部(仅在自动刷新开启时)
|
||
if (autoRefreshCheckbox && autoRefreshCheckbox.checked) {
|
||
const container = $('#logContainer');
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
// 更新状态
|
||
const statusEl = $('#processStatus');
|
||
if (status.status === 'completed') {
|
||
statusEl.textContent = '处理完成';
|
||
statusEl.className = 'process-status completed';
|
||
$('#newProcess').style.display = 'inline-flex';
|
||
// 任务完成,立即停止轮询
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
showToast('数据处理完成!', 'success');
|
||
// 任务完成,不立即恢复上传区域,让用户查看结果
|
||
// 用户可以通过点击"新任务"按钮或切换页面来重置
|
||
this.files = []; // 清空已上传的文件
|
||
} else if (status.status === 'failed') {
|
||
statusEl.textContent = '处理失败';
|
||
statusEl.className = 'process-status failed';
|
||
$('#newProcess').style.display = 'inline-flex';
|
||
// 任务失败,立即停止轮询
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
showToast('数据处理失败', 'error');
|
||
// 任务失败,不立即恢复上传区域
|
||
this.files = [];
|
||
} else {
|
||
statusEl.textContent = '处理中...';
|
||
statusEl.className = 'process-status processing';
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('轮询状态失败:', error);
|
||
// 如果请求失败(如 404),可能是任务ID错误或任务已完成
|
||
if (error.message && error.message.includes('404')) {
|
||
// 任务不存在,停止轮询并恢复界面
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
showToast('任务不存在或已完成', 'warning');
|
||
$('#processStatus').textContent = '任务不存在';
|
||
$('#processStatus').className = 'process-status failed';
|
||
this.restoreUploadUI();
|
||
} else {
|
||
// 其他错误(如网络错误),继续轮询但显示错误提示
|
||
console.warn('轮询状态失败,继续重试:', error);
|
||
}
|
||
}
|
||
};
|
||
|
||
poll();
|
||
this.pollInterval = setInterval(poll, 1000);
|
||
}
|
||
|
||
async refreshLogOnce() {
|
||
if (!this.taskId) return;
|
||
|
||
try {
|
||
const status = await api('/process/status', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ task_id: this.taskId })
|
||
});
|
||
|
||
// 更新日志
|
||
const logContent = $('#logContent');
|
||
if (status.logs && status.logs.length > 0) {
|
||
logContent.innerHTML = status.logs.map(log => {
|
||
let level = 'info';
|
||
if (log.includes('[SUCCESS]')) level = 'success';
|
||
else if (log.includes('[ERROR]')) level = 'error';
|
||
else if (log.includes('[WARN]')) level = 'warn';
|
||
return `<div class="log-line ${level}">${log}</div>`;
|
||
}).join('');
|
||
// 滚动到底部
|
||
const container = $('#logContainer');
|
||
if (container) {
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
} else if (status.status === 'processing') {
|
||
// 处理中但还没有日志,显示等待信息
|
||
if (!logContent.innerHTML || logContent.innerHTML.trim() === '') {
|
||
logContent.innerHTML = '<div class="log-line info">等待处理开始...</div>';
|
||
}
|
||
}
|
||
|
||
// 不自动滚动,让用户自己控制
|
||
} catch (error) {
|
||
console.error('手动刷新日志失败:', error);
|
||
}
|
||
}
|
||
|
||
restoreUploadUI() {
|
||
// 恢复显示上传区域(使用 flex 布局)
|
||
$('#uploadZone').style.display = 'flex';
|
||
// 只有当有文件时才显示文件列表
|
||
if (this.files.length > 0) {
|
||
$('#fileList').style.display = 'block';
|
||
}
|
||
// 隐藏进度和统计
|
||
$('#totalProgress').style.display = 'none';
|
||
$('#uploadStats').style.display = 'none';
|
||
this.isUploading = false;
|
||
$('#startUpload').disabled = false;
|
||
$('#clearFiles').disabled = false;
|
||
}
|
||
|
||
reset() {
|
||
this.files = [];
|
||
this.taskId = null;
|
||
this.isUploading = false;
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
|
||
this.updateFileList();
|
||
// 隐藏处理区域
|
||
const processSection = $('#processSection');
|
||
if (processSection) processSection.style.display = 'none';
|
||
const processActions = $('#processActions');
|
||
if (processActions) processActions.style.display = 'none';
|
||
const processStatus = $('#processStatus');
|
||
if (processStatus) {
|
||
processStatus.className = 'process-status processing';
|
||
processStatus.textContent = '处理中...';
|
||
}
|
||
const logContent = $('#logContent');
|
||
if (logContent) logContent.innerHTML = '';
|
||
// 恢复显示上传区域
|
||
this.restoreUploadUI();
|
||
console.log('任务已重置,UI 已恢复');
|
||
}
|
||
|
||
async checkActiveTask() {
|
||
// 检查是否有正在进行的任务(全局状态)
|
||
// 只在用户导航到上传页面时调用,避免无意义的请求
|
||
try {
|
||
const result = await api('/task/status');
|
||
if (result.has_active) {
|
||
// 有任务正在进行,隐藏上传区域,显示处理进度
|
||
$('#uploadZone').style.display = 'none';
|
||
$('#fileList').style.display = 'none';
|
||
$('#totalProgress').style.display = 'none';
|
||
$('#uploadStats').style.display = 'none';
|
||
this.taskId = result.task_id;
|
||
$('#processSection').style.display = 'block';
|
||
$('#newProcess').style.display = 'none';
|
||
$('#processStatus').className = 'process-status processing';
|
||
|
||
// 根据阶段显示不同状态
|
||
const stageText = result.stage === 'uploading' ? '上传中...' : '处理中...';
|
||
$('#processStatus').textContent = stageText;
|
||
|
||
// 显示已有的日志
|
||
if (result.logs && result.logs.length > 0) {
|
||
const logContent = $('#logContent');
|
||
logContent.innerHTML = result.logs.map(log => {
|
||
let level = 'info';
|
||
if (log.includes('[SUCCESS]')) level = 'success';
|
||
else if (log.includes('[ERROR]')) level = 'error';
|
||
else if (log.includes('[WARN]')) level = 'warn';
|
||
return `<div class="log-line ${level}">${log}</div>`;
|
||
}).join('');
|
||
const container = $('#logContainer');
|
||
container.scrollTop = container.scrollHeight;
|
||
} else if (result.stage === 'uploading') {
|
||
$('#logContent').innerHTML = '<div class="log-line info">正在上传文件...</div>';
|
||
} else {
|
||
$('#logContent').innerHTML = '';
|
||
}
|
||
|
||
// 只有在处理中且自动刷新开启时才开始轮询
|
||
// 避免多人同时轮询导致并发过大
|
||
if (result.stage === 'processing') {
|
||
const autoRefreshCheckbox = $('#autoRefreshLog');
|
||
if (autoRefreshCheckbox && autoRefreshCheckbox.checked) {
|
||
this.pollStatus();
|
||
}
|
||
} else if (result.stage === 'uploading') {
|
||
// 上传中,轮询全局状态(但也要检查自动刷新)
|
||
const autoRefreshCheckbox = $('#autoRefreshLog');
|
||
if (autoRefreshCheckbox && autoRefreshCheckbox.checked) {
|
||
this.pollGlobalStatus();
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('检查活动任务失败:', error);
|
||
}
|
||
}
|
||
|
||
pollGlobalStatus() {
|
||
// 轮询全局任务状态(用于等待上传完成)
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
}
|
||
|
||
const poll = async () => {
|
||
try {
|
||
// 检查自动刷新开关,如果关闭则停止轮询
|
||
const autoRefreshCheckbox = $('#autoRefreshLog');
|
||
if (autoRefreshCheckbox && !autoRefreshCheckbox.checked) {
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
const result = await api('/task/status');
|
||
|
||
if (!result.has_active) {
|
||
// 任务已完成或取消,停止轮询并恢复上传界面
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
$('#processSection').style.display = 'none';
|
||
this.restoreUploadUI();
|
||
showToast('任务已完成', 'info');
|
||
} else if (result.stage === 'processing') {
|
||
// 转为处理中状态,切换到处理轮询
|
||
if (this.pollInterval) {
|
||
clearInterval(this.pollInterval);
|
||
this.pollInterval = null;
|
||
}
|
||
this.taskId = result.task_id;
|
||
$('#processStatus').textContent = '处理中...';
|
||
// 只有自动刷新开启时才继续轮询
|
||
if (autoRefreshCheckbox && autoRefreshCheckbox.checked) {
|
||
this.pollStatus();
|
||
}
|
||
} else {
|
||
// 仍在上传中
|
||
$('#processStatus').textContent = '上传中...';
|
||
}
|
||
} catch (error) {
|
||
console.error('轮询全局状态失败:', error);
|
||
}
|
||
};
|
||
|
||
poll();
|
||
this.pollInterval = setInterval(poll, 1000);
|
||
}
|
||
}
|
||
|
||
|
||
// ==================== 历史记录 ====================
|
||
|
||
class HistoryManager {
|
||
constructor() {
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
$('#refreshHistory').addEventListener('click', () => this.load());
|
||
$('#clearHistory').addEventListener('click', () => this.clear());
|
||
|
||
window.addEventListener('pagechange', (e) => {
|
||
if (e.detail.page === 'history') {
|
||
this.load();
|
||
}
|
||
});
|
||
}
|
||
|
||
async load() {
|
||
const container = $('#historyList');
|
||
container.innerHTML = '<div class="loading">加载中...</div>';
|
||
|
||
try {
|
||
const data = await api('/history', { method: 'POST' });
|
||
|
||
if (data.records.length === 0) {
|
||
container.innerHTML = `
|
||
<div class="empty-state">
|
||
<span class="empty-icon">📭</span>
|
||
<p>暂无处理记录</p>
|
||
</div>
|
||
`;
|
||
// 即使没有历史记录,也要加载总占用大小
|
||
this.loadTotalSize();
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = data.records.map(record => `
|
||
<div class="history-item" data-id="${record.id}">
|
||
<div class="history-status ${record.status}"></div>
|
||
<div class="history-info">
|
||
<div class="history-id">${record.id}</div>
|
||
<div class="history-meta">
|
||
${formatDateTime(record.timestamp)} ·
|
||
${record.file_count} 个文件 ·
|
||
${record.elapsed_time ? record.elapsed_time + ' 秒' : '-'} ·
|
||
<span class="record-size" data-id="${record.id}">计算中...</span>
|
||
</div>
|
||
</div>
|
||
<div class="history-actions">
|
||
<button class="btn btn-sm btn-outline view-log" data-id="${record.id}">
|
||
📋 日志
|
||
</button>
|
||
<button class="btn btn-sm btn-danger delete-record" data-id="${record.id}">
|
||
🗑️
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
// 加载每个记录的占用大小
|
||
container.querySelectorAll('.record-size').forEach(el => {
|
||
this.loadRecordSize(el.dataset.id, el);
|
||
});
|
||
|
||
// 加载总占用大小
|
||
this.loadTotalSize();
|
||
|
||
// 绑定事件
|
||
container.querySelectorAll('.view-log').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
this.viewLog(btn.dataset.id);
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll('.delete-record').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
this.delete(btn.dataset.id);
|
||
});
|
||
});
|
||
|
||
} catch (error) {
|
||
container.innerHTML = `<div class="empty-state"><p>加载失败: ${error.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
async viewLog(id) {
|
||
try {
|
||
const record = await api('/history/detail', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ record_id: id })
|
||
});
|
||
|
||
// 使用模态框显示日志
|
||
const modal = document.createElement('div');
|
||
modal.className = 'modal active';
|
||
modal.innerHTML = `
|
||
<div class="modal-backdrop"></div>
|
||
<div class="modal-content" style="width: 800px; max-height: 80vh;">
|
||
<h3>处理日志 - ${id}</h3>
|
||
<div class="log-container" style="height: 400px; margin-bottom: 16px;">
|
||
<div class="log-content">
|
||
${record.logs.map(log => {
|
||
let level = 'info';
|
||
if (log.includes('[SUCCESS]')) level = 'success';
|
||
else if (log.includes('[ERROR]')) level = 'error';
|
||
else if (log.includes('[WARN]')) level = 'warn';
|
||
return `<div class="log-line ${level}">${log}</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button class="btn btn-outline close-modal">关闭</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
modal.querySelector('.close-modal').addEventListener('click', () => modal.remove());
|
||
modal.querySelector('.modal-backdrop').addEventListener('click', () => modal.remove());
|
||
|
||
} catch (error) {
|
||
showToast(`加载日志失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async delete(id) {
|
||
const confirmed = await showConfirm('删除记录', '确定要删除这条历史记录吗?');
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
await api('/history/delete', { method: 'POST', body: JSON.stringify({ record_id: id }) });
|
||
showToast('删除成功', 'success');
|
||
this.load();
|
||
} catch (error) {
|
||
showToast(`删除失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async clear() {
|
||
const confirmed = await showConfirm('清空历史', '确定要清空所有历史记录吗?此操作不可恢复。');
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
await api('/history/clear', { method: 'POST' });
|
||
showToast('已清空所有历史记录', 'success');
|
||
this.load();
|
||
} catch (error) {
|
||
showToast(`清空失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async loadRecordSize(recordId, element) {
|
||
try {
|
||
const result = await api('/history/size', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ record_id: recordId })
|
||
});
|
||
if (result.success) {
|
||
element.textContent = result.size_formatted;
|
||
} else {
|
||
element.textContent = '计算失败';
|
||
}
|
||
} catch (error) {
|
||
element.textContent = '计算失败';
|
||
}
|
||
}
|
||
|
||
async loadTotalSize() {
|
||
const totalSizeEl = $('#totalHistorySize');
|
||
if (!totalSizeEl) return;
|
||
|
||
try {
|
||
const result = await api('/cache/size');
|
||
if (result.success) {
|
||
totalSizeEl.textContent = result.size_formatted;
|
||
} else {
|
||
totalSizeEl.textContent = '计算失败';
|
||
}
|
||
} catch (error) {
|
||
totalSizeEl.textContent = '计算失败';
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ==================== 数据库管理 ====================
|
||
|
||
class DatabaseManager {
|
||
constructor() {
|
||
this.currentTable = null;
|
||
this.currentPage = 1;
|
||
this.pageSize = 50;
|
||
this.totalPages = 1;
|
||
this.columns = [];
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
$('#downloadTable').addEventListener('click', () => this.downloadTable());
|
||
$('#truncateTable').addEventListener('click', () => this.truncateTable());
|
||
$('#dropTable').addEventListener('click', () => this.dropTable());
|
||
$('#dropAllTables').addEventListener('click', () => this.dropAllTables());
|
||
|
||
// 分页按钮事件
|
||
$('#firstPage').addEventListener('click', () => this.goToPage(1));
|
||
$('#prevPage').addEventListener('click', () => this.goToPage(this.currentPage - 1));
|
||
$('#nextPage').addEventListener('click', () => this.goToPage(this.currentPage + 1));
|
||
$('#lastPage').addEventListener('click', () => this.goToPage(this.totalPages));
|
||
|
||
// 页面大小选择
|
||
$('#pageSizeSelect').addEventListener('change', (e) => {
|
||
this.pageSize = parseInt(e.target.value);
|
||
this.currentPage = 1;
|
||
this.loadData();
|
||
});
|
||
|
||
// 跳转页面输入框
|
||
$('#pageJumpInput').addEventListener('keyup', (e) => {
|
||
if (e.key === 'Enter') {
|
||
const page = parseInt(e.target.value);
|
||
if (page >= 1 && page <= this.totalPages) {
|
||
this.goToPage(page);
|
||
} else {
|
||
e.target.value = this.currentPage;
|
||
showToast(`页码必须在 1-${this.totalPages} 之间`, 'error');
|
||
}
|
||
}
|
||
});
|
||
|
||
// 失去焦点时恢复当前页
|
||
$('#pageJumpInput').addEventListener('blur', (e) => {
|
||
e.target.value = this.currentPage;
|
||
});
|
||
|
||
window.addEventListener('pagechange', (e) => {
|
||
if (e.detail.page === 'database') {
|
||
this.testConnection();
|
||
this.loadTables();
|
||
}
|
||
});
|
||
}
|
||
|
||
async testConnection() {
|
||
const versionEl = $('#dbVersion');
|
||
if (!versionEl) return;
|
||
|
||
const versionBadge = versionEl.querySelector('.version-badge');
|
||
if (!versionBadge) return;
|
||
|
||
versionBadge.classList.remove('connected', 'error', 'unknown');
|
||
versionBadge.classList.add('unknown');
|
||
versionBadge.textContent = '检测中';
|
||
|
||
try {
|
||
const result = await api('/database/test', { method: 'POST' });
|
||
|
||
if (result.success) {
|
||
// 连接成功,加载服务器信息
|
||
await this.loadServerInfo();
|
||
} else {
|
||
// 连接失败
|
||
versionBadge.classList.remove('unknown');
|
||
versionBadge.classList.add('error');
|
||
versionBadge.textContent = '连接失败';
|
||
versionBadge.title = result.message || '无法连接到数据库';
|
||
}
|
||
} catch (error) {
|
||
// 连接异常
|
||
versionBadge.classList.remove('unknown');
|
||
versionBadge.classList.add('error');
|
||
versionBadge.textContent = '连接失败';
|
||
versionBadge.title = error.message || '连接异常';
|
||
}
|
||
}
|
||
|
||
async loadServerInfo() {
|
||
const versionEl = $('#dbVersion');
|
||
const loadDataEl = $('#loadDataSupport');
|
||
|
||
if (!versionEl || !loadDataEl) return;
|
||
|
||
try {
|
||
const info = await api('/database/info');
|
||
|
||
if (info.success) {
|
||
// 显示版本(连接成功)
|
||
const versionBadge = versionEl.querySelector('.version-badge');
|
||
versionBadge.classList.remove('unknown', 'error');
|
||
versionBadge.classList.add('connected');
|
||
versionBadge.textContent = info.version || '-';
|
||
versionBadge.title = '数据库版本';
|
||
|
||
// 显示 LOAD DATA INFILE 支持状态
|
||
const badge = loadDataEl.querySelector('.support-badge');
|
||
badge.classList.remove('unknown', 'supported', 'unsupported');
|
||
|
||
if (info.load_data_infile) {
|
||
badge.classList.add('supported');
|
||
badge.textContent = '已启用';
|
||
badge.title = info.load_data_message || '支持高速导入';
|
||
} else {
|
||
badge.classList.add('unsupported');
|
||
badge.textContent = '未启用';
|
||
badge.title = info.load_data_message || '将使用标准导入模式';
|
||
}
|
||
} else {
|
||
// 获取信息失败,但连接可能成功
|
||
const versionBadge = versionEl.querySelector('.version-badge');
|
||
versionBadge.classList.remove('unknown');
|
||
versionBadge.classList.add('error');
|
||
versionBadge.textContent = '获取失败';
|
||
versionBadge.title = info.error || '无法获取服务器信息';
|
||
}
|
||
} catch (error) {
|
||
console.error('获取服务器信息失败:', error);
|
||
const versionBadge = versionEl.querySelector('.version-badge');
|
||
versionBadge.classList.remove('unknown');
|
||
versionBadge.classList.add('error');
|
||
versionBadge.textContent = '获取失败';
|
||
versionBadge.title = error.message || '获取服务器信息异常';
|
||
}
|
||
}
|
||
|
||
async loadTables() {
|
||
const container = $('#tableList');
|
||
container.innerHTML = '<div class="loading">加载中...</div>';
|
||
|
||
try {
|
||
const data = await api('/database/tables');
|
||
|
||
if (data.tables.length === 0) {
|
||
container.innerHTML = '<div class="empty-state" style="padding: 24px;"><p>暂无数据表</p></div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = data.tables.map(table => `
|
||
<div class="table-item" data-table="${table}">
|
||
<span class="table-icon">📊</span>
|
||
<span>${table}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
container.querySelectorAll('.table-item').forEach(item => {
|
||
item.addEventListener('click', () => {
|
||
container.querySelectorAll('.table-item').forEach(i => i.classList.remove('active'));
|
||
item.classList.add('active');
|
||
this.selectTable(item.dataset.table);
|
||
});
|
||
});
|
||
|
||
} catch (error) {
|
||
container.innerHTML = `<div class="empty-state" style="padding: 24px;"><p>加载失败</p></div>`;
|
||
}
|
||
}
|
||
|
||
async selectTable(tableName) {
|
||
this.currentTable = tableName;
|
||
this.currentPage = 1;
|
||
|
||
// 重置页面大小为默认值
|
||
$('#pageSizeSelect').value = this.pageSize.toString();
|
||
$('#pageJumpInput').value = '1';
|
||
|
||
$('#dbToolbar').style.display = 'flex';
|
||
$('#currentTable').textContent = tableName;
|
||
$('#pagination').style.display = 'flex';
|
||
|
||
try {
|
||
const info = await api('/database/table/info', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ table_name: tableName })
|
||
});
|
||
this.columns = info.columns.map(c => c.Field);
|
||
$('#rowCount').textContent = `${info.row_count} 行`;
|
||
|
||
await this.loadData();
|
||
|
||
} catch (error) {
|
||
showToast(`加载表信息失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
goToPage(page) {
|
||
if (page < 1) page = 1;
|
||
if (page > this.totalPages) page = this.totalPages;
|
||
if (page === this.currentPage) return;
|
||
|
||
this.currentPage = page;
|
||
this.loadData();
|
||
}
|
||
|
||
async loadData() {
|
||
const container = $('#dataTableContainer');
|
||
container.innerHTML = '<div class="loading">加载中...</div>';
|
||
|
||
try {
|
||
const result = await api('/database/table/data', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
table_name: this.currentTable,
|
||
page: this.currentPage,
|
||
page_size: this.pageSize
|
||
})
|
||
});
|
||
|
||
this.totalPages = result.total_pages;
|
||
this.renderTable(result.data);
|
||
|
||
// 更新分页信息
|
||
$('#totalRecords').textContent = result.total;
|
||
$('#totalPagesDisplay').textContent = this.totalPages;
|
||
$('#pageJumpInput').value = this.currentPage;
|
||
$('#pageJumpInput').max = this.totalPages;
|
||
|
||
// 更新按钮状态
|
||
$('#firstPage').disabled = this.currentPage <= 1;
|
||
$('#prevPage').disabled = this.currentPage <= 1;
|
||
$('#nextPage').disabled = this.currentPage >= this.totalPages;
|
||
$('#lastPage').disabled = this.currentPage >= this.totalPages;
|
||
|
||
} catch (error) {
|
||
container.innerHTML = `<div class="empty-state"><p>加载失败: ${error.message}</p></div>`;
|
||
}
|
||
}
|
||
|
||
renderTable(data) {
|
||
const container = $('#dataTableContainer');
|
||
|
||
// 使用已保存的字段列表,如果没有则从数据中获取
|
||
const columns = this.columns && this.columns.length > 0
|
||
? this.columns
|
||
: (data.length > 0 ? Object.keys(data[0]) : []);
|
||
|
||
if (columns.length === 0) {
|
||
container.innerHTML = '<div class="empty-state"><p>暂无数据</p></div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = `
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
${columns.map(col => `<th>${col}</th>`).join('')}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${data.length === 0 ? `
|
||
<tr class="empty-row">
|
||
<td colspan="${columns.length}" style="text-align: center; padding: 40px; color: var(--td-text-color-placeholder);">
|
||
<div class="empty-state" style="padding: 0;">
|
||
<p>暂无数据</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
` : data.map(row => `
|
||
<tr>
|
||
${columns.map(col => `<td title="${row[col] || ''}">${row[col] ?? ''}</td>`).join('')}
|
||
</tr>
|
||
`).join('')}
|
||
</tbody>
|
||
</table>
|
||
`;
|
||
}
|
||
|
||
async downloadTable() {
|
||
if (!this.currentTable) return;
|
||
try {
|
||
const response = await fetch('/api/download', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify({
|
||
table_name: this.currentTable,
|
||
format: 'xlsx'
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('下载失败');
|
||
}
|
||
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${this.currentTable}_${new Date().toISOString().slice(0, 10)}.xlsx`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
} catch (error) {
|
||
showToast(`下载失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async truncateTable() {
|
||
if (!this.currentTable) return;
|
||
|
||
const confirmed = await showConfirm('清空表', `确定要清空表 "${this.currentTable}" 的所有数据吗?此操作不可恢复。`);
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
await api('/database/table/truncate', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ table_name: this.currentTable })
|
||
});
|
||
showToast('表已清空', 'success');
|
||
this.loadData();
|
||
} catch (error) {
|
||
showToast(`清空失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async dropTable() {
|
||
if (!this.currentTable) return;
|
||
|
||
const confirmed = await showConfirm('删除表', `确定要删除表 "${this.currentTable}" 吗?此操作不可恢复。`);
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
await api('/database/table/drop', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ table_name: this.currentTable })
|
||
});
|
||
showToast('表已删除', 'success');
|
||
this.currentTable = null;
|
||
$('#dbToolbar').style.display = 'none';
|
||
$('#pagination').style.display = 'none';
|
||
$('#dataTableContainer').innerHTML = '<div class="empty-state"><span class="empty-icon">👈</span><p>请选择左侧的数据表</p></div>';
|
||
this.loadTables();
|
||
} catch (error) {
|
||
showToast(`删除失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async dropAllTables() {
|
||
// 首先获取所有表
|
||
let tables = [];
|
||
try {
|
||
const result = await api('/database/tables', { method: 'POST' });
|
||
tables = result.tables || [];
|
||
} catch (error) {
|
||
showToast(`获取表列表失败: ${error.message}`, 'error');
|
||
return;
|
||
}
|
||
|
||
if (tables.length === 0) {
|
||
showToast('数据库中没有表', 'info');
|
||
return;
|
||
}
|
||
|
||
// 第一次确认
|
||
const firstConfirm = await showConfirm(
|
||
'删除全部表',
|
||
`警告:此操作将删除数据库中的所有 ${tables.length} 个表!\n\n表列表:${tables.slice(0, 10).join(', ')}${tables.length > 10 ? '...' : ''}\n\n此操作不可恢复,请谨慎操作!`
|
||
);
|
||
|
||
if (!firstConfirm) return;
|
||
|
||
// 第二次确认(双重确认,防止误操作)
|
||
const secondConfirm = await showConfirm(
|
||
'最后确认',
|
||
`您确定要删除所有 ${tables.length} 个表吗?\n\n此操作将永久删除所有数据,无法恢复!\n\n请再次确认。`
|
||
);
|
||
|
||
if (!secondConfirm) return;
|
||
|
||
// 执行删除
|
||
try {
|
||
const result = await api('/database/table/drop-all', {
|
||
method: 'POST'
|
||
});
|
||
|
||
showToast(`已删除 ${result.dropped_count} 个表`, 'success');
|
||
|
||
// 重置当前状态
|
||
this.currentTable = null;
|
||
$('#dbToolbar').style.display = 'none';
|
||
$('#pagination').style.display = 'none';
|
||
$('#dataTableContainer').innerHTML = '<div class="empty-state"><span class="empty-icon">📭</span><p>数据库中没有表</p></div>';
|
||
|
||
// 重新加载表列表
|
||
this.loadTables();
|
||
} catch (error) {
|
||
showToast(`删除失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ==================== 设置管理 ====================
|
||
|
||
class SettingsManager {
|
||
constructor() {
|
||
this.sheetFilters = [];
|
||
this.extractFields = [];
|
||
this.fieldSearchKeyword = '';
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
// 数据库配置保存
|
||
$('#saveMysqlConfig').addEventListener('click', () => this.saveMysqlConfig());
|
||
$('#testDbConnection').addEventListener('click', () => this.testConnection());
|
||
|
||
// 密码显示/隐藏
|
||
$('#togglePassword').addEventListener('click', () => this.togglePasswordVisibility());
|
||
|
||
// Sheet 过滤规则
|
||
$('#addSheetFilter').addEventListener('click', () => this.addSheetFilter());
|
||
$('#newSheetFilter').addEventListener('keyup', (e) => {
|
||
if (e.key === 'Enter') this.addSheetFilter();
|
||
});
|
||
$('#saveSheetFilter').addEventListener('click', () => this.saveSheetFilters());
|
||
|
||
// 字段映射
|
||
$('#addFieldMapping').addEventListener('click', () => this.addFieldMapping());
|
||
$('#saveFieldMapping').addEventListener('click', () => this.saveFieldMappings());
|
||
|
||
// 字段搜索
|
||
$('#fieldSearchInput').addEventListener('input', (e) => {
|
||
this.fieldSearchKeyword = e.target.value.trim().toLowerCase();
|
||
this.renderFieldMappings();
|
||
});
|
||
|
||
// 配置管理
|
||
$('#downloadConfig').addEventListener('click', () => this.downloadConfig());
|
||
$('#uploadConfig').addEventListener('change', (e) => this.uploadConfig(e));
|
||
|
||
window.addEventListener('pagechange', (e) => {
|
||
if (e.detail.page === 'settings') {
|
||
this.loadConfig();
|
||
}
|
||
});
|
||
}
|
||
|
||
togglePasswordVisibility() {
|
||
const input = $('#configPasswd');
|
||
const icon = $('#togglePassword .eye-icon');
|
||
|
||
if (input.type === 'password') {
|
||
input.type = 'text';
|
||
icon.textContent = '🙈';
|
||
} else {
|
||
input.type = 'password';
|
||
icon.textContent = '👁';
|
||
}
|
||
}
|
||
|
||
async loadConfig() {
|
||
try {
|
||
const config = await api('/config/full');
|
||
|
||
// 数据库配置
|
||
$('#configHost').value = config.mysql?.host || '';
|
||
$('#configPort').value = config.mysql?.port || 3306;
|
||
$('#configDbname').value = config.mysql?.dbname || '';
|
||
$('#configUser').value = config.mysql?.user || '';
|
||
$('#configPasswd').value = config.mysql?.passwd || '';
|
||
$('#configUpdate').textContent = `更新时间: ${config.update || '-'}`;
|
||
|
||
// Sheet 过滤规则
|
||
this.sheetFilters = config.sheet_filter || [];
|
||
this.renderSheetFilters();
|
||
|
||
// 字段映射
|
||
this.extractFields = config.extract_fields || [];
|
||
this.renderFieldMappings();
|
||
|
||
} catch (error) {
|
||
showToast(`加载配置失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async saveMysqlConfig() {
|
||
const data = {
|
||
host: $('#configHost').value.trim(),
|
||
port: parseInt($('#configPort').value) || 3306,
|
||
user: $('#configUser').value.trim(),
|
||
passwd: $('#configPasswd').value,
|
||
dbname: $('#configDbname').value.trim()
|
||
};
|
||
|
||
if (!data.host || !data.user || !data.dbname) {
|
||
showToast('请填写完整的数据库配置', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await api('/config/mysql', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data)
|
||
});
|
||
showToast('数据库配置已保存', 'success');
|
||
$('#configUpdate').textContent = `更新时间: ${result.update}`;
|
||
} catch (error) {
|
||
showToast(`保存失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async testConnection() {
|
||
try {
|
||
const result = await api('/database/test', { method: 'POST' });
|
||
if (result.success) {
|
||
showToast('数据库连接成功', 'success');
|
||
} else {
|
||
showToast(`连接失败: ${result.message}`, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast(`测试失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== Sheet 过滤规则 ====================
|
||
|
||
renderSheetFilters() {
|
||
const container = $('#sheetFilterList');
|
||
if (this.sheetFilters.length === 0) {
|
||
container.innerHTML = '<div class="empty-hint">暂无过滤规则</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = this.sheetFilters.map((filter, index) => `
|
||
<div class="filter-item" data-index="${index}">
|
||
<span class="filter-text">${filter}</span>
|
||
<button class="btn-icon remove-filter" data-index="${index}" title="删除">✕</button>
|
||
</div>
|
||
`).join('');
|
||
|
||
// 绑定删除事件
|
||
container.querySelectorAll('.remove-filter').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const index = parseInt(btn.dataset.index);
|
||
this.sheetFilters.splice(index, 1);
|
||
this.renderSheetFilters();
|
||
});
|
||
});
|
||
}
|
||
|
||
addSheetFilter() {
|
||
const input = $('#newSheetFilter');
|
||
const value = input.value.trim();
|
||
|
||
if (!value) {
|
||
showToast('请输入过滤关键词', 'warning');
|
||
return;
|
||
}
|
||
|
||
if (this.sheetFilters.includes(value)) {
|
||
showToast('该关键词已存在', 'warning');
|
||
return;
|
||
}
|
||
|
||
this.sheetFilters.push(value);
|
||
input.value = '';
|
||
this.renderSheetFilters();
|
||
}
|
||
|
||
async saveSheetFilters() {
|
||
try {
|
||
const result = await api('/config/sheet-filter', {
|
||
method: 'POST',
|
||
body: JSON.stringify(this.sheetFilters)
|
||
});
|
||
showToast('Sheet 过滤规则已保存', 'success');
|
||
$('#configUpdate').textContent = `更新时间: ${result.update}`;
|
||
} catch (error) {
|
||
showToast(`保存失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// ==================== 字段映射配置 ====================
|
||
|
||
renderFieldMappings() {
|
||
const container = $('#fieldMappingList');
|
||
|
||
const countEl = $('#fieldCount');
|
||
|
||
// 更新字段数量显示
|
||
if (countEl) {
|
||
countEl.textContent = `${this.extractFields.length} 个字段`;
|
||
}
|
||
|
||
if (this.extractFields.length === 0) {
|
||
container.innerHTML = '<div class="empty-hint">暂无字段映射,点击下方按钮添加</div>';
|
||
return;
|
||
}
|
||
|
||
// 可用的字段类型
|
||
const fieldTypes = [
|
||
{ value: 'string', label: '字符串' },
|
||
{ value: 'datetime', label: '日期时间' },
|
||
{ value: 'int', label: '整数' },
|
||
{ value: 'float', label: '浮点数' },
|
||
{ value: 'text', label: '长文本' }
|
||
];
|
||
|
||
container.innerHTML = this.extractFields.map((field, index) => {
|
||
// 根据搜索关键词决定是否显示
|
||
const shouldShow = !this.fieldSearchKeyword || (() => {
|
||
const fieldName = (field.Field || '').toLowerCase();
|
||
const extractSources = (field.Extract || []).join(' ').toLowerCase();
|
||
return fieldName.includes(this.fieldSearchKeyword) || extractSources.includes(this.fieldSearchKeyword);
|
||
})();
|
||
|
||
return `
|
||
<div class="field-mapping-item" data-index="${index}" ${!shouldShow ? 'style="display: none;"' : ''}>
|
||
<span class="field-mapping-number">${index + 1}</span>
|
||
<button class="btn-icon remove-mapping" data-index="${index}" title="删除此映射">✕</button>
|
||
<div class="field-mapping-header">
|
||
<div class="field-name">
|
||
<label>数据库字段名</label>
|
||
<input type="text" class="form-input field-input" value="${field.Field || ''}"
|
||
data-index="${index}" data-prop="Field" placeholder="输入字段名">
|
||
</div>
|
||
<div class="field-type">
|
||
<label>字段类型</label>
|
||
<select class="form-select type-select" data-index="${index}">
|
||
${fieldTypes.map(t => `
|
||
<option value="${t.value}" ${(field.Type || 'string') === t.value ? 'selected' : ''}>
|
||
${t.label}
|
||
</option>
|
||
`).join('')}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="extract-list">
|
||
<div class="extract-list-header">
|
||
<label>提取来源 (${(field.Extract || []).length} 个)</label>
|
||
<div class="add-extract-row">
|
||
<input type="text" class="form-input extract-input" placeholder="输入 Excel 列名"
|
||
data-index="${index}">
|
||
<button class="btn btn-sm btn-outline add-extract" data-index="${index}">添加</button>
|
||
</div>
|
||
</div>
|
||
${(field.Extract || []).length > 0 ? `
|
||
<div class="extract-tree" data-index="${index}">
|
||
${(field.Extract || []).map((extract, ei) => `
|
||
<div class="extract-tree-item">
|
||
<span class="tree-text">${extract}</span>
|
||
<button class="tree-remove" data-field="${index}" data-extract="${ei}" title="删除">✕</button>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
` : `
|
||
<div class="extract-empty">暂无提取来源,请在下方添加</div>
|
||
`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
// 绑定删除映射事件
|
||
container.querySelectorAll('.remove-mapping').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const index = parseInt(btn.dataset.index);
|
||
this.extractFields.splice(index, 1);
|
||
this.renderFieldMappings();
|
||
});
|
||
});
|
||
|
||
// 绑定字段名修改事件
|
||
container.querySelectorAll('.field-input').forEach(input => {
|
||
input.addEventListener('change', () => {
|
||
const index = parseInt(input.dataset.index);
|
||
this.extractFields[index].Field = input.value.trim();
|
||
});
|
||
});
|
||
|
||
// 绑定字段类型修改事件
|
||
container.querySelectorAll('.type-select').forEach(select => {
|
||
select.addEventListener('change', () => {
|
||
const index = parseInt(select.dataset.index);
|
||
this.extractFields[index].Type = select.value;
|
||
});
|
||
});
|
||
|
||
// 绑定删除提取来源事件
|
||
container.querySelectorAll('.tree-remove').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const fieldIndex = parseInt(btn.dataset.field);
|
||
const extractIndex = parseInt(btn.dataset.extract);
|
||
this.extractFields[fieldIndex].Extract.splice(extractIndex, 1);
|
||
this.renderFieldMappings();
|
||
});
|
||
});
|
||
|
||
// 绑定添加提取来源事件
|
||
container.querySelectorAll('.add-extract').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const index = parseInt(btn.dataset.index);
|
||
const input = container.querySelector(`.extract-input[data-index="${index}"]`);
|
||
const value = input.value.trim();
|
||
|
||
if (!value) return;
|
||
|
||
if (!this.extractFields[index].Extract) {
|
||
this.extractFields[index].Extract = [];
|
||
}
|
||
|
||
if (!this.extractFields[index].Extract.includes(value)) {
|
||
this.extractFields[index].Extract.push(value);
|
||
this.renderFieldMappings();
|
||
} else {
|
||
showToast('该来源已存在', 'warning');
|
||
}
|
||
});
|
||
});
|
||
|
||
// 绑定回车添加
|
||
container.querySelectorAll('.extract-input').forEach(input => {
|
||
input.addEventListener('keyup', (e) => {
|
||
if (e.key === 'Enter') {
|
||
const index = parseInt(input.dataset.index);
|
||
container.querySelector(`.add-extract[data-index="${index}"]`).click();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
addFieldMapping() {
|
||
this.extractFields.push({
|
||
Field: '',
|
||
Type: 'string',
|
||
Extract: []
|
||
});
|
||
this.renderFieldMappings();
|
||
|
||
// 滚动到底部
|
||
const container = $('#fieldMappingList');
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
async saveFieldMappings() {
|
||
// 过滤空字段
|
||
const validFields = this.extractFields.filter(f => f.Field && f.Field.trim());
|
||
|
||
if (validFields.length === 0) {
|
||
showToast('请至少添加一个有效的字段映射', 'warning');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await api('/config/extract-fields', {
|
||
method: 'POST',
|
||
body: JSON.stringify(validFields)
|
||
});
|
||
this.extractFields = validFields;
|
||
showToast('字段映射配置已保存', 'success');
|
||
$('#configUpdate').textContent = `更新时间: ${result.update}`;
|
||
} catch (error) {
|
||
showToast(`保存失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async downloadConfig() {
|
||
try {
|
||
const response = await fetch('/api/config/download', {
|
||
method: 'GET'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('下载失败');
|
||
}
|
||
|
||
// 获取文件名(从 Content-Disposition 头或使用默认名称)
|
||
const contentDisposition = response.headers.get('Content-Disposition');
|
||
let filename = 'Configure.json';
|
||
if (contentDisposition) {
|
||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/);
|
||
if (filenameMatch) {
|
||
filename = filenameMatch[1];
|
||
}
|
||
}
|
||
|
||
// 下载文件
|
||
const blob = await response.blob();
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
|
||
showToast('配置文件下载成功', 'success');
|
||
} catch (error) {
|
||
showToast(`下载失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
async uploadConfig(event) {
|
||
const file = event.target.files[0];
|
||
if (!file) return;
|
||
|
||
// 验证文件类型
|
||
if (!file.name.endsWith('.json')) {
|
||
showToast('只支持 JSON 格式的配置文件', 'error');
|
||
event.target.value = '';
|
||
return;
|
||
}
|
||
|
||
// 确认上传
|
||
const confirmed = await showConfirm(
|
||
'上传配置',
|
||
`确定要上传配置文件 "${file.name}" 吗?当前配置将被替换,系统会自动备份原配置。`
|
||
);
|
||
|
||
if (!confirmed) {
|
||
event.target.value = '';
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
|
||
const response = await fetch('/api/config/upload', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
const result = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(result.detail || '上传失败');
|
||
}
|
||
|
||
// 重新加载配置
|
||
await this.loadConfig();
|
||
|
||
let message = '配置文件上传成功';
|
||
if (result.backup) {
|
||
message += `,原配置已备份为 ${result.backup}`;
|
||
}
|
||
|
||
showToast(message, 'success');
|
||
$('#configUpdate').textContent = `更新时间: ${result.update}`;
|
||
|
||
} catch (error) {
|
||
showToast(`上传失败: ${error.message}`, 'error');
|
||
} finally {
|
||
// 清空文件选择,允许重复选择同一文件
|
||
event.target.value = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
// ==================== Cache 大小显示 ====================
|
||
|
||
async function updateCacheSize() {
|
||
const cacheSizeEl = $('#cacheSize');
|
||
if (!cacheSizeEl) return;
|
||
|
||
try {
|
||
const result = await api('/cache/size');
|
||
if (result.success) {
|
||
cacheSizeEl.textContent = `历史数据: ${result.size_formatted}`;
|
||
} else {
|
||
cacheSizeEl.textContent = '计算失败';
|
||
}
|
||
} catch (error) {
|
||
cacheSizeEl.textContent = '计算失败';
|
||
}
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
initApp();
|
||
});
|
||
|
||
function initApp() {
|
||
// 主题管理(最先初始化)
|
||
new ThemeManager();
|
||
|
||
// 其他模块
|
||
const navigation = new Navigation();
|
||
window.fileUploader = new FileUploader();
|
||
new HistoryManager();
|
||
new DatabaseManager();
|
||
new SettingsManager();
|
||
|
||
// 恢复上次访问的页面
|
||
navigation.restorePage();
|
||
|
||
console.log('CapacityReport v2.0.2 已加载');
|
||
|
||
// 退出登录按钮事件(事件委托)
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.closest('.logout-btn') || e.target.closest('#logoutBtn')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
logout();
|
||
}
|
||
});
|
||
|
||
// 修改密码按钮事件
|
||
const changePwdBtn = $('#changePassword');
|
||
if (changePwdBtn) {
|
||
changePwdBtn.addEventListener('click', async () => {
|
||
const currentPwd = $('#currentPassword')?.value;
|
||
const newPwd = $('#newPassword')?.value;
|
||
const confirmPwd = $('#confirmPassword')?.value;
|
||
|
||
if (!currentPwd) { showToast('请输入当前密码', 'warning'); return; }
|
||
if (!newPwd) { showToast('请输入新密码', 'warning'); return; }
|
||
if (newPwd.length < 4) { showToast('新密码长度不能少于4位', 'warning'); return; }
|
||
if (newPwd !== confirmPwd) { showToast('两次输入的新密码不一致', 'warning'); return; }
|
||
|
||
try {
|
||
const res = await api('/change-password', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ current_password: currentPwd, new_password: newPwd })
|
||
});
|
||
if (res.success) {
|
||
showToast('密码修改成功,请重新登录', 'success');
|
||
$('#currentPassword').value = '';
|
||
$('#newPassword').value = '';
|
||
$('#confirmPassword').value = '';
|
||
setTimeout(() => logout(), 1500);
|
||
}
|
||
} catch (err) {
|
||
showToast(err.message || '密码修改失败', 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 重启服务按钮事件(使用事件委托,支持所有页面的重启按钮)
|
||
document.addEventListener('click', async (e) => {
|
||
if (e.target.closest('.restart-btn') || e.target.closest('#restartService')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
|
||
const confirmed = await showConfirm(
|
||
'重启服务',
|
||
'确定要重启服务吗?这将中断当前所有操作。'
|
||
);
|
||
|
||
if (!confirmed) return;
|
||
|
||
// 显示加载遮罩
|
||
showRestartOverlay('正在重启服务...');
|
||
|
||
try {
|
||
await api('/service/restart', { method: 'POST' });
|
||
} catch (error) {
|
||
// 请求可能因为服务重启而失败,这是正常的
|
||
}
|
||
|
||
// 开始轮询检测服务是否恢复
|
||
pollServiceStatus();
|
||
}
|
||
});
|
||
|
||
// 显示重启加载遮罩
|
||
function showRestartOverlay(message) {
|
||
let overlay = $('#restartOverlay');
|
||
if (overlay) {
|
||
overlay.querySelector('.restart-overlay-text').textContent = message;
|
||
overlay.classList.add('active');
|
||
}
|
||
}
|
||
|
||
// 隐藏重启加载遮罩
|
||
function hideRestartOverlay() {
|
||
let overlay = $('#restartOverlay');
|
||
if (overlay) {
|
||
overlay.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
// 更新遮罩文字
|
||
function updateRestartOverlayText(message) {
|
||
let overlay = $('#restartOverlay');
|
||
if (overlay) {
|
||
overlay.querySelector('.restart-overlay-text').textContent = message;
|
||
}
|
||
}
|
||
|
||
// 轮询检测服务状态
|
||
function pollServiceStatus() {
|
||
let attempts = 0;
|
||
const maxAttempts = 60; // 最多尝试 60 次(约 5 分钟)
|
||
const pollInterval = 5000; // 每 5 秒检测一次
|
||
|
||
const checkService = async () => {
|
||
attempts++;
|
||
|
||
try {
|
||
const response = await fetch('/api/service/status', {
|
||
method: 'GET',
|
||
headers: { 'Content-Type': 'application/json' }
|
||
});
|
||
|
||
if (response.ok) {
|
||
// 服务已恢复
|
||
updateRestartOverlayText('服务已恢复,正在刷新页面...');
|
||
setTimeout(() => {
|
||
window.location.reload();
|
||
}, 500);
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
// 服务还未恢复,继续轮询
|
||
}
|
||
|
||
if (attempts < maxAttempts) {
|
||
setTimeout(checkService, pollInterval);
|
||
} else {
|
||
// 超时,提示用户手动刷新
|
||
hideRestartOverlay();
|
||
showToast('服务重启超时,请手动刷新页面', 'warning');
|
||
}
|
||
};
|
||
|
||
// 延迟 3 秒后开始轮询,给服务一些启动时间
|
||
setTimeout(checkService, 3000);
|
||
}
|
||
|
||
// 侧边栏折叠功能
|
||
const sidebar = $('#sidebar');
|
||
|
||
// 从 localStorage 读取折叠状态
|
||
const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
|
||
if (isCollapsed && sidebar) {
|
||
sidebar.classList.add('collapsed');
|
||
}
|
||
|
||
// 使用事件委托为所有侧边栏切换按钮绑定事件
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.closest('#sidebarToggle') || e.target.closest('.sidebar-toggle')) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (sidebar) {
|
||
sidebar.classList.toggle('collapsed');
|
||
const collapsed = sidebar.classList.contains('collapsed');
|
||
localStorage.setItem('sidebarCollapsed', collapsed ? 'true' : 'false');
|
||
}
|
||
}
|
||
});
|
||
|
||
// 初始化脚本编辑器(延迟初始化,只在页面显示时创建)
|
||
window.scriptEditor = new ScriptEditor();
|
||
|
||
// 监听页面切换事件,延迟初始化 Monaco Editor
|
||
window.addEventListener('pagechange', (e) => {
|
||
if (e.detail.page === 'script' && window.scriptEditor) {
|
||
// 延迟一点确保 DOM 已更新
|
||
setTimeout(() => {
|
||
window.scriptEditor.ensureEditor();
|
||
}, 100);
|
||
}
|
||
});
|
||
|
||
// 如果初始页面就是脚本编辑页面,也需要初始化
|
||
const initialPage = document.querySelector('.page.active')?.id;
|
||
if (initialPage === 'page-script' && window.scriptEditor) {
|
||
setTimeout(() => {
|
||
window.scriptEditor.ensureEditor();
|
||
}, 300);
|
||
}
|
||
}
|
||
|
||
|
||
// ==================== 脚本编辑器类 ====================
|
||
|
||
class ScriptEditor {
|
||
constructor() {
|
||
this.editor = null;
|
||
this.originalContent = '';
|
||
this.isModified = false;
|
||
this.monacoReady = false;
|
||
this.monacoLoading = false;
|
||
this.initAttempted = false;
|
||
this.taskId = null;
|
||
this.scriptPollInterval = null;
|
||
|
||
// 只绑定事件,不立即初始化编辑器
|
||
this.bindEvents();
|
||
}
|
||
|
||
async waitForLoader() {
|
||
// 等待 loader.js 加载完成
|
||
let attempts = 0;
|
||
const maxAttempts = 50; // 最多等待 5 秒
|
||
|
||
while (attempts < maxAttempts) {
|
||
if (typeof require !== 'undefined' && typeof require.config === 'function') {
|
||
return true;
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
attempts++;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async initMonaco() {
|
||
// 如果正在加载或已加载,直接返回
|
||
if (this.monacoLoading || this.monacoReady) {
|
||
return;
|
||
}
|
||
|
||
this.monacoLoading = true;
|
||
|
||
try {
|
||
// 等待 loader.js 加载完成
|
||
const loaderReady = await this.waitForLoader();
|
||
if (!loaderReady) {
|
||
throw new Error('Monaco Editor loader.js 加载超时');
|
||
}
|
||
|
||
// 只在第一次配置,避免重复配置导致冲突
|
||
if (!window.__monacoConfigSet) {
|
||
require.config({
|
||
paths: {
|
||
'vs': '/static/lib/monaco/vs'
|
||
},
|
||
'vs/nls': {
|
||
availableLanguages: {
|
||
'*': 'zh-cn' // 使用中文语言包
|
||
}
|
||
},
|
||
// 添加错误处理配置
|
||
onError: (err) => {
|
||
console.error('Monaco Editor 模块加载错误:', err);
|
||
// 不抛出错误,让加载继续
|
||
}
|
||
});
|
||
window.__monacoConfigSet = true;
|
||
}
|
||
|
||
// 先预加载关键依赖模块,确保它们完全加载
|
||
// 这样可以避免竞态条件导致的 undefined 描述符问题
|
||
await new Promise((resolve, reject) => {
|
||
setTimeout(() => {
|
||
try {
|
||
// 先加载 editor.api,确保基础 API 可用
|
||
require(['vs/editor.api.001a2486'], (apiModule) => {
|
||
if (!apiModule || !apiModule.editor) {
|
||
reject(new Error('editor.api 模块加载不完整'));
|
||
return;
|
||
}
|
||
// 验证关键 API 是否可用
|
||
if (!apiModule.editor || typeof apiModule.editor.create !== 'function') {
|
||
reject(new Error('editor.api 缺少关键方法'));
|
||
return;
|
||
}
|
||
console.log('editor.api 已加载并验证');
|
||
resolve();
|
||
}, (err) => {
|
||
console.error('editor.api 加载失败:', err);
|
||
reject(err);
|
||
});
|
||
} catch (error) {
|
||
reject(error);
|
||
}
|
||
}, 100);
|
||
});
|
||
|
||
// 再等待一点时间,确保所有依赖模块完全初始化
|
||
await new Promise(resolve => setTimeout(resolve, 150));
|
||
|
||
// 再加载完整的编辑器
|
||
await new Promise((resolve, reject) => {
|
||
try {
|
||
require(['vs/editor/editor.main'], (module) => {
|
||
// 检查模块是否正确加载
|
||
if (!module || !module.m) {
|
||
reject(new Error('Monaco Editor 模块加载不完整'));
|
||
return;
|
||
}
|
||
|
||
// 验证关键 API 是否可用
|
||
if (!module.m.editor || !module.m.editor.create) {
|
||
reject(new Error('Monaco Editor API 不完整'));
|
||
return;
|
||
}
|
||
|
||
// 将模块暴露到全局
|
||
window.monaco = module.m;
|
||
|
||
this.monacoReady = true;
|
||
this.monacoLoading = false;
|
||
console.log('Monaco Editor 加载成功');
|
||
resolve();
|
||
}, (err) => {
|
||
this.monacoLoading = false;
|
||
console.error('Monaco Editor 模块加载失败:', err);
|
||
// 尝试获取更详细的错误信息
|
||
if (err && err.requireModules) {
|
||
console.error('失败的模块:', err.requireModules);
|
||
}
|
||
reject(err);
|
||
});
|
||
} catch (error) {
|
||
this.monacoLoading = false;
|
||
console.error('Monaco Editor 加载异常:', error);
|
||
reject(error);
|
||
}
|
||
});
|
||
} catch (error) {
|
||
this.monacoLoading = false;
|
||
console.error('初始化 Monaco Editor 失败:', error);
|
||
showToast('编辑器加载失败: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async ensureEditor() {
|
||
// 检查容器是否存在且可见
|
||
const container = document.getElementById('scriptEditor');
|
||
if (!container) {
|
||
console.warn('脚本编辑器容器不存在');
|
||
return;
|
||
}
|
||
|
||
// 检查容器是否可见
|
||
const page = document.getElementById('page-script');
|
||
if (!page || !page.classList.contains('active')) {
|
||
console.warn('脚本编辑页面未激活');
|
||
return;
|
||
}
|
||
|
||
// 如果编辑器已创建,只需重新布局
|
||
if (this.editor) {
|
||
setTimeout(() => {
|
||
this.editor.layout();
|
||
}, 100);
|
||
return;
|
||
}
|
||
|
||
// 如果 Monaco 未加载,先加载
|
||
if (!this.monacoReady) {
|
||
await this.initMonaco();
|
||
}
|
||
|
||
// 创建编辑器
|
||
if (this.monacoReady && !this.editor) {
|
||
this.createEditor();
|
||
}
|
||
}
|
||
|
||
createEditor() {
|
||
const container = document.getElementById('scriptEditor');
|
||
if (!container) {
|
||
console.error('脚本编辑器容器不存在');
|
||
return;
|
||
}
|
||
|
||
// 如果编辑器已存在,先销毁
|
||
if (this.editor) {
|
||
try {
|
||
this.editor.dispose();
|
||
} catch (e) {
|
||
console.warn('销毁旧编辑器失败:', e);
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 检测当前主题
|
||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||
|
||
// 创建编辑器实例
|
||
this.editor = monaco.editor.create(container, {
|
||
value: '-- 加载中...',
|
||
language: 'sql',
|
||
theme: isDark ? 'vs-dark' : 'vs',
|
||
fontSize: 14,
|
||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace",
|
||
minimap: { enabled: true },
|
||
automaticLayout: true,
|
||
scrollBeyondLastLine: false,
|
||
wordWrap: 'on',
|
||
lineNumbers: 'on',
|
||
renderLineHighlight: 'all',
|
||
selectOnLineNumbers: true,
|
||
roundedSelection: true,
|
||
cursorBlinking: 'smooth',
|
||
cursorSmoothCaretAnimation: 'on',
|
||
smoothScrolling: true,
|
||
tabSize: 4,
|
||
insertSpaces: true,
|
||
folding: true,
|
||
foldingStrategy: 'indentation',
|
||
showFoldingControls: 'always',
|
||
bracketPairColorization: { enabled: true },
|
||
guides: {
|
||
bracketPairs: true,
|
||
indentation: true
|
||
},
|
||
suggest: {
|
||
showKeywords: true,
|
||
showSnippets: true
|
||
}
|
||
});
|
||
|
||
// 监听内容变化
|
||
this.editor.onDidChangeModelContent(() => {
|
||
this.checkModified();
|
||
});
|
||
|
||
// 监听光标位置变化
|
||
this.editor.onDidChangeCursorPosition((e) => {
|
||
this.updateCursorPosition(e.position);
|
||
});
|
||
|
||
// 添加快捷键
|
||
this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
|
||
this.saveScript();
|
||
});
|
||
|
||
// 监听窗口大小变化,自动调整布局
|
||
window.addEventListener('resize', () => {
|
||
if (this.editor) {
|
||
this.editor.layout();
|
||
}
|
||
});
|
||
|
||
// 延迟加载脚本内容,确保编辑器已完全渲染
|
||
setTimeout(() => {
|
||
this.loadScript();
|
||
}, 200);
|
||
} catch (error) {
|
||
console.error('创建编辑器失败:', error);
|
||
showToast('创建编辑器失败: ' + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
async loadScript() {
|
||
try {
|
||
this.updateStatus('加载中...');
|
||
const result = await api('/script/content');
|
||
|
||
if (result.success) {
|
||
this.originalContent = result.content;
|
||
if (this.editor) {
|
||
this.editor.setValue(result.content);
|
||
}
|
||
|
||
// 更新路径和修改时间
|
||
const pathEl = document.getElementById('scriptPath');
|
||
const modifiedEl = document.getElementById('scriptModified');
|
||
|
||
if (pathEl) {
|
||
pathEl.textContent = result.path;
|
||
pathEl.title = result.path;
|
||
}
|
||
|
||
if (modifiedEl && result.modified) {
|
||
modifiedEl.textContent = `最后修改: ${result.modified}`;
|
||
}
|
||
|
||
this.isModified = false;
|
||
this.updateStatus('就绪');
|
||
} else {
|
||
showToast('加载脚本失败: ' + result.error, 'error');
|
||
this.updateStatus('加载失败');
|
||
}
|
||
} catch (error) {
|
||
showToast('加载脚本失败: ' + error.message, 'error');
|
||
this.updateStatus('加载失败');
|
||
}
|
||
}
|
||
|
||
async saveScript() {
|
||
if (!this.editor) return;
|
||
|
||
try {
|
||
this.updateStatus('保存中...');
|
||
const content = this.editor.getValue();
|
||
|
||
const result = await api('/script/save', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ content })
|
||
});
|
||
|
||
if (result.success) {
|
||
this.originalContent = content;
|
||
this.isModified = false;
|
||
|
||
// 更新修改时间
|
||
const modifiedEl = document.getElementById('scriptModified');
|
||
if (modifiedEl && result.modified) {
|
||
modifiedEl.textContent = `最后修改: ${result.modified}`;
|
||
}
|
||
|
||
showToast('脚本保存成功', 'success');
|
||
this.updateStatus('已保存');
|
||
} else {
|
||
showToast('保存失败: ' + result.error, 'error');
|
||
this.updateStatus('保存失败');
|
||
}
|
||
} catch (error) {
|
||
showToast('保存失败: ' + error.message, 'error');
|
||
this.updateStatus('保存失败');
|
||
}
|
||
}
|
||
|
||
formatScript() {
|
||
if (!this.editor) return;
|
||
|
||
// Monaco 内置的格式化功能
|
||
this.editor.getAction('editor.action.formatDocument').run();
|
||
showToast('格式化完成', 'success');
|
||
}
|
||
|
||
checkModified() {
|
||
if (!this.editor) return;
|
||
|
||
const current = this.editor.getValue();
|
||
this.isModified = current !== this.originalContent;
|
||
|
||
// 更新标题显示修改状态
|
||
const saveBtn = document.getElementById('saveScript');
|
||
if (saveBtn) {
|
||
if (this.isModified) {
|
||
saveBtn.classList.add('modified');
|
||
saveBtn.innerHTML = '💾 保存 *';
|
||
} else {
|
||
saveBtn.classList.remove('modified');
|
||
saveBtn.innerHTML = '💾 保存';
|
||
}
|
||
}
|
||
}
|
||
|
||
updateCursorPosition(position) {
|
||
const cursorEl = document.getElementById('editorCursor');
|
||
if (cursorEl) {
|
||
cursorEl.textContent = `行 ${position.lineNumber}, 列 ${position.column}`;
|
||
}
|
||
}
|
||
|
||
updateStatus(status) {
|
||
const statusEl = document.getElementById('editorStatus');
|
||
if (statusEl) {
|
||
statusEl.textContent = status;
|
||
}
|
||
}
|
||
|
||
updateTheme(isDark) {
|
||
if (this.editor) {
|
||
monaco.editor.setTheme(isDark ? 'vs-dark' : 'vs');
|
||
}
|
||
}
|
||
|
||
async runScript() {
|
||
// 检查是否有未保存的修改
|
||
if (this.isModified) {
|
||
const confirmed = await showConfirm(
|
||
'运行脚本',
|
||
'脚本有未保存的修改,是否先保存再运行?\n\n点击"确定"将先保存脚本,点击"取消"将直接运行当前内容。'
|
||
);
|
||
|
||
if (confirmed) {
|
||
await this.saveScript();
|
||
// 等待保存完成
|
||
await new Promise(resolve => setTimeout(resolve, 500));
|
||
}
|
||
}
|
||
|
||
try {
|
||
// 调用执行脚本 API
|
||
const result = await api('/script/execute', {
|
||
method: 'POST'
|
||
});
|
||
|
||
if (result.success) {
|
||
this.taskId = result.task_id;
|
||
showToast('脚本执行任务已启动', 'success');
|
||
|
||
// 跳转到上传页面显示处理进度
|
||
const navItem = document.querySelector('[data-page="upload"]');
|
||
if (navItem) {
|
||
navItem.click();
|
||
}
|
||
|
||
// 等待页面切换完成后再设置处理进度
|
||
setTimeout(() => {
|
||
// 使用上传页面的处理逻辑
|
||
if (window.fileUploader) {
|
||
window.fileUploader.taskId = this.taskId;
|
||
|
||
// 显示处理进度区域(使用上传页面的处理区域)
|
||
const processSection = document.getElementById('processSection');
|
||
if (processSection) {
|
||
processSection.style.display = 'block';
|
||
const processStatus = document.getElementById('processStatus');
|
||
if (processStatus) {
|
||
processStatus.className = 'process-status processing';
|
||
processStatus.textContent = '执行中...';
|
||
}
|
||
|
||
// 清空日志
|
||
const logContent = document.getElementById('logContent');
|
||
if (logContent) {
|
||
logContent.innerHTML = '';
|
||
}
|
||
|
||
// 隐藏上传区域和文件列表
|
||
const uploadZone = document.getElementById('uploadZone');
|
||
const fileList = document.getElementById('fileList');
|
||
if (uploadZone) uploadZone.style.display = 'none';
|
||
if (fileList) fileList.style.display = 'none';
|
||
|
||
// 使用上传页面的轮询逻辑
|
||
if (window.fileUploader.pollStatus) {
|
||
window.fileUploader.pollStatus();
|
||
}
|
||
}
|
||
}
|
||
}, 300);
|
||
} else {
|
||
showToast('启动脚本执行失败', 'error');
|
||
}
|
||
} catch (error) {
|
||
if (error.message.includes('已有任务在运行')) {
|
||
showToast('已有任务在运行,请等待完成', 'warning');
|
||
} else {
|
||
showToast(`启动脚本执行失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
pollScriptStatus() {
|
||
if (this.scriptPollInterval) {
|
||
clearInterval(this.scriptPollInterval);
|
||
}
|
||
|
||
const poll = async () => {
|
||
try {
|
||
// 检查自动刷新开关
|
||
const autoRefreshCheckbox = document.getElementById('scriptAutoRefreshLog');
|
||
if (autoRefreshCheckbox && !autoRefreshCheckbox.checked) {
|
||
if (this.scriptPollInterval) {
|
||
clearInterval(this.scriptPollInterval);
|
||
this.scriptPollInterval = null;
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 检查全局任务状态
|
||
const statusCheck = await api('/task/status');
|
||
|
||
if (!statusCheck.has_active) {
|
||
// 任务已完成,停止轮询
|
||
if (this.scriptPollInterval) {
|
||
clearInterval(this.scriptPollInterval);
|
||
this.scriptPollInterval = null;
|
||
}
|
||
|
||
// 获取最终状态
|
||
if (this.taskId) {
|
||
const finalStatus = await api('/process/status', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ task_id: this.taskId })
|
||
});
|
||
|
||
const processStatus = document.getElementById('scriptProcessStatus');
|
||
if (processStatus) {
|
||
if (finalStatus.status === 'completed') {
|
||
processStatus.className = 'process-status completed';
|
||
processStatus.textContent = '执行完成';
|
||
showToast('脚本执行完成', 'success');
|
||
} else {
|
||
processStatus.className = 'process-status failed';
|
||
processStatus.textContent = '执行失败';
|
||
showToast('脚本执行失败', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
this.taskId = null;
|
||
} else if (statusCheck.stage === 'processing' && this.taskId) {
|
||
// 获取处理状态和日志
|
||
const status = await api('/process/status', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ task_id: this.taskId })
|
||
});
|
||
|
||
// 更新日志显示
|
||
const logContent = document.getElementById('scriptLogContent');
|
||
if (logContent && status.logs) {
|
||
logContent.innerHTML = status.logs.map(log => {
|
||
let level = 'info';
|
||
if (log.includes('[ERROR]')) level = 'error';
|
||
else if (log.includes('[WARN]')) level = 'warn';
|
||
else if (log.includes('[SUCCESS]')) level = 'success';
|
||
return `<div class="log-line ${level}">${log}</div>`;
|
||
}).join('');
|
||
|
||
// 滚动到底部
|
||
logContent.scrollTop = logContent.scrollHeight;
|
||
}
|
||
|
||
// 更新状态
|
||
const processStatus = document.getElementById('scriptProcessStatus');
|
||
if (processStatus) {
|
||
if (status.status === 'completed') {
|
||
processStatus.className = 'process-status completed';
|
||
processStatus.textContent = '执行完成';
|
||
} else if (status.status === 'failed') {
|
||
processStatus.className = 'process-status failed';
|
||
processStatus.textContent = '执行失败';
|
||
} else {
|
||
processStatus.className = 'process-status processing';
|
||
processStatus.textContent = '执行中...';
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('轮询脚本执行状态失败:', error);
|
||
}
|
||
};
|
||
|
||
poll();
|
||
this.scriptPollInterval = setInterval(poll, 1000);
|
||
}
|
||
|
||
bindEvents() {
|
||
// 保存按钮
|
||
document.addEventListener('click', (e) => {
|
||
if (e.target.closest('#saveScript')) {
|
||
this.saveScript();
|
||
}
|
||
if (e.target.closest('#formatScript')) {
|
||
this.formatScript();
|
||
}
|
||
if (e.target.closest('#runScript')) {
|
||
this.runScript();
|
||
}
|
||
});
|
||
|
||
// 监听主题变化
|
||
const observer = new MutationObserver((mutations) => {
|
||
mutations.forEach((mutation) => {
|
||
if (mutation.attributeName === 'data-theme') {
|
||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||
this.updateTheme(isDark);
|
||
}
|
||
});
|
||
});
|
||
|
||
observer.observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['data-theme']
|
||
});
|
||
|
||
// 页面离开前提示保存
|
||
window.addEventListener('beforeunload', (e) => {
|
||
if (this.isModified) {
|
||
e.preventDefault();
|
||
e.returnValue = '您有未保存的更改,确定要离开吗?';
|
||
return e.returnValue;
|
||
}
|
||
});
|
||
}
|
||
}
|