Files
assist/src/web/static/js/pages/knowledge.js

673 lines
27 KiB
JavaScript
Raw Normal View History

/**
* 知识库页面组件
*/
export default class Knowledge {
constructor(container, route) {
this.container = container;
this.route = route;
this.currentPage = 1; // 初始化当前页码
this.init();
}
async init() {
this.render();
this.bindEvents();
await Promise.all([
this.loadKnowledgeList(),
this.loadStats()
]);
}
async loadStats() {
try {
const response = await fetch('/api/knowledge/stats');
if (response.ok) {
const stats = await response.json();
// 更新统计数据显示
const totalEl = this.container.querySelector('#stat-total');
const activeEl = this.container.querySelector('#stat-active');
const catsEl = this.container.querySelector('#stat-categories');
const confEl = this.container.querySelector('#stat-confidence');
if (totalEl) totalEl.textContent = stats.total_entries || 0;
if (activeEl) activeEl.textContent = stats.active_entries || 0; // 后端现在返回的是已验证数量
if (catsEl) catsEl.textContent = Object.keys(stats.category_distribution || {}).length;
if (confEl) confEl.textContent = ((stats.average_confidence || 0) * 100).toFixed(0) + '%';
}
} catch (error) {
console.error('加载统计信息失败:', error);
}
}
render() {
this.container.innerHTML = `
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<h1 class="page-title">知识库</h1>
<p class="page-subtitle">管理和维护知识条目</p>
</div>
<div>
<button class="btn btn-primary" id="btn-import-file">
<i class="fas fa-file-import me-2"></i>
</button>
<input type="file" id="file-input" style="display: none;" accept=".txt,.md">
</div>
</div>
<!-- 统计卡片 -->
<div class="row row-cards mb-4">
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<span class="bg-primary text-white avatar">
<i class="fas fa-book"></i>
</span>
</div>
<div class="col">
<div class="font-weight-medium" id="stat-total">0</div>
<div class="text-muted">总条目</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<span class="bg-green text-white avatar">
<i class="fas fa-check"></i>
</span>
</div>
<div class="col">
<div class="font-weight-medium" id="stat-active">0</div>
<div class="text-muted">已验证</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<span class="bg-blue text-white avatar">
<i class="fas fa-tags"></i>
</span>
</div>
<div class="col">
<div class="font-weight-medium" id="stat-categories">0</div>
<div class="text-muted">分类数量</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto">
<span class="bg-yellow text-white avatar">
<i class="fas fa-star"></i>
</span>
</div>
<div class="col">
<div class="font-weight-medium" id="stat-confidence">0%</div>
<div class="text-muted">平均置信度</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3 class="card-title">知识条目列表</h3>
<div class="card-options">
<div class="btn-group me-2 d-none" id="batch-actions">
<button class="btn btn-success btn-sm" id="btn-batch-verify">
<i class="fas fa-check me-1"></i>
</button>
<button class="btn btn-warning btn-sm" id="btn-batch-unverify">
<i class="fas fa-times me-1"></i>
</button>
<button class="btn btn-danger btn-sm" id="btn-batch-delete">
<i class="fas fa-trash me-1"></i>
</button>
</div>
<div class="input-group">
<input type="text" class="form-control" placeholder="搜索知识..." id="search-input">
<button class="btn btn-secondary" id="btn-search">
<i class="fas fa-search"></i>
</button>
</div>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-vcenter">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="check-all">
</th>
<th style="width: 50px;">#</th>
<th>问题/主题</th>
<th>内容预览</th>
<th style="width: 150px;">分类</th>
<th style="width: 100px;">置信度</th>
<th style="width: 100px;">操作</th>
</tr>
</thead>
<tbody id="knowledge-list-body">
<tr>
<td colspan="6" class="text-center py-4">正在加载数据...</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-center mt-3" id="pagination-container">
<!-- 分页控件 -->
</div>
</div>
</div>
`;
}
bindEvents() {
// 导入文件按钮
const fileInput = this.container.querySelector('#file-input');
const importBtn = this.container.querySelector('#btn-import-file');
importBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', async (e) => {
if (e.target.files.length > 0) {
await this.uploadFile(e.target.files[0]);
// 清空选择,允许再次选择同名文件
fileInput.value = '';
}
});
// 搜索功能
const searchInput = this.container.querySelector('#search-input');
const searchBtn = this.container.querySelector('#btn-search');
const performSearch = () => {
const query = searchInput.value.trim();
this.loadKnowledgeList(1, query);
};
searchBtn.addEventListener('click', performSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
performSearch();
}
});
// 批量操作按钮
const batchVerifyBtn = this.container.querySelector('#btn-batch-verify');
if (batchVerifyBtn) {
batchVerifyBtn.addEventListener('click', () => this.batchAction('verify'));
}
const batchUnverifyBtn = this.container.querySelector('#btn-batch-unverify');
if (batchUnverifyBtn) {
batchUnverifyBtn.addEventListener('click', () => this.batchAction('unverify'));
}
const batchDeleteBtn = this.container.querySelector('#btn-batch-delete');
if (batchDeleteBtn) {
batchDeleteBtn.addEventListener('click', () => this.batchAction('delete'));
}
// 全选复选框
const checkAll = this.container.querySelector('#check-all');
if (checkAll) {
checkAll.addEventListener('change', (e) => {
const checks = this.container.querySelectorAll('.item-check');
checks.forEach(check => check.checked = e.target.checked);
this.updateBatchButtons();
});
}
}
bindCheckboxEvents() {
const checks = this.container.querySelectorAll('.item-check');
checks.forEach(check => {
check.addEventListener('change', () => {
this.updateBatchButtons();
// 如果有一个未选中,取消全选选中状态
if (!check.checked) {
const checkAll = this.container.querySelector('#check-all');
if (checkAll) checkAll.checked = false;
}
});
});
}
updateBatchButtons() {
const checkedCount = this.container.querySelectorAll('.item-check:checked').length;
const actionsGroup = this.container.querySelector('#batch-actions');
if (actionsGroup) {
if (checkedCount > 0) {
actionsGroup.classList.remove('d-none');
// 更新删除按钮文本
const deleteBtn = this.container.querySelector('#btn-batch-delete');
if (deleteBtn) deleteBtn.innerHTML = `<i class="fas fa-trash me-1"></i>删除 (${checkedCount})`;
} else {
actionsGroup.classList.add('d-none');
}
}
}
async batchDeleteKnowledge() {
const checks = this.container.querySelectorAll('.item-check:checked');
const ids = Array.from(checks).map(check => parseInt(check.dataset.id));
console.log('Deleting IDs:', ids);
if (ids.length === 0) {
alert('请先选择要删除的知识条目');
return;
}
if (!confirm(`确定要删除选中的 ${ids.length} 条知识吗?`)) {
return;
}
try {
const response = await fetch('/api/knowledge/batch_delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: ids })
});
const result = await response.json();
if (response.ok && result.success) {
alert(result.message || '删除成功');
// 重置全选状态
const checkAll = this.container.querySelector('#check-all');
if (checkAll) checkAll.checked = false;
this.updateBatchDeleteButton();
// 刷新列表和统计(保持当前页)
await Promise.all([
this.loadKnowledgeList(this.currentPage),
this.loadStats()
]);
} else {
alert(`删除失败: ${result.message || '未知错误'}`);
}
} catch (error) {
console.error('批量删除出错:', error);
alert('批量删除出错,请查看控制台');
}
}
async loadKnowledgeList(page = null, query = '') {
// 如果未指定页码,使用当前页码,默认为 1
const targetPage = page || this.currentPage || 1;
this.currentPage = targetPage;
const tbody = this.container.querySelector('#knowledge-list-body');
// 柔性加载:不立即清空,而是降低透明度并显示加载态
// 这可以防止表格高度塌陷导致的视觉跳动
tbody.style.opacity = '0.5';
tbody.style.transition = 'opacity 0.2s';
// 如果表格是空的(第一次加载),则显示加载占位符
if (!tbody.hasChildNodes() || tbody.children.length === 0 || tbody.querySelector('.text-center')) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4"><div class="spinner-border text-primary" role="status"></div><div class="mt-2">加载中...</div></td></tr>';
tbody.style.opacity = '1';
}
try {
let url = `/api/knowledge?page=${targetPage}&per_page=10`;
if (query) {
url = `/api/knowledge/search?q=${encodeURIComponent(query)}`;
}
const response = await fetch(url);
const result = await response.json();
tbody.innerHTML = '';
tbody.style.opacity = '1'; // 恢复不透明
// 处理搜索结果(通常是数组)和分页结果(包含 items的差异
let items = [];
if (Array.isArray(result)) {
items = result;
} else if (result.items) {
items = result.items;
}
if (items.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center py-4 text-muted">暂无知识条目</td></tr>';
return;
}
items.forEach((item, index) => {
// ... (渲染逻辑保持不变)
const tr = document.createElement('tr');
// 验证状态图标
let statusBadge = '';
if (item.is_verified) {
statusBadge = '<span class="text-success ms-2" title="已验证"><i class="fas fa-check-circle"></i></span>';
}
// 验证操作按钮
let verifyBtn = '';
if (item.is_verified) {
verifyBtn = `
<button type="button" class="btn btn-sm btn-icon btn-outline-warning btn-unverify" data-id="${item.id}" title="取消验证">
<i class="fas fa-times"></i>
</button>
`;
} else {
verifyBtn = `
<button type="button" class="btn btn-sm btn-icon btn-outline-success btn-verify" data-id="${item.id}" title="验证通过">
<i class="fas fa-check"></i>
</button>
`;
}
tr.innerHTML = `
<td><input type="checkbox" class="form-check-input item-check" data-id="${item.id}"></td>
<td>${(targetPage - 1) * 10 + index + 1}</td>
<td>
<div class="text-truncate" style="max-width: 200px;" title="${item.question}">
${item.question}
${statusBadge}
</div>
</td>
<td><div class="text-truncate" style="max-width: 300px;" title="${item.answer}">${item.answer}</div></td>
<td><span class="badge bg-blue-lt">${item.category || '未分类'}</span></td>
<td>${(item.confidence_score * 100).toFixed(0)}%</td>
<td>
${verifyBtn}
<button type="button" class="btn btn-sm btn-icon btn-outline-danger btn-delete" data-id="${item.id}" title="删除">
<i class="fas fa-trash"></i>
</button>
</td>
`;
// 绑定验证/取消验证事件
const verifyActionBtn = tr.querySelector('.btn-verify, .btn-unverify');
if (verifyActionBtn) {
verifyActionBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const isVerify = verifyActionBtn.classList.contains('btn-verify');
await this.toggleVerify(item.id, isVerify, tr);
});
}
// 绑定删除事件
const deleteBtn = tr.querySelector('.btn-delete');
deleteBtn.addEventListener('click', (e) => {
e.preventDefault();
this.deleteKnowledge(item.id);
});
tbody.appendChild(tr);
});
// 重新绑定复选框事件
this.bindCheckboxEvents();
// 渲染分页
if (result.pages && result.pages > 1) {
this.renderPagination(result);
} else {
this.container.querySelector('#pagination-container').innerHTML = '';
}
} catch (error) {
console.error('加载知识列表失败:', error);
tbody.innerHTML = `<tr><td colspan="7" class="text-center py-4 text-danger">加载失败: ${error.message}</td></tr>`;
tbody.style.opacity = '1';
}
}
async uploadFile(file) {
const formData = new FormData();
formData.append('file', file);
// 显示上传中提示
const importBtn = this.container.querySelector('#btn-import-file');
const originalText = importBtn.innerHTML;
importBtn.disabled = true;
importBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 上传中...';
try {
const response = await fetch('/api/knowledge/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
alert(`文件上传成功!共提取 ${result.knowledge_count} 条知识。`);
// 刷新列表和统计
await Promise.all([
this.loadKnowledgeList(),
this.loadStats()
]);
} else {
alert(`上传失败: ${result.error || result.message || '未知错误'}`);
}
} catch (error) {
console.error('上传文件出错:', error);
alert('上传文件出错,请查看控制台');
} finally {
importBtn.disabled = false;
importBtn.innerHTML = originalText;
}
}
async toggleVerify(id, isVerify, trElement = null) {
const action = isVerify ? 'verify' : 'unverify';
const url = `/api/knowledge/${action}/${id}`;
try {
// 如果有 trElement先显示加载状态
let originalBtnHtml = '';
let actionBtn = null;
if (trElement) {
actionBtn = trElement.querySelector('.btn-verify, .btn-unverify');
if (actionBtn) {
originalBtnHtml = actionBtn.innerHTML;
actionBtn.disabled = true;
actionBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
}
}
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const result = await response.json();
if (response.ok && result.success) {
// 如果提供了 DOM 元素,直接更新 DOM避免刷新整个列表导致跳动
if (trElement) {
this.updateRowStatus(trElement, id, isVerify);
// 后台静默刷新统计数据
this.loadStats();
} else {
// 仅刷新列表和统计,不跳转页面
await Promise.all([
this.loadKnowledgeList(this.currentPage),
this.loadStats()
]);
}
} else {
alert(`${isVerify ? '验证' : '取消验证'}失败: ${result.message}`);
// 恢复按钮状态
if (actionBtn) {
actionBtn.disabled = false;
actionBtn.innerHTML = originalBtnHtml;
}
}
} catch (error) {
console.error('操作出错:', error);
// 恢复按钮状态
if (trElement) {
const actionBtn = trElement.querySelector('.btn-verify, .btn-unverify');
if (actionBtn) {
actionBtn.disabled = false;
// 简单恢复,无法精确还原之前的图标
actionBtn.innerHTML = isVerify ? '<i class="fas fa-check"></i>' : '<i class="fas fa-times"></i>';
}
}
}
}
updateRowStatus(tr, id, isVerified) {
// 1. 更新问题列的状态图标
const questionCell = tr.cells[2]; // 第3列是问题
const questionDiv = questionCell.querySelector('div');
// 移除旧的徽章
const oldBadge = questionDiv.querySelector('.text-success');
if (oldBadge) oldBadge.remove();
// 如果是验证通过,添加徽章
if (isVerified) {
const statusBadge = document.createElement('span');
statusBadge.className = 'text-success ms-2';
statusBadge.title = '已验证';
statusBadge.innerHTML = '<i class="fas fa-check-circle"></i>';
questionDiv.appendChild(statusBadge);
}
// 2. 更新操作按钮
const actionCell = tr.cells[6]; // 第7列是操作
const actionBtn = actionCell.querySelector('.btn-verify, .btn-unverify');
if (actionBtn) {
// 创建新按钮
const newBtn = document.createElement('button');
newBtn.type = 'button';
newBtn.className = `btn btn-sm btn-icon btn-outline-${isVerified ? 'warning' : 'success'} ${isVerified ? 'btn-unverify' : 'btn-verify'}`;
newBtn.dataset.id = id;
newBtn.title = isVerified ? '取消验证' : '验证通过';
newBtn.innerHTML = `<i class="fas fa-${isVerified ? 'times' : 'check'}"></i>`;
// 重新绑定事件
newBtn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
await this.toggleVerify(id, !isVerified, tr);
});
// 替换旧按钮
actionBtn.replaceWith(newBtn);
}
}
async deleteKnowledge(id) {
if (!confirm('确定要删除这条知识吗?')) {
return;
}
try {
const response = await fetch(`/api/knowledge/delete/${id}`, {
method: 'DELETE'
});
const result = await response.json();
if (response.ok && result.success) {
// 刷新列表和统计(保持当前页)
await Promise.all([
this.loadKnowledgeList(this.currentPage),
this.loadStats()
]);
} else {
alert(`删除失败: ${result.message || '未知错误'}`);
}
} catch (error) {
console.error('删除出错:', error);
alert('删除出错,请查看控制台');
}
}
renderPagination(pagination) {
const { page, pages } = pagination;
const container = this.container.querySelector('#pagination-container');
let html = '<ul class="pagination">';
// 上一页
html += `
<li class="page-item ${page === 1 ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${page - 1}" tabindex="-1">上一页</a>
</li>
`;
// 页码 (只显示当前页附近的页码)
const startPage = Math.max(1, page - 2);
const endPage = Math.min(pages, page + 2);
if (startPage > 1) {
html += '<li class="page-item"><a class="page-link" href="#" data-page="1">1</a></li>';
if (startPage > 2) {
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
}
}
for (let i = startPage; i <= endPage; i++) {
html += `
<li class="page-item ${i === page ? 'active' : ''}">
<a class="page-link" href="#" data-page="${i}">${i}</a>
</li>
`;
}
if (endPage < pages) {
if (endPage < pages - 1) {
html += '<li class="page-item disabled"><span class="page-link">...</span></li>';
}
html += `<li class="page-item"><a class="page-link" href="#" data-page="${pages}">${pages}</a></li>`;
}
// 下一页
html += `
<li class="page-item ${page === pages ? 'disabled' : ''}">
<a class="page-link" href="#" data-page="${page + 1}">下一页</a>
</li>
`;
html += '</ul>';
container.innerHTML = html;
// 绑定点击事件
container.querySelectorAll('.page-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const newPage = parseInt(e.target.dataset.page);
if (newPage && newPage !== page && newPage >= 1 && newPage <= pages) {
this.loadKnowledgeList(newPage);
}
});
});
}
}