/** * 小红书采集器前端逻辑 */ // ========== 状态管理 ========== const state = { isRunning: false, mode: 'single', maxThreads: 3, totalUsers: 0, completedUsers: 0, failedUsers: 0, activeThreads: 0, usersStatus: {}, threadStatus: {}, eventSource: null, autoScrollLogs: true, logFilter: 'all', logs: [] }; // ========== DOM 元素 ========== const elements = { startBtn: null, stopBtn: null, clearLogsBtn: null, exportDataBtn: null, themeToggle: null, themeIcon: null, modeRadios: null, threadControl: null, threadCount: null, progressBar: null, progressText: null, totalUsers: null, completedUsers: null, failedUsers: null, activeThreadsStat: null, activeThreads: null, userList: null, userCount: null, threadMonitorPanel: null, threadList: null, logContainer: null, logFilter: null, logSearch: null, screenshotPanel: null, screenshotImage: null, closeScreenshot: null, // 账号管理相关 addAccountBtn: null, refreshAccountsBtn: null, accountStatus: null, loginStatusIndicator: null, loginStatusText: null, // 模态框相关 accountModal: null, closeAccountModal: null, loginBtn: null, logoutBtn: null, loginInfo: null, modalLoginStatus: null, loginTime: null, modalUserList: null, modalUserCount: null, clearAllUsersBtn: null, newUserIdInput: null, addUserIdBtn: null, importFromTxtBtn: null, exportToTxtBtn: null, // 登录二维码模态框 loginQrModal: null, closeLoginQrModal: null, qrcodeContainer: null, qrLoginStatus: null, qrExpireTime: null, // Toast toast: null }; // ========== 初始化 ========== function init() { // 获取 DOM 元素 elements.startBtn = document.getElementById('startBtn'); elements.stopBtn = document.getElementById('stopBtn'); elements.clearLogsBtn = document.getElementById('clearLogsBtn'); elements.exportDataBtn = document.getElementById('exportDataBtn'); elements.themeToggle = document.getElementById('themeToggle'); elements.themeIcon = document.getElementById('themeIcon'); elements.modeRadios = document.querySelectorAll('input[name="mode"]'); elements.threadControl = document.getElementById('threadControl'); elements.threadCount = document.getElementById('threadCount'); elements.progressBar = document.getElementById('progressBar'); elements.progressText = document.getElementById('progressText'); elements.totalUsers = document.getElementById('totalUsers'); elements.completedUsers = document.getElementById('completedUsers'); elements.failedUsers = document.getElementById('failedUsers'); elements.activeThreadsStat = document.getElementById('activeThreadsStat'); elements.activeThreads = document.getElementById('activeThreads'); elements.userList = document.getElementById('userList'); elements.userCount = document.getElementById('userCount'); elements.threadMonitorPanel = document.getElementById('threadMonitorPanel'); elements.threadList = document.getElementById('threadList'); elements.logContainer = document.getElementById('logContainer'); elements.logFilter = document.getElementById('logFilter'); elements.logSearch = document.getElementById('logSearch'); elements.screenshotPanel = document.getElementById('screenshotPanel'); elements.screenshotImage = document.getElementById('screenshotImage'); elements.closeScreenshot = document.getElementById('closeScreenshot'); // 账号管理相关 elements.addAccountBtn = document.getElementById('addAccountBtn'); elements.refreshAccountsBtn = document.getElementById('refreshAccountsBtn'); elements.accountStatus = document.getElementById('accountStatus'); elements.loginStatusIndicator = document.getElementById('loginStatusIndicator'); elements.loginStatusText = document.getElementById('loginStatusText'); // 模态框相关 elements.accountModal = document.getElementById('accountModal'); elements.closeAccountModal = document.getElementById('closeAccountModal'); elements.loginBtn = document.getElementById('loginBtn'); elements.logoutBtn = document.getElementById('logoutBtn'); elements.loginInfo = document.getElementById('loginInfo'); elements.modalLoginStatus = document.getElementById('modalLoginStatus'); elements.loginTime = document.getElementById('loginTime'); elements.modalUserList = document.getElementById('modalUserList'); elements.modalUserCount = document.getElementById('modalUserCount'); elements.clearAllUsersBtn = document.getElementById('clearAllUsersBtn'); elements.newUserIdInput = document.getElementById('newUserIdInput'); elements.addUserIdBtn = document.getElementById('addUserIdBtn'); elements.importFromTxtBtn = document.getElementById('importFromTxtBtn'); elements.exportToTxtBtn = document.getElementById('exportToTxtBtn'); // 登录二维码模态框 elements.loginQrModal = document.getElementById('loginQrModal'); elements.closeLoginQrModal = document.getElementById('closeLoginQrModal'); elements.qrcodeContainer = document.getElementById('qrcodeContainer'); elements.qrLoginStatus = document.getElementById('qrLoginStatus'); elements.qrExpireTime = document.getElementById('qrExpireTime'); // Toast elements.toast = document.getElementById('toast'); // 加载主题 loadTheme(); // 加载登录状态 loadAccountStatus(); // 加载初始数据 loadInitialData(); // 绑定事件 bindEvents(); // 连接 SSE connectEventStream(); } // ========== 事件绑定 ========== function bindEvents() { // 启动按钮 elements.startBtn.addEventListener('click', startScraping); // 停止按钮 elements.stopBtn.addEventListener('click', stopScraping); // 清空日志 elements.clearLogsBtn.addEventListener('click', clearLogs); // 导出数据 elements.exportDataBtn.addEventListener('click', exportData); // 主题切换 elements.themeToggle.addEventListener('click', toggleTheme); // 模式切换 elements.modeRadios.forEach(radio => { radio.addEventListener('change', (e) => { state.mode = e.target.value; updateModeUI(); }); }); // 线程数变化 elements.threadCount.addEventListener('change', (e) => { state.maxThreads = parseInt(e.target.value); }); // 日志过滤 elements.logFilter.addEventListener('change', (e) => { state.logFilter = e.target.value; renderLogs(); }); // 日志搜索 elements.logSearch.addEventListener('input', debounce((e) => { searchLogs(e.target.value); }, 300)); // 关闭截图 elements.closeScreenshot.addEventListener('click', () => { elements.screenshotPanel.style.display = 'none'; }); // 日志容器滚动监听(自动滚动控制) elements.logContainer.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = elements.logContainer; state.autoScrollLogs = scrollTop + clientHeight >= scrollHeight - 10; }); // 账号管理事件 elements.addAccountBtn.addEventListener('click', openAccountModal); elements.refreshAccountsBtn.addEventListener('click', refreshAccounts); elements.closeAccountModal.addEventListener('click', closeAccountModal); elements.loginBtn.addEventListener('click', openLoginQrModal); elements.logoutBtn.addEventListener('click', logout); elements.clearAllUsersBtn.addEventListener('click', clearAllUsers); elements.addUserIdBtn.addEventListener('click', addUserId); elements.importFromTxtBtn.addEventListener('click', importFromTxt); elements.exportToTxtBtn.addEventListener('click', exportToTxt); // 新用户输入框回车事件 elements.newUserIdInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { addUserId(); } }); // 登录二维码模态框关闭 elements.closeLoginQrModal.addEventListener('click', closeLoginQrModal); // 点击模态框外部关闭 window.addEventListener('click', (e) => { if (e.target === elements.accountModal) { closeAccountModal(); } if (e.target === elements.loginQrModal) { closeLoginQrModal(); } }); } // ========== 加载初始数据 ========== async function loadInitialData() { try { // 加载状态 const statusRes = await fetch('/api/status'); const statusData = await statusRes.json(); updateStatus(statusData); // 加载用户列表 const usersRes = await fetch('/api/users'); const usersData = await usersRes.json(); renderUserList(usersData.users); // 加载历史日志 const logsRes = await fetch('/api/logs?limit=100'); const logsData = await logsRes.json(); state.logs = logsData.logs; renderLogs(); } catch (error) { console.error('加载初始数据失败:', error); } } // ========== 启动采集 ========== async function startScraping() { try { const response = await fetch('/api/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: state.mode, max_threads: state.maxThreads }) }); const data = await response.json(); if (response.ok) { state.isRunning = true; updateButtons(); addLog('SUCCESS', data.message); } else { addLog('ERROR', data.detail || '启动失败'); } } catch (error) { addLog('ERROR', `启动失败: ${error.message}`); } } // ========== 停止采集 ========== async function stopScraping() { try { const response = await fetch('/api/stop', { method: 'POST' }); const data = await response.json(); if (response.ok) { state.isRunning = false; updateButtons(); addLog('WARNING', data.message); } } catch (error) { addLog('ERROR', `停止失败: ${error.message}`); } } // ========== 清空日志 ========== async function clearLogs() { try { const response = await fetch('/api/logs/clear', { method: 'POST' }); const data = await response.json(); if (response.ok) { state.logs = []; renderLogs(); addLog('SUCCESS', '日志已清空'); } } catch (error) { addLog('ERROR', `清空日志失败: ${error.message}`); } } // ========== 导出数据 ========== async function exportData() { try { // 下载日志 window.location.href = '/api/logs/download'; addLog('SUCCESS', '正在下载日志文件...'); } catch (error) { addLog('ERROR', `导出失败: ${error.message}`); } } // ========== 搜索日志 ========== async function searchLogs(keyword) { if (!keyword) { // 加载所有日志 const logsRes = await fetch('/api/logs?limit=100'); const logsData = await logsRes.json(); state.logs = logsData.logs; } else { // 搜索日志 const searchRes = await fetch(`/api/logs/search?keyword=${encodeURIComponent(keyword)}&limit=100`); const searchData = await searchRes.json(); state.logs = searchData.logs; } renderLogs(); } // ========== SSE 事件流 ========== function connectEventStream() { if (state.eventSource) { state.eventSource.close(); } state.eventSource = new EventSource('/api/events'); state.eventSource.onmessage = (event) => { if (event.data.startsWith(': heartbeat')) { return; // 忽略心跳 } try { const data = JSON.parse(event.data); handleEvent(data); } catch (error) { console.error('解析事件失败:', error); } }; state.eventSource.onerror = (error) => { console.error('SSE 连接错误:', error); // 5秒后重连 setTimeout(() => { if (!state.eventSource || state.eventSource.readyState === EventSource.CLOSED) { connectEventStream(); } }, 5000); }; } // ========== 处理事件 ========== function handleEvent(event) { switch (event.type) { case 'log': addLog(event.level, event.message, event.thread_id, event.timestamp, false); break; case 'progress': updateProgress(event); break; case 'user_status': updateUserStatus(event); break; case 'thread_status': updateThreadStatus(event); break; case 'screenshot': showScreenshot(event.path); break; case 'finished': state.isRunning = false; updateButtons(); addLog('SUCCESS', '所有采集任务完成'); break; case 'error': addLog('ERROR', event.message); break; } } // ========== 更新状态 ========== function updateStatus(data) { state.isRunning = data.is_running; state.totalUsers = data.total_users; state.completedUsers = data.completed_users; state.failedUsers = data.failed_users; state.activeThreads = data.active_threads; state.usersStatus = data.users_status || {}; updateButtons(); updateProgress(); renderUserListFromStatus(); } // ========== 更新进度 ========== function updateProgress(data) { if (data) { state.completedUsers = data.completed || state.completedUsers; state.failedUsers = data.failed || state.failedUsers; state.activeThreads = data.active_threads || state.activeThreads; } const total = state.totalUsers; const completed = state.completedUsers; const failed = state.failedUsers; const progress = total > 0 ? Math.round(((completed + failed) / total) * 100) : 0; elements.progressBar.style.width = `${progress}%`; elements.progressText.textContent = `${progress}%`; elements.totalUsers.textContent = total; elements.completedUsers.textContent = completed; elements.failedUsers.textContent = failed; elements.activeThreads.textContent = state.activeThreads; // 多线程模式显示活跃线程 if (state.mode === 'multi') { elements.activeThreadsStat.style.display = 'flex'; } } // ========== 更新用户状态 ========== function updateUserStatus(data) { state.usersStatus[data.user_id] = { status: data.status, note_count: data.note_count, thread_id: data.thread_id }; renderUserListFromStatus(); } // ========== 更新线程状态 ========== function updateThreadStatus(data) { state.threadStatus[data.thread_id] = { status: data.status, current_user: data.current_user }; renderThreadList(); } // ========== 渲染用户列表 ========== function renderUserList(users) { if (!users || users.length === 0) { elements.userList.innerHTML = `
暂无用户数据
请在 data/users.txt 中添加用户 ID
暂无线程活动
登录状态已保存到本地
${data.expire_in_hours ? `有效期:${data.expire_in_hours}小时
` : ''}登录失败
'; elements.qrLoginStatus.textContent = '登录失败'; } } catch (error) { elements.qrcodeContainer.innerHTML = '网络错误
'; elements.qrLoginStatus.textContent = '网络错误'; } } // 关闭登录二维码模态框 function closeLoginQrModal() { elements.loginQrModal.classList.remove('show'); } // 检查登录状态 async function checkLoginStatus() { try { const response = await fetch('/api/login/status'); const data = await response.json(); if (data.logged_in) { elements.qrLoginStatus.textContent = '登录成功!'; setTimeout(() => { closeLoginQrModal(); closeAccountModal(); updateLoginStatus(true, data.login_time, data.expire_time); showToast('登录成功', 'success'); }, 1000); } else { // 继续检查 setTimeout(checkLoginStatus, 2000); } } catch (error) { // 网络错误,继续检查 setTimeout(checkLoginStatus, 2000); } } // 退出登录 async function logout() { try { const response = await fetch('/api/logout', { method: 'POST' }); const data = await response.json(); if (response.ok) { updateLoginStatus(false); showToast('已退出登录', 'success'); closeAccountModal(); } else { showToast('退出失败', 'error'); } } catch (error) { showToast('退出失败', 'error'); } } // 更新登录状态显示 function updateLoginStatus(isLoggedIn, loginTime = null, expireTime = null) { const indicator = elements.loginStatusIndicator; const statusText = elements.loginStatusText; const modalStatus = elements.modalLoginStatus; if (isLoggedIn) { indicator.className = 'status-indicator online'; statusText.textContent = '已登录'; modalStatus.textContent = '已登录'; modalStatus.style.color = 'var(--success-color)'; if (loginTime) { elements.loginTime.textContent = new Date(loginTime).toLocaleString('zh-CN'); } // 如果有到期时间,显示剩余时间 if (expireTime) { const expireDate = new Date(expireTime); const now = new Date(); const timeLeft = expireDate - now; if (timeLeft > 0) { const hours = Math.floor(timeLeft / (1000 * 60 * 60)); const minutes = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)); elements.loginTime.textContent = new Date(loginTime).toLocaleString('zh-CN'); elements.loginTime.innerHTML += `暂无用户数据
请在 data/users.txt 中添加用户 ID