Files
TalkingQ_Dashboard/scripts/knowledge-base.js
“yuanmengy” 69785c214f first commit
2026-06-10 17:31:01 +08:00

1145 lines
44 KiB
JavaScript
Raw Permalink 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.

/**
* TalkingQ Platform - 知识库管理页面
* 包含知识库列表、添加知识库弹窗、筛选、分页等功能
*
* API预留:
* - GET /api/v1/knowledge-bases - 获取知识库列表
* - POST /api/v1/knowledge-bases - 创建知识库
* - DELETE /api/v1/knowledge-bases/:id - 删除知识库
* - POST /api/v1/knowledge-bases/:id/rebuild - 重建索引
*/
registerPage('knowledge-base', function(container) {
// ==================== 状态管理 ====================
let currentFilter = 'all';
let searchText = '';
let selectedRows = new Set();
let currentPage = 1;
let pageSize = 10;
let sortField = 'updatedAt';
let sortOrder = 'desc';
let isLoading = true;
let hasStartedInitialLoad = false;
let kbData = [];
// ==================== API预留 - 后端接口定义 ====================
/**
* 获取知识库列表
* GET /api/v1/knowledge-bases
* Query: { page, pageSize, filter, search, sort, order }
* Response: { list: [], total, page, pageSize }
*/
const API_GET_LIST = '/api/v1/knowledge-bases';
/**
* 删除知识库
* DELETE /api/v1/knowledge-bases/:id
* Response: { success: true }
*/
const API_DELETE = '/api/v1/knowledge-bases/:id';
/**
* 重建索引
* POST /api/v1/knowledge-bases/:id/rebuild
* Response: { success: true, taskId: string }
*/
const API_REBUILD = '/api/v1/knowledge-bases/:id/rebuild';
/**
* 批量删除
* DELETE /api/v1/knowledge-bases/batch
* Body: { ids: string[] }
* Response: { success: true, deleted: number }
*/
const API_BATCH_DELETE = '/api/v1/knowledge-bases/batch';
/**
* 导出知识库
* GET /api/v1/knowledge-bases/:id/export
* Response: 文件流
*/
const API_EXPORT = '/api/v1/knowledge-bases/:id/export';
// ==================== 模拟数据 ====================
const mockKbData = [
{ id: 'kb-001', name: '儿童科学百科', description: '包含各类儿童科学知识的百科全书', docs: 2840, size: '128MB', type: 'doc_search', typeName: '文档搜索', status: 'ready', createdAt: '2026-05-01 10:00:00', updatedAt: '2026-05-10 14:32:15', tags: ['儿童', '科学'] },
{ id: 'kb-002', name: '故事素材库 V2', description: '精选各类儿童故事素材', docs: 5200, size: '340MB', type: 'doc_search', typeName: '文档搜索', status: 'ready', createdAt: '2026-04-28 09:00:00', updatedAt: '2026-05-09 09:15:42', tags: ['故事', '儿童'] },
{ id: 'kb-003', name: '小学数学知识点', description: '小学数学知识体系整理', docs: 1560, size: '86MB', type: 'doc_search', typeName: '文档搜索', status: 'processing', createdAt: '2026-05-08 14:00:00', updatedAt: '2026-05-11 16:45:30', tags: ['数学', '教育'] },
{ id: 'kb-004', name: '英语启蒙词典', description: '儿童英语启蒙词汇', docs: 3200, size: '210MB', type: 'image_qa', typeName: '图片问答', status: 'ready', createdAt: '2026-04-20 11:00:00', updatedAt: '2026-05-08 11:20:08', tags: ['英语', '启蒙'] },
{ id: 'kb-005', name: '唐诗宋词精选', description: '经典唐诗宋词收录', docs: 890, size: '45MB', type: 'doc_search', typeName: '文档搜索', status: 'ready', createdAt: '2026-04-15 08:00:00', updatedAt: '2026-05-07 08:30:55', tags: ['诗词', '文学'] },
{ id: 'kb-006', name: '历史人物故事', description: '历史名人故事集', docs: 1200, size: '78MB', type: 'video_search', typeName: '音视频搜索', status: 'error', createdAt: '2026-04-10 16:00:00', updatedAt: '2026-05-06 14:10:22', tags: ['历史', '故事'] },
{ id: 'kb-007', name: '产品参数数据库', description: '公司产品技术参数', docs: 450, size: '32MB', type: 'data_query', typeName: '数据查询', status: 'ready', createdAt: '2026-05-05 10:00:00', updatedAt: '2026-05-11 10:00:00', tags: ['产品', '技术'] },
{ id: 'kb-008', name: '音乐教育资源库', description: '儿童音乐教育资源', docs: 560, size: '65MB', type: 'video_search', typeName: '音视频搜索', status: 'uploading', createdAt: '2026-05-12 10:00:00', updatedAt: '2026-05-12 18:00:00', tags: ['音乐', '教育'] },
];
// 初始化数据。页面刷新后回到 mockSPA 内新建/编辑/删除会保留在全局目录中。
kbData = Array.isArray(window.__knowledgeBaseCatalog) && window.__knowledgeBaseCatalog.length
? window.__knowledgeBaseCatalog.map(item => ({ ...item, tags: [...(item.tags || [])] }))
: mockKbData.map(item => ({ ...item, tags: [...(item.tags || [])] }));
window.__knowledgeBaseCatalog = kbData;
function syncKnowledgeBaseCatalog() {
window.__knowledgeBaseCatalog = kbData;
}
function getKbById(id) {
return kbData.find(kb => kb.id === id);
}
function getNowText() {
const date = new Date();
const pad = value => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function downloadTextFile(filename, content, mime = 'application/json;charset=utf-8') {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ==================== 工具函数 ====================
/**
* 获取状态标签信息
*/
function getStatusBadge(status) {
const map = {
ready: { label: '已就绪', cls: 'ready' },
processing: { label: '处理中', cls: 'processing' },
uploading: { label: '上传中', cls: 'uploading' },
error: { label: '异常', cls: 'error' },
};
return map[status] || { label: status, cls: 'default' };
}
/**
* 获取类型标签信息
*/
function getTypeTag(type) {
const types = {
doc_search: { label: '文档搜索', cls: 'doc_search' },
data_query: { label: '数据查询', cls: 'data_query' },
image_qa: { label: '图片问答', cls: 'image_qa' },
video_search: { label: '音视频搜索', cls: 'video_search' },
};
return types[type] || { label: type, cls: '' };
}
/**
* 获取类型图标
*/
function getTypeIcon(type) {
const icons = {
doc_search: 'file-text',
data_query: 'table',
image_qa: 'image',
video_search: 'video'
};
return icons[type] || 'database';
}
/**
* 格式化时间
*/
function formatTime(timeStr) {
if (!timeStr) return '-';
return timeStr;
}
/**
* HTML转义
*/
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 生成分页按钮HTML
*/
function generatePagination(current, total) {
if (total <= 1) return '';
let html = '';
const maxVisible = 5;
let start = Math.max(1, current - Math.floor(maxVisible / 2));
let end = Math.min(total, start + maxVisible - 1);
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
if (start > 1) {
html += `<button class="kb-pagination-btn" onclick="window.__kbGoPage(1)">1</button>`;
if (start > 2) html += `<span style="padding:0 4px;color:var(--text-muted)">...</span>`;
}
for (let i = start; i <= end; i++) {
html += `<button class="kb-pagination-btn ${i === current ? 'active' : ''}" onclick="window.__kbGoPage(${i})">${i}</button>`;
}
if (end < total) {
if (end < total - 1) html += `<span style="padding:0 4px;color:var(--text-muted)">...</span>`;
html += `<button class="kb-pagination-btn" onclick="window.__kbGoPage(${total})">${total}</button>`;
}
return html;
}
// ==================== 数据处理 ====================
/**
* 筛选数据
*/
function getFilteredData() {
let filtered = [...kbData];
// 状态筛选
if (currentFilter !== 'all') {
filtered = filtered.filter(kb => kb.status === currentFilter);
}
// 搜索筛选
if (searchText) {
const s = searchText.toLowerCase();
filtered = filtered.filter(kb =>
kb.name.toLowerCase().includes(s) ||
kb.description.toLowerCase().includes(s) ||
kb.id.toLowerCase().includes(s) ||
kb.tags.some(t => t.toLowerCase().includes(s))
);
}
// 排序
filtered.sort((a, b) => {
let valA = a[sortField];
let valB = b[sortField];
if (sortField === 'name' || sortField === 'typeName') {
return sortOrder === 'asc' ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
if (sortField === 'createdAt' || sortField === 'updatedAt') {
valA = Date.parse(valA || '') || 0;
valB = Date.parse(valB || '') || 0;
} else {
valA = Number(valA) || 0;
valB = Number(valB) || 0;
}
return sortOrder === 'asc' ? valA - valB : valB - valA;
});
return filtered;
}
// ==================== 渲染函数 ====================
/**
* 渲染页面
*/
function renderPage() {
const filteredData = getFilteredData();
const total = filteredData.length;
const totalPages = Math.ceil(total / pageSize) || 1;
const displayData = filteredData.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// 统计
const readyCount = kbData.filter(k => k.status === 'ready').length;
const processingCount = kbData.filter(k => k.status === 'processing' || k.status === 'uploading').length;
const errorCount = kbData.filter(k => k.status === 'error').length;
container.innerHTML = `
<div class="page-container" style="padding:0">
<!-- 批量操作栏 -->
<div class="kb-batch-bar ${selectedRows.size > 0 ? 'visible' : ''}" id="batchBar">
<div class="kb-batch-info">
已选择 <strong>${selectedRows.size}</strong> 项
</div>
<div class="kb-batch-actions">
<button class="btn btn-sm btn-outline" onclick="window.__kbBatchExport()">
<i data-lucide="download" style="width:14px;height:14px"></i> 导出
</button>
<button class="btn btn-sm btn-danger" onclick="window.__kbBatchDelete()">
<i data-lucide="trash-2" style="width:14px;height:14px"></i> 删除
</button>
<button class="btn btn-sm btn-ghost" onclick="window.__kbClearSelection()">
取消选择
</button>
</div>
</div>
<!-- 页面头部 -->
<div class="kb-list-header">
<div class="kb-list-title">
<button class="page-back-btn" data-tooltip="返回应用管理" onclick="navigateTo('agent-manage')">
<i data-lucide="arrow-left" style="width:16px;height:16px"></i>
</button>
<div>
<h1 class="page-title">知识库管理</h1>
<p class="page-desc">管理和配置您的知识库,用于 AI 检索和问答</p>
</div>
</div>
<div class="kb-list-actions">
<button class="btn btn-primary" id="addKbBtn">
<i data-lucide="plus"></i> 创建知识库
</button>
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar" style="justify-content:space-between">
<div class="filter-tabs">
<div class="filter-tab ${currentFilter === 'all' ? 'active' : ''}" data-filter="all" onclick="window.__kbFilter('all')">
全部 <span class="filter-count">${kbData.length}</span>
</div>
<div class="filter-tab ${currentFilter === 'ready' ? 'active' : ''}" data-filter="ready" onclick="window.__kbFilter('ready')">
已就绪 <span class="filter-count">${readyCount}</span>
</div>
<div class="filter-tab ${currentFilter === 'processing' ? 'active' : ''}" data-filter="processing" onclick="window.__kbFilter('processing')">
处理中 <span class="filter-count">${processingCount}</span>
</div>
<div class="filter-tab ${currentFilter === 'error' ? 'active' : ''}" data-filter="error" onclick="window.__kbFilter('error')">
异常 <span class="filter-count">${errorCount}</span>
</div>
</div>
<div class="filter-search">
<i data-lucide="search" style="width:15px;height:15px"></i>
<input type="text" id="kbSearchInput" placeholder="搜索知识库名称、描述、标签或ID..." value="${escapeHtml(searchText)}">
</div>
</div>
<!-- 加载状态 -->
${isLoading ? `
<div class="kb-loading">
<div class="kb-loading-spinner"></div>
<span>加载中...</span>
</div>
` : ''}
<!-- Skeleton加载状态 -->
${isLoading ? renderSkeleton() : ''}
<!-- 表格 -->
${!isLoading ? `
<div class="kb-table-wrapper">
<table class="kb-table">
<thead>
<tr>
<th style="width:40px">
<input type="checkbox" class="row-checkbox" id="selectAllKb" onchange="window.__kbSelectAll(this.checked)">
</th>
<th class="sortable ${sortField === 'name' ? 'sorted' : ''}" onclick="window.__kbSort('name')">
知识库名称
<i data-lucide="chevrons-up-down" class="sort-icon" style="width:12px;height:12px"></i>
</th>
<th class="sortable ${sortField === 'typeName' ? 'sorted' : ''}" onclick="window.__kbSort('typeName')">
类型
<i data-lucide="chevrons-up-down" class="sort-icon" style="width:12px;height:12px"></i>
</th>
<th class="sortable ${sortField === 'docs' ? 'sorted' : ''}" onclick="window.__kbSort('docs')">
数据量
<i data-lucide="chevrons-up-down" class="sort-icon" style="width:12px;height:12px"></i>
</th>
<th>状态</th>
<th class="sortable ${sortField === 'createdAt' ? 'sorted' : ''}" onclick="window.__kbSort('createdAt')">
创建时间
<i data-lucide="chevrons-up-down" class="sort-icon" style="width:12px;height:12px"></i>
</th>
<th class="sortable ${sortField === 'updatedAt' ? 'sorted' : ''}" onclick="window.__kbSort('updatedAt')">
更新时间
<i data-lucide="chevrons-up-down" class="sort-icon" style="width:12px;height:12px"></i>
</th>
<th style="width:220px">操作</th>
</tr>
</thead>
<tbody>
${displayData.length === 0 ? `
<tr>
<td colspan="8">
${renderEmptyState()}
</td>
</tr>
` : displayData.map(kb => renderTableRow(kb)).join('')}
</tbody>
</table>
<!-- 分页 -->
${total > 0 ? `
<div class="kb-pagination">
<div class="kb-pagination-info">
共 <strong>${total}</strong> 条记录,每页 ${pageSize}
</div>
<div class="kb-pagination-controls">
<button class="kb-pagination-btn" onclick="window.__kbGoPage(${currentPage > 1 ? currentPage - 1 : 1})" ${currentPage === 1 ? 'disabled' : ''}>
<i data-lucide="chevron-left" style="width:14px;height:14px"></i>
</button>
${generatePagination(currentPage, totalPages)}
<button class="kb-pagination-btn" onclick="window.__kbGoPage(${currentPage < totalPages ? currentPage + 1 : totalPages})" ${currentPage === totalPages ? 'disabled' : ''}>
<i data-lucide="chevron-right" style="width:14px;height:14px"></i>
</button>
</div>
</div>
` : ''}
</div>
` : ''}
</div>
`;
lucide.createIcons({ elements: [container] });
bindEvents();
}
/**
* 渲染表格行
*/
function renderTableRow(kb) {
return `
<tr class="${selectedRows.has(kb.id) ? 'selected' : ''}" data-id="${kb.id}">
<td>
<input type="checkbox" class="row-checkbox" ${selectedRows.has(kb.id) ? 'checked' : ''}
onchange="window.__kbToggleSelect('${kb.id}', this.checked)">
</td>
<td>
<div class="kb-name-cell">
<div class="kb-name-icon">
<i data-lucide="${getTypeIcon(kb.type)}" style="width:18px;height:18px"></i>
</div>
<div>
<div class="kb-name-text">${escapeHtml(kb.name)}</div>
<div class="kb-name-desc">${escapeHtml(kb.description) || '暂无描述'}</div>
</div>
</div>
</td>
<td>
<span class="kb-type-tag ${getTypeTag(kb.type).cls}">
${getTypeTag(kb.type).label}
</span>
</td>
<td>
<div style="display:flex;flex-direction:column;gap:2px">
<span>${kb.docs.toLocaleString()} 文档</span>
<span style="font-size:12px;color:var(--text-muted)">${kb.size}</span>
</div>
</td>
<td>
<span class="kb-status-badge ${getStatusBadge(kb.status).cls}">
<span class="status-dot"></span>
${getStatusBadge(kb.status).label}
</span>
</td>
<td style="font-size:13px;color:var(--text-secondary)">${formatTime(kb.createdAt)}</td>
<td style="font-size:13px;color:var(--text-secondary)">${formatTime(kb.updatedAt)}</td>
<td>
<div class="kb-table-actions">
<button class="btn btn-ghost btn-sm" data-tooltip="查看详情" onclick="window.__kbDetail('${kb.id}')">
<i data-lucide="eye" style="width:14px;height:14px"></i>
</button>
<button class="btn btn-ghost btn-sm" data-tooltip="命中测试" onclick="window.__kbHitTest('${kb.id}')">
<i data-lucide="search" style="width:14px;height:14px"></i>
</button>
<button class="btn btn-ghost btn-sm" data-tooltip="编辑" onclick="window.__kbEdit('${kb.id}')">
<i data-lucide="settings" style="width:14px;height:14px"></i>
</button>
<div class="dropdown" id="dropdown-${kb.id}">
<button class="btn btn-ghost btn-sm" data-tooltip="更多操作" onclick="window.__kbToggleDropdown('${kb.id}', this, event)">
<i data-lucide="more-horizontal" style="width:14px;height:14px"></i>
</button>
<div class="dropdown-menu" style="display:none" onclick="event.stopPropagation()">
<div class="dropdown-item" onclick="event.stopPropagation();window.__kbRebuildIndex('${kb.id}')">
<i data-lucide="refresh-cw" style="width:14px;height:14px"></i> 重建索引
</div>
<div class="dropdown-item" onclick="event.stopPropagation();window.__kbExport('${kb.id}')">
<i data-lucide="download" style="width:14px;height:14px"></i> 导出
</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item danger" onclick="event.stopPropagation();window.__kbDelete('${kb.id}')">
<i data-lucide="trash-2" style="width:14px;height:14px"></i> 删除
</div>
</div>
</div>
</div>
</td>
</tr>
`;
}
/**
* 渲染空状态
*/
function renderEmptyState() {
return `
<div class="kb-empty">
<div class="kb-empty-icon">
<i data-lucide="database" style="width:36px;height:36px"></i>
</div>
<div class="kb-empty-title">暂无知识库</div>
<div class="kb-empty-desc">点击上方「添加知识库」开始创建您的第一个知识库</div>
<button class="btn btn-primary" onclick="navigateTo('kb-create')">
<i data-lucide="plus" style="width:14px;height:14px"></i> 创建知识库
</button>
</div>
`;
}
/**
* 渲染Skeleton加载状态
*/
function renderSkeleton() {
return `
<div class="kb-table-wrapper" style="padding:20px">
${[1,2,3,4,5].map(() => `
<div style="display:flex;align-items:center;gap:16px;padding:16px 0;border-bottom:1px solid var(--border)">
<div class="skeleton skeleton-avatar" style="width:18px;height:18px"></div>
<div style="flex:1">
<div class="skeleton skeleton-text" style="width:200px"></div>
<div class="skeleton skeleton-text" style="width:120px"></div>
</div>
<div class="skeleton skeleton-text" style="width:60px"></div>
<div class="skeleton skeleton-text" style="width:80px"></div>
</div>
`).join('')}
</div>
`;
}
// ==================== 事件绑定 ====================
function bindEvents() {
// 添加知识库
container.querySelector('#addKbBtn')?.addEventListener('click', () => navigateTo('kb-create'));
// 搜索
container.querySelector('#kbSearchInput')?.addEventListener('input', debounce((e) => {
searchText = e.target.value;
currentPage = 1;
selectedRows.clear();
renderPage();
}, 300));
// 模拟加载,只在首次进入页面时触发一次,避免重渲染打断下拉菜单等交互。
if (isLoading && !hasStartedInitialLoad) {
hasStartedInitialLoad = true;
setTimeout(() => {
isLoading = false;
renderPage();
}, 500);
}
}
/**
* 防抖函数
*/
function debounce(fn, delay) {
let timer = null;
return function(...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// ==================== 添加知识库弹窗 ====================
/**
* 显示创建知识库弹窗
*/
function showCreateKbModal() {
let searchValue = '';
let selectedType = 'all';
let selectedStatus = 'all';
let filteredKbData = [...kbData];
let selectedForAdd = new Set();
const renderKbList = () => {
// 应用筛选
filteredKbData = kbData.filter(kb => {
if (selectedType !== 'all' && kb.type !== selectedType) return false;
if (selectedStatus !== 'all' && kb.status !== selectedStatus) return false;
if (searchValue) {
const matchName = kb.name.toLowerCase().includes(searchValue.toLowerCase());
const matchTags = kb.tags.some(t => t.toLowerCase().includes(searchValue.toLowerCase()));
const matchDesc = kb.description?.toLowerCase().includes(searchValue.toLowerCase());
if (!matchName && !matchTags && !matchDesc) return false;
}
return true;
});
const modalBody = document.querySelector('.kb-select-list-area');
if (!modalBody) return;
if (filteredKbData.length === 0) {
modalBody.innerHTML = `
<div class="empty-state-illustration">
<div class="empty-state-icon">
<i data-lucide="search" style="width:32px;height:32px"></i>
</div>
<div class="empty-state-title">未找到匹配的知识库</div>
<div class="empty-state-desc">尝试其他搜索关键词或筛选条件</div>
</div>
`;
} else {
modalBody.innerHTML = filteredKbData.map(kb => `
<div class="kb-select-item" data-id="${kb.id}">
<div class="kb-select-icon">
<i data-lucide="${getTypeIcon(kb.type)}" style="width:20px;height:20px"></i>
</div>
<div class="kb-select-info">
<div class="kb-select-name">
${escapeHtml(kb.name)}
${selectedForAdd.has(kb.id) ? '<span style="color:var(--primary);font-size:12px">✓ 已选择</span>' : ''}
</div>
<div class="kb-select-meta">
<span>${kb.docs.toLocaleString()} 文档</span>
<span>·</span>
<span>${kb.size}</span>
<span>·</span>
<span class="kb-type-tag ${getTypeTag(kb.type).cls}" style="padding:1px 6px;font-size:10px">${getTypeTag(kb.type).label}</span>
<span>·</span>
<span class="kb-status-badge ${getStatusBadge(kb.status).cls}" style="padding:1px 6px;font-size:10px">
<span class="status-dot" style="width:5px;height:5px"></span>
${getStatusBadge(kb.status).label}
</span>
</div>
<div class="kb-select-tags">
${kb.tags.map(t => `<span class="tag" style="font-size:10px;padding:1px 6px">${t}</span>`).join('')}
</div>
</div>
<div class="kb-select-action">
<button class="btn btn-primary btn-sm" onclick="event.stopPropagation();window.__toggleKbSelect('${kb.id}','${escapeHtml(kb.name)}')"
${kb.status === 'processing' || kb.status === 'uploading' ? 'disabled' : ''}>
${selectedForAdd.has(kb.id) ? '取消' : (kb.status === 'processing' || kb.status === 'uploading' ? '处理中' : '选择')}
</button>
</div>
</div>
`).join('');
}
// 更新统计
const summary = document.querySelector('.kb-select-info-summary');
if (summary) {
summary.textContent = `${filteredKbData.length} 个知识库${selectedForAdd.size > 0 ? `,已选择 ${selectedForAdd.size}` : ''}`;
}
lucide.createIcons({ elements: [modalBody] });
};
const modalOverlay = document.createElement('div');
modalOverlay.className = 'modal-overlay';
modalOverlay.innerHTML = `
<div class="modal kb-select-modal">
<div class="modal-header">
<span class="modal-title">添加知识库</span>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body kb-select-modal-wrapper">
<div class="kb-select-modal-header">
<div class="kb-select-modal-filters">
<div class="filter-search" style="max-width:280px">
<i data-lucide="search" style="width:15px;height:15px"></i>
<input type="text" id="kbSelectSearch" placeholder="搜索知识库名称或标签...">
</div>
<div class="kb-select-type-filter" id="typeFilter">
<button class="kb-select-type-btn active" data-type="all">全部类型</button>
<button class="kb-select-type-btn" data-type="doc_search">文档搜索</button>
<button class="kb-select-type-btn" data-type="data_query">数据查询</button>
<button class="kb-select-type-btn" data-type="image_qa">图片问答</button>
<button class="kb-select-type-btn" data-type="video_search">音视频搜索</button>
</div>
<div class="kb-select-type-filter" id="statusFilter" style="margin-top:8px">
<button class="kb-select-type-btn active" data-status="all">全部状态</button>
<button class="kb-select-type-btn" data-status="ready">已就绪</button>
<button class="kb-select-type-btn" data-status="processing">处理中</button>
<button class="kb-select-type-btn" data-status="error">异常</button>
</div>
</div>
</div>
<div class="kb-select-list-area"></div>
<div class="kb-select-modal-footer">
<div class="kb-select-info-summary">共 ${kbData.length} 个知识库</div>
<div class="kb-create-link-btn" onclick="window.__goToCreateKb()">
<i data-lucide="plus" style="width:14px;height:14px"></i>
创建新知识库
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="window.__closeAddKbModal()">取消</button>
<button class="btn btn-primary" id="confirmAddKbBtn" onclick="window.__confirmAddKb()" disabled>
确认添加 <span id="selectedCountBadge">${selectedForAdd.size > 0 ? `(${selectedForAdd.size})` : ''}</span>
</button>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
document.body.classList.add('modal-open');
lucide.createIcons({ elements: [modalOverlay] });
const close = () => {
modalOverlay.classList.remove('open');
setTimeout(() => {
modalOverlay.remove();
if (!document.querySelector('.modal-overlay')) {
document.body.classList.remove('modal-open');
}
}, 200);
};
// 关闭事件
modalOverlay.querySelector('.modal-close').addEventListener('click', close);
modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) close(); });
// 绑定弹窗内事件
setTimeout(() => {
// 搜索
const searchInput = document.getElementById('kbSelectSearch');
searchInput?.addEventListener('input', (e) => {
searchValue = e.target.value;
renderKbList();
});
// 类型筛选
document.querySelectorAll('#typeFilter .kb-select-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#typeFilter .kb-select-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedType = btn.dataset.type;
renderKbList();
});
});
// 状态筛选
document.querySelectorAll('#statusFilter .kb-select-type-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#statusFilter .kb-select-type-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
selectedStatus = btn.dataset.status;
renderKbList();
});
});
renderKbList();
}, 100);
// 全局函数
window.__closeAddKbModal = close;
window.__toggleKbSelect = function(id, name) {
if (selectedForAdd.has(id)) {
selectedForAdd.delete(id);
} else {
selectedForAdd.add(id);
}
// 更新确认按钮状态
const confirmBtn = document.getElementById('confirmAddKbBtn');
const countBadge = document.getElementById('selectedCountBadge');
if (confirmBtn) {
confirmBtn.disabled = selectedForAdd.size === 0;
}
if (countBadge) {
countBadge.textContent = selectedForAdd.size > 0 ? `(${selectedForAdd.size})` : '';
}
renderKbList();
};
window.__confirmAddKb = function() {
if (selectedForAdd.size === 0) {
Toast.warning('请先选择知识库');
return;
}
const selectedNames = Array.from(selectedForAdd).map(id => {
const kb = kbData.find(k => k.id === id);
return kb ? kb.name : id;
});
Toast.success(`已添加 ${selectedForAdd.size} 个知识库到当前应用`);
close();
};
window.__goToCreateKb = function() {
close();
navigateTo('kb-create');
};
requestAnimationFrame(() => modalOverlay.classList.add('open'));
}
// ==================== 全局操作函数 ====================
// 筛选
window.__kbFilter = function(filter) {
currentFilter = filter;
currentPage = 1;
selectedRows.clear();
renderPage();
};
// 排序
window.__kbSort = function(field) {
if (sortField === field) {
sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
} else {
sortField = field;
sortOrder = 'asc';
}
renderPage();
};
// 分页
window.__kbGoPage = function(page) {
const totalPages = Math.ceil(getFilteredData().length / pageSize) || 1;
if (page >= 1 && page <= totalPages) {
currentPage = page;
renderPage();
}
};
// 选择
window.__kbToggleSelect = function(id, checked) {
if (checked) {
selectedRows.add(id);
} else {
selectedRows.delete(id);
}
renderPage();
};
window.__kbSelectAll = function(checked) {
const displayData = getFilteredData().slice((currentPage - 1) * pageSize, currentPage * pageSize);
if (checked) {
displayData.forEach(kb => selectedRows.add(kb.id));
} else {
selectedRows.clear();
}
renderPage();
};
window.__kbClearSelection = function() {
selectedRows.clear();
renderPage();
};
// 下拉菜单
let __kbCloseDropdownHandler = null;
window.__kbToggleDropdown = function(id, btn, e) {
if (e) e.stopPropagation();
const dropdown = btn.closest('.dropdown');
const menu = dropdown.querySelector('.dropdown-menu');
const isOpen = dropdown.classList.contains('open');
// 关闭所有下拉菜单
document.querySelectorAll('.dropdown').forEach(d => {
d.classList.remove('open');
d.querySelector('.dropdown-menu').style.display = 'none';
});
document.removeEventListener('click', __kbCloseDropdownHandler);
document.removeEventListener('mousedown', __kbCloseDropdownHandler);
if (!isOpen) {
dropdown.classList.add('open');
menu.style.display = 'block';
__kbCloseDropdownHandler = (ev) => {
if (!dropdown.contains(ev.target)) {
dropdown.classList.remove('open');
menu.style.display = 'none';
document.removeEventListener('mousedown', __kbCloseDropdownHandler);
}
};
setTimeout(() => {
document.addEventListener('mousedown', __kbCloseDropdownHandler);
}, 10);
}
};
function openEditKbModal(id) {
const kb = getKbById(id);
if (!kb) {
Toast.warning('未找到该知识库');
return;
}
createModal({
title: '编辑知识库',
size: 'lg',
content: `
<div class="form-group">
<label class="form-label">知识库名称 <span style="color:var(--danger)">*</span></label>
<input class="form-control" id="kbEditName" maxlength="64" value="${escapeHtml(kb.name)}" placeholder="请输入知识库名称">
</div>
<div class="form-group">
<label class="form-label">知识库描述</label>
<textarea class="form-control" id="kbEditDesc" rows="3" maxlength="500" placeholder="请输入知识库描述">${escapeHtml(kb.description || '')}</textarea>
</div>
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
<div class="form-group">
<label class="form-label">知识库类型</label>
<select class="form-control" id="kbEditType">
<option value="doc_search" ${kb.type === 'doc_search' ? 'selected' : ''}>文档搜索</option>
<option value="data_query" ${kb.type === 'data_query' ? 'selected' : ''}>数据查询</option>
<option value="image_qa" ${kb.type === 'image_qa' ? 'selected' : ''}>图片问答</option>
<option value="video_search" ${kb.type === 'video_search' ? 'selected' : ''}>音视频搜索</option>
</select>
</div>
<div class="form-group">
<label class="form-label">状态</label>
<select class="form-control" id="kbEditStatus">
<option value="ready" ${kb.status === 'ready' ? 'selected' : ''}>已就绪</option>
<option value="processing" ${kb.status === 'processing' ? 'selected' : ''}>处理中</option>
<option value="uploading" ${kb.status === 'uploading' ? 'selected' : ''}>上传中</option>
<option value="error" ${kb.status === 'error' ? 'selected' : ''}>异常</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">标签</label>
<input class="form-control" id="kbEditTags" value="${escapeHtml((kb.tags || []).join(', '))}" placeholder="多个标签用逗号分隔">
</div>
<div class="form-hint">
<i data-lucide="info" style="width:12px;height:12px;vertical-align:middle"></i>
前端 mock 会立即更新当前列表;后端接入后调用 PUT /api/v1/knowledge-bases/:id。
</div>
`,
confirmText: '保存',
onConfirm: () => {
const name = document.getElementById('kbEditName')?.value.trim();
if (!name) {
Toast.warning('请输入知识库名称');
return false;
}
const type = document.getElementById('kbEditType')?.value || kb.type;
const tags = (document.getElementById('kbEditTags')?.value || '')
.split(/[,]/)
.map(tag => tag.trim())
.filter(Boolean);
kb.name = name;
kb.description = document.getElementById('kbEditDesc')?.value.trim() || '';
kb.type = type;
kb.typeName = getTypeTag(type).label;
kb.status = document.getElementById('kbEditStatus')?.value || kb.status;
kb.tags = tags;
kb.updatedAt = getNowText();
syncKnowledgeBaseCatalog();
renderPage();
Toast.success('知识库信息已更新');
}
});
lucide.createIcons();
}
function exportKnowledgeBases(ids) {
const items = ids.map(id => getKbById(id)).filter(Boolean);
if (!items.length) {
Toast.warning('请先选择要导出的知识库');
return;
}
createModal({
title: items.length > 1 ? '批量导出知识库' : '导出知识库',
content: `
<div class="form-group">
<label class="form-label">导出范围</label>
<div style="padding:10px 12px;background:var(--bg-base);border-radius:var(--radius);font-size:13px;line-height:1.6">
${items.map(item => escapeHtml(item.name)).join('、')}
</div>
</div>
<div class="form-group">
<label class="form-label">导出格式</label>
<select class="form-control" id="kbExportFormat">
<option value="json">JSON 配置数据</option>
<option value="csv">CSV 列表数据</option>
</select>
</div>
<label class="checkbox-label">
<input type="checkbox" id="kbExportIncludeStats" checked>
<span>包含文档数量、数据量、状态和标签</span>
</label>
<div class="form-hint">
浏览器会下载到默认下载目录;后端接入后替换为 GET /api/v1/knowledge-bases/:id/export。
</div>
`,
confirmText: '确认导出',
onConfirm: () => {
const format = document.getElementById('kbExportFormat')?.value || 'json';
const includeStats = document.getElementById('kbExportIncludeStats')?.checked !== false;
const exportRows = items.map(item => ({
id: item.id,
name: item.name,
description: item.description,
type: item.type,
typeName: item.typeName,
...(includeStats ? {
docs: item.docs,
size: item.size,
status: item.status,
tags: item.tags,
} : {}),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
}));
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
if (format === 'csv') {
const headers = Object.keys(exportRows[0]);
const csv = [
headers.join(','),
...exportRows.map(row => headers.map(key => `"${String(Array.isArray(row[key]) ? row[key].join('|') : row[key] ?? '').replace(/"/g, '""')}"`).join(',')),
].join('\n');
downloadTextFile(`knowledge-bases-${stamp}.csv`, csv, 'text/csv;charset=utf-8');
} else {
downloadTextFile(`knowledge-bases-${stamp}.json`, JSON.stringify(exportRows, null, 2));
}
Toast.success(`已导出 ${items.length} 个知识库`);
}
});
}
// 操作
window.__kbDetail = function(id) {
navigateTo('kb-detail?id=' + id);
};
window.__kbHitTest = function(id) {
navigateTo('kb-detail?id=' + id + '&tab=hittest');
};
window.__kbEdit = function(id) {
openEditKbModal(id);
};
/**
* 删除知识库
* API: DELETE /api/v1/knowledge-bases/:id
*/
window.__kbDelete = function(id) {
const kb = kbData.find(k => k.id === id);
createModal({
title: '删除知识库',
content: `
<p>确定要删除知识库「${kb?.name}」吗?</p>
<div class="parse-error-tip" style="margin-top:12px">
<i data-lucide="alert-triangle" style="width:16px;height:16px;flex-shrink:0"></i>
<div>
<strong>此操作不可恢复</strong>
<p style="margin-top:4px;font-size:12px">关联应用将无法继续使用该知识库,所有索引数据将被永久删除。</p>
</div>
</div>
`,
confirmText: '删除',
confirmClass: 'btn-danger',
onConfirm: () => {
// TODO: 调用 API DELETE /api/v1/knowledge-bases/:id
const idx = kbData.findIndex(k => k.id === id);
if (idx !== -1) kbData.splice(idx, 1);
selectedRows.delete(id);
syncKnowledgeBaseCatalog();
renderPage();
Toast.success('知识库已删除');
}
});
lucide.createIcons();
};
/**
* 重建索引
* API: POST /api/v1/knowledge-bases/:id/rebuild
*/
window.__kbRebuildIndex = function(id) {
const kb = kbData.find(k => k.id === id);
createModal({
title: '重建索引',
content: `
<p>确定要重建知识库「${kb?.name}」的索引吗?</p>
<p style="color:var(--text-muted);font-size:13px;margin-top:8px">
重建过程中知识库可正常访问,预计需要几分钟时间。
</p>
`,
confirmText: '重建',
onConfirm: () => {
// TODO: 调用 API POST /api/v1/knowledge-bases/:id/rebuild
if (kb) {
kb.status = 'processing';
kb.updatedAt = getNowText();
syncKnowledgeBaseCatalog();
renderPage();
}
Toast.success('索引重建任务已启动');
}
});
};
/**
* 导出知识库
* API: GET /api/v1/knowledge-bases/:id/export
*/
window.__kbExport = function(id) {
exportKnowledgeBases([id]);
};
/**
* 批量导出
* API: GET /api/v1/knowledge-bases/batch/export
*/
window.__kbBatchExport = function() {
exportKnowledgeBases(Array.from(selectedRows));
};
/**
* 批量删除
* API: DELETE /api/v1/knowledge-bases/batch
*/
window.__kbBatchDelete = function() {
if (selectedRows.size === 0) {
Toast.warning('请先选择要删除的知识库');
return;
}
const deleteCount = selectedRows.size;
createModal({
title: '批量删除',
content: `
<p>确定要删除选中的 <strong>${selectedRows.size}</strong> 个知识库吗?</p>
<div class="parse-error-tip" style="margin-top:12px">
<i data-lucide="alert-triangle" style="width:16px;height:16px;flex-shrink:0"></i>
<div>
<strong>此操作不可恢复</strong>
<p style="margin-top:4px;font-size:12px">所有选中的知识库及其索引数据将被永久删除。</p>
</div>
</div>
`,
confirmText: '删除',
confirmClass: 'btn-danger',
onConfirm: () => {
// TODO: 调用 API DELETE /api/v1/knowledge-bases/batch
selectedRows.forEach(id => {
const idx = kbData.findIndex(k => k.id === id);
if (idx !== -1) kbData.splice(idx, 1);
});
selectedRows.clear();
syncKnowledgeBaseCatalog();
renderPage();
Toast.success(`已删除 ${deleteCount} 个知识库`);
}
});
};
// 导出弹窗函数
window.__showCreateKbModal = function() {
navigateTo('kb-create');
};
// ==================== 初始化 ====================
renderPage();
});