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

673 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 知识库页面组件
*/
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);
}
});
});
}
}