feat: 优化数据分析页面,添加Excel工单导入功能

- 优化数据分析页面,添加可定制的图表功能
- 支持多种图表类型:折线图、柱状图、饼图、环形图、雷达图、极坐标图
- 添加图表定制功能:时间范围选择、数据维度选择
- 实现Excel工单导入功能,支持详情.xlsx文件
- 添加工单编辑功能,包括前端UI和后端API
- 修复WebSocket连接错误,处理invalid Connection header问题
- 简化预警管理参数,改为卡片式选择
- 实现Agent主动调用,无需人工干预
- 改进知识库导入,结合累计工单内容与大模型输出
This commit is contained in:
zhaojie
2025-09-10 23:13:08 +08:00
parent e08b570f22
commit 0c03ff20aa
16 changed files with 3077 additions and 51 deletions

View File

@@ -1198,9 +1198,14 @@ class TSPDashboard {
</div>
</div>
<div class="ms-3">
<button class="btn btn-sm btn-outline-primary" onclick="dashboard.updateWorkOrder(${workorder.id})">
<i class="fas fa-edit"></i>
</button>
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-info" onclick="dashboard.viewWorkOrderDetails(${workorder.id})" title="查看详情">
<i class="fas fa-eye"></i>
</button>
<button class="btn btn-sm btn-outline-primary" onclick="dashboard.updateWorkOrder(${workorder.id})" title="编辑">
<i class="fas fa-edit"></i>
</button>
</div>
</div>
</div>
</div>
@@ -1262,17 +1267,748 @@ class TSPDashboard {
}
}
async viewWorkOrderDetails(workorderId) {
try {
const response = await fetch(`/api/workorders/${workorderId}`);
const workorder = await response.json();
if (workorder.error) {
this.showNotification('获取工单详情失败', 'error');
return;
}
this.showWorkOrderDetailsModal(workorder);
} catch (error) {
console.error('获取工单详情失败:', error);
this.showNotification('获取工单详情失败', 'error');
}
}
showWorkOrderDetailsModal(workorder) {
// 创建模态框HTML
const modalHtml = `
<div class="modal fade" id="workOrderDetailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">工单详情 - ${workorder.order_id || workorder.id}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<h6>基本信息</h6>
<table class="table table-sm">
<tr>
<td><strong>工单号:</strong></td>
<td>${workorder.order_id || workorder.id}</td>
</tr>
<tr>
<td><strong>标题:</strong></td>
<td>${workorder.title}</td>
</tr>
<tr>
<td><strong>分类:</strong></td>
<td>${workorder.category}</td>
</tr>
<tr>
<td><strong>优先级:</strong></td>
<td><span class="badge bg-${this.getPriorityColor(workorder.priority)}">${this.getPriorityText(workorder.priority)}</span></td>
</tr>
<tr>
<td><strong>状态:</strong></td>
<td><span class="badge bg-${this.getStatusColor(workorder.status)}">${this.getStatusText(workorder.status)}</span></td>
</tr>
<tr>
<td><strong>创建时间:</strong></td>
<td>${new Date(workorder.created_at).toLocaleString()}</td>
</tr>
<tr>
<td><strong>更新时间:</strong></td>
<td>${new Date(workorder.updated_at).toLocaleString()}</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h6>问题描述</h6>
<div class="border p-3 rounded">
${workorder.description}
</div>
${workorder.resolution ? `
<h6 class="mt-3">解决方案</h6>
<div class="border p-3 rounded bg-light">
${workorder.resolution}
</div>
` : ''}
${workorder.satisfaction_score ? `
<h6 class="mt-3">满意度评分</h6>
<div class="border p-3 rounded">
<div class="progress">
<div class="progress-bar" style="width: ${workorder.satisfaction_score * 100}%"></div>
</div>
<small class="text-muted">${workorder.satisfaction_score}/5.0</small>
</div>
` : ''}
</div>
</div>
${workorder.conversations && workorder.conversations.length > 0 ? `
<h6>对话记录</h6>
<div class="conversation-history" style="max-height: 300px; overflow-y: auto;">
${workorder.conversations.map(conv => `
<div class="border-bottom pb-2 mb-2">
<div class="d-flex justify-content-between">
<small class="text-muted">${new Date(conv.timestamp).toLocaleString()}</small>
</div>
<div class="mb-1">
<strong>用户:</strong> ${conv.user_message}
</div>
<div>
<strong>助手:</strong> ${conv.assistant_response}
</div>
</div>
`).join('')}
</div>
` : ''}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary" onclick="dashboard.updateWorkOrder(${workorder.id})">编辑工单</button>
</div>
</div>
</div>
</div>
`;
// 移除已存在的模态框
const existingModal = document.getElementById('workOrderDetailsModal');
if (existingModal) {
existingModal.remove();
}
// 添加新的模态框到页面
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 显示模态框
const modal = new bootstrap.Modal(document.getElementById('workOrderDetailsModal'));
modal.show();
// 模态框关闭时移除DOM元素
document.getElementById('workOrderDetailsModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
async updateWorkOrder(workorderId) {
try {
// 获取工单详情
const response = await fetch(`/api/workorders/${workorderId}`);
const workorder = await response.json();
if (response.ok) {
this.showEditWorkOrderModal(workorder);
} else {
throw new Error(workorder.error || '获取工单详情失败');
}
} catch (error) {
console.error('获取工单详情失败:', error);
this.showNotification('获取工单详情失败: ' + error.message, 'error');
}
}
showEditWorkOrderModal(workorder) {
// 创建编辑工单模态框
const modalHtml = `
<div class="modal fade" id="editWorkOrderModal" tabindex="-1" aria-labelledby="editWorkOrderModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editWorkOrderModalLabel">编辑工单 #${workorder.id}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form id="editWorkOrderForm">
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="editTitle" class="form-label">标题 *</label>
<input type="text" class="form-control" id="editTitle" value="${workorder.title}" required>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="editPriority" class="form-label">优先级</label>
<select class="form-select" id="editPriority">
<option value="low" ${workorder.priority === 'low' ? 'selected' : ''}>低</option>
<option value="medium" ${workorder.priority === 'medium' ? 'selected' : ''}>中</option>
<option value="high" ${workorder.priority === 'high' ? 'selected' : ''}>高</option>
<option value="urgent" ${workorder.priority === 'urgent' ? 'selected' : ''}>紧急</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="editCategory" class="form-label">分类</label>
<select class="form-select" id="editCategory">
<option value="技术问题" ${workorder.category === '技术问题' ? 'selected' : ''}>技术问题</option>
<option value="业务问题" ${workorder.category === '业务问题' ? 'selected' : ''}>业务问题</option>
<option value="系统故障" ${workorder.category === '系统故障' ? 'selected' : ''}>系统故障</option>
<option value="功能需求" ${workorder.category === '功能需求' ? 'selected' : ''}>功能需求</option>
<option value="其他" ${workorder.category === '其他' ? 'selected' : ''}>其他</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="editStatus" class="form-label">状态</label>
<select class="form-select" id="editStatus">
<option value="open" ${workorder.status === 'open' ? 'selected' : ''}>待处理</option>
<option value="in_progress" ${workorder.status === 'in_progress' ? 'selected' : ''}>处理中</option>
<option value="resolved" ${workorder.status === 'resolved' ? 'selected' : ''}>已解决</option>
<option value="closed" ${workorder.status === 'closed' ? 'selected' : ''}>已关闭</option>
</select>
</div>
</div>
</div>
<div class="mb-3">
<label for="editDescription" class="form-label">描述 *</label>
<textarea class="form-control" id="editDescription" rows="4" required>${workorder.description}</textarea>
</div>
<div class="mb-3">
<label for="editResolution" class="form-label">解决方案</label>
<textarea class="form-control" id="editResolution" rows="3" placeholder="请输入解决方案...">${workorder.resolution || ''}</textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="editSatisfactionScore" class="form-label">满意度评分 (1-5)</label>
<input type="number" class="form-control" id="editSatisfactionScore" min="1" max="5" value="${workorder.satisfaction_score || ''}">
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-primary" onclick="dashboard.saveWorkOrder(${workorder.id})">保存修改</button>
</div>
</div>
</div>
</div>
`;
// 移除已存在的模态框
const existingModal = document.getElementById('editWorkOrderModal');
if (existingModal) {
existingModal.remove();
}
// 添加新模态框到页面
document.body.insertAdjacentHTML('beforeend', modalHtml);
// 显示模态框
const modal = new bootstrap.Modal(document.getElementById('editWorkOrderModal'));
modal.show();
// 模态框关闭时清理
document.getElementById('editWorkOrderModal').addEventListener('hidden.bs.modal', function() {
this.remove();
});
}
async saveWorkOrder(workorderId) {
try {
// 获取表单数据
const formData = {
title: document.getElementById('editTitle').value,
description: document.getElementById('editDescription').value,
category: document.getElementById('editCategory').value,
priority: document.getElementById('editPriority').value,
status: document.getElementById('editStatus').value,
resolution: document.getElementById('editResolution').value,
satisfaction_score: parseInt(document.getElementById('editSatisfactionScore').value) || null
};
// 验证必填字段
if (!formData.title.trim() || !formData.description.trim()) {
this.showNotification('标题和描述不能为空', 'error');
return;
}
// 发送更新请求
const response = await fetch(`/api/workorders/${workorderId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (response.ok) {
this.showNotification('工单更新成功', 'success');
// 关闭模态框
const modal = bootstrap.Modal.getInstance(document.getElementById('editWorkOrderModal'));
modal.hide();
// 刷新工单列表
this.loadWorkOrders();
} else {
throw new Error(result.error || '更新工单失败');
}
} catch (error) {
console.error('更新工单失败:', error);
this.showNotification('更新工单失败: ' + error.message, 'error');
}
}
// 工单导入功能
showImportModal() {
// 显示导入模态框
const modal = new bootstrap.Modal(document.getElementById('importWorkOrderModal'));
modal.show();
// 重置表单
document.getElementById('excel-file-input').value = '';
document.getElementById('import-progress').classList.add('d-none');
document.getElementById('import-result').classList.add('d-none');
}
async downloadTemplate() {
try {
const response = await fetch('/api/workorders/import/template');
const result = await response.json();
if (result.success) {
// 创建下载链接
const link = document.createElement('a');
link.href = result.template_url;
link.download = '工单导入模板.xlsx';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
this.showNotification('模板下载成功', 'success');
} else {
throw new Error(result.error || '下载模板失败');
}
} catch (error) {
console.error('下载模板失败:', error);
this.showNotification('下载模板失败: ' + error.message, 'error');
}
}
async importWorkOrders() {
const fileInput = document.getElementById('excel-file-input');
const file = fileInput.files[0];
if (!file) {
this.showNotification('请选择要导入的Excel文件', 'error');
return;
}
// 验证文件类型
if (!file.name.match(/\.(xlsx|xls)$/)) {
this.showNotification('只支持Excel文件(.xlsx, .xls)', 'error');
return;
}
// 验证文件大小
if (file.size > 16 * 1024 * 1024) {
this.showNotification('文件大小不能超过16MB', 'error');
return;
}
// 显示进度条
document.getElementById('import-progress').classList.remove('d-none');
document.getElementById('import-result').classList.add('d-none');
try {
// 创建FormData
const formData = new FormData();
formData.append('file', file);
// 发送导入请求
const response = await fetch('/api/workorders/import', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
// 显示成功消息
document.getElementById('import-progress').classList.add('d-none');
document.getElementById('import-result').classList.remove('d-none');
document.getElementById('import-success-message').textContent =
`成功导入 ${result.imported_count} 个工单`;
this.showNotification(result.message, 'success');
// 刷新工单列表
this.loadWorkOrders();
// 3秒后关闭模态框
setTimeout(() => {
const modal = bootstrap.Modal.getInstance(document.getElementById('importWorkOrderModal'));
modal.hide();
}, 3000);
} else {
throw new Error(result.error || '导入工单失败');
}
} catch (error) {
console.error('导入工单失败:', error);
document.getElementById('import-progress').classList.add('d-none');
this.showNotification('导入工单失败: ' + error.message, 'error');
}
}
// 数据分析
async loadAnalytics() {
try {
const response = await fetch('/api/analytics');
const analytics = await response.json();
this.updateAnalyticsDisplay(analytics);
this.initializeCharts();
} catch (error) {
console.error('加载分析数据失败:', error);
}
}
// 初始化图表
initializeCharts() {
this.charts = {};
this.updateCharts();
}
// 更新所有图表
async updateCharts() {
try {
const timeRange = document.getElementById('timeRange').value;
const chartType = document.getElementById('chartType').value;
const dataDimension = document.getElementById('dataDimension').value;
// 获取数据
const response = await fetch(`/api/analytics?timeRange=${timeRange}&dimension=${dataDimension}`);
const data = await response.json();
// 更新统计卡片
this.updateStatisticsCards(data);
// 更新图表
this.updateMainChart(data, chartType);
this.updateDistributionChart(data);
this.updateTrendChart(data);
this.updatePriorityChart(data);
// 更新分析报告
this.updateAnalyticsReport(data);
} catch (error) {
console.error('更新图表失败:', error);
this.showNotification('更新图表失败: ' + error.message, 'error');
}
}
// 更新统计卡片
updateStatisticsCards(data) {
const total = data.workorders?.total || 0;
const open = data.workorders?.open || 0;
const resolved = data.workorders?.resolved || 0;
const avgSatisfaction = data.satisfaction?.average || 0;
document.getElementById('totalWorkorders').textContent = total;
document.getElementById('openWorkorders').textContent = open;
document.getElementById('resolvedWorkorders').textContent = resolved;
document.getElementById('avgSatisfaction').textContent = avgSatisfaction.toFixed(1);
// 更新进度条
if (total > 0) {
document.getElementById('openProgress').style.width = `${(open / total) * 100}%`;
document.getElementById('resolvedProgress').style.width = `${(resolved / total) * 100}%`;
document.getElementById('satisfactionProgress').style.width = `${(avgSatisfaction / 5) * 100}%`;
}
}
// 更新主图表
updateMainChart(data, chartType) {
const ctx = document.getElementById('mainChart').getContext('2d');
// 销毁现有图表
if (this.charts.mainChart) {
this.charts.mainChart.destroy();
}
const chartData = this.prepareChartData(data, chartType);
this.charts.mainChart = new Chart(ctx, {
type: chartType,
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '数据分析趋势'
},
legend: {
display: true,
position: 'top'
}
},
scales: chartType === 'pie' || chartType === 'doughnut' ? {} : {
x: {
display: true,
title: {
display: true,
text: '时间'
}
},
y: {
display: true,
title: {
display: true,
text: '数量'
}
}
}
}
});
}
// 更新分布图表
updateDistributionChart(data) {
const ctx = document.getElementById('distributionChart').getContext('2d');
if (this.charts.distributionChart) {
this.charts.distributionChart.destroy();
}
const categories = data.workorders?.by_category || {};
const labels = Object.keys(categories);
const values = Object.values(categories);
this.charts.distributionChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: [
'#FF6384',
'#36A2EB',
'#FFCE56',
'#4BC0C0',
'#9966FF',
'#FF9F40'
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '工单分类分布'
},
legend: {
display: true,
position: 'bottom'
}
}
}
});
}
// 更新趋势图表
updateTrendChart(data) {
const ctx = document.getElementById('trendChart').getContext('2d');
if (this.charts.trendChart) {
this.charts.trendChart.destroy();
}
const trendData = data.trend || [];
const labels = trendData.map(item => item.date);
const workorders = trendData.map(item => item.workorders);
const alerts = trendData.map(item => item.alerts);
this.charts.trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: '工单数量',
data: workorders,
borderColor: '#36A2EB',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: 0.4
}, {
label: '预警数量',
data: alerts,
borderColor: '#FF6384',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '时间趋势分析'
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: '日期'
}
},
y: {
display: true,
title: {
display: true,
text: '数量'
}
}
}
}
});
}
// 更新优先级图表
updatePriorityChart(data) {
const ctx = document.getElementById('priorityChart').getContext('2d');
if (this.charts.priorityChart) {
this.charts.priorityChart.destroy();
}
const priorities = data.workorders?.by_priority || {};
const labels = Object.keys(priorities).map(p => this.getPriorityText(p));
const values = Object.values(priorities);
this.charts.priorityChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '工单数量',
data: values,
backgroundColor: [
'#28a745', // 低 - 绿色
'#ffc107', // 中 - 黄色
'#fd7e14', // 高 - 橙色
'#dc3545' // 紧急 - 红色
]
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '优先级分布'
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
}
// 准备图表数据
prepareChartData(data, chartType) {
const trendData = data.trend || [];
const labels = trendData.map(item => item.date);
const workorders = trendData.map(item => item.workorders);
if (chartType === 'pie' || chartType === 'doughnut') {
const categories = data.workorders?.by_category || {};
return {
labels: Object.keys(categories),
datasets: [{
data: Object.values(categories),
backgroundColor: [
'#FF6384',
'#36A2EB',
'#FFCE56',
'#4BC0C0',
'#9966FF',
'#FF9F40'
]
}]
};
} else {
return {
labels: labels,
datasets: [{
label: '工单数量',
data: workorders,
borderColor: '#36A2EB',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: chartType === 'line' ? 0.4 : 0
}]
};
}
}
// 导出图表
exportChart(chartId) {
if (this.charts[chartId]) {
const link = document.createElement('a');
link.download = `${chartId}_chart.png`;
link.href = this.charts[chartId].toBase64Image();
link.click();
}
}
// 全屏图表
fullscreenChart(chartId) {
// 这里可以实现全屏显示功能
this.showNotification('全屏功能开发中', 'info');
}
// 导出报告
async exportReport() {
try {
const response = await fetch('/api/analytics/export');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'analytics_report.xlsx';
link.click();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('导出报告失败:', error);
this.showNotification('导出报告失败: ' + error.message, 'error');
}
}
// 打印报告
printReport() {
window.print();
}
updateAnalyticsDisplay(analytics) {
// 更新分析报告
const reportContainer = document.getElementById('analytics-report');