802 lines
33 KiB
JavaScript
802 lines
33 KiB
JavaScript
|
|
/**
|
|||
|
|
* 眼镜行业情报分析系统前端逻辑
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
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 '';
|
|||
|
|
// 预处理:修复 **紧贴中文或标点导致无法解析的问题
|
|||
|
|
// 将 **...** 强制替换为 <strong>...</strong>
|
|||
|
|
let fixedText = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
|||
|
|
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 = `
|
|||
|
|
<i class="fas fa-${icon}" style="font-size:1.2rem;color:${color}"></i>
|
|||
|
|
<span class="toast-message">${message}</span>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
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 = `
|
|||
|
|
<div style="font-weight:600;font-size:0.9rem">${report.company_name} - ${report.title}</div>
|
|||
|
|
<div style="font-size:0.8rem;color:#64748b;margin-top:4px">
|
|||
|
|
AI分析已完成 • ${new Date(report.created_at).toLocaleDateString()}
|
|||
|
|
</div>
|
|||
|
|
<button class="btn-xs" style="margin-top:8px" onclick="app.showReportDetail(${report.id})">查看详情</button>
|
|||
|
|
`;
|
|||
|
|
insightsContainer.appendChild(el);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (insightsContainer.children.length === 0) {
|
|||
|
|
insightsContainer.innerHTML = '<div style="color:#aaa;text-align:center;padding:20px">暂无分析结果</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 渲染日志
|
|||
|
|
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 = `
|
|||
|
|
<div style="display:flex;justify-content:space-between">
|
|||
|
|
<span style="font-weight:500">[${log.task_name}]</span>
|
|||
|
|
<span style="color:${statusColor}">${log.status}</span>
|
|||
|
|
</div>
|
|||
|
|
<div>${log.message || '-'}</div>
|
|||
|
|
<div class="log-time">${new Date(log.started_at).toLocaleString()}</div>
|
|||
|
|
`;
|
|||
|
|
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 => `
|
|||
|
|
<tr>
|
|||
|
|
<td>${c.stock_code}</td>
|
|||
|
|
<td>${c.short_name || '-'}</td>
|
|||
|
|
<td>${c.company_name}</td>
|
|||
|
|
<td>${c.industry || '-'}</td>
|
|||
|
|
<td>${c.report_count}</td>
|
|||
|
|
<td><span class="tag ${c.is_active ? 'analysis-done' : ''}">${c.is_active ? '监控中' : '暂停'}</span></td>
|
|||
|
|
<td>
|
|||
|
|
<button class="btn-xs edit-btn" data-id="${c.id}" style="margin-right:8px">编辑</button>
|
|||
|
|
<button class="btn-xs remove-btn" data-id="${c.id}" style="color:red;border-color:red">删除</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
`).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 = '<option value="">所有公司</option>' +
|
|||
|
|
companies.map(c => `<option value="${c.id}">${c.company_name}</option>`).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 = '<i class="fas fa-save"></i> 更新信息';
|
|||
|
|
} else {
|
|||
|
|
// 新增模式
|
|||
|
|
document.getElementById('company-id').value = '';
|
|||
|
|
document.getElementById('company-code').disabled = false;
|
|||
|
|
title.textContent = '添加公司';
|
|||
|
|
desc.style.display = 'block';
|
|||
|
|
btn.innerHTML = '<i class="fas fa-search"></i> 查询并添加';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 `
|
|||
|
|
<tr>
|
|||
|
|
<td style="font-weight:600">${r.company_name}</td>
|
|||
|
|
<td><div style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${r.title}">${r.title}</div></td>
|
|||
|
|
<td><span class="tag">${r.report_type === '年度报告' ? '年报' : '半年报'}</span></td>
|
|||
|
|
<td>${r.report_year}</td>
|
|||
|
|
<td>${new Date(r.announcement_time).toLocaleDateString()}</td>
|
|||
|
|
<td><span class="tag ${statusClass}">${statusTag}</span></td>
|
|||
|
|
<td>
|
|||
|
|
<button class="btn-xs" onclick="app.showReportDetail(${r.id})">
|
|||
|
|
<i class="fas fa-eye"></i> 详情
|
|||
|
|
</button>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
`;
|
|||
|
|
}).join('');
|
|||
|
|
|
|||
|
|
if (reports.length === 0) {
|
|||
|
|
grid.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:#aaa">暂无报告数据</td></tr>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} 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 = `
|
|||
|
|
<span class="tag">${report.stock_code}</span>
|
|||
|
|
<span class="tag">${report.report_year}年${report.report_period}</span>
|
|||
|
|
<span class="tag ${report.is_analyzed ? 'analysis-done' : (isProcessing ? 'analysis-processing' : 'analysis-pending')}">
|
|||
|
|
${statusText} ${isProcessing ? '<i class="fa-solid fa-circle-notch fa-spin"></i>' : ''}
|
|||
|
|
</span>
|
|||
|
|
`;
|
|||
|
|
|
|||
|
|
// 获取当前激活的 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 = `
|
|||
|
|
<div style="padding:40px;text-align:center">
|
|||
|
|
<i class="fa-solid fa-circle-notch fa-spin" style="font-size:2rem;color:#2563eb"></i>
|
|||
|
|
<p style="margin-top:16px">AI正在深度分析中...</p>
|
|||
|
|
<p style="font-size:0.8rem;color:#64748b;margin-top:8px">由于章节较多(通常15-25个),且每个章节分析耗时约30-60秒,整份报告分析可能需要5-10分钟。</p>
|
|||
|
|
<p style="font-size:0.8rem;color:#64748b">您可以切换到“章节分析”标签查看实时进度。</p>
|
|||
|
|
</div>`;
|
|||
|
|
} else if (!report.is_downloaded) {
|
|||
|
|
summaryTab.innerHTML = '<div style="padding:40px;text-align:center;color:#f59e0b"><i class="fas fa-exclamation-triangle" style="font-size:2rem"></i><p style="margin-top:16px">报告尚未下载</p></div>';
|
|||
|
|
} else if (!report.is_extracted) {
|
|||
|
|
summaryTab.innerHTML = '<div style="padding:40px;text-align:center;color:#64748b"><i class="fas fa-file-pdf" style="font-size:2rem"></i><p style="margin-top:16px">PDF已下载,请点击下方按钮开始分析</p></div>';
|
|||
|
|
} else {
|
|||
|
|
summaryTab.innerHTML = '<div style="padding:40px;text-align:center;color:#64748b"><i class="fas fa-brain" style="font-size:2rem"></i><p style="margin-top:16px">内容已提取,请点击下方按钮开始分析</p></div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 填充章节分析
|
|||
|
|
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 => `
|
|||
|
|
<div style="margin-bottom:20px;border-bottom:1px solid #eee;padding-bottom:20px">
|
|||
|
|
<h4 style="margin-bottom:10px;color:#2563eb">${s.section_name}</h4>
|
|||
|
|
<div class="markdown-body">${this.parseMarkdown(s.summary)}</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
if (isProcessing) {
|
|||
|
|
sectionsList.innerHTML += `<div style="padding:20px;text-align:center;color:#2563eb"><i class="fa-solid fa-circle-notch fa-spin"></i> 正在分析新的章节...</div>`;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
sectionsList.innerHTML = `<div style="padding:20px;text-align:center;color:#aaa">${isProcessing ? '<i class="fa-solid fa-circle-notch fa-spin"></i> 正在处理首个章节...' : '暂无章节分析'}</div>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 填充原文
|
|||
|
|
const rawList = document.getElementById('raw-content-list');
|
|||
|
|
if (report.extracted_contents.length > 0) {
|
|||
|
|
rawList.innerHTML = report.extracted_contents.map(c => `
|
|||
|
|
<div style="margin-bottom:20px;background:#f9f9f9;padding:15px;border-radius:8px">
|
|||
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:8px;align-items:flex-start">
|
|||
|
|
<strong style="margin-right:12px;word-break:break-word">${c.section_name}</strong>
|
|||
|
|
<span class="tag" style="flex-shrink:0;white-space:nowrap">匹配: ${c.section_keyword}</span>
|
|||
|
|
</div>
|
|||
|
|
<div style="white-space:pre-wrap;font-size:0.85rem;max-height:300px;overflow-y:auto">${c.content}</div>
|
|||
|
|
</div>
|
|||
|
|
`).join('');
|
|||
|
|
} else {
|
|||
|
|
rawList.innerHTML = '<div style="padding:20px;text-align:center;color:#aaa">未提取到相关内容</div>';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 动态设置按钮
|
|||
|
|
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 = `
|
|||
|
|
<a href="#" style="color:#64748b;text-decoration:underline" onclick="event.preventDefault(); app.triggerAnalysis(${report.id}, true)">
|
|||
|
|
发现卡住了?点此强制重新开始
|
|||
|
|
</a>`;
|
|||
|
|
} 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();
|
|||
|
|
});
|