program/static/js/app.js

842 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 神东煤炭综采工作面数据管理与AI分析系统
* 前端 JavaScript 逻辑
*/
// ==================== 全局变量 ====================
let units = [];
let aiModels = [];
let realtimeData = [];
// ==================== 初始化 ====================
document.addEventListener('DOMContentLoaded', function() {
initNavigation();
updateTime();
setInterval(updateTime, 1000);
loadUnits();
loadAIModels();
loadAllStats();
initForms();
initTimeRange();
});
// ==================== 导航 ====================
function initNavigation() {
const links = document.querySelectorAll('.navbar-menu a');
links.forEach(link => {
link.addEventListener('click', function(e) {
e.preventDefault();
const page = this.getAttribute('data-page');
switchPage(page);
});
});
}
function switchPage(pageName) {
// 更新菜单激活状态
document.querySelectorAll('.navbar-menu a').forEach(a => a.classList.remove('active'));
const activeLink = document.querySelector(`.navbar-menu a[data-page="${pageName}"]`);
if (activeLink) activeLink.classList.add('active');
// 切换页面
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
const page = document.getElementById(pageName);
if (page) page.classList.add('active');
}
// ==================== 时间更新 ====================
function updateTime() {
const now = new Date();
const timeStr = now.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const el = document.getElementById('current-time');
if (el) el.textContent = timeStr;
}
// ==================== API 请求 ====================
async function apiRequest(endpoint, options = {}) {
try {
// AI分析请求设置更长的超时时间10分钟
const isAiRequest = endpoint.startsWith('/ai/');
const timeoutMs = isAiRequest ? (options.timeout || 600000) : (options.timeout || 30000);
// 创建带超时的AbortController
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(`/api${endpoint}`, {
method: options.method || 'GET',
headers: {
'Content-Type': 'application/json',
...options.headers
},
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok || !data.success) {
// 优先使用 error 字段,其次使用 data.contentAI分析错误最后使用默认提示
let errorMsg = data.error || '请求失败';
if (data.data && data.data.status === 'error' && data.data.content) {
errorMsg = data.data.content;
}
throw new Error(errorMsg);
}
return data;
} catch (error) {
console.error('API请求错误:', error);
if (error.name === 'AbortError') {
throw new Error(`请求超时(等待时间过长),请检查网络连接或稍后重试`);
}
throw error;
}
}
// ==================== Toast 提示 ====================
function showToast(message, type = 'success') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.remove();
}, 3000);
}
// ==================== 加载总体统计 ====================
async function loadAllStats() {
try {
const data = await apiRequest('/stats/all');
const stats = data.data || {};
// 更新顶部统计卡片
const unitCountEl = document.getElementById('unit-count');
const tagCountEl = document.getElementById('tag-count');
const totalCollectsEl = document.getElementById('total-collects');
const todayCollectsEl = document.getElementById('today-collects');
const dataTotalEl = document.getElementById('data-total');
if (unitCountEl) unitCountEl.textContent = stats.unit_count || 0;
if (tagCountEl) tagCountEl.textContent = stats.tag_count || 0;
if (totalCollectsEl) totalCollectsEl.textContent = stats.total_collects || 0;
if (todayCollectsEl) todayCollectsEl.textContent = stats.today_collects || 0;
if (dataTotalEl) dataTotalEl.textContent = formatNumber(stats.data_total || 0);
// 渲染工作面统计表格
renderUnitStatsTable(stats.unit_stats || []);
} catch (error) {
console.error('加载总体统计失败:', error);
}
}
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}
function renderUnitStatsTable(unitStats) {
const container = document.getElementById('unit-stats-table');
if (!container) return;
if (!unitStats || unitStats.length === 0) {
container.innerHTML = '<p class="text-muted">暂无工作面数据</p>';
return;
}
let html = `
<table class="data-table">
<thead>
<tr>
<th>工作面名称</th>
<th>工作面代码</th>
<th>采集次数</th>
<th>监测点数</th>
<th>最近采集时间</th>
</tr>
</thead>
<tbody>
`;
unitStats.forEach(unit => {
html += `
<tr onclick="switchToMonitor('${unit.unit_code}')" style="cursor:pointer;">
<td>${unit.unit_name || '-'}</td>
<td><code>${unit.unit_code}</code></td>
<td>${unit.collect_count || 0}</td>
<td>${unit.tag_count || 0}</td>
<td>${unit.last_time || '暂无'}</td>
</tr>
`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
// ==================== 加载工作面列表 ====================
async function loadUnits() {
try {
const data = await apiRequest('/units');
units = data.data || [];
// 更新下拉框
updateUnitSelects();
// 更新列表
renderUnitList();
// 加载采集状态
loadCollectStatus();
// 加载总体统计
loadAllStats();
} catch (error) {
showToast('加载工作面列表失败: ' + error.message, 'error');
}
}
function updateUnitSelects() {
const selects = ['collect-unit', 'monitor-unit', 'fault-unit', 'trend-unit', 'report-unit'];
selects.forEach(id => {
const select = document.getElementById(id);
if (select) {
const current = select.value;
select.innerHTML = '<option value="">请选择工作面</option>';
units.forEach(unit => {
const option = document.createElement('option');
option.value = unit.unit_code;
option.textContent = `${unit.unit_name} (${unit.unit_code})`;
select.appendChild(option);
});
if (current) select.value = current;
}
});
}
function renderUnitList() {
const container = document.getElementById('unit-list');
if (!container) return;
if (units.length === 0) {
container.innerHTML = '<p class="text-muted">暂无工作面数据</p>';
return;
}
container.innerHTML = units.map(unit => `
<div class="unit-item" onclick="switchToMonitor('${unit.unit_code}')">
<div>
<div class="unit-name">${unit.unit_name}</div>
<span class="unit-code">${unit.unit_code}</span>
</div>
<div class="unit-status">
<span class="status-dot ${unit.has_data ? '' : 'empty'}"></span>
<span>${unit.has_data ? '有数据' : '暂无数据'}</span>
</div>
</div>
`).join('');
}
function switchToMonitor(unitCode) {
const monitorSelect = document.getElementById('monitor-unit');
if (monitorSelect) {
monitorSelect.value = unitCode;
switchPage('monitor');
loadRealtimeData();
}
}
// ==================== 加载采集状态 ====================
async function loadCollectStatus() {
const container = document.getElementById('collect-status');
if (!container) return;
try {
const promises = units.map(async (unit) => {
try {
const data = await apiRequest(`/stats/${unit.unit_code}`);
return {
...unit,
stats: data.data
};
} catch {
return { ...unit, stats: null };
}
});
const results = await Promise.all(promises);
if (results.length === 0) {
container.innerHTML = '<p>暂无采集记录</p>';
return;
}
container.innerHTML = results.map(item => `
<div class="collect-status-item">
<span>${item.unit_name}</span>
<span>
${item.stats ? `
采集${item.stats.today_count}次 · ${item.stats.last_time || '未知'}
` : '暂无数据'}
</span>
</div>
`).join('');
} catch (error) {
console.error('加载采集状态失败:', error);
}
}
// ==================== 加载AI模型列表 ====================
async function loadAIModels() {
try {
const data = await apiRequest('/ai/models');
aiModels = data.data || [];
const selects = ['fault-model', 'trend-model'];
selects.forEach(id => {
const select = document.getElementById(id);
if (select) {
select.innerHTML = '<option value="">默认模型</option>';
aiModels.forEach(model => {
const option = document.createElement('option');
option.value = model.key;
option.textContent = `${model.name}${model.available ? '' : ' (未配置)'}`;
if (!model.available) option.disabled = true;
select.appendChild(option);
});
}
});
} catch (error) {
console.error('加载AI模型列表失败:', error);
}
}
// ==================== 表单初始化 ====================
function initForms() {
// 数据采集表单
const collectForm = document.getElementById('collect-form');
if (collectForm) {
collectForm.addEventListener('submit', async function(e) {
e.preventDefault();
await startCollect();
});
}
// 故障诊断表单
const faultForm = document.getElementById('fault-form');
if (faultForm) {
faultForm.addEventListener('submit', async function(e) {
e.preventDefault();
await startFaultAnalysis();
});
}
// 趋势分析表单
const trendForm = document.getElementById('trend-form');
if (trendForm) {
trendForm.addEventListener('submit', async function(e) {
e.preventDefault();
await startTrendAnalysis();
});
}
// 报告生成表单
const reportForm = document.getElementById('report-form');
if (reportForm) {
reportForm.addEventListener('submit', async function(e) {
e.preventDefault();
await generateReport();
});
}
}
// ==================== 时间范围初始化 ====================
function initTimeRange() {
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 1 * 60 * 60 * 1000);
const formatDateTime = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}`;
};
const startEl = document.getElementById('trend-start');
const endEl = document.getElementById('trend-end');
if (startEl) startEl.value = formatDateTime(oneHourAgo);
if (endEl) endEl.value = formatDateTime(now);
}
// ==================== 数据采集 ====================
async function startCollect() {
const unitCode = document.getElementById('collect-unit').value;
const deviceType = document.getElementById('collect-device').value;
if (!unitCode) {
showToast('请选择工作面', 'warning');
return;
}
const btn = document.getElementById('collect-btn');
const btnText = btn.querySelector('.btn-text');
const btnLoading = btn.querySelector('.btn-loading');
btn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'inline';
addLog('开始采集数据...', 'info');
try {
const body = { unit_code: unitCode };
if (deviceType) body.device_type = deviceType;
const data = await apiRequest('/collect', {
method: 'POST',
body: body
});
const result = data.data;
addLog(`采集完成: ${result.message || '成功'}`, 'success');
if (result.total_collected) {
addLog(`共采集 ${result.total_collected} 条数据`, 'success');
}
showToast('数据采集成功');
// 刷新状态
loadCollectStatus();
updateUnitDataStatus();
} catch (error) {
addLog(`采集失败: ${error.message}`, 'error');
showToast('数据采集失败: ' + error.message, 'error');
} finally {
btn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
}
async function collectAll() {
const btn = document.getElementById('collect-all-btn');
btn.disabled = true;
btn.textContent = '采集中...';
addLog('开始全量采集...', 'info');
try {
const data = await apiRequest('/collect/all', {
method: 'POST'
});
addLog('全量采集完成', 'success');
showToast('全量采集完成');
loadCollectStatus();
updateUnitDataStatus();
} catch (error) {
addLog(`全量采集失败: ${error.message}`, 'error');
showToast('全量采集失败', 'error');
} finally {
btn.disabled = false;
btn.textContent = '全量采集';
}
}
function updateUnitDataStatus() {
// 重新加载units以更新状态
loadUnits();
}
// ==================== 日志 ====================
function addLog(message, type = 'info') {
const container = document.getElementById('collect-log');
if (!container) return;
const time = new Date().toLocaleTimeString('zh-CN');
const logEl = document.createElement('p');
logEl.className = `log-${type}`;
logEl.textContent = `[${time}] ${message}`;
container.appendChild(logEl);
container.scrollTop = container.scrollHeight;
}
// ==================== 实时数据 ====================
async function loadRealtimeData() {
const unitCode = document.getElementById('monitor-unit').value;
if (!unitCode) {
document.getElementById('realtime-data').innerHTML = '<p>请选择工作面查看实时数据</p>';
return;
}
try {
const data = await apiRequest(`/realtime/${unitCode}`);
realtimeData = data.data || [];
// 更新时间
const timeEl = document.getElementById('monitor-time');
if (timeEl && data.snapshot_time) {
timeEl.textContent = `数据时间: ${data.snapshot_time}`;
}
// 更新统计
updateStats(realtimeData);
// 渲染表格
renderRealtimeTable(realtimeData);
} catch (error) {
showToast('加载实时数据失败: ' + error.message, 'error');
}
}
function updateStats(data) {
// 更新首页的监测点总数(仅当在首页时才有意义)
const tagCountEl = document.getElementById('tag-count');
if (tagCountEl) {
tagCountEl.textContent = data.length;
}
}
function renderRealtimeTable(data) {
const container = document.getElementById('realtime-data');
if (!data || data.length === 0) {
container.innerHTML = '<p>暂无数据</p>';
return;
}
// 按设备分类
const categories = categorizeData(data);
let html = '';
for (const [cat, items] of Object.entries(categories)) {
html += `
<h3 style="margin: 16px 0 8px; color: var(--gray-700);">${cat}</h3>
<table class="data-table">
<thead>
<tr>
<th>参数名称</th>
<th>数值</th>
<th>单位</th>
<th>状态</th>
</tr>
</thead>
<tbody>
`;
items.forEach(item => {
const tag = item.tag_short_name || item.tag_name || '';
// 优先使用后端返回的中文名称,否则显示英文标签
const displayName = item.chinese_name && item.chinese_name !== tag
? `${item.chinese_name} <small style="color: #999;">(${tag})</small>`
: tag;
const value = item.tag_value != null ? item.tag_value :
(item.tag_value_bool != null ? (item.tag_value_bool ? 'ON' : 'OFF') : 'N/A');
const unit = item.tag_unit || '';
const quality = item.quality || 0;
const valueClass = quality === 0 ? 'value-normal' : 'value-warning';
const statusBadge = quality === 0 ?
'<span class="badge badge-success">正常</span>' :
'<span class="badge badge-warning">异常</span>';
html += `
<tr>
<td>${displayName}</td>
<td class="${valueClass}">${value}</td>
<td>${unit}</td>
<td>${statusBadge}</td>
</tr>
`;
});
html += '</tbody></table>';
}
container.innerHTML = html;
}
function categorizeData(data) {
const categories = {};
data.forEach(item => {
const tag = item.tag_short_name || item.tag_name || '';
let cat = '其他';
if (tag.includes('Shearer') || tag.includes('shearer')) {
cat = '采煤机';
} else if (tag.includes('AFC') || tag.includes('LOA') || tag.includes('CRU')) {
cat = '三机';
} else if (tag.includes('Emulsion') || tag.includes('Spray')) {
cat = '泵站';
} else if (tag.includes('Loop')) {
cat = '开关';
}
if (!categories[cat]) categories[cat] = [];
categories[cat].push(item);
});
return categories;
}
function filterRealtimeData() {
const filter = document.getElementById('monitor-filter').value;
const search = document.getElementById('monitor-search').value.toLowerCase();
let filtered = realtimeData;
if (filter) {
filtered = filtered.filter(item => {
const tag = item.tag_short_name || item.tag_name || '';
const chinese = (item.chinese_name || '').toLowerCase();
return tag.includes(filter) || chinese.includes(filter);
});
}
if (search) {
filtered = filtered.filter(item => {
const tag = (item.tag_short_name || item.tag_name || '').toLowerCase();
const chinese = (item.chinese_name || '').toLowerCase();
return tag.includes(search) || chinese.includes(search);
});
}
renderRealtimeTable(filtered);
}
// ==================== AI分析 ====================
async function startFaultAnalysis() {
const unitCode = document.getElementById('fault-unit').value;
const modelKey = document.getElementById('fault-model').value;
if (!unitCode) {
showToast('请选择工作面', 'warning');
return;
}
const btn = document.getElementById('fault-btn');
btn.disabled = true;
btn.textContent = '分析中...';
const resultEl = document.getElementById('analysis-result');
resultEl.innerHTML = '<p><span class="loading"></span> AI正在分析中请稍候...</p>';
try {
const body = { unit_code: unitCode };
if (modelKey) body.model_key = modelKey;
const data = await apiRequest('/ai/fault', {
method: 'POST',
body: body
});
displayAnalysisResult(data.data);
showToast('故障诊断完成');
} catch (error) {
resultEl.innerHTML = `<p class="text-muted">分析失败: ${error.message}</p>`;
showToast('故障诊断失败', 'error');
} finally {
btn.disabled = false;
btn.textContent = '开始诊断';
}
}
async function startTrendAnalysis() {
const unitCode = document.getElementById('trend-unit').value;
const startTime = document.getElementById('trend-start').value;
const endTime = document.getElementById('trend-end').value;
const modelKey = document.getElementById('trend-model').value;
if (!unitCode || !startTime || !endTime) {
showToast('请填写完整信息', 'warning');
return;
}
const btn = document.getElementById('trend-btn');
btn.disabled = true;
btn.textContent = '分析中...';
const resultEl = document.getElementById('analysis-result');
resultEl.innerHTML = '<p><span class="loading"></span> AI正在分析趋势请稍候...</p>';
try {
const body = {
unit_code: unitCode,
start_time: startTime,
end_time: endTime
};
if (modelKey) body.model_key = modelKey;
const data = await apiRequest('/ai/trend', {
method: 'POST',
body: body
});
displayAnalysisResult(data.data);
showToast('趋势分析完成');
} catch (error) {
resultEl.innerHTML = `<p class="text-muted">分析失败: ${error.message}</p>`;
showToast('趋势分析失败', 'error');
} finally {
btn.disabled = false;
btn.textContent = '开始分析';
}
}
function displayAnalysisResult(data) {
const resultEl = document.getElementById('analysis-result');
const summaryCard = document.getElementById('data-summary-card');
const summaryContent = document.getElementById('data-summary-content');
const summaryDataCount = document.getElementById('summary-data-count');
// 显示数据摘要
if (data.data_summary) {
if (summaryCard) summaryCard.style.display = 'block';
if (summaryDataCount) {
const count = data.data_count || 0;
summaryDataCount.textContent = `${count} 个监测点`;
}
if (summaryContent) {
summaryContent.innerHTML = formatMarkdown(data.data_summary);
}
} else {
if (summaryCard) summaryCard.style.display = 'none';
}
if (data.status === 'success') {
resultEl.innerHTML = `
<div class="result-meta" style="margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--gray-300);">
<span class="badge badge-success">分析成功</span>
${data.model_name ? `<span class="badge badge-info" style="margin-left: 8px;">${data.model_name}</span>` : ''}
</div>
<div class="result-content">${formatMarkdown(data.content)}</div>
`;
} else {
resultEl.innerHTML = `
<div class="result-meta">
<span class="badge badge-danger">分析失败</span>
</div>
<p style="margin-top: 12px;">${data.content || '未知错误'}</p>
`;
if (summaryCard) summaryCard.style.display = 'none';
}
}
function formatMarkdown(text) {
// 简单Markdown格式化
let html = text
// 标题
.replace(/^#### (.+)$/gm, '<h4>$1</h4>')
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
// 加粗
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// 斜体
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// 无序列表
.replace(/^- (.+)$/gm, '<li>$1</li>')
// 分割线
.replace(/^---$/gm, '<hr>')
// 换行
.replace(/\n/g, '<br>');
// 包裹连续的li
html = html.replace(/(<li>.*?<\/li>(?:<br>)?)+/g, function(match) {
return '<ul>' + match.replace(/<br>/g, '') + '</ul>';
});
return html;
}
function copyResult() {
const resultEl = document.getElementById('analysis-result');
const text = resultEl.innerText;
navigator.clipboard.writeText(text).then(() => {
showToast('已复制到剪贴板');
}).catch(() => {
showToast('复制失败', 'error');
});
}
// ==================== 报告生成 ====================
async function generateReport() {
const unitCode = document.getElementById('report-unit').value;
const reportType = document.getElementById('report-type').value;
if (!unitCode) {
showToast('请选择工作面', 'warning');
return;
}
const btn = document.getElementById('report-btn');
btn.disabled = true;
btn.textContent = '生成中...';
const previewEl = document.getElementById('report-preview');
previewEl.innerHTML = '<p><span class="loading"></span> 正在生成报告...</p>';
try {
const data = await apiRequest('/report/word', {
method: 'POST',
body: {
unit_code: unitCode,
type: reportType
}
});
const fileInfo = data.data;
previewEl.innerHTML = `
<div style="text-align: center; padding: 40px;">
<div style="font-size: 48px; margin-bottom: 16px;">📄</div>
<h3>报告生成成功</h3>
<p style="color: var(--gray-700); margin: 12px 0;">文件名: ${fileInfo.filename}</p>
<a href="/api/report/download/${fileInfo.filename}" class="btn btn-primary" download>
下载报告
</a>
</div>
`;
showToast('报告生成成功');
} catch (error) {
previewEl.innerHTML = `<p class="text-muted">报告生成失败: ${error.message}</p>`;
showToast('报告生成失败', 'error');
} finally {
btn.disabled = false;
btn.textContent = '生成报告';
}
}