/** * 眼镜行业情报分析系统前端逻辑 */ const API_BASE = 'http://localhost:8000/api'; // 配置 Marked.js if (typeof marked !== 'undefined') { marked.use({ gfm: true, breaks: true, mangle: false, headerIds: false }); } const app = { // 状态 state: { companies: [], reports: [], currentReport: null }, // 辅助:增强 Markdown 解析(修复中文粗体失效问题) parseMarkdown(text) { if (!text) return ''; // 预处理:修复 **紧贴中文或标点导致无法解析的问题 // 将 **...** 强制替换为 ... let fixedText = text.replace(/\*\*(.*?)\*\*/g, '$1'); return marked.parse(fixedText); }, // 初始化 init() { this.bindEvents(); this.checkStatus(); this.fetchDashboard(); // 初始路由 this.navigateTo('dashboard'); }, // 自定义 Toast showToast(message, type = 'info') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast ${type}`; let icon = 'info-circle'; if (type === 'success') icon = 'check-circle'; if (type === 'error') icon = 'exclamation-circle'; // 动态计算CSS变量颜色 let color = 'var(--primary-color)'; if (type === 'success') color = '#10b981'; if (type === 'error') color = '#ef4444'; toast.innerHTML = ` ${message} `; container.appendChild(toast); // 3秒后自动消失 setTimeout(() => { toast.style.animation = 'fadeOut 0.3s forwards'; setTimeout(() => toast.remove(), 300); }, 3000); }, // 自定义 Confirm showConfirm(title, message) { return new Promise((resolve) => { const overlay = document.getElementById('confirm-overlay'); const titleEl = document.getElementById('confirm-title'); const msgEl = document.getElementById('confirm-message'); const btnOk = document.getElementById('confirm-ok'); const btnCancel = document.getElementById('confirm-cancel'); titleEl.textContent = title; msgEl.textContent = message; overlay.classList.add('active'); const close = (result) => { overlay.classList.remove('active'); // 移除监听器防止内存泄漏 btnOk.onclick = null; btnCancel.onclick = null; resolve(result); }; btnOk.onclick = () => close(true); btnCancel.onclick = () => close(false); }); }, // 绑定事件 bindEvents() { // 导航切换 document.querySelectorAll('.nav-item').forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const pageId = e.currentTarget.dataset.page; this.navigateTo(pageId); // 移动端点击导航后自动收起 if (window.innerWidth <= 768) { this.toggleSidebar(false); } }); }); // 移动端菜单 const mobileBtn = document.getElementById('mobile-menu-btn'); const overlay = document.getElementById('sidebar-overlay'); if (mobileBtn) { mobileBtn.addEventListener('click', () => this.toggleSidebar()); } if (overlay) { overlay.addEventListener('click', () => this.toggleSidebar(false)); } // 定时任务按钮 document.getElementById('run-now-btn').addEventListener('click', () => this.runSchedulerNow()); // 添加公司 document.getElementById('btn-add-company').addEventListener('click', () => { this.openCompanyModal(); }); document.getElementById('add-company-form').addEventListener('submit', (e) => { e.preventDefault(); this.handleSaveCompany(e.target); }); // 模态框关闭 document.querySelectorAll('.close').forEach(btn => { btn.addEventListener('click', (e) => { const modal = e.target.closest('.modal'); modal.classList.remove('active'); if (modal.id === 'report-modal') { this.clearDetailPolling(); // 如果详情页显示已完成,关闭时自动刷新列表 if (this.state.currentReport && this.state.currentReport.is_analyzed) { this.fetchReports(); // 刷新列表 } } }); }); // 点击背景关闭模态框 document.querySelectorAll('.modal').forEach(modal => { modal.addEventListener('click', (e) => { if (e.target === modal) { modal.classList.remove('active'); if (modal.id === 'report-modal') { this.clearDetailPolling(); } } }); }); // ESC键关闭模态框 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { document.querySelectorAll('.modal.active').forEach(modal => { modal.classList.remove('active'); if (modal.id === 'report-modal') { this.clearDetailPolling(); } }); } }); // 标签页切换 document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', (e) => { const tabId = e.target.dataset.tab; // 更新按钮状态 e.target.parentElement.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); e.target.classList.add('active'); // 更新内容显示 const modalBody = e.target.closest('.modal-body'); modalBody.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); modalBody.querySelector(`#${tabId}`).classList.add('active'); }); }); // 报告筛选 const filters = ['filter-company', 'filter-type', 'filter-status']; filters.forEach(id => { document.getElementById(id).addEventListener('change', () => this.fetchReports()); }); // 注:重新分析按钮的事件在 showReportDetail 中动态绑定 }, // 切换侧边栏 toggleSidebar(forceState) { const sidebar = document.querySelector('.sidebar'); const overlay = document.getElementById('sidebar-overlay'); const btn = document.getElementById('mobile-menu-btn'); if (!sidebar || !overlay) return; const isActive = sidebar.classList.contains('active'); const newState = forceState !== undefined ? forceState : !isActive; if (newState) { sidebar.classList.add('active'); overlay.classList.add('active'); if (btn) btn.style.display = 'none'; // Optional: hide toggle button when open } else { sidebar.classList.remove('active'); overlay.classList.remove('active'); if (btn) btn.style.display = ''; } }, // 路由导航 navigateTo(pageId) { // 切换页面时清除详情轮询 this.clearDetailPolling(); // 更新导航激活状态 document.querySelectorAll('.nav-item').forEach(item => { item.classList.toggle('active', item.dataset.page === pageId); }); // 更新页面显示 document.querySelectorAll('.page').forEach(page => { page.classList.remove('active'); }); document.getElementById(`${pageId}-page`).classList.add('active'); // 页面初始化逻辑 if (pageId === 'companies') this.fetchCompanies(); if (pageId === 'reports') { this.fetchCompanies(); // 用于筛选下拉 this.fetchReports(); } if (pageId === 'dashboard') this.fetchDashboard(); }, // 获取仪表盘数据 async fetchDashboard() { try { // 获取统计数据 const [companies, reports, logs] = await Promise.all([ fetch(`${API_BASE}/companies`).then(r => r.json()), fetch(`${API_BASE}/reports?page_size=5`).then(r => r.json()), fetch(`${API_BASE}/scheduler/logs`).then(r => r.json()) ]); // 更新计数 document.getElementById('stat-companies').textContent = companies.length; document.getElementById('stat-reports').textContent = reports.length; // 实际应该请求总数API // 简单估算已分析数(实际应从API获取) const analyzedCount = reports.filter(r => r.is_analyzed).length; document.getElementById('stat-analyzed').textContent = analyzedCount; // 渲染最新洞察 const insightsContainer = document.getElementById('recent-insights-list'); insightsContainer.innerHTML = ''; reports.slice(0, 5).forEach(report => { if (report.is_analyzed) { const el = document.createElement('div'); el.className = 'insight-item card'; el.style.marginBottom = '10px'; el.style.padding = '12px'; el.innerHTML = `
${report.company_name} - ${report.title}
AI分析已完成 • ${new Date(report.created_at).toLocaleDateString()}
`; insightsContainer.appendChild(el); } }); if (insightsContainer.children.length === 0) { insightsContainer.innerHTML = '
暂无分析结果
'; } // 渲染日志 const logsContainer = document.getElementById('logs-list'); logsContainer.innerHTML = ''; logs.forEach(log => { const el = document.createElement('div'); el.className = 'log-item'; const statusColor = log.status === 'failed' ? 'red' : (log.status === 'completed' ? 'green' : 'blue'); el.innerHTML = `
[${log.task_name}] ${log.status}
${log.message || '-'}
${new Date(log.started_at).toLocaleString()}
`; logsContainer.appendChild(el); }); } catch (error) { console.error('获取仪表盘数据失败', error); } }, // 获取公司 async fetchCompanies() { try { const res = await fetch(`${API_BASE}/companies`); const companies = await res.json(); this.state.companies = companies; // 渲染表格 const tbody = document.querySelector('#companies-table tbody'); if (tbody) { tbody.innerHTML = companies.map(c => ` ${c.stock_code} ${c.short_name || '-'} ${c.company_name} ${c.industry || '-'} ${c.report_count} ${c.is_active ? '监控中' : '暂停'} `).join(''); // 绑定事件 tbody.querySelectorAll('.remove-btn').forEach(btn => { btn.addEventListener('click', (e) => this.deleteCompany(e.target.dataset.id)); }); tbody.querySelectorAll('.edit-btn').forEach(btn => { btn.addEventListener('click', (e) => this.openCompanyModal(e.target.dataset.id)); }); } // 更新筛选下拉 const select = document.getElementById('filter-company'); if (select) { select.innerHTML = '' + companies.map(c => ``).join(''); } } catch (error) { console.error('获取公司失败', error); } }, // 打开公司模态框 (新增/编辑) openCompanyModal(id = null) { const modal = document.getElementById('company-modal'); const form = document.getElementById('add-company-form'); const title = document.getElementById('company-modal-title'); const desc = document.getElementById('company-modal-desc'); const btn = document.getElementById('btn-save-company'); form.reset(); if (id) { // 编辑模式 const company = this.state.companies.find(c => c.id == id); if (!company) return; document.getElementById('company-id').value = company.id; document.getElementById('company-code').value = company.stock_code; document.getElementById('company-code').disabled = true; // 禁止修改代码 document.getElementById('company-name').value = company.company_name; document.getElementById('company-short-name').value = company.short_name; document.getElementById('company-industry').value = company.industry; title.textContent = '编辑公司信息'; desc.style.display = 'none'; btn.innerHTML = ' 更新信息'; } else { // 新增模式 document.getElementById('company-id').value = ''; document.getElementById('company-code').disabled = false; title.textContent = '添加公司'; desc.style.display = 'block'; btn.innerHTML = ' 查询并添加'; } this.showModal('company-modal'); }, // 保存公司 (新增/更新) async handleSaveCompany(form) { const formData = new FormData(form); const data = Object.fromEntries(formData.entries()); const id = data.id; // 移除空值 if (!data.company_name) delete data.company_name; if (!data.short_name) delete data.short_name; if (!data.industry) delete data.industry; try { let url = `${API_BASE}/companies`; let method = 'POST'; if (id) { url = `${API_BASE}/companies/${id}`; method = 'PUT'; } else { data.is_active = true; } const res = await fetch(url, { method: method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (res.ok) { this.showToast(id ? '更新成功' : '添加成功,将在下次定时任务中同步报告', 'success'); document.getElementById('company-modal').classList.remove('active'); this.fetchCompanies(); } else { const err = await res.json(); this.showToast('操作失败: ' + err.detail, 'error'); } } catch (error) { console.error('API错误', error); this.showToast('操作失败: 网络错误', 'error'); } }, // 删除公司 async deleteCompany(id) { if (!await this.showConfirm('删除确认', '确定要删除监控吗?已下载的报告将保留。')) return; try { await fetch(`${API_BASE}/companies/${id}`, { method: 'DELETE' }); this.showToast('删除成功', 'success'); this.fetchCompanies(); } catch (error) { this.showToast('删除失败', 'error'); } }, // 获取报告 async fetchReports() { const companyId = document.getElementById('filter-company').value; const type = document.getElementById('filter-type').value; const status = document.getElementById('filter-status').value; let url = `${API_BASE}/reports?page_size=50`; if (companyId) url += `&company_id=${companyId}`; if (type) url += `&report_type=${type}`; if (status === 'analyzed') url += `&is_analyzed=true`; if (status === 'pending') url += `&is_analyzed=false`; try { const res = await fetch(url); const reports = await res.json(); this.state.reports = reports; const grid = document.getElementById('reports-list'); if (!grid) return; grid.innerHTML = reports.map(r => { // 状态标签逻辑 let statusTag = ''; let statusClass = ''; // 优先判断是否正在处理中 const processingStates = ['extracting', 'analyzing', 'summarizing']; if (processingStates.includes(r.analysis_status)) { statusTag = '正在处理'; statusClass = 'analysis-processing'; } else if (r.is_analyzed) { statusTag = 'AI已解读'; statusClass = 'analysis-done'; } else if (r.is_extracted) { statusTag = '待AI分析'; statusClass = 'analysis-pending'; } else if (r.is_downloaded) { statusTag = '待提取'; statusClass = 'analysis-pending'; } else { statusTag = '待下载'; statusClass = ''; } return ` ${r.company_name}
${r.title}
${r.report_type === '年度报告' ? '年报' : '半年报'} ${r.report_year} ${new Date(r.announcement_time).toLocaleDateString()} ${statusTag} `; }).join(''); if (reports.length === 0) { grid.innerHTML = '暂无报告数据'; } } catch (error) { console.error('获取报告失败', error); } }, // 轮询句柄 detailPollingInterval: null, // 清除详情轮询 clearDetailPolling() { if (this.detailPollingInterval) { clearTimeout(this.detailPollingInterval); this.detailPollingInterval = null; } }, // 显示报告详情 async showReportDetail(id) { // 清除旧的轮询 this.clearDetailPolling(); try { const res = await fetch(`${API_BASE}/reports/${id}`); const report = await res.json(); this.state.currentReport = report; // 填充基本信息 document.getElementById('modal-title').textContent = report.title; const tags = document.getElementById('modal-tags'); // 状态显示映射 const statusMap = { 'pending': '待处理', 'extracting': '提取中', 'analyzing': '分析中', 'summarizing': '汇总中', 'completed': '已完成', 'failed': '失败' }; const statusText = statusMap[report.analysis_status] || report.analysis_status; const isProcessing = ['extracting', 'analyzing', 'summarizing'].includes(report.analysis_status); tags.innerHTML = ` ${report.stock_code} ${report.report_year}年${report.report_period} ${statusText} ${isProcessing ? '' : ''} `; // 获取当前激活的 Tab,如果不存在则默认为 'ai-summary' const modal = document.getElementById('report-modal'); const activeTabBtn = modal.querySelector('.tab-btn.active'); const activeTabId = activeTabBtn ? activeTabBtn.dataset.tab : 'ai-summary'; // 设置 Tab 切换逻辑 (使用事件委托,但要防止重复绑定) if (!modal.hasAttribute('data-tabs-initialized')) { modal.addEventListener('click', (e) => { if (e.target.classList.contains('tab-btn')) { // 移除所有 active modal.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); modal.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); // 激活当前 e.target.classList.add('active'); const targetId = e.target.dataset.tab; const targetContent = document.getElementById(targetId); if (targetContent) targetContent.classList.add('active'); } }); modal.setAttribute('data-tabs-initialized', 'true'); } // 恢复 Tab 状态 const tabs = modal.querySelectorAll('.tab-btn'); const contents = modal.querySelectorAll('.tab-content'); tabs.forEach(t => { if (t.dataset.tab === activeTabId) { t.classList.add('active'); } else { t.classList.remove('active'); } }); contents.forEach(c => { if (c.id === activeTabId) { c.classList.add('active'); } else { c.classList.remove('active'); } }); // 填充AI总结 const summaryTab = document.getElementById('summary-content'); const summaries = report.analysis_results.filter(a => a.analysis_type === 'summary'); summaries.sort((a, b) => b.id - a.id); const summary = summaries[0]; if (summary) { summaryTab.innerHTML = this.parseMarkdown(summary.summary); } else if (isProcessing) { summaryTab.innerHTML = `

