refactor: dashboard.js 模块化拆分 从7766行拆为13个独立模块
核心 dashboard.js 缩减至266行,只保留:
- TSPDashboard class 定义(constructor、init、bindEvents、switchTab)
- 分页组件、缓存、状态管理、自动刷新、WebSocket、i18n
功能模块拆分到 modules/ 目录:
- chat.js (207行) 智能对话
- agent.js (424行) Agent管理
- alerts.js (348行) 预警管理
- knowledge.js (699行) 知识库管理
- workorders.js (约500行) 工单管理
- conversations.js (671行) 对话历史
- monitoring.js (455行) Token/AI监控
- system.js (1388行) 系统优化+设置+数据分析图表
- tenants.js (165行) 租户管理
- utils.js (573行) 工具函数
- dashboard-home.js (286行) 仪表板首页
- feishu-sync.js (698行) 飞书同步管理器
拆分方式:Object.assign(TSPDashboard.prototype, {...})
this 引用完全不会断,所有模块方法互相调用正常
This commit is contained in:
File diff suppressed because it is too large
Load Diff
424
src/web/static/js/modules/agent.js
Normal file
424
src/web/static/js/modules/agent.js
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
// Agent管理模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
|
||||||
|
async toggleAgentMode(enabled) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/toggle', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.isAgentMode = enabled;
|
||||||
|
this.showNotification(`Agent模式已${enabled ? '启用' : '禁用'}`, 'success');
|
||||||
|
this.loadAgentData();
|
||||||
|
} else {
|
||||||
|
this.showNotification('切换Agent模式失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('切换Agent模式失败:', error);
|
||||||
|
this.showNotification('切换Agent模式失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadAgentData() {
|
||||||
|
try {
|
||||||
|
const [statusResp, toolsResp] = await Promise.all([
|
||||||
|
fetch('/api/agent/status'),
|
||||||
|
fetch('/api/agent/tools/stats')
|
||||||
|
]);
|
||||||
|
const data = await statusResp.json();
|
||||||
|
const toolsData = await toolsResp.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// 更新 ReactAgent 状态
|
||||||
|
const react = data.react_agent || {};
|
||||||
|
const stateEl = document.getElementById('agent-current-state');
|
||||||
|
stateEl.textContent = react.status || (data.is_active ? 'active' : 'inactive');
|
||||||
|
stateEl.className = `badge ${data.is_active ? 'bg-success' : 'bg-secondary'}`;
|
||||||
|
document.getElementById('agent-available-tools').textContent = react.tool_count || 0;
|
||||||
|
document.getElementById('agent-max-rounds').textContent = react.max_tool_rounds || 5;
|
||||||
|
document.getElementById('agent-history-count').textContent = react.history_count || 0;
|
||||||
|
|
||||||
|
// 工具列表 — 使用 ReactAgent 的工具定义
|
||||||
|
const tools = (toolsData.success ? toolsData.tools : []) || [];
|
||||||
|
this.updateToolsList(tools);
|
||||||
|
|
||||||
|
// 执行历史
|
||||||
|
const history = (toolsData.success ? toolsData.recent_history : []) || [];
|
||||||
|
this.updateAgentExecutionHistory(history);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载Agent数据失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateToolsList(tools) {
|
||||||
|
const toolsList = document.getElementById('tools-list');
|
||||||
|
if (!tools || tools.length === 0) {
|
||||||
|
toolsList.innerHTML = '<div class="empty-state"><i class="fas fa-tools"></i><p>暂无工具</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolsHtml = tools.map(tool => {
|
||||||
|
// ReactAgent 工具定义格式: { name, description, parameters }
|
||||||
|
const params = tool.parameters || {};
|
||||||
|
const paramList = Object.entries(params).map(([k, v]) =>
|
||||||
|
`<code>${k}</code><span class="text-muted small">(${v.required ? '必填' : '可选'})</span>`
|
||||||
|
).join(', ');
|
||||||
|
return `
|
||||||
|
<div class="border-bottom pb-2 mb-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>${tool.name}</strong>
|
||||||
|
<div class="text-muted small">${tool.description || ''}</div>
|
||||||
|
${paramList ? `<div class="small mt-1">参数: ${paramList}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" data-tool="${tool.name}" title="执行工具">
|
||||||
|
<i class="fas fa-play"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
toolsList.innerHTML = toolsHtml;
|
||||||
|
|
||||||
|
// 绑定执行事件
|
||||||
|
toolsList.querySelectorAll('button[data-tool]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const tool = btn.getAttribute('data-tool');
|
||||||
|
let params = {};
|
||||||
|
try {
|
||||||
|
const input = prompt('请输入执行参数(JSON):', '{}');
|
||||||
|
if (input === null) return;
|
||||||
|
params = JSON.parse(input);
|
||||||
|
} catch (e) {
|
||||||
|
this.showNotification('参数格式错误,应为JSON', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/agent/tools/execute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tool, parameters: params })
|
||||||
|
});
|
||||||
|
const res = await resp.json();
|
||||||
|
if (res.success) {
|
||||||
|
this.showNotification(`工具 ${tool} 执行成功`, 'success');
|
||||||
|
await this.loadAgentData();
|
||||||
|
} else {
|
||||||
|
this.showNotification(res.error || `工具 ${tool} 执行失败`, 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.showNotification('执行工具失败: ' + err.message, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateExecutionHistory(history) {
|
||||||
|
const historyContainer = document.getElementById('agent-execution-history');
|
||||||
|
if (history.length === 0) {
|
||||||
|
historyContainer.innerHTML = '<div class="empty-state"><i class="fas fa-history"></i><p>暂无执行历史</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyHtml = history.slice(-5).map(item => `
|
||||||
|
<div class="border-bottom pb-2 mb-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>${item.type || '未知任务'}</strong>
|
||||||
|
<small class="text-muted">${new Date(item.timestamp).toLocaleString()}</small>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">${item.description || '无描述'}</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="badge ${item.success ? 'bg-success' : 'bg-danger'}">
|
||||||
|
${item.success ? '成功' : '失败'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
historyContainer.innerHTML = historyHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
async startAgentMonitoring() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/monitoring/start', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('Agent监控已启动', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('启动Agent监控失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('启动Agent监控失败:', error);
|
||||||
|
this.showNotification('启动Agent监控失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopAgentMonitoring() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/monitoring/stop', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('Agent监控已停止', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('停止Agent监控失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('停止Agent监控失败:', error);
|
||||||
|
this.showNotification('停止Agent监控失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async proactiveMonitoring() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/proactive-monitoring', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(`主动监控完成,发现 ${data.proactive_actions?.length || 0} 个行动机会`, 'info');
|
||||||
|
} else {
|
||||||
|
this.showNotification('主动监控失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('主动监控失败:', error);
|
||||||
|
this.showNotification('主动监控失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async intelligentAnalysis() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/intelligent-analysis', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('智能分析完成', 'success');
|
||||||
|
// 更新分析图表
|
||||||
|
this.updateAnalyticsChart(data.analysis);
|
||||||
|
} else {
|
||||||
|
this.showNotification('智能分析失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('智能分析失败:', error);
|
||||||
|
this.showNotification('智能分析失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAnalyticsChart(analysis) {
|
||||||
|
if (!this.charts.analytics || !analysis) return;
|
||||||
|
|
||||||
|
// 更新分析图表数据
|
||||||
|
const labels = analysis.trends?.dates || [];
|
||||||
|
const satisfactionData = analysis.trends?.satisfaction || [];
|
||||||
|
const resolutionTimeData = analysis.trends?.resolution_time || [];
|
||||||
|
|
||||||
|
this.charts.analytics.data.labels = labels;
|
||||||
|
this.charts.analytics.data.datasets[0].data = satisfactionData;
|
||||||
|
this.charts.analytics.data.datasets[1].data = resolutionTimeData;
|
||||||
|
this.charts.analytics.update();
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendAgentMessage() {
|
||||||
|
const messageInput = document.getElementById('agent-message-input');
|
||||||
|
const message = messageInput.value.trim();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
this.showNotification('请输入消息', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 显示发送状态
|
||||||
|
const sendBtn = document.getElementById('send-agent-message');
|
||||||
|
const originalText = sendBtn.innerHTML;
|
||||||
|
sendBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>发送中...';
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
|
||||||
|
const response = await fetch('/api/agent/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: message,
|
||||||
|
context: {
|
||||||
|
user_id: 'admin',
|
||||||
|
session_id: `agent_session_${Date.now()}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('Agent响应成功', 'success');
|
||||||
|
// 清空输入框
|
||||||
|
messageInput.value = '';
|
||||||
|
// 刷新执行历史
|
||||||
|
await this.loadAgentExecutionHistory();
|
||||||
|
} else {
|
||||||
|
this.showNotification('Agent响应失败: ' + (data.error || '未知错误'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送Agent消息失败:', error);
|
||||||
|
this.showNotification('发送Agent消息失败: ' + error.message, 'error');
|
||||||
|
} finally {
|
||||||
|
// 恢复按钮状态
|
||||||
|
const sendBtn = document.getElementById('send-agent-message');
|
||||||
|
sendBtn.innerHTML = '<i class="fas fa-paper-plane me-1"></i>发送';
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空Agent对话
|
||||||
|
clearAgentChat() {
|
||||||
|
document.getElementById('agent-message-input').value = '';
|
||||||
|
this.showNotification('对话已清空', 'info');
|
||||||
|
},
|
||||||
|
|
||||||
|
// 加载Agent执行历史
|
||||||
|
async loadAgentExecutionHistory() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/action-history?limit=10');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.updateExecutionHistory(data.history);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载Agent执行历史失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 触发示例动作 (from lines 6856+)
|
||||||
|
async triggerSampleAction() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/trigger-sample', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('示例动作执行成功', 'success');
|
||||||
|
await this.loadAgentExecutionHistory();
|
||||||
|
} else {
|
||||||
|
this.showNotification('示例动作执行失败: ' + (data.error || '未知错误'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('触发示例动作失败:', error);
|
||||||
|
this.showNotification('触发示例动作失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清空Agent历史 (from lines 6871+)
|
||||||
|
async clearAgentHistory() {
|
||||||
|
if (!confirm('确定要清空Agent执行历史吗?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/clear-history', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('Agent历史已清空', 'success');
|
||||||
|
await this.loadAgentExecutionHistory();
|
||||||
|
} else {
|
||||||
|
this.showNotification('清空Agent历史失败: ' + (data.error || '未知错误'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清空Agent历史失败:', error);
|
||||||
|
this.showNotification('清空Agent历史失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAgentExecutionHistory(history) {
|
||||||
|
const container = document.getElementById('agent-execution-history');
|
||||||
|
|
||||||
|
if (!history || history.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-history"></i>
|
||||||
|
<p>暂无执行历史</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyHtml = history.map(record => {
|
||||||
|
const startTime = new Date(record.start_time * 1000).toLocaleString();
|
||||||
|
const endTime = new Date(record.end_time * 1000).toLocaleString();
|
||||||
|
const duration = Math.round((record.end_time - record.start_time) * 100) / 100;
|
||||||
|
|
||||||
|
const priorityColor = {
|
||||||
|
5: 'danger',
|
||||||
|
4: 'warning',
|
||||||
|
3: 'info',
|
||||||
|
2: 'secondary',
|
||||||
|
1: 'light'
|
||||||
|
}[record.priority] || 'secondary';
|
||||||
|
|
||||||
|
const confidenceColor = record.confidence >= 0.8 ? 'success' :
|
||||||
|
record.confidence >= 0.5 ? 'warning' : 'danger';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">${record.description}</h6>
|
||||||
|
<div class="d-flex gap-3 mb-2">
|
||||||
|
<span class="badge bg-${priorityColor}">优先级 ${record.priority}</span>
|
||||||
|
<span class="badge bg-${confidenceColor}">置信度 ${(record.confidence * 100).toFixed(0)}%</span>
|
||||||
|
<span class="badge bg-${record.success ? 'success' : 'danger'}">${record.success ? '成功' : '失败'}</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="fas fa-clock me-1"></i>开始: ${startTime} |
|
||||||
|
<i class="fas fa-stopwatch me-1"></i>耗时: ${duration}秒
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3">
|
||||||
|
<span class="badge bg-primary">${record.action_type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${record.result && record.result.message ? `
|
||||||
|
<div class="mt-2">
|
||||||
|
<small class="text-muted">结果: ${record.result.message}</small>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = historyHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshAgentHistory() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/agent/action-history?limit=20');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.updateAgentExecutionHistory(data.history);
|
||||||
|
this.showNotification(`已加载 ${data.count} 条执行历史`, 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '获取执行历史失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新Agent历史失败:', error);
|
||||||
|
this.showNotification('刷新Agent历史失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
348
src/web/static/js/modules/alerts.js
Normal file
348
src/web/static/js/modules/alerts.js
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
// 预警管理模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
async loadAlerts(page = 1, forceRefresh = false) {
|
||||||
|
const cacheKey = `alerts_page_${page}`;
|
||||||
|
|
||||||
|
if (!forceRefresh && this.cache.has(cacheKey)) {
|
||||||
|
const cachedData = this.cache.get(cacheKey);
|
||||||
|
this.updateAlertsDisplay(cachedData.alerts);
|
||||||
|
this.updateAlertsPagination(cachedData);
|
||||||
|
this.updateAlertStatistics(cachedData.alerts); // 添加统计更新
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pageSize = this.getPageSize('alerts-pagination');
|
||||||
|
const response = await fetch(`/api/alerts?page=${page}&per_page=${pageSize}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.cache.set(cacheKey, data);
|
||||||
|
this.updateAlertsDisplay(data.alerts);
|
||||||
|
this.updateAlertsPagination(data);
|
||||||
|
this.updateAlertStatistics(data.alerts); // 添加统计更新
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载预警失败:', error);
|
||||||
|
this.showNotification('加载预警失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAlertsDisplay(alerts) {
|
||||||
|
const container = document.getElementById('alerts-container');
|
||||||
|
|
||||||
|
if (alerts.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<h5>暂无活跃预警</h5>
|
||||||
|
<p>系统运行正常,没有需要处理的预警</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加预警列表的批量操作头部
|
||||||
|
const headerHtml = `
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<input type="checkbox" id="select-all-alerts" class="form-check-input me-2" onchange="dashboard.toggleSelectAllAlerts()">
|
||||||
|
<label for="select-all-alerts" class="form-check-label">全选</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-danger" id="batch-delete-alerts" onclick="dashboard.batchDeleteAlerts()" disabled>
|
||||||
|
<i class="fas fa-trash me-1"></i>批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const alertsHtml = alerts.map(alert => `
|
||||||
|
<div class="alert-item ${alert.level}">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<input type="checkbox" class="form-check-input me-2 alert-checkbox" value="${alert.id}" onchange="dashboard.updateBatchDeleteAlertsButton()">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="badge bg-${this.getAlertColor(alert.level)} me-2">${this.getLevelText(alert.level)}</span>
|
||||||
|
<span class="fw-bold">${alert.rule_name || '未知规则'}</span>
|
||||||
|
<span class="ms-auto text-muted small">${this.formatTime(alert.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="alert-message mb-2">${alert.message}</div>
|
||||||
|
<div class="alert-meta text-muted small">
|
||||||
|
类型: ${this.getTypeText(alert.alert_type)} |
|
||||||
|
级别: ${this.getLevelText(alert.level)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3">
|
||||||
|
<button class="btn btn-sm btn-outline-success" onclick="dashboard.resolveAlert(${alert.id})" title="解决预警">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
<span class="d-none d-md-inline ms-1">解决</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = headerHtml + alertsHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAlertsPagination(data) {
|
||||||
|
this.createPaginationComponent(data, 'alerts-pagination', 'loadAlerts', '条预警');
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAlertStatistics(alerts) {
|
||||||
|
// 如果传入的是分页数据,需要重新获取全部数据来计算统计
|
||||||
|
if (alerts && alerts.length > 0) {
|
||||||
|
// 检查是否是分页数据(通常分页数据少于50条)
|
||||||
|
const pageSize = this.getPageSize('alerts-pagination');
|
||||||
|
if (alerts.length <= pageSize) {
|
||||||
|
// 可能是分页数据,需要获取全部数据
|
||||||
|
this.updateAlertStatisticsFromAPI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用传入的数据计算统计
|
||||||
|
const stats = (alerts || []).reduce((acc, alert) => {
|
||||||
|
acc[alert.level] = (acc[alert.level] || 0) + 1;
|
||||||
|
acc.total = (acc.total || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
document.getElementById('critical-alerts').textContent = stats.critical || 0;
|
||||||
|
document.getElementById('warning-alerts').textContent = stats.warning || 0;
|
||||||
|
document.getElementById('info-alerts').textContent = stats.info || 0;
|
||||||
|
document.getElementById('total-alerts-count').textContent = stats.total || 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 从API获取全部预警数据来计算统计
|
||||||
|
async updateAlertStatisticsFromAPI() {
|
||||||
|
try {
|
||||||
|
// 获取全部预警数据(不分页)
|
||||||
|
const response = await fetch('/api/alerts?per_page=1000'); // 获取大量数据
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.alerts) {
|
||||||
|
const stats = data.alerts.reduce((acc, alert) => {
|
||||||
|
acc[alert.level] = (acc[alert.level] || 0) + 1;
|
||||||
|
acc.total = (acc.total || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
document.getElementById('critical-alerts').textContent = stats.critical || 0;
|
||||||
|
document.getElementById('warning-alerts').textContent = stats.warning || 0;
|
||||||
|
document.getElementById('info-alerts').textContent = stats.info || 0;
|
||||||
|
document.getElementById('total-alerts-count').textContent = stats.total || 0;
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
this.cache.set('alerts_stats', {
|
||||||
|
data: data.alerts,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取全部预警统计失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 预警批量删除功能
|
||||||
|
toggleSelectAllAlerts() {
|
||||||
|
const selectAllCheckbox = document.getElementById('select-all-alerts');
|
||||||
|
const alertCheckboxes = document.querySelectorAll('.alert-checkbox');
|
||||||
|
|
||||||
|
alertCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = selectAllCheckbox.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateBatchDeleteAlertsButton();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBatchDeleteAlertsButton() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('.alert-checkbox:checked');
|
||||||
|
const batchDeleteBtn = document.getElementById('batch-delete-alerts');
|
||||||
|
|
||||||
|
if (batchDeleteBtn) {
|
||||||
|
batchDeleteBtn.disabled = selectedCheckboxes.length === 0;
|
||||||
|
batchDeleteBtn.textContent = selectedCheckboxes.length > 0
|
||||||
|
? `批量删除 (${selectedCheckboxes.length})`
|
||||||
|
: '批量删除';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchDeleteAlerts() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('.alert-checkbox:checked');
|
||||||
|
const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
this.showNotification('请选择要删除的预警', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`确定要删除选中的 ${selectedIds.length} 个预警吗?此操作不可撤销。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch-delete/alerts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids: selectedIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message, 'success');
|
||||||
|
|
||||||
|
// 清除所有相关缓存
|
||||||
|
this.cache.delete('alerts');
|
||||||
|
this.cache.delete('alerts_stats');
|
||||||
|
|
||||||
|
// 立即更新统计数字,避免跳动
|
||||||
|
await this.updateStatsAfterDelete(selectedIds.length);
|
||||||
|
|
||||||
|
// 重新加载预警列表
|
||||||
|
await this.loadAlerts();
|
||||||
|
|
||||||
|
// 重置批量删除按钮状态
|
||||||
|
this.updateBatchDeleteAlertsButton();
|
||||||
|
} else {
|
||||||
|
this.showNotification(data.error || '批量删除失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除预警失败:', error);
|
||||||
|
this.showNotification('批量删除预警失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除后更新统计数字(平滑更新)
|
||||||
|
async updateStatsAfterDelete(deletedCount) {
|
||||||
|
try {
|
||||||
|
// 直接调用API获取最新统计,不依赖页面显示数据
|
||||||
|
await this.updateAlertStatisticsFromAPI();
|
||||||
|
|
||||||
|
// 更新最后更新时间,避免智能更新机制干扰
|
||||||
|
this.lastUpdateTimes.alerts = Date.now();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新删除后统计失败:', error);
|
||||||
|
// 如果计算失败,直接刷新
|
||||||
|
await this.refreshAlertStats();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取当前显示的预警数据
|
||||||
|
getCurrentDisplayedAlerts() {
|
||||||
|
const alertElements = document.querySelectorAll('.alert-item');
|
||||||
|
const alerts = [];
|
||||||
|
|
||||||
|
alertElements.forEach(element => {
|
||||||
|
const level = element.querySelector('.alert-level')?.textContent?.trim();
|
||||||
|
if (level) {
|
||||||
|
alerts.push({ level: level.toLowerCase() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 计算预警统计
|
||||||
|
calculateAlertStats(alerts) {
|
||||||
|
const stats = { critical: 0, warning: 0, info: 0, total: 0 };
|
||||||
|
|
||||||
|
alerts.forEach(alert => {
|
||||||
|
const level = alert.level.toLowerCase();
|
||||||
|
if (stats.hasOwnProperty(level)) {
|
||||||
|
stats[level]++;
|
||||||
|
}
|
||||||
|
stats.total++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 平滑更新预警统计数字
|
||||||
|
smoothUpdateAlertStats(newStats) {
|
||||||
|
// 获取当前显示的数字
|
||||||
|
const currentCritical = parseInt(document.getElementById('critical-alerts')?.textContent || '0');
|
||||||
|
const currentWarning = parseInt(document.getElementById('warning-alerts')?.textContent || '0');
|
||||||
|
const currentInfo = parseInt(document.getElementById('info-alerts')?.textContent || '0');
|
||||||
|
const currentTotal = parseInt(document.getElementById('total-alerts-count')?.textContent || '0');
|
||||||
|
|
||||||
|
// 平滑过渡到新数字
|
||||||
|
this.animateNumberChange('critical-alerts', currentCritical, newStats.critical);
|
||||||
|
this.animateNumberChange('warning-alerts', currentWarning, newStats.warning);
|
||||||
|
this.animateNumberChange('info-alerts', currentInfo, newStats.info);
|
||||||
|
this.animateNumberChange('total-alerts-count', currentTotal, newStats.total);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 数字变化动画
|
||||||
|
animateNumberChange(elementId, from, to) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const duration = 300; // 300ms动画
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
// 使用缓动函数
|
||||||
|
const easeOut = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const currentValue = Math.round(from + (to - from) * easeOut);
|
||||||
|
|
||||||
|
element.textContent = currentValue;
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 解决预警后更新统计数字
|
||||||
|
async updateStatsAfterResolve(alertId) {
|
||||||
|
try {
|
||||||
|
// 直接调用API获取最新统计,不依赖页面显示数据
|
||||||
|
await this.updateAlertStatisticsFromAPI();
|
||||||
|
|
||||||
|
// 更新最后更新时间,避免智能更新机制干扰
|
||||||
|
this.lastUpdateTimes.alerts = Date.now();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新解决后统计失败:', error);
|
||||||
|
// 如果计算失败,直接刷新
|
||||||
|
await this.refreshAlertStats();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async resolveAlert(alertId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/alerts/${alertId}/resolve`, { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('预警已解决', 'success');
|
||||||
|
|
||||||
|
// 清除缓存
|
||||||
|
this.cache.delete('alerts');
|
||||||
|
this.cache.delete('alerts_stats');
|
||||||
|
|
||||||
|
// 立即更新统计数字
|
||||||
|
await this.updateStatsAfterResolve(alertId);
|
||||||
|
|
||||||
|
// 重新加载预警列表
|
||||||
|
this.loadAlerts();
|
||||||
|
} else {
|
||||||
|
this.showNotification('解决预警失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解决预警失败:', error);
|
||||||
|
this.showNotification('解决预警失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
207
src/web/static/js/modules/chat.js
Normal file
207
src/web/static/js/modules/chat.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
// 智能对话模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
async startChat() {
|
||||||
|
try {
|
||||||
|
const userId = document.getElementById('user-id').value;
|
||||||
|
const workOrderId = document.getElementById('work-order-id').value;
|
||||||
|
const tenantId = document.getElementById('chat-tenant-id')?.value || 'default';
|
||||||
|
|
||||||
|
const response = await fetch('/api/chat/session', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
user_id: userId,
|
||||||
|
work_order_id: workOrderId ? parseInt(workOrderId) : null,
|
||||||
|
tenant_id: tenantId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.sessionId = data.session_id;
|
||||||
|
document.getElementById('start-chat').disabled = true;
|
||||||
|
document.getElementById('end-chat').disabled = false;
|
||||||
|
document.getElementById('message-input').disabled = false;
|
||||||
|
document.getElementById('send-button').disabled = false;
|
||||||
|
document.getElementById('session-info').textContent = `会话ID: ${this.sessionId}`;
|
||||||
|
document.getElementById('connection-status').className = 'badge bg-success';
|
||||||
|
document.getElementById('connection-status').innerHTML = '<i class="fas fa-circle me-1"></i>已连接';
|
||||||
|
|
||||||
|
this.showNotification('对话已开始', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('开始对话失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('开始对话失败:', error);
|
||||||
|
this.showNotification('开始对话失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async endChat() {
|
||||||
|
try {
|
||||||
|
if (!this.sessionId) return;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/chat/session/${this.sessionId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.sessionId = null;
|
||||||
|
document.getElementById('start-chat').disabled = false;
|
||||||
|
document.getElementById('end-chat').disabled = true;
|
||||||
|
document.getElementById('message-input').disabled = true;
|
||||||
|
document.getElementById('send-button').disabled = true;
|
||||||
|
document.getElementById('session-info').textContent = '未开始对话';
|
||||||
|
document.getElementById('connection-status').className = 'badge bg-secondary';
|
||||||
|
document.getElementById('connection-status').innerHTML = '<i class="fas fa-circle me-1"></i>未连接';
|
||||||
|
|
||||||
|
this.showNotification('对话已结束', 'info');
|
||||||
|
} else {
|
||||||
|
this.showNotification('结束对话失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('结束对话失败:', error);
|
||||||
|
this.showNotification('结束对话失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage() {
|
||||||
|
const messageInput = document.getElementById('message-input');
|
||||||
|
const message = messageInput.value.trim();
|
||||||
|
|
||||||
|
if (!message || !this.sessionId) return;
|
||||||
|
|
||||||
|
// 显示用户消息
|
||||||
|
this.addMessage('user', message);
|
||||||
|
messageInput.value = '';
|
||||||
|
|
||||||
|
// 显示占位提示:小奇正在查询中
|
||||||
|
const typingId = this.showTypingIndicator();
|
||||||
|
|
||||||
|
// 发送消息到服务器
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/chat/message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: this.sessionId,
|
||||||
|
message: message
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.updateTypingIndicator(typingId, data.response, data.knowledge_used);
|
||||||
|
} else {
|
||||||
|
this.updateTypingIndicator(typingId, '抱歉,处理您的消息时出现了错误。', null, true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送消息失败:', error);
|
||||||
|
this.updateTypingIndicator(typingId, '网络连接错误,请稍后重试。', null, true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showTypingIndicator() {
|
||||||
|
const messagesContainer = document.getElementById('chat-messages');
|
||||||
|
const id = `typing-${Date.now()}`;
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = 'message assistant';
|
||||||
|
messageDiv.id = id;
|
||||||
|
const avatar = document.createElement('div');
|
||||||
|
avatar.className = 'message-avatar';
|
||||||
|
avatar.innerHTML = '<i class="fas fa-robot"></i>';
|
||||||
|
const contentDiv = document.createElement('div');
|
||||||
|
contentDiv.className = 'message-content';
|
||||||
|
contentDiv.innerHTML = `
|
||||||
|
<div>小奇正在查询中,请稍后…</div>
|
||||||
|
<div class="message-time">${new Date().toLocaleTimeString()}</div>
|
||||||
|
`;
|
||||||
|
messageDiv.appendChild(avatar);
|
||||||
|
messageDiv.appendChild(contentDiv);
|
||||||
|
messagesContainer.appendChild(messageDiv);
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTypingIndicator(typingId, content, knowledgeUsed = null, isError = false) {
|
||||||
|
const node = document.getElementById(typingId);
|
||||||
|
if (!node) {
|
||||||
|
// 回退:若占位不存在则直接追加
|
||||||
|
this.addMessage('assistant', content, knowledgeUsed, isError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const contentDiv = node.querySelector('.message-content');
|
||||||
|
if (contentDiv) {
|
||||||
|
contentDiv.innerHTML = `
|
||||||
|
<div>${content}</div>
|
||||||
|
<div class="message-time">${new Date().toLocaleTimeString()}</div>
|
||||||
|
`;
|
||||||
|
if (knowledgeUsed && knowledgeUsed.length > 0) {
|
||||||
|
const knowledgeDiv = document.createElement('div');
|
||||||
|
knowledgeDiv.className = 'knowledge-info';
|
||||||
|
knowledgeDiv.innerHTML = `
|
||||||
|
<i class="fas fa-lightbulb me-1"></i>
|
||||||
|
使用了知识库: ${knowledgeUsed.map(k => k.question || k.source || '实时数据').join(', ')}
|
||||||
|
`;
|
||||||
|
contentDiv.appendChild(knowledgeDiv);
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
contentDiv.style.borderLeft = '4px solid #dc3545';
|
||||||
|
} else {
|
||||||
|
contentDiv.style.borderLeft = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const messagesContainer = document.getElementById('chat-messages');
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
},
|
||||||
|
|
||||||
|
addMessage(role, content, knowledgeUsed = null, isError = false) {
|
||||||
|
const messagesContainer = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
// 移除欢迎消息
|
||||||
|
const welcomeMsg = messagesContainer.querySelector('.text-center');
|
||||||
|
if (welcomeMsg) {
|
||||||
|
welcomeMsg.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageDiv = document.createElement('div');
|
||||||
|
messageDiv.className = `message ${role}`;
|
||||||
|
|
||||||
|
const avatar = document.createElement('div');
|
||||||
|
avatar.className = 'message-avatar';
|
||||||
|
avatar.innerHTML = role === 'user' ? '<i class="fas fa-user"></i>' : '<i class="fas fa-robot"></i>';
|
||||||
|
|
||||||
|
const contentDiv = document.createElement('div');
|
||||||
|
contentDiv.className = 'message-content';
|
||||||
|
contentDiv.innerHTML = `
|
||||||
|
<div>${content}</div>
|
||||||
|
<div class="message-time">${new Date().toLocaleTimeString()}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (knowledgeUsed && knowledgeUsed.length > 0) {
|
||||||
|
const knowledgeDiv = document.createElement('div');
|
||||||
|
knowledgeDiv.className = 'knowledge-info';
|
||||||
|
knowledgeDiv.innerHTML = `
|
||||||
|
<i class="fas fa-lightbulb me-1"></i>
|
||||||
|
使用了知识库: ${knowledgeUsed.map(k => k.question).join(', ')}
|
||||||
|
`;
|
||||||
|
contentDiv.appendChild(knowledgeDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
contentDiv.style.borderLeft = '4px solid #dc3545';
|
||||||
|
}
|
||||||
|
|
||||||
|
messageDiv.appendChild(avatar);
|
||||||
|
messageDiv.appendChild(contentDiv);
|
||||||
|
messagesContainer.appendChild(messageDiv);
|
||||||
|
|
||||||
|
// 滚动到底部
|
||||||
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
671
src/web/static/js/modules/conversations.js
Normal file
671
src/web/static/js/modules/conversations.js
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
// 对话历史模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
async loadConversationTenantList() {
|
||||||
|
this.conversationCurrentTenantId = null;
|
||||||
|
this.renderConversationBreadcrumb(null);
|
||||||
|
|
||||||
|
// 加载全局统计
|
||||||
|
this.loadConversationStats(null);
|
||||||
|
|
||||||
|
// 显示租户列表容器,隐藏详情容器
|
||||||
|
const tenantListEl = document.getElementById('conversation-tenant-list');
|
||||||
|
const tenantDetailEl = document.getElementById('conversation-tenant-detail');
|
||||||
|
|
||||||
|
if (tenantListEl) tenantListEl.style.display = '';
|
||||||
|
if (tenantDetailEl) tenantDetailEl.style.display = 'none';
|
||||||
|
|
||||||
|
// 显示加载中 spinner
|
||||||
|
if (tenantListEl) {
|
||||||
|
tenantListEl.innerHTML = '<div class="col-12 text-center py-4"><i class="fas fa-spinner fa-spin fa-2x"></i><p class="mt-2 text-muted">加载中...</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conversations/tenants');
|
||||||
|
const tenants = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(tenants) || tenants.length === 0) {
|
||||||
|
if (tenantListEl) {
|
||||||
|
tenantListEl.innerHTML = '<div class="col-12 text-center py-4"><i class="fas fa-comments fa-2x text-muted"></i><p class="mt-2 text-muted">暂无对话会话数据</p></div>';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardsHtml = tenants.map(t => {
|
||||||
|
const lastActive = t.last_active_time ? new Date(t.last_active_time).toLocaleString() : '无';
|
||||||
|
return `
|
||||||
|
<div class="col-md-4 col-sm-6 mb-3">
|
||||||
|
<div class="card h-100 shadow-sm" style="cursor:pointer" onclick="dashboard.loadConversationTenantDetail('${t.tenant_id}')">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title"><i class="fas fa-building me-2"></i>${t.tenant_id}</h6>
|
||||||
|
<div class="d-flex justify-content-between mt-3">
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">会话总数</small>
|
||||||
|
<h5 class="mb-0">${t.session_count}</h5>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">消息总数</small>
|
||||||
|
<h5 class="mb-0">${t.message_count}</h5>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">活跃会话</small>
|
||||||
|
<h5 class="mb-0 text-success">${t.active_session_count}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<small class="text-muted"><i class="fas fa-clock me-1"></i>最近活跃: ${lastActive}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
if (tenantListEl) {
|
||||||
|
tenantListEl.innerHTML = cardsHtml;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载对话租户列表失败:', error);
|
||||||
|
if (tenantListEl) {
|
||||||
|
tenantListEl.innerHTML = '<div class="col-12 text-center py-4 text-danger"><i class="fas fa-exclamation-triangle"></i> 加载失败</div>';
|
||||||
|
}
|
||||||
|
this.showNotification('加载对话租户列表失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 加载对话历史租户详情视图
|
||||||
|
async loadConversationTenantDetail(tenantId, page = 1) {
|
||||||
|
this.conversationCurrentTenantId = tenantId;
|
||||||
|
this.paginationConfig.currentConversationPage = page;
|
||||||
|
|
||||||
|
// 隐藏租户列表,显示详情容器
|
||||||
|
const tenantListEl = document.getElementById('conversation-tenant-list');
|
||||||
|
const tenantDetailEl = document.getElementById('conversation-tenant-detail');
|
||||||
|
|
||||||
|
if (tenantListEl) tenantListEl.style.display = 'none';
|
||||||
|
if (tenantDetailEl) tenantDetailEl.style.display = '';
|
||||||
|
|
||||||
|
// 渲染面包屑(如果 renderConversationBreadcrumb 已实现)
|
||||||
|
if (typeof this.renderConversationBreadcrumb === 'function') {
|
||||||
|
this.renderConversationBreadcrumb(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载租户级统计(如果 loadConversationStats 已实现)
|
||||||
|
if (typeof this.loadConversationStats === 'function') {
|
||||||
|
this.loadConversationStats(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示加载中 spinner
|
||||||
|
const sessionListEl = document.getElementById('conversation-session-list');
|
||||||
|
if (sessionListEl) {
|
||||||
|
sessionListEl.innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-2x"></i><p class="mt-2 text-muted">加载中...</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const perPage = this.getPageSize('conversation-session-pagination');
|
||||||
|
const statusFilter = document.getElementById('conversation-status-filter')?.value || '';
|
||||||
|
const dateFilter = document.getElementById('conversation-detail-date-filter')?.value || '';
|
||||||
|
|
||||||
|
let url = `/api/conversations/sessions?tenant_id=${encodeURIComponent(tenantId)}&page=${page}&per_page=${perPage}`;
|
||||||
|
if (statusFilter) url += `&status=${encodeURIComponent(statusFilter)}`;
|
||||||
|
if (dateFilter) url += `&date_filter=${encodeURIComponent(dateFilter)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.sessions) {
|
||||||
|
this.renderConversationSessionTable(data.sessions, tenantId);
|
||||||
|
this.updateConversationTenantPagination(data);
|
||||||
|
} else {
|
||||||
|
if (sessionListEl) {
|
||||||
|
sessionListEl.innerHTML = '<div class="text-center py-4"><i class="fas fa-comments fa-2x text-muted"></i><p class="mt-2 text-muted">暂无会话数据</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载租户会话列表失败:', error);
|
||||||
|
if (sessionListEl) {
|
||||||
|
sessionListEl.innerHTML = '<div class="text-center py-4 text-danger"><i class="fas fa-exclamation-triangle"></i> 加载失败</div>';
|
||||||
|
}
|
||||||
|
this.showNotification('加载租户会话列表失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 渲染会话表格
|
||||||
|
renderConversationSessionTable(sessions, tenantId) {
|
||||||
|
const sessionListEl = document.getElementById('conversation-session-list');
|
||||||
|
if (!sessionListEl) return;
|
||||||
|
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
sessionListEl.innerHTML = '<div class="text-center py-4"><i class="fas fa-comments fa-2x text-muted"></i><p class="mt-2 text-muted">暂无会话数据</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusBadge = (status) => {
|
||||||
|
if (status === 'active') return '<span class="badge bg-success">活跃</span>';
|
||||||
|
if (status === 'ended') return '<span class="badge bg-secondary">已结束</span>';
|
||||||
|
return `<span class="badge bg-info">${status || '未知'}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceBadge = (source) => {
|
||||||
|
if (source === 'feishu') return '<span class="badge bg-purple">飞书</span>';
|
||||||
|
if (source === 'websocket') return '<span class="badge bg-blue">WebSocket</span>';
|
||||||
|
if (source === 'api') return '<span class="badge bg-indigo">API</span>';
|
||||||
|
return `<span class="badge bg-secondary">${source || '未知'}</span>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (isoStr) => {
|
||||||
|
if (!isoStr) return '-';
|
||||||
|
return new Date(isoStr).toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowsHtml = sessions.map(s => `
|
||||||
|
<tr style="cursor:pointer" onclick="dashboard.viewSessionMessages('${s.session_id}', '${(s.title || '').replace(/'/g, "\\'")}')">
|
||||||
|
<td>${s.title || s.session_id || '-'}</td>
|
||||||
|
<td class="text-center">${s.message_count || 0}</td>
|
||||||
|
<td class="text-center">${statusBadge(s.status)}</td>
|
||||||
|
<td class="text-center">${sourceBadge(s.source)}</td>
|
||||||
|
<td>${formatTime(s.created_at)}</td>
|
||||||
|
<td>${formatTime(s.updated_at)}</td>
|
||||||
|
<td class="text-center" onclick="event.stopPropagation()">
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="dashboard.deleteConversationSession('${s.session_id}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
sessionListEl.innerHTML = `
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover table-striped mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>会话标题</th>
|
||||||
|
<th class="text-center">消息数</th>
|
||||||
|
<th class="text-center">状态</th>
|
||||||
|
<th class="text-center">来源</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>更新时间</th>
|
||||||
|
<th class="text-center">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${rowsHtml}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 查看会话消息详情
|
||||||
|
async viewSessionMessages(sessionId, sessionTitle) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/sessions/${encodeURIComponent(sessionId)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.messages) {
|
||||||
|
// 更新面包屑到第三层(如果已实现)
|
||||||
|
if (typeof this.renderConversationBreadcrumb === 'function') {
|
||||||
|
this.renderConversationBreadcrumb(this.conversationCurrentTenantId, sessionTitle || sessionId);
|
||||||
|
}
|
||||||
|
this.showSessionMessagesModal(data.session, data.messages);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '获取会话消息失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取会话消息失败:', error);
|
||||||
|
this.showNotification('获取会话消息失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 显示会话消息模态框
|
||||||
|
showSessionMessagesModal(session, messages) {
|
||||||
|
const messagesHtml = messages.map(msg => `
|
||||||
|
<div class="mb-3 p-3 border rounded">
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong class="text-primary"><i class="fas fa-user me-1"></i>用户:</strong>
|
||||||
|
<div class="mt-1">${msg.user_message || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong class="text-success"><i class="fas fa-robot me-1"></i>助手:</strong>
|
||||||
|
<div class="mt-1">${msg.assistant_response || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-muted">
|
||||||
|
<small>
|
||||||
|
${msg.timestamp ? new Date(msg.timestamp).toLocaleString() : ''}
|
||||||
|
${msg.response_time ? ` | 响应: ${msg.response_time}s` : ''}
|
||||||
|
${msg.confidence_score ? ` | 置信度: ${Math.round(msg.confidence_score * 100)}%` : ''}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="sessionMessagesModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title"><i class="fas fa-comments me-2"></i>${session?.title || session?.session_id || '会话详情'}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3 text-muted">
|
||||||
|
<small>
|
||||||
|
会话ID: ${session?.session_id || '-'} |
|
||||||
|
消息数: ${session?.message_count || 0} |
|
||||||
|
状态: ${session?.status || '-'} |
|
||||||
|
来源: ${session?.source || '-'}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
${messages.length > 0 ? messagesHtml : '<div class="text-center text-muted py-3">暂无消息记录</div>'}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 移除已存在的模态框
|
||||||
|
const existingModal = document.getElementById('sessionMessagesModal');
|
||||||
|
if (existingModal) existingModal.remove();
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('sessionMessagesModal'));
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
// 模态框关闭后清理
|
||||||
|
document.getElementById('sessionMessagesModal').addEventListener('hidden.bs.modal', function () {
|
||||||
|
this.remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 对话租户详情分页
|
||||||
|
updateConversationTenantPagination(data) {
|
||||||
|
this.createPaginationComponent(data, 'conversation-session-pagination', 'loadConversationTenantDetailPage', '条会话');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.2: 面包屑导航
|
||||||
|
renderConversationBreadcrumb(tenantId, sessionTitle) {
|
||||||
|
const breadcrumbEl = document.getElementById('conversation-breadcrumb');
|
||||||
|
if (!breadcrumbEl) return;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
breadcrumbEl.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionTitle) {
|
||||||
|
// 租户详情视图: "对话历史 > {tenant_id}"
|
||||||
|
breadcrumbEl.innerHTML = `
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="#" onclick="dashboard.loadConversationTenantList(); return false;"><i class="fas fa-history me-1"></i>对话历史</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">${tenantId}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// 消息详情视图: "对话历史 > {tenant_id} > {session_title}"
|
||||||
|
breadcrumbEl.innerHTML = `
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="#" onclick="dashboard.loadConversationTenantList(); return false;"><i class="fas fa-history me-1"></i>对话历史</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="#" onclick="dashboard.loadConversationTenantDetail('${tenantId}'); return false;">${tenantId}</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">${sessionTitle}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.conversationCurrentTenantId = tenantId;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 应用对话筛选条件
|
||||||
|
applyConversationFilters() {
|
||||||
|
if (this.conversationCurrentTenantId) {
|
||||||
|
this.loadConversationTenantDetail(this.conversationCurrentTenantId, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.3: 删除会话并处理空租户自动返回
|
||||||
|
async deleteConversationSession(sessionId) {
|
||||||
|
if (!confirm('确定要删除这个会话及其所有消息吗?')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('会话已删除', 'success');
|
||||||
|
// 刷新当前租户详情视图并检查是否还有剩余会话
|
||||||
|
if (this.conversationCurrentTenantId) {
|
||||||
|
const tenantId = this.conversationCurrentTenantId;
|
||||||
|
const perPage = this.getPageSize('conversation-session-pagination');
|
||||||
|
const checkUrl = `/api/conversations/sessions?tenant_id=${encodeURIComponent(tenantId)}&page=1&per_page=${perPage}`;
|
||||||
|
const checkResp = await fetch(checkUrl);
|
||||||
|
const checkData = await checkResp.json();
|
||||||
|
|
||||||
|
if (!checkData.sessions || checkData.sessions.length === 0 || checkData.total === 0) {
|
||||||
|
// 该租户下已无会话,自动返回租户列表视图
|
||||||
|
this.loadConversationTenantList();
|
||||||
|
} else {
|
||||||
|
// 仍有会话,刷新当前详情视图(调整页码避免越界)
|
||||||
|
const maxPage = checkData.total_pages || 1;
|
||||||
|
const currentPage = Math.min(this.paginationConfig.currentConversationPage, maxPage);
|
||||||
|
await this.loadConversationTenantDetail(tenantId, currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '删除失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除会话失败:', error);
|
||||||
|
this.showNotification('删除会话失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 对话历史管理
|
||||||
|
async loadConversationHistory(page = 1) {
|
||||||
|
try {
|
||||||
|
const pageSize = this.getPageSize('conversations-pagination');
|
||||||
|
const response = await fetch(`/api/conversations?page=${page}&per_page=${pageSize}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.conversations) {
|
||||||
|
this.renderConversationList(data.conversations || []);
|
||||||
|
this.updateConversationPagination(data);
|
||||||
|
this.updateConversationStats(data.stats || {});
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '加载对话历史失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载对话历史失败:', error);
|
||||||
|
this.showNotification('加载对话历史失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderConversationList(conversations) {
|
||||||
|
const container = document.getElementById('conversation-list');
|
||||||
|
if (!conversations || conversations.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="fas fa-comments"></i>
|
||||||
|
<p>暂无对话记录</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = conversations.map(conv => {
|
||||||
|
// 识别来源和类型
|
||||||
|
let sourceBadge = '';
|
||||||
|
const method = conv.invocation_method || '';
|
||||||
|
const userId = conv.user_id || '';
|
||||||
|
|
||||||
|
// 判断是否来自飞书 (根据调用方式或ID格式)
|
||||||
|
if (method.includes('feishu') || userId.startsWith('ou_')) {
|
||||||
|
sourceBadge = '<span class="badge bg-purple me-1">飞书</span>';
|
||||||
|
} else {
|
||||||
|
sourceBadge = '<span class="badge bg-blue me-1">Web</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断群聊/私聊
|
||||||
|
let typeBadge = '';
|
||||||
|
if (method.includes('group')) {
|
||||||
|
typeBadge = '<span class="badge bg-azure me-1">群聊</span>';
|
||||||
|
} else if (method.includes('p2p')) {
|
||||||
|
typeBadge = '<span class="badge bg-indigo me-1">私聊</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="card mb-3 conversation-item" data-conversation-id="${conv.id}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1" style="font-family: var(--font-family-primary); font-weight: 600;">
|
||||||
|
${sourceBadge}
|
||||||
|
${typeBadge}
|
||||||
|
用户: ${userId || '匿名'}
|
||||||
|
</h6>
|
||||||
|
<small class="text-muted">${new Date(conv.timestamp).toLocaleString()}</small>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="dashboard.viewConversation(${conv.id})">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-danger" onclick="dashboard.deleteConversation(${conv.id})">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="conversation-preview">
|
||||||
|
<p class="mb-1"><strong>用户:</strong> ${conv.user_message?.substring(0, 100)}${conv.user_message?.length > 100 ? '...' : ''}</p>
|
||||||
|
<p class="mb-0"><strong>助手:</strong> ${conv.assistant_response?.substring(0, 100)}${conv.assistant_response?.length > 100 ? '...' : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<span class="badge bg-secondary">响应时间: ${conv.response_time || 0}ms</span>
|
||||||
|
${conv.work_order_id ? `<span class="badge bg-info">工单: ${conv.work_order_id}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConversationPagination(data) {
|
||||||
|
this.createPaginationComponent(data, 'conversations-pagination', 'loadConversationHistory', '条对话');
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConversationStats(stats) {
|
||||||
|
document.getElementById('conversation-total').textContent = stats.total || 0;
|
||||||
|
document.getElementById('conversation-today').textContent = stats.today || 0;
|
||||||
|
document.getElementById('conversation-avg-response').textContent = `${stats.avg_response_time || 0}ms`;
|
||||||
|
document.getElementById('conversation-active-users').textContent = stats.active_users || 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 8.3: 加载对话统计(支持租户级别)
|
||||||
|
async loadConversationStats(tenantId) {
|
||||||
|
try {
|
||||||
|
let url = '/api/conversations/analytics';
|
||||||
|
if (tenantId) {
|
||||||
|
url += `?tenant_id=${encodeURIComponent(tenantId)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const analytics = data.analytics || {};
|
||||||
|
const convStats = analytics.conversations || {};
|
||||||
|
|
||||||
|
const totalEl = document.getElementById('conversation-total');
|
||||||
|
const todayEl = document.getElementById('conversation-today');
|
||||||
|
const avgResponseEl = document.getElementById('conversation-avg-response');
|
||||||
|
const activeUsersEl = document.getElementById('conversation-active-users');
|
||||||
|
|
||||||
|
if (totalEl) totalEl.textContent = convStats.total || 0;
|
||||||
|
if (todayEl) todayEl.textContent = convStats.today || 0;
|
||||||
|
if (avgResponseEl) avgResponseEl.textContent = `${Math.round(convStats.avg_response_time || 0)}ms`;
|
||||||
|
if (activeUsersEl) activeUsersEl.textContent = convStats.active_users || 0;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载对话统计失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshConversationHistory() {
|
||||||
|
// 先尝试触发一次合并迁移(幂等,重复调用也安全)
|
||||||
|
try {
|
||||||
|
await fetch('/api/conversations/migrate-merge', { method: 'POST' });
|
||||||
|
} catch (e) { /* 忽略迁移失败 */ }
|
||||||
|
// 根据当前视图状态刷新:租户详情视图或租户列表视图
|
||||||
|
if (this.conversationCurrentTenantId) {
|
||||||
|
await this.loadConversationTenantDetail(this.conversationCurrentTenantId, this.paginationConfig.currentConversationPage);
|
||||||
|
} else {
|
||||||
|
await this.loadConversationTenantList();
|
||||||
|
}
|
||||||
|
this.showNotification('对话历史已刷新', 'success');
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearAllConversations() {
|
||||||
|
if (!confirm('确定要清空所有对话历史吗?此操作不可恢复!')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/conversations/clear', { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('对话历史已清空', 'success');
|
||||||
|
await this.loadConversationHistory();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '清空对话历史失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清空对话历史失败:', error);
|
||||||
|
this.showNotification('清空对话历史失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteConversation(conversationId) {
|
||||||
|
if (!confirm('确定要删除这条对话记录吗?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('对话记录已删除', 'success');
|
||||||
|
await this.loadConversationHistory();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '删除对话记录失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除对话记录失败:', error);
|
||||||
|
this.showNotification('删除对话记录失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewConversation(conversationId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/conversations/${conversationId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
data.user_id = data.user_id || '匿名';
|
||||||
|
this.showConversationModal(data);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '获取对话详情失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取对话详情失败:', error);
|
||||||
|
this.showNotification('获取对话详情失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showConversationModal(conversation) {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="conversationModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">对话详情</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<strong>用户:</strong> ${conversation.user_id || '匿名'}<br>
|
||||||
|
<strong>时间:</strong> ${new Date(conversation.timestamp).toLocaleString()}<br>
|
||||||
|
<strong>响应时间:</strong> ${conversation.response_time || 0}ms
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6>用户消息:</h6>
|
||||||
|
<div class="border p-3 rounded">${conversation.user_message || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<h6>助手回复:</h6>
|
||||||
|
<div class="border p-3 rounded">${conversation.assistant_response || ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 移除已存在的模态框
|
||||||
|
const existingModal = document.getElementById('conversationModal');
|
||||||
|
if (existingModal) {
|
||||||
|
existingModal.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新模态框
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
|
||||||
|
// 显示模态框
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('conversationModal'));
|
||||||
|
modal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
async filterConversations() {
|
||||||
|
const search = document.getElementById('conversation-search').value.trim();
|
||||||
|
const userFilter = document.getElementById('conversation-user-filter')?.value || '';
|
||||||
|
const dateFilter = document.getElementById('conversation-date-filter')?.value || '';
|
||||||
|
|
||||||
|
// Task 8.1: 在 Tenant_Detail_View 中搜索时自动附加 tenant_id 参数
|
||||||
|
if (this.conversationCurrentTenantId) {
|
||||||
|
if (!search) {
|
||||||
|
// 清空搜索时恢复当前租户的完整分页列表
|
||||||
|
this.loadConversationTenantDetail(this.conversationCurrentTenantId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const perPage = this.getPageSize('conversation-session-pagination');
|
||||||
|
let url = `/api/conversations/sessions?search=${encodeURIComponent(search)}&tenant_id=${encodeURIComponent(this.conversationCurrentTenantId)}&page=1&per_page=${perPage}`;
|
||||||
|
if (dateFilter) url += `&date_filter=${encodeURIComponent(dateFilter)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.sessions) {
|
||||||
|
this.renderConversationSessionTable(data.sessions, this.conversationCurrentTenantId);
|
||||||
|
this.updateConversationTenantPagination(data);
|
||||||
|
} else {
|
||||||
|
const sessionListEl = document.getElementById('conversation-session-list');
|
||||||
|
if (sessionListEl) {
|
||||||
|
sessionListEl.innerHTML = '<div class="text-center py-4"><i class="fas fa-search fa-2x text-muted"></i><p class="mt-2 text-muted">未找到匹配的会话</p></div>';
|
||||||
|
}
|
||||||
|
const paginationEl = document.getElementById('conversation-session-pagination');
|
||||||
|
if (paginationEl) paginationEl.innerHTML = '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索租户会话失败:', error);
|
||||||
|
this.showNotification('搜索会话失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非租户详情视图:保持原有行为
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.append('search', search);
|
||||||
|
if (userFilter) params.append('user_id', userFilter);
|
||||||
|
if (dateFilter) params.append('date_filter', dateFilter);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/conversations?${params.toString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.renderConversationList(data.conversations || []);
|
||||||
|
this.renderConversationPagination(data.pagination || {});
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '筛选对话失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('筛选对话失败:', error);
|
||||||
|
this.showNotification('筛选对话失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
286
src/web/static/js/modules/dashboard-home.js
Normal file
286
src/web/static/js/modules/dashboard-home.js
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// Dashboard Home Module - 仪表板首页相关方法
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
|
||||||
|
async loadDashboardData() {
|
||||||
|
try {
|
||||||
|
const [sessionsResponse, alertsResponse, workordersResponse, knowledgeResponse] = await Promise.all([
|
||||||
|
fetch('/api/chat/sessions'),
|
||||||
|
fetch('/api/alerts?per_page=1000'),
|
||||||
|
fetch('/api/workorders'),
|
||||||
|
fetch('/api/knowledge/stats')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sessions = await sessionsResponse.json();
|
||||||
|
const alerts = await alertsResponse.json();
|
||||||
|
const workorders = await workordersResponse.json();
|
||||||
|
const knowledge = await knowledgeResponse.json();
|
||||||
|
|
||||||
|
document.getElementById('total-sessions').textContent = sessions.sessions?.length || 0;
|
||||||
|
document.getElementById('total-alerts').textContent = alerts.alerts?.length || 0;
|
||||||
|
document.getElementById('total-workorders').textContent = workorders.workorders?.filter(w => w.status === 'open').length || 0;
|
||||||
|
document.getElementById('knowledge-count').textContent = knowledge.total_entries || 0;
|
||||||
|
|
||||||
|
if (alerts.alerts) {
|
||||||
|
this.updateAlertStatistics(alerts.alerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('knowledge-total').textContent = knowledge.total_entries || 0;
|
||||||
|
document.getElementById('knowledge-active').textContent = knowledge.active_entries || 0;
|
||||||
|
const confidencePercent = Math.round((knowledge.average_confidence || 0) * 100);
|
||||||
|
document.getElementById('knowledge-confidence-bar').style.width = `${confidencePercent}%`;
|
||||||
|
document.getElementById('knowledge-confidence-bar').setAttribute('aria-valuenow', confidencePercent);
|
||||||
|
document.getElementById('knowledge-confidence-bar').textContent = `${confidencePercent}%`;
|
||||||
|
|
||||||
|
await this.updatePerformanceChart(sessions, alerts, workorders);
|
||||||
|
await this.updateSystemHealth();
|
||||||
|
await this.loadAnalytics();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载仪表板数据失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
initCharts() {
|
||||||
|
const performanceCtx = document.getElementById('performanceChart');
|
||||||
|
if (performanceCtx) {
|
||||||
|
this.charts.performance = new Chart(performanceCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: '工单数量',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#007bff',
|
||||||
|
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}, {
|
||||||
|
label: '预警数量',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#dc3545',
|
||||||
|
backgroundColor: 'rgba(220, 53, 69, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyticsCtx = document.getElementById('analyticsChart');
|
||||||
|
if (analyticsCtx) {
|
||||||
|
this.charts.analytics = new Chart(analyticsCtx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: '满意度',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#28a745',
|
||||||
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}, {
|
||||||
|
label: '解决时间(小时)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#ffc107',
|
||||||
|
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryCtx = document.getElementById('categoryChart');
|
||||||
|
if (categoryCtx) {
|
||||||
|
this.charts.category = new Chart(categoryCtx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
data: [],
|
||||||
|
backgroundColor: [
|
||||||
|
'#007bff',
|
||||||
|
'#28a745',
|
||||||
|
'#ffc107',
|
||||||
|
'#dc3545',
|
||||||
|
'#17a2b8'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePerformanceChart(sessions, alerts, workorders) {
|
||||||
|
if (!this.charts.performance) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/analytics?days=7&dimension=performance');
|
||||||
|
const analyticsData = await response.json();
|
||||||
|
|
||||||
|
if (analyticsData.trend && analyticsData.trend.length > 0) {
|
||||||
|
const labels = analyticsData.trend.map(item => {
|
||||||
|
const date = new Date(item.date);
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const workorderData = analyticsData.trend.map(item => item.workorders || 0);
|
||||||
|
const alertData = analyticsData.trend.map(item => item.alerts || 0);
|
||||||
|
|
||||||
|
this.charts.performance.data.labels = labels;
|
||||||
|
this.charts.performance.data.datasets[0].data = workorderData;
|
||||||
|
this.charts.performance.data.datasets[1].data = alertData;
|
||||||
|
this.charts.performance.update();
|
||||||
|
} else {
|
||||||
|
this.charts.performance.data.labels = ['暂无数据'];
|
||||||
|
this.charts.performance.data.datasets[0].data = [0];
|
||||||
|
this.charts.performance.data.datasets[1].data = [0];
|
||||||
|
this.charts.performance.update();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取性能趋势数据失败:', error);
|
||||||
|
this.charts.performance.data.labels = ['数据加载失败'];
|
||||||
|
this.charts.performance.data.datasets[0].data = [0];
|
||||||
|
this.charts.performance.data.datasets[1].data = [0];
|
||||||
|
this.charts.performance.update();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSystemHealth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
const settings = await response.json();
|
||||||
|
|
||||||
|
const healthScore = Math.max(0, 100 - (settings.memory_usage_percent || 0) - (settings.cpu_usage_percent || 0));
|
||||||
|
const healthProgress = document.getElementById('health-progress');
|
||||||
|
const healthDot = document.getElementById('system-health-dot');
|
||||||
|
const healthText = document.getElementById('system-health-text');
|
||||||
|
|
||||||
|
if (healthProgress) {
|
||||||
|
healthProgress.style.width = `${healthScore}%`;
|
||||||
|
healthProgress.setAttribute('aria-valuenow', healthScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthDot) {
|
||||||
|
healthDot.className = 'health-dot';
|
||||||
|
if (healthScore >= 80) healthDot.classList.add('excellent');
|
||||||
|
else if (healthScore >= 60) healthDot.classList.add('good');
|
||||||
|
else if (healthScore >= 40) healthDot.classList.add('fair');
|
||||||
|
else if (healthScore >= 20) healthDot.classList.add('poor');
|
||||||
|
else healthDot.classList.add('critical');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (healthText) {
|
||||||
|
const statusText = healthScore >= 80 ? '优秀' :
|
||||||
|
healthScore >= 60 ? '良好' :
|
||||||
|
healthScore >= 40 ? '一般' :
|
||||||
|
healthScore >= 20 ? '较差' : '严重';
|
||||||
|
healthText.textContent = `${statusText} (${healthScore}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryProgress = document.getElementById('memory-progress');
|
||||||
|
if (memoryProgress && settings.memory_usage_percent !== undefined) {
|
||||||
|
memoryProgress.style.width = `${settings.memory_usage_percent}%`;
|
||||||
|
memoryProgress.setAttribute('aria-valuenow', settings.memory_usage_percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuProgress = document.getElementById('cpu-progress');
|
||||||
|
if (cpuProgress && settings.cpu_usage_percent !== undefined) {
|
||||||
|
cpuProgress.style.width = `${settings.cpu_usage_percent}%`;
|
||||||
|
cpuProgress.setAttribute('aria-valuenow', settings.cpu_usage_percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新系统健康状态失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadHealth() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/health');
|
||||||
|
const data = await response.json();
|
||||||
|
this.updateHealthDisplay(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载健康状态失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateHealthDisplay(health) {
|
||||||
|
const healthScore = health.health_score || 0;
|
||||||
|
const healthStatus = health.status || 'unknown';
|
||||||
|
|
||||||
|
const healthDot = document.getElementById('health-dot');
|
||||||
|
const healthStatusText = document.getElementById('health-status');
|
||||||
|
const systemHealthDot = document.getElementById('system-health-dot');
|
||||||
|
const systemHealthText = document.getElementById('system-health-text');
|
||||||
|
const healthProgress = document.getElementById('health-progress');
|
||||||
|
|
||||||
|
if (healthDot) {
|
||||||
|
healthDot.className = `health-dot ${healthStatus}`;
|
||||||
|
}
|
||||||
|
if (healthStatusText) {
|
||||||
|
healthStatusText.textContent = this.getHealthStatusText(healthStatus);
|
||||||
|
}
|
||||||
|
if (systemHealthDot) {
|
||||||
|
systemHealthDot.className = `health-dot ${healthStatus}`;
|
||||||
|
}
|
||||||
|
if (systemHealthText) {
|
||||||
|
systemHealthText.textContent = this.getHealthStatusText(healthStatus);
|
||||||
|
}
|
||||||
|
if (healthProgress) {
|
||||||
|
healthProgress.style.width = `${healthScore * 100}%`;
|
||||||
|
healthProgress.className = `progress-bar ${this.getHealthColor(healthScore)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const memoryUsage = health.memory_usage || 0;
|
||||||
|
const memoryProgress = document.getElementById('memory-progress');
|
||||||
|
if (memoryProgress) {
|
||||||
|
memoryProgress.style.width = `${memoryUsage}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuUsage = health.cpu_usage || 0;
|
||||||
|
const cpuProgress = document.getElementById('cpu-progress');
|
||||||
|
if (cpuProgress) {
|
||||||
|
cpuProgress.style.width = `${cpuUsage}%`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getHealthStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'excellent': '优秀',
|
||||||
|
'good': '良好',
|
||||||
|
'fair': '一般',
|
||||||
|
'poor': '较差',
|
||||||
|
'critical': '严重',
|
||||||
|
'unknown': '未知'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
},
|
||||||
|
|
||||||
|
getHealthColor(score) {
|
||||||
|
if (score >= 0.8) return 'bg-success';
|
||||||
|
if (score >= 0.6) return 'bg-info';
|
||||||
|
if (score >= 0.4) return 'bg-warning';
|
||||||
|
return 'bg-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
698
src/web/static/js/modules/feishu-sync.js
Normal file
698
src/web/static/js/modules/feishu-sync.js
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
class FeishuSyncManager {
|
||||||
|
constructor() {
|
||||||
|
this.loadConfig();
|
||||||
|
this.refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/config');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const config = data.config;
|
||||||
|
document.getElementById('appId').value = config.feishu.app_id || '';
|
||||||
|
document.getElementById('appSecret').value = '';
|
||||||
|
document.getElementById('appToken').value = config.feishu.app_token || '';
|
||||||
|
document.getElementById('tableId').value = config.feishu.table_id || '';
|
||||||
|
|
||||||
|
// 显示配置状态
|
||||||
|
const statusBadge = config.feishu.status === 'active' ?
|
||||||
|
'<span class="badge bg-success">已配置</span>' :
|
||||||
|
'<span class="badge bg-warning">未配置</span>';
|
||||||
|
|
||||||
|
// 可以在这里添加状态显示
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载配置失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveConfig() {
|
||||||
|
const config = {
|
||||||
|
app_id: document.getElementById('appId').value,
|
||||||
|
app_secret: document.getElementById('appSecret').value,
|
||||||
|
app_token: document.getElementById('appToken').value,
|
||||||
|
table_id: document.getElementById('tableId').value
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.app_id || !config.app_secret || !config.app_token || !config.table_id) {
|
||||||
|
this.showNotification('请填写完整的配置信息', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('配置保存成功', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('配置保存失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('配置保存失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection() {
|
||||||
|
try {
|
||||||
|
this.showNotification('正在测试连接...', 'info');
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/test-connection');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('飞书连接正常', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('连接失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('连接测试失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncFromFeishu() {
|
||||||
|
try {
|
||||||
|
const limit = document.getElementById('syncLimit').value;
|
||||||
|
this.showNotification('开始从飞书同步数据...', 'info');
|
||||||
|
this.showProgress(true);
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/sync-from-feishu', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
generate_ai_suggestions: false,
|
||||||
|
limit: parseInt(limit)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message, 'success');
|
||||||
|
this.addSyncLog(data.message);
|
||||||
|
this.refreshStatus();
|
||||||
|
} else {
|
||||||
|
this.showNotification('同步失败: ' + data.error, 'error');
|
||||||
|
this.addSyncLog('同步失败: ' + data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('同步失败: ' + error.message, 'error');
|
||||||
|
this.addSyncLog('同步失败: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
this.showProgress(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncWithAI() {
|
||||||
|
try {
|
||||||
|
const limit = document.getElementById('syncLimit').value;
|
||||||
|
this.showNotification('开始同步数据并生成AI建议...', 'info');
|
||||||
|
this.showProgress(true);
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/sync-from-feishu', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
generate_ai_suggestions: true,
|
||||||
|
limit: parseInt(limit)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message, 'success');
|
||||||
|
this.addSyncLog(data.message);
|
||||||
|
this.refreshStatus();
|
||||||
|
} else {
|
||||||
|
this.showNotification('同步失败: ' + data.error, 'error');
|
||||||
|
this.addSyncLog('同步失败: ' + data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('同步失败: ' + error.message, 'error');
|
||||||
|
this.addSyncLog('同步失败: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
this.showProgress(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开字段映射管理页面
|
||||||
|
openFieldMapping() {
|
||||||
|
const section = document.getElementById('fieldMappingSection');
|
||||||
|
if (section.style.display === 'none') {
|
||||||
|
section.style.display = 'block';
|
||||||
|
// 自动加载映射状态
|
||||||
|
this.loadMappingStatus();
|
||||||
|
} else {
|
||||||
|
section.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewFeishuData() {
|
||||||
|
try {
|
||||||
|
this.showNotification('正在获取飞书数据预览...', 'info');
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/preview-feishu-data');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.displayPreviewData(data.preview_data);
|
||||||
|
this.showNotification(`获取到 ${data.total_count} 条预览数据`, 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('获取预览数据失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('获取预览数据失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayPreviewData(data) {
|
||||||
|
const tbody = document.querySelector('#previewTable tbody');
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
|
||||||
|
data.forEach(item => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td>${item.record_id}</td>
|
||||||
|
<td>${item.fields['TR Number'] || '-'}</td>
|
||||||
|
<td>${item.fields['TR Description'] || '-'}</td>
|
||||||
|
<td>${item.fields['Type of problem'] || '-'}</td>
|
||||||
|
<td>${item.fields['Source'] || '-'}</td>
|
||||||
|
<td>${item.fields['TR (Priority/Status)'] || '-'}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="feishuSync.createWorkorder('${item.record_id}')">
|
||||||
|
<i class="fas fa-plus"></i> 创建工单
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('previewSection').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const status = data.status;
|
||||||
|
document.getElementById('totalLocalWorkorders').textContent = status.total_local_workorders || 0;
|
||||||
|
document.getElementById('syncedWorkorders').textContent = status.synced_workorders || 0;
|
||||||
|
document.getElementById('unsyncedWorkorders').textContent = status.unsynced_workorders || 0;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新状态失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showProgress(show) {
|
||||||
|
const progress = document.getElementById('syncProgress');
|
||||||
|
if (show) {
|
||||||
|
progress.style.display = 'block';
|
||||||
|
const bar = progress.querySelector('.progress-bar');
|
||||||
|
bar.style.width = '100%';
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
progress.style.display = 'none';
|
||||||
|
const bar = progress.querySelector('.progress-bar');
|
||||||
|
bar.style.width = '0%';
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSyncLog(message) {
|
||||||
|
const log = document.getElementById('syncLog');
|
||||||
|
const timestamp = new Date().toLocaleString();
|
||||||
|
const logEntry = document.createElement('div');
|
||||||
|
logEntry.innerHTML = `<small class="text-muted">[${timestamp}]</small> ${message}`;
|
||||||
|
|
||||||
|
if (log.querySelector('.text-muted')) {
|
||||||
|
log.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
log.appendChild(logEntry);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/config/export');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// 创建下载链接
|
||||||
|
const blob = new Blob([data.config], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `feishu_config_${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
this.showNotification('配置导出成功', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('配置导出失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('配置导出失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showImportModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('importConfigModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async importConfig() {
|
||||||
|
try {
|
||||||
|
const configJson = document.getElementById('configJson').value.trim();
|
||||||
|
|
||||||
|
if (!configJson) {
|
||||||
|
this.showNotification('请输入配置JSON数据', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/config/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ config: configJson })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('配置导入成功', 'success');
|
||||||
|
this.loadConfig();
|
||||||
|
this.refreshStatus();
|
||||||
|
|
||||||
|
// 关闭模态框
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('importConfigModal'));
|
||||||
|
modal.hide();
|
||||||
|
document.getElementById('configJson').value = '';
|
||||||
|
} else {
|
||||||
|
this.showNotification('配置导入失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('配置导入失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetConfig() {
|
||||||
|
if (confirm('确定要重置所有配置吗?此操作不可撤销!')) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/config/reset', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('配置重置成功', 'success');
|
||||||
|
this.loadConfig();
|
||||||
|
this.refreshStatus();
|
||||||
|
} else {
|
||||||
|
this.showNotification('配置重置失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('配置重置失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWorkorder(recordId) {
|
||||||
|
if (confirm(`确定要从飞书记录 ${recordId} 创建工单吗?`)) {
|
||||||
|
try {
|
||||||
|
this.showNotification('正在创建工单...', 'info');
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/create-workorder', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
record_id: recordId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message, 'success');
|
||||||
|
// 刷新工单列表(如果用户在工单页面)
|
||||||
|
if (typeof window.refreshWorkOrders === 'function') {
|
||||||
|
window.refreshWorkOrders();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showNotification('创建工单失败: ' + data.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('创建工单失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段映射管理方法
|
||||||
|
async discoverFields() {
|
||||||
|
try {
|
||||||
|
this.showNotification('正在发现字段...', 'info');
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/field-mapping/discover', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ limit: 5 })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.displayDiscoveryResults(data.discovery_report);
|
||||||
|
this.showNotification('字段发现完成', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('字段发现失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('字段发现失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayDiscoveryResults(report) {
|
||||||
|
const container = document.getElementById('fieldMappingContent');
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
// 已映射字段
|
||||||
|
if (report.mapped_fields && Object.keys(report.mapped_fields).length > 0) {
|
||||||
|
html += '<div class="mb-3"><h6 class="text-success"><i class="fas fa-check-circle"></i> 已映射字段</h6>';
|
||||||
|
for (const [feishuField, localField] of Object.entries(report.mapped_fields)) {
|
||||||
|
html += `<div class="alert alert-success py-2">
|
||||||
|
<strong>${feishuField}</strong> → <span class="badge bg-success">${localField}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未映射字段和建议
|
||||||
|
if (report.unmapped_fields && report.unmapped_fields.length > 0) {
|
||||||
|
html += '<div class="mb-3"><h6 class="text-warning"><i class="fas fa-exclamation-triangle"></i> 未映射字段</h6>';
|
||||||
|
for (const field of report.unmapped_fields) {
|
||||||
|
html += `<div class="alert alert-warning py-2">
|
||||||
|
<strong>${field}</strong>`;
|
||||||
|
|
||||||
|
const suggestions = report.suggested_mappings[field] || [];
|
||||||
|
if (suggestions.length > 0) {
|
||||||
|
html += '<div class="mt-2"><small class="text-muted">建议映射:</small>';
|
||||||
|
suggestions.slice(0, 2).forEach(suggestion => {
|
||||||
|
html += `<div class="mt-1">
|
||||||
|
<span class="badge bg-${suggestion.confidence === 'high' ? 'success' : 'warning'}">${suggestion.local_field}</span>
|
||||||
|
<small class="text-muted">(${suggestion.reason})</small>
|
||||||
|
<button class="btn btn-sm btn-outline-primary ms-2" onclick="feishuSync.applySuggestion('${field}', '${suggestion.local_field}')">应用</button>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async applySuggestion(feishuField, localField) {
|
||||||
|
if (confirm(`确定要将 "${feishuField}" 映射到 "${localField}" 吗?`)) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/field-mapping/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
feishu_field: feishuField,
|
||||||
|
local_field: localField,
|
||||||
|
priority: 3
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('映射添加成功!', 'success');
|
||||||
|
this.discoverFields(); // 重新发现字段
|
||||||
|
} else {
|
||||||
|
this.showNotification('添加映射失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMappingStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/field-mapping/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.displayMappingStatus(data.status);
|
||||||
|
} else {
|
||||||
|
this.showNotification('获取映射状态失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayMappingStatus(status) {
|
||||||
|
const container = document.getElementById('fieldMappingContent');
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
html += `<div class="row mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-primary">${status.total_mappings}</h5>
|
||||||
|
<p class="card-text">直接映射</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-info">${status.total_aliases}</h5>
|
||||||
|
<p class="card-text">别名映射</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title text-warning">${status.total_patterns}</h5>
|
||||||
|
<p class="card-text">模式匹配</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-center">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title ${status.auto_mapping_enabled ? 'text-success' : 'text-danger'}">
|
||||||
|
${status.auto_mapping_enabled ? '启用' : '禁用'}
|
||||||
|
</h5>
|
||||||
|
<p class="card-text">自动映射</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// 显示当前映射
|
||||||
|
if (status.field_mapping && Object.keys(status.field_mapping).length > 0) {
|
||||||
|
html += '<h6>当前字段映射:</h6><div class="row">';
|
||||||
|
for (const [feishuField, localField] of Object.entries(status.field_mapping)) {
|
||||||
|
html += `<div class="col-md-6 mb-2">
|
||||||
|
<div class="alert alert-info py-2">
|
||||||
|
<strong>${feishuField}</strong> → <span class="badge bg-primary">${localField}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger float-end" onclick="feishuSync.removeMapping('${feishuField}')">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMapping(feishuField) {
|
||||||
|
if (confirm(`确定要删除映射 "${feishuField}" 吗?`)) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/field-mapping/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
feishu_field: feishuField
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('映射删除成功!', 'success');
|
||||||
|
this.loadMappingStatus(); // 刷新状态
|
||||||
|
} else {
|
||||||
|
this.showNotification('删除映射失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showAddMappingModal() {
|
||||||
|
// 简单的添加映射功能
|
||||||
|
const feishuField = prompt('请输入飞书字段名:');
|
||||||
|
if (!feishuField) return;
|
||||||
|
|
||||||
|
const localField = prompt('请输入本地字段名 (如: order_id, description, category):');
|
||||||
|
if (!localField) return;
|
||||||
|
|
||||||
|
this.addFieldMapping(feishuField, localField);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFieldMapping(feishuField, localField) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/feishu-sync/field-mapping/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
feishu_field: feishuField,
|
||||||
|
local_field: localField,
|
||||||
|
priority: 3
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('映射添加成功!', 'success');
|
||||||
|
this.loadMappingStatus(); // 刷新状态
|
||||||
|
} else {
|
||||||
|
this.showNotification('添加映射失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('请求失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkPermissions() {
|
||||||
|
try {
|
||||||
|
this.showNotification('正在检查飞书权限...', 'info');
|
||||||
|
|
||||||
|
const response = await fetch('/api/feishu-sync/check-permissions');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.displayPermissionCheck(data.permission_check, data.summary);
|
||||||
|
this.showNotification('权限检查完成', 'success');
|
||||||
|
} else {
|
||||||
|
this.showNotification('权限检查失败: ' + data.error, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showNotification('权限检查失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayPermissionCheck(permissionCheck, summary) {
|
||||||
|
const container = document.getElementById('fieldMappingContent');
|
||||||
|
|
||||||
|
let html = '<div class="card"><div class="card-header"><h6 class="card-title mb-0"><i class="fas fa-shield-alt me-2"></i>飞书权限检查结果</h6></div><div class="card-body">';
|
||||||
|
|
||||||
|
// 整体状态
|
||||||
|
const statusClass = permissionCheck.success ? 'success' : 'danger';
|
||||||
|
const statusIcon = permissionCheck.success ? 'check-circle' : 'exclamation-triangle';
|
||||||
|
html += `<div class="alert alert-${statusClass}">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
整体状态: ${permissionCheck.success ? '正常' : '异常'}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// 检查项目
|
||||||
|
html += '<h6>检查项目:</h6>';
|
||||||
|
for (const [checkName, checkResult] of Object.entries(permissionCheck.checks)) {
|
||||||
|
const statusClass = checkResult.status === 'success' ? 'success' :
|
||||||
|
checkResult.status === 'warning' ? 'warning' : 'danger';
|
||||||
|
const statusIcon = checkResult.status === 'success' ? 'check-circle' :
|
||||||
|
checkResult.status === 'warning' ? 'exclamation-triangle' : 'times-circle';
|
||||||
|
|
||||||
|
html += `<div class="alert alert-${statusClass} py-2">
|
||||||
|
<i class="fas fa-${statusIcon}"></i>
|
||||||
|
<strong>${checkName}</strong>: ${checkResult.message}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修复建议
|
||||||
|
if (permissionCheck.recommendations && permissionCheck.recommendations.length > 0) {
|
||||||
|
html += '<h6>修复建议:</h6><ul class="list-group mb-3">';
|
||||||
|
permissionCheck.recommendations.forEach(rec => {
|
||||||
|
html += `<li class="list-group-item">${rec}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误信息
|
||||||
|
if (permissionCheck.errors && permissionCheck.errors.length > 0) {
|
||||||
|
html += '<h6>错误信息:</h6><ul class="list-group">';
|
||||||
|
permissionCheck.errors.forEach(error => {
|
||||||
|
html += `<li class="list-group-item list-group-item-danger">${error}</li>`;
|
||||||
|
});
|
||||||
|
html += '</ul>';
|
||||||
|
}
|
||||||
|
|
||||||
|
html += '</div></div>';
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// 显示字段映射管理区域
|
||||||
|
const section = document.getElementById('fieldMappingSection');
|
||||||
|
section.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
const container = document.getElementById('notificationContainer');
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = `alert alert-${type === 'error' ? 'danger' : type} alert-dismissible fade show`;
|
||||||
|
alert.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(alert);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.parentNode.removeChild(alert);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
699
src/web/static/js/modules/knowledge.js
Normal file
699
src/web/static/js/modules/knowledge.js
Normal file
@@ -0,0 +1,699 @@
|
|||||||
|
// 知识库管理模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
async loadKnowledge(page = 1) {
|
||||||
|
// 委托给租户列表视图
|
||||||
|
this.loadKnowledgeTenantList();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 6.1: 加载租户列表视图
|
||||||
|
async loadKnowledgeTenantList() {
|
||||||
|
this.knowledgeCurrentTenantId = null;
|
||||||
|
this.renderKnowledgeBreadcrumb(null);
|
||||||
|
|
||||||
|
// 显示租户列表容器,隐藏详情容器
|
||||||
|
const tenantListEl = document.getElementById('knowledge-tenant-list');
|
||||||
|
const tenantDetailEl = document.getElementById('knowledge-tenant-detail');
|
||||||
|
const searchBar = document.getElementById('knowledge-search-bar');
|
||||||
|
const addBtn = document.getElementById('knowledge-add-btn');
|
||||||
|
const uploadBtn = document.getElementById('knowledge-upload-btn');
|
||||||
|
|
||||||
|
tenantListEl.style.display = '';
|
||||||
|
tenantDetailEl.style.display = 'none';
|
||||||
|
if (searchBar) searchBar.style.display = 'none';
|
||||||
|
if (addBtn) addBtn.style.display = 'none';
|
||||||
|
if (uploadBtn) uploadBtn.style.display = 'none';
|
||||||
|
|
||||||
|
// 显示加载中
|
||||||
|
tenantListEl.innerHTML = '<div class="col-12 text-center py-4"><i class="fas fa-spinner fa-spin fa-2x"></i><p class="mt-2 text-muted">加载中...</p></div>';
|
||||||
|
|
||||||
|
// 加载全局统计
|
||||||
|
this.loadKnowledgeStats(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/knowledge/tenants');
|
||||||
|
const tenants = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(tenants) || tenants.length === 0) {
|
||||||
|
tenantListEl.innerHTML = '<div class="col-12 text-center py-4"><i class="fas fa-database fa-2x text-muted"></i><p class="mt-2 text-muted">暂无知识库数据</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardsHtml = tenants.map(t => `
|
||||||
|
<div class="col-md-4 col-sm-6 mb-3">
|
||||||
|
<div class="card h-100 shadow-sm" style="cursor:pointer" onclick="dashboard.openTenantDetail('${t.tenant_id}')">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title"><i class="fas fa-building me-2"></i>${t.tenant_id}</h6>
|
||||||
|
<div class="d-flex justify-content-between mt-3">
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">知识条目</small>
|
||||||
|
<h5 class="mb-0">${t.entry_count}</h5>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">已验证</small>
|
||||||
|
<h5 class="mb-0 text-success">${t.verified_count}</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress mt-3" style="height: 6px;">
|
||||||
|
<div class="progress-bar bg-success" style="width: ${t.entry_count > 0 ? Math.round(t.verified_count / t.entry_count * 100) : 0}%"></div>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">${t.entry_count > 0 ? Math.round(t.verified_count / t.entry_count * 100) : 0}% 已验证</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
tenantListEl.innerHTML = cardsHtml;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载租户列表失败:', error);
|
||||||
|
tenantListEl.innerHTML = '<div class="col-12 text-center py-4 text-danger"><i class="fas fa-exclamation-triangle"></i> 加载失败</div>';
|
||||||
|
this.showNotification('加载租户列表失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 6.2: 刷新按钮
|
||||||
|
refreshKnowledge() {
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, this.paginationConfig.currentKnowledgePage);
|
||||||
|
} else {
|
||||||
|
this.loadKnowledgeTenantList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 加载租户详情视图
|
||||||
|
async loadKnowledgeTenantDetail(tenantId, page = 1) {
|
||||||
|
this.knowledgeCurrentTenantId = tenantId;
|
||||||
|
this.paginationConfig.currentKnowledgePage = page;
|
||||||
|
this.renderKnowledgeBreadcrumb(tenantId);
|
||||||
|
|
||||||
|
// 隐藏租户列表,显示详情容器
|
||||||
|
const tenantListEl = document.getElementById('knowledge-tenant-list');
|
||||||
|
const tenantDetailEl = document.getElementById('knowledge-tenant-detail');
|
||||||
|
const searchBar = document.getElementById('knowledge-search-bar');
|
||||||
|
const addBtn = document.getElementById('knowledge-add-btn');
|
||||||
|
const uploadBtn = document.getElementById('knowledge-upload-btn');
|
||||||
|
|
||||||
|
tenantListEl.style.display = 'none';
|
||||||
|
tenantDetailEl.style.display = '';
|
||||||
|
if (searchBar) searchBar.style.display = '';
|
||||||
|
if (addBtn) addBtn.style.display = '';
|
||||||
|
if (uploadBtn) uploadBtn.style.display = '';
|
||||||
|
|
||||||
|
// 清空搜索框
|
||||||
|
const searchInput = document.getElementById('knowledge-search');
|
||||||
|
if (searchInput) searchInput.value = '';
|
||||||
|
|
||||||
|
// 加载租户级统计
|
||||||
|
this.loadKnowledgeStats(tenantId);
|
||||||
|
|
||||||
|
const listEl = document.getElementById('knowledge-list');
|
||||||
|
listEl.innerHTML = '<div class="loading-spinner"><i class="fas fa-spinner fa-spin"></i></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pageSize = this.getPageSize('knowledge-pagination');
|
||||||
|
const categoryFilter = document.getElementById('knowledge-category-filter')?.value || '';
|
||||||
|
const verifiedFilter = document.getElementById('knowledge-verified-filter')?.value || '';
|
||||||
|
|
||||||
|
let url = `/api/knowledge?tenant_id=${encodeURIComponent(tenantId)}&page=${page}&per_page=${pageSize}`;
|
||||||
|
if (categoryFilter) url += `&category=${encodeURIComponent(categoryFilter)}`;
|
||||||
|
if (verifiedFilter) url += `&verified=${encodeURIComponent(verifiedFilter)}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.knowledge) {
|
||||||
|
this.updateKnowledgeDisplay(data.knowledge);
|
||||||
|
this.updateKnowledgeTenantPagination(data);
|
||||||
|
} else {
|
||||||
|
this.updateKnowledgeDisplay(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载租户详情失败:', error);
|
||||||
|
listEl.innerHTML = '<div class="text-center py-4 text-danger"><i class="fas fa-exclamation-triangle"></i> 加载失败</div>';
|
||||||
|
this.showNotification('加载租户详情失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 填充分类筛选下拉框(从统计数据获取完整分类列表)
|
||||||
|
populateCategoryFilter(categories) {
|
||||||
|
const select = document.getElementById('knowledge-category-filter');
|
||||||
|
if (!select) return;
|
||||||
|
const currentValue = select.value;
|
||||||
|
const sorted = categories.slice().sort();
|
||||||
|
const existingOptions = Array.from(select.options).slice(1).map(o => o.value);
|
||||||
|
if (JSON.stringify(sorted) !== JSON.stringify(existingOptions)) {
|
||||||
|
select.innerHTML = '<option value="">全部分类</option>' +
|
||||||
|
sorted.map(c => `<option value="${c}"${c === currentValue ? ' selected' : ''}>${c}</option>`).join('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 从租户卡片进入详情(重置筛选条件)
|
||||||
|
openTenantDetail(tenantId) {
|
||||||
|
const categoryFilterEl = document.getElementById('knowledge-category-filter');
|
||||||
|
const verifiedFilterEl = document.getElementById('knowledge-verified-filter');
|
||||||
|
if (categoryFilterEl) categoryFilterEl.value = '';
|
||||||
|
if (verifiedFilterEl) verifiedFilterEl.value = '';
|
||||||
|
this.loadKnowledgeTenantDetail(tenantId, 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 应用筛选条件
|
||||||
|
applyKnowledgeFilters() {
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.1: 租户详情分页
|
||||||
|
updateKnowledgeTenantPagination(data) {
|
||||||
|
this.createPaginationComponent(data, 'knowledge-pagination', 'loadKnowledgeTenantDetailPage', '条知识');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.2: 面包屑导航
|
||||||
|
renderKnowledgeBreadcrumb(tenantId) {
|
||||||
|
const breadcrumbEl = document.getElementById('knowledge-breadcrumb');
|
||||||
|
if (!breadcrumbEl) return;
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
breadcrumbEl.innerHTML = '<h5 class="mb-0"><i class="fas fa-database me-2"></i>知识库</h5>';
|
||||||
|
} else {
|
||||||
|
breadcrumbEl.innerHTML = `
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb mb-0">
|
||||||
|
<li class="breadcrumb-item"><a href="#" onclick="dashboard.loadKnowledgeTenantList(); return false;"><i class="fas fa-database me-1"></i>知识库</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">${tenantId}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.3 / 8.2: 刷新当前知识库视图的辅助方法
|
||||||
|
async refreshKnowledgeCurrentView() {
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
await this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, this.paginationConfig.currentKnowledgePage);
|
||||||
|
} else {
|
||||||
|
await this.loadKnowledgeTenantList();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 8.2: 加载知识库统计(支持租户级别)
|
||||||
|
async loadKnowledgeStats(tenantId) {
|
||||||
|
try {
|
||||||
|
let url = '/api/knowledge/stats';
|
||||||
|
if (tenantId) {
|
||||||
|
url += `?tenant_id=${encodeURIComponent(tenantId)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
const knowledge = await response.json();
|
||||||
|
|
||||||
|
const totalEl = document.getElementById('knowledge-total');
|
||||||
|
const activeEl = document.getElementById('knowledge-active');
|
||||||
|
const confidenceBarEl = document.getElementById('knowledge-confidence-bar');
|
||||||
|
|
||||||
|
if (totalEl) totalEl.textContent = knowledge.total_entries || 0;
|
||||||
|
if (activeEl) activeEl.textContent = knowledge.active_entries || 0;
|
||||||
|
|
||||||
|
const confidencePercent = Math.round((knowledge.average_confidence || 0) * 100);
|
||||||
|
if (confidenceBarEl) {
|
||||||
|
confidenceBarEl.style.width = `${confidencePercent}%`;
|
||||||
|
confidenceBarEl.setAttribute('aria-valuenow', confidencePercent);
|
||||||
|
confidenceBarEl.textContent = `${confidencePercent}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task 7.1: 在租户详情视图中,用统计数据填充分类筛选下拉框
|
||||||
|
if (tenantId && knowledge.category_distribution) {
|
||||||
|
this.populateCategoryFilter(Object.keys(knowledge.category_distribution));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载知识库统计失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateKnowledgeDisplay(knowledge) {
|
||||||
|
const container = document.getElementById('knowledge-list');
|
||||||
|
|
||||||
|
if (knowledge.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><i class="fas fa-database"></i><p>暂无知识条目</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加知识库列表的批量操作头部
|
||||||
|
const headerHtml = `
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<input type="checkbox" id="select-all-knowledge" class="form-check-input me-2" onchange="dashboard.toggleSelectAllKnowledge()">
|
||||||
|
<label for="select-all-knowledge" class="form-check-label">全选</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-success" id="batch-verify-knowledge" onclick="dashboard.batchVerifyKnowledge()" disabled>
|
||||||
|
<i class="fas fa-check-circle me-1"></i>批量验证
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-warning" id="batch-unverify-knowledge" onclick="dashboard.batchUnverifyKnowledge()" disabled>
|
||||||
|
<i class="fas fa-times-circle me-1"></i>批量取消验证
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" id="batch-delete-knowledge" onclick="dashboard.batchDeleteKnowledge()" disabled>
|
||||||
|
<i class="fas fa-trash me-1"></i>批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const knowledgeHtml = knowledge.map(item => `
|
||||||
|
<div class="knowledge-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<input type="checkbox" class="form-check-input me-2 knowledge-checkbox" value="${item.id}" onchange="dashboard.updateBatchDeleteKnowledgeButton()">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">${item.question}</h6>
|
||||||
|
<p class="text-muted mb-2">${item.answer}</p>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<small class="text-muted">分类: ${item.category}</small>
|
||||||
|
<small class="text-muted">置信度: ${Math.round(item.confidence_score * 100)}%</small>
|
||||||
|
<small class="text-muted">使用次数: ${item.usage_count || 0}</small>
|
||||||
|
<span class="badge ${item.is_verified ? 'bg-success' : 'bg-warning'}">
|
||||||
|
${item.is_verified ? '已验证' : '未验证'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
${item.is_verified ?
|
||||||
|
`<button class="btn btn-sm btn-outline-warning" onclick="dashboard.unverifyKnowledge(${item.id})" title="取消验证">
|
||||||
|
<i class="fas fa-times-circle"></i>
|
||||||
|
<span class="d-none d-md-inline ms-1">取消验证</span>
|
||||||
|
</button>` :
|
||||||
|
`<button class="btn btn-sm btn-outline-success" onclick="dashboard.verifyKnowledge(${item.id})" title="验证">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span class="d-none d-md-inline ms-1">验证</span>
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="dashboard.deleteKnowledge(${item.id})" title="删除">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
<span class="d-none d-md-inline ms-1">删除</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = headerHtml + knowledgeHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateKnowledgePagination(data) {
|
||||||
|
this.createPaginationComponent(data, 'knowledge-pagination', 'loadKnowledge', '条知识');
|
||||||
|
},
|
||||||
|
|
||||||
|
async searchKnowledge() {
|
||||||
|
const query = document.getElementById('knowledge-search').value.trim();
|
||||||
|
if (!query) {
|
||||||
|
// Task 8.1: 清空搜索时恢复当前租户的分页列表
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId);
|
||||||
|
} else {
|
||||||
|
this.loadKnowledge();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Task 8.1: 搜索时附加 tenant_id 参数
|
||||||
|
let url = `/api/knowledge/search?q=${encodeURIComponent(query)}`;
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
url += `&tenant_id=${encodeURIComponent(this.knowledgeCurrentTenantId)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url);
|
||||||
|
const results = await response.json();
|
||||||
|
this.updateKnowledgeDisplay(results);
|
||||||
|
// 搜索结果不显示分页
|
||||||
|
const paginationEl = document.getElementById('knowledge-pagination');
|
||||||
|
if (paginationEl) paginationEl.innerHTML = '';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索知识库失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async addKnowledge() {
|
||||||
|
const question = document.getElementById('knowledge-question').value.trim();
|
||||||
|
const answer = document.getElementById('knowledge-answer').value.trim();
|
||||||
|
const category = document.getElementById('knowledge-category').value;
|
||||||
|
const confidence = parseFloat(document.getElementById('knowledge-confidence').value);
|
||||||
|
|
||||||
|
if (!question || !answer) {
|
||||||
|
this.showNotification('请填写完整信息', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Task 7.3: 添加知识条目时自动设置 tenant_id
|
||||||
|
const body = {
|
||||||
|
question,
|
||||||
|
answer,
|
||||||
|
category,
|
||||||
|
confidence_score: confidence
|
||||||
|
};
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
body.tenant_id = this.knowledgeCurrentTenantId;
|
||||||
|
}
|
||||||
|
const response = await fetch('/api/knowledge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('知识添加成功', 'success');
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('addKnowledgeModal')).hide();
|
||||||
|
document.getElementById('knowledge-form').reset();
|
||||||
|
// Task 7.3: 刷新当前视图
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
this.loadKnowledgeTenantDetail(this.knowledgeCurrentTenantId, this.paginationConfig.currentKnowledgePage);
|
||||||
|
} else {
|
||||||
|
this.loadKnowledgeTenantList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showNotification('添加知识失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加知识失败:', error);
|
||||||
|
this.showNotification('添加知识失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async uploadFile() {
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const processMethod = document.getElementById('process-method').value;
|
||||||
|
const category = document.getElementById('file-category').value;
|
||||||
|
const confidence = parseFloat(document.getElementById('file-confidence').value);
|
||||||
|
const manualQuestion = document.getElementById('manual-question').value.trim();
|
||||||
|
|
||||||
|
if (!fileInput.files[0]) {
|
||||||
|
this.showNotification('请选择文件', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processMethod === 'manual' && !manualQuestion) {
|
||||||
|
this.showNotification('请指定问题', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示进度条
|
||||||
|
const progressDiv = document.getElementById('upload-progress');
|
||||||
|
const progressBar = progressDiv.querySelector('.progress-bar');
|
||||||
|
const statusText = document.getElementById('upload-status');
|
||||||
|
|
||||||
|
progressDiv.style.display = 'block';
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
statusText.textContent = '正在上传文件...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
formData.append('process_method', processMethod);
|
||||||
|
formData.append('category', category);
|
||||||
|
formData.append('confidence_score', confidence);
|
||||||
|
if (manualQuestion) {
|
||||||
|
formData.append('manual_question', manualQuestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟进度更新
|
||||||
|
let progress = 0;
|
||||||
|
const progressInterval = setInterval(() => {
|
||||||
|
progress += Math.random() * 20;
|
||||||
|
if (progress > 90) progress = 90;
|
||||||
|
progressBar.style.width = progress + '%';
|
||||||
|
|
||||||
|
if (progress < 30) {
|
||||||
|
statusText.textContent = '正在上传文件...';
|
||||||
|
} else if (progress < 60) {
|
||||||
|
statusText.textContent = '正在解析文件内容...';
|
||||||
|
} else if (progress < 90) {
|
||||||
|
statusText.textContent = '正在生成知识库...';
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
const response = await fetch('/api/knowledge/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
statusText.textContent = '处理完成!';
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
progressDiv.style.display = 'none';
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(`文件处理成功,生成了 ${data.knowledge_count || 0} 条知识`, 'success');
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('uploadFileModal')).hide();
|
||||||
|
document.getElementById('file-upload-form').reset();
|
||||||
|
this.refreshKnowledgeCurrentView();
|
||||||
|
} else {
|
||||||
|
this.showNotification(data.error || '文件处理失败', 'error');
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件上传失败:', error);
|
||||||
|
progressDiv.style.display = 'none';
|
||||||
|
this.showNotification('文件上传失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async verifyKnowledge(knowledgeId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/knowledge/verify/${knowledgeId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
verified_by: 'admin'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('知识库验证成功', 'success');
|
||||||
|
this.refreshKnowledgeCurrentView();
|
||||||
|
} else {
|
||||||
|
this.showNotification('知识库验证失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('验证知识库失败:', error);
|
||||||
|
this.showNotification('验证知识库失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async unverifyKnowledge(knowledgeId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/knowledge/unverify/${knowledgeId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('取消验证成功', 'success');
|
||||||
|
this.refreshKnowledgeCurrentView();
|
||||||
|
} else {
|
||||||
|
this.showNotification('取消验证失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('取消验证失败:', error);
|
||||||
|
this.showNotification('取消验证失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteKnowledge(knowledgeId) {
|
||||||
|
if (!confirm('确定要删除这条知识库条目吗?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/knowledge/delete/${knowledgeId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('知识库条目已删除', 'success');
|
||||||
|
|
||||||
|
// Task 7.3: 删除后检查是否所有条目已删除,若是则返回租户列表
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
const checkResp = await fetch(`/api/knowledge?tenant_id=${encodeURIComponent(this.knowledgeCurrentTenantId)}&page=1&per_page=1`);
|
||||||
|
const checkData = await checkResp.json();
|
||||||
|
if (checkData.total === 0) {
|
||||||
|
this.showNotification('该租户下已无知识条目,返回租户列表', 'info');
|
||||||
|
this.loadKnowledgeTenantList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshKnowledgeCurrentView();
|
||||||
|
} else {
|
||||||
|
this.showNotification('知识库删除失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除知识库失败:', error);
|
||||||
|
this.showNotification('删除知识库失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 知识库批量删除功能
|
||||||
|
toggleSelectAllKnowledge() {
|
||||||
|
const selectAllCheckbox = document.getElementById('select-all-knowledge');
|
||||||
|
const knowledgeCheckboxes = document.querySelectorAll('.knowledge-checkbox');
|
||||||
|
|
||||||
|
knowledgeCheckboxes.forEach(checkbox => {
|
||||||
|
checkbox.checked = selectAllCheckbox.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateBatchDeleteKnowledgeButton();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBatchDeleteKnowledgeButton() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('.knowledge-checkbox:checked');
|
||||||
|
const batchDeleteBtn = document.getElementById('batch-delete-knowledge');
|
||||||
|
const batchVerifyBtn = document.getElementById('batch-verify-knowledge');
|
||||||
|
const batchUnverifyBtn = document.getElementById('batch-unverify-knowledge');
|
||||||
|
const hasSelection = selectedCheckboxes.length > 0;
|
||||||
|
|
||||||
|
if (batchDeleteBtn) {
|
||||||
|
batchDeleteBtn.disabled = !hasSelection;
|
||||||
|
batchDeleteBtn.innerHTML = hasSelection
|
||||||
|
? `<i class="fas fa-trash me-1"></i>批量删除 (${selectedCheckboxes.length})`
|
||||||
|
: '<i class="fas fa-trash me-1"></i>批量删除';
|
||||||
|
}
|
||||||
|
if (batchVerifyBtn) {
|
||||||
|
batchVerifyBtn.disabled = !hasSelection;
|
||||||
|
}
|
||||||
|
if (batchUnverifyBtn) {
|
||||||
|
batchUnverifyBtn.disabled = !hasSelection;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchDeleteKnowledge() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('.knowledge-checkbox:checked');
|
||||||
|
const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
this.showNotification('请选择要删除的知识库条目', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`确定要删除选中的 ${selectedIds.length} 个知识库条目吗?此操作不可撤销。`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch-delete/knowledge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ids: selectedIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message, 'success');
|
||||||
|
|
||||||
|
// 清除缓存并强制刷新
|
||||||
|
this.cache.delete('knowledge');
|
||||||
|
|
||||||
|
// Task 7.3: 删除后检查是否所有条目已删除,若是则返回租户列表
|
||||||
|
if (this.knowledgeCurrentTenantId) {
|
||||||
|
const checkResp = await fetch(`/api/knowledge?tenant_id=${encodeURIComponent(this.knowledgeCurrentTenantId)}&page=1&per_page=1`);
|
||||||
|
const checkData = await checkResp.json();
|
||||||
|
if (checkData.total === 0) {
|
||||||
|
this.showNotification('该租户下已无知识条目,返回租户列表', 'info');
|
||||||
|
this.loadKnowledgeTenantList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.refreshKnowledgeCurrentView();
|
||||||
|
|
||||||
|
// 重置批量操作按钮状态
|
||||||
|
this.updateBatchDeleteKnowledgeButton();
|
||||||
|
} else {
|
||||||
|
this.showNotification(data.error || '批量删除失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量删除知识库条目失败:', error);
|
||||||
|
this.showNotification('批量删除知识库条目失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.3: 批量验证知识条目
|
||||||
|
async batchVerifyKnowledge() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('.knowledge-checkbox:checked');
|
||||||
|
const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
this.showNotification('请选择要验证的知识库条目', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/knowledge/batch_verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: selectedIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message || '批量验证成功', 'success');
|
||||||
|
await this.refreshKnowledgeCurrentView();
|
||||||
|
this.updateBatchDeleteKnowledgeButton();
|
||||||
|
} else {
|
||||||
|
this.showNotification(data.error || '批量验证失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量验证知识库条目失败:', error);
|
||||||
|
this.showNotification('批量验证知识库条目失败', 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Task 7.3: 批量取消验证知识条目
|
||||||
|
async batchUnverifyKnowledge() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('.knowledge-checkbox:checked');
|
||||||
|
const selectedIds = Array.from(selectedCheckboxes).map(cb => parseInt(cb.value));
|
||||||
|
|
||||||
|
if (selectedIds.length === 0) {
|
||||||
|
this.showNotification('请选择要取消验证的知识库条目', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/knowledge/batch_unverify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: selectedIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message || '批量取消验证成功', 'success');
|
||||||
|
await this.refreshKnowledgeCurrentView();
|
||||||
|
this.updateBatchDeleteKnowledgeButton();
|
||||||
|
} else {
|
||||||
|
this.showNotification(data.error || '批量取消验证失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量取消验证知识库条目失败:', error);
|
||||||
|
this.showNotification('批量取消验证知识库条目失败', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
455
src/web/static/js/modules/monitoring.js
Normal file
455
src/web/static/js/modules/monitoring.js
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
// 监控模块(Token监控 + AI监控)
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
async loadTokenMonitor() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/token-monitor/stats');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.updateTokenStats(data);
|
||||||
|
this.loadTokenChart();
|
||||||
|
this.loadTokenRecords();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '加载Token监控数据失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载Token监控数据失败:', error);
|
||||||
|
this.showNotification('加载Token监控数据失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTokenStats(stats) {
|
||||||
|
document.getElementById('token-today').textContent = stats.today_tokens || 0;
|
||||||
|
document.getElementById('token-month').textContent = stats.month_tokens || 0;
|
||||||
|
document.getElementById('token-cost').textContent = `¥${stats.total_cost || 0}`;
|
||||||
|
document.getElementById('token-budget').textContent = `¥${stats.budget_limit || 1000}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTokenChart() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/token-monitor/chart');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.renderTokenChart(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载Token图表失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderTokenChart(data) {
|
||||||
|
const ctx = document.getElementById('tokenChart').getContext('2d');
|
||||||
|
|
||||||
|
if (this.charts.tokenChart) {
|
||||||
|
this.charts.tokenChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.charts.tokenChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: data.labels || [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Token消耗',
|
||||||
|
data: data.tokens || [],
|
||||||
|
borderColor: '#007bff',
|
||||||
|
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||||
|
tension: 0.4
|
||||||
|
}, {
|
||||||
|
label: '成本',
|
||||||
|
data: data.costs || [],
|
||||||
|
borderColor: '#28a745',
|
||||||
|
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||||
|
tension: 0.4,
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Token数量'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '成本 (元)'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadTokenRecords() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/token-monitor/records');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.renderTokenRecords(data.records || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载Token记录失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderTokenRecords(records) {
|
||||||
|
const tbody = document.getElementById('token-records');
|
||||||
|
|
||||||
|
if (!records || records.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-center text-muted">暂无记录</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = records.map(record => `
|
||||||
|
<tr>
|
||||||
|
<td>${new Date(record.timestamp).toLocaleString()}</td>
|
||||||
|
<td>${record.user_id || '匿名'}</td>
|
||||||
|
<td>${record.model || 'qwen-turbo'}</td>
|
||||||
|
<td>${record.input_tokens || 0}</td>
|
||||||
|
<td>${record.output_tokens || 0}</td>
|
||||||
|
<td>${record.total_tokens || 0}</td>
|
||||||
|
<td>¥${record.cost || 0}</td>
|
||||||
|
<td>${record.response_time || 0}ms</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveTokenSettings() {
|
||||||
|
const dailyThreshold = document.getElementById('daily-threshold').value;
|
||||||
|
const monthlyBudget = document.getElementById('monthly-budget').value;
|
||||||
|
const enableAlerts = document.getElementById('enable-alerts').checked;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/token-monitor/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
daily_threshold: parseInt(dailyThreshold),
|
||||||
|
monthly_budget: parseFloat(monthlyBudget),
|
||||||
|
enable_alerts: enableAlerts
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('Token设置已保存', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '保存Token设置失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存Token设置失败:', error);
|
||||||
|
this.showNotification('保存Token设置失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateTokenChart(period) {
|
||||||
|
// 更新按钮状态
|
||||||
|
document.querySelectorAll('#tokenChart').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
event.target.classList.add('active');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/token-monitor/chart?period=${period}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.renderTokenChart(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新Token图表失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async exportTokenData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/token-monitor/export');
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'token_usage_data.xlsx';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
this.showNotification('Token数据导出成功', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('导出Token数据失败:', error);
|
||||||
|
this.showNotification('导出Token数据失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshTokenData() {
|
||||||
|
await this.loadTokenMonitor();
|
||||||
|
this.showNotification('Token数据已刷新', 'success');
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI监控
|
||||||
|
async loadAIMonitor() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai-monitor/stats');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.updateAIStats(data);
|
||||||
|
this.loadModelComparisonChart();
|
||||||
|
this.loadErrorDistributionChart();
|
||||||
|
this.loadErrorLog();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '加载AI监控数据失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载AI监控数据失败:', error);
|
||||||
|
this.showNotification('加载AI监控数据失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateAIStats(stats) {
|
||||||
|
document.getElementById('ai-success-rate').textContent = `${stats.success_rate || 0}%`;
|
||||||
|
document.getElementById('ai-response-time').textContent = `${stats.avg_response_time || 0}ms`;
|
||||||
|
document.getElementById('ai-error-rate').textContent = `${stats.error_rate || 0}%`;
|
||||||
|
document.getElementById('ai-total-calls').textContent = stats.total_calls || 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadModelComparisonChart() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai-monitor/model-comparison');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.renderModelComparisonChart(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载模型对比图表失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderModelComparisonChart(data) {
|
||||||
|
const ctx = document.getElementById('modelComparisonChart').getContext('2d');
|
||||||
|
|
||||||
|
if (this.charts.modelComparisonChart) {
|
||||||
|
this.charts.modelComparisonChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.charts.modelComparisonChart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.models || [],
|
||||||
|
datasets: [{
|
||||||
|
label: '成功率 (%)',
|
||||||
|
data: data.success_rates || [],
|
||||||
|
backgroundColor: 'rgba(40, 167, 69, 0.8)'
|
||||||
|
}, {
|
||||||
|
label: '平均响应时间 (ms)',
|
||||||
|
data: data.response_times || [],
|
||||||
|
backgroundColor: 'rgba(255, 193, 7, 0.8)',
|
||||||
|
yAxisID: 'y1'
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'left',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '成功率 (%)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y1: {
|
||||||
|
type: 'linear',
|
||||||
|
display: true,
|
||||||
|
position: 'right',
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: '响应时间 (ms)'
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadErrorDistributionChart() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai-monitor/error-distribution');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.renderErrorDistributionChart(data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载错误分布图表失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderErrorDistributionChart(data) {
|
||||||
|
const ctx = document.getElementById('errorDistributionChart').getContext('2d');
|
||||||
|
|
||||||
|
if (this.charts.errorDistributionChart) {
|
||||||
|
this.charts.errorDistributionChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.charts.errorDistributionChart = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: data.error_types || [],
|
||||||
|
datasets: [{
|
||||||
|
data: data.counts || [],
|
||||||
|
backgroundColor: [
|
||||||
|
'#dc3545',
|
||||||
|
'#fd7e14',
|
||||||
|
'#ffc107',
|
||||||
|
'#17a2b8',
|
||||||
|
'#6c757d'
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadErrorLog() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai-monitor/error-log');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.renderErrorLog(data.errors || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载错误日志失败:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
renderErrorLog(errors) {
|
||||||
|
const tbody = document.getElementById('error-log');
|
||||||
|
|
||||||
|
if (!errors || errors.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="text-center text-muted">暂无错误记录</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = errors.map(error => `
|
||||||
|
<tr>
|
||||||
|
<td>${new Date(error.timestamp).toLocaleString()}</td>
|
||||||
|
<td><span class="badge bg-danger">${error.error_type || '未知'}</span></td>
|
||||||
|
<td>${error.error_message || ''}</td>
|
||||||
|
<td>${error.model || 'qwen-turbo'}</td>
|
||||||
|
<td>${error.user_id || '匿名'}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" onclick="dashboard.viewErrorDetail(${error.id})">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
tbody.innerHTML = html;
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshErrorLog() {
|
||||||
|
await this.loadErrorLog();
|
||||||
|
this.showNotification('错误日志已刷新', 'success');
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearErrorLog() {
|
||||||
|
if (!confirm('确定要清空错误日志吗?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai-monitor/error-log', { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('错误日志已清空', 'success');
|
||||||
|
await this.loadErrorLog();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || '清空错误日志失败');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('清空错误日志失败:', error);
|
||||||
|
this.showNotification('清空错误日志失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async viewErrorDetail(convId) {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('errorDetailModal'));
|
||||||
|
const body = document.getElementById('errorDetailBody');
|
||||||
|
body.innerHTML = '<div class="text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>加载中...</div>';
|
||||||
|
modal.show();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/ai-monitor/error-log/${convId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const d = data.detail;
|
||||||
|
body.innerHTML = `
|
||||||
|
<table class="table table-bordered mb-0">
|
||||||
|
<tr><th style="width:120px">记录ID</th><td>${d.id}</td></tr>
|
||||||
|
<tr><th>时间</th><td>${d.timestamp ? new Date(d.timestamp).toLocaleString() : '-'}</td></tr>
|
||||||
|
<tr><th>分类</th><td>${d.category || '-'}</td></tr>
|
||||||
|
<tr><th>来源</th><td>${d.source || '-'}</td></tr>
|
||||||
|
<tr><th>置信度</th><td>${d.confidence_score != null ? d.confidence_score : '-'}</td></tr>
|
||||||
|
<tr><th>响应时间</th><td>${d.response_time != null ? d.response_time + ' ms' : '-'}</td></tr>
|
||||||
|
<tr><th>用户消息</th><td><pre class="mb-0" style="white-space:pre-wrap">${this.escapeHtml(d.user_message || '-')}</pre></td></tr>
|
||||||
|
<tr><th>助手回复</th><td><pre class="mb-0" style="white-space:pre-wrap">${this.escapeHtml(d.assistant_response || '-')}</pre></td></tr>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
body.innerHTML = `<div class="alert alert-danger">${data.error || '加载失败'}</div>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
body.innerHTML = `<div class="alert alert-danger">请求失败: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
});
|
||||||
1388
src/web/static/js/modules/system.js
Normal file
1388
src/web/static/js/modules/system.js
Normal file
File diff suppressed because it is too large
Load Diff
165
src/web/static/js/modules/tenants.js
Normal file
165
src/web/static/js/modules/tenants.js
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// 租户管理模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
async loadTenantList() {
|
||||||
|
const container = document.getElementById('tenant-list');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<div class="text-center py-4"><i class="fas fa-spinner fa-spin fa-2x"></i></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tenants');
|
||||||
|
const tenants = await response.json();
|
||||||
|
|
||||||
|
if (!Array.isArray(tenants) || tenants.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-center py-4 text-muted">暂无租户,请点击"新建租户"创建</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = tenants.map(t => {
|
||||||
|
const feishuCfg = t.config?.feishu || {};
|
||||||
|
const groupCount = (feishuCfg.chat_groups || []).length;
|
||||||
|
const hasFeishu = feishuCfg.app_id || groupCount > 0;
|
||||||
|
return `
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body d-flex justify-content-between align-items-center py-2">
|
||||||
|
<div>
|
||||||
|
<strong>${t.name}</strong>
|
||||||
|
<span class="text-muted ms-2">(${t.tenant_id})</span>
|
||||||
|
${t.description ? `<br><small class="text-muted">${t.description}</small>` : ''}
|
||||||
|
${!t.is_active ? '<span class="badge bg-secondary ms-2">已禁用</span>' : ''}
|
||||||
|
${hasFeishu ? `<span class="badge bg-info ms-2"><i class="fas fa-robot me-1"></i>飞书${groupCount > 0 ? ` (${groupCount}群)` : ''}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-primary" onclick="dashboard.showEditTenantModal('${t.tenant_id}', '${(t.name || '').replace(/'/g, "\\'")}', '${(t.description || '').replace(/'/g, "\\'")}')">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</button>
|
||||||
|
${t.tenant_id !== 'default' ? `
|
||||||
|
<button class="btn btn-outline-danger" onclick="dashboard.deleteTenant('${t.tenant_id}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载租户列表失败:', error);
|
||||||
|
container.innerHTML = '<div class="text-center py-4 text-danger">加载失败</div>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showCreateTenantModal() {
|
||||||
|
document.getElementById('tenantModalTitle').textContent = '新建租户';
|
||||||
|
document.getElementById('tenant-edit-id').value = '';
|
||||||
|
document.getElementById('tenant-id-input').value = '';
|
||||||
|
document.getElementById('tenant-id-input').disabled = false;
|
||||||
|
document.getElementById('tenant-id-group').style.display = '';
|
||||||
|
document.getElementById('tenant-name-input').value = '';
|
||||||
|
document.getElementById('tenant-desc-input').value = '';
|
||||||
|
document.getElementById('tenant-feishu-appid').value = '';
|
||||||
|
document.getElementById('tenant-feishu-appsecret').value = '';
|
||||||
|
document.getElementById('tenant-feishu-chatgroups').value = '';
|
||||||
|
new bootstrap.Modal(document.getElementById('tenantModal')).show();
|
||||||
|
},
|
||||||
|
|
||||||
|
async showEditTenantModal(tenantId, name, description) {
|
||||||
|
document.getElementById('tenantModalTitle').textContent = '编辑租户';
|
||||||
|
document.getElementById('tenant-edit-id').value = tenantId;
|
||||||
|
document.getElementById('tenant-id-input').value = tenantId;
|
||||||
|
document.getElementById('tenant-id-input').disabled = true;
|
||||||
|
document.getElementById('tenant-name-input').value = name;
|
||||||
|
document.getElementById('tenant-desc-input').value = description;
|
||||||
|
|
||||||
|
// 加载租户的飞书配置
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/tenants');
|
||||||
|
const tenants = await resp.json();
|
||||||
|
const tenant = tenants.find(t => t.tenant_id === tenantId);
|
||||||
|
const feishuCfg = tenant?.config?.feishu || {};
|
||||||
|
document.getElementById('tenant-feishu-appid').value = feishuCfg.app_id || '';
|
||||||
|
document.getElementById('tenant-feishu-appsecret').value = feishuCfg.app_secret || '';
|
||||||
|
document.getElementById('tenant-feishu-chatgroups').value = (feishuCfg.chat_groups || []).join('\n');
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('tenant-feishu-appid').value = '';
|
||||||
|
document.getElementById('tenant-feishu-appsecret').value = '';
|
||||||
|
document.getElementById('tenant-feishu-chatgroups').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
new bootstrap.Modal(document.getElementById('tenantModal')).show();
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveTenant() {
|
||||||
|
const editId = document.getElementById('tenant-edit-id').value;
|
||||||
|
const tenantId = document.getElementById('tenant-id-input').value.trim();
|
||||||
|
const name = document.getElementById('tenant-name-input').value.trim();
|
||||||
|
const description = document.getElementById('tenant-desc-input').value.trim();
|
||||||
|
|
||||||
|
// 飞书配置
|
||||||
|
const feishuAppId = document.getElementById('tenant-feishu-appid').value.trim();
|
||||||
|
const feishuAppSecret = document.getElementById('tenant-feishu-appsecret').value.trim();
|
||||||
|
const chatGroupsText = document.getElementById('tenant-feishu-chatgroups').value.trim();
|
||||||
|
const chatGroups = chatGroupsText ? chatGroupsText.split('\n').map(s => s.trim()).filter(Boolean) : [];
|
||||||
|
|
||||||
|
const config = {};
|
||||||
|
if (feishuAppId || feishuAppSecret || chatGroups.length > 0) {
|
||||||
|
config.feishu = {};
|
||||||
|
if (feishuAppId) config.feishu.app_id = feishuAppId;
|
||||||
|
if (feishuAppSecret) config.feishu.app_secret = feishuAppSecret;
|
||||||
|
if (chatGroups.length > 0) config.feishu.chat_groups = chatGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
this.showNotification('租户名称不能为空', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (editId) {
|
||||||
|
response = await fetch(`/api/tenants/${encodeURIComponent(editId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, description, config })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (!tenantId) {
|
||||||
|
this.showNotification('租户标识不能为空', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response = await fetch('/api/tenants', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tenant_id: tenantId, name, description, config })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('tenantModal')).hide();
|
||||||
|
this.showNotification(editId ? '租户已更新' : '租户已创建', 'success');
|
||||||
|
this.loadTenantList();
|
||||||
|
this.populateTenantSelectors();
|
||||||
|
} else {
|
||||||
|
this.showNotification(data.error || '操作失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('保存租户失败:', error);
|
||||||
|
this.showNotification('保存租户失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteTenant(tenantId) {
|
||||||
|
if (!confirm(`确定要删除租户 "${tenantId}" 吗?该操作不会删除关联数据。`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tenants/${encodeURIComponent(tenantId)}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('租户已删除', 'success');
|
||||||
|
this.loadTenantList();
|
||||||
|
} else {
|
||||||
|
this.showNotification(data.error || '删除失败', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除租户失败:', error);
|
||||||
|
this.showNotification('删除租户失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
573
src/web/static/js/modules/utils.js
Normal file
573
src/web/static/js/modules/utils.js
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
// 工具函数模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
getLevelText(level) {
|
||||||
|
const levelMap = {
|
||||||
|
'critical': '严重',
|
||||||
|
'error': '错误',
|
||||||
|
'warning': '警告',
|
||||||
|
'info': '信息'
|
||||||
|
};
|
||||||
|
return levelMap[level] || level;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTypeText(type) {
|
||||||
|
const typeMap = {
|
||||||
|
'performance': '性能',
|
||||||
|
'quality': '质量',
|
||||||
|
'volume': '量级',
|
||||||
|
'system': '系统',
|
||||||
|
'business': '业务'
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAlertColor(level) {
|
||||||
|
const colorMap = {
|
||||||
|
'critical': 'danger',
|
||||||
|
'error': 'danger',
|
||||||
|
'warning': 'warning',
|
||||||
|
'info': 'info'
|
||||||
|
};
|
||||||
|
return colorMap[level] || 'secondary';
|
||||||
|
},
|
||||||
|
|
||||||
|
getPriorityText(priority) {
|
||||||
|
const priorityMap = {
|
||||||
|
'low': '低',
|
||||||
|
'medium': '中',
|
||||||
|
'high': '高',
|
||||||
|
'urgent': '紧急'
|
||||||
|
};
|
||||||
|
return priorityMap[priority] || priority;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSeverityText(severity) {
|
||||||
|
const severityMap = {
|
||||||
|
'low': '低',
|
||||||
|
'medium': '中',
|
||||||
|
'high': '高',
|
||||||
|
'critical': '严重'
|
||||||
|
};
|
||||||
|
return severityMap[severity] || severity;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSatisfactionText(level) {
|
||||||
|
const satisfactionMap = {
|
||||||
|
'very_satisfied': '非常满意',
|
||||||
|
'satisfied': '满意',
|
||||||
|
'neutral': '一般',
|
||||||
|
'dissatisfied': '不满意'
|
||||||
|
};
|
||||||
|
return satisfactionMap[level] || level;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPerformanceTrendText(trend) {
|
||||||
|
const trendMap = {
|
||||||
|
'up': '上升',
|
||||||
|
'down': '下降',
|
||||||
|
'stable': '稳定'
|
||||||
|
};
|
||||||
|
return trendMap[trend] || trend;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPriorityColor(priority) {
|
||||||
|
const colorMap = {
|
||||||
|
'low': 'secondary',
|
||||||
|
'medium': 'primary',
|
||||||
|
'high': 'warning',
|
||||||
|
'urgent': 'danger'
|
||||||
|
};
|
||||||
|
return colorMap[priority] || 'secondary';
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'open': '待处理',
|
||||||
|
'in_progress': '处理中',
|
||||||
|
'resolved': '已解决',
|
||||||
|
'closed': '已关闭'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 统计数字预览功能
|
||||||
|
async showStatPreview(type, status) {
|
||||||
|
try {
|
||||||
|
let title = '';
|
||||||
|
let data = [];
|
||||||
|
let apiUrl = '';
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'workorder':
|
||||||
|
title = this.getWorkorderPreviewTitle(status);
|
||||||
|
apiUrl = status === 'all' ? '/api/workorders' : `/api/workorders/by-status/${status}`;
|
||||||
|
break;
|
||||||
|
case 'alert':
|
||||||
|
title = this.getAlertPreviewTitle(status);
|
||||||
|
apiUrl = `/api/alerts/by-level/${status}`;
|
||||||
|
break;
|
||||||
|
case 'knowledge':
|
||||||
|
title = this.getKnowledgePreviewTitle(status);
|
||||||
|
apiUrl = `/api/knowledge/by-status/${status}`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示加载状态
|
||||||
|
this.showLoadingModal(title);
|
||||||
|
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// 处理不同的API响应结构
|
||||||
|
if (result.success !== false) {
|
||||||
|
if (result.success === true) {
|
||||||
|
// 新API结构: {success: true, data: {workorders: [...]}}
|
||||||
|
data = result.data[type + 's'] || result.data.knowledge || [];
|
||||||
|
} else if (result.workorders) {
|
||||||
|
// 旧API结构: {workorders: [...], page: 1, ...}
|
||||||
|
data = result.workorders || [];
|
||||||
|
} else if (result.alerts) {
|
||||||
|
// 预警API结构
|
||||||
|
data = result.alerts || [];
|
||||||
|
} else if (result.knowledge) {
|
||||||
|
// 知识库API结构
|
||||||
|
data = result.knowledge || [];
|
||||||
|
} else {
|
||||||
|
data = [];
|
||||||
|
}
|
||||||
|
this.showPreviewModal(title, type, data);
|
||||||
|
} else {
|
||||||
|
const errorMsg = result.error || result.message || '未知错误';
|
||||||
|
this.showNotification('获取数据失败: ' + errorMsg, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('预览失败:', error);
|
||||||
|
this.showNotification('预览失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getWorkorderPreviewTitle(status) {
|
||||||
|
const titles = {
|
||||||
|
'all': '所有工单',
|
||||||
|
'open': '待处理工单',
|
||||||
|
'in_progress': '处理中工单',
|
||||||
|
'resolved': '已解决工单',
|
||||||
|
'closed': '已关闭工单'
|
||||||
|
};
|
||||||
|
return titles[status] || '工单列表';
|
||||||
|
},
|
||||||
|
|
||||||
|
getAlertPreviewTitle(level) {
|
||||||
|
const titles = {
|
||||||
|
'critical': '严重预警',
|
||||||
|
'warning': '警告预警',
|
||||||
|
'info': '信息预警'
|
||||||
|
};
|
||||||
|
return titles[level] || '预警列表';
|
||||||
|
},
|
||||||
|
|
||||||
|
getKnowledgePreviewTitle(status) {
|
||||||
|
const titles = {
|
||||||
|
'verified': '已验证知识',
|
||||||
|
'unverified': '未验证知识'
|
||||||
|
};
|
||||||
|
return titles[status] || '知识库条目';
|
||||||
|
},
|
||||||
|
|
||||||
|
showLoadingModal(title) {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="statPreviewModal" tabindex="-1" data-bs-backdrop="true" data-bs-keyboard="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">${title}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body text-center">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">加载中...</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2">正在加载数据...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 移除已存在的模态框和遮罩
|
||||||
|
this.removeExistingModal();
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modalElement = document.getElementById('statPreviewModal');
|
||||||
|
const modal = new bootstrap.Modal(modalElement, {
|
||||||
|
backdrop: true,
|
||||||
|
keyboard: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加事件监听器确保正确清理
|
||||||
|
modalElement.addEventListener('hidden.bs.modal', () => {
|
||||||
|
this.cleanupModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
showPreviewModal(title, type, data) {
|
||||||
|
let contentHtml = '';
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
contentHtml = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
|
||||||
|
<h5 class="text-muted">暂无数据</h5>
|
||||||
|
<p class="text-muted">当前条件下没有找到相关记录</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
switch (type) {
|
||||||
|
case 'workorder':
|
||||||
|
contentHtml = this.generateWorkorderPreviewHtml(data);
|
||||||
|
break;
|
||||||
|
case 'alert':
|
||||||
|
contentHtml = this.generateAlertPreviewHtml(data);
|
||||||
|
break;
|
||||||
|
case 'knowledge':
|
||||||
|
contentHtml = this.generateKnowledgePreviewHtml(data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="statPreviewModal" tabindex="-1" data-bs-backdrop="true" data-bs-keyboard="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">
|
||||||
|
<i class="fas fa-eye me-2"></i>${title}
|
||||||
|
<span class="badge bg-primary ms-2">${data.length} 条记录</span>
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="关闭"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
${contentHtml}
|
||||||
|
</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.goToFullView('${type}', '${status}')">查看完整列表</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 移除已存在的模态框和遮罩
|
||||||
|
this.removeExistingModal();
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||||
|
const modalElement = document.getElementById('statPreviewModal');
|
||||||
|
const modal = new bootstrap.Modal(modalElement, {
|
||||||
|
backdrop: true,
|
||||||
|
keyboard: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加事件监听器确保正确清理
|
||||||
|
modalElement.addEventListener('hidden.bs.modal', () => {
|
||||||
|
this.cleanupModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
generateWorkorderPreviewHtml(workorders) {
|
||||||
|
return `
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>工单ID</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>优先级</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${workorders.map(wo => `
|
||||||
|
<tr>
|
||||||
|
<td>${wo.order_id || wo.id}</td>
|
||||||
|
<td>
|
||||||
|
<div class="text-truncate" style="max-width: 200px;" title="${wo.title}">
|
||||||
|
${wo.title}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-${this.getStatusColor(wo.status)}">
|
||||||
|
${this.getStatusText(wo.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-${this.getPriorityColor(wo.priority)}">
|
||||||
|
${this.getPriorityText(wo.priority)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${new Date(wo.created_at).toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="dashboard.viewWorkOrderDetails(${wo.id})">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
generateAlertPreviewHtml(alerts) {
|
||||||
|
return `
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>预警ID</th>
|
||||||
|
<th>消息</th>
|
||||||
|
<th>级别</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${alerts.map(alert => `
|
||||||
|
<tr>
|
||||||
|
<td>${alert.id}</td>
|
||||||
|
<td>
|
||||||
|
<div class="text-truncate" style="max-width: 300px;" title="${alert.message}">
|
||||||
|
${alert.message}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-${this.getAlertLevelColor(alert.level)}">
|
||||||
|
${this.getLevelText(alert.level)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${this.getTypeText(alert.alert_type)}</td>
|
||||||
|
<td>${new Date(alert.created_at).toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-success" onclick="dashboard.resolveAlert(${alert.id})">
|
||||||
|
<i class="fas fa-check"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
generateKnowledgePreviewHtml(knowledge) {
|
||||||
|
return `
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>标题</th>
|
||||||
|
<th>分类</th>
|
||||||
|
<th>验证状态</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${knowledge.map(item => `
|
||||||
|
<tr>
|
||||||
|
<td>${item.id}</td>
|
||||||
|
<td>
|
||||||
|
<div class="text-truncate" style="max-width: 250px;" title="${item.title}">
|
||||||
|
${item.title}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>${item.category || '未分类'}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-${item.is_verified ? 'success' : 'warning'}">
|
||||||
|
${item.is_verified ? '已验证' : '未验证'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>${new Date(item.created_at).toLocaleString()}</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
${item.is_verified ?
|
||||||
|
`<button class="btn btn-sm btn-outline-warning" onclick="dashboard.unverifyKnowledge(${item.id})" title="取消验证">
|
||||||
|
<i class="fas fa-times-circle"></i>
|
||||||
|
</button>` :
|
||||||
|
`<button class="btn btn-sm btn-outline-success" onclick="dashboard.verifyKnowledge(${item.id})" title="验证">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="dashboard.deleteKnowledge(${item.id})" title="删除">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAlertLevelColor(level) {
|
||||||
|
const colorMap = {
|
||||||
|
'critical': 'danger',
|
||||||
|
'warning': 'warning',
|
||||||
|
'info': 'info'
|
||||||
|
};
|
||||||
|
return colorMap[level] || 'secondary';
|
||||||
|
},
|
||||||
|
|
||||||
|
goToFullView(type, status) {
|
||||||
|
// 关闭预览模态框
|
||||||
|
const modal = bootstrap.Modal.getInstance(document.getElementById('statPreviewModal'));
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到对应的标签页
|
||||||
|
switch (type) {
|
||||||
|
case 'workorder':
|
||||||
|
this.switchTab('workorders');
|
||||||
|
// 设置筛选器
|
||||||
|
if (status !== 'all') {
|
||||||
|
setTimeout(() => {
|
||||||
|
const filter = document.getElementById('workorder-status-filter');
|
||||||
|
if (filter) {
|
||||||
|
filter.value = status;
|
||||||
|
this.loadWorkOrders();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'alert':
|
||||||
|
this.switchTab('alerts');
|
||||||
|
// 设置筛选器
|
||||||
|
setTimeout(() => {
|
||||||
|
const filter = document.getElementById('alert-filter');
|
||||||
|
if (filter) {
|
||||||
|
filter.value = status;
|
||||||
|
this.updateAlertsDisplay();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
break;
|
||||||
|
case 'knowledge':
|
||||||
|
this.switchTab('knowledge');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 模态框清理方法
|
||||||
|
removeExistingModal() {
|
||||||
|
const existingModal = document.getElementById('statPreviewModal');
|
||||||
|
if (existingModal) {
|
||||||
|
// 获取模态框实例并销毁
|
||||||
|
const modalInstance = bootstrap.Modal.getInstance(existingModal);
|
||||||
|
if (modalInstance) {
|
||||||
|
modalInstance.dispose();
|
||||||
|
}
|
||||||
|
existingModal.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理可能残留的遮罩
|
||||||
|
const backdrops = document.querySelectorAll('.modal-backdrop');
|
||||||
|
backdrops.forEach(backdrop => backdrop.remove());
|
||||||
|
|
||||||
|
// 恢复body的滚动
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.style.paddingRight = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanupModal() {
|
||||||
|
// 延迟清理,确保动画完成
|
||||||
|
setTimeout(() => {
|
||||||
|
this.removeExistingModal();
|
||||||
|
}, 300);
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatusColor(status) {
|
||||||
|
const colorMap = {
|
||||||
|
'open': 'warning',
|
||||||
|
'in_progress': 'info',
|
||||||
|
'resolved': 'success',
|
||||||
|
'closed': 'secondary'
|
||||||
|
};
|
||||||
|
return colorMap[status] || 'secondary';
|
||||||
|
},
|
||||||
|
|
||||||
|
formatTime(timestamp) {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now - date;
|
||||||
|
|
||||||
|
if (diff < 60000) { // 1分钟内
|
||||||
|
return '刚刚';
|
||||||
|
} else if (diff < 3600000) { // 1小时内
|
||||||
|
return `${Math.floor(diff / 60000)}分钟前`;
|
||||||
|
} else if (diff < 86400000) { // 1天内
|
||||||
|
return `${Math.floor(diff / 3600000)}小时前`;
|
||||||
|
} else {
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `notification alert alert-${type === 'error' ? 'danger' : type} alert-dismissible fade show`;
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.parentNode.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSimilarityExplanation(percent) {
|
||||||
|
if (percent >= 95) {
|
||||||
|
return "语义高度相似,AI建议与人工描述基本一致,建议自动审批";
|
||||||
|
} else if (percent >= 90) {
|
||||||
|
return "语义较为相似,AI建议与人工描述大体一致,建议人工审核";
|
||||||
|
} else if (percent >= 80) {
|
||||||
|
return "语义部分相似,AI建议与人工描述有一定差异,需要人工判断";
|
||||||
|
} else if (percent >= 60) {
|
||||||
|
return "语义相似度较低,AI建议与人工描述差异较大,建议使用人工描述";
|
||||||
|
} else {
|
||||||
|
return "语义差异很大,AI建议与人工描述差异很大,优先使用人工描述";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getSimilarityMessage(percent, approved, useHumanResolution = false) {
|
||||||
|
if (useHumanResolution) {
|
||||||
|
return `人工描述已保存!语义相似度: ${percent}%,AI准确率低于90%,将使用人工描述入库`;
|
||||||
|
} else if (approved) {
|
||||||
|
return `人工描述已保存!语义相似度: ${percent}%,已自动审批入库`;
|
||||||
|
} else if (percent >= 90) {
|
||||||
|
return `人工描述已保存!语义相似度: ${percent}%,建议人工审核后审批`;
|
||||||
|
} else if (percent >= 80) {
|
||||||
|
return `人工描述已保存!语义相似度: ${percent}%,需要人工判断是否审批`;
|
||||||
|
} else {
|
||||||
|
return `人工描述已保存!语义相似度: ${percent}%,建议使用人工描述入库`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
458
src/web/static/js/modules/workorders.js
Normal file
458
src/web/static/js/modules/workorders.js
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
// 工单管理模块
|
||||||
|
Object.assign(TSPDashboard.prototype, {
|
||||||
|
async generateAISuggestion(workorderId) {
|
||||||
|
const button = document.querySelector(`button[onclick="dashboard.generateAISuggestion(${workorderId})"]`);
|
||||||
|
const textarea = document.getElementById(`aiSuggestion_${workorderId}`);
|
||||||
|
try {
|
||||||
|
if (button) { button.classList.add('btn-loading'); button.disabled = true; }
|
||||||
|
if (textarea) { textarea.classList.add('ai-loading'); textarea.value = '正在生成AI建议,请稍候...'; }
|
||||||
|
const resp = await fetch(`/api/workorders/${workorderId}/ai-suggestion`, { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
if (textarea) {
|
||||||
|
textarea.value = data.suggestion || '';
|
||||||
|
textarea.classList.remove('ai-loading');
|
||||||
|
textarea.classList.add('success-animation');
|
||||||
|
setTimeout(() => textarea.classList.remove('success-animation'), 600);
|
||||||
|
}
|
||||||
|
this.showNotification('AI建议已生成', 'success');
|
||||||
|
} else { throw new Error(data.error || '生成失败'); }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('生成AI建议失败:', e);
|
||||||
|
if (textarea) { textarea.value = 'AI建议生成失败,请重试'; textarea.classList.remove('ai-loading'); }
|
||||||
|
this.showNotification('生成AI建议失败: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
if (button) { button.classList.remove('btn-loading'); button.disabled = false; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveHumanResolution(workorderId) {
|
||||||
|
try {
|
||||||
|
const text = document.getElementById(`humanResolution_${workorderId}`).value.trim();
|
||||||
|
if (!text) { this.showNotification('请输入人工描述', 'warning'); return; }
|
||||||
|
const resp = await fetch(`/api/workorders/${workorderId}/human-resolution`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ human_resolution: text })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
const simEl = document.getElementById(`aiSim_${workorderId}`);
|
||||||
|
const apprEl = document.getElementById(`aiApproved_${workorderId}`);
|
||||||
|
const approveBtn = document.getElementById(`approveBtn_${workorderId}`);
|
||||||
|
const percent = Math.round((data.similarity || 0) * 100);
|
||||||
|
if (simEl) {
|
||||||
|
simEl.innerHTML = `<i class="fas fa-percentage"></i>语义相似度: ${percent}%`;
|
||||||
|
simEl.className = percent >= 90 ? 'similarity-badge high' : percent >= 80 ? 'similarity-badge medium' : 'similarity-badge low';
|
||||||
|
simEl.title = this.getSimilarityExplanation(percent);
|
||||||
|
}
|
||||||
|
if (apprEl) {
|
||||||
|
if (data.use_human_resolution) { apprEl.textContent = '将使用人工描述入库'; apprEl.className = 'status-badge human-resolution'; }
|
||||||
|
else if (data.approved) { apprEl.textContent = '已自动审批'; apprEl.className = 'status-badge approved'; }
|
||||||
|
else { apprEl.textContent = '未审批'; apprEl.className = 'status-badge pending'; }
|
||||||
|
}
|
||||||
|
if (approveBtn) {
|
||||||
|
const canApprove = data.approved || data.use_human_resolution;
|
||||||
|
approveBtn.disabled = !canApprove;
|
||||||
|
if (data.use_human_resolution) { approveBtn.textContent = '使用人工描述入库'; approveBtn.className = 'approve-btn'; }
|
||||||
|
else if (data.approved) { approveBtn.textContent = '已自动审批'; approveBtn.className = 'approve-btn approved'; }
|
||||||
|
else { approveBtn.textContent = '审批入库'; approveBtn.className = 'approve-btn'; }
|
||||||
|
}
|
||||||
|
const message = this.getSimilarityMessage(percent, data.approved, data.use_human_resolution);
|
||||||
|
this.showNotification(message, data.approved ? 'success' : data.use_human_resolution ? 'warning' : 'info');
|
||||||
|
} else { throw new Error(data.error || '保存失败'); }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存人工描述失败:', e);
|
||||||
|
this.showNotification('保存人工描述失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async approveToKnowledge(workorderId) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/workorders/${workorderId}/approve-to-knowledge`, { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
const contentType = data.used_content === 'human_resolution' ? '人工描述' : 'AI建议';
|
||||||
|
const confidence = Math.round((data.confidence_score || 0) * 100);
|
||||||
|
this.showNotification(`已入库为知识条目!使用${contentType},置信度: ${confidence}%`, 'success');
|
||||||
|
} else { throw new Error(data.error || '入库失败'); }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('入库失败:', e);
|
||||||
|
this.showNotification('入库失败: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadWorkOrders(page = 1, forceRefresh = false) {
|
||||||
|
this.paginationConfig.currentWorkOrderPage = page;
|
||||||
|
const cacheKey = `workorders_page_${page}`;
|
||||||
|
if (!forceRefresh && this.cache.has(cacheKey)) {
|
||||||
|
const cachedData = this.cache.get(cacheKey);
|
||||||
|
this.updateWorkOrdersDisplay(cachedData.workorders);
|
||||||
|
this.updateWorkOrdersPagination(cachedData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const statusFilter = document.getElementById('workorder-status-filter')?.value || 'all';
|
||||||
|
const priorityFilter = document.getElementById('workorder-priority-filter')?.value || 'all';
|
||||||
|
const tenantFilter = document.getElementById('workorder-tenant-filter')?.value || 'all';
|
||||||
|
let url = '/api/workorders';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('page', page);
|
||||||
|
params.append('per_page', this.getPageSize('workorders-pagination').toString());
|
||||||
|
if (statusFilter !== 'all') params.append('status', statusFilter);
|
||||||
|
if (priorityFilter !== 'all') params.append('priority', priorityFilter);
|
||||||
|
if (tenantFilter !== 'all') params.append('tenant_id', tenantFilter);
|
||||||
|
if (forceRefresh) params.append('_t', Date.now().toString());
|
||||||
|
if (params.toString()) url += '?' + params.toString();
|
||||||
|
const response = await fetch(url, {
|
||||||
|
cache: forceRefresh ? 'no-cache' : 'default',
|
||||||
|
headers: forceRefresh ? { 'Cache-Control': 'no-cache', 'Pragma': 'no-cache' } : {}
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
const data = await response.json();
|
||||||
|
this.updateWorkOrdersDisplay(data.workorders);
|
||||||
|
this.updateWorkOrdersPagination(data);
|
||||||
|
this.cache.set(cacheKey, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载工单失败:', error);
|
||||||
|
this.showNotification('加载工单失败: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWorkOrdersDisplay(workorders) {
|
||||||
|
const container = document.getElementById('workorders-list');
|
||||||
|
if (workorders.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state"><i class="fas fa-tasks"></i><p>暂无工单</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const headerHtml = `<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<input type="checkbox" id="select-all-workorders" class="form-check-input me-2" onchange="dashboard.toggleSelectAllWorkorders()">
|
||||||
|
<label for="select-all-workorders" class="form-check-label">全选</label>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-danger" id="batch-delete-workorders" onclick="dashboard.batchDeleteWorkorders()" disabled>
|
||||||
|
<i class="fas fa-trash me-1"></i>批量删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
const workordersHtml = workorders.map(wo => `
|
||||||
|
<div class="work-order-item">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<input type="checkbox" class="form-check-input me-2 workorder-checkbox" value="${wo.id}" onchange="dashboard.updateBatchDeleteButton()">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h6 class="mb-1">${wo.title}</h6>
|
||||||
|
<p class="text-muted mb-2">${wo.description ? wo.description.substring(0, 100) + (wo.description.length > 100 ? '...' : '') : '无问题描述'}</p>
|
||||||
|
<div class="d-flex gap-3">
|
||||||
|
<span class="badge bg-${this.getPriorityColor(wo.priority)}">${this.getPriorityText(wo.priority)}</span>
|
||||||
|
<span class="badge bg-${this.getStatusColor(wo.status)}">${this.getStatusText(wo.status)}</span>
|
||||||
|
<small class="text-muted">分类: ${wo.category}</small>
|
||||||
|
<small class="text-muted">创建时间: ${new Date(wo.created_at).toLocaleString()}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-3">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button class="btn btn-sm btn-outline-info" onclick="dashboard.viewWorkOrderDetails(${wo.id})" title="查看详情"><i class="fas fa-eye"></i><span class="d-none d-md-inline ms-1">查看</span></button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="dashboard.updateWorkOrder(${wo.id})" title="编辑"><i class="fas fa-edit"></i><span class="d-none d-md-inline ms-1">编辑</span></button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="dashboard.deleteWorkOrder(${wo.id})" title="删除"><i class="fas fa-trash"></i><span class="d-none d-md-inline ms-1">删除</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
container.innerHTML = headerHtml + workordersHtml;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWorkOrdersPagination(data) {
|
||||||
|
this.createPaginationComponent(data, 'workorders-pagination', 'loadWorkOrders', '个工单');
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWorkOrderStatistics(workorders) {
|
||||||
|
const stats = workorders.reduce((acc, wo) => { acc.total = (acc.total || 0) + 1; acc[wo.status] = (acc[wo.status] || 0) + 1; return acc; }, {});
|
||||||
|
const statusMapping = {
|
||||||
|
'open': ['open', '待处理', '新建', 'new'],
|
||||||
|
'in_progress': ['in_progress', '处理中', '进行中', 'progress', 'processing'],
|
||||||
|
'resolved': ['resolved', '已解决', '已完成'],
|
||||||
|
'closed': ['closed', '已关闭', '关闭']
|
||||||
|
};
|
||||||
|
const mapped_counts = {'open': 0, 'in_progress': 0, 'resolved': 0, 'closed': 0};
|
||||||
|
for (const [status, count] of Object.entries(stats)) {
|
||||||
|
if (status === 'total') continue;
|
||||||
|
const status_lower = String(status).toLowerCase();
|
||||||
|
for (const [mapped_status, possible_values] of Object.entries(statusMapping)) {
|
||||||
|
if (possible_values.some(v => v.toLowerCase() === status_lower)) { mapped_counts[mapped_status] += count; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById('workorders-total').textContent = stats.total || 0;
|
||||||
|
document.getElementById('workorders-open').textContent = mapped_counts['open'];
|
||||||
|
document.getElementById('workorders-progress').textContent = mapped_counts['in_progress'];
|
||||||
|
document.getElementById('workorders-resolved').textContent = mapped_counts['resolved'];
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSelectAllWorkorders() {
|
||||||
|
const selectAllCheckbox = document.getElementById('select-all-workorders');
|
||||||
|
document.querySelectorAll('.workorder-checkbox').forEach(cb => cb.checked = selectAllCheckbox.checked);
|
||||||
|
this.updateBatchDeleteButton();
|
||||||
|
},
|
||||||
|
|
||||||
|
updateBatchDeleteButton() {
|
||||||
|
const selected = document.querySelectorAll('.workorder-checkbox:checked');
|
||||||
|
const btn = document.getElementById('batch-delete-workorders');
|
||||||
|
if (btn) { btn.disabled = selected.length === 0; btn.textContent = selected.length > 0 ? `批量删除 (${selected.length})` : '批量删除'; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async batchDeleteWorkorders() {
|
||||||
|
const selectedIds = Array.from(document.querySelectorAll('.workorder-checkbox:checked')).map(cb => parseInt(cb.value));
|
||||||
|
if (selectedIds.length === 0) { this.showNotification('请选择要删除的工单', 'warning'); return; }
|
||||||
|
if (!confirm(`确定要删除选中的 ${selectedIds.length} 个工单吗?此操作不可撤销。`)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch-delete/workorders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: selectedIds }) });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification(data.message, 'success');
|
||||||
|
this.cache.delete('workorders');
|
||||||
|
await this.loadWorkOrders(true);
|
||||||
|
await this.loadAnalytics();
|
||||||
|
this.updateBatchDeleteButton();
|
||||||
|
} else { this.showNotification(data.error || '批量删除失败', 'error'); }
|
||||||
|
} catch (error) { console.error('批量删除工单失败:', error); this.showNotification('批量删除工单失败', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async createWorkOrder() {
|
||||||
|
const title = document.getElementById('wo-title').value.trim();
|
||||||
|
const description = document.getElementById('wo-description').value.trim();
|
||||||
|
const category = document.getElementById('wo-category').value;
|
||||||
|
const priority = document.getElementById('wo-priority').value;
|
||||||
|
if (!title || !description) { this.showNotification('请填写完整信息', 'warning'); return; }
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/workorders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, description, category, priority }) });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('工单创建成功', 'success');
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('createWorkOrderModal')).hide();
|
||||||
|
document.getElementById('work-order-form').reset();
|
||||||
|
await this.loadWorkOrders();
|
||||||
|
await this.loadAnalytics();
|
||||||
|
} else { this.showNotification('创建工单失败: ' + (data.error || '未知错误'), 'error'); }
|
||||||
|
} catch (error) { console.error('创建工单失败:', error); this.showNotification('创建工单失败', 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
showCreateWorkOrderModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('createWorkOrderModal'));
|
||||||
|
modal.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 class="ai-suggestion-section">
|
||||||
|
<div class="ai-suggestion-header">
|
||||||
|
<h6 class="ai-suggestion-title"><i class="fas fa-robot"></i>AI建议与人工描述</h6>
|
||||||
|
<button class="generate-ai-btn" onclick="dashboard.generateAISuggestion(${workorder.id})"><i class="fas fa-magic me-1"></i>生成AI建议</button>
|
||||||
|
</div>
|
||||||
|
<div class="ai-suggestion-content">
|
||||||
|
<label class="form-label fw-bold text-primary mb-2"><i class="fas fa-brain me-1"></i>AI建议</label>
|
||||||
|
<textarea id="aiSuggestion_${workorder.id}" class="form-control" rows="4" placeholder="点击上方按钮生成AI建议..." readonly></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="human-resolution-content">
|
||||||
|
<label class="form-label fw-bold text-warning mb-2"><i class="fas fa-user-edit me-1"></i>人工描述</label>
|
||||||
|
<textarea id="humanResolution_${workorder.id}" class="form-control" rows="3" placeholder="请填写人工处理描述..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="similarity-indicator">
|
||||||
|
<button class="save-human-btn" onclick="dashboard.saveHumanResolution(${workorder.id})"><i class="fas fa-save me-1"></i>保存人工描述并评估</button>
|
||||||
|
<span id="aiSim_${workorder.id}" class="similarity-badge bg-secondary"><i class="fas fa-percentage"></i>相似度: --</span>
|
||||||
|
<span id="aiApproved_${workorder.id}" class="status-badge pending">未审批</span>
|
||||||
|
</div>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button id="approveBtn_${workorder.id}" class="approve-btn" onclick="dashboard.approveToKnowledge(${workorder.id})" disabled><i class="fas fa-check me-1"></i>审批入库</button>
|
||||||
|
</div>
|
||||||
|
</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();
|
||||||
|
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 (workorder.id) { this.showEditWorkOrderModal(workorder); }
|
||||||
|
else { throw new Error(workorder.error || '获取工单详情失败'); }
|
||||||
|
} catch (error) { console.error('获取工单详情失败:', error); this.showNotification('获取工单详情失败: ' + error.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteWorkOrder(workorderId) {
|
||||||
|
if (!confirm('确定要删除这个工单吗?此操作不可撤销。')) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workorders/${workorderId}`, { method: 'DELETE' });
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.showNotification('工单删除成功', 'success');
|
||||||
|
await this.loadWorkOrders(this.paginationConfig.currentWorkOrderPage, true);
|
||||||
|
await this.loadAnalytics();
|
||||||
|
} else { this.showNotification('删除工单失败: ' + (data.error || '未知错误'), 'error'); }
|
||||||
|
} catch (error) { console.error('删除工单失败:', error); this.showNotification('删除工单失败: ' + error.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
showEditWorkOrderModal(workorder) {
|
||||||
|
const modalHtml = `
|
||||||
|
<div class="modal fade" id="editWorkOrderModal" tabindex="-1"><div class="modal-dialog modal-lg"><div class="modal-content">
|
||||||
|
<div class="modal-header"><h5 class="modal-title">编辑工单 #${workorder.id}</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||||
|
<div class="modal-body"><form id="editWorkOrderForm">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8"><div class="mb-3"><label 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 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 class="form-label">分类</label><select class="form-select" id="editCategory">
|
||||||
|
<option value="技术问题" ${workorder.category === '技术问题' ? 'selected' : ''}>技术问题</option>
|
||||||
|
<option value="APP功能" ${workorder.category === 'APP功能' ? 'selected' : ''}>APP功能</option>
|
||||||
|
<option value="远程控制" ${workorder.category === '远程控制' ? 'selected' : ''}>远程控制</option>
|
||||||
|
<option value="车辆绑定" ${workorder.category === '车辆绑定' ? 'selected' : ''}>车辆绑定</option>
|
||||||
|
<option value="系统故障" ${workorder.category === '系统故障' ? 'selected' : ''}>系统故障</option>
|
||||||
|
<option value="OTA升级" ${workorder.category === 'OTA升级' ? 'selected' : ''}>OTA升级</option>
|
||||||
|
<option value="其他" ${workorder.category === '其他' ? 'selected' : ''}>其他</option>
|
||||||
|
</select></div></div>
|
||||||
|
<div class="col-md-6"><div class="mb-3"><label 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 class="form-label">问题详细描述 *</label><textarea class="form-control" id="editDescription" rows="4" required>${workorder.description}</textarea></div>
|
||||||
|
<div class="mb-3"><label 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 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);
|
||||||
|
new bootstrap.Modal(document.getElementById('editWorkOrderModal')).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 (result.success) {
|
||||||
|
this.showNotification('工单更新成功', 'success');
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('editWorkOrderModal')).hide();
|
||||||
|
await this.loadWorkOrders();
|
||||||
|
await this.loadAnalytics();
|
||||||
|
} 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 resp = await fetch('/api/workorders/import/template/file');
|
||||||
|
if (!resp.ok) throw new Error('下载接口返回错误');
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a'); a.href = blobUrl; a.download = '工单导入模板.xlsx';
|
||||||
|
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(blobUrl);
|
||||||
|
this.showNotification('模板下载成功', 'success');
|
||||||
|
} 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 {
|
||||||
|
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 (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();
|
||||||
|
setTimeout(() => { bootstrap.Modal.getInstance(document.getElementById('importWorkOrderModal')).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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -2629,6 +2629,19 @@
|
|||||||
<script src="{{ url_for('static', filename='js/app-new.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/app-new.js') }}"></script>
|
||||||
|
|
||||||
<!-- 原有dashboard.js保持兼容 -->
|
<!-- 原有dashboard.js保持兼容 -->
|
||||||
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v=1.0.9"></script>
|
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v=2.0.0"></script>
|
||||||
|
<!-- 功能模块 -->
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/utils.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/dashboard-home.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/chat.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/agent.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/alerts.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/knowledge.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/workorders.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/conversations.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/monitoring.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/system.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/tenants.js') }}?v=2.0.0"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modules/feishu-sync.js') }}?v=2.0.0"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user