2025-12-08 00:53:23 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 知识库页面组件
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
export default class Knowledge {
|
|
|
|
|
|
constructor(container, route) {
|
|
|
|
|
|
this.container = container;
|
|
|
|
|
|
this.route = route;
|
2026-02-11 22:53:08 +08:00
|
|
|
|
this.currentPage = 1; // 初始化当前页码
|
2025-12-08 00:53:23 +08:00
|
|
|
|
this.init();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async init() {
|
|
|
|
|
|
this.render();
|
2026-02-11 22:53:08 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-12-08 00:53:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
render() {
|
|
|
|
|
|
this.container.innerHTML = `
|
2026-02-11 22:53:08 +08:00
|
|
|
|
<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>
|
2025-12-08 00:53:23 +08:00
|
|
|
|
</div>
|
2026-02-11 22:53:08 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- 统计卡片 -->
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-12-08 00:53:23 +08:00
|
|
|
|
<div class="card">
|
2026-02-11 22:53:08 +08:00
|
|
|
|
<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>
|
2025-12-08 00:53:23 +08:00
|
|
|
|
<div class="card-body">
|
2026-02-11 22:53:08 +08:00
|
|
|
|
<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">
|
|
|
|
|
|
<!-- 分页控件 -->
|
2025-12-08 00:53:23 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}
|
2026-02-11 22:53:08 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-12-08 00:53:23 +08:00
|
|
|
|
}
|