AI正在深度分析中...

由于章节较多(通常15-25个),且每个章节分析耗时约30-60秒,整份报告分析可能需要5-10分钟。

您可以切换到“章节分析”标签查看实时进度。

`; } else if (!report.is_downloaded) { summaryTab.innerHTML = '

报告尚未下载

'; } else if (!report.is_extracted) { summaryTab.innerHTML = '

PDF已下载,请点击下方按钮开始分析

'; } else { summaryTab.innerHTML = '

内容已提取,请点击下方按钮开始分析

'; } // 填充章节分析 const sectionsList = document.getElementById('sections-list'); const sectionResults = report.analysis_results.filter(a => a.analysis_type === 'section'); if (sectionResults.length > 0) { sectionsList.innerHTML = sectionResults.map(s => `

${s.section_name}

${this.parseMarkdown(s.summary)}
`).join(''); if (isProcessing) { sectionsList.innerHTML += `
正在分析新的章节...
`; } } else { sectionsList.innerHTML = `
${isProcessing ? ' 正在处理首个章节...' : '暂无章节分析'}
`; } // 填充原文 const rawList = document.getElementById('raw-content-list'); if (report.extracted_contents.length > 0) { rawList.innerHTML = report.extracted_contents.map(c => `
${c.section_name} 匹配: ${c.section_keyword}
${c.content}
`).join(''); } else { rawList.innerHTML = '
未提取到相关内容
'; } // 动态设置按钮 const analyzeBtn = document.getElementById('btn-reanalyze'); const forceResetContainer = document.getElementById('force-reset-container') || document.createElement('div'); // 确保 force-reset-container 存在并紧跟在 analyzeBtn 后面 if (!document.getElementById('force-reset-container')) { forceResetContainer.id = 'force-reset-container'; forceResetContainer.style.marginTop = '10px'; forceResetContainer.style.fontSize = '0.75rem'; analyzeBtn.parentNode.appendChild(forceResetContainer); } forceResetContainer.innerHTML = ''; analyzeBtn.disabled = isProcessing; if (isProcessing) { analyzeBtn.textContent = '分析中...'; // 增加强制重置选项 forceResetContainer.innerHTML = ` 发现卡住了?点此强制重新开始 `; } else if (report.analysis_status === 'failed') { analyzeBtn.textContent = '分析失败,点此重试'; analyzeBtn.disabled = false; analyzeBtn.onclick = () => this.triggerAnalysis(report.id, true); } else if (report.is_analyzed) { analyzeBtn.textContent = '重新分析'; analyzeBtn.onclick = async () => { if (await this.showConfirm('重新分析', '确定要重新分析此报告吗?旧的分析结果将被清除。')) { this.triggerAnalysis(report.id, true); } }; } else { analyzeBtn.textContent = '开始分析'; analyzeBtn.onclick = () => { this.triggerAnalysis(report.id, false); }; } // 下载链接 const downloadBtn = document.getElementById('btn-download'); if (report.pdf_url) { downloadBtn.href = report.pdf_url; downloadBtn.style.display = 'inline-flex'; } else { downloadBtn.style.display = 'none'; } // 显示模态框 this.showModal('report-modal'); // 如果正在处理中,启动轮询 if (isProcessing) { this.detailPollingInterval = setTimeout(() => this.showReportDetail(id), 5000); } } catch (error) { console.error('获取详情失败', error); this.showToast('无法获取报告详情', 'error'); } }, // 触发分析 async triggerAnalysis(reportId, force = false) { try { const res = await fetch(`${API_BASE}/analysis/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ report_id: reportId, force: force }) }); if (res.ok) { this.showToast('分析任务已启动', 'success'); // 如果当前正在查看这个报告的详情,不需要关闭模态框,直接进入轮询 if (this.state.currentReport && this.state.currentReport.id === reportId) { this.showReportDetail(reportId); } else { document.getElementById('report-modal').classList.remove('active'); } this.fetchReports(); // 刷新列表状态 } else { const err = await res.json(); this.showToast('提交失败: ' + err.detail, 'error'); } } catch (error) { this.showToast('网络错误', 'error'); } }, // 运行调度器 async runSchedulerNow() { if (!await this.showConfirm('立即同步', '确定要立即开始同步所有公司的报告吗?可能需要较长时间。')) return; try { await fetch(`${API_BASE}/scheduler/run-now`, { method: 'POST' }); this.showToast('同步任务已触发,请在日志中查看进度', 'success'); this.checkStatus(); } catch (error) { this.showToast('操作失败', 'error'); } }, // 检查状态 async checkStatus() { try { const res = await fetch(`${API_BASE}/scheduler/status`); const status = await res.json(); const indicator = document.getElementById('scheduler-indicator'); indicator.classList.toggle('active', status.is_running); const nextTime = status.next_run_time ? new Date(status.next_run_time).toLocaleString() : '未调度'; document.getElementById('next-run-time').textContent = `下次: ${nextTime}`; } catch (error) { console.error('状态检查失败'); } }, // 显示模态框 showModal(id) { document.getElementById(id).classList.add('active'); } }; // 启动应用 document.addEventListener('DOMContentLoaded', () => { app.init(); });